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
51 changes: 51 additions & 0 deletions .changeset/job-handler-data-reach.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
---
"@objectstack/runtime": minor
---

feat(runtime): give a declarative job's handler data reach (#14094)

`defineJob` is the platform's only metadata shape for scheduled work. Its
handler resolves out of `defineStack({ functions })`, and until now `AppPlugin`
invoked it with `{ jobId, data, bundle }` — no engine, no logger, nothing to
write with. The job registered, appeared in the admin UI, was scheduled, ran on
time, and did nothing. `objectstack validate` passed and no author-time gate
said otherwise.

The context `AppPlugin` builds now also carries:

- **`ql`** — the live ObjectQL engine, the same handle `defineStack({ onEnable })`
receives as `ctx.ql`;
- **`logger`** — the plugin `Logger`, so a job's diagnostics land in the
platform's log stream instead of `console`.

`JobHandlerContext` is exported from `@objectstack/runtime` for handlers that
want to annotate their argument.

**Why a job and a flow `script` node differ here.** `FlowFunctionContext` also
carries no engine, and that is coherent for a flow function: the flow graph does
the I/O around it (`get_record` before, `create_record` after), which is what
the per-run write metrics count. A job has no graph — no node before it, none
after — so the same emptiness leaves it unable to do the one thing scheduled
work exists for. Nothing about a `script` node's contract changes.

**Why the module-scope escape was not documented instead.** Closing over a
client bound by `onEnable` does not survive the shipped deployment path:
`objectstack build` emits `functions` into a sibling runtime module exporting
only `{ functions, meta }`, the artifact JSON carries no `onEnable`, and
`mergeRuntimeModule` merges only `functions` — so on an artifact-served boot the
binding is never made and the handler runs against an empty slot, silently. The
regression suite therefore proves the write on **both** boot paths: the
TS-config path and a real artifact loaded through `loadArtifactBundle`.

**Additive.** This widens the object passed to the handler, the same shape
`JobRunOutcome` took. `IJobService`'s
`JobHandler = (context: { jobId: string; data?: unknown }) => …` is untouched —
the function `AppPlugin` hands to `IJobService.schedule` is a wrapper that
satisfies it exactly, and the new members are added inside that wrapper. An
existing handler that destructures `{ jobId }` or `{ jobId, data }` is unchanged
byte for byte, and no `IJobService` implementation grows a member.

The kernel's `getService` was considered and deliberately not added: it would
put the whole service registry on a job's context permanently, and the measured
population of shipped `defineJob` handlers needs the engine and nothing else.
Adding it later is additive by exactly this argument, so nothing is foreclosed.
44 changes: 42 additions & 2 deletions content/docs/automation/jobs.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -122,8 +122,48 @@ a long time with no function of that name anywhere in the app was skipped at
every boot, and the sweep never ran. If a job appears to do nothing, read the
boot log for its name before reading its schedule.

At run time the handler is invoked with `{ jobId, data }`. What it returns
decides how the run is recorded:
## What the handler is given

At run time the handler is invoked with a `JobHandlerContext`
(`@objectstack/runtime`):

| Member | What it is |
|:---|:---|
| `jobId` | the job's `name` — its identity everywhere |
| `data` | the payload of a manual `trigger(name, data)` run; absent on a scheduled run |
| `bundle` | the application's metadata bundle — declarations, not a data handle |
| `ql` | the live ObjectQL engine — the same handle `defineStack({ onEnable })` receives |
| `logger` | the platform logger, so a job's diagnostics are not `console` output |

```typescript
import type { JobHandlerContext } from '@objectstack/runtime';

export async function sweepProjectHealth({ ql, jobId, logger }: JobHandlerContext) {
const stale = await ql.find('project', { where: { status: 'active' } });
for (const p of stale) await ql.update('project', { id: p.id, health: 'green' });
logger.info('sweep complete', { job: jobId, scanned: stale.length });
}
```

`ql` is why a job does **not** follow the pure-function rule a flow `script`
node follows. A `script` node returns a value and the flow graph does the I/O
around it — a `get_record` before, a `create_record` after — so its context
deliberately carries no engine. A job has no graph: no node before it, none
after. Writing records on a timer is the thing scheduled work exists for, so
the handler is handed the engine directly. A job's writes are still not counted
by any caller, so declare its `functions` entry `effect: 'writes'` — that makes
the run report "cannot say" rather than silently claiming it wrote nothing.

<Callout type="warn">
Do **not** reach the engine by having `onEnable` assign a module-scope global
the handler reads later. That binding does not survive a built artifact:
`objectstack build` emits your `functions` into a sibling runtime module, the
artifact JSON carries no `onEnable`, and only `functions` is merged back on
load — so on an artifact-served boot the global is never assigned and the job
runs against nothing, silently. Take `ql` from the context.
</Callout>

What the handler returns decides how the run is recorded:

