From 8f6586b71e380182f97fcd40b56441a12f333317 Mon Sep 17 00:00:00 2001 From: Laurin Weger Date: Sun, 19 Oct 2025 13:28:57 +0200 Subject: [PATCH] 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(); });