Skip to content

Commit ac2177b

Browse files
Copilothotlong
andcommitted
refactor: move InMemoryStrategy to driver-memory, strategy contracts to spec
- Move AnalyticsStrategy, StrategyContext, DriverCapabilities interfaces to @objectstack/spec/contracts (Protocol First principle) - Move InMemoryStrategy from service-analytics to driver-memory - Replace built-in InMemoryStrategy with internal FallbackDelegateStrategy in AnalyticsService (auto-added when fallbackService is configured) - service-analytics/strategies/types.ts now re-exports from spec - Analytics service registration remains in service-analytics (already correct) - All tests pass: 30 service-analytics, 94 driver-memory, 6704 spec Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com> Agent-Logs-Url: https://github.com/objectstack-ai/spec/sessions/94c99192-9b5e-4b97-809a-902bc64293c3
1 parent 9d85f1b commit ac2177b

7 files changed

Lines changed: 182 additions & 156 deletions

File tree

packages/services/service-analytics/src/strategies/in-memory-strategy.ts renamed to packages/plugins/driver-memory/src/in-memory-strategy.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,12 @@
11
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
22

3-
import type { AnalyticsQuery, AnalyticsResult } from '@objectstack/spec/contracts';
4-
import type { AnalyticsStrategy, StrategyContext } from './types.js';
3+
import type { AnalyticsQuery, AnalyticsResult, AnalyticsStrategy, StrategyContext } from '@objectstack/spec/contracts';
54

