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
42 changes: 42 additions & 0 deletions .changeset/declarative-job-run-outcome.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
---
'@objectstack/runtime': patch
---

fix(runtime): a declarative job's `JobRunOutcome` reaches the adapter that records it (#14256)

`AppPlugin`'s declarative-job registration block handed `IJobService.schedule` a
block-bodied arrow that *awaited* the bundle handler and returned nothing, so
the wrapper was a `Promise<void>` whatever the handler resolved. Measured
through an `IJobService` typed only at the contract:

```
HANDLER RESOLVED: {"outcome":"degraded","reason":"STORE_UNAVAILABLE"}
WRAPPER RESOLVED: undefined
```

#6617's third outcome was therefore unreachable from `defineJob`. All three
shipped adapters (`cron-job-adapter`, `interval-job-adapter`, `db-job-adapter`)
map a resolved `{ outcome: 'degraded', reason }` onto a run status distinct from
`success` — they simply never got the value on this path, so a declarative job
that ran to completion while its work did not happen (store unavailable, zero
rows matched) was recorded as `success` with `reason` dropped. The three-outcome
table in `content/docs/automation/jobs.mdx` — the page whose whole subject is
the declarative door — was false on exactly that door. The imperative route (a
handler registered straight on `IJobService.schedule`) was unaffected
throughout; the wrapper is the whole defect.

The repair is to return the handler's resolved value. Patch rather than minor:
no export, type, schema or authoring surface changes, and `JobHandler` has
declared `Promise<void | JobRunOutcome>` since #6617 — this makes the declared
contract true on a route where it was not. Additive in the ruling's sense: a
handler resolving `undefined` (every handler written before #6617) still
resolves `undefined` through the wrapper and still takes the `success` branch.
The one visible change for an existing app is the correction itself — a
declarative handler that *was already* resolving `{ outcome: 'degraded' }` now
lands `sys_job_run.status: 'degraded'` (reason in `error`, `failure_count` flat,
still never retried) where it previously landed `success`.

Pinned by `packages/runtime/src/app-plugin.job-degraded-outcome.test.ts`, which
drives the real `DbJobAdapter` over a real ObjectQL engine carrying the real
`sys_job` / `sys_job_run` declarations and asserts the persisted cell, not the
wrapper's return value.
305 changes: 305 additions & 0 deletions packages/runtime/src/app-plugin.job-degraded-outcome.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,305 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* #14256 — a DECLARATIVE job's `JobRunOutcome` must reach the adapter that
* records it.
*
* ## What was measured before the fix
*
* `AppPlugin`'s declarative-job registration block handed `IJobService.schedule`
* a block-bodied arrow that *awaited* the bundle handler and returned nothing,
* so the wrapper was a `Promise<void>` whatever the handler resolved. The
* reporter's probe, driven through an `IJobService` typed only at the contract:
*
* ```
* HANDLER RESOLVED: {"outcome":"degraded","reason":"STORE_UNAVAILABLE"}
* WRAPPER RESOLVED: undefined
* ```
*
* #6617's third outcome was therefore unreachable from `defineJob`: a job that
* ran to completion while its work did not happen (store unavailable, zero rows
* matched) was recorded as `success`, with `reason` dropped. The imperative
* route — a handler registered straight on `IJobService.schedule` — was
* unaffected the whole time, which is exactly what localises the defect to this
* wrapper.
*
* ## What these cases assert, and why it is not the wrapper's return value
*
* The deliverable named on the card is **the value that lands in the row**:
* `sys_job_run.status` distinct from `success`, driven through `DbJobAdapter`,
* the adapter that records it. A case asserting only that the wrapper returns
* what the handler returned re-states the one-expression repair; the
* consequence — what gets RECORDED — is what
* `content/docs/automation/jobs.mdx`'s three-outcome table promises a
* declarative author and what the declarative door did not deliver. So the
* primary assertions read the persisted cell, and the wrapper's own resolved
* value is pinned only as the corroborating middle term.
*
* ## The rig: a real engine, real `sys_job*` declarations, the real adapter
*
* `DbJobAdapter` writes `sys_job` / `sys_job_run` through ObjectQL, and
* `sys_job_run.status` is an *enforced* `Field.select` whose vocabulary carries
* `degraded` (#7072). Driving the real engine over the migrated sqlite
* `:memory:` backend (#5704) therefore proves two things a recording double
* cannot: the status the adapter writes is a value the record validator
* accepts, and it is the value a reader of the row gets back. Nothing here is
* about any one driver's behaviour, and this file is not on
* `scripts/driver-memory-census.ledger.json` — ⛔ do not "simplify" it onto
* `@objectstack/driver-memory` (#6664).
*/

import { describe, it, expect, vi, afterEach } from 'vitest';
import type { PluginContext } from '@objectstack/core';
import type { IJobService, JobHandler, JobRunOutcome, JobSchedule } from '@objectstack/spec/contracts';
import { defineJob } from '@objectstack/spec/system';
import { ObjectQL } from '@objectstack/objectql';
import { SqlDriver } from '@objectstack/driver-sql';
import { DbJobAdapter } from '@objectstack/service-job';
import { SysJob, SysJobRun } from '@objectstack/platform-objects/audit';
import { AppPlugin } from './app-plugin.js';
import type { JobHandlerContext } from './job-handler-context.js';

/** The reason a #5529-shaped handler reports when its store is unreachable. */
const REASON = 'STORE_UNAVAILABLE';

interface Harness {
engine: ObjectQL;
adapter: DbJobAdapter;
ctx: PluginContext;
fireReady: () => Promise<void>;
errorLogs: () => string[];
warnLogs: () => string[];
runRows: () => Promise<Array<Record<string, unknown>>>;
jobRow: (name: string) => Promise<Record<string, unknown> | undefined>;
}

const live: Array<{
engine?: ObjectQL;
adapter?: DbJobAdapter;
driver?: SqlDriver;
}> = [];

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 */ }
}
});

/**
* A real engine over the migrated test backend, carrying the REAL `sys_job*`.
*
* ⚠️ [#10629] No expected-read-refusal capture here, deliberately and by
* MEASUREMENT: every read this file performs carries `isSystem`, so the
* engine's single-tenant probe over the unprovisioned `sys_organization` never
* fires and neither refusal channel emits a frame (checked on the red run: zero
* `refused a read on` and zero `Find operation failed` lines for the whole
* file). Installing a capture that nothing provokes would assert a mute.
*/
async function bootEngine(): Promise<{ engine: ObjectQL; driver: SqlDriver }> {
const driver = new SqlDriver({
client: 'better-sqlite3',
connection: { filename: ':memory:' },
useNullAsDefault: true,
});
await driver.initObjects([SysJob as never, SysJobRun as never]);
const engine = new ObjectQL();
engine.registerDriver(driver as never, true);
await engine.init();
engine.registry.registerObject(SysJob as never);
engine.registry.registerObject(SysJobRun as never);
return { engine, driver };
}

async function harness(): Promise<Harness> {
const { engine, driver } = await bootEngine();
// The adapter that RECORDS the outcome — the coordinate the card names.
const adapter = new DbJobAdapter({ engine: engine as never });
live.push({ engine, adapter, driver });

const readyHooks: Array<() => Promise<void>> = [];
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<void>) => {
if (event === 'kernel:ready') readyHooks.push(cb);
}),
trigger: vi.fn(),
} as unknown as PluginContext;

const rows = async (table: string, where?: Record<string, unknown>) => {
const found = await engine.find(table, {
...(where ? { where } : {}),
context: { isSystem: true, positions: [], permissions: [] },
} as never);
return Array.isArray(found) ? found as Array<Record<string, unknown>> : [];
};

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])),
runRows: () => rows('sys_job_run'),
jobRow: async (name: string) => (await rows('sys_job', { name }))[0],
};
}

/**
* The #5529 specimen as a DECLARATIVE job handler: it fires its shot at an
* unreachable store, completes normally — a throw is the retry signal, which is
* the wrong report — and says so by resolving the third outcome.
*/
async function degradingHandler(_jobCtx: JobHandlerContext): Promise<JobRunOutcome> {
return { outcome: 'degraded', reason: REASON };
}

/** A pre-#6617 handler: resolves nothing, means `success`. */
async function silentHandler(_jobCtx: JobHandlerContext): Promise<void> {
/* did its work, reports nothing */
}

function stackWith(jobName: string, handlerKey: string, fn: unknown) {
return {
id: 'com.test.job-degraded-outcome',
jobs: [defineJob({
name: jobName,
schedule: { type: 'cron', expression: '0 1 * * *' },
handler: handlerKey,
})],
functions: { [handlerKey]: fn },
};
}

describe('#14256 — a declarative job\'s degraded outcome reaches `sys_job_run`', () => {
it('lands `sys_job_run.status` distinct from `success`, with the reason in `error`', async () => {
const h = await harness();
const plugin = new AppPlugin(stackWith('wake_sweep', 'wake', degradingHandler));

await plugin.start!(h.ctx);
await h.fireReady();
expect(await h.adapter.listJobs()).toContain('wake_sweep');

// Run it the way the scheduler runs it — through the adapter, never by
// calling the handler directly.
await h.adapter.trigger('wake_sweep');

const runs = await h.runRows();
expect(runs).toHaveLength(1);
// The card's assertion, in its own words: a status DISTINCT from
// `success`. Spelled as the inequality first, because that is the
// contract (`spec/contracts/job-service.ts`), then as the value the
// shipped adapters agree on.
expect(runs[0].status).not.toBe('success');
expect(runs[0].status).toBe('degraded');
expect(runs[0].error).toBe(REASON);
expect(runs[0].job_name).toBe('wake_sweep');
expect(h.errorLogs()).toEqual([]);
});

it('mirrors onto `sys_job.last_status` and leaves `failure_count` flat — degraded is not a failure', async () => {
const h = await harness();
const plugin = new AppPlugin(stackWith('wake_sweep', 'wake', degradingHandler));
await plugin.start!(h.ctx);
await h.fireReady();
await h.adapter.trigger('wake_sweep');

const job = await h.jobRow('wake_sweep');
expect(job?.last_status).toBe('degraded');
expect(job?.last_error).toBe(REASON);
// `degraded` never retries and never alerts (#5548 / #7072).
expect(job?.failure_count).toBe(0);
expect(job?.run_count).toBe(1);
});

it('carries the outcome for the `{ handler, effect }` function form too', async () => {
// The shape a declared-effect entry takes (#4396) — the same route, one
// more indirection through `collectBundleFunctions`.
const h = await harness();
const plugin = new AppPlugin(
stackWith('wake_sweep', 'wake', { handler: degradingHandler, effect: 'writes' }),
);
await plugin.start!(h.ctx);
await h.fireReady();
await h.adapter.trigger('wake_sweep');

const runs = await h.runRows();
expect(runs[0].status).toBe('degraded');
expect(runs[0].error).toBe(REASON);
});
});

describe('#14256 — the controls that keep the assertion honest', () => {
it('CONTROL: the IMPERATIVE route already recorded it — the defect was the wrapper, not the adapter', async () => {
// Registered straight on `IJobService.schedule`, with no AppPlugin in
// the path. This case passed before the fix and passes after it, so a
// red on the declarative cases above localises to the wrapper rather
// than to `DbJobAdapter` or the `sys_job_run` write.
const h = await harness();
await h.adapter.schedule(
'imperative_wake',
{ type: 'cron', expression: '0 1 * * *' },
async (): Promise<JobRunOutcome> => ({ outcome: 'degraded', reason: REASON }),
);
await h.adapter.trigger('imperative_wake');

const runs = await h.runRows();
expect(runs[0].status).toBe('degraded');
expect(runs[0].error).toBe(REASON);
});

it('CONTROL: a job whose handler reports nothing still lands `success` — the additivity clause', async () => {
// The compatibility promise #6617 owes, on the exact shape every handler
// written before it has. It also proves the degraded assertion above is
// not passing because the rig writes `degraded` unconditionally.
const h = await harness();
const plugin = new AppPlugin(stackWith('quiet_sweep', 'quiet', silentHandler));
await plugin.start!(h.ctx);
await h.fireReady();
await h.adapter.trigger('quiet_sweep');

const runs = await h.runRows();
expect(runs).toHaveLength(1);
expect(runs[0].status).toBe('success');
expect(runs[0].error).toBeNull();
expect((await h.jobRow('quiet_sweep'))?.last_status).toBe('success');
});

it('the middle term: the wrapper AppPlugin hands `schedule` resolves the handler\'s outcome', async () => {
// The reporter's probe, kept as corroboration and NOT as the deliverable:
// it re-states the repair, while the cases above pin its consequence.
// Typed only at the contract, exactly as a third-party `IJobService` is.
const h = await harness();
const scheduled: Array<{ name: string; handler: JobHandler }> = [];
const recording: IJobService = {
async schedule(name: string, _schedule: JobSchedule, handler: JobHandler) {
scheduled.push({ name, handler });
},
async cancel() { /* noop */ },
async trigger() { /* noop */ },
};
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;
});

const plugin = new AppPlugin(stackWith('probe_wake', 'wake', degradingHandler));
await plugin.start!(h.ctx);
await h.fireReady();

expect(scheduled.map(s => s.name)).toEqual(['probe_wake']);
// `WRAPPER RESOLVED: undefined` was the measurement on the card.
const resolved = await scheduled[0].handler({ jobId: 'probe_wake' });
expect(resolved).toEqual({ outcome: 'degraded', reason: REASON });
});
});
21 changes: 20 additions & 1 deletion packages/runtime/src/app-plugin.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -1033,7 +1033,26 @@ export class AppPlugin implements Plugin {
ql,
logger: ctx.logger,
};
await handler(jobContext);
// #14256: RETURN the handler's resolved
// value. `JobHandler` is
// `(context) => Promise<void | JobRunOutcome>`
// and all three shipped adapters map a
// resolved `{ outcome: 'degraded', reason }`
// onto a `sys_job_run.status` distinct from
// `success` (#6617/#5548). A block-bodied
// arrow that only awaited made this wrapper
// a `Promise<void>`, so the third outcome
// was unreachable from `defineJob`: a job
// that ran to completion while its work did
// not happen was recorded as `success` with
// `reason` dropped, and the three-outcome
// table in `content/docs/automation/jobs.mdx`
// was false on the declarative door.
// A handler that resolves `undefined` — every
// handler written before #6617 — still returns
// `undefined` here, which is the `success`
// branch exactly as before.
return await handler(jobContext);
},
// #3494: thread the authored retryPolicy/timeout to the adapter
(job.retryPolicy || job.timeout)
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
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
42 changes: 42 additions & 0 deletions .changeset/declarative-job-run-outcome.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
---
'@objectstack/runtime': patch
---

