@signalium/query 0.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,386 @@
1
+ ## EntityStore Design & Execution Plan
2
+
3
+ ### Goals
4
+
5
+ - **Normalized key–value store** for parsed entities and queries.
6
+ - **Set semantics**: `set(entity)` updates only that entity; do not auto-update its children. The parser is responsible for setting children separately as it continues parsing.
7
+ - **Persistence**: Storage is backed by a pluggable API capable of get/set/delete for JSON values.
8
+ - **Queries as roots**: Queries are stored in a normalized manner and act as ultimate consumers of entities.
9
+ - **Consumer counts**: Each entity tracks how many direct consumers it has. Queries are the ultimate consumers; entities may also consume other entities.
10
+ - **Cascading cleanup**: On query update or eviction, adjust consumed counts of referenced entities. If any reaches zero, remove it and recursively decrement counts of the entities it consumes.
11
+ - **LRU per query type**: Maintain an LRU cache per query type keyed by query id. Track last item pointer and count; if capacity is exceeded, evict the last.
12
+
13
+ ### High-level Architecture
14
+
15
+ - The store persists three normalized maps:
16
+
17
+ - **Entities**: `entity:{type}:{id}` → `{ value: JSON, consumerCount: number, consumes: EntityKey[] }`
18
+ - **Queries**: `query:{type}:{id}` → `{ value: JSON, consumes: EntityKey[] }`
19
+ - (Optional) **Metadata**: versioning, migration markers, etc.
20
+
21
+ - The store also maintains in-memory indexes:
22
+ - **LRU per query type**: `{ [queryType: string]: DoublyLinkedList<queryId> }` with an O(1) id→node map, `tail` pointer (last item), `head` pointer (first item), and `size`.
23
+ - **Config**: `{ maxCacheSizeByQueryType?: Record<string, number> }`.
24
+
25
+ ### Data Model
26
+
27
+ - **EntityKey**: `{ type: string; id: string }` with canonical string key `entity:${type}:${id}`.
28
+ - **QueryKey**: `{ type: string; id: string }` with canonical string key `query:${type}:${id}`.
29
+ - **EntityRecord**:
30
+ - `value: unknown` (JSON-serializable)
31
+ - `consumerCount: number` (direct consumers only)
32
+ - `consumes: EntityKey[]` (entities this entity directly references; used only for cascading deletes)
33
+ - **QueryRecord**:
34
+ - `value: unknown` (JSON-serializable)
35
+ - `consumes: EntityKey[]` (entities this query directly references)
36
+
37
+ Notes:
38
+
39
+ - Consumer counts are mutated only when queries change or are evicted. Entity sets do not modify counts; they merely persist `value` (and optionally `consumes` for cascading purposes), deferring count math to query updates.
40
+ - Cascading deletes traverse "down" edges via `consumes` lists.
41
+
42
+ ### Persistence API (injected)
43
+
44
+ The store is constructed with a persistence adapter that abstracts durable storage. Minimal interface:
45
+
46
+ ```ts
47
+ interface PersistentKV {
48
+ get<T = unknown>(key: string): Promise<T | undefined>;
49
+ set<T = unknown>(key: string, value: T): Promise<void>;
50
+ delete(key: string): Promise<void>;
51
+ // Optional optimizations
52
+ mget?<T = unknown>(keys: string[]): Promise<(T | undefined)[]>;
53
+ mset?<T = unknown>(entries: Array<{ key: string; value: T }>): Promise<void>;
54
+ mdelete?(keys: string[]): Promise<void>;
55
+ // Optional transactional boundary; if unavailable we use best-effort ordering
56
+ transaction?<T>(fn: () => Promise<T>): Promise<T>;
57
+ }
58
+ ```
59
+
60
+ The adapter stores JSON-serializable values. If `transaction` is provided, we wrap multi-key updates (e.g., query install/evict) to maintain atomicity of counts and records.
61
+
62
+ ### Public API (EntityStore)
63
+
64
+ ```ts
65
+ type JSONValue = null | boolean | number | string | JSONValue[] | { [k: string]: JSONValue };
66
+
67
+ interface EntityRef {
68
+ type: string;
69
+ id: string;
70
+ }
71
+
72
+ interface EntityStoreOptions {
73
+ kv: PersistentKV;
74
+ maxCacheSizeByQueryType?: Record<string, number>; // default 0 (no LRU) per type if absent
75
+ }
76
+
77
+ class EntityStore {
78
+ constructor(options: EntityStoreOptions);
79
+
80
+ // Entities
81
+ setEntity(ref: EntityRef, value: JSONValue, consumes?: EntityRef[]): Promise<void>; // does not change counts
82
+ getEntity(ref: EntityRef): Promise<JSONValue | undefined>;
83
+ hasEntity(ref: EntityRef): Promise<boolean>;
84
+
85
+ // Queries
86
+ setQuery(ref: EntityRef /* query type/id */, value: JSONValue, consumes: EntityRef[]): Promise<void>;
87
+ getQuery(ref: EntityRef): Promise<JSONValue | undefined>;
88
+ hasQuery(ref: EntityRef): Promise<boolean>;
89
+ evictQuery(ref: EntityRef): Promise<void>; // triggers count decrements and cascading deletions
90
+
91
+ // LRU
92
+ touchQuery(ref: EntityRef): void; // promote to MRU; called by get/setQuery
93
+ }
94
+ ```
95
+
96
+ ### Core Behaviors
97
+
98
+ #### Setting an entity
99
+
100
+ - Persist `EntityRecord` at `entity:{type}:{id}` with provided `value` and optional `consumes`.
101
+ - Do not mutate `consumerCount` (preserve existing count; default 0 if new).
102
+ - Do not walk children; parser is responsible for also setting the entities referenced by `consumes` as separate operations.
103
+
104
+ #### Installing/updating a query
105
+
106
+ - Read existing `QueryRecord` (if any) to get `prevConsumes`.
107
+ - Persist `QueryRecord` with new `value` and `consumes`.
108
+ - Compute set diff:
109
+ - `added = consumes − prevConsumes`
110
+ - `removed = prevConsumes − consumes`
111
+ - For each `added` entity: increment its `consumerCount` by 1; persist.
112
+ - For each `removed` entity: decrement its `consumerCount` by 1; if count reaches 0, delete it and cascade (see below).
113
+ - Update LRU for the query type (promote to MRU; possibly evict tail if over capacity).
114
+ - Prefer to wrap the above in a transaction if the adapter supports it.
115
+
116
+ #### Evicting a query (explicit or via LRU)
117
+
118
+ - Read the `QueryRecord` to get its `consumes` list.
119
+ - Delete the query record.
120
+ - For each consumed entity: decrement `consumerCount`; if it reaches 0, delete the entity and cascade.
121
+ - Update LRU structures (remove node; adjust size and tail/head as needed).
122
+
123
+ #### Cascading delete
124
+
125
+ - When an entity `E` is removed due to `consumerCount` reaching 0:
126
+ 1. Read `E.consumes`.
127
+ 2. Delete `E`.
128
+ 3. For each entity `C` in `E.consumes`:
129
+ - Decrement `C.consumerCount`.
130
+ - If `C.consumerCount` reaches 0, repeat recursively for `C`.
131
+
132
+ Notes:
133
+
134
+ - Only direct consumer counts are stored; cascades traverse down the `consumes` edges.
135
+ - Entity updates (via `setEntity`) do not affect counts.
136
+
137
+ ### LRU Cache per Query Type
138
+
139
+ - For each query `type`, maintain an in-memory doubly-linked list with:
140
+ - `head` (MRU), `tail` (LRU/last item), `size`, `capacity` (from config), and a map `id→node`.
141
+ - On `getQuery`/`setQuery`/`touchQuery`:
142
+ - If node exists, move to `head`.
143
+ - If new, create node at `head`, increment `size`.
144
+ - If `size > capacity`, evict `tail` (call `evictQuery` for that id) and update `tail` pointer and `size`.
145
+ - The spec requires a pointer to the last item and the current size; we will also track `head` for O(1) promotion.
146
+ - LRU state is memory-only (non-persistent) by default. See open questions.
147
+
148
+ ### Complexity Targets
149
+
150
+ - `setEntity`: O(1) persistence; O(k) if writing `consumes` with k entries.
151
+ - `setQuery`: O(a + r + log(1)) ≈ O(a + r) for added/removed refs; O(1) LRU updates.
152
+ - `evictQuery`: O(c) for consumed entities; O(1) LRU updates.
153
+ - `cascadeDelete`: O(total entities traversed).
154
+
155
+ ### Failure & Consistency
156
+
157
+ - If `transaction` exists, wrap multi-step updates to avoid partial count diverge.
158
+ - Without transactions, operations are ordered to keep store mostly-consistent; on crash mid-operation, counts may temporarily diverge. Recovery strategies (optional):
159
+ - On boot, scan queries and recompute counts from `consumes`.
160
+ - Or store an operation journal to replay.
161
+
162
+ ### Edge Cases
163
+
164
+ - Re-setting an entity without `consumes`: preserve existing `consumes` unless explicitly clearing.
165
+ - Queries with duplicate refs: deduplicate before diffing.
166
+ - Negative counts are prevented by clamping at 0 and logging diagnostics.
167
+ - Entities may be referenced by multiple query types and queries.
168
+
169
+ ### Telemetry & Debuggability (optional but recommended)
170
+
171
+ - Toggleable debug logging for count increments/decrements, cascade paths, and evictions.
172
+ - Introspection methods: dump counts for an entity, list of consumers, and query LRU snapshots.
173
+
174
+ ### Implementation Plan
175
+
176
+ 1. Define types/interfaces: `EntityRef`, `EntityRecord`, `QueryRecord`, `PersistentKV`, configuration.
177
+ 2. Implement key encoders/decoders: `toEntityKey`, `toQueryKey`.
178
+ 3. Implement persistence helpers: get/set/delete entity/query records with schema defaults.
179
+ 4. Implement LRU structure per query type with O(1) ops and capacity enforcement.
180
+ 5. Implement core API:
181
+ - `setEntity`
182
+ - `setQuery` with diffing and count updates
183
+ - `evictQuery` with cascading delete
184
+ - `getEntity`, `getQuery`, `hasEntity`, `hasQuery`, `touchQuery`
185
+ 6. Add optional transaction wrapper around multi-step operations.
186
+ 7. Add tests:
187
+ - Setting entities does not alter counts
188
+ - Query install increments counts (added), decrements counts (removed)
189
+ - Evict query decrements counts and cascades
190
+ - Cascade deletion removes deep chains
191
+ - LRU promotes on access and evicts over capacity
192
+ - Idempotency and deduplication
193
+ 8. Add diagnostics (optional): debug logs and introspection.
194
+
195
+ ### Assumptions
196
+
197
+ - Parser is authoritative for determining `consumes` lists for queries and entities.
198
+ - Only query operations change consumer counts. Entity updates do not.
199
+ - Cascading deletion traverses via `consumes` (downstream dependencies), not via back-references.
200
+ - LRU is in-memory and not persisted. On process restart, LRU state is rebuilt lazily as queries are accessed.
201
+
202
+ ### Open Questions for Clarification
203
+
204
+ - Should LRU promotion occur on both `getQuery` and `setQuery`, or only on `setQuery`?
205
+ - Should LRU state persist across sessions (e.g., persisted order), or is in-memory sufficient?
206
+ - Confirm that entity `consumes` should be recorded at `setEntity` time purely for cascading deletion, but should not trigger count changes by themselves.
207
+ - Should we support a batch API for installing a query and its entities in one transactional call from the parser?
208
+ - Error handling policy for missing entities referenced by a query: ignore, auto-create with count 0, or fail?
209
+ - Are there query-to-query dependencies, or are queries always roots only?
210
+ - Maximum default LRU capacity per query type if unspecified? (e.g., 0=no limit, 100, etc.)
211
+
212
+ ### Non-Goals (for now)
213
+
214
+ - Cross-tab synchronization of counts and LRU across multiple runtimes.
215
+ - Conflict resolution for concurrent writers without transactional storage.
216
+ - Versioned entities or time-travel.
217
+
218
+ ### Example Key Shapes
219
+
220
+ - Entity key: `entity:user:123`
221
+ - Query key: `query:UserById:123`
222
+
223
+ ### Example Flows (informal)
224
+
225
+ 1. Install query Q referencing A and B (A consumes C):
226
+
227
+ - Set entities A, B, C with values and `consumes` (A.consumes=[C]).
228
+ - setQuery(Q, value, consumes=[A, B]): increment counts(A, B).
229
+ - LRU touch Q.
230
+
231
+ 2. Update query Q to reference only B:
232
+
233
+ - setQuery(Q, value2, consumes=[B]): decrement counts(A) → 0 → delete A → decrement counts(C) → maybe 0 → delete C.
234
+ - LRU touch Q.
235
+
236
+ 3. LRU eviction of query Q when capacity exceeded:
237
+
238
+ - evictQuery(Q): decrement counts of its entities and cascade; remove Q; adjust LRU tail/size.
239
+
240
+ ### Testing Plan
241
+
242
+ Test framework: Vitest (repo already uses it). Use `InMemoryKV` for default persistence, and add targeted tests for adapters that support `transaction` vs. those that do not (simulated by omitting it).
243
+
244
+ - Unit test scaffolding
245
+
246
+ - **Helpers**: build `ref(type,id)` for `EntityRef`, `entRec(value, consumes, consumerCount)` factory, and `setup(capacityByType?)` that returns a fresh `EntityStore` with `InMemoryKV`.
247
+ - **Fixtures**: simple entity graphs: A→[C], B (no children), C (no children), D→[E, F], E, F.
248
+
249
+ - Entities behavior
250
+
251
+ - **setEntity does not modify counts**: set A with consumes [C]; assert A.consumerCount remains 0; C unaffected.
252
+ - **get/has entity**: set then get/has; nonexistent returns undefined/false.
253
+ - **preserve count on overwrite**: set A, then set A with new value and consumes; verify count unchanged.
254
+
255
+ - Queries install/update
256
+
257
+ - **install increments counts**: set A, B, then setQuery Q consumes [A, B]; assert A.count=1, B.count=1.
258
+ - **update diffing**: Q from [A, B] → [B]; A decremented to 0 and deleted; B remains 1.
259
+ - **idempotency**: setQuery Q with same consumes twice; counts remain stable (no double-increment).
260
+ - **deduplication**: setQuery with duplicate refs [A, A, B]; counts A=1, B=1.
261
+
262
+ - Query eviction
263
+
264
+ - **explicit evict**: install Q [A]; evictQuery(Q); A count→0, A deleted; cascades as needed.
265
+ - **LRU-triggered evict**: capacity=1 for type T; setQuery Q1, then Q2; Q1 evicted, counts adjusted.
266
+
267
+ - Cascading delete
268
+
269
+ - **single-level**: A consumes [C]; Q consumes [A]; update Q to []; expect A and C removed.
270
+ - **multi-level chain**: A→B→C, Q consumes [A]; removing Q deletes A, decrements B then C, both removed when counts hit 0.
271
+ - **shared child**: A→[C], B→[C], Q1 consumes [A], Q2 consumes [B]; evict Q1 keeps C (count from B path), evict Q2 then removes C.
272
+
273
+ - LRU per query type
274
+
275
+ - **promotion on get/set**: confirm `touchQuery` runs via `getQuery` and `setQuery` by observing eviction order after accesses.
276
+ - **per-type isolation**: capacity per type; ensure evictions for type T do not affect type U.
277
+ - **last pointer/size correctness**: after a series of touches/evictions, assert `size` and `last` match expectations.
278
+
279
+ - Transactional consistency
280
+
281
+ - **with transaction**: wrap kv in an adapter providing `transaction`; during `setQuery`, assert that if an injected failure occurs inside the transaction (throw), all changes are rolled back (query not persisted, counts unchanged).
282
+ - **without transaction**: simulate failure after query persisted but before count updates; on next startup (optional utility), recompute counts from queries (if recovery tool exists) or assert partial state is acceptable per assumptions.
283
+
284
+ - Error/edge handling
285
+
286
+ - **missing entity in consumes**: setQuery referencing entity not present; increment creates a placeholder record with count=1 and value=null; subsequent setEntity fills value but does not disrupt count.
287
+ - **negative count clamping**: multiple removals cannot drive counts below 0.
288
+ - **empty consumes**: installing query with [] removes prior references, no residual entities.
289
+ - **large consumes**: performance sanity (non-exhaustive): install query with 1k entities; ensure operation completes and counts are correct.
290
+
291
+ - API invariants
292
+
293
+ - **touch side-effects**: `getQuery` touches LRU but does not change store contents.
294
+ - **hasQuery/hasEntity**: do not mutate LRU or counts.
295
+ - **get non-existent query**: returns undefined and does not create LRU entries.
296
+
297
+ - Optional property tests (if time permits)
298
+
299
+ - Generate random DAGs of entities and random query consume sets; after arbitrary sequences of installs/updates/evictions, assert: (1) counts equal number of queries (and entities) that directly consume each entity; (2) no entity exists with count=0; (3) removing all queries removes all entities.
300
+
301
+ - Concurrency simulation (coarse)
302
+
303
+ - Interleave `setQuery` and `evictQuery` on different queries referencing overlapping entities; assert final counts match expected math; verify no negative counts and no orphaned zero-count entities.
304
+
305
+ - Snapshot/inspection (if debug hooks added)
306
+ - Use debug dumps to snapshot LRU and entity maps before/after operations to assert structural correctness without relying on internal private fields.
307
+
308
+ ### Parser Integration & Execution Plan
309
+
310
+ What exists in `packages/query/src/parser.ts`:
311
+
312
+ - **Validator framework**: `Validator<T>` with `parse`/`serialize`, nullable/optional/nullish wrappers, and primitive/object/array/tuple/record combinators. Also `entity(...)` helper to declare entity-shaped validators and `schema(...)`, `query(...)`, `mutation(...)` placeholders.
313
+ - **ParseContext** skeleton:
314
+ - `shouldStoreEntities: boolean`
315
+ - `errors: ValidationError[]`
316
+ - `entityStore: EntityStore`
317
+ - `formatRegistry`
318
+ - `config: { getEntityKey(entity: Record<string, unknown>): string }`
319
+ - A minimal `EntityStore` interface in `parser.ts` (string-keyed get/set) used as a placeholder.
320
+
321
+ What is needed to finish the parser and integrate with the new `EntityStore`:
322
+
323
+ - **Adopt the new store API**: Replace the local `EntityStore` placeholder with imports from `src/entity-store.ts` and `src/persistence.ts` (`EntityStore`, `EntityRef`, `JSONValue`). The parser must call:
324
+
325
+ - `setEntity(ref, value, consumes)` for every encountered entity.
326
+ - `setQuery(ref, value, consumes)` once per query response.
327
+
328
+ - **Entity identification**:
329
+
330
+ - Use `config.getEntityKey` to compute a unique id for any object validated by an `entity(...)` validator. Split the id into `(type, id)`; or extend config to return `{ type, id }`. If only a single string is returned, define parsing rules (e.g., `"type:id"`).
331
+ - Create `EntityRef { type, id }` and record it.
332
+
333
+ - **Graph extraction (consumes edges)**:
334
+
335
+ - During parsing, whenever an `entity(...)` validator successfully parses an object `E`, record:
336
+ - `ERef = { type, id }` for `E`.
337
+ - `EConsumes: EntityRef[]` containing direct child entities encountered while parsing `E`’s fields.
338
+ - Maintain a traversal stack and a `seen` set (by entity key) to avoid cycles and duplicate processing.
339
+
340
+ - **Accumulation**:
341
+
342
+ - Build an in-memory map `entityKey → { value: JSONValue, consumes: EntityRef[] }` for all entities found in the response payload.
343
+ - Build `queryConsumes: EntityRef[]` for top-level entities referenced directly by the query result (roots from this response).
344
+
345
+ - **Persistence**:
346
+
347
+ - If `ParseContext.shouldStoreEntities` is true and there are no validation errors, persist:
348
+ 1. `setEntity` for each entity map entry (value as plain JSON; not proxies/signals), with its `consumes`.
349
+ 2. `setQuery` for the query ref and payload (also plain JSON), with `queryConsumes`.
350
+ - If the underlying KV supports transactions, optionally wrap the whole write (entities then query) in `kv.transaction` via `EntityStore` orchestration.
351
+
352
+ - **Validation and errors**:
353
+
354
+ - Accumulate `errors` during validation; if non-empty, do not write to the store.
355
+ - Provide clear `path` information in `ValidationError` as traversals descend.
356
+
357
+ - **LRU**: No direct parser interaction is required; `EntityStore.setQuery` triggers LRU updates.
358
+
359
+ - **Serialization**:
360
+
361
+ - Parsers should produce plain JSON-serializable data for the store. If runtime objects (e.g., Dates via formatters) are produced during parsing, ensure a serializer is available if persisted, or convert to a JSON representation prior to `setEntity`/`setQuery`.
362
+
363
+ - **API surface additions for parser**:
364
+ - `ParseContext.config.getEntityRef?: (value: Record<string, unknown>) => EntityRef` preferred over string key; keep `getEntityKey` as a fallback for backwards compatibility.
365
+ - Optional `onEntity?(ref: EntityRef, value: JSONValue): void` hook for instrumentation/testing.
366
+
367
+ Implementation steps in `parser.ts`:
368
+
369
+ 1. Replace the local `EntityStore` interface with imports from the new implementation and update `ParseContext` types accordingly.
370
+ 2. Extend configuration to accept `getEntityRef` (or encode/decode of string keys into `{ type, id }`).
371
+ 3. Instrument `Validator` combinators to propagate entity discovery context:
372
+ - `entity(...)` must: (a) detect `EntityRef`, (b) push/pop an entity collection frame, (c) return parsed value while recording child entity refs found during recursive parsing of its fields.
373
+ 4. Implement traversal helpers:
374
+ - `collectEntities(value, validator, ctx): { entities: Map<string, { value, consumes }>, roots: EntityRef[] }`.
375
+ - Use a `seen` set keyed by `entity:${type}:${id}` to avoid re-parsing duplicates in the same payload.
376
+ 5. Implement `parseQuery(queryRef, validator, response, ctx)`:
377
+ - Validate and collect entities/roots.
378
+ - If `shouldStoreEntities` and no `errors`, persist via `EntityStore` (entities first, then query) in a transaction if available.
379
+ 6. Ensure `getQuery`/`getEntity` are not used by the parser during writing; the parser should be write-only to avoid accidentally hydrating stale values.
380
+ 7. Add tests per Testing Plan for parser integration flows (entity detection, consumes extraction, store writes, and error cases).
381
+
382
+ Open questions for parser:
383
+
384
+ - Should `getEntityKey` definitively encode both `type` and `id`? If not, provide another config `getEntityRef` returning both.
385
+ - Should parsed values be stored exactly as parsed (possibly with Date objects) or JSON-serialized first? Current store assumes JSON values.
386
+ - How to handle entities discovered multiple times within the same query result (first write wins vs. must be identical)? We will deduplicate and prefer the first encountered value for now.
package/package.json ADDED
@@ -0,0 +1,71 @@
1
+ {
2
+ "name": "@signalium/query",
3
+ "version": "0.0.0",
4
+ "type": "module",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "git+https://github.com/Signalium/signalium.git"
8
+ },
9
+ "description": "Chain-reactivity at critical mass",
10
+ "main": "./dist/cjs/index.js",
11
+ "module": "./dist/esm/index.js",
12
+ "types": "./dist/types/index.d.ts",
13
+ "exports": {
14
+ ".": {
15
+ "types": "./dist/types/index.d.ts",
16
+ "import": {
17
+ "development": "./src/index.ts",
18
+ "default": "./dist/esm/index.js"
19
+ },
20
+ "require": {
21
+ "default": "./dist/cjs/index.js"
22
+ }
23
+ },
24
+ "./package.json": "./package.json"
25
+ },
26
+ "peerDependencies": {
27
+ "react": ">=18.3.1",
28
+ "signalium": ">=2.0.6"
29
+ },
30
+ "peerDependenciesMeta": {
31
+ "react": {
32
+ "optional": true
33
+ }
34
+ },
35
+ "scripts": {
36
+ "dev": "vitest dev",
37
+ "dev:unit": "vitest dev --project unit",
38
+ "dev:transform": "vitest dev --project transform",
39
+ "dev:react": "vitest dev --project react",
40
+ "test": "vitest run",
41
+ "test:unit": "vitest run --project unit",
42
+ "test:transform": "vitest run --project transform",
43
+ "test:react": "vitest run --project react",
44
+ "check-types": "tsc --noEmit",
45
+ "build": "npm run build:esm && npm run build:cjs",
46
+ "build:esm": "tsc --build tsconfig.esm.json",
47
+ "build:cjs": "tsc --build tsconfig.cjs.json"
48
+ },
49
+ "author": "",
50
+ "license": "ISC",
51
+ "dependencies": {
52
+ "decoders": "^2.7.1"
53
+ },
54
+ "devDependencies": {
55
+ "@babel/core": "^7.23.6",
56
+ "@babel/preset-react": "^7.23.3",
57
+ "@vitejs/plugin-react": "^5.0.0",
58
+ "@vitest/browser": "^3.0.6",
59
+ "playwright": "^1.50.1",
60
+ "react": "^18.3.1",
61
+ "react-dom": "^18.3.1",
62
+ "rollup-plugin-const-enum": "^1.1.4",
63
+ "signalium": "2.0.6",
64
+ "vite": "^7.1.2",
65
+ "vite-plugin-babel": "^1.3.0",
66
+ "vite-plugin-dts": "^4.5.4",
67
+ "vite-tsconfig-paths": "^5.1.4",
68
+ "vitest": "^3.2.4",
69
+ "vitest-browser-react": "^1.0.1"
70
+ }
71
+ }
@@ -0,0 +1,63 @@
1
+ import { Signal, signal } from 'signalium';
2
+ import { EntityDef } from './types.js';
3
+ import { createEntityProxy } from './proxy.js';
4
+
5
+ export interface PreloadedEntityRecord {
6
+ key: number;
7
+ signal: Signal<Record<string, unknown>>;
8
+ cache: Map<PropertyKey, any>;
9
+ proxy?: Record<string, unknown>;
10
+ }
11
+
12
+ export type EntityRecord = Required<PreloadedEntityRecord>;
13
+
14
+ export class EntityStore {
15
+ private map = new Map<number, PreloadedEntityRecord | EntityRecord>();
16
+
17
+ hasEntity(key: number): boolean {
18
+ return this.map.has(key);
19
+ }
20
+
21
+ getEntity(key: number): PreloadedEntityRecord | EntityRecord | undefined {
22
+ return this.map.get(key);
23
+ }
24
+
25
+ hydratePreloadedEntity(key: number, shape: EntityDef): EntityRecord {
26
+ const record = this.getEntity(key);
27
+ if (record === undefined) {
28
+ throw new Error(`Entity ${key} not found`);
29
+ }
30
+
31
+ record.proxy = createEntityProxy(key, record, shape);
32
+
33
+ return record as EntityRecord;
34
+ }
35
+
36
+ setPreloadedEntity(key: number, obj: Record<string, unknown>): PreloadedEntityRecord {
37
+ const record: PreloadedEntityRecord = {
38
+ key,
39
+ signal: signal(obj),
40
+ cache: new Map(),
41
+ proxy: undefined,
42
+ };
43
+
44
+ this.map.set(key, record);
45
+
46
+ return record;
47
+ }
48
+
49
+ setEntity(key: number, obj: Record<string, unknown>, shape: EntityDef): EntityRecord {
50
+ let record = this.map.get(key);
51
+
52
+ if (record === undefined) {
53
+ record = this.setPreloadedEntity(key, obj);
54
+
55
+ record.proxy = createEntityProxy(key, record, shape);
56
+ } else {
57
+ record.signal.value = obj;
58
+ record.cache.clear();
59
+ }
60
+
61
+ return record as EntityRecord;
62
+ }
63
+ }