Skip to content

Commit 1baf976

Browse files
fix(metadata-protocol)!: a delete with dropStorage drops an object's table only with its last stored row, refused 409 while another row remains (#23464)
Fixes #23415 Clause-②: no ## What this does `deleteMetaItem` (`packages/metadata-protocol/src/protocol.ts`) now decides the table drop once, before anything is written. A delete with `dropStorage` that would leave another stored row of the object (another package's row, or the package-less row) is refused `409 RESOURCE_CONFLICT`. Nothing is deleted. The message gives the way out first: send the delete without `dropStorage`, or delete the other rows first and drop the table with the last one. Then it names the rows that remain. The decision reads the rows the delete's own address read already holds (`packagelessAddress`, from the package-axis work in PR #23290 / PR #23408). It adds no second row lookup. The "what remains" values the receipt used are hoisted to the decision point, and the drop call site reads the one verdict (`dropsStorage`). The refusal is rethrown as is from the delete's `catch`, like the package-less ambiguity refusal, so it is never re-wrapped as a failed delete. This is the seat's chosen remedy (a loud refusal, not "skip the drop and say so in the receipt"). An explicit `dropStorage=true` that drops nothing would be an opt-in the runtime does not honour while the receipt answers success. ## Measured On the real stack: better-sqlite3 `:memory:` tables, real `ObjectQL`, real `ObjectStackProtocolImplementation`, real REST routes. Packages `com.probe.p` and `com.probe.q` each hold a row of `probe_ticket`, and one record exists. The request is `DELETE /api/v1/meta/object/probe_ticket?package=com.probe.p&dropStorage=true`. | | before (base `04a31487`, pin red) | after | |---|---|---| | status | `200`, receipt: "Deleted object 'probe_ticket' (package=com.probe.p), and it still exists: its row bound to package 'com.probe.q' remains." | `409 RESOURCE_CONFLICT` | | stored active rows | `[com.probe.q]` | `[com.probe.p, com.probe.q]` | | table exists | `false` (Q's record gone) | `true`, record `kept` still there | | P's row plus a package-less row | rows `[null]`, table `false` | `409`, rows and table unchanged | The refusal as served: ```text 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 (package=com.probe.p), object 'probe_ticket' is still served: its row bound to package 'com.probe.q' remains, and the object's records are in its table. dropStorage drops an object's table only with the object's last row. ``` ## Which rows count (the reading the guard is based on) - **Only active rows.** The guard runs only where `shouldDropStorage` says yes, and that already requires `active`. The rows it reads are the active rows at the delete's address. A draft is never materialised or served, so it holds no records. A delete of the last active row with `dropStorage` still drops the table even if another package holds only a draft. That behaviour is unchanged. - **A package-less row counts.** A package-less row beside the deleted package's row serves the object over the same table, so it counts as a remaining row (pinned). ## Premise check: no new error code `RESOURCE_CONFLICT` / 409 is the standard catalog's "generic resource conflict" (`packages/spec/src/api/errors.zod.ts`). It is also the code the package-less ambiguity refusal answers for the same rows (`packagelessAddressAmbiguityRefusal`, PR #23290). The refusal comes from environment state, not from the request body: the same request is accepted once the other rows are gone. That is the case this code covers. No code is minted, and no spec, ledger or error-code file changes. ## A second door reaches the same refusal: uninstall `deletePackage` (the uninstall) deletes each of the package's rows with `packageId` set and, unless `keepData` is set, with `dropStorage: true`. Before this change, uninstalling P from an object P and Q both hold dropped Q's table through the same call. I measured the uninstall in process on the same stack: `protocol.deletePackage({ packageId: 'com.probe.p' })` with no `keepData`. - **Now:** `success: false`, `failedCount: 1`, and `failed[0]` carries this refusal (`code: RESOURCE_CONFLICT`). P's row of the object stays, and the table and its record stay. This is how `deletePackage` already reports any per-item refusal (its `failed[]` contract). - **Before:** the per-item delete is the call measured above, so it dropped the table. The new answer is safer than the data loss it replaces. It is still a partial uninstall: with a `package` service registered, the `sys_packages` row is deleted before the per-item loop, so the package's row of the shared object is left behind. The refusal's text also names `dropStorage`, which an uninstall request never sends. Changing that shape means changing `deletePackage`, which is outside this card's file surface (see Acceptance notes). ## Tests Head `8a7b08b392` (the fix plus one merge of `origin/main` at `05ab9ef895`). The metadata-protocol and rest suites were re-run on that head after the merge. The objectql suite, the typechecks and the ablation ran on the pre-merge head `dce4a7ea77`. The merge brought no change to `protocol.ts`, `packages/rest` or the delete path: it touched objectql's lookup integrity and spec page parsing. - New pin, `packages/rest/src/meta-delete-drop-storage-other-rows.test.ts`, on the real stack, 5 cases: - (a) on a control-plane kernel AND an environment kernel, `?package=P&dropStorage=true` is refused 409 `RESOURCE_CONFLICT`, the message names Q, and both rows, the table and Q's record stay; - a package-less remaining row is refused the same way; - (b)+(c) without `dropStorage`, P's delete is unchanged: `200`, the receipt names Q, the table and record stay. Then Q's delete (the last row) with `dropStorage` drops the table; - a single-package control: `?package=P&dropStorage=true` drops the table. - Red at base: 3 failed, 2 passed (the two controls). Green after the fix: 5 passed. - `pnpm --filter @objectstack/metadata-protocol test`: 235 files passed (3 skipped), 28385 tests passed. After the merge: 28389 passed. - `pnpm --filter @objectstack/objectql test`: 415 files passed, 8087 tests passed. The protocol delete tests live here. - `pnpm --filter @objectstack/rest test`: 282 files passed, 5850 tests passed (326 skipped). The same after the merge. - `pnpm --filter @objectstack/metadata-protocol typecheck`: exit 0. `pnpm --filter @objectstack/rest typecheck` (tsc plus `check:test-typecheck`): exit 0. `tsconfig.test.json --listFilesOnly` lists the new test. - `@objectstack/cli` was not run. `os meta delete` has no `dropStorage` flag on purpose, and `delete-reset-carriers.test.ts` pins that the flag is absent, so no CLI test reaches this path. **Ablation (one run).** The test imports `@objectstack/metadata-protocol` through its `dist/` (`KNOWN_UNALIASED_TEST_IMPORTS`), so each leg rebuilt it and checked the marker in `dist/` before the run. - **Mutated leg:** `scripts/ablation-replace.mjs` replaced the guard `if (dropsStorage && remains) {` with a condition that never holds for a real name. The anchor count went 1 to 0 and the blob went `4d93ece86aea` to `79bb58a95ed6`. `ablation-dist-preflight.mjs` found the marker in `dist/index.js` and `dist/index.cjs`. The pin went red: 3 failed (the three refusal cases), 2 passed (the controls). - **Restore leg:** the blob is back to the HEAD blob `4d93ece86aea` and `git diff HEAD` is empty. After a rebuild, `--absent` confirmed the marker is gone from all 24 built files and the tree is clean. The pin is green, 5 passed. ## Gates (local subset) `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 65 commands at head `8a7b08b392`. I ran all 65 there, unlocked, capturing each exit code before any pipe: 65 exit 0. `--ran` reconciliation: "65 derived, 65 run, 0 NOT-MEASURED, 0 UNRUN". An earlier run of the same list at `9534b10c1` had three non-zero results. All three were fixed or explained before the final run: - `check:rest-log-spy-declared` (exit 1) was a real finding: the first version of the pin spied on `console.warn`. The spy was removed. - `check-plugin-teardown-shape --self-test` (exit 3) could not reach its pinned fixture commit on this shallow clone. It passes once that commit is fetched. - `check:dual-build-cjs-loads` (exit 3) was PREREQUISITE NOT MET: some packages had no `dist/`. CI runs everything outside this subset: Lint & Repo Gates, TypeScript Type Check, Test Core, Build Core. ## Acceptance notes - **Pin placement.** The claim names `protocol-delete-object-registry-heal.test.ts` (PR #23408's `twoPackageObject`) or a sibling. That fixture's engine double has no tables and no `dropObjectSchema`, so "Q's records survive" would rest on a hand-modelled drop there. The pin therefore lives beside the REST delete door's real-stack tests (`meta-draft-head-package-inheritance.test.ts` is the harness it mirrors), on a real SQLite table. No production file outside the claimed drop region changed. - **Spec text.** `DeleteMetaItemRequestSchema.dropStorage`'s description (`packages/spec/src/api/protocol.zod.ts`) still lists only the type, state and `sys_` conditions. It does not name this refusal. The claim scoped that file to the skip remedy only, so it is untouched here. The in-code JSDoc on `deleteMetaItem`'s `dropStorage` names the refusal. Carrier: the next PR that touches `protocol.zod.ts`'s delete request. - **Uninstall shape.** See above. Whether `deletePackage` should refuse the whole uninstall before its first durable step, or skip the drop for a shared object and say so, is a decision about `deletePackage`. It is outside this card's file surface and is raised in the dev report. - **Legacy raw-engine path.** The drop's second call site (the legacy raw-engine delete path) is unchanged. `object` is `allowRuntimeCreate: true`, so an object delete always takes the repository path. The legacy path reaches only code-only types, which `shouldDropStorage` already rules out. --- _Generated by [Claude Code](https://claude.ai/code/session_0121ooBYRmS8ziFXm9ai5YHm)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent cb9841a commit 1baf976

