This commit is contained in:
seb
2026-10-29 03:17:47 +01:00
parent b0521a4ea6
commit 75a83bdc28
21 changed files with 701 additions and 24 deletions

View File

@@ -20,6 +20,7 @@
"init-schema": "node scripts/init-schema.js", "init-schema": "node scripts/init-schema.js",
"purge-db": "node scripts/purge-db.js", "purge-db": "node scripts/purge-db.js",
"reset-db": "node scripts/reset-db.js", "reset-db": "node scripts/reset-db.js",
"downgrade": "node scripts/downgrade.js",
"test": "node --test \"test/*.test.js\"", "test": "node --test \"test/*.test.js\"",
"test:api": "node scripts/test-api.js", "test:api": "node scripts/test-api.js",
"test:benchmark": "node scripts/benchmark.js" "test:benchmark": "node scripts/benchmark.js"

54
scripts/downgrade.js Normal file
View File

@@ -0,0 +1,54 @@
/**
* Downgrades the database schema to a target version.
*
* npm run downgrade -- <targetVersion>
*
* Provider from env: DB_PROVIDER=sqlite (+ SQLITE_PATH) or mssql (MSSQL_*, default).
* Runs src/providers/<provider>/downgrade/<from>_to_<to>.js one step at a time,
* each in its own transaction. Not part of the server — the server only upgrades.
* Note: starting a server whose code knows a higher version upgrades again.
*/
import 'dotenv/config';
const [rawTarget] = process.argv.slice(2);
const target = Number(rawTarget);
if (!rawTarget || !Number.isInteger(target) || target < 1) {
console.error('Usage: npm run downgrade -- <targetVersion>');
process.exit(1);
}
const providerName = process.env.DB_PROVIDER || (process.env.SQLITE_PATH ? 'sqlite' : 'mssql');
let provider;
if (providerName === 'sqlite' || providerName === 'better-sqlite3') {
if (!process.env.SQLITE_PATH) {
console.error('SQLITE_PATH is required for DB_PROVIDER=sqlite');
process.exit(1);
}
const { BetterSqlite3Provider } = await import('../src/providers/sqlite3/index.js');
provider = new BetterSqlite3Provider({ filename: process.env.SQLITE_PATH, fileMustExist: true });
} else if (providerName === 'mssql') {
const { MssqlProvider } = await import('../src/providers/mssql/index.js');
provider = new MssqlProvider({
server: process.env.MSSQL_SERVER,
database: process.env.MSSQL_DATABASE,
user: process.env.MSSQL_USER,
password: process.env.MSSQL_PASSWORD,
options: { encrypt: false, trustServerCertificate: true },
});
} else {
console.error(`Unknown DB_PROVIDER: ${providerName}`);
process.exit(1);
}
try {
const before = await provider.getSchemaVersion();
const steps = await provider.downgradeSchema(target);
console.log(`Schema ${providerName}: ${before} -> ${target} (${steps} step(s))`);
} catch (err) {
console.error(`Downgrade failed: ${err.message}`);
process.exitCode = 1;
} finally {
await provider.close();
}

View File

