@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.
- package/README.md +3 -1
- package/dist/cjs/PlanDiff.d.ts +19 -4
- package/dist/cjs/PlanDiff.d.ts.map +1 -1
- package/dist/cjs/PlanDiff.js +29 -4
- package/dist/cjs/PlanDiff.js.map +2 -2
- package/dist/cjs/StepKey.d.ts.map +1 -1
- package/dist/cjs/StepKey.js +24 -22
- package/dist/cjs/StepKey.js.map +2 -2
- package/dist/cjs/internal/node.d.ts.map +1 -1
- package/dist/esm/PlanDiff.d.ts +19 -4
- package/dist/esm/PlanDiff.d.ts.map +1 -1
- package/dist/esm/PlanDiff.js +55 -8
- package/dist/esm/PlanDiff.js.map +1 -1
- package/dist/esm/StepKey.d.ts.map +1 -1
- package/dist/esm/StepKey.js +8 -5
- package/dist/esm/StepKey.js.map +1 -1
- package/dist/esm/internal/node.d.ts.map +1 -1
- package/docs/README.md +3 -1
- package/docs/api.md +1 -1
- package/docs/guides/diff-two-plans.md +61 -74
- package/docs/installation.md +5 -1
- package/docs/quickstart.md +1 -1
- package/package.json +4 -4
- package/src/PlanDiff.ts +49 -8
- package/src/StepKey.ts +28 -23
|
@@ -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,
|
|
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.
|
|
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
|
|
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
|
|
94
|
-
needs saying, and `diff` says nothing else. Four lists come
|
|
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
|
|
99
|
-
| `removed` | Node ids
|
|
100
|
-
| `rekeyed` | Nodes
|
|
101
|
-
| `unchanged` | Node ids
|
|
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.
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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
|
-
|
|
135
|
-
|
|
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
|
|
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
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
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
|
|
168
|
-
Plan.compile({
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
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*
|
|
178
|
-
const next = yield*
|
|
179
|
-
|
|
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
|
|
185
|
+
node inserted.ts
|
|
197
186
|
```
|
|
198
187
|
|
|
199
188
|
```text
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
189
|
+
{
|
|
190
|
+
added: [ 'step-0' ],
|
|
191
|
+
removed: [],
|
|
192
|
+
rekeyed: [],
|
|
193
|
+
unchanged: [ 'step-1', 'step-2' ]
|
|
194
|
+
}
|
|
206
195
|
```
|
|
207
196
|
|
|
208
|
-
Only `
|
|
209
|
-
and `
|
|
210
|
-
|
|
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
|
|
233
|
-
|
|
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
|
package/docs/installation.md
CHANGED
|
@@ -13,7 +13,11 @@ sidebar:
|
|
|
13
13
|
|
|
14
14
|
## Install
|
|
15
15
|
|
|
16
|
-
|
|
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:
|
package/docs/quickstart.md
CHANGED
|
@@ -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
|
|
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.
|
|
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.
|
|
222
|
-
"@smthrs/keys": "1.0.0-rc.
|
|
223
|
-
"@smthrs/canonical": "1.0.0-rc.
|
|
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
|
|
10
|
-
* the same
|
|
11
|
-
* `changed: ["input[1]"]` — is a report for a human, derived
|
|
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
|
|
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
|
|
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 =
|
|
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) => !
|
|
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.
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
Effect.
|
|
222
|
-
|
|
223
|
-
entries.
|
|
224
|
-
|
|
225
|
-
|
|
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
|
|