From 17914b5bcf7db38b795136c3c3268c1bd9342d65 Mon Sep 17 00:00:00 2001 From: Kris Nye Date: Wed, 15 Jul 2026 17:45:30 -0700 Subject: [PATCH] feat(data): db.derive inputs overload for external observables MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a second `db.derive(inputs, compute)` shape: a fixed record of external `Observe` values (e.g. service observables that don't live in the ECS) is subscribed once at the root and injected as the callback's second argument — current values, keyed like `inputs`, unwrapped `Observe → U`, strongly typed. The first argument stays the read projection, so one synchronous body can fold external values and ECS reads together. The derive recomputes when any input emits OR a recorded ECS read could have changed, and withholds its first value until every input has emitted (fromProperties semantics). Inputs are static for the derive's lifetime; dynamic cases still compose with withSwitch / fromKeys. Includes version bump to 0.9.82. 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 +- .../ecs/database/database-read.type-test.ts | 47 +++++++ packages/data/src/ecs/database/database.ts | 26 +++- .../src/ecs/database/observe-derive.test.ts | 88 ++++++++++++++ .../data/src/ecs/database/observe-derive.ts | 115 ++++++++++++------ .../database/observed/observed-database.ts | 4 + 21 files changed, 260 insertions(+), 52 deletions(-) diff --git a/package.json b/package.json index e5d50bf0..57afdfeb 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "data-monorepo", - "version": "0.9.81", + "version": "0.9.82", "private": true, "scripts": { "build": "pnpm -r run build", diff --git a/packages/data-ai/package.json b/packages/data-ai/package.json index dd953c19..8e6c6dbf 100644 --- a/packages/data-ai/package.json +++ b/packages/data-ai/package.json @@ -1,6 +1,6 @@ { "name": "@adobe/data-ai", - "version": "0.9.81", + "version": "0.9.82", "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 0d789787..4125ad11 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.81", + "version": "0.9.82", "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 57cfec1c..c68a2f89 100644 --- a/packages/data-gpu/package.json +++ b/packages/data-gpu/package.json @@ -1,6 +1,6 @@ { "name": "@adobe/data-gpu", - "version": "0.9.81", + "version": "0.9.82", "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 dd25b586..74532f98 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.81", + "version": "0.9.82", "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 8970afb2..50ce9ee8 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.81", + "version": "0.9.82", "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 d425a6d3..c06f9454 100644 --- a/packages/data-lit/package.json +++ b/packages/data-lit/package.json @@ -1,6 +1,6 @@ { "name": "@adobe/data-lit", - "version": "0.9.81", + "version": "0.9.82", "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 19e2039c..547445c7 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.81", + "version": "0.9.82", "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 cbd6bb0e..8407efc2 100644 --- a/packages/data-persistence/package.json +++ b/packages/data-persistence/package.json @@ -1,6 +1,6 @@ { "name": "@adobe/data-persistence", - "version": "0.9.81", + "version": "0.9.82", "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 e76379fb..fcd73824 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.81", + "version": "0.9.82", "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 e4bf983f..7a0419df 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.81", + "version": "0.9.82", "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 97044ec0..e344327f 100644 --- a/packages/data-react/package.json +++ b/packages/data-react/package.json @@ -1,6 +1,6 @@ { "name": "@adobe/data-react", - "version": "0.9.81", + "version": "0.9.82", "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 880b4d34..4fb37046 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.81", + "version": "0.9.82", "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 6c2004ba..113d83f1 100644 --- a/packages/data-solid/package.json +++ b/packages/data-solid/package.json @@ -1,6 +1,6 @@ { "name": "@adobe/data-solid", - "version": "0.9.81", + "version": "0.9.82", "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 5a2ab6d4..265f42fd 100644 --- a/packages/data-sync/package.json +++ b/packages/data-sync/package.json @@ -1,6 +1,6 @@ { "name": "@adobe/data-sync", - "version": "0.9.81", + "version": "0.9.82", "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 94626777..7b3ff5b3 100644 --- a/packages/data/package.json +++ b/packages/data/package.json @@ -1,6 +1,6 @@ { "name": "@adobe/data", - "version": "0.9.81", + "version": "0.9.82", "description": "Adobe data oriented programming library", "type": "module", "sideEffects": false, diff --git a/packages/data/src/ecs/database/database-read.type-test.ts b/packages/data/src/ecs/database/database-read.type-test.ts index 9f902e55..565dec64 100644 --- a/packages/data/src/ecs/database/database-read.type-test.ts +++ b/packages/data/src/ecs/database/database-read.type-test.ts @@ -104,6 +104,53 @@ function derivePositive() { type _Head = Assert>>; } +// ============================================================================ +// derive — INPUTS overload: a fixed record of external observables is injected +// as CURRENT values (second callback arg), unwrapped `Observe → U`; the first +// callback arg stays the read projection. This is how a derive folds in values +// that don't live in the ECS (e.g. service observables) alongside ECS reads. +// ============================================================================ + +declare const numberInput: Observe; +declare const stringInput: Observe; + +function deriveInputsPositive() { + // inputs are injected as current values, strongly typed by unwrapping each Observe + const sum = db.derive( + { n: numberInput, s: stringInput }, + (d, inputs) => { + type _Inputs = Assert>; + // the first arg is still the read projection + const r: number = d.resources.frameRate; + return `${inputs.s}:${inputs.n + r}`; + }, + ); + type _Sum = Assert>>; + + // a body that mixes an ECS read with an injected input value + const mixed = db.derive( + { n: numberInput }, + (d, inputs) => (d.get(0 as Entity, "x") ?? 0) + inputs.n, + ); + type _Mixed = Assert>>; +} + +function deriveInputsNegative() { + db.derive({ n: numberInput }, (d, inputs) => { + void d; + // @ts-expect-error `n` unwraps to number, not string + const bad: string = inputs.n; + return bad; + }); + db.derive({ n: numberInput }, (d, inputs) => { + void d; + // @ts-expect-error `m` is not an injected input + return inputs.m; + }); + // @ts-expect-error an input value must be an Observe, not a bare value + db.derive({ n: 123 }, (d, inputs) => inputs.n); +} + // ============================================================================ // derive — NEGATIVE: everything the read projection removes // ============================================================================ diff --git a/packages/data/src/ecs/database/database.ts b/packages/data/src/ecs/database/database.ts index 1ec382d3..63dff486 100644 --- a/packages/data/src/ecs/database/database.ts +++ b/packages/data/src/ecs/database/database.ts @@ -169,10 +169,32 @@ export interface Database< * never re-notifies. * * The callback receives `Database.Read`, so an *intersection* database - * (e.g. `IndexedCoreDatabase & ResourceComputedDatabase`) resolves to the - * merged read surface — consumers never need to cast. + * resolves to the merged read surface — consumers never need to cast. */ derive(compute: (db: Database.Read) => T): Observe; + /** + * `derive` with a fixed record of external `inputs` — arbitrary `Observe` + * values that do NOT live in the ECS (e.g. observables exposed by services). + * Their CURRENT values are injected as the second callback argument, keyed the + * same as `inputs` and unwrapped (`Observe → U`); the first argument stays + * the read projection, so a body can fold external values and ECS reads into + * one synchronous expression. + * + * The inputs are subscribed once, at the root (not read dynamically inside the + * body), so the set is fixed for the life of the derive. The derive recomputes + * when any input emits OR an ECS read it recorded could have changed; like + * {@link Observe.fromProperties}, the first value is withheld until every input + * has produced one. Anything dynamic — an input set that depends on data, or + * an input whose arguments come from an ECS read — composes with + * {@link Observe.withSwitch} / {@link Observe.fromKeys} around the derive. + */ + derive>, T>( + inputs: I, + compute: ( + db: Database.Read, + inputs: { readonly [K in keyof I]: I[K] extends Observe ? U : never }, + ) => T, + ): Observe; /** * Wipes all entities and resets all resources to their plugin defaults, * preserving database identity (observers, transaction wrappers, sync diff --git a/packages/data/src/ecs/database/observe-derive.test.ts b/packages/data/src/ecs/database/observe-derive.test.ts index f8f32c76..03e7fd98 100644 --- a/packages/data/src/ecs/database/observe-derive.test.ts +++ b/packages/data/src/ecs/database/observe-derive.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it, beforeEach, vi } from "vitest"; import { Database } from "./database.js"; import { Entity } from "../entity/entity.js"; +import { Observe } from "../../observe/index.js"; const createTestDatabase = () => Database.create( @@ -38,6 +39,25 @@ const createTestDatabase = () => const flush = () => new Promise((resolve) => queueMicrotask(() => resolve())); +// A minimal external (non-ECS) observable, standing in for a value a service +// might expose: emits the current value on subscribe, re-emits on `set`. +const makeSource = (initial: T) => { + let value = initial; + const observers = new Set<(v: T) => void>(); + const observe: Observe = (notify) => { + observers.add(notify); + notify(value); + return () => { + observers.delete(notify); + }; + }; + const set = (v: T) => { + value = v; + for (const o of observers) o(v); + }; + return { observe, set }; +}; + describe("db.derive", () => { let db: ReturnType; let foo: Entity; @@ -125,6 +145,74 @@ describe("db.derive", () => { unsubscribe(); }); + // ── inputs overload: fold external (non-ECS) observables into a derive ── + + it("injects external input values and recomputes when either an input or an ECS read changes", () => { + const scale = makeSource(2); + const observer = vi.fn(); + // one body reads an ECS value (`a` on foo) AND the injected external input + const unsubscribe = db.derive( + { scale: scale.observe }, + (d, inputs) => (d.get(foo, "a") ?? 0) * inputs.scale, + )(observer); + expect(observer).toHaveBeenLastCalledWith(2); // a=1 * scale=2 + db.transactions.setA({ e: foo, a: 5 }); // ECS dep changed + expect(observer).toHaveBeenLastCalledWith(10); // 5 * 2 (latest input reused) + scale.set(3); // external input changed + expect(observer).toHaveBeenLastCalledWith(15); // 5 * 3 (latest ECS read reused) + unsubscribe(); + }); + + it("combines two external inputs with an ECS index read", () => { + db.transactions.makeFoo({ a: 1, b: 0 }); // bucket a=1 now holds 2 entities + const weight = makeSource(10); + const label = makeSource("count"); + const observer = vi.fn(); + const unsubscribe = db.derive( + { weight: weight.observe, label: label.observe }, + (d, inputs) => `${inputs.label}=${d.indexes.byA.find({ a: 1 }).length * inputs.weight}`, + )(observer); + expect(observer).toHaveBeenLastCalledWith("count=20"); // 2 entities * 10 + weight.set(100); + expect(observer).toHaveBeenLastCalledWith("count=200"); + label.set("total"); + expect(observer).toHaveBeenLastCalledWith("total=200"); + unsubscribe(); + }); + + it("withholds the first value until every input has emitted", () => { + // an input that does not emit synchronously on subscribe + let emitInput: ((v: number) => void) | undefined; + const deferred: Observe = (notify) => { + emitInput = notify; + return () => { + emitInput = undefined; + }; + }; + const observer = vi.fn(); + const unsubscribe = db.derive({ d: deferred }, (_db, inputs) => inputs.d + 1)(observer); + expect(observer).not.toHaveBeenCalled(); // no input value yet → nothing computed + emitInput?.(10); + expect(observer).toHaveBeenCalledTimes(1); + expect(observer).toHaveBeenLastCalledWith(11); + unsubscribe(); + }); + + it("structurally dedupes across input changes", () => { + const flag = makeSource(1); + const compute = vi.fn((_db: any, inputs: { flag: number }) => (inputs.flag > 0 ? "pos" : "neg")); + const observer = vi.fn(); + const unsubscribe = db.derive({ flag: flag.observe }, compute)(observer); + expect(observer).toHaveBeenCalledTimes(1); // "pos" + flag.set(5); // still positive → recompute runs but result unchanged → no emit + expect(compute).toHaveBeenCalledTimes(2); + expect(observer).toHaveBeenCalledTimes(1); + flag.set(-1); // flips + expect(observer).toHaveBeenCalledTimes(2); + expect(observer).toHaveBeenLastCalledWith("neg"); + unsubscribe(); + }); + it("re-emits on a change to an index bucket it read", async () => { const observer = vi.fn(); const unsubscribe = db.derive((d) => d.indexes.byA.find({ a: 1 }).length)(observer); diff --git a/packages/data/src/ecs/database/observe-derive.ts b/packages/data/src/ecs/database/observe-derive.ts index dc381f8a..993bc6eb 100644 --- a/packages/data/src/ecs/database/observe-derive.ts +++ b/packages/data/src/ecs/database/observe-derive.ts @@ -249,46 +249,93 @@ export const createDerive = ( archetypes: store.archetypes, }; - return (compute: (db: any) => T): Observe => (notify) => { - let last: T; - let hasLast = false; - let deps: DepSet = emptyDeps(); + // Two shapes. Without `inputs`, the callback receives only the read + // projection. With `inputs`, a fixed record of external observables (values + // that don't live in the ECS — e.g. service observables) is subscribed once + // at the root and their current values are injected as the callback's second + // argument; the derive recomputes when any input emits OR an ECS read it + // recorded could have changed, and — per `fromProperties` — withholds its + // first value until every input has produced one. + function derive(compute: (db: any) => T): Observe; + function derive>, T>( + inputs: I, + compute: (db: any, inputs: { readonly [K in keyof I]: I[K] extends Observe ? U : never }) => T, + ): Observe; + // Implementation signature — hidden; callers only ever see the two typed + // overloads above. `inputs` is `any` here solely because an overloaded impl + // signature must remain callable for the typed overload (whose `inputs` param + // is checked contravariantly); the public surface stays fully typed. + function derive( + arg1: ((db: unknown, inputs?: any) => unknown) | Record>, + arg2?: (db: unknown, inputs?: any) => unknown, + ): Observe { + const inputObservables = typeof arg1 === "function" ? undefined : arg1; + const compute = typeof arg1 === "function" ? arg1 : arg2; + if (compute === undefined) { + throw new Error("db.derive requires a compute function"); + } - const run = (): T => { - recording = emptyDeps(); - try { - return compute(recorder); - } finally { - deps = recording; - recording = null; - } - }; + return (notify) => { + let last: unknown; + let hasLast = false; + let deps: DepSet = emptyDeps(); + // Latest injected input snapshot; `ready` gates the ECS commit path + // until the inputs exist (no inputs → ready immediately). + let latestInputs: Record | undefined; + let ready = inputObservables === undefined; - const emit = (value: T) => { - if (!hasLast || !equals(last, value)) { - last = value; - hasLast = true; - notify(value); - } - }; + const run = (): unknown => { + recording = emptyDeps(); + try { + return compute(recorder, latestInputs); + } finally { + deps = recording; + recording = null; + } + }; - emit(run()); + const emit = (value: unknown) => { + if (!hasLast || !equals(last, value)) { + last = value; + hasLast = true; + notify(value); + } + }; - // `observeTransactions` fires once per committed transaction, so a - // recompute happens at most once per commit. Emit synchronously at the - // commit boundary — the same cadence as `observe.entity` / the raw - // component observers a hand-written computed would use — rather than - // deferring to a microtask, which would coalesce several commits in one - // turn (e.g. a burst of ephemeral drag commits) into a single emission - // and starve consumers that route per commit. - const unobserve = observeTransactions((result) => { - if (affected(deps, result)) { + // ECS commit gate — recompute when a recorded read could have changed. + // `observeTransactions` fires once per committed transaction, so a + // recompute happens at most once per commit, synchronously at the + // commit boundary (the same cadence as `observe.entity` / the raw + // component observers a hand-written computed would use). Guarded by + // `ready` so it never runs before the first dep set is recorded. + const unobserveTransactions = observeTransactions((result) => { + if (ready && affected(deps, result)) { + emit(run()); + } + }); + + if (inputObservables === undefined) { emit(run()); + return () => { + unobserveTransactions(); + }; } - }); - return () => { - unobserve(); + // Inputs are subscribed once here at the root — never read inside the + // recording run — so the set is fixed for the derive's lifetime. Each + // emission refreshes the injected snapshot and recomputes; the first + // (all inputs present) is also the derive's first value. + const unobserveInputs = Observe.fromProperties(inputObservables)((values) => { + latestInputs = values; + ready = true; + emit(run()); + }); + + return () => { + unobserveInputs(); + unobserveTransactions(); + }; }; - }; + } + return derive; }; diff --git a/packages/data/src/ecs/database/observed/observed-database.ts b/packages/data/src/ecs/database/observed/observed-database.ts index 1ef8ec8d..fed90d04 100644 --- a/packages/data/src/ecs/database/observed/observed-database.ts +++ b/packages/data/src/ecs/database/observed/observed-database.ts @@ -37,6 +37,10 @@ export interface ObservedDatabase< // Internal (loose) type — the precise `Database.Read` callback surface // is enforced at the public `Database.derive` boundary. derive(compute: (db: any) => T): Observe; + derive>, T>( + inputs: I, + compute: (db: any, inputs: { readonly [K in keyof I]: I[K] extends Observe ? U : never }) => T, + ): Observe; readonly execute: (handler: (ctx: TransactionContext) => Entity | void, options?: { transient?: boolean; userId?: number | string }) => TransactionResult; readonly reset: () => void; readonly toData: (copy?: boolean) => unknown;