diff --git a/.changeset/notification-event-migration-run-receipt.md b/.changeset/notification-event-migration-run-receipt.md new file mode 100644 index 0000000000..e2c05326b8 --- /dev/null +++ b/.changeset/notification-event-migration-run-receipt.md @@ -0,0 +1,17 @@ +--- +"@objectstack/metadata": minor +--- + +A completed run of the ADR-0030 notification cut-over now records itself in the `sys_migration` deployment ledger, per the ruled claim matrix. + +`migrateSysNotificationToEvent` reports `migrated` / `already_done` / `not_applicable` / `error` to its caller and — until now — recorded nothing anywhere. Once that line had scrolled, "did this cut-over run here, and when" had no answer in the deployment even in principle. The ledger row is what answers it, and what a run of this migration may claim under `NOTIFICATION_EVENT_MIGRATION_ID` is stated on that constant in `@objectstack/spec/system`: + +- `last_run_at` — stamped on every completed non-`error` run (`migrated`, `already_done`, `not_applicable` alike). +- `applied_at` — stamped only on `migrated`. Never cleared: a later `already_done` leaves an earlier backfill's stamp alone, because the backfill really did happen. +- `verified_at` — never written, in either direction. This migration has no self-check, and `verified_at` means a self-check passed. On a store created after the cut-over the row already exists and `attestFreshDatastore` set `verified_at` at birth; that certificate survives a run untouched, because the column is omitted from the update rather than sent as `null`. +- `blocking: 0`, and `details` carrying `{ outcome }` verbatim. +- An `error` run writes no claim at all — it does not know what it did, so it does not say. + +**Receipt, not gate.** Nothing reads a row under this id as a precondition and nothing may: a gate would need the self-check that does not exist. The row is what an operator reads, in the shape `sys_migration` already documents for the seed-tenancy repair. + +Two additions to the published surface of `@objectstack/metadata/migrations`, both driven by that: a new `SysNotificationMigrationReceipt` type, and a new `receipt` member on `SysNotificationMigrationResult` reporting what became of the claim (`inserted` / `updated` / `not-claimed` / `no-ledger` / `failed`, with a reason on the last two). This directory takes no logger and reports to its caller, so the claim's own fate is reported the same way the migration's is — a receipt that could not be written is never swallowed. Reading a result is unaffected; code that CONSTRUCTS a `SysNotificationMigrationResult` by hand (a test double) now supplies `receipt`. diff --git a/packages/metadata/src/migrations/index.ts b/packages/metadata/src/migrations/index.ts index a0acb931d4..3de008ca97 100644 --- a/packages/metadata/src/migrations/index.ts +++ b/packages/metadata/src/migrations/index.ts @@ -50,4 +50,5 @@ export { migrateSysNotificationToEvent, type SysNotificationMigrationResult, type SysNotificationMigrationOptions, + type SysNotificationMigrationReceipt, } from './migrate-sys-notification-to-event.js'; diff --git a/packages/metadata/src/migrations/migrate-sys-notification-to-event.test.ts b/packages/metadata/src/migrations/migrate-sys-notification-to-event.test.ts index d88f8ddcd4..3aa54ae63b 100644 --- a/packages/metadata/src/migrations/migrate-sys-notification-to-event.test.ts +++ b/packages/metadata/src/migrations/migrate-sys-notification-to-event.test.ts @@ -10,6 +10,11 @@ import { describe, it, expect } from 'vitest'; // (file, verb) pairs sat in the gate's DEBT ledger until #5619 sank the two // predicates into a package that depends on neither side. import { assertEngineDeleteDispatch, assertEngineUpdateDispatch, assertEngineFindOnePredicate, type EngineFindOneQueryInput } from '@objectstack/metadata-core'; +// [#16100] The ledger contract and its SHIPPED readers/writers, so the receipt +// cases below measure what a consumer would really see rather than a literal +// this file agrees with itself about. +import { DATA_MIGRATION_FLAG_OBJECT, NOTIFICATION_EVENT_MIGRATION_ID } from '@objectstack/spec/system'; +import { attestFreshDatastore, isDataMigrationVerified } from '@objectstack/platform-objects/system'; import { migrateSysNotificationToEvent } from './migrate-sys-notification-to-event.js'; /** Columns the legacy (pre-ADR-0030) sys_notification table physically has. */ @@ -40,23 +45,79 @@ function fakeDriver(rows: any[], columns: string[] = LEGACY_TABLE_COLUMNS) { }; } -function fakeEngine() { +/** + * [#16100] The `sys_migration` deployment ledger a host may or may not carry. + * + * `IDataEngine` does not declare `getObject`, so the receipt writer PROBES for + * it — which makes "was a ledger configured on this double?" the difference + * between a host that can hold the run receipt and one that cannot. A + * `fakeEngine()` built with no argument is the latter, and that is deliberately + * what every case predating #16100 exercises: they measure the migration, not + * the receipt, and none of them may start writing a ledger row. + */ +interface FakeLedger { + /** Rows already in `sys_migration` — e.g. a fresh store's birth attestation. */ + rows?: Array>; + /** Object names this kernel has registered. Defaults to the ledger alone. */ + registered?: string[]; + /** Make every `sys_migration` write throw with this message. */ + failWrites?: string; +} + +const LEDGER_OBJECT = 'sys_migration'; + +function fakeEngine(ledger?: FakeLedger) { const inserts: Array<{ object: string; row: any }> = []; const updates: Array<{ object: string; data: any }> = []; + const finds: Array<{ object: string; query: any }> = []; + const stored = new Map>( + (ledger?.rows ?? []).map((r) => [String(r.id), { ...r }]), + ); + const registered = new Set(ledger?.registered ?? [LEDGER_OBJECT]); return { inserts, updates, + finds, + /** The ledger's contents AFTER the run — the fresh-store assertion's subject. */ + stored, engine: { + // Present only when a ledger was configured: the probe's own input. + ...(ledger + ? { + getObject(name: string) { + return registered.has(name) ? { name } : undefined; + }, + } + : {}), async insert(object: string, row: any) { inserts.push({ object, row }); + if (object === LEDGER_OBJECT) { + if (ledger?.failWrites) throw new Error(ledger.failWrites); + stored.set(String(row.id), { ...row }); + } return { id: `${object}_${inserts.length}`, ...row }; }, async update(object: string, data: any, options?: Record) { assertEngineUpdateDispatch(data, options); updates.push({ object, data }); + if (object === LEDGER_OBJECT) { + if (ledger?.failWrites) throw new Error(ledger.failWrites); + const id = String(data.id); + // MERGE, not replace — that is what an UPDATE does to the + // columns it does not name, and the whole point of the + // fresh-store case is which columns are named. + stored.set(id, { ...(stored.get(id) ?? {}), ...data }); + } return data; }, - async find() { return []; }, + async find(object?: string, query?: Record) { + if (object !== undefined) finds.push({ object, query }); + if (ledger && object === LEDGER_OBJECT) { + const row = stored.get(String(query?.where?.id)); + return row ? [{ ...row }] : []; + } + return []; + }, async findOne(object: string, query?: EngineFindOneQueryInput) { assertEngineFindOnePredicate(object, query); return null; }, async delete(_object?: string, options?: Record) { @@ -323,3 +384,308 @@ describe('#13998 the timestamp spelling written into the new rows', () => { expect(receipt.row.at).toBe(REPORTED_READ_INSTANT); }); }); + +// --------------------------------------------------------------------------- +// [#16100] The run receipt in the `sys_migration` deployment ledger. +// +// What a run of this migration may claim under `NOTIFICATION_EVENT_MIGRATION_ID` +// is RULED (maintainer 「同意」 to decision batch #47 item 5, recorded on +// #15710) and the ruling lives on that constant's docblock in +// `@objectstack/spec/system`. The spec side already pins the ruling's TEXT and +// that the receipt shape authorises nothing +// (`packages/spec/src/system/notification-event-migration-ledger.pin.test.ts`); +// these cases pin the RUNTIME half — what this writer actually sends, per +// outcome. +// +// ⚠️ There is no operator-reachable run of this migration today: it has no +// production call site and `os migrate` has no `notification-event` +// sub-command. Until that changes these cases are the ONLY thing that exercises +// the writer, which is why every arm of the matrix is pinned separately rather +// than one happy path standing in for four. +// --------------------------------------------------------------------------- + +/** The run's injected clock — every stamp the receipt writes is this instant. */ +const RUN_AT = '2026-09-06T07:08:09.000Z'; +/** A different instant, so a preserved birth stamp cannot pass by matching it. */ +const BIRTH_AT = '2026-03-04T05:06:07.000Z'; + +type FakeEngineHarness = ReturnType; + +/** Every write this run sent to the ledger object, in order. */ +function ledgerWrites(e: FakeEngineHarness) { + return [ + ...e.inserts.filter((i) => i.object === LEDGER_OBJECT).map((i) => ({ verb: 'insert' as const, row: i.row })), + ...e.updates.filter((u) => u.object === LEDGER_OBJECT).map((u) => ({ verb: 'update' as const, row: u.data })), + ]; +} + +/** The ledger row as it stands AFTER the run. */ +function ledgerRow(e: FakeEngineHarness) { + return e.stored.get(NOTIFICATION_EVENT_MIGRATION_ID); +} + +/** A legacy row, so the run reports `migrated`. */ +function legacyRow() { + return { + id: 'n1', recipient_id: 'u1', type: 'mention', title: 'hi', body: null, url: null, + actor_name: null, is_read: 0, read_at: null, + created_at: '2026-01-01T00:00:00.000Z', organization_id: 'org_1', + }; +} + +describe('#16100 the run receipt written into sys_migration', () => { + it('control: the id and the ledger object are the ones the contract declares', () => { + // Without this the cases below could all agree with each other about a + // string neither the reader nor the attestation writer uses. + expect(NOTIFICATION_EVENT_MIGRATION_ID).toBe('adr-0030-notification-event'); + expect(DATA_MIGRATION_FLAG_OBJECT).toBe(LEDGER_OBJECT); + }); + + it('`migrated` — claims last_run_at AND applied_at, never verified_at', async () => { + const d = fakeDriver([legacyRow()]); + const e = fakeEngine({}); + + const result = await migrateSysNotificationToEvent({ driver: d.driver, data: e.engine, now: () => RUN_AT }); + + expect(result.status).toBe('migrated'); + expect(result.receipt).toEqual({ outcome: 'inserted' }); + const writes = ledgerWrites(e); + expect(writes.map((w) => w.verb)).toEqual(['insert']); + expect(writes[0]!.row).toMatchObject({ + id: NOTIFICATION_EVENT_MIGRATION_ID, + last_run_at: RUN_AT, + applied_at: RUN_AT, + verified_at: null, + blocking: 0, + details: JSON.stringify({ outcome: 'migrated' }), + }); + }); + + it.each(['already_done', 'not_applicable'] as const)( + '`%s` — claims last_run_at and NOT applied_at', + async (outcome) => { + // `already_done`: the legacy column is there and no legacy row is. + // `not_applicable`: the column was never there at all. + const d = outcome === 'already_done' + ? fakeDriver([]) + : fakeDriver([], ['id', 'topic', 'payload', 'severity', 'created_at']); + const e = fakeEngine({}); + + const result = await migrateSysNotificationToEvent({ driver: d.driver, data: e.engine, now: () => RUN_AT }); + + expect(result.status).toBe(outcome); + expect(result.receipt).toEqual({ outcome: 'inserted' }); + const writes = ledgerWrites(e); + expect(writes.map((w) => w.verb)).toEqual(['insert']); + expect(writes[0]!.row).toMatchObject({ + id: NOTIFICATION_EVENT_MIGRATION_ID, + last_run_at: RUN_AT, + applied_at: null, + verified_at: null, + blocking: 0, + details: JSON.stringify({ outcome }), + }); + }, + ); + + it('`error` (no raw-SQL surface) — writes NO ledger claim, and does not even read the ledger', async () => { + const e = fakeEngine({}); + + const result = await migrateSysNotificationToEvent({ driver: {} as any, data: e.engine, now: () => RUN_AT }); + + expect(result.status).toBe('error'); + expect(result.receipt).toEqual({ outcome: 'not-claimed' }); + expect(ledgerWrites(e)).toEqual([]); + expect(e.stored.size).toBe(0); + // Not merely "wrote nothing": an `error` run has no business asking the + // ledger anything, so the read never happens either. + expect(e.finds.filter((f) => f.object === LEDGER_OBJECT)).toEqual([]); + }); + + it('`error` (a throw mid-run) — the other error return site claims nothing either', async () => { + // The first `error` case returns before the try block; this one comes + // out of the catch, with rows already rewritten. Both must be silent in + // the ledger, and only a case per return site can say so. + const failing = { + async raw(sql: string) { + if (sql.startsWith('PRAGMA table_info')) return LEGACY_TABLE_COLUMNS.map((name) => ({ name })); + if (sql.startsWith('SELECT id, recipient_id')) throw new Error('connection reset'); + return []; + }, + } as any; + const e = fakeEngine({}); + + const result = await migrateSysNotificationToEvent({ driver: failing, data: e.engine, now: () => RUN_AT }); + + expect(result.status).toBe('error'); + expect(result.error).toContain('connection reset'); + expect(result.receipt).toEqual({ outcome: 'not-claimed' }); + expect(ledgerWrites(e)).toEqual([]); + }); + + it('details carries exactly `{ outcome }`, JSON-encoded — nothing else', async () => { + const d = fakeDriver([legacyRow()]); + const e = fakeEngine({}); + await migrateSysNotificationToEvent({ driver: d.driver, data: e.engine, now: () => RUN_AT }); + expect(JSON.parse(String(ledgerRow(e)!.details))).toEqual({ outcome: 'migrated' }); + }); + + it('the receipt authorises nothing — and the control shows the `false` is the null, not the shape', async () => { + const d = fakeDriver([legacyRow()]); + const e = fakeEngine({}); + + await migrateSysNotificationToEvent({ driver: d.driver, data: e.engine, now: () => RUN_AT }); + + // Read back through the SHIPPED reader, not through the row literal: + // "receipt, not gate" is a claim about what a consumer sees. + expect(await isDataMigrationVerified(e.engine, NOTIFICATION_EVENT_MIGRATION_ID)).toBe(false); + // Non-vacuity: the same row with a certificate WOULD authorise, so the + // `false` above is about `verified_at` and not about an unreadable row. + e.stored.set(NOTIFICATION_EVENT_MIGRATION_ID, { ...ledgerRow(e)!, verified_at: RUN_AT }); + expect(await isDataMigrationVerified(e.engine, NOTIFICATION_EVENT_MIGRATION_ID)).toBe(true); + }); +}); + +describe('#16100 the fresh-store case — a birth attestation this writer may not touch', () => { + /** + * Seed the row the way a real fresh store gets it: through the SHIPPED + * producer, `attestFreshDatastore`, which sets `verified_at` at birth for + * every member of `CREATION_ATTESTED_MIGRATION_IDS` — this id among them. + * Hand-writing the row here would pin this file's idea of the birth shape + * instead of the producer's. + */ + async function freshStore(rows: any[] = [], columns?: string[]) { + const e = fakeEngine({}); + const attested = await attestFreshDatastore(e.engine, { + migrationIds: [NOTIFICATION_EVENT_MIGRATION_ID], + }); + expect(attested, 'the birth attestation did not happen — the case would be vacuous') + .toEqual([NOTIFICATION_EVENT_MIGRATION_ID]); + const birth = e.stored.get(NOTIFICATION_EVENT_MIGRATION_ID)!; + expect(birth.verified_at, 'a fresh store is verified BY BIRTH — nothing to preserve otherwise') + .toBeTruthy(); + // Re-stamp the birth columns to a distinct instant so a value that + // merely LOOKS preserved cannot be this run's own stamp echoed back. + e.stored.set(NOTIFICATION_EVENT_MIGRATION_ID, { + ...birth, verified_at: BIRTH_AT, last_run_at: BIRTH_AT, created_at: BIRTH_AT, updated_at: BIRTH_AT, + }); + e.inserts.length = 0; + e.updates.length = 0; + e.finds.length = 0; + const d = columns ? fakeDriver(rows, columns) : fakeDriver(rows); + return { e, d }; + } + + it('a `not_applicable` run UPDATES the row and never names verified_at or applied_at', async () => { + // The realistic fresh-store shape: the table was created after the + // cut-over, so it has no `recipient_id` column at all. + const { e, d } = await freshStore([], ['id', 'topic', 'payload', 'severity', 'created_at']); + + const result = await migrateSysNotificationToEvent({ driver: d.driver, data: e.engine, now: () => RUN_AT }); + + expect(result.status).toBe('not_applicable'); + expect(result.receipt).toEqual({ outcome: 'updated' }); + + const writes = ledgerWrites(e); + expect(writes.map((w) => w.verb)).toEqual(['update']); + // The payload half: the columns are not sent AT ALL. Asserting the + // stored value alone would pass on a writer that sent the old value + // back, which is a different (and unwritable) thing to promise. + expect(Object.keys(writes[0]!.row).sort()).toEqual( + ['blocking', 'details', 'id', 'last_run_at', 'updated_at'], + ); + expect(writes[0]!.row).not.toHaveProperty('verified_at'); + expect(writes[0]!.row).not.toHaveProperty('applied_at'); + + // The stored half: the birth certificate survives, untouched and still + // distinguishable from this run's stamp. + const row = ledgerRow(e)!; + expect(row.verified_at).toBe(BIRTH_AT); + expect(row.applied_at).toBe(null); + expect(row.last_run_at).toBe(RUN_AT); + expect(JSON.parse(String(row.details))).toEqual({ outcome: 'not_applicable' }); + + // And it still reads as verified — by birth, never by this run. + expect(await isDataMigrationVerified(e.engine, NOTIFICATION_EVENT_MIGRATION_ID)).toBe(true); + }); + + it('a `migrated` run on a fresh store stamps applied_at and STILL leaves verified_at alone', async () => { + const { e, d } = await freshStore([legacyRow()]); + + const result = await migrateSysNotificationToEvent({ driver: d.driver, data: e.engine, now: () => RUN_AT }); + + expect(result.status).toBe('migrated'); + expect(result.receipt).toEqual({ outcome: 'updated' }); + const writes = ledgerWrites(e); + expect(writes[0]!.row).toMatchObject({ last_run_at: RUN_AT, applied_at: RUN_AT }); + expect(writes[0]!.row).not.toHaveProperty('verified_at'); + expect(ledgerRow(e)!.verified_at).toBe(BIRTH_AT); + }); + + it('a later non-`migrated` run does not CLEAR an earlier run\'s applied_at', async () => { + // The other half of "applied_at only on `migrated`": only on `migrated` + // is it STAMPED — it is never un-stamped, because an earlier backfill + // really did happen and a later no-op does not undo it. + const e = fakeEngine({}); + const first = await migrateSysNotificationToEvent({ + driver: fakeDriver([legacyRow()]).driver, data: e.engine, now: () => BIRTH_AT, + }); + expect(first.status).toBe('migrated'); + expect(ledgerRow(e)!.applied_at).toBe(BIRTH_AT); + + const second = await migrateSysNotificationToEvent({ + driver: fakeDriver([]).driver, data: e.engine, now: () => RUN_AT, + }); + + expect(second.status).toBe('already_done'); + expect(second.receipt).toEqual({ outcome: 'updated' }); + expect(ledgerRow(e)!.applied_at).toBe(BIRTH_AT); + expect(ledgerRow(e)!.last_run_at).toBe(RUN_AT); + }); +}); + +describe('#16100 when the claim cannot land, the caller is told', () => { + it('a host with no object registry reports `no-ledger` and writes nothing', async () => { + // `fakeEngine()` with NO argument carries no `getObject` — exactly the + // double every case predating #16100 uses, which is why none of them + // acquired a ledger write. + const d = fakeDriver([legacyRow()]); + const e = fakeEngine(); + + const result = await migrateSysNotificationToEvent({ driver: d.driver, data: e.engine, now: () => RUN_AT }); + + expect(result.status).toBe('migrated'); + expect(result.receipt.outcome).toBe('no-ledger'); + expect(result.receipt.reason).toContain('getObject'); + expect(e.inserts.map((i) => i.object)).toEqual(['sys_inbox_message', 'sys_notification_receipt']); + }); + + it('an engine without the ledger object registered reports `no-ledger` and names the remedy', async () => { + const d = fakeDriver([legacyRow()]); + const e = fakeEngine({ registered: [] }); + + const result = await migrateSysNotificationToEvent({ driver: d.driver, data: e.engine, now: () => RUN_AT }); + + expect(result.status).toBe('migrated'); + expect(result.receipt.outcome).toBe('no-ledger'); + expect(result.receipt.reason).toContain(DATA_MIGRATION_FLAG_OBJECT); + expect(result.receipt.reason).toContain('PlatformObjectsPlugin'); + expect(ledgerWrites(e)).toEqual([]); + }); + + it('a ledger write that throws reports `failed` and leaves the migration result intact', async () => { + // The #4420 shape on this row: the data really was rewritten, every + // other reading is clean, and the only durable record that it happened + // is absent. The caller is told, which is what keeps it from being a + // silent degradation. + const d = fakeDriver([legacyRow()]); + const e = fakeEngine({ failWrites: 'readonly transaction' }); + + const result = await migrateSysNotificationToEvent({ driver: d.driver, data: e.engine, now: () => RUN_AT }); + + expect(result.status).toBe('migrated'); + expect(result.migrated).toBe(1); + expect(result.receipt).toEqual({ outcome: 'failed', reason: 'readonly transaction' }); + expect(e.stored.size).toBe(0); + }); +}); diff --git a/packages/metadata/src/migrations/migrate-sys-notification-to-event.ts b/packages/metadata/src/migrations/migrate-sys-notification-to-event.ts index 4357eabe7b..c30be0d1dd 100644 --- a/packages/metadata/src/migrations/migrate-sys-notification-to-event.ts +++ b/packages/metadata/src/migrations/migrate-sys-notification-to-event.ts @@ -32,9 +32,18 @@ * (see `./driver-exec.ts`); `data` (IDataEngine) performs the * structured inbox/receipt writes and the event rewrite so ids, JSON fields and * tenant stamping are handled uniformly across drivers. + * + * A completed run also records itself in the `sys_migration` deployment ledger + * under `NOTIFICATION_EVENT_MIGRATION_ID`, per the ruled claim matrix carried + * on that constant (#16100) — see "The run receipt" below. That row is a + * RECEIPT an operator reads, never a gate. */ import type { IDataDriver, IDataEngine } from '@objectstack/spec/contracts'; +import { + DATA_MIGRATION_FLAG_OBJECT, + NOTIFICATION_EVENT_MIGRATION_ID, +} from '@objectstack/spec/system'; import { type DriverExec, driverExecRefusal, resolveDriverExec } from './driver-exec.js'; @@ -54,11 +63,46 @@ const LEGACY_COLUMNS = [ 'read_at', ] as const; +/** + * What one run recorded in the `sys_migration` deployment ledger (#16100). + * + * This directory reports to its CALLER and to nobody else — no module under + * `packages/metadata/src/migrations` takes a logger — so the ledger claim's + * own fate is reported the same way the migration's is. That is also the third + * legal answer to AGENTS.md's degradation rule: a failure handed to the caller + * does not "look normal from the outside", because the caller was told. + * + * - `inserted` / `updated` — the claim landed, as a new row or over the row + * that was already there. + * - `not-claimed` — nothing was owed. An `error` run writes no ledger claim + * at all (the ruled matrix), so this is the correct, complete outcome for + * it and never a failure. + * - `no-ledger` — a claim was owed and there is nowhere to put it: the host + * is not an engine that carries the ledger, or `sys_migration` is not + * registered on this kernel. `reason` says which. + * - `failed` — a claim was owed, the write was attempted, and it threw. The + * data migration itself still did what `status` says it did; what is + * missing is the durable record that it ran. `reason` carries the error. + */ +export interface SysNotificationMigrationReceipt { + outcome: 'inserted' | 'updated' | 'not-claimed' | 'no-ledger' | 'failed'; + /** Why no claim landed — present on `no-ledger` and `failed` only. */ + reason?: string; +} + export interface SysNotificationMigrationResult { status: 'migrated' | 'already_done' | 'not_applicable' | 'error'; /** Number of legacy rows split into inbox + receipt + event. */ migrated: number; error?: string; + /** + * What this run claimed in the `sys_migration` ledger under + * {@link NOTIFICATION_EVENT_MIGRATION_ID}. Always present: writing the + * receipt is part of what a run DOES, and a caller that cannot tell "the + * claim landed" from "the claim was never attempted" is the unanswerable + * state the ledger row exists to remove. + */ + receipt: SysNotificationMigrationReceipt; } export interface SysNotificationMigrationOptions { @@ -68,11 +112,27 @@ export interface SysNotificationMigrationOptions { now?(): string; } +/** What the migration itself decided, before the ledger claim is written. */ +type MigrationOutcome = Omit; + export async function migrateSysNotificationToEvent( opts: SysNotificationMigrationOptions, ): Promise { - const { data } = opts; const now = opts.now ?? (() => new Date().toISOString()); + const outcome = await runNotificationEventMigration(opts, now); + // ONE exit, so the ruled matrix is applied to the outcome exactly once and + // a `return` added inside the runner tomorrow cannot bypass it. `now()` is + // read again HERE on purpose: the claim's stamp is when the run FINISHED, + // not when it started, and a caller injecting `now` can pin both. + const receipt = await recordNotificationEventReceipt(opts.data, outcome.status, now()); + return { ...outcome, receipt }; +} + +async function runNotificationEventMigration( + opts: SysNotificationMigrationOptions, + now: () => string, +): Promise { + const { data } = opts; const exec = resolveDriverExec(opts.driver); if (!exec) { @@ -170,6 +230,197 @@ export async function migrateSysNotificationToEvent( } } +// --------------------------------------------------------------------------- +// The run receipt (#16100) — the ruled ledger-claim matrix +// --------------------------------------------------------------------------- +// +// What a run of this migration may claim under `NOTIFICATION_EVENT_MIGRATION_ID` +// is RULED (maintainer 「同意」 to decision batch #47 item 5), and the ruling is +// carried in that constant's own docblock in `@objectstack/spec/system`. This +// file is the runtime half: the runner receives the data engine and is the only +// place that knows the four-valued outcome, so it is the only place the claim +// can be written from. +// +// ⛔ RECEIPT, NOT GATE. Nothing reads a row under this id as a precondition and +// nothing may — a gate would need the self-check this migration does not have. +// The row is what an operator reads, in the shape `sys-migration.object.ts` +// already documents for the #8686 seed-tenancy repair (`verified_at: null`, +// `blocking: 0` by construction), which is exactly the shape +// `isDataMigrationFlagVerified` answers `false` to. + +/** The row-effect one outcome is entitled to. */ +interface LedgerClaim { + /** Write a claim for this outcome at all? */ + readonly claims: boolean; + /** Stamp `applied_at` with this run's timestamp? */ + readonly appliesBackfill: boolean; +} + +/** + * The ruled matrix, TOTAL over the result union rather than derived from it. + * + * A mapped type keyed by `status` is the point: a fifth outcome added to + * {@link SysNotificationMigrationResult} makes this object literal a COMPILE + * ERROR instead of silently inheriting whichever arm a ternary happened to + * fall into. The ledger claim of a new outcome has to be decided, not + * inherited. + * + * `verified_at` is absent from this table on purpose: it is not a per-outcome + * decision, it is a column this migration NEVER writes in any direction. See + * {@link buildNotificationEventClaim}. + */ +const LEDGER_CLAIM: { + readonly [S in SysNotificationMigrationResult['status']]: LedgerClaim; +} = { + migrated: { claims: true, appliesBackfill: true }, + already_done: { claims: true, appliesBackfill: false }, + not_applicable: { claims: true, appliesBackfill: false }, + // An `error` run writes NO ledger claim at all — it does not know what it + // did, so it may not say. + error: { claims: false, appliesBackfill: false }, +}; + +/** + * The engine surface the receipt needs, duck-typed. + * + * `IDataEngine` declares `find`/`insert`/`update` but NOT `getObject`, and + * "is the ledger registered on this kernel?" cannot be asked without it — the + * same question, asked the same way, as `readDataMigrationFlag` + * (`@objectstack/platform-objects`) and `resolveSeedTenancyLedger` + * (`@objectstack/metadata-protocol`). Probing rather than requiring keeps a + * remote or virtual engine that carries no object registry from being refused + * the migration itself over bookkeeping. + */ +interface MigrationLedger { + getObject(name: string): unknown; + find(object: string, query: Record, options?: Record): Promise; + insert(object: string, data: Record, options?: Record): Promise; + update(object: string, data: Record, options?: Record): Promise; +} + +const LEDGER_METHODS = ['getObject', 'find', 'insert', 'update'] as const; + +/** The ledger seam on this engine, or `undefined` where the host is not one. */ +function resolveMigrationLedger(data: IDataEngine): MigrationLedger | undefined { + const candidate = data as unknown as Record; + for (const method of LEDGER_METHODS) { + if (typeof candidate[method] !== 'function') return undefined; + } + return candidate as unknown as MigrationLedger; +} + +/** + * The columns one outcome's claim writes, split by whether a row is already + * there — pure, so the matrix is testable without an engine. + * + * The split is the whole reading, and it is what the fresh-store case needs: + * + * - **`verified_at` is never written in either direction.** On an INSERT the + * claim spells `null` — the documented receipt shape, and the absence of a + * certificate rather than a claim about one. On an UPDATE the key is + * OMITTED, so a value that is already there survives untouched. That is not + * a nicety: this id is in `CREATION_ATTESTED_MIGRATION_IDS`, so a store + * created after the cut-over already carries a row whose `verified_at` was + * set by `attestFreshDatastore` at BIRTH, for a fact this run neither + * earned nor disproved. Sending `verified_at` at all would either forge + * that certificate or revoke it. + * - **`applied_at` follows the same rule for the same reason.** It is + * stamped only on `migrated`; on the other two outcomes the key is omitted + * from an UPDATE, so an EARLIER `migrated` run's stamp — a true fact about + * this deployment — is preserved rather than cleared by a later + * `already_done`. On an INSERT there is no earlier run, so it spells + * `null`. + * - **`blocking: 0` always.** Blocking means "the gate must stay closed" and + * nothing gates on this id; nothing here counts discrepancies either. + * - **`details`** carries `{ outcome }` verbatim, JSON-encoded, which is what + * the column holds for every other writer. + * - **`advisory`, `deviation_observed_at`, `deviation_detail`** are not + * written. Nothing here produces an advisory finding, and the deviation + * columns belong to ADR-0104's escape-hatch protocol, which this migration + * does not participate in. Writing a column no path here ever produces is + * the declared-≠-enforced shape. + */ +function buildNotificationEventClaim( + status: SysNotificationMigrationResult['status'], + now: string, + exists: boolean, +): Record { + const claim = LEDGER_CLAIM[status]; + const row: Record = { + id: NOTIFICATION_EVENT_MIGRATION_ID, + last_run_at: now, + blocking: 0, + details: JSON.stringify({ outcome: status }), + updated_at: now, + }; + if (claim.appliesBackfill) row.applied_at = now; + if (!exists) { + // A brand-new row: there is no prior value to preserve, so the two + // columns this migration never claims are spelled as the absence they + // are, and the row gets its creation stamp. + row.applied_at = claim.appliesBackfill ? now : null; + row.verified_at = null; + row.created_at = now; + } + return row; +} + +/** + * Record this run under `NOTIFICATION_EVENT_MIGRATION_ID`, and report what + * became of the claim. + * + * ⛔ Never throws. The migration's own outcome is the answer this function's + * caller asked for; a bookkeeping failure must not destroy it. The failure is + * not swallowed either — it comes back as {@link SysNotificationMigrationReceipt}, + * which is the reporting channel every module in this directory already uses. + */ +async function recordNotificationEventReceipt( + data: IDataEngine, + status: SysNotificationMigrationResult['status'], + now: string, +): Promise { + if (!LEDGER_CLAIM[status].claims) return { outcome: 'not-claimed' }; + + const ledger = resolveMigrationLedger(data); + if (!ledger) { + return { + outcome: 'no-ledger', + reason: + `the \`data\` engine carries no object registry (${LEDGER_METHODS.join('/')}), so ` + + `${DATA_MIGRATION_FLAG_OBJECT} cannot be reached from here`, + }; + } + + try { + if (!ledger.getObject(DATA_MIGRATION_FLAG_OBJECT)) { + return { + outcome: 'no-ledger', + reason: + `${DATA_MIGRATION_FLAG_OBJECT} is not registered on this kernel — compose ` + + 'PlatformObjectsPlugin, which carries the deployment ledger', + }; + } + const context = { isSystem: true }; + const rows = await ledger.find( + DATA_MIGRATION_FLAG_OBJECT, + { where: { id: NOTIFICATION_EVENT_MIGRATION_ID }, limit: 1 }, + { context }, + ); + const exists = rows?.[0]?.id === NOTIFICATION_EVENT_MIGRATION_ID; + const row = buildNotificationEventClaim(status, now, exists); + // One row per migration id — a re-run overwrites its own claim rather + // than appending; `sys_migration_journal` is where per-RUN history lives. + if (exists) { + await ledger.update(DATA_MIGRATION_FLAG_OBJECT, row, { context }); + return { outcome: 'updated' }; + } + await ledger.insert(DATA_MIGRATION_FLAG_OBJECT, row, { context }); + return { outcome: 'inserted' }; + } catch (err: any) { + return { outcome: 'failed', reason: err?.message ?? String(err) }; + } +} + // --------------------------------------------------------------------------- // Internal helpers // --------------------------------------------------------------------------- diff --git a/packages/metadata/tsconfig.json b/packages/metadata/tsconfig.json index 3055112da8..c67040664d 100644 --- a/packages/metadata/tsconfig.json +++ b/packages/metadata/tsconfig.json @@ -9,10 +9,13 @@ ], "compilerOptions": { "outDir": "dist", - "rootDir": "src", + "rootDir": "../..", "types": [ "node", "js-yaml" - ] + ], + "paths": { + "@objectstack/platform-objects/system": ["../platform-objects/src/system/index.ts"] + } } } diff --git a/packages/metadata/vitest.config.ts b/packages/metadata/vitest.config.ts index 48350fede6..87b076737a 100644 --- a/packages/metadata/vitest.config.ts +++ b/packages/metadata/vitest.config.ts @@ -43,6 +43,18 @@ export default defineConfig({ replacement: path.join(path.resolve(__dirname, '..'), 'spec/src/$1/index.ts'), }, { find: /^@objectstack\/spec$/, replacement: path.resolve(__dirname, '../spec/src/index.ts') }, + // [#16100] The deployment-ledger writer/reader pair the notification-event + // migration's receipt cases drive (`attestFreshDatastore` seeds the + // fresh-store row, `isDataMigrationVerified` reads the verdict back). The + // entry is ANCHORED on the subpath rather than spelled bare: this package + // publishes a FILE-shaped subpath (`./plugin`), so a bare prefix rule with + // a file replacement would resolve `…/system` to + // `…/platform-objects/src/index.ts/system` — ENOTDIR at run time, from a + // config that reads as correct. + { + find: /^@objectstack\/platform-objects\/system$/, + replacement: path.resolve(__dirname, '../platform-objects/src/system/index.ts'), + }, // Subpath BEFORE the bare package, same prefix-match reason: `./node` is a // published subpath served by a FILE (`types/src/node.ts` — the node-only slice // the root export deliberately excludes), so the bare entry would resolve it to