From dc6b292f86740bd042b47bb335a6d38c1b3e552c Mon Sep 17 00:00:00 2001 From: Kris Nye Date: Tue, 14 Jul 2026 01:13:24 -0700 Subject: [PATCH 1/4] feat(data): value-partitioned archetypes (shared components) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Components declared `partition: true` store each distinct runtime value in its own archetype, whose partition column is a zero-per-row const buffer — entities sharing a value are contiguous (a storage-level spatial broad phase). Adds a discriminated `ensureArchetype` (Archetype | Router), routing insert, partition-value migration on update, a partition `where` on queryArchetypes, and full discrimination through the store/database/ transaction-context types. Index maintenance is wired via in-place insert decoration (no Proxy). Also removes the unused `observeDependentValue`. 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/ecs/archetype/archetype.ts | 22 ++ .../data/src/ecs/database/create-plugin.ts | 3 +- .../ecs/database/database.partition.test.ts | 49 +++++ .../database/database.partition.type-test.ts | 46 +++++ packages/data/src/ecs/database/database.ts | 9 +- packages/data/src/ecs/database/index.ts | 1 - .../database/observe-dependent-value.test.ts | 170 --------------- .../ecs/database/observe-dependent-value.ts | 58 ------ .../transactional-store.ts | 15 +- packages/data/src/ecs/store/core/core.ts | 51 +++-- .../store/core/create-core.partition.test.ts | 187 +++++++++++++++++ .../data/src/ecs/store/core/create-core.ts | 195 +++++++++++++++--- .../src/ecs/store/core/select-entities.ts | 5 +- .../src/ecs/store/entity-select-options.ts | 6 +- packages/data/src/ecs/store/partition.ts | 70 +++++++ .../data/src/ecs/store/partition.type-test.ts | 104 ++++++++++ .../public/create-store.partition.test.ts | 145 +++++++++++++ .../create-store.partition.type-test.ts | 87 ++++++++ .../data/src/ecs/store/public/create-store.ts | 75 +++---- packages/data/src/ecs/store/store.ts | 23 ++- .../src/ecs/store/transaction-functions.ts | 6 +- packages/data/src/schema/schema.ts | 7 + 38 files changed, 1009 insertions(+), 357 deletions(-) create mode 100644 packages/data/src/ecs/database/database.partition.test.ts create mode 100644 packages/data/src/ecs/database/database.partition.type-test.ts delete mode 100644 packages/data/src/ecs/database/observe-dependent-value.test.ts delete mode 100644 packages/data/src/ecs/database/observe-dependent-value.ts create mode 100644 packages/data/src/ecs/store/core/create-core.partition.test.ts create mode 100644 packages/data/src/ecs/store/partition.ts create mode 100644 packages/data/src/ecs/store/partition.type-test.ts create mode 100644 packages/data/src/ecs/store/public/create-store.partition.test.ts create mode 100644 packages/data/src/ecs/store/public/create-store.partition.type-test.ts diff --git a/package.json b/package.json index 00842545..f20c73f3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "data-monorepo", - "version": "0.9.78", + "version": "0.9.79", "private": true, "scripts": { "build": "pnpm -r run build", diff --git a/packages/data-ai/package.json b/packages/data-ai/package.json index fea62303..58850c04 100644 --- a/packages/data-ai/package.json +++ b/packages/data-ai/package.json @@ -1,6 +1,6 @@ { "name": "@adobe/data-ai", - "version": "0.9.78", + "version": "0.9.79", "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 207f4c12..209809c6 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.78", + "version": "0.9.79", "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 6c70f43b..217be77e 100644 --- a/packages/data-gpu/package.json +++ b/packages/data-gpu/package.json @@ -1,6 +1,6 @@ { "name": "@adobe/data-gpu", - "version": "0.9.78", + "version": "0.9.79", "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 860a58c7..850ad4fe 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.78", + "version": "0.9.79", "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 935046eb..03364236 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.78", + "version": "0.9.79", "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 516f87e8..5300f708 100644 --- a/packages/data-lit/package.json +++ b/packages/data-lit/package.json @@ -1,6 +1,6 @@ { "name": "@adobe/data-lit", - "version": "0.9.78", + "version": "0.9.79", "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 3eaf5515..6f0c5ac4 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.78", + "version": "0.9.79", "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 28f86716..54e53bf6 100644 --- a/packages/data-persistence/package.json +++ b/packages/data-persistence/package.json @@ -1,6 +1,6 @@ { "name": "@adobe/data-persistence", - "version": "0.9.78", + "version": "0.9.79", "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 64fcead5..d236e0bd 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.78", + "version": "0.9.79", "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 433cd9a8..e71020b3 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.78", + "version": "0.9.79", "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 519137dd..205aa0f5 100644 --- a/packages/data-react/package.json +++ b/packages/data-react/package.json @@ -1,6 +1,6 @@ { "name": "@adobe/data-react", - "version": "0.9.78", + "version": "0.9.79", "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 09f8b163..62b3e662 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.78", + "version": "0.9.79", "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 4d70b0c2..ae2cfff9 100644 --- a/packages/data-solid/package.json +++ b/packages/data-solid/package.json @@ -1,6 +1,6 @@ { "name": "@adobe/data-solid", - "version": "0.9.78", + "version": "0.9.79", "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 6778635b..f328cebd 100644 --- a/packages/data-sync/package.json +++ b/packages/data-sync/package.json @@ -1,6 +1,6 @@ { "name": "@adobe/data-sync", - "version": "0.9.78", + "version": "0.9.79", "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 d8561241..ae38e2c9 100644 --- a/packages/data/package.json +++ b/packages/data/package.json @@ -1,6 +1,6 @@ { "name": "@adobe/data", - "version": "0.9.78", + "version": "0.9.79", "description": "Adobe data oriented programming library", "type": "module", "sideEffects": false, diff --git a/packages/data/src/ecs/archetype/archetype.ts b/packages/data/src/ecs/archetype/archetype.ts index ee0300ef..1d04d491 100644 --- a/packages/data/src/ecs/archetype/archetype.ts +++ b/packages/data/src/ecs/archetype/archetype.ts @@ -43,6 +43,28 @@ export interface Archetype ex fromData: (data: unknown) => void } +/** + * Write-only handle over a *family* of archetypes that share a component set but + * differ by the value of one or more `partition` components. `insert` reads the + * partition value(s) from the row, resolves (creating on first use) the concrete + * child archetype for that value, and inserts there. + * + * A family has no single dense column view, so — unlike {@link Archetype} — a + * Router exposes no `columns`, `rowCount`, or iteration. Read a family through + * `queryArchetypes` (optionally filtered by partition value), or narrow to one + * concrete member by supplying the value to `ensureArchetype`. + * + * `insert` is deliberately signature-identical to {@link Archetype.insert}: a + * discriminated `Archetype | Router` (produced when the requested keys are + * not statically known to include/exclude a partition component) therefore still + * permits `.insert` with no narrowing — only dense column access requires having + * resolved to a concrete {@link Archetype}. + */ +export interface Router { + readonly components: ComponentSet>; + insert: >(rowData: Exact, T>) => Entity; +} + export type FromArchetype = T extends ReadonlyArchetype ? { readonly [K in keyof C]: C[K] } : T extends Archetype ? { readonly [K in keyof C]: C[K] } : diff --git a/packages/data/src/ecs/database/create-plugin.ts b/packages/data/src/ecs/database/create-plugin.ts index 8661b88e..653d1aec 100644 --- a/packages/data/src/ecs/database/create-plugin.ts +++ b/packages/data/src/ecs/database/create-plugin.ts @@ -7,6 +7,7 @@ import type { ArchetypeComponents } from "../store/archetype-components.js"; import type { TransactionDeclarations, ToTransactionFunctions } from "../store/transaction-functions.js"; import type { ToActionFunctions } from "../store/action-functions.js"; import type { FromSchemas } from "../../schema/index.js"; +import type { PartitionKeysOf } from "../store/partition.js"; import type { StringKeyof, NoInfer, RemoveIndex } from "../../types/types.js"; import { combinePlugins } from "./combine-plugins.js"; import { Store } from "../store/store.js"; @@ -189,7 +190,7 @@ export function createPlugin< const RS extends ResourceSchemas, const A extends ArchetypeComponents & XP['components'] & IP['components']>>, const IX extends IndexDeclarations & XP['components'] & IP['components']>, RemoveIndex & XP['archetypes'] & IP['archetypes']>, - const TD extends TransactionDeclarations & XP['components'] & IP['components']>, FromSchemas & XP['resources'] & IP['resources']>, RemoveIndex & XP['archetypes'] & IP['archetypes'], RemoveIndex & XP['indexes'] & IP['indexes']>, + const TD extends TransactionDeclarations & XP['components'] & IP['components']>, FromSchemas & XP['resources'] & IP['resources']>, RemoveIndex & XP['archetypes'] & IP['archetypes'], RemoveIndex & XP['indexes'] & IP['indexes'], PartitionKeysOf & XP['components'] & IP['components']>>, const AD, const S extends string = never, const SVF extends ServiceFactories>> = {}, diff --git a/packages/data/src/ecs/database/database.partition.test.ts b/packages/data/src/ecs/database/database.partition.test.ts new file mode 100644 index 00000000..3fa3200b --- /dev/null +++ b/packages/data/src/ecs/database/database.partition.test.ts @@ -0,0 +1,49 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { describe, it, expect } from "vitest"; +import { Database } from "./database.js"; + +// A plugin whose `cell` component partitions, with a unique name index. The +// transactions write through the transaction context `t`, exercising routing +// and index maintenance on the committed-transaction path. +const plugin = () => Database.Plugin.create({ + components: { + cell: { type: "integer", partition: true }, + name: { type: "string" }, + }, + archetypes: { Spatial: ["cell", "name"] }, + indexes: { byName: { key: "name", unique: true } }, + transactions: { + add: (t, input: { cell: number; name: string }) => t.archetypes.Spatial.insert(input), + move: (t, input: { entity: number; cell: number }) => { t.update(input.entity, { cell: input.cell }); }, + }, +}); + +describe("Database partition integration (transaction context)", () => { + it("routes t.archetypes. inserts and maintains indexes", () => { + const db = Database.create(plugin()); + const a = db.transactions.add({ cell: 1, name: "alice" }) as number; + const b = db.transactions.add({ cell: 2, name: "bob" }) as number; + + expect(db.indexes.byName.get({ name: "alice" })).toBe(a); + expect(db.indexes.byName.get({ name: "bob" })).toBe(b); + // Distinct cells → distinct concrete archetypes. + expect(db.locate(a)!.archetype).not.toBe(db.locate(b)!.archetype); + expect(db.get(a, "cell")).toBe(1); + }); + + it("migrates on a partition-value change inside a transaction; index follows", () => { + const db = Database.create(plugin()); + const e = db.transactions.add({ cell: 1, name: "mover" }) as number; + db.transactions.move({ entity: e, cell: 2 }); + expect(db.get(e, "cell")).toBe(2); + expect(db.indexes.byName.get({ name: "mover" })).toBe(e); + }); + + it("enforces a unique index across value-children inside a transaction (atomic)", () => { + const db = Database.create(plugin()); + const first = db.transactions.add({ cell: 1, name: "dup" }) as number; + expect(() => db.transactions.add({ cell: 2, name: "dup" })).toThrow(/[Uu]nique/); + // Original intact — the failed transaction rolled back cleanly. + expect(db.indexes.byName.get({ name: "dup" })).toBe(first); + }); +}); diff --git a/packages/data/src/ecs/database/database.partition.type-test.ts b/packages/data/src/ecs/database/database.partition.type-test.ts new file mode 100644 index 00000000..e6cd5e5d --- /dev/null +++ b/packages/data/src/ecs/database/database.partition.type-test.ts @@ -0,0 +1,46 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +// +// Compile-time (red/green) verification that the transaction context `t` +// discriminates partition archetypes — both `t.archetypes.` and +// `t.ensureArchetype`. Wrapped in a never-called function so the file +// type-checks without executing the plugin factory. + +import { Database } from "./database.js"; + +function _transactionContextDiscrimination() { + Database.Plugin.create({ + components: { + cell: { type: "integer", partition: true }, + position: { type: "number" }, + health: { type: "number" }, + }, + archetypes: { + Spatial: ["cell", "position"], // partition family + Mob: ["position", "health"], // concrete + }, + transactions: { + check: (t) => { + // A partitioned named archetype is a Router on `t` — write via insert. + t.archetypes.Spatial.insert({ cell: 1, position: 2 }); + // @ts-expect-error - Spatial is a partition family (Router), not a concrete Archetype + t.archetypes.Spatial.columns; + + // A non-partition named archetype is a concrete Archetype on `t`. + t.archetypes.Mob.columns; + t.archetypes.Mob.insert({ position: 1, health: 100 }); + + // ensureArchetype on `t` discriminates just like on the store. + const router = t.ensureArchetype(["id", "cell", "position"]); + router.insert({ cell: 1, position: 2 }); + // @ts-expect-error - a partition family (Router) has no columns + router.columns; + + const concrete = t.ensureArchetype(["id", "cell", "position"], { cell: 7 }); + concrete.columns; + + // @ts-expect-error - partition value must match the component type (cell is number) + t.ensureArchetype(["id", "cell", "position"], { cell: "nope" }); + }, + }, + }); +} diff --git a/packages/data/src/ecs/database/database.ts b/packages/data/src/ecs/database/database.ts index f1c343b2..44fbf3f9 100644 --- a/packages/data/src/ecs/database/database.ts +++ b/packages/data/src/ecs/database/database.ts @@ -21,6 +21,7 @@ import type { ConcurrencyStrategy } from "./concurrency/concurrency-strategy.js" import { observeSelectDeep as _observeSelectDeep } from "./public/observe-select-deep.js"; import { ResourceSchemas } from "../resource-schemas.js"; import { ComponentSchemas } from "../component-schemas.js"; +import { PartitionKeysOf } from "../store/partition.js"; import { FromSchemas } from "../../schema/index.js"; import type { TransactionDeclarations, @@ -110,7 +111,8 @@ export interface Database< SV = {}, CV = unknown, IX extends IndexDeclarations = {}, -> extends ReadonlyStore, Service { + PK extends string = never, +> extends ReadonlyStore, Service { readonly transactions: F & Service; readonly actions: AF & Service; readonly services: SV; @@ -247,7 +249,8 @@ export namespace Database { ToActionFunctions>, FromServiceFactories>, FromComputedFactories>, - RemoveIndex + RemoveIndex, + PartitionKeysOf> >; export const create = createDatabase; @@ -325,7 +328,7 @@ export namespace Database { * another plugin's transaction declaration; `ToStore

` is the bare * store type and does not include `userId`. */ - export type ToTransactionContext

= TransactionContext>, FromSchemas>, RemoveIndex>; + export type ToTransactionContext

= TransactionContext>, FromSchemas>, RemoveIndex, RemoveIndex, PartitionKeysOf>>; export type ToSystemDatabase

= Database.FromPlugin

& { // Systems are allowed to access the database store directly. // This direct access will NOT trigger observable transactions. diff --git a/packages/data/src/ecs/database/index.ts b/packages/data/src/ecs/database/index.ts index 0bc68921..b0d879c6 100644 --- a/packages/data/src/ecs/database/index.ts +++ b/packages/data/src/ecs/database/index.ts @@ -9,7 +9,6 @@ export * from "./reconciling/reconciling-entry.js"; export * from "./observed/create-observed-database.js"; export * from "./observed/observed-database.js"; export * from "./database.js"; -export * from "./observe-dependent-value.js"; export * from "./transactional-store/index.js"; export * from "./database-schema/index.js"; export type { Undoable } from "./undoable.js"; diff --git a/packages/data/src/ecs/database/observe-dependent-value.test.ts b/packages/data/src/ecs/database/observe-dependent-value.test.ts deleted file mode 100644 index b016b89e..00000000 --- a/packages/data/src/ecs/database/observe-dependent-value.test.ts +++ /dev/null @@ -1,170 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { describe, it, expect, vi } from 'vitest'; -import { observeDependentValue } from './observe-dependent-value.js'; -import { Database } from './database.js'; - -describe('observeDependentValue', () => { - it('should compute and observe dependent values from resources', async () => { - const transactions = { - updateA: (t: any, value: number) => { t.resources.a = value; }, - updateB: (t: any, value: number) => { t.resources.b = value; }, - updateC: (t: any, value: number) => { t.resources.c = value; }, - updateAB: (t: any, values: { a: number; b: number }) => { - t.resources.a = values.a; - t.resources.b = values.b; - } - }; - const database = Database.create(Database.Plugin.create({ - components: {}, - resources: { a: { default: 10 }, b: { default: 20 }, c: { default: 30 } }, - archetypes: {}, - transactions, - })); - - // Create a compute function that sums resources 'a' and 'b' - const computeSum = (store: { resources: { a: number; b: number; c: number } }) => { - return store.resources.a + store.resources.b; - }; - - // Create the dependent value observable - const sumObservable = observeDependentValue(database, computeSum); - - // Track observed values - const observedValues: number[] = []; - const unsubscribe = sumObservable((value) => { - observedValues.push(value); - }); - - // Should immediately provide the initial computed value - expect(observedValues).toEqual([30]); // 10 + 20 - - // Update resource 'a' - database.transactions.updateA(15); - await Promise.resolve(); - - // Should recompute and notify with new value - expect(observedValues).toEqual([30, 35]); // 15 + 20 - - // Update resource 'b' - database.transactions.updateB(25); - await Promise.resolve(); - - // Should recompute and notify with new value - expect(observedValues).toEqual([30, 35, 40]); // 15 + 25 - - // Update resource 'c' (not used in computation) - database.transactions.updateC(50); - await Promise.resolve(); - - // Should NOT recompute since 'c' is not used in the computation - expect(observedValues).toEqual([30, 35, 40]); - - // Update both 'a' and 'b' in same transaction - database.transactions.updateAB({ a: 5, b: 10 }); - await Promise.resolve(); - - // Should recompute once with final values - expect(observedValues).toEqual([30, 35, 40, 15]); // 5 + 10 - - // Test unsubscribe - unsubscribe(); - - // Update after unsubscribe - database.transactions.updateA(100); - await Promise.resolve(); - - // Should not notify after unsubscribe - expect(observedValues).toEqual([30, 35, 40, 15]); - }); - - it('should handle multiple observers correctly', async () => { - const database = Database.create(Database.Plugin.create({ - components: {}, - resources: { a: { default: 1 }, b: { default: 2 }, c: { default: 3 } }, - archetypes: {}, - transactions: { - updateA: (t: any, value: number) => { t.resources.a = value; }, - updateB: (t: any, value: number) => { t.resources.b = value; } - }, - })); - - const sumObservable = observeDependentValue(database, (db) => { - return db.resources.a + db.resources.b; - }); - - const values1: number[] = []; - const values2: number[] = []; - - const unsubscribe1 = sumObservable((value) => values1.push(value)); - const unsubscribe2 = sumObservable((value) => values2.push(value)); - - // Both observers should get initial value - expect(values1).toEqual([3]); - expect(values2).toEqual([3]); - - // Update a resource - database.transactions.updateA(5); - await Promise.resolve(); - - // Both observers should get updated value - expect(values1).toEqual([3, 7]); - expect(values2).toEqual([3, 7]); - - // Unsubscribe one observer - unsubscribe1(); - - // Update again - database.transactions.updateB(8); - await Promise.resolve(); - - // Only remaining observer should get update - expect(values1).toEqual([3, 7]); // No more updates - expect(values2).toEqual([3, 7, 13]); // Gets new update - - unsubscribe2(); - }); - - it('should handle complex computed values', async () => { - const database = Database.create(Database.Plugin.create({ - components: {}, - resources: { - count: { default: 5 as number }, - multiplier: { default: 2 as number }, - offset: { default: 10 as number }, - }, - archetypes: {}, - transactions: { - updateCount: (t, value: number) => { t.resources.count = value; }, - updateMultiplier: (t, value: number) => { t.resources.multiplier = value; } - }, - })); - - type TestDatabase = typeof database; - - const complexObservable = observeDependentValue(database, (db) => { - return (db.resources.count * db.resources.multiplier) + db.resources.offset; - }); - - const values: number[] = []; - const unsubscribe = complexObservable((value) => values.push(value)); - - // Initial: (5 * 2) + 10 = 20 - expect(values).toEqual([20]); - - // Update count - database.transactions.updateCount(3); - await Promise.resolve(); - - // New: (3 * 2) + 10 = 16 - expect(values).toEqual([20, 16]); - - // Update multiplier - database.transactions.updateMultiplier(4); - await Promise.resolve(); - - // New: (3 * 4) + 10 = 22 - expect(values).toEqual([20, 16, 22]); - - unsubscribe(); - }); -}); \ No newline at end of file diff --git a/packages/data/src/ecs/database/observe-dependent-value.ts b/packages/data/src/ecs/database/observe-dependent-value.ts deleted file mode 100644 index e661e91d..00000000 --- a/packages/data/src/ecs/database/observe-dependent-value.ts +++ /dev/null @@ -1,58 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { Observe } from "../../observe/index.js"; -import { Database } from "./database.js"; -import { ReadonlyStore, Store } from "../store/index.js"; - -/** - * Creates an observe function that is computed based on the stores resources. - */ -export function observeDependentValue< - D extends Database, - T, ->(db: D, compute: (store: D extends Database ? ReadonlyStore : never) => T): Observe { - return Observe.withLazy(() => { - const resourcesUsed = determineResourcesUsed(db, compute); - - // Create observables for only the resources that are actually used - const resourceObservables = - resourcesUsed.map(resource => (db.observe.resources as any)[resource]); - - // Use fromArray to combine the resource observables, batch notifications, then map and deduplicate - return Observe.withDeduplicate( - Observe.withMap( - Observe.withBatch(Observe.fromArray(resourceObservables)), - (_resources) => { - return compute(db as any); - } - ) - ); - }); -} - -function determineResourcesUsed>(db: D, compute: (db: D extends Database ? ReadonlyStore : never) => any): string[] { - const accessedResources = new Set(); - - // Create a proxy that tracks which resource properties are accessed - const resourceProxy = new Proxy(db.resources, { - get(target, prop) { - if (typeof prop === 'string') { - accessedResources.add(prop); - } - return (target as any)[prop]; - } - }); - - // Create a database object with our proxied resources - const dbProxy = { ...db, resources: resourceProxy }; - - // Call the compute function with our proxied database - // This will track which resources are accessed - try { - compute(dbProxy as any); - } catch (error) { - // Ignore errors during dependency detection - // The actual computation will happen later with the real database - } - - return Array.from(accessedResources); -} \ No newline at end of file diff --git a/packages/data/src/ecs/database/transactional-store/transactional-store.ts b/packages/data/src/ecs/database/transactional-store/transactional-store.ts index 52b7ae62..6b8bc70c 100644 --- a/packages/data/src/ecs/database/transactional-store/transactional-store.ts +++ b/packages/data/src/ecs/database/transactional-store/transactional-store.ts @@ -11,6 +11,7 @@ import { ArchetypeComponents } from "../../store/archetype-components.js"; import { FromSchemas } from "../../../schema/from-schemas.js"; import { Undoable } from "../undoable.js"; import { IndexDeclarations } from "../../store/index-types.js"; +import { PartitionKeysOf } from "../../store/partition.js"; /** * The first argument passed to every transaction function. Extends the full @@ -26,7 +27,8 @@ export type TransactionContext< R extends ResourceComponents, A extends ArchetypeComponents>, IX extends IndexDeclarations = {}, -> = Store & { + PK extends string = never, +> = Store & { readonly userId: number | string | undefined; }; @@ -35,7 +37,8 @@ export interface TransactionalStore< R extends ResourceComponents = never, A extends ArchetypeComponents> = never, IX extends IndexDeclarations = {}, -> extends ReadonlyStore { + PK extends string = never, +> extends ReadonlyStore { /** * Execute a transaction on the store. * The transactionFunction must NOT directly mutate archetype rows as those changes would not be captured. @@ -44,7 +47,7 @@ export interface TransactionalStore< * @returns A promise that resolves when the transaction is complete. */ execute( - transactionFunction: (t: TransactionContext) => Entity | void, + transactionFunction: (t: TransactionContext) => Entity | void, options?: { intermediate?: boolean; userId?: number | string; @@ -56,10 +59,12 @@ export interface TransactionalStore< ): TransactionalStore< C & (S extends Store.Schema ? FromSchemas : never), R & (S extends Store.Schema ? FromSchemas : never), - A & (S extends Store.Schema ? XA : never) + A & (S extends Store.Schema ? XA : never), + IX, + PK | (S extends Store.Schema ? PartitionKeysOf : never) >; - transactionStore: Store; + transactionStore: Store; } export type TransactionInsertOperation = { diff --git a/packages/data/src/ecs/store/core/core.ts b/packages/data/src/ecs/store/core/core.ts index adfece7d..037e63c3 100644 --- a/packages/data/src/ecs/store/core/core.ts +++ b/packages/data/src/ecs/store/core/core.ts @@ -1,21 +1,32 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { Entity } from "../../entity/entity.js"; -import { Archetype, ReadonlyArchetype } from "../../archetype/archetype.js"; +import { Archetype, ReadonlyArchetype, Router } from "../../archetype/archetype.js"; import { Schema } from "../../../schema/index.js"; import { RequiredComponents } from "../../required-components.js"; import { StringKeyof } from "../../../types/index.js"; import { Components } from "../components.js"; import { OptionalComponents } from "../../optional-components.js"; +import { HasPartitionKey } from "../partition.js"; export type EntityValues = { readonly [K in (RequiredComponents & StringKeyof)]: (C & OptionalComponents)[K] } export type EntityReadValues = RequiredComponents & { readonly [K in StringKeyof as string extends K ? never : K]?: (C & OptionalComponents)[K] } export type EntityUpdateValues = Partial>; -export type ArchetypeQueryOptions = - { exclude?: readonly StringKeyof[] }; +export type ArchetypeQueryOptions = { + exclude?: readonly StringKeyof[]; + /** + * Filter to archetypes whose partition component equals the given value. + * A partition column is const per archetype, so this is decided at + * archetype granularity (O(archetypes), no row scan). Keyed to the store's + * partition components only — a non-partition column is not const per + * archetype, so a value filter on it is not archetype-decidable. + */ + where?: { readonly [K in Extract>]?: (C & RequiredComponents & OptionalComponents)[K] }; +}; export interface ReadonlyCore< C extends Components = never, + PK extends string = never, > { readonly componentSchemas: { readonly [K in StringKeyof]: Schema }; @@ -23,11 +34,20 @@ export interface ReadonlyCore< Include extends StringKeyof, >( include: readonly Include[] | ReadonlySet, - options?: ArchetypeQueryOptions + options?: ArchetypeQueryOptions ): readonly ReadonlyArchetype>[]; - ensureArchetype: >( - components: readonly CC[] | ReadonlySet - ) => ReadonlyArchetype; + // No partition value → a concrete ReadonlyArchetype unless the key set + // includes a partition component, in which case a Router (write-only). + ensureArchetype>( + components: readonly CC[] | ReadonlySet, + ): HasPartitionKey extends true + ? Router + : ReadonlyArchetype; + // Partition value(s) supplied → the concrete value-child, always. + ensureArchetype>( + components: readonly CC[] | ReadonlySet, + partitionValues: { readonly [K in Extract]: (C & RequiredComponents & OptionalComponents)[K] }, + ): ReadonlyArchetype; locate: (entity: Entity) => { archetype: ReadonlyArchetype, row: number } | null; read(entity: Entity, minArchetype: ReadonlyArchetype | Archetype): Readonly & EntityReadValues | null; @@ -47,16 +67,23 @@ export interface ReadonlyCore< */ export interface Core< C extends Components = never, -> extends ReadonlyCore { + PK extends string = never, +> extends ReadonlyCore { queryArchetypes< Include extends StringKeyof, >( include: readonly Include[] | ReadonlySet, - options?: ArchetypeQueryOptions + options?: ArchetypeQueryOptions ): readonly Archetype>[]; - ensureArchetype: >( - components: readonly CC[] | ReadonlySet - ) => Archetype; + ensureArchetype>( + components: readonly CC[] | ReadonlySet, + ): HasPartitionKey extends true + ? Router + : Archetype; + ensureArchetype>( + components: readonly CC[] | ReadonlySet, + partitionValues: { readonly [K in Extract]: (C & RequiredComponents & OptionalComponents)[K] }, + ): Archetype; locate: (entity: Entity) => { archetype: Archetype, row: number } | null; delete: (entity: Entity) => void; update: (entity: Entity, values: EntityUpdateValues) => void; diff --git a/packages/data/src/ecs/store/core/create-core.partition.test.ts b/packages/data/src/ecs/store/core/create-core.partition.test.ts new file mode 100644 index 00000000..8e1c7274 --- /dev/null +++ b/packages/data/src/ecs/store/core/create-core.partition.test.ts @@ -0,0 +1,187 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { describe, it, expect } from "vitest"; +import { createCore } from "./create-core.js"; +import { Schema } from "../../../schema/index.js"; +import { constBufferType } from "../../../typed-buffer/create-const-buffer.js"; + +const positionSchema = { + type: "object", + properties: { x: { type: "number" }, y: { type: "number" } }, +} as const satisfies Schema; + +// A partition component: every distinct `cell` value gets its own archetype. +const cellSchema = { type: "integer", partition: true } as const satisfies Schema; + +const makeCore = () => createCore({ cell: cellSchema, position: positionSchema }); + +describe("partition components", () => { + describe("ensureArchetype return discrimination", () => { + it("returns a Router (no dense view) when a partition key is present without a value", () => { + const core = makeCore(); + const router = core.ensureArchetype(["id", "cell", "position"]); + expect(typeof router.insert).toBe("function"); + // A family has no single dense column view. + expect((router as any).columns).toBeUndefined(); + expect((router as any).rowCount).toBeUndefined(); + }); + + it("returns a concrete Archetype when the partition value is supplied", () => { + const core = makeCore(); + const arch = core.ensureArchetype(["id", "cell", "position"], { cell: 7 }); + expect(arch.columns).toBeDefined(); + expect(arch.rowCount).toBe(0); + }); + + it("returns a concrete Archetype (not a Router) when no partition component is in the set", () => { + const core = makeCore(); + const arch = core.ensureArchetype(["id", "position"]); + expect(arch.columns).toBeDefined(); + expect((arch as any).insert).toBeDefined(); + }); + }); + + describe("routing insert", () => { + it("routes rows with distinct partition values into distinct archetypes", () => { + const core = makeCore(); + const router = core.ensureArchetype(["id", "cell", "position"]); + const a = router.insert({ cell: 1, position: { x: 1, y: 1 } }); + const b = router.insert({ cell: 2, position: { x: 2, y: 2 } }); + const c = router.insert({ cell: 1, position: { x: 3, y: 3 } }); + + const cell1 = core.ensureArchetype(["id", "cell", "position"], { cell: 1 }); + const cell2 = core.ensureArchetype(["id", "cell", "position"], { cell: 2 }); + + // Same value → same archetype; different value → different archetype. + expect(cell1).not.toBe(cell2); + expect(cell1.rowCount).toBe(2); // a and c + expect(cell2.rowCount).toBe(1); // b + + expect(core.locate(a)!.archetype).toBe(cell1); + expect(core.locate(c)!.archetype).toBe(cell1); + expect(core.locate(b)!.archetype).toBe(cell2); + }); + + it("stores the partition value as a const column (zero per-row bytes)", () => { + const core = makeCore(); + core.ensureArchetype(["id", "cell", "position"]).insert({ cell: 42, position: { x: 0, y: 0 } }); + const arch = core.ensureArchetype(["id", "cell", "position"], { cell: 42 }); + expect(arch.columns.cell.type).toBe(constBufferType); + expect(arch.columns.cell.typedArrayElementSizeInBytes).toBe(0); + expect(arch.columns.cell.get(0)).toBe(42); + }); + + it("reads the routed partition value back through the entity", () => { + const core = makeCore(); + const router = core.ensureArchetype(["id", "cell", "position"]); + const e = router.insert({ cell: 9, position: { x: 5, y: 6 } }); + expect(core.get(e, "cell")).toBe(9); + expect(core.read(e)).toMatchObject({ cell: 9, position: { x: 5, y: 6 } }); + }); + }); + + describe("queryArchetypes", () => { + it("returns every value-child of a partitioned component set", () => { + const core = makeCore(); + const router = core.ensureArchetype(["id", "cell", "position"]); + router.insert({ cell: 1, position: { x: 0, y: 0 } }); + router.insert({ cell: 2, position: { x: 0, y: 0 } }); + router.insert({ cell: 3, position: { x: 0, y: 0 } }); + + const all = core.queryArchetypes(["cell", "position"]); + expect(all.length).toBe(3); + }); + + it("filters to a single value-child with a partition `where` (broad phase)", () => { + const core = makeCore(); + const router = core.ensureArchetype(["id", "cell", "position"]); + router.insert({ cell: 1, position: { x: 0, y: 0 } }); + router.insert({ cell: 2, position: { x: 0, y: 0 } }); + router.insert({ cell: 2, position: { x: 0, y: 0 } }); + + const cell2 = core.queryArchetypes(["cell", "position"], { where: { cell: 2 } }); + expect(cell2.length).toBe(1); + expect(cell2[0].columns.cell.get(0)).toBe(2); + expect(cell2[0].rowCount).toBe(2); + + const none = core.queryArchetypes(["cell", "position"], { where: { cell: 99 } }); + expect(none.length).toBe(0); + }); + }); + + describe("update migration", () => { + it("migrates an entity to a new value-child when its partition value changes", () => { + const core = makeCore(); + const router = core.ensureArchetype(["id", "cell", "position"]); + const e = router.insert({ cell: 1, position: { x: 7, y: 8 } }); + + const cell1 = core.ensureArchetype(["id", "cell", "position"], { cell: 1 }); + const cell2 = core.ensureArchetype(["id", "cell", "position"], { cell: 2 }); + expect(cell1.rowCount).toBe(1); + expect(cell2.rowCount).toBe(0); + + core.update(e, { cell: 2 }); + + // Same entity id, moved archetype, position preserved. + expect(core.locate(e)!.archetype).toBe(cell2); + expect(cell1.rowCount).toBe(0); + expect(cell2.rowCount).toBe(1); + expect(core.get(e, "cell")).toBe(2); + expect(core.read(e)).toMatchObject({ cell: 2, position: { x: 7, y: 8 } }); + }); + + it("does not migrate when a non-partition field changes", () => { + const core = makeCore(); + const router = core.ensureArchetype(["id", "cell", "position"]); + const e = router.insert({ cell: 5, position: { x: 1, y: 1 } }); + const cell5 = core.locate(e)!.archetype; + + core.update(e, { position: { x: 9, y: 9 } }); + + expect(core.locate(e)!.archetype).toBe(cell5); + expect(core.read(e)).toMatchObject({ cell: 5, position: { x: 9, y: 9 } }); + }); + + it("does not migrate when a partition value is set to its current value", () => { + const core = makeCore(); + const router = core.ensureArchetype(["id", "cell", "position"]); + const e = router.insert({ cell: 3, position: { x: 0, y: 0 } }); + const before = core.locate(e)!.archetype; + core.update(e, { cell: 3 }); + expect(core.locate(e)!.archetype).toBe(before); + }); + }); + + describe("serialization", () => { + it("round-trips partition children (values + rows) through toData/fromData", () => { + const core = makeCore(); + const router = core.ensureArchetype(["id", "cell", "position"]); + const a = router.insert({ cell: 1, position: { x: 1, y: 1 } }); + const b = router.insert({ cell: 2, position: { x: 2, y: 2 } }); + + const snapshot = core.toData(true); + + const restored = makeCore(); + restored.fromData(snapshot); + + expect(restored.get(a, "cell")).toBe(1); + expect(restored.get(b, "cell")).toBe(2); + expect(restored.read(a)).toMatchObject({ cell: 1, position: { x: 1, y: 1 } }); + expect(restored.read(b)).toMatchObject({ cell: 2, position: { x: 2, y: 2 } }); + + const cell1 = restored.ensureArchetype(["id", "cell", "position"], { cell: 1 }); + expect(cell1.columns.cell.type).toBe(constBufferType); + expect(cell1.rowCount).toBe(1); + }); + }); + + describe("feature is inert without partition components", () => { + it("a store with no partition component behaves exactly as before", () => { + const core = createCore({ position: positionSchema }); + const arch = core.ensureArchetype(["id", "position"]); + // Concrete archetype, direct insert, dense columns — no Router anywhere. + expect(arch.columns).toBeDefined(); + const e = arch.insert({ position: { x: 1, y: 2 } }); + expect(core.read(e)).toMatchObject({ position: { x: 1, y: 2 } }); + }); + }); +}); diff --git a/packages/data/src/ecs/store/core/create-core.ts b/packages/data/src/ecs/store/core/create-core.ts index ef94f1bc..60a518f4 100644 --- a/packages/data/src/ecs/store/core/create-core.ts +++ b/packages/data/src/ecs/store/core/create-core.ts @@ -12,6 +12,7 @@ import { Assert, Equal, Simplify, StringKeyof } from "../../../types/index.js"; import { ComponentSchemas } from "../../component-schemas.js"; import { OptionalComponents } from "../../optional-components.js"; import { True } from "../../../schema/true/index.js"; +import { PartitionKeysOf } from "../partition.js"; /** * Serialization format version stamped into every `toData` snapshot and @@ -35,6 +36,11 @@ export const ECS_SNAPSHOT_VERSION = 1; */ type SerializedArchetype = { readonly componentNames: readonly string[]; + // Per-partition-component const values, needed to reconstruct the exact + // value-child on restore (the value is part of archetype identity but is + // not derivable from `componentNames` alone). Absent for non-partition + // archetypes. + readonly partitionValues?: Record; readonly data?: unknown; }; @@ -45,7 +51,17 @@ type SerializedCore = { readonly archetypesData: readonly SerializedArchetype[]; }; -export function createCore(newComponentSchemas: NC): Core]: Schema.ToType }>> { +export function createCore( + newComponentSchemas: NC, + /** + * Called once, right after each archetype is created (direct or lazily as a + * partition value-child). The Store layer uses it to decorate the + * archetype's `insert` in place with index maintenance — so every write + * path (direct insert, router routing, migration target) shares one + * maintained insert with no wrapper or Proxy indirection. + */ + onArchetypeCreated?: (archetype: Archetype) => void, +): Core]: Schema.ToType }>, PartitionKeysOf> { type C = RequiredComponents & { [K in StringKeyof]: Schema.ToType }; const componentSchemas: { readonly [K in StringKeyof]: Schema } = { @@ -58,6 +74,35 @@ export function createCore(newComponentSchemas: NC) const getLocationTable = (entity: Entity) => entity < 0 ? nonPersistentLocationTable : persistentLocationTable; const archetypes = [] as unknown as Archetype[] & { readonly [x: string]: Archetype }; + // A component declared `partition: true`: every distinct runtime value gets + // its own archetype whose column for that component is a const buffer (zero + // per-row bytes). Read from the *live* component schema so components added + // after createCore (via the Store layer's `extend`) are detected too. Cost + // is only paid when *resolving* an archetype (an O(names) scan) — never on + // the per-row insert hot path, which goes straight to a concrete archetype. + const isPartition = (name: string): boolean => + (componentSchemas[name as StringKeyof] as Schema | undefined)?.partition === true; + + // Identity → archetype. Replaces the former linear scan (O(archetypes) per + // resolve) with an O(1) lookup, and — for partition components — folds the + // per-archetype const value into the key so value-children are distinct. + const archetypeByIdentity = new Map>(); + const partitionNamesIn = (sortedNames: readonly string[]): string[] => + sortedNames.filter(isPartition); + const identityKey = ( + sortedNames: readonly string[], + partitionValues: Record | undefined, + partitionNames: readonly string[], + ): string => { + const base = sortedNames.join(","); + if (partitionNames.length === 0) return base; + // `typeof` tag guards "1"(number) vs "1"(string) collisions. + const vals = partitionNames + .map((n) => `${n}=${typeof partitionValues![n]}:${String(partitionValues![n])}`) + .join(","); + return `${base}|${vals}`; + }; + const queryArchetypes = < Include extends StringKeyof, >( @@ -65,38 +110,64 @@ export function createCore(newComponentSchemas: NC) options?: ArchetypeQueryOptions ): readonly Archetype>[] => { const includeArray = Array.from(include); + const where = options?.where as Record | undefined; const results: Archetype>[] = []; for (const archetype of archetypes) { const hasAllRequired = includeArray.every(comp => archetype.columns[comp] !== undefined); const hasNoExcluded = !options?.exclude || options.exclude.every(comp => archetype.columns[comp] === undefined); - if (hasAllRequired && hasNoExcluded) { + // Partition `where`: a partition column is const per archetype, so a + // value filter is decidable at archetype granularity (O(archetypes), + // no row scan). Read the const via get(0) — valid even at rowCount 0. + let matchesWhere = true; + if (where) { + for (const key in where) { + const column = archetype.columns[key]; + if (column === undefined || column.get(0) !== where[key]) { + matchesWhere = false; + break; + } + } + } + if (hasAllRequired && hasNoExcluded && matchesWhere) { results.push(archetype as unknown as Archetype>); } } return results; } - const ensureArchetype = >(componentNames: readonly CC[] | ReadonlySet): Archetype => { - const componentCount = Array.isArray(componentNames) - ? (componentNames as readonly CC[]).length - : (componentNames as ReadonlySet).size; - for (const archetype of queryArchetypes(componentNames)) { - if (archetype.components.size === componentCount) { - return archetype as unknown as Archetype; + // Concrete archetype for exactly `componentNames`, resolved by identity and + // created on first use. A partition component in the set requires its value + // in `partitionValues`; that value is baked into the column schema as + // `const`, so the column is a zero-per-row const buffer. This is the single + // internal primitive behind ensureArchetype, the router, migration, and + // restore — the *only* place archetypes are created. + const resolveArchetype = ( + componentNames: readonly string[] | ReadonlySet, + partitionValues?: Record, + ): Archetype => { + const namesArr = Array.from(componentNames); + const sorted = namesArr.slice().sort(); + const partitionNames = partitionNamesIn(sorted); + for (const n of partitionNames) { + if (partitionValues?.[n] === undefined) { + throw new Error(`partition component '${n}' requires a value to resolve a concrete archetype`); } } + const key = identityKey(sorted, partitionValues, partitionNames); + const existing = archetypeByIdentity.get(key); + if (existing) return existing; + const id = archetypes.length; - const archetypeComponentSchemas: { [K in CC]: Schema } = {} as { [K in CC]: Schema }; + const archetypeComponentSchemas: Record = {}; let hasId = false; let isNonPersistent = false; - for (const comp of componentNames as Iterable) { - if (comp === "id") { - hasId = true; - } - if (comp === "nonPersistent") { - isNonPersistent = true; - } - archetypeComponentSchemas[comp] = componentSchemas[comp]; + for (const comp of namesArr) { + if (comp === "id") hasId = true; + if (comp === "nonPersistent") isNonPersistent = true; + const base = componentSchemas[comp as StringKeyof]; + archetypeComponentSchemas[comp] = isPartition(comp) + ? { ...base, const: partitionValues![comp] } + : base; } if (!hasId) { throw new Error("id is required"); @@ -107,8 +178,44 @@ export function createCore(newComponentSchemas: NC) isNonPersistent ? nonPersistentLocationTable : persistentLocationTable ); archetypes.push(archetype as unknown as Archetype); - return archetype as unknown as Archetype; - } + archetypeByIdentity.set(key, archetype); + onArchetypeCreated?.(archetype); + return archetype; + }; + + // Write-only handle over a partition family: reads the partition value(s) + // from each row, resolves (creating on first use) the concrete child, and + // inserts there. Its `insert` is the by-keys routing inserter. + const makeRouter = (componentNames: readonly string[] | ReadonlySet) => { + const namesArr = Array.from(componentNames); + const partitionNames = partitionNamesIn(namesArr.slice().sort()); + const partitionValuesOf = (rowData: any): Record => { + const values: Record = {}; + for (const n of partitionNames) values[n] = rowData[n]; + return values; + }; + return { + components: new Set(namesArr) as Set, + // Routes to the concrete child and calls its `insert`. When the Store + // layer has decorated inserts with index maintenance (via + // onArchetypeCreated), the child's `insert` is already the maintained + // one — so routing needs no special-casing at the Store layer. + insert: (rowData: any): Entity => resolveArchetype(namesArr, partitionValuesOf(rowData)).insert(rowData), + }; + }; + + const ensureArchetype = (( + componentNames: readonly string[] | ReadonlySet, + partitionValues?: Record, + ): any => { + if (partitionValues === undefined) { + const namesArr = Array.from(componentNames); + if (namesArr.some(isPartition)) { + return makeRouter(componentNames); + } + } + return resolveArchetype(componentNames, partitionValues); + }) as Core["ensureArchetype"]; const locateInternal = (entity: Entity) => { return (entity < 0 ? nonPersistentLocationTable : persistentLocationTable).locate(entity); @@ -161,7 +268,18 @@ export function createCore(newComponentSchemas: NC) (addComponents ??= []).push(key as StringKeyof); } } - if (addComponents || removeComponents) { + // A partition value change migrates the entity to a different child + // archetype even when the component *set* is unchanged, because the + // partition value is part of archetype identity. + let partitionValueChanged = false; + for (const key in components) { + if (isPartition(key) && currentArchetype.components.has(key as StringKeyof)) { + if ((components as any)[key] !== currentArchetype.columns[key]!.get(currentLocation.row)) { + partitionValueChanged = true; + } + } + } + if (addComponents || removeComponents || partitionValueChanged) { // currently changing archetype requires a set, but later we should have an edge map for better performance // Alternatively we can have a faster path using addComponent and deleteComponent. const newComponents = new Set(currentArchetype.components); @@ -175,7 +293,19 @@ export function createCore(newComponentSchemas: NC) newComponents.delete(comp); } } - newArchetype = ensureArchetype(newComponents as ReadonlySet>) as unknown as Archetype; + // Target child's partition values: the updated value where provided, + // else the entity's current (const) value. + let newPartitionValues: Record | undefined; + const targetPartitionNames = partitionNamesIn([...newComponents].sort()); + if (targetPartitionNames.length > 0) { + newPartitionValues = {}; + for (const n of targetPartitionNames) { + newPartitionValues[n] = (n in components) + ? (components as any)[n] + : currentArchetype.columns[n]?.get(currentLocation.row); + } + } + newArchetype = resolveArchetype(newComponents, newPartitionValues) as unknown as Archetype; } if (newArchetype !== currentArchetype) { // create a new row in the new archetype @@ -241,11 +371,16 @@ export function createCore(newComponentSchemas: NC) // reproduced on load. Only persistent archetypes carry `data`; // nonPersistent ones back the negative-ID space, whose location // table is never serialized, so their rows aren't persistent. - archetypesData: archetypes.map((archetype): SerializedArchetype => - archetype.components.has("nonPersistent") - ? { componentNames: [...archetype.components] } - : { componentNames: [...archetype.components], data: archetype.toData(copy) } - ) + archetypesData: archetypes.map((archetype): SerializedArchetype => { + const componentNames = [...archetype.components]; + const partitionNames = partitionNamesIn(componentNames.slice().sort()); + const partitionValues = partitionNames.length > 0 + ? Object.fromEntries(partitionNames.map((n) => [n, archetype.columns[n]!.get(0)])) + : undefined; + return archetype.components.has("nonPersistent") + ? { componentNames, partitionValues } + : { componentNames, partitionValues, data: archetype.toData(copy) }; + }) }), fromData: (data: SerializedCore) => { if (data.version !== ECS_SNAPSHOT_VERSION) { @@ -273,10 +408,12 @@ export function createCore(newComponentSchemas: NC) } } persistentLocationTable.fromData(data.entityLocationTableData); - for (const { componentNames, data: archetypeData } of data.archetypesData) { + for (const { componentNames, partitionValues, data: archetypeData } of data.archetypesData) { // Recreating the archetype reserves its id and leaves it // empty; only persistent entries carry data to restore. - const archetype = ensureArchetype(componentNames as StringKeyof[]); + // resolveArchetype (not the public ensureArchetype) so a + // partition archetype restores as its concrete value-child. + const archetype = resolveArchetype(componentNames, partitionValues); if (archetypeData !== undefined) { archetype.fromData(archetypeData); } diff --git a/packages/data/src/ecs/store/core/select-entities.ts b/packages/data/src/ecs/store/core/select-entities.ts index e33b47bf..26e0c0be 100644 --- a/packages/data/src/ecs/store/core/select-entities.ts +++ b/packages/data/src/ecs/store/core/select-entities.ts @@ -17,7 +17,10 @@ export const selectEntities = < include: readonly Include[] | ReadonlySet, options?: EntitySelectOptions> ): readonly Entity[] => { - const archetypes = core.queryArchetypes(include, options as any); + // Pass only archetype-level options (exclude) to queryArchetypes. `options.where` + // here is a row-level Filter and is applied per-row below via selectRows — it + // must NOT be forwarded as queryArchetypes' partition-equality `where`. + const archetypes = core.queryArchetypes(include, options?.exclude ? { exclude: options.exclude as any } : undefined); let length = 0; for (const archetype of archetypes) { length += archetype.rowCount; diff --git a/packages/data/src/ecs/store/entity-select-options.ts b/packages/data/src/ecs/store/entity-select-options.ts index 706f6d49..eb8cd2a3 100644 --- a/packages/data/src/ecs/store/entity-select-options.ts +++ b/packages/data/src/ecs/store/entity-select-options.ts @@ -8,9 +8,13 @@ export type OrderClause = { [K in keyof T]?: boolean }; export type EntitySelectOptions< C extends object, T extends object, -> = ArchetypeQueryOptions & { +> = Omit, "where"> & { /** * Filter the results by the given condition using a declarative where clause. + * This is a *row-level* {@link Filter} (supports comparison operators), + * distinct from `ArchetypeQueryOptions.where`, which is archetype-level + * partition-value equality. Select owns row filtering, so it replaces the + * inherited partition `where` rather than intersecting with it. */ where?: Filter; /** diff --git a/packages/data/src/ecs/store/partition.ts b/packages/data/src/ecs/store/partition.ts new file mode 100644 index 00000000..3d59d4c4 --- /dev/null +++ b/packages/data/src/ecs/store/partition.ts @@ -0,0 +1,70 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. + +import { StringKeyof } from "../../types/types.js"; +import { ComponentSchemas } from "../component-schemas.js"; +import { RequiredComponents } from "../required-components.js"; +import { Archetype, Router } from "../archetype/archetype.js"; + +/** + * The component keys of a schema map that are declared `partition: true`. + * + * Detection is at the *schema* level, where the marker survives — the resolved + * component value map (`FromSchemas`) has already dropped it. Requires the + * schema literal to be `as const` so `partition` is the literal `true`, not + * `boolean` (the project's `... as const satisfies Schema` idiom guarantees it). + */ +export type PartitionKeysOf = { + [K in StringKeyof]: CS[K] extends { partition: true } ? K : never; +}[StringKeyof]; + +/** + * Whether a component-key set intersects the store's partition keys. + * + * The `[PK] extends [never]` guard is load-bearing: when a store declares no + * partition components (`PK = never` — the default everywhere), this collapses + * to `false` *without* inspecting `Keys`. That matters because `Keys` is often a + * generic indexed access (`A[K][number]` inside a `TransactionContext`); + * `Extract` would otherwise stay a *deferred* conditional, + * leaving `store.archetypes.` as the unresolved union `Router | Archetype` + * and breaking assignability in every generic store context. Short-circuiting + * on `PK` keeps non-partition stores paying nothing and resolving eagerly. + */ +export type HasPartitionKey = + [PK] extends [never] ? false + : [Extract] extends [never] ? false : true; + +/** + * Return type of `ensureArchetype(keys, values?)`: + * - a value is supplied → a concrete {@link Archetype} (the resolved member), + * regardless of partitioning — this is the same-value fast path; + * - else the key set includes a partition component → a {@link Router} that + * routes each `insert` by the row's partition value; + * - else → a concrete {@link Archetype} (today's behavior, unchanged). + * + * When `Keys` is not statically known to include or exclude a partition + * component, `HasPartitionKey` stays a union and the result widens to + * `Archetype | Router` — which still exposes `.insert` (both branches + * share it); only dense column access requires narrowing to `Archetype`. + */ +export type EnsureArchetypeResult< + C extends RequiredComponents, + Keys extends string, + PK extends string, + ValueProvided extends boolean, +> = ValueProvided extends true + ? Archetype + : HasPartitionKey extends true + ? Router + : Archetype; + +/** + * Type of `store.archetypes.`: a {@link Router} when the declared archetype + * includes a partition component, else a concrete {@link Archetype}. The keys are + * statically known here (from the schema's `archetypes` map), so this never + * widens to a union — the discrimination is exact per declared name. + */ +export type StoreArchetypeHandle< + C extends RequiredComponents, + Keys extends string, + PK extends string, +> = HasPartitionKey extends true ? Router : Archetype; diff --git a/packages/data/src/ecs/store/partition.type-test.ts b/packages/data/src/ecs/store/partition.type-test.ts new file mode 100644 index 00000000..1929c739 --- /dev/null +++ b/packages/data/src/ecs/store/partition.type-test.ts @@ -0,0 +1,104 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. + +import { Assert } from "../../types/assert.js"; +import { Equal } from "../../types/equal.js"; +import { RequiredComponents } from "../required-components.js"; +import { Archetype, Router } from "../archetype/archetype.js"; +import { + PartitionKeysOf, + HasPartitionKey, + EnsureArchetypeResult, + StoreArchetypeHandle, +} from "./partition.js"; + +// --------------------------------------------------------------------------- +// Fixtures +// --------------------------------------------------------------------------- + +type Schemas = { + id: { type: "integer" }; + position: { type: "number" }; + cell: { type: "integer"; partition: true }; + team: { type: "string"; partition: true }; +}; + +type NoPartitionSchemas = { + id: { type: "integer" }; + position: { type: "number" }; +}; + +// A schema that names the field but opts out — must NOT count as a partition. +type OptOutSchemas = { + a: { type: "number"; partition: false }; + b: { type: "number" }; +}; + +type PK = PartitionKeysOf; // "cell" | "team" + +type CellRow = RequiredComponents & { cell: number; position: number }; +type PosRow = RequiredComponents & { position: number }; + +// --------------------------------------------------------------------------- +// PartitionKeysOf — positive +// --------------------------------------------------------------------------- + +type _keys = Assert, "cell" | "team">>; +type _keysNone = Assert, never>>; +type _keysOptOut = Assert, never>>; + +// PartitionKeysOf — negative: a non-partition column must not be extracted. +// @ts-expect-error - "position" is not declared partition, so this Equal is false +type _keysWrong = Assert, "cell" | "team" | "position">>; + +// --------------------------------------------------------------------------- +// HasPartitionKey — positive +// --------------------------------------------------------------------------- + +type _has = Assert, true>>; +type _hasNot = Assert, false>>; + +// --------------------------------------------------------------------------- +// EnsureArchetypeResult — positive +// --------------------------------------------------------------------------- + +// partition key present, no value → Router +type _r1 = Assert, Router>>; +// partition key present, value supplied → concrete Archetype (fast path) +type _r2 = Assert, Archetype>>; +// no partition key → concrete Archetype (unchanged behavior) +type _r3 = Assert, Archetype>>; + +// EnsureArchetypeResult — negative: a partitioned, value-less request is NOT a +// concrete Archetype (that's the whole point — it can't be one). +// @ts-expect-error - this must be a Router, not an Archetype +type _rWrong = Assert, Archetype>>; + +// --------------------------------------------------------------------------- +// StoreArchetypeHandle — positive +// --------------------------------------------------------------------------- + +type _h1 = Assert, Router>>; +type _h2 = Assert, Archetype>>; + +// --------------------------------------------------------------------------- +// Runtime-surface guarantees (the load-bearing ergonomics) +// --------------------------------------------------------------------------- + +declare const router: Router; + +// A Router can insert (routing write) ... +router.insert({ cell: 1, position: 2 }); +// ... but exposes no dense view: +// @ts-expect-error - a family has no single dense column view +router.columns; +// @ts-expect-error - a family has no row count +router.rowCount; + +// The discriminated union (dynamic keys) still permits `.insert` without +// narrowing — both branches share an identical signature ... +declare const handle: Archetype | Router; +handle.insert({ cell: 1, position: 2 }); +// ... while dense access is correctly gated behind resolving to a concrete +// archetype (supply a value, or narrow): +// @ts-expect-error - .columns requires a concrete Archetype, not a possible family +handle.columns; diff --git a/packages/data/src/ecs/store/public/create-store.partition.test.ts b/packages/data/src/ecs/store/public/create-store.partition.test.ts new file mode 100644 index 00000000..661408bc --- /dev/null +++ b/packages/data/src/ecs/store/public/create-store.partition.test.ts @@ -0,0 +1,145 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { describe, it, expect } from "vitest"; +import { createStore } from "./create-store.js"; +import { constBufferType } from "../../../typed-buffer/create-const-buffer.js"; + +// `cell` partitions; `name` is uniquely indexed. Runtime routing is driven by +// the schema value's `partition: true`, so these tests exercise the Store layer +// (index maintenance, unique enforcement) over routed inserts. +const schema = { + components: { + cell: { type: "integer", partition: true }, + name: { type: "string" }, + }, + resources: {}, + archetypes: { Spatial: ["cell", "name"] }, + indexes: { byName: { key: "name", unique: true } }, +} as const; + +// Runtime behavior test — the store is intentionally `any`-typed here; the +// static discrimination is covered by create-store.partition.type-test.ts. +const makeStore = (): any => createStore(schema as any); + +describe("Store partition integration", () => { + it("dynamically extends with a partition component AFTER creation; N values → N archetypes, each with a const column", () => { + // Start with an empty store, then extend it — this exercises the live + // (post-construction) partition detection, not the schema passed at + // creation time. + const store: any = createStore(); + store.extend({ + components: { + cell: { type: "integer", partition: true }, + name: { type: "string" }, + }, + resources: {}, + archetypes: { Spatial: ["cell", "name"] }, + indexes: { byName: { key: "name", unique: true } }, + }); + + // The dynamically-added partitioned archetype is a Router (no dense view). + const spatial = store.archetypes.Spatial; + expect(spatial.columns).toBeUndefined(); + + // Insert N entities with N distinct partition values. + const N = 5; + const entities: number[] = []; + for (let i = 0; i < N; i++) { + entities.push(spatial.insert({ cell: i, name: `e${i}` })); + } + + // Query the archetypes — exactly N, one per distinct value. + const archetypes = store.queryArchetypes(["cell", "name"]); + expect(archetypes.length).toBe(N); + + // Every distinct value lives in its own archetype, and each archetype + // still carries a named `cell` column that is a const typed buffer + // (zero per-row bytes). + const valuesSeen = new Set(); + for (const arch of archetypes) { + expect(arch.rowCount).toBe(1); + const cellColumn = arch.columns.cell; + expect(cellColumn.type).toBe(constBufferType); + expect(cellColumn.typedArrayElementSizeInBytes).toBe(0); + valuesSeen.add(cellColumn.get(0)); + } + expect([...valuesSeen].sort((a, b) => a - b)).toEqual([0, 1, 2, 3, 4]); + + // The index (also added dynamically) resolves each routed entity. + for (let i = 0; i < N; i++) { + expect((store.indexes as any).byName.get({ name: `e${i}` })).toBe(entities[i]); + } + }); + + + it("store.archetypes. is a Proxy-free Router that routes inserts", () => { + const store = makeStore(); + const spatial = store.archetypes.Spatial as any; + expect(typeof spatial.insert).toBe("function"); + expect(spatial.columns).toBeUndefined(); // a family has no dense view + + const a = spatial.insert({ cell: 1, name: "a" }); + const b = spatial.insert({ cell: 2, name: "b" }); + expect(store.locate(a)!.archetype).not.toBe(store.locate(b)!.archetype); + expect(store.get(a, "cell")).toBe(1); + expect(store.get(b, "cell")).toBe(2); + }); + + it("maintains indexes on routed inserts (find across value-children)", () => { + const store = makeStore(); + const spatial = store.archetypes.Spatial as any; + const a = spatial.insert({ cell: 1, name: "alice" }); + const b = spatial.insert({ cell: 2, name: "bob" }); + const byName = (store.indexes as any).byName; + expect(byName.get({ name: "alice" })).toBe(a); + expect(byName.get({ name: "bob" })).toBe(b); + }); + + it("enforces a unique index across different value-children, atomically", () => { + const store = makeStore(); + const spatial = store.archetypes.Spatial as any; + const first = spatial.insert({ cell: 1, name: "dup" }); + // Same name, different cell → different archetype, still a unique conflict. + expect(() => spatial.insert({ cell: 2, name: "dup" })).toThrow(/[Uu]nique/); + // The original row is intact (atomic — no partial mutation). + expect((store.indexes as any).byName.get({ name: "dup" })).toBe(first); + }); + + it("queryArchetypes `where` filters value-children at the store layer", () => { + const store = makeStore(); + const spatial = store.archetypes.Spatial as any; + spatial.insert({ cell: 1, name: "a" }); + spatial.insert({ cell: 2, name: "b" }); + spatial.insert({ cell: 2, name: "c" }); + const cell2 = store.queryArchetypes(["cell", "name"], { where: { cell: 2 } } as any); + expect(cell2.length).toBe(1); + expect(cell2[0].rowCount).toBe(2); + }); + + it("store.ensureArchetype routes (no value) and resolves concrete (with value)", () => { + const store = makeStore(); + const router = store.ensureArchetype(["id", "cell", "name"]) as any; + expect(router.columns).toBeUndefined(); + router.insert({ cell: 5, name: "x" }); + + const concrete = store.ensureArchetype(["id", "cell", "name"], { cell: 5 } as any) as any; + expect(concrete.columns).toBeDefined(); + expect(concrete.rowCount).toBe(1); + }); + + it("index find works when inserting via the concrete-child path too", () => { + const store = makeStore(); + const concrete = store.ensureArchetype(["id", "cell", "name"], { cell: 9 } as any) as any; + const e = concrete.insert({ cell: 9, name: "zed" }); + expect((store.indexes as any).byName.get({ name: "zed" })).toBe(e); + }); + + it("index updates when a partition-value change migrates an entity", () => { + const store = makeStore(); + const spatial = store.archetypes.Spatial as any; + const e = spatial.insert({ cell: 1, name: "mover" }); + store.update(e, { cell: 2 }); + // Entity moved value-children; the name index still resolves it. + expect(store.get(e, "cell")).toBe(2); + expect((store.indexes as any).byName.get({ name: "mover" })).toBe(e); + }); +}); diff --git a/packages/data/src/ecs/store/public/create-store.partition.type-test.ts b/packages/data/src/ecs/store/public/create-store.partition.type-test.ts new file mode 100644 index 00000000..31e9ee37 --- /dev/null +++ b/packages/data/src/ecs/store/public/create-store.partition.type-test.ts @@ -0,0 +1,87 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +// +// Compile-time (red/green) verification of the partition public surface on +// `Store` — discrimination of `store.archetypes.` and `ensureArchetype`, +// and the partition-keyed `queryArchetypes` where. Bodies live in never-called +// functions so the file type-checks without executing. + +import { Store } from "../store.js"; +import { createStore } from "./create-store.js"; + +// ── Fixture: `cell` partitions; `position`/`health` do not ── +type SpatialStoreSchema = { + components: { + cell: { type: "integer"; partition: true }; + position: { type: "number" }; + health: { type: "number" }; + }; + resources: {}; + archetypes: { + Spatial: ["cell", "position"]; // includes a partition component → family + Mob: ["position", "health"]; // no partition component → concrete + }; +}; + +type SpatialStore = Store.FromSchema; +declare const store: SpatialStore; + +// store.archetypes. — discriminated per declared name +function _archetypesMap() { + // A partitioned named archetype is a Router — write via insert, no dense view. + store.archetypes.Spatial.insert({ cell: 1, position: 2 }); + // @ts-expect-error - Spatial is a partition family (Router), not a concrete Archetype + store.archetypes.Spatial.columns; + // @ts-expect-error - a Router has no rowCount + store.archetypes.Spatial.rowCount; + + // A non-partitioned named archetype is a concrete Archetype. + store.archetypes.Mob.columns; + store.archetypes.Mob.rowCount; + store.archetypes.Mob.insert({ position: 1, health: 100 }); +} + +// store.ensureArchetype(keys, values?) — discriminated +function _ensureArchetype() { + // Partition key present, no value → Router (insert only). + const router = store.ensureArchetype(["id", "cell", "position"]); + router.insert({ cell: 1, position: 2 }); + // @ts-expect-error - a partition family (Router) has no columns + router.columns; + + // Partition value supplied → concrete Archetype (dense access). + const concrete = store.ensureArchetype(["id", "cell", "position"], { cell: 7 }); + concrete.columns; + concrete.rowCount; + + // No partition component in the set → concrete Archetype. + const plain = store.ensureArchetype(["id", "position"]); + plain.columns; + + // @ts-expect-error - partition value must match the component's type (cell is number) + store.ensureArchetype(["id", "cell", "position"], { cell: "nope" }); +} + +// queryArchetypes where — keyed to partition components only +function _queryWhere() { + store.queryArchetypes(["cell", "position"], { where: { cell: 7 } }); + // @ts-expect-error - `position` is not a partition component; not archetype-decidable + store.queryArchetypes(["cell", "position"], { where: { position: 1 } }); + // @ts-expect-error - partition where value must match the component type (cell is number) + store.queryArchetypes(["cell"], { where: { cell: "x" } }); +} + +// createStore return threads PK (inference, not just Store.FromSchema) +function _inferenceCheck() { + const s = createStore({ + components: { + cell: { type: "integer", partition: true }, + position: { type: "number" }, + }, + resources: {}, + archetypes: { Spatial: ["cell", "position"] }, + } as const); + s.archetypes.Spatial.insert({ cell: 1, position: 2 }); + // @ts-expect-error - inferred Spatial is a Router (partition family), no columns + s.archetypes.Spatial.columns; + return s; +} diff --git a/packages/data/src/ecs/store/public/create-store.ts b/packages/data/src/ecs/store/public/create-store.ts index 1c347156..061b490c 100644 --- a/packages/data/src/ecs/store/public/create-store.ts +++ b/packages/data/src/ecs/store/public/create-store.ts @@ -19,6 +19,7 @@ import { IndexDeclarationObject, RuntimeIndex, } from "../../database/index-registry/index.js"; +import { PartitionKeysOf } from "../partition.js"; export function createStore< CS extends ComponentSchemas = {}, @@ -26,7 +27,7 @@ export function createStore< A extends ArchetypeComponents> = {} >( schema?: Store.Schema, -): Store, FromSchemas, A> { +): Store, FromSchemas, A, {}, PartitionKeysOf> { const schemaArg = schema as any; const hasSchemaShape = schemaArg && @@ -52,7 +53,10 @@ export function createStore< const archetypeComponentNames = {} as A; const componentAndResourceSchemas: { [K in StringKeyof]: Schema } = {} as any; - const core = createCore(componentAndResourceSchemas) as unknown as Core; + const core = createCore( + componentAndResourceSchemas, + (archetype) => decorateArchetypeForIndexes(archetype), + ) as unknown as Core; // Index registry. Owned at the Store layer because index state is // *derived* from store data, and every mutation that needs to keep @@ -206,58 +210,26 @@ export function createStore< const archetypes = {} as any; - /** - * Wraps a raw Core archetype so `insert` maintains index state. Pre-checks - * unique constraints *before* the column mutation so a collision throws - * without partially mutating the store. Other archetype methods are - * passed through unchanged via the Proxy. - * - * Cached by raw archetype identity so repeated lookups - * (`store.archetypes.X`, `store.ensureArchetype([...])`) all return the - * same wrapper reference — code that uses `===` to compare archetypes - * keeps working. - */ - const archetypeWrapCache = new WeakMap(); - const wrapArchetypeForIndexes = (archetype: any): any => { - const cached = archetypeWrapCache.get(archetype); - if (cached) return cached; + // Decorate an archetype's `insert` in place with index maintenance. The core + // calls this (via `onArchetypeCreated`) the moment any archetype is created — + // direct or a lazily-created partition value-child — so every write path + // (direct insert, router routing, migration target) shares one maintained + // insert. No Proxy and no forwarding wrapper: the archetype you hold *is* the + // index-maintaining one, and `===` identity is preserved for free. + const decorateArchetypeForIndexes = (archetype: any): void => { const rawInsert = archetype.insert.bind(archetype); - const wrappedInsert = (values: any) => { - // `archetype` is the raw core archetype — its id + component set - // let the registry dispatch to only the applicable indexes. + archetype.insert = (values: any): Entity => { + // Pre-check unique constraints before the column mutation so a + // collision throws without partially mutating the store. The + // archetype's id + component set let the registry dispatch to only + // the applicable indexes. indexRegistry.checkUniqueAvailableForInsert(archetype, values); const entity = rawInsert(values); indexRegistry.applyInsert(entity, archetype, values); return entity; }; - const wrapped = new Proxy(archetype, { - get(target, prop, receiver) { - if (prop === "insert") return wrappedInsert; - return Reflect.get(target, prop, receiver); - }, - }); - archetypeWrapCache.set(archetype, wrapped); - return wrapped; }; - const ensureArchetype = ((componentNames: any) => { - return wrapArchetypeForIndexes(core.ensureArchetype(componentNames)); - }) as Core["ensureArchetype"]; - - // `queryArchetypes` and `locate` also surface archetypes — wrap them - // so `===` comparisons against `store.archetypes.X` continue to hold. - // Cached wrappers guarantee identity stability per raw archetype. - const queryArchetypes = ((include: any, options?: any) => { - const raw = core.queryArchetypes(include, options); - return raw.map((a) => wrapArchetypeForIndexes(a)); - }) as Core["queryArchetypes"]; - - const locate = ((entity: Entity) => { - const loc = core.locate(entity); - if (loc === null) return null; - return { archetype: wrapArchetypeForIndexes(loc.archetype), row: loc.row }; - }) as Core["locate"]; - const updateEntity = (entity: Entity, values: any) => { indexRegistry.checkUniqueAvailableForUpdate(entity, values); // `update` can move the entity to a different archetype (when it @@ -320,8 +292,9 @@ export function createStore< continue; } archetypeComponentNames[name as keyof typeof archetypeComponentNames] = newComponents as any; - const archetype = core.ensureArchetype(["id", ...(newComponents as any)]); - (archetypes as any)[name] = wrapArchetypeForIndexes(archetype); + // Insert is already index-maintaining (decorated at creation), and a + // partitioned archetype resolves to a Router — both surfaced directly. + (archetypes as any)[name] = core.ensureArchetype(["id", ...(newComponents as any)]); } // indexes: registry enforces (===)-or-throw on same name and @@ -342,10 +315,10 @@ export function createStore< } const store: Store = { + // `ensureArchetype` / `queryArchetypes` / `locate` come straight from + // `core`: inserts are decorated at creation, so no Store-layer wrapping + // is needed and `===` identity holds (the raw archetype is the handle). ...core, - ensureArchetype, - queryArchetypes, - locate, update: updateEntity, delete: deleteEntity, resources, diff --git a/packages/data/src/ecs/store/store.ts b/packages/data/src/ecs/store/store.ts index 39d14835..fd36379b 100644 --- a/packages/data/src/ecs/store/store.ts +++ b/packages/data/src/ecs/store/store.ts @@ -6,7 +6,8 @@ import { Entity } from "../entity/entity.js"; import { IntersectTuple, Simplify, StringKeyof } from "../../types/types.js"; import { Components } from "./components.js"; import { ArchetypeComponents } from "./archetype-components.js"; -import { Archetype, ReadonlyArchetype } from "../archetype/archetype.js"; +import { Archetype, ReadonlyArchetype, Router } from "../archetype/archetype.js"; +import { HasPartitionKey, PartitionKeysOf } from "./partition.js"; import { EntitySelectOptions } from "./entity-select-options.js"; import { Undoable } from "../database/undoable.js"; import { Assert } from "../../types/assert.js"; @@ -41,9 +42,13 @@ export interface ReadonlyStore< R extends ResourceComponents = never, A extends ArchetypeComponents> = never, IX extends IndexDeclarations = {}, -> extends BaseStore, ReadonlyCore { + PK extends string = never, +> extends BaseStore, ReadonlyCore { readonly resources: { readonly [K in StringKeyof]: R[K] }; - readonly archetypes: { readonly [K in StringKeyof]: ReadonlyArchetype } + readonly archetypes: { readonly [K in StringKeyof]: + HasPartitionKey extends true + ? Router + : ReadonlyArchetype } readonly indexes: { readonly [K in keyof IX]: Index.Handle }; } @@ -57,14 +62,18 @@ export interface Store< R extends ResourceComponents = {}, A extends ArchetypeComponents> = {}, IX extends IndexDeclarations = {}, -> extends BaseStore, Core { + PK extends string = never, +> extends BaseStore, Core { /** * This is used when a store is used to represent a transaction. * For most stores, this is ignored if it is set. */ undoable?: Undoable; readonly resources: { -readonly [K in StringKeyof]: R[K] }; - readonly archetypes: { -readonly [K in StringKeyof]: Archetype } + readonly archetypes: { -readonly [K in StringKeyof]: + HasPartitionKey extends true + ? Router + : Archetype } /** * Index handles keyed by user-chosen name. Returned handles are the * same references at the Store layer and the Database layer; mutations @@ -75,7 +84,7 @@ export interface Store< /** Wipe all entities and reset resources to plugin defaults. O(num_archetypes + num_resources). */ reset(): void; fromData(data: unknown): void - extend(schema: S): S extends Store.Schema ? Store : never; + extend(schema: S): S extends Store.Schema ? Store> : never; } // eslint-disable-next-line @typescript-eslint/no-namespace @@ -100,7 +109,7 @@ export namespace Store { readonly indexes?: IX; }; - export type FromSchema = T extends Store.Schema ? Store, FromSchemas, A, IX> : never; + export type FromSchema = T extends Store.Schema ? Store, FromSchemas, A, IX, PartitionKeysOf> : never; export namespace Schema { diff --git a/packages/data/src/ecs/store/transaction-functions.ts b/packages/data/src/ecs/store/transaction-functions.ts index e9dd29e6..5d2f04a4 100644 --- a/packages/data/src/ecs/store/transaction-functions.ts +++ b/packages/data/src/ecs/store/transaction-functions.ts @@ -13,7 +13,8 @@ export type TransactionDeclaration< R extends ResourceComponents, A extends ArchetypeComponents>, IX extends IndexDeclarations = {}, - Input extends any | void = any> = (t: TransactionContext, input: Input) => void | Entity; + PK extends string = never, + Input extends any | void = any> = (t: TransactionContext, input: Input) => void | Entity; export type AsyncArgsProvider = () => Promise | AsyncGenerator; @@ -22,7 +23,8 @@ export type TransactionDeclarations< R extends ResourceComponents, A extends ArchetypeComponents>, IX extends IndexDeclarations = {}, -> = { readonly [Q: string]: TransactionDeclaration }; + PK extends string = never, +> = { readonly [Q: string]: TransactionDeclaration }; /** * Converts from TransactionDeclarations to TransactionFunctions by removing diff --git a/packages/data/src/schema/schema.ts b/packages/data/src/schema/schema.ts index fd89887f..95a3ce58 100644 --- a/packages/data/src/schema/schema.ts +++ b/packages/data/src/schema/schema.ts @@ -26,6 +26,13 @@ export interface Schema { description?: string; conditionals?: readonly Conditional[]; nonPersistent?: boolean; + // When true (only valid on a primitive schema), every distinct runtime value + // of this component is stored in its own archetype: the value is lifted into + // archetype identity and held as a const column (zero per-row bytes). Entities + // sharing a value are therefore contiguous — the storage-level partition a + // coarse spatial broad-phase wants. See the archetype `Router` return of + // `ensureArchetype` and the partition `where` filter on `queryArchetypes`. + partition?: boolean; mutable?: boolean; // defaults to false default?: any; precision?: 1 | 2; From f227d5bdd4ae9c376452ec26321c2e00838e7646 Mon Sep 17 00:00:00 2001 From: Kris Nye Date: Tue, 14 Jul 2026 09:42:45 -0700 Subject: [PATCH 2/4] refactor(data): remove unsafe casts from partition tests and consumer code Fixes the root type issues the casts were hiding rather than casting: - align ArchetypeQueryOptions.exclude domain (drops `options.exclude as any` in selectEntities) - thread IX through createStore's return type - fix Store.extend to resolve component/resource schemas (C & FromSchemas) Partition tests are now fully typed and cast-free (no `as number`/`as any`); also drops an identity cast in the core router. Co-Authored-By: Claude Opus 4.8 --- .../ecs/database/database.partition.test.ts | 8 +- packages/data/src/ecs/store/core/core.ts | 2 +- .../store/core/create-core.partition.test.ts | 9 ++- .../data/src/ecs/store/core/create-core.ts | 2 +- .../src/ecs/store/core/select-entities.ts | 2 +- .../public/create-store.partition.test.ts | 74 +++++++------------ .../data/src/ecs/store/public/create-store.ts | 10 ++- packages/data/src/ecs/store/store.ts | 2 +- 8 files changed, 47 insertions(+), 62 deletions(-) diff --git a/packages/data/src/ecs/database/database.partition.test.ts b/packages/data/src/ecs/database/database.partition.test.ts index 3fa3200b..07b5288a 100644 --- a/packages/data/src/ecs/database/database.partition.test.ts +++ b/packages/data/src/ecs/database/database.partition.test.ts @@ -21,8 +21,8 @@ const plugin = () => Database.Plugin.create({ describe("Database partition integration (transaction context)", () => { it("routes t.archetypes. inserts and maintains indexes", () => { const db = Database.create(plugin()); - const a = db.transactions.add({ cell: 1, name: "alice" }) as number; - const b = db.transactions.add({ cell: 2, name: "bob" }) as number; + const a = db.transactions.add({ cell: 1, name: "alice" }); + const b = db.transactions.add({ cell: 2, name: "bob" }); expect(db.indexes.byName.get({ name: "alice" })).toBe(a); expect(db.indexes.byName.get({ name: "bob" })).toBe(b); @@ -33,7 +33,7 @@ describe("Database partition integration (transaction context)", () => { it("migrates on a partition-value change inside a transaction; index follows", () => { const db = Database.create(plugin()); - const e = db.transactions.add({ cell: 1, name: "mover" }) as number; + const e = db.transactions.add({ cell: 1, name: "mover" }); db.transactions.move({ entity: e, cell: 2 }); expect(db.get(e, "cell")).toBe(2); expect(db.indexes.byName.get({ name: "mover" })).toBe(e); @@ -41,7 +41,7 @@ describe("Database partition integration (transaction context)", () => { it("enforces a unique index across value-children inside a transaction (atomic)", () => { const db = Database.create(plugin()); - const first = db.transactions.add({ cell: 1, name: "dup" }) as number; + const first = db.transactions.add({ cell: 1, name: "dup" }); expect(() => db.transactions.add({ cell: 2, name: "dup" })).toThrow(/[Uu]nique/); // Original intact — the failed transaction rolled back cleanly. expect(db.indexes.byName.get({ name: "dup" })).toBe(first); diff --git a/packages/data/src/ecs/store/core/core.ts b/packages/data/src/ecs/store/core/core.ts index 037e63c3..bce53f9f 100644 --- a/packages/data/src/ecs/store/core/core.ts +++ b/packages/data/src/ecs/store/core/core.ts @@ -14,7 +14,7 @@ export type EntityReadValues = RequiredComponents & { readonly [K in StringKe export type EntityUpdateValues = Partial>; export type ArchetypeQueryOptions = { - exclude?: readonly StringKeyof[]; + exclude?: readonly StringKeyof[]; /** * Filter to archetypes whose partition component equals the given value. * A partition column is const per archetype, so this is decided at diff --git a/packages/data/src/ecs/store/core/create-core.partition.test.ts b/packages/data/src/ecs/store/core/create-core.partition.test.ts index 8e1c7274..6ae65fbe 100644 --- a/packages/data/src/ecs/store/core/create-core.partition.test.ts +++ b/packages/data/src/ecs/store/core/create-core.partition.test.ts @@ -20,9 +20,10 @@ describe("partition components", () => { const core = makeCore(); const router = core.ensureArchetype(["id", "cell", "position"]); expect(typeof router.insert).toBe("function"); - // A family has no single dense column view. - expect((router as any).columns).toBeUndefined(); - expect((router as any).rowCount).toBeUndefined(); + // A family has no single dense column view. `in` (not a cast) — the + // Router type has no such members, so probe presence structurally. + expect("columns" in router).toBe(false); + expect("rowCount" in router).toBe(false); }); it("returns a concrete Archetype when the partition value is supplied", () => { @@ -36,7 +37,7 @@ describe("partition components", () => { const core = makeCore(); const arch = core.ensureArchetype(["id", "position"]); expect(arch.columns).toBeDefined(); - expect((arch as any).insert).toBeDefined(); + expect(typeof arch.insert).toBe("function"); }); }); diff --git a/packages/data/src/ecs/store/core/create-core.ts b/packages/data/src/ecs/store/core/create-core.ts index 60a518f4..a05adde1 100644 --- a/packages/data/src/ecs/store/core/create-core.ts +++ b/packages/data/src/ecs/store/core/create-core.ts @@ -195,7 +195,7 @@ export function createCore( return values; }; return { - components: new Set(namesArr) as Set, + components: new Set(namesArr), // Routes to the concrete child and calls its `insert`. When the Store // layer has decorated inserts with index maintenance (via // onArchetypeCreated), the child's `insert` is already the maintained diff --git a/packages/data/src/ecs/store/core/select-entities.ts b/packages/data/src/ecs/store/core/select-entities.ts index 26e0c0be..49aed091 100644 --- a/packages/data/src/ecs/store/core/select-entities.ts +++ b/packages/data/src/ecs/store/core/select-entities.ts @@ -20,7 +20,7 @@ export const selectEntities = < // Pass only archetype-level options (exclude) to queryArchetypes. `options.where` // here is a row-level Filter and is applied per-row below via selectRows — it // must NOT be forwarded as queryArchetypes' partition-equality `where`. - const archetypes = core.queryArchetypes(include, options?.exclude ? { exclude: options.exclude as any } : undefined); + const archetypes = core.queryArchetypes(include, options?.exclude ? { exclude: options.exclude } : undefined); let length = 0; for (const archetype of archetypes) { length += archetype.rowCount; diff --git a/packages/data/src/ecs/store/public/create-store.partition.test.ts b/packages/data/src/ecs/store/public/create-store.partition.test.ts index 661408bc..819ae33b 100644 --- a/packages/data/src/ecs/store/public/create-store.partition.test.ts +++ b/packages/data/src/ecs/store/public/create-store.partition.test.ts @@ -3,9 +3,9 @@ import { describe, it, expect } from "vitest"; import { createStore } from "./create-store.js"; import { constBufferType } from "../../../typed-buffer/create-const-buffer.js"; -// `cell` partitions; `name` is uniquely indexed. Runtime routing is driven by -// the schema value's `partition: true`, so these tests exercise the Store layer -// (index maintenance, unique enforcement) over routed inserts. +// `cell` partitions; `name` is uniquely indexed. `as const` preserves the +// `partition: true` literal (so the store is typed with the discrimination) and +// the index declaration (so `store.indexes.byName` is typed). const schema = { components: { cell: { type: "integer", partition: true }, @@ -16,44 +16,28 @@ const schema = { indexes: { byName: { key: "name", unique: true } }, } as const; -// Runtime behavior test — the store is intentionally `any`-typed here; the -// static discrimination is covered by create-store.partition.type-test.ts. -const makeStore = (): any => createStore(schema as any); +const makeStore = () => createStore(schema); describe("Store partition integration", () => { it("dynamically extends with a partition component AFTER creation; N values → N archetypes, each with a const column", () => { - // Start with an empty store, then extend it — this exercises the live - // (post-construction) partition detection, not the schema passed at - // creation time. - const store: any = createStore(); - store.extend({ - components: { - cell: { type: "integer", partition: true }, - name: { type: "string" }, - }, - resources: {}, - archetypes: { Spatial: ["cell", "name"] }, - indexes: { byName: { key: "name", unique: true } }, - }); + // Start with an empty store, then extend — exercises live (post-construction) + // partition detection, not the schema passed at creation. + const store = createStore().extend(schema); // The dynamically-added partitioned archetype is a Router (no dense view). const spatial = store.archetypes.Spatial; - expect(spatial.columns).toBeUndefined(); + expect("columns" in spatial).toBe(false); // Insert N entities with N distinct partition values. const N = 5; - const entities: number[] = []; - for (let i = 0; i < N; i++) { - entities.push(spatial.insert({ cell: i, name: `e${i}` })); - } + const entities = Array.from({ length: N }, (_, i) => spatial.insert({ cell: i, name: `e${i}` })); // Query the archetypes — exactly N, one per distinct value. const archetypes = store.queryArchetypes(["cell", "name"]); expect(archetypes.length).toBe(N); - // Every distinct value lives in its own archetype, and each archetype - // still carries a named `cell` column that is a const typed buffer - // (zero per-row bytes). + // Every distinct value lives in its own archetype, and each still carries + // a named `cell` column that is a const typed buffer (zero per-row bytes). const valuesSeen = new Set(); for (const arch of archetypes) { expect(arch.rowCount).toBe(1); @@ -66,16 +50,15 @@ describe("Store partition integration", () => { // The index (also added dynamically) resolves each routed entity. for (let i = 0; i < N; i++) { - expect((store.indexes as any).byName.get({ name: `e${i}` })).toBe(entities[i]); + expect(store.indexes.byName.get({ name: `e${i}` })).toBe(entities[i]); } }); - it("store.archetypes. is a Proxy-free Router that routes inserts", () => { const store = makeStore(); - const spatial = store.archetypes.Spatial as any; + const spatial = store.archetypes.Spatial; expect(typeof spatial.insert).toBe("function"); - expect(spatial.columns).toBeUndefined(); // a family has no dense view + expect("columns" in spatial).toBe(false); // a family has no dense view const a = spatial.insert({ cell: 1, name: "a" }); const b = spatial.insert({ cell: 2, name: "b" }); @@ -86,60 +69,59 @@ describe("Store partition integration", () => { it("maintains indexes on routed inserts (find across value-children)", () => { const store = makeStore(); - const spatial = store.archetypes.Spatial as any; + const spatial = store.archetypes.Spatial; const a = spatial.insert({ cell: 1, name: "alice" }); const b = spatial.insert({ cell: 2, name: "bob" }); - const byName = (store.indexes as any).byName; - expect(byName.get({ name: "alice" })).toBe(a); - expect(byName.get({ name: "bob" })).toBe(b); + expect(store.indexes.byName.get({ name: "alice" })).toBe(a); + expect(store.indexes.byName.get({ name: "bob" })).toBe(b); }); it("enforces a unique index across different value-children, atomically", () => { const store = makeStore(); - const spatial = store.archetypes.Spatial as any; + const spatial = store.archetypes.Spatial; const first = spatial.insert({ cell: 1, name: "dup" }); // Same name, different cell → different archetype, still a unique conflict. expect(() => spatial.insert({ cell: 2, name: "dup" })).toThrow(/[Uu]nique/); // The original row is intact (atomic — no partial mutation). - expect((store.indexes as any).byName.get({ name: "dup" })).toBe(first); + expect(store.indexes.byName.get({ name: "dup" })).toBe(first); }); it("queryArchetypes `where` filters value-children at the store layer", () => { const store = makeStore(); - const spatial = store.archetypes.Spatial as any; + const spatial = store.archetypes.Spatial; spatial.insert({ cell: 1, name: "a" }); spatial.insert({ cell: 2, name: "b" }); spatial.insert({ cell: 2, name: "c" }); - const cell2 = store.queryArchetypes(["cell", "name"], { where: { cell: 2 } } as any); + const cell2 = store.queryArchetypes(["cell", "name"], { where: { cell: 2 } }); expect(cell2.length).toBe(1); expect(cell2[0].rowCount).toBe(2); }); it("store.ensureArchetype routes (no value) and resolves concrete (with value)", () => { const store = makeStore(); - const router = store.ensureArchetype(["id", "cell", "name"]) as any; - expect(router.columns).toBeUndefined(); + const router = store.ensureArchetype(["id", "cell", "name"]); + expect("columns" in router).toBe(false); router.insert({ cell: 5, name: "x" }); - const concrete = store.ensureArchetype(["id", "cell", "name"], { cell: 5 } as any) as any; + const concrete = store.ensureArchetype(["id", "cell", "name"], { cell: 5 }); expect(concrete.columns).toBeDefined(); expect(concrete.rowCount).toBe(1); }); it("index find works when inserting via the concrete-child path too", () => { const store = makeStore(); - const concrete = store.ensureArchetype(["id", "cell", "name"], { cell: 9 } as any) as any; + const concrete = store.ensureArchetype(["id", "cell", "name"], { cell: 9 }); const e = concrete.insert({ cell: 9, name: "zed" }); - expect((store.indexes as any).byName.get({ name: "zed" })).toBe(e); + expect(store.indexes.byName.get({ name: "zed" })).toBe(e); }); it("index updates when a partition-value change migrates an entity", () => { const store = makeStore(); - const spatial = store.archetypes.Spatial as any; + const spatial = store.archetypes.Spatial; const e = spatial.insert({ cell: 1, name: "mover" }); store.update(e, { cell: 2 }); // Entity moved value-children; the name index still resolves it. expect(store.get(e, "cell")).toBe(2); - expect((store.indexes as any).byName.get({ name: "mover" })).toBe(e); + expect(store.indexes.byName.get({ name: "mover" })).toBe(e); }); }); diff --git a/packages/data/src/ecs/store/public/create-store.ts b/packages/data/src/ecs/store/public/create-store.ts index 061b490c..8e1fa423 100644 --- a/packages/data/src/ecs/store/public/create-store.ts +++ b/packages/data/src/ecs/store/public/create-store.ts @@ -20,14 +20,16 @@ import { RuntimeIndex, } from "../../database/index-registry/index.js"; import { PartitionKeysOf } from "../partition.js"; +import { IndexDeclarations } from "../index-types.js"; export function createStore< CS extends ComponentSchemas = {}, RS extends ResourceSchemas = {}, - A extends ArchetypeComponents> = {} + A extends ArchetypeComponents> = {}, + IX extends IndexDeclarations, A> = {}, >( - schema?: Store.Schema, -): Store, FromSchemas, A, {}, PartitionKeysOf> { + schema?: Store.Schema, +): Store, FromSchemas, A, IX, PartitionKeysOf> { const schemaArg = schema as any; const hasSchemaShape = schemaArg && @@ -36,7 +38,7 @@ export function createStore< "resources" in schemaArg && "archetypes" in schemaArg; - const normalizedSchema: Store.Schema = hasSchemaShape + const normalizedSchema: Store.Schema = hasSchemaShape ? schemaArg : { components: {} as CS, diff --git a/packages/data/src/ecs/store/store.ts b/packages/data/src/ecs/store/store.ts index fd36379b..73542818 100644 --- a/packages/data/src/ecs/store/store.ts +++ b/packages/data/src/ecs/store/store.ts @@ -84,7 +84,7 @@ export interface Store< /** Wipe all entities and reset resources to plugin defaults. O(num_archetypes + num_resources). */ reset(): void; fromData(data: unknown): void - extend(schema: S): S extends Store.Schema ? Store> : never; + extend(schema: S): S extends Store.Schema ? Store, R & FromSchemas, A & XA, IX & XIX, PK | PartitionKeysOf> : never; } // eslint-disable-next-line @typescript-eslint/no-namespace From 61d31285ad6d0da3a8c07eb6f34fcb3e03ffb52c Mon Sep 17 00:00:00 2001 From: Kris Nye Date: Tue, 14 Jul 2026 16:25:46 -0700 Subject: [PATCH 3/4] perf(data): partition-aware sort in select ordered queries When the leading run of order keys are partition components, each archetype holds a constant value for them, so the archetypes already bucket the rows on that leading key: sort the buckets by value, sort only the remaining keys within each bucket, and concatenate in bucket order. Turns the O(N log N) sort into O(N log(N/K)), and O(N) when a partition key is the sole order key. Falls back to the full sort when no leading order key is a partition component, with identical output. Tests compare against a naive full sort across leading, compound, descending, where-filtered, fallback, large-K, and tie cases. Co-Authored-By: Claude Opus 4.8 --- .../core/select-entities.partition.test.ts | 111 ++++++++++++++++++ .../src/ecs/store/core/select-entities.ts | 111 ++++++++++++++---- 2 files changed, 201 insertions(+), 21 deletions(-) create mode 100644 packages/data/src/ecs/store/core/select-entities.partition.test.ts diff --git a/packages/data/src/ecs/store/core/select-entities.partition.test.ts b/packages/data/src/ecs/store/core/select-entities.partition.test.ts new file mode 100644 index 00000000..1a4c1d1f --- /dev/null +++ b/packages/data/src/ecs/store/core/select-entities.partition.test.ts @@ -0,0 +1,111 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { describe, it, expect } from "vitest"; +import { createStore } from "../public/create-store.js"; +import { compare } from "../../../functions/compare.js"; +import type { Entity } from "../../entity/entity.js"; + +// `cell` and `region` partition; `rank` does not. The partition-aware sort in +// selectEntities must produce output identical to a naive full sort. +const schema = { + components: { + cell: { type: "integer", partition: true }, + region: { type: "integer", partition: true }, + rank: { type: "integer" }, + }, + resources: {}, + archetypes: { E: ["cell", "region", "rank"] }, +} as const; + +type Row = { cell: number; region: number; rank: number }; +type Order = { readonly [K in keyof Row]?: boolean }; + +const makeStore = (rows: readonly Row[]) => { + const store = createStore(schema); + const ids = rows.map((r) => store.archetypes.E.insert(r)); + return { store, ids }; +}; + +// Independent reference: read every row and sort by the order keys with the +// same `compare` semantics selectEntities uses. +const referenceSort = ( + store: ReturnType["store"], + ids: readonly Entity[], + order: Order, + where?: (r: Row) => boolean, +): Entity[] => { + const keys = Object.keys(order) as (keyof Row)[]; + const withVals = ids + .map((id) => ({ id, r: store.read(id) as unknown as Row })) + .filter((x) => (where ? where(x.r) : true)); + withVals.sort((a, b) => { + for (const k of keys) { + const c = compare(a.r[k], b.r[k]); + if (c !== 0) return order[k] ? c : -c; + } + return 0; + }); + return withVals.map((x) => x.id); +}; + +// Rows with a globally-unique `rank` so every order that ends in `rank` is a +// total order (no ties → the reference comparison is unambiguous). +const uniqueRows = (n: number): Row[] => + Array.from({ length: n }, (_, i) => ({ + cell: i % 5, + region: i % 3, + rank: (i * 7) % n, // a permutation of 0..n-1 (7 and n=100 are coprime) + })); + +describe("selectEntities partition-aware ordering", () => { + const orders: [string, Order][] = [ + ["sole partition key (cell asc) + rank tiebreak", { cell: true, rank: true }], + ["partition key descending (cell desc) + rank", { cell: false, rank: true }], + ["compound partition keys (cell, region) + rank", { cell: true, region: true, rank: true }], + ["partition then descending secondary (cell asc, rank desc)", { cell: true, rank: false }], + ["non-partition leading key (rank asc) — fallback path", { rank: true }], + ["non-partition leading then partition (rank asc, cell asc) — fallback", { rank: true, cell: true }], + ]; + + for (const [name, order] of orders) { + it(`matches a naive full sort: ${name}`, () => { + const rows = uniqueRows(100); + const { store, ids } = makeStore(rows); + const got = store.select(["cell", "region", "rank"], { order }); + const expected = referenceSort(store, ids, order); + expect(got).toEqual(expected); + }); + } + + it("matches a naive full sort with a where filter (cell asc, rank asc)", () => { + const rows = uniqueRows(100); + const { store, ids } = makeStore(rows); + const order: Order = { cell: true, rank: true }; + const got = store.select(["cell", "region", "rank"], { where: { rank: { ">=": 50 } }, order }); + const expected = referenceSort(store, ids, order, (r) => r.rank >= 50); + expect(got).toEqual(expected); + }); + + it("large K (many distinct partition values) still matches — exercises bucketing at scale", () => { + // Distinct cell per entity → one bucket each; sole partition order key is + // the O(N) path (no within-bucket sort). + const rows: Row[] = Array.from({ length: 200 }, (_, i) => ({ cell: i, region: 0, rank: i })); + const { store, ids } = makeStore(rows); + const order: Order = { cell: true }; + expect(store.select(["cell", "region", "rank"], { order })).toEqual(referenceSort(store, ids, order)); + }); + + it("with ties on the sole partition key, output is correctly ordered and a permutation of the input", () => { + // Many entities share each cell value → ties the reference can't + // disambiguate; assert the weaker invariants instead. + const rows: Row[] = Array.from({ length: 60 }, (_, i) => ({ cell: i % 4, region: 0, rank: i })); + const { store, ids } = makeStore(rows); + const got = store.select(["cell", "region", "rank"], { order: { cell: true } }); + + // Non-decreasing by cell. + for (let i = 1; i < got.length; i++) { + expect(store.get(got[i - 1], "cell")! <= store.get(got[i], "cell")!).toBe(true); + } + // Same set of entities, none lost or duplicated. + expect([...got].sort((a, b) => a - b)).toEqual([...ids].sort((a, b) => a - b)); + }); +}); diff --git a/packages/data/src/ecs/store/core/select-entities.ts b/packages/data/src/ecs/store/core/select-entities.ts index 49aed091..cac4053a 100644 --- a/packages/data/src/ecs/store/core/select-entities.ts +++ b/packages/data/src/ecs/store/core/select-entities.ts @@ -49,31 +49,100 @@ export const selectEntities = < return entities; } - // now we know there is an order, there might be a where - // ordering means we are going to want to extract the order values into an array that also contains id - const entityValues = new Array(); - for (const archetype of archetypes) { - const idTypedArray = archetype.columns.id.getTypedArray(); - for (const row of selectRows>(archetype as any, options.where)) { - const entityValue = { id: idTypedArray[row] } as any; - for (const order in options.order!) { - entityValue[order] = archetype.columns[order]!.get(row); - } - entityValues.push(entityValue); - } + // ── Ordered results ───────────────────────────────────────────────────── + // When the *leading run* of order keys are partition components, each + // archetype holds a constant value for them, so the archetypes already + // bucket the rows on that leading key. We can then sort the buckets by + // value, sort only the *remaining* keys within each bucket, and concatenate + // in bucket order — turning an O(N log N) sort into O(N log(N/K)), and O(N) + // when a partition key is the sole order key. When no leading order key is a + // partition component (leadCount === 0) this falls back to one full sort, + // matching the previous behaviour exactly. + const order = options.order!; + const schemas = core.componentSchemas; + // Keys of `order` are, by OrderClause>, exactly the picked + // component keys — safe to view as Include[]. + const orderKeys = Object.keys(order) as Include[]; + type Arch = (typeof archetypes)[number]; + // Order/partition keys are sortable columns, so their values are comparable + // primitives — the contract `compare` accepts. Read them at that type. + type Comparable = Parameters[0]; + + let leadCount = 0; + while (leadCount < orderKeys.length && schemas[orderKeys[leadCount]]?.partition === true) { + leadCount++; } - // now that we have the entity values with the order values, we can sort the entity values - entityValues.sort((a, b) => { - for (const order in options.order!) { + + const compareByKeys = ( + keys: readonly Include[], + a: { readonly [k: string]: Comparable }, + b: { readonly [k: string]: Comparable }, + ): number => { + for (const key of keys) { // `compare` (code-point for strings, numeric for numbers) — not // `a - b`, which is NaN for string order keys, and never locale. - const cmp = compare(a[order], b[order]); - if (cmp !== 0) { - const ascending = options.order[order]; - return ascending ? cmp : -cmp; + const cmp = compare(a[key], b[key]); + if (cmp !== 0) return order[key] ? cmp : -cmp; + } + return 0; + }; + + // Gather ids for `archs`, sorted by `sortKeys` (empty → left in archetype + + // row order). `where` is applied per row. A stable sort over the same gather + // order keeps tie-ordering identical to the single full sort. + const collect = (archs: readonly Arch[], sortKeys: readonly Include[]): Entity[] => { + if (sortKeys.length === 0) { + const ids: Entity[] = []; + for (const archetype of archs) { + const idTypedArray = archetype.columns.id.getTypedArray(); + for (const row of selectRows>(archetype as any, options.where)) { + ids.push(idTypedArray[row]); + } } + return ids; + } + const rows: { id: Entity; [k: string]: Comparable }[] = []; + for (const archetype of archs) { + const idTypedArray = archetype.columns.id.getTypedArray(); + for (const row of selectRows>(archetype as any, options.where)) { + const value: { id: Entity; [k: string]: Comparable } = { id: idTypedArray[row] }; + for (const key of sortKeys) value[key] = archetype.columns[key]!.get(row) as Comparable; + rows.push(value); + } + } + rows.sort((a, b) => compareByKeys(sortKeys, a, b)); + return rows.map((r) => r.id); + }; + + if (leadCount === 0) { + return collect(archetypes, orderKeys); + } + + // Bucket archetypes by their leading partition-value tuple, preserving + // archetype iteration order within a bucket (so tie-ordering matches the + // full sort). A bucket can span multiple archetypes (shape fan-out). + const leadKeys = orderKeys.slice(0, leadCount); + const restKeys = orderKeys.slice(leadCount); + const buckets = new Map(); + for (const archetype of archetypes) { + const values = leadKeys.map((key) => archetype.columns[key]!.get(0) as Comparable); + // JSON is an unambiguous key for a primitive tuple: it distinguishes + // 1 from "1" and never collides across tuple boundaries. + const bucketKey = JSON.stringify(values); + let bucket = buckets.get(bucketKey); + if (!bucket) { bucket = { values, archs: [] }; buckets.set(bucketKey, bucket); } + bucket.archs.push(archetype); + } + const orderedBuckets = [...buckets.values()].sort((a, b) => { + for (let i = 0; i < leadKeys.length; i++) { + const cmp = compare(a.values[i], b.values[i]); + if (cmp !== 0) return order[leadKeys[i]] ? cmp : -cmp; } return 0; }); - return entityValues.map(entityValue => entityValue.id); -} \ No newline at end of file + const result: Entity[] = []; + for (const bucket of orderedBuckets) { + for (const id of collect(bucket.archs, restKeys)) result.push(id); + } + return result; +} From 17abbe0d108cefe612a42e73e42f54376ea8708a Mon Sep 17 00:00:00 2001 From: Kris Nye Date: Tue, 14 Jul 2026 20:16:57 -0700 Subject: [PATCH 4/4] =?UTF-8?q?refactor(data):=20Router=20=E2=86=92=20Arch?= =?UTF-8?q?etype.Router=20(namespace=20pattern)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Router was too general a name to sit peer to Archetype and forced an extra import at every use site. Move it under the Archetype namespace via declaration merging so it reads Archetype.Router and rides the existing Archetype import. Co-Authored-By: Claude Opus 4.8 --- packages/data/src/ecs/archetype/archetype.ts | 43 ++++++++++--------- packages/data/src/ecs/store/core/core.ts | 6 +-- packages/data/src/ecs/store/partition.ts | 14 +++--- .../data/src/ecs/store/partition.type-test.ts | 10 ++--- packages/data/src/ecs/store/store.ts | 6 +-- 5 files changed, 41 insertions(+), 38 deletions(-) diff --git a/packages/data/src/ecs/archetype/archetype.ts b/packages/data/src/ecs/archetype/archetype.ts index 1d04d491..db307e2f 100644 --- a/packages/data/src/ecs/archetype/archetype.ts +++ b/packages/data/src/ecs/archetype/archetype.ts @@ -43,26 +43,29 @@ export interface Archetype ex fromData: (data: unknown) => void } -/** - * Write-only handle over a *family* of archetypes that share a component set but - * differ by the value of one or more `partition` components. `insert` reads the - * partition value(s) from the row, resolves (creating on first use) the concrete - * child archetype for that value, and inserts there. - * - * A family has no single dense column view, so — unlike {@link Archetype} — a - * Router exposes no `columns`, `rowCount`, or iteration. Read a family through - * `queryArchetypes` (optionally filtered by partition value), or narrow to one - * concrete member by supplying the value to `ensureArchetype`. - * - * `insert` is deliberately signature-identical to {@link Archetype.insert}: a - * discriminated `Archetype | Router` (produced when the requested keys are - * not statically known to include/exclude a partition component) therefore still - * permits `.insert` with no narrowing — only dense column access requires having - * resolved to a concrete {@link Archetype}. - */ -export interface Router { - readonly components: ComponentSet>; - insert: >(rowData: Exact, T>) => Entity; +// eslint-disable-next-line @typescript-eslint/no-namespace +export namespace Archetype { + /** + * Write-only handle over a *family* of archetypes that share a component set + * but differ by the value of one or more `partition` components. `insert` + * reads the partition value(s) from the row, resolves (creating on first use) + * the concrete child archetype for that value, and inserts there. + * + * A family has no single dense column view, so — unlike {@link Archetype} — a + * Router exposes no `columns`, `rowCount`, or iteration. Read a family through + * `queryArchetypes` (optionally filtered by partition value), or narrow to one + * concrete member by supplying the value to `ensureArchetype`. + * + * `insert` is deliberately signature-identical to {@link Archetype.insert}: a + * discriminated `Archetype | Archetype.Router` (produced when the + * requested keys are not statically known to include/exclude a partition + * component) therefore still permits `.insert` with no narrowing — only dense + * column access requires having resolved to a concrete {@link Archetype}. + */ + export interface Router { + readonly components: ComponentSet>; + insert: >(rowData: Exact, T>) => Entity; + } } export type FromArchetype = diff --git a/packages/data/src/ecs/store/core/core.ts b/packages/data/src/ecs/store/core/core.ts index bce53f9f..6ef9e543 100644 --- a/packages/data/src/ecs/store/core/core.ts +++ b/packages/data/src/ecs/store/core/core.ts @@ -1,7 +1,7 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { Entity } from "../../entity/entity.js"; -import { Archetype, ReadonlyArchetype, Router } from "../../archetype/archetype.js"; +import { Archetype, ReadonlyArchetype } from "../../archetype/archetype.js"; import { Schema } from "../../../schema/index.js"; import { RequiredComponents } from "../../required-components.js"; import { StringKeyof } from "../../../types/index.js"; @@ -41,7 +41,7 @@ export interface ReadonlyCore< ensureArchetype>( components: readonly CC[] | ReadonlySet, ): HasPartitionKey extends true - ? Router + ? Archetype.Router : ReadonlyArchetype; // Partition value(s) supplied → the concrete value-child, always. ensureArchetype>( @@ -78,7 +78,7 @@ export interface Core< ensureArchetype>( components: readonly CC[] | ReadonlySet, ): HasPartitionKey extends true - ? Router + ? Archetype.Router : Archetype; ensureArchetype>( components: readonly CC[] | ReadonlySet, diff --git a/packages/data/src/ecs/store/partition.ts b/packages/data/src/ecs/store/partition.ts index 3d59d4c4..17ab42e9 100644 --- a/packages/data/src/ecs/store/partition.ts +++ b/packages/data/src/ecs/store/partition.ts @@ -3,7 +3,7 @@ import { StringKeyof } from "../../types/types.js"; import { ComponentSchemas } from "../component-schemas.js"; import { RequiredComponents } from "../required-components.js"; -import { Archetype, Router } from "../archetype/archetype.js"; +import { Archetype } from "../archetype/archetype.js"; /** * The component keys of a schema map that are declared `partition: true`. @@ -25,7 +25,7 @@ export type PartitionKeysOf = { * to `false` *without* inspecting `Keys`. That matters because `Keys` is often a * generic indexed access (`A[K][number]` inside a `TransactionContext`); * `Extract` would otherwise stay a *deferred* conditional, - * leaving `store.archetypes.` as the unresolved union `Router | Archetype` + * leaving `store.archetypes.` as the unresolved union `Archetype.Router | Archetype` * and breaking assignability in every generic store context. Short-circuiting * on `PK` keeps non-partition stores paying nothing and resolving eagerly. */ @@ -37,13 +37,13 @@ export type HasPartitionKey = * Return type of `ensureArchetype(keys, values?)`: * - a value is supplied → a concrete {@link Archetype} (the resolved member), * regardless of partitioning — this is the same-value fast path; - * - else the key set includes a partition component → a {@link Router} that + * - else the key set includes a partition component → a {@link Archetype.Router} that * routes each `insert` by the row's partition value; * - else → a concrete {@link Archetype} (today's behavior, unchanged). * * When `Keys` is not statically known to include or exclude a partition * component, `HasPartitionKey` stays a union and the result widens to - * `Archetype | Router` — which still exposes `.insert` (both branches + * `Archetype | Archetype.Router` — which still exposes `.insert` (both branches * share it); only dense column access requires narrowing to `Archetype`. */ export type EnsureArchetypeResult< @@ -54,11 +54,11 @@ export type EnsureArchetypeResult< > = ValueProvided extends true ? Archetype : HasPartitionKey extends true - ? Router + ? Archetype.Router : Archetype; /** - * Type of `store.archetypes.`: a {@link Router} when the declared archetype + * Type of `store.archetypes.`: a {@link Archetype.Router} when the declared archetype * includes a partition component, else a concrete {@link Archetype}. The keys are * statically known here (from the schema's `archetypes` map), so this never * widens to a union — the discrimination is exact per declared name. @@ -67,4 +67,4 @@ export type StoreArchetypeHandle< C extends RequiredComponents, Keys extends string, PK extends string, -> = HasPartitionKey extends true ? Router : Archetype; +> = HasPartitionKey extends true ? Archetype.Router : Archetype; diff --git a/packages/data/src/ecs/store/partition.type-test.ts b/packages/data/src/ecs/store/partition.type-test.ts index 1929c739..47b196aa 100644 --- a/packages/data/src/ecs/store/partition.type-test.ts +++ b/packages/data/src/ecs/store/partition.type-test.ts @@ -3,7 +3,7 @@ import { Assert } from "../../types/assert.js"; import { Equal } from "../../types/equal.js"; import { RequiredComponents } from "../required-components.js"; -import { Archetype, Router } from "../archetype/archetype.js"; +import { Archetype } from "../archetype/archetype.js"; import { PartitionKeysOf, HasPartitionKey, @@ -62,7 +62,7 @@ type _hasNot = Assert, false>>; // --------------------------------------------------------------------------- // partition key present, no value → Router -type _r1 = Assert, Router>>; +type _r1 = Assert, Archetype.Router>>; // partition key present, value supplied → concrete Archetype (fast path) type _r2 = Assert, Archetype>>; // no partition key → concrete Archetype (unchanged behavior) @@ -77,14 +77,14 @@ type _rWrong = Assert, Router>>; +type _h1 = Assert, Archetype.Router>>; type _h2 = Assert, Archetype>>; // --------------------------------------------------------------------------- // Runtime-surface guarantees (the load-bearing ergonomics) // --------------------------------------------------------------------------- -declare const router: Router; +declare const router: Archetype.Router; // A Router can insert (routing write) ... router.insert({ cell: 1, position: 2 }); @@ -96,7 +96,7 @@ router.rowCount; // The discriminated union (dynamic keys) still permits `.insert` without // narrowing — both branches share an identical signature ... -declare const handle: Archetype | Router; +declare const handle: Archetype | Archetype.Router; handle.insert({ cell: 1, position: 2 }); // ... while dense access is correctly gated behind resolving to a concrete // archetype (supply a value, or narrow): diff --git a/packages/data/src/ecs/store/store.ts b/packages/data/src/ecs/store/store.ts index 73542818..a17d4bbd 100644 --- a/packages/data/src/ecs/store/store.ts +++ b/packages/data/src/ecs/store/store.ts @@ -6,7 +6,7 @@ import { Entity } from "../entity/entity.js"; import { IntersectTuple, Simplify, StringKeyof } from "../../types/types.js"; import { Components } from "./components.js"; import { ArchetypeComponents } from "./archetype-components.js"; -import { Archetype, ReadonlyArchetype, Router } from "../archetype/archetype.js"; +import { Archetype, ReadonlyArchetype } from "../archetype/archetype.js"; import { HasPartitionKey, PartitionKeysOf } from "./partition.js"; import { EntitySelectOptions } from "./entity-select-options.js"; import { Undoable } from "../database/undoable.js"; @@ -47,7 +47,7 @@ export interface ReadonlyStore< readonly resources: { readonly [K in StringKeyof]: R[K] }; readonly archetypes: { readonly [K in StringKeyof]: HasPartitionKey extends true - ? Router + ? Archetype.Router : ReadonlyArchetype } readonly indexes: { readonly [K in keyof IX]: Index.Handle }; } @@ -72,7 +72,7 @@ export interface Store< readonly resources: { -readonly [K in StringKeyof]: R[K] }; readonly archetypes: { -readonly [K in StringKeyof]: HasPartitionKey extends true - ? Router + ? Archetype.Router : Archetype } /** * Index handles keyed by user-chosen name. Returned handles are the