@smthrs/plan 1.0.0-rc.5 → 1.0.0-rc.7

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 (48) hide show
  1. package/CHANGELOG.md +2 -0
  2. package/README.md +18 -18
  3. package/THIRD_PARTY_NOTICES.md +48 -0
  4. package/dist/cjs/KeyMaterial.d.ts +1 -1
  5. package/dist/cjs/KeyMaterial.js.map +1 -1
  6. package/dist/cjs/Node.d.ts +71 -0
  7. package/dist/cjs/Node.d.ts.map +1 -1
  8. package/dist/cjs/Node.js +7 -0
  9. package/dist/cjs/Node.js.map +2 -2
  10. package/dist/cjs/StepKey.d.ts +1 -1
  11. package/dist/cjs/StepKey.js.map +1 -1
  12. package/dist/cjs/internal/SchemaIdentity.d.ts +140 -0
  13. package/dist/cjs/internal/SchemaIdentity.d.ts.map +1 -0
  14. package/dist/cjs/internal/SchemaIdentity.js +718 -0
  15. package/dist/cjs/internal/SchemaIdentity.js.map +7 -0
  16. package/dist/cjs/internal/node.d.ts +1 -1
  17. package/dist/cjs/internal/node.d.ts.map +1 -1
  18. package/dist/cjs/internal/node.js +2 -1
  19. package/dist/cjs/internal/node.js.map +2 -2
  20. package/dist/esm/KeyMaterial.d.ts +1 -1
  21. package/dist/esm/KeyMaterial.js +1 -1
  22. package/dist/esm/KeyMaterial.js.map +1 -1
  23. package/dist/esm/Node.d.ts +71 -0
  24. package/dist/esm/Node.d.ts.map +1 -1
  25. package/dist/esm/Node.js +46 -0
  26. package/dist/esm/Node.js.map +1 -1
  27. package/dist/esm/StepKey.d.ts +1 -1
  28. package/dist/esm/StepKey.js +1 -1
  29. package/dist/esm/StepKey.js.map +1 -1
  30. package/dist/esm/internal/SchemaIdentity.d.ts +140 -0
  31. package/dist/esm/internal/SchemaIdentity.d.ts.map +1 -0
  32. package/dist/esm/internal/SchemaIdentity.js +1002 -0
  33. package/dist/esm/internal/SchemaIdentity.js.map +1 -0
  34. package/dist/esm/internal/node.d.ts +1 -1
  35. package/dist/esm/internal/node.d.ts.map +1 -1
  36. package/dist/esm/internal/node.js +3 -2
  37. package/dist/esm/internal/node.js.map +1 -1
  38. package/docs/README.md +1 -1
  39. package/docs/api.md +11 -1
  40. package/docs/concepts/authoring-ast.md +2 -1
  41. package/docs/guides/diff-two-plans.md +158 -13
  42. package/docs/installation.md +3 -3
  43. package/package.json +9 -8
  44. package/src/KeyMaterial.ts +1 -1
  45. package/src/Node.ts +79 -0
  46. package/src/StepKey.ts +1 -1
  47. package/src/internal/SchemaIdentity.ts +1096 -0
  48. package/src/internal/node.ts +3 -2
@@ -1,6 +1,6 @@
1
1
  ---
2
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."
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, why node ids are positional, and why the report is in no digest."
4
4
  sidebar:
5
5
  order: 5
6
6
  ---
@@ -8,28 +8,81 @@ sidebar:
8
8
  "What is different about this run" is a set comparison, not archaeology.
9
9
  `PlanDiff.diff` is that comparison as a value.
10
10
 
