The spine. Turns active recurring duties into tasks, once per period, forever, without a lock.
Blocked-by: #1 (period engine).
Files you own
src/jobs/dispatch.job.ts (new), added to dulyJobs in src/jobs/index.ts
test/dispatch.test.ts (new)
Do not touch objectstack.config.ts.
Shape
defineJob({
name: 'duly_dispatch',
label: 'Dispatch due tasks',
schedule: { type: 'cron', expression: '0 1 * * *', timezone: 'UTC' },
handler: /* … */,
retryPolicy: /* … */,
timeout: /* … */,
})
Runs hourly-or-daily in UTC and resolves each duty in its own timezone — a single UTC pass covers every zone because the question asked per duty is "is this period's task due to exist yet", not "is it midnight here".
What it does, per active duty
Select duly_duty where status = 'active', form = 'recurring', and today is inside [effective_from, effective_to] (either bound may be null).
For each, for the current period and any period whose visible_from <= today:
| Field |
Value |
subject |
duty.name copied, so renaming the duty never rewrites history |
duty, owner |
from the duty |
business_unit |
denormalised from the duty, so a later transfer does not move historical rollups |
source |
duty.source |
period_key |
periodKeyFor(...) |
due_date |
dueDateFor(...) |
visible_from |
visibleFromFor(due_date, duty.lead_days) |
status |
open |
Then set duty.last_dispatched_period to the latest key it created.
Idempotency — the part that matters
duly_task carries a unique index duly_task_dispatch_identity on (duty, owner, period_key), scoped to the organization. Rely on it. Attempt the insert and swallow the uniqueness violation; do not read-then-write, which is a race that two overlapping runs will lose.
This is the constraint that lets the job be ordinary: re-runnable, killable mid-run, safe to invoke twice concurrently. A run that inserts nothing because everything already exists is a successful run.
Explicitly not dispatched
Backfill
Accept optional job input { from?: string; to?: string } (ISO dates). When present, generate every period in the window via periodsBetween instead of just the current one. Same insert path, so backfilling twice is a no-op.
Acceptance
- running the job twice over the same clock produces zero second-pass inserts
- a duty in
Asia/Shanghai and one in America/Los_Angeles get the correct local period on the same UTC run
- standing duties produce nothing, in any run
- a paused duty produces nothing; un-pausing it and re-running produces only the current period, not the gap
last_dispatched_period advances and never regresses
- backfill over a 13-month window produces exactly the expected key set, and a second backfill inserts nothing
Gates
pnpm validate && pnpm typecheck && pnpm test && pnpm build.
The spine. Turns active recurring duties into tasks, once per period, forever, without a lock.
Blocked-by: #1 (period engine).
Files you own
src/jobs/dispatch.job.ts(new), added todulyJobsinsrc/jobs/index.tstest/dispatch.test.ts(new)Do not touch
objectstack.config.ts.Shape
Runs hourly-or-daily in UTC and resolves each duty in its own
timezone— a single UTC pass covers every zone because the question asked per duty is "is this period's task due to exist yet", not "is it midnight here".What it does, per active duty
Select
duly_dutywherestatus = 'active',form = 'recurring', and today is inside[effective_from, effective_to](either bound may be null).For each, for the current period and any period whose
visible_from <= today:subjectduty.namecopied, so renaming the duty never rewrites historyduty,ownerbusiness_unitsourceduty.sourceperiod_keyperiodKeyFor(...)due_datedueDateFor(...)visible_fromvisibleFromFor(due_date, duty.lead_days)statusopenThen set
duty.last_dispatched_periodto the latest key it created.Idempotency — the part that matters
duly_taskcarries a unique indexduly_task_dispatch_identityon(duty, owner, period_key), scoped to the organization. Rely on it. Attempt the insert and swallow the uniqueness violation; do not read-then-write, which is a race that two overlapping runs will lose.This is the constraint that lets the job be ordinary: re-runnable, killable mid-run, safe to invoke twice concurrently. A run that inserts nothing because everything already exists is a successful run.
Explicitly not dispatched
form: 'standing'— a standing duty never generates a task. Skip it entirely; do not create and immediately close.form: 'one_off'— dispatched by hand or by the assignment fan-out (Assignment fan-out — one piece of work becomes N independent tasks #6), never here.statusispausedorretired.Backfill
Accept optional job input
{ from?: string; to?: string }(ISO dates). When present, generate every period in the window viaperiodsBetweeninstead of just the current one. Same insert path, so backfilling twice is a no-op.Acceptance
Asia/Shanghaiand one inAmerica/Los_Angelesget the correct local period on the same UTC runlast_dispatched_periodadvances and never regressesGates
pnpm validate && pnpm typecheck && pnpm test && pnpm build.