@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/README.md CHANGED
@@ -1,18 +1,10 @@
1
1
  # @objectstack/service-analytics
2
2
 
3
- Analytics Service for ObjectStack implements `IAnalyticsService` with multi-driver strategy pattern (NativeSQL, ObjectQL, InMemory).
3
+ The shipped provider for the kernel's **`analytics`** service slot a cube/dataset
4
+ query engine implementing `IAnalyticsService` over a priority-ordered strategy chain.
4
5
 
5
- ## Features
6
-
7
- - **Multi-Driver Architecture**: Choose the right execution strategy for your analytics queries
8
- - **NativeSQL**: Direct SQL execution for maximum performance on large datasets
9
- - **ObjectQL**: Leverage ObjectStack's query engine for metadata-aware analytics
10
- - **InMemory**: Fast aggregations on small datasets without database round-trips
11
- - **Aggregation Functions**: SUM, COUNT, AVG, MIN, MAX, GROUP BY, HAVING
12
- - **Time Series Analysis**: Time-based aggregations and grouping
13
- - **Custom Metrics**: Define and track custom business metrics
14
- - **Dashboard Integration**: Auto-generated REST endpoints for visualization
15
- - **Type-Safe**: Full TypeScript support with inferred result types
6
+ Slot criticality: `optional` (`ServiceRequirementDef` in `@objectstack/spec/system`).
7
+ Without it, `/api/v1/analytics/*` answers 404 rather than degrading.
16
8
 
17
9
  ## Installation
18
10
 
@@ -20,370 +12,179 @@ Analytics Service for ObjectStack — implements `IAnalyticsService` with multi-
20
12
  pnpm add @objectstack/service-analytics
21
13
  ```
22
14
 
23
- ## Basic Usage
24
-
25
- ```typescript
26
- import { defineStack } from '@objectstack/spec';
27
- import { ServiceAnalytics } from '@objectstack/service-analytics';
28
-
29
- const stack = defineStack({
30
- services: [
31
- ServiceAnalytics.configure({
32
- defaultDriver: 'objectql', // or 'sql', 'memory'
33
- enableCaching: true,
34
- }),
35
- ],
36
- });
37
- ```
15
+ ## Usage
38
16
 
39
- ## Configuration
17
+ The entry point is the kernel plugin `AnalyticsServicePlugin`. Construct it and hand
18
+ it to the kernel; it registers the service under `'analytics'` during `init`.
40
19
 
41
20
  ```typescript
42
- interface AnalyticsServiceConfig {
43
- /** Default execution driver */
44
- defaultDriver?: 'sql' | 'objectql' | 'memory';
45
-
46
- /** Enable query result caching */
47
- enableCaching?: boolean;
48
-
49
- /** Cache TTL in seconds (default: 300) */
50
- cacheTTL?: number;
51
-
52
- /** Maximum result set size for in-memory driver */
53
- maxMemoryResults?: number;
54
- }
55
- ```
21
+ import { LiteKernel } from '@objectstack/core';
22
+ import type { Cube } from '@objectstack/spec/data';
23
+ import type { IAnalyticsService } from '@objectstack/spec/contracts';
24
+ import { AnalyticsServicePlugin } from '@objectstack/service-analytics';
25
+
26
+ const ordersCube: Cube = {
27
+ name: 'orders',
28
+ title: 'Orders',
29
+ sql: 'orders',
30
+ measures: {
31
+ count: { name: 'count', label: 'Count', type: 'count', sql: '*' },
32
+ total_amount: { name: 'total_amount', label: 'Total Amount', type: 'sum', sql: 'amount' },
33
+ },
34
+ dimensions: {
35
+ status: { name: 'status', label: 'Status', type: 'string', sql: 'status' },
36
+ },
37
+ };
56
38
 
57
- ## Service API
39
+ const kernel = new LiteKernel();
40
+ kernel.use(new AnalyticsServicePlugin({ cubes: [ordersCube] }));
41
+ await kernel.bootstrap();
58
42
 
59
- ```typescript
60
- // Get analytics service from kernel
61
43
  const analytics = kernel.getService<IAnalyticsService>('analytics');
