diff --git a/packages/data/src/ecs/database/database-read.type-test.ts b/packages/data/src/ecs/database/database-read.type-test.ts new file mode 100644 index 00000000..9f902e55 --- /dev/null +++ b/packages/data/src/ecs/database/database-read.type-test.ts @@ -0,0 +1,238 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. + +import { Assert } from "../../types/assert.js"; +import { Equal } from "../../types/equal.js"; +import { Database } from "./database.js"; +import { Entity } from "../entity/entity.js"; +import { Observe } from "../../observe/index.js"; + +/** + * Compile-time tests for `Database.Read` and the `db.derive` + * callback surface. Type-only — nothing here executes. `@ts-expect-error` + * marks the accesses that MUST NOT compile (the footguns the read projection + * removes structurally). + */ + +const plugin = Database.Plugin.create({ + components: { + x: { type: "number" }, + y: { type: "string" }, + }, + resources: { + frameRate: { type: "number", default: 30 }, + }, + archetypes: { + Foo: ["x"], + }, + indexes: { + byX: { key: "x" }, + byXUnique: { key: "x", unique: true }, + }, +}); + +const db = Database.create(plugin); + +// ============================================================================ +// derive — POSITIVE: the read surface a compute body is allowed to touch +// ============================================================================ + +function derivePositive() { + // value reads + const gotten = db.derive((d) => d.get(0 as Entity, "x")); + type _Gotten = Assert>>; + + const selected = db.derive((d) => d.select(["x"])); + type _Selected = Assert>>; + + // presence `select` — `exclude` is allowed (membership-based) + db.derive((d) => d.select(["x"], { exclude: ["y"] })); + // …but the value-dependent options are NOT on the derive surface: they can + // only be tracked coarsely, so a value-keyed / ordered reactive read must go + // through a declared index. + db.derive((d) => + // @ts-expect-error `where` is not offered on a derive's select — use an index + d.select(["x"], { where: { x: 1 } }), + ); + db.derive((d) => + // @ts-expect-error `order` is not offered on a derive's select — use an index + d.select(["x"], { order: { x: true } }), + ); + + // whole-entity read + db.derive((d) => d.read(0 as Entity)); + + // projection read: exactly the requested fields, optionality preserved, + // unrequested fields (incl. id) excluded + db.derive((d) => { + const p = d.read(0 as Entity, ["x", "y"]); + if (p !== null) { + const _x: number | undefined = p.x; + const _y: string | undefined = p.y; + void _x; + void _y; + // @ts-expect-error `id` was not requested, so it is not on the projection + p.id; + } + return p; + }); + + // archetype read: narrowed to the archetype's own row (no extra fields) + db.derive((d) => { + const foo = d.read(0 as Entity, db.archetypes.Foo); + if (foo !== null) { + const _x: number = foo.x; + // @ts-expect-error `y` is not part of the Foo archetype — narrowed read excludes it + foo.y; + } + return foo; + }); + + // resource reads + const frameRate = db.derive((d) => d.resources.frameRate); + type _FrameRate = Assert>>; + + // archetype identity — and the realistic composition select(archetype.components) + db.derive((d) => d.archetypes.Foo.components); + db.derive((d) => d.archetypes.Foo.id); + db.derive((d) => d.select(d.archetypes.Foo.components)); + + // index lookups + db.derive((d) => d.indexes.byX.find({ x: 1 })); + db.derive((d) => d.indexes.byX.findRange({ x: 1 })); + // a unique index also exposes `get` + const head = db.derive((d) => d.indexes.byXUnique.get({ x: 1 })); + type _Head = Assert>>; +} + +// ============================================================================ +// derive — NEGATIVE: everything the read projection removes +// ============================================================================ + +function deriveNegative() { + db.derive((d) => { + // @ts-expect-error observers are not exposed — a derive subscribes to its own reads + return d.observe; + }); + db.derive((d) => { + // @ts-expect-error transactions / writes are not exposed to a read-only derive + return d.transactions; + }); + db.derive((d) => { + // @ts-expect-error queryArchetypes (raw column access) is not exposed + return d.queryArchetypes; + }); + db.derive((d) => { + // @ts-expect-error locate (row access) is not exposed + return d.locate; + }); + db.derive((d) => { + // @ts-expect-error per-archetype column access is not exposed + return d.archetypes.Foo.columns; + }); + db.derive((d) => { + // @ts-expect-error per-archetype rowCount (table access) is not exposed + return d.archetypes.Foo.rowCount; + }); + db.derive((d) => { + // @ts-expect-error per-archetype insert (write) is not exposed + return d.archetypes.Foo.insert; + }); + db.derive((d) => { + // @ts-expect-error index observe is not exposed — a derive subscribes for you + return d.indexes.byX.observe; + }); +} + +// ============================================================================ +// Database.Read directly (the public projection type) +// ============================================================================ + +type ReadDb = Database.Read; +declare const rd: ReadDb; + +function readProjectionPositive() { + const v: number | undefined = rd.get(0 as Entity, "x"); + const ids: readonly Entity[] = rd.select(["x"]); + const excluded: readonly Entity[] = rd.select(["x"], { exclude: ["y"] }); + const fr: number = rd.resources.frameRate; + rd.archetypes.Foo.components.has("x"); + const found: readonly Entity[] = rd.indexes.byX.find({ x: 1 }); + void v; + void ids; + void excluded; + void fr; + void found; +} + +function readProjectionNegative() { + // @ts-expect-error observers omitted from the read projection + rd.observe; + // @ts-expect-error index observe omitted from the read projection + rd.indexes.byX.observe; + // @ts-expect-error table/column access omitted from the read projection + rd.archetypes.Foo.columns; + // @ts-expect-error transactions omitted from the read projection + rd.transactions; + // @ts-expect-error `where` (value-dependent) omitted from the read projection's select + rd.select(["x"], { where: { x: 1 } }); + // @ts-expect-error `order` (value-dependent) omitted from the read projection's select + rd.select(["x"], { order: { x: true } }); +} + +// ============================================================================ +// Read distributes over an INTERSECTION (Option A). The structural definition +// means `Database.Read` merges both members' read surfaces, and because +// `derive` passes `Database.Read`, a derive on an intersection receiver +// sees indexes + resources + archetypes from BOTH members — so a consumer of a +// composed `A & B` database (e.g. an indexed core intersected with a +// resource-computed layer) never needs to cast. +// ============================================================================ + +const pluginA = Database.Plugin.create({ + components: { a: { type: "number" } }, + resources: { ra: { type: "number", default: 0 } }, + archetypes: { AOnly: ["a"] }, + indexes: { byA: { key: "a" } }, +}); +const pluginB = Database.Plugin.create({ + components: { b: { type: "string" } }, + resources: { rb: { type: "string", default: "" } }, + archetypes: { BOnly: ["b"] }, + indexes: { byB: { key: "b" } }, +}); + +const dbA = Database.create(pluginA); +const dbB = Database.create(pluginB); +type Both = typeof dbA & typeof dbB; + +declare const rb2: Database.Read; +declare const both: Both; + +function intersectionRead() { + // both members' resources + const _ra: number = rb2.resources.ra; + const _rb: string = rb2.resources.rb; + // both members' indexes (find-only) + const _fa: readonly Entity[] = rb2.indexes.byA.find({ a: 1 }); + const _fb: readonly Entity[] = rb2.indexes.byB.find({ b: "x" }); + // both members' archetype identities + rb2.archetypes.AOnly.components.has("a"); + rb2.archetypes.BOnly.components.has("b"); + void _ra; + void _rb; + void _fa; + void _fb; +} + +function intersectionDerive() { + // `Database.Read` on an intersection receiver → merged surface. + both.derive((d) => { + const _ra: number = d.resources.ra; + const _rb: string = d.resources.rb; + d.indexes.byA.find({ a: 1 }); + d.indexes.byB.find({ b: "x" }); + void _ra; + void _rb; + return 0; + }); +} diff --git a/packages/data/src/ecs/database/database.ts b/packages/data/src/ecs/database/database.ts index f1c343b2..1ec382d3 100644 --- a/packages/data/src/ecs/database/database.ts +++ b/packages/data/src/ecs/database/database.ts @@ -159,6 +159,20 @@ export interface Database< options?: EntitySelectOptions> ): Observe; } + /** + * Reactive derivation. `compute` runs against a read-only projection of this + * database ({@link Database.Read} — value / index / resource reads only; no + * observers, writes, or table access). The derive records exactly the reads + * it performs and re-emits when any could have changed: the initial value on + * subscribe, then at most once per committed transaction (synchronously at + * the commit boundary), structurally deduplicated so an unchanged result + * never re-notifies. + * + * The callback receives `Database.Read`, so an *intersection* database + * (e.g. `IndexedCoreDatabase & ResourceComputedDatabase`) resolves to the + * merged read surface — consumers never need to cast. + */ + derive(compute: (db: Database.Read) => T): Observe; /** * Wipes all entities and resets all resources to their plugin defaults, * preserving database identity (observers, transaction wrappers, sync @@ -250,6 +264,55 @@ export namespace Database { RemoveIndex >; + /** + * The read-only projection of a Database that a `db.derive` callback + * receives. It exposes only the auto-trackable read surface — + * - value reads: `get`, `read`, `select` + * - resource reads: `resources` + * - index lookups: `indexes..find` / `findRange` / `get` + * - archetype identity: `archetypes..components` / `id` + * — and structurally OMITS everything a derived computation must not touch: + * - `observe` (a derive subscribes to what it reads for you) + * - transactions / actions / any write + * - direct table access: `queryArchetypes`, `locate`, and per-archetype + * `columns` / `rowCount` / `insert` / row reads + * - `services` / `computed` / lifecycle (`extend`, `reset`, `toData`, …) + * + * Because the surface is narrowed structurally, misuse is a compile error + * rather than a value that must be guarded / thrown on at runtime. + */ + // Defined structurally (mapping over `DB`'s own members) rather than via + // `DB extends Database`. The `infer` form collapses an + // *intersection* database `A & B` to whichever single member the conditional + // matches, losing the other's surface. Mapping over the members directly lets + // `Read` distribute over the intersection and merge both read surfaces — + // so a `db.derive` on an intersection receiver (see the top-level `derive` + // signature, which passes `Database.Read`) sees the combined + // indexes + resources + archetypes, and consumers never need to cast. + export type Read> = + Pick & { + // Presence `select` only — `include` (+ `exclude`), no `where` / `order`. + // Membership queries are reactively precise (they change only on archetype + // migration); the value-dependent `where` / `order` options can be tracked + // only coarsely (any write to a filtered/sorted column re-runs the whole + // derive), so they are deliberately absent — a value-keyed or ordered + // reactive read must go through a declared index (`indexes..find` / + // `findRange`), which is precise and O(bucket). Typed loosely over entity + // names because the structural `Read` (needed for the intersection merge + // above) does not recover `DB`'s component map; the result is entities, so + // no value typing is lost. + select( + include: readonly string[] | ReadonlySet, + options?: { readonly exclude?: readonly string[] }, + ): readonly Entity[]; + readonly indexes: { + readonly [K in keyof DB["indexes"]]: Omit; + }; + readonly archetypes: { + readonly [K in keyof DB["archetypes"]]: Pick; + }; + }; + export const create = createDatabase; export const is = (value: unknown): value is Database => { @@ -270,6 +333,14 @@ export namespace Database { export namespace Index { export type Handle> = StoreIndex.Handle; + /** + * The index handle as exposed to a `db.derive` callback: the + * synchronous lookups (`find` / `findRange` / `get`) only. `observe` is + * removed — a derive subscribes to the reads it performs automatically, so + * calling `observe` from inside one is a category error. + */ + export type ReadHandle> = + Omit, "observe">; } // eslint-disable-next-line @typescript-eslint/no-namespace diff --git a/packages/data/src/ecs/database/observe-derive.test.ts b/packages/data/src/ecs/database/observe-derive.test.ts new file mode 100644 index 00000000..f8f32c76 --- /dev/null +++ b/packages/data/src/ecs/database/observe-derive.test.ts @@ -0,0 +1,185 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { describe, expect, it, beforeEach, vi } from "vitest"; +import { Database } from "./database.js"; +import { Entity } from "../entity/entity.js"; + +const createTestDatabase = () => + Database.create( + Database.Plugin.create({ + components: { + a: { type: "number" }, + b: { type: "number" }, + }, + resources: { + r: { type: "number", default: 0 }, + }, + archetypes: { + Foo: ["a", "b"], + }, + indexes: { + byA: { key: "a" }, + }, + transactions: { + makeFoo(store, args: { a: number; b: number }) { + return store.archetypes.Foo.insert(args); + }, + setA(store, args: { e: Entity; a: number }) { + store.update(args.e, { a: args.a }); + }, + setB(store, args: { e: Entity; b: number }) { + store.update(args.e, { b: args.b }); + }, + setR(store, args: { r: number }) { + store.resources.r = args.r; + }, + }, + }), + ); + +const flush = () => new Promise((resolve) => queueMicrotask(() => resolve())); + +describe("db.derive", () => { + let db: ReturnType; + let foo: Entity; + + beforeEach(() => { + db = createTestDatabase(); + foo = db.transactions.makeFoo({ a: 1, b: 10 }); + }); + + it("emits the initial computed value synchronously on subscribe", () => { + const observer = vi.fn(); + const unsubscribe = db.derive((d) => d.get(foo, "a"))(observer); + expect(observer).toHaveBeenCalledTimes(1); + expect(observer).toHaveBeenLastCalledWith(1); + unsubscribe(); + }); + + it("re-emits when a component it read changes", async () => { + const observer = vi.fn(); + const unsubscribe = db.derive((d) => d.get(foo, "a"))(observer); + db.transactions.setA({ e: foo, a: 2 }); + await flush(); + expect(observer).toHaveBeenCalledTimes(2); + expect(observer).toHaveBeenLastCalledWith(2); + unsubscribe(); + }); + + it("does NOT re-emit when a component it did not read changes (projection read scopes the deps)", async () => { + const observer = vi.fn(); + // reads only `a` via the projection read + const unsubscribe = db.derive((d) => d.read(foo, ["a"])?.a ?? -1)(observer); + expect(observer).toHaveBeenCalledTimes(1); + db.transactions.setB({ e: foo, b: 99 }); // b is unread + await flush(); + expect(observer).toHaveBeenCalledTimes(1); // still only the initial emission + db.transactions.setA({ e: foo, a: 3 }); // a IS read + await flush(); + expect(observer).toHaveBeenCalledTimes(2); + expect(observer).toHaveBeenLastCalledWith(3); + unsubscribe(); + }); + + it("a whole-entity read re-emits on any component change of that entity", async () => { + const observer = vi.fn(); + const unsubscribe = db.derive((d) => d.read(foo)?.b ?? -1)(observer); + db.transactions.setB({ e: foo, b: 77 }); + await flush(); + expect(observer).toHaveBeenLastCalledWith(77); + unsubscribe(); + }); + + it("structurally dedupes: an input change that yields an equal result does not re-emit", async () => { + const observer = vi.fn(); + // sign of `a` — 1 and 5 both map to "pos" + const unsubscribe = db.derive((d) => ((d.get(foo, "a") ?? 0) > 0 ? "pos" : "neg"))(observer); + expect(observer).toHaveBeenCalledTimes(1); + expect(observer).toHaveBeenLastCalledWith("pos"); + db.transactions.setA({ e: foo, a: 5 }); // still positive + await flush(); + expect(observer).toHaveBeenCalledTimes(1); // recomputed to "pos" === "pos" → suppressed + db.transactions.setA({ e: foo, a: -1 }); // flips + await flush(); + expect(observer).toHaveBeenCalledTimes(2); + expect(observer).toHaveBeenLastCalledWith("neg"); + unsubscribe(); + }); + + it("re-emits on membership change of a select it read", async () => { + const observer = vi.fn(); + const unsubscribe = db.derive((d) => d.select(["a"]).length)(observer); + expect(observer).toHaveBeenLastCalledWith(1); + db.transactions.makeFoo({ a: 100, b: 0 }); + await flush(); + expect(observer).toHaveBeenLastCalledWith(2); + unsubscribe(); + }); + + it("re-emits on a change to a resource it read", async () => { + const observer = vi.fn(); + const unsubscribe = db.derive((d) => d.resources.r)(observer); + expect(observer).toHaveBeenLastCalledWith(0); + db.transactions.setR({ r: 42 }); + await flush(); + expect(observer).toHaveBeenLastCalledWith(42); + unsubscribe(); + }); + + it("re-emits on a change to an index bucket it read", async () => { + const observer = vi.fn(); + const unsubscribe = db.derive((d) => d.indexes.byA.find({ a: 1 }).length)(observer); + expect(observer).toHaveBeenLastCalledWith(1); + db.transactions.makeFoo({ a: 1, b: 0 }); // another entity in bucket a=1 + await flush(); + expect(observer).toHaveBeenLastCalledWith(2); + unsubscribe(); + }); + + it("index read is bucket-precise: a change to a different bucket does not re-run the body", () => { + // `compute` counts body runs; the per-dep recompute of `find` inside + // `affected` is separate, so this distinguishes "body re-ran, deduped" + // from "body never re-ran". + const compute = vi.fn((d: any) => d.indexes.byA.find({ a: 1 }).length); + const unsubscribe = db.derive(compute)(() => {}); + expect(compute).toHaveBeenCalledTimes(1); + db.transactions.makeFoo({ a: 2, b: 0 }); // a DIFFERENT bucket (a=2) + expect(compute).toHaveBeenCalledTimes(1); // bucket a=1 unchanged → body not re-run + db.transactions.makeFoo({ a: 1, b: 0 }); // OUR bucket (a=1) + expect(compute).toHaveBeenCalledTimes(2); // body re-run + unsubscribe(); + }); + + it("presence select is membership-precise: a value write to a selected column does not re-run the body", () => { + const compute = vi.fn((d: any) => d.select(["a"]).length); + const unsubscribe = db.derive(compute)(() => {}); + expect(compute).toHaveBeenCalledTimes(1); + db.transactions.setA({ e: foo, a: 999 }); // value write, membership unchanged + expect(compute).toHaveBeenCalledTimes(1); // no archetype migration → body not re-run + db.transactions.makeFoo({ a: 5, b: 0 }); // new Foo → membership changes + expect(compute).toHaveBeenCalledTimes(2); // body re-run + unsubscribe(); + }); + + it("recomputes synchronously, at most once per committed transaction", () => { + const observer = vi.fn(); + const unsubscribe = db.derive((d) => d.read(foo))(observer); + observer.mockClear(); + // Each transaction fires exactly one synchronous recompute at its commit + // boundary (no await needed) — the cadence a per-commit consumer relies on. + db.transactions.setA({ e: foo, a: 8 }); + expect(observer).toHaveBeenCalledTimes(1); + db.transactions.setB({ e: foo, b: 9 }); + expect(observer).toHaveBeenCalledTimes(2); + unsubscribe(); + }); + + it("stops emitting after unsubscribe", async () => { + const observer = vi.fn(); + const unsubscribe = db.derive((d) => d.get(foo, "a"))(observer); + unsubscribe(); + observer.mockClear(); + db.transactions.setA({ e: foo, a: 123 }); + await flush(); + expect(observer).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/data/src/ecs/database/observe-derive.ts b/packages/data/src/ecs/database/observe-derive.ts new file mode 100644 index 00000000..dc381f8a --- /dev/null +++ b/packages/data/src/ecs/database/observe-derive.ts @@ -0,0 +1,294 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. + +import { Observe } from "../../observe/index.js"; +import { equals } from "../../equals.js"; +import { StringKeyof } from "../../types/types.js"; +import { Entity } from "../entity/entity.js"; +import { ReadonlyStore } from "../store/index.js"; +import { TransactionResult } from "./transactional-store/transactional-store.js"; + +/** + * A set-valued read (an index `find` / `findRange` / `get`, or a presence + * `select`) whose dependency is the *result sequence itself*, not the columns + * behind it. Rather than re-run the whole (potentially expensive) compute body + * whenever a backing column changes anywhere, we cheaply gate, then recompute + * *just this read* and compare its result to what it returned last time — the + * same bucket-precision technique {@link observeIndexEntities} uses. Only a + * genuinely different sequence marks the derive affected, so a change to an + * unrelated bucket (or an unrelated archetype's membership) costs one cheap + * recompute, never a full body re-run. + */ +type SetRead = + // Index lookup: gate on `changedComponents ∩ readColumns` — the bucket + // contents/order cannot move unless a key or sort column changed. + | { readonly kind: "index"; readonly readColumns: ReadonlySet; recompute(): unknown; last: unknown } + // Presence `select(include, { exclude })`: membership is archetype-shaped, so + // it can only change when *some* archetype's membership changed this commit + // (`changedArchetypes`). A pure value write never moves it. + | { readonly kind: "select"; recompute(): readonly Entity[]; last: readonly Entity[] }; + +/** + * The dependency set a single `compute` run touched. Three kinds: + * - entity deps — a specific entity, either whole (`read(entity)`) or scoped + * to a set of components (`get` / `read(entity, archetype | components[])`). + * Re-run when that entity changed (and, when scoped, when one of the watched + * components changed on it, or it was deleted). + * - `columns` — component names a *resource* read depends on. A resource is a + * singleton entity carrying its own dedicated component, so `changedComponents` + * intersecting these is precise-in-effect. (This is the only remaining use of + * the coarse column gate — index and `select` reads are handled precisely + * below.) + * - `setReads` — index lookups and presence `select`s, tracked precisely by + * recompute-and-compare (see {@link SetRead}). + * + * `select`'s value-dependent `where` / `order` options are intentionally NOT + * part of the derive read surface ({@link Database.Read}): they can only be + * tracked coarsely (any value write to a filtered/sorted column), so a + * value-keyed or ordered reactive read must go through a declared index, which + * is precise and O(bucket). + */ +interface DepSet { + readonly entityWhole: Set; + readonly entityComponents: Map>; + readonly columns: Set; + readonly setReads: SetRead[]; +} + +const emptyDeps = (): DepSet => ({ + entityWhole: new Set(), + entityComponents: new Map(), + columns: new Set(), + setReads: [], +}); + +const watchComponent = (deps: DepSet, entity: Entity, component: string) => { + let set = deps.entityComponents.get(entity); + if (set === undefined) { + deps.entityComponents.set(entity, (set = new Set())); + } + set.add(component); +}; + +const affected = (deps: DepSet, result: TransactionResult): boolean => { + const { changedEntities, changedComponents, changedArchetypes } = result; + const { entityWhole, entityComponents, columns, setReads } = deps; + + // Entity deps: iterate the (usually small) *changed* set and probe the + // watched sets — O(|changedEntities|), not O(|watched|). A commit touches a + // handful of entities regardless of how many a derive reads. + if (entityWhole.size > 0 || entityComponents.size > 0) { + for (const [entity, values] of changedEntities) { + if (entityWhole.has(entity)) { + return true; + } + const watched = entityComponents.get(entity); + if (watched === undefined) { + continue; + } + if (values === null) { + // entity deleted — any scoped read of it is now stale + return true; + } + for (const component of Object.keys(values)) { + if (watched.has(component)) { + return true; + } + } + } + } + + // Resource column deps. + if (columns.size > 0 && !changedComponents.isDisjointFrom(columns)) { + return true; + } + + // Set-valued reads: cheap gate, then recompute-and-compare this one read. + for (const dep of setReads) { + if (dep.kind === "index") { + if (changedComponents.isDisjointFrom(dep.readColumns)) { + continue; + } + } else if (changedArchetypes.size === 0) { + continue; + } + if (!equals(dep.last, dep.recompute())) { + return true; + } + } + + return false; +}; + +/** + * Builds `db.derive`. A single read-recording wrapper is constructed + * once and shared across every derive on this database: because a `compute` + * body is synchronous and performs no writes or nested derives, no two runs can + * overlap, so the wrapper's "currently-recording" target is reset per run and + * safely reused. + */ +export const createDerive = ( + store: ReadonlyStore, + observeTransactions: Observe>, +) => { + // The active recording target, non-null only for the duration of one + // synchronous `compute` run. + let recording: DepSet | null = null; + + // `resources` and `indexes` are built lazily and cached: the store's + // resource and index maps are populated during database construction, which + // finishes AFTER this factory is created, but before any derive can run. + let resourcesRecorder: Record | null = null; + const buildResources = () => { + const out: Record = {}; + for (const name of Object.keys(store.resources)) { + Object.defineProperty(out, name, { + enumerable: true, + get() { + if (recording) { + recording.columns.add(name); + } + return (store.resources as Record)[name]; + }, + }); + } + return out; + }; + + let indexesRecorder: Record | null = null; + const buildIndexes = () => { + const out: Record = {}; + const storeIndexes = (store.indexes ?? {}) as Record; + for (const name of Object.keys(storeIndexes)) { + const handle = storeIndexes[name]; + const readColumns: ReadonlySet = new Set(handle.readColumns ?? []); + // Record a per-bucket dependency: the exact result this lookup + // returned, plus a thunk to recompute it. `affected` gates on the + // read columns then recompute-compares, so an unrelated bucket's + // change recomputes this (cheap Map-get + slice) to an identical + // sequence and is suppressed — the derive body never re-runs. + const recordLookup = (result: unknown, recompute: () => unknown): void => { + if (recording) { + recording.setReads.push({ kind: "index", readColumns, recompute, last: result }); + } + }; + const readHandle: Record = { + find: (arg: unknown) => { + const result = handle.find(arg); + recordLookup(result, () => handle.find(arg)); + return result; + }, + findRange: (arg: unknown) => { + const result = handle.findRange(arg); + recordLookup(result, () => handle.findRange(arg)); + return result; + }, + }; + if (typeof handle.get === "function") { + readHandle.get = (arg: unknown) => { + const result = handle.get(arg); + recordLookup(result, () => handle.get(arg)); + return result; + }; + } + out[name] = readHandle; + } + return out; + }; + + // The read-recording projection handed to `compute`. Delegates every read to + // the real store and records the dependency it implies. Typed loosely here; + // the precise `Database.Read<…>` surface is enforced at the public + // `db.derive` boundary. + const recorder = { + get: (entity: Entity, component: StringKeyof) => { + if (recording) { + watchComponent(recording, entity, component); + } + return store.get(entity, component); + }, + read: (entity: Entity, archetypeOrComponents?: { readonly components: ReadonlySet } | readonly string[]) => { + if (recording) { + if (archetypeOrComponents === undefined) { + recording.entityWhole.add(entity); + } else if (Array.isArray(archetypeOrComponents)) { + for (const component of archetypeOrComponents as readonly string[]) { + watchComponent(recording, entity, component); + } + } else { + for (const component of (archetypeOrComponents as { readonly components: ReadonlySet }).components) { + watchComponent(recording, entity, component); + } + } + } + return (store.read as (e: Entity, a?: unknown) => unknown)(entity, archetypeOrComponents); + }, + // Presence select only — `include` + `exclude`, no `where` / `order` + // (value-dependent options are omitted from the derive surface; use a + // declared index for value-keyed or ordered reactive reads). Recorded as + // a set-valued read gated on `changedArchetypes`: the result is + // membership-shaped, so a pure value write can never move it, and an + // unrelated migration recompute-compares to the same sequence. + select: (include: readonly string[] | ReadonlySet, options?: { exclude?: readonly string[] }) => { + const exclude = options?.exclude; + const args: [unknown, unknown] = [include, exclude === undefined ? undefined : { exclude }]; + const run = (): readonly Entity[] => (store.select as (i: unknown, o?: unknown) => readonly Entity[])(...args); + const result = run(); + if (recording) { + recording.setReads.push({ kind: "select", recompute: run, last: result }); + } + return result; + }, + get resources() { + return (resourcesRecorder ??= buildResources()); + }, + get indexes() { + return (indexesRecorder ??= buildIndexes()); + }, + // Archetype identity (components / id) is static, so reading it records + // no dependency; delegate to the real archetypes. + archetypes: store.archetypes, + }; + + return (compute: (db: any) => T): Observe => (notify) => { + let last: T; + let hasLast = false; + let deps: DepSet = emptyDeps(); + + const run = (): T => { + recording = emptyDeps(); + try { + return compute(recorder); + } finally { + deps = recording; + recording = null; + } + }; + + const emit = (value: T) => { + if (!hasLast || !equals(last, value)) { + last = value; + hasLast = true; + notify(value); + } + }; + + emit(run()); + + // `observeTransactions` fires once per committed transaction, so a + // recompute happens at most once per commit. Emit synchronously at the + // commit boundary — the same cadence as `observe.entity` / the raw + // component observers a hand-written computed would use — rather than + // deferring to a microtask, which would coalesce several commits in one + // turn (e.g. a burst of ephemeral drag commits) into a single emission + // and starve consumers that route per commit. + const unobserve = observeTransactions((result) => { + if (affected(deps, result)) { + emit(run()); + } + }); + + return () => { + unobserve(); + }; + }; +}; diff --git a/packages/data/src/ecs/database/observed/create-observed-database.ts b/packages/data/src/ecs/database/observed/create-observed-database.ts index fd035557..3ab88061 100644 --- a/packages/data/src/ecs/database/observed/create-observed-database.ts +++ b/packages/data/src/ecs/database/observed/create-observed-database.ts @@ -10,6 +10,7 @@ import { Archetype, ArchetypeId, ReadonlyArchetype } from "../../archetype/index import { Store } from "../../store/index.js"; import { TransactionResult } from "../transactional-store/index.js"; import { observeSelectEntities } from "../observe-select-entities.js"; +import { createDerive } from "../observe-derive.js"; import { createTransactionalStore } from "../transactional-store/create-transactional-store.js"; import { RequiredComponents } from "../../required-components.js"; import { Entity } from "../../entity/entity.js"; @@ -175,6 +176,7 @@ export function createObservedDatabase< ...rest, resources, observe, + derive: createDerive(store, observeTransaction), execute, reset: () => { store.reset(); diff --git a/packages/data/src/ecs/database/observed/observed-database.ts b/packages/data/src/ecs/database/observed/observed-database.ts index de28138d..1ef8ec8d 100644 --- a/packages/data/src/ecs/database/observed/observed-database.ts +++ b/packages/data/src/ecs/database/observed/observed-database.ts @@ -34,6 +34,9 @@ export interface ObservedDatabase< options?: any ): Observe; }; + // Internal (loose) type — the precise `Database.Read` callback surface + // is enforced at the public `Database.derive` boundary. + derive(compute: (db: any) => T): Observe; readonly execute: (handler: (ctx: TransactionContext) => Entity | void, options?: { transient?: boolean; userId?: number | string }) => TransactionResult; readonly reset: () => void; readonly toData: (copy?: boolean) => unknown; diff --git a/packages/data/src/ecs/store/core/core.ts b/packages/data/src/ecs/store/core/core.ts index adfece7d..9405bc36 100644 --- a/packages/data/src/ecs/store/core/core.ts +++ b/packages/data/src/ecs/store/core/core.ts @@ -30,7 +30,28 @@ export interface ReadonlyCore< ) => ReadonlyArchetype; locate: (entity: Entity) => { archetype: ReadonlyArchetype, row: number } | null; - read(entity: Entity, minArchetype: ReadonlyArchetype | Archetype): Readonly & EntityReadValues | null; + /** + * Read exactly the components of `archetype`. A membership GATE: returns + * `null` unless the entity is a superset of `archetype`. The result is + * narrowed to the archetype's own row — reading a component outside + * `archetype` off the result is a compile error (use a wider archetype, the + * component-list overload, or `read(entity)`). + */ + read(entity: Entity, archetype: ReadonlyArchetype | Archetype): Readonly | null; + /** + * Read a chosen subset of an entity's components. + * + * A pure PROJECTION, not a membership gate: returns `null` only when the + * entity does not exist. A requested component the entity does not have + * comes back absent — the field stays optional in the result, exactly as + * from the full `read(entity)`. + * + * Prefer this over `read(entity)` when only a few fields are needed: it + * names the exact components touched, so `db.derive` can scope its + * recompute to just those fields instead of the whole entity. `id` is + * always readable; the element type is inferred as a literal union. + */ + read>>(entity: Entity, components: readonly K[]): Readonly, K>> | null; read(entity: Entity): EntityReadValues | null; get>(entity: Entity, component: K): C[K] | undefined; /** diff --git a/packages/data/src/ecs/store/core/create-core.ts b/packages/data/src/ecs/store/core/create-core.ts index ef94f1bc..a5c0c43d 100644 --- a/packages/data/src/ecs/store/core/create-core.ts +++ b/packages/data/src/ecs/store/core/create-core.ts @@ -114,13 +114,33 @@ export function createCore(newComponentSchemas: NC) return (entity < 0 ? nonPersistentLocationTable : persistentLocationTable).locate(entity); } - const readEntity = (entity: Entity, minArchetype?: ReadonlyArchetype | Archetype): any => { + const readEntity = ( + entity: Entity, + archetypeOrComponents?: ReadonlyArchetype | Archetype | readonly string[] + ): any => { const location = locateInternal(entity); if (location === null) { return null; } const archetype = archetypes[location.archetype]; - if (minArchetype && location.archetype !== minArchetype.id && !archetype.components.isSupersetOf(minArchetype.components)) { + // Component-list form: a pure projection — never gates on membership. + // Reads ONLY the requested components' columns (never the whole row); an + // absent component is simply omitted (the field is optional in the type). + if (Array.isArray(archetypeOrComponents)) { + const projected: Record = {}; + for (const component of archetypeOrComponents as readonly string[]) { + const column = archetype.columns[component]; + if (column !== undefined) { + projected[component] = column.get(location.row); + } + } + return projected; + } + // Archetype form: a membership gate — null unless the entity is a + // superset of the archetype. The array case returned above, so a + // defined `archetypeOrComponents` here is an archetype. + const archetypeArg = archetypeOrComponents as ReadonlyArchetype | undefined; + if (archetypeArg && location.archetype !== archetypeArg.id && !archetype.components.isSupersetOf(archetypeArg.components)) { return null; } return getRowData(archetype, location.row);