Repository navigation
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
Conversation
…s, read-only The operator's remover of orphaned master-detail rows must list, in its dry run, the deeper details its delete reaches. The engine answers that with the cascade set delete() already collects, through the same walk, filing no elevation record (none is a delete). Claude-Session: https://claude.ai/code/session_01QK6pdbxKZiHdFApL8RYVh9 Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QK6pdbxKZiHdFApL8RYVh9 Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QK6pdbxKZiHdFApL8RYVh9 Co-authored-by: Claude <noreply@anthropic.com>
…phan-detail-operator-command
Claude-Session: https://claude.ai/code/session_01QK6pdbxKZiHdFApL8RYVh9 Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 2 package(s): 34 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 6 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 42 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 9da6534f7fbe527c3ded637babcf88f5765863ae && git checkout 9da6534f7fbe527c3ded637babcf88f5765863ae
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin d68aab6fde5fc3ac5f403f67b5bdbd464aa9ea0f 5231f8fd739fa365fb184c20b5450ee4c7a82966 && git checkout -B drift-repro d68aab6fde5fc3ac5f403f67b5bdbd464aa9ea0f && git merge --no-ff 5231f8fd739fa365fb184c20b5450ee4c7a82966
node scripts/docs-audit/affected-docs.mjs --json d68aab6fde5fc3ac5f403f67b5bdbd464aa9ea0f
|
… changeset The PR adds one public method, ObjectQL.inspectCascadeDelete, measured in the published declaration closure of @objectstack/objectql, plus the new os db orphans command. The declaration line lands in the changeset beside the PR body's, as other changesets on main place it. Levels stay minor for both packages. Claude-Session: https://claude.ai/code/session_01QK6pdbxKZiHdFApL8RYVh9 Co-authored-by: Claude <noreply@anthropic.com>
Contract reviewServed-tier: The face that makes this review owed is the ① Derived judgments
② Semver levelClause-②: yes (widening)
③ Boundary flags
Implemented-by: VERDICT: PASS Generated by Claude Code |
席位核对Seat review ·
Generated by Claude Code |
Fixes #24964
Clause-②: yes (widening) (the new public method
ObjectQL.inspectCascadeDeleteon@objectstack/objectql, and the new commandos 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 exportedObjectQLclass. It reaches the published declaration closure:packages/objectql/dist/util-*.d.ts, whichdist/index.d.tsre-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 gradedminor, and the changeset carries the sameClause-②: yes (widening)line.What this does
os db orphansis the operator's door to the detail rows whose requiredmaster_detailmaster 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.os migrate plandoes. It lists each orphan's object, record id, field and missing master id, and the deeper detail rows its delete removes with it.--deleterequires--export PATH. Every record the run deletes is written as stored, owner-only (0600, created withwx), and read back before the first delete. Each delete is the engine's own system-contextdelete(), so the master-detail's cascade removes the deeper details.undeterminedreference (its target object could not be checked), a dangling reference on a field that is not a required master-detail (named inotherDangling), and an orphan whose cascade could not be read (withheld).@objectstack/objectql:inspectCascadeDelete(object, id). A read-only method that returns what a by-iddelete()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
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, includingserve.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.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.--deleterefuses 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.ObjectQL.prototype.deletecall 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.engine.delete(object, { where: { id }, context: { isSystem: true } }). Deeper details come frominspectCascadeDelete. In the objectql test, its listing equals whatdelete()then removes. Declared, as the ruling asks: the dry run lists every record acascaderelation reaches, at any depth. Arestrictdependent 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). Aset_nullreference 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
inspectDanglingReferences({ objects })returned, on a measurement boot (real kernel, SQL driver; fixture namesoc_*),{"objectName":"oc_member","recordId":"…","field":"campaign","target":"oc_campaign","value":"Spring Promo"}indangling. One limit:undeterminedis 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.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 throwsERR_SUMMARY_RECOMPUTE(SummaryRecomputeError,written: true) after the rows are already removed: "Record Spring Promo not found in oc_campaign". The command recognises that code throughSUMMARY_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.os secret orphans:bootSchemaStack(read-only for the report, plain for--delete),--deleteplus--export,wxand0600, a read-back check,--yes, and--jsonas one document. Reused as is:absentTableReads,describeDriverSqliteFilewithJOURNAL_MODE_SWITCH_NOTE, and theformat.tshelpers.PlatformObjectsPluginis composed asos servecomposes it, so the cascade sees the platform's relations.git grep -n "danglingReferences\|inspectDanglingReferences" origin/main -- packages/cli/src packages/rest/src packages/runtime/srchits onlyschema-migrate.teardown.integration.test.ts, a lifecycle-sweep test, so no source door existed. The control, the same grep inpackages/objectql/src, hitsengine.ts(3) andlifecycle-service.ts(3). The HotCRM measurement (an administrator'sGET/DELETEanswer 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 provenblob == HEAD,git diff HEADempty)isRequiredMasterFindingreturnstrue): 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 fromsrc/, so no rebuild is involved."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
0770e4fc9unless 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-servicepassed 18 files and 328 tests (exit 0). The newengine-inspect-cascade-delete.test.tshas 5 cases. Declared narrowing: the package's other test files were not run locally, and CI'sTest Coreruns 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=2passed 318 files and 4699 tests (exit 0).@objectstack/cli, integration tier, targeted at the files this diff touches:orphans.integration.test.tspassed 9/9 (exit 0). Inschema-migrate.one-shot-family.integration.test.tsat26706a69a,-tselected thedb orphansrows, 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, andtest/json-stdout-purity.e2e.test.ts(its single-member spawn is in the acceptance notes).pnpm --filter @objectstack/cli typecheckandpnpm --filter @objectstack/objectql typecheckboth exit 0,check:test-typecheckincluded, with no new debt.node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackderived 105 commands, and every one exits 0.--ranreconciles: "105 derived, 105 run, 0 NOT-MEASURED, 0 UNRUN" (exit 0).43f215942had one real red.check:slot-lookupflagged ananylookup in the new integration test, now typed (0770e4fc9).check-engine-split-ratiorefused on shallow history, and history was deepened.check:skill-examples,check:dual-build-cjs-loadsandcheck:i18n-coveragerefused for missing builds of packages outside this diff. All four are green on the re-run at this head.Acceptance notes
undeterminedbucket 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.isControlledByParentMasterField(@objectstack/spec/data). Its body is exactlytype === 'master_detail' && required === true, and it is the one place the spec spells that. The name speaks ofcontrolled_by_parent; the predicate does not depend on the sharing model. Noted, not filed. Carrier: none.json-stdout-puritye2e (a spawn-tier file that runs every family member in sequence) gains itsdb orphansrow and is declared to CI. Locally, the same spawn was driven for this member alone (tsx bin/run-dev.js db orphans --jsonagainst 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