fix(runtime): a declarative job's `JobRunOutcome` reaches the adapter that records it (#14256)

`AppPlugin`'s declarative-job registration block handed `IJobService.schedule` a
block-bodied arrow that *awaited* the bundle handler and returned nothing, so
the wrapper was a `Promise<void>` whatever the handler resolved. Measured
through an `IJobService` typed only at the contract:

```
HANDLER RESOLVED: {"outcome":"degraded","reason":"STORE_UNAVAILABLE"}
WRAPPER RESOLVED: undefined
```

#6617's third outcome was therefore unreachable from `defineJob`. All three
shipped adapters (`cron-job-adapter`, `interval-job-adapter`, `db-job-adapter`)
map a resolved `{ outcome: 'degraded', reason }` onto a run status distinct from
`success` — they simply never got the value on this path, so a declarative job
that ran to completion while its work did not happen (store unavailable, zero
rows matched) was recorded as `success` with `reason` dropped. The three-outcome
table in `content/docs/automation/jobs.mdx` — the page whose whole subject is
the declarative door — was false on exactly that door. The imperative route (a
handler registered straight on `IJobService.schedule`) was unaffected
throughout; the wrapper is the whole defect.

The repair is to return the handler's resolved value. Patch rather than minor:
no export, type, schema or authoring surface changes, and `JobHandler` has
declared `Promise<void | JobRunOutcome>` since #6617 — this makes the declared
contract true on a route where it was not. Additive in the ruling's sense: a
handler resolving `undefined` (every handler written before #6617) still
resolves `undefined` through the wrapper and still takes the `success` branch.
The one visible change for an existing app is the correction itself — a
declarative handler that *was already* resolving `{ outcome: 'degraded' }` now
lands `sys_job_run.status: 'degraded'` (reason in `error`, `failure_count` flat,
still never retried) where it previously landed `success`.

Pinned by `packages/runtime/src/app-plugin.job-degraded-outcome.test.ts`, which
drives the real `DbJobAdapter` over a real ObjectQL engine carrying the real
`sys_job` / `sys_job_run` declarations and asserts the persisted cell, not the
wrapper's return value.
305 changes: 305 additions & 0 deletions packages/runtime/src/app-plugin.job-degraded-outcome.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,305 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* #14256 — a DECLARATIVE job's `JobRunOutcome` must reach the adapter that
* records it.
*
* ## What was measured before the fix
*
* `AppPlugin`'s declarative-job registration block handed `IJobService.schedule`
* a block-bodied arrow that *awaited* the bundle handler and returned nothing,
* so the wrapper was a `Promise<void>` whatever the handler resolved. The
* reporter's probe, driven through an `IJobService` typed only at the contract:
*
* ```
* HANDLER RESOLVED: {"outcome":"degraded","reason":"STORE_UNAVAILABLE"}
* WRAPPER RESOLVED: undefined
* ```
*
* #6617's third outcome was therefore unreachable from `defineJob`: a job that
* ran to completion while its work did not happen (store unavailable, zero rows
* matched) was recorded as `success`, with `reason` dropped. The imperative
* route — a handler registered straight on `IJobService.schedule` — was
* unaffected the whole time, which is exactly what localises the defect to this
* wrapper.
*
* ## What these cases assert, and why it is not the wrapper's return value
*
* The deliverable named on the card is **the value that lands in the row**:
* `sys_job_run.status` distinct from `success`, driven through `DbJobAdapter`,
* the adapter that records it. A case asserting only that the wrapper returns
* what the handler returned re-states the one-expression repair; the
* consequence — what gets RECORDED — is what
* `content/docs/automation/jobs.mdx`'s three-outcome table promises a
* declarative author and what the declarative door did not deliver. So the
* primary assertions read the persisted cell, and the wrapper's own resolved
* value is pinned only as the corroborating middle term.
*
* ## The rig: a real engine, real `sys_job*` declarations, the real adapter
*
* `DbJobAdapter` writes `sys_job` / `sys_job_run` through ObjectQL, and
* `sys_job_run.status` is an *enforced* `Field.select` whose vocabulary carries
* `degraded` (#7072). Driving the real engine over the migrated sqlite
* `:memory:` backend (#5704) therefore proves two things a recording double
* cannot: the status the adapter writes is a value the record validator
* accepts, and it is the value a reader of the row gets back. Nothing here is
* about any one driver's behaviour, and this file is not on
* `scripts/driver-memory-census.ledger.json` — ⛔ do not "simplify" it onto
* `@objectstack/driver-memory` (#6664).
*/

import { describe, it, expect, vi, afterEach } from 'vitest';
import type { PluginContext } from '@objectstack/core';
import type { IJobService, JobHandler, JobRunOutcome, JobSchedule } from '@objectstack/spec/contracts';
import { defineJob } from '@objectstack/spec/system';
import { ObjectQL } from '@objectstack/objectql';
import { SqlDriver } from '@objectstack/driver-sql';
import { DbJobAdapter } from '@objectstack/service-job';
import { SysJob, SysJobRun } from '@objectstack/platform-objects/audit';
import { AppPlugin } from './app-plugin.js';
import type { JobHandlerContext } from './job-handler-context.js';

/** The reason a #5529-shaped handler reports when its store is unreachable. */
const REASON = 'STORE_UNAVAILABLE';

interface Harness {
engine: ObjectQL;
adapter: DbJobAdapter;
ctx: PluginContext;
fireReady: () => Promise<void>;
errorLogs: () => string[];
warnLogs: () => string[];
runRows: () => Promise<Array<Record<string, unknown>>>;
jobRow: (name: string) => Promise<Record<string, unknown> | undefined>;
}

const live: Array<{
engine?: ObjectQL;
adapter?: DbJobAdapter;
driver?: SqlDriver;
}> = [];

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 */ }
}
});

/**
* A real engine over the migrated test backend, carrying the REAL `sys_job*`.
*
* ⚠️ [#10629] No expected-read-refusal capture here, deliberately and by
* MEASUREMENT: every read this file performs carries `isSystem`, so the
* engine's single-tenant probe over the unprovisioned `sys_organization` never
* fires and neither refusal channel emits a frame (checked on the red run: zero
* `refused a read on` and zero `Find operation failed` lines for the whole
* file). Installing a capture that nothing provokes would assert a mute.
*/
async function bootEngine(): Promise<{ engine: ObjectQL; driver: SqlDriver }> {
const driver = new SqlDriver({
client: 'better-sqlite3',
connection: { filename: ':memory:' },
useNullAsDefault: true,
});
await driver.initObjects([SysJob as never, SysJobRun as never]);
const engine = new ObjectQL();
engine.registerDriver(driver as never, true);
await engine.init();
engine.registry.registerObject(SysJob as never);
engine.registry.registerObject(SysJobRun as never);
return { engine, driver };
}

async function harness(): Promise<Harness> {
const { engine, driver } = await bootEngine();
// The adapter that RECORDS the outcome — the coordinate the card names.
const adapter = new DbJobAdapter({ engine: engine as never });
live.push({ engine, adapter, driver });

const readyHooks: Array<() => Promise<void>> = [];
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<void>) => {
if (event === 'kernel:ready') readyHooks.push(cb);
}),
trigger: vi.fn(),
} as unknown as PluginContext;

const rows = async (table: string, where?: Record<string, unknown>) => {
const found = await engine.find(table, {
...(where ? { where } : {}),
context: { isSystem: true, positions: [], permissions: [] },
} as never);
return Array.isArray(found) ? found as Array<Record<string, unknown>> : [];
};

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])),
runRows: () => rows('sys_job_run'),
jobRow: async (name: string) => (await rows('sys_job', { name }))[0],
};
}

/**
* The #5529 specimen as a DECLARATIVE job handler: it fires its shot at an
* unreachable store, completes normally — a throw is the retry signal, which is
* the wrong report — and says so by resolving the third outcome.
*/
async function degradingHandler(_jobCtx: JobHandlerContext): Promise<JobRunOutcome> {
return { outcome: 'degraded', reason: REASON };
}

/** A pre-#6617 handler: resolves nothing, means `success`. */
async function silentHandler(_jobCtx: JobHandlerContext): Promise<void> {
/* did its work, reports nothing */
}

function stackWith(jobName: string, handlerKey: string, fn: unknown) {
return {
id: 'com.test.job-degraded-outcome',
jobs: [defineJob({
name: jobName,
schedule: { type: 'cron', expression: '0 1 * * *' },
handler: handlerKey,
})],
functions: { [handlerKey]: fn },
};
}

describe('#14256 — a declarative job\'s degraded outcome reaches `sys_job_run`', () => {
it('lands `sys_job_run.status` distinct from `success`, with the reason in `error`', async () => {
const h = await harness();
const plugin = new AppPlugin(stackWith('wake_sweep', 'wake', degradingHandler));

await plugin.start!(h.ctx);
await h.fireReady();
expect(await h.adapter.listJobs()).toContain('wake_sweep');

// Run it the way the scheduler runs it — through the adapter, never by
// calling the handler directly.
await h.adapter.trigger('wake_sweep');

const runs = await h.runRows();
expect(runs).toHaveLength(1);
// The card's assertion, in its own words: a status DISTINCT from
// `success`. Spelled as the inequality first, because that is the
// contract (`spec/contracts/job-service.ts`), then as the value the
// shipped adapters agree on.
expect(runs[0].status).not.toBe('success');
expect(runs[0].status).toBe('degraded');
expect(runs[0].error).toBe(REASON);
expect(runs[0].job_name).toBe('wake_sweep');
expect(h.errorLogs()).toEqual([]);
});

it('mirrors onto `sys_job.last_status` and leaves `failure_count` flat — degraded is not a failure', async () => {
const h = await harness();
const plugin = new AppPlugin(stackWith('wake_sweep', 'wake', degradingHandler));
await plugin.start!(h.ctx);
await h.fireReady();
await h.adapter.trigger('wake_sweep');

const job = await h.jobRow('wake_sweep');
expect(job?.last_status).toBe('degraded');
expect(job?.last_error).toBe(REASON);
// `degraded` never retries and never alerts (#5548 / #7072).
expect(job?.failure_count).toBe(0);
expect(job?.run_count).toBe(1);
});

it('carries the outcome for the `{ handler, effect }` function form too', async () => {
// The shape a declared-effect entry takes (#4396) — the same route, one
// more indirection through `collectBundleFunctions`.
const h = await harness();
const plugin = new AppPlugin(
stackWith('wake_sweep', 'wake', { handler: degradingHandler, effect: 'writes' }),
);
await plugin.start!(h.ctx);
await h.fireReady();
await h.adapter.trigger('wake_sweep');

const runs = await h.runRows();
expect(runs[0].status).toBe('degraded');
expect(runs[0].error).toBe(REASON);
});
});

describe('#14256 — the controls that keep the assertion honest', () => {
it('CONTROL: the IMPERATIVE route already recorded it — the defect was the wrapper, not the adapter', async () => {
// Registered straight on `IJobService.schedule`, with no AppPlugin in
// the path. This case passed before the fix and passes after it, so a
// red on the declarative cases above localises to the wrapper rather
// than to `DbJobAdapter` or the `sys_job_run` write.
const h = await harness();
await h.adapter.schedule(
'imperative_wake',
{ type: 'cron', expression: '0 1 * * *' },
async (): Promise<JobRunOutcome> => ({ outcome: 'degraded', reason: REASON }),
);
await h.adapter.trigger('imperative_wake');

const runs = await h.runRows();
expect(runs[0].status).toBe('degraded');
expect(runs[0].error).toBe(REASON);
});

it('CONTROL: a job whose handler reports nothing still lands `success` — the additivity clause', async () => {
// The compatibility promise #6617 owes, on the exact shape every handler
// written before it has. It also proves the degraded assertion above is
// not passing because the rig writes `degraded` unconditionally.
const h = await harness();
const plugin = new AppPlugin(stackWith('quiet_sweep', 'quiet', silentHandler));
await plugin.start!(h.ctx);
await h.fireReady();
await h.adapter.trigger('quiet_sweep');

const runs = await h.runRows();
expect(runs).toHaveLength(1);
expect(runs[0].status).toBe('success');
expect(runs[0].error).toBeNull();
expect((await h.jobRow('quiet_sweep'))?.last_status).toBe('success');
});

it('the middle term: the wrapper AppPlugin hands `schedule` resolves the handler\'s outcome', async () => {
// The reporter's probe, kept as corroboration and NOT as the deliverable:
// it re-states the repair, while the cases above pin its consequence.
// Typed only at the contract, exactly as a third-party `IJobService` is.
const h = await harness();
const scheduled: Array<{ name: string; handler: JobHandler }> = [];
const recording: IJobService = {
async schedule(name: string, _schedule: JobSchedule, handler: JobHandler) {
scheduled.push({ name, handler });
},
async cancel() { /* noop */ },
async trigger() { /* noop */ },
};
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;
});

const plugin = new AppPlugin(stackWith('probe_wake', 'wake', degradingHandler));
await plugin.start!(h.ctx);
await h.fireReady();

expect(scheduled.map(s => s.name)).toEqual(['probe_wake']);
// `WRAPPER RESOLVED: undefined` was the measurement on the card.
const resolved = await scheduled[0].handler({ jobId: 'probe_wake' });
expect(resolved).toEqual({ outcome: 'degraded', reason: REASON });
});
});
21 changes: 20 additions & 1 deletion packages/runtime/src/app-plugin.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -1033,7 +1033,26 @@ export class AppPlugin implements Plugin {
ql,
logger: ctx.logger,
};
await handler(jobContext);
// #14256: RETURN the handler's resolved
// value. `JobHandler` is
// `(context) => Promise<void | JobRunOutcome>`
// and all three shipped adapters map a
// resolved `{ outcome: 'degraded', reason }`
// onto a `sys_job_run.status` distinct from
// `success` (#6617/#5548). A block-bodied
// arrow that only awaited made this wrapper
// a `Promise<void>`, so the third outcome
// was unreachable from `defineJob`: a job
// that ran to completion while its work did
// not happen was recorded as `success` with
// `reason` dropped, and the three-outcome
// table in `content/docs/automation/jobs.mdx`
// was false on the declarative door.
// A handler that resolves `undefined` — every
// handler written before #6617 — still returns
// `undefined` here, which is the `success`
// branch exactly as before.
return await handler(jobContext);
},
// #3494: thread the authored retryPolicy/timeout to the adapter
(job.retryPolicy || job.timeout)
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
42 changes: 42 additions & 0 deletions .changeset/declarative-job-run-outcome.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
---
'@objectstack/runtime': patch
---

