Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 @@ -26052,6 +26052,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 @@ -26141,6 +26144,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