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.
- package/README.md +3 -2
- package/dist/index.cjs +6 -2
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +6 -2
- package/dist/index.js.map +1 -1
- package/package.json +3 -2
- package/skills/doxum-runtime/SKILL.md +64 -82
- package/skills/doxum-runtime/agents/openai.yaml +2 -2
- package/skills/doxum-runtime/references/document-runtime.md +370 -0
- package/skills/doxum-runtime/references/integrations.md +215 -0
- package/skills/doxum-runtime/references/{invariants.en.md → invariants.md} +5 -2
- package/skills/doxum-runtime/references/projections.md +467 -0
- package/skills/doxum-runtime/references/public-api.md +623 -0
- package/skills/doxum-runtime/references/recipes.md +493 -0
- package/skills/doxum-runtime/references/api.en.md +0 -283
- package/skills/doxum-runtime/references/api.zh-CN.md +0 -276
- package/skills/doxum-runtime/references/guide.en.md +0 -138
- package/skills/doxum-runtime/references/guide.zh-CN.md +0 -116
- package/skills/doxum-runtime/references/invariants.zh-CN.md +0 -53
- package/skills/doxum-runtime/references/patterns.en.md +0 -162
- package/skills/doxum-runtime/references/patterns.zh-CN.md +0 -159
- package/skills/doxum-runtime/references/projections.en.md +0 -313
- package/skills/doxum-runtime/references/projections.zh-CN.md +0 -306
|
@@ -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.
|