@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.
Files changed (93) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +150 -0
  3. package/dist/apply.d.ts +42 -0
  4. package/dist/apply.d.ts.map +1 -0
  5. package/dist/apply.js +315 -0
  6. package/dist/apply.js.map +1 -0
  7. package/dist/config.d.ts +58 -0
  8. package/dist/config.d.ts.map +1 -0
  9. package/dist/config.js +61 -0
  10. package/dist/config.js.map +1 -0
  11. package/dist/errors.d.ts +24 -0
  12. package/dist/errors.d.ts.map +1 -0
  13. package/dist/errors.js +12 -0
  14. package/dist/errors.js.map +1 -0
  15. package/dist/examples/baseline.d.ts +18 -0
  16. package/dist/examples/baseline.d.ts.map +1 -0
  17. package/dist/examples/baseline.js +433 -0
  18. package/dist/examples/baseline.js.map +1 -0
  19. package/dist/examples/index.d.ts +1194 -0
  20. package/dist/examples/index.d.ts.map +1 -0
  21. package/dist/examples/index.js +20 -0
  22. package/dist/examples/index.js.map +1 -0
  23. package/dist/examples/minimal.d.ts +4 -0
  24. package/dist/examples/minimal.d.ts.map +1 -0
  25. package/dist/examples/minimal.js +25 -0
  26. package/dist/examples/minimal.js.map +1 -0
  27. package/dist/examples/postmark-refactor.d.ts +16 -0
  28. package/dist/examples/postmark-refactor.d.ts.map +1 -0
  29. package/dist/examples/postmark-refactor.js +468 -0
  30. package/dist/examples/postmark-refactor.js.map +1 -0
  31. package/dist/graph.d.ts +568 -0
  32. package/dist/graph.d.ts.map +1 -0
  33. package/dist/graph.js +266 -0
  34. package/dist/graph.js.map +1 -0
  35. package/dist/index.d.ts +12 -0
  36. package/dist/index.d.ts.map +1 -0
  37. package/dist/index.js +12 -0
  38. package/dist/index.js.map +1 -0
  39. package/dist/integrity.d.ts +20 -0
  40. package/dist/integrity.d.ts.map +1 -0
  41. package/dist/integrity.js +167 -0
  42. package/dist/integrity.js.map +1 -0
  43. package/dist/manifest.d.ts +65 -0
  44. package/dist/manifest.d.ts.map +1 -0
  45. package/dist/manifest.js +60 -0
  46. package/dist/manifest.js.map +1 -0
  47. package/dist/patch.d.ts +824 -0
  48. package/dist/patch.d.ts.map +1 -0
  49. package/dist/patch.js +84 -0
  50. package/dist/patch.js.map +1 -0
  51. package/dist/primitives.d.ts +92 -0
  52. package/dist/primitives.d.ts.map +1 -0
  53. package/dist/primitives.js +120 -0
  54. package/dist/primitives.js.map +1 -0
  55. package/dist/utils.d.ts +6 -0
  56. package/dist/utils.d.ts.map +1 -0
  57. package/dist/utils.js +8 -0
  58. package/dist/utils.js.map +1 -0
  59. package/dist/validate.d.ts +18 -0
  60. package/dist/validate.d.ts.map +1 -0
  61. package/dist/validate.js +95 -0
  62. package/dist/validate.js.map +1 -0
  63. package/dist/version.d.ts +21 -0
  64. package/dist/version.d.ts.map +1 -0
  65. package/dist/version.js +24 -0
  66. package/dist/version.js.map +1 -0
  67. package/examples/broadcast-baseline.graph.json +289 -0
  68. package/examples/broadcast-baseline.patch.json +321 -0
  69. package/examples/minimal.graph.json +45 -0
  70. package/examples/postmark-refactor.graph.json +580 -0
  71. package/examples/postmark-refactor.render-manifest.json +67 -0
  72. package/examples/pr-lens.config.json +32 -0
  73. package/json-schema/config.schema.json +153 -0
  74. package/json-schema/graph-doc.schema.json +1032 -0
  75. package/json-schema/patch-doc.schema.json +1383 -0
  76. package/json-schema/render-manifest.schema.json +183 -0
  77. package/package.json +66 -0
  78. package/src/apply.ts +399 -0
  79. package/src/config.ts +69 -0
  80. package/src/errors.ts +34 -0
  81. package/src/examples/baseline.ts +437 -0
  82. package/src/examples/index.ts +25 -0
  83. package/src/examples/minimal.ts +26 -0
  84. package/src/examples/postmark-refactor.ts +480 -0
  85. package/src/graph.ts +331 -0
  86. package/src/index.ts +82 -0
  87. package/src/integrity.ts +216 -0
  88. package/src/manifest.ts +64 -0
  89. package/src/patch.ts +100 -0
  90. package/src/primitives.ts +146 -0
  91. package/src/utils.ts +7 -0
  92. package/src/validate.ts +132 -0
  93. 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
+ };