11
+ ## Before you start
12
+
13
+ The examples run with `node <file>.ts` on Node.js 26.4.0 or later and need no
14
+ engine, database, or jj. Compiling a plan needs Effect's `Crypto` service, which
15
+ `@effect/platform-node` supplies. In a project that installed `@smthrs/flows`,
16
+ add this package and `@smthrs/flow` at the version of `@smthrs/flows` you have,
17
+ plus the platform package:
18
+
19
+ ```bash
20
+ npm install @smthrs/plan@1.0.0-rc.7 @smthrs/flow@1.0.0-rc.7 @effect/platform-node
21
+ ```
22
+
23
+ The outputs below are real output from 1.0.0-rc.6.
24
+
25
+ ## Compare a re-plan
26
+
27
+ Create `diff.ts`. It compiles the two drafts from
28
+ [Compile drafts into a plan](./compile-a-plan.md), edits one field of one
29
+ draft's body, compiles again, and compares:
30
+
11
31
  ```ts
12
32
  import * as NodeCrypto from "@effect/platform-node/NodeCrypto"
33
+ import * as KeyMaterial from "@smthrs/plan/KeyMaterial"
13
34
  import * as Plan from "@smthrs/plan/Plan"
14
35
  import * as PlanDiff from "@smthrs/plan/PlanDiff"
15
- import type * as Crypto from "effect/Crypto"
16
36
  import * as Effect from "effect/Effect"
17
37
 
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>
38
+ const readPr: Plan.NodeDraft = {
39
+ id: "read-pr",
40
+ material: {
41
+ version: KeyMaterial.version,
42
+ kind: "sealed",
43
+ body: { action: "read-pr", pr: 4821 },
44
+ inputs: [],
45
+ layers: [],
46
+ capabilities: ["net:get"]
47
+ },
48
+ effects: { reads: [], writes: ["pr.json"], boundaryMode: "hard" }
49
+ }
50
+
51
+ const runTests: Plan.NodeDraft = {
52
+ id: "run-tests",
53
+ material: {
54
+ version: KeyMaterial.version,
55
+ kind: "sealed",
56
+ body: { action: "run-tests" },
57
+ inputs: [{ _tag: "Ref", from: "read-pr", path: [] }],
58
+ layers: [],
59
+ capabilities: []
60
+ },
61
+ effects: { reads: ["pr.json"], writes: ["report.json"], boundaryMode: "hard" }
62
+ }
63
+
64
+ const compile = (nodes: ReadonlyArray<Plan.NodeDraft>) =>
65
+ Plan.compile({ planId: "review-4821", flow: "example/Review", nodes })
22
66
 
23
67
  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
- })
68
+ const before = yield* compile([readPr, runTests])
69
+ const after = yield* compile([
70
+ readPr,
71
+ { ...runTests, material: { ...runTests.material, body: { action: "run-tests", jobs: 4 } } }
72
+ ])
30
73
  const changes: PlanDiff.PlanDiff = PlanDiff.diff(before, after)
31
74
  return changes.rekeyed.map((entry) => `${entry.id}: ${entry.changed.join(", ")}`)
32
75
  }).pipe(Effect.provide(NodeCrypto.layer))
76
+
77
+ console.log(await Effect.runPromise(compare))
78
+ ```
79
+
80
+ ```bash
81
+ node diff.ts
82
+ ```
83
+
84
+ ```text
85
+ [ 'run-tests: body' ]
33
86
  ```
34
87
 
35
88
  `compare` answers `["run-tests: body"]`. The edit moved one field of one node's
@@ -70,6 +123,94 @@ reported as nothing changed.
70
123
  key moved for a reason the comparison does not model. Treat an empty `changed`
71
124
  on a re-keyed node as a bug report, not as noise.
72
125
 
