Skip to content

feat(app-cron): cron as a driver System (S2)#45

Merged
wmadden-electric merged 15 commits into
mainfrom
claude/cron-driver-system
Jul 12, 2026
Merged

feat(app-cron): cron as a driver System (S2)#45
wmadden-electric merged 15 commits into
mainfrom
claude/cron-driver-system

Conversation

@wmadden-electric

Copy link
Copy Markdown
Contributor

Realizes ADR-0020 on top of S1's structured params (#41). Slice S2 of the Config Params + Cron project (Linear TML-3010).

What this is

Cron is a driver, not a resource: an always-on cron-scheduler compute service that depends on a single trigger(jobId) endpoint and calls it on a timer. The app writes a router that implements trigger(jobId) and dispatches each id to real work. No new composition primitive — the scheduler is an ordinary consumer of the router's exposed endpoint (the storefront→auth shape).

New package @prisma/app-cron:

  • triggerContract — the single trigger(jobId) RPC call edge.
  • defineSchedule({ tick: '60s', … }) — the one source of truth for job ids; produces the structured jobs list. parseEvery handles s/m/h/d.
  • cronScheduler(schedule) — the reusable always-on compute(); jobs is a structured param (S1), trigger its only dep. Ships a self-contained boot entry.
  • serveSchedule(service, schedule, handlers) — the router's entry; forces a handler per scheduled job id at compile time (mirrors serve()'s exhaustiveness), dispatches POST /rpc/trigger {jobId} to the right one.
  • cron(name, { schedule, router }) — a system helper and the realization swap boundary: the app never provisions the scheduler itself, so a future native platform-timer realization is an internal change, not an app change.

How it's proven

  • examples/cron/ — worker + router + cron() system, validating the helper's generics against a real composition.
  • jobs round-trip integration test — bootstrapService stashes the structured jobs param exactly as a deploy would; a fresh empty-default scheduler reads it back through config() unchanged.
  • end-to-end firing integration test — the real router entry booted via bootstrapService against a fake worker; runScheduler (fake timer) drives a real trigger client over HTTP; each job id dispatches to its worker method. Deterministic, no wall-clock.

Notes

  • The shipped dist/scheduler-entry.mjs is inlined (self-contained) so the emulated scheduler deploys; build.module targets a wrapper whose default export is the runnable node (guarded by a test).
  • No live cloud deploy in CI — proof is in-process with controlled time. The port→ingress-capability redesign remains the project's recorded follow-up.
  • ADR-0020 marked Accepted.

pnpm build, typecheck, test, test:types, lint green.

Slice spec: .drive/projects/config-params-and-cron/specs/s2-cron-driver-system.md.

🤖 Generated with Claude Code

Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
…mer loop

New @prisma/app-cron package (S2, TML-3010). The reusable driver pieces:

- triggerContract: the single trigger(jobId) RPC call edge.
- defineSchedule / parseEvery: a job-id→interval map to the structured jobs
  list; s/m/h/d intervals to milliseconds.
- cronScheduler(schedule): an always-on compute() whose jobs param default is
  the schedule and whose only dep is trigger; job-agnostic.
- runScheduler: the pure, injectable firing loop (a rejected call is logged,
  never fatal — idempotent targets heal missed ticks).
- scheduler-entry: the reusable boot module the build points at; reads jobs
  from config() and trigger from load(), so it needs no app-specific node.

serveSchedule and the cron() system helper land in the next dispatch.

Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
serveSchedule(service, schedule, handlers) is serve() specialized to the
trigger contract: the single exposed trigger method dispatches on jobId to a
handler map that must cover the schedule's job ids exactly — the same
compile-time exhaustiveness serve() enforces over exposed methods, sourced from
the schedule instead of the contract. Delegates to the real serve() so routing,
status codes, and the load()-once guarantee are shared, not reimplemented.

