Skip to content
2 changes: 1 addition & 1 deletion .changeset/23415-drop-storage-other-rows.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
---
'@objectstack/metadata-protocol': minor
---
Expand All @@ -14,7 +14,7 @@

**FROM.** `DELETE /api/v1/meta/object/my_object?package=com.acme.a&dropStorage=true` (`deleteMetaItem({ type: 'object', name, packageId, dropStorage: true })` in process), of an object a second package `com.acme.b` also holds, removed `com.acme.a`'s row, answered `200` with a receipt saying `com.acme.b`'s row remains, and dropped the object's table. The object stayed defined and served by `com.acme.b`'s row while its records were gone: its next data read answered `500`. The same held for a package-less row left beside the deleted package's row, and for an uninstall (`deletePackage`, which deletes each of the package's rows with `dropStorage` unless `keepData` is set).

**TO.** Such a delete is refused `409 RESOURCE_CONFLICT` before anything is written: both rows, the table and its records stay. The message names the rows that remain and the way out. An uninstall reports the refusal for that object in its `failed[]` list, as it reports any per-item refusal, and leaves the object's row and table in place.
**TO.** Such a delete is refused `409 RESOURCE_CONFLICT` before anything is written: both rows, the table and its records stay. The message names the rows that remain and the way out. An uninstall of a package holding such an object, without `keepData`, is refused whole, `409 RESOURCE_CONFLICT`, before anything is removed; its message names the object, the rows that keep it, and the ways out.

**Unchanged.** The delete of an object's last stored row with `dropStorage` still drops its table. A delete without `dropStorage` still removes the named package's row and keeps the table, and its receipt names the rows that remain. Only `active` rows count: a draft is never materialised, so a remaining draft does not keep the table.

Expand Down
21 changes: 21 additions & 0 deletions .changeset/23473-uninstall-shared-object-refusal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
---
'@objectstack/metadata-protocol': minor
---

fix(metadata-protocol)!: an uninstall without `keepData` of a package holding an object another stored row also holds is refused `409 RESOURCE_CONFLICT` before anything is removed, instead of dropping the object's table while the other row still serves it (ADR-0048)

Clause-②: no (narrowing)

<!-- adr-0087: not-required (no-migration-prescription) No metadata moves: no authorable spelling, export or stored shape is removed, renamed or re-shaped, and no stored row is rewritten, converted or deleted, so there is nothing for `objectstack migrate meta` to rewrite. What narrows is one runtime door: an uninstall that would drop an object's table while another stored row of the object stays is refused before anything durable; the remedy is in the request a caller sends (`keepData`, or delete a row of the object first), never a rewrite of a package source or a stored row. The other categories are closed on facts: the package publishes (not unpublished); no ADR-0087 id is named or touched (not registered or already-registered); and no exported declaration is removed or narrowed (not runtime-interface-only, type-surface-only or runtime-export-retired). -->

**BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes.

A `sys_metadata` row is keyed by its package (ADR-0048), so one object can hold one row per package, and a package-less row beside them, all served over the one table its name maps to. An uninstall drops each of its objects' tables unless `keepData` is set, and a table is dropped only with the object's last stored row.

**FROM.** `DELETE /api/v1/packages/com.acme.a` (`deletePackage({ packageId: 'com.acme.a' })` in process), for a package whose object a second package `com.acme.b` (or the package-less row) also holds, removed the package and its rows and dropped the shared object's table along with the package's row of it. The object stayed defined and served by `com.acme.b`'s row while its records were gone: its next data read answered `500`.

**TO.** Such an uninstall is refused `409 RESOURCE_CONFLICT` before its first durable step: the `sys_packages` row, the registry entry, every row, the tables and their records all stay. The message names each shared object and the rows that keep it, and the ways out.

**Unchanged.** An uninstall with `keepData` drops no table, so a shared object does not refuse it. An uninstall whose objects no other row holds still drops their tables.

