Skip to content

Commit 77cbe13

Browse files
krisnyeclaude
andcommitted
feat(data): reserve id/nonPersistent/nonShared names; single-source the id name
- Throw if a component schema defines a reserved built-in name (id, nonPersistent, nonShared) — at createCore and at store.extend — instead of silently clobbering the built-in (which post-id-removal corrupts the entity-id column, since resolveArchetype seeds it from componentSchemas[ID]). Covered by a unit test that runs for both the core and store factories. - Abstract the "id" component name to a single source of truth: `ID` (runtime) and `IdComponent` (type) in required-components.ts, with `RequiredComponents = Record<IdComponent, Entity>`. All ECS id-column access (data + data-persistence) now goes through these, so the name could be changed in one place. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent c0c62ce commit 77cbe13

14 files changed

Lines changed: 90 additions & 45 deletions

File tree

packages/data-persistence/src/service/create-incremental-persistence-service.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
// © 2026 Adobe. MIT License. See /LICENSE for details.
22

3-
import { ECS_SNAPSHOT_VERSION, Entity, serializedEntityLocationTables, type Archetype, type EntityLocationEntry } from "@adobe/data/ecs";
3+
import { ECS_SNAPSHOT_VERSION, Entity, ID, serializedEntityLocationTables, type Archetype, type EntityLocationEntry } from "@adobe/data/ecs";
44
import { createColumnEncoder } from "../encoder/create-column-encoder.js";
55
import {
66
decodeJournalSnapshot,
@@ -133,7 +133,7 @@ export const createIncrementalPersistenceService = async (
133133
// Skip the implicit `id` column — entity ids are recovered
134134
// from the per-quadrant entity-location files, so storing them
135135
// again per archetype row would be redundant.
136-
if (component === "id") continue;
136+
if (component === ID) continue;
137137
// Skip nonPersistent-schema components — their values are never
138138
// saved; on load they're reset to default or the component is
139139
// stripped (see store.reconstructNonPersistentColumns).
@@ -146,7 +146,7 @@ export const createIncrementalPersistenceService = async (
146146

147147
const componentIds = new Map<string, number>();
148148
for (const component of archetype.components) {
149-
if (component === "id" || isNonPersistentComponent(component)) continue;
149+
if (component === ID || isNonPersistentComponent(component)) continue;
150150
componentIds.set(component, internComponent(component));
151151
}
152152

@@ -670,7 +670,7 @@ export const createIncrementalPersistenceService = async (
670670
const colMan = aMan.columns[componentName]!;
671671
// The implicit `id` column is reconstructed from the entity
672672
// location table on a separate pass below.
673-
if (colMan.component === "id") continue;
673+
if (colMan.component === ID) continue;
674674
await restoreColumn(aMan, colMan, liveArchetype);
675675
}
676676

@@ -932,7 +932,7 @@ export const createIncrementalPersistenceService = async (
932932
if (entry.componentId === 0) return;
933933
const componentName = manifest.components[entry.componentId];
934934
if (componentName === undefined) return;
935-
if (componentName === "id") return;
935+
if (componentName === ID) return;
936936

937937
const colMan = aMan.columns[componentName];
938938
if (colMan === undefined) return;

packages/data-persistence/src/service/internal-access.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
// what runtime contract makes it sound.
77

88
import type { Archetype, Database, ReadonlyArchetype } from "@adobe/data/ecs";
9+
import { ID } from "@adobe/data/ecs";
910
import type { Store } from "@adobe/data/ecs";
1011
import type { TypedBuffer } from "@adobe/data/typed-buffer";
1112

@@ -70,5 +71,5 @@ export const getColumn = (
7071
export const getIdColumn = (
7172
archetype: ReadonlyArchetype<any>,
7273
): TypedBuffer<number> | undefined => {
73-
return getColumn(archetype, "id") as TypedBuffer<number> | undefined;
74+
return getColumn(archetype, ID) as TypedBuffer<number> | undefined;
7475
};

packages/data/src/ecs/archetype/archetype.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
11
// © 2026 Adobe. MIT License. See /LICENSE for details.
2-
import { RequiredComponents } from "../required-components.js";
2+
import { RequiredComponents, IdComponent } from "../required-components.js";
33
import { Entity } from "../entity/entity.js";
44
import { Table, ReadonlyTable } from "../../table/index.js";
55
import { Assert } from "../../types/assert.js";
66
import { Equal } from "../../types/equal.js";
77
import { Exact, StringKeyof } from "../../types/types.js";
88

9-
export type EntityInsertValues<C> = Omit<C, "id">;
9+
export type EntityInsertValues<C> = Omit<C, IdComponent>;
1010
export type ArchetypeId = number;
1111

1212
/**
@@ -84,8 +84,8 @@ export namespace Archetype {
8484
// via the `columns` position (typed `C & RequiredComponents`), but `id` is never
8585
// part of the component row.
8686
export type FromArchetype<T> =
87-
T extends ReadonlyArchetype<infer C> ? { readonly [K in keyof Omit<C, "id">]: C[K] } :
88-
T extends Archetype<infer C> ? { readonly [K in keyof Omit<C, "id">]: C[K] } :
87+
T extends ReadonlyArchetype<infer C> ? { readonly [K in keyof Omit<C, IdComponent>]: C[K] } :
88+
T extends Archetype<infer C> ? { readonly [K in keyof Omit<C, IdComponent>]: C[K] } :
8989
never;
9090

9191
// compile time type tests.
@@ -94,7 +94,7 @@ type TestFromReadonlyArchetype = Assert<Equal<FromArchetype<ReadonlyArchetype<{
9494
type TestFromArchetype = Assert<Equal<FromArchetype<Archetype<{ a: number, b: string }>>, { readonly a: number, readonly b: string }>>;
9595
// …but it remains a real, typed column so swap-remove / manual traversal can read
9696
// `columns.id` directly: `id` is present in `columns` even though it is not in `C`.
97-
type TestIdColumnStillTyped = Assert<"id" extends keyof Archetype<{ a: number }>["columns"] ? true : false>;
97+
type TestIdColumnStillTyped = Assert<IdComponent extends keyof Archetype<{ a: number }>["columns"] ? true : false>;
9898

9999
// Compile-time tests for Exact in insert method
100100
{

packages/data/src/ecs/archetype/create-archetype.ts

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import { Schema } from "../../schema/index.js";
44
import * as TABLE from "../../table/index.js";
55
import { Archetype, EntityInsertValues } from "./archetype.js";
6+
import { ID, IdComponent } from "../required-components.js";
67
import { EntityLocationTable } from "../entity-location-table/entity-location-table.js";
78
import { Entity } from "../entity/entity.js";
89
import { StringKeyof } from "../../types/types.js";
@@ -91,7 +92,7 @@ const buildSpecializedInsert = (
9192
const componentParamValues: TypedBuffer<any>[] = [];
9293
const sets: string[] = [];
9394
for (const name of componentNames) {
94-
if (name === "id") continue;
95+
if (name === ID) continue;
9596
const local = `_${name}`;
9697
componentParamNames.push(local);
9798
componentParamValues.push(columns[name]);
@@ -142,7 +143,7 @@ ${sets.join("\n")}
142143
archetypeId,
143144
ensureCapacity,
144145
entityLocationTable,
145-
columns.id as TypedBuffer<number>,
146+
columns[ID] as TypedBuffer<number>,
146147
...componentParamValues,
147148
);
148149
};
@@ -158,21 +159,21 @@ const buildGenericInsert = (
158159
return (archetype: any, rowData: any) => {
159160
const row = TABLE.addRow(archetype, rowData);
160161
const entity = entityLocationTable.create({ archetype: archetypeId, row });
161-
archetype.columns.id.set(row, entity);
162+
archetype.columns[ID].set(row, entity);
162163
return entity;
163164
};
164165
};
165166

166-
export const createArchetype = <C extends { id: typeof Entity.schema }>(
167+
export const createArchetype = <C extends Record<IdComponent, typeof Entity.schema>>(
167168
components: C,
168169
id: number,
169170
entityLocationTable: EntityLocationTable,
170-
): Archetype<Omit<{ [K in keyof C]: Schema.ToType<C[K]> }, "id">> => {
171+
): Archetype<Omit<{ [K in keyof C]: Schema.ToType<C[K]> }, IdComponent>> => {
171172
// The archetype's public COMPONENT set excludes `id`: id is the entity's
172173
// identity, a column but never a component value. (`table.columns` and the
173174
// runtime `componentSet` still carry id — required for swap-remove and
174175
// serialization — but that is asserted below where the types are narrowed.)
175-
type PublicComponents = Omit<{ [K in keyof C]: Schema.ToType<C[K]> }, "id">;
176+
type PublicComponents = Omit<{ [K in keyof C]: Schema.ToType<C[K]> }, IdComponent>;
176177
const table = TABLE.createTable(components);
177178
const componentSet = new Set(Object.keys(components));
178179

packages/data/src/ecs/archetype/delete-row.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
// © 2026 Adobe. MIT License. See /LICENSE for details.
22
import * as TABLE from "../../table/index.js";
33
import { Archetype } from "./archetype.js";
4+
import { ID } from "../required-components.js";
45
import { EntityLocationTable } from "../entity-location-table/entity-location-table.js";
56
import { Entity } from "../entity/entity.js";
67

@@ -15,7 +16,7 @@ import { Entity } from "../entity/entity.js";
1516
export const deleteRow = <C>(archetype: Archetype<C>, row: number, entityLocationTable: EntityLocationTable): Entity | undefined => {
1617
const movedARowToFillHole = TABLE.deleteRow(archetype, row);
1718
if (movedARowToFillHole) {
18-
const movedId = archetype.columns.id.get(row);
19+
const movedId = archetype.columns[ID].get(row);
1920
entityLocationTable.update(movedId, { archetype: archetype.id, row });
2021
return movedId;
2122
}

packages/data/src/ecs/database/observed/create-observed-database.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { observeSelectEntities } from "../observe-select-entities.js";
1414
import { createDerive } from "../observe-derive.js";
1515
import { createTransactionalStore } from "../transactional-store/create-transactional-store.js";
1616
import { Entity } from "../../entity/entity.js";
17+
import { ID } from "../../required-components.js";
1718
import { EntityReadValues, EntityUpdateValues } from "../../store/core/index.js";
1819
import { ObservedDatabase } from "./observed-database.js";
1920

@@ -117,7 +118,7 @@ export function createObservedDatabase<
117118

118119
const resourceArchetypeComponents = (resource: string): StringKeyof<C>[] => {
119120
const schema = (store.componentSchemas as any)[resource];
120-
const names: StringKeyof<C>[] = ["id" as StringKeyof<C>, resource as unknown as StringKeyof<C>];
121+
const names: StringKeyof<C>[] = [ID as StringKeyof<C>, resource as unknown as StringKeyof<C>];
121122
if (schema?.nonPersistent) names.push("nonPersistent" as StringKeyof<C>);
122123
if (schema?.nonShared) names.push("nonShared" as StringKeyof<C>);
123124
return names;
@@ -126,7 +127,7 @@ export function createObservedDatabase<
126127
const observeResource = Object.fromEntries(
127128
Object.entries(store.resources).map(([resource]) => {
128129
const archetype = store.ensureArchetype(resourceArchetypeComponents(resource));
129-
const resourceId = archetype.columns.id.get(0);
130+
const resourceId = archetype.columns[ID].get(0);
130131
return [resource, Observe.withMap(observeEntity(resourceId), (values) => (values as any)?.[resource] ?? null)];
131132
})
132133
) as { [K in StringKeyof<R>]: Observe<R[K]>; };
@@ -200,7 +201,7 @@ export function createObservedDatabase<
200201
(observe as any).resources = Object.fromEntries(
201202
Object.entries(store.resources).map(([resource]) => {
202203
const archetype = store.ensureArchetype(resourceArchetypeComponents(resource));
203-
const resourceId = archetype.columns.id.get(0);
204+
const resourceId = archetype.columns[ID].get(0);
204205
return [resource, Observe.withMap(observeEntity(resourceId), (values) => (values as any)?.[resource] ?? null)];
205206
})
206207
);

packages/data/src/ecs/database/transactional-store/apply-operations.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
// © 2026 Adobe. MIT License. See /LICENSE for details.
22
import { Store } from "../../store/index.js";
3+
import { ID } from "../../required-components.js";
34
import { TransactionWriteOperation } from "./transactional-store.js";
45
import { StringKeyof } from "../../../types/types.js";
56

@@ -21,7 +22,7 @@ export const applyOperations = (
2122
for (const operation of operations) {
2223
switch (operation.type) {
2324
case "insert": {
24-
const componentNames = ["id", ...Object.keys(operation.values)] as StringKeyof<any>[];
25+
const componentNames = [ID, ...Object.keys(operation.values)] as StringKeyof<any>[];
2526
const archetype = store.ensureArchetype(componentNames);
2627
archetype.insert(operation.values as never);
2728
break;

packages/data/src/ecs/database/transactional-store/create-transactional-store.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { Archetype, ArchetypeId, EntityInsertValues } from "../../archetype/inde
33
import { ResourceComponents } from "../../store/resource-components.js";
44
import { Store } from "../../store/index.js";
55
import { Entity } from "../../entity/entity.js";
6+
import { ID } from "../../required-components.js";
67
import { EntityUpdateValues } from "../../store/core/index.js";
78
import { TransactionalStore, TransactionResult, TransactionWriteOperation } from "./transactional-store.js";
89
import { StringKeyof } from "../../../types/types.js";
@@ -154,7 +155,7 @@ export function createTransactionalStore<
154155
throw new Error(`Entity not found: ${entity}`);
155156
}
156157

157-
const { id: _ignore, ...oldValuesWithoutId } = oldValues as any;
158+
const { [ID]: _ignore, ...oldValuesWithoutId } = oldValues as any;
158159
for (const key in oldValuesWithoutId) {
159160
changed.components.add(key);
160161
}
@@ -171,7 +172,7 @@ export function createTransactionalStore<
171172

172173
const resourceComponentNames = (name: string): StringKeyof<C>[] => {
173174
const schema = (store.componentSchemas as any)[name];
174-
const names = ["id", name] as StringKeyof<C>[];
175+
const names = [ID, name] as StringKeyof<C>[];
175176
if (schema?.nonPersistent) names.push("nonPersistent" as StringKeyof<C>);
176177
if (schema?.nonShared) names.push("nonShared" as StringKeyof<C>);
177178
return names;
@@ -182,7 +183,7 @@ export function createTransactionalStore<
182183
const resourceId = name as keyof C;
183184
const componentNames = resourceComponentNames(name);
184185
const archetype = store.ensureArchetype(componentNames);
185-
const entityId = archetype.columns.id.get(0);
186+
const entityId = archetype.columns[ID].get(0);
186187
Object.defineProperty(resources, name, {
187188
get: Object.getOwnPropertyDescriptor(store.resources, name)!.get,
188189
set: (newValue) => {
@@ -290,7 +291,7 @@ export function createTransactionalStore<
290291
const resourceId = name as keyof C;
291292
const componentNames = resourceComponentNames(name);
292293
const archetype = store.ensureArchetype(componentNames);
293-
const entityId = archetype.columns.id.get(0);
294+
const entityId = archetype.columns[ID].get(0);
294295
Object.defineProperty(resources, name, {
295296
get: Object.getOwnPropertyDescriptor(store.resources, name)!.get,
296297
set: (newValue: any) => {
Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,25 @@
11
// © 2026 Adobe. MIT License. See /LICENSE for details.
22
import { Entity } from "./entity/entity.js";
33

4-
// © 2026 Adobe. MIT License. See /LICENSE for details.
5-
export type RequiredComponents = { id: Entity };
4+
/**
5+
* The reserved component name for an entity's identity — the single source of
6+
* truth for the name. Every archetype carries this column; it is the entity's
7+
* key, never a component value. All ECS access to the identity column goes
8+
* through this constant (runtime) and {@link IdComponent} (type), so the name
9+
* could be changed here in one place (e.g. to `"entity"`) and the whole ECS
10+
* would follow.
11+
*/
12+
export const ID = "id" as const;
13+
14+
/** The identity component's name as a type — mirrors {@link ID}. */
15+
export type IdComponent = typeof ID;
16+
17+
/** The always-present identity column every entity row is keyed by. */
18+
export type RequiredComponents = Record<IdComponent, Entity>;
19+
20+
/**
21+
* Component names reserved by the ECS. User schemas may not define these — the
22+
* store/core throw if a schema does. `id` is the entity identity; `nonPersistent`
23+
* / `nonShared` are the built-in quadrant markers (see entity/persistence-sharing).
24+
*/
25+
export const RESERVED_COMPONENT_NAMES: readonly string[] = [ID, "nonPersistent", "nonShared"];

packages/data/src/ecs/store/core/core.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import { Entity } from "../../entity/entity.js";
44
import { Archetype, ReadonlyArchetype } from "../../archetype/archetype.js";
55
import { Schema } from "../../../schema/index.js";
6-
import { RequiredComponents } from "../../required-components.js";
6+
import { RequiredComponents, IdComponent } from "../../required-components.js";
77
import { StringKeyof } from "../../../types/index.js";
88
import { Components } from "../components.js";
99
import { OptionalComponents } from "../../optional-components.js";
@@ -15,7 +15,7 @@ import { PersistenceScope, ToDataOptions } from "../../persistence-scope.js";
1515
// stays a real column (see the Archetype interface) but is never part of a read.
1616
export type EntityValues<C> = { readonly [K in StringKeyof<C & OptionalComponents>]: (C & OptionalComponents)[K] }
1717
export type EntityReadValues<C> = { readonly [K in StringKeyof<C & OptionalComponents> as string extends K ? never : K]?: (C & OptionalComponents)[K] }
18-
export type EntityUpdateValues<C> = Partial<Omit<C, "id">>;
18+
export type EntityUpdateValues<C> = Partial<Omit<C, IdComponent>>;
1919

2020
export type ArchetypeQueryOptions<C extends object, PK extends string = never> = {
2121
exclude?: readonly StringKeyof<C & RequiredComponents & OptionalComponents>[];

0 commit comments

Comments
 (0)