@coldtea/pr-lens-schema 0.1.0
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/LICENSE +21 -0
- package/README.md +150 -0
- package/dist/apply.d.ts +42 -0
- package/dist/apply.d.ts.map +1 -0
- package/dist/apply.js +315 -0
- package/dist/apply.js.map +1 -0
- package/dist/config.d.ts +58 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +61 -0
- package/dist/config.js.map +1 -0
- package/dist/errors.d.ts +24 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +12 -0
- package/dist/errors.js.map +1 -0
- package/dist/examples/baseline.d.ts +18 -0
- package/dist/examples/baseline.d.ts.map +1 -0
- package/dist/examples/baseline.js +433 -0
- package/dist/examples/baseline.js.map +1 -0
- package/dist/examples/index.d.ts +1194 -0
- package/dist/examples/index.d.ts.map +1 -0
- package/dist/examples/index.js +20 -0
- package/dist/examples/index.js.map +1 -0
- package/dist/examples/minimal.d.ts +4 -0
- package/dist/examples/minimal.d.ts.map +1 -0
- package/dist/examples/minimal.js +25 -0
- package/dist/examples/minimal.js.map +1 -0
- package/dist/examples/postmark-refactor.d.ts +16 -0
- package/dist/examples/postmark-refactor.d.ts.map +1 -0
- package/dist/examples/postmark-refactor.js +468 -0
- package/dist/examples/postmark-refactor.js.map +1 -0
- package/dist/graph.d.ts +568 -0
- package/dist/graph.d.ts.map +1 -0
- package/dist/graph.js +266 -0
- package/dist/graph.js.map +1 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +12 -0
- package/dist/index.js.map +1 -0
- package/dist/integrity.d.ts +20 -0
- package/dist/integrity.d.ts.map +1 -0
- package/dist/integrity.js +167 -0
- package/dist/integrity.js.map +1 -0
- package/dist/manifest.d.ts +65 -0
- package/dist/manifest.d.ts.map +1 -0
- package/dist/manifest.js +60 -0
- package/dist/manifest.js.map +1 -0
- package/dist/patch.d.ts +824 -0
- package/dist/patch.d.ts.map +1 -0
- package/dist/patch.js +84 -0
- package/dist/patch.js.map +1 -0
- package/dist/primitives.d.ts +92 -0
- package/dist/primitives.d.ts.map +1 -0
- package/dist/primitives.js +120 -0
- package/dist/primitives.js.map +1 -0
- package/dist/utils.d.ts +6 -0
- package/dist/utils.d.ts.map +1 -0
- package/dist/utils.js +8 -0
- package/dist/utils.js.map +1 -0
- package/dist/validate.d.ts +18 -0
- package/dist/validate.d.ts.map +1 -0
- package/dist/validate.js +95 -0
- package/dist/validate.js.map +1 -0
- package/dist/version.d.ts +21 -0
- package/dist/version.d.ts.map +1 -0
- package/dist/version.js +24 -0
- package/dist/version.js.map +1 -0
- package/examples/broadcast-baseline.graph.json +289 -0
- package/examples/broadcast-baseline.patch.json +321 -0
- package/examples/minimal.graph.json +45 -0
- package/examples/postmark-refactor.graph.json +580 -0
- package/examples/postmark-refactor.render-manifest.json +67 -0
- package/examples/pr-lens.config.json +32 -0
- package/json-schema/config.schema.json +153 -0
- package/json-schema/graph-doc.schema.json +1032 -0
- package/json-schema/patch-doc.schema.json +1383 -0
- package/json-schema/render-manifest.schema.json +183 -0
- package/package.json +66 -0
- package/src/apply.ts +399 -0
- package/src/config.ts +69 -0
- package/src/errors.ts +34 -0
- package/src/examples/baseline.ts +437 -0
- package/src/examples/index.ts +25 -0
- package/src/examples/minimal.ts +26 -0
- package/src/examples/postmark-refactor.ts +480 -0
- package/src/graph.ts +331 -0
- package/src/index.ts +82 -0
- package/src/integrity.ts +216 -0
- package/src/manifest.ts +64 -0
- package/src/patch.ts +100 -0
- package/src/primitives.ts +146 -0
- package/src/utils.ts +7 -0
- package/src/validate.ts +132 -0
- package/src/version.ts +31 -0
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "https://raw.githubusercontent.com/coldteadotai/pr-lens/main/packages/schema/json-schema/render-manifest.schema.json",
|
|
4
|
+
"title": "PR Lens render manifest",
|
|
5
|
+
"version": "0.1.0",
|
|
6
|
+
"type": "object",
|
|
7
|
+
"properties": {
|
|
8
|
+
"schemaVersion": {
|
|
9
|
+
"type": "string",
|
|
10
|
+
"pattern": "^0\\.1\\.\\d+$",
|
|
11
|
+
"description": "Contract version the document targets. Current: 0.1.0."
|
|
12
|
+
},
|
|
13
|
+
"kind": {
|
|
14
|
+
"type": "string",
|
|
15
|
+
"const": "render-manifest"
|
|
16
|
+
},
|
|
17
|
+
"generatedAt": {
|
|
18
|
+
"type": "string",
|
|
19
|
+
"format": "date-time",
|
|
20
|
+
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$"
|
|
21
|
+
},
|
|
22
|
+
"graph": {
|
|
23
|
+
"type": "object",
|
|
24
|
+
"properties": {
|
|
25
|
+
"id": {
|
|
26
|
+
"type": "string",
|
|
27
|
+
"minLength": 1,
|
|
28
|
+
"maxLength": 128,
|
|
29
|
+
"pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]*$",
|
|
30
|
+
"description": "Stable identifier, unique within its collection in a document."
|
|
31
|
+
},
|
|
32
|
+
"headSha": {
|
|
33
|
+
"type": "string",
|
|
34
|
+
"pattern": "^[0-9a-f]{7,40}$",
|
|
35
|
+
"description": "Git commit sha, abbreviated or full."
|
|
36
|
+
},
|
|
37
|
+
"contentHash": {
|
|
38
|
+
"type": "string",
|
|
39
|
+
"pattern": "^[0-9a-f]{16,64}$",
|
|
40
|
+
"description": "Hash of the graph document this render came from."
|
|
41
|
+
}
|
|
42
|
+
},
|
|
43
|
+
"required": [
|
|
44
|
+
"contentHash"
|
|
45
|
+
],
|
|
46
|
+
"additionalProperties": false,
|
|
47
|
+
"description": "The document that was rendered."
|
|
48
|
+
},
|
|
49
|
+
"renderer": {
|
|
50
|
+
"type": "object",
|
|
51
|
+
"properties": {
|
|
52
|
+
"name": {
|
|
53
|
+
"type": "string",
|
|
54
|
+
"minLength": 1,
|
|
55
|
+
"maxLength": 64
|
|
56
|
+
},
|
|
57
|
+
"version": {
|
|
58
|
+
"type": "string",
|
|
59
|
+
"minLength": 1,
|
|
60
|
+
"maxLength": 32
|
|
61
|
+
}
|
|
62
|
+
},
|
|
63
|
+
"required": [
|
|
64
|
+
"name",
|
|
65
|
+
"version"
|
|
66
|
+
],
|
|
67
|
+
"additionalProperties": false
|
|
68
|
+
},
|
|
69
|
+
"assets": {
|
|
70
|
+
"minItems": 1,
|
|
71
|
+
"maxItems": 256,
|
|
72
|
+
"type": "array",
|
|
73
|
+
"items": {
|
|
74
|
+
"type": "object",
|
|
75
|
+
"properties": {
|
|
76
|
+
"id": {
|
|
77
|
+
"type": "string",
|
|
78
|
+
"minLength": 1,
|
|
79
|
+
"maxLength": 128,
|
|
80
|
+
"pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]*$",
|
|
81
|
+
"description": "Stable identifier, unique within its collection in a document."
|
|
82
|
+
},
|
|
83
|
+
"lens": {
|
|
84
|
+
"type": "string",
|
|
85
|
+
"enum": [
|
|
86
|
+
"architecture",
|
|
87
|
+
"data-flow"
|
|
88
|
+
],
|
|
89
|
+
"description": "Rendering lens."
|
|
90
|
+
},
|
|
91
|
+
"theme": {
|
|
92
|
+
"type": "string",
|
|
93
|
+
"enum": [
|
|
94
|
+
"light",
|
|
95
|
+
"dark"
|
|
96
|
+
],
|
|
97
|
+
"description": "Half of a <picture> pair."
|
|
98
|
+
},
|
|
99
|
+
"view": {
|
|
100
|
+
"description": "Drill-down view this asset renders, when it is not the root.",
|
|
101
|
+
"type": "string",
|
|
102
|
+
"minLength": 1,
|
|
103
|
+
"maxLength": 128,
|
|
104
|
+
"pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]*$"
|
|
105
|
+
},
|
|
106
|
+
"mediaType": {
|
|
107
|
+
"type": "string",
|
|
108
|
+
"const": "image/svg+xml"
|
|
109
|
+
},
|
|
110
|
+
"contentHash": {
|
|
111
|
+
"type": "string",
|
|
112
|
+
"pattern": "^[0-9a-f]{16,64}$",
|
|
113
|
+
"description": "Hash of the asset bytes; the identity of this render."
|
|
114
|
+
},
|
|
115
|
+
"bytes": {
|
|
116
|
+
"type": "integer",
|
|
117
|
+
"minimum": 1,
|
|
118
|
+
"maximum": 9007199254740991
|
|
119
|
+
},
|
|
120
|
+
"width": {
|
|
121
|
+
"type": "integer",
|
|
122
|
+
"minimum": 1,
|
|
123
|
+
"maximum": 9007199254740991
|
|
124
|
+
},
|
|
125
|
+
"height": {
|
|
126
|
+
"type": "integer",
|
|
127
|
+
"minimum": 1,
|
|
128
|
+
"maximum": 9007199254740991
|
|
129
|
+
},
|
|
130
|
+
"animated": {
|
|
131
|
+
"default": false,
|
|
132
|
+
"description": "Contains SMIL animation.",
|
|
133
|
+
"type": "boolean"
|
|
134
|
+
},
|
|
135
|
+
"url": {
|
|
136
|
+
"description": "Where the asset is published, once it has been uploaded.",
|
|
137
|
+
"type": "string",
|
|
138
|
+
"format": "uri"
|
|
139
|
+
},
|
|
140
|
+
"path": {
|
|
141
|
+
"description": "Local path, for surfaces that write files instead of uploading.",
|
|
142
|
+
"type": "string",
|
|
143
|
+
"minLength": 1,
|
|
144
|
+
"maxLength": 1024
|
|
145
|
+
}
|
|
146
|
+
},
|
|
147
|
+
"required": [
|
|
148
|
+
"id",
|
|
149
|
+
"lens",
|
|
150
|
+
"theme",
|
|
151
|
+
"mediaType",
|
|
152
|
+
"contentHash",
|
|
153
|
+
"bytes",
|
|
154
|
+
"width",
|
|
155
|
+
"height"
|
|
156
|
+
],
|
|
157
|
+
"additionalProperties": false,
|
|
158
|
+
"anyOf": [
|
|
159
|
+
{
|
|
160
|
+
"required": [
|
|
161
|
+
"url"
|
|
162
|
+
]
|
|
163
|
+
},
|
|
164
|
+
{
|
|
165
|
+
"required": [
|
|
166
|
+
"path"
|
|
167
|
+
]
|
|
168
|
+
}
|
|
169
|
+
],
|
|
170
|
+
"description": "One rendered SVG."
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
},
|
|
174
|
+
"required": [
|
|
175
|
+
"schemaVersion",
|
|
176
|
+
"kind",
|
|
177
|
+
"graph",
|
|
178
|
+
"renderer",
|
|
179
|
+
"assets"
|
|
180
|
+
],
|
|
181
|
+
"additionalProperties": false,
|
|
182
|
+
"description": "A PR Lens render manifest."
|
|
183
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@coldtea/pr-lens-schema",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "The PR Lens contract: versioned schemas for architecture / data-flow graph documents, baseline patches, config and render manifests.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "Ohans Emmanuel",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/coldteadotai/pr-lens.git",
|
|
10
|
+
"directory": "packages/schema"
|
|
11
|
+
},
|
|
12
|
+
"homepage": "https://github.com/coldteadotai/pr-lens/tree/main/packages/schema#readme",
|
|
13
|
+
"keywords": [
|
|
14
|
+
"pr-lens",
|
|
15
|
+
"code-review",
|
|
16
|
+
"architecture-diagram",
|
|
17
|
+
"data-flow",
|
|
18
|
+
"json-schema",
|
|
19
|
+
"zod"
|
|
20
|
+
],
|
|
21
|
+
"type": "module",
|
|
22
|
+
"sideEffects": false,
|
|
23
|
+
"exports": {
|
|
24
|
+
".": {
|
|
25
|
+
"types": "./dist/index.d.ts",
|
|
26
|
+
"default": "./dist/index.js"
|
|
27
|
+
},
|
|
28
|
+
"./examples": {
|
|
29
|
+
"types": "./dist/examples/index.d.ts",
|
|
30
|
+
"default": "./dist/examples/index.js"
|
|
31
|
+
},
|
|
32
|
+
"./json-schema/*.json": "./json-schema/*.json",
|
|
33
|
+
"./package.json": "./package.json"
|
|
34
|
+
},
|
|
35
|
+
"files": [
|
|
36
|
+
"dist",
|
|
37
|
+
"src",
|
|
38
|
+
"json-schema",
|
|
39
|
+
"examples",
|
|
40
|
+
"README.md",
|
|
41
|
+
"LICENSE"
|
|
42
|
+
],
|
|
43
|
+
"publishConfig": {
|
|
44
|
+
"access": "public"
|
|
45
|
+
},
|
|
46
|
+
"engines": {
|
|
47
|
+
"node": ">=20.11"
|
|
48
|
+
},
|
|
49
|
+
"dependencies": {
|
|
50
|
+
"zod": "^4.4.3"
|
|
51
|
+
},
|
|
52
|
+
"devDependencies": {
|
|
53
|
+
"@types/node": "^20.19.0",
|
|
54
|
+
"ajv": "^8.20.0",
|
|
55
|
+
"tsx": "4.23.12",
|
|
56
|
+
"typescript": "7.0.2",
|
|
57
|
+
"vitest": "4.1.11"
|
|
58
|
+
},
|
|
59
|
+
"scripts": {
|
|
60
|
+
"build": "rm -rf dist && tsc -p tsconfig.build.json && pnpm run schema:emit",
|
|
61
|
+
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
62
|
+
"schema:emit": "tsx scripts/emit-artifacts.ts",
|
|
63
|
+
"test": "vitest run",
|
|
64
|
+
"test:watch": "vitest"
|
|
65
|
+
}
|
|
66
|
+
}
|
package/src/apply.ts
ADDED
|
@@ -0,0 +1,399 @@
|
|
|
1
|
+
import { formatIssues, PrLensSchemaError, type Parsed, type SchemaIssue } from "./errors.js";
|
|
2
|
+
import { safeParseGraphDoc } from "./validate.js";
|
|
3
|
+
import type { Flow, GraphDoc, GraphEdge, Lane, View } from "./graph.js";
|
|
4
|
+
import { graphSnapshotIssues } from "./integrity.js";
|
|
5
|
+
import { targetDescribesATransition, type PatchDoc, type PatchOp } from "./patch.js";
|
|
6
|
+
import { assertNever } from "./utils.js";
|
|
7
|
+
|
|
8
|
+
type Collections = {
|
|
9
|
+
lanes: Lane[];
|
|
10
|
+
nodes: GraphDoc["nodes"];
|
|
11
|
+
edges: GraphEdge[];
|
|
12
|
+
flows: Flow[];
|
|
13
|
+
stats: GraphDoc["stats"];
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
const conflict = (path: string, message: string): SchemaIssue => ({
|
|
17
|
+
code: "PATCH_CONFLICT",
|
|
18
|
+
path,
|
|
19
|
+
message,
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
const brokenReference = (path: string, message: string): SchemaIssue => ({
|
|
23
|
+
code: "BROKEN_REFERENCE",
|
|
24
|
+
path,
|
|
25
|
+
message,
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
const indexOfId = (items: readonly { id: string }[], id: string): number =>
|
|
29
|
+
items.findIndex((item) => item.id === id);
|
|
30
|
+
|
|
31
|
+
type RemovedIds = {
|
|
32
|
+
lanes: ReadonlySet<string>;
|
|
33
|
+
nodes: ReadonlySet<string>;
|
|
34
|
+
edges: ReadonlySet<string>;
|
|
35
|
+
flows: ReadonlySet<string>;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const nothingRemoved: RemovedIds = {
|
|
39
|
+
lanes: new Set(),
|
|
40
|
+
nodes: new Set(),
|
|
41
|
+
edges: new Set(),
|
|
42
|
+
flows: new Set(),
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* A view whose selection loses its last element no longer has a subject, so
|
|
47
|
+
* it goes rather than being left to render as an empty section.
|
|
48
|
+
*/
|
|
49
|
+
const pruneViews = (views: readonly View[], removed: RemovedIds): View[] =>
|
|
50
|
+
views.flatMap((view) => {
|
|
51
|
+
const children = pruneViews(view.children, removed);
|
|
52
|
+
|
|
53
|
+
switch (view.scope.kind) {
|
|
54
|
+
case "all":
|
|
55
|
+
return [{ ...view, children }];
|
|
56
|
+
case "selection": {
|
|
57
|
+
const scope = {
|
|
58
|
+
kind: "selection",
|
|
59
|
+
lanes: view.scope.lanes.filter((id) => !removed.lanes.has(id)),
|
|
60
|
+
nodes: view.scope.nodes.filter((id) => !removed.nodes.has(id)),
|
|
61
|
+
edges: view.scope.edges.filter((id) => !removed.edges.has(id)),
|
|
62
|
+
flows: view.scope.flows.filter((id) => !removed.flows.has(id)),
|
|
63
|
+
} as const;
|
|
64
|
+
|
|
65
|
+
const selected =
|
|
66
|
+
scope.lanes.length + scope.nodes.length + scope.edges.length + scope.flows.length;
|
|
67
|
+
return selected === 0 ? [] : [{ ...view, scope, children }];
|
|
68
|
+
}
|
|
69
|
+
default:
|
|
70
|
+
return assertNever(view.scope, "Unhandled view scope");
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
/** Layout hints name elements, so they strand the same way view scopes do. */
|
|
75
|
+
const pruneLayout = (layout: GraphDoc["layout"], removed: RemovedIds): GraphDoc["layout"] => {
|
|
76
|
+
if (layout === undefined) return undefined;
|
|
77
|
+
|
|
78
|
+
const rank = layout.rank
|
|
79
|
+
? Object.fromEntries(
|
|
80
|
+
Object.entries(layout.rank).filter(([nodeId]) => !removed.nodes.has(nodeId)),
|
|
81
|
+
)
|
|
82
|
+
: undefined;
|
|
83
|
+
|
|
84
|
+
return {
|
|
85
|
+
...layout,
|
|
86
|
+
laneOrder: layout.laneOrder.filter((id) => !removed.lanes.has(id)),
|
|
87
|
+
...(rank ? { rank } : {}),
|
|
88
|
+
};
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Deleting a node would strand every edge and flow step that touched it, so
|
|
93
|
+
* those go with it. A flow left with fewer than two participants, or with no
|
|
94
|
+
* steps, no longer describes a pipeline and is dropped whole.
|
|
95
|
+
*/
|
|
96
|
+
const cascadeNodeRemoval = (working: Collections, nodeId: string): RemovedIds => {
|
|
97
|
+
const strandedEdges = working.edges.filter((edge) => edge.from === nodeId || edge.to === nodeId);
|
|
98
|
+
working.edges = working.edges.filter((edge) => edge.from !== nodeId && edge.to !== nodeId);
|
|
99
|
+
|
|
100
|
+
const droppedFlows: string[] = [];
|
|
101
|
+
working.flows = working.flows.flatMap((flow) => {
|
|
102
|
+
const participants = flow.participants.filter((participant) => participant.node !== nodeId);
|
|
103
|
+
if (participants.length === flow.participants.length) return [flow];
|
|
104
|
+
|
|
105
|
+
const messages = flow.messages.filter(
|
|
106
|
+
(message) => message.from !== nodeId && message.to !== nodeId,
|
|
107
|
+
);
|
|
108
|
+
if (participants.length < 2 || messages.length === 0) {
|
|
109
|
+
droppedFlows.push(flow.id);
|
|
110
|
+
return [];
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return [{ ...flow, participants, messages }];
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
return {
|
|
117
|
+
...nothingRemoved,
|
|
118
|
+
nodes: new Set([nodeId]),
|
|
119
|
+
edges: new Set(strandedEdges.map((edge) => edge.id)),
|
|
120
|
+
flows: new Set(droppedFlows),
|
|
121
|
+
};
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Applies patch operations to a stored graph — the way a baseline map moves
|
|
126
|
+
* forward as pull requests merge, instead of being re-extracted wholesale.
|
|
127
|
+
*
|
|
128
|
+
* Operations apply in order and the first conflict stops the batch: a later
|
|
129
|
+
* operation in the same patch was written against the state the earlier one
|
|
130
|
+
* was supposed to produce.
|
|
131
|
+
*
|
|
132
|
+
* A successful result is a fully valid graph document: per-operation checks
|
|
133
|
+
* catch the common mistakes with a message naming the operation, and the
|
|
134
|
+
* candidate is validated as a whole before it is handed back, so no patch can
|
|
135
|
+
* quietly leave a document the renderer would choke on.
|
|
136
|
+
*/
|
|
137
|
+
export const applyPatch = (graph: GraphDoc, ops: readonly PatchOp[]): Parsed<GraphDoc> => {
|
|
138
|
+
const working: Collections = {
|
|
139
|
+
lanes: [...graph.lanes],
|
|
140
|
+
nodes: [...graph.nodes],
|
|
141
|
+
edges: [...graph.edges],
|
|
142
|
+
flows: [...graph.flows],
|
|
143
|
+
stats: graph.stats,
|
|
144
|
+
};
|
|
145
|
+
let views = graph.views;
|
|
146
|
+
let layout = graph.layout;
|
|
147
|
+
|
|
148
|
+
const prune = (removed: RemovedIds): void => {
|
|
149
|
+
views = pruneViews(views, removed);
|
|
150
|
+
layout = pruneLayout(layout, removed);
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
for (const [index, op] of ops.entries()) {
|
|
154
|
+
const at = `ops[${index}]`;
|
|
155
|
+
const reject = (issue: SchemaIssue): Parsed<GraphDoc> => ({
|
|
156
|
+
ok: false,
|
|
157
|
+
error: new PrLensSchemaError(issue.code, `${at}: ${issue.message}`, [issue]),
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
switch (op.op) {
|
|
161
|
+
case "add_lane": {
|
|
162
|
+
if (indexOfId(working.lanes, op.lane.id) !== -1)
|
|
163
|
+
return reject(conflict(`${at}.lane.id`, `lane '${op.lane.id}' already exists`));
|
|
164
|
+
working.lanes.push(op.lane);
|
|
165
|
+
break;
|
|
166
|
+
}
|
|
167
|
+
case "update_lane": {
|
|
168
|
+
const position = indexOfId(working.lanes, op.id);
|
|
169
|
+
const lane = working.lanes[position];
|
|
170
|
+
if (lane === undefined) return reject(conflict(`${at}.id`, `unknown lane '${op.id}'`));
|
|
171
|
+
working.lanes[position] = { ...lane, ...op.patch };
|
|
172
|
+
break;
|
|
173
|
+
}
|
|
174
|
+
case "remove_lane": {
|
|
175
|
+
const position = indexOfId(working.lanes, op.id);
|
|
176
|
+
if (position === -1) return reject(conflict(`${at}.id`, `unknown lane '${op.id}'`));
|
|
177
|
+
|
|
178
|
+
const occupant = working.nodes.find((node) => node.lane === op.id);
|
|
179
|
+
if (occupant !== undefined)
|
|
180
|
+
return reject(
|
|
181
|
+
conflict(
|
|
182
|
+
`${at}.id`,
|
|
183
|
+
`lane '${op.id}' still holds node '${occupant.id}'; move or remove its nodes first`,
|
|
184
|
+
),
|
|
185
|
+
);
|
|
186
|
+
|
|
187
|
+
working.lanes.splice(position, 1);
|
|
188
|
+
prune({ ...nothingRemoved, lanes: new Set([op.id]) });
|
|
189
|
+
break;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
case "add_node": {
|
|
193
|
+
if (indexOfId(working.nodes, op.node.id) !== -1)
|
|
194
|
+
return reject(conflict(`${at}.node.id`, `node '${op.node.id}' already exists`));
|
|
195
|
+
if (indexOfId(working.lanes, op.node.lane) === -1)
|
|
196
|
+
return reject(brokenReference(`${at}.node.lane`, `unknown lane '${op.node.lane}'`));
|
|
197
|
+
working.nodes.push(op.node);
|
|
198
|
+
break;
|
|
199
|
+
}
|
|
200
|
+
case "update_node": {
|
|
201
|
+
const position = indexOfId(working.nodes, op.id);
|
|
202
|
+
const node = working.nodes[position];
|
|
203
|
+
if (node === undefined) return reject(conflict(`${at}.id`, `unknown node '${op.id}'`));
|
|
204
|
+
if (op.patch.lane !== undefined && indexOfId(working.lanes, op.patch.lane) === -1)
|
|
205
|
+
return reject(brokenReference(`${at}.patch.lane`, `unknown lane '${op.patch.lane}'`));
|
|
206
|
+
working.nodes[position] = { ...node, ...op.patch };
|
|
207
|
+
break;
|
|
208
|
+
}
|
|
209
|
+
case "remove_node": {
|
|
210
|
+
const position = indexOfId(working.nodes, op.id);
|
|
211
|
+
if (position === -1) return reject(conflict(`${at}.id`, `unknown node '${op.id}'`));
|
|
212
|
+
working.nodes.splice(position, 1);
|
|
213
|
+
prune(cascadeNodeRemoval(working, op.id));
|
|
214
|
+
break;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
case "add_edge": {
|
|
218
|
+
if (indexOfId(working.edges, op.edge.id) !== -1)
|
|
219
|
+
return reject(conflict(`${at}.edge.id`, `edge '${op.edge.id}' already exists`));
|
|
220
|
+
for (const [field, id] of [
|
|
221
|
+
["from", op.edge.from],
|
|
222
|
+
["to", op.edge.to],
|
|
223
|
+
] as const) {
|
|
224
|
+
if (indexOfId(working.nodes, id) === -1)
|
|
225
|
+
return reject(brokenReference(`${at}.edge.${field}`, `unknown node '${id}'`));
|
|
226
|
+
}
|
|
227
|
+
working.edges.push(op.edge);
|
|
228
|
+
break;
|
|
229
|
+
}
|
|
230
|
+
case "update_edge": {
|
|
231
|
+
const position = indexOfId(working.edges, op.id);
|
|
232
|
+
const edge = working.edges[position];
|
|
233
|
+
if (edge === undefined) return reject(conflict(`${at}.id`, `unknown edge '${op.id}'`));
|
|
234
|
+
for (const [field, id] of [
|
|
235
|
+
["from", op.patch.from],
|
|
236
|
+
["to", op.patch.to],
|
|
237
|
+
] as const) {
|
|
238
|
+
if (id !== undefined && indexOfId(working.nodes, id) === -1)
|
|
239
|
+
return reject(brokenReference(`${at}.patch.${field}`, `unknown node '${id}'`));
|
|
240
|
+
}
|
|
241
|
+
working.edges[position] = { ...edge, ...op.patch };
|
|
242
|
+
break;
|
|
243
|
+
}
|
|
244
|
+
case "remove_edge": {
|
|
245
|
+
const position = indexOfId(working.edges, op.id);
|
|
246
|
+
if (position === -1) return reject(conflict(`${at}.id`, `unknown edge '${op.id}'`));
|
|
247
|
+
working.edges.splice(position, 1);
|
|
248
|
+
prune({ ...nothingRemoved, edges: new Set([op.id]) });
|
|
249
|
+
break;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
case "add_flow": {
|
|
253
|
+
if (indexOfId(working.flows, op.flow.id) !== -1)
|
|
254
|
+
return reject(conflict(`${at}.flow.id`, `flow '${op.flow.id}' already exists`));
|
|
255
|
+
for (const [participantIndex, participant] of op.flow.participants.entries()) {
|
|
256
|
+
if (indexOfId(working.nodes, participant.node) === -1)
|
|
257
|
+
return reject(
|
|
258
|
+
brokenReference(
|
|
259
|
+
`${at}.flow.participants[${participantIndex}].node`,
|
|
260
|
+
`unknown node '${participant.node}'`,
|
|
261
|
+
),
|
|
262
|
+
);
|
|
263
|
+
}
|
|
264
|
+
working.flows.push(op.flow);
|
|
265
|
+
break;
|
|
266
|
+
}
|
|
267
|
+
case "update_flow": {
|
|
268
|
+
const position = indexOfId(working.flows, op.id);
|
|
269
|
+
const flow = working.flows[position];
|
|
270
|
+
if (flow === undefined) return reject(conflict(`${at}.id`, `unknown flow '${op.id}'`));
|
|
271
|
+
for (const [participantIndex, participant] of (op.patch.participants ?? []).entries()) {
|
|
272
|
+
if (indexOfId(working.nodes, participant.node) === -1)
|
|
273
|
+
return reject(
|
|
274
|
+
brokenReference(
|
|
275
|
+
`${at}.patch.participants[${participantIndex}].node`,
|
|
276
|
+
`unknown node '${participant.node}'`,
|
|
277
|
+
),
|
|
278
|
+
);
|
|
279
|
+
}
|
|
280
|
+
working.flows[position] = { ...flow, ...op.patch };
|
|
281
|
+
break;
|
|
282
|
+
}
|
|
283
|
+
case "remove_flow": {
|
|
284
|
+
const position = indexOfId(working.flows, op.id);
|
|
285
|
+
if (position === -1) return reject(conflict(`${at}.id`, `unknown flow '${op.id}'`));
|
|
286
|
+
working.flows.splice(position, 1);
|
|
287
|
+
prune({ ...nothingRemoved, flows: new Set([op.id]) });
|
|
288
|
+
break;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
case "set_stats": {
|
|
292
|
+
working.stats = op.stats;
|
|
293
|
+
break;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
default:
|
|
297
|
+
return assertNever(op, "Unhandled patch operation");
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
return safeParseGraphDoc({
|
|
302
|
+
...graph,
|
|
303
|
+
lanes: working.lanes,
|
|
304
|
+
nodes: working.nodes,
|
|
305
|
+
edges: working.edges,
|
|
306
|
+
flows: working.flows,
|
|
307
|
+
stats: working.stats,
|
|
308
|
+
views,
|
|
309
|
+
layout,
|
|
310
|
+
});
|
|
311
|
+
};
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* Applies a patch document, honouring the target it declares.
|
|
315
|
+
*
|
|
316
|
+
* `applyPatch` takes bare operations and trusts the caller to have picked the
|
|
317
|
+
* right document; this checks first. A baseline map is long-lived and patched
|
|
318
|
+
* repeatedly, so applying a patch to the wrong map, or to one that has moved
|
|
319
|
+
* on since the patch was written, has to fail loudly rather than merge.
|
|
320
|
+
*
|
|
321
|
+
* A stored map is a snapshot, not a diff: everything in it is `unchanged`,
|
|
322
|
+
* and `base` and `head` both name the single commit it reflects. So both
|
|
323
|
+
* advance to the patch's `toSha` — leaving `base` behind would claim the
|
|
324
|
+
* elements this patch just added had been there, unchanged, all along. The
|
|
325
|
+
* commit the map came from stays recorded on the patch itself.
|
|
326
|
+
*
|
|
327
|
+
* That snapshot rule is checked rather than assumed, on the way in as well as
|
|
328
|
+
* on the way out: a patch is free to carry `added` or `modified` elements, and
|
|
329
|
+
* neither a patch that leaves them in the map nor one that quietly launders a
|
|
330
|
+
* map that already carries them is allowed through.
|
|
331
|
+
*
|
|
332
|
+
* The target rules are re-checked here rather than trusted from parsing. A
|
|
333
|
+
* zod refinement does not survive into the inferred type, so a caller holding
|
|
334
|
+
* a `PatchDoc` it assembled itself would otherwise skip them.
|
|
335
|
+
*/
|
|
336
|
+
export const applyPatchDoc = (graph: GraphDoc, patch: PatchDoc): Parsed<GraphDoc> => {
|
|
337
|
+
const { graphId, fromSha, toSha } = patch.target;
|
|
338
|
+
|
|
339
|
+
const reject = (
|
|
340
|
+
code: SchemaIssue["code"],
|
|
341
|
+
message: string,
|
|
342
|
+
issue: SchemaIssue,
|
|
343
|
+
): Parsed<GraphDoc> => ({
|
|
344
|
+
ok: false,
|
|
345
|
+
error: new PrLensSchemaError(code, message, [issue]),
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
const rejectIssues = (
|
|
349
|
+
code: SchemaIssue["code"],
|
|
350
|
+
message: string,
|
|
351
|
+
issues: SchemaIssue[],
|
|
352
|
+
): Parsed<GraphDoc> => ({
|
|
353
|
+
ok: false,
|
|
354
|
+
error: new PrLensSchemaError(code, `${message}:\n${formatIssues(issues)}`, issues),
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
if (!targetDescribesATransition(patch.target))
|
|
358
|
+
return reject(
|
|
359
|
+
"PATCH_CONFLICT",
|
|
360
|
+
`patch moves the map from ${fromSha} to the same commit, so it describes no transition`,
|
|
361
|
+
{ code: "PATCH_CONFLICT", path: "target.toSha", message: "no transition" },
|
|
362
|
+
);
|
|
363
|
+
|
|
364
|
+
const contamination = graphSnapshotIssues(graph);
|
|
365
|
+
if (contamination.length > 0)
|
|
366
|
+
return rejectIssues("NOT_A_SNAPSHOT", "the graph being patched is not a stored map", contamination);
|
|
367
|
+
|
|
368
|
+
if (graph.id !== graphId)
|
|
369
|
+
return reject(
|
|
370
|
+
"PATCH_CONFLICT",
|
|
371
|
+
`patch targets graph '${graphId}' but was applied to '${graph.id ?? "an unidentified graph"}'`,
|
|
372
|
+
{ code: "PATCH_CONFLICT", path: "target.graphId", message: "wrong graph" },
|
|
373
|
+
);
|
|
374
|
+
|
|
375
|
+
if (graph.provenance.head.sha !== fromSha)
|
|
376
|
+
return reject(
|
|
377
|
+
"PATCH_CONFLICT",
|
|
378
|
+
`patch expects the graph at ${fromSha} but it reflects ${graph.provenance.head.sha}`,
|
|
379
|
+
{ code: "PATCH_CONFLICT", path: "target.fromSha", message: "stale baseline" },
|
|
380
|
+
);
|
|
381
|
+
|
|
382
|
+
const applied = applyPatch(graph, patch.ops);
|
|
383
|
+
if (!applied.ok) return applied;
|
|
384
|
+
|
|
385
|
+
const snapshot: GraphDoc = {
|
|
386
|
+
...applied.value,
|
|
387
|
+
provenance: {
|
|
388
|
+
...applied.value.provenance,
|
|
389
|
+
base: { ...applied.value.provenance.base, sha: toSha },
|
|
390
|
+
head: { ...applied.value.provenance.head, sha: toSha },
|
|
391
|
+
},
|
|
392
|
+
};
|
|
393
|
+
|
|
394
|
+
const issues = graphSnapshotIssues(snapshot);
|
|
395
|
+
if (issues.length > 0)
|
|
396
|
+
return rejectIssues("NOT_A_SNAPSHOT", "patch leaves a map that is not a stored map", issues);
|
|
397
|
+
|
|
398
|
+
return { ok: true, value: snapshot };
|
|
399
|
+
};
|