mirror of
https://github.com/BetterCorp/BetterFrame.git
synced 2026-05-26 16:56:33 +00:00
- Eliminated layout_templates as separate entity — regions/grid now live directly on layouts - Displays created from kiosk pairing (not standalone), each display has kiosk_id FK - Removed Templates from sidebar nav and all template routes/pages - Layout creation uses preset buttons (fullscreen, 2x2, 1+3, 3x3) that set regions directly on the layout - Setup no longer creates default display/layout (deferred to pairing) - Pairing creates HDMI-0 display for new kiosk - Bundle reads regions from layout directly, no template lookup - Rust kiosk updated to match new bundle format - DB migration adds regions/grid_cols/grid_rows to layouts, kiosk_id to displays, copies existing template data
166 lines
5.1 KiB
TypeScript
166 lines
5.1 KiB
TypeScript
/**
|
||
* 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";
|
||
import { registerRepo } from "../../shared/plugin-registry.js";
|
||
|
||
// ---- Config -----------------------------------------------------------------
|
||
|
||
const ConfigSchema = av.object(
|
||
{
|
||
sqlitePath: av.string().minLength(1).default("/var/lib/betterframe/betterframe.db"),
|
||
},
|
||
{ 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[];
|
||
|
||
// The DB handle and Repository are created in init() and exposed for
|
||
// sibling-service consumption.
|
||
private db?: DatabaseSync;
|
||
private _repo?: Repository;
|
||
|
||
constructor(cfg: BSBServiceConstructor<InstanceType<typeof Config>, typeof EventSchemas>) {
|
||
super(cfg);
|
||
}
|
||
|
||
async init(obs: Observable): Promise<void> {
|
||
const path = this.config.sqlitePath;
|
||
obs.log.info("opening sqlite at {path}", { path });
|
||
|
||
// Ensure parent dir exists (in dev BETTERFRAME_DATA_DIR may be in $HOME)
|
||
try {
|
||
mkdirSync(dirname(path), { recursive: true });
|
||
} catch (err) {
|
||
obs.log.warn("mkdir failed for {dir}: {err}", {
|
||
dir: dirname(path),
|
||
err: (err as Error).message,
|
||
});
|
||
}
|
||
|
||
this.db = new DatabaseSync(path);
|
||
|
||
// SQLite pragmas for an embedded one-writer setup
|
||
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");
|
||
|
||
obs.log.info("running {n} migrations", { n: MIGRATIONS.length });
|
||
for (const entry of MIGRATIONS) {
|
||
if (typeof entry === "string") {
|
||
this.db.exec(entry);
|
||
} else {
|
||
entry(this.db);
|
||
}
|
||
}
|
||
|
||
this._repo = new Repository(this.db, async (table, op, id) => {
|
||
// Best-effort broadcast — never let a failed event-bus call fail a write.
|
||
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,
|
||
});
|
||
}
|
||
});
|
||
|
||
registerRepo(this._repo);
|
||
obs.log.info("store ready");
|
||
}
|
||
|
||
async run(_obs: Observable): Promise<void> {
|
||
// Long-lived; no work in run().
|
||
}
|
||
|
||
async dispose(): Promise<void> {
|
||
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;
|
||
}
|
||
}
|