@objectstack/service-analytics 17.0.0-rc.3 → 17.0.0-rc.4

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/index.d.cts CHANGED
@@ -96,7 +96,46 @@ interface RelationshipTarget {
96
96
  * namespaced objects).
97
97
  */
98
98
  type RelationshipResolver = (baseObject: string, relationshipName: string) => string | RelationshipTarget | undefined;
99
- declare function compileDataset(dataset: Dataset, resolver?: RelationshipResolver): CompiledDataset;
99
+ /**
100
+ * Optional probes the compiler consults to reject metadata that is decidable
101
+ * BEFORE any query runs. Every probe is optional and every one of them is
102
+ * tiered "cannot answer, do not block" (the same stand-down as
103
+ * `isRegisteredObject` / `getObjectFieldNames` on `AnalyticsServiceConfig`):
104
+ * a host without a data engine compiles exactly as it did before.
105
+ */
106
+ interface DatasetCompileOptions {
107
+ /**
108
+ * [#5115] The datasource `objectName` DECLARES (`object.datasource`), or
109
+ * `undefined` when nothing authoritative can answer (no data engine, unknown
110
+ * object).
111
+ *
112
+ * With it the compiler can settle at COMPILE time what #5033 could only
113
+ * report at QUERY time: a dataset whose join crosses datasources declares a
114
+ * statement no driver can execute, because the analytics engine lowers the
115
+ * whole dataset into ONE SQL statement on the base object's datasource.
116
+ *
117
+ * IMPORTANT — `'default'` is not an answer. In `ObjectQL.getDriver`'s
118
+ * resolution order an explicit `object.datasource` other than `'default'`
119
+ * wins outright (step 1); `'default'` is the schema's DEFAULT value and means
120
+ * only "no explicit binding", after which routing is decided by
121
+ * `datasourceMapping` rules, the ADR-0057 §3.6 lifecycle split, and the
122
+ * owning package's `defaultDatasource` — none of which are visible from here.
123
+ * The compiler therefore treats `'default'`/`undefined` as UNANSWERED. See
124
+ * {@link compileDataset}.
125
+ */
126
+ getObjectDatasource?: (objectName: string) => string | undefined;
127
+ /**
128
+ * ADR-0062 D6 — is `objectName` federated (bound to an external datasource)?
129
+ *
130
+ * A federated participant is EXEMPT from the cross-datasource rejection:
131
+ * `NativeSQLStrategy.canHandle` already declines a cube whose base or joined
132
+ * object is external, so such a dataset is served by the ObjectQL FK-expand
133
+ * path (two reads, joined in memory) — which crosses datasources by
134
+ * construction. Rejecting it here would break a path that works today.
135
+ */
136
+ isExternalObject?: (objectName: string) => boolean;
137
+ }
138
+ declare function compileDataset(dataset: Dataset, resolver?: RelationshipResolver, options?: DatasetCompileOptions): CompiledDataset;
100
139
 
101
140
  /**
102
141
  * Dimension display-label resolution (ADR-0021).
@@ -352,6 +391,24 @@ interface AnalyticsServiceConfig {
352
391
  * `StrategyContext.isExternalObject`.
353
392
  */
354
393
  isExternalObject?: (objectName: string) => boolean;
