@marianmeres/ownsuite 1.0.3 → 2.1.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"
@@ -19,25 +19,51 @@ entry: "./src/mod.ts"
19
19
 
20
20
  Client-side helper library for **owner-scoped** UIs. Generic domain managers for CRUD over collections where every row is implicitly filtered to the authenticated subject by the server. Mirrors the shape of `@marianmeres/ecsuite` but applies to arbitrary owner-scoped collections instead of hard-coded e-commerce domains.
21
21
 
22
+ **Also (opt-in)** provides the blessed account-lifecycle surface for apps built on `@marianmeres/stack-account`: `suite.auth` (register / login / logout / OAuth init / password reset / delete account), `suite.profile` (`/me` CRUD + OAuth link list), `suite.session` (reactive JWT + subject, pluggable storage). Pass an `AuthAdapter` to `createOwnsuite({ adapters: { auth } })` to attach them.
23
+
22
24
  Pairs with:
23
25
  - **`@marianmeres/collection`** — `ownerIdScope` route hook (read-side owner enforcement).
24
26
  - **`@marianmeres/stack-common`** — `ownsuiteOptions()` server helper for mounting `/me/*` routes.
27
+ - **`@marianmeres/stack-account`** — default adapters (`createStackAccountAuthAdapter`, `createStackAccountProfileAdapter`) target its REST surface.
25
28
 
26
29
  ## Architecture
27
30
 
28
31
  ```
29
32
  Ownsuite (orchestrator)
30
- ├── #pubsub (shared event bus)
33
+ ├── #pubsub (shared event bus, cleared on destroy)
31
34
  ├── #context (propagated to all domains on setContext)
32
35
  └── 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
36
+ ├── store (Svelte-compatible DomainStateWrapper<OwnedCollectionState<TRow>>)
37
+ ├── adapter (OwnedCollectionAdapter)
38
+ ├── state machine: initializing → ready ↔ syncing → error
39
+ ├── optimistic update + per-row rollback on update/delete
40
+ ├── mutation chain (serial create/update/delete)
41
+ ├── abort-supersede (initialize/refresh — newer call aborts older)
42
+ └── destroy() (aborts in-flight ops, drops adapter)
37
43
  ```
38
44
 
39
45
  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
46
 
47
+ ### Concurrency model
48
+
49
+ - **Mutations serialize** per manager via an internal promise chain. A
50
+ `create/update/delete` that starts while another is in-flight queues
51
+ behind it; callers still receive their own result through the returned
52
+ promise. Rejections on the chain are swallowed so they do not block
53
+ later mutations.
54
+ - **Reads abort-supersede**: a new `initialize()` or `refresh()` aborts
55
+ any in-flight read on the same manager. The aborted call resolves
56
+ without writing to the store.
57
+ - **onSuccess uses live data, not a captured snapshot**, so interleaving
58
+ reads and mutations never resurrect deleted rows or clobber writes.
59
+ - **Rollback is per-row**: a failed `update` reverts just the updated
60
+ row; a failed `delete` re-inserts the deleted row at its original
61
+ position. An interleaved refresh that brought new rows is preserved.
62
+ - **AbortSignal plumbing**: every adapter call receives
63
+ `ctx.signal: AbortSignal`. `reset()` and `destroy()` abort all active
64
+ signals. Adapters should forward the signal to `fetch()` — ignoring
65
+ it is safe but leaves abandoned requests running.
66
+
41
67
  ## Directory Structure
42
68
 
