doxum 0.1.9 → 0.1.11

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 (35) hide show
  1. package/README.md +37 -18
  2. package/dist/{contract-BNStLbSE.d.ts → contract-CeAnEPBA.d.ts} +31 -14
  3. package/dist/{contract-CIU5FCC1.d.cts → contract-DtGVSXSK.d.cts} +31 -14
  4. package/dist/index.cjs +2 -2
  5. package/dist/index.d.cts +3 -3
  6. package/dist/index.d.ts +3 -3
  7. package/dist/index.js +2 -2
  8. package/dist/{integration-C87tjRop.cjs → integration-BKgGuodm.cjs} +2 -2
  9. package/dist/{integration-C87tjRop.cjs.map → integration-BKgGuodm.cjs.map} +1 -1
  10. package/dist/{integration-D5XCBLJ8.js → integration-VTZ3ICsO.js} +2 -2
  11. package/dist/{integration-D5XCBLJ8.js.map → integration-VTZ3ICsO.js.map} +1 -1
  12. package/dist/integration.cjs +2 -2
  13. package/dist/integration.d.cts +2 -2
  14. package/dist/integration.d.ts +2 -2
  15. package/dist/integration.js +2 -2
  16. package/dist/local-sync.d.cts +1 -1
  17. package/dist/local-sync.d.ts +1 -1
  18. package/dist/react.cjs +2 -2
  19. package/dist/react.js +2 -2
  20. package/dist/{store-cp5CpfCy.d.ts → store-Bs0C0rNd.d.ts} +2 -2
  21. package/dist/{store-CD0KdGsq.d.cts → store-CJuwJl_K.d.cts} +2 -2
  22. package/dist/{store-D7QH6Rzw.js → store-DHFXfWSz.js} +90 -47
  23. package/dist/store-DHFXfWSz.js.map +1 -0
  24. package/dist/{store-1Uob0Ghk.cjs → store-DU2u-qFH.cjs} +95 -52
  25. package/dist/store-DU2u-qFH.cjs.map +1 -0
  26. package/package.json +1 -1
  27. package/skills/doxum-runtime/SKILL.md +11 -2
  28. package/skills/doxum-runtime/references/guide.en.md +23 -14
  29. package/skills/doxum-runtime/references/guide.zh-CN.md +20 -13
  30. package/skills/doxum-runtime/references/patterns.en.md +10 -7
  31. package/skills/doxum-runtime/references/patterns.zh-CN.md +9 -7
  32. package/skills/doxum-runtime/references/projections.en.md +211 -0
  33. package/skills/doxum-runtime/references/projections.zh-CN.md +195 -0
  34. package/dist/store-1Uob0Ghk.cjs.map +0 -1
  35. package/dist/store-D7QH6Rzw.js.map +0 -1
@@ -13,12 +13,12 @@ const document = createDocument({
13
13
  initial: { tasks: { a: { title: 'Write', done: false } } },
14
14
  });
15
15
  document.update(draft => {
16
- const task = draft.tasks.a;
16
+ const task = draft.tasks.get('a');
17
17
  if (task) task.done = !task.done;
18
- draft.tasks.b = { title: 'Review', done: false };
18
+ draft.tasks.put('b', { title: 'Review', done: false });
19
19
  return { warnings: [] };
20
20
  });
21
- const done = select(document, state => state.tasks.a?.done);
21
+ const done = select(document, state => state.tasks.get('a')?.done);
22
22
  const tasks = select(document, state => snapshot(state.tasks));