126
+ ## Node ids are positional
127
+
128
+ A node id is the node's structural address in the body, such as
129
+ `root.flow.andThen`, not the name of its step. Appending a step to a flow
130
+ nests the steps before it one level deeper, so a diff by id reports an
131
+ unchanged step as `added` under its new address and reports the addresses it
132
+ left as `rekeyed`. The step's key does not change.
133
+
134
+ Create `positional.ts`. It compiles a two-step flow and the same flow with a
135
+ third step appended, diffs them, then matches the steps by key:
136
+
137
+ ```ts
138
+ import * as NodeCrypto from "@effect/platform-node/NodeCrypto"
139
+ import { Action, Flow, Graph } from "@smthrs/flow"
140
+ import { Node } from "@smthrs/plan"
141
+ import * as Plan from "@smthrs/plan/Plan"
142
+ import * as PlanDiff from "@smthrs/plan/PlanDiff"
143
+ import * as Effect from "effect/Effect"
144
+ import * as Schema from "effect/Schema"
145
+
146
+ const Draft = Action.make("fix/Draft", { payload: { issue: Schema.String }, success: Schema.String })
147
+ const Test = Action.make("fix/Test", { payload: { plan: Schema.String }, success: Schema.String })
148
+ const Report = Action.make("fix/Report", { payload: { result: Schema.String }, success: Schema.String })
149
+
150
+ const before = Flow.make("fix/Fix", {
151
+ payload: { issue: Schema.String },
152
+ success: Schema.String,
153
+ body: (input) => Draft.call(input).pipe(Node.bindPlanned((plan) => Test.call({ plan })))
154
+ })
155
+
156
+ /** The same flow with one step appended. Draft and Test are unchanged. */
157
+ const after = Flow.make("fix/Fix", {
158
+ payload: { issue: Schema.String },
159
+ success: Schema.String,
160
+ body: (input) =>
161
+ Draft.call(input).pipe(
162
+ Node.bindPlanned((plan) => Test.call({ plan })),
163
+ Node.bindPlanned((result) => Report.call({ result }))
164
+ )
165
+ })
166
+
167
+ const planOf = (flow: Parameters<typeof Graph.build>[0]) =>
168
+ Plan.compile({ planId: "fix-1", flow: "fix/Fix", nodes: Graph.drafts(Graph.build(flow, { issue: "#42" })) })
169
+
170
+ /** The action a step node runs, read from its key material. */
171
+ const actionOf = (node: Plan.PlanNode) => {
172
+ const body = node.material.body
173
+ return typeof body === "object" && body !== null && "action" in body ? String(body.action) : undefined
174
+ }
175
+
176
+ const program = Effect.gen(function*() {
177
+ const previous = yield* planOf(before)
178
+ const next = yield* planOf(after)
179
+ const changes = PlanDiff.diff(previous, next)
180
+ console.log("added ", changes.added)
181
+ console.log("rekeyed ", changes.rekeyed.map((entry) => entry.id))
182
+ console.log("unchanged", changes.unchanged)
183
+
184
+ // Match steps by key, not by id: an equal key is the same step.
185
+ const recorded = new Set(previous.nodes.map((node) => node.key))
186
+ for (const node of next.nodes) {
187
+ const action = actionOf(node)
188
+ if (action !== undefined) console.log(`${action.padEnd(11)} ${recorded.has(node.key) ? "same key" : "new key"}`)
189
+ }
190
+ }).pipe(Effect.provide(NodeCrypto.layer))
191
+
192
+ await Effect.runPromise(program)
193
+ ```
194
+
195
+ ```bash
196
+ node positional.ts
197
+ ```
198
+
199
+ ```text
200
+ added [ 'root.flow.andThen.andThen', 'root.flow.andThen.then' ]
201
+ rekeyed [ 'root.flow.andThen', 'root.flow.then', 'root.flow', 'root' ]
202
+ unchanged []
203
+ fix/Draft same key
204
+ fix/Test same key
205
+ fix/Report new key
206
+ ```
207
+
208
+ Only `Report` is new. `Draft` and `Test` moved to `root.flow.andThen.andThen`
209
+ and `root.flow.andThen.then` with the keys they had, so they come back under
210
+ `added`; their old ids now hold other nodes and come back under `rekeyed`. To
211
+ ask which steps changed, compare keys rather than ids, as the last loop does: a
212
+ key present in both plans is the same step.
213
+
73
214
  ## Comparison uses the key's own JSON semantics
74
215
 
75
216
  Each compared field is projected through the same JSON mirror the keys are
@@ -88,7 +229,9 @@ The pairing this is built for is a re-plan of the same flow: compile the flow
88
229
  again against the current source and compare the result with the plan an
89
230
  operator last saw. `added` and `removed` are the shape change, `rekeyed` is the
90
231
  work that will not come from cache, and `unchanged` is everything a cache hit
