@spotto/semantic-query 1.0.70-alpha.33 → 1.0.70-alpha.34

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.
package/dist/validate.js CHANGED
@@ -1,796 +1,796 @@
1
- "use strict";
2
- Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.queryUsesCustomFields = exports.validateSemanticQuery = exports.assertSemanticQuery = exports.coerceSemanticValue = exports.findSemanticField = exports.SEMANTIC_TIMESTAMP_FIELD = exports.SEMANTIC_VALUE_SLOT = void 0;
4
- const contract_1 = require("@spotto/contract");
5
- const suggest_1 = require("./suggest");
6
- const resolve_name_1 = require("./resolve-name");
7
- const dates_1 = require("./dates");
8
- const types_1 = require("./types");
9
- /**
10
- * Every rule of the semantic query language, in one place.
11
- *
12
- * This is the gate both back-ends stand behind: nothing executes a query that
13
- * has not passed through here, so a rule fixed here is fixed for the client's
14
- * offline evaluation and the server's Mongo read at the same time. Before this
15
- * package existed the rules lived inside the Mongo compiler — which meant the
16
- * only way to ask "is this query valid?" was to try to compile it to MongoDB,
17
- * and a client could not ask at all.
18
- *
19
- * Every rule below was paid for with a query that RAN and returned the wrong
20
- * rows. None of them throw in the ordinary case; all of them exist because the
21
- * failure they prevent is silent.
22
- *
23
- * Fail-fast: the FIRST problem is the answer. The issues array exists because
24
- * the wire shape has always carried one, not because more than one is ever
25
- * reported.
26
- */
27
- const LIMITS = contract_1.SEMANTIC_QUERY_LIMITS;
28
- /** The live storage slots. `valueBool` does not exist; the live key is `valueBoolean`. */
29
- exports.SEMANTIC_VALUE_SLOT = {
30
- STRING: 'valueString',
31
- INTEGER: 'valueInteger',
32
- DECIMAL: 'valueDecimal',
33
- BOOLEAN: 'valueBoolean',
34
- DATE: 'valueDate',
35
- };
36
- /** Millisecond-timestamp fields, by condition type. `createdDate` uses the ObjectId. */
37
- exports.SEMANTIC_TIMESTAMP_FIELD = {
38
- lastUpdated: 'lastUpdated',
39
- lastChanged: 'lastChanged',
40
- lastSeen: 'state.lastSeen',
41
- firstSeen: 'state.firstSeen',
42
- };
43
- /**
44
- * The complete property vocabulary, per condition type. Closed at the
45
- * PROPERTY level, not just types and operators: a generated
46
- * `{"type":"and","conditions":[…],"negate":true}` once compiled as a plain
47
- * AND — the description said "NOT IN" while the query matched exactly the
48
- * opposite set, with no error anywhere.
49
- */
50
- const TIMESTAMP_KEYS = ['type', 'operator', 'value', 'includeNeverSet'];
51
- const ALLOWED_KEYS = {
52
- and: ['type', 'conditions'],
53
- or: ['type', 'conditions'],
54
- assetType: ['type', 'path', 'includeSubtypes'],
55
- location: ['type', 'path', 'includeSublocations'],
56
- homeLocation: ['type', 'path', 'includeSublocations'],
57
- withAsset: ['type', 'assetId', 'hasAny'],
58
- customField: ['type', 'fieldName', 'dataType', 'operator', 'value', 'includeUnset'],
59
- createdDate: TIMESTAMP_KEYS,
60
- lastUpdated: TIMESTAMP_KEYS,
61
- lastChanged: TIMESTAMP_KEYS,
62
- lastSeen: TIMESTAMP_KEYS,
63
- firstSeen: TIMESTAMP_KEYS,
64
- geolocation: ['type', 'exists'],
65
- tags: ['type', 'operator', 'values'],
66
- telemetry: ['type', 'field', 'operator', 'value'],
67
- readiness: ['type', 'level', 'levels'],
68
- kit: ['type', 'isKit', 'isMember', 'satisfied', 'manifest', 'parentAssetId'],
69
- locationStatus: ['type', 'values'],
70
- // Location vocabulary. `tags`, `geolocation`, the timestamps and `telemetry`
71
- // are shared with assets above — same word, same fact, one entry.
72
- path: ['type', 'path', 'includeSublocations'],
73
- locationType: ['type', 'values'],
74
- hasReaders: ['type', 'value'],
75
- geofence: ['type', 'exists'],
76
- // One word, two vocabularies: the location condition takes only
77
- // `manifestId`; the snapshot fork adds `satisfied`. This table is per-WORD,
78
- // so `satisfied` is admitted here for both — the `case 'manifest'` branch
79
- // is what refuses it on a location query.
80
- manifest: ['type', 'manifestId', 'satisfied'],
81
- // Event vocabulary. `location` reuses the ASSET entry above (the location
82
- // entity's own word is `path`) — the two happen to declare the same
83
- // PROPERTIES, which is all this table checks, but they are NOT the same
84
- // fact: an asset's `location` is where it is now, an event's is where it
85
- // happened. `occurred` does NOT reuse TIMESTAMP_KEYS:
86
- // `includeNeverSet` is meaningless when every event has a timestamp, and
87
- // accepting a property that can never apply is how a query comes to say
88
- // something the compiler ignores.
89
- occurred: ['type', 'operator', 'value'],
90
- eventType: ['type', 'values'],
91
- reader: ['type', 'name'],
92
- user: ['type', 'name'],
93
- action: ['type', 'name'],
94
- // Snapshot vocabulary. `occurred`, `location`, `assetType`, `manifest`,
95
- // `user` and `reader` are all shared with the entries above — same word,
96
- // same properties, one entry. Only these two are the snapshot's own.
97
- //
98
- // `asset` takes an id and nothing else. It does NOT get `withAsset`'s
99
- // `hasAny`: every snapshot has a subject, so "any asset at all" is not a
100
- // question, and accepting a property that can never discriminate is how a
101
- // query comes to say something the compiler ignores.
102
- snapshotType: ['type', 'values'],
103
- asset: ['type', 'assetId'],
104
- // Reader vocabulary. `location` is shared with the entries above.
105
- deviceType: ['type', 'values'],
106
- readerType: ['type', 'values'],
107
- attachedAsset: ['type', 'assetId'],
108
- online: ['type', 'value'],
109
- hasReported: ['type', 'value'],
110
- hasGeolocation: ['type', 'value'],
111
- };
112
- const HEX24 = /^[0-9a-fA-F]{24}$/;
113
- /** The kit.manifest vocabulary (SemanticManifestState in the contract). */
114
- const MANIFEST_STATES = [
115
- 'satisfied',
116
- 'needsRecheck',
117
- 'notSatisfied',
118
- 'neverChecked',
119
- ];
120
- function checkProperties(cond, path) {
121
- // The table is typed against the contract's vocabulary (a new condition type
122
- // is a compile error until it is listed); the lookup itself takes whatever
123
- // arbitrary string arrived on the wire.
124
- const allowed = ALLOWED_KEYS[String(cond.type)];
125
- if (!allowed)
126
- return; // unknown type gets its own, better message
127
- const extra = Object.keys(cond).filter((k) => !allowed.includes(k));
128
- if (extra.length) {
129
- (0, types_1.failSemanticValidation)(path, 'UNKNOWN_PROPERTY', `'${cond.type}' does not support ${extra.map((k) => `'${k}'`).join(', ')}` +
130
- ` (allowed: ${allowed.filter((k) => k !== 'type').join(', ') || 'none'})`, extra.includes('negate') || extra.includes('not')
131
- ? 'The query language has no negation. Express the opposite directly, ' +
132
- 'or use notExists for an unset field.'
133
- : undefined);
134
- }
135
- }
136
- /**
137
- * A non-empty values array drawn from a closed platform set. The three
138
- * enum-valued conditions (`eventType`, `snapshotType`, `deviceType`,
139
- * `readerType`) all want exactly this, and an unknown value must be a loud
140
- * failure with a suggestion rather than an arm that matches nothing.
141
- */
142
- function requireEnumValues(values, universe, label, path) {
143
- if (!Array.isArray(values) || values.length === 0) {
144
- (0, types_1.failSemanticValidation)(path, 'BAD_SHAPE', `${label} needs a non-empty values array`);
145
- }
146
- const unknown = values.filter((v) => !universe.includes(v));
147
- if (unknown.length) {
148
- (0, types_1.failSemanticValidation)(path, 'BAD_VALUE', `Unknown ${label} ${unknown.map((v) => `'${v}'`).join(', ')}`, (0, suggest_1.nearest)(universe, String(unknown[0])));
149
- }
150
- }
151
- function requireString(v, path, field) {
152
- if (typeof v !== 'string' || v === '') {
153
- (0, types_1.failSemanticValidation)(path, 'BAD_SHAPE', `'${field}' must be a non-empty string`);
154
- }
155
- }
156
- /**
157
- * Mutually-exclusive properties, enforced here and not only at the wire
158
- * boundary.
159
- *
160
- * Closing key NAMES is not enough. A back-end that merely picked one branch
161
- * would silently IGNORE the other — a query that runs and returns rows
162
- * unrelated to half of what it says. Same failure class as the invented
163
- * `negate` property, one level down.
164
- */
165
- function requireExactlyOne(cond, keys, path, label) {
166
- const set = keys.filter((k) => cond[k] !== undefined);
167
- if (set.length !== 1) {
168
- (0, types_1.failSemanticValidation)(path, 'BAD_SHAPE', set.length === 0
169
- ? `${label} needs exactly one of ${keys.join(', ')}`
170
- : `${label} takes exactly one of ${keys.join(', ')} — got ${set.join(' and ')}`);
171
- }
172
- }
173
- /**
174
- * Boolean-typed properties must be REAL booleans, not truthy stand-ins. This
175
- * is an axis where the two back-ends part company: the Mongo compiler branches
176
- * on truthiness while the evaluator compares with `===`, so `isKit: null`
177
- * matches every non-kit in Mongo and nothing in memory. Worst is
178
- * `satisfied: null`, which compiles to `{groupSatisfiesManifest: null}` — Mongo
179
- * reads that as "missing OR null", i.e. the whole non-kit fleet: the exact
180
- * wrong-rows bug the exact-match ruling closed, back through a side door. And
181
- * even where the engines agree (`includeSubtypes: "false"` reads as true on
182
- * both sides), the query silently means the opposite of what was written.
183
- */
184
- function requireBooleansIfSet(cond, keys, path) {
185
- for (const k of keys) {
186
- const v = cond[k];
187
- if (v !== undefined && typeof v !== 'boolean') {
188
- (0, types_1.failSemanticValidation)(path, 'BAD_VALUE', `'${k}' must be true or false — got ${JSON.stringify(v)}`);
189
- }
190
- }
191
- }
192
- /**
193
- * Find a field by name, case-insensitively — the one lookup both back-ends
194
- * use, so neither can resolve a name to a different field than the other.
195
- */
196
- function findSemanticField(catalogue, fieldName) {
197
- return catalogue.fields.find((f) => f.name.toLowerCase() === String(fieldName).toLowerCase());
198
- }
199
- exports.findSemanticField = findSemanticField;
200
- // ---------------------------------------------------------------------------
201
- // Leaves
202
- // ---------------------------------------------------------------------------
203
- /**
204
- * Is this word in the queried entity's vocabulary?
205
- *
206
- * A condition can be perfectly well-formed and still be nonsense for the thing
207
- * being queried — `readiness` on a location, `snapshotType` on an asset. Left
208
- * unchecked that is not a syntax error, it is a query that runs and answers
209
- * the wrong question, so it is refused here with the entity named.
210
- *
211
- * When the word belongs to a DIFFERENT entity, say which. That is the
212
- * generator's likeliest mistake — it has seen the whole language somewhere —
213
- * and "'readiness' is an asset condition" sends it to the right place, where
214
- * "not a location condition" only says no.
215
- */
216
- function checkEntityVocabulary(type, ctx, path) {
217
- var _a;
218
- const allowed = (_a = contract_1.semanticConditionTypesByEntity[ctx.entityType]) !== null && _a !== void 0 ? _a : [];
219
- if (allowed.includes(type))
220
- return;
221
- // An entity with no vocabulary is one the language does not speak yet, and
222
- // that is the real reason nothing will work — a per-condition complaint here
223
- // would send the caller looking for a word that does not exist.
224
- if (!allowed.length) {
225
- (0, types_1.failSemanticValidation)(path, 'UNSUPPORTED_ENTITY', `'${ctx.entityType}' queries are not supported yet`);
226
- }
227
- const owner = Object.keys(contract_1.semanticConditionTypesByEntity).find((e) => e !== ctx.entityType && contract_1.semanticConditionTypesByEntity[e].includes(type));
228
- if (owner) {
229
- (0, types_1.failSemanticValidation)(path, 'WRONG_ENTITY', `'${type}' is a ${owner} condition — this is a ${ctx.entityType} query`);
230
- }
231
- // Reuses the language's existing "that is not a condition type" code — this
232
- // gate now reaches that case before the leaf switch does, and one meaning
233
- // should not have two codes.
234
- (0, types_1.failSemanticValidation)(path, 'UNKNOWN_TYPE', `'${type}' is not a ${ctx.entityType} condition`, (0, suggest_1.nearest)([...allowed], type));
235
- }
236
- function checkLeaf(cond, ctx, path) {
237
- // Also checked here, not only in the tree walk: leaves nested inside an
238
- // `or` reach this function directly.
239
- checkProperties(cond, path);
240
- checkEntityVocabulary(cond.type, ctx, path);
241
- switch (cond.type) {
242
- case 'assetType':
243
- requireString(cond.path, path, 'path');
244
- requireBooleansIfSet(cond, ['includeSubtypes'], path);
245
- return;
246
- case 'location':
247
- case 'homeLocation':
248
- requireString(cond.path, path, 'path');
249
- requireBooleansIfSet(cond, ['includeSublocations'], path);
250
- return;
251
- case 'withAsset': {
252
- requireExactlyOne(cond, ['assetId', 'hasAny'], path, 'withAsset');
253
- requireBooleansIfSet(cond, ['hasAny'], path);
254
- if (cond.hasAny !== undefined)
255
- return;
256
- if (!cond.assetId || !HEX24.test(cond.assetId)) {
257
- (0, types_1.failSemanticValidation)(path, 'BAD_ASSET_ID', 'withAsset needs a valid assetId (24-hex) or hasAny');
258
- }
259
- return;
260
- }
261
- case 'geolocation':
262
- requireBooleansIfSet(cond, ['exists'], path);
263
- return;
264
- case 'tags':
265
- // Both back-ends read anything that is not 'all' as 'any', so an
266
- // unchecked operator is a typo silently widening all-of to any-of —
267
- // with the two engines agreeing and both wrong about the intent.
268
- if (cond.operator !== undefined &&
269
- !contract_1.semanticSetOperators.includes(cond.operator)) {
270
- (0, types_1.failSemanticValidation)(path, 'BAD_OPERATOR', `tags operator must be one of ${contract_1.semanticSetOperators.join(', ')} — got '${cond.operator}'`, (0, suggest_1.nearest)(contract_1.semanticSetOperators, String(cond.operator)));
271
- }
272
- if (!Array.isArray(cond.values) || cond.values.length === 0) {
273
- (0, types_1.failSemanticValidation)(path, 'BAD_TAGS', 'tags condition needs a non-empty values array');
274
- }
275
- return;
276
- case 'telemetry': {
277
- // Re-checked here, not only at the wire boundary: this value becomes part
278
- // of a document PATH downstream, so an unchecked field name would build a
279
- // query over an arbitrary key.
280
- if (!contract_1.semanticTelemetryFields.includes(cond.field)) {
281
- (0, types_1.failSemanticValidation)(path, 'BAD_VALUE', `Unknown telemetry field '${cond.field}' (allowed: ${contract_1.semanticTelemetryFields.join(', ')})`, (0, suggest_1.nearest)(contract_1.semanticTelemetryFields, String(cond.field)));
282
- }
283
- if (!['eq', 'ne', 'gt', 'gte', 'lt', 'lte'].includes(cond.operator)) {
284
- (0, types_1.failSemanticValidation)(path, 'BAD_OPERATOR', `Unknown telemetry operator '${cond.operator}'`);
285
- }
286
- if (typeof cond.value !== 'number') {
287
- (0, types_1.failSemanticValidation)(path, 'BAD_VALUE', `telemetry '${cond.field}' needs a numeric value`);
288
- }
289
- return;
290
- }
291
- case 'readiness':
292
- checkReadiness(cond, path);
293
- return;
294
- case 'kit': {
295
- requireExactlyOne(cond, ['isKit', 'isMember', 'satisfied', 'manifest', 'parentAssetId'], path, 'kit');
296
- requireBooleansIfSet(cond, ['isKit', 'isMember', 'satisfied'], path);
297
- if (cond.manifest !== undefined &&
298
- !MANIFEST_STATES.includes(cond.manifest)) {
299
- (0, types_1.failSemanticValidation)(path, 'BAD_VALUE', `kit.manifest must be one of ${MANIFEST_STATES.join(', ')}`);
300
- }
301
- if (cond.parentAssetId !== undefined && !HEX24.test(cond.parentAssetId)) {
302
- (0, types_1.failSemanticValidation)(path, 'BAD_ASSET_ID', 'kit.parentAssetId must be a 24-hex asset id');
303
- }
304
- return;
305
- }
306
- case 'locationStatus': {
307
- if (!Array.isArray(cond.values) || cond.values.length === 0) {
308
- (0, types_1.failSemanticValidation)(path, 'BAD_SHAPE', 'locationStatus needs a non-empty values array');
309
- }
310
- const unknown = cond.values.filter((v) => !contract_1.LOCATION_STATUS.includes(v));
311
- if (unknown.length) {
312
- (0, types_1.failSemanticValidation)(path, 'BAD_VALUE', `Unknown location status ${unknown.map((v) => `'${v}'`).join(', ')}`, (0, suggest_1.nearest)(contract_1.LOCATION_STATUS, String(unknown[0])));
313
- }
314
- return;
315
- }
316
- case 'createdDate':
317
- case 'lastUpdated':
318
- case 'lastChanged':
319
- case 'lastSeen':
320
- case 'firstSeen':
321
- checkTimestamp(cond, ctx, path);
322
- return;
323
- case 'customField':
324
- checkCustomField(cond, ctx, path);
325
- return;
326
- // --- Location vocabulary ---------------------------------------------
327
- case 'path':
328
- requireString(cond.path, path, 'path');
329
- requireBooleansIfSet(cond, ['includeSublocations'], path);
330
- return;
331
- case 'locationType': {
332
- if (!Array.isArray(cond.values) || cond.values.length === 0) {
333
- (0, types_1.failSemanticValidation)(path, 'BAD_SHAPE', 'locationType needs a non-empty values array');
334
- }
335
- const unknown = cond.values.filter((v) => !contract_1.LOCATION_TYPES.includes(v));
336
- if (unknown.length) {
337
- (0, types_1.failSemanticValidation)(path, 'BAD_VALUE', `Unknown location type ${unknown.map((v) => `'${v}'`).join(', ')}`, (0, suggest_1.nearest)(contract_1.LOCATION_TYPES, String(unknown[0])));
338
- }
339
- return;
340
- }
341
- case 'hasReaders':
342
- requireBooleansIfSet(cond, ['value'], path);
343
- if (cond.value === undefined) {
344
- (0, types_1.failSemanticValidation)(path, 'BAD_SHAPE', "hasReaders needs 'value' (true or false)");
345
- }
346
- return;
347
- case 'geofence':
348
- requireBooleansIfSet(cond, ['exists'], path);
349
- if (cond.exists === undefined) {
350
- (0, types_1.failSemanticValidation)(path, 'BAD_SHAPE', "geofence needs 'exists' (true or false)");
351
- }
352
- return;
353
- case 'manifest': {
354
- // Read through a widened local: `satisfied` exists only on the snapshot
355
- // fork of this word, so the union type does not carry it.
356
- const satisfied = cond.satisfied;
357
- if (ctx.entityType === 'snapshot') {
358
- // Snapshot fork: the id becomes optional and `satisfied` joins it —
359
- // either alone ("all failed checks") or together, at least one
360
- // present. Boolean-strict: the flag it reads is exact-match, and a
361
- // truthy string would silently match nothing.
362
- if (cond.manifestId === undefined && satisfied === undefined) {
363
- (0, types_1.failSemanticValidation)(path, 'BAD_SHAPE', "manifest needs 'manifestId', 'satisfied', or both");
364
- }
365
- if (satisfied !== undefined && typeof satisfied !== 'boolean') {
366
- (0, types_1.failSemanticValidation)(path, 'BAD_VALUE', 'manifest.satisfied must be true or false');
367
- }
368
- if (cond.manifestId !== undefined && !HEX24.test(String(cond.manifestId))) {
369
- (0, types_1.failSemanticValidation)(path, 'BAD_MANIFEST_ID', 'manifest.manifestId must be a 24-hex manifest id');
370
- }
371
- return;
372
- }
373
- // Location: exactly the historical shape. The shared key table admits
374
- // `satisfied` for both entities, so this is where a location query is
375
- // refused it.
376
- if (satisfied !== undefined) {
377
- (0, types_1.failSemanticValidation)(path, 'BAD_SHAPE', "'satisfied' is a snapshot manifest property — a location manifest condition takes only 'manifestId'");
378
- }
379
- // Ids point. A name would have to be resolved, and a resolution that
380
- // missed would run and match nothing.
381
- if (!HEX24.test(String(cond.manifestId))) {
382
- (0, types_1.failSemanticValidation)(path, 'BAD_MANIFEST_ID', 'manifest.manifestId must be a 24-hex manifest id');
383
- }
384
- return;
385
- }
386
- // --- Event vocabulary -------------------------------------------------
387
- case 'occurred':
388
- // Deliberately NOT `checkTimestamp`: that helper also permits
389
- // `includeNeverSet`, which is meaningless when every event has a
390
- // timestamp by construction.
391
- if (!contract_1.semanticTimestampOperators.includes(cond.operator)) {
392
- (0, types_1.failSemanticValidation)(path, 'BAD_OPERATOR', `occurred operator must be one of ${contract_1.semanticTimestampOperators.join(', ')}` +
393
- ` — got '${cond.operator}'`, (0, suggest_1.nearest)(contract_1.semanticTimestampOperators, String(cond.operator)));
394
- }
395
- // Read through a widened local: `value` is non-optional in the
396
- // contract, so comparing it to `undefined` narrows `cond` to `never` and
397
- // every later reference stops compiling. The wire can still omit it.
398
- {
399
- const raw = cond.value;
400
- if (raw === undefined) {
401
- (0, types_1.failSemanticValidation)(path, 'BAD_SHAPE', `occurred ${cond.operator} needs a value`);
402
- }
403
- try {
404
- (0, dates_1.semanticDateBoundaryMs)(raw, cond.operator, ctx.now, ctx.tz);
405
- }
406
- catch (e) {
407
- return (0, types_1.failSemanticValidation)(path, 'BAD_DATE', e.message);
408
- }
409
- }
410
- return;
411
- case 'eventType': {
412
- if (!Array.isArray(cond.values) || cond.values.length === 0) {
413
- (0, types_1.failSemanticValidation)(path, 'BAD_SHAPE', 'eventType needs a non-empty values array');
414
- }
415
- const unknown = cond.values.filter((v) => !contract_1.EVENT_TYPES.includes(v));
416
- if (unknown.length) {
417
- (0, types_1.failSemanticValidation)(path, 'BAD_VALUE', `Unknown event type ${unknown.map((v) => `'${v}'`).join(', ')}`, (0, suggest_1.nearest)(contract_1.EVENT_TYPES, String(unknown[0])));
418
- }
419
- return;
420
- }
421
- // The three name-resolved words. Resolution happens HERE rather than in
422
- // the compiler so both engines see the same ids, and so an unknown or
423
- // ambiguous name is a validation issue with a suggestion — the shape the
424
- // AI repair loop and the wire both already understand.
425
- case 'reader':
426
- resolveOrFail(ctx.catalogue.readers, cond.name, path, 'reader');
427
- return;
428
- case 'user':
429
- resolveOrFail(ctx.catalogue.users, cond.name, path, 'user');
430
- return;
431
- case 'action':
432
- resolveOrFail(ctx.catalogue.actions, cond.name, path, 'action');
433
- return;
434
- // --- Snapshot vocabulary ----------------------------------------------
435
- case 'snapshotType': {
436
- if (!Array.isArray(cond.values) || cond.values.length === 0) {
437
- (0, types_1.failSemanticValidation)(path, 'BAD_SHAPE', 'snapshotType needs a non-empty values array');
438
- }
439
- const unknown = cond.values.filter((v) => !contract_1.SNAPSHOT_TYPES.includes(v));
440
- if (unknown.length) {
441
- (0, types_1.failSemanticValidation)(path, 'BAD_VALUE', `Unknown snapshot type ${unknown.map((v) => `'${v}'`).join(', ')}` +
442
- ` (${contract_1.SNAPSHOT_TYPES.join(', ')})`, (0, suggest_1.nearest)(contract_1.SNAPSHOT_TYPES, String(unknown[0])));
443
- }
444
- return;
445
- }
446
- case 'asset':
447
- // An id, never a name — the refusal the event vocabulary is built on.
448
- // A name here would be a substring regex, and "BC-A1" would quietly
449
- // bring back "BC-A10".
450
- if (!HEX24.test(String(cond.assetId))) {
451
- (0, types_1.failSemanticValidation)(path, 'BAD_ASSET_ID', 'asset.assetId must be a 24-hex asset id');
452
- }
453
- return;
454
- // --- Reader vocabulary ------------------------------------------------
455
- case 'deviceType':
456
- requireEnumValues(cond.values, contract_1.DEVICE_TYPES, 'deviceType', path);
457
- return;
458
- case 'readerType':
459
- requireEnumValues(cond.values, contract_1.READER_TYPES, 'readerType', path);
460
- return;
461
- case 'attachedAsset':
462
- if (!HEX24.test(String(cond.assetId))) {
463
- (0, types_1.failSemanticValidation)(path, 'BAD_ASSET_ID', 'attachedAsset.assetId must be a 24-hex asset id');
464
- }
465
- return;
466
- case 'online':
467
- case 'hasReported':
468
- case 'hasGeolocation':
469
- // Required, not optional. A boolean condition with no value has no
470
- // meaning to fall back to — defaulting it either way would answer a
471
- // question the caller did not ask.
472
- if (typeof cond.value !== 'boolean') {
473
- (0, types_1.failSemanticValidation)(path, 'BAD_SHAPE', `${cond.type} needs 'value' (true or false)`);
474
- }
475
- return;
476
- default:
477
- (0, types_1.failSemanticValidation)(path, 'UNKNOWN_TYPE', `Unknown condition type '${cond.type}'`);
478
- }
479
- }
480
- /**
481
- * Resolve a name or fail with the resolver's own issue. The compiler resolves
482
- * again from the same catalogue to get the ids it emits; this pass exists so
483
- * the FAILURE happens at validation time, where it becomes a repairable issue
484
- * rather than a query that ran against nothing.
485
- */
486
- function resolveOrFail(catalogue, name, path, noun) {
487
- const { issue } = (0, resolve_name_1.resolveName)(catalogue, name, { path, noun });
488
- if (issue)
489
- (0, types_1.failSemanticValidation)(path, issue.code, issue.message, issue.suggestion);
490
- }
491
- function checkReadiness(cond, path) {
492
- var _a;
493
- if (cond.level !== undefined && cond.levels !== undefined) {
494
- (0, types_1.failSemanticValidation)(path, 'BAD_SHAPE', "readiness takes 'level' or 'levels', not both");
495
- }
496
- const levels = (_a = cond.levels) !== null && _a !== void 0 ? _a : (cond.level === undefined ? undefined : [cond.level]);
497
- if (!levels)
498
- (0, types_1.failSemanticValidation)(path, 'BAD_SHAPE', "readiness needs 'level' or 'levels'");
499
- if (!Array.isArray(levels))
500
- (0, types_1.failSemanticValidation)(path, 'BAD_SHAPE', "'levels' must be an array");
501
- if (levels.length === 0)
502
- (0, types_1.failSemanticValidation)(path, 'BAD_SHAPE', "'levels' must be non-empty");
503
- for (const l of levels) {
504
- if (l !== 0 && l !== 1 && l !== 2) {
505
- (0, types_1.failSemanticValidation)(path, 'BAD_VALUE', `readiness level must be 0 (ready), 1 (conditional) or 2 (not ready) — got ${l}`);
506
- }
507
- }
508
- }
509
- function checkTimestamp(cond, ctx, path) {
510
- const op = cond.operator;
511
- if (!['gt', 'gte', 'lt', 'lte'].includes(op)) {
512
- (0, types_1.failSemanticValidation)(path, 'BAD_OPERATOR', `'${cond.type}' supports gt, gte, lt, lte — got '${op}'`);
513
- }
514
- if (cond.value === undefined) {
515
- (0, types_1.failSemanticValidation)(path, 'BAD_SHAPE', `'${cond.type}' ${op} needs a value`);
516
- }
517
- if (cond.includeNeverSet !== undefined && op !== 'lt' && op !== 'lte') {
518
- (0, types_1.failSemanticValidation)(path, 'BAD_SHAPE', `'includeNeverSet' applies to lt/lte only — a missing timestamp correctly fails ${op}`);
519
- }
520
- requireBooleansIfSet(cond, ['includeNeverSet'], path);
521
- let ms;
522
- try {
523
- ms = (0, dates_1.semanticDateBoundaryMs)(cond.value, op, ctx.now, ctx.tz);
524
- }
525
- catch (e) {
526
- return (0, types_1.failSemanticValidation)(path, 'BAD_DATE', e.message);
527
- }
528
- if (cond.type === 'createdDate') {
529
- // Creation time is carried by the ObjectId, whose 4-byte timestamp is
530
- // unsigned: dates before 1970 or past 2106 have no id to compare against,
531
- // and must fail as a validation issue rather than as an exception escaping
532
- // from a back-end. `+1` because the inclusive forms step a second (see the
533
- // compiler's boundary-second note).
534
- const second = Math.floor(ms / 1000) + 1;
535
- if (second < 0 || second > 0xffffffff) {
536
- (0, types_1.failSemanticValidation)(path, 'BAD_DATE', `'${cond.type}': date is outside the representable range (1970–2106)`);
537
- }
538
- }
539
- }
540
- function checkCustomField(cond, ctx, path) {
541
- var _a;
542
- const fdef = findSemanticField(ctx.catalogue, cond.fieldName);
543
- if (!fdef) {
544
- (0, types_1.failSemanticValidation)(path, 'UNKNOWN_FIELD', `No custom field named '${cond.fieldName}'`, (0, suggest_1.nearest)(ctx.catalogue.fields.map((f) => f.name), String(cond.fieldName)));
545
- }
546
- if (cond.operator === 'exists' || cond.operator === 'notExists') {
547
- if (cond.value !== undefined) {
548
- (0, types_1.failSemanticValidation)(path, 'BAD_SHAPE', `'${cond.fieldName}' ${cond.operator} takes no value`);
549
- }
550
- }
551
- else if (cond.value === undefined) {
552
- (0, types_1.failSemanticValidation)(path, 'BAD_SHAPE', `'${cond.fieldName}' ${cond.operator} needs a value`);
553
- }
554
- if (cond.includeUnset !== undefined && cond.operator !== 'ne') {
555
- (0, types_1.failSemanticValidation)(path, 'BAD_SHAPE', "'includeUnset' applies to the ne operator only — it is what decides " +
556
- 'whether an unset field counts as "not equal"');
557
- }
558
- requireBooleansIfSet(cond, ['includeUnset'], path);
559
- // Honest refusal until the DATETIME ruling lands: matching against the wrong
560
- // slot is a valid query that returns nothing.
561
- if (fdef.dataType === 'DATETIME') {
562
- (0, types_1.failSemanticValidation)(path, 'UNSUPPORTED_DATATYPE', `'${fdef.name}' is a date-and-time field, which semantic queries do not support yet`);
563
- }
564
- // A dropdown value must match an option exactly, or the query silently
565
- // returns nothing. Reject with the nearest option instead.
566
- if (((_a = fdef.options) === null || _a === void 0 ? void 0 : _a.length) && cond.operator === 'eq' && typeof cond.value === 'string') {
567
- if (!fdef.options.includes(cond.value)) {
568
- (0, types_1.failSemanticValidation)(path, 'BAD_OPTION', `'${cond.value}' is not an option for '${fdef.name}'`, (0, suggest_1.nearest)(fdef.options, cond.value));
569
- }
570
- }
571
- if (cond.operator === 'contains') {
572
- if (fdef.dataType !== 'STRING') {
573
- (0, types_1.failSemanticValidation)(path, 'BAD_OPERATOR', `'contains' applies to STRING fields only ('${fdef.name}' is ${fdef.dataType})`);
574
- }
575
- return;
576
- }
577
- if (cond.operator === 'exists' || cond.operator === 'notExists')
578
- return;
579
- if (!['eq', 'ne', 'gt', 'gte', 'lt', 'lte'].includes(cond.operator)) {
580
- (0, types_1.failSemanticValidation)(path, 'BAD_OPERATOR', `Unknown operator '${cond.operator}' on '${fdef.name}'`);
581
- }
582
- // The value must be expressible in the slot it will be compared against.
583
- checkValue(cond, fdef, ctx, path);
584
- }
585
- /**
586
- * Turn a written value into the form its storage slot actually holds.
587
- *
588
- * Shared deliberately: the Mongo compiler and the in-memory evaluator must
589
- * compare against the SAME value, and this is where a numeric literal for a
590
- * text field, or a relative date for a DATE field, becomes one concrete thing.
591
- * Two copies of this would classify the same document differently.
592
- *
593
- * Throws a plain Error — the caller decides whether that is a validation issue
594
- * (it is, at the gate) or an impossible state (it is, after the gate).
595
- */
596
- function coerceSemanticValue(value, dataType, now, tz) {
597
- switch (dataType) {
598
- case 'DATE':
599
- // Confirmed encoding: YYYYMMDD integer, e.g. 20260815.
600
- return (0, dates_1.semanticCivilToYyyymmdd)((0, dates_1.resolveSemanticDate)(value, now, tz));
601
- case 'INTEGER':
602
- case 'DECIMAL': {
603
- const n = typeof value === 'number' ? value : Number(value);
604
- if (!Number.isFinite(n)) {
605
- throw new Error(`is ${dataType} but got '${String(value)}'`);
606
- }
607
- return n;
608
- }
609
- case 'BOOLEAN': {
610
- if (typeof value === 'boolean')
611
- return value;
612
- if (value === 'true' || value === 'false')
613
- return value === 'true';
614
- throw new Error(`is BOOLEAN but got '${String(value)}'`);
615
- }
616
- default:
617
- return typeof value === 'string' ? value : String(value !== null && value !== void 0 ? value : '');
618
- }
619
- }
620
- exports.coerceSemanticValue = coerceSemanticValue;
621
- function checkValue(cond, fdef, ctx, path) {
622
- try {
623
- coerceSemanticValue(cond.value, fdef.dataType, ctx.now, ctx.tz);
624
- }
625
- catch (e) {
626
- const message = e.message;
627
- fdef.dataType === 'DATE'
628
- ? (0, types_1.failSemanticValidation)(path, 'BAD_DATE', `'${fdef.name}': ${message}`)
629
- : (0, types_1.failSemanticValidation)(path, 'BAD_VALUE', `'${fdef.name}' ${message}`);
630
- }
631
- }
632
- // ---------------------------------------------------------------------------
633
- // Tree
634
- // ---------------------------------------------------------------------------
635
- function checkConditions(conds, ctx, depth, budget, path) {
636
- if (!Array.isArray(conds))
637
- (0, types_1.failSemanticValidation)(path, 'BAD_SHAPE', 'conditions must be an array');
638
- if (depth > LIMITS.maxDepth) {
639
- (0, types_1.failSemanticValidation)(path, 'TOO_DEEP', `Nesting exceeds maximum depth ${LIMITS.maxDepth}`);
640
- }
641
- conds.forEach((cond, i) => {
642
- if (++budget.n > LIMITS.maxConditions) {
643
- (0, types_1.failSemanticValidation)(path, 'TOO_MANY', `Query exceeds maximum of ${LIMITS.maxConditions} conditions`);
644
- }
645
- const p = `${path}[${i}]`;
646
- if (!cond || typeof cond !== 'object')
647
- (0, types_1.failSemanticValidation)(p, 'BAD_SHAPE', 'condition must be an object');
648
- if (cond.type === 'and' || cond.type === 'or') {
649
- // Leaves are checked inside checkLeaf (the only path an or-branch leaf
650
- // takes), so groups are checked here and nowhere twice.
651
- checkProperties(cond, p);
652
- if (!Array.isArray(cond.conditions) || cond.conditions.length === 0) {
653
- // An empty group means "always true" — quietly widening the query to
654
- // everything.
655
- (0, types_1.failSemanticValidation)(p, 'BAD_SHAPE', `'${cond.type}' needs a non-empty conditions array`);
656
- }
657
- }
658
- if (cond.type === 'and') {
659
- checkConditions(cond.conditions, ctx, depth + 1, budget, p);
660
- }
661
- else if (cond.type === 'or') {
662
- cond.conditions.forEach((c, j) => {
663
- const bp = `${p}[${j}]`;
664
- // Or-branches bypass the top of this walk, so its object guard must
665
- // run here too — a null branch is a validation issue, not a TypeError
666
- // out of the property check.
667
- if (!c || typeof c !== 'object')
668
- (0, types_1.failSemanticValidation)(bp, 'BAD_SHAPE', 'condition must be an object');
669
- if (c.type === 'and' || c.type === 'or') {
670
- // The recursive walk counts the group itself against the budget —
671
- // counting it here as well double-charged every nested group.
672
- checkConditions([c], ctx, depth + 1, budget, bp);
673
- }
674
- else {
675
- // Leaves never pass through the walk above, so they are counted
676
- // here. Uncounted branches were a stored query that a 60-byte GET
677
- // could replay as tens of thousands of regex arms over the whole
678
- // collection.
679
- if (++budget.n > LIMITS.maxConditions) {
680
- (0, types_1.failSemanticValidation)(p, 'TOO_MANY', `Query exceeds maximum of ${LIMITS.maxConditions} conditions`);
681
- }
682
- checkLeaf(c, ctx, bp);
683
- }
684
- });
685
- }
686
- else {
687
- checkLeaf(cond, ctx, p);
688
- }
689
- });
690
- }
691
- // ---------------------------------------------------------------------------
692
- // Entry points
693
- // ---------------------------------------------------------------------------
694
- /**
695
- * Assert a query is valid, throwing `SemanticQueryValidationError` on the
696
- * first problem. This is the gate: a back-end may assume anything that passed
697
- * through it is safe to execute.
698
- */
699
- function assertSemanticQuery(semanticQuery, catalogue, opts = {}) {
700
- var _a, _b, _c;
701
- const ctx = {
702
- catalogue,
703
- now: (_a = opts.now) !== null && _a !== void 0 ? _a : new Date(),
704
- tz: (_b = opts.timeZone) !== null && _b !== void 0 ? _b : 'UTC',
705
- entityType: (_c = opts.entityType) !== null && _c !== void 0 ? _c : 'asset',
706
- };
707
- if (!semanticQuery || !Array.isArray(semanticQuery.conditions)) {
708
- (0, types_1.failSemanticValidation)('conditions', 'BAD_SHAPE', 'semanticQuery.conditions must be an array');
709
- }
710
- // The closed vocabulary applies to the ENVELOPE too. Without this a top-level
711
- // `{conditions:[…], negate:true}` passes cleanly — the original
712
- // invented-property failure, one level up from the conditions it guards.
713
- const envelopeExtra = Object.keys(semanticQuery).filter((k) => k !== 'version' && k !== 'conditions');
714
- if (envelopeExtra.length) {
715
- (0, types_1.failSemanticValidation)('', 'UNKNOWN_PROPERTY', `A semantic query has only 'version' and 'conditions' — got ${envelopeExtra
716
- .map((k) => `'${k}'`)
717
- .join(', ')}`);
718
- }
719
- if (semanticQuery.version !== undefined &&
720
- semanticQuery.version !== contract_1.SEMANTIC_QUERY_VERSION) {
721
- (0, types_1.failSemanticValidation)('version', 'BAD_VERSION', `Unsupported semantic query version ${semanticQuery.version} ` +
722
- `(this build understands version ${contract_1.SEMANTIC_QUERY_VERSION})`);
723
- }
724
- checkConditions(semanticQuery.conditions, ctx, 1, { n: 0 }, 'conditions');
725
- checkEventAssetTypeScope(semanticQuery.conditions, ctx);
726
- }
727
- exports.assertSemanticQuery = assertSemanticQuery;
728
- /**
729
- * Every condition ANDed with the rest at the top level — descending through
730
- * `and` groups, never through `or`, because an or-branch is an alternative
731
- * rather than a further constraint.
732
- */
733
- function conjuncts(conditions) {
734
- return conditions.flatMap((c) => c.type === 'and' && Array.isArray(c.conditions)
735
- ? conjuncts(c.conditions)
736
- : [c]);
737
- }
738
- /**
739
- * `assetType` beside an `eventType` that names no asset-CRUD event.
740
- *
741
- * The condition already scopes itself to `ASSET_CRUD_EVENT_TYPES` at the leaf,
742
- * so this conjunction is not WRONG — it is provably empty, which is worse:
743
- * "movements of forklifts" would render as an ordinary filter over an ordinary
744
- * empty feed, the exact silent-nothing that kept `assetType` out of the event
745
- * vocabulary in the first place. Refusing it with a reason is what lets the
746
- * word exist at all.
747
- *
748
- * Only conjuncts are considered — `(forklift creations) OR (any movement)` is
749
- * a legitimate question, and leaf scoping already answers it correctly.
750
- */
751
- function checkEventAssetTypeScope(conditions, ctx) {
752
- var _a;
753
- if (ctx.entityType !== 'event')
754
- return;
755
- const flat = conjuncts(conditions);
756
- if (!flat.some((c) => c.type === 'assetType'))
757
- return;
758
- for (const cond of flat) {
759
- if (cond.type !== 'eventType')
760
- continue;
761
- const values = (_a = cond.values) !== null && _a !== void 0 ? _a : [];
762
- if (!values.length)
763
- continue;
764
- if (values.some((v) => contract_1.ASSET_CRUD_EVENT_TYPES.includes(v))) {
765
- continue;
766
- }
767
- (0, types_1.failSemanticValidation)('conditions', 'CONFLICTING_CONDITIONS', `An asset type can only narrow ${contract_1.ASSET_CRUD_EVENT_TYPES.join(', ')} — ` +
768
- `the other event kinds do not record the asset's type, so pairing it ` +
769
- `with ${values.join(', ')} matches nothing`, `Drop the asset type, or ask about ${contract_1.ASSET_CRUD_EVENT_TYPES.join(', ')}`);
770
- }
771
- }
772
- /** Validate without throwing. Empty array = valid. */
773
- function validateSemanticQuery(semanticQuery, catalogue, opts = {}) {
774
- try {
775
- assertSemanticQuery(semanticQuery, catalogue, opts);
776
- return [];
777
- }
778
- catch (e) {
779
- if (e instanceof types_1.SemanticQueryValidationError)
780
- return e.issues;
781
- throw e;
782
- }
783
- }
784
- exports.validateSemanticQuery = validateSemanticQuery;
785
- /** True when any condition touches a custom field — both back-ends need to know. */
786
- function queryUsesCustomFields(semanticQuery) {
787
- var _a;
788
- const walk = (conds) => conds.some((c) => {
789
- var _a;
790
- return (c === null || c === void 0 ? void 0 : c.type) === 'customField' ||
791
- (((c === null || c === void 0 ? void 0 : c.type) === 'and' || (c === null || c === void 0 ? void 0 : c.type) === 'or') && walk((_a = c.conditions) !== null && _a !== void 0 ? _a : []));
792
- });
793
- return walk((_a = semanticQuery === null || semanticQuery === void 0 ? void 0 : semanticQuery.conditions) !== null && _a !== void 0 ? _a : []);
794
- }
795
- exports.queryUsesCustomFields = queryUsesCustomFields;
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.queryUsesCustomFields = exports.validateSemanticQuery = exports.assertSemanticQuery = exports.coerceSemanticValue = exports.findSemanticField = exports.SEMANTIC_TIMESTAMP_FIELD = exports.SEMANTIC_VALUE_SLOT = void 0;
4
+ const contract_1 = require("@spotto/contract");
5
+ const suggest_1 = require("./suggest");
6
+ const resolve_name_1 = require("./resolve-name");
7
+ const dates_1 = require("./dates");
8
+ const types_1 = require("./types");
9
+ /**
10
+ * Every rule of the semantic query language, in one place.
11
+ *
12
+ * This is the gate both back-ends stand behind: nothing executes a query that
13
+ * has not passed through here, so a rule fixed here is fixed for the client's
14
+ * offline evaluation and the server's Mongo read at the same time. Before this
15
+ * package existed the rules lived inside the Mongo compiler — which meant the
16
+ * only way to ask "is this query valid?" was to try to compile it to MongoDB,
17
+ * and a client could not ask at all.
18
+ *
19
+ * Every rule below was paid for with a query that RAN and returned the wrong
20
+ * rows. None of them throw in the ordinary case; all of them exist because the
21
+ * failure they prevent is silent.
22
+ *
23
+ * Fail-fast: the FIRST problem is the answer. The issues array exists because
24
+ * the wire shape has always carried one, not because more than one is ever
25
+ * reported.
26
+ */
27
+ const LIMITS = contract_1.SEMANTIC_QUERY_LIMITS;
28
+ /** The live storage slots. `valueBool` does not exist; the live key is `valueBoolean`. */
29
+ exports.SEMANTIC_VALUE_SLOT = {
30
+ STRING: 'valueString',
31
+ INTEGER: 'valueInteger',
32
+ DECIMAL: 'valueDecimal',
33
+ BOOLEAN: 'valueBoolean',
34
+ DATE: 'valueDate',
35
+ };
36
+ /** Millisecond-timestamp fields, by condition type. `createdDate` uses the ObjectId. */
37
+ exports.SEMANTIC_TIMESTAMP_FIELD = {
38
+ lastUpdated: 'lastUpdated',
39
+ lastChanged: 'lastChanged',
40
+ lastSeen: 'state.lastSeen',
41
+ firstSeen: 'state.firstSeen',
42
+ };
43
+ /**
44
+ * The complete property vocabulary, per condition type. Closed at the
45
+ * PROPERTY level, not just types and operators: a generated
46
+ * `{"type":"and","conditions":[…],"negate":true}` once compiled as a plain
47
+ * AND — the description said "NOT IN" while the query matched exactly the
48
+ * opposite set, with no error anywhere.
49
+ */
50
+ const TIMESTAMP_KEYS = ['type', 'operator', 'value', 'includeNeverSet'];
51
+ const ALLOWED_KEYS = {
52
+ and: ['type', 'conditions'],
53
+ or: ['type', 'conditions'],
54
+ assetType: ['type', 'path', 'includeSubtypes'],
55
+ location: ['type', 'path', 'includeSublocations'],
56
+ homeLocation: ['type', 'path', 'includeSublocations'],
57
+ withAsset: ['type', 'assetId', 'hasAny'],
58
+ customField: ['type', 'fieldName', 'dataType', 'operator', 'value', 'includeUnset'],
59
+ createdDate: TIMESTAMP_KEYS,
60
+ lastUpdated: TIMESTAMP_KEYS,
61
+ lastChanged: TIMESTAMP_KEYS,
62
+ lastSeen: TIMESTAMP_KEYS,
63
+ firstSeen: TIMESTAMP_KEYS,
64
+ geolocation: ['type', 'exists'],
65
+ tags: ['type', 'operator', 'values'],
66
+ telemetry: ['type', 'field', 'operator', 'value'],
67
+ readiness: ['type', 'level', 'levels'],
68
+ kit: ['type', 'isKit', 'isMember', 'satisfied', 'manifest', 'parentAssetId'],
69
+ locationStatus: ['type', 'values'],
70
+ // Location vocabulary. `tags`, `geolocation`, the timestamps and `telemetry`
71
+ // are shared with assets above — same word, same fact, one entry.
72
+ path: ['type', 'path', 'includeSublocations'],
73
+ locationType: ['type', 'values'],
74
+ hasReaders: ['type', 'value'],
75
+ geofence: ['type', 'exists'],
76
+ // One word, two vocabularies: the location condition takes only
77
+ // `manifestId`; the snapshot fork adds `satisfied`. This table is per-WORD,
78
+ // so `satisfied` is admitted here for both — the `case 'manifest'` branch
79
+ // is what refuses it on a location query.
80
+ manifest: ['type', 'manifestId', 'satisfied'],
81
+ // Event vocabulary. `location` reuses the ASSET entry above (the location
82
+ // entity's own word is `path`) — the two happen to declare the same
83
+ // PROPERTIES, which is all this table checks, but they are NOT the same
84
+ // fact: an asset's `location` is where it is now, an event's is where it
85
+ // happened. `occurred` does NOT reuse TIMESTAMP_KEYS:
86
+ // `includeNeverSet` is meaningless when every event has a timestamp, and
87
+ // accepting a property that can never apply is how a query comes to say
88
+ // something the compiler ignores.
89
+ occurred: ['type', 'operator', 'value'],
90
+ eventType: ['type', 'values'],
91
+ reader: ['type', 'name'],
92
+ user: ['type', 'name'],
93
+ action: ['type', 'name'],
94
+ // Snapshot vocabulary. `occurred`, `location`, `assetType`, `manifest`,
95
+ // `user` and `reader` are all shared with the entries above — same word,
96
+ // same properties, one entry. Only these two are the snapshot's own.
97
+ //
98
+ // `asset` takes an id and nothing else. It does NOT get `withAsset`'s
99
+ // `hasAny`: every snapshot has a subject, so "any asset at all" is not a
100
+ // question, and accepting a property that can never discriminate is how a
101
+ // query comes to say something the compiler ignores.
102
+ snapshotType: ['type', 'values'],
103
+ asset: ['type', 'assetId'],
104
+ // Reader vocabulary. `location` is shared with the entries above.
105
+ deviceType: ['type', 'values'],
106
+ readerType: ['type', 'values'],
107
+ attachedAsset: ['type', 'assetId'],
108
+ online: ['type', 'value'],
109
+ hasReported: ['type', 'value'],
110
+ hasGeolocation: ['type', 'value'],
111
+ };
112
+ const HEX24 = /^[0-9a-fA-F]{24}$/;
113
+ /** The kit.manifest vocabulary (SemanticManifestState in the contract). */
114
+ const MANIFEST_STATES = [
115
+ 'satisfied',
116
+ 'needsRecheck',
117
+ 'notSatisfied',
118
+ 'neverChecked',
119
+ ];
120
+ function checkProperties(cond, path) {
121
+ // The table is typed against the contract's vocabulary (a new condition type
122
+ // is a compile error until it is listed); the lookup itself takes whatever
123
+ // arbitrary string arrived on the wire.
124
+ const allowed = ALLOWED_KEYS[String(cond.type)];
125
+ if (!allowed)
126
+ return; // unknown type gets its own, better message
127
+ const extra = Object.keys(cond).filter((k) => !allowed.includes(k));
128
+ if (extra.length) {
129
+ (0, types_1.failSemanticValidation)(path, 'UNKNOWN_PROPERTY', `'${cond.type}' does not support ${extra.map((k) => `'${k}'`).join(', ')}` +
130
+ ` (allowed: ${allowed.filter((k) => k !== 'type').join(', ') || 'none'})`, extra.includes('negate') || extra.includes('not')
131
+ ? 'The query language has no negation. Express the opposite directly, ' +
132
+ 'or use notExists for an unset field.'
133
+ : undefined);
134
+ }
135
+ }
136
+ /**
137
+ * A non-empty values array drawn from a closed platform set. The three
138
+ * enum-valued conditions (`eventType`, `snapshotType`, `deviceType`,
139
+ * `readerType`) all want exactly this, and an unknown value must be a loud
140
+ * failure with a suggestion rather than an arm that matches nothing.
141
+ */
142
+ function requireEnumValues(values, universe, label, path) {
143
+ if (!Array.isArray(values) || values.length === 0) {
144
+ (0, types_1.failSemanticValidation)(path, 'BAD_SHAPE', `${label} needs a non-empty values array`);
145
+ }
146
+ const unknown = values.filter((v) => !universe.includes(v));
147
+ if (unknown.length) {
148
+ (0, types_1.failSemanticValidation)(path, 'BAD_VALUE', `Unknown ${label} ${unknown.map((v) => `'${v}'`).join(', ')}`, (0, suggest_1.nearest)(universe, String(unknown[0])));
149
+ }
150
+ }
151
+ function requireString(v, path, field) {
152
+ if (typeof v !== 'string' || v === '') {
153
+ (0, types_1.failSemanticValidation)(path, 'BAD_SHAPE', `'${field}' must be a non-empty string`);
154
+ }
155
+ }
156
+ /**
157
+ * Mutually-exclusive properties, enforced here and not only at the wire
158
+ * boundary.
159
+ *
160
+ * Closing key NAMES is not enough. A back-end that merely picked one branch
161
+ * would silently IGNORE the other — a query that runs and returns rows
162
+ * unrelated to half of what it says. Same failure class as the invented
163
+ * `negate` property, one level down.
164
+ */
165
+ function requireExactlyOne(cond, keys, path, label) {
166
+ const set = keys.filter((k) => cond[k] !== undefined);
167
+ if (set.length !== 1) {
168
+ (0, types_1.failSemanticValidation)(path, 'BAD_SHAPE', set.length === 0
169
+ ? `${label} needs exactly one of ${keys.join(', ')}`
170
+ : `${label} takes exactly one of ${keys.join(', ')} — got ${set.join(' and ')}`);
171
+ }
172
+ }
173
+ /**
174
+ * Boolean-typed properties must be REAL booleans, not truthy stand-ins. This
175
+ * is an axis where the two back-ends part company: the Mongo compiler branches
176
+ * on truthiness while the evaluator compares with `===`, so `isKit: null`
177
+ * matches every non-kit in Mongo and nothing in memory. Worst is
178
+ * `satisfied: null`, which compiles to `{groupSatisfiesManifest: null}` — Mongo
179
+ * reads that as "missing OR null", i.e. the whole non-kit fleet: the exact
180
+ * wrong-rows bug the exact-match ruling closed, back through a side door. And
181
+ * even where the engines agree (`includeSubtypes: "false"` reads as true on
182
+ * both sides), the query silently means the opposite of what was written.
183
+ */
184
+ function requireBooleansIfSet(cond, keys, path) {
185
+ for (const k of keys) {
186
+ const v = cond[k];
187
+ if (v !== undefined && typeof v !== 'boolean') {
188
+ (0, types_1.failSemanticValidation)(path, 'BAD_VALUE', `'${k}' must be true or false — got ${JSON.stringify(v)}`);
189
+ }
190
+ }
191
+ }
192
+ /**
193
+ * Find a field by name, case-insensitively — the one lookup both back-ends
194
+ * use, so neither can resolve a name to a different field than the other.
195
+ */
196
+ function findSemanticField(catalogue, fieldName) {
197
+ return catalogue.fields.find((f) => f.name.toLowerCase() === String(fieldName).toLowerCase());
198
+ }
199
+ exports.findSemanticField = findSemanticField;
200
+ // ---------------------------------------------------------------------------
201
+ // Leaves
202
+ // ---------------------------------------------------------------------------
203
+ /**
204
+ * Is this word in the queried entity's vocabulary?
205
+ *
206
+ * A condition can be perfectly well-formed and still be nonsense for the thing
207
+ * being queried — `readiness` on a location, `snapshotType` on an asset. Left
208
+ * unchecked that is not a syntax error, it is a query that runs and answers
209
+ * the wrong question, so it is refused here with the entity named.
210
+ *
211
+ * When the word belongs to a DIFFERENT entity, say which. That is the
212
+ * generator's likeliest mistake — it has seen the whole language somewhere —
213
+ * and "'readiness' is an asset condition" sends it to the right place, where
214
+ * "not a location condition" only says no.
215
+ */
216
+ function checkEntityVocabulary(type, ctx, path) {
217
+ var _a;
218
+ const allowed = (_a = contract_1.semanticConditionTypesByEntity[ctx.entityType]) !== null && _a !== void 0 ? _a : [];
219
+ if (allowed.includes(type))
220
+ return;
221
+ // An entity with no vocabulary is one the language does not speak yet, and
222
+ // that is the real reason nothing will work — a per-condition complaint here
223
+ // would send the caller looking for a word that does not exist.
224
+ if (!allowed.length) {
225
+ (0, types_1.failSemanticValidation)(path, 'UNSUPPORTED_ENTITY', `'${ctx.entityType}' queries are not supported yet`);
226
+ }
227
+ const owner = Object.keys(contract_1.semanticConditionTypesByEntity).find((e) => e !== ctx.entityType && contract_1.semanticConditionTypesByEntity[e].includes(type));
228
+ if (owner) {
229
+ (0, types_1.failSemanticValidation)(path, 'WRONG_ENTITY', `'${type}' is a ${owner} condition — this is a ${ctx.entityType} query`);
230
+ }
231
+ // Reuses the language's existing "that is not a condition type" code — this
232
+ // gate now reaches that case before the leaf switch does, and one meaning
233
+ // should not have two codes.
234
+ (0, types_1.failSemanticValidation)(path, 'UNKNOWN_TYPE', `'${type}' is not a ${ctx.entityType} condition`, (0, suggest_1.nearest)([...allowed], type));
235
+ }
236
+ function checkLeaf(cond, ctx, path) {
237
+ // Also checked here, not only in the tree walk: leaves nested inside an
238
+ // `or` reach this function directly.
239
+ checkProperties(cond, path);
240
+ checkEntityVocabulary(cond.type, ctx, path);
241
+ switch (cond.type) {
242
+ case 'assetType':
243
+ requireString(cond.path, path, 'path');
244
+ requireBooleansIfSet(cond, ['includeSubtypes'], path);
245
+ return;
246
+ case 'location':
247
+ case 'homeLocation':
248
+ requireString(cond.path, path, 'path');
249
+ requireBooleansIfSet(cond, ['includeSublocations'], path);
250
+ return;
251
+ case 'withAsset': {
252
+ requireExactlyOne(cond, ['assetId', 'hasAny'], path, 'withAsset');
253
+ requireBooleansIfSet(cond, ['hasAny'], path);
254
+ if (cond.hasAny !== undefined)
255
+ return;
256
+ if (!cond.assetId || !HEX24.test(cond.assetId)) {
257
+ (0, types_1.failSemanticValidation)(path, 'BAD_ASSET_ID', 'withAsset needs a valid assetId (24-hex) or hasAny');
258
+ }
259
+ return;
260
+ }
261
+ case 'geolocation':
262
+ requireBooleansIfSet(cond, ['exists'], path);
263
+ return;
264
+ case 'tags':
265
+ // Both back-ends read anything that is not 'all' as 'any', so an
266
+ // unchecked operator is a typo silently widening all-of to any-of —
267
+ // with the two engines agreeing and both wrong about the intent.
268
+ if (cond.operator !== undefined &&
269
+ !contract_1.semanticSetOperators.includes(cond.operator)) {
270
+ (0, types_1.failSemanticValidation)(path, 'BAD_OPERATOR', `tags operator must be one of ${contract_1.semanticSetOperators.join(', ')} — got '${cond.operator}'`, (0, suggest_1.nearest)(contract_1.semanticSetOperators, String(cond.operator)));
271
+ }
272
+ if (!Array.isArray(cond.values) || cond.values.length === 0) {
273
+ (0, types_1.failSemanticValidation)(path, 'BAD_TAGS', 'tags condition needs a non-empty values array');
274
+ }
275
+ return;
276
+ case 'telemetry': {
277
+ // Re-checked here, not only at the wire boundary: this value becomes part
278
+ // of a document PATH downstream, so an unchecked field name would build a
279
+ // query over an arbitrary key.
280
+ if (!contract_1.semanticTelemetryFields.includes(cond.field)) {
281
+ (0, types_1.failSemanticValidation)(path, 'BAD_VALUE', `Unknown telemetry field '${cond.field}' (allowed: ${contract_1.semanticTelemetryFields.join(', ')})`, (0, suggest_1.nearest)(contract_1.semanticTelemetryFields, String(cond.field)));
282
+ }
283
+ if (!['eq', 'ne', 'gt', 'gte', 'lt', 'lte'].includes(cond.operator)) {
284
+ (0, types_1.failSemanticValidation)(path, 'BAD_OPERATOR', `Unknown telemetry operator '${cond.operator}'`);
285
+ }
286
+ if (typeof cond.value !== 'number') {
287
+ (0, types_1.failSemanticValidation)(path, 'BAD_VALUE', `telemetry '${cond.field}' needs a numeric value`);
288
+ }
289
+ return;
290
+ }
291
+ case 'readiness':
292
+ checkReadiness(cond, path);
293
+ return;
294
+ case 'kit': {
295
+ requireExactlyOne(cond, ['isKit', 'isMember', 'satisfied', 'manifest', 'parentAssetId'], path, 'kit');
296
+ requireBooleansIfSet(cond, ['isKit', 'isMember', 'satisfied'], path);
297
+ if (cond.manifest !== undefined &&
298
+ !MANIFEST_STATES.includes(cond.manifest)) {
299
+ (0, types_1.failSemanticValidation)(path, 'BAD_VALUE', `kit.manifest must be one of ${MANIFEST_STATES.join(', ')}`);
300
+ }
301
+ if (cond.parentAssetId !== undefined && !HEX24.test(cond.parentAssetId)) {
302
+ (0, types_1.failSemanticValidation)(path, 'BAD_ASSET_ID', 'kit.parentAssetId must be a 24-hex asset id');
303
+ }
304
+ return;
305
+ }
306
+ case 'locationStatus': {
307
+ if (!Array.isArray(cond.values) || cond.values.length === 0) {
308
+ (0, types_1.failSemanticValidation)(path, 'BAD_SHAPE', 'locationStatus needs a non-empty values array');
309
+ }
310
+ const unknown = cond.values.filter((v) => !contract_1.LOCATION_STATUS.includes(v));
311
+ if (unknown.length) {
312
+ (0, types_1.failSemanticValidation)(path, 'BAD_VALUE', `Unknown location status ${unknown.map((v) => `'${v}'`).join(', ')}`, (0, suggest_1.nearest)(contract_1.LOCATION_STATUS, String(unknown[0])));
313
+ }
314
+ return;
315
+ }
316
+ case 'createdDate':
317
+ case 'lastUpdated':
318
+ case 'lastChanged':
319
+ case 'lastSeen':
320
+ case 'firstSeen':
321
+ checkTimestamp(cond, ctx, path);
322
+ return;
323
+ case 'customField':
324
+ checkCustomField(cond, ctx, path);
325
+ return;
326
+ // --- Location vocabulary ---------------------------------------------
327
+ case 'path':
328
+ requireString(cond.path, path, 'path');
329
+ requireBooleansIfSet(cond, ['includeSublocations'], path);
330
+ return;
331
+ case 'locationType': {
332
+ if (!Array.isArray(cond.values) || cond.values.length === 0) {
333
+ (0, types_1.failSemanticValidation)(path, 'BAD_SHAPE', 'locationType needs a non-empty values array');
334
+ }
335
+ const unknown = cond.values.filter((v) => !contract_1.LOCATION_TYPES.includes(v));
336
+ if (unknown.length) {
337
+ (0, types_1.failSemanticValidation)(path, 'BAD_VALUE', `Unknown location type ${unknown.map((v) => `'${v}'`).join(', ')}`, (0, suggest_1.nearest)(contract_1.LOCATION_TYPES, String(unknown[0])));
338
+ }
339
+ return;
340
+ }
341
+ case 'hasReaders':
342
+ requireBooleansIfSet(cond, ['value'], path);
343
+ if (cond.value === undefined) {
344
+ (0, types_1.failSemanticValidation)(path, 'BAD_SHAPE', "hasReaders needs 'value' (true or false)");
345
+ }
346
+ return;
347
+ case 'geofence':
348
+ requireBooleansIfSet(cond, ['exists'], path);
349
+ if (cond.exists === undefined) {
350
+ (0, types_1.failSemanticValidation)(path, 'BAD_SHAPE', "geofence needs 'exists' (true or false)");
351
+ }
352
+ return;
353
+ case 'manifest': {
354
+ // Read through a widened local: `satisfied` exists only on the snapshot
355
+ // fork of this word, so the union type does not carry it.
356
+ const satisfied = cond.satisfied;
357
+ if (ctx.entityType === 'snapshot') {
358
+ // Snapshot fork: the id becomes optional and `satisfied` joins it —
359
+ // either alone ("all failed checks") or together, at least one
360
+ // present. Boolean-strict: the flag it reads is exact-match, and a
361
+ // truthy string would silently match nothing.
362
+ if (cond.manifestId === undefined && satisfied === undefined) {
363
+ (0, types_1.failSemanticValidation)(path, 'BAD_SHAPE', "manifest needs 'manifestId', 'satisfied', or both");
364
+ }
365
+ if (satisfied !== undefined && typeof satisfied !== 'boolean') {
366
+ (0, types_1.failSemanticValidation)(path, 'BAD_VALUE', 'manifest.satisfied must be true or false');
367
+ }
368
+ if (cond.manifestId !== undefined && !HEX24.test(String(cond.manifestId))) {
369
+ (0, types_1.failSemanticValidation)(path, 'BAD_MANIFEST_ID', 'manifest.manifestId must be a 24-hex manifest id');
370
+ }
371
+ return;
372
+ }
373
+ // Location: exactly the historical shape. The shared key table admits
374
+ // `satisfied` for both entities, so this is where a location query is
375
+ // refused it.
376
+ if (satisfied !== undefined) {
377
+ (0, types_1.failSemanticValidation)(path, 'BAD_SHAPE', "'satisfied' is a snapshot manifest property — a location manifest condition takes only 'manifestId'");
378
+ }
379
+ // Ids point. A name would have to be resolved, and a resolution that
380
+ // missed would run and match nothing.
381
+ if (!HEX24.test(String(cond.manifestId))) {
382
+ (0, types_1.failSemanticValidation)(path, 'BAD_MANIFEST_ID', 'manifest.manifestId must be a 24-hex manifest id');
383
+ }
384
+ return;
385
+ }
386
+ // --- Event vocabulary -------------------------------------------------
387
+ case 'occurred':
388
+ // Deliberately NOT `checkTimestamp`: that helper also permits
389
+ // `includeNeverSet`, which is meaningless when every event has a
390
+ // timestamp by construction.
391
+ if (!contract_1.semanticTimestampOperators.includes(cond.operator)) {
392
+ (0, types_1.failSemanticValidation)(path, 'BAD_OPERATOR', `occurred operator must be one of ${contract_1.semanticTimestampOperators.join(', ')}` +
393
+ ` — got '${cond.operator}'`, (0, suggest_1.nearest)(contract_1.semanticTimestampOperators, String(cond.operator)));
394
+ }
395
+ // Read through a widened local: `value` is non-optional in the
396
+ // contract, so comparing it to `undefined` narrows `cond` to `never` and
397
+ // every later reference stops compiling. The wire can still omit it.
398
+ {
399
+ const raw = cond.value;
400
+ if (raw === undefined) {
401
+ (0, types_1.failSemanticValidation)(path, 'BAD_SHAPE', `occurred ${cond.operator} needs a value`);
402
+ }
403
+ try {
404
+ (0, dates_1.semanticDateBoundaryMs)(raw, cond.operator, ctx.now, ctx.tz);
405
+ }
406
+ catch (e) {
407
+ return (0, types_1.failSemanticValidation)(path, 'BAD_DATE', e.message);
408
+ }
409
+ }
410
+ return;
411
+ case 'eventType': {
412
+ if (!Array.isArray(cond.values) || cond.values.length === 0) {
413
+ (0, types_1.failSemanticValidation)(path, 'BAD_SHAPE', 'eventType needs a non-empty values array');
414
+ }
415
+ const unknown = cond.values.filter((v) => !contract_1.EVENT_TYPES.includes(v));
416
+ if (unknown.length) {
417
+ (0, types_1.failSemanticValidation)(path, 'BAD_VALUE', `Unknown event type ${unknown.map((v) => `'${v}'`).join(', ')}`, (0, suggest_1.nearest)(contract_1.EVENT_TYPES, String(unknown[0])));
418
+ }
419
+ return;
420
+ }
421
+ // The three name-resolved words. Resolution happens HERE rather than in
422
+ // the compiler so both engines see the same ids, and so an unknown or
423
+ // ambiguous name is a validation issue with a suggestion — the shape the
424
+ // AI repair loop and the wire both already understand.
425
+ case 'reader':
426
+ resolveOrFail(ctx.catalogue.readers, cond.name, path, 'reader');
427
+ return;
428
+ case 'user':
429
+ resolveOrFail(ctx.catalogue.users, cond.name, path, 'user');
430
+ return;
431
+ case 'action':
432
+ resolveOrFail(ctx.catalogue.actions, cond.name, path, 'action');
433
+ return;
434
+ // --- Snapshot vocabulary ----------------------------------------------
435
+ case 'snapshotType': {
436
+ if (!Array.isArray(cond.values) || cond.values.length === 0) {
437
+ (0, types_1.failSemanticValidation)(path, 'BAD_SHAPE', 'snapshotType needs a non-empty values array');
438
+ }
439
+ const unknown = cond.values.filter((v) => !contract_1.SNAPSHOT_TYPES.includes(v));
440
+ if (unknown.length) {
441
+ (0, types_1.failSemanticValidation)(path, 'BAD_VALUE', `Unknown snapshot type ${unknown.map((v) => `'${v}'`).join(', ')}` +
442
+ ` (${contract_1.SNAPSHOT_TYPES.join(', ')})`, (0, suggest_1.nearest)(contract_1.SNAPSHOT_TYPES, String(unknown[0])));
443
+ }
444
+ return;
445
+ }
446
+ case 'asset':
447
+ // An id, never a name — the refusal the event vocabulary is built on.
448
+ // A name here would be a substring regex, and "BC-A1" would quietly
449
+ // bring back "BC-A10".
450
+ if (!HEX24.test(String(cond.assetId))) {
451
+ (0, types_1.failSemanticValidation)(path, 'BAD_ASSET_ID', 'asset.assetId must be a 24-hex asset id');
452
+ }
453
+ return;
454
+ // --- Reader vocabulary ------------------------------------------------
455
+ case 'deviceType':
456
+ requireEnumValues(cond.values, contract_1.DEVICE_TYPES, 'deviceType', path);
457
+ return;
458
+ case 'readerType':
459
+ requireEnumValues(cond.values, contract_1.READER_TYPES, 'readerType', path);
460
+ return;
461
+ case 'attachedAsset':
462
+ if (!HEX24.test(String(cond.assetId))) {
463
+ (0, types_1.failSemanticValidation)(path, 'BAD_ASSET_ID', 'attachedAsset.assetId must be a 24-hex asset id');
464
+ }
465
+ return;
466
+ case 'online':
467
+ case 'hasReported':
468
+ case 'hasGeolocation':
469
+ // Required, not optional. A boolean condition with no value has no
470
+ // meaning to fall back to — defaulting it either way would answer a
471
+ // question the caller did not ask.
472
+ if (typeof cond.value !== 'boolean') {
473
+ (0, types_1.failSemanticValidation)(path, 'BAD_SHAPE', `${cond.type} needs 'value' (true or false)`);
474
+ }
475
+ return;
476
+ default:
477
+ (0, types_1.failSemanticValidation)(path, 'UNKNOWN_TYPE', `Unknown condition type '${cond.type}'`);
478
+ }
479
+ }
480
+ /**
481
+ * Resolve a name or fail with the resolver's own issue. The compiler resolves
482
+ * again from the same catalogue to get the ids it emits; this pass exists so
483
+ * the FAILURE happens at validation time, where it becomes a repairable issue
484
+ * rather than a query that ran against nothing.
485
+ */
486
+ function resolveOrFail(catalogue, name, path, noun) {
487
+ const { issue } = (0, resolve_name_1.resolveName)(catalogue, name, { path, noun });
488
+ if (issue)
489
+ (0, types_1.failSemanticValidation)(path, issue.code, issue.message, issue.suggestion);
490
+ }
491
+ function checkReadiness(cond, path) {
492
+ var _a;
493
+ if (cond.level !== undefined && cond.levels !== undefined) {
494
+ (0, types_1.failSemanticValidation)(path, 'BAD_SHAPE', "readiness takes 'level' or 'levels', not both");
495
+ }
496
+ const levels = (_a = cond.levels) !== null && _a !== void 0 ? _a : (cond.level === undefined ? undefined : [cond.level]);
497
+ if (!levels)
498
+ (0, types_1.failSemanticValidation)(path, 'BAD_SHAPE', "readiness needs 'level' or 'levels'");
499
+ if (!Array.isArray(levels))
500
+ (0, types_1.failSemanticValidation)(path, 'BAD_SHAPE', "'levels' must be an array");
501
+ if (levels.length === 0)
502
+ (0, types_1.failSemanticValidation)(path, 'BAD_SHAPE', "'levels' must be non-empty");
503
+ for (const l of levels) {
504
+ if (l !== 0 && l !== 1 && l !== 2) {
505
+ (0, types_1.failSemanticValidation)(path, 'BAD_VALUE', `readiness level must be 0 (ready), 1 (conditional) or 2 (not ready) — got ${l}`);
506
+ }
507
+ }
508
+ }
509
+ function checkTimestamp(cond, ctx, path) {
510
+ const op = cond.operator;
511
+ if (!['gt', 'gte', 'lt', 'lte'].includes(op)) {
512
+ (0, types_1.failSemanticValidation)(path, 'BAD_OPERATOR', `'${cond.type}' supports gt, gte, lt, lte — got '${op}'`);
513
+ }
514
+ if (cond.value === undefined) {
515
+ (0, types_1.failSemanticValidation)(path, 'BAD_SHAPE', `'${cond.type}' ${op} needs a value`);
516
+ }
517
+ if (cond.includeNeverSet !== undefined && op !== 'lt' && op !== 'lte') {
518
+ (0, types_1.failSemanticValidation)(path, 'BAD_SHAPE', `'includeNeverSet' applies to lt/lte only — a missing timestamp correctly fails ${op}`);
519
+ }
520
+ requireBooleansIfSet(cond, ['includeNeverSet'], path);
521
+ let ms;
522
+ try {
523
+ ms = (0, dates_1.semanticDateBoundaryMs)(cond.value, op, ctx.now, ctx.tz);
524
+ }
525
+ catch (e) {
526
+ return (0, types_1.failSemanticValidation)(path, 'BAD_DATE', e.message);
527
+ }
528
+ if (cond.type === 'createdDate') {
529
+ // Creation time is carried by the ObjectId, whose 4-byte timestamp is
530
+ // unsigned: dates before 1970 or past 2106 have no id to compare against,
531
+ // and must fail as a validation issue rather than as an exception escaping
532
+ // from a back-end. `+1` because the inclusive forms step a second (see the
533
+ // compiler's boundary-second note).
534
+ const second = Math.floor(ms / 1000) + 1;
535
+ if (second < 0 || second > 0xffffffff) {
536
+ (0, types_1.failSemanticValidation)(path, 'BAD_DATE', `'${cond.type}': date is outside the representable range (1970–2106)`);
537
+ }
538
+ }
539
+ }
540
+ function checkCustomField(cond, ctx, path) {
541
+ var _a;
542
+ const fdef = findSemanticField(ctx.catalogue, cond.fieldName);
543
+ if (!fdef) {
544
+ (0, types_1.failSemanticValidation)(path, 'UNKNOWN_FIELD', `No custom field named '${cond.fieldName}'`, (0, suggest_1.nearest)(ctx.catalogue.fields.map((f) => f.name), String(cond.fieldName)));
545
+ }
546
+ if (cond.operator === 'exists' || cond.operator === 'notExists') {
547
+ if (cond.value !== undefined) {
548
+ (0, types_1.failSemanticValidation)(path, 'BAD_SHAPE', `'${cond.fieldName}' ${cond.operator} takes no value`);
549
+ }
550
+ }
551
+ else if (cond.value === undefined) {
552
+ (0, types_1.failSemanticValidation)(path, 'BAD_SHAPE', `'${cond.fieldName}' ${cond.operator} needs a value`);
553
+ }
554
+ if (cond.includeUnset !== undefined && cond.operator !== 'ne') {
555
+ (0, types_1.failSemanticValidation)(path, 'BAD_SHAPE', "'includeUnset' applies to the ne operator only — it is what decides " +
556
+ 'whether an unset field counts as "not equal"');
557
+ }
558
+ requireBooleansIfSet(cond, ['includeUnset'], path);
559
+ // Honest refusal until the DATETIME ruling lands: matching against the wrong
560
+ // slot is a valid query that returns nothing.
561
+ if (fdef.dataType === 'DATETIME') {
562
+ (0, types_1.failSemanticValidation)(path, 'UNSUPPORTED_DATATYPE', `'${fdef.name}' is a date-and-time field, which semantic queries do not support yet`);
563
+ }
564
+ // A dropdown value must match an option exactly, or the query silently
565
+ // returns nothing. Reject with the nearest option instead.
566
+ if (((_a = fdef.options) === null || _a === void 0 ? void 0 : _a.length) && cond.operator === 'eq' && typeof cond.value === 'string') {
567
+ if (!fdef.options.includes(cond.value)) {
568
+ (0, types_1.failSemanticValidation)(path, 'BAD_OPTION', `'${cond.value}' is not an option for '${fdef.name}'`, (0, suggest_1.nearest)(fdef.options, cond.value));
569
+ }
570
+ }
571
+ if (cond.operator === 'contains') {
572
+ if (fdef.dataType !== 'STRING') {
573
+ (0, types_1.failSemanticValidation)(path, 'BAD_OPERATOR', `'contains' applies to STRING fields only ('${fdef.name}' is ${fdef.dataType})`);
574
+ }
575
+ return;
576
+ }
577
+ if (cond.operator === 'exists' || cond.operator === 'notExists')
578
+ return;
579
+ if (!['eq', 'ne', 'gt', 'gte', 'lt', 'lte'].includes(cond.operator)) {
580
+ (0, types_1.failSemanticValidation)(path, 'BAD_OPERATOR', `Unknown operator '${cond.operator}' on '${fdef.name}'`);
581
+ }
582
+ // The value must be expressible in the slot it will be compared against.
583
+ checkValue(cond, fdef, ctx, path);
584
+ }
585
+ /**
586
+ * Turn a written value into the form its storage slot actually holds.
587
+ *
588
+ * Shared deliberately: the Mongo compiler and the in-memory evaluator must
589
+ * compare against the SAME value, and this is where a numeric literal for a
590
+ * text field, or a relative date for a DATE field, becomes one concrete thing.
591
+ * Two copies of this would classify the same document differently.
592
+ *
593
+ * Throws a plain Error — the caller decides whether that is a validation issue
594
+ * (it is, at the gate) or an impossible state (it is, after the gate).
595
+ */
596
+ function coerceSemanticValue(value, dataType, now, tz) {
597
+ switch (dataType) {
598
+ case 'DATE':
599
+ // Confirmed encoding: YYYYMMDD integer, e.g. 20260815.
600
+ return (0, dates_1.semanticCivilToYyyymmdd)((0, dates_1.resolveSemanticDate)(value, now, tz));
601
+ case 'INTEGER':
602
+ case 'DECIMAL': {
603
+ const n = typeof value === 'number' ? value : Number(value);
604
+ if (!Number.isFinite(n)) {
605
+ throw new Error(`is ${dataType} but got '${String(value)}'`);
606
+ }
607
+ return n;
608
+ }
609
+ case 'BOOLEAN': {
610
+ if (typeof value === 'boolean')
611
+ return value;
612
+ if (value === 'true' || value === 'false')
613
+ return value === 'true';
614
+ throw new Error(`is BOOLEAN but got '${String(value)}'`);
615
+ }
616
+ default:
617
+ return typeof value === 'string' ? value : String(value !== null && value !== void 0 ? value : '');
618
+ }
619
+ }
620
+ exports.coerceSemanticValue = coerceSemanticValue;
621
+ function checkValue(cond, fdef, ctx, path) {
622
+ try {
623
+ coerceSemanticValue(cond.value, fdef.dataType, ctx.now, ctx.tz);
624
+ }
625
+ catch (e) {
626
+ const message = e.message;
627
+ fdef.dataType === 'DATE'
628
+ ? (0, types_1.failSemanticValidation)(path, 'BAD_DATE', `'${fdef.name}': ${message}`)
629
+ : (0, types_1.failSemanticValidation)(path, 'BAD_VALUE', `'${fdef.name}' ${message}`);
630
+ }
631
+ }
632
+ // ---------------------------------------------------------------------------
633
+ // Tree
634
+ // ---------------------------------------------------------------------------
635
+ function checkConditions(conds, ctx, depth, budget, path) {
636
+ if (!Array.isArray(conds))
637
+ (0, types_1.failSemanticValidation)(path, 'BAD_SHAPE', 'conditions must be an array');
638
+ if (depth > LIMITS.maxDepth) {
639
+ (0, types_1.failSemanticValidation)(path, 'TOO_DEEP', `Nesting exceeds maximum depth ${LIMITS.maxDepth}`);
640
+ }
641
+ conds.forEach((cond, i) => {
642
+ if (++budget.n > LIMITS.maxConditions) {
643
+ (0, types_1.failSemanticValidation)(path, 'TOO_MANY', `Query exceeds maximum of ${LIMITS.maxConditions} conditions`);
644
+ }
645
+ const p = `${path}[${i}]`;
646
+ if (!cond || typeof cond !== 'object')
647
+ (0, types_1.failSemanticValidation)(p, 'BAD_SHAPE', 'condition must be an object');
648
+ if (cond.type === 'and' || cond.type === 'or') {
649
+ // Leaves are checked inside checkLeaf (the only path an or-branch leaf
650
+ // takes), so groups are checked here and nowhere twice.
651
+ checkProperties(cond, p);
652
+ if (!Array.isArray(cond.conditions) || cond.conditions.length === 0) {
653
+ // An empty group means "always true" — quietly widening the query to
654
+ // everything.
655
+ (0, types_1.failSemanticValidation)(p, 'BAD_SHAPE', `'${cond.type}' needs a non-empty conditions array`);
656
+ }
657
+ }
658
+ if (cond.type === 'and') {
659
+ checkConditions(cond.conditions, ctx, depth + 1, budget, p);
660
+ }
661
+ else if (cond.type === 'or') {
662
+ cond.conditions.forEach((c, j) => {
663
+ const bp = `${p}[${j}]`;
664
+ // Or-branches bypass the top of this walk, so its object guard must
665
+ // run here too — a null branch is a validation issue, not a TypeError
666
+ // out of the property check.
667
+ if (!c || typeof c !== 'object')
668
+ (0, types_1.failSemanticValidation)(bp, 'BAD_SHAPE', 'condition must be an object');
669
+ if (c.type === 'and' || c.type === 'or') {
670
+ // The recursive walk counts the group itself against the budget —
671
+ // counting it here as well double-charged every nested group.
672
+ checkConditions([c], ctx, depth + 1, budget, bp);
673
+ }
674
+ else {
675
+ // Leaves never pass through the walk above, so they are counted
676
+ // here. Uncounted branches were a stored query that a 60-byte GET
677
+ // could replay as tens of thousands of regex arms over the whole
678
+ // collection.
679
+ if (++budget.n > LIMITS.maxConditions) {
680
+ (0, types_1.failSemanticValidation)(p, 'TOO_MANY', `Query exceeds maximum of ${LIMITS.maxConditions} conditions`);
681
+ }
682
+ checkLeaf(c, ctx, bp);
683
+ }
684
+ });
685
+ }
686
+ else {
687
+ checkLeaf(cond, ctx, p);
688
+ }
689
+ });
690
+ }
691
+ // ---------------------------------------------------------------------------
692
+ // Entry points
693
+ // ---------------------------------------------------------------------------
694
+ /**
695
+ * Assert a query is valid, throwing `SemanticQueryValidationError` on the
696
+ * first problem. This is the gate: a back-end may assume anything that passed
697
+ * through it is safe to execute.
698
+ */
699
+ function assertSemanticQuery(semanticQuery, catalogue, opts = {}) {
700
+ var _a, _b, _c;
701
+ const ctx = {
702
+ catalogue,
703
+ now: (_a = opts.now) !== null && _a !== void 0 ? _a : new Date(),
704
+ tz: (_b = opts.timeZone) !== null && _b !== void 0 ? _b : 'UTC',
705
+ entityType: (_c = opts.entityType) !== null && _c !== void 0 ? _c : 'asset',
706
+ };
707
+ if (!semanticQuery || !Array.isArray(semanticQuery.conditions)) {
708
+ (0, types_1.failSemanticValidation)('conditions', 'BAD_SHAPE', 'semanticQuery.conditions must be an array');
709
+ }
710
+ // The closed vocabulary applies to the ENVELOPE too. Without this a top-level
711
+ // `{conditions:[…], negate:true}` passes cleanly — the original
712
+ // invented-property failure, one level up from the conditions it guards.
713
+ const envelopeExtra = Object.keys(semanticQuery).filter((k) => k !== 'version' && k !== 'conditions');
714
+ if (envelopeExtra.length) {
715
+ (0, types_1.failSemanticValidation)('', 'UNKNOWN_PROPERTY', `A semantic query has only 'version' and 'conditions' — got ${envelopeExtra
716
+ .map((k) => `'${k}'`)
717
+ .join(', ')}`);
718
+ }
719
+ if (semanticQuery.version !== undefined &&
720
+ semanticQuery.version !== contract_1.SEMANTIC_QUERY_VERSION) {
721
+ (0, types_1.failSemanticValidation)('version', 'BAD_VERSION', `Unsupported semantic query version ${semanticQuery.version} ` +
722
+ `(this build understands version ${contract_1.SEMANTIC_QUERY_VERSION})`);
723
+ }
724
+ checkConditions(semanticQuery.conditions, ctx, 1, { n: 0 }, 'conditions');
725
+ checkEventAssetTypeScope(semanticQuery.conditions, ctx);
726
+ }
727
+ exports.assertSemanticQuery = assertSemanticQuery;
728
+ /**
729
+ * Every condition ANDed with the rest at the top level — descending through
730
+ * `and` groups, never through `or`, because an or-branch is an alternative
731
+ * rather than a further constraint.
732
+ */
733
+ function conjuncts(conditions) {
734
+ return conditions.flatMap((c) => c.type === 'and' && Array.isArray(c.conditions)
735
+ ? conjuncts(c.conditions)
736
+ : [c]);
737
+ }
738
+ /**
739
+ * `assetType` beside an `eventType` that names no asset-CRUD event.
740
+ *
741
+ * The condition already scopes itself to `ASSET_CRUD_EVENT_TYPES` at the leaf,
742
+ * so this conjunction is not WRONG — it is provably empty, which is worse:
743
+ * "movements of forklifts" would render as an ordinary filter over an ordinary
744
+ * empty feed, the exact silent-nothing that kept `assetType` out of the event
745
+ * vocabulary in the first place. Refusing it with a reason is what lets the
746
+ * word exist at all.
747
+ *
748
+ * Only conjuncts are considered — `(forklift creations) OR (any movement)` is
749
+ * a legitimate question, and leaf scoping already answers it correctly.
750
+ */
751
+ function checkEventAssetTypeScope(conditions, ctx) {
752
+ var _a;
753
+ if (ctx.entityType !== 'event')
754
+ return;
755
+ const flat = conjuncts(conditions);
756
+ if (!flat.some((c) => c.type === 'assetType'))
757
+ return;
758
+ for (const cond of flat) {
759
+ if (cond.type !== 'eventType')
760
+ continue;
761
+ const values = (_a = cond.values) !== null && _a !== void 0 ? _a : [];
762
+ if (!values.length)
763
+ continue;
764
+ if (values.some((v) => contract_1.ASSET_CRUD_EVENT_TYPES.includes(v))) {
765
+ continue;
766
+ }
767
+ (0, types_1.failSemanticValidation)('conditions', 'CONFLICTING_CONDITIONS', `An asset type can only narrow ${contract_1.ASSET_CRUD_EVENT_TYPES.join(', ')} — ` +
768
+ `the other event kinds do not record the asset's type, so pairing it ` +
769
+ `with ${values.join(', ')} matches nothing`, `Drop the asset type, or ask about ${contract_1.ASSET_CRUD_EVENT_TYPES.join(', ')}`);
770
+ }
771
+ }
772
+ /** Validate without throwing. Empty array = valid. */
773
+ function validateSemanticQuery(semanticQuery, catalogue, opts = {}) {
774
+ try {
775
+ assertSemanticQuery(semanticQuery, catalogue, opts);
776
+ return [];
777
+ }
778
+ catch (e) {
779
+ if (e instanceof types_1.SemanticQueryValidationError)
780
+ return e.issues;
781
+ throw e;
782
+ }
783
+ }
784
+ exports.validateSemanticQuery = validateSemanticQuery;
785
+ /** True when any condition touches a custom field — both back-ends need to know. */
786
+ function queryUsesCustomFields(semanticQuery) {
787
+ var _a;
788
+ const walk = (conds) => conds.some((c) => {
789
+ var _a;
790
+ return (c === null || c === void 0 ? void 0 : c.type) === 'customField' ||
791
+ (((c === null || c === void 0 ? void 0 : c.type) === 'and' || (c === null || c === void 0 ? void 0 : c.type) === 'or') && walk((_a = c.conditions) !== null && _a !== void 0 ? _a : []));
792
+ });
793
+ return walk((_a = semanticQuery === null || semanticQuery === void 0 ? void 0 : semanticQuery.conditions) !== null && _a !== void 0 ? _a : []);
794
+ }
795
+ exports.queryUsesCustomFields = queryUsesCustomFields;
796
796
  //# sourceMappingURL=validate.js.map