Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
158 changes: 158 additions & 0 deletions packages/core/src/fallbacks/fallbacks.test.ts
Original file line number Diff line number Diff line change
@@ -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';
Comment on lines +2 to +5
Copy link

Copilot AI Feb 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The test file imports are missing .js file extensions. In ESM, all relative imports should include the file extension. The imports should be:

import { createMemoryCache } from './memory-cache.js';
import { createMemoryQueue } from './memory-queue.js';
import { createMemoryJob } from './memory-job.js';
import { CORE_FALLBACK_FACTORIES } from './index.js';

This is inconsistent with the production code which correctly uses .js extensions (see index.ts and kernel.ts).

Suggested change
import { createMemoryCache } from './memory-cache';
import { createMemoryQueue } from './memory-queue';
import { createMemoryJob } from './memory-job';
import { CORE_FALLBACK_FACTORIES } from './index';
import { createMemoryCache } from './memory-cache.js';
import { createMemoryQueue } from './memory-queue.js';
import { createMemoryJob } from './memory-job.js';
import { CORE_FALLBACK_FACTORIES } from './index.js';

Copilot uses AI. Check for mistakes.

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);
Copy link

Copilot AI Feb 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test calls getQueueSize() without any parameters, but according to the IQueueService contract, it should accept a queue: string parameter. The test should be:

expect(await queue.getQueueSize('test-q')).toBe(0);

This test failure would be caught if the CORE_FALLBACK_FACTORIES used proper typing (see comment on index.ts).

Suggested change
expect(await queue.getQueueSize()).toBe(0);
expect(await queue.getQueueSize('test-q')).toBe(0);

Copilot uses AI. Check for mistakes.
});

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([]);
});
});
20 changes: 20 additions & 0 deletions packages/core/src/fallbacks/index.ts
Original file line number Diff line number Diff line change
@@ -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<string, () => Record<string, any>> = {
cache: createMemoryCache,
queue: createMemoryQueue,
job: createMemoryJob,
};
Comment on lines +16 to +20
Copy link

Copilot AI Feb 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The type Record<string, () => Record<string, any>> is very loose and doesn't provide type safety. Consider importing the service interfaces and using a more precise type:

import type { ICacheService } from '@objectstack/spec/contracts/cache-service';
import type { IQueueService } from '@objectstack/spec/contracts/queue-service';
import type { IJobService } from '@objectstack/spec/contracts/job-service';

export const CORE_FALLBACK_FACTORIES: {
  cache: () => ICacheService;
  queue: () => IQueueService;
  job: () => IJobService;
} = {
  cache: createMemoryCache,
  queue: createMemoryQueue,
  job: createMemoryJob,
};

This would provide better type checking and catch API mismatches at compile time (like the missing parameters in getQueueSize and publish).