65
/**
76
* InMemoryStrategy — Priority 3
87
*
98
* Delegates to an existing `IAnalyticsService` instance that was registered
10-
* as a fallback (typically `MemoryAnalyticsService` from `@objectstack/driver-memory`).
9+
* as a fallback (typically `MemoryAnalyticsService` from this package).
1110
*
1211
* This is the lowest-priority strategy, used in:
1312
* - `dev` / `test` environments

packages/plugins/driver-memory/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ export { LocalStoragePersistenceAdapter } from './persistence/local-storage-adap
1111
export { MemoryAnalyticsService } from './memory-analytics.js';
1212
export type { MemoryAnalyticsConfig } from './memory-analytics.js';
1313

14+
export { InMemoryStrategy } from './in-memory-strategy.js';
15+
1416
export default {
1517
id: 'com.objectstack.driver.memory',
1618
version: '1.0.0',

packages/services/service-analytics/src/__tests__/analytics-service.test.ts

Lines changed: 39 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@ import { AnalyticsService } from '../analytics-service.js';
77
import { CubeRegistry } from '../cube-registry.js';
88
import { NativeSQLStrategy } from '../strategies/native-sql-strategy.js';
99
import { ObjectQLStrategy } from '../strategies/objectql-strategy.js';
10-
import { InMemoryStrategy } from '../strategies/in-memory-strategy.js';
1110
import type { DriverCapabilities } from '../strategies/types.js';
1211

1312
// ─────────────────────────────────────────────────────────────────
@@ -262,98 +261,69 @@ describe('ObjectQLStrategy', () => {
262261
});
263262

264263
// ─────────────────────────────────────────────────────────────────
265-
// InMemoryStrategy
264+
// FallbackDelegateStrategy (internal, tested via AnalyticsService)
266265
// ─────────────────────────────────────────────────────────────────
267266

268-
describe('InMemoryStrategy', () => {
269-
const strategy = new InMemoryStrategy();
270-
271-
it('should have correct name and priority', () => {
272-
expect(strategy.name).toBe('InMemoryStrategy');
273-
expect(strategy.priority).toBe(30);
274-
});
275-
276-
it('should handle when fallbackService is available', () => {
277-
const ctx = {
278-
getCube: () => ordersCube,
279-
queryCapabilities: () => ({ nativeSql: false, objectqlAggregate: false, inMemory: false }),
280-
fallbackService: {
281-
query: vi.fn(),
282-
getMeta: vi.fn(),
283-
},
284-
};
285-
expect(strategy.canHandle(baseQuery, ctx)).toBe(true);
286-
});
287-
288-
it('should handle when inMemory capability is true', () => {
289-
const ctx = {
290-
getCube: () => ordersCube,
291-
queryCapabilities: () => ({ nativeSql: false, objectqlAggregate: false, inMemory: true }),
292-
};
293-
expect(strategy.canHandle(baseQuery, ctx)).toBe(true);
294-
});
295-
296-
it('should not handle without fallback and not inMemory', () => {
297-
const ctx = {
298-
getCube: () => ordersCube,
299-
queryCapabilities: () => ({ nativeSql: false, objectqlAggregate: false, inMemory: false }),
300-
};
301-
expect(strategy.canHandle(baseQuery, ctx)).toBe(false);
302-
});
303-
304-
it('should delegate to fallback service', async () => {
267+
describe('FallbackDelegateStrategy (via AnalyticsService)', () => {
268+
it('should auto-add FallbackDelegateStrategy when fallbackService is configured', async () => {
305269
const mockResult: AnalyticsResult = { rows: [{ count: 10 }], fields: [{ name: 'count', type: 'number' }] };
306-
const fallbackService = {
270+
const fallback: IAnalyticsService = {
307271
query: vi.fn().mockResolvedValue(mockResult),
308-
getMeta: vi.fn(),
272+
getMeta: vi.fn().mockResolvedValue([]),
309273
};
310274

311-
const ctx = {
312-
getCube: () => ordersCube,
313-
queryCapabilities: () => ({ nativeSql: false, objectqlAggregate: false, inMemory: true }),
314-
fallbackService,
315-
};
275+
const service = new AnalyticsService({
276+
cubes: [ordersCube],
277+
logger: silentLogger,
278+
fallbackService: fallback,
279+
});
316280

317-
const result = await strategy.execute(baseQuery, ctx);
318-
expect(fallbackService.query).toHaveBeenCalledWith(baseQuery);
281+
const result = await service.query(baseQuery);
282+
expect(fallback.query).toHaveBeenCalledWith(baseQuery);
319283
expect(result).toEqual(mockResult);
320284
});
321285

322-
it('should throw when no fallback service', async () => {
323-
const ctx = {
324-
getCube: () => ordersCube,
325-
queryCapabilities: () => ({ nativeSql: false, objectqlAggregate: false, inMemory: true }),
326-
};
286+
it('should NOT add FallbackDelegateStrategy when no fallbackService', async () => {
287+
const service = new AnalyticsService({
288+
cubes: [ordersCube],
289+
logger: silentLogger,
290+
queryCapabilities: () => ({ nativeSql: false, objectqlAggregate: false, inMemory: false }),
291+
});
327292

328-
await expect(strategy.execute(baseQuery, ctx)).rejects.toThrow('No fallback analytics service');
293+
await expect(service.query(baseQuery)).rejects.toThrow('No strategy can handle');
329294
});
330295

331-
it('should delegate generateSql to fallback', async () => {
332-
const fallbackService = {
296+
it('should delegate generateSql to fallback service', async () => {
297+
const fallback: IAnalyticsService = {
333298
query: vi.fn(),
334299
getMeta: vi.fn(),
335300
generateSql: vi.fn().mockResolvedValue({ sql: 'SELECT 1', params: [] }),
336301
};
337302

338-
const ctx = {
339-
getCube: () => ordersCube,
340-
queryCapabilities: () => ({ nativeSql: false, objectqlAggregate: false, inMemory: true }),
341-
fallbackService,
342-
};
303+
const service = new AnalyticsService({
304+
cubes: [ordersCube],
305+
logger: silentLogger,
306+
fallbackService: fallback,
307+
});
343308

344-
const { sql } = await strategy.generateSql(baseQuery, ctx);
309+
const { sql } = await service.generateSql(baseQuery);
345310
expect(sql).toBe('SELECT 1');
346311
});
347312

348313
it('should return placeholder SQL when fallback has no generateSql', async () => {
349-
const ctx = {
350-
getCube: () => ordersCube,
351-
queryCapabilities: () => ({ nativeSql: false, objectqlAggregate: false, inMemory: true }),
352-
fallbackService: { query: vi.fn(), getMeta: vi.fn() },
314+
const fallback: IAnalyticsService = {
315+
query: vi.fn().mockResolvedValue({ rows: [], fields: [] }),
316+
getMeta: vi.fn(),
353317
};
354318

355-
const { sql } = await strategy.generateSql(baseQuery, ctx);
356-
expect(sql).toContain('InMemoryStrategy');
319+
const service = new AnalyticsService({
320+
cubes: [ordersCube],
321+
logger: silentLogger,
322+
fallbackService: fallback,
323+
});
324+
325+
const { sql } = await service.generateSql(baseQuery);
326+
expect(sql).toContain('FallbackDelegateStrategy');
357327
});
358328
});
359329

@@ -389,7 +359,7 @@ describe('AnalyticsService', () => {
389359
expect(result.rows).toHaveLength(1);
390360
});
391361

392-
it('should fall back to InMemoryStrategy with fallback service', async () => {
362+
it('should fall back to FallbackDelegateStrategy with fallback service', async () => {
393363
const mockResult: AnalyticsResult = {
394364
rows: [{ count: 100 }],
395365
fields: [{ name: 'count', type: 'number' }],

packages/services/service-analytics/src/analytics-service.ts

Lines changed: 44 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,6 @@ import { CubeRegistry } from './cube-registry.js';
1313
import type { AnalyticsStrategy, DriverCapabilities, StrategyContext } from './strategies/types.js';
1414
import { NativeSQLStrategy } from './strategies/native-sql-strategy.js';
1515
import { ObjectQLStrategy } from './strategies/objectql-strategy.js';
16-
import { InMemoryStrategy } from './strategies/in-memory-strategy.js';
1716

1817
/**
1918
* Configuration for AnalyticsService.
@@ -73,7 +72,10 @@ const DEFAULT_CAPABILITIES: DriverCapabilities = {
7372
* |:---:|:---|:---|
7473
* | P1 (10) | NativeSQLStrategy | Driver supports raw SQL |
7574
* | P2 (20) | ObjectQLStrategy | Driver supports aggregate AST |
76-
* | P3 (30) | InMemoryStrategy | Fallback service registered |
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.
7779
*
7880
* The service also owns a `CubeRegistry` for metadata discovery and
7981
* auto-inference from object schemas.
@@ -103,11 +105,19 @@ export class AnalyticsService implements IAnalyticsService {
103105
};
104106

105107
// 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.
106111
const builtIn: AnalyticsStrategy[] = [
107112
new NativeSQLStrategy(),
108113
new ObjectQLStrategy(),
109-
new InMemoryStrategy(),
110114
];
115+
116+
// Auto-add fallback delegate when fallbackService is provided
117+
if (config.fallbackService) {
118+
builtIn.push(new FallbackDelegateStrategy());
119+
}
120+
111121
const custom = config.strategies || [];
112122
this.strategies = [...builtIn, ...custom].sort((a, b) => a.priority - b.priority);
113123

@@ -188,3 +198,34 @@ export class AnalyticsService implements IAnalyticsService {
188198
);
189199
}
190200
}
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+
}

packages/services/service-analytics/src/index.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,5 +14,6 @@ export { CubeRegistry } from './cube-registry.js';
1414
// Strategies
1515
export { NativeSQLStrategy } from './strategies/native-sql-strategy.js';
1616
export { ObjectQLStrategy } from './strategies/objectql-strategy.js';
17-
export { InMemoryStrategy } from './strategies/in-memory-strategy.js';
1817
export type { AnalyticsStrategy, StrategyContext, DriverCapabilities } from './strategies/types.js';
18+
19+
// Note: InMemoryStrategy is exported from @objectstack/driver-memory
Lines changed: 7 additions & 80 deletions
Original file line numberDiff line numberDiff line change
@@ -1,84 +1,11 @@
11
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
22

3-
import type { AnalyticsQuery, AnalyticsResult, CubeMeta } from '@objectstack/spec/contracts';
4-
import type { Cube } from '@objectstack/spec/data';
5-
6-
/**
7-
* Driver capability descriptor.
8-
*
9-
* Used by the strategy chain to decide at runtime which execution path
10-
* is available for a given cube / object.
11-
*/
12-
export interface DriverCapabilities {
13-
/** Driver supports native SQL execution (e.g. Postgres, MySQL, SQLite). */
14-
nativeSql: boolean;
15-
/** Driver supports ObjectQL aggregate() operations. */
16-
objectqlAggregate: boolean;
17-
/** Driver is an in-memory implementation (dev/test only). */
18-
inMemory: boolean;
19-
}
20-
21-
/**
22-
* Context passed to every strategy so it can access shared infrastructure.
23-
*/
24-
export interface StrategyContext {
25-
/** Resolve a cube definition by name. */
26-
getCube(name: string): Cube | undefined;
27-
/** Probe driver capabilities for the object backing a cube. */
28-
queryCapabilities(cubeName: string): DriverCapabilities;
29-
/**
30-
* Execute a raw SQL string on the driver that owns `objectName`.
31-
* Only available when `nativeSql` capability is true.
32-
*/
33-
executeRawSql?(objectName: string, sql: string, params: unknown[]): Promise<Record<string, unknown>[]>;
34-
/**
35-
* Execute an ObjectQL aggregate query.
36-
* Only available when `objectqlAggregate` capability is true.
37-
*/
38-
executeAggregate?(objectName: string, options: {
39-
groupBy?: string[];
40-
aggregations?: Array<{ field: string; method: string; alias: string }>;
41-
filter?: Record<string, unknown>;
42-
}): Promise<Record<string, unknown>[]>;
43-
/**
44-
* Fallback in-memory analytics service (e.g. MemoryAnalyticsService from driver-memory).
45-
*/
46-
fallbackService?: {
47-
query(query: AnalyticsQuery): Promise<AnalyticsResult>;
48-
getMeta(cubeName?: string): Promise<CubeMeta[]>;
49-
generateSql?(query: AnalyticsQuery): Promise<{ sql: string; params: unknown[] }>;
50-
};
51-
}
52-
533
/**
54-
* AnalyticsStrategy — One link in the priority-ordered strategy chain.
55-
*
56-
* Each strategy is responsible for:
57-
* 1. Determining whether it *can* handle a query (via `canHandle`).
58-
* 2. Executing the query using its specific driver path.
59-
* 3. Optionally generating a SQL representation of the query.
4+
* Strategy pattern types — re-exported from @objectstack/spec/contracts
5+
* for convenience. The canonical definitions live in the spec package.
606
*/
61-
export interface AnalyticsStrategy {
62-
/** Human-readable strategy name (e.g. 'NativeSQLStrategy'). */
63-
readonly name: string;
64-
/** Priority (lower = higher priority). P1=10, P2=20, P3=30. */
65-
readonly priority: number;
66-
67-
/**
68-
* Return `true` if this strategy can handle the given query in the
69-
* current runtime context (driver capabilities, cube availability, etc.).
70-
*/
71-
canHandle(query: AnalyticsQuery, ctx: StrategyContext): boolean;
72-
73-
/**
74-
* Execute the analytical query.
75-
* Called only when `canHandle` returned `true`.
76-
*/
77-
execute(query: AnalyticsQuery, ctx: StrategyContext): Promise<AnalyticsResult>;
78-
79-
/**
80-
* Generate a SQL representation without executing.
81-
* Called only when `canHandle` returned `true`.
82-
*/
83-
generateSql(query: AnalyticsQuery, ctx: StrategyContext): Promise<{ sql: string; params: unknown[] }>;
84-
}
7+
export type {
8+
AnalyticsStrategy,
9+
StrategyContext,
10+
DriverCapabilities,
11+
} from '@objectstack/spec/contracts';

0 commit comments

Comments
 (0)