Files
2026-07-01 12:10:12 +02:00

606 lines
20 KiB
JavaScript

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