doxum 0.1.8 → 0.1.10

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.
Files changed (70) hide show
  1. package/README.md +233 -324
  2. package/dist/contract-CeAnEPBA.d.ts +458 -0
  3. package/dist/contract-DtGVSXSK.d.cts +458 -0
  4. package/dist/driver-BlR81Dqg.js +200 -0
  5. package/dist/driver-BlR81Dqg.js.map +1 -0
  6. package/dist/driver-xOIkwrB8.cjs +241 -0
  7. package/dist/driver-xOIkwrB8.cjs.map +1 -0
  8. package/dist/index.cjs +666 -2222
  9. package/dist/index.cjs.map +1 -1
  10. package/dist/index.d.cts +10 -20
  11. package/dist/index.d.ts +10 -18
  12. package/dist/index.js +634 -2189
  13. package/dist/index.js.map +1 -1
  14. package/dist/{integration-CZwCwFBS.cjs → integration-BKgGuodm.cjs} +4 -9
  15. package/dist/integration-BKgGuodm.cjs.map +1 -0
  16. package/dist/{integration-B56u1l9V.js → integration-VTZ3ICsO.js} +2 -7
  17. package/dist/integration-VTZ3ICsO.js.map +1 -0
  18. package/dist/integration.cjs +11 -11
  19. package/dist/integration.d.cts +10 -4
  20. package/dist/integration.d.ts +10 -4
  21. package/dist/integration.js +4 -3
  22. package/dist/issue-DVaGQGeP.js +576 -0
  23. package/dist/issue-DVaGQGeP.js.map +1 -0
  24. package/dist/issue-DhrNdQNg.cjs +797 -0
  25. package/dist/issue-DhrNdQNg.cjs.map +1 -0
  26. package/dist/local-sync.cjs +67 -39
  27. package/dist/local-sync.cjs.map +1 -1
  28. package/dist/local-sync.d.cts +10 -10
  29. package/dist/local-sync.d.ts +10 -10
  30. package/dist/local-sync.js +66 -38
  31. package/dist/local-sync.js.map +1 -1
  32. package/dist/react.cjs +23 -4
  33. package/dist/react.cjs.map +1 -1
  34. package/dist/react.d.cts +8 -3
  35. package/dist/react.d.ts +8 -3
  36. package/dist/react.js +21 -6
  37. package/dist/react.js.map +1 -1
  38. package/dist/store-Bs0C0rNd.d.ts +262 -0
  39. package/dist/store-CJuwJl_K.d.cts +262 -0
  40. package/dist/store-DHFXfWSz.js +2554 -0
  41. package/dist/store-DHFXfWSz.js.map +1 -0
  42. package/dist/store-DU2u-qFH.cjs +2793 -0
  43. package/dist/store-DU2u-qFH.cjs.map +1 -0
  44. package/package.json +1 -1
  45. package/skills/doxum-runtime/SKILL.md +28 -44
  46. package/skills/doxum-runtime/references/guide.en.md +99 -366
  47. package/skills/doxum-runtime/references/guide.zh-CN.md +83 -290
  48. package/skills/doxum-runtime/references/invariants.en.md +42 -165
  49. package/skills/doxum-runtime/references/invariants.zh-CN.md +34 -97
  50. package/skills/doxum-runtime/references/patterns.en.md +73 -225
  51. package/skills/doxum-runtime/references/patterns.zh-CN.md +73 -179
  52. package/dist/chunk-pbuEa-1d.js +0 -13
  53. package/dist/contract-CIcftgR3.d.ts +0 -561
  54. package/dist/contract-b87yPRDp.d.cts +0 -561
  55. package/dist/driver-CNxqMVFH.cjs +0 -69
  56. package/dist/driver-CNxqMVFH.cjs.map +0 -1
  57. package/dist/driver-CbzfW5MR.js +0 -46
  58. package/dist/driver-CbzfW5MR.js.map +0 -1
  59. package/dist/integration-B56u1l9V.js.map +0 -1
  60. package/dist/integration-CZwCwFBS.cjs.map +0 -1
  61. package/dist/ownership-CY0nPXGF.cjs +0 -304
  62. package/dist/ownership-CY0nPXGF.cjs.map +0 -1
  63. package/dist/ownership-CduRygE7.js +0 -245
  64. package/dist/ownership-CduRygE7.js.map +0 -1
  65. package/dist/runtime-0mOFbe_H.cjs +0 -2439
  66. package/dist/runtime-0mOFbe_H.cjs.map +0 -1
  67. package/dist/runtime-BzxODO9Q.d.ts +0 -150
  68. package/dist/runtime-CRVMB9n1.d.cts +0 -150
  69. package/dist/runtime-DF1q9Gje.js +0 -2129
  70. package/dist/runtime-DF1q9Gje.js.map +0 -1
