@smthrs/plan 1.0.0-rc.7 → 1.0.0-rc.9
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/Effects.d.ts +29 -11
- package/dist/cjs/Effects.d.ts.map +1 -1
- package/dist/cjs/Effects.js.map +2 -2
- package/dist/cjs/FileSet.d.ts +121 -51
- package/dist/cjs/FileSet.d.ts.map +1 -1
- package/dist/cjs/FileSet.js.map +2 -2
- package/dist/cjs/GraphBuildError.d.ts +2 -4
- package/dist/cjs/GraphBuildError.d.ts.map +1 -1
- package/dist/cjs/GraphBuildError.js.map +2 -2
- package/dist/cjs/KeyMaterial.d.ts +13 -8
- package/dist/cjs/KeyMaterial.d.ts.map +1 -1
- package/dist/cjs/KeyMaterial.js.map +2 -2
- package/dist/cjs/Plan.d.ts +72 -23
- package/dist/cjs/Plan.d.ts.map +1 -1
- package/dist/cjs/Plan.js +3 -0
- package/dist/cjs/Plan.js.map +2 -2
- package/dist/cjs/PlanDiff.d.ts +26 -7
- 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 +38 -17
- 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/Effects.d.ts +29 -11
- package/dist/esm/Effects.d.ts.map +1 -1
- package/dist/esm/Effects.js +18 -7
- package/dist/esm/Effects.js.map +1 -1
- package/dist/esm/FileSet.d.ts +121 -51
- package/dist/esm/FileSet.d.ts.map +1 -1
- package/dist/esm/FileSet.js +97 -35
- package/dist/esm/FileSet.js.map +1 -1
- package/dist/esm/GraphBuildError.d.ts +2 -4
- package/dist/esm/GraphBuildError.d.ts.map +1 -1
- package/dist/esm/GraphBuildError.js +2 -3
- package/dist/esm/GraphBuildError.js.map +1 -1
- package/dist/esm/KeyMaterial.d.ts +13 -8
- package/dist/esm/KeyMaterial.d.ts.map +1 -1
- package/dist/esm/KeyMaterial.js +11 -4
- package/dist/esm/KeyMaterial.js.map +1 -1
- package/dist/esm/Plan.d.ts +72 -23
- package/dist/esm/Plan.d.ts.map +1 -1
- package/dist/esm/Plan.js +61 -16
- package/dist/esm/Plan.js.map +1 -1
- package/dist/esm/PlanDiff.d.ts +26 -7
- package/dist/esm/PlanDiff.d.ts.map +1 -1
- package/dist/esm/PlanDiff.js +55 -9
- package/dist/esm/PlanDiff.js.map +1 -1
- package/dist/esm/StepKey.d.ts +38 -17
- package/dist/esm/StepKey.d.ts.map +1 -1
- package/dist/esm/StepKey.js +41 -16
- 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 +138 -74
- package/docs/installation.md +5 -1
- package/docs/quickstart.md +1 -1
- package/package.json +4 -4
- package/src/Effects.ts +29 -11
- package/src/FileSet.ts +121 -51
- package/src/GraphBuildError.ts +2 -4
- package/src/KeyMaterial.ts +13 -8
- package/src/Plan.ts +66 -23
- package/src/PlanDiff.ts +56 -11
- package/src/StepKey.ts +66 -40
package/src/Plan.ts
CHANGED
|
@@ -34,12 +34,17 @@ import * as StepKey from "./StepKey.ts"
|
|
|
34
34
|
* What a node does to the world, declared. Paths only — measuring them is
|
|
35
35
|
* run-time work, so a digest here would break the no-I/O law.
|
|
36
36
|
*
|
|
37
|
+
* Decoding fails with a `SchemaError` when a path is not workspace-relative.
|
|
38
|
+
* {@link compile} also refuses a node whose `removes` overlaps its `writes`
|
|
39
|
+
* with an `invalid_effects` {@link PlanError}.
|
|
40
|
+
*
|
|
37
41
|
* @since 0.1.0
|
|
38
42
|
* @category schemas
|
|
39
|
-
* @slop
|
|
40
43
|
*/
|
|
41
44
|
export const NodeEffects = Schema.Struct({
|
|
45
|
+
/** Paths the node reads. A read orders the node after every writer of an overlapping path. */
|
|
42
46
|
reads: Schema.Array(FileSet.ReadDeclaration),
|
|
47
|
+
/** Paths the node writes. Two writers of an overlapping path form a conflict. */
|
|
43
48
|
writes: Schema.Array(FileSet.Declaration),
|
|
44
49
|
/**
|
|
45
50
|
* Paths the node declares it will DELETE. A removal mutates the world exactly
|
|
@@ -47,6 +52,7 @@ export const NodeEffects = Schema.Struct({
|
|
|
47
52
|
* conflict pass and the reader-after-writer pass see them as one set.
|
|
48
53
|
*/
|
|
49
54
|
removes: Schema.optional(Schema.Array(FileSet.Pattern)),
|
|
55
|
+
/** How strictly the sandbox enforces these declarations. */
|
|
50
56
|
boundaryMode: FileSet.BoundaryMode
|
|
51
57
|
})
|
|
52
58
|
|
|
@@ -55,16 +61,18 @@ export const NodeEffects = Schema.Struct({
|
|
|
55
61
|
*
|
|
56
62
|
* @since 0.1.0
|
|
57
63
|
* @category models
|
|
58
|
-
* @slop
|
|
59
64
|
*/
|
|
60
65
|
export type NodeEffects = typeof NodeEffects.Type
|
|
61
66
|
|
|
62
67
|
/**
|
|
63
|
-
* The plan-time
|
|
68
|
+
* The plan-time verdict for two writers of an overlapping path:
|
|
69
|
+
* `serialize` gives the later writer an ordering edge, `lane` records a lane
|
|
70
|
+
* annotation on both writers and adds no edge, and `fail` refuses the plan
|
|
71
|
+
* with `overlap_forbidden`. For a pair, `fail` dominates `lane`, which
|
|
72
|
+
* dominates `serialize`. The same schema as `Effects.ConflictStrategy`.
|
|
64
73
|
*
|
|
65
74
|
* @since 0.1.0
|
|
66
75
|
* @category schemas
|
|
67
|
-
* @slop
|
|
68
76
|
*/
|
|
69
77
|
export const PairStrategy = Effects.ConflictStrategy
|
|
70
78
|
|
|
@@ -73,18 +81,17 @@ export const PairStrategy = Effects.ConflictStrategy
|
|
|
73
81
|
*
|
|
74
82
|
* @since 0.1.0
|
|
75
83
|
* @category models
|
|
76
|
-
* @slop
|
|
77
84
|
*/
|
|
78
85
|
export type PairStrategy = typeof PairStrategy.Type
|
|
79
86
|
|
|
80
87
|
/**
|
|
81
88
|
* The runtime half of a conflict annotation: what the scheduler
|
|
82
89
|
* does when the overlap the plan predicted actually bites, or when a
|
|
83
|
-
* scheduled node's inputs are invalidated under it.
|
|
90
|
+
* scheduled node's inputs are invalidated under it. For a pair,
|
|
91
|
+
* `stop-merge` dominates `delay-rebase`.
|
|
84
92
|
*
|
|
85
93
|
* @since 0.1.0
|
|
86
94
|
* @category schemas
|
|
87
|
-
* @slop
|
|
88
95
|
*/
|
|
89
96
|
export const RuntimeStrategy = Schema.Literals(["delay-rebase", "stop-merge"])
|
|
90
97
|
|
|
@@ -93,7 +100,6 @@ export const RuntimeStrategy = Schema.Literals(["delay-rebase", "stop-merge"])
|
|
|
93
100
|
*
|
|
94
101
|
* @since 0.1.0
|
|
95
102
|
* @category models
|
|
96
|
-
* @slop
|
|
97
103
|
*/
|
|
98
104
|
export type RuntimeStrategy = typeof RuntimeStrategy.Type
|
|
99
105
|
|
|
@@ -104,9 +110,11 @@ const NodeKind = Schema.Literals(["step", "agent", "merge"])
|
|
|
104
110
|
* One resolved overlap between two writers that no dependency path already
|
|
105
111
|
* orders. Conflict is a property of the PAIR, not of one declaration.
|
|
106
112
|
*
|
|
113
|
+
* `with` names the other node, `paths` lists the overlapping declarations,
|
|
114
|
+
* and `strategy` and `runtime` are the verdicts resolved for the pair.
|
|
115
|
+
*
|
|
107
116
|
* @since 0.1.0
|
|
108
117
|
* @category schemas
|
|
109
|
-
* @slop
|
|
110
118
|
*/
|
|
111
119
|
export const ConflictAnnotation = Schema.Struct({
|
|
112
120
|
with: Schema.NonEmptyString,
|
|
@@ -120,7 +128,6 @@ export const ConflictAnnotation = Schema.Struct({
|
|
|
120
128
|
*
|
|
121
129
|
* @since 0.1.0
|
|
122
130
|
* @category models
|
|
123
|
-
* @slop
|
|
124
131
|
*/
|
|
125
132
|
export type ConflictAnnotation = typeof ConflictAnnotation.Type
|
|
126
133
|
|
|
@@ -138,9 +145,11 @@ export type ConflictAnnotation = typeof ConflictAnnotation.Type
|
|
|
138
145
|
* a later elaboration can resolve a pair against them without re-reading the
|
|
139
146
|
* flow source.
|
|
140
147
|
*
|
|
148
|
+
* `generation` is 0 for nodes from {@link compile} and grows by one with each
|
|
149
|
+
* {@link append}.
|
|
150
|
+
*
|
|
141
151
|
* @since 0.1.0
|
|
142
152
|
* @category schemas
|
|
143
|
-
* @slop
|
|
144
153
|
*/
|
|
145
154
|
export const PlanNode = Schema.Struct({
|
|
146
155
|
id: Schema.NonEmptyString,
|
|
@@ -161,7 +170,6 @@ export const PlanNode = Schema.Struct({
|
|
|
161
170
|
*
|
|
162
171
|
* @since 0.1.0
|
|
163
172
|
* @category models
|
|
164
|
-
* @slop
|
|
165
173
|
*/
|
|
166
174
|
export type PlanNode = typeof PlanNode.Type
|
|
167
175
|
|
|
@@ -175,9 +183,11 @@ export type PlanNode = typeof PlanNode.Type
|
|
|
175
183
|
* `baseDigest` is the digest at generation 0: what a human approved and what
|
|
176
184
|
* a `RUNNING` run pins. `digest` advances with every appended elaboration.
|
|
177
185
|
*
|
|
186
|
+
* Decoding checks only the shape. Use {@link verify} to check that keys,
|
|
187
|
+
* edges, and digests match the declarations.
|
|
188
|
+
*
|
|
178
189
|
* @since 0.1.0
|
|
179
190
|
* @category schemas
|
|
180
|
-
* @slop
|
|
181
191
|
*/
|
|
182
192
|
export const Plan = Schema.Struct({
|
|
183
193
|
planId: Schema.NonEmptyString,
|
|
@@ -193,7 +203,6 @@ export const Plan = Schema.Struct({
|
|
|
193
203
|
*
|
|
194
204
|
* @since 0.1.0
|
|
195
205
|
* @category models
|
|
196
|
-
* @slop
|
|
197
206
|
*/
|
|
198
207
|
export type Plan = typeof Plan.Type
|
|
199
208
|
|
|
@@ -202,13 +211,17 @@ export type Plan = typeof Plan.Type
|
|
|
202
211
|
*
|
|
203
212
|
* @since 0.1.0
|
|
204
213
|
* @category models
|
|
205
|
-
* @slop
|
|
206
214
|
*/
|
|
207
215
|
export interface NodeDraft {
|
|
216
|
+
/** A non-empty id, unique within the plan. */
|
|
208
217
|
readonly id: string
|
|
218
|
+
/** The key inputs. Its `Ref` inputs name the node's material dependencies. */
|
|
209
219
|
readonly material: KeyMaterial.KeyMaterial
|
|
220
|
+
/** The declared reads, writes, and removals. They replace `material.effects`. */
|
|
210
221
|
readonly effects: NodeEffects
|
|
222
|
+
/** `step`, `agent`, or `merge`. Defaults to `step`. */
|
|
211
223
|
readonly kind?: PlanNode["kind"] | undefined
|
|
224
|
+
/** A safe integer scheduling priority. Defaults to 0. */
|
|
212
225
|
readonly priority?: number | undefined
|
|
213
226
|
/** This declaration's preferred plan-time verdict for a detected overlap. */
|
|
214
227
|
readonly conflictStrategy?: PairStrategy | undefined
|
|
@@ -217,11 +230,22 @@ export interface NodeDraft {
|
|
|
217
230
|
}
|
|
218
231
|
|
|
219
232
|
/**
|
|
220
|
-
* A graph the compiler refuses.
|
|
233
|
+
* A graph the compiler refuses. `code` names the rule:
|
|
234
|
+
*
|
|
235
|
+
* - `cycle`: material or inferred edges form a cycle.
|
|
236
|
+
* - `unknown_dependency`: a `Ref` names a node that is not in the plan.
|
|
237
|
+
* - `duplicate_node`: two nodes share an id.
|
|
238
|
+
* - `overlap_forbidden`: two unordered writers overlap and the pair's
|
|
239
|
+
* strategy is `fail`.
|
|
240
|
+
* - `invalid_effects`: a node both writes and removes one path.
|
|
241
|
+
* - `invalid_node`: a draft field, the plan id, or the flow name is malformed.
|
|
242
|
+
* - `invalid_plan`: {@link verify} found a plan that does not match its
|
|
243
|
+
* declarations.
|
|
244
|
+
* - `graph_too_large`: the plan exceeds {@link maximumPlanNodes} or the
|
|
245
|
+
* effect-analysis work budget.
|
|
221
246
|
*
|
|
222
247
|
* @since 0.1.0
|
|
223
248
|
* @category errors
|
|
224
|
-
* @slop
|
|
225
249
|
*/
|
|
226
250
|
export class PlanError extends Schema.TaggedError<PlanError>()("@smthrs/plan/PlanError", {
|
|
227
251
|
code: Schema.Literals([
|
|
@@ -245,7 +269,6 @@ export class PlanError extends Schema.TaggedError<PlanError>()("@smthrs/plan/Pla
|
|
|
245
269
|
*
|
|
246
270
|
* @since 1.0.0
|
|
247
271
|
* @category limits
|
|
248
|
-
* @slop
|
|
249
272
|
*/
|
|
250
273
|
export const maximumPlanNodes = 10_000
|
|
251
274
|
|
|
@@ -671,9 +694,15 @@ const keyNodes = (
|
|
|
671
694
|
* {@link maximumPlanNodes} and fail with `graph_too_large` above that limit
|
|
672
695
|
* or the effect-analysis work budget. Analysis yields periodically.
|
|
673
696
|
*
|
|
697
|
+
* The options and drafts are copied when `compile` is called, so later
|
|
698
|
+
* caller mutation does not reach the plan. The result is deeply frozen.
|
|
699
|
+
*
|
|
700
|
+
* @param options - `planId` and `flow` name the plan; `nodes` are the drafts.
|
|
701
|
+
* @returns The generation-0 plan, whose `baseDigest` equals its `digest`.
|
|
702
|
+
* Fails with {@link PlanError} for a refused graph, `KeyMaterialError` or
|
|
703
|
+
* `SchemaError` when a key cannot be derived.
|
|
674
704
|
* @since 0.1.0
|
|
675
705
|
* @category constructors
|
|
676
|
-
* @slop
|
|
677
706
|
*/
|
|
678
707
|
export const compile = (options: {
|
|
679
708
|
readonly planId: string
|
|
@@ -710,9 +739,16 @@ export const compile = (options: {
|
|
|
710
739
|
* Re-ordering after a reconciliation happens by re-keying *future* steps,
|
|
711
740
|
* never by rewriting history.
|
|
712
741
|
*
|
|
742
|
+
* A plan not produced by this module is checked with {@link verify} first.
|
|
743
|
+
*
|
|
744
|
+
* @param plan - The plan to grow.
|
|
745
|
+
* @param drafts - At least one new node. Ids must not repeat existing ones;
|
|
746
|
+
* `Ref` inputs may name existing nodes.
|
|
747
|
+
* @returns The plan with one more generation and a new `digest`. Fails with
|
|
748
|
+
* {@link PlanError} (`invalid_node` for an empty `drafts`, `invalid_plan` for a
|
|
749
|
+
* plan that fails verification), `KeyMaterialError`, or `SchemaError`.
|
|
713
750
|
* @since 0.1.0
|
|
714
751
|
* @category constructors
|
|
715
|
-
* @slop
|
|
716
752
|
*/
|
|
717
753
|
export const append = (
|
|
718
754
|
plan: Plan,
|
|
@@ -832,9 +868,10 @@ export const verify = (
|
|
|
832
868
|
* The nodes added by the newest generation: what {@link module:PlanStore}
|
|
833
869
|
* appends and what the `subgraph-appended` journal record names.
|
|
834
870
|
*
|
|
871
|
+
* @param plan - A plan.
|
|
872
|
+
* @returns The nodes whose generation equals `plan.generation`, in order.
|
|
835
873
|
* @since 0.1.0
|
|
836
874
|
* @category accessors
|
|
837
|
-
* @slop
|
|
838
875
|
*/
|
|
839
876
|
export const generationNodes = (plan: Plan): ReadonlyArray<PlanNode> =>
|
|
840
877
|
plan.nodes.filter((node) => node.generation === plan.generation)
|
|
@@ -844,9 +881,11 @@ export const generationNodes = (plan: Plan): ReadonlyArray<PlanNode> =>
|
|
|
844
881
|
* {@link verify}: such values take verify's trusted fast path and need no
|
|
845
882
|
* second schema validation.
|
|
846
883
|
*
|
|
884
|
+
* @param input - Any value.
|
|
885
|
+
* @returns `true` only for a plan returned by {@link compile},
|
|
886
|
+
* {@link append}, or {@link verify}; a structurally equal copy returns `false`.
|
|
847
887
|
* @since 1.0.0
|
|
848
888
|
* @category guards
|
|
849
|
-
* @slop
|
|
850
889
|
*/
|
|
851
890
|
export const isVerified = (input: unknown): input is Plan =>
|
|
852
891
|
typeof input === "object" && input !== null && frozenPlans.has(input as Plan)
|
|
@@ -858,9 +897,13 @@ export const isVerified = (input: unknown): input is Plan =>
|
|
|
858
897
|
* plan's integrity from the already-verified prefix instead of decoding and
|
|
859
898
|
* re-verifying every stored row.
|
|
860
899
|
*
|
|
900
|
+
* For a generation-0 plan no generation precedes the newest, so this is the
|
|
901
|
+
* digest of an empty node list.
|
|
902
|
+
*
|
|
903
|
+
* @param plan - A plan.
|
|
904
|
+
* @returns The approval digest of the nodes older than `plan.generation`.
|
|
861
905
|
* @since 1.0.0
|
|
862
906
|
* @category accessors
|
|
863
|
-
* @slop
|
|
864
907
|
*/
|
|
865
908
|
export const prefixDigest = (
|
|
866
909
|
plan: Plan
|
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
|
*/
|
|
@@ -23,11 +23,13 @@ import type * as Planned from "./Planned.ts"
|
|
|
23
23
|
*
|
|
24
24
|
* @since 0.1.0
|
|
25
25
|
* @category models
|
|
26
|
-
* @slop
|
|
27
26
|
*/
|
|
28
27
|
export interface Rekeyed {
|
|
28
|
+
/** The node's id in the newer plan. */
|
|
29
29
|
readonly id: string
|
|
30
|
+
/** The step key the paired node carried in the older plan. */
|
|
30
31
|
readonly from: string
|
|
32
|
+
/** The step key the node carries in the newer plan. */
|
|
31
33
|
readonly to: string
|
|
32
34
|
/**
|
|
33
35
|
* Field labels such as `"kind"`, `"body"`, `"layers"`, `"capabilities"`,
|
|
@@ -44,12 +46,15 @@ export interface Rekeyed {
|
|
|
44
46
|
*
|
|
45
47
|
* @since 0.1.0
|
|
46
48
|
* @category models
|
|
47
|
-
* @slop
|
|
48
49
|
*/
|
|
49
50
|
export interface PlanDiff {
|
|
51
|
+
/** Ids, in the newer plan, of nodes with no counterpart in the older plan. */
|
|
50
52
|
readonly added: ReadonlyArray<string>
|
|
53
|
+
/** Ids, in the older plan, of nodes with no counterpart in the newer plan. */
|
|
51
54
|
readonly removed: ReadonlyArray<string>
|
|
55
|
+
/** Nodes paired by id whose step key changed, with the fields that changed it. */
|
|
52
56
|
readonly rekeyed: ReadonlyArray<Rekeyed>
|
|
57
|
+
/** Ids, in the newer plan, of nodes whose step key is unchanged, including nodes whose id moved. */
|
|
53
58
|
readonly unchanged: ReadonlyArray<string>
|
|
54
59
|
}
|
|
55
60
|
|
|
@@ -157,24 +162,64 @@ const changedFields = (
|
|
|
157
162
|
/**
|
|
158
163
|
* Compares a plan against the last plan for the same flow.
|
|
159
164
|
*
|
|
165
|
+
* Nodes pair in three passes, each over the nodes no earlier pass paired:
|
|
166
|
+
* the same id with the same key, then the same key under any id, then the
|
|
167
|
+
* same id with a different key. The second pass exists because flow node ids
|
|
168
|
+
* can be positional: inserting a step shifts the ids after it while their
|
|
169
|
+
* keys stay equal, and that is one added step, not a re-key of every later
|
|
170
|
+
* one (#3905).
|
|
171
|
+
*
|
|
172
|
+
* `added`, `rekeyed`, and `unchanged` name nodes by their id in `next`;
|
|
173
|
+
* `removed` names nodes by their id in `previous`. A moved id can therefore
|
|
174
|
+
* appear once in `removed` or `added` and once in `unchanged`.
|
|
175
|
+
*
|
|
176
|
+
* @param previous - The last plan recorded for the flow.
|
|
177
|
+
* @param next - The plan to compare against it.
|
|
178
|
+
* @returns The added, removed, re-keyed, and unchanged node ids, each in its
|
|
179
|
+
* plan's node order.
|
|
160
180
|
* @since 0.1.0
|
|
161
181
|
* @category constructors
|
|
162
|
-
* @slop
|
|
163
182
|
*/
|
|
164
183
|
export const diff = (previous: Plan.Plan, next: Plan.Plan): PlanDiff => {
|
|
184
|
+
// pairs maps a next node to the previous node it is the same step as.
|
|
185
|
+
const pairs = new Map<Plan.PlanNode, Plan.PlanNode>()
|
|
186
|
+
const paired = new Set<Plan.PlanNode>()
|
|
187
|
+
const pair = (node: Plan.PlanNode, original: Plan.PlanNode) => {
|
|
188
|
+
pairs.set(node, original)
|
|
189
|
+
paired.add(original)
|
|
190
|
+
}
|
|
165
191
|
const before = new Map(previous.nodes.map((node) => [node.id, node]))
|
|
166
|
-
const
|
|
192
|
+
for (const node of next.nodes) {
|
|
193
|
+
const original = before.get(node.id)
|
|
194
|
+
if (original !== undefined && original.key === node.key) pair(node, original)
|
|
195
|
+
}
|
|
196
|
+
const byKey = new Map<string, Array<Plan.PlanNode>>()
|
|
197
|
+
for (const node of previous.nodes) {
|
|
198
|
+
if (paired.has(node)) continue
|
|
199
|
+
const same = byKey.get(node.key)
|
|
200
|
+
if (same === undefined) byKey.set(node.key, [node])
|
|
201
|
+
else same.push(node)
|
|
202
|
+
}
|
|
203
|
+
for (const node of next.nodes) {
|
|
204
|
+
if (pairs.has(node)) continue
|
|
205
|
+
const original = byKey.get(node.key)?.shift()
|
|
206
|
+
if (original !== undefined) pair(node, original)
|
|
207
|
+
}
|
|
167
208
|
const rekeyedIds = new Set<string>()
|
|
168
209
|
for (const node of next.nodes) {
|
|
210
|
+
if (pairs.has(node)) continue
|
|
169
211
|
const original = before.get(node.id)
|
|
170
|
-
if (original !== undefined && original
|
|
212
|
+
if (original !== undefined && !paired.has(original)) {
|
|
213
|
+
pair(node, original)
|
|
214
|
+
rekeyedIds.add(node.id)
|
|
215
|
+
}
|
|
171
216
|
}
|
|
172
217
|
const added: Array<string> = []
|
|
173
218
|
const rekeyed: Array<Rekeyed> = []
|
|
174
219
|
const unchanged: Array<string> = []
|
|
175
220
|
const sameField = fieldComparison()
|
|
176
221
|
for (const node of next.nodes) {
|
|
177
|
-
const original =
|
|
222
|
+
const original = pairs.get(node)
|
|
178
223
|
if (original === undefined) {
|
|
179
224
|
added.push(node.id)
|
|
180
225
|
continue
|
|
@@ -192,7 +237,7 @@ export const diff = (previous: Plan.Plan, next: Plan.Plan): PlanDiff => {
|
|
|
192
237
|
}
|
|
193
238
|
return {
|
|
194
239
|
added,
|
|
195
|
-
removed: previous.nodes.filter((node) => !
|
|
240
|
+
removed: previous.nodes.filter((node) => !paired.has(node)).map((node) => node.id),
|
|
196
241
|
rekeyed,
|
|
197
242
|
unchanged
|
|
198
243
|
}
|
package/src/StepKey.ts
CHANGED
|
@@ -43,7 +43,6 @@ import type * as KeyMaterial from "./KeyMaterial.ts"
|
|
|
43
43
|
*
|
|
44
44
|
* @since 0.1.0
|
|
45
45
|
* @category models
|
|
46
|
-
* @slop
|
|
47
46
|
*/
|
|
48
47
|
export type StepKey = StoredKey
|
|
49
48
|
|
|
@@ -72,7 +71,6 @@ type GraphReference = "ref" | "pending" | "ref-projected"
|
|
|
72
71
|
*
|
|
73
72
|
* @since 0.1.0
|
|
74
73
|
* @category models
|
|
75
|
-
* @slop
|
|
76
74
|
*/
|
|
77
75
|
export interface DigestInput {
|
|
78
76
|
readonly [DigestInputTypeId]: typeof DigestInputTypeId
|
|
@@ -94,9 +92,12 @@ export interface DigestInput {
|
|
|
94
92
|
* Nominally tags a precomputed digest so it is hashed as a digest reference
|
|
95
93
|
* rather than a literal value.
|
|
96
94
|
*
|
|
95
|
+
* @param digest - The precomputed digest.
|
|
96
|
+
* @param reference - Which graph reference resolved to the digest, and the
|
|
97
|
+
* projection path for `ref-projected`. Omit for a plain digest.
|
|
98
|
+
* @returns The tagged digest input.
|
|
97
99
|
* @since 0.1.0
|
|
98
100
|
* @category constructors
|
|
99
|
-
* @slop
|
|
100
101
|
*/
|
|
101
102
|
export const digestInput = (
|
|
102
103
|
digest: string,
|
|
@@ -112,11 +113,13 @@ export const digestInput = (
|
|
|
112
113
|
})
|
|
113
114
|
|
|
114
115
|
/**
|
|
115
|
-
* Type guard for values produced by {@link digestInput}.
|
|
116
|
+
* Type guard for values produced by {@link digestInput}. A plain object
|
|
117
|
+
* shaped like `{ digest: "..." }` is not one.
|
|
116
118
|
*
|
|
119
|
+
* @param value - Any value.
|
|
120
|
+
* @returns `true` only for a value carrying the private digest-input brand.
|
|
117
121
|
* @since 0.1.0
|
|
118
122
|
* @category guards
|
|
119
|
-
* @slop
|
|
120
123
|
*/
|
|
121
124
|
export const isDigestInput = (value: unknown): value is DigestInput =>
|
|
122
125
|
typeof value === "object" && value !== null && DigestInputTypeId in value &&
|
|
@@ -146,7 +149,6 @@ export const isDigestInput = (value: unknown): value is DigestInput =>
|
|
|
146
149
|
*
|
|
147
150
|
* @since 0.1.0
|
|
148
151
|
* @category models
|
|
149
|
-
* @slop
|
|
150
152
|
*/
|
|
151
153
|
export type EnvironmentIdentity = typeof EnvironmentIdentity.Type
|
|
152
154
|
|
|
@@ -180,9 +182,13 @@ export const EnvironmentIdentity = Schema.Union([
|
|
|
180
182
|
* digest. Entries are sound only while each settled `from` value is immutable;
|
|
181
183
|
* callers must create a fresh memo when those values can change.
|
|
182
184
|
*
|
|
185
|
+
* `digest(from, path, compute)` runs `compute` once per address and shares its
|
|
186
|
+
* result with concurrent callers. A failed or interrupted computation is not
|
|
187
|
+
* kept: the next caller recomputes, and a waiter whose leader was interrupted
|
|
188
|
+
* becomes the new leader instead of inheriting the interrupt.
|
|
189
|
+
*
|
|
183
190
|
* @since 0.1.0
|
|
184
191
|
* @category models
|
|
185
|
-
* @slop
|
|
186
192
|
*/
|
|
187
193
|
export interface DigestMemo {
|
|
188
194
|
readonly digest: (
|
|
@@ -196,37 +202,42 @@ export interface DigestMemo {
|
|
|
196
202
|
* Creates an empty projected-value digest memo. Projection addresses are
|
|
197
203
|
* JSON-encoded `[from, path]` tuples so segment boundaries are unambiguous.
|
|
198
204
|
*
|
|
205
|
+
* @returns A memo with no entries.
|
|
199
206
|
* @since 0.1.0
|
|
200
207
|
* @category constructors
|
|
201
|
-
* @slop
|
|
202
208
|
*/
|
|
203
209
|
export const makeDigestMemo = (): DigestMemo => {
|
|
204
210
|
const entries = new Map<string, Deferred.Deferred<StepKey, Schema.SchemaError>>()
|
|
211
|
+
// Registering the entry and installing its exit handler run uninterruptibly,
|
|
212
|
+
// so an interrupt can never land between them and strand a Deferred that no
|
|
213
|
+
// fiber will complete. The wait and the computation stay interruptible.
|
|
205
214
|
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
|
-
|
|
215
|
+
Effect.uninterruptibleMask((restore) =>
|
|
216
|
+
Effect.suspend(() => {
|
|
217
|
+
const address = JSON.stringify([from, path])
|
|
218
|
+
const existing = entries.get(address)
|
|
219
|
+
if (existing !== undefined) {
|
|
220
|
+
return restore(Effect.flatMap(Effect.exit(Deferred.await(existing)), (exit) => {
|
|
221
|
+
if (Exit.isSuccess(exit)) return Effect.succeed(exit.value)
|
|
222
|
+
// A waiter did not request the leader's cancellation. It competes to
|
|
223
|
+
// become the replacement leader instead of inheriting that interrupt.
|
|
224
|
+
return Cause.hasInterrupts(exit.cause) ? digest(from, path, compute) : Effect.failCause(exit.cause)
|
|
225
|
+
}))
|
|
226
|
+
}
|
|
227
|
+
const pending = Deferred.makeUnsafe<StepKey, Schema.SchemaError>()
|
|
228
|
+
entries.set(address, pending)
|
|
229
|
+
return restore(compute).pipe(
|
|
230
|
+
Effect.onExit((exit) =>
|
|
231
|
+
Effect.gen(function*() {
|
|
232
|
+
if (Exit.isFailure(exit) && entries.get(address) === pending) {
|
|
233
|
+
entries.delete(address)
|
|
234
|
+
}
|
|
235
|
+
yield* Deferred.done(pending, exit)
|
|
236
|
+
})
|
|
237
|
+
)
|
|
227
238
|
)
|
|
228
|
-
)
|
|
229
|
-
|
|
239
|
+
})
|
|
240
|
+
)
|
|
230
241
|
return { digest }
|
|
231
242
|
}
|
|
232
243
|
|
|
@@ -235,7 +246,6 @@ export const makeDigestMemo = (): DigestMemo => {
|
|
|
235
246
|
*
|
|
236
247
|
* @since 0.1.0
|
|
237
248
|
* @category models
|
|
238
|
-
* @slop
|
|
239
249
|
*/
|
|
240
250
|
export interface ContentIdentity {
|
|
241
251
|
readonly body: unknown
|
|
@@ -256,7 +266,6 @@ export interface ContentIdentity {
|
|
|
256
266
|
*
|
|
257
267
|
* @since 0.1.0
|
|
258
268
|
* @category models
|
|
259
|
-
* @slop
|
|
260
269
|
*/
|
|
261
270
|
export interface OrdinalIdentity {
|
|
262
271
|
readonly runId: string
|
|
@@ -266,11 +275,18 @@ export interface OrdinalIdentity {
|
|
|
266
275
|
}
|
|
267
276
|
|
|
268
277
|
/**
|
|
269
|
-
* Stable failures while
|
|
278
|
+
* Stable failures while deriving a key. `code` names the rule:
|
|
279
|
+
*
|
|
280
|
+
* - `invalid_environment`: an {@link EnvironmentIdentity} that is neither
|
|
281
|
+
* declared nor run-scoped.
|
|
282
|
+
* - `missing_dependency`: a `Ref` or `Pending` input names a node with no
|
|
283
|
+
* recorded digest, or, at dispatch, a `Ref` names one with no settled
|
|
284
|
+
* result.
|
|
285
|
+
* - `non_content_material`: a content-key constructor received material whose
|
|
286
|
+
* tier is not `sealed`.
|
|
270
287
|
*
|
|
271
288
|
* @since 0.1.0
|
|
272
289
|
* @category errors
|
|
273
|
-
* @slop
|
|
274
290
|
*/
|
|
275
291
|
export class KeyMaterialError extends Schema.TaggedError<KeyMaterialError>()("@smthrs/plan/KeyMaterialError", {
|
|
276
292
|
code: Schema.Literals(["invalid_environment", "missing_dependency", "non_content_material"]),
|
|
@@ -418,9 +434,11 @@ export const environmentIdentity = (
|
|
|
418
434
|
* declarations are normalized before serialization; write declarations remain
|
|
419
435
|
* part of the identity even when the step writes nothing.
|
|
420
436
|
*
|
|
437
|
+
* @param identity - The step's content material.
|
|
438
|
+
* @returns The content key. Fails with `invalid_environment` for a malformed
|
|
439
|
+
* environment, or `SchemaError` when the material cannot be serialized.
|
|
421
440
|
* @since 0.1.0
|
|
422
441
|
* @category constructors
|
|
423
|
-
* @slop
|
|
424
442
|
*/
|
|
425
443
|
export const content = (
|
|
426
444
|
identity: ContentIdentity
|
|
@@ -442,9 +460,10 @@ export const content = (
|
|
|
442
460
|
* Produces a run-local ordinal key for compensable, irreversible, or unsealed
|
|
443
461
|
* work. These keys intentionally cannot be reused across runs.
|
|
444
462
|
*
|
|
463
|
+
* @param identity - The run id, optional parent scope, ordinal, and tier.
|
|
464
|
+
* @returns The ordinal key.
|
|
445
465
|
* @since 0.1.0
|
|
446
466
|
* @category constructors
|
|
447
|
-
* @slop
|
|
448
467
|
*/
|
|
449
468
|
export const ordinal = (
|
|
450
469
|
identity: OrdinalIdentity
|
|
@@ -461,9 +480,13 @@ export const ordinal = (
|
|
|
461
480
|
* and never enter the hashed value — rename a node and nothing re-keys; change
|
|
462
481
|
* what a node consumes and everything downstream of it does.
|
|
463
482
|
*
|
|
483
|
+
* @param material - The node's key material; its tier must be `sealed`.
|
|
484
|
+
* @param dependencyDigests - The key of every node the material references,
|
|
485
|
+
* by node id.
|
|
486
|
+
* @returns The content key. Fails with `non_content_material` for a
|
|
487
|
+
* non-sealed tier and `missing_dependency` for an unresolved reference.
|
|
464
488
|
* @since 0.1.0
|
|
465
489
|
* @category constructors
|
|
466
|
-
* @slop
|
|
467
490
|
*/
|
|
468
491
|
export const fromKeyMaterial = (
|
|
469
492
|
material: KeyMaterial.KeyMaterial,
|
|
@@ -632,7 +655,6 @@ const orderingOnly = "ordering-only"
|
|
|
632
655
|
*
|
|
633
656
|
* @since 0.1.0
|
|
634
657
|
* @category utilities
|
|
635
|
-
* @slop
|
|
636
658
|
*/
|
|
637
659
|
export const project = (value: unknown, path: ReadonlyArray<string>): unknown => {
|
|
638
660
|
let current = value
|
|
@@ -667,9 +689,13 @@ export const project = (value: unknown, path: ReadonlyArray<string>): unknown =>
|
|
|
667
689
|
* halt rule guarantees it: a dependent of failed or skipped work never
|
|
668
690
|
* dispatches, so a `Ref` always resolves against a success.
|
|
669
691
|
*
|
|
692
|
+
* @param options - The material, the dependencies' settled results, the
|
|
693
|
+
* measured hermetic boundary, and an optional environment and memo.
|
|
694
|
+
* @returns The dispatch key. Fails with `non_content_material` for a
|
|
695
|
+
* non-sealed tier, `missing_dependency` for a `Ref` absent from `results`,
|
|
696
|
+
* or `invalid_environment` for a malformed environment.
|
|
670
697
|
* @since 0.1.0
|
|
671
698
|
* @category constructors
|
|
672
|
-
* @slop
|
|
673
699
|
*/
|
|
674
700
|
export const dispatchIdentity = (options: {
|
|
675
701
|
readonly material: KeyMaterial.KeyMaterial
|