diff --git a/packages/qa/dogfood/test/audit-key-material.dogfood.test.ts b/packages/qa/dogfood/test/audit-key-material.dogfood.test.ts index 221d0e8f624..31b48b476e1 100644 --- a/packages/qa/dogfood/test/audit-key-material.dogfood.test.ts +++ b/packages/qa/dogfood/test/audit-key-material.dogfood.test.ts @@ -30,7 +30,7 @@ // ⛔ No column value appears in a title, an assertion message or a log line: // every comparison is reported as a count or a boolean. import { spawnSync } from 'node:child_process'; -import { mkdtempSync, readFileSync, rmSync } from 'node:fs'; +import { mkdtempSync, rmSync } from 'node:fs'; import { createRequire } from 'node:module'; import { tmpdir } from 'node:os'; import { dirname, join } from 'node:path'; @@ -38,10 +38,13 @@ import { fileURLToPath } from 'node:url'; import { isDeepStrictEqual } from 'node:util'; import { DatabaseSync } from 'node:sqlite'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { readFixtureAsOf } from './upgrade-harness/fixture-as-of.js'; import { loadSqlIntoSqlite } from './upgrade-harness/sqlite-text-dump.js'; const FIXTURE = new URL('./upgrade-harness/fixture-18.0/', import.meta.url); -const fixtureSql = readFileSync(new URL('database.sql', FIXTURE), 'utf8'); +// Its rotation shards re-dated to the day before this run (`fixture-as-of.ts`): +// the command's boot drops a shard older than the retention window. +const { sql: fixtureSql } = readFixtureAsOf(FIXTURE); const SHOWCASE_DIR = fileURLToPath(new URL('../../../../examples/app-showcase/', import.meta.url)); const CLI_ROOT = dirname(createRequire(import.meta.url).resolve('@objectstack/cli/package.json')); const CLI_BIN = join(CLI_ROOT, 'bin', 'run.js'); diff --git a/packages/qa/dogfood/test/audit-mail-bodies.dogfood.test.ts b/packages/qa/dogfood/test/audit-mail-bodies.dogfood.test.ts index 048092f5b95..6384128dc75 100644 --- a/packages/qa/dogfood/test/audit-mail-bodies.dogfood.test.ts +++ b/packages/qa/dogfood/test/audit-mail-bodies.dogfood.test.ts @@ -32,7 +32,7 @@ // ⛔ No body, link or token value appears in a title, an assertion message or a // log line: every comparison is reported as a count or a boolean. import { spawnSync } from 'node:child_process'; -import { mkdtempSync, readFileSync, rmSync } from 'node:fs'; +import { mkdtempSync, rmSync } from 'node:fs'; import { createRequire } from 'node:module'; import { tmpdir } from 'node:os'; import { dirname, join } from 'node:path'; @@ -41,10 +41,13 @@ import { isDeepStrictEqual } from 'node:util'; import { DatabaseSync } from 'node:sqlite'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { SECRET_MASK } from '@objectstack/spec/data'; +import { readFixtureAsOf } from './upgrade-harness/fixture-as-of.js'; import { loadSqlIntoSqlite } from './upgrade-harness/sqlite-text-dump.js'; const FIXTURE = new URL('./upgrade-harness/fixture-18.0/', import.meta.url); -const fixtureSql = readFileSync(new URL('database.sql', FIXTURE), 'utf8'); +// Its rotation shards re-dated to the day before this run (`fixture-as-of.ts`): +// the command's boot drops a shard older than the retention window. +const { sql: fixtureSql } = readFixtureAsOf(FIXTURE); const SHOWCASE_DIR = fileURLToPath(new URL('../../../../examples/app-showcase/', import.meta.url)); const CLI_ROOT = dirname(createRequire(import.meta.url).resolve('@objectstack/cli/package.json')); const CLI_BIN = join(CLI_ROOT, 'bin', 'run.js'); diff --git a/packages/qa/dogfood/test/identity-mail-read-gate.dogfood.test.ts b/packages/qa/dogfood/test/identity-mail-read-gate.dogfood.test.ts index 62ee90b9943..56881fb3fbb 100644 --- a/packages/qa/dogfood/test/identity-mail-read-gate.dogfood.test.ts +++ b/packages/qa/dogfood/test/identity-mail-read-gate.dogfood.test.ts @@ -51,16 +51,19 @@ // ⛔ No body, link or token value appears in a test title, an assertion // message or a log line: every probe compares a value read as the system // against a response by inclusion, and reports a boolean. -import { mkdtempSync, readFileSync, rmSync } from 'node:fs'; +import { mkdtempSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import type { VerifyStack } from '@objectstack/verify'; import { ceremoniedFixture } from './upgrade-harness/ceremony.js'; +import { readFixtureAsOf } from './upgrade-harness/fixture-as-of.js'; import { FIXTURE_PEOPLE, bootFixture } from './upgrade-harness/populate-fixture.js'; const FIXTURE = new URL('./upgrade-harness/fixture-18.0/', import.meta.url); -const fixtureSql = readFileSync(new URL('database.sql', FIXTURE), 'utf8'); +// Its rotation shards re-dated to the day before this run (`fixture-as-of.ts`): +// a boot drops a shard older than the retention window, its mirror rows with it. +const { sql: fixtureSql } = readFixtureAsOf(FIXTURE); const SYS = { context: { isSystem: true } } as const; const BODY_FIELDS = ['body_text', 'body_html'] as const; diff --git a/packages/qa/dogfood/test/upgrade-18x-harness.dogfood.test.ts b/packages/qa/dogfood/test/upgrade-18x-harness.dogfood.test.ts index 1627c4049f0..ae767e89bde 100644 --- a/packages/qa/dogfood/test/upgrade-18x-harness.dogfood.test.ts +++ b/packages/qa/dogfood/test/upgrade-18x-harness.dogfood.test.ts @@ -44,7 +44,7 @@ // ⛔ Additive change passes: a row HEAD's boot ADDS (an activity shard, a // seeder's row) is not a failure, so counts are compared as "at least", and // every fixture row is looked up by id. -import { copyFileSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'; +import { copyFileSync, mkdtempSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { DatabaseSync } from 'node:sqlite'; @@ -52,15 +52,18 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { isOrganizationOwnershipBootRefusal } from '@objectstack/runtime'; import { ORGANIZATION_OWNERSHIP_SHAPE_GENERATION } from '@objectstack/spec/system'; import type { VerifyStack } from '@objectstack/verify'; -import { columnsOf, type FixtureManifest } from './upgrade-harness/fixture-manifest.js'; +import { readFixtureAsOf } from './upgrade-harness/fixture-as-of.js'; +import { columnsOf } from './upgrade-harness/fixture-manifest.js'; import { FIXTURE_NAMES, FIXTURE_PEOPLE, bootFixture } from './upgrade-harness/populate-fixture.js'; import { loadSqlIntoSqlite } from './upgrade-harness/sqlite-text-dump.js'; import { OWNERSHIP_MARKER, SERVED_ENV, runOwnershipCeremony, runSchemaPlan } from './upgrade-harness/ceremony.js'; const FIXTURE = new URL('./upgrade-harness/fixture-18.0/', import.meta.url); const MARKER = OWNERSHIP_MARKER; -const manifest = JSON.parse(readFileSync(new URL('manifest.json', FIXTURE), 'utf8')) as FixtureManifest; -const fixtureSql = readFileSync(new URL('database.sql', FIXTURE), 'utf8'); +// Its rotation shards re-dated to the day before this run (`fixture-as-of.ts`), +// the manifest's physical names with them: a boot drops a shard older than the +// retention window, and the fixture's date must not decide what this measures. +const { sql: fixtureSql, manifest } = readFixtureAsOf(FIXTURE); /** The guard's refusal down a failed boot's `cause` chain (the kernel wraps a failed `start()`). */ function refusalIn(error: unknown): unknown { @@ -333,8 +336,7 @@ const NOT_A_TENANT_COLUMN = [ ]; const isShard = (table: string): boolean => /__r\d{6,8}$/.test(table); -const gaManifest = JSON.parse(readFileSync(new URL('manifest.json', GA_FIXTURE), 'utf8')) as FixtureManifest; -const gaFixtureSql = readFileSync(new URL('database.sql', GA_FIXTURE), 'utf8'); +const { sql: gaFixtureSql, manifest: gaManifest } = readFixtureAsOf(GA_FIXTURE); describe(`[#24884] an 18.0.0 database (fixture from ${gaManifest.ref} @ ${gaManifest.sha.slice(0, 12)}) boots HEAD with no refusal and no destructive drift`, () => { const dir = mkdtempSync(join(tmpdir(), 'dogfood-upgrade-18-0-0-')); diff --git a/packages/qa/dogfood/test/upgrade-18x-index-equivalence.dogfood.test.ts b/packages/qa/dogfood/test/upgrade-18x-index-equivalence.dogfood.test.ts index 8331eb8db71..b777f7e67c2 100644 --- a/packages/qa/dogfood/test/upgrade-18x-index-equivalence.dogfood.test.ts +++ b/packages/qa/dogfood/test/upgrade-18x-index-equivalence.dogfood.test.ts @@ -22,17 +22,19 @@ // there every 18.0 index survives the ceremony; a SQLite store an earlier 18.x // ceremony certified holds them too. The second block models that store by // restoring the 13 indexes exactly as 18.0 wrote them. -import { copyFileSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'; +import { copyFileSync, mkdtempSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { DatabaseSync } from 'node:sqlite'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import type { VerifyStack } from '@objectstack/verify'; import { ceremoniedFixture } from './upgrade-harness/ceremony.js'; +import { readFixtureAsOf } from './upgrade-harness/fixture-as-of.js'; import { bootFixture } from './upgrade-harness/populate-fixture.js'; import { relaxTenantColumns } from './pre-18-tenant-column.testkit.js'; -const fixtureSql = readFileSync(new URL('./upgrade-harness/fixture-18.0/database.sql', import.meta.url), 'utf8'); +// Its rotation shards re-dated to the day before this run (`fixture-as-of.ts`). +const { sql: fixtureSql } = readFixtureAsOf(new URL('./upgrade-harness/fixture-18.0/', import.meta.url)); /** * The organization uniques a `kernel:ready` migration in `metadata-protocol` diff --git a/packages/qa/dogfood/test/upgrade-harness-fixture-as-of.test.ts b/packages/qa/dogfood/test/upgrade-harness-fixture-as-of.test.ts new file mode 100644 index 00000000000..3666c5202f1 --- /dev/null +++ b/packages/qa/dogfood/test/upgrade-harness-fixture-as-of.test.ts @@ -0,0 +1,140 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// The upgrade fixtures' rotation shards survive a boot on ANY run date, once +// read through `readFixtureAsOf` (`upgrade-harness/fixture-as-of.ts`). +// +// A fixture's `sys_activity` rows live in shards named by the day the +// generator ran, and every boot runs driver-sql's rotation against its clock, +// dropping each shard older than the window. Here the real rotation +// (`SqlDriver#rotateShards`, the entry point the boot and the lifecycle sweep +// call) runs with `sys_activity`'s own declaration over a copy of each fixture +// at a spread of run dates — the fixture's own day, the day its window closed, +// months and a leap year later, and a date before it was generated — and the +// fixture's shard must come out retained with every row. The control: the same +// rotation over the fixture read as it was written drops the shard once the +// run is past its window, so the pin can fail. +import { mkdtempSync, readFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { DatabaseSync } from 'node:sqlite'; +import { afterAll, describe, expect, it } from 'vitest'; +import { SqlDriver } from '@objectstack/driver-sql'; +import { SysActivity } from '../../../plugins/plugin-audit/src/objects/sys-activity.object.js'; +import { readFixtureAsOf, renameShardTables, shardRenamesAsOf } from './upgrade-harness/fixture-as-of.js'; +import { loadSqlIntoSqlite } from './upgrade-harness/sqlite-text-dump.js'; + +const DAY = 86_400_000; +const FIXTURES = [ + { name: '18.0', dir: new URL('./upgrade-harness/fixture-18.0/', import.meta.url) }, + { name: '18.0.0', dir: new URL('./upgrade-harness/fixture-18.0.0/', import.meta.url) }, +] as const; + +/** Run dates, by their offset in days from the fixture's newest shard. */ +const RUN_OFFSETS = [-20, 0, 1, 13, 14, 30, 180, 400] as const; + +const dayKeyOf = (ms: number): string => new Date(ms).toISOString().slice(0, 10).replace(/-/g, ''); +const msOf = (key: string): number => Date.UTC(Number(key.slice(0, 4)), Number(key.slice(4, 6)) - 1, Number(key.slice(6, 8))); + +/** The shard tables of `file` and their row counts. */ +function shardRows(file: string): Record { + const db = new DatabaseSync(file, { readOnly: true }); + try { + const names = (db.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name LIKE 'sys_activity__r%' ORDER BY name").all() as Array<{ name: string }>).map((r) => r.name); + return Object.fromEntries(names.map((n) => [n, Number((db.prepare(`SELECT COUNT(*) AS n FROM "${n}"`).get() as { n: number }).n)])); + } finally { + db.close(); + } +} + +/** `sql` loaded into a new file under `dir`, then the real rotation of `sys_activity` at `nowMs`. */ +async function rotateAt(dir: string, tag: string, sql: string, nowMs: number) { + const file = join(dir, `${tag}.db`); + loadSqlIntoSqlite(sql, file); + const driver = new SqlDriver({ client: 'better-sqlite3', connection: { filename: file }, useNullAsDefault: true }); + await driver.connect(); + try { + return { file, rotation: await driver.rotateShards(SysActivity as never, nowMs) }; + } finally { + await driver.disconnect(); + } +} + +describe.each(FIXTURES)('the $name fixture, read as of the run, keeps its activity shard through a boot on any date', ({ name, dir }) => { + const work = mkdtempSync(join(tmpdir(), `dogfood-fixture-as-of-${name}-`)); + afterAll(() => rmSync(work, { recursive: true, force: true })); + + /** The dump as the generator wrote it, and the shard it names. */ + const written = readFileSync(new URL('database.sql', dir), 'utf8'); + const shards = [...shardRenamesAsOf(written).keys()]; + const fixtureShard = shards[0] ?? ''; + const shardDayMs = msOf(fixtureShard.slice(-8)); + + it('carries one activity shard (a vacuous fixture is no pin)', () => { + expect(shards).toHaveLength(1); + expect(fixtureShard).toMatch(/^sys_activity__r\d{8}$/); + }); + + it.each(RUN_OFFSETS)('a run %i day(s) after the fixture\'s shard: the re-dated shard is retained with every row', async (offset) => { + const nowMs = shardDayMs + offset * DAY + 12 * 3_600_000; + const fixture = readFixtureAsOf(dir, nowMs); + const expected = `sys_activity__r${dayKeyOf(nowMs - DAY)}`; + + // The dump and the manifest name the same re-dated shard, and the old name is gone from the dump. + expect([...fixture.renames.values()]).toEqual([expected]); + if (expected !== fixtureShard) expect(fixture.sql.includes(fixtureShard)).toBe(false); + expect(fixture.manifest.reshaped.find((t) => t.table === 'sys_activity')!.physical).toEqual([expected]); + const rows = fixture.manifest.tables[expected]; + expect(rows, 'the manifest counts the re-dated shard').toBeGreaterThan(0); + + const { file, rotation } = await rotateAt(work, `as-of-${offset}`, fixture.sql, nowMs); + expect(rotation.dropped).toEqual([]); + expect(rotation.shards).toContain(expected); + // HEAD rotates in a shard of its own beside the fixture's, as on a real upgrade. + expect(rotation.current).not.toBe(expected); + expect(shardRows(file)[expected]).toBe(rows); + // The shard's indexes moved with it: every shard index is named after the table it sits on. + const db = new DatabaseSync(file, { readOnly: true }); + try { + const indexes = db.prepare("SELECT name, tbl_name FROM sqlite_master WHERE type = 'index' AND name LIKE 'idx_sys_activity__r%'").all() as Array<{ name: string; tbl_name: string }>; + expect(indexes.filter((i) => i.tbl_name === expected).length).toBeGreaterThan(0); + expect(indexes.filter((i) => !i.name.startsWith(`idx_${i.tbl_name}_`))).toEqual([]); + } finally { + db.close(); + } + }); + + it('control: read as it was written, the shard is dropped once the run is past the window', async () => { + const { rotation } = await rotateAt(work, 'as-written', written, shardDayMs + 14 * DAY); + expect(rotation.dropped).toEqual([fixtureShard]); + }); +}); + +describe('the re-date itself', () => { + const two = [ + 'CREATE TABLE `sys_activity__r20261019` (`id` varchar(255));', + 'CREATE TABLE `sys_activity__r20261020` (`id` varchar(255));', + "INSERT INTO \"sys_activity__r20261019\" (\"id\") VALUES('a');", + "INSERT INTO \"sys_activity__r20261020\" (\"id\") VALUES('b');", + 'CREATE INDEX `idx_sys_activity__r20261019_type` on `sys_activity__r20261019` (`id`);', + ].join('\n'); + + it('keeps each shard\'s distance from the newest, in one pass (a shard renamed onto another\'s old name is not renamed twice)', () => { + const renames = shardRenamesAsOf(two, msOf('20261022')); + expect(Object.fromEntries(renames)).toEqual({ + sys_activity__r20261019: 'sys_activity__r20261020', + sys_activity__r20261020: 'sys_activity__r20261021', + }); + expect(renameShardTables(two, renames).split('\n')).toEqual([ + 'CREATE TABLE `sys_activity__r20261020` (`id` varchar(255));', + 'CREATE TABLE `sys_activity__r20261021` (`id` varchar(255));', + "INSERT INTO \"sys_activity__r20261020\" (\"id\") VALUES('a');", + "INSERT INTO \"sys_activity__r20261021\" (\"id\") VALUES('b');", + 'CREATE INDEX `idx_sys_activity__r20261020_type` on `sys_activity__r20261020` (`id`);', + ]); + }); + + it('refuses a shard of a rotation family it cannot re-date, rather than load it on the wall clock', () => { + expect(() => shardRenamesAsOf('CREATE TABLE `sys_other__r20261019` (`id` varchar(255));')).toThrow(/sys_other__r20261019.*ROTATION_UNITS/); + expect(() => shardRenamesAsOf('CREATE TABLE `sys_activity__r202610` (`id` varchar(255));')).toThrow(/not a day shard key/); + }); +}); diff --git a/packages/qa/dogfood/test/upgrade-harness/fixture-as-of.ts b/packages/qa/dogfood/test/upgrade-harness/fixture-as-of.ts new file mode 100644 index 00000000000..16d603e35de --- /dev/null +++ b/packages/qa/dogfood/test/upgrade-harness/fixture-as-of.ts @@ -0,0 +1,130 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// An upgrade-harness fixture, read as of the run: its rotation shards re-dated +// so the database reads as written the day before the run, whatever the date. +// +// ## Why +// +// A fixture is a dump taken on the day it was generated, and a rotation object +// (`sys_activity`, `lifecycle.storage: { strategy: 'rotation', unit: 'day' }`) +// stores its rows in shard tables named by that day (`sys_activity__r20261019`). +// Every boot of the fixture runs driver-sql's `ensureRotation` against the +// wall clock, and it DROPS each shard older than the retention window. So a +// fixture's activity rows were alive only for the window's length after the +// generator ran, and every test that boots it went red the morning the window +// passed them, with no commit in between. +// +// ## What this does, and why not a pinned clock +// +// Each shard table the dump creates is renamed, in the SQL text before it is +// loaded, to the shard key of the day before the run, every shard keeping its +// distance in days from the newest one: the table, its indexes and its rows' +// INSERTs move together, byte for byte otherwise. The manifest's physical +// names go through the same map, so the census and the database agree. +// +// One day before the run, not the run's own day: the fixture is a database an +// earlier install wrote, so HEAD's boot rotates in a shard of its own beside +// it, as it does for every real upgrade (the 18.0.0 leg's "HEAD's shard NOT +// NULL" reading depends on that), and a run that crosses UTC midnight still +// finds the fixture's shard inside the window. +// +// A pinned clock would have to reach every process that opens the file: the +// test's own boots AND each `os migrate …` binary the suites spawn, which run +// the same rotation on their own clock. Faking `Date` there would move every +// timestamp those processes write (sessions, deliveries, audit rows) along with +// it. The shard name is the only thing the rotation reads, so it is the only +// thing re-dated; the rows' own timestamps are left as the fixture recorded +// them, since no read the harness drives picks a shard by them (the read +// is the `UNION ALL` view over every retained shard). +// +// ⛔ Production retention and `ensureRotation` are untouched: this is the +// fixture's age, not the window. +import { readFileSync } from 'node:fs'; +import type { FixtureManifest } from './fixture-manifest.js'; + +/** + * The rotation families a fixture may carry, by the unit of their shard key. + * Only day keys are re-dated: a week key must land on a Monday and a month key + * is six digits, and no fixture carries either today. A shard of a family not + * listed here is refused, never loaded as it is — loading it as it is would + * reintroduce the wall-clock dependency this module removes. + */ +const ROTATION_UNITS: Readonly> = { sys_activity: 'day' }; + +const DAY_MS = 86_400_000; + +/** A shard table a dump creates: `CREATE TABLE`, quoted with backticks or double quotes. */ +const SHARD_TABLE = /^CREATE TABLE [`"]([A-Za-z0-9_]+?)__r(\d+)[`"]/gm; + +const dayKeyOf = (ms: number): string => new Date(ms).toISOString().slice(0, 10).replace(/-/g, ''); + +function msOfDayKey(key: string): number { + if (!/^\d{8}$/.test(key)) throw new Error(`fixture-as-of: ${key} is not a day shard key`); + const ms = Date.UTC(Number(key.slice(0, 4)), Number(key.slice(4, 6)) - 1, Number(key.slice(6, 8))); + if (dayKeyOf(ms) !== key) throw new Error(`fixture-as-of: ${key} is not a day shard key`); + return ms; +} + +/** + * Old shard table → new shard table for `sql`, re-dated so its newest shard is + * the day before `nowMs`. Empty when the dump carries no shard. Throws on a + * shard of a family `ROTATION_UNITS` does not list. + */ +export function shardRenamesAsOf(sql: string, nowMs: number = Date.now()): Map { + const shards: Array<{ table: string; family: string; ms: number }> = []; + for (const m of sql.matchAll(SHARD_TABLE)) { + const [, family, key] = m as unknown as [string, string, string]; + if (ROTATION_UNITS[family] !== 'day') { + throw new Error( + `fixture-as-of: the fixture creates shard ${family}__r${key} of a rotation family this harness cannot re-date; ` + + 'add the family and its unit to ROTATION_UNITS in test/upgrade-harness/fixture-as-of.ts', + ); + } + shards.push({ table: `${family}__r${key}`, family, ms: msOfDayKey(key) }); + } + const renames = new Map(); + if (shards.length === 0) return renames; + const newest = Math.max(...shards.map((s) => s.ms)); + const shift = msOfDayKey(dayKeyOf(nowMs)) - DAY_MS - newest; + for (const s of shards) renames.set(s.table, `${s.family}__r${dayKeyOf(s.ms + shift)}`); + return renames; +} + +/** + * `text` with every shard table it names renamed: the table itself, and the + * indexes named after it (`idx__`), so they move together. + * One pass over every name at once, so a shard renamed onto another shard's + * old name is never renamed a second time. + */ +export function renameShardTables(text: string, renames: ReadonlyMap): string { + if (renames.size === 0) return text; + const any = new RegExp(`(? renames.get(from)!); +} + +/** A fixture as one run reads it: the dump and the manifest, their shards re-dated together. */ +export interface FixtureAsOf { + sql: string; + manifest: FixtureManifest; + /** Old shard table → the name this run's copy carries; empty when the fixture has no shard. */ + renames: ReadonlyMap; +} + +/** + * The fixture in `dir` (`database.sql` and `manifest.json`), re-dated so it + * reads as written the day before `nowMs`. + */ +export function readFixtureAsOf(dir: URL, nowMs: number = Date.now()): FixtureAsOf { + const sql = readFileSync(new URL('database.sql', dir), 'utf8'); + const manifest = JSON.parse(readFileSync(new URL('manifest.json', dir), 'utf8')) as FixtureManifest; + const renames = shardRenamesAsOf(sql, nowMs); + return { + sql: renameShardTables(sql, renames), + manifest: { + ...manifest, + tables: Object.fromEntries(Object.entries(manifest.tables).map(([t, n]) => [renames.get(t) ?? t, n])), + reshaped: manifest.reshaped.map((t) => ({ ...t, physical: t.physical.map((p) => renames.get(p) ?? p) })), + }, + renames, + }; +} diff --git a/scripts/cross-package-test-inputs.mjs b/scripts/cross-package-test-inputs.mjs index de7677ceb0a..083369a46c9 100644 --- a/scripts/cross-package-test-inputs.mjs +++ b/scripts/cross-package-test-inputs.mjs @@ -1199,6 +1199,10 @@ export const CROSS_PACKAGE_TEST_INPUTS = { 'packages/client/src/**', 'packages/mcp/src/**', 'packages/plugins/plugin-hono-server/src/**', + // test/upgrade-harness-fixture-as-of.test.ts IMPORTS sys_activity's + // declaration from source and runs the real rotation with its policy over + // the upgrade fixtures. Per-file, matching what the import names. + 'packages/plugins/plugin-audit/src/objects/sys-activity.object.ts', 'packages/rest/src/**', 'packages/runtime/src/**', 'packages/services/service-print/src/**', diff --git a/turbo.json b/turbo.json index 87fdc791458..724160b3cd0 100644 --- a/turbo.json +++ b/turbo.json @@ -596,6 +596,7 @@ "!**/node_modules/**", "$TURBO_ROOT$/packages/client/src/**", "$TURBO_ROOT$/packages/mcp/src/**", + "$TURBO_ROOT$/packages/plugins/plugin-audit/src/objects/sys-activity.object.ts", "$TURBO_ROOT$/packages/plugins/plugin-hono-server/src/**", "$TURBO_ROOT$/packages/rest/src/**", "$TURBO_ROOT$/packages/runtime/src/**",