From dbaf263773b32119c00c3a93d5a99507940211ef Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 14 Feb 2026 01:35:43 +0000 Subject: [PATCH 1/2] Initial plan From 62d3910fdf742302213d540b1166cdce3ad3cb62 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 14 Feb 2026 01:41:30 +0000 Subject: [PATCH 2/2] feat: extract core-level in-memory fallbacks for cache/queue/job into @objectstack/core - Create packages/core/src/fallbacks/ with memory-cache.ts, memory-queue.ts, memory-job.ts - Wire CORE_FALLBACK_FACTORIES into ObjectKernel.validateSystemRequirements() - Export fallbacks from core index.ts - Add comprehensive tests for all fallback implementations - Update plugin-dev to import from @objectstack/core instead of local stubs Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com> --- packages/core/src/fallbacks/fallbacks.test.ts | 158 ++++++++++++++++++ packages/core/src/fallbacks/index.ts | 20 +++ packages/core/src/fallbacks/memory-cache.ts | 34 ++++ packages/core/src/fallbacks/memory-job.ts | 23 +++ packages/core/src/fallbacks/memory-queue.ts | 28 ++++ packages/core/src/index.ts | 3 + packages/core/src/kernel.ts | 13 +- packages/plugins/plugin-dev/src/dev-plugin.ts | 72 +------- 8 files changed, 281 insertions(+), 70 deletions(-) create mode 100644 packages/core/src/fallbacks/fallbacks.test.ts create mode 100644 packages/core/src/fallbacks/index.ts create mode 100644 packages/core/src/fallbacks/memory-cache.ts create mode 100644 packages/core/src/fallbacks/memory-job.ts create mode 100644 packages/core/src/fallbacks/memory-queue.ts diff --git a/packages/core/src/fallbacks/fallbacks.test.ts b/packages/core/src/fallbacks/fallbacks.test.ts new file mode 100644 index 000000000..3e13acfdf --- /dev/null +++ b/packages/core/src/fallbacks/fallbacks.test.ts @@ -0,0 +1,158 @@ +import { describe, it, expect } from 'vitest'; +import { createMemoryCache } from './memory-cache'; +import { createMemoryQueue } from './memory-queue'; +import { createMemoryJob } from './memory-job'; +import { CORE_FALLBACK_FACTORIES } from './index'; + +describe('CORE_FALLBACK_FACTORIES', () => { + it('should have exactly 3 entries: cache, queue, job', () => { + expect(Object.keys(CORE_FALLBACK_FACTORIES)).toEqual(['cache', 'queue', 'job']); + }); + + it('should map to factory functions', () => { + for (const factory of Object.values(CORE_FALLBACK_FACTORIES)) { + expect(typeof factory).toBe('function'); + } + }); +}); + +describe('createMemoryCache', () => { + it('should return an object with _fallback: true', () => { + const cache = createMemoryCache(); + expect(cache._fallback).toBe(true); + expect(cache._serviceName).toBe('cache'); + }); + + it('should set and get a value', async () => { + const cache = createMemoryCache(); + await cache.set('key1', 'value1'); + expect(await cache.get('key1')).toBe('value1'); + }); + + it('should return undefined for missing key', async () => { + const cache = createMemoryCache(); + expect(await cache.get('nonexistent')).toBeUndefined(); + }); + + it('should delete a key', async () => { + const cache = createMemoryCache(); + await cache.set('key1', 'value1'); + expect(await cache.delete('key1')).toBe(true); + expect(await cache.get('key1')).toBeUndefined(); + }); + + it('should check if a key exists with has()', async () => { + const cache = createMemoryCache(); + expect(await cache.has('key1')).toBe(false); + await cache.set('key1', 'value1'); + expect(await cache.has('key1')).toBe(true); + }); + + it('should clear all entries', async () => { + const cache = createMemoryCache(); + await cache.set('a', 1); + await cache.set('b', 2); + await cache.clear(); + expect(await cache.has('a')).toBe(false); + expect(await cache.has('b')).toBe(false); + }); + + it('should expire entries based on TTL', async () => { + const cache = createMemoryCache(); + // Set with very short TTL (0.001 seconds = 1ms) + await cache.set('temp', 'data', 0.001); + // Wait for expiry + await new Promise(r => setTimeout(r, 20)); + expect(await cache.get('temp')).toBeUndefined(); + }); + + it('should track hit/miss stats', async () => { + const cache = createMemoryCache(); + await cache.set('key1', 'value1'); + await cache.get('key1'); // hit + await cache.get('missing'); // miss + const stats = await cache.stats(); + expect(stats.hits).toBe(1); + expect(stats.misses).toBe(1); + expect(stats.keyCount).toBe(1); + }); +}); + +describe('createMemoryQueue', () => { + it('should return an object with _fallback: true', () => { + const queue = createMemoryQueue(); + expect(queue._fallback).toBe(true); + expect(queue._serviceName).toBe('queue'); + }); + + it('should publish and deliver to subscriber synchronously', async () => { + const queue = createMemoryQueue(); + const received: any[] = []; + await queue.subscribe('test-q', async (msg: any) => { received.push(msg); }); + const id = await queue.publish('test-q', { hello: 'world' }); + expect(id).toMatch(/^fallback-msg-/); + expect(received).toHaveLength(1); + expect(received[0].data).toEqual({ hello: 'world' }); + }); + + it('should not deliver to unsubscribed queue', async () => { + const queue = createMemoryQueue(); + const received: any[] = []; + await queue.subscribe('q1', async (msg: any) => { received.push(msg); }); + await queue.unsubscribe('q1'); + await queue.publish('q1', 'data'); + expect(received).toHaveLength(0); + }); + + it('should return queue size of 0', async () => { + const queue = createMemoryQueue(); + expect(await queue.getQueueSize()).toBe(0); + }); + + it('should purge a queue', async () => { + const queue = createMemoryQueue(); + const received: any[] = []; + await queue.subscribe('q1', async (msg: any) => { received.push(msg); }); + await queue.purge('q1'); + await queue.publish('q1', 'data'); + expect(received).toHaveLength(0); + }); +}); + +describe('createMemoryJob', () => { + it('should return an object with _fallback: true', () => { + const job = createMemoryJob(); + expect(job._fallback).toBe(true); + expect(job._serviceName).toBe('job'); + }); + + it('should schedule and list jobs', async () => { + const job = createMemoryJob(); + await job.schedule('daily-report', '0 0 * * *', async () => {}); + expect(await job.listJobs()).toEqual(['daily-report']); + }); + + it('should cancel a job', async () => { + const job = createMemoryJob(); + await job.schedule('temp-job', '* * * * *', async () => {}); + await job.cancel('temp-job'); + expect(await job.listJobs()).toEqual([]); + }); + + it('should trigger a job handler', async () => { + const job = createMemoryJob(); + let triggered = false; + await job.schedule('my-job', '* * * * *', async (ctx: any) => { + triggered = true; + expect(ctx.jobId).toBe('my-job'); + expect(ctx.data).toEqual({ key: 'val' }); + }); + await job.trigger('my-job', { key: 'val' }); + expect(triggered).toBe(true); + }); + + it('should return empty executions', async () => { + const job = createMemoryJob(); + expect(await job.getExecutions()).toEqual([]); + }); +}); diff --git a/packages/core/src/fallbacks/index.ts b/packages/core/src/fallbacks/index.ts new file mode 100644 index 000000000..472262831 --- /dev/null +++ b/packages/core/src/fallbacks/index.ts @@ -0,0 +1,20 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import { createMemoryCache } from './memory-cache.js'; +import { createMemoryQueue } from './memory-queue.js'; +import { createMemoryJob } from './memory-job.js'; + +export { createMemoryCache } from './memory-cache.js'; +export { createMemoryQueue } from './memory-queue.js'; +export { createMemoryJob } from './memory-job.js'; + +/** + * Map of core-criticality service names to their in-memory fallback factories. + * Used by ObjectKernel.validateSystemRequirements() to auto-inject fallbacks + * when no real plugin provides the service. + */ +export const CORE_FALLBACK_FACTORIES: Record Record> = { + cache: createMemoryCache, + queue: createMemoryQueue, + job: createMemoryJob, +}; diff --git a/packages/core/src/fallbacks/memory-cache.ts b/packages/core/src/fallbacks/memory-cache.ts new file mode 100644 index 000000000..5d8611843 --- /dev/null +++ b/packages/core/src/fallbacks/memory-cache.ts @@ -0,0 +1,34 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * In-memory Map-backed cache fallback. + * + * Implements the ICacheService contract with basic get/set/delete/has/clear + * and TTL expiry. Used by ObjectKernel as an automatic fallback when no + * real cache plugin (e.g. Redis) is registered. + */ +export function createMemoryCache() { + const store = new Map(); + let hits = 0; + let misses = 0; + return { + _fallback: true, _serviceName: 'cache', + async get(key: string): Promise { + const entry = store.get(key); + if (!entry || (entry.expires && Date.now() > entry.expires)) { + store.delete(key); + misses++; + return undefined; + } + hits++; + return entry.value as T; + }, + async set(key: string, value: T, ttl?: number): Promise { + store.set(key, { value, expires: ttl ? Date.now() + ttl * 1000 : undefined }); + }, + async delete(key: string): Promise { return store.delete(key); }, + async has(key: string): Promise { return store.has(key); }, + async clear(): Promise { store.clear(); }, + async stats() { return { hits, misses, keyCount: store.size }; }, + }; +} diff --git a/packages/core/src/fallbacks/memory-job.ts b/packages/core/src/fallbacks/memory-job.ts new file mode 100644 index 000000000..007e14b4d --- /dev/null +++ b/packages/core/src/fallbacks/memory-job.ts @@ -0,0 +1,23 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * In-memory job scheduler fallback. + * + * Implements the IJobService contract with basic schedule/cancel/trigger + * operations. Used by ObjectKernel as an automatic fallback when no real + * job plugin (e.g. Agenda / BullMQ) is registered. + */ +export function createMemoryJob() { + const jobs = new Map(); + return { + _fallback: true, _serviceName: 'job', + async schedule(name: string, schedule: any, handler: any): Promise { jobs.set(name, { schedule, handler }); }, + async cancel(name: string): Promise { jobs.delete(name); }, + async trigger(name: string, data?: unknown): Promise { + const job = jobs.get(name); + if (job?.handler) await job.handler({ jobId: name, data }); + }, + async getExecutions(): Promise { return []; }, + async listJobs(): Promise { return [...jobs.keys()]; }, + }; +} diff --git a/packages/core/src/fallbacks/memory-queue.ts b/packages/core/src/fallbacks/memory-queue.ts new file mode 100644 index 000000000..1e35556f5 --- /dev/null +++ b/packages/core/src/fallbacks/memory-queue.ts @@ -0,0 +1,28 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * In-memory publish/subscribe queue fallback. + * + * Implements the IQueueService contract with synchronous in-process delivery. + * Used by ObjectKernel as an automatic fallback when no real queue plugin + * (e.g. BullMQ / RabbitMQ) is registered. + */ +export function createMemoryQueue() { + const handlers = new Map(); + let msgId = 0; + return { + _fallback: true, _serviceName: 'queue', + async publish(queue: string, data: T): Promise { + const id = `fallback-msg-${++msgId}`; + const fns = handlers.get(queue) ?? []; + for (const fn of fns) fn({ id, data, attempts: 1, timestamp: Date.now() }); + return id; + }, + async subscribe(queue: string, handler: (msg: any) => Promise): Promise { + handlers.set(queue, [...(handlers.get(queue) ?? []), handler]); + }, + async unsubscribe(queue: string): Promise { handlers.delete(queue); }, + async getQueueSize(): Promise { return 0; }, + async purge(queue: string): Promise { handlers.delete(queue); }, + }; +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 32ee59d38..e1645c19b 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -23,6 +23,9 @@ export * from './security/index.js'; // Export environment utilities export * from './utils/env.js'; +// Export in-memory fallbacks for core-criticality services +export * from './fallbacks/index.js'; + // Export Phase 2 components - Advanced lifecycle management export * from './health-monitor.js'; export * from './hot-reload.js'; diff --git a/packages/core/src/kernel.ts b/packages/core/src/kernel.ts index 54aa66450..c16af9dea 100644 --- a/packages/core/src/kernel.ts +++ b/packages/core/src/kernel.ts @@ -6,6 +6,7 @@ import type { LoggerConfig } from '@objectstack/spec/system'; import { ServiceRequirementDef } from '@objectstack/spec/system'; import { PluginLoader, PluginMetadata, ServiceLifecycle, ServiceFactory, PluginStartupResult } from './plugin-loader.js'; import { isNode, safeExit } from './utils/env.js'; +import { CORE_FALLBACK_FACTORIES } from './fallbacks/index.js'; /** * Enhanced Kernel Configuration @@ -234,8 +235,16 @@ export class ObjectKernel { this.logger.error(`CRITICAL: Required service missing: ${serviceName}`); missingServices.push(serviceName); } else if (criticality === 'core') { - this.logger.warn(`CORE: Core service missing, functionality may be degraded: ${serviceName}`); - missingCoreServices.push(serviceName); + // Auto-inject in-memory fallback if available + const factory = CORE_FALLBACK_FACTORIES[serviceName]; + if (factory) { + const fallback = factory(); + this.registerService(serviceName, fallback); + this.logger.warn(`Service '${serviceName}' not provided — using in-memory fallback`); + } else { + this.logger.warn(`CORE: Core service missing, functionality may be degraded: ${serviceName}`); + missingCoreServices.push(serviceName); + } } else { this.logger.info(`Info: Optional service not present: ${serviceName}`); } diff --git a/packages/plugins/plugin-dev/src/dev-plugin.ts b/packages/plugins/plugin-dev/src/dev-plugin.ts index a42d8eadf..4e5d32e60 100644 --- a/packages/plugins/plugin-dev/src/dev-plugin.ts +++ b/packages/plugins/plugin-dev/src/dev-plugin.ts @@ -1,6 +1,6 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. -import { Plugin, PluginContext } from '@objectstack/core'; +import { Plugin, PluginContext, createMemoryCache, createMemoryQueue, createMemoryJob } from '@objectstack/core'; /** * All 17 core kernel service names as defined in CoreServiceName. @@ -33,70 +33,6 @@ const SECURITY_SERVICE_NAMES = [ * a trivially useful implementation. */ -/** ICacheService — in-memory Map-backed stub */ -function createCacheStub() { - const store = new Map(); - let hits = 0; - let misses = 0; - return { - _dev: true, _serviceName: 'cache', - async get(key: string): Promise { - const entry = store.get(key); - if (!entry || (entry.expires && Date.now() > entry.expires)) { - store.delete(key); - misses++; - return undefined; - } - hits++; - return entry.value as T; - }, - async set(key: string, value: T, ttl?: number): Promise { - store.set(key, { value, expires: ttl ? Date.now() + ttl * 1000 : undefined }); - }, - async delete(key: string): Promise { return store.delete(key); }, - async has(key: string): Promise { return store.has(key); }, - async clear(): Promise { store.clear(); }, - async stats() { return { hits, misses, keyCount: store.size }; }, - }; -} - -/** IQueueService — in-memory publish/subscribe stub */ -function createQueueStub() { - const handlers = new Map(); - let msgId = 0; - return { - _dev: true, _serviceName: 'queue', - async publish(queue: string, data: T): Promise { - const id = `dev-msg-${++msgId}`; - const fns = handlers.get(queue) ?? []; - for (const fn of fns) fn({ id, data, attempts: 1, timestamp: Date.now() }); - return id; - }, - async subscribe(queue: string, handler: (msg: any) => Promise): Promise { - handlers.set(queue, [...(handlers.get(queue) ?? []), handler]); - }, - async unsubscribe(queue: string): Promise { handlers.delete(queue); }, - async getQueueSize(): Promise { return 0; }, - async purge(queue: string): Promise { handlers.delete(queue); }, - }; -} - -/** IJobService — no-op job scheduler stub */ -function createJobStub() { - const jobs = new Map(); - return { - _dev: true, _serviceName: 'job', - async schedule(name: string, schedule: any, handler: any): Promise { jobs.set(name, { schedule, handler }); }, - async cancel(name: string): Promise { jobs.delete(name); }, - async trigger(name: string, data?: unknown): Promise { - const job = jobs.get(name); - if (job?.handler) await job.handler({ jobId: name, data }); - }, - async getExecutions(): Promise { return []; }, - async listJobs(): Promise { return [...jobs.keys()]; }, - }; -} - /** IStorageService — in-memory file storage stub */ function createStorageStub() { const files = new Map(); @@ -346,9 +282,9 @@ function createSecurityFieldMaskerStub() { * from `packages/spec/src/contracts/`. */ const DEV_STUB_FACTORIES: Record Record> = { - 'cache': createCacheStub, - 'queue': createQueueStub, - 'job': createJobStub, + 'cache': createMemoryCache, + 'queue': createMemoryQueue, + 'job': createMemoryJob, 'file-storage': createStorageStub, 'search': createSearchStub, 'automation': createAutomationStub,