Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions .changeset/20751-services-strings-stage7-state-the-decision.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
15 changes: 9 additions & 6 deletions packages/services/service-analytics/src/analytics-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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. `
Expand Down Expand Up @@ -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 ('<field>_sum' / '_avg' / '_min' / '_max' / '_count_distinct'), or ` +
Expand Down
27 changes: 15 additions & 12 deletions packages/services/service-analytics/src/comparand-shape.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.`
);
}

Expand Down Expand Up @@ -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).`
);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.`,
);
}

Expand Down Expand Up @@ -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.`,
);
}

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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].`,
Expand Down Expand Up @@ -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<string, unknown>;
Expand Down
20 changes: 0 additions & 20 deletions scripts/doc-authoring-prose-id.baseline.json
Original file line number Diff line number Diff line change
@@ -1,31 +1,11 @@
{
"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,
"#6050": 1,
"#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
Expand Down
Loading