From 4a657139d4e60516a64448b197aeba60ea001275 Mon Sep 17 00:00:00 2001 From: Kris Nye Date: Tue, 15 Sep 2026 20:52:18 -0700 Subject: [PATCH 1/3] =?UTF-8?q?feat(data):=20insert-time=20default-factory?= =?UTF-8?q?=20components=20(archetype=E2=86=92store)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A component schema may name a zero-arg `defaultFactory` (pure-JSON name, resolved by a store-level registry). Such a component is OPTIONAL at insert: omit it and the archetype mints a fresh value; supply it (replication inbound / load) and the supplied value wins. Threads a default-factory-keys type param (DFK) through Core and Store so the omission is type-checked. Naming a factory the registry lacks throws at archetype construction. Co-Authored-By: Claude Opus 4.8 --- packages/data/src/ecs/archetype/archetype.ts | 51 +++++++++++++++--- .../ecs/archetype/create-archetype.test.ts | 53 ++++++++++++++++++- .../src/ecs/archetype/create-archetype.ts | 41 ++++++++++++-- packages/data/src/ecs/default-factory-keys.ts | 14 +++++ packages/data/src/ecs/store/core/core.ts | 16 +++--- .../data/src/ecs/store/core/create-core.ts | 28 +++++++++- packages/data/src/ecs/store/partition.ts | 4 +- .../data/src/ecs/store/public/create-store.ts | 13 ++++- packages/data/src/ecs/store/store.ts | 17 +++--- packages/data/src/schema/schema.ts | 12 +++++ 10 files changed, 220 insertions(+), 29 deletions(-) create mode 100644 packages/data/src/ecs/default-factory-keys.ts diff --git a/packages/data/src/ecs/archetype/archetype.ts b/packages/data/src/ecs/archetype/archetype.ts index 9c74347c..9ee88205 100644 --- a/packages/data/src/ecs/archetype/archetype.ts +++ b/packages/data/src/ecs/archetype/archetype.ts @@ -4,9 +4,24 @@ import { Entity } from "../entity/entity.js"; import { Table, ReadonlyTable } from "../../table/index.js"; import { Assert } from "../../types/assert.js"; import { Equal } from "../../types/equal.js"; -import { Exact, StringKeyof } from "../../types/types.js"; +import { Exact, Simplify, StringKeyof } from "../../types/types.js"; -export type EntityInsertValues = Omit; +// `DK` (default-factory keys) names the components whose schema declares a +// `defaultFactory` (see Schema.defaultFactory). Those are OPTIONAL at insert: +// omit one and the archetype mints it via the factory; supply it (replication +// inbound / load) and the supplied value wins. Every other component stays +// required. `DK` defaults to `never`, so an archetype with no default factories +// keeps the original "all non-id components required" shape. +export type EntityInsertValues = + // No default keys → exactly `Omit` (byte-identical to the + // original shape, so every existing archetype/store/transaction type is + // unchanged). Only when default keys exist do we split them out as optional. + [DK] extends [never] + ? Omit + : Simplify< + & Omit + & Partial, IdComponent>>> + >; export type ArchetypeId = number; /** @@ -47,9 +62,9 @@ export interface ReadonlyArchetype extends BaseArchetype, ReadonlyTable< toData: (copy?: boolean, omit?: ReadonlySet) => unknown } -export interface Archetype extends BaseArchetype, Table { +export interface Archetype extends BaseArchetype, Table { readonly components: ComponentSet>; - insert: >(rowData: Exact, T>) => Entity; + insert: >(rowData: Exact, T>) => Entity; /** See {@link ReadonlyArchetype.toData}. */ toData: (copy?: boolean, omit?: ReadonlySet) => unknown /** @@ -80,9 +95,9 @@ export namespace Archetype { * component) therefore still permits `.insert` with no narrowing — only dense * column access requires having resolved to a concrete {@link Archetype}. */ - export interface Router { + export interface Router { readonly components: ComponentSet>; - insert: >(rowData: Exact, T>) => Entity; + insert: >(rowData: Exact, T>) => Entity; } } @@ -91,7 +106,7 @@ export namespace Archetype { // part of the component row. export type FromArchetype = T extends ReadonlyArchetype ? { readonly [K in keyof Omit]: C[K] } : - T extends Archetype ? { readonly [K in keyof Omit]: C[K] } : + T extends Archetype ? { readonly [K in keyof Omit]: C[K] } : never; // compile time type tests. @@ -120,4 +135,26 @@ type TestIdColumnStillTyped = Assert; + + const testOmitDefaulted = (arch: GuidArchetype) => { + // guid omitted → the archetype mints it. Must compile. + arch.insert({ position: [0, 0, 0] }); + // guid supplied (replication-inbound / load path) → wins. Must compile. + arch.insert({ position: [0, 0, 0], guid: [1, 2] }); + }; + + const testStillRequired = (arch: GuidArchetype) => { + // @ts-expect-error - position is not a default-factory key, still required. + arch.insert({ guid: [1, 2] }); + }; + + // A defaulted key is only optional, never removed: EntityInsertValues keeps it. + type Values = EntityInsertValues<{ guid: [number, number], position: [number, number, number] }, "guid">; + type TestGuidOptional = Assert>; } \ No newline at end of file diff --git a/packages/data/src/ecs/archetype/create-archetype.test.ts b/packages/data/src/ecs/archetype/create-archetype.test.ts index 8d0f2eb2..2b519b4d 100644 --- a/packages/data/src/ecs/archetype/create-archetype.test.ts +++ b/packages/data/src/ecs/archetype/create-archetype.test.ts @@ -1,6 +1,6 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { describe, it, expect } from 'vitest'; -import { createArchetype } from '../archetype/index.js'; +import { createArchetype, type Archetype } from '../archetype/index.js'; import { createEntityLocationTable } from '../entity-location-table/index.js'; import { Entity } from '../entity/entity.js'; import { U32 } from '../../math/u32/index.js'; @@ -132,6 +132,57 @@ describe('createArchetype', () => { expect(newArchetype.columns.id.get(1)).toBe(4); }); + it('mints a default-factory component when the insert row omits it, fresh per insert', () => { + const entityLocationTable = createEntityLocationTable(); + const components = { id: Entity.schema, value: U32.schema, seq: U32.schema }; + let next = 100; + // Runtime invariant the compiler can't see: `seq` has a default factory, so + // it is optional at insert. createArchetype's return type reports DK=never + // (the core/store boundary is where DK is derived), so we assert it here. + const archetype = createArchetype(components, 5, entityLocationTable, undefined, { + seq: () => next++, + }) as unknown as Archetype<{ value: number; seq: number }, "seq">; + + // seq omitted → minted by the factory, a fresh value each insert. + archetype.insert({ value: 1 }); + archetype.insert({ value: 2 }); + expect(archetype.columns.seq.get(0)).toBe(100); + expect(archetype.columns.seq.get(1)).toBe(101); + expect(next).toBe(102); + }); + + it('uses the supplied value over the factory (replication-inbound / load path)', () => { + const entityLocationTable = createEntityLocationTable(); + const components = { id: Entity.schema, seq: U32.schema }; + let calls = 0; + // Runtime invariant the compiler can't see: `seq` is default-factory ⇒ DK. + const archetype = createArchetype(components, 6, entityLocationTable, undefined, { + seq: () => { calls++; return 999; }, + }) as unknown as Archetype<{ seq: number }, "seq">; + + archetype.insert({ seq: 7 }); + expect(archetype.columns.seq.get(0)).toBe(7); + expect(calls).toBe(0); // factory never ran for a supplied value + }); + + it('applies default factories on the generic (non-identifier name) insert path', () => { + const entityLocationTable = createEntityLocationTable(); + // A non-identifier component name forces buildGenericInsert (codegen is skipped). + const components = { id: Entity.schema, ['weird-name']: U32.schema }; + let next = 10; + // `any` archetype handle: this test exercises the runtime generic-insert + // path, not the insert types (a non-identifier key isn't a valid TS field). + const archetype: any = createArchetype(components as any, 7, entityLocationTable, undefined, { + ['weird-name']: () => next++, + }); + + archetype.insert({}); // omitted → minted + archetype.insert({ ['weird-name']: 42 }); // supplied → wins + expect((archetype.columns as any)['weird-name'].get(0)).toBe(10); + expect((archetype.columns as any)['weird-name'].get(1)).toBe(42); + expect(next).toBe(11); + }); + it('should preserve component set during serialization/deserialization', () => { const entityLocationTable = createEntityLocationTable(); const components = { diff --git a/packages/data/src/ecs/archetype/create-archetype.ts b/packages/data/src/ecs/archetype/create-archetype.ts index 00b30736..ca27489a 100644 --- a/packages/data/src/ecs/archetype/create-archetype.ts +++ b/packages/data/src/ecs/archetype/create-archetype.ts @@ -70,6 +70,7 @@ const buildSpecializedInsert = ( archetypeId: number, columns: Record>, entityLocationTable: EntityLocationTable, + defaultFactories: Record unknown>, ): InsertImpl | null => { const componentNames = Object.keys(columns); if (componentNames.some((n) => !SAFE_IDENT.test(n))) { @@ -90,14 +91,26 @@ const buildSpecializedInsert = ( // with reserved words or shadowing globals (e.g. a component called // `delete`). const componentParamNames: string[] = []; - const componentParamValues: TypedBuffer[] = []; + const componentParamValues: (TypedBuffer | (() => unknown))[] = []; const sets: string[] = []; for (const name of componentNames) { if (name === ID) continue; const local = `_${name}`; componentParamNames.push(local); componentParamValues.push(columns[name]); - sets.push(` ${local}.set(row, rowData.${name});`); + const factory = defaultFactories[name]; + if (factory === undefined) { + sets.push(` ${local}.set(row, rowData.${name});`); + } else { + // Default-factory component: the insert row MAY omit it. When it does, + // mint a fresh value via the baked factory const; a supplied value + // (replication inbound / load) always wins, so the factory never runs + // for it. One extra `!== undefined` branch, only on defaulted columns. + const factoryLocal = `_factory_${name}`; + componentParamNames.push(factoryLocal); + componentParamValues.push(factory); + sets.push(` ${local}.set(row, rowData.${name} !== undefined ? rowData.${name} : ${factoryLocal}());`); + } } const factoryBody = ` @@ -137,7 +150,9 @@ ${sets.join("\n")} ensureCapacityFn: typeof ensureCapacity, entityLocationTable: EntityLocationTable, idColumn: TypedBuffer, - ...componentColumns: TypedBuffer[] + // Trailing args are the baked per-component column refs, each optionally + // followed by its `_factory_` const — hence the widened element type. + ...componentColumnsAndFactories: (TypedBuffer | (() => unknown))[] ) => InsertImpl; return factory( @@ -156,9 +171,17 @@ ${sets.join("\n")} const buildGenericInsert = ( archetypeId: number, entityLocationTable: EntityLocationTable, + defaultFactories: Record unknown>, ): InsertImpl => { + const factoryEntries = Object.entries(defaultFactories); return (archetype: any, rowData: any) => { const row = TABLE.addRow(archetype, rowData); + // Mint any default-factory component the row omitted. addRow only sets the + // columns present in rowData, so an omitted defaulted column is filled here + // (a supplied value went through addRow already and is left untouched). + for (const [name, factory] of factoryEntries) { + if (rowData[name] === undefined) archetype.columns[name].set(row, factory()); + } const entity = entityLocationTable.create({ archetype: archetypeId, row }); archetype.columns[ID].set(row, entity); return entity; @@ -170,6 +193,14 @@ export const createArchetype = value` map (see Schema.defaultFactory). The + // caller (core) resolves each component's `defaultFactory` NAME against the + // store registry and passes only the entries whose component this archetype + // actually carries. Omitted → no defaulting (the original insert shape). The + // returned Archetype's insert type still reports every component as required + // (DK = never here); the OPTIONAL-at-insert typing is applied by the core / + // store boundary that knows which names are defaulted (see ensureArchetype). + defaultFactories: Record unknown> = {}, ): Archetype }, IdComponent>> => { // The archetype's public COMPONENT set excludes `id`: id is the entity's // identity, a column but never a component value. (`table.columns` and the @@ -184,8 +215,8 @@ export const createArchetype = { insertImpl = - buildSpecializedInsert(id, archetype.columns as Record>, entityLocationTable) ?? - buildGenericInsert(id, entityLocationTable); + buildSpecializedInsert(id, archetype.columns as Record>, entityLocationTable, defaultFactories) ?? + buildGenericInsert(id, entityLocationTable, defaultFactories); }; const createEntity = (rowData: EntityInsertValues): Entity => { diff --git a/packages/data/src/ecs/default-factory-keys.ts b/packages/data/src/ecs/default-factory-keys.ts new file mode 100644 index 00000000..ff5729cc --- /dev/null +++ b/packages/data/src/ecs/default-factory-keys.ts @@ -0,0 +1,14 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { StringKeyof } from "../types/types.js"; + +/** + * The component names whose schema declares a `defaultFactory` (see + * Schema.defaultFactory). Derived from the raw component SCHEMAS (which still + * carry the `defaultFactory` name — it is erased by `Schema.ToType`), so it can + * be threaded alongside the value-typed component set to mark those components + * OPTIONAL at insert (the archetype mints them when the row omits them). A union + * of the matching keys, or `never` when no component names a factory. + */ +export type DefaultFactoryKeys = { + [K in StringKeyof]: CS[K] extends { defaultFactory: string } ? K : never +}[StringKeyof]; diff --git a/packages/data/src/ecs/store/core/core.ts b/packages/data/src/ecs/store/core/core.ts index 61bef23d..9dfc0045 100644 --- a/packages/data/src/ecs/store/core/core.ts +++ b/packages/data/src/ecs/store/core/core.ts @@ -31,6 +31,9 @@ export type ArchetypeQueryOptions = export interface ReadonlyCore< C extends Components = never, PK extends string = never, + // Default-factory component names (see DefaultFactoryKeys / Schema.defaultFactory). + // A returned archetype whose set includes one of these types it OPTIONAL at insert. + DFK extends string = never, > { readonly componentSchemas: { readonly [K in StringKeyof]: Schema }; @@ -45,7 +48,7 @@ export interface ReadonlyCore< ensureArchetype>( components: readonly CC[] | ReadonlySet, ): HasPartitionKey extends true - ? Archetype.Router<{ [K in CC]: (C & OptionalComponents)[K] }> + ? Archetype.Router<{ [K in CC]: (C & OptionalComponents)[K] }, Extract> : ReadonlyArchetype<{ [K in CC]: (C & OptionalComponents)[K] }>; // Partition value(s) supplied → the concrete value-child, always. ensureArchetype>( @@ -97,22 +100,23 @@ export interface ReadonlyCore< export interface Core< C extends Components = never, PK extends string = never, -> extends ReadonlyCore { + DFK extends string = never, +> extends ReadonlyCore { queryArchetypes< Include extends StringKeyof, >( include: readonly Include[] | ReadonlySet, options?: ArchetypeQueryOptions - ): readonly Archetype>[]; + ): readonly Archetype, Extract>[]; ensureArchetype>( components: readonly CC[] | ReadonlySet, ): HasPartitionKey extends true - ? Archetype.Router<{ [K in CC]: (C & OptionalComponents)[K] }> - : Archetype<{ [K in CC]: (C & OptionalComponents)[K] }>; + ? Archetype.Router<{ [K in CC]: (C & OptionalComponents)[K] }, Extract> + : Archetype<{ [K in CC]: (C & OptionalComponents)[K] }, Extract>; ensureArchetype>( components: readonly CC[] | ReadonlySet, partitionValues: { readonly [K in Extract]: (C & OptionalComponents)[K] }, - ): Archetype<{ [K in CC]: (C & OptionalComponents)[K] }>; + ): Archetype<{ [K in CC]: (C & OptionalComponents)[K] }, Extract>; locate: (entity: Entity) => { archetype: Archetype, row: number } | null; /** * Deletes the entity. Returns the entity that was swap-moved into the diff --git a/packages/data/src/ecs/store/core/create-core.ts b/packages/data/src/ecs/store/core/create-core.ts index 772e4489..1db1ee63 100644 --- a/packages/data/src/ecs/store/core/create-core.ts +++ b/packages/data/src/ecs/store/core/create-core.ts @@ -20,6 +20,7 @@ import { ComponentSchemas } from "../../component-schemas.js"; import { OptionalComponents } from "../../optional-components.js"; import { True } from "../../../schema/true/index.js"; import { PartitionKeysOf } from "../partition.js"; +import { DefaultFactoryKeys } from "../../default-factory-keys.js"; import { MemoryAllocator } from "../../../cache/memory-allocator.js"; /** @@ -80,7 +81,15 @@ export function createCore( * place numeric component storage in a shareable arena. */ allocator?: MemoryAllocator, -): Core]: Schema.ToType }>, PartitionKeysOf> { + /** + * Registry resolving a component schema's `defaultFactory` NAME to a + * `() => value` (see Schema.defaultFactory / CreateStoreOptions). A component + * whose schema names a factory is minted at insert when its row omits it. A + * named factory absent from this registry is a construction-time error, thrown + * when the first archetype carrying that component is resolved. + */ + defaultFactories: Record unknown> = {}, +): Core]: Schema.ToType }>, PartitionKeysOf, DefaultFactoryKeys> { type C = RequiredComponents & { [K in StringKeyof]: Schema.ToType }; // Reserved names (`id`, `nonPersistent`, `nonShared`) are the ECS's own @@ -205,6 +214,10 @@ export function createCore( const archetypeComponentSchemas: Record = { [ID]: componentSchemas[ID] }; let isNonPersistent = false; let isNonShared = false; + // Resolved `component name → () => value` for the components of THIS + // archetype whose schema names a default factory. Empty for the common + // archetype with none. + const archetypeDefaultFactories: Record unknown> = {}; for (const comp of namesArr) { if (comp === ID) continue; if (comp === "nonPersistent") isNonPersistent = true; @@ -213,12 +226,25 @@ export function createCore( archetypeComponentSchemas[comp] = isPartition(comp) ? { ...base, const: partitionValues![comp] } : base; + const factoryName = (base as Schema | undefined)?.defaultFactory; + if (factoryName !== undefined) { + const factory = defaultFactories[factoryName]; + if (factory === undefined) { + // Fail fast at construction (not per insert): a component names a + // factory the store was never given, so it could never mint. + throw new Error( + `Component "${comp}" declares defaultFactory "${factoryName}", but no such factory was provided to the store's defaultFactories registry.`, + ); + } + archetypeDefaultFactories[comp] = factory; + } } const archetype = ARCHETYPE.createArchetype( archetypeComponentSchemas as any, id, locationTables[quadrantFor(isNonPersistent, isNonShared)]!, allocator, + archetypeDefaultFactories, ); archetypes.push(archetype as unknown as Archetype); archetypeByIdentity.set(key, archetype); diff --git a/packages/data/src/ecs/store/partition.ts b/packages/data/src/ecs/store/partition.ts index ab3f5482..e484700e 100644 --- a/packages/data/src/ecs/store/partition.ts +++ b/packages/data/src/ecs/store/partition.ts @@ -50,8 +50,8 @@ export type HasPartitionKey = * caller). `Has` is a *naked* type parameter so a `boolean` (from `PK = any`) * distributes to `Archetype.Router | Concrete`. */ -export type ArchetypeOrRouter = - Has extends true ? Archetype.Router : Concrete; +export type ArchetypeOrRouter = + Has extends true ? Archetype.Router : Concrete; /** * Return type of `ensureArchetype(keys, values?)`: diff --git a/packages/data/src/ecs/store/public/create-store.ts b/packages/data/src/ecs/store/public/create-store.ts index 7c6811fe..2e638f44 100644 --- a/packages/data/src/ecs/store/public/create-store.ts +++ b/packages/data/src/ecs/store/public/create-store.ts @@ -22,6 +22,7 @@ import { RuntimeIndex, } from "../../database/index-registry/index.js"; import { PartitionKeysOf } from "../partition.js"; +import { DefaultFactoryKeys } from "../../default-factory-keys.js"; import { IndexDeclarations } from "../index-types.js"; import { MemoryAllocator } from "../../../cache/memory-allocator.js"; @@ -33,6 +34,15 @@ export interface CreateStoreOptions { * to place numeric component storage in a single shareable arena. */ allocator?: MemoryAllocator; + /** + * Resolves a component schema's `defaultFactory` NAME to a `() => value` that + * mints the component at insert when the row omits it (see + * {@link Schema.defaultFactory}). Keeps the factory — a function — OUT of the + * pure-JSON schema: the schema carries only the name, this registry carries + * the behavior. A component naming a factory absent here throws when its first + * archetype is resolved (fail fast, not per insert). + */ + defaultFactories?: Record unknown>; } export function createStore< @@ -43,7 +53,7 @@ export function createStore< >( schema?: Store.Schema, options?: CreateStoreOptions, -): Store, FromSchemas, A, IX, PartitionKeysOf> { +): Store, FromSchemas, A, IX, PartitionKeysOf, DefaultFactoryKeys> { const schemaArg = schema as any; const hasSchemaShape = schemaArg && @@ -73,6 +83,7 @@ export function createStore< componentAndResourceSchemas, (archetype) => decorateArchetypeForIndexes(archetype), options?.allocator, + options?.defaultFactories, ) as unknown as Core; // Index registry. Owned at the Store layer because index state is diff --git a/packages/data/src/ecs/store/store.ts b/packages/data/src/ecs/store/store.ts index cfc6339d..09897d06 100644 --- a/packages/data/src/ecs/store/store.ts +++ b/packages/data/src/ecs/store/store.ts @@ -8,6 +8,7 @@ import { Components } from "./components.js"; import { ArchetypeComponents } from "./archetype-components.js"; import { Archetype, ReadonlyArchetype } from "../archetype/archetype.js"; import { HasPartitionKey, PartitionKeysOf, ArchetypeOrRouter } from "./partition.js"; +import { DefaultFactoryKeys } from "../default-factory-keys.js"; import { EntitySelectOptions } from "./entity-select-options.js"; import { Undoable } from "../database/undoable.js"; import { Assert } from "../../types/assert.js"; @@ -59,12 +60,14 @@ export interface ReadonlyStore< A extends ArchetypeComponents> = never, IX extends IndexDeclarations = {}, PK extends string = never, -> extends BaseStore, ReadonlyCore { + DFK extends string = never, +> extends BaseStore, ReadonlyCore { readonly resources: { readonly [K in StringKeyof]: R[K] }; readonly archetypes: { readonly [K in StringKeyof]: ArchetypeOrRouter< HasPartitionKey, { [Col in A[K][number]]: (C & OptionalComponents)[Col] }, - ReadonlyArchetype<{ [Col in A[K][number]]: (C & OptionalComponents)[Col] }> + ReadonlyArchetype<{ [Col in A[K][number]]: (C & OptionalComponents)[Col] }>, + Extract > } readonly indexes: { readonly [K in keyof IX]: Index.Handle }; } @@ -80,7 +83,8 @@ export interface Store< A extends ArchetypeComponents> = {}, IX extends IndexDeclarations = {}, PK extends string = never, -> extends BaseStore, Core { + DFK extends string = never, +> extends BaseStore, Core { /** * This is used when a store is used to represent a transaction. * For most stores, this is ignored if it is set. @@ -97,7 +101,8 @@ export interface Store< readonly archetypes: { -readonly [K in StringKeyof]: ArchetypeOrRouter< HasPartitionKey, { [Col in A[K][number]]: (C & OptionalComponents)[Col] }, - Archetype<{ [Col in A[K][number]]: (C & OptionalComponents)[Col] }> + Archetype<{ [Col in A[K][number]]: (C & OptionalComponents)[Col] }, Extract>, + Extract > } /** * Index handles keyed by user-chosen name. Returned handles are the @@ -120,7 +125,7 @@ export interface Store< */ pruneToSchema(keep: ReadonlySet): void; fromData(data: unknown, scope?: PersistenceScope): void - extend(schema: S): S extends Store.Schema ? Store, R & FromSchemas, A & XA, IX & XIX, PK | PartitionKeysOf> : never; + extend(schema: S): S extends Store.Schema ? Store, R & FromSchemas, A & XA, IX & XIX, PK | PartitionKeysOf, DFK | DefaultFactoryKeys> : never; } // eslint-disable-next-line @typescript-eslint/no-namespace @@ -145,7 +150,7 @@ export namespace Store { readonly indexes?: IX; }; - export type FromSchema = T extends Store.Schema ? Store, FromSchemas, A, IX, PartitionKeysOf> : never; + export type FromSchema = T extends Store.Schema ? Store, FromSchemas, A, IX, PartitionKeysOf, DefaultFactoryKeys> : never; export namespace Schema { diff --git a/packages/data/src/schema/schema.ts b/packages/data/src/schema/schema.ts index ba7b89ab..f783c297 100644 --- a/packages/data/src/schema/schema.ts +++ b/packages/data/src/schema/schema.ts @@ -53,6 +53,18 @@ export interface Schema { entity?: boolean; mutable?: boolean; // defaults to false default?: any; + // Name of a zero-arg factory that mints this component's value at insert time + // when the insert row omits it — resolved to a `() => value` by the store's + // `defaultFactories` registry (see CreateStoreOptions), exactly as + // `interpolators` names are resolved by the animation system. A serializable + // NAME, never a function — a Schema is pure JSON. Unlike `default` (one shared + // literal, backfilled on load), the factory runs PER insert, so each entity + // gets a fresh value — the intended shape for a per-entity identity such as a + // cross-runtime GUID. A component naming a factory is OPTIONAL at insert: omit + // it and the factory mints one; SUPPLY it (replication-inbound / load) and the + // supplied value wins, so the factory never runs. Naming a factory the registry + // does not provide is a construction-time error (fail fast, not per insert). + defaultFactory?: string; precision?: 1 | 2; multipleOf?: number; mediaType?: string; // media type such as image/jpeg, image/png, video/* etc. From 11ef914ea6348724848d1bfed8747e3e49852c9a Mon Sep 17 00:00:00 2001 From: Kris Nye Date: Tue, 15 Sep 2026 20:59:52 -0700 Subject: [PATCH 2/3] feat(data): thread default-factory keys through the plugin chain Transaction bodies and system stores now type default-factory components as optional at insert (DFK flows via TransactionDeclarations + Plugin.ToStore), so consumer insert sites can omit them and the archetype mints them. createDatabase accepts a defaultFactories registry. The read-only Database interface and FromPlugin extends-chain are deliberately untouched to avoid amplifying the plugin-composition instantiation cost. E2E + type tests added. Co-Authored-By: Claude Opus 4.8 --- .../data/src/ecs/database/create-plugin.ts | 8 +- packages/data/src/ecs/database/database.ts | 3 +- .../src/ecs/database/default-factory.test.ts | 86 +++++++++++++++++++ .../ecs/database/public/create-database.ts | 15 +++- .../src/ecs/store/transaction-functions.ts | 8 +- 5 files changed, 112 insertions(+), 8 deletions(-) create mode 100644 packages/data/src/ecs/database/default-factory.test.ts diff --git a/packages/data/src/ecs/database/create-plugin.ts b/packages/data/src/ecs/database/create-plugin.ts index 9764e401..6e078e19 100644 --- a/packages/data/src/ecs/database/create-plugin.ts +++ b/packages/data/src/ecs/database/create-plugin.ts @@ -8,6 +8,7 @@ import type { TransactionDeclarations, ToTransactionFunctions } from "../store/t import type { ToActionFunctions } from "../store/action-functions.js"; import type { FromSchemas } from "../../schema/index.js"; import type { PartitionKeysOf } from "../store/partition.js"; +import type { DefaultFactoryKeys } from "../default-factory-keys.js"; import type { StringKeyof, NoInfer, RemoveIndex } from "../../types/types.js"; import { combinePlugins } from "./combine-plugins.js"; import { Store } from "../store/store.js"; @@ -190,7 +191,7 @@ export function createPlugin< const RS extends ResourceSchemas, const A extends ArchetypeComponents & XP['components'] & IP['components']>>, const IX extends IndexDeclarations & XP['components'] & IP['components']>, RemoveIndex & XP['archetypes'] & IP['archetypes']>, - const TD extends TransactionDeclarations & XP['components'] & IP['components']>, FromSchemas & XP['resources'] & IP['resources']>, RemoveIndex & XP['archetypes'] & IP['archetypes'], RemoveIndex & XP['indexes'] & IP['indexes'], PartitionKeysOf & XP['components'] & IP['components']>>, + const TD extends TransactionDeclarations & XP['components'] & IP['components']>, FromSchemas & XP['resources'] & IP['resources']>, RemoveIndex & XP['archetypes'] & IP['archetypes'], RemoveIndex & XP['indexes'] & IP['indexes'], PartitionKeysOf & XP['components'] & IP['components']>, DefaultFactoryKeys & XP['components'] & IP['components']>>, const AD, const S extends string = never, const SVF extends ServiceFactories>> = {}, @@ -244,7 +245,10 @@ export function createPlugin< readonly store: Store< FromSchemas & XP['components'] & IP['components']>, FromSchemas & XP['resources'] & IP['resources']>, - RemoveIndex & XP['archetypes'] & IP['archetypes'] + RemoveIndex & XP['archetypes'] & IP['archetypes'], + {}, + never, + DefaultFactoryKeys & XP['components'] & IP['components']> > services: { -readonly [K in keyof FromServiceFactories & XP['services'] & IP['services']>]: FromServiceFactories & XP['services'] & IP['services']>[K] } }) => SystemFunction | void; diff --git a/packages/data/src/ecs/database/database.ts b/packages/data/src/ecs/database/database.ts index 5c86722f..0e1668d5 100644 --- a/packages/data/src/ecs/database/database.ts +++ b/packages/data/src/ecs/database/database.ts @@ -24,6 +24,7 @@ import { toSystemDatabase as _toSystemDatabase } from "./to-system-database.js"; import { ResourceSchemas } from "../resource-schemas.js"; import { ComponentSchemas } from "../component-schemas.js"; import { PartitionKeysOf } from "../store/partition.js"; +import type { DefaultFactoryKeys } from "../default-factory-keys.js"; import { FromSchemas } from "../../schema/index.js"; import type { TransactionDeclarations, @@ -507,7 +508,7 @@ export namespace Database { * type transaction functions operate on; a store *is* the transaction * context, so there is no separate transaction-context type. */ - export type ToStore

