@marianmeres/ownsuite 1.0.3 → 2.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.
package/AGENTS.md CHANGED
@@ -6,7 +6,7 @@ Machine-readable documentation for AI coding assistants.
6
6
 
7
7
  ```yaml
8
8
  name: "@marianmeres/ownsuite"
9
- version: "1.0.0"
9
+ version: "2.0.0"
10
10
  type: "library"
11
11
  language: "typescript"
12
12
  runtime: "deno"
@@ -27,17 +27,40 @@ Pairs with:
27
27
 
28
28
  ```
29
29
  Ownsuite (orchestrator)
30
- ├── #pubsub (shared event bus)
30
+ ├── #pubsub (shared event bus, cleared on destroy)
31
31
  ├── #context (propagated to all domains on setContext)
32
32
  └── domains: Map<string, OwnedCollectionManager>
33
- ├── store (Svelte-compatible DomainStateWrapper<OwnedCollectionState<TRow>>)
34
- ├── adapter (OwnedCollectionAdapter)
35
- ├── state machine: initializing → ready ↔ syncing → error
36
- └── optimistic update + rollback on create/update/delete
33
+ ├── store (Svelte-compatible DomainStateWrapper<OwnedCollectionState<TRow>>)
34
+ ├── adapter (OwnedCollectionAdapter)
35
+ ├── state machine: initializing → ready ↔ syncing → error
36
+ ├── optimistic update + per-row rollback on update/delete
37
+ ├── mutation chain (serial create/update/delete)
38
+ ├── abort-supersede (initialize/refresh — newer call aborts older)
39
+ └── destroy() (aborts in-flight ops, drops adapter)
37
40
  ```
38
41
 
39
42
  Each domain holds one list of rows. List operations replace the list wholesale; single-row ops mutate it in place so subscribers see stable references.
40
43
 
44
+ ### Concurrency model
45
+
46
+ - **Mutations serialize** per manager via an internal promise chain. A
47
+ `create/update/delete` that starts while another is in-flight queues
48
+ behind it; callers still receive their own result through the returned
49
+ promise. Rejections on the chain are swallowed so they do not block
50
+ later mutations.
51
+ - **Reads abort-supersede**: a new `initialize()` or `refresh()` aborts
52
+ any in-flight read on the same manager. The aborted call resolves
53
+ without writing to the store.
54
+ - **onSuccess uses live data, not a captured snapshot**, so interleaving
55
+ reads and mutations never resurrect deleted rows or clobber writes.
56
+ - **Rollback is per-row**: a failed `update` reverts just the updated
57
+ row; a failed `delete` re-inserts the deleted row at its original
58
+ position. An interleaved refresh that brought new rows is preserved.
59
+ - **AbortSignal plumbing**: every adapter call receives
60
+ `ctx.signal: AbortSignal`. `reset()` and `destroy()` abort all active
61
+ signals. Adapters should forward the signal to `fetch()` — ignoring
62
+ it is safe but leaves abandoned requests running.
63
+
41
64
  ## Directory Structure
42
65
 
43
66
  ```
