import { queryOne, queryRequired } from './db.js'; function isPlainObject(value) { return Boolean(value) && typeof value === 'object' && !Array.isArray(value); } export function mergeSceneParams(base, patch) { const next = { ...base }; for (const [key, value] of Object.entries(patch)) { if (value === null) { delete next[key]; continue; } const current = next[key]; if (isPlainObject(current) && isPlainObject(value)) { next[key] = mergeSceneParams(current, value); continue; } next[key] = value; } return next; } function parseJsonObject(value) { if (typeof value === 'string') return JSON.parse(value); return (value ?? {}); } function normalizePresetNode(row) { return { ...row, params: parseJsonObject(row.params) }; } function normalizeDeck(row) { return { ...row, params: parseJsonObject(row.params) }; } function normalizeScene(row) { return { ...row, params: parseJsonObject(row.params), transition: parseJsonObject(row.transition) }; } function normalizeInitialConditionGenerator(row) { return { ...row, supported_classes: parseJsonObject(row.supported_classes), params_schema: parseJsonObject(row.params_schema), default_params: parseJsonObject(row.default_params) }; } function normalizeCaEngine(row) { return { ...row, supported_classes: parseJsonObject(row.supported_classes), params_schema: parseJsonObject(row.params_schema), default_params: parseJsonObject(row.default_params) }; } function normalizeStates(states) { return Array.isArray(states) ? [...states].sort() : states; } function caClassEquals(left, right) { return (left.dimensions === right.dimensions && JSON.stringify(normalizeStates(left.states)) === JSON.stringify(normalizeStates(right.states)) && neighborhoodsCompatible(left, right)); } function neighborhoodsCompatible(left, right) { if (!left.neighborhoodId || !right.neighborhoodId) return true; return left.neighborhoodId === right.neighborhoodId; } export async function createPresetTree(db, input) { return queryRequired(db, ` INSERT INTO ca_preset_trees (slug, name, description, schema_version) VALUES ($1, $2, $3, $4) RETURNING * `, [input.slug, input.name, input.description ?? null, input.schemaVersion ?? 1]); } export async function listPresetTrees(db) { const result = await db.query(` SELECT tree.*, COUNT(node.id)::integer AS node_count FROM ca_preset_trees tree LEFT JOIN ca_preset_nodes node ON node.tree_id = tree.id AND node.archived_at IS NULL WHERE tree.archived_at IS NULL GROUP BY tree.id ORDER BY tree.created_at DESC, tree.name `); return result.rows; } export async function deletePresetTree(db, treeId) { const result = await db.query('DELETE FROM ca_preset_trees WHERE id = $1 RETURNING id', [treeId]); return result.rows.length > 0; } export async function createPresetNode(db, input) { const row = await queryRequired(db, ` INSERT INTO ca_preset_nodes ( tree_id, parent_id, slug, name, kind, description, notes, sort_order, schema_version, params ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10::jsonb) RETURNING * `, [ input.treeId, input.parentId ?? null, input.slug, input.name, input.kind ?? 'scene_base', input.description ?? null, input.notes ?? null, input.sortOrder ?? 0, input.schemaVersion ?? 1, JSON.stringify(input.params ?? {}) ]); return normalizePresetNode(row); } export async function updatePresetNode(db, treeId, nodeId, patch) { const existing = await getPresetNode(db, treeId, nodeId); if (!existing) return null; const row = await queryRequired(db, ` UPDATE ca_preset_nodes SET parent_id = $3, slug = $4, name = $5, kind = $6, description = $7, notes = $8, sort_order = $9, schema_version = $10, params = $11::jsonb, updated_at = now() WHERE tree_id = $1 AND id = $2 RETURNING * `, [ treeId, nodeId, Object.hasOwn(patch, 'parentId') ? patch.parentId : existing.parent_id, patch.slug ?? existing.slug, patch.name ?? existing.name, patch.kind ?? existing.kind, Object.hasOwn(patch, 'description') ? patch.description : existing.description, Object.hasOwn(patch, 'notes') ? patch.notes : existing.notes, patch.sortOrder ?? existing.sort_order, patch.schemaVersion ?? existing.schema_version, JSON.stringify(patch.params ?? existing.params) ]); return normalizePresetNode(row); } export async function getPresetNode(db, treeId, nodeId) { const row = await queryOne(db, 'SELECT * FROM ca_preset_nodes WHERE tree_id = $1 AND id = $2', [treeId, nodeId]); return row ? normalizePresetNode(row) : null; } export async function listPresetNodes(db, treeId) { const result = await db.query(` SELECT * FROM ca_preset_nodes WHERE tree_id = $1 ORDER BY parent_id NULLS FIRST, sort_order, slug `, [treeId]); return result.rows.map(normalizePresetNode); } export async function listPresetNodeUsage(db, treeId) { const result = await db.query(` SELECT preset_node_id AS node_id, count(*) AS scene_count FROM ca_scenes WHERE preset_tree_id = $1 AND preset_node_id IS NOT NULL AND archived_at IS NULL GROUP BY preset_node_id `, [treeId]); return result.rows.map((row) => ({ node_id: row.node_id, scene_count: Number(row.scene_count) })); } export async function deletePresetNode(db, treeId, nodeId) { const result = await db.query('DELETE FROM ca_preset_nodes WHERE tree_id = $1 AND id = $2 RETURNING id', [treeId, nodeId]); return result.rows.length > 0; } export async function resolvePresetNode(db, treeId, nodeId) { const result = await db.query(` WITH RECURSIVE ancestry AS ( SELECT *, 0 AS depth FROM ca_preset_nodes WHERE tree_id = $1 AND id = $2 UNION ALL SELECT parent.*, child.depth + 1 AS depth FROM ca_preset_nodes parent JOIN ancestry child ON parent.tree_id = child.tree_id AND parent.id = child.parent_id ) SELECT * FROM ancestry ORDER BY depth DESC `, [treeId, nodeId]); if (result.rows.length === 0) return null; const ancestry = result.rows.map((row) => normalizePresetNode(row)); const params = ancestry.reduce((resolved, node) => mergeSceneParams(resolved, node.params), {}); return { treeId, nodeId, ancestry, params }; } export async function createInitialConditionGenerator(db, input) { const row = await queryRequired(db, ` INSERT INTO ca_initial_condition_generators ( slug, name, description, generator_kind, supported_classes, params_schema, default_params ) VALUES ($1, $2, $3, $4, $5::jsonb, $6::jsonb, $7::jsonb) RETURNING * `, [ input.slug, input.name, input.description ?? null, input.generatorKind, JSON.stringify(input.supportedClasses), JSON.stringify(input.paramsSchema ?? {}), JSON.stringify(input.defaultParams ?? {}) ]); return normalizeInitialConditionGenerator(row); } export async function listInitialConditionGenerators(db, filter = {}) { const result = await db.query(` SELECT * FROM ca_initial_condition_generators WHERE archived_at IS NULL ORDER BY name, slug `); const generators = result.rows.map(normalizeInitialConditionGenerator); if (!filter.caClass) return generators; return generators.filter((generator) => generator.supported_classes.some((supportedClass) => caClassEquals(supportedClass, filter.caClass))); } export async function getInitialConditionGenerator(db, generatorId) { const row = await queryOne(db, 'SELECT * FROM ca_initial_condition_generators WHERE id = $1', [generatorId]); return row ? normalizeInitialConditionGenerator(row) : null; } export async function updateInitialConditionGenerator(db, generatorId, patch) { const existing = await getInitialConditionGenerator(db, generatorId); if (!existing) return null; const row = await queryRequired(db, ` UPDATE ca_initial_condition_generators SET slug = $2, name = $3, description = $4, generator_kind = $5, supported_classes = $6::jsonb, params_schema = $7::jsonb, default_params = $8::jsonb, updated_at = now() WHERE id = $1 RETURNING * `, [ generatorId, patch.slug ?? existing.slug, patch.name ?? existing.name, Object.hasOwn(patch, 'description') ? patch.description : existing.description, patch.generatorKind ?? existing.generator_kind, JSON.stringify(patch.supportedClasses ?? existing.supported_classes), JSON.stringify(patch.paramsSchema ?? existing.params_schema), JSON.stringify(patch.defaultParams ?? existing.default_params) ]); return normalizeInitialConditionGenerator(row); } export async function createCaEngine(db, input) { const row = await queryRequired(db, ` INSERT INTO ca_engines ( slug, name, description, engine_kind, license, owner, ip_notice, supported_classes, params_schema, default_params ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8::jsonb, $9::jsonb, $10::jsonb) RETURNING * `, [ input.slug, input.name, input.description ?? null, input.engineKind, input.license ?? null, input.owner ?? null, input.ipNotice ?? null, JSON.stringify(input.supportedClasses), JSON.stringify(input.paramsSchema ?? {}), JSON.stringify(input.defaultParams ?? {}) ]); return normalizeCaEngine(row); } export async function listCaEngines(db, filter = {}) { const result = await db.query(` SELECT * FROM ca_engines WHERE archived_at IS NULL ORDER BY name, slug `); const engines = result.rows.map(normalizeCaEngine); if (!filter.caClass) return engines; return engines.filter((engine) => engine.supported_classes.some((supportedClass) => caClassEquals(supportedClass, filter.caClass))); } export async function getCaEngine(db, engineId) { const row = await queryOne(db, 'SELECT * FROM ca_engines WHERE id = $1', [engineId]); return row ? normalizeCaEngine(row) : null; } export async function listCaEnginePresetUsage(db, engineId) { const engine = await getCaEngine(db, engineId); if (!engine) return null; const result = await db.query(` SELECT nodes.*, trees.name AS tree_name FROM ca_preset_nodes nodes JOIN ca_preset_trees trees ON trees.id = nodes.tree_id WHERE nodes.archived_at IS NULL AND trees.archived_at IS NULL ORDER BY trees.name, nodes.sort_order, nodes.name `); const nodes = result.rows.map((row) => ({ ...normalizePresetNode(row), tree_name: row.tree_name })); const byId = new Map(nodes.map((node) => [`${node.tree_id}:${node.id}`, node])); const resolvedById = new Map(); const resolving = new Set(); function resolveNode(node) { const key = `${node.tree_id}:${node.id}`; const cached = resolvedById.get(key); if (cached) return cached; if (resolving.has(key)) return node.params; resolving.add(key); const parent = node.parent_id ? byId.get(`${node.tree_id}:${node.parent_id}`) : undefined; const resolved = mergeSceneParams(parent ? resolveNode(parent) : {}, node.params); resolving.delete(key); resolvedById.set(key, resolved); return resolved; } return nodes .filter((node) => { const simulation = resolveNode(node).simulation; return isPlainObject(simulation) && simulation.engineId === engine.engine_kind; }) .map((node) => ({ tree_id: node.tree_id, tree_name: node.tree_name, node_id: node.id, node_name: node.name, node_kind: node.kind })); } export async function updateCaEngine(db, engineId, patch) { const existing = await getCaEngine(db, engineId); if (!existing) return null; const row = await queryRequired(db, ` UPDATE ca_engines SET slug = $2, name = $3, description = $4, engine_kind = $5, license = $6, owner = $7, ip_notice = $8, supported_classes = $9::jsonb, params_schema = $10::jsonb, default_params = $11::jsonb, updated_at = now() WHERE id = $1 RETURNING * `, [ engineId, patch.slug ?? existing.slug, patch.name ?? existing.name, Object.hasOwn(patch, 'description') ? patch.description : existing.description, patch.engineKind ?? existing.engine_kind, Object.hasOwn(patch, 'license') ? patch.license : existing.license, Object.hasOwn(patch, 'owner') ? patch.owner : existing.owner, Object.hasOwn(patch, 'ipNotice') ? patch.ipNotice : existing.ip_notice, JSON.stringify(patch.supportedClasses ?? existing.supported_classes), JSON.stringify(patch.paramsSchema ?? existing.params_schema), JSON.stringify(patch.defaultParams ?? existing.default_params) ]); return normalizeCaEngine(row); } export async function createDeck(db, input) { const row = await queryRequired(db, ` INSERT INTO ca_decks (slug, title, description, schema_version, params) VALUES ($1, $2, $3, $4, $5::jsonb) RETURNING * `, [ input.slug, input.title, input.description ?? null, input.schemaVersion ?? 1, JSON.stringify(input.params ?? {}) ]); return normalizeDeck(row); } export async function listDecks(db) { const result = await db.query(` SELECT * FROM ca_decks WHERE archived_at IS NULL ORDER BY created_at, slug `); return result.rows.map(normalizeDeck); } export async function getDeck(db, deckId) { const row = await queryOne(db, 'SELECT * FROM ca_decks WHERE id = $1', [deckId]); return row ? normalizeDeck(row) : null; } export async function updateDeck(db, deckId, patch) { const existing = await getDeck(db, deckId); if (!existing) return null; const row = await queryRequired(db, ` UPDATE ca_decks SET slug = $2, title = $3, description = $4, schema_version = $5, params = $6::jsonb, archived_at = $7, updated_at = now() WHERE id = $1 RETURNING * `, [ deckId, patch.slug ?? existing.slug, patch.title ?? existing.title, Object.hasOwn(patch, 'description') ? patch.description : existing.description, patch.schemaVersion ?? existing.schema_version, JSON.stringify(patch.params ?? existing.params), Object.hasOwn(patch, 'archivedAt') ? patch.archivedAt : existing.archived_at ]); return normalizeDeck(row); } export async function deleteDeck(db, deckId) { const result = await db.query('DELETE FROM ca_decks WHERE id = $1 RETURNING id', [deckId]); return result.rows.length > 0; } export async function createScene(db, input) { const row = await queryRequired(db, ` INSERT INTO ca_scenes ( deck_id, order_index, title, description, preset_tree_id, preset_node_id, apply_mode, requires_previous_scene, schema_version, params, transition ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10::jsonb, $11::jsonb) RETURNING * `, [ input.deckId, input.orderIndex, input.title, input.description ?? null, input.presetTreeId ?? null, input.presetNodeId ?? null, input.applyMode ?? 'reinitialize', input.requiresPreviousScene ?? input.applyMode === 'patch_existing', input.schemaVersion ?? 1, JSON.stringify(input.params ?? {}), JSON.stringify(input.transition ?? {}) ]); return normalizeScene(row); } export async function getScene(db, sceneId) { const row = await queryOne(db, 'SELECT * FROM ca_scenes WHERE id = $1', [sceneId]); return row ? normalizeScene(row) : null; } export async function listScenes(db, deckId) { const result = await db.query(` SELECT * FROM ca_scenes WHERE deck_id = $1 ORDER BY order_index `, [deckId]); return result.rows.map(normalizeScene); } export async function updateScene(db, sceneId, patch) { const existing = await getScene(db, sceneId); if (!existing) return null; const nextApplyMode = patch.applyMode ?? existing.apply_mode; const nextRequiresPreviousScene = Object.hasOwn(patch, 'requiresPreviousScene') ? patch.requiresPreviousScene : nextApplyMode === 'patch_existing' ? true : existing.requires_previous_scene; const row = await queryRequired(db, ` UPDATE ca_scenes SET deck_id = $2, order_index = $3, title = $4, description = $5, preset_tree_id = $6, preset_node_id = $7, apply_mode = $8, requires_previous_scene = $9, schema_version = $10, params = $11::jsonb, transition = $12::jsonb, archived_at = $13, updated_at = now() WHERE id = $1 RETURNING * `, [ sceneId, patch.deckId ?? existing.deck_id, patch.orderIndex ?? existing.order_index, patch.title ?? existing.title, Object.hasOwn(patch, 'description') ? patch.description : existing.description, Object.hasOwn(patch, 'presetTreeId') ? patch.presetTreeId : existing.preset_tree_id, Object.hasOwn(patch, 'presetNodeId') ? patch.presetNodeId : existing.preset_node_id, nextApplyMode, nextRequiresPreviousScene, patch.schemaVersion ?? existing.schema_version, JSON.stringify(patch.params ?? existing.params), JSON.stringify(patch.transition ?? existing.transition), Object.hasOwn(patch, 'archivedAt') ? patch.archivedAt : existing.archived_at ]); return normalizeScene(row); } export async function deleteScene(db, sceneId) { const result = await db.query('DELETE FROM ca_scenes WHERE id = $1 RETURNING id', [sceneId]); return result.rows.length > 0; } export async function resolveScene(db, sceneId) { const sceneRow = await queryOne(db, 'SELECT * FROM ca_scenes WHERE id = $1', [sceneId]); if (!sceneRow) return null; const scene = normalizeScene(sceneRow); if (!scene.preset_tree_id || !scene.preset_node_id) { return { scene, ancestry: [], params: scene.params }; } const resolvedNode = await resolvePresetNode(db, scene.preset_tree_id, scene.preset_node_id); if (!resolvedNode) return null; return { scene, ancestry: resolvedNode.ancestry, params: mergeSceneParams(resolvedNode.params, scene.params) }; } export async function resolveDeck(db, deckId) { const deck = await getDeck(db, deckId); if (!deck) return null; const scenes = await listScenes(db, deckId); const resolvedScenes = []; for (const scene of scenes) { if (!scene.preset_tree_id || !scene.preset_node_id) { resolvedScenes.push({ scene, ancestry: [], params: scene.params }); continue; } const resolvedNode = await resolvePresetNode(db, scene.preset_tree_id, scene.preset_node_id); if (!resolvedNode) { throw new Error(`Unable to resolve preset node for scene ${scene.id}`); } resolvedScenes.push({ scene, ancestry: resolvedNode.ancestry, params: mergeSceneParams(resolvedNode.params, scene.params) }); } return { deck, scenes: resolvedScenes }; } //# sourceMappingURL=caStudioRepository.js.map