@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/AGENTS.md +292 -0
- package/API.md +423 -0
- package/CLAUDE.md +3 -0
- package/LICENSE +21 -0
- package/README.md +108 -0
- package/dist/adapters/mock.d.ts +34 -0
- package/dist/adapters/mock.js +73 -0
- package/dist/adapters/mod.d.ts +1 -0
- package/dist/adapters/mod.js +1 -0
- package/dist/domains/base.d.ts +64 -0
- package/dist/domains/base.js +151 -0
- package/dist/domains/mod.d.ts +2 -0
- package/dist/domains/mod.js +2 -0
- package/dist/domains/owned-collection.d.ts +43 -0
- package/dist/domains/owned-collection.js +201 -0
- package/dist/mod.d.ts +31 -0
- package/dist/mod.js +31 -0
- package/dist/ownsuite.d.ts +85 -0
- package/dist/ownsuite.js +120 -0
- package/dist/types/adapter.d.ts +49 -0
- package/dist/types/adapter.js +11 -0
- package/dist/types/events.d.ts +63 -0
- package/dist/types/events.js +6 -0
- package/dist/types/mod.d.ts +3 -0
- package/dist/types/mod.js +3 -0
- package/dist/types/state.d.ts +55 -0
- package/dist/types/state.js +8 -0
- package/package.json +29 -0
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
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
|
+
[](https://www.npmjs.com/package/@marianmeres/ownsuite)
|
|
4
|
+
[](https://jsr.io/@marianmeres/ownsuite)
|
|
5
|
+
[](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";
|