Skip to content

Commit 157ebd0

Browse files
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
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
---
2+
'@objectstack/cli': minor
3+
'@objectstack/objectql': minor
4+
---
5+
6+
feat(cli, objectql): `os db orphans` — report the detail rows whose required master-detail master does not exist, and delete them only behind `--delete`, after a pre-delete export
7+
8+
Clause-②: yes (widening)
9+
10+
**What was wrong.** A system-context write is exempt from the write-time reference check, so an elevated writer could store a master-detail id that names no master. Such a row's access derives from that master, so every user door answered 404 for it, the administrator's `GET` and `DELETE` included, and a list query left it out. The integrity audit reported the row and, by design, never rewrites it. No door existed to remove it, so it stayed until someone edited the database by hand.
11+
12+
**`os db orphans` (new command).** An explicit operator command. Nothing on any boot, upgrade, migrate or lifecycle path runs it.
13+
14+
- **The default run is a report** that writes no row or table and creates no file. It boots read-only, as `os migrate plan` does. It lists each detail row whose required `master_detail` master does not exist, with its object, record id, field and missing master id. It also lists the deeper detail rows that the delete's cascade removes with it. `--json` prints one document, `{ mode: 'report', plan }`.
15+
- **What is never deleted, and is said.** A reference whose target object could not be checked (not registered, no driver, or a failed read) is counted as `undetermined`. Unknown is not missing. A dangling reference on the same objects through a field that is not a required master-detail is listed in `otherDangling`. An orphan whose cascade could not be read is listed as `withheld`, with the reason. A scan bounded by `--max-rows` (default 100000) names every object it could not read in full.
16+
- **`--delete` requires `--export <path>`.** The export holds every record the run deletes, as the driver stores it. It is written owner-only, is created rather than overwritten, and is read back before the first delete. If it cannot be written or does not hold every record, the run refuses with nothing deleted. Just before deleting, the run reads the report again and skips an orphan whose master now exists, or whose cascade now reaches a record the export does not hold. Each delete is the engine's own system-context delete, so the master-detail's cascade removes the deeper details. A `restrict` dependent refuses the delete, and a cascade on one datasource is one unit of work, so nothing of that orphan is removed. A `set_null` reference to a deleted row is cleared. A roll-up on the missing master cannot be recomputed, and the run notes this rather than failing. Each delete is judged by reading its records back.
17+
- **Refusals**, each exit 1 with nothing deleted, and the code under `--json` as `error`: `export_required`, `export_exists`, `export_failed`, `export_incomplete`, `confirmation_required` (`--json` without `--yes`), `boot_failed`, `no_engine` and `scan_failed`.
18+
19+
**`@objectstack/objectql`: `ObjectQL.inspectCascadeDelete(object, id)` (new method).** A read-only method that returns every record a by-id `delete()` of that record removes, root first. It is the cascade set the delete collects for itself, read through the same walk. It writes nothing, runs no hook, and files no reference-check elevation record, because no delete happens. It reads as the system with no organization. `restrict` and `set_null` relations are not listed, because neither removes a record. A dependents probe that fails propagates.
20+
21+
Nothing that ran before changes: the audit still reports and never rewrites, and the system-write exemption is unchanged.

‎content/docs/deployment/cli.mdx‎

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,7 @@ predicate/schema/binding mistakes that fail silently at runtime), and `os dev --
8282
| `os dev [package]` | | Start development mode with hot reload |
8383
| `os serve [config]` | | Start the ObjectStack server with plugin auto-detection |
8484
| `os db clean` | | Reclaim SQLite free space with a one-time `VACUUM` (ADR-0057) |
85+
| `os db orphans` | | Report detail rows whose required master-detail master does not exist; delete them only with `--delete` |
8586

8687
#### `os init`
8788

@@ -423,6 +424,48 @@ os db clean --database file:./data/app.db # explicit target
423424
**Options:**
424425
- `-d, --database <url>` — SQLite database URL/path (defaults to `$OS_DATABASE_URL`, then the per-project dev DB)
425426
427+
#### `os db orphans`
428+
429+
Reports the detail rows whose **required** `master_detail` master does not exist, and —
430+
only behind `--delete` — deletes them. **Report-only by default: without `--delete` it
431+
writes no row or table and creates no file.** It is never run for you; nothing on any
432+
boot, upgrade, migrate or lifecycle path invokes it.
433+
434+
Such a row is written by an elevated (system-context) writer, which the write-time
435+
reference check exempts. Its access derives from a master that does not exist, so no
436+
user door reads or deletes it — an administrator's `GET` and `DELETE` answer 404 — and
437+
the platform's integrity audit reports it without ever rewriting it. This command is
438+
where an operator acts on that report.
439+
440+
The report lists each orphan's object, record id, field and missing master id, and the
441+
deeper detail rows its delete removes with it (the master-detail's own cascade). It
442+
also names, without ever deleting them, the dangling references on those objects that
443+
are not a required master-detail, and counts the references whose target could not be
444+
checked at all: unknown is not missing, and none of those rows is listed or deleted.
445+
446+
```bash
447+
os db orphans # report (writes no rows)
448+
os db orphans --json # the same report, machine-readable
449+
os db orphans --delete --export ./orphans-backup.json
450+
```
451+
452+
**Options:**
453+
- `--delete` — delete the listed orphans and their cascade (default off)
454+
- `--export <path>` — **mandatory with `--delete`**: every record the run deletes, as stored, written
455+
owner-only and read back before the first delete; refuses to overwrite an existing file
456+
- `--max-rows <n>` — rows read per object and in total (default 100000); the report names every object it
457+
could not read in full
458+
- `-y, --yes` — skip the confirmation prompt
459+
- `--database-url <url>`, `--json`
460+
461+
`--delete` deletes only what the same run listed. Just before deleting it reads the
462+
report again and skips an orphan whose master now exists, and it skips one whose cascade
463+
now reaches a record the export does not hold. Each delete is the engine's own
464+
system-context delete, so a `restrict` dependent refuses it (reported as failed, with
465+
nothing of that orphan removed) and a `set_null` reference to a deleted row is cleared.
466+
A roll-up on the missing master cannot be recomputed, and the run says so. Keep the
467+
export until you are certain the sweep was right: it is the only copy.
468+
426469
#### Console UI
427470
428471
Launch the development server with the Console UI:

0 commit comments

Comments
 (0)