44
+ const result = await analytics.query({ cube: 'orders', measures: ['orders.count'] });
62
45
  ```
63
46
 
64
- ### Basic Aggregations
65
-
66
- ```typescript
67
- // Count records
68
- const totalOrders = await analytics.count({
69
- object: 'order',
70
- filters: [{ field: 'status', operator: 'eq', value: 'completed' }],
71
- });
72
-
73
- // Sum field values
74
- const totalRevenue = await analytics.sum({
75
- object: 'order',
76
- field: 'amount',
77
- filters: [{ field: 'created_at', operator: 'gte', value: '2024-01-01' }],
78
- });
79
-
80
- // Calculate average
81
- const avgOrderValue = await analytics.avg({
82
- object: 'order',
83
- field: 'amount',
84
- });
85
-
86
- // Find min/max
87
- const highestOrder = await analytics.max({
88
- object: 'order',
89
- field: 'amount',
90
- });
91
- ```
92
-
93
- ### Group By Aggregations
47
+ `LiteKernel.use()` is synchronous; `ObjectKernel.use()` returns a promise — await it there.
94
48
 
95
- ```typescript
96
- // Revenue by product category
97
- const revenueByCategory = await analytics.groupBy({
98
- object: 'order_item',
99
- groupBy: ['product.category'],
100
- aggregations: [
101
- { function: 'sum', field: 'total', as: 'revenue' },
102
- { function: 'count', as: 'order_count' },
103
- ],
104
- });
105
-
106
- // Result format:
107
- // [
108
- // { category: 'Electronics', revenue: 125000, order_count: 342 },
109
- // { category: 'Clothing', revenue: 98000, order_count: 567 },
110
- // ]
111
- ```
49
+ ## Plugin options
112
50
 
113
- ### Time Series Analytics
114
-
115
- ```typescript
116
- // Daily revenue for the past 30 days
117
- const dailyRevenue = await analytics.timeSeries({
118
- object: 'order',
119
- dateField: 'created_at',
120
- interval: 'day',
121
- aggregations: [
122
- { function: 'sum', field: 'amount', as: 'revenue' },
123
- { function: 'count', as: 'orders' },
124
- ],
125
- filters: [
126
- {
127
- field: 'created_at',
128
- operator: 'gte',
129
- value: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000),
130
- },
131
- ],
132
- });
133
-
134
- // Result format:
135
- // [
136
- // { date: '2024-01-01', revenue: 12500, orders: 45 },
137
- // { date: '2024-01-02', revenue: 15200, orders: 52 },
138
- // ]
139
- ```
140
-
141
- ### Custom Metrics
142
-
143
- ```typescript
144
- // Define a metric
145
- analytics.defineMetric({
146
- name: 'monthly_recurring_revenue',
147
- description: 'MRR from active subscriptions',
148
- calculation: {
149
- object: 'subscription',
150
- aggregation: 'sum',
151
- field: 'amount',
152
- filters: [{ field: 'status', operator: 'eq', value: 'active' }],
153
- },
154
- });
51
+ Every field of `AnalyticsServicePluginOptions` is optional. The plugin bridges the
52
+ host's engine into `AnalyticsServiceConfig`; anything left unset falls back to what
53
+ the plugin can auto-discover from the kernel.
155
54
 
