Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/data-ai/.claude/rules/features/data/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
6 changes: 3 additions & 3 deletions packages/data/src/ecs/database/create-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -212,8 +212,8 @@ export function createPlugin<
services?: SVF & {
readonly [K: string]: (db: Database.FromPlugin<AmbientPlugin<XP, IP>>) => unknown
},
components?: CS & NoReservedNames<CS>,
resources?: RS & NoReservedNames<RS>,
components?: CS & NoReservedNames<CS> & NoNameClash<CS, RemoveIndex<XP['resources'] & IP['resources']>>,
resources?: RS & NoReservedNames<RS> & NoNameClash<RS, RemoveIndex<CS & XP['components'] & IP['components']>>,
archetypes?: A,
indexes?: IX,
computed?: CVF & PluginComputedFactories<FullDBForPlugin<RemoveIndex<CS>, RemoveIndex<RS>, RemoveIndex<A>, {}, string, XP['actions'] & IP['actions'], AmbientPlugin<XP, IP>, RemoveIndex<SVF>, RemoveIndex<IX>>>,
Expand Down
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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/);
});
});
9 changes: 3 additions & 6 deletions packages/data/src/ecs/database/public/create-database.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,8 +75,6 @@ interface CreateDatabaseOptions<P extends Database.Plugin<any, any, any, any, an
* absent from this map throws when its first archetype is resolved.
*/
defaultFactories?: Record<string, () => unknown>;
/** Component/resource names acknowledged as clashing (see {@link CreateStoreOptions}). */
nameClashes?: readonly string[];
}

export function createDatabase(): Database<{}, {}, {}, {}, never, {}, {}, {}>
Expand All @@ -90,7 +88,7 @@ export function createDatabase(
plugin?: Database.Plugin<any, any, any, any, any, any, any, any>,
options?: CreateDatabaseOptions<any>,
): 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;
}
Expand Down Expand Up @@ -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<string, () => unknown>,
nameClashes?: readonly string[],
}): any {
const store = Store.create({
components: {},
resources: {},
archetypes: {},
}, { allocator, defaultFactories, nameClashes });
}, { allocator, defaultFactories });

const observedDatabase = createObservedDatabase(store);

Expand Down
18 changes: 18 additions & 0 deletions packages/data/src/ecs/database/reserved-names.type-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
7 changes: 7 additions & 0 deletions packages/data/src/ecs/reserved-names.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<T> = { readonly [K in Extract<keyof T, IdComponent | keyof OptionalComponents>]: 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<T, Other> = { readonly [K in Extract<keyof T, keyof Other>]: never };
33 changes: 10 additions & 23 deletions packages/data/src/ecs/store/public/create-store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});

Expand Down
27 changes: 13 additions & 14 deletions packages/data/src/ecs/store/public/create-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,14 +43,14 @@ export interface CreateStoreOptions {
* archetype is resolved (fail fast, not per insert).
*/
defaultFactories?: Record<string, () => 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 = {},
Expand Down Expand Up @@ -84,7 +84,6 @@ export function createStore<
const resourceSchemas = {} as RS;
const archetypeComponentNames = {} as A;
const componentAndResourceSchemas: { [K in StringKeyof<C | R>]: Schema } = {} as any;
const acknowledgedClashes = new Set(options?.nameClashes);

const core = createCore(
componentAndResourceSchemas,
Expand Down Expand Up @@ -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;
Expand All @@ -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) {
Expand Down
Loading