-
Notifications
You must be signed in to change notification settings - Fork 10
Expand file tree
/
Copy pathplugin.ts
More file actions
1064 lines (1030 loc) · 53.9 KB
/
Copy pathplugin.ts
File metadata and controls
1064 lines (1030 loc) · 53.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
import type { Plugin, PluginContext } from '@objectstack/core';
import type { Cube, FilterCondition } from '@objectstack/spec/data';
import { AggregationFunction } from '@objectstack/spec/data';
import type { ExecutionContext } from '@objectstack/spec/kernel';
import type { IAnalyticsService, IDataDriver, IDataEngine, IObjectQLEngine, II18nService } from '@objectstack/spec/contracts';
import { translateObject, type ObjectLike, type ObjectFieldLike, type TranslationBundle } from '@objectstack/spec/system';
import { AnalyticsService } from './analytics-service.js';
import type { AnalyticsServiceConfig } from './analytics-service.js';
import type { AnalyticsDriverCapabilities } from './strategies/types.js';
import { pickDisplayField, type DimensionLabelDeps } from './dimension-labels.js';
import { assertReadScopeCannotVacate } from './read-scope-sql.js';
/**
* The slice of the DECLARED engine contracts this plugin's auto-bridges
* consume, derived from `IDataEngine` / `IObjectQLEngine` instead of being
* re-declared structurally (#4251 B3, extending #11493's ruling and the
* datasource half of #11833 that landed as PR #12011).
*
* A consumer-local structural re-declaration meets no compiler on the PRODUCER
* side, so engine-surface drift lands here silently. This seam carried exactly
* that: the hand-written `aggregate` declared `aggregations[].function` as
* `string` where the contract declares a six-value enum, and nothing compiled
* the two against each other.
*
* Three of these members could not be named from a contract until #12248
* landed the #11833 ruling: `resolveEffectiveDatasource` and
* `getDriverForObject` (fork 1, declared OPTIONAL exactly so seams like this
* one keep degrading), and `getObject`'s structured `ServiceObject` return
* (fork 3, which used to be `unknown`). Before those, the structural type was
* the only way to name them at all.
*
* ## Optionality is load-bearing, and this preserves the profile exactly
*
* `aggregate` stays REQUIRED, as the hand-written type had it: it is the
* member a `'data'` service must expose for this bridge to exist, and the
* runtime `typeof svc.aggregate === 'function'` probe below is what decides
* whether a registered service qualifies.
*
* Everything else stays OPTIONAL. A lightweight kernel can register a `'data'`
* service with no raw-SQL escape hatch, no datasource registry and no schema
* registry; the `engine?.x` / `typeof … === 'function'` probes below are the
* other half of that graceful-degradation contract. `getObject` is REQUIRED on
* `IObjectQLEngine`, so the `Partial<>` around it is not decoration — it is
* what keeps this seam usable against an engine that is not ObjectQL.
*/
type DataEngineLike =
Pick<IDataEngine, 'aggregate'>
& Partial<Pick<IDataEngine, 'execute' | 'resolveEffectiveDatasource' | 'getDriverForObject'>>
& Partial<Pick<IObjectQLEngine, 'getObject'>>;
/**
* The slice of the `IDataDriver` CONTRACT the analytics layer consumes —
* `temporalFilterValue` / `temporalFilterColumnSql` are first-class contract
* members since ADR-0053 D-A2, no longer a duck-typed local invention.
*
* Since #12248 declared `IDataEngine.getDriverForObject?`, this is the
* RETURN-side narrowing that member's own docblock prescribes, applied at the
* two call sites below — not a re-declaration of the member. `Pick<IDataDriver,
* …>` admits the full contract value, so the engine keeps handing back whatever
* driver it registered while this seam states the only two members it reads.
* The runtime `typeof` guards below remain the correct way to consume an
* optional contract member.
*/
type TemporalDriverSurface = Pick<
IDataDriver,
'temporalFilterValue' | 'temporalFilterColumnSql'
>;
/**
* [#15684] The slice of a SQL driver that NAMES ITS OWN DIALECT — `'sqlite'` /
* `'postgres'` / `'mysql'` / `'unknown'`, `SqlDriver.dialectName`.
*
* Read STRUCTURALLY rather than through `IDataDriver`, unlike
* {@link TemporalDriverSurface} above, and the difference is deliberate: a
* dialect is a property of the SQL driver FAMILY (`SqlDriver` and the three
* drivers that extend it), not of every driver — the memory and mongo drivers
* have no dialect to name, and a contract member they can only answer
* `undefined` to declares a capability the platform does not have. The runtime
* `typeof` guard below is what makes the read safe either way, and the value
* is passed through verbatim: `text-match-sql.ts` normalises an unrecognised
* name to `'unknown'`, which compiles the `LIKE` these compilers always
* emitted.
*/
type DialectNamingDriver = { readonly dialectName?: unknown };
/**
* Re-parse a bridge-supplied aggregation `method` as the engine contract's
* `AggregationFunction` before it is forwarded as `function`, refusing
* anything else.
*
* Both sides now declare the same six-value enum: `IDataEngine.aggregate`'s
* `aggregations[].function`, and — since #12776 — the analytics strategy
* contract (`StrategyContext.executeAggregate`) plus the two consumer-local
* config mirrors this package keeps in lockstep with it (#12940). So this
* parse is DEFENCE IN DEPTH behind a compile-time check, not the only check
* (#11833).
*
* That is a reason to keep it, not to delete it. Types are erased: a
* JavaScript app supplying its own `executeAggregate`, or host drift arriving
* through a cube object that never met `CubeSchema`'s parse (the path
* `aggregate-bridge-function-vocabulary.test.ts` drives end to end), still
* reaches this seam carrying a method the engine does not declare. What the
* refusal buys is in `plugin.ts`'s forward below and in #12209: the engine is
* never handed a `function` no driver declares.
*
* Parsing with the spec's OWN enum keeps a single vocabulary — no local
* literal list to drift, and `AggregationFunction`'s error map already knows
* the retired `array_agg` / `string_agg` spellings.
*/
function parseEngineAggregateFunction(
method: AggregationFunction,
alias: string,
): NonNullable<Parameters<IDataEngine['aggregate']>[1]['aggregations']>[number]['function'] {
const parsed = AggregationFunction.safeParse(method);
if (!parsed.success) {
throw new Error(
`[Analytics] The aggregate bridge cannot forward the aggregation ` +
`"${alias}": "${method}" is not one of the engine's aggregate functions ` +
`(${AggregationFunction.options.join(', ')}). A custom-SQL measure is ` +
`refused earlier, with a caller-facing diagnostic, by ObjectQLStrategy; ` +
`reaching this point means the analytics layer produced a method the ` +
`engine contract does not declare.`,
);
}
return parsed.data;
}
/**
* Configuration for AnalyticsServicePlugin.
*/
export interface AnalyticsServicePluginOptions {
/** Pre-defined cube definitions (from manifest). */
cubes?: Cube[];
/**
* Probe driver capabilities for a given cube.
* When omitted, defaults to in-memory only.
*/
queryCapabilities?: (cubeName: string) => AnalyticsDriverCapabilities;
/**
* Execute raw SQL on a driver. Enables NativeSQLStrategy.
*/
executeRawSql?: (objectName: string, sql: string, params: unknown[]) => Promise<Record<string, unknown>[]>;
/**
* Execute ObjectQL aggregate. Enables ObjectQLStrategy.
*/
executeAggregate?: (objectName: string, options: {
groupBy?: string[];
/**
* The CUSTOM bridge's view of the aggregation entries — an app author's
* own `executeAggregate`, as opposed to the auto-bridge below. Mirrors
* `StrategyContext.executeAggregate`
* (`packages/spec/src/contracts/analytics-service.ts`) and must stay in
* lockstep with it; the two members that lockstep is load-bearing for:
*
* - `filter` (#10576, the #10413 contract field) — a custom bridge MUST
* forward it to the real engine the same way the auto-bridge does, or a
* measure-scoped filter this plugin lowers onto the aggregation
* silently never reaches storage.
* - `method` is the spec's OWN six-value `AggregationFunction`, not
* `string`: #12776 narrowed the contract, #12940 brought this mirror
* back into line. This is the declaration a custom-bridge author types
* their handler against, so it is where the compile-time vocabulary
* #12776 bought for strategy authors reaches them too.
*/
aggregations?: Array<{ field: string; method: AggregationFunction; alias: string; filter?: Record<string, unknown> }>;
filter?: Record<string, unknown>;
/** Reference timezone (IANA) for date bucketing — ADR-0053 Phase 2. */
timezone?: string;
/**
* ADR-0021 D-C (#3602) — the request's ExecutionContext. A custom bridge
* MUST forward it to its engine so engine-side RLS applies; dropping it is
* what made the built-in bridge fall open in #3597.
*/
context?: ExecutionContext;
}) => Promise<Record<string, unknown>[]>;
/**
* ADR-0021 D-C — context-aware per-object read scope (tenant + RLS). The
* runtime supplies this from its sharing middleware so the analytics raw-SQL
* path cannot bypass tenant isolation. Receives the request's ExecutionContext
* and returns the RLS `FilterCondition` for the object (what `RLSCompiler`
* emits). When omitted, the plugin auto-bridges to a registered `'security'`
* service exposing `getReadFilter(object, context)` if one is present.
*/
getReadScope?: (
objectName: string,
context?: ExecutionContext,
) =>
| FilterCondition
| null
| undefined
| Promise<FilterCondition | null | undefined>;
/**
* The OBJECT-LEVEL read admission — "may this caller read this object at
* all". The sibling of {@link AnalyticsServicePluginOptions.getReadScope} and
* NOT a substitute for it: the scope answers WHICH ROWS and answers
* `undefined` for a caller with no grant at all, so a door holding only the
* scope cannot tell "unrestricted" from "not permitted" — which is how the
* raw-SQL path served a row count for an object whose `/data` door answers
* 403. When omitted, the plugin auto-bridges to a registered `'security'`
* service, preferring `canReadObject(object, context)` and falling back to
* `explain({ object, operation: 'read' }, context).allowed`.
*/
admitObjectRead?: (
objectName: string,
context?: ExecutionContext,
) => boolean | Promise<boolean>;
/**
* ADR-0021 D-C — join allowlist per cube (the dataset's declared `include`).
* Typically wired from the dataset registry's compiled `allowedRelationships`.
*/
getAllowedRelationships?: (cubeName: string) => Set<string> | undefined;
/** Enable debug logging. */
debug?: boolean;
/**
* [#8286] Echo the executed statement back to CALLERS in
* `AnalyticsResult.sql` (`/api/v1/analytics/query`).
*
* Distinct from {@link AnalyticsServicePluginOptions.debug} above, which is
* server-side log verbosity only: raising log level must never widen what
* travels to a tenant. Default and rationale live with the service config —
* see `AnalyticsServiceConfig.debugSql`. Undefined here means "no host
* choice", which the service resolves to development-only.
*/
debugSql?: boolean;
}
/**
* AnalyticsServicePlugin — Kernel plugin for multi-driver analytics.
*
* Lifecycle:
* 1. **init** — Creates `AnalyticsService`, registers as `'analytics'` service.
* If an existing analytics service is already registered (e.g. MemoryAnalyticsService
* from dev-plugin), it is captured as the `fallbackService`.
* 2. **start** — Triggers `'analytics:ready'` hook so other plugins can
* register cubes or extend the service.
* 3. **destroy** — Cleans up references.
*
* @example
* ```ts
* import { LiteKernel } from '@objectstack/core';
* import { AnalyticsServicePlugin } from '@objectstack/service-analytics';
*
* const kernel = new LiteKernel();
* kernel.use(new AnalyticsServicePlugin({
* cubes: [ordersCube],
* queryCapabilities: (cube) => ({ nativeSql: true, objectqlAggregate: true, inMemory: false }),
* executeRawSql: async (obj, sql, params) => pgPool.query(sql, params).then(r => r.rows),
* }));
* await kernel.bootstrap();
*
* const analytics = kernel.getService<IAnalyticsService>('analytics');
* const result = await analytics.query({ cube: 'orders', measures: ['orders.count'] });
* ```
*/
export class AnalyticsServicePlugin implements Plugin {
name = 'com.objectstack.service-analytics';
/**
* Services init() registers on every path (ADR-0116, #4131) — lets the
* kernel name this plugin when a consumer requires one before it inits.
*/
providesServices = ['analytics'];
version = '1.0.0';
type = 'standard' as const;
dependencies: string[] = [];
/**
* init() probes the `data` engine ObjectQLPlugin provides for the
* auto-bridge — order-if-present so the probe verdict is deterministic
* (ADR-0116, #4471). Soft, not hard: without an engine the plugin
* degrades on purpose (per-query lazy resolution / explicit
* `executeAggregate`).
*/
optionalDependencies: string[] = ['com.objectstack.engine.objectql'];
private service?: AnalyticsService;
private readonly options: AnalyticsServicePluginOptions;
constructor(options: AnalyticsServicePluginOptions = {}) {
this.options = options;
}
async init(ctx: PluginContext): Promise<void> {
// Check if there is an existing analytics service (e.g. from dev-plugin)
let fallbackService: IAnalyticsService | undefined;
try {
const existing = ctx.getService<IAnalyticsService>('analytics');
if (existing && typeof existing.query === 'function') {
fallbackService = existing;
ctx.logger.debug('[Analytics] Found existing analytics service, using as fallback');
}
} catch {
// No existing service — that's fine
}
// Auto-bridge: when caller did not supply executeAggregate, look up the
// kernel's IDataEngine (registered as 'data' by ObjectQLPlugin) lazily and
// translate AnalyticsStrategy's `{method, filter}` shape into the engine's
// `{function, where}` shape. This lets users write
// `new AnalyticsServicePlugin({ cubes })`
// without re-implementing the bridge in every app.
let executeAggregate = this.options.executeAggregate;
let autoBridged = false;
if (!executeAggregate) {
const tryGetDataEngine = (): DataEngineLike | undefined => {
try {
const svc = ctx.getService<DataEngineLike>('data');
return svc && typeof svc.aggregate === 'function' ? svc : undefined;
} catch {
return undefined;
}
};
// Probe now (warn if missing) but resolve at call time so plugin order
// does not matter as long as 'data' exists by the time a query runs.
if (!tryGetDataEngine()) {
ctx.logger.warn(
'[Analytics] No "data" service registered yet at init; ' +
'will retry per-query. Register ObjectQLPlugin or pass executeAggregate.',
);
}
executeAggregate = async (objectName, { groupBy, aggregations, filter, timezone, context }) => {
const engine = tryGetDataEngine();
if (!engine) {
throw new Error(
'[Analytics] Cannot execute aggregate: no IDataEngine ("data") service is registered. ' +
'Add ObjectQLPlugin to the kernel or supply AnalyticsServicePlugin({ executeAggregate }).',
);
}
const rows = await engine.aggregate(objectName, {
where: filter,
groupBy,
// [#10413 phase 2 / #10576] `a.filter` is the per-aggregation
// predicate `ObjectQLStrategy` lowers a measure's own scoped
// `filter` into. This map already renames `method` → `function`
// for the engine's own vocabulary; dropping `filter` here — as this
// bridge did before this line existed — would have made the
// strategy's lowering a NO-OP on every real deployment that boots
// through this auto-bridge (the default path: `new
// AnalyticsServicePlugin({ cubes })` with no custom
// `executeAggregate`), passing every unit test that stubs
// `executeAggregate` directly while silently dropping the filter in
// production — the exact declared-≠-enforced shape Prime Directive
// #10 calls out. Omitted (not `filter: undefined`) when the
// aggregation carries none, matching the engine's own
// vacuous-filter convention.
aggregations: aggregations?.map((a) => ({
// [#11833] `function` is the engine contract's SIX-value
// `AggregationFunction`. This bridge's own input declared
// `method: string` when that history was written
// (`StrategyContext.executeAggregate`, spec
// `contracts/analytics-service.ts`), so the two ends of this
// rename spoke different vocabularies: narrowing the engine side
// to the contract turned the forward into a compile error — the
// correct signal, and the one the deleted structural type hid by
// declaring `function: string` on both sides.
//
// Since #12776 (contract) and #12940 (this plugin's own config
// mirror above), BOTH ends declare the enum, so the rename is
// enum-to-enum and the parse below is defence in depth behind a
// compile-time check rather than the only check — see
// `parseEngineAggregateFunction` for why erased types still leave
// it load-bearing.
//
// It was closed by PARSING with the spec enum itself rather than
// by widening back to `string` (what hid it) or casting past it
// (which keeps the hole and adds a lie). `AggregationFunction` is
// the same schema `AggregationNodeSchema.function` is built from,
// so there is one vocabulary, and its own error map already
// carries the `array_agg`/`string_agg` retirement prescriptions.
//
// TIERING, deliberately: the reachable producer of a non-aggregate
// method — a custom-SQL measure (`AggregationMetricType`
// `number`/`string`/`boolean`) — is already refused upstream with a
// caller-blaming 400 by `ObjectQLStrategy.resolveMeasureAggregation`
// (#12209). Anything still arriving here is host drift, which that
// refusal's docblock assigns to the undeclared-500 tier — so this
// throws rather than re-blaming the caller, and it answers loudly
// instead of letting the engine answer `null` per bucket under the
// author's own measure name (the #4157 class).
function: parseEngineAggregateFunction(a.method, a.alias),
field: a.field,
alias: a.alias,
...(a.filter ? { filter: a.filter } : {}),
})),
// ADR-0053 Phase 2: thread the reference tz so date buckets resolve on
// that zone's calendar days (engine buckets in-memory when non-UTC).
timezone,
// ADR-0021 D-C (#3602): thread the caller's identity so the engine's
// middleware chain scopes the read itself. `BaseEngineOptions.context`
// is `.optional()`, so nothing ever forced this bridge to pass it —
// and it did not, which is how an authenticated aggregate reached the
// engine with no principal and plugin-security fell open (#3597).
context,
});
return rows as Record<string, unknown>[];
};
autoBridged = true;
}
// Auto-bridge raw SQL when the data engine exposes `execute()` and the
// caller did not supply their own `executeRawSql`. This unlocks
// NativeSQLStrategy (priority 10) which can emit `LEFT JOIN`s for
// dotted dimension/measure references like `account.industry`.
let executeRawSql = this.options.executeRawSql;
let autoBridgedRawSql = false;
if (!executeRawSql) {
const tryGetExecutor = (): DataEngineLike | undefined => {
try {
const svc = ctx.getService<DataEngineLike>('data');
return svc && typeof svc.execute === 'function' ? svc : undefined;
} catch {
return undefined;
}
};
// Always wire the bridge — resolution happens at call time, mirroring
// the executeAggregate auto-bridge above. This way plugin-init order
// does not matter as long as `data` exists by the time a query runs.
executeRawSql = async (objectName, sql, params) => {
const engine = tryGetExecutor();
if (!engine || !engine.execute) {
throw new Error(
'[Analytics] Cannot execute raw SQL: no IDataEngine ("data") service with execute() is registered.',
);
}
// NativeSQLStrategy emits `$1, $2, …` placeholders. Knex (used by
// driver-sql) speaks `?` placeholders, so translate.
const knexSql = sql.replace(/\$(\d+)/g, '?');
// #5033 — `object` is ObjectQL's FIRST driver-selection key. This bridge
// received the object name and dropped it, so every dataset raw-SQL read
// fell through to the DEFAULT driver while the object-routed path
// (`executeAggregate` → `engine.aggregate(objectName, …)`, right above)
// resolved the object's own datasource. The two dataset execution paths
// must give ONE answer to "which datasource is this object in": an
// object routed elsewhere (ADR-0057 §3.6 telemetry split, an explicit
// `object.datasource`, a `datasourceMapping` rule) read `no such table`
// on the default DB and degraded to a confident `0` over live rows.
const result = await engine.execute(knexSql, { args: params, object: objectName });
// A driver that cannot run SQL (e.g. the in-memory driver) returns
// null from execute(). Silently mapping that to [] made EVERY dataset
// query on such environments report "No rows" while looking healthy
// (HTTP 200, compiled SQL attached). Throw a TYPED error instead so
// the orchestrator can fall back to an aggregate-based strategy —
// never fabricate an empty result.
if (result === null || result === undefined) {
const err = new Error(
'[Analytics] The "data" engine\'s driver returned null for raw SQL — ' +
'this driver does not support SQL execution. The query will fall back ' +
'to an aggregate-based strategy when one is available.',
) as Error & { code: string };
err.code = 'RAW_SQL_UNSUPPORTED';
throw err;
}
if (Array.isArray(result)) return result as Record<string, unknown>[];
if (typeof result === 'object' && 'rows' in (result as Record<string, unknown>)) {
return (result as { rows: Record<string, unknown>[] }).rows;
}
return [];
};
autoBridgedRawSql = true;
}
// Default capabilities: when we have an aggregate bridge, advertise
// ObjectQL support so ObjectQLStrategy is selected. Callers can still
// override via options.queryCapabilities.
const queryCapabilities = this.options.queryCapabilities
?? (() => ({
nativeSql: !!executeRawSql,
objectqlAggregate: !!executeAggregate,
inMemory: false,
}));
// ADR-0021 D-C — wire the read-scope provider. Prefer an explicit option;
// otherwise auto-bridge to a registered `'security'` service that exposes
// `getReadFilter(object, context)` (resolved at call time so plugin-init
// order does not matter). This keeps analytics decoupled from security.
interface SecurityReadFilter {
getReadFilter(
object: string,
context?: ExecutionContext,
):
| FilterCondition
| null
| undefined
| Promise<FilterCondition | null | undefined>;
}
let getReadScope = this.options.getReadScope;
let autoBridgedReadScope = false;
let securityPresentAtInit = false;
if (!getReadScope) {
const trySecurity = (): SecurityReadFilter | undefined => {
try {
const svc = ctx.getService<SecurityReadFilter>('security');
return svc && typeof svc.getReadFilter === 'function' ? svc : undefined;
} catch {
return undefined;
}
};
// ALWAYS wire the bridge — resolution happens at call time, mirroring the
// executeAggregate / executeRawSql auto-bridges above. Gating the
// ASSIGNMENT on an init-time probe (as this did) made analytics RLS
// silently plugin-ORDER-DEPENDENT: a kernel that registers this plugin
// before the security plugin got NO read-scope provider at all, so every
// strategy ran unscoped and only a WARN marked it. The repo's own
// `bootStack` harness registers in exactly that order, which is why no
// dogfood test could ever observe analytics RLS.
securityPresentAtInit = !!trySecurity();
getReadScope = (object, context) => trySecurity()?.getReadFilter(object, context);
autoBridgedReadScope = true;
}
// The OBJECT-LEVEL half of the same read, bridged the same way and for the
// same reason the scope is: analytics stays decoupled from security, and
// resolution happens at CALL time so plugin-registration order does not
// decide whether the gate exists.
//
// ## Why there are two spellings and neither of them is "admit"
//
// `canReadObject` is the direct answer and the one this repo's
// `plugin-security` serves. A security service that predates it is still a
// conforming `ISecurityService`, and the fallback for such a service is NOT
// to admit — falling open on absence is exactly the defect this gate
// closes. It is `explain`, which is NOT optional on that contract and whose
// `allowed` is the same bottom line ("would the middleware allow this
// operation?") computed by the same enforcement walk. `explain` is the
// heavier call, which is why it is the fallback and not the primary; it
// never fires against an in-repo stack.
//
// ## The three resolutions, and why only ONE of them admits
//
// "No security service" and "the security service could not be used" are
// different states and they get opposite answers. Collapsing them is the
// shape of the defect this whole change removes, one level up.
//
// ABSENT — `getService('security')` returns nothing. There is no
// object-level gate on this deployment at all, including on
// `/data`, because that gate IS this plugin's absent
// middleware. The two doors still agree, which is the
// equivalence property the card asks for, so this ADMITS and
// is reported loudly at init below.
// UNUSABLE — a security service exists but cannot answer: resolving it
// THREW, or the object it returned carries neither
// `canReadObject` nor `explain`. This is a wired-but-broken
// provider, and `/data`'s middleware does NOT fall open in
// that state — so admitting here would reopen the exact
// divergence between the two doors that this PR closes, and
// it would do it silently. It DENIES, and says why.
// USABLE — ask it (below).
//
// The distinction is worth the type: both unusable corners used to be
// spelled `return undefined` beside the absent one, and three lines later
// all three read `if (!svc) return true`. A deployment whose security
// service throws on resolution is not a deployment without security.
interface SecurityReadAdmission {
canReadObject?(object: string, context?: ExecutionContext): boolean | Promise<boolean>;
explain?(
request: { object: string; operation: string },
callerContext?: ExecutionContext,
): Promise<{ allowed?: boolean }>;
}
type SecurityAdmissionResolution =
| { kind: 'usable'; svc: SecurityReadAdmission }
| { kind: 'absent' }
| { kind: 'unusable'; why: string };
let admitObjectRead = this.options.admitObjectRead;
let autoBridgedReadAdmission = false;
if (!admitObjectRead) {
const trySecurityAdmission = (): SecurityAdmissionResolution => {
let svc: SecurityReadAdmission | undefined;
try {
svc = ctx.getService<SecurityReadAdmission>('security');
} catch (e) {
// ⛔ Not `absent`. A throwing resolver is a service that exists and
// failed, and a failed security lookup is a refusal everywhere else
// in this stack.
return {
kind: 'unusable',
why:
`resolving the "security" service threw ` +
`(${String((e as Error)?.message ?? e)})`,
};
}
if (!svc) return { kind: 'absent' };
if (typeof svc.canReadObject !== 'function' && typeof svc.explain !== 'function') {
// A registered service that answers neither question cannot admit
// anything. `explain` is NON-optional on `ISecurityService`, so a
// conforming provider never lands here — reaching it means the
// registered object is not the contract it claims to be.
return {
kind: 'unusable',
why:
'the registered "security" service exposes neither canReadObject() ' +
'nor explain(), so it cannot answer an object-level read admission',
};
}
return { kind: 'usable', svc };
};
admitObjectRead = async (object, context) => {
const resolved = trySecurityAdmission();
// No security service resolved at call time → no object-level gate on
// this deployment, which is the state reported at init.
if (resolved.kind === 'absent') return true;
if (resolved.kind === 'unusable') {
ctx.logger.error(
`[Analytics] object-level read admission could not be resolved for "${object}" — ` +
`denying the query (fail-closed): ${resolved.why}. ` +
'A security service is wired on this deployment, so analytics must not fall open: ' +
'GET /data/' + object + ' does not.',
);
return false;
}
const svc = resolved.svc;
if (typeof svc.canReadObject === 'function') {
return await svc.canReadObject(object, context);
}
const decision = await svc.explain!({ object, operation: 'read' }, context);
// A conforming `explain` always answers `allowed`; a shape that does
// not is a broken provider, and the fail-closed reading is the only
// safe one here.
return decision?.allowed === true;
};
autoBridgedReadAdmission = true;
}
// ADR-0021 — relationship → target-object resolver. A dataset's `include`
// names lookup/master_detail FIELDS on the base object; the joined TABLE is
// each field's `reference` target (which can differ from the field name,
// e.g. lookup `account` → object `crm_account`). Resolve from the 'data'
// engine's object schema at compile time so cross-object joins target the
// right table. Resolved lazily so plugin-init order doesn't matter.
const relationshipResolver = (baseObject: string, relationshipName: string): string | undefined => {
const engine = (() => {
try {
const svc = ctx.getService<DataEngineLike>('data');
return svc && typeof svc.getObject === 'function' ? svc : undefined;
} catch { return undefined; }
})();
const obj = engine?.getObject?.(baseObject);
const field = obj?.fields?.[relationshipName];
if (field && (field.type === 'lookup' || field.type === 'master_detail') && field.reference) {
return field.reference;
}
// Unknown to the schema — fall back to the relationship name as the table
// (legacy same-name convention). Returning undefined would make the
// compiler reject the dataset; the name-as-table fallback is safer for
// engines that don't expose getObject.
return engine ? undefined : relationshipName;
};
// ADR-0021 — dimension display-label resolution. `queryDataset` groups by a
// dimension's raw stored value; for `select` fields the user-facing text is
// the option label, and for `lookup`/`master_detail` fields it's the related
// record's display name. Wire the two low-level capabilities the resolver
// needs from the 'data' engine (resolved lazily so plugin-init order is free):
// - field metadata (select options + lookup target), via getObject
// - id→name pairs, via the executeAggregate bridge (group by id + name)
const dataEngine = (): DataEngineLike | undefined => {
try {
const svc = ctx.getService<DataEngineLike>('data');
return svc && typeof svc.getObject === 'function' ? svc : undefined;
} catch { return undefined; }
};
// #16773 — a select option's `label` (`SelectOptionSchema.label`) is a
// PLAIN authored string; its translation, if any, lives in an i18n
// TRANSLATION BUNDLE, not on the field metadata `getObjectFields` reads.
// Resolved lazily, same as `dataEngine` above, so plugin-init order is
// free and a kernel with no i18n service configured degrades to exactly
// today's (locale-blind, authored-label) behaviour.
const i18nService = (): II18nService | undefined => {
try {
const svc = ctx.getService<II18nService>('i18n');
return svc && typeof svc.getTranslations === 'function' && typeof svc.getLocales === 'function'
? svc
: undefined;
} catch { return undefined; }
};
// Mirrors `RestServer.buildTranslationBundle` (`packages/rest`) — this
// package has no dependency on `packages/rest`, so the ~10-line glue that
// turns an `II18nService` into a `TranslationBundle` is rebuilt here
// against the SAME public `II18nService` surface. This is NOT a second
// "translate a select option label" implementation: the actual lookup
// stays exactly one function, `translateObject` below, imported rather
// than reimplemented.
const buildTranslationBundle = (i18n: II18nService): TranslationBundle | undefined => {
const locales = i18n.getLocales();
if (!locales.length) return undefined;
const bundle: TranslationBundle = {};
for (const locale of locales) {
const data = i18n.getTranslations(locale);
if (data && typeof data === 'object') (bundle as Record<string, unknown>)[locale] = data;
}
return Object.keys(bundle).length ? bundle : undefined;
};
const labelResolver: DimensionLabelDeps = {
getObjectFields: (objectName) => dataEngine()?.getObject?.(objectName)?.fields,
fetchRecordLabels: async (targetObject, ids, scope, context) => {
const map = new Map<unknown, string>();
const displayField = pickDisplayField(dataEngine()?.getObject?.(targetObject)?.fields);
if (!displayField || !executeAggregate || ids.length === 0) return map;
// [#14329] The FOURTH read-scope door, and the same guard the other
// three answer with. #13640 guarded the ObjectQL engine merge and
// #13926 the echo merge and `NativeSQLStrategy.applyReadScope`; this
// hook is a fourth consumer of the same `readScopeProvider` output
// that meets NEITHER `compileScopedFilterToSql` nor the guard — the
// `$and` below hands the scope straight to `executeAggregate`, so a
// vacating spelling (`$not` over `$in: []` and its measured siblings,
// reachable from any out-of-repo `getReadScope` producer the
// `StrategyContext` spec contract admits) used to let this per-record
// read run effectively unscoped for the ids in hand — leaking exactly
// the display names the referenced object's RLS exists to hide.
//
// Placement mirrors `ObjectQLStrategy.resolveFkAttr`, this hook's
// structural twin (same id-`$in` `$and` scope, same `executeAggregate`,
// guarded since #13640): AFTER the early returns, because a call that
// reads nothing cannot widen anything and refusing it would be pure
// over-denial; and BEFORE the chunk loop, so one scope gets one verdict
// rather than one per 500 ids. The condition is spelled to match the
// composition on the next line exactly, so the set of scopes guarded
// and the set of scopes `$and`-ed are provably the same set.
//
// ⛔ Zero compiler change: the #13571 lowering residue is ruled and
// untouched. This guard is the walk, not the lowering.
if (scope) assertReadScopeCannotVacate(scope, targetObject);
// #3680 — the sort-key pass hands over the PRE-window id set (every
// grouped value, not just the displayed page), so a high-cardinality
// lookup dimension can push thousands of ids through here. Chunk the
// `$in` so the bound-parameter count stays under every driver's limit
// (SQLite's historic floor is 999 variables).
const CHUNK = 500;
for (let i = 0; i < ids.length; i += CHUNK) {
// #3602 — AND the referenced object's own read scope into the id filter,
// with `$and` (never key-merge) so it cannot be displaced by the id
// predicate — the same composition the strategy uses for the aggregate.
// Without it this per-record read leaks display names the target's RLS
// would hide (fires when the referenced object is stricter than the base).
const idFilter: Record<string, unknown> = { id: { $in: ids.slice(i, i + CHUNK) } };
const filter = scope ? { $and: [idFilter, scope] } : idFilter;
// Group by (id, displayField) — one row per record — reusing the aggregate
// bridge rather than adding a record-fetch capability. A count keeps engines
// that require ≥1 aggregation happy; the count itself is unused.
const rows = await executeAggregate(targetObject, {
groupBy: ['id', displayField],
aggregations: [{ field: 'id', method: 'count', alias: '_c' }],
filter,
// #3602 second belt — `scope` above is the analytics layer's own
// predicate on this per-record read; the context makes the engine's
// middleware scope it as well.
context,
});
for (const r of rows) {
if (r.id != null && r[displayField] != null) map.set(r.id, String(r[displayField]));
}
}
return map;
},
// #16773 — route a select option label through the SAME translator the
// object-metadata REST endpoint uses (`GET /meta/object/:name`, which
// is where the console's list/kanban/grid renderers get theirs), so a
// chart's category/series labels match what those surfaces render for
// the identical field. `undefined` (no i18n service, no locales
// declared, or nothing in the bundle for this locale) leaves the
// caller's authored-label fallback untouched.
translateSelectOptions: (objectName, fieldName, options, locale) => {
if (!locale) return undefined;
const i18n = i18nService();
if (!i18n) return undefined;
const bundle = buildTranslationBundle(i18n);
if (!bundle) return undefined;
const fallback = typeof i18n.getFallbackLocale === 'function' ? i18n.getFallbackLocale() : undefined;
const defaultLocale = typeof i18n.getDefaultLocale === 'function' ? i18n.getDefaultLocale() : undefined;
// `value` here is `unknown` (an option's stored value, of whatever
// shape the field declares); `ObjectFieldLike.options[].value` narrows
// to `string | number | boolean` (`SelectOptionSchema.value`'s real
// runtime type). The cast is a type-only widening back to what this
// capability's own signature promises — no value is coerced.
const fields: Record<string, ObjectFieldLike> = {
[fieldName]: { name: fieldName, options: options as ObjectFieldLike['options'] },
};
const doc: ObjectLike = { name: objectName, fields };
const translated = translateObject(doc, bundle, {
locale,
fallbackChain: typeof fallback === 'string' && fallback.length > 0 ? [fallback] : undefined,
defaultLocale: typeof defaultLocale === 'string' && defaultLocale.length > 0 ? defaultLocale : undefined,
});
const translatedField = Array.isArray(translated.fields)
? translated.fields.find((f) => f && f.name === fieldName)
: translated.fields?.[fieldName];
return Array.isArray(translatedField?.options)
? (translatedField.options as typeof options)
: undefined;
},
};
// ADR-0037 P3 — draft data preview: resolve the PENDING seed draft's rows
// for an object via the kernel protocol (state:'draft' read — a published
// seed's rows are already in the real table and must NOT overlay). Lazy
// service lookup so plugin order doesn't matter; null ⇒ no pending seed ⇒
// queryDataset falls through to live data.
const draftRowsResolver = async (objectName: string): Promise<Record<string, unknown>[] | null> => {
type ProtocolLike = {
getMetaItems?(req: { type: string; previewDrafts?: boolean }): Promise<unknown>;
getMetaItem?(req: { type: string; name: string; state?: string }): Promise<unknown>;
};
let protocol: ProtocolLike | undefined;
try {
protocol = ctx.getService<ProtocolLike>('protocol');
} catch { return null; }
if (!protocol?.getMetaItems || !protocol.getMetaItem) return null;
const res = await protocol.getMetaItems({ type: 'seed', previewDrafts: true }).catch(() => null);
const list = Array.isArray(res)
? res
: (res && typeof res === 'object' && Array.isArray((res as { items?: unknown[] }).items)
? (res as { items: unknown[] }).items
: []);
const rows: Record<string, unknown>[] = [];
let pending = false;
for (const entry of list) {
const body = ((entry as { item?: unknown })?.item ?? entry) as { name?: string; object?: string } | null;
if (!body?.name || body.object !== objectName) continue;
// Only a PENDING draft row qualifies; getMetaItem({state:'draft'})
// throws no_draft when the seed is already published.
const draft = await protocol.getMetaItem({ type: 'seed', name: body.name, state: 'draft' }).catch(() => null);
const draftBody = (draft as { item?: { records?: unknown[] } } | null)?.item;
if (!draftBody) continue;
pending = true;
for (const r of Array.isArray(draftBody.records) ? draftBody.records : []) {
if (r && typeof r === 'object') rows.push(r as Record<string, unknown>);
}
}
return pending ? rows : null;
};
// Temporal storage-form coercion (fixes the SQLite datetime "No rows" bug).
// The raw-SQL strategy binds dashboard relative-date tokens (already expanded
// to ISO strings) directly, bypassing the driver's CRUD coercion. Delegate to
// the driver — the single source of truth for the on-disk storage convention —
// so a `Field.datetime` ISO comparand becomes epoch ms on SQLite, while
// `Field.date` text and native-timestamp (Postgres) columns pass through
// unchanged. Resolved at call time so plugin-init order does not matter.
const coerceTemporalFilterValue = (
objectName: string,
fieldName: string,
value: unknown,
): unknown => {
try {
const svc = ctx.getService<DataEngineLike>('data');
const driver: TemporalDriverSurface | undefined = svc?.getDriverForObject?.(objectName);
if (driver && typeof driver.temporalFilterValue === 'function') {
return driver.temporalFilterValue(objectName, fieldName, value);
}
} catch {
// No data engine / driver, or it doesn't support coercion — leave the
// value as-is (today's behaviour; safe for text/native-timestamp paths).
}
return value;
};
// The column half of the same fix (#3912). A SQLite `Field.datetime` column
// holds BOTH storage forms — INTEGER epoch from a `Date` write, ISO TEXT from
// a REST/JSON write or a `NOW()` default — so coercing the comparand alone
// matched whichever half the writer produced and returned an empty window for
// the other. Ask the driver for the column expression that normalises both.
const coerceTemporalFilterColumn = (
objectName: string,
fieldName: string,
columnSql: string,
): string => {
try {
const svc = ctx.getService<DataEngineLike>('data');
const driver: TemporalDriverSurface | undefined = svc?.getDriverForObject?.(objectName);
if (driver && typeof driver.temporalFilterColumnSql === 'function') {
return driver.temporalFilterColumnSql(objectName, fieldName, columnSql);
}
} catch {
// Same tiering as above — an unresolvable driver emits the bare column,
// which is today's behaviour and correct on every non-mixed dialect.
}
return columnSql;
};
/**
* [#15684] The dialect that will EXECUTE what the three SQL compilers emit.
*
* Asked of the DRIVER that owns the object, through the same
* `getDriverForObject` seam the two temporal hooks above use, because the
* driver is the single source of truth for its own dialect — recomputing
* it here from a datasource config would be a second implementation
* drifting one knex spelling behind the driver's own emission sets.
*
* `undefined` on every tier that cannot answer — no data engine, a driver
* that names no dialect (memory, mongo), a throw — and `undefined` keeps
* the plain `LIKE`, which is exactly the pre-#15684 behaviour.
*/
const sqlDialect = (objectName: string): string | undefined => {
try {
const svc = ctx.getService<DataEngineLike>('data');
const driver = svc?.getDriverForObject?.(objectName) as DialectNamingDriver | undefined;
const named = driver?.dialectName;
return typeof named === 'string' ? named : undefined;
} catch {
// Same tiering as the temporal hooks: an unresolvable driver keeps the
// dialect-blind construct, which is today's behaviour.
return undefined;
}
};
const config: AnalyticsServiceConfig = {
cubes: this.options.cubes,
logger: ctx.logger,
queryCapabilities,
executeRawSql,
executeAggregate,
fallbackService,
getReadScope,
admitObjectRead,
getAllowedRelationships: this.options.getAllowedRelationships,
coerceTemporalFilterValue,
coerceTemporalFilterColumn,
relationshipResolver,
labelResolver,
// [#8286] Passed through as authored — `undefined` is "this host did not
// choose", which the service resolves to development-only. Defaulting it
// here would be a second copy of that decision, drifting the moment one
// of the two moves.
debugSql: this.options.debugSql,
// Source-field metadata behind the display chains on result columns:
// ADR-0053 currency (`currencyConfig.defaultCurrency`) and percent scale
// (`max`, which is what marks whole-percent storage — objectui#3136).
sourceFieldMeta: (object: string, field: string) => {
const f = dataEngine()?.getObject?.(object)?.fields?.[field] as
| { type?: string; max?: number; currencyConfig?: { defaultCurrency?: string } }
| undefined;
return f ? { type: f.type, max: f.max, defaultCurrency: f.currencyConfig?.defaultCurrency } : undefined;
},
// #5033 — the datasource an object is bound to, used ONLY to name the
// actual cause when a dataset's SQL references a table that is not on the
// datasource the query was routed to. Undefined ⇒ the object rides the
// default datasource (or the engine cannot answer), and the diagnostic
// says so rather than inventing a name.
//
// [#5288] Asked of the ENGINE's resolver, not of the object's declaration.
// `getObject(name).datasource` is the declared value — step 1 of the five
// `getDriver` routes by — so an object placed by a `datasourceMapping`
// rule, by the ADR-0057 §3.6 lifecycle split, or by its package's
// `defaultDatasource` answered `'default'`, and the diagnostic named a
// database the rows are not in. Recomputing those rules here instead would
// be the second implementation `resolveMappedDatasource` (#4462) exists to
// prevent: it drifts by one step, silently, and the drift only surfaces as
// an error message pointing at the wrong database.
getObjectDatasource: (objectName: string) => dataEngine()?.resolveEffectiveDatasource?.(objectName),
// [#15684] The executing driver's own dialect — see `sqlDialect` above.
sqlDialect,
// ADR-0062 D6 — a federated object carries an `external` block (ADR-0015).
// Reported so NativeSQLStrategy declines it (its hand-compiled FROM would
// hit the wrong physical table) and the driver-correct ObjectQL path runs.
isExternalObject: (objectName: string) => {
const obj = dataEngine()?.getObject?.(objectName);
return !!(obj && obj.external != null);
},
// [#3867] Existence probe for the cube auto-inference gate. Reads the
// same schema registry the data path's #3770 gate consults, through the
// engine accessor this bridge already uses above — so "which objects
// exist" has one answer across /data and /analytics.
//
// `dataEngine()` resolves lazily and may be absent entirely (analytics
// installed without a data engine). Reporting `false` there would 404
// every cube, so an unresolvable engine reports `true` — "cannot answer,
// do not block" — mirroring the tiering #3770 took on the data path.
isRegisteredObject: (name: string) => {
const engine = dataEngine();
if (!engine) return true;
return engine.getObject?.(name) != null;
},
// [#4437, #5520] Field names for the two source-field gates — measures
// (#4437) and dimensions/timeDimensions (#5520). Read from the
// SAME schema registry `isRegisteredObject` above consults (and the data
// path's #4315 gate reads), so "which fields exist" has one answer across
// /data and /analytics. `undefined` — no engine, unknown object, or an
// object with no field map (an external datasource whose columns are not
// mirrored locally) — means "cannot answer", and the gate stands down.
getObjectFieldNames: (objectName: string) => {
const fields = dataEngine()?.getObject?.(objectName)?.fields;
if (!fields || typeof fields !== 'object') return undefined;
const names = Object.keys(fields);
return names.length > 0 ? names : undefined;
},
draftRowsResolver,
};
if (autoBridgedReadScope && securityPresentAtInit) {
ctx.logger.info('[Analytics] Auto-bridged getReadScope → "security" service (getReadFilter)');
} else if (autoBridgedReadScope) {
// The bridge IS wired and will resolve at call time — this is only a
// heads-up that security had not registered yet at our init. It becomes a
// real problem only if no security service ever appears.
ctx.logger.info(
'[Analytics] getReadScope bridged to the "security" service; that service is not ' +
'registered yet at init and will be resolved per query (plugin order is not significant).',
);
} else if (!getReadScope) {