From 75a83bdc280c5b3cec04d8e734ef43fd90a3b558 Mon Sep 17 00:00:00 2001 From: seb Date: Thu, 29 Oct 2026 03:17:47 +0100 Subject: [PATCH] u --- package.json | 1 + scripts/downgrade.js | 54 ++++++ src/index.js | 2 + src/providers/mssql/downgrade/2_to_1.js | 15 ++ src/providers/mssql/index.js | 22 +++ src/providers/mssql/migrate.js | 122 ++++++++++++ src/providers/mssql/queries/posterminal.js | 36 +++- src/providers/mssql/schema.js | 7 +- src/providers/mssql/upgrade/1_to_2.js | 17 ++ src/providers/schemaMigrations.js | 184 +++++++++++++++++++ src/providers/sqlite3/downgrade/2_to_1.js | 16 ++ src/providers/sqlite3/index.js | 22 +++ src/providers/sqlite3/migrate.js | 121 ++++++++++++ src/providers/sqlite3/queries/posterminal.js | 2 +- src/providers/sqlite3/schema.js | 7 +- src/providers/sqlite3/upgrade/1_to_2.js | 16 ++ src/store/posterminal/findByPairingpin.js | 2 +- src/store/posterminal/findByToken.js | 2 +- src/store/posterminal/list.js | 4 +- src/store/posterminal/setClientversion.js | 26 +++ src/store/posterminal/upsert.js | 47 ++++- 21 files changed, 701 insertions(+), 24 deletions(-) create mode 100644 scripts/downgrade.js create mode 100644 src/providers/mssql/downgrade/2_to_1.js create mode 100644 src/providers/mssql/migrate.js create mode 100644 src/providers/mssql/upgrade/1_to_2.js create mode 100644 src/providers/schemaMigrations.js create mode 100644 src/providers/sqlite3/downgrade/2_to_1.js create mode 100644 src/providers/sqlite3/migrate.js create mode 100644 src/providers/sqlite3/upgrade/1_to_2.js create mode 100644 src/store/posterminal/setClientversion.js diff --git a/package.json b/package.json index 955ed73..55b8bc6 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "init-schema": "node scripts/init-schema.js", "purge-db": "node scripts/purge-db.js", "reset-db": "node scripts/reset-db.js", + "downgrade": "node scripts/downgrade.js", "test": "node --test \"test/*.test.js\"", "test:api": "node scripts/test-api.js", "test:benchmark": "node scripts/benchmark.js" diff --git a/scripts/downgrade.js b/scripts/downgrade.js new file mode 100644 index 0000000..0a12c38 --- /dev/null +++ b/scripts/downgrade.js @@ -0,0 +1,54 @@ +/** + * Downgrades the database schema to a target version. + * + * npm run downgrade -- + * + * Provider from env: DB_PROVIDER=sqlite (+ SQLITE_PATH) or mssql (MSSQL_*, default). + * Runs src/providers//downgrade/_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 -- '); + 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(); +} + diff --git a/src/index.js b/src/index.js index 6951714..0711b9e 100644 --- a/src/index.js +++ b/src/index.js @@ -149,6 +149,7 @@ import posterminalGetCategoryList from './store/posterminal/getCategoryList.js'; import posterminalGetProductList from './store/posterminal/getProductList.js'; import posterminalSetMaxKnownObituaryId from './store/posterminal/setMaxKnownObituaryId.js'; import posterminalTouchLastseen from './store/posterminal/touchLastseen.js'; +import posterminalSetClientversion from './store/posterminal/setClientversion.js'; import langCreate from './store/lang/create.js'; import langDelete from './store/lang/delete.js'; import langList from './store/lang/list.js'; @@ -535,6 +536,7 @@ export default class LibDashStore { getProductList: posterminalGetProductList(provider), setMaxKnownObituaryId: posterminalSetMaxKnownObituaryId(provider), touchLastseen: posterminalTouchLastseen(provider), + setClientversion: posterminalSetClientversion(provider), }; this.account = { diff --git a/src/providers/mssql/downgrade/2_to_1.js b/src/providers/mssql/downgrade/2_to_1.js new file mode 100644 index 0000000..21d91e6 --- /dev/null +++ b/src/providers/mssql/downgrade/2_to_1.js @@ -0,0 +1,15 @@ +/** + * Schema 2 → 1 (MSSQL): drops posterminal `sort`, `enabled`, `clientversion`. + * @param {{ query: (sql: string, params?: object) => Promise, assertVersion: (v: number) => Promise, setVersion: (v: number) => Promise }} 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); +} + diff --git a/src/providers/mssql/index.js b/src/providers/mssql/index.js index f936f6d..85b99ce 100644 --- a/src/providers/mssql/index.js +++ b/src/providers/mssql/index.js @@ -9,6 +9,7 @@ import { mkdirSync, promises as fsPromises } from 'node:fs'; import sql from 'mssql'; import { logSlowQuery } from '../provider.js'; import { safeWrite, safeReadByHash, safeDeleteByHash } from '../storage.js'; +import { upgradeSchema, downgradeSchema, readSchemaVersion } from './migrate.js'; function normalizeRows(result) { const recordset = result?.recordset ?? []; @@ -306,6 +307,9 @@ export class MssqlProvider { async initialize() { 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'); for (const script of SCHEMA_SCRIPTS) { if (script && script.trim()) { @@ -371,6 +375,24 @@ export class MssqlProvider { } } + /** + * Current `meta.version`, or `null` if the database has none. + * @returns {Promise} + */ + 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} Steps applied. + */ + async downgradeSchema(target, log) { + return downgradeSchema(this, target, log); + } + close() { return this.pool ? this.pool.close() : Promise.resolve(); } diff --git a/src/providers/mssql/migrate.js b/src/providers/mssql/migrate.js new file mode 100644 index 0000000..730b7e5 --- /dev/null +++ b/src/providers/mssql/migrate.js @@ -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} + */ +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, + }); +} + diff --git a/src/providers/mssql/queries/posterminal.js b/src/providers/mssql/queries/posterminal.js index b2c1d8c..1ab6a59 100644 --- a/src/providers/mssql/queries/posterminal.js +++ b/src/providers/mssql/queries/posterminal.js @@ -10,10 +10,13 @@ SELECT root_category_id AS rootCategoryId, sync_root_articles AS syncRootArticles, lastseen_at AS lastseenAt, - updated_at AS updatedAt + updated_at AS updatedAt, + sort, + enabled, + clientversion FROM posterminal WHERE company_id = @companyId -ORDER BY id +ORDER BY sort, id `; export const GET_BY_ID = ` @@ -29,7 +32,10 @@ SELECT root_category_id AS rootCategoryId, sync_root_articles AS syncRootArticles, lastseen_at AS lastseenAt, - updated_at AS updatedAt + updated_at AS updatedAt, + sort, + enabled, + clientversion FROM posterminal WHERE id = @id `; @@ -47,7 +53,10 @@ SELECT root_category_id AS rootCategoryId, sync_root_articles AS syncRootArticles, lastseen_at AS lastseenAt, - updated_at AS updatedAt + updated_at AS updatedAt, + sort, + enabled, + clientversion FROM posterminal WHERE pairingpin = @pairingpin `; @@ -59,17 +68,23 @@ WHERE id = @id `; 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) -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 -VALUES (@companyId, @name, @token, @pairingpin, @pairedAt, @clientserial, @maxknownobituaryid, @rootCategoryId, @syncRootArticles, @lastseenAt, SYSUTCDATETIME()) +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, INSERTED.sort, INSERTED.enabled, INSERTED.clientversion +VALUES (@companyId, @name, @token, @pairingpin, @pairedAt, @clientserial, @maxknownobituaryid, @rootCategoryId, @syncRootArticles, @lastseenAt, @sort, @enabled, SYSUTCDATETIME()) `; export const UPDATE = ` 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 `; +export const SET_CLIENTVERSION = ` +UPDATE posterminal +SET clientversion = @clientversion, updated_at = SYSUTCDATETIME() +WHERE id = @id +`; + export const TOUCH_LASTSEEN = ` UPDATE posterminal SET lastseen_at = SYSUTCDATETIME() @@ -166,7 +181,10 @@ SELECT root_category_id AS rootCategoryId, sync_root_articles AS syncRootArticles, lastseen_at AS lastseenAt, - updated_at AS updatedAt + updated_at AS updatedAt, + sort, + enabled, + clientversion FROM posterminal WHERE token = @token `; diff --git a/src/providers/mssql/schema.js b/src/providers/mssql/schema.js index c678318..1b7d973 100644 --- a/src/providers/mssql/schema.js +++ b/src/providers/mssql/schema.js @@ -1,3 +1,5 @@ +import { SCHEMA_VERSION } from './migrate.js'; + export const INIT_META = ` IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = 'meta') BEGIN @@ -11,7 +13,7 @@ END IF NOT EXISTS (SELECT 1 FROM meta WHERE [key] = N'version') BEGIN INSERT INTO meta ([key], value) - VALUES (N'version', N'1'); + VALUES (N'version', N'${SCHEMA_VERSION}'); END `; @@ -966,6 +968,9 @@ BEGIN sync_root_articles BIT NOT NULL CONSTRAINT DF_posterminal_sync_root_articles DEFAULT 1, lastseen_at DATETIME2 NULL, 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_root_category FOREIGN KEY (root_category_id) REFERENCES category(id) ON DELETE SET NULL ); diff --git a/src/providers/mssql/upgrade/1_to_2.js b/src/providers/mssql/upgrade/1_to_2.js new file mode 100644 index 0000000..03e7915 --- /dev/null +++ b/src/providers/mssql/upgrade/1_to_2.js @@ -0,0 +1,17 @@ +/** + * Schema 1 → 2 (MSSQL): posterminal gets `sort`, `enabled`, `clientversion`. + * @param {{ query: (sql: string, params?: object) => Promise, assertVersion: (v: number) => Promise, setVersion: (v: number) => Promise }} 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); +} + diff --git a/src/providers/schemaMigrations.js b/src/providers/schemaMigrations.js new file mode 100644 index 0000000..770645d --- /dev/null +++ b/src/providers/schemaMigrations.js @@ -0,0 +1,184 @@ +/** + * Versioned schema migrations shared by the MSSQL and SQLite providers. + * + * Layout (per provider): + * src/providers//upgrade/_to_.js (to = from + 1) + * src/providers//downgrade/_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 _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} getVersion + * @param {(version: number) => Promise|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) => Promise} options.runStep + * Provider hook: runs `fn(ctx)` in a transaction and verifies the end version. + * @param {(message: string) => void} [options.log] + * @returns {Promise} 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}`); + } +} + diff --git a/src/providers/sqlite3/downgrade/2_to_1.js b/src/providers/sqlite3/downgrade/2_to_1.js new file mode 100644 index 0000000..ab3e44a --- /dev/null +++ b/src/providers/sqlite3/downgrade/2_to_1.js @@ -0,0 +1,16 @@ +/** + * Schema 2 → 1 (SQLite): drops posterminal `sort`, `enabled`, `clientversion`. + * @param {{ exec: (sql: string) => void, assertVersion: (v: number) => Promise, setVersion: (v: number) => Promise }} 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); +} + diff --git a/src/providers/sqlite3/index.js b/src/providers/sqlite3/index.js index 9b288ef..febf81e 100644 --- a/src/providers/sqlite3/index.js +++ b/src/providers/sqlite3/index.js @@ -9,6 +9,7 @@ import Database from 'better-sqlite3'; import { AsyncLocalStorage } from 'node:async_hooks'; import { logSlowQuery } from '../provider.js'; import { SCHEMA_SCRIPTS } from './schema.js'; +import { upgradeSchema, downgradeSchema, readSchemaVersion } from './migrate.js'; const asyncTxStorage = new AsyncLocalStorage(); @@ -359,6 +360,9 @@ export class BetterSqlite3Provider extends BetterSqlite3Executor { * @returns {Promise} */ async initialize() { + // Versioned upgrades first, so schema.js never meets an outdated table shape. + await upgradeSchema(this.db); + for (const script of SCHEMA_SCRIPTS) { if (script && script.trim()) { this.db.exec(script); @@ -475,6 +479,24 @@ export class BetterSqlite3Provider extends BetterSqlite3Executor { } /** Explicitly closes the database. */ + /** + * Current `meta.version`, or `null` if the database has none. + * @returns {Promise} + */ + 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} Steps applied. + */ + async downgradeSchema(target, log) { + return downgradeSchema(this.db, target, log); + } + close() { this.db.close(); } diff --git a/src/providers/sqlite3/migrate.js b/src/providers/sqlite3/migrate.js new file mode 100644 index 0000000..ac24d89 --- /dev/null +++ b/src/providers/sqlite3/migrate.js @@ -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, + }); +} + diff --git a/src/providers/sqlite3/queries/posterminal.js b/src/providers/sqlite3/queries/posterminal.js index 2f7773f..08993bb 100644 --- a/src/providers/sqlite3/queries/posterminal.js +++ b/src/providers/sqlite3/queries/posterminal.js @@ -10,7 +10,7 @@ export const { COUNT_CUSTOMER_GROUPS, COUNT_COMPOSITE_PRODUCTS, MAX_ORDER_ID, LIST_POS_PRODUCT_CATEGORIES, LIST_POS_CUSTOMER_GROUP_IDS, LIST_POS_PRODUCT_PRICES, 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, } = queries; diff --git a/src/providers/sqlite3/schema.js b/src/providers/sqlite3/schema.js index 8e4af56..20934b5 100644 --- a/src/providers/sqlite3/schema.js +++ b/src/providers/sqlite3/schema.js @@ -1,3 +1,5 @@ +import { SCHEMA_VERSION } from './migrate.js'; + const UTC_NOW = "strftime('%Y-%m-%dT%H:%M:%fZ', 'now')"; export const INIT_META = ` @@ -5,7 +7,7 @@ CREATE TABLE IF NOT EXISTS meta ( "key" TEXT PRIMARY KEY, 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 = ` @@ -629,6 +631,9 @@ CREATE TABLE IF NOT EXISTS posterminal ( sync_root_articles INTEGER NOT NULL DEFAULT 1, lastseen_at TEXT, 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 (root_category_id) REFERENCES category(id) ON DELETE SET NULL ); diff --git a/src/providers/sqlite3/upgrade/1_to_2.js b/src/providers/sqlite3/upgrade/1_to_2.js new file mode 100644 index 0000000..266e5ea --- /dev/null +++ b/src/providers/sqlite3/upgrade/1_to_2.js @@ -0,0 +1,16 @@ +/** + * Schema 1 → 2 (SQLite): posterminal gets `sort`, `enabled`, `clientversion`. + * @param {{ exec: (sql: string) => void, assertVersion: (v: number) => Promise, setVersion: (v: number) => Promise }} 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); +} + diff --git a/src/store/posterminal/findByPairingpin.js b/src/store/posterminal/findByPairingpin.js index f95e933..7cf9dc5 100644 --- a/src/store/posterminal/findByPairingpin.js +++ b/src/store/posterminal/findByPairingpin.js @@ -10,7 +10,7 @@ export default function findByPairingpin(provider) { * * @param {string} pairingpin - The pairing pin to search for. * @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) => { if (!pairingpin) return null; diff --git a/src/store/posterminal/findByToken.js b/src/store/posterminal/findByToken.js index 8bd67bd..6b55df5 100644 --- a/src/store/posterminal/findByToken.js +++ b/src/store/posterminal/findByToken.js @@ -10,7 +10,7 @@ export default function findByToken(provider) { * * @param {string} token - The auth token to search for. * @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) => { const trimmed = String(token ?? '').trim(); diff --git a/src/store/posterminal/list.js b/src/store/posterminal/list.js index 00b4add..38b14c4 100644 --- a/src/store/posterminal/list.js +++ b/src/store/posterminal/list.js @@ -12,11 +12,11 @@ export default function list(provider) { * * @param {number} googleAuthId - The id of the Google auth user. * @param {object} [tx] - Optional transaction to run within. - * @returns {Promise>} The posterminals. + * @returns {Promise>} The posterminals. * * @example * 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) => { const { companyId } = await access(googleAuthId, tx); diff --git a/src/store/posterminal/setClientversion.js b/src/store/posterminal/setClientversion.js new file mode 100644 index 0000000..ce32211 --- /dev/null +++ b/src/store/posterminal/setClientversion.js @@ -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 }; + }; +} + diff --git a/src/store/posterminal/upsert.js b/src/store/posterminal/upsert.js index dc1d806..60aa8f3 100644 --- a/src/store/posterminal/upsert.js +++ b/src/store/posterminal/upsert.js @@ -31,13 +31,15 @@ export default function upsert(provider) { * @param {string} input.pairingpin - The pairing pin. * @param {string} [input.token] - Optional token. * @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. - * @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. * * @example * 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) => { const id = input.id ?? null; @@ -45,6 +47,12 @@ export default function upsert(provider) { if (name === '' || (!name && !id)) { 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 pairedAt = input.pairedAt ?? null; const clientserial = input.clientserial ?? null; @@ -61,6 +69,14 @@ export default function upsert(provider) { 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 = input.pairingpin !== undefined ? (input.pairingpin ? String(input.pairingpin).trim() : null) @@ -90,27 +106,32 @@ export default function upsert(provider) { id, companyId, name: nameVal, - token, + token: tokenVal, pairingpin, - pairedAt, - clientserial, + pairedAt: pairedAtVal, + clientserial: clientserialVal, maxknownobituaryid, rootCategoryId, syncRootArticles, lastseenAt, + sort, + enabled, }); return { id, name: nameVal, - token, + token: tokenVal, pairingpin, - pairedAt, - clientserial, + pairedAt: pairedAtVal, + clientserial: clientserialVal, maxknownobituaryid, rootCategoryId, syncRootArticles, 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 syncRootArticles = input.syncRootArticles !== undefined ? (input.syncRootArticles ? 1 : 0) : 1; const lastseenAt = input.lastseenAt ?? null; + const sort = input.sort ?? 0; + const enabled = input.enabled === false ? 0 : 1; const created = await runner.query(createSql, { companyId, @@ -131,6 +154,8 @@ export default function upsert(provider) { rootCategoryId, syncRootArticles, lastseenAt, + sort, + enabled, }); const row = created.rows[0] ?? { @@ -144,6 +169,9 @@ export default function upsert(provider) { rootCategoryId, syncRootArticles, lastseenAt, + sort, + enabled, + clientversion: null, }; await runner.query(seedSyncQueueSql, { @@ -163,6 +191,9 @@ export default function upsert(provider) { syncRootArticles: row.syncRootArticles !== undefined ? (row.syncRootArticles ? 1 : 0) : 1, lastseenAt: row.lastseenAt ?? null, updatedAt: row.updatedAt ?? null, + sort: row.sort ?? sort, + enabled: row.enabled !== undefined ? (row.enabled ? 1 : 0) : enabled, + clientversion: row.clientversion ?? null, }; }; }