ai-chatbot/packages/mcp/src/storage/sqlite-scene-store.test.ts
2026-04-27 14:31:04 -04:00

327 lines
10 KiB
TypeScript

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('appends and lists scene events in order', async () => {
const graph = makeGraph()
const meta = await store.save({ id: 'live', name: 'Live', graph })
const first = await store.appendSceneEvent({
sceneId: meta.id,
version: meta.version,
kind: 'save_scene',
graph,
})
const updatedGraph = makeGraph({
nodes: {
...graph.nodes,
wall_new: {
object: 'node',
id: 'wall_new',
type: 'wall',
parentId: 'building_def',
visible: true,
metadata: {},
children: [],
start: [0, 0],
end: [1, 0],
thickness: 0.1,
height: 2.5,
frontSide: 'unknown',
backSide: 'unknown',
},
} as SceneGraph['nodes'],
})
const second = await store.appendSceneEvent({
sceneId: meta.id,
version: meta.version,
kind: 'create_wall',
graph: updatedGraph,
})
expect(second.eventId).toBeGreaterThan(first.eventId)
expect((await store.listSceneEvents('live')).map((event) => event.kind)).toEqual([
'save_scene',
'create_wall',
])
const afterFirst = await store.listSceneEvents('live', { afterEventId: first.eventId })
expect(afterFirst).toHaveLength(1)
expect(afterFirst[0]!.eventId).toBe(second.eventId)
expect(afterFirst[0]!.graph.nodes.wall_new).toBeDefined()
})
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)
})
})