Reset core/viewer/editor/mcp packages and apps/editor from private-editor
Wholesale swap of packages/{core,viewer,editor,mcp} and apps/editor with the
versions from the private editor repo, which is the production source of truth.
Setup changes:
- packages/{core,viewer,editor} versions held at 0.7.0 baseline (matching
the most recent published release) so a bump=minor publishes 0.8.0
- packages/mcp held at 0.1.1 (never published; first publish will go through
the new release.yml flow)
- peerDependencies and devDependencies for inter-package @pascal-app/*
references pinned to ^0.7.0 instead of '*' / 'workspace:*' so they are
valid for npm consumers
- Root package.json: TypeScript bumped to 6.0.2, added overrides for
@types/react, @types/react-dom, @types/three to prevent JSX namespace
fragmentation across the workspace
- release.yml extended to also publish editor and mcp; 'both' option renamed
to 'all'; added a sync step that updates inter-package peerDeps/devDeps to
match the new versions on every bump (so viewer/editor/mcp tarballs always
reference the version of core they were built against)
- Root scripts gained release:editor and release:mcp shortcuts
Verification:
- bun install --frozen-lockfile is consistent
- packages/{core,viewer,mcp} build cleanly, dist/index.d.ts emitted
- packages/editor check-types reports 21 pre-existing errors, identical to
what private-editor currently reports
Open PRs against editor-v2 will need rebasing/conflict resolution.
This commit is contained in:
@@ -0,0 +1,85 @@
|
||||
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
|
||||
import { z } from 'zod'
|
||||
import type { SceneOperations } from '../../operations'
|
||||
import { ErrorCode, throwMcpError } from '../errors'
|
||||
import { currentLevelContext, projectStatusPayload } from './metadata'
|
||||
|
||||
export const createProjectInput = {
|
||||
name: z.string().min(1).max(200),
|
||||
id: z.string().min(1).max(64).optional(),
|
||||
isPrivate: z.boolean().default(true),
|
||||
}
|
||||
|
||||
export const createProjectOutput = {
|
||||
id: z.string(),
|
||||
projectId: z.string(),
|
||||
name: z.string(),
|
||||
editorUrl: z.string(),
|
||||
url: z.string(),
|
||||
ownerId: z.string().nullable(),
|
||||
thumbnailUrl: z.string().nullable(),
|
||||
publishedVersion: z.number().nullable(),
|
||||
latestVersion: z.number().nullable(),
|
||||
draftVersion: z.number().nullable(),
|
||||
browserVisibleVersion: z.number().nullable(),
|
||||
version: z.number(),
|
||||
isEmpty: z.boolean(),
|
||||
sizeBytes: z.number(),
|
||||
nodeCount: z.number(),
|
||||
graphHash: z.string().nullable(),
|
||||
createdAt: z.string(),
|
||||
updatedAt: z.string(),
|
||||
levelIds: z.array(z.string()),
|
||||
defaultLevelId: z.string().nullable(),
|
||||
nextStep: z.string(),
|
||||
}
|
||||
|
||||
export function registerCreateProject(server: McpServer, operations: SceneOperations): void {
|
||||
server.registerTool(
|
||||
'create_project',
|
||||
{
|
||||
title: 'Create project',
|
||||
description:
|
||||
'Create a browser-visible Pascal project for the authenticated user. Use this before save_scene when the user asks for a new project.',
|
||||
inputSchema: createProjectInput,
|
||||
outputSchema: createProjectOutput,
|
||||
},
|
||||
async ({ name, id, isPrivate }) => {
|
||||
if (!operations.canCreateProject) {
|
||||
throwMcpError(
|
||||
ErrorCode.InvalidRequest,
|
||||
'create_project_unavailable: this MCP store cannot create hosted projects',
|
||||
)
|
||||
}
|
||||
try {
|
||||
const status = await operations.createProject({
|
||||
name,
|
||||
...(id !== undefined ? { id } : {}),
|
||||
isPrivate,
|
||||
})
|
||||
operations.setActiveScene({
|
||||
id: status.id,
|
||||
name: status.name,
|
||||
projectId: status.projectId,
|
||||
ownerId: status.ownerId,
|
||||
thumbnailUrl: status.thumbnailUrl,
|
||||
version: status.version,
|
||||
})
|
||||
const payload = {
|
||||
...projectStatusPayload(
|
||||
status,
|
||||
'The project is now bound to this MCP session. Open editorUrl now; semantic tools will update the browser-visible draft. Call save_scene with saveMode: "checkpoint" only when you want a meaningful version.',
|
||||
),
|
||||
...currentLevelContext(operations),
|
||||
}
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: JSON.stringify(payload) }],
|
||||
structuredContent: payload,
|
||||
}
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err)
|
||||
throwMcpError(ErrorCode.InvalidRequest, msg)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
|
||||
import { z } from 'zod'
|
||||
import type { SceneOperations } from '../../operations'
|
||||
import { ErrorCode, McpError, throwMcpError } from '../errors'
|
||||
import { currentLevelContext, projectStatusPayload } from './metadata'
|
||||
|
||||
export const getProjectStatusInput = {
|
||||
id: z.string().min(1).max(64),
|
||||
}
|
||||
|
||||
export const getProjectStatusOutput = {
|
||||
id: z.string(),
|
||||
projectId: z.string(),
|
||||
name: z.string(),
|
||||
editorUrl: z.string(),
|
||||
url: z.string(),
|
||||
ownerId: z.string().nullable(),
|
||||
thumbnailUrl: z.string().nullable(),
|
||||
publishedVersion: z.number().nullable(),
|
||||
latestVersion: z.number().nullable(),
|
||||
draftVersion: z.number().nullable(),
|
||||
browserVisibleVersion: z.number().nullable(),
|
||||
version: z.number(),
|
||||
isEmpty: z.boolean(),
|
||||
sizeBytes: z.number(),
|
||||
nodeCount: z.number(),
|
||||
graphHash: z.string().nullable(),
|
||||
createdAt: z.string(),
|
||||
updatedAt: z.string(),
|
||||
levelIds: z.array(z.string()),
|
||||
defaultLevelId: z.string().nullable(),
|
||||
nextStep: z.string(),
|
||||
}
|
||||
|
||||
export function registerGetProjectStatus(server: McpServer, operations: SceneOperations): void {
|
||||
server.registerTool(
|
||||
'get_project_status',
|
||||
{
|
||||
title: 'Get project status',
|
||||
description:
|
||||
'Authoritative status/debug call for a Pascal project: editor URL, browser-visible version, latest saved version, published version, node count, and graph hash.',
|
||||
inputSchema: getProjectStatusInput,
|
||||
outputSchema: getProjectStatusOutput,
|
||||
},
|
||||
async ({ id }) => {
|
||||
try {
|
||||
const status = await operations.getProjectStatus(id)
|
||||
if (!status) {
|
||||
throwMcpError(ErrorCode.InvalidParams, 'project_not_found', { id })
|
||||
}
|
||||
const activeScene = operations.getActiveScene()
|
||||
if (activeScene?.id !== status.id) {
|
||||
const scene = await operations.loadStoredScene(status.id)
|
||||
if (scene) {
|
||||
operations.loadJSON(scene.graph)
|
||||
operations.setActiveScene(scene)
|
||||
}
|
||||
}
|
||||
const nextStep =
|
||||
status.nodeCount > 0
|
||||
? 'Open editorUrl or continue editing, then save_scene again.'
|
||||
: 'Project is empty. Build a scene with semantic tools or create_from_template, then save_scene.'
|
||||
const payload = {
|
||||
...projectStatusPayload(status, nextStep),
|
||||
...currentLevelContext(operations),
|
||||
}
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: JSON.stringify(payload) }],
|
||||
structuredContent: payload,
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof McpError) throw err
|
||||
const msg = err instanceof Error ? err.message : String(err)
|
||||
throwMcpError(ErrorCode.InternalError, msg)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
|
||||
import type { SceneOperations } from '../../operations'
|
||||
import { registerCreateProject } from './create-project'
|
||||
import { registerDeleteScene } from './delete-scene'
|
||||
import { registerGetProjectStatus } from './get-project-status'
|
||||
import { registerListScenes } from './list-scenes'
|
||||
import { registerLoadScene } from './load-scene'
|
||||
import { registerRenameScene } from './rename-scene'
|
||||
@@ -13,6 +15,8 @@ import { registerSaveScene } from './save-scene'
|
||||
* entry points share the same storage boundary.
|
||||
*/
|
||||
export function registerSceneLifecycleTools(server: McpServer, operations: SceneOperations): void {
|
||||
registerCreateProject(server, operations)
|
||||
registerGetProjectStatus(server, operations)
|
||||
registerSaveScene(server, operations)
|
||||
registerLoadScene(server, operations)
|
||||
registerListScenes(server, operations)
|
||||
@@ -20,7 +24,13 @@ export function registerSceneLifecycleTools(server: McpServer, operations: Scene
|
||||
registerRenameScene(server, operations)
|
||||
}
|
||||
|
||||
export { createProjectInput, createProjectOutput, registerCreateProject } from './create-project'
|
||||
export { deleteSceneInput, deleteSceneOutput, registerDeleteScene } from './delete-scene'
|
||||
export {
|
||||
getProjectStatusInput,
|
||||
getProjectStatusOutput,
|
||||
registerGetProjectStatus,
|
||||
} from './get-project-status'
|
||||
export { listScenesInput, listScenesOutput, registerListScenes } from './list-scenes'
|
||||
export { loadSceneInput, loadSceneOutput, registerLoadScene } from './load-scene'
|
||||
export { registerRenameScene, renameSceneInput, renameSceneOutput } from './rename-scene'
|
||||
|
||||
@@ -23,6 +23,10 @@ export const listScenesOutput = {
|
||||
ownerId: z.string().nullable(),
|
||||
sizeBytes: z.number(),
|
||||
nodeCount: z.number(),
|
||||
editorUrl: z.string().optional(),
|
||||
url: z.string().optional(),
|
||||
published: z.boolean().optional(),
|
||||
graphHash: z.string().optional(),
|
||||
}),
|
||||
),
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
|
||||
import { z } from 'zod'
|
||||
import type { SceneOperations } from '../../operations'
|
||||
import { ErrorCode, throwMcpError } from '../errors'
|
||||
import { currentLevelContext, sceneMetaPayload } from './metadata'
|
||||
|
||||
export const loadSceneInput = {
|
||||
id: z.string().min(1).max(64),
|
||||
@@ -18,6 +19,14 @@ export const loadSceneOutput = {
|
||||
ownerId: z.string().nullable(),
|
||||
sizeBytes: z.number(),
|
||||
nodeCount: z.number(),
|
||||
url: z.string(),
|
||||
editorUrl: z.string(),
|
||||
published: z.boolean(),
|
||||
isDraft: z.boolean(),
|
||||
saveMode: z.enum(['draft', 'checkpoint']),
|
||||
graphHash: z.string().optional(),
|
||||
levelIds: z.array(z.string()),
|
||||
defaultLevelId: z.string().nullable(),
|
||||
}
|
||||
|
||||
export function registerLoadScene(server: McpServer, bridge: SceneOperations): void {
|
||||
@@ -43,16 +52,8 @@ export function registerLoadScene(server: McpServer, bridge: SceneOperations): v
|
||||
throwMcpError(ErrorCode.InvalidRequest, `load_failed: ${msg}`, { id })
|
||||
}
|
||||
const payload = {
|
||||
id: result.id,
|
||||
name: result.name,
|
||||
projectId: result.projectId,
|
||||
thumbnailUrl: result.thumbnailUrl,
|
||||
version: result.version,
|
||||
createdAt: result.createdAt,
|
||||
updatedAt: result.updatedAt,
|
||||
ownerId: result.ownerId,
|
||||
sizeBytes: result.sizeBytes,
|
||||
nodeCount: result.nodeCount,
|
||||
...sceneMetaPayload(result, result.graph),
|
||||
...currentLevelContext(bridge),
|
||||
}
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: JSON.stringify(payload) }],
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
import type { SceneGraph } from '@pascal-app/core/clone-scene-graph'
|
||||
import type { AnyNodeId } from '@pascal-app/core/schema'
|
||||
import type { SceneOperations } from '../../operations'
|
||||
import type { ProjectStatus, SceneMeta } from '../../storage/types'
|
||||
|
||||
export function computeGraphHash(graph: SceneGraph): string {
|
||||
const normalized = JSON.stringify({
|
||||
nodes: graph.nodes ?? {},
|
||||
rootNodeIds: graph.rootNodeIds ?? [],
|
||||
collections: (graph as Record<string, unknown>).collections ?? {},
|
||||
})
|
||||
return createHash('sha256').update(normalized).digest('hex')
|
||||
}
|
||||
|
||||
export function editorUrlFor(meta: Pick<SceneMeta, 'id' | 'editorUrl' | 'url'>): string {
|
||||
return meta.editorUrl ?? meta.url ?? `/editor/${meta.id}`
|
||||
}
|
||||
|
||||
export function sceneMetaPayload(meta: SceneMeta, graph?: SceneGraph) {
|
||||
const editorUrl = editorUrlFor(meta)
|
||||
return {
|
||||
id: meta.id,
|
||||
name: meta.name,
|
||||
projectId: meta.projectId,
|
||||
thumbnailUrl: meta.thumbnailUrl,
|
||||
version: meta.version,
|
||||
createdAt: meta.createdAt,
|
||||
updatedAt: meta.updatedAt,
|
||||
ownerId: meta.ownerId,
|
||||
sizeBytes: meta.sizeBytes,
|
||||
nodeCount: meta.nodeCount,
|
||||
editorUrl,
|
||||
url: editorUrl,
|
||||
published: meta.published ?? true,
|
||||
isDraft: meta.isDraft ?? false,
|
||||
saveMode: meta.saveMode ?? (meta.isDraft ? 'draft' : 'checkpoint'),
|
||||
graphHash: meta.graphHash ?? (graph ? computeGraphHash(graph) : undefined),
|
||||
}
|
||||
}
|
||||
|
||||
export function projectStatusPayload(status: ProjectStatus, nextStep?: string) {
|
||||
return {
|
||||
id: status.id,
|
||||
projectId: status.projectId,
|
||||
name: status.name,
|
||||
editorUrl: status.editorUrl,
|
||||
url: status.url,
|
||||
ownerId: status.ownerId,
|
||||
thumbnailUrl: status.thumbnailUrl,
|
||||
publishedVersion: status.publishedVersion,
|
||||
latestVersion: status.latestVersion,
|
||||
draftVersion: status.draftVersion,
|
||||
browserVisibleVersion: status.browserVisibleVersion,
|
||||
version: status.version,
|
||||
isEmpty: status.isEmpty,
|
||||
sizeBytes: status.sizeBytes,
|
||||
nodeCount: status.nodeCount,
|
||||
graphHash: status.graphHash,
|
||||
createdAt: status.createdAt,
|
||||
updatedAt: status.updatedAt,
|
||||
...(nextStep ? { nextStep } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
export function currentLevelContext(operations: SceneOperations) {
|
||||
const levels = operations.findNodes({ type: 'level' }).sort((a, b) => {
|
||||
const aa = a.type === 'level' ? a.level : 0
|
||||
const bb = b.type === 'level' ? b.level : 0
|
||||
return aa - bb
|
||||
})
|
||||
const levelIds = levels.map((level) => level.id as AnyNodeId as string)
|
||||
return {
|
||||
levelIds,
|
||||
defaultLevelId: levelIds[0] ?? null,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { beforeEach, describe, expect, test } from 'bun:test'
|
||||
import { Client } from '@modelcontextprotocol/sdk/client/index.js'
|
||||
import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js'
|
||||
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
|
||||
import { registerCreateProject } from './create-project'
|
||||
import { registerGetProjectStatus } from './get-project-status'
|
||||
import {
|
||||
createTestSceneOperations,
|
||||
InMemorySceneStore,
|
||||
parseToolText,
|
||||
type StoredTextContent,
|
||||
} from './test-utils'
|
||||
|
||||
describe('project lifecycle tools', () => {
|
||||
let client: Client
|
||||
let store: InMemorySceneStore
|
||||
|
||||
beforeEach(async () => {
|
||||
store = new InMemorySceneStore()
|
||||
const { operations } = createTestSceneOperations({ store })
|
||||
const server = new McpServer({ name: 'test', version: '0.0.0' })
|
||||
registerCreateProject(server, operations)
|
||||
registerGetProjectStatus(server, operations)
|
||||
const [srvT, cliT] = InMemoryTransport.createLinkedPair()
|
||||
client = new Client({ name: 'test-client', version: '0.0.0' })
|
||||
await Promise.all([server.connect(srvT), client.connect(cliT)])
|
||||
})
|
||||
|
||||
test('creates a project and returns an editor URL', async () => {
|
||||
const result = await client.callTool({
|
||||
name: 'create_project',
|
||||
arguments: { name: 'Dogfood house' },
|
||||
})
|
||||
expect(result.isError).toBeFalsy()
|
||||
const parsed = parseToolText(result.content as StoredTextContent[])
|
||||
expect(parsed.name).toBe('Dogfood house')
|
||||
expect(typeof parsed.projectId).toBe('string')
|
||||
expect(parsed.editorUrl).toBe(`/editor/${parsed.projectId}`)
|
||||
expect(parsed.nodeCount).toBe(0)
|
||||
expect(parsed.nextStep).toContain('save_scene')
|
||||
})
|
||||
|
||||
test('reports status for an existing project', async () => {
|
||||
const project = await store.createProject({ name: 'Status house' })
|
||||
const result = await client.callTool({
|
||||
name: 'get_project_status',
|
||||
arguments: { id: project.projectId },
|
||||
})
|
||||
expect(result.isError).toBeFalsy()
|
||||
const parsed = parseToolText(result.content as StoredTextContent[])
|
||||
expect(parsed.projectId).toBe(project.projectId)
|
||||
expect(parsed.editorUrl).toBe(`/editor/${project.projectId}`)
|
||||
expect(parsed.nodeCount).toBe(0)
|
||||
})
|
||||
})
|
||||
@@ -29,7 +29,7 @@ describe('save_scene', () => {
|
||||
await Promise.all([server.connect(srvT), client.connect(cliT)])
|
||||
})
|
||||
|
||||
test('saves the current scene and returns SceneMeta with url', async () => {
|
||||
test('saves the current scene and returns SceneMeta with editorUrl', async () => {
|
||||
const result = await client.callTool({
|
||||
name: 'save_scene',
|
||||
arguments: { name: 'My Scene' },
|
||||
@@ -39,7 +39,10 @@ describe('save_scene', () => {
|
||||
expect(parsed.name).toBe('My Scene')
|
||||
expect(typeof parsed.id).toBe('string')
|
||||
expect(parsed.version).toBe(1)
|
||||
expect(parsed.url).toBe(`/scene/${parsed.id}`)
|
||||
expect(parsed.url).toBe(`/editor/${parsed.id}`)
|
||||
expect(parsed.editorUrl).toBe(`/editor/${parsed.id}`)
|
||||
expect(parsed.published).toBe(true)
|
||||
expect(typeof parsed.graphHash).toBe('string')
|
||||
expect(parsed.nodeCount).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
|
||||
@@ -6,12 +6,23 @@ import type { SceneOperations } from '../../operations'
|
||||
import { SceneVersionConflictError } from '../../storage/types'
|
||||
import { ErrorCode, throwMcpError } from '../errors'
|
||||
import { appendLiveSceneEvent } from '../live-sync'
|
||||
import { currentLevelContext, sceneMetaPayload } from './metadata'
|
||||
|
||||
export const saveSceneInput = {
|
||||
id: z.string().min(1).max(64).optional(),
|
||||
name: z.string().min(1).max(200),
|
||||
projectId: z.string().optional(),
|
||||
expectedVersion: z.number().int().positive().optional(),
|
||||
saveMode: z
|
||||
.enum(['draft', 'checkpoint'])
|
||||
.default('draft')
|
||||
.describe(
|
||||
'`draft` updates the browser-visible working model without polluting version history. `checkpoint` creates a meaningful saved version.',
|
||||
),
|
||||
publish: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe('For checkpoint saves, publish the checkpoint as the browser-visible version.'),
|
||||
thumbnail: z.string().url().optional(),
|
||||
includeCurrentScene: z
|
||||
.boolean()
|
||||
@@ -37,6 +48,13 @@ export const saveSceneOutput = {
|
||||
sizeBytes: z.number(),
|
||||
nodeCount: z.number(),
|
||||
url: z.string(),
|
||||
editorUrl: z.string(),
|
||||
published: z.boolean(),
|
||||
isDraft: z.boolean(),
|
||||
saveMode: z.enum(['draft', 'checkpoint']),
|
||||
graphHash: z.string().optional(),
|
||||
levelIds: z.array(z.string()),
|
||||
defaultLevelId: z.string().nullable(),
|
||||
}
|
||||
|
||||
export function registerSaveScene(server: McpServer, bridge: SceneOperations): void {
|
||||
@@ -45,11 +63,21 @@ export function registerSaveScene(server: McpServer, bridge: SceneOperations): v
|
||||
{
|
||||
title: 'Save scene',
|
||||
description:
|
||||
'Persist the current scene (or a provided graph) to the SceneStore. Returns the SceneMeta along with a `url` pointing to `/scene/<id>`.',
|
||||
'Save the current scene (or a provided graph) to the SceneStore. Defaults to a browser-visible draft save so agents can iterate without creating many project versions. Use saveMode: "checkpoint" for meaningful version history.',
|
||||
inputSchema: saveSceneInput,
|
||||
outputSchema: saveSceneOutput,
|
||||
},
|
||||
async ({ id, name, projectId, expectedVersion, thumbnail, includeCurrentScene, graph }) => {
|
||||
async ({
|
||||
id,
|
||||
name,
|
||||
projectId,
|
||||
expectedVersion,
|
||||
saveMode,
|
||||
publish,
|
||||
thumbnail,
|
||||
includeCurrentScene,
|
||||
graph,
|
||||
}) => {
|
||||
let sceneGraph: SceneGraph
|
||||
if (includeCurrentScene) {
|
||||
const validation = bridge.validateScene()
|
||||
@@ -98,23 +126,17 @@ export function registerSaveScene(server: McpServer, bridge: SceneOperations): v
|
||||
graph: sceneGraph,
|
||||
...(thumbnail !== undefined ? { thumbnailUrl: thumbnail } : {}),
|
||||
...(expectedVersion !== undefined ? { expectedVersion } : {}),
|
||||
saveMode,
|
||||
...(publish !== undefined ? { publish } : {}),
|
||||
operation: 'save_scene',
|
||||
})
|
||||
await appendLiveSceneEvent(bridge, meta.id, meta.version, 'save_scene', sceneGraph)
|
||||
if (includeCurrentScene) {
|
||||
bridge.setActiveScene(meta)
|
||||
}
|
||||
const payload = {
|
||||
id: meta.id,
|
||||
name: meta.name,
|
||||
projectId: meta.projectId,
|
||||
thumbnailUrl: meta.thumbnailUrl,
|
||||
version: meta.version,
|
||||
createdAt: meta.createdAt,
|
||||
updatedAt: meta.updatedAt,
|
||||
ownerId: meta.ownerId,
|
||||
sizeBytes: meta.sizeBytes,
|
||||
nodeCount: meta.nodeCount,
|
||||
url: `/scene/${meta.id}`,
|
||||
...sceneMetaPayload(meta, sceneGraph),
|
||||
...currentLevelContext(bridge),
|
||||
}
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: JSON.stringify(payload) }],
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { SceneBridge } from '../../bridge/scene-bridge'
|
||||
import { createSceneOperations, type SceneOperations } from '../../operations'
|
||||
import {
|
||||
type ProjectCreateOptions,
|
||||
type ProjectStatus,
|
||||
type SceneListOptions,
|
||||
type SceneMeta,
|
||||
type SceneMutateOptions,
|
||||
@@ -10,6 +12,7 @@ import {
|
||||
SceneVersionConflictError,
|
||||
type SceneWithGraph,
|
||||
} from '../../storage/types'
|
||||
import { computeGraphHash, editorUrlFor } from './metadata'
|
||||
|
||||
export type StoredTextContent = { type: string; text: string }
|
||||
|
||||
@@ -39,7 +42,40 @@ export function createTestSceneOperations(options?: {
|
||||
export class InMemorySceneStore implements SceneStore {
|
||||
readonly backend = 'sqlite' as const
|
||||
private readonly data = new Map<string, SceneWithGraph>()
|
||||
private readonly projects = new Map<
|
||||
string,
|
||||
{
|
||||
id: string
|
||||
name: string
|
||||
ownerId: string | null
|
||||
isPrivate: boolean
|
||||
thumbnailUrl: string | null
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
>()
|
||||
private idCounter = 0
|
||||
private projectCounter = 0
|
||||
|
||||
async createProject(opts: ProjectCreateOptions): Promise<ProjectStatus> {
|
||||
const id = opts.id ?? `project_${++this.projectCounter}`
|
||||
const now = new Date().toISOString()
|
||||
this.projects.set(id, {
|
||||
id,
|
||||
name: opts.name,
|
||||
ownerId: opts.ownerId ?? null,
|
||||
isPrivate: opts.isPrivate ?? true,
|
||||
thumbnailUrl: null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
return this.toProjectStatus(id)
|
||||
}
|
||||
|
||||
async getProjectStatus(id: string): Promise<ProjectStatus | null> {
|
||||
if (!(this.projects.has(id) || this.data.has(id))) return null
|
||||
return this.toProjectStatus(id)
|
||||
}
|
||||
|
||||
async save(opts: SceneSaveOptions): Promise<SceneMeta> {
|
||||
const existing = opts.id ? this.data.get(opts.id) : undefined
|
||||
@@ -63,9 +99,14 @@ export class InMemorySceneStore implements SceneStore {
|
||||
ownerId: opts.ownerId ?? existing.ownerId,
|
||||
sizeBytes: serialized.length,
|
||||
nodeCount,
|
||||
editorUrl: existing.editorUrl ?? `/editor/${existing.id}`,
|
||||
url: existing.url ?? `/editor/${existing.id}`,
|
||||
published: true,
|
||||
graphHash: computeGraphHash(opts.graph),
|
||||
graph: opts.graph,
|
||||
}
|
||||
this.data.set(existing.id, updated)
|
||||
this.touchProject(existing.id, opts.name, updated.updatedAt)
|
||||
return this.toMeta(updated)
|
||||
}
|
||||
|
||||
@@ -80,7 +121,7 @@ export class InMemorySceneStore implements SceneStore {
|
||||
const record: SceneWithGraph = {
|
||||
id,
|
||||
name: opts.name,
|
||||
projectId: opts.projectId ?? null,
|
||||
projectId: opts.projectId ?? (this.projects.has(id) ? id : null),
|
||||
thumbnailUrl: opts.thumbnailUrl ?? null,
|
||||
version: 1,
|
||||
createdAt: now,
|
||||
@@ -88,9 +129,14 @@ export class InMemorySceneStore implements SceneStore {
|
||||
ownerId: opts.ownerId ?? null,
|
||||
sizeBytes: serialized.length,
|
||||
nodeCount,
|
||||
editorUrl: `/editor/${id}`,
|
||||
url: `/editor/${id}`,
|
||||
published: true,
|
||||
graphHash: computeGraphHash(opts.graph),
|
||||
graph: opts.graph,
|
||||
}
|
||||
this.data.set(id, record)
|
||||
this.touchProject(id, opts.name, now)
|
||||
return this.toMeta(record)
|
||||
}
|
||||
|
||||
@@ -142,10 +188,29 @@ export class InMemorySceneStore implements SceneStore {
|
||||
updatedAt: new Date().toISOString(),
|
||||
}
|
||||
this.data.set(id, updated)
|
||||
this.touchProject(id, newName, updated.updatedAt)
|
||||
return this.toMeta(updated)
|
||||
}
|
||||
|
||||
private touchProject(id: string, name: string, updatedAt: string): void {
|
||||
const existing = this.projects.get(id)
|
||||
if (existing) {
|
||||
this.projects.set(id, { ...existing, name, updatedAt })
|
||||
return
|
||||
}
|
||||
this.projects.set(id, {
|
||||
id,
|
||||
name,
|
||||
ownerId: null,
|
||||
isPrivate: true,
|
||||
thumbnailUrl: null,
|
||||
createdAt: updatedAt,
|
||||
updatedAt,
|
||||
})
|
||||
}
|
||||
|
||||
private toMeta(rec: SceneWithGraph): SceneMeta {
|
||||
const editorUrl = editorUrlFor(rec)
|
||||
return {
|
||||
id: rec.id,
|
||||
name: rec.name,
|
||||
@@ -157,6 +222,37 @@ export class InMemorySceneStore implements SceneStore {
|
||||
ownerId: rec.ownerId,
|
||||
sizeBytes: rec.sizeBytes,
|
||||
nodeCount: rec.nodeCount,
|
||||
editorUrl,
|
||||
url: editorUrl,
|
||||
published: rec.published ?? true,
|
||||
graphHash: rec.graphHash ?? computeGraphHash(rec.graph),
|
||||
}
|
||||
}
|
||||
|
||||
private toProjectStatus(id: string): ProjectStatus {
|
||||
const project = this.projects.get(id)
|
||||
const scene = this.data.get(id)
|
||||
const now = new Date().toISOString()
|
||||
const editorUrl = `/editor/${id}`
|
||||
return {
|
||||
id,
|
||||
projectId: id,
|
||||
name: scene?.name ?? project?.name ?? id,
|
||||
editorUrl,
|
||||
url: editorUrl,
|
||||
ownerId: scene?.ownerId ?? project?.ownerId ?? null,
|
||||
thumbnailUrl: scene?.thumbnailUrl ?? project?.thumbnailUrl ?? null,
|
||||
publishedVersion: scene?.version ?? null,
|
||||
latestVersion: scene?.version ?? null,
|
||||
draftVersion: null,
|
||||
browserVisibleVersion: scene?.version ?? null,
|
||||
version: scene?.version ?? 0,
|
||||
isEmpty: !scene || scene.nodeCount === 0,
|
||||
sizeBytes: scene?.sizeBytes ?? 0,
|
||||
nodeCount: scene?.nodeCount ?? 0,
|
||||
graphHash: scene?.graphHash ?? (scene ? computeGraphHash(scene.graph) : null),
|
||||
createdAt: scene?.createdAt ?? project?.createdAt ?? now,
|
||||
updatedAt: scene?.updatedAt ?? project?.updatedAt ?? now,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user