You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Commit c7368a9
Browse filesBrowse the repository at this point in the historyBrowse files
fix(cli): os db orphans sweeps details whose controlled-by-parent master is optional (#31226)
<!-- claude-projects-attribution: {"github_login":"os-project-manager"}
-->
_Requested by **Leehom** · [project
thread](https://claude.ai/code/project/chan_01TenzsRopZj4jMbvjX8uz1X?thread=cmsg_01TenzsRopZj4jMbvjX8uz1X9EjYahUm1cF2Gdc8QN2miR)_Fixes#30976
Clause-②: no
Metadata-protocol: none
## 维护者速读
- 改了什么:`os db orphans` 现在也扫描「主表可选」的子对象(`controlled_by_parent` 对象唯一的
`master_detail` 声明为 `required: false`,#30905 裁决 A
放行的写法):这类行若指向一条已不存在的主记录,会被列为孤儿(`--delete`
时先导出再随级联删除);主表字段为空的行是合法的,不列出、不删除。
- 为什么改:扫描只认 `required: true` 的主表字段(spec 的
`isControlledByParentMasterField`),PR #30940
引入可选主表后,这类悬空行既没有任何用户入口能读到(访问权从不存在的主记录派生),`os db orphans` 也看不见。
- 风险与回滚:只改 CLI 的运维命令(手动执行、默认只报告不写);删除范围只多出「可选主表且主记录确已不存在」的行,判定复用 spec
已发布的 `controlledByParentMasterOf`,不改 spec。回滚即还原本 PR。
- 先看:`packages/cli/src/utils/orphan-detail-sweep.ts` 的
`masterFieldsOf`(新判定,含 `sharingModel`
限定);`packages/cli/src/commands/db/orphans.integration.test.ts` 新夹具
`od_tip` 与改动的删除断言;`orphan-detail-sweep.test.ts` 新 describe。
- 两分钟验证:`pnpm --filter @objectstack/cli exec vitest run
src/commands/db/orphans.integration.test.ts`,预期 10 个用例全绿,其中 `[#30976]`
用例证明空主表行 `t_none` 不被列出、悬空行 `t_orphan` 被列为孤儿。
- 需要你做的:无,席位落地。
## What changes
- **`packages/cli/src/utils/orphan-detail-sweep.ts`** — the sweep's
"which field is a master" judgement moves into one local predicate,
`masterFieldsOf(object)`:
- every REQUIRED `master_detail` (`isControlledByParentMasterField`,
unchanged — the CONTROL);
- on a `controlled_by_parent` object, its OPTIONAL master:
`controlledByParentMasterOf(fields)` from `@objectstack/spec/data`
answering `optional: true` (the one `master_detail`, declared `required:
false`, with no required one beside it). The spec function reads any
field map and leaves the sharing model to the caller, so the sweep asks
`sharingModel === 'controlled_by_parent'` itself: only such an object
derives its rows' access from that field (plugin-security's cbp read
filter, `security-plugin-cbp-read-filter.ts`, returns no filter for any
other model).
- `objectsWithRequiredMaster` → `objectsWithMaster`,
`isRequiredMasterFinding` → `isMasterFinding` (module-internal to the
CLI; not on any `exports` subpath). Docblocks and the "other dangling"
note no longer say "required master-detail".
- A row whose optional master is EMPTY is never a finding: the
dangling-reference audit treats `null` / `undefined` / `''` as "no link"
(`packages/objectql/src/integrity/dangling-reference-audit.ts`, "Empty
is not a reference"), so the sweep adds no null check of its own.
- **`packages/cli/src/commands/db/orphans.ts`** — the command's
`description`, docblock and human heading said "required master-detail
master"; that became false with this change, so they now say "master"
(description: "Report detail rows whose master-detail reference names a
master that does not exist, …").
- **`content/docs/deployment/cli.mdx`** — the `os db orphans` table row
and section say the same, and name the optional master and the
empty-master row.
- `.changeset/30976-orphans-optional-master.md` — `@objectstack/cli`
patch.
No `packages/spec` edit: the optional-master judgement already exists
there (`controlledByParentMasterOf`, public since PR #30940); the
order's stop-line (a new spec predicate) is not reached.
## Tests
- `packages/cli/src/utils/orphan-detail-sweep.test.ts` (unit, fake
read-only engine):
- fixture gains `sw_optional` (cbp, one `master_detail` `required:
false`, plus a lookup), `sw_optional_private` (the same lone optional
`master_detail` on a `private` object) and `sw_required_cbp` (cbp with a
required master and an optional `master_detail` beside it);
- the existing "reads only the objects that declare a master" case now
expects `sw_optional` and `sw_required_cbp` read, `sw_optional_private`
not;
- new `[#30976]` describe: `masterFieldsOf` per object (CONTROL:
`sw_detail` still answers its two required masters), and a dangling
optional master is an orphan while a dangling lookup on the same object
and a dangling optional `master_detail` beside a required master stay in
`otherDangling`, never deletable.
- `packages/cli/src/commands/db/orphans.integration.test.ts`
(integration, real boot + SQLite): fixture gains `od_tip`, cbp with an
optional master on `od_campaign`, seeded through a system write with
three rows — `t_none` (no campaign), `t_live` (the live campaign),
`t_orphan` (the missing campaign).
- new case `[#30976] an optional master: …`: `t_orphan` is an orphan;
`t_none` and `t_live` appear in neither `orphans` nor `otherDangling`;
- the existing cases now carry the second orphan: the report lists
`od_tip/t_orphan` (counts `orphans: 2`), `--delete` removes it and keeps
`t_none` and `t_live` (`od_tip: ['t_live', 't_none']` after the run),
the export holds it, and in the restrict case the unrestricted `od_tip`
orphan is deleted while `od_member` is refused as before.
- six module-top side-effect imports (`@objectstack/service-datasource`,
`driver-turso`, `runtime`, `platform-objects/plugin`, `plugin-security`,
`rest`) — `check:test-source-alias` asks them of an edited test file
whose source reaches those dynamic imports, copying
`serve-host-crypto-provider.test.ts`.
<details><summary>Runs</summary>
- The three orphan files (`orphan-detail-sweep.test.ts`,
`orphans.integration.test.ts`, `orphans.no-auto-run.test.ts`) on head
`ec530681`: `Test Files 3 passed (3) · Tests 25 passed (25)`.
- `pnpm --filter @objectstack/cli exec vitest run --project unit` on
`0967087c` (the `origin/main` merge; `ec530681` only adds the six
imports to the integration file): `Test Files 2 failed | 396 passed
(398) · Tests 5595 passed | 29 skipped`. The two files were
`test/published-subpath-console.pin.test.ts` and
`test/published-subpath-hook-body.pin.test.ts`, refusing "packages/cli
is not built (./dist/index.js is absent)"; after `pnpm --filter
@objectstack/cli build` both ran: `Test Files 2 passed (2) · Tests 29
passed (29)`.
- `pnpm --filter @objectstack/cli typecheck` (`tsc --noEmit && pnpm
check:test-typecheck`): exit 0.
- The integration layer is run here because the diff edits an
integration-tier file; the rest of that layer is CI's.
</details>
<details><summary>Ablations (both restored to the HEAD blob, `git diff
HEAD` empty after each)</summary>
1. **The optional-master arm** — `node scripts/ablation-replace.mjs
--file packages/cli/src/utils/orphan-detail-sweep.ts --anchor 'if
(master?.optional === true) out.push(master.field);' --delete` (anchor 1
→ 0, blob `7e854c86` → `766c6ecb`), running the unit file and the
integration file: **8 failed | 10 passed (18)**. Red: the three unit
cases above, e.g. `AssertionError: expected [] to deeply equal [ {
object: 'sw_optional', …(3) } ]`, and five integration cases, e.g.
`[#30976] an optional master: …` `AssertionError: expected [
'od_member', 'od_member_note', …(1) ] to include 'od_tip'` and the
report case `expected [ { object: 'od_member', …(3) } ] to deeply equal
[ { object: 'od_member', …(3) }, …(1) ]`. The CONTROL case ("an orphan
is a dangling REQUIRED master-detail …") stayed green under the
ablation.
2. **The `controlled_by_parent` limit** (negative pin: a lone optional
`master_detail` on a `private` object is not a master) — anchor `if
(def?.sharingModel === 'controlled_by_parent') {` replaced by `if (def
!== undefined || def?.sharingModel === 'controlled_by_parent') {` (blob
`7e854c86` → `9bc35e09`), unit file: **2 failed | 6 passed (8)** —
`expected [ 'master' ] to deeply equal []` for `sw_optional_private`,
and the objects list with one extra entry.
The CLI suites resolve `orphan-detail-sweep.ts` and `orphans.ts` from
`src/` (relative imports), so no build leg was needed.
</details>
## Acceptance notes
- **Premise measured, holds.** On `origin/main` `658d84f5`,
`isControlledByParentMasterField`
(`packages/spec/src/data/controlled-by-parent-master.ts`) answers `type
=== 'master_detail' && required === true` only; PR #30940 left it a
required-master predicate and added `controlledByParentMasterOf` beside
it for the optional master. The sweep called only the former.
- **Deleting an optional-master orphan is the master-detail's own
semantics**, which is what the #24964 ruling (record 6303808312) set as
the remedy: a `master_detail` cascades or restricts (`deleteBehavior:
'set_null'` on it is a parse-time refusal, `field.zod.ts`), so the
master's own delete would have removed such a row or been refused while
it stood. Nulling the reference instead would be a different remedy and
is not offered.
- **Not widened:** a lone `required: false` `master_detail` on a non-cbp
object, and one beside a required master on a cbp object, stay "other
dangling" — named, never deleted. A row with an empty optional master is
never read as an orphan.
- **Surface beyond the order's expected surface**
(`orphan-detail-sweep.ts` + its test): `orphans.ts` text,
`content/docs/deployment/cli.mdx`, and `orphans.integration.test.ts`.
The first two said "required master-detail master", which this change
makes false; the integration file is the only place the null-master half
can fail for real (the unit file's audit is a fake, and the audit is
what skips empty values). Declared in the report.
## Verification
Gate union derived by `node scripts/pm/dispatch-gates.mjs --commands
--repo objectstack-ai/objectstack` (92 families for this diff, the same
list at `0967087c` and `ec530681`), run on head **`ec530681`**, each
exit code captured before any pipe, reconciled with `--ran`:
- **92 run, exit 0 each.** `--ran`: "92 derived famil(ies) accounted for
— 92 run, 0 NOT-MEASURED (a DERIVED zero — all 92 recorded an exit code
and none of them is 3)".
- Prerequisites built first: `pnpm --filter '@objectstack/cli^...'
build` (VERDICT command-exit 0), `pnpm --filter @objectstack/cli build`,
and the build closure `check:i18n-coverage` names plus
`@objectstack/client` / `@objectstack/client-react` for
`check:skill-examples` (turbo, VERDICT command-exit 0).
`check-plugin-teardown-shape.mjs --self-test` needed `git fetch
--depth=1 origin 621a487` for its pinned fixture on this shallow clone.
- The first round, on `0967087c`, answered `check:test-source-alias`
exit 1 — the edited integration file reached six dynamic imports with no
module-top load — fixed in `ec530681` (the six imports above), then exit
0; and four exit 3 PREREQUISITE NOT MET (the three above plus
`check:dual-build-cjs-loads`, which on the second round found enough
built `dist/` to measure: "116 published require entry point(s) across
71 package(s) load").
- CI families (every required check) are read from this PR's check-runs,
not re-run here.
`pnpm lint` scoped: `node --stack-size=4000
node_modules/eslint/bin/eslint.js packages/cli --no-inline-config`, exit
0 (the package has no `lint` script); re-run on the edited integration
file at `ec530681`, exit 0.
`node scripts/check-platform-checklist.mjs --changed-from origin/main`
(two module-internal renames): exit 0, "0 anchored files in the change
set".
---
_Generated by [Claude
Code](https://claude.ai/code/session_01FEkQ5madiyLr75cvc8cS31)_
---------
Co-authored-by: Claude <noreply@anthropic.com>
fix(cli): `os db orphans` sweeps details whose controlled-by-parent master is optional
6
+
7
+
Clause-②: no
8
+
Metadata-protocol: none
9
+
10
+
A `controlled_by_parent` object may declare its one master as an optional `master_detail` (`required: false`). `os db orphans` read only required masters, so a detail row of such an object that still named a master that no longer exists was never reported, and `--delete` never removed it. The sweep now reads that optional master too, judged by `controlledByParentMasterOf` from `@objectstack/spec/data`: a row naming a missing master is reported (and deleted with its cascade under `--delete`, after the pre-delete export), and a row that names no master is valid and is never listed. A lone optional `master_detail` on an object that is not `controlled_by_parent`, or one beside a required master, stays an ordinary reference: named, never deleted.
0 commit comments