@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 +122 -16
- package/API.md +78 -17
- package/README.md +21 -2
- package/dist/adapters/mock.d.ts +10 -3
- package/dist/adapters/mock.js +79 -25
- package/dist/domains/base.d.ts +66 -10
- package/dist/domains/base.js +165 -13
- package/dist/domains/owned-collection.d.ts +29 -4
- package/dist/domains/owned-collection.js +240 -120
- package/dist/ownsuite.d.ts +37 -5
- package/dist/ownsuite.js +92 -10
- package/dist/types/adapter.d.ts +4 -0
- package/dist/types/state.d.ts +10 -0
- package/docs/future-improvements.md +81 -0
- package/package.json +15 -6
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: "
|
|
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
|
|
34
|
-
├── adapter
|
|
35
|
-
├── state machine:
|
|
36
|
-
|
|
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 {
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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 (
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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>(
|
|
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: (
|
|
412
|
-
getOne: (id,
|
|
413
|
-
create: (data,
|
|
414
|
-
update: (id, data,
|
|
415
|
-
delete: async (id,
|
|
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;
|
|
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)
|
package/dist/adapters/mock.d.ts
CHANGED
|
@@ -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,
|
|
6
|
-
*
|
|
7
|
-
* optimistic-update rollback
|
|
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.
|
package/dist/adapters/mock.js
CHANGED
|
@@ -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,
|
|
6
|
-
*
|
|
7
|
-
* optimistic-update rollback
|
|
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
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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(
|
|
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,
|
|
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,
|
|
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
|
|
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,
|
|
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 = {
|
|
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,
|
|
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);
|