package/README.md CHANGED
@@ -1,355 +1,261 @@
1
1
  # Doxum
2
2
 
3
- Doxum is a typed TypeScript runtime for complex mutable documents. It is built
4
- for editors and product surfaces that need structured state, atomic updates,
5
- undo/redo, precise change notifications, and incremental derived data.
6
-
7
- Licensed under the [MIT License](LICENSE).
8
-
9
- The `doxum` core entry is a local in-memory runtime. The optional
10
- `doxum/local-sync` browser attachment adds IndexedDB persistence and
11
- same-origin cross-tab synchronization with one writable tab at a time; network
12
- collaboration, authorization, and conflict resolution remain separate
13
- application concerns.
14
-
15
- ## Packages
16
-
17
- - `doxum` defines schemas, mutations, history, subscriptions, and views.
18
- - `doxum/local-sync` lets one Web-Lock leader write a runtime synchronously,
19
- persists its commands asynchronously to IndexedDB, and makes other tabs
20
- ordered read-only mirrors.
21
- - `doxum/react` binds Doxum read models to React 18+ with fine-grained external
22
- store subscriptions.
23
-
24
- ## Install
3
+ Doxum is a TypeScript runtime for mutable documents with synchronous atomic
4
+ updates, reversible final changes, history, precise subscriptions, and incremental
5
+ projections. Licensed under the [MIT License](LICENSE).
25
6
 
26
7
  ```sh
27
8
  pnpm add doxum
28
9
  ```
29
10
 
30
- For React bindings, install React alongside Doxum and import from `doxum/react`:
31
-
32
- ```sh
33
- pnpm add doxum react
34
- ```
35
-
36
- Use the package manager that owns your application if it is not pnpm.
37
-
38
- ## AI Development Guide
39
-
40
- Doxum ships a task-oriented guide for AI assistants and application developers.
41
- It is the recommended reference for using `doxum` and `doxum/react` in place
42
- of a generated, symbol-by-symbol API reference. The guide is included in the
43
- published `doxum` package at `skills/doxum-runtime`.
44
-
45
- - [English guide](skills/doxum-runtime/references/guide.en.md)
46
- - [中文指南](skills/doxum-runtime/references/guide.zh-CN.md)
47
- - [English patterns](skills/doxum-runtime/references/patterns.en.md)
48
- - [中文模式参考](skills/doxum-runtime/references/patterns.zh-CN.md)
49
- - [English invariants](skills/doxum-runtime/references/invariants.en.md)
50
- - [中文不变量](skills/doxum-runtime/references/invariants.zh-CN.md)
51
- - [AI skill instructions](skills/doxum-runtime/SKILL.md)
52
-
53
- Tools that support `SKILL.md` can install or link the complete
54
- `doxum-runtime` directory as the `$doxum-runtime` skill. The files are also
55
- ordinary Markdown: read the guide matching your working language, then use the
56
- patterns and invariants references for the task at hand.
57
-
58
- ## Quick Start
59
-
60
- Define the document shape once. Doxum infers the immutable document value and
61
- the reader and writer APIs from that schema.
11
+ ## Define And Update
62
12
 