**Remedy.** Uninstall with `?keepData=true` (`keepData: true` in process) to keep every table of the package; or first delete the package's own row of each shared object without `dropStorage` (`DELETE /api/v1/meta/object/NAME?package=com.acme.a`), which keeps that table for the other rows, then uninstall again to drop the tables the package alone held.
70 changes: 70 additions & 0 deletions packages/metadata-protocol/src/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25889,6 +25889,9 @@ export class ObjectStackProtocolImplementation implements
* 3. the registry's uninstall refusal (another package extends an object
* this one owns, ADR-0029), asked through
* `SchemaRegistry.assertPackageUninstallable` — pure; thrown as is;
* then [#23473] the shared-object refusal (another stored row holds an
* object whose table this uninstall would drop) — reads only, the
* per-item delete's own address reads; `RESOURCE_CONFLICT` / 409;
* 4. the `sys_packages` delete through the `package` service — the FIRST
* durable step; a refusal, returned or thrown, is thrown as this verb's
* failure (see {@link packagePersistFailureError});
Expand Down Expand Up @@ -25978,6 +25981,73 @@ export class ObjectStackProtocolImplementation implements
packageRegistry.assertPackageUninstallable(request.packageId);
}

// [#23473, ADR-0048] THE SHARED-OBJECT REFUSAL, ALSO ASKED BEFORE THE
// STORE DELETE. A `sys_metadata` row is keyed by its package, so an
// object this package holds can also be held by another package's row,
// or by the package-less row, all served over the ONE table its name
// maps to. The per-item delete below drops an object's table only with
// its last stored row and otherwise refuses `409`
// ({@link dropStorageRemainingRowsRefusal}). Met there, that refusal
// came after the stored package row was gone, so it left the package
// half-uninstalled: no `sys_packages` row, and its row of the shared
// object still stored and served.
//
// So the same question is asked here, of the same rows, before anything
// durable: for each active object row whose table this uninstall would
// drop ({@link shouldDropStorage}), the per-item delete's own address
// read ({@link packagelessAddress}, the type folded as that delete
// folds it). An object this package holds there beside any other row
// refuses the whole uninstall, with nothing removed, as step 3 does
// for an extended object. ⛔ Not "uninstall and keep the shared table":
// dropping the tables is this verb's declared default, and an
// uninstall that quietly drops fewer than it says is an opt-in the
// runtime does not honour.
//
// With `keepData: true` no table is dropped, so sharing decides
// nothing and nothing is read. `RESOURCE_CONFLICT` / 409, no code
// minted: the per-item refusal's code for the same rows (environment
// state, not the request: the same uninstall is accepted once the other
// rows, or this package's row, are gone). A failed read is this verb's
// step-2 read failure ({@link metadataReadFailureError}), never "not
// shared". The prescription first: a 4xx message can be truncated.
if (request.keepData !== true) {
const shared: string[] = [];
const asked = new Set<string>();
for (const row of rows) {
if (row?.state === 'draft' || typeof row?.name !== 'string') continue;
const canonical = canonicalMetaType(String(row.type));
const type = PLURAL_TO_SINGULAR[canonical] ?? canonical;
if (!this.shouldDropStorage(type, row.name, true, 'active') || asked.has(row.name)) continue;
asked.add(row.name);
let at: { candidates: readonly string[]; unbound: boolean };
try {
at = await this.packagelessAddress(type, row.name, undefined, 'active');
} catch (e) {
throw metadataReadFailureError(e);
}
if (!at.candidates.includes(request.packageId)) continue;
const others = at.candidates.filter((id) => id !== request.packageId);
if (others.length === 0 && !at.unbound) continue;
shared.push(`'${row.name}' (${remainingRowsPhrase(others, at.unbound, 'row')})`);
}
if (shared.length > 0) {
const more = shared.length > 5 ? `; and ${shared.length - 5} more` : '';
const err = new Error(
`Uninstall package '${request.packageId}' with keepData (?keepData=true; keepData: true in `
+ 'process), which keeps all its object tables, or first delete, without dropStorage, its row '
+ 'or the other rows of each object below (DELETE /api/v1/meta/object/NAME?package=ID). '
+ "Nothing was removed: an uninstall drops an object's table only with the object's last row, and "
+ (shared.length === 1
? 'an object of this package is'
: `${shared.length} objects of this package are`)
+ ` also held by other rows: ${shared.slice(0, 5).join('; ')}${more}.`,
) as Error & { code: 'RESOURCE_CONFLICT'; status: 409 };
err.code = 'RESOURCE_CONFLICT';
err.status = 409;
throw err;
}
}

// [#21276] THE STORE DELETE COMES FIRST, and its refusal is this verb's
// refusal. Triage's ruling: refuse before withdrawing, not undo. Every
// step above this one only reads; every step below it — the per-item
Expand Down
Loading
Loading