doxum 0.1.29 → 0.1.30

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.
@@ -0,0 +1,493 @@
1
+ # Doxum Recipes
2
+
3
+ These are end-to-end public-API patterns. Adapt names and domain types; keep ownership and lifecycle semantics intact. Exact signatures live in [Public API](public-api.md).
4
+
5
+ ## Canonical document with dynamic entities
6
+
7
+ ```ts
8
+ import { createDocument, field, map, object, read, snapshot, type Infer } from 'doxum';
9
+
10
+ const task = object({
11
+ title: field<string>(),
12
+ done: field<boolean>(),
13
+ });
14
+
15
+ export const model = object({
16
+ tasks: map(task),
17
+ });
18
+
19
+ export type DocumentValue = Infer<typeof model>;
20
+
21
+ const document = createDocument({
22
+ schema: model,
23
+ initial: {
24
+ tasks: {
25
+ a: { title: 'Write', done: false },
26
+ },
27
+ },
28
+ });
29
+
30
+ document.update(draft => {
31
+ const task = draft.tasks.get('a');
32
+ if (task) task.done = true;
33
+ draft.tasks.put('b', { title: 'Review', done: false });
34
+ });
35
+
36
+ const done = read(document, value => value.tasks.get('a')?.done);
37
+ const durableTasks = read(document, value => snapshot(value.tasks));
38
+ ```
39
+
40
+ Use `object` for addressable structure, `map` for dynamic membership, and `field` for atomic payloads.
41
+
42
+ ## Atomic payload versus addressable structure
43
+
44
+ ```ts
45
+ const position = object({
46
+ x: field<number>(),
47
+ y: field<number>(),
48
+ });
49
+
50
+ const model = object({
51
+ position,
52
+ stroke: field<readonly { x: number; y: number }[]>(),
53
+ rows: list(field<{ id: string; label: string }>(), {
54
+ keyOf: row => row.id,
55
+ }),
56
+ });
57
+ ```
58
+
59
+ Mutate `position.x` independently. Replace `stroke` whole. Edit `rows` through keyed list methods.
60
+
61
+ ## Replace a nested member that contains collections
62
+
63
+ ```ts
64
+ import { createDocument, field, map, object, replace, table } from 'doxum';
65
+
66
+ const model = object({
67
+ entries: map(
68
+ object({
69
+ rows: table(object({ title: field<string>() })),
70
+ })
71
+ ),
72
+ });
73
+
74
+ const document = createDocument({
75
+ schema: model,
76
+ initial: {
77
+ entries: {
78
+ a: {
79
+ rows: {
80
+ ids: ['x'],
81
+ byId: { x: { title: 'First' } },
82
+ },
83
+ },
84
+ },
85
+ },
86
+ });
87
+
88
+ document.update(draft => {
89
+ const entry = draft.entries.get('a')!;
90
+ replace(entry, 'rows', {
91
+ ids: ['y'],
92
+ byId: { y: { title: 'Replacement' } },
93
+ });
94
+ });
95
+ ```
96
+
97
+ Use top-level `replace(parent,key,value)` for object/variant members whose Draft representation contains collection tools. Use a collection's own `replace(next)` when the collection itself is the mutation target.
98
+
99
+ ## Branded domain keys
100
+
101
+ ```ts
102
+ type PersonId = string & { readonly __person: unique symbol };
103
+
104
+ const personId = (value: unknown): value is PersonId =>
105
+ typeof value === 'string' && value.startsWith('person:');
106
+
107
+ const model = object({
108
+ people: map(object({ name: field<string>() }), { key: personId }),
109
+ });
110
+
111
+ const initial = parse(model, {
112
+ people: {
113
+ 'person:1': { name: 'Ada' },
114
+ },
115
+ });
116
+ ```
117
+
118
+ The branded key type flows through map/table methods, typed paths, impact, `observe`, and keyed projections.
119
+
120
+ ## Ordered table edits
121
+
122
+ ```ts
123
+ document.update(draft => {
124
+ draft.rows.create('c', { title: 'C' }, { after: 'b' });
125
+ draft.rows.move(['a', 'c'], { at: 'start' });
126
+ draft.rows.replace('b', { title: 'Updated B' });
127
+ });
128
+ ```
129
+
130
+ A multi-key move preserves the selected members' current relative order. `reorder(ids)` is for an exact full-membership permutation.
131
+
132
+ ## Ordered list edits
133
+
134
+ ```ts
135
+ const rows = list(field<{ id: string; label: string }>(), {
136
+ keyOf: row => row.id,
137
+ });
138
+
139
+ document.update(draft => {
140
+ draft.rows.insert({ id: 'c', label: 'C' }, { at: 'start' });
141
+ draft.rows.move('c', { after: 'a' });
142
+ draft.rows.replace('a', { id: 'a', label: 'A+' });
143
+ });
144
+ ```
145
+
146
+ Member replacement keeps the addressed stable key.
147
+
148
+ ## Tree edits
149
+
150
+ ```ts
151
+ const model = object({
152
+ outline: tree(field<{ title: string }>()),
153
+ });
154
+
155
+ document.update(draft => {
156
+ draft.outline.insert('root', { title: 'Root' });
157
+ draft.outline.insert('child', { title: 'Child' }, { parentId: 'root', index: 0 });
158
+ draft.outline.move('child', { index: 0 });
159
+ draft.outline.replace('child', { title: 'Renamed' });
160
+ });
161
+ ```
162
+
163
+ Use tree operations for topology. Never manually patch `rootId`, `parentId`, or `children` records.
164
+
165
+ ## Expected transaction rejection
166
+
167
+ ```ts
168
+ const result = document.update(draft => {
169
+ const row = draft.rows.get(id);
170
+ if (!row) {
171
+ throw new TransactionRejected({
172
+ code: 'missing-row',
173
+ message: 'Row does not exist',
174
+ });
175
+ }
176
+ row.title = title;
177
+ });
178
+
179
+ if (result.status === 'rejected') {
180
+ showIssues(result.issues);
181
+ }
182
+ ```
183
+
184
+ Use `TransactionRejected` only for expected application rejection. Ordinary exceptions are bugs/infrastructure failures and are rethrown after rollback.
185
+
186
+ ## Fine-grained document selector
187
+
188
+ ```ts
189
+ const selectedTitle = select(document, value => value.rows.get(activeId)?.title);
190
+
191
+ const stop = selectedTitle.subscribe(() => {
192
+ render(selectedTitle.current());
193
+ });
194
+ ```
195
+
196
+ The selector dynamically tracks what it actually reads and rebinds when branches change.
197
+
198
+ ## Commit path subscription
199
+
200
+ ```ts
201
+ const stop = document.subscribe(
202
+ path => path.rows.item(rowId).title,
203
+ commit => {
204
+ console.log(commit.revision, commit.changes);
205
+ }
206
+ );
207
+ ```
208
+
209
+ Use path subscription when the consumer cares about canonical commit occurrence at a known schema location.
210
+
211
+ ## History group
212
+
213
+ ```ts
214
+ const group = document.history.group();
215
+ try {
216
+ document.update(draft => editPartA(draft));
217
+ document.update(draft => editPartB(draft));
218
+ group.end();
219
+ } catch (error) {
220
+ group.cancel();
221
+ throw error;
222
+ }
223
+ ```
224
+
225
+ Undo/redo travels committed ChangeSets. Grouping is a history concern, not a cross-transaction rollback mechanism for arbitrary external side effects.
226
+
227
+ ## Apply external ChangeSet
228
+
229
+ ```ts
230
+ const result = document.apply(incomingChanges, {
231
+ expectedRevision: document.revision(),
232
+ source: 'remote',
233
+ });
234
+
235
+ if (result.status === 'rejected') {
236
+ handleMutationIssues(result.issues);
237
+ }
238
+ ```
239
+
240
+ Always provide the baseline revision. Do not trust incoming `before` fields as authority over local state.
241
+
242
+ ## Basic projection pipeline
243
+
244
+ ```ts
245
+ const rows = observe(document, path => path.rows);
246
+ const filter = input<'all' | 'open'>('all');
247
+
248
+ const visible = derive.keyed.filter(
249
+ rows,
250
+ { filter },
251
+ (row, _id, { filter }) => filter === 'all' || !row.done
252
+ );
253
+
254
+ const labels = derive.keyed(visible, row => row.title);
255
+ const count = derive({ labels }, ({ labels }) => labels.size);
256
+
257
+ const runtime = createProjectionRuntime({ onError: reportProjectionError });
258
+
259
+ runtime.read(labels);
260
+ runtime.read(count);
261
+ runtime.update(filter, 'open');
262
+ ```
263
+
264
+ Use projection input for Runtime-local UI/application state. Keep it out of the canonical document unless the state needs document history/persistence/replay semantics.
265
+
266
+ ## Dynamic keyed join
267
+
268
+ ```ts
269
+ const cards = derive.keyed(
270
+ items,
271
+ {
272
+ record: { source: records, key: item => item.recordId },
273
+ related: { source: records, keys: item => item.relatedRecordIds },
274
+ density,
275
+ },
276
+ (item, itemId, { record, related, density }) => buildCard(itemId, item, record, related, density)
277
+ );
278
+ ```
279
+
280
+ The Runtime owns reverse invalidation. Do not subscribe to `records` and manually maintain `recordId -> itemIds` just for dependency routing.
281
+
282
+ ## Ordered keyed entries as a scalar value
283
+
284
+ ```ts
285
+ const entries = derive.keyed.entries(records);
286
+ ```
287
+
288
+ Use this instead of a two-stage `derive.keyed(records, (value,key) => [key,value])` followed by `values`.
289
+
290
+ ## Active keyed lookup
291
+
292
+ ```ts
293
+ const activeId = input<RowId | undefined>(undefined);
294
+ const activeRow = derive.keyed.get(rows, activeId);
295
+ ```
296
+
297
+ The binding remains meaningful while the selected key is absent and reacts if that key appears later.
298
+
299
+ ## Reverse index / groupBy
300
+
301
+ ```ts
302
+ const recordIdsBySection = derive.keyed.groupBy(records, record => record.sectionIds);
303
+ ```
304
+
305
+ Output is keyed by section id; each value is ordered source record ids. Use this instead of maintaining a global reverse-index Map in application code.
306
+
307
+ ## Optional scalar to keyed singleton
308
+
309
+ ```ts
310
+ const activeRecordCollection = derive.keyed.singleton(activeRecord, record => record.id);
311
+ ```
312
+
313
+ `undefined` becomes empty membership; present value becomes one keyed member.
314
+
315
+ ## Stable keyed item Readables
316
+
317
+ ```ts
318
+ const items = runtime.items(rows);
319
+ const row = items.get(rowId);
320
+
321
+ const stop = row.subscribe(() => {
322
+ renderRow(row.current());
323
+ });
324
+ ```
325
+
326
+ Use `items.keys` for ordered membership and `items.get(key)` for one membership-lifecycle readable. Avoid business/React layers maintaining their own selector cache.
327
+
328
+ ## Runtime-local keyed UI state
329
+
330
+ ```ts
331
+ const selection = input.collection<RowId, boolean>();
332
+
333
+ runtime.update(selection, draft => {
334
+ draft.set(rowId, true);
335
+ draft.remove(previousId);
336
+ });
337
+ ```
338
+
339
+ If the edit callback or entry equality throws, no partial next collection state is installed.
340
+
341
+ ## Scoped projection lifecycle
342
+
343
+ ```ts
344
+ const scope = runtime.scope();
345
+
346
+ const localFilter = scope.own(input<'all' | 'open'>('all'));
347
+ const localVisible = scope.own(
348
+ derive.keyed.filter(
349
+ rows,
350
+ { filter: localFilter },
351
+ (row, _id, { filter }) => filter === 'all' || !row.done
352
+ )
353
+ );
354
+
355
+ scope.read(localVisible);
356
+ scope.update(localFilter, 'open');
357
+ scope.dispose();
358
+ ```
359
+
360
+ Own the definition before it is first materialized as a root. Use scope for lifecycle, not as a second graph implementation.
361
+
362
+ ## Stateful keyed calculation
363
+
364
+ ```ts
365
+ import { incremental } from 'doxum/advanced';
366
+
367
+ const sectionTotals = incremental.keyed(
368
+ sections,
369
+ {
370
+ records: { source: records, keys: section => section.recordIds },
371
+ },
372
+ {
373
+ state: () => ({ runs: 0 }),
374
+ process: ({ dependencies, state }) => {
375
+ state.runs++;
376
+ let total = 0;
377
+ for (const record of dependencies.records.values()) total += record.amount;
378
+ return total;
379
+ },
380
+ }
381
+ );
382
+ ```
383
+
384
+ Use this when each driver key truly needs retained state. If the value is a pure function of current dependencies, ordinary `derive.keyed` is simpler.
385
+
386
+ ## Direct incremental collection patch
387
+
388
+ ```ts
389
+ const projected = incremental.collection(
390
+ { rows },
391
+ {
392
+ process: ({ values, changes, output, reset }) => {
393
+ if (reset) {
394
+ for (const [id, row] of values.rows) output.set(id, project(row));
395
+ output.order([...values.rows.keys()]);
396
+ return;
397
+ }
398
+
399
+ const change = changes.rows;
400
+ if (!change || change.kind === 'reset') return;
401
+ for (const item of change.added) output.set(item.key, project(item.after));
402
+ for (const item of change.updated) output.set(item.key, project(item.after));
403
+ for (const item of change.removed) output.remove(item.key);
404
+ if (change.order) output.order([...values.rows.keys()]);
405
+ },
406
+ }
407
+ );
408
+ ```
409
+
410
+ Prefer ordinary keyed derive for pure per-entry projection. Use direct patching when the incremental algorithm itself is the requirement.
411
+
412
+ ## Multi-output incremental processor
413
+
414
+ ```ts
415
+ const render = incremental.group(
416
+ { scene },
417
+ {
418
+ output: define => ({
419
+ cards: define.collection<CardId, Card>(),
420
+ count: define.value<number>(),
421
+ }),
422
+ state: () => ({ runs: 0 }),
423
+ process: ({ values, output, state }) => {
424
+ state.runs++;
425
+ const cards = buildCards(values.scene);
426
+ for (const [id, card] of cards) output.cards.set(id, card);
427
+ output.cards.order([...cards.keys()]);
428
+ output.count.set(cards.size);
429
+ },
430
+ }
431
+ );
432
+ ```
433
+
434
+ Use one group only when outputs actually share retained state or incremental work.
435
+
436
+ ## React provider and hooks
437
+
438
+ ```tsx
439
+ const runtime = useMemo(() => createProjectionRuntime({ onError: report }), []);
440
+ useEffect(() => () => runtime.dispose(), [runtime]);
441
+
442
+ return (
443
+ <ProjectionProvider value={runtime}>
444
+ <Rows />
445
+ </ProjectionProvider>
446
+ );
447
+ ```
448
+
449
+ Inside components:
450
+
451
+ ```tsx
452
+ const rows = useProjection(visibleRows);
453
+ const active = useProjection(rowsProjection, rows => rows.get(activeId));
454
+ const [filter, setFilter] = useInput(filterInput);
455
+ const title = useDocumentSelector(document, value => value.title);
456
+ ```
457
+
458
+ Keep Runtime creation/disposal at a stable application/component boundary.
459
+
460
+ ## External source adapter
461
+
462
+ ```ts
463
+ const external: ExternalValueSource<number> = {
464
+ kind: 'value',
465
+ current: () => current,
466
+ revision: () => revision,
467
+ subscribe(listener) {
468
+ listeners.add(listener);
469
+ return () => listeners.delete(listener);
470
+ },
471
+ };
472
+
473
+ const projected = observe(external);
474
+ ```
475
+
476
+ On update, update the source's current state/revision, then notify listeners with `{ value, revision, cause? }`.
477
+
478
+ ## Local sync
479
+
480
+ ```ts
481
+ const sync = await attachLocalSync({
482
+ runtime: document,
483
+ database: 'app',
484
+ documentId: 'doc:1',
485
+ schemaVersion: 1,
486
+ onError: reportSyncError,
487
+ });
488
+
489
+ await sync.flush();
490
+ await sync.dispose();
491
+ ```
492
+
493
+ Local-sync provides browser durability and leadership. Keep network collaboration, server authorization, and distributed merge policy in a separate boundary.