import type { SubshapeRef, TopoRefValue } from './types' import { matchSubshapes, type SubshapeMatch, type SubshapeSignature } from './topologyNaming' export type PersistedTopoRef = TopoRefValue export type TopoRefResolution = { status: 'resolved' | 'ambiguous' | 'deleted' ref?: SubshapeRef candidates?: SubshapeRef[] } export type TopologyMigration = { records: PersistedTopoRef[] matches: SubshapeMatch[] counts: Record, number> } const transientIndexKeys = new Set(['faceIndex', 'edgeIndex', 'vertexIndex', 'subshapeIndex']) const statuses = new Set>(['stable', 'ambiguous', 'new', 'deleted']) const kinds = new Set(['face', 'edge', 'vertex']) const finiteNonNegativeInteger = (value: unknown) => typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 export const createPersistedTopoRef = (objectId: string, ref: SubshapeRef, generation: number): PersistedTopoRef => { if (!objectId.trim()) throw new RangeError('TopoRef objectId is required.') if (!finiteNonNegativeInteger(generation)) throw new RangeError('TopoRef generation must be a non-negative integer.') if (!ref.persistentId.trim()) throw new RangeError('TopoRef persistentId is required.') if (!finiteNonNegativeInteger(ref.topologyVersion)) throw new RangeError('TopoRef topologyVersion must be a non-negative integer.') return { schemaVersion: 1, objectId, kind: ref.kind, persistentId: ref.persistentId, topologyVersion: ref.topologyVersion, generation, status: ref.status ?? 'stable', signature: ref.signature, candidates: ref.candidates ? [...new Set(ref.candidates)] : undefined, } } export const serializeTopoRef = (record: PersistedTopoRef) => JSON.stringify(createPersistedTopoRef(record.objectId, { shapeId: record.objectId, kind: record.kind, persistentId: record.persistentId, topologyVersion: record.topologyVersion, status: record.status, signature: record.signature, candidates: record.candidates, }, record.generation)) export const parseTopoRef = (json: string): PersistedTopoRef => { let value: unknown try { value = JSON.parse(json) } catch { throw new SyntaxError('TopoRef JSON is invalid.') } if (!value || typeof value !== 'object' || Array.isArray(value)) throw new TypeError('TopoRef must be a JSON object.') const source = value as Record for (const key of transientIndexKeys) if (key in source) throw new RangeError(`TopoRef must not persist transient ${key}.`) if (source.schemaVersion !== 1) throw new RangeError('TopoRef schemaVersion is not supported.') if (typeof source.objectId !== 'string' || typeof source.persistentId !== 'string') throw new TypeError('TopoRef objectId and persistentId must be strings.') if (!kinds.has(source.kind as SubshapeRef['kind'])) throw new RangeError('TopoRef kind is invalid.') if (!statuses.has(source.status as NonNullable)) throw new RangeError('TopoRef status is invalid.') if (!finiteNonNegativeInteger(source.topologyVersion) || !finiteNonNegativeInteger(source.generation)) throw new RangeError('TopoRef version fields must be non-negative integers.') if (source.signature !== undefined && typeof source.signature !== 'string') throw new TypeError('TopoRef signature must be a string.') if (source.candidates !== undefined && (!Array.isArray(source.candidates) || !source.candidates.every((candidate) => typeof candidate === 'string'))) throw new TypeError('TopoRef candidates must be strings.') return createPersistedTopoRef(source.objectId, { shapeId: source.objectId, kind: source.kind as SubshapeRef['kind'], persistentId: source.persistentId, topologyVersion: source.topologyVersion as number, status: source.status as NonNullable, signature: source.signature as string | undefined, candidates: source.candidates as string[] | undefined, }, source.generation as number) } export const resolveTopoRef = (record: PersistedTopoRef, current: SubshapeRef[]): TopoRefResolution => { const sameKind = current.filter((ref) => ref.kind === record.kind) const byId = sameKind.filter((ref) => ref.persistentId === record.persistentId) if (byId.length === 1 && byId[0].status !== 'ambiguous') return { status: 'resolved', ref: byId[0] } const bySignature = record.signature ? sameKind.filter((ref) => ref.signature === record.signature) : [] const candidates = byId.length > 0 ? byId : bySignature if (candidates.length === 1 && candidates[0].status !== 'ambiguous') return { status: 'resolved', ref: candidates[0] } if (candidates.length > 0) return { status: 'ambiguous', candidates } return { status: 'deleted' } } export const migrateTopoRefs = ( objectId: string, previous: Array<{ ref: SubshapeRef; signature: SubshapeSignature }>, current: Array<{ ref: SubshapeRef; signature: SubshapeSignature }>, generation: number, ): TopologyMigration => { const matches = matchSubshapes(previous, current) const counts: TopologyMigration['counts'] = { stable: 0, ambiguous: 0, new: 0, deleted: 0 } const records = matches.map((match) => { counts[match.status] += 1 return createPersistedTopoRef(objectId, match.current, generation) }) return { records, matches, counts } }