23
23
  document.subscribe(
24
24
  path => path.tasks.item('a').done,
@@ -43,20 +43,24 @@ returns its original readonly reference. Published data is not frozen. Field cop
43
43
  are not supported; mutable exports and serialization belong to application boundaries.
44
44
 
45
45
  Infer preserves optional properties and flat variant unions. Read/Draft contain
46
- collection tools; assign(scope, key, inferValue) handles plain replacements containing
47
- nested table/list/tree data.
46
+ collection tools. Maps use explicit methods; table/list/tree overload replace for
47
+ member and whole-container replacement. `replace(parent, key, inferValue)` handles
48
+ plain replacement of object or variant members that contain collection data.
48
49
 
49
50
  ## Containers And Validation
50
51
 
51
- | Definition | Data | Draft methods |
52
- | -------------------------------- | ------------- | ------------------------------------------------------------- |
53
- | map(valueSchema, { key }?) | record | indexing, assignment, delete |
54
- | table(objectOrVariant, { key }?) | ids/byId | get/has/ids/create/remove/move |
55
- | list(field, { keyOf }) | array | get/has/ids/insert/set/remove/move/replace |
56
- | tree(field) | rootId?/nodes | get/has/rootId/parent/children/insert/set/remove/move/replace |
52
+ | Definition | Data | Draft methods |
53
+ | -------------------------------- | ------------- | --------------------------------------------------------- |
54
+ | map(valueSchema, { key }?) | record | get/has/ids/put/remove/replace |
55
+ | table(objectOrVariant, { key }?) | ids/byId | get/has/ids/create/remove/move/replace |
56
+ | list(field, { keyOf }) | array | get/has/ids/insert/remove/move/replace |
57
+ | tree(field) | rootId?/nodes | get/has/rootId/parent/children/insert/remove/move/replace |
57
58
 
58
- Read scopes expose only read methods. Map supports field/object/variant values.
59
- List replacement retains the addressed key. Simple arrays and strokes can be one
59
+ Read scopes expose only read methods. Map supports field/object/variant values;
60
+ put is an upsert and removing a missing key is a no-op. `replace(id, value)` is
61
+ existing-only for table/list/tree, preserves table/list order, and changes only a
62
+ tree payload. `replace(value)` replaces a whole collection. List member replacement
63
+ retains the addressed key. Simple arrays and strokes can be one
60
64
  atomic field. Optional supports field/variant/map/list/tree. Absent differs from
61
65
  present undefined. Variant tags are readonly; change branch by whole replacement.
62
66
 
@@ -64,7 +68,7 @@ Pure synchronous functions and Standard Schema v1 validators receive original in
64
68
  They must not mutate it; successful output is ignored, with no copy or deep conversion
65
69
  check. Transform values before entering Doxum. parse(model, unknown) copies validated
66
70
  structure and shares readonly payloads; strict parse requires atomic validators.
67
- Branded map/table keys flow through access, symbolic paths and impact.
71
+ Branded map/table keys flow through methods, symbolic paths and impact.
68
72
  Path callbacks describe locations, including absent entries, and compile at registration.
69
73
  React useDocumentSelector tracks actual reads and changes dependencies when branching.
70
74
 
@@ -87,6 +91,11 @@ Use `project(document, path => path.tasks, mapper)` for incremental mapping.
87
91
  Pure values use `project(sources, compute)`; stateful algorithms use tagged value
88
92
  or collection specs. Definitions are lazy and are materialized by a
89
93
  `createProjectionStore({ onError })` instance. Sources remain explicit.
94
+ Ordinary mappers only model one-source, same-key transforms. Cross-collection or
95
+ cross-key dependencies use an advanced collection processor with an application-owned
96
+ reverse dependency index; core does not track reads performed by processors. See the
97
+ [projection reference](projections.en.md) for event shapes, lifecycle, writer semantics,
98
+ join indexing, batching, and fault recovery.
90
99
  `input` and `project(readable)` connect external boundary values. Dispose the store
91
100
  with the owning service. `store.batch` defers projection settlement/listeners, not document commits/listeners. Reads
92
101
  inside a batch see the last publication; no cross-document rollback is provided.
@@ -13,12 +13,12 @@ const document = createDocument({
13
13
  initial: { tasks: { a: { title: 'Write', done: false } } },
14
14
  });
15
15
  document.update(draft => {
16
- const task = draft.tasks.a;
16
+ const task = draft.tasks.get('a');
17
17
  if (task) task.done = !task.done;
18
- draft.tasks.b = { title: 'Review', done: false };
18
+ draft.tasks.put('b', { title: 'Review', done: false });
19
19
  return { warnings: [] };
20
20
  });
21
- const done = select(document, state => state.tasks.a?.done);
21
+ const done = select(document, state => state.tasks.get('a')?.done);
22
22
  const tasks = select(document, state => snapshot(state.tasks));
23
23
  document.subscribe(
24
24
  path => path.tasks.item('a').done,
@@ -37,25 +37,28 @@ object/variant 结构只接受 schema 声明的自身属性及 variant 判别字
37
37
  snapshot 只复制 schema 结构;snapshot(rawPayload) 返回原始只读引用。
38
38
  发布数据不做运行时冻结,不再支持字段 copier。需要可修改副本或序列化时由应用边界显式处理。
39
39
 
40
- Infer 保留 optional 属性和扁平 variant 联合。Read/Draft 带集合方法;
41
- 含 table/list/tree 数据的整体替换使用 assign(scope, key, inferValue)。
40
+ Infer 保留 optional 属性和扁平 variant 联合。Read/Draft 带集合方法。map 使用显式方法;
41
+ table/list/tree 的 replace 同时支持成员和整体替换。含集合数据的 object/variant 成员
42
+ 使用 `replace(parent, key, inferValue)` 传入普通 Infer 数据。
42
43
 
43
44
  ## 容器与校验
44
45
 
45
- | 声明 | 数据 | Draft 方法 |
46
- | -------------------------------- | ------------- | ------------------------------------------------------------- |
47
- | map(valueSchema, { key }?) | record | 索引、赋值、delete |
48
- | table(objectOrVariant, { key }?) | ids/byId | get/has/ids/create/remove/move |
49
- | list(field, { keyOf }) | 普通数组 | get/has/ids/insert/set/remove/move/replace |
50
- | tree(field) | rootId?/nodes | get/has/rootId/parent/children/insert/set/remove/move/replace |
46
+ | 声明 | 数据 | Draft 方法 |
47
+ | -------------------------------- | ------------- | --------------------------------------------------------- |
48
+ | map(valueSchema, { key }?) | record | get/has/ids/put/remove/replace |
49
+ | table(objectOrVariant, { key }?) | ids/byId | get/has/ids/create/remove/move/replace |
50
+ | list(field, { keyOf }) | 普通数组 | get/has/ids/insert/remove/move/replace |
51
+ | tree(field) | rootId?/nodes | get/has/rootId/parent/children/insert/remove/move/replace |
51
52
 
52
- Read 只暴露读取方法。map 支持 field/object/variant。list 替换项必须保持寻址键。
53
+ Read 只暴露读取方法。map 支持 field/object/variant;put 是 upsert,remove 缺失键是 no-op。
54
+ table/list/tree 的 `replace(id, value)` 只替换已存在成员,保持 table/list 顺序,且 tree
55
+ 只改变 payload;`replace(value)` 替换整个集合。list 替换项必须保持寻址键。
53
56
  简单数组与笔画可作为一个原子 field。optional 支持 field/variant/map/list/tree,
54
57
  缺失与存在的 undefined 不同。variant tag 只读,通过整体替换切换分支。
55
58
 
56
59
  校验器是纯同步函数或 Standard Schema v1,直接接收原始引用且不得修改它。
57
60
  成功返回值被忽略,不复制输入,也不深度检查转换;数据转换在进入 Doxum 前完成。
58
- parse(model, unknown) 复制校验后的结构并共享只读 payload;严格解析要求原子字段具备校验器。品牌键贯穿 map/table 访问、
61
+ parse(model, unknown) 复制校验后的结构并共享只读 payload;严格解析要求原子字段具备校验器。品牌键贯穿 map/table 方法、
59
62
  符号路径和 impact。路径回调描述地址,包括缺失键,订阅注册时解析。
60
63
  React useDocumentSelector 追踪实际读取,并在选择分支改变时更新依赖。
61
64
 
@@ -76,6 +79,10 @@ observerErrors 属于已提交结果。
76
79
  纯派生值使用 `project(sources, compute)`;有状态算法使用带 kind 的 value
77
80
  或 collection spec。定义是惰性的,由 `createProjectionStore({ onError })`
78
81
  实例物化和管理。sources 仍然显式声明。
82
+ 普通 mapper 只表达单 source、同键转换。跨集合或跨键依赖使用 advanced collection
83
+ processor,并由应用维护反向依赖索引;core 不追踪 processor 内的读取。event 形状、
84
+ 生命周期、writer 语义、join 索引、batch 与 fault 恢复见
85
+ [Projection 参考](projections.zh-CN.md)。
79
86
  `input` 与 `project(readable)` 接入外部边界值。随所属服务 dispose store。
80
87
  `store.batch` 推迟投影结算与通知,但不推迟文档提交和文档通知;内部读取上次发布值,
81
88
  不提供跨文档回滚。
@@ -11,7 +11,7 @@ const model = object({
11
11
  });
12
12
  ```
13
13
 
14
- Edit position.x, replace stroke whole, use rows.set(key, value) for keyed item edits.
14
+ Edit position.x, replace stroke whole, and use rows.replace(key, value) for keyed item edits.
15
15
 
16
16
  ## Complex Replacement
17
17
 
@@ -21,13 +21,13 @@ const model = object({
21
21
  });
22
22
  const document = createDocument({ schema: model, initial: { entries: {} } });
23
23
  document.update(draft => {
24
- assign(draft.entries, 'a', { rows: { ids: ['x'], byId: { x: { title: 'First' } } } });
25
- draft.entries.a!.rows.get('x')!.title = 'Updated';
24
+ draft.entries.put('a', { rows: { ids: ['x'], byId: { x: { title: 'First' } } } });
25
+ draft.entries.get('a')!.rows.get('x')!.title = 'Updated';
26
26
  });
27
27
  ```
28
28
 
29
- TypeScript cannot express different read/write types for mapped properties.
30
- assign checks Infer replacement data and uses the same mutation session.
29
+ Map entries use put. Use top-level replace for object/variant members whose Draft
30
+ type contains collection tools; collection replace(next) handles whole containers.
31
31
 
32
32
  ## Domain Keys
33
33
 
@@ -45,7 +45,7 @@ const model = object({ people: map(object({ name: field(text) }), { key: personI
45
45
  const initial = parse(model, { people: { 'person:1': { name: 'Ada' } } });
46
46
  ```
47
47
 
48
- Brands flow through indexed access, table methods, symbolic paths and collection impact.
48
+ Brands flow through map/table methods, symbolic paths and collection impact.
49
49
 
50
50
  ## Ordered Edits, History And Replay
51
51
 
@@ -101,4 +101,7 @@ Use `useProjection` with a store for projection definitions; use `useReadable`
101
101
  for history and other existing Readable values. Custom collection processors stage
102
102
  writer.set/remove/order/replace and use scoped previous/next reads. Candidates span
103
103
  the complete batch; derive output from final state. Processor dependencies remain
104
- explicit even though React selectors track actual reads.
104
+ explicit even though React selectors track actual reads. Use a mapper only when a
105
+ source key affects the same output key. For cross-collection joins, declare every
106
+ source and maintain the domain's reverse dependency index as described in the
107
+ [projection reference](projections.en.md).
@@ -11,7 +11,7 @@ const model = object({
11
11
  });
12
12
  ```
13
13
 
14
- position.x 细粒度赋值;stroke 整体替换;rows.set(key, value) 按稳定键替换项。
14
+ position.x 细粒度赋值;stroke 整体替换;rows.replace(key, value) 按稳定键替换项。
15
15
 
16
16
  ## 含集合的整体替换
17
17
 
@@ -21,13 +21,13 @@ const model = object({
21
21
  });
22
22
  const document = createDocument({ schema: model, initial: { entries: {} } });
23
23
  document.update(draft => {
24
- assign(draft.entries, 'a', { rows: { ids: ['x'], byId: { x: { title: 'First' } } } });
25
- draft.entries.a!.rows.get('x')!.title = 'Updated';
24
+ draft.entries.put('a', { rows: { ids: ['x'], byId: { x: { title: 'First' } } } });
25
+ draft.entries.get('a')!.rows.get('x')!.title = 'Updated';
26
26
  });
27
27
  ```
28
28
 
29
- TypeScript 映射属性不能分别指定读写类型。assign 校验对应 Infer 数据,
30
- 经同一 mutation session 修改。
29
+ map 条目使用 put。Draft 类型含集合方法的 object/variant 成员使用顶层 replace;
30
+ 集合整体替换使用集合自身的 replace(next),并经同一 mutation session 修改。
31
31
 
32
32
  ## 领域键
33
33
 
@@ -45,7 +45,7 @@ const model = object({ people: map(object({ name: field(text) }), { key: personI
45
45
  const initial = parse(model, { people: { 'person:1': { name: 'Ada' } } });
46
46
  ```
47
47
 
48
- 品牌类型贯穿索引、table 方法、符号路径和 collection impact。
48
+ 品牌类型贯穿 map/table 方法、符号路径和 collection impact。
49
49
 
50
50
  ## 顺序、History 与重放
51
51
 
@@ -100,4 +100,6 @@ store.get(scaled);
100
100
  使用 `useReadable` 或 `useHistory`。自定义集合 processor 通过
101
101
  writer.set/remove/order/replace 暂存输出,
102
102
  previous/next 读取只在作用域内有效。candidates 汇总整个 batch,以最终状态派生输出。
103
- React 追踪实际读取,但 processor 依赖仍显式声明。
103
+ React 追踪实际读取,但 processor 依赖仍显式声明。只有 source 键影响同名输出键时
104
+ 才使用 mapper。跨集合 join 应声明全部 source,并按
105
+ [Projection 参考](projections.zh-CN.md)维护领域反向依赖索引。
@@ -0,0 +1,211 @@
1
+ # Doxum Projection Reference
2
+
3
+ Use this reference when choosing, implementing, or reviewing a projection. The
4
+ [public long-form contract](../../../docs/projections.md) provides a complete join
5
+ example; source code inspection should not be necessary for normal application work.
6
+
7
+ ## Choose The Form
8
+
9
+ | Relationship | API |
10
+ | --------------------------------------------- | ------------------------------------------------- |
11
+ | Whole document source | `project(document)` |
12
+ | Target-limited document source | `project(document, [pick, ...])` |
13
+ | Document collection source | `project(document, pick)` |
14
+ | One source key to the same output key | `project(collection, mapper)` |
15
+ | Pure current values to one value | `project(sources, compute)` |
16
+ | Stateful incremental value | `project({ kind: 'value', sources, build })` |
17
+ | Multiple sources or cross-key collection work | `project({ kind: 'collection', sources, build })` |
18
+ | Application input | `input(initial)` and `store.set(input, value)` |
19
+ | Existing `Readable` | `project(readable)` |
20
+
21
+ Definitions are lazy and reusable. `createProjectionStore({ onError })` owns one
22
+ materialization graph. Every store has independent published values, revisions,
23
+ inputs, subscriptions, processor instances, and closure indexes.
24
+
25
+ An ordinary mapper is deliberately limited to:
26
+
27
+ ```text
28
+ source key K changed -> output key K may change
29
+ ```
30
+
31
+ It rebuilds on reset and follows source order, but does not track reads performed
32
+ inside the mapper. Never read another collection in a mapper and assume it becomes
33
+ a dependency. Declare all sources in an advanced processor instead.
34
+
35
+ ## Source Event Contract
36
+
37
+ Advanced processors receive events, not just current values:
38
+
39
+ ```ts
40
+ type ValueEvent<T> = {
41
+ value: T;
42
+ previous: T;
43
+ changed: boolean;
44
+ revision: number;
45
+ reset: boolean;
46
+ };
47
+
48
+ type CollectionEvent<K extends string, V> = {
49
+ get(key: K): V | undefined;
50
+ has(key: K): boolean;
51
+ ids(): readonly K[];
52
+ change: CollectionImpact<K> | undefined;
53
+ revision: number;
54
+ reset: boolean;
55
+ };
56
+
57
+ type DocumentEvent<S> = {
58
+ read: Read<S>;
59
+ revision: number;
60
+ commits: readonly DocumentCommit<S>[];
61
+ reset: boolean;
62
+ };
63
+
64
+ type DocumentCollectionEvent<S, N, K extends string> = {
65
+ read: CollectionAccess<K, N>;
66
+ revision: number;
67
+ commits: readonly DocumentCommit<S>[];
68
+ reset: boolean;
69
+ candidates: { keys: readonly K[]; orderDirty: boolean };
70
+ };
71
+ ```
72
+
73
+ Projected collection `change` is incremental (`added`, `removed`, `updated`,
74
+ `orderChanged`), reset, or undefined. Document collection candidates are the
75
+ union across all relevant commits in a store batch. A later commit may cancel an
76
+ earlier one without removing its candidate key. Always derive from final `read`
77
+ state. Use `commit.impact.collection(pick)` for per-commit relationship changes.
78
+
79
+ Across a batch, a value event's `previous` is the pre-batch value and `value` is
80
+ the final value. A net-zero document transaction produces no commit or candidates.
81
+
82
+ ## Advanced Value Processor
83
+
84
+ ```ts
85
+ const value = project({
86
+ kind: 'value',
87
+ sources,
88
+ build: events => ({
89
+ value: buildValue(events),
90
+ update: events =>
91
+ needsRebuild(events)
92
+ ? { kind: 'rebuild' }
93
+ : changed(events)
94
+ ? { kind: 'changed', value: updateValue(events) }
95
+ : { kind: 'unchanged' },
96
+ }),
97
+ });
98
+ ```
99
+
100
+ Prefer `project(sources, compute)` unless retained state prevents meaningful work.
101
+
102
+ ## Advanced Collection Processor
103
+
104
+ ```ts
105
+ const result = project({
106
+ kind: 'collection',
107
+ sources,
108
+ name: 'optional diagnostic name',
109
+ isEqual: Object.is,
110
+ build: ({ sources, previous, next, writer }) => {
111
+ // Build the complete output and any store-local indexes.
112
+ return {
113
+ update: ({ sources, previous, next, writer }) => {
114
+ // Stage only affected keys, or return { kind: 'rebuild' }.
115
+ },
116
+ };
117
+ },
118
+ });
119
+ ```
120
+
121
+ `build` runs on first materialization, source reset, explicit `store.rebuild`, an
122
+ update-requested rebuild, and fault recovery. Build starts from a cleared output
123
+ and must stage the complete result. Its closure state is local to one store and
124
+ must be reconstructible from sources.
125
+
126
+ `update` runs when a declared source participates in settlement. Returning
127
+ `{ kind: 'rebuild' }` discards staged update writes and performs a fresh build.
128
+ All callbacks are synchronous; Promises and thenables are rejected. Reads and
129
+ writes during processing or notification must not reenter a source or document.
130
+
131
+ `previous`, `next`, source readers, and `writer` are borrowed for the current
132
+ synchronous callback only:
133
+
134
+ - `previous` sees only published output.
135
+ - `next` immediately sees current staged writes.
136
+ - `writer.set(key, value)` stages presence.
137
+ - `writer.remove(key)` stages absence; a missing removal is a no-op.
138
+ - Repeated operations on one key use the last operation.
139
+ - `writer.order(ids)` supplies the entire next order, with every key exactly once.
140
+ - `writer.replace(entries)` clears and replaces all output in entry order; keys
141
+ must be unique.
142
+
143
+ Staged output publishes atomically. Equality defaults to `Object.is`; equal sets
144
+ keep the old reference. Net-zero output does not increment revision or notify.
145
+
146
+ ## Cross-Collection Join Pattern
147
+
148
+ Do not add a generic join abstraction. The domain owns missing references,
149
+ cardinality, reconnect, delete, ordering, and result-key policy. Model the join as
150
+ an advanced collection with explicit sources and indexes.
151
+
152
+ For routes derived from `edges(from, to)` and node geometry, maintain:
153
+
154
+ ```text
155
+ endpoints: edgeId -> [fromNodeId, toNodeId]
156
+ adjacency: nodeId -> Set<edgeId>
157
+ ```
158
+
159
+ On build, scan edges once, populate both indexes, and write the complete routes.
160
+ On an edge event:
161
+
162
+ 1. Read exact changed edge ids from each commit's collection impact.
163
+ 2. Detach every changed id from its old endpoints.
164
+ 3. Read final edge state; attach its final endpoints, or delete its forward entry.
165
+ 4. Add that edge id to the affected output set.
166
+
167
+ On a node collection event, map added/updated/removed node ids through `adjacency`
168
+ and add only adjacent edges to the affected set. Finally read current endpoint
169
+ geometry and set or remove each affected route. Rebuild on either source reset.
170
+
171
+ After an edge reconnects, the old node must no longer select it. Deleting an edge
172
+ must remove both forward and reverse entries. Missing endpoint behavior is an
173
+ explicit domain decision. Do not scan all edges for every node change unless the
174
+ known collection size makes that tradeoff intentional.
175
+
176
+ Closure indexes are not part of the writer's staged transaction. An uncaught
177
+ processor failure triggers fresh-build recovery, but a processor that catches a
178
+ failure and continues keeps its own mutations. Perform fallible work before
179
+ mutating live indexes, stage temporary index changes and commit them on success,
180
+ or request a complete rebuild.
181
+
182
+ ## Batching, Faults, And Lifetime
183
+
184
+ `store.batch` defers projection settlement and projection listeners only. Document
185
+ commits and document listeners remain synchronous. Reads inside the batch return
186
+ the last publication; settlement after the outer batch uses final source state.
187
+ There is no cross-document rollback.
188
+
189
+ Processor or writer validation failure publishes no partial output. The runtime
190
+ may retry with a fresh build; persistent failure faults that projection and blocks
191
+ its descendants while independent branches continue. Reads then throw
192
+ `ProjectionError`. A later source update or `store.rebuild(projection)` can recover.
193
+ `onError` also receives source, blocked, and listener failures. Listener failures
194
+ never undo published projection state or accepted document commits.
195
+
196
+ `store.release(projection)` releases an independently releasable materialization;
197
+ do not release a node still used by a materialized downstream projection.
198
+ `store.dispose()` invalidates the complete graph and releases its subscriptions.
199
+
200
+ ## Review Checklist
201
+
202
+ - All result-changing sources are declared.
203
+ - Candidate selection covers add, update, remove, reconnect, and missing reference.
204
+ - Batch logic reads final state instead of replaying imagined intermediate states.
205
+ - Reset and explicit rebuild reconstruct complete output, order, and indexes.
206
+ - Build never depends on prior output or a previous processor closure.
207
+ - `writer.order` is complete and valid whenever used.
208
+ - Equal values preserve references; unrelated changes produce no output revision.
209
+ - A thrown update cannot silently corrupt closure indexes.
210
+ - Tests cover unrelated commits, dynamic dependencies, stable references, reset,
211
+ batch behavior, recovery, and disposal.
@@ -0,0 +1,195 @@
1
+ # Doxum Projection 参考
2
+
3
+ 设计、实现或审查 projection 时使用本文。[公开完整契约](../../../docs/projections.md)
4
+ 提供完整 join 示例;正常应用开发不应再要求阅读运行时源码。
5
+
6
+ ## 选择定义形式
7
+
8
+ | 依赖关系 | API |
9
+ | ------------------------------ | ------------------------------------------------- |
10
+ | 整个文档作为 source | `project(document)` |
11
+ | 只关注指定文档路径 | `project(document, [pick, ...])` |
12
+ | 一个文档集合 source | `project(document, pick)` |
13
+ | 一个 source 键映射到同名输出键 | `project(collection, mapper)` |
14
+ | 当前 source 值纯计算出一个值 | `project(sources, compute)` |
15
+ | 有状态增量 value | `project({ kind: 'value', sources, build })` |
16
+ | 多 source 或跨键集合计算 | `project({ kind: 'collection', sources, build })` |
17
+ | 应用输入 | `input(initial)` 与 `store.set(input, value)` |
18
+ | 既有 `Readable` | `project(readable)` |
19
+
20
+ 定义是惰性、可复用的描述。`createProjectionStore({ onError })` 拥有一个物化图。
21
+ 同一定义在不同 store 内分别拥有发布值、revision、input、订阅、processor 实例和闭包索引。
22
+
23
+ 普通 mapper 的契约有意限制为:
24
+
25
+ ```text
26
+ source 键 K 变化 -> 输出键 K 可能变化
27
+ ```
28
+
29
+ 它在 reset 时重建并跟随 source 顺序,但不会追踪 mapper 内的读取。不要在 mapper
30
+ 里读取另一个集合并假定它自动成为依赖;应改用 advanced processor,并显式声明所有 source。
31
+
32
+ ## Source Event 契约
33
+
34
+ advanced processor 接收 event,而不只是当前值:
35
+
36
+ ```ts
37
+ type ValueEvent<T> = {
38
+ value: T;
39
+ previous: T;
40
+ changed: boolean;
41
+ revision: number;
42
+ reset: boolean;
43
+ };
44
+
45
+ type CollectionEvent<K extends string, V> = {
46
+ get(key: K): V | undefined;
47
+ has(key: K): boolean;
48
+ ids(): readonly K[];
49
+ change: CollectionImpact<K> | undefined;
50
+ revision: number;
51
+ reset: boolean;
52
+ };
53
+
54
+ type DocumentEvent<S> = {
55
+ read: Read<S>;
56
+ revision: number;
57
+ commits: readonly DocumentCommit<S>[];
58
+ reset: boolean;
59
+ };
60
+
61
+ type DocumentCollectionEvent<S, N, K extends string> = {
62
+ read: CollectionAccess<K, N>;
63
+ revision: number;
64
+ commits: readonly DocumentCommit<S>[];
65
+ reset: boolean;
66
+ candidates: { keys: readonly K[]; orderDirty: boolean };
67
+ };
68
+ ```
69
+
70
+ 投影集合的 `change` 可能是 incremental(`added`、`removed`、`updated`、
71
+ `orderChanged`)、reset 或 undefined。文档集合的 candidates 是一个 store batch
72
+ 内所有相关 commit 的并集。后续 commit 即使抵消早先变化,也不会移除候选键。
73
+ 必须用最终 `read` 状态计算输出。需要维护关系索引时,用
74
+ `commit.impact.collection(pick)` 取得每次 commit 的精确变化。
75
+
76
+ batch 中 value event 的 `previous` 是 batch 前的值,`value` 是最终值。
77
+ 单个净零文档事务不会产生 commit 或 candidates。
78
+
79
+ ## Advanced Value Processor
80
+
81
+ ```ts
82
+ const value = project({
83
+ kind: 'value',
84
+ sources,
85
+ build: events => ({
86
+ value: buildValue(events),
87
+ update: events =>
88
+ needsRebuild(events)
89
+ ? { kind: 'rebuild' }
90
+ : changed(events)
91
+ ? { kind: 'changed', value: updateValue(events) }
92
+ : { kind: 'unchanged' },
93
+ }),
94
+ });
95
+ ```
96
+
97
+ 除非保留状态能避免有意义的工作,否则优先使用 `project(sources, compute)`。
98
+
99
+ ## Advanced Collection Processor
100
+
101
+ ```ts
102
+ const result = project({
103
+ kind: 'collection',
104
+ sources,
105
+ name: 'optional diagnostic name',
106
+ isEqual: Object.is,
107
+ build: ({ sources, previous, next, writer }) => {
108
+ // 构建完整输出和 store 局部索引。
109
+ return {
110
+ update: ({ sources, previous, next, writer }) => {
111
+ // 只暂存受影响的键,或返回 { kind: 'rebuild' }。
112
+ },
113
+ };
114
+ },
115
+ });
116
+ ```
117
+
118
+ 首次物化、source reset、显式 `store.rebuild`、update 请求 rebuild 以及 fault
119
+ 恢复时会调用 `build`。build 从已清空的输出开始,必须写出完整结果。
120
+ 其闭包状态只属于一个 store,并且必须能完全由 sources 重建。
121
+
122
+ 任一已声明 source 参与结算时调用 `update`。返回 `{ kind: 'rebuild' }` 会丢弃
123
+ 本次 update 暂存的写入,并执行全新 build。所有回调同步执行;Promise 和 thenable
124
+ 会被拒绝。processing 或 notification 中的读取和写入不得重入 source 或 document。
125
+
126
+ `previous`、`next`、source reader 和 `writer` 都只在当前同步回调内有效:
127
+
128
+ - `previous` 只看到已发布输出。
129
+ - `next` 立即看到当前暂存的写入。
130
+ - `writer.set(key, value)` 暂存存在值。
131
+ - `writer.remove(key)` 暂存缺失;删除不存在的键是 no-op。
132
+ - 同一键多次操作以最后一次为准。
133
+ - `writer.order(ids)` 提供完整 next 顺序,每个输出键必须恰好出现一次。
134
+ - `writer.replace(entries)` 清空并按 entries 顺序替换全部输出,键不能重复。
135
+
136
+ 暂存输出原子发布。相等判断默认使用 `Object.is`,相等的 set 保留旧引用。
137
+ 净零输出不增加 revision,也不通知 listener。
138
+
139
+ ## 跨集合 Join 模式
140
+
141
+ 不要增加通用 join 抽象。缺失引用、基数、重连、删除、顺序和结果键策略都属于领域逻辑。
142
+ 应使用带显式 sources 和索引的 advanced collection 表达 join。
143
+
144
+ 例如 route 由 `edges(from, to)` 和 node 几何信息计算,维护:
145
+
146
+ ```text
147
+ endpoints: edgeId -> [fromNodeId, toNodeId]
148
+ adjacency: nodeId -> Set<edgeId>
149
+ ```
150
+
151
+ build 时扫描一次 edges,填充两个索引并写出全部 routes。edge 变化时:
152
+
153
+ 1. 从每个 commit 的 collection impact 读取精确变化的 edge id。
154
+ 2. 将每个变化 id 从旧 endpoints 中解绑。
155
+ 3. 读取最终 edge 状态;绑定其最终 endpoints,或删除其正向索引。
156
+ 4. 将该 edge id 加入受影响输出集合。
157
+
158
+ node 集合变化时,把 added/updated/removed node id 通过 `adjacency` 映射为相邻
159
+ edge,并只将这些 edge 加入受影响集合。最后读取当前 endpoint 几何信息,对每个
160
+ 受影响 route 执行 set 或 remove。任一 source reset 都应 rebuild。
161
+
162
+ edge 重连后,旧 node 不得再选中它;删除 edge 必须同时移除正向和反向索引。
163
+ endpoint 缺失时的行为由领域显式定义。除非集合规模已知且权衡明确,不要在每次
164
+ node 变化时扫描全部 edges。
165
+
166
+ 闭包索引不属于 writer 的 staged transaction。未捕获的 processor 失败会触发 fresh
167
+ build 恢复,但 processor 如果自行捕获失败后继续执行,其索引修改会保留。先完成可能
168
+ 失败的工作再修改活动索引,或在临时状态上暂存索引变化并在成功后提交,或请求完整 rebuild。
169
+
170
+ ## Batch、Fault 与生命周期
171
+
172
+ `store.batch` 只推迟 projection 结算和 projection listener;文档 commit 和文档
173
+ listener 仍同步执行。batch 内读取返回上次发布值,最外层 batch 结束后根据最终
174
+ source 状态结算。不提供跨文档回滚。
175
+
176
+ processor 或 writer 校验失败不会发布部分输出。runtime 可以用新 build 重试;
177
+ 持续失败会使该 projection fault,并阻塞下游,独立分支仍继续工作。此时读取抛
178
+ `ProjectionError`。后续 source 更新或 `store.rebuild(projection)` 可以恢复。
179
+ `onError` 还接收 source、blocked 和 listener 失败。listener 失败不会撤销已发布
180
+ projection 状态或已接受的文档 commit。
181
+
182
+ `store.release(projection)` 释放可独立释放的物化结果;仍被已物化下游使用的节点
183
+ 不能释放。`store.dispose()` 使整个图失效并解除其全部订阅。
184
+
185
+ ## 审查清单
186
+
187
+ - 所有能改变结果的 source 都已声明。
188
+ - 候选选择覆盖 add、update、remove、reconnect 和缺失引用。
189
+ - batch 逻辑读取最终状态,而不是模拟中间状态。
190
+ - reset 和显式 rebuild 能重建完整输出、顺序和索引。
191
+ - build 不依赖旧输出或旧 processor 闭包。
192
+ - 使用 `writer.order` 时提供完整且有效的顺序。
193
+ - 相等结果保留引用;无关变化不产生输出 revision。
194
+ - update 抛错不会悄悄破坏闭包索引。
195
+ - 测试覆盖无关 commit、动态依赖、稳定引用、reset、batch、恢复和 disposal。