From 3fa0afc12a0dc24c2721ebaf35ff324b0031221e Mon Sep 17 00:00:00 2001 From: Kris Nye Date: Mon, 8 Jun 2026 11:52:30 -0700 Subject: [PATCH] feat(ecs): merge `imports` plugins at runtime (types still exclude them) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously `imports` was type-only: the imported plugins' members were visible to local declarations but contributed nothing at runtime, so a consumer that forgot to re-list them in the top-level combine got `undefined` transactions/ resources at runtime despite a clean compile. Now `imports` merges into the assembled plugin at runtime exactly like `extends` (order: imports, then extends, then local — preserving service init order). This is a pure runtime change: the result TYPE is unchanged and still excludes the imported members, so the quadratic-killing property is preserved (imports do not propagate through downstream result types). No type-signature changes. Adds "imports runtime behavior" tests proving imported members exist on the assembled plugin and on a database built from it alone (no separate combine). Updates docs that incorrectly stated the consumer must re-list imports. Bump to 0.9.65. Co-Authored-By: Claude Opus 4.8 --- 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/scripts/typeperf/README.md | 9 ++-- .../src/ecs/database/create-plugin.test.ts | 52 +++++++++++++++++++ .../data/src/ecs/database/create-plugin.ts | 20 ++++--- .../ecs/database/imports-chain.type-test.ts | 22 ++++---- 17 files changed, 94 insertions(+), 35 deletions(-) diff --git a/package.json b/package.json index 1079731..1e604b8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "data-monorepo", - "version": "0.9.64", + "version": "0.9.65", "private": true, "scripts": { "build": "pnpm -r run build", diff --git a/packages/data-lit-tictactoe/package.json b/packages/data-lit-tictactoe/package.json index 8b24078..c36aae6 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.64", + "version": "0.9.65", "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 1545f58..08fecab 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.64", + "version": "0.9.65", "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 ede5322..2f4271d 100644 --- a/packages/data-lit/package.json +++ b/packages/data-lit/package.json @@ -1,6 +1,6 @@ { "name": "@adobe/data-lit", - "version": "0.9.64", + "version": "0.9.65", "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 a4e15f4..a29b85c 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.64", + "version": "0.9.65", "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 5fd5d27..689bc70 100644 --- a/packages/data-persistence/package.json +++ b/packages/data-persistence/package.json @@ -1,6 +1,6 @@ { "name": "@adobe/data-persistence", - "version": "0.9.64", + "version": "0.9.65", "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 4d2d39d..55248eb 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.64", + "version": "0.9.65", "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 a8b5592..12308c1 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.64", + "version": "0.9.65", "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 96fa02d..fbf1b6a 100644 --- a/packages/data-react/package.json +++ b/packages/data-react/package.json @@ -1,6 +1,6 @@ { "name": "@adobe/data-react", - "version": "0.9.64", + "version": "0.9.65", "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 7d5338a..1bd17b6 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.64", + "version": "0.9.65", "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 354f07f..19de146 100644 --- a/packages/data-solid/package.json +++ b/packages/data-solid/package.json @@ -1,6 +1,6 @@ { "name": "@adobe/data-solid", - "version": "0.9.64", + "version": "0.9.65", "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 4c65738..9f0c8df 100644 --- a/packages/data-sync/package.json +++ b/packages/data-sync/package.json @@ -1,6 +1,6 @@ { "name": "@adobe/data-sync", - "version": "0.9.64", + "version": "0.9.65", "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 c9dbae2..b186331 100644 --- a/packages/data/package.json +++ b/packages/data/package.json @@ -1,6 +1,6 @@ { "name": "@adobe/data", - "version": "0.9.64", + "version": "0.9.65", "description": "Adobe data oriented programming library", "type": "module", "sideEffects": false, diff --git a/packages/data/scripts/typeperf/README.md b/packages/data/scripts/typeperf/README.md index 5aeed1e..a8a2518 100644 --- a/packages/data/scripts/typeperf/README.md +++ b/packages/data/scripts/typeperf/README.md @@ -64,10 +64,10 @@ the knee. ## Result — `imports` vs `extends` `imports` makes ancestor types visible to local declarations **without** -re-exporting them into the result type. Each link's result stays -`O(local members)`, so the chain becomes **linear**; consumers compose the -union once via `Database.Plugin.combine(...)`. Measured (TS 5.8.3, -`node scripts/typeperf/measure.mjs extends imports`): +re-exporting them into the result type (it still merges them at runtime, like +`extends` — the difference is purely in the result type). Each link's result +type stays `O(local members)`, so the chain becomes **linear**. Measured +(TS 5.8.3, `node scripts/typeperf/measure.mjs extends imports`): | depth | extends (inst) | imports (inst) | speedup | |------:|---------------:|---------------:|--------:| @@ -94,4 +94,3 @@ several ancestors imports a combined context per import site over local-only (O(1)) operands, so the total stays linear in total members as long as authors keep result types local — which `imports` enforces by construction. - diff --git a/packages/data/src/ecs/database/create-plugin.test.ts b/packages/data/src/ecs/database/create-plugin.test.ts index 9331e69..61f89a7 100644 --- a/packages/data/src/ecs/database/create-plugin.test.ts +++ b/packages/data/src/ecs/database/create-plugin.test.ts @@ -675,5 +675,57 @@ describe("Database.Plugin.create", () => { }).not.toThrow(); }); }); + + describe("imports runtime behavior", () => { + // `imports` differs from `extends` ONLY in the result type (imported + // members are not declared there — see imports-chain.type-test.ts). + // At runtime, imports merge into the assembled plugin exactly like + // extends, so the consumer does NOT have to re-list the imported plugin + // in the top-level combine for its members to exist. + const basePlugin = createPlugin({ + resources: { baseScale: { default: 7 as number } }, + transactions: { + setBaseScale: (_t, _input: { scale: number }) => { }, + }, + }); + + it("merges imported plugin members into the runtime plugin object", () => { + const featurePlugin = createPlugin({ + imports: basePlugin, + resources: { featureFlag: { default: true as boolean } }, + }); + // Imported members are present on the assembled plugin at runtime... + expect((featurePlugin.resources as any).baseScale).toBeDefined(); + expect((featurePlugin.transactions as any).setBaseScale).toBeDefined(); + // ...alongside the local ones. + expect((featurePlugin.resources as any).featureFlag).toBeDefined(); + }); + + it("imported members are usable on a database built from the plugin alone", () => { + const featurePlugin = createPlugin({ + imports: basePlugin, + resources: { featureFlag: { default: true as boolean } }, + }); + // No separate combine(basePlugin, ...) — imports already merged. + const db = Database.create(featurePlugin); + expect((db.resources as any).baseScale).toBe(7); + expect(() => (db.transactions as any).setBaseScale({ scale: 9 })).not.toThrow(); + }); + + it("merges imports first, then extends, then local declarations", () => { + const extendedBase = createPlugin({ + resources: { fromExtends: { default: 1 as number } }, + }); + const combined = createPlugin({ + imports: basePlugin, + extends: extendedBase, + resources: { fromLocal: { default: 2 as number } }, + }); + const r = combined.resources as any; + expect(r.baseScale).toBeDefined(); // from imports + expect(r.fromExtends).toBeDefined(); // from extends + expect(r.fromLocal).toBeDefined(); // local + }); + }); }); diff --git a/packages/data/src/ecs/database/create-plugin.ts b/packages/data/src/ecs/database/create-plugin.ts index 89c9f32..faf33a6 100644 --- a/packages/data/src/ecs/database/create-plugin.ts +++ b/packages/data/src/ecs/database/create-plugin.ts @@ -247,13 +247,19 @@ export function createPlugin< systems: plugins.systems ?? {}, }; - // `imports` is a type-only contract: it makes the imported plugins' types - // visible to this plugin's local declarations without re-exporting them into - // the result. At runtime it contributes nothing — the consumer is responsible - // for actually including the imported plugins in the final - // `Database.Plugin.combine(...)`. Only `extends` merges at runtime. - if (plugins.extends) { - return combinePlugins(plugins.extends, plugin) as any; + // `imports` differs from `extends` only at the TYPE level: the imported + // plugins' members are NOT declared in this plugin's result type, so they + // don't propagate through downstream result types (the source of the + // quadratic `extends` blowup). At RUNTIME, however, imports merge in exactly + // like extends — so the imported components/resources/transactions/etc. are + // present in the assembled database without the consumer having to re-list + // them in the top-level combine. Order: imports first, then extends, then + // this plugin's own declarations (preserves service initialization order). + const bases: Database.Plugin[] = []; + if (plugins.imports) bases.push(plugins.imports); + if (plugins.extends) bases.push(plugins.extends); + if (bases.length > 0) { + return combinePlugins(...bases, plugin) as any; } return plugin as any; } \ No newline at end of file diff --git a/packages/data/src/ecs/database/imports-chain.type-test.ts b/packages/data/src/ecs/database/imports-chain.type-test.ts index a26ed1d..2386a42 100644 --- a/packages/data/src/ecs/database/imports-chain.type-test.ts +++ b/packages/data/src/ecs/database/imports-chain.type-test.ts @@ -13,18 +13,18 @@ import type { True, False } from "../../types/types.js"; * - `extends` — base types are visible to local declarations AND * re-exported into the result plugin's type. * - `imports` — base types are visible to local declarations ONLY; - * they do NOT flow into the result plugin's type. + * they do NOT flow into the result plugin's TYPE. * - * The result-type asymmetry is what keeps deep dependency graphs cheap: an - * `imports` link's result stays O(local members) instead of accumulating the - * full chain (see scripts/typeperf — `imports` is linear where `extends` is - * quadratic in chain depth). The consumer reconstitutes the union once, at the - * top, via `Database.Plugin.combine(...)`. - * - * These tests verify BOTH halves of that contract: + * The two merge identically at RUNTIME (both pull the base's members into the + * assembled plugin); they differ only in the result TYPE. That result-type + * asymmetry is what keeps deep dependency graphs cheap: an `imports` link's + * result type stays O(local members) instead of accumulating the full chain + * (see scripts/typeperf — `imports` is linear where `extends` is quadratic in + * chain depth). Runtime-merge behavior is covered by create-plugin.test.ts + * ("imports runtime behavior"); this file covers the type contract: * 1. Visibility — a plugin that `imports` a base gets FULL type safety on the * base's components/resources/transactions (no weakening vs `extends`). - * 2. Non-export — the imported members are absent from the result type. + * 2. Non-export — the imported members are absent from the result TYPE. */ // ============================================================================ @@ -106,7 +106,9 @@ type _ExtendsReExportsComponent = True<'baseColor' extends keyof ExtendedResult[ type _ExtendsReExportsTx = True<'setBaseColor' extends keyof ExtendedResult['transactions'] ? true : false>; // ============================================================================ -// 3. Consumer reconstitutes the union via combine — full DB is type-safe +// 3. To regain the imported members in the TYPE, combine the base back in +// explicitly (runtime already merged them — this is purely to surface the +// base's members on the database type for the consumer). // ============================================================================ function testCombinedUsage() {