| The handler… | Recorded as | Retried? |
|:---|:---|:---|
Expand Down
Loading
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
51 changes: 51 additions & 0 deletions .changeset/job-handler-data-reach.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
---
"@objectstack/runtime": minor
---

feat(runtime): give a declarative job's handler data reach (#14094)

`defineJob` is the platform's only metadata shape for scheduled work. Its
handler resolves out of `defineStack({ functions })`, and until now `AppPlugin`
invoked it with `{ jobId, data, bundle }` — no engine, no logger, nothing to
write with. The job registered, appeared in the admin UI, was scheduled, ran on
time, and did nothing. `objectstack validate` passed and no author-time gate
said otherwise.

The context `AppPlugin` builds now also carries:

- **`ql`** — the live ObjectQL engine, the same handle `defineStack({ onEnable })`
receives as `ctx.ql`;
- **`logger`** — the plugin `Logger`, so a job's diagnostics land in the
platform's log stream instead of `console`.

`JobHandlerContext` is exported from `@objectstack/runtime` for handlers that
want to annotate their argument.

**Why a job and a flow `script` node differ here.** `FlowFunctionContext` also
carries no engine, and that is coherent for a flow function: the flow graph does
the I/O around it (`get_record` before, `create_record` after), which is what
the per-run write metrics count. A job has no graph — no node before it, none
after — so the same emptiness leaves it unable to do the one thing scheduled
work exists for. Nothing about a `script` node's contract changes.

**Why the module-scope escape was not documented instead.** Closing over a
client bound by `onEnable` does not survive the shipped deployment path:
`objectstack build` emits `functions` into a sibling runtime module exporting
only `{ functions, meta }`, the artifact JSON carries no `onEnable`, and
`mergeRuntimeModule` merges only `functions` — so on an artifact-served boot the
binding is never made and the handler runs against an empty slot, silently. The
regression suite therefore proves the write on **both** boot paths: the
TS-config path and a real artifact loaded through `loadArtifactBundle`.

**Additive.** This widens the object passed to the handler, the same shape
`JobRunOutcome` took. `IJobService`'s
`JobHandler = (context: { jobId: string; data?: unknown }) => …` is untouched —
the function `AppPlugin` hands to `IJobService.schedule` is a wrapper that
satisfies it exactly, and the new members are added inside that wrapper. An
existing handler that destructures `{ jobId }` or `{ jobId, data }` is unchanged
byte for byte, and no `IJobService` implementation grows a member.

The kernel's `getService` was considered and deliberately not added: it would
put the whole service registry on a job's context permanently, and the measured
population of shipped `defineJob` handlers needs the engine and nothing else.
Adding it later is additive by exactly this argument, so nothing is foreclosed.
44 changes: 42 additions & 2 deletions content/docs/automation/jobs.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -122,8 +122,48 @@ a long time with no function of that name anywhere in the app was skipped at
every boot, and the sweep never ran. If a job appears to do nothing, read the
boot log for its name before reading its schedule.

At run time the handler is invoked with `{ jobId, data }`. What it returns
decides how the run is recorded:
## What the handler is given

At run time the handler is invoked with a `JobHandlerContext`
(`@objectstack/runtime`):

| Member | What it is |
|:---|:---|
| `jobId` | the job's `name` — its identity everywhere |
| `data` | the payload of a manual `trigger(name, data)` run; absent on a scheduled run |
| `bundle` | the application's metadata bundle — declarations, not a data handle |
| `ql` | the live ObjectQL engine — the same handle `defineStack({ onEnable })` receives |
| `logger` | the platform logger, so a job's diagnostics are not `console` output |

```typescript
import type { JobHandlerContext } from '@objectstack/runtime';

export async function sweepProjectHealth({ ql, jobId, logger }: JobHandlerContext) {
const stale = await ql.find('project', { where: { status: 'active' } });
for (const p of stale) await ql.update('project', { id: p.id, health: 'green' });
logger.info('sweep complete', { job: jobId, scanned: stale.length });
}
```

`ql` is why a job does **not** follow the pure-function rule a flow `script`
node follows. A `script` node returns a value and the flow graph does the I/O
around it — a `get_record` before, a `create_record` after — so its context
deliberately carries no engine. A job has no graph: no node before it, none
after. Writing records on a timer is the thing scheduled work exists for, so
the handler is handed the engine directly. A job's writes are still not counted
by any caller, so declare its `functions` entry `effect: 'writes'` — that makes
the run report "cannot say" rather than silently claiming it wrote nothing.

<Callout type="warn">
Do **not** reach the engine by having `onEnable` assign a module-scope global
the handler reads later. That binding does not survive a built artifact:
`objectstack build` emits your `functions` into a sibling runtime module, the
artifact JSON carries no `onEnable`, and only `functions` is merged back on
load — so on an artifact-served boot the global is never assigned and the job
runs against nothing, silently. Take `ql` from the context.
</Callout>

What the handler returns decides how the run is recorded:

| The handler… | Recorded as | Retried? |
|:---|:---|:---|
Expand Down
Loading
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
51 changes: 51 additions & 0 deletions .changeset/job-handler-data-reach.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
---
"@objectstack/runtime": minor
---

feat(runtime): give a declarative job's handler data reach (#14094)

`defineJob` is the platform's only metadata shape for scheduled work. Its
handler resolves out of `defineStack({ functions })`, and until now `AppPlugin`
invoked it with `{ jobId, data, bundle }` — no engine, no logger, nothing to
write with. The job registered, appeared in the admin UI, was scheduled, ran on
time, and did nothing. `objectstack validate` passed and no author-time gate
said otherwise.

The context `AppPlugin` builds now also carries:

- **`ql`** — the live ObjectQL engine, the same handle `defineStack({ onEnable })`
receives as `ctx.ql`;
- **`logger`** — the plugin `Logger`, so a job's diagnostics land in the
platform's log stream instead of `console`.

`JobHandlerContext` is exported from `@objectstack/runtime` for handlers that
want to annotate their argument.

**Why a job and a flow `script` node differ here.** `FlowFunctionContext` also
carries no engine, and that is coherent for a flow function: the flow graph does
the I/O around it (`get_record` before, `create_record` after), which is what
the per-run write metrics count. A job has no graph — no node before it, none
after — so the same emptiness leaves it unable to do the one thing scheduled
work exists for. Nothing about a `script` node's contract changes.

**Why the module-scope escape was not documented instead.** Closing over a
client bound by `onEnable` does not survive the shipped deployment path:
`objectstack build` emits `functions` into a sibling runtime module exporting
only `{ functions, meta }`, the artifact JSON carries no `onEnable`, and
`mergeRuntimeModule` merges only `functions` — so on an artifact-served boot the
binding is never made and the handler runs against an empty slot, silently. The
regression suite therefore proves the write on **both** boot paths: the
TS-config path and a real artifact loaded through `loadArtifactBundle`.

**Additive.** This widens the object passed to the handler, the same shape
`JobRunOutcome` took. `IJobService`'s
`JobHandler = (context: { jobId: string; data?: unknown }) => …` is untouched —
the function `AppPlugin` hands to `IJobService.schedule` is a wrapper that
satisfies it exactly, and the new members are added inside that wrapper. An
existing handler that destructures `{ jobId }` or `{ jobId, data }` is unchanged
byte for byte, and no `IJobService` implementation grows a member.

The kernel's `getService` was considered and deliberately not added: it would
put the whole service registry on a job's context permanently, and the measured
population of shipped `defineJob` handlers needs the engine and nothing else.
Adding it later is additive by exactly this argument, so nothing is foreclosed.
44 changes: 42 additions & 2 deletions content/docs/automation/jobs.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -122,8 +122,48 @@ a long time with no function of that name anywhere in the app was skipped at
every boot, and the sweep never ran. If a job appears to do nothing, read the
boot log for its name before reading its schedule.

At run time the handler is invoked with `{ jobId, data }`. What it returns
decides how the run is recorded:
## What the handler is given

At run time the handler is invoked with a `JobHandlerContext`
(`@objectstack/runtime`):

| Member | What it is |
|:---|:---|
| `jobId` | the job's `name` — its identity everywhere |
| `data` | the payload of a manual `trigger(name, data)` run; absent on a scheduled run |
| `bundle` | the application's metadata bundle — declarations, not a data handle |
| `ql` | the live ObjectQL engine — the same handle `defineStack({ onEnable })` receives |
| `logger` | the platform logger, so a job's diagnostics are not `console` output |

```typescript
import type { JobHandlerContext } from '@objectstack/runtime';

export async function sweepProjectHealth({ ql, jobId, logger }: JobHandlerContext) {
const stale = await ql.find('project', { where: { status: 'active' } });
for (const p of stale) await ql.update('project', { id: p.id, health: 'green' });
logger.info('sweep complete', { job: jobId, scanned: stale.length });
}
```

`ql` is why a job does **not** follow the pure-function rule a flow `script`
node follows. A `script` node returns a value and the flow graph does the I/O
around it — a `get_record` before, a `create_record` after — so its context
deliberately carries no engine. A job has no graph: no node before it, none
after. Writing records on a timer is the thing scheduled work exists for, so
the handler is handed the engine directly. A job's writes are still not counted
by any caller, so declare its `functions` entry `effect: 'writes'` — that makes
the run report "cannot say" rather than silently claiming it wrote nothing.

<Callout type="warn">
Do **not** reach the engine by having `onEnable` assign a module-scope global
the handler reads later. That binding does not survive a built artifact:
`objectstack build` emits your `functions` into a sibling runtime module, the
artifact JSON carries no `onEnable`, and only `functions` is merged back on
load — so on an artifact-served boot the global is never assigned and the job
runs against nothing, silently. Take `ql` from the context.
</Callout>

What the handler returns decides how the run is recorded:

