Repository navigation
Commit 157ebd0
feat(cli): os db orphans — report detail rows whose required master-detail master is missing, and delete them only behind --delete after a pre-delete export (#25039)
Fixes #24964
Clause-②: yes (widening) (the new public method
`ObjectQL.inspectCascadeDelete` on `@objectstack/objectql`, and the new
command `os db orphans`)
The line above is the seat's re-declaration (the claim, 6305964995, said
`no`): this PR adds one public method, `ObjectQL.inspectCascadeDelete`,
to the exported `ObjectQL` class. It reaches the published declaration
closure: `packages/objectql/dist/util-*.d.ts`, which `dist/index.d.ts`
re-exports the class from, carries 2 hits (declaration and doc); the
control, `inspectDanglingReferences`, carries 3 hits in the same file.
So `@objectstack/objectql`'s published surface widens by one method,
both packages are graded `minor`, and the changeset carries the same
`Clause-②: yes (widening)` line.
## What this does
`os db orphans` is the operator's door to the detail rows whose
**required** `master_detail` master does not exist. A system-context
write is exempt from the write-time reference check, so an elevated
writer can store a master id that names no master. Every user door then
answers 404 for that row, and the integrity audit reports it and never
rewrites it.
- **Default run: a report** that writes no row, no table and no file. It
boots read-only, as `os migrate plan` does. It lists each orphan's
object, record id, field and missing master id, and the deeper detail
rows its delete removes with it.
- **`--delete` requires `--export PATH`.** Every record the run deletes
is written as stored, owner-only (`0600`, created with `wx`), and read
back before the first delete. Each delete is the engine's own
system-context `delete()`, so the master-detail's cascade removes the
deeper details.
- **Never deleted:** an `undetermined` reference (its target object
could not be checked), a dangling reference on a field that is not a
required master-detail (named in `otherDangling`), and an orphan whose
cascade could not be read (`withheld`).
- **`@objectstack/objectql`: `inspectCascadeDelete(object, id)`.** A
read-only method that returns what a by-id `delete()` removes, root
first. It returns the cascade set the delete collects for itself
(`collectCascadeDeleteSet`), read through the same walk. It files no
reference-check elevation record, because no delete happens.
## The ruling's five constraints (6303808312), and where each is held
| # | Constraint | Held by |
|---|---|---|
| 1 | Explicit operator command; nothing on any boot, upgrade, migrate
or lifecycle path invokes it | `orphans.no-auto-run.test.ts`. A
resolving import scan: no source file imports the command, no source
file but the command imports its plan, and no source file but those two
calls `.inspectCascadeDelete(`. Positive controls run first, including
`serve.ts`'s dynamic import of the boot migration gate. The one-shot
family pin (`schema-migrate.one-shot-family.integration.test.ts`) also
shows its boots arm no lifecycle sweep. |
| 2 | Default run is a dry-run report: object, record id, field, missing
master id | `orphans.integration.test.ts`, the "lists the orphan…" case.
The database is byte-identical before and after (schema plus every row),
and no export file exists. |
| 3 | Delete only behind an explicit flag, only rows the same run listed
| `--delete` refuses without `--export` (`export_required`). Just before
deleting, the audit runs again, and an orphan no longer dangling as
reported is skipped. Before its own delete, each orphan's cascade is
read again, and one that now reaches a record the export lacks is
skipped. |
| 4 | Mandatory pre-delete export, written before the first delete | The
order pin intercepts the command's first `ObjectQL.prototype.delete`
call and reads the export there. It finds `['od_member/m_orphan',
'od_member_note/n_orphan']` already on disk. Refusals cover an
unwritable path (`export_failed`) and an existing path
(`export_exists`); in both, nothing is deleted. |
| 5 | Deletion applies the master-detail's own delete semantics; deeper
details listed in the dry run | The delete is `engine.delete(object, {
where: { id }, context: { isSystem: true } })`. Deeper details come from
`inspectCascadeDelete`. In the objectql test, its listing equals what
`delete()` then removes. **Declared, as the ruling asks:** the dry run
lists every record a `cascade` relation reaches, at any depth. A
`restrict` dependent outside the cascade is not listed, because it
removes nothing. It refuses the delete, and the cascade rolls back as
one unit (pinned: the note the cascade reached first is back). A
`set_null` reference to a deleted record is not listed either, because
it removes nothing; the engine clears it. That clearing is the engine's
existing behaviour and **NOT MEASURED** by this PR. |
## Mechanism hypotheses, measured
- **H1 holds.** `inspectDanglingReferences({ objects })` returned, on a
measurement boot (real kernel, SQL driver; fixture names `oc_*`),
`{"objectName":"oc_member","recordId":"…","field":"campaign","target":"oc_campaign","value":"Spring
Promo"}` in `dangling`. One limit: `undetermined` is a **count**, not
itemised. So the report counts unprobeable references and names none of
them. The pin uses a master object that is not registered at all: the
row is counted, never listed, and never deleted.
- **H2 holds, with one measured wrinkle.** A system-context `delete()`
of the orphan removed it and its required master-detail child (the
cascade). When the missing master has a roll-up over the detail, the
delete throws `ERR_SUMMARY_RECOMPUTE` (`SummaryRecomputeError`,
`written: true`) after the rows are already removed: "Record Spring
Promo not found in oc_campaign". The command recognises that code
through `SUMMARY_RECOMPUTE_CODE`. It judges every delete by reading the
cascade's records back, never by what the call returned. It reports a
note naming the roll-up on the missing master. Pinned in the main delete
case.
- **H3 holds.** The shape mirrors `os secret orphans`: `bootSchemaStack`
(read-only for the report, plain for `--delete`), `--delete` plus
`--export`, `wx` and `0600`, a read-back check, `--yes`, and `--json` as
one document. Reused as is: `absentTableReads`,
`describeDriverSqliteFile` with `JOURNAL_MODE_SWITCH_NOTE`, and the
`format.ts` helpers. `PlatformObjectsPlugin` is composed as `os serve`
composes it, so the cascade sees the platform's relations.
- **The seat's question: yes, one new method.** Measured on the built
closure (above). The alternative was a copy of "which relations cascade"
in the CLI, which would be free to disagree with the delete it previews.
- **Premise re-check:** `git grep -n
"danglingReferences\|inspectDanglingReferences" origin/main --
packages/cli/src packages/rest/src packages/runtime/src` hits only
`schema-migrate.teardown.integration.test.ts`, a lifecycle-sweep test,
so no source door existed. The control, the same grep in
`packages/objectql/src`, hits `engine.ts` (3) and `lifecycle-service.ts`
(3). The HotCRM measurement (an administrator's `GET`/`DELETE` answer
404) is **NOT MEASURED** here: this boot composes no security plugin,
and a user-context read in it returned the row.
## Ablation (one leg each, through `scripts/ablation-replace.mjs`; both
restores proven `blob == HEAD`, `git diff HEAD` empty)
- **Master-detail filter** (`isRequiredMasterFinding` returns `true`): 5
of 9 integration cases red and 1 of 6 unit cases red. A dangling lookup
on the live member made it an "orphan", and the live member was deleted.
The source is imported directly from `src/`, so no rebuild is involved.
- **Export before the first delete** (verified from memory, not
written): 3 of 9 integration cases red. The order pin read `"no file"`
at the first delete, the main delete case found no export, and the
unwritable-path refusal no longer refused.
## Tests and gates
All at head `0770e4fc9` unless a sha is named. Exit codes were captured
before any pipe.
- `@objectstack/objectql`, targeted (local project): `vitest run
--project local --maxWorkers=2 engine-cascade
engine-inspect-cascade-delete engine-dangling-reference
integrity/dangling-reference lifecycle-service` passed 18 files and 328
tests (exit 0). The new `engine-inspect-cascade-delete.test.ts` has 5
cases. **Declared narrowing:** the package's other test files were not
run locally, and CI's `Test Core` runs them all. The narrowing is by
file name: tests that reach the cascade walk under another name are not
in it.
- `@objectstack/cli`, unit tier, whole: `vitest run --project unit
--maxWorkers=2` passed 318 files and 4699 tests (exit 0).
- `@objectstack/cli`, integration tier, targeted at the files this diff
touches: `orphans.integration.test.ts` passed 9/9 (exit 0). In
`schema-migrate.one-shot-family.integration.test.ts` at `26706a69a`,
`-t` selected the `db orphans` rows, the table-equals-source case and
the lifecycle-arming cases: 10 passed, 98 skipped (exit 0). **Declared
to CI:** the rest of the integration tier, and
`test/json-stdout-purity.e2e.test.ts` (its single-member spawn is in the
acceptance notes).
- Typecheck: `pnpm --filter @objectstack/cli typecheck` and `pnpm
--filter @objectstack/objectql typecheck` both exit 0,
`check:test-typecheck` included, with no new debt.
- Gates: `node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack` derived 105 commands, and every one exits 0.
`--ran` reconciles: "105 derived, 105 run, 0 NOT-MEASURED, 0 UNRUN"
(exit 0).
- The first run at `43f215942` had one real red. `check:slot-lookup`
flagged an `any` lookup in the new integration test, now typed
(`0770e4fc9`).
- Four refused with a prerequisite, not a finding.
`check-engine-split-ratio` refused on shallow history, and history was
deepened. `check:skill-examples`, `check:dual-build-cjs-loads` and
`check:i18n-coverage` refused for missing builds of packages outside
this diff. All four are green on the re-run at this head.
- Lint, the repo-wide scan, is CI's.
## Acceptance notes
- The `undetermined` bucket is a count in the audit's report
(`DanglingReferenceReport.undetermined`), so the command can say how
many references it could not check, but not which rows hold them.
Itemising it would change the audit's published report shape. Noted, not
filed. Carrier: none.
- "Required master-detail" is read through
`isControlledByParentMasterField` (`@objectstack/spec/data`). Its body
is exactly `type === 'master_detail' && required === true`, and it is
the one place the spec spells that. The name speaks of
`controlled_by_parent`; the predicate does not depend on the sharing
model. Noted, not filed. Carrier: none.
- The `json-stdout-purity` e2e (a spawn-tier file that runs every family
member in sequence) gains its `db orphans` row and is declared to CI.
Locally, the same spawn was driven for this member alone (`tsx
bin/run-dev.js db orphans --json` against a project with no database).
It exited 0 with one JSON document on stdout, the boot diagnostics on
stderr, and no database file created.
---
_Generated by [Claude
Code](https://claude.ai/code/session_01QK6pdbxKZiHdFApL8RYVh9)_
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent f8c5fb3 commit 157ebd0
11 files changed
Lines changed: 1790 additions & 1 deletion
File tree
- .changeset
- content/docs/deployment
- packages
- cli
- src
- commands/db
- utils
- test
- objectql/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 | |
|---|---|---|---|
| |||
82 | 82 | | |
83 | 83 | | |
84 | 84 | | |
| 85 | + | |
85 | 86 | | |
86 | 87 | | |
87 | 88 | | |
| |||
423 | 424 | | |
424 | 425 | | |
425 | 426 | | |
| 427 | + | |
| 428 | + | |
| 429 | + | |
| 430 | + | |
| 431 | + | |
| 432 | + | |
| 433 | + | |
| 434 | + | |
| 435 | + | |
| 436 | + | |
| 437 | + | |
| 438 | + | |
| 439 | + | |
| 440 | + | |
| 441 | + | |
| 442 | + | |
| 443 | + | |
| 444 | + | |
| 445 | + | |
| 446 | + | |
| 447 | + | |
| 448 | + | |
| 449 | + | |
| 450 | + | |
| 451 | + | |
| 452 | + | |
| 453 | + | |
| 454 | + | |
| 455 | + | |
| 456 | + | |
| 457 | + | |
| 458 | + | |
| 459 | + | |
| 460 | + | |
| 461 | + | |
| 462 | + | |
| 463 | + | |
| 464 | + | |
| 465 | + | |
| 466 | + | |
| 467 | + | |
| 468 | + | |
426 | 469 | | |
427 | 470 | | |
428 | 471 | | |
| |||
0 commit comments