@@ -65,7 +88,9 @@ tests/
65
88
  ```typescript
66
89
  // Main
67
90
  export { Ownsuite, createOwnsuite } from "./ownsuite.ts";
68
- export type { OwnsuiteConfig, OwnsuiteDomainConfig } from "./ownsuite.ts";
91
+ export type {
92
+ OwnsuiteConfig, OwnsuiteDomainConfig, SetContextOptions,
93
+ } from "./ownsuite.ts";
69
94
 
70
95
  // Domain managers
71
96
  export { BaseDomainManager, OwnedCollectionManager } from "./domains/mod.ts";
@@ -102,15 +127,23 @@ Triggered by `initialize()`, `refresh()`, `create()`, `update()`, `delete()` on
102
127
 
103
128
  1. **Client NEVER sets `owner_id`.** The server stamps it from the authenticated JWT via `@marianmeres/collection`'s `ownerIdExtractor`. Including `owner_id` in a create/update payload will be rejected (belt-and-braces) or silently ignored (immutability guarantee on update).
104
129
 
105
- 2. **Ownership mismatches return 404, not 403.** When the server's `ownerIdScope` rejects access to a foreign row, it responds 404 to avoid leaking row existence. Adapter implementations must not treat 404 as a soft miss they must throw so the manager transitions to `error` state for unexpected 404s on previously-visible rows.
130
+ 2. **Ownership mismatches return 404, not 403.** When the server's `ownerIdScope` rejects access to a foreign row, it responds 404 to avoid leaking row existence. For `list`/`refresh`, adapters must throw the manager transitions to `error`. For `getOne`, a throw lands only in a returned `null` (see invariant 7).
131
+
132
+ 3. **Row ids default to `model_id`, fallback `id`.** Override via `getRowId` in `OwnsuiteDomainConfig` or `OwnedCollectionManagerOptions` when rows have a different key shape. Empty string is rejected.
133
+
134
+ 4. **`initialize()` never rejects.** Per-domain errors land in that domain's `error` state; the top-level promise resolves. Use `suite.hasErrors()` / `suite.errors()` to detect failed boots, or subscribe to `domain:error`.
135
+
136
+ 5. **Optimistic updates roll back per-row on failure.** `update` mutates the single target row; on error that row reverts to its pre-call value. `delete` removes the target row; on error it is re-inserted at its original position (unless another op has since re-added it). `create` does NOT optimistically insert. Rollback reads the *live* store so an interleaved `refresh()` that brought new rows is preserved.
137
+
138
+ 6. **`OwnsuiteContext.subjectId` is a hint, not authorization.** The server is authoritative. Setting it client-side has no security effect. When subject changes, call `suite.setContext(ctx, { replace: true, refresh: true })` to clear stale per-subject caches.
106
139
 
107
- 3. **Row ids default to `model_id`, fallback `id`.** Override via `getRowId` in `OwnsuiteDomainConfig` or `OwnedCollectionManagerOptions` when rows have a different key shape.
140
+ 7. **`getOne()` does NOT transition the domain to `error`.** A failing single-row read (commonly a 404 for an un-owned row) returns `null` and emits nothing. The list state is preserved.
108
141
 
109
- 4. **`initialize()` never rejects.** Per-domain errors land in that domain's `error` state; the top-level promise resolves. Callers that need failure detection should subscribe to `domain:error` events or inspect `manager.get().state`.
142
+ 8. **`update(id)` for a row absent from the cached list does NOT insert.** A missing index means the row was filtered out or never loaded the successful server response is acknowledged (`own:row:updated` is emitted) but the list remains untouched. Call `refresh()` to surface the row.
110
143
 
111
- 5. **Optimistic updates roll back on failure.** `update` and `delete` mutate the list before the server call; on error the list is restored to its pre-call snapshot and the manager transitions to `error`. `create` does NOT optimistically insert (no client-assigned id) — it only inserts after the server returns.
144
+ 9. **Mutations serialize; reads abort-supersede.** Within a single manager, `create/update/delete` run one-at-a-time in call order. A newer `initialize/refresh` aborts an older one (the older call becomes a no-op).
112
145
 
113
- 6. **`OwnsuiteContext.subjectId` is a hint, not authorization.** The server is authoritative. Setting it client-side has no security effect.
146
+ 10. **`ctx.signal` is present on every adapter call.** Adapters should forward it to `fetch()`. Signals abort on `reset()`, `destroy()`, and read-supersede.
114
147
 
115
148
  ## Common Patterns
116
149
 
@@ -152,6 +185,29 @@ suite.on("domain:error", (e) => {/* e.error */});
152
185
  suite.onAny(({ event, data }) => {/* wildcard envelope */});
153
186
  ```
154
187
 
188
+ ### Detecting boot failures
189
+
190
+ ```typescript
191
+ await suite.initialize();
192
+ if (suite.hasErrors()) {
193
+ const errs = suite.errors(); // { [domainName]: DomainError }
194
+ // route to error UI, log, retry, ...
195
+ }
196
+ ```
197
+
198
+ ### Switching subject mid-session
199
+
200
+ ```typescript
201
+ // Clears the previous subject's context keys and re-fetches every domain.
202
+ suite.setContext({ subjectId: newId }, { replace: true, refresh: true });
203
+ ```
204
+
205
+ ### Cleanup
206
+
207
+ ```typescript
208
+ suite.destroy(); // aborts in-flight requests, unsubscribes pubsub, drops adapters
209
+ ```
210
+
155
211
  ### Implementing a real adapter
156
212
 