394
+ /**
395
+ * [#5033] The datasource `objectName` is bound to, or `undefined` when it
396
+ * rides the default one (or nothing authoritative can answer).
397
+ *
398
+ * It never selects a driver (that is `engine.execute`'s `object` key, which
399
+ * the `plugin.ts` bridge now passes). It exists so that when a dataset's SQL
400
+ * references a table that is NOT on the datasource its base object routed to,
401
+ * the failure can name the actual cause — *table X is not on datasource Y* —
402
+ * instead of the misleading "backing object … is unavailable" that a
403
+ * cross-datasource join used to produce.
404
+ *
405
+ * [#5115] The same probe now also gates COMPILATION: `registerDataset` hands
406
+ * it to `compileDataset`, which rejects a dataset whose join crosses
407
+ * datasources before any query is ever built. Absence keeps the pre-#5115
408
+ * behaviour exactly ("cannot answer, do not block") — the query-time
409
+ * diagnostic above stays as the backstop.
410
+ */
411
+ getObjectDatasource?: (objectName: string) => string | undefined;
355
412
  /**
356
413
  * [#3867] Is `name` a registered object in this kernel's schema registry?
357
414
  *
@@ -383,6 +440,18 @@ interface AnalyticsServiceConfig {
383
440
  * the same mistake with a `400 INVALID_FIELD` naming the field (#4315/#4254);
384
441
  * this hook is what lets the ANALYTICS route give the same answer.
385
442
  *
443
+ * [#5520] The same probe now answers for DIMENSIONS too
444
+ * ({@link AnalyticsService.assertDimensionFields}). #4437 gated only the
445
+ * measure half, so the identical typo one key over — `dimensions:
446
+ * ['bogus_dim']` — still reached the driver as a `GROUP BY` column and came
447
+ * back as the same 500. One probe, one answer, both member kinds.
448
+ *
449
+ * [#5669] …and for the `where` members ({@link AnalyticsService.assertWhereFields}),
450
+ * the third and last request key that carries a field name. `where:
451
+ * {bogus_col: 'x'}` compiled straight into `WHERE bogus_col = $1` for exactly
452
+ * as long as #4437 and #5520 had each closed only their own key. One probe now
453
+ * answers for all three.
454
+ *
386
455
  * Same tiering as {@link isRegisteredObject}: absence means "skip the check"
387
456
  * (registry-less hosts, engine doubles, external datasources whose columns
388
457
  * are not mirrored locally). The production bridge in `plugin.ts` wires it
@@ -473,6 +542,13 @@ declare class AnalyticsService implements IAnalyticsService {
473
542
  private readonly isRegisteredObject?;
474
543
  /** [#4437] Field-name probe gating measure source-field resolution. */
475
544
  private readonly getObjectFieldNames?;
545
+ /**
546
+ * [#5033] Datasource probe for the missing-source triage — and, since #5115,
547
+ * for the compile-time cross-datasource join gate in `compileDataset`.
548
+ */
549
+ private readonly getObjectDatasource?;
550
+ /** ADR-0062 D6 — federated-object probe (strategy routing + #5115's gate). */
551
+ private readonly isExternalObject?;
476
552
  /** [#3867] One-shot flag for the {@link assertInferableCube} stand-down warning. */
477
553
  private warnedNoObjectRegistry;
478
554
  readonly cubeRegistry: CubeRegistry;
