Skip to content

Add @corbits/cron: cron ticks delivered as mail (CL-8183) - #800

Merged
TheGreatAxios merged 4 commits into
mainfrom
cl-8183-cron
Sep 17, 2026
Merged

TheGreatAxios merged 4 commits into
mainfrom
cl-8183-cron

Conversation

@TheGreatAxios

Copy link
Copy Markdown
Contributor

Summary

The vendored Interchange workflow-trigger grammar
(vendor/intx/workflow/src/definition/triggers.ts) has no schedule
trigger and no scheduler. @corbits/cron is a small experimental bridge
instead: a tenant saves a cron expression plus a mail
(to/subject/body) on its own cron.schedule table, and
createCronTicker polls for due schedules and delivers each as plain
mail through the host's transport — a schedule-triggered workflow becomes
a mail-triggered one addressed at itself, so workflows keep the plain
mail trigger they already have.

  • src/cron.ts — the 5-field cron parser, moved unchanged from the now
    retired @corbits/workflow-schedule (which is deleted entirely).
  • src/schema.ts — the cron.schedule table (own cron Postgres schema,
    FK'd to Interchange's native tenant table) plus its migration,
    registered in scripts/db-setup.ts alongside every other installed
    package.
  • src/ticker.ts — createCronTicker({ db, deliver, intervalMs }):
    claims due rows with SELECT ... FOR UPDATE SKIP LOCKED in one
    transaction so two concurrent tickers never double-fire a row, and only
    the latest missed tick fires (a schedule down for hours fires once on
    recovery, not once per missed minute).
  • src/mount.ts — mountCron(app, { db, requireTenantMember }): CRUD at
    /api/tenants/:tenantId/cron.

@corbits/workflow-schedule's only other consumer,
@corbits/workflows' scheduleCronFromProjection (native
ScheduleTrigger parsing off a frozen projection), is deleted alongside
it — nothing outside that one file read it, and native schedule triggers
don't fire against the vendored grammar anyway. packages/cron needs an
explicit ruling added to scripts/checks/no-product-tenancy.ts for its
one table, same as chat/webhook-triggers/notify/etc.

Suggested hub mount (not wired up — apps/hub/src/index.ts intentionally untouched)

const cronApp = new Hono<TenantEnv>();
mountCron(cronApp, {
  db,
  requireTenantMember: (ctx, tenantId) => {
    const c = ctx as { get(key: "tenant"): { id: string } };
    return c.get("tenant").id === tenantId;
  },
});
app.route("/", cronApp);

createCronTicker({
  db,
  intervalMs: 60_000,
  deliver: (message) =>
    lookups.persistMail({
      senderAddress: message.from,
      recipients: message.to,
      raw: buildRawMessage(message),
    }),
}).start();

Test plan

  • bun run typecheck
  • bun run lint
  • bun run fmt
  • bun run check:structural
  • bun test packages/cron (unit + DB-gated, against local Postgres)
  • CI (DB-gated ticker suite + full monorepo bun run check)

@linear-code

linear-code Bot commented Sep 17, 2026

Copy link
Copy Markdown

CL-8183

Cron expression tests moved from the retired @corbits/workflow-schedule,
plus a DB-gated ticker test proving a due row fires once, a not-yet-due
row doesn't, and SKIP LOCKED means two concurrent tickers never
double-fire the same row.
The vendored Interchange workflow-trigger grammar
(vendor/intx/workflow/src/definition/triggers.ts) has no `schedule`
trigger and no scheduler, so a saved cron expression can never launch a
workflow natively. @corbits/cron is a small experimental bridge instead:
a tenant saves a cron expression plus a mail (to/subject/body) on its own
`cron.schedule` table, and `createCronTicker` polls for due schedules and
delivers each as plain mail through the host's transport — a
schedule-triggered workflow becomes a mail-triggered one addressed at
itself.

- Moves the 5-field cron parser out of the retired
  @corbits/workflow-schedule (deleted entirely; its only other consumer,
  @corbits/workflows' scheduleCronFromProjection, is deleted alongside it
  since nothing outside that package read the re-export).
- One drizzle table, its own `cron` Postgres schema, FK'd to
  Interchange's native `tenant` table.
- createCronTicker claims due rows with SELECT ... FOR UPDATE SKIP
  LOCKED so concurrent tickers never double-fire, and only the latest
  missed tick fires.
- mountCron adds CRUD at /api/tenants/:tenantId/cron.
@TheGreatAxios
TheGreatAxios merged commit e960bf8 into main Sep 17, 2026
4 of 5 checks passed
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.

1 participant