157
213
  ```typescript
@@ -159,14 +215,14 @@ import type { OwnedCollectionAdapter } from "@marianmeres/ownsuite";
159
215
  import { HTTP_ERROR } from "@marianmeres/http-utils";
160
216
 
161
217
  const adapter: OwnedCollectionAdapter = {
162
- async list(_ctx, query) {
218
+ async list(ctx, query) {
163
219
  const url = new URL(`/api/shop/me/col/order/mod`, location.origin);
164
220
  if (query) for (const [k, v] of Object.entries(query)) url.searchParams.set(k, String(v));
165
- const res = await fetch(url);
221
+ const res = await fetch(url, { signal: ctx.signal }); // forward abort
166
222
  if (!res.ok) throw new HTTP_ERROR.BadRequest(await res.text());
167
223
  return await res.json(); // { data, meta }
168
224
  },
169
- // getOne, create, update, delete similarly
225
+ // getOne, create, update, delete similarly — always forward ctx.signal
170
226
  };
171
227
  ```
172
228
 
@@ -234,10 +290,14 @@ dev:
234
290
  ## Testing
235
291
 
236
292
  ```bash
237
- deno task test # run all tests (10 tests)
293
+ deno task test # run all tests (26 tests across ownsuite.test.ts + concurrency.test.ts)
238
294
  deno task test:watch # watch mode
239
295
  ```
240
296
 
297
+ `tests/concurrency.test.ts` covers the critical invariants: concurrent
298
+ mutations, abort-supersede, getOne-not-setting-error, phantom-row
299
+ prevention, destroy semantics, and the errors()/hasErrors() helpers.
300
+
241
301
  ## Build & Publish
242
302
 