156
- // Query the metric
157
- const mrr = await analytics.getMetric('monthly_recurring_revenue');
158
- ```
55
+ | Option | Type | Default | Purpose |
56
+ |:---|:---|:---|:---|
57
+ | `cubes` | `Cube[]` | none | Cube definitions registered at init. |
58
+ | `queryCapabilities` | `(cubeName: string) => AnalyticsDriverCapabilities` | in-memory only | Which execution paths a cube's backing driver supports. |
59
+ | `executeRawSql` | `(objectName, sql, params) => Promise<Record<string, unknown>[]>` | auto-bridged to the ObjectQL engine | Enables `NativeSQLStrategy`. |
60
+ | `executeAggregate` | `(objectName, options) => Promise<Record<string, unknown>[]>` | auto-bridged to the ObjectQL engine | Enables `ObjectQLStrategy`. |
61
+ | `getReadScope` | `(objectName, context?) => FilterCondition \| null \| undefined \| Promise<…>` | auto-bridges to a registered `'security'` service exposing `getReadFilter` | Per-object tenant/RLS read scope (ADR-0021 D-C). |
62
+ | `getAllowedRelationships` | `(cubeName: string) => Set<string> \| undefined` | supplied by compiled datasets | Join allowlist per cube. |
63
+ | `debug` | `boolean` | `false` | Server-side log verbosity only. |
64
+ | `debugSql` | `boolean` | development only (`NODE_ENV === 'development'`) | Echo the executed statement back to callers in `AnalyticsResult.sql`. |
159
65
 
160
- ## Multi-Driver Strategy
66
+ `debug` and `debugSql` are deliberately separate: raising log verbosity must never
67
+ widen what travels to a tenant.
161
68
 
162
- ### When to Use Each Driver
69
+ ## Service API
163
70
 
164
- #### NativeSQL Driver
165
- **Best for**: Large datasets, complex joins, database-specific optimizations
71
+ `IAnalyticsService` (from `@objectstack/spec/contracts`) declares four members — two
72
+ required, two optional:
166
73
 
167
74
  ```typescript
168
- const result = await analytics.query({
169
- driver: 'sql',
170
- object: 'order',
171
- aggregations: [{ function: 'sum', field: 'amount' }],
172
- groupBy: ['customer_id'],
173
- having: [{ field: 'sum_amount', operator: 'gt', value: 10000 }],
174
- });
175
- ```
176
-
177
- **Advantages:**
178
- - Direct SQL execution for maximum performance
179
- - Leverages database indexes and query optimization
180
- - Handles millions of records efficiently
181
-
182
- **Limitations:**
183
- - Bypasses ObjectStack metadata layer
184
- - May miss field-level transformations
185
- - Less portable across databases
186
-
187
- #### ObjectQL Driver
188
- **Best for**: Metadata-aware analytics, cross-object aggregations
75
+ import type { IAnalyticsService } from '@objectstack/spec/contracts';
76
+
77
+ // query(query, context?) -> Promise<AnalyticsResult> (required)
78
+ // getMeta(cubeName?) -> Promise<CubeMeta[]> (required)
79
+ // generateSql?(query, context?) -> Promise<{ sql, params }> (optional)
80
+ // queryDataset?(dataset, selection, context?, options?) (optional)
81
+ ```
82
+
83
+ This package implements all four. Pass the caller's `ExecutionContext` as the second
84
+ argument: without it the per-object read scope resolves to no filter and the query
85
+ runs unscoped.
86
+
87
+ ### AnalyticsQuery
88
+
89
+ `AnalyticsQuery` is a **strict** schema (`AnalyticsQuerySchema`, `@objectstack/spec/data`)
90
+ with exactly these fields; `measures` is the only required one, and an undeclared key
91
+ is rejected rather than dropped.
92
+
93
+ | Field | Type | Notes |
94
+ |:---|:---|:---|
95
+ | `cube` | `string?` | Optional when supplied by the request wrapper. |
96
+ | `measures` | `string[]` | Required. |
97
+ | `dimensions` | `string[]?` | |
98
+ | `where` | `FilterCondition?` | Canonical Query DSL filter — the same shape `find()` takes. |
99
+ | `timeDimensions` | `{ dimension, granularity?, dateRange? }[]?` | Also strict per item. |
100
+ | `order` | `Record<string, 'asc' \| 'desc'>?` | |
101
+ | `limit` | `number?` | |
102
+ | `offset` | `number?` | |
103
+ | `timezone` | `string?` | IANA name. No default — an absent timezone means the engine resolves it. |
104
+
105
+ There is no `filters` key and no `aggregations` key. `filters` is rejected at the REST
106
+ door with a 400 naming `where`. There is no per-metric filter key either — the cube
107
+ metric's `filters` was removed (#10414: no strategy ever read it); fold a per-metric
108
+ condition into the metric's own `sql` expression, or use an ADR-0021 dataset measure's
109
+ structured `filter`.
189
110
 
