Skip to content

Commit e972e4c

Browse files
authored
Merge pull request #599 from objectstack-ai/copilot/add-service-contract-interfaces
2 parents dc6f3a0 + 804d43a commit e972e4c

11 files changed

Lines changed: 1064 additions & 0 deletions
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
import { describe, it, expect } from 'vitest';
2+
import type { ICacheService, CacheStats } from './cache-service';
3+
4+
describe('Cache Service Contract', () => {
5+
it('should allow a minimal ICacheService implementation', () => {
6+
const cache: ICacheService = {
7+
get: async <T>(_key: string) => undefined as T | undefined,
8+
set: async (_key, _value, _ttl?) => {},
9+
delete: async (_key) => false,
10+
has: async (_key) => false,
11+
clear: async () => {},
12+
stats: async () => ({ hits: 0, misses: 0, keyCount: 0 }),
13+
};
14+
15+
expect(typeof cache.get).toBe('function');
16+
expect(typeof cache.set).toBe('function');
17+
expect(typeof cache.delete).toBe('function');
18+
expect(typeof cache.has).toBe('function');
19+
expect(typeof cache.clear).toBe('function');
20+
expect(typeof cache.stats).toBe('function');
21+
});
22+
23+
it('should perform basic cache operations', async () => {
24+
const store = new Map<string, { value: unknown; ttl?: number }>();
25+
26+
const cache: ICacheService = {
27+
get: async <T>(key: string) => {
28+
const entry = store.get(key);
29+
return entry ? entry.value as T : undefined;
30+
},
31+
set: async (key, value, ttl?) => {
32+
store.set(key, { value, ttl });
33+
},
34+
delete: async (key) => store.delete(key),
35+
has: async (key) => store.has(key),
36+
clear: async () => { store.clear(); },
37+
stats: async () => ({
38+
hits: 0,
39+
misses: 0,
40+
keyCount: store.size,
41+
}),
42+
};
43+
44+
await cache.set('user:1', { name: 'Alice' }, 60);
45+
expect(await cache.has('user:1')).toBe(true);
46+
expect(await cache.get('user:1')).toEqual({ name: 'Alice' });
47+
48+
await cache.delete('user:1');
49+
expect(await cache.has('user:1')).toBe(false);
50+
expect(await cache.get('user:1')).toBeUndefined();
51+
});
52+
53+
it('should return cache stats', async () => {
54+
let hits = 0;
55+
let misses = 0;
56+
const store = new Map<string, unknown>();
57+
58+
const cache: ICacheService = {
59+
get: async <T>(key: string) => {
60+
if (store.has(key)) {
61+
hits++;
62+
return store.get(key) as T;
63+
}
64+
misses++;
65+
return undefined;
66+
},
67+
set: async (key, value) => { store.set(key, value); },
68+
delete: async (key) => store.delete(key),
69+
has: async (key) => store.has(key),
70+
clear: async () => { store.clear(); },
71+
stats: async (): Promise<CacheStats> => ({
72+
hits,
73+
misses,
74+
keyCount: store.size,
75+
memoryUsage: 1024,
76+
}),
77+
};
78+
79+
await cache.set('a', 1);
80+
await cache.get('a');
81+
await cache.get('b');
82+
83+
const s = await cache.stats();
84+
expect(s.hits).toBe(1);
85+
expect(s.misses).toBe(1);
86+
expect(s.keyCount).toBe(1);
87+
expect(s.memoryUsage).toBe(1024);
88+
});
89+
90+
it('should clear all entries', async () => {
91+
const store = new Map<string, unknown>();
92+
93+
const cache: ICacheService = {
94+
get: async <T>(key: string) => store.get(key) as T | undefined,
95+
set: async (key, value) => { store.set(key, value); },
96+
delete: async (key) => store.delete(key),
97+
has: async (key) => store.has(key),
98+
clear: async () => { store.clear(); },
99+
stats: async () => ({ hits: 0, misses: 0, keyCount: store.size }),
100+
};
101+
102+
await cache.set('x', 1);
103+
await cache.set('y', 2);
104+
await cache.clear();
105+
106+
expect(await cache.has('x')).toBe(false);
107+
expect(await cache.has('y')).toBe(false);
108+
const s = await cache.stats();
109+
expect(s.keyCount).toBe(0);
110+
});
111+
});
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* ICacheService - Cache Service Contract
5+
*
6+
* Defines the interface for cache operations in ObjectStack.
7+
* Concrete implementations (Redis, Memory, etc.) should implement this interface.
8+
*
9+
* Follows Dependency Inversion Principle - plugins depend on this interface,
10+
* not on concrete cache implementations.
11+
*
12+
* Aligned with CoreServiceName 'cache' in core-services.zod.ts.
13+
*/
14+
15+
/**
16+
* Cache statistics for monitoring and observability
17+
*/
18+
export interface CacheStats {
19+
/** Total number of cache hits */
20+
hits: number;
21+
/** Total number of cache misses */
22+
misses: number;
23+
/** Number of keys currently stored */
24+
keyCount: number;
25+
/** Memory usage in bytes (if available) */
26+
memoryUsage?: number;
27+
}
28+
29+
export interface ICacheService {
30+
/**
31+
* Get a cached value by key
32+
* @param key - Cache key
33+
* @returns The cached value, or undefined if not found
34+
*/
35+
get<T = unknown>(key: string): Promise<T | undefined>;
36+
37+
/**
38+
* Set a value in the cache
39+
* @param key - Cache key
40+
* @param value - Value to cache
41+
* @param ttl - Optional time-to-live in seconds
42+
*/
43+
set<T = unknown>(key: string, value: T, ttl?: number): Promise<void>;
44+
45+
/**
46+
* Delete a cached value by key
47+
* @param key - Cache key
48+
* @returns True if the key was deleted, false if it did not exist
49+
*/
50+
delete(key: string): Promise<boolean>;
51+
52+
/**
53+
* Check if a key exists in the cache
54+
* @param key - Cache key
55+
* @returns True if the key exists
56+
*/
57+
has(key: string): Promise<boolean>;
58+
59+
/**
60+
* Clear all entries from the cache
61+
*/
62+
clear(): Promise<void>;
63+
64+
/**
65+
* Get cache statistics
66+
* @returns Cache stats including hits, misses, and key count
67+
*/
68+
stats(): Promise<CacheStats>;
69+
}

