Repository navigation
Commit 1baf976
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
- .changeset
- packages
- metadata-protocol/src
- rest/src
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
20746 | 20746 | | |
20747 | 20747 | | |
20748 | 20748 | | |
| 20749 | + | |
| 20750 | + | |
| 20751 | + | |
| 20752 | + | |
| 20753 | + | |
| 20754 | + | |
| 20755 | + | |
| 20756 | + | |
| 20757 | + | |
| 20758 | + | |
| 20759 | + | |
| 20760 | + | |
| 20761 | + | |
| 20762 | + | |
| 20763 | + | |
| 20764 | + | |
| 20765 | + | |
| 20766 | + | |
| 20767 | + | |
| 20768 | + | |
| 20769 | + | |
| 20770 | + | |
| 20771 | + | |
| 20772 | + | |
| 20773 | + | |
| 20774 | + | |
| 20775 | + | |
| 20776 | + | |
| 20777 | + | |
| 20778 | + | |
| 20779 | + | |
| 20780 | + | |
| 20781 | + | |
| 20782 | + | |
| 20783 | + | |
| 20784 | + | |
| 20785 | + | |
| 20786 | + | |
| 20787 | + | |
| 20788 | + | |
| 20789 | + | |
| 20790 | + | |
| 20791 | + | |
| 20792 | + | |
| 20793 | + | |
| 20794 | + | |
| 20795 | + | |
| 20796 | + | |
| 20797 | + | |
| 20798 | + | |
| 20799 | + | |
20749 | 20800 | | |
20750 | 20801 | | |
20751 | 20802 | | |
| |||
28366 | 28417 | | |
28367 | 28418 | | |
28368 | 28419 | | |
| 28420 | + | |
| 28421 | + | |
| 28422 | + | |
28369 | 28423 | | |
28370 | 28424 | | |
28371 | 28425 | | |
| |||
28559 | 28613 | | |
28560 | 28614 | | |
28561 | 28615 | | |
| 28616 | + | |
| 28617 | + | |
| 28618 | + | |
28562 | 28619 | | |
28563 | 28620 | | |
28564 | 28621 | | |
| |||
28655 | 28712 | | |
28656 | 28713 | | |
28657 | 28714 | | |
| 28715 | + | |
| 28716 | + | |
| 28717 | + | |
| 28718 | + | |
| 28719 | + | |
| 28720 | + | |
| 28721 | + | |
| 28722 | + | |
| 28723 | + | |
| 28724 | + | |
| 28725 | + | |
| 28726 | + | |
| 28727 | + | |
| 28728 | + | |
| 28729 | + | |
| 28730 | + | |
| 28731 | + | |
| 28732 | + | |
| 28733 | + | |
28658 | 28734 | | |
28659 | 28735 | | |
28660 | 28736 | | |
| |||
28706 | 28782 | | |
28707 | 28783 | | |
28708 | 28784 | | |
28709 | | - | |
| 28785 | + | |
| 28786 | + | |
| 28787 | + | |
28710 | 28788 | | |
28711 | 28789 | | |
28712 | 28790 | | |
| |||
28739 | 28817 | | |
28740 | 28818 | | |
28741 | 28819 | | |
28742 | | - | |
28743 | | - | |
28744 | | - | |
28745 | | - | |
28746 | | - | |
28747 | 28820 | | |
28748 | 28821 | | |
28749 | 28822 | | |
| |||
28793 | 28866 | | |
28794 | 28867 | | |
28795 | 28868 | | |
28796 | | - | |
| 28869 | + | |
| 28870 | + | |
| 28871 | + | |
| 28872 | + | |
| 28873 | + | |
28797 | 28874 | | |
28798 | 28875 | | |
28799 | 28876 | | |
| |||
0 commit comments