Compare commits

...

4 Commits

Author SHA1 Message Date
seb
c7db2262b0 u 2026-10-29 03:29:07 +01:00
seb
0fb5893a70 u 2026-10-29 03:28:39 +01:00
seb
75a83bdc28 u 2026-10-29 03:17:47 +01:00
seb
b0521a4ea6 u 2026-10-29 02:55:51 +01:00
27 changed files with 863 additions and 256 deletions

11
API.md
View File

@@ -256,8 +256,8 @@ The store is exposed as a `LibDashStore` instance (see `src/index.js`). Each met
- `ship(provider: object): Function`
- [posterminal](API.posterminal.md)
- `delete(googleAuthId: number, id: number, tx?: object): Promise<void>`
- `findByPairingpin(pairingpin: string, tx?: object): 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>`
- `findByToken(token: string, tx?: object): 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>`
- `findByPairingpin(pairingpin: string, tx?: object): 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>`
- `findByToken(token: string, tx?: object): 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>`
- `getCategoryCount(input: {posterminalId: number, cursor?: number}, tx?: object): Promise<number>`
- `getCategoryList(input: {posterminalId: number, cursor?: number, limit?: number}, tx?: object): Promise<Array<object>>`
- `getCompositeProductCount(input: {posterminalId: number, cursor?: number}, tx?: object): Promise<number>`
@@ -270,17 +270,18 @@ The store is exposed as a `LibDashStore` instance (see `src/index.js`). Each met
- `getMaxOrderIdCount(input: {posterminalId: number}, tx?: object): Promise<number>`
- `getProductCount(input: {posterminalId: number, cursor?: number}, tx?: object): Promise<number>`
- `getProductList(input: {posterminalId: number, cursor?: number, limit?: number}, tx?: object): Promise<Array<object>>`
- `list(googleAuthId: number, tx?: object): 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}>>`
- `pair(id: number, input: {token: string, clientserial?: string, pairedAt?: string}, tx?: object): Promise<{id: number, token: string, clientserial: string|null, pairedAt: string|null}>`
- `list(googleAuthId: number, tx?: object): 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}>>`
- `pair(id: number, input: {token: string, clientserial?: string, pairedAt?: string, clientversion?: string|null}, tx?: object): Promise<{id: number, token: string, clientserial: string|null, pairedAt: string|null, clientversion: string|null}>`
- `recordObituary(input: {companyId: number, entityType: string, entityId: number}, tx?: object): Promise<void>`
- `recordSyncQueue(input: {companyId: number, entityType: string, entityId: number}, tx?: object): Promise<void>`
- `setClientversion(id: number, clientversion: string|null, tx?: object): Promise<{id: number, clientversion: string|null}>`
- `setMaxKnownObituaryId(id: number, maxknownobituaryid: number|null, tx?: object): Promise<{id: number, maxknownobituaryid: number|null}>`
- `setOff(googleAuthId: number, posterminalId: number, categoryId: number, tx?: object): Promise<{id: number, categoryIds: Array<number>}>`
- `setOffAll(googleAuthId: number, posterminalId: number, tx?: object): Promise<{id: number, categoryIds: Array<number>}>`
- `setOn(googleAuthId: number, posterminalId: number, categoryId: number, tx?: object): Promise<{id: number, categoryIds: Array<number>}>`
- `setOnAll(googleAuthId: number, posterminalId: number, tx?: object): Promise<{id: number, categoryIds: Array<number>}>`
- `touchLastseen(id: number, tx?: object): Promise<{id: number}>`
- `upsert(googleAuthId: number, input: {name: string, pairingpin: string, token?: string, id?: number}, tx?: object): Promise<{id: number, name: string, token: string, pairingpin: string}>`
- `upsert(googleAuthId: number, input: {name: string, pairingpin: string, token?: string, id?: number, sort?: number, enabled?: boolean}, tx?: object): Promise<{id: number, name: string, token: string, pairingpin: string, sort: number, enabled: number}>`
- [printer](API.printer.md)
- `create(googleAuthId: number, input: {address: string, type: string, model?: string, serial?: string, sort?: number, desc?: string}, tx?: object): Promise<{id: number, company_id: number, address: string, type: string, model: string|null, serial: string|null, sort: number, desc: string|null}>`
- `delete(googleAuthId: number, id: number, tx?: object): Promise<void>`

