Uh oh!
There was an error while loading. Please reload this page.
feat(runtime): give a declarative job's handler data reach so scheduled work can read and write records - #14262
Conversation
`defineJob` is the platform's only metadata shape for scheduled work, and
`AppPlugin` invoked its handler with `{ jobId, data, bundle }` — no engine,
no logger, nothing to write with. The job registered, appeared in the admin
UI, was scheduled, ran on time, and did nothing.
The context AppPlugin builds now also carries `ql` (the same ObjectQL handle
`defineStack({ onEnable })` receives) and `logger`. `JobHandlerContext` is
exported from `@objectstack/runtime`.
A job has no graph — no node before it, none after — so unlike a flow
`script` node it cannot be a pure value-returner whose I/O the graph
performs. Nothing about the `script` node contract changes.
Additive: `IJobService`'s `JobHandler` is untouched; the members are added
inside the wrapper AppPlugin hands to `schedule`, so an existing handler is
unchanged byte for byte.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q5WBDtaUnoz5XuJ6jk8pQ5📓 Docs Drift CheckThis PR changes 1 package(s): 3 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
What this run could not see
Coarse fallback — 24 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 45e39eb79396d9b4150fc3c4f332e9dd601ecb90 && git checkout 45e39eb79396d9b4150fc3c4f332e9dd601ecb90
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 035951faf99c42c258d470102da7346f309f7346 72c20362c0663aa27bbfb0cf834cfa3ce144528e && git checkout -B drift-repro 035951faf99c42c258d470102da7346f309f7346 && git merge --no-ff 72c20362c0663aa27bbfb0cf834cfa3ce144528e
node scripts/docs-audit/affected-docs.mjs --json 035951faf99c42c258d470102da7346f309f7346
|
…memory: backend #5704 migrated this project's test backends to sqlite `:memory:` and ruled that only the two files in `scripts/driver-memory-census.ledger.json` keep `@objectstack/driver-memory`, each being one arm of a cross-family pin that cannot run on SQL. This suite is neither — it needs *a* store, not that store — so `pnpm check:driver-memory-census` was right to refuse it as an unledgered arrival (#6664). Migrated rather than ledgered: the census stays at 2 ruled consumers and the ledger is untouched. The card's own reproduction used the in-memory driver because that is what the reporter had in hand; that was a manual probe, never a constraint on this rig. Provisioning `sweep_note` and nothing else makes the engine's single-tenant probe read an absent `sys_organization`, so the expected refusal is withheld and asserted through `expected-read-refusal-noise.ts` (#10629) rather than muted. The probe is memoised behind the first data operation, so the three context-shape tests that never touch the store declare that with `harness({ touchesStore: false })` — the withholding is unconditional either way, only the must-have-fired set narrows. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q5WBDtaUnoz5XuJ6jk8pQ5
Uh oh!
There was an error while loading. Please reload this page.
…ve published surface The diff widens the published surface additively: `@objectstack/metadata`'s entry gains a named type (`MetadataKeyedItem`) and `MetadataLoader` gains an optional member (`loadManyKeyed?`). This repo's precedent for additive public-surface widening is `minor`, not `patch` (R12: #14262's `job-handler-data-reach.md` and #14247, both `"@objectstack/runtime": minor`). Front matter only; the changeset body is byte-identical. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68
Closes#14094
defineJobis the platform's only metadata shape for scheduled work. Its handler resolves out ofdefineStack({ functions }), andAppPlugininvoked it with{ jobId, data, bundle }— no engine, no service registry, no logger, no session. The job registered, appeared in the metadata registry and the admin UI, was scheduled, ran on time, and did nothing.objectstack validatepassed, and the only related boot warning covers a missing handler, not a handler with no reach.What changed
packages/runtime/src/app-plugin.ts— the context the declarative-job wrapper builds now also carries:ql— the live ObjectQL engine, the same handledefineStack({ onEnable })receives asctx.ql;logger— the pluginLogger, so a job's diagnostics land in the platform's log stream instead ofconsole.JobHandlerContext(new,packages/runtime/src/job-handler-context.ts, re-exported from@objectstack/runtime) is the type a handler can annotate against.content/docs/automation/jobs.mdxdocuments the context and warns against the module-scope route.A job has no graph — this does not reopen the flow-
script-node contractFlowFunctionContextcarries no engine either, and that is coherent for a flow function: the flow graph does the I/O around it — aget_recordnode before, acreate_recordnode after — so the function stays a pure value-returner, and #4354's per-run write metrics depend on exactly that. Nothing here changes what ascriptnode receives.A job has no graph. There is no node before it and none after. So the same emptiness that is a clean contract for a script node leaves a job unable to do the one thing jobs exist for. That asymmetry is the whole argument; a reader who collapses the two cases will read this as an inconsistency rather than as a gap.
Why the module-scope escape was not documented instead
Closing over a client bound from
onEnableis available to a flow function and does not survive the shipped deployment path for a job:objectstack buildemitsfunctionsinto a sibling runtime module whose only exports are{ functions, meta };onEnable;mergeRuntimeModule(packages/runtime/src/load-artifact-bundle.ts) merges onlyfunctions.So on an artifact-served boot the binding is never made, the module-scope slot stays empty, and the job runs against nothing — silently. Documenting that escape would make the failure harder to find, not easier. The regression suite therefore proves the write on both boot paths, and the artifact one goes through the real
loadArtifactBundle, asserting on the way that the loaded bundle carries noonEnable.Additive —
IJobServiceis untouchedJobHandleris declared as taking{ jobId: string; data?: unknown }and resolvingvoidor aJobRunOutcome. The functionAppPluginhands toIJobService.scheduleis a wrapper that satisfies it exactly; the new members are added inside that wrapper. Same shape as #6617'sJobRunOutcomewidening. No existing handler adapts, and noIJobServiceimplementation grows a member. Pinned by two tests: a handler written against the pre-change context runs unchanged, and a third-partyIJobServicetyped only at the contract schedules and drives the job to a real record write.Measurements the dispatch asked for
Zone 2.3 — the card's probe re-run on current
main. Reproduced exactly, atorigin/main66ecc50a, on a real booted engine driving the realCronJobAdapter:Identical to the card's reading against published
@objectstack/*17.2.0.Zone 2.1 — is the narrow route reachable without
packages/spec? YES.Clause-②: no.packages/specis not touched. Two readings carry it: the boundary typeJobHandlerconstrains only the wrapperAppPluginpasses toIJobService.schedule, which is unchanged; and the bundle callable it invokes is typed as takinganyand returningany(collectBundleFunctions), authored throughfunctions, whose schema member is a barez.function()— no context type constrains it. The producer of the extra members isAppPlugin, so the runtime is where the type belongs.Zone 2.2 —
qlversus the kernel'sgetService: settled by measurement, not by scope. The repo ships exactly onedefineJobdeclaration (examples/app-showcase,showcase_health_sweep→sweepProjectHealth). It needsfind+updateand a logger, and nothing else — it reaches them today through a module-scopelet hostfilled bybindShowcaseJobRuntimefromonEnable, which is the escape above. The card's own reporting app takes an engine as an explicit argument. Zero measured pull forautomation,emailorqueuefrom any job.getServicewould put the whole service registry on the job context permanently — a materially larger surface to support forever, and the place an AI-authored metadata app would reach for a service it has no declared relationship with. Adding it later is additive by exactly the argument above, so choosing the narrow member now forecloses nothing.Zone 2.5 — no change to what a job sees on error. The wrapper's throw/reject path is untouched; retry and failure semantics are exactly as before.
Zone 2.4 — no collision with #14143.
packages/runtime/src/action-execution.tsis not in the diff.The test backend
The suite boots on sqlite
:memory:, the backend #5704 migrated this project's test rigs to. It needs a store; nothing in it is about any one driver. The card's own reproduction used@objectstack/driver-memorybecause that is what the reporter had in hand — a manual probe, never a constraint on the rig — and reaching for it here made this an unledgered arrival thatpnpm check:driver-memory-censusrefused (#6664). Migrated rather than ledgered: the census stays at 2 ruled consumers andscripts/driver-memory-census.ledger.jsonis untouched by this PR.Provisioning
sweep_noteand nothing else makes the engine's single-tenant probe read an absentsys_organization, so the expected refusal is withheld and asserted throughexpected-read-refusal-noise.ts(#10629) rather than muted. That probe is memoised behind the first data operation, so the three context-shape tests that never touch the store declare it withharness({ touchesStore: false })— the API's ownsilentChannels(required)narrowing. The withholding stays unconditional; only the must-have-fired set narrows.Does
content/docs/automation/jobs.mdxstill tell the truth?For everything this PR touches, yes — and the page is now pinned rather than merely asserted: the context table (
jobId/data/bundle/ql/logger) is held exactly by the suite's key-set assertion,datais checked on both the scheduled and the manual-trigger path, and the Callout's claim about the artifact boot is the artifact test.One row on that page is not true, and this PR neither introduced nor fixes it: under What the handler returns, "resolves
{ outcome: 'degraded', reason? }is recorded asdegraded". Measured on the declarative path — schedule through a recordingIJobService, capture the wrapper, call it:The outcome is dropped before any adapter sees it. That is filed as #14256 with the measurement; the honest repair is its one-expression code fix, not a caveat in the page, which is why the page was left alone.
Verification
Union run after the final commit, at
72c20362.pnpm --filter @objectstack/runtime test— 206 files, 3048 tests, all passed (the whole package, not a subset), both before and after the backend migration.pnpm --filter @objectstack/service-job test— 9 files, 94 tests passed (theIJobServiceside, untouched and confirmed so).pnpm --filter @objectstack/runtime typecheck— clean.excludes**/*.test.ts, so it says nothing about the new test file; measured separately with the exclusion lifted (tsc --noEmit, exit 0,--listFilesconfirming the file is in the program — 1 hit there versus 0 under the package config).packages/runtime/src/app-plugin.job-data-reach.test.ts: 8 tests, including two firing positive controls — the same shipped callable invoked with the pre-change context rejects and the store is unchanged, so neither passing write assertion can be vacuous.pnpm check:driver-memory-census— reproduced red on the first head, green at72c20362, ledger untouched.scripts/pm/dispatch-gates.mjsran green, ratchets re-run at72c20362. Plus all 38 families in that derivation'sundeterminedbucket — 36 green, 2 refusing on an unbuilt sibling package. That bucket is where the census gate lives: it declares no path population, so no path derivation can name it for any card, and running only the derived list is what let this one reach CI.Not measured locally, each refusing on a stated prerequisite rather than failing:
check:type-check-debtandcheck:dual-build-cjs-loads(exit 3 — both need the whole workspace built),check-test-completeness(exit 3 — grades a saved turbo log CI tees),scripts/pm/check-half-states.mjs(network-bound PM patrol), and, from the undetermined bucket,check:app-nav-i18nand@objectstack/client'scheck:exported-any-returns(both refuse rather than compute a false green over an unbuilt package). CI runs all of them.Filed, not fixed
JobRunOutcome, sodefineJobcannot report a degraded run #14256 — the same wrapper discards the handler'sJobRunOutcome, so a declarative job that resolves{ outcome: 'degraded' }is recorded assuccess. Adjacent to this seam but a different defect class (outbound, not inbound), so it is not folded in here. The measurement above is on the card.examples/app-showcase's nightly sweep still reaches its engine through the module-scope global, so it silently does nothing on an artifact-served boot. Now migratable to the context member this PR adds.Independent of #14095, exactly as the card says: that one is about recognising a uniqueness violation once you can reach the store; this one is about reaching it at all.
Authored by Claude Code, session
session_01Q5WBDtaUnoz5XuJ6jk8pQ5(https://claude.ai/code/session_01Q5WBDtaUnoz5XuJ6jk8pQ5).Generated by Claude Code