2026-05-09 23:09:13 +00:00
|
|
|
|
/**
|
|
|
|
|
|
* service-store — the only service that opens the sqlite database.
|
|
|
|
|
|
*
|
|
|
|
|
|
* Architecture choice (v0.1):
|
|
|
|
|
|
* For now, other services hold a typed reference to this plugin's
|
|
|
|
|
|
* `Repository` instance via constructor injection (BSB plugin clients).
|
|
|
|
|
|
* We expose the high-level data API as plain methods rather than wiring
|
|
|
|
|
|
* every CRUD operation as a typed BSB event.
|
|
|
|
|
|
*
|
|
|
|
|
|
* Reason: 60+ tables × 4 operations × 2 (input + output) anyvali schemas
|
|
|
|
|
|
* would be ~2000 lines of declarative bus plumbing. The event bus pays off
|
|
|
|
|
|
* when calls cross processes; in v0.1 everything is single-process.
|
|
|
|
|
|
*
|
|
|
|
|
|
* When we scale `service-coordinator-ws` to multiple instances (one per N
|
|
|
|
|
|
* kiosks), we'll graduate the hot-path operations (bundle lookup, label
|
|
|
|
|
|
* filter) to typed returnable events and keep the rest as direct calls.
|
|
|
|
|
|
*
|
|
|
|
|
|
* To-then: emit a domain-event broadcast on every write so listeners
|
|
|
|
|
|
* (e.g. coordinator-ws notifying kiosks of bundle changes) can react.
|
|
|
|
|
|
*/
|
|
|
|
|
|
import { DatabaseSync, type StatementSync } from "node:sqlite";
|
|
|
|
|
|
import { dirname } from "node:path";
|
|
|
|
|
|
import { mkdirSync } from "node:fs";
|
|
|
|
|
|
|
|
|
|
|
|
import * as av from "@anyvali/js";
|
|
|
|
|
|
import {
|
|
|
|
|
|
BSBService,
|
|
|
|
|
|
type BSBServiceConstructor,
|
|
|
|
|
|
createConfigSchema,
|
|
|
|
|
|
createEventSchemas,
|
|
|
|
|
|
createBroadcastEvent,
|
|
|
|
|
|
type Observable,
|
|
|
|
|
|
} from "@bsb/base";
|
|
|
|
|
|
|
|
|
|
|
|
import { MIGRATIONS } from "./migrations.js";
|
|
|
|
|
|
import { Repository } from "./repository.js";
|
refactor: collapse 6 non-service plugins into shared modules
BSB plugins should be actual services (own port, lifecycle, resource
ownership). Moved secrets, auth, pairing, bundle, nodered-bridge, and
cec-relay from plugin folders to shared modules under server/src/shared/.
4 BSB plugins remain: service-store, service-admin-http,
service-api-http, service-coordinator-ws.
service-admin-http now initializes secrets + auth as plain modules in
init() using the store repo from the plugin-registry singleton. No
more setSiblings() hack or inter-plugin wiring.
sec-config.yaml updated: secrets/auth config moved into
service-admin-http, pairing config into service-api-http, nodered
config into service-coordinator-ws.
2026-05-10 00:29:25 +00:00
|
|
|
|
import { registerRepo } from "../../shared/plugin-registry.js";
|
2026-05-14 05:33:10 +00:00
|
|
|
|
import { envStr } from "../../shared/env-overrides.js";
|
2026-05-09 23:09:13 +00:00
|
|
|
|
|
|
|
|
|
|
// ---- Config -----------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
const ConfigSchema = av.object(
|
|
|
|
|
|
{
|
2026-05-18 20:50:48 +00:00
|
|
|
|
/** Backend selector. Override at runtime via BF_DB env. */
|
|
|
|
|
|
driver: av.enum_(["sqlite", "postgres"] as const).default("sqlite"),
|
|
|
|
|
|
/** sqlite-only: filesystem path to the .db file. */
|
2026-05-09 23:09:13 +00:00
|
|
|
|
sqlitePath: av.string().minLength(1).default("/var/lib/betterframe/betterframe.db"),
|
2026-05-18 20:50:48 +00:00
|
|
|
|
/** postgres-only: full libpq URL. Override via BF_PG_URL env. */
|
|
|
|
|
|
pgUrl: av.string().default(""),
|
2026-05-09 23:09:13 +00:00
|
|
|
|
},
|
|
|
|
|
|
{ unknownKeys: "strip" },
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
export const Config = createConfigSchema(
|
|
|
|
|
|
{
|
|
|
|
|
|
name: "service-store",
|
|
|
|
|
|
description:
|
|
|
|
|
|
"BetterFrame canonical SQLite store. The single writer in the system; " +
|
|
|
|
|
|
"all other services read/write through this plugin.",
|
|
|
|
|
|
tags: ["service", "store", "sqlite"],
|
|
|
|
|
|
},
|
|
|
|
|
|
ConfigSchema,
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
// ---- Event schemas ----------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
const broadcastDomainChange = av.object(
|
|
|
|
|
|
{
|
|
|
|
|
|
table: av.string(),
|
|
|
|
|
|
op: av.enum_(["create", "update", "delete"] as const),
|
|
|
|
|
|
id: av.optional(av.union([av.string(), av.int()])),
|
|
|
|
|
|
},
|
|
|
|
|
|
{ unknownKeys: "reject" },
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
export const EventSchemas = createEventSchemas({
|
|
|
|
|
|
emitEvents: {},
|
|
|
|
|
|
onEvents: {},
|
|
|
|
|
|
emitReturnableEvents: {},
|
|
|
|
|
|
onReturnableEvents: {},
|
|
|
|
|
|
emitBroadcast: {
|
|
|
|
|
|
"store.changed": createBroadcastEvent(broadcastDomainChange, "Domain row changed"),
|
|
|
|
|
|
},
|
|
|
|
|
|
onBroadcast: {},
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// ---- Plugin -----------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
export class Plugin extends BSBService<InstanceType<typeof Config>, typeof EventSchemas> {
|
|
|
|
|
|
static override Config = Config;
|
|
|
|
|
|
static override EventSchemas = EventSchemas;
|
|
|
|
|
|
|
|
|
|
|
|
initBeforePlugins?: string[];
|
|
|
|
|
|
initAfterPlugins?: string[];
|
|
|
|
|
|
runBeforePlugins?: string[];
|
|
|
|
|
|
runAfterPlugins?: string[];
|
|
|
|
|
|
|
|
|
|
|
|
private db?: DatabaseSync;
|
|
|
|
|
|
private _repo?: Repository;
|
2026-05-21 09:34:29 +00:00
|
|
|
|
private purgeTimer?: ReturnType<typeof setInterval>;
|
2026-05-09 23:09:13 +00:00
|
|
|
|
|
|
|
|
|
|
constructor(cfg: BSBServiceConstructor<InstanceType<typeof Config>, typeof EventSchemas>) {
|
|
|
|
|
|
super(cfg);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async init(obs: Observable): Promise<void> {
|
2026-05-18 20:50:48 +00:00
|
|
|
|
const driver = envStr("BF_DB", this.config.driver) as "sqlite" | "postgres";
|
|
|
|
|
|
|
2026-05-23 00:13:28 +00:00
|
|
|
|
if (driver === "postgres") {
|
|
|
|
|
|
const pgUrl = envStr("BF_PG_URL", this.config.pgUrl ?? "");
|
|
|
|
|
|
if (!pgUrl) throw new Error("BF_DB=postgres requires BF_PG_URL");
|
|
|
|
|
|
obs.log.info("connecting to postgres at {url}", { url: pgUrl.replace(/:[^:@]+@/, ":***@") });
|
|
|
|
|
|
|
|
|
|
|
|
const { PgAdapter } = await import("./pg-adapter.js");
|
|
|
|
|
|
const adapter = new PgAdapter(pgUrl);
|
|
|
|
|
|
|
|
|
|
|
|
// Run PG migrations. Track version in schema_migrations table.
|
|
|
|
|
|
const { TENANT_MIGRATIONS } = await import("./migrations-pg.js");
|
|
|
|
|
|
const versionRow = await adapter.get<{ version: number }>(
|
|
|
|
|
|
`SELECT COALESCE(MAX(version), 0) AS version FROM schema_migrations WHERE schema_name = 'public'`,
|
|
|
|
|
|
).catch(() => undefined);
|
|
|
|
|
|
const currentVersion = versionRow?.version ?? 0;
|
|
|
|
|
|
if (currentVersion < TENANT_MIGRATIONS.length) {
|
|
|
|
|
|
obs.log.info("running PG migrations from {from} to {to}", {
|
|
|
|
|
|
from: currentVersion,
|
|
|
|
|
|
to: TENANT_MIGRATIONS.length,
|
|
|
|
|
|
});
|
|
|
|
|
|
// Ensure schema_migrations exists (bootstrap).
|
|
|
|
|
|
await adapter.exec(`CREATE TABLE IF NOT EXISTS schema_migrations (
|
|
|
|
|
|
schema_name TEXT NOT NULL, version INTEGER NOT NULL,
|
|
|
|
|
|
applied_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
|
|
|
|
PRIMARY KEY (schema_name, version)
|
|
|
|
|
|
)`);
|
|
|
|
|
|
for (let i = currentVersion; i < TENANT_MIGRATIONS.length; i++) {
|
|
|
|
|
|
await adapter.exec(TENANT_MIGRATIONS[i]!);
|
|
|
|
|
|
await adapter.run(
|
|
|
|
|
|
`INSERT INTO schema_migrations (schema_name, version) VALUES ('public', ?)`,
|
|
|
|
|
|
[i + 1],
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
} else {
|
|
|
|
|
|
obs.log.info("PG schema up to date (version {v})", { v: currentVersion });
|
|
|
|
|
|
}
|
2026-05-09 23:09:13 +00:00
|
|
|
|
|
2026-05-23 00:13:28 +00:00
|
|
|
|
this._repo = new Repository(adapter, async (table, op, id) => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
await this.events.emitBroadcast("store.changed", obs, { table, op, id });
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
obs.log.warn("broadcast store.changed failed: {err}", {
|
|
|
|
|
|
err: (err as Error).message,
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
2026-05-09 23:09:13 +00:00
|
|
|
|
});
|
2026-05-23 00:13:28 +00:00
|
|
|
|
} else {
|
|
|
|
|
|
// SQLite path (default).
|
|
|
|
|
|
const path = envStr("BF_SQLITE_PATH", this.config.sqlitePath);
|
|
|
|
|
|
obs.log.info("opening sqlite at {path}", { path });
|
2026-05-09 23:09:13 +00:00
|
|
|
|
|
2026-05-23 00:13:28 +00:00
|
|
|
|
try {
|
|
|
|
|
|
mkdirSync(dirname(path), { recursive: true });
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
obs.log.warn("mkdir failed for {dir}: {err}", {
|
|
|
|
|
|
dir: dirname(path),
|
|
|
|
|
|
err: (err as Error).message,
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
2026-05-09 23:09:13 +00:00
|
|
|
|
|
2026-05-23 00:13:28 +00:00
|
|
|
|
this.db = new DatabaseSync(path);
|
|
|
|
|
|
this.db.exec("PRAGMA journal_mode = WAL");
|
|
|
|
|
|
this.db.exec("PRAGMA synchronous = NORMAL");
|
|
|
|
|
|
this.db.exec("PRAGMA foreign_keys = ON");
|
|
|
|
|
|
this.db.exec("PRAGMA busy_timeout = 10000");
|
2026-05-09 23:09:13 +00:00
|
|
|
|
|
2026-05-23 00:13:28 +00:00
|
|
|
|
const row = this.db.prepare("PRAGMA user_version").get() as { user_version: number };
|
|
|
|
|
|
const currentVersion = row.user_version;
|
|
|
|
|
|
const targetVersion = MIGRATIONS.length;
|
2026-05-10 20:18:03 +00:00
|
|
|
|
|
2026-05-23 00:13:28 +00:00
|
|
|
|
if (currentVersion < targetVersion) {
|
|
|
|
|
|
obs.log.info("running migrations from {from} to {to}", {
|
|
|
|
|
|
from: currentVersion,
|
|
|
|
|
|
to: targetVersion,
|
|
|
|
|
|
});
|
|
|
|
|
|
for (let i = currentVersion; i < targetVersion; i++) {
|
|
|
|
|
|
const entry = MIGRATIONS[i];
|
|
|
|
|
|
if (typeof entry === "string") {
|
|
|
|
|
|
this.db.exec(entry);
|
|
|
|
|
|
} else if (typeof entry === "function") {
|
|
|
|
|
|
entry(this.db);
|
|
|
|
|
|
}
|
2026-05-10 20:18:03 +00:00
|
|
|
|
}
|
2026-05-23 00:13:28 +00:00
|
|
|
|
this.db.exec(`PRAGMA user_version = ${targetVersion}`);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
obs.log.info("schema up to date (version {v})", { v: currentVersion });
|
2026-05-10 19:39:09 +00:00
|
|
|
|
}
|
2026-05-09 23:09:13 +00:00
|
|
|
|
|
2026-05-23 00:13:28 +00:00
|
|
|
|
const { SqliteAdapter } = await import("./sqlite-adapter.js");
|
|
|
|
|
|
const adapter = SqliteAdapter.fromExisting(this.db);
|
2026-05-23 00:07:44 +00:00
|
|
|
|
|
2026-05-23 00:13:28 +00:00
|
|
|
|
this._repo = new Repository(adapter, async (table, op, id) => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
await this.events.emitBroadcast("store.changed", obs, { table, op, id });
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
obs.log.warn("broadcast store.changed failed: {err}", {
|
|
|
|
|
|
err: (err as Error).message,
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
2026-05-09 23:09:13 +00:00
|
|
|
|
|
refactor: collapse 6 non-service plugins into shared modules
BSB plugins should be actual services (own port, lifecycle, resource
ownership). Moved secrets, auth, pairing, bundle, nodered-bridge, and
cec-relay from plugin folders to shared modules under server/src/shared/.
4 BSB plugins remain: service-store, service-admin-http,
service-api-http, service-coordinator-ws.
service-admin-http now initializes secrets + auth as plain modules in
init() using the store repo from the plugin-registry singleton. No
more setSiblings() hack or inter-plugin wiring.
sec-config.yaml updated: secrets/auth config moved into
service-admin-http, pairing config into service-api-http, nodered
config into service-coordinator-ws.
2026-05-10 00:29:25 +00:00
|
|
|
|
registerRepo(this._repo);
|
2026-05-21 09:34:29 +00:00
|
|
|
|
|
2026-05-22 23:30:26 +00:00
|
|
|
|
// Startup purge
|
|
|
|
|
|
this.runPurge(obs);
|
2026-05-21 09:34:29 +00:00
|
|
|
|
|
2026-05-09 23:09:13 +00:00
|
|
|
|
obs.log.info("store ready");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-23 00:07:44 +00:00
|
|
|
|
private async runPurge(obs: Observable): Promise<void> {
|
2026-05-22 23:30:26 +00:00
|
|
|
|
if (!this._repo) return;
|
|
|
|
|
|
const r = this._repo;
|
2026-05-23 00:07:44 +00:00
|
|
|
|
const kl = await r.purgeKioskLogs(14);
|
|
|
|
|
|
const el = await r.purgeEventLog(30, 100_000);
|
|
|
|
|
|
const al = await r.purgeAuditLog(90);
|
2026-05-22 23:30:26 +00:00
|
|
|
|
if (kl + el + al > 0) {
|
|
|
|
|
|
obs.log.info("purge: {kl} kiosk_logs, {el} event_log, {al} audit_log", { kl, el, al });
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-21 09:34:29 +00:00
|
|
|
|
async run(obs: Observable): Promise<void> {
|
2026-05-22 23:30:26 +00:00
|
|
|
|
// Purge every 6 hours.
|
|
|
|
|
|
this.purgeTimer = setInterval(() => this.runPurge(obs), 6 * 60 * 60 * 1000);
|
2026-05-09 23:09:13 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async dispose(): Promise<void> {
|
2026-05-21 09:34:29 +00:00
|
|
|
|
if (this.purgeTimer) clearInterval(this.purgeTimer);
|
2026-05-09 23:09:13 +00:00
|
|
|
|
this.db?.close();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Public accessor for sibling services. Throws before init() completes —
|
|
|
|
|
|
* services that need the repo should declare their initAfterPlugins
|
|
|
|
|
|
* dependency on `service-store`.
|
|
|
|
|
|
*/
|
|
|
|
|
|
get repo(): Repository {
|
|
|
|
|
|
if (!this._repo) {
|
|
|
|
|
|
throw new Error("service-store: repository accessed before init()");
|
|
|
|
|
|
}
|
|
|
|
|
|
return this._repo;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|