packages/spec/src/contracts/index.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,3 +15,8 @@ export * from './plugin-validator.js';
1515
export * from './startup-orchestrator.js';
1616
export * from './plugin-lifecycle-events.js';
1717
export * from './schema-driver.js';
18+
export * from './cache-service.js';
19+
export * from './search-service.js';
20+
export * from './queue-service.js';
21+
export * from './notification-service.js';
22+
export * from './storage-service.js';
Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
import { describe, it, expect } from 'vitest';
2+
import type { INotificationService, NotificationMessage, NotificationResult } from './notification-service';
3+
4+
describe('Notification Service Contract', () => {
5+
it('should allow a minimal INotificationService implementation with required methods', () => {
6+
const service: INotificationService = {
7+
send: async (_message) => ({ success: true }),
8+
};
9+
10+
expect(typeof service.send).toBe('function');
11+
});
12+
13+
it('should allow a full implementation with optional methods', () => {
14+
const service: INotificationService = {
15+
send: async () => ({ success: true }),
16+
sendBatch: async (messages) => messages.map(() => ({ success: true })),
17+
getChannels: () => ['email', 'sms', 'push'],
18+
};
19+
20+
expect(service.sendBatch).toBeDefined();
21+
expect(service.getChannels).toBeDefined();
22+
});
23+
24+
it('should send a notification successfully', async () => {
25+
const sent: NotificationMessage[] = [];
26+
27+
const service: INotificationService = {
28+
send: async (message): Promise<NotificationResult> => {
29+
sent.push(message);
30+
return { success: true, messageId: `msg-${sent.length}` };
31+
},
32+
};
33+
34+
const result = await service.send({
35+
channel: 'email',
36+
to: 'user@example.com',
37+
subject: 'Welcome',
38+
body: 'Hello, welcome to the platform!',
39+
});
40+
41+
expect(result.success).toBe(true);
42+
expect(result.messageId).toBe('msg-1');
43+
expect(sent).toHaveLength(1);
44+
expect(sent[0].channel).toBe('email');
45+
});
46+
47+
it('should handle notification failures', async () => {
48+
const service: INotificationService = {
49+
send: async (_message): Promise<NotificationResult> => ({
50+
success: false,
51+
error: 'Invalid recipient',
52+
}),
53+
};
54+
55+
const result = await service.send({
56+
channel: 'sms',
57+
to: '',
58+
body: 'Test',
59+
});
60+
61+
expect(result.success).toBe(false);
62+
expect(result.error).toBe('Invalid recipient');
63+
});
64+
65+
it('should send to multiple recipients', async () => {
66+
const delivered: string[] = [];
67+
68+
const service: INotificationService = {
69+
send: async (message) => {
70+
const recipients = Array.isArray(message.to) ? message.to : [message.to];
71+
delivered.push(...recipients);
72+
return { success: true };
73+
},
74+
};
75+
76+
await service.send({
77+
channel: 'push',
78+
to: ['user-1', 'user-2', 'user-3'],
79+
body: 'New update available',
80+
});
81+
82+
expect(delivered).toEqual(['user-1', 'user-2', 'user-3']);
83+
});
84+
85+
it('should support batch sending', async () => {
86+
const service: INotificationService = {
87+
send: async () => ({ success: true }),
88+
sendBatch: async (messages) =>
89+
messages.map((_, i) => ({
90+
success: i !== 1, // Second message fails
91+
messageId: `msg-${i}`,
92+
error: i === 1 ? 'Rate limited' : undefined,
93+
})),
94+
};
95+
96+
const results = await service.sendBatch!([
97+
{ channel: 'email', to: 'a@test.com', body: 'Hello A' },
98+
{ channel: 'email', to: 'b@test.com', body: 'Hello B' },
99+
{ channel: 'email', to: 'c@test.com', body: 'Hello C' },
100+
]);
101+
102+
expect(results).toHaveLength(3);
103+
expect(results[0].success).toBe(true);
104+
expect(results[1].success).toBe(false);
105+
expect(results[1].error).toBe('Rate limited');
106+
expect(results[2].success).toBe(true);
107+
});
108+
109+
it('should list available channels', () => {
110+
const service: INotificationService = {
111+
send: async () => ({ success: true }),
112+
getChannels: () => ['email', 'sms', 'in-app'],
113+
};
114+
115+
const channels = service.getChannels!();
116+
expect(channels).toContain('email');
117+
expect(channels).toContain('sms');
118+
expect(channels).toContain('in-app');
119+
});
120+
121+
it('should support template-based notifications', async () => {
122+
const sent: NotificationMessage[] = [];
123+
124+
const service: INotificationService = {
125+
send: async (message) => {
126+
sent.push(message);
127+
return { success: true };
128+
},
129+
};
130+
131+
await service.send({
132+
channel: 'email',
133+
to: 'user@example.com',
134+
body: '',
135+
templateId: 'welcome-email',
136+
templateData: { userName: 'Alice', activationUrl: 'https://example.com/activate' },
137+
});
138+
139+
expect(sent[0].templateId).toBe('welcome-email');
140+
expect(sent[0].templateData?.userName).toBe('Alice');
141+
});
142+
});

0 commit comments

Comments
 (0)