| The handler… | Recorded as | Retried? |
|:---|:---|:---|
Expand Down
Loading
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
51 changes: 51 additions & 0 deletions .changeset/job-handler-data-reach.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
---
"@objectstack/runtime": minor
---

feat(runtime): give a declarative job's handler data reach (#14094)

`defineJob` is the platform's only metadata shape for scheduled work. Its
handler resolves out of `defineStack({ functions })`, and until now `AppPlugin`
invoked it with `{ jobId, data, bundle }` — no engine, no logger, nothing to
write with. The job registered, appeared in the admin UI, was scheduled, ran on
time, and did nothing. `objectstack validate` passed and no author-time gate
said otherwise.

The context `AppPlugin` builds now also carries:

- **`ql`** — the live ObjectQL engine, the same handle `defineStack({ onEnable })`
receives as `ctx.ql`;
- **`logger`** — the plugin `Logger`, so a job's diagnostics land in the
platform's log stream instead of `console`.

`JobHandlerContext` is exported from `@objectstack/runtime` for handlers that
want to annotate their argument.

**Why a job and a flow `script` node differ here.** `FlowFunctionContext` also
carries no engine, and that is coherent for a flow function: the flow graph does
the I/O around it (`get_record` before, `create_record` after), which is what
the per-run write metrics count. A job has no graph — no node before it, none
after — so the same emptiness leaves it unable to do the one thing scheduled
work exists for. Nothing about a `script` node's contract changes.

**Why the module-scope escape was not documented instead.** Closing over a
client bound by `onEnable` does not survive the shipped deployment path:
`objectstack build` emits `functions` into a sibling runtime module exporting
only `{ functions, meta }`, the artifact JSON carries no `onEnable`, and
`mergeRuntimeModule` merges only `functions` — so on an artifact-served boot the
binding is never made and the handler runs against an empty slot, silently. The
regression suite therefore proves the write on **both** boot paths: the
TS-config path and a real artifact loaded through `loadArtifactBundle`.

**Additive.** This widens the object passed to the handler, the same shape
`JobRunOutcome` took. `IJobService`'s
`JobHandler = (context: { jobId: string; data?: unknown }) => …` is untouched —
the function `AppPlugin` hands to `IJobService.schedule` is a wrapper that
satisfies it exactly, and the new members are added inside that wrapper. An
existing handler that destructures `{ jobId }` or `{ jobId, data }` is unchanged
byte for byte, and no `IJobService` implementation grows a member.

The kernel's `getService` was considered and deliberately not added: it would
put the whole service registry on a job's context permanently, and the measured
population of shipped `defineJob` handlers needs the engine and nothing else.
Adding it later is additive by exactly this argument, so nothing is foreclosed.
44 changes: 42 additions & 2 deletions content/docs/automation/jobs.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -122,8 +122,48 @@ a long time with no function of that name anywhere in the app was skipped at
every boot, and the sweep never ran. If a job appears to do nothing, read the
boot log for its name before reading its schedule.

At run time the handler is invoked with `{ jobId, data }`. What it returns
decides how the run is recorded:
## What the handler is given

At run time the handler is invoked with a `JobHandlerContext`
(`@objectstack/runtime`):

| Member | What it is |
|:---|:---|
| `jobId` | the job's `name` — its identity everywhere |
| `data` | the payload of a manual `trigger(name, data)` run; absent on a scheduled run |
| `bundle` | the application's metadata bundle — declarations, not a data handle |
| `ql` | the live ObjectQL engine — the same handle `defineStack({ onEnable })` receives |
| `logger` | the platform logger, so a job's diagnostics are not `console` output |

```typescript
import type { JobHandlerContext } from '@objectstack/runtime';

export async function sweepProjectHealth({ ql, jobId, logger }: JobHandlerContext) {
const stale = await ql.find('project', { where: { status: 'active' } });
for (const p of stale) await ql.update('project', { id: p.id, health: 'green' });
logger.info('sweep complete', { job: jobId, scanned: stale.length });
}
```

`ql` is why a job does **not** follow the pure-function rule a flow `script`
node follows. A `script` node returns a value and the flow graph does the I/O
around it — a `get_record` before, a `create_record` after — so its context
deliberately carries no engine. A job has no graph: no node before it, none
after. Writing records on a timer is the thing scheduled work exists for, so
the handler is handed the engine directly. A job's writes are still not counted
by any caller, so declare its `functions` entry `effect: 'writes'` — that makes
the run report "cannot say" rather than silently claiming it wrote nothing.

<Callout type="warn">
Do **not** reach the engine by having `onEnable` assign a module-scope global
the handler reads later. That binding does not survive a built artifact:
`objectstack build` emits your `functions` into a sibling runtime module, the
artifact JSON carries no `onEnable`, and only `functions` is merged back on
load — so on an artifact-served boot the global is never assigned and the job
runs against nothing, silently. Take `ql` from the context.
</Callout>

What the handler returns decides how the run is recorded:

| The handler… | Recorded as | Retried? |
|:---|:---|:---|
Expand Down
Loading
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
51 changes: 51 additions & 0 deletions .changeset/job-handler-data-reach.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
---
"@objectstack/runtime": minor
---

feat(runtime): give a declarative job's handler data reach (#14094)

`defineJob` is the platform's only metadata shape for scheduled work. Its
handler resolves out of `defineStack({ functions })`, and until now `AppPlugin`
invoked it with `{ jobId, data, bundle }` — no engine, no logger, nothing to
write with. The job registered, appeared in the admin UI, was scheduled, ran on
time, and did nothing. `objectstack validate` passed and no author-time gate
said otherwise.

The context `AppPlugin` builds now also carries:

- **`ql`** — the live ObjectQL engine, the same handle `defineStack({ onEnable })`
receives as `ctx.ql`;
- **`logger`** — the plugin `Logger`, so a job's diagnostics land in the
platform's log stream instead of `console`.

`JobHandlerContext` is exported from `@objectstack/runtime` for handlers that
want to annotate their argument.

**Why a job and a flow `script` node differ here.** `FlowFunctionContext` also
carries no engine, and that is coherent for a flow function: the flow graph does
the I/O around it (`get_record` before, `create_record` after), which is what
the per-run write metrics count. A job has no graph — no node before it, none
after — so the same emptiness leaves it unable to do the one thing scheduled
work exists for. Nothing about a `script` node's contract changes.

**Why the module-scope escape was not documented instead.** Closing over a
client bound by `onEnable` does not survive the shipped deployment path:
`objectstack build` emits `functions` into a sibling runtime module exporting
only `{ functions, meta }`, the artifact JSON carries no `onEnable`, and
`mergeRuntimeModule` merges only `functions` — so on an artifact-served boot the
binding is never made and the handler runs against an empty slot, silently. The
regression suite therefore proves the write on **both** boot paths: the
TS-config path and a real artifact loaded through `loadArtifactBundle`.

**Additive.** This widens the object passed to the handler, the same shape
`JobRunOutcome` took. `IJobService`'s
`JobHandler = (context: { jobId: string; data?: unknown }) => …` is untouched —
the function `AppPlugin` hands to `IJobService.schedule` is a wrapper that
satisfies it exactly, and the new members are added inside that wrapper. An
existing handler that destructures `{ jobId }` or `{ jobId, data }` is unchanged
byte for byte, and no `IJobService` implementation grows a member.

The kernel's `getService` was considered and deliberately not added: it would
put the whole service registry on a job's context permanently, and the measured
population of shipped `defineJob` handlers needs the engine and nothing else.
Adding it later is additive by exactly this argument, so nothing is foreclosed.
44 changes: 42 additions & 2 deletions content/docs/automation/jobs.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -122,8 +122,48 @@ a long time with no function of that name anywhere in the app was skipped at
every boot, and the sweep never ran. If a job appears to do nothing, read the
boot log for its name before reading its schedule.

At run time the handler is invoked with `{ jobId, data }`. What it returns
decides how the run is recorded:
## What the handler is given

At run time the handler is invoked with a `JobHandlerContext`
(`@objectstack/runtime`):

| Member | What it is |
|:---|:---|
| `jobId` | the job's `name` — its identity everywhere |
| `data` | the payload of a manual `trigger(name, data)` run; absent on a scheduled run |
| `bundle` | the application's metadata bundle — declarations, not a data handle |
| `ql` | the live ObjectQL engine — the same handle `defineStack({ onEnable })` receives |
| `logger` | the platform logger, so a job's diagnostics are not `console` output |

```typescript
import type { JobHandlerContext } from '@objectstack/runtime';

export async function sweepProjectHealth({ ql, jobId, logger }: JobHandlerContext) {
const stale = await ql.find('project', { where: { status: 'active' } });
for (const p of stale) await ql.update('project', { id: p.id, health: 'green' });
logger.info('sweep complete', { job: jobId, scanned: stale.length });
}
```

`ql` is why a job does **not** follow the pure-function rule a flow `script`
node follows. A `script` node returns a value and the flow graph does the I/O
around it — a `get_record` before, a `create_record` after — so its context
deliberately carries no engine. A job has no graph: no node before it, none
after. Writing records on a timer is the thing scheduled work exists for, so
the handler is handed the engine directly. A job's writes are still not counted
by any caller, so declare its `functions` entry `effect: 'writes'` — that makes
the run report "cannot say" rather than silently claiming it wrote nothing.

<Callout type="warn">
Do **not** reach the engine by having `onEnable` assign a module-scope global
the handler reads later. That binding does not survive a built artifact:
`objectstack build` emits your `functions` into a sibling runtime module, the
artifact JSON carries no `onEnable`, and only `functions` is merged back on
load — so on an artifact-served boot the global is never assigned and the job
runs against nothing, silently. Take `ql` from the context.
</Callout>

What the handler returns decides how the run is recorded:

| The handler… | Recorded as | Retried? |
|:---|:---|:---|
Expand Down
Loading
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
51 changes: 51 additions & 0 deletions .changeset/job-handler-data-reach.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
---
"@objectstack/runtime": minor
---

feat(runtime): give a declarative job's handler data reach (#14094)

`defineJob` is the platform's only metadata shape for scheduled work. Its
handler resolves out of `defineStack({ functions })`, and until now `AppPlugin`
invoked it with `{ jobId, data, bundle }` — no engine, no logger, nothing to
write with. The job registered, appeared in the admin UI, was scheduled, ran on
time, and did nothing. `objectstack validate` passed and no author-time gate
said otherwise.

The context `AppPlugin` builds now also carries:

- **`ql`** — the live ObjectQL engine, the same handle `defineStack({ onEnable })`
receives as `ctx.ql`;
- **`logger`** — the plugin `Logger`, so a job's diagnostics land in the
platform's log stream instead of `console`.

`JobHandlerContext` is exported from `@objectstack/runtime` for handlers that
want to annotate their argument.

**Why a job and a flow `script` node differ here.** `FlowFunctionContext` also
carries no engine, and that is coherent for a flow function: the flow graph does
the I/O around it (`get_record` before, `create_record` after), which is what
the per-run write metrics count. A job has no graph — no node before it, none
after — so the same emptiness leaves it unable to do the one thing scheduled
work exists for. Nothing about a `script` node's contract changes.

**Why the module-scope escape was not documented instead.** Closing over a
client bound by `onEnable` does not survive the shipped deployment path:
`objectstack build` emits `functions` into a sibling runtime module exporting
only `{ functions, meta }`, the artifact JSON carries no `onEnable`, and
`mergeRuntimeModule` merges only `functions` — so on an artifact-served boot the
binding is never made and the handler runs against an empty slot, silently. The
regression suite therefore proves the write on **both** boot paths: the
TS-config path and a real artifact loaded through `loadArtifactBundle`.

**Additive.** This widens the object passed to the handler, the same shape
`JobRunOutcome` took. `IJobService`'s
`JobHandler = (context: { jobId: string; data?: unknown }) => …` is untouched —
the function `AppPlugin` hands to `IJobService.schedule` is a wrapper that
satisfies it exactly, and the new members are added inside that wrapper. An
existing handler that destructures `{ jobId }` or `{ jobId, data }` is unchanged
byte for byte, and no `IJobService` implementation grows a member.

The kernel's `getService` was considered and deliberately not added: it would
put the whole service registry on a job's context permanently, and the measured
population of shipped `defineJob` handlers needs the engine and nothing else.
Adding it later is additive by exactly this argument, so nothing is foreclosed.
44 changes: 42 additions & 2 deletions content/docs/automation/jobs.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -122,8 +122,48 @@ a long time with no function of that name anywhere in the app was skipped at
every boot, and the sweep never ran. If a job appears to do nothing, read the
boot log for its name before reading its schedule.

At run time the handler is invoked with `{ jobId, data }`. What it returns
decides how the run is recorded:
## What the handler is given

At run time the handler is invoked with a `JobHandlerContext`
(`@objectstack/runtime`):

| Member | What it is |
|:---|:---|
| `jobId` | the job's `name` — its identity everywhere |
| `data` | the payload of a manual `trigger(name, data)` run; absent on a scheduled run |
| `bundle` | the application's metadata bundle — declarations, not a data handle |
| `ql` | the live ObjectQL engine — the same handle `defineStack({ onEnable })` receives |
| `logger` | the platform logger, so a job's diagnostics are not `console` output |

```typescript
import type { JobHandlerContext } from '@objectstack/runtime';

export async function sweepProjectHealth({ ql, jobId, logger }: JobHandlerContext) {
const stale = await ql.find('project', { where: { status: 'active' } });
for (const p of stale) await ql.update('project', { id: p.id, health: 'green' });
logger.info('sweep complete', { job: jobId, scanned: stale.length });
}
```

`ql` is why a job does **not** follow the pure-function rule a flow `script`
node follows. A `script` node returns a value and the flow graph does the I/O
around it — a `get_record` before, a `create_record` after — so its context
deliberately carries no engine. A job has no graph: no node before it, none
after. Writing records on a timer is the thing scheduled work exists for, so
the handler is handed the engine directly. A job's writes are still not counted
by any caller, so declare its `functions` entry `effect: 'writes'` — that makes
the run report "cannot say" rather than silently claiming it wrote nothing.

<Callout type="warn">
Do **not** reach the engine by having `onEnable` assign a module-scope global
the handler reads later. That binding does not survive a built artifact:
`objectstack build` emits your `functions` into a sibling runtime module, the
artifact JSON carries no `onEnable`, and only `functions` is merged back on
load — so on an artifact-served boot the global is never assigned and the job
runs against nothing, silently. Take `ql` from the context.
</Callout>

What the handler returns decides how the run is recorded:

