Adding the lab project

This commit is contained in:
2026-07-01 12:10:12 +02:00
commit 48ce52b52c
56 changed files with 22289 additions and 0 deletions
+235
View File
@@ -0,0 +1,235 @@
import express from 'express';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { createCaEngine, createDeck, createInitialConditionGenerator, createPresetNode, createPresetTree, createScene, deleteDeck, deletePresetNode, deletePresetTree, deleteScene, getCaEngine, getDeck, getInitialConditionGenerator, getPresetNode, getScene, listCaEngines, listCaEnginePresetUsage, listDecks, listInitialConditionGenerators, listPresetNodeUsage, listPresetNodes, listPresetTrees, listScenes, resolveDeck, resolvePresetNode, resolveScene, updateCaEngine, updateDeck, updateInitialConditionGenerator, updatePresetNode, updateScene } from './caStudioRepository.js';
const __dirname = dirname(fileURLToPath(import.meta.url));
const adminPublicPath = resolve(__dirname, '../public/admin');
function asyncRoute(handler) {
return (request, response, next) => {
handler(request, response).catch(next);
};
}
function routeParam(request, key) {
const value = request.params[key];
if (typeof value !== 'string') {
throw new Error(`Missing route parameter: ${key}`);
}
return value;
}
function queryString(value) {
return typeof value === 'string' ? value : undefined;
}
function parseStates(value) {
const trimmed = value.trim();
if (/^\d+$/.test(trimmed))
return Number(trimmed);
if (trimmed.startsWith('['))
return JSON.parse(trimmed);
return trimmed.split(',').map((state) => state.trim()).filter(Boolean);
}
function caClassFromQuery(query) {
const dimensions = queryString(query.dimensions);
const states = queryString(query.states);
if (!dimensions && !states)
return undefined;
if (!dimensions || !states) {
throw new Error('Filtering initial condition generators requires dimensions and states');
}
return {
dimensions: Number(dimensions),
states: parseStates(states)
};
}
export function createCaStudioApi(db) {
const app = express();
app.use(express.json({ limit: '1mb' }));
app.use('/admin', express.static(adminPublicPath));
app.get('/', (_request, response) => {
response.redirect('/admin');
});
app.get(/^\/(?:admin|view)(?:\/.*)?$/, (_request, response) => {
response.sendFile(resolve(adminPublicPath, 'index.html'));
});
app.post('/api/ca/preset-trees', asyncRoute(async (request, response) => {
const tree = await createPresetTree(db, request.body);
response.status(201).json({ ...tree, node_count: 0 });
}));
app.get('/api/ca/preset-trees', asyncRoute(async (_request, response) => {
response.json(await listPresetTrees(db));
}));
app.delete('/api/ca/preset-trees/:treeId', asyncRoute(async (request, response) => {
const deleted = await deletePresetTree(db, routeParam(request, 'treeId'));
response.sendStatus(deleted ? 204 : 404);
}));
app.get('/api/ca/preset-trees/:treeId/nodes', asyncRoute(async (request, response) => {
response.json(await listPresetNodes(db, routeParam(request, 'treeId')));
}));
app.post('/api/ca/preset-trees/:treeId/nodes', asyncRoute(async (request, response) => {
const node = await createPresetNode(db, {
...request.body,
treeId: routeParam(request, 'treeId')
});
response.status(201).json(node);
}));
app.get('/api/ca/preset-trees/:treeId/nodes/:nodeId/resolved', asyncRoute(async (request, response) => {
const resolved = await resolvePresetNode(db, routeParam(request, 'treeId'), routeParam(request, 'nodeId'));
if (!resolved) {
response.sendStatus(404);
return;
}
response.json(resolved);
}));
app.get('/api/ca/preset-trees/:treeId/nodes/:nodeId', asyncRoute(async (request, response) => {
const node = await getPresetNode(db, routeParam(request, 'treeId'), routeParam(request, 'nodeId'));
if (!node) {
response.sendStatus(404);
return;
}
response.json(node);
}));
app.get('/api/ca/preset-trees/:treeId/node-usage', asyncRoute(async (request, response) => {
response.json(await listPresetNodeUsage(db, routeParam(request, 'treeId')));
}));
app.patch('/api/ca/preset-trees/:treeId/nodes/:nodeId', asyncRoute(async (request, response) => {
const node = await updatePresetNode(db, routeParam(request, 'treeId'), routeParam(request, 'nodeId'), request.body);
if (!node) {
response.sendStatus(404);
return;
}
response.json(node);
}));
app.delete('/api/ca/preset-trees/:treeId/nodes/:nodeId', asyncRoute(async (request, response) => {
const deleted = await deletePresetNode(db, routeParam(request, 'treeId'), routeParam(request, 'nodeId'));
response.sendStatus(deleted ? 204 : 404);
}));
app.post('/api/ca/initial-condition-generators', asyncRoute(async (request, response) => {
response.status(201).json(await createInitialConditionGenerator(db, request.body));
}));
app.get('/api/ca/initial-condition-generators', asyncRoute(async (request, response) => {
response.json(await listInitialConditionGenerators(db, { caClass: caClassFromQuery(request.query) }));
}));
app.get('/api/ca/initial-condition-generators/:generatorId', asyncRoute(async (request, response) => {
const generator = await getInitialConditionGenerator(db, routeParam(request, 'generatorId'));
if (!generator) {
response.sendStatus(404);
return;
}
response.json(generator);
}));
app.patch('/api/ca/initial-condition-generators/:generatorId', asyncRoute(async (request, response) => {
const generator = await updateInitialConditionGenerator(db, routeParam(request, 'generatorId'), request.body);
if (!generator) {
response.sendStatus(404);
return;
}
response.json(generator);
}));
app.post('/api/ca/engines', asyncRoute(async (request, response) => {
response.status(201).json(await createCaEngine(db, request.body));
}));
app.get('/api/ca/engines', asyncRoute(async (request, response) => {
response.json(await listCaEngines(db, { caClass: caClassFromQuery(request.query) }));
}));
app.get('/api/ca/engines/:engineId/usage', asyncRoute(async (request, response) => {
const usage = await listCaEnginePresetUsage(db, routeParam(request, 'engineId'));
if (!usage) {
response.sendStatus(404);
return;
}
response.json(usage);
}));
app.get('/api/ca/engines/:engineId', asyncRoute(async (request, response) => {
const engine = await getCaEngine(db, routeParam(request, 'engineId'));
if (!engine) {
response.sendStatus(404);
return;
}
response.json(engine);
}));
app.patch('/api/ca/engines/:engineId', asyncRoute(async (request, response) => {
const engine = await updateCaEngine(db, routeParam(request, 'engineId'), request.body);
if (!engine) {
response.sendStatus(404);
return;
}
response.json(engine);
}));
app.post('/api/ca/decks', asyncRoute(async (request, response) => {
response.status(201).json(await createDeck(db, request.body));
}));
app.get('/api/ca/decks', asyncRoute(async (_request, response) => {
response.json(await listDecks(db));
}));
app.get('/api/ca/decks/:deckId', asyncRoute(async (request, response) => {
const deck = await getDeck(db, routeParam(request, 'deckId'));
if (!deck) {
response.sendStatus(404);
return;
}
response.json(deck);
}));
app.patch('/api/ca/decks/:deckId', asyncRoute(async (request, response) => {
const deck = await updateDeck(db, routeParam(request, 'deckId'), request.body);
if (!deck) {
response.sendStatus(404);
return;
}
response.json(deck);
}));
app.delete('/api/ca/decks/:deckId', asyncRoute(async (request, response) => {
const deleted = await deleteDeck(db, routeParam(request, 'deckId'));
response.sendStatus(deleted ? 204 : 404);
}));
app.get('/api/ca/decks/:deckId/resolved', asyncRoute(async (request, response) => {
const resolved = await resolveDeck(db, routeParam(request, 'deckId'));
if (!resolved) {
response.sendStatus(404);
return;
}
response.json(resolved);
}));
app.get('/api/ca/decks/:deckId/scenes', asyncRoute(async (request, response) => {
response.json(await listScenes(db, routeParam(request, 'deckId')));
}));
app.post('/api/ca/decks/:deckId/scenes', asyncRoute(async (request, response) => {
const scene = await createScene(db, {
...request.body,
deckId: routeParam(request, 'deckId')
});
response.status(201).json(scene);
}));
app.get('/api/ca/scenes/:sceneId', asyncRoute(async (request, response) => {
const scene = await getScene(db, routeParam(request, 'sceneId'));
if (!scene) {
response.sendStatus(404);
return;
}
response.json(scene);
}));
app.patch('/api/ca/scenes/:sceneId', asyncRoute(async (request, response) => {
const scene = await updateScene(db, routeParam(request, 'sceneId'), request.body);
if (!scene) {
response.sendStatus(404);
return;
}
response.json(scene);
}));
app.delete('/api/ca/scenes/:sceneId', asyncRoute(async (request, response) => {
const deleted = await deleteScene(db, routeParam(request, 'sceneId'));
response.sendStatus(deleted ? 204 : 404);
}));
app.get('/api/ca/scenes/:sceneId/resolved', asyncRoute(async (request, response) => {
const resolved = await resolveScene(db, routeParam(request, 'sceneId'));
if (!resolved) {
response.sendStatus(404);
return;
}
response.json(resolved);
}));
app.use((error, _request, response, _next) => {
const message = error instanceof Error ? error.message : 'Unknown error';
response.status(400).json({ error: message });
});
return app;
}
//# sourceMappingURL=caStudioApi.js.map
File diff suppressed because one or more lines are too long
+606
View File
@@ -0,0 +1,606 @@
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
File diff suppressed because one or more lines are too long
+12
View File
@@ -0,0 +1,12 @@
export async function queryOne(db, sql, params = []) {
const result = await db.query(sql, params);
return result.rows[0] ?? null;
}
export async function queryRequired(db, sql, params = []) {
const row = await queryOne(db, sql, params);
if (!row) {
throw new Error('Expected database row, received none');
}
return row;
}
//# sourceMappingURL=db.js.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"file":"db.js","sourceRoot":"","sources":["../src/db.ts"],"names":[],"mappings":"AASA,MAAM,CAAC,KAAK,UAAU,QAAQ,CAAM,EAAa,EAAE,GAAW,EAAE,SAAoB,EAAE;IACpF,MAAM,MAAM,GAAG,MAAM,EAAE,CAAC,KAAK,CAAM,GAAG,EAAE,MAAM,CAAC,CAAA;IAC/C,OAAO,MAAM,CAAC,IAAI,CAAC,CAAC,CAAC,IAAI,IAAI,CAAA;AAC/B,CAAC;AAED,MAAM,CAAC,KAAK,UAAU,aAAa,CAAM,EAAa,EAAE,GAAW,EAAE,SAAoB,EAAE;IACzF,MAAM,GAAG,GAAG,MAAM,QAAQ,CAAM,EAAE,EAAE,GAAG,EAAE,MAAM,CAAC,CAAA;IAChD,IAAI,CAAC,GAAG,EAAE,CAAC;QACT,MAAM,IAAI,KAAK,CAAC,sCAAsC,CAAC,CAAA;IACzD,CAAC;IACD,OAAO,GAAG,CAAA;AACZ,CAAC"}
+13
View File
@@ -0,0 +1,13 @@
import { Pool } from 'pg';
import { createCaStudioApi } from './caStudioApi.js';
const databaseUrl = process.env.DATABASE_URL;
if (!databaseUrl) {
throw new Error('DATABASE_URL is required');
}
const pool = new Pool({ connectionString: databaseUrl });
const app = createCaStudioApi(pool);
const port = Number(process.env.PORT ?? 3100);
app.listen(port, () => {
console.log(`CA Studio API listening on http://localhost:${port}`);
});
//# sourceMappingURL=server.js.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"file":"server.js","sourceRoot":"","sources":["../src/server.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,IAAI,EAAE,MAAM,IAAI,CAAA;AACzB,OAAO,EAAE,iBAAiB,EAAE,MAAM,kBAAkB,CAAA;AAEpD,MAAM,WAAW,GAAG,OAAO,CAAC,GAAG,CAAC,YAAY,CAAA;AAE5C,IAAI,CAAC,WAAW,EAAE,CAAC;IACjB,MAAM,IAAI,KAAK,CAAC,0BAA0B,CAAC,CAAA;AAC7C,CAAC;AAED,MAAM,IAAI,GAAG,IAAI,IAAI,CAAC,EAAE,gBAAgB,EAAE,WAAW,EAAE,CAAC,CAAA;AACxD,MAAM,GAAG,GAAG,iBAAiB,CAAC,IAAI,CAAC,CAAA;AACnC,MAAM,IAAI,GAAG,MAAM,CAAC,OAAO,CAAC,GAAG,CAAC,IAAI,IAAI,IAAI,CAAC,CAAA;AAE7C,GAAG,CAAC,MAAM,CAAC,IAAI,EAAE,GAAG,EAAE;IACpB,OAAO,CAAC,GAAG,CAAC,+CAA+C,IAAI,EAAE,CAAC,CAAA;AACpE,CAAC,CAAC,CAAA"}