63
13
  ```ts
64
- import { createDocument, field, object, schema, table, type Infer } from 'doxum';
65
-
66
- const task = object({
67
- title: field<string>(),
68
- completed: field<boolean>(),
69
- });
14
+ import { createDocument, field, map, object, type Infer } from 'doxum';
70
15
 
71
- const taskSchema = schema({
72
- title: field<string>(),
73
- tasks: table(task),
74
- });
75
-
76
- type Task = Infer<typeof task>;
77
- type TaskDocument = Infer<typeof taskSchema>;
78
-
79
- const runtime = createDocument({
80
- schema: taskSchema,
81
- initial: {
82
- title: 'Launch',
83
- tasks: {
84
- ids: ['task-1'],
85
- byId: {
86
- 'task-1': { title: 'Write the brief', completed: false },
87
- },
88
- },
89
- },
90
- });
16
+ const task = object({ title: field<string>(), done: field<boolean>() });
17
+ const model = object({ tasks: map(task), title: field<string>() });
18
+ type TaskDocument = Infer<typeof model>;
91
19
 
92
- runtime.update(tx => {
93
- tx.write.tasks.item('task-1').completed.set(true);
94
- return tx.read.tasks.get('task-1')?.title.get();
20
+ const document = createDocument({
21
+ schema: model,
22
+ initial: { title: 'Launch', tasks: { a: { title: 'Write', done: false } } },
95
23
  });
96
- ```
97
-
98
- An update is synchronous and atomic. `apply` decodes untrusted operation
99
- payloads before they reach mutation code. A malformed or semantically rejected
100
- operation returns a typed `MutationIssue`; if an operation is rejected, if the
101
- transaction calls `tx.reject`, or if user code throws, every preceding change
102
- in that update is rolled back.
103
-
104
- Mutation failure codes are a closed public `MutationIssueCode` union. For
105
- application validation, use `tx.report` or `tx.reject` with a
106
- diagnostic `{ code, message, address? }`; Doxum adds `source: 'application'`.
107
- Published diagnostic arrays and addresses are copied and frozen.
108
-
109
- ## Infer Value Types
110
-
111
- Use `Infer<typeof node>` or `Infer<typeof documentSchema>` for value types.
112
- Schema-generated objects are flattened, with readonly properties and optional
113
- presence preserved. Variants produce a discriminated union of flat branches:
114
-
115
- ```ts
116
- import { field, object, variant, type Infer } from 'doxum';
117
24
 
118
- const outcome = variant('kind', {
119
- victory: object({ reason: field<'sealed' | 'destroyed'>() }),
120
- defeat: object({ reason: field<'deadline' | 'collapse'>() }),
25
+ document.update(draft => {
26
+ const task = draft.tasks.get('a');
27
+ if (task) task.done = true;
28
+ draft.tasks.put('b', { title: 'Review', done: false });
29
+ draft.tasks.remove('a');
30
+ return { warnings: [] };
121
31
  });
122
-
123
- type Outcome = Infer<typeof outcome>;
124
- // { readonly kind: 'victory'; readonly reason: 'sealed' | 'destroyed' }
125
- // | { readonly kind: 'defeat'; readonly reason: 'deadline' | 'collapse' }
126
32
  ```
127
33
 
128
- `Infer` includes `undefined` for an optional node; in a containing object its
129
- property is optional. User-supplied types in `field<T>`, dict, list and tree
130
- values retain their original type structure. Flattening does not recursively
131
- rewrite those types or apply deep readonly to them. TypeScript controls the
132
- exact alias presentation in editor hovers.
133
-
134
- ## Read And Subscribe
135
-
136
- Use `select` for a one-off typed read. Use schema selectors and `subscribe`
137
- when a non-React consumer needs only relevant commits.
34
+ The root object is the schema identity. Multiple runtimes can share its definition;
35
+ their data, revisions and subscriptions remain independent. Definitions are immutable.
36
+
37
+ `object()` exposes editable structure with a closed schema: undeclared own properties,
38
+ including symbols and non-enumerable properties, are rejected at input boundaries.
39
+ A variant allows its discriminant and the active branch's declared members.
40
+ Use `map()` for dynamic keys and `field<T>()` for arbitrary payload objects.
41
+ `field<T>()` is atomic: replace the whole
42
+ value, including objects, arrays, Maps and Dates. Atomic payloads are shared by
43
+ reference across inputs, reads, snapshots, commits and history. They are deeply
44
+ readonly in `Infer`/`Read`/`Draft`: never mutate them through any alias, even after
45
+ removal from the document. Published results are readonly by contract, without
46
+ defensive deep copying or runtime freezing. Classes and functions need no copier.
47
+
48
+ Drafts and structural reads are borrowed for their synchronous callback. Draft and
49
+ internal reader proxies must not escape their callback; escaping them is undefined
50
+ behavior. Ordinary property
51
+ reads see preceding writes in the same update. Same-address proxies are stable
52
+ within a scope and resolve against current structure after replacement.
53
+
54
+ Throw `new TransactionRejected({ code, message, address? })` for expected business
55
+ rejection. Schema and structural failures also return `status: 'rejected'` and
56
+ restore all prior work. Other thrown values roll back and are rethrown unchanged.
57
+ Normal callback returns, including `false`, are business results, not cancellation.
58
+ Committed observer failures are returned in `observerErrors` without rollback.
59
+
60
+ ## Read And Observe
138
61
 
