// lib/activate.js // activateTheme -- staged, persists COMPOSITE hash, refreshes assets (ARCHITECTURE.md 7.8) const crypto = require("crypto"); const { getState } = require("@saltcorn/data/db/state"); const { compileTheme } = require("./compile"); const themeStore = require("./themeStore"); const cssCache = require("./cssCache"); const activePointer = require("./activePointer"); const { normalizeLayoutTree } = require("./layoutTree"); const { PLUGIN_NAME } = require("./constants"); function compositeHash(entries /* CompiledEntry[] */) { // Fold the default + every role-override entry's content hash into ONE cache-buster so that // re-activating ANY single role changes activeHash (and thus every emitted ?v). (folded fix) const h = crypto.createHash("sha1"); for (const e of entries.filter(Boolean)) { h.update(`${e.role ?? "_"}:${e.hash};`); } return h.digest("hex").slice(0, 8); } async function activateTheme(id, role) { // Resolve uniformly through the store so the SEAM FIX applies (getById returns a // normalized ThemeDTO for builtin ids too) -- STAGE-1's compile probe then sees the // same defaulted token shape STAGE-2 caches and serves. const theme = await themeStore.getById(id); if (!theme) { const e = new Error("theme not found"); e.stage = "compile"; throw e; } // STAGE 1: compile (distinct failure stage -> compile_failed). Pointer untouched on throw. let compiled; try { compiled = compileTheme(theme.tokens, { engine: theme.engine }); } catch (e) { e.stage = "compile"; throw e; } // STAGE 2: prime cache for ALL pointer-implied keys (default + every activeByRole role). const { activeThemeId, activeByRole } = activePointer.getActivePointer(); const nextByRole = role == null ? activeByRole : { ...activeByRole, [role]: id }; const nextDefault = role == null ? id : activeThemeId; cssCache.bustAll(); const entries = []; if (nextDefault) entries.push(await cssCache.warm(nextDefault, null)); for (const r of Object.keys(nextByRole)) entries.push(await cssCache.warm(nextByRole[r], Number(r))); // STAGE 3: persist pointer + COMPOSITE hash, re-register, refresh assets, propagate // (distinct failure -> activation_failed). // activeHash folds in the default AND every role-override entry's content hash, so re-activating // a single role CHANGES activeHash -> the per-role ?v link changes -> immutable role CSS is busted. // (folded review fix: role-override cache-buster never changed -> stale immutable CSS) const nextHash = compositeHash(entries); // LAYOUT MODE: persist the activated theme's layout tree into cfg so wrap() can read // it synchronously (and it rides backup with the pointer). No-op when layout mode is off. const liveCfg = getState().plugin_cfgs[PLUGIN_NAME] || {}; let layoutPatch = {}; if (liveCfg.layoutMode) { const tree = normalizeLayoutTree(theme.layoutTree); layoutPatch = role == null ? { activeLayoutTree: tree } : { activeLayoutTreeByRole: { ...(liveCfg.activeLayoutTreeByRole || {}), [role]: tree } }; } try { const patch = role == null ? { activeThemeId: id, activeByRole: nextByRole, activeHash: nextHash, ...layoutPatch } : { activeByRole: nextByRole, activeHash: nextHash, ...layoutPatch }; // ALWAYS update activeHash, incl. role path await activePointer.setActivePointer(patch); // upsert + loadPlugin + refresh_views + processSend } catch (e) { cssCache.bustTheme(id); // drop orphan cache entry (folded fix) e.stage = "activation"; throw e; } return { activeThemeId: nextDefault, activeByRole: nextByRole, cssHash: nextHash }; } module.exports = { activateTheme, compositeHash };