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); +}