diff --git a/.changeset/20751-services-strings-stage7-state-the-decision.md b/.changeset/20751-services-strings-stage7-state-the-decision.md new file mode 100644 index 0000000000..8d6394c968 --- /dev/null +++ b/.changeset/20751-services-strings-stage7-state-the-decision.md @@ -0,0 +1,17 @@ +--- +'@objectstack/service-analytics': patch +--- + +Analytics filter refusals, the no-strategy diagnostic and the cube-gate warning no longer cite tracker numbers; each one states the decision behind it in words + +Clause-②: no + +Some strings the analytics service shows to callers, authors and operators pointed at an issue-tracker number for the reason behind them. The number goes; where the sentence did not already say what was decided, it now does. + +- The two field-reference refusals (a `{ $field }` comparand the SQL lowering cannot render, and a `{ $field }` used as a `$between` bound) say the engine path's driver enforces the cross-field rules (declared same-table columns only, never the tenant-isolation column, one comparison class) with metadata it owns, so those rules are enforced in one place. The bound refusal also says `FieldReferenceSchema` was removed from the `$between` endpoint union rather than implemented there, since nothing asked for it. +- The no-strategy diagnostic for a cross-field filter on a deployment with no aggregate bridge says the same about the engine path. +- The `where` refusals: an undefined comparand is refused rather than read as null, on the SQL drivers and on this door alike; a field constraint with zero operators is refused on every backend, because neither "every row" nor "no row" is the author's intent; a field constraint mixing `$` operators with bare keys is refused by both doors in the package; and the two filter-array refusals say a filter array is lowered at every door or refused, never dropped, so it means the same rows whichever door it enters. Where the undefined-comparand refusal cited a tracker number for the silent widening, it now says that a dropped predicate widens the query; the mixed-wrapper refusal already said so and only drops its citation. +- The dotted-measure refusal drops its citation; the sentence already says measures do not traverse relationships and that the prefix used to be dropped silently. +- The warning logged when no object-registry hook is configured says the inactive gate is the one that answers 404 `CUBE_NOT_FOUND` for a name that is neither a registered cube nor a registered object. + +Text only: no status, error code, field, route or control flow moves. A client or log filter that matches the old text (for example a tracker-number suffix) needs the new spelling. diff --git a/packages/services/service-analytics/src/__tests__/cross-field-engine-fallback.test.ts b/packages/services/service-analytics/src/__tests__/cross-field-engine-fallback.test.ts index 9c93b5032b..a0e9f90fb5 100644 --- a/packages/services/service-analytics/src/__tests__/cross-field-engine-fallback.test.ts +++ b/packages/services/service-analytics/src/__tests__/cross-field-engine-fallback.test.ts @@ -416,7 +416,7 @@ describe('[#7598] cross-field `$field` on the analytics face — served via the const err = await errorFrom(() => run({ amount: { $gt: { $field: 'budget' } } })); expect(err.message).toContain('budget'); expect(err.message).toContain('executeAggregate'); - expect(err.message).toContain('#7598'); + expect(err.message).toContain('so those rules are enforced in one place'); // …and the narrowness control, which is the half that makes the sentence // trustworthy: a literal filter on the SAME deployment still runs. diff --git a/packages/services/service-analytics/src/analytics-service.ts b/packages/services/service-analytics/src/analytics-service.ts index de5f8bf2d1..ae5535888d 100644 --- a/packages/services/service-analytics/src/analytics-service.ts +++ b/packages/services/service-analytics/src/analytics-service.ts @@ -3664,9 +3664,10 @@ export class AnalyticsService implements IAnalyticsService { if (!this.warnedNoObjectRegistry) { this.warnedNoObjectRegistry = true; this.logger.warn( - '[Analytics] no object-registry hook configured — the cube-inference existence gate ' + - '(#3867) is INACTIVE for this service; an unregistered cube name reaches the driver ' + - 'as a raw table name.', + '[Analytics] no object-registry hook configured — the cube-inference existence gate, ' + + 'which answers 404 CUBE_NOT_FOUND for a name that is neither a registered cube nor a ' + + 'registered object, is INACTIVE for this service; an unregistered cube name reaches the ' + + 'driver as a raw table name.', ); } return; @@ -3864,8 +3865,10 @@ export class AnalyticsService implements IAnalyticsService { ? `This query's filter compares against the field reference ` + `{ "$field": "${crossField.ref}" } under "${crossField.op}" on "${crossField.field}", and ` + `NativeSQLStrategy DECLINES a cross-field comparison so that it routes to the ObjectQL ` + - `engine path — whose driver compiles it and enforces the #5222 rulings with metadata it ` + - `owns (#7598). No such path is configured here, so the capability is unavailable on this ` + + `engine path — whose driver compiles it and enforces the cross-field rules (declared ` + + `same-table columns only, never the tenant-isolation column, one comparison class) with ` + + `metadata it owns, so those rules are enforced in one place, next to the metadata they ` + + `read. No such path is configured here, so the capability is unavailable on this ` + `deployment: supply an \`executeAggregate\` bridge (the plugin auto-wires one from the ` + `engine), or compare against a literal value. Every other query on this cube is ` + `unaffected. ` @@ -3965,7 +3968,7 @@ function mintableMeasureKey(member: string, cubeName: string): string { throw invalidMemberError( `[Analytics] Measure '${member}' on cube '${cubeName}' is a DOTTED member, and ` + `measures do not traverse relationships — only dimensions do — so there is no ` + - `related column for this to aggregate. Until #5918 the prefix was silently ` + + `related column for this to aggregate. Before this refusal the prefix was silently ` + `dropped, so the aggregate ran against '${cubeName}' itself while the result ` + `column kept the label '${member}'. Aggregate one of the object's OWN fields ` + `instead ('_sum' / '_avg' / '_min' / '_max' / '_count_distinct'), or ` + diff --git a/packages/services/service-analytics/src/comparand-shape.ts b/packages/services/service-analytics/src/comparand-shape.ts index 0eee584e8c..b509ccc901 100644 --- a/packages/services/service-analytics/src/comparand-shape.ts +++ b/packages/services/service-analytics/src/comparand-shape.ts @@ -601,13 +601,14 @@ export function fieldReferenceComparandMessage( `with nothing to read. ⚠️ 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 — 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).` + `six scalar operators, and the analytics native-SQL strategy DECLINES such a query so it ` + + `routes to the ObjectQL engine path and runs there — the driver enforcing declared-only ` + + `enumeration, the tenant-isolation ban and the comparison class with metadata it owns, so ` + + `those rules are enforced in one place, next to the metadata they read. 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.` ); } @@ -648,17 +649,19 @@ export function fieldReferenceBetweenBoundMessage( 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 ` + + `driver-sqlite-wasm refuse both endpoints, @objectstack/formula does not resolve a ` + `reference inside a list either — it orders the bounds against the raw reference object, which ` + `no value compares meaningfully to — 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 ` + + `all (FieldReferenceSchema was removed from the $between endpoint union rather than implemented ` + + `there, since nothing asked for it, 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 — a position the SQL drivers DO compile — 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 — on the ObjectQL engine path, where the driver enforces the #5222 rulings ` + - `(#7598).` + `which IS served — on the ObjectQL engine path, where the driver enforces the cross-field ` + + `rules (declared same-table columns only, never the tenant-isolation column, one comparison ` + + `class).` ); } diff --git a/packages/services/service-analytics/src/strategies/filter-normalizer.ts b/packages/services/service-analytics/src/strategies/filter-normalizer.ts index 01912b019d..7d768ab363 100644 --- a/packages/services/service-analytics/src/strategies/filter-normalizer.ts +++ b/packages/services/service-analytics/src/strategies/filter-normalizer.ts @@ -865,14 +865,16 @@ function undefinedComparandError(field: string, path: string): Error { `whose value is undefined cannot be told apart from an ABSENT key — yet the two mean OPPOSITE ` + `things (a predicate versus no constraint at all), so there is no reading of it that is not a ` + `guess. It used to compile, two ways: in a FIELD position the key was dropped outright, so a ` + - `single-key where ran with no filter at all and the chart was drawn over every row (#3650's ` + - `widening, which this module refuses everywhere else); in an OPERATOR or list position it ` + - `became a comparison against null, which is UNKNOWN for every row and charts nothing. ` + + `single-key where ran with no filter at all and the chart was drawn over every row (a dropped ` + + `predicate WIDENS the query, which this module refuses everywhere else); in an OPERATOR or ` + + `list position it became a comparison against null, which is UNKNOWN for every row and charts ` + + `nothing. ` + `Write null if the null predicate was meant ({ "${field}": null } or { "${field}": { "$null": true } }), ` + `or omit the key entirely when the value is genuinely absent — an omitted key is the same "no ` + `constraint" without the ambiguity. The producer to fix is whoever BUILT this where: undefined ` + `cannot cross JSON, so it is in-process code spreading a possibly-absent value into a filter ` + - `object (#6050 ruling B, pushed down to this door by #6386).`, + `object. An undefined comparand is refused rather than read as null, on the SQL drivers and on ` + + `this door alike.`, ); } @@ -1019,9 +1021,9 @@ function mixedFieldWrapperError(field: string, opKeys: string[], nonOpKeys: stri `explicitly: { "$and": [{ "${field}": { "$op": ... } }, { "${field}": { "${example}": ... } }] }. ` + `This shape used to compile by silently DROPPING every non-$ sibling, and a dropped conjunct ` + `does not narrow the query, it WIDENS it: the chart included rows the author excluded, with ` + - `nothing to read (#3650's failure mode, which this module refuses everywhere else). The sibling ` + - `door in this package (read-scope-sql.ts) already fails closed on this exact shape — one shape, ` + - `one answer (#6444).`, + `nothing to read — the failure mode this module refuses everywhere else. The sibling ` + + `door in this package (read-scope-sql.ts) already fails closed on this exact shape, so both ` + + `doors refuse it: one shape, one answer.`, ); } @@ -1111,8 +1113,9 @@ function fieldLeaves(key: string, raw: unknown): NormalizedFilterNode[] { if (Object.keys(wrapper).length === 0) { throw invalidFilterError( `[analytics] "${key}" carries a field constraint with zero operators ({}). ` + - `Refusing rather than reading it as "every row" or "no row" — #5240 ruled this ` + - `shape refused on every backend.`, + `Refusing rather than reading it as "every row" or "no row": neither reading is the ` + + `author's intent (a filter that recorded a field and never its operator), so this shape ` + + `is refused on every backend.`, ); } // [#6444] A wrapper mixing $-operator keys with non-$ siblings is refused @@ -1447,7 +1450,8 @@ function filterArrayNotLowerableError(where: unknown[]): Error { `A filter array is a comparison [field, operator, value], a logical node ` + `["and"|"or", ...conditions], or a list of those — it is INPUT-ONLY sugar (spec ` + `'FilterArray'), lowered to a FilterCondition by @objectstack/spec parseFilterAST() at ` + - `every door, this one included (#5158/#5334). This value cannot be lowered, and an ` + + `every door, this one included, so it means the same rows whichever door it enters. This ` + + `value cannot be lowered, and an ` + `unapplied filter would have charted the UNFILTERED dataset. Recognised operators: ` + `${[...VALID_AST_OPERATORS].sort().join(', ')}. Infix joins ([condA, "or", condB]) are ` + `NOT one of the shapes — write the prefix form ["or", condA, condB].`, @@ -2062,7 +2066,8 @@ export function lowerAnalyticsWhere( throw invalidFilterError( `[analytics] filter array ${JSON.stringify(where)} passed isFilterAST() but ` + `parseFilterAST() lowered it to ${JSON.stringify(condition)}. Refusing rather than ` + - `charting the dataset unfiltered (#5158/#5334).`, + `charting the dataset unfiltered: a filter array is lowered at every door or refused, ` + + `never dropped.`, ); } return condition as Record; diff --git a/scripts/doc-authoring-prose-id.baseline.json b/scripts/doc-authoring-prose-id.baseline.json index 635e20cf97..5358ddd03f 100644 --- a/scripts/doc-authoring-prose-id.baseline.json +++ b/scripts/doc-authoring-prose-id.baseline.json @@ -1,15 +1,4 @@ { - "packages/services/service-analytics/src/analytics-service.ts": { - "#3867": 1, - "#5222": 1, - "#5918": 1, - "#7598": 1 - }, - "packages/services/service-analytics/src/comparand-shape.ts": { - "#5222": 3, - "#7596": 1, - "#7598": 3 - }, "packages/services/service-analytics/src/read-scope-sql.ts": { "#5347": 1, "#5369": 1, @@ -17,15 +6,6 @@ "#6125": 1, "#6387": 1 }, - "packages/services/service-analytics/src/strategies/filter-normalizer.ts": { - "#3650": 2, - "#5158": 2, - "#5240": 1, - "#5334": 2, - "#6050": 1, - "#6386": 1, - "#6444": 1 - }, "packages/services/service-analytics/src/strategies/native-sql-strategy.ts": { "#5222": 1, "#7598": 1