Skip to content

Commit 0518fd7

Browse files
test(dogfood): the upgrade fixtures read as written the day before the run, so a boot's rotation never drops their activity shard (#28867)
<!-- claude-projects-attribution: {"github_login":"marchtian"} --> _Requested by **田騏** · [project thread](https://claude.ai/code/project/chan_01VmuUhXU9SSUzqJYyXSmYSi?thread=cmsg_01VmuUhXU9SSUzqJYyXSmYSi9paTx1Lu8ENYwwssthfK3h)_ Fixes #28849 Clause-②: no Metadata-protocol: none ## 维护者速读 - **改了什么**:升级测试用的 18.0 / 18.0.0 夹具数据库,加载时把活动流(`sys_activity`)的按日分片表重命名为“运行日前一天”,四个测试从此不再随日历过期;只改测试侧,产品的保留期与轮转逻辑一行未动。 - **为什么改**:夹具里的活动分片以生成当天命名(10-19 / 10-20),每次启动都会按真实时钟删掉 14 天前的分片;11-02 起 18.0 夹具的四个 dogfood 用例全红,18.0.0 夹具 11-03 也会跟着红。 - **风险与回滚**:风险低,只涉及 dogfood(私有包,不发布)与 `turbo.json` 的一条测试输入声明;回滚即 revert 本 PR,代价是这批用例重新随日期变红。 - **先看**:`packages/qa/dogfood/test/upgrade-harness/fixture-as-of.ts`(重命名逻辑与为何不钉时钟);`packages/qa/dogfood/test/upgrade-harness-fixture-as-of.test.ts`(用真实轮转在多个日期验证);`packages/qa/dogfood/test/upgrade-18x-harness.dogfood.test.ts`(两处夹具读取改走新入口)。 - **两分钟验证**:`pnpm --filter @objectstack/dogfood exec vitest run test/upgrade-harness-fixture-as-of.test.ts`,预期 22 个用例全绿(含“按原样读取则分片被删”的对照用例)。 - **需要你做的**:无,席位落地。 ## What changes - New `packages/qa/dogfood/test/upgrade-harness/fixture-as-of.ts`: `readFixtureAsOf(dir, nowMs = Date.now())` reads a fixture's `database.sql` and `manifest.json` and re-dates every rotation shard the dump creates (`sys_activity__rYYYYMMDD`) so the newest one is the day before the run, every shard keeping its distance from the newest. The rename happens in the SQL text before loading, in one pass, so the shard table, the indexes named after it and its INSERTs move together; the manifest's `physical` names and `tables` keys go through the same map. A shard of a family it cannot re-date (anything but a day key of a listed family) is refused, never loaded on the wall clock. - Every 18.0 and 18.0.0 fixture load goes through it: `upgrade-18x-harness` (both describes), `audit-key-material`, `audit-mail-bodies`, `identity-mail-read-gate`, `upgrade-18x-index-equivalence`. - New `packages/qa/dogfood/test/upgrade-harness-fixture-as-of.test.ts`: runs the real rotation (`SqlDriver#rotateShards`, with `sys_activity`'s own declaration imported from source) over each fixture at run dates from 20 days before the shard to 400 days after it; the re-dated shard must be retained with every row, beside a fresh current shard. Control: the fixture read as written loses its shard at day 14. - `scripts/cross-package-test-inputs.mjs` and `turbo.json`: declare `packages/plugins/plugin-audit/src/objects/sys-activity.object.ts` as a dogfood test input, since the new test imports it from source (`check:cross-package-test-inputs` asked for it). ## Why `sys_activity` is a rotation object (`lifecycle.storage: { strategy: 'rotation', shards: 14, unit: 'day' }`). Every boot runs driver-sql's `ensureRotation` against `Date.now()` and drops each shard older than the window. The fixture's activity rows live in `sys_activity__r20261019` (18.0) and `sys_activity__r20261020` (18.0.0), so the 18.0 suites went red when the UTC day turned to 2026-11-02, and the 18.0.0 leg would follow on 2026-11-03. Why re-dating the shard and not pinning the clock: the fixture is opened by two kinds of process. One is the test's own boots. The other is each `os migrate organization-ownership`, `os migrate audit-key-material`, `os migrate audit-mail-bodies` and `os migrate plan` binary the suites spawn, and those run the same rotation on their own clock (the `expected 8, got 4` failure is the spawned `--apply` dropping the shard). A pinned clock would have to be injected into every one of those processes and would move every timestamp they write (sessions, deliveries, audit rows) with it. The only product seam for the time, `ensureRotation(..., nowMs)`, is not reachable from a boot without a product change. The shard name is the only thing the rotation reads, so it is the only thing re-dated; row timestamps stay as the fixture recorded them (reads go through the `UNION ALL` view over every retained shard, never by row timestamp). Why the day before the run rather than 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 on a real upgrade. The 18.0.0 leg's "HEAD's shard NOT NULL" and "shard residue" readings depend on that. A run that crosses UTC midnight still finds the fixture's shard inside the window. ## Tests <details><summary>Before (origin/main 3e62a22, run date 2026-11-02): 4 failed, exactly the triage's four</summary> ``` pnpm --filter @objectstack/dogfood exec vitest run --maxWorkers=2 test/upgrade-18x-harness.dogfood.test.ts test/audit-key-material.dogfood.test.ts test/audit-mail-bodies.dogfood.test.ts test/identity-mail-read-gate.dogfood.test.ts test/upgrade-18x-index-equivalence.dogfood.test.ts FAIL test/audit-key-material.dogfood.test.ts > ... > 2. --apply leaves 0 copies carrying one ... AssertionError: expected 4 to be 8 FAIL test/audit-mail-bodies.dogfood.test.ts > ... > 2. --apply leaves 0 clear copies ... AssertionError: expected 2 to be 4 FAIL test/identity-mail-read-gate.dogfood.test.ts > ... > sys_activity: the platform admin reads the 18.0 copy ... AssertionError: expected 0 to be greater than 0 FAIL test/upgrade-18x-harness.dogfood.test.ts > [#24522] ... > 3. every 18.0 row of every reshaped table is where the ceremony put it ... Error: no such table: sys_activity__r20261019 Test Files 4 failed | 1 passed (5) Tests 4 failed | 55 passed (59) ``` The same base with the clock shifted one day ahead in every process (a local `--import` preload, not committed) adds the 18.0.0 leg's two failures, `no such table: sys_activity__r20261020` and the missing shard residue: tomorrow's red. </details> <details><summary>After (head 48a5608): all green today, and at simulated run dates +1, +30 and +180 days</summary> ``` same five files + test/upgrade-harness-fixture-as-of.test.ts Test Files 6 passed (6) Tests 81 passed (81) clock shifted +1 / +30 / +180 days in every process (vitest, workers, each spawned os binary), the five dogfood files: Test Files 5 passed (5) Tests 59 passed (59) (each of the three runs) ``` The assertions measure what they measured before: step 3 now finds every fixture activity row by id in the re-dated shard, and the audit commands count and rewrite the fixture's full 8 and 4 activity copies. </details> ## Acceptance notes - `fixture-18.0.0-next.5` (the CLI prerelease fixture) carries no rotation shard, so its loader is unchanged. - Only day-unit shard keys are re-dated; no fixture carries a week or month family today, and one would be refused loudly at load. - No changeset: every changed file is in the private `@objectstack/dogfood` package or repo-root config (`turbo.json`, `scripts/`), which publish nothing. ## Verification <details><summary>Local gates</summary> - `pnpm --filter @objectstack/dogfood typecheck`: exit 0 (the package's `tsconfig.json` includes `test/**/*`, so the new files are compiled). - `eslint` on every touched `.ts` / `.mjs` file: exit 0. - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`: 69 derived families, 68 exit 0, 1 NOT MEASURED: `pnpm check:dual-build-cjs-loads` exit 3 (PREREQUISITE NOT MET, it reads the whole tree's `dist/` and only the dogfood closure was built; CI runs it). `pnpm check:cross-package-test-inputs` first exited 1 on the new source import, and exits 0 after the declaration in the second commit. - `dispatch-gates --ran`: "69 derived famil(ies) accounted for — 68 run, 1 NOT-MEASURED". </details> --- 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent c594e1c commit 0518fd7

9 files changed

Lines changed: 302 additions & 14 deletions

‎packages/qa/dogfood/test/audit-key-material.dogfood.test.ts‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,18 +30,21 @@
3030
// ⛔ No column value appears in a title, an assertion message or a log line:
3131
// every comparison is reported as a count or a boolean.
3232
import { spawnSync } from 'node:child_process';
33-
import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
33+
import { mkdtempSync, rmSync } from 'node:fs';
3434
import { createRequire } from 'node:module';
3535
import { tmpdir } from 'node:os';
3636
import { dirname, join } from 'node:path';
3737
import { fileURLToPath } from 'node:url';
3838
import { isDeepStrictEqual } from 'node:util';
3939
import { DatabaseSync } from 'node:sqlite';
4040
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
41+
import { readFixtureAsOf } from './upgrade-harness/fixture-as-of.js';
4142
import { loadSqlIntoSqlite } from './upgrade-harness/sqlite-text-dump.js';
4243

4344
const FIXTURE = new URL('./upgrade-harness/fixture-18.0/', import.meta.url);
44-
const fixtureSql = readFileSync(new URL('database.sql', FIXTURE), 'utf8');
45+
// Its rotation shards re-dated to the day before this run (`fixture-as-of.ts`):
46+
// the command's boot drops a shard older than the retention window.
47+
const { sql: fixtureSql } = readFixtureAsOf(FIXTURE);
4548
const SHOWCASE_DIR = fileURLToPath(new URL('../../../../examples/app-showcase/', import.meta.url));
4649
const CLI_ROOT = dirname(createRequire(import.meta.url).resolve('@objectstack/cli/package.json'));
4750
const CLI_BIN = join(CLI_ROOT, 'bin', 'run.js');

‎packages/qa/dogfood/test/audit-mail-bodies.dogfood.test.ts‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@
3232
// ⛔ No body, link or token value appears in a title, an assertion message or a
3333
// log line: every comparison is reported as a count or a boolean.
3434
import { spawnSync } from 'node:child_process';
35-
import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
35+
import { mkdtempSync, rmSync } from 'node:fs';
3636
import { createRequire } from 'node:module';
3737
import { tmpdir } from 'node:os';
3838
import { dirname, join } from 'node:path';
@@ -41,10 +41,13 @@ import { isDeepStrictEqual } from 'node:util';
4141
import { DatabaseSync } from 'node:sqlite';
4242
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
4343
import { SECRET_MASK } from '@objectstack/spec/data';
44+
import { readFixtureAsOf } from './upgrade-harness/fixture-as-of.js';
4445
import { loadSqlIntoSqlite } from './upgrade-harness/sqlite-text-dump.js';
4546

4647
const FIXTURE = new URL('./upgrade-harness/fixture-18.0/', import.meta.url);
47-
const fixtureSql = readFileSync(new URL('database.sql', FIXTURE), 'utf8');
48+
// Its rotation shards re-dated to the day before this run (`fixture-as-of.ts`):
49+
// the command's boot drops a shard older than the retention window.
50+
const { sql: fixtureSql } = readFixtureAsOf(FIXTURE);
4851
const SHOWCASE_DIR = fileURLToPath(new URL('../../../../examples/app-showcase/', import.meta.url));
4952
const CLI_ROOT = dirname(createRequire(import.meta.url).resolve('@objectstack/cli/package.json'));
5053
const CLI_BIN = join(CLI_ROOT, 'bin', 'run.js');

‎packages/qa/dogfood/test/identity-mail-read-gate.dogfood.test.ts‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,16 +51,19 @@
5151
// ⛔ No body, link or token value appears in a test title, an assertion
5252
// message or a log line: every probe compares a value read as the system
5353
// against a response by inclusion, and reports a boolean.
54-
import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
54+
import { mkdtempSync, rmSync } from 'node:fs';
5555
import { tmpdir } from 'node:os';
5656
import { join } from 'node:path';
5757
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
5858
import type { VerifyStack } from '@objectstack/verify';
5959
import { ceremoniedFixture } from './upgrade-harness/ceremony.js';
60+
import { readFixtureAsOf } from './upgrade-harness/fixture-as-of.js';
6061
import { FIXTURE_PEOPLE, bootFixture } from './upgrade-harness/populate-fixture.js';
6162

6263
const FIXTURE = new URL('./upgrade-harness/fixture-18.0/', import.meta.url);
63-
const fixtureSql = readFileSync(new URL('database.sql', FIXTURE), 'utf8');
64+
// Its rotation shards re-dated to the day before this run (`fixture-as-of.ts`):
65+
// a boot drops a shard older than the retention window, its mirror rows with it.
66+
const { sql: fixtureSql } = readFixtureAsOf(FIXTURE);
6467

6568
const SYS = { context: { isSystem: true } } as const;
6669
const BODY_FIELDS = ['body_text', 'body_html'] as const;

‎packages/qa/dogfood/test/upgrade-18x-harness.dogfood.test.ts‎

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -44,23 +44,26 @@
4444
// ⛔ Additive change passes: a row HEAD's boot ADDS (an activity shard, a
4545
// seeder's row) is not a failure, so counts are compared as "at least", and
4646
// every fixture row is looked up by id.
47-
import { copyFileSync, mkdtempSync, readFileSync, rmSync } from 'node:fs';
47+
import { copyFileSync, mkdtempSync, rmSync } from 'node:fs';
4848
import { tmpdir } from 'node:os';
4949
import { join } from 'node:path';
5050
import { DatabaseSync } from 'node:sqlite';
5151
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
5252
import { isOrganizationOwnershipBootRefusal } from '@objectstack/runtime';
5353
import { ORGANIZATION_OWNERSHIP_SHAPE_GENERATION } from '@objectstack/spec/system';
5454
import type { VerifyStack } from '@objectstack/verify';
55-
import { columnsOf, type FixtureManifest } from './upgrade-harness/fixture-manifest.js';
55+
import { readFixtureAsOf } from './upgrade-harness/fixture-as-of.js';
56+
import { columnsOf } from './upgrade-harness/fixture-manifest.js';
5657
import { FIXTURE_NAMES, FIXTURE_PEOPLE, bootFixture } from './upgrade-harness/populate-fixture.js';
5758
import { loadSqlIntoSqlite } from './upgrade-harness/sqlite-text-dump.js';
5859
import { OWNERSHIP_MARKER, SERVED_ENV, runOwnershipCeremony, runSchemaPlan } from './upgrade-harness/ceremony.js';
5960

6061
const FIXTURE = new URL('./upgrade-harness/fixture-18.0/', import.meta.url);
6162
const MARKER = OWNERSHIP_MARKER;
62-
const manifest = JSON.parse(readFileSync(new URL('manifest.json', FIXTURE), 'utf8')) as FixtureManifest;
63-
const fixtureSql = readFileSync(new URL('database.sql', FIXTURE), 'utf8');
63+
// Its rotation shards re-dated to the day before this run (`fixture-as-of.ts`),
64+
// the manifest's physical names with them: a boot drops a shard older than the
65+
// retention window, and the fixture's date must not decide what this measures.
66+
const { sql: fixtureSql, manifest } = readFixtureAsOf(FIXTURE);
6467

6568
/** The guard's refusal down a failed boot's `cause` chain (the kernel wraps a failed `start()`). */
6669
function refusalIn(error: unknown): unknown {
@@ -333,8 +336,7 @@ const NOT_A_TENANT_COLUMN = [
333336
];
334337

335338
const isShard = (table: string): boolean => /__r\d{6,8}$/.test(table);
336-
const gaManifest = JSON.parse(readFileSync(new URL('manifest.json', GA_FIXTURE), 'utf8')) as FixtureManifest;
337-
const gaFixtureSql = readFileSync(new URL('database.sql', GA_FIXTURE), 'utf8');
339+
const { sql: gaFixtureSql, manifest: gaManifest } = readFixtureAsOf(GA_FIXTURE);
338340

339341
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`, () => {
340342
const dir = mkdtempSync(join(tmpdir(), 'dogfood-upgrade-18-0-0-'));

‎packages/qa/dogfood/test/upgrade-18x-index-equivalence.dogfood.test.ts‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,17 +22,19 @@
2222
// there every 18.0 index survives the ceremony; a SQLite store an earlier 18.x
2323
// ceremony certified holds them too. The second block models that store by
2424
// restoring the 13 indexes exactly as 18.0 wrote them.
25-
import { copyFileSync, mkdtempSync, readFileSync, rmSync } from 'node:fs';
25+
import { copyFileSync, mkdtempSync, rmSync } from 'node:fs';
2626
import { tmpdir } from 'node:os';
2727
import { join } from 'node:path';
2828
import { DatabaseSync } from 'node:sqlite';
2929
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
3030
import type { VerifyStack } from '@objectstack/verify';
3131
import { ceremoniedFixture } from './upgrade-harness/ceremony.js';
32+
import { readFixtureAsOf } from './upgrade-harness/fixture-as-of.js';
3233
import { bootFixture } from './upgrade-harness/populate-fixture.js';
3334
import { relaxTenantColumns } from './pre-18-tenant-column.testkit.js';
3435

35-
const fixtureSql = readFileSync(new URL('./upgrade-harness/fixture-18.0/database.sql', import.meta.url), 'utf8');
36+
// Its rotation shards re-dated to the day before this run (`fixture-as-of.ts`).
37+
const { sql: fixtureSql } = readFixtureAsOf(new URL('./upgrade-harness/fixture-18.0/', import.meta.url));
3638

3739
/**
3840
* The organization uniques a `kernel:ready` migration in `metadata-protocol`
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// The upgrade fixtures' rotation shards survive a boot on ANY run date, once
4+
// read through `readFixtureAsOf` (`upgrade-harness/fixture-as-of.ts`).
5+
//
6+
// A fixture's `sys_activity` rows live in shards named by the day the
7+
// generator ran, and every boot runs driver-sql's rotation against its clock,
8+
// dropping each shard older than the window. Here the real rotation
9+
// (`SqlDriver#rotateShards`, the entry point the boot and the lifecycle sweep
10+
// call) runs with `sys_activity`'s own declaration over a copy of each fixture
11+
// at a spread of run dates — the fixture's own day, the day its window closed,
12+
// months and a leap year later, and a date before it was generated — and the
13+
// fixture's shard must come out retained with every row. The control: the same
14+
// rotation over the fixture read as it was written drops the shard once the
15+
// run is past its window, so the pin can fail.
16+
import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
17+
import { tmpdir } from 'node:os';
18+
import { join } from 'node:path';
19+
import { DatabaseSync } from 'node:sqlite';
20+
import { afterAll, describe, expect, it } from 'vitest';
21+
import { SqlDriver } from '@objectstack/driver-sql';
22+
import { SysActivity } from '../../../plugins/plugin-audit/src/objects/sys-activity.object.js';
23+
import { readFixtureAsOf, renameShardTables, shardRenamesAsOf } from './upgrade-harness/fixture-as-of.js';
24+
import { loadSqlIntoSqlite } from './upgrade-harness/sqlite-text-dump.js';
25+
26+
const DAY = 86_400_000;
27+
const FIXTURES = [
28+
{ name: '18.0', dir: new URL('./upgrade-harness/fixture-18.0/', import.meta.url) },
29+
{ name: '18.0.0', dir: new URL('./upgrade-harness/fixture-18.0.0/', import.meta.url) },
30+
] as const;
31+
32+
/** Run dates, by their offset in days from the fixture's newest shard. */
33+
const RUN_OFFSETS = [-20, 0, 1, 13, 14, 30, 180, 400] as const;
34+
35+
const dayKeyOf = (ms: number): string => new Date(ms).toISOString().slice(0, 10).replace(/-/g, '');
36+
const msOf = (key: string): number => Date.UTC(Number(key.slice(0, 4)), Number(key.slice(4, 6)) - 1, Number(key.slice(6, 8)));
37+
38+
/** The shard tables of `file` and their row counts. */
39+
function shardRows(file: string): Record<string, number> {
40+
const db = new DatabaseSync(file, { readOnly: true });
41+
try {
42+
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);
43+
return Object.fromEntries(names.map((n) => [n, Number((db.prepare(`SELECT COUNT(*) AS n FROM "${n}"`).get() as { n: number }).n)]));
44+
} finally {
45+
db.close();
46+
}
47+
}
48+
49+
/** `sql` loaded into a new file under `dir`, then the real rotation of `sys_activity` at `nowMs`. */
50+
async function rotateAt(dir: string, tag: string, sql: string, nowMs: number) {
51+
const file = join(dir, `${tag}.db`);
52+
loadSqlIntoSqlite(sql, file);
53+
const driver = new SqlDriver({ client: 'better-sqlite3', connection: { filename: file }, useNullAsDefault: true });
54+
await driver.connect();
55+
try {
56+
return { file, rotation: await driver.rotateShards(SysActivity as never, nowMs) };
57+
} finally {
58+
await driver.disconnect();
59+
}
60+
}
61+
62+
describe.each(FIXTURES)('the $name fixture, read as of the run, keeps its activity shard through a boot on any date', ({ name, dir }) => {
63+
const work = mkdtempSync(join(tmpdir(), `dogfood-fixture-as-of-${name}-`));
64+
afterAll(() => rmSync(work, { recursive: true, force: true }));
65+
66+
/** The dump as the generator wrote it, and the shard it names. */
67+
const written = readFileSync(new URL('database.sql', dir), 'utf8');
68+
const shards = [...shardRenamesAsOf(written).keys()];
69+
const fixtureShard = shards[0] ?? '';
70+
const shardDayMs = msOf(fixtureShard.slice(-8));
71+
72+
it('carries one activity shard (a vacuous fixture is no pin)', () => {
73+
expect(shards).toHaveLength(1);
74+
expect(fixtureShard).toMatch(/^sys_activity__r\d{8}$/);
75+
});
76+
77+
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) => {
78+
const nowMs = shardDayMs + offset * DAY + 12 * 3_600_000;
79+
const fixture = readFixtureAsOf(dir, nowMs);
80+
const expected = `sys_activity__r${dayKeyOf(nowMs - DAY)}`;
81+
82+
// The dump and the manifest name the same re-dated shard, and the old name is gone from the dump.
83+
expect([...fixture.renames.values()]).toEqual([expected]);
84+
if (expected !== fixtureShard) expect(fixture.sql.includes(fixtureShard)).toBe(false);
85+
expect(fixture.manifest.reshaped.find((t) => t.table === 'sys_activity')!.physical).toEqual([expected]);
86+
const rows = fixture.manifest.tables[expected];
87+
expect(rows, 'the manifest counts the re-dated shard').toBeGreaterThan(0);
88+
89+
const { file, rotation } = await rotateAt(work, `as-of-${offset}`, fixture.sql, nowMs);
90+
expect(rotation.dropped).toEqual([]);
91+
expect(rotation.shards).toContain(expected);
92+
// HEAD rotates in a shard of its own beside the fixture's, as on a real upgrade.
93+
expect(rotation.current).not.toBe(expected);
94+
expect(shardRows(file)[expected]).toBe(rows);
95+
// The shard's indexes moved with it: every shard index is named after the table it sits on.
96+
const db = new DatabaseSync(file, { readOnly: true });
97+
try {
98+
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 }>;
99+
expect(indexes.filter((i) => i.tbl_name === expected).length).toBeGreaterThan(0);
100+
expect(indexes.filter((i) => !i.name.startsWith(`idx_${i.tbl_name}_`))).toEqual([]);
101+
} finally {
102+
db.close();
103+
}
104+
});
105+
106+
it('control: read as it was written, the shard is dropped once the run is past the window', async () => {
107+
const { rotation } = await rotateAt(work, 'as-written', written, shardDayMs + 14 * DAY);
108+
expect(rotation.dropped).toEqual([fixtureShard]);
109+
});
110+
});
111+
112+
describe('the re-date itself', () => {
113+
const two = [
114+
'CREATE TABLE `sys_activity__r20261019` (`id` varchar(255));',
115+
'CREATE TABLE `sys_activity__r20261020` (`id` varchar(255));',
116+
"INSERT INTO \"sys_activity__r20261019\" (\"id\") VALUES('a');",
117+
"INSERT INTO \"sys_activity__r20261020\" (\"id\") VALUES('b');",
118+
'CREATE INDEX `idx_sys_activity__r20261019_type` on `sys_activity__r20261019` (`id`);',
119+
].join('\n');
120+
121+
it('keeps each shard\'s distance from the newest, in one pass (a shard renamed onto another\'s old name is not renamed twice)', () => {
122+
const renames = shardRenamesAsOf(two, msOf('20261022'));
123+
expect(Object.fromEntries(renames)).toEqual({
124+
sys_activity__r20261019: 'sys_activity__r20261020',
125+
sys_activity__r20261020: 'sys_activity__r20261021',
126+
});
127+
expect(renameShardTables(two, renames).split('\n')).toEqual([
128+
'CREATE TABLE `sys_activity__r20261020` (`id` varchar(255));',
129+
'CREATE TABLE `sys_activity__r20261021` (`id` varchar(255));',
130+
"INSERT INTO \"sys_activity__r20261020\" (\"id\") VALUES('a');",
131+
"INSERT INTO \"sys_activity__r20261021\" (\"id\") VALUES('b');",
132+
'CREATE INDEX `idx_sys_activity__r20261020_type` on `sys_activity__r20261020` (`id`);',
133+
]);
134+
});
135+
136+
it('refuses a shard of a rotation family it cannot re-date, rather than load it on the wall clock', () => {
137+
expect(() => shardRenamesAsOf('CREATE TABLE `sys_other__r20261019` (`id` varchar(255));')).toThrow(/sys_other__r20261019.*ROTATION_UNITS/);
138+
expect(() => shardRenamesAsOf('CREATE TABLE `sys_activity__r202610` (`id` varchar(255));')).toThrow(/not a day shard key/);
139+
});
140+
});

0 commit comments

Comments
 (0)