139
62
  ```ts
140
- import { select } from 'doxum';
141
-
142
- const taskTitles = select(runtime, read =>
143
- read.tasks.ids().map(id => read.tasks.get(id)?.title.get())
63
+ import { select, snapshot } from 'doxum';
64
+
65
+ const title = select(document, state => state.title);
66
+ const tasks = select(document, state => snapshot(state.tasks));
67
+ document.subscribe(
68
+ path => path.tasks.item('b').title,
69
+ commit => {
70
+ console.log(commit.revision, commit.changes);
71
+ }
144
72
  );
145
-
146
- const tasks = taskSchema.collection(path => path.tasks);
147
- const stop = runtime.subscribe(tasks, commit => {
148
- const change = commit.impact.collection(tasks);
149
- if (change.kind === 'incremental') console.log(change.updated);
150
- });
151
-
152
- stop();
73
+ document.subscribe([path => path.title, path => path.tasks], () => {});
153
74
  ```
154
75
 
155
- Each committed update produces a revision, forward operations, inverse
156
- operations, and a `DocumentImpact`. Collection impacts distinguish added,
157
- removed, updated, and reordered entries.
76
+ `snapshot` exports a stable value with the subtree's `Infer` type: editable schema
77
+ structure is copied, while immutable atomic payloads keep their identity.
78
+ `snapshot(rawPayload)` returns that same readonly reference. To edit exported
79
+ payloads, explicitly copy them in application code; `structuredClone` works for
80
+ supported types, while classes/functions need application-specific handling.
81
+ Use ordinary properties for fine-grained reads. Data callbacks read real values;
82
+ path callbacks describe symbolic schema locations, including missing entries.
83
+ Subscription paths compile once during registration.
158
84
 
159
- ## History And Operations
160
-
161
- Local history is enabled by default with a capacity of 100 commits. Doxum
162
- records inverse operations, so undo and redo follow the same mutation path as
163
- ordinary updates.
85
+ React integration:
164
86
 
165
87
  ```ts
166
- runtime.history.undo();
167
- runtime.history.redo();
88
+ import { useDocumentSelector, useHistory, useReadable } from 'doxum/react';
168
89
 
169
- runtime.apply([{ type: 'field.set', at: ['title'], value: 'Ship Doxum' }]);
90
+ const title = useDocumentSelector(document, state => state.title);
91
+ const history = useHistory(document.history);
170
92
  ```
171
93
 