243
303
  ```bash
@@ -277,6 +337,52 @@ Joy ships:
277
337
 
278
338
  See the full-stack-app-template repo for the end-to-end example.
279
339
 
340
+ ## Breaking changes in 2.0.0
341
+
342
+ The 1.x line has one open set of correctness bugs and a permissive API
343
+ that leaked state into domain errors on non-list operations. 2.0.0 fixes
344
+ those; the behaviors changed are:
345
+
346
+ 1. **`getOne()` no longer transitions the domain to `error`.** Previously
347
+ any adapter throw from `getOne` set `state: "error"` on the whole
348
+ domain, invalidating a healthy list view. Now it returns `null` and
349
+ logs at debug level. Callers relying on the error-state transition
350
+ must subscribe differently (wrap `getOne` or inspect adapter errors
351
+ directly).
352
+
353
+ 2. **`update(id, ...)` for an id absent from the cached list no longer
354
+ prepends a phantom row** on successful server response. The server
355
+ update is still applied (and `own:row:updated` emitted), but the list
356
+ stays as-is. Call `refresh()` to surface the row. Previously the row
357
+ was inserted at the top of the list.
358
+
359
+ 3. **`OwnsuiteContext.signal` is now populated by the manager on every
360
+ adapter call.** Adapters that declared `ctx: OwnsuiteContext` see no
361
+ compile break (the field was already allowed via the index
362
+ signature); adapters that want cancellation should now forward
363
+ `ctx.signal` to `fetch()`. Adapters that ignore it continue to work.
364
+
365
+ 4. **`createMockOwnedCollectionAdapter` rejects `create` payloads
366
+ containing `model_id`** by default. Tests that were relying on
367
+ passing a `model_id` at create time must either drop the field or
368
+ opt out via `rejectClientId: false` in the options. Rows with an
369
+ empty-string `model_id` in `seed` are also rejected.
370
+
371
+ 5. **Rollback is now per-row, not whole-list.** Behavioral semantics
372
+ are stricter: a failed `update` reverts only the updated row; a
373
+ failed `delete` re-inserts only the deleted row. If your app relied
374
+ on the whole-list-restore side effect (e.g., to drop rows added by
375
+ a concurrent refresh that raced with a failing mutation), note this
376
+ subtle shift.
377
+
378
+ 6. **`reset()` now emits `domain:state:changed`** for each domain that
379
+ transitions out of a non-initializing state. Subscribers that count
380
+ events may see more of them.
381
+
382
+ Non-breaking additions: `suite.destroy()`, `suite.errors()`,
383
+ `suite.hasErrors()`, `suite.setContext(ctx, { replace, refresh })`,
384
+ `manager.isDestroyed`, `manager.replaceContext(ctx)`.
385
+
280
386
  ## Differences from `@marianmeres/ecsuite`
281
387
 
282
388
  | Aspect | ecsuite | ownsuite |
package/API.md CHANGED
@@ -98,17 +98,44 @@ Initialize all registered domains (or a subset). Runs in parallel. Individual do
98
98
 
99
99
  **Returns:** `Promise<void>`
100
100
 
101
- #### `suite.setContext(ctx)`
101
+ #### `suite.setContext(ctx, options?)`
102
102
 
103
- Merge `ctx` into the shared context and propagate to every registered domain manager.
103
+ Update the shared context and propagate to every registered domain manager.
104
104
 
105
105
  **Parameters:**
106
106
  - `ctx` (`OwnsuiteContext`)
107
+ - `options` (`SetContextOptions`, optional)
108
+ - `options.replace` (`boolean`, default `false`) — replace the context wholesale instead of merging. Use this when the subject changes and previous per-subject keys must not leak into adapter calls.
109
+ - `options.refresh` (`boolean`, default `false`) — fire-and-forget `refresh()` on every domain after the context change. Recommended when `subjectId` changes so stale per-subject caches are cleared.
110
+
111
+ **Example:**
112
+ ```typescript
113
+ // Subject change: drop old context + re-fetch every domain
114
+ suite.setContext({ subjectId: newId }, { replace: true, refresh: true });
115
+ ```
107
116
 
108
117
  #### `suite.getContext(): OwnsuiteContext`
109
118
 
110
119
  Snapshot of current shared context.
111
120
 
121
+ #### `suite.errors(): Record<string, DomainError>`
122
+
123
+ Map of currently-errored domains to their `DomainError`. Empty if none are in error state. Use after `initialize()` to detect silent boot failures.
124
+
125
+ #### `suite.hasErrors(): boolean`
126
+
127
+ True if any domain is currently in `error` state.
128
+
129
+ #### `suite.destroy()`
130
+
131
+ Dispose of the suite: destroys every registered domain (which aborts in-flight adapter requests), clears the domain map, and unsubscribes every listener attached to the internal pubsub. Safe to call multiple times.
132
+
133
+ Subsequent method calls are best-effort no-ops (e.g., `initialize()` returns immediately, `setContext()` ignores the call). `registerDomain()` throws after destroy.
134
+
135
+ #### `suite.isDestroyed: boolean`
136
+
137
+ True after `destroy()` has been called.
138
+
112
139
  #### `suite.on(type, subscriber)`
113
140
 
114
141
  Subscribe to a specific event type.
@@ -177,7 +204,9 @@ Re-fetch the list. Same as `initialize` but re-entrant; accepts an adapter-speci
177
204
 
178
205
  #### `manager.getOne(id): Promise<TRow | null>`
179
206
 
180
- Fetch a single row by id. Does **not** mutate the list. Returns `null` on error and transitions the manager to `error` state.
207
+ Fetch a single row by id. Does **not** mutate the list and does **not** transition the domain to `error` on failure — a 404 for an un-owned row or a network blip on a read shouldn't invalidate a healthy list view. Returns `null` on any failure (including missing adapter). Emits `own:row:fetched` on success.
208
+
209
+ Callers that need error detail should wrap this method and inspect the adapter error themselves.
181
210
 
182
211
  #### `manager.create(data): Promise<TRow | null>`
183
212
 
@@ -190,15 +219,19 @@ Create a new row. On success, prepends the server-returned row to the list. On f
190
219
 
191
220
  #### `manager.update(id, data): Promise<TRow | null>`
192
221
 
193
- Update a row. Optimistically merges `data` into the existing row; on server failure the list is rolled back to its pre-call state. On success, the server-returned row replaces the optimistic one.
222
+ Update a row. Optimistically merges `data` into the existing row; on server failure the single row reverts to its pre-call value (other rows are untouched — including any added by an interleaved `refresh()`). On success, the server-returned row replaces the optimistic one.
223
+
224
+ If `id` is **not** in the current cached list (filtered out by an active query, or not loaded), the optimistic step is a no-op AND the successful server response is **not** inserted — call `refresh()` if you want the row to appear. The `own:row:updated` event is emitted regardless.
194
225
 
195
226
  **Parameters:**
196
227
  - `id` (`string`)
197
228
  - `data` (`TUpdate`)
198
229
 
230
+ Mutations serialize per-manager — a `create/update/delete` that starts while another is in-flight queues behind it.
231
+
199
232
  #### `manager.delete(id): Promise<boolean>`
200
233
 
201
- Delete a row. Optimistically removes it from the list; on server failure the list is rolled back.
234
+ Delete a row. Optimistically removes it from the list; on server failure the single row is re-inserted at its original position (unless another op has since re-added it).
202
235
 
203
236
  **Returns:** `true` on success, `false` on failure.
204
237
 
@@ -214,13 +247,21 @@ Find a row by id in the current list without hitting the server.
214
247
 
215
248
  Swap or inspect the adapter at runtime.
216
249
 
217
- #### `manager.setContext(ctx)` / `manager.getContext()`
250
+ #### `manager.setContext(ctx)` / `manager.replaceContext(ctx)` / `manager.getContext()`
218
251
 
219
- Per-manager context. `Ownsuite.setContext()` propagates to every manager.
252
+ Per-manager context. `setContext` merges into the existing context; `replaceContext` replaces it wholesale. `Ownsuite.setContext()` propagates to every manager (with the same `{ replace }` option).
220
253
 
221
254
  #### `manager.reset()`
222
255
 
223
- Reset to `initializing` state.
256
+ Reset to `initializing` state. Aborts any in-flight reads or mutations (their completions become no-ops) and emits `domain:state:changed`.
257
+
258
+ #### `manager.destroy()`
259
+
260
+ Abort in-flight operations, drop the adapter reference, and mark the manager as destroyed. Subsequent method calls are best-effort no-ops. Usually invoked via `Ownsuite.destroy()`, but safe to call directly.
261
+
262
+ #### `manager.isDestroyed: boolean`
263
+
264
+ True after `destroy()` has been called.
224
265
 
225
266
  ---
226
267
 
@@ -255,16 +296,26 @@ interface OwnsuiteDomainConfig<TRow, TCreate, TUpdate> {
255
296
  }
256
297
  ```
