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

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