@smthrs/plan 0.0.0-stage → 1.0.0-rc.4

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 (199) hide show
  1. package/CHANGELOG.md +183 -0
  2. package/LICENSE +21 -0
  3. package/README.md +228 -2
  4. package/dist/cjs/CachePolicy.d.ts +131 -0
  5. package/dist/cjs/CachePolicy.d.ts.map +1 -0
  6. package/dist/cjs/CachePolicy.js +50 -0
  7. package/dist/cjs/CachePolicy.js.map +7 -0
  8. package/dist/cjs/Effects.d.ts +240 -0
  9. package/dist/cjs/Effects.d.ts.map +1 -0
  10. package/dist/cjs/Effects.js +76 -0
  11. package/dist/cjs/Effects.js.map +7 -0
  12. package/dist/cjs/FileSet.d.ts +288 -0
  13. package/dist/cjs/FileSet.d.ts.map +1 -0
  14. package/dist/cjs/FileSet.js +191 -0
  15. package/dist/cjs/FileSet.js.map +7 -0
  16. package/dist/cjs/GraphBuildError.d.ts +122 -0
  17. package/dist/cjs/GraphBuildError.d.ts.map +1 -0
  18. package/dist/cjs/GraphBuildError.js +71 -0
  19. package/dist/cjs/GraphBuildError.js.map +7 -0
  20. package/dist/cjs/KeyMaterial.d.ts +102 -0
  21. package/dist/cjs/KeyMaterial.d.ts.map +1 -0
  22. package/dist/cjs/KeyMaterial.js +64 -0
  23. package/dist/cjs/KeyMaterial.js.map +7 -0
  24. package/dist/cjs/Node.d.ts +606 -0
  25. package/dist/cjs/Node.d.ts.map +1 -0
  26. package/dist/cjs/Node.js +207 -0
  27. package/dist/cjs/Node.js.map +7 -0
  28. package/dist/cjs/Placement.d.ts +117 -0
  29. package/dist/cjs/Placement.d.ts.map +1 -0
  30. package/dist/cjs/Placement.js +46 -0
  31. package/dist/cjs/Placement.js.map +7 -0
  32. package/dist/cjs/Plan.d.ts +440 -0
  33. package/dist/cjs/Plan.d.ts.map +1 -0
  34. package/dist/cjs/Plan.js +508 -0
  35. package/dist/cjs/Plan.js.map +7 -0
  36. package/dist/cjs/PlanDiff.d.ts +58 -0
  37. package/dist/cjs/PlanDiff.d.ts.map +1 -0
  38. package/dist/cjs/PlanDiff.js +124 -0
  39. package/dist/cjs/PlanDiff.js.map +7 -0
  40. package/dist/cjs/Planned.d.ts +121 -0
  41. package/dist/cjs/Planned.d.ts.map +1 -0
  42. package/dist/cjs/Planned.js +72 -0
  43. package/dist/cjs/Planned.js.map +7 -0
  44. package/dist/cjs/Repetition.d.ts +31 -0
  45. package/dist/cjs/Repetition.d.ts.map +1 -0
  46. package/dist/cjs/Repetition.js +36 -0
  47. package/dist/cjs/Repetition.js.map +7 -0
  48. package/dist/cjs/Scheduling.d.ts +89 -0
  49. package/dist/cjs/Scheduling.d.ts.map +1 -0
  50. package/dist/cjs/Scheduling.js +76 -0
  51. package/dist/cjs/Scheduling.js.map +7 -0
  52. package/dist/cjs/StepKey.d.ts +333 -0
  53. package/dist/cjs/StepKey.d.ts.map +1 -0
  54. package/dist/cjs/StepKey.js +311 -0
  55. package/dist/cjs/StepKey.js.map +7 -0
  56. package/dist/cjs/index.d.ts +91 -0
  57. package/dist/cjs/index.d.ts.map +1 -0
  58. package/dist/cjs/index.js +59 -0
  59. package/dist/cjs/index.js.map +7 -0
  60. package/dist/cjs/internal/ConflictAnnotation.d.ts +59 -0
  61. package/dist/cjs/internal/ConflictAnnotation.d.ts.map +1 -0
  62. package/dist/cjs/internal/ConflictAnnotation.js +247 -0
  63. package/dist/cjs/internal/ConflictAnnotation.js.map +7 -0
  64. package/dist/cjs/internal/EffectCandidates.d.ts +19 -0
  65. package/dist/cjs/internal/EffectCandidates.d.ts.map +1 -0
  66. package/dist/cjs/internal/EffectCandidates.js +92 -0
  67. package/dist/cjs/internal/EffectCandidates.js.map +7 -0
  68. package/dist/cjs/internal/JsonMirror.d.ts +34 -0
  69. package/dist/cjs/internal/JsonMirror.d.ts.map +1 -0
  70. package/dist/cjs/internal/JsonMirror.js +195 -0
  71. package/dist/cjs/internal/JsonMirror.js.map +7 -0
  72. package/dist/cjs/internal/effects.d.ts +245 -0
  73. package/dist/cjs/internal/effects.d.ts.map +1 -0
  74. package/dist/cjs/internal/effects.js +290 -0
  75. package/dist/cjs/internal/effects.js.map +7 -0
  76. package/dist/cjs/internal/node.d.ts +526 -0
  77. package/dist/cjs/internal/node.d.ts.map +1 -0
  78. package/dist/cjs/internal/node.js +312 -0
  79. package/dist/cjs/internal/node.js.map +7 -0
  80. package/dist/cjs/package.json +1 -0
  81. package/dist/cjs/test/PlanFixtures.d.ts +127 -0
  82. package/dist/cjs/test/PlanFixtures.d.ts.map +1 -0
  83. package/dist/cjs/test/PlanFixtures.js +59 -0
  84. package/dist/cjs/test/PlanFixtures.js.map +7 -0
  85. package/dist/esm/CachePolicy.d.ts +131 -0
  86. package/dist/esm/CachePolicy.d.ts.map +1 -0
  87. package/dist/esm/CachePolicy.js +117 -0
  88. package/dist/esm/CachePolicy.js.map +1 -0
  89. package/dist/esm/Effects.d.ts +240 -0
  90. package/dist/esm/Effects.d.ts.map +1 -0
  91. package/dist/esm/Effects.js +167 -0
  92. package/dist/esm/Effects.js.map +1 -0
  93. package/dist/esm/FileSet.d.ts +288 -0
  94. package/dist/esm/FileSet.d.ts.map +1 -0
  95. package/dist/esm/FileSet.js +324 -0
  96. package/dist/esm/FileSet.js.map +1 -0
  97. package/dist/esm/GraphBuildError.d.ts +122 -0
  98. package/dist/esm/GraphBuildError.d.ts.map +1 -0
  99. package/dist/esm/GraphBuildError.js +146 -0
  100. package/dist/esm/GraphBuildError.js.map +1 -0
  101. package/dist/esm/KeyMaterial.d.ts +102 -0
  102. package/dist/esm/KeyMaterial.d.ts.map +1 -0
  103. package/dist/esm/KeyMaterial.js +85 -0
  104. package/dist/esm/KeyMaterial.js.map +1 -0
  105. package/dist/esm/Node.d.ts +606 -0
  106. package/dist/esm/Node.d.ts.map +1 -0
  107. package/dist/esm/Node.js +521 -0
  108. package/dist/esm/Node.js.map +1 -0
  109. package/dist/esm/Placement.d.ts +117 -0
  110. package/dist/esm/Placement.d.ts.map +1 -0
  111. package/dist/esm/Placement.js +65 -0
  112. package/dist/esm/Placement.js.map +1 -0
  113. package/dist/esm/Plan.d.ts +440 -0
  114. package/dist/esm/Plan.d.ts.map +1 -0
  115. package/dist/esm/Plan.js +673 -0
  116. package/dist/esm/Plan.js.map +1 -0
  117. package/dist/esm/PlanDiff.d.ts +58 -0
  118. package/dist/esm/PlanDiff.d.ts.map +1 -0
  119. package/dist/esm/PlanDiff.js +159 -0
  120. package/dist/esm/PlanDiff.js.map +1 -0
  121. package/dist/esm/Planned.d.ts +121 -0
  122. package/dist/esm/Planned.d.ts.map +1 -0
  123. package/dist/esm/Planned.js +147 -0
  124. package/dist/esm/Planned.js.map +1 -0
  125. package/dist/esm/Repetition.d.ts +31 -0
  126. package/dist/esm/Repetition.d.ts.map +1 -0
  127. package/dist/esm/Repetition.js +24 -0
  128. package/dist/esm/Repetition.js.map +1 -0
  129. package/dist/esm/Scheduling.d.ts +89 -0
  130. package/dist/esm/Scheduling.d.ts.map +1 -0
  131. package/dist/esm/Scheduling.js +82 -0
  132. package/dist/esm/Scheduling.js.map +1 -0
  133. package/dist/esm/StepKey.d.ts +333 -0
  134. package/dist/esm/StepKey.d.ts.map +1 -0
  135. package/dist/esm/StepKey.js +478 -0
  136. package/dist/esm/StepKey.js.map +1 -0
  137. package/dist/esm/index.d.ts +91 -0
  138. package/dist/esm/index.d.ts.map +1 -0
  139. package/dist/esm/index.js +91 -0
  140. package/dist/esm/index.js.map +1 -0
  141. package/dist/esm/internal/ConflictAnnotation.d.ts +59 -0
  142. package/dist/esm/internal/ConflictAnnotation.d.ts.map +1 -0
  143. package/dist/esm/internal/ConflictAnnotation.js +332 -0
  144. package/dist/esm/internal/ConflictAnnotation.js.map +1 -0
  145. package/dist/esm/internal/EffectCandidates.d.ts +19 -0
  146. package/dist/esm/internal/EffectCandidates.d.ts.map +1 -0
  147. package/dist/esm/internal/EffectCandidates.js +86 -0
  148. package/dist/esm/internal/EffectCandidates.js.map +1 -0
  149. package/dist/esm/internal/JsonMirror.d.ts +34 -0
  150. package/dist/esm/internal/JsonMirror.d.ts.map +1 -0
  151. package/dist/esm/internal/JsonMirror.js +204 -0
  152. package/dist/esm/internal/JsonMirror.js.map +1 -0
  153. package/dist/esm/internal/effects.d.ts +245 -0
  154. package/dist/esm/internal/effects.d.ts.map +1 -0
  155. package/dist/esm/internal/effects.js +459 -0
  156. package/dist/esm/internal/effects.js.map +1 -0
  157. package/dist/esm/internal/node.d.ts +526 -0
  158. package/dist/esm/internal/node.d.ts.map +1 -0
  159. package/dist/esm/internal/node.js +564 -0
  160. package/dist/esm/internal/node.js.map +1 -0
  161. package/dist/esm/test/PlanFixtures.d.ts +127 -0
  162. package/dist/esm/test/PlanFixtures.d.ts.map +1 -0
  163. package/dist/esm/test/PlanFixtures.js +60 -0
  164. package/dist/esm/test/PlanFixtures.js.map +1 -0
  165. package/docs/README.md +165 -0
  166. package/docs/api.md +1475 -0
  167. package/docs/concepts/authoring-ast.md +203 -0
  168. package/docs/concepts/effects-and-conflicts.md +135 -0
  169. package/docs/concepts/plan-value.md +168 -0
  170. package/docs/concepts/step-keys.md +179 -0
  171. package/docs/guides/author-a-node-graph.md +182 -0
  172. package/docs/guides/compile-a-plan.md +146 -0
  173. package/docs/guides/declare-file-effects.md +143 -0
  174. package/docs/guides/diff-two-plans.md +101 -0
  175. package/docs/installation.md +71 -0
  176. package/docs/quickstart.md +128 -0
  177. package/docs/testing.md +73 -0
  178. package/docs/troubleshooting.md +314 -0
  179. package/package.json +254 -3
  180. package/src/CachePolicy.ts +143 -0
  181. package/src/Effects.ts +284 -0
  182. package/src/FileSet.ts +411 -0
  183. package/src/GraphBuildError.ts +158 -0
  184. package/src/KeyMaterial.ts +106 -0
  185. package/src/Node.ts +871 -0
  186. package/src/Placement.ts +99 -0
  187. package/src/Plan.ts +868 -0
  188. package/src/PlanDiff.ts +199 -0
  189. package/src/Planned.ts +208 -0
  190. package/src/Repetition.ts +33 -0
  191. package/src/Scheduling.ts +154 -0
  192. package/src/StepKey.ts +710 -0
  193. package/src/index.ts +103 -0
  194. package/src/internal/ConflictAnnotation.ts +374 -0
  195. package/src/internal/EffectCandidates.ts +89 -0
  196. package/src/internal/JsonMirror.ts +255 -0
  197. package/src/internal/effects.ts +543 -0
  198. package/src/internal/node.ts +858 -0
  199. package/src/test/PlanFixtures.ts +76 -0