43
69
  ```
@@ -47,17 +73,28 @@ src/
47
73
  ├── types/
48
74
  │ ├── mod.ts
49
75
  │ ├── state.ts # DomainState/Wrapper/Error, OwnsuiteContext, OwnedCollectionState
50
- │ ├── events.ts # OwnsuiteEventType, OwnsuiteEvent union, per-event interfaces
51
- └── adapter.ts # OwnedCollectionAdapter, OwnedListResult, OwnedRowResult
76
+ │ ├── events.ts # OwnsuiteEventType, OwnsuiteEvent union (incl. auth:* / profile:* / oauth:*)
77
+ ├── adapter.ts # OwnedCollectionAdapter, OwnedListResult, OwnedRowResult
78
+ │ └── auth.ts # AuthAdapter, ProfileAdapter, SessionState/Subject/Status, OAuth*
52
79
  ├── domains/
53
80
  │ ├── mod.ts
54
81
  │ ├── base.ts # BaseDomainManager abstract class (mirrors ecsuite)
55
- └── owned-collection.ts # OwnedCollectionManager<TRow, TCreate, TUpdate>
82
+ ├── owned-collection.ts # OwnedCollectionManager<TRow, TCreate, TUpdate>
83
+ │ ├── session.ts # SessionManager + pluggable SessionStorage resolver
84
+ │ ├── auth.ts # AuthManager (register/login/logout/OAuth/verify/delete)
85
+ │ └── profile.ts # ProfileManager (/me singleton)
86
+ ├── oauth/
87
+ │ └── popup.ts # openOAuthPopup + injectable PopupWindowHost for tests
56
88
  └── adapters/
57
89
  ├── mod.ts
58
- └── mock.ts # createMockOwnedCollectionAdapter for tests
90
+ ├── mock.ts # createMockOwnedCollectionAdapter
91
+ ├── mock-auth.ts # createMockAuthAdapter / createMockProfileAdapter / createMockAuthStore
92
+ └── stack-account.ts # createStackAccountAuthAdapter / createStackAccountProfileAdapter
59
93
  tests/
60
- └── ownsuite.test.ts
94
+ ├── ownsuite.test.ts # core suite + OwnedCollectionManager
95
+ ├── concurrency.test.ts # critical-invariant coverage (abort-supersede, rollback, etc.)
96
+ ├── auth.test.ts # AuthManager / ProfileManager / SessionManager
97
+ └── oauth-popup.test.ts # openOAuthPopup message / timeout / close / origin semantics
61
98
  ```
62
99
 
63
100
  ## Key Exports
@@ -65,7 +102,9 @@ tests/
65
102
  ```typescript
66
103
  // Main
67
104
  export { Ownsuite, createOwnsuite } from "./ownsuite.ts";
68
- export type { OwnsuiteConfig, OwnsuiteDomainConfig } from "./ownsuite.ts";
105
+ export type {
106
+ OwnsuiteConfig, OwnsuiteDomainConfig, SetContextOptions,
107
+ } from "./ownsuite.ts";
69
108
 
70
109
  // Domain managers
71
110
  export { BaseDomainManager, OwnedCollectionManager } from "./domains/mod.ts";
@@ -85,8 +124,65 @@ export type {
85
124
  // Mock adapter (for tests)
86
125
  export { createMockOwnedCollectionAdapter } from "./adapters/mod.ts";
87
126
  export type { MockAdapterOptions } from "./adapters/mod.ts";
127
+
128
+ // Account lifecycle (optional — attached when adapters.auth is supplied)
129
+ export { SessionManager, AuthManager, ProfileManager } from "./domains/mod.ts";
130
+ export type {
131
+ AuthAdapter, ProfileAdapter, AuthTokenResult, ProfileResult,
132
+ SessionState, SessionSubject, SessionStatus,
133
+ SessionStorage, SessionStorageType,
134
+ OAuthConnection, OAuthProvider, OAuthInitOptions, OAuthAction,
135
+ } from "./types/mod.ts";
136
+
137
+ // OAuth popup helper
138
+ export { openOAuthPopup } from "./oauth/popup.ts";
139
+ export type {
140
+ OAuthPopupMessage, OAuthPopupLoginMessage, OAuthPopupLinkMessage,
141
+ OpenOAuthPopupOptions, PopupWindowHost, PopupWindowHandle,
142
+ } from "./oauth/popup.ts";
143
+
144
+ // Default stack-account adapters
145
+ export {
146
+ createStackAccountAuthAdapter,
147
+ createStackAccountProfileAdapter,
148
+ } from "./adapters/mod.ts";
149
+ export type { StackAccountAdapterOptions } from "./adapters/mod.ts";
150
+
151
+ // Mock auth adapter (for tests)
152
+ export {
153
+ createMockAuthAdapter, createMockProfileAdapter,
154
+ createMockAuthStore, verifyMockAccount,
155
+ } from "./adapters/mod.ts";
156
+ export type { MockAuthStore } from "./adapters/mod.ts";
88
157
  ```
89
158
 
