@dudousxd/nestjs-catalog 0.11.0 → 0.13.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.
@@ -82,10 +82,12 @@ function createCatalogController(path, guards, decorators = []) {
82
82
  registry;
83
83
  service;
84
84
  traces;
85
- constructor(registry, service, traces) {
85
+ workspace;
86
+ constructor(registry, service, traces, workspace) {
86
87
  this.registry = registry;
87
88
  this.service = service;
88
89
  this.traces = traces;
90
+ this.workspace = workspace;
89
91
  }
90
92
  requireTraces() {
91
93
  if (!this.traces) {
@@ -283,6 +285,28 @@ function createCatalogController(path, guards, decorators = []) {
283
285
  response.setHeader('content-disposition', `attachment; filename="${filename}"`);
284
286
  return (0, catalog_query_cache_1.toCsv)(result);
285
287
  }
288
+ /**
289
+ * Every SQL this query has ever been, newest first.
290
+ *
291
+ * `catalog:read`, the same scope `GET saved-queries/:id` asks for, because
292
+ * it hands back the same field one version older. Gating history harder than
293
+ * the current text would be gating the diff rather than the data — the
294
+ * caller can already read what the statement says today, and the authoring
295
+ * scope that `POST`/`PATCH` require is about *choosing what SQL runs*, which
296
+ * reading an old body is not.
297
+ *
298
+ * An empty list is a real answer: a query nobody has edited since revisions
299
+ * shipped may have nothing recorded. A store that keeps none at all says so
300
+ * rather than answering `[]`, because "we do not keep these" and "nothing
301
+ * has changed" are different facts and a screen would draw them the same.
302
+ */
303
+ savedQueryRevisions(id) {
304
+ const workspace = this.workspace;
305
+ if (!workspace || !(0, catalog_workspace_1.supportsSavedQueryRevisions)(workspace)) {
306
+ throw new common_1.BadRequestException("This catalog's workspace store keeps no revisions, so a saved query's SQL can be read but not compared with what it used to be.");
307
+ }
308
+ return workspace.listSavedQueryRevisions(id);
309
+ }
286
310
  dashboards() {
287
311
  return this.service.listDashboards();
288
312
  }
@@ -555,6 +579,14 @@ function createCatalogController(path, guards, decorators = []) {
555
579
  __metadata("design:paramtypes", [String, Object]),
556
580
  __metadata("design:returntype", Promise)
557
581
  ], CatalogController.prototype, "exportSavedQuery", null);
582
+ __decorate([
583
+ (0, common_1.Get)('saved-queries/:id/revisions'),
584
+ (0, catalog_route_auth_1.RequireScopes)('catalog:read'),
585
+ __param(0, (0, common_1.Param)('id')),
586
+ __metadata("design:type", Function),
587
+ __metadata("design:paramtypes", [String]),
588
+ __metadata("design:returntype", void 0)
589
+ ], CatalogController.prototype, "savedQueryRevisions", null);
558
590
  __decorate([
559
591
  (0, common_1.Get)('dashboards'),
560
592
  (0, catalog_route_auth_1.RequireScopes)('catalog:read'),
@@ -667,8 +699,10 @@ function createCatalogController(path, guards, decorators = []) {
667
699
  (0, common_1.Controller)(path),
668
700
  __param(2, (0, common_1.Optional)()),
669
701
  __param(2, (0, common_1.Inject)(catalog_workspace_1.CATALOG_TRACE_STORE)),
702
+ __param(3, (0, common_1.Optional)()),
703
+ __param(3, (0, common_1.Inject)(catalog_workspace_1.CATALOG_WORKSPACE_STORE)),
670
704
  __metadata("design:paramtypes", [catalog_registry_base_1.CatalogRegistry,
671
- catalog_service_1.CatalogService, Object])
705
+ catalog_service_1.CatalogService, Object, Object])
672
706
  ], CatalogController);
673
707
  if (guards.length > 0) {
674
708
  (0, common_1.UseGuards)(...guards)(CatalogController);
@@ -106,10 +106,17 @@ export interface CatalogEventPayloads {
106
106
  snapshotId: string;
107
107
  };
108
108
  /**
109
- * Someone changed a label, description, unit or visibility.
109
+ * Someone changed a label, description, unit or visibility — or stated how a
110
+ * type reconciles deletes.
110
111
  *
111
- * Presentation-only, and emitted anyway: "who renamed this column and when"
112
- * is a governance question, and the answer is otherwise nowhere.
112
+ * Presentation was the whole of it once, and emitted anyway: "who renamed this
113
+ * column and when" is a governance question whose answer is otherwise nowhere.
114
+ * Per-type load expectations then arrived on the same event, and a delete
115
+ * strategy is not presentation — it decides whether an incremental load of the
116
+ * type may commit at all. `changed` tells the two apart (`expectation.deletes`
117
+ * and `expectation.cleared` against the field names a rename carries), which is
118
+ * why widening this event was better than minting a second one nobody's
119
+ * recorder would have been reading.
113
120
  */
114
121
  'type.curated': {
115
122
  typeName: string;
@@ -7,6 +7,7 @@
7
7
  * schedules, retries and checkpoints, and writing a second one would mean two
8
8
  * systems each believing they decide when a load runs.
9
9
  */
10
+ import type { CatalogRevision } from './catalog.workspace';
10
11
  /**
11
12
  * Where a connector pulls from.
12
13
  *
@@ -121,6 +122,211 @@ export interface CatalogConnector {
121
122
  lastRunAt?: string;
122
123
  lastRunStatus?: 'succeeded' | 'failed' | 'running';
123
124
  }
125
+ /**
126
+ * What a load has to be true of before it is allowed to become the data
127
+ * everybody reads.
128
+ *
129
+ * Two failures live here, and they are the same failure seen from two ends: a
130
+ * load that is *fresh and wrong*. Every signal this catalog publishes about a
131
+ * type — `lastCommittedAt`, the age badge on the Model screen, a green run in
132
+ * the runs list — reports on whether a load HAPPENED. None of them reports on
133
+ * whether what it loaded resembles the dataset it replaced, and a snapshot
134
+ * commit is atomic, so the moment a wrong load commits it is indistinguishable
135
+ * from a right one until somebody counts rows by hand.
136
+ *
137
+ * - **Deletes.** An incremental connector asks its source for what changed
138
+ * since a watermark. A row physically removed from the source never changes
139
+ * again, so it is never returned again, so `carryForward` copies it into
140
+ * every subsequent snapshot forever. The catalog does not go wrong at any
141
+ * point; it simply never finds out. See {@link DeleteReconciliation}.
142
+ * - **Collapse.** A source-side filter change, a broken `WHERE`, a partial
143
+ * outage: the connector returns 12 rows where it returned 40,000, the
144
+ * snapshot commits, and the freshness signals all say healthy — correctly,
145
+ * because it IS fresh. See {@link RowCountBound}.
146
+ *
147
+ * **Why a policy object and not a column on the connector.** Both facts are
148
+ * statements about a *type*, not about the reader of a source. "It is
149
+ * acceptable that `Employee` accumulates rows deleted upstream" and "`Employee`
150
+ * must never lose half its rows in one load" stay true whether the rows arrive
151
+ * from a connector, from a workflow sink, or from an application POSTing to the
152
+ * publish API — and all three of those paths end at the same two methods on
153
+ * `PublishService`, which is where these are enforced. A per-connector field
154
+ * would have covered one of the three and would have had to be checked in three
155
+ * places to cover the rest.
156
+ *
157
+ * The second reason is who should be able to change it. Accepting that a
158
+ * dataset silently accumulates deleted rows is not a checkbox decision; it is
159
+ * the kind of thing that should appear in a diff with a reason attached, which
160
+ * is why {@link DeleteReconciliation} makes the reason a required field.
161
+ */
162
+ export interface CatalogLoadExpectations {
163
+ /** Applied to every type that has no entry of its own. */
164
+ default?: LoadExpectation;
165
+ /**
166
+ * Keyed by object type name. Merged OVER {@link default} field by field, so a
167
+ * host can set one house-wide row-count bound and still say something about
168
+ * deletes for the three types that are loaded incrementally.
169
+ */
170
+ byType?: Record<string, LoadExpectation>;
171
+ }
172
+ export interface LoadExpectation {
173
+ /**
174
+ * How deletions at the source reach this type. **Absent means the load is
175
+ * refused**, which is the whole mechanism — see {@link
176
+ * refuseUndeclaredDeletes}.
177
+ */
178
+ deletes?: DeleteReconciliation;
179
+ /**
180
+ * How far one load may move this type's row count. Merged over
181
+ * {@link DEFAULT_ROW_COUNT_BOUND}, so a host that only wants to raise
182
+ * `maxShrink` writes exactly that one field.
183
+ */
184
+ rowCount?: Partial<RowCountBound>;
185
+ }
186
+ /**
187
+ * How a type that is loaded incrementally learns about rows that were deleted.
188
+ *
189
+ * Three answers, and the honest thing to say about them up front is that only
190
+ * one is *policed*. What this file enforces is that somebody chose one and
191
+ * wrote down why — because the state being prevented is nobody having thought
192
+ * about it at all, and that state is invisible by construction.
193
+ *
194
+ * The fourth answer, tombstones off a change feed, is the correct one and is
195
+ * deliberately not here. It needs the source to publish a delete stream, the
196
+ * catalog to hold a delete log per type, and the merge to apply it — which is
197
+ * a larger machine than the problem justifies today, and adding a strategy name
198
+ * that nothing implements would be exactly the dropdown-with-a-lie this
199
+ * codebase refuses everywhere else.
200
+ */
201
+ export type DeleteReconciliation =
202
+ /**
203
+ * Nothing reconciles them, and that is a decision somebody made.
204
+ *
205
+ * The legitimate cases are real and common: an append-only ledger where rows
206
+ * are never removed, a source that only ever soft-retires records by changing
207
+ * a status the transform can see, or a dataset where a handful of stale rows
208
+ * is genuinely cheaper than a nightly full read. What is not legitimate is
209
+ * arriving here by default, which is why {@link because} cannot be omitted.
210
+ */
211
+ {
212
+ strategy: 'accepted';
213
+ because: string;
214
+ }
215
+ /**
216
+ * The source marks a deletion instead of performing one, and the watermark
217
+ * therefore sees it — a `deleted_at` that moves, a status column that flips —
218
+ * so the deleted row arrives as an ordinary change and the transform drops it
219
+ * or the type keeps it flagged.
220
+ *
221
+ * The strongest of the three, and the one that pushes a requirement onto a
222
+ * source that may refuse it. Not verifiable from here: the catalog cannot
223
+ * tell a source that soft-deletes from one that claims to, so this is a
224
+ * declaration like the one above. It is a separate value anyway because the
225
+ * two say completely different things to the next person who reads the
226
+ * config, and collapsing them would lose that.
227
+ */
228
+ | {
229
+ strategy: 'soft-deleted-at-source';
230
+ because: string;
231
+ column?: string;
232
+ }
233
+ /**
234
+ * Full reads reconcile, incremental reads fill the gaps between them.
235
+ *
236
+ * The interval is the trade-off, and it is stated in time rather than in runs
237
+ * because "reconciled daily" is what anybody actually means and because the
238
+ * only thing the catalog can count is the snapshots a store chooses to
239
+ * report, which is a window of unknown depth. {@link refuseStaleReconciliation}
240
+ * makes the interval real: once the newest full load of the type is older
241
+ * than `withinMs`, incremental loads of it stop committing.
242
+ */
243
+ | {
244
+ strategy: 'periodic-full-reload';
245
+ because: string;
246
+ withinMs: number;
247
+ };
248
+ /**
249
+ * How far a single load may move a type's row count before it is refused.
250
+ *
251
+ * **Asymmetric on purpose.** A type that doubles has usually had a good day —
252
+ * a backfill landed, a new base was onboarded, a source finished catching up.
253
+ * A type that loses 90% has almost never had a good day. Bounding both sides by
254
+ * the same number would mean picking a growth bound loose enough to be useless
255
+ * as a shrink bound, or a shrink bound tight enough to refuse every backfill.
256
+ *
257
+ * **Conditional on the store, and a host configuring this should know which
258
+ * condition.** {@link refuseRowCountDrift} is pure and decides on two numbers;
259
+ * somebody has to fetch them, and both come from members that are optional on
260
+ * the store interface. Without `currentSnapshot` there is no served baseline;
261
+ * without `listSnapshots`, or from a `listSnapshots` whose window does not
262
+ * reach the snapshot about to be committed, there is no count for the pending
263
+ * one. Either way the bound is not applied to that commit. That is the same
264
+ * permissive-rather-than-punishing stance {@link CARRIED_FROM_LABEL} takes for
265
+ * the same reason — an adapter that records less than the bundled one is not
266
+ * the failure this file exists for — but it means a number written here is a
267
+ * bound the store has to be able to measure, not one it is guaranteed to have.
268
+ * `PublishService.assertRowCountIsPlausible` is where that is decided; a skip
269
+ * that is not said out loud there is a bound believed to be on and off, which
270
+ * is the one outcome neither this file nor that one may produce.
271
+ */
272
+ export interface RowCountBound {
273
+ /**
274
+ * The largest fraction of the previously served snapshot a load may lose.
275
+ * `0.5` refuses a load that comes back with less than half of what is live.
276
+ */
277
+ maxShrink: number;
278
+ /**
279
+ * The ratio above which growth is refused — `10` refuses a load ten times the
280
+ * size of the previous one. **Absent means growth is never refused**, which is
281
+ * the default, because the failure this file exists for is collapse and a
282
+ * growth bound that fires on a legitimate backfill teaches people to raise
283
+ * every bound in this object until none of them do anything.
284
+ */
285
+ maxGrowth?: number;
286
+ /**
287
+ * Below this many rows in the previously served snapshot, no ratio applies.
288
+ *
289
+ * A percentage of a small number is noise. A four-row lookup table dropping to
290
+ * one is a 75% collapse and is also a Tuesday, and a bound that fires on it is
291
+ * a bound somebody switches off — taking the forty-thousand-row types with it.
292
+ */
293
+ minRows: number;
294
+ }
295
+ /**
296
+ * A per-type expectation as an operator set it, with who and when.
297
+ *
298
+ * The layer between a host's `byType` entry and its `default`. It exists because
299
+ * the control the docblocks above argue for was never "it must be in code" —
300
+ * it is that **somebody chose a strategy and wrote down why**, which needs
301
+ * attribution and visibility rather than compilation. A host object gives the
302
+ * reason a place to live and gives attribution to nobody: a `git blame` on a
303
+ * deployment's wiring names whoever last reformatted the file. So the reason
304
+ * arrives with the principal that set it and the instant they did, and the
305
+ * declaration requirement is unchanged — {@link refuseUndeclaredDeletes} asks
306
+ * the same question of a stored row as it does of a host one.
307
+ *
308
+ * The grain is still the type, and only the type. A connector, a workflow sink
309
+ * and an application POSTing to the publish API all end at the same two
310
+ * `PublishService` methods and all have the same delete problem, so a per-
311
+ * connector or per-workflow row would give one dataset several answers to one
312
+ * question — see {@link CatalogLoadExpectations}, which argues it at length and
313
+ * is unaffected by this layer existing.
314
+ *
315
+ * Both policy fields are optional, and a row may carry either, both or neither:
316
+ * precedence is resolved field by field, so an operator raising a shrink bound
317
+ * says nothing about deletes and does not have to.
318
+ */
319
+ export interface StoredLoadExpectation {
320
+ typeName: string;
321
+ deletes?: DeleteReconciliation;
322
+ rowCount?: Partial<RowCountBound>;
323
+ /** Principal id of whoever set it. */
324
+ setBy: string;
325
+ /** Actor id when a person was behind the principal — the audit's real subject. */
326
+ setByActor?: string;
327
+ /** ISO 8601. */
328
+ setAt: string;
329
+ }
124
330
  /**
125
331
  * TypeScript is Node's own type stripping, so it costs no compiler and no build
126
332
  * step — and types are erased, never checked. A transform with a wrong type
@@ -136,6 +342,14 @@ export declare function isTransformLanguage(value: unknown): value is TransformL
136
342
  * Versioned, because a load that produced surprising numbers is investigated
137
343
  * afterwards, and "which code ran" is the first question. Bumping the version
138
344
  * on every change costs a row and answers it.
345
+ *
346
+ * The version used to be the *whole* answer, and it was half of one: it named
347
+ * code that no longer existed anywhere, because one row per transform is
348
+ * overwritten in place. Each version's code is now recorded as a
349
+ * {@link CatalogRevision}, read through
350
+ * {@link CatalogPipelineStore.listTransformRevisions}, so the number on a run
351
+ * and the text it names are both retrievable. `version` still counts saves that
352
+ * changed the code and nothing else — see `saveTransform`.
139
353
  */
140
354
  export interface CatalogTransform {
141
355
  id: string;
@@ -223,17 +437,26 @@ export interface ConnectorRun {
223
437
  error?: string;
224
438
  startedAt: string;
225
439
  finishedAt?: string;
226
- /** Which transform version ran, so a surprising load can be traced to code. */
440
+ /**
441
+ * Which transform version ran, so a surprising load can be traced to code.
442
+ *
443
+ * Traced to the code itself, now, and not only to a number: the version this
444
+ * names is the version of a {@link CatalogRevision}, so `transforms/:id/revisions`
445
+ * answers with the body that produced these rows. Until that route existed
446
+ * this field could only establish *that* the transform had been edited since.
447
+ */
227
448
  transformVersion?: number;
228
449
  /** Which workflow ran, when the connector delegated to one. */
229
450
  workflowId?: string;
230
451
  /**
231
452
  * Which *version* of it ran.
232
453
  *
233
- * The same question `transformVersion` answers, asked of the graph. A
234
- * workflow keeps only its latest shape exactly as a transform keeps only
235
- * its latest code so this number is what connects a run to the graph that
236
- * produced it, and the only way to know a graph has changed since.
454
+ * The same question `transformVersion` answers, asked of the graph — and no
455
+ * longer answered as well, which is worth knowing before relying on it. A
456
+ * workflow keeps only its latest shape (see {@link CatalogWorkflow} for why it
457
+ * is excluded from revisions while a transform is not), so this number
458
+ * connects a run to the graph that produced it and is the only way to know a
459
+ * graph has changed since. It cannot produce the graph.
237
460
  */
238
461
  workflowVersion?: number;
239
462
  /**
@@ -435,12 +658,34 @@ export interface WorkflowGraph {
435
658
  * the code; for a workflow it means the code *and* the wiring, so both the
436
659
  * graph version and the per-node transform versions are recorded on the run.
437
660
  *
438
- * Like a transform, only the latest shape is kept. Storing every past graph was
439
- * the alternative and was rejected for consistency: transforms already answer
440
- * "which code ran" with a number and no history, and a model where the graph is
441
- * fully recoverable but the code inside it is not would give false confidence in
442
- * an audit. The limitation is real and worth stating plainly — an edited graph
443
- * cannot be reconstructed from an old run, only identified as different.
661
+ * **Only the latest shape is kept, and unlike a transform it is not
662
+ * revisioned.** That asymmetry is a decision rather than an oversight, and this
663
+ * is where somebody looking for the missing feature will look, so it is argued
664
+ * here.
665
+ *
666
+ * A transform's code and a saved query's SQL are text a person typed, and
667
+ * {@link CatalogRevision} archives text: two bodies, a line differ, done. A
668
+ * graph is a structure. Its "body" would be JSON nobody wrote, and a text diff
669
+ * over it is dominated by key order and canvas positions — it would report a
670
+ * dragged box as a change to what the load does, which is the opposite of what
671
+ * {@link workflowGraphHash} is careful to exclude. Diffing graphs is a graph
672
+ * problem and deserves a screen that draws one, not a line differ pointed at
673
+ * serialised nodes.
674
+ *
675
+ * The decisive reason is the counter. {@link version} is bumped on **draft**
676
+ * edits deliberately — see the note on it — so that a run's `workflowVersion`
677
+ * can never mean two different graphs. Archiving one body per version would
678
+ * therefore store every autosave of a canvas somebody is still dragging boxes
679
+ * around on, and under the per-subject cap that {@link CATALOG_REVISION_LIMIT}
680
+ * imposes, that noise would evict the versions that actually ran. A counter
681
+ * designed to be cheap to inflate and an archive designed to be bounded do not
682
+ * compose; making them compose means keying the archive on behaviour rather than
683
+ * on saves, which is what `graphHash` already is, and that is a different
684
+ * feature from this one.
685
+ *
686
+ * So the limitation stays, stated plainly: an edited graph cannot be
687
+ * reconstructed from an old run, only identified as different. A diff screen
688
+ * answers for the code and the SQL and not for the wiring.
444
689
  */
445
690
  /**
446
691
  * Whether this graph is still being drawn, or is something somebody declared
@@ -804,7 +1049,45 @@ export interface CatalogStageStore {
804
1049
  * does, because a flag is a claim and a method is the thing itself.
805
1050
  */
806
1051
  export declare function supportsWorkflows(store: CatalogPipelineStore): store is CatalogPipelineStore & CatalogWorkflowStore;
1052
+ /**
1053
+ * Whether this store keeps a transform's history.
1054
+ *
1055
+ * The method rather than a flag, exactly as {@link supportsWorkflows} argues: a
1056
+ * flag is a claim and a method is the thing itself.
1057
+ */
1058
+ export declare function supportsTransformRevisions(store: CatalogPipelineStore): store is CatalogPipelineStore & Required<Pick<CatalogPipelineStore, 'listTransformRevisions'>>;
807
1059
  export declare function supportsWorkflowStages(store: CatalogPipelineStore): store is CatalogPipelineStore & CatalogStageStore;
1060
+ /**
1061
+ * A store that really does hold operator-set expectations, all four members
1062
+ * present.
1063
+ *
1064
+ * Derived from {@link CatalogPipelineStore} rather than declared as a separate
1065
+ * interface the way {@link CatalogWorkflowStore} is, and the difference is not
1066
+ * stylistic: these four are optional members OF the pipeline store, so writing
1067
+ * them out a second time here would be a copy that can drift from the one the
1068
+ * signatures are read from. `CatalogWorkflowStore` predates that lesson and is
1069
+ * mixed in through `Partial<>`, which reaches the same place from the other
1070
+ * side.
1071
+ */
1072
+ export type CatalogLoadExpectationStore = Required<Pick<CatalogPipelineStore, 'listLoadExpectations' | 'getLoadExpectation' | 'saveLoadExpectation' | 'clearLoadExpectation'>>;
1073
+ /**
1074
+ * Whether an operator can set a load expectation on this deployment at all.
1075
+ *
1076
+ * The methods rather than a flag, the same argument as {@link supportsWorkflows}
1077
+ * — and all four of them by name rather than one standing in for the rest, for
1078
+ * that function's other reason: the write path and the read path are used at
1079
+ * different moments, so a store with the getter and not the setter would narrow
1080
+ * cleanly here and fail on the save, after the screen had already offered an
1081
+ * editor.
1082
+ *
1083
+ * A store that has none of them is not broken and is not second-class. It
1084
+ * behaves exactly as every store did before this existed: the host's
1085
+ * `CATALOG_LOAD_EXPECTATIONS` object is the only layer, which is a complete and
1086
+ * supported answer. What this probe buys is that the console can say "this
1087
+ * deployment's store cannot hold operator-set expectations" instead of offering
1088
+ * an editor whose save has nowhere to go.
1089
+ */
1090
+ export declare function supportsLoadExpectations(store: CatalogPipelineStore): store is CatalogPipelineStore & CatalogLoadExpectationStore;
808
1091
  export interface CatalogPipelineStore extends Partial<CatalogWorkflowStore>, Partial<CatalogStageStore> {
809
1092
  listConnectors(): Promise<CatalogConnector[]>;
810
1093
  getConnector(id: string): Promise<CatalogConnector | undefined>;
@@ -842,6 +1125,69 @@ export interface CatalogPipelineStore extends Partial<CatalogWorkflowStore>, Par
842
1125
  description?: string;
843
1126
  }, createdBy: string): Promise<CatalogTransform>;
844
1127
  deleteTransform(id: string): Promise<boolean>;
1128
+ /**
1129
+ * Every version of this transform's code, newest first.
1130
+ *
1131
+ * **Optional**, mixed in for the same reason {@link CatalogWorkflowStore} is:
1132
+ * a store written against the previous shape of this interface still
1133
+ * implements it, and turning that into a compile error would be a breaking
1134
+ * change for an additive feature. {@link supportsTransformRevisions} is how a
1135
+ * caller asks, so a deployment whose store keeps no history gets a sentence
1136
+ * rather than a method that is missing at run time.
1137
+ *
1138
+ * The list is what makes `transformVersion` on a run mean something: the
1139
+ * revision whose {@link CatalogRevision.version} equals it holds the code that
1140
+ * produced those rows. Bounded — see {@link CATALOG_REVISION_LIMIT} for what
1141
+ * that bound costs.
1142
+ */
1143
+ listTransformRevisions?(id: string): Promise<CatalogRevision[]>;
1144
+ /**
1145
+ * Per-type load expectations as an operator set them.
1146
+ *
1147
+ * **Optional**, and here more deliberately than anywhere else in this
1148
+ * interface. `@dudousxd/nestjs-catalog-store-mikro-orm` is not the only
1149
+ * implementation — a host may have written its own against an earlier shape of
1150
+ * this file — and every one of them satisfies `CatalogPipelineStore` today.
1151
+ * Widening it with four required members would turn all of them into compile
1152
+ * errors for a feature that is purely additive, and, worse, would do it
1153
+ * *silently* to the ones checked structurally: `isPipelineStore` and the
1154
+ * `supports*` probes narrow on methods, so a store that no longer satisfies
1155
+ * the interface is discovered by a caller, at run time, rather than by a build.
1156
+ * {@link supportsLoadExpectations} is how a caller asks, and a store that
1157
+ * implements none of these behaves exactly as it does today — the host's
1158
+ * `CATALOG_LOAD_EXPECTATIONS` object is then the whole policy.
1159
+ *
1160
+ * These hold rows; they do not resolve them. Precedence — a host's `byType`
1161
+ * entry over a stored row over the host's `default`, field by field — is the
1162
+ * pipeline package's business, beside the enforcement functions that consume
1163
+ * it, and it stays pure and synchronous. A store that resolved would be a
1164
+ * second place the precedence is decided, which for a policy whose whole point
1165
+ * is "somebody decided this" is the one duplication that cannot be tolerated.
1166
+ */
1167
+ listLoadExpectations?(): Promise<StoredLoadExpectation[]>;
1168
+ getLoadExpectation?(typeName: string): Promise<StoredLoadExpectation | undefined>;
1169
+ /**
1170
+ * Upsert, keyed by type name, recording the principal and the instant.
1171
+ *
1172
+ * `setBy` and `setByActor` are arguments rather than fields on the
1173
+ * `expectation` for the reason `startRun` records attribution the way it does:
1174
+ * a caller cannot claim them. `setAt` is not an input at all — a stored
1175
+ * timestamp a client could choose is not an audit record.
1176
+ *
1177
+ * `setByActor` is the person behind the principal when there was one. The
1178
+ * write route requires a human, so in practice there always is; it is
1179
+ * separate from `setBy` because a principal is a key and an actor is a
1180
+ * subject, and the trail needs the second to answer "who decided this".
1181
+ */
1182
+ saveLoadExpectation?(typeName: string, expectation: Pick<StoredLoadExpectation, 'deletes' | 'rowCount'>, setBy: string, setByActor?: string): Promise<StoredLoadExpectation>;
1183
+ /**
1184
+ * Drop the stored row for a type. The host's layer is untouched, so a type the
1185
+ * deployment declared in code keeps that declaration.
1186
+ *
1187
+ * `false` means there was nothing stored, which is a fact a caller may report
1188
+ * and never an error.
1189
+ */
1190
+ clearLoadExpectation?(typeName: string): Promise<boolean>;
845
1191
  startRun(input: {
846
1192
  connectorId: string;
847
1193
  snapshotId: string;
@@ -21,7 +21,9 @@ exports.workflowGraphHash = workflowGraphHash;
21
21
  exports.isWorkflowNode = isWorkflowNode;
22
22
  exports.isWorkflowEdge = isWorkflowEdge;
23
23
  exports.supportsWorkflows = supportsWorkflows;
24
+ exports.supportsTransformRevisions = supportsTransformRevisions;
24
25
  exports.supportsWorkflowStages = supportsWorkflowStages;
26
+ exports.supportsLoadExpectations = supportsLoadExpectations;
25
27
  exports.isPipelineStore = isPipelineStore;
26
28
  /**
27
29
  * Where a connector pulls from.
@@ -146,12 +148,34 @@ exports.WORKFLOW_NODE_ID_PATTERN = /^[A-Za-z0-9_-]{1,64}$/;
146
148
  * the code; for a workflow it means the code *and* the wiring, so both the
147
149
  * graph version and the per-node transform versions are recorded on the run.
148
150
  *
149
- * Like a transform, only the latest shape is kept. Storing every past graph was
150
- * the alternative and was rejected for consistency: transforms already answer
151
- * "which code ran" with a number and no history, and a model where the graph is
152
- * fully recoverable but the code inside it is not would give false confidence in
153
- * an audit. The limitation is real and worth stating plainly — an edited graph
154
- * cannot be reconstructed from an old run, only identified as different.
151
+ * **Only the latest shape is kept, and unlike a transform it is not
152
+ * revisioned.** That asymmetry is a decision rather than an oversight, and this
153
+ * is where somebody looking for the missing feature will look, so it is argued
154
+ * here.
155
+ *
156
+ * A transform's code and a saved query's SQL are text a person typed, and
157
+ * {@link CatalogRevision} archives text: two bodies, a line differ, done. A
158
+ * graph is a structure. Its "body" would be JSON nobody wrote, and a text diff
159
+ * over it is dominated by key order and canvas positions — it would report a
160
+ * dragged box as a change to what the load does, which is the opposite of what
161
+ * {@link workflowGraphHash} is careful to exclude. Diffing graphs is a graph
162
+ * problem and deserves a screen that draws one, not a line differ pointed at
163
+ * serialised nodes.
164
+ *
165
+ * The decisive reason is the counter. {@link version} is bumped on **draft**
166
+ * edits deliberately — see the note on it — so that a run's `workflowVersion`
167
+ * can never mean two different graphs. Archiving one body per version would
168
+ * therefore store every autosave of a canvas somebody is still dragging boxes
169
+ * around on, and under the per-subject cap that {@link CATALOG_REVISION_LIMIT}
170
+ * imposes, that noise would evict the versions that actually ran. A counter
171
+ * designed to be cheap to inflate and an archive designed to be bounded do not
172
+ * compose; making them compose means keying the archive on behaviour rather than
173
+ * on saves, which is what `graphHash` already is, and that is a different
174
+ * feature from this one.
175
+ *
176
+ * So the limitation stays, stated plainly: an edited graph cannot be
177
+ * reconstructed from an old run, only identified as different. A diff screen
178
+ * answers for the code and the SQL and not for the wiring.
155
179
  */
156
180
  /**
157
181
  * Whether this graph is still being drawn, or is something somebody declared
@@ -694,9 +718,41 @@ function supportsWorkflows(store) {
694
718
  // transforms into the target.
695
719
  typeof store.publishWorkflow === 'function');
696
720
  }
721
+ /**
722
+ * Whether this store keeps a transform's history.
723
+ *
724
+ * The method rather than a flag, exactly as {@link supportsWorkflows} argues: a
725
+ * flag is a claim and a method is the thing itself.
726
+ */
727
+ function supportsTransformRevisions(store) {
728
+ return typeof store.listTransformRevisions === 'function';
729
+ }
697
730
  function supportsWorkflowStages(store) {
698
731
  return typeof store.writeStage === 'function' && typeof store.readStage === 'function';
699
732
  }
733
+ /**
734
+ * Whether an operator can set a load expectation on this deployment at all.
735
+ *
736
+ * The methods rather than a flag, the same argument as {@link supportsWorkflows}
737
+ * — and all four of them by name rather than one standing in for the rest, for
738
+ * that function's other reason: the write path and the read path are used at
739
+ * different moments, so a store with the getter and not the setter would narrow
740
+ * cleanly here and fail on the save, after the screen had already offered an
741
+ * editor.
742
+ *
743
+ * A store that has none of them is not broken and is not second-class. It
744
+ * behaves exactly as every store did before this existed: the host's
745
+ * `CATALOG_LOAD_EXPECTATIONS` object is the only layer, which is a complete and
746
+ * supported answer. What this probe buys is that the console can say "this
747
+ * deployment's store cannot hold operator-set expectations" instead of offering
748
+ * an editor whose save has nowhere to go.
749
+ */
750
+ function supportsLoadExpectations(store) {
751
+ return (typeof store.listLoadExpectations === 'function' &&
752
+ typeof store.getLoadExpectation === 'function' &&
753
+ typeof store.saveLoadExpectation === 'function' &&
754
+ typeof store.clearLoadExpectation === 'function');
755
+ }
700
756
  exports.CATALOG_PIPELINE_STORE = Symbol('CATALOG_PIPELINE_STORE');
701
757
  function isPipelineStore(store) {
702
758
  return (typeof store === 'object' &&
@@ -345,6 +345,31 @@ export interface CatalogMergeStore extends CatalogWriteStore {
345
345
  export declare const CATALOG_RESERVED_COLUMNS: readonly ["_snapshot_id", "_principal_id", "_loaded_at", "_batch", "_row"];
346
346
  export type CatalogReservedColumn = (typeof CATALOG_RESERVED_COLUMNS)[number];
347
347
  export declare function isReservedColumn(column: string): boolean;
348
+ /**
349
+ * Why a name cannot be written into SQL, in the words a publisher is given.
350
+ *
351
+ * One class for the whole ecosystem rather than one per adapter, so
352
+ * `instanceof` is a usable question across packages. The publish-time check in
353
+ * the pipeline package catches this to tell "that name cannot be an identifier"
354
+ * from "something else failed inside the store", and with a class per adapter
355
+ * that check would re-throw the moment the mounted store was not the one it
356
+ * imported — turning a 400 that names the property into a 500 that names
357
+ * nothing.
358
+ */
359
+ export declare class UnsafeIdentifierError extends Error {
360
+ constructor(value: string);
361
+ }
362
+ /** Whether a name can be written into SQL as it stands. */
363
+ export declare function isSafeIdentifier(value: string): boolean;
364
+ /**
365
+ * Refuse a name that cannot be a SQL identifier.
366
+ *
367
+ * Throws rather than answering, because the caller's next line writes the value
368
+ * into a statement: a boolean that can be ignored is a boolean that eventually
369
+ * is. {@link isSafeIdentifier} is there for the callers that are asking rather
370
+ * than about to build.
371
+ */
372
+ export declare function assertSafeIdentifier(value: string): void;
348
373
  /** One property, and the column it cannot have. */
349
374
  export interface CatalogColumnCollision {
350
375
  /** `reserved` — it lands on a store column. `shared` — two properties collide. */