@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.
@@ -191,7 +191,7 @@ function createCatalogController(path, guards, decorators = []) {
191
191
  return this.registry.getSnapshot();
192
192
  }
193
193
  /** One generic read endpoint for every type in the catalog. */
194
- objects(name, page, size, search, sort, dir, snapshot) {
194
+ objects(name, page, size, search, sort, dir, snapshot, filter) {
195
195
  return this.service.readObjects(name, {
196
196
  page: page ? Number(page) : undefined,
197
197
  size: size ? Number(size) : undefined,
@@ -199,6 +199,7 @@ function createCatalogController(path, guards, decorators = []) {
199
199
  sort,
200
200
  dir: dir === 'desc' ? 'desc' : 'asc',
201
201
  snapshot,
202
+ filters: repeatable(filter),
202
203
  });
203
204
  }
204
205
  /**
@@ -492,8 +493,9 @@ function createCatalogController(path, guards, decorators = []) {
492
493
  __param(4, (0, common_1.Query)('sort')),
493
494
  __param(5, (0, common_1.Query)('dir')),
494
495
  __param(6, (0, common_1.Query)('snapshot')),
496
+ __param(7, (0, common_1.Query)('filter')),
495
497
  __metadata("design:type", Function),
496
- __metadata("design:paramtypes", [String, String, String, String, String, String, String]),
498
+ __metadata("design:paramtypes", [String, String, String, String, String, String, String, Object]),
497
499
  __metadata("design:returntype", void 0)
498
500
  ], CatalogController.prototype, "objects", null);
499
501
  __decorate([
@@ -739,6 +741,20 @@ function actorOf(request, claimed) {
739
741
  return resolved;
740
742
  return claimed?.trim() || 'console';
741
743
  }
744
+ /**
745
+ * A query parameter that may appear once or many times, as a list either way.
746
+ *
747
+ * Unlike {@link parseOutcomes} below, nothing is split on commas and nothing is
748
+ * dropped. A filter's value is free text that may contain a comma — a
749
+ * description, a date range written by hand — and an unrecognised filter must
750
+ * reach the service so it can be refused by name rather than vanish into an
751
+ * unfiltered page.
752
+ */
753
+ function repeatable(raw) {
754
+ if (raw === undefined)
755
+ return undefined;
756
+ return Array.isArray(raw) ? raw : [raw];
757
+ }
742
758
  /**
743
759
  * `?outcome=failed`, `?outcome=failed,incomplete`, `?outcome=a&outcome=b`.
744
760
  *
@@ -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;
@@ -0,0 +1,170 @@
1
+ import type { CatalogPropertyDef, ScalarType } from './catalog.types';
2
+ /**
3
+ * Filtering a catalogued type, derived from the type.
4
+ *
5
+ * **Nothing here lists a filterable column, and nothing anywhere else may.** The
6
+ * types this catalog serves are created at runtime — `PUT publish/:type/schema`
7
+ * writes the properties and the store builds the physical columns from them — so
8
+ * a hand-maintained list of what is filterable is a list that goes quiet the day
9
+ * somebody publishes a column nobody edited it for. That is the failure mode this
10
+ * module exists to remove: the operators a column offers are a function of the
11
+ * column, computed by {@link filterOperatorsFor}, and both the server (deciding
12
+ * what it will accept) and the console (deciding what to draw) call that one
13
+ * function. A screen offering a control the server would refuse, or refusing one
14
+ * the server would take, is then not expressible.
15
+ *
16
+ * This is also why `@dudousxd/nestjs-filter` is not what is used here. That
17
+ * library derives its filters from entities and routes known at compile time, and
18
+ * these types do not exist at compile time.
19
+ *
20
+ * **The module is pure and imports nothing but types**, because it ships to the
21
+ * browser through `@dudousxd/nestjs-catalog/client` — the same reason
22
+ * `validateWorkflow` does. A second copy of the derivation living in the console
23
+ * is exactly the drift that entry point exists to prevent.
24
+ */
25
+ /**
26
+ * Every operator, in the order a control should offer them.
27
+ *
28
+ * One list, so nothing narrows a stored or transmitted operator against a second
29
+ * hand-maintained copy of these names — the same argument
30
+ * `CATALOG_SNAPSHOT_MODES` makes one file over.
31
+ *
32
+ * There is no `between`. A range is `gte` and `lte` on one property, which is two
33
+ * filters that compose with everything else rather than one operator that needs
34
+ * its own wire form, its own second value and its own "what if the ends are the
35
+ * wrong way round" answer.
36
+ */
37
+ export declare const CATALOG_FILTER_OPERATORS: readonly ["eq", "ne", "contains", "gte", "lte", "gt", "lt", "empty", "notEmpty"];
38
+ export type CatalogFilterOperator = (typeof CATALOG_FILTER_OPERATORS)[number];
39
+ export declare function isCatalogFilterOperator(value: string): value is CatalogFilterOperator;
40
+ /**
41
+ * The operators that take no value.
42
+ *
43
+ * "Has no value" cannot be spelled as a comparison — `= NULL` matches nothing in
44
+ * SQL and `= ''` misses the NULLs — so it is an operator rather than a value a
45
+ * caller types, and a filter carrying one is complete without a `value`.
46
+ */
47
+ export declare const VALUELESS_FILTER_OPERATORS: readonly ["empty", "notEmpty"];
48
+ export declare function filterOperatorTakesValue(operator: CatalogFilterOperator): boolean;
49
+ /** One filter as it crosses the wire: a property name, an operator, raw text. */
50
+ export interface CatalogObjectFilter {
51
+ /**
52
+ * The property's `name`, which is its identity in the type — never its
53
+ * `columnName`. On a published type the two differ whenever the source spelled
54
+ * a column in a way SQL cannot: `Asset Id` arrives as `columnName` and the
55
+ * property is `Asset_Id`. Filtering by the source spelling would resolve to no
56
+ * property at all on every one of them.
57
+ */
58
+ property: string;
59
+ op: CatalogFilterOperator;
60
+ /** Absent for {@link VALUELESS_FILTER_OPERATORS}. Text, as it was typed. */
61
+ value?: string;
62
+ }
63
+ /**
64
+ * The minimum a column has to say for the rule below to run on it.
65
+ *
66
+ * Structural rather than `CatalogPropertyDef`, because the console holds the
67
+ * *page's* columns rather than the type's — `CatalogObjectPage.columns` — and the
68
+ * whole point is that both sides ask the same function. Both shapes satisfy this.
69
+ */
70
+ export interface CatalogFilterableColumn {
71
+ name: string;
72
+ type: ScalarType;
73
+ hidden?: boolean;
74
+ classification?: string;
75
+ }
76
+ /**
77
+ * What may be filtered on this column, derived from what the column is.
78
+ *
79
+ * An empty list means "not filterable", and there are three ways to get one.
80
+ *
81
+ * **Hidden** is the overlay saying a column is not part of the generic UI, and a
82
+ * filter is generic UI.
83
+ *
84
+ * **Classified** is the one worth stating out loud, because the column is
85
+ * otherwise perfectly filterable and the value is never rendered anyway. It is
86
+ * excluded for the reason `MikroOrmReadStore.buildWhere` already excludes it from
87
+ * SEARCH: a predicate over a classified column leaks it through row membership.
88
+ * Filtering is strictly worse than searching there — `gte`/`lte` let a reader
89
+ * binary-search a value they may not see, in as many requests as it takes.
90
+ *
91
+ * **`json`** because a blob has no useful comparison, and it is already dropped
92
+ * from the readable columns by `CatalogService.visibleColumns`.
93
+ */
94
+ export declare function filterOperatorsFor(column: CatalogFilterableColumn): CatalogFilterOperator[];
95
+ /** Which operators a column offers, once the store's own limits are applied. */
96
+ export declare function offeredFilterOperators(column: CatalogFilterableColumn, supported: readonly CatalogFilterOperator[]): CatalogFilterOperator[];
97
+ /**
98
+ * `property:op:value`, repeated once per filter.
99
+ *
100
+ * The value is everything after the second colon, so it may contain colons — a
101
+ * timestamp does. A property name containing one cannot be addressed by this
102
+ * form; that is refused by name in {@link resolveObjectFilters} rather than
103
+ * silently mis-parsed, because a filter that quietly does not apply is a screen
104
+ * showing every row as though it had been filtered.
105
+ */
106
+ export declare function encodeObjectFilter(filter: CatalogObjectFilter): string;
107
+ export declare function parseObjectFilter(raw: string): CatalogObjectFilter | undefined;
108
+ /**
109
+ * A filter with the property it names already resolved to the type's own.
110
+ *
111
+ * The property is carried as the definition rather than as a string, and that is
112
+ * the guard rather than a nicety: a store builds a predicate out of a column
113
+ * name, and the only names it can be handed here are ones that came off the type.
114
+ * A caller's string never reaches SQL — it is matched against the type first, and
115
+ * the store then maps the property to its physical column exactly as `sort` and
116
+ * `search` already do, through the adapter's identifier rule.
117
+ */
118
+ export interface CatalogResolvedFilter {
119
+ property: CatalogPropertyDef;
120
+ op: CatalogFilterOperator;
121
+ /** Coerced to the property's type. Absent for the valueless operators. */
122
+ value?: string | number | boolean | Date;
123
+ }
124
+ export interface CatalogFilterResolution {
125
+ filters: CatalogResolvedFilter[];
126
+ /** One sentence per filter that could not be honoured. Empty means all were. */
127
+ problems: string[];
128
+ }
129
+ /**
130
+ * How many filters one read may carry.
131
+ *
132
+ * A cap rather than none, because every filter is another conjunct in the two
133
+ * statements a paged read issues, and a caller that can send five hundred can
134
+ * make one request cost whatever it likes. Twenty is far past what a person
135
+ * builds by hand and far short of anything that would trouble the optimiser.
136
+ */
137
+ export declare const CATALOG_FILTER_LIMIT = 20;
138
+ /**
139
+ * Turn what arrived into what a store may be given, or say why not.
140
+ *
141
+ * **Unhonourable filters are reported, never dropped.** The neighbouring
142
+ * `parseOutcomes` on the controller drops an unrecognised trace outcome on
143
+ * purpose, and this goes the other way for a reason that is worth the two
144
+ * sentences: dropping there costs a filter, so a typo widens the result set and
145
+ * shows more traces than asked for. Dropping HERE would narrow nothing — the read
146
+ * would come back unfiltered and the screen would present the whole table as
147
+ * though it were the matching rows. A filter that silently does not apply is the
148
+ * one failure mode a filtering UI must not have.
149
+ *
150
+ * @param columns the properties this reader may see. Deliberately the visible,
151
+ * non-blob columns rather than `type.properties`: a filter is only ever resolved
152
+ * against what the same request would return.
153
+ */
154
+ export declare function resolveObjectFilters(columns: readonly CatalogPropertyDef[], raw: readonly string[]): CatalogFilterResolution;
155
+ /**
156
+ * A typed value, or the reason there is none.
157
+ *
158
+ * Refusing rather than passing the text through is the whole of this function's
159
+ * value. MySQL compares a string to a `DOUBLE` by coercing the string — `'abc'`
160
+ * becomes `0` — so `mileage >= abc` is not an error, it is `mileage >= 0`, and it
161
+ * comes back as a full page of rows that look filtered. The same is true of a
162
+ * date that does not parse.
163
+ */
164
+ export declare function coerceFilterValue(type: ScalarType, value: string): {
165
+ ok: true;
166
+ value: string | number | boolean | Date;
167
+ } | {
168
+ ok: false;
169
+ problem: string;
170
+ };
@@ -0,0 +1,272 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.CATALOG_FILTER_LIMIT = exports.VALUELESS_FILTER_OPERATORS = exports.CATALOG_FILTER_OPERATORS = void 0;
4
+ exports.isCatalogFilterOperator = isCatalogFilterOperator;
5
+ exports.filterOperatorTakesValue = filterOperatorTakesValue;
6
+ exports.filterOperatorsFor = filterOperatorsFor;
7
+ exports.offeredFilterOperators = offeredFilterOperators;
8
+ exports.encodeObjectFilter = encodeObjectFilter;
9
+ exports.parseObjectFilter = parseObjectFilter;
10
+ exports.resolveObjectFilters = resolveObjectFilters;
11
+ exports.coerceFilterValue = coerceFilterValue;
12
+ /**
13
+ * Filtering a catalogued type, derived from the type.
14
+ *
15
+ * **Nothing here lists a filterable column, and nothing anywhere else may.** The
16
+ * types this catalog serves are created at runtime — `PUT publish/:type/schema`
17
+ * writes the properties and the store builds the physical columns from them — so
18
+ * a hand-maintained list of what is filterable is a list that goes quiet the day
19
+ * somebody publishes a column nobody edited it for. That is the failure mode this
20
+ * module exists to remove: the operators a column offers are a function of the
21
+ * column, computed by {@link filterOperatorsFor}, and both the server (deciding
22
+ * what it will accept) and the console (deciding what to draw) call that one
23
+ * function. A screen offering a control the server would refuse, or refusing one
24
+ * the server would take, is then not expressible.
25
+ *
26
+ * This is also why `@dudousxd/nestjs-filter` is not what is used here. That
27
+ * library derives its filters from entities and routes known at compile time, and
28
+ * these types do not exist at compile time.
29
+ *
30
+ * **The module is pure and imports nothing but types**, because it ships to the
31
+ * browser through `@dudousxd/nestjs-catalog/client` — the same reason
32
+ * `validateWorkflow` does. A second copy of the derivation living in the console
33
+ * is exactly the drift that entry point exists to prevent.
34
+ */
35
+ /**
36
+ * Every operator, in the order a control should offer them.
37
+ *
38
+ * One list, so nothing narrows a stored or transmitted operator against a second
39
+ * hand-maintained copy of these names — the same argument
40
+ * `CATALOG_SNAPSHOT_MODES` makes one file over.
41
+ *
42
+ * There is no `between`. A range is `gte` and `lte` on one property, which is two
43
+ * filters that compose with everything else rather than one operator that needs
44
+ * its own wire form, its own second value and its own "what if the ends are the
45
+ * wrong way round" answer.
46
+ */
47
+ exports.CATALOG_FILTER_OPERATORS = [
48
+ 'eq',
49
+ 'ne',
50
+ 'contains',
51
+ 'gte',
52
+ 'lte',
53
+ 'gt',
54
+ 'lt',
55
+ 'empty',
56
+ 'notEmpty',
57
+ ];
58
+ function isCatalogFilterOperator(value) {
59
+ return exports.CATALOG_FILTER_OPERATORS.some((operator) => operator === value);
60
+ }
61
+ /**
62
+ * The operators that take no value.
63
+ *
64
+ * "Has no value" cannot be spelled as a comparison — `= NULL` matches nothing in
65
+ * SQL and `= ''` misses the NULLs — so it is an operator rather than a value a
66
+ * caller types, and a filter carrying one is complete without a `value`.
67
+ */
68
+ exports.VALUELESS_FILTER_OPERATORS = ['empty', 'notEmpty'];
69
+ function filterOperatorTakesValue(operator) {
70
+ return !exports.VALUELESS_FILTER_OPERATORS.some((valueless) => valueless === operator);
71
+ }
72
+ const TEXT_OPERATORS = [
73
+ 'contains',
74
+ 'eq',
75
+ 'ne',
76
+ 'empty',
77
+ 'notEmpty',
78
+ ];
79
+ const NUMBER_OPERATORS = [
80
+ 'eq',
81
+ 'ne',
82
+ 'gte',
83
+ 'lte',
84
+ 'gt',
85
+ 'lt',
86
+ 'empty',
87
+ 'notEmpty',
88
+ ];
89
+ /**
90
+ * A date gets the two range ends and nothing else.
91
+ *
92
+ * No `eq`, deliberately. These columns are `DATETIME`, and a person filtering a
93
+ * date types a day — so `= 2026-03-04` compares against midnight and misses every
94
+ * row loaded at any other second of that day. It looks like "nothing happened on
95
+ * the 4th", which is the most expensive wrong answer a filter can give. `gte` the
96
+ * 4th and `lte` the 4th is the same intent expressed in a way that is true.
97
+ */
98
+ const DATE_OPERATORS = ['gte', 'lte', 'empty', 'notEmpty'];
99
+ const BOOLEAN_OPERATORS = ['eq', 'empty', 'notEmpty'];
100
+ /**
101
+ * What may be filtered on this column, derived from what the column is.
102
+ *
103
+ * An empty list means "not filterable", and there are three ways to get one.
104
+ *
105
+ * **Hidden** is the overlay saying a column is not part of the generic UI, and a
106
+ * filter is generic UI.
107
+ *
108
+ * **Classified** is the one worth stating out loud, because the column is
109
+ * otherwise perfectly filterable and the value is never rendered anyway. It is
110
+ * excluded for the reason `MikroOrmReadStore.buildWhere` already excludes it from
111
+ * SEARCH: a predicate over a classified column leaks it through row membership.
112
+ * Filtering is strictly worse than searching there — `gte`/`lte` let a reader
113
+ * binary-search a value they may not see, in as many requests as it takes.
114
+ *
115
+ * **`json`** because a blob has no useful comparison, and it is already dropped
116
+ * from the readable columns by `CatalogService.visibleColumns`.
117
+ */
118
+ function filterOperatorsFor(column) {
119
+ if (column.hidden === true)
120
+ return [];
121
+ if (column.classification)
122
+ return [];
123
+ switch (column.type) {
124
+ case 'number':
125
+ return [...NUMBER_OPERATORS];
126
+ case 'date':
127
+ return [...DATE_OPERATORS];
128
+ case 'boolean':
129
+ return [...BOOLEAN_OPERATORS];
130
+ case 'json':
131
+ return [];
132
+ default:
133
+ // `string`, `uuid` and `unknown`. The warehouse stores all three as text
134
+ // and the ORM store compares them as strings, so they take the same
135
+ // operators; `unknown` is a column whose type nobody could derive, and
136
+ // treating it as text is what every other read path here does with it.
137
+ return [...TEXT_OPERATORS];
138
+ }
139
+ }
140
+ /** Which operators a column offers, once the store's own limits are applied. */
141
+ function offeredFilterOperators(column, supported) {
142
+ return filterOperatorsFor(column).filter((operator) => supported.some((available) => available === operator));
143
+ }
144
+ /**
145
+ * `property:op:value`, repeated once per filter.
146
+ *
147
+ * The value is everything after the second colon, so it may contain colons — a
148
+ * timestamp does. A property name containing one cannot be addressed by this
149
+ * form; that is refused by name in {@link resolveObjectFilters} rather than
150
+ * silently mis-parsed, because a filter that quietly does not apply is a screen
151
+ * showing every row as though it had been filtered.
152
+ */
153
+ function encodeObjectFilter(filter) {
154
+ if (!filterOperatorTakesValue(filter.op))
155
+ return `${filter.property}:${filter.op}`;
156
+ return `${filter.property}:${filter.op}:${filter.value ?? ''}`;
157
+ }
158
+ function parseObjectFilter(raw) {
159
+ const parts = raw.split(':');
160
+ if (parts.length < 2)
161
+ return undefined;
162
+ const [property, op, ...rest] = parts;
163
+ if (!property || !isCatalogFilterOperator(op))
164
+ return undefined;
165
+ if (!filterOperatorTakesValue(op))
166
+ return { property, op };
167
+ return { property, op, value: rest.join(':') };
168
+ }
169
+ /**
170
+ * How many filters one read may carry.
171
+ *
172
+ * A cap rather than none, because every filter is another conjunct in the two
173
+ * statements a paged read issues, and a caller that can send five hundred can
174
+ * make one request cost whatever it likes. Twenty is far past what a person
175
+ * builds by hand and far short of anything that would trouble the optimiser.
176
+ */
177
+ exports.CATALOG_FILTER_LIMIT = 20;
178
+ /**
179
+ * Turn what arrived into what a store may be given, or say why not.
180
+ *
181
+ * **Unhonourable filters are reported, never dropped.** The neighbouring
182
+ * `parseOutcomes` on the controller drops an unrecognised trace outcome on
183
+ * purpose, and this goes the other way for a reason that is worth the two
184
+ * sentences: dropping there costs a filter, so a typo widens the result set and
185
+ * shows more traces than asked for. Dropping HERE would narrow nothing — the read
186
+ * would come back unfiltered and the screen would present the whole table as
187
+ * though it were the matching rows. A filter that silently does not apply is the
188
+ * one failure mode a filtering UI must not have.
189
+ *
190
+ * @param columns the properties this reader may see. Deliberately the visible,
191
+ * non-blob columns rather than `type.properties`: a filter is only ever resolved
192
+ * against what the same request would return.
193
+ */
194
+ function resolveObjectFilters(columns, raw) {
195
+ const filters = [];
196
+ const problems = [];
197
+ if (raw.length > exports.CATALOG_FILTER_LIMIT) {
198
+ problems.push(`${raw.length} filters were sent and at most ${exports.CATALOG_FILTER_LIMIT} are accepted on one read.`);
199
+ return { filters, problems };
200
+ }
201
+ for (const entry of raw) {
202
+ const resolved = resolveOne(columns, entry);
203
+ if ('problem' in resolved)
204
+ problems.push(resolved.problem);
205
+ else
206
+ filters.push(resolved.filter);
207
+ }
208
+ return { filters, problems };
209
+ }
210
+ /** One entry: the filter it names, or the sentence explaining why it is not one. */
211
+ function resolveOne(columns, entry) {
212
+ const parsed = parseObjectFilter(entry);
213
+ if (!parsed) {
214
+ return {
215
+ problem: `"${entry}" is not a filter. The form is property:operator:value, and the operator is one of ${exports.CATALOG_FILTER_OPERATORS.join(', ')}.`,
216
+ };
217
+ }
218
+ const property = columns.find((column) => column.name === parsed.property);
219
+ if (!property) {
220
+ return {
221
+ problem: `${parsed.property} is not a readable property of this type. Filter by a property's name — on a published type that is the identifier form, which is not always how the source spelled the column.`,
222
+ };
223
+ }
224
+ const allowed = filterOperatorsFor(property);
225
+ if (!allowed.some((operator) => operator === parsed.op)) {
226
+ return {
227
+ problem: allowed.length === 0
228
+ ? `${property.name} cannot be filtered.`
229
+ : `${property.name} is ${property.type} and cannot be filtered with ${parsed.op}. It takes ${allowed.join(', ')}.`,
230
+ };
231
+ }
232
+ if (!filterOperatorTakesValue(parsed.op))
233
+ return { filter: { property, op: parsed.op } };
234
+ const coerced = coerceFilterValue(property.type, parsed.value ?? '');
235
+ if (!coerced.ok)
236
+ return { problem: `${property.name}: ${coerced.problem}` };
237
+ return { filter: { property, op: parsed.op, value: coerced.value } };
238
+ }
239
+ /**
240
+ * A typed value, or the reason there is none.
241
+ *
242
+ * Refusing rather than passing the text through is the whole of this function's
243
+ * value. MySQL compares a string to a `DOUBLE` by coercing the string — `'abc'`
244
+ * becomes `0` — so `mileage >= abc` is not an error, it is `mileage >= 0`, and it
245
+ * comes back as a full page of rows that look filtered. The same is true of a
246
+ * date that does not parse.
247
+ */
248
+ function coerceFilterValue(type, value) {
249
+ if (type === 'number') {
250
+ const parsed = Number(value);
251
+ if (value.trim() === '' || !Number.isFinite(parsed)) {
252
+ return { ok: false, problem: `"${value}" is not a number.` };
253
+ }
254
+ return { ok: true, value: parsed };
255
+ }
256
+ if (type === 'date') {
257
+ const parsed = new Date(value);
258
+ if (Number.isNaN(parsed.getTime())) {
259
+ return { ok: false, problem: `"${value}" is not a date.` };
260
+ }
261
+ return { ok: true, value: parsed };
262
+ }
263
+ if (type === 'boolean') {
264
+ const normalised = value.trim().toLowerCase();
265
+ if (['true', '1', 'yes'].includes(normalised))
266
+ return { ok: true, value: true };
267
+ if (['false', '0', 'no'].includes(normalised))
268
+ return { ok: true, value: false };
269
+ return { ok: false, problem: `"${value}" is not true or false.` };
270
+ }
271
+ return { ok: true, value };
272
+ }