doxum 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +218 -0
- package/dist/chunk-pbuEa-1d.js +13 -0
- package/dist/contract-DwNiKioc.d.ts +480 -0
- package/dist/contract-Otb5W6cQ.d.cts +480 -0
- package/dist/dependency-BdEMyquf.js +735 -0
- package/dist/dependency-BdEMyquf.js.map +1 -0
- package/dist/dependency-DLcCvNKq.cjs +1015 -0
- package/dist/dependency-DLcCvNKq.cjs.map +1 -0
- package/dist/index.cjs +2718 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +89 -0
- package/dist/index.d.ts +87 -0
- package/dist/index.js +2689 -0
- package/dist/index.js.map +1 -0
- package/dist/integration.cjs +15 -0
- package/dist/integration.cjs.map +1 -0
- package/dist/integration.d.cts +11 -0
- package/dist/integration.d.ts +11 -0
- package/dist/integration.js +14 -0
- package/dist/integration.js.map +1 -0
- package/dist/react.cjs +99 -0
- package/dist/react.cjs.map +1 -0
- package/dist/react.d.cts +16 -0
- package/dist/react.d.ts +16 -0
- package/dist/react.js +96 -0
- package/dist/react.js.map +1 -0
- package/package.json +89 -0
- package/skills/doxum-runtime/SKILL.md +51 -0
- package/skills/doxum-runtime/agents/openai.yaml +4 -0
- package/skills/doxum-runtime/references/guide.en.md +295 -0
- package/skills/doxum-runtime/references/guide.zh-CN.md +240 -0
- package/skills/doxum-runtime/references/invariants.en.md +147 -0
- package/skills/doxum-runtime/references/invariants.zh-CN.md +97 -0
- package/skills/doxum-runtime/references/patterns.en.md +287 -0
- package/skills/doxum-runtime/references/patterns.zh-CN.md +240 -0
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# Doxum Runtime 不变量
|
|
2
|
+
|
|
3
|
+
修改或审查 mutation、addressing、impact、notification、history、tree 或 projection 前应阅读本页。这些是设计边界,而不是可选的代码风格。
|
|
4
|
+
|
|
5
|
+
## 唯一的 canonical 写入权威
|
|
6
|
+
|
|
7
|
+
`createDocument` 拥有 canonical mutable document state。新的 mutation 行为只能通过 `runtime.update`、`runtime.apply` 或 `runtime.replace`。writer 只是向 mutation session 产生 operation,而不是绕过 runtime 修改对象的出口。
|
|
8
|
+
|
|
9
|
+
绝不能新增:
|
|
10
|
+
|
|
11
|
+
- 并行的可写 document cache;
|
|
12
|
+
- 在 runtime 外编辑 document state 的 reducer;
|
|
13
|
+
- 由调用方手工同步的 view;
|
|
14
|
+
- 绕过 decode、normalization、inverse 记录、rollback、impact、history 或 notification 的 operation 执行路径。
|
|
15
|
+
|
|
16
|
+
## Transaction 生命周期与原子性
|
|
17
|
+
|
|
18
|
+
transaction callback 必须同步。reader 与 writer 只在 callback 执行期间有效。`async` callback、保存 reader/writer 供后续使用、嵌套写入,以及 notification 期间写入都会破坏 runtime 边界。
|
|
19
|
+
|
|
20
|
+
每个 session 都是原子的。若一个 operation 在之前已有状态变更后被拒绝,session 之前的全部工作都必须回滚。callback 抛错也会回滚,然后将原始错误继续抛出。预期中的拒绝应该通过返回结果表示,而不是异常协议。
|
|
21
|
+
|
|
22
|
+
## 保持引擎问题与应用问题分离
|
|
23
|
+
|
|
24
|
+
| 问题 | 所有者 | 结果形状 | 正确处理方式 |
|
|
25
|
+
| ----------------------------------------- | ---------------- | ----------------------------------------------- | -------------------------------------------- |
|
|
26
|
+
| malformed、无法解析或语义无效的 operation | Doxum engine | `source: 'mutation'` 的 `MutationIssue` | 检查 rejected operation/transaction result。 |
|
|
27
|
+
| 应用业务规则或校验 | Application | `source: 'application'` 的 `DocumentDiagnostic` | 使用 `tx.report` 或 `tx.reject`。 |
|
|
28
|
+
| callback 缺陷或意外失败 | Application code | rollback 后抛出的 error | 在 transaction 外修复或处理异常。 |
|
|
29
|
+
| processor、flush 或 listener 失败 | Observer | committed result 上的 `observerErrors` | 修复 observer;不要重放已提交的 write。 |
|
|
30
|
+
|
|
31
|
+
不要再创建笼统的 `invalid` status,不要把全部 error 字符串化为同一个形状,也不要将 notification failure 变成 mutation rejection。Mutation issue code 是封闭的公共词汇,必须保持精确。
|
|
32
|
+
|
|
33
|
+
## Schema 拥有 addressing 与 selector
|
|
34
|
+
|
|
35
|
+
schema 定义 operation 与 selector 的合法语义地址。address resolution 结合 schema 结构和当前 document state,这对于 collection entry 和 variant branch 都是必要的。
|
|
36
|
+
|
|
37
|
+
长期使用的 target 应由 `schema.value(...)` 与 `schema.collection(...)` 创建。runtime 的地址域使用 `runtime.address`;target 的 identity 与 bucket 使用导出的 `target` namespace。不要新增 string-path parser、另一种 address type、自定义 selector ID 或独立的 impact-target equality helper。
|
|
38
|
+
|
|
39
|
+
## Operation 只有一条 pipeline
|
|
40
|
+
|
|
41
|
+
外部 operation 输入遵循如下顺序:
|
|
42
|
+
|
|
43
|
+
```text
|
|
44
|
+
decode -> normalize -> resolve -> execute -> inverse + journal -> publish
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
`apply` 是不可信 operation envelope 的边界。executor code 看到输入前必须完成 decode;随后要归一为唯一 canonical operation shape、按 schema resolve,并且只能整体 publish 或整体 rollback。每个 committed operation 都需要精确的 inverse data 与精确的 impact。
|
|
48
|
+
|
|
49
|
+
本地应用行为应使用 writer,而不是手工拼 operation object。直接构造 operation 适用于边界 adapter、fixture、migration 和有意的 replay。
|
|
50
|
+
|
|
51
|
+
## 所有权必须明确
|
|
52
|
+
|
|
53
|
+
- `initial` 在成为 canonical state 前会被 clone。
|
|
54
|
+
- 普通 operation 中的结构化 payload 会转移到 canonical state。提交后仍修改它,可能会修改 canonical data。
|
|
55
|
+
- commit 与 history 的 operation payload 是不可变 snapshot。
|
|
56
|
+
- 已发布的 diagnostic 与 selector address 会被复制并冻结。
|
|
57
|
+
- tree replacement snapshot 会先校验并 clone。
|
|
58
|
+
|
|
59
|
+
不要承诺 Doxum 有意进行 payload transfer 的地方存在深度不可变性。调用方若要保留可变所有权,应在提交前自行 clone。
|
|
60
|
+
|
|
61
|
+
## Tree 完整性是整个 document 的完整性
|
|
62
|
+
|
|
63
|
+
每个存在的 tree 必须为空,或恰好只有一个 root;从 root 可完整到达所有节点、无环、无重复 child 引用,且 parent/child 双向一致。initial state 与 replacement snapshot 会完整检查,本地 tree operation 则增量维持它。
|
|
64
|
+
|
|
65
|
+
不能接受不连通 forest、orphan node、非 root move 到无 parent、将 root 挂到另一节点之下,或直接编辑 tree 的内部 record。tree operation 被拒绝时,整个 transaction 必须保持不变。
|
|
66
|
+
|
|
67
|
+
## Impact 与 notification 描述已提交状态
|
|
68
|
+
|
|
69
|
+
每次 commit 都发布一个 `DocumentImpact`;它不是让调用方修改的 mutable change log。value impact 使用 `affects(target)` 判断;collection impact 精确报告 added、removed、updated 和 order change,或在 replacement/subtree reset 后报告 `reset`。
|
|
70
|
+
|
|
71
|
+
notification 顺序是可观察行为:
|
|
72
|
+
|
|
73
|
+
```text
|
|
74
|
+
commit -> materialized processors -> processor flushes -> targeted listeners -> root listeners
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
update 与 notification 窗口中禁止写入。processor、flush 和 listener error 会被收集,但 committed document、revision 与 history 必须保持稳定。
|
|
78
|
+
|
|
79
|
+
## 派生状态应声明,而不是由调用方同步
|
|
80
|
+
|
|
81
|
+
`CollectionView` 从一个已声明的 collection 派生,并增量维护 ids、keyed values 和惰性 aggregate array。`MaterializedView` 从 document read、跟踪到的 impact dependency 和可选的更早 materialized source 派生一个值。materialized view 只能依赖同一 runtime 中比它更早创建的 view。
|
|
82
|
+
|
|
83
|
+
不要把 derived value 缓存在 canonical document state 中,除非它本来就是领域数据。不要让 UI 手工将变更推入 view。view 与 subscription 都应随 owner dispose。
|
|
84
|
+
|
|
85
|
+
## Framework 与产品边界
|
|
86
|
+
|
|
87
|
+
`core` 必须保持 framework-neutral。`doxum/react` 是从 core 到 React 的单向 adapter;core 不能 import React 或 UI 概念。Doxum 有意不决定 persistence format、网络同步、authorization、retry、acknowledgement、ordering 或 conflict resolution。应用必须在 apply operation 或 replace snapshot 前做出这些决策。
|
|
88
|
+
|
|
89
|
+
## 改动检查清单
|
|
90
|
+
|
|
91
|
+
runtime 相关改动交付前检查:
|
|
92
|
+
|
|
93
|
+
- 每一次 canonical write 是否仍经由 `createDocument`?
|
|
94
|
+
- 适用时,是否测试了成功、rejected rollback、inverse history 与 impact/subscription 行为?
|
|
95
|
+
- tree 与 collection 路径是否避免了意外的全量 document copy 或 traversal?
|
|
96
|
+
- 公共生命周期语义变化时,是否更新了 README 与 architecture guide?
|
|
97
|
+
- 过时的 protocol type、局部 helper 和重复的 address/target 解释是否被删除,而不是为了兼容继续保留?
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
# Doxum Patterns
|
|
2
|
+
|
|
3
|
+
Read this reference when implementing Doxum application code. It is organized by
|
|
4
|
+
the decisions that preserve the runtime model, rather than by an exhaustive
|
|
5
|
+
list of exported symbols.
|
|
6
|
+
|
|
7
|
+
## Model data by its mutation semantics
|
|
8
|
+
|
|
9
|
+
| Need | Schema node | Canonical value | Writer behavior |
|
|
10
|
+
| ------------------------------- | -------------------------- | ------------------- | -------------------------------------------- |
|
|
11
|
+
| One scalar or immutable leaf | `field<T>()` | `T` | `set`, and `clear` for an optional field |
|
|
12
|
+
| Nested named fields | `object({ ... })` | object | child writers |
|
|
13
|
+
| Tagged structural alternatives | `variant('kind', { ... })` | tagged object | `replace` the complete branch value |
|
|
14
|
+
| One structured entity | `single(entity)` | object | child writers |
|
|
15
|
+
| Ordered entities | `table(entity)` | `{ ids, byId }` | `create`, `item`, `remove`, `move` |
|
|
16
|
+
| Unordered entities | `map(entity)` | id record | `create`, `item`, `remove` |
|
|
17
|
+
| Scalar record | `record<Id, T>()` | complete record | `set`, `delete`, `replace` |
|
|
18
|
+
| Sparse scalar dictionary | `dict<Key, T>()` | partial record | `set`, `delete`, `replace` |
|
|
19
|
+
| Ordered scalar/structural items | `list({ keyOf })` | array | `insert`, `move`, `remove`, `replace` |
|
|
20
|
+
| One rooted hierarchy | `tree<T>()` | `{ rootId, nodes }` | `insert`, `move`, `remove`, `set`, `replace` |
|
|
21
|
+
|
|
22
|
+
Use a table when ordering is product-visible. Do not use a map plus a separate
|
|
23
|
+
array of ids: that creates two mutation protocols and two sources of order.
|
|
24
|
+
Use a list only when every item has a stable, unique application key; never use
|
|
25
|
+
the current index as `keyOf`.
|
|
26
|
+
|
|
27
|
+
```ts
|
|
28
|
+
import { field, list, map, object, schema, table, tree } from 'doxum';
|
|
29
|
+
|
|
30
|
+
type Tag = { id: string; name: string };
|
|
31
|
+
|
|
32
|
+
const note = object({ body: field<string>() });
|
|
33
|
+
const documentSchema = schema({
|
|
34
|
+
notes: table(note),
|
|
35
|
+
tags: list<Tag>({ keyOf: tag => tag.id }),
|
|
36
|
+
collaborators: map(object({ name: field<string>() })),
|
|
37
|
+
outline: tree<{ title: string }>(),
|
|
38
|
+
});
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## Local domain command: read, validate, write
|
|
42
|
+
|
|
43
|
+
Keep a domain command inside one transaction when all of its changes must
|
|
44
|
+
succeed or fail together. Read before creating operations when a business rule
|
|
45
|
+
depends on current state; call `tx.reject` for a blocking application rule.
|
|
46
|
+
|
|
47
|
+
```ts
|
|
48
|
+
function completeTask(id: string) {
|
|
49
|
+
return runtime.update(tx => {
|
|
50
|
+
const task = tx.read.tasks.get(id);
|
|
51
|
+
if (!task) {
|
|
52
|
+
tx.reject({
|
|
53
|
+
source: 'application',
|
|
54
|
+
code: 'task-not-found',
|
|
55
|
+
message: `Task '${id}' does not exist.`,
|
|
56
|
+
address: ['tasks', id],
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
if (task.completed.get()) return { changed: false };
|
|
60
|
+
|
|
61
|
+
tx.write.tasks.item(id).completed.set(true);
|
|
62
|
+
tx.report({
|
|
63
|
+
source: 'application',
|
|
64
|
+
code: 'task-completed',
|
|
65
|
+
message: 'Task marked complete.',
|
|
66
|
+
address: ['tasks', id],
|
|
67
|
+
});
|
|
68
|
+
return { changed: true };
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
`tx.report` does not reject the transaction. It is suitable for warnings,
|
|
74
|
+
audit-oriented feedback, or application messages that should accompany a valid
|
|
75
|
+
commit. `tx.reject` stops the transaction by returning a rejected result to the
|
|
76
|
+
outer caller. Neither is a substitute for malformed-operation handling:
|
|
77
|
+
engine failures are `MutationIssue` values supplied by Doxum.
|
|
78
|
+
|
|
79
|
+
## Apply external operations at one boundary
|
|
80
|
+
|
|
81
|
+
Keep serialization, authorization, network ordering, and conflict policy in an
|
|
82
|
+
application adapter. Once that adapter decides a batch may be applied, pass the
|
|
83
|
+
whole batch to Doxum.
|
|
84
|
+
|
|
85
|
+
```ts
|
|
86
|
+
async function receiveRemote(batch: unknown) {
|
|
87
|
+
// Authenticate, order, de-duplicate, and choose conflict policy here.
|
|
88
|
+
const result = runtime.apply(batch, {
|
|
89
|
+
source: 'remote',
|
|
90
|
+
history: false,
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
if (result.status === 'rejected') {
|
|
94
|
+
logRejectedOperations(result.issues);
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
if (result.status === 'committed') reportObserverErrors(result.observerErrors);
|
|
98
|
+
}
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
The cast above belongs only at a dynamic boundary whose runtime input is
|
|
102
|
+
actually unknown. Keep it there; do not loosen operation types throughout the
|
|
103
|
+
application. Doxum still validates malformed envelopes and semantic invalidity
|
|
104
|
+
before publishing a commit.
|
|
105
|
+
|
|
106
|
+
Use `replace` only for a new, trusted canonical snapshot. It produces reset
|
|
107
|
+
impact and invalidates local history. It is not a convenient way to express a
|
|
108
|
+
small change.
|
|
109
|
+
|
|
110
|
+
## Place ordered entries with anchors
|
|
111
|
+
|
|
112
|
+
Tables and lists use one `DocumentAnchor` vocabulary:
|
|
113
|
+
|
|
114
|
+
```ts
|
|
115
|
+
{
|
|
116
|
+
at: 'start';
|
|
117
|
+
}
|
|
118
|
+
{
|
|
119
|
+
at: 'end';
|
|
120
|
+
}
|
|
121
|
+
{
|
|
122
|
+
before: 'other-id';
|
|
123
|
+
}
|
|
124
|
+
{
|
|
125
|
+
after: 'other-id';
|
|
126
|
+
}
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
Use anchors instead of calculating indices in application code. They name the
|
|
130
|
+
domain position, let Doxum validate missing references, and keep table, list,
|
|
131
|
+
and operation replay semantics aligned.
|
|
132
|
+
|
|
133
|
+
```ts
|
|
134
|
+
runtime.update(tx => {
|
|
135
|
+
tx.write.tasks.create({ id: 'review', value: newTask }, { before: 'publish' });
|
|
136
|
+
tx.write.tags.insert({ id: 'urgent', name: 'Urgent' }, { at: 'start' });
|
|
137
|
+
tx.write.tags.move('urgent', { after: 'planning' });
|
|
138
|
+
});
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
## Work with trees as one invariant
|
|
142
|
+
|
|
143
|
+
A Doxum tree is either empty or a single connected root. Its `nodes` maintain
|
|
144
|
+
reciprocal parent/child links, unique children, full reachability, and no
|
|
145
|
+
cycles. Root replacement validates the whole snapshot; local writers preserve
|
|
146
|
+
the invariant incrementally.
|
|
147
|
+
|
|
148
|
+
```ts
|
|
149
|
+
runtime.update(tx => {
|
|
150
|
+
tx.write.outline.insert('root', { title: 'Project' });
|
|
151
|
+
tx.write.outline.insert('plan', { title: 'Plan' }, 'root');
|
|
152
|
+
tx.write.outline.move('plan', 'root', 0);
|
|
153
|
+
tx.write.outline.set('plan', { title: 'Plan release' });
|
|
154
|
+
});
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
Create the root with no `parentId` only when the tree is empty. Once a root
|
|
158
|
+
exists, every inserted node needs an existing parent. Do not move a non-root
|
|
159
|
+
node to `undefined`, re-parent the root, or directly edit `{ rootId, nodes }`
|
|
160
|
+
outside a validated `tree.replace` or runtime `replace` snapshot.
|
|
161
|
+
|
|
162
|
+
## Select and subscribe with the domain target
|
|
163
|
+
|
|
164
|
+
Create selectors once with their owning schema. This creates a stable public
|
|
165
|
+
contract for subscriptions and impact interpretation.
|
|
166
|
+
|
|
167
|
+
```ts
|
|
168
|
+
const notes = documentSchema.collection(path => path.notes);
|
|
169
|
+
const body = documentSchema.value(path => path.notes.item('a').body);
|
|
170
|
+
|
|
171
|
+
const unsubscribe = runtime.subscribe([notes, body], commit => {
|
|
172
|
+
if (commit.impact.affects(body)) refreshNoteUI();
|
|
173
|
+
|
|
174
|
+
const change = commit.impact.collection(notes);
|
|
175
|
+
if (change.kind === 'incremental' && change.updated.has('a')) refreshRow('a');
|
|
176
|
+
});
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
Use `target.same(left, right)` only when you need to compare two impact targets
|
|
180
|
+
as values. Use `target.address`, `target.id`, `target.belongs`, and
|
|
181
|
+
`target.bucket` rather than duplicating their interpretation in a framework
|
|
182
|
+
adapter or local helper.
|
|
183
|
+
|
|
184
|
+
## Build a collection view for mapped rows
|
|
185
|
+
|
|
186
|
+
Use a collection view when a table or map needs a reusable read model. Its
|
|
187
|
+
mapping callback can read the entry's typed fields; `isEqual` lets the view
|
|
188
|
+
retain a previous mapped value when a remapped row is semantically unchanged.
|
|
189
|
+
|
|
190
|
+
```ts
|
|
191
|
+
const notes = documentSchema.collection(path => path.notes);
|
|
192
|
+
|
|
193
|
+
const noteSummaries = createCollectionView({
|
|
194
|
+
runtime,
|
|
195
|
+
source: notes,
|
|
196
|
+
map: (id, note) => ({ id, preview: note.body.get().slice(0, 80) }),
|
|
197
|
+
isEqual: (left, right) => left.id === right.id && left.preview === right.preview,
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
const stop = noteSummaries.item('a').subscribe(() => rerenderRow('a'));
|
|
201
|
+
const all = noteSummaries.all.current();
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
Do not make callers push changes into the view. It listens to its declared
|
|
205
|
+
source and recomputes from runtime state. Dispose `noteSummaries` and `stop`
|
|
206
|
+
with the UI or service that owns them.
|
|
207
|
+
|
|
208
|
+
## Build a materialized aggregate or index
|
|
209
|
+
|
|
210
|
+
Use a materialized view when you own an aggregate that can update from a
|
|
211
|
+
commit. During `update`, inspect the impact through the provided `impact`
|
|
212
|
+
object. That records the actual dependencies, so future unrelated commits can
|
|
213
|
+
skip this update.
|
|
214
|
+
|
|
215
|
+
```ts
|
|
216
|
+
const notes = documentSchema.collection(path => path.notes);
|
|
217
|
+
|
|
218
|
+
const noteCount = createMaterializedView(runtime, {
|
|
219
|
+
build: ({ read }) => ({
|
|
220
|
+
value: read.notes.ids().length,
|
|
221
|
+
update: ({ impact, read }) => {
|
|
222
|
+
const change = impact.collection(notes);
|
|
223
|
+
if (
|
|
224
|
+
change.kind === 'incremental' &&
|
|
225
|
+
change.added.size === 0 &&
|
|
226
|
+
change.removed.size === 0 &&
|
|
227
|
+
change.updated.size === 0 &&
|
|
228
|
+
!change.orderChanged
|
|
229
|
+
) {
|
|
230
|
+
return { kind: 'unchanged' };
|
|
231
|
+
}
|
|
232
|
+
return {
|
|
233
|
+
kind: 'changed',
|
|
234
|
+
value: read.notes.ids().length,
|
|
235
|
+
change,
|
|
236
|
+
};
|
|
237
|
+
},
|
|
238
|
+
}),
|
|
239
|
+
});
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
Return `rebuild` if local incremental logic cannot safely handle a commit.
|
|
243
|
+
Views created later can name earlier views in `sources`; Doxum processes that
|
|
244
|
+
graph in creation order and flushes it before external listeners observe the
|
|
245
|
+
commit.
|
|
246
|
+
|
|
247
|
+
## Bind read models in React
|
|
248
|
+
|
|
249
|
+
For a direct document read, use `useDocumentSelector`. It tracks paths and
|
|
250
|
+
collection entries read during each selector execution, including dynamic
|
|
251
|
+
dependencies.
|
|
252
|
+
|
|
253
|
+
```tsx
|
|
254
|
+
function Note({ id }: { id: string }) {
|
|
255
|
+
const body = useDocumentSelector(runtime, read => read.notes.get(id)?.body.get());
|
|
256
|
+
return <p>{body ?? 'Missing note'}</p>;
|
|
257
|
+
}
|
|
258
|
+
```
|
|
259
|
+
|
|
260
|
+
For an existing read model, use its narrower hook:
|
|
261
|
+
|
|
262
|
+
```tsx
|
|
263
|
+
function NoteRow({ id }: { id: string }) {
|
|
264
|
+
const summary = useReadable(noteSummaries.item(id));
|
|
265
|
+
return <p>{summary?.preview}</p>;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function UndoButton() {
|
|
269
|
+
const history = useHistory(runtime.history);
|
|
270
|
+
return (
|
|
271
|
+
<button disabled={history.undoDepth === 0} onClick={() => history.undo()}>
|
|
272
|
+
Undo
|
|
273
|
+
</button>
|
|
274
|
+
);
|
|
275
|
+
}
|
|
276
|
+
```
|
|
277
|
+
|
|
278
|
+
Keep selectors pure. They should read Doxum state and calculate a value; do not
|
|
279
|
+
write, subscribe manually, or cause I/O while a selector is running.
|
|
280
|
+
|
|
281
|
+
## Test the behavior that changes
|
|
282
|
+
|
|
283
|
+
For a mutation change, cover the successful commit, rejected partial batch
|
|
284
|
+
rollback, inverse/history result, and relevant impact or subscription outcome.
|
|
285
|
+
For a projection, cover unrelated commits, dynamic dependencies, stable
|
|
286
|
+
references where expected, and disposal. For large tables, lists, and trees,
|
|
287
|
+
add a regression test that proves unrelated data is not copied or traversed.
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
# Doxum 模式参考
|
|
2
|
+
|
|
3
|
+
实现 Doxum 应用代码时阅读本页。它按能保持 runtime 模型正确的决策组织,而不是机械穷举所有导出符号。
|
|
4
|
+
|
|
5
|
+
## 按修改语义建模数据
|
|
6
|
+
|
|
7
|
+
| 需求 | Schema node | Canonical value | Writer 行为 |
|
|
8
|
+
| -------------------- | -------------------------- | ------------------- | -------------------------------------------- |
|
|
9
|
+
| 一个标量或不可变叶子 | `field<T>()` | `T` | `set`;optional field 还可 `clear` |
|
|
10
|
+
| 命名的嵌套字段 | `object({ ... })` | object | 子 writer |
|
|
11
|
+
| 带 tag 的结构分支 | `variant('kind', { ... })` | tagged object | `replace` 完整分支值 |
|
|
12
|
+
| 一个结构化实体 | `single(entity)` | object | 子 writer |
|
|
13
|
+
| 有序实体 | `table(entity)` | `{ ids, byId }` | `create`、`item`、`remove`、`move` |
|
|
14
|
+
| 无序实体 | `map(entity)` | id record | `create`、`item`、`remove` |
|
|
15
|
+
| 标量 record | `record<Id, T>()` | 完整 record | `set`、`delete`、`replace` |
|
|
16
|
+
| 稀疏标量字典 | `dict<Key, T>()` | partial record | `set`、`delete`、`replace` |
|
|
17
|
+
| 有序标量/结构条目 | `list({ keyOf })` | array | `insert`、`move`、`remove`、`replace` |
|
|
18
|
+
| 单根层级 | `tree<T>()` | `{ rootId, nodes }` | `insert`、`move`、`remove`、`set`、`replace` |
|
|
19
|
+
|
|
20
|
+
当顺序对产品可见时使用 table。不要用 map 加另一份 ids array 表示顺序:这会产生两套 mutation protocol 和两个顺序来源。list 只适合每个条目都有稳定、唯一应用 key 的情形;不能把当前 index 当作 `keyOf`。
|
|
21
|
+
|
|
22
|
+
```ts
|
|
23
|
+
import { field, list, map, object, schema, table, tree } from 'doxum';
|
|
24
|
+
|
|
25
|
+
type Tag = { id: string; name: string };
|
|
26
|
+
|
|
27
|
+
const note = object({ body: field<string>() });
|
|
28
|
+
const documentSchema = schema({
|
|
29
|
+
notes: table(note),
|
|
30
|
+
tags: list<Tag>({ keyOf: tag => tag.id }),
|
|
31
|
+
collaborators: map(object({ name: field<string>() })),
|
|
32
|
+
outline: tree<{ title: string }>(),
|
|
33
|
+
});
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## 本地领域命令:读取、校验、写入
|
|
37
|
+
|
|
38
|
+
所有修改要么全部成功、要么全部失败时,将领域命令放进同一个 transaction。业务规则依赖当前状态时,先读取再产生 operation;阻断型业务规则调用 `tx.reject`。
|
|
39
|
+
|
|
40
|
+
```ts
|
|
41
|
+
function completeTask(id: string) {
|
|
42
|
+
return runtime.update(tx => {
|
|
43
|
+
const task = tx.read.tasks.get(id);
|
|
44
|
+
if (!task) {
|
|
45
|
+
tx.reject({
|
|
46
|
+
source: 'application',
|
|
47
|
+
code: 'task-not-found',
|
|
48
|
+
message: `Task '${id}' does not exist.`,
|
|
49
|
+
address: ['tasks', id],
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
if (task.completed.get()) return { changed: false };
|
|
53
|
+
|
|
54
|
+
tx.write.tasks.item(id).completed.set(true);
|
|
55
|
+
tx.report({
|
|
56
|
+
source: 'application',
|
|
57
|
+
code: 'task-completed',
|
|
58
|
+
message: 'Task marked complete.',
|
|
59
|
+
address: ['tasks', id],
|
|
60
|
+
});
|
|
61
|
+
return { changed: true };
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
`tx.report` 不会拒绝 transaction,适合 warning、审计反馈或伴随合法 commit 的应用消息。`tx.reject` 会中止 transaction,并让外层调用者得到 rejected result。两者都不能替代 malformed operation 的处理:引擎失败由 Doxum 提供的 `MutationIssue` 表示。
|
|
67
|
+
|
|
68
|
+
## 在一个边界应用外部 operation
|
|
69
|
+
|
|
70
|
+
将序列化、授权、网络排序和冲突策略集中在应用 adapter 中。adapter 判断一个 batch 可以应用后,再把完整 batch 交给 Doxum。
|
|
71
|
+
|
|
72
|
+
```ts
|
|
73
|
+
async function receiveRemote(batch: unknown) {
|
|
74
|
+
// 在这里执行认证、排序、去重和冲突策略。
|
|
75
|
+
const result = runtime.apply(batch, {
|
|
76
|
+
source: 'remote',
|
|
77
|
+
history: false,
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
if (result.status === 'rejected') {
|
|
81
|
+
logRejectedOperations(result.issues);
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
if (result.status === 'committed') reportObserverErrors(result.observerErrors);
|
|
85
|
+
}
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
上面的断言只应存在于真正接受未知运行时输入的动态边界;不要把 operation type 放宽到整个应用。Doxum 仍会在发布 commit 前校验 malformed envelope 与语义错误。
|
|
89
|
+
|
|
90
|
+
`replace` 只用于新的、可信的 canonical snapshot。它会产生 reset impact 并使 local history 失效,不是表达微小变更的便捷写法。
|
|
91
|
+
|
|
92
|
+
## 用 Anchor 放置有序条目
|
|
93
|
+
|
|
94
|
+
table 与 list 共用同一套 `DocumentAnchor` 词汇:
|
|
95
|
+
|
|
96
|
+
```ts
|
|
97
|
+
{
|
|
98
|
+
at: 'start';
|
|
99
|
+
}
|
|
100
|
+
{
|
|
101
|
+
at: 'end';
|
|
102
|
+
}
|
|
103
|
+
{
|
|
104
|
+
before: 'other-id';
|
|
105
|
+
}
|
|
106
|
+
{
|
|
107
|
+
after: 'other-id';
|
|
108
|
+
}
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
不要在应用代码中计算 index,而应使用 anchor。它表达领域位置,让 Doxum 校验缺失的引用,并让 table、list 与 operation replay 语义保持一致。
|
|
112
|
+
|
|
113
|
+
```ts
|
|
114
|
+
runtime.update(tx => {
|
|
115
|
+
tx.write.tasks.create({ id: 'review', value: newTask }, { before: 'publish' });
|
|
116
|
+
tx.write.tags.insert({ id: 'urgent', name: 'Urgent' }, { at: 'start' });
|
|
117
|
+
tx.write.tags.move('urgent', { after: 'planning' });
|
|
118
|
+
});
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
## 将 tree 视为一个整体不变量
|
|
122
|
+
|
|
123
|
+
Doxum tree 要么为空,要么存在唯一且连通的 root。`nodes` 必须维护双向 parent/child 关系、无重复 child、全量可达与无环。root replace 会校验完整 snapshot;本地 writer 会以增量方式维持该不变量。
|
|
124
|
+
|
|
125
|
+
```ts
|
|
126
|
+
runtime.update(tx => {
|
|
127
|
+
tx.write.outline.insert('root', { title: 'Project' });
|
|
128
|
+
tx.write.outline.insert('plan', { title: 'Plan' }, 'root');
|
|
129
|
+
tx.write.outline.move('plan', 'root', 0);
|
|
130
|
+
tx.write.outline.set('plan', { title: 'Plan release' });
|
|
131
|
+
});
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
只有在 tree 为空时,才能不传 `parentId` 创建 root。root 已存在后,每个新节点都必须有已存在的 parent。不要将非 root move 到 `undefined`,不要重新挂载 root,也不要在经过校验的 `tree.replace` 或 runtime `replace` snapshot 之外直接编辑 `{ rootId, nodes }`。
|
|
135
|
+
|
|
136
|
+
## 通过领域 target select 与 subscribe
|
|
137
|
+
|
|
138
|
+
从所属 schema 创建 selector 一次。这为 subscription 与 impact 解释建立稳定的公共契约。
|
|
139
|
+
|
|
140
|
+
```ts
|
|
141
|
+
const notes = documentSchema.collection(path => path.notes);
|
|
142
|
+
const body = documentSchema.value(path => path.notes.item('a').body);
|
|
143
|
+
|
|
144
|
+
const unsubscribe = runtime.subscribe([notes, body], commit => {
|
|
145
|
+
if (commit.impact.affects(body)) refreshTitleUI();
|
|
146
|
+
|
|
147
|
+
const change = commit.impact.collection(notes);
|
|
148
|
+
if (change.kind === 'incremental' && change.updated.has('a')) refreshRow('a');
|
|
149
|
+
});
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
只有需要将两个 impact target 当作值比较时,使用 `target.same(left, right)`。应使用 `target.address`、`target.id`、`target.belongs` 与 `target.bucket`,不要在 framework adapter 或局部 helper 中复制它们的解释逻辑。
|
|
153
|
+
|
|
154
|
+
## 用 CollectionView 构建映射行
|
|
155
|
+
|
|
156
|
+
当一个 table 或 map 需要可复用 read model 时,使用 collection view。mapping callback 可以读取 entry 的类型化字段;`isEqual` 允许 view 在重新映射出的 row 语义不变时保留旧值。
|
|
157
|
+
|
|
158
|
+
```ts
|
|
159
|
+
const notes = documentSchema.collection(path => path.notes);
|
|
160
|
+
|
|
161
|
+
const noteSummaries = createCollectionView({
|
|
162
|
+
runtime,
|
|
163
|
+
source: notes,
|
|
164
|
+
map: (id, note) => ({ id, preview: note.body.get().slice(0, 80) }),
|
|
165
|
+
isEqual: (left, right) => left.id === right.id && left.preview === right.preview,
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
const stop = noteSummaries.item('a').subscribe(() => rerenderRow('a'));
|
|
169
|
+
const all = noteSummaries.all.current();
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
不要让调用方把变更推入 view。它会监听声明的 source 并从 runtime state 重新计算。`noteSummaries` 和 `stop` 应随所属 UI 或 service 一起 dispose。
|
|
173
|
+
|
|
174
|
+
## 用 MaterializedView 构建聚合或索引
|
|
175
|
+
|
|
176
|
+
当你拥有可以根据 commit 增量更新的 aggregate 时,使用 materialized view。`update` 期间通过传入的 `impact` 对象检查影响范围;这会记录真实依赖,使之后无关的 commit 可以跳过此次 update。
|
|
177
|
+
|
|
178
|
+
```ts
|
|
179
|
+
const notes = documentSchema.collection(path => path.notes);
|
|
180
|
+
|
|
181
|
+
const noteCount = createMaterializedView(runtime, {
|
|
182
|
+
build: ({ read }) => ({
|
|
183
|
+
value: read.notes.ids().length,
|
|
184
|
+
update: ({ impact, read }) => {
|
|
185
|
+
const change = impact.collection(notes);
|
|
186
|
+
if (
|
|
187
|
+
change.kind === 'incremental' &&
|
|
188
|
+
change.added.size === 0 &&
|
|
189
|
+
change.removed.size === 0 &&
|
|
190
|
+
change.updated.size === 0 &&
|
|
191
|
+
!change.orderChanged
|
|
192
|
+
) {
|
|
193
|
+
return { kind: 'unchanged' };
|
|
194
|
+
}
|
|
195
|
+
return {
|
|
196
|
+
kind: 'changed',
|
|
197
|
+
value: read.notes.ids().length,
|
|
198
|
+
change,
|
|
199
|
+
};
|
|
200
|
+
},
|
|
201
|
+
}),
|
|
202
|
+
});
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
局部增量逻辑无法安全处理一个 commit 时,返回 `rebuild`。后创建的 view 可以在 `sources` 中声明更早创建的 view;Doxum 会按创建顺序处理该图,并在外部 listener 看到 commit 前将其 flush 完成。
|
|
206
|
+
|
|
207
|
+
## 在 React 中绑定读模型
|
|
208
|
+
|
|
209
|
+
直接读取 document 时使用 `useDocumentSelector`。它会跟踪每次 selector 执行中读取的 path 和 collection entry,包括动态依赖。
|
|
210
|
+
|
|
211
|
+
```tsx
|
|
212
|
+
function Note({ id }: { id: string }) {
|
|
213
|
+
const body = useDocumentSelector(runtime, read => read.notes.get(id)?.body.get());
|
|
214
|
+
return <p>{body ?? 'Missing note'}</p>;
|
|
215
|
+
}
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
已有 read model 则使用更窄的 hook:
|
|
219
|
+
|
|
220
|
+
```tsx
|
|
221
|
+
function NoteRow({ id }: { id: string }) {
|
|
222
|
+
const summary = useReadable(noteSummaries.item(id));
|
|
223
|
+
return <p>{summary?.preview}</p>;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function UndoButton() {
|
|
227
|
+
const history = useHistory(runtime.history);
|
|
228
|
+
return (
|
|
229
|
+
<button disabled={history.undoDepth === 0} onClick={() => history.undo()}>
|
|
230
|
+
Undo
|
|
231
|
+
</button>
|
|
232
|
+
);
|
|
233
|
+
}
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
selector 应保持纯粹:读取 Doxum state 并计算结果即可;不要在 selector 执行中写入、手动订阅或发起 I/O。
|
|
237
|
+
|
|
238
|
+
## 测试真正发生变化的行为
|
|
239
|
+
|
|
240
|
+
mutation 改动应覆盖成功 commit、部分 batch 的 rejected rollback、inverse/history 结果,以及相关 impact 或 subscription 行为。projection 改动应覆盖无关 commit、动态依赖、应当稳定的引用和 dispose。大型 table、list 与 tree 应增加回归测试,证明无关数据没有被复制或遍历。
|