172
- Use `runtime.history.group()` for one action spanning multiple synchronous
173
- updates, such as a drag. Keep its handle until the action ends, then call
174
- `end()` to retain one undo entry or `cancel()` to apply its inverses atomically.
175
- Groups cannot nest. Undo, redo, clear, remote/replace commits, and committed
176
- updates with `history: false` end the current group. History implements
177
- `Readable<HistoryState>` and can be used with `useReadable` or `fromReadable`.
178
-
179
- `apply` is the boundary for replaying operations from persistence or a network
180
- adapter. Doxum does not provide those adapters. A `replace` or a commit marked
181
- as `remote` invalidates local history because its prior inverse sequence is no
182
- longer authoritative.
183
-
184
- The returned result distinguishes `committed`, `unchanged`, and `rejected`.
185
- Observer failures do not turn a completed write into a rejection: committed
186
- results expose them in `observerErrors`, after history and canonical state have
187
- already settled.
188
-
189
- ## Local Persistence And Cross-Tab Sync
190
-
191
- `doxum/local-sync` is an optional browser attachment for a document that needs
192
- offline persistence and same-origin, cross-tab convergence. It uses IndexedDB
193
- as the ordered checkpoint and commit log, Web Locks to serialize background
194
- confirmation, and BroadcastChannel only to notify other tabs to catch up from
195
- IndexedDB. It does not need a server or Yjs.
196
-
197
- The attachment deliberately uses a simple single-writer model. It hydrates the
198
- runtime from the IndexedDB checkpoint and ordered command log, then attempts to
199
- hold a Web Lock for that document. The lock holder is the `leader` and may use
200
- the normal synchronous runtime APIs. All other attached tabs are `follower`
201
- mirrors: they read the durable tail in order after a BroadcastChannel hint, and
202
- their direct writes throw `LocalSyncReadOnlyError`. When the leader disposes,
203
- a follower catches up and becomes the next leader.
204
-
205
- The leader's `runtime.update`, normal `runtime.apply`, and
206
- `runtime.history.undo()` / `redo()` stay synchronous and immediately visible.
207
- Local-sync observes the resulting local, system, and history commits and writes
208
- their JSON operation batches to IndexedDB in the background. It deliberately
209
- rejects `runtime.replace()` and an externally supplied `apply(..., {
210
- source: 'remote' })` while attached: neither is a local operation command that
211
- the log can faithfully append. `flush()` is the explicit point that waits for
212
- commits observed before the call to persist (or, in a follower, waits to catch
213
- up to the durable head). A browser crash, quota failure, or malformed JSON
214
- payload can therefore leave an already visible leader commit unpersisted;
215
- observe `state` or use `onError` to surface that condition.
94
+ React tracks fields actually read and updates dependencies when the selector
95
+ branches. The core has no React dependency. `asReadable(document)` removes write
96
+ capabilities while retaining selection, subscription and projection support.
97
+
98
+ ## Containers And Parsing
99
+
100
+ - `map(field(...))`, `map(object(...))`, `map(variant(...))` expose
101
+ `get/has/ids/put/remove/replace`. `put` is an upsert; removing a missing key is a no-op.
102
+ - `table(object(...))` retains `{ ids, byId }` data and exposes
103
+ `get/has/ids/create/remove/move/replace`.
104
+ - `list(field(...), { keyOf })` retains a plain array and exposes
105
+ `get/has/ids/insert/remove/move/replace`.
106
+ - `tree(field(...))` retains `{ rootId?, nodes }` and exposes topology reads plus
107
+ `insert/remove/move/replace`.
108
+ - `variant(tag, branches)` has a readonly discriminant and whole-value branch replacement.
109
+ - `optional(node)` permits absence for fields, variants, maps, lists and trees.
110
+
111
+ Map/table key validators preserve branded string types through access, paths,
112
+ projection keys and impact queries. Validators are pure, synchronous functions or
113
+ Standard Schema v1 validators. They receive the original value, must not mutate it,
114
+ and their successful output is ignored. Perform transformations before calling
115
+ Doxum; it does not detect validator mutation or conversion. `parse(model, unknown)`
116
+ validates and copies schema structure while sharing readonly payloads. Strict parsing
117
+ requires validators for atomic fields; typed in-memory fields can omit them.
118
+
119
+ Collection `replace` has two forms: `replace(id, value)` replaces one existing
120
+ table/list/tree member without changing order or topology, while `replace(value)`
121
+ replaces the entire map/table/list/tree. Use top-level `replace(parent, key, value)`
122
+ when replacing an object or variant member whose draft type exposes collection tools:
216
123
 
217
124
  ```ts
218
- import { createDocument, select } from 'doxum';
219
- import { attachLocalSync } from 'doxum/local-sync';
220
-
221
- const runtime = createDocument({
222
- schema: taskSchema,
223
- initial: {
224
- title: 'Launch',
225
- tasks: { ids: [], byId: {} },
226
- },
125
+ import { replace, table, variant } from 'doxum';
126
+ const boardModel = object({
127
+ entries: map(object({ rows: table(task) })),
128
+ view: variant('kind', {
129
+ empty: object({}),
130
+ tasks: object({ rows: table(task) }),
131
+ }),
227
132
  });
228
-
229
- const localSync = await attachLocalSync({
230
- runtime,
231
- database: 'my-app',
232
- documentId: 'project-1',
133
+ const board = createDocument({
134
+ schema: boardModel,
135
+ initial: { entries: {}, view: { kind: 'empty' } },
233
136
  });
137
+ board.update(draft => {
138
+ draft.entries.put('a', { rows: { ids: [], byId: {} } });
139
+ replace(draft, 'view', { kind: 'tasks', rows: { ids: [], byId: {} } });
140
+ });
141
+ ```
234
142
 
235
- if (localSync.state.current().status === 'leader') {
236
- runtime.update(tx => {
237
- tx.write.title.set('Ship Doxum');
238
- });
143
+ Top-level `replace` checks the parent key and its plain `Infer` value, then enters the
144
+ same transaction write path. It is useful for variant replacement and initializing
145
+ optional collections. Map entries use `put`; collection-wide replacements use the
146
+ collection's own `replace`. Ordinary field and object-member assignments remain the
147
+ common case. See [value boundaries](docs/value-boundaries.md).
239
148
 
240
- runtime.history.undo();
241
- }
149
+ ## Changes And History
242
150
 
