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 { 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 { createSceneApi, type SceneStoreLike } from './scene-api'
|
||||||
export type {
|
export type {
|
||||||
Affordance,
|
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