@objectstack/service-analytics 17.0.0 → 17.2.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/CHANGELOG.md +444 -0
- package/README.md +136 -335
- package/dist/index.cjs +307 -64
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +113 -13
- package/dist/index.d.ts +113 -13
- package/dist/index.js +278 -30
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
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}.
|
|
@@ -1281,6 +1308,11 @@ declare class NativeSQLStrategy implements AnalyticsStrategy {
|
|
|
1281
1308
|
*/
|
|
1282
1309
|
private lookupMember;
|
|
1283
1310
|
private resolveDimensionSql;
|
|
1311
|
+
/**
|
|
1312
|
+
* @param predicate - The measure's own scoped filter, already compiled to a
|
|
1313
|
+
* SQL boolean (`null` = the measure declares none, or declares one that
|
|
1314
|
+
* constrains nothing — `compileFilterNode`'s TRUE). #10298.
|
|
1315
|
+
*/
|
|
1284
1316
|
private resolveMeasureSql;
|
|
1285
1317
|
private resolveFieldSql;
|
|
1286
1318
|
/**
|
|
@@ -1416,6 +1448,59 @@ declare class ObjectQLStrategy implements AnalyticsStrategy {
|
|
|
1416
1448
|
private withReadScope;
|
|
1417
1449
|
/** Is `field` a resolved cross-object (relationship-traversal) reference? */
|
|
1418
1450
|
private isCrossObjectField;
|
|
1451
|
+
/**
|
|
1452
|
+
* The member view {@link planCrossObject} judges a filter by: EVERY member
|
|
1453
|
+
* that will end up in the engine's predicate, structure discarded, keyed by
|
|
1454
|
+
* RESOLVED field name (#10759), valued by WHERE THE MEMBER CAME FROM
|
|
1455
|
+
* (#10861).
|
|
1456
|
+
*
|
|
1457
|
+
* Both call sites — `execute()` and `generateSql()` — are handed this and
|
|
1458
|
+
* nothing else, which is what makes the invariant `planCrossObject` states
|
|
1459
|
+
* for itself ("the preview accepts/rejects the same set") structural rather
|
|
1460
|
+
* than a coincidence maintained by hand. They used to build the view
|
|
1461
|
+
* separately: the echo flattened the tree, `execute()` passed the ENGINE
|
|
1462
|
+
* FILTER, and a filter record answers a different question — it is a
|
|
1463
|
+
* predicate to evaluate, not an inventory of members. An `$or`, a `$not` or
|
|
1464
|
+
* an unmergeable nested `$and` travels in it as one opaque `$and` entry, so
|
|
1465
|
+
* the members inside were unreadable from the outside and the envelope check
|
|
1466
|
+
* could not reject what it could not see.
|
|
1467
|
+
*
|
|
1468
|
+
* ## Two producers, one inventory (#10861)
|
|
1469
|
+
*
|
|
1470
|
+
* The caller's `where` is not the only thing that reaches `engine.aggregate`
|
|
1471
|
+
* as a predicate. Since PR #10758 the compiled dataset's own definition-level
|
|
1472
|
+
* `filter` is lowered onto `execute()`'s `conjuncts` and rendered by
|
|
1473
|
+
* `generateSql()`, so a dataset declaring `filter: { 'account.region': 'West' }`
|
|
1474
|
+
* sent `{"$and":[{"account.region":"West"}]}` to an engine that cannot join —
|
|
1475
|
+
* measured on both doors, which AGREED in accepting it, so #10759's
|
|
1476
|
+
* preview/execution symmetry had nothing to restore. Refusing it is a
|
|
1477
|
+
* widening of the refusal set, ruled by the maintainer on 2026-08-22 (Option
|
|
1478
|
+
* A, query-time refusal): fold the scope's leaves in HERE, where driver
|
|
1479
|
+
* capability is known, rather than in `dataset-compiler.ts`, which cannot see
|
|
1480
|
+
* which driver will serve the dataset and would refuse a dataset that is
|
|
1481
|
+
* perfectly legal on a native-SQL deployment.
|
|
1482
|
+
*
|
|
1483
|
+
* Structure is discarded on purpose — a member is cross-object or it is not,
|
|
1484
|
+
* and which branch of a disjunction it sits in cannot make
|
|
1485
|
+
* `engine.aggregate` able to join it. PROVENANCE is not discarded, because it
|
|
1486
|
+
* decides what the refusal can tell the caller to go fix: `AnalyticsRequestKey`
|
|
1487
|
+
* is the analytics REQUEST vocabulary and a dataset's `filter` is not in it,
|
|
1488
|
+
* so a scope-borne member must not be reported as `param: 'where'` — see
|
|
1489
|
+
* `planCrossObject`. The value slot carries that and nothing else; it never
|
|
1490
|
+
* reaches a driver.
|
|
1491
|
+
*
|
|
1492
|
+
* Dataset leaves are inserted FIRST so a member named by BOTH producers keeps
|
|
1493
|
+
* the caller's provenance (last write wins on a duplicate key): if it is in
|
|
1494
|
+
* the request too, the request is the actionable place to fix it.
|
|
1495
|
+
*
|
|
1496
|
+
* Time-dimension WINDOWS are deliberately absent (they live in
|
|
1497
|
+
* `dateRangeBounds`, not in `where`). They need no arm here: a cross-object
|
|
1498
|
+
* time dimension is refused by `planCrossObject`'s own first loop, over
|
|
1499
|
+
* `query.timeDimensions`, and refused as the time dimension the author wrote
|
|
1500
|
+
* rather than as the lowered predicate it becomes — which is the better
|
|
1501
|
+
* diagnostic and the reason that loop runs first.
|
|
1502
|
+
*/
|
|
1503
|
+
private filterMemberView;
|
|
1419
1504
|
/**
|
|
1420
1505
|
* Plan how to serve cross-object references on this join-less path (#3654).
|
|
1421
1506
|
*
|
|
@@ -1426,21 +1511,36 @@ declare class ObjectQLStrategy implements AnalyticsStrategy {
|
|
|
1426
1511
|
* query (direct path), a plan for an in-envelope cross-object query.
|
|
1427
1512
|
*
|
|
1428
1513
|
* THROWS for anything outside the envelope — a cross-object MEASURE or FILTER
|
|
1429
|
-
* (needs a real join to evaluate), a
|
|
1514
|
+
* (needs a real join to evaluate), a cross-object leaf in the DATASET's own
|
|
1515
|
+
* definition-level `filter` (#10861 — same join it does not have, arriving
|
|
1516
|
+
* from the producer PR #10758 added), a MULTI-HOP dimension (`a.b.c`), or a
|
|
1430
1517
|
* non-recombinable measure (`avg`/`count_distinct`, whose sub-bucket values
|
|
1431
1518
|
* cannot be merged). A loud error beats the silent mis-bucket #3654 kills.
|
|
1432
|
-
* `generateSql()` calls this too, so the preview accepts/rejects the same set
|
|
1433
|
-
*
|
|
1434
|
-
*
|
|
1435
|
-
*
|
|
1436
|
-
*
|
|
1437
|
-
*
|
|
1438
|
-
*
|
|
1439
|
-
*
|
|
1440
|
-
*
|
|
1441
|
-
*
|
|
1442
|
-
*
|
|
1443
|
-
*
|
|
1519
|
+
* `generateSql()` calls this too, so the preview accepts/rejects the same set
|
|
1520
|
+
* — and since #10759 both callers derive `filter` from the one
|
|
1521
|
+
* {@link filterMemberView}, so that sentence is enforced by construction
|
|
1522
|
+
* instead of restated at two call sites.
|
|
1523
|
+
*
|
|
1524
|
+
* [#5716] All five refusals below are `invalidMemberError` — `INVALID_FIELD` /
|
|
1525
|
+
* 400, naming the member — and the four that predate #10861 keep their
|
|
1526
|
+
* MESSAGES unchanged (they are good diagnostics, and #5923's tests read
|
|
1527
|
+
* them). Each is decided by two facts and nothing else: a member that will
|
|
1528
|
+
* reach the engine's predicate, and whether that member resolves across a
|
|
1529
|
+
* join. Neither is an internal invariant — a cube where the member exists and
|
|
1530
|
+
* a driver that could serve it are both perfectly ordinary, which is exactly
|
|
1531
|
+
* what the "run this on a native-SQL driver" half of every message says. They
|
|
1532
|
+
* are member-level rather than dataset-level (hence not `datasetInvalidError`)
|
|
1533
|
+
* because the fix is always to change or drop ONE named member, and because
|
|
1534
|
+
* four of them fire on `/analytics/query` where no dataset exists.
|
|
1535
|
+
*
|
|
1536
|
+
* [#10861] The fifth is the exception that proves the rule and is written to
|
|
1537
|
+
* it: it can only fire where a dataset DOES exist, and it is the one refusal
|
|
1538
|
+
* here whose member no request key named — so it carries `cube` and no
|
|
1539
|
+
* `param`, and says in its own words which document to go and edit. It stays
|
|
1540
|
+
* `INVALID_FIELD` rather than becoming `DATASET_INVALID` because the verdict
|
|
1541
|
+
* is the same physical one as its neighbour — this engine cannot join this
|
|
1542
|
+
* member — and splitting the code by PROVENANCE would make a caller branch on
|
|
1543
|
+
* two wire shapes for one capability limit.
|
|
1444
1544
|
*
|
|
1445
1545
|
* Detection is on RESOLVED field names, so a dotted dimension the cube
|
|
1446
1546
|
* flattens to a real column is treated as base, not cross-object.
|
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}.
|
|
@@ -1281,6 +1308,11 @@ declare class NativeSQLStrategy implements AnalyticsStrategy {
|
|
|
1281
1308
|
*/
|
|
1282
1309
|
private lookupMember;
|
|
1283
1310
|
private resolveDimensionSql;
|
|
1311
|
+
/**
|
|
1312
|
+
* @param predicate - The measure's own scoped filter, already compiled to a
|
|
1313
|
+
* SQL boolean (`null` = the measure declares none, or declares one that
|
|
1314
|
+
* constrains nothing — `compileFilterNode`'s TRUE). #10298.
|
|
1315
|
+
*/
|
|
1284
1316
|
private resolveMeasureSql;
|
|
1285
1317
|
private resolveFieldSql;
|
|
1286
1318
|
/**
|
|
@@ -1416,6 +1448,59 @@ declare class ObjectQLStrategy implements AnalyticsStrategy {
|
|
|
1416
1448
|
private withReadScope;
|
|
1417
1449
|
/** Is `field` a resolved cross-object (relationship-traversal) reference? */
|
|
1418
1450
|
private isCrossObjectField;
|
|
1451
|
+
/**
|
|
1452
|
+
* The member view {@link planCrossObject} judges a filter by: EVERY member
|
|
1453
|
+
* that will end up in the engine's predicate, structure discarded, keyed by
|
|
1454
|
+
* RESOLVED field name (#10759), valued by WHERE THE MEMBER CAME FROM
|
|
1455
|
+
* (#10861).
|
|
1456
|
+
*
|
|
1457
|
+
* Both call sites — `execute()` and `generateSql()` — are handed this and
|
|
1458
|
+
* nothing else, which is what makes the invariant `planCrossObject` states
|
|
1459
|
+
* for itself ("the preview accepts/rejects the same set") structural rather
|
|
1460
|
+
* than a coincidence maintained by hand. They used to build the view
|
|
1461
|
+
* separately: the echo flattened the tree, `execute()` passed the ENGINE
|
|
1462
|
+
* FILTER, and a filter record answers a different question — it is a
|
|
1463
|
+
* predicate to evaluate, not an inventory of members. An `$or`, a `$not` or
|
|
1464
|
+
* an unmergeable nested `$and` travels in it as one opaque `$and` entry, so
|
|
1465
|
+
* the members inside were unreadable from the outside and the envelope check
|
|
1466
|
+
* could not reject what it could not see.
|
|
1467
|
+
*
|
|
1468
|
+
* ## Two producers, one inventory (#10861)
|
|
1469
|
+
*
|
|
1470
|
+
* The caller's `where` is not the only thing that reaches `engine.aggregate`
|
|
1471
|
+
* as a predicate. Since PR #10758 the compiled dataset's own definition-level
|
|
1472
|
+
* `filter` is lowered onto `execute()`'s `conjuncts` and rendered by
|
|
1473
|
+
* `generateSql()`, so a dataset declaring `filter: { 'account.region': 'West' }`
|
|
1474
|
+
* sent `{"$and":[{"account.region":"West"}]}` to an engine that cannot join —
|
|
1475
|
+
* measured on both doors, which AGREED in accepting it, so #10759's
|
|
1476
|
+
* preview/execution symmetry had nothing to restore. Refusing it is a
|
|
1477
|
+
* widening of the refusal set, ruled by the maintainer on 2026-08-22 (Option
|
|
1478
|
+
* A, query-time refusal): fold the scope's leaves in HERE, where driver
|
|
1479
|
+
* capability is known, rather than in `dataset-compiler.ts`, which cannot see
|
|
1480
|
+
* which driver will serve the dataset and would refuse a dataset that is
|
|
1481
|
+
* perfectly legal on a native-SQL deployment.
|
|
1482
|
+
*
|
|
1483
|
+
* Structure is discarded on purpose — a member is cross-object or it is not,
|
|
1484
|
+
* and which branch of a disjunction it sits in cannot make
|
|
1485
|
+
* `engine.aggregate` able to join it. PROVENANCE is not discarded, because it
|
|
1486
|
+
* decides what the refusal can tell the caller to go fix: `AnalyticsRequestKey`
|
|
1487
|
+
* is the analytics REQUEST vocabulary and a dataset's `filter` is not in it,
|
|
1488
|
+
* so a scope-borne member must not be reported as `param: 'where'` — see
|
|
1489
|
+
* `planCrossObject`. The value slot carries that and nothing else; it never
|
|
1490
|
+
* reaches a driver.
|
|
1491
|
+
*
|
|
1492
|
+
* Dataset leaves are inserted FIRST so a member named by BOTH producers keeps
|
|
1493
|
+
* the caller's provenance (last write wins on a duplicate key): if it is in
|
|
1494
|
+
* the request too, the request is the actionable place to fix it.
|
|
1495
|
+
*
|
|
1496
|
+
* Time-dimension WINDOWS are deliberately absent (they live in
|
|
1497
|
+
* `dateRangeBounds`, not in `where`). They need no arm here: a cross-object
|
|
1498
|
+
* time dimension is refused by `planCrossObject`'s own first loop, over
|
|
1499
|
+
* `query.timeDimensions`, and refused as the time dimension the author wrote
|
|
1500
|
+
* rather than as the lowered predicate it becomes — which is the better
|
|
1501
|
+
* diagnostic and the reason that loop runs first.
|
|
1502
|
+
*/
|
|
1503
|
+
private filterMemberView;
|
|
1419
1504
|
/**
|
|
1420
1505
|
* Plan how to serve cross-object references on this join-less path (#3654).
|
|
1421
1506
|
*
|
|
@@ -1426,21 +1511,36 @@ declare class ObjectQLStrategy implements AnalyticsStrategy {
|
|
|
1426
1511
|
* query (direct path), a plan for an in-envelope cross-object query.
|
|
1427
1512
|
*
|
|
1428
1513
|
* THROWS for anything outside the envelope — a cross-object MEASURE or FILTER
|
|
1429
|
-
* (needs a real join to evaluate), a
|
|
1514
|
+
* (needs a real join to evaluate), a cross-object leaf in the DATASET's own
|
|
1515
|
+
* definition-level `filter` (#10861 — same join it does not have, arriving
|
|
1516
|
+
* from the producer PR #10758 added), a MULTI-HOP dimension (`a.b.c`), or a
|
|
1430
1517
|
* non-recombinable measure (`avg`/`count_distinct`, whose sub-bucket values
|
|
1431
1518
|
* cannot be merged). A loud error beats the silent mis-bucket #3654 kills.
|
|
1432
|
-
* `generateSql()` calls this too, so the preview accepts/rejects the same set
|
|
1433
|
-
*
|
|
1434
|
-
*
|
|
1435
|
-
*
|
|
1436
|
-
*
|
|
1437
|
-
*
|
|
1438
|
-
*
|
|
1439
|
-
*
|
|
1440
|
-
*
|
|
1441
|
-
*
|
|
1442
|
-
*
|
|
1443
|
-
*
|
|
1519
|
+
* `generateSql()` calls this too, so the preview accepts/rejects the same set
|
|
1520
|
+
* — and since #10759 both callers derive `filter` from the one
|
|
1521
|
+
* {@link filterMemberView}, so that sentence is enforced by construction
|
|
1522
|
+
* instead of restated at two call sites.
|
|
1523
|
+
*
|
|
1524
|
+
* [#5716] All five refusals below are `invalidMemberError` — `INVALID_FIELD` /
|
|
1525
|
+
* 400, naming the member — and the four that predate #10861 keep their
|
|
1526
|
+
* MESSAGES unchanged (they are good diagnostics, and #5923's tests read
|
|
1527
|
+
* them). Each is decided by two facts and nothing else: a member that will
|
|
1528
|
+
* reach the engine's predicate, and whether that member resolves across a
|
|
1529
|
+
* join. Neither is an internal invariant — a cube where the member exists and
|
|
1530
|
+
* a driver that could serve it are both perfectly ordinary, which is exactly
|
|
1531
|
+
* what the "run this on a native-SQL driver" half of every message says. They
|
|
1532
|
+
* are member-level rather than dataset-level (hence not `datasetInvalidError`)
|
|
1533
|
+
* because the fix is always to change or drop ONE named member, and because
|
|
1534
|
+
* four of them fire on `/analytics/query` where no dataset exists.
|
|
1535
|
+
*
|
|
1536
|
+
* [#10861] The fifth is the exception that proves the rule and is written to
|
|
1537
|
+
* it: it can only fire where a dataset DOES exist, and it is the one refusal
|
|
1538
|
+
* here whose member no request key named — so it carries `cube` and no
|
|
1539
|
+
* `param`, and says in its own words which document to go and edit. It stays
|
|
1540
|
+
* `INVALID_FIELD` rather than becoming `DATASET_INVALID` because the verdict
|
|
1541
|
+
* is the same physical one as its neighbour — this engine cannot join this
|
|
1542
|
+
* member — and splitting the code by PROVENANCE would make a caller branch on
|
|
1543
|
+
* two wire shapes for one capability limit.
|
|
1444
1544
|
*
|
|
1445
1545
|
* Detection is on RESOLVED field names, so a dotted dimension the cube
|
|
1446
1546
|
* flattens to a real column is treated as base, not cross-object.
|