@objectstack/service-analytics 17.0.0 → 17.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.cts CHANGED
@@ -1186,6 +1186,33 @@ declare class NativeSQLStrategy implements AnalyticsStrategy {
1186
1186
  readonly name = "NativeSQLStrategy";
1187
1187
  readonly priority = 10;
1188
1188
  canHandle(query: AnalyticsQuery, ctx: StrategyContext): boolean;
1189
+ /**
1190
+ * [#8690] Does the query's `where` compare a declared TIME dimension against
1191
+ * a value no temporal storage rule can read? See the ruling at
1192
+ * {@link canHandle}.
1193
+ *
1194
+ * The classification comes from the CUBE, the only metadata this package has:
1195
+ * a dimension declares `type: 'time'` (compiled from the dataset dimension's
1196
+ * `type: 'date'`), and {@link lookupMember} is the same resolution every other
1197
+ * member lookup in this strategy uses, so "the member the gate classified"
1198
+ * and "the member the compiler emits" cannot drift apart.
1199
+ *
1200
+ * A `time` dimension is read with the DATETIME rule — the permissive one of
1201
+ * the three. That is the right direction because this is a routing decision,
1202
+ * not a verdict: the engine door re-judges with the field's real declared
1203
+ * type and has the final say, so under-classifying an exotic spelling merely
1204
+ * leaves today's behaviour, while over-classifying would silently move a
1205
+ * working dashboard off the fast path. The comparands this card measured
1206
+ * (`last_30_days`, `not-a-date-at-all`) are unreadable under all three rules,
1207
+ * so the decline fires for them whichever backing type the dimension has.
1208
+ *
1209
+ * `lowerAnalyticsWhere` rather than `query.where` raw, so the authored ARRAY
1210
+ * sugar is seen after `parseFilterAST` has lowered it; a THROW from that
1211
+ * lowering is not this gate's to answer — the filter is malformed either way
1212
+ * and `normalizeAnalyticsFilterTree` refuses it a moment later with the
1213
+ * message and envelope it has always had.
1214
+ */
1215
+ private carriesUninterpretableTemporalComparand;
1189
1216
  /**
1190
1217
  * [#7598] Does serving this query require the cross-field capability this
1191
1218
  * strategy declines? See the ruling recorded at {@link canHandle}.
package/dist/index.d.ts CHANGED
@@ -1186,6 +1186,33 @@ declare class NativeSQLStrategy implements AnalyticsStrategy {
1186
1186
  readonly name = "NativeSQLStrategy";
1187
1187
  readonly priority = 10;
1188
1188
  canHandle(query: AnalyticsQuery, ctx: StrategyContext): boolean;
1189
+ /**
1190
+ * [#8690] Does the query's `where` compare a declared TIME dimension against
1191
+ * a value no temporal storage rule can read? See the ruling at
1192
+ * {@link canHandle}.
1193
+ *
1194
+ * The classification comes from the CUBE, the only metadata this package has:
1195
+ * a dimension declares `type: 'time'` (compiled from the dataset dimension's
1196
+ * `type: 'date'`), and {@link lookupMember} is the same resolution every other
1197
+ * member lookup in this strategy uses, so "the member the gate classified"
1198
+ * and "the member the compiler emits" cannot drift apart.
1199
+ *
1200
+ * A `time` dimension is read with the DATETIME rule — the permissive one of
1201
+ * the three. That is the right direction because this is a routing decision,
1202
+ * not a verdict: the engine door re-judges with the field's real declared
1203
+ * type and has the final say, so under-classifying an exotic spelling merely
1204
+ * leaves today's behaviour, while over-classifying would silently move a
1205
+ * working dashboard off the fast path. The comparands this card measured
1206
+ * (`last_30_days`, `not-a-date-at-all`) are unreadable under all three rules,
1207
+ * so the decline fires for them whichever backing type the dimension has.
1208
+ *
1209
+ * `lowerAnalyticsWhere` rather than `query.where` raw, so the authored ARRAY
1210
+ * sugar is seen after `parseFilterAST` has lowered it; a THROW from that
1211
+ * lowering is not this gate's to answer — the filter is malformed either way
1212
+ * and `normalizeAnalyticsFilterTree` refuses it a moment later with the
1213
+ * message and envelope it has always had.
1214
+ */
1215
+ private carriesUninterpretableTemporalComparand;
1189
1216
  /**
1190
1217
  * [#7598] Does serving this query require the cross-field capability this
1191
1218
  * strategy declines? See the ruling recorded at {@link canHandle}.
package/dist/index.js CHANGED
@@ -124,17 +124,19 @@ import { isFilterAST, parseFilterAST, VALID_AST_OPERATORS } from "@objectstack/s
124
124
  import { StandardErrorCode } from "@objectstack/spec/api";
125
125
 
126
126
  // src/comparand-shape.ts
127
+ import {
128
+ isUninterpretableTemporalComparand
129
+ } from "@objectstack/core";
130
+ import {
131
+ isAcceptedFilterComparand,
132
+ ACCEPTED_FILTER_COMPARAND_TYPES_SENTENCE
133
+ } from "@objectstack/spec/data";
127
134
  function isBindableComparand(value) {
128
- if (value === null || value === void 0) return true;
129
- const kind = typeof value;
130
- if (kind === "string" || kind === "number" || kind === "bigint" || kind === "boolean") return true;
131
- return value instanceof Date || ArrayBuffer.isView(value);
135
+ if (value === void 0) return true;
136
+ return isAcceptedFilterComparand(value) || ArrayBuffer.isView(value);
132
137
  }
133
138
  function isRenderableTextComparand(value) {
134
- if (value === null || value === void 0) return true;
135
- const kind = typeof value;
136
- if (kind === "string" || kind === "number" || kind === "bigint" || kind === "boolean") return true;
137
- return value instanceof Date;
139
+ return value === void 0 || isAcceptedFilterComparand(value);
138
140
  }
139
141
  function isFieldReference(value) {
140
142
  if (!value || typeof value !== "object" || Array.isArray(value)) return false;
@@ -170,6 +172,51 @@ function findIn(node, field) {
170
172
  }
171
173
  return null;
172
174
  }
175
+ function findUninterpretableTemporalMember(filter, kindOf) {
176
+ return findUninterpretableIn(filter, "", kindOf);
177
+ }
178
+ function findUninterpretableIn(node, field, kindOf) {
179
+ if (!node || typeof node !== "object") return null;
180
+ if (Array.isArray(node)) {
181
+ for (const child of node) {
182
+ const hit = findUninterpretableIn(child, field, kindOf);
183
+ if (hit) return hit;
184
+ }
185
+ return null;
186
+ }
187
+ if (node instanceof Date || ArrayBuffer.isView(node)) return null;
188
+ if (isFieldReference(node)) return null;
189
+ for (const [key, value] of Object.entries(node)) {
190
+ const scope = key.startsWith("$") ? field : key;
191
+ const kind = scope ? kindOf(scope) : null;
192
+ if (kind) {
193
+ const hit2 = judgeTemporalLiterals(value, scope, kind);
194
+ if (hit2) return hit2;
195
+ continue;
196
+ }
197
+ const hit = findUninterpretableIn(value, scope, kindOf);
198
+ if (hit) return hit;
199
+ }
200
+ return null;
201
+ }
202
+ function judgeTemporalLiterals(value, field, kind) {
203
+ if (Array.isArray(value)) {
204
+ for (const member of value) {
205
+ const hit = judgeTemporalLiterals(member, field, kind);
206
+ if (hit) return hit;
207
+ }
208
+ return null;
209
+ }
210
+ if (value && typeof value === "object") {
211
+ if (value instanceof Date || ArrayBuffer.isView(value) || isFieldReference(value)) return null;
212
+ for (const nested of Object.values(value)) {
213
+ const hit = judgeTemporalLiterals(nested, field, kind);
214
+ if (hit) return hit;
215
+ }
216
+ return null;
217
+ }
218
+ return isUninterpretableTemporalComparand(kind, value) ? { field, kind, value } : null;
219
+ }
173
220
  var TEXT_PATTERN_OPERATORS = /* @__PURE__ */ new Set([
174
221
  "$contains",
175
222
  "$notContains",
@@ -187,7 +234,7 @@ function shapePreview(value) {
187
234
  }
188
235
  }
189
236
  function unrenderableTextComparandMessage(op, field, value) {
190
- return `"${op}" on "${field}" matches against the TEXT of a pattern, but its comparand is ${Array.isArray(value) ? "an array" : "an object"} (${shapePreview(value)}). filter.zod.ts declares it a string (StringOperatorSchema); a string, number, boolean, null or Date is accepted. Refusing rather than stringifying it: String({}) is "[object Object]", so the pattern that ran would be one nobody wrote \u2014 and a row storing that literal text matches it.`;
237
+ return `"${op}" on "${field}" matches against the TEXT of a pattern, but its comparand is ${Array.isArray(value) ? "an array" : "an object"} (${shapePreview(value)}). filter.zod.ts declares it a string (StringOperatorSchema); ${ACCEPTED_FILTER_COMPARAND_TYPES_SENTENCE} is accepted. Refusing rather than stringifying it: String({}) is "[object Object]", so the pattern that ran would be one nobody wrote \u2014 and a row storing that literal text matches it.`;
191
238
  }
192
239
  function fieldReferenceComparandMessage(op, field, ref, position) {
193
240
  return `"${op}" on "${field}"${position ? ` (${position})` : ""} compares against the field reference { "$field": "${ref}" }, which this compiler does not lower into a column-to-column comparison. Refusing rather than binding it: the reference object used to become the BOUND VALUE of the comparison, so the emitted predicate compared "${field}" against the reference itself \u2014 a value no row can hold \u2014 and a read scope built from it answered the wrong row set with nothing to read. \u26A0\uFE0F This is NOT the platform declining the rule. @objectstack/spec declares this shape (FieldReferenceSchema), @objectstack/formula resolves it per record in memory, driver-sql / driver-sqlite-wasm compile it to a same-table column comparison for the six scalar operators since #5222, and since the 2026-08-12 ruling on #7598 the analytics native-SQL strategy DECLINES such a query so it routes to the ObjectQL engine path and runs there \u2014 the driver enforcing declared-only enumeration, the tenant-isolation ban and the comparison class with metadata it owns. What refuses here is this SQL lowering, whose only remaining caller is the /analytics/sql display echo; it has no faithful rendering of the predicate the engine path actually runs, and half-rendering one would describe a query that returns different rows. Run the query itself (/analytics/query) to get its rows (#7598).`;
@@ -196,7 +243,7 @@ function fieldReferenceBetweenBoundMessage(op, field, ref, index) {
196
243
  return `"${op}" on "${field}" has the field reference { "$field": "${ref}" } at index ${index} of its [min, max] bounds. A range BOUND may not be a field reference on any backend: driver-sql and driver-sqlite-wasm refuse both endpoints (#5222), @objectstack/formula does not resolve a reference inside a list either \u2014 it orders the bounds against the raw reference object, which no value compares meaningfully to \u2014 and @objectstack/spec no longer declares the position at all (#7596 removed FieldReferenceSchema from the $between endpoint union, ADR-0049 declared = enforced). Refusing rather than lowering it: this compiler splits $between into its two bounds, so the reference would arrive at the driver under a "$gte" / "$lte" the author never wrote \u2014 a position the SQL drivers DO compile \u2014 and the range would quietly succeed here while the identical filter is refused everywhere else. Use a literal bound, or spell the comparison you meant as a scalar one ({ "${field}": { "$gte": { "$field": "${ref}" } } }), which IS served \u2014 on the ObjectQL engine path, where the driver enforces the #5222 rulings (#7598).`;
197
244
  }
198
245
  function unbindableListMemberMessage(op, field, value, index) {
199
- return `"${op}" on "${field}" has a value at index ${index} of its list that cannot be bound as a SQL parameter: ${shapePreview(value)}. Every member of an $in/$nin/$between list is a comparand in its own right \u2014 use a string, number, boolean, null, Date or binary value. Refusing rather than binding it: the member can equal no stored value, so the list silently loses that entry (and a $nin loses the exclusion the caller wrote).`;
246
+ return `"${op}" on "${field}" has a value at index ${index} of its list that cannot be bound as a SQL parameter: ${shapePreview(value)}. Every member of an $in/$nin/$between list is a comparand in its own right \u2014 use ${ACCEPTED_FILTER_COMPARAND_TYPES_SENTENCE} (or a binary value). Refusing rather than binding it: the member can equal no stored value, so the list silently loses that entry (and a $nin loses the exclusion the caller wrote).`;
200
247
  }
201
248
 
202
249
  // src/strategies/filter-normalizer.ts
@@ -1009,9 +1056,51 @@ var NativeSQLStrategy = class {
1009
1056
  }
1010
1057
  }
1011
1058
  if (this.carriesCrossFieldComparison(query, ctx)) return false;
1059
+ if (this.carriesUninterpretableTemporalComparand(query, ctx)) return false;
1012
1060
  const caps = ctx.queryCapabilities(query.cube);
1013
1061
  return caps.nativeSql && typeof ctx.executeRawSql === "function";
1014
1062
  }
1063
+ /**
1064
+ * [#8690] Does the query's `where` compare a declared TIME dimension against
1065
+ * a value no temporal storage rule can read? See the ruling at
1066
+ * {@link canHandle}.
1067
+ *
1068
+ * The classification comes from the CUBE, the only metadata this package has:
1069
+ * a dimension declares `type: 'time'` (compiled from the dataset dimension's
1070
+ * `type: 'date'`), and {@link lookupMember} is the same resolution every other
1071
+ * member lookup in this strategy uses, so "the member the gate classified"
1072
+ * and "the member the compiler emits" cannot drift apart.
1073
+ *
1074
+ * A `time` dimension is read with the DATETIME rule — the permissive one of
1075
+ * the three. That is the right direction because this is a routing decision,
1076
+ * not a verdict: the engine door re-judges with the field's real declared
1077
+ * type and has the final say, so under-classifying an exotic spelling merely
1078
+ * leaves today's behaviour, while over-classifying would silently move a
1079
+ * working dashboard off the fast path. The comparands this card measured
1080
+ * (`last_30_days`, `not-a-date-at-all`) are unreadable under all three rules,
1081
+ * so the decline fires for them whichever backing type the dimension has.
1082
+ *
1083
+ * `lowerAnalyticsWhere` rather than `query.where` raw, so the authored ARRAY
1084
+ * sugar is seen after `parseFilterAST` has lowered it; a THROW from that
1085
+ * lowering is not this gate's to answer — the filter is malformed either way
1086
+ * and `normalizeAnalyticsFilterTree` refuses it a moment later with the
1087
+ * message and envelope it has always had.
1088
+ */
1089
+ carriesUninterpretableTemporalComparand(query, ctx) {
1090
+ const cube = query.cube ? ctx.getCube(query.cube) : void 0;
1091
+ if (!cube) return false;
1092
+ let where = null;
1093
+ try {
1094
+ where = lowerAnalyticsWhere(query);
1095
+ } catch {
1096
+ return false;
1097
+ }
1098
+ if (!where) return false;
1099
+ return findUninterpretableTemporalMember(
1100
+ where,
1101
+ (member) => this.lookupMember(cube, member, "dimension")?.type === "time" ? "datetime" : null
1102
+ ) !== null;
1103
+ }
1015
1104
  /**
1016
1105
  * [#7598] Does serving this query require the cross-field capability this
1017
1106
  * strategy declines? See the ruling recorded at {@link canHandle}.