159
+ ## Account lifecycle (optional)
160
+
161
+ When `adapters.auth` is supplied, `createOwnsuite` instantiates three extra managers and attaches them as readonly suite properties:
162
+
163
+ - **`suite.session: SessionManager`** — reactive `{ status, subject, jwt, expiresAt }` persisted via pluggable `SessionStorage` (`"local"` / `"session"` / `"memory"` / custom object). Hydrates on construction; discards expired sessions. Exposes `subscribe` (Svelte-compatible), `get()`, and state-mutation methods (`setAuthenticated`, `setUnverified`, `clear`, `patchSubject`). Writes are driven by `AuthManager`, not consumers directly.
164
+
165
+ - **`suite.auth: AuthManager`** — verbs only, no state. `register` / `login` / `logout` / `resendVerification` / `requestPasswordReset` / `changePassword` / `deleteAccount` / `initiateOAuth` (`mode: "popup" | "redirect"`) / `handleOAuthCallback` (redirect mode). Each call pipes the result into the session and fires an `onIdentityChanged` hook that resets + re-initializes every owner-scoped domain with the fresh context.
166
+
167
+ - **`suite.profile: ProfileManager`** — singleton (one-row) `/me` CRUD. `fetch` / `update` / `listOAuth` / `unlinkOAuth`. Every successful fetch/update patches the session subject in place so consumers reading `suite.session` see email / roles / verification / connections without a second fetch. Update emits `profile:updated`.
168
+
169
+ The session subscribes to its own store and propagates `ctx.jwt` + `ctx.subjectId` into every registered owner-scoped domain automatically — authentication changes propagate without any manual wiring from consumers.
170
+
171
+ ### Auth events (emitted on the shared pubsub)
172
+
173
+ - `auth:register` — `{ email, requiresVerification }`
174
+ - `auth:login` — `{ email }`
175
+ - `auth:logout` — `{ subjectId? }`
176
+ - `auth:session:changed` — `{ session: SessionState }`
177
+ - `auth:verification:required` — `{ email }` (fired when `status` transitions to `"unverified"`)
178
+ - `profile:updated` — `{ email }`
179
+ - `oauth:linked` — `{ connection }`
180
+ - `oauth:unlinked` — `{ provider }`
181
+
182
+ ### OAuth popup protocol
183
+
184
+ `suite.auth.initiateOAuth(provider, opts)` with `mode: "popup"` opens a popup at the server's `/oauth/{provider}/init` URL and awaits a `postMessage` from the server's callback page (`{ type: "oauth_login_success" | "oauth_link_success" | "oauth_error", ... }`). For `mode: "redirect"` the top window navigates and the app's callback page calls `suite.auth.handleOAuthCallback()` on mount.
185
+
90
186
  ## State Machine
91
187
 
92
188
  ```
@@ -102,15 +198,23 @@ Triggered by `initialize()`, `refresh()`, `create()`, `update()`, `delete()` on
102
198
 
103
199
  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
200
 
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.
201
+ 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).
202
+
203
+ 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.
106
204
 
107
- 3. **Row ids default to `model_id`, fallback `id`.** Override via `getRowId` in `OwnsuiteDomainConfig` or `OwnedCollectionManagerOptions` when rows have a different key shape.
205
+ 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`.
108
206
 
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`.
207
+ 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.
110
208
 
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.
209
+ 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.
112
210
 
113
- 6. **`OwnsuiteContext.subjectId` is a hint, not authorization.** The server is authoritative. Setting it client-side has no security effect.
211
+ 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.
212
+
213
+ 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.
214
+
215
+ 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).
216
+
217
+ 10. **`ctx.signal` is present on every adapter call.** Adapters should forward it to `fetch()`. Signals abort on `reset()`, `destroy()`, and read-supersede.
114
218
 
115
219
  ## Common Patterns
116
220
 
@@ -152,6 +256,29 @@ suite.on("domain:error", (e) => {/* e.error */});
152
256
  suite.onAny(({ event, data }) => {/* wildcard envelope */});
153
257
  ```
154
258
 
259
+ ### Detecting boot failures
260
+
261
+ ```typescript
262
+ await suite.initialize();
263
+ if (suite.hasErrors()) {
264
+ const errs = suite.errors(); // { [domainName]: DomainError }
265
+ // route to error UI, log, retry, ...
266
+ }
267
+ ```
268
+
269
+ ### Switching subject mid-session
270
+
271
+ ```typescript
272
+ // Clears the previous subject's context keys and re-fetches every domain.
273
+ suite.setContext({ subjectId: newId }, { replace: true, refresh: true });
274
+ ```
275
+
276
+ ### Cleanup
277
+
278
+ ```typescript
279
+ suite.destroy(); // aborts in-flight requests, unsubscribes pubsub, drops adapters
280
+ ```
281
+
155
282
  ### Implementing a real adapter
156
283
 
157
284
  ```typescript
@@ -159,14 +286,14 @@ import type { OwnedCollectionAdapter } from "@marianmeres/ownsuite";
159
286
  import { HTTP_ERROR } from "@marianmeres/http-utils";
160
287
 
161
288
  const adapter: OwnedCollectionAdapter = {
162
- async list(_ctx, query) {
289
+ async list(ctx, query) {
163
290
  const url = new URL(`/api/shop/me/col/order/mod`, location.origin);
164
291
  if (query) for (const [k, v] of Object.entries(query)) url.searchParams.set(k, String(v));
165
- const res = await fetch(url);
292
+ const res = await fetch(url, { signal: ctx.signal }); // forward abort
166
293
  if (!res.ok) throw new HTTP_ERROR.BadRequest(await res.text());
167
294
  return await res.json(); // { data, meta }
168
295
  },
169
- // getOne, create, update, delete similarly
296
+ // getOne, create, update, delete similarly — always forward ctx.signal
170
297
  };
171
298
  ```