3 files changed

Lines changed: 344 additions & 7 deletions

File tree

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
---
2+
'@objectstack/metadata-protocol': minor
3+
---
4+
5+
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)
6+
7+
Clause-②: no (narrowing)
8+
9+
<!-- 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). -->
10+
11+
**BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes.
12+
13+
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.
14+
15+
**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).
16+
17+
**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.
18+
19+
**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.
20+
21+
**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`.

‎packages/metadata-protocol/src/protocol.ts‎

Lines changed: 84 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -20746,6 +20746,57 @@ export class ObjectStackProtocolImplementation implements
2074620746
return true;
2074720747
}
2074820748

20749+
/**
20750+
* [#23415, ADR-0048] The refusal of a delete that asks to drop an object's
20751+
* table ({@link shouldDropStorage} says yes) while another stored row of
20752+
* the object stays after it. A `sys_metadata` row is keyed by its package,
20753+
* so one object can hold one row per package (and a package-less row), and
20754+
* every such row serves the object over the ONE table its name maps to.
20755+
* Dropping the table with one of them left the others' object served
20756+
* with its records gone: measured at `DELETE
20757+
* /api/v1/meta/object/NAME?package=P&dropStorage=true` on a booted
20758+
* showcase, `200` with a receipt naming Q's remaining row, then `500` on
20759+
* the object's next data read.
20760+
*
20761+
* Refused, not skipped: an explicit `dropStorage` that drops nothing would
20762+
* be an opt-in the runtime does not honour while the receipt answers
20763+
* success. Refused before any write, so nothing is deleted and the request
20764+
* succeeds once the remedy is applied: without `dropStorage` (this row
20765+
* goes, the table stays), or with the object's last row.
20766+
*
20767+
* The rows that count are the ones the delete's own address read
20768+
* ({@link packagelessAddress}) holds: the environment's ACTIVE rows of the
20769+
* object. A draft row is not one: a draft is never materialised and never
20770+
* served, so it holds no records, and the last active row's delete still
20771+
* drops the table it was the last to serve.
20772+
*
20773+
* `RESOURCE_CONFLICT` / 409, no code minted: the standard catalog's member
20774+
* for a refusal that comes from environment state rather than the request
20775+
* (the same request is accepted once the other rows are gone), as
20776+
* {@link packagelessAddressAmbiguityRefusal} answers for the same rows.
20777+
* The prescription first, because a 4xx message crosses the REST boundary
20778+
* bounded at 500 characters with its tail truncated.
20779+
*/
20780+
private static dropStorageRemainingRowsRefusal(
20781+
type: string,
20782+
name: string,
20783+
deletedPackage: string | null,
20784+
remainingPackages: readonly string[],
20785+
unboundRemains: boolean,
20786+
): Error & { code: 'RESOURCE_CONFLICT'; status: 409 } {
20787+
const which = deletedPackage !== null ? ` (package=${deletedPackage})` : '';
20788+
const err = new Error(
20789+
'Send the delete again without dropStorage to remove this row and keep the table, or delete the '
20790+
+ "object's other rows first and drop the table with the last one. Nothing was deleted. "
20791+
+ `After this delete${which}, ${type} '${name}' is still served: `
20792+
+ `${remainingRowsPhrase(remainingPackages, unboundRemains, 'row')}, and the object's records are in its table. `
20793+
+ "dropStorage drops an object's table only with the object's last row.",
20794+
) as Error & { code: 'RESOURCE_CONFLICT'; status: 409 };
20795+
err.code = 'RESOURCE_CONFLICT';
20796+
err.status = 409;
20797+
return err;
20798+
}
20799+
2074920800
/**
2075020801
* [commit 311433f6b] The item-name grammar verdict — stage 1 of the
2075120802
* maintainer-ruled retirement of compound `<section>/<name>` addressing
@@ -28366,6 +28417,9 @@ export class ObjectStackProtocolImplementation implements
2836628417
* is removed (object + active only; never `sys_`). Default false keeps
2836728418
* delete non-destructive to data. Used by the "discard a previewed
2836828419
* object" flow so a publish-to-preview leaves no orphan table.
28420+
* [#23415] Only with the object's last stored row: while another row
28421+
* of it stays stored, the delete is refused `409 RESOURCE_CONFLICT`
28422+
* ({@link dropStorageRemainingRowsRefusal}).
2836928423
*/
2837028424
dropStorage?: boolean;
2837128425
/** [#22877] The bundle member to reset — {@link resolveBundleMemberSelector}; omitted, the canonical one. */
@@ -28559,6 +28613,9 @@ export class ObjectStackProtocolImplementation implements
2855928613
// more packages hold: the door's own verdict, rethrown as it is by
2856028614
// the `catch` below, never re-wrapped as a delete that failed.
2856128615
let addressRefusal: Error | undefined;
28616+
// [#23415] The refusal of a table drop another stored row of the
28617+
// object still needs: rethrown as it is, as `addressRefusal` is.
28618+
let dropRefusal: Error | undefined;
2856228619

