Add relations cascade resolver (Phase 1, 1/6)
Pure traversal that walks a node's declared `relations` and returns the
full set of IDs that should be marked dirty alongside it. Two
implementations:
- `cascadeDirty(id, ctx)` — follows `hosts` (matching children) and
`affectsSpatial` (via injected spatialQuery). Phase 3 will add
`linkedBy: 'endpoint-match'`.
- `collectDescendants(id, ctx)` — pure subtree traversal for
`cascadeDelete: 'descendants'` and subtree deletion tools.
Both bounded by maxDepth (default 16) and visited-set so cycles in
bad data can't loop forever.
Context-based design: spatialQuery and childQuery are injected, so the
resolver itself stays pure — the DragAction runtime can plug in
spatialGridManager-backed queries; tests pass stubs.
Today, registry has no kinds → cascadeDirty(id) always returns just
{id}. No behavior change. Phase 3 wall is the first real consumer.
11 unit tests cover empty/no-relations baseline, hosts cascade, depth
limit, spatial query, missing spatialQuery branch, cycle protection,
childQuery override, descendant collection.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
3de098fe4f
commit
90702e74e2
@@ -1,4 +1,11 @@
|
||||
export { loadPlugin, nodeRegistry, registerNode } from './registry'
|
||||
export {
|
||||
type CascadeContext,
|
||||
type ChildQuery,
|
||||
cascadeDirty,
|
||||
collectDescendants,
|
||||
type SpatialQuery,
|
||||
} from './relations-resolver'
|
||||
export { createSceneApi, type SceneStoreLike } from './scene-api'
|
||||
export type {
|
||||
Affordance,
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
import { beforeEach, describe, expect, test } from 'bun:test'
|
||||
import { z } from 'zod'
|
||||
import type { AnyNode, AnyNodeId } from '../schema/types'
|
||||
import { nodeRegistry, registerNode } from './registry'
|
||||
import { cascadeDirty, collectDescendants, type SpatialQuery } from './relations-resolver'
|
||||
import type { AnyNodeDefinition, Relations, SceneApi } from './types'
|
||||
|
||||
const id = (s: string) => s as AnyNodeId
|
||||
|
||||
function makeDef(
|
||||
kind: string,
|
||||
relations?: Relations,
|
||||
overrides: Partial<AnyNodeDefinition> = {},
|
||||
): AnyNodeDefinition {
|
||||
return {
|
||||
kind,
|
||||
schemaVersion: 1,
|
||||
schema: z.object({ type: z.literal(kind) }) as any,
|
||||
category: 'utility',
|
||||
defaults: () => ({}) as any,
|
||||
capabilities: {},
|
||||
relations,
|
||||
renderer: { kind: 'parametric', module: async () => ({ default: () => null }) },
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function makeNode(kind: string, idStr: string, extra: Partial<AnyNode> = {}): AnyNode {
|
||||
return {
|
||||
id: id(idStr),
|
||||
type: kind,
|
||||
parentId: null,
|
||||
visible: true,
|
||||
...extra,
|
||||
} as unknown as AnyNode
|
||||
}
|
||||
|
||||
function makeFakeScene(nodes: Record<string, AnyNode>): SceneApi {
|
||||
return {
|
||||
get: ((nid: AnyNodeId) => nodes[nid as string]) as SceneApi['get'],
|
||||
update: () => {},
|
||||
upsert: () => id(''),
|
||||
delete: () => {},
|
||||
restore: () => {},
|
||||
restoreAll: () => {},
|
||||
markDirty: () => {},
|
||||
pauseHistory: () => {},
|
||||
resumeHistory: () => {},
|
||||
}
|
||||
}
|
||||
|
||||
describe('cascadeDirty', () => {
|
||||
beforeEach(() => {
|
||||
nodeRegistry._reset()
|
||||
})
|
||||
|
||||
test('starting node alone when registry is empty', () => {
|
||||
const scene = makeFakeScene({ a: makeNode('unknown', 'a') })
|
||||
const dirty = cascadeDirty(id('a'), { scene })
|
||||
expect(Array.from(dirty)).toEqual([id('a')])
|
||||
})
|
||||
|
||||
test('starting node alone when no relations declared', () => {
|
||||
registerNode(makeDef('thing'))
|
||||
const scene = makeFakeScene({ a: makeNode('thing', 'a') })
|
||||
const dirty = cascadeDirty(id('a'), { scene })
|
||||
expect(Array.from(dirty)).toEqual([id('a')])
|
||||
})
|
||||
|
||||
test('hosts cascade marks matching children dirty', () => {
|
||||
registerNode(makeDef('wall', { hosts: ['door', 'window'] }))
|
||||
registerNode(makeDef('door'))
|
||||
registerNode(makeDef('window'))
|
||||
registerNode(makeDef('lamp'))
|
||||
|
||||
const wall = makeNode('wall', 'w1', {
|
||||
children: [id('d1'), id('w2'), id('l1')],
|
||||
} as Partial<AnyNode>)
|
||||
const scene = makeFakeScene({
|
||||
w1: wall,
|
||||
d1: makeNode('door', 'd1', { parentId: id('w1') }),
|
||||
w2: makeNode('window', 'w2', { parentId: id('w1') }),
|
||||
l1: makeNode('lamp', 'l1', { parentId: id('w1') }), // not in hosts list
|
||||
})
|
||||
|
||||
const dirty = cascadeDirty(id('w1'), { scene })
|
||||
const ids = Array.from(dirty).sort()
|
||||
expect(ids).toEqual([id('d1'), id('w1'), id('w2')]) // l1 excluded
|
||||
})
|
||||
|
||||
test('hosts cascade is recursive but bounded by maxDepth', () => {
|
||||
registerNode(makeDef('a', { hosts: ['a'] })) // a hosts more a
|
||||
const nodes: Record<string, AnyNode> = {}
|
||||
for (let i = 0; i < 25; i++) {
|
||||
const childId = i < 24 ? id(`a${i + 1}`) : undefined
|
||||
nodes[`a${i}`] = makeNode('a', `a${i}`, {
|
||||
children: childId ? [childId] : [],
|
||||
} as Partial<AnyNode>)
|
||||
}
|
||||
const scene = makeFakeScene(nodes)
|
||||
const dirty = cascadeDirty(id('a0'), { scene, maxDepth: 5 })
|
||||
expect(dirty.size).toBe(6) // a0 + 5 descendants
|
||||
})
|
||||
|
||||
test('affectsSpatial cascade uses spatialQuery to find neighbors', () => {
|
||||
registerNode(makeDef('wall', { affectsSpatial: ['slab', 'zone'] }))
|
||||
registerNode(makeDef('slab'))
|
||||
registerNode(makeDef('zone'))
|
||||
|
||||
const scene = makeFakeScene({
|
||||
w1: makeNode('wall', 'w1'),
|
||||
s1: makeNode('slab', 's1'),
|
||||
z1: makeNode('zone', 'z1'),
|
||||
unrelated: makeNode('door', 'unrelated'),
|
||||
})
|
||||
|
||||
const spatialQuery: SpatialQuery = (node, kinds) => {
|
||||
if (node.id !== id('w1')) return []
|
||||
const matches: AnyNodeId[] = []
|
||||
if (kinds.includes('slab')) matches.push(id('s1'))
|
||||
if (kinds.includes('zone')) matches.push(id('z1'))
|
||||
return matches
|
||||
}
|
||||
|
||||
const dirty = cascadeDirty(id('w1'), { scene, spatialQuery })
|
||||
expect(Array.from(dirty).sort()).toEqual([id('s1'), id('w1'), id('z1')])
|
||||
})
|
||||
|
||||
test('affectsSpatial is a no-op when no spatialQuery is provided', () => {
|
||||
registerNode(makeDef('wall', { affectsSpatial: ['slab'] }))
|
||||
const scene = makeFakeScene({ w1: makeNode('wall', 'w1') })
|
||||
const dirty = cascadeDirty(id('w1'), { scene })
|
||||
expect(Array.from(dirty)).toEqual([id('w1')]) // spatial branch silently skipped
|
||||
})
|
||||
|
||||
test('cycle in hosts cascade does not loop forever', () => {
|
||||
registerNode(makeDef('a', { hosts: ['a'] }))
|
||||
const nodes: Record<string, AnyNode> = {
|
||||
a1: makeNode('a', 'a1', { children: [id('a2')] } as Partial<AnyNode>),
|
||||
a2: makeNode('a', 'a2', { children: [id('a1')] } as Partial<AnyNode>), // cycle
|
||||
}
|
||||
const scene = makeFakeScene(nodes)
|
||||
const dirty = cascadeDirty(id('a1'), { scene })
|
||||
expect(dirty.size).toBe(2)
|
||||
expect(dirty.has(id('a1'))).toBe(true)
|
||||
expect(dirty.has(id('a2'))).toBe(true)
|
||||
})
|
||||
|
||||
test('custom childQuery overrides the default node.children lookup', () => {
|
||||
registerNode(makeDef('wall', { hosts: ['door'] }))
|
||||
registerNode(makeDef('door'))
|
||||
const scene = makeFakeScene({
|
||||
w1: makeNode('wall', 'w1'), // no children field
|
||||
d1: makeNode('door', 'd1', { parentId: id('w1') }),
|
||||
})
|
||||
|
||||
// childQuery iterates the scene to find parentId matches — what you would
|
||||
// do for kinds that don't carry an explicit children array.
|
||||
const childQuery = (node: AnyNode) => {
|
||||
const result: AnyNodeId[] = []
|
||||
for (const candidate of [id('d1')]) {
|
||||
const c = scene.get(candidate)
|
||||
if (c && c.parentId === node.id) result.push(c.id)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
const dirty = cascadeDirty(id('w1'), { scene, childQuery })
|
||||
expect(Array.from(dirty).sort()).toEqual([id('d1'), id('w1')])
|
||||
})
|
||||
})
|
||||
|
||||
describe('collectDescendants', () => {
|
||||
beforeEach(() => {
|
||||
nodeRegistry._reset()
|
||||
})
|
||||
|
||||
test('returns just the start when no children', () => {
|
||||
const scene = makeFakeScene({ a: makeNode('thing', 'a') })
|
||||
const result = collectDescendants(id('a'), { scene })
|
||||
expect(Array.from(result)).toEqual([id('a')])
|
||||
})
|
||||
|
||||
test('returns full subtree regardless of relations declarations', () => {
|
||||
// No def registered — descendants still found via the children array.
|
||||
const scene = makeFakeScene({
|
||||
root: makeNode('thing', 'root', { children: [id('c1'), id('c2')] } as Partial<AnyNode>),
|
||||
c1: makeNode('thing', 'c1', { children: [id('g1')] } as Partial<AnyNode>),
|
||||
c2: makeNode('thing', 'c2'),
|
||||
g1: makeNode('thing', 'g1'),
|
||||
})
|
||||
|
||||
const result = collectDescendants(id('root'), { scene })
|
||||
expect(Array.from(result).sort()).toEqual([id('c1'), id('c2'), id('g1'), id('root')])
|
||||
})
|
||||
|
||||
test('respects maxDepth', () => {
|
||||
const scene = makeFakeScene({
|
||||
a: makeNode('thing', 'a', { children: [id('b')] } as Partial<AnyNode>),
|
||||
b: makeNode('thing', 'b', { children: [id('c')] } as Partial<AnyNode>),
|
||||
c: makeNode('thing', 'c', { children: [id('d')] } as Partial<AnyNode>),
|
||||
d: makeNode('thing', 'd'),
|
||||
})
|
||||
const result = collectDescendants(id('a'), { scene, maxDepth: 2 })
|
||||
expect(Array.from(result).sort()).toEqual([id('a'), id('b'), id('c')]) // d truncated
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,124 @@
|
||||
import type { AnyNode, AnyNodeId } from '../schema/types'
|
||||
import { nodeRegistry } from './registry'
|
||||
import type { SceneApi } from './types'
|
||||
|
||||
/**
|
||||
* Spatial neighbor query — given a node and a set of kinds, returns IDs of
|
||||
* neighboring nodes of those kinds. The runtime provides this from
|
||||
* `spatialGridManager`; tests can pass a stub.
|
||||
*/
|
||||
export type SpatialQuery = (node: AnyNode, kinds: readonly string[]) => Iterable<AnyNodeId>
|
||||
|
||||
/**
|
||||
* Returns the IDs of nodes that share `node` as a parent. The runtime can
|
||||
* pass an optimized index; the default fallback iterates the scene.
|
||||
*/
|
||||
export type ChildQuery = (node: AnyNode) => Iterable<AnyNodeId>
|
||||
|
||||
export type CascadeContext = {
|
||||
scene: SceneApi
|
||||
/** Optional: bounded spatial neighbor lookup. Required for `affectsSpatial`. */
|
||||
spatialQuery?: SpatialQuery
|
||||
/** Optional: children-by-parent lookup. Defaults to iterating the scene. */
|
||||
childQuery?: ChildQuery
|
||||
/** Safety cap on cascade depth — guards against bad data and pathological
|
||||
* registry configurations. Default 16 (deeper than the maxHostDepth of 6). */
|
||||
maxDepth?: number
|
||||
}
|
||||
|
||||
const DEFAULT_MAX_DEPTH = 16
|
||||
|
||||
/**
|
||||
* Walks the relations graph from one dirty node and returns the full set of
|
||||
* IDs (including the starting one) that should be marked dirty. Pure — does
|
||||
* NOT call `scene.markDirty`; callers iterate the result.
|
||||
*
|
||||
* Phase 1 implements:
|
||||
* - `hosts`: marks children whose `type` matches the kind list
|
||||
* - `affectsSpatial`: marks neighbors found via `spatialQuery`
|
||||
*
|
||||
* Phase 3 will add `linkedBy: 'endpoint-match'` for wall corner propagation.
|
||||
*/
|
||||
export function cascadeDirty(startId: AnyNodeId, ctx: CascadeContext): Set<AnyNodeId> {
|
||||
const result = new Set<AnyNodeId>()
|
||||
const maxDepth = ctx.maxDepth ?? DEFAULT_MAX_DEPTH
|
||||
walk(startId, ctx, result, 0, maxDepth)
|
||||
return result
|
||||
}
|
||||
|
||||
function walk(
|
||||
id: AnyNodeId,
|
||||
ctx: CascadeContext,
|
||||
result: Set<AnyNodeId>,
|
||||
depth: number,
|
||||
maxDepth: number,
|
||||
): void {
|
||||
if (result.has(id) || depth > maxDepth) return
|
||||
result.add(id)
|
||||
|
||||
const node = ctx.scene.get(id)
|
||||
if (!node) return
|
||||
|
||||
const def = nodeRegistry.get(node.type)
|
||||
if (!def?.relations) return
|
||||
|
||||
const { hosts, affectsSpatial } = def.relations
|
||||
|
||||
if (hosts && hosts.length > 0) {
|
||||
const childIds = ctx.childQuery ? ctx.childQuery(node) : defaultChildIds(node, ctx.scene)
|
||||
for (const childId of childIds) {
|
||||
const child = ctx.scene.get(childId)
|
||||
if (child && (hosts as readonly string[]).includes(child.type)) {
|
||||
walk(childId, ctx, result, depth + 1, maxDepth)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (affectsSpatial && affectsSpatial.length > 0 && ctx.spatialQuery) {
|
||||
for (const neighborId of ctx.spatialQuery(node, affectsSpatial)) {
|
||||
walk(neighborId, ctx, result, depth + 1, maxDepth)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fallback children lookup that reads the node's `children: AnyNodeId[]`
|
||||
* field if present. Most parametric nodes carry one; nodes that don't will
|
||||
* need a `childQuery` override on the context.
|
||||
*/
|
||||
function defaultChildIds(node: AnyNode, _scene: SceneApi): AnyNodeId[] {
|
||||
const maybeChildren = (node as unknown as { children?: AnyNodeId[] }).children
|
||||
return Array.isArray(maybeChildren) ? maybeChildren : []
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively collects every descendant of a node, plus the node itself.
|
||||
* Used by `cascadeDelete: 'descendants'` and by tools that need to delete a
|
||||
* subtree atomically. Independent of dirty-marking — pure traversal.
|
||||
*/
|
||||
export function collectDescendants(
|
||||
startId: AnyNodeId,
|
||||
ctx: Pick<CascadeContext, 'scene' | 'childQuery' | 'maxDepth'>,
|
||||
): Set<AnyNodeId> {
|
||||
const result = new Set<AnyNodeId>()
|
||||
const maxDepth = ctx.maxDepth ?? DEFAULT_MAX_DEPTH
|
||||
walkDescendants(startId, ctx, result, 0, maxDepth)
|
||||
return result
|
||||
}
|
||||
|
||||
function walkDescendants(
|
||||
id: AnyNodeId,
|
||||
ctx: Pick<CascadeContext, 'scene' | 'childQuery' | 'maxDepth'>,
|
||||
result: Set<AnyNodeId>,
|
||||
depth: number,
|
||||
maxDepth: number,
|
||||
): void {
|
||||
if (result.has(id) || depth > maxDepth) return
|
||||
result.add(id)
|
||||
const node = ctx.scene.get(id)
|
||||
if (!node) return
|
||||
const childIds = ctx.childQuery ? ctx.childQuery(node) : defaultChildIds(node, ctx.scene)
|
||||
for (const childId of childIds) {
|
||||
walkDescendants(childId, ctx, result, depth + 1, maxDepth)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user