91
- will skip.
232
+ will skip. That reading holds while the shape stays put. Once a step is added
233
+ or removed, ids move, so read the work by key as
234
+ [Node ids are positional](#node-ids-are-positional) shows.
92
235
 
93
236
  The command line prints the same comparison through
94
237
  [`smthrs flow plan`](/cli/flow), and the plan card it renders is what
@@ -96,6 +239,8 @@ The command line prints the same comparison through
96
239
 
97
240
  ## Next
98
241
 
242
+ - [Inspect the plan a body builds](../../../flow/docs/guides/inspect-the-plan.md):
243
+ build a flow's graph and read its nodes before anything runs.
99
244
  - [Step keys](../concepts/step-keys.md): why an upstream edit re-keys a
100
245
  downstream node whose own declaration did not change.
101
246
  - [The plan value](../concepts/plan-value.md): the digests a diff sits beside.
@@ -8,8 +8,8 @@ sidebar:
8
8
  ## Requirements
9
9
 
10
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.
11
+ - `effect` `^4.0.4`. The package's schemas, layers, and error classes are
12
+ Effect values, so it uses the one `effect` your application installs.
13
13
 
14
14
  ## Install
15
15
 
@@ -19,7 +19,7 @@ That is enough to build node graphs, compile plans, and diff them. Compiling
19
19
  asks for Effect's `Crypto` service, which a platform package supplies:
20
20
 
21
21
  ```bash
22
- pnpm add @effect/platform-node@4.0.0-rc.115 @effect/platform-node-shared@4.0.0-rc.115
22
+ pnpm add @effect/platform-node @effect/platform-node-shared
23
23
  ```
24
24
 
25
25
  `@smthrs/crypto`, `@smthrs/canonical` and [`@smthrs/keys`](/api/keys) arrive as
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@smthrs/plan",
3
3
  "type": "module",
4
- "version": "1.0.0-rc.5",
4
+ "version": "1.0.0-rc.7",
5
5
  "license": "MIT",
6
6
  "smthrs": {
7
7
  "group": "engine"
@@ -207,6 +207,7 @@
207
207
  "dist/**/*.d.ts.map",
208
208
  "dist/**/package.json",
209
209
  "LICENSE",
210
+ "THIRD_PARTY_NOTICES.md",
210
211
  "README.md",
211
212
  "CHANGELOG.md",
212
213
  "docs/"
@@ -217,19 +218,19 @@
217
218
  "tag": "next"
218
219
  },
219
220
  "dependencies": {
220
- "@smthrs/crypto": "1.0.0-rc.5",
221
- "@smthrs/keys": "1.0.0-rc.5",
222
- "@smthrs/canonical": "1.0.0-rc.5"
221
+ "@smthrs/crypto": "1.0.0-rc.7",
222
+ "@smthrs/keys": "1.0.0-rc.7",
223
+ "@smthrs/canonical": "1.0.0-rc.7"
223
224
  },
224
225
  "devDependencies": {
225
226
  "@effect/language-service": "0.87.2",
226
- "@effect/platform-node": "4.0.0-rc.115",
227
- "@effect/vitest": "4.0.0-rc.115",
227
+ "@effect/platform-node": "4.0.4",
228
+ "@effect/vitest": "4.0.4",
228
229
  "@eslint/js": "9.39.5",
229
230
  "@types/node": "26.4.1",
230
231
  "@vitest/coverage-v8": "5.0.0",
231
232
  "dprint": "0.57.1",
232
- "effect": "4.0.0-rc.115",
233
+ "effect": "4.0.4",
233
234
  "esbuild": "0.28.2",
234
235
  "eslint": "9.39.5",
235
236
  "eslint-import-resolver-typescript": "4.4.5",
@@ -243,7 +244,7 @@
243
244
  "fast-check": "4.9.0"
244
245
  },
245
246
  "peerDependencies": {
246
- "effect": "4.0.0-rc.115"
247
+ "effect": "^4.0.4"
247
248
  },
248
249
  "scripts": {
249
250
  "lint": "eslint src --max-warnings=0 && dprint check",
@@ -7,7 +7,7 @@
7
7
  * lives here, above `@smthrs/keys`, so the key package stays a leaf.
8
8
  *
9
9
  * Governing contract: the step-key rules at
10
- * https://smithers.sh/docs/concepts/content-addressing. Material is computed with no I/O,
10
+ * https://github.com/smithersai/smithers/blob/main/packages/smithers/flows/plan/docs/concepts/step-keys.md. Material is computed with no I/O,
11
11
  * and two hardening rules shape it: the nominal, never shape-sniffed
12
12
  * `InputRef` and the hashed `version`.
13
13
  *
package/src/Node.ts CHANGED
@@ -31,9 +31,11 @@
31
31
  import { dual } from "effect/Function"
32
32
  import type * as Pipeable from "effect/Pipeable"
33
33
  import type * as Schema from "effect/Schema"
34
+ import type * as SchemaAST from "effect/SchemaAST"
34
35
  import type * as Types from "effect/Types"
35
36
  import { GraphBuildError } from "./GraphBuildError.ts"
36
37
  import * as internal from "./internal/node.ts"
38
+ import * as internalSchemaIdentity from "./internal/SchemaIdentity.ts"
37
39
  import * as Planned from "./Planned.ts"
38
40
 
39
41
  /**
@@ -843,6 +845,83 @@ export const catchFilter = (ast: Ast): Schema.Top | undefined => ast._tag === "C
843
845
  */
844
846
  export const functionIdentity = (operation: unknown): FunctionIdentity => internal.functionIdentity(operation)
845
847
 
848
+ /**
849
+ * The document a schema's identity is: JSON-Schema-shaped, and the same bytes
850
+ * under every `effect` release this package supports.
851
+ *
852
+ * @since 1.0.0
853
+ * @category engine
854
+ */
855
+ export type SchemaIdentity = internalSchemaIdentity.Document
856
+
857
+ /**
858
+ * The one option a schema's identity is taken under: whether an object closes
859
+ * over the keys it declares (`"error"`) or leaves others open (`"ignore"`,
860
+ * the default). It has the meaning it has in `Schema.toJsonSchemaDocument`.
861
+ *
862
+ * @since 1.0.0
863
+ * @category engine
864
+ */
865
+ export type SchemaIdentityOptions = internalSchemaIdentity.Options
866
+
867
+ /**
868
+ * The refusal `schemaIdentity` and `representationIdentity` raise for a schema
869
+ * a step key cannot be derived from. Its message is the one
870
+ * `effect@4.0.0-rc.115` gave for the same schema, where it gave one.
871
+ *
872
+ * @since 1.0.0
873
+ * @category errors
874
+ */
875
+ export const SchemaIdentityError = internalSchemaIdentity.SchemaIdentityError
876
+
877
+ /**
878
+ * The type of {@link SchemaIdentityError}.
879
+ *
880
+ * @since 1.0.0
881
+ * @category errors
882
+ */
883
+ export type SchemaIdentityError = internalSchemaIdentity.SchemaIdentityError
884
+
885
+ /**
886
+ * Identifies a schema by the constraints it documents, so a call re-keys when
887
+ * what its callee accepts or produces changes, and a catch re-keys when its
888
+ * filter does.
889
+ *
890
+ * The document is what `Schema.toJsonSchemaDocument(schema, options)`
891
+ * returned under `effect@4.0.0-rc.115`. It is printed by this package, not by
892
+ * the installed `effect`, because a later release prints a different document
893
+ * for the same schema and a moved identity re-keys every recorded step. Use
894
+ * it wherever the document is hashed, compared or pinned; a JSON Schema shown
895
+ * to a model or a person comes from `Schema.toJsonSchemaDocument`.
896
+ *
897
+ * Reserved for the packages that derive identities. Authors never call it; it
898
+ * throws for a schema that has no JSON Schema form.
899
+ *
900
+ * @since 1.0.0
901
+ * @category engine
902
+ */
903
+ export const schemaIdentity = (schema: Schema.Top, options?: SchemaIdentityOptions): SchemaIdentity =>
904
+ internalSchemaIdentity.schemaIdentity(schema, options)
905
+
906
+ /**
907
+ * Identifies a schema AST by its encoded representation, the material a
908
+ * sealed action's step key folds in for its success and error schemas.
909
+ *
910
+ * The value is what `SchemaRepresentation.toJson(SchemaRepresentation.toRepresentation(ast))`
911
+ * returned under `effect@4.0.0-rc.115`, so a later `effect` release does not
912
+ * re-key recorded steps. The one exception is `Schema.brand`: from
913
+ * `effect@4.0.4` on a brand is not part of the AST, so a declaration with a
914
+ * brand re-keys once.
915
+ *
916
+ * Reserved for the packages that derive step keys. Authors never call it; it
917
+ * throws for an AST that has no representation.
918
+ *
919
+ * @since 1.0.0
920
+ * @category engine
921
+ */
922
+ export const representationIdentity = (ast: SchemaAST.AST): Schema.Json =>
923
+ internalSchemaIdentity.representationIdentity(ast)
924
+
846
925
  /**
847
926
  * Binds a frozen plain copy of inert captures as the callback's this receiver.
848
927
  *
package/src/StepKey.ts CHANGED
@@ -22,7 +22,7 @@
22
22
  * resolved references, and the hashed `version`.
23
23
  *
24
24
  * Governing contract: the step-key rules at
25
- * https://smithers.sh/docs/concepts/content-addressing.
25
+ * https://github.com/smithersai/smithers/blob/main/packages/smithers/flows/plan/docs/concepts/step-keys.md.
26
26
  *
27
27
  * @since 0.1.0
28
28
  */