308 lines
10 KiB
JavaScript
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,
|
|
};
|