Adding the lab project
This commit is contained in:
Vendored
+235
@@ -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
|
||||
Vendored
+1
File diff suppressed because one or more lines are too long
Vendored
+606
@@ -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
|
||||
+1
File diff suppressed because one or more lines are too long
Vendored
+12
@@ -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
|
||||
Vendored
+1
@@ -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"}
|
||||
Vendored
+13
@@ -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
|
||||
Vendored
+1
@@ -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"}
|
||||
Reference in New Issue
Block a user