diff --git a/.changeset/24964-orphan-detail-operator-command.md b/.changeset/24964-orphan-detail-operator-command.md new file mode 100644 index 00000000000..1c68fa241cc --- /dev/null +++ b/.changeset/24964-orphan-detail-operator-command.md @@ -0,0 +1,21 @@ +--- +'@objectstack/cli': minor +'@objectstack/objectql': minor +--- + +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 + +Clause-②: yes (widening) + +**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. + +**`os db orphans` (new command).** An explicit operator command. Nothing on any boot, upgrade, migrate or lifecycle path runs it. + +- **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 }`. +- **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. +- **`--delete` requires `--export `.** 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. +- **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`. + +**`@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. + +Nothing that ran before changes: the audit still reports and never rewrites, and the system-write exemption is unchanged. diff --git a/content/docs/deployment/cli.mdx b/content/docs/deployment/cli.mdx index 80b4a54a45d..21e36533d19 100644 --- a/content/docs/deployment/cli.mdx +++ b/content/docs/deployment/cli.mdx @@ -82,6 +82,7 @@ predicate/schema/binding mistakes that fail silently at runtime), and `os dev -- | `os dev [package]` | | Start development mode with hot reload | | `os serve [config]` | | Start the ObjectStack server with plugin auto-detection | | `os db clean` | | Reclaim SQLite free space with a one-time `VACUUM` (ADR-0057) | +| `os db orphans` | | Report detail rows whose required master-detail master does not exist; delete them only with `--delete` | #### `os init` @@ -423,6 +424,48 @@ os db clean --database file:./data/app.db # explicit target **Options:** - `-d, --database ` — SQLite database URL/path (defaults to `$OS_DATABASE_URL`, then the per-project dev DB) +#### `os db orphans` + +Reports the detail rows whose **required** `master_detail` master does not exist, and — +only behind `--delete` — deletes them. **Report-only by default: without `--delete` it +writes no row or table and creates no file.** It is never run for you; nothing on any +boot, upgrade, migrate or lifecycle path invokes it. + +Such a row is written by an elevated (system-context) writer, which the write-time +reference check exempts. Its access derives from a master that does not exist, so no +user door reads or deletes it — an administrator's `GET` and `DELETE` answer 404 — and +the platform's integrity audit reports it without ever rewriting it. This command is +where an operator acts on that report. + +The report lists each orphan's object, record id, field and missing master id, and the +deeper detail rows its delete removes with it (the master-detail's own cascade). It +also names, without ever deleting them, the dangling references on those objects that +are not a required master-detail, and counts the references whose target could not be +checked at all: unknown is not missing, and none of those rows is listed or deleted. + +```bash +os db orphans # report (writes no rows) +os db orphans --json # the same report, machine-readable +os db orphans --delete --export ./orphans-backup.json +``` + +**Options:** +- `--delete` — delete the listed orphans and their cascade (default off) +- `--export ` — **mandatory with `--delete`**: every record the run deletes, as stored, written + owner-only and read back before the first delete; refuses to overwrite an existing file +- `--max-rows ` — rows read per object and in total (default 100000); the report names every object it + could not read in full +- `-y, --yes` — skip the confirmation prompt +- `--database-url `, `--json` + +`--delete` deletes only what the same run listed. Just before deleting it reads the +report again and skips an orphan whose master now exists, and it skips one whose cascade +now reaches a record the export does not hold. Each delete is the engine's own +system-context delete, so a `restrict` dependent refuses it (reported as failed, with +nothing of that orphan removed) and a `set_null` reference to a deleted row is cleared. +A roll-up on the missing master cannot be recomputed, and the run says so. Keep the +export until you are certain the sweep was right: it is the only copy. + #### Console UI Launch the development server with the Console UI: diff --git a/packages/cli/src/commands/db/orphans.integration.test.ts b/packages/cli/src/commands/db/orphans.integration.test.ts new file mode 100644 index 00000000000..bb4500f028a --- /dev/null +++ b/packages/cli/src/commands/db/orphans.integration.test.ts @@ -0,0 +1,427 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#24964] `os db orphans` against a real boot and a real SQLite database: + * the ruling's constraints, each pinned where it can fail. + * + * ## The fixture + * + * A campaign with a roll-up over its members, members that are required + * master-detail children of a campaign (`controlled_by_parent`), and notes that + * are required master-detail children of a member. Seeded through a SYSTEM + * write, the one door that skips the write-time reference check, as the + * elevated writer that produced these rows in the field did: + * + * - a live member with a live campaign, and its note — the rows that must + * survive; + * - an ORPHAN member whose campaign id names no campaign, and its note — the + * deeper detail its delete's cascade reaches; + * - a member of an object whose master object is not registered at all — its + * reference cannot be probed, so it is UNDETERMINED, never an orphan; + * - a plain lookup naming no campaign, on the live member — dangling, listed, + * never deleted, and no reason to delete the row it sits on; + * - an object with no required master-detail at all — not read. + * + * ## What each case proves + * + * - The default run reports the orphan with its object, record id, field and + * missing master id, and its deeper detail; it writes no row, no table and + * no file. + * - `--delete` writes the export first — the first delete call FINDS it + * complete on disk (the order pin) — and then removes the orphan and its + * cascade, and nothing else. The roll-up on the missing master cannot be + * recomputed, and the run says so instead of failing. + * - The refusals answer their own `error` code and exit 1, with nothing + * deleted. + */ + +import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach, vi } from 'vitest'; +import { randomBytes } from 'node:crypto'; +import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { SqlDriver } from '@objectstack/driver-sql'; +import { ObjectQL, SUMMARY_RECOMPUTE_CODE } from '@objectstack/objectql'; +import { isExitSignal } from '../../utils/format.js'; +import { bootSchemaStack } from '../../utils/schema-migrate.js'; +import DbOrphans from './orphans.js'; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const CLI_ROOT = resolve(HERE, '..', '..', '..'); +const CASE_TIMEOUT_MS = 120_000; +const OVERRIDING_ENV = ['OS_DATABASE_URL', 'DATABASE_URL', 'TURSO_DATABASE_URL', 'OS_DATABASE_DRIVER', 'OS_HOME'] as const; +const SYSTEM = { context: { isSystem: true } }; + +const ARTIFACT = { + manifest: { id: 'com.example.orphan-details', name: 'Orphan Details', version: '0.0.0', type: 'app' }, + objects: [ + { + name: 'od_campaign', + fields: { + name: { type: 'text' }, + member_total: { type: 'summary', summaryOperations: { object: 'od_member', field: 'amount', function: 'sum' } }, + }, + }, + { + name: 'od_member', + sharingModel: 'controlled_by_parent', + fields: { + name: { type: 'text' }, + amount: { type: 'number' }, + campaign: { type: 'master_detail', reference: 'od_campaign', required: true }, + // A plain lookup: when it dangles, the row does not depend on it to exist. + referrer: { type: 'lookup', reference: 'od_campaign' }, + }, + }, + { + name: 'od_member_note', + sharingModel: 'controlled_by_parent', + fields: { body: { type: 'text' }, member: { type: 'master_detail', reference: 'od_member', required: true } }, + }, + { + // Its master object is not registered: no probe of it can run. + name: 'od_ghost_member', + fields: { name: { type: 'text' }, ghost: { type: 'master_detail', reference: 'od_ghost', required: true } }, + }, + { + // A `restrict` lookup to a member: a row of it refuses that member's delete. + name: 'od_hold', + fields: { name: { type: 'text' }, member: { type: 'lookup', reference: 'od_member', deleteBehavior: 'restrict' } }, + }, + { + // No required master-detail field at all: not this command's to read. + name: 'od_ref', + fields: { name: { type: 'text' }, campaign_ref: { type: 'lookup', reference: 'od_campaign' } }, + }, + ], +}; + +const MISSING_CAMPAIGN = 'Spring Promo'; +/** Every row is stamped with one organization, as a tenant's rows are: the audit probes each under its own. */ +const ORG = { organization_id: 'org_od' }; + +let dir: string; +let templateDb: string; +const savedEnv: Record = {}; +const savedCwd = process.cwd(); + +/** A system insert; the roll-up on a missing master refuses AFTER the row is written, as it does in the field. */ +async function seedRow(ql: ObjectQL, object: string, data: Record): Promise { + try { + await ql.insert(object, data, SYSTEM); + } catch (error) { + if ((error as { code?: unknown })?.code !== SUMMARY_RECOMPUTE_CODE) throw error; + } +} + +/** The ids each table holds, read on a connection of our own. */ +async function idsOf(dbFile: string): Promise> { + const driver = new SqlDriver({ client: 'better-sqlite3', connection: { filename: dbFile }, useNullAsDefault: true }); + const k = (driver as any).knex; + try { + const out: Record = {}; + for (const table of ['od_campaign', 'od_member', 'od_member_note', 'od_ghost_member', 'od_hold', 'od_ref']) { + out[table] = ((await k(table).select('id').orderBy('id')) as Array<{ id: string }>).map((r) => r.id); + } + return out; + } finally { + await driver.disconnect(); + } +} + +/** The schema plus every row of every table, as plain JSON. */ +async function readState(dbFile: string): Promise { + const driver = new SqlDriver({ client: 'better-sqlite3', connection: { filename: dbFile }, useNullAsDefault: true }); + const k = (driver as any).knex; + try { + const schema = await k.raw('SELECT type, name, sql FROM sqlite_master ORDER BY type, name'); + const rows: Record = {}; + for (const entry of schema as Array<{ type: string; name: string }>) { + if (entry.type !== 'table' || entry.name.startsWith('sqlite_')) continue; + rows[entry.name] = await k.raw(`SELECT * FROM "${entry.name}" ORDER BY rowid`); + } + return { schema, rows }; + } finally { + await driver.disconnect(); + } +} + +interface RunResult { payload: any; exitCode: number } + +async function runJson(argv: string[]): Promise { + const savedExit = process.exitCode; + const swallow = ((_chunk: unknown, ...rest: unknown[]) => { + const cb = rest.find((a) => typeof a === 'function') as (() => void) | undefined; + if (cb) cb(); + return true; + }) as typeof process.stdout.write; + const stdout = vi.spyOn(process.stdout, 'write').mockImplementation(swallow); + const stderr = vi.spyOn(process.stderr, 'write').mockImplementation(swallow); + const logs = [vi.spyOn(console, 'log'), vi.spyOn(console, 'warn'), vi.spyOn(console, 'error')] + .map((s) => s.mockImplementation(() => {})); + let thrownExit: number | undefined; + try { + try { + await DbOrphans.run(argv, { root: CLI_ROOT }); + } catch (error) { + if (!isExitSignal(error)) throw error; + thrownExit = (error as { oclif?: { exit?: number } }).oclif?.exit; + } + const out = stdout.mock.calls.map((c) => String(c[0])).join('').trim(); + let payload: any; + try { payload = JSON.parse(out); } catch { payload = { unparsed: out.slice(-400) }; } + return { payload, exitCode: thrownExit ?? (process.exitCode as number | undefined) ?? 0 }; + } finally { + process.exitCode = savedExit; + stdout.mockRestore(); + stderr.mockRestore(); + for (const s of logs) s.mockRestore(); + } +} + +let caseSeq = 0; +function prepareCase(): { dbFile: string; caseDir: string; url: string } { + caseSeq += 1; + const caseDir = join(dir, 'cases', String(caseSeq)); + mkdirSync(caseDir, { recursive: true }); + const dbFile = join(caseDir, 'app.db'); + copyFileSync(templateDb, dbFile); + return { dbFile, caseDir, url: `file:${dbFile}` }; +} + +beforeAll(async () => { + for (const key of [...OVERRIDING_ENV, 'OS_ARTIFACT_PATH', 'NODE_ENV', 'OS_SECRET_KEY'] as const) savedEnv[key] = process.env[key]; + for (const key of OVERRIDING_ENV) delete process.env[key]; + process.env.NODE_ENV = 'production'; + process.env.OS_SECRET_KEY = randomBytes(32).toString('hex'); + dir = mkdtempSync(join(tmpdir(), 'os-24964-')); + mkdirSync(join(dir, 'dist'), { recursive: true }); + mkdirSync(join(dir, 'data'), { recursive: true }); + process.env.OS_ARTIFACT_PATH = join(dir, 'dist', 'objectstack.json'); + writeFileSync(process.env.OS_ARTIFACT_PATH, JSON.stringify(ARTIFACT)); + templateDb = join(dir, 'data', 'template.db'); + process.chdir(dir); + const stack = await bootSchemaStack({ jsonOutput: false, databaseUrl: `file:${templateDb}`, projectRoot: dir }); + try { + const ql = stack.kernel.getService('objectql') as ObjectQL; + await seedRow(ql, 'od_campaign', { ...ORG, id: 'c_live', name: 'Live' }); + await seedRow(ql, 'od_member', { ...ORG, id: 'm_live', name: 'kept', amount: 5, campaign: 'c_live', referrer: 'Gone Campaign' }); + await seedRow(ql, 'od_member', { ...ORG, id: 'm_orphan', name: 'orphan', amount: 7, campaign: MISSING_CAMPAIGN }); + await seedRow(ql, 'od_member_note', { ...ORG, id: 'n_live', body: 'kept', member: 'm_live' }); + await seedRow(ql, 'od_member_note', { ...ORG, id: 'n_orphan', body: 'goes with its member', member: 'm_orphan' }); + await seedRow(ql, 'od_ghost_member', { ...ORG, id: 'g_row', name: 'unknown master', ghost: 'nobody_knows' }); + await seedRow(ql, 'od_ref', { ...ORG, id: 'r_row', name: 'dangling lookup', campaign_ref: 'Gone Campaign' }); + } finally { + await stack.shutdown(); + } + process.chdir(savedCwd); + // Non-vacuity: the seed is the state every case starts from. + expect(await idsOf(templateDb)).toEqual({ + od_campaign: ['c_live'], + od_member: ['m_live', 'm_orphan'], + od_member_note: ['n_live', 'n_orphan'], + od_ghost_member: ['g_row'], + od_hold: [], + od_ref: ['r_row'], + }); +}, 180_000); + +afterAll(() => { + process.chdir(savedCwd); + for (const [key, value] of Object.entries(savedEnv)) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + try { rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ } +}); + +beforeEach(() => { process.chdir(dir); }); +afterEach(() => { process.chdir(savedCwd); vi.restoreAllMocks(); }); + +describe('[#24964] os db orphans — the default run is a report that writes nothing', () => { + it('lists the orphan with object, record id, field and missing master id, and the deeper detail its delete removes', async () => { + const c = prepareCase(); + const before = await readState(c.dbFile); + + const { payload, exitCode } = await runJson(['--database-url', c.url, '--json']); + + expect(exitCode).toBe(0); + expect(payload.mode).toBe('report'); + expect(payload.plan.orphans).toEqual([{ + object: 'od_member', + recordId: 'm_orphan', + fields: [{ field: 'campaign', master: 'od_campaign', missingMasterId: MISSING_CAMPAIGN }], + cascade: [{ object: 'od_member_note', id: 'n_orphan' }], + }]); + expect(payload.plan.counts).toMatchObject({ orphans: 1, deeperDetails: 1, withheld: 0 }); + // Zero writes: no row, no table, and no file beside the database. + expect(await readState(c.dbFile)).toEqual(before); + expect(existsSync(join(c.caseDir, 'export.json'))).toBe(false); + }, CASE_TIMEOUT_MS); + + it('an unprobeable master is UNDETERMINED, and a dangling plain lookup is named — neither is an orphan', async () => { + const c = prepareCase(); + + const { payload } = await runJson(['--database-url', c.url, '--json']); + + // The ghost master's object is not registered, so its probe cannot run. + expect(payload.plan.undetermined).toBeGreaterThanOrEqual(1); + expect(payload.plan.complete).toBe(false); + const orphanIds = payload.plan.orphans.map((o: { recordId: string }) => o.recordId); + expect(orphanIds).not.toContain('g_row'); + // The scan READ the ghost's object: it is undetermined, not unscanned. + expect(payload.plan.objects).toContain('od_ghost_member'); + // A dangling lookup does not make its row an orphan; it is named for the operator. + expect(orphanIds).not.toContain('m_live'); + expect(payload.plan.otherDangling).toEqual([{ + objectName: 'od_member', recordId: 'm_live', field: 'referrer', target: 'od_campaign', value: 'Gone Campaign', + }]); + // An object with no required master-detail is not this command's to read. + expect(payload.plan.objects).not.toContain('od_ref'); + }, CASE_TIMEOUT_MS); +}); + +describe('[#24964] os db orphans --delete', () => { + it('writes the export first, then removes the orphan and its cascade and nothing else', async () => { + const c = prepareCase(); + const exportPath = join(c.caseDir, 'export.json'); + + const { payload, exitCode } = await runJson(['--delete', '--export', exportPath, '--yes', '--database-url', c.url, '--json']); + + expect(payload.error, JSON.stringify(payload).slice(0, 400)).toBeUndefined(); + expect(exitCode).toBe(0); + expect(payload.deleted).toEqual([expect.objectContaining({ + object: 'od_member', + recordId: 'm_orphan', + removed: [{ object: 'od_member', id: 'm_orphan' }, { object: 'od_member_note', id: 'n_orphan' }], + })]); + // The roll-up on the master that does not exist cannot be recomputed; the run says so, and deleted anyway. + expect(payload.deleted[0].notes.join(' ')).toMatch(/od_campaign\.member_total was not recomputed for Spring Promo/); + expect(payload.failed).toEqual([]); + + expect(await idsOf(c.dbFile)).toEqual({ + od_campaign: ['c_live'], + od_member: ['m_live'], + od_member_note: ['n_live'], + od_ghost_member: ['g_row'], + od_hold: [], + od_ref: ['r_row'], + }); + + const doc = JSON.parse(readFileSync(exportPath, 'utf8')); + expect(doc.format).toBe('objectstack.orphan-detail.pre-delete-export.v1'); + expect(doc.rows.map((r: { object: string; id: string }) => `${r.object}/${r.id}`)).toEqual([ + 'od_member/m_orphan', 'od_member_note/n_orphan', + ]); + expect(doc.rows[0].row).toMatchObject({ id: 'm_orphan', name: 'orphan', campaign: MISSING_CAMPAIGN }); + expect(doc.rows[1].row).toMatchObject({ id: 'n_orphan', body: 'goes with its member', member: 'm_orphan' }); + }, CASE_TIMEOUT_MS); + + it('the first delete finds the export already on disk, holding every record the run deletes', async () => { + const c = prepareCase(); + const exportPath = join(c.caseDir, 'export.json'); + const original = ObjectQL.prototype.delete; + let seenAtFirstDelete: string[] | 'no file' | undefined; + // Intercept the command's own delete, and only that one: the boot may delete + // rows of its own, and those are not this pin's. + vi.spyOn(ObjectQL.prototype, 'delete').mockImplementation(async function (this: ObjectQL, object: string, options?: any) { + if (object === 'od_member' && seenAtFirstDelete === undefined) { + seenAtFirstDelete = existsSync(exportPath) + ? JSON.parse(readFileSync(exportPath, 'utf8')).rows.map((r: { object: string; id: string }) => `${r.object}/${r.id}`) + : 'no file'; + throw Object.assign(new Error('injected: the first delete refuses'), { code: 'INJECTED_REFUSAL' }); + } + return original.call(this, object, options); + }); + + const { payload, exitCode } = await runJson(['--delete', '--export', exportPath, '--yes', '--database-url', c.url, '--json']); + + // Non-vacuity: the command reached its delete through the intercepted method. + expect(seenAtFirstDelete).toBeDefined(); + expect(seenAtFirstDelete).toEqual(['od_member/m_orphan', 'od_member_note/n_orphan']); + expect(exitCode).toBe(1); + expect(payload.failed).toEqual([expect.objectContaining({ object: 'od_member', recordId: 'm_orphan', code: 'INJECTED_REFUSAL' })]); + // Refused, so nothing went. + expect((await idsOf(c.dbFile)).od_member).toEqual(['m_live', 'm_orphan']); + expect((await idsOf(c.dbFile)).od_member_note).toEqual(['n_live', 'n_orphan']); + }, CASE_TIMEOUT_MS); +}); + +describe('[#24964] os db orphans --delete — the delete is the engine\'s own', () => { + it('a restrict dependent refuses the orphan\'s delete, and nothing of its cascade is removed', async () => { + const c = prepareCase(); + const exportPath = join(c.caseDir, 'export.json'); + // A row outside the cascade that holds the orphan through a `restrict` lookup. + const driver = new SqlDriver({ client: 'better-sqlite3', connection: { filename: c.dbFile }, useNullAsDefault: true }); + try { + await (driver as any).knex('od_hold').insert({ id: 'h_row', name: 'holds it', member: 'm_orphan' }); + } finally { + await driver.disconnect(); + } + + const { payload, exitCode } = await runJson(['--delete', '--export', exportPath, '--yes', '--database-url', c.url, '--json']); + + expect(exitCode).toBe(1); + expect(payload.deleted).toEqual([]); + expect(payload.failed).toEqual([expect.objectContaining({ + object: 'od_member', recordId: 'm_orphan', code: 'DELETE_RESTRICTED', + remaining: [{ object: 'od_member', id: 'm_orphan' }, { object: 'od_member_note', id: 'n_orphan' }], + })]); + // One unit of work: the note its cascade reached first is back too. + expect(await idsOf(c.dbFile)).toMatchObject({ + od_member: ['m_live', 'm_orphan'], od_member_note: ['n_live', 'n_orphan'], od_hold: ['h_row'], + }); + }, CASE_TIMEOUT_MS); +}); + +describe('[#24964] os db orphans — refusals answer their code and exit 1, with nothing deleted', () => { + it('--delete without --export is refused before anything boots', async () => { + const c = prepareCase(); + const before = await readState(c.dbFile); + + const { payload, exitCode } = await runJson(['--delete', '--yes', '--database-url', c.url, '--json']); + + expect(payload.error).toBe('export_required'); + expect(exitCode).toBe(1); + expect(await readState(c.dbFile)).toEqual(before); + }, CASE_TIMEOUT_MS); + + it('an export path that cannot be written refuses the run, with no row deleted', async () => { + const c = prepareCase(); + const exportPath = join(c.caseDir, 'no-such-directory', 'export.json'); + + const { payload, exitCode } = await runJson(['--delete', '--export', exportPath, '--yes', '--database-url', c.url, '--json']); + + expect(payload.error).toBe('export_failed'); + expect(exitCode).toBe(1); + expect(existsSync(exportPath)).toBe(false); + expect((await idsOf(c.dbFile)).od_member).toEqual(['m_live', 'm_orphan']); + expect((await idsOf(c.dbFile)).od_member_note).toEqual(['n_live', 'n_orphan']); + }, CASE_TIMEOUT_MS); + + it('an export path that already exists is refused, and left as it was', async () => { + const c = prepareCase(); + const exportPath = join(c.caseDir, 'export.json'); + writeFileSync(exportPath, 'not ours'); + + const { payload, exitCode } = await runJson(['--delete', '--export', exportPath, '--yes', '--database-url', c.url, '--json']); + + expect(payload.error).toBe('export_exists'); + expect(exitCode).toBe(1); + expect(readFileSync(exportPath, 'utf8')).toBe('not ours'); + expect((await idsOf(c.dbFile)).od_member).toEqual(['m_live', 'm_orphan']); + }, CASE_TIMEOUT_MS); + + it('--delete under --json without --yes asks for it, with nothing written', async () => { + const c = prepareCase(); + const exportPath = join(c.caseDir, 'export.json'); + + const { payload, exitCode } = await runJson(['--delete', '--export', exportPath, '--database-url', c.url, '--json']); + + expect(payload.error).toBe('confirmation_required'); + expect(exitCode).toBe(1); + expect(existsSync(exportPath)).toBe(false); + expect((await idsOf(c.dbFile)).od_member).toEqual(['m_live', 'm_orphan']); + }, CASE_TIMEOUT_MS); +}); diff --git a/packages/cli/src/commands/db/orphans.no-auto-run.test.ts b/packages/cli/src/commands/db/orphans.no-auto-run.test.ts new file mode 100644 index 00000000000..34cc86cff53 --- /dev/null +++ b/packages/cli/src/commands/db/orphans.no-auto-run.test.ts @@ -0,0 +1,107 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#24964] Nothing runs `os db orphans` for you. That is the ruling (record + * 6303808312, constraint 1: an explicit operator command, and nothing on any + * boot, upgrade, migrate or lifecycle-sweep path invokes it or may grow such a + * caller), and this is the pin that keeps it true. + * + * The distance between an operator command and an auto-remedy is one import: + * a boot path that reaches for this module, or for the plan it deletes from, + * deletes business data with nobody watching. So the pin reads the source for + * every way a caller could reach it — the command module, the plan module, and + * the engine's cascade inspection the plan reads — and admits only this + * command's own files and its tests. + * + * ## A negative result is evidence only after the instrument has produced a + * ## positive one + * + * "No file imports it" is also what a broken scanner says. So every absence + * below is preceded by the SAME scanner finding a call site that really + * exists, including one on the boot path itself (`commands/serve.ts` reaching + * the `kernel:ready` migration gate through a dynamic import, the shape an + * accidental auto-run would take). The shape is `multi-value-columns.no-auto-run.test.ts`'s. + */ + +import { describe, it, expect } from 'vitest'; +import { readFileSync, readdirSync, statSync } from 'node:fs'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +/** `packages/cli/src` — this package only; the scan never leaves it. */ +const SRC_ROOT = fileURLToPath(new URL('../../', import.meta.url)); + +function everyTsFile(dir: string, out: string[] = []): string[] { + for (const entry of readdirSync(dir)) { + const full = join(dir, entry); + if (statSync(full).isDirectory()) everyTsFile(full, out); + else if (entry.endsWith('.ts')) out.push(full); + } + return out; +} + +const ALL_FILES = everyTsFile(SRC_ROOT); +const relative = (file: string) => file.slice(SRC_ROOT.length).replaceAll('\\', '/'); +const isTestFile = (path: string) => /\.test\.ts$/.test(path); + +/** A static `from '…'` or a dynamic `import('…')` specifier. Read over raw text: prose naming one is a loud false positive, never a hole. */ +const IMPORT_SPECIFIER = /(?:\bfrom\s*|\bimport\s*\(\s*)['"]([^'"]+)['"]/g; + +/** Files whose relative import RESOLVES to `target` (a path under `src/`), whatever the specifier's spelling. */ +function importersOf(target: string): string[] { + const wanted = join(SRC_ROOT, target); + return ALL_FILES.filter((file) => { + for (const m of readFileSync(file, 'utf8').matchAll(IMPORT_SPECIFIER)) { + const spec = m[1]!; + if (!spec.startsWith('.')) continue; + if (resolve(dirname(file), spec).replace(/\.js$/, '.ts') === wanted) return true; + } + return false; + }).map(relative); +} + +const COMMAND = 'commands/db/orphans.ts'; +const PLAN = 'utils/orphan-detail-sweep.ts'; +/** The one-shot boot family's enumeration pin RUNS every bootSchemaStack caller's modes; a test, never a boot path. */ +const FAMILY_PIN = 'utils/schema-migrate.one-shot-family.integration.test.ts'; + +describe('[#24964] the scanner works (positive controls)', () => { + it('finds the CLI’s own boot path reaching the kernel:ready migration gate — a DYNAMIC import', () => { + expect(importersOf('utils/artifact-boot-migration.ts').filter((p) => !isTestFile(p))).toContain('commands/serve.ts'); + }); + + it('finds this command reaching its plan module, and the plan calling the cascade inspection', () => { + expect(importersOf(PLAN)).toContain(COMMAND); + expect(readFileSync(join(SRC_ROOT, PLAN), 'utf8')).toContain('.inspectCascadeDelete('); + }); + + it('tells this command apart from the two other `orphans.ts` commands', () => { + // `./orphans.js` is also how `os secret orphans` and `os storage orphans` are imported by their tests. + expect(importersOf('commands/secret/orphans.ts')).toContain('commands/secret/orphans.guards.test.ts'); + expect(importersOf(COMMAND)).not.toContain('commands/secret/orphans.guards.test.ts'); + }); + + it('the file population itself is real', () => { + expect(ALL_FILES.length).toBeGreaterThan(100); + expect(ALL_FILES.map(relative)).toContain(COMMAND); + }); +}); + +describe('[#24964] nothing on a boot, upgrade, migrate or lifecycle path invokes this command', () => { + it('no source file imports the command — its own tests and the family pin are the only readers', () => { + const importers = importersOf(COMMAND); + expect(importers.filter((p) => !isTestFile(p))).toEqual([]); + expect(importers.every((p) => p.startsWith('commands/db/orphans.') || p === FAMILY_PIN), importers.join(', ')).toBe(true); + }); + + it('no source file but the command imports the plan it deletes from', () => { + expect(importersOf(PLAN).filter((p) => !isTestFile(p))).toEqual([COMMAND]); + }); + + it('no source file but the command and its plan calls the engine’s cascade inspection', () => { + const callers = ALL_FILES + .filter((f) => !isTestFile(relative(f)) && readFileSync(f, 'utf8').includes('.inspectCascadeDelete(')) + .map(relative); + expect(callers.sort()).toEqual([COMMAND, PLAN].sort()); + }); +}); diff --git a/packages/cli/src/commands/db/orphans.ts b/packages/cli/src/commands/db/orphans.ts new file mode 100644 index 00000000000..ef2c7dc2817 --- /dev/null +++ b/packages/cli/src/commands/db/orphans.ts @@ -0,0 +1,444 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { Command, Flags } from '@oclif/core'; +import { createInterface } from 'node:readline'; +import { existsSync, readFileSync, writeFileSync } from 'node:fs'; +import { resolve as resolvePath } from 'node:path'; +import chalk from 'chalk'; +import { + printHeader, + printSuccess, + printWarning, + printError, + printInfo, + printStep, + createTimer, + emitJson, + errorCodeFields, + isExitSignal, +} from '../../utils/format.js'; +import { bootSchemaStack } from '../../utils/schema-migrate.js'; +import { absentTableReads } from '../../utils/absent-table-reads.js'; +import { describeDriverSqliteFile } from '../../utils/connection-display.js'; +import { JOURNAL_MODE_SWITCH_NOTE } from '../../utils/sqlite-occupancy.js'; +import type { + OrphanDetailExport, + OrphanDetailPlan, + OrphanDetailRow, + RecordRef, +} from '../../utils/orphan-detail-sweep.js'; + +/** + * `os db orphans` — the operator's door to the detail rows whose required + * master-detail master does not exist (#24964). + * + * ## Why such a row needs a door, and why this is the one + * + * 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. The row's + * access derives from that master, so every user door — the administrator's + * included — answers 404 for it, and the dangling-reference audit reports it + * and, by design, never rewrites it ("Reports; never rewrites", + * `packages/objectql/src/integrity/dangling-reference-audit.ts`). The remedy is + * a judgement for a person. This command is where that person acts. + * + * Removing such rows deletes business data, so the vehicle was the + * maintainer's decision (ruling record 6303808312, option A, on the template + * of the `os secret orphans` ruling, records 5271021652 and 5421208707): + * + * 1. An explicit operator command. ⛔ Nothing on any boot, upgrade, migrate + * or lifecycle-sweep path invokes it, and it must not grow such a caller + * (`orphans.no-auto-run.test.ts` holds it). + * 2. The default run is a dry-run report: each detail row whose required + * master-detail master does not exist — object, record id, field, and the + * missing master id. + * 3. Deletion only behind `--delete`, and only of the rows the same run's + * report lists. + * 4. A mandatory pre-delete export of every row the run deletes, written and + * read back before the first delete. + * 5. Deletion applies the master-detail's own delete semantics: the engine's + * by-id delete, whose cascade removes the deeper details. The deeper + * details it reaches are listed in the report too, read from the walk that + * delete runs (`ObjectQL.inspectCascadeDelete`). + * + * Out of the ruling, so not here: a lifecycle auto-remedy, any change to how + * access derives from a master, and the system-write exemption itself. + * + * ## The safety properties, and where each is checked + * + * - **Unknown is not missing.** An orphan is a finding in the audit's + * `dangling` bucket: its probe RAN and found no master. A reference whose + * target could not be probed is `undetermined`, counted, and never deleted. + * An orphan whose cascade could not be read is withheld. + * - **Only listed rows go.** Just before the first delete the audit is run + * again over the same objects, and the cascade of each orphan is read again + * just before its delete. An orphan whose master has since appeared is + * skipped, and so is one whose cascade now reaches a record the export does + * not hold. The window that remains is the one between that second read and + * the delete itself. + * - **The export comes first and is checked.** Written with owner-only + * permissions, created or not written at all (`wx`), read back, and every + * record the run would delete must be in it. Any failure refuses the whole + * run with nothing deleted. + * - **A delete is judged by what is gone, never by what it returned.** After + * each delete, every record of its cascade is read back. A delete whose roll-up + * recompute failed on the master it names, which does not exist, has still + * removed its rows (`SummaryRecomputeError` says so), and is reported as + * deleted with that note. + * + * Not previewed, and said so in the report: a `restrict` dependent outside the + * cascade refuses the delete when it runs (reported as failed, and a cascade on + * one datasource is one unit of work, so nothing of it is removed), and a + * `set_null` reference to a deleted record is cleared, as any delete clears it. + */ +export default class DbOrphans extends Command { + static override description = + 'Report detail rows whose required master-detail master does not exist, and (only with --delete) delete them ' + + 'with their cascade, after writing a pre-delete export. Report-only by default: it writes no row or table.'; + + static override examples = [ + '<%= config.bin %> db orphans', + '<%= config.bin %> db orphans --json', + '<%= config.bin %> db orphans --delete --export ./orphans-backup.json', + ]; + + static override flags = { + 'database-url': Flags.string({ + description: 'Database URL to inspect (defaults to $OS_DATABASE_URL / the project DB)', + env: 'OS_DATABASE_URL', + }), + delete: Flags.boolean({ + description: + 'Delete the listed orphans and the deeper details their cascade removes (default is a report that writes ' + + 'no row or table). Requires --export.', + default: false, + }), + export: Flags.string({ + description: + 'Path for the MANDATORY pre-delete export: every record the run deletes, as stored, written and read back ' + + 'before the first delete. Refuses to overwrite.', + }), + 'max-rows': Flags.integer({ + description: + 'Rows read per object, and in total. The report names every object it could not read in full.', + default: 100_000, + min: 1, + }), + yes: Flags.boolean({ char: 'y', description: 'Skip the --delete confirmation prompt', default: false }), + json: Flags.boolean({ + description: 'Output as JSON (implies non-interactive; requires --yes to delete)', + default: false, + }), + }; + + async run(): Promise { + const { flags } = await this.parse(DbOrphans); + const timer = createTimer(); + const json = flags.json; + + if (!json) printHeader('Database · detail rows whose master is missing'); + + if (flags.delete && !flags.export) { + const message = + 'Refusing to delete without --export. The pre-delete export is mandatory: it is the only copy of the ' + + 'records this run deletes, and those rows are business data no user door can read. Name a path for it.'; + if (json) { await emitJson({ error: 'export_required', message }, 1, { compact: true }); return; } + printError(message); + this.exit(1); + return; + } + + const exportPath = flags.export ? resolvePath(flags.export) : undefined; + if (exportPath && existsSync(exportPath)) { + const message = `Refusing to overwrite an existing export at ${exportPath}. Name a new path.`; + if (json) { await emitJson({ error: 'export_exists', message, path: exportPath }, 1, { compact: true }); return; } + printError(message); + this.exit(1); + return; + } + + if (!json) printStep(flags.delete ? 'Booting (DELETE mode)…' : 'Booting (report only)…'); + + // Loaded at the point of use, never at module load: oclif `import()`s every + // command module on every invocation while building its table. + const { planOrphanDetailSweep, buildOrphanDetailExport, exportedKeys, recordKey, recordsToDelete } = + await import('../../utils/orphan-detail-sweep.js'); + const { SUMMARY_RECOMPUTE_CODE } = await import('@objectstack/objectql'); + const { PlatformObjectsPlugin } = await import('@objectstack/platform-objects/plugin'); + + let stack; + try { + stack = await bootSchemaStack({ + jsonOutput: json, + databaseUrl: flags['database-url'], + // The platform's objects are registered as `os serve` registers them, so + // the cascade a delete runs here reaches the relations it reaches there. + extraPlugins: [new PlatformObjectsPlugin()], + // The report boots READ-ONLY, the boot `os migrate plan` takes. `--delete` + // keeps the plain boot: it deletes rows. + ...(flags.delete ? {} : { deferSchemaDdl: true, readOnlyProbe: true }), + }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (json) { await emitJson({ error: 'boot_failed', message }, 1, { compact: true }); return; } + printError(message); + this.exit(1); + return; + } + + try { + // Either mode's connect may have switched a SQLite file's journal mode, + // which rewrites its header and no row, so no line below says nothing changed. + const journalNote = describeDriverSqliteFile(stack.driver?.config) !== undefined + ? ` ${JOURNAL_MODE_SWITCH_NOTE}` + : ''; + const engine = stack.kernel.getService('objectql') as OrphanEngine | undefined; + if (!engine) { + const message = 'No ObjectQL engine on this runtime, so no row can be read.'; + if (json) { await emitJson({ error: 'no_engine', message }, 1, { compact: true }); return; } + printError(message); + this.exit(1); + return; + } + + // A table the read-only boot measured absent stores nothing, so it is not read. + const reads = absentTableReads(stack, (object) => engine.getConfigs()[object]); + const plan = await planOrphanDetailSweep({ engine, maxRows: flags['max-rows'], absent: reads.absent }); + reads.notice(json); + + if (!flags.delete) { + if (json) { await emitJson({ mode: 'report', plan }, 0, { compact: true }); return; } + renderPlan(plan); + printInfo(`Report only — no row or table was written or deleted.${journalNote} (${timer.display()})`); + if (plan.orphans.length > 0) printInfo('Re-run with --delete --export to delete the listed orphans.'); + return; + } + + const doomed = recordsToDelete(plan); + if (doomed.length === 0) { + if (json) { await emitJson({ mode: 'delete', deleted: [], skipped: [], failed: [], plan }, 0, { compact: true }); return; } + renderPlan(plan); + printSuccess('Nothing to delete.'); + return; + } + + if (!json) { + renderPlan(plan); + printWarning( + `About to permanently delete ${plan.orphans.length} orphan row(s) and ${doomed.length - plan.orphans.length} ` + + 'deeper detail row(s) their cascade removes. The export below is the only copy that survives.', + ); + } + if (!flags.yes) { + if (json) { await emitJson({ error: 'confirmation_required', hint: 'pass --yes' }, 1, { compact: true }); return; } + const ok = await confirm(chalk.yellow(` Delete ${doomed.length} row(s) after writing the export? [y/N] `)); + if (!ok) { printInfo(`Aborted — no export was written and no row was deleted.${journalNote}`); return; } + } + + // ── The export, written and READ BACK before anything is deleted ────── + let verified: Set; + try { + const rawRows = new Map>(); + for (const ref of doomed) { + const row = await readStoredRow(engine, ref); + if (row) rawRows.set(recordKey(ref.object, ref.id), row); + } + const doc = buildOrphanDetailExport({ plan, rawRows, producedBy: `${this.config.bin} db orphans --delete` }); + // Owner-only, and CREATED here or not written at all: the existence check + // above runs before the boot and the prompt, so it is a pre-check, never + // the guarantee. `wx` makes the guarantee atomic. Then read back rather + // than trusting the write: a short or unreadable file would leave the + // delete with no copy at all, and look like a clean run. + writeFileSync(exportPath!, `${JSON.stringify(doc, null, 2)}\n`, { encoding: 'utf8', mode: 0o600, flag: 'wx' }); + verified = exportedKeys(JSON.parse(readFileSync(exportPath!, 'utf8')) as OrphanDetailExport); + } catch (error) { + const message = + `Refusing to delete: the pre-delete export could not be written and read back at ${exportPath} — ` + + `${error instanceof Error ? error.message : String(error)}`; + if (json) { await emitJson({ error: 'export_failed', message }, 1, { compact: true }); return; } + printError(message); + this.exit(1); + return; + } + const missing = doomed.filter((r) => !verified.has(recordKey(r.object, r.id))); + if (missing.length > 0) { + const message = + `Refusing to delete: the export at ${exportPath} does not hold ${missing.length} of the ${doomed.length} ` + + `record(s) to be deleted (${missing.map((r) => `${r.object}/${r.id}`).join(', ')}).`; + if (json) { await emitJson({ error: 'export_incomplete', message, missing }, 1, { compact: true }); return; } + printError(message); + this.exit(1); + return; + } + if (!json) printSuccess(`Pre-delete export written (owner-only): ${exportPath}`); + + // ── Only rows the report listed, and only while they still are orphans ─ + const recheck = await planOrphanDetailSweep({ engine, maxRows: flags['max-rows'] }); + const stillOrphaned = new Set( + recheck.orphans.flatMap((o) => o.fields.map((f) => orphanFieldKey(o.object, o.recordId, f.field, f.missingMasterId))), + ); + + const deleted: Array<{ object: string; recordId: string; removed: RecordRef[]; notes: string[] }> = []; + const skipped: Array<{ object: string; recordId: string; reason: string; unlisted?: RecordRef[] }> = []; + const failed: Array<{ object: string; recordId: string; message: string; code?: string; remaining?: RecordRef[] }> = []; + const removed = new Set(); + + for (const orphan of plan.orphans) { + const { object, recordId } = orphan; + if (removed.has(recordKey(object, recordId))) { + skipped.push({ object, recordId, reason: 'already removed by the cascade of an orphan deleted before it' }); + continue; + } + if (!orphan.fields.every((f) => stillOrphaned.has(orphanFieldKey(object, recordId, f.field, f.missingMasterId)))) { + skipped.push({ + object, recordId, + reason: 'no longer an orphan as reported: its master now exists, or the row changed since the report', + }); + continue; + } + let cascade: RecordRef[]; + try { + cascade = await engine.inspectCascadeDelete(object, recordId); + } catch (error) { + failed.push({ object, recordId, message: `its cascade could not be read again, so it was not deleted: ${messageOf(error)}` }); + continue; + } + const unlisted = cascade.filter((r) => !verified.has(recordKey(r.object, r.id))); + if (unlisted.length > 0) { + skipped.push({ + object, recordId, unlisted, + reason: 'its cascade now reaches records the report did not list and the export does not hold', + }); + continue; + } + + const notes: string[] = []; + let refusal: unknown; + try { + await engine.delete(object, { where: { id: recordId }, context: { isSystem: true } }); + } catch (error) { + const e = error as { code?: unknown; failures?: Array<{ parentObject?: string; parentId?: string; field?: string }> }; + if (e?.code === SUMMARY_RECOMPUTE_CODE) { + // The rows WERE removed; a roll-up on the master could not be recomputed. + for (const f of e.failures ?? []) { + const onMissingMaster = orphan.fields.some((o) => o.master === f.parentObject && o.missingMasterId === f.parentId); + notes.push(onMissingMaster + ? `the roll-up ${f.parentObject}.${f.field} was not recomputed for ${f.parentId}, the master that does not exist` + : `the roll-up ${f.parentObject}.${f.field} could not be recomputed for ${f.parentId}: it may be stale`); + } + } else { + refusal = error; + } + } + + // Judged by what is gone, never by what the call returned. + const remaining: RecordRef[] = []; + for (const ref of cascade) { + if (await readStoredRow(engine, ref)) remaining.push(ref); + else removed.add(recordKey(ref.object, ref.id)); + } + if (remaining.length === 0) { + deleted.push({ object, recordId, removed: cascade, notes }); + } else { + failed.push({ + object, recordId, + message: refusal ? messageOf(refusal) : 'the delete returned, but these records are still stored', + ...(refusal ? errorCodeFields(refusal) : {}), + remaining, + }); + } + } + + if (json) { + await emitJson({ mode: 'delete', export: exportPath, deleted, skipped, failed, plan }, failed.length > 0 ? 1 : 0, { compact: true }); + return; + } + const rowCount = deleted.reduce((n, d) => n + d.removed.length, 0); + printSuccess(`Deleted ${deleted.length} orphan(s), ${rowCount} row(s) with their cascade, in ${timer.display()}.`); + for (const d of deleted) for (const n of d.notes) printInfo(` ${d.object}/${d.recordId}: ${n}`); + for (const s of skipped) printWarning(` skipped ${s.object}/${s.recordId}: ${s.reason}`); + for (const f of failed) printError(` ${f.object}/${f.recordId}: ${f.message}`); + printInfo(`Keep ${exportPath} until you are certain the sweep was correct — it is the only copy.`); + if (failed.length > 0) this.exit(1); + } catch (error) { + // A read the scan could not make is a refusal, and under `--json` a + // refusal is still one JSON document. + if (isExitSignal(error)) throw error; + const message = messageOf(error); + if (json) { await emitJson({ error: 'scan_failed', message, ...errorCodeFields(error) }, 1, { compact: true }); return; } + printError(message); + this.exit(1); + } finally { + await stack.shutdown(); + } + } +} + +/** The engine slice this command reads and the one write verb it uses. */ +type OrphanEngine = import('../../utils/orphan-detail-sweep.js').OrphanDetailEngine + & Pick; + +function orphanFieldKey(object: string, recordId: string, field: string, masterId: string): string { + return JSON.stringify([object, recordId, field, masterId]); +} + +function messageOf(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +/** + * The record as its driver stores it, unscoped, or `null` when it is not + * there. The driver's own read: the export must hold the stored row whole, and + * the read-back after a delete must see a record whatever its access says. + */ +async function readStoredRow(engine: OrphanEngine, ref: RecordRef): Promise | null> { + const driver = engine.getDriverForObject(ref.object); + if (!driver) throw new Error(`No driver resolves for '${ref.object}', so ${ref.object}/${ref.id} could not be read.`); + return (await driver.findOne(ref.object, { where: { id: ref.id }, limit: 1 })) ?? null; +} + +async function confirm(question: string): Promise { + if (!process.stdin.isTTY) return false; // non-interactive → require --yes + const rl = createInterface({ input: process.stdin, output: process.stdout }); + try { + const answer: string = await new Promise((resolve) => rl.question(question, resolve)); + return /^y(es)?$/i.test(answer.trim()); + } finally { + rl.close(); + } +} + +/** Render the plan for a human. */ +function renderPlan(plan: OrphanDetailPlan): void { + console.log(chalk.bold('\n Detail rows whose required master does not exist')); + printInfo( + `scanned ${plan.scanned} row(s) of ${plan.objects.length} object(s) · orphans ${plan.counts.orphans} · ` + + `deeper details ${plan.counts.deeperDetails} · withheld ${plan.counts.withheld}`, + ); + for (const orphan of plan.orphans) renderOrphan(orphan); + for (const w of plan.withheld) { + console.log(` ${chalk.yellow('withheld')} ${w.object}/${w.recordId}`); + console.log(chalk.dim(` ${w.reason}`)); + } + if (plan.otherDangling.length > 0) { + console.log(chalk.bold('\n Other dangling references (listed, never deleted)')); + for (const d of plan.otherDangling) { + console.log(` ${d.objectName}/${d.recordId}.${d.field} → ${d.target} ${d.value}`); + } + } + if (plan.notes.length > 0) { + console.log(chalk.bold('\n Read before acting')); + for (const note of plan.notes) printWarning(note); + } +} + +function renderOrphan(orphan: OrphanDetailRow): void { + console.log(` ${chalk.red('ORPHAN')} ${orphan.object}/${orphan.recordId}`); + for (const f of orphan.fields) { + console.log(chalk.dim(` ${f.field} → ${f.master} ${f.missingMasterId}, which does not exist`)); + } + if (orphan.cascade.length > 0) { + console.log(chalk.dim(` its delete also removes ${orphan.cascade.length} deeper detail row(s):`)); + for (const c of orphan.cascade) console.log(chalk.dim(` ${c.object}/${c.id}`)); + } +} diff --git a/packages/cli/src/utils/orphan-detail-sweep.test.ts b/packages/cli/src/utils/orphan-detail-sweep.test.ts new file mode 100644 index 00000000000..c9bb583435b --- /dev/null +++ b/packages/cli/src/utils/orphan-detail-sweep.test.ts @@ -0,0 +1,149 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#24964] The plan behind `os db orphans`, away from a boot: the judgements + * that decide what is deletable, each in its failing direction. + * + * The engine slice here is read-only (no write verb), so it stands in for the + * three reads the plan makes and nothing else. The command against a real + * engine and database is `../commands/db/orphans.integration.test.ts`. + */ + +import { describe, it, expect, vi } from 'vitest'; +import type { DanglingReference, DanglingReferenceReport } from '@objectstack/objectql'; +import { + buildOrphanDetailExport, + objectsWithRequiredMaster, + planOrphanDetailSweep, + recordKey, + recordsToDelete, + type OrphanDetailEngine, +} from './orphan-detail-sweep.js'; + +const CONFIGS = { + sw_master: { name: 'sw_master', fields: { name: { type: 'text' } } }, + sw_detail: { + name: 'sw_detail', + fields: { + master: { type: 'master_detail', reference: 'sw_master', required: true }, + second: { type: 'master_detail', reference: 'sw_master', required: true }, + loose: { type: 'master_detail', reference: 'sw_master' }, + link: { type: 'lookup', reference: 'sw_master' }, + }, + }, + sw_absent: { name: 'sw_absent', fields: { master: { type: 'master_detail', reference: 'sw_master', required: true } } }, + sw_plain: { name: 'sw_plain', fields: { link: { type: 'lookup', reference: 'sw_master' } } }, +}; + +const finding = (recordId: string, field: string, value: string): DanglingReference => + ({ objectName: 'sw_detail', recordId, field, target: 'sw_master', value }); + +function report(dangling: DanglingReference[], extra: Partial = {}): DanglingReferenceReport { + return { + scanned: 10, dangling, undetermined: 0, unreadableObjects: [], truncatedObjects: [], + unscannedObjects: [], aborted: false, ...extra, + }; +} + +function engineWith(rep: DanglingReferenceReport, cascade: (object: string, id: string | number) => Promise>) { + const inspectDanglingReferences = vi.fn(async () => rep); + const engine = { + getConfigs: () => CONFIGS, + inspectDanglingReferences, + inspectCascadeDelete: vi.fn(cascade), + } as unknown as OrphanDetailEngine; + return { engine, inspectDanglingReferences }; +} + +const rootOnly = async (object: string, id: string | number) => [{ object, id: String(id) }]; + +describe('[#24964] planOrphanDetailSweep', () => { + it('reads only the objects that declare a REQUIRED master-detail, and not a table the boot measured absent', async () => { + expect(objectsWithRequiredMaster(CONFIGS)).toEqual(['sw_detail', 'sw_absent']); + const { engine, inspectDanglingReferences } = engineWith(report([]), rootOnly); + + const plan = await planOrphanDetailSweep({ engine, maxRows: 50, absent: (o) => o === 'sw_absent' }); + + expect(inspectDanglingReferences).toHaveBeenCalledWith({ objects: ['sw_detail'], rowsPerObject: 50, maxRows: 50 }); + expect(plan.notRead).toEqual(['sw_absent']); + expect(plan.objects).toEqual(['sw_detail']); + }); + + it('an orphan is a dangling REQUIRED master-detail; a lookup or an optional master-detail is named and never deletable', async () => { + const { engine } = engineWith(report([ + finding('d1', 'master', 'gone_a'), + finding('d1', 'second', 'gone_b'), + finding('d2', 'loose', 'gone_c'), + finding('d3', 'link', 'gone_d'), + ]), rootOnly); + + const plan = await planOrphanDetailSweep({ engine, maxRows: 50 }); + + // One row, both of its missing masters. + expect(plan.orphans).toEqual([{ + object: 'sw_detail', + recordId: 'd1', + fields: [ + { field: 'master', master: 'sw_master', missingMasterId: 'gone_a' }, + { field: 'second', master: 'sw_master', missingMasterId: 'gone_b' }, + ], + cascade: [], + }]); + expect(plan.otherDangling.map((d) => `${d.recordId}.${d.field}`)).toEqual(['d2.loose', 'd3.link']); + expect(recordsToDelete(plan).map((r) => r.id)).toEqual(['d1']); + }); + + it('an orphan whose cascade cannot be read is WITHHELD, never deletable', async () => { + const { engine } = engineWith(report([finding('d1', 'master', 'gone'), finding('d2', 'master', 'gone')]), async (object, id) => { + if (id === 'd1') throw new Error('connection reset reading sw_line'); + return [{ object, id: String(id) }]; + }); + + const plan = await planOrphanDetailSweep({ engine, maxRows: 50 }); + + expect(plan.withheld).toEqual([expect.objectContaining({ recordId: 'd1', reason: expect.stringContaining('connection reset') })]); + expect(plan.orphans.map((o) => o.recordId)).toEqual(['d2']); + expect(recordsToDelete(plan).map((r) => r.id)).toEqual(['d2']); + }); + + it('undetermined and unread rows are reported as such: the plan is incomplete, and lists nothing for them', async () => { + const { engine } = engineWith(report([], { undetermined: 3, truncatedObjects: ['sw_detail'] }), rootOnly); + + const plan = await planOrphanDetailSweep({ engine, maxRows: 50 }); + + expect(plan.complete).toBe(false); + expect(plan.undetermined).toBe(3); + expect(plan.orphans).toEqual([]); + expect(plan.notes.join(' ')).toMatch(/Unknown is not missing/); + expect(plan.notes.join(' ')).toMatch(/--max-rows/); + }); +}); + +describe('[#24964] the records a run deletes, and the export that must hold them', () => { + const plan = { + orphans: [ + { object: 'sw_detail', recordId: 'd1', fields: [], cascade: [{ object: 'sw_line', id: 'l1' }, { object: 'sw_detail', id: 'd2' }] }, + // Reached by the first orphan's cascade too: deleted, and exported, once. + { object: 'sw_detail', recordId: 'd2', fields: [], cascade: [{ object: 'sw_line', id: 'l2' }] }, + ], + }; + + it('every orphan and every deeper detail, each once', () => { + expect(recordsToDelete(plan).map((r) => `${r.object}/${r.id}`)) + .toEqual(['sw_detail/d1', 'sw_line/l1', 'sw_detail/d2', 'sw_line/l2']); + }); + + it('the export refuses to leave a record out', () => { + const rawRows = new Map>( + ['sw_detail/d1', 'sw_line/l1', 'sw_detail/d2'].map((k) => { + const [object, id] = k.split('/') as [string, string]; + return [recordKey(object, id), { id }]; + }), + ); + expect(() => buildOrphanDetailExport({ plan, rawRows, producedBy: 'test' })).toThrow(/sw_line\/l2/); + + rawRows.set(recordKey('sw_line', 'l2'), { id: 'l2' }); + const doc = buildOrphanDetailExport({ plan, rawRows, producedBy: 'test' }); + expect(doc.rows.map((r) => `${r.object}/${r.id}`)).toEqual(['sw_detail/d1', 'sw_line/l1', 'sw_detail/d2', 'sw_line/l2']); + }); +}); diff --git a/packages/cli/src/utils/orphan-detail-sweep.ts b/packages/cli/src/utils/orphan-detail-sweep.ts new file mode 100644 index 00000000000..37c07b7a24e --- /dev/null +++ b/packages/cli/src/utils/orphan-detail-sweep.ts @@ -0,0 +1,348 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { isControlledByParentMasterField } from '@objectstack/spec/data'; +import type { DanglingReference, DanglingReferenceReport, ObjectQL } from '@objectstack/objectql'; + +/** + * [#24964] The plan behind `os db orphans`: the detail rows whose REQUIRED + * master-detail master does not exist, and what deleting each one removes. + * + * ## Where the rows come from + * + * A system-context write is exempt from the write-time reference check (seed + * replay and package install write in an order that only resolves once the + * batch completes), so a platform or elevated-script write can store a + * master-detail id that names no master. Such a row's access derives from a + * master that does not exist, so no user door reads or deletes it, and the + * dangling-reference audit (`ObjectQL.inspectDanglingReferences`) reports it + * and, by design, never rewrites it. This plan is that audit's report, read for + * one question an operator can act on. + * + * ## The judgements are the engine's, never restated here + * + * - **Which rows are orphans** is the audit's `dangling` bucket — the engine's + * own existence probe, the one the write-path guard uses — kept to the + * findings on a REQUIRED `master_detail` field. "Required master-detail" is + * `isControlledByParentMasterField` from `@objectstack/spec/data`: a + * `master_detail` declared `required: true`, and nothing else. That is the + * one place the spec spells it. + * - **What a delete removes** is `ObjectQL.inspectCascadeDelete`: the cascade + * set the engine's own by-id delete collects, read through the same walk. A + * list of "which relations cascade" kept in this file would be free to + * disagree with the delete it previews. + * + * ## Unknown is not absent, and nothing unknown is deleted + * + * - The audit's `undetermined` values (a target object that is not + * registered, has no driver, or whose read failed) are counted and never + * listed as orphans. The audit counts them; it does not itemise them. + * - A dangling reference on any other field (a plain `lookup`, a `master_detail` + * that is not required) is NAMED in `otherDangling` and never deleted: its row + * does not depend on that master for its existence. + * - An orphan whose cascade could not be read is WITHHELD, with the reason, + * and never deleted. "The probe failed" is not "there are no deeper details". + * - A bounded scan says what it did not read (`truncatedObjects`, + * `unscannedObjects`, `unreadableObjects`). Every row it does list is proven + * dangling on its own, so an incomplete scan narrows the deletion and never + * widens it. + * + * Read-only by construction: nothing here holds a write verb. + */ + +/** The slice of the engine the plan reads. Typed off the engine itself, so it cannot drift from it. */ +export type OrphanDetailEngine = Pick; + +/** One required master-detail field on an orphan row, naming a master that does not exist. */ +export interface OrphanDetailField { + field: string; + /** The master object the field declares. */ + master: string; + /** The stored master id, which names no row of `master`. */ + missingMasterId: string; +} + +/** A record, as the cascade set names it. */ +export interface RecordRef { + object: string; + id: string; +} + +/** A detail row whose required master is missing, and what deleting it removes. */ +export interface OrphanDetailRow { + object: string; + recordId: string; + fields: OrphanDetailField[]; + /** The deeper details the delete's cascade removes with it — the root excluded. */ + cascade: RecordRef[]; +} + +/** An orphan that is NOT deleted, and why. */ +export interface WithheldOrphanDetailRow { + object: string; + recordId: string; + fields: OrphanDetailField[]; + reason: string; +} + +export interface OrphanDetailPlan { + /** Objects declaring a required master-detail field — the ones the audit read. */ + objects: string[]; + /** Objects with such a field whose table this database does not have: nothing is stored there. */ + notRead: string[]; + /** Rows the audit read and examined. */ + scanned: number; + /** The deletable rows, in the audit's order. */ + orphans: OrphanDetailRow[]; + /** Orphans the plan will not delete, each with its reason. */ + withheld: WithheldOrphanDetailRow[]; + /** Dangling references on the scanned objects that are not a required master-detail. Never deleted. */ + otherDangling: DanglingReference[]; + /** Reference values on the scanned objects whose target could not be probed. Not orphans. */ + undetermined: number; + unreadableObjects: string[]; + truncatedObjects: string[]; + unscannedObjects: string[]; + aborted: boolean; + /** `true` only when every row of every scanned object was read and every probe could run. */ + complete: boolean; + counts: { + orphans: number; + deeperDetails: number; + withheld: number; + otherDangling: number; + undetermined: number; + }; + /** What an operator must read before acting, in plain sentences. */ + notes: string[]; +} + +/** One identity for a record across the plan, the export and the delete. */ +export function recordKey(object: string, id: string): string { + return JSON.stringify([object, id]); +} + +/** The objects that declare at least one required master-detail field, in registration order. */ +export function objectsWithRequiredMaster(configs: Record): string[] { + const out: string[] = []; + for (const [name, obj] of Object.entries(configs)) { + const fields = (obj as { fields?: unknown } | null | undefined)?.fields; + if (!fields || typeof fields !== 'object') continue; + if (Object.values(fields as Record).some((f) => isControlledByParentMasterField(f))) out.push(name); + } + return out; +} + +/** Is `finding` on a required master-detail field of its object? */ +function isRequiredMasterFinding(configs: Record, finding: DanglingReference): boolean { + const fields = (configs[finding.objectName] as { fields?: Record } | undefined)?.fields; + return isControlledByParentMasterField(fields?.[finding.field]); +} + +/** + * The orphans in a dangling-reference report, one entry per row, its fields + * gathered: a row with two required masters both missing is one orphan. + */ +export function orphanFindings( + configs: Record, + report: Pick, +): { orphans: Array>; other: DanglingReference[] } { + const byRow = new Map>(); + const other: DanglingReference[] = []; + for (const finding of report.dangling) { + if (!isRequiredMasterFinding(configs, finding)) { + other.push(finding); + continue; + } + const key = recordKey(finding.objectName, finding.recordId); + let row = byRow.get(key); + if (!row) { + row = { object: finding.objectName, recordId: finding.recordId, fields: [] }; + byRow.set(key, row); + } + row.fields.push({ field: finding.field, master: finding.target, missingMasterId: finding.value }); + } + return { orphans: [...byRow.values()], other }; +} + +export async function planOrphanDetailSweep(input: { + engine: OrphanDetailEngine; + /** The audit's budget, per object and overall. */ + maxRows: number; + /** Did the boot measure this object's table absent? Such a table is not read: it stores nothing. */ + absent?: (object: string) => boolean; +}): Promise { + const configs = input.engine.getConfigs() as Record; + const declaring = objectsWithRequiredMaster(configs); + const notRead = declaring.filter((name) => input.absent?.(name) === true); + const objects = declaring.filter((name) => !notRead.includes(name)); + + const empty: DanglingReferenceReport = { + scanned: 0, dangling: [], undetermined: 0, unreadableObjects: [], truncatedObjects: [], + unscannedObjects: [], aborted: false, + }; + const report = objects.length === 0 + ? empty + : await input.engine.inspectDanglingReferences({ objects, rowsPerObject: input.maxRows, maxRows: input.maxRows }); + + const { orphans: found, other } = orphanFindings(configs, report); + const orphans: OrphanDetailRow[] = []; + const withheld: WithheldOrphanDetailRow[] = []; + for (const row of found) { + let members: RecordRef[]; + try { + members = await input.engine.inspectCascadeDelete(row.object, row.recordId); + } catch (error) { + withheld.push({ + ...row, + reason: 'what deleting it would remove could not be read, so it is not deleted: ' + + (error instanceof Error ? error.message : String(error)), + }); + continue; + } + const root = recordKey(row.object, row.recordId); + orphans.push({ ...row, cascade: members.filter((m) => recordKey(m.object, m.id) !== root) }); + } + + const unscannedObjects = report.unscannedObjects ?? []; + const aborted = report.aborted === true; + const notes: string[] = []; + if (report.truncatedObjects.length > 0) { + notes.push( + `These objects hold more rows than --max-rows (${input.maxRows}), so only the first ${input.maxRows} of each ` + + `were read, and their other rows are not in this report: ${report.truncatedObjects.join(', ')}. ` + + 'Re-run with a larger --max-rows to read them.', + ); + } + if (unscannedObjects.length > 0) { + notes.push( + `The --max-rows budget (${input.maxRows}) ran out before these objects were read, so none of their rows is in ` + + `this report: ${unscannedObjects.join(', ')}.`, + ); + } + if (report.unreadableObjects.length > 0) { + notes.push( + `The rows of these objects could not be read, so none of them is in this report: ${report.unreadableObjects.join(', ')}.`, + ); + } + if (report.undetermined > 0) { + notes.push( + `${report.undetermined} reference value(s) on the scanned objects name a record whose object could not be ` + + 'checked (it is not registered, has no driver, or its read failed). Unknown is not missing: none of ' + + 'them is listed as an orphan, and none is deleted.', + ); + } + if (other.length > 0) { + notes.push( + `${other.length} other stored reference(s) on the scanned objects name a record that does not exist, on a ` + + 'field that is not a required master-detail. They are listed so you can see them; this command never ' + + 'deletes their rows, because those rows do not depend on that record to exist.', + ); + } + if (aborted) notes.push('The scan was called off before it finished, so its silence about the rest proves nothing.'); + + return { + objects, + notRead, + scanned: report.scanned, + orphans, + withheld, + otherDangling: other, + undetermined: report.undetermined, + unreadableObjects: report.unreadableObjects, + truncatedObjects: report.truncatedObjects, + unscannedObjects, + aborted, + complete: !aborted && report.undetermined === 0 && report.unreadableObjects.length === 0 + && report.truncatedObjects.length === 0 && unscannedObjects.length === 0, + counts: { + orphans: orphans.length, + deeperDetails: orphans.reduce((n, o) => n + o.cascade.length, 0), + withheld: withheld.length, + otherDangling: other.length, + undetermined: report.undetermined, + }, + notes, + }; +} + +/** Every record a `--delete` run of this plan removes, orphans and deeper details, each once. */ +export function recordsToDelete(plan: Pick): RecordRef[] { + const seen = new Set(); + const out: RecordRef[] = []; + for (const orphan of plan.orphans) { + for (const ref of [{ object: orphan.object, id: orphan.recordId }, ...orphan.cascade]) { + const key = recordKey(ref.object, ref.id); + if (seen.has(key)) continue; + seen.add(key); + out.push(ref); + } + } + return out; +} + +/** The pre-delete export: every record the run deletes, as stored, written before the first delete. */ +export interface OrphanDetailExport { + /** Format marker, so a restorer can refuse a document it does not know. */ + format: 'objectstack.orphan-detail.pre-delete-export.v1'; + generatedAt: string; + /** The command that produced it, for the operator reading it months later. */ + producedBy: string; + warning: string; + /** Why each row is deleted: the report this run made. */ + orphans: OrphanDetailRow[]; + /** Each record the run deletes, as the driver stores it. */ + rows: Array<{ object: string; id: string; row: Record }>; +} + +export const ORPHAN_EXPORT_RESTORE_HINT = + 'To restore a record, re-insert its `row` into the table named by `object` with every column verbatim, ' + + 'the id included. Restore a deeper detail after the orphan it belongs to. Restoring an orphan does not ' + + 'restore its master: the master it names did not exist when it was deleted.'; + +/** + * Build the export for the records a `--delete` run is about to remove. + * + * ⛔ Throws rather than leaving a record out: a record the export cannot hold + * is a record that would be deleted with no copy anywhere. + */ +export function buildOrphanDetailExport(input: { + plan: Pick; + /** The stored rows, keyed by {@link recordKey}, as read from the driver. */ + rawRows: ReadonlyMap>; + producedBy: string; + now?: () => Date; +}): OrphanDetailExport { + const rows: OrphanDetailExport['rows'] = []; + for (const ref of recordsToDelete(input.plan)) { + const raw = input.rawRows.get(recordKey(ref.object, ref.id)); + if (!raw) { + throw new Error( + `Cannot build the pre-delete export: no stored row was read for ${ref.object}/${ref.id}, which this run ` + + 'would delete. Refusing to delete a record the export cannot hold.', + ); + } + rows.push({ object: ref.object, id: ref.id, row: raw }); + } + return { + format: 'objectstack.orphan-detail.pre-delete-export.v1', + generatedAt: (input.now?.() ?? new Date()).toISOString(), + producedBy: input.producedBy, + warning: + 'This file holds every record the run deleted, as stored: business data, with whatever it contained. ' + + 'Protect it as you protect a database backup, and delete it only once you are certain the sweep was ' + + `correct. ${ORPHAN_EXPORT_RESTORE_HINT}`, + orphans: input.plan.orphans, + rows, + }; +} + +/** The records a read-back export holds, by {@link recordKey} — the check made before the first delete. */ +export function exportedKeys(doc: Partial | null | undefined): Set { + const keys = new Set(); + for (const r of doc?.rows ?? []) { + if (r && typeof r.object === 'string' && typeof r.id === 'string' && r.row && typeof r.row === 'object') { + keys.add(recordKey(r.object, r.id)); + } + } + return keys; +} diff --git a/packages/cli/src/utils/schema-migrate.one-shot-family.integration.test.ts b/packages/cli/src/utils/schema-migrate.one-shot-family.integration.test.ts index b63ede0b718..917c47e9ff7 100644 --- a/packages/cli/src/utils/schema-migrate.one-shot-family.integration.test.ts +++ b/packages/cli/src/utils/schema-migrate.one-shot-family.integration.test.ts @@ -54,6 +54,7 @@ import type { IObjectQLEngine } from '@objectstack/spec/contracts'; import { isExitSignal } from './format.js'; import { bootSchemaStack } from './schema-migrate.js'; import { buildDataMigrationPlugins } from './data-migration-plugins.js'; +import DbOrphans from '../commands/db/orphans.js'; import MigrateAccountIssuer from '../commands/migrate/account-issuer.js'; import MigrateActivityChangedFields from '../commands/migrate/activity-changed-fields.js'; import MigrateCommentParentPair from '../commands/migrate/comment-parent-pair.js'; @@ -155,6 +156,13 @@ const invoke = (command: { run: (argv: string[], opts: { root: string }) => Prom * modes it has. The first case below holds this table equal to the source. */ const CALLERS: Record = { + 'commands/db/orphans.ts': { + run: invoke(DbOrphans), + noWrite: [{ label: 'db orphans', argv: ['--database-url', '@DB@', '--json'] }], + // The fixture holds no orphan, so the run deletes nothing and writes no + // export: the plain boot a delete takes is what this case is about. + write: [{ label: 'db orphans --delete', argv: ['--delete', '--export', '@DIR@/orphans-export.json', '--yes', '--database-url', '@DB@', '--json'] }], + }, 'commands/migrate/account-issuer.ts': { run: invoke(MigrateAccountIssuer), noWrite: [{ label: 'migrate account-issuer', argv: ['--database-url', '@DB@', '--json'] }], diff --git a/packages/cli/test/json-stdout-purity.e2e.test.ts b/packages/cli/test/json-stdout-purity.e2e.test.ts index c2c269dd467..10569549b98 100644 --- a/packages/cli/test/json-stdout-purity.e2e.test.ts +++ b/packages/cli/test/json-stdout-purity.e2e.test.ts @@ -81,6 +81,9 @@ const COMMANDS_DIR = resolve(HERE, '../src/commands'); * --stored` rather than `migrate meta`. */ const FAMILY: Record = { + // Report-only is its DEFAULT and the only form driven here: without + // `--delete` it boots read-only, reports and writes nothing. + 'db orphans': [], // Its bare form boots: it takes the `os migrate plan` shape (`deferSchemaDdl` // + `readOnlyProbe`), so a fixture with no database file is opened as an // empty in-memory one rather than being brought into existence. No table diff --git a/packages/objectql/src/engine-inspect-cascade-delete.test.ts b/packages/objectql/src/engine-inspect-cascade-delete.test.ts new file mode 100644 index 00000000000..0f95b0f944c --- /dev/null +++ b/packages/objectql/src/engine-inspect-cascade-delete.test.ts @@ -0,0 +1,183 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#24964] `ObjectQL.inspectCascadeDelete` lists what a by-id `delete()` of a + * record removes, and does nothing else. + * + * It exists so the operator's remover of orphaned detail rows (`os db + * orphans`) can list, in its dry run, the deeper details the delete reaches. + * The listing is only worth printing if it is the delete's own set, so the + * first pin compares the two on the same engine: what the inspection lists is + * exactly what the delete then removes, root first, and nothing outside it. + * + * The rest pin "nothing else": no driver write, no #12166 elevation record + * (that record names a delete, and none happened), relations that remove + * nothing (`restrict`, `set_null`) not listed, and a dependents probe that + * fails propagates rather than answering "no deeper details". + */ + +import { describe, it, expect, vi } from 'vitest'; +import { ObjectQL } from './engine.js'; + +type Row = Record; + +function makeDriver() { + const stores = new Map>(); + const storeFor = (o: string): Map => { + let s = stores.get(o); + if (!s) { s = new Map(); stores.set(o, s); } + return s; + }; + let nextId = 0; + const matches = (row: Row, where: unknown): boolean => { + if (!where || typeof where !== 'object') return true; + for (const [k, v] of Object.entries(where as Row)) { + if (k.startsWith('$')) continue; + const exp = v && typeof v === 'object' && '$eq' in (v as Row) ? (v as Row).$eq : v; + if ((row[k] ?? null) !== (exp ?? null)) return false; + } + return true; + }; + const writes: string[] = []; + const driver: any = { + name: 'primary', version: '0.0.0', supports: {}, + writes, + failFindOn: undefined as string | undefined, + rowIds: (o: string): string[] => Array.from(storeFor(o).keys()).sort(), + async connect() {}, async disconnect() {}, async checkHealth() { return true; }, async execute() { return null; }, + async syncSchema() {}, + async find(o: string, ast: { where?: unknown; limit?: unknown } | undefined) { + if (driver.failFindOn === o) throw new Error(`connection reset reading ${o}`); + const rows = Array.from(storeFor(o).values()).filter((r) => matches(r, ast?.where)); + return typeof ast?.limit === 'number' ? rows.slice(0, ast.limit) : rows; + }, + async findOne(o: string, ast: { where?: unknown } | undefined) { + for (const r of storeFor(o).values()) if (matches(r, ast?.where)) return r; + return null; + }, + async create(o: string, data: Row) { + writes.push(`create ${o}`); + nextId += 1; + const id = (data.id as string | undefined) ?? `r_${nextId}`; + const row = { ...data, id }; + storeFor(o).set(id, row); + return row; + }, + async update(o: string, id: string, data: Row) { + writes.push(`update ${o}`); + const cur = storeFor(o).get(String(id)); + if (!cur) throw new Error(`not found ${o}/${id}`); + const up = { ...cur, ...data, id }; + storeFor(o).set(String(id), up); + return up; + }, + async delete(o: string, id: string) { writes.push(`delete ${o}`); return storeFor(o).delete(String(id)); }, + async count(o: string, ast: { where?: unknown } | undefined) { return (await this.find(o, ast)).length; }, + async bulkCreate(o: string, rows: Row[]) { return Promise.all(rows.map((r) => this.create(o, r))); }, + async bulkUpdate() { return []; }, + async bulkDelete() {}, + }; + return driver; +} + +async function makeEngine() { + const logger = { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() }; + const engine = new ObjectQL({ logger } as any); + const driver = makeDriver(); + engine.registerDriver(driver, true); + await engine.init(); + const id = { name: 'id', type: 'text' as const, primaryKey: true }; + const name = { name: 'name', type: 'text' as const }; + const objects = [ + { name: 'ic_master', fields: { id, name } }, + { name: 'ic_detail', fields: { id, name, master: { name: 'master', type: 'master_detail', reference: 'ic_master', required: true } } }, + { name: 'ic_line', fields: { id, name, detail: { name: 'detail', type: 'master_detail', reference: 'ic_detail', required: true } } }, + // Relations that remove nothing on a delete of an `ic_detail`. + { name: 'ic_pin', fields: { id, name, detail: { name: 'detail', type: 'lookup', reference: 'ic_detail', deleteBehavior: 'restrict' } } }, + { name: 'ic_tag', fields: { id, name, detail: { name: 'detail', type: 'lookup', reference: 'ic_detail', deleteBehavior: 'set_null' } } }, + ]; + for (const o of objects) engine.registry.registerObject(o as any, '__test__'); + return { engine, driver, logger }; +} + +const SYSTEM = { context: { isSystem: true } } as any; + +async function seed(engine: ObjectQL) { + const m = await engine.insert('ic_master', { name: 'M' }, SYSTEM); + const d1 = await engine.insert('ic_detail', { name: 'D1', master: m.id }, SYSTEM); + const d2 = await engine.insert('ic_detail', { name: 'D2', master: m.id }, SYSTEM); + const l1 = await engine.insert('ic_line', { name: 'L1', detail: d1.id }, SYSTEM); + const l2 = await engine.insert('ic_line', { name: 'L2', detail: d1.id }, SYSTEM); + const l3 = await engine.insert('ic_line', { name: 'L3', detail: d2.id }, SYSTEM); + return { m, d1, d2, l1, l2, l3 }; +} + +describe('[#24964] inspectCascadeDelete lists what delete() removes, and does nothing else', () => { + it('lists the root first, then every deeper detail the cascade reaches, and nothing outside it', async () => { + const { engine } = await makeEngine(); + const { m, d1, d2, l1, l2, l3 } = await seed(engine); + + const listed = await engine.inspectCascadeDelete('ic_master', m.id); + + expect(listed[0]).toEqual({ object: 'ic_master', id: String(m.id) }); + expect(listed.slice(1).map((r) => `${r.object}/${r.id}`).sort()).toEqual( + [`ic_detail/${d1.id}`, `ic_detail/${d2.id}`, `ic_line/${l1.id}`, `ic_line/${l2.id}`, `ic_line/${l3.id}`].sort(), + ); + // One level down: only that detail's own lines. + expect((await engine.inspectCascadeDelete('ic_detail', d2.id)).map((r) => `${r.object}/${r.id}`)) + .toEqual([`ic_detail/${d2.id}`, `ic_line/${l3.id}`]); + }); + + it('what it lists is exactly what the delete then removes', async () => { + const { engine, driver } = await makeEngine(); + const { d1 } = await seed(engine); + const before = { detail: driver.rowIds('ic_detail'), line: driver.rowIds('ic_line'), master: driver.rowIds('ic_master') }; + + const listed = await engine.inspectCascadeDelete('ic_detail', d1.id); + await engine.delete('ic_detail', { where: { id: d1.id }, context: { isSystem: true } } as any); + + const removed = [ + ...before.detail.filter((x: string) => !driver.rowIds('ic_detail').includes(x)).map((x: string) => `ic_detail/${x}`), + ...before.line.filter((x: string) => !driver.rowIds('ic_line').includes(x)).map((x: string) => `ic_line/${x}`), + ...before.master.filter((x: string) => !driver.rowIds('ic_master').includes(x)).map((x: string) => `ic_master/${x}`), + ].sort(); + // Non-vacuity: the delete reached a deeper level, so the equality says something. + expect(removed.length).toBe(3); + expect(listed.map((r) => `${r.object}/${r.id}`).sort()).toEqual(removed); + }); + + it('issues no write and files no elevation record', async () => { + const { engine, driver, logger } = await makeEngine(); + const { m } = await seed(engine); + driver.writes.length = 0; + logger.info.mockClear(); + + await engine.inspectCascadeDelete('ic_master', m.id); + + expect(driver.writes).toEqual([]); + const elevation = logger.info.mock.calls.filter((c) => String(c[0]).includes('[reference-cleanup]')); + expect(elevation).toEqual([]); + + // The control: the delete it previews does file that record, through the same walk. + await engine.delete('ic_master', { where: { id: m.id }, context: { isSystem: true } } as any); + expect(logger.info.mock.calls.some((c) => String(c[0]).includes('[reference-cleanup]'))).toBe(true); + }); + + it('does not list a row a restrict or set_null relation holds: neither removes it', async () => { + const { engine } = await makeEngine(); + const { d1 } = await seed(engine); + await engine.insert('ic_pin', { name: 'P', detail: d1.id }, SYSTEM); + await engine.insert('ic_tag', { name: 'T', detail: d1.id }, SYSTEM); + + const objects = new Set((await engine.inspectCascadeDelete('ic_detail', d1.id)).map((r) => r.object)); + expect(objects).toEqual(new Set(['ic_detail', 'ic_line'])); + }); + + it('propagates a dependents probe that fails, rather than answering no deeper details', async () => { + const { engine, driver } = await makeEngine(); + const { d1 } = await seed(engine); + driver.failFindOn = 'ic_line'; + + await expect(engine.inspectCascadeDelete('ic_detail', d1.id)).rejects.toThrow(/connection reset reading ic_line/); + }); +}); diff --git a/packages/objectql/src/engine.ts b/packages/objectql/src/engine.ts index dd71823aff7..06b9025c859 100644 --- a/packages/objectql/src/engine.ts +++ b/packages/objectql/src/engine.ts @@ -10835,6 +10835,54 @@ export class ObjectQL implements IObjectQLEngine { ); } + /** + * [#24964] Read-only: every record a by-id {@link delete} of `object`/`id` + * would remove, the root first — the cascade set that delete collects for + * itself before it writes anything, read through the same walk. + * + * Exists for the operator's remover of the rows {@link inspectDanglingReferences} + * reports (`os db orphans`). Its dry run must list the deeper details the + * delete would reach, and it can only say that truthfully by asking the walk + * the delete runs. A copy of "which relations cascade" in the command would + * be free to disagree with the delete it previews. + * + * It reads, and does nothing else: + * + * - it issues no write and runs no hook; + * - it files no #12166 elevation record: that record says the platform read + * an object as system for a delete, and none happens here; + * - it reads as SYSTEM with no organization, the probes a system-context + * delete makes (`{ isSystem: true }`, no `tenantId`). A delete under any + * other context can reach a different set. + * + * What it lists is the `cascade` relations' reach, the delete's phase 1. + * A `restrict` dependent outside the set refuses the delete when it runs, + * and a `set_null` reference to a member is cleared when it runs. Neither + * is listed here: neither removes a record. + * + * Like the delete's own walk, it propagates an unreadable registry or a + * dependents probe that fails for any reason but a table that was never + * provisioned. ⛔ Never treat a throw as "no deeper details". + */ + async inspectCascadeDelete( + object: string, + id: string | number, + ): Promise> { + const resolved = this.resolveObjectName(object); + const set = await this.collectCascadeDeleteSet( + resolved, id, undefined, this._registry.getAllObjects(), { fileElevations: false }, + ); + const root = { object: resolved, id: String(id) }; + const members = [root]; + for (const [memberObject, ids] of set.members) { + for (const memberId of ids) { + if (memberObject === root.object && memberId === root.id) continue; + members.push({ object: memberObject, id: memberId }); + } + } + return members; + } + /** * Register the crypto provider that backs `secret`-typed fields. * @@ -19309,12 +19357,18 @@ export class ObjectQL implements IObjectQLEngine { * `set_null` relations are not read here. Every probe here is an elevated * read, so it files its #12166 record before it runs, through the * once-per-record ledger the walk shares ({@link fileReferenceCheckElevation}). + * + * [#24964] …except for {@link inspectCascadeDelete}, which passes + * `fileElevations: false`: that record says "read as system FOR THE DELETE + * of" the record, and an inspection deletes nothing, so filing it there would + * put a delete that never happened in the log. The reads are the same. */ private async collectCascadeDeleteSet( object: string, id: string | number, context: ExecutionContext | undefined, objects: ServiceObject[], + opts: { fileElevations: boolean } = { fileElevations: true }, ): Promise { const set: CascadeDeleteSet = { members: new Map(), entered: new Map(), elevationsFiled: new Set() }; cascadeSetAdd(set.members, object, id); @@ -19345,7 +19399,9 @@ export class ObjectQL implements IObjectQLEngine { const queue: Array<{ object: string; id: string | number }> = [{ object, id }]; for (const target of queue) { for (const { childName, fieldName, fdef } of cascadingInto(target.object)) { - this.fileReferenceCheckElevation(set, target.object, target.id, childName, fieldName, context); + if (opts.fileElevations) { + this.fileReferenceCheckElevation(set, target.object, target.id, childName, fieldName, context); + } const rows = await this.probeReferencingRows( childName, fieldName, fdef, target.id, this.referenceProbeFilter(fieldName, fdef, target.id), context, );