@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/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.
|