diff --git a/.changeset/retire-adr-0030-notification-event-migration.md b/.changeset/retire-adr-0030-notification-event-migration.md new file mode 100644 index 0000000000..2ae652749b --- /dev/null +++ b/.changeset/retire-adr-0030-notification-event-migration.md @@ -0,0 +1,54 @@ +--- +'@objectstack/metadata': minor +'@objectstack/spec': minor +--- + +**BREAKING** — retire the `adr-0030-notification-event` data migration. + +`migrateSysNotificationToEvent` had no way to be run: zero production callers +anywhere in the repo, and no `os migrate` sub-command, while the two sibling +members of `CREATION_ATTESTED_MIGRATION_IDS` had both. The runner, its barrel +export, its tests, the ruled `sys_migration` receipt-claim matrix, that matrix's +pin, and the id's membership in `CREATION_ATTESTED_MIGRATION_IDS` are removed +together. Pre-ADR-0030 `sys_notification` rows are not carried by the platform +on this line. + +## What is gone, and what an upgrader does about it + +⭐ **Nothing is renamed and nothing replaces it**, so there is no new spelling to +adopt — every item below is a deletion, and the fix is to stop using it. + +- `migrateSysNotificationToEvent` (`@objectstack/metadata/migrations`) — deleted. + No replacement exists, and none is coming: an `os migrate notification-event` + sub-command was considered and refused. Delete the call. The compiler delivers + this one: the import fails to resolve. +- `SysNotificationMigrationResult`, `SysNotificationMigrationOptions` and + `SysNotificationMigrationReceipt` (same entry point) — deleted with it. They + described that runner's own result, options and receipt and nothing else. +- `CREATION_ATTESTED_MIGRATION_IDS` (`@objectstack/spec/system`) — was a + three-member tuple and is now a two-member one holding + `'adr-0104-file-references'` and `'adr-0104-value-shapes'`. Both ADR-0104 ids + keep their sub-commands, their receipt rows and their birth attestation; only + the notification id left. Code typed against + `(typeof CREATION_ATTESTED_MIGRATION_IDS)[number]` that names the notification + id no longer compiles — delete that arm. + +`NOTIFICATION_EVENT_MIGRATION_ID` (`@objectstack/spec/system`) is **kept**. A +deployment attested at birth, or one that made the operator call while the runner +shipped, still holds a `sys_migration` row keyed `'adr-0030-notification-event'`, +and the constant is that row's name. Nothing writes or reads a row under it any +more — `attestFreshDatastore` no longer includes it — and it is not a +registration: it gates nothing and never did. + +## Reversal path + +Two answers were considered and both refused: an `os migrate notification-event` +sub-command is a permanent operator surface for a migration with no measured +demand, and a boot-time invoker is an unattended data rewrite nobody asked for. +⚠️ Nobody has measured whether any live deployment carries pre-ADR-0030 +`sys_notification` rows. If a **named** deployment turns out to hold rows it +needs, the migration returns as an operator-runnable sub-command shaped exactly +like `files-to-references` / `value-shapes` — dry-run default, `--apply` gate, +documented consequence — under its own card. + + diff --git a/docs/handoff/adr-0030-notification-convergence.md b/docs/handoff/adr-0030-notification-convergence.md index 6cc14ad572..112237b397 100644 --- a/docs/handoff/adr-0030-notification-convergence.md +++ b/docs/handoff/adr-0030-notification-convergence.md @@ -62,16 +62,33 @@ a per-user inbox row directly. a `dedupKey`. No more direct `sys_notification` writes. The plugin resolves the `messaging` service lazily at hook time (`audit-plugin.ts`). -### Data migration (not auto-run) +### ⚰️ Data migration — RETIRED, there is none + +This section used to describe `packages/metadata/src/migrations/migrate-sys-notification-to-event.ts` -(exported from `@objectstack/metadata/migrations`). Splits each legacy -`sys_notification` inbox row into `sys_inbox_message` + a receipt, rewrites the -row to the event shape, and clears the legacy columns. **Idempotent**; reports -`not_applicable` on fresh installs. +(exported from `@objectstack/metadata/migrations`), which split each legacy +`sys_notification` inbox row into `sys_inbox_message` + a receipt, rewrote the +row to the event shape and cleared the legacy columns. + +**That runner is gone (#16194).** It had zero production callers and no +`os migrate` sub-command, and both ways of giving it one were refused: an +operator door is a permanent surface for a migration with no measured demand, +and a boot-time invoker is an unattended data rewrite. ⇒ **Pre-ADR-0030 +`sys_notification` rows are not carried by the platform on this line.** After +the cut-over the bell shows rows emitted from the new pipeline onward; older +per-user inbox rows stay where they are, unread by the new UI. + +⚠️ Nobody has measured whether any live deployment still holds pre-ADR-0030 +`sys_notification` rows. If **your named deployment** does and needs them, ⛔ do +not re-add the call — the migration returns as an operator-runnable `os migrate` +sub-command shaped exactly like `files-to-references` / `value-shapes` (dry-run +default, `--apply` gate, documented consequence), under its own card. Say so on +#16194. ### Tests -`messaging-service`, `inbox-channel`, `messaging-service-plugin`, `notify-node`, -and the migration all have updated/added coverage. All green. +`messaging-service`, `inbox-channel`, `messaging-service-plugin` and +`notify-node` all have updated/added coverage. All green. (The migration's own +suites went with the runner.) --- @@ -79,7 +96,9 @@ and the migration all have updated/added coverage. All green. The bell read `sys_notification.{recipient_id, is_read, title, body, …}`. Those fields **no longer exist**. Until objectui is updated, the bell will be empty / -error. **Do the objectui cut-over and the data migration together.** +error. ⚰️ There is **no data migration to pair it with** any more (see above): +do the objectui cut-over on its own, and expect the bell to start from the +rows the new pipeline emits. ### objectui changes required (`app-shell`) 1. **`AppHeader.tsx` / `InboxPopover.tsx`**: poll **`sys_inbox_message`** filtered @@ -98,13 +117,19 @@ error. **Do the objectui cut-over and the data migration together.** ### Cut-over sequence (avoid a blank bell) 1. Deploy this framework change (objects + emit + producers). New notifications now land in `sys_inbox_message` + receipts. -2. Run `migrateSysNotificationToEvent({ driver, data })` to carry existing - notifications into `sys_inbox_message` + receipts. -3. Deploy the objectui bell repoint. +2. Deploy the objectui bell repoint. (Step order tolerates a brief window where new rows exist but the UI hasn't flipped — the inbox is being populated the whole time.) +> ⚰️ **This sequence used to have three steps.** The middle one was +> `migrateSysNotificationToEvent({ driver, data })`, carrying pre-cut-over +> `sys_notification` rows into `sys_inbox_message` + receipts. That runner is +> retired (#16194) and **pre-ADR-0030 rows are not carried by the platform on +> this line** — the reasoning, the unmeasured-deployment caveat and the reversal +> path are in [Data migration — RETIRED](#-data-migration--retired-there-is-none) +> above. ⛔ Do not re-add the call here. + --- ## Behavior notes / watch-outs diff --git a/packages/metadata/src/migrations/driver-exec.ts b/packages/metadata/src/migrations/driver-exec.ts index 32aaee4cd1..d8dc543234 100644 --- a/packages/metadata/src/migrations/driver-exec.ts +++ b/packages/metadata/src/migrations/driver-exec.ts @@ -10,10 +10,12 @@ * `(path, init)`. `SqlDriver` keeps its knex handle `protected`, so * `driver.raw` is `undefined` there too, and `SqliteWasmDriver` inherits that. * The result was a published, operator-documented migration path that refused - * every driver the platform ships — quietly, because - * `migrateSysNotificationToEvent` *returns* `{ status: 'error' }` rather than - * throwing, and the message blamed the operator's driver instead of saying the - * migration did not run. + * every driver the platform ships — quietly, because the helper that found the + * defect *returned* `{ status: 'error' }` rather than throwing, and the message + * blamed the operator's driver instead of saying the migration did not run. + * (That helper — the ADR-0030 notification cut-over — has since been retired + * whole; the surface argument below is what outlived it, and every remaining + * migration in this directory depends on it.) * * ## Why `execute` is tried FIRST * diff --git a/packages/metadata/src/migrations/index.ts b/packages/metadata/src/migrations/index.ts index 3de008ca97..5e42483f18 100644 --- a/packages/metadata/src/migrations/index.ts +++ b/packages/metadata/src/migrations/index.ts @@ -46,9 +46,32 @@ export { dropProjectionTables, type DropProjectionResult } from './drop-projecti */ -export { - migrateSysNotificationToEvent, - type SysNotificationMigrationResult, - type SysNotificationMigrationOptions, - type SysNotificationMigrationReceipt, -} from './migrate-sys-notification-to-event.js'; +/** + * ⚰️ TOMBSTONE — `migrateSysNotificationToEvent` / + * `migrate-sys-notification-to-event.ts` (with `SysNotificationMigrationResult` + * / `SysNotificationMigrationOptions` / `SysNotificationMigrationReceipt`) was + * REMOVED. ⛔ Do not reintroduce a runner for the ADR-0030 notification + * cut-over in this package. + * + * It had no way to be run. Re-measured on the tree this tombstone landed on: + * ZERO production callers anywhere in `packages` / `apps` / `examples` — the + * definition, its own usage docblock, this barrel line, one comment in + * `./driver-exec.ts`, the id's docblock in `@objectstack/spec` and three test + * files were every non-CHANGELOG occurrence there was. The instrument was live + * in the same run: the symbol was in 17 files. + * + * Two ways to give it one were considered and both refused. An + * `os migrate notification-event` sub-command shaped like its two + * `CREATION_ATTESTED_MIGRATION_IDS` siblings is a permanent operator surface + * for a migration with no measured demand; a boot-time invoker is an + * unattended data rewrite nobody asked for. Pre-ADR-0030 `sys_notification` + * rows are not carried by the platform on this line. + * + * ⚠️ Stated rather than papered over: nobody has measured whether any live + * deployment carries pre-ADR-0030 `sys_notification` rows. If a NAMED + * deployment turns out to hold rows it needs, the migration returns as an + * operator-runnable sub-command shaped exactly like `files-to-references` / + * `value-shapes`, under its own card — with the dry-run posture, the `--apply` + * gate and the documented consequence those two carry, none of which this one + * ever had. + */ 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 deleted file mode 100644 index d008ec80de..0000000000 --- a/packages/metadata/src/migrations/migrate-sys-notification-to-event.test.ts +++ /dev/null @@ -1,754 +0,0 @@ -// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. - -import { describe, it, expect } from 'vitest'; -// [#5855] The fake engine's write verbs route through the producer's OWN -// dispatch predicates (#4550 delete / #5480 update), so this double cannot -// accept a call `ObjectQL.` refuses. Imported from -// `@objectstack/metadata-core` (already a `dependencies` entry here) and not -// from `@objectstack/objectql`, which depends on this package — that import -// would close a dependency cycle turbo rejects, and is why both of this file's -// (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. */ -const LEGACY_TABLE_COLUMNS = [ - 'id', 'recipient_id', 'type', 'title', 'body', 'url', 'actor_name', - 'is_read', 'read_at', 'created_at', 'organization_id', 'topic', 'payload', 'severity', -]; - -function fakeDriver(rows: any[], columns: string[] = LEGACY_TABLE_COLUMNS) { - const updates: Array<{ sql: string; bindings: any[] }> = []; - return { - updates, - driver: { - async raw(sql: string, bindings: any[] = []) { - if (sql.startsWith('PRAGMA table_info')) { - return columns.map((name) => ({ name })); - } - if (sql.startsWith('SELECT id, recipient_id')) { - return rows; - } - if (sql.startsWith('UPDATE')) { - updates.push({ sql, bindings }); - return []; - } - return []; - }, - } as any, - }; -} - -/** - * [#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; options?: Record }> = []; - 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, options?: Record) { - inserts.push({ object, row, options }); - 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(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) { - assertEngineDeleteDispatch(options); - return {}; - }, - async count() { return 0; }, - async aggregate() { return []; }, - } as any, - }; -} - -describe('migrateSysNotificationToEvent', () => { - it('splits each legacy row into inbox + receipt and rewrites the event', async () => { - const d = fakeDriver([ - { id: 'n1', recipient_id: 'u1', type: 'mention', title: 'You were mentioned', body: 'hi', url: '/x', actor_name: 'Ada', is_read: 0, read_at: null, created_at: '2026-01-01T00:00:00.000Z', organization_id: 'org_1' }, - { id: 'n2', recipient_id: 'u2', type: 'assignment', title: 'Assigned', body: null, url: null, actor_name: null, is_read: 1, read_at: '2026-02-02T00:00:00.000Z', created_at: '2026-02-01T00:00:00.000Z', organization_id: 'org_1' }, - ]); - const e = fakeEngine(); - - const result = await migrateSysNotificationToEvent({ driver: d.driver, data: e.engine }); - - expect(result.status).toBe('migrated'); - expect(result.migrated).toBe(2); - - const inbox = e.inserts.filter((i) => i.object === 'sys_inbox_message'); - const receipts = e.inserts.filter((i) => i.object === 'sys_notification_receipt'); - expect(inbox).toHaveLength(2); - expect(receipts).toHaveLength(2); - - // Row 1: unread → delivered receipt; inbox keyed by recipient, linked to event. - expect(inbox[0].row).toMatchObject({ user_id: 'u1', notification_id: 'n1', title: 'You were mentioned', action_url: '/x', organization_id: 'org_1' }); - expect(receipts[0].row).toMatchObject({ notification_id: 'n1', user_id: 'u1', channel: 'inbox', state: 'delivered' }); - - // Row 2: read → read receipt carrying read_at. - expect(receipts[1].row).toMatchObject({ notification_id: 'n2', user_id: 'u2', state: 'read', at: '2026-02-02T00:00:00.000Z' }); - - // The event row is rewritten (topic ← type, payload built) and legacy columns nulled. - const ev = e.updates.filter((u) => u.object === 'sys_notification'); - expect(ev[0].data).toMatchObject({ id: 'n1', topic: 'mention', payload: { title: 'You were mentioned', url: '/x', actorName: 'Ada' } }); - expect(d.updates).toHaveLength(2); - expect(d.updates[0].sql).toContain('"recipient_id" = NULL'); - expect(d.updates[0].bindings).toEqual(['n1']); - }); - - it('works on a Postgres-style driver where PRAGMA throws (information_schema fallback)', async () => { - // PRAGMA raises a syntax error on Postgres; columnExists must fall - // through to information_schema rather than reporting not_applicable. - const rows = [ - { id: 'n1', recipient_id: 'u1', type: 'mention', title: 'hi', body: null, url: null, actor_name: null, is_read: false, read_at: null, created_at: '2026-01-01T00:00:00.000Z', organization_id: 'org_1' }, - ]; - const updates: Array<{ sql: string; bindings: any[] }> = []; - const pgDriver = { - async raw(sql: string, bindings: any[] = []) { - if (sql.startsWith('PRAGMA')) throw new Error('syntax error at or near "PRAGMA"'); - if (sql.includes('information_schema')) { - // bindings = [table, column]; report the column as present. - return [{ column_name: bindings[1] }]; - } - if (sql.startsWith('SELECT id, recipient_id')) return rows; - if (sql.startsWith('UPDATE')) { updates.push({ sql, bindings }); return []; } - return []; - }, - } as any; - const e = fakeEngine(); - - const result = await migrateSysNotificationToEvent({ driver: pgDriver, data: e.engine }); - - expect(result.status).toBe('migrated'); - expect(result.migrated).toBe(1); - expect(e.inserts.map((i) => i.object)).toEqual(['sys_inbox_message', 'sys_notification_receipt']); - }); - - it('is idempotent — no legacy rows means already_done', async () => { - const d = fakeDriver([]); - const e = fakeEngine(); - const result = await migrateSysNotificationToEvent({ driver: d.driver, data: e.engine }); - expect(result.status).toBe('already_done'); - expect(e.inserts).toHaveLength(0); - }); - - it('reports not_applicable when the table never had a recipient_id column', async () => { - const d = fakeDriver([], ['id', 'topic', 'payload', 'severity', 'created_at']); - const e = fakeEngine(); - const result = await migrateSysNotificationToEvent({ driver: d.driver, data: e.engine }); - expect(result.status).toBe('not_applicable'); - }); - - it('errors cleanly when the driver has NEITHER raw() nor execute()', async () => { - // The totality floor. A driver offering no raw-SQL surface at all must - // still be refused — that half of the guard is not what was wrong with - // it. What was wrong is that `raw` was the ONLY surface it accepted, so - // it also refused every driver this repo ships; see - // `real-driver-exec-surface.test.ts` for the other half. - const e = fakeEngine(); - const result = await migrateSysNotificationToEvent({ driver: {} as any, data: e.engine }); - expect(result.status).toBe('error'); - expect(result.error).toContain('.execute(sql, bindings?)'); - expect(result.error).toContain('.raw(sql, bindings?)'); - }); - - it('drives a driver that offers only execute(), passing bindings positionally', async () => { - // A double deliberately shaped like the DECLARED contract - // (`IDataDriver.execute(command, parameters?, options?)`) rather than - // like the helper's old assumption. The real-driver coverage lives in - // `real-driver-exec-surface.test.ts`; this case additionally pins that - // the second argument arrives as the bindings ARRAY, which is the part a - // mechanical `raw`->`execute` rename could get wrong silently. - const seen: Array<{ sql: string; bindings: unknown }> = []; - const executeOnly = { - async execute(sql: string, bindings?: unknown[]) { - seen.push({ sql, bindings }); - if (sql.startsWith('PRAGMA table_info')) { - return LEGACY_TABLE_COLUMNS.map((name) => ({ name })); - } - if (sql.startsWith('SELECT id, recipient_id')) { - return [{ id: 'n1', recipient_id: 'u1', type: 'mention', title: 't', 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' }]; - } - return []; - }, - } as any; - expect(typeof executeOnly.raw, 'the double must NOT carry a raw()').not.toBe('function'); - const e = fakeEngine(); - - const result = await migrateSysNotificationToEvent({ driver: executeOnly, data: e.engine }); - - expect(result.status).toBe('migrated'); - expect(result.migrated).toBe(1); - const update = seen.find((c) => c.sql.startsWith('UPDATE')); - expect(update?.bindings).toEqual(['n1']); - // An unbound statement gets an empty array, never `undefined` — the - // shape `SqlDriver.execute` and TursoDriver both normalize to anyway. - expect(seen.find((c) => c.sql.startsWith('PRAGMA'))?.bindings).toEqual([]); - }); -}); - -// --------------------------------------------------------------------------- -// [#13998] What this migration WRITES, when the legacy row hands out a `Date`. -// -// `selectLegacyRows` reads through `driver.raw`/`execute` — a door that does -// not run `formatOutput`, so none of its repairs apply. On SQLite the legacy -// stamps come back as canonical ISO TEXT and `String(row.created_at)` is the -// IDENTITY, which is why every case above stayed green while the defect was -// live. On Postgres and MySQL an instant column materialises as a JS `Date` -// (pinned in `driver-sql/src/sql-driver-13567-audit-stamp-materialisation.test.ts`), -// and this migration is one-way: whatever spelling lands is what the platform -// carries afterwards. -// -// `@objectstack/metadata` has no driver dependency and must not grow one — the -// layering runs the other way — so, exactly like the OCC seam's own regression -// suite, the discriminating input here is a HAND-MADE `Date`. That is the whole -// point of these cases: they break the SQLite identity the cases above rely on. -// --------------------------------------------------------------------------- - -/** The instant from the production report, kept verbatim (#13567 / #13382). */ -const REPORTED_INSTANT = '2026-08-30T10:19:25.947Z'; -/** A second instant, so the receipt's `at` cannot pass by matching `created_at`. */ -const REPORTED_READ_INSTANT = '2026-08-31T02:03:04.567Z'; - -/** Canonical audit-timestamp text — what SQLite stores and what must be written. */ -const ISO_Z = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/; - -/** - * Run `body` with the process pinned to `tz`, then restore. - * - * Forced rather than required so these cases are non-vacuous on any runner: - * Test Core runs at UTC, a developer runs at whatever their laptop is set to. - * Restoring rather than assuming matters because vitest reuses a worker across - * files — a leaked `TZ` would silently re-zone whatever runs next in this - * process. Mirrors `underProcessZone` in the driver-side pin. - */ -async function underProcessZone(tz: string, body: () => Promise | T): Promise { - const previous = process.env.TZ; - process.env.TZ = tz; - try { - return await body(); - } finally { - if (previous === undefined) delete process.env.TZ; - else process.env.TZ = previous; - } -} - -// --------------------------------------------------------------------------- -// [#16312] The historical-import channel is DECLARED on the two L5 writes -// --------------------------------------------------------------------------- -// -// ⚠️ Read what this can and cannot say, because the card exists because the -// difference was missed once already. -// -// This double runs NO hooks. It is faithful about DISPATCH — its write verbs -// route through the producer's own predicates — and silent about the before -// phase, so it cannot observe whether `sys_stamp_audit_insert` kept or -// overwrote `created_at`. That is exactly the seam the defect lived in: every -// assertion in this file about `created_at` passed on BOTH sides of #15964's -// change, and the suite read `23 passed` while migrated rows were being -// stamped with the migration instant. -// -// ⇒ what follows is a pin on the CALL SHAPE, which is inside what this double -// can see, and nothing more. The EFFECT — that the real audit hook honours it -// and the row lands with the notification's own instant — is measured on a real -// engine, with a real driver, in -// `packages/runtime/src/notification-migration-audit-preservation.integration.test.ts`. -// ⛔ Do not read a green here as evidence the timeline is preserved; that is the -// reading this card was filed to retire. -// --------------------------------------------------------------------------- - -describe('#16312 the two L5 writes declare the historical-import channel', () => { - it('inbox and receipt inserts both carry `context.preserveAudit`', async () => { - const d = fakeDriver([ - { - id: 'n1', recipient_id: 'u1', type: 'task.assigned', title: 'T', - body: 'B', url: '/r/1', actor_name: 'Ada', is_read: 1, - read_at: REPORTED_READ_INSTANT, created_at: REPORTED_INSTANT, - organization_id: 'org1', - }, - ]); - const e = fakeEngine(); - await migrateSysNotificationToEvent({ driver: d.driver, data: e.engine }); - - const inbox = e.inserts.find((i) => i.object === 'sys_inbox_message')!; - const receipt = e.inserts.find((i) => i.object === 'sys_notification_receipt')!; - expect(inbox.options).toEqual({ context: { preserveAudit: true } }); - expect(receipt.options).toEqual({ context: { preserveAudit: true } }); - }); - - it('the event rewrite does NOT ask for it — that write really is happening now', async () => { - const d = fakeDriver([ - { - id: 'n1', recipient_id: 'u1', type: 'task.assigned', title: 'T', - body: null, url: null, actor_name: null, is_read: 0, - read_at: null, created_at: REPORTED_INSTANT, organization_id: null, - }, - ]); - const e = fakeEngine(); - await migrateSysNotificationToEvent({ driver: d.driver, data: e.engine }); - - // `sys_stamp_audit_update` stamps `updated_at`, never `created_at`, so - // the source row keeps its own instant without asking for anything — - // and `updated_at = now` is the true fact about this write. - const rewrite = e.updates.find((u) => u.object === 'sys_notification'); - expect(rewrite).toBeDefined(); - expect(e.updates.filter((u) => u.object === 'sys_notification')).toHaveLength(1); - }); -}); - -describe('#13998 the timestamp spelling written into the new rows', () => { - it('control — `String(Date)` is NOT the canonical spelling (the input discriminates)', async () => { - const value = new Date(REPORTED_INSTANT); - expect(value.getMilliseconds(), 'the fixture is vacuous without sub-second digits').toBe(947); - - const spelled = await underProcessZone('Asia/Shanghai', () => String(value)); - // The prefix only: the trailing `(China Standard Time)` is the one - // implementation-defined part of `toString`. - expect(spelled.startsWith('Sun Aug 30 2026 18:19:25 GMT+0800')).toBe(true); - // Whole seconds in the PROCESS zone: the milliseconds are gone. - expect(Date.parse(spelled)).toBe(value.getTime() - value.getMilliseconds()); - expect(spelled).not.toBe(REPORTED_INSTANT); - expect(spelled).not.toMatch(ISO_Z); - // …and the canonical rendering of the same instant is zone-independent. - expect(value.toISOString()).toBe(REPORTED_INSTANT); - }); - - it('canonicalises a `Date` created_at/read_at into ISO on inbox, receipt and receipt.at', async () => { - const createdAt = new Date(REPORTED_INSTANT); - const readAt = new Date(REPORTED_READ_INSTANT); - const d = fakeDriver([ - { - id: 'n1', recipient_id: 'u1', type: 'mention', title: 'You were mentioned', - body: 'hi', url: '/x', actor_name: 'Ada', is_read: 1, - // The discriminating input: what Postgres/MySQL actually hand out. - read_at: readAt, created_at: createdAt, organization_id: 'org_1', - }, - ]); - const e = fakeEngine(); - - const result = await underProcessZone('Asia/Shanghai', () => - migrateSysNotificationToEvent({ driver: d.driver, data: e.engine })); - - expect(result.status).toBe('migrated'); - expect(result.migrated).toBe(1); - - const inbox = e.inserts.find((i) => i.object === 'sys_inbox_message')!; - const receipt = e.inserts.find((i) => i.object === 'sys_notification_receipt')!; - - // Every written stamp is canonical ISO-Z text — not a `Date`, and not a - // `Date.prototype.toString` rendering carrying the migrating host's zone. - for (const [where, written] of [ - ['inbox.created_at', inbox.row.created_at], - ['receipt.created_at', receipt.row.created_at], - ['receipt.at', receipt.row.at], - ] as const) { - expect(typeof written, `${where} must be written as text`).toBe('string'); - expect(written as string, `${where} must be canonical ISO-Z`).toMatch(ISO_Z); - // The zone the OLD spelling would have baked in is absent. - expect(written as string, `${where} must not carry a GMT offset`).not.toContain('GMT'); - } - - // The instants themselves are preserved to the millisecond — the half - // `String(Date)` silently dropped. - expect(inbox.row.created_at).toBe(REPORTED_INSTANT); - expect(receipt.row.created_at).toBe(REPORTED_INSTANT); - expect(receipt.row.at).toBe(REPORTED_READ_INSTANT); - // …and `at` is the READ stamp, not `created_at` echoed back. - expect(receipt.row.at).not.toBe(receipt.row.created_at); - - // The zone was restored rather than leaked into whatever runs next. - expect(process.env.TZ).not.toBe('Asia/Shanghai'); - }); - - it('leaves canonical ISO text exactly as it found it (the SQLite path is unchanged)', async () => { - const d = fakeDriver([ - { - id: 'n1', recipient_id: 'u1', type: 'mention', title: 'hi', body: null, - url: null, actor_name: null, is_read: 1, read_at: REPORTED_READ_INSTANT, - created_at: REPORTED_INSTANT, organization_id: 'org_1', - }, - ]); - const e = fakeEngine(); - - const result = await migrateSysNotificationToEvent({ driver: d.driver, data: e.engine }); - - expect(result.status).toBe('migrated'); - const inbox = e.inserts.find((i) => i.object === 'sys_inbox_message')!; - const receipt = e.inserts.find((i) => i.object === 'sys_notification_receipt')!; - expect(inbox.row.created_at).toBe(REPORTED_INSTANT); - expect(receipt.row.created_at).toBe(REPORTED_INSTANT); - 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 deleted file mode 100644 index 52e54513fd..0000000000 --- a/packages/metadata/src/migrations/migrate-sys-notification-to-event.ts +++ /dev/null @@ -1,587 +0,0 @@ -// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. - -/** - * Migration: sys_notification (per-user inbox) → notification event (ADR-0030) - * - * ADR-0030 re-models `sys_notification` from a per-user *inbox* into the L2 - * *event* (one row per `emit`). This migration preserves users' existing bell - * notifications across the cut-over by splitting each legacy row into the new - * layered model: - * - * legacy sys_notification row (recipient_id, type, title, body, url, - * actor_name, is_read, read_at, …) - * │ - * ├─► sys_inbox_message (L5 in-app materialization, keyed by user) - * ├─► sys_notification_receipt (L5 read-state: 'read' if is_read else 'delivered') - * └─► the sys_notification row itself is rewritten to the event shape - * (topic ← type, payload ← {title,body,url,actor_name}) and its legacy - * inbox columns are cleared. - * - * Idempotent: it acts only on rows that still carry the legacy shape - * (`recipient_id IS NOT NULL`); a second run is a no-op. Safe when the legacy - * columns were never present (a fresh install created directly in the new - * shape) — it reports `not_applicable`. - * - * Usage: - * import { migrateSysNotificationToEvent } from '@objectstack/metadata/migrations'; - * await migrateSysNotificationToEvent({ driver, data }); - * - * `driver` provides raw access to read legacy columns the re-modeled schema no - * longer projects and to clear them — through the surface `IDataDriver` - * declares, `execute(sql, bindings?)`, falling back to `raw(sql, bindings?)` - * (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 { operatorFacingErrorText } from '@objectstack/types'; - -import { - DATA_MIGRATION_FLAG_OBJECT, - NOTIFICATION_EVENT_MIGRATION_ID, -} from '@objectstack/spec/system'; - -import { type DriverExec, driverExecRefusal, resolveDriverExec } from './driver-exec.js'; - -const EVENT_OBJECT = 'sys_notification'; -const INBOX_OBJECT = 'sys_inbox_message'; -const RECEIPT_OBJECT = 'sys_notification_receipt'; - -/** - * The write context the two L5 materializing inserts carry: this is a - * HISTORICAL IMPORT, so the row's `created_at` is the notification's own - * instant and not the moment this migration ran. - * - * ## Read the fossil before you move this - * - * `preserveAudit` is not a bypass of audit — it is the door audit left open - * for exactly this case, and it has a ruling behind it: - * - * - **#3493** put it there. `sys_stamp_audit_insert` / `sys_stamp_audit_update` - * (`@objectstack/objectql`'s `plugin.ts`) read `session.preserveAudit` and, - * when it is set, keep a supplied `created_at` / `updated_at` / `updated_by` - * instead of overwriting it with the write instant. Its own words: "a - * 'historical' import reinstates the ORIGINAL timeline". Opt-in and - * server-set only — `ExecutionContext.preserveAudit` is documented as never - * client-supplied, and REST import reaches it only through the - * `treatAsHistorical` request flag (`packages/rest/src/import-runner.ts`). - * - **#15964** (maintainer ruling 2026-09-06, decision batch #54, option A) - * reaffirmed it while removing the accident this migration was living on. - * `created_at` used to be `record.created_at ?? now` on EVERY insert — - * client-preferred with no flag at all — which laundered a forged - * `created_at` past the static-`readonly` strip on an ordinary - * authenticated POST. The ruling made the ordinary branch stamp `now` and - * kept the `preserveAudit` branch, deliberately: "Under `preserveAudit` the - * preservation is DECLARED, so the same keep is the ruled historical-import - * channel and stays — which is why the fix is this ternary and not a bare - * `= now`." - * - * ⇒ the fossil says the channel exists FOR this; declaring it is the ruled - * remedy, and ⛔ restoring the create-side `??` is not (#16312). - * - * ## Why the strip does not eat these keys - * - * The 2026-08-08 ruling narrowed the CREATE-side `readonly` strip's exemption - * to `isSystem` alone, so a non-system create asking for `preserveAudit` is - * warned that the exemption is UPDATE-only. That does not bite here: - * `staticReadonlyInsertSubject` returns `null` for an object whose name starts - * with `sys_` or that carries `managedBy` — both target objects are `sys_` and - * `sys_notification_receipt` is `managedBy: 'engine-owned'` — so no create-side - * static strip runs on them at all and no warning is owed. Pinned, on a real - * engine, by `packages/runtime/src/notification-migration-audit-preservation.integration.test.ts`. - * - * ⛔ NOT carried on the `data.update` that rewrites the event row below. That - * write really is happening now, so `updated_at = now` is the true fact; - * `sys_stamp_audit_update` never touches `created_at`, which is why the source - * row keeps its own. - */ -const HISTORICAL_IMPORT = { context: { preserveAudit: true } }; - -/** Legacy inbox columns cleared once a row is rewritten to the event shape. */ -const LEGACY_COLUMNS = [ - 'recipient_id', - 'type', - 'title', - 'body', - 'url', - 'actor_name', - 'is_read', - '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 { - driver: IDataDriver; - data: IDataEngine; - /** Defaults to `() => new Date().toISOString()`. */ - now?(): string; -} - -/** What the migration itself decided, before the ledger claim is written. */ -type MigrationOutcome = Omit; - -export async function migrateSysNotificationToEvent( - opts: SysNotificationMigrationOptions, -): Promise { - 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) { - return { - status: 'error', - migrated: 0, - error: driverExecRefusal('migrateSysNotificationToEvent'), - }; - } - - // No legacy `recipient_id` column → the table never held the inbox shape. - if (!(await columnExists(exec, EVENT_OBJECT, 'recipient_id'))) { - return { status: 'not_applicable', migrated: 0 }; - } - - // Only null-out columns that actually exist on this deployment. - const presentLegacy: string[] = []; - for (const col of LEGACY_COLUMNS) { - if (await columnExists(exec, EVENT_OBJECT, col)) presentLegacy.push(col); - } - - let migrated = 0; - try { - const rows = await selectLegacyRows(exec); - if (rows.length === 0) return { status: 'already_done', migrated: 0 }; - - for (const row of rows) { - const id = String(row.id); - const recipientId = row.recipient_id != null ? String(row.recipient_id) : null; - if (!recipientId) continue; // defensive — guarded by the SELECT filter - const orgId = row.organization_id != null ? String(row.organization_id) : null; - const createdAt = row.created_at != null ? canonicalTimestampText(row.created_at) : now(); - const title = row.title != null ? String(row.title) : (row.type != null ? String(row.type) : 'Notification'); - const isRead = row.is_read === true || row.is_read === 1 || row.is_read === '1'; - // One topic for both the inbox row and the rewritten event, so the - // materialization and its L2 event never disagree (empty/null legacy - // `type` → 'legacy'). - const eventTopic = row.type != null && String(row.type).length > 0 ? String(row.type) : 'legacy'; - - // L5 in-app materialization. - await data.insert( - INBOX_OBJECT, - { - user_id: recipientId, - notification_id: id, - topic: eventTopic, - title, - body_md: row.body ?? null, - severity: 'info', - action_url: row.url ?? null, - organization_id: orgId, - created_at: createdAt, - }, - HISTORICAL_IMPORT, - ); - - // L5 receipt (read-state spine). - await data.insert( - RECEIPT_OBJECT, - { - notification_id: id, - delivery_id: null, - user_id: recipientId, - channel: 'inbox', - state: isRead ? 'read' : 'delivered', - at: isRead && row.read_at != null ? canonicalTimestampText(row.read_at) : createdAt, - organization_id: orgId, - created_at: createdAt, - }, - HISTORICAL_IMPORT, - ); - - // Rewrite the row itself to the L2 event shape (engine handles JSON). - await data.update( - EVENT_OBJECT, - { - id, - topic: eventTopic, - severity: 'info', - payload: { - title: row.title ?? null, - body: row.body ?? null, - url: row.url ?? null, - actorName: row.actor_name ?? null, - }, - }, - { where: { id } }, - ); - - // Clear the legacy inbox columns so the row no longer matches the - // migration filter (idempotency) and carries no stale recipient. - if (presentLegacy.length > 0) { - const setClause = presentLegacy.map((c) => `"${c}" = NULL`).join(', '); - await exec(`UPDATE "${EVENT_OBJECT}" SET ${setClause} WHERE id = ?`, [id]); - } - - migrated += 1; - } - - return { status: 'migrated', migrated }; - } catch (err: any) { - return { status: 'error', migrated, error: operatorFacingErrorText(err) }; - } -} - -// --------------------------------------------------------------------------- -// 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 -// --------------------------------------------------------------------------- - -/** - * The canonical text spelling of a timestamp read back out of the legacy table. - * - * `selectLegacyRows` reads through `driver.raw`/`execute`, which hands the - * dialect client's own materialisation straight back — that door does not run - * `formatOutput`, so none of its repairs apply here on any dialect: - * - * - `created_at` is a BUILTIN audit column, so it is never in `datetimeFields` - * and no declared-field coercion reaches it; what repairs it is - * `formatOutput`'s own `AUDIT_TIMESTAMP_COLUMNS` pass - * (`repairNaiveUtcAuditTimestamp`) at the RECORD read door — a door this - * path does not go through. - * - `read_at` is a LEGACY column ADR-0030 removed from the object, so it is - * not declared either — it can never enter `datetimeFields`, and it is not - * an audit column, so no arm of `formatOutput` could reach it even at the - * record read door. - * - * ⚠️ The reason no repair reaches this path is the SEAM, ⛔ not an - * `if (this.isSqlite)` gate inside `formatOutput`. Both of `formatOutput`'s - * timestamp passes — the `AUDIT_TIMESTAMP_COLUMNS` pass and the - * `normalizeSqliteDatetimeOutput` pass over `datetimeFields` — sat inside that - * arm until #13973 ([ADR-0053 D-F1]) lifted them out, and they run on EVERY - * dialect now. So the record read door presents canonical text everywhere while - * this raw-SQL door still hands back whatever the client materialised, which is - * why the divergence below survives the ruling HERE and nowhere upstream of it. - * - * On SQLite both arrive as canonical ISO text and `String()` is the identity — - * which is why every test in this directory stayed green. On Postgres and - * MySQL an instant column materialises as a JS `Date` - * (`withPostgresCalendarDayAsText` leaves the instant types alone deliberately, - * [ADR-0053 D-F2]; pinned in - * `sql-driver-13567-audit-stamp-materialisation.test.ts` §B3, which reads the - * same row raw through knex and still gets the dialect's `Date` on the live - * cells), and `String(date)` spells - * - * Sun Aug 30 2026 18:19:25 GMT+0800 (China Standard Time) - * - * — whole seconds in the MIGRATING HOST's zone, with the milliseconds gone. - * This migration is one-way and this value is WRITTEN, so that spelling is what - * the platform would carry afterwards: either accepted and stored skewed and - * de-precisioned, or rejected outright, since the trailing zone name is in no - * dialect's timestamp grammar (#13998). - * - * Canonicalising HERE, at the consumer that writes, is deliberate and is the - * only shape that could also repair an already-migrated deployment (#13973 - * option A). It is not a tolerant alias: `Date` and ISO text are two - * materialisations of ONE instant, not two spellings of a key. Matches the - * repo's existing correct form at `metadata-protocol/src/protocol.ts` (the - * `occurred_at` read in `readMetadataAuditEvents`); anything that is neither a - * string nor a `Date` keeps its previous `String()` rendering unchanged rather - * than having a unit guessed for it on a one-way write path. - */ -function canonicalTimestampText(value: unknown): string { - if (typeof value === 'string') return value; - if (value instanceof Date) return value.toISOString(); - return String(value); -} - -async function selectLegacyRows(exec: DriverExec): Promise { - const result: any[] = await exec( - `SELECT id, recipient_id, type, title, body, url, actor_name, is_read, read_at, created_at, organization_id ` + - `FROM "${EVENT_OBJECT}" WHERE recipient_id IS NOT NULL`, - ); - // knex wraps some results as `[rows]`; normalize both shapes. - if (Array.isArray(result) && result.length > 0 && Array.isArray(result[0])) { - return result[0]; - } - return Array.isArray(result) ? result : []; -} - -async function columnExists(exec: DriverExec, table: string, column: string): Promise { - // SQLite path: PRAGMA table_info. On Postgres/others this raises a syntax - // error — swallow it *locally* and fall through to information_schema (the - // outer-catch version of this would never reach the fallback, making the - // migration silently no-op on every non-SQLite DB). - try { - const rows: any = await exec(`PRAGMA table_info("${table}")`); - const list: any[] = Array.isArray(rows) - ? (Array.isArray(rows[0]) ? rows[0] : rows) - : []; - if (list.length > 0 && list.some((r: any) => r?.name != null)) { - return list.some((r: any) => r?.name === column); - } - } catch { - /* not SQLite — fall through to information_schema */ - } - // Postgres / others. - try { - const result: any = await exec( - `SELECT column_name FROM information_schema.columns WHERE table_name = ? AND column_name = ?`, - [table, column], - ); - const list: any[] = Array.isArray(result) - ? (Array.isArray(result[0]) ? result[0] : result) - : []; - return list.length > 0; - } catch { - return false; - } -} diff --git a/packages/metadata/src/migrations/notification-event-migration-retirement.test.ts b/packages/metadata/src/migrations/notification-event-migration-retirement.test.ts new file mode 100644 index 0000000000..9172d4d789 --- /dev/null +++ b/packages/metadata/src/migrations/notification-event-migration-retirement.test.ts @@ -0,0 +1,111 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#16194] The ADR-0030 notification cut-over is RETIRED — the runner half. + * + * `migrateSysNotificationToEvent` had no way to be run. Every non-CHANGELOG + * reference to it was the definition, its own usage docblock, one barrel line, + * one comment in `./driver-exec.ts`, the id's docblock in `@objectstack/spec` + * and three test files — zero production callers in `packages` / `apps` / + * `examples`, with the instrument live in the same run (the symbol was in 17 + * files). Neither of the two ways to give it a caller was accepted: an + * `os migrate notification-event` sub-command is a permanent operator surface + * for a migration with no measured demand, and a boot-time invoker is an + * unattended data rewrite. + * + * ⭐ **This file proves the removal is a REMOVAL, not a rename.** The module is + * gone from disk, no file in this package imports it under any spelling, and + * the barrel exports nothing named for it — while the sibling migrations that + * share the directory and the barrel are asserted present in the same cases. + * A probe that finds nothing everywhere is a dead probe, so every negative + * below is paired with the positive that shows it looks in the right place. + */ + +import { existsSync, readFileSync, readdirSync } from 'node:fs'; +import { dirname, join, relative } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { describe, it, expect } from 'vitest'; + +// ⭐ Imported at MODULE TOP on purpose. This barrel pulls in the whole +// migrations surface, and paying that load inside a clocked `it()` made the +// case time out at 5s under a loaded box — a flake that reads as a retirement +// regression. A clocked window measures behaviour, never loading (AGENTS.md). +import * as migrationsBarrel from './index.js'; + +const HERE = dirname(fileURLToPath(import.meta.url)); + +/** Every `.ts` file under this package's `src/`, walked once. */ +function sourceFiles(root: string): string[] { + const out: string[] = []; + const walk = (dir: string): void => { + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const full = join(dir, entry.name); + if (entry.isDirectory()) walk(full); + else if (entry.name.endsWith('.ts')) out.push(full); + } + }; + walk(root); + return out; +} + +const SRC_ROOT = join(HERE, '..'); + +describe('[#16194] migrateSysNotificationToEvent is retired from @objectstack/metadata', () => { + it('the module is gone from disk; its directory siblings are not', () => { + expect( + existsSync(join(HERE, 'migrate-sys-notification-to-event.ts')), + 'the runner is back on disk — re-read #16194 before restoring it', + ).toBe(false); + expect(existsSync(join(HERE, 'migrate-sys-notification-to-event.test.ts'))).toBe(false); + + // Anti-vacuity: the probe looks in the right directory. + expect(existsSync(join(HERE, 'migrate-project-id-to-environment-id.ts'))).toBe(true); + expect(existsSync(join(HERE, 'drop-projection-tables.ts'))).toBe(true); + expect(existsSync(join(HERE, 'driver-exec.ts'))).toBe(true); + }); + + it('nothing in this package imports or re-exports the module, under any spelling', () => { + const importers: string[] = []; + const survivors: string[] = []; + for (const file of sourceFiles(SRC_ROOT)) { + const src = readFileSync(file, 'utf-8'); + if (/(?:import|export)[^;]*['"][^'"]*migrate-sys-notification-to-event(?:\.js)?['"]/.test(src)) { + importers.push(relative(SRC_ROOT, file)); + } + if (/(?:import|export)[^;]*['"][^'"]*migrate-project-id-to-environment-id(?:\.js)?['"]/.test(src)) { + survivors.push(relative(SRC_ROOT, file)); + } + } + expect(importers, 'a resurrected import means the retirement is being undone').toEqual([]); + // The same regex shape, on a sibling that IS imported: proves the scan runs. + expect(survivors.length, 'the import scan found nothing at all — it is dead').toBeGreaterThan(0); + }); + + it('the barrel exports no name derived from it, and still exports its siblings', () => { + const names = Object.keys(migrationsBarrel); + + for (const gone of [ + 'migrateSysNotificationToEvent', + 'SysNotificationMigrationResult', + 'SysNotificationMigrationOptions', + 'SysNotificationMigrationReceipt', + ]) { + expect(names, `the barrel re-exports ${gone}`).not.toContain(gone); + } + // No rename either: nothing named for the cut-over survives on the barrel. + expect(names.filter((n) => /SysNotification|NotificationToEvent/i.test(n))).toEqual([]); + + // The control — the barrel is really loaded and really has exports. + expect(names).toContain('migrateProjectIdToEnvironmentId'); + expect(names).toContain('migrateEnvIdToProjectId'); + expect(names).toContain('dropProjectionTables'); + }); + + it('the barrel carries a tombstone, so the next author meets the ruling', () => { + const barrel = readFileSync(join(HERE, 'index.ts'), 'utf-8'); + expect(barrel).toMatch(/TOMBSTONE/); + expect(barrel).toMatch(/migrateSysNotificationToEvent/); + expect(barrel).toMatch(/files-to-references/); + }); +}); diff --git a/packages/metadata/src/migrations/raw-exec-operator-detail-16657.test.ts b/packages/metadata/src/migrations/raw-exec-operator-detail-16657.test.ts index dbedaef3d2..516a22cd35 100644 --- a/packages/metadata/src/migrations/raw-exec-operator-detail-16657.test.ts +++ b/packages/metadata/src/migrations/raw-exec-operator-detail-16657.test.ts @@ -41,7 +41,6 @@ import { AFFECTED_TABLES, migrateProjectIdToEnvironmentId, } from './migrate-project-id-to-environment-id.js'; -import { migrateSysNotificationToEvent } from './migrate-sys-notification-to-event.js'; import { dropProjectionTables } from './drop-projection-tables.js'; /** `rawStatementFaultError`'s composed message, verbatim (`sql-driver.ts`). */ @@ -161,33 +160,3 @@ describe('[#16657] dropProjectionTables — the per-table error record', () => { expect(results.every((r) => r.error === 'database is locked')).toBe(true); }); }); - -describe('[#16657] migrateSysNotificationToEvent — the run-level error record', () => { - /** No `getObject`, so the receipt is `no-ledger` and the run is the subject. */ - const noLedgerEngine = { async find() { return []; } } as never; - - const legacyColumns = ['id', 'recipient_id', 'type', 'title', 'body']; - - it('a refused legacy SELECT records the dialect text', async () => { - const result = await migrateSysNotificationToEvent({ - driver: refusingDriver(legacyColumns, () => - rawStatementFault('select id, recipient_id from "sys_event" - no such column: topic'), - ), - data: noLedgerEngine, - }); - - expect(result.status).toBe('error'); - expect(result.error).toBe('select id, recipient_id from "sys_event" - no such column: topic'); - expect(result.error).not.toContain('refused to run a raw statement'); - }); - - it('an UNDECLARED refusal is recorded on its own message channel', async () => { - const result = await migrateSysNotificationToEvent({ - driver: refusingDriver(legacyColumns, () => new Error('connection reset')), - data: noLedgerEngine, - }); - - expect(result.status).toBe('error'); - expect(result.error).toBe('connection reset'); - }); -}); diff --git a/packages/metadata/src/migrations/real-driver-exec-surface.test.ts b/packages/metadata/src/migrations/real-driver-exec-surface.test.ts index ff0134621c..2f6bd7f13a 100644 --- a/packages/metadata/src/migrations/real-driver-exec-surface.test.ts +++ b/packages/metadata/src/migrations/real-driver-exec-surface.test.ts @@ -26,23 +26,10 @@ import { afterEach, describe, expect, it } from 'vitest'; import { SqliteWasmDriver } from '@objectstack/driver-sqlite-wasm'; -// The engine double's write verbs route through the producer's OWN dispatch -// predicates, so it cannot accept a call `ObjectQL.` would refuse — the -// same pinning the sibling suite in this directory carries. Imported from -// `@objectstack/metadata-core` (a `dependencies` entry here) and not from -// `@objectstack/objectql`, which depends on this package: that edge would close -// a cycle turbo rejects. -import { - assertEngineDeleteDispatch, - assertEngineFindOnePredicate, - assertEngineUpdateDispatch, - type EngineFindOneQueryInput, -} from '@objectstack/metadata-core'; import { dropProjectionTables } from './drop-projection-tables.js'; import { migrateEnvIdToProjectId } from './migrate-env-id-to-project-id.js'; import { migrateProjectIdToEnvironmentId } from './migrate-project-id-to-environment-id.js'; -import { migrateSysNotificationToEvent } from './migrate-sys-notification-to-event.js'; /** Every driver made here, torn down in `afterEach` (sql.js holds a WASM heap). */ const live: SqliteWasmDriver[] = []; @@ -58,43 +45,6 @@ async function realDriver(): Promise { function sql(driver: SqliteWasmDriver): (statement: string, bindings?: unknown[]) => Promise { return (statement, bindings) => (driver as any).execute(statement, bindings ?? []); } - -/** - * Engine double for the ONE helper that also needs an `IDataEngine`. The driver - * under test is real; this stands in only for the structured-write half, which - * is not what this file is about. - */ -function recordingEngine() { - const inserts: Array<{ object: string; row: any }> = []; - const updates: Array<{ object: string; data: any }> = []; - return { - inserts, - updates, - engine: { - async insert(object: string, row: any) { - inserts.push({ object, row }); - return { id: `${object}_${inserts.length}`, ...row }; - }, - async update(object: string, data: any, options?: Record) { - assertEngineUpdateDispatch(data, options); - updates.push({ object, data }); - return data; - }, - async find() { return []; }, - async findOne(object: string, query?: EngineFindOneQueryInput) { - assertEngineFindOnePredicate(object, query); - return null; - }, - async delete(_object?: string, options?: Record) { - assertEngineDeleteDispatch(options); - return {}; - }, - async count() { return 0; }, - async aggregate() { return []; }, - } as any, - }; -} - afterEach(async () => { while (live.length > 0) { await live.pop()!.disconnect().catch(() => undefined); @@ -193,55 +143,4 @@ describe('migrations against a driver this repo actually defines', () => { expect(names).not.toContain('sys_object'); expect(names).not.toContain('sys_view'); }); - - it('migrateSysNotificationToEvent carries legacy rows across on a real database', async () => { - const driver = await realDriver(); - const run = sql(driver); - await run( - 'CREATE TABLE "sys_notification" (' + - 'id TEXT PRIMARY KEY, recipient_id TEXT, type TEXT, title TEXT, body TEXT, url TEXT, ' + - 'actor_name TEXT, is_read INTEGER, read_at TEXT, created_at TEXT, organization_id TEXT, ' + - 'topic TEXT, payload TEXT, severity TEXT)', - ); - await run( - 'INSERT INTO "sys_notification" ' + - '(id, recipient_id, type, title, body, url, actor_name, is_read, read_at, created_at, organization_id) ' + - 'VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', - ['n1', 'u1', 'mention', 'You were mentioned', 'hi', '/x', 'Ada', 0, null, '2026-01-01T00:00:00.000Z', 'org_1'], - ); - const e = recordingEngine(); - - const result = await migrateSysNotificationToEvent({ driver, data: e.engine }); - - // This is the assertion the card is about: an operator following - // `docs/handoff/adr-0030-notification-convergence.md` step 2 with their - // platform driver used to get `{ status: 'error', migrated: 0 }` here. - expect(result.status).toBe('migrated'); - expect(result.migrated).toBe(1); - expect(e.inserts.map((i) => i.object)).toEqual(['sys_inbox_message', 'sys_notification_receipt']); - expect(e.inserts[0]!.row).toMatchObject({ user_id: 'u1', notification_id: 'n1', action_url: '/x' }); - - // The legacy columns were really cleared, through the real driver, with - // the id passed as a BINDING — the one call site that binds a value. - const rows: any = await run('SELECT recipient_id, title FROM "sys_notification" WHERE id = ?', ['n1']); - const list: any[] = Array.isArray(rows) ? (Array.isArray(rows[0]) ? rows[0] : rows) : []; - expect(list[0]?.recipient_id).toBeNull(); - expect(list[0]?.title).toBeNull(); - }); - - it('migrateSysNotificationToEvent reports not_applicable on a real post-cut-over table', async () => { - const driver = await realDriver(); - await sql(driver)( - 'CREATE TABLE "sys_notification" (id TEXT PRIMARY KEY, topic TEXT, payload TEXT, severity TEXT, created_at TEXT)', - ); - const e = recordingEngine(); - - const result = await migrateSysNotificationToEvent({ driver, data: e.engine }); - - // Distinguishes the repair from "accepts anything": a real driver whose - // table never held the inbox shape must still be told apart from one the - // migration could not drive at all. - expect(result.status).toBe('not_applicable'); - expect(e.inserts).toHaveLength(0); - }); }); diff --git a/packages/platform-objects/src/plugin.test.ts b/packages/platform-objects/src/plugin.test.ts index 2e52cff776..e6cbe48a45 100644 --- a/packages/platform-objects/src/plugin.test.ts +++ b/packages/platform-objects/src/plugin.test.ts @@ -186,7 +186,8 @@ describe('PlatformObjectsPlugin: fresh-datastore attestation (#3438, ADR-0104)', await boot(engine); expect(engine.rows.map((r: any) => r.id).sort()).toEqual([ - 'adr-0030-notification-event', // #15710 ruling 3 — creation-attested + // ⚰️ `adr-0030-notification-event` was the third id and left the set + // with its runner (#16194) — two members, both ADR-0104. 'adr-0104-file-references', 'adr-0104-value-shapes', ]); @@ -265,7 +266,6 @@ describe('PlatformObjectsPlugin: fresh-datastore attestation (#3438, ADR-0104)', await ctx._flush('app:seeded'); expect(engine.rows.map((r: any) => r.id).sort()).toEqual([ - 'adr-0030-notification-event', 'adr-0104-file-references', 'adr-0104-value-shapes', ]); @@ -282,7 +282,7 @@ describe('PlatformObjectsPlugin: fresh-datastore attestation (#3438, ADR-0104)', await ctx._flush('app:seeded'); await ctx._flushReady(); - expect(engine.rows).toHaveLength(3); + expect(engine.rows).toHaveLength(2); }); /** @@ -308,10 +308,8 @@ describe('PlatformObjectsPlugin: fresh-datastore attestation (#3438, ADR-0104)', await ctx._flush('app:seeded'); await ctx._flushReady(); - expect(engine.rows.map((r: any) => r.id)).toEqual([ - 'adr-0104-value-shapes', - 'adr-0030-notification-event', - ]); + // The contradicted id is the FILE one; the only other member survives. + expect(engine.rows.map((r: any) => r.id)).toEqual(['adr-0104-value-shapes']); }); }); @@ -373,7 +371,6 @@ describe('PlatformObjectsPlugin: fresh-datastore attestation (#3438, ADR-0104)', await ctx._flush('app:seeded'); expect(engine.rows.map((r: any) => r.id).sort()).toEqual([ - 'adr-0030-notification-event', 'adr-0104-file-references', 'adr-0104-value-shapes', ]); @@ -396,7 +393,7 @@ describe('PlatformObjectsPlugin: fresh-datastore attestation (#3438, ADR-0104)', state.inFlight = 0; // app B settled await ctx._flush('app:seeded'); - expect(engine.rows).toHaveLength(3); + expect(engine.rows).toHaveLength(2); }); /** @@ -452,7 +449,6 @@ describe('PlatformObjectsPlugin: fresh-datastore attestation (#3438, ADR-0104)', await ctx._flushReady(); expect(engine.rows.map((r: any) => r.id).sort()).toEqual([ - 'adr-0030-notification-event', 'adr-0104-file-references', 'adr-0104-value-shapes', ]); @@ -468,7 +464,7 @@ describe('PlatformObjectsPlugin: fresh-datastore attestation (#3438, ADR-0104)', await ctx._flushReady(); - expect(engine.rows).toHaveLength(3); + expect(engine.rows).toHaveLength(2); }); /** diff --git a/packages/platform-objects/src/system/migration-flag.test.ts b/packages/platform-objects/src/system/migration-flag.test.ts index 036625e059..e90aa38793 100644 --- a/packages/platform-objects/src/system/migration-flag.test.ts +++ b/packages/platform-objects/src/system/migration-flag.test.ts @@ -167,26 +167,35 @@ describe('fresh-datastore attestation (ADR-0104, 2026-07-30 addendum)', () => { }); /** - * #15710 ruling 3. The ADR-0030 cut-over id is attested at birth like the - * two ADR-0104 ids — a store created after the cut-over never held a legacy - * inbox row — and in the SAME uniform shape: `applied_at: null`, - * `blocking: 0`, `details.attested`, `verified_at` set for the birth fact. - * What a RUN of that migration may claim (never `verified_at`) lives on the - * id's docblock in `@objectstack/spec`; this pins that the writer treats it - * as one more member and invents no per-id shape. Named by symbol, not by - * iterating the array: the loop above would stay green with the member gone. + * [#16194] The ADR-0030 cut-over left this array. Its runner was retired + * whole — nothing can run the migration, so nothing may attest that it did. + * + * ⭐ Both halves, in one case. "No row for the retired id" alone cannot tell + * a correct removal from an attestation that wrote nothing at all, so the + * two survivors are asserted present, verified and in the uniform shape in + * the same run. Named by symbol, not by iterating the array: the loop above + * follows the array wherever it goes and would stay green either way. */ - it('attests the ADR-0030 notification-event id at birth, in the uniform shape', async () => { + it('writes rows for the two survivors and NONE for the retired ADR-0030 id', async () => { const engine = fakeEngine(); const attested = await attestFreshDatastore(engine); - expect(attested).toContain(NOTIFICATION_EVENT_MIGRATION_ID); - const row = engine.tables.sys_migration.find((r) => r.id === NOTIFICATION_EVENT_MIGRATION_ID)!; - expect(row).toMatchObject({ applied_at: null, blocking: 0, advisory: 0 }); - expect(row.verified_at).toBeTruthy(); - expect(JSON.parse(String(row.details))).toEqual(CREATION_ATTESTATION_DETAIL); - expect(await isDataMigrationVerified(engine, NOTIFICATION_EVENT_MIGRATION_ID)).toBe(true); + expect(attested).not.toContain(NOTIFICATION_EVENT_MIGRATION_ID); + expect(engine.tables.sys_migration.find((r) => r.id === NOTIFICATION_EVENT_MIGRATION_ID)).toBeUndefined(); + expect(await isDataMigrationVerified(engine, NOTIFICATION_EVENT_MIGRATION_ID)).toBe(false); + + // The control. Removal, not collapse: both ADR-0104 ids keep their rows. + for (const id of [FILE_REFERENCES_MIGRATION_ID, VALUE_SHAPES_MIGRATION_ID]) { + expect(attested).toContain(id); + const row = engine.tables.sys_migration.find((r) => r.id === id)!; + expect(row, `${id} lost its attestation row`).toBeDefined(); + expect(row).toMatchObject({ applied_at: null, blocking: 0 }); + expect(row.verified_at).toBeTruthy(); + expect(JSON.parse(String(row.details))).toEqual(CREATION_ATTESTATION_DETAIL); + expect(await isDataMigrationVerified(engine, id)).toBe(true); + } + expect(engine.tables.sys_migration).toHaveLength(2); }); /** @@ -329,28 +338,42 @@ describe('fresh-datastore attestation (ADR-0104, 2026-07-30 addendum)', () => { [VALUE_SHAPES_MIGRATION_ID]: 'value-shapes', }; + /** + * ⚰️ Re-fixtured. This case's subject used to be + * `adr-0030-notification-event`, the third creation-attested id, which + * has since been retired out of the array whole. Every SURVIVING member + * has a remedy row, so the array can no longer supply the case with a + * lookup MISS — and the miss is the entire branch #16067 landed. The id + * below is therefore a synthetic one handed in through `migrationIds`: + * ⛔ it must NOT be a member of {@link CREATION_ATTESTED_MIGRATION_IDS}, + * or this case silently stops testing the branch it names. + */ + const NO_REMEDY_ID = 'adr-0000-no-value-shape-contract'; + it('an id with NO value-shape contract is never-contradictable, and is told to run nothing', async () => { + expect( + (CREATION_ATTESTED_MIGRATION_IDS as readonly string[]).includes(NO_REMEDY_ID), + 'the synthetic id joined the real array — pick another, or this case tests nothing', + ).toBe(false); const engine = fakeEngine(); - engine.valueShapeViolationsAdmitted = () => ({ - [NOTIFICATION_EVENT_MIGRATION_ID]: VIOLATED, - }); + engine.valueShapeViolationsAdmitted = () => ({ [NO_REMEDY_ID]: VIOLATED }); const logger = { info: vi.fn(), warn: vi.fn() }; - const attested = await attestFreshDatastore(engine, { logger }); + const attested = await attestFreshDatastore(engine, { + logger, + migrationIds: [...CREATION_ATTESTED_MIGRATION_IDS, NO_REMEDY_ID], + }); - // A value-shape tally is evidence about value shapes. The ADR-0030 - // cut-over's fact — no legacy per-user inbox row here — is not one, so - // this counterexample disproves nothing about it and the birth + // A value-shape tally is evidence about value shapes. An id whose fact + // is not a value-shape fact is not disproved by one, so the birth // observation still settles it. - expect(attested).toContain(NOTIFICATION_EVENT_MIGRATION_ID); - expect(await isDataMigrationVerified(engine, NOTIFICATION_EVENT_MIGRATION_ID)).toBe(true); + expect(attested).toContain(NO_REMEDY_ID); + expect(await isDataMigrationVerified(engine, NO_REMEDY_ID)).toBe(true); const warnings = logger.warn.mock.calls.map((c) => String(c[0] ?? '')).join('\n'); // ⭐ The card's pin: the operator is NOT sent to `os migrate - // value-shapes`, which neither attests nor clears this id. There is no - // `os migrate notification-event` to send them to either — that - // cut-over is an operator call with no self-check — so the correct - // sentence here is no sentence. + // value-shapes`, which neither attests nor clears this id — the + // correct sentence for a lookup miss is no sentence. expect(warnings).not.toContain('value-shapes'); expect(warnings).toBe(''); }); diff --git a/packages/platform-objects/src/system/migration-flag.ts b/packages/platform-objects/src/system/migration-flag.ts index ea21430213..f1c444daa8 100644 --- a/packages/platform-objects/src/system/migration-flag.ts +++ b/packages/platform-objects/src/system/migration-flag.ts @@ -220,11 +220,12 @@ export async function recordDataMigrationRun( * answer for every id that has one is a DIFFERENT command. A two-way branch * answered both at once: it read the file id and gave every other id * `value-shapes` by default. That default was silently wrong the moment a third - * id joined {@link CREATION_ATTESTED_MIGRATION_IDS} — `adr-0030-notification-event` - * would have been told to run `os migrate value-shapes --apply`, a command that - * does not attest it, does not clear it, and has nothing to do with it (there is - * no `os migrate notification-event` at all; that cut-over is an operator call - * with no self-check, ruled on `NOTIFICATION_EVENT_MIGRATION_ID`'s docblock). + * id joined {@link CREATION_ATTESTED_MIGRATION_IDS}: the ADR-0030 notification + * cut-over (`adr-0030-notification-event`, since RETIRED whole — no runner, no + * membership) would have been told to run `os migrate value-shapes --apply`, a + * command that does not attest it, does not clear it, and has nothing to do + * with it. The member is gone; the defect the branch encoded is not, and this + * map is what keeps it gone. * * ⛔ So a new member must NOT inherit a remedy. An id absent from this map is * never-contradictable *by this evidence* — a value-shape tally says nothing diff --git a/packages/runtime/src/notification-migration-audit-preservation.integration.test.ts b/packages/runtime/src/notification-migration-audit-preservation.integration.test.ts deleted file mode 100644 index 4be5fa23b5..0000000000 --- a/packages/runtime/src/notification-migration-audit-preservation.integration.test.ts +++ /dev/null @@ -1,282 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -/** - * [#16312] The `sys_notification` → event migration keeps the ORIGINAL - * notification instant, measured through the REAL `sys_stamp_audit_insert` - * hook. - * - * ## Why this file exists, and why it is not in `packages/metadata` - * - * The migration under test lives in - * `packages/metadata/src/migrations/migrate-sys-notification-to-event.ts` and - * its own suite drives it through a **fake engine double** whose `insert` - * records the payload directly. That double is faithful about DISPATCH — its - * write verbs route through `assertEngineUpdateDispatch` / - * `assertEngineDeleteDispatch`, so it cannot accept a call `ObjectQL` would - * refuse — and **silent about the before phase**: it runs no hooks at all. - * - * The defect lived in exactly that seam. `sys_stamp_audit_insert` used to read - * `record.created_at = record.created_at ?? now` on EVERY insert, so the - * migration's back-dated `created_at` survived by accident. #15964 ruled that - * laundering out (maintainer ruling 2026-09-06, decision batch #54, option A): - * an ordinary create now stamps `now`, and preservation is DECLARED through - * `context.preserveAudit`. This migration never declared it — and the sibling - * suite stayed `23 passed` on both sides of that change, because a double that - * runs no hooks cannot tell the two behaviours apart. - * - * ⇒ a test that still uses that double proves nothing here, however well its - * assertions read. This one boots a REAL kernel: `ObjectQLPlugin` registers - * the shipped audit hooks, `MessagingServicePlugin` registers and provisions - * `sys_notification` / `sys_inbox_message` / `sys_notification_receipt`, and - * `SqliteWasmDriver` is a real driver running real SQLite in-process. - * - * ⚠️ It is in `packages/runtime` and not next to the code it tests because - * **`@objectstack/objectql` depends on `@objectstack/metadata`** — the edge a - * test-only import would close is a cycle turbo rejects, which is the same - * constraint `migrations/real-driver-exec-surface.test.ts` records in its own - * header and the reason `date-bucket-parity-turso` moved out of its driver - * package. `@objectstack/runtime` is the nearest package that depends on BOTH - * halves, and it already owns this domain (`src/domains/notifications.ts`, - * `notification-schema-conformance.integration.test.ts`). - * - * ## The three readings, and why the first two are load-bearing - * - * `hook is live` and `the declared channel is open` are ANTI-VACUITY controls. - * Without them a green `the migration preserves` is indistinguishable from a - * fixture where no hook ran — which is precisely the failure mode this file - * exists to close, so it must not be able to recur one level up. Delete the - * `preserveAudit` flag from the migration and only the third goes red; delete - * the audit hook from the engine and the FIRST goes red, by name. - */ - -import { describe, it, expect, beforeAll, afterAll } from 'vitest'; -import { ObjectKernel } from '@objectstack/core'; -import { ObjectQLPlugin } from '@objectstack/objectql'; -import { SqliteWasmDriver } from '@objectstack/driver-sqlite-wasm'; -import { MessagingServicePlugin } from '@objectstack/service-messaging'; -import { migrateSysNotificationToEvent } from '@objectstack/metadata/migrations'; -import type { IDataEngine } from '@objectstack/spec/contracts'; - -import { DriverPlugin } from './driver-plugin.js'; - -const EVENT_OBJECT = 'sys_notification'; -const INBOX_OBJECT = 'sys_inbox_message'; -const RECEIPT_OBJECT = 'sys_notification_receipt'; - -/** - * The legacy inbox columns ADR-0030 removed from the object. A deployment that - * predates the cut-over still carries them, so the fixture puts them back onto - * the table the plugin provisions — which is what the migration's own - * `columnExists` probe is written to find. - */ -const LEGACY_COLUMNS: ReadonlyArray = [ - ['recipient_id', 'TEXT'], - ['type', 'TEXT'], - ['title', 'TEXT'], - ['body', 'TEXT'], - ['url', 'TEXT'], - ['actor_name', 'TEXT'], - ['is_read', 'INTEGER'], - ['read_at', 'TEXT'], -]; - -/** - * Deliberately years in the past. The whole verdict is "is this instant, or the - * instant the migration ran, on the row?" — so the two must never be within a - * clock-skew of each other, and `expect(...).not.toBe(RUN_WINDOW)` is not what - * discriminates: the positive assertion is. - */ -const NOTIFICATION_INSTANT = '2019-03-04T05:06:07.891Z'; -const READ_INSTANT = '2019-03-05T06:07:08.912Z'; - -/** `Date.parse` of a stored value, whatever spelling the driver handed back. */ -function instantOf(value: unknown): number { - if (value instanceof Date) return value.getTime(); - return Date.parse(String(value)); -} - -describe('[#16312] the sys_notification migration preserves the original audit timeline', () => { - let kernel: ObjectKernel; - let driver: SqliteWasmDriver; - let data: IDataEngine; - /** Raw SQL through the driver's own surface — the same door an operator has. */ - let sql: (statement: string, bindings?: unknown[]) => Promise; - /** Wall clock read just before the migration runs; the value the defect writes. */ - let runWindowStart = 0; - - beforeAll(async () => { - kernel = new ObjectKernel({ logger: { level: 'silent' } }); - driver = new SqliteWasmDriver({ filename: ':memory:' }); - await kernel.use(new DriverPlugin(driver)); - await kernel.use(new ObjectQLPlugin()); - // Inline delivery: this file never calls `emit()`, but the plugin's - // table provisioning is what puts the three objects on the database. - await kernel.use(new MessagingServicePlugin({ reliableDelivery: false })); - await kernel.bootstrap(); - - data = kernel.getService('objectql'); - sql = (statement, bindings) => (driver as any).execute(statement, bindings ?? []); - - // Put the pre-ADR-0030 inbox columns back on the provisioned table. - for (const [column, type] of LEGACY_COLUMNS) { - await sql(`ALTER TABLE "${EVENT_OBJECT}" ADD COLUMN "${column}" ${type}`); - } - - // One legacy row, written through raw SQL exactly as the old shape left - // it — no engine, no hooks, so `created_at` is the notification's own - // instant and nothing has had a chance to restamp it. - await sql( - `INSERT INTO "${EVENT_OBJECT}" (id, recipient_id, type, title, body, url, actor_name, is_read, read_at, created_at) ` + - `VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - [ - 'ntf_legacy_16312', - 'usr_16312', - 'task.assigned', - 'Legacy notification', - 'body text', - '/records/1', - 'Ada', - 1, - READ_INSTANT, - NOTIFICATION_INSTANT, - ], - ); - - runWindowStart = Date.now(); - const result = await migrateSysNotificationToEvent({ driver, data }); - expect(result.status).toBe('migrated'); - expect(result.migrated).toBe(1); - }, 120_000); - - afterAll(async () => { - if (kernel) { - await Promise.race([ - kernel.shutdown(), - new Promise((resolve) => setTimeout(resolve, 10_000)), - ]); - } - }, 30_000); - - /** - * ANTI-VACUITY CONTROL — the shipped audit hook really runs on this engine. - * - * An ordinary insert carrying a back-dated `created_at` and NO write - * context must come back stamped with the write instant. This is the - * assertion the fake-engine double in `packages/metadata` cannot make, and - * it is what makes the third case below a measurement rather than a - * restatement of the fixture. - */ - it('control — the real audit hook is live here: an ordinary insert is restamped `now`', async () => { - const before = Date.now(); - await data.insert(INBOX_OBJECT, { - user_id: 'usr_control_ordinary', - notification_id: 'ntf_control_ordinary', - topic: 'control', - title: 'ordinary create', - severity: 'info', - created_at: NOTIFICATION_INSTANT, - }); - const rows = await sql( - `SELECT created_at FROM "${INBOX_OBJECT}" WHERE user_id = ?`, - ['usr_control_ordinary'], - ); - const stored = instantOf(firstRow(rows).created_at); - expect(stored).not.toBe(Date.parse(NOTIFICATION_INSTANT)); - expect(stored).toBeGreaterThanOrEqual(before - 1000); - }); - - /** - * ANTI-VACUITY CONTROL — the historical-import channel is open on THIS - * write path, for THIS object. - * - * `preserveAudit` is a fossil with a ruling behind it (#3493, reaffirmed by - * #15964): it is the declared channel for reinstating an original timeline, - * not a bypass of audit. The 2026-08-08 ruling narrowed the create-side - * READONLY-strip exemption to `isSystem` only — but `staticReadonlyInsertSubject` - * exits early for a `sys_`-prefixed or `managedBy` object, so no strip runs - * on these two at all and no `preserveAudit is UPDATE-only` warning is owed. - * That reasoning is what this case turns into a measurement. - */ - it('control — `context.preserveAudit` keeps a supplied created_at on this object', async () => { - await data.insert( - INBOX_OBJECT, - { - user_id: 'usr_control_preserve', - notification_id: 'ntf_control_preserve', - topic: 'control', - title: 'declared historical create', - severity: 'info', - created_at: NOTIFICATION_INSTANT, - }, - { context: { preserveAudit: true } }, - ); - const rows = await sql( - `SELECT created_at FROM "${INBOX_OBJECT}" WHERE user_id = ?`, - ['usr_control_preserve'], - ); - expect(instantOf(firstRow(rows).created_at)).toBe(Date.parse(NOTIFICATION_INSTANT)); - }); - - /** - * THE CARD. Both migrated rows carry the NOTIFICATION's instant, not the - * migration's. - * - * RED before the fix: the two writes passed no options bag at all, so the - * audit hook took the ordinary branch and stamped the migration instant on - * every migrated inbox row and receipt — an entire bell history flattened - * to "all arrived today". - */ - it('the migrated inbox row keeps the notification instant, not the migration instant', async () => { - const rows = await sql( - `SELECT created_at FROM "${INBOX_OBJECT}" WHERE notification_id = ?`, - ['ntf_legacy_16312'], - ); - expect(instantOf(firstRow(rows).created_at)).toBe(Date.parse(NOTIFICATION_INSTANT)); - }); - - it('the migrated receipt keeps the notification instant on created_at and the read instant on `at`', async () => { - const rows = await sql( - `SELECT created_at, at FROM "${RECEIPT_OBJECT}" WHERE notification_id = ?`, - ['ntf_legacy_16312'], - ); - const row = firstRow(rows); - expect(instantOf(row.created_at)).toBe(Date.parse(NOTIFICATION_INSTANT)); - // `at` is an ordinary declared field, so no audit hook was ever going - // to touch it. Asserted anyway: it is the half of the receipt's - // timeline that was ALREADY correct, and a change that "fixed" - // `created_at` by moving both to one value would be caught here. - expect(instantOf(row.at)).toBe(Date.parse(READ_INSTANT)); - }); - - /** - * The source row survives the migration with its own `created_at` intact — - * the fact that makes a bad run recoverable rather than terminal. It is - * asserted here because the migration REWRITES this row (`data.update` to - * the event shape, then a raw `SET … = NULL` over the legacy columns), and - * `created_at` is in neither of those two sets. - */ - it('the source sys_notification row is rewritten in place and keeps its own created_at', async () => { - const rows = await sql( - `SELECT created_at, topic, recipient_id FROM "${EVENT_OBJECT}" WHERE id = ?`, - ['ntf_legacy_16312'], - ); - const row = firstRow(rows); - expect(instantOf(row.created_at)).toBe(Date.parse(NOTIFICATION_INSTANT)); - // Rewritten, not skipped: the event shape landed and the legacy - // recipient is gone, so this row really did go through both writes. - expect(row.topic).toBe('task.assigned'); - expect(row.recipient_id).toBeNull(); - // ⇒ and it still predates the run. This is the seat's re-grading input - // stated as an assertion: a bad run is recoverable because the source - // instant is still here to backfill FROM. - expect(instantOf(row.created_at)).toBeLessThan(runWindowStart); - }); -}); - -/** knex wraps some results as `[rows]`; normalize both shapes and take the first. */ -function firstRow(result: any): any { - const list = Array.isArray(result) && Array.isArray(result[0]) ? result[0] : result; - expect(Array.isArray(list)).toBe(true); - expect(list.length).toBeGreaterThan(0); - return list[0]; -} diff --git a/packages/spec/src/system/migration.zod.ts b/packages/spec/src/system/migration.zod.ts index 4289cf7ad8..e99721356e 100644 --- a/packages/spec/src/system/migration.zod.ts +++ b/packages/spec/src/system/migration.zod.ts @@ -170,61 +170,31 @@ export const FILE_REFERENCES_MIGRATION_ID = 'adr-0104-file-references'; export const VALUE_SHAPES_MIGRATION_ID = 'adr-0104-value-shapes'; /** - * Well-known migration id: ADR-0030 notification convergence — this - * deployment's legacy per-user `sys_notification` inbox rows split into - * `sys_inbox_message` + `sys_notification_receipt`, and the `sys_notification` - * row itself rewritten to the L2 event shape, by `migrateSysNotificationToEvent` - * (`@objectstack/metadata/migrations`). + * ⚰️ RETIRED migration id: ADR-0030 notification convergence. * - * Registered so that "has this cut-over run here?" is ANSWERABLE at all. The - * cut-over is destructive and one-way, it is handed to operators as a call they - * make themselves (`docs/handoff/adr-0030-notification-convergence.md`, under - * "Data migration (not auto-run)"), and it shipped with no id — so a deployment - * that ran it recorded nothing, and one that did not is indistinguishable from - * one that did. A ledger row can only be keyed by an id; without one the - * question has no place to be answered even in principle. That absence, not the - * migration, is what this constant repairs. + * The runner this id stood for — `migrateSysNotificationToEvent` in + * `@objectstack/metadata/migrations` — is GONE, together with its barrel + * export, its tests, the ruled `sys_migration` receipt-claim matrix this + * docblock used to state, that matrix's pin, and this id's membership in + * {@link CREATION_ATTESTED_MIGRATION_IDS}. Nothing in the platform runs the + * cut-over, writes a row under this id, or reads one: pre-ADR-0030 + * `sys_notification` rows are not carried by the platform on this line. * - * What a run of it may claim in the ledger is RULED (maintainer 「同意」 to - * decision batch #47 item 5, recorded on #15710 — the question batch #21 - * reserved when the id was registered under #14025). The two ids above take - * their column semantics from an `os migrate` command that scans, self-checks, - * and only then records; this migration has no such command and no self-check - * — it reports `migrated` / `already_done` / `not_applicable` / `error` to its - * caller and nothing else — so its claims are narrower than theirs: + * The constant survives as the NAME of rows already written under it — a + * deployment attested at birth, or one that made the operator call while the + * runner shipped, still holds `sys_migration` row `adr-0030-notification-event` + * and an operator reading that table needs the id to still mean something. + * ⛔ It is not a registration: writing a fresh row under it claims a fact no + * code in this repo can produce any more. * - * - `last_run_at`: set on every COMPLETED non-`error` run — `migrated`, - * `already_done` and `not_applicable` alike. - * - `applied_at`: set only on `migrated` (legacy inbox rows were rewritten). - * - `verified_at`: NEVER set by a run of this migration. `verified_at` means - * a self-check passed, and there is no self-check to pass. - * - `blocking`: `0` by construction — nothing counts discrepancies. - * - `details.outcome`: the four-valued result, verbatim. - * - an `error` run writes NO ledger claim at all. - * - * 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.object.ts` - * (`@objectstack/platform-objects`) already documents for the seed-tenancy - * repair — `verified_at: null`, `blocking: 0` by construction — which is - * exactly the shape {@link isDataMigrationFlagVerified} answers `false` to. - * - * Creation-attested. A datastore created after the cut-over has no legacy - * inbox rows by construction, and its creator observed it come into being - * with none — the same "true by birth, observably" argument - * {@link CREATION_ATTESTED_MIGRATION_IDS} makes for its other members — so - * this id is a member of that array. Leaving it out would make a fresh - * store's ledger read "never ran" for a registered id, which is false. The - * attestation is not a run: `attestFreshDatastore` - * (`@objectstack/platform-objects`) writes one uniform shape for every member - * — `details.attested: 'datastore-created-empty'`, `applied_at: null`, - * `blocking: 0`, and `verified_at` set for the fact observed at birth — so on - * a fresh store this row does read as verified, by birth and never by a run, - * and it still gates nothing. - * - * Which caller writes the run receipt when the migration runs is the runner's - * contract (`@objectstack/metadata/migrations`), decided in its own lane — not - * here. + * ⛔ Do not re-add a runner, an `os migrate` sub-command, or a boot-time + * invoker under this id. Both were considered and refused: an operator door is + * a permanent surface for a migration with no measured demand, and an + * unattended boot-time data rewrite is the higher-variance shape. If a named + * deployment turns out to hold pre-ADR-0030 notification rows it needs, the + * migration returns as an operator-runnable sub-command shaped exactly like + * `files-to-references` / `value-shapes`, under its own card — ⛔ never as a + * quiet reintroduction here. */ export const NOTIFICATION_EVENT_MIGRATION_ID = 'adr-0030-notification-event'; @@ -232,28 +202,32 @@ export const NOTIFICATION_EVENT_MIGRATION_ID = 'adr-0030-notification-event'; * The migrations a datastore attests at CREATION rather than by scanning. * * Every fact these ids stand for — no legacy file value here, no malformed - * stored value here, no legacy `sys_notification` inbox row here — is true by - * construction of an empty store, and true *observably*: the creator watched - * it come into being with no rows at all. That is the same observed-transition - * discipline the gates run on, not version-gating in disguise; a store that - * merely *looks* empty when found earns nothing, because "found empty" is an - * inference and "created empty" is an observation. + * stored value here — is true by construction of an empty store, and true + * *observably*: the creator watched it come into being with no rows at all. + * That is the same observed-transition discipline the gates run on, not + * version-gating in disguise; a store that merely *looks* empty when found + * earns nothing, because "found empty" is an inference and "created empty" is + * an observation. * * Without this, every deployment born on a version that already ships the * migrations would start lax and stay lax until someone ran a command that is, * for them, a no-op — so the warn regime would never die out. * - * The third member is the ADR-0030 cut-over - * ({@link NOTIFICATION_EVENT_MIGRATION_ID}): a store created after it never - * held a per-user inbox row for the migration to split, so the birth - * observation settles that fact exactly as it settles the two ADR-0104 ones. - * Its attestation row is the same uniform shape as theirs; what a RUN of that - * migration may claim differs, and lives on the id's own docblock. + * ⚰️ The ADR-0030 cut-over ({@link NOTIFICATION_EVENT_MIGRATION_ID}) was the + * third member and is no longer one. Its runner was retired, so there is no + * longer a fact for a birth observation to settle here: attesting an id whose + * migration cannot run records a claim about a migration that does not exist. + * The id itself is kept as the name of rows already written under it, and + * ⛔ must not be re-added to this array without the runner coming back. + * + * Every member of this array is attested by `attestFreshDatastore` + * (`@objectstack/platform-objects`) in one uniform shape, so a member added + * here is a row written on every fresh store — a deliberate act, never a + * tidying-up. */ export const CREATION_ATTESTED_MIGRATION_IDS = [ FILE_REFERENCES_MIGRATION_ID, VALUE_SHAPES_MIGRATION_ID, - NOTIFICATION_EVENT_MIGRATION_ID, ] as const; export const DataMigrationFlagSchema = lazySchema(() => z.object({ diff --git a/packages/spec/src/system/notification-event-migration-ledger.pin.test.ts b/packages/spec/src/system/notification-event-migration-ledger.pin.test.ts deleted file mode 100644 index b089765089..0000000000 --- a/packages/spec/src/system/notification-event-migration-ledger.pin.test.ts +++ /dev/null @@ -1,244 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -/** - * [#15710] What a run of `adr-0030-notification-event` may claim in the - * `sys_migration` ledger — the ruled matrix, pinned to the docblock that - * states it, to the array that carries the creation-attestation half, and to - * the arbiter that makes "receipt, not gate" a fact rather than a sentence. - * - * When the id was registered (#14025) its docblock deliberately said the - * column semantics were NOT settled and that "silence is not an answer": the - * two ADR-0104 ids take their meaning from an `os migrate` command that scans, - * self-checks and only then records, and this migration has neither. The - * maintainer then ruled (decision batch #47 item 5, verbatim 「同意」; batch #21 - * had reserved the question): `last_run_at` on every completed non-`error` - * run, `applied_at` only on `migrated`, `verified_at` never, `details.outcome` - * carrying the four-valued result, an `error` run writing no claim; the row is - * a receipt, not a gate; and the id joins `CREATION_ATTESTED_MIGRATION_IDS`. - * - * ## What is pinned, and why each half - * - * 1. The MEMBERSHIP, as a literal list in order. The runtime reader - * (`attestFreshDatastore` in `@objectstack/platform-objects`) and its own - * pins iterate the array, so they follow any change to it by construction — - * a member silently dropped would leave every one of them green. Only a - * literal notices. - * 2. The DOCBLOCK, as relations rather than wording. The matrix is read as - * bullets: the `last_run_at` bullet names all three non-`error` outcomes, - * the `applied_at` bullet ties itself to `migrated` alone, the - * `verified_at` bullet carries the negation, the `details.outcome` and - * `error` bullets exist, and the retired "silence is not an answer" - * sentence is gone. Rewording freely is fine; dropping an outcome, or - * re-entering `verified_at` as a claim a run may make, goes red. - * 3. The ARBITER. The receipt shape the ruling names (`verified_at: null`, - * `blocking: 0`, `last_run_at` set, `applied_at` set or not) must answer - * `false` to {@link isDataMigrationFlagVerified} — otherwise "receipt, not - * gate" would be prose over a row that authorises something. The control - * beside it proves the `false` is not vacuous. - * - * The self-test feeds the historical paragraph to the same readers, so the - * docblock half cannot pass merely by the prose falling silent. - */ - -import { readFileSync } from 'node:fs'; -import { dirname, join } from 'node:path'; -import { fileURLToPath } from 'node:url'; - -import { describe, it, expect } from 'vitest'; - -import { - CREATION_ATTESTED_MIGRATION_IDS, - FILE_REFERENCES_MIGRATION_ID, - NOTIFICATION_EVENT_MIGRATION_ID, - VALUE_SHAPES_MIGRATION_ID, - isDataMigrationFlagVerified, - type DataMigrationFlag, -} from './migration.zod'; - -const HERE = dirname(fileURLToPath(import.meta.url)); -const SOURCE = join(HERE, 'migration.zod.ts'); - -/** The JSDoc block attached to the `NOTIFICATION_EVENT_MIGRATION_ID` constant. */ -function notificationEventDoc(): string { - const source = readFileSync(SOURCE, 'utf8'); - const decl = source.indexOf( - "export const NOTIFICATION_EVENT_MIGRATION_ID = 'adr-0030-notification-event'", - ); - expect(decl, 'the `NOTIFICATION_EVENT_MIGRATION_ID` declaration moved — re-anchor this pin').toBeGreaterThan(-1); - const open = source.lastIndexOf('/**', decl); - const close = source.indexOf('*/', open); - expect(open, 'no JSDoc block precedes `NOTIFICATION_EVENT_MIGRATION_ID`').toBeGreaterThan(-1); - expect(close, 'unterminated JSDoc block').toBeLessThan(decl); - return source.slice(open, close + 2); -} - -/** A JSDoc block as flat prose — decorations dropped, wrapped lines rejoined. */ -function flatten(block: string): string { - return block - .replace(/^\/\*\*/, '') - .replace(/\*\/$/, '') - .split('\n') - .map((line) => line.replace(/^\s*\*\s?/, '').trim()) - .join(' ') - .replace(/\s+/g, ' ') - .trim(); -} - -/** - * The `- ` bullets of a JSDoc block, each rejoined with its continuation - * lines. A bullet ends at the next bullet or at a blank comment line. - */ -function bullets(block: string): string[] { - const out: string[] = []; - let current: string[] | null = null; - for (const raw of block.split('\n')) { - const line = raw.replace(/^\s*\*\s?/, '').replace(/^\s*\*\/?\s*$/, '').trim(); - if (line.startsWith('- ')) { - if (current) out.push(current.join(' ')); - current = [line.slice(2)]; - } else if (line === '' || line === '/**' || line === '*/') { - if (current) out.push(current.join(' ')); - current = null; - } else if (current) { - current.push(line); - } - } - if (current) out.push(current.join(' ')); - return out.map((b) => b.replace(/\s+/g, ' ').trim()); -} - -/** The bullet whose subject is `column` — written as a backticked lead. */ -function bulletFor(block: string, column: string): string { - const found = bullets(block).find((b) => b.startsWith('`' + column + '`')); - expect(found, `the matrix has no \`${column}\` bullet`).toBeDefined(); - return found as string; -} - -/** The sentence the registration wrote and the ruling retired. */ -const RETIRED_CLAIM = /silence is not an answer/i; - -/** - * The registration's paragraph, verbatim (#14025 / PR #15450). The self-test - * feeds it to every reader above: it must fail the matrix and trip the - * retired-claim predicate, or a docblock that quietly went back to it would - * pass. - */ -const HISTORICAL_PARAGRAPH = `/** - * WARNING — what a row under this id MEANS is deliberately NOT settled here, - * and its silence is not an answer. The two ids above are written by an - * \`os migrate\` command that scans, self-checks, and only then records, which is - * what gives \`last_run_at\` / \`applied_at\` / \`verified_at\` / \`blocking\` their - * meaning for them. This migration has no such command and no self-check: it - * reports \`migrated\` / \`already_done\` / \`not_applicable\` / \`error\` to its - * caller and nothing else. Which of those columns a run of it may legitimately - * claim, whether anything may gate on the row, and whether a datastore created - * after the cut-over belongs in {@link CREATION_ATTESTED_MIGRATION_IDS}, are - * open contract questions on this surface (#14025) — not facts this constant - * asserts, and not ones to settle by copying the neighbours above. - */`; - -const AT = '2026-09-05T00:00:00.000Z'; - -/** The receipt a run may write, per outcome — never `verified_at`. */ -function runReceipt(outcome: 'migrated' | 'already_done' | 'not_applicable'): DataMigrationFlag { - return { - id: NOTIFICATION_EVENT_MIGRATION_ID, - last_run_at: AT, - verified_at: null, - applied_at: outcome === 'migrated' ? AT : null, - blocking: 0, - details: JSON.stringify({ outcome }), - }; -} - -describe('adr-0030-notification-event: creation-attested membership (#15710 ruling 3)', () => { - it('the id is the well-known string the runner is registered under', () => { - expect(NOTIFICATION_EVENT_MIGRATION_ID).toBe('adr-0030-notification-event'); - }); - - it('CREATION_ATTESTED_MIGRATION_IDS is exactly the three ids, in registration order', () => { - // Literal on purpose — see the header. A `toContain` alone would not - // notice a member swapped for another, and a length would not notice - // which one went. - expect([...CREATION_ATTESTED_MIGRATION_IDS]).toEqual([ - FILE_REFERENCES_MIGRATION_ID, - VALUE_SHAPES_MIGRATION_ID, - NOTIFICATION_EVENT_MIGRATION_ID, - ]); - expect([...CREATION_ATTESTED_MIGRATION_IDS]).toEqual([ - 'adr-0104-file-references', - 'adr-0104-value-shapes', - 'adr-0030-notification-event', - ]); - }); - - it('the member is a TYPE of the array too, not only a value', () => { - // Fails to compile if the tuple type loses the member: the writer's - // `migrationIds` option is typed against `readonly string[]`, so nothing - // downstream would notice a narrowing at the type level either. - const member: (typeof CREATION_ATTESTED_MIGRATION_IDS)[number] = 'adr-0030-notification-event'; - expect(CREATION_ATTESTED_MIGRATION_IDS.includes(member)).toBe(true); - }); -}); - -describe('adr-0030-notification-event: the docblock states the ruled ledger-claim matrix (#15710 ruling 1)', () => { - it('`last_run_at` is claimed on every completed non-`error` run — all three outcomes named', () => { - const bullet = bulletFor(notificationEventDoc(), 'last_run_at'); - expect(bullet).toMatch(/non-`error`/); - expect(bullet).toMatch(/`migrated`/); - expect(bullet).toMatch(/`already_done`/); - expect(bullet).toMatch(/`not_applicable`/); - }); - - it('`applied_at` is claimed only on `migrated`', () => { - const bullet = bulletFor(notificationEventDoc(), 'applied_at'); - expect(bullet).toMatch(/only on `migrated`/); - expect(bullet).not.toMatch(/`already_done`|`not_applicable`/); - }); - - it('`verified_at` is never claimed by a run — the bullet carries the negation and says why', () => { - const bullet = bulletFor(notificationEventDoc(), 'verified_at'); - expect(bullet).toMatch(/\bNEVER\b/); - expect(bullet).toMatch(/self-check/); - }); - - it('`details.outcome` carries the four-valued result, and an `error` run writes no claim', () => { - const doc = notificationEventDoc(); - expect(bulletFor(doc, 'details.outcome')).toMatch(/four-valued/); - const error = bullets(doc).find((b) => /^an `error` run/.test(b)); - expect(error, 'the matrix has no `error` bullet').toBeDefined(); - expect(error).toMatch(/\bNO\b[^.]*claim/); - }); - - it('says "receipt, not gate", cites the receipt precedent, and no longer defers the question', () => { - const prose = flatten(notificationEventDoc()); - expect(prose).toMatch(/Receipt, not gate/); - expect(prose).toMatch(/sys-migration\.object\.ts/); - expect(prose).toMatch(/`verified_at: null`, `blocking: 0`/); - expect(prose).not.toMatch(RETIRED_CLAIM); - }); - - it('points at the array for the creation-attested half', () => { - expect(flatten(notificationEventDoc())).toMatch(/\{@link CREATION_ATTESTED_MIGRATION_IDS\}/); - }); - - it('self-test: the registration-era paragraph fails every reader above', () => { - // No bullets → no matrix; the retired sentence trips the predicate. A - // docblock that went back to deferring cannot pass by saying less. - expect(bullets(HISTORICAL_PARAGRAPH)).toEqual([]); - expect(flatten(HISTORICAL_PARAGRAPH)).toMatch(RETIRED_CLAIM); - }); -}); - -describe('adr-0030-notification-event: the run receipt authorises nothing (#15710 ruling 2)', () => { - it.each(['migrated', 'already_done', 'not_applicable'] as const)( - 'a `%s` receipt reads as not verified to the one arbiter', - (outcome) => { - expect(isDataMigrationFlagVerified(runReceipt(outcome))).toBe(false); - }, - ); - - it('control: the same row with `verified_at` set WOULD authorise — the `false` above is the null, not the shape', () => { - expect(isDataMigrationFlagVerified({ ...runReceipt('migrated'), verified_at: AT })).toBe(true); - }); -}); diff --git a/packages/spec/src/system/notification-event-migration-retirement.test.ts b/packages/spec/src/system/notification-event-migration-retirement.test.ts new file mode 100644 index 0000000000..611d467ec9 --- /dev/null +++ b/packages/spec/src/system/notification-event-migration-retirement.test.ts @@ -0,0 +1,186 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#16194] The ADR-0030 notification cut-over is RETIRED — the spec half. + * + * `migrateSysNotificationToEvent` had no way to be run: zero production + * callers, and no `os migrate` sub-command, while the two sibling members of + * {@link CREATION_ATTESTED_MIGRATION_IDS} had both. Giving it an operator door + * or a boot-time invoker were the two other answers and both were refused; the + * runner, its barrel export, its tests, the ruled `sys_migration` + * receipt-claim matrix, that matrix's pin and this id's membership in the + * attested array were withdrawn together. + * + * ## What is pinned here, and why each half + * + * ⭐ **The survivors are asserted by name, not by counting.** A case that only + * says "the retired id is gone" cannot tell a correct removal from an array + * that lost everything — the two ADR-0104 ids keep their sub-commands, their + * receipt rows and their attestation, and this file is where that is stated. + * So every case below carries the positive half beside the negative one. + * + * ⭐ **The membership is read as a LITERAL, in order.** Every runtime reader + * (`attestFreshDatastore` in `@objectstack/platform-objects`, and its own + * pins) iterates the array, so they follow any change to it by construction: a + * member silently added back would leave all of them green. + * + * ⭐ **The docblock is read for the matrix's ABSENCE.** The retired half was + * prose, and prose is what comes back first — a `last_run_at` / `applied_at` / + * `verified_at` claim matrix re-entering this constant's docblock is a receipt + * contract for a migration that cannot run. The self-test at the bottom feeds + * the pre-retirement paragraph to the same reader, so the absence check cannot + * pass merely because the reader stopped finding anything. + */ + +import { readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { describe, it, expect } from 'vitest'; + +import { + CREATION_ATTESTED_MIGRATION_IDS, + FILE_REFERENCES_MIGRATION_ID, + NOTIFICATION_EVENT_MIGRATION_ID, + VALUE_SHAPES_MIGRATION_ID, +} from './migration.zod'; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const SOURCE = join(HERE, 'migration.zod.ts'); + +/** The JSDoc block attached to the `NOTIFICATION_EVENT_MIGRATION_ID` constant. */ +function notificationEventDoc(): string { + const source = readFileSync(SOURCE, 'utf8'); + const decl = source.indexOf( + "export const NOTIFICATION_EVENT_MIGRATION_ID = 'adr-0030-notification-event'", + ); + expect(decl, 'the `NOTIFICATION_EVENT_MIGRATION_ID` declaration moved — re-anchor this pin').toBeGreaterThan(-1); + const open = source.lastIndexOf('/**', decl); + expect(open, 'no JSDoc block precedes `NOTIFICATION_EVENT_MIGRATION_ID`').toBeGreaterThan(-1); + const close = source.indexOf('*/', open); + expect(close, 'unterminated JSDoc block').toBeLessThan(decl); + return source.slice(open, close + 2); +} + +/** A JSDoc block as flat prose — decorations dropped, wrapped lines rejoined. */ +function flatten(block: string): string { + return block + .replace(/^\/\*\*/, '') + .replace(/\*\/$/, '') + .split('\n') + .map((line) => line.replace(/^\s*\*\s?/, '').trim()) + .join(' ') + .replace(/\s+/g, ' ') + .trim(); +} + +/** + * The `- ` bullets of a JSDoc block, each rejoined with its continuation + * lines. A bullet ends at the next bullet or at a blank comment line. + */ +function bullets(block: string): string[] { + const out: string[] = []; + let current: string[] | null = null; + for (const raw of block.split('\n')) { + const line = raw.replace(/^\s*\*\s?/, '').replace(/^\s*\*\/?\s*$/, '').trim(); + if (line.startsWith('- ')) { + if (current) out.push(current.join(' ')); + current = [line.slice(2)]; + } else if (line === '' || line === '/**' || line === '*/') { + if (current) out.push(current.join(' ')); + current = null; + } else if (current) { + current.push(line); + } + } + if (current) out.push(current.join(' ')); + return out.map((b) => b.replace(/\s+/g, ' ').trim()); +} + +/** The columns the withdrawn receipt-claim matrix stated, as bullet leads. */ +const MATRIX_COLUMNS = ['last_run_at', 'applied_at', 'verified_at', 'details.outcome'] as const; + +/** True when `block` carries a claim matrix — a bullet led by a ledger column. */ +function statesClaimMatrix(block: string): boolean { + const leads = bullets(block); + return MATRIX_COLUMNS.some((column) => leads.some((b) => b.startsWith('`' + column + '`'))); +} + +/** + * The docblock as it stood BEFORE the retirement, abbreviated to the shape the + * reader above judges. The self-test feeds it back in: it must be recognised + * as a claim matrix, or the absence assertion would pass on any prose at all. + */ +const PRE_RETIREMENT_MATRIX = `/** + * Well-known migration id: ADR-0030 notification convergence. + * + * - \`last_run_at\`: set on every COMPLETED non-\`error\` run — \`migrated\`, + * \`already_done\` and \`not_applicable\` alike. + * - \`applied_at\`: set only on \`migrated\` (legacy inbox rows were rewritten). + * - \`verified_at\`: NEVER set by a run of this migration. + * - \`details.outcome\`: the four-valued result, verbatim. + */`; + +describe('[#16194] adr-0030-notification-event: the id left the creation-attested set', () => { + it('CREATION_ATTESTED_MIGRATION_IDS is exactly the TWO surviving ids, in order', () => { + // Literal on purpose. A length alone would not notice which member went, + // and a `not.toContain` alone would pass on an array that lost everything. + expect([...CREATION_ATTESTED_MIGRATION_IDS]).toEqual([ + FILE_REFERENCES_MIGRATION_ID, + VALUE_SHAPES_MIGRATION_ID, + ]); + expect([...CREATION_ATTESTED_MIGRATION_IDS]).toEqual([ + 'adr-0104-file-references', + 'adr-0104-value-shapes', + ]); + expect(CREATION_ATTESTED_MIGRATION_IDS).toHaveLength(2); + }); + + it('the two ADR-0104 siblings SURVIVE — the control that tells removal from collapse', () => { + expect(FILE_REFERENCES_MIGRATION_ID).toBe('adr-0104-file-references'); + expect(VALUE_SHAPES_MIGRATION_ID).toBe('adr-0104-value-shapes'); + expect(CREATION_ATTESTED_MIGRATION_IDS).toContain(FILE_REFERENCES_MIGRATION_ID); + expect(CREATION_ATTESTED_MIGRATION_IDS).toContain(VALUE_SHAPES_MIGRATION_ID); + }); + + it('the retired id is not a member, by value', () => { + expect( + (CREATION_ATTESTED_MIGRATION_IDS as readonly string[]).includes(NOTIFICATION_EVENT_MIGRATION_ID), + ).toBe(false); + }); + + it('the id itself is KEPT — it still names rows already written under it', () => { + // The retirement withdrew the runner and the membership, not the name of + // a `sys_migration` row a deployment already holds. Re-adding a runner is + // what is refused; deleting the constant is a separate question. + expect(NOTIFICATION_EVENT_MIGRATION_ID).toBe('adr-0030-notification-event'); + }); +}); + +describe('[#16194] adr-0030-notification-event: the ruled receipt-claim matrix is withdrawn', () => { + it("the id's docblock no longer states a ledger-claim matrix", () => { + const doc = notificationEventDoc(); + expect( + statesClaimMatrix(doc), + 'a receipt-claim matrix is back on an id no code can write a row for — re-read #16194', + ).toBe(false); + }); + + it('the docblock says the runner is gone and refuses both rejected alternatives', () => { + const prose = flatten(notificationEventDoc()); + expect(prose).toMatch(/RETIRED/); + expect(prose).toMatch(/migrateSysNotificationToEvent/); + // The two answers the ruling refused, named so a later author meets them. + expect(prose).toMatch(/os migrate/); + expect(prose).toMatch(/boot-time invoker/); + // The reversal path, so retirement is not read as a dead end. + expect(prose).toMatch(/files-to-references/); + }); + + it('self-test: the PRE-retirement matrix is recognised by the same reader', () => { + // Without this the absence assertion above would pass on a docblock that + // simply said less — including one that never had bullets at all. + expect(statesClaimMatrix(PRE_RETIREMENT_MATRIX)).toBe(true); + expect(bullets(PRE_RETIREMENT_MATRIX).length).toBeGreaterThan(0); + }); +}); diff --git a/scripts/engine-double-contract.pinned.json b/scripts/engine-double-contract.pinned.json index 2aae71a29b..6a5d119c53 100644 --- a/scripts/engine-double-contract.pinned.json +++ b/scripts/engine-double-contract.pinned.json @@ -1781,36 +1781,6 @@ "verb": "update", "pinned": 1 }, - { - "file": "packages/metadata/src/migrations/migrate-sys-notification-to-event.test.ts", - "verb": "delete", - "pinned": 1 - }, - { - "file": "packages/metadata/src/migrations/migrate-sys-notification-to-event.test.ts", - "verb": "findOne", - "pinned": 1 - }, - { - "file": "packages/metadata/src/migrations/migrate-sys-notification-to-event.test.ts", - "verb": "update", - "pinned": 1 - }, - { - "file": "packages/metadata/src/migrations/real-driver-exec-surface.test.ts", - "verb": "delete", - "pinned": 1 - }, - { - "file": "packages/metadata/src/migrations/real-driver-exec-surface.test.ts", - "verb": "findOne", - "pinned": 1 - }, - { - "file": "packages/metadata/src/migrations/real-driver-exec-surface.test.ts", - "verb": "update", - "pinned": 1 - }, { "file": "packages/objectql/src/action-activation.test.ts", "verb": "update",