mirror of
https://github.com/actualbudget/actual.git
synced 2026-05-18 21:23:35 -05:00
Compare commits
1 Commits
7710-bundl
...
7710-tests
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
81f3999ddd |
@@ -152,14 +152,13 @@ async function stagePluginsService(): Promise<void> {
|
||||
}
|
||||
|
||||
async function stagePublicData(): Promise<void> {
|
||||
// The current loot-core worker inlines everything it reads at init, so
|
||||
// new clients never touch `data/`. `default-db.sqlite` is still staged
|
||||
// here for one release so older clients pinned by a stale service-worker
|
||||
// cache can finish populating their in-memory FS after upgrade.
|
||||
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'),
|
||||
|
||||
@@ -89,7 +89,6 @@
|
||||
"#types/*": "./src/types/*.ts",
|
||||
"#mocks/*": "./src/mocks/*.ts",
|
||||
"#migrations/*": "./migrations/*.js",
|
||||
"#default-db.sqlite": "./default-db.sqlite",
|
||||
"#*": "./src/*.ts"
|
||||
},
|
||||
"exports": {
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
|
||||
import type { Plugin } from 'vite';
|
||||
|
||||
// `import bytes from './file?bytes'` inlines the file as a Uint8Array. Used
|
||||
// to embed assets that backend init must read without hitting the network —
|
||||
// SharedWorker fetches bypass the service-worker cache in some browser/PWA
|
||||
// contexts, so anything fetched at init breaks offline app load.
|
||||
const BYTES_QUERY = '?bytes';
|
||||
|
||||
export function bytesLoader(): Plugin {
|
||||
return {
|
||||
name: 'loot-core-bytes-loader',
|
||||
enforce: 'pre',
|
||||
async resolveId(id, importer) {
|
||||
if (!id.endsWith(BYTES_QUERY)) return null;
|
||||
const base = id.slice(0, -BYTES_QUERY.length);
|
||||
// Delegate so package imports (`#path/...`) resolve via Vite/Rolldown.
|
||||
const resolved = await this.resolve(base, importer, { skipSelf: true });
|
||||
if (!resolved) return null;
|
||||
return resolved.id + BYTES_QUERY;
|
||||
},
|
||||
load(id) {
|
||||
if (!id.endsWith(BYTES_QUERY)) return null;
|
||||
const filePath = id.slice(0, -BYTES_QUERY.length);
|
||||
const base64 = readFileSync(filePath).toString('base64');
|
||||
// Block-scope the base64 + intermediate binary string so V8 can GC
|
||||
// them after the IIFE returns; only the Uint8Array stays resident.
|
||||
return `const bytes = (() => {
|
||||
const bin = atob(${JSON.stringify(base64)});
|
||||
const out = new Uint8Array(bin.length);
|
||||
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
|
||||
return out;
|
||||
})();
|
||||
export default bytes;
|
||||
`;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,10 +1,6 @@
|
||||
import 'fake-indexeddb/auto';
|
||||
import { readFileSync } from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
import { IDBFactory } from 'fake-indexeddb';
|
||||
|
||||
import defaultDbBytes from '#default-db.sqlite?bytes';
|
||||
import { patchFetchForSqlJS } from '#mocks/util';
|
||||
import * as idb from '#platform/server/indexeddb';
|
||||
import * as sqlite from '#platform/server/sqlite';
|
||||
@@ -119,18 +115,3 @@ describe('join', () => {
|
||||
expect(join('/foo', '../bar')).toBe('/bar');
|
||||
});
|
||||
});
|
||||
|
||||
describe('bundled default-db.sqlite', () => {
|
||||
const onDiskPath = path.resolve(__dirname, '../../../../default-db.sqlite');
|
||||
|
||||
test('matches the on-disk file byte-for-byte', () => {
|
||||
expect(defaultDbBytes).toBeInstanceOf(Uint8Array);
|
||||
// SQLite file header `SQLite format 3\0` — readable failure if a future
|
||||
// loader change accidentally decodes the asset as text.
|
||||
expect(Array.from(defaultDbBytes.slice(0, 16))).toEqual([
|
||||
0x53, 0x51, 0x4c, 0x69, 0x74, 0x65, 0x20, 0x66, 0x6f, 0x72, 0x6d, 0x61,
|
||||
0x74, 0x20, 0x33, 0x00,
|
||||
]);
|
||||
expect(defaultDbBytes).toEqual(new Uint8Array(readFileSync(onDiskPath)));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,10 +2,6 @@
|
||||
import { SQLiteFS } from 'absurd-sql';
|
||||
import IndexedDBBackend from 'absurd-sql/dist/indexeddb-backend';
|
||||
|
||||
// Inlined so backend init does not require network: SharedWorker fetches
|
||||
// bypass the service-worker cache in some browser/PWA contexts, so any
|
||||
// init-path fetch breaks offline app load.
|
||||
import defaultDbBytes from '#default-db.sqlite?bytes';
|
||||
import * as connection from '#platform/server/connection';
|
||||
import { join } from '#platform/server/fs/path-join';
|
||||
import * as idb from '#platform/server/indexeddb';
|
||||
@@ -239,8 +235,29 @@ async function _removeFile(filepath: string) {
|
||||
FS.unlink(filepath);
|
||||
}
|
||||
|
||||
// Load files from the server that should exist by default
|
||||
async function populateDefaultFilesystem() {
|
||||
await _writeFile(bundledDatabasePath, defaultDbBytes);
|
||||
const index = await (
|
||||
await fetch(process.env.PUBLIC_URL + 'data-file-index.txt')
|
||||
).text();
|
||||
const files = index
|
||||
.split('\n')
|
||||
.map(name => name.trim())
|
||||
.filter(name => name !== '');
|
||||
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(
|
||||
files.map(async file => {
|
||||
const contents = await fetchFile(process.env.PUBLIC_URL + 'data/' + file);
|
||||
await _writeFile('/' + file, contents);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const populateFileHierarchy = async function () {
|
||||
|
||||
@@ -43,7 +43,6 @@ import {
|
||||
startBackupService,
|
||||
stopBackupService,
|
||||
} from './backups';
|
||||
import { classifyUpdateVersionError } from './classify-error';
|
||||
|
||||
const DEMO_BUDGET_ID = '_demo-budget';
|
||||
const TEST_BUDGET_ID = '_test-budget';
|
||||
@@ -552,17 +551,20 @@ async function _loadBudget(id: Budget['id']): Promise<{
|
||||
await updateVersion();
|
||||
} catch (e) {
|
||||
logger.warn('Error updating', e);
|
||||
const { error, report } = classifyUpdateVersionError(e.message);
|
||||
if (report) {
|
||||
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 {
|
||||
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 { error };
|
||||
return result;
|
||||
}
|
||||
|
||||
await db.loadClock();
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -1,25 +0,0 @@
|
||||
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 };
|
||||
}
|
||||
@@ -9,7 +9,6 @@ import * as db from '#server/db';
|
||||
import {
|
||||
applyMigration,
|
||||
getAppliedMigrations,
|
||||
getMigrationId,
|
||||
getMigrationList,
|
||||
getMigrationsDir,
|
||||
getPending,
|
||||
@@ -36,11 +35,11 @@ describe('Migrations', () => {
|
||||
expect(getPending(applied, available)).toMatchSnapshot();
|
||||
});
|
||||
|
||||
test('bundled list is sorted by id and includes the latest sql migration', async () => {
|
||||
test('default migrations 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);
|
||||
const ids = available.map(name => parseInt(name));
|
||||
expect(ids).toEqual([...ids].sort((a, b) => a - b));
|
||||
});
|
||||
|
||||
@@ -101,16 +100,6 @@ 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'];
|
||||
@@ -169,14 +158,6 @@ describe('getMigrationList', () => {
|
||||
|
||||
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', () => {
|
||||
@@ -190,7 +171,10 @@ describe('withMigrationsDir', () => {
|
||||
expect(getMigrationsDir()).toBe(before);
|
||||
});
|
||||
|
||||
test('restores the previous dir when the callback throws', async () => {
|
||||
// TDD placeholder for a follow-up that wraps `withMigrationsDir` in
|
||||
// try/finally. Today a throwing callback leaks MIGRATIONS_DIR into the
|
||||
// rest of the suite — enable this test alongside the fix.
|
||||
test.skip('restores the previous dir when the callback throws', async () => {
|
||||
const before = getMigrationsDir();
|
||||
|
||||
await expect(
|
||||
@@ -204,7 +188,7 @@ describe('withMigrationsDir', () => {
|
||||
});
|
||||
|
||||
describe('applyMigration', () => {
|
||||
test('SQL path against bundled dir applies the SQL and records the id', async () => {
|
||||
test('SQL path applies the SQL and records the id', async () => {
|
||||
expect(await getAppliedMigrations(db.getDatabase())).toEqual([]);
|
||||
|
||||
// 1548957970627_remove-db-version.sql drops the db_version table.
|
||||
@@ -224,43 +208,6 @@ describe('applyMigration', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
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;');
|
||||
@@ -273,8 +220,8 @@ describe('applyMigration', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('migrate (end-to-end against bundled dir)', () => {
|
||||
test('applies every bundled migration on a fresh init.sql DB', async () => {
|
||||
describe('migrate (end-to-end)', () => {
|
||||
test('applies every migration on a fresh init.sql DB', async () => {
|
||||
const pending = await migrate(db.getDatabase());
|
||||
|
||||
expect(pending.length).toBeGreaterThan(0);
|
||||
@@ -282,7 +229,7 @@ describe('migrate (end-to-end against bundled dir)', () => {
|
||||
const applied = await getAppliedMigrations(db.getDatabase());
|
||||
expect(applied.length).toBe(pending.length);
|
||||
|
||||
// Both bundled JS and SQL migrations executed.
|
||||
// Both JS and SQL migrations executed.
|
||||
expect(applied).toContain(1632571489012); // JS
|
||||
expect(applied).toContain(1769000000000); // SQL: custom_upcoming_length
|
||||
|
||||
|
||||
@@ -14,19 +14,6 @@ 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 = {
|
||||
@@ -37,36 +24,21 @@ 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;
|
||||
try {
|
||||
await func();
|
||||
} finally {
|
||||
MIGRATIONS_DIR = oldDir;
|
||||
}
|
||||
await func();
|
||||
MIGRATIONS_DIR = oldDir;
|
||||
}
|
||||
|
||||
export function getMigrationsDir(): string {
|
||||
return MIGRATIONS_DIR;
|
||||
}
|
||||
|
||||
export function getMigrationId(name: string): number {
|
||||
function getMigrationId(name: string): number {
|
||||
return parseInt(name.match(/^(\d)+/)[0]);
|
||||
}
|
||||
|
||||
@@ -105,9 +77,7 @@ export async function getAppliedMigrations(db: Database): Promise<number[]> {
|
||||
export async function getMigrationList(
|
||||
migrationsDir: string,
|
||||
): Promise<string[]> {
|
||||
const files = isDefaultMigrationsDir(migrationsDir)
|
||||
? bundledMigrationNames
|
||||
: await fs.listDir(migrationsDir);
|
||||
const files = await fs.listDir(migrationsDir);
|
||||
return files
|
||||
.filter(name => name.match(/(\.sql|\.js)$/))
|
||||
.sort((m1, m2) => {
|
||||
@@ -162,13 +132,11 @@ 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 {
|
||||
const sql = isDefaultMigrationsDir(migrationsDir)
|
||||
? bundledSqlByName[name]
|
||||
: await fs.readFile(fs.join(migrationsDir, name));
|
||||
await applySql(db, sql);
|
||||
await applySql(db, code);
|
||||
}
|
||||
sqlite.runQuery(db, 'INSERT INTO __migrations__ (id) VALUES (?)', [
|
||||
getMigrationId(name),
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// @ts-strict-ignore
|
||||
import * as db from '#server/db';
|
||||
|
||||
import { updateVersion } from './update';
|
||||
@@ -29,7 +30,8 @@ describe('updateVersion (happy path via emptyDatabase)', () => {
|
||||
const row = await db.first<{ value: string }>(
|
||||
"SELECT value FROM __meta__ WHERE key = 'view-hash'",
|
||||
);
|
||||
expect(row?.value).toMatch(/^[0-9a-f]{32}$/);
|
||||
expect(row).not.toBe(null);
|
||||
expect(row.value).toMatch(/^[0-9a-f]{32}$/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -44,7 +46,7 @@ describe('updateViews (re-run behavior)', () => {
|
||||
const after = await db.first<{ value: string }>(
|
||||
"SELECT value FROM __meta__ WHERE key = 'view-hash'",
|
||||
);
|
||||
expect(after?.value).toBe(before?.value);
|
||||
expect(after.value).toBe(before.value);
|
||||
});
|
||||
|
||||
test('recreates views when the stored hash differs', async () => {
|
||||
@@ -56,8 +58,8 @@ describe('updateViews (re-run behavior)', () => {
|
||||
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}$/);
|
||||
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 }>(
|
||||
@@ -67,39 +69,39 @@ describe('updateViews (re-run behavior)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// TDD placeholders for issue #7710 — a follow-up adds `probeViews()` to
|
||||
// updateViews so that a migration/schema desync surfaces as a recoverable
|
||||
// `schema-out-of-sync` error at startup instead of a cryptic "no such
|
||||
// column" runtime error. Enable these alongside the probe implementation.
|
||||
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.
|
||||
test.skip('throws schema-out-of-sync with the failing view name when an underlying table is missing', async () => {
|
||||
// Dropping `schedules` makes `v_schedules` unresolvable when probed, even
|
||||
// though CREATE VIEW itself succeeds — sqlite resolves view columns
|
||||
// lazily at prepare-time.
|
||||
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 () => {
|
||||
test.skip('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;
|
||||
let caught: Error | null = null;
|
||||
try {
|
||||
await updateVersion();
|
||||
} catch (e) {
|
||||
thrown = e;
|
||||
caught = e as Error;
|
||||
}
|
||||
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');
|
||||
expect(caught).not.toBe(null);
|
||||
expect(caught.message).toContain('schema-out-of-sync');
|
||||
expect(caught.message).toContain('v_schedules');
|
||||
expect(caught.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.
|
||||
// Force re-run of view creation on a healthy DB.
|
||||
await db.run("UPDATE __meta__ SET value = 'stale' WHERE key = 'view-hash'");
|
||||
|
||||
await expect(updateVersion()).resolves.toBeUndefined();
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
// @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';
|
||||
@@ -13,29 +11,6 @@ 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 }>(
|
||||
@@ -53,7 +28,6 @@ async function updateViews() {
|
||||
hashKey,
|
||||
currentHash,
|
||||
]);
|
||||
probeViews();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
declare module '*?bytes' {
|
||||
const bytes: Uint8Array;
|
||||
export default bytes;
|
||||
}
|
||||
@@ -5,8 +5,6 @@ import { defineConfig } from 'vite';
|
||||
import { nodePolyfills } from 'vite-plugin-node-polyfills';
|
||||
import peggyLoader from 'vite-plugin-peggy-loader';
|
||||
|
||||
import { bytesLoader } from './scripts/bytes-loader.mts';
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig(({ mode }) => {
|
||||
const isDev = mode === 'development';
|
||||
@@ -63,7 +61,6 @@ export default defineConfig(({ mode }) => {
|
||||
'process.env.ACTUAL_DOCUMENT_DIR': JSON.stringify('/documents'),
|
||||
},
|
||||
plugins: [
|
||||
bytesLoader(),
|
||||
peggyLoader(),
|
||||
// https://github.com/davidmyersdev/vite-plugin-node-polyfills/issues/142
|
||||
nodePolyfills({
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import peggyLoader from 'vite-plugin-peggy-loader';
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
import { bytesLoader } from './scripts/bytes-loader.mts';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: 'jsdom',
|
||||
@@ -13,5 +11,5 @@ export default defineConfig({
|
||||
],
|
||||
maxWorkers: 2,
|
||||
},
|
||||
plugins: [bytesLoader(), peggyLoader()],
|
||||
plugins: [peggyLoader()],
|
||||
});
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
---
|
||||
category: Bugfixes
|
||||
authors: [MatissJanis]
|
||||
---
|
||||
|
||||
Bundle `default-db.sqlite` into the loot-core worker chunk so backend init no longer fetches it from the server. Fixes the FatalError users hit when opening the app while the sync server was unreachable (e.g. local-network host on a different Wi-Fi, PWA on iOS).
|
||||
Reference in New Issue
Block a user