@smthrs/plan 1.0.0-rc.7 → 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, why node ids are positional, 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
  ---
@@ -17,10 +17,10 @@ add this package and `@smthrs/flow` at the version of `@smthrs/flows` you have,
17
17
  plus the platform package:
18
18
 
19
19
  ```bash
20
- npm install @smthrs/plan@1.0.0-rc.7 @smthrs/flow@1.0.0-rc.7 @effect/platform-node
20
+ npm install @smthrs/plan@1.0.0-rc.8 @smthrs/flow@1.0.0-rc.8 @effect/platform-node
21
21
  ```
22
22
 
23
- The outputs below are real output from 1.0.0-rc.6.
23
+ The outputs below are real output from the release that install line names.
24
24
 
25
25
  ## Compare a re-plan
26
26
 
@@ -90,15 +90,16 @@ body, so that node re-keyed and the report says which field did it.
90
90
 
91
91
  ## The verdict is the key
92
92
 
93
- Two nodes with the same id and the same key are the same step. Nothing else
94
- 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:
95
96
 
96
- | Field | Contents |
97
- | ----------- | ------------------------------------------------------------------------ |
98
- | `added` | Node ids present in `next` and absent from `previous`. |
99
- | `removed` | Node ids present in `previous` and absent from `next`. |
100
- | `rekeyed` | Nodes present in both whose key moved, with `from`, `to`, and `changed`. |
101
- | `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. |
102
103
 
103
104
  ## The attribution is for a human
104
105
 
@@ -126,90 +127,76 @@ on a re-keyed node as a bug report, not as noise.
126
127
  ## Node ids are positional
127
128
 
128
129
  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.
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:
133
134
 
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:
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:
136
146
 
137
147
  ```ts
138
148
  import * as NodeCrypto from "@effect/platform-node/NodeCrypto"
139
- import { Action, Flow, Graph } from "@smthrs/flow"
140
- import { Node } from "@smthrs/plan"
149
+ import * as KeyMaterial from "@smthrs/plan/KeyMaterial"
141
150
  import * as Plan from "@smthrs/plan/Plan"
142
151
  import * as PlanDiff from "@smthrs/plan/PlanDiff"
143
152
  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
153
 
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
- )
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" }
165
166
  })
166
167
 
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
- }
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
+ })
175
174
 
176
175
  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
- }
176
+ const previous = yield* compile(["lint", "test"])
177
+ const next = yield* compile(["format", "lint", "test"])
178
+ console.log(PlanDiff.diff(previous, next))
190
179
  }).pipe(Effect.provide(NodeCrypto.layer))
191
180
 
192
181
  await Effect.runPromise(program)
193
182
  ```
194
183
 
195
184
  ```bash
196
- node positional.ts
185
+ node inserted.ts
197
186
  ```
198
187
 
199
188
  ```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
189
+ {
190
+ added: [ 'step-0' ],
191
+ removed: [],
192
+ rekeyed: [],
193
+ unchanged: [ 'step-1', 'step-2' ]
194
+ }
206
195
  ```
207
196
 
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.
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`.
213
200
 
214
201
  ## Comparison uses the key's own JSON semantics
215
202
 
@@ -229,8 +216,8 @@ The pairing this is built for is a re-plan of the same flow: compile the flow
229
216
  again against the current source and compare the result with the plan an
230
217
  operator last saw. `added` and `removed` are the shape change, `rekeyed` is the
231
218
  work that will not come from cache, and `unchanged` is everything a cache hit
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
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
234
221
  [Node ids are positional](#node-ids-are-positional) shows.
235
222
 
236
223
  The command line prints the same comparison through
@@ -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.7",
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.7",
222
- "@smthrs/keys": "1.0.0-rc.7",
223
- "@smthrs/canonical": "1.0.0-rc.7"
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