@@ -149,6 +149,7 @@ import posterminalGetCategoryList from './store/posterminal/getCategoryList.js';
import posterminalGetProductList from './store/posterminal/getProductList.js'; import posterminalGetProductList from './store/posterminal/getProductList.js';
import posterminalSetMaxKnownObituaryId from './store/posterminal/setMaxKnownObituaryId.js'; import posterminalSetMaxKnownObituaryId from './store/posterminal/setMaxKnownObituaryId.js';
import posterminalTouchLastseen from './store/posterminal/touchLastseen.js'; import posterminalTouchLastseen from './store/posterminal/touchLastseen.js';
import posterminalSetClientversion from './store/posterminal/setClientversion.js';
import langCreate from './store/lang/create.js'; import langCreate from './store/lang/create.js';
import langDelete from './store/lang/delete.js'; import langDelete from './store/lang/delete.js';
import langList from './store/lang/list.js'; import langList from './store/lang/list.js';
@@ -535,6 +536,7 @@ export default class LibDashStore {
getProductList: posterminalGetProductList(provider), getProductList: posterminalGetProductList(provider),
setMaxKnownObituaryId: posterminalSetMaxKnownObituaryId(provider), setMaxKnownObituaryId: posterminalSetMaxKnownObituaryId(provider),
touchLastseen: posterminalTouchLastseen(provider), touchLastseen: posterminalTouchLastseen(provider),
setClientversion: posterminalSetClientversion(provider),
}; };
this.account = { this.account = {

View File

@@ -0,0 +1,15 @@
/**
* Schema 2 → 1 (MSSQL): drops posterminal `sort`, `enabled`, `clientversion`.
* @param {{ query: (sql: string, params?: object) => Promise<unknown>, assertVersion: (v: number) => Promise<void>, setVersion: (v: number) => Promise<void> }} ctx
*/
export default async function downgrade({ query, assertVersion, setVersion }) {
await assertVersion(2);
await query(`
ALTER TABLE posterminal DROP CONSTRAINT DF_posterminal_sort, DF_posterminal_enabled;
ALTER TABLE posterminal DROP COLUMN sort, enabled, clientversion;
`);
await setVersion(1);
}

View File

@@ -9,6 +9,7 @@ import { mkdirSync, promises as fsPromises } from 'node:fs';
import sql from 'mssql'; import sql from 'mssql';
import { logSlowQuery } from '../provider.js'; import { logSlowQuery } from '../provider.js';
import { safeWrite, safeReadByHash, safeDeleteByHash } from '../storage.js'; import { safeWrite, safeReadByHash, safeDeleteByHash } from '../storage.js';
import { upgradeSchema, downgradeSchema, readSchemaVersion } from './migrate.js';
function normalizeRows(result) { function normalizeRows(result) {
const recordset = result?.recordset ?? []; const recordset = result?.recordset ?? [];
@@ -306,6 +307,9 @@ export class MssqlProvider {
async initialize() { async initialize() {
const pool = await this.ensurePool(); const pool = await this.ensurePool();
// Versioned upgrades first, so schema.js never meets an outdated table shape.
await upgradeSchema(this);
const { SCHEMA_SCRIPTS } = await import('./schema.js'); const { SCHEMA_SCRIPTS } = await import('./schema.js');
for (const script of SCHEMA_SCRIPTS) { for (const script of SCHEMA_SCRIPTS) {
if (script && script.trim()) { if (script && script.trim()) {
@@ -371,6 +375,24 @@ export class MssqlProvider {
} }
} }
/**
* Current `meta.version`, or `null` if the database has none.
* @returns {Promise<number|null>}
*/
async getSchemaVersion() {
return readSchemaVersion((sql, params) => this.query(sql, params));
}
/**
* Runs downgrade steps down to `target`. CLI only (`npm run downgrade`).
* @param {number} target
* @param {(message: string) => void} [log]
* @returns {Promise<number>} Steps applied.
*/
async downgradeSchema(target, log) {
return downgradeSchema(this, target, log);
}
close() { close() {
return this.pool ? this.pool.close() : Promise.resolve(); return this.pool ? this.pool.close() : Promise.resolve();
} }

View File

@@ -0,0 +1,122 @@
/**
* MSSQL schema versioning (see ../schemaMigrations.js).
*
* Step context: `{ query, getVersion, assertVersion, setVersion }` where
* `query(sql, params)` runs inside the step's transaction (same contract as
* `provider.query`). Note: a column added by `ALTER TABLE` is not visible to
* later statements of the SAME batch — use separate `query()` calls.
*/
import {
BASE_SCHEMA_VERSION,
assertStepResult,
latestSchemaVersion,
makeVersionContext,
migrateSchema,
parseSchemaVersion,
} from '../schemaMigrations.js';
export const UPGRADE_DIR = new URL('./upgrade/', import.meta.url);
export const DOWNGRADE_DIR = new URL('./downgrade/', import.meta.url);
/** Schema version a fresh database is created at. */
export const SCHEMA_VERSION = latestSchemaVersion(UPGRADE_DIR);
/**
* @typedef {(sql: string, params?: object) => Promise<{ rows: any[] }>} QueryFn
*/
/**
* Reads `meta.version`, or `null` when the meta table/row is missing.
* @param {QueryFn} query
* @returns {Promise<number|null>}
*/
export async function readSchemaVersion(query) {
const result = await query(`
IF OBJECT_ID(N'dbo.meta', N'U') IS NOT NULL
SELECT value FROM meta WHERE [key] = N'version'
ELSE
SELECT CAST(NULL AS NVARCHAR(MAX)) AS value WHERE 1 = 0
`);
const row = result.rows[0];
return row ? parseSchemaVersion(row.value) : null;
}
async function writeSchemaVersion(query, version) {
await query(`
UPDATE meta SET value = @version WHERE [key] = N'version';
IF @@ROWCOUNT = 0 INSERT INTO meta ([key], value) VALUES (N'version', @version);
`, { version: String(version) });
}
async function hasAnyTable(query) {
const result = await query(`SELECT TOP 1 1 AS found FROM sys.tables WHERE is_ms_shipped = 0`);
return result.rows.length > 0;
}
/**
* @param {import('./index.js').MssqlProvider} provider
*/
function makeRunStep(provider) {
return async (step, fn) => {
await provider.transaction(async (runner) => {
const query = (sql, params) => runner.query(sql, params);
const ctx = {
query,
...makeVersionContext(step.file, () => readSchemaVersion(query), (v) => writeSchemaVersion(query, v)),
};
await fn(ctx);
assertStepResult(step, await readSchemaVersion(query));
});
};
}
/**
* Upgrades an existing database to `SCHEMA_VERSION`. Must run BEFORE the
* `schema.js` scripts so they never meet an outdated table shape.
* Fresh (empty) databases are skipped — `INIT_META` stamps them.
* A non-empty database without a version row is treated as version 1.
* @param {import('./index.js').MssqlProvider} provider
* @param {(message: string) => void} [log]
*/
export async function upgradeSchema(provider, log) {
const query = (sql, params) => provider.query(sql, params);
if (!(await hasAnyTable(query))) return 0;
let current = await readSchemaVersion(query);
if (current === null) {
await query(`
IF OBJECT_ID(N'dbo.meta', N'U') IS NULL
CREATE TABLE meta ([key] NVARCHAR(255) NOT NULL, value NVARCHAR(MAX) NOT NULL, CONSTRAINT PK_meta PRIMARY KEY ([key]));
`);
await writeSchemaVersion(query, BASE_SCHEMA_VERSION);
current = BASE_SCHEMA_VERSION;
}
return migrateSchema({
direction: 'upgrade',
dirUrl: UPGRADE_DIR,
current,
target: SCHEMA_VERSION,
runStep: makeRunStep(provider),
log,
});
}
/**
* Downgrades the database to `target` (CLI only, never at server start).
* @param {import('./index.js').MssqlProvider} provider
* @param {number} target
* @param {(message: string) => void} [log]
*/
export async function downgradeSchema(provider, target, log) {
const current = await readSchemaVersion((sql, params) => provider.query(sql, params));
if (current === null) throw new Error('Database has no schema version (meta table missing)');
return migrateSchema({
direction: 'downgrade',
dirUrl: DOWNGRADE_DIR,
current,
target,
runStep: makeRunStep(provider),
log,
});
}

View File

@@ -10,10 +10,13 @@ SELECT
root_category_id AS rootCategoryId, root_category_id AS rootCategoryId,
sync_root_articles AS syncRootArticles, sync_root_articles AS syncRootArticles,
lastseen_at AS lastseenAt, lastseen_at AS lastseenAt,
updated_at AS updatedAt updated_at AS updatedAt,
sort,
enabled,
clientversion
FROM posterminal FROM posterminal
WHERE company_id = @companyId WHERE company_id = @companyId
ORDER BY id ORDER BY sort, id
`; `;
export const GET_BY_ID = ` export const GET_BY_ID = `
@@ -29,7 +32,10 @@ SELECT
root_category_id AS rootCategoryId, root_category_id AS rootCategoryId,
sync_root_articles AS syncRootArticles, sync_root_articles AS syncRootArticles,
lastseen_at AS lastseenAt, lastseen_at AS lastseenAt,
updated_at AS updatedAt updated_at AS updatedAt,
sort,
enabled,
clientversion
FROM posterminal FROM posterminal
WHERE id = @id WHERE id = @id
`; `;
@@ -47,7 +53,10 @@ SELECT
root_category_id AS rootCategoryId, root_category_id AS rootCategoryId,
sync_root_articles AS syncRootArticles, sync_root_articles AS syncRootArticles,
lastseen_at AS lastseenAt, lastseen_at AS lastseenAt,
updated_at AS updatedAt updated_at AS updatedAt,
sort,
enabled,
clientversion
FROM posterminal FROM posterminal
WHERE pairingpin = @pairingpin WHERE pairingpin = @pairingpin
`; `;
@@ -59,17 +68,23 @@ WHERE id = @id
`; `;
export const CREATE = ` export const CREATE = `
INSERT INTO posterminal (company_id, name, token, pairingpin, paired_at, clientserial, maxknownobituaryid, root_category_id, sync_root_articles, lastseen_at, updated_at) INSERT INTO posterminal (company_id, name, token, pairingpin, paired_at, clientserial, maxknownobituaryid, root_category_id, sync_root_articles, lastseen_at, sort, enabled, updated_at)
OUTPUT INSERTED.id, INSERTED.name, INSERTED.token, INSERTED.pairingpin, INSERTED.paired_at AS pairedAt, INSERTED.clientserial AS clientserial, INSERTED.maxknownobituaryid AS maxknownobituaryid, INSERTED.root_category_id AS rootCategoryId, INSERTED.sync_root_articles AS syncRootArticles, INSERTED.lastseen_at AS lastseenAt, INSERTED.updated_at AS updatedAt OUTPUT INSERTED.id, INSERTED.name, INSERTED.token, INSERTED.pairingpin, INSERTED.paired_at AS pairedAt, INSERTED.clientserial AS clientserial, INSERTED.maxknownobituaryid AS maxknownobituaryid, INSERTED.root_category_id AS rootCategoryId, INSERTED.sync_root_articles AS syncRootArticles, INSERTED.lastseen_at AS lastseenAt, INSERTED.updated_at AS updatedAt, INSERTED.sort, INSERTED.enabled, INSERTED.clientversion
VALUES (@companyId, @name, @token, @pairingpin, @pairedAt, @clientserial, @maxknownobituaryid, @rootCategoryId, @syncRootArticles, @lastseenAt, SYSUTCDATETIME()) VALUES (@companyId, @name, @token, @pairingpin, @pairedAt, @clientserial, @maxknownobituaryid, @rootCategoryId, @syncRootArticles, @lastseenAt, @sort, @enabled, SYSUTCDATETIME())
`; `;
export const UPDATE = ` export const UPDATE = `
UPDATE posterminal UPDATE posterminal
SET name = @name, token = @token, pairingpin = @pairingpin, paired_at = @pairedAt, clientserial = @clientserial, maxknownobituaryid = @maxknownobituaryid, root_category_id = @rootCategoryId, sync_root_articles = @syncRootArticles, lastseen_at = @lastseenAt, updated_at = SYSUTCDATETIME() SET name = @name, token = @token, pairingpin = @pairingpin, paired_at = @pairedAt, clientserial = @clientserial, maxknownobituaryid = @maxknownobituaryid, root_category_id = @rootCategoryId, sync_root_articles = @syncRootArticles, lastseen_at = @lastseenAt, sort = @sort, enabled = @enabled, updated_at = SYSUTCDATETIME()
WHERE id = @id AND company_id = @companyId WHERE id = @id AND company_id = @companyId
`; `;
export const SET_CLIENTVERSION = `
UPDATE posterminal
SET clientversion = @clientversion, updated_at = SYSUTCDATETIME()
WHERE id = @id
`;
export const TOUCH_LASTSEEN = ` export const TOUCH_LASTSEEN = `
UPDATE posterminal UPDATE posterminal
SET lastseen_at = SYSUTCDATETIME() SET lastseen_at = SYSUTCDATETIME()
@@ -166,7 +181,10 @@ SELECT
root_category_id AS rootCategoryId, root_category_id AS rootCategoryId,
sync_root_articles AS syncRootArticles, sync_root_articles AS syncRootArticles,
lastseen_at AS lastseenAt, lastseen_at AS lastseenAt,
updated_at AS updatedAt updated_at AS updatedAt,
sort,
enabled,
clientversion
FROM posterminal FROM posterminal
WHERE token = @token WHERE token = @token
`; `;

View File

@@ -1,3 +1,5 @@
import { SCHEMA_VERSION } from './migrate.js';
export const INIT_META = ` export const INIT_META = `
IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = 'meta') IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = 'meta')
BEGIN BEGIN
@@ -11,7 +13,7 @@ END
IF NOT EXISTS (SELECT 1 FROM meta WHERE [key] = N'version') IF NOT EXISTS (SELECT 1 FROM meta WHERE [key] = N'version')
BEGIN BEGIN
INSERT INTO meta ([key], value) INSERT INTO meta ([key], value)
VALUES (N'version', N'1'); VALUES (N'version', N'${SCHEMA_VERSION}');
END END
`; `;
@@ -966,6 +968,9 @@ BEGIN
sync_root_articles BIT NOT NULL CONSTRAINT DF_posterminal_sync_root_articles DEFAULT 1, sync_root_articles BIT NOT NULL CONSTRAINT DF_posterminal_sync_root_articles DEFAULT 1,
lastseen_at DATETIME2 NULL, lastseen_at DATETIME2 NULL,
updated_at DATETIME2 NOT NULL CONSTRAINT DF_posterminal_updated_at DEFAULT SYSUTCDATETIME(), updated_at DATETIME2 NOT NULL CONSTRAINT DF_posterminal_updated_at DEFAULT SYSUTCDATETIME(),
sort INT NOT NULL CONSTRAINT DF_posterminal_sort DEFAULT 0,
enabled BIT NOT NULL CONSTRAINT DF_posterminal_enabled DEFAULT 1,
clientversion NVARCHAR(255) NULL,
CONSTRAINT FK_posterminal_company FOREIGN KEY (company_id) REFERENCES company(id) ON DELETE CASCADE, CONSTRAINT FK_posterminal_company FOREIGN KEY (company_id) REFERENCES company(id) ON DELETE CASCADE,
CONSTRAINT FK_posterminal_root_category FOREIGN KEY (root_category_id) REFERENCES category(id) ON DELETE SET NULL CONSTRAINT FK_posterminal_root_category FOREIGN KEY (root_category_id) REFERENCES category(id) ON DELETE SET NULL
); );

View File

@@ -0,0 +1,17 @@
/**
* Schema 1 → 2 (MSSQL): posterminal gets `sort`, `enabled`, `clientversion`.
* @param {{ query: (sql: string, params?: object) => Promise<unknown>, assertVersion: (v: number) => Promise<void>, setVersion: (v: number) => Promise<void> }} ctx
*/
export default async function upgrade({ query, assertVersion, setVersion }) {
await assertVersion(1);
await query(`
ALTER TABLE posterminal ADD
sort INT NOT NULL CONSTRAINT DF_posterminal_sort DEFAULT 0,
enabled BIT NOT NULL CONSTRAINT DF_posterminal_enabled DEFAULT 1,
clientversion NVARCHAR(255) NULL;
`);
await setVersion(2);
}

View File

@@ -0,0 +1,184 @@
/**
* Versioned schema migrations shared by the MSSQL and SQLite providers.
*
* Layout (per provider):
* src/providers/<provider>/upgrade/<from>_to_<to>.js (to = from + 1)
* src/providers/<provider>/downgrade/<from>_to_<to>.js (to = from - 1)
*
* Each step file default-exports `async function (ctx)`. `ctx` always has:
* - `getVersion()` → current `meta.version` (number)
* - `assertVersion(expected)` → throws unless `meta.version === expected`
* - `setVersion(version)` → writes `meta.version`
* plus provider-specific handles (`db`/`exec` for SQLite, `query` for MSSQL).
*
* A step must assert its start version and finish by setting its end version;
* the runner verifies the end version and rolls back on mismatch. Each step
* runs in its own transaction.
*
* Fresh databases are created from `schema.js` (always the latest shape) and
* stamped with `SCHEMA_VERSION` (= highest upgrade target) by `INIT_META`.
* Existing databases are upgraded step by step at provider `initialize()`.
*
* Adding a version N → N+1:
* 1. Add `upgrade/N_to_N+1.js` and `downgrade/N+1_to_N.js` for BOTH providers.
* 2. Update both `schema.js` files so a fresh DB has the new shape.
* `SCHEMA_VERSION` is derived from the upgrade folder — nothing else to bump.
*/
import { readdirSync } from 'node:fs';
/** Version of a database that has never run a versioned upgrade. */
export const BASE_SCHEMA_VERSION = 1;
const STEP_FILE = /^(\d+)_to_(\d+)\.js$/;
/**
* Lists and validates the step files of one direction.
* @param {URL} dirUrl - Directory URL (trailing slash).
* @param {'upgrade'|'downgrade'} direction
* @returns {Array<{ from: number, to: number, file: string, url: URL }>} Sorted by `from` (asc for upgrade, desc for downgrade).
*/
export function listSteps(dirUrl, direction) {
let names;
try {
names = readdirSync(dirUrl);
} catch (err) {
if (err.code === 'ENOENT') return [];
throw err;
}
const delta = direction === 'upgrade' ? 1 : -1;
const steps = [];
const seen = new Set();
for (const file of names) {
if (!file.endsWith('.js')) continue;
const match = STEP_FILE.exec(file);
if (!match) {
throw new Error(`Invalid schema ${direction} file name: ${file} (expected <from>_to_<to>.js)`);
}
const from = Number(match[1]);
const to = Number(match[2]);
if (to !== from + delta) {
throw new Error(`Invalid schema ${direction} step ${file}: must go from N to N${delta > 0 ? '+' : '-'}1`);
}
if (seen.has(from)) {
throw new Error(`Duplicate schema ${direction} step starting at version ${from}`);
}
seen.add(from);
steps.push({ from, to, file, url: new URL(file, dirUrl) });
}
return steps.sort((a, b) => (a.from - b.from) * delta);
}
/**
* Highest schema version reachable through a gap-free upgrade chain starting
* at `BASE_SCHEMA_VERSION`.
* @param {URL} upgradeDirUrl
* @returns {number}
*/
export function latestSchemaVersion(upgradeDirUrl) {
let version = BASE_SCHEMA_VERSION;
for (const step of listSteps(upgradeDirUrl, 'upgrade')) {
if (step.from !== version) {
throw new Error(`Schema upgrade chain broken: expected ${version}_to_${version + 1}.js, found ${step.file}`);
}
version = step.to;
}
return version;
}
/**
* Parses a `meta.version` value.
* @param {unknown} value
* @returns {number}
*/
export function parseSchemaVersion(value) {
const version = Number(value);
if (!Number.isInteger(version) || version < BASE_SCHEMA_VERSION) {
throw new Error(`Invalid schema version in meta table: ${value}`);
}
return version;
}
/**
* Builds the version helpers every step receives.
* @param {string} file - Step file name (for error messages).
* @param {() => Promise<number>|number} getVersion
* @param {(version: number) => Promise<void>|void} setVersion
*/
export function makeVersionContext(file, getVersion, setVersion) {
return {
getVersion: async () => getVersion(),
assertVersion: async (expected) => {
const actual = await getVersion();
if (actual !== expected) {
throw new Error(`Schema step ${file}: expected database version ${expected}, found ${actual}`);
}
},
setVersion: async (version) => {
if (!Number.isInteger(version) || version < BASE_SCHEMA_VERSION) {
throw new Error(`Schema step ${file}: invalid version ${version}`);
}
await setVersion(version);
},
};
}
/**
* Runs the steps that move the database from `current` to `target`.
*
* @param {object} options
* @param {'upgrade'|'downgrade'} options.direction
* @param {URL} options.dirUrl - Step directory for this direction.
* @param {number} options.current - Current database version.
* @param {number} options.target - Target version.
* @param {(step: { from: number, to: number, file: string }, fn: (ctx: object) => Promise<void>) => Promise<void>} options.runStep
* Provider hook: runs `fn(ctx)` in a transaction and verifies the end version.
* @param {(message: string) => void} [options.log]
* @returns {Promise<number>} Number of steps applied.
*/
export async function migrateSchema({ direction, dirUrl, current, target, runStep, log = console.log }) {
if (direction === 'upgrade' && target < current) {
throw new Error(`Database schema version ${current} is newer than this application supports (${target})`);
}
if (direction === 'downgrade' && target > current) {
throw new Error(`Cannot downgrade schema from version ${current} to higher version ${target}`);
}
if (target < BASE_SCHEMA_VERSION) {
throw new Error(`Cannot ${direction} schema below version ${BASE_SCHEMA_VERSION}`);
}
const byFrom = new Map(listSteps(dirUrl, direction).map((step) => [step.from, step]));
const plan = [];
for (let version = current; version !== target; ) {
const step = byFrom.get(version);
if (!step) {
const next = direction === 'upgrade' ? version + 1 : version - 1;
throw new Error(`Missing schema ${direction} step ${version}_to_${next}.js`);
}
plan.push(step);
version = step.to;
}
for (const step of plan) {
const mod = await import(step.url.href);
if (typeof mod.default !== 'function') {
throw new Error(`Schema ${direction} step ${step.file} must default-export a function`);
}
await runStep(step, mod.default);
log(`schema ${direction} ${step.from} -> ${step.to} (${step.file})`);
}
return plan.length;
}
/**
* Throws unless the version a step left behind matches its target.
* @param {{ file: string, to: number }} step
* @param {number} actual
*/
export function assertStepResult(step, actual) {
if (actual !== step.to) {
throw new Error(`Schema step ${step.file} must end with version ${step.to}, found ${actual}`);
}
}

View File

@@ -0,0 +1,16 @@
/**
* Schema 2 → 1 (SQLite): drops posterminal `sort`, `enabled`, `clientversion`.
* @param {{ exec: (sql: string) => void, assertVersion: (v: number) => Promise<void>, setVersion: (v: number) => Promise<void> }} ctx
*/
export default async function downgrade({ exec, assertVersion, setVersion }) {
await assertVersion(2);
exec(`
ALTER TABLE posterminal DROP COLUMN clientversion;
ALTER TABLE posterminal DROP COLUMN enabled;
ALTER TABLE posterminal DROP COLUMN sort;
`);
await setVersion(1);
}

View File

@@ -9,6 +9,7 @@ import Database from 'better-sqlite3';
import { AsyncLocalStorage } from 'node:async_hooks'; import { AsyncLocalStorage } from 'node:async_hooks';
import { logSlowQuery } from '../provider.js'; import { logSlowQuery } from '../provider.js';
import { SCHEMA_SCRIPTS } from './schema.js'; import { SCHEMA_SCRIPTS } from './schema.js';
import { upgradeSchema, downgradeSchema, readSchemaVersion } from './migrate.js';
const asyncTxStorage = new AsyncLocalStorage(); const asyncTxStorage = new AsyncLocalStorage();
@@ -359,6 +360,9 @@ export class BetterSqlite3Provider extends BetterSqlite3Executor {
* @returns {Promise<void>} * @returns {Promise<void>}
*/ */
async initialize() { async initialize() {
// Versioned upgrades first, so schema.js never meets an outdated table shape.
await upgradeSchema(this.db);
for (const script of SCHEMA_SCRIPTS) { for (const script of SCHEMA_SCRIPTS) {
if (script && script.trim()) { if (script && script.trim()) {
this.db.exec(script); this.db.exec(script);
@@ -475,6 +479,24 @@ export class BetterSqlite3Provider extends BetterSqlite3Executor {
} }
/** Explicitly closes the database. */ /** Explicitly closes the database. */
/**
* Current `meta.version`, or `null` if the database has none.
* @returns {Promise<number|null>}
*/
async getSchemaVersion() {
return readSchemaVersion(this.db);
}
/**
* Runs downgrade steps down to `target`. CLI only (`npm run downgrade`).
* @param {number} target
* @param {(message: string) => void} [log]
* @returns {Promise<number>} Steps applied.
*/
async downgradeSchema(target, log) {
return downgradeSchema(this.db, target, log);
}
close() { close() {
this.db.close(); this.db.close();
} }

View File

@@ -0,0 +1,121 @@
/**
* SQLite schema versioning (see ../schemaMigrations.js).
*
* Step context: `{ db, exec, getVersion, assertVersion, setVersion }` where
* `db` is the raw better-sqlite3 Database and `exec(sql)` runs DDL/DML.
* Each step runs in `BEGIN IMMEDIATE … COMMIT` with foreign key enforcement
* switched off (SQLite's recommended procedure for table rebuilds);
* `PRAGMA foreign_key_check` must pass before commit.
*/
import {
BASE_SCHEMA_VERSION,
assertStepResult,
latestSchemaVersion,
makeVersionContext,
migrateSchema,
parseSchemaVersion,
} from '../schemaMigrations.js';
export const UPGRADE_DIR = new URL('./upgrade/', import.meta.url);
export const DOWNGRADE_DIR = new URL('./downgrade/', import.meta.url);
/** Schema version a fresh database is created at. */
export const SCHEMA_VERSION = latestSchemaVersion(UPGRADE_DIR);
function hasTable(db, name) {
return !!db.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?").get(name);
}
function hasAnyTable(db) {
return !!db.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' LIMIT 1").get();
}
/**
* Reads `meta.version`, or `null` when the meta table/row is missing.
* @param {import('better-sqlite3').Database} db
* @returns {number|null}
*/
export function readSchemaVersion(db) {
if (!hasTable(db, 'meta')) return null;
const row = db.prepare(`SELECT value FROM meta WHERE "key" = 'version'`).get();
return row ? parseSchemaVersion(row.value) : null;
}
function writeSchemaVersion(db, version) {
db.prepare(`INSERT INTO meta ("key", value) VALUES ('version', ?)
ON CONFLICT("key") DO UPDATE SET value = excluded.value`).run(String(version));
}
function makeRunStep(db) {
return async (step, fn) => {
const fkWasOn = db.pragma('foreign_keys', { simple: true }) === 1;
if (fkWasOn) db.pragma('foreign_keys = OFF');
db.exec('BEGIN IMMEDIATE');
try {
const ctx = {
db,
exec: (sql) => db.exec(sql),
...makeVersionContext(step.file, () => readSchemaVersion(db), (v) => writeSchemaVersion(db, v)),
};
await fn(ctx);
assertStepResult(step, readSchemaVersion(db));
const violations = db.pragma('foreign_key_check');
if (violations.length > 0) {
throw new Error(`Schema step ${step.file} left ${violations.length} foreign key violation(s)`);
}
db.exec('COMMIT');
} catch (err) {
if (db.inTransaction) db.exec('ROLLBACK');
throw err;
} finally {
if (fkWasOn) db.pragma('foreign_keys = ON');
}
};
}
/**
* Upgrades an existing database to `SCHEMA_VERSION`. Must run BEFORE the
* `schema.js` scripts so they never meet an outdated table shape.
* Fresh (empty) databases are skipped — `INIT_META` stamps them.
* A non-empty database without a version row is treated as version 1.
* @param {import('better-sqlite3').Database} db
* @param {(message: string) => void} [log]
*/
export async function upgradeSchema(db, log) {
if (!hasAnyTable(db)) return 0;
let current = readSchemaVersion(db);
if (current === null) {
db.exec(`CREATE TABLE IF NOT EXISTS meta ("key" TEXT PRIMARY KEY, value TEXT NOT NULL)`);
writeSchemaVersion(db, BASE_SCHEMA_VERSION);
current = BASE_SCHEMA_VERSION;
}
return migrateSchema({
direction: 'upgrade',
dirUrl: UPGRADE_DIR,
current,
target: SCHEMA_VERSION,
runStep: makeRunStep(db),
log,
});
}
/**
* Downgrades the database to `target` (CLI only, never at server start).
* @param {import('better-sqlite3').Database} db
* @param {number} target
* @param {(message: string) => void} [log]
*/
export async function downgradeSchema(db, target, log) {
const current = readSchemaVersion(db);
if (current === null) throw new Error('Database has no schema version (meta table missing)');
return migrateSchema({
direction: 'downgrade',
dirUrl: DOWNGRADE_DIR,
current,
target,
runStep: makeRunStep(db),
log,
});
}

View File

@@ -10,7 +10,7 @@ export const {
COUNT_CUSTOMER_GROUPS, COUNT_COMPOSITE_PRODUCTS, MAX_ORDER_ID, COUNT_CUSTOMER_GROUPS, COUNT_COMPOSITE_PRODUCTS, MAX_ORDER_ID,
LIST_POS_PRODUCT_CATEGORIES, LIST_POS_CUSTOMER_GROUP_IDS, LIST_POS_PRODUCT_PRICES, LIST_POS_PRODUCT_CATEGORIES, LIST_POS_CUSTOMER_GROUP_IDS, LIST_POS_PRODUCT_PRICES,
LIST_POS_MANDATED_OPTIONS, LIST_POS_PARENT_OPTIONS, LIST_POS_MANDATED_OPTIONS, LIST_POS_PARENT_OPTIONS,
TOUCH_LASTSEEN, RECORD_OBITUARY, RECORD_SYNC_QUEUE, SEED_SYNC_QUEUE_FOR_TERMINAL, TOUCH_LASTSEEN, SET_CLIENTVERSION, RECORD_OBITUARY, RECORD_SYNC_QUEUE, SEED_SYNC_QUEUE_FOR_TERMINAL,
COUNT_DELETED_ENTITIES, COUNT_DELETED_ENTITIES,
} = queries; } = queries;

View File

@@ -1,3 +1,5 @@
import { SCHEMA_VERSION } from './migrate.js';
const UTC_NOW = "strftime('%Y-%m-%dT%H:%M:%fZ', 'now')"; const UTC_NOW = "strftime('%Y-%m-%dT%H:%M:%fZ', 'now')";
export const INIT_META = ` export const INIT_META = `
@@ -5,7 +7,7 @@ CREATE TABLE IF NOT EXISTS meta (
"key" TEXT PRIMARY KEY, "key" TEXT PRIMARY KEY,
value TEXT NOT NULL value TEXT NOT NULL
); );
INSERT OR IGNORE INTO meta ("key", value) VALUES ('version', '1'); INSERT OR IGNORE INTO meta ("key", value) VALUES ('version', '${SCHEMA_VERSION}');
`; `;
export const INIT_LANGUAGE = ` export const INIT_LANGUAGE = `
@@ -629,6 +631,9 @@ CREATE TABLE IF NOT EXISTS posterminal (
sync_root_articles INTEGER NOT NULL DEFAULT 1, sync_root_articles INTEGER NOT NULL DEFAULT 1,
lastseen_at TEXT, lastseen_at TEXT,
updated_at TEXT NOT NULL DEFAULT (${UTC_NOW}), updated_at TEXT NOT NULL DEFAULT (${UTC_NOW}),
sort INTEGER NOT NULL DEFAULT 0,
enabled INTEGER NOT NULL DEFAULT 1,
clientversion TEXT,
FOREIGN KEY (company_id) REFERENCES company(id) ON DELETE CASCADE, FOREIGN KEY (company_id) REFERENCES company(id) ON DELETE CASCADE,
FOREIGN KEY (root_category_id) REFERENCES category(id) ON DELETE SET NULL FOREIGN KEY (root_category_id) REFERENCES category(id) ON DELETE SET NULL
); );

View File

@@ -0,0 +1,16 @@
/**
* Schema 1 → 2 (SQLite): posterminal gets `sort`, `enabled`, `clientversion`.
* @param {{ exec: (sql: string) => void, assertVersion: (v: number) => Promise<void>, setVersion: (v: number) => Promise<void> }} ctx
*/
export default async function upgrade({ exec, assertVersion, setVersion }) {
await assertVersion(1);
exec(`
ALTER TABLE posterminal ADD COLUMN sort INTEGER NOT NULL DEFAULT 0;
ALTER TABLE posterminal ADD COLUMN enabled INTEGER NOT NULL DEFAULT 1;
ALTER TABLE posterminal ADD COLUMN clientversion TEXT;
`);
await setVersion(2);
}

View File

@@ -10,7 +10,7 @@ export default function findByPairingpin(provider) {
* *
* @param {string} pairingpin - The pairing pin to search for. * @param {string} pairingpin - The pairing pin to search for.
* @param {object} [tx] - Optional transaction to run within. * @param {object} [tx] - Optional transaction to run within.
* @returns {Promise<{id: number, companyId: number, name: string, token: string|null, pairingpin: string|null, pairedAt: string|null, clientserial: string|null, maxknownobituaryid: number|null, rootCategoryId: number|null, syncRootArticles: boolean|number, lastseenAt: string|null, updatedAt: string}|null>} The posterminal, or null if not found. * @returns {Promise<{id: number, companyId: number, name: string, token: string|null, pairingpin: string|null, pairedAt: string|null, clientserial: string|null, maxknownobituaryid: number|null, rootCategoryId: number|null, syncRootArticles: boolean|number, lastseenAt: string|null, updatedAt: string, sort: number, enabled: boolean|number, clientversion: string|null}|null>} The posterminal, or null if not found.
*/ */
return async (pairingpin, tx) => { return async (pairingpin, tx) => {
if (!pairingpin) return null; if (!pairingpin) return null;

View File

@@ -10,7 +10,7 @@ export default function findByToken(provider) {
* *
* @param {string} token - The auth token to search for. * @param {string} token - The auth token to search for.
* @param {object} [tx] - Optional transaction to run within. * @param {object} [tx] - Optional transaction to run within.
* @returns {Promise<{id: number, companyId: number, name: string, token: string|null, pairingpin: string|null, pairedAt: string|null, clientserial: string|null, maxknownobituaryid: number|null, rootCategoryId: number|null, syncRootArticles: boolean|number, lastseenAt: string|null, updatedAt: string}|null>} The posterminal, or null if not found. * @returns {Promise<{id: number, companyId: number, name: string, token: string|null, pairingpin: string|null, pairedAt: string|null, clientserial: string|null, maxknownobituaryid: number|null, rootCategoryId: number|null, syncRootArticles: boolean|number, lastseenAt: string|null, updatedAt: string, sort: number, enabled: boolean|number, clientversion: string|null}|null>} The posterminal, or null if not found.
*/ */
return async (token, tx) => { return async (token, tx) => {
const trimmed = String(token ?? '').trim(); const trimmed = String(token ?? '').trim();

View File

@@ -12,11 +12,11 @@ export default function list(provider) {
* *
* @param {number} googleAuthId - The id of the Google auth user. * @param {number} googleAuthId - The id of the Google auth user.
* @param {object} [tx] - Optional transaction to run within. * @param {object} [tx] - Optional transaction to run within.
* @returns {Promise<Array<{id: number, name: string, token: string|null, pairingpin: string|null, pairedAt: string|null, clientserial: string|null, maxknownobituaryid: number|null, rootCategoryId: number|null, syncRootArticles: boolean|number, lastseenAt: string|null, updatedAt: string}>>} The posterminals. * @returns {Promise<Array<{id: number, name: string, token: string|null, pairingpin: string|null, pairedAt: string|null, clientserial: string|null, maxknownobituaryid: number|null, rootCategoryId: number|null, syncRootArticles: boolean|number, lastseenAt: string|null, updatedAt: string, sort: number, enabled: boolean|number, clientversion: string|null}>>} The posterminals.
* *
* @example * @example
* const terminals = await store.posterminal.list(42); * const terminals = await store.posterminal.list(42);
* // => [{ id, name, token, pairingpin, pairedAt, clientserial, maxknownobituaryid, rootCategoryId, syncRootArticles, lastseenAt, updatedAt }] * // => [{ id, name, token, pairingpin, pairedAt, clientserial, maxknownobituaryid, rootCategoryId, syncRootArticles, lastseenAt, updatedAt, sort, enabled, clientversion }]
*/ */
return async (googleAuthId, tx) => { return async (googleAuthId, tx) => {
const { companyId } = await access(googleAuthId, tx); const { companyId } = await access(googleAuthId, tx);

View File

@@ -0,0 +1,26 @@
import { SET_CLIENTVERSION as MSSQL_SET } from '../../providers/mssql/queries/posterminal.js';
import { SET_CLIENTVERSION as SQLITE_SET } from '../../providers/sqlite3/queries/posterminal.js';
import makeRunner from '../runner.js';
export default function setClientversion(provider) {
const sql = provider?.dialect === 'sqlite3' ? SQLITE_SET : MSSQL_SET;
/**
* Stores the software version reported by a POS terminal.
*
* @param {number} id - The posterminal id.
* @param {string|null} clientversion - Version string reported by the client (trimmed; empty → null).
* @param {object} [tx] - Optional transaction to run within.
* @returns {Promise<{id: number, clientversion: string|null}>}
*
* @example
* await store.posterminal.setClientversion(7, '1.10.12.0');
* // => { id: 7, clientversion: '1.10.12.0' }
*/
return async (id, clientversion, tx) => {
const value = clientversion == null ? null : String(clientversion).trim() || null;
await makeRunner(provider, tx).query(sql, { id: Number(id), clientversion: value });
return { id: Number(id), clientversion: value };
};
}

View File

@@ -31,13 +31,15 @@ export default function upsert(provider) {
* @param {string} input.pairingpin - The pairing pin. * @param {string} input.pairingpin - The pairing pin.
* @param {string} [input.token] - Optional token. * @param {string} [input.token] - Optional token.
* @param {number} [input.id] - Optional id to update an existing posterminal. * @param {number} [input.id] - Optional id to update an existing posterminal.
* @param {number} [input.sort] - Display order (integer, ascending). Default 0 on create.
* @param {boolean} [input.enabled] - Whether the terminal may sync. Default true on create.
* @param {object} [tx] - Optional transaction to run within. * @param {object} [tx] - Optional transaction to run within.
* @returns {Promise<{id: number, name: string, token: string, pairingpin: string}>} The upserted posterminal. * @returns {Promise<{id: number, name: string, token: string, pairingpin: string, sort: number, enabled: number}>} The upserted posterminal.
* @throws {Error} If the posterminal is invalid. * @throws {Error} If the posterminal is invalid.
* *
* @example * @example
* const terminal = await store.posterminal.upsert(42, { name: 'Kasse 1', pairingpin: '1234', token: 'abc' }); * const terminal = await store.posterminal.upsert(42, { name: 'Kasse 1', pairingpin: '1234', token: 'abc' });
* // => { id, name, token, pairingpin } * // => { id, name, token, pairingpin, sort, enabled }
*/ */
return async (googleAuthId, input = {}, tx) => { return async (googleAuthId, input = {}, tx) => {
const id = input.id ?? null; const id = input.id ?? null;
@@ -45,6 +47,12 @@ export default function upsert(provider) {
if (name === '' || (!name && !id)) { if (name === '' || (!name && !id)) {
throw new Error('Invalid posterminal'); throw new Error('Invalid posterminal');
} }
if (input.sort !== undefined && !Number.isInteger(input.sort)) {
throw new Error('Invalid posterminal');
}
if (input.enabled !== undefined && typeof input.enabled !== 'boolean') {
throw new Error('Invalid posterminal');
}
const token = input.token ?? null; const token = input.token ?? null;
const pairedAt = input.pairedAt ?? null; const pairedAt = input.pairedAt ?? null;
const clientserial = input.clientserial ?? null; const clientserial = input.clientserial ?? null;
@@ -61,6 +69,14 @@ export default function upsert(provider) {
const nameVal = name !== undefined ? name : existing.rows[0].name; const nameVal = name !== undefined ? name : existing.rows[0].name;
// Pairing fields are kept unless explicitly provided (PATCH must not unpair).
const tokenVal = input.token !== undefined ? token : (existing.rows[0].token ?? null);
const pairedAtVal = input.pairedAt !== undefined ? pairedAt : (existing.rows[0].pairedAt ?? null);
const clientserialVal = input.clientserial !== undefined ? clientserial : (existing.rows[0].clientserial ?? null);
const sort = input.sort !== undefined ? input.sort : existing.rows[0].sort;
const enabled = input.enabled !== undefined ? (input.enabled ? 1 : 0) : (existing.rows[0].enabled ? 1 : 0);
const pairingpin = const pairingpin =
input.pairingpin !== undefined input.pairingpin !== undefined
? (input.pairingpin ? String(input.pairingpin).trim() : null) ? (input.pairingpin ? String(input.pairingpin).trim() : null)
@@ -90,27 +106,32 @@ export default function upsert(provider) {
id, id,
companyId, companyId,
name: nameVal, name: nameVal,
token, token: tokenVal,
pairingpin, pairingpin,
pairedAt, pairedAt: pairedAtVal,
clientserial, clientserial: clientserialVal,
maxknownobituaryid, maxknownobituaryid,
rootCategoryId, rootCategoryId,
syncRootArticles, syncRootArticles,
lastseenAt, lastseenAt,
sort,
enabled,
}); });
return { return {
id, id,
name: nameVal, name: nameVal,
token, token: tokenVal,
pairingpin, pairingpin,
pairedAt, pairedAt: pairedAtVal,
clientserial, clientserial: clientserialVal,
maxknownobituaryid, maxknownobituaryid,
rootCategoryId, rootCategoryId,
syncRootArticles, syncRootArticles,
lastseenAt, lastseenAt,
sort,
enabled,
clientversion: existing.rows[0].clientversion ?? null,
}; };
} }
@@ -119,6 +140,8 @@ export default function upsert(provider) {
const rootCategoryId = input.rootCategoryId != null ? Number(input.rootCategoryId) : null; const rootCategoryId = input.rootCategoryId != null ? Number(input.rootCategoryId) : null;
const syncRootArticles = input.syncRootArticles !== undefined ? (input.syncRootArticles ? 1 : 0) : 1; const syncRootArticles = input.syncRootArticles !== undefined ? (input.syncRootArticles ? 1 : 0) : 1;
const lastseenAt = input.lastseenAt ?? null; const lastseenAt = input.lastseenAt ?? null;
const sort = input.sort ?? 0;
const enabled = input.enabled === false ? 0 : 1;
const created = await runner.query(createSql, { const created = await runner.query(createSql, {
companyId, companyId,
@@ -131,6 +154,8 @@ export default function upsert(provider) {
rootCategoryId, rootCategoryId,
syncRootArticles, syncRootArticles,
lastseenAt, lastseenAt,
sort,
enabled,
}); });
const row = created.rows[0] ?? { const row = created.rows[0] ?? {
@@ -144,6 +169,9 @@ export default function upsert(provider) {
rootCategoryId, rootCategoryId,
syncRootArticles, syncRootArticles,
lastseenAt, lastseenAt,
sort,
enabled,
clientversion: null,
}; };
await runner.query(seedSyncQueueSql, { await runner.query(seedSyncQueueSql, {
@@ -163,6 +191,9 @@ export default function upsert(provider) {
syncRootArticles: row.syncRootArticles !== undefined ? (row.syncRootArticles ? 1 : 0) : 1, syncRootArticles: row.syncRootArticles !== undefined ? (row.syncRootArticles ? 1 : 0) : 1,
lastseenAt: row.lastseenAt ?? null, lastseenAt: row.lastseenAt ?? null,
updatedAt: row.updatedAt ?? null, updatedAt: row.updatedAt ?? null,
sort: row.sort ?? sort,
enabled: row.enabled !== undefined ? (row.enabled ? 1 : 0) : enabled,
clientversion: row.clientversion ?? null,
}; };
}; };
} }