Adds the type-tests config (vitest.config.ts, mirroring @prisma/app-rpc) so
test:types runs the .test-d.ts exhaustiveness cases.

Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
cron(name, { schedule, router }) wraps the app's router with the reusable
scheduler as ordinary system()/provision() composition: the returned system's
boundary deps mirror the router's own deps (forwarded straight into the router),
and the scheduler is wired to the router's trigger port. The app never
provisions the scheduler itself, so a future native platform-timer realization
is an internal change, not an app change (ADR-0020). A Load test proves the
worker→router→scheduler wiring and that an unwired dep throws.

Marks ADR-0020 Accepted.

Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
examples/cron composes a worker (target) + router (serveSchedule) + the cron()
system, validating cron()'s generics against a real composition. Two bun-test
integration tests prove the pipeline:

- jobs round-trip: bootstrapService stashes the structured jobs param exactly as
  a deploy would; a fresh scheduler reads it back through config() unchanged —
  S1's structured param carrying the schedule end to end.
- end-to-end firing: the real router entry booted via bootstrapService against a
  fake worker; runScheduler (fake timer) drives a real trigger client over HTTP;
  each job id dispatches to its worker method. Deterministic, no wall-clock.

Also makes the shipped dist/scheduler-entry.mjs self-contained (inline @prisma/*
and arktype) so the emulated scheduler actually deploys — assemble() copies the
entry standalone. runScheduler is now exported for the firing test to drive.

Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
Review found the reusable scheduler could not deploy: cronScheduler set
build.module to import.meta.url, which resolves to the package barrel
(dist/index.mjs, named exports only). The deploy bootstrap does
`import main from <module>; main.run(...)`, so a barrel with no default export
makes main.run undefined and throws at boot.

- Add src/scheduler-node.ts, whose DEFAULT export is a runnable scheduler node
  (empty schedule — run() reads the real jobs from the stashed env), and point
  build.module at it. Emit it as a tsdown entry (external deps; the deploy
  re-bundles it via assemble()).
- Guard it: a test asserts build.module targets scheduler-node.mjs and that
  module's default is runnable, so the barrel regression can't return.
- jobs-roundtrip test now reads through an empty-default scheduler (as the real
  entry does); the previous full-default node returned schedule.jobs even with
  no env stashed, so it could not fail.
- Align the slice spec's unknown-jobId note with the shipped 500 (serve's error
  path; unreachable from a matched scheduler).

Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
Integrates #44 (pnPostgres) / #43 (hex-composition close-out). Regenerates
pnpm-lock.yaml from the merged manifests — the auto-merge left it internally
inconsistent (a dangling snapshot ref) because my branch predated main's large
dependency change, which is what failed CI's frozen install on the PR merge.

Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
@pkg-pr-new

pkg-pr-new Bot commented Jul 11, 2026

Copy link
Copy Markdown

Open in StackBlitz

@prisma/alchemy

npm i https://pkg.pr.new/@prisma/alchemy@45

@prisma/app

npm i https://pkg.pr.new/@prisma/app@45

@prisma/app-assemble

npm i https://pkg.pr.new/@prisma/app-assemble@45

@prisma/app-cli

npm i https://pkg.pr.new/@prisma/app-cli@45

@prisma/app-cloud

npm i https://pkg.pr.new/@prisma/app-cloud@45

@prisma/app-nextjs

npm i https://pkg.pr.new/@prisma/app-nextjs@45

@prisma/app-node

npm i https://pkg.pr.new/@prisma/app-node@45

@prisma/app-rpc

npm i https://pkg.pr.new/@prisma/app-rpc@45

prisma-app

npm i https://pkg.pr.new/prisma-app@45

commit: 4a2b631

core-model.md's package table and versioning.md's published-packages list are
the canonical package registries; add the new @prisma/app-cron entry.

Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
The cron example was three packages (root + systems/worker + systems/router).
Deploy is per service node (build.module/entry, file-relative — 'not a
discovered package dir', ADR-0004), so the per-service npm package was
incidental ceremony. Collapse to one package: src/worker/*, src/router/*,
tests/*, and a single package.json/tsconfig/tsdown/prisma-app.config. Cross-
service package specifiers (@cron/worker/*) become plain relative imports; the
cron mechanics and both integration tests are unchanged.

The one real constraint: assemble() bundles each service into dirname(entry)/
bundle and rm's it first, so the two services' built entries must live in
separate dirs — dist/worker/ and dist/router/, via two independent tsdown
builds (a single multi-entry build would split shared code into a chunk
assemble() wouldn't copy). Verified by running assemble() on both: distinct
bundle dirs, no collision.

storefront-auth stays multi-package on purpose — it has the node-vs-Next.js
build-adapter split and shows cross-package system composition; cron now shows
the one-app-one-package shape.

Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>

@wmadden wmadden left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a great start but some of the typing looks problematic

Comment thread packages/app-cron/src/scheduler-node.ts Outdated
Comment thread examples/cron/src/runner/server.ts
Comment thread examples/cron/src/router/schedule.ts Outdated
Comment thread examples/cron/testing/fake.ts
Comment thread packages/app-cron/package.json Outdated
Comment thread packages/app-cron/tsdown.config.ts Outdated
Comment thread packages/app-cron/tsdown.config.ts Outdated
Comment thread packages/app-cron/src/system.ts Outdated
Comment thread packages/app-cron/src/serve-schedule.ts Outdated
Review verdict on #45: no package-per-System. Prisma Cloud's common Systems
live in ONE package with one entry point per System — a distinct subpath means
importing '.' never loads cron's module graph, which is stronger tree-shaking
than barrel pruning. @prisma/app-cron is deleted before ever reaching npm;
object storage (S5) will be the second subpath.

- packages/app-cloud/src/cron/: the moved System. scheduler-node.ts is now
  scheduler-service.ts — it is the scheduler's service.ts (the module whose
  default export is the runnable node that build.module must point at).
- arktype dropped: the schedule/trigger schemas are hand-rolled Standard
  Schemas (core's scalarSchema precedent), so folding cron in adds only the
  tiny app-rpc/app-node workspace deps to app-cloud.
- tsdown: cron's library entries build into dist/cron with their chunks (a
  key-prefix build put the shared chunk one level up, silently breaking
  build.module's relative resolution — caught by running the built output);
  scheduler-entry keeps its independent self-contained build.
- Typing (review): serveSchedule and cron() take real type params (D/P/RD/RP)
  instead of any — handlers now type deps as HydratedDeps<D> directly; core's
  Wiring accepts InputRef per slot, so a system body forwarding its own inputs
  into provision() type-checks under abstract generics and cron()'s
  blindCast<never> is deleted. Covariance test-d cases added (a router
  exposing extra ports still fits).
- examples/cron (review): router-entry.ts renamed server.ts; schedule folded
  into service.ts (router = service.ts + server.ts); the fake moved out of
  src/ to testing/fake.ts.

BREAKING: imports move from @prisma/app-cron to @prisma/app-cloud/cron (the
package only ever existed as pkg.pr.new previews).

Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>

@wmadden wmadden left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Better

Comment thread examples/cron/src/worker/contract.ts Outdated
Comment thread examples/cron/system.ts Outdated
Comment thread packages/app-cloud/src/cron/scheduler-entrypoint.ts
Comment thread packages/app-cloud/src/cron/standard-schema.ts Outdated
Comment thread packages/app-cloud/src/cron/system.ts Outdated
Comment thread packages/app-cloud/src/cron/system.ts Outdated
Comment thread packages/app-cloud/src/cron/system.ts Outdated
…rred provision ids

Addresses the 7 CHANGES_REQUESTED threads on PR #45:

- Restore arktype: delete the hand-rolled Standard Schemas; cron/contract.ts,
  cron/scheduler.ts and the example's worker/contract.ts use arktype `type`
  (already a transitive dep via @prisma-next/postgres). The scheduler-entrypoint
  and example bundles inline arktype (noExternal).
- Rename cron's job-dispatch role router -> runner (cron() opt, example src/
  dir, ADR-0020, docs). cron internals keep the stable id 'runner'.
- Rename scheduler-entry -> scheduler-entrypoint (file, package export, tsdown
  entry key, comments). The core BuildAdapter.entry field is unchanged.
- Rewrite cron/system.ts doc comments in plain English (no 'swap boundary', no
  ADR philosophy).
- SystemBuilder.provision gains 5 id-less overloads that infer the id from
  node.name; load-system.ts normalizes both call shapes. cron() now takes a
  single opts arg with optional name. Example uses the inferred form.

Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
… wiring→deps

Follow-up to the review pass: collapse provision()'s overloads and fold the
positional id + wiring into a single trailing options object, per review
discussion.

- provision(id, node, wiring) / provision(node, wiring) → provision(node, {
  id?, deps? }). The id defaults to node.name; `deps` — the producers that
  satisfy the node's slots (was `wiring`) — lives on the same object.
- 10 overloads → 5: the id is no longer an overload axis. Each node kind keeps
  a conditional overload (`ProvisionArgs<D>`: `deps` required exactly when the
  node declares slots, so a dependency can't be left unwired at compile time)
  plus an explicit-`deps` overload that generic wrappers (e.g. cron()) resolve
  to when the conditional can't evaluate an unbound D.
- Type `Wiring<D>` → `DepBindings<D>`; Load's user-facing "Wiring for…" and
  "received wiring for a resource" messages now say "deps". Internal
  graph-edge identifiers (validateWiring/wiringEdges) keep the wiring term.
- Migrated all provision() call sites across packages/examples/test.

The compiler now enforces that a depful node is wired; tests that exercise
Load's runtime backstop opt out with an explicit cast.

Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
…ndary or return

Ports the closed-root overload from PR #50: a system with no deps and no
expose is written `system(name, body)` instead of `system(name, {}, () =>
({}))`. The body only provisions and returns nothing; the impl discriminates
on whether the second arg is a function and wraps a closed-root body to return
`{}`. The generic boundary overload's two blindCasts are gone — the impl is
non-generic, the overloads carry the types.

The cron example adopts it: `system('cron-example', ({ provision }) => { … })`,
no `{}` boundary and no `return {}`.

Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
Resolves the provision() API divergence: main made service ids optional via
10 positional-wiring overloads; this branch uses the { id?, deps } options
object (5 overloads, DepBindings). Kept this branch's approved design and
migrated main's new call sites to it.

- node.ts: dropped main's leaked Wiring overloads from the auto-merge (kept the
  5 deps overloads). Both sides added the closed-root system() overload
  identically, so that merged clean.
- load-system.ts: kept the (child, opts) provision closure; kept the
  'deps'-worded Load errors.
- examples/storefront-auth + app-cli help text: adopted main's closed-root
  system() form with this branch's deps wiring; ids inferred where the node
  name matches.
- control-lowering.test.ts: took main's withEnv(PRISMA_PROJECT_ID) version
  (ADR-0019) and codemodded its provision calls to the deps form.
- Deduped overlapping id-inference tests (kept the comprehensive set covering
  resource + child-system inference).

Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
…#46)

main advanced again (PR #46: pnPostgres deploy-migrate lowering + pn-widgets
example, plus a node.ts doc-comment refactor). Re-merged and kept this branch's
{ id?, deps } provision API:

- node.ts: took main's refactored file (AnyContract alias, condensed comments,
  closed-root system overload) and re-applied the 5 deps overloads +
  DepBindings/ProvisionArgs over main's 10 Wiring overloads.
- app-cloud/package.json: unioned deps (main's @prisma-next/cli,
  migration-tools, pathe + this branch's arktype, @standard-schema/spec,
  app-node, app-rpc); regenerated the lockfile.
- prisma-next-shapes.test-d.ts / prisma-next.test.ts / pn-widgets example:
  adopted main's new pnPostgres `config` param and migrated the provision
  calls to the deps form (pn-widgets also made closed-root).
- invariants.test.ts: kept main's builtEntryGraph helper + this branch's
  cron-inclusive exports assertion.

Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
@wmadden-electric wmadden-electric merged commit 2cb7ff9 into main Jul 12, 2026
11 checks passed
@wmadden-electric wmadden-electric deleted the claude/cron-driver-system branch July 12, 2026 12:57
wmadden-electric added a commit that referenced this pull request Jul 12, 2026
system() → module(); SystemNode → ModuleNode, SystemContext → ModuleContext,
SystemBuilder → ModuleBuilder, SystemOutputs → ModuleOutputs; the node kind
discriminant 'system' → 'module'. load-system.ts → load-module.ts (loadSystem →
loadModule); the system-* test files and side-effect-system/valid-system
fixtures → *-module. Includes the app-cloud/cron package from #45: cron() now
returns ModuleNode, cron/system.ts → cron/module.ts, cron system.test(-d).ts →
module.test(-d).ts.

Package names (@prisma/app*), the prisma-app CLI, prisma-app.config.ts, and
plain-English uses (filesystem, type system) are untouched.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
wmadden-electric added a commit that referenced this pull request Jul 12, 2026
storefront-auth/systems/ → modules/ (pnpm-workspace glob + lockfile links
follow); the deploy roots system.ts → module.ts across storefront-auth, cron
(#45), and pn-widgets (#46), with each package.json script, exports map, and
tsconfig include updated. The deploy-verify-destroy GitHub action and the
bug-report issue template follow suit (deploy module.ts; module() comment).

The @storefront-auth/* package names are unchanged — scoped names, not paths.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
wmadden-electric added a commit that referenced this pull request Jul 12, 2026
system() → module(); SystemNode → ModuleNode, SystemContext → ModuleContext,
SystemBuilder → ModuleBuilder, SystemOutputs → ModuleOutputs; the node kind
discriminant 'system' → 'module'. load-system.ts → load-module.ts (loadSystem →
loadModule); the system-* test files and side-effect-system/valid-system
fixtures → *-module. Includes the app-cloud/cron package from #45: cron() now
returns ModuleNode, cron/system.ts → cron/module.ts, cron system.test(-d).ts →
module.test(-d).ts.

Package names (@prisma/app*), the prisma-app CLI, prisma-app.config.ts, and
plain-English uses (filesystem, type system) are untouched.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
wmadden-electric added a commit that referenced this pull request Jul 12, 2026
storefront-auth/systems/ → modules/ (pnpm-workspace glob + lockfile links
follow); the deploy roots system.ts → module.ts across storefront-auth, cron
(#45), and pn-widgets (#46), with each package.json script, exports map, and
tsconfig include updated. The deploy-verify-destroy GitHub action and the
bug-report issue template follow suit (deploy module.ts; module() comment).

The @storefront-auth/* package names are unchanged — scoped names, not paths.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
wmadden-electric added a commit that referenced this pull request Jul 12, 2026
system() → module(); SystemNode → ModuleNode, SystemContext → ModuleContext,
SystemBuilder → ModuleBuilder, SystemOutputs → ModuleOutputs; the node kind
discriminant 'system' → 'module'. load-system.ts → load-module.ts (loadSystem →
loadModule); the system-* test files and side-effect-system/valid-system
fixtures → *-module. Includes the app-cloud/cron package from #45: cron() now
returns ModuleNode, cron/system.ts → cron/module.ts, cron system.test(-d).ts →
module.test(-d).ts.

Package names (@prisma/app*), the prisma-app CLI, prisma-app.config.ts, and
plain-English uses (filesystem, type system) are untouched.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
wmadden-electric added a commit that referenced this pull request Jul 12, 2026
storefront-auth/systems/ → modules/ (pnpm-workspace glob + lockfile links
follow); the deploy roots system.ts → module.ts across storefront-auth, cron
(#45), and pn-widgets (#46), with each package.json script, exports map, and
tsconfig include updated. The deploy-verify-destroy GitHub action and the
bug-report issue template follow suit (deploy module.ts; module() comment).

The @storefront-auth/* package names are unchanged — scoped names, not paths.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: willbot <w.a.madden+machine@gmail.com>
Signed-off-by: Will Madden <madden@prisma.io>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants