diff --git a/.changeset/23948-declared-rule-mirrors-advisory.md b/.changeset/23948-declared-rule-mirrors-advisory.md new file mode 100644 index 00000000000..068d58b1c14 --- /dev/null +++ b/.changeset/23948-declared-rule-mirrors-advisory.md @@ -0,0 +1,19 @@ +--- +"@objectstack/cli": minor +--- + +feat(cli): `os migrate organization-ownership --apply` certifies a database whose packages declare sharing rules — the declared sharing-rule mirrors this release's boot still seeds are reported as advisory, not as discrepancies + +Clause-②: yes (widening — the apply report file, the plan file and both `--json` payloads gain an `advisory` field on a departure entry, and the run's `advisory` count includes that departure's rows) + +- **What an operator saw.** On a v17 database whose packages declare sharing rules (HotCRM declares eleven), `--apply` ended with `complete: true` and `blocking: 11`, and never certified: the `adr-0131-organization-ownership` row of `sys_migration` kept `verified_at` null. The post-check waited for the `declared-rule-mirrors` departure on `sys_sharing_rule` to leave. That departure is the uncustomized `managed_by` `package` / `platform` rows with no organization. This release's own served boot writes those rows on every start, including the first v18 boot that the organization-ownership refusal stops, so they could never leave on this release. +- **What it sees now.** `--apply` reports `blocking: 0` and certifies: `verified_at` is set, with `blocking` 0 in the marker every reader checks. The mirror rows are named, counted and kept: + - the plan and the apply print a warning before the first write: `sys_sharing_rule holds N organization-less row(s) of … which stay: …`. The plan file's and the report's `warnings` carry it too; + - in the plan file and in the apply report (`--json` and the `--out` file), the `declared-rule-mirrors` entry of `tables[].departures[]` keeps `id`, `label`, `as` and `rows`, and gains `advisory`: why the rows stay, and what removes them. A departure without `advisory` still blocks; + - `tables[].remaining` no longer counts these rows. The run's `advisory` and the ledger's `advisory` count them, beside the unattributable rows; + - the rows keep `organization_id` NULL, so `sys_sharing_rule`'s `notNull` reads `willReceive: false` with `N row(s) stay NULL on this release, reported as advisory: …`. The plan file used to read `willReceive: true` for that table; + - the human summary prints `N row(s) advisory, not held against certification — …` under the table. Its closing warnings name the unattributable rows and the advisory rows separately; + - nothing is deleted. Every rule row stays as it was. +- **What still blocks.** Every other departure: `global-rung-move`, `template-promotion`, and a host fate supplement's own departures, even one named `declared-rule-mirrors`. Every other remaining row blocks too. +- **Already ran `--apply` and stopped at `blocking: 11`?** Plan again on this release, review the plan, and run `--apply` with it. This change leaves the inventory digest as it was, so a checkpoint recorded under this release's inventory resumes: only the post-check runs, and it certifies. A checkpoint recorded by a release whose inventory differed is superseded: every step runs again, each one idempotent, and the post-check certifies. +- **When blocking returns.** The release that retires the declared sharing-rule seeder (ADR-0131 D2) sets `DECLARED_SHARING_RULE_SEEDER_RUNS` to `false` in the same change. From that release on, these rows hold certification again until its ceremony deletes them. diff --git a/packages/cli/src/commands/migrate/organization-ownership.declared-rule-mirrors.integration.test.ts b/packages/cli/src/commands/migrate/organization-ownership.declared-rule-mirrors.integration.test.ts new file mode 100644 index 00000000000..044680f94f8 --- /dev/null +++ b/packages/cli/src/commands/migrate/organization-ownership.declared-rule-mirrors.integration.test.ts @@ -0,0 +1,238 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * `os migrate organization-ownership --apply` on a v17-shaped database whose + * packages declare sharing rules, on a release whose served boot still seeds + * them — through the command, over a SQLite file. + * + * ## The database + * + * Built the way an environment's is. First a served boot of THIS release + * (`@objectstack/verify`'s `bootStack`: `os serve`'s composition, plugin starts + * included) of a package declaring one sharing rule, over the file. Then what + * a 17.x runtime left beside it: ten more declared rules its seeder wrote + * organization-less, an organization's own rule, a customized declared rule + * with no organization, and a column-drop table still carrying + * `organization_id`. That is HotCRM's measured shape: eleven declared rules, + * one of them seeded by the first v18 boot, before any ceremony ran. + * + * ## The cases + * + * 1. The served boot writes the declared rule as exactly the row the + * `declared-rule-mirrors` departure names (`managed_by 'package'`, + * uncustomized, no organization), and that is what + * `DECLARED_SHARING_RULE_SEEDER_RUNS` states. ⛔ When the seeder retires + * this case fails until the constant flips — in the same change. + * 2. Plan, then `--apply --json`, development boots like the boot that built + * the file: `blocking 0`, the eleven rows advisory and named, the + * `sys_migration` marker verified with `blocking 0`, every rule row kept. + * Then a served boot of the certified file with the v18 boot guard + * composed (`os serve`'s refusal) starts — with a table put back in the + * v17 shape, so the guard decides on the marker and not on columns — and + * `os migrate apply`'s drop gate reads the marker as verified. + * + * The same verdict without a boot — the restore once the seeder retires, and a + * host departure of the same id that still blocks — is + * `utils/organization-ownership-apply.declared-rule-mirrors.test.ts`. + */ + +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; +import Database from 'better-sqlite3'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { ORGANIZATION_OWNERSHIP_MIGRATION_ID } from '@objectstack/spec/system'; +import { bootStack } from '@objectstack/verify'; +import { findV17ShapeTables, OrganizationOwnershipBootGuardPlugin } from '@objectstack/runtime'; +import { isExitSignal } from '../../utils/format.js'; +import { readOrganizationOwnershipMarker } from '../../utils/organization-ownership-drop-gate.js'; +import { DECLARED_SHARING_RULE_SEEDER_RUNS } from '../../utils/organization-ownership-inventory.js'; + +// Pay the first transform of the dist-resolved workspace deps the boots reach +// through dynamic `import()`s at MODULE LOAD, not inside a clocked case. +import '@objectstack/objectql'; +import '@objectstack/metadata-protocol'; + +type Command = (typeof import('./organization-ownership.js'))['default']; +let MigrateOrganizationOwnership: Command; + +const CLI_ROOT = fileURLToPath(new URL('../../..', import.meta.url)); +const CASE_TIMEOUT_MS = 120_000; +/** The rows a 17.x seeder left, beside the one this release's served boot writes. */ +const V17_MIRRORS = 10; +const MIRRORS = V17_MIRRORS + 1; + +/** Env that would point a boot somewhere other than the fixture, or change what it composes. */ +const OVERRIDING_ENV = [ + 'OS_DATABASE_URL', 'DATABASE_URL', 'TURSO_DATABASE_URL', 'TURSO_AUTH_TOKEN', 'OS_DATABASE_AUTH_TOKEN', + 'OS_DATABASE_DRIVER', 'OS_HOME', 'OS_ARTIFACT_PATH', 'OS_TELEMETRY_DB', 'OS_TENANCY_POSTURE', + 'OS_MULTI_ORG_ENABLED', 'OS_SKIP_SCHEMA_SYNC', 'NODE_ENV', +] as const; + +/** A package declaring one sharing rule; built anew per boot, as `bootStack` asks. */ +const stackConfig = () => ({ + manifest: { id: 'com.test.declared_rule_mirrors', name: 'Declared rule mirrors', type: 'app', version: '1.0.0' }, + objects: [ + { + name: 'crm_deal', + label: 'Deal', + fields: { name: { type: 'text', label: 'Name' }, status: { type: 'text', label: 'Status' } }, + }, + ], + sharingRules: [ + { + name: 'share_open_deals', + type: 'criteria', + object: 'crm_deal', + accessLevel: 'read', + condition: 'record.status == "open"', + sharedWith: { type: 'position', value: 'sales_rep' }, + }, + ], +}); + +let dir: string; +let file: string; +/** What the served boot left in `sys_sharing_rule`, read before the 17.x leftovers join it. */ +let seeded: Record[]; +const savedEnv: Record = {}; +const savedCwd = process.cwd(); + +/** One statement against the file, through a connection of its own. */ +function sql(statement: string, ...params: unknown[]): Record[] { + const db = new Database(file); + try { + const prepared = db.prepare(statement); + return prepared.reader ? (prepared.all(...params) as Record[]) : (prepared.run(...params), []); + } finally { + db.close(); + } +} + +/** The real command, its stdout parsed, and the exit it asked for. */ +async function run(args: string[]): Promise<{ payload: any; exitCode: number }> { + 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); + vi.spyOn(process.stderr, 'write').mockImplementation(swallow); + vi.spyOn(console, 'log').mockImplementation(() => {}); + vi.spyOn(console, 'warn').mockImplementation(() => {}); + vi.spyOn(console, 'error').mockImplementation(() => {}); + process.chdir(dir); + let exitCode: number | undefined; + try { + try { + await MigrateOrganizationOwnership.run(args, { root: CLI_ROOT }); + } catch (error) { + if (!isExitSignal(error)) throw error; + exitCode = (error as { oclif?: { exit?: number } }).oclif?.exit; + } + const payload = JSON.parse(stdout.mock.calls.map((c) => String(c[0])).join('').trim()); + return { payload, exitCode: exitCode ?? (process.exitCode as number | undefined) ?? 0 }; + } finally { + process.chdir(savedCwd); + process.exitCode = savedExit; + vi.restoreAllMocks(); + } +} + +describe('os migrate organization-ownership on a database whose packages declare sharing rules', () => { + beforeAll(async () => { + for (const key of OVERRIDING_ENV) { + savedEnv[key] = process.env[key]; + delete process.env[key]; + } + ({ default: MigrateOrganizationOwnership } = await import('./organization-ownership.js')); + dir = mkdtempSync(join(tmpdir(), 'os-ceremony-declared-rules-')); + file = join(dir, 'fixture.db'); + + // The served boot of this release, over the file. + const stack = await bootStack(stackConfig(), { databaseFile: file }); + await stack.stop(); + seeded = sql('SELECT name, managed_by, customized, organization_id FROM sys_sharing_rule ORDER BY name'); + + // What a 17.x runtime left beside it. + const [{ id: organization }] = sql(`SELECT id FROM sys_organization WHERE slug = 'default'`) as Array<{ id: string }>; + const rule = (id: string, name: string, managedBy: string, customized: number, organizationId: string | null) => + sql( + 'INSERT INTO sys_sharing_rule (id, organization_id, name, label, object_name, criteria_json, recipient_type, ' + + `recipient_id, access_level, active, managed_by, customized) VALUES (?, ?, ?, ?, 'crm_deal', ?, 'position', ` + + `'sales_rep', 'read', 1, ?, ?)`, + id, organizationId, name, name, '{"status":"open"}', managedBy, customized, + ); + for (let i = 1; i <= V17_MIRRORS; i++) rule(`rule_v17_${i}`, `share_v17_${i}`, 'package', 0, null); + rule('rule_organization', 'organization_rule', 'admin', 0, organization!); + rule('rule_customized', 'customized_rule', 'package', 1, null); + sql('ALTER TABLE sys_job ADD COLUMN organization_id varchar(255)'); + sql(`INSERT INTO sys_job (id, name, organization_id) VALUES ('job_v17', 'nightly', NULL)`); + }, CASE_TIMEOUT_MS); + + afterAll(() => { + for (const key of OVERRIDING_ENV) { + if (savedEnv[key] === undefined) delete process.env[key]; + else process.env[key] = savedEnv[key]; + } + if (dir) rmSync(dir, { recursive: true, force: true }); + }); + + it('a served boot of this release writes the declared rule as the row the departure names, as DECLARED_SHARING_RULE_SEEDER_RUNS states', () => { + expect( + seeded, + `DECLARED_SHARING_RULE_SEEDER_RUNS (utils/organization-ownership-inventory.ts) is ${DECLARED_SHARING_RULE_SEEDER_RUNS}, and ` + + `a served boot of this release wrote ${seeded.length} declared sharing rule row(s): set the constant to what the ` + + 'boot does, in the change that changed the boot', + ).toEqual( + DECLARED_SHARING_RULE_SEEDER_RUNS + ? [{ name: 'share_open_deals', managed_by: 'package', customized: 0, organization_id: null }] + : [], + ); + }); + + it('--apply certifies: blocking 0, the eleven rows advisory, the marker verified; then the served boot with the v18 guard starts', async () => { + const planFile = join(dir, 'plan.json'); + const plan = await run(['--dev', '--database-url', `file:${file}`, '--json', '--out', planFile]); + expect(plan.payload.error, JSON.stringify(plan.payload).slice(0, 800)).toBeUndefined(); + expect(plan.exitCode).toBe(0); + // Said before the first write, and carried by the plan file. + expect(plan.payload.plan.warnings).toContainEqual(expect.stringMatching(new RegExp(`^sys_sharing_rule holds ${MIRRORS} organization-less row\\(s\\) of `))); + + const apply = await run([ + '--dev', '--apply', '--backup-taken', '--plan-file', planFile, '--database-url', `file:${file}`, '--json', + '--out', join(dir, 'apply.json'), + ]); + expect(apply.payload.error, JSON.stringify(apply.payload).slice(0, 800)).toBeUndefined(); + const { report } = apply.payload; + const unattributable = report.tables.reduce((n: number, t: { unattributable: unknown[] }) => n + t.unattributable.length, 0); + expect([apply.exitCode, report.complete, report.blocking, report.advisory]).toEqual([0, true, 0, unattributable + MIRRORS]); + expect(report.tables.find((t: { object: string }) => t.object === 'sys_sharing_rule')).toMatchObject({ + remaining: 0, + departures: [{ id: 'declared-rule-mirrors', as: 'mirror-deletion', rows: MIRRORS, advisory: expect.stringMatching(/own boot still writes them/) }], + notNull: { willReceive: false, reason: expect.stringMatching(new RegExp(`^${MIRRORS} row\\(s\\) stay NULL on this release, reported as advisory: `)) }, + }); + expect( + sql('SELECT verified_at IS NOT NULL AS verified, applied_at IS NOT NULL AS applied, blocking, advisory FROM sys_migration WHERE id = ?', ORGANIZATION_OWNERSHIP_MIGRATION_ID), + ).toEqual([{ verified: 1, applied: 1, blocking: 0, advisory: unattributable + MIRRORS }]); + // Nothing of the table was deleted; the eleven kept their NULL. + expect(sql('SELECT COUNT(*) AS n, SUM(organization_id IS NULL) AS nulls FROM sys_sharing_rule')).toEqual([{ n: MIRRORS + 2, nulls: MIRRORS }]); + + // A table back in the v17 shape, so the guard decides on the marker. + sql('ALTER TABLE sys_job ADD COLUMN organization_id varchar(255)'); + const served = await bootStack(stackConfig(), { + databaseFile: file, + extraPlugins: [new OrganizationOwnershipBootGuardPlugin({ dev: true })], + }); + try { + const engine = served.kernel.getService('objectql') as Parameters[0] & + Parameters[0]; + expect(await findV17ShapeTables(engine)).toEqual(['sys_job']); + expect((await readOrganizationOwnershipMarker(engine)).verified).toBe(true); + } finally { + await served.stop(); + } + }, CASE_TIMEOUT_MS); +}); diff --git a/packages/cli/src/commands/migrate/organization-ownership.remote-face.integration.test.ts b/packages/cli/src/commands/migrate/organization-ownership.remote-face.integration.test.ts index a38ebc28a9a..1c1a568acbb 100644 --- a/packages/cli/src/commands/migrate/organization-ownership.remote-face.integration.test.ts +++ b/packages/cli/src/commands/migrate/organization-ownership.remote-face.integration.test.ts @@ -27,7 +27,9 @@ * - the plan exits 0 and leaves the database byte-identical (a logical dump of * every catalog row and every table's rows, before and after), and names the * remote database, not the placeholder Knex client; - * - the apply completes with `blocking 0`; + * - the apply completes with `blocking 0`, the declared sharing-rule mirrors a + * 17.x seeder left reported as advisory (this release's boot still seeds + * them: `DECLARED_SHARING_RULE_SEEDER_RUNS`), not as discrepancies; * - each column and the index keyed on it are gone from the server, the rows * are not, and the `adr-0131-organization-ownership` marker is verified. * @@ -77,6 +79,8 @@ const OVERRIDING_ENV = [ /** `column-drop` tables the 17.x leftovers are put on, when the composition has them. */ const LEFTOVER_CANDIDATES = ['sys_job', 'sys_metadata_activation', 'sys_presence'] as const; +/** Declared sharing rules a 17.x seeder wrote organization-less (HotCRM declares eleven). */ +const RULE_MIRRORS = 11; let dir: string; let projectDir: string; @@ -204,6 +208,15 @@ describe.skipIf(!SQLD)('os migrate organization-ownership on a Turso REMOTE data if (leftovers.includes('sys_job')) { await exec(`INSERT INTO "sys_job" ("id", "organization_id") VALUES ('job_v17_a', NULL), ('job_v17_b', NULL)`); } + if (tables.has('sys_sharing_rule')) { + for (let i = 1; i <= RULE_MIRRORS; i++) { + await exec( + `INSERT INTO "sys_sharing_rule" ("id", "name", "object_name", "managed_by", "customized", "organization_id") ` + + `VALUES (?, ?, 'crm_deal', 'package', 0, NULL)`, + [`rule_v17_${i}`, `share_v17_${i}`], + ); + } + } }); expect(leftovers.length).toBeGreaterThan(0); }, CASE_TIMEOUT_MS); @@ -233,6 +246,10 @@ describe.skipIf(!SQLD)('os migrate organization-ownership on a Turso REMOTE data ]); expect(apply.payload.error, JSON.stringify(apply.payload).slice(0, 800)).toBeUndefined(); expect([apply.exitCode, apply.payload.report.complete, apply.payload.report.blocking]).toEqual([0, true, 0]); + expect(apply.payload.report.tables.find((t: { object: string }) => t.object === 'sys_sharing_rule')).toMatchObject({ + remaining: 0, + departures: [{ id: 'declared-rule-mirrors', rows: RULE_MIRRORS, advisory: expect.any(String) }], + }); for (const t of leftovers) expect(await hasOrganizationColumn(t)).toBe(false); const after = await secondConnection(async (exec) => ({ @@ -244,7 +261,8 @@ describe.skipIf(!SQLD)('os migrate organization-ownership on a Turso REMOTE data [ORGANIZATION_OWNERSHIP_MIGRATION_ID], )).map((r) => ({ applied: Number(r.applied), verified: Number(r.verified) })), jobs: leftovers.includes('sys_job') ? Number((await exec('SELECT count(*) AS n FROM "sys_job"'))[0]!.n) : 2, + rules: Number((await exec('SELECT count(*) AS n FROM "sys_sharing_rule" WHERE "organization_id" IS NULL'))[0]!.n), })); - expect(after).toEqual({ indexes: [], marker: [{ applied: 1, verified: 1 }], jobs: 2 }); + expect(after).toEqual({ indexes: [], marker: [{ applied: 1, verified: 1 }], jobs: 2, rules: RULE_MIRRORS }); }, CASE_TIMEOUT_MS); }); diff --git a/packages/cli/src/commands/migrate/organization-ownership.ts b/packages/cli/src/commands/migrate/organization-ownership.ts index 1fa89dac356..7e5b49d20b9 100644 --- a/packages/cli/src/commands/migrate/organization-ownership.ts +++ b/packages/cli/src/commands/migrate/organization-ownership.ts @@ -257,7 +257,9 @@ class ReportFileReservation { * row {@link ORGANIZATION_OWNERSHIP_MIGRATION_ID}) so an interrupted run * resumes where it stopped, and ends with the post-check. The ledger row is * the ceremony's marker: `applied_at` once every step has finished, - * `verified_at` when the post-check also reads zero discrepancies. A report + * `verified_at` when the post-check also reads zero discrepancies (a departure + * this release cannot perform — the declared sharing-rule mirrors its own boot + * keeps seeding — is reported as advisory, not as a discrepancy). A report * file is written beside the plan's, never over an existing one: it is reserved * (created exclusively) before the boot, so an `--out` that cannot be written * is refused before anything executes (`apply_refused`), and a report that @@ -972,7 +974,11 @@ export default class MigrateOrganizationOwnership extends Command { `${table.remaining ? chalk.red(`remaining ${table.remaining}`) : ''}`, ); for (const departure of table.departures) { - console.log(chalk.yellow(` ${departure.rows} row(s) still to leave by a step not yet run — ${departure.label}`)); + console.log(chalk.yellow( + departure.advisory + ? ` ${departure.rows} row(s) advisory, not held against certification — ${departure.label}: ${departure.advisory}` + : ` ${departure.rows} row(s) still to leave by a step not yet run — ${departure.label}`, + )); } for (const conflict of table.templateConflicts ?? []) { console.log(chalk.yellow( @@ -983,8 +989,17 @@ export default class MigrateOrganizationOwnership extends Command { } } console.log(''); - if (report.advisory > 0) { - printWarning(`${report.advisory} row(s) whose owner cannot be derived stay NULL; they are listed by id in the report file.`); + // `advisory` counts two populations; each is said in its own terms. + const unattributable = report.tables.reduce((n, t) => n + t.unattributable.length, 0); + const held = report.tables.reduce((n, t) => n + t.departures.reduce((m, d) => m + (d.advisory ? d.rows : 0), 0), 0); + if (unattributable > 0) { + printWarning(`${unattributable} row(s) whose owner cannot be derived stay NULL; they are listed by id in the report file.`); + } + if (held > 0) { + printWarning( + `${held} row(s) of a departure this release cannot perform stay NULL and are reported as advisory, not as ` + + 'discrepancies; each is named above with what removes it.', + ); } if (verdictCode === 0) { printSuccess(`The ceremony is complete; recorded as ${ORGANIZATION_OWNERSHIP_MIGRATION_ID}. Report: ${target}`); diff --git a/packages/cli/src/utils/organization-ownership-apply.declared-rule-mirrors.test.ts b/packages/cli/src/utils/organization-ownership-apply.declared-rule-mirrors.test.ts new file mode 100644 index 00000000000..75b1a208172 --- /dev/null +++ b/packages/cli/src/utils/organization-ownership-apply.declared-rule-mirrors.test.ts @@ -0,0 +1,256 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The ADR-0131 D10 `--apply` on a database whose packages declare sharing + * rules, while this release's own boot still seeds them + * (`DECLARED_SHARING_RULE_SEEDER_RUNS`, the inventory). + * + * The fixture is HotCRM's measured shape: eleven uncustomized declared rules + * the seeder wrote organization-less (`managed_by 'package'`, `customized 0`), + * beside an organization-authored rule and a customized declared rule with no + * organization, plus a column-drop table still carrying `organization_id`. + * + * - On this release the eleven are the `declared-rule-mirrors` departure, and + * no step of it can remove them: the run reports them as advisory — named, + * counted, still NULL, never deleted — and the verdict is `blocking 0`. + * - The restore: the release that retires the seeder flips the constant, and + * the same fixture then holds certification on the same eleven rows again. + * - The rule is keyed on the table AND the departure id: a host fate + * supplement departure of the same id on its own table still blocks. + * + * The command face of the same verdict (the ledger marker, the boot guard, + * the served boot that seeds the rows) is + * `commands/migrate/organization-ownership.declared-rule-mirrors.integration.test.ts`. + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import Database from 'better-sqlite3'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { buildOrganizationOwnershipPlan, inventoryDigest } from './organization-ownership-plan.js'; +import { + applyOrganizationOwnership, + reviewedConflictsOf, + type ApplyExecutor, + type CeremonyCheckpoint, + type CeremonyLedgerPort, + type ColumnDropPort, + type OrganizationOwnershipApplyReport, + type TableOutcome, +} from './organization-ownership-apply.js'; +import { mergeHostFateSupplement, type InventoryEntry } from './organization-ownership-inventory.js'; + +/** + * The restore case answers as the release that retires the seeder will + * (`runs: false`); every other case reads the shipped constant, through the + * real function, untouched (`runs: undefined`). + */ +const seeder = vi.hoisted(() => ({ runs: undefined as boolean | undefined })); +vi.mock('./organization-ownership-inventory.js', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + advisoryDepartureReason: (object: string, departureId: string) => + seeder.runs === undefined + ? actual.advisoryDepartureReason(object, departureId) + : actual.advisoryDepartureReason(object, departureId, seeder.runs), + }; +}); + +const MIRRORS = 11; +const CITE = 'fixture: a host-declared fate (test data, not any host\'s declaration)'; + +let dir: string; +let db: Database.Database; +/** Every write statement the ceremony issued, in order. */ +let writes: string[]; + +function executor(): ApplyExecutor { + return { + dialect: 'sqlite', + client: 'better-sqlite3', + async query(sql, params = []) { + return db.prepare(sql).all(...(params as unknown[])) as Record[]; + }, + async execute(sql, params = []) { + writes.push(sql); + db.prepare(sql).run(...(params as unknown[])); + }, + }; +} + +const columnDrops: ColumnDropPort = { + async drop(columns) { + return columns.map(({ table, column }) => { + writes.push(`ALTER TABLE ${table} DROP COLUMN ${column}`); + db.exec(`ALTER TABLE "${table}" DROP COLUMN "${column}"`); + return { table, column, outcome: 'dropped' as const }; + }); + }, +}; + +type Verdict = { complete: boolean; blocking: number; advisory: number }; + +function ledger(): CeremonyLedgerPort & { verdicts: Verdict[] } { + let stored: CeremonyCheckpoint | null = null; + const verdicts: Verdict[] = []; + return { + verdicts, + async read() { + return stored ? (JSON.parse(JSON.stringify(stored)) as CeremonyCheckpoint) : null; + }, + async write(checkpoint, verdict) { + stored = JSON.parse(JSON.stringify(checkpoint)) as CeremonyCheckpoint; + verdicts.push(verdict); + }, + }; +} + +/** Plan, then apply that reviewed plan, under `single`, as the command does. */ +async function apply(l: ReturnType, inventory?: readonly InventoryEntry[]): Promise { + const database = join(dir, 'fixture.db'); + const reviewed = await buildOrganizationOwnershipPlan({ + reader: executor(), + posture: 'single', + database, + ...(inventory ? { inventory } : {}), + }); + return applyOrganizationOwnership({ + executor: executor(), + posture: 'single', + database, + ...(inventory ? { inventory } : {}), + reviewedPlan: { + file: 'plan.json', + generatedAt: reviewed.generatedAt, + database, + inventoryDigest: inventoryDigest(inventory), + posture: 'single', + conflicts: reviewedConflictsOf(reviewed), + }, + catalog: { async resolve() { return undefined; } }, + door: { + async save(type, name) { + throw new Error(`no environment save is expected here (${type} ${name})`); + }, + }, + templates: { async find() { return undefined; } }, + views: { async list() { return []; } }, + lock: { async verdict() { return { editable: true }; } }, + columnDrops, + ledger: l, + now: () => new Date('2026-10-16T00:00:00Z'), + }); +} + +const rows = (sql: string): Record[] => db.prepare(sql).all() as Record[]; +const tableOf = (report: OrganizationOwnershipApplyReport, object: string): TableOutcome => + report.tables.find((t) => t.object === object)!; + +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'os-d10-declared-rule-mirrors-')); + db = new Database(join(dir, 'fixture.db')); + writes = []; + db.exec(` + CREATE TABLE sys_organization (id TEXT PRIMARY KEY, slug TEXT); + INSERT INTO sys_organization VALUES ('org_default', 'default'); + CREATE TABLE sys_sharing_rule (id TEXT PRIMARY KEY, organization_id TEXT, name TEXT, object_name TEXT, + managed_by TEXT, customized INTEGER); + INSERT INTO sys_sharing_rule VALUES + ('rule_org', 'org_default', 'mine', 'crm_deal', 'admin', 0), + ('rule_customized', NULL, 'share_custom', 'crm_deal', 'package', 1); + CREATE TABLE sys_job (id TEXT PRIMARY KEY, organization_id TEXT, name TEXT); + INSERT INTO sys_job VALUES ('job_v17', NULL, 'nightly'); + `); + const mirror = db.prepare(`INSERT INTO sys_sharing_rule VALUES (?, NULL, ?, 'crm_deal', 'package', 0)`); + for (let i = 1; i <= MIRRORS; i++) mirror.run(`rule_declared_${String(i).padStart(2, '0')}`, `share_declared_${i}`); +}); + +afterEach(() => { + seeder.runs = undefined; + db.close(); + rmSync(dir, { recursive: true, force: true }); +}); + +describe('the declared sharing-rule mirrors while this release\'s boot still seeds them', () => { + it('blocking 0: the eleven are advisory — named, counted, still NULL, never deleted — and the verdict certifies', async () => { + const l = ledger(); + const report = await apply(l); + + expect([report.complete, report.blocking, report.advisory]).toEqual([true, 0, MIRRORS]); + expect(l.verdicts.at(-1)).toEqual({ complete: true, blocking: 0, advisory: MIRRORS }); + const rule = tableOf(report, 'sys_sharing_rule'); + expect(rule.departures).toEqual([ + { + id: 'declared-rule-mirrors', + label: expect.stringMatching(/^uncustomized declared sharing rules/), + as: 'mirror-deletion', + rows: MIRRORS, + advisory: expect.stringMatching(/^this release's own boot still writes them .* \(ADR-0131 D2\) deletes them$/), + }, + ]); + expect(rule.remaining).toBe(0); + expect(rule.notNull).toEqual({ + willReceive: false, + reason: `${MIRRORS} row(s) stay NULL on this release, reported as advisory: ${rule.departures[0]!.label}`, + }); + expect(report.warnings).toEqual([expect.stringMatching(new RegExp(`^sys_sharing_rule holds ${MIRRORS} organization-less row\\(s\\) of `))]); + // The customized rule is no mirror: it took the Default Organization. The + // mirrors kept NULL, and nothing of the table was deleted. + expect(rows(`SELECT COUNT(*) AS n, SUM(organization_id IS NULL) AS nulls FROM sys_sharing_rule`)).toEqual([{ n: MIRRORS + 2, nulls: MIRRORS }]); + expect(rows(`SELECT organization_id FROM sys_sharing_rule WHERE id = 'rule_customized'`)).toEqual([{ organization_id: 'org_default' }]); + expect(writes.filter((w) => /^DELETE/i.test(w))).toEqual([]); + expect(tableOf(report, 'sys_job').columnDropped).toBe(true); + }); + + it('the restore: once the seeder retires, the same fixture holds certification on the same eleven rows again', async () => { + seeder.runs = false; + const l = ledger(); + const report = await apply(l); + + expect([report.complete, report.blocking, report.advisory]).toEqual([true, MIRRORS, 0]); + expect(l.verdicts.at(-1)).toEqual({ complete: true, blocking: MIRRORS, advisory: 0 }); + const rule = tableOf(report, 'sys_sharing_rule'); + expect(rule.departures).toEqual([{ id: 'declared-rule-mirrors', label: expect.any(String), as: 'mirror-deletion', rows: MIRRORS }]); + expect(rule.remaining).toBe(MIRRORS); + expect(rule.notNull).toEqual({ willReceive: false, reason: expect.stringMatching(new RegExp(`^${MIRRORS} row\\(s\\) still wait for a step not yet run: `)) }); + expect(report.warnings).toEqual([]); + expect(rows(`SELECT COUNT(*) AS n FROM sys_sharing_rule`)).toEqual([{ n: MIRRORS + 2 }]); + }); + + it('keyed on the table: a host-declared departure under the same id on its own table still blocks, beside the advisory mirrors', async () => { + db.exec(` + CREATE TABLE sys_fixture_rule_copy (id TEXT PRIMARY KEY, organization_id TEXT, managed_by TEXT); + INSERT INTO sys_fixture_rule_copy VALUES ('copy_1', NULL, 'package'), ('copy_2', NULL, 'package'); + `); + const inventory = mergeHostFateSupplement( + [ + { + object: 'sys_fixture_rule_copy', + fate: 'report', + citation: CITE, + departures: [ + { + id: 'declared-rule-mirrors', + label: 'a host table\'s seeded copies, removed by a step no ceremony runs', + citation: CITE, + as: 'mirror-deletion', + where: [{ column: 'managed_by', equals: 'package' }], + }, + ], + }, + ], + 'host-fates.json', + ); + const l = ledger(); + const report = await apply(l, inventory); + + expect([report.complete, report.blocking, report.advisory]).toEqual([true, 2, MIRRORS]); + expect(l.verdicts.at(-1)).toEqual({ complete: true, blocking: 2, advisory: MIRRORS }); + const copy = tableOf(report, 'sys_fixture_rule_copy'); + expect(copy.remaining).toBe(2); + expect(copy.departures).toEqual([{ id: 'declared-rule-mirrors', label: expect.any(String), as: 'mirror-deletion', rows: 2 }]); + expect(tableOf(report, 'sys_sharing_rule').remaining).toBe(0); + }); +}); diff --git a/packages/cli/src/utils/organization-ownership-apply.test.ts b/packages/cli/src/utils/organization-ownership-apply.test.ts index a768930c8d0..b096ca4d4a6 100644 --- a/packages/cli/src/utils/organization-ownership-apply.test.ts +++ b/packages/cli/src/utils/organization-ownership-apply.test.ts @@ -882,7 +882,7 @@ describe('ADR-0131 D10 --apply — idempotent, resumable, gated', () => { expect(rows('SELECT COUNT(*) AS n FROM sys_email_template')).toEqual([{ n: 6 }]); }); - it('does not certify a ceremony whose departures have not left: the declared sharing-rule mirrors keep verified_at null', async () => { + it('certifies a ceremony whose only remaining NULL rows are the declared sharing-rule mirrors this release still seeds: advisory, named, kept', async () => { db.exec(` CREATE TABLE sys_sharing_rule (id TEXT PRIMARY KEY, organization_id TEXT, name TEXT, managed_by TEXT, customized INTEGER); INSERT INTO sys_sharing_rule VALUES ('rule_declared', NULL, 'share_deals', 'package', 0), ('rule_org', 'org_b', 'mine', 'admin', 0); @@ -890,17 +890,29 @@ describe('ADR-0131 D10 --apply — idempotent, resumable, gated', () => { const l = ledger(); const report = await apply(catalog(), l); - // Every step ran, and the moved settings rung reads zero; the rule mirror - // is D2's to remove, so it still counts and is named. + // Every step ran, and the moved settings rung reads zero. The rule mirror + // is D2's to remove, and this release's own boot keeps seeding it, so it + // is advisory: named and counted beside the unattributable rows, never a + // discrepancy (the restore and the negative legs: + // `organization-ownership-apply.declared-rule-mirrors.test.ts`). + const unattributable = report.tables.reduce((n, t) => n + t.unattributable.length, 0); expect(report.complete).toBe(true); - expect(report.blocking).toBe(1); - expect(l.verdicts.at(-1)).toMatchObject({ complete: true, blocking: 1 }); + expect(report.blocking).toBe(0); + expect(report.advisory).toBe(unattributable + 1); + expect(l.verdicts.at(-1)).toEqual({ complete: true, blocking: 0, advisory: unattributable + 1 }); expect(tableOf(report, 'sys_setting').departures).toEqual([]); const rule = tableOf(report, 'sys_sharing_rule'); - expect(rule.remaining).toBe(1); - expect(rule.departures).toEqual([expect.objectContaining({ id: 'declared-rule-mirrors', as: 'mirror-deletion', rows: 1 })]); - expect(rule.notNull.willReceive).toBe(false); - expect(rows(`SELECT id FROM sys_sharing_rule ORDER BY id`).map((r) => r.id)).toEqual(['rule_declared', 'rule_org']); + expect(rule.remaining).toBe(0); + expect(rule.departures).toEqual([ + { id: 'declared-rule-mirrors', label: expect.any(String), as: 'mirror-deletion', rows: 1, advisory: expect.stringMatching(/own boot still writes them/) }, + ]); + // Still NULL, so the column takes no NOT NULL; the operator was told before the first write. + expect(rule.notNull).toEqual({ willReceive: false, reason: expect.stringMatching(/^1 row\(s\) stay NULL on this release, reported as advisory: /) }); + expect(report.warnings.filter((w) => w.startsWith('sys_sharing_rule holds 1 organization-less row(s) of '))).toHaveLength(1); + expect(rows(`SELECT id, organization_id FROM sys_sharing_rule ORDER BY id`)).toEqual([ + { id: 'rule_declared', organization_id: null }, + { id: 'rule_org', organization_id: 'org_b' }, + ]); }); it('promotes under a wall too: a template with no conflict ends verified, its seeded mirror and its row gone', async () => { diff --git a/packages/cli/src/utils/organization-ownership-apply.ts b/packages/cli/src/utils/organization-ownership-apply.ts index 31d05723488..e3237c0dc62 100644 --- a/packages/cli/src/utils/organization-ownership-apply.ts +++ b/packages/cli/src/utils/organization-ownership-apply.ts @@ -124,6 +124,11 @@ import { describeSecurityPluginComposition, type SchemaMigrationComposition } fr * one would refuse them; they are reported, never deleted). * * Then the post-check re-runs the plan and reports, per table, what remains. + * A departure no step of this release can perform — the declared sharing-rule + * mirrors, which this release's own boot keeps seeding + * (`DECLARED_SHARING_RULE_SEEDER_RUNS`, the inventory) — is reported as + * advisory: named and counted, its rows still NULL, never deleted, and not a + * discrepancy. Every other remaining row is one. * * ## Idempotent and resumable * @@ -431,12 +436,15 @@ export interface TableOutcome { /** * Rows that leave this table by a step this ceremony has not yet performed * (a move, or a mirror its own stage removes): each counts as remaining - * until it is gone, and its label is the remedy. + * until it is gone, and its label is the remedy. One that carries `advisory` + * is a departure no step of this release can perform, because its own boot + * re-creates the rows (`PlannedDeparture.advisory`): its rows are named and + * counted here and in the run's `advisory`, never in `remaining`. */ - departures: Array<{ id: string; label: string; as: 'mirror-deletion' | 'moved'; rows: number }>; + departures: Array<{ id: string; label: string; as: 'mirror-deletion' | 'moved'; rows: number; advisory?: string }>; /** Templates: each `(name, locale)` whose rows still differ, with every candidate row and its organization. */ templateConflicts?: TemplateConflict[]; - /** Post-check: what is left for this table's fate, `0` when the fate is done. */ + /** Post-check: what is left for this table's fate, `0` when the fate is done; advisory departures are not in it. */ remaining: number; notNull: TablePlan['notNull']; } @@ -466,7 +474,11 @@ export interface OrganizationOwnershipApplyReport { complete: boolean; /** Blockers (an unfinished ceremony) or post-check discrepancies (a finished one). Zero is the verdict. */ blocking: number; - /** Rows reported, never guessed: unattributable rows and rows held for another stage. */ + /** + * Rows reported, never guessed and never held against the verdict: + * unattributable rows, and the rows of an advisory departure, held for the + * release that can remove them (`TableOutcome.departures`). + */ advisory: number; } @@ -2506,8 +2518,15 @@ const RUNNERS: Record Promise> = { 'column-drops': runColumnDrops, }; -/** NULL rows a departure still has to carry out of the table. */ -const departingRows = (table: TablePlan): number => table.departures.reduce((n, d) => n + d.rows, 0); +/** + * NULL rows a departure still has to carry out of the table. An advisory + * departure's rows are not among them: no step of this release removes them + * (`PlannedDeparture.advisory`), so they are counted as advisory instead. + */ +const departingRows = (table: TablePlan): number => table.departures.reduce((n, d) => n + (d.advisory ? 0 : d.rows), 0); + +/** NULL rows of the table's advisory departures: reported, never held against the verdict. */ +const advisoryRows = (table: TablePlan): number => table.departures.reduce((n, d) => n + (d.advisory ? d.rows : 0), 0); /** What a table still owes its fate, read from the post-check plan. */ async function remainingFor(run: Run, table: TablePlan, complete: boolean): Promise { @@ -2732,7 +2751,9 @@ export async function applyOrganizationOwnership(opts: ApplyOptions): Promise d.rows > 0).map((d) => ({ id: d.id, label: d.label, as: d.as, rows: d.rows })); + const departures: TableOutcome['departures'] = (a?.departures ?? []) + .filter((d) => d.rows > 0) + .map((d) => ({ id: d.id, label: d.label, as: d.as, rows: d.rows, ...(d.advisory ? { advisory: d.advisory } : {}) })); if (a?.physical === 'present' && object === TEMPLATES) { let unpromoted = 0; for (const physical of a.tables) { @@ -2747,7 +2768,10 @@ export async function applyOrganizationOwnership(opts: ApplyOptions): Promise n + d.rows, 0); + // Advisory rows stay NULL too, and the plan's own NOT NULL reading + // (`ref.notNull`) already says so; only a step not yet run overrides it. + const pending = departures.filter((d) => !d.advisory); + const waiting = pending.reduce((n, d) => n + d.rows, 0); tables.push({ object, fate: ref.fate, @@ -2763,11 +2787,11 @@ export async function applyOrganizationOwnership(opts: ApplyOptions): Promise 0 - ? { willReceive: false, reason: `${waiting} row(s) still wait for a step not yet run: ${departures.map((d) => d.label).join('; ')}` } + ? { willReceive: false, reason: `${waiting} row(s) still wait for a step not yet run: ${pending.map((d) => d.label).join('; ')}` } : ref.notNull, }); } - const advisory = after.summary.unattributableRows; + const advisory = after.summary.unattributableRows + after.tables.reduce((n, t) => n + advisoryRows(t), 0); const blocking = complete ? discrepancies : steps.reduce((n, s) => n + s.blockers.length, 0) || 1; await opts.ledger.write(checkpoint, { complete, blocking, advisory }); return { diff --git a/packages/cli/src/utils/organization-ownership-inventory.ts b/packages/cli/src/utils/organization-ownership-inventory.ts index 0898cad9b82..e15a504d8b8 100644 --- a/packages/cli/src/utils/organization-ownership-inventory.ts +++ b/packages/cli/src/utils/organization-ownership-inventory.ts @@ -509,6 +509,8 @@ export const ORGANIZATION_OWNERSHIP_INVENTORY: readonly InventoryEntry[] = [ fate: 'attribution', citation: `${D3_ORG_ROWS} (sharing rules an organization authors); ${D10_SINGLE}`, anchors: [], + // Advisory, not blocking, while this release's boot still seeds these rows: + // DECLARED_SHARING_RULE_SEEDER_RUNS below. departures: [ { id: 'declared-rule-mirrors', @@ -612,6 +614,68 @@ export function inventoryEntryFor(object: string): InventoryEntry | undefined { return BY_OBJECT.get(object); } +// ── A departure this release cannot perform: the declared sharing-rule mirrors ─ + +/** + * Does a served boot of this release still write the package-declared sharing + * rules into `sys_sharing_rule`? + * + * It does: `@objectstack/plugin-sharing`'s `start()` runs + * `bootstrapDeclaredSharingRules` on every `os serve` / `os dev` boot, and + * under `single` it writes each declared rule organization-less, as + * `managed_by 'package'`, uncustomized — exactly the population the + * `declared-rule-mirrors` departure on `sys_sharing_rule` names. That includes + * the first v18 boot, the one the organization-ownership refusal stops: its + * plugins start before `kernel:ready`, so it seeds before the ceremony has run. + * (The ceremony's own boot does not: it composes the sharing plugin for its + * declarations, its `start()` suppressed.) + * + * So on this release that departure cannot happen: the step that removes the + * mirrors is ADR-0131 D2's retirement of that seeder, and the boot keeps + * re-creating them until then. The ceremony therefore reports those rows as + * advisory — named, counted, never deleted, and still NULL, so their table + * does not receive NOT NULL — and does not hold its certification on them + * ({@link advisoryDepartureReason}). That is the ordering ruled for ADR-0131 + * C3: the D13/D14 zero-NULL gate is read as satisfied by a D10 report whose + * only remaining NULL rows are these mirrors, and any other NULL row keeps + * blocking. + * + * ⛔ Set it to `false` in the change that retires that seeder, and in no other: + * a release that seeds the rows must not hold certification on them, and a + * release that has stopped seeding them must hold it again until its ceremony + * deletes them. `commands/migrate/organization-ownership.declared-rule-mirrors.integration.test.ts` + * boots a served stack declaring one sharing rule and fails, naming this + * constant, while what that boot writes disagrees with it. + */ +export const DECLARED_SHARING_RULE_SEEDER_RUNS: boolean = true; + +/** The one departure {@link DECLARED_SHARING_RULE_SEEDER_RUNS} decides, by its table and its id. */ +const DECLARED_RULE_MIRRORS = { object: 'sys_sharing_rule', departure: 'declared-rule-mirrors' } as const; + +/** + * Why the rows of `departureId` on `object` stay on this release without + * holding the ceremony's certification, or `null` when they hold it — every + * departure but the declared sharing-rule mirrors, and those too once their + * seeder retires ({@link DECLARED_SHARING_RULE_SEEDER_RUNS}). + * + * Keyed on the table AND the id: a host fate supplement can declare a + * departure under any id on its own table, and that departure's rows block + * like every other. The answer is the ceremony's verdict rule, not inventory + * data, so it is not part of the plan's `inventoryDigest`: a plan reviewed + * under it executes the same writes either way. + */ +export function advisoryDepartureReason( + object: string, + departureId: string, + seederRuns: boolean = DECLARED_SHARING_RULE_SEEDER_RUNS, +): string | null { + if (!seederRuns || object !== DECLARED_RULE_MIRRORS.object || departureId !== DECLARED_RULE_MIRRORS.departure) return null; + return ( + 'this release\'s own boot still writes them (the declared-sharing-rule seeder runs on every served boot), so no ' + + 'step of this release can remove them; the release that retires that seeder (ADR-0131 D2) deletes them' + ); +} + // ── The host fate supplement ────────────────────────────────────────────────── /** Why a host fate supplement cannot join the inventory. */ diff --git a/packages/cli/src/utils/organization-ownership-plan.ts b/packages/cli/src/utils/organization-ownership-plan.ts index ba6386b1e9c..7d3fd9851c9 100644 --- a/packages/cli/src/utils/organization-ownership-plan.ts +++ b/packages/cli/src/utils/organization-ownership-plan.ts @@ -9,6 +9,7 @@ import { LEGACY_SHIPPED_SET_SEEDS, LEGACY_SHIPPED_SET_SEEDS_CATEGORY, ORGANIZATION_OWNERSHIP_INVENTORY, + advisoryDepartureReason, type Anchor, type InventoryCategory, type InventoryEntry, @@ -163,6 +164,15 @@ export interface PlannedDeparture { as: 'mirror-deletion' | 'moved'; /** NULL-organization rows this departure takes out of attribution. */ rows: number; + /** + * Present when no step of this release can carry these rows out, because its + * own boot re-creates them: why, and what removes them + * ({@link advisoryDepartureReason}). Such rows stay NULL — their table does + * not receive NOT NULL — and are reported as advisory, never held against + * the ceremony's certification. Absent, the rows leave by a ceremony step, + * and count as remaining until they have. + */ + advisory?: string; } export interface TablePlan { @@ -980,7 +990,10 @@ async function planPresentTable(ctx: PlanContext, entry: InventoryEntry, source: organizationColumn: hasOrg ? 'present' : 'absent', rows: { total: 0, organizationNull: 0, stamped: 0 }, fateCounts: {}, - departures: (entry.departures ?? []).map((d) => ({ id: d.id, label: d.label, citation: d.citation, as: d.as, rows: 0 })), + departures: (entry.departures ?? []).map((d) => { + const advisory = advisoryDepartureReason(entry.object, d.id); + return { id: d.id, label: d.label, citation: d.citation, as: d.as, rows: 0, ...(advisory ? { advisory } : {}) }; + }), categories: [], unattributable: [], notNull: { willReceive: false, reason: '' }, @@ -1048,6 +1061,9 @@ async function planPresentTable(ctx: PlanContext, entry: InventoryEntry, source: const rows = plan.rows!; const departing = plan.departures.reduce((n, d) => n + d.rows, 0); + // NULL rows no step of this release removes, beside the unattributable + // ones: they stay NULL, so the column stays nullable on this release. + const held = heldNullReason(plan); switch (entry.fate) { case 'column-drop': plan.fateCounts = { columnPresent: hasOrg, rowsLosingTheirStamp: rows.stamped, rowsWithNullOrganization: rows.organizationNull }; @@ -1061,27 +1077,50 @@ async function planPresentTable(ctx: PlanContext, entry: InventoryEntry, source: const attributed = Object.values(derived).reduce((n, v) => n + v, 0); plan.attribution = derived; plan.fateCounts = { rowsToAttribute: attributed, departingRows: departing, unattributableRows: plan.unattributable.length }; + const staying = [ + ...(plan.unattributable.length > 0 ? [`${plan.unattributable.length} row(s) whose owner cannot be derived stay NULL and are reported`] : []), + ...(held ? [held] : []), + ]; plan.notNull = !hasOrg ? { willReceive: false, reason: 'the table carries no organization_id column' } - : plan.unattributable.length === 0 + : staying.length === 0 ? { willReceive: true, reason: 'every NULL row is attributed or departs' } - : { willReceive: false, reason: `${plan.unattributable.length} row(s) whose owner cannot be derived stay NULL and are reported` }; + : { willReceive: false, reason: staying.join('; ') }; break; } - case 'report': + case 'report': { plan.fateCounts = { reportedRows: plan.unattributable.length, departingRows: departing }; + const staying = [ + ...(plan.unattributable.length > 0 ? [`${plan.unattributable.length} reported row(s) stay NULL`] : []), + ...(held ? [held] : []), + ]; plan.notNull = entry.notNullExempt ? { willReceive: false, reason: entry.notNullExempt } : !hasOrg ? { willReceive: false, reason: 'the table carries no organization_id column' } - : plan.unattributable.length === 0 + : staying.length === 0 ? { willReceive: true, reason: 'no NULL row remains' } - : { willReceive: false, reason: `${plan.unattributable.length} reported row(s) stay NULL` }; + : { willReceive: false, reason: staying.join('; ') }; break; + } } return plan; } +/** + * The NOT NULL reading's clause for a table's advisory departures — rows no + * step of this release carries out ({@link PlannedDeparture.advisory}), which + * therefore stay NULL — or `null` when it holds none. + */ +function heldNullReason(plan: TablePlan): string | null { + const held = plan.departures.filter((d) => d.advisory && d.rows > 0); + if (held.length === 0) return null; + return ( + `${held.reduce((n, d) => n + d.rows, 0)} row(s) stay NULL on this release, reported as advisory: ` + + held.map((d) => d.label).join('; ') + ); +} + /** Keyset-walk the NULL-organization rows of one physical table and classify each. */ async function walkNullRows( ctx: PlanContext, @@ -1333,6 +1372,18 @@ export async function buildOrganizationOwnershipPlan(opts: { 'longer needed.', ); } + // Certifying with NULL rows standing is decided on the operator's behalf, so + // it is said before the first write, once per departure that holds rows. + for (const table of tables) { + for (const departure of table.departures) { + if (!departure.advisory || departure.rows === 0) continue; + warnings.push( + `${table.object} holds ${departure.rows} organization-less row(s) of ${departure.label}, which stay: ` + + `${departure.advisory}. The ceremony reports them as advisory and does not hold its ` + + `certification on them; they keep organization_id NULL, so ${table.object} does not receive NOT NULL on this release.`, + ); + } + } const byFate = { 'column-drop': 0, 'mirror-deletion': 0, attribution: 0, report: 0 } as Record; for (const t of tables) if (t.physical === 'present') byFate[t.fate]++;