View File

@@ -45,7 +45,7 @@ Finds a posterminal by its pairingpin.
**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.
`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.
## `posterminal.findByToken`
@@ -62,7 +62,7 @@ Finds a posterminal by its auth token.
**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.
`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.
## `posterminal.getCategoryCount`
@@ -311,13 +311,13 @@ Lists the posterminals of the user's selected company.
**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.
`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**
```js
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 }]
```
## `posterminal.pair`
@@ -335,11 +335,12 @@ Completes POS device pairing for a terminal.
| `input.token` | `string` | The auth token for the paired device. |
| `[input.clientserial]` | `string` | The client name or serial identifier. |
| `[input.pairedAt]` | `string` | Timestamp when paired. |
| `[input.clientversion]` | `string|null` | Client software version reported by the POS device during pairing. |
| `[tx]` | `object` | Optional transaction to run within. |
**Returns**
`Promise<{id: number, token: string, clientserial: string|null, pairedAt: string|null}>`
`Promise<{id: number, token: string, clientserial: string|null, pairedAt: string|null, clientversion: string|null}>`
## `posterminal.recordObituary`
@@ -381,6 +382,31 @@ Enqueues a sync notification for all POS terminals of a company.
`Promise<void>`
## `posterminal.setClientversion`
*Source: `src/store/posterminal/setClientversion.js`*
Stores the software version reported by a POS terminal.
**Parameters**
| Name | Type | Description |
| --- | --- | --- |
| `id` | `number` | The posterminal id. |
| `clientversion` | `string|null` | Version string reported by the client (trimmed; empty → null). |
| `[tx]` | `object` | Optional transaction to run within. |
**Returns**
`Promise<{id: number, clientversion: string|null}>`
**Example**
```js
await store.posterminal.setClientversion(7, '1.10.12.0');
// => { id: 7, clientversion: '1.10.12.0' }
```
## `posterminal.setMaxKnownObituaryId`
*Source: `src/store/posterminal/setMaxKnownObituaryId.js`*
@@ -550,11 +576,13 @@ Creates or updates a posterminal for the user's selected company.
| `input.pairingpin` | `string` | The pairing pin. |
| `[input.token]` | `string` | Optional token. |
| `[input.id]` | `number` | Optional id to update an existing posterminal. |
| `[input.sort]` | `number` | Display order (integer, ascending). Default 0 on create. |
| `[input.enabled]` | `boolean` | Whether the terminal may sync. Default true on create. |
| `[tx]` | `object` | Optional transaction to run within. |
**Returns**
`Promise<{id: number, name: string, token: string, pairingpin: string}>` — The upserted posterminal.
`Promise<{id: number, name: string, token: string, pairingpin: string, sort: number, enabled: number}>` — The upserted posterminal.
**Throws**
@@ -564,5 +592,5 @@ Creates or updates a posterminal for the user's selected company.
```js
const terminal = await store.posterminal.upsert(42, { name: 'Kasse 1', pairingpin: '1234', token: 'abc' });
// => { id, name, token, pairingpin }
// => { id, name, token, pairingpin, sort, enabled }
```

View File

@@ -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"

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

@@ -1,112 +0,0 @@
import 'dotenv/config';
import Database from 'better-sqlite3';
import { MssqlProvider } from '../src/providers/mssql/index.js';
async function migrateSqlite(sqlitePath) {
console.log(`Migrating SQLite database at: ${sqlitePath}`);
const db = new Database(sqlitePath);
const columns = db.pragma('table_info("manifest")');
const hasCreatedAt = columns.some((c) => c.name === 'created_at');
if (hasCreatedAt) {
console.log('SQLite manifest already has created_at column.');
db.close();
return;
}
db.pragma('foreign_keys = OFF');
db.exec(`
BEGIN TRANSACTION;
CREATE TABLE manifest_new (
id INTEGER PRIMARY KEY,
company_id INTEGER NOT NULL,
"number" TEXT NOT NULL,
"type" TEXT NOT NULL,
entity_id INTEGER,
external_number TEXT,
"date" TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
FOREIGN KEY (company_id) REFERENCES company(id) ON DELETE NO ACTION,
FOREIGN KEY (entity_id) REFERENCES entity(id) ON DELETE SET NULL
);
INSERT INTO manifest_new (id, company_id, "number", "type", entity_id, external_number, "date", created_at)
SELECT id, company_id, "number", "type", entity_id, external_number, "date", "date"
FROM manifest;
DROP TABLE manifest;
ALTER TABLE manifest_new RENAME TO manifest;
COMMIT;
`);
db.pragma('foreign_keys = ON');
const fkErrors = db.pragma('foreign_key_check');
if (fkErrors.length > 0) {
console.error('Foreign key check failed after SQLite migration:', fkErrors);
} else {
console.log('SQLite migration completed successfully.');
}
console.log('Updated SQLite manifest columns:', db.pragma('table_info("manifest")'));
console.log('Updated SQLite manifest sample row:', db.prepare('SELECT * FROM manifest LIMIT 1').get());
db.close();
}
async function migrateMssql() {
console.log('Migrating MSSQL database...');
const config = {
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,
},
};
const provider = new MssqlProvider(config);
try {
const pool = await provider.ensurePool();
const checkRes = await pool.request().query(`
SELECT * FROM sys.columns
WHERE object_id = OBJECT_ID(N'manifest') AND name = 'created_at'
`);
if (checkRes.recordset.length > 0) {
console.log('MSSQL manifest already has created_at column.');
} else {
await pool.request().query(`
ALTER TABLE manifest ADD created_at DATETIME2 NOT NULL CONSTRAINT DF_manifest_created_at DEFAULT SYSUTCDATETIME();
`);
await pool.request().query(`
UPDATE manifest SET created_at = [date];
`);
console.log('MSSQL migration completed successfully.');
}
const colsRes = await pool.request().query(`
SELECT COLUMN_NAME, DATA_TYPE, IS_NULLABLE, COLUMN_DEFAULT
FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_NAME = 'manifest'
`);
console.log('Updated MSSQL manifest columns:', colsRes.recordset);
const sampleRes = await pool.request().query(`
SELECT TOP 1 * FROM manifest
`);
console.log('Updated MSSQL manifest sample row:', sampleRes.recordset[0]);
await provider.close();
} catch (err) {
console.error('MSSQL migration failed:', err);
await provider.close();
throw err;
}
}
async function run() {
await migrateSqlite('/home/seb/src/dashback/data/dash.db');
await migrateMssql();
}
run().catch((err) => {
console.error('Migration failed:', err);
process.exit(1);
});

View File

@@ -1,105 +0,0 @@
import 'dotenv/config';
import Database from 'better-sqlite3';
import { MssqlProvider } from '../src/providers/mssql/index.js';
async function migrateSqlite(sqlitePath) {
console.log(`Migrating SQLite database at: ${sqlitePath}`);
const db = new Database(sqlitePath);
const row = db.prepare("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'printer'").get();
if (!row) {
console.log('Printer table does not exist in SQLite.');
db.close();
return;
}
if (row.sql.includes("'ipp'")) {
console.log("SQLite printer table already includes 'ipp' in CHECK constraint.");
db.close();
return;
}
db.pragma('foreign_keys = OFF');
db.exec(`
BEGIN TRANSACTION;
CREATE TABLE printer_new (
id INTEGER PRIMARY KEY,
company_id INTEGER NOT NULL,
address TEXT NOT NULL,
type TEXT NOT NULL CHECK (type IN ('esc/pos', 'zpl', 'ipp')),
model TEXT,
serial TEXT,
sort INTEGER NOT NULL DEFAULT 0,
"desc" TEXT,
FOREIGN KEY (company_id) REFERENCES company(id) ON DELETE CASCADE
);
INSERT INTO printer_new (id, company_id, address, type, model, serial, sort, "desc")
SELECT id, company_id, address, type, model, serial, sort, "desc"
FROM printer;
DROP TABLE printer;
ALTER TABLE printer_new RENAME TO printer;
COMMIT;
`);
db.pragma('foreign_keys = ON');
const fkErrors = db.pragma('foreign_key_check');
if (fkErrors.length > 0) {
console.error('Foreign key check failed after SQLite migration:', fkErrors);
} else {
console.log('SQLite migration completed successfully.');
}
const updatedRow = db.prepare("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'printer'").get();
console.log('Updated SQLite printer table SQL:', updatedRow?.sql);
db.close();
}
async function migrateMssql() {
console.log('Migrating MSSQL database...');
const config = {
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,
},
};
try {
const provider = new MssqlProvider(config);
const pool = await provider.ensurePool();
const tableRes = await pool.request().query(`
SELECT * FROM sys.tables WHERE name = 'printer'
`);
if (tableRes.recordset.length === 0) {
console.log('Printer table does not exist in MSSQL.');
await provider.close();
return;
}
// Drop old CK_printer_type constraint if exists and add new one
await pool.request().query(`
IF EXISTS (SELECT * FROM sys.check_constraints WHERE name = 'CK_printer_type')
BEGIN
ALTER TABLE printer DROP CONSTRAINT CK_printer_type;
END
ALTER TABLE printer ADD CONSTRAINT CK_printer_type CHECK (type IN ('esc/pos', 'zpl', 'ipp'));
`);
console.log('MSSQL migration completed successfully.');
await provider.close();
} catch (err) {
console.warn('MSSQL migration skipped/failed (possibly server not running):', err.message);
}
}
async function run() {
await migrateSqlite('/home/seb/src/dashback/data/dash.db');
await migrateMssql();
}
run().catch((err) => {
console.error('Migration failed:', err);
process.exit(1);
});

View File

@@ -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 = {

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 { 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<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() {
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,
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,29 +53,38 @@ 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
`;
export const PAIR = `
UPDATE posterminal
SET token = @token, paired_at = @pairedAt, clientserial = @clientserial, pairingpin = NULL, lastseen_at = @pairedAt, updated_at = SYSUTCDATETIME()
SET token = @token, paired_at = @pairedAt, clientserial = @clientserial, clientversion = @clientversion, pairingpin = NULL, lastseen_at = @pairedAt, updated_at = SYSUTCDATETIME()
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, clientversion = @clientversion, 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
`;

