← Files BranchaARCHIVED FILE
skills/brancha/app/src/server/nodes/node.service.ts
13.3 KB · Oct 3, 2026 · 06:30 UTC
import { isArtifactType, type ArtifactType, type NodeType } from '../../shared/types.js';
import { ArtifactStore } from '../artifacts/artifact.store.js';
import { renderArtifact } from '../artifacts/render.js';
import { createId, transaction } from '../db/connection.js';
import { HttpError } from '../http/errors.js';
import { ARTIFACT_CONTENT_SECURITY_POLICY, MEDIA_CONTENT_SECURITY_POLICY } from '../http/security.js';
import { ProjectRepository } from '../projects/project.repository.js';
import { projectEvents } from '../realtime/events.js';
import type { Node } from './node.types.js';
import { NodeRepository } from './node.repository.js';
const MEDIA_TYPES = new Set<ArtifactType>(['image', 'video', 'audio']);
function requireTitle(value: unknown): string {
const title = typeof value === 'string' ? value.trim() : '';
if (!title) throw new HttpError(400, 'title required', 'TITLE_REQUIRED');
return title;
}
function optionalContent(value: unknown): string | null {
return typeof value === 'string' && value.length ? value : null;
}
function positionOf(value: unknown, fallback: number): number {
if (value === undefined) return fallback;
if (!Number.isInteger(value) || Number(value) < 0) throw new HttpError(400, 'position must be a non-negative integer', 'INVALID_POSITION');
return Number(value);
}
function mediaSourceIsValid(source: string, type: ArtifactType): boolean {
if (!MEDIA_TYPES.has(type)) return true;
const value = source.trim();
if (value.startsWith(`data:${type}/`)) return true;
try {
const url = new URL(value);
return url.protocol === 'https:'
|| (url.protocol === 'http:' && ['127.0.0.1', 'localhost', '::1'].includes(url.hostname));
} catch {
return false;
}
}
function requireArtifactSource(source: unknown, type: ArtifactType): string {
const value = typeof source === 'string' ? source : '';
if (!value.trim()) throw new HttpError(400, 'artifact required', 'ARTIFACT_REQUIRED');
if (!mediaSourceIsValid(value, type)) {
throw new HttpError(400, `${type} artifact must be a matching data URI, HTTPS URL, or loopback URL`, 'INVALID_MEDIA_SOURCE');
}
return value;
}
function artifactPath(node: Node): string | null {
return isArtifactType(node.type) ? node.content : null;
}
export class NodeService {
constructor(
private readonly nodes: NodeRepository,
private readonly projects: ProjectRepository,
private readonly artifacts: ArtifactStore,
) {}
create(projectId: string, input: Record<string, unknown>) {
this.requireProject(projectId);
const parentId = typeof input.parentId === 'string' ? input.parentId : '';
if (!parentId) throw new HttpError(400, 'parentId required', 'PARENT_REQUIRED');
if (!this.nodes.findInProject(parentId, projectId)) throw new HttpError(404, 'parent node not found', 'PARENT_NOT_FOUND');
const artifactType = this.artifactTypeOf(input.artifactType);
if (input.artifact !== undefined && !artifactType) throw new HttpError(400, 'artifactType required', 'ARTIFACT_TYPE_REQUIRED');
if (artifactType && input.content !== undefined && input.content !== null) {
throw new HttpError(400, 'content cannot be combined with artifactType', 'AMBIGUOUS_NODE_CONTENT');
}
const id = createId();
const source = artifactType && input.artifact !== undefined ? requireArtifactSource(input.artifact, artifactType) : null;
const artifact = source && artifactType ? this.artifacts.write(projectId, id, artifactType, source) : null;
try {
transaction(() => {
this.nodes.insert({
id,
projectId,
parentId,
position: positionOf(input.position, this.nodes.nextPosition(projectId, parentId)),
type: artifactType ?? 'text',
title: requireTitle(input.title),
content: artifactType ? artifact : optionalContent(input.content),
});
this.projects.touch(projectId);
});
} catch (error) {
this.artifacts.remove(artifact);
throw error;
}
projectEvents.publish(projectId);
return { id };
}
createBatch(projectId: string, input: Record<string, unknown>) {
this.requireProject(projectId);
if (!Array.isArray(input.nodes) || input.nodes.length === 0) throw new HttpError(400, 'nodes must be a non-empty array', 'NODES_REQUIRED');
if (input.nodes.length > 100) throw new HttpError(400, 'batch is limited to 100 nodes', 'BATCH_TOO_LARGE');
const written: string[] = [];
try {
const result = transaction(() => {
const ids = new Map<string, string>();
const created: Array<{ key: string; id: string }> = [];
for (const raw of input.nodes as unknown[]) {
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) throw new HttpError(400, 'each batch node must be an object', 'INVALID_BATCH_NODE');
const item = raw as Record<string, unknown>;
const key = typeof item.key === 'string' ? item.key.trim() : '';
if (!key || ids.has(key)) throw new HttpError(400, 'batch node keys must be unique', 'INVALID_BATCH_KEY');
const parentId = typeof item.parentKey === 'string' ? ids.get(item.parentKey)
: typeof item.parentId === 'string' ? item.parentId : undefined;
if (!parentId || !this.nodes.findInProject(parentId, projectId)) throw new HttpError(404, `parent not found for ${key}`, 'PARENT_NOT_FOUND');
const artifactType = this.artifactTypeOf(item.artifactType, key);
if (item.artifact !== undefined && !artifactType) throw new HttpError(400, `artifactType required for ${key}`, 'ARTIFACT_TYPE_REQUIRED');
if (artifactType && item.content !== undefined && item.content !== null) {
throw new HttpError(400, `content cannot be combined with artifactType for ${key}`, 'AMBIGUOUS_NODE_CONTENT');
}
const id = createId();
const source = artifactType && item.artifact !== undefined ? requireArtifactSource(item.artifact, artifactType) : null;
const artifact = source && artifactType ? this.artifacts.write(projectId, id, artifactType, source) : null;
if (artifact) written.push(artifact);
this.nodes.insert({
id,
projectId,
parentId,
position: positionOf(item.position, this.nodes.nextPosition(projectId, parentId)),
type: artifactType ?? 'text',
title: requireTitle(item.title),
content: artifactType ? artifact : optionalContent(item.content),
});
ids.set(key, id);
created.push({ key, id });
}
this.projects.touch(projectId);
return { nodes: created };
});
projectEvents.publish(projectId);
return result;
} catch (error) {
this.artifacts.removeMany(written);
throw error;
}
}
update(nodeId: string, input: Record<string, unknown>) {
const node = this.requireNode(nodeId);
const title = input.title === undefined ? node.title : requireTitle(input.title);
const changesContent = input.content !== undefined;
const type: NodeType = changesContent ? 'text' : node.type;
const content = changesContent ? optionalContent(input.content) : node.content;
let parentId = node.parent_id;
if (input.parentId !== undefined) {
if (!node.parent_id) throw new HttpError(400, 'root node cannot be moved', 'ROOT_NODE_MOVE_NOT_ALLOWED');
parentId = typeof input.parentId === 'string' ? input.parentId : '';
const parent = this.nodes.findInProject(parentId, node.project_id);
if (!parent) throw new HttpError(404, 'parent node not found', 'PARENT_NOT_FOUND');
if (parentId === nodeId || this.nodes.isInSubtree(nodeId, parentId)) throw new HttpError(400, 'move would create a cycle', 'TREE_CYCLE');
}
const expected = typeof input.expectedUpdatedAt === 'string' ? input.expectedUpdatedAt : undefined;
transaction(() => {
if (!this.nodes.updateNode(nodeId, parentId, positionOf(input.position, node.position), type, title, content, expected)) {
throw new HttpError(409, 'node changed since it was read', 'VERSION_CONFLICT');
}
this.projects.touch(node.project_id);
});
if (changesContent) this.artifacts.remove(artifactPath(node));
projectEvents.publish(node.project_id);
return this.requireNode(nodeId);
}
updateArtifact(nodeId: string, input: Record<string, unknown>) {
const node = this.requireNode(nodeId);
if (!isArtifactType(input.artifactType)) throw new HttpError(400, 'artifactType required', 'ARTIFACT_TYPE_REQUIRED');
const type = input.artifactType;
const expected = typeof input.expectedUpdatedAt === 'string' ? input.expectedUpdatedAt : undefined;
if (expected && expected !== node.updated_at) throw new HttpError(409, 'node changed since it was read', 'VERSION_CONFLICT');
const artifact = this.artifacts.write(node.project_id, nodeId, type, requireArtifactSource(input.artifact, type));
const previousPath = artifactPath(node);
try {
transaction(() => {
if (!this.nodes.updateNode(nodeId, node.parent_id, node.position, type, node.title, artifact, expected)) {
throw new HttpError(409, 'node changed since it was read', 'VERSION_CONFLICT');
}
this.projects.touch(node.project_id);
});
} catch (error) {
if (previousPath !== artifact) this.artifacts.remove(artifact);
throw error;
}
if (previousPath !== artifact) this.artifacts.remove(previousPath);
projectEvents.publish(node.project_id);
return { ok: true as const };
}
markArtifactError(nodeId: string, input: Record<string, unknown>) {
const node = this.requireNode(nodeId);
if (!isArtifactType(node.type)) throw new HttpError(400, 'node has no artifact', 'ARTIFACT_TYPE_REQUIRED');
const error = typeof input.error === 'string' ? input.error.trim() : '';
if (!error) throw new HttpError(400, 'error required', 'ERROR_REQUIRED');
const expected = typeof input.expectedUpdatedAt === 'string' ? input.expectedUpdatedAt : undefined;
transaction(() => {
if (!this.nodes.updateNode(nodeId, node.parent_id, node.position, 'error', node.title, error, expected)) {
throw new HttpError(409, 'node changed since it was read', 'VERSION_CONFLICT');
}
this.projects.touch(node.project_id);
});
this.artifacts.remove(artifactPath(node));
projectEvents.publish(node.project_id);
return { ok: true as const };
}
clearArtifact(nodeId: string) {
const node = this.requireNode(nodeId);
transaction(() => {
this.nodes.updateNode(nodeId, node.parent_id, node.position, 'text', node.title, null);
this.projects.touch(node.project_id);
});
this.artifacts.remove(artifactPath(node));
projectEvents.publish(node.project_id);
return { ok: true as const };
}
delete(nodeId: string) {
const node = this.requireNode(nodeId);
if (!node.parent_id) throw new HttpError(400, 'root node cannot be deleted', 'ROOT_NODE_IMMUTABLE');
const paths = this.nodes.listSubtree(nodeId).map(artifactPath);
const deleted = transaction(() => {
const count = this.nodes.deleteSubtree(nodeId);
this.projects.touch(node.project_id);
return count;
});
this.artifacts.removeMany(paths);
projectEvents.publish(node.project_id);
return { ok: true as const, deleted };
}
html(nodeId: string) {
const node = this.requireArtifactNode(nodeId);
const type = node.type as ArtifactType;
const path = node.content as string;
const mime = this.artifacts.mime(path, type);
const source = MEDIA_TYPES.has(type)
? mime.startsWith('text/uri-list') ? this.artifacts.readText(path).trim() : `/api/nodes/${node.id}/artifact`
: this.artifacts.readText(path);
return {
document: renderArtifact(source, type),
policy: MEDIA_TYPES.has(type) ? MEDIA_CONTENT_SECURITY_POLICY : ARTIFACT_CONTENT_SECURITY_POLICY,
};
}
content(nodeId: string) {
const node = this.requireNode(nodeId);
return { content: node.type === 'text' || node.type === 'error' ? node.content : null };
}
artifactSource(nodeId: string) {
const node = this.requireArtifactNode(nodeId);
return { source: this.artifacts.source(node.content as string, node.type as ArtifactType), artifactType: node.type };
}
artifact(nodeId: string) {
const node = this.requireArtifactNode(nodeId);
const path = node.content as string;
const type = node.type as ArtifactType;
return { content: this.artifacts.read(path), mime: this.artifacts.mime(path, type), filename: this.artifacts.filename(path) };
}
private artifactTypeOf(value: unknown, key?: string): ArtifactType | null {
if (value === undefined || value === null) return null;
if (isArtifactType(value)) return value;
throw new HttpError(400, key ? `invalid artifactType for ${key}` : 'invalid artifactType', 'INVALID_ARTIFACT_TYPE');
}
private requireProject(projectId: string) {
const project = this.projects.find(projectId);
if (!project) throw new HttpError(404, 'project not found', 'PROJECT_NOT_FOUND');
return project;
}
private requireNode(nodeId: string) {
const node = this.nodes.find(nodeId);
if (!node) throw new HttpError(404, 'node not found', 'NODE_NOT_FOUND');
return node;
}
private requireArtifactNode(nodeId: string) {
const node = this.requireNode(nodeId);
if (!isArtifactType(node.type) || !node.content) throw new HttpError(404, 'artifact not found', 'ARTIFACT_NOT_FOUND');
return node;
}
}
SHA-256: 4fbd2c4b4d53ba7b3d371ffb090a061ed62d2545ab1709d6193d82d6bced3389