fix(mcp): use local sqlite scene storage
This commit is contained in:
@@ -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`)
|
||||
}
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
}
|
||||
@@ -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}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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[]>
|
||||
|
||||
Reference in New Issue
Block a user