View File

@@ -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
);

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 { 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<void>}
*/
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<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() {
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,
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;

View File

@@ -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
);

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 {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;

View File

@@ -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();

View File

@@ -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<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
* 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);

View File

@@ -13,15 +13,18 @@ export default function pair(provider) {
* @param {string} input.token - The auth token for the paired device.
* @param {string} [input.clientserial] - The client name or serial identifier.
* @param {string} [input.pairedAt] - Timestamp when paired.
* @param {string|null} [input.clientversion] - Client software version reported by the POS device during pairing.
* @param {object} [tx] - Optional transaction to run within.
* @returns {Promise<{id: number, token: string, clientserial: string|null, pairedAt: string|null}>}
* @returns {Promise<{id: number, token: string, clientserial: string|null, pairedAt: string|null, clientversion: string|null}>}
*/
return async (id, { token, clientserial, pairedAt }, tx) => {
return async (id, { token, clientserial, pairedAt, clientversion }, tx) => {
const versionVal = clientversion ? String(clientversion).trim() : null;
await makeRunner(provider, tx).query(sql, {
id,
token,
clientserial: clientserial ?? null,
pairedAt: pairedAt ?? null,
clientversion: versionVal,
});
return {
@@ -29,6 +32,7 @@ export default function pair(provider) {
token,
clientserial: clientserial ?? null,
pairedAt: pairedAt ?? null,
clientversion: versionVal,
};
};
}

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.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,15 @@ 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 clientversion = existing.rows[0].clientversion ?? null;
const pairingpin =
input.pairingpin !== undefined
? (input.pairingpin ? String(input.pairingpin).trim() : null)
@@ -90,27 +107,33 @@ 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,
clientversion,
});
return {
id,
name: nameVal,
token,
token: tokenVal,
pairingpin,
pairedAt,
clientserial,
pairedAt: pairedAtVal,
clientserial: clientserialVal,
maxknownobituaryid,
rootCategoryId,
syncRootArticles,
lastseenAt,
sort,
enabled,
clientversion,
};
}
@@ -119,6 +142,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 +156,8 @@ export default function upsert(provider) {
rootCategoryId,
syncRootArticles,
lastseenAt,
sort,
enabled,
});
const row = created.rows[0] ?? {
@@ -144,6 +171,9 @@ export default function upsert(provider) {
rootCategoryId,
syncRootArticles,
lastseenAt,
sort,
enabled,
clientversion: null,
};
await runner.query(seedSyncQueueSql, {
@@ -163,6 +193,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,
};
};
}

