feat(mcp,editor): Option A+B storage + 10 agent deliverables (Phase 7)
Ships the combined filesystem/Supabase storage adapter + MCP scene lifecycle tools + Next.js API routes + editor /scene/[id] route, so an MCP save is directly openable at /scene/<id> without any injection hack. End-to-end verified: 10/10 e2e steps pass. Storage (A1/A2/A3): - SceneStore interface + error classes + slug helpers - FilesystemSceneStore at $PASCAL_DATA_DIR (defaults XDG/~/.pascal) with atomic writes, .index sidecar, optimistic locking - SupabaseSceneStore with scenes + scene_revisions tables, RLS migration SQL, mock-backed unit tests - createSceneStore(env) auto-selects based on SUPABASE_URL + SUPABASE_SERVICE_ROLE_KEY MCP tools (A4, A8, A9, A10): - save_scene / load_scene / list_scenes / delete_scene / rename_scene - list_templates / create_from_template (3 seed templates: empty-studio, two-bedroom, garden-house) - generate_variants (7 mutation kinds, seeded RNG, save=true|false) - photo_to_scene (vision sampling → scene graph → save) Editor (A5, A6): - /api/scenes + /api/scenes/[id] with RFC 7232 If-Match locking - /scene/[id] and /scenes route pages with save button, SceneLoader - Removed the window.__pascalScene dev injection hack Security + UX edges (A7, A8): - AssetUrl Zod validator: asset:// blob: data:image/ /path https: (http://localhost for dev) + PASCAL_ALLOWED_ASSET_ORIGINS env allowlist. Hardens scan.url, guide.url, item.asset.src, material.texture.url, MaterialMaps.*Map - Auto-frame camera on empty→non-empty scene transition (camera-controls:fit-scene emitter event) Shared utilities: - rehydrateSiteChildren() extracted to packages/mcp/src/lib/ and used by both create-from-template and generate-variants to work around the SiteNode.children-as-objects vs. ids inconsistency (CROSS_CUTTING §2) - Storage + MCP subpath exports added to packages/mcp/package.json (CROSS_CUTTING §4) Tests: 293 pass / 0 fail across 40 files (was 142 pre-Phase-7). Biome: clean. Phase-7 e2e script at packages/mcp/test-reports/phase7-e2e.ts: MCP HTTP + editor Next.js both point at $PASCAL_DATA_DIR = /tmp/pascal-e2e, save_scene from MCP, GET /api/scenes/<id> from editor server, /scenes list page renders all saved scenes, scene page renders SceneLoader, delete_scene works. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
42bd05db9c
commit
e8d0b13ff5
@@ -0,0 +1,599 @@
|
||||
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)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,388 @@
|
||||
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`)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { SceneStore } from './types'
|
||||
|
||||
export * from './slug'
|
||||
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.
|
||||
*
|
||||
* Implementations are loaded via dynamic `import()` so consumers only pay the
|
||||
* cost of the backend they actually use.
|
||||
*/
|
||||
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()
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
const MAX_SLUG_LENGTH = 64
|
||||
const GENERATED_SLUG_LENGTH = 12
|
||||
const SLUG_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/
|
||||
const ALPHABET = 'abcdefghijklmnopqrstuvwxyz0123456789'
|
||||
|
||||
/**
|
||||
* Normalizes a raw string into a slug:
|
||||
* - lowercase
|
||||
* - spaces → hyphen
|
||||
* - strip non [a-z0-9-]
|
||||
* - collapse consecutive hyphens
|
||||
* - trim hyphens from ends
|
||||
* - enforce ≤ 64 chars
|
||||
*
|
||||
* Throws if the result is empty.
|
||||
*/
|
||||
export function sanitizeSlug(raw: string): string {
|
||||
const sanitized = raw
|
||||
.toLowerCase()
|
||||
.replace(/\s+/g, '-')
|
||||
.replace(/[^a-z0-9-]/g, '')
|
||||
.replace(/-+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
.slice(0, MAX_SLUG_LENGTH)
|
||||
.replace(/-+$/g, '')
|
||||
|
||||
if (sanitized.length === 0) {
|
||||
throw new Error('Slug cannot be empty after sanitization')
|
||||
}
|
||||
|
||||
return sanitized
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a string is already a valid slug (no sanitization performed).
|
||||
*/
|
||||
export function isValidSlug(s: string): boolean {
|
||||
if (typeof s !== 'string') return false
|
||||
if (s.length === 0 || s.length > MAX_SLUG_LENGTH) return false
|
||||
return SLUG_PATTERN.test(s)
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a fresh 12-char lowercase alphanumeric slug using crypto randomness.
|
||||
*/
|
||||
export function generateSlug(): string {
|
||||
const raw = globalThis.crypto?.randomUUID?.().replace(/-/g, '') ?? fallbackRandom()
|
||||
const base = raw.toLowerCase().replace(/[^a-z0-9]/g, '')
|
||||
if (base.length >= GENERATED_SLUG_LENGTH) {
|
||||
return base.slice(0, GENERATED_SLUG_LENGTH)
|
||||
}
|
||||
// Pad with additional random chars if for any reason the base is short.
|
||||
let out = base
|
||||
while (out.length < GENERATED_SLUG_LENGTH) {
|
||||
out += ALPHABET[Math.floor(Math.random() * ALPHABET.length)]
|
||||
}
|
||||
return out.slice(0, GENERATED_SLUG_LENGTH)
|
||||
}
|
||||
|
||||
function fallbackRandom(): string {
|
||||
let out = ''
|
||||
for (let i = 0; i < GENERATED_SLUG_LENGTH * 2; i++) {
|
||||
out += ALPHABET[Math.floor(Math.random() * ALPHABET.length)]
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import { generateSlug, isValidSlug, sanitizeSlug } from './slug'
|
||||
|
||||
describe('sanitizeSlug', () => {
|
||||
test('lowercases input', () => {
|
||||
expect(sanitizeSlug('MyScene')).toBe('myscene')
|
||||
})
|
||||
|
||||
test('converts spaces to hyphens', () => {
|
||||
expect(sanitizeSlug('my awesome scene')).toBe('my-awesome-scene')
|
||||
})
|
||||
|
||||
test('strips non alphanumeric characters', () => {
|
||||
expect(sanitizeSlug('hello@world!_$%scene')).toBe('helloworldscene')
|
||||
})
|
||||
|
||||
test('collapses runs of hyphens', () => {
|
||||
expect(sanitizeSlug('a---b--c')).toBe('a-b-c')
|
||||
})
|
||||
|
||||
test('collapses runs from mixed input', () => {
|
||||
expect(sanitizeSlug('a b c')).toBe('a-b-c')
|
||||
})
|
||||
|
||||
test('trims hyphens from the ends', () => {
|
||||
expect(sanitizeSlug('---foo---')).toBe('foo')
|
||||
})
|
||||
|
||||
test('enforces 64-char maximum', () => {
|
||||
const long = 'a'.repeat(200)
|
||||
const result = sanitizeSlug(long)
|
||||
expect(result.length).toBeLessThanOrEqual(64)
|
||||
expect(result).toBe('a'.repeat(64))
|
||||
})
|
||||
|
||||
test('trims trailing hyphen after truncation', () => {
|
||||
const raw = `${'a'.repeat(63)}-bbbbb`
|
||||
const result = sanitizeSlug(raw)
|
||||
expect(result.endsWith('-')).toBe(false)
|
||||
expect(result.length).toBeLessThanOrEqual(64)
|
||||
})
|
||||
|
||||
test('throws when result is empty', () => {
|
||||
expect(() => sanitizeSlug('')).toThrow()
|
||||
expect(() => sanitizeSlug('!!!')).toThrow()
|
||||
expect(() => sanitizeSlug(' ')).toThrow()
|
||||
})
|
||||
|
||||
test('preserves already-valid slugs', () => {
|
||||
expect(sanitizeSlug('already-valid-123')).toBe('already-valid-123')
|
||||
})
|
||||
})
|
||||
|
||||
describe('isValidSlug', () => {
|
||||
test('accepts typical slugs', () => {
|
||||
expect(isValidSlug('my-scene')).toBe(true)
|
||||
expect(isValidSlug('scene123')).toBe(true)
|
||||
expect(isValidSlug('a')).toBe(true)
|
||||
})
|
||||
|
||||
test('rejects empty string', () => {
|
||||
expect(isValidSlug('')).toBe(false)
|
||||
})
|
||||
|
||||
test('rejects uppercase', () => {
|
||||
expect(isValidSlug('MyScene')).toBe(false)
|
||||
})
|
||||
|
||||
test('rejects underscores and other punctuation', () => {
|
||||
expect(isValidSlug('my_scene')).toBe(false)
|
||||
expect(isValidSlug('my.scene')).toBe(false)
|
||||
expect(isValidSlug('my/scene')).toBe(false)
|
||||
})
|
||||
|
||||
test('rejects leading or trailing hyphens', () => {
|
||||
expect(isValidSlug('-foo')).toBe(false)
|
||||
expect(isValidSlug('foo-')).toBe(false)
|
||||
})
|
||||
|
||||
test('rejects consecutive hyphens', () => {
|
||||
expect(isValidSlug('foo--bar')).toBe(false)
|
||||
})
|
||||
|
||||
test('rejects strings > 64 chars', () => {
|
||||
expect(isValidSlug('a'.repeat(65))).toBe(false)
|
||||
})
|
||||
|
||||
test('accepts exactly 64 chars', () => {
|
||||
expect(isValidSlug('a'.repeat(64))).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('generateSlug', () => {
|
||||
test('returns a 12-char string', () => {
|
||||
const slug = generateSlug()
|
||||
expect(slug).toHaveLength(12)
|
||||
})
|
||||
|
||||
test('is lowercase alphanumeric', () => {
|
||||
for (let i = 0; i < 50; i++) {
|
||||
const slug = generateSlug()
|
||||
expect(slug).toMatch(/^[a-z0-9]{12}$/)
|
||||
}
|
||||
})
|
||||
|
||||
test('passes isValidSlug', () => {
|
||||
for (let i = 0; i < 20; i++) {
|
||||
expect(isValidSlug(generateSlug())).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
test('produces unique values across many calls', () => {
|
||||
const seen = new Set<string>()
|
||||
for (let i = 0; i < 200; i++) {
|
||||
seen.add(generateSlug())
|
||||
}
|
||||
// Allow for a tiny chance of collision but near-certain uniqueness.
|
||||
expect(seen.size).toBeGreaterThan(195)
|
||||
})
|
||||
})
|
||||
|
||||
// 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).
|
||||
@@ -0,0 +1,333 @@
|
||||
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')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,414 @@
|
||||
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}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import type { SceneGraph } from '@pascal-app/core/clone-scene-graph'
|
||||
|
||||
/**
|
||||
* Slug-safe scene identifier: lowercase alphanumerics and hyphens, ≤ 64 chars.
|
||||
*/
|
||||
export type SceneId = string
|
||||
|
||||
export interface SceneMeta {
|
||||
id: SceneId
|
||||
name: string
|
||||
projectId: string | null
|
||||
thumbnailUrl: string | null
|
||||
/** Monotonic, incremented on every save. */
|
||||
version: number
|
||||
/** ISO 8601 timestamp. */
|
||||
createdAt: string
|
||||
/** ISO 8601 timestamp. */
|
||||
updatedAt: string
|
||||
ownerId: string | null
|
||||
sizeBytes: number
|
||||
nodeCount: number
|
||||
}
|
||||
|
||||
export interface SceneWithGraph extends SceneMeta {
|
||||
graph: SceneGraph
|
||||
}
|
||||
|
||||
export interface SceneSaveOptions {
|
||||
id?: SceneId
|
||||
name: string
|
||||
projectId?: string | null
|
||||
ownerId?: string | null
|
||||
graph: SceneGraph
|
||||
thumbnailUrl?: string | null
|
||||
/** When set, save fails with `SceneVersionConflictError` on mismatch. */
|
||||
expectedVersion?: number
|
||||
}
|
||||
|
||||
export interface SceneListOptions {
|
||||
projectId?: string
|
||||
ownerId?: string
|
||||
limit?: number
|
||||
}
|
||||
|
||||
export interface SceneMutateOptions {
|
||||
expectedVersion?: number
|
||||
}
|
||||
|
||||
export interface SceneStore {
|
||||
readonly backend: 'filesystem' | 'supabase'
|
||||
save(opts: SceneSaveOptions): Promise<SceneMeta>
|
||||
load(id: SceneId): Promise<SceneWithGraph | null>
|
||||
list(opts?: SceneListOptions): Promise<SceneMeta[]>
|
||||
delete(id: SceneId, opts?: SceneMutateOptions): Promise<boolean>
|
||||
rename(id: SceneId, newName: string, opts?: SceneMutateOptions): Promise<SceneMeta>
|
||||
}
|
||||
|
||||
export class SceneNotFoundError extends Error {
|
||||
readonly code = 'not_found' as const
|
||||
constructor(message = 'Scene not found') {
|
||||
super(message)
|
||||
this.name = 'SceneNotFoundError'
|
||||
}
|
||||
}
|
||||
|
||||
export class SceneVersionConflictError extends Error {
|
||||
readonly code = 'version_conflict' as const
|
||||
constructor(message = 'Scene version conflict') {
|
||||
super(message)
|
||||
this.name = 'SceneVersionConflictError'
|
||||
}
|
||||
}
|
||||
|
||||
export class SceneInvalidError extends Error {
|
||||
readonly code = 'invalid' as const
|
||||
constructor(message = 'Scene invalid') {
|
||||
super(message)
|
||||
this.name = 'SceneInvalidError'
|
||||
}
|
||||
}
|
||||
|
||||
export class SceneTooLargeError extends Error {
|
||||
readonly code = 'too_large' as const
|
||||
constructor(message = 'Scene too large') {
|
||||
super(message)
|
||||
this.name = 'SceneTooLargeError'
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user