|
| 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