Compare commits

...

1 Commits

Author SHA1 Message Date
github-actions[bot]
b9dae6ba3d [AI] Bundle SQL migrations into the worker chunk; probe views at startup (#7710)
Fixes the FatalError users hit after upgrading to 26.5.0 (issue #7710),
where `Error: no such column: _.custom_upcoming_length` repeated on
every page. Same fragility caused #7759 (FatalError when the migration
list couldn't be fetched at all from a different network).

Root cause: the service worker precaches `data-file-index.txt` and the
staged migration `.sql` files separately from the AQL view definitions
(which live inside the kcab worker JS chunk). On upgrade, a stale
precache could serve an old `data-file-index.txt` alongside the new JS
chunk whose views referenced the not-yet-applied column.

Fix:
- Inline all SQL migrations into the same JS chunk as the AQL schema
  via `import.meta.glob({ query: '?raw', eager: true })`, so the two
  cannot desync across a service-worker cache boundary.
- Synthesize JS migration names from `Object.keys(javascriptMigrations)`
  so we don't need a second glob.
- Remove the build-time copy of `migrations/` into `public/data/` and
  drop the dead `mkdir('/migrations')` at runtime; keep a defensive
  filter that ignores any `migrations/…` entries from stale precaches.
- Add `probeViews()` to `updateViews`: after view recreation, run
  `SELECT * FROM <v> LIMIT 0` against each configured view so a future
  schema/migration desync surfaces as a recoverable `schema-out-of-sync`
  error at startup instead of a cryptic per-query crash.
- Funnel the new error through the existing `out-of-sync-migrations`
  recovery dialog via a new `classifyUpdateVersionError` helper —
  guides the user to re-sync from server.

Reliability fixes uncovered while writing tests:
- `withMigrationsDir` wraps in try/finally so a throwing callback no
  longer leaks MIGRATIONS_DIR into the rest of the suite.
- The `migrate` mock in `mocks/setup.ts` preserves the real return
  value (and resets the uuid seed in a `finally`).

Test coverage: 40 tests across migrations.test.ts (27), update.test.ts
(7), and classify-error.test.ts (6), covering the migration runner,
view probe, error funnel, and end-to-end migrate against the bundled
list with column-presence assertions.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 20:19:58 +01:00
10 changed files with 575 additions and 24 deletions

View File

@@ -152,13 +152,12 @@ async function stagePluginsService(): Promise<void> {
}
async function stagePublicData(): Promise<void> {
// Migrations are bundled into the loot-core worker chunk; purge stale
// staged copies from previous builds.
const migrationsDest = path.resolve(publicDataDir, 'migrations');
await mkdir(publicDataDir, { recursive: true });
await rm(migrationsDest, { recursive: true, force: true });
await Promise.all([
cp(path.resolve(lootCoreRoot, 'migrations'), migrationsDest, {
recursive: true,
}),
cp(
path.resolve(lootCoreRoot, 'default-db.sqlite'),
path.resolve(publicDataDir, 'default-db.sqlite'),

View File

@@ -71,8 +71,11 @@ vi.mock('#server/migrate/migrations', async () => {
...realMigrations,
migrate: async db => {
_id = 100_000_000;
await realMigrations.migrate(db);
_id = 1;
try {
return await realMigrations.migrate(db);
} finally {
_id = 1;
}
},
};
});

View File

@@ -243,13 +243,12 @@ async function populateDefaultFilesystem() {
const files = index
.split('\n')
.map(name => name.trim())
.filter(name => name !== '');
.filter(name => name !== '')
// Migrations are bundled into the worker chunk; ignore any stale
// `migrations/…` entries a service-worker precache might still serve.
.filter(name => !name.startsWith('migrations/'));
const fetchFile = url => fetch(url).then(res => res.arrayBuffer());
// This is hardcoded. We know we must create the migrations
// directory, it's not worth complicating the index to support
// creating arbitrary folders.
await mkdir('/migrations');
await mkdir('/demo-budget');
await Promise.all(

View File

@@ -43,6 +43,7 @@ import {
startBackupService,
stopBackupService,
} from './backups';
import { classifyUpdateVersionError } from './classify-error';
const DEMO_BUDGET_ID = '_demo-budget';
const TEST_BUDGET_ID = '_test-budget';
@@ -551,20 +552,17 @@ async function _loadBudget(id: Budget['id']): Promise<{
await updateVersion();
} catch (e) {
logger.warn('Error updating', e);
let result;
if (e.message.includes('out-of-sync-migrations')) {
result = { error: 'out-of-sync-migrations' };
} else if (e.message.includes('out-of-sync-data')) {
result = { error: 'out-of-sync-data' };
} else {
const { error, report } = classifyUpdateVersionError(e.message);
if (report) {
captureException(e);
}
if (error === 'loading-budget') {
logger.info('Error updating budget ' + id, e);
logger.log('Error updating budget', e);
result = { error: 'loading-budget' };
}
await closeBudget();
return result;
return { error };
}
await db.loadClock();

View File

@@ -0,0 +1,62 @@
import { classifyUpdateVersionError } from './classify-error';
describe('classifyUpdateVersionError', () => {
test('out-of-sync-migrations from migrate() maps to the same code, no report', () => {
expect(classifyUpdateVersionError('out-of-sync-migrations')).toEqual({
error: 'out-of-sync-migrations',
report: false,
});
});
test('out-of-sync-data maps to the same code, no report', () => {
expect(classifyUpdateVersionError('out-of-sync-data')).toEqual({
error: 'out-of-sync-data',
report: false,
});
});
test('schema-out-of-sync (from probeViews) funnels to out-of-sync-migrations and reports', () => {
expect(
classifyUpdateVersionError(
'schema-out-of-sync: v_schedules: no such column: custom_upcoming_length',
),
).toEqual({
error: 'out-of-sync-migrations',
report: true,
});
});
test('unknown messages fall back to loading-budget and report', () => {
expect(classifyUpdateVersionError('something broke')).toEqual({
error: 'loading-budget',
report: true,
});
expect(classifyUpdateVersionError('')).toEqual({
error: 'loading-budget',
report: true,
});
});
test('substring matching: longer prefixed messages still classify correctly', () => {
expect(
classifyUpdateVersionError(
'Error: out-of-sync-migrations (id mismatch at index 3)',
).error,
).toBe('out-of-sync-migrations');
expect(
classifyUpdateVersionError(
'Failed: out-of-sync-data — local clock diverged',
).error,
).toBe('out-of-sync-data');
});
test('schema-out-of-sync is routed correctly even though it shares the "out-of-sync" prefix', () => {
// The `out-of-sync-migrations` check looks for the `-migrations` suffix,
// so the schema error doesn't match it and falls through to the schema
// branch as intended.
const result = classifyUpdateVersionError('schema-out-of-sync: v_x: ...');
expect(result.error).toBe('out-of-sync-migrations');
expect(result.report).toBe(true);
});
});

View File

@@ -0,0 +1,25 @@
export type UpdateVersionErrorCode =
| 'out-of-sync-migrations'
| 'out-of-sync-data'
| 'loading-budget';
// Maps an `updateVersion` failure to the user-facing error and a flag for
// whether the error is an unexpected bug worth reporting. `out-of-sync-*`
// errors are expected recovery states (the user is guided to resync from the
// server); `schema-out-of-sync` and any unrecognized message indicate a real
// bug.
export function classifyUpdateVersionError(message: string): {
error: UpdateVersionErrorCode;
report: boolean;
} {
if (message.includes('out-of-sync-migrations')) {
return { error: 'out-of-sync-migrations', report: false };
}
if (message.includes('out-of-sync-data')) {
return { error: 'out-of-sync-data', report: false };
}
if (message.includes('schema-out-of-sync')) {
return { error: 'out-of-sync-migrations', report: true };
}
return { error: 'loading-budget', report: true };
}

View File

@@ -1,16 +1,29 @@
// @ts-strict-ignore
import { mkdtempSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join as pathJoin } from 'node:path';
import * as fs from '#platform/server/fs';
import * as db from '#server/db';
import {
applyMigration,
getAppliedMigrations,
getMigrationId,
getMigrationList,
getMigrationsDir,
getPending,
getUpMigration,
migrate,
withMigrationsDir,
} from './migrations';
beforeEach(global.emptyDatabase(true));
function makeTempMigrationsDir(prefix: string): string {
return mkdtempSync(pathJoin(tmpdir(), prefix));
}
describe('Migrations', () => {
test('gets the latest migrations', async () => {
const applied = await getAppliedMigrations(db.getDatabase());
@@ -23,6 +36,14 @@ describe('Migrations', () => {
expect(getPending(applied, available)).toMatchSnapshot();
});
test('bundled list is sorted by id and includes the latest sql migration', async () => {
const available = await getMigrationList(fs.migrationsPath);
expect(available).toContain('1769000000000_add_custom_upcoming_length.sql');
const ids = available.map(getMigrationId);
expect(ids).toEqual([...ids].sort((a, b) => a - b));
});
test('applied migrations are returned in order', async () => {
return withMigrationsDir(
__dirname + '/../../mocks/migrations',
@@ -79,3 +100,282 @@ describe('Migrations', () => {
);
});
});
describe('getMigrationId', () => {
test('parses the leading numeric run', () => {
expect(getMigrationId('1769000000000_add_custom_upcoming_length.sql')).toBe(
1769000000000,
);
expect(getMigrationId('1632571489012_remove_cache.js')).toBe(1632571489012);
expect(getMigrationId('1.sql')).toBe(1);
});
});
describe('getUpMigration', () => {
test('returns the matching name', () => {
const names = ['1000_a.sql', '2000_b.sql', '3000_c.js'];
expect(getUpMigration(2000, names)).toBe('2000_b.sql');
expect(getUpMigration(3000, names)).toBe('3000_c.js');
});
test('returns undefined when no name matches', () => {
expect(getUpMigration(9999, ['1000_a.sql'])).toBeUndefined();
expect(getUpMigration(1, [])).toBeUndefined();
});
});
describe('getPending', () => {
const all = ['1000_a.sql', '2000_b.sql', '3000_c.sql'];
test('returns names whose ids are not in applied', () => {
expect(getPending([1000], all)).toEqual(['2000_b.sql', '3000_c.sql']);
});
test('returns empty when all are applied', () => {
expect(getPending([1000, 2000, 3000], all)).toEqual([]);
});
test('returns full list when none are applied', () => {
expect(getPending([], all)).toEqual(all);
});
test('ignores applied ids that are not in the available list', () => {
expect(getPending([1000, 9999], all)).toEqual(['2000_b.sql', '3000_c.sql']);
});
});
describe('getMigrationList', () => {
test('sorts numerically, not lexically, for mixed-length ids', async () => {
const dir = makeTempMigrationsDir('mig-sort-');
// Lexical sort would put "100" before "9"; numeric sort puts "9" first.
writeFileSync(pathJoin(dir, '9_short.sql'), '');
writeFileSync(pathJoin(dir, '100_long.sql'), '');
writeFileSync(pathJoin(dir, '1000_longer.sql'), '');
const list = await getMigrationList(dir);
expect(list).toEqual(['9_short.sql', '100_long.sql', '1000_longer.sql']);
});
test('filters out files that are not .sql or .js', async () => {
const dir = makeTempMigrationsDir('mig-filter-');
writeFileSync(pathJoin(dir, '1_keep.sql'), '');
writeFileSync(pathJoin(dir, '2_keep.js'), 'export default function() {}');
writeFileSync(pathJoin(dir, '3_ignore.txt'), '');
writeFileSync(pathJoin(dir, '4_ignore.md'), '');
writeFileSync(pathJoin(dir, '.force-copy-windows'), '');
const list = await getMigrationList(dir);
expect(list).toEqual(['1_keep.sql', '2_keep.js']);
});
test('returns the bundled list for the default migrations dir', async () => {
const list = await getMigrationList(fs.migrationsPath);
// JS migrations are present as synthesized `${id}.js` entries.
expect(list).toContain('1632571489012.js');
// Real SQL migrations keep their on-disk filename.
expect(list).toContain('1548957970627_remove-db-version.sql');
});
});
describe('withMigrationsDir', () => {
test('restores the previous dir after the callback resolves', async () => {
const before = getMigrationsDir();
await withMigrationsDir('/tmp/whatever', async () => {
expect(getMigrationsDir()).toBe('/tmp/whatever');
});
expect(getMigrationsDir()).toBe(before);
});
test('restores the previous dir when the callback throws', async () => {
const before = getMigrationsDir();
await expect(
withMigrationsDir('/tmp/whatever', async () => {
throw new Error('boom');
}),
).rejects.toThrow('boom');
expect(getMigrationsDir()).toBe(before);
});
});
describe('applyMigration', () => {
test('SQL path against bundled dir applies the SQL and records the id', async () => {
expect(await getAppliedMigrations(db.getDatabase())).toEqual([]);
// 1548957970627_remove-db-version.sql drops the db_version table.
await applyMigration(
db.getDatabase(),
'1548957970627_remove-db-version.sql',
fs.migrationsPath,
);
const tbl = await db.first<{ name: string }>(
"SELECT name FROM sqlite_master WHERE name = 'db_version'",
);
expect(tbl).toBe(null);
expect(await getAppliedMigrations(db.getDatabase())).toEqual([
1548957970627,
]);
});
test('JS path against bundled dir runs the registered handler', async () => {
// 1632571489012_remove_cache.js drops spreadsheet_cells and creates
// zero_budget_months (plus other budget tables). Bundled names use the
// synthesized `${id}.js` form.
await applyMigration(
db.getDatabase(),
'1632571489012.js',
fs.migrationsPath,
);
const newTable = await db.first<{ name: string }>(
"SELECT name FROM sqlite_master WHERE name = 'zero_budget_months'",
);
expect(newTable).toBeDefined();
expect(newTable.name).toBe('zero_budget_months');
const droppedTable = await db.first<{ name: string }>(
"SELECT name FROM sqlite_master WHERE name = 'spreadsheet_cells'",
);
expect(droppedTable).toBe(null);
expect(await getAppliedMigrations(db.getDatabase())).toEqual([
1632571489012,
]);
});
test('JS path throws when the migration id is not registered', async () => {
await expect(
applyMigration(db.getDatabase(), '9999999999999_unknown.js', '/ignored'),
).rejects.toThrow(
'Could not find JS migration code to run for 9999999999999',
);
// Nothing recorded in __migrations__.
expect(await getAppliedMigrations(db.getDatabase())).toEqual([]);
});
test('SQL path propagates SQLite errors and skips recording the id', async () => {
const dir = makeTempMigrationsDir('mig-bad-');
writeFileSync(pathJoin(dir, '1_broken.sql'), 'THIS IS NOT VALID SQL;');
await expect(
applyMigration(db.getDatabase(), '1_broken.sql', dir),
).rejects.toThrow();
expect(await getAppliedMigrations(db.getDatabase())).toEqual([]);
});
});
describe('migrate (end-to-end against bundled dir)', () => {
test('applies every bundled migration on a fresh init.sql DB', async () => {
const pending = await migrate(db.getDatabase());
expect(pending.length).toBeGreaterThan(0);
const applied = await getAppliedMigrations(db.getDatabase());
expect(applied.length).toBe(pending.length);
// Both bundled JS and SQL migrations executed.
expect(applied).toContain(1632571489012); // JS
expect(applied).toContain(1769000000000); // SQL: custom_upcoming_length
// The column the JS migration creates exists in the database.
const zb = await db.first<{ name: string }>(
"SELECT name FROM sqlite_master WHERE name = 'zero_budget_months'",
);
expect(zb.name).toBe('zero_budget_months');
// The column the latest SQL migration adds exists.
const cols = await db.all<{ name: string }>(
"PRAGMA table_info('schedules')",
);
expect(cols.map(c => c.name)).toContain('custom_upcoming_length');
});
test('returns an empty pending list on the second invocation (idempotent)', async () => {
await migrate(db.getDatabase());
const second = await migrate(db.getDatabase());
expect(second).toEqual([]);
});
});
describe('patchBadMigrations', () => {
test('replaces the bad-filters id with the new-filters id before validity check', async () => {
return withMigrationsDir(
__dirname + '/../../mocks/migrations',
async () => {
// Inject the bad id. The mock dir doesn't contain either id, so the
// only way patching can avoid an "out-of-sync" throw is by removing
// the bad id (and inserting the new one, which we also assert isn't
// tripping validity below because it's not in available either).
db.runQuery('INSERT INTO __migrations__ (id) VALUES (1685375406832)');
// The new id (1688749527273) gets inserted by patchBadMigrations.
// Neither id is in the mock available list, so validity will still
// complain — assert via the patched table state directly.
await migrate(db.getDatabase()).catch(() => {
// expected: validity throws because 1688749527273 isn't in mocks
});
const ids = await getAppliedMigrations(db.getDatabase());
expect(ids).not.toContain(1685375406832);
expect(ids).toContain(1688749527273);
},
);
});
test('is a no-op when the bad id is not in __migrations__', async () => {
return withMigrationsDir(
__dirname + '/../../mocks/migrations',
async () => {
await migrate(db.getDatabase());
const ids = await getAppliedMigrations(db.getDatabase());
expect(ids).not.toContain(1685375406832);
expect(ids).not.toContain(1688749527273);
},
);
});
});
describe('checkDatabaseValidity (via migrate)', () => {
test('throws when more migrations are recorded than are available', async () => {
return withMigrationsDir(
__dirname + '/../../mocks/migrations',
async () => {
// Mock dir has 3 migrations. Insert 4 unrelated ids → applied.length
// (4) > available.length (3) → length-branch throw.
db.runQuery('INSERT INTO __migrations__ (id) VALUES (1)');
db.runQuery('INSERT INTO __migrations__ (id) VALUES (2)');
db.runQuery('INSERT INTO __migrations__ (id) VALUES (3)');
db.runQuery('INSERT INTO __migrations__ (id) VALUES (4)');
await expect(migrate(db.getDatabase())).rejects.toThrow(
'out-of-sync-migrations',
);
},
);
});
test('throws on id mismatch even when counts match', async () => {
return withMigrationsDir(
__dirname + '/../../mocks/migrations',
async () => {
// Same count as available (3) but the ids don't line up.
db.runQuery('INSERT INTO __migrations__ (id) VALUES (1)');
db.runQuery('INSERT INTO __migrations__ (id) VALUES (2)');
db.runQuery('INSERT INTO __migrations__ (id) VALUES (3)');
await expect(migrate(db.getDatabase())).rejects.toThrow(
'out-of-sync-migrations',
);
},
);
});
});

View File

@@ -14,6 +14,19 @@ import { logger } from '#platform/server/log';
import * as sqlite from '#platform/server/sqlite';
import * as prefs from '#server/prefs';
// Inline SQL migrations into the worker chunk so they cannot desync with the
// AQL schema across a service-worker cache boundary. `import.meta.glob` is
// statically analyzed, so the path must be a literal relative to this file.
const bundledSqlByName: Record<string, string> = Object.fromEntries(
Object.entries(
import.meta.glob<string>('../../../migrations/*.sql', {
query: '?raw',
import: 'default',
eager: true,
}),
).map(([p, sql]) => [p.slice(p.lastIndexOf('/') + 1), sql]),
);
let MIGRATIONS_DIR = fs.migrationsPath;
const javascriptMigrations = {
@@ -24,21 +37,36 @@ const javascriptMigrations = {
1765518577215: m1765518577215,
};
// JS migrations are looked up by id, so the on-disk filename is irrelevant
// once bundled — `${id}.js` is enough to round-trip through the sort/filter
// path in `getMigrationList`.
const bundledMigrationNames: string[] = [
...Object.keys(bundledSqlByName),
...Object.keys(javascriptMigrations).map(id => `${id}.js`),
];
function isDefaultMigrationsDir(dir: string): boolean {
return dir === fs.migrationsPath;
}
export async function withMigrationsDir(
dir: string,
func: () => Promise<void>,
): Promise<void> {
const oldDir = MIGRATIONS_DIR;
MIGRATIONS_DIR = dir;
await func();
MIGRATIONS_DIR = oldDir;
try {
await func();
} finally {
MIGRATIONS_DIR = oldDir;
}
}
export function getMigrationsDir(): string {
return MIGRATIONS_DIR;
}
function getMigrationId(name: string): number {
export function getMigrationId(name: string): number {
return parseInt(name.match(/^(\d)+/)[0]);
}
@@ -77,7 +105,9 @@ export async function getAppliedMigrations(db: Database): Promise<number[]> {
export async function getMigrationList(
migrationsDir: string,
): Promise<string[]> {
const files = await fs.listDir(migrationsDir);
const files = isDefaultMigrationsDir(migrationsDir)
? bundledMigrationNames
: await fs.listDir(migrationsDir);
return files
.filter(name => name.match(/(\.sql|\.js)$/))
.sort((m1, m2) => {
@@ -132,11 +162,13 @@ export async function applyMigration(
name: string,
migrationsDir: string,
): Promise<void> {
const code = await fs.readFile(fs.join(migrationsDir, name));
if (name.match(/\.js$/)) {
await applyJavaScript(db, getMigrationId(name));
} else {
await applySql(db, code);
const sql = isDefaultMigrationsDir(migrationsDir)
? bundledSqlByName[name]
: await fs.readFile(fs.join(migrationsDir, name));
await applySql(db, sql);
}
sqlite.runQuery(db, 'INSERT INTO __migrations__ (id) VALUES (?)', [
getMigrationId(name),

View File

@@ -0,0 +1,107 @@
import * as db from '#server/db';
import { updateVersion } from './update';
beforeEach(global.emptyDatabase());
const VIEWS = [
'v_payees',
'v_categories',
'v_schedules',
'v_transactions_internal',
'v_transactions_internal_alive',
'v_transactions',
];
describe('updateVersion (happy path via emptyDatabase)', () => {
test('all configured views are created and queryable after init', async () => {
for (const view of VIEWS) {
// Throws if the view doesn't exist or its definition is invalid.
const row = await db.first<Record<string, unknown>>(
`SELECT * FROM ${view} LIMIT 1`,
);
// Either an empty result or a row — we just need the SELECT to succeed.
expect(row === null || typeof row === 'object').toBe(true);
}
});
test('view hash is stored in __meta__ after init', async () => {
const row = await db.first<{ value: string }>(
"SELECT value FROM __meta__ WHERE key = 'view-hash'",
);
expect(row?.value).toMatch(/^[0-9a-f]{32}$/);
});
});
describe('updateViews (re-run behavior)', () => {
test('is a no-op when the stored hash matches (does not change __meta__ row)', async () => {
const before = await db.first<{ value: string }>(
"SELECT value FROM __meta__ WHERE key = 'view-hash'",
);
await updateVersion();
const after = await db.first<{ value: string }>(
"SELECT value FROM __meta__ WHERE key = 'view-hash'",
);
expect(after?.value).toBe(before?.value);
});
test('recreates views when the stored hash differs', async () => {
// Force a hash mismatch.
await db.run("UPDATE __meta__ SET value = 'stale' WHERE key = 'view-hash'");
await updateVersion();
const after = await db.first<{ value: string }>(
"SELECT value FROM __meta__ WHERE key = 'view-hash'",
);
expect(after?.value).not.toBe('stale');
expect(after?.value).toMatch(/^[0-9a-f]{32}$/);
// Views are still queryable after recreation.
const row = await db.first<{ id: string }>(
'SELECT * FROM v_payees LIMIT 1',
);
expect(row === null || typeof row === 'object').toBe(true);
});
});
describe('probeViews (failure surfaces schema-out-of-sync)', () => {
test('throws schema-out-of-sync with the failing view name when an underlying table is missing', async () => {
// Simulate the #7710 desync: a migration was recorded as applied but the
// table/column it created is gone. Dropping `schedules` makes `v_schedules`
// unresolvable when probed, even though CREATE VIEW itself succeeds.
db.execQuery('DROP TABLE schedules');
await db.run("UPDATE __meta__ SET value = 'stale' WHERE key = 'view-hash'");
await expect(updateVersion()).rejects.toThrow(/schema-out-of-sync/);
});
test('error message includes the view name and the underlying cause', async () => {
db.execQuery('DROP TABLE schedules');
await db.run("UPDATE __meta__ SET value = 'stale' WHERE key = 'view-hash'");
let thrown: unknown;
try {
await updateVersion();
} catch (e) {
thrown = e;
}
if (!(thrown instanceof Error)) {
throw new Error(
`expected updateVersion to throw an Error, got: ${String(thrown)}`,
);
}
expect(thrown.message).toContain('schema-out-of-sync');
expect(thrown.message).toContain('v_schedules');
expect(thrown.message.toLowerCase()).toContain('schedules');
});
test('does not throw when every view resolves cleanly (fresh DB)', async () => {
// Force re-run of view creation + probe on a healthy DB.
await db.run("UPDATE __meta__ SET value = 'stale' WHERE key = 'view-hash'");
await expect(updateVersion()).resolves.toBeUndefined();
});
});

View File

@@ -1,6 +1,8 @@
// @ts-strict-ignore
import { createHash } from 'node:crypto';
import { logger } from '#platform/server/log';
import { makeViews, schema, schemaConfig } from './aql';
import * as db from './db';
import * as migrations from './migrate/migrations';
@@ -11,6 +13,29 @@ async function runMigrations() {
await migrations.migrate(db.getDatabase());
}
// `'fields'` is a non-view entry inside each table's view map, shared with
// `makeViews` — skip it.
function getConfiguredViewNames(): string[] {
return Object.values(schemaConfig.views).flatMap(tableViews =>
Object.keys(tableViews).filter(name => name !== 'fields'),
);
}
// Fail fast when the newly-created views reference columns the migrations
// didn't add, so the user hits the recovery dialog once at startup instead of
// a cryptic error on every UI query.
function probeViews(): void {
for (const viewName of getConfiguredViewNames()) {
try {
db.execQuery(`SELECT * FROM ${viewName} LIMIT 0`);
} catch (e) {
const message = e instanceof Error ? e.message : String(e);
logger.error(`View ${viewName} failed schema probe`, e);
throw new Error(`schema-out-of-sync: ${viewName}: ${message}`);
}
}
}
async function updateViews() {
const hashKey = 'view-hash';
const row = await db.first<{ value: string }>(
@@ -28,6 +53,7 @@ async function updateViews() {
hashKey,
currentHash,
]);
probeViews();
}
}