Skip to content
21 changes: 21 additions & 0 deletions .changeset/23415-drop-storage-other-rows.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
---
'@objectstack/metadata-protocol': minor
---

fix(metadata-protocol)!: a metadata delete with `dropStorage` drops an object's table only with the object's last stored row, and is refused `409 RESOURCE_CONFLICT` while another row of the object remains (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 write door: a metadata delete that asks to drop an object's table, while another stored row of the object stays after it, is refused before anything is written; the remedy is in the request a caller sends (drop the opt-in, or delete the other rows 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. Every such row serves the object over the one table its name maps to.

**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.

**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.

**Remedy.** To remove one package's row of an object another row still holds, send the delete without `dropStorage`. To drop the table, delete the object's other rows first, then send the last row's delete with `dropStorage`.
91 changes: 84 additions & 7 deletions packages/metadata-protocol/src/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20747,6 +20747,57 @@ export class ObjectStackProtocolImplementation implements
return true;
}

/**
* [#23415, ADR-0048] The refusal of a delete that asks to drop an object's
* table ({@link shouldDropStorage} says yes) while another stored row of
* the object stays after it. A `sys_metadata` row is keyed by its package,
* so one object can hold one row per package (and a package-less row), and
* every such row serves the object over the ONE table its name maps to.
* Dropping the table with one of them left the others' object served
* with its records gone: measured at `DELETE
* /api/v1/meta/object/NAME?package=P&dropStorage=true` on a booted
* showcase, `200` with a receipt naming Q's remaining row, then `500` on
* the object's next data read.
*
* Refused, not skipped: an explicit `dropStorage` that drops nothing would
* be an opt-in the runtime does not honour while the receipt answers
* success. Refused before any write, so nothing is deleted and the request
* succeeds once the remedy is applied: without `dropStorage` (this row
* goes, the table stays), or with the object's last row.
*
* The rows that count are the ones the delete's own address read
* ({@link packagelessAddress}) holds: the environment's ACTIVE rows of the
* object. A draft row is not one: a draft is never materialised and never
* served, so it holds no records, and the last active row's delete still
* drops the table it was the last to serve.
*
* `RESOURCE_CONFLICT` / 409, no code minted: the standard catalog's member
* for a refusal that comes from environment state rather than the request
* (the same request is accepted once the other rows are gone), as
* {@link packagelessAddressAmbiguityRefusal} answers for the same rows.
* The prescription first, because a 4xx message crosses the REST boundary
* bounded at 500 characters with its tail truncated.
*/
private static dropStorageRemainingRowsRefusal(
type: string,
name: string,
deletedPackage: string | null,
remainingPackages: readonly string[],
unboundRemains: boolean,
): Error & { code: 'RESOURCE_CONFLICT'; status: 409 } {
const which = deletedPackage !== null ? ` (package=${deletedPackage})` : '';
const err = new Error(
'Send the delete again without dropStorage to remove this row and keep the table, or delete the '
+ "object's other rows first and drop the table with the last one. Nothing was deleted. "
+ `After this delete${which}, ${type} '${name}' is still served: `
+ `${remainingRowsPhrase(remainingPackages, unboundRemains, 'row')}, and the object's records are in its table. `
+ "dropStorage drops an object's table only with the object's last row.",
) as Error & { code: 'RESOURCE_CONFLICT'; status: 409 };
err.code = 'RESOURCE_CONFLICT';
err.status = 409;
return err;
}

