From f7dde2e33f04e389484771df593624951cff20a7 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 1 Sep 2026 15:27:56 +0000 Subject: [PATCH 1/3] wip(runtime): give a declarative job handler data reach (#14094) --- .../src/app-plugin.job-data-reach.test.ts | 396 ++++++++++++++++++ packages/runtime/src/app-plugin.ts | 20 +- packages/runtime/src/index.ts | 4 + packages/runtime/src/job-handler-context.ts | 94 +++++ 4 files changed, 513 insertions(+), 1 deletion(-) create mode 100644 packages/runtime/src/app-plugin.job-data-reach.test.ts create mode 100644 packages/runtime/src/job-handler-context.ts diff --git a/packages/runtime/src/app-plugin.job-data-reach.test.ts b/packages/runtime/src/app-plugin.job-data-reach.test.ts new file mode 100644 index 0000000000..12121c3944 --- /dev/null +++ b/packages/runtime/src/app-plugin.job-data-reach.test.ts @@ -0,0 +1,396 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #14094 — a declarative job's handler must be able to READ AND WRITE A RECORD. + * + * ## What was measured before the fix + * + * A real booted stack with one `defineJob` whose handler records its own + * argument reported, on `origin/main` `66ecc50a` (and, in the filed card, + * against published `@objectstack/*` 17.2.0): + * + * ``` + * JOB CONTEXT KEYS: bundle, data, jobId + * jobId -> string + * data -> undefined + * bundle -> object + * ``` + * + * That is the whole context. No engine, no service registry, no logger. The + * platform ships exactly ONE metadata shape for scheduled work, resolves its + * handler out of `defineStack({ functions })`, and then hands that handler + * nothing to write with. The job registers, appears in the admin UI, is + * scheduled, runs on time, and does nothing — `objectstack validate` passes. + * + * ## Why the flow-`script`-node contract is NOT the same case + * + * `FlowFunctionContext` carries no engine either, and that is COHERENT for a + * flow function: the flow graph does the I/O around it (`get_record` before, + * `create_record` after), which is what #4354's per-run write metrics count. + * A JOB HAS NO GRAPH — no node before it, none after — so the same emptiness + * leaves it unable to do the one thing jobs exist for. Nothing here changes + * what a `script` node receives. + * + * ## Why the ARTIFACT path is tested, not just the TS-config path + * + * The documented escape (close over a client at module scope, bound from + * `defineStack({ onEnable })`) does not survive the shipped deployment path: + * `objectstack build` emits `{ functions, meta }` into a sibling runtime + * module, the artifact JSON carries no `onEnable`, and `mergeRuntimeModule` + * merges only `functions`. So the binding is never made on an artifact-served + * boot and the module-scope slot stays empty — silently. A fix proved only on + * the TS-config path would be a second escape with the same blind spot, so the + * artifact boot is driven through the REAL `loadArtifactBundle` here. + */ + +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import type { PluginContext } from '@objectstack/core'; +import type { JobHandler } from '@objectstack/spec/contracts'; +import { defineJob } from '@objectstack/spec/system'; +import { ObjectQL } from '@objectstack/objectql'; +import { InMemoryDriver } from '@objectstack/driver-memory'; +import { CronJobAdapter } from '@objectstack/service-job'; +import { AppPlugin } from './app-plugin.js'; +import { loadArtifactBundle } from './load-artifact-bundle.js'; +import type { JobHandlerContext } from './job-handler-context.js'; + +/** The record a scheduled sweep is supposed to be able to write. */ +const NOTE = { + name: 'sweep_note', + label: 'Sweep Note', + fields: { + title: { type: 'text' }, + swept: { type: 'text' }, + }, +}; + +/** + * The three members the context carried BEFORE #14094 — pinned as a set so the + * widening is visible in the diff of anything that changes it, and so + * "existing handlers unchanged" is asserted rather than asserted-about. + */ +const PRE_14094_KEYS = ['bundle', 'data', 'jobId'] as const; +/** What #14094 added, and nothing else. */ +const ADDED_KEYS = ['logger', 'ql'] as const; + +interface Harness { + engine: ObjectQL; + adapter: CronJobAdapter; + ctx: PluginContext; + fireReady: () => Promise; + errorLogs: () => string[]; + warnLogs: () => string[]; +} + +const live: Array<{ engine?: ObjectQL; adapter?: CronJobAdapter; dir?: string }> = []; + +afterEach(async () => { + for (const entry of live.splice(0)) { + try { await entry.adapter?.destroy(); } catch { /* noop */ } + try { await entry.engine?.destroy(); } catch { /* noop */ } + if (entry.dir) rmSync(entry.dir, { recursive: true, force: true }); + } +}); + +/** A real engine over the in-memory driver, with `sweep_note` registered. */ +async function bootEngine(): Promise { + const driver = new InMemoryDriver(); + const engine = new ObjectQL(); + engine.registerDriver(driver as never, true); + await engine.init(); + engine.registry.registerObject(NOTE as never); + return engine; +} + +async function harness(): Promise { + const engine = await bootEngine(); + const adapter = new CronJobAdapter(); + live.push({ engine, adapter }); + + const readyHooks: Array<() => Promise> = []; + const ctx = { + logger: { info: vi.fn(), error: vi.fn(), warn: vi.fn(), debug: vi.fn() }, + registerService: vi.fn(), + getService: vi.fn((name: string) => { + if (name === 'job') return adapter; + if (name === 'objectql') return engine; + return undefined; + }), + getServices: vi.fn(() => []), + hook: vi.fn((event: string, cb: () => Promise) => { + if (event === 'kernel:ready') readyHooks.push(cb); + }), + trigger: vi.fn(), + } as unknown as PluginContext; + + return { + engine, + adapter, + ctx, + fireReady: async () => { for (const cb of readyHooks) await cb(); }, + errorLogs: () => vi.mocked(ctx.logger.error).mock.calls.map(c => String(c[0])), + warnLogs: () => vi.mocked(ctx.logger.warn).mock.calls.map(c => String(c[0])), + }; +} + +/** The nightly-sweep shape the card is about: read some rows, write one back. */ +async function sweepHandler(jobCtx: JobHandlerContext): Promise { + const { ql, jobId, logger } = jobCtx; + const pending = await ql.find('sweep_note', { where: { swept: 'no' } }) as Array<{ id: string }>; + for (const row of pending) { + await ql.update('sweep_note', { id: row.id, swept: jobId }); + } + logger.info('[test] sweep complete', { job: jobId, updated: pending.length }); +} + +const rowsOf = (result: unknown): Array> => + Array.isArray(result) ? result as Array> : []; + +describe('#14094 — a declarative job handler has data reach (TS-config path)', () => { + it('reads and writes a record through the context it is invoked with', async () => { + const h = await harness(); + await h.engine.insert('sweep_note', { title: 'first', swept: 'no' }); + await h.engine.insert('sweep_note', { title: 'second', swept: 'no' }); + + const plugin = new AppPlugin({ + id: 'com.test.job-reach', + jobs: [defineJob({ + name: 'nightly_sweep', + schedule: { type: 'cron', expression: '0 1 * * *' }, + handler: 'sweep', + })], + functions: { sweep: { handler: sweepHandler, effect: 'writes' } }, + }); + + await plugin.start!(h.ctx); + await h.fireReady(); + expect(await h.adapter.listJobs()).toContain('nightly_sweep'); + + // The job runs the way the scheduler runs it — no direct handler call. + await h.adapter.trigger('nightly_sweep'); + + // SUBSTANCE: the STORE changed. Not "the handler was called", not "the + // context had a key" — the rows a scheduled sweep exists to update. + const after = rowsOf(await h.engine.find('sweep_note', {})); + expect(after).toHaveLength(2); + expect(after.map(r => r.swept).sort()).toEqual(['nightly_sweep', 'nightly_sweep']); + expect(h.errorLogs()).toEqual([]); + }); + + it('the context is the pre-#14094 set PLUS exactly `ql` and `logger`', async () => { + const h = await harness(); + const seen: Array> = []; + + const plugin = new AppPlugin({ + id: 'com.test.job-reach', + jobs: [defineJob({ name: 'probe_job', schedule: { type: 'interval', intervalMs: 60_000 }, handler: 'probe' })], + functions: { probe: async (c: Record) => { seen.push(c); } }, + }); + await plugin.start!(h.ctx); + await h.fireReady(); + await h.adapter.trigger('probe_job'); + + expect(seen).toHaveLength(1); + const keys = Object.keys(seen[0]).sort(); + expect(keys).toEqual([...PRE_14094_KEYS, ...ADDED_KEYS].sort()); + + // The pre-existing members keep their meaning — `jobId` is the job's + // name, `bundle` is the metadata bundle, `data` is the trigger payload. + expect(seen[0].jobId).toBe('probe_job'); + expect(seen[0].bundle).toBeTypeOf('object'); + expect(seen[0].data).toBeUndefined(); + + // And the added members are the LIVE handles, not placeholders. + expect(seen[0].ql).toBe(h.engine); + expect(seen[0].logger).toBe(h.ctx.logger); + }); + + it('`data` from a manual trigger still reaches the handler beside the new members', async () => { + const h = await harness(); + const seen: Array> = []; + const plugin = new AppPlugin({ + id: 'com.test.job-reach', + jobs: [defineJob({ name: 'payload_job', schedule: { type: 'interval', intervalMs: 60_000 }, handler: 'probe' })], + functions: { probe: async (c: Record) => { seen.push(c); } }, + }); + await plugin.start!(h.ctx); + await h.fireReady(); + + await h.adapter.trigger('payload_job', { reason: 'manual' }); + + expect(seen[0].data).toEqual({ reason: 'manual' }); + expect(seen[0].ql).toBe(h.engine); + }); +}); + +describe('#14094 — the same reach on the ARTIFACT path', () => { + /** + * Builds on disk what `objectstack build` emits: a JSON artifact carrying + * NO `onEnable` plus a sibling ESM module exporting `{ functions, meta }`, + * and loads it through the REAL `loadArtifactBundle` / `mergeRuntimeModule`. + */ + async function bootFromArtifact() { + const dir = mkdtempSync(join(tmpdir(), 'os-job-reach-14094-')); + const h = await harness(); + live.push({ dir }); + + // The handler lives only in the sibling module — exactly as a built app + // ships it. It has no module-scope binding seam and no `onEnable` to + // fill one: everything it writes with comes from its argument. + writeFileSync(join(dir, 'runtime.mjs'), ` +export const functions = { + sweep: async ({ ql, jobId }) => { + const pending = await ql.find('sweep_note', { where: { swept: 'no' } }); + for (const row of pending) await ql.update('sweep_note', { id: row.id, swept: jobId }); + }, +}; +export const meta = { builtAt: '2026-09-01T00:00:00.000Z' }; +`, 'utf-8'); + + const artifact = { + manifest: { id: 'com.test.job-reach-artifact', name: 'Artifact Job Reach', version: '1.0.0' }, + // What the builder lowers a callable to: a handler REF plus what the + // function declared about itself. The callable rides in the module. + functions: { sweep: { handler: 'sweep', effect: 'writes' } }, + jobs: [JSON.parse(JSON.stringify(defineJob({ + name: 'artifact_sweep', + schedule: { type: 'cron', expression: '0 2 * * *' }, + handler: 'sweep', + })))], + runtimeModule: './runtime.mjs', + }; + const artifactPath = join(dir, 'objectstack.artifact.json'); + writeFileSync(artifactPath, JSON.stringify(artifact), 'utf-8'); + + const bundle = await loadArtifactBundle(artifactPath, { tag: '[test:14094]' }); + return { dir, h, bundle }; + } + + it('an artifact-served job writes a record — the path where the module-scope escape silently fails', async () => { + const { h, bundle } = await bootFromArtifact(); + + // The premise of Zone 1.2, asserted rather than assumed: there is no + // `onEnable` on this boot path, so "have onEnable assign a module-scope + // global" is not available to the handler at all. + expect(bundle).not.toBeNull(); + expect((bundle as Record).onEnable).toBeUndefined(); + // The callable did arrive — via `functions`, the only thing merged. + expect(typeof (bundle as any).functions.sweep.handler).toBe('function'); + + await h.engine.insert('sweep_note', { title: 'artifact row', swept: 'no' }); + + const plugin = new AppPlugin(bundle); + await plugin.start!(h.ctx); + await h.fireReady(); + expect(await h.adapter.listJobs()).toContain('artifact_sweep'); + + await h.adapter.trigger('artifact_sweep'); + + const after = rowsOf(await h.engine.find('sweep_note', {})); + expect(after).toHaveLength(1); + expect(after[0].swept).toBe('artifact_sweep'); + expect(h.errorLogs()).toEqual([]); + }); + + it('FIRING CONTROL: the same artifact handler cannot write without the reach', async () => { + const { h, bundle } = await bootFromArtifact(); + await h.engine.insert('sweep_note', { title: 'artifact row', swept: 'no' }); + + // The exact callable the artifact shipped, invoked with the context + // shape this card measured BEFORE the fix. If the assertion above ever + // passed vacuously — because something else wrote the row — this would + // pass too. It does not: with no `ql` the handler cannot even start. + const shipped = (bundle as any).functions.sweep.handler as (c: unknown) => Promise; + await expect( + shipped({ jobId: 'artifact_sweep', data: undefined, bundle }), + ).rejects.toThrow(/ql/i); + + const after = rowsOf(await h.engine.find('sweep_note', {})); + expect(after[0].swept).toBe('no'); + }); +}); + +describe('#14094 — additivity (Zone 1.1)', () => { + it('a handler written against the PRE-#14094 context runs unchanged, byte for byte', async () => { + const h = await harness(); + const calls: Array<{ jobId: string; data?: unknown }> = []; + + // Verbatim the shape `IJobService`'s `JobHandler` declares — the type an + // existing handler was written against. It names two members and knows + // nothing about `ql` / `logger`. + const legacy = async (context: { jobId: string; data?: unknown }): Promise => { + calls.push({ jobId: context.jobId, data: context.data }); + }; + // Still a `JobHandler`: this line is what a NARROWING would break. + const stillAJobHandler: JobHandler = legacy; + expect(typeof stillAJobHandler).toBe('function'); + + const plugin = new AppPlugin({ + id: 'com.test.job-reach', + jobs: [defineJob({ name: 'legacy_job', schedule: { type: 'interval', intervalMs: 60_000 }, handler: 'legacy' })], + functions: { legacy }, + }); + await plugin.start!(h.ctx); + await h.fireReady(); + await h.adapter.trigger('legacy_job'); + + expect(calls).toEqual([{ jobId: 'legacy_job', data: undefined }]); + expect(h.errorLogs()).toEqual([]); + expect(h.warnLogs()).toEqual([]); + }); + + it('`IJobService` is untouched: the function AppPlugin schedules still satisfies `JobHandler`', async () => { + const h = await harness(); + const scheduled: Array<{ name: string; handler: JobHandler }> = []; + // A THIRD-PARTY IJobService implementation, typed at the contract and + // nothing wider. It compiles and runs against what AppPlugin hands it — + // the widening happens INSIDE that wrapper, never at this boundary. + const recording = { + schedule: async (name: string, _schedule: unknown, handler: JobHandler) => { + scheduled.push({ name, handler }); + }, + cancel: async () => { /* noop */ }, + trigger: async (name: string, data?: unknown) => { + const entry = scheduled.find(s => s.name === name); + await entry?.handler({ jobId: name, data }); + }, + }; + vi.mocked(h.ctx.getService).mockImplementation((name: string) => { + if (name === 'job') return recording as never; + if (name === 'objectql') return h.engine as never; + return undefined as never; + }); + + await h.engine.insert('sweep_note', { title: 'third party', swept: 'no' }); + const plugin = new AppPlugin({ + id: 'com.test.job-reach', + jobs: [defineJob({ name: 'third_party_job', schedule: { type: 'cron', expression: '0 3 * * *' }, handler: 'sweep' })], + functions: { sweep: { handler: sweepHandler, effect: 'writes' } }, + }); + await plugin.start!(h.ctx); + await h.fireReady(); + + expect(scheduled.map(s => s.name)).toEqual(['third_party_job']); + await recording.trigger('third_party_job'); + + const after = rowsOf(await h.engine.find('sweep_note', {})); + expect(after[0].swept).toBe('third_party_job'); + }); + + it('FIRING CONTROL: the sweep handler fails on the pre-#14094 context', async () => { + const h = await harness(); + await h.engine.insert('sweep_note', { title: 'control', swept: 'no' }); + + await expect( + (sweepHandler as unknown as (c: unknown) => Promise)({ + jobId: 'nightly_sweep', data: undefined, bundle: {}, + }), + ).rejects.toThrow(/ql/i); + + const after = rowsOf(await h.engine.find('sweep_note', {})); + expect(after[0].swept).toBe('no'); + }); +}); diff --git a/packages/runtime/src/app-plugin.ts b/packages/runtime/src/app-plugin.ts index 7f6b0de188..fa5b2bf6bf 100644 --- a/packages/runtime/src/app-plugin.ts +++ b/packages/runtime/src/app-plugin.ts @@ -16,6 +16,7 @@ import { QuickJSScriptRunner } from './sandbox/quickjs-runner.js'; import { hookBodyRunnerFactory, actionBodyRunnerFactory } from './sandbox/body-runner.js'; import { GLOBAL_ACTION_OBJECT_KEY } from './action-execution.js'; import { toBoundaryJobSchedule } from './job-schedule.js'; +import type { JobHandlerContext } from './job-handler-context.js'; import { countServerTiming, SEMCONV } from '@objectstack/observability'; import { resolveMetrics } from './observability/observability-service-plugin.js'; @@ -936,8 +937,25 @@ export class AppPlugin implements Plugin { // bare cron string. Same seam, same place, as the // retryPolicy/timeout threading just below. toBoundaryJobSchedule(job.schedule, jobName), + // #14094: the handler is given DATA REACH. A job has no + // graph — no node before it, none after — so unlike a + // flow `script` node it cannot be a pure value-returner + // whose I/O the surrounding graph performs. `ql` is the + // same engine handle `defineStack({ onEnable })` gets, and + // it is the only route that survives the ARTIFACT path: + // an artifact carries no `onEnable` and `mergeRuntimeModule` + // merges only `functions`, so the module-scope-global + // escape is never bound on an artifact-served boot. + // Additive — see `JobHandlerContext` for the full argument. async (jobCtx: any) => { - await handler({ ...jobCtx, jobId: jobName, bundle: this.bundle }); + const jobContext: JobHandlerContext = { + ...jobCtx, + jobId: jobName, + bundle: this.bundle, + ql, + logger: ctx.logger, + }; + await handler(jobContext); }, // #3494: thread the authored retryPolicy/timeout to the adapter (job.retryPolicy || job.timeout) diff --git a/packages/runtime/src/index.ts b/packages/runtime/src/index.ts index 7e11452716..e425a8a235 100644 --- a/packages/runtime/src/index.ts +++ b/packages/runtime/src/index.ts @@ -59,6 +59,10 @@ export { MigrationRecoveryPlugin, describeInterruptedRun } from './migration-rec export { DefaultDatasourcePlugin } from './default-datasource-plugin.js'; export type { DefaultDatasourceDefinition, DefaultDatasourcePluginOptions } from './default-datasource-plugin.js'; export { AppPlugin, collectBundleHooks, collectBundleFunctions, collectBundleFunctionEntries, collectBundleActions } from './app-plugin.js'; +// #14094 — what a DECLARATIVE job's handler is invoked with. A job has no graph, +// so unlike a flow `script` node it is given data reach (`ql`) instead of being a +// pure value-returner whose I/O the surrounding graph performs. +export type { JobHandlerContext } from './job-handler-context.js'; export { SeedLoaderService } from './seed-loader.js'; // Boot-summary seed outcome accumulator (#3415/#3430) — the single writer // contract shared by AppPlugin and the marketplace rehydrate/heal path. diff --git a/packages/runtime/src/job-handler-context.ts b/packages/runtime/src/job-handler-context.ts new file mode 100644 index 0000000000..d9d964f3fe --- /dev/null +++ b/packages/runtime/src/job-handler-context.ts @@ -0,0 +1,94 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The context a DECLARATIVE background job's handler is invoked with (#14094). + * + * ## Why this type exists at all — a job has no graph + * + * A `defineStack({ functions })` entry reached from a flow `script` node is + * handed `FlowFunctionContext`, which carries no engine handle. That emptiness + * is COHERENT for a flow function: the flow graph does the I/O around it — a + * `get_record` node before, a `create_record` node after — so the function + * stays a pure value-returner and #4354's per-run write metrics can count what + * the graph persisted. ⛔ Nothing here reopens that contract. + * + * A JOB has no graph. `defineJob` is the platform's ONLY metadata shape for + * scheduled work, its handler resolves out of the same `functions` map, and + * there is no node before it and none after. Until #14094 the context it + * received was `{ jobId, data, bundle }` — no engine, no logger, nothing to + * write with — so the one thing scheduled work exists to do (write records on a + * timer: a nightly sweep, a dispatcher, a reconciliation) had no supported + * route. The job registered, appeared in the admin UI, was scheduled, ran on + * time, and did nothing; `objectstack validate` passed and no author-time gate + * said otherwise. + * + * ## Why not "close over a client at module scope" + * + * That escape is real for a flow function and does NOT survive the shipped + * deployment path for a job. The only place an application is handed `ctx.ql` + * is `defineStack({ onEnable })`, so the escape is really "have `onEnable` + * assign a module-scope global the handler reads later" — and `objectstack + * build` emits `functions` into a sibling runtime module exporting only + * `{ functions, meta }`. The artifact JSON carries no `onEnable` and + * `mergeRuntimeModule` merges only `functions`, so on an artifact-served boot + * the binding is never made and the slot stays empty — silently. + * `app-plugin.job-data-reach.test.ts` pins both boot paths for exactly that + * reason. + * + * ## Additivity + * + * This is a WIDENING of the object passed to the handler, in the same shape + * #6617's `JobRunOutcome` widening took. `IJobService`'s + * `JobHandler = (context: { jobId: string; data?: unknown }) => …` is untouched + * — the function `AppPlugin` hands to `IJobService.schedule` is a wrapper that + * satisfies it exactly, and the members below are added INSIDE that wrapper. + * An existing handler that destructures `{ jobId }` or `{ jobId, data }` is + * unchanged byte for byte; no `IJobService` implementation grows a member. + */ + +import type { IObjectQLEngine, Logger } from '@objectstack/spec/contracts'; + +/** + * What `AppPlugin` invokes a declarative job's `functions` entry with. + * + * ```ts + * // objectstack.config.ts + * defineStack({ + * jobs: [defineJob({ name: 'nightly_sweep', schedule: { type: 'cron', expression: '0 1 * * *' }, handler: 'sweep' })], + * functions: { sweep: { handler: sweep, effect: 'writes' } }, + * }); + * + * // the handler — no module-scope global, no onEnable binding seam + * async function sweep({ jobId, ql, logger }: JobHandlerContext) { + * const stale = await ql.find('task', { where: { status: 'open' } }); + * for (const t of stale) await ql.update('task', { id: t.id, status: 'expired' }); + * logger.info('swept', { job: jobId, count: stale.length }); + * } + * ``` + */ +export interface JobHandlerContext { + /** The job's `name` — its identity everywhere (#4667). */ + jobId: string; + /** Payload of a manual `IJobService.trigger(name, data)` run; absent on a scheduled run. */ + data?: unknown; + /** + * The application's metadata bundle, as `AppPlugin` holds it. Present since + * before #14094 and unchanged — declarations, not a data handle. + */ + bundle: unknown; + /** + * The live ObjectQL engine — the SAME handle `defineStack({ onEnable })` + * receives as `ctx.ql`. This is the member #14094 added: the supported way + * for scheduled work to read and write records. + * + * A job's writes are not counted by any caller, so its `functions` entry + * should still declare `effect: 'writes'` (#4396) — that declaration makes + * the run report "cannot say" instead of silently claiming it wrote nothing. + */ + ql: IObjectQLEngine; + /** + * The plugin logger — the SAME `Logger` `onEnable` receives — so a job's own + * diagnostics land in the platform's log stream instead of `console`. + */ + logger: Logger; +} From 95f0cfbbe1fde8e67a5af0b3f7cb65faec5b93c4 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 1 Sep 2026 16:34:09 +0000 Subject: [PATCH 2/3] feat(runtime): give a declarative job's handler data reach (#14094) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `defineJob` is the platform's only metadata shape for scheduled work, and `AppPlugin` invoked its handler with `{ jobId, data, bundle }` — no engine, no logger, nothing to write with. The job registered, appeared in the admin UI, was scheduled, ran on time, and did nothing. The context AppPlugin builds now also carries `ql` (the same ObjectQL handle `defineStack({ onEnable })` receives) and `logger`. `JobHandlerContext` is exported from `@objectstack/runtime`. A job has no graph — no node before it, none after — so unlike a flow `script` node it cannot be a pure value-returner whose I/O the graph performs. Nothing about the `script` node contract changes. Additive: `IJobService`'s `JobHandler` is untouched; the members are added inside the wrapper AppPlugin hands to `schedule`, so an existing handler is unchanged byte for byte. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Q5WBDtaUnoz5XuJ6jk8pQ5 --- .changeset/job-handler-data-reach.md | 51 +++++++++++++++++++ content/docs/automation/jobs.mdx | 44 +++++++++++++++- .../src/app-plugin.job-data-reach.test.ts | 7 ++- 3 files changed, 98 insertions(+), 4 deletions(-) create mode 100644 .changeset/job-handler-data-reach.md diff --git a/.changeset/job-handler-data-reach.md b/.changeset/job-handler-data-reach.md new file mode 100644 index 0000000000..1a43d04a78 --- /dev/null +++ b/.changeset/job-handler-data-reach.md @@ -0,0 +1,51 @@ +--- +"@objectstack/runtime": minor +--- + +feat(runtime): give a declarative job's handler data reach (#14094) + +`defineJob` is the platform's only metadata shape for scheduled work. Its +handler resolves out of `defineStack({ functions })`, and until now `AppPlugin` +invoked it with `{ jobId, data, bundle }` — no engine, no logger, nothing to +write with. The job registered, appeared in the admin UI, was scheduled, ran on +time, and did nothing. `objectstack validate` passed and no author-time gate +said otherwise. + +The context `AppPlugin` builds now also carries: + +- **`ql`** — the live ObjectQL engine, the same handle `defineStack({ onEnable })` + receives as `ctx.ql`; +- **`logger`** — the plugin `Logger`, so a job's diagnostics land in the + platform's log stream instead of `console`. + +`JobHandlerContext` is exported from `@objectstack/runtime` for handlers that +want to annotate their argument. + +**Why a job and a flow `script` node differ here.** `FlowFunctionContext` also +carries no engine, and that is coherent for a flow function: the flow graph does +the I/O around it (`get_record` before, `create_record` after), which is what +the per-run write metrics count. A job has no graph — no node before it, none +after — so the same emptiness leaves it unable to do the one thing scheduled +work exists for. Nothing about a `script` node's contract changes. + +**Why the module-scope escape was not documented instead.** Closing over a +client bound by `onEnable` does not survive the shipped deployment path: +`objectstack build` emits `functions` into a sibling runtime module exporting +only `{ functions, meta }`, the artifact JSON carries no `onEnable`, and +`mergeRuntimeModule` merges only `functions` — so on an artifact-served boot the +binding is never made and the handler runs against an empty slot, silently. The +regression suite therefore proves the write on **both** boot paths: the +TS-config path and a real artifact loaded through `loadArtifactBundle`. + +**Additive.** This widens the object passed to the handler, the same shape +`JobRunOutcome` took. `IJobService`'s +`JobHandler = (context: { jobId: string; data?: unknown }) => …` is untouched — +the function `AppPlugin` hands to `IJobService.schedule` is a wrapper that +satisfies it exactly, and the new members are added inside that wrapper. An +existing handler that destructures `{ jobId }` or `{ jobId, data }` is unchanged +byte for byte, and no `IJobService` implementation grows a member. + +The kernel's `getService` was considered and deliberately not added: it would +put the whole service registry on a job's context permanently, and the measured +population of shipped `defineJob` handlers needs the engine and nothing else. +Adding it later is additive by exactly this argument, so nothing is foreclosed. diff --git a/content/docs/automation/jobs.mdx b/content/docs/automation/jobs.mdx index a3597f1b69..393afca17e 100644 --- a/content/docs/automation/jobs.mdx +++ b/content/docs/automation/jobs.mdx @@ -122,8 +122,48 @@ a long time with no function of that name anywhere in the app was skipped at every boot, and the sweep never ran. If a job appears to do nothing, read the boot log for its name before reading its schedule. -At run time the handler is invoked with `{ jobId, data }`. What it returns -decides how the run is recorded: +## What the handler is given + +At run time the handler is invoked with a `JobHandlerContext` +(`@objectstack/runtime`): + +| Member | What it is | +|:---|:---| +| `jobId` | the job's `name` — its identity everywhere | +| `data` | the payload of a manual `trigger(name, data)` run; absent on a scheduled run | +| `bundle` | the application's metadata bundle — declarations, not a data handle | +| `ql` | the live ObjectQL engine — the same handle `defineStack({ onEnable })` receives | +| `logger` | the platform logger, so a job's diagnostics are not `console` output | + +```typescript +import type { JobHandlerContext } from '@objectstack/runtime'; + +export async function sweepProjectHealth({ ql, jobId, logger }: JobHandlerContext) { + const stale = await ql.find('project', { where: { status: 'active' } }); + for (const p of stale) await ql.update('project', { id: p.id, health: 'green' }); + logger.info('sweep complete', { job: jobId, scanned: stale.length }); +} +``` + +`ql` is why a job does **not** follow the pure-function rule a flow `script` +node follows. A `script` node returns a value and the flow graph does the I/O +around it — a `get_record` before, a `create_record` after — so its context +deliberately carries no engine. A job has no graph: no node before it, none +after. Writing records on a timer is the thing scheduled work exists for, so +the handler is handed the engine directly. A job's writes are still not counted +by any caller, so declare its `functions` entry `effect: 'writes'` — that makes +the run report "cannot say" rather than silently claiming it wrote nothing. + + + Do **not** reach the engine by having `onEnable` assign a module-scope global + the handler reads later. That binding does not survive a built artifact: + `objectstack build` emits your `functions` into a sibling runtime module, the + artifact JSON carries no `onEnable`, and only `functions` is merged back on + load — so on an artifact-served boot the global is never assigned and the job + runs against nothing, silently. Take `ql` from the context. + + +What the handler returns decides how the run is recorded: | The handler… | Recorded as | Retried? | |:---|:---|:---| diff --git a/packages/runtime/src/app-plugin.job-data-reach.test.ts b/packages/runtime/src/app-plugin.job-data-reach.test.ts index 12121c3944..12d1ae115a 100644 --- a/packages/runtime/src/app-plugin.job-data-reach.test.ts +++ b/packages/runtime/src/app-plugin.job-data-reach.test.ts @@ -306,7 +306,10 @@ export const meta = { builtAt: '2026-09-01T00:00:00.000Z' }; const shipped = (bundle as any).functions.sweep.handler as (c: unknown) => Promise; await expect( shipped({ jobId: 'artifact_sweep', data: undefined, bundle }), - ).rejects.toThrow(/ql/i); + // Reaching through the absent handle — `undefined.find(...)` — is what + // "no data reach" IS. The message wording is V8's, so only the shape is + // pinned; the store assertion below is what carries the control. + ).rejects.toThrow(TypeError); const after = rowsOf(await h.engine.find('sweep_note', {})); expect(after[0].swept).toBe('no'); @@ -388,7 +391,7 @@ describe('#14094 — additivity (Zone 1.1)', () => { (sweepHandler as unknown as (c: unknown) => Promise)({ jobId: 'nightly_sweep', data: undefined, bundle: {}, }), - ).rejects.toThrow(/ql/i); + ).rejects.toThrow(TypeError); const after = rowsOf(await h.engine.find('sweep_note', {})); expect(after[0].swept).toBe('no'); From 72c20362c0663aa27bbfb0cf834cfa3ce144528e Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 1 Sep 2026 17:34:58 +0000 Subject: [PATCH 3/3] test(runtime): boot the job-data-reach suite on the migrated sqlite :memory: backend MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #5704 migrated this project's test backends to sqlite `:memory:` and ruled that only the two files in `scripts/driver-memory-census.ledger.json` keep `@objectstack/driver-memory`, each being one arm of a cross-family pin that cannot run on SQL. This suite is neither — it needs *a* store, not that store — so `pnpm check:driver-memory-census` was right to refuse it as an unledgered arrival (#6664). Migrated rather than ledgered: the census stays at 2 ruled consumers and the ledger is untouched. The card's own reproduction used the in-memory driver because that is what the reporter had in hand; that was a manual probe, never a constraint on this rig. Provisioning `sweep_note` and nothing else makes the engine's single-tenant probe read an absent `sys_organization`, so the expected refusal is withheld and asserted through `expected-read-refusal-noise.ts` (#10629) rather than muted. The probe is memoised behind the first data operation, so the three context-shape tests that never touch the store declare that with `harness({ touchesStore: false })` — the withholding is unconditional either way, only the must-have-fired set narrows. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Q5WBDtaUnoz5XuJ6jk8pQ5 --- .../src/app-plugin.job-data-reach.test.ts | 89 ++++++++++++++++--- 1 file changed, 77 insertions(+), 12 deletions(-) diff --git a/packages/runtime/src/app-plugin.job-data-reach.test.ts b/packages/runtime/src/app-plugin.job-data-reach.test.ts index 12d1ae115a..8a4e33ff82 100644 --- a/packages/runtime/src/app-plugin.job-data-reach.test.ts +++ b/packages/runtime/src/app-plugin.job-data-reach.test.ts @@ -41,6 +41,18 @@ * boot and the module-scope slot stays empty — silently. A fix proved only on * the TS-config path would be a second escape with the same blind spot, so the * artifact boot is driven through the REAL `loadArtifactBundle` here. + * + * ## The backend, and why it is sqlite `:memory:` + * + * These tests need *a* store — nothing here is about any one driver's behaviour. + * #5704 migrated this project's test backends to sqlite `:memory:` and ruled + * that only the two files in `scripts/driver-memory-census.ledger.json` keep + * `@objectstack/driver-memory`, each being one arm of a cross-family pin that + * genuinely cannot run on SQL. This file is neither, so it boots on the migrated + * backend. ⛔ Do not "simplify" it back onto the in-memory driver: that is an + * unledgered arrival and `pnpm check:driver-memory-census` refuses it (#6664). + * The card's own reproduction used the in-memory driver because that is what the + * reporter had in hand — a manual probe, never a constraint on this rig. */ import { describe, it, expect, vi, afterEach } from 'vitest'; @@ -51,11 +63,15 @@ import type { PluginContext } from '@objectstack/core'; import type { JobHandler } from '@objectstack/spec/contracts'; import { defineJob } from '@objectstack/spec/system'; import { ObjectQL } from '@objectstack/objectql'; -import { InMemoryDriver } from '@objectstack/driver-memory'; +import { SqlDriver } from '@objectstack/driver-sql'; import { CronJobAdapter } from '@objectstack/service-job'; import { AppPlugin } from './app-plugin.js'; import { loadArtifactBundle } from './load-artifact-bundle.js'; import type { JobHandlerContext } from './job-handler-context.js'; +import { + captureExpectedReadRefusals, + type ExpectedReadRefusalCapture, +} from './expected-read-refusal-noise.js'; /** The record a scheduled sweep is supposed to be able to write. */ const NOTE = { @@ -85,30 +101,78 @@ interface Harness { warnLogs: () => string[]; } -const live: Array<{ engine?: ObjectQL; adapter?: CronJobAdapter; dir?: string }> = []; +const live: Array<{ + engine?: ObjectQL; + adapter?: CronJobAdapter; + driver?: SqlDriver; + dir?: string; + noise?: ExpectedReadRefusalCapture; + /** The channels this test's path MUST have provoked — see `harness()`. */ + requiredChannels?: readonly string[]; +}> = []; + +/** + * [#10629] This fixture provisions `sweep_note` and nothing else, so the + * engine's own single-tenant probe (`ObjectQL.probeInstallOrganizations`) reads + * a `sys_organization` that was never created. The probe is fail-soft by + * construction, but the driver and the engine each log the fault on the way out. + * Withheld and ASSERTED rather than muted — see `expected-read-refusal-noise.ts`. + */ +const ABSENT_TENANCY_TABLE = 'sys_organization'; afterEach(async () => { for (const entry of live.splice(0)) { try { await entry.adapter?.destroy(); } catch { /* noop */ } try { await entry.engine?.destroy(); } catch { /* noop */ } + try { await entry.driver?.disconnect(); } catch { /* noop */ } if (entry.dir) rmSync(entry.dir, { recursive: true, force: true }); + // A capture nobody asserts is a mute. The probe is memoised behind the + // FIRST data operation, so only the paths that actually touch the store + // provoke it — `silentChannels(required)` is the API's own answer to a + // table read on some of a file's paths and not others. The withholding + // is unconditional either way; only the must-have-fired set narrows. + if (entry.noise) { + expect(entry.noise.silentChannels(entry.requiredChannels ?? [ABSENT_TENANCY_TABLE])).toEqual([]); + } } }); -/** A real engine over the in-memory driver, with `sweep_note` registered. */ -async function bootEngine(): Promise { - const driver = new InMemoryDriver(); +/** + * A real engine over the MIGRATED test backend — sqlite `:memory:` (#5704) — + * with `sweep_note` provisioned and registered. + */ +async function bootEngine(): Promise<{ engine: ObjectQL; driver: SqlDriver; noise: ExpectedReadRefusalCapture }> { + const driver = new SqlDriver({ + client: 'better-sqlite3', + connection: { filename: ':memory:' }, + useNullAsDefault: true, + }); + // Installed before the driver runs a statement and before the engine issues + // a read — the two sinks the expected refusal travels out on. + const noise = captureExpectedReadRefusals([ABSENT_TENANCY_TABLE]); + noise.captureDriver(driver); + await driver.initObjects([NOTE as never]); const engine = new ObjectQL(); + noise.captureEngine(engine); engine.registerDriver(driver as never, true); await engine.init(); engine.registry.registerObject(NOTE as never); - return engine; + return { engine, driver, noise }; } -async function harness(): Promise { - const engine = await bootEngine(); +/** + * @param opts.touchesStore whether this test's path performs a data operation. + * `true` (the default) requires the tenancy probe to have fired and been + * withheld; a context-shape test that never reads or writes passes `false`, + * which keeps the withholding and drops only the must-have-fired requirement. + */ +async function harness(opts: { touchesStore?: boolean } = {}): Promise { + const { engine, driver, noise } = await bootEngine(); const adapter = new CronJobAdapter(); - live.push({ engine, adapter }); + live.push({ + engine, adapter, driver, noise, + requiredChannels: opts.touchesStore === false ? [] : [ABSENT_TENANCY_TABLE], + }); const readyHooks: Array<() => Promise> = []; const ctx = { @@ -181,7 +245,8 @@ describe('#14094 — a declarative job handler has data reach (TS-config path)', }); it('the context is the pre-#14094 set PLUS exactly `ql` and `logger`', async () => { - const h = await harness(); + // Reads nothing and writes nothing — this one is about the shape. + const h = await harness({ touchesStore: false }); const seen: Array> = []; const plugin = new AppPlugin({ @@ -209,7 +274,7 @@ describe('#14094 — a declarative job handler has data reach (TS-config path)', }); it('`data` from a manual trigger still reaches the handler beside the new members', async () => { - const h = await harness(); + const h = await harness({ touchesStore: false }); const seen: Array> = []; const plugin = new AppPlugin({ id: 'com.test.job-reach', @@ -318,7 +383,7 @@ export const meta = { builtAt: '2026-09-01T00:00:00.000Z' }; describe('#14094 — additivity (Zone 1.1)', () => { it('a handler written against the PRE-#14094 context runs unchanged, byte for byte', async () => { - const h = await harness(); + const h = await harness({ touchesStore: false }); const calls: Array<{ jobId: string; data?: unknown }> = []; // Verbatim the shape `IJobService`'s `JobHandler` declares — the type an