172
299
 
@@ -234,10 +361,16 @@ dev:
234
361
  ## Testing
235
362
 
236
363
  ```bash
237
- deno task test # run all tests (10 tests)
364
+ deno task test # run all tests (44 tests across 4 files)
238
365
  deno task test:watch # watch mode
239
366
  ```
240
367
 
368
+ Coverage by file:
369
+ - `tests/ownsuite.test.ts` — core suite + `OwnedCollectionManager` CRUD, events, rollback.
370
+ - `tests/concurrency.test.ts` — critical invariants: concurrent mutations, abort-supersede, `getOne` not setting error, phantom-row prevention, destroy semantics, `errors()`/`hasErrors()` helpers.
371
+ - `tests/auth.test.ts` — `AuthManager` / `ProfileManager` / `SessionManager`: register / login / logout / unverified gate / OAuth login (popup + redirect) / OAuth unlink / profile update patching session / deleteAccount / identity-change hook propagation.
372
+ - `tests/oauth-popup.test.ts` — `openOAuthPopup` message / timeout / popup-closed / origin-mismatch semantics via injectable `PopupWindowHost`.
373
+
241
374
  ## Build & Publish
242
375
 
243
376
  ```bash
@@ -277,6 +410,58 @@ Joy ships:
277
410
 
278
411
  See the full-stack-app-template repo for the end-to-end example.
279
412
 
413
+ ## Breaking changes in 2.0.0
414
+
415
+ The 1.x line has one open set of correctness bugs and a permissive API
416
+ that leaked state into domain errors on non-list operations. 2.0.0 fixes
417
+ those; the behaviors changed are:
418
+
419
+ 1. **`getOne()` no longer transitions the domain to `error`.** Previously
420
+ any adapter throw from `getOne` set `state: "error"` on the whole
421
+ domain, invalidating a healthy list view. Now it returns `null` and
422
+ logs at debug level. Callers relying on the error-state transition
423
+ must subscribe differently (wrap `getOne` or inspect adapter errors
424
+ directly).
425
+
426
+ 2. **`update(id, ...)` for an id absent from the cached list no longer
427
+ prepends a phantom row** on successful server response. The server
428
+ update is still applied (and `own:row:updated` emitted), but the list
429
+ stays as-is. Call `refresh()` to surface the row. Previously the row
430
+ was inserted at the top of the list.
431
+
432
+ 3. **`OwnsuiteContext.signal` is now populated by the manager on every
433
+ adapter call.** Adapters that declared `ctx: OwnsuiteContext` see no
434
+ compile break (the field was already allowed via the index
435
+ signature); adapters that want cancellation should now forward
436
+ `ctx.signal` to `fetch()`. Adapters that ignore it continue to work.
437
+
438
+ 4. **`createMockOwnedCollectionAdapter` rejects `create` payloads
439
+ containing `model_id`** by default. Tests that were relying on
440
+ passing a `model_id` at create time must either drop the field or
441
+ opt out via `rejectClientId: false` in the options. Rows with an
442
+ empty-string `model_id` in `seed` are also rejected.
443
+
444
+ 5. **Rollback is now per-row, not whole-list.** Behavioral semantics
445
+ are stricter: a failed `update` reverts only the updated row; a
446
+ failed `delete` re-inserts only the deleted row. If your app relied
447
+ on the whole-list-restore side effect (e.g., to drop rows added by
448
+ a concurrent refresh that raced with a failing mutation), note this
449
+ subtle shift.
450
+
451
+ 6. **`reset()` now emits `domain:state:changed`** for each domain that
452
+ transitions out of a non-initializing state. Subscribers that count
453
+ events may see more of them.
454
+
455
+ Non-breaking additions: `suite.destroy()`, `suite.errors()`,
456
+ `suite.hasErrors()`, `suite.setContext(ctx, { replace, refresh })`,
457
+ `manager.isDestroyed`, `manager.replaceContext(ctx)`.
458
+
459
+ **Account lifecycle managers (opt-in addition).** `suite.auth` /
460
+ `suite.session` / `suite.profile` attach automatically when
461
+ `adapters.auth` is passed to `createOwnsuite`. Existing owner-scoped
462
+ CRUD consumers see no change unless they opt in. Full surface is
463
+ described in the "Account lifecycle (optional)" section above.
464
+
280
465
  ## Differences from `@marianmeres/ecsuite`
281
466
 
282
467
  | Aspect | ecsuite | ownsuite |