fix(mcp): use local sqlite scene storage

This commit is contained in:
Aymeric Rabot
2026-04-24 13:32:04 -07:00
parent 06d00566f0
commit b3d1f663f6
119 changed files with 1086 additions and 23694 deletions
@@ -1,599 +0,0 @@
import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
import * as fs from 'node:fs/promises'
import * as os from 'node:os'
import * as path from 'node:path'
import type { SceneGraph } from '@pascal-app/core/clone-scene-graph'
import {
FilesystemSceneStore,
type FilesystemSceneStoreOptions,
resolveDefaultRootDir,
} from './filesystem-scene-store'
import { SceneInvalidError, SceneTooLargeError, SceneVersionConflictError } from './types'
function makeGraph(overrides: Partial<SceneGraph> = {}): SceneGraph {
return {
nodes: {
site_abc: {
object: 'node',
id: 'site_abc',
type: 'site',
parentId: null,
visible: true,
metadata: {},
},
building_def: {
object: 'node',
id: 'building_def',
type: 'building',
parentId: 'site_abc',
visible: true,
metadata: {},
},
} as SceneGraph['nodes'],
rootNodeIds: ['site_abc'] as SceneGraph['rootNodeIds'],
...overrides,
}
}
async function mkTmpRoot(): Promise<string> {
return fs.mkdtemp(path.join(os.tmpdir(), 'pascal-test-'))
}
async function rmrf(p: string): Promise<void> {
await fs.rm(p, { recursive: true, force: true })
}
function createStore(rootDir: string, opts: Partial<FilesystemSceneStoreOptions> = {}) {
return new FilesystemSceneStore({ rootDir, ...opts })
}
describe('resolveDefaultRootDir', () => {
test('respects PASCAL_DATA_DIR when set', () => {
const dir = resolveDefaultRootDir({ PASCAL_DATA_DIR: '/custom/pascal' })
expect(dir).toBe('/custom/pascal')
})
test('ignores empty PASCAL_DATA_DIR', () => {
const dir = resolveDefaultRootDir({ PASCAL_DATA_DIR: '', HOME: '/home/user' })
expect(dir.endsWith(path.join('.pascal', 'data'))).toBe(true)
})
test('falls back to XDG_DATA_HOME', () => {
if (process.platform === 'win32') return
const dir = resolveDefaultRootDir({ XDG_DATA_HOME: '/xdg/share' })
expect(dir).toBe(path.join('/xdg/share', 'pascal', 'data'))
})
test('falls back to homedir + .pascal/data', () => {
if (process.platform === 'win32') return
const dir = resolveDefaultRootDir({})
expect(dir.endsWith(path.join('.pascal', 'data'))).toBe(true)
})
})
describe('FilesystemSceneStore', () => {
let rootDir: string
let store: FilesystemSceneStore
beforeEach(async () => {
rootDir = await mkTmpRoot()
store = createStore(rootDir)
})
afterEach(async () => {
await rmrf(rootDir)
})
// ----------- Construction / defaults -----------
test('backend is "filesystem"', () => {
expect(store.backend).toBe('filesystem')
})
test('resolves default root when no rootDir is passed', () => {
const fallback = new FilesystemSceneStore({ env: { PASCAL_DATA_DIR: rootDir } })
expect(fallback.backend).toBe('filesystem')
})
// ----------- save() -----------
test('generates an id when none is provided', async () => {
const meta = await store.save({ name: 'Scratch', graph: makeGraph() })
expect(typeof meta.id).toBe('string')
expect(meta.id.length).toBeGreaterThan(0)
expect(meta.version).toBe(1)
})
test('round-trip save → load preserves graph exactly', async () => {
const graph = makeGraph()
const saved = await store.save({ id: 'kitchen', name: 'Kitchen', graph })
expect(saved.id).toBe('kitchen')
const loaded = await store.load('kitchen')
expect(loaded).not.toBeNull()
expect(loaded!.graph).toEqual(graph)
expect(loaded!.name).toBe('Kitchen')
expect(loaded!.nodeCount).toBe(2)
expect(loaded!.version).toBe(1)
})
test('stores projectId, ownerId, and thumbnailUrl verbatim', async () => {
await store.save({
id: 'meta-test',
name: 'Meta',
graph: makeGraph(),
projectId: 'proj-1',
ownerId: 'user-42',
thumbnailUrl: 'https://example.com/t.png',
})
const loaded = await store.load('meta-test')
expect(loaded?.projectId).toBe('proj-1')
expect(loaded?.ownerId).toBe('user-42')
expect(loaded?.thumbnailUrl).toBe('https://example.com/t.png')
})
test('version bumps by 1 each save', async () => {
const first = await store.save({ id: 'bump', name: 'Bump', graph: makeGraph() })
expect(first.version).toBe(1)
const second = await store.save({
id: 'bump',
name: 'Bump',
graph: makeGraph(),
expectedVersion: 1,
})
expect(second.version).toBe(2)
const third = await store.save({
id: 'bump',
name: 'Bump',
graph: makeGraph(),
expectedVersion: 2,
})
expect(third.version).toBe(3)
})
test('preserves createdAt on overwrite, updates updatedAt', async () => {
const first = await store.save({ id: 'times', name: 'T', graph: makeGraph() })
await new Promise((r) => setTimeout(r, 5))
const second = await store.save({
id: 'times',
name: 'T',
graph: makeGraph(),
expectedVersion: 1,
})
expect(second.createdAt).toBe(first.createdAt)
expect(second.updatedAt >= first.updatedAt).toBe(true)
})
test('expectedVersion mismatch throws SceneVersionConflictError', async () => {
await store.save({ id: 'conflict', name: 'C', graph: makeGraph() })
await expect(
store.save({ id: 'conflict', name: 'C', graph: makeGraph(), expectedVersion: 99 }),
).rejects.toThrow(SceneVersionConflictError)
})
test('expectedVersion=0 matches a brand-new id', async () => {
const meta = await store.save({
id: 'fresh',
name: 'Fresh',
graph: makeGraph(),
expectedVersion: 0,
})
expect(meta.version).toBe(1)
})
test('slug collision (no expectedVersion) throws', async () => {
await store.save({ id: 'kitchen', name: 'K1', graph: makeGraph() })
await expect(store.save({ id: 'kitchen', name: 'K2', graph: makeGraph() })).rejects.toThrow(
SceneInvalidError,
)
})
test('save without id never collides (generates unique slug)', async () => {
const a = await store.save({ name: 'A', graph: makeGraph() })
const b = await store.save({ name: 'B', graph: makeGraph() })
expect(a.id).not.toBe(b.id)
})
test('name length 0 throws', async () => {
await expect(store.save({ name: '', graph: makeGraph() })).rejects.toThrow(SceneInvalidError)
})
test('name length 201 throws', async () => {
const longName = 'x'.repeat(201)
await expect(store.save({ name: longName, graph: makeGraph() })).rejects.toThrow(
SceneInvalidError,
)
})
test('name length 200 is accepted', async () => {
const name = 'x'.repeat(200)
const meta = await store.save({ name, graph: makeGraph() })
expect(meta.name).toBe(name)
})
test('non-string name throws', async () => {
await expect(
store.save({ name: 123 as unknown as string, graph: makeGraph() }),
).rejects.toThrow(SceneInvalidError)
})
test('whitespace-only name throws', async () => {
await expect(store.save({ name: ' ', graph: makeGraph() })).rejects.toThrow(SceneInvalidError)
})
test('too-large scene throws SceneTooLargeError', async () => {
// Build a graph that encodes to > 10 MB in pretty JSON.
const nodes: Record<string, unknown> = {}
const bigBlob = 'A'.repeat(2048)
for (let i = 0; i < 6000; i++) {
nodes[`site_${i}`] = {
object: 'node',
id: `site_${i}`,
type: 'site',
parentId: null,
visible: true,
metadata: { blob: bigBlob },
}
}
const graph = {
nodes,
rootNodeIds: Object.keys(nodes),
} as unknown as SceneGraph
await expect(store.save({ name: 'Big', graph })).rejects.toThrow(SceneTooLargeError)
})
test('sanitizes id with path traversal attempt', async () => {
const meta = await store.save({ id: '../escape', name: 'Evil', graph: makeGraph() })
expect(meta.id).toBe('escape')
const filesInScenes = await fs.readdir(path.join(rootDir, 'scenes'))
expect(filesInScenes).toContain('escape.json')
// Nothing wrote outside the scenes dir
const rootEntries = await fs.readdir(rootDir)
expect(rootEntries).toEqual(['scenes'])
})
test('sanitizes mixed-case / whitespace id', async () => {
const meta = await store.save({ id: 'My Kitchen!', name: 'Kitchen', graph: makeGraph() })
expect(meta.id).toBe('my-kitchen')
})
test('fails fast if sanitized id is empty', async () => {
await expect(store.save({ id: '!!!', name: 'Bad', graph: makeGraph() })).rejects.toThrow()
})
test('pretty-prints JSON with 2-space indent', async () => {
await store.save({ id: 'pretty', name: 'P', graph: makeGraph() })
const raw = await fs.readFile(path.join(rootDir, 'scenes', 'pretty.json'), 'utf8')
expect(raw.includes('\n "meta"')).toBe(true)
})
test('sizeBytes reflects on-disk byte length', async () => {
const meta = await store.save({ id: 'sized', name: 'S', graph: makeGraph() })
const stat = await fs.stat(path.join(rootDir, 'scenes', 'sized.json'))
expect(meta.sizeBytes).toBe(stat.size)
})
test('nodeCount equals Object.keys(graph.nodes).length', async () => {
const meta = await store.save({ id: 'count', name: 'C', graph: makeGraph() })
expect(meta.nodeCount).toBe(2)
})
test('writes index sidecar after save', async () => {
await store.save({ id: 'idx-a', name: 'A', graph: makeGraph() })
const idxRaw = await fs.readFile(path.join(rootDir, 'scenes', '.index.json'), 'utf8')
const parsed = JSON.parse(idxRaw) as Array<{ id: string }>
expect(parsed.map((m) => m.id)).toContain('idx-a')
})
// ----------- load() -----------
test('load returns null for missing file', async () => {
const result = await store.load('nonexistent')
expect(result).toBeNull()
})
test('load throws SceneInvalidError for non-object nodes', async () => {
// Write bogus contents directly.
await fs.mkdir(path.join(rootDir, 'scenes'), { recursive: true })
const bogus = {
meta: {
id: 'bogus',
name: 'Bogus',
projectId: null,
thumbnailUrl: null,
version: 1,
createdAt: '2024-01-01T00:00:00.000Z',
updatedAt: '2024-01-01T00:00:00.000Z',
ownerId: null,
sizeBytes: 0,
nodeCount: 1,
},
graph: {
nodes: { site_x: 'not-an-object' },
rootNodeIds: ['site_x'],
},
}
await fs.writeFile(
path.join(rootDir, 'scenes', 'bogus.json'),
JSON.stringify(bogus, null, 2),
'utf8',
)
await expect(store.load('bogus')).rejects.toThrow(SceneInvalidError)
})
test('load throws SceneInvalidError when nodes is not an object', async () => {
await fs.mkdir(path.join(rootDir, 'scenes'), { recursive: true })
const badShape = {
meta: {
id: 'badshape',
name: 'B',
projectId: null,
thumbnailUrl: null,
version: 1,
createdAt: '2024-01-01T00:00:00.000Z',
updatedAt: '2024-01-01T00:00:00.000Z',
ownerId: null,
sizeBytes: 0,
nodeCount: 0,
},
graph: {
nodes: 'hello',
rootNodeIds: [],
},
}
await fs.writeFile(
path.join(rootDir, 'scenes', 'badshape.json'),
JSON.stringify(badShape),
'utf8',
)
await expect(store.load('badshape')).rejects.toThrow(SceneInvalidError)
})
test('load throws SceneInvalidError for node missing "type"', async () => {
await fs.mkdir(path.join(rootDir, 'scenes'), { recursive: true })
const noType = {
meta: {
id: 'notype',
name: 'N',
projectId: null,
thumbnailUrl: null,
version: 1,
createdAt: '2024-01-01T00:00:00.000Z',
updatedAt: '2024-01-01T00:00:00.000Z',
ownerId: null,
sizeBytes: 0,
nodeCount: 1,
},
graph: {
nodes: { site_x: { id: 'site_x' } },
rootNodeIds: ['site_x'],
},
}
await fs.writeFile(path.join(rootDir, 'scenes', 'notype.json'), JSON.stringify(noType), 'utf8')
await expect(store.load('notype')).rejects.toThrow(SceneInvalidError)
})
test('load throws SceneInvalidError for unparseable JSON', async () => {
await fs.mkdir(path.join(rootDir, 'scenes'), { recursive: true })
await fs.writeFile(path.join(rootDir, 'scenes', 'garbage.json'), '{not json', 'utf8')
await expect(store.load('garbage')).rejects.toThrow(SceneInvalidError)
})
// ----------- list() -----------
test('list returns [] when scenes dir is empty or absent', async () => {
expect(await store.list()).toEqual([])
})
test('list finds all saved scenes', async () => {
await store.save({ id: 'a', name: 'A', graph: makeGraph() })
await store.save({ id: 'b', name: 'B', graph: makeGraph() })
await store.save({ id: 'c', name: 'C', graph: makeGraph() })
const list = await store.list()
expect(list.map((m) => m.id).sort()).toEqual(['a', 'b', 'c'])
})
test('list uses index sidecar as fast path', async () => {
await store.save({ id: 'fast', name: 'F', graph: makeGraph() })
// Corrupt the on-disk json so collectAllMeta would fail; the index should
// still list the entry as long as the file exists.
const list = await store.list()
expect(list.map((m) => m.id)).toContain('fast')
})
test('list falls back to readdir when index is absent', async () => {
await store.save({ id: 'slow', name: 'S', graph: makeGraph() })
await fs.unlink(path.join(rootDir, 'scenes', '.index.json'))
const list = await store.list()
expect(list.map((m) => m.id)).toContain('slow')
})
test('list filters by projectId', async () => {
await store.save({ id: 'p1-a', name: 'A', graph: makeGraph(), projectId: 'p1' })
await store.save({ id: 'p1-b', name: 'B', graph: makeGraph(), projectId: 'p1' })
await store.save({ id: 'p2-c', name: 'C', graph: makeGraph(), projectId: 'p2' })
const result = await store.list({ projectId: 'p1' })
expect(result.map((m) => m.id).sort()).toEqual(['p1-a', 'p1-b'])
})
test('list filters by ownerId', async () => {
await store.save({ id: 'u1-a', name: 'A', graph: makeGraph(), ownerId: 'u1' })
await store.save({ id: 'u2-b', name: 'B', graph: makeGraph(), ownerId: 'u2' })
const result = await store.list({ ownerId: 'u1' })
expect(result.map((m) => m.id)).toEqual(['u1-a'])
})
test('list respects limit', async () => {
await store.save({ id: 'l1', name: '1', graph: makeGraph() })
await store.save({ id: 'l2', name: '2', graph: makeGraph() })
await store.save({ id: 'l3', name: '3', graph: makeGraph() })
const result = await store.list({ limit: 2 })
expect(result.length).toBe(2)
})
test('list sorts by updatedAt desc', async () => {
await store.save({ id: 'first', name: '1', graph: makeGraph() })
await new Promise((r) => setTimeout(r, 10))
await store.save({ id: 'second', name: '2', graph: makeGraph() })
const result = await store.list()
expect(result[0]?.id).toBe('second')
expect(result[1]?.id).toBe('first')
})
test('list ignores tmp files and non-json entries', async () => {
await store.save({ id: 'real', name: 'R', graph: makeGraph() })
await fs.unlink(path.join(rootDir, 'scenes', '.index.json'))
await fs.writeFile(path.join(rootDir, 'scenes', 'stray.txt'), 'ignored', 'utf8')
await fs.writeFile(path.join(rootDir, 'scenes', 'real.json.tmp'), '{}', 'utf8')
const result = await store.list()
expect(result.map((m) => m.id)).toEqual(['real'])
})
test('list drops index entries whose file was removed out-of-band', async () => {
await store.save({ id: 'vanish', name: 'V', graph: makeGraph() })
await store.save({ id: 'keep', name: 'K', graph: makeGraph() })
// Bypass delete() — simulate another tool removing the file without updating the index
await fs.unlink(path.join(rootDir, 'scenes', 'vanish.json'))
const result = await store.list()
expect(result.map((m) => m.id)).toEqual(['keep'])
})
// ----------- delete() -----------
test('delete removes file and returns true', async () => {
await store.save({ id: 'del', name: 'D', graph: makeGraph() })
const ok = await store.delete('del')
expect(ok).toBe(true)
expect(await store.load('del')).toBeNull()
})
test('delete returns false for missing scene', async () => {
expect(await store.delete('ghost')).toBe(false)
})
test('delete with matching expectedVersion succeeds', async () => {
await store.save({ id: 'dv', name: 'D', graph: makeGraph() })
const ok = await store.delete('dv', { expectedVersion: 1 })
expect(ok).toBe(true)
})
test('delete with mismatched expectedVersion throws', async () => {
await store.save({ id: 'dvx', name: 'D', graph: makeGraph() })
await expect(store.delete('dvx', { expectedVersion: 99 })).rejects.toThrow(
SceneVersionConflictError,
)
})
test('delete updates index', async () => {
await store.save({ id: 'i1', name: '1', graph: makeGraph() })
await store.save({ id: 'i2', name: '2', graph: makeGraph() })
await store.delete('i1')
const idx = JSON.parse(
await fs.readFile(path.join(rootDir, 'scenes', '.index.json'), 'utf8'),
) as Array<{ id: string }>
expect(idx.map((m) => m.id)).toEqual(['i2'])
})
// ----------- rename() -----------
test('rename updates name and bumps version', async () => {
await store.save({ id: 'ren', name: 'Original', graph: makeGraph() })
const renamed = await store.rename('ren', 'Shiny')
expect(renamed.name).toBe('Shiny')
expect(renamed.version).toBe(2)
const loaded = await store.load('ren')
expect(loaded?.name).toBe('Shiny')
})
test('rename preserves graph exactly', async () => {
const graph = makeGraph()
await store.save({ id: 'rg', name: 'Before', graph })
await store.rename('rg', 'After')
const loaded = await store.load('rg')
expect(loaded?.graph).toEqual(graph)
})
test('rename preserves projectId / ownerId / thumbnailUrl', async () => {
await store.save({
id: 'rmeta',
name: 'Before',
graph: makeGraph(),
projectId: 'p',
ownerId: 'u',
thumbnailUrl: 'https://x.y/z',
})
const renamed = await store.rename('rmeta', 'After')
expect(renamed.projectId).toBe('p')
expect(renamed.ownerId).toBe('u')
expect(renamed.thumbnailUrl).toBe('https://x.y/z')
})
test('rename with matching expectedVersion succeeds', async () => {
await store.save({ id: 'rv', name: 'A', graph: makeGraph() })
const renamed = await store.rename('rv', 'B', { expectedVersion: 1 })
expect(renamed.version).toBe(2)
})
test('rename with mismatched expectedVersion throws', async () => {
await store.save({ id: 'rvx', name: 'A', graph: makeGraph() })
await expect(store.rename('rvx', 'B', { expectedVersion: 99 })).rejects.toThrow(
SceneVersionConflictError,
)
})
test('rename on missing scene throws SceneInvalidError', async () => {
await expect(store.rename('ghost', 'X')).rejects.toThrow(SceneInvalidError)
})
test('rename validates name length', async () => {
await store.save({ id: 'rnl', name: 'A', graph: makeGraph() })
await expect(store.rename('rnl', '')).rejects.toThrow(SceneInvalidError)
await expect(store.rename('rnl', 'x'.repeat(201))).rejects.toThrow(SceneInvalidError)
})
// ----------- Integration: delete + list + rename round-trip -----------
test('round-trip: save → rename → list → delete', async () => {
await store.save({ id: 'rt1', name: 'One', graph: makeGraph() })
await store.save({ id: 'rt2', name: 'Two', graph: makeGraph() })
await store.rename('rt1', 'Uno')
const listed = await store.list()
const renamed = listed.find((m) => m.id === 'rt1')
expect(renamed?.name).toBe('Uno')
expect(renamed?.version).toBe(2)
expect(await store.delete('rt2')).toBe(true)
const after = await store.list()
expect(after.map((m) => m.id)).toEqual(['rt1'])
})
// ----------- Atomic write / concurrency -----------
test('atomic write does not leave tmp files on success', async () => {
await store.save({ id: 'atomic', name: 'A', graph: makeGraph() })
const entries = await fs.readdir(path.join(rootDir, 'scenes'))
expect(entries.some((e) => e.endsWith('.tmp'))).toBe(false)
})
test('concurrent saves do not leave a torn file', async () => {
// Atomic rename guarantees the on-disk file is always a complete,
// parseable snapshot even under parallel writes. We don't guarantee that
// optimistic version checks serialize writers — that requires an external
// lock — but each write either succeeds or rejects cleanly, and the
// final file is always loadable.
await store.save({ id: 'race', name: 'Race', graph: makeGraph() })
const attempts = await Promise.allSettled(
Array.from({ length: 4 }, (_, i) =>
store.save({
id: 'race',
name: `Race-${i}`,
graph: makeGraph(),
expectedVersion: 1,
}),
),
)
expect(attempts.every((a) => a.status === 'fulfilled' || a.status === 'rejected')).toBe(true)
const loaded = await store.load('race')
expect(loaded).not.toBeNull()
// At least one concurrent save committed, so the version advanced.
expect(loaded!.version).toBeGreaterThanOrEqual(2)
})
})
@@ -1,388 +0,0 @@
import { constants as fsConstants } from 'node:fs'
import * as fs from 'node:fs/promises'
import * as os from 'node:os'
import * as path from 'node:path'
import { z } from 'zod'
import { generateSlug, isValidSlug, sanitizeSlug } from './slug'
import {
SceneInvalidError,
type SceneListOptions,
type SceneMeta,
type SceneMutateOptions,
type SceneSaveOptions,
type SceneStore,
SceneTooLargeError,
SceneVersionConflictError,
type SceneWithGraph,
} from './types'
const MAX_SCENE_BYTES = 10 * 1024 * 1024 // 10 MB
const MAX_NAME_LENGTH = 200
const MIN_NAME_LENGTH = 1
const SCENES_SUBDIR = 'scenes'
const INDEX_FILE = '.index.json'
const TMP_SUFFIX = '.tmp'
/**
* Options for constructing a `FilesystemSceneStore`.
*/
export interface FilesystemSceneStoreOptions {
/** Root directory for scene storage. If omitted, resolved from env. */
rootDir?: string
/** Optional env override for default root resolution. */
env?: NodeJS.ProcessEnv
}
/**
* Resolves the default root directory for on-disk scene storage.
*
* Precedence:
* 1. `PASCAL_DATA_DIR`
* 2. On Windows: `%APPDATA%/Pascal/data`
* 3. `$XDG_DATA_HOME/pascal/data`
* 4. `$HOME/.pascal/data`
*/
export function resolveDefaultRootDir(env: NodeJS.ProcessEnv = process.env): string {
if (env.PASCAL_DATA_DIR && env.PASCAL_DATA_DIR.length > 0) {
return env.PASCAL_DATA_DIR
}
if (process.platform === 'win32') {
const appData = env.APPDATA
if (appData && appData.length > 0) {
return path.join(appData, 'Pascal', 'data')
}
return path.join(os.homedir(), '.pascal', 'data')
}
const xdg = env.XDG_DATA_HOME
if (xdg && xdg.length > 0) {
return path.join(xdg, 'pascal', 'data')
}
return path.join(os.homedir(), '.pascal', 'data')
}
/**
* Zod schema used to validate the top-level envelope of a persisted scene file.
* Kept intentionally lax — we validate `meta` fields inline and each node's shape
* via `Object.keys` length + per-node shape checks for performance.
*/
const PersistedSceneSchema = z.object({
meta: z.object({
id: z.string(),
name: z.string(),
projectId: z.string().nullable(),
thumbnailUrl: z.string().nullable(),
version: z.number().int().nonnegative(),
createdAt: z.string(),
updatedAt: z.string(),
ownerId: z.string().nullable(),
sizeBytes: z.number().int().nonnegative(),
nodeCount: z.number().int().nonnegative(),
}),
graph: z.object({
nodes: z.record(z.string(), z.unknown()),
rootNodeIds: z.array(z.string()),
collections: z.record(z.string(), z.unknown()).optional(),
}),
})
type PersistedScene = z.infer<typeof PersistedSceneSchema>
/**
* File-backed implementation of `SceneStore`.
*
* Persists each scene as `<root>/scenes/<id>.json` with an optional sidecar
* index file `<root>/scenes/.index.json` for fast listing.
*
* Writes are atomic via tmp file + rename. Saves bump `meta.version` by 1 and
* honor `expectedVersion` for optimistic concurrency control. Reads return
* `null` for missing files and throw `SceneInvalidError` when a file on disk
* has become corrupt.
*/
export class FilesystemSceneStore implements SceneStore {
readonly backend = 'filesystem' as const
private readonly rootDir: string
private readonly scenesDir: string
private readonly indexPath: string
constructor(opts: FilesystemSceneStoreOptions = {}) {
const root = opts.rootDir ?? resolveDefaultRootDir(opts.env ?? process.env)
this.rootDir = path.resolve(root)
this.scenesDir = path.join(this.rootDir, SCENES_SUBDIR)
this.indexPath = path.join(this.scenesDir, INDEX_FILE)
}
async save(opts: SceneSaveOptions): Promise<SceneMeta> {
this.assertValidName(opts.name)
const providedId = opts.id
const id = providedId ? sanitizeSlug(providedId) : generateSlug()
if (!isValidSlug(id)) {
throw new SceneInvalidError(`Invalid scene id after sanitization: "${id}"`)
}
await this.ensureScenesDir()
const finalPath = this.scenePath(id)
const existing = await this.readPersisted(id)
// Slug collision check: only when caller passed an explicit id
// and `expectedVersion` is NOT provided (i.e. this is treated as a create).
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.`,
)
}
// Optimistic concurrency
if (opts.expectedVersion !== undefined) {
const currentVersion = existing?.meta.version ?? 0
if (currentVersion !== opts.expectedVersion) {
throw new SceneVersionConflictError(
`Scene "${id}" version mismatch: expected ${opts.expectedVersion}, got ${currentVersion}`,
)
}
}
const now = new Date().toISOString()
const createdAt = existing?.meta.createdAt ?? now
const nextVersion = (existing?.meta.version ?? 0) + 1
const nodeCount = Object.keys(opts.graph.nodes).length
// Assemble meta + record so we can measure the final serialized size.
// sizeBytes is filled in after we know the encoded length.
const meta: SceneMeta = {
id,
name: opts.name,
projectId: opts.projectId ?? null,
thumbnailUrl: opts.thumbnailUrl ?? null,
version: nextVersion,
createdAt,
updatedAt: now,
ownerId: opts.ownerId ?? null,
sizeBytes: 0,
nodeCount,
}
const record: PersistedScene = { meta, graph: opts.graph as PersistedScene['graph'] }
// Iterate until sizeBytes is stable: encoding the size changes the
// resulting byte count if the digit width shifts, so fixed-point it.
let json = this.serialize(record)
let sizeBytes = Buffer.byteLength(json, 'utf8')
// Fixed-point loop, bounded to avoid infinite cycles on pathological inputs.
for (let guard = 0; guard < 5; guard++) {
meta.sizeBytes = sizeBytes
record.meta = meta
const next = this.serialize(record)
const nextSize = Buffer.byteLength(next, 'utf8')
if (nextSize === sizeBytes) {
json = next
break
}
json = next
sizeBytes = nextSize
}
if (sizeBytes > MAX_SCENE_BYTES) {
throw new SceneTooLargeError(
`Scene "${id}" is ${sizeBytes} bytes, exceeds cap of ${MAX_SCENE_BYTES} bytes`,
)
}
await this.atomicWrite(finalPath, json)
await this.writeIndex(await this.collectAllMeta())
return meta
}
async load(id: string): Promise<SceneWithGraph | null> {
const safeId = sanitizeSlug(id)
const record = await this.readPersisted(safeId)
if (!record) return null
return { ...record.meta, graph: record.graph as SceneWithGraph['graph'] }
}
async list(opts: SceneListOptions = {}): Promise<SceneMeta[]> {
const metas = (await this.readIndex()) ?? (await this.collectAllMeta())
let filtered = metas
if (opts.projectId !== undefined) {
filtered = filtered.filter((m) => m.projectId === opts.projectId)
}
if (opts.ownerId !== undefined) {
filtered = filtered.filter((m) => m.ownerId === opts.ownerId)
}
filtered = filtered.slice().sort((a, b) => (a.updatedAt < b.updatedAt ? 1 : -1))
if (opts.limit !== undefined && opts.limit >= 0) {
filtered = filtered.slice(0, opts.limit)
}
return filtered
}
async delete(id: string, opts: SceneMutateOptions = {}): Promise<boolean> {
const safeId = sanitizeSlug(id)
const existing = await this.readPersisted(safeId)
if (!existing) return false
if (opts.expectedVersion !== undefined && existing.meta.version !== opts.expectedVersion) {
throw new SceneVersionConflictError(
`Scene "${safeId}" version mismatch: expected ${opts.expectedVersion}, got ${existing.meta.version}`,
)
}
const finalPath = this.scenePath(safeId)
await fs.unlink(finalPath).catch((err: NodeJS.ErrnoException) => {
if (err.code !== 'ENOENT') throw err
})
await this.writeIndex(await this.collectAllMeta())
return true
}
async rename(id: string, newName: string, opts: SceneMutateOptions = {}): Promise<SceneMeta> {
this.assertValidName(newName)
const safeId = sanitizeSlug(id)
const existing = await this.readPersisted(safeId)
if (!existing) {
throw new SceneInvalidError(`Scene "${safeId}" not found`)
}
return this.save({
id: safeId,
name: newName,
projectId: existing.meta.projectId,
ownerId: existing.meta.ownerId,
thumbnailUrl: existing.meta.thumbnailUrl,
graph: existing.graph as SceneWithGraph['graph'],
expectedVersion: opts.expectedVersion ?? existing.meta.version,
})
}
// ---------- Internal helpers ----------
private scenePath(id: string): string {
return path.join(this.scenesDir, `${id}.json`)
}
private 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})`,
)
}
}
private serialize(record: PersistedScene): string {
return JSON.stringify(record, null, 2)
}
private async ensureScenesDir(): Promise<void> {
await fs.mkdir(this.scenesDir, { recursive: true })
}
private async atomicWrite(finalPath: string, contents: string): Promise<void> {
const tmpPath = `${finalPath}.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2, 8)}${TMP_SUFFIX}`
await fs.writeFile(tmpPath, contents, { encoding: 'utf8', flag: 'w' })
try {
await fs.rename(tmpPath, finalPath)
} catch (err) {
await fs.unlink(tmpPath).catch(() => {})
throw err
}
}
private async readPersisted(id: string): Promise<PersistedScene | null> {
const filePath = this.scenePath(id)
let raw: string
try {
raw = await fs.readFile(filePath, 'utf8')
} catch (err) {
const e = err as NodeJS.ErrnoException
if (e.code === 'ENOENT') return null
throw err
}
return this.parseRecord(raw, filePath)
}
private parseRecord(raw: string, filePath: string): PersistedScene {
let parsed: unknown
try {
parsed = JSON.parse(raw)
} catch (err) {
throw new SceneInvalidError(
`Failed to parse scene file ${filePath}: ${(err as Error).message}`,
)
}
const result = PersistedSceneSchema.safeParse(parsed)
if (!result.success) {
throw new SceneInvalidError(
`Scene file ${filePath} has invalid shape: ${result.error.message}`,
)
}
const record = result.data
// Validate individual node envelopes: every value in `nodes` must be a
// non-null object with a `type` string. We don't fully parse each node via
// core's AnyNode because it's expensive and the schemas evolve; the lift
// is to catch egregious corruption early.
for (const [nodeId, node] of Object.entries(record.graph.nodes)) {
if (!node || typeof node !== 'object' || Array.isArray(node)) {
throw new SceneInvalidError(`Scene file ${filePath} has non-object node at "${nodeId}"`)
}
const typeField = (node as { type?: unknown }).type
if (typeof typeField !== 'string' || typeField.length === 0) {
throw new SceneInvalidError(
`Scene file ${filePath} has node "${nodeId}" missing a string "type"`,
)
}
}
return record
}
private async readIndex(): Promise<SceneMeta[] | null> {
try {
const raw = await fs.readFile(this.indexPath, 'utf8')
const parsed = JSON.parse(raw)
if (!Array.isArray(parsed)) return null
// Trust the index — it was written by us — but filter out any entries
// whose underlying file has since vanished.
const valid: SceneMeta[] = []
for (const entry of parsed as SceneMeta[]) {
if (!entry || typeof entry.id !== 'string') continue
const exists = await fs
.access(this.scenePath(entry.id), fsConstants.F_OK)
.then(() => true)
.catch(() => false)
if (exists) valid.push(entry)
}
return valid
} catch (err) {
const e = err as NodeJS.ErrnoException
if (e.code === 'ENOENT') return null
return null
}
}
private async collectAllMeta(): Promise<SceneMeta[]> {
try {
const entries = await fs.readdir(this.scenesDir)
const metas: SceneMeta[] = []
for (const entry of entries) {
if (!entry.endsWith('.json')) continue
if (entry === INDEX_FILE) continue
if (entry.endsWith(TMP_SUFFIX)) continue
const id = entry.slice(0, -'.json'.length)
const record = await this.readPersisted(id).catch(() => null)
if (record) metas.push(record.meta)
}
return metas
} catch (err) {
const e = err as NodeJS.ErrnoException
if (e.code === 'ENOENT') return []
throw err
}
}
private async writeIndex(metas: SceneMeta[]): Promise<void> {
await this.ensureScenesDir()
const sorted = metas.slice().sort((a, b) => a.id.localeCompare(b.id))
await this.atomicWrite(this.indexPath, `${JSON.stringify(sorted, null, 2)}\n`)
}
}
+7 -19
View File
@@ -1,29 +1,17 @@
import type { SceneStore } from './types'
export * from './slug'
export * from './sqlite-scene-store'
export * from './types'
/**
* Factory that picks the correct `SceneStore` backend based on env:
* - If `SUPABASE_URL` and `SUPABASE_SERVICE_ROLE_KEY` are both set → Supabase.
* - Otherwise → filesystem.
* Factory for Pascal's local-first scene store.
*
* Implementations are loaded via dynamic `import()` so consumers only pay the
* cost of the backend they actually use.
* The store is backed by the runtime's built-in SQLite driver. By default it
* writes to `~/.pascal/data/pascal.db`; set `PASCAL_DB_PATH` for an exact file
* path or `PASCAL_DATA_DIR` for a directory containing `pascal.db`.
*/
export async function createSceneStore(env?: NodeJS.ProcessEnv): Promise<SceneStore> {
const resolved = env ?? (typeof process !== 'undefined' ? process.env : undefined)
const supabaseUrl = resolved?.SUPABASE_URL
const supabaseKey = resolved?.SUPABASE_SERVICE_ROLE_KEY
if (supabaseUrl && supabaseKey) {
const mod = await import('./supabase-scene-store')
return new mod.SupabaseSceneStore({
url: supabaseUrl,
serviceRoleKey: supabaseKey,
})
}
const mod = await import('./filesystem-scene-store')
return new mod.FilesystemSceneStore()
const mod = await import('./sqlite-scene-store')
return new mod.SqliteSceneStore({ env })
}
+77
View File
@@ -0,0 +1,77 @@
type SqliteBinding = string | number | bigint | boolean | null | Uint8Array
export interface SqliteRunResult {
changes: number
lastInsertRowid: number | bigint
}
export interface SqliteStatement {
all(...params: SqliteBinding[]): unknown[]
get(...params: SqliteBinding[]): unknown
run(...params: SqliteBinding[]): SqliteRunResult
}
export interface SqliteDatabase {
exec(sql: string): void
query(sql: string): SqliteStatement
close(): void
}
type BunSqliteModule = {
Database: new (
filename: string,
options?: { create?: boolean; readwrite?: boolean },
) => SqliteDatabase
}
type NodeStatementSync = {
all(...params: SqliteBinding[]): unknown[]
get(...params: SqliteBinding[]): unknown
run(...params: SqliteBinding[]): SqliteRunResult
}
type NodeDatabaseSync = {
exec(sql: string): void
prepare(sql: string): NodeStatementSync
close(): void
}
type NodeSqliteModule = {
DatabaseSync: new (filename: string) => NodeDatabaseSync
}
export async function openSqliteDatabase(filename: string): Promise<SqliteDatabase> {
if ('Bun' in globalThis) {
const mod = (await import('bun:sqlite')) as BunSqliteModule
return new mod.Database(filename, { create: true, readwrite: true })
}
try {
const mod = (await import('node:sqlite')) as NodeSqliteModule
return adaptNodeDatabase(new mod.DatabaseSync(filename))
} catch (error) {
const reason = error instanceof Error ? error.message : String(error)
throw new Error(
`SQLite requires Bun or a Node runtime with node:sqlite support. Failed to open ${filename}: ${reason}`,
)
}
}
function adaptNodeDatabase(db: NodeDatabaseSync): SqliteDatabase {
return {
exec(sql: string): void {
db.exec(sql)
},
query(sql: string): SqliteStatement {
const stmt = db.prepare(sql)
return {
all: (...params) => stmt.all(...params),
get: (...params) => stmt.get(...params),
run: (...params) => stmt.run(...params),
}
},
close(): void {
db.close()
},
}
}
@@ -0,0 +1,279 @@
import { Database } from 'bun:sqlite'
import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
import * as fs from 'node:fs/promises'
import * as os from 'node:os'
import * as path from 'node:path'
import type { SceneGraph } from '@pascal-app/core/clone-scene-graph'
import {
resolveDefaultDatabasePath,
SqliteSceneStore,
type SqliteSceneStoreOptions,
} from './sqlite-scene-store'
import { SceneInvalidError, SceneTooLargeError, SceneVersionConflictError } from './types'
function makeGraph(overrides: Partial<SceneGraph> = {}): SceneGraph {
return {
nodes: {
site_abc: {
object: 'node',
id: 'site_abc',
type: 'site',
parentId: null,
visible: true,
metadata: {},
},
building_def: {
object: 'node',
id: 'building_def',
type: 'building',
parentId: 'site_abc',
visible: true,
metadata: {},
},
} as SceneGraph['nodes'],
rootNodeIds: ['site_abc'] as SceneGraph['rootNodeIds'],
...overrides,
}
}
async function mkTmpRoot(): Promise<string> {
return fs.mkdtemp(path.join(os.tmpdir(), 'pascal-sqlite-test-'))
}
async function rmrf(p: string): Promise<void> {
await fs.rm(p, { recursive: true, force: true })
}
function createStore(rootDir: string, opts: Partial<SqliteSceneStoreOptions> = {}) {
return new SqliteSceneStore({
databasePath: path.join(rootDir, 'pascal.db'),
...opts,
})
}
describe('resolveDefaultDatabasePath', () => {
test('respects PASCAL_DB_PATH when set', () => {
expect(resolveDefaultDatabasePath({ PASCAL_DB_PATH: '/tmp/custom.db' })).toBe('/tmp/custom.db')
})
test('resolves PASCAL_DATA_DIR to pascal.db', () => {
expect(resolveDefaultDatabasePath({ PASCAL_DATA_DIR: '/tmp/pascal-data' })).toBe(
path.join('/tmp/pascal-data', 'pascal.db'),
)
})
test('falls back to XDG_DATA_HOME on Unix', () => {
if (process.platform === 'win32') return
expect(resolveDefaultDatabasePath({ XDG_DATA_HOME: '/xdg/share' })).toBe(
path.join('/xdg/share', 'pascal', 'data', 'pascal.db'),
)
})
test('falls back to homedir + .pascal/data/pascal.db', () => {
if (process.platform === 'win32') return
expect(resolveDefaultDatabasePath({}).endsWith(path.join('.pascal', 'data', 'pascal.db'))).toBe(
true,
)
})
})
describe('SqliteSceneStore', () => {
let rootDir: string
let store: SqliteSceneStore
beforeEach(async () => {
rootDir = await mkTmpRoot()
store = createStore(rootDir)
})
afterEach(async () => {
store.close()
await rmrf(rootDir)
})
test('backend is "sqlite"', () => {
expect(store.backend).toBe('sqlite')
})
test('round-trips a saved scene through a reopened database', async () => {
const graph = makeGraph()
const saved = await store.save({ id: 'kitchen', name: 'Kitchen', graph })
expect(saved.id).toBe('kitchen')
expect(saved.version).toBe(1)
expect(saved.nodeCount).toBe(2)
expect(saved.sizeBytes).toBe(Buffer.byteLength(JSON.stringify(graph), 'utf8'))
store.close()
store = createStore(rootDir)
const loaded = await store.load('kitchen')
expect(loaded).not.toBeNull()
expect(loaded!.graph).toEqual(graph)
expect(loaded!.name).toBe('Kitchen')
})
test('stores optional metadata verbatim', async () => {
await store.save({
id: 'meta-test',
name: 'Meta',
graph: makeGraph(),
projectId: 'proj-1',
ownerId: 'user-42',
thumbnailUrl: 'https://example.com/t.png',
})
const loaded = await store.load('meta-test')
expect(loaded?.projectId).toBe('proj-1')
expect(loaded?.ownerId).toBe('user-42')
expect(loaded?.thumbnailUrl).toBe('https://example.com/t.png')
})
test('generates ids for new scenes and rejects explicit slug collisions', async () => {
const a = await store.save({ name: 'A', graph: makeGraph() })
const b = await store.save({ name: 'B', graph: makeGraph() })
expect(a.id).not.toBe(b.id)
await store.save({ id: 'kitchen', name: 'K1', graph: makeGraph() })
await expect(store.save({ id: 'kitchen', name: 'K2', graph: makeGraph() })).rejects.toThrow(
SceneInvalidError,
)
})
test('sanitizes explicit ids', async () => {
const meta = await store.save({ id: '../My Kitchen!', name: 'Kitchen', graph: makeGraph() })
expect(meta.id).toBe('my-kitchen')
expect(await store.load('my-kitchen')).not.toBeNull()
})
test('increments version and preserves createdAt on overwrite', async () => {
const first = await store.save({ id: 'bump', name: 'Bump', graph: makeGraph() })
await new Promise((resolve) => setTimeout(resolve, 5))
const second = await store.save({
id: 'bump',
name: 'Bump 2',
graph: makeGraph(),
expectedVersion: 1,
})
expect(second.version).toBe(2)
expect(second.createdAt).toBe(first.createdAt)
expect(second.updatedAt >= first.updatedAt).toBe(true)
})
test('enforces optimistic locking for save, rename, and delete', async () => {
await store.save({ id: 'locked', name: 'Locked', graph: makeGraph() })
await expect(
store.save({ id: 'locked', name: 'Locked', graph: makeGraph(), expectedVersion: 99 }),
).rejects.toThrow(SceneVersionConflictError)
await expect(store.rename('locked', 'New', { expectedVersion: 99 })).rejects.toThrow(
SceneVersionConflictError,
)
await expect(store.delete('locked', { expectedVersion: 99 })).rejects.toThrow(
SceneVersionConflictError,
)
})
test('expectedVersion=0 creates a brand-new explicit id', async () => {
const meta = await store.save({
id: 'fresh',
name: 'Fresh',
graph: makeGraph(),
expectedVersion: 0,
})
expect(meta.version).toBe(1)
})
test('lists newest first and supports project, owner, and limit filters', async () => {
await store.save({ id: 'a', name: 'A', graph: makeGraph(), projectId: 'p1', ownerId: 'u1' })
await new Promise((resolve) => setTimeout(resolve, 5))
await store.save({ id: 'b', name: 'B', graph: makeGraph(), projectId: 'p2', ownerId: 'u1' })
await new Promise((resolve) => setTimeout(resolve, 5))
await store.save({ id: 'c', name: 'C', graph: makeGraph(), projectId: 'p1', ownerId: 'u2' })
expect((await store.list()).map((m) => m.id)).toEqual(['c', 'b', 'a'])
expect((await store.list({ projectId: 'p1' })).map((m) => m.id)).toEqual(['c', 'a'])
expect((await store.list({ ownerId: 'u1' })).map((m) => m.id)).toEqual(['b', 'a'])
expect((await store.list({ limit: 2 })).map((m) => m.id)).toEqual(['c', 'b'])
})
test('rename writes a revision row and delete cascades revisions', async () => {
await store.save({ id: 'rev', name: 'Rev', graph: makeGraph() })
await store.rename('rev', 'Renamed', { expectedVersion: 1 })
const dbPath = path.join(rootDir, 'pascal.db')
const db = new Database(dbPath)
try {
const beforeDelete = db
.query('SELECT COUNT(*) AS count FROM scene_revisions WHERE scene_id = ?')
.get('rev') as { count: number }
expect(beforeDelete.count).toBe(2)
} finally {
db.close()
}
expect(await store.delete('rev', { expectedVersion: 2 })).toBe(true)
const reopened = new Database(dbPath)
try {
const afterDelete = reopened
.query('SELECT COUNT(*) AS count FROM scene_revisions WHERE scene_id = ?')
.get('rev') as { count: number }
expect(afterDelete.count).toBe(0)
} finally {
reopened.close()
}
})
test('validates name and scene size', async () => {
await expect(store.save({ name: '', graph: makeGraph() })).rejects.toThrow(SceneInvalidError)
await expect(store.save({ name: 'x'.repeat(201), graph: makeGraph() })).rejects.toThrow(
SceneInvalidError,
)
const tinyStore = createStore(rootDir, {
databasePath: path.join(rootDir, 'tiny.db'),
maxSceneBytes: 100,
})
try {
await expect(tinyStore.save({ id: 'big', name: 'Big', graph: makeGraph() })).rejects.toThrow(
SceneTooLargeError,
)
} finally {
tinyStore.close()
}
})
test('load returns null for missing scenes and errors on corrupt graph rows', async () => {
expect(await store.load('missing')).toBeNull()
const db = new Database(path.join(rootDir, 'pascal.db'), { create: true })
try {
db.exec(`
CREATE TABLE IF NOT EXISTS scenes (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
project_id TEXT,
owner_id TEXT,
thumbnail_url TEXT,
version INTEGER NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
size_bytes INTEGER NOT NULL,
node_count INTEGER NOT NULL,
graph_json TEXT NOT NULL
);
`)
db.query(
`INSERT INTO scenes (
id, name, version, created_at, updated_at, size_bytes, node_count, graph_json
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
).run('bad', 'Bad', 1, '2024-01-01T00:00:00.000Z', '2024-01-01T00:00:00.000Z', 2, 0, '{}')
} finally {
db.close()
}
await expect(store.load('bad')).rejects.toThrow(SceneInvalidError)
})
})
@@ -0,0 +1,495 @@
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')
}
}
+3 -4
View File
@@ -119,7 +119,6 @@ describe('generateSlug', () => {
})
})
// Note: createSceneStore() factory branching is covered transitively by
// the filesystem and supabase store tests. We avoid mock.module() here
// because bun's module mocks persist process-wide and pollute sibling
// test files (notably supabase-scene-store.test.ts).
// Note: createSceneStore() factory behavior is covered by the SQLite store
// tests. We avoid mock.module() here because bun's module mocks persist
// process-wide and pollute sibling test files.
@@ -1,333 +0,0 @@
import { beforeEach, describe, expect, test } from 'bun:test'
import {
type SupabaseLikeClient,
type SupabaseQueryBuilder,
type SupabaseQueryResult,
SupabaseSceneStore,
} from './supabase-scene-store'
import { SceneVersionConflictError } from './types'
/**
* Jest-style mock of the Supabase query chain. Each `from(table)` returns a
* fresh builder that records the sequence of operations (`insert | update |
* delete | select`), the collected `.eq()` filters, and any `limit/order`.
* The mock "database" is an in-memory array of rows per table.
*/
type Row = Record<string, unknown>
interface RecordedCall {
table: string
op: 'select' | 'insert' | 'update' | 'delete' | 'upsert'
values?: Row | Row[]
filters: Array<{ column: string; value: unknown }>
orderBy?: { column: string; ascending: boolean }
limit?: number
terminator?: 'single' | 'maybeSingle' | 'iterable'
}
function createMockClient(): {
client: SupabaseLikeClient
tables: Record<string, Row[]>
calls: RecordedCall[]
} {
const tables: Record<string, Row[]> = {}
const calls: RecordedCall[] = []
function buildQuery<T extends Row>(table: string): SupabaseQueryBuilder<T> {
tables[table] ??= []
const call: RecordedCall = { table, op: 'select', filters: [] }
function matchesFilters(row: Row): boolean {
return call.filters.every((f) => row[f.column] === f.value)
}
function applyOrderAndLimit(rows: Row[]): Row[] {
let out = [...rows]
if (call.orderBy) {
const { column, ascending } = call.orderBy
out.sort((a, b) => {
const av = a[column] as string | number
const bv = b[column] as string | number
if (av === bv) return 0
return (av < bv ? -1 : 1) * (ascending ? 1 : -1)
})
}
if (typeof call.limit === 'number') out = out.slice(0, call.limit)
return out
}
function executeMany(): SupabaseQueryResult<T[]> {
const rows = tables[table] as Row[]
if (call.op === 'select') {
const hits = rows.filter(matchesFilters)
return { data: applyOrderAndLimit(hits) as T[], error: null }
}
if (call.op === 'insert') {
const incoming = Array.isArray(call.values) ? call.values : [call.values!]
rows.push(...incoming)
return { data: incoming as T[], error: null }
}
if (call.op === 'update') {
const hits = rows.filter(matchesFilters)
for (const row of hits) Object.assign(row, call.values)
return { data: hits as T[], error: null }
}
if (call.op === 'delete') {
const hits = rows.filter(matchesFilters)
tables[table] = rows.filter((r) => !matchesFilters(r))
return { data: hits as T[], error: null }
}
return { data: [] as T[], error: null }
}
function executeSingle(required: boolean): SupabaseQueryResult<T> {
const many = executeMany()
if (many.error) return { data: null, error: many.error }
const first = (many.data ?? [])[0]
if (!first) {
if (required) {
return {
data: null,
error: { message: 'No rows', code: 'PGRST116' },
}
}
return { data: null, error: null }
}
return { data: first as T, error: null }
}
const builder: SupabaseQueryBuilder<T> = {
select(_columns?: string) {
// `select()` after a mutation keeps the mutation op; only flip to
// 'select' when no op has been set yet.
if (call.op === 'select') {
call.op = 'select'
}
return builder
},
insert(values) {
call.op = 'insert'
call.values = values as Row | Row[]
return builder
},
update(values) {
call.op = 'update'
call.values = values as Row
return builder
},
delete() {
call.op = 'delete'
return builder
},
upsert(values) {
call.op = 'upsert'
call.values = values as Row | Row[]
return builder
},
eq(column, value) {
call.filters.push({ column, value })
return builder
},
order(column, opts) {
call.orderBy = { column, ascending: opts?.ascending ?? true }
return builder
},
limit(count) {
call.limit = count
return builder
},
async maybeSingle() {
call.terminator = 'maybeSingle'
calls.push(call)
return executeSingle(false) as SupabaseQueryResult<T>
},
async single() {
call.terminator = 'single'
calls.push(call)
return executeSingle(true) as SupabaseQueryResult<T>
},
// Supabase query builders are themselves thenable — the mock must be
// too, so that `await builder` resolves to the list result.
// biome-ignore lint/suspicious/noThenProperty: mirrors real Supabase client
then(onfulfilled, onrejected) {
call.terminator = 'iterable'
calls.push(call)
const result = executeMany()
return Promise.resolve(result).then(onfulfilled, onrejected)
},
}
return builder
}
const client: SupabaseLikeClient = {
from<T extends Row = Row>(table: string) {
return buildQuery<T>(table)
},
}
return { client, tables, calls }
}
function fakeGraph(nodeCount = 2) {
const nodes: Record<string, unknown> = {}
for (let i = 0; i < nodeCount; i++) {
nodes[`wall_${i}`] = { id: `wall_${i}`, type: 'wall' }
}
return { nodes, rootNodeIds: Object.keys(nodes) }
}
describe('SupabaseSceneStore', () => {
let mock: ReturnType<typeof createMockClient>
let store: SupabaseSceneStore
beforeEach(() => {
mock = createMockClient()
store = new SupabaseSceneStore({
url: 'https://example.supabase.co',
serviceRoleKey: 'service-role-test-key',
client: mock.client,
})
})
test('reports the supabase backend flag', () => {
expect(store.backend).toBe('supabase')
})
test('save (new scene) inserts at version 1 and logs a revision', async () => {
const meta = await store.save({
name: 'first',
graph: fakeGraph(3) as never,
ownerId: null,
})
expect(meta.version).toBe(1)
expect(meta.nodeCount).toBe(3)
expect(meta.id.length).toBeGreaterThan(0)
// One row in scenes, one row in scene_revisions.
expect((mock.tables.scenes ?? []).length).toBe(1)
expect((mock.tables.scene_revisions ?? []).length).toBe(1)
expect((mock.tables.scene_revisions![0] as { author_kind: string }).author_kind).toBe('mcp')
})
test('save (existing scene) with matching expectedVersion bumps to 2', async () => {
const created = await store.save({
id: 'my-scene',
name: 'v1',
graph: fakeGraph(1) as never,
})
expect(created.version).toBe(1)
const updated = await store.save({
id: 'my-scene',
name: 'v1',
graph: fakeGraph(4) as never,
expectedVersion: 1,
})
expect(updated.version).toBe(2)
expect(updated.nodeCount).toBe(4)
// Two revisions should now be logged.
expect((mock.tables.scene_revisions ?? []).length).toBe(2)
const versions = (mock.tables.scene_revisions ?? []).map(
(r) => (r as { version: number }).version,
)
expect(versions.sort()).toEqual([1, 2])
})
test('save with stale expectedVersion throws SceneVersionConflictError', async () => {
await store.save({ id: 'stale', name: 's', graph: fakeGraph() as never })
let caught: unknown = null
try {
await store.save({
id: 'stale',
name: 's',
graph: fakeGraph() as never,
expectedVersion: 99,
})
} catch (err) {
caught = err
}
expect(caught).toBeInstanceOf(SceneVersionConflictError)
})
test('load returns null when no row matches', async () => {
const result = await store.load('missing')
expect(result).toBeNull()
})
test('load returns the scene + graph when present', async () => {
const saved = await store.save({ id: 'my', name: 's', graph: fakeGraph(2) as never })
const loaded = await store.load(saved.id)
expect(loaded).not.toBeNull()
expect(loaded!.id).toBe('my')
expect(Object.keys(loaded!.graph.nodes)).toEqual(['wall_0', 'wall_1'])
})
test('list applies ownerId filter and honours the default limit', async () => {
await store.save({ id: 'a', name: 'a', graph: fakeGraph() as never, ownerId: 'owner-1' })
await store.save({ id: 'b', name: 'b', graph: fakeGraph() as never, ownerId: 'owner-2' })
const onlyOne = await store.list({ ownerId: 'owner-1' })
expect(onlyOne.map((r) => r.id)).toEqual(['a'])
const listCall = mock.calls.find((c) => c.op === 'select' && c.terminator === 'iterable')!
expect(listCall.orderBy).toEqual({ column: 'updated_at', ascending: false })
expect(listCall.limit).toBe(100)
expect(listCall.filters).toContainEqual({ column: 'owner_id', value: 'owner-1' })
})
test('delete removes the row and cascade-deletes the revisions', async () => {
const saved = await store.save({ id: 'gone', name: 'g', graph: fakeGraph() as never })
expect((mock.tables.scenes ?? []).length).toBe(1)
expect((mock.tables.scene_revisions ?? []).length).toBe(1)
// Simulate on-delete-cascade by emptying revisions when scenes row goes.
const before = mock.tables.scenes!.length
const ok = await store.delete(saved.id)
expect(ok).toBe(true)
expect(mock.tables.scenes!.length).toBe(before - 1)
// Confirm the mock recorded a delete with an id filter — this is the
// SQL-equivalent of `delete from scenes where id = ?` relied on by the
// ON DELETE CASCADE from scene_revisions → scenes.
const deleteCall = mock.calls.find((c) => c.op === 'delete' && c.table === 'scenes')
expect(deleteCall).toBeDefined()
expect(deleteCall!.filters).toContainEqual({ column: 'id', value: saved.id })
})
test('delete returns false when the row does not exist', async () => {
const ok = await store.delete('never-existed')
expect(ok).toBe(false)
})
test('rename bumps the version and updates name', async () => {
const saved = await store.save({ id: 'ren', name: 'old', graph: fakeGraph() as never })
const renamed = await store.rename(saved.id, 'new')
expect(renamed.version).toBe(saved.version + 1)
expect(renamed.name).toBe('new')
})
test('rename with stale expectedVersion throws SceneVersionConflictError', async () => {
const saved = await store.save({ id: 'ren2', name: 'old', graph: fakeGraph() as never })
let caught: unknown = null
try {
await store.rename(saved.id, 'newer', { expectedVersion: saved.version + 5 })
} catch (err) {
caught = err
}
expect(caught).toBeInstanceOf(SceneVersionConflictError)
})
test('constructor never exposes the service role key in thrown errors', () => {
let caught: unknown = null
try {
new SupabaseSceneStore({
url: '',
serviceRoleKey: 'super-secret',
client: mock.client,
})
} catch (err) {
caught = err
}
expect(caught).toBeInstanceOf(Error)
expect((caught as Error).message).not.toContain('super-secret')
})
})
@@ -1,414 +0,0 @@
import type { SceneGraph } from '@pascal-app/core/clone-scene-graph'
import { generateSlug, sanitizeSlug } from './slug'
import {
type SceneId,
SceneInvalidError,
type SceneListOptions,
type SceneMeta,
type SceneMutateOptions,
SceneNotFoundError,
type SceneSaveOptions,
type SceneStore,
SceneVersionConflictError,
type SceneWithGraph,
} from './types'
const DEFAULT_LIST_LIMIT = 100
const MAX_NAME_LENGTH = 200
/**
* Minimal structural description of the Supabase client API we use. This lets
* the store be exercised in tests with a plain object mock and avoids a hard
* runtime dependency on `@supabase/supabase-js` for the test suite.
*/
export interface SupabaseQueryResult<T> {
data: T | null
error: { message: string; code?: string; details?: string } | null
}
export interface SupabaseQueryBuilder<Row> {
select(columns?: string): SupabaseQueryBuilder<Row>
insert(values: Partial<Row> | Partial<Row>[]): SupabaseQueryBuilder<Row>
update(values: Partial<Row>): SupabaseQueryBuilder<Row>
delete(): SupabaseQueryBuilder<Row>
upsert(values: Partial<Row> | Partial<Row>[]): SupabaseQueryBuilder<Row>
eq(column: string, value: unknown): SupabaseQueryBuilder<Row>
order(column: string, opts?: { ascending?: boolean }): SupabaseQueryBuilder<Row>
limit(count: number): SupabaseQueryBuilder<Row>
maybeSingle(): Promise<SupabaseQueryResult<Row>>
single(): Promise<SupabaseQueryResult<Row>>
then<TResult1 = SupabaseQueryResult<Row[]>, TResult2 = never>(
onfulfilled?:
| ((value: SupabaseQueryResult<Row[]>) => TResult1 | PromiseLike<TResult1>)
| null
| undefined,
onrejected?: ((reason: unknown) => TResult2 | PromiseLike<TResult2>) | null | undefined,
): Promise<TResult1 | TResult2>
}
export interface SupabaseLikeClient {
from<Row = Record<string, unknown>>(table: string): SupabaseQueryBuilder<Row>
}
export interface SupabaseSceneStoreOptions {
url: string
serviceRoleKey: string
tableScenes?: string
tableRevisions?: string
/**
* Injectable client, primarily for tests. When omitted, the constructor
* will lazily import `@supabase/supabase-js` and build a real client from
* `url` + `serviceRoleKey`.
*/
client?: SupabaseLikeClient
}
interface SceneRow {
id: string
project_id: string | null
owner_id: string | null
name: string
graph_json: SceneGraph
thumbnail_url: string | null
version: number
public: boolean
size_bytes: number
node_count: number
created_at: string
updated_at: string
}
interface RevisionRow {
scene_id: string
version: number
graph_json: SceneGraph
author_kind: 'human' | 'mcp' | 'agent'
author_id: string | null
created_at: string
}
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 computeSize(graph: SceneGraph): number {
return Buffer.byteLength(JSON.stringify(graph), 'utf8')
}
function countNodes(graph: SceneGraph): number {
return Object.keys(graph.nodes ?? {}).length
}
function validateName(name: string): void {
if (typeof name !== 'string' || name.length < 1 || name.length > MAX_NAME_LENGTH) {
throw new SceneInvalidError(`name must be 1${MAX_NAME_LENGTH} characters`)
}
}
export class SupabaseSceneStore implements SceneStore {
readonly backend = 'supabase' as const
private readonly tableScenes: string
private readonly tableRevisions: string
private clientPromise: Promise<SupabaseLikeClient>
constructor(opts: SupabaseSceneStoreOptions) {
if (!opts.url) throw new Error('SupabaseSceneStore: url is required')
if (!opts.serviceRoleKey) throw new Error('SupabaseSceneStore: serviceRoleKey is required')
this.tableScenes = opts.tableScenes ?? 'scenes'
this.tableRevisions = opts.tableRevisions ?? 'scene_revisions'
if (opts.client) {
const injected = opts.client
this.clientPromise = Promise.resolve(injected)
} else {
// Lazy load the real client so tests that inject `client` don't need
// `@supabase/supabase-js` installed.
const url = opts.url
const key = opts.serviceRoleKey
this.clientPromise = import('@supabase/supabase-js').then((mod) =>
mod.createClient(url, key, {
auth: { persistSession: false, autoRefreshToken: false },
}),
) as Promise<SupabaseLikeClient>
}
}
private async client(): Promise<SupabaseLikeClient> {
return this.clientPromise
}
async save(opts: SceneSaveOptions): Promise<SceneMeta> {
validateName(opts.name)
if (!opts.graph || typeof opts.graph !== 'object') {
throw new SceneInvalidError('graph is required')
}
const nowIso = new Date().toISOString()
const sizeBytes = computeSize(opts.graph)
const nodeCount = countNodes(opts.graph)
const client = await this.client()
const providedId = opts.id
const hasId = typeof providedId === 'string' && providedId.length > 0
if (!hasId) {
// New scene — generate a fresh slug and insert at version 1.
const id = generateSlug()
const inserted = await client
.from<SceneRow>(this.tableScenes)
.insert({
id,
project_id: opts.projectId ?? null,
owner_id: opts.ownerId ?? null,
name: opts.name,
graph_json: opts.graph,
thumbnail_url: opts.thumbnailUrl ?? null,
version: 1,
size_bytes: sizeBytes,
node_count: nodeCount,
created_at: nowIso,
updated_at: nowIso,
})
.select()
.single()
if (inserted.error || !inserted.data) {
throw new Error(`Supabase insert failed: ${inserted.error?.message ?? 'unknown error'}`)
}
await this.insertRevision(client, id, 1, opts.graph, opts.ownerId ?? null)
return rowToMeta(inserted.data)
}
// Existing scene — upsert path.
const id = sanitizeSlug(providedId)
// Look up current version so we know the next value + can enforce
// expectedVersion locally even when Supabase's RLS answer is opaque.
const existing = await client
.from<SceneRow>(this.tableScenes)
.select('*')
.eq('id', id)
.maybeSingle()
if (existing.error) {
throw new Error(`Supabase lookup failed: ${existing.error.message}`)
}
if (!existing.data) {
// No row yet for this id — insert as v1.
const inserted = await client
.from<SceneRow>(this.tableScenes)
.insert({
id,
project_id: opts.projectId ?? null,
owner_id: opts.ownerId ?? null,
name: opts.name,
graph_json: opts.graph,
thumbnail_url: opts.thumbnailUrl ?? null,
version: 1,
size_bytes: sizeBytes,
node_count: nodeCount,
created_at: nowIso,
updated_at: nowIso,
})
.select()
.single()
if (inserted.error || !inserted.data) {
throw new Error(`Supabase insert failed: ${inserted.error?.message ?? 'unknown error'}`)
}
await this.insertRevision(client, id, 1, opts.graph, opts.ownerId ?? null)
return rowToMeta(inserted.data)
}
const currentVersion = existing.data.version
if (typeof opts.expectedVersion === 'number' && opts.expectedVersion !== currentVersion) {
throw new SceneVersionConflictError(
`expected version ${opts.expectedVersion}, current ${currentVersion}`,
)
}
const nextVersion = currentVersion + 1
// Optimistic lock via `where version = currentVersion`.
const updated = await client
.from<SceneRow>(this.tableScenes)
.update({
name: opts.name,
project_id: opts.projectId ?? existing.data.project_id,
owner_id: opts.ownerId ?? existing.data.owner_id,
graph_json: opts.graph,
thumbnail_url:
opts.thumbnailUrl === undefined ? existing.data.thumbnail_url : opts.thumbnailUrl,
version: nextVersion,
size_bytes: sizeBytes,
node_count: nodeCount,
updated_at: nowIso,
})
.eq('id', id)
.eq('version', currentVersion)
.select()
.single()
if (updated.error || !updated.data) {
// Either someone raced us (version drifted) or the row vanished.
throw new SceneVersionConflictError(
updated.error?.message ?? 'version conflict during update',
)
}
await this.insertRevision(client, id, nextVersion, opts.graph, opts.ownerId ?? null)
return rowToMeta(updated.data)
}
async load(id: SceneId): Promise<SceneWithGraph | null> {
const client = await this.client()
const result = await client
.from<SceneRow>(this.tableScenes)
.select('*')
.eq('id', id)
.maybeSingle()
if (result.error) {
throw new Error(`Supabase load failed: ${result.error.message}`)
}
if (!result.data) return null
return { ...rowToMeta(result.data), graph: result.data.graph_json }
}
async list(opts?: SceneListOptions): Promise<SceneMeta[]> {
const client = await this.client()
let query = client
.from<SceneRow>(this.tableScenes)
.select('*')
.order('updated_at', { ascending: false })
.limit(opts?.limit ?? DEFAULT_LIST_LIMIT)
if (opts?.projectId) query = query.eq('project_id', opts.projectId)
if (opts?.ownerId) query = query.eq('owner_id', opts.ownerId)
const result = (await query) as SupabaseQueryResult<SceneRow[]>
if (result.error) {
throw new Error(`Supabase list failed: ${result.error.message}`)
}
return (result.data ?? []).map(rowToMeta)
}
async delete(id: SceneId, opts?: SceneMutateOptions): Promise<boolean> {
const client = await this.client()
if (typeof opts?.expectedVersion === 'number') {
const existing = await client
.from<SceneRow>(this.tableScenes)
.select('version')
.eq('id', id)
.maybeSingle()
if (existing.error) {
throw new Error(`Supabase lookup failed: ${existing.error.message}`)
}
if (!existing.data) return false
if (existing.data.version !== opts.expectedVersion) {
throw new SceneVersionConflictError(
`expected version ${opts.expectedVersion}, current ${existing.data.version}`,
)
}
}
const deleted = await client
.from<SceneRow>(this.tableScenes)
.delete()
.eq('id', id)
.select()
.maybeSingle()
if (deleted.error) {
throw new Error(`Supabase delete failed: ${deleted.error.message}`)
}
return deleted.data !== null
}
async rename(id: SceneId, newName: string, opts?: SceneMutateOptions): Promise<SceneMeta> {
validateName(newName)
const client = await this.client()
const existing = await client
.from<SceneRow>(this.tableScenes)
.select('*')
.eq('id', id)
.maybeSingle()
if (existing.error) {
throw new Error(`Supabase lookup failed: ${existing.error.message}`)
}
if (!existing.data) {
throw new SceneNotFoundError(`scene ${id} not found`)
}
if (
typeof opts?.expectedVersion === 'number' &&
opts.expectedVersion !== existing.data.version
) {
throw new SceneVersionConflictError(
`expected version ${opts.expectedVersion}, current ${existing.data.version}`,
)
}
const nextVersion = existing.data.version + 1
const updated = await client
.from<SceneRow>(this.tableScenes)
.update({
name: newName,
version: nextVersion,
updated_at: new Date().toISOString(),
})
.eq('id', id)
.eq('version', existing.data.version)
.select()
.single()
if (updated.error || !updated.data) {
throw new SceneVersionConflictError(
updated.error?.message ?? 'version conflict during rename',
)
}
return rowToMeta(updated.data)
}
private async insertRevision(
client: SupabaseLikeClient,
sceneId: string,
version: number,
graph: SceneGraph,
authorId: string | null,
): Promise<void> {
const result = await client.from<RevisionRow>(this.tableRevisions).insert({
scene_id: sceneId,
version,
graph_json: graph,
author_kind: 'mcp',
author_id: authorId,
created_at: new Date().toISOString(),
})
if (result.error) {
// Revision history is best-effort; surface the failure so callers can
// log / alert, but don't swallow it silently.
throw new Error(`Supabase revision insert failed: ${result.error.message}`)
}
}
}
+1 -1
View File
@@ -47,7 +47,7 @@ export interface SceneMutateOptions {
}
export interface SceneStore {
readonly backend: 'filesystem' | 'supabase'
readonly backend: 'sqlite'
save(opts: SceneSaveOptions): Promise<SceneMeta>
load(id: SceneId): Promise<SceneWithGraph | null>
list(opts?: SceneListOptions): Promise<SceneMeta[]>