From b3d05abcbc4ceb6161617dcfe1bc682a74364a6f Mon Sep 17 00:00:00 2001 From: Kris Nye Date: Tue, 14 Jul 2026 16:40:16 -0700 Subject: [PATCH 1/6] feat(data): types for Database.Read + db.observe.derive (interfaces only) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Design-review PR — the type surface for a reactive-derivation primitive, no runtime implementation yet. - `Database.Read`: the read-only projection a derive callback receives. Keeps value reads (`get`/`read`/`select`), resource reads, index LOOKUPS (`find`/`findRange`/`get`), and archetype identity (`components`/`id`). Structurally OMITS observers, transactions/writes, `queryArchetypes`, `locate`, and per-archetype table/column/`insert` access — so touching a non-derivable API is a compile error, not a runtime throw. - `Database.Index.ReadHandle`: an index handle minus `observe`. - `db.observe.derive(compute: (db: Database.Read<…>) => T, options?) : Observe` — signature only; the intent is that a derive records the reads `compute` performs and re-emits when any could have changed. Compile-time tests (`database-read.type-test.ts`) pin the positive surface and, via `@ts-expect-error`, that `observe` / `transactions` / `queryArchetypes` / `locate` / `columns` / `rowCount` / `insert` / `indexes.*.observe` are all rejected. typecheck + lint clean. No implementation and no version bump — for reviewing the types; not to merge. Co-Authored-By: Claude Opus 4.8 --- .../ecs/database/database-read.type-test.ts | 136 ++++++++++++++++++ packages/data/src/ecs/database/database.ts | 58 ++++++++ 2 files changed, 194 insertions(+) create mode 100644 packages/data/src/ecs/database/database-read.type-test.ts 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..ef424132 --- /dev/null +++ b/packages/data/src/ecs/database/database-read.type-test.ts @@ -0,0 +1,136 @@ +// © 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.observe.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.observe.derive((d) => d.get(0 as Entity, "x")); + type _Gotten = Assert>>; + + const selected = db.observe.derive((d) => d.select(["x"])); + type _Selected = Assert>>; + + db.observe.derive((d) => d.read(0 as Entity)); + + // resource reads + const frameRate = db.observe.derive((d) => d.resources.frameRate); + type _FrameRate = Assert>>; + + // archetype identity — and the realistic composition select(archetype.components) + db.observe.derive((d) => d.archetypes.Foo.components); + db.observe.derive((d) => d.archetypes.Foo.id); + db.observe.derive((d) => d.select(d.archetypes.Foo.components)); + + // index lookups + db.observe.derive((d) => d.indexes.byX.find({ x: 1 })); + db.observe.derive((d) => d.indexes.byX.findRange({ x: 1 })); + // a unique index also exposes `get` + const head = db.observe.derive((d) => d.indexes.byXUnique.get({ x: 1 })); + type _Head = Assert>>; + + // options.equals + db.observe.derive((d) => d.get(0 as Entity, "x"), { equals: (a, b) => a === b }); +} + +// ============================================================================ +// derive — NEGATIVE: everything the read projection removes +// ============================================================================ + +function deriveNegative() { + db.observe.derive((d) => { + // @ts-expect-error observers are not exposed — a derive subscribes to its own reads + return d.observe; + }); + db.observe.derive((d) => { + // @ts-expect-error transactions / writes are not exposed to a read-only derive + return d.transactions; + }); + db.observe.derive((d) => { + // @ts-expect-error queryArchetypes (raw column access) is not exposed + return d.queryArchetypes; + }); + db.observe.derive((d) => { + // @ts-expect-error locate (row access) is not exposed + return d.locate; + }); + db.observe.derive((d) => { + // @ts-expect-error per-archetype column access is not exposed + return d.archetypes.Foo.columns; + }); + db.observe.derive((d) => { + // @ts-expect-error per-archetype rowCount (table access) is not exposed + return d.archetypes.Foo.rowCount; + }); + db.observe.derive((d) => { + // @ts-expect-error per-archetype insert (write) is not exposed + return d.archetypes.Foo.insert; + }); + db.observe.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 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 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; +} diff --git a/packages/data/src/ecs/database/database.ts b/packages/data/src/ecs/database/database.ts index f1c343b2..e3769973 100644 --- a/packages/data/src/ecs/database/database.ts +++ b/packages/data/src/ecs/database/database.ts @@ -158,6 +158,24 @@ export interface Database< include: readonly Include[] | ReadonlySet, options?: EntitySelectOptions> ): Observe; + /** + * Reactive derivation. `compute` is run against a read-only projection of + * this database ({@link Database.Read}); the derive records exactly the + * reads it performs, subscribes to their change signals, and re-emits the + * recomputed value whenever any of those reads could have changed. Emits + * the initial value on subscribe; coalesces a transaction's changes into a + * single recompute; deduplicates consecutive equal results (`options.equals`, + * defaulting to reference equality). + * + * The projection omits observers, writes, and direct table/column access, + * so a `compute` body cannot subscribe, mutate, or read raw rows — the + * dependency set is exactly what it read, which removes the whole class of + * "forgot to subscribe to a field I read" staleness bugs. + */ + derive( + compute: (db: Database.Read>) => T, + options?: { readonly equals?: (previous: T, next: T) => boolean } + ): Observe; } /** * Wipes all entities and resets all resources to their plugin defaults, @@ -250,6 +268,38 @@ export namespace Database { RemoveIndex >; + /** + * The read-only projection of a Database that a `db.observe.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. + */ + export type Read> = + DB extends Database + ? Pick, "get" | "read" | "select" | "resources"> & { + readonly indexes: { + readonly [K in keyof IX]: Database.Index.ReadHandle; + }; + readonly archetypes: { + readonly [K in keyof ReadonlyStore["archetypes"]]: Pick< + ReadonlyStore["archetypes"][K], + "components" | "id" + >; + }; + } + : never; + export const create = createDatabase; export const is = (value: unknown): value is Database => { @@ -270,6 +320,14 @@ export namespace Database { export namespace Index { export type Handle> = StoreIndex.Handle; + /** + * The index handle as exposed to a `db.observe.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 From f54604a18eb64db25c3eb83f5fb3853e5f41b6d8 Mon Sep 17 00:00:00 2001 From: Kris Nye Date: Tue, 14 Jul 2026 18:45:11 -0700 Subject: [PATCH 2/6] feat(data): implement db.observe.derive + read projections MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the spec from the prior commit. read overloads (core.ts): - read(entity, archetype) narrowed to `Readonly` (was `& EntityReadValues`); `minArchetype` renamed to `archetype`. The narrowing's blast radius across adobe/data was zero. - new read(entity, components[]) projection — present-or-undefined, `null` only when the entity is absent — enabling component-scoped derive deps. derive engine (observe-derive.ts): - ONE read-recording wrapper per database, shared across all derives; its recorded-dep set is reset per synchronous compute run. Safe to share because compute is synchronous and the read surface exposes no writes / observers / nested derives, so runs cannot overlap or recurse. - deps reduce to entity deps (gated on changedEntities + changed-component intersection) and column deps (select include/exclude/where/order, index readColumns, resource names) checked against changedComponents. One transaction subscription per derive; recompute coalesced on a microtask; emit gated by structural `equals`. Tests: observe-derive.test.ts — initial emit, component-scoped re-emit vs whole-entity coarseness, structural dedupe, select / index / resource deps, coalescing, disposal. Type tests updated for the projection read. typecheck + lint + 911 ecs/observe tests green. Co-Authored-By: Claude Opus 4.8 --- .../ecs/database/database-read.type-test.ts | 30 +- packages/data/src/ecs/database/database.ts | 7 +- .../src/ecs/database/observe-derive.test.ts | 160 +++++++++++ .../data/src/ecs/database/observe-derive.ts | 256 ++++++++++++++++++ .../observed/create-observed-database.ts | 2 + .../database/observed/observed-database.ts | 3 + packages/data/src/ecs/store/core/core.ts | 23 +- .../data/src/ecs/store/core/create-core.ts | 24 +- 8 files changed, 497 insertions(+), 8 deletions(-) create mode 100644 packages/data/src/ecs/database/observe-derive.test.ts create mode 100644 packages/data/src/ecs/database/observe-derive.ts diff --git a/packages/data/src/ecs/database/database-read.type-test.ts b/packages/data/src/ecs/database/database-read.type-test.ts index ef424132..2a6f6865 100644 --- a/packages/data/src/ecs/database/database-read.type-test.ts +++ b/packages/data/src/ecs/database/database-read.type-test.ts @@ -44,8 +44,35 @@ function derivePositive() { const selected = db.observe.derive((d) => d.select(["x"])); type _Selected = Assert>>; + // whole-entity read db.observe.derive((d) => d.read(0 as Entity)); + // projection read: exactly the requested fields, optionality preserved, + // unrequested fields (incl. id) excluded + db.observe.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.observe.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.observe.derive((d) => d.resources.frameRate); type _FrameRate = Assert>>; @@ -61,9 +88,6 @@ function derivePositive() { // a unique index also exposes `get` const head = db.observe.derive((d) => d.indexes.byXUnique.get({ x: 1 })); type _Head = Assert>>; - - // options.equals - db.observe.derive((d) => d.get(0 as Entity, "x"), { equals: (a, b) => a === b }); } // ============================================================================ diff --git a/packages/data/src/ecs/database/database.ts b/packages/data/src/ecs/database/database.ts index e3769973..92351124 100644 --- a/packages/data/src/ecs/database/database.ts +++ b/packages/data/src/ecs/database/database.ts @@ -171,10 +171,13 @@ export interface Database< * so a `compute` body cannot subscribe, mutate, or read raw rows — the * dependency set is exactly what it read, which removes the whole class of * "forgot to subscribe to a field I read" staleness bugs. + * + * Emits the initial value on subscribe, coalesces a transaction's changes + * into a single recompute, and structurally compares the result before + * emitting so an unchanged value never re-notifies. */ derive( - compute: (db: Database.Read>) => T, - options?: { readonly equals?: (previous: T, next: T) => boolean } + compute: (db: Database.Read>) => T ): Observe; } /** 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..fd334e7f --- /dev/null +++ b/packages/data/src/ecs/database/observe-derive.test.ts @@ -0,0 +1,160 @@ +// © 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.observe.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.observe.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.observe.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.observe.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.observe.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.observe.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.observe.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.observe.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.observe.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("coalesces multiple synchronous mutations into a single recompute", async () => { + const observer = vi.fn(); + const unsubscribe = db.observe.derive((d) => d.read(foo))(observer); + observer.mockClear(); + db.transactions.setA({ e: foo, a: 8 }); + db.transactions.setB({ e: foo, b: 9 }); + await flush(); + // two transactions, but they land before the microtask drains → we accept + // at most one recompute per microtask turn (not one per transaction). + expect(observer.mock.calls.length).toBeLessThanOrEqual(2); + unsubscribe(); + }); + + it("stops emitting after unsubscribe", async () => { + const observer = vi.fn(); + const unsubscribe = db.observe.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..1d38da3a --- /dev/null +++ b/packages/data/src/ecs/database/observe-derive.ts @@ -0,0 +1,256 @@ +// © 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"; + +/** + * The dependency set a single `compute` run touched. Reads reduce to two kinds + * of dependency: + * - 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). + * - column deps — component names whose *value or membership* a `select`, an + * index lookup, or a resource read depends on. Re-run when the transaction's + * `changedComponents` intersects them. (`select` include/exclude/where/order + * keys, an index's `readColumns`, and a resource's own component name all + * land here — a set of entities can only gain/lose a member, reorder, or + * change a read value by touching one of these columns.) + */ +interface DepSet { + readonly entityWhole: Set; + readonly entityComponents: Map>; + readonly columns: Set; +} + +const emptyDeps = (): DepSet => ({ + entityWhole: new Set(), + entityComponents: new Map(), + columns: new Set(), +}); + +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 } = result; + for (const entity of deps.entityWhole) { + if (changedEntities.has(entity)) { + return true; + } + } + for (const [entity, components] of deps.entityComponents) { + if (!changedEntities.has(entity)) { + continue; + } + const values = changedEntities.get(entity); + if (values === undefined) { + // unreachable: `has(entity)` was true above — satisfies the map's + // `V | undefined` return type. + continue; + } + if (values === null) { + // entity deleted — any scoped read of it is now stale + return true; + } + for (const component of Object.keys(values)) { + if (components.has(component)) { + return true; + } + } + } + if (deps.columns.size > 0) { + for (const component of changedComponents) { + if (deps.columns.has(component)) { + return true; + } + } + } + return false; +}; + +/** + * Builds `db.observe.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: readonly string[] = handle.readColumns ?? []; + const trackIndex = () => { + if (recording) { + for (const column of readColumns) { + recording.columns.add(column); + } + } + }; + const readHandle: Record = { + find: (arg: unknown) => { + trackIndex(); + return handle.find(arg); + }, + findRange: (arg: unknown) => { + trackIndex(); + return handle.findRange(arg); + }, + }; + if (typeof handle.get === "function") { + readHandle.get = (arg: unknown) => { + trackIndex(); + return handle.get(arg); + }; + } + 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.observe.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); + }, + select: (include: readonly string[] | ReadonlySet, options?: { exclude?: readonly string[]; where?: object; order?: object }) => { + if (recording) { + for (const component of include) { + recording.columns.add(component); + } + if (options?.exclude) { + for (const component of options.exclude) { + recording.columns.add(component); + } + } + if (options?.where) { + for (const component of Object.keys(options.where)) { + recording.columns.add(component); + } + } + if (options?.order) { + for (const component of Object.keys(options.order)) { + recording.columns.add(component); + } + } + } + return (store.select as (i: unknown, o?: unknown) => readonly Entity[])(include, options); + }, + 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 scheduled = 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()); + + const unobserve = observeTransactions((result) => { + if (!affected(deps, result)) { + return; + } + if (scheduled) { + return; + } + scheduled = true; + queueMicrotask(() => { + scheduled = false; + 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..4e6e9e97 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"; @@ -143,6 +144,7 @@ export function createObservedDatabase< entity: observeEntity, archetype: observeArchetype, select: observeSelectEntities(transactionalStore, observeTransaction), + derive: createDerive(store, observeTransaction), }; const notifyAllObserversStoreReloaded = () => { diff --git a/packages/data/src/ecs/database/observed/observed-database.ts b/packages/data/src/ecs/database/observed/observed-database.ts index de28138d..7adc0035 100644 --- a/packages/data/src/ecs/database/observed/observed-database.ts +++ b/packages/data/src/ecs/database/observed/observed-database.ts @@ -33,6 +33,9 @@ export interface ObservedDatabase< include: readonly Include[] | ReadonlySet, options?: any ): Observe; + // Internal (loose) type — the precise `Database.Read<…>` callback surface + // is enforced at the public `Database.observe.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; diff --git a/packages/data/src/ecs/store/core/core.ts b/packages/data/src/ecs/store/core/core.ts index adfece7d..8942517f 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.observe.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..7acc72ce 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. + // Return only the requested components the entity actually has; an + // absent component is simply omitted (the field is optional in the type). + if (Array.isArray(archetypeOrComponents)) { + const full = getRowData(archetype, location.row) as Record; + const projected: Record = {}; + for (const component of archetypeOrComponents as readonly string[]) { + if (component in full) { + projected[component] = full[component]; + } + } + 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); From a5ca1bca28a0965774291465ca167a96ae7418db Mon Sep 17 00:00:00 2001 From: Kris Nye Date: Tue, 14 Jul 2026 19:41:41 -0700 Subject: [PATCH 3/6] fix(data): derive emits synchronously per commit, not microtask-coalesced MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `observeTransactions` fires once per committed transaction, so a derive already recomputes at most once per commit — deferring the emit to a microtask only coalesced *across* commits in one turn, which starves consumers that route per commit (e.g. a burst of ephemeral drag commits, where each must produce its own downstream effect). Emit synchronously at the commit boundary instead — the same cadence as `observe.entity` / the raw component observers a hand-written computed would use. Structural dedupe still suppresses unchanged results. Co-Authored-By: Claude Opus 4.8 --- packages/data/src/ecs/database/database.ts | 7 ++++--- .../src/ecs/database/observe-derive.test.ts | 10 +++++----- .../data/src/ecs/database/observe-derive.ts | 20 +++++++++---------- 3 files changed, 18 insertions(+), 19 deletions(-) diff --git a/packages/data/src/ecs/database/database.ts b/packages/data/src/ecs/database/database.ts index 92351124..4af37cd2 100644 --- a/packages/data/src/ecs/database/database.ts +++ b/packages/data/src/ecs/database/database.ts @@ -172,9 +172,10 @@ export interface Database< * dependency set is exactly what it read, which removes the whole class of * "forgot to subscribe to a field I read" staleness bugs. * - * Emits the initial value on subscribe, coalesces a transaction's changes - * into a single recompute, and structurally compares the result before - * emitting so an unchanged value never re-notifies. + * Emits the initial value on subscribe, then recomputes at most once per + * committed transaction — synchronously at the commit boundary — and + * structurally compares the result before emitting so an unchanged value + * never re-notifies. */ derive( compute: (db: Database.Read>) => T diff --git a/packages/data/src/ecs/database/observe-derive.test.ts b/packages/data/src/ecs/database/observe-derive.test.ts index fd334e7f..f47cbc36 100644 --- a/packages/data/src/ecs/database/observe-derive.test.ts +++ b/packages/data/src/ecs/database/observe-derive.test.ts @@ -135,16 +135,16 @@ describe("db.observe.derive", () => { unsubscribe(); }); - it("coalesces multiple synchronous mutations into a single recompute", async () => { + it("recomputes synchronously, at most once per committed transaction", () => { const observer = vi.fn(); const unsubscribe = db.observe.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 }); - await flush(); - // two transactions, but they land before the microtask drains → we accept - // at most one recompute per microtask turn (not one per transaction). - expect(observer.mock.calls.length).toBeLessThanOrEqual(2); + expect(observer).toHaveBeenCalledTimes(2); unsubscribe(); }); diff --git a/packages/data/src/ecs/database/observe-derive.ts b/packages/data/src/ecs/database/observe-derive.ts index 1d38da3a..4c050e1e 100644 --- a/packages/data/src/ecs/database/observe-derive.ts +++ b/packages/data/src/ecs/database/observe-derive.ts @@ -212,7 +212,6 @@ export const createDerive = ( return (compute: (db: any) => T): Observe => (notify) => { let last: T; let hasLast = false; - let scheduled = false; let deps: DepSet = emptyDeps(); const run = (): T => { @@ -235,18 +234,17 @@ export const createDerive = ( 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)) { - return; - } - if (scheduled) { - return; - } - scheduled = true; - queueMicrotask(() => { - scheduled = false; + if (affected(deps, result)) { emit(run()); - }); + } }); return () => { From 03e618bdab2aabda65543e6ac1714f9b15457b80 Mon Sep 17 00:00:00 2001 From: Kris Nye Date: Tue, 14 Jul 2026 19:55:41 -0700 Subject: [PATCH 4/6] perf(data): projection read reads only the requested columns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `read(entity, components[])` built the full row via `getRowData` and then discarded the columns it didn't need. Read only the requested components' columns directly (`archetype.columns[c].get(row)`), skipping absent ones — no whole-row allocation or reads of columns the caller didn't ask for. Co-Authored-By: Claude Opus 4.8 --- packages/data/src/ecs/store/core/create-core.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/data/src/ecs/store/core/create-core.ts b/packages/data/src/ecs/store/core/create-core.ts index 7acc72ce..a5c0c43d 100644 --- a/packages/data/src/ecs/store/core/create-core.ts +++ b/packages/data/src/ecs/store/core/create-core.ts @@ -124,14 +124,14 @@ export function createCore(newComponentSchemas: NC) } const archetype = archetypes[location.archetype]; // Component-list form: a pure projection — never gates on membership. - // Return only the requested components the entity actually has; an + // 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 full = getRowData(archetype, location.row) as Record; const projected: Record = {}; for (const component of archetypeOrComponents as readonly string[]) { - if (component in full) { - projected[component] = full[component]; + const column = archetype.columns[component]; + if (column !== undefined) { + projected[component] = column.get(location.row); } } return projected; From 1a4a27716e03c00b270fcb475ed50e56cd3b1363 Mon Sep 17 00:00:00 2001 From: Kris Nye Date: Tue, 14 Jul 2026 20:19:16 -0700 Subject: [PATCH 5/6] feat(data): move derive to top-level db.derive; make Database.Read structural MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Redefine `Database.Read` by mapping over `DB`'s own members instead of `DB extends Database`. The `infer` form collapsed an intersection `A & B` to a single matched member; the structural form distributes, so `Read` merges both read surfaces. Move `derive` off `db.observe.derive` to a top-level `db.derive` whose callback receives `Database.Read`. On an intersection receiver `this` resolves to `A & B`, so a derive over a composed database sees indexes + resources + archetypes from both members — consumers of an `A & B` database no longer cast. Co-Authored-By: Claude Opus 4.8 --- .../ecs/database/database-read.type-test.ts | 100 ++++++++++++++---- packages/data/src/ecs/database/database.ts | 69 ++++++------ .../src/ecs/database/observe-derive.test.ts | 22 ++-- .../data/src/ecs/database/observe-derive.ts | 4 +- .../observed/create-observed-database.ts | 2 +- .../database/observed/observed-database.ts | 6 +- packages/data/src/ecs/store/core/core.ts | 2 +- 7 files changed, 129 insertions(+), 76 deletions(-) diff --git a/packages/data/src/ecs/database/database-read.type-test.ts b/packages/data/src/ecs/database/database-read.type-test.ts index 2a6f6865..eed1d44e 100644 --- a/packages/data/src/ecs/database/database-read.type-test.ts +++ b/packages/data/src/ecs/database/database-read.type-test.ts @@ -7,7 +7,7 @@ import { Entity } from "../entity/entity.js"; import { Observe } from "../../observe/index.js"; /** - * Compile-time tests for `Database.Read` and the `db.observe.derive` + * 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). @@ -38,18 +38,18 @@ const db = Database.create(plugin); function derivePositive() { // value reads - const gotten = db.observe.derive((d) => d.get(0 as Entity, "x")); + const gotten = db.derive((d) => d.get(0 as Entity, "x")); type _Gotten = Assert>>; - const selected = db.observe.derive((d) => d.select(["x"])); + const selected = db.derive((d) => d.select(["x"])); type _Selected = Assert>>; // whole-entity read - db.observe.derive((d) => d.read(0 as Entity)); + db.derive((d) => d.read(0 as Entity)); // projection read: exactly the requested fields, optionality preserved, // unrequested fields (incl. id) excluded - db.observe.derive((d) => { + db.derive((d) => { const p = d.read(0 as Entity, ["x", "y"]); if (p !== null) { const _x: number | undefined = p.x; @@ -63,7 +63,7 @@ function derivePositive() { }); // archetype read: narrowed to the archetype's own row (no extra fields) - db.observe.derive((d) => { + db.derive((d) => { const foo = d.read(0 as Entity, db.archetypes.Foo); if (foo !== null) { const _x: number = foo.x; @@ -74,19 +74,19 @@ function derivePositive() { }); // resource reads - const frameRate = db.observe.derive((d) => d.resources.frameRate); + const frameRate = db.derive((d) => d.resources.frameRate); type _FrameRate = Assert>>; // archetype identity — and the realistic composition select(archetype.components) - db.observe.derive((d) => d.archetypes.Foo.components); - db.observe.derive((d) => d.archetypes.Foo.id); - db.observe.derive((d) => d.select(d.archetypes.Foo.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.observe.derive((d) => d.indexes.byX.find({ x: 1 })); - db.observe.derive((d) => d.indexes.byX.findRange({ x: 1 })); + 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.observe.derive((d) => d.indexes.byXUnique.get({ x: 1 })); + const head = db.derive((d) => d.indexes.byXUnique.get({ x: 1 })); type _Head = Assert>>; } @@ -95,35 +95,35 @@ function derivePositive() { // ============================================================================ function deriveNegative() { - db.observe.derive((d) => { + db.derive((d) => { // @ts-expect-error observers are not exposed — a derive subscribes to its own reads return d.observe; }); - db.observe.derive((d) => { + db.derive((d) => { // @ts-expect-error transactions / writes are not exposed to a read-only derive return d.transactions; }); - db.observe.derive((d) => { + db.derive((d) => { // @ts-expect-error queryArchetypes (raw column access) is not exposed return d.queryArchetypes; }); - db.observe.derive((d) => { + db.derive((d) => { // @ts-expect-error locate (row access) is not exposed return d.locate; }); - db.observe.derive((d) => { + db.derive((d) => { // @ts-expect-error per-archetype column access is not exposed return d.archetypes.Foo.columns; }); - db.observe.derive((d) => { + db.derive((d) => { // @ts-expect-error per-archetype rowCount (table access) is not exposed return d.archetypes.Foo.rowCount; }); - db.observe.derive((d) => { + db.derive((d) => { // @ts-expect-error per-archetype insert (write) is not exposed return d.archetypes.Foo.insert; }); - db.observe.derive((d) => { + db.derive((d) => { // @ts-expect-error index observe is not exposed — a derive subscribes for you return d.indexes.byX.observe; }); @@ -158,3 +158,61 @@ function readProjectionNegative() { // @ts-expect-error transactions omitted from the read projection rd.transactions; } + +// ============================================================================ +// 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 4af37cd2..8c0aa4f4 100644 --- a/packages/data/src/ecs/database/database.ts +++ b/packages/data/src/ecs/database/database.ts @@ -158,29 +158,21 @@ export interface Database< include: readonly Include[] | ReadonlySet, options?: EntitySelectOptions> ): Observe; - /** - * Reactive derivation. `compute` is run against a read-only projection of - * this database ({@link Database.Read}); the derive records exactly the - * reads it performs, subscribes to their change signals, and re-emits the - * recomputed value whenever any of those reads could have changed. Emits - * the initial value on subscribe; coalesces a transaction's changes into a - * single recompute; deduplicates consecutive equal results (`options.equals`, - * defaulting to reference equality). - * - * The projection omits observers, writes, and direct table/column access, - * so a `compute` body cannot subscribe, mutate, or read raw rows — the - * dependency set is exactly what it read, which removes the whole class of - * "forgot to subscribe to a field I read" staleness bugs. - * - * Emits the initial value on subscribe, then recomputes at most once per - * committed transaction — synchronously at the commit boundary — and - * structurally compares the result before emitting so an unchanged value - * never re-notifies. - */ - derive( - compute: (db: Database.Read>) => T - ): 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 @@ -273,7 +265,7 @@ export namespace Database { >; /** - * The read-only projection of a Database that a `db.observe.derive` callback + * 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` @@ -289,20 +281,23 @@ export namespace Database { * 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> = - DB extends Database - ? Pick, "get" | "read" | "select" | "resources"> & { - readonly indexes: { - readonly [K in keyof IX]: Database.Index.ReadHandle; - }; - readonly archetypes: { - readonly [K in keyof ReadonlyStore["archetypes"]]: Pick< - ReadonlyStore["archetypes"][K], - "components" | "id" - >; - }; - } - : never; + Pick & { + readonly indexes: { + readonly [K in keyof DB["indexes"]]: Omit; + }; + readonly archetypes: { + readonly [K in keyof DB["archetypes"]]: Pick; + }; + }; export const create = createDatabase; @@ -325,7 +320,7 @@ export namespace Database { export type Handle> = StoreIndex.Handle; /** - * The index handle as exposed to a `db.observe.derive` callback: the + * 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. diff --git a/packages/data/src/ecs/database/observe-derive.test.ts b/packages/data/src/ecs/database/observe-derive.test.ts index f47cbc36..fefe32d2 100644 --- a/packages/data/src/ecs/database/observe-derive.test.ts +++ b/packages/data/src/ecs/database/observe-derive.test.ts @@ -38,7 +38,7 @@ const createTestDatabase = () => const flush = () => new Promise((resolve) => queueMicrotask(() => resolve())); -describe("db.observe.derive", () => { +describe("db.derive", () => { let db: ReturnType; let foo: Entity; @@ -49,7 +49,7 @@ describe("db.observe.derive", () => { it("emits the initial computed value synchronously on subscribe", () => { const observer = vi.fn(); - const unsubscribe = db.observe.derive((d) => d.get(foo, "a"))(observer); + const unsubscribe = db.derive((d) => d.get(foo, "a"))(observer); expect(observer).toHaveBeenCalledTimes(1); expect(observer).toHaveBeenLastCalledWith(1); unsubscribe(); @@ -57,7 +57,7 @@ describe("db.observe.derive", () => { it("re-emits when a component it read changes", async () => { const observer = vi.fn(); - const unsubscribe = db.observe.derive((d) => d.get(foo, "a"))(observer); + const unsubscribe = db.derive((d) => d.get(foo, "a"))(observer); db.transactions.setA({ e: foo, a: 2 }); await flush(); expect(observer).toHaveBeenCalledTimes(2); @@ -68,7 +68,7 @@ describe("db.observe.derive", () => { 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.observe.derive((d) => d.read(foo, ["a"])?.a ?? -1)(observer); + 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(); @@ -82,7 +82,7 @@ describe("db.observe.derive", () => { it("a whole-entity read re-emits on any component change of that entity", async () => { const observer = vi.fn(); - const unsubscribe = db.observe.derive((d) => d.read(foo)?.b ?? -1)(observer); + const unsubscribe = db.derive((d) => d.read(foo)?.b ?? -1)(observer); db.transactions.setB({ e: foo, b: 77 }); await flush(); expect(observer).toHaveBeenLastCalledWith(77); @@ -92,7 +92,7 @@ describe("db.observe.derive", () => { 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.observe.derive((d) => ((d.get(foo, "a") ?? 0) > 0 ? "pos" : "neg"))(observer); + 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 @@ -107,7 +107,7 @@ describe("db.observe.derive", () => { it("re-emits on membership change of a select it read", async () => { const observer = vi.fn(); - const unsubscribe = db.observe.derive((d) => d.select(["a"]).length)(observer); + const unsubscribe = db.derive((d) => d.select(["a"]).length)(observer); expect(observer).toHaveBeenLastCalledWith(1); db.transactions.makeFoo({ a: 100, b: 0 }); await flush(); @@ -117,7 +117,7 @@ describe("db.observe.derive", () => { it("re-emits on a change to a resource it read", async () => { const observer = vi.fn(); - const unsubscribe = db.observe.derive((d) => d.resources.r)(observer); + const unsubscribe = db.derive((d) => d.resources.r)(observer); expect(observer).toHaveBeenLastCalledWith(0); db.transactions.setR({ r: 42 }); await flush(); @@ -127,7 +127,7 @@ describe("db.observe.derive", () => { it("re-emits on a change to an index bucket it read", async () => { const observer = vi.fn(); - const unsubscribe = db.observe.derive((d) => d.indexes.byA.find({ a: 1 }).length)(observer); + 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(); @@ -137,7 +137,7 @@ describe("db.observe.derive", () => { it("recomputes synchronously, at most once per committed transaction", () => { const observer = vi.fn(); - const unsubscribe = db.observe.derive((d) => d.read(foo))(observer); + 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. @@ -150,7 +150,7 @@ describe("db.observe.derive", () => { it("stops emitting after unsubscribe", async () => { const observer = vi.fn(); - const unsubscribe = db.observe.derive((d) => d.get(foo, "a"))(observer); + const unsubscribe = db.derive((d) => d.get(foo, "a"))(observer); unsubscribe(); observer.mockClear(); db.transactions.setA({ e: foo, a: 123 }); diff --git a/packages/data/src/ecs/database/observe-derive.ts b/packages/data/src/ecs/database/observe-derive.ts index 4c050e1e..bac1860c 100644 --- a/packages/data/src/ecs/database/observe-derive.ts +++ b/packages/data/src/ecs/database/observe-derive.ts @@ -79,7 +79,7 @@ const affected = (deps: DepSet, result: TransactionResult): boolean => { }; /** - * Builds `db.observe.derive`. A single read-recording wrapper is constructed + * 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 @@ -151,7 +151,7 @@ export const createDerive = ( // 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.observe.derive` boundary. + // `db.derive` boundary. const recorder = { get: (entity: Entity, component: StringKeyof) => { if (recording) { 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 4e6e9e97..3ab88061 100644 --- a/packages/data/src/ecs/database/observed/create-observed-database.ts +++ b/packages/data/src/ecs/database/observed/create-observed-database.ts @@ -144,7 +144,6 @@ export function createObservedDatabase< entity: observeEntity, archetype: observeArchetype, select: observeSelectEntities(transactionalStore, observeTransaction), - derive: createDerive(store, observeTransaction), }; const notifyAllObserversStoreReloaded = () => { @@ -177,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 7adc0035..1ef8ec8d 100644 --- a/packages/data/src/ecs/database/observed/observed-database.ts +++ b/packages/data/src/ecs/database/observed/observed-database.ts @@ -33,10 +33,10 @@ export interface ObservedDatabase< include: readonly Include[] | ReadonlySet, options?: any ): Observe; - // Internal (loose) type — the precise `Database.Read<…>` callback surface - // is enforced at the public `Database.observe.derive` boundary. - derive(compute: (db: any) => T): 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 8942517f..9405bc36 100644 --- a/packages/data/src/ecs/store/core/core.ts +++ b/packages/data/src/ecs/store/core/core.ts @@ -47,7 +47,7 @@ export interface ReadonlyCore< * 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.observe.derive` can scope its + * 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. */ From 191b8b7601edc06e1104f3a957bbf34144282557 Mon Sep 17 00:00:00 2001 From: Kris Nye Date: Tue, 14 Jul 2026 21:58:02 -0700 Subject: [PATCH 6/6] =?UTF-8?q?perf(data):=20make=20derive=20deps=20precis?= =?UTF-8?q?e=20=E2=80=94=20bucket-level=20indexes,=20membership-level=20se?= =?UTF-8?q?lect?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The derive read-recorder previously reduced index lookups and `select`s to a coarse global column set: any write to a backing column, anywhere, re-ran the whole compute body (suppressed only downstream by structural dedupe). For a per-key fan-out of expensive derives that is a real footgun — an unrelated row's change recomputes every body. Track set-valued reads precisely instead, reusing observeIndexEntities' technique: record the result sequence + a recompute thunk, cheaply gate, then recompute just that read and compare. An index lookup gates on `changedComponents ∩ readColumns` then compares its bucket sequence; a presence `select` gates on `changedArchetypes` (membership is archetype-shaped, so a value write can never move it). Only a genuinely different result re-runs the body — a false positive costs one O(bucket) recompute, not a body run. Also drop the value-dependent `where` / `order` options from the derive's `select` surface (`Database.Read`): they can only be tracked coarsely, so a value-keyed or ordered reactive read must go through a declared index. And invert the entity-dep scan to O(changedEntities) rather than O(watched). Co-Authored-By: Claude Opus 4.8 --- .../ecs/database/database-read.type-test.ts | 20 +++ packages/data/src/ecs/database/database.ts | 16 +- .../src/ecs/database/observe-derive.test.ts | 25 +++ .../data/src/ecs/database/observe-derive.ts | 170 +++++++++++------- 4 files changed, 165 insertions(+), 66 deletions(-) diff --git a/packages/data/src/ecs/database/database-read.type-test.ts b/packages/data/src/ecs/database/database-read.type-test.ts index eed1d44e..9f902e55 100644 --- a/packages/data/src/ecs/database/database-read.type-test.ts +++ b/packages/data/src/ecs/database/database-read.type-test.ts @@ -44,6 +44,20 @@ function derivePositive() { 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)); @@ -139,11 +153,13 @@ 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; } @@ -157,6 +173,10 @@ function readProjectionNegative() { 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 } }); } // ============================================================================ diff --git a/packages/data/src/ecs/database/database.ts b/packages/data/src/ecs/database/database.ts index 8c0aa4f4..1ec382d3 100644 --- a/packages/data/src/ecs/database/database.ts +++ b/packages/data/src/ecs/database/database.ts @@ -290,7 +290,21 @@ export namespace Database { // signature, which passes `Database.Read`) sees the combined // indexes + resources + archetypes, and consumers never need to cast. export type Read> = - Pick & { + 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; }; diff --git a/packages/data/src/ecs/database/observe-derive.test.ts b/packages/data/src/ecs/database/observe-derive.test.ts index fefe32d2..f8f32c76 100644 --- a/packages/data/src/ecs/database/observe-derive.test.ts +++ b/packages/data/src/ecs/database/observe-derive.test.ts @@ -135,6 +135,31 @@ describe("db.derive", () => { 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); diff --git a/packages/data/src/ecs/database/observe-derive.ts b/packages/data/src/ecs/database/observe-derive.ts index bac1860c..dc381f8a 100644 --- a/packages/data/src/ecs/database/observe-derive.ts +++ b/packages/data/src/ecs/database/observe-derive.ts @@ -8,29 +8,57 @@ import { ReadonlyStore } from "../store/index.js"; import { TransactionResult } from "./transactional-store/transactional-store.js"; /** - * The dependency set a single `compute` run touched. Reads reduce to two kinds - * of dependency: + * 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). - * - column deps — component names whose *value or membership* a `select`, an - * index lookup, or a resource read depends on. Re-run when the transaction's - * `changedComponents` intersects them. (`select` include/exclude/where/order - * keys, an index's `readColumns`, and a resource's own component name all - * land here — a set of entities can only gain/lose a member, reorder, or - * change a read value by touching one of these columns.) + * - `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) => { @@ -42,39 +70,52 @@ const watchComponent = (deps: DepSet, entity: Entity, component: string) => { }; const affected = (deps: DepSet, result: TransactionResult): boolean => { - const { changedEntities, changedComponents } = result; - for (const entity of deps.entityWhole) { - if (changedEntities.has(entity)) { - return true; - } - } - for (const [entity, components] of deps.entityComponents) { - if (!changedEntities.has(entity)) { - continue; - } - const values = changedEntities.get(entity); - if (values === undefined) { - // unreachable: `has(entity)` was true above — satisfies the map's - // `V | undefined` return type. - continue; - } - if (values === null) { - // entity deleted — any scoped read of it is now stale - return true; - } - for (const component of Object.keys(values)) { - if (components.has(component)) { + 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; + } + } } } - if (deps.columns.size > 0) { - for (const component of changedComponents) { - if (deps.columns.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; }; @@ -119,28 +160,34 @@ export const createDerive = ( const storeIndexes = (store.indexes ?? {}) as Record; for (const name of Object.keys(storeIndexes)) { const handle = storeIndexes[name]; - const readColumns: readonly string[] = handle.readColumns ?? []; - const trackIndex = () => { + 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) { - for (const column of readColumns) { - recording.columns.add(column); - } + recording.setReads.push({ kind: "index", readColumns, recompute, last: result }); } }; const readHandle: Record = { find: (arg: unknown) => { - trackIndex(); - return handle.find(arg); + const result = handle.find(arg); + recordLookup(result, () => handle.find(arg)); + return result; }, findRange: (arg: unknown) => { - trackIndex(); - return handle.findRange(arg); + const result = handle.findRange(arg); + recordLookup(result, () => handle.findRange(arg)); + return result; }, }; if (typeof handle.get === "function") { readHandle.get = (arg: unknown) => { - trackIndex(); - return handle.get(arg); + const result = handle.get(arg); + recordLookup(result, () => handle.get(arg)); + return result; }; } out[name] = readHandle; @@ -175,28 +222,21 @@ export const createDerive = ( } return (store.read as (e: Entity, a?: unknown) => unknown)(entity, archetypeOrComponents); }, - select: (include: readonly string[] | ReadonlySet, options?: { exclude?: readonly string[]; where?: object; order?: object }) => { + // 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) { - for (const component of include) { - recording.columns.add(component); - } - if (options?.exclude) { - for (const component of options.exclude) { - recording.columns.add(component); - } - } - if (options?.where) { - for (const component of Object.keys(options.where)) { - recording.columns.add(component); - } - } - if (options?.order) { - for (const component of Object.keys(options.order)) { - recording.columns.add(component); - } - } + recording.setReads.push({ kind: "select", recompute: run, last: result }); } - return (store.select as (i: unknown, o?: unknown) => readonly Entity[])(include, options); + return result; }, get resources() { return (resourcesRecorder ??= buildResources());