@dudousxd/nestjs-catalog 0.12.0 → 0.14.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.
@@ -122,6 +122,211 @@ export interface CatalogConnector {
122
122
  lastRunAt?: string;
123
123
  lastRunStatus?: 'succeeded' | 'failed' | 'running';
124
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
+ }
125
330
  /**
126
331
  * TypeScript is Node's own type stripping, so it costs no compiler and no build
127
332
  * step — and types are erased, never checked. A transform with a wrong type
@@ -852,6 +1057,37 @@ export declare function supportsWorkflows(store: CatalogPipelineStore): store is
852
1057
  */
853
1058
  export declare function supportsTransformRevisions(store: CatalogPipelineStore): store is CatalogPipelineStore & Required<Pick<CatalogPipelineStore, 'listTransformRevisions'>>;
854
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;
855
1091
  export interface CatalogPipelineStore extends Partial<CatalogWorkflowStore>, Partial<CatalogStageStore> {
856
1092
  listConnectors(): Promise<CatalogConnector[]>;
857
1093
  getConnector(id: string): Promise<CatalogConnector | undefined>;
@@ -905,6 +1141,53 @@ export interface CatalogPipelineStore extends Partial<CatalogWorkflowStore>, Par
905
1141
  * that bound costs.
906
1142
  */
907
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>;
908
1191
  startRun(input: {
909
1192
  connectorId: string;
910
1193
  snapshotId: string;
@@ -23,6 +23,7 @@ exports.isWorkflowEdge = isWorkflowEdge;
23
23
  exports.supportsWorkflows = supportsWorkflows;
24
24
  exports.supportsTransformRevisions = supportsTransformRevisions;
25
25
  exports.supportsWorkflowStages = supportsWorkflowStages;
26
+ exports.supportsLoadExpectations = supportsLoadExpectations;
26
27
  exports.isPipelineStore = isPipelineStore;
27
28
  /**
28
29
  * Where a connector pulls from.
@@ -729,6 +730,29 @@ function supportsTransformRevisions(store) {
729
730
  function supportsWorkflowStages(store) {
730
731
  return typeof store.writeStage === 'function' && typeof store.readStage === 'function';
731
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
+ }
732
756
  exports.CATALOG_PIPELINE_STORE = Symbol('CATALOG_PIPELINE_STORE');
733
757
  function isPipelineStore(store) {
734
758
  return (typeof store === 'object' &&
@@ -63,6 +63,21 @@ export declare class CatalogService {
63
63
  readObjects(typeName: string, query: CatalogObjectQuery & {
64
64
  snapshot?: string;
65
65
  }): Promise<CatalogObjectPage>;
66
+ /** What the mounted store can push into a read predicate. Empty when it cannot. */
67
+ private filterOperators;
68
+ /**
69
+ * Every filter, or a refusal naming all of them at once.
70
+ *
71
+ * One message listing every problem rather than the first: somebody who built
72
+ * four filters and got two of them wrong should learn that in one round trip.
73
+ *
74
+ * The store is asked whether it can honour the operators before the read runs,
75
+ * which is what stops a store that does not filter from answering with an
76
+ * unfiltered page. That refusal is worth more than it costs — a screen only
77
+ * offers what `filterOperators` reported, so a caller reaching this branch is
78
+ * one that built the request itself.
79
+ */
80
+ private resolveFilters;
66
81
  /** Empty when the store keeps no history. */
67
82
  listSnapshots(typeName: string): Promise<SnapshotRef[]>;
68
83
  /** What the mounted store can do — the screens branch on this. */
@@ -15,6 +15,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
15
15
  exports.CatalogService = void 0;
16
16
  const common_1 = require("@nestjs/common");
17
17
  const catalog_events_1 = require("./catalog.events");
18
+ const catalog_filters_1 = require("./catalog.filters");
18
19
  const catalog_options_1 = require("./catalog.options");
19
20
  const catalog_query_1 = require("./catalog.query");
20
21
  const catalog_query_cache_1 = require("./catalog.query-cache");
@@ -130,14 +131,24 @@ let CatalogService = class CatalogService {
130
131
  // Sort is validated here rather than in the store: an unrecognised column
131
132
  // must never reach a query builder, whatever the engine.
132
133
  const sort = columns.some((c) => c.name === query.sort) ? query.sort : undefined;
133
- const { rows, total } = await this.store.read(type, fields, {
134
+ // Filters, against the same `columns` a sort is checked against and for the
135
+ // same reason — with one difference in what a failure means. An unrecognised
136
+ // sort falls back to the primary key, because the rows are the same rows in a
137
+ // different order. An unrecognised filter cannot fall back to anything: the
138
+ // read would come back holding rows the caller asked to exclude, and neither
139
+ // the caller nor the screen has any way to tell.
140
+ const filters = this.resolveFilters(columns, query.filters ?? []);
141
+ const result = await this.store.read(type, fields, {
134
142
  page,
135
143
  size,
136
144
  search: query.search,
137
145
  sort,
138
146
  dir: query.dir === 'desc' ? 'desc' : 'asc',
139
147
  snapshot: query.snapshot,
148
+ ...(filters.length > 0 ? { filters } : {}),
140
149
  });
150
+ const { rows, total } = result;
151
+ const storeOperators = this.filterOperators();
141
152
  return {
142
153
  type: type.name,
143
154
  page,
@@ -150,10 +161,52 @@ let CatalogService = class CatalogService {
150
161
  type: c.type,
151
162
  classification: c.classification,
152
163
  unit: c.unit,
164
+ columnName: c.columnName,
165
+ // What this deployment will actually accept for this column: the rule
166
+ // derived from the column, narrowed by what the mounted store can do.
167
+ // Sent per column so a console needs no second request and no table of
168
+ // its own — see `catalog.filters.ts` on why a hand-kept list is the
169
+ // failure mode being avoided.
170
+ filterOperators: (0, catalog_filters_1.offeredFilterOperators)(c, storeOperators),
153
171
  })),
154
172
  rows,
173
+ ...(result.snapshot ? { snapshot: result.snapshot } : {}),
155
174
  };
156
175
  }
176
+ /** What the mounted store can push into a read predicate. Empty when it cannot. */
177
+ filterOperators() {
178
+ return (0, catalog_store_1.supportsObjectFilters)(this.store) ? this.store.objectFilterOperators : [];
179
+ }
180
+ /**
181
+ * Every filter, or a refusal naming all of them at once.
182
+ *
183
+ * One message listing every problem rather than the first: somebody who built
184
+ * four filters and got two of them wrong should learn that in one round trip.
185
+ *
186
+ * The store is asked whether it can honour the operators before the read runs,
187
+ * which is what stops a store that does not filter from answering with an
188
+ * unfiltered page. That refusal is worth more than it costs — a screen only
189
+ * offers what `filterOperators` reported, so a caller reaching this branch is
190
+ * one that built the request itself.
191
+ */
192
+ resolveFilters(columns, raw) {
193
+ if (raw.length === 0)
194
+ return [];
195
+ const { filters, problems } = (0, catalog_filters_1.resolveObjectFilters)(columns, raw);
196
+ const supported = this.filterOperators();
197
+ const unsupported = filters
198
+ .map((filter) => filter.op)
199
+ .filter((op) => !supported.some((available) => available === op));
200
+ if (unsupported.length > 0) {
201
+ throw new common_1.BadRequestException(supported.length === 0
202
+ ? "This catalog's store does not filter object reads, so it can only be paged, searched and sorted."
203
+ : `This catalog's store cannot filter with ${[...new Set(unsupported)].join(', ')}. It applies ${supported.join(', ')}.`);
204
+ }
205
+ if (problems.length > 0) {
206
+ throw new common_1.BadRequestException(problems.join(' '));
207
+ }
208
+ return filters;
209
+ }
157
210
  /** Empty when the store keeps no history. */
158
211
  async listSnapshots(typeName) {
159
212
  const type = this.registry.getType(typeName);
@@ -1,4 +1,5 @@
1
1
  import { BadRequestException } from '@nestjs/common';
2
+ import type { CatalogFilterOperator, CatalogResolvedFilter } from './catalog.filters';
2
3
  import type { CatalogObjectQuery, CatalogObjectTypeDef } from './catalog.types';
3
4
  /**
4
5
  * Where the objects actually live.
@@ -149,14 +150,74 @@ export interface CatalogStoreCapabilities {
149
150
  * into a boot failure.
150
151
  */
151
152
  export declare function isCatalogStoreCapabilities(value: unknown): value is CatalogStoreCapabilities;
152
- export interface CatalogReadQuery extends CatalogObjectQuery {
153
+ /**
154
+ * What a store is asked for, once the service has vetted it.
155
+ *
156
+ * `Omit<..., 'filters'>` and not a plain extension, and the omission is the
157
+ * point: `CatalogObjectQuery.filters` is the caller's raw
158
+ * `property:operator:value` text, and a store must never be handed one. What
159
+ * arrives here instead is {@link CatalogResolvedFilter}, whose property is the
160
+ * type's own definition — so the column a predicate is built from came off the
161
+ * type rather than off the request, and the type system says so rather than a
162
+ * comment. `sort` is a bare string only because every store already re-matches it
163
+ * against the type before using it; a filter carries more than a name, so
164
+ * resolving it once in the service is both cheaper and harder to get wrong.
165
+ */
166
+ export interface CatalogReadQuery extends Omit<CatalogObjectQuery, 'filters'> {
153
167
  /** Read as of a specific snapshot. Ignored when `timeTravel` is false. */
154
168
  snapshot?: string;
169
+ /**
170
+ * Every one of these must be applied. A store that cannot apply one must not
171
+ * silently return the rows it would have returned anyway — declare the
172
+ * operators it can honour (see {@link CatalogFilteringReadStore}) and the
173
+ * service will refuse the read instead.
174
+ */
175
+ filters?: CatalogResolvedFilter[];
155
176
  }
156
177
  export interface CatalogReadResult {
157
178
  rows: Array<Record<string, unknown>>;
158
179
  total: number;
180
+ /**
181
+ * Which snapshot these rows came from, and whether it is the one being served.
182
+ *
183
+ * Answered by the store because the store is what resolved it: a read that was
184
+ * given no snapshot falls back to the pointer, so only the store knows which id
185
+ * the rows actually carry. Reporting it costs nothing — every store that keeps
186
+ * history has already read both values by the time it builds the query — and it
187
+ * is what lets a screen say "this is not the current load" on the strength of
188
+ * what was read rather than of what it thinks it asked for.
189
+ *
190
+ * Absent from a store that keeps no history, which is the honest answer there:
191
+ * the rows are the current state and there is no other state to be reading.
192
+ */
193
+ snapshot?: {
194
+ id: string;
195
+ current: boolean;
196
+ };
159
197
  }
198
+ /**
199
+ * A store that applies {@link CatalogReadQuery.filters}.
200
+ *
201
+ * Declared, never assumed, and the reason is the same one the capability object
202
+ * one file up gives for every field on it: a store that ignores a filter answers
203
+ * with more rows than were asked for, and there is nothing about that answer to
204
+ * distinguish it from a filter that genuinely matched everything. So a store says
205
+ * which operators it can push into its predicate, the service offers exactly
206
+ * those to the screen, and a filter naming anything else is refused rather than
207
+ * quietly dropped.
208
+ *
209
+ * A guard rather than a field on `CatalogStoreCapabilities`, deliberately: the
210
+ * capability object is intersected by the fan-out through an exhaustiveness check
211
+ * that fails to compile when a field is added and not composed, and this is not a
212
+ * property that composes the way those do — a fan-out reads through its primary,
213
+ * so what its primary can filter is what it can filter. Asking the object it
214
+ * holds is the check that stays true when that changes.
215
+ */
216
+ export interface CatalogFilteringReadStore extends CatalogReadStore {
217
+ /** Which operators this store can apply. A subset of `CATALOG_FILTER_OPERATORS`. */
218
+ readonly objectFilterOperators: readonly CatalogFilterOperator[];
219
+ }
220
+ export declare function supportsObjectFilters(store: unknown): store is CatalogFilteringReadStore;
160
221
  /** The minimum a store must do: return rows of a catalogued type. */
161
222
  export interface CatalogReadStore {
162
223
  readonly capabilities: CatalogStoreCapabilities;
@@ -345,6 +406,31 @@ export interface CatalogMergeStore extends CatalogWriteStore {
345
406
  export declare const CATALOG_RESERVED_COLUMNS: readonly ["_snapshot_id", "_principal_id", "_loaded_at", "_batch", "_row"];
346
407
  export type CatalogReservedColumn = (typeof CATALOG_RESERVED_COLUMNS)[number];
347
408
  export declare function isReservedColumn(column: string): boolean;
409
+ /**
410
+ * Why a name cannot be written into SQL, in the words a publisher is given.
411
+ *
412
+ * One class for the whole ecosystem rather than one per adapter, so
413
+ * `instanceof` is a usable question across packages. The publish-time check in
414
+ * the pipeline package catches this to tell "that name cannot be an identifier"
415
+ * from "something else failed inside the store", and with a class per adapter
416
+ * that check would re-throw the moment the mounted store was not the one it
417
+ * imported — turning a 400 that names the property into a 500 that names
418
+ * nothing.
419
+ */
420
+ export declare class UnsafeIdentifierError extends Error {
421
+ constructor(value: string);
422
+ }
423
+ /** Whether a name can be written into SQL as it stands. */
424
+ export declare function isSafeIdentifier(value: string): boolean;
425
+ /**
426
+ * Refuse a name that cannot be a SQL identifier.
427
+ *
428
+ * Throws rather than answering, because the caller's next line writes the value
429
+ * into a statement: a boolean that can be ignored is a boolean that eventually
430
+ * is. {@link isSafeIdentifier} is there for the callers that are asking rather
431
+ * than about to build.
432
+ */
433
+ export declare function assertSafeIdentifier(value: string): void;
348
434
  /** One property, and the column it cannot have. */
349
435
  export interface CatalogColumnCollision {
350
436
  /** `reserved` — it lands on a store column. `shared` — two properties collide. */
@@ -1,8 +1,11 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.CATALOG_STORE = exports.CatalogColumnCollisionError = exports.CATALOG_RESERVED_COLUMNS = exports.CATALOG_SNAPSHOT_MODES = void 0;
3
+ exports.CATALOG_STORE = exports.CatalogColumnCollisionError = exports.UnsafeIdentifierError = exports.CATALOG_RESERVED_COLUMNS = exports.CATALOG_SNAPSHOT_MODES = void 0;
4
4
  exports.isCatalogStoreCapabilities = isCatalogStoreCapabilities;
5
+ exports.supportsObjectFilters = supportsObjectFilters;
5
6
  exports.isReservedColumn = isReservedColumn;
7
+ exports.isSafeIdentifier = isSafeIdentifier;
8
+ exports.assertSafeIdentifier = assertSafeIdentifier;
6
9
  exports.findColumnCollisions = findColumnCollisions;
7
10
  exports.assertNoColumnCollisions = assertNoColumnCollisions;
8
11
  exports.isWriteStore = isWriteStore;
@@ -47,6 +50,11 @@ function isCatalogStoreCapabilities(value) {
47
50
  }
48
51
  return true;
49
52
  }
53
+ function supportsObjectFilters(store) {
54
+ return (typeof store === 'object' &&
55
+ store !== null &&
56
+ Array.isArray(Reflect.get(store, 'objectFilterOperators')));
57
+ }
50
58
  /**
51
59
  * The columns a snapshot-emulating store adds to every object table.
52
60
  *
@@ -72,6 +80,73 @@ exports.CATALOG_RESERVED_COLUMNS = [
72
80
  function isReservedColumn(column) {
73
81
  return exports.CATALOG_RESERVED_COLUMNS.some((reserved) => reserved === column.toLowerCase());
74
82
  }
83
+ /**
84
+ * What a name has to look like before a store will write it into SQL.
85
+ *
86
+ * Identifiers are *rejected*, never escaped and never sanitised. Every table
87
+ * and column name a store emits arrives from another application over HTTP and
88
+ * ends up in DDL and in SELECT lists, where no placeholder can stand in for it,
89
+ * so anything outside this character set never becomes SQL at all.
90
+ *
91
+ * Here, beside {@link CATALOG_RESERVED_COLUMNS}, for the same reason: it is
92
+ * part of what the catalog promises a *publisher*. Refuse a property name and
93
+ * the sentence explaining why is the only statement of the rule most people
94
+ * will ever read, so it belongs to the contract rather than to whichever
95
+ * adapter happens to be mounted.
96
+ *
97
+ * And for one more reason. It used to be two copies — `store-mikro-orm` and
98
+ * `store-clickhouse` each carried this pattern and this sentence, byte for
99
+ * byte — and the publish-time refusal in the pipeline package borrowed the
100
+ * MySQL one so that publish-time and DDL-time could not disagree about the
101
+ * character set, the length or the wording. That bought the guarantee for a
102
+ * MySQL deployment and left a ClickHouse-only one trusting two files to be
103
+ * edited together. One definition is the guarantee; two identical ones are a
104
+ * habit.
105
+ *
106
+ * 63 characters because it is under MySQL's 64-character ceiling and no engine
107
+ * a store here targets refuses a name that short, and because the number is
108
+ * quoted in the refusal below: a per-store limit would mean a publisher being
109
+ * told a different rule depending on what is mounted, for a name the catalog
110
+ * would then be unable to promise anything about across a fan-out.
111
+ *
112
+ * Not exported. A `RegExp` is mutable and shared state, and the two questions
113
+ * anyone has of it — "may I?" and "why not?" — are {@link isSafeIdentifier} and
114
+ * {@link UnsafeIdentifierError}.
115
+ */
116
+ const SAFE_IDENTIFIER = /^[A-Za-z_][A-Za-z0-9_]{0,62}$/;
117
+ /**
118
+ * Why a name cannot be written into SQL, in the words a publisher is given.
119
+ *
120
+ * One class for the whole ecosystem rather than one per adapter, so
121
+ * `instanceof` is a usable question across packages. The publish-time check in
122
+ * the pipeline package catches this to tell "that name cannot be an identifier"
123
+ * from "something else failed inside the store", and with a class per adapter
124
+ * that check would re-throw the moment the mounted store was not the one it
125
+ * imported — turning a 400 that names the property into a 500 that names
126
+ * nothing.
127
+ */
128
+ class UnsafeIdentifierError extends Error {
129
+ constructor(value) {
130
+ super(`Refusing to use "${value}" as a SQL identifier: letters, digits and underscore only, starting with a letter or underscore, 63 characters max.`);
131
+ }
132
+ }
133
+ exports.UnsafeIdentifierError = UnsafeIdentifierError;
134
+ /** Whether a name can be written into SQL as it stands. */
135
+ function isSafeIdentifier(value) {
136
+ return SAFE_IDENTIFIER.test(value);
137
+ }
138
+ /**
139
+ * Refuse a name that cannot be a SQL identifier.
140
+ *
141
+ * Throws rather than answering, because the caller's next line writes the value
142
+ * into a statement: a boolean that can be ignored is a boolean that eventually
143
+ * is. {@link isSafeIdentifier} is there for the callers that are asking rather
144
+ * than about to build.
145
+ */
146
+ function assertSafeIdentifier(value) {
147
+ if (!isSafeIdentifier(value))
148
+ throw new UnsafeIdentifierError(value);
149
+ }
75
150
  /**
76
151
  * Every way a type's properties would fight over a physical column.
77
152
  *