// An in-memory stand-in for the CMS: just enough of /api/edit, /api/nodes and // /api/schemas to develop and test the editor with no server and no login. // Semantics follow docs/onpage-editor.md (atomic batches, inverse ops, 409 on a // stale version, patches = re-rendered subtrees). It is deliberately small - when // real behaviour differs, fix the mock, then the editor. export interface MNode { id: string; dataType: string; data: Record; version: number; tags: string[] } export interface MEdge { id: string; from: string; to: string; type: string; handle?: string; sort: number } export class HttpError extends Error { constructor(readonly status: number, message: string) { super(message) } } type Op = Record export class Graph { nodes = new Map() edges = new Map() private seq = 100 constructor(seed?: { nodes: Omit[]; edges: Omit[] }) { if (seed) { for (const n of seed.nodes) this.nodes.set(n.id, { version: 1, tags: [], ...n }) for (const e of seed.edges) this.edges.set(e.id, { type: 'child', ...e }) } } private id(p: string) { return `${p}${++this.seq}` } node(id: string): MNode { const n = this.nodes.get(id) if (!n) throw new HttpError(404, `node ${id} not found`) return n } edge(id: string): MEdge { const e = this.edges.get(id) if (!e) throw new HttpError(404, `edge ${id} not found`) return e } children(id: string): MEdge[] { return [...this.edges.values()].filter((e) => e.from === id && e.type === 'child').sort((a, b) => a.sort - b.sort) } parents(id: string): MEdge[] { return [...this.edges.values()].filter((e) => e.to === id) } edgesOf(id: string) { return { from: this.children(id), into: this.parents(id) } } private renumber(parent: string) { this.children(parent).forEach((e, i) => (e.sort = i)) } /** Place edge `e` at `index` among its parent's children. */ private place(e: MEdge, index?: number) { const sibs = this.children(e.from).filter((x) => x.id !== e.id) const at = index === undefined || index > sibs.length ? sibs.length : Math.max(0, index) sibs.splice(at, 0, e) sibs.forEach((x, i) => (x.sort = i)) } private subtree(edgeId: string): { nodes: MNode[]; edges: MEdge[] } { const root = this.edge(edgeId) const nodes: MNode[] = [] const edges: MEdge[] = [root] const walk = (id: string) => { nodes.push(this.node(id)) for (const c of this.children(id)) { edges.push(c) walk(c.to) } } walk(root.to) return { nodes, edges } } /** Apply a batch atomically. Returns results, inverse (already reversed) and touched ids. */ edit(ops: Op[]) { if (!Array.isArray(ops) || ops.length < 1 || ops.length > 200) throw new HttpError(400, 'ops must hold 1-200 operations') const snapshot = structuredClone({ nodes: [...this.nodes], edges: [...this.edges], seq: this.seq }) const results: Array<{ node?: string; edge?: string }> = [] const inverse: Op[] = [] const touched = new Set() try { ops.forEach((op, i) => { try { const { result, inv, touch } = this.applyOne(op) results.push(result) inverse.unshift(...inv) touch.forEach((t) => touched.add(t)) } catch (e) { if (e instanceof HttpError) throw new HttpError(e.status, `op ${i} (${op.op}): ${e.message}`) throw e } }) } catch (e) { this.nodes = new Map(snapshot.nodes) this.edges = new Map(snapshot.edges) this.seq = snapshot.seq throw e } return { results, inverse, touched: [...touched] } } private applyOne(op: Op): { result: { node?: string; edge?: string }; inv: Op[]; touch: string[] } { switch (op.op) { case 'insert': { this.node(op.parent) if (!op.type) throw new HttpError(400, 'type required') const node: MNode = { id: op.id ?? this.id('n'), dataType: op.type, data: { ...(op.data ?? {}) }, version: 1, tags: [] } this.nodes.set(node.id, node) const edge: MEdge = { id: this.id('e'), from: op.parent, to: node.id, type: 'child', handle: op.handle, sort: 0 } this.edges.set(edge.id, edge) this.place(edge, op.index) return { result: { node: node.id, edge: edge.id }, inv: [{ op: 'delete', edge: edge.id }], touch: [op.parent] } } case 'set': { const n = this.node(op.id) if (op.version !== undefined && op.version !== n.version) throw new HttpError(409, `stale version (have ${n.version}, got ${op.version})`) const undo: Record = {} for (const [k, v] of Object.entries(op.data ?? {})) { if (['lastModified', 'status'].includes(k)) throw new HttpError(400, `lifecycle key ${k} cannot be set`) undo[k] = k in n.data ? n.data[k] : null if (v === null) delete n.data[k] else n.data[k] = v } n.version++ return { result: { node: n.id }, inv: [{ op: 'set', id: n.id, data: undo }], touch: [n.id] } } case 'move': { const e = this.edge(op.edge) const before = { parent: e.from, handle: e.handle, index: this.children(e.from).findIndex((x) => x.id === e.id) } const to = op.parent ?? e.from if (this.isDescendant(to, e.to)) throw new HttpError(400, 'move would create a cycle') const oldParent = e.from e.from = to if ('handle' in op) e.handle = op.handle this.renumber(oldParent) this.place(e, op.index) return { result: { edge: e.id }, inv: [{ op: 'move', edge: e.id, parent: before.parent, handle: before.handle, index: before.index }], touch: [...new Set([oldParent, to])], } } case 'delete': { const e = this.edge(op.edge) const snap = this.subtree(e.id) for (const x of snap.edges) this.edges.delete(x.id) // Subtree nodes survive only if still composed elsewhere (shared blocks). for (const n of snap.nodes) if (this.parents(n.id).length === 0) this.nodes.delete(n.id) this.renumber(e.from) return { result: {}, inv: [{ op: 'restore', edge: e.id, nodes: structuredClone(snap.nodes), edges: structuredClone(snap.edges) }], touch: [e.from] } } case 'restore': { for (const n of op.nodes ?? []) this.nodes.set(n.id, structuredClone(n)) for (const e of op.edges ?? []) this.edges.set(e.id, structuredClone(e)) const root = this.edge(op.edge) this.place(root, root.sort) return { result: { edge: root.id, node: root.to }, inv: [{ op: 'delete', edge: root.id }], touch: [root.from] } } case 'graft': { // Link an existing node (tree.link) or create a subtree. The editor uses `tree: { link }` to attach // searched nodes into slots. if (op.tree?.link) { this.node(op.parent) const target = this.node(op.tree.link) const edge: MEdge = { id: this.id('e'), from: op.parent, to: target.id, type: 'child', handle: op.handle ?? op.tree.handle, sort: 0 } this.edges.set(edge.id, edge) this.place(edge, op.index) return { result: { node: target.id, edge: edge.id }, inv: [{ op: 'delete', edge: edge.id }], touch: [op.parent] } } throw new HttpError(400, 'graft without tree.link is not implemented by the mock') } case 'duplicate': { const e = this.edge(op.edge) const map = new Map() const clone = (id: string): string => { const src = this.node(id) const copy: MNode = { ...structuredClone(src), id: this.id('n'), version: 1 } this.nodes.set(copy.id, copy) map.set(id, copy.id) for (const c of this.children(id)) { const ce: MEdge = { ...c, id: this.id('e'), from: copy.id, to: clone(c.to) } this.edges.set(ce.id, ce) } return copy.id } const nid = clone(e.to) const ne: MEdge = { ...e, id: this.id('e'), to: nid } this.edges.set(ne.id, ne) this.place(ne, e.sort + 1) return { result: { node: nid, edge: ne.id }, inv: [{ op: 'delete', edge: ne.id }], touch: [e.from] } } default: throw new HttpError(400, `op "${op.op}" is not implemented by the mock`) } } private isDescendant(id: string, ancestor: string): boolean { if (id === ancestor) return true return this.children(ancestor).some((c) => this.isDescendant(id, c.to)) } /** Root = a node nobody composes. */ roots(): MNode[] { return [...this.nodes.values()].filter((n) => this.parents(n.id).length === 0) } }