190
111
  ```typescript
191
- const result = await analytics.query({
192
- driver: 'objectql',
193
- object: 'opportunity',
194
- aggregations: [
195
- { function: 'sum', field: 'amount' },
196
- { function: 'count' },
197
- ],
198
- groupBy: ['account.industry'],
112
+ const revenueByStatus = await analytics.query({
113
+ cube: 'orders',
114
+ measures: ['orders.total_amount'],
115
+ dimensions: ['orders.status'],
116
+ where: { is_active: true },
117
+ order: { 'orders.total_amount': 'desc' },
118
+ limit: 10,
199
119
  });
120
+ // result.rows — Record<string, unknown>[]
121
+ // result.fields — column metadata (name, type, label?, format?, currency?, percentScale?)
200
122
  ```
201
123
 
202
- **Advantages:**
203
- - Respects object/field metadata and permissions
204
- - Handles formula fields and computed values
205
- - Consistent with ObjectQL query behavior
124
+ ## Strategy chain
206
125
 
207
- **Limitations:**
208
- - Slightly slower than direct SQL
209
- - Additional abstraction layer
126
+ `AnalyticsService` delegates to a priority-ordered chain; the first strategy whose
127
+ `canHandle` returns true serves the query.
210
128
 
211
- #### InMemory Driver
212
- **Best for**: Small datasets, pre-filtered results, real-time dashboards
129
+ | Priority | Strategy | Condition |
130
+ |:---:|:---|:---|
131
+ | 10 | `NativeSQLStrategy` | driver supports raw SQL (`executeRawSql`) |
132
+ | 20 | `ObjectQLStrategy` | driver supports aggregate AST (`executeAggregate`) |
133
+ | 30 | custom strategies, or the internal delegate added when `fallbackService` is set | injected by the host |
213
134
 
214
- ```typescript
215
- const result = await analytics.query({
216
- driver: 'memory',
217
- object: 'task',
218
- aggregations: [{ function: 'count' }],
219
- groupBy: ['status'],
220
- });
221
- ```
222
-
223
- **Advantages:**
224
- - Zero database round-trips for cached data
225
- - Instant results for small datasets
226
- - Useful for client-side analytics
227
-
228
- **Limitations:**
229
- - Limited to `maxMemoryResults` (default: 10,000)
230
- - Requires data to be loaded into memory first
135
+ `InMemoryStrategy` is **not** built in — it ships from `@objectstack/driver-memory` and
136
+ is injected through `AnalyticsServiceConfig.strategies` (or `fallbackService`).
231
137
 
232
- ## REST API Endpoints
138
+ ## REST API
233
139
 
234
- When used with `@objectstack/rest`:
140
+ Served by the runtime dispatcher's `/analytics` domain when this service occupies the
141
+ slot. These four routes are the whole surface:
235
142
 
