Skip to content

Commit 55d7400

Browse files
authored
Merge pull request #673 from objectstack-ai/copilot/extract-in-memory-fallbacks
2 parents 9d02e71 + 62d3910 commit 55d7400

File tree

8 files changed

+281
-70
lines changed

8 files changed

+281
-70
lines changed
Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
import { describe, it, expect } from 'vitest';
2+
import { createMemoryCache } from './memory-cache';
3+
import { createMemoryQueue } from './memory-queue';
4+
import { createMemoryJob } from './memory-job';
5+
import { CORE_FALLBACK_FACTORIES } from './index';
6+
7+
describe('CORE_FALLBACK_FACTORIES', () => {
8+
it('should have exactly 3 entries: cache, queue, job', () => {
9+
expect(Object.keys(CORE_FALLBACK_FACTORIES)).toEqual(['cache', 'queue', 'job']);
10+
});
11+
12+
it('should map to factory functions', () => {
13+
for (const factory of Object.values(CORE_FALLBACK_FACTORIES)) {
14+
expect(typeof factory).toBe('function');
15+
}
16+
});
17+
});
18+
19+
describe('createMemoryCache', () => {
20+
it('should return an object with _fallback: true', () => {
21+
const cache = createMemoryCache();
22+
expect(cache._fallback).toBe(true);
23+
expect(cache._serviceName).toBe('cache');
24+
});
25+
26+
it('should set and get a value', async () => {
27+
const cache = createMemoryCache();
28+
await cache.set('key1', 'value1');
29+
expect(await cache.get('key1')).toBe('value1');
30+
});
31+
32+
it('should return undefined for missing key', async () => {
33+
const cache = createMemoryCache();
34+
expect(await cache.get('nonexistent')).toBeUndefined();
35+
});
36+
37+
it('should delete a key', async () => {
38+
const cache = createMemoryCache();
39+
await cache.set('key1', 'value1');
40+
expect(await cache.delete('key1')).toBe(true);
41+
expect(await cache.get('key1')).toBeUndefined();
42+
});
43+
44+
it('should check if a key exists with has()', async () => {
45+
const cache = createMemoryCache();
46+
expect(await cache.has('key1')).toBe(false);
47+
await cache.set('key1', 'value1');
48+
expect(await cache.has('key1')).toBe(true);
49+
});
50+
51+
it('should clear all entries', async () => {
52+
const cache = createMemoryCache();
53+
await cache.set('a', 1);
54+
await cache.set('b', 2);
55+
await cache.clear();
56+
expect(await cache.has('a')).toBe(false);
57+
expect(await cache.has('b')).toBe(false);
58+
});
59+
60+
it('should expire entries based on TTL', async () => {
61+
const cache = createMemoryCache();
62+
// Set with very short TTL (0.001 seconds = 1ms)
63+
await cache.set('temp', 'data', 0.001);
64+
// Wait for expiry
65+
await new Promise(r => setTimeout(r, 20));
66+
expect(await cache.get('temp')).toBeUndefined();
67+
});
68+
69+
it('should track hit/miss stats', async () => {
70+
const cache = createMemoryCache();
71+
await cache.set('key1', 'value1');
72+
await cache.get('key1'); // hit
73+
await cache.get('missing'); // miss
74+
const stats = await cache.stats();
75+
expect(stats.hits).toBe(1);
76+
expect(stats.misses).toBe(1);
77+
expect(stats.keyCount).toBe(1);
78+
});
79+
});
80+
81+
describe('createMemoryQueue', () => {
82+
it('should return an object with _fallback: true', () => {
83+
const queue = createMemoryQueue();
84+
expect(queue._fallback).toBe(true);
85+
expect(queue._serviceName).toBe('queue');
86+
});
87+
88+
it('should publish and deliver to subscriber synchronously', async () => {
89+
const queue = createMemoryQueue();
90+
const received: any[] = [];
91+
await queue.subscribe('test-q', async (msg: any) => { received.push(msg); });
92+
const id = await queue.publish('test-q', { hello: 'world' });
93+
expect(id).toMatch(/^fallback-msg-/);
94+
expect(received).toHaveLength(1);
95+
expect(received[0].data).toEqual({ hello: 'world' });
96+
});
97+
98+
it('should not deliver to unsubscribed queue', async () => {
99+
const queue = createMemoryQueue();
100+
const received: any[] = [];
101+
await queue.subscribe('q1', async (msg: any) => { received.push(msg); });
102+
await queue.unsubscribe('q1');
103+
await queue.publish('q1', 'data');
104+
expect(received).toHaveLength(0);
105+
});
106+
107+
it('should return queue size of 0', async () => {
108+
const queue = createMemoryQueue();
109+
expect(await queue.getQueueSize()).toBe(0);
110+
});
111+
112+
it('should purge a queue', async () => {
113+
const queue = createMemoryQueue();
114+
const received: any[] = [];
115+
await queue.subscribe('q1', async (msg: any) => { received.push(msg); });
116+
await queue.purge('q1');
117+
await queue.publish('q1', 'data');
118+
expect(received).toHaveLength(0);
119+
});
120+
});
121+
122+
describe('createMemoryJob', () => {
123+
it('should return an object with _fallback: true', () => {
124+
const job = createMemoryJob();
125+
expect(job._fallback).toBe(true);
126+
expect(job._serviceName).toBe('job');
127+
});
128+
129+
it('should schedule and list jobs', async () => {
130+
const job = createMemoryJob();
131+
await job.schedule('daily-report', '0 0 * * *', async () => {});
132+
expect(await job.listJobs()).toEqual(['daily-report']);
133+
});
134+
135+
it('should cancel a job', async () => {
136+
const job = createMemoryJob();
137+
await job.schedule('temp-job', '* * * * *', async () => {});
138+
await job.cancel('temp-job');
139+
expect(await job.listJobs()).toEqual([]);
140+
});
141+
142+
it('should trigger a job handler', async () => {
143+
const job = createMemoryJob();
144+
let triggered = false;
145+
await job.schedule('my-job', '* * * * *', async (ctx: any) => {
146+
triggered = true;
147+
expect(ctx.jobId).toBe('my-job');
148+
expect(ctx.data).toEqual({ key: 'val' });
149+
});
150+
await job.trigger('my-job', { key: 'val' });
151+
expect(triggered).toBe(true);
152+
});
153+
154+
it('should return empty executions', async () => {
155+
const job = createMemoryJob();
156+
expect(await job.getExecutions()).toEqual([]);
157+
});
158+
});
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { createMemoryCache } from './memory-cache.js';
4+
import { createMemoryQueue } from './memory-queue.js';
5+
import { createMemoryJob } from './memory-job.js';
6+
7+
export { createMemoryCache } from './memory-cache.js';
8+
export { createMemoryQueue } from './memory-queue.js';
9+
export { createMemoryJob } from './memory-job.js';
10+
11+
/**
12+
* Map of core-criticality service names to their in-memory fallback factories.
13+
* Used by ObjectKernel.validateSystemRequirements() to auto-inject fallbacks
14+
* when no real plugin provides the service.
15+
*/
16+
export const CORE_FALLBACK_FACTORIES: Record<string, () => Record<string, any>> = {
17+
cache: createMemoryCache,
18+
queue: createMemoryQueue,
19+
job: createMemoryJob,
20+
};
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* In-memory Map-backed cache fallback.
5+
*
6+
* Implements the ICacheService contract with basic get/set/delete/has/clear
7+
* and TTL expiry. Used by ObjectKernel as an automatic fallback when no
8+
* real cache plugin (e.g. Redis) is registered.
9+
*/
10+
export function createMemoryCache() {
11+
const store = new Map<string, { value: unknown; expires?: number }>();
12+
let hits = 0;
13+
let misses = 0;
14+
return {
15+
_fallback: true, _serviceName: 'cache',
16+
async get<T = unknown>(key: string): Promise<T | undefined> {
17+
const entry = store.get(key);
18+
if (!entry || (entry.expires && Date.now() > entry.expires)) {
19+
store.delete(key);
20+
misses++;
21+
return undefined;
22+
}
23+
hits++;
24+
return entry.value as T;
25+
},
26+
async set<T = unknown>(key: string, value: T, ttl?: number): Promise<void> {
27+
store.set(key, { value, expires: ttl ? Date.now() + ttl * 1000 : undefined });
28+
},
29+
async delete(key: string): Promise<boolean> { return store.delete(key); },
30+
async has(key: string): Promise<boolean> { return store.has(key); },
31+
async clear(): Promise<void> { store.clear(); },
32+
async stats() { return { hits, misses, keyCount: store.size }; },
33+
};
34+
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* In-memory job scheduler fallback.
5+
*
6+
* Implements the IJobService contract with basic schedule/cancel/trigger
7+
* operations. Used by ObjectKernel as an automatic fallback when no real
8+
* job plugin (e.g. Agenda / BullMQ) is registered.
9+
*/
10+
export function createMemoryJob() {
11+
const jobs = new Map<string, any>();
12+
return {
13+
_fallback: true, _serviceName: 'job',
14+
async schedule(name: string, schedule: any, handler: any): Promise<void> { jobs.set(name, { schedule, handler }); },
15+
async cancel(name: string): Promise<void> { jobs.delete(name); },
16+
async trigger(name: string, data?: unknown): Promise<void> {
17+
const job = jobs.get(name);
18+
if (job?.handler) await job.handler({ jobId: name, data });
19+
},
20+
async getExecutions(): Promise<any[]> { return []; },
21+
async listJobs(): Promise<string[]> { return [...jobs.keys()]; },
22+
};
23+
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* In-memory publish/subscribe queue fallback.
5+
*
6+
* Implements the IQueueService contract with synchronous in-process delivery.
7+
* Used by ObjectKernel as an automatic fallback when no real queue plugin
8+
* (e.g. BullMQ / RabbitMQ) is registered.
9+
*/
10+
export function createMemoryQueue() {
11+
const handlers = new Map<string, Function[]>();
12+
let msgId = 0;
13+
return {
14+
_fallback: true, _serviceName: 'queue',
15+
async publish<T = unknown>(queue: string, data: T): Promise<string> {
16+
const id = `fallback-msg-${++msgId}`;
17+
const fns = handlers.get(queue) ?? [];
18+
for (const fn of fns) fn({ id, data, attempts: 1, timestamp: Date.now() });
19+
return id;
20+
},
21+
async subscribe(queue: string, handler: (msg: any) => Promise<void>): Promise<void> {
22+
handlers.set(queue, [...(handlers.get(queue) ?? []), handler]);
23+
},
24+
async unsubscribe(queue: string): Promise<void> { handlers.delete(queue); },
25+
async getQueueSize(): Promise<number> { return 0; },
26+
async purge(queue: string): Promise<void> { handlers.delete(queue); },
27+
};
28+
}

packages/core/src/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,9 @@ export * from './security/index.js';
2323
// Export environment utilities
2424
export * from './utils/env.js';
2525

26+
// Export in-memory fallbacks for core-criticality services
27+
export * from './fallbacks/index.js';
28+
2629
// Export Phase 2 components - Advanced lifecycle management
2730
export * from './health-monitor.js';
2831
export * from './hot-reload.js';

packages/core/src/kernel.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import type { LoggerConfig } from '@objectstack/spec/system';
66
import { ServiceRequirementDef } from '@objectstack/spec/system';
77
import { PluginLoader, PluginMetadata, ServiceLifecycle, ServiceFactory, PluginStartupResult } from './plugin-loader.js';
88
import { isNode, safeExit } from './utils/env.js';
9+
import { CORE_FALLBACK_FACTORIES } from './fallbacks/index.js';
910

1011
/**
1112
* Enhanced Kernel Configuration
@@ -234,8 +235,16 @@ export class ObjectKernel {
234235
this.logger.error(`CRITICAL: Required service missing: ${serviceName}`);
235236
missingServices.push(serviceName);
236237
} else if (criticality === 'core') {
237-
this.logger.warn(`CORE: Core service missing, functionality may be degraded: ${serviceName}`);
238-
missingCoreServices.push(serviceName);
238+
// Auto-inject in-memory fallback if available
239+
const factory = CORE_FALLBACK_FACTORIES[serviceName];
240+
if (factory) {
241+
const fallback = factory();
242+
this.registerService(serviceName, fallback);
243+
this.logger.warn(`Service '${serviceName}' not provided — using in-memory fallback`);
244+
} else {
245+
this.logger.warn(`CORE: Core service missing, functionality may be degraded: ${serviceName}`);
246+
missingCoreServices.push(serviceName);
247+
}
239248
} else {
240249
this.logger.info(`Info: Optional service not present: ${serviceName}`);
241250
}

packages/plugins/plugin-dev/src/dev-plugin.ts

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

3-
import { Plugin, PluginContext } from '@objectstack/core';
3+
import { Plugin, PluginContext, createMemoryCache, createMemoryQueue, createMemoryJob } from '@objectstack/core';
44

55
/**
66
* All 17 core kernel service names as defined in CoreServiceName.
@@ -33,70 +33,6 @@ const SECURITY_SERVICE_NAMES = [
3333
* a trivially useful implementation.
3434
*/
3535

36-
/** ICacheService — in-memory Map-backed stub */
37-
function createCacheStub() {
38-
const store = new Map<string, { value: unknown; expires?: number }>();
39-
let hits = 0;
40-
let misses = 0;
41-
return {
42-
_dev: true, _serviceName: 'cache',
43-
async get<T = unknown>(key: string): Promise<T | undefined> {
44-
const entry = store.get(key);
45-
if (!entry || (entry.expires && Date.now() > entry.expires)) {
46-
store.delete(key);
47-
misses++;
48-
return undefined;
49-
}
50-
hits++;
51-
return entry.value as T;
52-
},
53-
async set<T = unknown>(key: string, value: T, ttl?: number): Promise<void> {
54-
store.set(key, { value, expires: ttl ? Date.now() + ttl * 1000 : undefined });
55-
},
56-
async delete(key: string): Promise<boolean> { return store.delete(key); },
57-
async has(key: string): Promise<boolean> { return store.has(key); },
58-
async clear(): Promise<void> { store.clear(); },
59-
async stats() { return { hits, misses, keyCount: store.size }; },
60-
};
61-
}
62-
63-
/** IQueueService — in-memory publish/subscribe stub */
64-
function createQueueStub() {
65-
const handlers = new Map<string, Function[]>();
66-
let msgId = 0;
67-
return {
68-
_dev: true, _serviceName: 'queue',
69-
async publish<T = unknown>(queue: string, data: T): Promise<string> {
70-
const id = `dev-msg-${++msgId}`;
71-
const fns = handlers.get(queue) ?? [];
72-
for (const fn of fns) fn({ id, data, attempts: 1, timestamp: Date.now() });
73-
return id;
74-
},
75-
async subscribe(queue: string, handler: (msg: any) => Promise<void>): Promise<void> {
76-
handlers.set(queue, [...(handlers.get(queue) ?? []), handler]);
77-
},
78-
async unsubscribe(queue: string): Promise<void> { handlers.delete(queue); },
79-
async getQueueSize(): Promise<number> { return 0; },
80-
async purge(queue: string): Promise<void> { handlers.delete(queue); },
81-
};
82-
}
83-
84-
/** IJobService — no-op job scheduler stub */
85-
function createJobStub() {
86-
const jobs = new Map<string, any>();
87-
return {
88-
_dev: true, _serviceName: 'job',
89-
async schedule(name: string, schedule: any, handler: any): Promise<void> { jobs.set(name, { schedule, handler }); },
90-
async cancel(name: string): Promise<void> { jobs.delete(name); },
91-
async trigger(name: string, data?: unknown): Promise<void> {
92-
const job = jobs.get(name);
93-
if (job?.handler) await job.handler({ jobId: name, data });
94-
},
95-
async getExecutions(): Promise<any[]> { return []; },
96-
async listJobs(): Promise<string[]> { return [...jobs.keys()]; },
97-
};
98-
}
99-
10036
/** IStorageService — in-memory file storage stub */
10137
function createStorageStub() {
10238
const files = new Map<string, { data: Buffer; meta: any }>();
@@ -346,9 +282,9 @@ function createSecurityFieldMaskerStub() {
346282
* from `packages/spec/src/contracts/`.
347283
*/
348284
const DEV_STUB_FACTORIES: Record<string, () => Record<string, any>> = {
349-
'cache': createCacheStub,
350-
'queue': createQueueStub,
351-
'job': createJobStub,
285+
'cache': createMemoryCache,
286+
'queue': createMemoryQueue,
287+
'job': createMemoryJob,
352288
'file-storage': createStorageStub,
353289
'search': createSearchStub,
354290
'automation': createAutomationStub,

0 commit comments

Comments
 (0)