@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 ADDED
@@ -0,0 +1,292 @@
1
+ # AGENTS.md - @marianmeres/ownsuite
2
+
3
+ Machine-readable documentation for AI coding assistants.
4
+
5
+ ## Package Overview
6
+
7
+ ```yaml
8
+ name: "@marianmeres/ownsuite"
9
+ version: "1.0.0"
10
+ type: "library"
11
+ language: "typescript"
12
+ runtime: "deno"
13
+ npm_compatible: true
14
+ license: "MIT"
15
+ entry: "./src/mod.ts"
16
+ ```
17
+
18
+ ## Purpose
19
+
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
+
22
+ Pairs with:
23
+ - **`@marianmeres/collection`** — `ownerIdScope` route hook (read-side owner enforcement).
24
+ - **`@marianmeres/stack-common`** — `ownsuiteOptions()` server helper for mounting `/me/*` routes.
25
+
26
+ ## Architecture
27
+
28
+ ```
29
+ Ownsuite (orchestrator)
30
+ ├── #pubsub (shared event bus)
31
+ ├── #context (propagated to all domains on setContext)
32
+ └── domains: Map<string, OwnedCollectionManager>
33
+ ├── store (Svelte-compatible DomainStateWrapper<OwnedCollectionState<TRow>>)
34
+ ├── adapter (OwnedCollectionAdapter)
35
+ ├── state machine: initializing → ready ↔ syncing → error
36
+ └── optimistic update + rollback on create/update/delete
37
+ ```
38
+
39
+ 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
+
41
+ ## Directory Structure
42
+
43
+ ```
44
+ src/
45
+ ├── mod.ts # entry, re-exports all
46
+ ├── ownsuite.ts # Ownsuite class, createOwnsuite, OwnsuiteConfig
47
+ ├── types/
48
+ │ ├── mod.ts
49
+ │ ├── state.ts # DomainState/Wrapper/Error, OwnsuiteContext, OwnedCollectionState
50
+ │ ├── events.ts # OwnsuiteEventType, OwnsuiteEvent union, per-event interfaces
51
+ │ └── adapter.ts # OwnedCollectionAdapter, OwnedListResult, OwnedRowResult
52
+ ├── domains/
53
+ │ ├── mod.ts
54
+ │ ├── base.ts # BaseDomainManager abstract class (mirrors ecsuite)
55
+ │ └── owned-collection.ts # OwnedCollectionManager<TRow, TCreate, TUpdate>
56
+ └── adapters/
57
+ ├── mod.ts
58
+ └── mock.ts # createMockOwnedCollectionAdapter for tests
59
+ tests/
60
+ └── ownsuite.test.ts
61
+ ```
62
+
63
+ ## Key Exports
64
+
65
+ ```typescript
66
+ // Main
67
+ export { Ownsuite, createOwnsuite } from "./ownsuite.ts";
68
+ export type { OwnsuiteConfig, OwnsuiteDomainConfig } from "./ownsuite.ts";
69
+
70
+ // Domain managers
71
+ export { BaseDomainManager, OwnedCollectionManager } from "./domains/mod.ts";
72
+ export type { BaseDomainOptions, OwnedCollectionManagerOptions } from "./domains/mod.ts";
73
+
74
+ // Types
75
+ export type {
76
+ DomainError, DomainState, DomainStateWrapper,
77
+ OwnsuiteContext, OwnedCollectionState,
78
+ OwnsuiteEvent, OwnsuiteEventType, DomainName,
79
+ StateChangedEvent, ErrorEvent, SyncedEvent,
80
+ ListFetchedEvent, RowFetchedEvent,
81
+ RowCreatedEvent, RowUpdatedEvent, RowDeletedEvent,
82
+ OwnedCollectionAdapter, OwnedListResult, OwnedRowResult,
83
+ } from "./types/mod.ts";
84
+
85
+ // Mock adapter (for tests)
86
+ export { createMockOwnedCollectionAdapter } from "./adapters/mod.ts";
87
+ export type { MockAdapterOptions } from "./adapters/mod.ts";
88
+ ```
89
+
90
+ ## State Machine
91
+
92
+ ```
93
+ initializing → ready
94
+ ready ↔ syncing
95
+ syncing → error (on failure; rolled-back data restored)
96
+ error → syncing (retry)
97
+ ```
98
+
99
+ Triggered by `initialize()`, `refresh()`, `create()`, `update()`, `delete()` on each domain manager.
100
+
101
+ ## Critical Invariants
102
+
103
+ 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
+
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.
106
+
107
+ 3. **Row ids default to `model_id`, fallback `id`.** Override via `getRowId` in `OwnsuiteDomainConfig` or `OwnedCollectionManagerOptions` when rows have a different key shape.
108
+
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`.
110
+
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.
112
+
113
+ 6. **`OwnsuiteContext.subjectId` is a hint, not authorization.** The server is authoritative. Setting it client-side has no security effect.
114
+
115
+ ## Common Patterns
116
+
117
+ ### Register domains at construction
118
+
119
+ ```typescript
120
+ const suite = createOwnsuite({
121
+ context: { subjectId: "user-123" },
122
+ domains: {
123
+ orders: { adapter: ordersAdapter },
124
+ addresses: { adapter: addressesAdapter, getRowId: (r) => r.address_id },
125
+ },
126
+ });
127
+ await suite.initialize();
128
+ ```
129
+
130
+ ### Register domains after construction
131
+
132
+ ```typescript
133
+ const suite = createOwnsuite();
134
+ suite.registerDomain("orders", { adapter: ordersAdapter });
135
+ await suite.initialize(["orders"]);
136
+ ```
137
+
138
+ ### Subscribe (Svelte-compatible)
139
+
140
+ ```typescript
141
+ suite.domain("orders").subscribe((s) => {
142
+ // s: { state, data, error, lastSyncedAt }
143
+ // s.data: { rows: TRow[]; meta: Record<string, unknown> } | null
144
+ });
145
+ ```
146
+
147
+ ### Events
148
+
149
+ ```typescript
150
+ suite.on("own:row:created", (e) => {/* e.rowId, e.domain, e.timestamp */});
151
+ suite.on("domain:error", (e) => {/* e.error */});
152
+ suite.onAny(({ event, data }) => {/* wildcard envelope */});
153
+ ```
154
+
155
+ ### Implementing a real adapter
156
+
157
+ ```typescript
158
+ import type { OwnedCollectionAdapter } from "@marianmeres/ownsuite";
159
+ import { HTTP_ERROR } from "@marianmeres/http-utils";
160
+
161
+ const adapter: OwnedCollectionAdapter = {
162
+ async list(_ctx, query) {
163
+ const url = new URL(`/api/shop/me/col/order/mod`, location.origin);
164
+ if (query) for (const [k, v] of Object.entries(query)) url.searchParams.set(k, String(v));
165
+ const res = await fetch(url);
166
+ if (!res.ok) throw new HTTP_ERROR.BadRequest(await res.text());
167
+ return await res.json(); // { data, meta }
168
+ },
169
+ // getOne, create, update, delete similarly
170
+ };
171
+ ```
172
+
173
+ Joy ships a reusable factory at `src/admin/packages/joy/src/routes/me/owned-collection-adapter.ts` — use it as reference.
174
+
175
+ ### Testing with the mock adapter
176
+
177
+ ```typescript
178
+ import {
179
+ createMockOwnedCollectionAdapter,
180
+ createOwnsuite,
181
+ } from "@marianmeres/ownsuite";
182
+
183
+ const adapter = createMockOwnedCollectionAdapter({
184
+ seed: [{ model_id: "1", data: { label: "a" } }],
185
+ failOn: { update: true },
186
+ });
187
+ const suite = createOwnsuite({ domains: { notes: { adapter } } });
188
+ await suite.initialize();
189
+ await suite.domain("notes").update("1", { data: { label: "b" } });
190
+ // state === "error"; list rolled back to original
191
+ ```
192
+
193
+ ## Common Tasks
194
+
195
+ ### Add a new event type
196
+
197
+ 1. Add string literal to `OwnsuiteEventType` in `src/types/events.ts`
198
+ 2. Add event interface extending `OwnsuiteEventBase`
199
+ 3. Add to `OwnsuiteEvent` discriminated union
200
+ 4. Emit via `this.emit({ type, domain, timestamp, ... })` from the manager
201
+
202
+ ### Add a new domain shape (beyond OwnedCollectionManager)
203
+
204
+ 1. Create manager class in `src/domains/` extending `BaseDomainManager<TData, TAdapter>`
205
+ 2. Implement `initialize()` (required abstract method)
206
+ 3. Emit domain-specific events via `this.emit(...)`
207
+ 4. Register in `Ownsuite` if first-class, or let consumers register manually via `setAdapter`
208
+
209
+ Note: `Ownsuite.registerDomain()` currently hard-codes `OwnedCollectionManager`. If adding a different manager shape, extend `Ownsuite` with a second registration method or make the manager type pluggable.
210
+
211
+ ### Switch an existing domain to a different adapter at runtime
212
+
213
+ ```typescript
214
+ suite.domain("orders").setAdapter(newAdapter);
215
+ await suite.domain("orders").refresh();
216
+ ```
217
+
218
+ ## Dependencies
219
+
220
+ ```yaml
221
+ runtime:
222
+ "@marianmeres/clog": "^3.15.3"
223
+ "@marianmeres/collection-types": "^1.36.0"
224
+ "@marianmeres/http-utils": "^2.5.1"
225
+ "@marianmeres/pubsub": "^2.4.6"
226
+ "@marianmeres/store": "^2.4.4"
227
+ dev:
228
+ "@marianmeres/npmbuild": "^1.11.0"
229
+ "@std/assert": "^1.0.19"
230
+ "@std/fs": "^1.0.23"
231
+ "@std/path": "^1.1.4"
232
+ ```
233
+
234
+ ## Testing
235
+
236
+ ```bash
237
+ deno task test # run all tests (10 tests)
238
+ deno task test:watch # watch mode
239
+ ```
240
+
241
+ ## Build & Publish
242
+
243
+ ```bash
244
+ deno task npm:build # build npm-compatible dist via @marianmeres/npmbuild
245
+ deno task npm:publish # build + npm publish --access=public
246
+ deno task publish # JSR publish + npm publish
247
+ deno task release # bump + changelog
248
+ deno task rp # release patch + publish
249
+ deno task rpm # release minor + publish
250
+ ```
251
+
252
+ ## Integration Notes
253
+
254
+ ### Server-side pairing
255
+
256
+ The client-side scope assumes the server enforces owner-based filtering. This requires:
257
+
258
+ 1. **Collection package**: a collection with `owner_id_mode: "auto"` or `"required"`.
259
+ 2. **Server mount**: `createCollectionRoutes(app, mw, { adapter, ...ownsuiteOptions() })` from `@marianmeres/stack-common`. This wires both `ownerIdExtractor` (write-side, stamps `owner_id` from subject on create) and `ownerIdScope` (read-side, 404 on foreign rows + auto-filtered lists).
260
+ 3. **Auth middleware**: must populate `ctx.locals.subject` (typically via `@marianmeres/stack-common`'s `createJwtMiddleware`) before the collection routes handle the request.
261
+
262
+ URL shape the default adapter helper expects:
263
+ ```
264
+ List GET {apiRoot}/{stack}/me/col/{entity}/mod
265
+ Get GET {apiRoot}/{stack}/me/col/{entity}/mod/{id}
266
+ Create POST {apiRoot}/{stack}/me/col/{entity}/mod
267
+ Update PUT {apiRoot}/{stack}/me/col/{entity}/mod/{id}
268
+ Delete DELETE {apiRoot}/{stack}/me/col/{entity}/mod/{id}
269
+ ```
270
+
271
+ ### Joy admin SPA pairing
272
+
273
+ Joy ships:
274
+ - `src/admin/packages/joy/src/components/layout/LayoutCustomer.svelte` — simplified chrome for `/me/*`.
275
+ - `src/admin/packages/joy/src/routes/me/MeRouter.svelte` — route entry point.
276
+ - `src/admin/packages/joy/src/routes/me/owned-collection-adapter.ts` — reusable adapter factory.
277
+
278
+ See the full-stack-app-template repo for the end-to-end example.
279
+
280
+ ## Differences from `@marianmeres/ecsuite`
281
+
282
+ | Aspect | ecsuite | ownsuite |
283
+ |--------|---------|----------|
284
+ | Domains | Fixed 6 (cart, wishlist, order, customer, payment, product) | Arbitrary, registered by name |
285
+ | State shape | Domain-specific (cart items, orders list, etc.) | Generic `{ rows, meta }` per domain |
286
+ | Persistence | localStorage for cart/wishlist | None (server is source of truth) |
287
+ | Scoping | Customer-id hint in context | Server-enforced via `owner_id` |
288
+ | Optimistic create | Yes (cart items) | No (no client-assigned id) |
289
+ | Optimistic update/delete | Yes | Yes |
290
+ | Event namespaces | `cart:*`, `order:*`, etc. | `own:list:*`, `own:row:*` |
291
+
292
+ Consumers that already use ecsuite can compose both suites in the same app.