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..db307e2f 100644 --- a/packages/data/src/ecs/archetype/archetype.ts +++ b/packages/data/src/ecs/archetype/archetype.ts @@ -43,6 +43,31 @@ export interface Archetype ex fromData: (data: unknown) => void } +// 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 = 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..07b5288a --- /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" }); + 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); + // 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" }); + 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" }); + 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..6ef9e543 100644 --- a/packages/data/src/ecs/store/core/core.ts +++ b/packages/data/src/ecs/store/core/core.ts @@ -7,15 +7,26 @@ 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 + ? Archetype.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 + ? Archetype.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..6ae65fbe --- /dev/null +++ b/packages/data/src/ecs/store/core/create-core.partition.test.ts @@ -0,0 +1,188 @@ +// © 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. `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", () => { + 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(typeof arch.insert).toBe("function"); + }); + }); + + 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..a05adde1 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), + // 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.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 e33b47bf..cac4053a 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 } : undefined); let length = 0; for (const archetype of archetypes) { length += archetype.rowCount; @@ -46,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; +} 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..17ab42e9 --- /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 } 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 `Archetype.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 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 | 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 + ? Archetype.Router + : 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. + */ +export type StoreArchetypeHandle< + C extends RequiredComponents, + Keys extends string, + PK extends string, +> = 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 new file mode 100644 index 00000000..47b196aa --- /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 } 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, Archetype.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, Archetype.Router>>; +type _h2 = Assert, Archetype>>; + +// --------------------------------------------------------------------------- +// Runtime-surface guarantees (the load-bearing ergonomics) +// --------------------------------------------------------------------------- + +declare const router: Archetype.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 | 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..819ae33b --- /dev/null +++ b/packages/data/src/ecs/store/public/create-store.partition.test.ts @@ -0,0 +1,127 @@ +// © 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. `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 }, + name: { type: "string" }, + }, + resources: {}, + archetypes: { Spatial: ["cell", "name"] }, + indexes: { byName: { key: "name", unique: true } }, +} as const; + +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 — 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("columns" in spatial).toBe(false); + + // Insert N entities with N distinct partition values. + const N = 5; + 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 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.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; + expect(typeof spatial.insert).toBe("function"); + 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" }); + 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; + const a = spatial.insert({ cell: 1, name: "alice" }); + const b = spatial.insert({ cell: 2, name: "bob" }); + 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; + 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.byName.get({ name: "dup" })).toBe(first); + }); + + it("queryArchetypes `where` filters value-children at the store layer", () => { + const store = makeStore(); + 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 } }); + 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"]); + expect("columns" in router).toBe(false); + router.insert({ cell: 5, name: "x" }); + + 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 }); + const e = concrete.insert({ cell: 9, name: "zed" }); + 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; + 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.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..8e1fa423 100644 --- a/packages/data/src/ecs/store/public/create-store.ts +++ b/packages/data/src/ecs/store/public/create-store.ts @@ -19,14 +19,17 @@ import { IndexDeclarationObject, 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> { + schema?: Store.Schema, +): Store, FromSchemas, A, IX, PartitionKeysOf> { const schemaArg = schema as any; const hasSchemaShape = schemaArg && @@ -35,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, @@ -52,7 +55,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 +212,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 +294,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 +317,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..a17d4bbd 100644 --- a/packages/data/src/ecs/store/store.ts +++ b/packages/data/src/ecs/store/store.ts @@ -7,6 +7,7 @@ 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 { 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 + ? Archetype.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 + ? Archetype.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, R & FromSchemas, A & XA, IX & XIX, PK | PartitionKeysOf> : 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;