496 lines
15 KiB
TypeScript
496 lines
15 KiB
TypeScript
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<SqliteDatabase> | 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<SceneMeta> {
|
|
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<SceneWithGraph | null> {
|
|
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<SceneMeta[]> {
|
|
const clauses: string[] = []
|
|
const bindings: Array<string | number> = []
|
|
|
|
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<boolean> {
|
|
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<SceneMeta> {
|
|
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<SqliteDatabase> {
|
|
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<T>(fn: (db: SqliteDatabase) => T | Promise<T>): Promise<T> {
|
|
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')
|
|
}
|
|
}
|