@dudousxd/nestjs-catalog 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 (47) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +123 -0
  3. package/dist/catalog.controller.d.ts +8 -0
  4. package/dist/catalog.controller.js +482 -0
  5. package/dist/catalog.decorators.d.ts +37 -0
  6. package/dist/catalog.decorators.js +50 -0
  7. package/dist/catalog.environment.d.ts +442 -0
  8. package/dist/catalog.environment.js +645 -0
  9. package/dist/catalog.events.d.ts +179 -0
  10. package/dist/catalog.events.js +110 -0
  11. package/dist/catalog.module.d.ts +5 -0
  12. package/dist/catalog.module.js +71 -0
  13. package/dist/catalog.options.d.ts +79 -0
  14. package/dist/catalog.options.js +4 -0
  15. package/dist/catalog.overlay-store.d.ts +25 -0
  16. package/dist/catalog.overlay-store.js +44 -0
  17. package/dist/catalog.overlay-store.token.d.ts +1 -0
  18. package/dist/catalog.overlay-store.token.js +4 -0
  19. package/dist/catalog.pipeline.d.ts +800 -0
  20. package/dist/catalog.pipeline.js +606 -0
  21. package/dist/catalog.principal.d.ts +209 -0
  22. package/dist/catalog.principal.js +245 -0
  23. package/dist/catalog.query-cache.d.ts +25 -0
  24. package/dist/catalog.query-cache.js +0 -0
  25. package/dist/catalog.query.d.ts +76 -0
  26. package/dist/catalog.query.js +64 -0
  27. package/dist/catalog.registry.base.d.ts +21 -0
  28. package/dist/catalog.registry.base.js +17 -0
  29. package/dist/catalog.registry.d.ts +44 -0
  30. package/dist/catalog.registry.js +359 -0
  31. package/dist/catalog.service.d.ts +115 -0
  32. package/dist/catalog.service.js +366 -0
  33. package/dist/catalog.store.d.ts +419 -0
  34. package/dist/catalog.store.js +175 -0
  35. package/dist/catalog.types.d.ts +165 -0
  36. package/dist/catalog.types.js +19 -0
  37. package/dist/catalog.workspace.d.ts +426 -0
  38. package/dist/catalog.workspace.js +87 -0
  39. package/dist/client.d.ts +86 -0
  40. package/dist/client.js +83 -0
  41. package/dist/index.d.ts +19 -0
  42. package/dist/index.js +109 -0
  43. package/dist/stores/mikro-orm-read.store.d.ts +20 -0
  44. package/dist/stores/mikro-orm-read.store.js +120 -0
  45. package/dist/transform-runner.d.ts +54 -0
  46. package/dist/transform-runner.js +280 -0
  47. package/package.json +54 -0