fix(runtime): a declarative job's `JobRunOutcome` reaches the adapter that records it (#14256)

`AppPlugin`'s declarative-job registration block handed `IJobService.schedule` a
block-bodied arrow that *awaited* the bundle handler and returned nothing, so
the wrapper was a `Promise<void>` whatever the handler resolved. Measured
through an `IJobService` typed only at the contract:

```
HANDLER RESOLVED: {"outcome":"degraded","reason":"STORE_UNAVAILABLE"}
WRAPPER RESOLVED: undefined
```

#6617's third outcome was therefore unreachable from `defineJob`. All three
shipped adapters (`cron-job-adapter`, `interval-job-adapter`, `db-job-adapter`)
map a resolved `{ outcome: 'degraded', reason }` onto a run status distinct from
`success` — they simply never got the value on this path, so a declarative job
that ran to completion while its work did not happen (store unavailable, zero
rows matched) was recorded as `success` with `reason` dropped. The three-outcome
table in `content/docs/automation/jobs.mdx` — the page whose whole subject is
the declarative door — was false on exactly that door. The imperative route (a
handler registered straight on `IJobService.schedule`) was unaffected
throughout; the wrapper is the whole defect.

The repair is to return the handler's resolved value. Patch rather than minor:
no export, type, schema or authoring surface changes, and `JobHandler` has
declared `Promise<void | JobRunOutcome>` since #6617 — this makes the declared
contract true on a route where it was not. Additive in the ruling's sense: a
handler resolving `undefined` (every handler written before #6617) still
resolves `undefined` through the wrapper and still takes the `success` branch.
The one visible change for an existing app is the correction itself — a
declarative handler that *was already* resolving `{ outcome: 'degraded' }` now
lands `sys_job_run.status: 'degraded'` (reason in `error`, `failure_count` flat,
still never retried) where it previously landed `success`.

Pinned by `packages/runtime/src/app-plugin.job-degraded-outcome.test.ts`, which
drives the real `DbJobAdapter` over a real ObjectQL engine carrying the real
`sys_job` / `sys_job_run` declarations and asserts the persisted cell, not the
wrapper's return value.
305 changes: 305 additions & 0 deletions packages/runtime/src/app-plugin.job-degraded-outcome.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,305 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* #14256 — a DECLARATIVE job's `JobRunOutcome` must reach the adapter that
* records it.
*
* ## What was measured before the fix
*
* `AppPlugin`'s declarative-job registration block handed `IJobService.schedule`
* a block-bodied arrow that *awaited* the bundle handler and returned nothing,
* so the wrapper was a `Promise<void>` whatever the handler resolved. The
* reporter's probe, driven through an `IJobService` typed only at the contract:
*
* ```
* HANDLER RESOLVED: {"outcome":"degraded","reason":"STORE_UNAVAILABLE"}
* WRAPPER RESOLVED: undefined
* ```
*
* #6617's third outcome was therefore unreachable from `defineJob`: a job that
* ran to completion while its work did not happen (store unavailable, zero rows
* matched) was recorded as `success`, with `reason` dropped. The imperative
* route — a handler registered straight on `IJobService.schedule` — was
* unaffected the whole time, which is exactly what localises the defect to this
* wrapper.
*
* ## What these cases assert, and why it is not the wrapper's return value
*
* The deliverable named on the card is **the value that lands in the row**:
* `sys_job_run.status` distinct from `success`, driven through `DbJobAdapter`,
* the adapter that records it. A case asserting only that the wrapper returns
* what the handler returned re-states the one-expression repair; the
* consequence — what gets RECORDED — is what
* `content/docs/automation/jobs.mdx`'s three-outcome table promises a
* declarative author and what the declarative door did not deliver. So the
* primary assertions read the persisted cell, and the wrapper's own resolved
* value is pinned only as the corroborating middle term.
*
* ## The rig: a real engine, real `sys_job*` declarations, the real adapter
*
* `DbJobAdapter` writes `sys_job` / `sys_job_run` through ObjectQL, and
* `sys_job_run.status` is an *enforced* `Field.select` whose vocabulary carries
* `degraded` (#7072). Driving the real engine over the migrated sqlite
* `:memory:` backend (#5704) therefore proves two things a recording double
* cannot: the status the adapter writes is a value the record validator
* accepts, and it is the value a reader of the row gets back. Nothing here is
* about any one driver's behaviour, and this file is not on
* `scripts/driver-memory-census.ledger.json` — ⛔ do not "simplify" it onto
* `@objectstack/driver-memory` (#6664).
*/

import { describe, it, expect, vi, afterEach } from 'vitest';
import type { PluginContext } from '@objectstack/core';
import type { IJobService, JobHandler, JobRunOutcome, JobSchedule } from '@objectstack/spec/contracts';
import { defineJob } from '@objectstack/spec/system';
import { ObjectQL } from '@objectstack/objectql';
import { SqlDriver } from '@objectstack/driver-sql';
import { DbJobAdapter } from '@objectstack/service-job';
import { SysJob, SysJobRun } from '@objectstack/platform-objects/audit';
import { AppPlugin } from './app-plugin.js';
import type { JobHandlerContext } from './job-handler-context.js';

/** The reason a #5529-shaped handler reports when its store is unreachable. */
const REASON = 'STORE_UNAVAILABLE';

interface Harness {
engine: ObjectQL;
adapter: DbJobAdapter;
ctx: PluginContext;
fireReady: () => Promise<void>;
errorLogs: () => string[];
warnLogs: () => string[];
runRows: () => Promise<Array<Record<string, unknown>>>;
jobRow: (name: string) => Promise<Record<string, unknown> | undefined>;
}

const live: Array<{
engine?: ObjectQL;
adapter?: DbJobAdapter;
driver?: SqlDriver;
}> = [];

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 */ }
}
});

/**
* A real engine over the migrated test backend, carrying the REAL `sys_job*`.
*
* ⚠️ [#10629] No expected-read-refusal capture here, deliberately and by
* MEASUREMENT: every read this file performs carries `isSystem`, so the
* engine's single-tenant probe over the unprovisioned `sys_organization` never
* fires and neither refusal channel emits a frame (checked on the red run: zero
* `refused a read on` and zero `Find operation failed` lines for the whole
* file). Installing a capture that nothing provokes would assert a mute.
*/
async function bootEngine(): Promise<{ engine: ObjectQL; driver: SqlDriver }> {
const driver = new SqlDriver({
client: 'better-sqlite3',
connection: { filename: ':memory:' },
useNullAsDefault: true,
});
await driver.initObjects([SysJob as never, SysJobRun as never]);
const engine = new ObjectQL();
engine.registerDriver(driver as never, true);
await engine.init();
engine.registry.registerObject(SysJob as never);
engine.registry.registerObject(SysJobRun as never);
return { engine, driver };
}

async function harness(): Promise<Harness> {
const { engine, driver } = await bootEngine();
// The adapter that RECORDS the outcome — the coordinate the card names.
const adapter = new DbJobAdapter({ engine: engine as never });
live.push({ engine, adapter, driver });

const readyHooks: Array<() => Promise<void>> = [];
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<void>) => {
if (event === 'kernel:ready') readyHooks.push(cb);
}),
trigger: vi.fn(),
} as unknown as PluginContext;

const rows = async (table: string, where?: Record<string, unknown>) => {
const found = await engine.find(table, {
...(where ? { where } : {}),
context: { isSystem: true, positions: [], permissions: [] },
} as never);
return Array.isArray(found) ? found as Array<Record<string, unknown>> : [];
};

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])),
runRows: () => rows('sys_job_run'),
jobRow: async (name: string) => (await rows('sys_job', { name }))[0],
};
}

/**
* The #5529 specimen as a DECLARATIVE job handler: it fires its shot at an
* unreachable store, completes normally — a throw is the retry signal, which is
* the wrong report — and says so by resolving the third outcome.
*/
async function degradingHandler(_jobCtx: JobHandlerContext): Promise<JobRunOutcome> {
return { outcome: 'degraded', reason: REASON };
}

/** A pre-#6617 handler: resolves nothing, means `success`. */
async function silentHandler(_jobCtx: JobHandlerContext): Promise<void> {
/* did its work, reports nothing */
}

function stackWith(jobName: string, handlerKey: string, fn: unknown) {
return {
id: 'com.test.job-degraded-outcome',
jobs: [defineJob({
name: jobName,
schedule: { type: 'cron', expression: '0 1 * * *' },
handler: handlerKey,
})],
functions: { [handlerKey]: fn },
};
}

describe('#14256 — a declarative job\'s degraded outcome reaches `sys_job_run`', () => {
it('lands `sys_job_run.status` distinct from `success`, with the reason in `error`', async () => {
const h = await harness();
const plugin = new AppPlugin(stackWith('wake_sweep', 'wake', degradingHandler));

await plugin.start!(h.ctx);
await h.fireReady();
expect(await h.adapter.listJobs()).toContain('wake_sweep');

// Run it the way the scheduler runs it — through the adapter, never by
// calling the handler directly.
await h.adapter.trigger('wake_sweep');

const runs = await h.runRows();
expect(runs).toHaveLength(1);
// The card's assertion, in its own words: a status DISTINCT from
// `success`. Spelled as the inequality first, because that is the
// contract (`spec/contracts/job-service.ts`), then as the value the
// shipped adapters agree on.
expect(runs[0].status).not.toBe('success');
expect(runs[0].status).toBe('degraded');
expect(runs[0].error).toBe(REASON);
expect(runs[0].job_name).toBe('wake_sweep');
expect(h.errorLogs()).toEqual([]);
});

it('mirrors onto `sys_job.last_status` and leaves `failure_count` flat — degraded is not a failure', async () => {
const h = await harness();
const plugin = new AppPlugin(stackWith('wake_sweep', 'wake', degradingHandler));
await plugin.start!(h.ctx);
await h.fireReady();
await h.adapter.trigger('wake_sweep');

const job = await h.jobRow('wake_sweep');
expect(job?.last_status).toBe('degraded');
expect(job?.last_error).toBe(REASON);
// `degraded` never retries and never alerts (#5548 / #7072).
expect(job?.failure_count).toBe(0);
expect(job?.run_count).toBe(1);
});

it('carries the outcome for the `{ handler, effect }` function form too', async () => {
// The shape a declared-effect entry takes (#4396) — the same route, one
// more indirection through `collectBundleFunctions`.
const h = await harness();
const plugin = new AppPlugin(
stackWith('wake_sweep', 'wake', { handler: degradingHandler, effect: 'writes' }),
);
await plugin.start!(h.ctx);
await h.fireReady();
await h.adapter.trigger('wake_sweep');

const runs = await h.runRows();
expect(runs[0].status).toBe('degraded');
expect(runs[0].error).toBe(REASON);
});
});

describe('#14256 — the controls that keep the assertion honest', () => {
it('CONTROL: the IMPERATIVE route already recorded it — the defect was the wrapper, not the adapter', async () => {
// Registered straight on `IJobService.schedule`, with no AppPlugin in
// the path. This case passed before the fix and passes after it, so a
// red on the declarative cases above localises to the wrapper rather
// than to `DbJobAdapter` or the `sys_job_run` write.
const h = await harness();
await h.adapter.schedule(
'imperative_wake',
{ type: 'cron', expression: '0 1 * * *' },
async (): Promise<JobRunOutcome> => ({ outcome: 'degraded', reason: REASON }),
);
await h.adapter.trigger('imperative_wake');

const runs = await h.runRows();
expect(runs[0].status).toBe('degraded');
expect(runs[0].error).toBe(REASON);
});

it('CONTROL: a job whose handler reports nothing still lands `success` — the additivity clause', async () => {
// The compatibility promise #6617 owes, on the exact shape every handler
// written before it has. It also proves the degraded assertion above is
// not passing because the rig writes `degraded` unconditionally.
const h = await harness();
const plugin = new AppPlugin(stackWith('quiet_sweep', 'quiet', silentHandler));
await plugin.start!(h.ctx);
await h.fireReady();
await h.adapter.trigger('quiet_sweep');

const runs = await h.runRows();
expect(runs).toHaveLength(1);
expect(runs[0].status).toBe('success');
expect(runs[0].error).toBeNull();
expect((await h.jobRow('quiet_sweep'))?.last_status).toBe('success');
});

it('the middle term: the wrapper AppPlugin hands `schedule` resolves the handler\'s outcome', async () => {
// The reporter's probe, kept as corroboration and NOT as the deliverable:
// it re-states the repair, while the cases above pin its consequence.
// Typed only at the contract, exactly as a third-party `IJobService` is.
const h = await harness();
const scheduled: Array<{ name: string; handler: JobHandler }> = [];
const recording: IJobService = {
async schedule(name: string, _schedule: JobSchedule, handler: JobHandler) {
scheduled.push({ name, handler });
},
async cancel() { /* noop */ },
async trigger() { /* noop */ },
};
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;
});

const plugin = new AppPlugin(stackWith('probe_wake', 'wake', degradingHandler));
await plugin.start!(h.ctx);
await h.fireReady();

expect(scheduled.map(s => s.name)).toEqual(['probe_wake']);
// `WRAPPER RESOLVED: undefined` was the measurement on the card.
const resolved = await scheduled[0].handler({ jobId: 'probe_wake' });
expect(resolved).toEqual({ outcome: 'degraded', reason: REASON });
});
});
21 changes: 20 additions & 1 deletion packages/runtime/src/app-plugin.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -1033,7 +1033,26 @@ export class AppPlugin implements Plugin {
ql,
logger: ctx.logger,
};
await handler(jobContext);
// #14256: RETURN the handler's resolved
// value. `JobHandler` is
// `(context) => Promise<void | JobRunOutcome>`
// and all three shipped adapters map a
// resolved `{ outcome: 'degraded', reason }`
// onto a `sys_job_run.status` distinct from
// `success` (#6617/#5548). A block-bodied
// arrow that only awaited made this wrapper
// a `Promise<void>`, so the third outcome
// was unreachable from `defineJob`: a job
// that ran to completion while its work did
// not happen was recorded as `success` with
// `reason` dropped, and the three-outcome
// table in `content/docs/automation/jobs.mdx`
// was false on the declarative door.
// A handler that resolves `undefined` — every
// handler written before #6617 — still returns
// `undefined` here, which is the `success`
// branch exactly as before.
return await handler(jobContext);
},
// #3494: thread the authored retryPolicy/timeout to the adapter
(job.retryPolicy || job.timeout)
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
42 changes: 42 additions & 0 deletions .changeset/declarative-job-run-outcome.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
---
'@objectstack/runtime': patch
---

