@@ -15,18 +15,89 @@ State Specification is authoritative and the ECS conforms to it. A feature witho
1515this rule does not apply — see ` ../index.md ` , Two modes.
1616
1717``` ts
18- // state.ts — the aggregate + the transition/derivation namespace.
19- export type State = { readonly todos: readonly Todo []; readonly displayCompleted: boolean };
18+ // data/todo/todo.ts — an entity value type: plain readonly data, NO id.
19+ export type Todo = { readonly name: string ; readonly complete: boolean ; readonly order: number };
20+ export * as Todo from " ./public.js" ;
21+
22+ // data/todo/is.ts — a structural type guard, re-exported so it reads as `Todo.is`.
23+ // `"k" in v` narrows `v` so each field reads without a cast.
24+ export const is = (v : unknown ): v is Todo =>
25+ typeof v === " object" && v !== null &&
26+ " name" in v && typeof v .name === " string" &&
27+ " complete" in v && typeof v .complete === " boolean" &&
28+ " order" in v && typeof v .order === " number" ;
29+
30+ // data/state/state.ts — singletons + one identity-keyed entity map.
31+ export type State = {
32+ readonly displayCompleted: boolean ; // a SINGLETON → an ECS resource of the same name
33+ readonly entities: ReadonlyMap <number , Todo >; // ALL entities, keyed by id; the value has no id
34+ };
2035export * as State from " ./public.js" ;
2136```
2237
23- Every ** state-based** feature owns a ` State ` (a scalar ` { playing: boolean } ` , or
24- ` {} ` when there is none). An ECS-based feature has no ` State ` at all.
38+ Every ** state-based** feature owns a ` State ` . An ECS-based feature has none.
39+
40+ ## The standard ` State ` shape
41+
42+ A ` State ` has two kinds of field:
43+
44+ - ** Singletons** — every non-` entities ` field (` displayCompleted ` , a ` score ` , a
45+ ` board ` ). Each becomes an ECS ** resource** of the same name (see
46+ ` ../services/main-service/resources.md ` ).
47+ - ** ` entities ` ** — a single ` ReadonlyMap<number, EntityValue> ` holding * every* entity,
48+ keyed by a numeric id, where ` EntityValue ` is the union of the feature's entity value
49+ types (` Todo ` , or ` Bullet | Asteroid ` ). Omit ` entities ` entirely for a feature with
50+ no entities (` tictactoe ` , ` dashboard ` ) — it is then all singletons.
51+
52+ This maps 1:1 to the ECS with ** zero dependency on it** — the key is a plain ` number ` ,
53+ never the ECS ` Entity ` , so ` data/ ` provably cannot reach ECS machinery. Singletons ↔
54+ resources; each ` entities ` value ↔ one entity whose ** component set is the value's own
55+ keys** , so ` fromState ` inserts a value into the archetype named by ` Object.keys(value) `
56+ and ` toState ` reads every entity back into the map. Identity lives in the key, never a
57+ field — see ` ../../data-modelling.md ` (Entities are keyed, never id-bearing values).
58+
59+ ### Entity value types — structural, id-less, one ` is ` guard each
2560
26- ** A ` State ` field's collection type carries its ordering** — see
27- ` ../../data-modelling.md ` (Collection ordering is carried by the type). ` todos `
28- above is a ` ReadonlyArray ` because todo order is a user-visible, reorderable fact;
29- an unordered entity bag (bullets, sprites) is a ` ReadonlySet ` .
61+ Each entity type is its own ` data/<type>/ ` namespace (` data/index.md ` ): a plain
62+ readonly type with ** no ` id ` ** plus a structural ` is ` guard (` is.ts ` , re-exported so it
63+ reads ` Todo.is ` ). A ** sub-archetype** is an intersection — ` Bar = Foo & { baz } ` — and
64+ its guard ** composes the base guard** : ` Bar.is ` calls ` Foo.is(v) ` first, then checks the
65+ added fields (` "baz" in v && typeof v.baz === "boolean" ` ). So ` Bar.is ⟹ Foo.is ` by
66+ construction.
67+
68+ ** Model structurally, like the ECS — no tags by default.** ECS systems match purely on
69+ structure (a mover runs on anything with ` position ` + ` velocity ` , regardless of
70+ "kind"), and ` State ` mirrors that:
71+
72+ - ** A property name means one thing feature-wide.** No two entity types may declare the
73+ same property name with a different type/semantics — that is what lets every property
74+ collapse to exactly one ECS component and keeps structural matching sound.
75+ - ** No marker / ` kind ` / tag field unless a genuine modelling need demands it.** Prefer
76+ discriminating on the presence of real components; add a tag only when structure alone
77+ cannot express the distinction.
78+
79+ ### Querying entities — ` State.getXEntities ` , returning ids
80+
81+ Entity queries are ` state/ ` derivations that return entity ** ids** (look the value up
82+ with ` state.entities.get(id) ` ), mirroring the ECS ` select ` . Perf is irrelevant here
83+ (full-map scan + guard); the ECS realises them as archetype queries.
84+
85+ - ** Unordered** → ` ReadonlySet<number> ` : keys whose value passes the guard. It is a
86+ ** superset** match — ` getFooEntities ` (via ` Foo.is ` ) also includes ` Bar ` entities,
87+ just as ` queryArchetypes(["…Foo's cols"]) ` returns the ` Foo ` * and* ` Bar ` archetypes.
88+ - ** Ordered** → ` ReadonlyArray<number> ` : the matching ids sorted by an explicit ` order `
89+ component, exactly like an ECS ` select(cols, { order }) ` . Order is ** never** carried
90+ by the ` entities ` map (it is identity-keyed / unordered); a meaningful order is a
91+ component on the value and the derivation returns the sorted ids.
92+
93+ ``` ts
94+ // data/state/get-todos.ts — an ordered membership derivation returning ids
95+ export const getTodos = (state : State ): readonly number [] =>
96+ [... state .entities ]
97+ .filter (([, v ]) => Todo .is (v ))
98+ .sort (([, a ], [, b ]) => a .order - b .order )
99+ .map (([id ]) => id );
100+ ```
30101
31102** ` State ` has a standard shape.** Two exports are conventional and drive
32103conformance:
@@ -106,21 +177,28 @@ export const entity = ConformanceApi.entity;
106177``` ts
107178// create-todo.ts
108179import { Match } from " @adobe/data-testing" ;
109- import type { Conformance } from " ./conformance-case.js" ; // the thin per-feature alias above
110- import type { Services } from " ../../services/services.js" ; // the feature's service map
180+ import type { Conformance } from " ./conformance-case.js" ; // the thin per-feature alias above
181+ import type { Services } from " ../../services/services.js" ; // the feature's service map
111182export const createTodo = (
112- state : Pick <State , " todos " >,
113- { name , complete , analytics }: { name: string ; complete ? : boolean } & Pick <Services , " analytics" >,
114- ): Pick <State , " todos " > => {
183+ state : Pick <State , " entities " >,
184+ { name , analytics }: { name: string } & Pick <Services , " analytics" >,
185+ ): Pick <State , " entities " > => {
115186 analytics .todoCreated ({ name });
116- return { todos: [... state .todos , { name , complete: complete ?? false }] }; // writes patch only
187+ // The spec mints the id (the map key) and the order; the value carries neither an id
188+ // nor anything the ECS allocates — identity is the key.
189+ const id = Math .max (0 , ... state .entities .keys ()) + 1 ;
190+ const order = state .entities .size ;
191+ return { entities: new Map (state .entities ).set (id , { name , complete: false , order }) };
117192};
118193
119194export const cases: Conformance <typeof createTodo > = [
120- { name: " appends the first todo" ,
121- before: {}, // empty delta — the default State.create()
195+ { name: " adds the first todo" ,
196+ before: {}, // empty delta — the default State.create() (empty entities)
122197 args: { name: " a" , analytics: AnalyticsService .createFake () },
123- after: { todos: [{ id: Match .anyNumber , name: " a" , complete: false }] }, // only the changed field
198+ // The map key is an id the ECS mints — use `Match.ref("label")` (a DISTINCT
199+ // label per entity). The value is id-less so content compares directly. (Maps
200+ // compare entry-wise / order-independently — see conformance.md.)
201+ after: { entities: new Map ([[Match .ref (" a" ), { name: " a" , complete: false , order: 0 }]]) },
124202 effects: { analytics: [[" todoCreated" , { name: " a" }]] } },
125203];
126204```
@@ -155,25 +233,42 @@ export const cases: Conformance<typeof createTodo> = [
155233 overrides the default wholesale.)
156234- ** ` after ` leaves minted values open** with the shared matchers ` Match.anyNumber `
157235 / ` Match.anyString ` , imported from ` @adobe/data-testing ` — there is ** no**
158- per-feature ` matchers.ts ` anymore. An entity's own numeric ` id ` is ** ignored by
159- default** (the ECS allocates it from its own id-space), so a case simply ** omits
160- ` id ` ** and the entity's content still compares — no ` id: Match.anyNumber ` needed.
161- (Reach for ` id: Match.anyNumber ` only when the type makes ` id ` required and a
162- literal would otherwise pin it.) Match by content, not by the value you don't
163- control. ` Match ` is framework-agnostic and
236+ per-feature ` matchers.ts ` anymore. An entity's identity is the ` entities ` map ** key** ,
237+ not a value field, so entity content compares directly with nothing to ignore. The
238+ key is an ECS-minted id, so use ** ` Match.ref("label") ` with a DISTINCT label per map
239+ entry** (` [Match.ref("a"), value] ` ) — ` ref ` returns a fresh object so distinct labels
240+ are distinct keys, and its injective binding both keeps entities distinct and lets an
241+ entity ** correlate** with a reference elsewhere in the case (reuse the label, e.g. a
242+ ` selectedId: Match.ref("a") ` singleton). Do ** not** use ` Match.anyNumber ` as a map key
243+ — it is a shared singleton, so two entries keyed by it collapse to one. ` entity(specId) `
244+ is for ** ` args ` ** (it resolves via the seed map), ** not** ` after ` keys — a case's ` after `
245+ entities may be freshly created, with no seed mapping. Match by content, not by a value
246+ you don't control. ` Match ` is framework-agnostic and
164247 honors any asymmetric matcher, so vitest's ` expect.stringContaining(...) ` interops
165248 on the expected side too. When an id must ** line up in two places** within one
166249 comparison — a ` selectedId ` that points at a specific todo, say — use
167250 ` Match.ref(label) ` : it asserts id * correspondence* (a bijection up to renaming),
168251 not a pinned value, so the two occurrences of the label must resolve to the same
169252 actual id and two labels can't collide. ` anyNumber ` /` anyString ` are for an id a
170253 case does not pin at all; ` ref ` for one that must be consistent across the case.
171- - ** Entity-addressed cases use ` entity(specId) ` .** A transition that addresses an
172- entity by id writes it as ` args: { id: entity(2) } ` — ` entity ` imported from the
173- feature's ` conformance-case.ts ` (re-exported from ` @adobe/data-testing ` ). It types
174- as the id it stands for (like ` Match.anyNumber ` ), so it slots into the transform's
175- own arg type. ` runSpec ` unwraps it to the plain data-id for the pure side; the ECS
176- runners resolve it to the seeded entity (see ` conformance.md ` ).
254+ - ** Entity-addressed cases use ` entity(specId) ` in ` args ` .** A transition that
255+ addresses an entity by id writes it as ` args: { id: entity(2) } ` — ` entity ` imported
256+ from the feature's ` conformance-case.ts ` (re-exported from ` @adobe/data-testing ` ). It
257+ types as the id it stands for, so it slots into the transform's own arg type. ` runSpec `
258+ unwraps it to the plain data-id for the pure side; the ECS runners resolve it (via the
259+ ` fromState ` seed map) to the seeded entity (see ` conformance.md ` ).
260+ - ** The ` entities ` map key convention across a case, in one place:**
261+ - ** ` before ` ** (the seed): ** plain spec-id numbers** (` new Map([[1, …], [2, …]]) ` ).
262+ ` fromState ` seeds from these and returns the ` spec-id → entity ` map, so `args:
263+ { id: entity(1) }` resolves to the entity seeded for ` 1`.
264+ - ** ` after ` ** (the expectation, compared against the ECS by content): ** ` Match.ref `
265+ with a distinct label per entry** (` [[Match.ref("a"), …], [Match.ref("b"), …]] ` ) —
266+ the ECS mints its own ids, so keys must be open; ` ref ` is a fresh object (distinct
267+ keys don't collapse) and injective (entities stay distinct, and a label reused in a
268+ singleton reference correlates). Never ` entity(specId) ` here — a case's ` after ` may
269+ hold freshly created entities with no seed mapping.
270+ - ** ` samples ` ** (round-tripped ` toState ∘ fromState ` ): same as ` after ` — ** ` Match.ref `
271+ distinct labels** (they compare against ECS-minted ids).
177272- No per-transform test. The single ** ` spec.test.ts ` ** is one call —
178273 ` Conformance.runSpec({ state: State, transitions }) ` importing ` transitions `
179274 from the test-only ` ./transitions.js ` (above) — that auto-discovers every module
@@ -220,8 +315,10 @@ state change and the service calls.
220315## Derivations — ` (state) => value ` , cases ` { input, value } `
221316
222317Pure selectors that ** compose the aggregate** — a value drawn from ** two or more
223- ` State ` fields** (` visibleTodos ` from ` todos ` + ` displayCompleted ` ;
224- ` currentPlayer ` from ` board ` + ` firstPlayer ` ). A value computed from a ** single**
318+ ` State ` fields** (` visibleTodos ` from ` entities ` + ` displayCompleted ` ;
319+ ` currentPlayer ` from ` board ` + ` firstPlayer ` ). An entity query like ` visibleTodos `
320+ returns entity ** ids** (` ReadonlyArray<number> ` / ` ReadonlySet<number> ` ), never the
321+ values (look those up in ` entities ` ). A value computed from a ** single**
225322` State ` field is that field's own type math and lives on its ` data/<type> `
226323namespace (` winner ` /` status ` from ` board ` → ` data/board-state ` ), tested there —
227324** not** in ` state/ ` . A feature may therefore have zero ` state/ ` derivations.
0 commit comments