236
143
  ```
237
- POST /api/v1/analytics/count # Count records
238
- POST /api/v1/analytics/sum # Sum field values
239
- POST /api/v1/analytics/avg # Calculate average
240
- POST /api/v1/analytics/min # Find minimum
241
- POST /api/v1/analytics/max # Find maximum
242
- POST /api/v1/analytics/group-by # Group by aggregation
243
- POST /api/v1/analytics/time-series # Time series analysis
244
- GET /api/v1/analytics/metrics # List custom metrics
245
- GET /api/v1/analytics/metrics/:name # Get metric value
246
- ```
247
-
248
- ## Dashboard Integration
249
-
250
- ```typescript
251
- // Define a dashboard with multiple metrics
252
- const salesDashboard = {
253
- title: 'Sales Dashboard',
254
- metrics: [
255
- {
256
- title: 'Total Revenue',
257
- query: {
258
- object: 'order',
259
- aggregation: 'sum',
260
- field: 'amount',
261
- },
262
- },
263
- {
264
- title: 'Revenue by Region',
265
- query: {
266
- object: 'order',
267
- aggregations: [{ function: 'sum', field: 'amount', as: 'revenue' }],
268
- groupBy: ['account.billing_region'],
269
- },
270
- },
271
- ],
272
- };
273
-
274
- // Execute all dashboard queries
275
- const dashboardData = await analytics.executeDashboard(salesDashboard);
144
+ POST /api/v1/analytics/query # execute an AnalyticsQuery
145
+ GET /api/v1/analytics/meta[?cube=] # cube metadata for discovery
146
+ POST /api/v1/analytics/sql # generate SQL without executing (dry-run)
147
+ POST /api/v1/analytics/dataset/query # run a dataset selection (ADR-0021)
276
148
  ```
277
149
 
278
- ## Advanced Features
150
+ `POST /analytics/sql` answers 404 when the slot's occupant does not implement the
151
+ optional `generateSql`.
279
152
 
280
- ### Query Caching
153
+ ## Exports
281
154
 
282
155
  ```typescript
283
- // Enable caching for expensive queries
284
- const result = await analytics.query({
285
- object: 'order',
286
- aggregations: [{ function: 'sum', field: 'amount' }],
287
- cache: {
288
- enabled: true,
289
- ttl: 600, // 10 minutes
290
- },
291
- });
292
-
293
- // Invalidate cache when data changes
294
- analytics.invalidateCache('order');
156
+ import {
157
+ AnalyticsService, AnalyticsServicePlugin, CubeRegistry, DatasetExecutor,
158
+ NativeSQLStrategy, ObjectQLStrategy,
159
+ compileDataset, compileScopedFilterToSql,
160
+ combineFilters, evaluateDerivedMeasures, fillEmptyGroups, mergeByDimensions, shiftRange,
161
+ createOrderLabelResolver, pickDisplayField, resolveDimensionLabels, withLabelFetchCache,
162
+ } from '@objectstack/service-analytics';
295
163
  ```
296
164
 
297
- ### Comparative Analytics
298
-
299
- ```typescript
300
- // Compare current vs. previous period
301
- const comparison = await analytics.compare({
302
- object: 'order',
303
- aggregation: 'sum',
304
- field: 'amount',
305
- currentPeriod: {
306
- start: '2024-01-01',
307
- end: '2024-01-31',
308
- },
309
- comparisonPeriod: {
310
- start: '2023-12-01',
311
- end: '2023-12-31',
312
- },
313
- });
165
+ Types: `AnalyticsServiceConfig`, `AnalyticsServicePluginOptions`, `AnalyticsStrategy`,
166
+ `StrategyContext`, `AnalyticsDriverCapabilities`, `CompiledDataset`,
167
+ `DatasetCompileOptions`, `DatasetSelection`, `CompareTo`, `DerivedMeasureSpec`,
168
+ `RelationshipResolver`, `RelationshipTarget`, `DimensionLabelDeps`, `FieldMetaLite`,
169
+ `OrderLabelResolver`.
314
170
 
315
- // Result:
316
- // {
317
- // current: 125000,
318
- // comparison: 110000,
319
- // change: 15000,
320
- // percentChange: 13.64
321
- // }
322
- ```
171
+ ## Advanced: constructing the service directly
323
172
 
324
- ### Funnel Analysis
173
+ `AnalyticsService` is exported for hosts that wire their own kernel integration.
174
+ `AnalyticsServiceConfig` is the wider surface the plugin builds — it adds `logger`,
175
+ `strategies`, `fallbackService`, `coerceTemporalFilterValue`,
176
+ `coerceTemporalFilterColumn`, `isExternalObject`, `getObjectDatasource`,
177
+ `isRegisteredObject` and the dataset resolvers on top of the plugin options above.
325
178
 