@@ -547,6 +623,19 @@ declare class AnalyticsService implements IAnalyticsService {
547
623
  * `cube.measures` (e.g. `amount_sum`, `amount_avg` emitted by dashboard
548
624
  * widget translators), inject suffix-inferred Metric entries so the
549
625
  * strategies pick the right aggregation function and field.
626
+ *
627
+ * It is also where the three SOURCE-FIELD gates run, on every path out of this
628
+ * method and always BEFORE the (possibly augmented) cube is registered:
629
+ * {@link assertMeasureFields} (#4437), {@link assertDimensionFields} (#5520)
630
+ * and {@link assertWhereFields} (#5669) — one per request key that can carry a
631
+ * field name. All three answer the same question — does the object actually
632
+ * have the column this member resolves to — and all three must answer it here,
633
+ * because from the strategy onwards the answer is the driver's `no such
634
+ * column`.
635
+ *
636
+ * They run in request-key order (measures → dimensions/timeDimensions →
637
+ * where), so a query that gets several wrong is answered about one at a time,
638
+ * naming a real mistake either way.
550
639
  */
551
640
  private ensureCube;
552
641
  /**
@@ -578,6 +667,132 @@ declare class AnalyticsService implements IAnalyticsService {
578
667
  * queries that used to work.
579
668
  */
580
669
  private assertMeasureFields;
670
+ /**
671
+ * [#5520] Reject a DIMENSION whose source field the backing object does not
672
+ * have, BEFORE the strategy compiles it into `GROUP BY`.
673
+ *
674
+ * The symmetric half of {@link assertMeasureFields}. #4437 closed the measure
675
+ * side and stopped there, so the identical mistake one request key over still
676
+ * reached the driver:
677
+ *
678
+ * ```
679
+ * POST /analytics/query {"cube":"crm_account","measures":["account_count"],"dimensions":["bogus_dim"]}
680
+ * → 500 {"code":"SQLITE_ERROR","message":"Internal server error"}
681
+ *
682
+ * POST /analytics/dataset/query {"selection":{"dimensions":["bogus_dim"],…}}
683
+ * → 500 {"code":"ANALYTICS_QUERY_FAILED",
684
+ * "error":"SELECT bogus_dim AS \"bogus_dim\", … GROUP BY bogus_dim - no such column: bogus_dim"}
685
+ * ```
686
+ *
687
+ * A driver error class as the caller's `error.code` is the ADR-0112 violation
688
+ * #4437 named, and the dataset face additionally echoed the generated
689
+ * statement — physical table and column names — back to the caller. The
690
+ * envelope here is deliberately the SAME as the measure gate's
691
+ * (`INVALID_FIELD`/400 + `field`/`object`/`param`), because "the query names a
692
+ * field the object does not have" is ONE mistake and must have one wire shape
693
+ * whichever member kind carried it.
694
+ *
695
+ * What it checks, and what it deliberately does not:
696
+ *
697
+ * - **Both dimension keys.** `query.dimensions` and `query.timeDimensions`
698
+ * land in the same `cube.dimensions` bag, are resolved by the same
699
+ * `lookupMember`, and produced the same 500 (a bogus time dimension became
700
+ * `date_trunc('month', bogus_at)`); `param` reports which key carried it.
701
+ * - **An UNDECLARED but real field stays legal.** `dimensions: ['phone']` on a
702
+ * cube that never declared `phone` groups by `phone` today — the dimension
703
+ * twin of measure auto-inference, and an established contract. So the
704
+ * question asked is "does the OBJECT have this field", never "did the cube
705
+ * declare this dimension". An undeclared member is checked against the
706
+ * object under the name the strategies would use as the column (their own
707
+ * `resolveDimensionSql`/`resolveFieldName` fallback: the member itself).
708
+ * - Only when the cube's `sql` is a bare OBJECT NAME, only when
709
+ * {@link AnalyticsServiceConfig.getObjectFieldNames} answers, and only for
710
+ * sources that are BARE COLUMNS — same three stand-downs as the measure
711
+ * gate, for the same reasons (no field list to check against; nothing
712
+ * authoritative to consult; a dotted reference resolves through a join whose
713
+ * target this gate cannot see, so it belongs to the join allowlist).
714
+ * - `id` / `created_at` / `updated_at` are admitted unconditionally, matching
715
+ * the data path's `resolveQueryFields`.
716
+ *
717
+ * Runs after the measure gate and before the `where` gate on each `ensureCube`
718
+ * path, so a query that gets several wrong is answered about its measure
719
+ * first — one rejection at a time, naming a real mistake either way.
720
+ */
721
+ private assertDimensionFields;
722
+ /**
723
+ * [#5669] Reject a `where` member whose source field the backing object does
724
+ * not have, BEFORE the strategy compiles it into `WHERE`.
725
+ *
726
+ * The third and last param of one defect. #4437 gated `measures`, #5520 gated
727
+ * `dimensions`/`timeDimensions`, and the filter face — the request key that
728
+ * most often carries a hand-typed field name — had no gate at all:
729
+ *
730
+ * ```
731
+ * POST /analytics/query {"cube":"crm_account","measures":["count"],"where":{"bogus_col":"x"}}
732
+ * → SELECT COUNT(*) AS "count" FROM "crm_account" WHERE bogus_col = $1
733
+ * → 500 {"code":"SQLITE_ERROR","message":"Internal server error"}
734
+ * ```
735
+ *
736
+ * Same envelope as its two siblings (`INVALID_FIELD`/400 + `field`/`object`/
737
+ * `param`), because "the query names a field the object does not have" is ONE
738
+ * mistake whichever request key carried it, and the DATA route has answered it
739
+ * that way since #4315/#4254 (`resolveQueryFields`).
740
+ *
741
+ * # Where the field names come from: the SQL producer's own reader
742
+ *
743
+ * The members are collected through `normalizeAnalyticsFilterTree` +
744
+ * `collectFilterLeaves` — the SAME pair both strategies call to build the
745
+ * predicate. This is deliberate and is the whole reason this gate is not a
746
+ * second filter-tree walker: a hand-rolled walk would have to re-derive
747
+ * `$and`/`$or`/`$not` recursion, `$`-prefixed operator keys, `$between`
748
+ * lowering, the nested-relation dot flattening (`{owner: {region: 'NA'}}` →
749
+ * member `owner.region`) and the #5334 array lowering, and every divergence
750
+ * would show up as "the field the gate saw" not being "the column that reached
751
+ * SQL" — in either direction (a phantom rejection, or a hole).
752
+ * `collectFilterLeaves` discards structure, which is exactly right here:
753
+ * whether a predicate sits under an `$or` changes nothing about whether its
754
+ * column exists. (Its doc's warning — never rebuild a predicate from this list
755
+ * — does not apply; this gate builds nothing.)
756
+ *
757
+ * # Three stand-downs at query level, plus the per-member ones
758
+ *
759
+ * - No {@link AnalyticsServiceConfig.getObjectFieldNames}, cube `sql` that is
760
+ * not a bare object name, or a probe that cannot answer for the object — the
761
+ * same three tiers as the measure and dimension gates, for the same reasons.
762
+ * - A `where` the normalizer REFUSES (an unknown operator, a non-array
763
+ * `$and`, an unlowerable filter array) is not judged here: this gate stands
764
+ * down and lets the refusal happen where it already does. Those inputs
765
+ * already answer `INVALID_FILTER`/400 from the strategy (#5352/#5367's
766
+ * geography, not this gate's), and pulling them forward into `ensureCube`
767
+ * would newly refuse them on the draft-preview path too, whose
768
+ * `matchesWhere` never consults the normalizer at all. A field gate that
769
+ * cannot read the tree has nothing to say about it.
770
+ * - Per member, {@link resolveMemberSource} stands down on an expression `sql`
771
+ * and on a dotted relation traversal — for the dimension gate's reasons.
772
+ *
773
+ * # Array `where` IS gated, and #5353's fix did not change that
774
+ *
775
+ * Since #5334 an array `where` is lowered by `normalizeAnalyticsFilterTree`
776
+ * and compiles to the identical predicate — a measured fact,
777
+ * `where: [['bogus_col','=','x']]` and `where: {bogus_col: 'x'}` both produce
778
+ * `WHERE bogus_col = $1` and hand `executeAggregate` the same
779
+ * `{bogus_col: 'x'}`. Gating one spelling and not the other would answer one
780
+ * mistake two ways, which is the split this whole gate family exists to close.
781
+ *
782
+ * `inferCubeFromQuery` used to skip an array `where` when minting the ad-hoc
783
+ * cube's `dimensions` — a separate question (the cube's dimension VOCABULARY,
784
+ * not which columns reach the driver), fixed by #5353 by lowering before
785
+ * reading keys. Because this gate reads filter LEAVES rather than
786
+ * `cube.dimensions`, that fix could not change its verdicts, and measurement
787
+ * confirms it did not: the array where's keys now reach `cube.dimensions`, so
788
+ * {@link resolveMemberSource} takes the DECLARED-dimension branch for those
789
+ * members instead of the undeclared-bare-column one — and both branches yield
790
+ * the same `source` for the same member, since the minted dimension's `sql` IS
791
+ * the member name. What did change is the rejection's suggestion list, in the
792
+ * direction that closes the split: `Valid filter members:` now reads the same
793
+ * for both spellings of one filter.
794
+ */
795
+ private assertWhereFields;
581
796
  /**
582
797
  * [#3867] Gate on the cube auto-inference path: a name with no registered
583
798
  * Cube may only be inferred into one if it is a registered object.
@@ -833,6 +1048,10 @@ declare class DatasetExecutor {
833
1048
  * Left-merge `extra` rows onto `base` rows by their dimension-key tuple,
834
1049
  * copying the listed value columns. Rows in `extra` with no base match are
835
1050
  * appended (outer-ish merge so comparison-only buckets still surface).
1051
+ *
1052
+ * Rows are matched by {@link dimensionKeyOf} — read its notes before changing
1053
+ * how the key is built. Both the ambiguity it removes and the type coercion it
1054
+ * keeps are load-bearing, and both fail silently when got wrong.
836
1055
  */
837
1056
  declare function mergeByDimensions(base: Record<string, unknown>[], extra: Record<string, unknown>[], dimensions: string[], valueColumns: string[]): Record<string, unknown>[];
838
1057
 
@@ -934,9 +1153,17 @@ declare class NativeSQLStrategy implements AnalyticsStrategy {
934
1153
  * driver-backed `coerceTemporalFilterValue` hook (single source of truth for
935
1154
  * the date/datetime storage convention — see StrategyContext); when the hook
936
1155
  * is absent, or returns the value unchanged (the field is not a temporal
937
- * column, or the dialect stores it as a native timestamp), falls back to the
938
- * generic boolean/number recovery so non-temporal typed columns still bind
939
- * correctly.
1156
+ * column, or the dialect stores it as a native timestamp), falls back to
1157
+ * {@link toSqlBindValue} so an unbindable JS type still reaches the driver as
1158
+ * something it can bind.
1159
+ *
1160
+ * [#5526] `value` is `unknown`, not `string`, because a leaf now carries the
1161
+ * author's comparand at its own type. Both halves of this method were already
1162
+ * `unknown`-typed for it: the hook's contract is
1163
+ * `coerceTemporalFilterValue(object, field, value: unknown)` and the fallback
1164
+ * converts only what a driver cannot bind. What CHANGED is that a string is no
1165
+ * longer re-typed on the way out — the fallback used to be
1166
+ * `coerceFilterValueForSql`, which read `'007'` as the integer `7`.
940
1167
  */
941
1168
  private coerceTemporal;
942
1169
  /**
@@ -965,6 +1192,25 @@ declare class NativeSQLStrategy implements AnalyticsStrategy {
965
1192
  * does bind tighter than `OR`, so `a AND b OR c` happens to be right, but
966
1193
  * being right by construction is what keeps a future edit from making it
967
1194
  * wrong.
1195
+ *
1196
+ * # `null` is the constant TRUE, and TRUE absorbs a disjunction (#5325)
1197
+ *
1198
+ * A `null` return means "constrains nothing", which is the boolean TRUE — the
1199
+ * AND identity, so it drops out of an `and`, but the OR ABSORBER, so one TRUE
1200
+ * disjunct makes the whole `or` TRUE. Filtering it out of an `or` narrowed the
1201
+ * query to the surviving branches. `NOT TRUE ≡ FALSE`, so a negation whose
1202
+ * operand constrains nothing compiles to the FALSE constant rather than
1203
+ * disappearing (which added no `WHERE` and charted every row).
1204
+ *
1205
+ * # The invariant that keeps `params` aligned
1206
+ *
1207
+ * **A call that returns `null` leaves `params` exactly as it found it.** It
1208
+ * has to: a value bound with no `$n` to consume it shifts every later
1209
+ * placeholder onto the wrong value, and a filter that binds the WRONG comparand
1210
+ * is worse than one that is merely too wide (#5297). Leaves decide emptiness
1211
+ * before they bind, and the absorbing `or` — the one place a clause that HAS
1212
+ * bound is discarded — truncates back to the length it started at, so the
1213
+ * invariant holds inductively for every node kind.
968
1214
  */
969
1215
  private compileFilterNode;
970
1216
  private buildFilterClause;
@@ -1035,6 +1281,17 @@ declare class ObjectQLStrategy implements AnalyticsStrategy {
1035
1281
  * cannot be merged). A loud error beats the silent mis-bucket #3654 kills.
1036
1282
  * `generateSql()` calls this too, so the preview accepts/rejects the same set.
1037
1283
  *
1284
+ * [#5716] All four refusals below are `invalidMemberError` — `INVALID_FIELD` /
1285
+ * 400, naming the member — and the MESSAGES are unchanged (they are good
1286
+ * diagnostics, and #5923's tests read them). Each is decided by two caller-side
1287
+ * facts and nothing else: a member the query named, and whether that member
1288
+ * resolves across a join. Neither is an internal invariant — a cube where the
1289
+ * member exists and a driver that could serve it are both perfectly ordinary,
1290
+ * which is exactly what the "run this on a native-SQL driver" half of each
1291
+ * message says. They are member-level rather than dataset-level (hence not
1292
+ * `datasetInvalidError`) because the fix is always to change or drop ONE named
1293
+ * member, and because they fire on `/analytics/query` where no dataset exists.
1294
+ *
1038
1295
  * Detection is on RESOLVED field names, so a dotted dimension the cube
1039
1296
  * flattens to a real column is treated as base, not cross-object.
1040
1297
  */
@@ -1055,10 +1312,21 @@ declare class ObjectQLStrategy implements AnalyticsStrategy {
1055
1312
  * Render one normalized filter as a display SQL predicate for `generateSql`.
1056
1313
  *
1057
1314
  * Mirrors `NativeSQLStrategy.buildFilterClause`'s operator vocabulary so the
1058
- * two previews read alike, but binds through `coerceFilterValueForObjectQL`:
1059
- * the comparand shown is the one THIS path actually hands the engine (a real
1060
- * boolean, not SQL's 1/0). Returns null for an operator/value combination
1061
- * that carries no predicate, matching `execute()`, which drops it too.
1315
+ * two previews read alike, but binds the comparand VERBATIM: the value shown is
1316
+ * the one THIS path actually hands the engine (a real boolean, not SQL's 1/0).
1317
+ *
1318
+ * [#5526] "Verbatim" is now literal. This used to bind through
1319
+ * `coerceFilterValueForObjectQL`, which decoded the string a `string[]` leaf
1320
+ * carried back into a type — so an echo could show `7` for a filter the author
1321
+ * wrote as `'007'`. A leaf carries the author's value at its own type, so the
1322
+ * echo needs no conversion at all to stay honest about execution. The LIKE
1323
+ * family is still the one exception, for the reason `filter.zod.ts` gives: its
1324
+ * comparand is declared a `string`, and what binds is the PATTERN.
1325
+ *
1326
+ * `null` means "this leaf carries no predicate" — a value-less scalar leaf,
1327
+ * which `execute()` and `NativeSQLStrategy` drop too. It does NOT mean "I could
1328
+ * not render that operator": #5333 was exactly that conflation, and an
1329
+ * unrenderable operator now THROWS (see the exit below).
1062
1330
  */
1063
1331
  private buildFilterClauseSql;
1064
1332
  /**
@@ -1107,12 +1375,29 @@ declare class ObjectQLStrategy implements AnalyticsStrategy {
1107
1375
  * path hands them over rather than lowering them.
1108
1376
  */
1109
1377
  private applyFilterNode;
1110
- /** A node as a standalone `FilterCondition` the engine can consume. */
1378
+ /**
1379
+ * A node as a standalone `FilterCondition` the engine can consume.
1380
+ *
1381
+ * `null` = no constraint, which is the boolean TRUE — the AND identity but the
1382
+ * OR ABSORBER, so a `null` branch makes the whole disjunction unconstrained
1383
+ * instead of collapsing it to its surviving branches (#5325). FALSE is handed
1384
+ * to the engine as `{$not: {}}`, the spelling `driver-sql`, `formula` and
1385
+ * `driver-memory`'s matcher all already pin as the zero-row filter (#5134) —
1386
+ * this strategy invents no second one.
1387
+ */
1111
1388
  private filterNodeToCondition;
1112
1389
  /**
1113
1390
  * Render a normalized filter node as the display SQL `/analytics/sql`
1114
1391
  * echoes. Values still bind as `$n` placeholders — the echo travels to the
1115
1392
  * browser, so a comparand is never inlined.
1393
+ *
1394
+ * The boolean identities render too (#5325). This string exists to REPRODUCE
1395
+ * execution: a `{$not: {}}` filter that runs as zero rows but echoes SQL with
1396
+ * no `WHERE` hands whoever is debugging "why is this chart empty" a statement
1397
+ * that returns the whole table. Same reason the absorbed `$or` branch and the
1398
+ * `params` truncation below match {@link NativeSQLStrategy.compileFilterNode}
1399
+ * exactly — including the invariant that a `null` return leaves `params`
1400
+ * untouched, so no comparand is left with no placeholder to consume it.
1116
1401
  */
1117
1402
  private renderFilterNodeSql;
1118
1403
  private mergeFilterOperand;
@@ -1135,9 +1420,16 @@ declare class ObjectQLStrategy implements AnalyticsStrategy {
1135
1420
  * performs the same half-open translation itself because it binds into raw
1136
1421
  * SQL, so one dashboard reads the same on every driver.
1137
1422
  *
1138
- * Comparands are coerced by the SAME helper the `where` path uses, so an
1139
- * epoch-ms bound recovers as a number and an ISO string stays a string. No
1140
- * STORAGE coercion happens here, deliberately: `NativeSQLStrategy` needs
1423
+ * [#5526] Bounds are forwarded at the type `dateRange` is DECLARED with
1424
+ * `string` (`AnalyticsQuerySchema`'s `timeDimensions[].dateRange: string[]`)
1425
+ * and nothing re-types them. They used to pass through
1426
+ * `coerceFilterValueForObjectQL`, whose TSDoc advertised that "an epoch-ms
1427
+ * bound recovers as a number"; that was a lenient CONSUMER rescuing a shape the
1428
+ * contract does not declare, and the same guess is what read a `'007'` filter
1429
+ * comparand as `7` (Prime Directive #12 — the producer or the spec is where an
1430
+ * epoch-ms window would have to be declared, not here). An author who wants an
1431
+ * instant window writes it as one; a declared `string` binds as a string. No
1432
+ * STORAGE coercion happens here either, deliberately: `NativeSQLStrategy` needs
1141
1433
  * `coerceTemporal` because it binds into raw SQL and had to learn that a
1142
1434
  * SQLite `Field.datetime` is an INTEGER epoch (#2034); this path goes through
1143
1435
  * `engine.aggregate()`, where the driver's own CRUD filter coercion applies —
@@ -1156,6 +1448,23 @@ declare class ObjectQLStrategy implements AnalyticsStrategy {
1156
1448
  * fallback here errs toward the narrower query instead.
1157
1449
  */
1158
1450
  private dateRangeBounds;
1451
+ /**
1452
+ * One leaf as the operand the engine's `FilterCondition` expects.
1453
+ *
1454
+ * [#5526] The comparand is passed through UNCONVERTED. That is the whole of
1455
+ * this path's share of the fix: the engine compares against the value as
1456
+ * STORED, and a leaf now carries the value the author wrote, so `'007'` stays
1457
+ * `'007'`, `true` stays `true` and `7` stays `7` with nothing in between to
1458
+ * re-type them. The two `coerceFilterValueForObjectQL` calls this replaced
1459
+ * existed only to undo `stringifyForCube`, and undoing it required guessing.
1460
+ *
1461
+ * The four LIKE-family arms are the exception, and a contract one:
1462
+ * `filter.zod.ts` declares `$contains` / `$notContains` / `$startsWith` /
1463
+ * `$endsWith` as `z.string()`, so this PRODUCER must hand the engine a real
1464
+ * string — `String(…)`, the same normalisation `like-pattern.ts` applies at the
1465
+ * two SQL emitters and `driver-sql`'s `applyLike` applies at the driver, so one
1466
+ * `$contains` means one thing on every face (#5567's invariant).
1467
+ */
1159
1468
  private convertFilter;
1160
1469
  private extractObjectName;
1161
1470
  /**
@@ -1180,4 +1489,4 @@ declare class ObjectQLStrategy implements AnalyticsStrategy {
1180
1489
  private buildFieldMeta;
1181
1490
  }
1182
1491
 
1183
- export { AnalyticsService, type AnalyticsServiceConfig, AnalyticsServicePlugin, type AnalyticsServicePluginOptions, type CompareTo, type CompiledDataset, CubeRegistry, DatasetExecutor, type DerivedMeasureSpec, type DimensionLabelDeps, type FieldMetaLite, NativeSQLStrategy, ObjectQLStrategy, type OrderLabelResolver, type RelationshipResolver, type RelationshipTarget, combineFilters, compileDataset, compileScopedFilterToSql, createOrderLabelResolver, evaluateDerivedMeasures, fillEmptyGroups, mergeByDimensions, pickDisplayField, resolveDimensionLabels, shiftRange, withLabelFetchCache };
1492
+ export { AnalyticsService, type AnalyticsServiceConfig, AnalyticsServicePlugin, type AnalyticsServicePluginOptions, type CompareTo, type CompiledDataset, CubeRegistry, type DatasetCompileOptions, DatasetExecutor, type DerivedMeasureSpec, type DimensionLabelDeps, type FieldMetaLite, NativeSQLStrategy, ObjectQLStrategy, type OrderLabelResolver, type RelationshipResolver, type RelationshipTarget, combineFilters, compileDataset, compileScopedFilterToSql, createOrderLabelResolver, evaluateDerivedMeasures, fillEmptyGroups, mergeByDimensions, pickDisplayField, resolveDimensionLabels, shiftRange, withLabelFetchCache };