2856328620
try {
2856428621
const targetState: 'active' | 'draft' = request.state === 'draft' ? 'draft' : 'active';
@@ -28655,6 +28712,25 @@ export class ObjectStackProtocolImplementation implements
2865528712
};
2865628713
}
2865728714

28715+
// [#23165] What is still stored at this address once this row
28716+
// is gone, from the rows the address read already holds. Read
28717+
// by the table drop's verdict just below and by the receipt.
28718+
const remainingPackages = rowsAtAddress.candidates.filter((id) => id !== packageId);
28719+
const unboundRemains = rowsAtAddress.unbound && packageId !== null;
28720+
const remains = remainingPackages.length > 0 || unboundRemains;
28721+
// [#23415] The table drop, decided ONCE, before anything is
28722+
// written: an object's table goes only with its last stored
28723+
// row. A `dropStorage` delete that would leave another row of
28724+
// the object stored, and served over this table, is refused
28725+
// whole — see {@link dropStorageRemainingRowsRefusal}.
28726+
const dropsStorage = this.shouldDropStorage(request.type, request.name, request.dropStorage, targetState);
28727+
if (dropsStorage && remains) {
28728+
dropRefusal = ObjectStackProtocolImplementation.dropStorageRemainingRowsRefusal(
28729+
singularTypeForRepo, request.name, packageId, remainingPackages, unboundRemains,
28730+
);
28731+
throw dropRefusal;
28732+
}
28733+
2865828734
// Last-write-wins parent resolution unless the caller pinned
2865928735
// an explicit version (Studio's "Reset" button is unpinned;
2866028736
// a future "delete vN" flow can pass parentVersion).
@@ -28706,7 +28782,9 @@ export class ObjectStackProtocolImplementation implements
2870628782

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