243
- select(runtime, read => read.title.get()); // leader write or follower replay
244
- await localSync.flush(); // persist observed leader commands / catch up a follower
151
+ A commit contains `{ revision, source, changes, impact }`. Repeated writes to one
152
+ field produce one first-before/final-after fact. Net-zero transactions do not
153
+ advance revision or notify. A `members` change shares its container address across
154
+ member transitions: `added` carries `after`, `removed` carries `before`, and
155
+ `updated` carries both. `order` records key sequences, `tree` records touched nodes
156
+ and nullable root IDs, and `reset` records a whole-document transition. There are
157
+ no per-value presence wrappers or intermediate assignment logs.
245
158
 
246
- await localSync.dispose();
159
+ ```ts
160
+ const changes = {
161
+ changes: [
162
+ {
163
+ kind: 'members',
164
+ at: ['tasks', 'a'],
165
+ members: [{ key: 'title', kind: 'updated', before: 'A', after: 'B' }],
166
+ },
167
+ ],
168
+ };
247
169
  ```
248
170
 
249
- `localSync.state` is a `Readable`: `state.current()` returns a stable snapshot,
250
- `state.subscribe(listener)` observes persistence, leadership and error changes,
251
- and React can consume it with `useReadable(localSync.state)`.
252
-
253
- `attachLocalSync` first hydrates the passed runtime from IndexedDB; await it
254
- before allowing reads or edits. It does not make runtime mutation asynchronous,
255
- does not own `runtime.dispose()`, and does not expose a second undo API. Runtime
256
- history is in-memory only: hydration and remote tail application invalidate it,
257
- so an undo stack never transfers to a new leader or survives reopening a tab.
258
- Local-sync data must be JSON. Because command validation happens after the
259
- runtime commit, a non-JSON payload is reported as a post-commit attachment error
260
- rather than rolling back an already observed document change.
171
+ Each container appears in at most one members group. Grouping preserves exact
172
+ field impact; a group at `[]` is an incremental root-member change, not a reset.
173
+ An ordered container's group may also contain `order: { before, after }` with its
174
+ complete key sequences. Order-only groups use `members: []`; a standalone `order`
175
+ change or a second group for the same container is rejected. Apply installs each
176
+ group's members and order together before moving to the next group.
261
177
 
262
- ## React
178
+ `document.apply(changes, { expectedRevision })` accepts unknown input. The revision
179
+ must match this runtime. The decoder rejects malformed and overlapping facts, then
180
+ the same session applies the complete transition atomically. Received `before`
181
+ values support reverse replay but are not trusted as local undo data; the runtime
182
+ captures its actual old state. Revision is a local baseline, not a distributed
183
+ conflict-resolution protocol.
263
184
 
264
- `useDocumentSelector` learns the paths read by its selector and re-renders only
265
- when a matching commit changes the selected result.
185
+ Published ChangeSets are readonly in their entirety, including envelope objects,
186
+ addresses, transitions and order arrays. The runtime reuses validation of its own
187
+ publications and normalized storage input by identity; fresh unknown input still
188
+ passes through the decoder and every apply validates against current local state.
266
189
 
267
- ```tsx
268
- import { useDocumentSelector } from 'doxum/react';
190
+ `history.undo()` and `redo()` replay the same changes by direction. A history group
191
+ holds complete commits and travels atomically with one notification:
269
192
 
270
- function TaskCount() {
271
- const count = useDocumentSelector(runtime, read => read.tasks.ids().length);
272
- return <output>{count}</output>;
273
- }
193
+ ```ts
194
+ const group = document.history.group();
195
+ document.update(draft => {
196
+ draft.title = 'First';
197
+ });
198
+ document.update(draft => {
199
+ draft.title = 'Final';
200
+ });
201
+ group.end();
202
+ document.history.undo();
274
203
  ```
275
204
 
276
- Use `useReadable` and `useHistory` with Doxum collection
277
- views, materialized views, and history state.
278
-
279
- ## Derived Views
205
+ `group.cancel()` restores its start and pre-group history. Local `replace` is an
206
+ explicit reversible root reset. Remote commits invalidate local history. Writes
207
+ with `history: false` close the active group.
280
208
 
281
- `createProjectionRuntime` owns an explicit graph of derived values and keyed
282
- collections. It combines document runtimes and external values without owning
283
- their mutations or history.
209
+ ## Projections
284
210
 
285
211
  ```ts
