@mark1russell7/render-node 0.0.0-bootstrap.0 → 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/src/ops.ts ADDED
@@ -0,0 +1,74 @@
1
+ import { some, none, isSome } from "@mark1russell7/render-optional";
2
+ import type { DerefFn, FlowFn, Node, NodeOps, SplashFn, TargetsFn } from "./types.ts";
3
+ import { valueEquals } from "./equality.ts";
4
+
5
+ const hasOwn = (o: object, key: string): boolean => Object.prototype.hasOwnProperty.call(o, key);
6
+
7
+ /**
8
+ * The default write. It keeps the old value when the new result is structurally equal, thus an equal
9
+ * object keeps its old reference. A change from a value to `none`, or from `none` to a value, is a change.
10
+ */
11
+ export const defaultSplash: SplashFn = (value, target) => {
12
+ const prev = target.value;
13
+ if (isSome(prev) ? isSome(value) && valueEquals(prev.value, value.value) : !isSome(value)) return false;
14
+ target.value = value;
15
+ return true;
16
+ };
17
+
18
+ /** The default propagation. A change of a node makes its value readers dirty. */
19
+ export const defaultFlow: FlowFn = (target) => target.seats;
20
+
21
+ /**
22
+ * The default resolution. It goes through slots as far as they exist, then through the own fields of the value.
23
+ * A path that ends on a node gives the value of that node. For a container, this value is the record of its slots.
24
+ * A path that stops at a container before its end reads a missing slot, thus it gives `none`.
25
+ */
26
+ export const defaultDeref: DerefFn = (root, path, store) => {
27
+ let current = root;
28
+ let i = 0;
29
+ while (i < path.length) {
30
+ const slotId = current.slots.get(path[i]!);
31
+ if (slotId === undefined) break;
32
+ const next = store.nodes.get(slotId);
33
+ if (!next) break;
34
+ current = next;
35
+ i++;
36
+ }
37
+ if (i < path.length && current.slots.size > 0) return none;
38
+ if (!isSome(current.value)) return none;
39
+ let value: unknown = current.value.value;
40
+ for (; i < path.length; i++) {
41
+ const segment = path[i]!;
42
+ if (value === null || typeof value !== "object" || !hasOwn(value, segment)) return none;
43
+ value = (value as Record<string, unknown>)[segment];
44
+ }
45
+ return some(value);
46
+ };
47
+
48
+ /**
49
+ * The default dependencies of a path, which agree with `defaultDeref`. The nodes that the path goes through are
50
+ * structural targets, and the node where it ends is the terminal. A path that stops at a container before its end
51
+ * reads a missing slot: it has no terminal, and the container is a structural target.
52
+ */
53
+ export const defaultTargets: TargetsFn = (root, path, store) => {
54
+ const through: Node[] = [];
55
+ let current = root;
56
+ let i = 0;
57
+ for (; i < path.length; i++) {
58
+ const slotId = current.slots.get(path[i]!);
59
+ const next = slotId === undefined ? undefined : store.nodes.get(slotId);
60
+ if (!next) break;
61
+ through.push(current);
62
+ current = next;
63
+ }
64
+ if (i < path.length && current.slots.size > 0) return { through: [...through, current], terminal: undefined };
65
+ return { through, terminal: current };
66
+ };
67
+
68
+ /** The default semantics of a store. */
69
+ export const defaultOps: NodeOps = {
70
+ splash: defaultSplash,
71
+ flow: defaultFlow,
72
+ deref: defaultDeref,
73
+ targets: defaultTargets,
74
+ };
@@ -0,0 +1,46 @@
1
+ import type { NodeId, NodeStore } from "./types.ts";
2
+
3
+
4
+ /**
5
+ * This function sorts the nodes of a store by their static dependencies (Kahn's algorithm).
6
+ *
7
+ * The edges come from the `targets` op of the store, like the seat wiring.
8
+ * A reader depends on the terminal of each path and on each node that the path goes through.
9
+ * A path to a node that is not in the store adds no edge.
10
+ *
11
+ * The result has the dependencies before their readers. A node on a cycle of two or more nodes is not in the
12
+ * result. A reference of a node to itself adds no edge. The epochs of the engine use the edges of `flow`
13
+ * instead, thus this function is a tool for analysis.
14
+ */
15
+ export const toposort = (store: NodeStore): NodeId[] => {
16
+ const dependedBy = new Map<NodeId, Set<NodeId>>();
17
+ const inDegree = new Map<NodeId, number>();
18
+ for (const id of store.nodes.keys()) {
19
+ dependedBy.set(id, new Set());
20
+ inDegree.set(id, 0);
21
+ }
22
+ for (const [id, n] of store.nodes) {
23
+ for (const path of n.reads) {
24
+ const root = path[0] === undefined ? undefined : store.nodes.get(path[0]);
25
+ if (!root) continue;
26
+ const { through, terminal } = store.nodeOps.targets(root, path.slice(1), store);
27
+ for (const target of terminal === undefined ? through : [...through, terminal]) {
28
+ const readers = dependedBy.get(target.id)!;
29
+ if (target.id === id || readers.has(id)) continue;
30
+ readers.add(id);
31
+ inDegree.set(id, inDegree.get(id)! + 1);
32
+ }
33
+ }
34
+ }
35
+
36
+ const order: NodeId[] = [];
37
+ for (const [id, d] of inDegree) if (d === 0) order.push(id);
38
+ for (let head = 0; head < order.length; head++) {
39
+ for (const readerId of dependedBy.get(order[head]!)!) {
40
+ const d = inDegree.get(readerId)! - 1;
41
+ inDegree.set(readerId, d);
42
+ if (d === 0) order.push(readerId);
43
+ }
44
+ }
45
+ return order;
46
+ };
package/src/types.ts ADDED
@@ -0,0 +1,108 @@
1
+ import type { DepPath, Expr, Ops } from "@mark1russell7/render-dsl";
2
+ import type { Optional } from "@mark1russell7/render-optional";
3
+
4
+ /** The identity of a node in its store. */
5
+ export type NodeId = string;
6
+
7
+ /**
8
+ * A node is the reactive primitive. It is a reactive variable (an expression and its value),
9
+ * a seat (it knows its readers) and a container of named slots.
10
+ *
11
+ * Only the engine changes a node. The `splash` op writes `value` through its target (`SplashTarget`).
12
+ */
13
+ export type Node = {
14
+ readonly id: NodeId;
15
+ /**
16
+ * The expression is the source of truth. The value is a cache of its evaluation.
17
+ * A write changes the expression, thus a later evaluation cannot revert the write.
18
+ */
19
+ readonly expr: Expr;
20
+ /** The value of the expression after the last epoch. `none` is a value: the expression has no result. */
21
+ readonly value: Optional<unknown>;
22
+ /** The free reference paths of `expr`. */
23
+ readonly reads: readonly DepPath[];
24
+ /** The value readers: the nodes whose read path ends on this node. */
25
+ readonly seats: ReadonlySet<NodeId>;
26
+ /** The structural readers: the nodes whose read path goes through a slot of this node. */
27
+ readonly seatsStructural: ReadonlySet<NodeId>;
28
+ /** The nodes that have this node as a reader. Unwiring uses this index. */
29
+ readonly seatedOn: ReadonlySet<NodeId>;
30
+ /** The container that owns this node as a slot. The removal of the owner also removes this node. */
31
+ readonly parent: NodeId | undefined;
32
+ /** The containers that have a slot to this node: the owner and each container that shares the node. */
33
+ readonly heldBy: ReadonlySet<NodeId>;
34
+ /**
35
+ * The named slots of a container. A node with slots is a container: its expression is a `record`
36
+ * form over its slots, thus its value is the record of their values.
37
+ */
38
+ readonly slots: ReadonlyMap<string, NodeId>;
39
+ };
40
+
41
+ /**
42
+ * The reactive semantics of a store. The engine uses these three ops for each write, each epoch and
43
+ * each reference. Biblo layers class methods on top of them (`classNodeOps`).
44
+ */
45
+ export type NodeOps = {
46
+ /** This op writes a result to a node. It gives `true` when the value changed. */
47
+ readonly splash: SplashFn;
48
+ /** This op gives the nodes that a change of the target makes dirty. */
49
+ readonly flow: FlowFn;
50
+ /** This op resolves a path from a root node. It goes through slots first, then through the fields of the value. */
51
+ readonly deref: DerefFn;
52
+ /**
53
+ * This op gives the nodes that a path from a root node depends on. The engine seats the reader on them.
54
+ * A custom `deref` gives a matching `targets`, thus the wiring always agrees with the resolution.
55
+ */
56
+ readonly targets: TargetsFn;
57
+ };
58
+
59
+ /** The target of the write op: a node whose `value` the op can write. */
60
+ export type SplashTarget = Omit<Node, "value"> & { value: Optional<unknown> };
61
+
62
+ /** The write op. It gives `true` when the value of the target changed. */
63
+ export type SplashFn = (value: Optional<unknown>, target: SplashTarget, store: NodeStore) => boolean;
64
+
65
+ /** The propagation op. It gives the nodes that a change of the target makes dirty. */
66
+ export type FlowFn = (target: Node, store: NodeStore) => ReadonlySet<NodeId>;
67
+
68
+ /** The resolution op. It gives the value at a path from a root node. */
69
+ export type DerefFn = (root: Node, path: readonly string[], store: NodeStore) => Optional<unknown>;
70
+
71
+ /**
72
+ * The nodes that a read path depends on. A change of the value of `terminal` makes the reader dirty.
73
+ * A change of the slots of a node in `through` makes the reader resolve its path again.
74
+ * A path with no terminal reads no value, for example a missing slot of a container.
75
+ */
76
+ export type ReadTargets = { readonly through: readonly Node[]; readonly terminal: Node | undefined };
77
+
78
+ /** The dependency op: the nodes that a path from a root node depends on. It agrees with `deref`. */
79
+ export type TargetsFn = (root: Node, path: readonly string[], store: NodeStore) => ReadTargets;
80
+
81
+ /** The record of one epoch. */
82
+ export type EpochStats = {
83
+ /** The nodes that the epoch evaluated. */
84
+ readonly evaluated: ReadonlySet<NodeId>;
85
+ /** The nodes whose value changed in the epoch. */
86
+ readonly changed: ReadonlySet<NodeId>;
87
+ /** The nodes on a dependency cycle. A cycle evaluates again until its values are stable, up to `MAX_CYCLE_ROUNDS` rounds. */
88
+ readonly cyclic: ReadonlySet<NodeId>;
89
+ /** The number of nodes in the store after the epoch. */
90
+ readonly total: number;
91
+ };
92
+
93
+ /** A store of nodes, with the semantics of its reactivity and the op registry of its expressions. */
94
+ export type NodeStore = {
95
+ readonly nodes: ReadonlyMap<NodeId, Node>;
96
+ readonly nodeOps: NodeOps;
97
+ readonly ops: Ops;
98
+ /** The record of the last epoch, or `null` before the first epoch. */
99
+ readonly epochStats: EpochStats | null;
100
+ };
101
+
102
+ /** The options of `nodeStore`. */
103
+ export type StoreOptions = {
104
+ /** The reactive semantics. The default is `defaultOps`. */
105
+ readonly nodeOps?: NodeOps | undefined;
106
+ /** The op registry of the expressions. The default is an empty registry. */
107
+ readonly ops?: Ops | undefined;
108
+ };
package/src/writes.ts ADDED
@@ -0,0 +1,266 @@
1
+ /**
2
+ * The public operations of a store. Each operation keeps the store consistent. At the end of the operation, or after
3
+ * the end of the enclosing batch, the value of each node agrees with its expression.
4
+ * The seats agree with the reads too. `store.epochStats` records the epoch of the last operation.
5
+ */
6
+ import type { Expr } from "@mark1russell7/render-dsl";
7
+ import { lit } from "@mark1russell7/render-dsl";
8
+ import type { Optional } from "@mark1russell7/render-optional";
9
+ import { none } from "@mark1russell7/render-optional";
10
+ import type { Node, NodeId, NodeStore, StoreOptions } from "./types.ts";
11
+ import type { MutableNode, StoreState } from "./engine.ts";
12
+ import {
13
+ expandNow, flushUnlessBatched, freshId, insertNode, linkSlot, makeNode, makeState, publicStore, replaceExpr,
14
+ runBatch, state, touch, touchExpansion, touchRecord, unlinkSlot, unwireNode,
15
+ } from "./engine.ts";
16
+ import { defaultOps } from "./ops.ts";
17
+
18
+ /** This function makes an empty store. The options give its reactive semantics and its op registry. */
19
+ export const nodeStore = (options: StoreOptions = {}): NodeStore =>
20
+ publicStore(makeState(options.nodeOps ?? defaultOps, options.ops ?? {}));
21
+
22
+ /**
23
+ * This function gives an independent copy of a store: the same nodes, values, slots and seats, and the same
24
+ * counters. A write to one store does not change the other. The expressions and the values are immutable,
25
+ * thus the copy shares them. The options replace the semantics or the op registry of the copy, for example the
26
+ * class semantics of a copied biblo. The function throws inside a batch, because a batch has pending work.
27
+ */
28
+ export const forkStore = (store: NodeStore, options: StoreOptions = {}): NodeStore => {
29
+ const s = state(store);
30
+ if (s.pending !== null || s.batchDepth > 0) throw new Error("forkStore: the store is in a batch");
31
+ const copy: StoreState = {
32
+ nodes: new Map(),
33
+ nodeOps: options.nodeOps ?? s.nodeOps,
34
+ ops: options.ops ?? s.ops,
35
+ epochStats: s.epochStats,
36
+ nextId: s.nextId,
37
+ dangling: new Map([...s.dangling].map(([id, readers]) => [id, new Set(readers)])),
38
+ pending: null,
39
+ batchDepth: 0,
40
+ epoch: s.epoch,
41
+ };
42
+ for (const [id, n] of s.nodes) {
43
+ const node: MutableNode = {
44
+ ...n,
45
+ seats: new Set(n.seats),
46
+ seatsStructural: new Set(n.seatsStructural),
47
+ seatedOn: new Set(n.seatedOn),
48
+ heldBy: new Set(n.heldBy),
49
+ slots: new Map(n.slots),
50
+ // The scratch fields of the epoch machine start again: no epoch is in progress
51
+ mark: 0,
52
+ dirty: 0,
53
+ index: -1,
54
+ low: -1,
55
+ onStack: false,
56
+ next: null,
57
+ };
58
+ copy.nodes.set(id, node);
59
+ }
60
+ return publicStore(copy);
61
+ };
62
+
63
+ /** This function gives a node of the store, or `undefined`. */
64
+ export const getNode = (store: NodeStore, id: NodeId): Node | undefined => store.nodes.get(id);
65
+
66
+ /** This function gives the value of a node, or `none` when the store has no such node. */
67
+ export const readValue = (store: NodeStore, id: NodeId): Optional<unknown> => store.nodes.get(id)?.value ?? none;
68
+
69
+ /**
70
+ * This function makes `fn` one transaction. The writes in `fn` change the structure at once, and one epoch
71
+ * at the end evaluates all their effects. A batch inside a batch joins the outer batch.
72
+ */
73
+ export const batch = <T>(store: NodeStore, fn: () => T): T => runBatch(state(store), fn);
74
+
75
+ /**
76
+ * This function adds a node with an expression and evaluates it. It gives the ID of the node.
77
+ * The ID is the hint when no node has it, otherwise a fresh ID. A reader that waited for this ID gets rewired.
78
+ */
79
+ export const addNode = (store: NodeStore, expr: Expr, idHint?: string): NodeId => {
80
+ const s = state(store);
81
+ const n = makeNode(freshId(s, idHint), expr);
82
+ insertNode(s, n);
83
+ flushUnlessBatched(s);
84
+ return n.id;
85
+ };
86
+
87
+ /**
88
+ * This function sets the expression of a node. This is the fundamental write.
89
+ * The engine rewires the reads of the node, evaluates it and propagates the change.
90
+ * A write to a container collapses it first: its own slots are removed, and the expression replaces the record.
91
+ */
92
+ export const setExpr = (store: NodeStore, id: NodeId, expr: Expr): void => {
93
+ const s = state(store);
94
+ const n = s.nodes.get(id);
95
+ if (n) {
96
+ if (n.slots.size > 0) collapse(s, n);
97
+ replaceExpr(s, n, expr);
98
+ touch(s, id);
99
+ }
100
+ flushUnlessBatched(s);
101
+ };
102
+
103
+ /**
104
+ * This function writes a literal value: it is `setExpr` with `lit(value)`.
105
+ * A write to a derived node makes it an input node, like a value that a person types over a formula.
106
+ */
107
+ export const setValue = (store: NodeStore, id: NodeId, value: unknown): void => {
108
+ setExpr(store, id, lit(value));
109
+ };
110
+
111
+ /** This function writes many literal values in one batch, thus in one epoch. */
112
+ export const fillMany = (store: NodeStore, writes: Iterable<readonly [NodeId, unknown]>): void => {
113
+ batch(store, () => {
114
+ for (const [id, value] of writes) setValue(store, id, value);
115
+ });
116
+ };
117
+
118
+ /** The options of `setSlot`. */
119
+ export type SlotOptions = {
120
+ /**
121
+ * With `own`, the container becomes the owner of the child when the child has no owner. The removal of the
122
+ * owner removes the child, and so does a slot change that drops the child. Without `own`, the child is shared.
123
+ */
124
+ readonly own?: boolean;
125
+ };
126
+
127
+ /**
128
+ * This function points a slot of a container at a node, or removes the slot when `childId` is `undefined`.
129
+ * This is the structural write: the container gets a new record expression, and each reader whose path goes
130
+ * through the container resolves its path again. A node that the container owns and drops is removed.
131
+ * The function does nothing when a node is missing or when the child is the container itself.
132
+ */
133
+ export const setSlot = (
134
+ store: NodeStore,
135
+ parentId: NodeId,
136
+ name: string,
137
+ childId: NodeId | undefined,
138
+ options: SlotOptions = {},
139
+ ): void => {
140
+ const s = state(store);
141
+ const parent = s.nodes.get(parentId);
142
+ const child = childId === undefined ? undefined : s.nodes.get(childId);
143
+ if (parent && (childId === undefined || (child && childId !== parentId)) && parent.slots.get(name) !== childId) {
144
+ const previous = parent.slots.get(name);
145
+ if (previous !== undefined) unlinkSlot(s, parent, name);
146
+ if (child) {
147
+ linkSlot(parent, name, child);
148
+ if (options.own === true && (child.parent === undefined || !s.nodes.has(child.parent))) child.parent = parent.id;
149
+ }
150
+ if (previous !== undefined) dropIfOwned(s, parent, previous);
151
+ touchRecord(s, parent.id);
152
+ for (const r of parent.seatsStructural) touch(s, r);
153
+ for (const r of parent.seats) touch(s, r);
154
+ }
155
+ flushUnlessBatched(s);
156
+ };
157
+
158
+ /**
159
+ * This function expands a node whose value is a plain object. Each field becomes a slot, and
160
+ * a field that is a plain object becomes a container in turn. The node becomes a container, and its slots
161
+ * are from then on the source of truth of its fields. A reader of a field gets a seat on the field node,
162
+ * thus an edit of one field makes only its own readers dirty. An expansion of a derived node keeps the current fields.
163
+ *
164
+ * Inside a batch, the expansion waits for the epoch of the batch, thus it uses the settled value.
165
+ * The function does nothing for a container or for a value that is not a plain object.
166
+ */
167
+ export const expandNode = (store: NodeStore, id: NodeId): void => {
168
+ const s = state(store);
169
+ if (s.batchDepth > 0) touchExpansion(s, id);
170
+ else expandNow(s, id);
171
+ flushUnlessBatched(s);
172
+ };
173
+
174
+ /**
175
+ * This function removes a node and the slots that it owns. The containers that hold it lose the slot.
176
+ * Its readers resolve their paths again: a path to a removed node gives `none`. If a node with the same ID
177
+ * comes back later, the readers find it.
178
+ */
179
+ export const removeNode = (store: NodeStore, id: NodeId): void => {
180
+ const s = state(store);
181
+ const n = s.nodes.get(id);
182
+ if (n) removeOwned(s, n);
183
+ flushUnlessBatched(s);
184
+ };
185
+
186
+ /**
187
+ * This function evaluates the full store again. It rewires each node, makes each record expression again,
188
+ * and starts one epoch in which each node evaluates one time in topological order. The operations keep the
189
+ * store consistent, thus this function is a repair tool. It does not revert a write.
190
+ */
191
+ export const resolveAll = (store: NodeStore): void => {
192
+ const s = state(store);
193
+ for (const n of s.nodes.values()) {
194
+ touch(s, n.id);
195
+ if (n.slots.size > 0) touchRecord(s, n.id);
196
+ }
197
+ flushUnlessBatched(s);
198
+ };
199
+
200
+ // === Internal ===
201
+
202
+ /** A node that the container owns and no longer holds is removed: it was a part of the container. */
203
+ const dropIfOwned = (s: StoreState, container: MutableNode, childId: NodeId): void => {
204
+ const former = s.nodes.get(childId);
205
+ if (!former || former.parent !== container.id) return;
206
+ for (const target of container.slots.values()) if (target === childId) return;
207
+ removeOwned(s, former);
208
+ };
209
+
210
+ /** A container gives up its slots: the owned slot nodes are removed, and the shared ones are only released. */
211
+ const collapse = (s: StoreState, n: MutableNode): void => {
212
+ const slots = [...n.slots.values()];
213
+ for (const name of Array.from(n.slots.keys())) unlinkSlot(s, n, name);
214
+ for (const childId of slots) {
215
+ const child = s.nodes.get(childId);
216
+ if (child && child.parent === n.id) removeOwned(s, child);
217
+ }
218
+ for (const r of n.seatsStructural) touch(s, r);
219
+ };
220
+
221
+ /** This function removes a node and its owned slot subtree, and touches each reader that stays. */
222
+ const removeOwned = (s: StoreState, root: MutableNode): void => {
223
+ const doomed: MutableNode[] = [];
224
+ const doomedIds = new Set<NodeId>();
225
+ // The owned subtree, with a stack of its own: a deep tree does not overflow the call stack
226
+ for (const todo = [root]; todo.length > 0;) {
227
+ const n = todo.pop();
228
+ if (n === undefined || doomedIds.has(n.id)) continue;
229
+ doomedIds.add(n.id);
230
+ doomed.push(n);
231
+ for (const childId of n.slots.values()) {
232
+ const child = s.nodes.get(childId);
233
+ if (child && child.parent === n.id) todo.push(child);
234
+ }
235
+ }
236
+
237
+ // The owner and each container that shares a removed node lose the slot
238
+ for (const n of doomed) {
239
+ for (const r of n.seatsStructural) touch(s, r);
240
+ for (const r of n.seats) touch(s, r);
241
+ for (const holderId of Array.from(n.heldBy)) {
242
+ const holder = doomedIds.has(holderId) ? undefined : s.nodes.get(holderId);
243
+ if (!holder) continue;
244
+ for (const [name, target] of Array.from(holder.slots)) {
245
+ if (target === n.id) unlinkSlot(s, holder, name);
246
+ }
247
+ touchRecord(s, holder.id);
248
+ for (const r of holder.seatsStructural) touch(s, r);
249
+ }
250
+ }
251
+ // A removed container no longer holds the nodes that it shares
252
+ for (const n of doomed) {
253
+ for (const target of n.slots.values()) if (!doomedIds.has(target)) s.nodes.get(target)?.heldBy.delete(n.id);
254
+ }
255
+
256
+ for (const n of doomed) {
257
+ for (const readerId of n.seats) s.nodes.get(readerId)?.seatedOn.delete(n.id);
258
+ for (const readerId of n.seatsStructural) s.nodes.get(readerId)?.seatedOn.delete(n.id);
259
+ n.seats.clear();
260
+ n.seatsStructural.clear();
261
+ }
262
+ for (const n of doomed) {
263
+ unwireNode(s, n);
264
+ s.nodes.delete(n.id);
265
+ }
266
+ };