@objectstack/service-analytics 3.2.9

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.
@@ -0,0 +1,231 @@
1
+ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2
+
3
+ import type {
4
+ IAnalyticsService,
5
+ AnalyticsQuery,
6
+ AnalyticsResult,
7
+ CubeMeta,
8
+ } from '@objectstack/spec/contracts';
9
+ import type { Cube } from '@objectstack/spec/data';
10
+ import type { Logger } from '@objectstack/spec/contracts';
11
+ import { createLogger } from '@objectstack/core';
12
+ import { CubeRegistry } from './cube-registry.js';
13
+ import type { AnalyticsStrategy, DriverCapabilities, StrategyContext } from './strategies/types.js';
14
+ import { NativeSQLStrategy } from './strategies/native-sql-strategy.js';
15
+ import { ObjectQLStrategy } from './strategies/objectql-strategy.js';
16
+
17
+ /**
18
+ * Configuration for AnalyticsService.
19
+ */
20
+ export interface AnalyticsServiceConfig {
21
+ /** Pre-defined cube definitions (from manifest). */
22
+ cubes?: Cube[];
23
+ /** Logger instance. */
24
+ logger?: Logger;
25
+ /**
26
+ * Probe driver capabilities for the object that backs a cube.
27
+ * The service calls this function to decide which strategy can handle a query.
28
+ */
29
+ queryCapabilities?: (cubeName: string) => DriverCapabilities;
30
+ /**
31
+ * Execute raw SQL on the driver for a given object.
32
+ * Required for NativeSQLStrategy.
33
+ */
34
+ executeRawSql?: (objectName: string, sql: string, params: unknown[]) => Promise<Record<string, unknown>[]>;
35
+ /**
36
+ * Execute an ObjectQL aggregate query.
37
+ * Required for ObjectQLStrategy.
38
+ */
39
+ executeAggregate?: (objectName: string, options: {
40
+ groupBy?: string[];
41
+ aggregations?: Array<{ field: string; method: string; alias: string }>;
42
+ filter?: Record<string, unknown>;
43
+ }) => Promise<Record<string, unknown>[]>;
44
+ /**
45
+ * Fallback IAnalyticsService (e.g. MemoryAnalyticsService).
46
+ * Used by InMemoryStrategy.
47
+ */
48
+ fallbackService?: IAnalyticsService;
49
+ /**
50
+ * Custom strategies to add/replace the defaults.
51
+ * They are merged with the built-in strategies and sorted by priority.
52
+ */
53
+ strategies?: AnalyticsStrategy[];
54
+ }
55
+
56
+ /**
57
+ * Default capabilities when probing is not configured — assumes in-memory only.
58
+ */
59
+ const DEFAULT_CAPABILITIES: DriverCapabilities = {
60
+ nativeSql: false,
61
+ objectqlAggregate: false,
62
+ inMemory: true,
63
+ };
64
+
65
+ /**
66
+ * AnalyticsService — Multi-driver analytics orchestrator.
67
+ *
68
+ * Implements `IAnalyticsService` by delegating to a priority-ordered
69
+ * strategy chain:
70
+ *
71
+ * | Priority | Strategy | Condition |
72
+ * |:---:|:---|:---|
73
+ * | P1 (10) | NativeSQLStrategy | Driver supports raw SQL |
74
+ * | P2 (20) | ObjectQLStrategy | Driver supports aggregate AST |
75
+ * | P3 (30) | (custom / InMemoryStrategy from driver-memory) | Injected by user |
76
+ *
77
+ * When `fallbackService` is configured, an internal delegate strategy
78
+ * is automatically appended at priority 30 as a safety net.
79
+ *
80
+ * The service also owns a `CubeRegistry` for metadata discovery and
81
+ * auto-inference from object schemas.
82
+ */
83
+ export class AnalyticsService implements IAnalyticsService {
84
+ private readonly strategies: AnalyticsStrategy[];
85
+ private readonly strategyCtx: StrategyContext;
86
+ readonly cubeRegistry: CubeRegistry;
87
+ private readonly logger: Logger;
88
+
89
+ constructor(config: AnalyticsServiceConfig = {}) {
90
+ this.logger = config.logger || createLogger({ level: 'info', format: 'pretty' });
91
+ this.cubeRegistry = new CubeRegistry();
92
+
93
+ // Register pre-defined cubes
94
+ if (config.cubes) {
95
+ this.cubeRegistry.registerAll(config.cubes);
96
+ }
97
+
98
+ // Build strategy context
99
+ this.strategyCtx = {
100
+ getCube: (name) => this.cubeRegistry.get(name),
101
+ queryCapabilities: config.queryCapabilities || (() => DEFAULT_CAPABILITIES),
102
+ executeRawSql: config.executeRawSql,
103
+ executeAggregate: config.executeAggregate,
104
+ fallbackService: config.fallbackService,
105
+ };
106
+
107
+ // Build strategy chain (built-in + custom, sorted by priority)
108
+ // InMemoryStrategy is NOT built-in — it lives in @objectstack/driver-memory
109
+ // and should be passed via config.strategies when needed.
110
+ // When fallbackService is configured, an internal delegate is added at P3.
111
+ const builtIn: AnalyticsStrategy[] = [
112
+ new NativeSQLStrategy(),
113
+ new ObjectQLStrategy(),
114
+ ];
115
+
116
+ // Auto-add fallback delegate when fallbackService is provided
117
+ if (config.fallbackService) {
118
+ builtIn.push(new FallbackDelegateStrategy());
119
+ }
120
+
121
+ const custom = config.strategies || [];
122
+ this.strategies = [...builtIn, ...custom].sort((a, b) => a.priority - b.priority);
123
+
124
+ this.logger.info(
125
+ `[Analytics] Initialized with ${this.cubeRegistry.size} cubes, ` +
126
+ `${this.strategies.length} strategies: ${this.strategies.map(s => s.name).join(' → ')}`,
127
+ );
128
+ }
129
+
130
+ /**
131
+ * Execute an analytical query by delegating to the first capable strategy.
132
+ */
133
+ async query(query: AnalyticsQuery): Promise<AnalyticsResult> {
134
+ if (!query.cube) {
135
+ throw new Error('Cube name is required in analytics query');
136
+ }
137
+
138
+ const strategy = this.resolveStrategy(query);
139
+ this.logger.debug(`[Analytics] Query on cube "${query.cube}" → ${strategy.name}`);
140
+
141
+ return strategy.execute(query, this.strategyCtx);
142
+ }
143
+
144
+ /**
145
+ * Get cube metadata for discovery.
146
+ */
147
+ async getMeta(cubeName?: string): Promise<CubeMeta[]> {
148
+ // If a fallback service is configured, merge its metadata with the registry
149
+ const cubes = cubeName
150
+ ? [this.cubeRegistry.get(cubeName)].filter(Boolean) as Cube[]
151
+ : this.cubeRegistry.getAll();
152
+
153
+ return cubes.map(cube => ({
154
+ name: cube.name,
155
+ title: cube.title,
156
+ measures: Object.entries(cube.measures).map(([key, measure]) => ({
157
+ name: `${cube.name}.${key}`,
158
+ type: measure.type,
159
+ title: measure.label,
160
+ })),
161
+ dimensions: Object.entries(cube.dimensions).map(([key, dimension]) => ({
162
+ name: `${cube.name}.${key}`,
163
+ type: dimension.type,
164
+ title: dimension.label,
165
+ })),
166
+ }));
167
+ }
168
+
169
+ /**
170
+ * Generate SQL for a query without executing it (dry-run).
171
+ */
172
+ async generateSql(query: AnalyticsQuery): Promise<{ sql: string; params: unknown[] }> {
173
+ if (!query.cube) {
174
+ throw new Error('Cube name is required for SQL generation');
175
+ }
176
+
177
+ const strategy = this.resolveStrategy(query);
178
+ this.logger.debug(`[Analytics] generateSql on cube "${query.cube}" → ${strategy.name}`);
179
+
180
+ return strategy.generateSql(query, this.strategyCtx);
181
+ }
182
+
183
+ // ── Internal ─────────────────────────────────────────────────────
184
+
185
+ /**
186
+ * Walk the strategy chain and return the first strategy that can handle the query.
187
+ */
188
+ private resolveStrategy(query: AnalyticsQuery): AnalyticsStrategy {
189
+ for (const strategy of this.strategies) {
190
+ if (strategy.canHandle(query, this.strategyCtx)) {
191
+ return strategy;
192
+ }
193
+ }
194
+ throw new Error(
195
+ `[Analytics] No strategy can handle query for cube "${query.cube}". ` +
196
+ `Checked: ${this.strategies.map(s => s.name).join(', ')}. ` +
197
+ 'Ensure a compatible driver is configured or a fallback service is registered.',
198
+ );
199
+ }
200
+ }
201
+
202
+ /**
203
+ * FallbackDelegateStrategy — Internal strategy for fallback service delegation.
204
+ *
205
+ * Automatically added to the strategy chain when `fallbackService` is configured.
206
+ * Not exported — consumers who need explicit in-memory support should use
207
+ * `InMemoryStrategy` from `@objectstack/driver-memory`.
208
+ */
209
+ class FallbackDelegateStrategy implements AnalyticsStrategy {
210
+ readonly name = 'FallbackDelegateStrategy';
211
+ readonly priority = 30;
212
+
213
+ canHandle(query: AnalyticsQuery, ctx: StrategyContext): boolean {
214
+ if (!query.cube) return false;
215
+ return !!ctx.fallbackService;
216
+ }
217
+
218
+ async execute(query: AnalyticsQuery, ctx: StrategyContext): Promise<AnalyticsResult> {
219
+ return ctx.fallbackService!.query(query);
220
+ }
221
+
222
+ async generateSql(query: AnalyticsQuery, ctx: StrategyContext): Promise<{ sql: string; params: unknown[] }> {
223
+ if (ctx.fallbackService?.generateSql) {
224
+ return ctx.fallbackService.generateSql(query);
225
+ }
226
+ return {
227
+ sql: `-- FallbackDelegateStrategy: SQL generation not supported for cube "${query.cube}"`,
228
+ params: [],
229
+ };
230
+ }
231
+ }
@@ -0,0 +1,147 @@
1
+ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2
+
3
+ import type { Cube } from '@objectstack/spec/data';
4
+
5
+ /**
6
+ * CubeRegistry — Central registry for analytics cube definitions.
7
+ *
8
+ * Cubes can be registered from two sources:
9
+ * 1. **Manifest definitions** — Explicit cube definitions in `objectstack.config.ts`.
10
+ * 2. **Object schema inference** — Auto-generated cubes from ObjectQL object schemas.
11
+ *
12
+ * The registry is the single source of truth for cube metadata discovery
13
+ * (used by `getMeta()` and the strategy chain).
14
+ */
15
+ export class CubeRegistry {
16
+ private cubes = new Map<string, Cube>();
17
+
18
+ /** Register a single cube definition. Overwrites if name already exists. */
19
+ register(cube: Cube): void {
20
+ this.cubes.set(cube.name, cube);
21
+ }
22
+
23
+ /** Register multiple cube definitions at once. */
24
+ registerAll(cubes: Cube[]): void {
25
+ for (const cube of cubes) {
26
+ this.register(cube);
27
+ }
28
+ }
29
+
30
+ /** Get a cube definition by name. */
31
+ get(name: string): Cube | undefined {
32
+ return this.cubes.get(name);
33
+ }
34
+
35
+ /** Check if a cube is registered. */
36
+ has(name: string): boolean {
37
+ return this.cubes.has(name);
38
+ }
39
+
40
+ /** Return all registered cubes. */
41
+ getAll(): Cube[] {
42
+ return Array.from(this.cubes.values());
43
+ }
44
+
45
+ /** Return all cube names. */
46
+ names(): string[] {
47
+ return Array.from(this.cubes.keys());
48
+ }
49
+
50
+ /** Number of registered cubes. */
51
+ get size(): number {
52
+ return this.cubes.size;
53
+ }
54
+
55
+ /** Remove all cubes. */
56
+ clear(): void {
57
+ this.cubes.clear();
58
+ }
59
+
60
+ /**
61
+ * Auto-generate a cube definition from an object schema.
62
+ *
63
+ * Heuristic rules:
64
+ * - `number` fields → `sum`, `avg`, `min`, `max` measures
65
+ * - `boolean` fields → `count` measure (count where true)
66
+ * - All non-computed fields → dimensions
67
+ * - `date`/`datetime` fields → time dimensions with standard granularities
68
+ * - A default `count` measure is always added
69
+ *
70
+ * @param objectName - The snake_case object name (used as table/cube name)
71
+ * @param fields - Array of field descriptors `{ name, type, label? }`
72
+ */
73
+ inferFromObject(
74
+ objectName: string,
75
+ fields: Array<{ name: string; type: string; label?: string }>,
76
+ ): Cube {
77
+ const measures: Record<string, any> = {
78
+ count: {
79
+ name: 'count',
80
+ label: 'Count',
81
+ type: 'count',
82
+ sql: '*',
83
+ },
84
+ };
85
+ const dimensions: Record<string, any> = {};
86
+
87
+ for (const field of fields) {
88
+ const label = field.label || field.name;
89
+
90
+ // All fields become dimensions
91
+ const dimType = this.fieldTypeToDimensionType(field.type);
92
+ dimensions[field.name] = {
93
+ name: field.name,
94
+ label,
95
+ type: dimType,
96
+ sql: field.name,
97
+ ...(dimType === 'time'
98
+ ? { granularities: ['day', 'week', 'month', 'quarter', 'year'] }
99
+ : {}),
100
+ };
101
+
102
+ // Numeric fields also become aggregation measures
103
+ if (field.type === 'number' || field.type === 'currency' || field.type === 'percent') {
104
+ measures[`${field.name}_sum`] = {
105
+ name: `${field.name}_sum`,
106
+ label: `${label} (Sum)`,
107
+ type: 'sum',
108
+ sql: field.name,
109
+ };
110
+ measures[`${field.name}_avg`] = {
111
+ name: `${field.name}_avg`,
112
+ label: `${label} (Avg)`,
113
+ type: 'avg',
114
+ sql: field.name,
115
+ };
116
+ }
117
+ }
118
+
119
+ const cube: Cube = {
120
+ name: objectName,
121
+ title: objectName,
122
+ sql: objectName,
123
+ measures,
124
+ dimensions,
125
+ public: false,
126
+ };
127
+
128
+ this.register(cube);
129
+ return cube;
130
+ }
131
+
132
+ private fieldTypeToDimensionType(fieldType: string): string {
133
+ switch (fieldType) {
134
+ case 'number':
135
+ case 'currency':
136
+ case 'percent':
137
+ return 'number';
138
+ case 'boolean':
139
+ return 'boolean';
140
+ case 'date':
141
+ case 'datetime':
142
+ return 'time';
143
+ default:
144
+ return 'string';
145
+ }
146
+ }
147
+ }
package/src/index.ts ADDED
@@ -0,0 +1,19 @@
1
+ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2
+
3
+ // Core service
4
+ export { AnalyticsService } from './analytics-service.js';
5
+ export type { AnalyticsServiceConfig } from './analytics-service.js';
6
+
7
+ // Kernel plugin
8
+ export { AnalyticsServicePlugin } from './plugin.js';
9
+ export type { AnalyticsServicePluginOptions } from './plugin.js';
10
+
11
+ // Cube registry
12
+ export { CubeRegistry } from './cube-registry.js';
13
+
14
+ // Strategies
15
+ export { NativeSQLStrategy } from './strategies/native-sql-strategy.js';
16
+ export { ObjectQLStrategy } from './strategies/objectql-strategy.js';
17
+ export type { AnalyticsStrategy, StrategyContext, DriverCapabilities } from './strategies/types.js';
18
+
19
+ // Note: InMemoryStrategy is exported from @objectstack/driver-memory
package/src/plugin.ts ADDED
@@ -0,0 +1,133 @@
1
+ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2
+
3
+ import type { Plugin, PluginContext } from '@objectstack/core';
4
+ import type { Cube } from '@objectstack/spec/data';
5
+ import type { IAnalyticsService } from '@objectstack/spec/contracts';
6
+ import { AnalyticsService } from './analytics-service.js';
7
+ import type { AnalyticsServiceConfig } from './analytics-service.js';
8
+ import type { DriverCapabilities } from './strategies/types.js';
9
+
10
+ /**
11
+ * Configuration for AnalyticsServicePlugin.
12
+ */
13
+ export interface AnalyticsServicePluginOptions {
14
+ /** Pre-defined cube definitions (from manifest). */
15
+ cubes?: Cube[];
16
+ /**
17
+ * Probe driver capabilities for a given cube.
18
+ * When omitted, defaults to in-memory only.
19
+ */
20
+ queryCapabilities?: (cubeName: string) => DriverCapabilities;
21
+ /**
22
+ * Execute raw SQL on a driver. Enables NativeSQLStrategy.
23
+ */
24
+ executeRawSql?: (objectName: string, sql: string, params: unknown[]) => Promise<Record<string, unknown>[]>;
25
+ /**
26
+ * Execute ObjectQL aggregate. Enables ObjectQLStrategy.
27
+ */
28
+ executeAggregate?: (objectName: string, options: {
29
+ groupBy?: string[];
30
+ aggregations?: Array<{ field: string; method: string; alias: string }>;
31
+ filter?: Record<string, unknown>;
32
+ }) => Promise<Record<string, unknown>[]>;
33
+ /** Enable debug logging. */
34
+ debug?: boolean;
35
+ }
36
+
37
+ /**
38
+ * AnalyticsServicePlugin — Kernel plugin for multi-driver analytics.
39
+ *
40
+ * Lifecycle:
41
+ * 1. **init** — Creates `AnalyticsService`, registers as `'analytics'` service.
42
+ * If an existing analytics service is already registered (e.g. MemoryAnalyticsService
43
+ * from dev-plugin), it is captured as the `fallbackService`.
44
+ * 2. **start** — Triggers `'analytics:ready'` hook so other plugins can
45
+ * register cubes or extend the service.
46
+ * 3. **destroy** — Cleans up references.
47
+ *
48
+ * @example
49
+ * ```ts
50
+ * import { LiteKernel } from '@objectstack/core';
51
+ * import { AnalyticsServicePlugin } from '@objectstack/service-analytics';
52
+ *
53
+ * const kernel = new LiteKernel();
54
+ * kernel.use(new AnalyticsServicePlugin({
55
+ * cubes: [ordersCube],
56
+ * queryCapabilities: (cube) => ({ nativeSql: true, objectqlAggregate: true, inMemory: false }),
57
+ * executeRawSql: async (obj, sql, params) => pgPool.query(sql, params).then(r => r.rows),
58
+ * }));
59
+ * await kernel.bootstrap();
60
+ *
61
+ * const analytics = kernel.getService<IAnalyticsService>('analytics');
62
+ * const result = await analytics.query({ cube: 'orders', measures: ['orders.count'] });
63
+ * ```
64
+ */
65
+ export class AnalyticsServicePlugin implements Plugin {
66
+ name = 'com.objectstack.service-analytics';
67
+ version = '1.0.0';
68
+ type = 'standard' as const;
69
+ dependencies: string[] = [];
70
+
71
+ private service?: AnalyticsService;
72
+ private readonly options: AnalyticsServicePluginOptions;
73
+
74
+ constructor(options: AnalyticsServicePluginOptions = {}) {
75
+ this.options = options;
76
+ }
77
+
78
+ async init(ctx: PluginContext): Promise<void> {
79
+ // Check if there is an existing analytics service (e.g. from dev-plugin)
80
+ let fallbackService: IAnalyticsService | undefined;
81
+ try {
82
+ const existing = ctx.getService<IAnalyticsService>('analytics');
83
+ if (existing && typeof existing.query === 'function') {
84
+ fallbackService = existing;
85
+ ctx.logger.debug('[Analytics] Found existing analytics service, using as fallback');
86
+ }
87
+ } catch {
88
+ // No existing service — that's fine
89
+ }
90
+
91
+ const config: AnalyticsServiceConfig = {
92
+ cubes: this.options.cubes,
93
+ logger: ctx.logger,
94
+ queryCapabilities: this.options.queryCapabilities,
95
+ executeRawSql: this.options.executeRawSql,
96
+ executeAggregate: this.options.executeAggregate,
97
+ fallbackService,
98
+ };
99
+
100
+ this.service = new AnalyticsService(config);
101
+
102
+ // Register or replace the analytics service
103
+ if (fallbackService) {
104
+ ctx.replaceService('analytics', this.service);
105
+ } else {
106
+ ctx.registerService('analytics', this.service);
107
+ }
108
+
109
+ if (this.options.debug) {
110
+ ctx.hook('analytics:beforeQuery', async (query: unknown) => {
111
+ ctx.logger.debug('[Analytics] Before query', { query });
112
+ });
113
+ }
114
+
115
+ ctx.logger.info('[Analytics] Service initialized');
116
+ }
117
+
118
+ async start(ctx: PluginContext): Promise<void> {
119
+ if (!this.service) return;
120
+
121
+ // Notify other plugins that analytics is ready
122
+ await ctx.trigger('analytics:ready', this.service);
123
+
124
+ ctx.logger.info(
125
+ `[Analytics] Service started with ${this.service.cubeRegistry.size} cubes: ` +
126
+ `${this.service.cubeRegistry.names().join(', ') || '(none)'}`,
127
+ );
128
+ }
129
+
130
+ async destroy(): Promise<void> {
131
+ this.service = undefined;
132
+ }
133
+ }