mirror of
https://github.com/actualbudget/actual.git
synced 2026-05-16 07:07:54 -05:00
Compare commits
1 Commits
master
...
7710-bundl
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b9dae6ba3d |
@@ -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'),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
25
packages/loot-core/src/server/budgetfiles/classify-error.ts
Normal file
25
packages/loot-core/src/server/budgetfiles/classify-error.ts
Normal 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 };
|
||||
}
|
||||
@@ -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',
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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),
|
||||
|
||||
107
packages/loot-core/src/server/update.test.ts
Normal file
107
packages/loot-core/src/server/update.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user