diff --git a/.changeset/job-fallback-must-not-fake-capability.md b/.changeset/job-fallback-must-not-fake-capability.md new file mode 100644 index 0000000000..396075ad58 --- /dev/null +++ b/.changeset/job-fallback-must-not-fake-capability.md @@ -0,0 +1,9 @@ +--- +'@objectstack/core': minor +--- + +`ObjectKernel` no longer pre-injects the in-memory `job` fallback for the `job` core-service slot — a fallback must not fake capability (#10746, maintainer ruling 2026-08-22). `createMemoryJob()`'s `schedule()` records a job and never fires it (it owns no timer), so pre-injecting it made every "prefer the platform job service, else own a timer" consumer take the job-service branch on a kernel without `@objectstack/service-job` and then silently never run: `plugin-reports` logged `dispatcher registered with job service` and dispatched nothing, ever. + +Behavior change, FROM → TO: on an `ObjectKernel` without a registered `job` service, `getService('job')` FROM resolving a non-scheduling in-memory registry TO throwing `Service 'job' not found`. Consumers' documented no-job-service paths take over (`plugin-reports` falls through to its own `setInterval` and scheduled reports actually dispatch; schedule triggers and declarative jobs warn loudly instead of scheduling into the void), and the kernel says the absence out loud at boot: `Core service missing, functionality may be degraded: job`. + +One-line fix if you relied on the old behavior: install `@objectstack/service-job` for real scheduling, or — if you deliberately want the manual-trigger in-memory registry — register it explicitly: `kernel.registerService('job', createMemoryJob())` (the factory is still exported from `@objectstack/core`). diff --git a/content/docs/kernel/services-checklist.mdx b/content/docs/kernel/services-checklist.mdx index 2ab8b1cf8d..0148603842 100644 --- a/content/docs/kernel/services-checklist.mdx +++ b/content/docs/kernel/services-checklist.mdx @@ -16,7 +16,7 @@ package catalog. The ObjectStack protocol defines **15 kernel services** registered via the `CoreServiceName` enum (v17 removed the never-implemented `graphql` entry and retired the never-filled `workflow` slot, #4451). Each service maps to a set of protocol methods governed by its per-domain contract (`DataProtocol`, `MetadataProtocol`, ...) — the transitional `ObjectStackProtocol` composition alias was dissolved in v17 (ADR-0076 D9); capability availability comes from the runtime discovery `services` registry. -**Key architecture principle**: the kernel guarantees only **data** and **metadata**, and even those are filled by packages (`@objectstack/objectql`, `@objectstack/metadata`) rather than baked in — the kernel's own contribution is an in-memory fallback for the `core` slots that have one (`metadata`, `cache`, `queue`, `job`, `i18n` — **not** `auth`). Everything else — including **auth** and **automation** — is delivered by plugins. `@objectstack/objectql` is an example kernel implementation to get the basic API running; production kernels will be rebuilt as separate plugins. +**Key architecture principle**: the kernel guarantees only **data** and **metadata**, and even those are filled by packages (`@objectstack/objectql`, `@objectstack/metadata`) rather than baked in — the kernel's own contribution is an in-memory fallback for the `core` slots that have one (`metadata`, `cache`, `queue`, `i18n` — **not** `auth`, and not `job`: an in-memory registry cannot fire a `schedule()`d job on its own, so pre-injecting one advertised a scheduler that never ran, and a fallback must not fake capability — #10746. The `job` slot stays empty, loudly, until `@objectstack/service-job` or an explicitly registered scheduler fills it). Everything else — including **auth** and **automation** — is delivered by plugins. `@objectstack/objectql` is an example kernel implementation to get the basic API running; production kernels will be rebuilt as separate plugins. **Legend** @@ -79,7 +79,7 @@ The ObjectStack protocol defines **15 kernel services** registered via the `Core | 12 | **search** | `optional` | — | ❌ Nothing ships | — | | 13 | **cache** | `core` | — | ✅ Built-in (in-memory fallback) | `@objectstack/service-cache` | | 14 | **queue** | `core` | — | ✅ Built-in (in-memory fallback) | `@objectstack/service-queue` | -| 15 | **job** | `core` | — | ✅ Built-in (in-memory fallback) | `@objectstack/service-job` | +| 15 | **job** | `core` | — | ❌ Plugin Required (no pre-injected fallback since #10746 — with no job plugin, `getService('job')` throws and the boot warns) | `@objectstack/service-job` | The Provider column mirrors `CORE_SERVICE_PROVIDER` in @@ -480,7 +480,7 @@ AppPlugin will: ## 11–15. Infrastructure Services -`cache`, `queue`, and `job` are `core` services: like `i18n`, the kernel auto-injects an in-memory fallback when no plugin registers them (see `CORE_FALLBACK_FACTORIES` in `packages/core/src/fallbacks/`). The `optional` services (`storage`, `search`) stay disabled until a plugin provides them. +`cache`, `queue`, and `job` are `core` services. For `cache` and `queue` — like `i18n` — the kernel auto-injects an in-memory fallback when no plugin registers them (see `CORE_FALLBACK_FACTORIES` in `packages/core/src/fallbacks/`). `job` is deliberately **not** on that list (#10746): an in-memory registry cannot fire a `schedule()`d job on its own, and a fallback must not fake capability — so with no job plugin installed, `getService('job')` throws, consumers take their documented no-scheduler paths (e.g. the reports dispatcher's own `setInterval`), and the boot warns `Core service missing, functionality may be degraded: job`. The `core` criticality itself is unchanged — it is exactly what makes the absence loud. Fill the slot with `@objectstack/service-job`, or register `createMemoryJob()` explicitly if a manual-`trigger()` registry is genuinely wanted. The `optional` services (`storage`, `search`) stay disabled until a plugin provides them. | Service | Description | |:--------|:------------| @@ -488,7 +488,7 @@ AppPlugin will: | **search** | **Nothing ships.** `ISearchService` and the engine enum (`elasticsearch`, `meilisearch`, …) exist in `@objectstack/spec`, but no package implements the contract or registers the `search` slot, so `CORE_SERVICE_PROVIDER.search` is `null`. | | **cache** | General-purpose cache. In-memory fallback; memory or Redis adapter via `@objectstack/service-cache`. | | **queue** | Message queue. In-memory fallback; durable DB-backed adapter (`sys_job_queue`) via `@objectstack/service-queue` (no BullMQ/Redis adapter is shipped). | -| **job** | Scheduled task execution via `@objectstack/service-job`. In-memory fallback; interval, cron, and DB-backed adapters with concurrency policy. | +| **job** | Scheduled task execution via `@objectstack/service-job` — interval, cron, and DB-backed adapters with concurrency policy. No pre-injected fallback (#10746): install the plugin, or the slot stays empty and the boot says so. | --- diff --git a/packages/core/src/fallbacks/fallbacks.test.ts b/packages/core/src/fallbacks/fallbacks.test.ts index bf99ab2b64..b7bb6119db 100644 --- a/packages/core/src/fallbacks/fallbacks.test.ts +++ b/packages/core/src/fallbacks/fallbacks.test.ts @@ -7,8 +7,14 @@ import { CORE_FALLBACK_FACTORIES } from './index'; import { readServiceSelfInfo } from '@objectstack/spec/api'; describe('CORE_FALLBACK_FACTORIES', () => { - it('should have exactly 5 entries: metadata, cache, queue, job, i18n', () => { - expect(Object.keys(CORE_FALLBACK_FACTORIES)).toEqual(['metadata', 'cache', 'queue', 'job', 'i18n']); + // [#10746] `job` is deliberately OFF this list — a fallback must not fake + // capability (maintainer ruling 2026-08-22). `createMemoryJob().schedule()` + // records a job and never fires it, so pre-injecting it made consumers + // treat "a `job` service resolves" as "a working scheduler" and silently + // never run. The factory stays exported for deliberate, explicit use; the + // kernel must not hand it out as if it honoured `schedule()`. + it('should have exactly 4 entries: metadata, cache, queue, i18n — job deliberately absent (#10746)', () => { + expect(Object.keys(CORE_FALLBACK_FACTORIES)).toEqual(['metadata', 'cache', 'queue', 'i18n']); }); // [#4058] Every kernel fallback must be readable through the ONE standard diff --git a/packages/core/src/fallbacks/index.ts b/packages/core/src/fallbacks/index.ts index a3f7542fc1..114db6567c 100644 --- a/packages/core/src/fallbacks/index.ts +++ b/packages/core/src/fallbacks/index.ts @@ -2,7 +2,6 @@ import { createMemoryCache } from './memory-cache.js'; import { createMemoryQueue } from './memory-queue.js'; -import { createMemoryJob } from './memory-job.js'; import { createMemoryI18n } from './memory-i18n.js'; import { createMemoryMetadata } from './memory-metadata.js'; @@ -18,13 +17,30 @@ export { /** * 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. + * This IS the kernel's pre-injection list: `ObjectKernel.preInjectCoreFallbacks()` + * registers an entry for every unprovided `core` service before Phase 2, and + * `validateSystemRequirements()` consults the same map as its final check. + * + * [#10746] `job` is deliberately ABSENT — a fallback must not fake capability + * (maintainer ruling 2026-08-22). `createMemoryJob()`'s `schedule()` records a + * job and never fires it, so pre-injecting it made every "prefer the platform + * job service, else own a timer" consumer take the job-service branch and then + * silently never run: `plugin-reports` logged `dispatcher registered with job + * service` and dispatched nothing, ever (measured: 0 reads of + * `sys_report_schedule` in 5600 ms with the success line present). With no + * entry here, `getService('job')` throws when no job plugin is installed, + * every consumer's documented no-job-service path becomes reachable (they all + * already run on `LiteKernel`, which injects no fallbacks), and the kernel + * says the absence out loud at boot: `validateSystemRequirements()` warns + * "Core service missing, functionality may be degraded: job". Do NOT re-add + * the entry to quiet that warning — install `@objectstack/service-job`, or + * register a real scheduler, instead. `createMemoryJob` stays exported below + * for embedders who deliberately want a manual-trigger job registry and have + * read its docblock. */ export const CORE_FALLBACK_FACTORIES: Record Record> = { metadata: createMemoryMetadata, cache: createMemoryCache, queue: createMemoryQueue, - job: createMemoryJob, i18n: createMemoryI18n, }; diff --git a/packages/core/src/fallbacks/memory-job.ts b/packages/core/src/fallbacks/memory-job.ts index b97da058b4..b0eb94b8a9 100644 --- a/packages/core/src/fallbacks/memory-job.ts +++ b/packages/core/src/fallbacks/memory-job.ts @@ -1,11 +1,15 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. /** - * In-memory job scheduler fallback. + * In-memory job registry — schedule/cancel/trigger bookkeeping with NO timer. * - * 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. + * [#10746] NOT pre-injected by ObjectKernel any more (it used to be, via + * `CORE_FALLBACK_FACTORIES`): a fallback must not fake capability (maintainer + * ruling 2026-08-22). Advertising a `schedule()` that records and never fires + * made every "prefer the platform job service, else own a timer" consumer + * take the job-service branch and then silently never run. The export remains + * for embedders who deliberately want a manual-trigger job registry — e.g. in + * tests that drive handlers via `trigger()` — and have read this docblock. * * [#4058] `degraded` (ADR-0076 D12), with the missing half named in the * message rather than left for a deployer to discover: `trigger()` really runs diff --git a/packages/core/src/kernel.test.ts b/packages/core/src/kernel.test.ts index 7efd0060cc..2d5e38f477 100644 --- a/packages/core/src/kernel.test.ts +++ b/packages/core/src/kernel.test.ts @@ -1155,4 +1155,43 @@ describe('ObjectKernel', () => { await kernel.shutdown(); }); }); + + describe('Core fallback pre-injection (#10746)', () => { + // The suite-level kernel sets `skipSystemValidation: true`, which skips + // pre-injection entirely — this pin needs the real path, so it boots + // its own kernel with validation ON (the production default). + it('pre-injects the honest core fallbacks but NOT job — a fallback must not fake capability', async () => { + const k = new ObjectKernel({ + logger: { level: 'error' }, + gracefulShutdown: false, + }); + // `data` is `required` criticality; provide it so bootstrap + // survives validateSystemRequirements(). + const dataProvider: Plugin = { + name: 'test.data-provider', + version: '1.0.0', + init: async (ctx: PluginContext) => { + ctx.registerService('data', { find: async () => [] }); + }, + }; + await k.use(dataProvider); + await k.bootstrap(); + try { + // The remaining core slots still pre-inject before Phase 2. + for (const slot of ['metadata', 'cache', 'queue', 'i18n']) { + expect(k.getService(slot), `fallback for '${slot}'`).toBeDefined(); + } + // `job` must NOT resolve: `createMemoryJob()`'s `schedule()` + // records a job and never fires it, so handing it out made + // every "prefer the platform job service" consumer schedule + // into the void while logging success (maintainer ruling + // 2026-08-22: declare only what you enforce). Absence is the + // honest answer — consumers' documented no-job-service paths + // (setInterval fallbacks, loud warns) take over. + expect(() => k.getService('job')).toThrow(/Service 'job' not found/); + } finally { + await k.shutdown(); + } + }); + }); }); diff --git a/packages/objectql/src/protocol-discovery.test.ts b/packages/objectql/src/protocol-discovery.test.ts index 3c5d002d54..4d2908609a 100644 --- a/packages/objectql/src/protocol-discovery.test.ts +++ b/packages/objectql/src/protocol-discovery.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect, beforeEach } from 'vitest'; import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; -import { createMemoryMetadata, CORE_FALLBACK_FACTORIES } from '@objectstack/core'; +import { createMemoryMetadata, createMemoryJob, CORE_FALLBACK_FACTORIES } from '@objectstack/core'; import { ObjectQL } from './engine.js'; describe('ObjectStackProtocolImplementation - Dynamic Service Discovery', () => { @@ -232,7 +232,12 @@ describe('ObjectStackProtocolImplementation - Dynamic Service Discovery', () => it('reports every CORE_FALLBACK_FACTORIES product as degraded, never available (#3898)', async () => { expect(Object.keys(CORE_FALLBACK_FACTORIES).length).toBeGreaterThan(0); - for (const [slot, factory] of Object.entries(CORE_FALLBACK_FACTORIES)) { + // [#10746] `job` came OFF the pre-injection list (a fallback must not + // fake capability), but `createMemoryJob` stays exported for deliberate + // registration — so its product stays in this gate's inventory: however + // it reaches a slot, discovery must never call it `available`. + const inventory = { ...CORE_FALLBACK_FACTORIES, job: createMemoryJob }; + for (const [slot, factory] of Object.entries(inventory)) { const mockServices = new Map(); mockServices.set(slot, factory()); @@ -382,9 +387,13 @@ describe('ObjectStackProtocolImplementation - Dynamic Service Discovery', () => }); it('never advertises a route for a cache/queue/job fallback either (#4318)', async () => { + // [#10746] `job` is off the pre-injection map but stays explicitly + // registrable, so the slot keeps its fallback-occupant coverage here. + const factoryFor = (slot: string) => + slot === 'job' ? createMemoryJob : CORE_FALLBACK_FACTORIES[slot]; for (const slot of ['cache', 'queue', 'job']) { const mockServices = new Map(); - mockServices.set(slot, CORE_FALLBACK_FACTORIES[slot]()); + mockServices.set(slot, factoryFor(slot)()); protocol = new ObjectStackProtocolImplementation(engine, () => mockServices); const reported = (await protocol.getDiscovery()).services[slot]; diff --git a/packages/plugins/plugin-reports/src/dispatcher-runs-on-object-kernel.test.ts b/packages/plugins/plugin-reports/src/dispatcher-runs-on-object-kernel.test.ts new file mode 100644 index 0000000000..7106abd72a --- /dev/null +++ b/packages/plugins/plugin-reports/src/dispatcher-runs-on-object-kernel.test.ts @@ -0,0 +1,126 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#10746] Scheduled reports must actually RUN on `ObjectKernel` — the kernel + * real deployments use — when no job plugin is installed. + * + * THE DEFECT THIS PINS. `ReportsServicePlugin.start()` prefers the platform + * job service and only falls through to its own `setInterval` when + * `ctx.getService('job')` yields nothing with a `schedule` method. + * `ObjectKernel.preInjectCoreFallbacks()` used to register `createMemoryJob()` + * for every unprovided `core` service before Phase 2 — and `createMemoryJob()` + * is honest in its own docblock that a `schedule()`d job NEVER fires on its + * own (its `schedule()` is `jobs.set(...)` and nothing else). So on an + * `ObjectKernel` without `@objectstack/service-job` the plugin logged + * `dispatcher registered with job service` and then dispatched nothing, ever: + * measured as 0 reads of `sys_report_schedule` in 5600 ms with the success + * line present. The `setInterval` branch the plugin's docblock offers as the + * single-kernel answer was dead code on the kernel real deployments use. + * + * THE REPAIR (maintainer ruling 2026-08-22, Option A — a fallback must not + * fake capability). `job` came off the kernel's pre-injection list + * (`CORE_FALLBACK_FACTORIES` in `@objectstack/core`), so `getService('job')` + * now throws when no job plugin is installed, the plugin's existing catch + * falls through to `setInterval`, and single-kernel deployments actually + * dispatch scheduled reports. + * + * WHY THIS FILE EXISTS BESIDE `plugin-shutdown-releases-dispatcher.test.ts`. + * That file pins RELEASE at shutdown, and its running-dispatcher leg runs on + * `LiteKernel` — which injects no fallbacks, so it could never see this + * defect. Nothing pinned that the dispatcher RUNS on `ObjectKernel`; that gap + * is exactly why the defect shipped invisibly. This pin is the acceptance + * evidence for the fix: same composition a real single-kernel deployment + * boots, no job plugin anywhere, and the poll traffic itself is the assertion. + */ + +import { describe, it, expect, afterEach } from 'vitest'; +import { ObjectKernel } from '@objectstack/core'; +import { ObjectQLPlugin } from '@objectstack/objectql'; +import type { ObjectQL } from '@objectstack/objectql'; +import { SqlDriver } from '@objectstack/driver-sql'; +import type { IDataEngine } from '@objectstack/spec/contracts'; +import { ReportsServicePlugin } from './reports-plugin.js'; + +/** + * The plugin floors its own interval at 5s (`Math.max(5_000, …)`), so this is + * the fastest REAL clock the dispatcher can be driven at. + */ +const DISPATCH_INTERVAL_MS = 5_000; +/** Comfortably past one tick boundary, so a window that sees zero is silence. */ +const OBSERVE_MS = 5_600; + +const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); + +const openKernels: Array<{ shutdown(): Promise }> = []; +const openDrivers: Array<{ disconnect?: () => Promise }> = []; + +afterEach(async () => { + // Kernels first, drivers second: the kernel's own teardown still wants a + // live driver to drain against. + while (openKernels.length) { + try { await openKernels.pop()?.shutdown(); } catch { /* already stopped */ } + } + while (openDrivers.length) { + try { await openDrivers.pop()?.disconnect?.(); } catch { /* noop */ } + } +}); + +/** + * Installs the read counter on the engine instance the dispatcher captured at + * `kernel:ready` (`ctx.getService('objectql')` resolves to this same object), + * so the tally is of real `ReportService.dispatchDue()` traffic, not a + * stand-in. + */ +function countScheduleReads(engineHolder: { getService: (n: string) => T }): () => number { + type EngineCall = (name: string, ...rest: unknown[]) => unknown; + const engine = engineHolder.getService('objectql') as unknown as + Record<'find', EngineCall>; + let reads = 0; + const orig = engine.find.bind(engine); + engine.find = (name: string, ...rest: unknown[]) => { + if (String(name) === 'sys_report_schedule') reads++; + return orig(name, ...rest); + }; + return () => reads; +} + +/** A real in-memory SQL driver, connected and registered on `engine`. */ +async function attachSqlite(objectql: any): Promise { + const driver: any = new SqlDriver({ + client: 'better-sqlite3', + connection: { filename: ':memory:' }, + useNullAsDefault: true, + }); + await driver.connect(); + objectql.registerDriver(driver, true); + openDrivers.push(driver); + await objectql.syncSchemas(); +} + +describe('#10746 the dispatcher RUNS on ObjectKernel with no job plugin', () => { + it( + 'polls sys_report_schedule — the setInterval fallback is reachable on the production kernel', + { timeout: 60_000 }, + async () => { + // The composition a real single-kernel deployment boots: ObjectKernel + // (NOT LiteKernel), the engine, the reports plugin — and no job plugin. + const kernel = new ObjectKernel({ logger: { level: 'silent' } }); + openKernels.push(kernel); + + await kernel.use(new ObjectQLPlugin()); + await kernel.use(new ReportsServicePlugin({ dispatchIntervalMs: DISPATCH_INTERVAL_MS })); + await kernel.bootstrap(); + + await attachSqlite(kernel.getService('objectql')); + const scheduleReads = countScheduleReads(kernel as any); + + await sleep(OBSERVE_MS); + + // THE PIN. Before the fix this was 0 — the kernel pre-injected a `job` + // fallback whose `schedule()` recorded the dispatcher and never fired + // it, while the plugin logged success. One tick boundary has passed, so + // silence here is the defect, not timing. + expect(scheduleReads()).toBeGreaterThan(0); + }, + ); +}); diff --git a/packages/runtime/src/http-dispatcher.test.ts b/packages/runtime/src/http-dispatcher.test.ts index 6b84153739..ceeb87efb3 100644 --- a/packages/runtime/src/http-dispatcher.test.ts +++ b/packages/runtime/src/http-dispatcher.test.ts @@ -3120,10 +3120,16 @@ describe('HttpDispatcher', () => { // shape, was test-invisible. it('reports every CORE_FALLBACK_FACTORIES product as degraded, never available (#3898)', async () => { - const { CORE_FALLBACK_FACTORIES } = await import('@objectstack/core'); + const { CORE_FALLBACK_FACTORIES, createMemoryJob } = await import('@objectstack/core'); expect(Object.keys(CORE_FALLBACK_FACTORIES).length).toBeGreaterThan(0); - for (const [slot, factory] of Object.entries(CORE_FALLBACK_FACTORIES)) { + // [#10746] `job` came OFF the pre-injection list (a fallback must + // not fake capability), but `createMemoryJob` stays exported for + // deliberate registration — so its product stays in THIS gate's + // inventory: however it reaches a slot, discovery must never call + // it `available`. + const inventory = { ...CORE_FALLBACK_FACTORIES, job: createMemoryJob }; + for (const [slot, factory] of Object.entries(inventory)) { const fallback = factory(); (kernel as any).getService = vi.fn().mockImplementation((n: string) => (n === slot ? fallback : null)); (kernel as any).services = new Map([[slot, fallback]]); @@ -3164,12 +3170,17 @@ describe('HttpDispatcher', () => { it('answers the cache/queue/job slots identically to the metadata-protocol builder (#4318)', async () => { const { ObjectStackProtocolImplementation } = await import('@objectstack/metadata-protocol'); - const { CORE_FALLBACK_FACTORIES } = await import('@objectstack/core'); + const { CORE_FALLBACK_FACTORIES, createMemoryJob } = await import('@objectstack/core'); + // [#10746] `job` is no longer ON the pre-injection map, but its + // factory stays exported and explicitly registrable, so the slot + // keeps both occupant shapes here. + const factoryFor = (slot: string) => + slot === 'job' ? createMemoryJob : CORE_FALLBACK_FACTORIES[slot]; for (const slot of ['cache', 'queue', 'job']) { // Both shapes an occupant can take: a real (unmarked) service - // and the kernel's self-describing in-memory fallback. - for (const svc of [{}, CORE_FALLBACK_FACTORIES[slot]()]) { + // and the self-describing in-memory fallback. + for (const svc of [{}, factoryFor(slot)()]) { (kernel as any).getService = vi.fn().mockImplementation((n: string) => (n === slot ? svc : null)); (kernel as any).services = new Map([[slot, svc]]);