286
- const projection = createProjectionRuntime({ onError: error => console.error(error) });
287
- const document = projection.document(runtime);
288
- const taskTitles = projection.map(
289
- document.collection(path => path.tasks),
290
- (_id, task) => task.title.get()
212
+ import { createProjectionStore, input, project } from 'doxum';
213
+
214
+ const titles = project(
215
+ document,
216
+ path => path.tasks,
217
+ (id, task) => `${id}: ${task.title}`
291
218
  );
292
- const taskCount = projection.value({ tasks: taskTitles }, ({ tasks }) => tasks.ids().length);
293
- const labels = projection.map(taskTitles, (id, title) => `${id}: ${title}`);
219
+ const count = project({ titles }, ({ titles }) => titles.ids().length);
220
+ const zoom = input(1);
221
+ const scaled = project({ count, zoom }, ({ count, zoom }) => count * zoom);
222
+
223
+ const store = createProjectionStore({ onError: console.error });
224
+ store.get(scaled);
225
+ store.set(zoom, 2);
294
226
  ```
295
227
 
296
- `projection.value(sources, compute, { isEqual }?)` is the ordinary pure-compute
297
- entry. Stateful algorithms use `projection.value({ sources, build }, { isEqual }?)`,
298
- where build returns `{ value, update }`. Both use the same scheduler and lifecycle.
299
-
300
- `projection.collection<Item>()(spec)` handles custom incremental algorithms. Its scoped
301
- writer supports `set`, `remove`, `order`, and `replace`; `previous` and `next`
302
- provide scoped reads. Declare all sources and use their native commit impacts
303
- or upstream collection changes to choose candidate keys. A document collection
304
- context also provides `candidates.keys`, `candidates.orderDirty` and `reset`,
305
- aggregated across the entire batch. Candidates include net-zero changes; read
306
- final state to decide the output. Doxum stages writes,
307
- applies equality, and publishes exact `CollectionImpact` changes. It does not
308
- automatically track item dependencies. `ids`, lazy `all`, and cached `item(id)`
309
- implement `Readable` and work with `useReadable`.
310
-
311
- `map` accepts document collections and upstream projection collections while
312
- preserving keys and order. Source dependencies stay explicit.
313
-
314
- Use `projection.input(initial, { isEqual })` for boundary values such as container
315
- size. Its application-owned `set` updates a read-only `source`. Use
316
- `projection.fromReadable(existing, { isEqual })` for an existing external source.
317
- Neither replaces a document with domain mutation semantics.
318
-
319
- Wrap the entire synchronous application action in `projection.batch(() => ...)`
320
- before its first document commit to combine document changes and editor cleanup.
321
- Batches nest, preserve committed source changes on exceptions, and provide no
322
- cross-document rollback. Projection reads inside a batch return the last
323
- published state. Document listeners still run synchronously.
324
-
325
- All affected nodes settle before listeners. Output revisions change only when
326
- their output changes. Update failures discard the processor instance and attempt
327
- one fresh build; persistent faults block descendants, while independent branches
328
- continue. Errors reach `onError` and, when inside document notification, the
329
- committed result's `observerErrors`. Manual `rebuild()` uses the same graph.
330
- Dispose the projection with its service; component unmount only unsubscribes.
331
- Disposing a node with consumers is rejected, and disposed handles throw.
332
-
333
- Schema access uses `object` for structured entities and `dict` for keyed values.
334
- Variants expose `reader.get()` as a discriminated union and `writer.replace()`
335
- for replacement. Optional presence is supported by field, variant, dict, list
336
- and tree nodes. Dictionaries expose `get(key)`, `has(key)`, `keys()` and
337
- `values()`; lists additionally support `get(key)` and `has(key)` using `keyOf`.
338
- Tree insert/move accept `{ parentId, index }`, with index denoting the final
339
- position after removing a moved node. See [API migration](docs/api-migration.md)
340
- for breaking changes and the public surface.
341
-
342
- ## Data Ownership
343
-
344
- Doxum clones the initial document. Structural values supplied through operations
345
- are transferred to its mutable canonical document, while published commit and
346
- history payloads are immutable snapshots. Treat data passed to an update as
347
- owned by Doxum after the call unless the value is intentionally immutable.
348
-
349
- Tree replacement snapshots are an exception: Doxum validates and clones the
350
- tree structure so a caller cannot later corrupt its single-root, connected,
351
- acyclic representation. Tree insert and move operations preserve the same
352
- invariant.
228
+ Projection declarations are lazy and reusable. A `ProjectionStore` owns
229
+ materialized values, subscriptions, batching, processor state and disposal.
230
+ `project(document, path)` binds a document collection; adding a mapper performs
231
+ incremental keyed mapping. `project(readable)` bridges an external readable.
232
+ Pure computations receive current values. Advanced processors use tagged specs:
233
+ `project({ kind: 'value', sources, build })` and
234
+ `project({ kind: 'collection', sources, build })`.
235
+ Document collection events provide scoped `read.get/has/ids`, final candidate
236
+ keys, order dirtiness, commits and reset state.
237
+
238
+ Processors settle before external listeners. `store.batch` defers graph
239
+ settlement and projection notifications, but document commits/listeners remain
240
+ synchronous. Projection readers inside the batch see the last publication.
241
+ Dispose projection stores with their owning service.
242
+ See [projection contracts](docs/projections.md).
243
+
244
+ ## Local Sync
245
+
246
+ `doxum/local-sync` attaches an IndexedDB timeline and Web Lock leadership to a
247
+ runtime. One leader writes synchronously and persists final changes asynchronously;
248
+ followers apply the durable sequence in order. `flush()` waits for persistence.
249
+ Durability errors do not roll back an already visible commit.
250
+
251
+ The storage format is IndexedDB version 5 with format version 3 records. Earlier
252
+ databases are rejected without upgrading, deleting or converting their data.
253
+ This JSON adapter rejects non-JSON atomic values. Network collaboration and
254
+ collaborative undo remain separate concerns; see [collaboration design](COLLABORATION_DESIGN.md).
255
+ Change count limits count individual members and tree nodes, not just outer groups.
256
+ `changeLimits` applies to newly authored local commits. Previously persisted
257
+ commits remain readable by followers and after reopening with smaller limits;
258
+ their JSON and ChangeSet structure are still validated.
353
259
 
354
260
  ## Development
355
261
 
@@ -359,29 +265,32 @@ pnpm run check
359
265
  pnpm run build
360
266
  pnpm run bench
361
267
  pnpm run profile
268
+ node test.mjs
269
+ node core/bench/architecture.mjs --isolate
362
270
  ```
363
271
 
364
- See [the architecture guide](docs/architecture.md) for the runtime pipeline and
365
- [AGENTS.md](AGENTS.md) for contribution rules.
272
+ The architecture benchmark isolates each workload in a fresh process. Use
273
+ `--allocation` for separate V8 allocation sampling, `DOXUM_BENCH_FILTER` for a
274
+ comma-separated workload list, and `DOXUM_BENCH_MODULE=/absolute/path/to/index.js`
275
+ to compare a saved build. Sampling timings are not normal latency measurements.
276
+ `pnpm run profile` reports work counters and separates writes, sealing, remaining
277
+ runtime/publication work and explicit impact queries.
278
+ It also reports structural generation advances and distinguishes captured order
279
+ baselines from final published order copies. Architecture workloads include order
280
+ round trips and repeated tree edits to expose costs hidden by commit-only benchmarks.
281
+
282
+ Builds produce root `dist` ESM/CJS/declarations for `doxum`, `doxum/integration`,
283
+ `doxum/local-sync` and `doxum/react`. Source ownership is described in
284
+ [architecture](docs/architecture.md) and [AGENTS.md](AGENTS.md).
285
+ The runtime shares one transaction lifecycle; complete mutation operations are
286
+ organized by domain under `core/src/mutation/operations`, with access, first-touch
287
+ recording and publication retaining their own responsibilities.
288
+ The published [runtime skill](skills/doxum-runtime/SKILL.md) includes English and
289
+ Chinese application guidance.
366
290
 
367
291
  ## Release
368
292
 
369
- `doxum` is released as one package. A normal release always increments the
370
- patch version:
371
-
372
- ```sh
373
- pnpm release
374
- ```
375
-
376
- Run it only from a clean, synchronized `main` branch. The command verifies the
377
- logged-in npm account, updates both public package versions, runs the complete
378
- check and build, verifies the publish tarball, publishes the package, then
379
- creates and pushes a `vX.Y.Z` release commit and tag.
380
-
381
- If npm accepts one package but the command cannot finish, it preserves the
382
- release state rather than reverting a version that may already be public. Fix
383
- the external failure and continue with:
384
-
385
- ```sh
386
- pnpm release:resume
387
- ```
293
+ Run `pnpm release` from a clean, synchronized `main` branch to execute the release
294
+ checks, build, tarball validation, npm publication and release commit/tag steps.
295
+ Use `pnpm release:resume` to continue an interrupted release after resolving its
296
+ external failure. Publication is a separate maintainer action.