Copilot uses AI. Check for mistakes.
34 changes: 34 additions & 0 deletions packages/core/src/fallbacks/memory-cache.ts
Original file line number Diff line number Diff line change
@@ -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<string, { value: unknown; expires?: number }>();
let hits = 0;
let misses = 0;
return {
_fallback: true, _serviceName: 'cache',
async get<T = unknown>(key: string): Promise<T | undefined> {
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<T = unknown>(key: string, value: T, ttl?: number): Promise<void> {
store.set(key, { value, expires: ttl ? Date.now() + ttl * 1000 : undefined });
},
async delete(key: string): Promise<boolean> { return store.delete(key); },
async has(key: string): Promise<boolean> { return store.has(key); },
async clear(): Promise<void> { store.clear(); },
async stats() { return { hits, misses, keyCount: store.size }; },
};
}
23 changes: 23 additions & 0 deletions packages/core/src/fallbacks/memory-job.ts
Original file line number Diff line number Diff line change
@@ -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<string, any>();
return {
_fallback: true, _serviceName: 'job',
async schedule(name: string, schedule: any, handler: any): Promise<void> { jobs.set(name, { schedule, handler }); },
Copy link

Copilot AI Feb 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The schedule method parameters use any types instead of the proper interfaces defined in IJobService. According to the contract:

schedule(name: string, schedule: JobSchedule, handler: JobHandler): Promise<void>;

The parameters should be properly typed. Consider importing the types:

import type { JobSchedule, JobHandler } from '@objectstack/spec/contracts/job-service';

async schedule(name: string, schedule: JobSchedule, handler: JobHandler): Promise<void>

This would provide better type safety and catch errors at compile time. Note: This is a pre-existing issue from the original dev-plugin implementation.

Copilot uses AI. Check for mistakes.
async cancel(name: string): Promise<void> { jobs.delete(name); },
async trigger(name: string, data?: unknown): Promise<void> {
const job = jobs.get(name);
if (job?.handler) await job.handler({ jobId: name, data });
},
async getExecutions(): Promise<any[]> { return []; },
async listJobs(): Promise<string[]> { return [...jobs.keys()]; },
};
}
28 changes: 28 additions & 0 deletions packages/core/src/fallbacks/memory-queue.ts
Original file line number Diff line number Diff line change
@@ -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<string, Function[]>();
let msgId = 0;
return {
_fallback: true, _serviceName: 'queue',
async publish<T = unknown>(queue: string, data: T): Promise<string> {
Copy link

Copilot AI Feb 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The publish method should accept an optional options parameter to match the IQueueService contract. The interface specifies:

publish<T = unknown>(queue: string, data: T, options?: QueuePublishOptions): Promise<string>;

But this implementation is missing the third parameter. It should be:

async publish<T = unknown>(queue: string, data: T, options?: QueuePublishOptions): Promise<string>

Even if the fallback doesn't implement delay/priority/retries, it should accept the parameter for API compatibility.

Suggested change
async publish<T = unknown>(queue: string, data: T): Promise<string> {
async publish<T = unknown>(queue: string, data: T, _options?: unknown): Promise<string> {

Copilot uses AI. Check for mistakes.
const id = `fallback-msg-${++msgId}`;
const fns = handlers.get(queue) ?? [];
for (const fn of fns) fn({ id, data, attempts: 1, timestamp: Date.now() });
Copy link

Copilot AI Feb 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The handler functions are invoked without await, which means:

  1. Errors in handlers will result in unhandled promise rejections
  2. The publish operation completes immediately without waiting for handlers

Consider either:

  1. Awaiting handlers sequentially: for (const fn of fns) await fn({ ... })
  2. Firing handlers in parallel and catching errors: Promise.all(fns.map(fn => fn({ ... }).catch(err => console.error(err))))

The current synchronous delivery is documented in the JSDoc comment, so if this is intentional for the fallback's simplicity, at least wrap the handler call in a try-catch or .catch() to prevent unhandled rejections.

Suggested change
for (const fn of fns) fn({ id, data, attempts: 1, timestamp: Date.now() });
for (const fn of fns) {
try {
const result = fn({ id, data, attempts: 1, timestamp: Date.now() });
if (result && typeof (result as any).catch === 'function') {
(result as Promise<unknown>).catch((err: unknown) => {
console.error('[memory-queue] handler error for queue "%s":', queue, err);
});
}
} catch (err) {
console.error('[memory-queue] handler threw synchronously for queue "%s":', queue, err);
}
}

Copilot uses AI. Check for mistakes.
return id;
},
async subscribe(queue: string, handler: (msg: any) => Promise<void>): Promise<void> {
handlers.set(queue, [...(handlers.get(queue) ?? []), handler]);
},
async unsubscribe(queue: string): Promise<void> { handlers.delete(queue); },
async getQueueSize(): Promise<number> { return 0; },
Copy link

Copilot AI Feb 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The getQueueSize method should accept a queue parameter to match the IQueueService contract defined in packages/spec/src/contracts/queue-service.ts. The interface specifies:

getQueueSize?(queue: string): Promise<number>;

But this implementation has no parameters. It should be:

async getQueueSize(queue: string): Promise<number> { return 0; },

Note: This is a pre-existing issue from the original dev-plugin implementation, but it should be fixed since we're extracting this as a production fallback.

Suggested change
async getQueueSize(): Promise<number> { return 0; },
async getQueueSize(queue: string): Promise<number> { return 0; },

Copilot uses AI. Check for mistakes.
async purge(queue: string): Promise<void> { handlers.delete(queue); },
};
}
3 changes: 3 additions & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
13 changes: 11 additions & 2 deletions packages/core/src/kernel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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);
}
Comment on lines +238 to +247
Copy link

Copilot AI Feb 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new auto-injection behavior in ObjectKernel.validateSystemRequirements() lacks integration tests. There should be a test in packages/core/src/kernel.test.ts that verifies:

  1. When a core-criticality service (cache/queue/job) is missing, the kernel auto-injects the fallback
  2. The fallback service is properly registered and accessible via getService()
  3. The appropriate warning is logged
  4. The fallback has _fallback: true marker