/**
* [commit 311433f6b] The item-name grammar verdict — stage 1 of the
* maintainer-ruled retirement of compound `<section>/<name>` addressing
Expand Down Expand Up @@ -28367,6 +28418,9 @@ export class ObjectStackProtocolImplementation implements
* is removed (object + active only; never `sys_`). Default false keeps
* delete non-destructive to data. Used by the "discard a previewed
* object" flow so a publish-to-preview leaves no orphan table.
* [#23415] Only with the object's last stored row: while another row
* of it stays stored, the delete is refused `409 RESOURCE_CONFLICT`
* ({@link dropStorageRemainingRowsRefusal}).
*/
dropStorage?: boolean;
/** [#22877] The bundle member to reset — {@link resolveBundleMemberSelector}; omitted, the canonical one. */
Expand Down Expand Up @@ -28560,6 +28614,9 @@ export class ObjectStackProtocolImplementation implements
// more packages hold: the door's own verdict, rethrown as it is by
// the `catch` below, never re-wrapped as a delete that failed.
let addressRefusal: Error | undefined;
// [#23415] The refusal of a table drop another stored row of the
// object still needs: rethrown as it is, as `addressRefusal` is.
let dropRefusal: Error | undefined;

try {
const targetState: 'active' | 'draft' = request.state === 'draft' ? 'draft' : 'active';
Expand Down Expand Up @@ -28656,6 +28713,25 @@ export class ObjectStackProtocolImplementation implements
};
}

// [#23165] What is still stored at this address once this row
// is gone, from the rows the address read already holds. Read
// by the table drop's verdict just below and by the receipt.
const remainingPackages = rowsAtAddress.candidates.filter((id) => id !== packageId);
const unboundRemains = rowsAtAddress.unbound && packageId !== null;
const remains = remainingPackages.length > 0 || unboundRemains;
// [#23415] The table drop, decided ONCE, before anything is
// written: an object's table goes only with its last stored
// row. A `dropStorage` delete that would leave another row of
// the object stored, and served over this table, is refused
// whole — see {@link dropStorageRemainingRowsRefusal}.
const dropsStorage = this.shouldDropStorage(request.type, request.name, request.dropStorage, targetState);
if (dropsStorage && remains) {
dropRefusal = ObjectStackProtocolImplementation.dropStorageRemainingRowsRefusal(
singularTypeForRepo, request.name, packageId, remainingPackages, unboundRemains,
);
throw dropRefusal;
}

// Last-write-wins parent resolution unless the caller pinned
// an explicit version (Studio's "Reset" button is unpinned;
// a future "delete vN" flow can pass parentVersion).
Expand Down Expand Up @@ -28707,7 +28783,9 @@ export class ObjectStackProtocolImplementation implements

// Storage teardown (opt-in): drop the now-orphaned physical table
// for a discarded object so a publish-to-preview leaves no residue.
if (this.shouldDropStorage(request.type, request.name, request.dropStorage, targetState)) {
// [#23415] Decided above; reached only when this was the
// object's last stored row.
if (dropsStorage) {
await this.dropObjectStorage(singularTypeForRepo, request.name);
}

Expand Down Expand Up @@ -28740,11 +28818,6 @@ export class ObjectStackProtocolImplementation implements
organizationId: orgId,
...mutationEventMember(singularTypeForRepo, member),
});
// [#23165] What is still stored at this address once this row
// is gone, from the rows the address read already holds.
const remainingPackages = rowsAtAddress.candidates.filter((id) => id !== packageId);
const unboundRemains = rowsAtAddress.unbound && packageId !== null;
const remains = remainingPackages.length > 0 || unboundRemains;
const deletedPackage = packageId !== null ? ` (package=${packageId})` : '';
return {
success: true,
Expand Down Expand Up @@ -28794,7 +28867,11 @@ export class ObjectStackProtocolImplementation implements
// [#21899] The door's own verdict, not a store failure: it
// leaves with its envelope and sentence, never re-wrapped as an
// overlay delete that failed.
if (err === originGatedRefusal || (addressRefusal !== undefined && err === addressRefusal)) throw err;
if (
err === originGatedRefusal
|| (addressRefusal !== undefined && err === addressRefusal)
|| (dropRefusal !== undefined && err === dropRefusal)
) throw err;
if (err instanceof ConflictError) {
// [#21207] Keyed values or none in the text and attributes.
const conflict = await this.metadataConflictRefusal(
Expand Down
Loading
Loading