fix(runtime): a declarative job's `JobRunOutcome` reaches the adapter that records it (#14256)

`AppPlugin`'s declarative-job registration block handed `IJobService.schedule` a
block-bodied arrow that *awaited* the bundle handler and returned nothing, so
the wrapper was a `Promise<void>` whatever the handler resolved. Measured
through an `IJobService` typed only at the contract:

```
HANDLER RESOLVED: {"outcome":"degraded","reason":"STORE_UNAVAILABLE"}
WRAPPER RESOLVED: undefined
```

#6617's third outcome was therefore unreachable from `defineJob`. All three
shipped adapters (`cron-job-adapter`, `interval-job-adapter`, `db-job-adapter`)
map a resolved `{ outcome: 'degraded', reason }` onto a run status distinct from
`success` — they simply never got the value on this path, so a declarative job
that ran to completion while its work did not happen (store unavailable, zero
rows matched) was recorded as `success` with `reason` dropped. The three-outcome
table in `content/docs/automation/jobs.mdx` — the page whose whole subject is
the declarative door — was false on exactly that door. The imperative route (a
handler registered straight on `IJobService.schedule`) was unaffected
throughout; the wrapper is the whole defect.

The repair is to return the handler's resolved value. Patch rather than minor:
no export, type, schema or authoring surface changes, and `JobHandler` has
declared `Promise<void | JobRunOutcome>` since #6617 — this makes the declared
contract true on a route where it was not. Additive in the ruling's sense: a
handler resolving `undefined` (every handler written before #6617) still
resolves `undefined` through the wrapper and still takes the `success` branch.
The one visible change for an existing app is the correction itself — a
declarative handler that *was already* resolving `{ outcome: 'degraded' }` now
lands `sys_job_run.status: 'degraded'` (reason in `error`, `failure_count` flat,
still never retried) where it previously landed `success`.

Pinned by `packages/runtime/src/app-plugin.job-degraded-outcome.test.ts`, which
drives the real `DbJobAdapter` over a real ObjectQL engine carrying the real
`sys_job` / `sys_job_run` declarations and asserts the persisted cell, not the
wrapper's return value.
305 changes: 305 additions & 0 deletions packages/runtime/src/app-plugin.job-degraded-outcome.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,305 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* #14256 — a DECLARATIVE job's `JobRunOutcome` must reach the adapter that
* records it.
*
* ## What was measured before the fix
*
* `AppPlugin`'s declarative-job registration block handed `IJobService.schedule`
* a block-bodied arrow that *awaited* the bundle handler and returned nothing,
* so the wrapper was a `Promise<void>` whatever the handler resolved. The
* reporter's probe, driven through an `IJobService` typed only at the contract:
*
* ```
* HANDLER RESOLVED: {"outcome":"degraded","reason":"STORE_UNAVAILABLE"}
* WRAPPER RESOLVED: undefined
* ```
*
* #6617's third outcome was therefore unreachable from `defineJob`: a job that
* ran to completion while its work did not happen (store unavailable, zero rows
* matched) was recorded as `success`, with `reason` dropped. The imperative
* route — a handler registered straight on `IJobService.schedule` — was
* unaffected the whole time, which is exactly what localises the defect to this
* wrapper.
*
* ## What these cases assert, and why it is not the wrapper's return value
*
* The deliverable named on the card is **the value that lands in the row**:
* `sys_job_run.status` distinct from `success`, driven through `DbJobAdapter`,
* the adapter that records it. A case asserting only that the wrapper returns
* what the handler returned re-states the one-expression repair; the
* consequence — what gets RECORDED — is what
* `content/docs/automation/jobs.mdx`'s three-outcome table promises a
* declarative author and what the declarative door did not deliver. So the
* primary assertions read the persisted cell, and the wrapper's own resolved
* value is pinned only as the corroborating middle term.
*
* ## The rig: a real engine, real `sys_job*` declarations, the real adapter
*
* `DbJobAdapter` writes `sys_job` / `sys_job_run` through ObjectQL, and
* `sys_job_run.status` is an *enforced* `Field.select` whose vocabulary carries
* `degraded` (#7072). Driving the real engine over the migrated sqlite
* `:memory:` backend (#5704) therefore proves two things a recording double
* cannot: the status the adapter writes is a value the record validator
* accepts, and it is the value a reader of the row gets back. Nothing here is
* about any one driver's behaviour, and this file is not on
* `scripts/driver-memory-census.ledger.json` — ⛔ do not "simplify" it onto
* `@objectstack/driver-memory` (#6664).
*/

import { describe, it, expect, vi, afterEach } from 'vitest';
import type { PluginContext } from '@objectstack/core';
import type { IJobService, JobHandler, JobRunOutcome, JobSchedule } from '@objectstack/spec/contracts';
import { defineJob } from '@objectstack/spec/system';
import { ObjectQL } from '@objectstack/objectql';
import { SqlDriver } from '@objectstack/driver-sql';
import { DbJobAdapter } from '@objectstack/service-job';
import { SysJob, SysJobRun } from '@objectstack/platform-objects/audit';
import { AppPlugin } from './app-plugin.js';
import type { JobHandlerContext } from './job-handler-context.js';

/** The reason a #5529-shaped handler reports when its store is unreachable. */
const REASON = 'STORE_UNAVAILABLE';

interface Harness {
engine: ObjectQL;
adapter: DbJobAdapter;
ctx: PluginContext;
fireReady: () => Promise<void>;
errorLogs: () => string[];
warnLogs: () => string[];
runRows: () => Promise<Array<Record<string, unknown>>>;
jobRow: (name: string) => Promise<Record<string, unknown> | undefined>;
}

const live: Array<{
engine?: ObjectQL;
adapter?: DbJobAdapter;
driver?: SqlDriver;
}> = [];

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 */ }
}
});

/**
* A real engine over the migrated test backend, carrying the REAL `sys_job*`.
*
* ⚠️ [#10629] No expected-read-refusal capture here, deliberately and by
* MEASUREMENT: every read this file performs carries `isSystem`, so the
* engine's single-tenant probe over the unprovisioned `sys_organization` never
* fires and neither refusal channel emits a frame (checked on the red run: zero
* `refused a read on` and zero `Find operation failed` lines for the whole
* file). Installing a capture that nothing provokes would assert a mute.
*/
async function bootEngine(): Promise<{ engine: ObjectQL; driver: SqlDriver }> {
const driver = new SqlDriver({
client: 'better-sqlite3',
connection: { filename: ':memory:' },
useNullAsDefault: true,
});
await driver.initObjects([SysJob as never, SysJobRun as never]);
const engine = new ObjectQL();
engine.registerDriver(driver as never, true);
await engine.init();
engine.registry.registerObject(SysJob as never);
engine.registry.registerObject(SysJobRun as never);
return { engine, driver };
}

async function harness(): Promise<Harness> {
const { engine, driver } = await bootEngine();
// The adapter that RECORDS the outcome — the coordinate the card names.
const adapter = new DbJobAdapter({ engine: engine as never });
live.push({ engine, adapter, driver });

const readyHooks: Array<() => Promise<void>> = [];
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<void>) => {
if (event === 'kernel:ready') readyHooks.push(cb);
}),
trigger: vi.fn(),
} as unknown as PluginContext;

const rows = async (table: string, where?: Record<string, unknown>) => {
const found = await engine.find(table, {
...(where ? { where } : {}),
context: { isSystem: true, positions: [], permissions: [] },
} as never);
return Array.isArray(found) ? found as Array<Record<string, unknown>> : [];
};

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])),
runRows: () => rows('sys_job_run'),
jobRow: async (name: string) => (await rows('sys_job', { name }))[0],
};
}

/**
* The #5529 specimen as a DECLARATIVE job handler: it fires its shot at an
* unreachable store, completes normally — a throw is the retry signal, which is
* the wrong report — and says so by resolving the third outcome.
*/
async function degradingHandler(_jobCtx: JobHandlerContext): Promise<JobRunOutcome> {
return { outcome: 'degraded', reason: REASON };
}

/** A pre-#6617 handler: resolves nothing, means `success`. */
async function silentHandler(_jobCtx: JobHandlerContext): Promise<void> {
/* did its work, reports nothing */
}

function stackWith(jobName: string, handlerKey: string, fn: unknown) {
return {
id: 'com.test.job-degraded-outcome',
jobs: [defineJob({
name: jobName,
schedule: { type: 'cron', expression: '0 1 * * *' },
handler: handlerKey,
})],
functions: { [handlerKey]: fn },
};
}

describe('#14256 — a declarative job\'s degraded outcome reaches `sys_job_run`', () => {
it('lands `sys_job_run.status` distinct from `success`, with the reason in `error`', async () => {
const h = await harness();
const plugin = new AppPlugin(stackWith('wake_sweep', 'wake', degradingHandler));

await plugin.start!(h.ctx);
await h.fireReady();
expect(await h.adapter.listJobs()).toContain('wake_sweep');

// Run it the way the scheduler runs it — through the adapter, never by
// calling the handler directly.
await h.adapter.trigger('wake_sweep');

const runs = await h.runRows();
expect(runs).toHaveLength(1);
// The card's assertion, in its own words: a status DISTINCT from
// `success`. Spelled as the inequality first, because that is the
// contract (`spec/contracts/job-service.ts`), then as the value the
// shipped adapters agree on.
expect(runs[0].status).not.toBe('success');
expect(runs[0].status).toBe('degraded');
expect(runs[0].error).toBe(REASON);
expect(runs[0].job_name).toBe('wake_sweep');
expect(h.errorLogs()).toEqual([]);
});

it('mirrors onto `sys_job.last_status` and leaves `failure_count` flat — degraded is not a failure', async () => {
const h = await harness();
const plugin = new AppPlugin(stackWith('wake_sweep', 'wake', degradingHandler));
await plugin.start!(h.ctx);
await h.fireReady();
await h.adapter.trigger('wake_sweep');

const job = await h.jobRow('wake_sweep');
expect(job?.last_status).toBe('degraded');
expect(job?.last_error).toBe(REASON);
// `degraded` never retries and never alerts (#5548 / #7072).
expect(job?.failure_count).toBe(0);
expect(job?.run_count).toBe(1);
});

it('carries the outcome for the `{ handler, effect }` function form too', async () => {
// The shape a declared-effect entry takes (#4396) — the same route, one
// more indirection through `collectBundleFunctions`.
const h = await harness();
const plugin = new AppPlugin(
stackWith('wake_sweep', 'wake', { handler: degradingHandler, effect: 'writes' }),
);
await plugin.start!(h.ctx);
await h.fireReady();
await h.adapter.trigger('wake_sweep');

const runs = await h.runRows();
expect(runs[0].status).toBe('degraded');
expect(runs[0].error).toBe(REASON);
});
});

describe('#14256 — the controls that keep the assertion honest', () => {
it('CONTROL: the IMPERATIVE route already recorded it — the defect was the wrapper, not the adapter', async () => {
// Registered straight on `IJobService.schedule`, with no AppPlugin in
// the path. This case passed before the fix and passes after it, so a
// red on the declarative cases above localises to the wrapper rather
// than to `DbJobAdapter` or the `sys_job_run` write.
const h = await harness();
await h.adapter.schedule(
'imperative_wake',
{ type: 'cron', expression: '0 1 * * *' },
async (): Promise<JobRunOutcome> => ({ outcome: 'degraded', reason: REASON }),
);
await h.adapter.trigger('imperative_wake');

const runs = await h.runRows();
expect(runs[0].status).toBe('degraded');
expect(runs[0].error).toBe(REASON);
});

it('CONTROL: a job whose handler reports nothing still lands `success` — the additivity clause', async () => {
// The compatibility promise #6617 owes, on the exact shape every handler
// written before it has. It also proves the degraded assertion above is
// not passing because the rig writes `degraded` unconditionally.
const h = await harness();
const plugin = new AppPlugin(stackWith('quiet_sweep', 'quiet', silentHandler));
await plugin.start!(h.ctx);
await h.fireReady();
await h.adapter.trigger('quiet_sweep');

const runs = await h.runRows();
expect(runs).toHaveLength(1);
expect(runs[0].status).toBe('success');
expect(runs[0].error).toBeNull();
expect((await h.jobRow('quiet_sweep'))?.last_status).toBe('success');
});

it('the middle term: the wrapper AppPlugin hands `schedule` resolves the handler\'s outcome', async () => {
// The reporter's probe, kept as corroboration and NOT as the deliverable:
// it re-states the repair, while the cases above pin its consequence.
// Typed only at the contract, exactly as a third-party `IJobService` is.
const h = await harness();
const scheduled: Array<{ name: string; handler: JobHandler }> = [];
const recording: IJobService = {
async schedule(name: string, _schedule: JobSchedule, handler: JobHandler) {
scheduled.push({ name, handler });
},
async cancel() { /* noop */ },
async trigger() { /* noop */ },
};
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;
});

const plugin = new AppPlugin(stackWith('probe_wake', 'wake', degradingHandler));
await plugin.start!(h.ctx);
await h.fireReady();

expect(scheduled.map(s => s.name)).toEqual(['probe_wake']);
// `WRAPPER RESOLVED: undefined` was the measurement on the card.
const resolved = await scheduled[0].handler({ jobId: 'probe_wake' });
expect(resolved).toEqual({ outcome: 'degraded', reason: REASON });
});
});
21 changes: 20 additions & 1 deletion packages/runtime/src/app-plugin.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -1033,7 +1033,26 @@ export class AppPlugin implements Plugin {
ql,
logger: ctx.logger,
};
await handler(jobContext);
// #14256: RETURN the handler's resolved
// value. `JobHandler` is
// `(context) => Promise<void | JobRunOutcome>`
// and all three shipped adapters map a
// resolved `{ outcome: 'degraded', reason }`
// onto a `sys_job_run.status` distinct from
// `success` (#6617/#5548). A block-bodied
// arrow that only awaited made this wrapper
// a `Promise<void>`, so the third outcome
// was unreachable from `defineJob`: a job
// that ran to completion while its work did
// not happen was recorded as `success` with
// `reason` dropped, and the three-outcome
// table in `content/docs/automation/jobs.mdx`
// was false on the declarative door.
// A handler that resolves `undefined` — every
// handler written before #6617 — still returns
// `undefined` here, which is the `success`
// branch exactly as before.
return await handler(jobContext);
},
// #3494: thread the authored retryPolicy/timeout to the adapter
(job.retryPolicy || job.timeout)
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
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
42 changes: 42 additions & 0 deletions .changeset/declarative-job-run-outcome.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
---
'@objectstack/runtime': patch
---