@@ -0,0 +1,858 @@
1
+ /**
2
+ * The node AST, and the side tables the functions hang off.
3
+ *
4
+ * Two invariants shape every declaration here. The AST is **closure-free**
5
+ * and keeps payloads as inert JSON mirrors, so a valid plan can be shipped,
6
+ * stored, diffed, and rendered without the process that built it; control flow is
7
+ * **structure**, so a branch stores both arms as ASTs rather than a function
8
+ * that would have to be run to find out what comes next.
9
+ *
10
+ * The functions an author does write — a mapper, a continuation, a branch
11
+ * predicate — live in `WeakMap`s keyed by the AST node they belong to, and the
12
+ * AST keeps only a {@link FunctionIdentity} digest of their source. The digest
13
+ * is what enters content identity (https://smithers.sh/docs/concepts/content-addressing); the
14
+ * `WeakMap` is what the run reaches for when it has the real value in hand, and
15
+ * it drops with the AST it is keyed by.
16
+ *
17
+ * Adapted from `@smthrs/core` (`packages/smithers/flows/core`)
18
+ * `internal/node.ts`. `Dynamic` does not come across — a model call is an
19
+ * ordinary action here — and neither do annotations, which are `Context`
20
+ * values and would break serializability. Scheduling priority is the one thing that does cross, as
21
+ * the plain JSON field {@link Scheduled.priority} rather than as an
22
+ * annotation, because the scheduler has to read it out of a stored plan.
23
+ *
24
+ * @since 0.1.0
25
+ */
26
+
27
+ import { isRecord } from "@smthrs/canonical/Record"
28
+ import { digestSync } from "@smthrs/crypto"
29
+ import * as Identity from "@smthrs/crypto/Identity"
30
+ import { identity } from "effect/Function"
31
+ import { pipeArguments } from "effect/Pipeable"
32
+ import * as Schema from "effect/Schema"
33
+ import type { Node } from "../Node.ts"
34
+ import type * as Planned from "../Planned.ts"
35
+ import { jsonMirror } from "./JsonMirror.ts"
36
+
37
+ /**
38
+ * The runtime type identifier every node carries.
39
+ *
40
+ * @since 0.1.0
41
+ * @private
42
+ * @slop
43
+ */
44
+ export const TypeId = "~@smthrs/plan/Node" as const
45
+
46
+ /**
47
+ * The type-level form of {@link TypeId}.
48
+ *
49
+ * @since 0.1.0
50
+ * @private
51
+ * @slop
52
+ */
53
+ export type TypeId = typeof TypeId
54
+
55
+ /**
56
+ * What every AST variant carries besides its own shape: an optional scheduling
57
+ * priority.
58
+ *
59
+ * A priority is JSON, not a `Context` annotation, which is why it can live in
60
+ * the AST at all: the whole point of dropping core's annotations was to keep a
61
+ * plan shippable, storable, and diffable. It is a scheduling HINT — the plan
62
+ * compiler copies it onto the node draft and the scheduler orders ready work by
63
+ * it, while key material never reads it, so raising a priority reorders work
64
+ * without re-keying a single step.
65
+ *
66
+ * @since 0.1.0
67
+ * @private
68
+ * @slop
69
+ */
70
+ export interface Scheduled {
71
+ readonly priority?: number | undefined
72
+ }
73
+
74
+ /**
75
+ * A constant, known before anything runs.
76
+ *
77
+ * @since 0.1.0
78
+ * @private
79
+ * @slop
80
+ */
81
+ export interface Succeed extends Scheduled {
82
+ readonly _tag: "Succeed"
83
+ readonly value: unknown
84
+ }
85
+
86
+ /**
87
+ * A constant typed failure, known before anything runs.
88
+ *
89
+ * The error is an inert JSON mirror for the same reason {@link Succeed.value}
90
+ * is: a stored plan has to carry what the node produces without the process
91
+ * that built it.
92
+ *
93
+ * @since 1.0.0
94
+ * @private
95
+ * @slop
96
+ */
97
+ export interface Fail extends Scheduled {
98
+ readonly _tag: "Fail"
99
+ readonly error: unknown
100
+ }
101
+
102
+ /**
103
+ * Independent children, combined by name. Width is fixed here, at plan time:
104
+ * there is no dynamic fan-out.
105
+ *
106
+ * @since 0.1.0
107
+ * @private
108
+ * @slop
109
+ */
110
+ export interface All extends Scheduled {
111
+ readonly _tag: "All"
112
+ readonly nodes: Readonly<Record<string, NodeAst>>
113
+ }
114
+
115
+ /**
116
+ * How a {@link Race} settles: `race` on the first member to settle, `any` on
117
+ * the first member to succeed, and `quorum` once `count` members succeed.
118
+ *
119
+ * @since 1.0.0
120
+ * @private
121
+ */
122
+ export type RaceMode = "race" | "any" | "quorum"
123
+
124
+ /**
125
+ * Independent children that settle early: the join decides as soon as its
126
+ * {@link RaceMode} can, and the members it did not wait for are interrupted.
127
+ * `count` is the successes a quorum needs, and 1 for `race` and `any`.
128
+ *
129
+ * @since 1.0.0
130
+ * @private
131
+ */
132
+ export interface Race extends Scheduled {
133
+ readonly _tag: "Race"
134
+ readonly mode: RaceMode
135
+ readonly count: number
136
+ readonly nodes: Readonly<Record<string, NodeAst>>
137
+ }
138
+
139
+ /**
140
+ * A deferred pure transformation of an upstream result. `map` transforms; it
141
+ * never decides.
142
+ *
143
+ * @since 0.1.0
144
+ * @private
145
+ * @slop
146
+ */
147
+ export interface Map extends Scheduled {
148
+ readonly _tag: "Map"
149
+ readonly first: NodeAst
150
+ readonly mapper: FunctionIdentity
151
+ }
152
+
153
+ /**
154
+ * A continuation after an upstream node. `next` is present when the author
155
+ * supplied a node directly instead of a builder, in which case the topology is
156
+ * already known and nothing has to be evaluated to reveal it.
157
+ *
158
+ * @since 0.1.0
159
+ * @private
160
+ * @slop
161
+ */
162
+ export interface AndThen extends Scheduled {
163
+ readonly _tag: "AndThen"
164
+ readonly first: NodeAst
165
+ readonly continuation: FunctionIdentity
166
+ readonly next?: NodeAst | undefined
167
+ }
168
+
169
+ /**
170
+ * A decision whose arms are static topology. The predicate runs at run time on
171
+ * the real value; both arms are already ASTs, so the exit condition and the
172
+ * handoff site are visible before anything runs.
173
+ *
174
+ * @since 0.1.0
175
+ * @private
176
+ * @slop
177
+ */
178
+ export interface Branch extends Scheduled {
179
+ readonly _tag: "Branch"
180
+ readonly subject: string
181
+ readonly first: NodeAst
182
+ readonly predicate: FunctionIdentity
183
+ readonly then: NodeAst
184
+ readonly else: NodeAst
185
+ }
186
+
187
+ /**
188
+ * A protected graph and its statically planned typed-failure continuation.
189
+ *
190
+ * The symbolic error `subject` is minted per catch node rather than shared,
191
+ * mirroring {@link Branch}. Failure arms are built before the graph assigns
192
+ * ids, so a nested arm that captured an outer error would resolve it to the
193
+ * inner catch's protected node under one shared token.
194
+ *
195
+ * An absent filter catches the entire typed error channel, while a present
196
+ * schema catches only values it accepts. This mirrors Effect's typed-error
197
+ * boundary and keeps defects outside normal recovery. The serializable AST carries the schema identity; the live schema
198
+ * remains in a side table beside the AST.
199
+ *
200
+ * @since 0.1.0
201
+ * @private
202
+ * @slop
203
+ */
204
+ export interface Catch extends Scheduled {
205
+ readonly _tag: "Catch"
206
+ readonly subject: string
207
+ readonly protected: NodeAst
208
+ readonly failure: NodeAst
209
+ readonly filter?: unknown | undefined
210
+ readonly filterIdentity?: FunctionIdentity | undefined
211
+ }
212
+
213
+ /**
214
+ * How a flow call joins the caller's plan: `inline` splices the callee's body
215
+ * in, `boundary` makes it one child execution, and `handoff` names the next
216
+ * trampoline round. Why the child call is a mode rather than a second AST tag
217
+ * is in `docs/concepts/authoring-ast.md`.
218
+ *
219
+ * @since 0.1.0
220
+ * @private
221
+ * @slop
222
+ */
223
+ export const CallMode = Schema.Literals(["inline", "boundary", "handoff"])
224
+
225
+ /**
226
+ * How a flow call joins the caller's plan.
227
+ *
228
+ * @since 0.1.0
229
+ * @private
230
+ * @slop
231
+ */
232
+ export type CallMode = typeof CallMode.Type
233
+
234
+ /**
235
+ * A call to another flow. The AST keeps the callee's tag and the payload;
236
+ * the declaration itself is in the side table, because a flow value carries
237
+ * schemas and a body and is not JSON.
238
+ *
239
+ * @since 0.1.0
240
+ * @private
241
+ * @slop
242
+ */
243
+ export interface FlowCall extends Scheduled {
244
+ readonly _tag: "FlowCall"
245
+ readonly flow: string
246
+ readonly mode: CallMode
247
+ readonly payload: unknown
248
+ }
249
+
250
+ /**
251
+ * A call to an action: the atom that does work. Same split as
252
+ * {@link FlowCall}, and no mode — an action is always one node.
253
+ *
254
+ * @since 0.1.0
255
+ * @private
256
+ * @slop
257
+ */
258
+ export interface ActionCall extends Scheduled {
259
+ readonly _tag: "ActionCall"
260
+ readonly action: string
261
+ readonly payload: unknown
262
+ }
263
+
264
+ /**
265
+ * The algorithms a {@link FunctionIdentity} may name. The tag is versioned so a
266
+ * change to identity semantics re-keys everything derived from one, rather than
267
+ * colliding with it.
268
+ *
269
+ * @since 0.1.0
270
+ * @private
271
+ * @slop
272
+ */
273
+ export const IdentityAlgorithm = Schema.Literals([
274
+ "sha256-source-ephemeral/v4",
275
+ "sha256-source-captures/v5",
276
+ "static-node/v1"
277
+ ])
278
+
279
+ /**
280
+ * The algorithms a {@link FunctionIdentity} may name.
281
+ *
282
+ * @since 0.1.0
283
+ * @private
284
+ * @slop
285
+ */
286
+ export type IdentityAlgorithm = typeof IdentityAlgorithm.Type
287
+
288
+ /**
289
+ * The serializable stand-in for a function. Captured functions digest their
290
+ * exact source and declared inert captures. Unannotated functions additionally
291
+ * carry process-local, per-object entropy so indistinguishable closure sources
292
+ * fail closed instead of sharing a cache key. The tag it names is
293
+ * {@link IdentityAlgorithm}.
294
+ *
295
+ * @since 0.1.0
296
+ * @private
297
+ * @slop
298
+ */
299
+ export interface FunctionIdentity {
300
+ readonly _tag: "FunctionIdentity"
301
+ readonly algorithm: IdentityAlgorithm
302
+ readonly digest: string
303
+ }
304
+
305
+ /**
306
+ * Brands an operation with every inert value it closes over, so its identity
307
+ * covers what it computes with and not only its source.
308
+ *
309
+ * `@smthrs/crypto` owns the captures, nested metadata, and freezing contract.
310
+ *
311
+ * @since 0.1.0
312
+ * @private
313
+ */
314
+ export const capture = Identity.capture
315
+
316
+ /**
317
+ * The serializable form of a planned value embedded in an AST value or
318
+ * payload. The strict proxy itself cannot enter the AST because serializing it
319
+ * is deliberately a build error.
320
+ *
321
+ * @since 0.1.0
322
+ * @private
323
+ * @slop
324
+ */
325
+ export interface PlannedReference {
326
+ readonly _tag: "PlannedReference"
327
+ readonly node: string
328
+ readonly path: ReadonlyArray<string>
329
+ }
330
+
331
+ // A bundled runtime and a dynamically loaded flow may contain separate
332
+ // compatible package copies. Share only live object metadata: JSON round trips
333
+ // still lose executable bindings, and structural lookalikes remain inert.
334
+ const stateKey = Symbol.for("@smthrs/plan/Node/live/v1")
335
+ interface LiveState {
336
+ readonly references: WeakSet<object>
337
+ readonly nodes: WeakSet<object>
338
+ readonly operations: WeakMap<AndThen | Map, Operation>
339
+ readonly predicates: WeakMap<Branch, Predicate>
340
+ readonly filters: WeakMap<Catch, Schema.Top>
341
+ readonly declarations: WeakMap<ActionCall | FlowCall, unknown>
342
+ }
343
+ const globals = globalThis as typeof globalThis & { [stateKey]?: LiveState }
344
+ const liveState = globals[stateKey] ??= {
345
+ references: new WeakSet(),
346
+ nodes: new WeakSet(),
347
+ operations: new WeakMap(),
348
+ predicates: new WeakMap(),
349
+ filters: new WeakMap(),
350
+ declarations: new WeakMap()
351
+ }
352
+
353
+ /** The AST references minted by this module; structural lookalikes are data. */
354
+ const plannedReferences = liveState.references
355
+
356
+ const makePlannedReference = (reference: Planned.Reference): PlannedReference => {
357
+ const value = Object.freeze({
358
+ _tag: "PlannedReference" as const,
359
+ node: reference.node,
360
+ path: Object.freeze([...reference.path])
361
+ })
362
+ plannedReferences.add(value)
363
+ return value
364
+ }
365
+
366
+ /**
367
+ * Reads an AST-owned planned reference without trusting its public data shape.
368
+ *
369
+ * @since 1.0.0
370
+ * @private
371
+ */
372
+ export const plannedReference = (value: unknown): PlannedReference | undefined =>
373
+ typeof value === "object" && value !== null && plannedReferences.has(value)
374
+ ? value as PlannedReference
375
+ : undefined
376
+
377
+ /**
378
+ * Every AST variant.
379
+ *
380
+ * @since 0.1.0
381
+ * @private
382
+ * @slop
383
+ */
384
+ export type NodeAst = Succeed | Fail | All | Race | Map | AndThen | Branch | Catch | FlowCall | ActionCall
385
+
386
+ type Operation = (value: unknown) => unknown
387
+
388
+ type Predicate = (value: unknown) => boolean
389
+
390
+ const operations = liveState.operations
391
+ const predicates = liveState.predicates
392
+ const filters = liveState.filters
393
+ const declarations = liveState.declarations
394
+
395
+ /**
396
+ * The JSON mirror of an AST payload. A planned value becomes an AST-owned
397
+ * {@link PlannedReference}, so the AST stays inert JSON.
398
+ *
399
+ * @private
400
+ */
401
+ const payloadMirror = (input: unknown): unknown =>
402
+ jsonMirror(input, (_value, reference) => makePlannedReference(reference))
403
+
404
+ /**
405
+ * Digests a function's exact source as UTF-8 with SHA-256. Exact source matters:
406
+ * whitespace inside a string literal is behavior, and normalizing it before
407
+ * hashing can make different functions share an identity. Unless its inert
408
+ * captures were declared with {@link capture}, the digest also includes
409
+ * process-local, per-function entropy: JavaScript cannot inspect a closure, so
410
+ * source-only identity would permit incorrect cache hits.
411
+ *
412
+ * @since 0.1.0
413
+ * @private
414
+ * @slop
415
+ */
416
+ export const functionIdentity: (operation: unknown) => FunctionIdentity = Identity.functionIdentity
417
+
418
+ /**
419
+ * The prototype every node shares, so `pipe` is one function rather than one
420
+ * closure per node.
421
+ *
422
+ * @since 0.1.0
423
+ * @private
424
+ * @slop
425
+ */
426
+ export const NodeProto = {
427
+ [TypeId]: {
428
+ _A: identity,
429
+ _E: identity,
430
+ _R: identity
431
+ },
432
+ pipe() {
433
+ // eslint-disable-next-line prefer-rest-params -- `pipeArguments` takes the arguments object itself.
434
+ return pipeArguments(this, arguments)
435
+ }
436
+ }
437
+
438
+ /**
439
+ * Every node {@link makeNode} built, so a node this module made is recognized
440
+ * in constant time without walking its AST again.
441
+ */
442
+ const liveNodes = liveState.nodes
443
+
444
+ /** Recognizes the algorithms {@link IdentityAlgorithm} declares, and nothing else. */
445
+ const isIdentityAlgorithm = Schema.is(IdentityAlgorithm)
446
+
447
+ /** Recognizes the modes {@link CallMode} declares, and nothing else. */
448
+ const isCallMode = Schema.is(CallMode)
449
+
450
+ /** Recognizes the modes {@link RaceMode} names, and nothing else. @private */
451
+ const isRaceMode = (value: unknown): value is RaceMode => value === "race" || value === "any" || value === "quorum"
452
+
453
+ /**
454
+ * Whether `count` is a successes target a {@link Race} of `members` can reach:
455
+ * a quorum needs between one and every member, and the other modes need one.
456
+ *
457
+ * @since 1.0.0
458
+ * @private
459
+ */
460
+ export const isRaceCount = (mode: RaceMode, count: unknown, members: number): boolean =>
461
+ members > 0 && Number.isSafeInteger(count) &&
462
+ (mode === "quorum" ? (count as number) >= 1 && (count as number) <= members : count === 1)
463
+
464
+ /** @private */
465
+ const isFunctionIdentity = (value: unknown): value is FunctionIdentity =>
466
+ isRecord(value) && value._tag === "FunctionIdentity" && isIdentityAlgorithm(value.algorithm) &&
467
+ typeof value.digest === "string"
468
+
469
+ /** One position of the {@link isNodeAst} walk: an AST to check, or one whose children are all checked. @private */
470
+ type AstVisit = { readonly enter: unknown } | { readonly exit: object }
471
+
472
+ /**
473
+ * Checks whether a value has the shape of a {@link NodeAst}: what a JSON round
474
+ * trip of a genuine AST produces, and nothing looser. Every variant's own
475
+ * fields are checked, and every child is walked. The walk is an explicit stack
476
+ * rather than recursion, so an arbitrarily deep AST cannot overflow the native
477
+ * stack, and it tracks the ancestors of the position it has reached, so a
478
+ * cyclic object is refused rather than walked forever while a shared sub-AST
479
+ * still passes.
480
+ *
481
+ * @since 1.0.0
482
+ * @private
483
+ */
484
+ export const isNodeAst = (value: unknown): value is NodeAst => {
485
+ const ancestors = new Set<object>()
486
+ const pending: Array<AstVisit> = [{ enter: value }]
487
+ while (pending.length > 0) {
488
+ const visit = pending.pop()!
489
+ if ("exit" in visit) {
490
+ ancestors.delete(visit.exit)
491
+ continue
492
+ }
493
+ const ast = visit.enter
494
+ if (!isRecord(ast) || ancestors.has(ast)) return false
495
+ if (ast.priority !== undefined && !Number.isSafeInteger(ast.priority)) return false
496
+ const children: Array<unknown> = []
497
+ switch (ast._tag) {
498
+ case "Succeed":
499
+ case "Fail":
500
+ break
501
+ case "All": {
502
+ if (!isRecord(ast.nodes) || Array.isArray(ast.nodes)) return false
503
+ for (const child of Object.values(ast.nodes)) children.push(child)
504
+ break
505
+ }
506
+ case "Race": {
507
+ if (!isRecord(ast.nodes) || Array.isArray(ast.nodes)) return false
508
+ const members = Object.values(ast.nodes)
509
+ if (!isRaceMode(ast.mode) || !isRaceCount(ast.mode, ast.count, members.length)) return false
510
+ for (const child of members) children.push(child)
511
+ break
512
+ }
513
+ case "Map": {
514
+ if (!isFunctionIdentity(ast.mapper)) return false
515
+ children.push(ast.first)
516
+ break
517
+ }
518
+ case "AndThen": {
519
+ if (!isFunctionIdentity(ast.continuation)) return false
520
+ children.push(ast.first)
521
+ if (ast.next !== undefined) children.push(ast.next)
522
+ break
523
+ }
524
+ case "Branch": {
525
+ if (typeof ast.subject !== "string" || !isFunctionIdentity(ast.predicate)) return false
526
+ children.push(ast.first, ast.then, ast.else)
527
+ break
528
+ }
529
+ case "Catch": {
530
+ if (typeof ast.subject !== "string") return false
531
+ if (ast.filterIdentity !== undefined && !isFunctionIdentity(ast.filterIdentity)) return false
532
+ children.push(ast.protected, ast.failure)
533
+ break
534
+ }
535
+ case "FlowCall": {
536
+ if (typeof ast.flow !== "string" || !isCallMode(ast.mode)) return false
537
+ break
538
+ }
539
+ case "ActionCall": {
540
+ if (typeof ast.action !== "string") return false
541
+ break
542
+ }
543
+ default:
544
+ return false
545
+ }
546
+ if (children.length > 0) {
547
+ ancestors.add(ast)
548
+ pending.push({ exit: ast })
549
+ for (const child of children) pending.push({ enter: child })
550
+ }
551
+ }
552
+ return true
553
+ }
554
+
555
+ /**
556
+ * Checks whether a value is a node.
557
+ *
558
+ * A node this module built is recognized by registration. A node that crossed
559
+ * a serialization boundary, an object whose prototype is {@link NodeProto} and
560
+ * whose own `ast` data property passes {@link isNodeAst}, is recognized by that
561
+ * shape: `@smthrs/flow` hands a rehydrated AST back as a node, and the side
562
+ * tables are all a round trip loses. The {@link TypeId} marker is an exported
563
+ * string any object can carry, so it counts for nothing on its own, and an
564
+ * `ast` that is missing, malformed, cyclic, or an accessor is refused because
565
+ * every combinator reads it as trusted topology. A proxy is judged by the
566
+ * shape it forwards.
567
+ *
568
+ * @since 1.0.0
569
+ * @private
570
+ */
571
+ export const isNode = (value: unknown): value is Node<unknown, unknown, any> => {
572
+ if (typeof value !== "object" || value === null) return false
573
+ if (liveNodes.has(value)) return true
574
+ if (Object.getPrototypeOf(value) !== NodeProto) return false
575
+ const ast = Object.getOwnPropertyDescriptor(value, "ast")
576
+ return ast !== undefined && "value" in ast && isNodeAst(ast.value)
577
+ }
578
+
579
+ /**
580
+ * Wraps an AST as a node.
581
+ *
582
+ * @since 0.1.0
583
+ * @private
584
+ * @slop
585
+ */
586
+ export const makeNode = <A = unknown, E = never, R = never>(ast: NodeAst): Node<A, E, R> => {
587
+ const node: Node<A, E, R> = Object.assign(Object.create(NodeProto), { ast })
588
+ liveNodes.add(node)
589
+ return node
590
+ }
591
+
592
+ /**
593
+ * Constructs a {@link Succeed}.
594
+ *
595
+ * @since 0.1.0
596
+ * @private
597
+ * @slop
598
+ */
599
+ export const succeed = (input: unknown): Succeed => ({ _tag: "Succeed", value: payloadMirror(input) })
600
+
601
+ /**
602
+ * Constructs a {@link Fail}.
603
+ *
604
+ * @since 1.0.0
605
+ * @private
606
+ * @slop
607
+ */
608
+ export const fail = (error: unknown): Fail => ({ _tag: "Fail", error: payloadMirror(error) })
609
+
610
+ /**
611
+ * Constructs an {@link All}.
612
+ *
613
+ * @since 0.1.0
614
+ * @private
615
+ * @slop
616
+ */
617
+ export const all = (nodes: Readonly<Record<string, NodeAst>>): All => ({ _tag: "All", nodes })
618
+
619
+ /**
620
+ * Constructs a {@link Race}.
621
+ *
622
+ * @since 1.0.0
623
+ * @private
624
+ */
625
+ export const race = (mode: RaceMode, count: number, nodes: Readonly<Record<string, NodeAst>>): Race => ({
626
+ _tag: "Race",
627
+ mode,
628
+ count,
629
+ nodes
630
+ })
631
+
632
+ /**
633
+ * Constructs a {@link Map}, filing the mapper under the AST it belongs to.
634
+ *
635
+ * @since 0.1.0
636
+ * @private
637
+ * @slop
638
+ */
639
+ export const map = (first: NodeAst, operation: Operation, source: unknown): Map => {
640
+ const ast: Map = { _tag: "Map", first, mapper: functionIdentity(source) }
641
+ operations.set(ast, operation)
642
+ return ast
643
+ }
644
+
645
+ /**
646
+ * Constructs an {@link AndThen} from a builder. The builder is evaluated once
647
+ * against a planned value when the graph is built, never here.
648
+ *
649
+ * @since 0.1.0
650
+ * @private
651
+ * @slop
652
+ */
653
+ export const andThen = (first: NodeAst, operation: Operation, source: unknown): AndThen => {
654
+ const ast: AndThen = { _tag: "AndThen", first, continuation: functionIdentity(source) }
655
+ operations.set(ast, operation)
656
+ return ast
657
+ }
658
+
659
+ /**
660
+ * Constructs an {@link AndThen} from a node the author supplied directly. There
661
+ * is no function to digest, so the continuation carries a fixed marker.
662
+ *
663
+ * @since 0.1.0
664
+ * @private
665
+ * @slop
666
+ */
667
+ export const andThenNode = (first: NodeAst, next: NodeAst): AndThen => ({
668
+ _tag: "AndThen",
669
+ first,
670
+ continuation: { _tag: "FunctionIdentity", algorithm: "static-node/v1", digest: digestSync("static-node") },
671
+ next
672
+ })
673
+
674
+ /**
675
+ * Constructs a {@link Branch}. Both arms arrive already evaluated, because a
676
+ * branch that had to be run to reveal its topology would not be a plan.
677
+ *
678
+ * @since 0.1.0
679
+ * @private
680
+ * @slop
681
+ */
682
+ export const branch = (
683
+ subject: string,
684
+ first: NodeAst,
685
+ predicate: Predicate,
686
+ source: unknown,
687
+ then: NodeAst,
688
+ otherwise: NodeAst
689
+ ): Branch => {
690
+ const ast: Branch = {
691
+ _tag: "Branch",
692
+ subject,
693
+ first,
694
+ predicate: functionIdentity(source),
695
+ then,
696
+ else: otherwise
697
+ }
698
+ predicates.set(ast, predicate)
699
+ return ast
700
+ }
701
+
702
+ /**
703
+ * JSON Schema does not describe arbitrary checks, declaration parsers, or
704
+ * suspended schema factories. Include every function reachable from the
705
+ * schema AST, preserving declared captures and treating opaque wrappers as
706
+ * ephemeral. The iterative walk also covers nested checks and cyclic ASTs.
707
+ *
708
+ * @private
709
+ */
710
+ const filterIdentity = (filter: Schema.Top): FunctionIdentity => {
711
+ const identities: Array<readonly [string, FunctionIdentity]> = []
712
+ const pending: Array<readonly [string, unknown]> = [["$", filter.ast]]
713
+ const seen = new WeakSet<object>()
714
+ while (pending.length > 0) {
715
+ const [path, current] = pending.pop()!
716
+ if (typeof current === "function") {
717
+ identities.push([path, functionIdentity(current)])
718
+ } else if (typeof current === "object" && current !== null && !seen.has(current)) {
719
+ seen.add(current)
720
+ const descriptors = Object.getOwnPropertyDescriptors(current)
721
+ for (const key of Object.keys(descriptors).sort()) {
722
+ const descriptor = descriptors[key]!
723
+ if ("value" in descriptor) pending.push([`${path}/${JSON.stringify(key)}`, descriptor.value])
724
+ else {
725
+ if (descriptor.get !== undefined) pending.push([`${path}/${JSON.stringify(key)}/get`, descriptor.get])
726
+ if (descriptor.set !== undefined) pending.push([`${path}/${JSON.stringify(key)}/set`, descriptor.set])
727
+ }
728
+ }
729
+ }
730
+ }
731
+ return {
732
+ _tag: "FunctionIdentity",
733
+ algorithm: identities.some(([, identity]) => identity.algorithm === "sha256-source-ephemeral/v4")
734
+ ? "sha256-source-ephemeral/v4"
735
+ : "sha256-source-captures/v5",
736
+ digest: digestSync(JSON.stringify(identities))
737
+ }
738
+ }
739
+
740
+ /**
741
+ * Constructs a {@link Catch} whose failure topology is already evaluated.
742
+ *
743
+ * @since 0.1.0
744
+ * @private
745
+ * @slop
746
+ */
747
+ export const catch_ = (subject: string, protectedAst: NodeAst, failure: NodeAst, filter?: Schema.Top): Catch => {
748
+ const ast: Catch = {
749
+ _tag: "Catch",
750
+ subject,
751
+ protected: protectedAst,
752
+ failure,
753
+ ...(filter === undefined
754
+ ? {}
755
+ : {
756
+ filter: Schema.toJsonSchemaDocument(filter, { onExcessProperty: "error" }),
757
+ filterIdentity: filterIdentity(filter)
758
+ })
759
+ }
760
+ if (filter !== undefined) filters.set(ast, filter)
761
+ return ast
762
+ }
763
+
764
+ /**
765
+ * Constructs a {@link FlowCall}, filing the flow declaration beside it.
766
+ *
767
+ * @since 0.1.0
768
+ * @private
769
+ * @slop
770
+ */
771
+ export const flowCall = (declaration: unknown, flow: string, mode: CallMode, payload: unknown): FlowCall => {
772
+ const ast: FlowCall = { _tag: "FlowCall", flow, mode, payload: payloadMirror(payload) }
773
+ declarations.set(ast, declaration)
774
+ return ast
775
+ }
776
+
777
+ /**
778
+ * Constructs an {@link ActionCall}, filing the action declaration beside
779
+ * it.
780
+ *
781
+ * @since 0.1.0
782
+ * @private
783
+ * @slop
784
+ */
785
+ export const actionCall = (declaration: unknown, action: string, payload: unknown): ActionCall => {
786
+ const ast: ActionCall = { _tag: "ActionCall", action, payload: payloadMirror(payload) }
787
+ declarations.set(ast, declaration)
788
+ return ast
789
+ }
790
+
791
+ /**
792
+ * The deferred function of a map or a continuation.
793
+ *
794
+ * @since 0.1.0
795
+ * @private
796
+ * @slop
797
+ */
798
+ export const operation = (ast: AndThen | Map): Operation | undefined => operations.get(ast)
799
+
800
+ /**
801
+ * The run-time predicate of a branch.
802
+ *
803
+ * @since 0.1.0
804
+ * @private
805
+ * @slop
806
+ */
807
+ export const predicate = (ast: Branch): Predicate | undefined => predicates.get(ast)
808
+
809
+ /**
810
+ * The optional error schema filter of a catch node.
811
+ *
812
+ * @since 0.1.0
813
+ * @private
814
+ * @slop
815
+ */
816
+ export const filter = (ast: Catch): Schema.Top | undefined => filters.get(ast)
817
+
818
+ /**
819
+ * The flow or action declaration a call node names.
820
+ *
821
+ * @since 0.1.0
822
+ * @private
823
+ * @slop
824
+ */
825
+ export const declaration = (ast: ActionCall | FlowCall): unknown => declarations.get(ast)
826
+
827
+ /**
828
+ * Copies an AST with a scheduling priority attached.
829
+ *
830
+ * The copy is shallow and the side tables are re-filed against it, because a
831
+ * function, a predicate, a filter schema, and a declaration are keyed by the
832
+ * exact AST object they belong to: a copy that left them behind would silently
833
+ * lose the continuation the graph walk has to evaluate.
834
+ *
835
+ * @since 0.1.0
836
+ * @private
837
+ * @slop
838
+ */
839
+ export const withPriority = (ast: NodeAst, priority: number): NodeAst => {
840
+ const prioritized = { ...ast, priority }
841
+ if (ast._tag === "AndThen" || ast._tag === "Map") {
842
+ const deferred = operations.get(ast)
843
+ if (deferred !== undefined) operations.set(prioritized as AndThen | Map, deferred)
844
+ }
845
+ if (ast._tag === "Branch") {
846
+ const decision = predicates.get(ast)
847
+ if (decision !== undefined) predicates.set(prioritized as Branch, decision)
848
+ }
849
+ if (ast._tag === "Catch") {
850
+ const schema = filters.get(ast)
851
+ if (schema !== undefined) filters.set(prioritized as Catch, schema)
852
+ }
853
+ if (ast._tag === "ActionCall" || ast._tag === "FlowCall") {
854
+ const target = declarations.get(ast)
855
+ if (target !== undefined) declarations.set(prioritized as ActionCall | FlowCall, target)
856
+ }
857
+ return prioritized
858
+ }