@mark1russell7/render-node 0.0.0-stage → 0.1.0
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 +39 -2
- package/build/index.d.ts +238 -0
- package/build/index.d.ts.map +1 -0
- package/build/index.js +846 -0
- package/build/index.js.map +1 -0
- package/package.json +66 -3
- package/src/engine.ts +572 -0
- package/src/equality.ts +47 -0
- package/src/index.ts +12 -0
- package/src/ops.ts +74 -0
- package/src/toposort.ts +46 -0
- package/src/types.ts +108 -0
- package/src/writes.ts +266 -0
package/src/engine.ts
ADDED
|
@@ -0,0 +1,572 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The internal state of a store and the epoch machine. The public operations are in `writes.ts`.
|
|
3
|
+
*
|
|
4
|
+
* Each write changes the structure at once and marks the nodes that it touched. A flush then rewires
|
|
5
|
+
* the touched nodes and starts one epoch over them. Outside a batch, each write flushes at its end.
|
|
6
|
+
*/
|
|
7
|
+
import type { DepPath, Expr, Ops, Resolver } from "@mark1russell7/render-dsl";
|
|
8
|
+
import { deps, evaluate, lit, ref } from "@mark1russell7/render-dsl";
|
|
9
|
+
import type { Optional } from "@mark1russell7/render-optional";
|
|
10
|
+
import { isSome, none, some } from "@mark1russell7/render-optional";
|
|
11
|
+
import type { EpochStats, Node, NodeId, NodeOps, NodeStore } from "./types.ts";
|
|
12
|
+
import { isPlainObject } from "./equality.ts";
|
|
13
|
+
|
|
14
|
+
/** The mutable form of a node. Only the engine sees it. */
|
|
15
|
+
export type MutableNode = {
|
|
16
|
+
readonly id: NodeId;
|
|
17
|
+
expr: Expr;
|
|
18
|
+
value: Optional<unknown>;
|
|
19
|
+
reads: readonly DepPath[];
|
|
20
|
+
readonly seats: Set<NodeId>;
|
|
21
|
+
readonly seatsStructural: Set<NodeId>;
|
|
22
|
+
readonly seatedOn: Set<NodeId>;
|
|
23
|
+
parent: NodeId | undefined;
|
|
24
|
+
readonly heldBy: Set<NodeId>;
|
|
25
|
+
readonly slots: Map<string, NodeId>;
|
|
26
|
+
/** True when a read of this node waits in `dangling`. */
|
|
27
|
+
dangles: boolean;
|
|
28
|
+
// The scratch fields of the epoch machine. They are valid only when `mark` is the current epoch.
|
|
29
|
+
mark: number;
|
|
30
|
+
dirty: number;
|
|
31
|
+
index: number;
|
|
32
|
+
low: number;
|
|
33
|
+
onStack: boolean;
|
|
34
|
+
next: MutableNode[] | null;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
/** The work that the next flush does. */
|
|
38
|
+
type Pending = {
|
|
39
|
+
/** The nodes to rewire and to evaluate. */
|
|
40
|
+
readonly touched: Set<NodeId>;
|
|
41
|
+
/** The containers whose record expression to make again from their slots. */
|
|
42
|
+
readonly records: Set<NodeId>;
|
|
43
|
+
/** The nodes to expand after the epoch, because a batch asked for an expansion of a value that was not settled. */
|
|
44
|
+
readonly expansions: Set<NodeId>;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
/** The full state of a store. `nodeStore` makes it, and the public type `NodeStore` shows a part of it. */
|
|
48
|
+
export type StoreState = {
|
|
49
|
+
readonly nodes: Map<NodeId, MutableNode>;
|
|
50
|
+
readonly nodeOps: NodeOps;
|
|
51
|
+
readonly ops: Ops;
|
|
52
|
+
epochStats: EpochStats | null;
|
|
53
|
+
nextId: number;
|
|
54
|
+
/** For each node ID that is not in the store: the readers whose path starts at it. */
|
|
55
|
+
readonly dangling: Map<NodeId, Set<NodeId>>;
|
|
56
|
+
pending: Pending | null;
|
|
57
|
+
batchDepth: number;
|
|
58
|
+
/** The counter of epochs. The scratch fields of a node use it as a stamp. */
|
|
59
|
+
epoch: number;
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
/** This function gives the internal state of a store. All stores come from `makeState`. */
|
|
63
|
+
export const state = (store: NodeStore): StoreState => store as unknown as StoreState;
|
|
64
|
+
|
|
65
|
+
/** This function gives the public view of a state. */
|
|
66
|
+
export const publicStore = (s: StoreState): NodeStore => s;
|
|
67
|
+
|
|
68
|
+
/** This function makes the state of an empty store. */
|
|
69
|
+
export const makeState = (nodeOps: NodeOps, ops: Ops): StoreState => ({
|
|
70
|
+
nodes: new Map(),
|
|
71
|
+
nodeOps,
|
|
72
|
+
ops,
|
|
73
|
+
epochStats: null,
|
|
74
|
+
nextId: 0,
|
|
75
|
+
dangling: new Map(),
|
|
76
|
+
pending: null,
|
|
77
|
+
batchDepth: 0,
|
|
78
|
+
epoch: 0,
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
/** This function makes a node that is not in a store yet. */
|
|
82
|
+
export const makeNode = (id: NodeId, expr: Expr): MutableNode => ({
|
|
83
|
+
id,
|
|
84
|
+
expr,
|
|
85
|
+
value: none,
|
|
86
|
+
reads: deps(expr),
|
|
87
|
+
seats: new Set(),
|
|
88
|
+
seatsStructural: new Set(),
|
|
89
|
+
seatedOn: new Set(),
|
|
90
|
+
parent: undefined,
|
|
91
|
+
heldBy: new Set(),
|
|
92
|
+
slots: new Map(),
|
|
93
|
+
dangles: false,
|
|
94
|
+
mark: 0,
|
|
95
|
+
dirty: 0,
|
|
96
|
+
index: -1,
|
|
97
|
+
low: -1,
|
|
98
|
+
onStack: false,
|
|
99
|
+
next: null,
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* This function gives an ID that no node of the store has. It uses the hint when the hint is free.
|
|
104
|
+
* Without a hint, the IDs have the form `n_<number>`, with a counter for each store.
|
|
105
|
+
*/
|
|
106
|
+
export const freshId = (s: StoreState, hint?: string): NodeId => {
|
|
107
|
+
if (hint !== undefined && !s.nodes.has(hint)) return hint;
|
|
108
|
+
const base = hint ?? "n";
|
|
109
|
+
for (;;) {
|
|
110
|
+
const id = `${base}${hint === undefined ? "_" : "~"}${String(s.nextId++)}`;
|
|
111
|
+
if (!s.nodes.has(id)) return id;
|
|
112
|
+
}
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
/** This function makes the `record` expression of a container: one field for each slot. */
|
|
116
|
+
export const recordOf = (n: MutableNode): Expr => {
|
|
117
|
+
const args: Expr[] = [];
|
|
118
|
+
for (const name of n.slots.keys()) args.push(lit(name), ref(n.id, name));
|
|
119
|
+
return { tag: "app", op: "record", args };
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
/** This function tells if the expression of a container is the record of its current slots. */
|
|
123
|
+
const isRecordOf = (n: MutableNode): boolean => {
|
|
124
|
+
const e = n.expr;
|
|
125
|
+
if (e.tag !== "app" || e.op !== "record" || e.args.length !== 2 * n.slots.size) return false;
|
|
126
|
+
let i = 0;
|
|
127
|
+
for (const name of n.slots.keys()) {
|
|
128
|
+
const k = e.args[i++]!;
|
|
129
|
+
const r = e.args[i++]!;
|
|
130
|
+
if (k.tag !== "lit" || k.value !== name) return false;
|
|
131
|
+
if (r.tag !== "ref" || r.path.length !== 2 || r.path[0] !== n.id || r.path[1] !== name) return false;
|
|
132
|
+
}
|
|
133
|
+
return true;
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
// === Slots ===
|
|
137
|
+
|
|
138
|
+
/** This function points a slot at a node and records the container in `heldBy` of the node. */
|
|
139
|
+
export const linkSlot = (container: MutableNode, name: string, child: MutableNode): void => {
|
|
140
|
+
container.slots.set(name, child.id);
|
|
141
|
+
child.heldBy.add(container.id);
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
/** This function removes a slot. The target forgets the container when no other slot of the container points at it. */
|
|
145
|
+
export const unlinkSlot = (s: StoreState, container: MutableNode, name: string): void => {
|
|
146
|
+
const target = container.slots.get(name);
|
|
147
|
+
container.slots.delete(name);
|
|
148
|
+
if (target === undefined) return;
|
|
149
|
+
for (const other of container.slots.values()) if (other === target) return;
|
|
150
|
+
s.nodes.get(target)?.heldBy.delete(container.id);
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
// === Wiring ===
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* This function gives the chain of nodes that a read path goes through. The chain starts at the root
|
|
157
|
+
* (`path[0]`, a node ID) and follows slots as far as they exist. The last node is the terminal: the path
|
|
158
|
+
* reads its value. The function gives an empty chain when the root is not in the store.
|
|
159
|
+
*/
|
|
160
|
+
export const readTargets = (nodes: ReadonlyMap<NodeId, Node>, path: DepPath): Node[] => {
|
|
161
|
+
const rootId = path[0];
|
|
162
|
+
const root = rootId === undefined ? undefined : nodes.get(rootId);
|
|
163
|
+
if (!root) return [];
|
|
164
|
+
const out: Node[] = [root];
|
|
165
|
+
let current = root;
|
|
166
|
+
for (let i = 1; i < path.length; i++) {
|
|
167
|
+
const slotId = current.slots.get(path[i]!);
|
|
168
|
+
const next = slotId === undefined ? undefined : nodes.get(slotId);
|
|
169
|
+
if (!next) break;
|
|
170
|
+
current = next;
|
|
171
|
+
out.push(current);
|
|
172
|
+
}
|
|
173
|
+
return out;
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* This function seats a node on the targets of its reads (the `targets` op of the store). The terminal gets a
|
|
178
|
+
* value seat, and each node that the path goes through gets a structural seat. A read whose root is not in the
|
|
179
|
+
* store waits in `dangling`. A missing slot of a container has no terminal. Thus a container cannot be on a
|
|
180
|
+
* false cycle with a reader of one of its missing slots.
|
|
181
|
+
*/
|
|
182
|
+
export const wireNode = (s: StoreState, n: MutableNode): void => {
|
|
183
|
+
for (const path of n.reads) {
|
|
184
|
+
const rootId = path[0];
|
|
185
|
+
const root = rootId === undefined ? undefined : s.nodes.get(rootId);
|
|
186
|
+
if (!root) {
|
|
187
|
+
if (rootId !== undefined) {
|
|
188
|
+
let waiting = s.dangling.get(rootId);
|
|
189
|
+
if (!waiting) s.dangling.set(rootId, (waiting = new Set()));
|
|
190
|
+
waiting.add(n.id);
|
|
191
|
+
n.dangles = true;
|
|
192
|
+
}
|
|
193
|
+
continue;
|
|
194
|
+
}
|
|
195
|
+
const targets = s.nodeOps.targets(root, path.slice(1), publicStore(s));
|
|
196
|
+
for (const t of targets.through) {
|
|
197
|
+
const m = s.nodes.get(t.id);
|
|
198
|
+
if (!m || m === n) continue;
|
|
199
|
+
m.seatsStructural.add(n.id);
|
|
200
|
+
n.seatedOn.add(m.id);
|
|
201
|
+
}
|
|
202
|
+
const terminal = targets.terminal === undefined ? undefined : s.nodes.get(targets.terminal.id);
|
|
203
|
+
if (terminal && terminal !== n) {
|
|
204
|
+
terminal.seats.add(n.id);
|
|
205
|
+
n.seatedOn.add(terminal.id);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
/** This function removes a node from each seat set that holds it, and from `dangling`. */
|
|
211
|
+
export const unwireNode = (s: StoreState, n: MutableNode): void => {
|
|
212
|
+
if (n.seatedOn.size > 0) {
|
|
213
|
+
for (const otherId of n.seatedOn) {
|
|
214
|
+
const other = s.nodes.get(otherId);
|
|
215
|
+
if (other) {
|
|
216
|
+
other.seats.delete(n.id);
|
|
217
|
+
other.seatsStructural.delete(n.id);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
n.seatedOn.clear();
|
|
221
|
+
}
|
|
222
|
+
if (n.dangles) {
|
|
223
|
+
for (const path of n.reads) {
|
|
224
|
+
const head = path[0];
|
|
225
|
+
const waiting = head === undefined ? undefined : s.dangling.get(head);
|
|
226
|
+
if (waiting) {
|
|
227
|
+
waiting.delete(n.id);
|
|
228
|
+
if (waiting.size === 0) s.dangling.delete(head!);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
n.dangles = false;
|
|
232
|
+
}
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
/** This function changes the expression of a node and keeps its reads correct. The caller touches the node. */
|
|
236
|
+
export const replaceExpr = (s: StoreState, n: MutableNode, expr: Expr): void => {
|
|
237
|
+
unwireNode(s, n);
|
|
238
|
+
n.expr = expr;
|
|
239
|
+
n.reads = deps(expr);
|
|
240
|
+
};
|
|
241
|
+
|
|
242
|
+
// === Pending work, batches and the flush ===
|
|
243
|
+
|
|
244
|
+
const pending = (s: StoreState): Pending =>
|
|
245
|
+
(s.pending ??= { touched: new Set(), records: new Set(), expansions: new Set() });
|
|
246
|
+
|
|
247
|
+
/** This function marks a node for the next flush: rewire and evaluate. */
|
|
248
|
+
export const touch = (s: StoreState, id: NodeId): void => {
|
|
249
|
+
pending(s).touched.add(id);
|
|
250
|
+
};
|
|
251
|
+
|
|
252
|
+
/** This function marks a container: the next flush makes its record expression again. */
|
|
253
|
+
export const touchRecord = (s: StoreState, id: NodeId): void => {
|
|
254
|
+
pending(s).records.add(id);
|
|
255
|
+
};
|
|
256
|
+
|
|
257
|
+
/** This function marks a node for an expansion after the next epoch, when its value is settled. */
|
|
258
|
+
export const touchExpansion = (s: StoreState, id: NodeId): void => {
|
|
259
|
+
pending(s).expansions.add(id);
|
|
260
|
+
};
|
|
261
|
+
|
|
262
|
+
/** This function adds a node to the store. Readers that waited for its ID get rewired. */
|
|
263
|
+
export const insertNode = (s: StoreState, n: MutableNode): void => {
|
|
264
|
+
s.nodes.set(n.id, n);
|
|
265
|
+
touch(s, n.id);
|
|
266
|
+
const waiting = s.dangling.get(n.id);
|
|
267
|
+
if (waiting) {
|
|
268
|
+
for (const r of waiting) touch(s, r);
|
|
269
|
+
s.dangling.delete(n.id);
|
|
270
|
+
}
|
|
271
|
+
};
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* This function expands a node with a settled value. Each field of a plain object becomes an owned
|
|
275
|
+
* slot, and a field that is a plain object becomes a container in turn. A field that holds one of its own
|
|
276
|
+
* ancestors stays a leaf, thus a cyclic object gives a finite tree. The caller flushes.
|
|
277
|
+
* The build uses its own stack, not the call stack, thus a deep value does not overflow it.
|
|
278
|
+
*/
|
|
279
|
+
export const expandNow = (s: StoreState, id: NodeId): void => {
|
|
280
|
+
const n = s.nodes.get(id);
|
|
281
|
+
const value = n && isSome(n.value) ? n.value.value : undefined;
|
|
282
|
+
if (!n || n.slots.size > 0 || !isPlainObject(value)) return;
|
|
283
|
+
type Frame = { readonly owner: MutableNode; readonly value: object; readonly fields: [string, unknown][]; next: number };
|
|
284
|
+
// The objects on the path from the root to the current field
|
|
285
|
+
const ancestors = new Set<object>([value]);
|
|
286
|
+
const frames: Frame[] = [{ owner: n, value, fields: Object.entries(value), next: 0 }];
|
|
287
|
+
for (let frame = frames.at(-1); frame !== undefined; frame = frames.at(-1)) {
|
|
288
|
+
const field = frame.fields[frame.next++];
|
|
289
|
+
if (field === undefined) {
|
|
290
|
+
frames.pop();
|
|
291
|
+
ancestors.delete(frame.value);
|
|
292
|
+
// A container gets its record after its fields, thus the records go from the leaves to the root
|
|
293
|
+
if (frame.owner !== n) touchRecord(s, frame.owner.id);
|
|
294
|
+
continue;
|
|
295
|
+
}
|
|
296
|
+
const [key, fieldValue] = field;
|
|
297
|
+
const sub = makeNode(freshId(s, `${frame.owner.id}.${key}`), lit(fieldValue));
|
|
298
|
+
sub.parent = frame.owner.id;
|
|
299
|
+
sub.value = some(fieldValue);
|
|
300
|
+
insertNode(s, sub);
|
|
301
|
+
linkSlot(frame.owner, key, sub);
|
|
302
|
+
if (isPlainObject(fieldValue) && !ancestors.has(fieldValue)) {
|
|
303
|
+
ancestors.add(fieldValue);
|
|
304
|
+
frames.push({ owner: sub, value: fieldValue, fields: Object.entries(fieldValue), next: 0 });
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
touchRecord(s, n.id);
|
|
308
|
+
for (const r of n.seats) touch(s, r);
|
|
309
|
+
for (const r of n.seatsStructural) touch(s, r);
|
|
310
|
+
};
|
|
311
|
+
|
|
312
|
+
/** This function does the pending work at once, except inside a batch. */
|
|
313
|
+
export const flushUnlessBatched = (s: StoreState): void => {
|
|
314
|
+
if (s.batchDepth === 0) flush(s);
|
|
315
|
+
};
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* This function makes `fn` one transaction: the pending work of all its writes becomes one epoch at the end.
|
|
319
|
+
* When `fn` throws, the flush still does the pending work, and the error of `fn` is the error of the batch.
|
|
320
|
+
*/
|
|
321
|
+
export const runBatch = <T>(s: StoreState, fn: () => T): T => {
|
|
322
|
+
s.batchDepth++;
|
|
323
|
+
let result: T;
|
|
324
|
+
try {
|
|
325
|
+
result = fn();
|
|
326
|
+
} catch (error) {
|
|
327
|
+
s.batchDepth--;
|
|
328
|
+
if (s.batchDepth === 0) {
|
|
329
|
+
try {
|
|
330
|
+
flush(s);
|
|
331
|
+
} catch {
|
|
332
|
+
// The error of fn is the cause. An error of the flush comes after it, thus the batch keeps the first one.
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
throw error;
|
|
336
|
+
}
|
|
337
|
+
s.batchDepth--;
|
|
338
|
+
if (s.batchDepth === 0) flush(s);
|
|
339
|
+
return result;
|
|
340
|
+
};
|
|
341
|
+
|
|
342
|
+
/** The limit of follow-up epochs in one flush. Only a custom op that writes on each epoch reaches it. */
|
|
343
|
+
const MAX_FOLLOW_UPS = 1000;
|
|
344
|
+
|
|
345
|
+
/** The error of an op during an epoch, with the dirty nodes that the epoch did not evaluate. */
|
|
346
|
+
class EpochFailure {
|
|
347
|
+
readonly retry: ReadonlySet<NodeId>;
|
|
348
|
+
readonly cause: unknown;
|
|
349
|
+
constructor(retry: ReadonlySet<NodeId>, cause: unknown) {
|
|
350
|
+
this.retry = retry;
|
|
351
|
+
this.cause = cause;
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* This function does the pending work. A custom op can write during an epoch. That write does not go into
|
|
357
|
+
* the epoch: it becomes pending, and the flush does it in a follow-up epoch. The stats join all epochs.
|
|
358
|
+
*
|
|
359
|
+
* When an op throws, the dirty nodes that the epoch did not evaluate stay pending. The next flush evaluates them,
|
|
360
|
+
* and the error goes to the caller. A throw before the evaluation, for example of a custom `targets` or `flow`
|
|
361
|
+
* op, keeps all the work of the round pending.
|
|
362
|
+
*/
|
|
363
|
+
const flush = (s: StoreState): void => {
|
|
364
|
+
let stats: { evaluated: Set<NodeId>; changed: Set<NodeId>; cyclic: Set<NodeId> } | null = null;
|
|
365
|
+
s.batchDepth++;
|
|
366
|
+
try {
|
|
367
|
+
for (let round = 0; s.pending; round++) {
|
|
368
|
+
if (round > MAX_FOLLOW_UPS) {
|
|
369
|
+
s.pending = null;
|
|
370
|
+
throw new Error(`The epochs did not settle after ${String(MAX_FOLLOW_UPS)} follow-ups: an op writes on each epoch.`);
|
|
371
|
+
}
|
|
372
|
+
const p = s.pending;
|
|
373
|
+
s.pending = null;
|
|
374
|
+
let epoch: { evaluated: Set<NodeId>; changed: Set<NodeId>; cyclic: Set<NodeId> };
|
|
375
|
+
try {
|
|
376
|
+
for (const id of p.records) {
|
|
377
|
+
const n = s.nodes.get(id);
|
|
378
|
+
if (!n) continue;
|
|
379
|
+
if (!isRecordOf(n)) replaceExpr(s, n, recordOf(n));
|
|
380
|
+
p.touched.add(id);
|
|
381
|
+
}
|
|
382
|
+
for (const id of p.touched) {
|
|
383
|
+
const n = s.nodes.get(id);
|
|
384
|
+
if (!n) continue;
|
|
385
|
+
unwireNode(s, n);
|
|
386
|
+
wireNode(s, n);
|
|
387
|
+
}
|
|
388
|
+
epoch = runEpoch(s, p.touched);
|
|
389
|
+
} catch (failure) {
|
|
390
|
+
if (failure instanceof EpochFailure) {
|
|
391
|
+
for (const id of failure.retry) touch(s, id);
|
|
392
|
+
for (const id of p.expansions) touchExpansion(s, id);
|
|
393
|
+
throw failure.cause;
|
|
394
|
+
}
|
|
395
|
+
// The wiring or the closure failed, thus no node evaluated: the full round stays pending
|
|
396
|
+
for (const id of p.records) touchRecord(s, id);
|
|
397
|
+
for (const id of p.touched) touch(s, id);
|
|
398
|
+
for (const id of p.expansions) touchExpansion(s, id);
|
|
399
|
+
throw failure;
|
|
400
|
+
}
|
|
401
|
+
if (!stats) stats = epoch;
|
|
402
|
+
else {
|
|
403
|
+
for (const id of epoch.evaluated) stats.evaluated.add(id);
|
|
404
|
+
for (const id of epoch.changed) stats.changed.add(id);
|
|
405
|
+
for (const id of epoch.cyclic) stats.cyclic.add(id);
|
|
406
|
+
}
|
|
407
|
+
// An expansion inside a batch waits for the settled value. It makes new pending work: a follow-up epoch.
|
|
408
|
+
for (const id of p.expansions) expandNow(s, id);
|
|
409
|
+
}
|
|
410
|
+
} finally {
|
|
411
|
+
s.batchDepth--;
|
|
412
|
+
}
|
|
413
|
+
s.epochStats = {
|
|
414
|
+
evaluated: stats?.evaluated ?? new Set(),
|
|
415
|
+
changed: stats?.changed ?? new Set(),
|
|
416
|
+
cyclic: stats?.cyclic ?? new Set(),
|
|
417
|
+
total: s.nodes.size,
|
|
418
|
+
};
|
|
419
|
+
};
|
|
420
|
+
|
|
421
|
+
// === The epoch ===
|
|
422
|
+
|
|
423
|
+
/** This function makes the resolver that the expressions of a store use: `path[0]` is a node ID. */
|
|
424
|
+
export const resolverOf = (store: NodeStore): Resolver => (path) => {
|
|
425
|
+
const rootId = path[0];
|
|
426
|
+
const root = rootId === undefined ? undefined : store.nodes.get(rootId);
|
|
427
|
+
return root ? store.nodeOps.deref(root, path.length === 1 ? [] : path.slice(1), store) : none;
|
|
428
|
+
};
|
|
429
|
+
|
|
430
|
+
/**
|
|
431
|
+
* The limit of the rounds of a cycle in one epoch. Like the iterative calculation of a spreadsheet, the members
|
|
432
|
+
* of a cycle evaluate again until their values are stable, or until this limit.
|
|
433
|
+
*/
|
|
434
|
+
export const MAX_CYCLE_ROUNDS = 100;
|
|
435
|
+
|
|
436
|
+
/**
|
|
437
|
+
* This function gives the strongly connected components of the closure (Tarjan, without recursion).
|
|
438
|
+
* Tarjan gives each component after all components that it reaches. Thus the reverse list is a topological
|
|
439
|
+
* order of the components, and each node comes after its inputs.
|
|
440
|
+
*/
|
|
441
|
+
const components = (closure: readonly MutableNode[]): MutableNode[][] => {
|
|
442
|
+
const out: MutableNode[][] = [];
|
|
443
|
+
const stack: MutableNode[] = [];
|
|
444
|
+
let counter = 0;
|
|
445
|
+
for (const start of closure) {
|
|
446
|
+
if (start.index !== -1) continue;
|
|
447
|
+
const frames: { readonly node: MutableNode; next: number }[] = [];
|
|
448
|
+
const open = (v: MutableNode): void => {
|
|
449
|
+
v.index = v.low = counter++;
|
|
450
|
+
v.onStack = true;
|
|
451
|
+
stack.push(v);
|
|
452
|
+
frames.push({ node: v, next: 0 });
|
|
453
|
+
};
|
|
454
|
+
open(start);
|
|
455
|
+
while (frames.length > 0) {
|
|
456
|
+
const frame = frames[frames.length - 1]!;
|
|
457
|
+
const v = frame.node;
|
|
458
|
+
const successors = v.next!;
|
|
459
|
+
if (frame.next < successors.length) {
|
|
460
|
+
const w = successors[frame.next++]!;
|
|
461
|
+
if (w.index === -1) open(w);
|
|
462
|
+
else if (w.onStack) v.low = Math.min(v.low, w.index);
|
|
463
|
+
continue;
|
|
464
|
+
}
|
|
465
|
+
frames.pop();
|
|
466
|
+
const parent = frames[frames.length - 1];
|
|
467
|
+
if (parent) parent.node.low = Math.min(parent.node.low, v.low);
|
|
468
|
+
if (v.low === v.index) {
|
|
469
|
+
const component: MutableNode[] = [];
|
|
470
|
+
let w: MutableNode;
|
|
471
|
+
do {
|
|
472
|
+
w = stack.pop()!;
|
|
473
|
+
w.onStack = false;
|
|
474
|
+
component.push(w);
|
|
475
|
+
} while (w !== v);
|
|
476
|
+
out.push(component);
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
return out.toReversed();
|
|
481
|
+
};
|
|
482
|
+
|
|
483
|
+
/**
|
|
484
|
+
* One epoch. The engine finds the closure of the frontier through `flow`, and it orders the strongly connected
|
|
485
|
+
* components of the closure topologically. Then it evaluates each dirty node in that order, thus each node
|
|
486
|
+
* reads settled values. A node is dirty when it is in the frontier or when one of its inputs changed.
|
|
487
|
+
*
|
|
488
|
+
* A node outside a cycle evaluates at most one time. The members of a cycle (a component with more than one
|
|
489
|
+
* node) evaluate again while one of them changes, up to `MAX_CYCLE_ROUNDS` rounds. The stats report them.
|
|
490
|
+
* The machine keeps its state in the scratch fields of the nodes, with the epoch number as a stamp.
|
|
491
|
+
*/
|
|
492
|
+
const runEpoch = (
|
|
493
|
+
s: StoreState,
|
|
494
|
+
frontier: ReadonlySet<NodeId>,
|
|
495
|
+
): { evaluated: Set<NodeId>; changed: Set<NodeId>; cyclic: Set<NodeId> } => {
|
|
496
|
+
const store = publicStore(s);
|
|
497
|
+
const epoch = ++s.epoch;
|
|
498
|
+
|
|
499
|
+
// 1. The closure: the nodes that the frontier can reach. Each node gets its successors one time.
|
|
500
|
+
const closure: MutableNode[] = [];
|
|
501
|
+
const stack: MutableNode[] = [];
|
|
502
|
+
const enter = (n: MutableNode): void => {
|
|
503
|
+
n.mark = epoch;
|
|
504
|
+
n.index = -1;
|
|
505
|
+
n.low = -1;
|
|
506
|
+
n.onStack = false;
|
|
507
|
+
n.next = null;
|
|
508
|
+
stack.push(n);
|
|
509
|
+
};
|
|
510
|
+
for (const id of frontier) {
|
|
511
|
+
const n = s.nodes.get(id);
|
|
512
|
+
if (n && n.mark !== epoch) {
|
|
513
|
+
enter(n);
|
|
514
|
+
n.dirty = epoch;
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
while (stack.length > 0) {
|
|
518
|
+
const n = stack.pop()!;
|
|
519
|
+
closure.push(n);
|
|
520
|
+
const next: MutableNode[] = [];
|
|
521
|
+
for (const id of s.nodeOps.flow(n, store)) {
|
|
522
|
+
const m = s.nodes.get(id);
|
|
523
|
+
if (!m || m === n) continue;
|
|
524
|
+
next.push(m);
|
|
525
|
+
if (m.mark !== epoch) enter(m);
|
|
526
|
+
}
|
|
527
|
+
n.next = next;
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
// 2. The order: the components, with each component after its inputs
|
|
531
|
+
const order = components(closure);
|
|
532
|
+
|
|
533
|
+
// 3. The evaluation of the dirty nodes, in order
|
|
534
|
+
const resolver = resolverOf(store);
|
|
535
|
+
const evaluated = new Set<NodeId>();
|
|
536
|
+
const changed = new Set<NodeId>();
|
|
537
|
+
const cyclic = new Set<NodeId>();
|
|
538
|
+
const evaluateNode = (n: MutableNode): boolean => {
|
|
539
|
+
n.dirty = 0;
|
|
540
|
+
evaluated.add(n.id);
|
|
541
|
+
if (!s.nodeOps.splash(evaluate(n.expr, resolver, s.ops), n, store)) return false;
|
|
542
|
+
changed.add(n.id);
|
|
543
|
+
for (const m of n.next!) m.dirty = epoch;
|
|
544
|
+
return true;
|
|
545
|
+
};
|
|
546
|
+
let position = 0;
|
|
547
|
+
try {
|
|
548
|
+
for (; position < order.length; position++) {
|
|
549
|
+
const component = order[position]!;
|
|
550
|
+
if (component.length === 1) {
|
|
551
|
+
const n = component[0]!;
|
|
552
|
+
if (n.dirty === epoch) evaluateNode(n);
|
|
553
|
+
continue;
|
|
554
|
+
}
|
|
555
|
+
for (const n of component) cyclic.add(n.id);
|
|
556
|
+
for (let round = 0; round < MAX_CYCLE_ROUNDS; round++) {
|
|
557
|
+
let any = false;
|
|
558
|
+
for (const n of component) if (n.dirty === epoch && evaluateNode(n)) any = true;
|
|
559
|
+
if (!any) break;
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
} catch (cause) {
|
|
563
|
+
const retry = new Set<NodeId>();
|
|
564
|
+
for (let i = position; i < order.length; i++) {
|
|
565
|
+
for (const n of order[i]!) if (n.dirty === epoch || i === position) retry.add(n.id);
|
|
566
|
+
}
|
|
567
|
+
for (const n of closure) n.next = null;
|
|
568
|
+
throw new EpochFailure(retry, cause);
|
|
569
|
+
}
|
|
570
|
+
for (const n of closure) n.next = null;
|
|
571
|
+
return { evaluated, changed, cyclic };
|
|
572
|
+
};
|
package/src/equality.ts
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Structural equality for node values.
|
|
3
|
+
*
|
|
4
|
+
* With reference equality only, each expression that makes an object is always dirty: each evaluation
|
|
5
|
+
* gives a new object, and each epoch propagates it. Node values are plain data, thus the default is structural:
|
|
6
|
+
*
|
|
7
|
+
* - `Object.is` for primitives, thus `NaN` is equal to `NaN`.
|
|
8
|
+
* - Element by element for arrays, own key by own key for plain objects.
|
|
9
|
+
* - An exotic object (a class instance, a function, a React element) is equal only to itself.
|
|
10
|
+
* - Below `MAX_DEPTH` levels, two values are not equal. This limit also stops a cyclic value.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const MAX_DEPTH = 32;
|
|
14
|
+
|
|
15
|
+
const hasOwn = (o: object, key: string): boolean => Object.prototype.hasOwnProperty.call(o, key);
|
|
16
|
+
|
|
17
|
+
/** This function tells if a value is a plain object: its prototype is `Object.prototype` or `null`. */
|
|
18
|
+
export const isPlainObject = (v: unknown): v is Record<string, unknown> => {
|
|
19
|
+
if (v === null || typeof v !== "object") return false;
|
|
20
|
+
const proto: unknown = Object.getPrototypeOf(v);
|
|
21
|
+
return proto === Object.prototype || proto === null;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const eq = (a: unknown, b: unknown, depth: number): boolean => {
|
|
25
|
+
if (Object.is(a, b)) return true;
|
|
26
|
+
if (depth >= MAX_DEPTH) return false;
|
|
27
|
+
if (typeof a !== "object" || typeof b !== "object" || a === null || b === null) return false;
|
|
28
|
+
|
|
29
|
+
if (Array.isArray(a) || Array.isArray(b)) {
|
|
30
|
+
if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) return false;
|
|
31
|
+
for (let i = 0; i < a.length; i++) {
|
|
32
|
+
if (!eq(a[i], b[i], depth + 1)) return false;
|
|
33
|
+
}
|
|
34
|
+
return true;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (!isPlainObject(a) || !isPlainObject(b)) return false;
|
|
38
|
+
const keysA = Object.keys(a);
|
|
39
|
+
if (keysA.length !== Object.keys(b).length) return false;
|
|
40
|
+
for (const k of keysA) {
|
|
41
|
+
if (!hasOwn(b, k) || !eq(a[k], b[k], depth + 1)) return false;
|
|
42
|
+
}
|
|
43
|
+
return true;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
/** This function compares two node values structurally. The module comment gives the rules. */
|
|
47
|
+
export const valueEquals = (a: unknown, b: unknown): boolean => eq(a, b, 0);
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export type {
|
|
2
|
+
NodeId, Node, NodeOps, SplashFn, SplashTarget, FlowFn, DerefFn, TargetsFn, ReadTargets, NodeStore, EpochStats, StoreOptions,
|
|
3
|
+
} from "./types.ts";
|
|
4
|
+
export { defaultSplash, defaultFlow, defaultDeref, defaultTargets, defaultOps } from "./ops.ts";
|
|
5
|
+
export {
|
|
6
|
+
type SlotOptions, nodeStore, forkStore, getNode, readValue, batch, addNode, setExpr, setValue, fillMany,
|
|
7
|
+
setSlot, expandNode, removeNode, resolveAll,
|
|
8
|
+
} from "./writes.ts";
|
|
9
|
+
export { MAX_CYCLE_ROUNDS } from "./engine.ts";
|
|
10
|
+
export { readTargets, resolverOf as storeResolver } from "./engine.ts";
|
|
11
|
+
export { toposort } from "./toposort.ts";
|
|
12
|
+
export { valueEquals, isPlainObject } from "./equality.ts";
|