fix(runtime): a declarative job's `JobRunOutcome` reaches the adapter that records it (#14256)

`AppPlugin`'s declarative-job registration block handed `IJobService.schedule` a
block-bodied arrow that *awaited* the bundle handler and returned nothing, so
the wrapper was a `Promise<void>` whatever the handler resolved. Measured
through an `IJobService` typed only at the contract:

```
HANDLER RESOLVED: {"outcome":"degraded","reason":"STORE_UNAVAILABLE"}
WRAPPER RESOLVED: undefined
```

#6617's third outcome was therefore unreachable from `defineJob`. All three
shipped adapters (`cron-job-adapter`, `interval-job-adapter`, `db-job-adapter`)
map a resolved `{ outcome: 'degraded', reason }` onto a run status distinct from
`success` — they simply never got the value on this path, so a declarative job
that ran to completion while its work did not happen (store unavailable, zero
rows matched) was recorded as `success` with `reason` dropped. The three-outcome
table in `content/docs/automation/jobs.mdx` — the page whose whole subject is
the declarative door — was false on exactly that door. The imperative route (a
handler registered straight on `IJobService.schedule`) was unaffected
throughout; the wrapper is the whole defect.

The repair is to return the handler's resolved value. Patch rather than minor:
no export, type, schema or authoring surface changes, and `JobHandler` has
declared `Promise<void | JobRunOutcome>` since #6617 — this makes the declared
contract true on a route where it was not. Additive in the ruling's sense: a
handler resolving `undefined` (every handler written before #6617) still
resolves `undefined` through the wrapper and still takes the `success` branch.
The one visible change for an existing app is the correction itself — a
declarative handler that *was already* resolving `{ outcome: 'degraded' }` now
lands `sys_job_run.status: 'degraded'` (reason in `error`, `failure_count` flat,
still never retried) where it previously landed `success`.

Pinned by `packages/runtime/src/app-plugin.job-degraded-outcome.test.ts`, which
drives the real `DbJobAdapter` over a real ObjectQL engine carrying the real
`sys_job` / `sys_job_run` declarations and asserts the persisted cell, not the
wrapper's return value.
305 changes: 305 additions & 0 deletions packages/runtime/src/app-plugin.job-degraded-outcome.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,305 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* #14256 — a DECLARATIVE job's `JobRunOutcome` must reach the adapter that
* records it.
*
* ## What was measured before the fix
*
* `AppPlugin`'s declarative-job registration block handed `IJobService.schedule`
* a block-bodied arrow that *awaited* the bundle handler and returned nothing,
* so the wrapper was a `Promise<void>` whatever the handler resolved. The
* reporter's probe, driven through an `IJobService` typed only at the contract:
*
* ```
* HANDLER RESOLVED: {"outcome":"degraded","reason":"STORE_UNAVAILABLE"}
* WRAPPER RESOLVED: undefined
* ```
*
* #6617's third outcome was therefore unreachable from `defineJob`: a job that
* ran to completion while its work did not happen (store unavailable, zero rows
* matched) was recorded as `success`, with `reason` dropped. The imperative
* route — a handler registered straight on `IJobService.schedule` — was
* unaffected the whole time, which is exactly what localises the defect to this
* wrapper.
*
* ## What these cases assert, and why it is not the wrapper's return value
*
* The deliverable named on the card is **the value that lands in the row**:
* `sys_job_run.status` distinct from `success`, driven through `DbJobAdapter`,
* the adapter that records it. A case asserting only that the wrapper returns
* what the handler returned re-states the one-expression repair; the
* consequence — what gets RECORDED — is what
* `content/docs/automation/jobs.mdx`'s three-outcome table promises a
* declarative author and what the declarative door did not deliver. So the
* primary assertions read the persisted cell, and the wrapper's own resolved
* value is pinned only as the corroborating middle term.
*
* ## The rig: a real engine, real `sys_job*` declarations, the real adapter
*
* `DbJobAdapter` writes `sys_job` / `sys_job_run` through ObjectQL, and
* `sys_job_run.status` is an *enforced* `Field.select` whose vocabulary carries
* `degraded` (#7072). Driving the real engine over the migrated sqlite
* `:memory:` backend (#5704) therefore proves two things a recording double
* cannot: the status the adapter writes is a value the record validator
* accepts, and it is the value a reader of the row gets back. Nothing here is
* about any one driver's behaviour, and this file is not on
* `scripts/driver-memory-census.ledger.json` — ⛔ do not "simplify" it onto
* `@objectstack/driver-memory` (#6664).
*/

import { describe, it, expect, vi, afterEach } from 'vitest';
import type { PluginContext } from '@objectstack/core';
import type { IJobService, JobHandler, JobRunOutcome, JobSchedule } from '@objectstack/spec/contracts';
import { defineJob } from '@objectstack/spec/system';
import { ObjectQL } from '@objectstack/objectql';
import { SqlDriver } from '@objectstack/driver-sql';
import { DbJobAdapter } from '@objectstack/service-job';
import { SysJob, SysJobRun } from '@objectstack/platform-objects/audit';
import { AppPlugin } from './app-plugin.js';
import type { JobHandlerContext } from './job-handler-context.js';

/** The reason a #5529-shaped handler reports when its store is unreachable. */
const REASON = 'STORE_UNAVAILABLE';

interface Harness {
engine: ObjectQL;
adapter: DbJobAdapter;
ctx: PluginContext;
fireReady: () => Promise<void>;
errorLogs: () => string[];
warnLogs: () => string[];
runRows: () => Promise<Array<Record<string, unknown>>>;
jobRow: (name: string) => Promise<Record<string, unknown> | undefined>;
}

const live: Array<{
engine?: ObjectQL;
adapter?: DbJobAdapter;
driver?: SqlDriver;
}> = [];

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 */ }
}
});

/**
* A real engine over the migrated test backend, carrying the REAL `sys_job*`.
*
* ⚠️ [#10629] No expected-read-refusal capture here, deliberately and by
* MEASUREMENT: every read this file performs carries `isSystem`, so the
* engine's single-tenant probe over the unprovisioned `sys_organization` never
* fires and neither refusal channel emits a frame (checked on the red run: zero
* `refused a read on` and zero `Find operation failed` lines for the whole
* file). Installing a capture that nothing provokes would assert a mute.
*/
async function bootEngine(): Promise<{ engine: ObjectQL; driver: SqlDriver }> {
const driver = new SqlDriver({
client: 'better-sqlite3',
connection: { filename: ':memory:' },
useNullAsDefault: true,
});
await driver.initObjects([SysJob as never, SysJobRun as never]);
const engine = new ObjectQL();
engine.registerDriver(driver as never, true);
await engine.init();
engine.registry.registerObject(SysJob as never);
engine.registry.registerObject(SysJobRun as never);
return { engine, driver };
}

async function harness(): Promise<Harness> {
const { engine, driver } = await bootEngine();
// The adapter that RECORDS the outcome — the coordinate the card names.
const adapter = new DbJobAdapter({ engine: engine as never });
live.push({ engine, adapter, driver });

const readyHooks: Array<() => Promise<void>> = [];
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<void>) => {
if (event === 'kernel:ready') readyHooks.push(cb);
}),
trigger: vi.fn(),
} as unknown as PluginContext;

const rows = async (table: string, where?: Record<string, unknown>) => {
const found = await engine.find(table, {
...(where ? { where } : {}),
context: { isSystem: true, positions: [], permissions: [] },
} as never);
return Array.isArray(found) ? found as Array<Record<string, unknown>> : [];
};

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])),
runRows: () => rows('sys_job_run'),
jobRow: async (name: string) => (await rows('sys_job', { name }))[0],
};
}

/**
* The #5529 specimen as a DECLARATIVE job handler: it fires its shot at an
* unreachable store, completes normally — a throw is the retry signal, which is
* the wrong report — and says so by resolving the third outcome.
*/
async function degradingHandler(_jobCtx: JobHandlerContext): Promise<JobRunOutcome> {
return { outcome: 'degraded', reason: REASON };
}

/** A pre-#6617 handler: resolves nothing, means `success`. */
async function silentHandler(_jobCtx: JobHandlerContext): Promise<void> {
/* did its work, reports nothing */
}

function stackWith(jobName: string, handlerKey: string, fn: unknown) {
return {
id: 'com.test.job-degraded-outcome',
jobs: [defineJob({
name: jobName,
schedule: { type: 'cron', expression: '0 1 * * *' },
handler: handlerKey,
})],
functions: { [handlerKey]: fn },
};
}

describe('#14256 — a declarative job\'s degraded outcome reaches `sys_job_run`', () => {
it('lands `sys_job_run.status` distinct from `success`, with the reason in `error`', async () => {
const h = await harness();
const plugin = new AppPlugin(stackWith('wake_sweep', 'wake', degradingHandler));

await plugin.start!(h.ctx);
await h.fireReady();
expect(await h.adapter.listJobs()).toContain('wake_sweep');

// Run it the way the scheduler runs it — through the adapter, never by
// calling the handler directly.
await h.adapter.trigger('wake_sweep');

const runs = await h.runRows();
expect(runs).toHaveLength(1);
// The card's assertion, in its own words: a status DISTINCT from
// `success`. Spelled as the inequality first, because that is the
// contract (`spec/contracts/job-service.ts`), then as the value the
// shipped adapters agree on.
expect(runs[0].status).not.toBe('success');
expect(runs[0].status).toBe('degraded');
expect(runs[0].error).toBe(REASON);
expect(runs[0].job_name).toBe('wake_sweep');
expect(h.errorLogs()).toEqual([]);
});

it('mirrors onto `sys_job.last_status` and leaves `failure_count` flat — degraded is not a failure', async () => {
const h = await harness();
const plugin = new AppPlugin(stackWith('wake_sweep', 'wake', degradingHandler));
await plugin.start!(h.ctx);
await h.fireReady();
await h.adapter.trigger('wake_sweep');

const job = await h.jobRow('wake_sweep');
expect(job?.last_status).toBe('degraded');
expect(job?.last_error).toBe(REASON);
// `degraded` never retries and never alerts (#5548 / #7072).
expect(job?.failure_count).toBe(0);
expect(job?.run_count).toBe(1);
});

it('carries the outcome for the `{ handler, effect }` function form too', async () => {
// The shape a declared-effect entry takes (#4396) — the same route, one
// more indirection through `collectBundleFunctions`.
const h = await harness();
const plugin = new AppPlugin(
stackWith('wake_sweep', 'wake', { handler: degradingHandler, effect: 'writes' }),
);
await plugin.start!(h.ctx);
await h.fireReady();
await h.adapter.trigger('wake_sweep');

const runs = await h.runRows();
expect(runs[0].status).toBe('degraded');
expect(runs[0].error).toBe(REASON);
});
});

describe('#14256 — the controls that keep the assertion honest', () => {
it('CONTROL: the IMPERATIVE route already recorded it — the defect was the wrapper, not the adapter', async () => {
// Registered straight on `IJobService.schedule`, with no AppPlugin in
// the path. This case passed before the fix and passes after it, so a
// red on the declarative cases above localises to the wrapper rather
// than to `DbJobAdapter` or the `sys_job_run` write.
const h = await harness();
await h.adapter.schedule(
'imperative_wake',
{ type: 'cron', expression: '0 1 * * *' },
async (): Promise<JobRunOutcome> => ({ outcome: 'degraded', reason: REASON }),
);
await h.adapter.trigger('imperative_wake');

const runs = await h.runRows();
expect(runs[0].status).toBe('degraded');
expect(runs[0].error).toBe(REASON);
});

it('CONTROL: a job whose handler reports nothing still lands `success` — the additivity clause', async () => {
// The compatibility promise #6617 owes, on the exact shape every handler
// written before it has. It also proves the degraded assertion above is
// not passing because the rig writes `degraded` unconditionally.
const h = await harness();
const plugin = new AppPlugin(stackWith('quiet_sweep', 'quiet', silentHandler));
await plugin.start!(h.ctx);
await h.fireReady();
await h.adapter.trigger('quiet_sweep');

const runs = await h.runRows();
expect(runs).toHaveLength(1);
expect(runs[0].status).toBe('success');
expect(runs[0].error).toBeNull();
expect((await h.jobRow('quiet_sweep'))?.last_status).toBe('success');
});

it('the middle term: the wrapper AppPlugin hands `schedule` resolves the handler\'s outcome', async () => {
// The reporter's probe, kept as corroboration and NOT as the deliverable:
// it re-states the repair, while the cases above pin its consequence.
// Typed only at the contract, exactly as a third-party `IJobService` is.
const h = await harness();
const scheduled: Array<{ name: string; handler: JobHandler }> = [];
const recording: IJobService = {
async schedule(name: string, _schedule: JobSchedule, handler: JobHandler) {
scheduled.push({ name, handler });
},
async cancel() { /* noop */ },
async trigger() { /* noop */ },
};
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;
});

const plugin = new AppPlugin(stackWith('probe_wake', 'wake', degradingHandler));
await plugin.start!(h.ctx);
await h.fireReady();

expect(scheduled.map(s => s.name)).toEqual(['probe_wake']);
// `WRAPPER RESOLVED: undefined` was the measurement on the card.
const resolved = await scheduled[0].handler({ jobId: 'probe_wake' });
expect(resolved).toEqual({ outcome: 'degraded', reason: REASON });
});
});
21 changes: 20 additions & 1 deletion packages/runtime/src/app-plugin.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -1033,7 +1033,26 @@ export class AppPlugin implements Plugin {
ql,
logger: ctx.logger,
};
await handler(jobContext);
// #14256: RETURN the handler's resolved
// value. `JobHandler` is
// `(context) => Promise<void | JobRunOutcome>`
// and all three shipped adapters map a
// resolved `{ outcome: 'degraded', reason }`
// onto a `sys_job_run.status` distinct from
// `success` (#6617/#5548). A block-bodied
// arrow that only awaited made this wrapper
// a `Promise<void>`, so the third outcome
// was unreachable from `defineJob`: a job
// that ran to completion while its work did
// not happen was recorded as `success` with
// `reason` dropped, and the three-outcome
// table in `content/docs/automation/jobs.mdx`
// was false on the declarative door.
// A handler that resolves `undefined` — every
// handler written before #6617 — still returns
// `undefined` here, which is the `success`
// branch exactly as before.
return await handler(jobContext);
},
// #3494: thread the authored retryPolicy/timeout to the adapter
(job.retryPolicy || job.timeout)
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
42 changes: 42 additions & 0 deletions .changeset/declarative-job-run-outcome.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
---
'@objectstack/runtime': patch
---