@@ -28739,11 +28817,6 @@ export class ObjectStackProtocolImplementation implements
2873928817
organizationId: orgId,
2874028818
...mutationEventMember(singularTypeForRepo, member),
2874128819
});
28742-
// [#23165] What is still stored at this address once this row
28743-
// is gone, from the rows the address read already holds.
28744-
const remainingPackages = rowsAtAddress.candidates.filter((id) => id !== packageId);
28745-
const unboundRemains = rowsAtAddress.unbound && packageId !== null;
28746-
const remains = remainingPackages.length > 0 || unboundRemains;
2874728820
const deletedPackage = packageId !== null ? ` (package=${packageId})` : '';
2874828821
return {
2874928822
success: true,
@@ -28793,7 +28866,11 @@ export class ObjectStackProtocolImplementation implements
2879328866
// [#21899] The door's own verdict, not a store failure: it
2879428867
// leaves with its envelope and sentence, never re-wrapped as an
2879528868
// overlay delete that failed.
28796-
if (err === originGatedRefusal || (addressRefusal !== undefined && err === addressRefusal)) throw err;
28869+
if (
28870+
err === originGatedRefusal
28871+
|| (addressRefusal !== undefined && err === addressRefusal)
28872+
|| (dropRefusal !== undefined && err === dropRefusal)
28873+
) throw err;
2879728874
if (err instanceof ConflictError) {
2879828875
// [#21207] Keyed values or none in the text and attributes.
2879928876
const conflict = await this.metadataConflictRefusal(

0 commit comments

Comments
 (0)