@smthrs/plan 1.0.0-rc.6 → 1.0.0-rc.8

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.
@@ -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, how a step whose positional id moved is paired by its key, 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.8 @smthrs/flow@1.0.0-rc.8 @effect/platform-node
21
+ ```
22
+
23
+ The outputs below are real output from the release that install line names.
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
@@ -37,15 +90,16 @@ body, so that node re-keyed and the report says which field did it.
37
90
 
38
91
  ## The verdict is the key
39
92
 
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:
93
+ Two nodes with the same key are the same step, under the same id or a moved
94
+ one. Nothing else needs saying, and `diff` says nothing else. Four lists come
95
+ back:
42
96
 
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. |
97
+ | Field | Contents |
98
+ | ----------- | --------------------------------------------------------------------- |
99
+ | `added` | Node ids in `next` that pair with no node in `previous`. |
100
+ | `removed` | Node ids in `previous` that pair with no node in `next`. |
101
+ | `rekeyed` | Nodes paired by id whose key moved, with `from`, `to`, and `changed`. |
102
+ | `unchanged` | Node ids in `next` paired with a node of the same key. |
49
103
 
50
104
  ## The attribution is for a human
51
105
 
@@ -70,6 +124,80 @@ reported as nothing changed.
70
124
  key moved for a reason the comparison does not model. Treat an empty `changed`
71
125
  on a re-keyed node as a bug report, not as noise.
72
126
 
127
+ ## Node ids are positional
128
+
129
+ A node id is the node's structural address in the body, such as
130
+ `root.flow.andThen`, not the name of its step. Adding a step to a flow can move
131
+ the steps around it to new addresses while their keys stay the same. `diff`
132
+ therefore pairs nodes in three passes, each over the nodes no earlier pass
133
+ paired:
134
+
135
+ 1. The same id with the same key: unchanged.
136
+ 2. The same key under a different id: the same step at a new address, reported
137
+ as unchanged under its id in `next`.
138
+ 3. The same id with a different key: re-keyed.
139
+
140
+ Whatever is left in `next` is `added`, and whatever is left in `previous` is
141
+ `removed`. `added`, `rekeyed`, and `unchanged` name nodes by their id in
142
+ `next`; `removed` names nodes by their id in `previous`.
143
+
144
+ Create `inserted.ts`. It compiles two steps, then the same two steps with a
145
+ third inserted before them, so every id shifts by one:
146
+
147
+ ```ts
148
+ import * as NodeCrypto from "@effect/platform-node/NodeCrypto"
149
+ import * as KeyMaterial from "@smthrs/plan/KeyMaterial"
150
+ import * as Plan from "@smthrs/plan/Plan"
151
+ import * as PlanDiff from "@smthrs/plan/PlanDiff"
152
+ import * as Effect from "effect/Effect"
153
+
154
+ /** A step whose id is its position, as a flow's structural ids are. */
155
+ const step = (position: number, action: string): Plan.NodeDraft => ({
156
+ id: `step-${position}`,
157
+ material: {
158
+ version: KeyMaterial.version,
159
+ kind: "sealed",
160
+ body: { action },
161
+ inputs: [],
162
+ layers: [],
163
+ capabilities: []
164
+ },
165
+ effects: { reads: [], writes: [], boundaryMode: "hard" }
166
+ })
167
+
168
+ const compile = (actions: ReadonlyArray<string>) =>
169
+ Plan.compile({
170
+ planId: "checks-1",
171
+ flow: "example/Checks",
172
+ nodes: actions.map((action, index) => step(index, action))
173
+ })
174
+
175
+ const program = Effect.gen(function*() {
176
+ const previous = yield* compile(["lint", "test"])
177
+ const next = yield* compile(["format", "lint", "test"])
178
+ console.log(PlanDiff.diff(previous, next))
179
+ }).pipe(Effect.provide(NodeCrypto.layer))
180
+
181
+ await Effect.runPromise(program)
182
+ ```
183
+
184
+ ```bash
185
+ node inserted.ts
186
+ ```
187
+
188
+ ```text
189
+ {
190
+ added: [ 'step-0' ],
191
+ removed: [],
192
+ rekeyed: [],
193
+ unchanged: [ 'step-1', 'step-2' ]
194
+ }
195
+ ```
196
+
197
+ Only the inserted `format` step is new. `lint` and `test` moved from `step-0`
198
+ and `step-1` to `step-1` and `step-2` with the keys they had, so they come back
199
+ as `unchanged`.
200
+
73
201
  ## Comparison uses the key's own JSON semantics
74
202
 
75
203
  Each compared field is projected through the same JSON mirror the keys are
@@ -88,7 +216,9 @@ The pairing this is built for is a re-plan of the same flow: compile the flow
88
216
  again against the current source and compare the result with the plan an
89
217
  operator last saw. `added` and `removed` are the shape change, `rekeyed` is the
90
218
  work that will not come from cache, and `unchanged` is everything a cache hit
91
- will skip.
219
+ will skip. That reading holds when a step is added or removed and ids move,
220
+ because `diff` pairs a moved step by its key, as
221
+ [Node ids are positional](#node-ids-are-positional) shows.
92
222
 
93
223
  The command line prints the same comparison through
94
224
  [`smthrs flow plan`](/cli/flow), and the plan card it renders is what
@@ -96,6 +226,8 @@ The command line prints the same comparison through
96
226
 
97
227
  ## Next
98
228
 
229
+ - [Inspect the plan a body builds](../../../flow/docs/guides/inspect-the-plan.md):
230
+ build a flow's graph and read its nodes before anything runs.
99
231
  - [Step keys](../concepts/step-keys.md): why an upstream edit re-keys a
100
232
  downstream node whose own declaration did not change.
101
233
  - [The plan value](../concepts/plan-value.md): the digests a diff sits beside.
@@ -13,7 +13,11 @@ sidebar:
13
13
 
14
14
  ## Install
15
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).
16
+ ```bash
17
+ npm install @smthrs/plan@next effect
18
+ ```
19
+
20
+ The `next` tag carries the 1.0 release candidates.
17
21
 
18
22
  That is enough to build node graphs, compile plans, and diff them. Compiling
19
23
  asks for Effect's `Crypto` service, which a platform package supplies:
@@ -17,7 +17,7 @@ Nothing here executes a node or writes a row. A plan is inert: persisting one is
17
17
  ## Prerequisites
18
18
 
19
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).
20
+ - A project with the packages installed: `npm install @smthrs/plan@next @effect/platform-node effect`.
21
21
 
22
22
  `@effect/platform-node` supplies Effect's `Crypto` service, which is the only
23
23
  thing compiling a plan asks for.
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.6",
4
+ "version": "1.0.0-rc.8",
5
5
  "license": "MIT",
6
6
  "smthrs": {
7
7
  "group": "engine"
@@ -218,9 +218,9 @@
218
218
  "tag": "next"
219
219
  },
220
220
  "dependencies": {
221
- "@smthrs/crypto": "1.0.0-rc.6",
222
- "@smthrs/keys": "1.0.0-rc.6",
223
- "@smthrs/canonical": "1.0.0-rc.6"
221
+ "@smthrs/crypto": "1.0.0-rc.8",
222
+ "@smthrs/keys": "1.0.0-rc.8",
223
+ "@smthrs/canonical": "1.0.0-rc.8"
224
224
  },
225
225
  "devDependencies": {
226
226
  "@effect/language-service": "0.87.2",
package/src/PlanDiff.ts CHANGED
@@ -6,10 +6,10 @@
6
6
  * 'what is different about this run' is a set comparison, not archaeology."
7
7
  * This module is the set comparison.
8
8
  *
9
- * The **verdict** is the key: two nodes with the same id and the same key are
10
- * the same step and nothing else needs saying. The **attribution** —
11
- * `changed: ["input[1]"]` — is a report for a human, derived by comparing the
12
- * declarations field by field. It is deliberately not part of any digest.
9
+ * The **verdict** is the key: two nodes with the same key are the same step,
10
+ * under the same id or a moved one, and nothing else needs saying. The
11
+ * **attribution** — `changed: ["input[1]"]` — is a report for a human, derived
12
+ * by comparing the declarations field by field. It is deliberately not part of any digest.
13
13
  *
14
14
  * @since 0.1.0
15
15
  */
@@ -157,24 +157,65 @@ const changedFields = (
157
157
  /**
158
158
  * Compares a plan against the last plan for the same flow.
159
159
  *
160
+ * Nodes pair in three passes, each over the nodes no earlier pass paired:
161
+ * the same id with the same key, then the same key under any id, then the
162
+ * same id with a different key. The second pass exists because flow node ids
163
+ * can be positional: inserting a step shifts the ids after it while their
164
+ * keys stay equal, and that is one added step, not a re-key of every later
165
+ * one (#3905).
166
+ *
167
+ * `added`, `rekeyed`, and `unchanged` name nodes by their id in `next`;
168
+ * `removed` names nodes by their id in `previous`. A moved id can therefore
169
+ * appear once in `removed` or `added` and once in `unchanged`.
170
+ *
171
+ * @param previous - The last plan recorded for the flow.
172
+ * @param next - The plan to compare against it.
173
+ * @returns The added, removed, re-keyed, and unchanged node ids, each in its
174
+ * plan's node order.
160
175
  * @since 0.1.0
161
176
  * @category constructors
162
177
  * @slop
163
178
  */
164
179
  export const diff = (previous: Plan.Plan, next: Plan.Plan): PlanDiff => {
180
+ // pairs maps a next node to the previous node it is the same step as.
181
+ const pairs = new Map<Plan.PlanNode, Plan.PlanNode>()
182
+ const paired = new Set<Plan.PlanNode>()
183
+ const pair = (node: Plan.PlanNode, original: Plan.PlanNode) => {
184
+ pairs.set(node, original)
185
+ paired.add(original)
186
+ }
165
187
  const before = new Map(previous.nodes.map((node) => [node.id, node]))
166
- const after = new Map(next.nodes.map((node) => [node.id, node]))
188
+ for (const node of next.nodes) {
189
+ const original = before.get(node.id)
190
+ if (original !== undefined && original.key === node.key) pair(node, original)
191
+ }
192
+ const byKey = new Map<string, Array<Plan.PlanNode>>()
193
+ for (const node of previous.nodes) {
194
+ if (paired.has(node)) continue
195
+ const same = byKey.get(node.key)
196
+ if (same === undefined) byKey.set(node.key, [node])
197
+ else same.push(node)
198
+ }
199
+ for (const node of next.nodes) {
200
+ if (pairs.has(node)) continue
201
+ const original = byKey.get(node.key)?.shift()
202
+ if (original !== undefined) pair(node, original)
203
+ }
167
204
  const rekeyedIds = new Set<string>()
168
205
  for (const node of next.nodes) {
206
+ if (pairs.has(node)) continue
169
207
  const original = before.get(node.id)
170
- if (original !== undefined && original.key !== node.key) rekeyedIds.add(node.id)
208
+ if (original !== undefined && !paired.has(original)) {
209
+ pair(node, original)
210
+ rekeyedIds.add(node.id)
211
+ }
171
212
  }
172
213
  const added: Array<string> = []
173
214
  const rekeyed: Array<Rekeyed> = []
174
215
  const unchanged: Array<string> = []
175
216
  const sameField = fieldComparison()
176
217
  for (const node of next.nodes) {
177
- const original = before.get(node.id)
218
+ const original = pairs.get(node)
178
219
  if (original === undefined) {
179
220
  added.push(node.id)
180
221
  continue
@@ -192,7 +233,7 @@ export const diff = (previous: Plan.Plan, next: Plan.Plan): PlanDiff => {
192
233
  }
193
234
  return {
194
235
  added,
195
- removed: previous.nodes.filter((node) => !after.has(node.id)).map((node) => node.id),
236
+ removed: previous.nodes.filter((node) => !paired.has(node)).map((node) => node.id),
196
237
  rekeyed,
197
238
  unchanged
198
239
  }
package/src/StepKey.ts CHANGED
@@ -202,31 +202,36 @@ export interface DigestMemo {
202
202
  */
203
203
  export const makeDigestMemo = (): DigestMemo => {
204
204
  const entries = new Map<string, Deferred.Deferred<StepKey, Schema.SchemaError>>()
205
+ // Registering the entry and installing its exit handler run uninterruptibly,
206
+ // so an interrupt can never land between them and strand a Deferred that no
207
+ // fiber will complete. The wait and the computation stay interruptible.
205
208
  const digest: DigestMemo["digest"] = (from, path, compute) =>
206
- Effect.suspend(() => {
207
- const address = JSON.stringify([from, path])
208
- const existing = entries.get(address)
209
- if (existing !== undefined) {
210
- return Effect.flatMap(Effect.exit(Deferred.await(existing)), (exit) => {
211
- if (Exit.isSuccess(exit)) return Effect.succeed(exit.value)
212
- // A waiter did not request the leader's cancellation. It competes to
213
- // become the replacement leader instead of inheriting that interrupt.
214
- return Cause.hasInterrupts(exit.cause) ? digest(from, path, compute) : Effect.failCause(exit.cause)
215
- })
216
- }
217
- const pending = Deferred.makeUnsafe<StepKey, Schema.SchemaError>()
218
- entries.set(address, pending)
219
- return compute.pipe(
220
- Effect.onExit((exit) =>
221
- Effect.gen(function*() {
222
- if (Exit.isFailure(exit) && entries.get(address) === pending) {
223
- entries.delete(address)
224
- }
225
- yield* Deferred.done(pending, exit)
226
- })
209
+ Effect.uninterruptibleMask((restore) =>
210
+ Effect.suspend(() => {
211
+ const address = JSON.stringify([from, path])
212
+ const existing = entries.get(address)
213
+ if (existing !== undefined) {
214
+ return restore(Effect.flatMap(Effect.exit(Deferred.await(existing)), (exit) => {
215
+ if (Exit.isSuccess(exit)) return Effect.succeed(exit.value)
216
+ // A waiter did not request the leader's cancellation. It competes to
217
+ // become the replacement leader instead of inheriting that interrupt.
218
+ return Cause.hasInterrupts(exit.cause) ? digest(from, path, compute) : Effect.failCause(exit.cause)
219
+ }))
220
+ }
221
+ const pending = Deferred.makeUnsafe<StepKey, Schema.SchemaError>()
222
+ entries.set(address, pending)
223
+ return restore(compute).pipe(
224
+ Effect.onExit((exit) =>
225
+ Effect.gen(function*() {
226
+ if (Exit.isFailure(exit) && entries.get(address) === pending) {
227
+ entries.delete(address)
228
+ }
229
+ yield* Deferred.done(pending, exit)
230
+ })
231
+ )
227
232
  )
228
- )
229
- })
233
+ })
234
+ )
230
235
  return { digest }
231
236
  }
232
237