fix(runtime): a declarative job's `JobRunOutcome` reaches the adapter that records it (#14256)

`AppPlugin`'s declarative-job registration block handed `IJobService.schedule` a
block-bodied arrow that *awaited* the bundle handler and returned nothing, so
the wrapper was a `Promise<void>` whatever the handler resolved. Measured
through an `IJobService` typed only at the contract:

```
HANDLER RESOLVED: {"outcome":"degraded","reason":"STORE_UNAVAILABLE"}
WRAPPER RESOLVED: undefined
```

#6617's third outcome was therefore unreachable from `defineJob`. All three
shipped adapters (`cron-job-adapter`, `interval-job-adapter`, `db-job-adapter`)
map a resolved `{ outcome: 'degraded', reason }` onto a run status distinct from
`success` — they simply never got the value on this path, so a declarative job
that ran to completion while its work did not happen (store unavailable, zero
rows matched) was recorded as `success` with `reason` dropped. The three-outcome
table in `content/docs/automation/jobs.mdx` — the page whose whole subject is
the declarative door — was false on exactly that door. The imperative route (a
handler registered straight on `IJobService.schedule`) was unaffected
throughout; the wrapper is the whole defect.

The repair is to return the handler's resolved value. Patch rather than minor:
no export, type, schema or authoring surface changes, and `JobHandler` has
declared `Promise<void | JobRunOutcome>` since #6617 — this makes the declared
contract true on a route where it was not. Additive in the ruling's sense: a
handler resolving `undefined` (every handler written before #6617) still
resolves `undefined` through the wrapper and still takes the `success` branch.
The one visible change for an existing app is the correction itself — a
declarative handler that *was already* resolving `{ outcome: 'degraded' }` now
lands `sys_job_run.status: 'degraded'` (reason in `error`, `failure_count` flat,
still never retried) where it previously landed `success`.

Pinned by `packages/runtime/src/app-plugin.job-degraded-outcome.test.ts`, which
drives the real `DbJobAdapter` over a real ObjectQL engine carrying the real
`sys_job` / `sys_job_run` declarations and asserts the persisted cell, not the
wrapper's return value.
305 changes: 305 additions & 0 deletions packages/runtime/src/app-plugin.job-degraded-outcome.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,305 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* #14256 — a DECLARATIVE job's `JobRunOutcome` must reach the adapter that
* records it.
*
* ## What was measured before the fix
*
* `AppPlugin`'s declarative-job registration block handed `IJobService.schedule`
* a block-bodied arrow that *awaited* the bundle handler and returned nothing,
* so the wrapper was a `Promise<void>` whatever the handler resolved. The
* reporter's probe, driven through an `IJobService` typed only at the contract:
*
* ```
* HANDLER RESOLVED: {"outcome":"degraded","reason":"STORE_UNAVAILABLE"}
* WRAPPER RESOLVED: undefined
* ```
*
* #6617's third outcome was therefore unreachable from `defineJob`: a job that
* ran to completion while its work did not happen (store unavailable, zero rows
* matched) was recorded as `success`, with `reason` dropped. The imperative
* route — a handler registered straight on `IJobService.schedule` — was
* unaffected the whole time, which is exactly what localises the defect to this
* wrapper.
*
* ## What these cases assert, and why it is not the wrapper's return value
*
* The deliverable named on the card is **the value that lands in the row**:
* `sys_job_run.status` distinct from `success`, driven through `DbJobAdapter`,
* the adapter that records it. A case asserting only that the wrapper returns
* what the handler returned re-states the one-expression repair; the
* consequence — what gets RECORDED — is what
* `content/docs/automation/jobs.mdx`'s three-outcome table promises a
* declarative author and what the declarative door did not deliver. So the
* primary assertions read the persisted cell, and the wrapper's own resolved
* value is pinned only as the corroborating middle term.
*
* ## The rig: a real engine, real `sys_job*` declarations, the real adapter
*
* `DbJobAdapter` writes `sys_job` / `sys_job_run` through ObjectQL, and
* `sys_job_run.status` is an *enforced* `Field.select` whose vocabulary carries
* `degraded` (#7072). Driving the real engine over the migrated sqlite
* `:memory:` backend (#5704) therefore proves two things a recording double
* cannot: the status the adapter writes is a value the record validator
* accepts, and it is the value a reader of the row gets back. Nothing here is
* about any one driver's behaviour, and this file is not on
* `scripts/driver-memory-census.ledger.json` — ⛔ do not "simplify" it onto
* `@objectstack/driver-memory` (#6664).
*/

import { describe, it, expect, vi, afterEach } from 'vitest';
import type { PluginContext } from '@objectstack/core';
import type { IJobService, JobHandler, JobRunOutcome, JobSchedule } from '@objectstack/spec/contracts';
import { defineJob } from '@objectstack/spec/system';
import { ObjectQL } from '@objectstack/objectql';
import { SqlDriver } from '@objectstack/driver-sql';
import { DbJobAdapter } from '@objectstack/service-job';
import { SysJob, SysJobRun } from '@objectstack/platform-objects/audit';
import { AppPlugin } from './app-plugin.js';
import type { JobHandlerContext } from './job-handler-context.js';

/** The reason a #5529-shaped handler reports when its store is unreachable. */
const REASON = 'STORE_UNAVAILABLE';

interface Harness {
engine: ObjectQL;
adapter: DbJobAdapter;
ctx: PluginContext;
fireReady: () => Promise<void>;
errorLogs: () => string[];
warnLogs: () => string[];
runRows: () => Promise<Array<Record<string, unknown>>>;
jobRow: (name: string) => Promise<Record<string, unknown> | undefined>;
}

const live: Array<{
engine?: ObjectQL;
adapter?: DbJobAdapter;
driver?: SqlDriver;
}> = [];

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 */ }
}
});

/**
* A real engine over the migrated test backend, carrying the REAL `sys_job*`.
*
* ⚠️ [#10629] No expected-read-refusal capture here, deliberately and by
* MEASUREMENT: every read this file performs carries `isSystem`, so the
* engine's single-tenant probe over the unprovisioned `sys_organization` never
* fires and neither refusal channel emits a frame (checked on the red run: zero
* `refused a read on` and zero `Find operation failed` lines for the whole
* file). Installing a capture that nothing provokes would assert a mute.
*/
async function bootEngine(): Promise<{ engine: ObjectQL; driver: SqlDriver }> {
const driver = new SqlDriver({
client: 'better-sqlite3',
connection: { filename: ':memory:' },
useNullAsDefault: true,
});
await driver.initObjects([SysJob as never, SysJobRun as never]);
const engine = new ObjectQL();
engine.registerDriver(driver as never, true);
await engine.init();
engine.registry.registerObject(SysJob as never);
engine.registry.registerObject(SysJobRun as never);
return { engine, driver };
}

async function harness(): Promise<Harness> {
const { engine, driver } = await bootEngine();
// The adapter that RECORDS the outcome — the coordinate the card names.
const adapter = new DbJobAdapter({ engine: engine as never });
live.push({ engine, adapter, driver });

const readyHooks: Array<() => Promise<void>> = [];
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<void>) => {
if (event === 'kernel:ready') readyHooks.push(cb);
}),
trigger: vi.fn(),
} as unknown as PluginContext;

const rows = async (table: string, where?: Record<string, unknown>) => {
const found = await engine.find(table, {
...(where ? { where } : {}),
context: { isSystem: true, positions: [], permissions: [] },
} as never);
return Array.isArray(found) ? found as Array<Record<string, unknown>> : [];
};

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])),
runRows: () => rows('sys_job_run'),
jobRow: async (name: string) => (await rows('sys_job', { name }))[0],
};
}

/**
* The #5529 specimen as a DECLARATIVE job handler: it fires its shot at an
* unreachable store, completes normally — a throw is the retry signal, which is
* the wrong report — and says so by resolving the third outcome.
*/
async function degradingHandler(_jobCtx: JobHandlerContext): Promise<JobRunOutcome> {
return { outcome: 'degraded', reason: REASON };
}

/** A pre-#6617 handler: resolves nothing, means `success`. */
async function silentHandler(_jobCtx: JobHandlerContext): Promise<void> {
/* did its work, reports nothing */
}

function stackWith(jobName: string, handlerKey: string, fn: unknown) {
return {
id: 'com.test.job-degraded-outcome',
jobs: [defineJob({
name: jobName,
schedule: { type: 'cron', expression: '0 1 * * *' },
handler: handlerKey,
})],
functions: { [handlerKey]: fn },
};
}

describe('#14256 — a declarative job\'s degraded outcome reaches `sys_job_run`', () => {
it('lands `sys_job_run.status` distinct from `success`, with the reason in `error`', async () => {
const h = await harness();
const plugin = new AppPlugin(stackWith('wake_sweep', 'wake', degradingHandler));

await plugin.start!(h.ctx);
await h.fireReady();
expect(await h.adapter.listJobs()).toContain('wake_sweep');

// Run it the way the scheduler runs it — through the adapter, never by
// calling the handler directly.
await h.adapter.trigger('wake_sweep');

const runs = await h.runRows();
expect(runs).toHaveLength(1);
// The card's assertion, in its own words: a status DISTINCT from
// `success`. Spelled as the inequality first, because that is the
// contract (`spec/contracts/job-service.ts`), then as the value the
// shipped adapters agree on.
expect(runs[0].status).not.toBe('success');
expect(runs[0].status).toBe('degraded');
expect(runs[0].error).toBe(REASON);
expect(runs[0].job_name).toBe('wake_sweep');
expect(h.errorLogs()).toEqual([]);
});

it('mirrors onto `sys_job.last_status` and leaves `failure_count` flat — degraded is not a failure', async () => {
const h = await harness();
const plugin = new AppPlugin(stackWith('wake_sweep', 'wake', degradingHandler));
await plugin.start!(h.ctx);
await h.fireReady();
await h.adapter.trigger('wake_sweep');

const job = await h.jobRow('wake_sweep');
expect(job?.last_status).toBe('degraded');
expect(job?.last_error).toBe(REASON);
// `degraded` never retries and never alerts (#5548 / #7072).
expect(job?.failure_count).toBe(0);
expect(job?.run_count).toBe(1);
});

it('carries the outcome for the `{ handler, effect }` function form too', async () => {
// The shape a declared-effect entry takes (#4396) — the same route, one
// more indirection through `collectBundleFunctions`.
const h = await harness();
const plugin = new AppPlugin(
stackWith('wake_sweep', 'wake', { handler: degradingHandler, effect: 'writes' }),
);
await plugin.start!(h.ctx);
await h.fireReady();
await h.adapter.trigger('wake_sweep');

const runs = await h.runRows();
expect(runs[0].status).toBe('degraded');
expect(runs[0].error).toBe(REASON);
});
});

describe('#14256 — the controls that keep the assertion honest', () => {
it('CONTROL: the IMPERATIVE route already recorded it — the defect was the wrapper, not the adapter', async () => {
// Registered straight on `IJobService.schedule`, with no AppPlugin in
// the path. This case passed before the fix and passes after it, so a
// red on the declarative cases above localises to the wrapper rather
// than to `DbJobAdapter` or the `sys_job_run` write.
const h = await harness();
await h.adapter.schedule(
'imperative_wake',
{ type: 'cron', expression: '0 1 * * *' },
async (): Promise<JobRunOutcome> => ({ outcome: 'degraded', reason: REASON }),
);
await h.adapter.trigger('imperative_wake');

const runs = await h.runRows();
expect(runs[0].status).toBe('degraded');
expect(runs[0].error).toBe(REASON);
});

it('CONTROL: a job whose handler reports nothing still lands `success` — the additivity clause', async () => {
// The compatibility promise #6617 owes, on the exact shape every handler
// written before it has. It also proves the degraded assertion above is
// not passing because the rig writes `degraded` unconditionally.
const h = await harness();
const plugin = new AppPlugin(stackWith('quiet_sweep', 'quiet', silentHandler));
await plugin.start!(h.ctx);
await h.fireReady();
await h.adapter.trigger('quiet_sweep');

const runs = await h.runRows();
expect(runs).toHaveLength(1);
expect(runs[0].status).toBe('success');
expect(runs[0].error).toBeNull();
expect((await h.jobRow('quiet_sweep'))?.last_status).toBe('success');
});

it('the middle term: the wrapper AppPlugin hands `schedule` resolves the handler\'s outcome', async () => {
// The reporter's probe, kept as corroboration and NOT as the deliverable:
// it re-states the repair, while the cases above pin its consequence.
// Typed only at the contract, exactly as a third-party `IJobService` is.
const h = await harness();
const scheduled: Array<{ name: string; handler: JobHandler }> = [];
const recording: IJobService = {
async schedule(name: string, _schedule: JobSchedule, handler: JobHandler) {
scheduled.push({ name, handler });
},
async cancel() { /* noop */ },
async trigger() { /* noop */ },
};
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;
});

const plugin = new AppPlugin(stackWith('probe_wake', 'wake', degradingHandler));
await plugin.start!(h.ctx);
await h.fireReady();

expect(scheduled.map(s => s.name)).toEqual(['probe_wake']);
// `WRAPPER RESOLVED: undefined` was the measurement on the card.
const resolved = await scheduled[0].handler({ jobId: 'probe_wake' });
expect(resolved).toEqual({ outcome: 'degraded', reason: REASON });
});
});
21 changes: 20 additions & 1 deletion packages/runtime/src/app-plugin.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -1033,7 +1033,26 @@ export class AppPlugin implements Plugin {
ql,
logger: ctx.logger,
};
await handler(jobContext);
// #14256: RETURN the handler's resolved
// value. `JobHandler` is
// `(context) => Promise<void | JobRunOutcome>`
// and all three shipped adapters map a
// resolved `{ outcome: 'degraded', reason }`
// onto a `sys_job_run.status` distinct from
// `success` (#6617/#5548). A block-bodied
// arrow that only awaited made this wrapper
// a `Promise<void>`, so the third outcome
// was unreachable from `defineJob`: a job
// that ran to completion while its work did
// not happen was recorded as `success` with
// `reason` dropped, and the three-outcome
// table in `content/docs/automation/jobs.mdx`
// was false on the declarative door.
// A handler that resolves `undefined` — every
// handler written before #6617 — still returns
// `undefined` here, which is the `success`
// branch exactly as before.
return await handler(jobContext);
},
// #3494: thread the authored retryPolicy/timeout to the adapter
(job.retryPolicy || job.timeout)
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
42 changes: 42 additions & 0 deletions .changeset/declarative-job-run-outcome.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
---
'@objectstack/runtime': patch
---