| The handler… | Recorded as | Retried? |
|:---|:---|:---|
Expand Down
Loading
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
51 changes: 51 additions & 0 deletions .changeset/job-handler-data-reach.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
---
"@objectstack/runtime": minor
---

feat(runtime): give a declarative job's handler data reach (#14094)

`defineJob` is the platform's only metadata shape for scheduled work. Its
handler resolves out of `defineStack({ functions })`, and until now `AppPlugin`
invoked it with `{ jobId, data, bundle }` — no engine, no logger, nothing to
write with. The job registered, appeared in the admin UI, was scheduled, ran on
time, and did nothing. `objectstack validate` passed and no author-time gate
said otherwise.

The context `AppPlugin` builds now also carries:

- **`ql`** — the live ObjectQL engine, the same handle `defineStack({ onEnable })`
receives as `ctx.ql`;
- **`logger`** — the plugin `Logger`, so a job's diagnostics land in the
platform's log stream instead of `console`.

`JobHandlerContext` is exported from `@objectstack/runtime` for handlers that
want to annotate their argument.

**Why a job and a flow `script` node differ here.** `FlowFunctionContext` also
carries no engine, and that is coherent for a flow function: the flow graph does
the I/O around it (`get_record` before, `create_record` after), which is what
the per-run write metrics count. A job has no graph — no node before it, none
after — so the same emptiness leaves it unable to do the one thing scheduled
work exists for. Nothing about a `script` node's contract changes.

**Why the module-scope escape was not documented instead.** Closing over a
client bound by `onEnable` does not survive the shipped deployment path:
`objectstack build` emits `functions` into a sibling runtime module exporting
only `{ functions, meta }`, the artifact JSON carries no `onEnable`, and
`mergeRuntimeModule` merges only `functions` — so on an artifact-served boot the
binding is never made and the handler runs against an empty slot, silently. The
regression suite therefore proves the write on **both** boot paths: the
TS-config path and a real artifact loaded through `loadArtifactBundle`.

**Additive.** This widens the object passed to the handler, the same shape
`JobRunOutcome` took. `IJobService`'s
`JobHandler = (context: { jobId: string; data?: unknown }) => …` is untouched —
the function `AppPlugin` hands to `IJobService.schedule` is a wrapper that
satisfies it exactly, and the new members are added inside that wrapper. An
existing handler that destructures `{ jobId }` or `{ jobId, data }` is unchanged
byte for byte, and no `IJobService` implementation grows a member.

The kernel's `getService` was considered and deliberately not added: it would
put the whole service registry on a job's context permanently, and the measured
population of shipped `defineJob` handlers needs the engine and nothing else.
Adding it later is additive by exactly this argument, so nothing is foreclosed.
44 changes: 42 additions & 2 deletions content/docs/automation/jobs.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -122,8 +122,48 @@ a long time with no function of that name anywhere in the app was skipped at
every boot, and the sweep never ran. If a job appears to do nothing, read the
boot log for its name before reading its schedule.

At run time the handler is invoked with `{ jobId, data }`. What it returns
decides how the run is recorded:
## What the handler is given

At run time the handler is invoked with a `JobHandlerContext`
(`@objectstack/runtime`):

| Member | What it is |
|:---|:---|
| `jobId` | the job's `name` — its identity everywhere |
| `data` | the payload of a manual `trigger(name, data)` run; absent on a scheduled run |
| `bundle` | the application's metadata bundle — declarations, not a data handle |
| `ql` | the live ObjectQL engine — the same handle `defineStack({ onEnable })` receives |
| `logger` | the platform logger, so a job's diagnostics are not `console` output |

```typescript
import type { JobHandlerContext } from '@objectstack/runtime';

export async function sweepProjectHealth({ ql, jobId, logger }: JobHandlerContext) {
const stale = await ql.find('project', { where: { status: 'active' } });
for (const p of stale) await ql.update('project', { id: p.id, health: 'green' });
logger.info('sweep complete', { job: jobId, scanned: stale.length });
}
```

`ql` is why a job does **not** follow the pure-function rule a flow `script`
node follows. A `script` node returns a value and the flow graph does the I/O
around it — a `get_record` before, a `create_record` after — so its context
deliberately carries no engine. A job has no graph: no node before it, none
after. Writing records on a timer is the thing scheduled work exists for, so
the handler is handed the engine directly. A job's writes are still not counted
by any caller, so declare its `functions` entry `effect: 'writes'` — that makes
the run report "cannot say" rather than silently claiming it wrote nothing.

<Callout type="warn">
Do **not** reach the engine by having `onEnable` assign a module-scope global
the handler reads later. That binding does not survive a built artifact:
`objectstack build` emits your `functions` into a sibling runtime module, the
artifact JSON carries no `onEnable`, and only `functions` is merged back on
load — so on an artifact-served boot the global is never assigned and the job
runs against nothing, silently. Take `ql` from the context.
</Callout>

What the handler returns decides how the run is recorded:

| The handler… | Recorded as | Retried? |
|:---|:---|:---|
Expand Down
Loading
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
51 changes: 51 additions & 0 deletions .changeset/job-handler-data-reach.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
---
"@objectstack/runtime": minor
---

feat(runtime): give a declarative job's handler data reach (#14094)

`defineJob` is the platform's only metadata shape for scheduled work. Its
handler resolves out of `defineStack({ functions })`, and until now `AppPlugin`
invoked it with `{ jobId, data, bundle }` — no engine, no logger, nothing to
write with. The job registered, appeared in the admin UI, was scheduled, ran on
time, and did nothing. `objectstack validate` passed and no author-time gate
said otherwise.

The context `AppPlugin` builds now also carries:

- **`ql`** — the live ObjectQL engine, the same handle `defineStack({ onEnable })`
receives as `ctx.ql`;
- **`logger`** — the plugin `Logger`, so a job's diagnostics land in the
platform's log stream instead of `console`.

`JobHandlerContext` is exported from `@objectstack/runtime` for handlers that
want to annotate their argument.

**Why a job and a flow `script` node differ here.** `FlowFunctionContext` also
carries no engine, and that is coherent for a flow function: the flow graph does
the I/O around it (`get_record` before, `create_record` after), which is what
the per-run write metrics count. A job has no graph — no node before it, none
after — so the same emptiness leaves it unable to do the one thing scheduled
work exists for. Nothing about a `script` node's contract changes.

**Why the module-scope escape was not documented instead.** Closing over a
client bound by `onEnable` does not survive the shipped deployment path:
`objectstack build` emits `functions` into a sibling runtime module exporting
only `{ functions, meta }`, the artifact JSON carries no `onEnable`, and
`mergeRuntimeModule` merges only `functions` — so on an artifact-served boot the
binding is never made and the handler runs against an empty slot, silently. The
regression suite therefore proves the write on **both** boot paths: the
TS-config path and a real artifact loaded through `loadArtifactBundle`.

**Additive.** This widens the object passed to the handler, the same shape
`JobRunOutcome` took. `IJobService`'s
`JobHandler = (context: { jobId: string; data?: unknown }) => …` is untouched —
the function `AppPlugin` hands to `IJobService.schedule` is a wrapper that
satisfies it exactly, and the new members are added inside that wrapper. An
existing handler that destructures `{ jobId }` or `{ jobId, data }` is unchanged
byte for byte, and no `IJobService` implementation grows a member.

The kernel's `getService` was considered and deliberately not added: it would
put the whole service registry on a job's context permanently, and the measured
population of shipped `defineJob` handlers needs the engine and nothing else.
Adding it later is additive by exactly this argument, so nothing is foreclosed.
44 changes: 42 additions & 2 deletions content/docs/automation/jobs.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -122,8 +122,48 @@ a long time with no function of that name anywhere in the app was skipped at
every boot, and the sweep never ran. If a job appears to do nothing, read the
boot log for its name before reading its schedule.

At run time the handler is invoked with `{ jobId, data }`. What it returns
decides how the run is recorded:
## What the handler is given

At run time the handler is invoked with a `JobHandlerContext`
(`@objectstack/runtime`):

| Member | What it is |
|:---|:---|
| `jobId` | the job's `name` — its identity everywhere |
| `data` | the payload of a manual `trigger(name, data)` run; absent on a scheduled run |
| `bundle` | the application's metadata bundle — declarations, not a data handle |
| `ql` | the live ObjectQL engine — the same handle `defineStack({ onEnable })` receives |
| `logger` | the platform logger, so a job's diagnostics are not `console` output |

```typescript
import type { JobHandlerContext } from '@objectstack/runtime';

export async function sweepProjectHealth({ ql, jobId, logger }: JobHandlerContext) {
const stale = await ql.find('project', { where: { status: 'active' } });
for (const p of stale) await ql.update('project', { id: p.id, health: 'green' });
logger.info('sweep complete', { job: jobId, scanned: stale.length });
}
```

`ql` is why a job does **not** follow the pure-function rule a flow `script`
node follows. A `script` node returns a value and the flow graph does the I/O
around it — a `get_record` before, a `create_record` after — so its context
deliberately carries no engine. A job has no graph: no node before it, none
after. Writing records on a timer is the thing scheduled work exists for, so
the handler is handed the engine directly. A job's writes are still not counted
by any caller, so declare its `functions` entry `effect: 'writes'` — that makes
the run report "cannot say" rather than silently claiming it wrote nothing.

<Callout type="warn">
Do **not** reach the engine by having `onEnable` assign a module-scope global
the handler reads later. That binding does not survive a built artifact:
`objectstack build` emits your `functions` into a sibling runtime module, the
artifact JSON carries no `onEnable`, and only `functions` is merged back on
load — so on an artifact-served boot the global is never assigned and the job
runs against nothing, silently. Take `ql` from the context.
</Callout>

What the handler returns decides how the run is recorded:

| The handler… | Recorded as | Retried? |
|:---|:---|:---|
Expand Down
Loading
Loading