257
298
 
299
+ ### `SetContextOptions`
300
+
301
+ ```typescript
302
+ interface SetContextOptions {
303
+ replace?: boolean; // default: false — merge into existing context
304
+ refresh?: boolean; // default: false — fire refresh() on every domain
305
+ }
306
+ ```
307
+
258
308
  ### `OwnsuiteContext`
259
309
 
260
310
  ```typescript
261
311
  interface OwnsuiteContext {
262
312
  subjectId?: string;
313
+ signal?: AbortSignal; // manager-injected, per-call
263
314
  [key: string]: unknown;
264
315
  }
265
316
  ```
266
317
 
267
- Context passed to adapters. **`subjectId` is a hint only** — the server authoritatively resolves the owner from the authenticated JWT. The context object is the extension point for passing host-app data (correlation ids, feature flags, tenants) through adapter calls.
318
+ Context passed to adapters. **`subjectId` is a hint only** — the server authoritatively resolves the owner from the authenticated JWT. **`signal` is injected by the manager** on every call; adapters should forward it to `fetch()` for cancellation on `reset()`/`destroy()`/read-supersede. The context object is also the extension point for passing host-app data (correlation ids, feature flags, tenants) through adapter calls.
268
319
 
269
320
  ### `OwnedCollectionAdapter<TRow, TCreate, TUpdate>`
270
321
 
@@ -383,9 +434,13 @@ interface MockAdapterOptions<TRow> {
383
434
  failOn?: { list?: boolean; getOne?: boolean; create?: boolean; update?: boolean; delete?: boolean };
384
435
  getRowId?: (row: TRow) => string;
385
436
  newId?: () => string;
437
+ /** Reject create payloads containing `model_id` (default: true). */
438
+ rejectClientId?: boolean;
386
439
  }
