u
This commit is contained in:
@@ -20,6 +20,7 @@
|
|||||||
"init-schema": "node scripts/init-schema.js",
|
"init-schema": "node scripts/init-schema.js",
|
||||||
"purge-db": "node scripts/purge-db.js",
|
"purge-db": "node scripts/purge-db.js",
|
||||||
"reset-db": "node scripts/reset-db.js",
|
"reset-db": "node scripts/reset-db.js",
|
||||||
|
"downgrade": "node scripts/downgrade.js",
|
||||||
"test": "node --test \"test/*.test.js\"",
|
"test": "node --test \"test/*.test.js\"",
|
||||||
"test:api": "node scripts/test-api.js",
|
"test:api": "node scripts/test-api.js",
|
||||||
"test:benchmark": "node scripts/benchmark.js"
|
"test:benchmark": "node scripts/benchmark.js"
|
||||||
|
|||||||
54
scripts/downgrade.js
Normal file
54
scripts/downgrade.js
Normal 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();
|
||||||
|
}
|
||||||
|
|
||||||
@@ -149,6 +149,7 @@ import posterminalGetCategoryList from './store/posterminal/getCategoryList.js';
|
|||||||
import posterminalGetProductList from './store/posterminal/getProductList.js';
|
import posterminalGetProductList from './store/posterminal/getProductList.js';
|
||||||
import posterminalSetMaxKnownObituaryId from './store/posterminal/setMaxKnownObituaryId.js';
|
import posterminalSetMaxKnownObituaryId from './store/posterminal/setMaxKnownObituaryId.js';
|
||||||
import posterminalTouchLastseen from './store/posterminal/touchLastseen.js';
|
import posterminalTouchLastseen from './store/posterminal/touchLastseen.js';
|
||||||
|
import posterminalSetClientversion from './store/posterminal/setClientversion.js';
|
||||||
import langCreate from './store/lang/create.js';
|
import langCreate from './store/lang/create.js';
|
||||||
import langDelete from './store/lang/delete.js';
|
import langDelete from './store/lang/delete.js';
|
||||||
import langList from './store/lang/list.js';
|
import langList from './store/lang/list.js';
|
||||||
@@ -535,6 +536,7 @@ export default class LibDashStore {
|
|||||||
getProductList: posterminalGetProductList(provider),
|
getProductList: posterminalGetProductList(provider),
|
||||||
setMaxKnownObituaryId: posterminalSetMaxKnownObituaryId(provider),
|
setMaxKnownObituaryId: posterminalSetMaxKnownObituaryId(provider),
|
||||||
touchLastseen: posterminalTouchLastseen(provider),
|
touchLastseen: posterminalTouchLastseen(provider),
|
||||||
|
setClientversion: posterminalSetClientversion(provider),
|
||||||
};
|
};
|
||||||
|
|
||||||
this.account = {
|
this.account = {
|
||||||
|
|||||||
15
src/providers/mssql/downgrade/2_to_1.js
Normal file
15
src/providers/mssql/downgrade/2_to_1.js
Normal 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);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -9,6 +9,7 @@ import { mkdirSync, promises as fsPromises } from 'node:fs';
|
|||||||
import sql from 'mssql';
|
import sql from 'mssql';
|
||||||
import { logSlowQuery } from '../provider.js';
|
import { logSlowQuery } from '../provider.js';
|
||||||
import { safeWrite, safeReadByHash, safeDeleteByHash } from '../storage.js';
|
import { safeWrite, safeReadByHash, safeDeleteByHash } from '../storage.js';
|
||||||
|
import { upgradeSchema, downgradeSchema, readSchemaVersion } from './migrate.js';
|
||||||
|
|
||||||
function normalizeRows(result) {
|
function normalizeRows(result) {
|
||||||
const recordset = result?.recordset ?? [];
|
const recordset = result?.recordset ?? [];
|
||||||
@@ -306,6 +307,9 @@ export class MssqlProvider {
|
|||||||
|
|
||||||
async initialize() {
|
async initialize() {
|
||||||
const pool = await this.ensurePool();
|
const pool = await this.ensurePool();
|
||||||
|
// Versioned upgrades first, so schema.js never meets an outdated table shape.
|
||||||
|
await upgradeSchema(this);
|
||||||
|
|
||||||
const { SCHEMA_SCRIPTS } = await import('./schema.js');
|
const { SCHEMA_SCRIPTS } = await import('./schema.js');
|
||||||
for (const script of SCHEMA_SCRIPTS) {
|
for (const script of SCHEMA_SCRIPTS) {
|
||||||
if (script && script.trim()) {
|
if (script && script.trim()) {
|
||||||
@@ -371,6 +375,24 @@ export class MssqlProvider {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Current `meta.version`, or `null` if the database has none.
|
||||||
|
* @returns {Promise<number|null>}
|
||||||
|
*/
|
||||||
|
async getSchemaVersion() {
|
||||||
|
return readSchemaVersion((sql, params) => this.query(sql, params));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runs downgrade steps down to `target`. CLI only (`npm run downgrade`).
|
||||||
|
* @param {number} target
|
||||||
|
* @param {(message: string) => void} [log]
|
||||||
|
* @returns {Promise<number>} Steps applied.
|
||||||
|
*/
|
||||||
|
async downgradeSchema(target, log) {
|
||||||
|
return downgradeSchema(this, target, log);
|
||||||
|
}
|
||||||
|
|
||||||
close() {
|
close() {
|
||||||
return this.pool ? this.pool.close() : Promise.resolve();
|
return this.pool ? this.pool.close() : Promise.resolve();
|
||||||
}
|
}
|
||||||
|
|||||||
122
src/providers/mssql/migrate.js
Normal file
122
src/providers/mssql/migrate.js
Normal 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
@@ -10,10 +10,13 @@ SELECT
|
|||||||
root_category_id AS rootCategoryId,
|
root_category_id AS rootCategoryId,
|
||||||
sync_root_articles AS syncRootArticles,
|
sync_root_articles AS syncRootArticles,
|
||||||
lastseen_at AS lastseenAt,
|
lastseen_at AS lastseenAt,
|
||||||
updated_at AS updatedAt
|
updated_at AS updatedAt,
|
||||||
|
sort,
|
||||||
|
enabled,
|
||||||
|
clientversion
|
||||||
FROM posterminal
|
FROM posterminal
|
||||||
WHERE company_id = @companyId
|
WHERE company_id = @companyId
|
||||||
ORDER BY id
|
ORDER BY sort, id
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export const GET_BY_ID = `
|
export const GET_BY_ID = `
|
||||||
@@ -29,7 +32,10 @@ SELECT
|
|||||||
root_category_id AS rootCategoryId,
|
root_category_id AS rootCategoryId,
|
||||||
sync_root_articles AS syncRootArticles,
|
sync_root_articles AS syncRootArticles,
|
||||||
lastseen_at AS lastseenAt,
|
lastseen_at AS lastseenAt,
|
||||||
updated_at AS updatedAt
|
updated_at AS updatedAt,
|
||||||
|
sort,
|
||||||
|
enabled,
|
||||||
|
clientversion
|
||||||
FROM posterminal
|
FROM posterminal
|
||||||
WHERE id = @id
|
WHERE id = @id
|
||||||
`;
|
`;
|
||||||
@@ -47,7 +53,10 @@ SELECT
|
|||||||
root_category_id AS rootCategoryId,
|
root_category_id AS rootCategoryId,
|
||||||
sync_root_articles AS syncRootArticles,
|
sync_root_articles AS syncRootArticles,
|
||||||
lastseen_at AS lastseenAt,
|
lastseen_at AS lastseenAt,
|
||||||
updated_at AS updatedAt
|
updated_at AS updatedAt,
|
||||||
|
sort,
|
||||||
|
enabled,
|
||||||
|
clientversion
|
||||||
FROM posterminal
|
FROM posterminal
|
||||||
WHERE pairingpin = @pairingpin
|
WHERE pairingpin = @pairingpin
|
||||||
`;
|
`;
|
||||||
@@ -59,17 +68,23 @@ WHERE id = @id
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
export const CREATE = `
|
export const CREATE = `
|
||||||
INSERT INTO posterminal (company_id, name, token, pairingpin, paired_at, clientserial, maxknownobituaryid, root_category_id, sync_root_articles, lastseen_at, updated_at)
|
INSERT INTO posterminal (company_id, name, token, pairingpin, paired_at, clientserial, maxknownobituaryid, root_category_id, sync_root_articles, lastseen_at, sort, enabled, updated_at)
|
||||||
OUTPUT INSERTED.id, INSERTED.name, INSERTED.token, INSERTED.pairingpin, INSERTED.paired_at AS pairedAt, INSERTED.clientserial AS clientserial, INSERTED.maxknownobituaryid AS maxknownobituaryid, INSERTED.root_category_id AS rootCategoryId, INSERTED.sync_root_articles AS syncRootArticles, INSERTED.lastseen_at AS lastseenAt, INSERTED.updated_at AS updatedAt
|
OUTPUT INSERTED.id, INSERTED.name, INSERTED.token, INSERTED.pairingpin, INSERTED.paired_at AS pairedAt, INSERTED.clientserial AS clientserial, INSERTED.maxknownobituaryid AS maxknownobituaryid, INSERTED.root_category_id AS rootCategoryId, INSERTED.sync_root_articles AS syncRootArticles, INSERTED.lastseen_at AS lastseenAt, INSERTED.updated_at AS updatedAt, INSERTED.sort, INSERTED.enabled, INSERTED.clientversion
|
||||||
VALUES (@companyId, @name, @token, @pairingpin, @pairedAt, @clientserial, @maxknownobituaryid, @rootCategoryId, @syncRootArticles, @lastseenAt, SYSUTCDATETIME())
|
VALUES (@companyId, @name, @token, @pairingpin, @pairedAt, @clientserial, @maxknownobituaryid, @rootCategoryId, @syncRootArticles, @lastseenAt, @sort, @enabled, SYSUTCDATETIME())
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export const UPDATE = `
|
export const UPDATE = `
|
||||||
UPDATE posterminal
|
UPDATE posterminal
|
||||||
SET name = @name, token = @token, pairingpin = @pairingpin, paired_at = @pairedAt, clientserial = @clientserial, maxknownobituaryid = @maxknownobituaryid, root_category_id = @rootCategoryId, sync_root_articles = @syncRootArticles, lastseen_at = @lastseenAt, updated_at = SYSUTCDATETIME()
|
SET name = @name, token = @token, pairingpin = @pairingpin, paired_at = @pairedAt, clientserial = @clientserial, maxknownobituaryid = @maxknownobituaryid, root_category_id = @rootCategoryId, sync_root_articles = @syncRootArticles, lastseen_at = @lastseenAt, sort = @sort, enabled = @enabled, updated_at = SYSUTCDATETIME()
|
||||||
WHERE id = @id AND company_id = @companyId
|
WHERE id = @id AND company_id = @companyId
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
export const SET_CLIENTVERSION = `
|
||||||
|
UPDATE posterminal
|
||||||
|
SET clientversion = @clientversion, updated_at = SYSUTCDATETIME()
|
||||||
|
WHERE id = @id
|
||||||
|
`;
|
||||||
|
|
||||||
export const TOUCH_LASTSEEN = `
|
export const TOUCH_LASTSEEN = `
|
||||||
UPDATE posterminal
|
UPDATE posterminal
|
||||||
SET lastseen_at = SYSUTCDATETIME()
|
SET lastseen_at = SYSUTCDATETIME()
|
||||||
@@ -166,7 +181,10 @@ SELECT
|
|||||||
root_category_id AS rootCategoryId,
|
root_category_id AS rootCategoryId,
|
||||||
sync_root_articles AS syncRootArticles,
|
sync_root_articles AS syncRootArticles,
|
||||||
lastseen_at AS lastseenAt,
|
lastseen_at AS lastseenAt,
|
||||||
updated_at AS updatedAt
|
updated_at AS updatedAt,
|
||||||
|
sort,
|
||||||
|
enabled,
|
||||||
|
clientversion
|
||||||
FROM posterminal
|
FROM posterminal
|
||||||
WHERE token = @token
|
WHERE token = @token
|
||||||
`;
|
`;
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { SCHEMA_VERSION } from './migrate.js';
|
||||||
|
|
||||||
export const INIT_META = `
|
export const INIT_META = `
|
||||||
IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = 'meta')
|
IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = 'meta')
|
||||||
BEGIN
|
BEGIN
|
||||||
@@ -11,7 +13,7 @@ END
|
|||||||
IF NOT EXISTS (SELECT 1 FROM meta WHERE [key] = N'version')
|
IF NOT EXISTS (SELECT 1 FROM meta WHERE [key] = N'version')
|
||||||
BEGIN
|
BEGIN
|
||||||
INSERT INTO meta ([key], value)
|
INSERT INTO meta ([key], value)
|
||||||
VALUES (N'version', N'1');
|
VALUES (N'version', N'${SCHEMA_VERSION}');
|
||||||
END
|
END
|
||||||
`;
|
`;
|
||||||
|
|
||||||
@@ -966,6 +968,9 @@ BEGIN
|
|||||||
sync_root_articles BIT NOT NULL CONSTRAINT DF_posterminal_sync_root_articles DEFAULT 1,
|
sync_root_articles BIT NOT NULL CONSTRAINT DF_posterminal_sync_root_articles DEFAULT 1,
|
||||||
lastseen_at DATETIME2 NULL,
|
lastseen_at DATETIME2 NULL,
|
||||||
updated_at DATETIME2 NOT NULL CONSTRAINT DF_posterminal_updated_at DEFAULT SYSUTCDATETIME(),
|
updated_at DATETIME2 NOT NULL CONSTRAINT DF_posterminal_updated_at DEFAULT SYSUTCDATETIME(),
|
||||||
|
sort INT NOT NULL CONSTRAINT DF_posterminal_sort DEFAULT 0,
|
||||||
|
enabled BIT NOT NULL CONSTRAINT DF_posterminal_enabled DEFAULT 1,
|
||||||
|
clientversion NVARCHAR(255) NULL,
|
||||||
CONSTRAINT FK_posterminal_company FOREIGN KEY (company_id) REFERENCES company(id) ON DELETE CASCADE,
|
CONSTRAINT FK_posterminal_company FOREIGN KEY (company_id) REFERENCES company(id) ON DELETE CASCADE,
|
||||||
CONSTRAINT FK_posterminal_root_category FOREIGN KEY (root_category_id) REFERENCES category(id) ON DELETE SET NULL
|
CONSTRAINT FK_posterminal_root_category FOREIGN KEY (root_category_id) REFERENCES category(id) ON DELETE SET NULL
|
||||||
);
|
);
|
||||||
|
|||||||
17
src/providers/mssql/upgrade/1_to_2.js
Normal file
17
src/providers/mssql/upgrade/1_to_2.js
Normal 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);
|
||||||
|
}
|
||||||
|
|
||||||
184
src/providers/schemaMigrations.js
Normal file
184
src/providers/schemaMigrations.js
Normal 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}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
16
src/providers/sqlite3/downgrade/2_to_1.js
Normal file
16
src/providers/sqlite3/downgrade/2_to_1.js
Normal 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);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -9,6 +9,7 @@ import Database from 'better-sqlite3';
|
|||||||
import { AsyncLocalStorage } from 'node:async_hooks';
|
import { AsyncLocalStorage } from 'node:async_hooks';
|
||||||
import { logSlowQuery } from '../provider.js';
|
import { logSlowQuery } from '../provider.js';
|
||||||
import { SCHEMA_SCRIPTS } from './schema.js';
|
import { SCHEMA_SCRIPTS } from './schema.js';
|
||||||
|
import { upgradeSchema, downgradeSchema, readSchemaVersion } from './migrate.js';
|
||||||
|
|
||||||
const asyncTxStorage = new AsyncLocalStorage();
|
const asyncTxStorage = new AsyncLocalStorage();
|
||||||
|
|
||||||
@@ -359,6 +360,9 @@ export class BetterSqlite3Provider extends BetterSqlite3Executor {
|
|||||||
* @returns {Promise<void>}
|
* @returns {Promise<void>}
|
||||||
*/
|
*/
|
||||||
async initialize() {
|
async initialize() {
|
||||||
|
// Versioned upgrades first, so schema.js never meets an outdated table shape.
|
||||||
|
await upgradeSchema(this.db);
|
||||||
|
|
||||||
for (const script of SCHEMA_SCRIPTS) {
|
for (const script of SCHEMA_SCRIPTS) {
|
||||||
if (script && script.trim()) {
|
if (script && script.trim()) {
|
||||||
this.db.exec(script);
|
this.db.exec(script);
|
||||||
@@ -475,6 +479,24 @@ export class BetterSqlite3Provider extends BetterSqlite3Executor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Explicitly closes the database. */
|
/** Explicitly closes the database. */
|
||||||
|
/**
|
||||||
|
* Current `meta.version`, or `null` if the database has none.
|
||||||
|
* @returns {Promise<number|null>}
|
||||||
|
*/
|
||||||
|
async getSchemaVersion() {
|
||||||
|
return readSchemaVersion(this.db);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runs downgrade steps down to `target`. CLI only (`npm run downgrade`).
|
||||||
|
* @param {number} target
|
||||||
|
* @param {(message: string) => void} [log]
|
||||||
|
* @returns {Promise<number>} Steps applied.
|
||||||
|
*/
|
||||||
|
async downgradeSchema(target, log) {
|
||||||
|
return downgradeSchema(this.db, target, log);
|
||||||
|
}
|
||||||
|
|
||||||
close() {
|
close() {
|
||||||
this.db.close();
|
this.db.close();
|
||||||
}
|
}
|
||||||
|
|||||||
121
src/providers/sqlite3/migrate.js
Normal file
121
src/providers/sqlite3/migrate.js
Normal 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
@@ -10,7 +10,7 @@ export const {
|
|||||||
COUNT_CUSTOMER_GROUPS, COUNT_COMPOSITE_PRODUCTS, MAX_ORDER_ID,
|
COUNT_CUSTOMER_GROUPS, COUNT_COMPOSITE_PRODUCTS, MAX_ORDER_ID,
|
||||||
LIST_POS_PRODUCT_CATEGORIES, LIST_POS_CUSTOMER_GROUP_IDS, LIST_POS_PRODUCT_PRICES,
|
LIST_POS_PRODUCT_CATEGORIES, LIST_POS_CUSTOMER_GROUP_IDS, LIST_POS_PRODUCT_PRICES,
|
||||||
LIST_POS_MANDATED_OPTIONS, LIST_POS_PARENT_OPTIONS,
|
LIST_POS_MANDATED_OPTIONS, LIST_POS_PARENT_OPTIONS,
|
||||||
TOUCH_LASTSEEN, RECORD_OBITUARY, RECORD_SYNC_QUEUE, SEED_SYNC_QUEUE_FOR_TERMINAL,
|
TOUCH_LASTSEEN, SET_CLIENTVERSION, RECORD_OBITUARY, RECORD_SYNC_QUEUE, SEED_SYNC_QUEUE_FOR_TERMINAL,
|
||||||
COUNT_DELETED_ENTITIES,
|
COUNT_DELETED_ENTITIES,
|
||||||
} = queries;
|
} = queries;
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { SCHEMA_VERSION } from './migrate.js';
|
||||||
|
|
||||||
const UTC_NOW = "strftime('%Y-%m-%dT%H:%M:%fZ', 'now')";
|
const UTC_NOW = "strftime('%Y-%m-%dT%H:%M:%fZ', 'now')";
|
||||||
|
|
||||||
export const INIT_META = `
|
export const INIT_META = `
|
||||||
@@ -5,7 +7,7 @@ CREATE TABLE IF NOT EXISTS meta (
|
|||||||
"key" TEXT PRIMARY KEY,
|
"key" TEXT PRIMARY KEY,
|
||||||
value TEXT NOT NULL
|
value TEXT NOT NULL
|
||||||
);
|
);
|
||||||
INSERT OR IGNORE INTO meta ("key", value) VALUES ('version', '1');
|
INSERT OR IGNORE INTO meta ("key", value) VALUES ('version', '${SCHEMA_VERSION}');
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export const INIT_LANGUAGE = `
|
export const INIT_LANGUAGE = `
|
||||||
@@ -629,6 +631,9 @@ CREATE TABLE IF NOT EXISTS posterminal (
|
|||||||
sync_root_articles INTEGER NOT NULL DEFAULT 1,
|
sync_root_articles INTEGER NOT NULL DEFAULT 1,
|
||||||
lastseen_at TEXT,
|
lastseen_at TEXT,
|
||||||
updated_at TEXT NOT NULL DEFAULT (${UTC_NOW}),
|
updated_at TEXT NOT NULL DEFAULT (${UTC_NOW}),
|
||||||
|
sort INTEGER NOT NULL DEFAULT 0,
|
||||||
|
enabled INTEGER NOT NULL DEFAULT 1,
|
||||||
|
clientversion TEXT,
|
||||||
FOREIGN KEY (company_id) REFERENCES company(id) ON DELETE CASCADE,
|
FOREIGN KEY (company_id) REFERENCES company(id) ON DELETE CASCADE,
|
||||||
FOREIGN KEY (root_category_id) REFERENCES category(id) ON DELETE SET NULL
|
FOREIGN KEY (root_category_id) REFERENCES category(id) ON DELETE SET NULL
|
||||||
);
|
);
|
||||||
|
|||||||
16
src/providers/sqlite3/upgrade/1_to_2.js
Normal file
16
src/providers/sqlite3/upgrade/1_to_2.js
Normal 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);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -10,7 +10,7 @@ export default function findByPairingpin(provider) {
|
|||||||
*
|
*
|
||||||
* @param {string} pairingpin - The pairing pin to search for.
|
* @param {string} pairingpin - The pairing pin to search for.
|
||||||
* @param {object} [tx] - Optional transaction to run within.
|
* @param {object} [tx] - Optional transaction to run within.
|
||||||
* @returns {Promise<{id: number, companyId: number, name: string, token: string|null, pairingpin: string|null, pairedAt: string|null, clientserial: string|null, maxknownobituaryid: number|null, rootCategoryId: number|null, syncRootArticles: boolean|number, lastseenAt: string|null, updatedAt: string}|null>} The posterminal, or null if not found.
|
* @returns {Promise<{id: number, companyId: number, name: string, token: string|null, pairingpin: string|null, pairedAt: string|null, clientserial: string|null, maxknownobituaryid: number|null, rootCategoryId: number|null, syncRootArticles: boolean|number, lastseenAt: string|null, updatedAt: string, sort: number, enabled: boolean|number, clientversion: string|null}|null>} The posterminal, or null if not found.
|
||||||
*/
|
*/
|
||||||
return async (pairingpin, tx) => {
|
return async (pairingpin, tx) => {
|
||||||
if (!pairingpin) return null;
|
if (!pairingpin) return null;
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ export default function findByToken(provider) {
|
|||||||
*
|
*
|
||||||
* @param {string} token - The auth token to search for.
|
* @param {string} token - The auth token to search for.
|
||||||
* @param {object} [tx] - Optional transaction to run within.
|
* @param {object} [tx] - Optional transaction to run within.
|
||||||
* @returns {Promise<{id: number, companyId: number, name: string, token: string|null, pairingpin: string|null, pairedAt: string|null, clientserial: string|null, maxknownobituaryid: number|null, rootCategoryId: number|null, syncRootArticles: boolean|number, lastseenAt: string|null, updatedAt: string}|null>} The posterminal, or null if not found.
|
* @returns {Promise<{id: number, companyId: number, name: string, token: string|null, pairingpin: string|null, pairedAt: string|null, clientserial: string|null, maxknownobituaryid: number|null, rootCategoryId: number|null, syncRootArticles: boolean|number, lastseenAt: string|null, updatedAt: string, sort: number, enabled: boolean|number, clientversion: string|null}|null>} The posterminal, or null if not found.
|
||||||
*/
|
*/
|
||||||
return async (token, tx) => {
|
return async (token, tx) => {
|
||||||
const trimmed = String(token ?? '').trim();
|
const trimmed = String(token ?? '').trim();
|
||||||
|
|||||||
@@ -12,11 +12,11 @@ export default function list(provider) {
|
|||||||
*
|
*
|
||||||
* @param {number} googleAuthId - The id of the Google auth user.
|
* @param {number} googleAuthId - The id of the Google auth user.
|
||||||
* @param {object} [tx] - Optional transaction to run within.
|
* @param {object} [tx] - Optional transaction to run within.
|
||||||
* @returns {Promise<Array<{id: number, name: string, token: string|null, pairingpin: string|null, pairedAt: string|null, clientserial: string|null, maxknownobituaryid: number|null, rootCategoryId: number|null, syncRootArticles: boolean|number, lastseenAt: string|null, updatedAt: string}>>} The posterminals.
|
* @returns {Promise<Array<{id: number, name: string, token: string|null, pairingpin: string|null, pairedAt: string|null, clientserial: string|null, maxknownobituaryid: number|null, rootCategoryId: number|null, syncRootArticles: boolean|number, lastseenAt: string|null, updatedAt: string, sort: number, enabled: boolean|number, clientversion: string|null}>>} The posterminals.
|
||||||
*
|
*
|
||||||
* @example
|
* @example
|
||||||
* const terminals = await store.posterminal.list(42);
|
* const terminals = await store.posterminal.list(42);
|
||||||
* // => [{ id, name, token, pairingpin, pairedAt, clientserial, maxknownobituaryid, rootCategoryId, syncRootArticles, lastseenAt, updatedAt }]
|
* // => [{ id, name, token, pairingpin, pairedAt, clientserial, maxknownobituaryid, rootCategoryId, syncRootArticles, lastseenAt, updatedAt, sort, enabled, clientversion }]
|
||||||
*/
|
*/
|
||||||
return async (googleAuthId, tx) => {
|
return async (googleAuthId, tx) => {
|
||||||
const { companyId } = await access(googleAuthId, tx);
|
const { companyId } = await access(googleAuthId, tx);
|
||||||
|
|||||||
26
src/store/posterminal/setClientversion.js
Normal file
26
src/store/posterminal/setClientversion.js
Normal 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 };
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
@@ -31,13 +31,15 @@ export default function upsert(provider) {
|
|||||||
* @param {string} input.pairingpin - The pairing pin.
|
* @param {string} input.pairingpin - The pairing pin.
|
||||||
* @param {string} [input.token] - Optional token.
|
* @param {string} [input.token] - Optional token.
|
||||||
* @param {number} [input.id] - Optional id to update an existing posterminal.
|
* @param {number} [input.id] - Optional id to update an existing posterminal.
|
||||||
|
* @param {number} [input.sort] - Display order (integer, ascending). Default 0 on create.
|
||||||
|
* @param {boolean} [input.enabled] - Whether the terminal may sync. Default true on create.
|
||||||
* @param {object} [tx] - Optional transaction to run within.
|
* @param {object} [tx] - Optional transaction to run within.
|
||||||
* @returns {Promise<{id: number, name: string, token: string, pairingpin: string}>} The upserted posterminal.
|
* @returns {Promise<{id: number, name: string, token: string, pairingpin: string, sort: number, enabled: number}>} The upserted posterminal.
|
||||||
* @throws {Error} If the posterminal is invalid.
|
* @throws {Error} If the posterminal is invalid.
|
||||||
*
|
*
|
||||||
* @example
|
* @example
|
||||||
* const terminal = await store.posterminal.upsert(42, { name: 'Kasse 1', pairingpin: '1234', token: 'abc' });
|
* const terminal = await store.posterminal.upsert(42, { name: 'Kasse 1', pairingpin: '1234', token: 'abc' });
|
||||||
* // => { id, name, token, pairingpin }
|
* // => { id, name, token, pairingpin, sort, enabled }
|
||||||
*/
|
*/
|
||||||
return async (googleAuthId, input = {}, tx) => {
|
return async (googleAuthId, input = {}, tx) => {
|
||||||
const id = input.id ?? null;
|
const id = input.id ?? null;
|
||||||
@@ -45,6 +47,12 @@ export default function upsert(provider) {
|
|||||||
if (name === '' || (!name && !id)) {
|
if (name === '' || (!name && !id)) {
|
||||||
throw new Error('Invalid posterminal');
|
throw new Error('Invalid posterminal');
|
||||||
}
|
}
|
||||||
|
if (input.sort !== undefined && !Number.isInteger(input.sort)) {
|
||||||
|
throw new Error('Invalid posterminal');
|
||||||
|
}
|
||||||
|
if (input.enabled !== undefined && typeof input.enabled !== 'boolean') {
|
||||||
|
throw new Error('Invalid posterminal');
|
||||||
|
}
|
||||||
const token = input.token ?? null;
|
const token = input.token ?? null;
|
||||||
const pairedAt = input.pairedAt ?? null;
|
const pairedAt = input.pairedAt ?? null;
|
||||||
const clientserial = input.clientserial ?? null;
|
const clientserial = input.clientserial ?? null;
|
||||||
@@ -61,6 +69,14 @@ export default function upsert(provider) {
|
|||||||
|
|
||||||
const nameVal = name !== undefined ? name : existing.rows[0].name;
|
const nameVal = name !== undefined ? name : existing.rows[0].name;
|
||||||
|
|
||||||
|
// Pairing fields are kept unless explicitly provided (PATCH must not unpair).
|
||||||
|
const tokenVal = input.token !== undefined ? token : (existing.rows[0].token ?? null);
|
||||||
|
const pairedAtVal = input.pairedAt !== undefined ? pairedAt : (existing.rows[0].pairedAt ?? null);
|
||||||
|
const clientserialVal = input.clientserial !== undefined ? clientserial : (existing.rows[0].clientserial ?? null);
|
||||||
|
|
||||||
|
const sort = input.sort !== undefined ? input.sort : existing.rows[0].sort;
|
||||||
|
const enabled = input.enabled !== undefined ? (input.enabled ? 1 : 0) : (existing.rows[0].enabled ? 1 : 0);
|
||||||
|
|
||||||
const pairingpin =
|
const pairingpin =
|
||||||
input.pairingpin !== undefined
|
input.pairingpin !== undefined
|
||||||
? (input.pairingpin ? String(input.pairingpin).trim() : null)
|
? (input.pairingpin ? String(input.pairingpin).trim() : null)
|
||||||
@@ -90,27 +106,32 @@ export default function upsert(provider) {
|
|||||||
id,
|
id,
|
||||||
companyId,
|
companyId,
|
||||||
name: nameVal,
|
name: nameVal,
|
||||||
token,
|
token: tokenVal,
|
||||||
pairingpin,
|
pairingpin,
|
||||||
pairedAt,
|
pairedAt: pairedAtVal,
|
||||||
clientserial,
|
clientserial: clientserialVal,
|
||||||
maxknownobituaryid,
|
maxknownobituaryid,
|
||||||
rootCategoryId,
|
rootCategoryId,
|
||||||
syncRootArticles,
|
syncRootArticles,
|
||||||
lastseenAt,
|
lastseenAt,
|
||||||
|
sort,
|
||||||
|
enabled,
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
name: nameVal,
|
name: nameVal,
|
||||||
token,
|
token: tokenVal,
|
||||||
pairingpin,
|
pairingpin,
|
||||||
pairedAt,
|
pairedAt: pairedAtVal,
|
||||||
clientserial,
|
clientserial: clientserialVal,
|
||||||
maxknownobituaryid,
|
maxknownobituaryid,
|
||||||
rootCategoryId,
|
rootCategoryId,
|
||||||
syncRootArticles,
|
syncRootArticles,
|
||||||
lastseenAt,
|
lastseenAt,
|
||||||
|
sort,
|
||||||
|
enabled,
|
||||||
|
clientversion: existing.rows[0].clientversion ?? null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -119,6 +140,8 @@ export default function upsert(provider) {
|
|||||||
const rootCategoryId = input.rootCategoryId != null ? Number(input.rootCategoryId) : null;
|
const rootCategoryId = input.rootCategoryId != null ? Number(input.rootCategoryId) : null;
|
||||||
const syncRootArticles = input.syncRootArticles !== undefined ? (input.syncRootArticles ? 1 : 0) : 1;
|
const syncRootArticles = input.syncRootArticles !== undefined ? (input.syncRootArticles ? 1 : 0) : 1;
|
||||||
const lastseenAt = input.lastseenAt ?? null;
|
const lastseenAt = input.lastseenAt ?? null;
|
||||||
|
const sort = input.sort ?? 0;
|
||||||
|
const enabled = input.enabled === false ? 0 : 1;
|
||||||
|
|
||||||
const created = await runner.query(createSql, {
|
const created = await runner.query(createSql, {
|
||||||
companyId,
|
companyId,
|
||||||
@@ -131,6 +154,8 @@ export default function upsert(provider) {
|
|||||||
rootCategoryId,
|
rootCategoryId,
|
||||||
syncRootArticles,
|
syncRootArticles,
|
||||||
lastseenAt,
|
lastseenAt,
|
||||||
|
sort,
|
||||||
|
enabled,
|
||||||
});
|
});
|
||||||
|
|
||||||
const row = created.rows[0] ?? {
|
const row = created.rows[0] ?? {
|
||||||
@@ -144,6 +169,9 @@ export default function upsert(provider) {
|
|||||||
rootCategoryId,
|
rootCategoryId,
|
||||||
syncRootArticles,
|
syncRootArticles,
|
||||||
lastseenAt,
|
lastseenAt,
|
||||||
|
sort,
|
||||||
|
enabled,
|
||||||
|
clientversion: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
await runner.query(seedSyncQueueSql, {
|
await runner.query(seedSyncQueueSql, {
|
||||||
@@ -163,6 +191,9 @@ export default function upsert(provider) {
|
|||||||
syncRootArticles: row.syncRootArticles !== undefined ? (row.syncRootArticles ? 1 : 0) : 1,
|
syncRootArticles: row.syncRootArticles !== undefined ? (row.syncRootArticles ? 1 : 0) : 1,
|
||||||
lastseenAt: row.lastseenAt ?? null,
|
lastseenAt: row.lastseenAt ?? null,
|
||||||
updatedAt: row.updatedAt ?? null,
|
updatedAt: row.updatedAt ?? null,
|
||||||
|
sort: row.sort ?? sort,
|
||||||
|
enabled: row.enabled !== undefined ? (row.enabled ? 1 : 0) : enabled,
|
||||||
|
clientversion: row.clientversion ?? null,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user