diff --git a/packages/data-ai/.claude/rules/features/data/index.md b/packages/data-ai/.claude/rules/features/data/index.md index 7d14e1f4..4ae8447b 100644 --- a/packages/data-ai/.claude/rules/features/data/index.md +++ b/packages/data-ai/.claude/rules/features/data/index.md @@ -28,5 +28,5 @@ fields, it takes them structurally (`Iterable<{ readonly mark: PlayerMark; readonly cellIndex: CellIndex }>`). Components and resources share **one namespace** across every feature in an app: -a name is one column or one resource, never both (the store warns on a clash, and the resource's schema replaces the component's; an app mid-rename lists it in the `nameClashes` create option), and +a name is one column or one resource, never both (the store throws on a clash), and has one owning feature. diff --git a/packages/data/src/ecs/database/create-plugin.ts b/packages/data/src/ecs/database/create-plugin.ts index 33a36416..0a4c5308 100644 --- a/packages/data/src/ecs/database/create-plugin.ts +++ b/packages/data/src/ecs/database/create-plugin.ts @@ -2,7 +2,7 @@ import { Database, SystemFunction, ServiceFactories, FromServiceFactories, FromComputedFactories, type PluginComputedFactories, type IndexDeclarations } from "./database.js"; import type { ComponentSchemas } from "../component-schemas.js"; -import type { NoReservedNames } from "../reserved-names.js"; +import type { NoNameClash, NoReservedNames } from "../reserved-names.js"; import type { ResourceSchemas } from "../resource-schemas.js"; import type { ArchetypeComponents } from "../store/archetype-components.js"; import type { TransactionDeclarations, ToTransactionFunctions } from "../store/transaction-functions.js"; @@ -212,8 +212,8 @@ export function createPlugin< services?: SVF & { readonly [K: string]: (db: Database.FromPlugin>) => unknown }, - components?: CS & NoReservedNames, - resources?: RS & NoReservedNames, + components?: CS & NoReservedNames & NoNameClash>, + resources?: RS & NoReservedNames & NoNameClash>, archetypes?: A, indexes?: IX, computed?: CVF & PluginComputedFactories, RemoveIndex, RemoveIndex, {}, string, XP['actions'] & IP['actions'], AmbientPlugin, RemoveIndex, RemoveIndex>>, diff --git a/packages/data/src/ecs/database/public/create-database-from-schema.test.ts b/packages/data/src/ecs/database/public/create-database-from-schema.test.ts index 64def6cc..6470a7f9 100644 --- a/packages/data/src/ecs/database/public/create-database-from-schema.test.ts +++ b/packages/data/src/ecs/database/public/create-database-from-schema.test.ts @@ -1,6 +1,6 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import { describe, it, expect, vi } from "vitest"; +import { describe, it, expect } from "vitest"; import { Database } from "../database.js"; import { Store } from "../../store/index.js"; import { F32 } from "../../../math/f32/index.js"; @@ -155,16 +155,17 @@ describe("Database.create from plugin", () => { }); -describe("Database.create nameClashes", () => { - it("passes the acknowledged clashes through to the store", () => { - const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); +describe("Database.create name clashes", () => { + it("throws when a plugin's resource reuses a component name from the plugin it extends", () => { const base = Database.Plugin.create({ components: { mode: { type: "number", default: 0 } } }); - const plugin = Database.Plugin.create({ extends: base, resources: { mode: { type: "number", default: 7 } } }); - const clashes = () => warn.mock.calls.filter(([message]) => String(message).includes("both a component and a resource")); - Database.create(plugin); - expect(clashes()).toHaveLength(1); - Database.create(plugin, { nameClashes: ["mode"] }); - expect(clashes()).toHaveLength(1); - warn.mockRestore(); + // @ts-expect-error — also a compile error; this covers the runtime check + const plugin = Database.Plugin.create({ extends: base, resources: { mode: { type: "number", default: 0 } } }); + expect(() => Database.create(plugin)).toThrow(/"mode" is both a component and a resource/); + }); + + it("throws when combined plugins declare the name as a component and a resource", () => { + const a = Database.Plugin.create({ components: { size: { type: "number", default: 0 } } }); + const b = Database.Plugin.create({ resources: { size: { type: "number", default: 0 } } }); + expect(() => Database.create(Database.Plugin.combine(a, b))).toThrow(/"size" is both a component and a resource/); }); }); diff --git a/packages/data/src/ecs/database/public/create-database.ts b/packages/data/src/ecs/database/public/create-database.ts index de715940..cbb781e4 100644 --- a/packages/data/src/ecs/database/public/create-database.ts +++ b/packages/data/src/ecs/database/public/create-database.ts @@ -75,8 +75,6 @@ interface CreateDatabaseOptions

unknown>; - /** Component/resource names acknowledged as clashing (see {@link CreateStoreOptions}). */ - nameClashes?: readonly string[]; } export function createDatabase(): Database<{}, {}, {}, {}, never, {}, {}, {}> @@ -90,7 +88,7 @@ export function createDatabase( plugin?: Database.Plugin, options?: CreateDatabaseOptions, ): any { - const db = createEmptyDatabase({ concurrency: options?.concurrency, versioning: options?.versioning, allocator: options?.allocator, defaultFactories: options?.defaultFactories, nameClashes: options?.nameClashes }); + const db = createEmptyDatabase({ concurrency: options?.concurrency, versioning: options?.versioning, allocator: options?.allocator, defaultFactories: options?.defaultFactories }); if (plugin === undefined) { return db; } @@ -143,18 +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, defaultFactories, nameClashes }: { +function createEmptyDatabase({ concurrency, versioning, allocator, defaultFactories }: { concurrency: ConcurrencyStrategyFactory | undefined, versioning?: DatabaseVersioning, allocator?: MemoryAllocator, defaultFactories?: Record unknown>, - nameClashes?: readonly string[], }): any { const store = Store.create({ components: {}, resources: {}, archetypes: {}, - }, { allocator, defaultFactories, nameClashes }); + }, { allocator, defaultFactories }); const observedDatabase = createObservedDatabase(store); diff --git a/packages/data/src/ecs/database/reserved-names.type-test.ts b/packages/data/src/ecs/database/reserved-names.type-test.ts index fffe40f9..9b856f47 100644 --- a/packages/data/src/ecs/database/reserved-names.type-test.ts +++ b/packages/data/src/ecs/database/reserved-names.type-test.ts @@ -11,3 +11,21 @@ export const reservedComponent = Database.Plugin.create({ components: { id: mode // @ts-expect-error — `nonShared` is reserved by the ECS export const reservedResource = Database.Plugin.create({ resources: { nonShared: mode } }); + +// A name can't be both a component and a resource, in one plugin or across `extends`. +// @ts-expect-error — `mode` is already a component +export const clashSamePlugin = Database.Plugin.create({ components: { mode }, resources: { mode } }); + +const base = Database.Plugin.create({ components: { mode }, resources: { level: mode } }); + +// @ts-expect-error — `mode` is a component in the extended plugin +export const clashResourceOverComponent = Database.Plugin.create({ extends: base, resources: { mode } }); + +// @ts-expect-error — `level` is a resource in the extended plugin +export const clashComponentOverResource = Database.Plugin.create({ extends: base, components: { level: mode } }); + +// POSITIVE — distinct names across `extends` compile, and inference is preserved. +export const distinct = Database.Plugin.create({ extends: base, components: { other: mode }, resources: { more: mode } }); +const distinctOtherSchema: typeof distinct.components.other = mode; +const distinctMoreSchema: typeof distinct.resources.more = mode; +void distinctOtherSchema; void distinctMoreSchema; diff --git a/packages/data/src/ecs/reserved-names.ts b/packages/data/src/ecs/reserved-names.ts index 375a96e8..369fc46a 100644 --- a/packages/data/src/ecs/reserved-names.ts +++ b/packages/data/src/ecs/reserved-names.ts @@ -8,3 +8,10 @@ import type { OptionalComponents } from "./optional-components.js"; * declaration fails to compile instead of throwing when the store is created. */ export type NoReservedNames = { readonly [K in Extract]: never }; + +/** + * Rejects names in `T` that are already declared in `Other`: a component name can't + * also be a resource name (and vice versa), because a resource is stored as a + * same-named component column. The store also throws on a clash at runtime. + */ +export type NoNameClash = { readonly [K in Extract]: never }; diff --git a/packages/data/src/ecs/store/public/create-store.test.ts b/packages/data/src/ecs/store/public/create-store.test.ts index 5992caf1..40f3af4a 100644 --- a/packages/data/src/ecs/store/public/create-store.test.ts +++ b/packages/data/src/ecs/store/public/create-store.test.ts @@ -15,34 +15,21 @@ describe("createStore", () => { ); describe("component/resource name clashes", () => { - const component = { type: "number", default: 0 } as const; - const resource = { type: "number", default: 7, nonPersistent: true } as const; - const clashWarnings = (warn: { mock: { calls: unknown[][] } }) => - warn.mock.calls.filter(([message]) => String(message).includes("both a component and a resource")); + const schema = { type: "number", default: 0 } as const; + const clash = /"mode" is both a component and a resource/; - it("warns on every store with an unacknowledged clash and lets the resource schema win", () => { - const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); - for (let i = 0; i < 2; i++) { - const store = createStore({ components: { mode: component }, resources: {}, archetypes: {} }); - store.extend({ components: {}, resources: { mode: resource }, archetypes: {} }); - expect(store.componentSchemas.mode).toBe(resource); - } - expect(clashWarnings(warn)).toHaveLength(2); - warn.mockRestore(); + it("throws when a resource reuses a component's name, even with the same schema", () => { + const store = createStore({ components: { mode: schema }, resources: {}, archetypes: {} }); + expect(() => store.extend({ components: {}, resources: { mode: schema }, archetypes: {} })).toThrow(clash); }); - it("does not warn for a clash listed in nameClashes", () => { - const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); - const store = createStore({ components: { mode: component }, resources: {}, archetypes: {} }, { nameClashes: ["mode"] }); - store.extend({ components: {}, resources: { mode: resource }, archetypes: {} }); - expect(store.componentSchemas.mode).toBe(resource); - expect(clashWarnings(warn)).toHaveLength(0); - warn.mockRestore(); + it("throws when a component reuses a resource's name, even with the same schema", () => { + const store = createStore({ components: {}, resources: { mode: schema }, archetypes: {} }); + expect(() => store.extend({ components: { mode: schema }, resources: {}, archetypes: {} })).toThrow(clash); }); - it("throws when a component redefines a resource's name with a different schema", () => { - const store = createStore({ components: {}, resources: { mode: { type: "number", default: 0 } }, archetypes: {} }); - expect(() => store.extend({ components: { mode: { type: "number", default: 1 } }, resources: {}, archetypes: {} })).toThrow(/must be identical/); + it("throws when one schema declares the name as both", () => { + expect(() => createStore({ components: { mode: schema }, resources: { mode: schema }, archetypes: {} })).toThrow(clash); }); }); diff --git a/packages/data/src/ecs/store/public/create-store.ts b/packages/data/src/ecs/store/public/create-store.ts index ff6e0ace..399c2e69 100644 --- a/packages/data/src/ecs/store/public/create-store.ts +++ b/packages/data/src/ecs/store/public/create-store.ts @@ -43,14 +43,14 @@ export interface CreateStoreOptions { * archetype is resolved (fail fast, not per insert). */ defaultFactories?: Record unknown>; - /** - * Names acknowledged as both a component and a resource, so they don't warn. The - * resource schema replaces the component's (the 0.10.20 behaviour). Transitional: - * rename one side and drop it from this list; an unlisted clash warns. - */ - nameClashes?: readonly string[]; } +// A resource is stored as a same-named component column on its own entity, so a +// name can't be both: the schemas would collide and the component's queries would +// return the resource's entity. +const nameClashMessage = (name: string) => + `"${name}" is both a component and a resource. A name must be one or the other; rename one of them.`; + export function createStore< CS extends ComponentSchemas = {}, RS extends ResourceSchemas = {}, @@ -84,7 +84,6 @@ export function createStore< const resourceSchemas = {} as RS; const archetypeComponentNames = {} as A; const componentAndResourceSchemas: { [K in StringKeyof]: Schema } = {} as any; - const acknowledgedClashes = new Set(options?.nameClashes); const core = createCore( componentAndResourceSchemas, @@ -365,8 +364,11 @@ export function createStore< if (RESERVED_COMPONENT_NAMES.includes(name)) { throw new Error(`Component name "${name}" is reserved by the ECS and cannot be defined.`); } - if (name in componentAndResourceSchemas) { - if (componentAndResourceSchemas[name as keyof typeof componentAndResourceSchemas] !== newComponentSchema) { + if (name in resourceSchemas) { + throw new Error(nameClashMessage(name)); + } + if (name in componentSchemas) { + if (componentSchemas[name as keyof typeof componentSchemas] !== newComponentSchema) { throw new Error(`Component schema for "${name}" must be identical when extending.`); } continue; @@ -385,11 +387,8 @@ export function createStore< if (RESERVED_COMPONENT_NAMES.includes(name)) { throw new Error(`Resource name "${name}" is reserved by the ECS and cannot be defined.`); } - // Components and resources share one schema map, so a same-named resource - // replaces the component's schema there (its column takes on the resource's - // flags). Kept for compatibility; rename one, or acknowledge it in `nameClashes`. - if (name in componentSchemas && !acknowledgedClashes.has(name)) { - console.warn(`@adobe/data: "${name}" is both a component and a resource; the resource schema replaces the component's. Rename one, or list it in the store's nameClashes option.`); + if (name in componentSchemas) { + throw new Error(nameClashMessage(name)); } if (name in resourceSchemas) { if (resourceSchemas[name as keyof typeof resourceSchemas] !== newResourceSchema) {