sc-theme-builder/lib/themeStore.js
2026-07-01 20:07:28 -05:00

308 lines
10 KiB
JavaScript

// The ONLY module that touches the themes Table or treats builtins as themes.
// All theme persistence (per-tenant Table CRUD) and the builtins-as-themes merge
// live here; everything above this layer speaks the canonical ThemeDTO and never
// sees a raw row. See ARCHITECTURE.md 4.1 (schema), 4.2 (builtins merge),
// 4.6 (multi-tenant), 6.6 (save CAS + delete semantics).
//
// ThemeDTO:
// { id, name, engine, tokens(object, normalized), layoutTree(object|null),
// version(>=1 rows, 0 builtins), builtin(bool), created_at?, updated_at? }
const crypto = require("crypto");
const db = require("@saltcorn/data/db");
const Table = require("@saltcorn/data/models/table");
const Field = require("@saltcorn/data/models/field");
const { THEME_TABLE, ENGINE } = require("./constants");
const { normalizeTokens } = require("./themeSchema");
const builtins = require("./builtins");
// --- column model (ARCHITECTURE.md 4.1) ------------------------------------
// id (serial pk) is created automatically by Table.create; the stable theme
// identity is the separate theme_id String column. JSON columns are String
// (text) because the base plugin registers no JSON type; the store does the
// stringify/parse itself.
const FIELD_DEFS = [
{ name: "theme_id", type: "String", required: true, is_unique: true },
{ name: "name", type: "String", required: true },
{ name: "engine", type: "String", required: true },
{ name: "tokens", type: "String", required: true },
{ name: "layout_tree", type: "String", required: false },
{ name: "version", type: "Integer", required: true },
{ name: "created_at", type: "Date", required: true },
{ name: "updated_at", type: "Date", required: true },
];
function nowIso() {
return new Date().toISOString();
}
// Coerce a stored timestamp (Date on PG, text on SQLite) to an ISO string.
function toIso(v) {
if (v == null) {
return undefined;
}
if (v instanceof Date) {
return v.toISOString();
}
return new Date(v).toISOString();
}
// Normalize a builtin's frozen definition into the canonical ThemeDTO. Builtins
// carry version 0, builtin:true, and no timestamps.
function builtinToTheme(b) {
return {
id: b.id,
name: b.name,
engine: b.engine || ENGINE,
tokens: normalizeTokens(b.tokens),
layoutTree: b.layoutTree || null,
version: 0,
builtin: true,
};
}
// Create the per-tenant Table once. Cheap fast-path when it already exists.
// All CRUD is tenant-implicit (the Table model schema-qualifies from
// async-local storage); ensureTable is iterated per tenant by onLoad.
async function ensureTableForCurrentTenant() {
const existing = Table.findOne({ name: THEME_TABLE });
if (existing) {
return existing;
}
const table = await Table.create(THEME_TABLE, { min_role_read: 1, min_role_write: 1 });
for (const f of FIELD_DEFS) {
await Field.create({
table: table,
name: f.name,
label: f.name,
type: f.type,
required: !!f.required,
is_unique: !!f.is_unique,
});
}
// Namespacing makes a collision impossible (row ids are uuid v4, builtin
// ids are "builtin:<slug>"); assert it so a stored "builtin:" id can never
// shadow a code-shipped builtin in list()/getById().
const rows = await db.select(THEME_TABLE, {});
const clash = rows.find((r) => typeof r.theme_id === "string" && r.theme_id.startsWith("builtin:"));
if (clash) {
throw new Error(`themeStore: stored theme_id may not start with "builtin:" (${clash.theme_id})`);
}
return table;
}
// Raw row (stored theme) -> canonical ThemeDTO. JSON columns are parsed here;
// tokens always pass through normalizeTokens so the compiler sees a current,
// fully-populated shape. builtin:false for every stored row.
function rowToTheme(row) {
let tokens = {};
try {
tokens = row.tokens ? JSON.parse(row.tokens) : {};
} catch (e) {
tokens = {};
}
let layoutTree = null;
if (row.layout_tree) {
try {
layoutTree = JSON.parse(row.layout_tree);
} catch (e) {
layoutTree = null;
}
}
return {
id: row.theme_id,
name: row.name,
engine: row.engine || ENGINE,
tokens: normalizeTokens(tokens),
layoutTree: layoutTree,
version: row.version,
builtin: false,
created_at: toIso(row.created_at),
updated_at: toIso(row.updated_at),
};
}
// All themes visible to the admin: stored rows PLUS the code-shipped builtins.
// Builtins are merged at read time so they appear even on a fresh, empty install.
async function list() {
const rows = await db.select(THEME_TABLE, {}, { orderBy: "name" });
const stored = rows.map(rowToTheme);
const builtinThemes = builtins.listBuiltins().map(builtinToTheme);
return [...stored, ...builtinThemes];
}
// Resolve a theme by its public id. SEAM FIX: builtin ids resolve too, so
// getThemeCss / cssCache.warm / activate handle ANY active theme uniformly
// (activating a builtin is allowed; ARCHITECTURE.md 4.2).
async function getById(id) {
if (builtins.isBuiltinId(id)) {
const b = builtins.get(id);
return b ? builtinToTheme(b) : null;
}
const row = await db.selectMaybeOne(THEME_TABLE, { theme_id: id });
return row ? rowToTheme(row) : null;
}
// App-enforced name uniqueness (no DB constraint). Returns the input name if
// free, else the first available "Name (2)", "Name (3)", ... suffix.
async function dedupName(name) {
const base = (name == null ? "" : String(name)).trim() || "Untitled";
const rows = await db.select(THEME_TABLE, {});
const taken = new Set(rows.map((r) => r.name));
if (!taken.has(base)) {
return base;
}
let n = 2;
while (taken.has(`${base} (${n})`)) {
n += 1;
}
return `${base} (${n})`;
}
// Insert a fresh stored theme (uuid theme_id, version 1, timestamps).
async function create({ name, engine, tokens, layoutTree }) {
const themeId = crypto.randomUUID();
const finalName = await dedupName(name);
const ts = nowIso();
await db.insert(THEME_TABLE, {
theme_id: themeId,
name: finalName,
engine: engine || ENGINE,
tokens: JSON.stringify(normalizeTokens(tokens)),
layout_tree: layoutTree == null ? null : JSON.stringify(layoutTree),
version: 1,
created_at: ts,
updated_at: ts,
});
return getById(themeId);
}
// Copy an existing theme (stored OR builtin) into a fresh stored row. This is
// the "load a builtin for edit = duplicate-to-edit" path.
async function duplicate(id, name) {
const src = await getById(id);
if (!src) {
return null;
}
const dupName = name != null ? name : `${src.name} copy`;
return create({
name: dupName,
engine: src.engine,
tokens: src.tokens,
layoutTree: src.layoutTree,
});
}
// Positional placeholder for raw parameterized SQL ("$1" on PG, "?" on SQLite).
// mkWhere is dialect-aware but cannot express version=version+1, so the two
// version-mutating updates issue raw SQL and pick the placeholder here.
function ph(n) {
return db.isSQLite ? "?" : `$${n}`;
}
// Optimistic compare-and-swap on version: UPDATE ... WHERE theme_id=$ AND
// version=$base, SET version=version+1. Returns the updated ThemeDTO, or null
// when no row matched (stale baseVersion / unknown id) so the handler can 409.
// RETURNING + rows.length is the matched-row count (portable; rowCount is
// PG-only and absent from the SQLite driver's {rows} result).
async function casUpdate(id, baseVersion, { tokens, layoutTree }) {
const schema = db.getTenantSchemaPrefix();
const ts = nowIso();
const sql = `update ${schema}"${THEME_TABLE}" `
+ `set tokens=${ph(1)}, layout_tree=${ph(2)}, version=version+1, updated_at=${ph(3)} `
+ `where theme_id=${ph(4)} and version=${ph(5)} returning theme_id`;
const params = [
JSON.stringify(normalizeTokens(tokens)),
layoutTree == null ? null : JSON.stringify(layoutTree),
ts,
id,
baseVersion,
];
const result = await db.query(sql, params);
if (!result || !result.rows || result.rows.length === 0) {
return null;
}
return getById(id);
}
// Unconditional save (force overwrite): bumps version, ignores baseVersion.
async function forceUpdate(id, { tokens, layoutTree }) {
const schema = db.getTenantSchemaPrefix();
const ts = nowIso();
const sql = `update ${schema}"${THEME_TABLE}" `
+ `set tokens=${ph(1)}, layout_tree=${ph(2)}, version=version+1, updated_at=${ph(3)} `
+ `where theme_id=${ph(4)}`;
await db.query(sql, [
JSON.stringify(normalizeTokens(tokens)),
layoutTree == null ? null : JSON.stringify(layoutTree),
ts,
id,
]);
return getById(id);
}
// Rename: name only, app-enforced uniqueness, NO version bump (rename does not
// touch the optimistic-lock counter; ARCHITECTURE.md 6.7).
async function rename(id, name) {
const finalName = await dedupName(name);
const ts = nowIso();
await db.updateWhere(THEME_TABLE, { name: finalName, updated_at: ts }, { theme_id: id });
return getById(id);
}
// Delete a stored theme by theme_id. Returns whether a row was removed.
async function remove(id) {
const before = await db.selectMaybeOne(THEME_TABLE, { theme_id: id });
if (!before) {
return false;
}
await db.deleteWhere(THEME_TABLE, { theme_id: id });
return true;
}
// First stored theme that is NOT id, used as a delete-of-active successor.
// Returns a ThemeDTO or null (caller falls back to builtins.defaultId()).
async function firstOther(id) {
const rows = await db.select(THEME_TABLE, {}, { orderBy: "name" });
for (const r of rows) {
if (r.theme_id !== id) {
return rowToTheme(r);
}
}
return null;
}
module.exports = {
ensureTableForCurrentTenant,
rowToTheme,
list,
getById,
create,
duplicate,
casUpdate,
forceUpdate,
rename,
remove,
firstOther,
dedupName,
};