Without these tests, regressions in this critical new feature could go undetected.

Copilot uses AI. Check for mistakes.
} else {
this.logger.info(`Info: Optional service not present: ${serviceName}`);
}
Expand Down
72 changes: 4 additions & 68 deletions packages/plugins/plugin-dev/src/dev-plugin.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -33,70 +33,6 @@ const SECURITY_SERVICE_NAMES = [
* a trivially useful implementation.
*/

/** ICacheService — in-memory Map-backed stub */
function createCacheStub() {
const store = new Map<string, { value: unknown; expires?: number }>();
let hits = 0;
let misses = 0;
return {
_dev: true, _serviceName: 'cache',
async get<T = unknown>(key: string): Promise<T | undefined> {
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<T = unknown>(key: string, value: T, ttl?: number): Promise<void> {
store.set(key, { value, expires: ttl ? Date.now() + ttl * 1000 : undefined });
},
async delete(key: string): Promise<boolean> { return store.delete(key); },
async has(key: string): Promise<boolean> { return store.has(key); },
async clear(): Promise<void> { store.clear(); },
async stats() { return { hits, misses, keyCount: store.size }; },
};
}

/** IQueueService — in-memory publish/subscribe stub */
function createQueueStub() {
const handlers = new Map<string, Function[]>();
let msgId = 0;
return {
_dev: true, _serviceName: 'queue',
async publish<T = unknown>(queue: string, data: T): Promise<string> {
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<void>): Promise<void> {
handlers.set(queue, [...(handlers.get(queue) ?? []), handler]);
},
async unsubscribe(queue: string): Promise<void> { handlers.delete(queue); },
async getQueueSize(): Promise<number> { return 0; },
async purge(queue: string): Promise<void> { handlers.delete(queue); },
};
}

/** IJobService — no-op job scheduler stub */
function createJobStub() {
const jobs = new Map<string, any>();
return {
_dev: true, _serviceName: 'job',
async schedule(name: string, schedule: any, handler: any): Promise<void> { jobs.set(name, { schedule, handler }); },
async cancel(name: string): Promise<void> { jobs.delete(name); },
async trigger(name: string, data?: unknown): Promise<void> {
const job = jobs.get(name);
if (job?.handler) await job.handler({ jobId: name, data });
},
async getExecutions(): Promise<any[]> { return []; },
async listJobs(): Promise<string[]> { return [...jobs.keys()]; },
};
}

/** IStorageService — in-memory file storage stub */
function createStorageStub() {
const files = new Map<string, { data: Buffer; meta: any }>();
Expand Down Expand Up @@ -346,9 +282,9 @@ function createSecurityFieldMaskerStub() {
* from `packages/spec/src/contracts/`.
*/
const DEV_STUB_FACTORIES: Record<string, () => Record<string, any>> = {
'cache': createCacheStub,
'queue': createQueueStub,
'job': createJobStub,
'cache': createMemoryCache,
'queue': createMemoryQueue,
'job': createMemoryJob,
Comment on lines +285 to +287
Copy link

Copilot AI Feb 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The createMemoryCache, createMemoryQueue, and createMemoryJob factories now return objects with _fallback: true instead of _dev: true. However, the existing test in dev-plugin.test.ts at line 95 still expects cache._dev to be true. This will cause the test to fail.

When DevPlugin uses these fallback factories, they should retain the _dev marker to distinguish them as development stubs rather than production fallbacks. Consider one of these solutions:

  1. Modify the fallback factories to accept an optional marker parameter: createMemoryCache({ marker: '_dev' })
  2. Wrap the fallback instances here to add the _dev marker: { ...createMemoryCache(), _dev: true }
  3. Update the test file to accept either _dev or _fallback markers
Suggested change
'cache': createMemoryCache,
'queue': createMemoryQueue,
'job': createMemoryJob,
'cache': () => ({ ...createMemoryCache(), _dev: true }),
'queue': () => ({ ...createMemoryQueue(), _dev: true }),
'job': () => ({ ...createMemoryJob(), _dev: true }),

Copilot uses AI. Check for mistakes.
'file-storage': createStorageStub,
'search': createSearchStub,
'automation': createAutomationStub,
Expand Down