387
440
  ```
388
441
 
442
+ The mock adapter forwards `ctx.signal` — `delayMs` waits can be aborted mid-sleep so tests that assert on abort-supersede semantics run deterministically.
443
+
389
444
  ---
390
445
 
391
446
  ## Implementing a real adapter
@@ -393,27 +448,33 @@ interface MockAdapterOptions<TRow> {
393
448
  Point the adapter at your server's owner-scoped mount (typically `/api/<stack>/me/col/<entity>/...`). The server is responsible for `owner_id` enforcement — the client only talks to `/me/*`.
394
449
 
395
450
  ```typescript
396
- import type { OwnedCollectionAdapter } from "@marianmeres/ownsuite";
451
+ import type { OwnedCollectionAdapter, OwnsuiteContext } from "@marianmeres/ownsuite";
397
452
  import { HTTP_ERROR } from "@marianmeres/http-utils";
398
453
 
399
454
  export function createRestAdapter(stack: string, entity: string): OwnedCollectionAdapter {
400
455
  const base = `/api/${stack}/me/col/${entity}`;
401
- const json = async <T>(method: string, url: string, body?: unknown): Promise<T> => {
456
+ const json = async <T>(
457
+ method: string,
458
+ url: string,
459
+ ctx: OwnsuiteContext,
460
+ body?: unknown,
461
+ ): Promise<T> => {
402
462
  const res = await fetch(url, {
403
463
  method,
404
464
  headers: { "content-type": "application/json" },
405
465
  body: body === undefined ? undefined : JSON.stringify(body),
466
+ signal: ctx.signal, // forward manager-injected abort signal
406
467
  });
407
468
  if (!res.ok) throw new HTTP_ERROR.BadRequest(await res.text());
408
469
  return await res.json();
409
470
  };
410
471
  return {
411
- list: (_ctx) => json("GET", `${base}/mod`),
412
- getOne: (id, _ctx) => json("GET", `${base}/mod/${id}`),
413
- create: (data, _ctx) => json("POST", `${base}/mod`, data),
414
- update: (id, data, _ctx) => json("PUT", `${base}/mod/${id}`, data),
415
- delete: async (id, _ctx) => {
416
- await json("DELETE", `${base}/mod/${id}`);
472
+ list: (ctx) => json("GET", `${base}/mod`, ctx),
473
+ getOne: (id, ctx) => json("GET", `${base}/mod/${id}`, ctx),
474
+ create: (data, ctx) => json("POST", `${base}/mod`, ctx, data),
475
+ update: (id, data, ctx) => json("PUT", `${base}/mod/${id}`, ctx, data),
476
+ delete: async (id, ctx) => {
477
+ await json("DELETE", `${base}/mod/${id}`, ctx);
417
478
  return true;
418
479
  },
419
480
  };
package/README.md CHANGED
@@ -16,11 +16,14 @@ Ownsuite gives front-end applications a uniform way to read, create, update and
16
16
  ## Features
17
17
 
18
18
  - **Generic domain managers** — register any owner-scoped collection by name; no hard-coded domain list
19
- - **Optimistic updates** — UI mutates immediately; the manager rolls back on server failure
19
+ - **Optimistic updates** with per-row rollback — UI mutates immediately; failed ops revert just the affected row
20
+ - **Race-safe concurrency** — mutations serialize; reads abort-supersede (a newer `refresh()` aborts an older one)
21
+ - **AbortSignal plumbing** — every adapter call receives a per-operation signal, wired to `destroy()` and route-change cancellation
20
22
  - **Svelte-compatible stores** — every domain exposes a `subscribe()` method
21
23
  - **Adapter pattern** — plug in any HTTP/WebSocket/mock transport
22
24
  - **Event system** — subscribe to list fetches, row CRUD, and lifecycle transitions
23
- - **Mock adapter** — in-memory fixture for tests, with configurable failure injection
25
+ - **Mock adapter** — in-memory fixture for tests, with configurable failure injection and latency
26
+ - **Explicit lifecycle** — `suite.destroy()` aborts in-flight work and releases listeners cleanly
24
27
 
25
28
  ## Installation
26
29
 
@@ -65,6 +68,12 @@ suite.domain("orders").subscribe((s) => {
65
68
  await suite.domain("orders").create({ data: { total: 99 } });
66
69
  await suite.domain("orders").update(id, { data: { total: 120 } });
67
70
  await suite.domain("orders").delete(id);
71
+
72
+ // 6. Detect silent boot failures
73
+ if (suite.hasErrors()) console.warn("boot errors:", suite.errors());
74
+
75
+ // 7. Clean up on teardown (SPA unmount, tenant switch, test harness)
76
+ suite.destroy();
68
77
  ```
69
78
 
70
79
  ## Architecture at a glance
@@ -103,6 +112,16 @@ await suite.domain("notes").update("1", { data: { label: "new" } });
103
112
 
104
113
  See [API.md](API.md) for complete API documentation.
105
114
 
115
+ ## Breaking changes in 2.0.0
116
+
117
+ - `getOne()` no longer transitions the domain to `error` on failure — it returns `null` quietly.
118
+ - `update(id, ...)` for an id absent from the cached list no longer prepends a phantom row — the server update is still applied server-side (event emitted), but the list stays unchanged. Call `refresh()` to surface it.
119
+ - `createMockOwnedCollectionAdapter` rejects `create` payloads containing a client-supplied `model_id` by default (opt out with `rejectClientId: false`).
120
+ - Rollback on failed `update`/`delete` is now per-row, not whole-list. Interleaved refresh results are preserved.
121
+ - `reset()` now emits `domain:state:changed`.
122
+
123
+ See [AGENTS.md](AGENTS.md) "Breaking changes in 2.0.0" for the full list and migration notes.
124
+
106
125
  ## License
107
126
 
108
127
  [MIT](LICENSE)
@@ -2,9 +2,10 @@
2
2
  * @module adapters/mock
3
3
  *
4
4
  * In-memory mock adapter for testing. Stores rows in a local Map keyed by
5
- * `model_id`, applies an optional latency, and can inject failures. Useful
6
- * for unit tests without a real server, and for exercising the manager's
7
- * optimistic-update rollback path deterministically.
5
+ * `model_id`, applies an optional latency, honors `ctx.signal` for
6
+ * cancellation, and can inject failures. Useful for unit tests without a
7
+ * real server, and for exercising the manager's optimistic-update rollback
8
+ * path deterministically.
8
9
  */
