import { mkdirSync } from 'node:fs' import * as os from 'node:os' import * as path from 'node:path' import type { SceneGraph } from '@pascal-app/core/clone-scene-graph' import { z } from 'zod' import { generateSlug, isValidSlug, sanitizeSlug } from './slug' import { openSqliteDatabase, type SqliteDatabase } from './sqlite-driver' import { SceneInvalidError, type SceneListOptions, type SceneMeta, type SceneMutateOptions, SceneNotFoundError, type SceneSaveOptions, type SceneStore, SceneTooLargeError, SceneVersionConflictError, type SceneWithGraph, } from './types' const DEFAULT_MAX_SCENE_BYTES = 10 * 1024 * 1024 const DEFAULT_LIST_LIMIT = 100 const MAX_NAME_LENGTH = 200 const MIN_NAME_LENGTH = 1 export interface SqliteSceneStoreOptions { /** Exact SQLite database file path. If omitted, resolved from env. */ databasePath?: string /** Optional env override for default path and size-limit resolution. */ env?: NodeJS.ProcessEnv /** Maximum UTF-8 byte length of graph JSON. Defaults to 10 MB. */ maxSceneBytes?: number } interface SceneRow { id: string name: string project_id: string | null owner_id: string | null thumbnail_url: string | null version: number created_at: string updated_at: string size_bytes: number node_count: number graph_json: string } const GraphSchema = z.object({ nodes: z.record(z.string(), z.unknown()), rootNodeIds: z.array(z.string()), collections: z.record(z.string(), z.unknown()).optional(), }) /** * Resolves Pascal's local SQLite database path. * * Precedence: * 1. `PASCAL_DB_PATH` * 2. `PASCAL_DATA_DIR/pascal.db` * 3. On Windows: `%APPDATA%/Pascal/data/pascal.db` * 4. `$XDG_DATA_HOME/pascal/data/pascal.db` * 5. `$HOME/.pascal/data/pascal.db` */ export function resolveDefaultDatabasePath(env: NodeJS.ProcessEnv = process.env): string { if (env.PASCAL_DB_PATH && env.PASCAL_DB_PATH.length > 0) { return env.PASCAL_DB_PATH } if (env.PASCAL_DATA_DIR && env.PASCAL_DATA_DIR.length > 0) { return path.join(env.PASCAL_DATA_DIR, 'pascal.db') } if (process.platform === 'win32') { const appData = env.APPDATA if (appData && appData.length > 0) { return path.join(appData, 'Pascal', 'data', 'pascal.db') } return path.join(os.homedir(), '.pascal', 'data', 'pascal.db') } const xdg = env.XDG_DATA_HOME if (xdg && xdg.length > 0) { return path.join(xdg, 'pascal', 'data', 'pascal.db') } return path.join(os.homedir(), '.pascal', 'data', 'pascal.db') } function resolveMaxSceneBytes( env: NodeJS.ProcessEnv | undefined, explicit: number | undefined, ): number { if (explicit !== undefined) { if (!Number.isInteger(explicit) || explicit <= 0) { throw new SceneInvalidError('maxSceneBytes must be a positive integer') } return explicit } const raw = env?.PASCAL_MAX_SCENE_BYTES if (raw === undefined || raw === '') return DEFAULT_MAX_SCENE_BYTES const parsed = Number.parseInt(raw, 10) if (!Number.isInteger(parsed) || parsed <= 0) { throw new SceneInvalidError('PASCAL_MAX_SCENE_BYTES must be a positive integer') } return parsed } function rowToMeta(row: SceneRow): SceneMeta { return { id: row.id, name: row.name, projectId: row.project_id, ownerId: row.owner_id, thumbnailUrl: row.thumbnail_url, version: row.version, createdAt: row.created_at, updatedAt: row.updated_at, sizeBytes: row.size_bytes, nodeCount: row.node_count, } } function assertValidName(name: string): void { if (typeof name !== 'string') { throw new SceneInvalidError('Scene name must be a string') } const trimmed = name.trim() if (trimmed.length < MIN_NAME_LENGTH || name.length > MAX_NAME_LENGTH) { throw new SceneInvalidError( `Scene name must be ${MIN_NAME_LENGTH}-${MAX_NAME_LENGTH} characters (got ${name.length})`, ) } } function serializeGraph(graph: SceneGraph): string { return JSON.stringify(graph) } function parseGraph(raw: string, context: string): SceneGraph { let parsed: unknown try { parsed = JSON.parse(raw) } catch (err) { throw new SceneInvalidError( `Failed to parse scene graph for ${context}: ${err instanceof Error ? err.message : String(err)}`, ) } const result = GraphSchema.safeParse(parsed) if (!result.success) { throw new SceneInvalidError(`Scene graph for ${context} has invalid shape: ${result.error}`) } const graph = result.data for (const [nodeId, node] of Object.entries(graph.nodes)) { if (!node || typeof node !== 'object' || Array.isArray(node)) { throw new SceneInvalidError(`Scene graph for ${context} has non-object node at "${nodeId}"`) } const typeField = (node as { type?: unknown }).type if (typeof typeField !== 'string' || typeField.length === 0) { throw new SceneInvalidError( `Scene graph for ${context} has node "${nodeId}" missing a string "type"`, ) } } return graph as SceneGraph } function asSceneRow(value: unknown): SceneRow | null { if (!value || typeof value !== 'object') return null return value as SceneRow } /** * SQLite-backed implementation of `SceneStore`. * * Uses one local database file, WAL mode, and transaction-scoped version checks * so a local editor and MCP process can safely share scenes on one machine. */ export class SqliteSceneStore implements SceneStore { readonly backend = 'sqlite' as const readonly databasePath: string private readonly maxSceneBytes: number private db: SqliteDatabase | null = null private dbPromise: Promise | null = null constructor(opts: SqliteSceneStoreOptions = {}) { const env = opts.env ?? process.env this.databasePath = path.resolve(opts.databasePath ?? resolveDefaultDatabasePath(env)) this.maxSceneBytes = resolveMaxSceneBytes(env, opts.maxSceneBytes) } async save(opts: SceneSaveOptions): Promise { return this.withWriteTransaction((db) => { assertValidName(opts.name) if (!opts.graph || typeof opts.graph !== 'object') { throw new SceneInvalidError('graph is required') } const providedId = opts.id const id = providedId ? sanitizeSlug(providedId) : this.generateUniqueId(db) if (!isValidSlug(id)) { throw new SceneInvalidError(`Invalid scene id after sanitization: "${id}"`) } const existing = this.getRow(db, id) if (existing && providedId !== undefined && opts.expectedVersion === undefined) { throw new SceneInvalidError( `Scene with id "${id}" already exists. Pass a different id or provide expectedVersion to overwrite.`, ) } if (opts.expectedVersion !== undefined) { const currentVersion = existing?.version ?? 0 if (currentVersion !== opts.expectedVersion) { throw new SceneVersionConflictError( `Scene "${id}" version mismatch: expected ${opts.expectedVersion}, got ${currentVersion}`, ) } } const graphJson = serializeGraph(opts.graph) const sizeBytes = Buffer.byteLength(graphJson, 'utf8') if (sizeBytes > this.maxSceneBytes) { throw new SceneTooLargeError( `Scene "${id}" is ${sizeBytes} bytes, exceeds cap of ${this.maxSceneBytes} bytes`, ) } const now = new Date().toISOString() const version = (existing?.version ?? 0) + 1 const createdAt = existing?.created_at ?? now const nodeCount = Object.keys(opts.graph.nodes ?? {}).length if (existing) { db.query( `UPDATE scenes SET name = ?, project_id = ?, owner_id = ?, thumbnail_url = ?, version = ?, updated_at = ?, size_bytes = ?, node_count = ?, graph_json = ? WHERE id = ?`, ).run( opts.name, opts.projectId ?? null, opts.ownerId ?? null, opts.thumbnailUrl ?? null, version, now, sizeBytes, nodeCount, graphJson, id, ) } else { db.query( `INSERT INTO scenes ( id, name, project_id, owner_id, thumbnail_url, version, created_at, updated_at, size_bytes, node_count, graph_json ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, ).run( id, opts.name, opts.projectId ?? null, opts.ownerId ?? null, opts.thumbnailUrl ?? null, version, createdAt, now, sizeBytes, nodeCount, graphJson, ) } db.query( `INSERT INTO scene_revisions ( scene_id, version, graph_json, author_kind, author_id, created_at ) VALUES (?, ?, ?, ?, ?, ?)`, ).run(id, version, graphJson, 'mcp', opts.ownerId ?? null, now) return { id, name: opts.name, projectId: opts.projectId ?? null, ownerId: opts.ownerId ?? null, thumbnailUrl: opts.thumbnailUrl ?? null, version, createdAt, updatedAt: now, sizeBytes, nodeCount, } }) } async load(id: string): Promise { const db = await this.database() const row = this.getRow(db, sanitizeSlug(id)) if (!row) return null return { ...rowToMeta(row), graph: parseGraph(row.graph_json, row.id), } } async list(opts: SceneListOptions = {}): Promise { const clauses: string[] = [] const bindings: Array = [] if (opts.projectId !== undefined) { clauses.push('project_id = ?') bindings.push(opts.projectId) } if (opts.ownerId !== undefined) { clauses.push('owner_id = ?') bindings.push(opts.ownerId) } const requestedLimit = opts.limit ?? DEFAULT_LIST_LIMIT const limit = Number.isInteger(requestedLimit) && requestedLimit >= 0 ? requestedLimit : 0 bindings.push(limit) const where = clauses.length > 0 ? `WHERE ${clauses.join(' AND ')}` : '' const db = await this.database() const rows = db .query( `SELECT id, name, project_id, owner_id, thumbnail_url, version, created_at, updated_at, size_bytes, node_count, graph_json FROM scenes ${where} ORDER BY updated_at DESC, id ASC LIMIT ?`, ) .all(...bindings) return rows.map((row) => rowToMeta(row as SceneRow)) } async delete(id: string, opts: SceneMutateOptions = {}): Promise { return this.withWriteTransaction((db) => { const safeId = sanitizeSlug(id) const existing = this.getRow(db, safeId) if (!existing) return false if (opts.expectedVersion !== undefined && existing.version !== opts.expectedVersion) { throw new SceneVersionConflictError( `Scene "${safeId}" version mismatch: expected ${opts.expectedVersion}, got ${existing.version}`, ) } db.query('DELETE FROM scenes WHERE id = ?').run(safeId) return true }) } async rename(id: string, newName: string, opts: SceneMutateOptions = {}): Promise { return this.withWriteTransaction((db) => { assertValidName(newName) const safeId = sanitizeSlug(id) const existing = this.getRow(db, safeId) if (!existing) { throw new SceneNotFoundError(`Scene "${safeId}" not found`) } if (opts.expectedVersion !== undefined && existing.version !== opts.expectedVersion) { throw new SceneVersionConflictError( `Scene "${safeId}" version mismatch: expected ${opts.expectedVersion}, got ${existing.version}`, ) } const now = new Date().toISOString() const nextVersion = existing.version + 1 db.query('UPDATE scenes SET name = ?, version = ?, updated_at = ? WHERE id = ?').run( newName, nextVersion, now, safeId, ) db.query( `INSERT INTO scene_revisions ( scene_id, version, graph_json, author_kind, author_id, created_at ) VALUES (?, ?, ?, ?, ?, ?)`, ).run(safeId, nextVersion, existing.graph_json, 'mcp', existing.owner_id, now) return { ...rowToMeta(existing), name: newName, version: nextVersion, updatedAt: now, } }) } close(): void { this.db?.close() this.db = null this.dbPromise = null } private async database(): Promise { if (this.db) return this.db if (!this.dbPromise) { this.dbPromise = (async () => { mkdirSync(path.dirname(this.databasePath), { recursive: true }) const db = await openSqliteDatabase(this.databasePath) db.exec('PRAGMA foreign_keys = ON') db.exec('PRAGMA journal_mode = WAL') db.exec('PRAGMA busy_timeout = 5000') this.migrate(db) this.db = db return db })() } return this.dbPromise } private migrate(db: SqliteDatabase): void { db.exec(` CREATE TABLE IF NOT EXISTS scenes ( id TEXT PRIMARY KEY, name TEXT NOT NULL CHECK (length(name) >= 1 AND length(name) <= 200), project_id TEXT, owner_id TEXT, thumbnail_url TEXT, version INTEGER NOT NULL CHECK (version >= 1), created_at TEXT NOT NULL, updated_at TEXT NOT NULL, size_bytes INTEGER NOT NULL CHECK (size_bytes >= 0), node_count INTEGER NOT NULL CHECK (node_count >= 0), graph_json TEXT NOT NULL ); CREATE INDEX IF NOT EXISTS scenes_project_updated_idx ON scenes(project_id, updated_at DESC); CREATE INDEX IF NOT EXISTS scenes_owner_updated_idx ON scenes(owner_id, updated_at DESC); CREATE TABLE IF NOT EXISTS scene_revisions ( scene_id TEXT NOT NULL, version INTEGER NOT NULL CHECK (version >= 1), graph_json TEXT NOT NULL, author_kind TEXT NOT NULL, author_id TEXT, created_at TEXT NOT NULL, PRIMARY KEY (scene_id, version), FOREIGN KEY (scene_id) REFERENCES scenes(id) ON DELETE CASCADE ); `) } private async withWriteTransaction(fn: (db: SqliteDatabase) => T | Promise): Promise { const db = await this.database() db.exec('BEGIN IMMEDIATE') try { const result = await fn(db) db.exec('COMMIT') return result } catch (err) { try { db.exec('ROLLBACK') } catch { // Ignore rollback errors so the original failure is preserved. } throw err } } private getRow(db: SqliteDatabase, id: string): SceneRow | null { return asSceneRow( db .query( `SELECT id, name, project_id, owner_id, thumbnail_url, version, created_at, updated_at, size_bytes, node_count, graph_json FROM scenes WHERE id = ?`, ) .get(id), ) } private generateUniqueId(db: SqliteDatabase): string { for (let attempt = 0; attempt < 20; attempt++) { const id = generateSlug() if (!this.getRow(db, id)) return id } throw new SceneInvalidError('Failed to generate a unique scene id') } }