= Store>, FromSchemas>, RemoveIndex, RemoveIndex, PartitionKeysOf>>; + export type ToStore

= Store>, FromSchemas>, RemoveIndex, RemoveIndex, PartitionKeysOf>, DefaultFactoryKeys>>; export type ToSystemDatabase

= Database.FromPlugin

& { // Systems are allowed to access the database store directly. // This direct access will NOT trigger observable transactions. diff --git a/packages/data/src/ecs/database/default-factory.test.ts b/packages/data/src/ecs/database/default-factory.test.ts new file mode 100644 index 00000000..d4019ce6 --- /dev/null +++ b/packages/data/src/ecs/database/default-factory.test.ts @@ -0,0 +1,86 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +// +// End-to-end tests for insert-time default-factory components, exercised through +// the full plugin chain (Database.create → transaction body store → archetype +// insert). A component whose schema names a `defaultFactory` is optional at +// insert: omit it and the archetype mints a fresh value via the registry-resolved +// factory; supply it (the replication-inbound / load path) and the supplied value +// wins. See Schema.defaultFactory and CreateStoreOptions.defaultFactories. + +import { describe, it, expect } from "vitest"; +import { Database } from "./database.js"; +import type { Schema } from "../../schema/index.js"; + +// `guid` stands in for a cross-runtime identity: a value the archetype must have +// at creation but that callers usually don't supply. `defaultFactory: "guid"` +// names the minting factory; the registry (below) provides it. +const guid = { type: "number", default: 0, defaultFactory: "guid" } as const satisfies Schema; +const value = { type: "number", default: 0 } as const satisfies Schema; + +const nodePlugin = Database.Plugin.create({ + components: { guid, value }, + archetypes: { Node: ["guid", "value"] } as const, + transactions: { + // `guid` omitted from the row → the archetype mints it. This is the ~30 + // insert sites' shape: no guid threaded, still compiles. + addNode(t, args: { value: number }) { + return t.archetypes.Node.insert(args); + }, + // `guid` supplied → adopted verbatim (replication-inbound / load). + adoptNode(t, args: { value: number; guid: number }) { + return t.archetypes.Node.insert(args); + }, + }, +}); + +const withRegistry = (start = 1000) => { + let next = start; + return { + db: Database.create(nodePlugin, { defaultFactories: { guid: () => next++ } }), + minted: () => next, + }; +}; + +describe("insert-time default-factory (full plugin chain)", () => { + it("mints an omitted default-factory component at creation, fresh per insert", () => { + const { db, minted } = withRegistry(1000); + + const a = db.transactions.addNode({ value: 1 }); + const b = db.transactions.addNode({ value: 2 }); + + expect(db.read(a)?.guid).toBe(1000); + expect(db.read(b)?.guid).toBe(1001); + expect(minted()).toBe(1002); // one mint per insert, no more + }); + + it("adopts a supplied value instead of minting (replication-inbound / load)", () => { + const { db, minted } = withRegistry(1000); + + const e = db.transactions.adoptNode({ value: 7, guid: 55 }); + + expect(db.read(e)?.guid).toBe(55); + expect(minted()).toBe(1000); // factory never ran for the supplied value + }); + + it("throws at archetype construction when a named factory was never registered", () => { + // No defaultFactories registry → `guid` names a factory the store lacks. + expect(() => { + const db = Database.create(nodePlugin); + db.transactions.addNode({ value: 1 }); + }).toThrow(/defaultFactory "guid"/); + }); +}); + +// Compile-time checks — the payoff for consumers: inside a transaction / system +// body (the mutable store `t`), default-factory components are optional at insert +// while every other component stays required. This is the shape the ~30 insert +// sites rely on. +{ + type NodeStore = Database.Plugin.ToStore; + const _typeChecks = (t: NodeStore) => { + t.archetypes.Node.insert({ value: 1 }); // guid omitted → OK (minted) + t.archetypes.Node.insert({ value: 1, guid: 2 }); // guid supplied → OK (adopted) + // @ts-expect-error - `value` is not a default-factory key, still required. + t.archetypes.Node.insert({ guid: 2 }); + }; +} diff --git a/packages/data/src/ecs/database/public/create-database.ts b/packages/data/src/ecs/database/public/create-database.ts index b3891b3b..cbb781e4 100644 --- a/packages/data/src/ecs/database/public/create-database.ts +++ b/packages/data/src/ecs/database/public/create-database.ts @@ -67,6 +67,14 @@ interface CreateDatabaseOptions

value` + * (see {@link Schema.defaultFactory} / {@link CreateStoreOptions}). A component + * naming a factory is minted at insert when its row omits it — the factory + * (a function) lives here, keeping the schema pure JSON. A named factory + * absent from this map throws when its first archetype is resolved. + */ + defaultFactories?: Record unknown>; } export function createDatabase(): Database<{}, {}, {}, {}, never, {}, {}, {}> @@ -80,7 +88,7 @@ export function createDatabase( plugin?: Database.Plugin, options?: CreateDatabaseOptions, ): any { - const db = createEmptyDatabase({ concurrency: options?.concurrency, versioning: options?.versioning, allocator: options?.allocator }); + const db = createEmptyDatabase({ concurrency: options?.concurrency, versioning: options?.versioning, allocator: options?.allocator, defaultFactories: options?.defaultFactories }); if (plugin === undefined) { return db; } @@ -133,16 +141,17 @@ function scopedSchemas(schemas: StoreSchemas, scope: PersistenceScope | undefine * Creates a database with empty store, no transactions, actions, services, computed, or systems. * All content is added via .extend(plugin). Single code path for extension. */ -function createEmptyDatabase({ concurrency, versioning, allocator }: { +function createEmptyDatabase({ concurrency, versioning, allocator, defaultFactories }: { concurrency: ConcurrencyStrategyFactory | undefined, versioning?: DatabaseVersioning, allocator?: MemoryAllocator, + defaultFactories?: Record unknown>, }): any { const store = Store.create({ components: {}, resources: {}, archetypes: {}, - }, { allocator }); + }, { allocator, defaultFactories }); const observedDatabase = createObservedDatabase(store); diff --git a/packages/data/src/ecs/store/transaction-functions.ts b/packages/data/src/ecs/store/transaction-functions.ts index 6cebfafe..66f5e03b 100644 --- a/packages/data/src/ecs/store/transaction-functions.ts +++ b/packages/data/src/ecs/store/transaction-functions.ts @@ -16,7 +16,10 @@ export type TransactionDeclaration< A extends ArchetypeComponents>, IX extends IndexDeclarations = {}, PK extends string = never, - Input extends any | void = any> = (t: Store, input: Input) => void | Entity; + // Default-factory component names (see DefaultFactoryKeys): optional at insert + // inside the transaction body's store `t`. Defaults to `never`. + DFK extends string = never, + Input extends any | void = any> = (t: Store, input: Input) => void | Entity; export type TransactionDeclarations< C extends Components, @@ -24,7 +27,8 @@ export type TransactionDeclarations< A extends ArchetypeComponents>, IX extends IndexDeclarations = {}, PK extends string = never, -> = { readonly [Q: string]: TransactionDeclaration }; + DFK extends string = never, +> = { readonly [Q: string]: TransactionDeclaration }; /** * Converts from TransactionDeclarations to TransactionFunctions by removing From a9d0759e94a98e79bf51175925357088d4c47a86 Mon Sep 17 00:00:00 2001 From: Kris Nye Date: Tue, 15 Sep 2026 21:39:33 -0700 Subject: [PATCH 3/3] chore: bump version to 0.10.13 Co-Authored-By: Claude Opus 4.8 --- package.json | 2 +- packages/data-ai/.claude-plugin/plugin.json | 2 +- packages/data-ai/package.json | 2 +- packages/data-gpu/package.json | 2 +- packages/data-lit/package.json | 2 +- packages/data-persistence/package.json | 2 +- packages/data-react/package.json | 2 +- packages/data-rpc/package.json | 2 +- packages/data-solid/package.json | 2 +- packages/data-sync/package.json | 2 +- packages/data-testing/package.json | 2 +- packages/data/package.json | 2 +- 12 files changed, 12 insertions(+), 12 deletions(-) diff --git a/package.json b/package.json index d7a707c8..10b4d25d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "data-monorepo", - "version": "0.10.12", + "version": "0.10.13", "private": true, "engines": { "node": ">=24" diff --git a/packages/data-ai/.claude-plugin/plugin.json b/packages/data-ai/.claude-plugin/plugin.json index 162a262f..9d8df036 100644 --- a/packages/data-ai/.claude-plugin/plugin.json +++ b/packages/data-ai/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "adobe-data-ai", - "version": "0.10.12", + "version": "0.10.13", "description": "Architecture skills for @adobe/data — data-oriented modelling, archetype iteration, hot-path performance, and related conventions.", "author": { "name": "Adobe" diff --git a/packages/data-ai/package.json b/packages/data-ai/package.json index 000ea6ba..b4724c0a 100644 --- a/packages/data-ai/package.json +++ b/packages/data-ai/package.json @@ -1,6 +1,6 @@ { "name": "@adobe/data-ai", - "version": "0.10.12", + "version": "0.10.13", "description": "Cross-agent architecture skills for @adobe/data — installable as a Claude Code plugin or copied into any Agent-Skills-compatible agent (Cursor, Codex).", "type": "module", "private": false, diff --git a/packages/data-gpu/package.json b/packages/data-gpu/package.json index 68f415c0..02d8bedb 100644 --- a/packages/data-gpu/package.json +++ b/packages/data-gpu/package.json @@ -1,6 +1,6 @@ { "name": "@adobe/data-gpu", - "version": "0.10.12", + "version": "0.10.13", "description": "Adobe data WebGPU plugins and types for graphics and compute", "type": "module", "private": false, diff --git a/packages/data-lit/package.json b/packages/data-lit/package.json index 0835d003..84b05ecb 100644 --- a/packages/data-lit/package.json +++ b/packages/data-lit/package.json @@ -1,6 +1,6 @@ { "name": "@adobe/data-lit", - "version": "0.10.12", + "version": "0.10.13", "description": "Adobe data Lit bindings - hooks, elements, decorators", "type": "module", "private": false, diff --git a/packages/data-persistence/package.json b/packages/data-persistence/package.json index 340525e4..c0af362e 100644 --- a/packages/data-persistence/package.json +++ b/packages/data-persistence/package.json @@ -1,6 +1,6 @@ { "name": "@adobe/data-persistence", - "version": "0.10.12", + "version": "0.10.13", "description": "Worker-based incremental persistence layer for @adobe/data ECS over OPFS (browser) and node:fs (server).", "type": "module", "sideEffects": false, diff --git a/packages/data-react/package.json b/packages/data-react/package.json index a3861eed..a3f99691 100644 --- a/packages/data-react/package.json +++ b/packages/data-react/package.json @@ -1,6 +1,6 @@ { "name": "@adobe/data-react", - "version": "0.10.12", + "version": "0.10.13", "description": "Adobe data React bindings — hooks and context for ECS database", "type": "module", "private": false, diff --git a/packages/data-rpc/package.json b/packages/data-rpc/package.json index 6977f610..a983464a 100644 --- a/packages/data-rpc/package.json +++ b/packages/data-rpc/package.json @@ -1,6 +1,6 @@ { "name": "@adobe/data-rpc", - "version": "0.10.12", + "version": "0.10.13", "description": "Schema-driven, bidirectional projection of @adobe/data async data services across a boundary (iframe / MessagePort / Worker). Only Data crosses the wire.", "type": "module", "sideEffects": false, diff --git a/packages/data-solid/package.json b/packages/data-solid/package.json index 14ded1bf..7fdb808a 100644 --- a/packages/data-solid/package.json +++ b/packages/data-solid/package.json @@ -1,6 +1,6 @@ { "name": "@adobe/data-solid", - "version": "0.10.12", + "version": "0.10.13", "description": "Adobe data SolidJS bindings — context and provider for ECS database", "type": "module", "private": false, diff --git a/packages/data-sync/package.json b/packages/data-sync/package.json index 51427f4e..b909bf18 100644 --- a/packages/data-sync/package.json +++ b/packages/data-sync/package.json @@ -1,6 +1,6 @@ { "name": "@adobe/data-sync", - "version": "0.10.12", + "version": "0.10.13", "description": "Multi-user real-time synchronisation for @adobe/data ECS — server, client, and in-process loopback.", "type": "module", "sideEffects": false, diff --git a/packages/data-testing/package.json b/packages/data-testing/package.json index a884cd8a..650b2bc8 100644 --- a/packages/data-testing/package.json +++ b/packages/data-testing/package.json @@ -1,6 +1,6 @@ { "name": "@adobe/data-testing", - "version": "0.10.12", + "version": "0.10.13", "description": "Conformance-testing utilities (Match + Conformance runners) for @adobe/data ECS features", "type": "module", "sideEffects": false, diff --git a/packages/data/package.json b/packages/data/package.json index 41a08568..b81352aa 100644 --- a/packages/data/package.json +++ b/packages/data/package.json @@ -1,6 +1,6 @@ { "name": "@adobe/data", - "version": "0.10.12", + "version": "0.10.13", "description": "Adobe data oriented programming library", "type": "module", "sideEffects": false,