@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,101 @@
1
+ ---
2
+ title: "Diff two plans"
3
+ description: "Compare a re-plan against the plan before it: what the verdict is, how a re-key is attributed to the field that caused it, and why the report is in no digest."
4
+ sidebar:
5
+ order: 5
6
+ ---
7
+
8
+ "What is different about this run" is a set comparison, not archaeology.
9
+ `PlanDiff.diff` is that comparison as a value.
10
+
11
+ ```ts
12
+ import * as NodeCrypto from "@effect/platform-node/NodeCrypto"
13
+ import * as Plan from "@smthrs/plan/Plan"
14
+ import * as PlanDiff from "@smthrs/plan/PlanDiff"
15
+ import type * as Crypto from "effect/Crypto"
16
+ import * as Effect from "effect/Effect"
17
+
18
+ /** The drafts and the compiled effect from "Compile drafts into a plan". */
19
+ declare const readPr: Plan.NodeDraft
20
+ declare const runTests: Plan.NodeDraft
21
+ declare const compiled: Effect.Effect<Plan.Plan, never, Crypto.Crypto>
22
+
23
+ export const compare = Effect.gen(function*() {
24
+ const before = yield* compiled
25
+ const after = yield* Plan.compile({
26
+ planId: "review-4821",
27
+ flow: "example/Review",
28
+ nodes: [readPr, { ...runTests, material: { ...runTests.material, body: { action: "run-tests", jobs: 4 } } }]
29
+ })
30
+ const changes: PlanDiff.PlanDiff = PlanDiff.diff(before, after)
31
+ return changes.rekeyed.map((entry) => `${entry.id}: ${entry.changed.join(", ")}`)
32
+ }).pipe(Effect.provide(NodeCrypto.layer))
33
+ ```
34
+
35
+ `compare` answers `["run-tests: body"]`. The edit moved one field of one node's
36
+ body, so that node re-keyed and the report says which field did it.
37
+
38
+ ## The verdict is the key
39
+
40
+ Two nodes with the same id and the same key are the same step. Nothing else
41
+ needs saying, and `diff` says nothing else. Four lists come back:
42
+
43
+ | Field | Contents |
44
+ | ----------- | ------------------------------------------------------------------------ |
45
+ | `added` | Node ids present in `next` and absent from `previous`. |
46
+ | `removed` | Node ids present in `previous` and absent from `next`. |
47
+ | `rekeyed` | Nodes present in both whose key moved, with `from`, `to`, and `changed`. |
48
+ | `unchanged` | Node ids present in both with the same key. |
49
+
50
+ ## The attribution is for a human
51
+
52
+ `Rekeyed.changed` is a list of field labels: `["body", "input[1]"]`. It is
53
+ derived by comparing declarations field by field, and it is deliberately part of
54
+ no digest. Changing how the report reads can never change what a plan hashes to.
55
+
56
+ The labels mirror the hashed declaration: the effect tier `kind`, the fields
57
+ the hashed material body folds, and the input positions:
58
+
59
+ ```text
60
+ kind body layers capabilities effects version nondeterministic placement input[n]
61
+ ```
62
+
63
+ An `input[n]` entry appears when that input's declaration changed, and also when
64
+ the declaration is unchanged but the node it references itself re-keyed. A node
65
+ re-keyed purely by an upstream edit is therefore attributed to the input
66
+ position that references it, even behind an unprojected `Pending`, rather than
67
+ reported as nothing changed.
68
+
69
+ `changed` is empty only when none of the compared fields moved, which means the
70
+ key moved for a reason the comparison does not model. Treat an empty `changed`
71
+ on a re-keyed node as a bug report, not as noise.
72
+
73
+ ## Comparison uses the key's own JSON semantics
74
+
75
+ Each compared field is projected through the same JSON mirror the keys are
76
+ derived from, so two `Date` bodies a generation apart attribute to `body` rather
77
+ than to nothing. The projection runs no accessor.
78
+
79
+ A field the projection refuses compares by an identity token scoped to that node
80
+ and field. The same value stays equal to itself, two distinct refused values
81
+ never compare equal, and `diff` stays a total function even for a value
82
+ canonical serialization would reject. `diff` never fails and never needs a
83
+ service.
84
+
85
+ ## Reporting a re-plan
86
+
87
+ The pairing this is built for is a re-plan of the same flow: compile the flow
88
+ again against the current source and compare the result with the plan an
89
+ operator last saw. `added` and `removed` are the shape change, `rekeyed` is the
90
+ work that will not come from cache, and `unchanged` is everything a cache hit
91
+ will skip.
92
+
93
+ The command line prints the same comparison through
94
+ [`smthrs flow plan`](/cli/flow), and the plan card it renders is what
95
+ [`smthrs approvals approve`](/cli/approvals) binds a decision to.
96
+
97
+ ## Next
98
+
99
+ - [Step keys](../concepts/step-keys.md): why an upstream edit re-keys a
100
+ downstream node whose own declaration did not change.
101
+ - [The plan value](../concepts/plan-value.md): the digests a diff sits beside.
@@ -0,0 +1,71 @@
1
+ ---
2
+ title: "Installation"
3
+ description: "Install @smthrs/plan and satisfy its peer requirements."
4
+ sidebar:
5
+ order: 1
6
+ ---
7
+
8
+ ## Requirements
9
+
10
+ - Node.js 26.4.0 or later.
11
+ - `effect` 4.0.0-rc.115. The package's schemas, layers, and error classes are
12
+ Effect values, so the version has to match the one your application uses.
13
+
14
+ ## Install
15
+
16
+ Not on npm yet; see [Installation](https://github.com/smithersai/smithers/blob/main/packages/smithers/flows/flow/docs/installation.md#use-the-libraries).
17
+
18
+ That is enough to build node graphs, compile plans, and diff them. Compiling
19
+ asks for Effect's `Crypto` service, which a platform package supplies:
20
+
21
+ ```bash
22
+ pnpm add @effect/platform-node@4.0.0-rc.115 @effect/platform-node-shared@4.0.0-rc.115
23
+ ```
24
+
25
+ `@smthrs/crypto`, `@smthrs/canonical` and [`@smthrs/keys`](/api/keys) arrive as
26
+ dependencies of this package; you do not install them yourself unless you use
27
+ them directly.
28
+
29
+ ## What persistence adds
30
+
31
+ This package performs no I/O. Recording a compiled plan is
32
+ [`@smthrs/plan-store`](/api/plan-store), which owns the SQL and brings
33
+ [`@smthrs/database`](/api/database) with it.
34
+
35
+ ## Import forms
36
+
37
+ The root entry point re-exports every module as a namespace:
38
+
39
+ ```ts
40
+ import { FileSet, Node, Plan, PlanDiff } from "@smthrs/plan"
41
+ ```
42
+
43
+ Each module is also its own entry point. Prefer this form: it keeps a bundle to
44
+ the modules you actually reach for.
45
+
46
+ ```ts
47
+ import * as Node from "@smthrs/plan/Node"
48
+ import * as Plan from "@smthrs/plan/Plan"
49
+ ```
50
+
51
+ Both forms resolve to the same values.
52
+
53
+ ## What you cannot import
54
+
55
+ `@smthrs/plan/internal/*` is blocked by the export map, and so is
56
+ `@smthrs/plan/<Module>/index`. Node reports `ERR_PACKAGE_PATH_NOT_EXPORTED` for
57
+ either.
58
+
59
+ `@smthrs/plan/test/PlanFixtures` is the exception that is meant for test code:
60
+ it builds drafts and compiles them, and
61
+ [`@smthrs/plan-store`](/api/plan-store) drives the same fixtures against the
62
+ persisted form.
63
+
64
+ ## Browser support
65
+
66
+ The package is browser-safe: it resolves no `node:` built-in and binds no
67
+ database. Compiling, diffing, and building node graphs all work in a browser.
68
+
69
+ ## Next
70
+
71
+ [Quickstart](./quickstart.md) compiles a plan, appends to it, and diffs it.
@@ -0,0 +1,128 @@
1
+ ---
2
+ title: "Quickstart"
3
+ description: "Compile a two-node plan, read its step keys, append a third node at the next generation, and diff the two graphs."
4
+ sidebar:
5
+ order: 2
6
+ ---
7
+
8
+ This quickstart takes one plan from drafts to a keyed graph and grows it. By the
9
+ end you will have a compiled graph with real step keys, a third node added at
10
+ generation 1 with the first two untouched, and a diff that says exactly what
11
+ changed.
12
+
13
+ Nothing here executes a node or writes a row. A plan is inert: persisting one is
14
+ [`@smthrs/plan-store`](/api/plan-store), and driving one is
15
+ [`@smthrs/engine-store`](/api/engine-store)'s `PlanScheduler`.
16
+
17
+ ## Prerequisites
18
+
19
+ - Node.js 26.4.0 or later.
20
+ - A package with the dependencies installed. Not on npm yet; see [Installation](https://github.com/smithersai/smithers/blob/main/packages/smithers/flows/flow/docs/installation.md#use-the-libraries).
21
+
22
+ `@effect/platform-node` supplies Effect's `Crypto` service, which is the only
23
+ thing compiling a plan asks for.
24
+
25
+ ## Declare two nodes
26
+
27
+ Create `quickstart.ts`. A `Plan.NodeDraft` is a node without its key: an id, the
28
+ key material that decides its identity, and the file effects it declares.
29
+
30
+ ```ts
31
+ import * as KeyMaterial from "@smthrs/plan/KeyMaterial"
32
+ import * as Plan from "@smthrs/plan/Plan"
33
+
34
+ /** Everything hashed about one node. `sealed` is the tier that may be reused across runs. */
35
+ const material = (body: unknown, inputs: ReadonlyArray<KeyMaterial.InputRef> = []): KeyMaterial.KeyMaterial => ({
36
+ version: KeyMaterial.version,
37
+ kind: "sealed",
38
+ body,
39
+ inputs,
40
+ layers: [],
41
+ capabilities: []
42
+ })
43
+
44
+ /** Two steps: the second consumes the first's result and reads the file it wrote. */
45
+ const drafts: ReadonlyArray<Plan.NodeDraft> = [
46
+ {
47
+ id: "read-pr",
48
+ material: material({ action: "read-pr", pr: 4821 }),
49
+ effects: { reads: [], writes: ["pr.json"], boundaryMode: "hard" }
50
+ },
51
+ {
52
+ id: "run-tests",
53
+ material: material({ action: "run-tests" }, [{ _tag: "Ref", from: "read-pr", path: [] }]),
54
+ effects: { reads: ["pr.json"], writes: ["report.json"], boundaryMode: "hard" }
55
+ }
56
+ ]
57
+ ```
58
+
59
+ The `Ref` input is the only place `run-tests` names `read-pr`. That one
60
+ declaration becomes both the hashed dependency and the graph edge, so an edge
61
+ and a key can never disagree about what a node consumes.
62
+
63
+ ## Compile, append, diff
64
+
65
+ ```ts
66
+ import * as NodeCrypto from "@effect/platform-node/NodeCrypto"
67
+ import * as PlanDiff from "@smthrs/plan/PlanDiff"
68
+ import * as Effect from "effect/Effect"
69
+
70
+ const program = Effect.gen(function*() {
71
+ const plan = yield* Plan.compile({ planId: "review-4821", flow: "example/Review", nodes: drafts })
72
+ console.log(plan.digest)
73
+
74
+ const grown = yield* Plan.append(plan, [{
75
+ id: "post-comment",
76
+ material: material({ action: "post-comment" }, [{ _tag: "Pending", from: "run-tests" }]),
77
+ effects: { reads: ["report.json"], writes: [], boundaryMode: "hard" }
78
+ }])
79
+
80
+ for (const node of grown.nodes) {
81
+ console.log(node.generation, node.id, node.key, node.dependsOn)
82
+ }
83
+ console.log(grown.generation, grown.baseDigest === plan.digest)
84
+ console.log(PlanDiff.diff(plan, grown).added)
85
+ })
86
+
87
+ await Effect.runPromise(program.pipe(Effect.provide(NodeCrypto.layer), Effect.orDie))
88
+ ```
89
+
90
+ Run the file with your TypeScript runner. The digests on your machine match
91
+ these, because a plan is a pure function of its declarations:
92
+
93
+ ```text
94
+ key1_61edccc875ff69b798e0c3874d167f65ff0a250b9fe67d27558b854fa2832302
95
+ 0 read-pr key1_6353326ab2a03804d6acfe916debc4f04f09f9deb8f0e93c1ebea3cdd983db18 []
96
+ 0 run-tests key1_e58229e3be2f6abf6f818baf8577fe7045edba6f5e88097b633898478da93985 [ 'read-pr' ]
97
+ 1 post-comment key1_0ac2aef1d5d4226849c6009450ad987b47dc02cf39d39ab975db3c673698241d [ 'run-tests' ]
98
+ 1 true
99
+ [ 'post-comment' ]
100
+ ```
101
+
102
+ ## What just happened
103
+
104
+ `Plan.compile` put the drafts in topological order, substituted each dependency
105
+ reference for the already computed key of the node it names, annotated the
106
+ overlap between write sets, and derived the plan digest. It read no file, no
107
+ clock, and no network: the only service it asked for was `Crypto`.
108
+
109
+ `Plan.append` then produced generation 1. The two original nodes kept their id,
110
+ key, edges, and generation byte for byte, and `post-comment` arrived pre-keyed
111
+ against them. The `true` line is `baseDigest` still naming the digest a human
112
+ would have approved, even though `digest` has moved on.
113
+
114
+ Read `post-comment`'s edge: it depends on `run-tests`, which it never consumed a
115
+ value from. Two separate declarations put it there. Its `Pending` input is an
116
+ ordering reference, and it reads `report.json`, which `run-tests` writes, so the
117
+ reader-after-writer pass would have ordered it behind the producer anyway.
118
+
119
+ ## Next steps
120
+
121
+ - [The plan value](./concepts/plan-value.md): generations, digests, and why a
122
+ plan grows instead of being rewritten.
123
+ - [Step keys](./concepts/step-keys.md): what goes into a key, what deliberately
124
+ does not, and why invalidation is re-keying.
125
+ - [Diff two plans](./guides/diff-two-plans.md): report what a re-plan changed,
126
+ attributed to the field that changed it.
127
+ - [Persist a plan](https://plan-store.smithers.sh/guides/persist-a-plan/): the
128
+ append-only store, in production shape.
@@ -0,0 +1,73 @@
1
+ ---
2
+ title: "Testing"
3
+ description: "How to test code that builds plans: the one service to provide, a real in-memory store, and the assertions worth writing."
4
+ ---
5
+
6
+ ## Testing code that builds plans
7
+
8
+ Compiling asks for Effect's `Crypto` service and nothing else, so a plan test
9
+ needs one layer:
10
+
11
+ ```ts
12
+ import * as NodeCrypto from "@effect/platform-node/NodeCrypto"
13
+ import type * as Crypto from "effect/Crypto"
14
+ import * as Effect from "effect/Effect"
15
+
16
+ /** Provides concrete Node cryptography to a test Effect. */
17
+ export const withCrypto = <A, E>(effect: Effect.Effect<A, E, Crypto.Crypto>): Effect.Effect<A, E> =>
18
+ Effect.provide(effect, NodeCrypto.layer)
19
+
20
+ /** The same, for an Effect expected to fail: yields the typed error. */
21
+ export const withCryptoFailure = <A, E>(effect: Effect.Effect<A, E, Crypto.Crypto>): Effect.Effect<E, A> =>
22
+ withCrypto(Effect.flip(effect))
23
+ ```
24
+
25
+ Those two helpers are the whole harness. No clock, no filesystem, and no fake
26
+ anything: a plan is a pure function of its declarations, so a plan test is an
27
+ ordinary value test.
28
+
29
+ Testing persistence is [`@smthrs/plan-store`](/api/plan-store)'s
30
+ [testing page](https://plan-store.smithers.sh/testing/). It drives the same
31
+ fixtures this package ships at `@smthrs/plan/test/PlanFixtures`, so a draft
32
+ builder is written once and used on both sides.
33
+
34
+ ### Assert on keys, not on shape
35
+
36
+ A plan's value is that identity is computable. The assertions that earn their
37
+ place say so:
38
+
39
+ - An edit re-keys its node and its dependent cone, and nothing else.
40
+ - A rename re-keys nothing, because ids are lookup addresses.
41
+ - An ordering edge, a priority change, or a conflict annotation leaves every key
42
+ where it was.
43
+ - Compiling the same drafts twice produces the same digest.
44
+
45
+ ### Mutate the caller's draft after compiling
46
+
47
+ A compiled plan is a deep-frozen snapshot. The cheapest proof is to compile,
48
+ mutate the object you passed in, and assert the stored material and the plan
49
+ digest did not move. The values worth trying are a `Date`, a `URL`, and an
50
+ object with a custom `toJSON`, because each one keys through a different path.
51
+
52
+ ### Keep fixtures out of test modules
53
+
54
+ Importing a `*.test.ts` module registers its suites in the importer, so shared
55
+ fixtures belong in an ordinary module. When one suite pulls a helper out of
56
+ another suite's file, it re-runs every case in that file, and a fast suite
57
+ inherits a slow one's runtime and its timeouts for work it never asked for.
58
+
59
+ ### Keep resource checks independent of wall time
60
+
61
+ The package runs files serially, including the dense compiler cases with their
62
+ original graph sizes, digest and edge-order assertions. FileSet's synchronous
63
+ adversarial matcher tests charge thread CPU time against the existing 100 ms
64
+ budget, excluding descheduling and work on other threads.
65
+
66
+ The planned-value lint test builds a dedicated consumer fixture project against
67
+ the real Node types. It asserts the exact conditional-truthiness diagnostics
68
+ without asking the editor project service to discover other workspace projects.
69
+ An isolated Node 24 run measured 6.96 seconds user plus 1.56 seconds system CPU
70
+ with `/usr/bin/time -p`. This case alone allows 70 seconds: eight times that CPU
71
+ cost, rounded up, for a heavily shared host. The package's 30-second default is
72
+ unchanged. Coverage reports and V8 scratch files use a per-process directory so
73
+ concurrent package invocations cannot clean each other's reports.
@@ -0,0 +1,314 @@
1
+ ---
2
+ title: "Troubleshooting"
3
+ description: "Every refusal @smthrs/plan raises, what causes it, and what to change: build errors, compile errors, and key material errors."
4
+ ---
5
+
6
+ Every failure this package reports is typed and carries a code. Find the code
7
+ and read the matching section. The full error schemas are in the
8
+ [API reference](./api.md).
9
+
10
+ Three error types, from three layers:
11
+
12
+ | Type | Raised by |
13
+ | ------------------ | ---------------------------------------------- |
14
+ | `GraphBuildError` | Building a node graph, before there is a plan. |
15
+ | `PlanError` | `Plan.compile` and `Plan.append`. |
16
+ | `KeyMaterialError` | The step-key compiler. |
17
+
18
+ Persistence raises a fourth, `PlanStoreError`, from
19
+ [`@smthrs/plan-store`](/api/plan-store); its
20
+ [troubleshooting page](https://plan-store.smithers.sh/troubleshooting/) covers
21
+ every code.
22
+
23
+ ## GraphBuildError
24
+
25
+ Thrown, not failed, because the author reading it is mid-body. Each one names
26
+ the site through `node` plus the recorded property `path`, and states the fix in
27
+ `message`.
28
+
29
+ ### planned_value_computed
30
+
31
+ **What happened.** A body computed on a step result. The message names the
32
+ value and the operation:
33
+
34
+ ```text
35
+ Planned value read-pr.count was computed at plan time by Symbol.toPrimitive.
36
+ Use Node.map to compute, Node.branch to decide, or pass the value into a payload.
37
+ ```
38
+
39
+ Template interpolation, `String(value)`, arithmetic through a coercion,
40
+ `JSON.stringify` of a payload holding one, calling it, `in`, and enumerating it
41
+ all land here.
42
+
43
+ **What to change.** Do the computation where it belongs. `Node.map` transforms a
44
+ result, `Node.branch` decides on one, and passing the value into a payload field
45
+ or reading a field off it stays a reference.
46
+
47
+ `Boolean(value)` and `value === other` cannot be refused, because JavaScript
48
+ exposes no trap for them. They reveal only proxy truthiness or identity and
49
+ never the planned result, so a decision written that way is silently wrong. Use
50
+ `Node.branch`.
51
+
52
+ ### invalid_all_member
53
+
54
+ **What happened.** `Node.all` received something that is not a node at the named
55
+ member.
56
+
57
+ **What to change.** Pass a node. An object that merely carries `Node.TypeId` is
58
+ not one: the marker is a public string, and every combinator reads `ast` as
59
+ trusted topology, so a lookalike is refused exactly like any other non-node.
60
+
61
+ ### invalid_join
62
+
63
+ **What happened.** `Node.race`, `Node.any`, or `Node.quorum` received a non-node
64
+ member, no members, or a quorum count outside 1 to its member count. `node` names
65
+ the member, or the combinator for a count.
66
+
67
+ **What to change.** Pass at least one node, and a whole-number quorum its members
68
+ can reach.
69
+
70
+ ### invalid_continuation
71
+
72
+ **What happened.** A branch arm, a catch arm, or a direct `andThen`
73
+ continuation did not return a node. `node` says which: `branch/subject/then`,
74
+ `catch/subject`, or `andThen/next`.
75
+
76
+ **What to change.** Return a node from the arm. An arm that has nothing to do
77
+ returns `Node.succeed(value)`.
78
+
79
+ ### invalid_priority
80
+
81
+ **What happened.** `Node.priority` received a value that is not a safe integer.
82
+
83
+ **What to change.** Pass a safe integer. No ordering could compare anything
84
+ else.
85
+
86
+ ### invalid_payload
87
+
88
+ **What happened.** A payload member cannot be captured as inert JSON without
89
+ running author code or losing identity: an accessor, or a prototype with no
90
+ `toJSON`. The message names the path:
91
+
92
+ ```text
93
+ Plan payload at $.config.timeout is an accessor
94
+ ```
95
+
96
+ **What to change.** Pass data. Read the accessor yourself and put the value in
97
+ the payload, or give the object a `toJSON` that returns data.
98
+
99
+ ### cyclic_payload
100
+
101
+ **What happened.** A payload member has a `toJSON` that returns its own
102
+ receiver. It once collapsed to an empty object, which keyed two different values
103
+ identically.
104
+
105
+ **What to change.** Return data from `toJSON`, not the object itself.
106
+
107
+ ### The codes this package does not raise
108
+
109
+ `GraphBuildErrorCode` also carries `recursion_requires_boundary`,
110
+ `placement_requires_boundary`, `graph_too_deep`, `duplicate_node`,
111
+ `payload_too_deep`, `unstable_callback`, `effect_outside_envelope`,
112
+ `effect_mode_widening`, `effect_tier_widening`, and
113
+ `capability_outside_grant`. Those come from
114
+ [`@smthrs/flow`](/api/flow)'s graph walk, which shares this vocabulary.
115
+
116
+ Six more come from [`@smthrs/core`](/api/core)'s graph builder, which raises
117
+ this error rather than a second class of its own: `write_conflict` for two work
118
+ nodes overlapping under `onConflict: "fail"`, where `node` names the first of
119
+ the pair and `Graph.conflicts` carries both; `missing_key_material` for a node
120
+ that reached key compilation without any; `dependency_cycle` for a dependency
121
+ set that cannot be ordered, where `node` names a node in the cycle;
122
+ `plan_too_large` for a node, edge, conflict, or effect-path limit crossed, where
123
+ `node` names the node whose admission crossed it; `payload_too_large` for one
124
+ plan value with more members than the bound, where `path` names the offending
125
+ value path; and `invalid_node` for a malformed node AST. All six are fatal.
126
+
127
+ The code set is closed so a caller can switch on it across all three packages.
128
+
129
+ The last four are the effect-authority refusals. `Effects` above defines the
130
+ rule: a declaration may narrow the envelope enclosing it and never widen it.
131
+
132
+ ```text
133
+ Flow "build/write-dist" at "root.flow" declares a path its caller's effect
134
+ envelope does not cover: etc/hosts.
135
+ ```
136
+
137
+ `effect_outside_envelope` names the escaping paths, `effect_mode_widening`
138
+ means an `expected` declaration sits inside a `hermetic` envelope, and
139
+ `effect_tier_widening` means a tier rose where it may only narrow. All three
140
+ are fatal: widen the caller's declared effects, or narrow the callee's.
141
+
142
+ `capability_outside_grant` is the one advisory code. The call still runs, with
143
+ the capability dropped, so declare it on the caller or stop requiring it on the
144
+ callee.
145
+
146
+ `unstable_callback` deserves its own note because the fix lives in your flow
147
+ body. `Graph.build` with `callbackIdentity: "stable"` refuses a callback whose
148
+ identity is process-local:
149
+
150
+ ```text
151
+ Callback run at "review" has process-local identity and cannot enter a stable graph.
152
+ ```
153
+
154
+ Wrap the callback in `Node.capture` and declare every semantic capture,
155
+ including the version of any imported implementation, so it keys by content
156
+ instead of by process.
157
+
158
+ ## PlanError
159
+
160
+ Failed, not thrown: `compile` and `append` return an Effect.
161
+
162
+ ### cycle
163
+
164
+ **What happened.** Either material dependencies close a cycle:
165
+
166
+ ```text
167
+ Plan cycle through node run-tests
168
+ ```
169
+
170
+ or a reader-after-writer edge contradicts an inferred ordering:
171
+
172
+ ```text
173
+ Plan cycle: node lint reads dist/bundle.js, which node bundle produces, so lint must
174
+ follow bundle, but bundle already depends on lint through bundle -> report -> lint
175
+ ```
176
+
177
+ **What to change.** For the first, break the dependency loop in the declarations.
178
+ For the second, inspect the inferred producer and `serialize` edges. If the
179
+ reader needs the new output, put the producer first or separate unrelated
180
+ overlapping writes. If it intentionally reads the earlier version, express that
181
+ sequence with a `Ref` or `Pending` dependency path from the writer to the reader.
182
+ Explicit read-before-write sequencing is valid; declaration order alone is not
183
+ an explicit version choice. Two unordered nodes that each need the other's new
184
+ output still form a cycle and must be split into stages.
185
+
186
+ ### unknown_dependency
187
+
188
+ **What happened.** A `Ref` or `Pending` names a node that is neither in the
189
+ drafts nor already in the plan.
190
+
191
+ **What to change.** Check the `from` spelling against the node ids in the same
192
+ compile. On an `append`, remember that the reference may name a node from an
193
+ earlier generation, which is legal, but not one that was never recorded.
194
+
195
+ ### duplicate_node
196
+
197
+ **What happened.** A draft reuses an id the plan already holds.
198
+
199
+ **What to change.** Rename one of them. A node id is durable dispatch identity,
200
+ so two nodes may never share one.
201
+
202
+ ### overlap_forbidden
203
+
204
+ **What happened.** Two nodes that both asked for the `fail` verdict genuinely
205
+ write overlapping paths, and no dependency path orders them:
206
+
207
+ ```text
208
+ Nodes bundle and minify both write dist/bundle.js
209
+ ```
210
+
211
+ **What to change.** `fail` is the verdict for a flow that promises disjointness,
212
+ so the declaration is the thing that broke the promise. Narrow one write set,
213
+ add a dependency that orders the pair, or change `conflictStrategy` to
214
+ `serialize` if serializing is acceptable after all.
215
+
216
+ ### invalid_effects
217
+
218
+ **What happened.** One path is declared as both a write and a removal.
219
+
220
+ **What to change.** Pick one. A removal mutates the world exactly as a write
221
+ does, and both plan passes fold the two together, so the pair is a contradiction
222
+ rather than a refinement.
223
+
224
+ ### invalid_node
225
+
226
+ **What happened.** The broadest code, covering everything a draft can get
227
+ wrong: an empty plan id, flow, or node id; a priority that is not a safe
228
+ integer; a `kind`, `conflictStrategy`, or `runtimeStrategy` outside its literal
229
+ set; key material or an effect declaration this release cannot decode, which
230
+ includes a path that is not workspace-relative; and a material payload holding
231
+ an accessor or an unsupported prototype.
232
+
233
+ The message names the node and, for a payload, the path:
234
+
235
+ ```text
236
+ Node run-tests has invalid material payload at $.config.timeout
237
+ ```
238
+
239
+ **What to change.** Read the message. Every case names the exact field. For a
240
+ path refusal, [Declare the files a node touches](./guides/declare-file-effects.md)
241
+ lists the forms `workspaceRelative` rejects and why each one is a correctness
242
+ hole rather than a style rule.
243
+
244
+ ### graph_too_large
245
+
246
+ **What happened.** The plan would hold more than `Plan.maximumPlanNodes` nodes,
247
+ which is 10,000, or effect analysis exceeded 250,000 candidate pairs or
248
+ 10,000,000 work units.
249
+
250
+ ```text
251
+ A plan may contain at most 10000 nodes, received 12000
252
+ ```
253
+
254
+ **What to change.** Split the work across flow boundaries. The node ceiling is checked
255
+ before effect analysis. A separate work budget bounds dense conflicts and
256
+ reachability updates, and applies across generations during verification.
257
+
258
+ ### invalid_plan
259
+
260
+ **What happened.** `Plan.verify` decoded a plan whose content does not hold
261
+ together. Every [`@smthrs/plan-store`](/api/plan-store) read and write runs it,
262
+ so this surfaces from `record`, `append`, and `get`, never from `compile`:
263
+
264
+ ```text
265
+ Plan approval digest does not match its compiled content
266
+ Plan nodes do not match their compiled keys, effects, ordering, or generations
267
+ Node run-tests has an invalid or out-of-order generation
268
+ ```
269
+
270
+ **What to change.** Pass the value `Plan.compile` or `Plan.append` returned.
271
+ A plan that fails here was assembled by hand, mutated after compiling, or
272
+ edited in the database. The store raises a `PlanStoreError` of the same name for
273
+ its own preconditions.
274
+
275
+ ## KeyMaterialError
276
+
277
+ ### non_content_material
278
+
279
+ **What happened.** `StepKey.fromKeyMaterial` or `StepKey.dispatchIdentity` was
280
+ asked for a key on material whose `kind` is `compensable` or `irreversible`.
281
+
282
+ **What to change.** Only `sealed` work gets a cross-run content key. Use
283
+ `StepKey.planIdentity` when compiling any tier's declaration, and
284
+ `StepKey.ordinal` when dispatching the other tiers: it mints a run-local key.
285
+ Do not change an action to `sealed` merely to make it plannable.
286
+
287
+ ### missing_dependency
288
+
289
+ **What happened.** A `Ref` or `Pending` resolved to nothing usable: no entry for
290
+ `from`, an entry that is not an own data property, or a digest that is not a
291
+ string. On the dispatch path, a missing settled result.
292
+
293
+ **What to change.** Supply every dependency the material names. A `Ref` naming
294
+ `toString` or `constructor` lands here on purpose, because a prototype-named
295
+ dependency would otherwise resolve to an inherited function and collide.
296
+
297
+ If you see this from a scheduler, a dependent dispatched before its dependency
298
+ settled. The halt rule is what guarantees it cannot: a dependent of failed or
299
+ skipped work never dispatches.
300
+
301
+ ### invalid_environment
302
+
303
+ **What happened.** An `EnvironmentIdentity` that is neither properly declared
304
+ nor properly run-scoped:
305
+
306
+ ```text
307
+ Undeclared environment identity requires a non-empty runScope
308
+ Declared environment identity must not include runScope
309
+ Environment identity requires a boolean declared field
310
+ ```
311
+
312
+ **What to change.** `declared: true` carries no `runScope`. `declared: false`
313
+ carries a non-empty one, which pins the key to a single run so a step whose
314
+ environment is unknown never serves a cross-run hit.