@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,645 @@
1
+ "use strict";
2
+ /**
3
+ * Which copy of the world a caller is looking at.
4
+ *
5
+ * A catalog that only ever has one copy of everything is a catalog where the
6
+ * first time a transform runs against real data is the time it runs against the
7
+ * data people depend on. Environments fix that, and the whole design turns on
8
+ * one distinction that is easy to blur and expensive to get wrong:
9
+ *
10
+ * - **Data never moves between environments.** Not by a button, not by an
11
+ * export, not by an accident of a missing `WHERE` clause. Production's rows
12
+ * are production's, and dev's rows are whatever dev loaded for itself.
13
+ * - **Configuration is promoted between them.** The model (object types and the
14
+ * labels somebody curated onto them) and the pipeline (connectors, transforms)
15
+ * are things you write once, try in dev, and then *release* to production.
16
+ *
17
+ * Isolation is therefore physical — one database per environment, so a
18
+ * statement issued on the dev connection cannot name a production table — and
19
+ * promotion is a deliberate, previewable release rather than a copy. See
20
+ * {@link planPromotion} for exactly what crosses and what is refused.
21
+ *
22
+ * This is not the same axis as a durable *tenant*. A tenant answers "who
23
+ * executes this work"; an environment answers "which copy of the world is it
24
+ * executing against". They are related only in that each environment must get
25
+ * its own durable keyspace ({@link durableKeyspaceFor}), for the reason spelled
26
+ * out there.
27
+ */
28
+ Object.defineProperty(exports, "__esModule", { value: true });
29
+ exports.PROMOTION_AUDIT_EVENT = exports.PROMOTION_WITHHELD_CONNECTION_FIELDS = exports.PROMOTION_WITHHELD_CONNECTOR_FIELDS = exports.PROMOTABLE_KINDS = exports.UnknownEnvironmentError = exports.UnresolvedEnvironmentError = exports.CATALOG_ENVIRONMENT_QUERY = exports.CATALOG_ENVIRONMENT_HEADER = void 0;
30
+ exports.isEnvironmentId = isEnvironmentId;
31
+ exports.assertEnvironmentId = assertEnvironmentId;
32
+ exports.environmentIdFromRequest = environmentIdFromRequest;
33
+ exports.resolveEnvironment = resolveEnvironment;
34
+ exports.durableKeyspaceFor = durableKeyspaceFor;
35
+ exports.catalogDatabaseNameFor = catalogDatabaseNameFor;
36
+ exports.stampEnvironment = stampEnvironment;
37
+ exports.isPromotableKind = isPromotableKind;
38
+ exports.planPromotion = planPromotion;
39
+ exports.isPromotable = isPromotable;
40
+ exports.effectiveChanges = effectiveChanges;
41
+ const node_crypto_1 = require("node:crypto");
42
+ /**
43
+ * Where a request declares its environment.
44
+ *
45
+ * A header rather than a subdomain or a port, because the whole point is that
46
+ * one deployment can serve several environments and the choice has to be
47
+ * visible in the request itself — a proxy rule or a DNS entry is a place the
48
+ * answer can be wrong without anybody reading it.
49
+ */
50
+ exports.CATALOG_ENVIRONMENT_HEADER = 'x-catalog-environment';
51
+ /**
52
+ * The query-parameter spelling, for a browser that cannot set a header — a
53
+ * `<img>` pointing at an embedded chart, a link somebody pastes into a ticket.
54
+ *
55
+ * Deliberately *lower* precedence than the header (see
56
+ * {@link environmentIdFromRequest}). A query string travels in referrers, logs
57
+ * and bookmarks; a header does not, so when both are present the one that was
58
+ * set deliberately by the client wins.
59
+ */
60
+ exports.CATALOG_ENVIRONMENT_QUERY = 'environment';
61
+ /**
62
+ * Deliberately narrow, and narrow for a reason that is not aesthetic.
63
+ *
64
+ * An environment id becomes a database name, a MikroORM context name and a
65
+ * Redis key prefix. All three are places where a hyphen, a quote or a
66
+ * non-ASCII character turns into either a syntax error at the worst possible
67
+ * moment or, worse, an identifier that has to be quoted and therefore an
68
+ * identifier somebody will eventually forget to quote. Twenty-four characters
69
+ * is well inside MySQL's 64-character limit even after a database-name prefix
70
+ * is glued on.
71
+ */
72
+ const ENVIRONMENT_ID_PATTERN = /^[a-z][a-z0-9_]{0,23}$/;
73
+ /**
74
+ * Names an environment may not take.
75
+ *
76
+ * `default` is the important one and it is here for the same reason the durable
77
+ * module refuses it as a tenant: "default" is the value that means "no
78
+ * namespace at all", so an environment called `default` would derive the bare
79
+ * keyspace and quietly share a results queue with every other engine on the
80
+ * Redis. The rest are MySQL's own schemas, which an environment must never be
81
+ * pointed at.
82
+ */
83
+ const RESERVED_ENVIRONMENT_IDS = [
84
+ 'default',
85
+ 'information_schema',
86
+ 'mysql',
87
+ 'performance_schema',
88
+ 'sys',
89
+ ];
90
+ function isEnvironmentId(value) {
91
+ return (typeof value === 'string' &&
92
+ ENVIRONMENT_ID_PATTERN.test(value) &&
93
+ !RESERVED_ENVIRONMENT_IDS.includes(value));
94
+ }
95
+ /**
96
+ * Narrows an id or throws, with the rule in the message.
97
+ *
98
+ * Throwing rather than sanitising: an id that had to be repaired is an id that
99
+ * no longer matches whatever the operator wrote in their configuration, and the
100
+ * two would disagree about which database the environment lives in.
101
+ */
102
+ function assertEnvironmentId(value) {
103
+ if (isEnvironmentId(value))
104
+ return value;
105
+ const shown = typeof value === 'string' ? `"${value}"` : String(value);
106
+ throw new Error(`${shown} is not a usable environment id. It must match ${ENVIRONMENT_ID_PATTERN} and must not be one of ${RESERVED_ENVIRONMENT_IDS.join(', ')} — an environment id becomes a database name, a connection name and a Redis key prefix, so it cannot be repaired for you without the repaired name disagreeing with the one in your configuration.`);
107
+ }
108
+ /**
109
+ * The caller named no environment.
110
+ *
111
+ * Its own class, and never folded into "unknown environment", because the two
112
+ * have different fixes: this one is a client that has not been taught to say
113
+ * which world it wants, and the other is a client that named one nobody has
114
+ * created. A single error message would send everybody to the wrong place.
115
+ */
116
+ class UnresolvedEnvironmentError extends Error {
117
+ constructor() {
118
+ super(`No environment was named on this request. Send the "${exports.CATALOG_ENVIRONMENT_HEADER}" header (or "?${exports.CATALOG_ENVIRONMENT_QUERY}="). There is deliberately no default: the environment that a silent default would pick is production, and a request that reached production because nobody said otherwise is exactly the accident environments exist to prevent.`);
119
+ }
120
+ }
121
+ exports.UnresolvedEnvironmentError = UnresolvedEnvironmentError;
122
+ /** The caller named an environment this deployment does not run. */
123
+ class UnknownEnvironmentError extends Error {
124
+ requested;
125
+ constructor(requested, known) {
126
+ super(`"${requested}" is not an environment this catalog runs. Known: ${known.join(', ') || '(none configured)'}.`);
127
+ this.requested = requested;
128
+ }
129
+ }
130
+ exports.UnknownEnvironmentError = UnknownEnvironmentError;
131
+ /**
132
+ * Reads the requested environment id off a request, without deciding whether it
133
+ * exists.
134
+ *
135
+ * Total and side-effect free so it can be used anywhere a request-like object
136
+ * is in hand — a guard, a middleware, a test. Returns undefined rather than
137
+ * throwing, because "nobody said" and "they said something wrong" are different
138
+ * refusals and the caller is the one that knows which message to produce.
139
+ */
140
+ function environmentIdFromRequest(request) {
141
+ if (!request || typeof request !== 'object')
142
+ return undefined;
143
+ const headers = Reflect.get(request, 'headers');
144
+ if (headers && typeof headers === 'object') {
145
+ const presented = Reflect.get(headers, exports.CATALOG_ENVIRONMENT_HEADER);
146
+ if (typeof presented === 'string' && presented.length > 0)
147
+ return presented;
148
+ // Node lowercases incoming header names, but a caller constructing a
149
+ // request-like object by hand may not have. Checking both costs nothing and
150
+ // removes a class of "it works in production and not in the test".
151
+ const upper = Reflect.get(headers, exports.CATALOG_ENVIRONMENT_HEADER.toUpperCase());
152
+ if (typeof upper === 'string' && upper.length > 0)
153
+ return upper;
154
+ }
155
+ const query = Reflect.get(request, 'query');
156
+ if (query && typeof query === 'object') {
157
+ const named = Reflect.get(query, exports.CATALOG_ENVIRONMENT_QUERY);
158
+ if (typeof named === 'string' && named.length > 0)
159
+ return named;
160
+ }
161
+ return undefined;
162
+ }
163
+ /**
164
+ * Turns a request into exactly one environment, or refuses.
165
+ *
166
+ * There is no fallback branch in this function and there must never be one.
167
+ * The failure this prevents is the one that has no symptom: a request that
168
+ * meant dev and was served by production returns perfectly plausible rows, and
169
+ * the only way anybody finds out is by noticing later that a number is wrong.
170
+ */
171
+ function resolveEnvironment(request, known) {
172
+ const requested = environmentIdFromRequest(request);
173
+ if (!requested)
174
+ throw new UnresolvedEnvironmentError();
175
+ const found = known.find((environment) => environment.id === requested);
176
+ if (!found) {
177
+ throw new UnknownEnvironmentError(requested, known.map((environment) => environment.id));
178
+ }
179
+ return found;
180
+ }
181
+ /**
182
+ * The Redis keyspace an environment's durable engine owns.
183
+ *
184
+ * Derived rather than configured, and that is the whole safety property. A
185
+ * deployment that sets `DURABLE_TENANT` by hand per environment has a
186
+ * copy-paste away from two environments on one keyspace, and the resulting
187
+ * failure is the one this project has already lived through: every
188
+ * `BullMQTransport` built on the bare `durable-*` prefix consumes a *single*
189
+ * `durable-results` queue, so one engine silently eats another's step results,
190
+ * the originating run's checkpoint stays `pending` forever, and nothing
191
+ * anywhere logs a word about it.
192
+ *
193
+ * The prefix keeps environments of different *services* apart on a shared
194
+ * Redis; the environment id keeps environments of this service apart from each
195
+ * other. Both halves are needed: `dev` alone would collide with any other
196
+ * product's dev engine.
197
+ */
198
+ function durableKeyspaceFor(environmentId, prefix = 'catalog') {
199
+ const id = assertEnvironmentId(environmentId);
200
+ const keyspace = `${prefix}-${id}`;
201
+ // Belt and braces. `assertEnvironmentId` already refuses "default", but this
202
+ // is the exact string whose meaning is "no namespace", and the cost of
203
+ // checking the composed value as well is one comparison against a constant.
204
+ if (keyspace === 'default') {
205
+ throw new Error(`A durable keyspace of "default" means the bare durable-* prefix, which is how two engines end up consuming each other's results queue.`);
206
+ }
207
+ return keyspace;
208
+ }
209
+ /**
210
+ * The database name an environment's tables live in.
211
+ *
212
+ * Composed here so the same rule is used by the thing that creates the database
213
+ * and the thing that connects to it. An operator may still override it per
214
+ * environment; what they may not do is have the two disagree.
215
+ */
216
+ function catalogDatabaseNameFor(base, environmentId) {
217
+ return `${base}_${assertEnvironmentId(environmentId)}`;
218
+ }
219
+ /** Stamps a batch of events with the environment they were read from. */
220
+ function stampEnvironment(environment, events) {
221
+ return events.map((event) => ({ ...event, environment }));
222
+ }
223
+ // -----------------------------------------------------------------------------
224
+ // Promotion: moving configuration, and refusing to move anything else.
225
+ // -----------------------------------------------------------------------------
226
+ /** What a promotion is allowed to carry. */
227
+ exports.PROMOTABLE_KINDS = [
228
+ /** The model: a type and its properties, including curated labels. */
229
+ 'objectType',
230
+ /** The code that shapes rows. */
231
+ 'transform',
232
+ /**
233
+ * The graph that wires transforms together.
234
+ *
235
+ * Listed after the transforms it names and before the connectors that run it,
236
+ * because this order is also the order an apply walks: a graph arriving
237
+ * before its code, or a connector before its graph, points at something
238
+ * missing for as long as the apply takes.
239
+ */
240
+ 'workflow',
241
+ /** The declaration of a load: what it reads, through what, on what schedule. */
242
+ 'connector',
243
+ ];
244
+ function isPromotableKind(value) {
245
+ return exports.PROMOTABLE_KINDS.some((kind) => kind === value);
246
+ }
247
+ /**
248
+ * Connector fields a promotion never carries, and why each one would be a bug.
249
+ *
250
+ * - `state` is where the last run got to — a watermark, a continuation token, a
251
+ * last-seen id. Carrying dev's watermark into production tells production it
252
+ * has already read everything up to that point, so the next production run
253
+ * skips real data and reports success. Carrying production's into dev makes
254
+ * dev replay. Either way the corruption is invisible: both runs finish green.
255
+ * - `lastRunAt` / `lastRunStatus` describe something that happened in the other
256
+ * environment. A production connector showing a green run it never performed
257
+ * is a lie told by the screen people check first.
258
+ * - `secretEnvVar` names the credential. It is only a *name*, so promoting it
259
+ * leaks nothing — but the name is how the environment finds its own key, and
260
+ * production reading `DEV_WAREHOUSE_PASSWORD` is production pointed at dev.
261
+ * - `enabled` is withheld on create only, so a newly promoted connector arrives
262
+ * switched off. Arriving enabled means the first scheduler tick runs, in
263
+ * production, code nobody has yet watched run there. On update the target
264
+ * keeps whatever it had: re-promoting a transform must not silently
265
+ * re-enable a connector an operator deliberately turned off.
266
+ */
267
+ exports.PROMOTION_WITHHELD_CONNECTOR_FIELDS = [
268
+ 'state',
269
+ 'lastRunAt',
270
+ 'lastRunStatus',
271
+ 'secretEnvVar',
272
+ 'enabled',
273
+ ];
274
+ /**
275
+ * A connection is matched, never copied — every field of it is withheld.
276
+ *
277
+ * A connection *is* an address and a credential reference, and those are the
278
+ * two things that most define what an environment is. Promoting dev's
279
+ * connection config into production would repoint production at the dev
280
+ * database, and the load that followed would be a successful load of the wrong
281
+ * data. So promotion requires a connection with the same id to already exist in
282
+ * the target, configured by whoever owns that environment, and reports its
283
+ * absence as a blocker rather than creating one.
284
+ */
285
+ exports.PROMOTION_WITHHELD_CONNECTION_FIELDS = [
286
+ 'config',
287
+ 'secretEnvVar',
288
+ 'lastCheckedAt',
289
+ 'lastCheckOk',
290
+ 'lastCheckError',
291
+ ];
292
+ /**
293
+ * The audit event name a completed promotion records.
294
+ *
295
+ * Deliberately *not* added to `CATALOG_EVENTS` and not emitted through
296
+ * `emitCatalog`. That channel is for things the library itself does during a
297
+ * load, and it is consumed by a recorder that writes wherever its workspace
298
+ * store points — which, with environments in play, is whichever environment
299
+ * happens to be in scope. A promotion is the one act that is genuinely about
300
+ * two environments at once, so its record is written directly into the target's
301
+ * audit table by the code that performed it, where there is no question about
302
+ * which environment the row belongs to.
303
+ */
304
+ exports.PROMOTION_AUDIT_EVENT = 'promotion.applied';
305
+ /**
306
+ * Compute what promoting `source` into `target` would do.
307
+ *
308
+ * Pure, and pure on purpose: this is the function whose output a person reads
309
+ * before agreeing to change production, so it must be runnable against two
310
+ * plain objects, in a test, with no database anywhere near it.
311
+ *
312
+ * **Additive.** Nothing in the target that is absent from the source is
313
+ * touched, let alone deleted. A promotion is a release of the things somebody
314
+ * built, not a mirror of one environment onto another — and a production
315
+ * connector that exists for a reason dev knows nothing about must survive a
316
+ * colleague promoting an unrelated transform.
317
+ *
318
+ * **Version numbers do not cross.** A transform's `version` counts the edits
319
+ * made *in the environment it lives in*. Copying dev's version onto production
320
+ * would make production's own history unreadable — v7 following v3 with nothing
321
+ * in between — so the target bumps its own, and the source version is carried
322
+ * in the change note instead, which is what actually answers "which code is
323
+ * this".
324
+ */
325
+ function planPromotion(input) {
326
+ const { from, to, source, target } = input;
327
+ const changes = [];
328
+ const blockers = [];
329
+ const withheld = [];
330
+ const selected = (kind, id) => input.select === undefined || input.select.includes(`${kind}:${id}`);
331
+ // --- The model -------------------------------------------------------------
332
+ const targetTypes = new Map(target.objectTypes.map((t) => [t.name, t]));
333
+ for (const type of source.objectTypes) {
334
+ if (!selected('objectType', type.name))
335
+ continue;
336
+ const existing = targetTypes.get(type.name);
337
+ const fields = diffObjectType(existing, type);
338
+ changes.push({
339
+ kind: 'objectType',
340
+ id: type.name,
341
+ name: type.displayName,
342
+ action: existing ? (fields.length ? 'update' : 'unchanged') : 'create',
343
+ fields,
344
+ notes: existing
345
+ ? []
346
+ : [
347
+ // Worth saying explicitly, because "the type is now in production"
348
+ // reads to most people as "the data is now in production".
349
+ 'Creates the type and its physical table in the target. No rows are carried — the table arrives empty and stays empty until something loads into it.',
350
+ ],
351
+ });
352
+ }
353
+ // --- Transforms ------------------------------------------------------------
354
+ const targetTransforms = new Map(target.transforms.map((t) => [t.id, t]));
355
+ for (const transform of source.transforms) {
356
+ if (!selected('transform', transform.id))
357
+ continue;
358
+ const existing = targetTransforms.get(transform.id);
359
+ const fields = diffFields(existing, transform, ['name', 'description', 'language', 'code']);
360
+ changes.push({
361
+ kind: 'transform',
362
+ id: transform.id,
363
+ name: transform.name,
364
+ action: existing ? (fields.length ? 'update' : 'unchanged') : 'create',
365
+ fields,
366
+ notes: existing && fields.length
367
+ ? [
368
+ `Source is v${transform.version}; the target will bump its own from v${existing.version} to v${existing.version + 1}. Version numbers count edits within an environment and deliberately do not cross.`,
369
+ ]
370
+ : [],
371
+ });
372
+ }
373
+ // --- Workflows -------------------------------------------------------------
374
+ // Before connectors, deliberately: the order changes are listed is the order
375
+ // they must be applied, and a connector arriving before the graph it runs
376
+ // would point at nothing for as long as the apply takes.
377
+ const targetWorkflows = new Map((target.workflows ?? []).map((workflow) => [workflow.id, workflow]));
378
+ for (const workflow of source.workflows ?? []) {
379
+ if (!selected('workflow', workflow.id))
380
+ continue;
381
+ const existing = targetWorkflows.get(workflow.id);
382
+ // Compared on the hash, not field by field: node positions and names are in
383
+ // the record but not in the hash, so moving a box on a canvas is correctly
384
+ // reported as nothing to release.
385
+ const fields = diffFields(existing, workflow, [
386
+ 'name',
387
+ 'description',
388
+ 'targetType',
389
+ 'graphHash',
390
+ ]);
391
+ changes.push({
392
+ kind: 'workflow',
393
+ id: workflow.id,
394
+ name: workflow.name,
395
+ action: existing ? (fields.length ? 'update' : 'unchanged') : 'create',
396
+ fields,
397
+ notes: existing && existing.graphHash !== workflow.graphHash
398
+ ? [
399
+ `The graph itself differs (${existing.graphHash.slice(0, 8)} → ${workflow.graphHash.slice(0, 8)}). Every transform the graph names must already be in ${to} or included here.`,
400
+ ]
401
+ : [],
402
+ });
403
+ }
404
+ // --- Connectors ------------------------------------------------------------
405
+ const targetConnectors = new Map(target.connectors.map((c) => [c.id, c]));
406
+ const targetConnections = new Map(target.connections.map((c) => [c.id, c]));
407
+ const targetTransformIds = new Set(target.transforms.map((t) => t.id));
408
+ const promotedTransformIds = new Set(changes.filter((change) => change.kind === 'transform').map((change) => change.id));
409
+ const targetWorkflowIds = new Set((target.workflows ?? []).map((workflow) => workflow.id));
410
+ const promotedWorkflowIds = new Set(changes.filter((change) => change.kind === 'workflow').map((change) => change.id));
411
+ for (const connector of source.connectors) {
412
+ if (!selected('connector', connector.id))
413
+ continue;
414
+ const existing = targetConnectors.get(connector.id);
415
+ const fields = diffFields(existing, connector, [
416
+ 'name',
417
+ 'description',
418
+ 'kind',
419
+ 'targetType',
420
+ 'config',
421
+ 'connectionId',
422
+ 'transformId',
423
+ 'workflowId',
424
+ 'schedule',
425
+ 'mode',
426
+ ]);
427
+ const notes = [];
428
+ // A connector reads through a connection, and the connection is the one
429
+ // thing that is genuinely per-environment. Requiring it to pre-exist is
430
+ // what stops a promotion from silently repointing the target at the
431
+ // source's database.
432
+ if (connector.connectionId) {
433
+ const match = targetConnections.get(connector.connectionId);
434
+ if (!match) {
435
+ const sourceConnection = source.connections.find((candidate) => candidate.id === connector.connectionId);
436
+ blockers.push({
437
+ kind: 'connection',
438
+ id: connector.connectionId,
439
+ name: sourceConnection?.name ?? connector.connectionId,
440
+ reason: `"${connector.name}" reads through connection ${connector.connectionId}, which does not exist in ${to}. Create it there, pointed at ${to}'s own system and with ${to}'s own credential, and run the preview again. A promotion will not create it: a connection is an address and a credential reference, and copying ${from}'s would point ${to} at ${from}'s data.`,
441
+ });
442
+ }
443
+ else {
444
+ withheld.push({
445
+ kind: 'connection',
446
+ id: match.id,
447
+ name: match.name,
448
+ fields: exports.PROMOTION_WITHHELD_CONNECTION_FIELDS,
449
+ why: `Matched by id to ${to}'s own "${match.name}". Its address and credential stay exactly as ${to} has them.`,
450
+ });
451
+ if (match.kind !== connector.kind) {
452
+ blockers.push({
453
+ kind: 'connection',
454
+ id: match.id,
455
+ name: match.name,
456
+ reason: `${to}'s connection "${match.name}" is a ${match.kind} connection, but "${connector.name}" expects a ${connector.kind} one. Same id, different kind of system — the load would fail on its first run, or worse, read something that happens to parse.`,
457
+ });
458
+ }
459
+ }
460
+ }
461
+ else {
462
+ notes.push(`Carries its own source configuration rather than reading through a connection, so whatever address is in its config is being promoted verbatim. Check it names something ${to} should be reading.`);
463
+ }
464
+ // A connector pointing at code that is not there is a load that fails on
465
+ // its first scheduled run, at night, in the target — the worst place to
466
+ // discover it. Caught here, where somebody is looking.
467
+ if (connector.transformId &&
468
+ !targetTransformIds.has(connector.transformId) &&
469
+ !promotedTransformIds.has(connector.transformId)) {
470
+ blockers.push({
471
+ kind: 'connector',
472
+ id: connector.id,
473
+ name: connector.name,
474
+ reason: `"${connector.name}" runs transform ${connector.transformId}, which is neither in ${to} nor included in this promotion. Add it to the selection, or the connector would arrive pointing at code that does not exist.`,
475
+ });
476
+ }
477
+ // The same hole, one level up. A workflow is a graph of transforms, so a
478
+ // connector arriving without it is worse than one arriving without a
479
+ // transform: nothing about the load is defined at all.
480
+ if (connector.workflowId &&
481
+ !targetWorkflowIds.has(connector.workflowId) &&
482
+ !promotedWorkflowIds.has(connector.workflowId)) {
483
+ blockers.push({
484
+ kind: 'connector',
485
+ id: connector.id,
486
+ name: connector.name,
487
+ reason: `"${connector.name}" runs workflow ${connector.workflowId}, which is neither in ${to} nor included in this promotion. Promote the workflow first, or the connector would arrive pointing at a graph that does not exist.`,
488
+ });
489
+ }
490
+ withheld.push({
491
+ kind: 'connector',
492
+ id: connector.id,
493
+ name: connector.name,
494
+ fields: existing
495
+ ? exports.PROMOTION_WITHHELD_CONNECTOR_FIELDS.filter((field) => field !== 'enabled')
496
+ : exports.PROMOTION_WITHHELD_CONNECTOR_FIELDS,
497
+ why: existing
498
+ ? `${to} keeps its own watermark, its own run history, its own credential reference and its own enabled/disabled switch.`
499
+ : `Arrives disabled, with no watermark and no credential reference. Point it at ${to}'s secret and enable it when somebody is watching.`,
500
+ });
501
+ if (!existing) {
502
+ notes.push('Arrives disabled. Enable it in the target once its connection has been checked.');
503
+ }
504
+ changes.push({
505
+ kind: 'connector',
506
+ id: connector.id,
507
+ name: connector.name,
508
+ action: existing ? (fields.length ? 'update' : 'unchanged') : 'create',
509
+ fields,
510
+ notes,
511
+ });
512
+ }
513
+ const createdAt = (input.now?.() ?? new Date()).toISOString();
514
+ return {
515
+ from,
516
+ to,
517
+ createdAt,
518
+ changes,
519
+ blockers,
520
+ withheld,
521
+ fingerprint: fingerprintOf(from, to, changes, blockers),
522
+ };
523
+ }
524
+ /** Whether a plan may be applied at all. */
525
+ function isPromotable(plan) {
526
+ return plan.blockers.length === 0;
527
+ }
528
+ /** The changes that actually do something. Used by both the apply and the summary. */
529
+ function effectiveChanges(plan) {
530
+ return plan.changes.filter((change) => change.action !== 'unchanged');
531
+ }
532
+ /**
533
+ * The hash the apply call has to present back.
534
+ *
535
+ * Built from the *effect* — what would change, from what, to what — and not
536
+ * from the whole plan object, because `createdAt` and the withheld list would
537
+ * make two identical previews taken a second apart disagree. A reviewer who
538
+ * approved a set of changes has approved those changes, not the clock.
539
+ *
540
+ * Blockers are included so that a plan which becomes appliable between the
541
+ * preview and the apply is not accepted under the old fingerprint: "it was
542
+ * blocked when I looked at it" is not approval of the unblocked version.
543
+ */
544
+ function fingerprintOf(from, to, changes, blockers) {
545
+ const effect = changes
546
+ .filter((change) => change.action !== 'unchanged')
547
+ .map((change) => [
548
+ change.kind,
549
+ change.id,
550
+ change.action,
551
+ change.fields.map((field) => `${field.field}=${stable(field.to)}`).join(','),
552
+ ].join('|'))
553
+ .sort();
554
+ const refusals = blockers
555
+ .map((blocker) => `${blocker.kind}:${blocker.id}:${blocker.reason}`)
556
+ .sort();
557
+ return (0, node_crypto_1.createHash)('sha256')
558
+ .update([from, to, ...effect, '--', ...refusals].join('\n'))
559
+ .digest('hex');
560
+ }
561
+ /**
562
+ * A stable string for any value, so two structurally equal configs hash the
563
+ * same however their keys happened to be ordered coming out of the database.
564
+ *
565
+ * Key order in a MySQL JSON column is not something either environment
566
+ * controls, so comparing serialised forms without sorting would report a
567
+ * difference on every promotion and train everybody to click through the diff.
568
+ */
569
+ function stable(value) {
570
+ if (value === undefined)
571
+ return 'undefined';
572
+ if (value === null)
573
+ return 'null';
574
+ if (Array.isArray(value))
575
+ return `[${value.map(stable).join(',')}]`;
576
+ if (typeof value === 'object') {
577
+ const entries = Object.entries(value)
578
+ .filter(([, item]) => item !== undefined)
579
+ .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
580
+ .map(([key, item]) => `${JSON.stringify(key)}:${stable(item)}`);
581
+ return `{${entries.join(',')}}`;
582
+ }
583
+ return JSON.stringify(value) ?? String(value);
584
+ }
585
+ /** Field-by-field diff over a named subset. Absent target means "all of it is new". */
586
+ function diffFields(existing, incoming, fields) {
587
+ const diffs = [];
588
+ for (const field of fields) {
589
+ const to = incoming[field];
590
+ const from = existing ? existing[field] : undefined;
591
+ if (stable(from) === stable(to))
592
+ continue;
593
+ diffs.push({ field, from, to });
594
+ }
595
+ return diffs;
596
+ }
597
+ /**
598
+ * The model's diff, which needs its own shape because a type's interesting
599
+ * change is usually inside its property list rather than on the type row.
600
+ *
601
+ * Properties are compared as a whole rather than one row per property: a
602
+ * reviewer asking "what changed about Mvr" wants "two properties added, one
603
+ * relabelled", and a diff that emitted forty unchanged property entries to say
604
+ * it would be a diff nobody reads.
605
+ */
606
+ function diffObjectType(existing, incoming) {
607
+ const diffs = diffFields(existing, incoming, [
608
+ 'displayName',
609
+ 'pluralDisplayName',
610
+ 'description',
611
+ 'icon',
612
+ 'group',
613
+ 'titleProperty',
614
+ 'primaryKey',
615
+ ]);
616
+ const before = new Map((existing?.properties ?? []).map((property) => [property.name, property]));
617
+ const after = new Map(incoming.properties.map((property) => [property.name, property]));
618
+ const added = [...after.keys()].filter((name) => !before.has(name));
619
+ const changed = [...after.entries()]
620
+ .filter(([name, property]) => {
621
+ const previous = before.get(name);
622
+ return previous !== undefined && stable(previous) !== stable(property);
623
+ })
624
+ .map(([name]) => name);
625
+ // Reported, never acted on. The store's `ensureType` is additive by design —
626
+ // it creates columns and never drops them — so a property that disappeared
627
+ // from the source leaves the target's column and its data exactly where they
628
+ // are. Saying so is the honest thing; silently listing it as a removal would
629
+ // promise a cleanup that does not happen.
630
+ const gone = [...before.keys()].filter((name) => !after.has(name));
631
+ if (added.length) {
632
+ diffs.push({ field: 'properties.added', from: [], to: added });
633
+ }
634
+ if (changed.length) {
635
+ diffs.push({ field: 'properties.changed', from: changed, to: changed });
636
+ }
637
+ if (gone.length) {
638
+ diffs.push({
639
+ field: 'properties.absentFromSource',
640
+ from: gone,
641
+ to: gone,
642
+ });
643
+ }
644
+ return diffs;
645
+ }