@marianmeres/ownsuite 1.0.1

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/API.md ADDED
@@ -0,0 +1,423 @@
1
+ # API
2
+
3
+ ## Functions
4
+
5
+ ### `createOwnsuite(config?)`
6
+
7
+ Convenience factory mirroring the ecsuite `createECSuite` convention. Equivalent to `new Ownsuite(config)`.
8
+
9
+ **Parameters:**
10
+ - `config` (`OwnsuiteConfig`, optional) — see [`OwnsuiteConfig`](#ownsuiteconfig).
11
+
12
+ **Returns:** `Ownsuite`
13
+
14
+ **Example:**
15
+ ```typescript
16
+ import { createOwnsuite } from "@marianmeres/ownsuite";
17
+
18
+ const suite = createOwnsuite({
19
+ context: { subjectId: "user-123" },
20
+ domains: {
21
+ orders: { adapter: myOrdersAdapter },
22
+ addresses: { adapter: myAddressesAdapter },
23
+ },
24
+ });
25
+ await suite.initialize();
26
+ ```
27
+
28
+ ---
29
+
30
+ ### `createMockOwnedCollectionAdapter(options?)`
31
+
32
+ In-memory mock adapter for tests. Stores rows in a local `Map` keyed by `model_id`. Applies optional latency and can inject failures per operation — useful for exercising the optimistic-update rollback path deterministically.
33
+
34
+ **Parameters:**
35
+ - `options` (`MockAdapterOptions<TRow>`, optional)
36
+ - `options.seed` (`TRow[]`, optional) — initial rows
37
+ - `options.delayMs` (`number`, optional) — artificial latency per call. Default: `0`
38
+ - `options.failOn` (`object`, optional) — per-operation failure injection: `{ list?, getOne?, create?, update?, delete? }`
39
+ - `options.getRowId` (`(row) => string`, optional) — row-id resolver. Default: `row.model_id ?? row.id`
40
+ - `options.newId` (`() => string`, optional) — id factory for new rows. Default: `crypto.randomUUID`
41
+
42
+ **Returns:** `OwnedCollectionAdapter<TRow> & { _rows(): TRow[] }`
43
+
44
+ The returned adapter exposes an extra `_rows()` method for test assertions that doesn't exist on production adapters.
45
+
46
+ **Example:**
47
+ ```typescript
48
+ const adapter = createMockOwnedCollectionAdapter({
49
+ seed: [{ model_id: "1", data: { label: "a" } }],
50
+ failOn: { create: true },
51
+ });
52
+ ```
53
+
54
+ ---
55
+
56
+ ## Classes
57
+
58
+ ### `Ownsuite`
59
+
60
+ Orchestrator that coordinates owner-scoped domain managers and provides a shared event bus.
61
+
62
+ #### `new Ownsuite(config?)`
63
+
64
+ **Parameters:** same as `createOwnsuite`.
65
+
66
+ #### `suite.registerDomain(name, cfg)`
67
+
68
+ Register a new domain after construction. Throws if `name` is already registered.
69
+
70
+ **Parameters:**
71
+ - `name` (`string`) — unique domain label
72
+ - `cfg` (`OwnsuiteDomainConfig<TRow, TCreate, TUpdate>`)
73
+ - `cfg.adapter` (`OwnedCollectionAdapter`) — required
74
+ - `cfg.getRowId` (`(row) => string`, optional) — custom row-id resolver
75
+
76
+ **Returns:** `OwnedCollectionManager<TRow, TCreate, TUpdate>`
77
+
78
+ #### `suite.domain(name)`
79
+
80
+ Look up a domain manager by name. Throws if unknown.
81
+
82
+ **Returns:** `OwnedCollectionManager<TRow, TCreate, TUpdate>`
83
+
84
+ #### `suite.hasDomain(name): boolean`
85
+
86
+ True if a domain by this name is registered.
87
+
88
+ #### `suite.domainNames(): string[]`
89
+
90
+ List registered domain names.
91
+
92
+ #### `suite.initialize(names?)`
93
+
94
+ Initialize all registered domains (or a subset). Runs in parallel. Individual domain errors land in that domain's error state and **do not** reject the returned promise.
95
+
96
+ **Parameters:**
97
+ - `names` (`string[]`, optional) — domain names to initialize. Default: all registered domains.
98
+
99
+ **Returns:** `Promise<void>`
100
+
101
+ #### `suite.setContext(ctx)`
102
+
103
+ Merge `ctx` into the shared context and propagate to every registered domain manager.
104
+
105
+ **Parameters:**
106
+ - `ctx` (`OwnsuiteContext`)
107
+
108
+ #### `suite.getContext(): OwnsuiteContext`
109
+
110
+ Snapshot of current shared context.
111
+
112
+ #### `suite.on(type, subscriber)`
113
+
114
+ Subscribe to a specific event type.
115
+
116
+ **Parameters:**
117
+ - `type` (`OwnsuiteEventType`)
118
+ - `subscriber` (`Subscriber`) — from `@marianmeres/pubsub`
119
+
120
+ **Returns:** `Unsubscriber`
121
+
122
+ **Example:**
123
+ ```typescript
124
+ const unsub = suite.on("own:row:created", (e) => {
125
+ console.log("created row", e.rowId, "in domain", e.domain);
126
+ });
127
+ ```
128
+
129
+ #### `suite.onAny(subscriber)`
130
+
131
+ Subscribe to all events. Wildcard subscribers receive an envelope `{ event: string, data: OwnsuiteEvent }` — see `@marianmeres/pubsub`.
132
+
133
+ **Returns:** `Unsubscriber`
134
+
135
+ #### `suite.reset()`
136
+
137
+ Reset every domain manager to the `initializing` state. Drops cached lists.
138
+
139
+ ---
140
+
141
+ ### `OwnedCollectionManager<TRow, TCreate, TUpdate>`
142
+
143
+ Generic manager for a single owner-scoped collection domain. One instance per collection path on the server.
144
+
145
+ #### `new OwnedCollectionManager(domainName, options?)`
146
+
147
+ Typically created via `Ownsuite.registerDomain()` — manual construction is possible but bypasses the shared pubsub.
148
+
149
+ **Parameters:**
150
+ - `domainName` (`string`) — label (informational, used in event payloads and logs)
151
+ - `options` (`OwnedCollectionManagerOptions<TRow, TCreate, TUpdate>`, optional)
152
+ - `options.adapter` (`OwnedCollectionAdapter`, optional)
153
+ - `options.getRowId` (`(row) => string`, optional) — default: `row.model_id ?? row.id`
154
+ - `options.context` (`OwnsuiteContext`, optional)
155
+ - `options.pubsub` (`PubSub`, optional) — shared event bus
156
+
157
+ #### `manager.subscribe(listener)`
158
+
159
+ Svelte-compatible subscribe. Listener receives the full [`DomainStateWrapper<OwnedCollectionState<TRow>>`](#domainstatewrappert).
160
+
161
+ **Returns:** `Unsubscriber`
162
+
163
+ #### `manager.get(): DomainStateWrapper<OwnedCollectionState<TRow>>`
164
+
165
+ Synchronous snapshot of the current state.
166
+
167
+ #### `manager.initialize(): Promise<void>`
168
+
169
+ Fetch the list from the server. Populates `data.rows` + `data.meta` and transitions to `ready`.
170
+
171
+ #### `manager.refresh(query?): Promise<void>`
172
+
173
+ Re-fetch the list. Same as `initialize` but re-entrant; accepts an adapter-specific query object.
174
+
175
+ **Parameters:**
176
+ - `query` (`Record<string, unknown>`, optional) — forwarded to `adapter.list(ctx, query)`
177
+
178
+ #### `manager.getOne(id): Promise<TRow | null>`
179
+
180
+ Fetch a single row by id. Does **not** mutate the list. Returns `null` on error and transitions the manager to `error` state.
181
+
182
+ #### `manager.create(data): Promise<TRow | null>`
183
+
184
+ Create a new row. On success, prepends the server-returned row to the list. On failure, the list is unchanged and the manager transitions to `error`.
185
+
186
+ **Parameters:**
187
+ - `data` (`TCreate`) — creation payload. The server stamps `owner_id` — do not set it client-side.
188
+
189
+ **Returns:** the server-returned row, or `null` on failure.
190
+
191
+ #### `manager.update(id, data): Promise<TRow | null>`
192
+
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.
194
+
195
+ **Parameters:**
196
+ - `id` (`string`)
197
+ - `data` (`TUpdate`)
198
+
199
+ #### `manager.delete(id): Promise<boolean>`
200
+
201
+ Delete a row. Optimistically removes it from the list; on server failure the list is rolled back.
202
+
203
+ **Returns:** `true` on success, `false` on failure.
204
+
205
+ #### `manager.getRows(): TRow[]`
206
+
207
+ Snapshot of current rows (empty array if not loaded).
208
+
209
+ #### `manager.findRow(id): TRow | undefined`
210
+
211
+ Find a row by id in the current list without hitting the server.
212
+
213
+ #### `manager.setAdapter(adapter)` / `manager.getAdapter()`
214
+
215
+ Swap or inspect the adapter at runtime.
216
+
217
+ #### `manager.setContext(ctx)` / `manager.getContext()`
218
+
219
+ Per-manager context. `Ownsuite.setContext()` propagates to every manager.
220
+
221
+ #### `manager.reset()`
222
+
223
+ Reset to `initializing` state.
224
+
225
+ ---
226
+
227
+ ### `BaseDomainManager<TData, TAdapter>`
228
+
229
+ Abstract base class. Not typically used directly — `OwnedCollectionManager` extends it. Provides reactive state, state-machine transitions, optimistic-update pattern, and event emission. Matches the shape of ecsuite's `BaseDomainManager`.
230
+
231
+ ---
232
+
233
+ ## Types
234
+
235
+ ### `OwnsuiteConfig`
236
+
237
+ ```typescript
238
+ interface OwnsuiteConfig {
239
+ context?: OwnsuiteContext;
240
+ domains?: Record<string, OwnsuiteDomainConfig>;
241
+ autoInitialize?: boolean;
242
+ }
243
+ ```
244
+
245
+ - `context` — initial context passed to every adapter call.
246
+ - `domains` — domain registry at construction time. Keys are arbitrary labels.
247
+ - `autoInitialize` — fire-and-forget `initialize()` in the constructor. Default: `false`.
248
+
249
+ ### `OwnsuiteDomainConfig<TRow, TCreate, TUpdate>`
250
+
251
+ ```typescript
252
+ interface OwnsuiteDomainConfig<TRow, TCreate, TUpdate> {
253
+ adapter: OwnedCollectionAdapter<TRow, TCreate, TUpdate>;
254
+ getRowId?: (row: TRow) => string;
255
+ }
256
+ ```
257
+
258
+ ### `OwnsuiteContext`
259
+
260
+ ```typescript
261
+ interface OwnsuiteContext {
262
+ subjectId?: string;
263
+ [key: string]: unknown;
264
+ }
265
+ ```
266
+
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.
268
+
269
+ ### `OwnedCollectionAdapter<TRow, TCreate, TUpdate>`
270
+
271
+ ```typescript
272
+ interface OwnedCollectionAdapter<TRow, TCreate = unknown, TUpdate = unknown> {
273
+ list(ctx: OwnsuiteContext, query?: Record<string, unknown>): Promise<OwnedListResult<TRow>>;
274
+ getOne(id: string, ctx: OwnsuiteContext): Promise<OwnedRowResult<TRow>>;
275
+ create(data: TCreate, ctx: OwnsuiteContext): Promise<OwnedRowResult<TRow>>;
276
+ update(id: string, data: TUpdate, ctx: OwnsuiteContext): Promise<OwnedRowResult<TRow>>;
277
+ delete(id: string, ctx: OwnsuiteContext): Promise<boolean>;
278
+ }
279
+ ```
280
+
281
+ Implementations throw on failure (ideally `HTTP_ERROR` from `@marianmeres/http-utils`); the manager handles rollback.
282
+
283
+ ### `OwnedListResult<TRow>` / `OwnedRowResult<TRow>`
284
+
285
+ ```typescript
286
+ interface OwnedListResult<TRow> {
287
+ data: TRow[];
288
+ meta: Record<string, unknown>;
289
+ }
290
+
291
+ interface OwnedRowResult<TRow> {
292
+ data: TRow;
293
+ meta?: Record<string, unknown>;
294
+ }
295
+ ```
296
+
297
+ Matches `@marianmeres/collection`'s REST envelope.
298
+
299
+ ### `OwnedCollectionState<TRow>`
300
+
301
+ ```typescript
302
+ interface OwnedCollectionState<TRow> {
303
+ rows: TRow[];
304
+ meta: Record<string, unknown>;
305
+ }
306
+ ```
307
+
308
+ ### `DomainStateWrapper<T>`
309
+
310
+ ```typescript
311
+ interface DomainStateWrapper<T> {
312
+ state: DomainState; // "initializing" | "ready" | "syncing" | "error"
313
+ data: T | null;
314
+ error: DomainError | null;
315
+ lastSyncedAt: number | null;
316
+ }
317
+ ```
318
+
319
+ ### `DomainState`
320
+
321
+ ```typescript
322
+ type DomainState = "initializing" | "ready" | "syncing" | "error";
323
+ ```
324
+
325
+ State transitions:
326
+
327
+ ```
328
+ initializing → ready
329
+ ready ↔ syncing
330
+ syncing → error (on failure; list is rolled back)
331
+ error → syncing (retry)
332
+ ```
333
+
334
+ ### `DomainError`
335
+
336
+ ```typescript
337
+ interface DomainError {
338
+ code: string; // e.g. "SYNC_FAILED", "FETCH_FAILED"
339
+ message: string;
340
+ operation: string; // e.g. "create", "update", "delete"
341
+ originalError?: unknown;
342
+ }
343
+ ```
344
+
345
+ ### `OwnsuiteEventType`
346
+
347
+ ```typescript
348
+ type OwnsuiteEventType =
349
+ | "domain:state:changed"
350
+ | "domain:error"
351
+ | "domain:synced"
352
+ | "own:list:fetched"
353
+ | "own:row:fetched"
354
+ | "own:row:created"
355
+ | "own:row:updated"
356
+ | "own:row:deleted";
357
+ ```
358
+
359
+ ### `OwnsuiteEvent`
360
+
361
+ Discriminated union of all event payloads. Every event has `type`, `timestamp`, and `domain` fields.
362
+
363
+ ```typescript
364
+ type OwnsuiteEvent =
365
+ | StateChangedEvent
366
+ | ErrorEvent
367
+ | SyncedEvent
368
+ | ListFetchedEvent // + count
369
+ | RowFetchedEvent // + rowId
370
+ | RowCreatedEvent // + rowId
371
+ | RowUpdatedEvent // + rowId
372
+ | RowDeletedEvent; // + rowId
373
+ ```
374
+
375
+ See [src/types/events.ts](src/types/events.ts) for individual event interfaces.
376
+
377
+ ### `MockAdapterOptions<TRow>`
378
+
379
+ ```typescript
380
+ interface MockAdapterOptions<TRow> {
381
+ seed?: TRow[];
382
+ delayMs?: number;
383
+ failOn?: { list?: boolean; getOne?: boolean; create?: boolean; update?: boolean; delete?: boolean };
384
+ getRowId?: (row: TRow) => string;
385
+ newId?: () => string;
386
+ }
387
+ ```
388
+
389
+ ---
390
+
391
+ ## Implementing a real adapter
392
+
393
+ 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
+
395
+ ```typescript
396
+ import type { OwnedCollectionAdapter } from "@marianmeres/ownsuite";
397
+ import { HTTP_ERROR } from "@marianmeres/http-utils";
398
+
399
+ export function createRestAdapter(stack: string, entity: string): OwnedCollectionAdapter {
400
+ const base = `/api/${stack}/me/col/${entity}`;
401
+ const json = async <T>(method: string, url: string, body?: unknown): Promise<T> => {
402
+ const res = await fetch(url, {
403
+ method,
404
+ headers: { "content-type": "application/json" },
405
+ body: body === undefined ? undefined : JSON.stringify(body),
406
+ });
407
+ if (!res.ok) throw new HTTP_ERROR.BadRequest(await res.text());
408
+ return await res.json();
409
+ };
410
+ 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}`);
417
+ return true;
418
+ },
419
+ };
420
+ }
421
+ ```
422
+
423
+ The [`@marianmeres/joy`](https://github.com/marianmeres/full-stack-app-template) admin SPA ships a reusable factory — `createOwnedCollectionAdapter()` in `src/routes/me/owned-collection-adapter.ts` — that implements exactly this shape.
package/CLAUDE.md ADDED
@@ -0,0 +1,3 @@
1
+ # Project Instructions
2
+
3
+ See [AGENTS.md](./AGENTS.md) for complete project documentation and AI agent instructions.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Marian Meres
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,108 @@
1
+ # @marianmeres/ownsuite
2
+
3
+ [![NPM Version](https://img.shields.io/npm/v/@marianmeres/ownsuite)](https://www.npmjs.com/package/@marianmeres/ownsuite)
4
+ [![JSR Version](https://img.shields.io/jsr/v/@marianmeres/ownsuite)](https://jsr.io/@marianmeres/ownsuite)
5
+ [![License](https://img.shields.io/github/license/marianmeres/ownsuite)](LICENSE)
6
+
7
+ Client-side helper library for owner-scoped UIs. Generic domain managers with optimistic updates, Svelte-compatible stores, and adapter-based server sync — the owner-scoped counterpart to [`@marianmeres/ecsuite`](https://jsr.io/@marianmeres/ecsuite).
8
+
9
+ ## What it does
10
+
11
+ Ownsuite gives front-end applications a uniform way to read, create, update and delete records from owner-scoped REST endpoints (typically `/me/*`). Each row is implicitly scoped to the authenticated subject by the server — the client never sets `owner_id`. The library pairs with:
12
+
13
+ - **[`@marianmeres/collection`](https://jsr.io/@marianmeres/collection)** — the `ownerIdScope` route hook enforces owner-based filtering on the server.
14
+ - **[`@marianmeres/stack-common`](https://jsr.io/@marianmeres/stack-common)** — the `ownsuiteOptions()` helper wires the server mount.
15
+
16
+ ## Features
17
+
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
20
+ - **Svelte-compatible stores** — every domain exposes a `subscribe()` method
21
+ - **Adapter pattern** — plug in any HTTP/WebSocket/mock transport
22
+ - **Event system** — subscribe to list fetches, row CRUD, and lifecycle transitions
23
+ - **Mock adapter** — in-memory fixture for tests, with configurable failure injection
24
+
25
+ ## Installation
26
+
27
+ ```bash
28
+ # Deno
29
+ deno add @marianmeres/ownsuite
30
+
31
+ # npm
32
+ npm install @marianmeres/ownsuite
33
+ ```
34
+
35
+ ## Quick Start
36
+
37
+ ```typescript
38
+ import { createOwnsuite } from "@marianmeres/ownsuite";
39
+ import { createOwnedCollectionAdapter } from "./my-adapters";
40
+
41
+ // 1. Build adapters that point at owner-scoped endpoints
42
+ const ordersAdapter = createOwnedCollectionAdapter({
43
+ apiRoot: "/api",
44
+ stack: "shop",
45
+ entity: "order",
46
+ });
47
+
48
+ // 2. Register domains under any name
49
+ const suite = createOwnsuite({
50
+ context: { subjectId: "user-123" },
51
+ domains: {
52
+ orders: { adapter: ordersAdapter },
53
+ },
54
+ });
55
+
56
+ // 3. Load the list from the server
57
+ await suite.initialize();
58
+
59
+ // 4. Subscribe (Svelte-compatible)
60
+ suite.domain("orders").subscribe((s) => {
61
+ console.log(s.state, s.data?.rows);
62
+ });
63
+
64
+ // 5. CRUD — the server stamps owner_id from the JWT; the client never sets it
65
+ await suite.domain("orders").create({ data: { total: 99 } });
66
+ await suite.domain("orders").update(id, { data: { total: 120 } });
67
+ await suite.domain("orders").delete(id);
68
+ ```
69
+
70
+ ## Architecture at a glance
71
+
72
+ ```
73
+ Ownsuite (orchestrator)
74
+ ├── domain("orders") ──┐
75
+ ├── domain("notes") ──┼──► OwnedCollectionManager<TRow>
76
+ ├── domain("...") ──┘ ├── store (Svelte-compatible)
77
+ ├── pubsub (events)
78
+ └── adapter (HTTP/mock)
79
+ ```
80
+
81
+ Each domain holds a single list of rows owned by the authenticated subject. List operations replace the list; single-row operations mutate it in place so subscribers see stable references without a re-fetch.
82
+
83
+ ## Testing with the mock adapter
84
+
85
+ ```typescript
86
+ import {
87
+ createMockOwnedCollectionAdapter,
88
+ createOwnsuite,
89
+ } from "@marianmeres/ownsuite";
90
+
91
+ const adapter = createMockOwnedCollectionAdapter({
92
+ seed: [{ model_id: "1", data: { label: "hello" } }],
93
+ failOn: { update: true }, // force update failures for rollback tests
94
+ });
95
+
96
+ const suite = createOwnsuite({ domains: { notes: { adapter } } });
97
+ await suite.initialize();
98
+ await suite.domain("notes").update("1", { data: { label: "new" } });
99
+ // list is rolled back; suite.domain("notes").get().state === "error"
100
+ ```
101
+
102
+ ## API
103
+
104
+ See [API.md](API.md) for complete API documentation.
105
+
106
+ ## License
107
+
108
+ [MIT](LICENSE)
@@ -0,0 +1,34 @@
1
+ /**
2
+ * @module adapters/mock
3
+ *
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.
8
+ */
9
+ import type { OwnedCollectionAdapter } from "../types/adapter.js";
10
+ export interface MockAdapterOptions<TRow> {
11
+ /** Initial seed rows. */
12
+ seed?: TRow[];
13
+ /** Artificial latency per call (ms). */
14
+ delayMs?: number;
15
+ /** If set, every call on matching operation throws. Use for rollback tests. */
16
+ failOn?: {
17
+ list?: boolean;
18
+ getOne?: boolean;
19
+ create?: boolean;
20
+ update?: boolean;
21
+ delete?: boolean;
22
+ };
23
+ /** Row-id resolver. Defaults to `row.model_id` or `row.id`. */
24
+ getRowId?: (row: TRow) => string;
25
+ /** Factory for new row ids (defaults to `crypto.randomUUID`). */
26
+ newId?: () => string;
27
+ }
28
+ /**
29
+ * Build an in-memory `OwnedCollectionAdapter` for tests.
30
+ */
31
+ export declare function createMockOwnedCollectionAdapter<TRow extends Record<string, unknown> = Record<string, unknown>, TCreate = Partial<TRow>, TUpdate = Partial<TRow>>(options?: MockAdapterOptions<TRow>): OwnedCollectionAdapter<TRow, TCreate, TUpdate> & {
32
+ /** Peek at the underlying store (tests only). */
33
+ _rows(): TRow[];
34
+ };
@@ -0,0 +1,73 @@
1
+ /**
2
+ * @module adapters/mock
3
+ *
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.
8
+ */
9
+ const defaultGetRowId = (r) => {
10
+ const rec = r;
11
+ 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`");
14
+ }
15
+ return id;
16
+ };
17
+ /**
18
+ * Build an in-memory `OwnedCollectionAdapter` for tests.
19
+ */
20
+ export function createMockOwnedCollectionAdapter(options = {}) {
21
+ const { delayMs = 0, failOn = {}, getRowId = defaultGetRowId, newId = () => crypto.randomUUID(), } = options;
22
+ const store = new Map();
23
+ for (const r of options.seed ?? [])
24
+ store.set(getRowId(r), r);
25
+ const sleep = () => delayMs > 0
26
+ ? new Promise((res) => setTimeout(res, delayMs))
27
+ : Promise.resolve();
28
+ return {
29
+ async list(_ctx, _query) {
30
+ await sleep();
31
+ if (failOn.list)
32
+ throw new Error("mock: list failed");
33
+ const rows = [...store.values()];
34
+ return { data: rows, meta: { total: rows.length } };
35
+ },
36
+ async getOne(id, _ctx) {
37
+ await sleep();
38
+ if (failOn.getOne)
39
+ throw new Error("mock: getOne failed");
40
+ const row = store.get(id);
41
+ if (!row)
42
+ throw new Error(`mock: row ${id} not found`);
43
+ return { data: row };
44
+ },
45
+ async create(data, _ctx) {
46
+ await sleep();
47
+ if (failOn.create)
48
+ throw new Error("mock: create failed");
49
+ const id = newId();
50
+ const row = { ...data, model_id: id };
51
+ store.set(id, row);
52
+ return { data: row };
53
+ },
54
+ async update(id, data, _ctx) {
55
+ await sleep();
56
+ if (failOn.update)
57
+ throw new Error("mock: update failed");
58
+ const existing = store.get(id);
59
+ if (!existing)
60
+ throw new Error(`mock: row ${id} not found`);
61
+ const merged = { ...existing, ...data };
62
+ store.set(id, merged);
63
+ return { data: merged };
64
+ },
65
+ async delete(id, _ctx) {
66
+ await sleep();
67
+ if (failOn.delete)
68
+ throw new Error("mock: delete failed");
69
+ return store.delete(id);
70
+ },
71
+ _rows: () => [...store.values()],
72
+ };
73
+ }
@@ -0,0 +1 @@
1
+ export * from "./mock.js";
@@ -0,0 +1 @@
1
+ export * from "./mock.js";