fix(runtime): a declarative job's `JobRunOutcome` reaches the adapter that records it (#14256)

`AppPlugin`'s declarative-job registration block handed `IJobService.schedule` a
block-bodied arrow that *awaited* the bundle handler and returned nothing, so
the wrapper was a `Promise<void>` whatever the handler resolved. Measured
through an `IJobService` typed only at the contract:

```
HANDLER RESOLVED: {"outcome":"degraded","reason":"STORE_UNAVAILABLE"}
WRAPPER RESOLVED: undefined
```

#6617's third outcome was therefore unreachable from `defineJob`. All three
shipped adapters (`cron-job-adapter`, `interval-job-adapter`, `db-job-adapter`)
map a resolved `{ outcome: 'degraded', reason }` onto a run status distinct from
`success` — they simply never got the value on this path, so a declarative job
that ran to completion while its work did not happen (store unavailable, zero
rows matched) was recorded as `success` with `reason` dropped. The three-outcome
table in `content/docs/automation/jobs.mdx` — the page whose whole subject is
the declarative door — was false on exactly that door. The imperative route (a
handler registered straight on `IJobService.schedule`) was unaffected
throughout; the wrapper is the whole defect.

The repair is to return the handler's resolved value. Patch rather than minor:
no export, type, schema or authoring surface changes, and `JobHandler` has
declared `Promise<void | JobRunOutcome>` since #6617 — this makes the declared
contract true on a route where it was not. Additive in the ruling's sense: a
handler resolving `undefined` (every handler written before #6617) still
resolves `undefined` through the wrapper and still takes the `success` branch.
The one visible change for an existing app is the correction itself — a
declarative handler that *was already* resolving `{ outcome: 'degraded' }` now
lands `sys_job_run.status: 'degraded'` (reason in `error`, `failure_count` flat,
still never retried) where it previously landed `success`.

Pinned by `packages/runtime/src/app-plugin.job-degraded-outcome.test.ts`, which
drives the real `DbJobAdapter` over a real ObjectQL engine carrying the real
`sys_job` / `sys_job_run` declarations and asserts the persisted cell, not the
wrapper's return value.
305 changes: 305 additions & 0 deletions packages/runtime/src/app-plugin.job-degraded-outcome.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,305 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* #14256 — a DECLARATIVE job's `JobRunOutcome` must reach the adapter that
* records it.
*
* ## What was measured before the fix
*
* `AppPlugin`'s declarative-job registration block handed `IJobService.schedule`
* a block-bodied arrow that *awaited* the bundle handler and returned nothing,
* so the wrapper was a `Promise<void>` whatever the handler resolved. The
* reporter's probe, driven through an `IJobService` typed only at the contract:
*
* ```
* HANDLER RESOLVED: {"outcome":"degraded","reason":"STORE_UNAVAILABLE"}
* WRAPPER RESOLVED: undefined
* ```
*
* #6617's third outcome was therefore unreachable from `defineJob`: a job that
* ran to completion while its work did not happen (store unavailable, zero rows
* matched) was recorded as `success`, with `reason` dropped. The imperative
* route — a handler registered straight on `IJobService.schedule` — was
* unaffected the whole time, which is exactly what localises the defect to this
* wrapper.
*
* ## What these cases assert, and why it is not the wrapper's return value
*
* The deliverable named on the card is **the value that lands in the row**:
* `sys_job_run.status` distinct from `success`, driven through `DbJobAdapter`,
* the adapter that records it. A case asserting only that the wrapper returns
* what the handler returned re-states the one-expression repair; the
* consequence — what gets RECORDED — is what
* `content/docs/automation/jobs.mdx`'s three-outcome table promises a
* declarative author and what the declarative door did not deliver. So the
* primary assertions read the persisted cell, and the wrapper's own resolved
* value is pinned only as the corroborating middle term.
*
* ## The rig: a real engine, real `sys_job*` declarations, the real adapter
*
* `DbJobAdapter` writes `sys_job` / `sys_job_run` through ObjectQL, and
* `sys_job_run.status` is an *enforced* `Field.select` whose vocabulary carries
* `degraded` (#7072). Driving the real engine over the migrated sqlite
* `:memory:` backend (#5704) therefore proves two things a recording double
* cannot: the status the adapter writes is a value the record validator
* accepts, and it is the value a reader of the row gets back. Nothing here is
* about any one driver's behaviour, and this file is not on
* `scripts/driver-memory-census.ledger.json` — ⛔ do not "simplify" it onto
* `@objectstack/driver-memory` (#6664).
*/

import { describe, it, expect, vi, afterEach } from 'vitest';
import type { PluginContext } from '@objectstack/core';
import type { IJobService, JobHandler, JobRunOutcome, JobSchedule } from '@objectstack/spec/contracts';
import { defineJob } from '@objectstack/spec/system';
import { ObjectQL } from '@objectstack/objectql';
import { SqlDriver } from '@objectstack/driver-sql';
import { DbJobAdapter } from '@objectstack/service-job';
import { SysJob, SysJobRun } from '@objectstack/platform-objects/audit';
import { AppPlugin } from './app-plugin.js';
import type { JobHandlerContext } from './job-handler-context.js';

/** The reason a #5529-shaped handler reports when its store is unreachable. */
const REASON = 'STORE_UNAVAILABLE';

interface Harness {
engine: ObjectQL;
adapter: DbJobAdapter;
ctx: PluginContext;
fireReady: () => Promise<void>;
errorLogs: () => string[];
warnLogs: () => string[];
runRows: () => Promise<Array<Record<string, unknown>>>;
jobRow: (name: string) => Promise<Record<string, unknown> | undefined>;
}

const live: Array<{
engine?: ObjectQL;
adapter?: DbJobAdapter;
driver?: SqlDriver;
}> = [];

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 */ }
}
});

/**
* A real engine over the migrated test backend, carrying the REAL `sys_job*`.
*
* ⚠️ [#10629] No expected-read-refusal capture here, deliberately and by
* MEASUREMENT: every read this file performs carries `isSystem`, so the
* engine's single-tenant probe over the unprovisioned `sys_organization` never
* fires and neither refusal channel emits a frame (checked on the red run: zero
* `refused a read on` and zero `Find operation failed` lines for the whole
* file). Installing a capture that nothing provokes would assert a mute.
*/
async function bootEngine(): Promise<{ engine: ObjectQL; driver: SqlDriver }> {
const driver = new SqlDriver({
client: 'better-sqlite3',
connection: { filename: ':memory:' },
useNullAsDefault: true,
});
await driver.initObjects([SysJob as never, SysJobRun as never]);
const engine = new ObjectQL();
engine.registerDriver(driver as never, true);
await engine.init();
engine.registry.registerObject(SysJob as never);
engine.registry.registerObject(SysJobRun as never);
return { engine, driver };
}

async function harness(): Promise<Harness> {
const { engine, driver } = await bootEngine();
// The adapter that RECORDS the outcome — the coordinate the card names.
const adapter = new DbJobAdapter({ engine: engine as never });
live.push({ engine, adapter, driver });

const readyHooks: Array<() => Promise<void>> = [];
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<void>) => {
if (event === 'kernel:ready') readyHooks.push(cb);
}),
trigger: vi.fn(),
} as unknown as PluginContext;

const rows = async (table: string, where?: Record<string, unknown>) => {
const found = await engine.find(table, {
...(where ? { where } : {}),
context: { isSystem: true, positions: [], permissions: [] },
} as never);
return Array.isArray(found) ? found as Array<Record<string, unknown>> : [];
};

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])),
runRows: () => rows('sys_job_run'),
jobRow: async (name: string) => (await rows('sys_job', { name }))[0],
};
}

/**
* The #5529 specimen as a DECLARATIVE job handler: it fires its shot at an
* unreachable store, completes normally — a throw is the retry signal, which is
* the wrong report — and says so by resolving the third outcome.
*/
async function degradingHandler(_jobCtx: JobHandlerContext): Promise<JobRunOutcome> {
return { outcome: 'degraded', reason: REASON };
}

/** A pre-#6617 handler: resolves nothing, means `success`. */
async function silentHandler(_jobCtx: JobHandlerContext): Promise<void> {
/* did its work, reports nothing */
}

function stackWith(jobName: string, handlerKey: string, fn: unknown) {
return {
id: 'com.test.job-degraded-outcome',
jobs: [defineJob({
name: jobName,
schedule: { type: 'cron', expression: '0 1 * * *' },
handler: handlerKey,
})],
functions: { [handlerKey]: fn },
};
}

describe('#14256 — a declarative job\'s degraded outcome reaches `sys_job_run`', () => {
it('lands `sys_job_run.status` distinct from `success`, with the reason in `error`', async () => {
const h = await harness();
const plugin = new AppPlugin(stackWith('wake_sweep', 'wake', degradingHandler));

await plugin.start!(h.ctx);
await h.fireReady();
expect(await h.adapter.listJobs()).toContain('wake_sweep');

// Run it the way the scheduler runs it — through the adapter, never by
// calling the handler directly.
await h.adapter.trigger('wake_sweep');

const runs = await h.runRows();
expect(runs).toHaveLength(1);
// The card's assertion, in its own words: a status DISTINCT from
// `success`. Spelled as the inequality first, because that is the
// contract (`spec/contracts/job-service.ts`), then as the value the
// shipped adapters agree on.
expect(runs[0].status).not.toBe('success');
expect(runs[0].status).toBe('degraded');
expect(runs[0].error).toBe(REASON);
expect(runs[0].job_name).toBe('wake_sweep');
expect(h.errorLogs()).toEqual([]);
});

it('mirrors onto `sys_job.last_status` and leaves `failure_count` flat — degraded is not a failure', async () => {
const h = await harness();
const plugin = new AppPlugin(stackWith('wake_sweep', 'wake', degradingHandler));
await plugin.start!(h.ctx);
await h.fireReady();
await h.adapter.trigger('wake_sweep');

const job = await h.jobRow('wake_sweep');
expect(job?.last_status).toBe('degraded');
expect(job?.last_error).toBe(REASON);
// `degraded` never retries and never alerts (#5548 / #7072).
expect(job?.failure_count).toBe(0);
expect(job?.run_count).toBe(1);
});

it('carries the outcome for the `{ handler, effect }` function form too', async () => {
// The shape a declared-effect entry takes (#4396) — the same route, one
// more indirection through `collectBundleFunctions`.
const h = await harness();
const plugin = new AppPlugin(
stackWith('wake_sweep', 'wake', { handler: degradingHandler, effect: 'writes' }),
);
await plugin.start!(h.ctx);
await h.fireReady();
await h.adapter.trigger('wake_sweep');

const runs = await h.runRows();
expect(runs[0].status).toBe('degraded');
expect(runs[0].error).toBe(REASON);
});
});

describe('#14256 — the controls that keep the assertion honest', () => {
it('CONTROL: the IMPERATIVE route already recorded it — the defect was the wrapper, not the adapter', async () => {
// Registered straight on `IJobService.schedule`, with no AppPlugin in
// the path. This case passed before the fix and passes after it, so a
// red on the declarative cases above localises to the wrapper rather
// than to `DbJobAdapter` or the `sys_job_run` write.
const h = await harness();
await h.adapter.schedule(
'imperative_wake',
{ type: 'cron', expression: '0 1 * * *' },
async (): Promise<JobRunOutcome> => ({ outcome: 'degraded', reason: REASON }),
);
await h.adapter.trigger('imperative_wake');

const runs = await h.runRows();
expect(runs[0].status).toBe('degraded');
expect(runs[0].error).toBe(REASON);
});

it('CONTROL: a job whose handler reports nothing still lands `success` — the additivity clause', async () => {
// The compatibility promise #6617 owes, on the exact shape every handler
// written before it has. It also proves the degraded assertion above is
// not passing because the rig writes `degraded` unconditionally.
const h = await harness();
const plugin = new AppPlugin(stackWith('quiet_sweep', 'quiet', silentHandler));
await plugin.start!(h.ctx);
await h.fireReady();
await h.adapter.trigger('quiet_sweep');

const runs = await h.runRows();
expect(runs).toHaveLength(1);
expect(runs[0].status).toBe('success');
expect(runs[0].error).toBeNull();
expect((await h.jobRow('quiet_sweep'))?.last_status).toBe('success');
});

it('the middle term: the wrapper AppPlugin hands `schedule` resolves the handler\'s outcome', async () => {
// The reporter's probe, kept as corroboration and NOT as the deliverable:
// it re-states the repair, while the cases above pin its consequence.
// Typed only at the contract, exactly as a third-party `IJobService` is.
const h = await harness();
const scheduled: Array<{ name: string; handler: JobHandler }> = [];
const recording: IJobService = {
async schedule(name: string, _schedule: JobSchedule, handler: JobHandler) {
scheduled.push({ name, handler });
},
async cancel() { /* noop */ },
async trigger() { /* noop */ },
};
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;
});

const plugin = new AppPlugin(stackWith('probe_wake', 'wake', degradingHandler));
await plugin.start!(h.ctx);
await h.fireReady();

expect(scheduled.map(s => s.name)).toEqual(['probe_wake']);
// `WRAPPER RESOLVED: undefined` was the measurement on the card.
const resolved = await scheduled[0].handler({ jobId: 'probe_wake' });
expect(resolved).toEqual({ outcome: 'degraded', reason: REASON });
});
});
21 changes: 20 additions & 1 deletion packages/runtime/src/app-plugin.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -1033,7 +1033,26 @@ export class AppPlugin implements Plugin {
ql,
logger: ctx.logger,
};
await handler(jobContext);
// #14256: RETURN the handler's resolved
// value. `JobHandler` is
// `(context) => Promise<void | JobRunOutcome>`
// and all three shipped adapters map a
// resolved `{ outcome: 'degraded', reason }`
// onto a `sys_job_run.status` distinct from
// `success` (#6617/#5548). A block-bodied
// arrow that only awaited made this wrapper
// a `Promise<void>`, so the third outcome
// was unreachable from `defineJob`: a job
// that ran to completion while its work did
// not happen was recorded as `success` with
// `reason` dropped, and the three-outcome
// table in `content/docs/automation/jobs.mdx`
// was false on the declarative door.
// A handler that resolves `undefined` — every
// handler written before #6617 — still returns
// `undefined` here, which is the `success`
// branch exactly as before.
return await handler(jobContext);
},
// #3494: thread the authored retryPolicy/timeout to the adapter
(job.retryPolicy || job.timeout)
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
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
42 changes: 42 additions & 0 deletions .changeset/declarative-job-run-outcome.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
---
'@objectstack/runtime': patch
---