9
10
  import type { OwnedCollectionAdapter } from "../types/adapter.js";
10
11
  export interface MockAdapterOptions<TRow> {
@@ -24,6 +25,12 @@ export interface MockAdapterOptions<TRow> {
24
25
  getRowId?: (row: TRow) => string;
25
26
  /** Factory for new row ids (defaults to `crypto.randomUUID`). */
26
27
  newId?: () => string;
28
+ /**
29
+ * If true (default), `create` rejects payloads that include a
30
+ * `model_id` — matches the production server contract where the server
31
+ * is authoritative over ids. Set to `false` to bypass for legacy tests.
32
+ */
33
+ rejectClientId?: boolean;
27
34
  }
28
35
  /**
29
36
  * Build an in-memory `OwnedCollectionAdapter` for tests.
@@ -2,68 +2,122 @@
2
2
  * @module adapters/mock
3
3
  *
4
4
  * In-memory mock adapter for testing. Stores rows in a local Map keyed by
5
- * `model_id`, applies an optional latency, and can inject failures. Useful
6
- * for unit tests without a real server, and for exercising the manager's
7
- * optimistic-update rollback path deterministically.
5
+ * `model_id`, applies an optional latency, honors `ctx.signal` for
6
+ * cancellation, and can inject failures. Useful for unit tests without a
7
+ * real server, and for exercising the manager's optimistic-update rollback
8
+ * path deterministically.
8
9
  */
9
10
  const defaultGetRowId = (r) => {
10
11
  const rec = r;
11
12
  const id = rec.model_id ?? rec.id;
12
- if (typeof id !== "string") {
13
- throw new Error("MockAdapter: row has no string `model_id` or `id`; pass `getRowId`");
13
+ if (typeof id !== "string" || id === "") {
14
+ throw new Error("MockAdapter: row has no non-empty string `model_id` or `id`; pass `getRowId`");
14
15
  }
15
16
  return id;
16
17
  };
18
+ function safeClone(value) {
19
+ if (value === null || value === undefined)
20
+ return value;
21
+ try {
22
+ return structuredClone(value);
23
+ }
24
+ catch {
25
+ try {
26
+ return JSON.parse(JSON.stringify(value));
27
+ }
28
+ catch {
29
+ return value;
30
+ }
31
+ }
32
+ }
33
+ /** Throw an AbortError-shaped error if the signal was aborted. */
34
+ function throwIfAborted(signal) {
35
+ if (signal?.aborted) {
36
+ const reason = signal.reason;
37
+ const err = new Error(typeof reason === "string" ? `mock: aborted (${reason})` : "mock: aborted");
38
+ err.name = "AbortError";
39
+ throw err;
40
+ }
41
+ }
17
42
  /**
18
43
  * Build an in-memory `OwnedCollectionAdapter` for tests.
19
44
  */
20
45
  export function createMockOwnedCollectionAdapter(options = {}) {
21
- const { delayMs = 0, failOn = {}, getRowId = defaultGetRowId, newId = () => crypto.randomUUID(), } = options;
46
+ const { delayMs = 0, failOn = {}, getRowId = defaultGetRowId, newId = () => crypto.randomUUID(), rejectClientId = true, } = options;
22
47
  const store = new Map();
23
48
  for (const r of options.seed ?? [])
24
49
  store.set(getRowId(r), r);
25
- const sleep = () => delayMs > 0
26
- ? new Promise((res) => setTimeout(res, delayMs))
27
- : Promise.resolve();
50
+ /**
51
+ * Latency helper that also observes abort. Returns when either the
52
+ * delay elapses or the signal fires — the caller then checks
53
+ * `throwIfAborted` to convert to an error.
54
+ */
55
+ const sleep = (signal) => {
56
+ if (delayMs <= 0)
57
+ return Promise.resolve();
58
+ return new Promise((resolve) => {
59
+ const t = setTimeout(resolve, delayMs);
60
+ signal?.addEventListener("abort", () => {
61
+ clearTimeout(t);
62
+ resolve();
63
+ }, { once: true });
64
+ });
65
+ };
28
66
  return {
29
- async list(_ctx, _query) {
30
- await sleep();
67
+ async list(ctx, _query) {
68
+ await sleep(ctx.signal);
69
+ throwIfAborted(ctx.signal);
31
70
  if (failOn.list)
32
71
  throw new Error("mock: list failed");
33
- const rows = [...store.values()];
72
+ const rows = [...store.values()].map((r) => safeClone(r));
34
73
  return { data: rows, meta: { total: rows.length } };
35
74
  },
36
- async getOne(id, _ctx) {
37
- await sleep();
75
+ async getOne(id, ctx) {
76
+ await sleep(ctx.signal);
77
+ throwIfAborted(ctx.signal);
38
78
  if (failOn.getOne)
39
79
  throw new Error("mock: getOne failed");
40
80
  const row = store.get(id);
41
81
  if (!row)
42
82
  throw new Error(`mock: row ${id} not found`);
43
- return { data: row };
83
+ return { data: safeClone(row) };
44
84
  },
45
- async create(data, _ctx) {
46
- await sleep();
85
+ async create(data, ctx) {
86
+ await sleep(ctx.signal);
87
+ throwIfAborted(ctx.signal);
47
88
  if (failOn.create)
48
89
  throw new Error("mock: create failed");
90
+ const input = data;
91
+ if (rejectClientId &&
92
+ input !== null &&
93
+ typeof input === "object" &&
94
+ "model_id" in input) {
95
+ throw new Error("mock: create payload must not include `model_id` — the server assigns the id");
96
+ }
49
97
  const id = newId();
50
- const row = { ...data, model_id: id };
98
+ const cloned = safeClone(data);
99
+ const row = { ...cloned, model_id: id };
51
100
  store.set(id, row);
52
- return { data: row };
101
+ return { data: safeClone(row) };
53
102
  },
54
- async update(id, data, _ctx) {
55
- await sleep();
103
+ async update(id, data, ctx) {
104
+ await sleep(ctx.signal);
105
+ throwIfAborted(ctx.signal);
56
106
  if (failOn.update)
57
107
  throw new Error("mock: update failed");
58
108
  const existing = store.get(id);
59
109
  if (!existing)
60
110
  throw new Error(`mock: row ${id} not found`);
61
- const merged = { ...existing, ...data };
111
+ const merged = {
112
+ ...existing,
113
+ ...safeClone(data),
114
+ };
62
115
  store.set(id, merged);
63
- return { data: merged };
116
+ return { data: safeClone(merged) };
64
117
  },
65
- async delete(id, _ctx) {
66
- await sleep();
118
+ async delete(id, ctx) {
119
+ await sleep(ctx.signal);
120
+ throwIfAborted(ctx.signal);
67
121
  if (failOn.delete)
68
122
  throw new Error("mock: delete failed");
69
123
  return store.delete(id);