From 177e2f2739f0185f87917f32cc37e772b33e6eb6 Mon Sep 17 00:00:00 2001 From: Laurin Weger Date: Sun, 19 Oct 2025 11:42:23 +0200 Subject: [PATCH 1/8] deep signals: emit nested patches when adding new object too --- sdk/js/alien-deepsignals/src/deepSignal.ts | 1297 +++++++++-------- .../src/test/watchPatches.test.ts | 729 ++++----- 2 files changed, 1075 insertions(+), 951 deletions(-) diff --git a/sdk/js/alien-deepsignals/src/deepSignal.ts b/sdk/js/alien-deepsignals/src/deepSignal.ts index 0d90548..48490bc 100644 --- a/sdk/js/alien-deepsignals/src/deepSignal.ts +++ b/sdk/js/alien-deepsignals/src/deepSignal.ts @@ -11,46 +11,46 @@ import { computed, signal, isSignal } from "./core"; /** A batched deep mutation (set/add/remove) from a deepSignal root. */ export type DeepPatch = { - /** Unique identifier for the deep signal root which produced this patch. */ - root: symbol; - /** Property path (array indices, object keys, synthetic Set entry ids) from the root to the mutated location. */ - path: (string | number)[]; + /** Unique identifier for the deep signal root which produced this patch. */ + root: symbol; + /** Property path (array indices, object keys, synthetic Set entry ids) from the root to the mutated location. */ + path: (string | number)[]; } & ( - | DeepSetAddPatch - | DeepSetRemovePatch - | DeepObjectAddPatch - | DeepRemovePatch - | DeepLiteralAddPatch + | DeepSetAddPatch + | DeepSetRemovePatch + | DeepObjectAddPatch + | DeepRemovePatch + | DeepLiteralAddPatch ); export interface DeepSetAddPatch { - /** Mutation kind applied at the resolved `path`. */ - op: "add"; - type: "set"; - /** New value for `set` mutations (omitted for `delete`). */ - value: (number | string | boolean)[] | { [id: string]: object }; + /** Mutation kind applied at the resolved `path`. */ + op: "add"; + type: "set"; + /** New value for `set` mutations (omitted for `delete`). */ + value: (number | string | boolean)[] | { [id: string]: object }; } export interface DeepSetRemovePatch { - /** Mutation kind applied at the resolved `path`. */ - op: "remove"; - type: "set"; - /** The value to be removed from the set. Either a literal or the key (id) of an object. */ - value: string | number | boolean; + /** Mutation kind applied at the resolved `path`. */ + op: "remove"; + type: "set"; + /** The value to be removed from the set. Either a literal or the key (id) of an object. */ + value: string | number | boolean; } export interface DeepObjectAddPatch { - /** Mutation kind applied at the resolved `path`. */ - op: "add"; - type: "object"; + /** Mutation kind applied at the resolved `path`. */ + op: "add"; + type: "object"; } export interface DeepRemovePatch { - /** Mutation kind applied at the resolved `path`. */ - op: "remove"; + /** Mutation kind applied at the resolved `path`. */ + op: "remove"; } export interface DeepLiteralAddPatch { - /** Mutation kind applied at the resolved `path` */ - op: "add"; - /** The literal value to be added at the resolved `path` */ - value: string | number | boolean; + /** Mutation kind applied at the resolved `path` */ + op: "add"; + /** The literal value to be added at the resolved `path` */ + value: string | number | boolean; } /** Callback signature for subscribeDeepMutations. */ @@ -58,12 +58,12 @@ export type DeepPatchSubscriber = (patches: DeepPatch[]) => void; /** Minimal per-proxy metadata for path reconstruction. */ interface ProxyMeta { - /** Parent proxy in the object graph (undefined for root). */ - parent?: object; - /** Key within the parent pointing to this proxy (undefined for root). */ - key?: string | number; - /** Stable root id symbol shared by the entire deepSignal tree. */ - root: symbol; + /** Parent proxy in the object graph (undefined for root). */ + parent?: object; + /** Key within the parent pointing to this proxy (undefined for root). */ + key?: string | number; + /** Stable root id symbol shared by the entire deepSignal tree. */ + root: symbol; } // Proxy -> metadata @@ -78,73 +78,117 @@ let microtaskScheduled = false; export const DEEP_SIGNAL_ROOT_ID = Symbol("alienDeepSignalRootId"); function buildPath( - startProxy: object, - leafKey: string | number + startProxy: object, + leafKey: string | number ): (string | number)[] { - const path: (string | number)[] = [leafKey]; - let cur: object | undefined = startProxy; - while (cur) { - const meta = proxyMeta.get(cur); - if (!meta) break; // Defensive: metadata should always exist. - if (meta.key === undefined) break; // Reached root (no key recorded). - path.unshift(meta.key); - cur = meta.parent; - } - return path; + const path: (string | number)[] = [leafKey]; + let cur: object | undefined = startProxy; + while (cur) { + const meta = proxyMeta.get(cur); + if (!meta) break; // Defensive: metadata should always exist. + if (meta.key === undefined) break; // Reached root (no key recorded). + path.unshift(meta.key); + cur = meta.parent; + } + return path; } function queuePatch(patch: DeepPatch) { - if (!pendingPatches) pendingPatches = new Map(); - const root = patch.root; - let list = pendingPatches.get(root); - if (!list) { - list = []; - pendingPatches.set(root, list); - } - list.push(patch); - if (!microtaskScheduled) { - microtaskScheduled = true; - queueMicrotask(() => { - microtaskScheduled = false; - const groups = pendingPatches; - pendingPatches = null; - if (!groups) return; - for (const [rootId, patches] of groups) { - if (!patches.length) continue; - const subs = mutationSubscribers.get(rootId); - if (subs) subs.forEach((cb) => cb(patches)); - } + if (!pendingPatches) pendingPatches = new Map(); + const root = patch.root; + let list = pendingPatches.get(root); + if (!list) { + list = []; + pendingPatches.set(root, list); + } + list.push(patch); + if (!microtaskScheduled) { + microtaskScheduled = true; + queueMicrotask(() => { + microtaskScheduled = false; + const groups = pendingPatches; + pendingPatches = null; + if (!groups) return; + for (const [rootId, patches] of groups) { + if (!patches.length) continue; + const subs = mutationSubscribers.get(rootId); + if (subs) subs.forEach((cb) => cb(patches)); + } + }); + } +} + +/** Recursively emit patches for all nested properties of a newly attached object. */ +function queueDeepPatches( + val: any, + rootId: symbol, + basePath: (string | number)[] +) { + if (!val || typeof val !== "object") { + // Emit patch for primitive leaf + queuePatch({ + root: rootId, + path: basePath, + op: "add", + value: val, + }); + return; + } + + // Emit patch for the object/array/Set itself + queuePatch({ + root: rootId, + path: basePath, + op: "add", + type: "object", }); - } + + // Recursively process nested properties + if (Array.isArray(val)) { + for (let i = 0; i < val.length; i++) { + queueDeepPatches(val[i], rootId, [...basePath, i]); + } + } else if (val instanceof Set) { + for (const entry of val) { + const key = getSetEntryKey(entry); + queueDeepPatches(entry, rootId, [...basePath, key]); + } + } else if (val.constructor === Object) { + for (const key in val) { + if (Object.prototype.hasOwnProperty.call(val, key)) { + queueDeepPatches(val[key], rootId, [...basePath, key]); + } + } + } } /** Subscribe to microtask-batched deep patches for a root (returns unsubscribe). */ export function subscribeDeepMutations( - root: object | symbol, - sub: DeepPatchSubscriber + root: object | symbol, + sub: DeepPatchSubscriber ): () => void { - const rootId = typeof root === "symbol" ? root : getDeepSignalRootId(root); - if (!rootId) - throw new Error( - "subscribeDeepMutations() expects a deepSignal root proxy or root id symbol" - ); - let set = mutationSubscribers.get(rootId); - if (!set) { - set = new Set(); - mutationSubscribers.set(rootId, set); - } - set.add(sub); - return () => { - const bucket = mutationSubscribers.get(rootId); - if (!bucket) return; - bucket.delete(sub); - if (bucket.size === 0) mutationSubscribers.delete(rootId); - }; + const rootId = typeof root === "symbol" ? root : getDeepSignalRootId(root); + if (!rootId) + throw new Error( + "subscribeDeepMutations() expects a deepSignal root proxy or root id symbol" + ); + let set = mutationSubscribers.get(rootId); + if (!set) { + set = new Set(); + mutationSubscribers.set(rootId, set); + } + set.add(sub); + return () => { + const bucket = mutationSubscribers.get(rootId); + if (!bucket) return; + bucket.delete(sub); + if (bucket.size === 0) mutationSubscribers.delete(rootId); + }; } /** Return the stable root symbol for any deepSignal proxy (undefined if not one). */ export function getDeepSignalRootId(obj: any): symbol | undefined { - return proxyMeta.get(obj)?.root; + return proxyMeta.get(obj)?.root; } // Proxy -> Map of property name -> signal function @@ -164,636 +208,648 @@ let peeking = false; // Deep array interface refining callback parameter types. type DeepArray = Array & { - map: ( - callbackfn: ( - value: DeepSignal, - index: number, - array: DeepSignalArray - ) => U, - thisArg?: any - ) => U[]; - forEach: ( - callbackfn: ( - value: DeepSignal, - index: number, - array: DeepSignalArray - ) => void, - thisArg?: any - ) => void; - concat(...items: ConcatArray[]): DeepSignalArray; - concat(...items: (T | ConcatArray)[]): DeepSignalArray; - reverse(): DeepSignalArray; - shift(): DeepSignal | undefined; - slice(start?: number, end?: number): DeepSignalArray; - splice(start: number, deleteCount?: number): DeepSignalArray; - splice( - start: number, - deleteCount: number, - ...items: T[] - ): DeepSignalArray; - filter( - predicate: ( - value: DeepSignal, - index: number, - array: DeepSignalArray - ) => value is DeepSignal, - thisArg?: any - ): DeepSignalArray; - filter( - predicate: ( - value: DeepSignal, - index: number, - array: DeepSignalArray - ) => unknown, - thisArg?: any - ): DeepSignalArray; - reduce( - callbackfn: ( - previousValue: DeepSignal, - currentValue: DeepSignal, - currentIndex: number, - array: DeepSignalArray - ) => T - ): DeepSignal; - reduce( - callbackfn: ( - previousValue: DeepSignal, - currentValue: DeepSignal, - currentIndex: number, - array: DeepSignalArray - ) => DeepSignal, - initialValue: T - ): DeepSignal; - reduce( - callbackfn: ( - previousValue: U, - currentValue: DeepSignal, - currentIndex: number, - array: DeepSignalArray - ) => U, - initialValue: U - ): U; - reduceRight( - callbackfn: ( - previousValue: DeepSignal, - currentValue: DeepSignal, - currentIndex: number, - array: DeepSignalArray - ) => T - ): DeepSignal; - reduceRight( - callbackfn: ( - previousValue: DeepSignal, - currentValue: DeepSignal, - currentIndex: number, - array: DeepSignalArray - ) => DeepSignal, - initialValue: T - ): DeepSignal; - reduceRight( - callbackfn: ( - previousValue: U, - currentValue: DeepSignal, - currentIndex: number, - array: DeepSignalArray - ) => U, - initialValue: U - ): U; + map: ( + callbackfn: ( + value: DeepSignal, + index: number, + array: DeepSignalArray + ) => U, + thisArg?: any + ) => U[]; + forEach: ( + callbackfn: ( + value: DeepSignal, + index: number, + array: DeepSignalArray + ) => void, + thisArg?: any + ) => void; + concat(...items: ConcatArray[]): DeepSignalArray; + concat(...items: (T | ConcatArray)[]): DeepSignalArray; + reverse(): DeepSignalArray; + shift(): DeepSignal | undefined; + slice(start?: number, end?: number): DeepSignalArray; + splice(start: number, deleteCount?: number): DeepSignalArray; + splice( + start: number, + deleteCount: number, + ...items: T[] + ): DeepSignalArray; + filter( + predicate: ( + value: DeepSignal, + index: number, + array: DeepSignalArray + ) => value is DeepSignal, + thisArg?: any + ): DeepSignalArray; + filter( + predicate: ( + value: DeepSignal, + index: number, + array: DeepSignalArray + ) => unknown, + thisArg?: any + ): DeepSignalArray; + reduce( + callbackfn: ( + previousValue: DeepSignal, + currentValue: DeepSignal, + currentIndex: number, + array: DeepSignalArray + ) => T + ): DeepSignal; + reduce( + callbackfn: ( + previousValue: DeepSignal, + currentValue: DeepSignal, + currentIndex: number, + array: DeepSignalArray + ) => DeepSignal, + initialValue: T + ): DeepSignal; + reduce( + callbackfn: ( + previousValue: U, + currentValue: DeepSignal, + currentIndex: number, + array: DeepSignalArray + ) => U, + initialValue: U + ): U; + reduceRight( + callbackfn: ( + previousValue: DeepSignal, + currentValue: DeepSignal, + currentIndex: number, + array: DeepSignalArray + ) => T + ): DeepSignal; + reduceRight( + callbackfn: ( + previousValue: DeepSignal, + currentValue: DeepSignal, + currentIndex: number, + array: DeepSignalArray + ) => DeepSignal, + initialValue: T + ): DeepSignal; + reduceRight( + callbackfn: ( + previousValue: U, + currentValue: DeepSignal, + currentIndex: number, + array: DeepSignalArray + ) => U, + initialValue: U + ): U; }; // Synthetic ids for Set entry objects (stable key for patches) let __blankNodeCounter = 0; const setObjectIds = new WeakMap(); const assignBlankNodeId = (obj: any) => { - if (setObjectIds.has(obj)) return setObjectIds.get(obj)!; - const id = `_b${++__blankNodeCounter}`; - setObjectIds.set(obj, id); - return id; + if (setObjectIds.has(obj)) return setObjectIds.get(obj)!; + const id = `_b${++__blankNodeCounter}`; + setObjectIds.set(obj, id); + return id; }; /** Assign (or override) synthetic id before Set.add(). */ export function setSetEntrySyntheticId(obj: object, id: string | number) { - setObjectIds.set(obj, String(id)); + setObjectIds.set(obj, String(id)); } const getSetEntryKey = (val: any): string | number => { - if (val && typeof val === "object") { - if (setObjectIds.has(val)) return setObjectIds.get(val)!; - if ( - typeof (val as any).id === "string" || - typeof (val as any).id === "number" - ) - return (val as any).id; - if ( - typeof (val as any)["@id"] === "string" || - typeof (val as any)["@id"] === "number" - ) - return (val as any)["@id"]; - return assignBlankNodeId(val); - } - return val as any; + if (val && typeof val === "object") { + if (setObjectIds.has(val)) return setObjectIds.get(val)!; + if ( + typeof (val as any).id === "string" || + typeof (val as any).id === "number" + ) + return (val as any).id; + if ( + typeof (val as any)["@id"] === "string" || + typeof (val as any)["@id"] === "number" + ) + return (val as any)["@id"]; + return assignBlankNodeId(val); + } + return val as any; }; /** Add entry with synthetic id; returns proxied object if applicable. */ export function addWithId( - set: Set, - entry: T, - id: string | number + set: Set, + entry: T, + id: string | number ): DeepSignal; export function addWithId(set: Set, entry: T, id: string | number): T; export function addWithId(set: Set, entry: any, id: string | number) { - if (entry && typeof entry === "object") setSetEntrySyntheticId(entry, id); - (set as any).add(entry); - if (entry && typeof entry === "object" && objToProxy.has(entry)) - return objToProxy.get(entry); - return entry; + if (entry && typeof entry === "object") setSetEntrySyntheticId(entry, id); + (set as any).add(entry); + if (entry && typeof entry === "object" && objToProxy.has(entry)) + return objToProxy.get(entry); + return entry; } /** Is value a deepSignal-managed proxy? */ export const isDeepSignal = (source: any) => { - return proxyToSignals.has(source); + return proxyToSignals.has(source); }; /** Was value explicitly marked shallow? */ export const isShallow = (source: any) => { - return ignore.has(source); + return ignore.has(source); }; /** Create (or reuse) a deep reactive proxy for an object / array / Set. */ export const deepSignal = (obj: T): DeepSignal => { - if (!shouldProxy(obj)) throw new Error("This object can't be observed."); - if (!objToProxy.has(obj)) { - // Create a unique root id symbol to identify this deep signal tree in patches. - const rootId = Symbol("deepSignalRoot"); - const proxy = createProxy(obj, objectHandlers, rootId) as DeepSignal; - const meta = proxyMeta.get(proxy)!; - meta.parent = undefined; // root has no parent - meta.key = undefined; // root not addressed by a key - meta.root = rootId; // ensure root id stored (explicit) - // Pre-register an empty signals map so isDeepSignal() is true before any property access. - if (!proxyToSignals.has(proxy)) proxyToSignals.set(proxy, new Map()); - objToProxy.set(obj, proxy); - } - return objToProxy.get(obj); + if (!shouldProxy(obj)) throw new Error("This object can't be observed."); + if (!objToProxy.has(obj)) { + // Create a unique root id symbol to identify this deep signal tree in patches. + const rootId = Symbol("deepSignalRoot"); + const proxy = createProxy(obj, objectHandlers, rootId) as DeepSignal; + const meta = proxyMeta.get(proxy)!; + meta.parent = undefined; // root has no parent + meta.key = undefined; // root not addressed by a key + meta.root = rootId; // ensure root id stored (explicit) + // Pre-register an empty signals map so isDeepSignal() is true before any property access. + if (!proxyToSignals.has(proxy)) proxyToSignals.set(proxy, new Map()); + objToProxy.set(obj, proxy); + } + return objToProxy.get(obj); }; /** Read property without tracking (untracked read). */ export const peek = < - T extends DeepSignalObject, - K extends keyof RevertDeepSignalObject + T extends DeepSignalObject, + K extends keyof RevertDeepSignalObject, >( - obj: T, - key: K + obj: T, + key: K ): RevertDeepSignal[K]> => { - peeking = true; - const value = obj[key]; - try { - peeking = false; - } catch (e) {} - return value as RevertDeepSignal[K]>; + peeking = true; + const value = obj[key]; + try { + peeking = false; + } catch (e) {} + return value as RevertDeepSignal[K]>; }; const shallowFlag = Symbol(ReactiveFlags.IS_SHALLOW); /** Mark object to skip deep proxying (only reference changes tracked). */ export function shallow(obj: T): Shallow { - ignore.add(obj); - return obj as Shallow; + ignore.add(obj); + return obj as Shallow; } const createProxy = ( - target: object, - handlers: ProxyHandler, - rootId?: symbol + target: object, + handlers: ProxyHandler, + rootId?: symbol ) => { - const proxy = new Proxy(target, handlers); - ignore.add(proxy); - // Initialize proxy metadata if not present. Root proxies provide a stable root id. - if (!proxyMeta.has(proxy)) { - proxyMeta.set(proxy, { root: rootId || Symbol("deepSignalDetachedRoot") }); - } - return proxy; + const proxy = new Proxy(target, handlers); + ignore.add(proxy); + // Initialize proxy metadata if not present. Root proxies provide a stable root id. + if (!proxyMeta.has(proxy)) { + proxyMeta.set(proxy, { + root: rootId || Symbol("deepSignalDetachedRoot"), + }); + } + return proxy; }; // Set-specific access & structural patch emission. function getFromSet( - raw: Set, - key: string | symbol, - receiver: object + raw: Set, + key: string | symbol, + receiver: object ): any { - const meta = proxyMeta.get(receiver); - // Helper to proxy a single entry (object) & assign synthetic id if needed. - const ensureEntryProxy = (entry: any) => { - if ( - entry && - typeof entry === "object" && - shouldProxy(entry) && - !objToProxy.has(entry) - ) { - const synthetic = getSetEntryKey(entry); - const childProxy = createProxy(entry, objectHandlers, meta!.root); - const childMeta = proxyMeta.get(childProxy)!; - childMeta.parent = receiver; - childMeta.key = synthetic; - objToProxy.set(entry, childProxy); - return childProxy; - } - if (objToProxy.has(entry)) return objToProxy.get(entry); - return entry; - }; - // Pre-pass to ensure any existing non-proxied object entries are proxied (enables deep patches after iteration) - if (meta) raw.forEach(ensureEntryProxy); - if (key === "add" || key === "delete" || key === "clear") { - const fn: Function = (raw as any)[key]; - return function (this: any, ...args: any[]) { - const sizeBefore = raw.size; - const result = fn.apply(raw, args); - if (raw.size !== sizeBefore) { - const metaNow = proxyMeta.get(receiver); + const meta = proxyMeta.get(receiver); + // Helper to proxy a single entry (object) & assign synthetic id if needed. + const ensureEntryProxy = (entry: any) => { if ( - metaNow && - metaNow.parent !== undefined && - metaNow.key !== undefined + entry && + typeof entry === "object" && + shouldProxy(entry) && + !objToProxy.has(entry) ) { - const containerPath = buildPath(metaNow.parent, metaNow.key); - if (key === "add") { - const entry = args[0]; - let synthetic = getSetEntryKey(entry); - if (entry && typeof entry === "object") { - for (const existing of raw.values()) { - if (existing === entry) continue; - if (getSetEntryKey(existing) === synthetic) { - synthetic = assignBlankNodeId(entry); - break; - } - } - } - let entryVal = entry; - if ( - entryVal && - typeof entryVal === "object" && - shouldProxy(entryVal) && - !objToProxy.has(entryVal) - ) { - const childProxy = createProxy( - entryVal, - objectHandlers, - metaNow.root - ); - const childMeta = proxyMeta.get(childProxy)!; - childMeta.parent = receiver; - childMeta.key = synthetic; - objToProxy.set(entryVal, childProxy); - entryVal = childProxy; - } - // Set entry add: emit object vs literal variant. - if (entryVal && typeof entryVal === "object") { - queuePatch({ - root: metaNow.root, - path: [...containerPath, synthetic], - op: "add", - type: "object", - }); - } else { - queuePatch({ - root: metaNow.root, - path: [...containerPath, synthetic], - op: "add", - value: entryVal, - }); - } - } else if (key === "delete") { - const entry = args[0]; const synthetic = getSetEntryKey(entry); - queuePatch({ - root: metaNow.root, - path: [...containerPath, synthetic], - op: "remove", - }); - } else if (key === "clear") { - // Structural clear: remove prior entry-level patches for this Set this tick. - if (pendingPatches) { - const group = pendingPatches.get(metaNow.root); - if (group && group.length) { - for (let i = group.length - 1; i >= 0; i--) { - const p = group[i]; - if ( - p.path.length === containerPath.length + 1 && - containerPath.every((seg, idx) => p.path[idx] === seg) - ) { - group.splice(i, 1); - } - } - } - } - queuePatch({ - root: metaNow.root, - path: containerPath, - op: "add", - type: "set", - value: [], - }); - } + const childProxy = createProxy(entry, objectHandlers, meta!.root); + const childMeta = proxyMeta.get(childProxy)!; + childMeta.parent = receiver; + childMeta.key = synthetic; + objToProxy.set(entry, childProxy); + return childProxy; } - } - return result; - }; - } - const makeIterator = (pair: boolean) => { - return function thisIter(this: any) { - const iterable = raw.values(); - return { - [Symbol.iterator]() { - return { - next() { - const n = iterable.next(); - if (n.done) return n; - const entry = ensureEntryProxy(n.value); - return { value: pair ? [entry, entry] : entry, done: false }; - }, - }; - }, - } as Iterable; - }; - }; - if (key === "values" || key === "keys") return makeIterator(false); - if (key === "entries") return makeIterator(true); - if (key === "forEach") { - return function thisForEach(this: any, cb: any, thisArg?: any) { - raw.forEach((entry: any) => { - cb.call(thisArg, ensureEntryProxy(entry), ensureEntryProxy(entry), raw); - }); + if (objToProxy.has(entry)) return objToProxy.get(entry); + return entry; }; - } - // Properly handle native iteration (for..of, Array.from, spread) by binding to the raw Set. - if (key === Symbol.iterator) { - // Return a function whose `this` is the raw Set (avoids brand check failure on the proxy). - return function (this: any) { - // Use raw.values() so we can still ensure child entries are proxied lazily. - const iterable = raw.values(); - return { - [Symbol.iterator]() { - return this; - }, - next() { - const n = iterable.next(); - if (n.done) return n; - const entry = ensureEntryProxy(n.value); - return { value: entry, done: false }; - }, - } as Iterator; + // Pre-pass to ensure any existing non-proxied object entries are proxied (enables deep patches after iteration) + if (meta) raw.forEach(ensureEntryProxy); + if (key === "add" || key === "delete" || key === "clear") { + const fn: Function = (raw as any)[key]; + return function (this: any, ...args: any[]) { + const sizeBefore = raw.size; + const result = fn.apply(raw, args); + if (raw.size !== sizeBefore) { + const metaNow = proxyMeta.get(receiver); + if ( + metaNow && + metaNow.parent !== undefined && + metaNow.key !== undefined + ) { + const containerPath = buildPath( + metaNow.parent, + metaNow.key + ); + if (key === "add") { + const entry = args[0]; + let synthetic = getSetEntryKey(entry); + if (entry && typeof entry === "object") { + for (const existing of raw.values()) { + if (existing === entry) continue; + if (getSetEntryKey(existing) === synthetic) { + synthetic = assignBlankNodeId(entry); + break; + } + } + } + let entryVal = entry; + if ( + entryVal && + typeof entryVal === "object" && + shouldProxy(entryVal) && + !objToProxy.has(entryVal) + ) { + const childProxy = createProxy( + entryVal, + objectHandlers, + metaNow.root + ); + const childMeta = proxyMeta.get(childProxy)!; + childMeta.parent = receiver; + childMeta.key = synthetic; + objToProxy.set(entryVal, childProxy); + entryVal = childProxy; + } + // Set entry add: emit object vs literal variant. + if (entryVal && typeof entryVal === "object") { + queuePatch({ + root: metaNow.root, + path: [...containerPath, synthetic], + op: "add", + type: "object", + }); + } else { + queuePatch({ + root: metaNow.root, + path: [...containerPath, synthetic], + op: "add", + value: entryVal, + }); + } + } else if (key === "delete") { + const entry = args[0]; + const synthetic = getSetEntryKey(entry); + queuePatch({ + root: metaNow.root, + path: [...containerPath, synthetic], + op: "remove", + }); + } else if (key === "clear") { + // Structural clear: remove prior entry-level patches for this Set this tick. + if (pendingPatches) { + const group = pendingPatches.get(metaNow.root); + if (group && group.length) { + for (let i = group.length - 1; i >= 0; i--) { + const p = group[i]; + if ( + p.path.length === + containerPath.length + 1 && + containerPath.every( + (seg, idx) => p.path[idx] === seg + ) + ) { + group.splice(i, 1); + } + } + } + } + queuePatch({ + root: metaNow.root, + path: containerPath, + op: "add", + type: "set", + value: [], + }); + } + } + } + return result; + }; + } + const makeIterator = (pair: boolean) => { + return function thisIter(this: any) { + const iterable = raw.values(); + return { + [Symbol.iterator]() { + return { + next() { + const n = iterable.next(); + if (n.done) return n; + const entry = ensureEntryProxy(n.value); + return { + value: pair ? [entry, entry] : entry, + done: false, + }; + }, + }; + }, + } as Iterable; + }; }; - } - if (key === Symbol.iterator.toString()) { - // string form access of iterator symbol; pass through (rare path) - } - const val = (raw as any)[key]; - if (typeof val === "function") return val.bind(raw); - return val; + if (key === "values" || key === "keys") return makeIterator(false); + if (key === "entries") return makeIterator(true); + if (key === "forEach") { + return function thisForEach(this: any, cb: any, thisArg?: any) { + raw.forEach((entry: any) => { + cb.call( + thisArg, + ensureEntryProxy(entry), + ensureEntryProxy(entry), + raw + ); + }); + }; + } + // Properly handle native iteration (for..of, Array.from, spread) by binding to the raw Set. + if (key === Symbol.iterator) { + // Return a function whose `this` is the raw Set (avoids brand check failure on the proxy). + return function (this: any) { + // Use raw.values() so we can still ensure child entries are proxied lazily. + const iterable = raw.values(); + return { + [Symbol.iterator]() { + return this; + }, + next() { + const n = iterable.next(); + if (n.done) return n; + const entry = ensureEntryProxy(n.value); + return { value: entry, done: false }; + }, + } as Iterator; + }; + } + if (key === Symbol.iterator.toString()) { + // string form access of iterator symbol; pass through (rare path) + } + const val = (raw as any)[key]; + if (typeof val === "function") return val.bind(raw); + return val; } const throwOnMutation = () => { - throw new Error( - "Don't mutate the signals directly (use the underlying property/value instead)." - ); + throw new Error( + "Don't mutate the signals directly (use the underlying property/value instead)." + ); }; // Does target define a getter for key? function hasGetter(target: any, key: any) { - return typeof descriptor(target, key)?.get === "function"; + return typeof descriptor(target, key)?.get === "function"; } // Lazily allocate / fetch signal map for a proxy receiver. function getSignals(receiver: object) { - if (!proxyToSignals.has(receiver)) proxyToSignals.set(receiver, new Map()); - return proxyToSignals.get(receiver)!; + if (!proxyToSignals.has(receiver)) proxyToSignals.set(receiver, new Map()); + return proxyToSignals.get(receiver)!; } // Wrap & link child object/array/Set if needed. function ensureChildProxy(value: any, parent: object, key: string | number) { - if (!shouldProxy(value)) return value; - if (!objToProxy.has(value)) { - const parentMeta = proxyMeta.get(parent)!; - const childProxy = createProxy(value, objectHandlers, parentMeta.root); - const childMeta = proxyMeta.get(childProxy)!; - childMeta.parent = parent; - childMeta.key = key as string; - objToProxy.set(value, childProxy); - } - return objToProxy.get(value); + if (!shouldProxy(value)) return value; + if (!objToProxy.has(value)) { + const parentMeta = proxyMeta.get(parent)!; + const childProxy = createProxy(value, objectHandlers, parentMeta.root); + const childMeta = proxyMeta.get(childProxy)!; + childMeta.parent = parent; + childMeta.key = key as string; + objToProxy.set(value, childProxy); + } + return objToProxy.get(value); } // Normalize raw property key (handles $-prefix & array meta) -> { key, returnSignal } function normalizeKey( - target: any, - fullKey: string, - isArrayMeta: boolean, - receiver: object + target: any, + fullKey: string, + isArrayMeta: boolean, + receiver: object ) { - let returnSignal = isArrayMeta || fullKey[0] === "$"; - if (!isArrayMeta && Array.isArray(target) && returnSignal) { - if (fullKey === "$") { - // Provide $ meta proxy for array index signals - if (!arrayToArrayOfSignals.has(target)) { - arrayToArrayOfSignals.set( - target, - createProxy(target, arrayHandlers, proxyMeta.get(receiver)?.root) - ); - } - return { shortCircuit: arrayToArrayOfSignals.get(target) }; + let returnSignal = isArrayMeta || fullKey[0] === "$"; + if (!isArrayMeta && Array.isArray(target) && returnSignal) { + if (fullKey === "$") { + // Provide $ meta proxy for array index signals + if (!arrayToArrayOfSignals.has(target)) { + arrayToArrayOfSignals.set( + target, + createProxy( + target, + arrayHandlers, + proxyMeta.get(receiver)?.root + ) + ); + } + return { shortCircuit: arrayToArrayOfSignals.get(target) }; + } + returnSignal = fullKey === "$length"; } - returnSignal = fullKey === "$length"; - } - const key = returnSignal ? fullKey.replace(rg, "") : fullKey; - return { key, returnSignal } as any; + const key = returnSignal ? fullKey.replace(rg, "") : fullKey; + return { key, returnSignal } as any; } // Create computed signal for getter property if needed. function ensureComputed( - signals: Map, - target: any, - key: any, - receiver: any + signals: Map, + target: any, + key: any, + receiver: any ) { - if (!signals.has(key) && hasGetter(target, key)) { - signals.set( - key, - computed(() => Reflect.get(target, key, receiver)) - ); - } + if (!signals.has(key) && hasGetter(target, key)) { + signals.set( + key, + computed(() => Reflect.get(target, key, receiver)) + ); + } } // Unified get trap factory (object / array meta variant) const get = - (isArrayMeta: boolean) => - (target: object, fullKey: string, receiver: object): unknown => { - if (peeking) return Reflect.get(target, fullKey, receiver); - // Set handling delegated completely. - if (target instanceof Set) { - return getFromSet(target as Set, fullKey as any, receiver); - } - const norm = normalizeKey(target, fullKey, isArrayMeta, receiver); - if ((norm as any).shortCircuit) return (norm as any).shortCircuit; // returned meta proxy - const { key, returnSignal } = norm as { - key: string; - returnSignal: boolean; + (isArrayMeta: boolean) => + (target: object, fullKey: string, receiver: object): unknown => { + if (peeking) return Reflect.get(target, fullKey, receiver); + // Set handling delegated completely. + if (target instanceof Set) { + return getFromSet(target as Set, fullKey as any, receiver); + } + const norm = normalizeKey(target, fullKey, isArrayMeta, receiver); + if ((norm as any).shortCircuit) return (norm as any).shortCircuit; // returned meta proxy + const { key, returnSignal } = norm as { + key: string; + returnSignal: boolean; + }; + // Symbol fast-path + if (typeof key === "symbol" && wellKnownSymbols.has(key)) + return Reflect.get(target, key, receiver); + const signals = getSignals(receiver); + ensureComputed(signals, target, key, receiver); + if (!signals.has(key)) { + let value = Reflect.get(target, key, receiver); + if (returnSignal && typeof value === "function") return; // user asked for signal wrapper of function => ignore + value = ensureChildProxy(value, receiver, key); + signals.set(key, signal(value)); + } + const sig = signals.get(key); + return returnSignal ? sig : sig(); }; - // Symbol fast-path - if (typeof key === "symbol" && wellKnownSymbols.has(key)) - return Reflect.get(target, key, receiver); - const signals = getSignals(receiver); - ensureComputed(signals, target, key, receiver); - if (!signals.has(key)) { - let value = Reflect.get(target, key, receiver); - if (returnSignal && typeof value === "function") return; // user asked for signal wrapper of function => ignore - value = ensureChildProxy(value, receiver, key); - signals.set(key, signal(value)); - } - const sig = signals.get(key); - return returnSignal ? sig : sig(); - }; // Standard object / array handlers const objectHandlers = { - get: get(false), - set(target: object, fullKey: string, val: any, receiver: object): boolean { - // Respect original getter/setter semantics - if (typeof descriptor(target, fullKey)?.set === "function") - return Reflect.set(target, fullKey, val, receiver); - if (!proxyToSignals.has(receiver)) proxyToSignals.set(receiver, new Map()); - const signals = proxyToSignals.get(receiver); - if (fullKey[0] === "$") { - if (!isSignal(val)) throwOnMutation(); - const key = fullKey.replace(rg, ""); - signals.set(key, val); - return Reflect.set(target, key, val.peek(), receiver); - } else { - let internal = val; - if (shouldProxy(val)) { - if (!objToProxy.has(val)) { - // Link newly wrapped child to its parent for path reconstruction. - // In some edge cases parent metadata might not yet be initialized (e.g., - // if a proxied structure was reconstructed in a way that bypassed the - // original deepSignal root path). Fall back to creating/assigning it. - let parentMeta = proxyMeta.get(receiver); - if (!parentMeta) { - // Assign a root id (new symbol) so downstream patches remain groupable. - const created: ProxyMeta = { - root: Symbol("deepSignalRootAuto"), - } as ProxyMeta; - proxyMeta.set(receiver, created); - parentMeta = created; - } - const childProxy = createProxy(val, objectHandlers, parentMeta!.root); - const childMeta = proxyMeta.get(childProxy)!; - childMeta.parent = receiver; - childMeta.key = fullKey; - objToProxy.set(val, childProxy); - } - internal = objToProxy.get(val); - } - const isNew = !(fullKey in target); - const result = Reflect.set(target, fullKey, val, receiver); - - if (!signals.has(fullKey)) { - // First write after structure change -> create signal. - signals.set(fullKey, signal(internal)); - } else { - // Subsequent writes -> update underlying signal. - signals.get(fullKey).set(internal); - } - if (isNew && objToIterable.has(target)) objToIterable.get(target).value++; - if (Array.isArray(target) && signals.has("length")) - signals.get("length").set(target.length); - // Emit patch (after mutation) so subscribers get final value snapshot. - const meta = proxyMeta.get(receiver); - if (meta) { - // Object/Array/Set assignment at property path. - if (val && typeof val === "object") { - queuePatch({ - root: meta.root, - path: buildPath(receiver, fullKey), - op: "add", - type: "object", - }); + get: get(false), + set(target: object, fullKey: string, val: any, receiver: object): boolean { + // Respect original getter/setter semantics + if (typeof descriptor(target, fullKey)?.set === "function") + return Reflect.set(target, fullKey, val, receiver); + if (!proxyToSignals.has(receiver)) + proxyToSignals.set(receiver, new Map()); + const signals = proxyToSignals.get(receiver); + if (fullKey[0] === "$") { + if (!isSignal(val)) throwOnMutation(); + const key = fullKey.replace(rg, ""); + signals.set(key, val); + return Reflect.set(target, key, val.peek(), receiver); } else { - queuePatch({ - root: meta.root, - path: buildPath(receiver, fullKey), - op: "add", - value: val, - }); + let internal = val; + if (shouldProxy(val)) { + if (!objToProxy.has(val)) { + // Link newly wrapped child to its parent for path reconstruction. + // In some edge cases parent metadata might not yet be initialized (e.g., + // if a proxied structure was reconstructed in a way that bypassed the + // original deepSignal root path). Fall back to creating/assigning it. + let parentMeta = proxyMeta.get(receiver); + if (!parentMeta) { + // Assign a root id (new symbol) so downstream patches remain groupable. + const created: ProxyMeta = { + root: Symbol("deepSignalRootAuto"), + } as ProxyMeta; + proxyMeta.set(receiver, created); + parentMeta = created; + } + const childProxy = createProxy( + val, + objectHandlers, + parentMeta!.root + ); + const childMeta = proxyMeta.get(childProxy)!; + childMeta.parent = receiver; + childMeta.key = fullKey; + objToProxy.set(val, childProxy); + } + internal = objToProxy.get(val); + } + const isNew = !(fullKey in target); + const result = Reflect.set(target, fullKey, val, receiver); + + if (!signals.has(fullKey)) { + // First write after structure change -> create signal. + signals.set(fullKey, signal(internal)); + } else { + // Subsequent writes -> update underlying signal. + signals.get(fullKey).set(internal); + } + if (isNew && objToIterable.has(target)) + objToIterable.get(target).value++; + if (Array.isArray(target) && signals.has("length")) + signals.get("length").set(target.length); + // Emit patch (after mutation) so subscribers get final value snapshot. + const meta = proxyMeta.get(receiver); + if (meta) { + // Recursively emit patches for all nested properties of newly attached objects + queueDeepPatches(val, meta.root, buildPath(receiver, fullKey)); + } + return result; } - } - return result; - } - }, - deleteProperty(target: object, key: string): boolean { - if (key[0] === "$") throwOnMutation(); - const signals = proxyToSignals.get(objToProxy.get(target)); - const result = Reflect.deleteProperty(target, key); - if (signals && signals.has(key)) signals.get(key).value = undefined; - objToIterable.has(target) && objToIterable.get(target).value++; - // Emit deletion patch - const receiverProxy = objToProxy.get(target); - const meta = receiverProxy && proxyMeta.get(receiverProxy); - if (meta) { - queuePatch({ - root: meta.root, - path: buildPath(receiverProxy, key), - op: "remove", - }); - } - return result; - }, - ownKeys(target: object): (string | symbol)[] { - if (!objToIterable.has(target)) objToIterable.set(target, signal(0)); - (objToIterable as any)._ = objToIterable.get(target).get(); - return Reflect.ownKeys(target); - }, + }, + deleteProperty(target: object, key: string): boolean { + if (key[0] === "$") throwOnMutation(); + const signals = proxyToSignals.get(objToProxy.get(target)); + const result = Reflect.deleteProperty(target, key); + if (signals && signals.has(key)) signals.get(key).value = undefined; + objToIterable.has(target) && objToIterable.get(target).value++; + // Emit deletion patch + const receiverProxy = objToProxy.get(target); + const meta = receiverProxy && proxyMeta.get(receiverProxy); + if (meta) { + queuePatch({ + root: meta.root, + path: buildPath(receiverProxy, key), + op: "remove", + }); + } + return result; + }, + ownKeys(target: object): (string | symbol)[] { + if (!objToIterable.has(target)) objToIterable.set(target, signal(0)); + (objToIterable as any)._ = objToIterable.get(target).get(); + return Reflect.ownKeys(target); + }, }; // Array `$` meta proxy handlers (index signals only) const arrayHandlers = { - get: get(true), - set: throwOnMutation, - deleteProperty: throwOnMutation, + get: get(true), + set: throwOnMutation, + deleteProperty: throwOnMutation, }; const wellKnownSymbols = new Set( - Object.getOwnPropertyNames(Symbol) - .map((key) => Symbol[key as WellKnownSymbols]) - .filter((value) => typeof value === "symbol") + Object.getOwnPropertyNames(Symbol) + .map((key) => Symbol[key as WellKnownSymbols]) + .filter((value) => typeof value === "symbol") ); // Supported constructors (Map intentionally excluded for now) const supported = new Set([Object, Array, Set]); const shouldProxy = (val: any): boolean => { - if (typeof val !== "object" || val === null) return false; - return supported.has(val.constructor) && !ignore.has(val); + if (typeof val !== "object" || val === null) return false; + return supported.has(val.constructor) && !ignore.has(val); }; /** TYPES **/ // Structural deep reactive view of an input type. export type DeepSignal = T extends Function - ? T - : T extends { [shallowFlag]: true } - ? T - : T extends Array - ? DeepSignalArray - : T extends object - ? DeepSignalObject - : T; + ? T + : T extends { [shallowFlag]: true } + ? T + : T extends Array + ? DeepSignalArray + : T extends object + ? DeepSignalObject + : T; /** Recursive mapped type converting an object graph into its deepSignal proxy shape. */ export type DeepSignalObject = { - [P in keyof T & string as `$${P}`]?: T[P] extends Function - ? never - : ReturnType>; + [P in keyof T & string as `$${P}`]?: T[P] extends Function + ? never + : ReturnType>; } & { - [P in keyof T]: DeepSignal; + [P in keyof T]: DeepSignal; }; /** Extract element type from an array. */ type ArrayType = T extends Array ? I : T; /** DeepSignal-enhanced array type (numeric indices & `$` meta accessors). */ type DeepSignalArray = DeepArray> & { - [key: number]: DeepSignal>; - $?: { [key: number]: ReturnType>> }; - $length?: ReturnType>; + [key: number]: DeepSignal>; + $?: { [key: number]: ReturnType>> }; + $length?: ReturnType>; }; /** Marker utility type for objects passed through without deep proxying. */ @@ -810,24 +866,25 @@ type RevertDeepSignalObject = Pick>; type RevertDeepSignalArray = Omit; /** Inverse mapped type removing deepSignal wrapper affordances. */ -export type RevertDeepSignal = T extends Array - ? RevertDeepSignalArray - : T extends object - ? RevertDeepSignalObject - : T; +export type RevertDeepSignal = + T extends Array + ? RevertDeepSignalArray + : T extends object + ? RevertDeepSignalObject + : T; /** Subset of ECMAScript well-known symbols we explicitly pass through without proxy wrapping. */ type WellKnownSymbols = - | "asyncIterator" - | "hasInstance" - | "isConcatSpreadable" - | "iterator" - | "match" - | "matchAll" - | "replace" - | "search" - | "species" - | "split" - | "toPrimitive" - | "toStringTag" - | "unscopables"; + | "asyncIterator" + | "hasInstance" + | "isConcatSpreadable" + | "iterator" + | "match" + | "matchAll" + | "replace" + | "search" + | "species" + | "split" + | "toPrimitive" + | "toStringTag" + | "unscopables"; diff --git a/sdk/js/alien-deepsignals/src/test/watchPatches.test.ts b/sdk/js/alien-deepsignals/src/test/watchPatches.test.ts index 8d2d067..e15603c 100644 --- a/sdk/js/alien-deepsignals/src/test/watchPatches.test.ts +++ b/sdk/js/alien-deepsignals/src/test/watchPatches.test.ts @@ -1,357 +1,424 @@ import { describe, it, expect } from "vitest"; import { - deepSignal, - setSetEntrySyntheticId, - addWithId, - DeepPatch, + deepSignal, + setSetEntrySyntheticId, + addWithId, + DeepPatch, } from "../deepSignal"; import { watch, observe } from "../watch"; describe("watch (patch mode)", () => { - it("emits set patches with correct paths and batching", async () => { - const state = deepSignal({ a: { b: 1 }, arr: [1, { x: 2 }] }); - const received: DeepPatch[][] = []; - const { stopListening: stop } = watch(state, ({ patches }) => { - received.push(patches); + it("emits set patches with correct paths and batching", async () => { + const state = deepSignal({ a: { b: 1 }, arr: [1, { x: 2 }] }); + const received: DeepPatch[][] = []; + const { stopListening: stop } = watch(state, ({ patches }) => { + received.push(patches); + }); + state.a.b = 2; + (state.arr[1] as any).x = 3; + state.arr.push(5); + await Promise.resolve(); + expect(received.length).toBe(1); + const batch = received[0]; + const paths = batch.map((p) => p.path.join(".")).sort(); + expect(paths).toContain("a.b"); + expect(paths).toContain("arr.1.x"); + expect(paths).toContain("arr.2"); + const addOps = batch.filter((p) => p.op === "add").length; + expect(addOps).toBe(batch.length); + stop(); }); - state.a.b = 2; - (state.arr[1] as any).x = 3; - state.arr.push(5); - await Promise.resolve(); - expect(received.length).toBe(1); - const batch = received[0]; - const paths = batch.map((p) => p.path.join(".")).sort(); - expect(paths).toContain("a.b"); - expect(paths).toContain("arr.1.x"); - expect(paths).toContain("arr.2"); - const addOps = batch.filter((p) => p.op === "add").length; - expect(addOps).toBe(batch.length); - stop(); - }); - it("emits delete patches without value", async () => { - const state = deepSignal<{ a: { b?: number }; c?: number }>({ - a: { b: 1 }, - c: 2, + it("emits delete patches without value", async () => { + const state = deepSignal<{ a: { b?: number }; c?: number }>({ + a: { b: 1 }, + c: 2, + }); + const out: DeepPatch[][] = []; + const { stopListening: stop } = watch(state, ({ patches }) => + out.push(patches) + ); + delete state.a.b; + delete state.c; + await Promise.resolve(); + expect(out.length).toBe(1); + const [batch] = out; + const deletePatches = batch.filter((p) => p.op === "remove"); + const delPaths = deletePatches.map((p) => p.path.join(".")).sort(); + expect(delPaths).toEqual(["a.b", "c"]); + deletePatches.forEach((p: any) => expect(p.value).toBeUndefined()); + stop(); }); - const out: DeepPatch[][] = []; - const { stopListening: stop } = watch(state, ({ patches }) => - out.push(patches) - ); - delete state.a.b; - delete state.c; - await Promise.resolve(); - expect(out.length).toBe(1); - const [batch] = out; - const deletePatches = batch.filter((p) => p.op === "remove"); - const delPaths = deletePatches.map((p) => p.path.join(".")).sort(); - expect(delPaths).toEqual(["a.b", "c"]); - deletePatches.forEach((p: any) => expect(p.value).toBeUndefined()); - stop(); - }); - it("observe patch mode mirrors watch patch mode", async () => { - const state = deepSignal({ a: 1 }); - const wp: DeepPatch[][] = []; - const ob: DeepPatch[][] = []; - const { stopListening: stop1 } = watch(state, ({ patches }) => - wp.push(patches) - ); - const { stopListening: stop2 } = observe(state, ({ patches }) => - ob.push(patches) - ); - state.a = 2; - await Promise.resolve(); - expect(wp.length).toBe(1); - expect(ob.length).toBe(1); - expect(wp[0][0].path.join(".")).toBe("a"); - stop1(); - stop2(); - }); + it("observe patch mode mirrors watch patch mode", async () => { + const state = deepSignal({ a: 1 }); + const wp: DeepPatch[][] = []; + const ob: DeepPatch[][] = []; + const { stopListening: stop1 } = watch(state, ({ patches }) => + wp.push(patches) + ); + const { stopListening: stop2 } = observe(state, ({ patches }) => + ob.push(patches) + ); + state.a = 2; + await Promise.resolve(); + expect(wp.length).toBe(1); + expect(ob.length).toBe(1); + expect(wp[0][0].path.join(".")).toBe("a"); + stop1(); + stop2(); + }); - it("filters out patches from other roots", async () => { - const a = deepSignal({ x: 1 }); - const b = deepSignal({ y: 2 }); - const out: DeepPatch[][] = []; - const { stopListening: stop } = watch(a, ({ patches }) => - out.push(patches) - ); - b.y = 3; - a.x = 2; - await Promise.resolve(); - expect(out.length).toBe(1); - expect(out[0][0].path.join(".")).toBe("x"); - stop(); - }); + it("filters out patches from other roots", async () => { + const a = deepSignal({ x: 1 }); + const b = deepSignal({ y: 2 }); + const out: DeepPatch[][] = []; + const { stopListening: stop } = watch(a, ({ patches }) => + out.push(patches) + ); + b.y = 3; + a.x = 2; + await Promise.resolve(); + expect(out.length).toBe(1); + expect(out[0][0].path.join(".")).toBe("x"); + stop(); + }); - it("emits patches for Set structural mutations (add/delete)", async () => { - const state = deepSignal<{ s: Set }>({ s: new Set([1, 2]) }); - const batches: DeepPatch[][] = []; - const { stopListening: stop } = watch(state, ({ patches }) => - batches.push(patches) - ); - state.s.add(3); - state.s.delete(1); - await Promise.resolve(); - expect(batches.length >= 1).toBe(true); - const allPaths = batches.flatMap((b) => b.map((p) => p.path.join("."))); - expect(allPaths.some((p) => p.startsWith("s."))).toBe(true); - stop(); - }); + it("emits patches for Set structural mutations (add/delete)", async () => { + const state = deepSignal<{ s: Set }>({ s: new Set([1, 2]) }); + const batches: DeepPatch[][] = []; + const { stopListening: stop } = watch(state, ({ patches }) => + batches.push(patches) + ); + state.s.add(3); + state.s.delete(1); + await Promise.resolve(); + expect(batches.length >= 1).toBe(true); + const allPaths = batches.flatMap((b) => b.map((p) => p.path.join("."))); + expect(allPaths.some((p) => p.startsWith("s."))).toBe(true); + stop(); + }); - it("emits patches for nested objects added after initialization", async () => { - const state = deepSignal<{ root: any }>({ root: {} }); - const patches: DeepPatch[][] = []; - const { stopListening: stop } = watch(state, ({ patches: batch }) => - patches.push(batch) - ); - state.root.child = { level: { value: 1 } }; - state.root.child.level.value = 2; - await Promise.resolve(); - const flat = patches.flat().map((p) => p.path.join(".")); - expect(flat).toContain("root.child"); - expect(flat).toContain("root.child.level.value"); - stop(); - }); + it("emits patches for nested objects added after initialization", async () => { + const state = deepSignal<{ root: any }>({ root: {} }); + const patches: DeepPatch[][] = []; + const { stopListening: stop } = watch(state, ({ patches: batch }) => + patches.push(batch) + ); + state.root.child = { level: { value: 1 }, l1: "val" }; + await Promise.resolve(); + const flat = patches.flat().map((p) => p.path.join(".")); + expect(flat).toContain("root.child"); + expect(flat).toContain("root.child.level.value"); + stop(); + }); - it("emits structural patches for sets of sets", async () => { - const innerA = new Set([{ id: "node1", x: 1 }]); - const s = new Set([innerA]); - const state = deepSignal<{ graph: Set }>({ graph: s }); - const batches: DeepPatch[][] = []; - const { stopListening: stop } = watch(state, ({ patches }) => - batches.push(patches) - ); - const innerB = new Set([{ id: "node2", x: 5 }]); - state.graph.add(innerB); - ([...innerA][0] as any).x = 2; - await Promise.resolve(); - const pathStrings = batches.flat().map((p) => p.path.join(".")); - expect(pathStrings.some((p) => p.startsWith("graph."))).toBe(true); - stop(); - }); + it("emits patches for deeply nested arrays and objects", async () => { + const state = deepSignal<{ data: any }>({ data: null }); + const patches: DeepPatch[][] = []; + const { stopListening: stop } = watch(state, ({ patches: batch }) => + patches.push(batch) + ); + state.data = { + users: [ + { + id: 1, + profile: { name: "Alice", settings: { theme: "dark" } }, + }, + { + id: 2, + profile: { name: "Bob", settings: { theme: "light" } }, + }, + ], + meta: { count: 2, active: true }, + }; + await Promise.resolve(); - it("tracks deep nested object mutation inside a Set entry after iteration", async () => { - const rawEntry = { id: "n1", data: { val: 1 } }; - const st = deepSignal({ bag: new Set([rawEntry]) }); - const collected: DeepPatch[][] = []; - const { stopListening: stop } = watch(st, ({ patches }) => - collected.push(patches) - ); - let proxied: any; - for (const e of st.bag.values()) { - proxied = e; - e.data.val; - } - proxied.data.val = 2; - await Promise.resolve(); - const flat = collected.flat().map((p: DeepPatch) => p.path.join(".")); - expect(flat.some((p: string) => p.endsWith("n1.data.val"))).toBe(true); - stop(); - }); + const flat = patches.flat().map((p) => p.path.join(".")); + // Check for root object + expect(flat).toContain("data"); + // Check for nested array + expect(flat).toContain("data.users"); + // Check for array elements + expect(flat).toContain("data.users.0"); + expect(flat).toContain("data.users.1"); + // Check for deeply nested properties + expect(flat).toContain("data.users.0.profile.settings.theme"); + expect(flat).toContain("data.users.1.profile.settings.theme"); + expect(flat).toContain("data.meta.count"); + expect(flat).toContain("data.meta.active"); + stop(); + }); - it("allows custom synthetic id for Set entry", async () => { - const node = { name: "x" }; - const state = deepSignal({ s: new Set() }); - const collected2: DeepPatch[][] = []; - const { stopListening: stop } = watch(state, ({ patches }) => - collected2.push(patches) - ); - addWithId(state.s as any, node, "custom123"); - await Promise.resolve(); - const flat = collected2.flat().map((p: DeepPatch) => p.path.join(".")); - expect(flat.some((p: string) => p === "s.custom123")).toBe(true); - stop(); - }); + it("emits patches for Set with nested objects added as one operation", async () => { + const state = deepSignal<{ container: any }>({ container: {} }); + const patches: DeepPatch[][] = []; + const { stopListening: stop } = watch(state, ({ patches: batch }) => + patches.push(batch) + ); + state.container.items = new Set([ + { id: "a", data: { nested: { value: 1 } } }, + { id: "b", data: { nested: { value: 2 } } }, + ]); + await Promise.resolve(); - describe("Set", () => { - it("emits single structural patch on Set.clear()", async () => { - const st = deepSignal({ s: new Set() }); - addWithId(st.s as any, { id: "a", x: 1 }, "a"); - addWithId(st.s as any, { id: "b", x: 2 }, "b"); - const batches: DeepPatch[][] = []; - const { stopListening: stop } = watch(st, ({ patches }) => - batches.push(patches) - ); - st.s.clear(); - await Promise.resolve(); - const all = batches.flat().map((p) => p.path.join(".")); - expect(all).toEqual(["s"]); - stop(); - }); - it("emits delete patch for object entry", async () => { - const st = deepSignal({ s: new Set() }); - const obj = { id: "n1", x: 1 }; - const patches: DeepPatch[][] = []; - const { stopListening: stop } = watch(st, ({ patches: batch }) => - patches.push(batch) - ); - st.s.add(obj); - st.s.delete(obj); - await Promise.resolve(); - const all = patches - .flat() - .filter((p) => p.op === "remove") - .map((p) => p.path.join(".")); - expect(all).toContain("s.n1"); - stop(); - }); - it("does not emit patch for duplicate add", async () => { - const st = deepSignal({ s: new Set([1]) }); - const patches: DeepPatch[][] = []; - const { stopListening: stop } = watch(st, ({ patches: batch }) => - patches.push(batch) - ); - st.s.add(1); - await Promise.resolve(); - expect(patches.length).toBe(0); - stop(); - }); - it("does not emit patch deleting non-existent entry", async () => { - const st = deepSignal({ s: new Set([1]) }); - const patches: DeepPatch[][] = []; - const { stopListening: stop } = watch(st, ({ patches: batch }) => - patches.push(batch) - ); - st.s.delete(2); - await Promise.resolve(); - expect(patches.length).toBe(0); - stop(); - }); - it("addWithId primitive returns primitive and emits patch with primitive key", async () => { - const st = deepSignal({ s: new Set() }); - const patches: DeepPatch[][] = []; - const { stopListening: stop } = watch(st, ({ patches: batch }) => - patches.push(batch) - ); - const ret = addWithId(st.s as any, 5, "ignored"); - expect(ret).toBe(5); - await Promise.resolve(); - const paths = patches.flat().map((p) => p.path.join(".")); - expect(paths).toContain("s.5"); - stop(); - }); - it("setSetEntrySyntheticId applies custom id without helper", async () => { - const st = deepSignal({ s: new Set() }); - const obj = { name: "x" }; - setSetEntrySyntheticId(obj, "customX"); - const patches: DeepPatch[][] = []; - const { stopListening: stop } = watch(st, ({ patches: batch }) => - patches.push(batch) - ); - st.s.add(obj); - await Promise.resolve(); - const paths = patches.flat().map((p) => p.path.join(".")); - expect(paths).toContain("s.customX"); - stop(); - }); - it("values/entries/forEach proxy nested mutation", async () => { - const st = deepSignal({ s: new Set() }); - const entry = addWithId(st.s as any, { id: "e1", inner: { v: 1 } }, "e1"); - const batches: DeepPatch[][] = []; - const { stopListening: stop } = watch(st, ({ patches }) => - batches.push(patches) - ); - for (const e of st.s.values()) { - e.inner.v; - } - entry.inner.v = 2; - await Promise.resolve(); - const vPaths = batches.flat().map((p) => p.path.join(".")); - expect(vPaths.some((p) => p.endsWith("e1.inner.v"))).toBe(true); - stop(); + const flat = patches.flat().map((p) => p.path.join(".")); + + // Check for the Set itself + expect(flat).toContain("container.items"); + // Check for Set entries (using their id as synthetic key) + expect(flat.some((p) => p.startsWith("container.items.a"))).toBe(true); + expect(flat.some((p) => p.startsWith("container.items.b"))).toBe(true); + // Check for deeply nested properties within Set entries + expect(flat).toContain("container.items.a.data.nested.value"); + expect(flat).toContain("container.items.b.data.nested.value"); + stop(); }); - it("raw reference mutation produces no deep patch while proxied does", async () => { - const raw = { id: "id1", data: { x: 1 } }; - const st = deepSignal({ s: new Set([raw]) }); - const batches: DeepPatch[][] = []; - const { stopListening: stop } = watch(st, ({ patches }) => - batches.push(patches) - ); - raw.data.x = 2; - await Promise.resolve(); - const afterRaw = batches.flat().map((p) => p.path.join(".")); - expect(afterRaw.some((p) => p.endsWith("id1.data.x"))).toBe(false); - let proxied: any; - for (const e of st.s.values()) proxied = e; - proxied.data.x = 3; - await Promise.resolve(); - const afterProxied = batches.flat().map((p) => p.path.join(".")); - expect(afterProxied.some((p) => p.endsWith("id1.data.x"))).toBe(true); - stop(); + + it("emits structural patches for sets of sets", async () => { + const innerA = new Set([{ id: "node1", x: 1 }]); + const s = new Set([innerA]); + const state = deepSignal<{ graph: Set }>({ graph: s }); + const batches: DeepPatch[][] = []; + const { stopListening: stop } = watch(state, ({ patches }) => + batches.push(patches) + ); + const innerB = new Set([{ id: "node2", x: 5 }]); + state.graph.add(innerB); + ([...innerA][0] as any).x = 2; + await Promise.resolve(); + const pathStrings = batches.flat().map((p) => p.path.join(".")); + expect(pathStrings.some((p) => p.startsWith("graph."))).toBe(true); + stop(); }); - it("synthetic id collision assigns unique blank node id", async () => { - const st = deepSignal({ s: new Set() }); - const a1 = { id: "dup", v: 1 }; - const a2 = { id: "dup", v: 2 }; - const patches: DeepPatch[][] = []; - const { stopListening: stop } = watch(st, ({ patches: batch }) => - patches.push(batch) - ); - st.s.add(a1); - st.s.add(a2); - await Promise.resolve(); - const keys = patches - .flat() - .filter((p) => p.op === "add") - .map((p) => p.path.slice(-1)[0]); - expect(new Set(keys).size).toBe(2); - stop(); + + it("tracks deep nested object mutation inside a Set entry after iteration", async () => { + const rawEntry = { id: "n1", data: { val: 1 } }; + const st = deepSignal({ bag: new Set([rawEntry]) }); + const collected: DeepPatch[][] = []; + const { stopListening: stop } = watch(st, ({ patches }) => + collected.push(patches) + ); + let proxied: any; + for (const e of st.bag.values()) { + proxied = e; + e.data.val; + } + proxied.data.val = 2; + await Promise.resolve(); + const flat = collected.flat().map((p: DeepPatch) => p.path.join(".")); + expect(flat.some((p: string) => p.endsWith("n1.data.val"))).toBe(true); + stop(); }); - it("allows Array.from() and spread on Set without brand errors and tracks nested mutation", async () => { - const st = deepSignal({ - s: new Set([{ id: "eIter", inner: { v: 1 } }]), - }); - // Regression: previously 'values method called on incompatible Proxy' was thrown here. - const arr = Array.from(st.s); - expect(arr.length).toBe(1); - expect(arr[0].inner.v).toBe(1); - const spread = [...st.s]; - expect(spread[0].inner.v).toBe(1); - const batches: DeepPatch[][] = []; - const { stopListening: stop } = watch(st, ({ patches }) => - batches.push(patches) - ); - spread[0].inner.v = 2; // mutate nested field of iterated (proxied) entry - await Promise.resolve(); - const flat = batches.flat().map((p) => p.path.join(".")); - expect(flat.some((p) => p.endsWith("eIter.inner.v"))).toBe(true); - stop(); + it("allows custom synthetic id for Set entry", async () => { + const node = { name: "x" }; + const state = deepSignal({ s: new Set() }); + const collected2: DeepPatch[][] = []; + const { stopListening: stop } = watch(state, ({ patches }) => + collected2.push(patches) + ); + addWithId(state.s as any, node, "custom123"); + await Promise.resolve(); + const flat = collected2.flat().map((p: DeepPatch) => p.path.join(".")); + expect(flat.some((p: string) => p === "s.custom123")).toBe(true); + stop(); }); - }); - describe("Arrays & mixed batch", () => { - it("emits patches for splice/unshift/shift in single batch", async () => { - const st = deepSignal({ arr: [1, 2, 3] }); - const batches: DeepPatch[][] = []; - const { stopListening: stop } = watch(st, ({ patches }) => - batches.push(patches) - ); - st.arr.splice(1, 1, 99, 100); - st.arr.unshift(0); - st.arr.shift(); - await Promise.resolve(); - const paths = batches.flat().map((p) => p.path.join(".")); - expect(paths.some((p) => p.startsWith("arr."))).toBe(true); - stop(); + describe("Set", () => { + it("emits single structural patch on Set.clear()", async () => { + const st = deepSignal({ s: new Set() }); + addWithId(st.s as any, { id: "a", x: 1 }, "a"); + addWithId(st.s as any, { id: "b", x: 2 }, "b"); + const batches: DeepPatch[][] = []; + const { stopListening: stop } = watch(st, ({ patches }) => + batches.push(patches) + ); + st.s.clear(); + await Promise.resolve(); + const all = batches.flat().map((p) => p.path.join(".")); + expect(all).toEqual(["s"]); + stop(); + }); + it("emits delete patch for object entry", async () => { + const st = deepSignal({ s: new Set() }); + const obj = { id: "n1", x: 1 }; + const patches: DeepPatch[][] = []; + const { stopListening: stop } = watch(st, ({ patches: batch }) => + patches.push(batch) + ); + st.s.add(obj); + st.s.delete(obj); + await Promise.resolve(); + const all = patches + .flat() + .filter((p) => p.op === "remove") + .map((p) => p.path.join(".")); + expect(all).toContain("s.n1"); + stop(); + }); + it("does not emit patch for duplicate add", async () => { + const st = deepSignal({ s: new Set([1]) }); + const patches: DeepPatch[][] = []; + const { stopListening: stop } = watch(st, ({ patches: batch }) => + patches.push(batch) + ); + st.s.add(1); + await Promise.resolve(); + expect(patches.length).toBe(0); + stop(); + }); + it("does not emit patch deleting non-existent entry", async () => { + const st = deepSignal({ s: new Set([1]) }); + const patches: DeepPatch[][] = []; + const { stopListening: stop } = watch(st, ({ patches: batch }) => + patches.push(batch) + ); + st.s.delete(2); + await Promise.resolve(); + expect(patches.length).toBe(0); + stop(); + }); + it("addWithId primitive returns primitive and emits patch with primitive key", async () => { + const st = deepSignal({ s: new Set() }); + const patches: DeepPatch[][] = []; + const { stopListening: stop } = watch(st, ({ patches: batch }) => + patches.push(batch) + ); + const ret = addWithId(st.s as any, 5, "ignored"); + expect(ret).toBe(5); + await Promise.resolve(); + const paths = patches.flat().map((p) => p.path.join(".")); + expect(paths).toContain("s.5"); + stop(); + }); + it("setSetEntrySyntheticId applies custom id without helper", async () => { + const st = deepSignal({ s: new Set() }); + const obj = { name: "x" }; + setSetEntrySyntheticId(obj, "customX"); + const patches: DeepPatch[][] = []; + const { stopListening: stop } = watch(st, ({ patches: batch }) => + patches.push(batch) + ); + st.s.add(obj); + await Promise.resolve(); + const paths = patches.flat().map((p) => p.path.join(".")); + expect(paths).toContain("s.customX"); + stop(); + }); + it("values/entries/forEach proxy nested mutation", async () => { + const st = deepSignal({ s: new Set() }); + const entry = addWithId( + st.s as any, + { id: "e1", inner: { v: 1 } }, + "e1" + ); + const batches: DeepPatch[][] = []; + const { stopListening: stop } = watch(st, ({ patches }) => + batches.push(patches) + ); + for (const e of st.s.values()) { + e.inner.v; + } + entry.inner.v = 2; + await Promise.resolve(); + const vPaths = batches.flat().map((p) => p.path.join(".")); + expect(vPaths.some((p) => p.endsWith("e1.inner.v"))).toBe(true); + stop(); + }); + it("raw reference mutation produces no deep patch while proxied does", async () => { + const raw = { id: "id1", data: { x: 1 } }; + const st = deepSignal({ s: new Set([raw]) }); + const batches: DeepPatch[][] = []; + const { stopListening: stop } = watch(st, ({ patches }) => + batches.push(patches) + ); + raw.data.x = 2; + await Promise.resolve(); + const afterRaw = batches.flat().map((p) => p.path.join(".")); + expect(afterRaw.some((p) => p.endsWith("id1.data.x"))).toBe(false); + let proxied: any; + for (const e of st.s.values()) proxied = e; + proxied.data.x = 3; + await Promise.resolve(); + const afterProxied = batches.flat().map((p) => p.path.join(".")); + expect(afterProxied.some((p) => p.endsWith("id1.data.x"))).toBe( + true + ); + stop(); + }); + it("synthetic id collision assigns unique blank node id", async () => { + const st = deepSignal({ s: new Set() }); + const a1 = { id: "dup", v: 1 }; + const a2 = { id: "dup", v: 2 }; + const patches: DeepPatch[][] = []; + const { stopListening: stop } = watch(st, ({ patches: batch }) => + patches.push(batch) + ); + st.s.add(a1); + st.s.add(a2); + await Promise.resolve(); + const keys = patches + .flat() + .filter((p) => p.op === "add") + .map((p) => p.path.slice(-1)[0]); + expect(new Set(keys).size).toBe(2); + stop(); + }); + + it("allows Array.from() and spread on Set without brand errors and tracks nested mutation", async () => { + const st = deepSignal({ + s: new Set([{ id: "eIter", inner: { v: 1 } }]), + }); + // Regression: previously 'values method called on incompatible Proxy' was thrown here. + const arr = Array.from(st.s); + expect(arr.length).toBe(1); + expect(arr[0].inner.v).toBe(1); + const spread = [...st.s]; + expect(spread[0].inner.v).toBe(1); + const batches: DeepPatch[][] = []; + const { stopListening: stop } = watch(st, ({ patches }) => + batches.push(patches) + ); + spread[0].inner.v = 2; // mutate nested field of iterated (proxied) entry + await Promise.resolve(); + const flat = batches.flat().map((p) => p.path.join(".")); + expect(flat.some((p) => p.endsWith("eIter.inner.v"))).toBe(true); + stop(); + }); }); - it("mixed object/array/Set mutations batch together", async () => { - const st = deepSignal({ o: { a: 1 }, arr: [1], s: new Set() }); - const batches: DeepPatch[][] = []; - const { stopListening: stop } = watch(st, ({ patches }) => - batches.push(patches) - ); - st.o.a = 2; - st.arr.push(2); - addWithId(st.s as any, { id: "z", v: 1 }, "z"); - await Promise.resolve(); - expect(batches.length).toBe(1); - const paths = batches[0].map((p) => p.path.join(".")); - expect(paths).toContain("o.a"); - expect(paths).toContain("arr.1"); - expect(paths.some((p) => p.startsWith("s."))).toBe(true); - stop(); + + describe("Arrays & mixed batch", () => { + it("emits patches for splice/unshift/shift in single batch", async () => { + const st = deepSignal({ arr: [1, 2, 3] }); + const batches: DeepPatch[][] = []; + const { stopListening: stop } = watch(st, ({ patches }) => + batches.push(patches) + ); + st.arr.splice(1, 1, 99, 100); + st.arr.unshift(0); + st.arr.shift(); + await Promise.resolve(); + const paths = batches.flat().map((p) => p.path.join(".")); + expect(paths.some((p) => p.startsWith("arr."))).toBe(true); + stop(); + }); + it("mixed object/array/Set mutations batch together", async () => { + const st = deepSignal({ o: { a: 1 }, arr: [1], s: new Set() }); + const batches: DeepPatch[][] = []; + const { stopListening: stop } = watch(st, ({ patches }) => + batches.push(patches) + ); + st.o.a = 2; + st.arr.push(2); + addWithId(st.s as any, { id: "z", v: 1 }, "z"); + await Promise.resolve(); + expect(batches.length).toBe(1); + const paths = batches[0].map((p) => p.path.join(".")); + expect(paths).toContain("o.a"); + expect(paths).toContain("arr.1"); + expect(paths.some((p) => p.startsWith("s."))).toBe(true); + stop(); + }); }); - }); }); From 1b21fd2b7c7033ff86743a2acb727ba01a1ffca1 Mon Sep 17 00:00:00 2001 From: Laurin Weger Date: Sun, 19 Oct 2025 12:03:55 +0200 Subject: [PATCH 2/8] deep signals: modify primitive set patch logic --- sdk/js/alien-deepsignals/src/deepSignal.ts | 32 ++++-- .../src/test/watchPatches.test.ts | 99 ++++++++++++++++++- 2 files changed, 121 insertions(+), 10 deletions(-) diff --git a/sdk/js/alien-deepsignals/src/deepSignal.ts b/sdk/js/alien-deepsignals/src/deepSignal.ts index 48490bc..cd71dba 100644 --- a/sdk/js/alien-deepsignals/src/deepSignal.ts +++ b/sdk/js/alien-deepsignals/src/deepSignal.ts @@ -490,8 +490,9 @@ function getFromSet( objToProxy.set(entryVal, childProxy); entryVal = childProxy; } - // Set entry add: emit object vs literal variant. + // Set entry add: emit object vs primitive variant. if (entryVal && typeof entryVal === "object") { + // Object entry: path includes synthetic id queuePatch({ root: metaNow.root, path: [...containerPath, synthetic], @@ -499,21 +500,36 @@ function getFromSet( type: "object", }); } else { + // Primitive entry: path is just the Set, value contains the primitive queuePatch({ root: metaNow.root, - path: [...containerPath, synthetic], + path: containerPath, op: "add", - value: entryVal, + type: "set", + value: [entryVal], }); } } else if (key === "delete") { const entry = args[0]; const synthetic = getSetEntryKey(entry); - queuePatch({ - root: metaNow.root, - path: [...containerPath, synthetic], - op: "remove", - }); + // Check if entry is primitive or object + if (entry && typeof entry === "object") { + // Object entry: path includes synthetic id + queuePatch({ + root: metaNow.root, + path: [...containerPath, synthetic], + op: "remove", + }); + } else { + // Primitive entry: path is just the Set, value contains the primitive + queuePatch({ + root: metaNow.root, + path: containerPath, + op: "remove", + type: "set", + value: entry, + }); + } } else if (key === "clear") { // Structural clear: remove prior entry-level patches for this Set this tick. if (pendingPatches) { diff --git a/sdk/js/alien-deepsignals/src/test/watchPatches.test.ts b/sdk/js/alien-deepsignals/src/test/watchPatches.test.ts index e15603c..4a30469 100644 --- a/sdk/js/alien-deepsignals/src/test/watchPatches.test.ts +++ b/sdk/js/alien-deepsignals/src/test/watchPatches.test.ts @@ -95,7 +95,16 @@ describe("watch (patch mode)", () => { await Promise.resolve(); expect(batches.length >= 1).toBe(true); const allPaths = batches.flatMap((b) => b.map((p) => p.path.join("."))); - expect(allPaths.some((p) => p.startsWith("s."))).toBe(true); + // For primitives, the path should be just "s" (the Set itself) + expect(allPaths.every((p) => p === "s")).toBe(true); + // Check the values + const patches = batches.flat(); + const addPatches = patches.filter((p) => p.op === "add"); + const deletePatches = patches.filter((p) => p.op === "remove"); + expect(addPatches.length).toBe(1); + expect(deletePatches.length).toBe(1); + expect((addPatches[0] as any).value[0]).toBe(3); + expect((deletePatches[0] as any).value).toBe(1); stop(); }); @@ -226,6 +235,89 @@ describe("watch (patch mode)", () => { }); describe("Set", () => { + it("emits patches for primitive adds", async () => { + const st = deepSignal({ s: new Set() }); + const batches: DeepPatch[][] = []; + const { stopListening: stop } = watch(st, ({ patches }) => + batches.push(patches) + ); + st.s.add(true); + st.s.add(2); + st.s.add("3"); + await Promise.resolve(); + + expect(batches.length).toBe(1); + const patches = batches[0]; + expect(patches.length).toBe(3); + + // All patches should have the same path (the Set itself) + patches.forEach((p) => { + expect(p.path.join(".")).toBe("s"); + expect(p.op).toBe("add"); + expect((p as any).type).toBe("set"); + }); + + // Check that values are in the value field, not in path + const values = patches.map((p: any) => p.value[0]); + expect(values).toContain(true); + expect(values).toContain(2); + expect(values).toContain("3"); + stop(); + }); + it("emits patches for primitive deletes", async () => { + const st = deepSignal({ s: new Set([true, 2, "3"]) }); + const batches: DeepPatch[][] = []; + const { stopListening: stop } = watch(st, ({ patches }) => + batches.push(patches) + ); + st.s.delete(true); + st.s.delete(2); + await Promise.resolve(); + + expect(batches.length).toBe(1); + const patches = batches[0]; + expect(patches.length).toBe(2); + + // All patches should have the same path (the Set itself) + patches.forEach((p) => { + expect(p.path.join(".")).toBe("s"); + expect(p.op).toBe("remove"); + expect((p as any).type).toBe("set"); + }); + + // Check that values are in the value field + const values = patches.map((p: any) => p.value); + expect(values).toContain(true); + expect(values).toContain(2); + stop(); + }); + it("does not emit patches for non-existent primitives", async () => { + const st = deepSignal({ s: new Set([1, 2]) }); + const batches: DeepPatch[][] = []; + const { stopListening: stop } = watch(st, ({ patches }) => + batches.push(patches) + ); + st.s.delete("nonexistent"); + st.s.delete(999); + await Promise.resolve(); + + expect(batches.length).toBe(0); + stop(); + }); + it("does not emit patches for already added primitive", async () => { + const st = deepSignal({ s: new Set([1, "test", true]) }); + const batches: DeepPatch[][] = []; + const { stopListening: stop } = watch(st, ({ patches }) => + batches.push(patches) + ); + st.s.add(1); + st.s.add("test"); + st.s.add(true); + await Promise.resolve(); + + expect(batches.length).toBe(0); + stop(); + }); it("emits single structural patch on Set.clear()", async () => { const st = deepSignal({ s: new Set() }); addWithId(st.s as any, { id: "a", x: 1 }, "a"); @@ -288,8 +380,11 @@ describe("watch (patch mode)", () => { const ret = addWithId(st.s as any, 5, "ignored"); expect(ret).toBe(5); await Promise.resolve(); + // For primitives, path should be just "s" and value should be in the value field const paths = patches.flat().map((p) => p.path.join(".")); - expect(paths).toContain("s.5"); + expect(paths).toContain("s"); + const values = patches.flat().map((p: any) => p.value?.[0]); + expect(values).toContain(5); stop(); }); it("setSetEntrySyntheticId applies custom id without helper", async () => { From 8f6586b71e380182f97fcd40b56441a12f333317 Mon Sep 17 00:00:00 2001 From: Laurin Weger Date: Sun, 19 Oct 2025 13:28:57 +0200 Subject: [PATCH 3/8] deep signals uses @id semantics, support for custom @id generator fn --- sdk/js/alien-deepsignals/src/deepSignal.ts | 166 +++++++-- .../src/test/deepSignalOptions.test.ts | 340 ++++++++++++++++++ .../src/test/watchPatches.test.ts | 22 +- 3 files changed, 497 insertions(+), 31 deletions(-) create mode 100644 sdk/js/alien-deepsignals/src/test/deepSignalOptions.test.ts diff --git a/sdk/js/alien-deepsignals/src/deepSignal.ts b/sdk/js/alien-deepsignals/src/deepSignal.ts index cd71dba..792cbb5 100644 --- a/sdk/js/alien-deepsignals/src/deepSignal.ts +++ b/sdk/js/alien-deepsignals/src/deepSignal.ts @@ -56,6 +56,14 @@ export interface DeepLiteralAddPatch { /** Callback signature for subscribeDeepMutations. */ export type DeepPatchSubscriber = (patches: DeepPatch[]) => void; +/** Options for configuring deepSignal behavior. */ +export interface DeepSignalOptions { + /** Custom function to generate synthetic IDs for objects without @id. */ + idGenerator?: () => string | number; + /** If true, add @id property to all objects in the tree. */ + addIdToObjects?: boolean; +} + /** Minimal per-proxy metadata for path reconstruction. */ interface ProxyMeta { /** Parent proxy in the object graph (undefined for root). */ @@ -64,10 +72,14 @@ interface ProxyMeta { key?: string | number; /** Stable root id symbol shared by the entire deepSignal tree. */ root: symbol; + /** Options inherited from root. */ + options?: DeepSignalOptions; } // Proxy -> metadata const proxyMeta = new WeakMap(); +// Root symbol -> options +const rootOptions = new Map(); // Root symbol -> subscribers const mutationSubscribers = new Map>(); // Pending patches grouped per root (flushed once per microtask) @@ -122,7 +134,8 @@ function queuePatch(patch: DeepPatch) { function queueDeepPatches( val: any, rootId: symbol, - basePath: (string | number)[] + basePath: (string | number)[], + options?: DeepSignalOptions ) { if (!val || typeof val !== "object") { // Emit patch for primitive leaf @@ -135,6 +148,28 @@ function queueDeepPatches( return; } + // Add @id to object if options specify it + if ( + options?.addIdToObjects && + val.constructor === Object && + !("@id" in val) + ) { + let syntheticId: string | number; + if (options.idGenerator) { + syntheticId = options.idGenerator(); + } else { + syntheticId = assignBlankNodeId(val); + } + + // Define @id on the raw object before proxying + Object.defineProperty(val, "@id", { + value: syntheticId, + writable: false, + enumerable: true, + configurable: false, + }); + } + // Emit patch for the object/array/Set itself queuePatch({ root: rootId, @@ -143,20 +178,33 @@ function queueDeepPatches( type: "object", }); + // Emit patch for @id if it exists + if ("@id" in val) { + queuePatch({ + root: rootId, + path: [...basePath, "@id"], + op: "add", + value: (val as any)["@id"], + }); + } + // Recursively process nested properties if (Array.isArray(val)) { for (let i = 0; i < val.length; i++) { - queueDeepPatches(val[i], rootId, [...basePath, i]); + queueDeepPatches(val[i], rootId, [...basePath, i], options); } } else if (val instanceof Set) { for (const entry of val) { const key = getSetEntryKey(entry); - queueDeepPatches(entry, rootId, [...basePath, key]); + queueDeepPatches(entry, rootId, [...basePath, key], options); } } else if (val.constructor === Object) { for (const key in val) { - if (Object.prototype.hasOwnProperty.call(val, key)) { - queueDeepPatches(val[key], rootId, [...basePath, key]); + if ( + Object.prototype.hasOwnProperty.call(val, key) && + key !== "@id" + ) { + queueDeepPatches(val[key], rootId, [...basePath, key], options); } } } @@ -319,17 +367,21 @@ export function setSetEntrySyntheticId(obj: object, id: string | number) { } const getSetEntryKey = (val: any): string | number => { if (val && typeof val === "object") { + // First check for explicitly assigned synthetic ID if (setObjectIds.has(val)) return setObjectIds.get(val)!; - if ( - typeof (val as any).id === "string" || - typeof (val as any).id === "number" - ) - return (val as any).id; + // Then check for @id property (primary identifier) if ( typeof (val as any)["@id"] === "string" || typeof (val as any)["@id"] === "number" ) return (val as any)["@id"]; + // Then check for id property (backward compatibility) + if ( + typeof (val as any).id === "string" || + typeof (val as any).id === "number" + ) + return (val as any).id; + // Fall back to generating a blank node ID return assignBlankNodeId(val); } return val as any; @@ -360,16 +412,28 @@ export const isShallow = (source: any) => { }; /** Create (or reuse) a deep reactive proxy for an object / array / Set. */ -export const deepSignal = (obj: T): DeepSignal => { +export const deepSignal = ( + obj: T, + options?: DeepSignalOptions +): DeepSignal => { if (!shouldProxy(obj)) throw new Error("This object can't be observed."); if (!objToProxy.has(obj)) { // Create a unique root id symbol to identify this deep signal tree in patches. const rootId = Symbol("deepSignalRoot"); - const proxy = createProxy(obj, objectHandlers, rootId) as DeepSignal; + if (options) { + rootOptions.set(rootId, options); + } + const proxy = createProxy( + obj, + objectHandlers, + rootId, + options + ) as DeepSignal; const meta = proxyMeta.get(proxy)!; meta.parent = undefined; // root has no parent meta.key = undefined; // root not addressed by a key meta.root = rootId; // ensure root id stored (explicit) + meta.options = options; // store options in metadata // Pre-register an empty signals map so isDeepSignal() is true before any property access. if (!proxyToSignals.has(proxy)) proxyToSignals.set(proxy, new Map()); objToProxy.set(obj, proxy); @@ -403,7 +467,8 @@ export function shallow(obj: T): Shallow { const createProxy = ( target: object, handlers: ProxyHandler, - rootId?: symbol + rootId?: symbol, + options?: DeepSignalOptions ) => { const proxy = new Proxy(target, handlers); ignore.add(proxy); @@ -411,8 +476,10 @@ const createProxy = ( if (!proxyMeta.has(proxy)) { proxyMeta.set(proxy, { root: rootId || Symbol("deepSignalDetachedRoot"), + options: options || rootOptions.get(rootId!), }); } + return proxy; }; @@ -432,7 +499,12 @@ function getFromSet( !objToProxy.has(entry) ) { const synthetic = getSetEntryKey(entry); - const childProxy = createProxy(entry, objectHandlers, meta!.root); + const childProxy = createProxy( + entry, + objectHandlers, + meta!.root, + meta!.options + ); const childMeta = proxyMeta.get(childProxy)!; childMeta.parent = receiver; childMeta.key = synthetic; @@ -462,6 +534,30 @@ function getFromSet( ); if (key === "add") { const entry = args[0]; + + // Add @id to object entries if options specify it + if ( + entry && + typeof entry === "object" && + metaNow.options?.addIdToObjects && + entry.constructor === Object && + !("@id" in entry) + ) { + let syntheticId: string | number; + if (metaNow.options.idGenerator) { + syntheticId = metaNow.options.idGenerator(); + } else { + syntheticId = assignBlankNodeId(entry); + } + + Object.defineProperty(entry, "@id", { + value: syntheticId, + writable: false, + enumerable: true, + configurable: false, + }); + } + let synthetic = getSetEntryKey(entry); if (entry && typeof entry === "object") { for (const existing of raw.values()) { @@ -482,7 +578,8 @@ function getFromSet( const childProxy = createProxy( entryVal, objectHandlers, - metaNow.root + metaNow.root, + metaNow.options ); const childMeta = proxyMeta.get(childProxy)!; childMeta.parent = receiver; @@ -492,13 +589,13 @@ function getFromSet( } // Set entry add: emit object vs primitive variant. if (entryVal && typeof entryVal === "object") { - // Object entry: path includes synthetic id - queuePatch({ - root: metaNow.root, - path: [...containerPath, synthetic], - op: "add", - type: "object", - }); + // Object entry: path includes synthetic id, and emit deep patches for nested properties + queueDeepPatches( + entry, + metaNow.root, + [...containerPath, synthetic], + metaNow.options + ); } else { // Primitive entry: path is just the Set, value contains the primitive queuePatch({ @@ -645,7 +742,12 @@ function ensureChildProxy(value: any, parent: object, key: string | number) { if (!shouldProxy(value)) return value; if (!objToProxy.has(value)) { const parentMeta = proxyMeta.get(parent)!; - const childProxy = createProxy(value, objectHandlers, parentMeta.root); + const childProxy = createProxy( + value, + objectHandlers, + parentMeta.root, + parentMeta.options + ); const childMeta = proxyMeta.get(childProxy)!; childMeta.parent = parent; childMeta.key = key as string; @@ -666,12 +768,14 @@ function normalizeKey( if (fullKey === "$") { // Provide $ meta proxy for array index signals if (!arrayToArrayOfSignals.has(target)) { + const receiverMeta = proxyMeta.get(receiver); arrayToArrayOfSignals.set( target, createProxy( target, arrayHandlers, - proxyMeta.get(receiver)?.root + receiverMeta?.root, + receiverMeta?.options ) ); } @@ -732,6 +836,10 @@ const get = const objectHandlers = { get: get(false), set(target: object, fullKey: string, val: any, receiver: object): boolean { + // Prevent modification of @id property + if (fullKey === "@id") { + throw new Error("Cannot modify readonly property '@id'"); + } // Respect original getter/setter semantics if (typeof descriptor(target, fullKey)?.set === "function") return Reflect.set(target, fullKey, val, receiver); @@ -763,7 +871,8 @@ const objectHandlers = { const childProxy = createProxy( val, objectHandlers, - parentMeta!.root + parentMeta!.root, + parentMeta!.options ); const childMeta = proxyMeta.get(childProxy)!; childMeta.parent = receiver; @@ -790,7 +899,12 @@ const objectHandlers = { const meta = proxyMeta.get(receiver); if (meta) { // Recursively emit patches for all nested properties of newly attached objects - queueDeepPatches(val, meta.root, buildPath(receiver, fullKey)); + queueDeepPatches( + val, + meta.root, + buildPath(receiver, fullKey), + meta.options + ); } return result; } diff --git a/sdk/js/alien-deepsignals/src/test/deepSignalOptions.test.ts b/sdk/js/alien-deepsignals/src/test/deepSignalOptions.test.ts new file mode 100644 index 0000000..5b3f7c7 --- /dev/null +++ b/sdk/js/alien-deepsignals/src/test/deepSignalOptions.test.ts @@ -0,0 +1,340 @@ +import { describe, it, expect } from "vitest"; +import { deepSignal, DeepPatch, DeepSignalOptions } from "../deepSignal"; +import { watch } from "../watch"; + +describe("deepSignal options", () => { + describe("custom ID generator", () => { + it("uses custom ID generator for objects without @id", async () => { + let counter = 1000; + const options: DeepSignalOptions = { + idGenerator: () => `custom-${counter++}`, + addIdToObjects: true, + }; + + const state = deepSignal({ data: {} as any }, options); + const patches: DeepPatch[][] = []; + const { stopListening: stop } = watch(state, ({ patches: batch }) => + patches.push(batch) + ); + + state.data.user = { name: "Alice" }; + await Promise.resolve(); + + // Check that @id was assigned + expect((state.data.user as any)["@id"]).toBe("custom-1000"); + + // Check that patch was emitted for @id + const flat = patches.flat().map((p) => p.path.join(".")); + expect(flat).toContain("data.user.@id"); + + stop(); + }); + + it("respects existing @id on objects", async () => { + const options: DeepSignalOptions = { + idGenerator: () => "should-not-be-used", + addIdToObjects: true, + }; + + const state = deepSignal({ items: [] as any[] }, options); + + state.items.push({ "@id": "existing-123", value: 42 }); + + // Should use the existing @id + expect((state.items[0] as any)["@id"]).toBe("existing-123"); + }); + + it("uses @id property from objects added to Sets", async () => { + const options: DeepSignalOptions = { + idGenerator: () => "fallback-id", + addIdToObjects: true, + }; + + const state = deepSignal({ s: new Set() }, options); + const patches: DeepPatch[][] = []; + const { stopListening: stop } = watch(state, ({ patches: batch }) => + patches.push(batch) + ); + + const obj = { "@id": "set-entry-1", data: "test" }; + state.s.add(obj); + + await Promise.resolve(); + + const flat = patches.flat().map((p) => p.path.join(".")); + // Path should use the @id as synthetic key + expect(flat.some((p) => p.startsWith("s.set-entry-1"))).toBe(true); + + stop(); + }); + }); + + describe("addIdToObjects option", () => { + it("adds @id to all nested objects when enabled", async () => { + const options: DeepSignalOptions = { + addIdToObjects: true, + }; + + const state = deepSignal({ root: {} as any }, options); + const patches: DeepPatch[][] = []; + const { stopListening: stop } = watch(state, ({ patches: batch }) => + patches.push(batch) + ); + + state.root.level1 = { + level2: { + level3: { value: "deep" }, + }, + }; + + await Promise.resolve(); + + // Check all levels have @id + expect((state.root.level1 as any)["@id"]).toBeDefined(); + expect((state.root.level1.level2 as any)["@id"]).toBeDefined(); + expect( + (state.root.level1.level2.level3 as any)["@id"] + ).toBeDefined(); + + // Check patches were emitted for all @id fields + const flat = patches.flat().map((p) => p.path.join(".")); + expect(flat).toContain("root.level1.@id"); + expect(flat).toContain("root.level1.level2.@id"); + expect(flat).toContain("root.level1.level2.level3.@id"); + + stop(); + }); + + it("does not add @id when option is false", () => { + const state = deepSignal({ data: { nested: {} } }); + + // Should not have @id + expect("@id" in (state.data as any)).toBe(false); + expect("@id" in (state.data.nested as any)).toBe(false); + }); + + it("adds @id to objects in arrays", async () => { + const options: DeepSignalOptions = { + addIdToObjects: true, + }; + + const state = deepSignal({ items: [] as any[] }, options); + const patches: DeepPatch[][] = []; + const { stopListening: stop } = watch(state, ({ patches: batch }) => + patches.push(batch) + ); + + state.items.push({ name: "Item 1" }, { name: "Item 2" }); + + await Promise.resolve(); + + // Both items should have @id + expect((state.items[0] as any)["@id"]).toBeDefined(); + expect((state.items[1] as any)["@id"]).toBeDefined(); + + // Check patches + const flat = patches.flat().map((p) => p.path.join(".")); + expect(flat).toContain("items.0.@id"); + expect(flat).toContain("items.1.@id"); + + stop(); + }); + + it("adds @id to objects in Sets", async () => { + const options: DeepSignalOptions = { + idGenerator: () => + `gen-${Math.random().toString(36).substr(2, 9)}`, + addIdToObjects: true, + }; + + const state = deepSignal({ s: new Set() }, options); + const patches: DeepPatch[][] = []; + const { stopListening: stop } = watch(state, ({ patches: batch }) => + patches.push(batch) + ); + + const obj1 = { value: 1 }; + const obj2 = { value: 2 }; + state.s.add(obj1); + state.s.add(obj2); + + await Promise.resolve(); + + // Get proxied objects from Set + const proxiedObjs = Array.from(state.s); + expect((proxiedObjs[0] as any)["@id"]).toBeDefined(); + expect((proxiedObjs[1] as any)["@id"]).toBeDefined(); + + // @id should be used as synthetic key in paths + const flat = patches.flat().map((p) => p.path.join(".")); + const obj1Id = (proxiedObjs[0] as any)["@id"]; + const obj2Id = (proxiedObjs[1] as any)["@id"]; + expect(flat.some((p) => p.startsWith(`s.${obj1Id}`))).toBe(true); + expect(flat.some((p) => p.startsWith(`s.${obj2Id}`))).toBe(true); + + stop(); + }); + }); + + describe("@id property behavior", () => { + it("makes @id readonly", () => { + const options: DeepSignalOptions = { + addIdToObjects: true, + }; + + const state = deepSignal({ obj: {} as any }, options); + state.obj.data = { value: 1 }; + + // Attempting to modify @id should throw + expect(() => { + (state.obj.data as any)["@id"] = "new-id"; + }).toThrow("Cannot modify readonly property '@id'"); + }); + + it("makes @id enumerable", () => { + const options: DeepSignalOptions = { + addIdToObjects: true, + }; + + const state = deepSignal({ obj: {} as any }, options); + state.obj.data = { value: 1 }; + + // @id should show up in Object.keys() + const keys = Object.keys(state.obj.data); + expect(keys).toContain("@id"); + }); + + it("emits patches for @id even on objects with existing @id", async () => { + const options: DeepSignalOptions = { + addIdToObjects: true, + }; + + const state = deepSignal({ container: {} as any }, options); + const patches: DeepPatch[][] = []; + const { stopListening: stop } = watch(state, ({ patches: batch }) => + patches.push(batch) + ); + + // Object already has @id before being added + const objWithId = { "@id": "pre-existing", data: "test" }; + state.container.item = objWithId; + + await Promise.resolve(); + + const flat = patches.flat().map((p) => p.path.join(".")); + // Patch should still be emitted for @id + expect(flat).toContain("container.item.@id"); + + // Verify the value in the patch + const idPatch = patches + .flat() + .find((p) => p.path.join(".") === "container.item.@id"); + expect((idPatch as any).value).toBe("pre-existing"); + + stop(); + }); + }); + + describe("options inheritance", () => { + it("child objects inherit options from root", async () => { + let idCounter = 5000; + const options: DeepSignalOptions = { + idGenerator: () => `inherited-${idCounter++}`, + addIdToObjects: true, + }; + + const state = deepSignal({ root: {} as any }, options); + + // Add nested structure + state.root.child = { + grandchild: { + value: "nested", + }, + }; + + // All should have IDs generated by the custom generator + expect((state.root.child as any)["@id"]).toMatch(/^inherited-/); + expect((state.root.child.grandchild as any)["@id"]).toMatch( + /^inherited-/ + ); + }); + + it("objects added to Sets inherit options", async () => { + let counter = 9000; + const options: DeepSignalOptions = { + idGenerator: () => `set-child-${counter++}`, + addIdToObjects: true, + }; + + const state = deepSignal({ s: new Set() }, options); + + const obj = { nested: { value: 1 } }; + state.s.add(obj); + + // Iterate to get proxied object + const proxied = Array.from(state.s)[0]; + + // Object and nested object should have custom IDs + expect((proxied as any)["@id"]).toMatch(/^set-child-/); + expect((proxied.nested as any)["@id"]).toMatch(/^set-child-/); + }); + }); + + describe("backward compatibility", () => { + it("still works without options", async () => { + const state = deepSignal({ data: { value: 1 } }); + const patches: DeepPatch[][] = []; + const { stopListening: stop } = watch(state, ({ patches: batch }) => + patches.push(batch) + ); + + state.data.value = 2; + await Promise.resolve(); + + expect(patches.flat().length).toBeGreaterThan(0); + stop(); + }); + + // TODO: Delete duplicate logic for `id`. Only accept @id. + it("objects with id property still work for Sets", async () => { + const state = deepSignal({ s: new Set() }); + const patches: DeepPatch[][] = []; + const { stopListening: stop } = watch(state, ({ patches: batch }) => + patches.push(batch) + ); + + state.s.add({ id: "legacy-id", value: 1 }); + await Promise.resolve(); + + const flat = patches.flat().map((p) => p.path.join(".")); + // Should use id as synthetic key + expect(flat.some((p) => p.startsWith("s.legacy-id"))).toBe(true); + + stop(); + }); + + it("@id takes precedence over id property", async () => { + const state = deepSignal({ s: new Set() }); + const patches: DeepPatch[][] = []; + const { stopListening: stop } = watch(state, ({ patches: batch }) => + patches.push(batch) + ); + + state.s.add({ + id: "should-not-use", + "@id": "should-use", + value: 1, + }); + await Promise.resolve(); + + const flat = patches.flat().map((p) => p.path.join(".")); + // Should use @id, not id + expect(flat.some((p) => p.startsWith("s.should-use"))).toBe(true); + expect(flat.some((p) => p.startsWith("s.should-not-use"))).toBe( + false + ); + + stop(); + }); + }); +}); diff --git a/sdk/js/alien-deepsignals/src/test/watchPatches.test.ts b/sdk/js/alien-deepsignals/src/test/watchPatches.test.ts index 4a30469..2658340 100644 --- a/sdk/js/alien-deepsignals/src/test/watchPatches.test.ts +++ b/sdk/js/alien-deepsignals/src/test/watchPatches.test.ts @@ -328,8 +328,13 @@ describe("watch (patch mode)", () => { ); st.s.clear(); await Promise.resolve(); - const all = batches.flat().map((p) => p.path.join(".")); - expect(all).toEqual(["s"]); + // clear() emits a single structural patch for the Set itself (op: "add", value: []) + const structuralPatches = batches + .flat() + .filter((p) => p.path.length === 1 && p.path[0] === "s"); + expect(structuralPatches.length).toBe(1); + expect(structuralPatches[0].op).toBe("add"); + expect((structuralPatches[0] as any).value).toEqual([]); stop(); }); it("emits delete patch for object entry", async () => { @@ -453,10 +458,17 @@ describe("watch (patch mode)", () => { st.s.add(a1); st.s.add(a2); await Promise.resolve(); - const keys = patches + // Filter for Set structural patches only (path length 2: ['s', syntheticId]) + const setAddPatches = patches .flat() - .filter((p) => p.op === "add") - .map((p) => p.path.slice(-1)[0]); + .filter( + (p) => + p.op === "add" && + p.path.length === 2 && + p.path[0] === "s" + ); + const keys = setAddPatches.map((p) => p.path.slice(-1)[0]); + // Both objects should have unique synthetic IDs despite id collision expect(new Set(keys).size).toBe(2); stop(); }); From 624d0c5a6d45a9d579b1577db5159510628228dd Mon Sep 17 00:00:00 2001 From: Laurin Weger Date: Sun, 19 Oct 2025 13:49:06 +0200 Subject: [PATCH 4/8] update deepsignals readme --- sdk/js/alien-deepsignals/README.md | 189 +++++++++++++++++++++++++---- 1 file changed, 166 insertions(+), 23 deletions(-) diff --git a/sdk/js/alien-deepsignals/README.md b/sdk/js/alien-deepsignals/README.md index e61af4b..9c13e9a 100644 --- a/sdk/js/alien-deepsignals/README.md +++ b/sdk/js/alien-deepsignals/README.md @@ -13,7 +13,8 @@ Core idea: wrap a data tree in a `Proxy` that lazily creates per-property signal - Patch stream: microtask‑batched granular mutations (paths + op) for syncing external stores / framework adapters. - Getter => computed: property getters become derived (readonly) signals automatically. - `$` accessors: TypeScript exposes `$prop` for each non‑function key plus `$` / `$length` for arrays. -- Sets: structural `add/delete/clear` emit patches; object entries get synthetic stable ids (prefers `id` / `@id` fields or auto‑generated blank IDs). +- Sets: structural `add/delete/clear` emit patches; object entries get synthetic stable ids via `@id` property. +- `@id` property system: configurable automatic ID assignment to objects with custom generators. - Shallow escape hatch: wrap sub-objects with `shallow(obj)` to track only reference replacement. ## Install @@ -46,6 +47,68 @@ state.$count!.set(5); // update via signal console.log(state.$count!()); // read via signal function ``` +## Configuration options + +`deepSignal(obj, options?)` accepts an optional configuration object: + +```ts +type DeepSignalOptions = { + idGenerator?: () => string | number; // Custom ID generator function + addIdToObjects?: boolean; // Automatically add @id to plain objects +}; +``` + +### Custom ID generation + +Provide a custom function to generate synthetic IDs instead of auto-generated blank node IDs: + +```ts +let counter = 0; +const state = deepSignal( + { items: new Set() }, + { + idGenerator: () => `urn:item:${++counter}`, + addIdToObjects: true + } +); + +state.items.add({ name: "Item 1" }); // Gets @id: "urn:item:1" +state.items.add({ name: "Item 2" }); // Gets @id: "urn:item:2" +``` + +### The `@id` property + +When `addIdToObjects: true`, plain objects automatically receive a readonly, enumerable `@id` property: + +```ts +const state = deepSignal( + { data: {} }, + { + idGenerator: () => `urn:uuid:${crypto.randomUUID()}`, + addIdToObjects: true + } +); + +state.data.user = { name: "Ada" }; +console.log(state.data.user["@id"]); // e.g., "urn:uuid:550e8400-e29b-41d4-a716-446655440000" + +// @id is readonly +state.data.user["@id"] = "new-id"; // TypeError in strict mode + +// @id assignment emits a patch +watch(state, ({ patches }) => { + // patches includes: { op: "add", path: ["data", "user", "@id"], value: "..." } +}); +``` + +**Key behaviors:** + +- `@id` is assigned **before** the object is proxied, ensuring it's available immediately +- `@id` properties are **readonly** and **enumerable** +- Assigning `@id` emits a patch just like any other property +- Objects with existing `@id` properties keep their values (not overwritten) +- Options propagate to nested objects created after initialization + ## Watching patches `watch(root, cb, options?)` observes a deepSignal root and invokes your callback with microtask‑batched mutation patches plus snapshots. @@ -113,23 +176,103 @@ Notes: ## Sets & synthetic ids -Object entries inside Sets need a stable key. Priority: +Object entries inside Sets need a stable key for patch paths. The synthetic ID resolution follows this priority: -1. `entry.id` -2. `entry['@id']` -3. Custom via `setSetEntrySyntheticId(entry, 'myId')` before `add` -4. Auto `_bN` blank id +1. Explicit custom ID via `setSetEntrySyntheticId(entry, 'myId')` (before `add`) +2. Existing `entry['@id']` property +3. Auto-generated blank node ID (`_bN` format) -Helpers: +### Working with Sets ```ts -import { addWithId, setSetEntrySyntheticId } from "alien-deepsignals"; +import { addWithId, setSetEntrySyntheticId } from "@ng-org/alien-deepsignals"; + +// Option 1: Use @id from configuration +const state = deepSignal( + { items: new Set() }, + { + idGenerator: () => `urn:uuid:${crypto.randomUUID()}`, + addIdToObjects: true + } +); +const item = { name: "Item 1" }; +state.items.add(item); // Automatically gets @id before being added +console.log(item["@id"]); // e.g., "urn:uuid:550e8400-..." + +// Option 2: Manually set synthetic ID +const obj = { value: 42 }; +setSetEntrySyntheticId(obj, "urn:custom:my-id"); +state.items.add(obj); + +// Option 3: Use convenience helper +addWithId(state.items as any, { value: 99 }, "urn:item:special"); + +// Option 4: Pre-assign @id property +const preTagged = { "@id": "urn:explicit:123", data: "..." }; +state.items.add(preTagged); // Uses "urn:explicit:123" as synthetic ID +``` + +### Set entry patches and paths + +When objects are added to Sets, their **synthetic ID becomes part of the patch path**. This allows patches to uniquely identify which Set entry is being mutated. + +```ts +const state = deepSignal( + { s: new Set() }, + { + idGenerator: () => "urn:entry:set-entry-1", + addIdToObjects: true + } +); + +watch(state, ({ patches }) => { + console.log(JSON.stringify(patches)); + // [ + // {"path":["s","urn:entry:set-entry-1"],"op":"add","type":"object"}, + // {"path":["s","urn:entry:set-entry-1","@id"],"op":"add","value":"urn:entry:set-entry-1"}, + // {"path":["s","urn:entry:set-entry-1","data"],"op":"add","value":"test"} + // ] +}); -setSetEntrySyntheticId(obj, "custom"); -state.settings.add(obj); -addWithId(state.settings as any, { x: 1 }, "x1"); +state.s.add({ data: "test" }); ``` +**Path structure explained:** + +- `["s", "urn:entry:set-entry-1"]` - The structural Set patch; the IRI identifies the entry +- `["s", "urn:entry:set-entry-1", "@id"]` - Patch for the @id property assignment +- `["s", "urn:entry:set-entry-1", "data"]` - Nested property patch; the IRI identifies which Set entry +- The synthetic ID (the IRI) is stable across mutations, allowing tracking of the same object + +**Mutating nested properties:** + +```ts +const state = deepSignal( + { users: new Set() }, + { + idGenerator: () => `urn:user:${crypto.randomUUID()}`, + addIdToObjects: true + } +); +const user = { name: "Ada", age: 30 }; +state.users.add(user); // Gets @id, e.g., "urn:user:550e8400-..." + +watch(state, ({ patches }) => { + console.log(JSON.stringify(patches)); + // [{"path":["users","urn:user:550e8400-...","age"],"op":"add","value":31}] +}); + +// Later mutation: synthetic ID identifies which Set entry changed +user.age = 31; +``` + +The path `["users", "urn:user:550e8400-...", "age"]` shows: +1. `users` - the Set container +2. `urn:user:550e8400-...` - the IRI identifying which object in the Set +3. `age` - the property being mutated + +This structure enables precise tracking of nested changes within Set entries, critical for syncing state changes or implementing undo/redo. + ## Shallow Skip deep proxying of a subtree (only reference replacement tracked): @@ -152,18 +295,18 @@ const n: number = state.$count!(); // typed number ## API surface -| Function | Description | -| ---------------------------------- | --------------------------------------- | -| `deepSignal(obj)` | Create (or reuse) reactive deep proxy. | -| `watch(root, cb, opts?)` | Observe batched deep mutations. | -| `observe(root, cb, opts?)` | Alias of `watch`. | -| `peek(obj,key)` | Untracked property read. | -| `shallow(obj)` | Mark object to skip deep proxying. | -| `isDeepSignal(val)` | Runtime predicate. | -| `isShallow(val)` | Was value marked shallow. | -| `setSetEntrySyntheticId(obj,id)` | Assign custom Set entry id. | -| `addWithId(set, entry, id)` | Insert with desired synthetic id. | -| `subscribeDeepMutations(root, cb)` | Low-level patch stream (used by watch). | +| Function | Description | +| ---------------------------------- | ------------------------------------------------- | +| `deepSignal(obj, options?)` | Create (or reuse) reactive deep proxy with optional configuration. | +| `watch(root, cb, opts?)` | Observe batched deep mutations. | +| `observe(root, cb, opts?)` | Alias of `watch`. | +| `peek(obj,key)` | Untracked property read. | +| `shallow(obj)` | Mark object to skip deep proxying. | +| `isDeepSignal(val)` | Runtime predicate. | +| `isShallow(val)` | Was value marked shallow. | +| `setSetEntrySyntheticId(obj,id)` | Assign custom Set entry id (highest priority). | +| `addWithId(set, entry, id)` | Insert with desired synthetic id (convenience). | +| `subscribeDeepMutations(root, cb)` | Low-level patch stream (used by watch). | ## Credits From d78b7dabd514a629d14a46191ee5337c5644f240 Mon Sep 17 00:00:00 2001 From: Laurin Weger Date: Sun, 19 Oct 2025 14:50:36 +0200 Subject: [PATCH 5/8] update shex typing transformer to use @id, @type --- .../transformers/ShexJTypingTransformer.ts | 18 +++++++++--------- sdk/js/shex-orm/src/types.ts | 2 +- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/sdk/js/shex-orm/src/schema-converter/transformers/ShexJTypingTransformer.ts b/sdk/js/shex-orm/src/schema-converter/transformers/ShexJTypingTransformer.ts index 9cd7d68..4463367 100644 --- a/sdk/js/shex-orm/src/schema-converter/transformers/ShexJTypingTransformer.ts +++ b/sdk/js/shex-orm/src/schema-converter/transformers/ShexJTypingTransformer.ts @@ -225,7 +225,7 @@ function dedupeCompactProperties( return merged; } -// Helpers to add id: IRI to anonymous object(-union) types +// Helpers to add @id: IRI to anonymous object(-union) types function ensureIdOnMembers(members?: any[]): void { if (!members) return; const props = (members.filter?.((m: any) => m?.kind === "property") || @@ -233,9 +233,9 @@ function ensureIdOnMembers(members?: any[]): void { if (!props.some((m) => m.name === "id")) { members.unshift( dom.create.property( - "id", + "@id", dom.create.namedTypeReference("IRI"), - dom.DeclarationFlags.None + dom.DeclarationFlags.ReadOnly ) ); } @@ -322,15 +322,15 @@ export const ShexJTypingTransformerCompact = ShexJTraverser.createTransformer< shapeInterface.shapeId = shapeDecl.id; if ( !shapeInterface.members.find( - (m) => m.kind === "property" && m.name === "id" + (m) => m.kind === "property" && m.name === "@id" ) ) { shapeInterface.members.unshift( dom.create.property( - "id", + "@id", dom.create.namedTypeReference("IRI"), - // Root interfaces should have mandatory id - dom.DeclarationFlags.None + // Root interfaces should have mandatory @id + dom.DeclarationFlags.ReadOnly ) ); } @@ -380,7 +380,7 @@ export const ShexJTypingTransformerCompact = ShexJTraverser.createTransformer< const merged = [ ...extInt.members.filter( (m) => - !(m.kind === "property" && m.name === "id") + !(m.kind === "property" && m.name === "@id") ), ...newInterface.members, ].filter( @@ -394,7 +394,7 @@ export const ShexJTypingTransformerCompact = ShexJTraverser.createTransformer< // Final pass: ensure only a single id property const idSeen = new Set(); newInterface.members = newInterface.members.filter((m, idx) => { - if (m.kind !== "property" || m.name !== "id") return true; + if (m.kind !== "property" || m.name !== "@id") return true; if (idSeen.size === 0) { idSeen.add(idx); // normalize id type to IRI diff --git a/sdk/js/shex-orm/src/types.ts b/sdk/js/shex-orm/src/types.ts index fa044bc..2f4308b 100644 --- a/sdk/js/shex-orm/src/types.ts +++ b/sdk/js/shex-orm/src/types.ts @@ -4,7 +4,7 @@ export interface ShapeType { } export interface BaseType extends Record { - id: string; + "@id": string; } export type Schema = { From 615f872663495efc46a77ece51c77401659caacf Mon Sep 17 00:00:00 2001 From: Laurin Weger Date: Sun, 19 Oct 2025 14:51:20 +0200 Subject: [PATCH 6/8] multi-framework-signals example: rebuild shapes --- .../src/shapes/orm/catShape.schema.ts | 2 +- .../src/shapes/orm/catShape.typings.ts | 6 +++--- .../src/shapes/orm/personShape.schema.ts | 2 +- .../src/shapes/orm/personShape.typings.ts | 6 +++--- .../src/shapes/orm/testShape.schema.ts | 2 +- .../src/shapes/orm/testShape.typings.ts | 8 ++++---- 6 files changed, 13 insertions(+), 13 deletions(-) diff --git a/sdk/js/examples/multi-framework-signals/src/shapes/orm/catShape.schema.ts b/sdk/js/examples/multi-framework-signals/src/shapes/orm/catShape.schema.ts index b74e4a1..613a0e1 100644 --- a/sdk/js/examples/multi-framework-signals/src/shapes/orm/catShape.schema.ts +++ b/sdk/js/examples/multi-framework-signals/src/shapes/orm/catShape.schema.ts @@ -19,7 +19,7 @@ export const catShapeSchema: Schema = { maxCardinality: 1, minCardinality: 1, iri: "http://www.w3.org/1999/02/22-rdf-syntax-ns#type", - readablePredicate: "type", + readablePredicate: "@type", }, { dataTypes: [ diff --git a/sdk/js/examples/multi-framework-signals/src/shapes/orm/catShape.typings.ts b/sdk/js/examples/multi-framework-signals/src/shapes/orm/catShape.typings.ts index a7c3c46..8ca421c 100644 --- a/sdk/js/examples/multi-framework-signals/src/shapes/orm/catShape.typings.ts +++ b/sdk/js/examples/multi-framework-signals/src/shapes/orm/catShape.typings.ts @@ -10,11 +10,11 @@ export type IRI = string; * Cat Type */ export interface Cat { - id: IRI; + readonly "@id": IRI; /** * Original IRI: http://www.w3.org/1999/02/22-rdf-syntax-ns#type */ - type: string; + "@type": string; /** * Original IRI: http://example.org/name */ @@ -31,7 +31,7 @@ export interface Cat { * Original IRI: http://example.org/address */ address: { - id: IRI; + readonly "@id": IRI; /** * Original IRI: http://example.org/street */ diff --git a/sdk/js/examples/multi-framework-signals/src/shapes/orm/personShape.schema.ts b/sdk/js/examples/multi-framework-signals/src/shapes/orm/personShape.schema.ts index c59fb5f..42166e5 100644 --- a/sdk/js/examples/multi-framework-signals/src/shapes/orm/personShape.schema.ts +++ b/sdk/js/examples/multi-framework-signals/src/shapes/orm/personShape.schema.ts @@ -19,7 +19,7 @@ export const personShapeSchema: Schema = { maxCardinality: 1, minCardinality: 1, iri: "http://www.w3.org/1999/02/22-rdf-syntax-ns#type", - readablePredicate: "type", + readablePredicate: "@type", }, { dataTypes: [ diff --git a/sdk/js/examples/multi-framework-signals/src/shapes/orm/personShape.typings.ts b/sdk/js/examples/multi-framework-signals/src/shapes/orm/personShape.typings.ts index db985e9..497ff3c 100644 --- a/sdk/js/examples/multi-framework-signals/src/shapes/orm/personShape.typings.ts +++ b/sdk/js/examples/multi-framework-signals/src/shapes/orm/personShape.typings.ts @@ -10,11 +10,11 @@ export type IRI = string; * Person Type */ export interface Person { - id: IRI; + readonly "@id": IRI; /** * Original IRI: http://www.w3.org/1999/02/22-rdf-syntax-ns#type */ - type: string; + "@type": string; /** * Original IRI: http://example.org/name */ @@ -23,7 +23,7 @@ export interface Person { * Original IRI: http://example.org/address */ address: { - id: IRI; + readonly "@id": IRI; /** * Original IRI: http://example.org/street */ diff --git a/sdk/js/examples/multi-framework-signals/src/shapes/orm/testShape.schema.ts b/sdk/js/examples/multi-framework-signals/src/shapes/orm/testShape.schema.ts index 2a3905b..562723b 100644 --- a/sdk/js/examples/multi-framework-signals/src/shapes/orm/testShape.schema.ts +++ b/sdk/js/examples/multi-framework-signals/src/shapes/orm/testShape.schema.ts @@ -19,7 +19,7 @@ export const testShapeSchema: Schema = { maxCardinality: 1, minCardinality: 1, iri: "http://www.w3.org/1999/02/22-rdf-syntax-ns#type", - readablePredicate: "type", + readablePredicate: "@type", extra: true, }, { diff --git a/sdk/js/examples/multi-framework-signals/src/shapes/orm/testShape.typings.ts b/sdk/js/examples/multi-framework-signals/src/shapes/orm/testShape.typings.ts index 046be0b..ba15ee7 100644 --- a/sdk/js/examples/multi-framework-signals/src/shapes/orm/testShape.typings.ts +++ b/sdk/js/examples/multi-framework-signals/src/shapes/orm/testShape.typings.ts @@ -10,11 +10,11 @@ export type IRI = string; * TestObject Type */ export interface TestObject { - id: IRI; + readonly "@id": IRI; /** * Original IRI: http://www.w3.org/1999/02/22-rdf-syntax-ns#type */ - type: string; + "@type": string; /** * Original IRI: http://example.org/stringValue */ @@ -35,7 +35,7 @@ export interface TestObject { * Original IRI: http://example.org/objectValue */ objectValue: { - id: IRI; + readonly "@id": IRI; /** * Original IRI: http://example.org/nestedString */ @@ -55,7 +55,7 @@ export interface TestObject { anotherObject?: Record< IRI, { - id: IRI; + readonly "@id": IRI; /** * Original IRI: http://example.org/prop1 */ From b4bcbecaafb804b672e34475a1fc4c1ce999866d Mon Sep 17 00:00:00 2001 From: Laurin Weger Date: Sun, 19 Oct 2025 18:08:34 +0200 Subject: [PATCH 7/8] updates to ng-signals --- .../signals/src/connector/applyDiff.test.ts | 360 +++++++++++++++--- sdk/js/signals/src/connector/applyDiff.ts | 244 ++++++++---- .../connector/createSignalObjectForShape.ts | 35 +- .../src/connector/ormConnectionHandler.ts | 217 +++++++++++ 4 files changed, 720 insertions(+), 136 deletions(-) create mode 100644 sdk/js/signals/src/connector/ormConnectionHandler.ts diff --git a/sdk/js/signals/src/connector/applyDiff.test.ts b/sdk/js/signals/src/connector/applyDiff.test.ts index 89a638a..bcb5faa 100644 --- a/sdk/js/signals/src/connector/applyDiff.test.ts +++ b/sdk/js/signals/src/connector/applyDiff.test.ts @@ -52,74 +52,169 @@ describe("applyDiff - set operations (primitives)", () => { }); }); -describe("applyDiff - set operations (object sets)", () => { - test("add object entries to new object-set", () => { - const state: any = {}; +describe("applyDiff - multi-valued objects (Set-based)", () => { + test("create multi-object container (Set) without @id", () => { + const state: any = { "urn:person1": {} }; const diff: Patch[] = [ { op: "add", - valType: "set", - path: p("users"), - value: { u1: { id: "u1", n: 1 }, u2: { id: "u2", n: 2 } }, + valType: "object", + path: p("urn:person1", "children"), }, ]; applyDiff(state, diff); - expect(state.users.u1).toEqual({ id: "u1", n: 1 }); - expect(state.users.u2).toEqual({ id: "u2", n: 2 }); + expect(state["urn:person1"].children).toBeInstanceOf(Set); }); - test("merge object entries into existing object-set", () => { - const state: any = { users: { u1: { id: "u1", n: 1 } } }; + + test("add object to Set with @id", () => { + const state: any = { "urn:person1": { children: new Set() } }; const diff: Patch[] = [ + // First patch creates the object in the Set { op: "add", - valType: "set", - path: p("users"), - value: { u2: { id: "u2", n: 2 } }, + valType: "object", + path: p("urn:person1", "children", "urn:child1"), + }, + // Second patch adds the @id property + { + op: "add", + path: p("urn:person1", "children", "urn:child1", "@id"), + value: "urn:child1", }, ]; applyDiff(state, diff); - expect(Object.keys(state.users).sort()).toEqual(["u1", "u2"]); + const children = state["urn:person1"].children; + expect(children).toBeInstanceOf(Set); + expect(children.size).toBe(1); + const child = [...children][0]; + expect(child["@id"]).toBe("urn:child1"); }); - test("remove object entries from object-set", () => { - const state: any = { users: { u1: {}, u2: {}, u3: {} } }; + + test("add properties to object in Set", () => { + const obj = { "@id": "urn:child1" }; + const state: any = { "urn:person1": { children: new Set([obj]) } }; const diff: Patch[] = [ { - op: "remove", - valType: "set", - path: p("users"), - value: ["u1", "u3"], + op: "add", + path: p("urn:person1", "children", "urn:child1", "name"), + value: "Alice", + }, + { + op: "add", + path: p("urn:person1", "children", "urn:child1", "age"), + value: 10, }, ]; applyDiff(state, diff); - expect(Object.keys(state.users)).toEqual(["u2"]); + const child = [...state["urn:person1"].children][0]; + expect(child.name).toBe("Alice"); + expect(child.age).toBe(10); + }); + + test("remove object from Set by @id", () => { + const obj1 = { "@id": "urn:child1", name: "Alice" }; + const obj2 = { "@id": "urn:child2", name: "Bob" }; + const state: any = { + "urn:person1": { children: new Set([obj1, obj2]) }, + }; + const diff: Patch[] = [ + { op: "remove", path: p("urn:person1", "children", "urn:child1") }, + ]; + applyDiff(state, diff); + const children = state["urn:person1"].children; + expect(children.size).toBe(1); + const remaining = [...children][0]; + expect(remaining["@id"]).toBe("urn:child2"); }); - test("adding primitives to existing object-set replaces with Set", () => { - const state: any = { mixed: { a: {}, b: {} } }; + + test("create nested Set (multi-valued property within object in Set)", () => { + const parent: any = { "@id": "urn:parent1" }; + const state: any = { root: { parents: new Set([parent]) } }; const diff: Patch[] = [ - { op: "add", valType: "set", path: p("mixed"), value: [1, 2] }, + { + op: "add", + valType: "object", + path: p("root", "parents", "urn:parent1", "children"), + }, + { + op: "add", + valType: "object", + path: p( + "root", + "parents", + "urn:parent1", + "children", + "urn:child1" + ), + }, + { + op: "add", + path: p( + "root", + "parents", + "urn:parent1", + "children", + "urn:child1", + "@id" + ), + value: "urn:child1", + }, ]; applyDiff(state, diff); - expect(state.mixed).toBeInstanceOf(Set); - expect([...state.mixed]).toEqual([1, 2]); + const nestedChildren = parent.children; + expect(nestedChildren).toBeInstanceOf(Set); + expect(nestedChildren.size).toBe(1); }); }); describe("applyDiff - object & literal operations", () => { - test("add object (create empty object)", () => { + test("create single object (with @id)", () => { + const state: any = { "urn:person1": {} }; + const diff: Patch[] = [ + { op: "add", path: p("urn:person1", "address"), valType: "object" }, + { + op: "add", + path: p("urn:person1", "address", "@id"), + value: "urn:addr1", + }, + ]; + applyDiff(state, diff); + expect(state["urn:person1"].address).toEqual({ "@id": "urn:addr1" }); + expect(state["urn:person1"].address).not.toBeInstanceOf(Set); + }); + + test("create multi-object container (without @id) -> Set", () => { + const state: any = { "urn:person1": {} }; + const diff: Patch[] = [ + { + op: "add", + path: p("urn:person1", "addresses"), + valType: "object", + }, + ]; + applyDiff(state, diff); + expect(state["urn:person1"].addresses).toBeInstanceOf(Set); + }); + + test("add object (create empty object with @id)", () => { const state: any = {}; const diff: Patch[] = [ { op: "add", path: p("address"), valType: "object" }, + { op: "add", path: p("address", "@id"), value: "urn:addr1" }, ]; applyDiff(state, diff); - expect(state.address).toEqual({}); + expect(state.address).toEqual({ "@id": "urn:addr1" }); + expect(state.address).not.toBeInstanceOf(Set); }); - test("add nested object path with ensurePathExists", () => { + test("add nested object path with ensurePathExists and @id", () => { const state: any = {}; const diff: Patch[] = [ { op: "add", path: p("a", "b", "c"), valType: "object" }, + { op: "add", path: p("a", "b", "c", "@id"), value: "urn:c1" }, ]; applyDiff(state, diff, true); - expect(state.a.b.c).toEqual({}); + expect(state.a.b.c).toEqual({ "@id": "urn:c1" }); + expect(state.a.b.c).not.toBeInstanceOf(Set); }); test("add primitive value", () => { const state: any = { address: {} }; @@ -156,23 +251,46 @@ describe("applyDiff - object & literal operations", () => { describe("applyDiff - multiple mixed patches in a single diff", () => { test("sequence of mixed set/object/literal add & remove", () => { const state: any = { - users: { u1: { id: "u1" } }, + "urn:person1": {}, tags: new Set(["old"]), }; const diff: Patch[] = [ + // Create multi-object Set { op: "add", - valType: "set", - path: p("users"), - value: { u2: { id: "u2" } }, + valType: "object", + path: p("urn:person1", "addresses"), + }, + { + op: "add", + valType: "object", + path: p("urn:person1", "addresses", "urn:addr1"), }, + { + op: "add", + path: p("urn:person1", "addresses", "urn:addr1", "@id"), + value: "urn:addr1", + }, + { + op: "add", + path: p("urn:person1", "addresses", "urn:addr1", "street"), + value: "Main St", + }, + // Create single object { op: "add", path: p("profile"), valType: "object" }, + { op: "add", path: p("profile", "@id"), value: "urn:profile1" }, { op: "add", path: p("profile", "name"), value: "Alice" }, + // Primitive set operations { op: "add", valType: "set", path: p("tags"), value: ["new"] }, { op: "remove", valType: "set", path: p("tags"), value: "old" }, ]; - applyDiff(state, diff); - expect(Object.keys(state.users).sort()).toEqual(["u1", "u2"]); + applyDiff(state, diff); // Enable ensurePathExists for nested object creation + expect(state["urn:person1"].addresses).toBeInstanceOf(Set); + expect(state["urn:person1"].addresses.size).toBe(1); + const addr = [...state["urn:person1"].addresses][0]; + expect(addr["@id"]).toBe("urn:addr1"); + expect(addr.street).toBe("Main St"); + expect(state.profile["@id"]).toBe("urn:profile1"); expect(state.profile.name).toBe("Alice"); expect([...state.tags]).toEqual(["new"]); }); @@ -180,8 +298,11 @@ describe("applyDiff - multiple mixed patches in a single diff", () => { test("complex nested path creation and mutations with ensurePathExists", () => { const state: any = {}; const diff: Patch[] = [ + // Create b as a single object (with @id) { op: "add", path: p("a", "b"), valType: "object" }, + { op: "add", path: p("a", "b", "@id"), value: "urn:b1" }, { op: "add", path: p("a", "b", "c"), value: 1 }, + // Create a primitive set { op: "add", valType: "set", @@ -193,6 +314,7 @@ describe("applyDiff - multiple mixed patches in a single diff", () => { { op: "remove", path: p("a", "b", "c") }, ]; applyDiff(state, diff, true); + expect(state.a.b["@id"]).toBe("urn:b1"); expect(state.a.b.c).toBeUndefined(); expect(state.a.b.d).toBe(2); expect(state.a.nums).toBeInstanceOf(Set); @@ -200,20 +322,166 @@ describe("applyDiff - multiple mixed patches in a single diff", () => { }); }); -describe("applyDiff - ignored / invalid scenarios", () => { - test("skip patch with non-leading slash path", () => { +describe("applyDiff - complete workflow example", () => { + test("full example: create person with single address and multiple children", () => { const state: any = {}; const diff: Patch[] = [ - { op: "add", path: "address/street", value: "x" }, + // Create root person object + { op: "add", path: p("urn:person1"), valType: "object" }, + { op: "add", path: p("urn:person1", "@id"), value: "urn:person1" }, + { op: "add", path: p("urn:person1", "name"), value: "John" }, + + // Add single address object + { op: "add", path: p("urn:person1", "address"), valType: "object" }, + { + op: "add", + path: p("urn:person1", "address", "@id"), + value: "urn:addr1", + }, + { + op: "add", + path: p("urn:person1", "address", "street"), + value: "1st Street", + }, + { + op: "add", + path: p("urn:person1", "address", "country"), + value: "Greece", + }, + + // Create multi-valued children Set + { + op: "add", + path: p("urn:person1", "children"), + valType: "object", + }, + + // Add first child + { + op: "add", + path: p("urn:person1", "children", "urn:child1"), + valType: "object", + }, + { + op: "add", + path: p("urn:person1", "children", "urn:child1", "@id"), + value: "urn:child1", + }, + { + op: "add", + path: p("urn:person1", "children", "urn:child1", "name"), + value: "Alice", + }, + + // Add second child + { + op: "add", + path: p("urn:person1", "children", "urn:child2"), + valType: "object", + }, + { + op: "add", + path: p("urn:person1", "children", "urn:child2", "@id"), + value: "urn:child2", + }, + { + op: "add", + path: p("urn:person1", "children", "urn:child2", "name"), + value: "Bob", + }, + + // Add primitive set (tags) + { + op: "add", + valType: "set", + path: p("urn:person1", "tags"), + value: ["developer", "parent"], + }, ]; - applyDiff(state, diff); - expect(state).toEqual({}); + + applyDiff(state, diff); // Enable ensurePathExists to create nested objects + + // Verify person + expect(state["urn:person1"]["@id"]).toBe("urn:person1"); + expect(state["urn:person1"].name).toBe("John"); + + // Verify single address (plain object) + expect(state["urn:person1"].address).not.toBeInstanceOf(Set); + expect(state["urn:person1"].address["@id"]).toBe("urn:addr1"); + expect(state["urn:person1"].address.street).toBe("1st Street"); + expect(state["urn:person1"].address.country).toBe("Greece"); + + // Verify children Set + const children = state["urn:person1"].children; + expect(children).toBeInstanceOf(Set); + expect(children.size).toBe(2); + + const childrenArray = [...children]; + const alice = childrenArray.find((c: any) => c["@id"] === "urn:child1"); + const bob = childrenArray.find((c: any) => c["@id"] === "urn:child2"); + expect(alice.name).toBe("Alice"); + expect(bob.name).toBe("Bob"); + + // Verify primitive set + expect(state["urn:person1"].tags).toBeInstanceOf(Set); + expect([...state["urn:person1"].tags].sort()).toEqual([ + "developer", + "parent", + ]); }); - test("missing parent without ensurePathExists -> patch skipped and no mutation", () => { - const state: any = {}; - const diff: Patch[] = [{ op: "add", path: p("a", "b", "c"), value: 1 }]; - applyDiff(state, diff, false); - expect(state).toEqual({}); + + test("update and remove operations on complex structure", () => { + // Start with pre-existing structure + const child1 = { "@id": "urn:child1", name: "Alice" }; + const child2 = { "@id": "urn:child2", name: "Bob" }; + const state: any = { + "urn:person1": { + "@id": "urn:person1", + name: "John", + address: { + "@id": "urn:addr1", + street: "1st Street", + country: "Greece", + }, + children: new Set([child1, child2]), + tags: new Set(["developer", "parent"]), + }, + }; + + const diff: Patch[] = [ + // Update address property + { + op: "add", + path: p("urn:person1", "address", "street"), + value: "2nd Street", + }, + + // Remove one child + { op: "remove", path: p("urn:person1", "children", "urn:child1") }, + + // Update child property + { + op: "add", + path: p("urn:person1", "children", "urn:child2", "age"), + value: 12, + }, + + // Remove tag + { + op: "remove", + valType: "set", + path: p("urn:person1", "tags"), + value: "developer", + }, + ]; + + applyDiff(state, diff); + + expect(state["urn:person1"].address.street).toBe("2nd Street"); + expect(state["urn:person1"].children.size).toBe(1); + expect([...state["urn:person1"].children][0]["@id"]).toBe("urn:child2"); + expect([...state["urn:person1"].children][0].age).toBe(12); + expect([...state["urn:person1"].tags]).toEqual(["parent"]); }); }); diff --git a/sdk/js/signals/src/connector/applyDiff.ts b/sdk/js/signals/src/connector/applyDiff.ts index f7da730..a99515f 100644 --- a/sdk/js/signals/src/connector/applyDiff.ts +++ b/sdk/js/signals/src/connector/applyDiff.ts @@ -21,14 +21,8 @@ export interface SetAddPatch { * New value for set mutations: * - A single primitive * - An array of primitives - * - An object (id -> object) for object "set" additions */ - value: - | number - | string - | boolean - | (number | string | boolean)[] - | { [id: string]: object }; + value: number | string | boolean | (number | string | boolean)[]; } export interface SetRemovePatch { @@ -37,8 +31,8 @@ export interface SetRemovePatch { valType: "set"; /** * The value(s) to be removed from the set. Either: - * - A single primitive / id - * - An array of primitives / ids + * - A single primitive + * - An array of primitives */ value: number | string | boolean | (number | string | boolean)[]; } @@ -67,57 +61,113 @@ function isPrimitive(v: unknown): v is string | number | boolean { ); } -// TODO: Escape slashes and tildes (~1, ~0) +/** + * Find an object in a Set by its @id property. + * Returns the object if found, otherwise undefined. + */ +function findInSetById(set: Set, id: string): any | undefined { + // TODO: We could optimize that by leveraging the key @id to object mapping in sets of deepSignals. + + for (const item of set) { + if (typeof item === "object" && item !== null && item["@id"] === id) { + return item; + } + } + return undefined; +} + /** * Apply a diff to an object. * - * * The syntax is inspired by RFC 6902 but it is not compatible. + * The syntax is inspired by RFC 6902 but it is not compatible. + * + * It supports Sets for multi-valued properties: + * - Primitive values are added as Sets (Set) + * - Multi-valued objects are stored in Sets, accessed by their @id property + * - Single objects are plain objects with an @id property + * + * Path traversal: + * - When traversing through a Set, the path segment is treated as an @id to find the object + * - When traversing through a plain object, the path segment is a property name * - * It supports sets: - * - Primitive values are added as sets, - * - Sets of objects are represented as objects with their id being the key. * @example operations * ```jsonc - * // Add one or more objects to a set. - * { "op": "add", "type": "set", "path": "/address", "value": { "ID1": {...}, "ID2": {...} } }, - * // Remove one or more objects from a set. - * { "op": "remove", "type": "set", "path": "/address", "value": ["ID1","ID2"] } - * // Add primitive types to a sets (URIs are treated just like strings) - * { "op": "add", "type": "set", "path": "/address", "value": [1,2,3] } - * // Remove primitive types from a set. - * { "op": "remove", "type": "set", "path": "/address", "value": [1,2] } + * // === SINGLE OBJECT === + * // Creating a single object (has @id at same level) + * { "op": "add", "path": "/urn:example:person1/address", "valType": "object" } + * { "op": "add", "path": "/urn:example:person1/address/@id", "value": "urn:test:address1" } + * // Adding primitives to single object + * { "op": "add", "path": "/urn:example:person1/address/street", "value": "1st street" } + * { "op": "add", "path": "/urn:example:person1/address/country", "value": "Greece" } + * // Remove a primitive from object + * { "op": "remove", "path": "/urn:example:person1/address/street" } + * // Remove the entire object + * { "op": "remove", "path": "/urn:example:person1/address" } + * + * // === MULTI-VALUED OBJECTS (Set) === + * // Creating a multi-object container (NO @id at this level -> creates Set) + * { "op": "add", "path": "/urn:example:person1/children", "valType": "object" } + * // Adding an object to the Set (path includes object's @id) + * { "op": "add", "path": "/urn:example:person1/children/urn:example:child1", "valType": "object" } + * { "op": "add", "path": "/urn:example:person1/children/urn:example:child1/@id", "value": "urn:example:child1" } + * // Adding properties to object in Set + * { "op": "add", "path": "/urn:example:person1/children/urn:example:child1/name", "value": "Alice" } + * // Remove an object from Set + * { "op": "remove", "path": "/urn:example:person1/children/urn:example:child1" } + * // Remove all objects (the Set itself) + * { "op": "remove", "path": "/urn:example:person1/children" } * - * // Creating an object. - * { "op": "add", "path": "/address", "type": "object" } - * // Adding primitives. - * { "op": "add", "path": "/address/street", value: "1st street" } - * { "op": "add", "path": "/address/country", value: "Greece" } - * // Remove a primitive. - * { "op": "remove", "path": "/address/street" } - * // Remove an object - * { "op": "remove", "path": "/address" } + * // === PRIMITIVE SETS === + * // Add primitive types to Sets + * { "op": "add", "valType": "set", "path": "/urn:example:person1/tags", "value": [1,2,3] } + * // Remove primitive types from a Set + * { "op": "remove", "valType": "set", "path": "/urn:example:person1/tags", "value": [1,2] } * ``` * * @param currentState The object before the patch - * @param diff An array of patches to apply to the object. + * @param patches An array of patches to apply to the object. * @param ensurePathExists If true, create nested objects along the path if the path does not exist. */ export function applyDiff( currentState: Record, - diff: Patch[], + patches: Patch[], ensurePathExists: boolean = false ) { - for (const patch of diff) { + for (let patchIndex = 0; patchIndex < patches.length; patchIndex++) { + const patch = patches[patchIndex]; if (!patch.path.startsWith("/")) continue; - const pathParts = patch.path.slice(1).split("/").filter(Boolean); + const pathParts = patch.path + .slice(1) + .split("/") + .filter(Boolean) + .map(decodePathSegment); if (pathParts.length === 0) continue; // root not supported const lastKey = pathParts[pathParts.length - 1]; let parentVal: any = currentState; let parentMissing = false; - // Traverse only intermediate segments + // Traverse only intermediate segments (to leaf object at path) for (let i = 0; i < pathParts.length - 1; i++) { const seg = pathParts[i]; + + // Handle Sets: if parentVal is a Set, find object by @id + if (parentVal instanceof Set) { + const foundObj = findInSetById(parentVal, seg); + if (foundObj) { + parentVal = foundObj; + } else if (ensurePathExists) { + // Create new object in the set with this @id + const newObj = { "@id": seg }; + parentVal.add(newObj); + parentVal = newObj; + } else { + parentMissing = true; + break; + } + continue; + } + + // Handle regular objects if ( parentVal != null && typeof parentVal === "object" && @@ -147,46 +197,71 @@ export function applyDiff( continue; } - // parentVal now should be an object into which we apply lastKey - if (parentVal == null || typeof parentVal !== "object") { + // parentVal now should be an object or Set into which we apply lastKey + if ( + parentVal == null || + (typeof parentVal !== "object" && !(parentVal instanceof Set)) + ) { console.warn( - `[applyDiff] Skipping patch because parent is not an object: ${patch.path}` + `[applyDiff] Skipping patch because parent is not an object or Set: ${patch.path}` ); continue; } const key = lastKey; - // If parent does not exist and we cannot create it, skip this patch - if (parentVal == null || typeof parentVal !== "object") continue; - // Handle set additions - if (patch.op === "add" && patch.valType === "set") { - const existing = parentVal[key]; - - // Normalize value - const raw = (patch as SetAddPatch).value; - if (raw == null) continue; + // Special handling when parent is a Set + if (parentVal instanceof Set) { + // The key represents the @id of an object within the Set + const targetObj = findInSetById(parentVal, key); - // Object-set (id -> object) - if ( - typeof raw === "object" && - !Array.isArray(raw) && - !isPrimitive(raw) - ) { - if ( - existing && - (existing instanceof Set || Array.isArray(existing)) - ) { - // Replace incompatible representation - parentVal[key] = {}; + // Handle object creation in a Set + if (patch.op === "add" && patch.valType === "object") { + if (!targetObj) { + // Determine if this will be a single object or nested Set + const hasId = patches + .at(patchIndex + 1) + ?.path.endsWith("@id"); + const newObj: any = hasId ? {} : new Set(); + // Pre-assign the @id so subsequent patches can find this object + if (hasId) { + newObj["@id"] = key; + } + parentVal.add(newObj); } - if (!parentVal[key] || typeof parentVal[key] !== "object") { - parentVal[key] = {}; + continue; + } + + // Handle remove from Set + if (patch.op === "remove" && !patch.valType) { + if (targetObj) { + parentVal.delete(targetObj); } - Object.assign(parentVal[key], raw); continue; } - // Set primitive(s) + // All other operations require the target object to exist + if (!targetObj) { + console.warn( + `[applyDiff] Target object with @id=${key} not found in Set for path: ${patch.path}` + ); + continue; + } + + // This shouldn't happen - we handle all intermediate segments in the traversal loop + console.warn( + `[applyDiff] Unexpected: reached end of path with Set as parent: ${patch.path}` + ); + continue; + } + + // Regular object handling (parentVal is a plain object, not a Set) + // Handle primitive set additions + if (patch.op === "add" && patch.valType === "set") { + const existing = parentVal[key]; + const raw = (patch as SetAddPatch).value; + if (raw == null) continue; + + // Normalize to array of primitives const toAdd: (string | number | boolean)[] = Array.isArray(raw) ? raw.filter(isPrimitive) : isPrimitive(raw) @@ -195,51 +270,48 @@ export function applyDiff( if (!toAdd.length) continue; + // Ensure we have a Set, create or add to existing if (existing instanceof Set) { for (const v of toAdd) existing.add(v); - } else if ( - existing && - typeof existing === "object" && - !Array.isArray(existing) && - !(existing instanceof Set) - ) { - // Existing is object-set (objects); adding primitives -> replace with Set - parentVal[key] = new Set(toAdd); } else { - // No existing or incompatible -> create a Set + // Create new Set (replaces any incompatible existing value) parentVal[key] = new Set(toAdd); } continue; } - // Handle set removals + // Handle primitive set removals if (patch.op === "remove" && patch.valType === "set") { const existing = parentVal[key]; const raw = (patch as SetRemovePatch).value; if (raw == null) continue; + const toRemove: (string | number | boolean)[] = Array.isArray(raw) ? raw : [raw]; if (existing instanceof Set) { for (const v of toRemove) existing.delete(v); - } else if (existing && typeof existing === "object") { - for (const v of toRemove) delete existing[v as any]; } continue; } - // Add object (ensure object exists) + // Add object (if it does not exist yet). + // Distinguish between single objects and multi-object containers: + // - If an @id patch follows for this path, it's a single object -> create {} + // - If no @id patch follows, it's a container for multi-valued objects -> create set. if (patch.op === "add" && patch.valType === "object") { - const cur = parentVal[key]; - if ( - cur === undefined || - cur === null || - typeof cur !== "object" || - cur instanceof Set - ) { + const leafVal = parentVal[key]; + const hasId = patches.at(patchIndex + 1)?.path.endsWith("@id"); + + // If the leafVal does not exist and it should be a set, create. + if (!hasId && !leafVal) { + parentVal[key] = new Set(); + } else if (!(typeof leafVal === "object")) { + // If the leave does not exist yet (as object), create it. parentVal[key] = {}; } + continue; } @@ -267,3 +339,7 @@ export function applyDiffToDeepSignal(currentState: object, diff: Patch[]) { applyDiff(currentState as Record, diff); }); } + +function decodePathSegment(segment: string): string { + return segment.replace("~1", "/").replace("~0", "~"); +} diff --git a/sdk/js/signals/src/connector/createSignalObjectForShape.ts b/sdk/js/signals/src/connector/createSignalObjectForShape.ts index 0665866..2ab3907 100644 --- a/sdk/js/signals/src/connector/createSignalObjectForShape.ts +++ b/sdk/js/signals/src/connector/createSignalObjectForShape.ts @@ -1,7 +1,7 @@ import type { Diff, Scope } from "../types.js"; import { applyDiff } from "./applyDiff.js"; -import type * as NG from "@ng-org/lib-wasm"; +import * as NG from "@ng-org/lib-wasm"; import { deepSignal, watch, batch } from "@ng-org/alien-deepsignals"; import type { DeepPatch, DeepSignalObject } from "@ng-org/alien-deepsignals"; @@ -42,9 +42,19 @@ function canonicalScope(scope: Scope | undefined): string { : String(scope); } +function decodePathSegment(segment: string): string { + return segment.replace("~1", "/").replace("~0", "~"); +} + +function escapePathSegment(segment: string): string { + return segment.replace("~", "~0").replace("/", "~1"); +} + export function deepPatchesToDiff(patches: DeepPatch[]): Diff { return patches.map((patch) => { - const path = "/" + patch.path.join("/"); + const path = + "/" + + patch.path.map((el) => escapePathSegment(el.toString())).join("/"); return { ...patch, path }; }) as Diff; } @@ -62,7 +72,10 @@ const recurseArrayToSet = (obj: any): any => { } }; -const setUpConnection = (entry: PoolEntry, wasmMessage: WasmMessage) => { +const handleInitialResponse = ( + entry: PoolEntry, + wasmMessage: WasmMessage +) => { const { connectionId, initialData } = wasmMessage; const { signalObject } = entry; @@ -125,7 +138,7 @@ const onMessage = (event: MessageEvent) => { if (type === "Stop") return; if (type === "InitialResponse") { - setUpConnection(entry, event.data); + handleInitialResponse(entry, event.data); } else if (type === "BackendUpdate" && diff) { applyDiff(entry.signalObject, diff); } else { @@ -133,6 +146,7 @@ const onMessage = (event: MessageEvent) => { } }; +// TODO: Should those be WeekMaps? const keyToEntry = new Map>(); const connectionIdToEntry = new Map>(); @@ -150,8 +164,15 @@ const cleanupSignalRegistry = }) : null; +/** + * + * @param shapeType + * @param scope + * @returns + */ export function createSignalObjectForShape( shapeType: ShapeType, + ng: typeof NG, scope?: Scope ) { const scopeKey = canonicalScope(scope); @@ -168,11 +189,13 @@ export function createSignalObjectForShape( } // Otherwise, create a new signal object and an entry for it. - const signalObject = deepSignal({}); + const signalObject = deepSignal(new Set()); + // Create entry to keep track of the connection with the backend. const entry: PoolEntry = { key, // The id for future communication between wasm and js land. + // TODO connectionId: `${key}_${new Date().toISOString()}`, shapeType, scopeKey, @@ -184,7 +207,7 @@ export function createSignalObjectForShape( readyPromise: Promise.resolve(), resolveReady: () => {}, // Function to manually release the connection. - // Only releases if no more references exist. + // Only releases if refCount is 0. release: () => { if (entry.refCount > 0) entry.refCount--; if (entry.refCount === 0) { diff --git a/sdk/js/signals/src/connector/ormConnectionHandler.ts b/sdk/js/signals/src/connector/ormConnectionHandler.ts new file mode 100644 index 0000000..6bb977b --- /dev/null +++ b/sdk/js/signals/src/connector/ormConnectionHandler.ts @@ -0,0 +1,217 @@ +import type { Diff as Patches, Scope } from "../types.ts"; +import { applyDiff } from "./applyDiff.ts"; + +import * as NG from "@ng-org/lib-wasm"; + +import { + deepSignal, + watch as watchDeepSignal, + batch, +} from "@ng-org/alien-deepsignals"; +import type { + DeepPatch, + DeepSignalObject, + WatchPatchCallback, + WatchPatchEvent, +} from "@ng-org/alien-deepsignals"; +import type { ShapeType, BaseType } from "@ng-org/shex-orm"; + +export class OrmConnection { + // TODO: WeakMaps? + private static idToEntry = new Map>(); + + private ng: typeof NG; + readonly shapeType: ShapeType; + readonly scope: Scope; + readonly signalObject: DeepSignalObject; + private refCount: number; + /*** Identifier as a combination of shape type and scope. Prevents duplications. */ + private identifier: string; + ready: boolean; + sessionId: number; + suspendDeepWatcher: boolean; + readyPromise: Promise; + // Promise that resolves once initial data has been applied. + resolveReady!: () => void; + + // FinalizationRegistry to clean up connections when signal objects are GC'd. + private static cleanupSignalRegistry = + typeof FinalizationRegistry === "function" + ? new FinalizationRegistry((connectionId) => { + // Best-effort fallback; look up by id and clean + const entry = this.idToEntry.get(connectionId); + if (!entry) return; + entry.release(); + }) + : null; + + private constructor(shapeType: ShapeType, scope: Scope, ng: typeof NG) { + this.shapeType = shapeType; + this.scope = scope; + this.ng = ng; + this.refCount = 0; + this.ready = false; + this.suspendDeepWatcher = false; + this.identifier = `${shapeType.shape}::${canonicalScope(scope)}`; + this.signalObject = deepSignal(new Set(), { + addIdToObjects: true, + idGenerator: this.generateSubjectIri, + }); + + // TODO: + this.sessionId = 1; + + // Schedule cleanup of the connection when the signal object is GC'd. + OrmConnection.cleanupSignalRegistry?.register( + this.signalObject, + this.identifier, + this.signalObject + ); + + // Add listener to deep signal object to report changes back to wasm land. + watchDeepSignal(this.signalObject as T, this.onSignalObjectUpdate); + + // Initialize per-entry readiness promise that resolves in setUpConnection + this.readyPromise = new Promise((resolve) => { + this.resolveReady = resolve; + }); + + // Establish connection to wasm land. + ng.orm_start(scope, shapeType, this.sessionId, this.onBackendMessage); + } + + /** + * Get a connection which contains the ORM and lifecycle methods. + * @param shapeType + * @param scope + * @param ng + * @returns + */ + public static getConnection( + shapeType: ShapeType, + scope: Scope, + ng: typeof NG + ): OrmConnection { + const scopeKey = canonicalScope(scope); + + // Unique identifier for a given shape type and scope. + const identifier = `${shapeType.shape}::${scopeKey}`; + + // If we already have an object for this shape+scope, + // return it and just increase the reference count. + // Otherwise, create new one. + const connection = + OrmConnection.idToEntry.get(identifier) ?? + new OrmConnection(shapeType, scope, ng); + + connection.refCount += 1; + + return connection; + } + + public release() { + if (this.refCount > 0) this.refCount--; + if (this.refCount === 0) { + OrmConnection.idToEntry.delete(this.identifier); + + OrmConnection.cleanupSignalRegistry?.unregister(this.signalObject); + } + } + + private onSignalObjectUpdate({ patches }: WatchPatchEvent) { + if (this.suspendDeepWatcher || !this.ready || !patches.length) return; + + const ormPatches = deepPatchesToDiff(patches); + + this.ng.orm_update( + this.scope, + this.shapeType.shape, + ormPatches, + this.sessionId + ); + } + + private onBackendMessage(...message: any) { + this.handleInitialResponse(message); + } + + private handleInitialResponse(...param: any) { + console.log("RESPONSE FROM BACKEND", param); + + // TODO: This will break, just provisionary. + const wasmMessage: WasmMessage = param; + const { initialData } = wasmMessage; + + // Assign initial data to empty signal object without triggering watcher at first. + this.suspendDeepWatcher = true; + batch(() => { + // Convert arrays to sets and apply to signalObject (we only have sets but can only transport arrays). + Object.assign(this.signalObject, recurseArrayToSet(initialData)!); + }); + + queueMicrotask(() => { + this.suspendDeepWatcher = false; + // Resolve readiness after initial data is committed and watcher armed. + this.resolveReady?.(); + }); + + this.ready = true; + } + private onBackendUpdate(...params: any) { + // Apply diff + } + + /** Function to create random subject IRIs for newly created nested objects. */ + private generateSubjectIri(path: (string | number)[]): string { + // Generate random string. + let b = Buffer.alloc(33); + crypto.getRandomValues(b); + const randomString = b.toString("base64url"); + + if (path.length > 0 && path[0].toString().startsWith("did:ng:o:")) { + // If the root is a nuri, use that as a base IRI. + let rootNuri = path[0] as string; + + return rootNuri.substring(0, 9 + 44) + ":q:" + randomString; + } else { + // Else, just generate a random IRI. + return "did:ng:q:" + randomString; + } + } +} + +// +// + +function escapePathSegment(segment: string): string { + return segment.replace("~", "~0").replace("/", "~1"); +} + +export function deepPatchesToDiff(patches: DeepPatch[]): Patches { + return patches.map((patch) => { + const path = + "/" + + patch.path.map((el) => escapePathSegment(el.toString())).join("/"); + return { ...patch, path }; + }) as Patches; +} + +const recurseArrayToSet = (obj: any): any => { + if (Array.isArray(obj)) { + return new Set(obj.map(recurseArrayToSet)); + } else if (obj && typeof obj === "object") { + for (const key of Object.keys(obj)) { + obj[key] = recurseArrayToSet(obj[key]); + } + return obj; + } else { + return obj; + } +}; + +function canonicalScope(scope: Scope | undefined): string { + if (scope == null) return ""; + return Array.isArray(scope) + ? scope.slice().sort().join(",") + : String(scope); +} From e979f0233afcfcab60e54392104e62f0160c1129 Mon Sep 17 00:00:00 2001 From: Laurin Weger Date: Sun, 19 Oct 2025 18:08:46 +0200 Subject: [PATCH 8/8] deep signals improvements --- sdk/js/alien-deepsignals/README.md | 8 +- sdk/js/alien-deepsignals/src/deepSignal.ts | 8 +- .../src/test/patchOptimized.test.ts | 62 ++-- .../alien-deepsignals/src/test/tier3.test.ts | 234 +++++++-------- .../alien-deepsignals/src/test/watch.test.ts | 154 +++++----- sdk/js/alien-deepsignals/src/watch.ts | 268 +++++++++--------- 6 files changed, 368 insertions(+), 366 deletions(-) diff --git a/sdk/js/alien-deepsignals/README.md b/sdk/js/alien-deepsignals/README.md index 9c13e9a..815950e 100644 --- a/sdk/js/alien-deepsignals/README.md +++ b/sdk/js/alien-deepsignals/README.md @@ -53,7 +53,7 @@ console.log(state.$count!()); // read via signal function ```ts type DeepSignalOptions = { - idGenerator?: () => string | number; // Custom ID generator function + idGenerator?: (pathToObject: (string | number)[]) => string | number; // Custom ID generator function addIdToObjects?: boolean; // Automatically add @id to plain objects }; ``` @@ -84,7 +84,7 @@ When `addIdToObjects: true`, plain objects automatically receive a readonly, enu const state = deepSignal( { data: {} }, { - idGenerator: () => `urn:uuid:${crypto.randomUUID()}`, + idGenerator: (_path) => `urn:uuid:${crypto.randomUUID()}`, addIdToObjects: true } ); @@ -191,7 +191,7 @@ import { addWithId, setSetEntrySyntheticId } from "@ng-org/alien-deepsignals"; const state = deepSignal( { items: new Set() }, { - idGenerator: () => `urn:uuid:${crypto.randomUUID()}`, + idGenerator: (_path) => `urn:uuid:${crypto.randomUUID()}`, addIdToObjects: true } ); @@ -250,7 +250,7 @@ state.s.add({ data: "test" }); const state = deepSignal( { users: new Set() }, { - idGenerator: () => `urn:user:${crypto.randomUUID()}`, + idGenerator: (path) => `urn:user:${path.join("-")}:${crypto.randomUUID()}`, addIdToObjects: true } ); diff --git a/sdk/js/alien-deepsignals/src/deepSignal.ts b/sdk/js/alien-deepsignals/src/deepSignal.ts index 792cbb5..b37f99d 100644 --- a/sdk/js/alien-deepsignals/src/deepSignal.ts +++ b/sdk/js/alien-deepsignals/src/deepSignal.ts @@ -59,7 +59,7 @@ export type DeepPatchSubscriber = (patches: DeepPatch[]) => void; /** Options for configuring deepSignal behavior. */ export interface DeepSignalOptions { /** Custom function to generate synthetic IDs for objects without @id. */ - idGenerator?: () => string | number; + idGenerator?: (pathToObject: (string | number)[]) => string | number; /** If true, add @id property to all objects in the tree. */ addIdToObjects?: boolean; } @@ -156,7 +156,7 @@ function queueDeepPatches( ) { let syntheticId: string | number; if (options.idGenerator) { - syntheticId = options.idGenerator(); + syntheticId = options.idGenerator(basePath); } else { syntheticId = assignBlankNodeId(val); } @@ -516,6 +516,7 @@ function getFromSet( }; // Pre-pass to ensure any existing non-proxied object entries are proxied (enables deep patches after iteration) if (meta) raw.forEach(ensureEntryProxy); + if (key === "add" || key === "delete" || key === "clear") { const fn: Function = (raw as any)[key]; return function (this: any, ...args: any[]) { @@ -545,7 +546,8 @@ function getFromSet( ) { let syntheticId: string | number; if (metaNow.options.idGenerator) { - syntheticId = metaNow.options.idGenerator(); + syntheticId = + metaNow.options.idGenerator(containerPath); } else { syntheticId = assignBlankNodeId(entry); } diff --git a/sdk/js/alien-deepsignals/src/test/patchOptimized.test.ts b/sdk/js/alien-deepsignals/src/test/patchOptimized.test.ts index 6922473..5e129c7 100644 --- a/sdk/js/alien-deepsignals/src/test/patchOptimized.test.ts +++ b/sdk/js/alien-deepsignals/src/test/patchOptimized.test.ts @@ -8,40 +8,40 @@ import { watch } from "../watch"; // times traverse() executes under each strategy. describe("watch patch-only simplified performance placeholder", () => { - let store: any; - const build = (breadth = 3, depth = 3) => { - const make = (d: number): any => { - if (d === 0) return { v: 0 }; - const obj: any = {}; - for (let i = 0; i < breadth; i++) obj["k" + i] = make(d - 1); - return obj; + let store: any; + const build = (breadth = 3, depth = 3) => { + const make = (d: number): any => { + if (d === 0) return { v: 0 }; + const obj: any = {}; + for (let i = 0; i < breadth; i++) obj["k" + i] = make(d - 1); + return obj; + }; + return make(depth); }; - return make(depth); - }; - beforeEach(() => { - store = deepSignal(build()); - }); + beforeEach(() => { + store = deepSignal(build()); + }); - function mutateAll(breadth = 3, depth = 3) { - const visit = (node: any, d: number) => { - if (d === 0) { - node.v++; - return; - } - for (let i = 0; i < breadth; i++) visit(node["k" + i], d - 1); - }; - visit(store, depth); - } + function mutateAll(breadth = 3, depth = 3) { + const visit = (node: any, d: number) => { + if (d === 0) { + node.v++; + return; + } + for (let i = 0; i < breadth; i++) visit(node["k" + i], d - 1); + }; + visit(store, depth); + } - it("receives a single batch of patches after deep mutations", async () => { - let batches = 0; - const { stopListening: stop } = watch(store, ({ patches }) => { - if (patches.length) batches++; + it("receives a single batch of patches after deep mutations", async () => { + let batches = 0; + const { stopListening: stop } = watch(store, ({ patches }) => { + if (patches.length) batches++; + }); + mutateAll(); + await Promise.resolve(); + expect(batches).toBe(1); + stop(); }); - mutateAll(); - await Promise.resolve(); - expect(batches).toBe(1); - stop(); - }); }); diff --git a/sdk/js/alien-deepsignals/src/test/tier3.test.ts b/sdk/js/alien-deepsignals/src/test/tier3.test.ts index ba54feb..1ce5273 100644 --- a/sdk/js/alien-deepsignals/src/test/tier3.test.ts +++ b/sdk/js/alien-deepsignals/src/test/tier3.test.ts @@ -1,148 +1,148 @@ import { describe, it, expect } from "vitest"; import { deepSignal, getDeepSignalRootId, peek } from "../deepSignal"; import { - watch, - __traverseCount, - __resetTraverseCount, - traverse, + watch, + __traverseCount, + __resetTraverseCount, + traverse, } from "../watch"; import { effect } from "../core"; describe("watch advanced", () => { - it("basic patch watcher fires on deep mutations", async () => { - const st = deepSignal({ a: { b: { c: 1 } } }); - let batches: number = 0; - watch(st, ({ patches }) => { - if (patches.length) batches++; + it("basic patch watcher fires on deep mutations", async () => { + const st = deepSignal({ a: { b: { c: 1 } } }); + let batches: number = 0; + watch(st, ({ patches }) => { + if (patches.length) batches++; + }); + st.a.b.c = 2; + st.a.b = { c: 3 } as any; + await Promise.resolve(); + expect(batches).toBeGreaterThan(0); }); - st.a.b.c = 2; - st.a.b = { c: 3 } as any; - await Promise.resolve(); - expect(batches).toBeGreaterThan(0); - }); - // multi-source value mode removed; patch-only now - skip equivalent + // multi-source value mode removed; patch-only now - skip equivalent - // getter source value mode removed in patch-only watcher + // getter source value mode removed in patch-only watcher - it("watch once option still stops after first batch", async () => { - const st = deepSignal({ a: 1 }); - let count = 0; - watch( - st, - () => { - count++; - }, - { once: true, immediate: true } - ); - st.a = 2; - st.a = 3; - await Promise.resolve(); - expect(count).toBe(1); - }); + it("watch once option still stops after first batch", async () => { + const st = deepSignal({ a: 1 }); + let count = 0; + watch( + st, + () => { + count++; + }, + { once: true, immediate: true } + ); + st.a = 2; + st.a = 3; + await Promise.resolve(); + expect(count).toBe(1); + }); - // observe value mode removed; observe is alias of watch + // observe value mode removed; observe is alias of watch }); describe("patches & root ids", () => { - it("root ids are unique", () => { - const a = deepSignal({}); - const b = deepSignal({}); - expect(getDeepSignalRootId(a)).not.toBe(getDeepSignalRootId(b)); - }); + it("root ids are unique", () => { + const a = deepSignal({}); + const b = deepSignal({}); + expect(getDeepSignalRootId(a)).not.toBe(getDeepSignalRootId(b)); + }); - // legacy watchPatches API removed; patch mode only valid for deepSignal roots - it("watch throws on non-deepSignal input", () => { - expect(() => watch({} as any, () => {})).toThrow(); - }); + // legacy watchPatches API removed; patch mode only valid for deepSignal roots + it("watch throws on non-deepSignal input", () => { + expect(() => watch({} as any, () => {})).toThrow(); + }); - it("Map unsupported does not emit patches", async () => { - const m = new Map(); - const st = deepSignal({ m }); - const patches: any[] = []; - const { stopListening: stop } = watch(st, ({ patches: batch }) => - patches.push(batch) - ); - m.set("a", 1); - await Promise.resolve(); - await Promise.resolve(); - expect(patches.length).toBe(0); - stop(); - }); + it("Map unsupported does not emit patches", async () => { + const m = new Map(); + const st = deepSignal({ m }); + const patches: any[] = []; + const { stopListening: stop } = watch(st, ({ patches: batch }) => + patches.push(batch) + ); + m.set("a", 1); + await Promise.resolve(); + await Promise.resolve(); + expect(patches.length).toBe(0); + stop(); + }); }); describe("tier3: Set iteration variants", () => { - it("entries() iteration proxies nested mutation", async () => { - const st = deepSignal({ s: new Set() }); - st.s.add({ id: "eEnt", inner: { v: 1 } }); - const paths: string[] = []; - const { stopListening: stop } = watch(st, ({ patches }) => - paths.push(...patches.map((pp: any) => pp.path.join("."))) - ); - for (const [val] of st.s.entries()) { - (val as any).inner.v; - } // ensure proxy - for (const [val] of st.s.entries()) { - (val as any).inner.v = 2; - } - await Promise.resolve(); - await Promise.resolve(); - expect(paths.some((p) => p.endsWith("eEnt.inner.v"))).toBe(true); - stop(); - }); + it("entries() iteration proxies nested mutation", async () => { + const st = deepSignal({ s: new Set() }); + st.s.add({ id: "eEnt", inner: { v: 1 } }); + const paths: string[] = []; + const { stopListening: stop } = watch(st, ({ patches }) => + paths.push(...patches.map((pp: any) => pp.path.join("."))) + ); + for (const [val] of st.s.entries()) { + (val as any).inner.v; + } // ensure proxy + for (const [val] of st.s.entries()) { + (val as any).inner.v = 2; + } + await Promise.resolve(); + await Promise.resolve(); + expect(paths.some((p) => p.endsWith("eEnt.inner.v"))).toBe(true); + stop(); + }); - it("forEach iteration proxies nested mutation", async () => { - const st = deepSignal({ s: new Set() }); - st.s.add({ id: "fe1", data: { n: 1 } }); - const { stopListening: stop } = watch(st, () => {}); - st.s.forEach((e) => (e as any).data.n); // access - st.s.forEach((e) => { - (e as any).data.n = 2; + it("forEach iteration proxies nested mutation", async () => { + const st = deepSignal({ s: new Set() }); + st.s.add({ id: "fe1", data: { n: 1 } }); + const { stopListening: stop } = watch(st, () => {}); + st.s.forEach((e) => (e as any).data.n); // access + st.s.forEach((e) => { + (e as any).data.n = 2; + }); + await Promise.resolve(); + await Promise.resolve(); + stop(); }); - await Promise.resolve(); - await Promise.resolve(); - stop(); - }); - it("keys() iteration returns proxies", async () => { - const st = deepSignal({ s: new Set() }); - st.s.add({ id: "k1", foo: { x: 1 } }); - const { stopListening: stop } = watch(st, () => {}); - for (const e of st.s.keys()) { - (e as any).foo.x = 2; - } - await Promise.resolve(); - await Promise.resolve(); - stop(); - }); + it("keys() iteration returns proxies", async () => { + const st = deepSignal({ s: new Set() }); + st.s.add({ id: "k1", foo: { x: 1 } }); + const { stopListening: stop } = watch(st, () => {}); + for (const e of st.s.keys()) { + (e as any).foo.x = 2; + } + await Promise.resolve(); + await Promise.resolve(); + stop(); + }); }); describe("tier3: peek behavior", () => { - it("peek does not create reactive dependency on property", async () => { - const st = deepSignal({ a: 1 }); - let runs = 0; - effect(() => { - runs++; - peek(st, "a"); + it("peek does not create reactive dependency on property", async () => { + const st = deepSignal({ a: 1 }); + let runs = 0; + effect(() => { + runs++; + peek(st, "a"); + }); + expect(runs).toBe(1); + st.a = 2; + // Flush microtasks + await Promise.resolve(); + await Promise.resolve(); + expect(runs).toBe(1); // no rerun }); - expect(runs).toBe(1); - st.a = 2; - // Flush microtasks - await Promise.resolve(); - await Promise.resolve(); - expect(runs).toBe(1); // no rerun - }); }); describe("tier3: traverse helper direct calls (symbols & sets)", () => { - it("traverse counts and respects depth param", () => { - __resetTraverseCount(); - const obj: any = { a: { b: { c: 1 } } }; - traverse(obj, 1); - const shallowCount = __traverseCount; - __resetTraverseCount(); - traverse(obj, 3); - const deepCount = __traverseCount; - expect(deepCount).toBeGreaterThan(shallowCount); - }); + it("traverse counts and respects depth param", () => { + __resetTraverseCount(); + const obj: any = { a: { b: { c: 1 } } }; + traverse(obj, 1); + const shallowCount = __traverseCount; + __resetTraverseCount(); + traverse(obj, 3); + const deepCount = __traverseCount; + expect(deepCount).toBeGreaterThan(shallowCount); + }); }); diff --git a/sdk/js/alien-deepsignals/src/test/watch.test.ts b/sdk/js/alien-deepsignals/src/test/watch.test.ts index 4ba399f..06291de 100644 --- a/sdk/js/alien-deepsignals/src/test/watch.test.ts +++ b/sdk/js/alien-deepsignals/src/test/watch.test.ts @@ -4,88 +4,88 @@ import { watch } from "../watch"; import { watchEffect } from "../watchEffect"; describe("watch", () => { - it("watch immediate", () => { - const store = deepSignal({ - userinfo: { - name: "tom", - }, + it("watch immediate", () => { + const store = deepSignal({ + userinfo: { + name: "tom", + }, + }); + let val!: string; + watch( + store, + ({ newValue }) => { + val = newValue.userinfo.name; + }, + { immediate: true } + ); + expect(val).toEqual("tom"); }); - let val!: string; - watch( - store, - ({ newValue }) => { - val = newValue.userinfo.name; - }, - { immediate: true } - ); - expect(val).toEqual("tom"); - }); - it("watch deep", () => { - const store = deepSignal({ - userinfo: { - name: "tom", - }, + it("watch deep", () => { + const store = deepSignal({ + userinfo: { + name: "tom", + }, + }); + let val!: string; + watch( + store, + ({ newValue }) => { + val = newValue.userinfo.name; + }, + { immediate: true } + ); + let value2!: string; + watch( + store, + ({ newValue }) => { + value2 = newValue.userinfo.name; + }, + { immediate: true } + ); + expect(val).toEqual("tom"); + store.userinfo.name = "jon"; + // patch delivery async (microtask) + return Promise.resolve().then(() => { + expect(val).toEqual("jon"); + // With refactored watch using native effect, shallow watcher now also updates root reference + expect(value2).toEqual("jon"); + }); }); - let val!: string; - watch( - store, - ({ newValue }) => { - val = newValue.userinfo.name; - }, - { immediate: true } - ); - let value2!: string; - watch( - store, - ({ newValue }) => { - value2 = newValue.userinfo.name; - }, - { immediate: true } - ); - expect(val).toEqual("tom"); - store.userinfo.name = "jon"; - // patch delivery async (microtask) - return Promise.resolve().then(() => { - expect(val).toEqual("jon"); - // With refactored watch using native effect, shallow watcher now also updates root reference - expect(value2).toEqual("jon"); - }); - }); - it("watch once", () => { - const store = deepSignal({ - userinfo: { - name: "tom", - }, + it("watch once", () => { + const store = deepSignal({ + userinfo: { + name: "tom", + }, + }); + let val!: string; + watch( + store, + ({ newValue }) => { + val = newValue.userinfo.name; + }, + { immediate: true, once: true } + ); + + expect(val).toEqual("tom"); + store.userinfo.name = "jon"; + // once watcher shouldn't update after first run + expect(val).toEqual("tom"); }); - let val!: string; - watch( - store, - ({ newValue }) => { - val = newValue.userinfo.name; - }, - { immediate: true, once: true } - ); - expect(val).toEqual("tom"); - store.userinfo.name = "jon"; - // once watcher shouldn't update after first run - expect(val).toEqual("tom"); - }); + it("watch effect", () => { + const store = deepSignal({ + userinfo: { + name: "tom", + }, + }); + let x = undefined; + watchEffect(() => { + x = store.userinfo.name; + }); - it("watch effect", () => { - const store = deepSignal({ - userinfo: { - name: "tom", - }, + expect(x).toEqual("tom"); + store.userinfo.name = "jon"; + expect(x).toEqual("jon"); }); - let x = undefined; - watchEffect(() => { - x = store.userinfo.name; - }); - - expect(x).toEqual("tom"); - store.userinfo.name = "jon"; - expect(x).toEqual("jon"); - }); }); diff --git a/sdk/js/alien-deepsignals/src/watch.ts b/sdk/js/alien-deepsignals/src/watch.ts index f3784c1..af737a0 100644 --- a/sdk/js/alien-deepsignals/src/watch.ts +++ b/sdk/js/alien-deepsignals/src/watch.ts @@ -1,10 +1,10 @@ import { isObject, isPlainObject, isSet, isMap, isArray } from "./utils"; import { isSignal } from "./core"; import { - isDeepSignal, - subscribeDeepMutations, - getDeepSignalRootId, - DeepPatch, + isDeepSignal, + subscribeDeepMutations, + getDeepSignalRootId, + DeepPatch, } from "./deepSignal"; import { ReactiveFlags } from "./contents"; @@ -15,125 +15,125 @@ export type WatchEffect = (registerCleanup: RegisterCleanup) => void; /** Options for {@link watch}. */ export interface WatchOptions { - /** Trigger the callback immediately with the current value (default: false). */ - immediate?: boolean; - /** Auto-stop the watcher after the first callback run that delivers patches (or immediate call if no patches). */ - once?: boolean; - /** Allow legacy/unknown options (ignored) to avoid hard breaks while migrating. */ - // eslint-disable-next-line @typescript-eslint/no-explicit-any - [legacy: string]: any; + /** Trigger the callback immediately with the current value (default: false). */ + immediate?: boolean; + /** Auto-stop the watcher after the first callback run that delivers patches (or immediate call if no patches). */ + once?: boolean; + /** Allow legacy/unknown options (ignored) to avoid hard breaks while migrating. */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + [legacy: string]: any; } export interface WatchPatchEvent { - /** Patch batch that triggered this callback (may be empty for immediate). */ - patches: DeepPatch[]; - /** Previous snapshot (deep-cloned) of the root value before these patches. Undefined on first call. */ - oldValue: Root | undefined; - /** Current root value (live proxy). */ - newValue: Root; + /** Patch batch that triggered this callback (may be empty for immediate). */ + patches: DeepPatch[]; + /** Previous snapshot (deep-cloned) of the root value before these patches. Undefined on first call. */ + oldValue: Root | undefined; + /** Current root value (live proxy). */ + newValue: Root; } export type WatchPatchCallback = ( - event: WatchPatchEvent + event: WatchPatchEvent ) => any; // Internal helper kept for external compatibility. export const remove = (arr: T[], el: T): void => { - const i = arr.indexOf(el); - if (i > -1) arr.splice(i, 1); + const i = arr.indexOf(el); + if (i > -1) arr.splice(i, 1); }; /** Observe patch batches on a deep signal root. */ export function watch( - source: Root, - callback: WatchPatchCallback, - options: WatchOptions = {} + source: Root, + callback: WatchPatchCallback, + options: WatchOptions = {} ) { - if (!isDeepSignal(source)) { - throw new Error( - "watch() now only supports deepSignal roots (patch mode only)" - ); - } - const { immediate, once } = options; - - const rootId = getDeepSignalRootId(source as any)!; - - let active = true; - let cleanup: (() => void) | undefined; - const registerCleanup: RegisterCleanup = (fn) => { - cleanup = fn; - }; - const runCleanup = () => { - if (cleanup) { - try { - cleanup(); - } catch { - /* ignore */ - } finally { - cleanup = undefined; - } + if (!isDeepSignal(source)) { + throw new Error( + "watch() now only supports deepSignal roots (patch mode only)" + ); } - }; - - // Deep clone snapshot helper (JSON clone sufficient for typical reactive plain data) - const clone = (v: any) => { - try { - return JSON.parse(JSON.stringify(v)); - } catch { - return undefined as any; - } - }; - let lastSnapshot: Root | undefined = clone(source); - - const stopListening = () => { - if (!active) return; - active = false; - runCleanup(); - unsubscribe && unsubscribe(); - }; - - const deliver = (patches: DeepPatch[]) => { - if (!active) return; - runCleanup(); - const prev = lastSnapshot; - const next = source as any as Root; // live proxy - try { - callback({ - patches, - oldValue: prev, - newValue: next, - }); - } finally { - if (active) lastSnapshot = clone(next); - if (once) stopListening(); + const { immediate, once } = options; + + const rootId = getDeepSignalRootId(source as any)!; + + let active = true; + let cleanup: (() => void) | undefined; + const registerCleanup: RegisterCleanup = (fn) => { + cleanup = fn; + }; + const runCleanup = () => { + if (cleanup) { + try { + cleanup(); + } catch { + /* ignore */ + } finally { + cleanup = undefined; + } + } + }; + + // Deep clone snapshot helper (JSON clone sufficient for typical reactive plain data) + const clone = (v: any) => { + try { + return JSON.parse(JSON.stringify(v)); + } catch { + return undefined as any; + } + }; + let lastSnapshot: Root | undefined = clone(source); + + const stopListening = () => { + if (!active) return; + active = false; + runCleanup(); + unsubscribe && unsubscribe(); + }; + + const deliver = (patches: DeepPatch[]) => { + if (!active) return; + runCleanup(); + const prev = lastSnapshot; + const next = source as any as Root; // live proxy + try { + callback({ + patches, + oldValue: prev, + newValue: next, + }); + } finally { + if (active) lastSnapshot = clone(next); + if (once) stopListening(); + } + }; + + const unsubscribe = subscribeDeepMutations(rootId, (patches) => { + if (!patches.length) return; // ignore empty batches + deliver(patches); + }); + + if (immediate) { + // Immediate call with empty patch list (snapshot only) + deliver([]); } - }; - - const unsubscribe = subscribeDeepMutations(rootId, (patches) => { - if (!patches.length) return; // ignore empty batches - deliver(patches); - }); - - if (immediate) { - // Immediate call with empty patch list (snapshot only) - deliver([]); - } - - return { - /** Stop listening to future patch batches; idempotent. */ - stopListening, - /** Register a cleanup callback run before the next invocation / stop. */ - registerCleanup, - }; + + return { + /** Stop listening to future patch batches; idempotent. */ + stopListening, + /** Register a cleanup callback run before the next invocation / stop. */ + registerCleanup, + }; } // observe alias export function observe( - source: any, - cb: WatchPatchCallback, - options?: WatchOptions + source: any, + cb: WatchPatchCallback, + options?: WatchOptions ) { - return watch(source, cb, options); + return watch(source, cb, options); } // Instrumentation counter for performance tests (number of traverse invocations) @@ -141,7 +141,7 @@ export function observe( export let __traverseCount = 0; // retained for external tooling/tests although watch no longer uses traversal /** Reset the traversal instrumentation counter back to 0. */ export function __resetTraverseCount() { - __traverseCount = 0; + __traverseCount = 0; } /** @@ -149,40 +149,40 @@ export function __resetTraverseCount() { * Depth-limited; protects against cycles via `seen` set; respects ReactiveFlags.SKIP opt-out. */ export function traverse( - value: unknown, - depth: number = Infinity, - seen?: Set + value: unknown, + depth: number = Infinity, + seen?: Set ): unknown { - __traverseCount++; - if (depth <= 0 || !isObject(value) || (value as any)[ReactiveFlags.SKIP]) { - return value; - } - - seen = seen || new Set(); - if (seen.has(value)) { - return value; - } - seen.add(value); - depth--; - if (isSignal(value)) { - traverse((value as any)(), depth, seen); - } else if (isArray(value)) { - for (let i = 0; i < value.length; i++) { - traverse(value[i], depth, seen); + __traverseCount++; + if (depth <= 0 || !isObject(value) || (value as any)[ReactiveFlags.SKIP]) { + return value; } - } else if (isSet(value) || isMap(value)) { - value.forEach((v: any) => { - traverse(v, depth, seen); - }); - } else if (isPlainObject(value)) { - for (const key in value) { - traverse(value[key], depth, seen); + + seen = seen || new Set(); + if (seen.has(value)) { + return value; } - for (const key of Object.getOwnPropertySymbols(value)) { - if (Object.prototype.propertyIsEnumerable.call(value, key)) { - traverse(value[key as any], depth, seen); - } + seen.add(value); + depth--; + if (isSignal(value)) { + traverse((value as any)(), depth, seen); + } else if (isArray(value)) { + for (let i = 0; i < value.length; i++) { + traverse(value[i], depth, seen); + } + } else if (isSet(value) || isMap(value)) { + value.forEach((v: any) => { + traverse(v, depth, seen); + }); + } else if (isPlainObject(value)) { + for (const key in value) { + traverse(value[key], depth, seen); + } + for (const key of Object.getOwnPropertySymbols(value)) { + if (Object.prototype.propertyIsEnumerable.call(value, key)) { + traverse(value[key as any], depth, seen); + } + } } - } - return value; + return value; }