fix(runtime): a declarative job's `JobRunOutcome` reaches the adapter that records it (#14256)

`AppPlugin`'s declarative-job registration block handed `IJobService.schedule` a
block-bodied arrow that *awaited* the bundle handler and returned nothing, so
the wrapper was a `Promise<void>` whatever the handler resolved. Measured
through an `IJobService` typed only at the contract:

```
HANDLER RESOLVED: {"outcome":"degraded","reason":"STORE_UNAVAILABLE"}
WRAPPER RESOLVED: undefined
```

#6617's third outcome was therefore unreachable from `defineJob`. All three
shipped adapters (`cron-job-adapter`, `interval-job-adapter`, `db-job-adapter`)
map a resolved `{ outcome: 'degraded', reason }` onto a run status distinct from
`success` — they simply never got the value on this path, so a declarative job
that ran to completion while its work did not happen (store unavailable, zero
rows matched) was recorded as `success` with `reason` dropped. The three-outcome
table in `content/docs/automation/jobs.mdx` — the page whose whole subject is
the declarative door — was false on exactly that door. The imperative route (a
handler registered straight on `IJobService.schedule`) was unaffected
throughout; the wrapper is the whole defect.

The repair is to return the handler's resolved value. Patch rather than minor:
no export, type, schema or authoring surface changes, and `JobHandler` has
declared `Promise<void | JobRunOutcome>` since #6617 — this makes the declared
contract true on a route where it was not. Additive in the ruling's sense: a
handler resolving `undefined` (every handler written before #6617) still
resolves `undefined` through the wrapper and still takes the `success` branch.
The one visible change for an existing app is the correction itself — a
declarative handler that *was already* resolving `{ outcome: 'degraded' }` now
lands `sys_job_run.status: 'degraded'` (reason in `error`, `failure_count` flat,
still never retried) where it previously landed `success`.

Pinned by `packages/runtime/src/app-plugin.job-degraded-outcome.test.ts`, which
drives the real `DbJobAdapter` over a real ObjectQL engine carrying the real
`sys_job` / `sys_job_run` declarations and asserts the persisted cell, not the
wrapper's return value.
305 changes: 305 additions & 0 deletions packages/runtime/src/app-plugin.job-degraded-outcome.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,305 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* #14256 — a DECLARATIVE job's `JobRunOutcome` must reach the adapter that
* records it.
*
* ## What was measured before the fix
*
* `AppPlugin`'s declarative-job registration block handed `IJobService.schedule`
* a block-bodied arrow that *awaited* the bundle handler and returned nothing,
* so the wrapper was a `Promise<void>` whatever the handler resolved. The
* reporter's probe, driven through an `IJobService` typed only at the contract:
*
* ```
* HANDLER RESOLVED: {"outcome":"degraded","reason":"STORE_UNAVAILABLE"}
* WRAPPER RESOLVED: undefined
* ```
*
* #6617's third outcome was therefore unreachable from `defineJob`: a job that
* ran to completion while its work did not happen (store unavailable, zero rows
* matched) was recorded as `success`, with `reason` dropped. The imperative
* route — a handler registered straight on `IJobService.schedule` — was
* unaffected the whole time, which is exactly what localises the defect to this
* wrapper.
*
* ## What these cases assert, and why it is not the wrapper's return value
*
* The deliverable named on the card is **the value that lands in the row**:
* `sys_job_run.status` distinct from `success`, driven through `DbJobAdapter`,
* the adapter that records it. A case asserting only that the wrapper returns
* what the handler returned re-states the one-expression repair; the
* consequence — what gets RECORDED — is what
* `content/docs/automation/jobs.mdx`'s three-outcome table promises a
* declarative author and what the declarative door did not deliver. So the
* primary assertions read the persisted cell, and the wrapper's own resolved
* value is pinned only as the corroborating middle term.
*
* ## The rig: a real engine, real `sys_job*` declarations, the real adapter
*
* `DbJobAdapter` writes `sys_job` / `sys_job_run` through ObjectQL, and
* `sys_job_run.status` is an *enforced* `Field.select` whose vocabulary carries
* `degraded` (#7072). Driving the real engine over the migrated sqlite
* `:memory:` backend (#5704) therefore proves two things a recording double
* cannot: the status the adapter writes is a value the record validator
* accepts, and it is the value a reader of the row gets back. Nothing here is
* about any one driver's behaviour, and this file is not on
* `scripts/driver-memory-census.ledger.json` — ⛔ do not "simplify" it onto
* `@objectstack/driver-memory` (#6664).
*/

import { describe, it, expect, vi, afterEach } from 'vitest';
import type { PluginContext } from '@objectstack/core';
import type { IJobService, JobHandler, JobRunOutcome, JobSchedule } from '@objectstack/spec/contracts';
import { defineJob } from '@objectstack/spec/system';
import { ObjectQL } from '@objectstack/objectql';
import { SqlDriver } from '@objectstack/driver-sql';
import { DbJobAdapter } from '@objectstack/service-job';
import { SysJob, SysJobRun } from '@objectstack/platform-objects/audit';
import { AppPlugin } from './app-plugin.js';
import type { JobHandlerContext } from './job-handler-context.js';

/** The reason a #5529-shaped handler reports when its store is unreachable. */
const REASON = 'STORE_UNAVAILABLE';

interface Harness {
engine: ObjectQL;
adapter: DbJobAdapter;
ctx: PluginContext;
fireReady: () => Promise<void>;
errorLogs: () => string[];
warnLogs: () => string[];
runRows: () => Promise<Array<Record<string, unknown>>>;
jobRow: (name: string) => Promise<Record<string, unknown> | undefined>;
}

const live: Array<{
engine?: ObjectQL;
adapter?: DbJobAdapter;
driver?: SqlDriver;
}> = [];

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 */ }
}
});

/**
* A real engine over the migrated test backend, carrying the REAL `sys_job*`.
*
* ⚠️ [#10629] No expected-read-refusal capture here, deliberately and by
* MEASUREMENT: every read this file performs carries `isSystem`, so the
* engine's single-tenant probe over the unprovisioned `sys_organization` never
* fires and neither refusal channel emits a frame (checked on the red run: zero
* `refused a read on` and zero `Find operation failed` lines for the whole
* file). Installing a capture that nothing provokes would assert a mute.
*/
async function bootEngine(): Promise<{ engine: ObjectQL; driver: SqlDriver }> {
const driver = new SqlDriver({
client: 'better-sqlite3',
connection: { filename: ':memory:' },
useNullAsDefault: true,
});
await driver.initObjects([SysJob as never, SysJobRun as never]);
const engine = new ObjectQL();
engine.registerDriver(driver as never, true);
await engine.init();
engine.registry.registerObject(SysJob as never);
engine.registry.registerObject(SysJobRun as never);
return { engine, driver };
}

async function harness(): Promise<Harness> {
const { engine, driver } = await bootEngine();
// The adapter that RECORDS the outcome — the coordinate the card names.
const adapter = new DbJobAdapter({ engine: engine as never });
live.push({ engine, adapter, driver });

const readyHooks: Array<() => Promise<void>> = [];
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<void>) => {
if (event === 'kernel:ready') readyHooks.push(cb);
}),
trigger: vi.fn(),
} as unknown as PluginContext;

const rows = async (table: string, where?: Record<string, unknown>) => {
const found = await engine.find(table, {
...(where ? { where } : {}),
context: { isSystem: true, positions: [], permissions: [] },
} as never);
return Array.isArray(found) ? found as Array<Record<string, unknown>> : [];
};

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])),
runRows: () => rows('sys_job_run'),
jobRow: async (name: string) => (await rows('sys_job', { name }))[0],
};
}

/**
* The #5529 specimen as a DECLARATIVE job handler: it fires its shot at an
* unreachable store, completes normally — a throw is the retry signal, which is
* the wrong report — and says so by resolving the third outcome.
*/
async function degradingHandler(_jobCtx: JobHandlerContext): Promise<JobRunOutcome> {
return { outcome: 'degraded', reason: REASON };
}

/** A pre-#6617 handler: resolves nothing, means `success`. */
async function silentHandler(_jobCtx: JobHandlerContext): Promise<void> {
/* did its work, reports nothing */
}

function stackWith(jobName: string, handlerKey: string, fn: unknown) {
return {
id: 'com.test.job-degraded-outcome',
jobs: [defineJob({
name: jobName,
schedule: { type: 'cron', expression: '0 1 * * *' },
handler: handlerKey,
})],
functions: { [handlerKey]: fn },
};
}

describe('#14256 — a declarative job\'s degraded outcome reaches `sys_job_run`', () => {
it('lands `sys_job_run.status` distinct from `success`, with the reason in `error`', async () => {
const h = await harness();
const plugin = new AppPlugin(stackWith('wake_sweep', 'wake', degradingHandler));

await plugin.start!(h.ctx);
await h.fireReady();
expect(await h.adapter.listJobs()).toContain('wake_sweep');

// Run it the way the scheduler runs it — through the adapter, never by
// calling the handler directly.
await h.adapter.trigger('wake_sweep');

const runs = await h.runRows();
expect(runs).toHaveLength(1);
// The card's assertion, in its own words: a status DISTINCT from
// `success`. Spelled as the inequality first, because that is the
// contract (`spec/contracts/job-service.ts`), then as the value the
// shipped adapters agree on.
expect(runs[0].status).not.toBe('success');
expect(runs[0].status).toBe('degraded');
expect(runs[0].error).toBe(REASON);
expect(runs[0].job_name).toBe('wake_sweep');
expect(h.errorLogs()).toEqual([]);
});

it('mirrors onto `sys_job.last_status` and leaves `failure_count` flat — degraded is not a failure', async () => {
const h = await harness();
const plugin = new AppPlugin(stackWith('wake_sweep', 'wake', degradingHandler));
await plugin.start!(h.ctx);
await h.fireReady();
await h.adapter.trigger('wake_sweep');

const job = await h.jobRow('wake_sweep');
expect(job?.last_status).toBe('degraded');
expect(job?.last_error).toBe(REASON);
// `degraded` never retries and never alerts (#5548 / #7072).
expect(job?.failure_count).toBe(0);
expect(job?.run_count).toBe(1);
});

it('carries the outcome for the `{ handler, effect }` function form too', async () => {
// The shape a declared-effect entry takes (#4396) — the same route, one
// more indirection through `collectBundleFunctions`.
const h = await harness();
const plugin = new AppPlugin(
stackWith('wake_sweep', 'wake', { handler: degradingHandler, effect: 'writes' }),
);
await plugin.start!(h.ctx);
await h.fireReady();
await h.adapter.trigger('wake_sweep');

const runs = await h.runRows();
expect(runs[0].status).toBe('degraded');
expect(runs[0].error).toBe(REASON);
});
});

describe('#14256 — the controls that keep the assertion honest', () => {
it('CONTROL: the IMPERATIVE route already recorded it — the defect was the wrapper, not the adapter', async () => {
// Registered straight on `IJobService.schedule`, with no AppPlugin in
// the path. This case passed before the fix and passes after it, so a
// red on the declarative cases above localises to the wrapper rather
// than to `DbJobAdapter` or the `sys_job_run` write.
const h = await harness();
await h.adapter.schedule(
'imperative_wake',
{ type: 'cron', expression: '0 1 * * *' },
async (): Promise<JobRunOutcome> => ({ outcome: 'degraded', reason: REASON }),
);
await h.adapter.trigger('imperative_wake');

const runs = await h.runRows();
expect(runs[0].status).toBe('degraded');
expect(runs[0].error).toBe(REASON);
});

it('CONTROL: a job whose handler reports nothing still lands `success` — the additivity clause', async () => {
// The compatibility promise #6617 owes, on the exact shape every handler
// written before it has. It also proves the degraded assertion above is
// not passing because the rig writes `degraded` unconditionally.
const h = await harness();
const plugin = new AppPlugin(stackWith('quiet_sweep', 'quiet', silentHandler));
await plugin.start!(h.ctx);
await h.fireReady();
await h.adapter.trigger('quiet_sweep');

const runs = await h.runRows();
expect(runs).toHaveLength(1);
expect(runs[0].status).toBe('success');
expect(runs[0].error).toBeNull();
expect((await h.jobRow('quiet_sweep'))?.last_status).toBe('success');
});

it('the middle term: the wrapper AppPlugin hands `schedule` resolves the handler\'s outcome', async () => {
// The reporter's probe, kept as corroboration and NOT as the deliverable:
// it re-states the repair, while the cases above pin its consequence.
// Typed only at the contract, exactly as a third-party `IJobService` is.
const h = await harness();
const scheduled: Array<{ name: string; handler: JobHandler }> = [];
const recording: IJobService = {
async schedule(name: string, _schedule: JobSchedule, handler: JobHandler) {
scheduled.push({ name, handler });
},
async cancel() { /* noop */ },
async trigger() { /* noop */ },
};
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;
});

const plugin = new AppPlugin(stackWith('probe_wake', 'wake', degradingHandler));
await plugin.start!(h.ctx);
await h.fireReady();

expect(scheduled.map(s => s.name)).toEqual(['probe_wake']);
// `WRAPPER RESOLVED: undefined` was the measurement on the card.
const resolved = await scheduled[0].handler({ jobId: 'probe_wake' });
expect(resolved).toEqual({ outcome: 'degraded', reason: REASON });
});
});
21 changes: 20 additions & 1 deletion packages/runtime/src/app-plugin.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -1033,7 +1033,26 @@ export class AppPlugin implements Plugin {
ql,
logger: ctx.logger,
};
await handler(jobContext);
// #14256: RETURN the handler's resolved
// value. `JobHandler` is
// `(context) => Promise<void | JobRunOutcome>`
// and all three shipped adapters map a
// resolved `{ outcome: 'degraded', reason }`
// onto a `sys_job_run.status` distinct from
// `success` (#6617/#5548). A block-bodied
// arrow that only awaited made this wrapper
// a `Promise<void>`, so the third outcome
// was unreachable from `defineJob`: a job
// that ran to completion while its work did
// not happen was recorded as `success` with
// `reason` dropped, and the three-outcome
// table in `content/docs/automation/jobs.mdx`
// was false on the declarative door.
// A handler that resolves `undefined` — every
// handler written before #6617 — still returns
// `undefined` here, which is the `success`
// branch exactly as before.
return await handler(jobContext);
},
// #3494: thread the authored retryPolicy/timeout to the adapter
(job.retryPolicy || job.timeout)
Expand Down
Loading