112
test/migration.test.js Normal file
View File

@@ -0,0 +1,112 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import LibDashStore from '../src/index.js';
import { BetterSqlite3Provider } from '../src/providers/sqlite3/index.js';
import { createTenant } from './helpers.js';
test('schema migrations & posterminal version 2', async (t) => {
const tempDir = mkdtempSync(join(tmpdir(), 'dash-migration-test-'));
const dbPath = join(tempDir, 'test.db');
const provider = new BetterSqlite3Provider({
filename: dbPath,
storageDir: join(tempDir, 'storage'),
});
t.after(() => {
try {
provider.db?.close?.();
} catch {}
rmSync(tempDir, { recursive: true, force: true });
});
await t.test('fresh database is initialized at version 2', async () => {
await provider.initialize();
const version = await provider.getSchemaVersion();
assert.equal(version, 2, 'Schema version must be 2');
const cols = provider.db.pragma('table_info("posterminal")').map((c) => c.name);
assert.ok(cols.includes('sort'), 'posterminal must have sort column');
assert.ok(cols.includes('enabled'), 'posterminal must have enabled column');
assert.ok(cols.includes('clientversion'), 'posterminal must have clientversion column');
});
let tenant;
let store;
let terminalId;
await t.test('posterminal store operations support sort, enabled, clientversion', async () => {
store = await new LibDashStore(provider);
tenant = await createTenant(store, { name: 'Migration Tenant' });
const created = await store.posterminal.upsert(tenant.googleAuthId, {
name: 'Terminal 1',
sort: 5,
enabled: true,
});
assert.equal(created.name, 'Terminal 1');
assert.equal(created.sort, 5);
assert.equal(created.enabled, 1);
terminalId = created.id;
// setClientversion
const versionRes = await store.posterminal.setClientversion(terminalId, '2.1.0');
assert.equal(versionRes.clientversion, '2.1.0');
// list
const list = await store.posterminal.list(tenant.googleAuthId);
assert.equal(list.length, 1);
assert.equal(list[0].sort, 5);
assert.equal(list[0].enabled, 1);
assert.equal(list[0].clientversion, '2.1.0');
// patch update (clientversion is not updated via upsert; existing value is preserved)
const updated = await store.posterminal.upsert(tenant.googleAuthId, {
id: terminalId,
sort: 10,
enabled: false,
});
assert.equal(updated.sort, 10);
assert.equal(updated.enabled, 0);
assert.equal(updated.clientversion, '2.1.0');
// pair with clientversion
const paired = await store.posterminal.pair(terminalId, {
token: 'tok_abc123',
clientserial: 'POS-Device-1',
pairedAt: '2026-10-29T03:00:00.000Z',
clientversion: '3.0.0-beta',
});
assert.equal(paired.token, 'tok_abc123');
assert.equal(paired.clientversion, '3.0.0-beta');
const afterPair = await store.posterminal.findByToken('tok_abc123');
assert.equal(afterPair.clientversion, '3.0.0-beta');
});
await t.test('downgrade from version 2 to version 1', async () => {
const steps = await provider.downgradeSchema(1);
assert.equal(steps, 1);
const version = await provider.getSchemaVersion();
assert.equal(version, 1);
const cols = provider.db.pragma('table_info("posterminal")').map((c) => c.name);
assert.ok(!cols.includes('sort'), 'sort column should be removed');
assert.ok(!cols.includes('enabled'), 'enabled column should be removed');
assert.ok(!cols.includes('clientversion'), 'clientversion column should be removed');
});
await t.test('upgrade from version 1 back to version 2 on re-initialize', async () => {
await provider.initialize();
const version = await provider.getSchemaVersion();
assert.equal(version, 2);
const cols = provider.db.pragma('table_info("posterminal")').map((c) => c.name);
assert.ok(cols.includes('sort'), 'sort column should be restored');
assert.ok(cols.includes('enabled'), 'enabled column should be restored');
assert.ok(cols.includes('clientversion'), 'clientversion column should be restored');
});
});