326
179
  ```typescript
327
- // Define a conversion funnel
328
- const funnel = await analytics.funnel({
329
- steps: [
330
- { object: 'lead', stage: 'new' },
331
- { object: 'lead', stage: 'qualified' },
332
- { object: 'opportunity', stage: 'proposal' },
333
- { object: 'opportunity', stage: 'closed_won' },
334
- ],
335
- dateRange: {
336
- start: '2024-01-01',
337
- end: '2024-01-31',
338
- },
339
- });
340
-
341
- // Result:
342
- // {
343
- // steps: [
344
- // { stage: 'new', count: 1000, percentage: 100 },
345
- // { stage: 'qualified', count: 450, percentage: 45 },
346
- // { stage: 'proposal', count: 200, percentage: 20 },
347
- // { stage: 'closed_won', count: 75, percentage: 7.5 },
348
- // ],
349
- // overallConversion: 0.075
350
- // }
351
- ```
180
+ import { AnalyticsService, CubeRegistry } from '@objectstack/service-analytics';
352
181
 
353
- ## Contract Implementation
182
+ const registry = new CubeRegistry();
183
+ registry.registerAll([ordersCube]);
354
184
 
355
- Implements `IAnalyticsService` from `@objectstack/spec/contracts`:
356
-
357
- ```typescript
358
- interface IAnalyticsService {
359
- count(options: CountOptions): Promise<number>;
360
- sum(options: AggregationOptions): Promise<number>;
361
- avg(options: AggregationOptions): Promise<number>;
362
- min(options: AggregationOptions): Promise<number>;
363
- max(options: AggregationOptions): Promise<number>;
364
- groupBy(options: GroupByOptions): Promise<AggregationResult[]>;
365
- timeSeries(options: TimeSeriesOptions): Promise<TimeSeriesResult[]>;
366
- defineMetric(metric: MetricDefinition): void;
367
- getMetric(name: string): Promise<number | AggregationResult[]>;
368
- }
185
+ const service = new AnalyticsService({ cubes: [ordersCube] });
369
186
  ```
370
187
 
371
- ## Performance Optimization
372
-
373
- 1. **Choose the Right Driver**: Use SQL for large datasets, InMemory for small
374
- 2. **Enable Caching**: Cache expensive queries with appropriate TTL
375
- 3. **Optimize Filters**: Filter early to reduce dataset size
376
- 4. **Use Indexes**: Ensure database indexes on frequently queried fields
377
- 5. **Batch Queries**: Execute multiple metrics in a single dashboard query
378
-
379
- ## Best Practices
380
-
381
- 1. **Driver Selection**: Start with ObjectQL, optimize to SQL if needed
382
- 2. **Metric Definitions**: Define reusable metrics for consistency
383
- 3. **Cache Strategy**: Cache expensive queries, invalidate on data changes
384
- 4. **Time Series**: Use appropriate intervals (hour/day/week/month)
385
- 5. **Group By**: Limit grouping dimensions to avoid explosion of result sets
386
-
387
188
  ## License
388
189
 
389
190
  Apache-2.0. See [LICENSING.md](../../../LICENSING.md).
@@ -391,5 +192,5 @@ Apache-2.0. See [LICENSING.md](../../../LICENSING.md).
391
192
  ## See Also
392
193
 
393
194
  - [@objectstack/objectql](../../objectql/)
394
- - [@objectstack/spec/contracts](../../spec/src/contracts/)
395
- - [Analytics Guide](/content/docs/data-modeling/analytics.mdx)
195
+ - [@objectstack/driver-memory](../../drivers/driver-memory/) — ships `InMemoryStrategy`
196
+ - [Analytics Guide](https://objectstack.ai/docs/data-modeling/analytics)