feat(app-cron): cron as a driver System (S2)#45
Merged
Conversation
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>
@prisma/alchemy
@prisma/app
@prisma/app-assemble
@prisma/app-cli
@prisma/app-cloud
@prisma/app-nextjs
@prisma/app-node
@prisma/app-rpc
prisma-app
commit: |
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
requested changes
Jul 12, 2026
wmadden
left a comment
Collaborator
There was a problem hiding this comment.
This is a great start but some of the typing looks problematic
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
requested changes
Jul 12, 2026
…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>
wmadden
approved these changes
Jul 12, 2026
…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>
wmadden
approved these changes
Jul 12, 2026
…#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
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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-schedulercompute service that depends on a singletrigger(jobId)endpoint and calls it on a timer. The app writes arouterthat implementstrigger(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 singletrigger(jobId)RPC call edge.defineSchedule({ tick: '60s', … })— the one source of truth for job ids; produces the structuredjobslist.parseEveryhandless/m/h/d.cronScheduler(schedule)— the reusable always-oncompute();jobsis a structured param (S1),triggerits 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 (mirrorsserve()'s exhaustiveness), dispatchesPOST /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.bootstrapServicestashes the structuredjobsparam exactly as a deploy would; a fresh empty-default scheduler reads it back throughconfig()unchanged.bootstrapServiceagainst 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
dist/scheduler-entry.mjsis inlined (self-contained) so the emulated scheduler deploys;build.moduletargets a wrapper whose default export is the runnable node (guarded by a test).port→ingress-capability redesign remains the project's recorded follow-up.pnpm build,typecheck,test,test:types,lintgreen.Slice spec:
.drive/projects/config-params-and-cron/specs/s2-cron-driver-system.md.🤖 Generated with Claude Code