@@ -0,0 +1,606 @@
1
+ "use strict";
2
+ /**
3
+ * Getting data in: where it comes from, and what turns it into rows.
4
+ *
5
+ * The shape NiFi and Airflow both settle on — a source, a transform, a sink —
6
+ * with the sink fixed, because the sink is the whole point of a catalog. What
7
+ * is deliberately *not* here is a scheduler: the durable engine already
8
+ * schedules, retries and checkpoints, and writing a second one would mean two
9
+ * systems each believing they decide when a load runs.
10
+ */
11
+ Object.defineProperty(exports, "__esModule", { value: true });
12
+ exports.CATALOG_PIPELINE_STORE = exports.WORKFLOW_ISSUE_CODES = exports.WORKFLOW_EXECUTION_MODES = exports.WORKFLOW_NODE_ID_PATTERN = exports.WORKFLOW_NODE_KINDS = exports.TRANSFORM_RUNNER = exports.TRANSFORM_LANGUAGES = exports.CONNECTOR_KINDS = void 0;
13
+ exports.isConnectorKind = isConnectorKind;
14
+ exports.isTransformLanguage = isTransformLanguage;
15
+ exports.isWorkflowNodeKind = isWorkflowNodeKind;
16
+ exports.isWorkflowExecutionMode = isWorkflowExecutionMode;
17
+ exports.validateWorkflow = validateWorkflow;
18
+ exports.workflowRunOrder = workflowRunOrder;
19
+ exports.workflowGraphHash = workflowGraphHash;
20
+ exports.isWorkflowNode = isWorkflowNode;
21
+ exports.isWorkflowEdge = isWorkflowEdge;
22
+ exports.supportsWorkflows = supportsWorkflows;
23
+ exports.supportsWorkflowStages = supportsWorkflowStages;
24
+ exports.isPipelineStore = isPipelineStore;
25
+ /**
26
+ * Where a connector pulls from.
27
+ *
28
+ * Deliberately a short list. Every kind here is one this service can actually
29
+ * execute — a kind that exists in the type and throws at run time is worse than
30
+ * one that is absent, because the first looks supported in a dropdown.
31
+ */
32
+ exports.CONNECTOR_KINDS = [
33
+ /** A JSON endpoint. */
34
+ 'http',
35
+ /** A SQL database, by connection URL. Read-only by construction. */
36
+ 'sql',
37
+ /** A file: local path, or anything `fetch` can GET — CSV, NDJSON, JSON. */
38
+ 'file',
39
+ /** An object store bucket and prefix — S3, MinIO, anything S3-compatible. */
40
+ 's3',
41
+ /** Records pasted into the config. For trying a transform against real shapes. */
42
+ 'inline',
43
+ ];
44
+ /**
45
+ * The type is derived from the list, not written beside it.
46
+ *
47
+ * A second hand-maintained copy of these names is the bug this shape exists to
48
+ * prevent: a store that narrows a database string against its own stale array
49
+ * and falls back to a default turns "the kind you chose" into "the kind that
50
+ * happened to be first", and the resulting failure names the wrong source
51
+ * entirely. Anything narrowing a stored value narrows against *this*.
52
+ */
53
+ function isConnectorKind(value) {
54
+ return exports.CONNECTOR_KINDS.some((kind) => kind === value);
55
+ }
56
+ /**
57
+ * TypeScript is Node's own type stripping, so it costs no compiler and no build
58
+ * step — and types are erased, never checked. A transform with a wrong type
59
+ * still runs; the editor's try pane is what catches it.
60
+ */
61
+ exports.TRANSFORM_LANGUAGES = ['javascript', 'typescript', 'python'];
62
+ /** Same reason as {@link isConnectorKind}: one list, no second copy to drift. */
63
+ function isTransformLanguage(value) {
64
+ return exports.TRANSFORM_LANGUAGES.some((language) => language === value);
65
+ }
66
+ exports.TRANSFORM_RUNNER = Symbol('TRANSFORM_RUNNER');
67
+ /* ---------------------------------------------------------------------------
68
+ * Workflows: a graph of steps that ends in exactly one commit.
69
+ *
70
+ * **Why "workflow" and not "flow".** `FlowView` in the React package is
71
+ * deliberately *derived* lineage: it reconstructs who fed what from the audit
72
+ * trail, on the argument that the graph is whatever the publishers actually
73
+ * did, which is more truthful than a diagram someone has to remember to update.
74
+ * This is the opposite object — authored by a person, executed as written, and
75
+ * wrong the moment it disagrees with intent rather than with history. Sharing
76
+ * the word would put a screen called "Flow" that infers and a screen called
77
+ * "Flow" that declares next to each other in the same console, and the first
78
+ * question every reader would ask is which one is real.
79
+ *
80
+ * "Workflow" is the word the rest of this ecosystem already uses for authored,
81
+ * ordered, resumable work — `@dudousxd/nestjs-durable` calls its unit a
82
+ * workflow and its parts steps — and that agreement is earned rather than
83
+ * borrowed: when durable is available a catalog workflow *is* compiled into a
84
+ * durable workflow, one step per node. "Pipeline" was the other candidate and
85
+ * is already taken by this file's subject as a whole (`CatalogPipelineStore`
86
+ * holds connectors, transforms and connections), so it would have named both
87
+ * the container and one thing inside it.
88
+ * ------------------------------------------------------------------------- */
89
+ /**
90
+ * What a node can be.
91
+ *
92
+ * Three kinds, and they are exactly the three verbs the existing connector
93
+ * runner already performs in sequence: fetch, transform, publish. Nothing here
94
+ * is a kind this service cannot execute, which is the same rule
95
+ * {@link CONNECTOR_KINDS} follows — a kind that exists in the type and throws
96
+ * at run time is worse than one that is absent, because the first looks
97
+ * supported in a palette.
98
+ *
99
+ * The kinds that were considered and rejected, since a small vocabulary is only
100
+ * defensible if the omissions are:
101
+ *
102
+ * - **filter** — a transform whose code returns a subset of what it was given.
103
+ * It needs no new execution path, only a different body, and adding the kind
104
+ * would mean two ways to drop rows and two places to look when rows go
105
+ * missing.
106
+ * - **branch / split** — already expressible: a node with two outbound edges is
107
+ * read by both successors, each of which filters differently. There is
108
+ * nothing for a branch node to *do*.
109
+ * - **merge / join** — a node with several inbound edges receives its inputs
110
+ * concatenated in edge order (see {@link WorkflowEdge}). A keyed join is then
111
+ * ordinary code inside the transform, which can already see every record.
112
+ * A `merge` kind would have had to carry a strategy field whose values the
113
+ * runner would have to implement one by one, and an unimplemented strategy in
114
+ * a dropdown is the failure this list exists to avoid.
115
+ */
116
+ exports.WORKFLOW_NODE_KINDS = [
117
+ /** Reads records out of a system. The roots of the graph. */
118
+ 'source',
119
+ /** Runs a {@link CatalogTransform} over what it is given. */
120
+ 'transform',
121
+ /** Writes into an object type and commits. Exactly one per workflow. */
122
+ 'sink',
123
+ ];
124
+ /** Same reason as {@link isConnectorKind}: one list, no second copy to drift. */
125
+ function isWorkflowNodeKind(value) {
126
+ return exports.WORKFLOW_NODE_KINDS.some((kind) => kind === value);
127
+ }
128
+ /**
129
+ * The longest a node id may be, and the alphabet it may use.
130
+ *
131
+ * Constrained rather than free-form for two concrete reasons. A node id becomes
132
+ * the name of a durable step, and durable step names are how a replay finds the
133
+ * checkpoint it already wrote — a step renamed between runs re-executes work
134
+ * that was already done. And staged rows are addressed by a key built from the
135
+ * run id, the node id and the batch number, so a node id containing the
136
+ * separator would let one node read another's rows.
137
+ */
138
+ exports.WORKFLOW_NODE_ID_PATTERN = /^[A-Za-z0-9_-]{1,64}$/;
139
+ /**
140
+ * How a workflow run is executed here.
141
+ *
142
+ * `CATALOG_DURABLE` is `own`, `attach` or `off`, so a deployment may genuinely
143
+ * have no engine, and the model must not pretend otherwise. A workflow that
144
+ * appeared checkpointed and was not would be the same silent no-op this codebase
145
+ * already had to remove once, when connectors carried a `schedule` field that
146
+ * nothing read for months.
147
+ */
148
+ exports.WORKFLOW_EXECUTION_MODES = [
149
+ /**
150
+ * Each node is a durable step. A ten-node graph that fails at node seven
151
+ * resumes at node seven, because the six before it have checkpoints.
152
+ */
153
+ 'durable',
154
+ /**
155
+ * The whole graph runs inside one call. It still runs, and it still commits
156
+ * atomically at the sink — but a failure at node seven re-runs node one.
157
+ */
158
+ 'inline',
159
+ ];
160
+ function isWorkflowExecutionMode(value) {
161
+ return exports.WORKFLOW_EXECUTION_MODES.some((mode) => mode === value);
162
+ }
163
+ /** Every way a graph can be refused. Exported so a canvas can key off the code. */
164
+ exports.WORKFLOW_ISSUE_CODES = [
165
+ 'empty',
166
+ 'invalid-node-id',
167
+ 'duplicate-node-id',
168
+ 'edge-endpoint-missing',
169
+ 'self-edge',
170
+ 'duplicate-edge',
171
+ 'cycle',
172
+ 'no-source',
173
+ 'source-has-input',
174
+ 'no-sink',
175
+ 'duplicate-sink-type',
176
+ 'sink-has-output',
177
+ 'unreachable',
178
+ 'dead-end',
179
+ 'transform-not-named',
180
+ ];
181
+ /**
182
+ * Everything that makes a graph unrunnable, in one pure function.
183
+ *
184
+ * Pure and dependency-free on purpose, and exported from the browser entry point
185
+ * as well as the server one, so the canvas and the store run *the same*
186
+ * validator. A canvas that validates against its own copy of these rules and a
187
+ * server that validates against another is a canvas that eventually lies —
188
+ * either by refusing something the server would accept, or, far worse, by
189
+ * accepting something the server then rejects at run time, halfway through a
190
+ * load. The server still calls this itself: shared code is not the same as
191
+ * trusted input, and the store must refuse a graph that arrived by curl.
192
+ *
193
+ * Structural problems are reported alone. Reachability computed over edges that
194
+ * point at nodes which do not exist produces a second page of consequences, and
195
+ * burying the one real problem under them is how a validation message stops
196
+ * being read.
197
+ */
198
+ function validateWorkflow(graph) {
199
+ const issues = [];
200
+ const nodes = graph.nodes ?? [];
201
+ const edges = graph.edges ?? [];
202
+ if (nodes.length === 0) {
203
+ return [
204
+ {
205
+ code: 'empty',
206
+ nodeIds: [],
207
+ message: 'This workflow has no nodes. Running it would commit an empty snapshot over whatever is live, so it is refused rather than saved as something that looks runnable.',
208
+ },
209
+ ];
210
+ }
211
+ const byId = new Map();
212
+ for (const node of nodes) {
213
+ if (!exports.WORKFLOW_NODE_ID_PATTERN.test(node.id)) {
214
+ issues.push({
215
+ code: 'invalid-node-id',
216
+ nodeIds: [node.id],
217
+ message: `Node id "${node.id}" is not usable. Ids may be 1-64 characters of letters, digits, underscore or hyphen: the id becomes a durable step name and part of the key its staged rows are stored under, and neither can carry arbitrary text safely.`,
218
+ });
219
+ // Registered anyway, deliberately. Leaving it out would make every edge
220
+ // touching it report a *missing node* as well, which sends the reader
221
+ // looking for a node they can see on the canvas. One problem, one message.
222
+ }
223
+ if (byId.has(node.id)) {
224
+ issues.push({
225
+ code: 'duplicate-node-id',
226
+ nodeIds: [node.id],
227
+ message: `Two nodes share the id "${node.id}". Edges name nodes by id, so a duplicate makes every wire touching it ambiguous.`,
228
+ });
229
+ continue;
230
+ }
231
+ byId.set(node.id, node);
232
+ }
233
+ const seenEdges = new Set();
234
+ for (const edge of edges) {
235
+ if (!byId.has(edge.from) || !byId.has(edge.to)) {
236
+ const missing = byId.has(edge.from) ? edge.to : edge.from;
237
+ issues.push({
238
+ code: 'edge-endpoint-missing',
239
+ nodeIds: [edge.from, edge.to],
240
+ message: `The edge "${edge.from}" → "${edge.to}" names node "${missing}", which is not in this workflow. It was most likely deleted while the wire stayed behind.`,
241
+ });
242
+ continue;
243
+ }
244
+ if (edge.from === edge.to) {
245
+ issues.push({
246
+ code: 'self-edge',
247
+ nodeIds: [edge.from],
248
+ message: `Node "${edge.from}" is wired to itself, which has no order to run in.`,
249
+ });
250
+ continue;
251
+ }
252
+ const key = `${edge.from}${edge.to}`;
253
+ if (seenEdges.has(key)) {
254
+ issues.push({
255
+ code: 'duplicate-edge',
256
+ nodeIds: [edge.from, edge.to],
257
+ message: `Node "${edge.from}" is wired into "${edge.to}" twice, so "${edge.to}" would receive the same rows twice and silently double its input.`,
258
+ });
259
+ continue;
260
+ }
261
+ seenEdges.add(key);
262
+ }
263
+ // Reachability and cycles are only meaningful once the graph is structurally
264
+ // sound. See the note on this function.
265
+ if (issues.length > 0)
266
+ return issues;
267
+ const outgoing = new Map();
268
+ const incoming = new Map();
269
+ for (const node of nodes) {
270
+ outgoing.set(node.id, []);
271
+ incoming.set(node.id, []);
272
+ }
273
+ for (const edge of edges) {
274
+ outgoing.get(edge.from)?.push(edge.to);
275
+ incoming.get(edge.to)?.push(edge.from);
276
+ }
277
+ const sources = [];
278
+ const sinks = [];
279
+ for (const node of nodes) {
280
+ if (node.kind === 'source')
281
+ sources.push(node);
282
+ if (node.kind === 'sink')
283
+ sinks.push(node);
284
+ if (node.kind === 'source' && (incoming.get(node.id)?.length ?? 0) > 0) {
285
+ issues.push({
286
+ code: 'source-has-input',
287
+ nodeIds: [node.id],
288
+ message: `Source "${node.name}" (${node.id}) has an inbound edge. A source reads from a system, not from another node; wire that node into a transform instead.`,
289
+ });
290
+ }
291
+ if (node.kind === 'sink' && (outgoing.get(node.id)?.length ?? 0) > 0) {
292
+ issues.push({
293
+ code: 'sink-has-output',
294
+ nodeIds: [node.id],
295
+ message: `Sink "${node.name}" (${node.id}) has an outbound edge. The sink commits the snapshot, so nothing can run after it.`,
296
+ });
297
+ }
298
+ if (node.kind === 'transform' && node.transformId.length === 0) {
299
+ issues.push({
300
+ code: 'transform-not-named',
301
+ nodeIds: [node.id],
302
+ message: `Transform node "${node.name}" (${node.id}) names no transform, so there is no code for it to run.`,
303
+ });
304
+ }
305
+ }
306
+ if (sources.length === 0) {
307
+ issues.push({
308
+ code: 'no-source',
309
+ nodeIds: [],
310
+ message: 'This workflow has no source node, so nothing would ever be read and the sink would commit an empty snapshot.',
311
+ });
312
+ }
313
+ if (sinks.length === 0) {
314
+ issues.push({
315
+ code: 'no-sink',
316
+ nodeIds: [],
317
+ message: 'This workflow has no sink node. A workflow ends at a sink, because the sink is what writes and commits — without one the graph computes rows and throws them away.',
318
+ });
319
+ }
320
+ // Several sinks are allowed, and the reason is the point of having a graph at
321
+ // all: one expensive read feeding several outputs. Forbidding it would mean
322
+ // pulling the same ten million rows twice to derive two types from them.
323
+ //
324
+ // What is refused is two sinks writing the *same* type. Each sink commits its
325
+ // own type independently — there is no distributed transaction here and the
326
+ // model does not pretend otherwise — but two snapshots of one type in one run
327
+ // leaves nothing to say which of them the readers should get.
328
+ const byTargetType = new Map();
329
+ for (const sink of sinks) {
330
+ const sharing = byTargetType.get(sink.targetType) ?? [];
331
+ sharing.push(sink);
332
+ byTargetType.set(sink.targetType, sharing);
333
+ }
334
+ for (const [targetType, sharing] of byTargetType) {
335
+ if (sharing.length < 2)
336
+ continue;
337
+ issues.push({
338
+ code: 'duplicate-sink-type',
339
+ nodeIds: sharing.map((sink) => sink.id),
340
+ message: `${sharing
341
+ .map((sink) => `"${sink.name}" (${sink.id})`)
342
+ .join(' and ')} both commit ${targetType}. Two snapshots of one type in a single run leaves nothing to say which one readers should get — wire these branches into one sink, or send them to different types.`,
343
+ });
344
+ }
345
+ // Cycles, by Kahn's algorithm: whatever is left with a non-zero in-degree
346
+ // after the queue drains is on one.
347
+ const indegree = new Map();
348
+ for (const node of nodes) {
349
+ indegree.set(node.id, incoming.get(node.id)?.length ?? 0);
350
+ }
351
+ const queue = nodes.filter((node) => indegree.get(node.id) === 0).map((n) => n.id);
352
+ const ordered = [];
353
+ while (queue.length > 0) {
354
+ const id = queue.shift();
355
+ if (id === undefined)
356
+ break;
357
+ ordered.push(id);
358
+ for (const next of outgoing.get(id) ?? []) {
359
+ const remaining = (indegree.get(next) ?? 0) - 1;
360
+ indegree.set(next, remaining);
361
+ if (remaining === 0)
362
+ queue.push(next);
363
+ }
364
+ }
365
+ if (ordered.length !== nodes.length) {
366
+ // What Kahn's algorithm leaves behind is the cycle *plus* everything
367
+ // downstream of it, because those never had their in-degree resolved
368
+ // either. Naming all of it would point at nodes that are perfectly well
369
+ // wired and merely stuck behind the loop, and a message that names the
370
+ // wrong node is worse than a vague one. Peeling off nodes with no outgoing
371
+ // edge inside the leftover set, repeatedly, strips exactly those tails and
372
+ // leaves the nodes actually on the loop.
373
+ const stuck = new Set(nodes.filter((node) => !ordered.includes(node.id)).map((node) => node.id));
374
+ for (let peeled = true; peeled;) {
375
+ peeled = false;
376
+ for (const id of stuck) {
377
+ const continues = (outgoing.get(id) ?? []).some((next) => stuck.has(next));
378
+ if (continues)
379
+ continue;
380
+ stuck.delete(id);
381
+ peeled = true;
382
+ }
383
+ }
384
+ const looped = [...stuck];
385
+ issues.push({
386
+ code: 'cycle',
387
+ nodeIds: looped,
388
+ message: `These nodes form a cycle: ${looped.join(' → ')}. A graph that loops has no order to run in and no point at which the load is finished, so it is refused rather than run until something times out.`,
389
+ });
390
+ // Reachability over a cyclic graph reports nodes as unreachable that are
391
+ // only unreachable *because* of the cycle, which points at the wrong boxes.
392
+ return issues;
393
+ }
394
+ const reachableFromSources = walk(sources.map((node) => node.id), outgoing);
395
+ const reachesASink = walk(sinks.map((sink) => sink.id), incoming);
396
+ for (const node of nodes) {
397
+ if (sources.length > 0 && !reachableFromSources.has(node.id)) {
398
+ issues.push({
399
+ code: 'unreachable',
400
+ nodeIds: [node.id],
401
+ message: `Node "${node.name}" (${node.id}) is not reachable from any source, so it would never run. Wire a source into it or delete it — a node on the canvas that silently does nothing is the thing this check exists to prevent.`,
402
+ });
403
+ continue;
404
+ }
405
+ if (sinks.length > 0 && !reachesASink.has(node.id)) {
406
+ issues.push({
407
+ code: 'dead-end',
408
+ nodeIds: [node.id],
409
+ message: `Node "${node.name}" (${node.id}) leads nowhere: nothing it produces reaches the sink, so it would be computed and thrown away. Every path has to end at the sink.`,
410
+ });
411
+ }
412
+ }
413
+ return issues;
414
+ }
415
+ /** Breadth-first reachability over one adjacency map. */
416
+ function walk(roots, adjacency) {
417
+ const seen = new Set(roots);
418
+ const queue = [...roots];
419
+ while (queue.length > 0) {
420
+ const id = queue.shift();
421
+ if (id === undefined)
422
+ break;
423
+ for (const next of adjacency.get(id) ?? []) {
424
+ if (seen.has(next))
425
+ continue;
426
+ seen.add(next);
427
+ queue.push(next);
428
+ }
429
+ }
430
+ return seen;
431
+ }
432
+ /**
433
+ * The order the nodes run in, and the inputs each one gets.
434
+ *
435
+ * Here rather than in the runner because the wiring rules — a node runs after
436
+ * everything wired into it, and receives its inputs in edge order — are the same
437
+ * rules {@link validateWorkflow} enforces, and two implementations of one rule
438
+ * is how a graph that validated comes out executing differently.
439
+ *
440
+ * Throws on an invalid graph rather than returning a best effort. A partial
441
+ * order over a broken graph is a load that half-happens, which is harder to
442
+ * recover from than one that never started.
443
+ */
444
+ function workflowRunOrder(graph) {
445
+ const issues = validateWorkflow(graph);
446
+ if (issues.length > 0) {
447
+ throw new Error(`Refusing to order an invalid workflow: ${issues.map((issue) => issue.message).join(' ')}`);
448
+ }
449
+ const byId = new Map(graph.nodes.map((node) => [node.id, node]));
450
+ const indegree = new Map();
451
+ const outgoing = new Map();
452
+ for (const node of graph.nodes) {
453
+ indegree.set(node.id, 0);
454
+ outgoing.set(node.id, []);
455
+ }
456
+ for (const edge of graph.edges) {
457
+ indegree.set(edge.to, (indegree.get(edge.to) ?? 0) + 1);
458
+ outgoing.get(edge.from)?.push(edge.to);
459
+ }
460
+ const ready = graph.nodes.filter((node) => indegree.get(node.id) === 0).map((node) => node.id);
461
+ const order = [];
462
+ while (ready.length > 0) {
463
+ const id = ready.shift();
464
+ if (id === undefined)
465
+ break;
466
+ const node = byId.get(id);
467
+ if (!node)
468
+ continue;
469
+ // Edge order, not node order: this is the array a merge reads its inputs
470
+ // from, and it is part of the fingerprint precisely because it is visible in
471
+ // the output.
472
+ const inputs = graph.edges.filter((edge) => edge.to === id).map((edge) => edge.from);
473
+ order.push({ node, inputs });
474
+ for (const next of outgoing.get(id) ?? []) {
475
+ const remaining = (indegree.get(next) ?? 0) - 1;
476
+ indegree.set(next, remaining);
477
+ if (remaining === 0)
478
+ ready.push(next);
479
+ }
480
+ }
481
+ return order;
482
+ }
483
+ /**
484
+ * A stable fingerprint of what a graph *does*.
485
+ *
486
+ * Behaviour only: node ids, kinds, the configuration each kind executes on, and
487
+ * the edges in their order. Names and canvas positions are excluded, so moving a
488
+ * box or fixing a typo in a label does not bump the version — the same rule
489
+ * `saveTransform` already applies when it bumps only on a code change, and for
490
+ * the same reason. A version number inflated by cosmetic edits is useless for
491
+ * the one question it exists to answer.
492
+ *
493
+ * Nodes are sorted by id because their array order changes nothing; edges are
494
+ * deliberately *not* sorted, because their order decides what a node with
495
+ * several inputs receives.
496
+ *
497
+ * FNV-1a rather than a hash from `node:crypto`: this file is imported by the
498
+ * browser entry point, and it is change detection rather than a security
499
+ * primitive — nobody is defending against a chosen-collision attack on their own
500
+ * canvas. Two passes with different offsets are concatenated, which is enough
501
+ * spread that an accidental collision between two graphs of one workflow is not
502
+ * a thing to plan for.
503
+ */
504
+ function workflowGraphHash(graph) {
505
+ const nodes = [...graph.nodes]
506
+ .sort((left, right) => (left.id < right.id ? -1 : left.id > right.id ? 1 : 0))
507
+ .map((node) => canonicalNode(node));
508
+ const edges = graph.edges.map((edge) => `${edge.from}>${edge.to}`);
509
+ const canonical = JSON.stringify({ nodes, edges });
510
+ return `${fnv1a(canonical, 0x811c9dc5)}${fnv1a(canonical, 0x01000193)}`;
511
+ }
512
+ /** The parts of a node that change what a run produces. */
513
+ function canonicalNode(node) {
514
+ if (node.kind === 'source') {
515
+ return JSON.stringify([
516
+ node.id,
517
+ node.kind,
518
+ node.sourceKind,
519
+ node.connectionId ?? '',
520
+ node.secretEnvVar ?? '',
521
+ node.mode ?? 'full',
522
+ // Sorted keys, so a canvas that rewrites the object in a different order
523
+ // does not look like an edit.
524
+ sortedEntries(node.config),
525
+ ]);
526
+ }
527
+ if (node.kind === 'transform') {
528
+ // The transform's *version* is deliberately not in here. Editing a
529
+ // transform is already recorded as a new transform version, and folding it
530
+ // in would bump every graph that references it — which would say the wiring
531
+ // changed when it did not.
532
+ return JSON.stringify([node.id, node.kind, node.transformId]);
533
+ }
534
+ return JSON.stringify([node.id, node.kind, node.targetType, node.mode ?? 'full']);
535
+ }
536
+ function sortedEntries(config) {
537
+ return Object.keys(config)
538
+ .sort()
539
+ .map((key) => [key, config[key]]);
540
+ }
541
+ function fnv1a(input, offset) {
542
+ let hash = offset;
543
+ for (let index = 0; index < input.length; index += 1) {
544
+ hash ^= input.charCodeAt(index);
545
+ // The 32-bit FNV prime, by shifts rather than multiplication, so the result
546
+ // stays inside a 32-bit integer instead of drifting through float precision.
547
+ hash += (hash << 1) + (hash << 4) + (hash << 7) + (hash << 8) + (hash << 24);
548
+ hash >>>= 0;
549
+ }
550
+ return hash.toString(16).padStart(8, '0');
551
+ }
552
+ /**
553
+ * Narrow a stored node, loudly.
554
+ *
555
+ * Used when reading a graph back out of a JSON column, and it throws rather than
556
+ * skipping what it does not recognise for a reason specific to graphs: dropping
557
+ * an unknown node silently changes what the workflow does — the surrounding
558
+ * edges then point at nothing, or worse, the graph still validates and simply
559
+ * omits a step — and a load that quietly ran nine of ten nodes is far harder to
560
+ * notice than one that refused to start.
561
+ */
562
+ function isWorkflowNode(value) {
563
+ if (typeof value !== 'object' || value === null)
564
+ return false;
565
+ const id = Reflect.get(value, 'id');
566
+ const name = Reflect.get(value, 'name');
567
+ const kind = Reflect.get(value, 'kind');
568
+ if (typeof id !== 'string' || typeof name !== 'string')
569
+ return false;
570
+ if (!isWorkflowNodeKind(kind))
571
+ return false;
572
+ if (kind === 'transform') {
573
+ return typeof Reflect.get(value, 'transformId') === 'string';
574
+ }
575
+ if (kind === 'sink') {
576
+ return typeof Reflect.get(value, 'targetType') === 'string';
577
+ }
578
+ const sourceKind = Reflect.get(value, 'sourceKind');
579
+ const config = Reflect.get(value, 'config');
580
+ return isConnectorKind(sourceKind) && typeof config === 'object' && config !== null;
581
+ }
582
+ function isWorkflowEdge(value) {
583
+ if (typeof value !== 'object' || value === null)
584
+ return false;
585
+ return (typeof Reflect.get(value, 'from') === 'string' && typeof Reflect.get(value, 'to') === 'string');
586
+ }
587
+ /**
588
+ * Whether this store can hold workflows at all.
589
+ *
590
+ * Checks the methods rather than a flag, the same way {@link isPipelineStore}
591
+ * does, because a flag is a claim and a method is the thing itself.
592
+ */
593
+ function supportsWorkflows(store) {
594
+ return (typeof store.listWorkflows === 'function' &&
595
+ typeof store.getWorkflow === 'function' &&
596
+ typeof store.saveWorkflow === 'function');
597
+ }
598
+ function supportsWorkflowStages(store) {
599
+ return typeof store.writeStage === 'function' && typeof store.readStage === 'function';
600
+ }
601
+ exports.CATALOG_PIPELINE_STORE = Symbol('CATALOG_PIPELINE_STORE');
602
+ function isPipelineStore(store) {
603
+ return (typeof store === 'object' &&
604
+ store !== null &&
605
+ typeof Reflect.get(store, 'listConnectors') === 'function');
606
+ }