From 7df6ebfab2841d95fcb7bc75fb0c4e0d6b90cc0e Mon Sep 17 00:00:00 2001 From: Kris Nye Date: Tue, 14 Jul 2026 13:39:20 -0700 Subject: [PATCH] feat(data): add Observe.fromKeys reactive keyed-collection combinator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fromKeys(keys$, observeChild) maps an observable list of keys to an observable of per-key child values in key order. Child subscriptions are keyed by identity and reused across list changes — only added/removed keys create/dispose a child — so a value change on one child re-emits just its slot without recomputing or re-subscribing the others. Emits once every current child has yielded (synchronously when all children are synchronous); re-emits on key-list change or any child emission. This is the dynamic, keyed counterpart to fromArray / fromProperties (which combine a fixed set of observe functions). It generalizes the local `fromEntities` helper written in firefly-platform's avTimelinePayload reactive-graph refactor; that consumer can now import this instead. Peer tests cover ordered emission, per-slot re-emit with sibling reuse, key add/remove/reorder, waiting for all children, empty list, and disposal. Co-Authored-By: Claude Opus 4.8 --- package.json | 2 +- packages/data-ai/package.json | 2 +- packages/data-gpu-samples/package.json | 2 +- packages/data-gpu/package.json | 2 +- packages/data-lit-tictactoe/package.json | 2 +- packages/data-lit-todo/package.json | 2 +- packages/data-lit/package.json | 2 +- packages/data-p2p-tictactoe/package.json | 2 +- packages/data-persistence/package.json | 2 +- packages/data-react-hello/package.json | 2 +- packages/data-react-pixie/package.json | 2 +- packages/data-react/package.json | 2 +- packages/data-solid-dashboard/package.json | 2 +- packages/data-solid/package.json | 2 +- packages/data-sync/package.json | 2 +- packages/data/package.json | 2 +- packages/data/src/observe/from-keys.test.ts | 165 ++++++++++++++++++++ packages/data/src/observe/from-keys.ts | 89 +++++++++++ packages/data/src/observe/public.ts | 1 + 19 files changed, 271 insertions(+), 16 deletions(-) create mode 100644 packages/data/src/observe/from-keys.test.ts create mode 100644 packages/data/src/observe/from-keys.ts diff --git a/package.json b/package.json index f20c73f3..10060782 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "data-monorepo", - "version": "0.9.79", + "version": "0.9.80", "private": true, "scripts": { "build": "pnpm -r run build", diff --git a/packages/data-ai/package.json b/packages/data-ai/package.json index 58850c04..04b01090 100644 --- a/packages/data-ai/package.json +++ b/packages/data-ai/package.json @@ -1,6 +1,6 @@ { "name": "@adobe/data-ai", - "version": "0.9.79", + "version": "0.9.80", "description": "Cross-agent architecture skills for @adobe/data — installable as a Claude Code plugin or copied into any Agent-Skills-compatible agent (Cursor, Codex).", "type": "module", "private": false, diff --git a/packages/data-gpu-samples/package.json b/packages/data-gpu-samples/package.json index 209809c6..bbd06fab 100644 --- a/packages/data-gpu-samples/package.json +++ b/packages/data-gpu-samples/package.json @@ -1,6 +1,6 @@ { "name": "data-gpu-samples", - "version": "0.9.79", + "version": "0.9.80", "description": "WebGPU samples built on @adobe/data-gpu", "type": "module", "private": true, diff --git a/packages/data-gpu/package.json b/packages/data-gpu/package.json index 217be77e..73cd3462 100644 --- a/packages/data-gpu/package.json +++ b/packages/data-gpu/package.json @@ -1,6 +1,6 @@ { "name": "@adobe/data-gpu", - "version": "0.9.79", + "version": "0.9.80", "description": "Adobe data WebGPU plugins and types for graphics and compute", "type": "module", "private": false, diff --git a/packages/data-lit-tictactoe/package.json b/packages/data-lit-tictactoe/package.json index 850ad4fe..582b434d 100644 --- a/packages/data-lit-tictactoe/package.json +++ b/packages/data-lit-tictactoe/package.json @@ -1,6 +1,6 @@ { "name": "data-lit-tictactoe", - "version": "0.9.79", + "version": "0.9.80", "description": "Tic-Tac-Toe sample - Lit web components with @adobe/data-lit and AgenticService", "type": "module", "private": true, diff --git a/packages/data-lit-todo/package.json b/packages/data-lit-todo/package.json index 03364236..47dd03dc 100644 --- a/packages/data-lit-todo/package.json +++ b/packages/data-lit-todo/package.json @@ -1,6 +1,6 @@ { "name": "data-lit-todo", - "version": "0.9.79", + "version": "0.9.80", "description": "Todo sample app demonstrating @adobe/data with Lit", "type": "module", "private": true, diff --git a/packages/data-lit/package.json b/packages/data-lit/package.json index 5300f708..ad3d432e 100644 --- a/packages/data-lit/package.json +++ b/packages/data-lit/package.json @@ -1,6 +1,6 @@ { "name": "@adobe/data-lit", - "version": "0.9.79", + "version": "0.9.80", "description": "Adobe data Lit bindings - hooks, elements, decorators", "type": "module", "private": false, diff --git a/packages/data-p2p-tictactoe/package.json b/packages/data-p2p-tictactoe/package.json index 6f0c5ac4..cd861eeb 100644 --- a/packages/data-p2p-tictactoe/package.json +++ b/packages/data-p2p-tictactoe/package.json @@ -1,6 +1,6 @@ { "name": "data-p2p-tictactoe", - "version": "0.9.79", + "version": "0.9.80", "description": "Serverless P2P tic-tac-toe — WebRTC DataChannel + @adobe/data-sync", "type": "module", "private": true, diff --git a/packages/data-persistence/package.json b/packages/data-persistence/package.json index 54e53bf6..b08dd78d 100644 --- a/packages/data-persistence/package.json +++ b/packages/data-persistence/package.json @@ -1,6 +1,6 @@ { "name": "@adobe/data-persistence", - "version": "0.9.79", + "version": "0.9.80", "description": "Worker-based incremental persistence layer for @adobe/data ECS over OPFS (browser) and node:fs (server).", "type": "module", "sideEffects": false, diff --git a/packages/data-react-hello/package.json b/packages/data-react-hello/package.json index d236e0bd..a7ca84f6 100644 --- a/packages/data-react-hello/package.json +++ b/packages/data-react-hello/package.json @@ -1,6 +1,6 @@ { "name": "data-react-hello", - "version": "0.9.79", + "version": "0.9.80", "description": "Hello World sample - click counter using @adobe/data-react", "type": "module", "private": true, diff --git a/packages/data-react-pixie/package.json b/packages/data-react-pixie/package.json index e71020b3..a09dda55 100644 --- a/packages/data-react-pixie/package.json +++ b/packages/data-react-pixie/package.json @@ -1,6 +1,6 @@ { "name": "data-react-pixie", - "version": "0.9.79", + "version": "0.9.80", "description": "PixiJS React sample - ECS sprites (bunny, fox) with @adobe/data-react", "type": "module", "private": true, diff --git a/packages/data-react/package.json b/packages/data-react/package.json index 205aa0f5..5b9bdb85 100644 --- a/packages/data-react/package.json +++ b/packages/data-react/package.json @@ -1,6 +1,6 @@ { "name": "@adobe/data-react", - "version": "0.9.79", + "version": "0.9.80", "description": "Adobe data React bindings — hooks and context for ECS database", "type": "module", "private": false, diff --git a/packages/data-solid-dashboard/package.json b/packages/data-solid-dashboard/package.json index 62b3e662..25aa6ac5 100644 --- a/packages/data-solid-dashboard/package.json +++ b/packages/data-solid-dashboard/package.json @@ -1,6 +1,6 @@ { "name": "data-solid-dashboard", - "version": "0.9.79", + "version": "0.9.80", "description": "Mini dashboard sample — multiple components sharing one @adobe/data ECS database with SolidJS", "type": "module", "private": true, diff --git a/packages/data-solid/package.json b/packages/data-solid/package.json index ae2cfff9..abe279df 100644 --- a/packages/data-solid/package.json +++ b/packages/data-solid/package.json @@ -1,6 +1,6 @@ { "name": "@adobe/data-solid", - "version": "0.9.79", + "version": "0.9.80", "description": "Adobe data SolidJS bindings — context and provider for ECS database", "type": "module", "private": false, diff --git a/packages/data-sync/package.json b/packages/data-sync/package.json index f328cebd..08f9bd73 100644 --- a/packages/data-sync/package.json +++ b/packages/data-sync/package.json @@ -1,6 +1,6 @@ { "name": "@adobe/data-sync", - "version": "0.9.79", + "version": "0.9.80", "description": "Multi-user real-time synchronisation for @adobe/data ECS — server, client, and in-process loopback.", "type": "module", "sideEffects": false, diff --git a/packages/data/package.json b/packages/data/package.json index ae38e2c9..97c38b4a 100644 --- a/packages/data/package.json +++ b/packages/data/package.json @@ -1,6 +1,6 @@ { "name": "@adobe/data", - "version": "0.9.79", + "version": "0.9.80", "description": "Adobe data oriented programming library", "type": "module", "sideEffects": false, diff --git a/packages/data/src/observe/from-keys.test.ts b/packages/data/src/observe/from-keys.test.ts new file mode 100644 index 00000000..80dbf30e --- /dev/null +++ b/packages/data/src/observe/from-keys.test.ts @@ -0,0 +1,165 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. + +import { describe, test, expect, vi } from "vitest"; +import { Notify, Observe } from "./index.js"; +import { fromKeys } from "./from-keys.js"; + +/** + * A manually-driven observe function: emits its current value synchronously on + * subscribe once seeded, re-emits on `emit`, and tracks live subscriber count so + * tests can assert per-key value subscription reuse / disposal. + */ +function manual(initial?: { value: T }) { + const observers = new Set>(); + let seeded = initial !== undefined; + let current = initial?.value as T; + const observe: Observe = (notify) => { + observers.add(notify); + if (seeded) { + notify(current); + } + return () => { + observers.delete(notify); + }; + }; + return { + observe, + emit(value: T) { + seeded = true; + current = value; + for (const notify of [...observers]) { + notify(value); + } + }, + subscriberCount: () => observers.size, + }; +} + +describe("fromKeys", () => { + test("emits per-key values in key order, synchronously when the values are synchronous", () => { + const keys = manual({ value: ["a", "b", "c"] }); + const values: Record>> = { + a: manual({ value: 1 }), + b: manual({ value: 2 }), + c: manual({ value: 3 }), + }; + const results: (readonly number[])[] = []; + const unsubscribe = fromKeys(keys.observe, (k: string) => values[k].observe)((v) => results.push(v)); + + expect(results).toEqual([[1, 2, 3]]); + unsubscribe(); + }); + + test("re-emits only the changed key's slot; the other keys keep their value and are not re-subscribed", () => { + const keys = manual({ value: ["a", "b"] }); + const a = manual({ value: 1 }); + const b = manual({ value: 2 }); + const observeValue = vi.fn((k: string) => (k === "a" ? a.observe : b.observe)); + + const results: (readonly number[])[] = []; + const unsubscribe = fromKeys(keys.observe, observeValue)((v) => results.push(v)); + + expect(results).toEqual([[1, 2]]); + // one value subscription per key, created once + expect(observeValue).toHaveBeenCalledTimes(2); + expect(a.subscriberCount()).toBe(1); + expect(b.subscriberCount()).toBe(1); + + a.emit(10); + expect(results).toEqual([[1, 2], [10, 2]]); + // b was untouched: no extra observeValue call, still one subscription + expect(observeValue).toHaveBeenCalledTimes(2); + expect(b.subscriberCount()).toBe(1); + + unsubscribe(); + }); + + test("adding a key subscribes a new value and re-emits with it appended", () => { + const keys = manual({ value: ["a"] }); + const a = manual({ value: 1 }); + const b = manual({ value: 2 }); + const map: Record> = { a: a.observe, b: b.observe }; + + const results: (readonly number[])[] = []; + fromKeys(keys.observe, (k: string) => map[k])((v) => results.push(v)); + + expect(results).toEqual([[1]]); + keys.emit(["a", "b"]); + expect(results[results.length - 1]).toEqual([1, 2]); + expect(b.subscriberCount()).toBe(1); + }); + + test("removing a key unsubscribes its value and re-emits without it", () => { + const keys = manual({ value: ["a", "b"] }); + const a = manual({ value: 1 }); + const b = manual({ value: 2 }); + const map: Record> = { a: a.observe, b: b.observe }; + + const results: (readonly number[])[] = []; + fromKeys(keys.observe, (k: string) => map[k])((v) => results.push(v)); + + expect(b.subscriberCount()).toBe(1); + keys.emit(["a"]); + expect(results[results.length - 1]).toEqual([1]); + // b's value subscription was disposed + expect(b.subscriberCount()).toBe(0); + // a survived — still one subscription + expect(a.subscriberCount()).toBe(1); + }); + + test("reordering the keys re-emits the values in the new order without re-subscribing", () => { + const keys = manual({ value: ["a", "b"] }); + const a = manual({ value: 1 }); + const b = manual({ value: 2 }); + const map: Record> = { a: a.observe, b: b.observe }; + const observeValue = vi.fn((k: string) => map[k]); + + const results: (readonly number[])[] = []; + fromKeys(keys.observe, observeValue)((v) => results.push(v)); + + expect(results[results.length - 1]).toEqual([1, 2]); + keys.emit(["b", "a"]); + expect(results[results.length - 1]).toEqual([2, 1]); + // no new subscriptions for a reorder + expect(observeValue).toHaveBeenCalledTimes(2); + }); + + test("waits for every key to yield a value before the first emission", () => { + const keys = manual({ value: ["a", "b"] }); + const a = manual({ value: 1 }); + const b = manual(); // not seeded — never yields synchronously + const map: Record> = { a: a.observe, b: b.observe }; + + const results: (readonly number[])[] = []; + fromKeys(keys.observe, (k: string) => map[k])((v) => results.push(v)); + + // b has not produced a value, so nothing is emitted yet + expect(results).toEqual([]); + b.emit(2); + expect(results).toEqual([[1, 2]]); + }); + + test("emits an empty array for an empty key list", () => { + const keys = manual({ value: [] }); + const results: (readonly number[])[] = []; + fromKeys(keys.observe, () => manual({ value: 0 }).observe)((v) => results.push(v)); + expect(results).toEqual([[]]); + }); + + test("disposing unsubscribes the key list and every value", () => { + const keys = manual({ value: ["a", "b"] }); + const a = manual({ value: 1 }); + const b = manual({ value: 2 }); + const map: Record> = { a: a.observe, b: b.observe }; + + const unsubscribe = fromKeys(keys.observe, (k: string) => map[k])(() => {}); + expect(keys.subscriberCount()).toBe(1); + expect(a.subscriberCount()).toBe(1); + expect(b.subscriberCount()).toBe(1); + + unsubscribe(); + expect(keys.subscriberCount()).toBe(0); + expect(a.subscriberCount()).toBe(0); + expect(b.subscriberCount()).toBe(0); + }); +}); diff --git a/packages/data/src/observe/from-keys.ts b/packages/data/src/observe/from-keys.ts new file mode 100644 index 00000000..434e81d2 --- /dev/null +++ b/packages/data/src/observe/from-keys.ts @@ -0,0 +1,89 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { Observe } from "./index.js"; + +/** + * Reactive keyed collection. Given an observable list of keys and a function + * that maps each key to an observe function for that key's value, yields an + * observe function of the per-key values in key order. + * + * Per-key value subscriptions are keyed by `===` identity and REUSED across + * list changes: when the key list changes, only added keys subscribe a value + * and only removed keys unsubscribe one — keys that remain are untouched. So a + * change in one key's value re-emits only that key's slot (every other key + * keeps its current value), without re-running or re-subscribing the rest. + * + * A result is only produced once every current key has yielded a value; if every + * value observe function yields synchronously on subscribe, the first result is + * produced synchronously. Re-emits whenever the key list changes or any live + * value yields. + * + * This is the dynamic, keyed counterpart to {@link fromArray} / {@link + * fromProperties} (which combine a fixed set of observe functions): here the set + * of value observe functions is itself driven by an observe function of keys. + * + * @param keys an observe function of the current key list (duplicate keys collapse) + * @param observeValue maps a key to the observe function for that key's value + */ +export function fromKeys( + keys: Observe, + observeValue: (key: K) => Observe +): Observe { + return (notify) => { + const entries = new Map void; value: V; ready: boolean }>(); + let currentKeys: readonly K[] = []; + let keysReady = false; + + const emitIfReady = () => { + if (!keysReady) { + return; + } + const values: V[] = []; + for (const key of currentKeys) { + const entry = entries.get(key); + // A newly-added key has not yielded its first value yet — wait for it. + if (entry === undefined || !entry.ready) { + return; + } + values.push(entry.value); + } + notify(values); + }; + + const ensureEntry = (key: K) => { + if (entries.has(key)) { + return; + } + const entry = { unsubscribe: () => {}, value: undefined as unknown as V, ready: false }; + entries.set(key, entry); + entry.unsubscribe = observeValue(key)((value) => { + entry.value = value; + entry.ready = true; + emitIfReady(); + }); + }; + + const unsubscribeKeys = keys((nextKeys) => { + currentKeys = nextKeys; + const keep = new Set(nextKeys); + for (const [key, entry] of entries) { + if (!keep.has(key)) { + entry.unsubscribe(); + entries.delete(key); + } + } + for (const key of nextKeys) { + ensureEntry(key); + } + keysReady = true; + emitIfReady(); + }); + + return () => { + unsubscribeKeys(); + for (const entry of entries.values()) { + entry.unsubscribe(); + } + entries.clear(); + }; + }; +} diff --git a/packages/data/src/observe/public.ts b/packages/data/src/observe/public.ts index 1546baad..e7ab237f 100644 --- a/packages/data/src/observe/public.ts +++ b/packages/data/src/observe/public.ts @@ -9,6 +9,7 @@ export * from "./from-element-id.js"; export * from "./from-element-properties-and-events.js"; export * from "./from-element-property.js"; export * from "./from-array.js"; +export * from "./from-keys.js"; export * from "./from-properties.js"; export * from "./from-promise-with-error.js"; export * from "./from-promise.js";