// 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:"); 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, };