@helipod/scheduler 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,711 @@
1
+ import { Driver, ComponentDefinition } from '@helipod/component';
2
+ import * as _helipod_values from '@helipod/values';
3
+ import { JSONValue } from '@helipod/values';
4
+ import { GuestDatabaseWriter, ActionApi } from '@helipod/executor';
5
+
6
+ /**
7
+ * A function reference as produced by codegen's `api`/`internal` proxy (see
8
+ * `@helipod/client`'s `FunctionReference`/`getFunctionPath`). Replicated here (rather than
9
+ * depending on `@helipod/client`, a client-facing SDK package) since a server component
10
+ * needs only this one-field shape.
11
+ */
12
+ interface FunctionReference {
13
+ readonly __path: string;
14
+ }
15
+ type FnRef = FunctionReference | string;
16
+ /** Resolve a `fnRef` (string path or codegen ref) to its string path. Mirrors `@helipod/client`'s `getFunctionPath`. */
17
+ declare function getFunctionPath(ref: FnRef): string;
18
+ /**
19
+ * Task 4 design note — `parentId` threading (cascading cancel):
20
+ *
21
+ * The original design was for the driver to set an ambient "current job id" while running a job,
22
+ * so a job scheduling a child (`ctx.scheduler.runAfter(...)` called from inside a driver-run job)
23
+ * would have that child's `parentId` populated automatically via `currentJobId()` below. That
24
+ * requires the ambient to survive from `driver.ts`'s `runPass()` — which only has the string
25
+ * `fnPath`/`jobId`, and calls `ctx.runFunction(claimed.fnPath, claimed.args)` — through
26
+ * `DriverContext.runFunction` → `InlineUdfExecutor.run` → this component's `context` builder,
27
+ * none of which currently carry a "who's calling" field. Wiring it soundly means extending
28
+ * `DriverContext.runFunction`'s signature, `RunOptions` (`packages/executor/src/executor.ts`),
29
+ * and `ComponentContext` (used by every `context:` facade, not just this one) — a cross-package
30
+ * change well outside `components/scheduler/*` with blast radius on every component, for a single
31
+ * driver's benefit.
32
+ *
33
+ * Chosen instead: cascading cancel is implemented generically over whatever `parentId` a job
34
+ * happens to have (`cancel()` below walks `by_parent` regardless of how `parentId` got set), and
35
+ * `currentJobId()` stays a stub returning `undefined`. Known limitation: a child scheduled from
36
+ * *inside* a driver-run job today gets `parentId: undefined` (not chained) — cascading cancel
37
+ * only reaches jobs whose `parentId` was set explicitly. The test suite (`test/reliability.test.ts`)
38
+ * exercises the cascade via the test-only `_system:insertJob` escape hatch, which sets `parentId`
39
+ * directly. Revisit if/when a later slice needs real parent/child chaining from driver-run jobs.
40
+ */
41
+ type JobState = "pending" | "inProgress" | "success" | "failed" | "canceled";
42
+ interface EnqueueOpts {
43
+ /** Relative delay in ms from the enqueueing call's `now()`. `runAt` wins when both are set. */
44
+ runAfter?: number;
45
+ /** Absolute due time (epoch ms). Takes precedence over `runAfter`. */
46
+ runAt?: number;
47
+ retry?: {
48
+ maxFailures: number;
49
+ };
50
+ name?: string;
51
+ onComplete?: string;
52
+ context?: JSONValue;
53
+ idempotencyKey?: string;
54
+ }
55
+ interface SchedulerContext {
56
+ runAfter(delayMs: number, fnRef: FnRef, args: JSONValue): Promise<string>;
57
+ runAt(ts: number | Date, fnRef: FnRef, args: JSONValue): Promise<string>;
58
+ cancel(id: string): Promise<void>;
59
+ /** Internal: the general enqueue path (workflow-style callers pass `opts` directly). */
60
+ enqueue(fnRef: FnRef, args: JSONValue, opts?: EnqueueOpts): Promise<string>;
61
+ }
62
+ /**
63
+ * The two `jobs`/`job_args` table names `enqueueInternal` writes to — bare (`"jobs"`) when
64
+ * called from this file's own namespaced `ctx.scheduler` facade, or fully qualified
65
+ * (`"scheduler/jobs"`) when called from a privileged context (`_cronTick` in `./modules.ts`, and
66
+ * the boot-time cron reconciler in `./crons.ts`) that bypasses namespace prefixing entirely —
67
+ * see `./modules.ts`'s module doc comment for why those callers must use fully-qualified names.
68
+ */
69
+ interface EnqueueTables {
70
+ jobs: string;
71
+ jobArgs: string;
72
+ }
73
+ /**
74
+ * The shared enqueue path: inserts a `pending` `jobs` row (+ `job_args`), honoring the
75
+ * born-canceled check and, since Task 5, an idempotent insert-or-noop on `opts.idempotencyKey`
76
+ * (looked up via `by_idempotency`) — if a job with that key already exists, its id is returned
77
+ * unchanged and nothing new is inserted. This dedup is what makes the cron cadence's occurrence
78
+ * key (`${cronName}:${fireTs}`, `_cronTick` in `./modules.ts`) safe to call more than once for
79
+ * the same occurrence (e.g. a reclaim-driven re-run of a cadence job).
80
+ *
81
+ * Factored out of `schedulerContext` (which calls it with bare table names, scoped to the calling
82
+ * mutation's own transaction via `db`) so `_cronTick` and the boot-time cron reconciler — both of
83
+ * which write with fully-qualified table names outside a normal namespaced call — can reuse the
84
+ * exact same logic rather than a parallel reimplementation drifting out of sync.
85
+ */
86
+ declare function enqueueInternal(db: GuestDatabaseWriter, now: () => number, tables: EnqueueTables, fnRef: FnRef, args: JSONValue, opts: EnqueueOpts,
87
+ /**
88
+ * Resolves the target's real kind for the job's `kind` column. Defaults to always-"mutation"
89
+ * — correct for every caller EXCEPT `schedulerContext`'s facade methods below, which pass a
90
+ * closure over `cctx.functionKind` (the only caller whose target might actually be an action;
91
+ * `_cronTick`'s cadence/work jobs and `fireOnComplete`'s callback are always mutations).
92
+ */
93
+ kindOf?: (fnPath: string) => "mutation" | "action"): Promise<string>;
94
+ /**
95
+ * The terminal outcome an `onComplete` callback is invoked with — a strict superset of
96
+ * `./modules.ts`'s `JobResult` (which only ever carries `success`/`failed`, the two outcomes
97
+ * `_complete` itself produces) plus `canceled`, produced by `cancel()` below. Defined here (not
98
+ * `./modules.ts`) so `./modules.ts` — which already imports from this file — can reuse it without
99
+ * a circular import back the other way.
100
+ */
101
+ type OnCompleteResult = {
102
+ kind: "success";
103
+ value: unknown;
104
+ } | {
105
+ kind: "failed";
106
+ error: string;
107
+ } | {
108
+ kind: "canceled";
109
+ };
110
+ /**
111
+ * Task 6 — the workflow-ready `onComplete`/`context` round-trip: if `job.onComplete` (a mutation
112
+ * path) is set, enqueue it with `{ jobId, context, result }`, where `context` is `job_args`'s
113
+ * `context` for THIS job read back verbatim (opaque to the scheduler — never interpreted, just
114
+ * round-tripped) and `result` is the terminal outcome. Enqueued via `runAt: now()` (the `runAfter:
115
+ * 0` semantics) so it's immediately due — the reactive wake picks it up on the very next
116
+ * commit-driven iteration, no extra latency beyond one dispatch pass.
117
+ *
118
+ * A no-op when `onComplete` is `undefined` (the common case — most jobs don't register a
119
+ * completion callback). Called from exactly two terminal transitions, never a retry:
120
+ * - `./modules.ts`'s `_complete`, on `state:"success"` and on dead-lettered `state:"failed"`
121
+ * (NOT on the back-to-`"pending"` retry branch — the job isn't actually done yet).
122
+ * - `cancel()` below, for both the directly-canceled job and any cascaded-canceled descendant.
123
+ */
124
+ declare function fireOnComplete(db: GuestDatabaseWriter, now: () => number, tables: EnqueueTables, jobId: string, onComplete: string | undefined, result: OnCompleteResult): Promise<void>;
125
+ /**
126
+ * The action-mode counterpart to `SchedulerContext` — same `runAfter`/`runAt`/`cancel` method
127
+ * signatures (so a function body scheduling work is portable between a mutation and an action),
128
+ * minus `enqueue` (the general opts-carrying path stays a mutation-only internal primitive; no
129
+ * action caller needs it yet). Deliberately NOT structurally assignable to `SchedulerContext` as a
130
+ * type (no `enqueue`) even though the three shared methods match exactly.
131
+ */
132
+ interface SchedulerActionContext {
133
+ runAfter(delayMs: number, fnRef: FnRef, args: JSONValue): Promise<string>;
134
+ runAt(ts: number | Date, fnRef: FnRef, args: JSONValue): Promise<string>;
135
+ cancel(id: string): Promise<void>;
136
+ }
137
+ /**
138
+ * Builds the action-mode `ctx.scheduler` — wired as `defineScheduler()`'s `buildAction` (see
139
+ * `./index.ts`). An action has no `db`, so `runAfter`/`runAt`/`cancel` can't write a `jobs` row
140
+ * directly the way `schedulerContext` above does; instead each delegates to `api.runMutation` of
141
+ * the internal `scheduler:_enqueue`/`scheduler:_cancel` mutations (`./modules.ts`), which run the
142
+ * SAME `enqueueInternal`/`cancel` logic inside their own fresh top-level transaction.
143
+ *
144
+ * `Date.now()` below (converting `runAfter`'s relative `delayMs` to an absolute `runAtMs`) is fine
145
+ * here even though queries/mutations must stay deterministic: an action is non-deterministic by
146
+ * design (see `ActionCtx`'s doc comment in `@helipod/executor`), and the scheduler never
147
+ * recomputes anything from this timestamp — `scheduler:_enqueue` just stores it as `nextTs`.
148
+ */
149
+ declare function schedulerActionContext(api: ActionApi): SchedulerActionContext;
150
+
151
+ type CatchUpPolicy = "skip" | "fireOnce" | "fireAll";
152
+ /** The two spec shapes `computeNextRun` understands. Stored on the `crons` row JSON-serialized (`spec: v.string()`). */
153
+ type CronSpec = {
154
+ kind: "interval";
155
+ ms: number;
156
+ } | {
157
+ kind: "cron";
158
+ expr: string;
159
+ };
160
+ interface CronRegistryEntry {
161
+ name: string;
162
+ spec: CronSpec;
163
+ tz: string;
164
+ catchUp: CatchUpPolicy;
165
+ workFnPath: string;
166
+ workArgs: JSONValue;
167
+ }
168
+ interface IntervalPeriod {
169
+ seconds?: number;
170
+ minutes?: number;
171
+ hours?: number;
172
+ }
173
+ interface DailyAt {
174
+ hourUTC: number;
175
+ minuteUTC: number;
176
+ }
177
+ interface HourlyAt {
178
+ minuteUTC: number;
179
+ }
180
+ type DayOfWeek = "sunday" | "monday" | "tuesday" | "wednesday" | "thursday" | "friday" | "saturday";
181
+ interface WeeklyAt {
182
+ dayOfWeek: DayOfWeek;
183
+ hourUTC: number;
184
+ minuteUTC: number;
185
+ }
186
+ interface MonthlyAt {
187
+ day: number;
188
+ hourUTC: number;
189
+ minuteUTC: number;
190
+ }
191
+ /** `tz`/`catchUp` are additive Helipod extensions (absent on Convex) — see the design spec §5.2. */
192
+ interface CronOpts {
193
+ tz?: string;
194
+ catchUp?: CatchUpPolicy;
195
+ }
196
+ /** `.daily`/`.hourly`/`.weekly`/`.monthly` take *UTC fields by name — `tz` isn't accepted (it'd be ambiguous which field it applies to); only `catchUp` is overridable. */
197
+ type CronUtcOpts = Pick<CronOpts, "catchUp">;
198
+ interface CronJobs {
199
+ interval(name: string, period: IntervalPeriod, fnRef: FnRef, args: JSONValue, opts?: CronOpts): void;
200
+ cron(name: string, expr: string, fnRef: FnRef, args: JSONValue, opts?: CronOpts): void;
201
+ daily(name: string, at: DailyAt, fnRef: FnRef, args: JSONValue, opts?: CronUtcOpts): void;
202
+ hourly(name: string, at: HourlyAt, fnRef: FnRef, args: JSONValue, opts?: CronUtcOpts): void;
203
+ weekly(name: string, at: WeeklyAt, fnRef: FnRef, args: JSONValue, opts?: CronUtcOpts): void;
204
+ monthly(name: string, at: MonthlyAt, fnRef: FnRef, args: JSONValue, opts?: CronUtcOpts): void;
205
+ /** Boot-reconciliation seam — not part of the public Convex-parity surface. */
206
+ __entries(): CronRegistryEntry[];
207
+ }
208
+ /** `cronJobs()` — see this file's module doc comment for the full `crons.ts` shape. */
209
+ declare function cronJobs(): CronJobs;
210
+ /**
211
+ * Computes the next fire time strictly AFTER `afterTs`, per `spec`. For `{kind:"interval"}`
212
+ * that's plain arithmetic; for `{kind:"cron"}` it's `cron-parser`'s `parseExpression(...).next()`
213
+ * against `afterTs` as `currentDate` in the given IANA `tz` — verified to return a date strictly
214
+ * greater than `afterTs` even when `afterTs` itself exactly matches the pattern (i.e. calling
215
+ * this again with the returned value as the new `afterTs` always advances — no infinite loop,
216
+ * no repeat).
217
+ */
218
+ declare function computeNextRun(spec: CronSpec, tz: string, afterTs: number): number;
219
+ /**
220
+ * Computes the LAST fire time AT-OR-BEFORE `atOrBeforeTs`, per `spec` — the mirror of
221
+ * `computeNextRun`, used only by `_cronTick`'s bounded catch-up path (`./modules.ts`, the
222
+ * unbounded-catch-up-loop fix) to find the single most recent missed occurrence in O(1) without
223
+ * stepping through a potentially enormous backlog. Only meaningful for `{kind:"cron"}` — an
224
+ * interval spec's "previous occurrence" is plain reverse arithmetic from its own numeric anchor,
225
+ * which `_cronTick` computes inline without a cron-parser round-trip, so this throws for
226
+ * `{kind:"interval"}` rather than silently doing the wrong thing.
227
+ *
228
+ * Implemented via `cron-parser`'s `.prev()`, called with `currentDate` one ms PAST
229
+ * `atOrBeforeTs` so an occurrence landing EXACTLY on `atOrBeforeTs` is still included —
230
+ * `cron-parser`'s `.next()`/`.prev()` are both strictly exclusive of `currentDate` itself
231
+ * (verified empirically, matching `computeNextRun`'s own "strictly after" contract on the other
232
+ * side).
233
+ */
234
+ declare function computePrevRun(spec: CronSpec, tz: string, atOrBeforeTs: number): number;
235
+ /**
236
+ * Enqueues (or re-enqueues) a cron's CADENCE job, embedding the resulting job's own id into its
237
+ * args (`{cronName, jobId}`) — the duplicate-cadence-chain fix's belt-and-braces half: a future
238
+ * `_cronTick` invocation can then recognize whether it's still the CURRENT canonical chain for
239
+ * this cron (by comparing its own `args.jobId` against `crons.cadenceJobId`) and self-terminate
240
+ * without rescheduling if not — see `_cronTick`'s doc comment in `./modules.ts`. This convergence
241
+ * check is a backstop even if `hasLiveCadence` (below) somehow still let two chains coexist.
242
+ *
243
+ * Two writes (insert, then patch `job_args`) rather than one, because the job's id isn't known
244
+ * until after the insert — self-referential args can't be supplied up front. This runs at most
245
+ * once per cadence tick (or once per boot-time reconcile), so the extra write is negligible.
246
+ */
247
+ declare function enqueueCadenceJob(db: GuestDatabaseWriter, now: () => number, tables: EnqueueTables, cronName: string, runAt: number): Promise<string>;
248
+
249
+ /**
250
+ * The `@helipod/scheduler` component schema (namespaced `scheduler/*` when composed).
251
+ *
252
+ * - `jobs` / `job_args`: split so a job's identity/state (small, hot — scanned by the driver's
253
+ * `by_next_ts` index) never carries the (possibly large) `args`/`context` payload.
254
+ * - `crons`: declared here for the schema to be stable across Task 2→5; only Task 5's cron
255
+ * scheduler reads/writes it (`cadenceJobId` links a cron to its currently-pending job).
256
+ */
257
+ declare const schedulerSchema: _helipod_values.SchemaDefinition<{
258
+ jobs: _helipod_values.TableDefinition<{
259
+ fnPath: {
260
+ readonly kind: "string";
261
+ check(value: _helipod_values.Value, path: string, out: _helipod_values.ValidationFailure[]): void;
262
+ toJSON(): _helipod_values.ValidatorJSON;
263
+ readonly _output: string;
264
+ readonly isOptional: "required";
265
+ };
266
+ kind: {
267
+ readonly kind: "union";
268
+ readonly members: [{
269
+ readonly value: "mutation";
270
+ readonly kind: "literal";
271
+ check(value: _helipod_values.Value, path: string, out: _helipod_values.ValidationFailure[]): void;
272
+ toJSON(): _helipod_values.ValidatorJSON;
273
+ readonly _output: "mutation";
274
+ readonly isOptional: "required";
275
+ }, {
276
+ readonly value: "action";
277
+ readonly kind: "literal";
278
+ check(value: _helipod_values.Value, path: string, out: _helipod_values.ValidationFailure[]): void;
279
+ toJSON(): _helipod_values.ValidatorJSON;
280
+ readonly _output: "action";
281
+ readonly isOptional: "required";
282
+ }];
283
+ check(value: _helipod_values.Value, path: string, out: _helipod_values.ValidationFailure[]): void;
284
+ toJSON(): _helipod_values.ValidatorJSON;
285
+ readonly _output: "mutation" | "action";
286
+ readonly isOptional: "required";
287
+ };
288
+ state: {
289
+ readonly kind: "union";
290
+ readonly members: [{
291
+ readonly value: "pending";
292
+ readonly kind: "literal";
293
+ check(value: _helipod_values.Value, path: string, out: _helipod_values.ValidationFailure[]): void;
294
+ toJSON(): _helipod_values.ValidatorJSON;
295
+ readonly _output: "pending";
296
+ readonly isOptional: "required";
297
+ }, {
298
+ readonly value: "inProgress";
299
+ readonly kind: "literal";
300
+ check(value: _helipod_values.Value, path: string, out: _helipod_values.ValidationFailure[]): void;
301
+ toJSON(): _helipod_values.ValidatorJSON;
302
+ readonly _output: "inProgress";
303
+ readonly isOptional: "required";
304
+ }, {
305
+ readonly value: "success";
306
+ readonly kind: "literal";
307
+ check(value: _helipod_values.Value, path: string, out: _helipod_values.ValidationFailure[]): void;
308
+ toJSON(): _helipod_values.ValidatorJSON;
309
+ readonly _output: "success";
310
+ readonly isOptional: "required";
311
+ }, {
312
+ readonly value: "failed";
313
+ readonly kind: "literal";
314
+ check(value: _helipod_values.Value, path: string, out: _helipod_values.ValidationFailure[]): void;
315
+ toJSON(): _helipod_values.ValidatorJSON;
316
+ readonly _output: "failed";
317
+ readonly isOptional: "required";
318
+ }, {
319
+ readonly value: "canceled";
320
+ readonly kind: "literal";
321
+ check(value: _helipod_values.Value, path: string, out: _helipod_values.ValidationFailure[]): void;
322
+ toJSON(): _helipod_values.ValidatorJSON;
323
+ readonly _output: "canceled";
324
+ readonly isOptional: "required";
325
+ }];
326
+ check(value: _helipod_values.Value, path: string, out: _helipod_values.ValidationFailure[]): void;
327
+ toJSON(): _helipod_values.ValidatorJSON;
328
+ readonly _output: "pending" | "inProgress" | "success" | "failed" | "canceled";
329
+ readonly isOptional: "required";
330
+ };
331
+ nextTs: {
332
+ readonly kind: "number";
333
+ check(value: _helipod_values.Value, path: string, out: _helipod_values.ValidationFailure[]): void;
334
+ toJSON(): _helipod_values.ValidatorJSON;
335
+ readonly _output: number;
336
+ readonly isOptional: "required";
337
+ };
338
+ attempts: {
339
+ readonly kind: "number";
340
+ check(value: _helipod_values.Value, path: string, out: _helipod_values.ValidationFailure[]): void;
341
+ toJSON(): _helipod_values.ValidatorJSON;
342
+ readonly _output: number;
343
+ readonly isOptional: "required";
344
+ };
345
+ maxFailures: {
346
+ readonly kind: "number";
347
+ check(value: _helipod_values.Value, path: string, out: _helipod_values.ValidationFailure[]): void;
348
+ toJSON(): _helipod_values.ValidatorJSON;
349
+ readonly _output: number;
350
+ readonly isOptional: "required";
351
+ };
352
+ leaseHolder: {
353
+ readonly inner: _helipod_values.Validator<string, _helipod_values.OptionalProperty>;
354
+ readonly kind: "optional";
355
+ check(value: _helipod_values.Value, path: string, out: _helipod_values.ValidationFailure[]): void;
356
+ toJSON(): _helipod_values.ValidatorJSON;
357
+ readonly _output: string | undefined;
358
+ readonly isOptional: "optional";
359
+ };
360
+ leaseExpiresAt: {
361
+ readonly inner: _helipod_values.Validator<number, _helipod_values.OptionalProperty>;
362
+ readonly kind: "optional";
363
+ check(value: _helipod_values.Value, path: string, out: _helipod_values.ValidationFailure[]): void;
364
+ toJSON(): _helipod_values.ValidatorJSON;
365
+ readonly _output: number | undefined;
366
+ readonly isOptional: "optional";
367
+ };
368
+ idempotencyKey: {
369
+ readonly inner: _helipod_values.Validator<string, _helipod_values.OptionalProperty>;
370
+ readonly kind: "optional";
371
+ check(value: _helipod_values.Value, path: string, out: _helipod_values.ValidationFailure[]): void;
372
+ toJSON(): _helipod_values.ValidatorJSON;
373
+ readonly _output: string | undefined;
374
+ readonly isOptional: "optional";
375
+ };
376
+ appVersion: {
377
+ readonly inner: _helipod_values.Validator<string, _helipod_values.OptionalProperty>;
378
+ readonly kind: "optional";
379
+ check(value: _helipod_values.Value, path: string, out: _helipod_values.ValidationFailure[]): void;
380
+ toJSON(): _helipod_values.ValidatorJSON;
381
+ readonly _output: string | undefined;
382
+ readonly isOptional: "optional";
383
+ };
384
+ name: {
385
+ readonly inner: _helipod_values.Validator<string, _helipod_values.OptionalProperty>;
386
+ readonly kind: "optional";
387
+ check(value: _helipod_values.Value, path: string, out: _helipod_values.ValidationFailure[]): void;
388
+ toJSON(): _helipod_values.ValidatorJSON;
389
+ readonly _output: string | undefined;
390
+ readonly isOptional: "optional";
391
+ };
392
+ hasArgs: {
393
+ readonly kind: "boolean";
394
+ check(value: _helipod_values.Value, path: string, out: _helipod_values.ValidationFailure[]): void;
395
+ toJSON(): _helipod_values.ValidatorJSON;
396
+ readonly _output: boolean;
397
+ readonly isOptional: "required";
398
+ };
399
+ onComplete: {
400
+ readonly inner: _helipod_values.Validator<string, _helipod_values.OptionalProperty>;
401
+ readonly kind: "optional";
402
+ check(value: _helipod_values.Value, path: string, out: _helipod_values.ValidationFailure[]): void;
403
+ toJSON(): _helipod_values.ValidatorJSON;
404
+ readonly _output: string | undefined;
405
+ readonly isOptional: "optional";
406
+ };
407
+ parentId: {
408
+ readonly inner: _helipod_values.Validator<string, _helipod_values.OptionalProperty>;
409
+ readonly kind: "optional";
410
+ check(value: _helipod_values.Value, path: string, out: _helipod_values.ValidationFailure[]): void;
411
+ toJSON(): _helipod_values.ValidatorJSON;
412
+ readonly _output: string | undefined;
413
+ readonly isOptional: "optional";
414
+ };
415
+ completedTs: {
416
+ readonly inner: _helipod_values.Validator<number, _helipod_values.OptionalProperty>;
417
+ readonly kind: "optional";
418
+ check(value: _helipod_values.Value, path: string, out: _helipod_values.ValidationFailure[]): void;
419
+ toJSON(): _helipod_values.ValidatorJSON;
420
+ readonly _output: number | undefined;
421
+ readonly isOptional: "optional";
422
+ };
423
+ /** The most recent failure's `String(error)` — set on retry AND on dead-letter (Task 4). */
424
+ lastError: {
425
+ readonly inner: _helipod_values.Validator<string, _helipod_values.OptionalProperty>;
426
+ readonly kind: "optional";
427
+ check(value: _helipod_values.Value, path: string, out: _helipod_values.ValidationFailure[]): void;
428
+ toJSON(): _helipod_values.ValidatorJSON;
429
+ readonly _output: string | undefined;
430
+ readonly isOptional: "optional";
431
+ };
432
+ }>;
433
+ job_args: _helipod_values.TableDefinition<{
434
+ jobId: {
435
+ readonly kind: "string";
436
+ check(value: _helipod_values.Value, path: string, out: _helipod_values.ValidationFailure[]): void;
437
+ toJSON(): _helipod_values.ValidatorJSON;
438
+ readonly _output: string;
439
+ readonly isOptional: "required";
440
+ };
441
+ args: {
442
+ readonly kind: "any";
443
+ check(): void;
444
+ toJSON(): _helipod_values.ValidatorJSON;
445
+ readonly _output: any;
446
+ readonly isOptional: "required";
447
+ };
448
+ context: {
449
+ readonly inner: _helipod_values.Validator<any, _helipod_values.OptionalProperty>;
450
+ readonly kind: "optional";
451
+ check(value: _helipod_values.Value, path: string, out: _helipod_values.ValidationFailure[]): void;
452
+ toJSON(): _helipod_values.ValidatorJSON;
453
+ readonly _output: any;
454
+ readonly isOptional: "optional";
455
+ };
456
+ }>;
457
+ crons: _helipod_values.TableDefinition<{
458
+ name: {
459
+ readonly kind: "string";
460
+ check(value: _helipod_values.Value, path: string, out: _helipod_values.ValidationFailure[]): void;
461
+ toJSON(): _helipod_values.ValidatorJSON;
462
+ readonly _output: string;
463
+ readonly isOptional: "required";
464
+ };
465
+ spec: {
466
+ readonly kind: "string";
467
+ check(value: _helipod_values.Value, path: string, out: _helipod_values.ValidationFailure[]): void;
468
+ toJSON(): _helipod_values.ValidatorJSON;
469
+ readonly _output: string;
470
+ readonly isOptional: "required";
471
+ };
472
+ tz: {
473
+ readonly kind: "string";
474
+ check(value: _helipod_values.Value, path: string, out: _helipod_values.ValidationFailure[]): void;
475
+ toJSON(): _helipod_values.ValidatorJSON;
476
+ readonly _output: string;
477
+ readonly isOptional: "required";
478
+ };
479
+ catchUp: {
480
+ readonly kind: "union";
481
+ readonly members: [{
482
+ readonly value: "skip";
483
+ readonly kind: "literal";
484
+ check(value: _helipod_values.Value, path: string, out: _helipod_values.ValidationFailure[]): void;
485
+ toJSON(): _helipod_values.ValidatorJSON;
486
+ readonly _output: "skip";
487
+ readonly isOptional: "required";
488
+ }, {
489
+ readonly value: "fireOnce";
490
+ readonly kind: "literal";
491
+ check(value: _helipod_values.Value, path: string, out: _helipod_values.ValidationFailure[]): void;
492
+ toJSON(): _helipod_values.ValidatorJSON;
493
+ readonly _output: "fireOnce";
494
+ readonly isOptional: "required";
495
+ }, {
496
+ readonly value: "fireAll";
497
+ readonly kind: "literal";
498
+ check(value: _helipod_values.Value, path: string, out: _helipod_values.ValidationFailure[]): void;
499
+ toJSON(): _helipod_values.ValidatorJSON;
500
+ readonly _output: "fireAll";
501
+ readonly isOptional: "required";
502
+ }];
503
+ check(value: _helipod_values.Value, path: string, out: _helipod_values.ValidationFailure[]): void;
504
+ toJSON(): _helipod_values.ValidatorJSON;
505
+ readonly _output: "skip" | "fireOnce" | "fireAll";
506
+ readonly isOptional: "required";
507
+ };
508
+ lastScheduledTs: {
509
+ readonly inner: _helipod_values.Validator<number, _helipod_values.OptionalProperty>;
510
+ readonly kind: "optional";
511
+ check(value: _helipod_values.Value, path: string, out: _helipod_values.ValidationFailure[]): void;
512
+ toJSON(): _helipod_values.ValidatorJSON;
513
+ readonly _output: number | undefined;
514
+ readonly isOptional: "optional";
515
+ };
516
+ workFnPath: {
517
+ readonly kind: "string";
518
+ check(value: _helipod_values.Value, path: string, out: _helipod_values.ValidationFailure[]): void;
519
+ toJSON(): _helipod_values.ValidatorJSON;
520
+ readonly _output: string;
521
+ readonly isOptional: "required";
522
+ };
523
+ workArgs: {
524
+ readonly kind: "any";
525
+ check(): void;
526
+ toJSON(): _helipod_values.ValidatorJSON;
527
+ readonly _output: any;
528
+ readonly isOptional: "required";
529
+ };
530
+ cadenceJobId: {
531
+ readonly inner: _helipod_values.Validator<string, _helipod_values.OptionalProperty>;
532
+ readonly kind: "optional";
533
+ check(value: _helipod_values.Value, path: string, out: _helipod_values.ValidationFailure[]): void;
534
+ toJSON(): _helipod_values.ValidatorJSON;
535
+ readonly _output: string | undefined;
536
+ readonly isOptional: "optional";
537
+ };
538
+ }>;
539
+ }>;
540
+
541
+ /**
542
+ * Internal modules for `@helipod/scheduler` — registered on `defineScheduler()`'s `modules` map
543
+ * (so they're reachable as `scheduler:_peekDue` / `scheduler:_claim` / `scheduler:_complete`),
544
+ * consumed ONLY by the Task 3 driver loop (`./driver.ts`) via `DriverContext.runFunction`, which
545
+ * always calls privileged (`runtime-embedded/src/runtime.ts`'s `driverCtx.runFunction` sets
546
+ * `privileged: true`). Privileged calls bypass namespace prefixing entirely (`kernel.ts`'s
547
+ * `requireTable`), so — unlike `facade.ts`, which runs namespaced and uses bare table names
548
+ * (`"jobs"`, `"job_args"`) — these modules must use the fully-qualified names
549
+ * (`"scheduler/jobs"`, `"scheduler/job_args"`).
550
+ *
551
+ * `_peekDue`/`_claim`/`_complete` are internal by convention (the `_` prefix + being paired only
552
+ * with the driver), not by enforced access control — see Task 3's research notes. That's an
553
+ * accepted gap for this slice (nothing else in the codebase enforces "driver-only" beyond
554
+ * `_system:*`/`_admin:*`'s separate privileged registries).
555
+ */
556
+ /** Cap on how many due jobs a single `_peekDue` batch returns, so one loop iteration can't run unbounded. */
557
+ declare const BATCH_CAP = 64;
558
+ /**
559
+ * Hard ceiling on how many missed occurrences `_cronTick`'s `catchUp:"fireAll"` path will
560
+ * materialize (and fire a work job for) in a single tick — see `_cronTick`'s doc comment. Without
561
+ * this, a `"fireAll"` cron down for a long time on a fast interval could try to enqueue an
562
+ * unbounded number of work jobs synchronously inside one mutation. Occurrences beyond the cap are
563
+ * discarded (logged), not deferred to a later tick — the cron's cadence always re-anchors past
564
+ * the ENTIRE true backlog on the SAME tick, regardless of how much of it actually got fired.
565
+ */
566
+ declare const CATCHUP_CAP = 1000;
567
+ /** How long a claim's lease is valid before it could be reclaimed by the sweep below. */
568
+ declare const LEASE_MS = 30000;
569
+ /**
570
+ * The driver's ONLY periodic timer: how often `scheduler:_reclaim` runs to sweep `inProgress`
571
+ * jobs whose lease has expired (an infra kill mid-run — the process that claimed the job died
572
+ * before completing it). Normal dispatch stays fully reactive/event-driven; this is a backstop.
573
+ */
574
+ declare const SWEEP_MS = 30000;
575
+ interface DueJob {
576
+ _id: string;
577
+ fnPath: string;
578
+ kind: "mutation" | "action";
579
+ state: JobState;
580
+ nextTs: number;
581
+ [key: string]: unknown;
582
+ }
583
+ interface PeekDueResult {
584
+ due: DueJob[];
585
+ earliestFutureTs: number | null;
586
+ }
587
+ interface ClaimResult {
588
+ jobId: string;
589
+ fnPath: string;
590
+ kind: "mutation" | "action";
591
+ args: JSONValue;
592
+ context: JSONValue | undefined;
593
+ onComplete: string | undefined;
594
+ }
595
+ type JobResult = {
596
+ kind: "success";
597
+ value: unknown;
598
+ } | {
599
+ kind: "failed";
600
+ error: string;
601
+ retryable?: boolean;
602
+ };
603
+
604
+ /**
605
+ * A `schedulerDriver()` also exposes:
606
+ * - `__tick`: a deterministic test seam for one loop iteration (no real timers).
607
+ * - `__wake`: the same fire-and-forget signal `DriverContext.onCommit`/timers use internally,
608
+ * exposed so a test can simulate a commit notification landing at a precise moment (e.g. from
609
+ * inside a job's own mutation body, to interleave with an in-flight `__tick()`) — the reactive
610
+ * `onCommit` path itself can't be driven precisely from a test, since it fires off the real
611
+ * commit fan-out on whatever schedule the runtime gives it.
612
+ * - `__sweep`: runs the lease-reclaim sweep (`scheduler:_reclaim`) exactly once, without arming
613
+ * (or waiting on) the real `SWEEP_MS`-interval timer — the deterministic test seam for Task 4's
614
+ * infra-kill reclaim path.
615
+ */
616
+ interface SchedulerDriver extends Driver {
617
+ __tick: () => Promise<void>;
618
+ __wake: () => void;
619
+ __sweep: () => Promise<void>;
620
+ }
621
+ /**
622
+ * `@helipod/scheduler`'s driver — the event-driven loop that actually RUNS due jobs.
623
+ *
624
+ * Two wake sources, NO fixed-interval polling:
625
+ * - **Reactive**: taps the runtime's commit fan-out (`DriverContext.onCommit`) and re-runs
626
+ * `iterate()` whenever a commit touches any `scheduler/*` table (an `enqueue`/`cancel`/
627
+ * `complete` write) — so a freshly-enqueued due job gets picked up with ~0 latency.
628
+ * - **Timer**: re-arms a single wall-clock timer to `earliestFutureTs` (the soonest still-pending
629
+ * job) after every iteration, so a job scheduled for later still fires once its time arrives
630
+ * without anything scanning `jobs` in between.
631
+ *
632
+ * Single-owner: an in-process `running` flag collapses overlapping wake-ups (two commits, or a
633
+ * commit racing a timer) into a single iteration at a time. Because `running` is set
634
+ * synchronously — before the first `await` — two `iterate()` calls issued back-to-back in the
635
+ * same synchronous turn can never both proceed; the second observes `running === true` and
636
+ * returns immediately. That said, the in-process flag is only a throughput optimization, NOT the
637
+ * correctness guarantee: the AUTHORITATIVE double-run guard is `scheduler:_claim`'s snapshot-read
638
+ * + exact `state === "pending"` check (`./modules.ts`), serialized by the single-writer OCC
639
+ * transactor — even if two iterations somehow ran concurrently (e.g. two runtimes sharing a
640
+ * store), at most one `_claim` call per job ever observes `"pending"`.
641
+ *
642
+ * A wake that arrives while `running` is already true is NOT dropped: it sets a coalesced
643
+ * `pendingWake` bit that the in-flight iteration checks at the end of every pass, looping for one
644
+ * more fresh peek/claim/complete pass instead of exiting. Without this, a commit that lands
645
+ * mid-iteration (an app mutation enqueuing a due-now job between the loop's awaits) would be
646
+ * silently swallowed — the timer re-arm at the end of the pass would use the `earliestFutureTs`
647
+ * that pass captured, which doesn't account for the new job, and the job would sit `pending`
648
+ * until some unrelated future wake.
649
+ *
650
+ * A job that throws while running is caught per-job (not allowed to escape the loop), so one bad
651
+ * job can't wedge the whole batch or leave `running` stuck `true` — `_complete` is always called
652
+ * (with a `failed` result) and the outer `try/finally` always clears `running`.
653
+ *
654
+ * `iterate()` always returns a promise that settles when the due set it's responsible for has
655
+ * actually been drained — including a coalesced call. A caller that arrives while a pass is
656
+ * already in flight (setting `pendingWake` per above) gets back the SAME promise as the in-flight
657
+ * pass, not an already-resolved one: with the reactive `onCommit` wake now real (not dead code —
658
+ * see `packages/runtime-embedded/src/runtime.ts`), a test's `__tick()` frequently races an
659
+ * app-mutation's own commit, which fires `wake()` before `__tick()` gets a turn. If `__tick()`
660
+ * merely no-op'd on a coalesced call, tests would observe results before the real work finished.
661
+ * `wake()`'s callers (reactive `onCommit`/timer) never await this return value, so they're
662
+ * unaffected — this only changes behavior for synchronous callers like `__tick()`.
663
+ */
664
+ declare function schedulerDriver(): SchedulerDriver;
665
+
666
+ /**
667
+ * Exponential backoff (with jitter) for a scheduler job's Nth failure. Pure and side-effect-free
668
+ * so it's trivially unit-testable and safe to call from a deterministic mutation (`_complete` in
669
+ * `./modules.ts`) — it takes its randomness as an injected `rng`, never touching `Math.random`
670
+ * directly unless the caller passes none (the default, for callers outside a UDF's determinism
671
+ * boundary — there are none in this codebase yet, but this keeps the function usable standalone).
672
+ *
673
+ * `_complete` passes `ctx.random` (the query/mutation guest ctx's seeded PRNG — see
674
+ * `@helipod/executor`'s `seeded-random.ts`), which is what actually gives the retry jitter its
675
+ * determinism-for-replay property: a mutation and its OCC-conflict replay call `ctx.random()` in
676
+ * the same sequence and get the same numbers, and tests get a computable, non-flaky result.
677
+ */
678
+ interface BackoffOptions {
679
+ /** Backoff for the first retry (attempts=1), before jitter. */
680
+ initialBackoffMs: number;
681
+ /** Multiplier applied per additional attempt. */
682
+ base: number;
683
+ }
684
+ declare const DEFAULT_BACKOFF_OPTIONS: BackoffOptions;
685
+ /**
686
+ * `attempts` is the failure count AFTER this failure is recorded (i.e. call with the
687
+ * already-incremented `attempts`, not the pre-failure count) — so the first retry (attempts=1)
688
+ * backs off `initialBackoffMs * base^2`, jittered to 50–100% of that.
689
+ */
690
+ declare function computeBackoff(attempts: number, rng?: () => number, o?: BackoffOptions): number;
691
+
692
+ /**
693
+ * `defineScheduler()` — the `@helipod/scheduler` component: the `jobs`/`job_args`/`crons`
694
+ * schema, the `ctx.scheduler` facade (`runAfter`/`runAt`/`cancel`/`enqueue`), the
695
+ * internal `_peekDue`/`_claim`/`_complete`/`_cronTick` modules, and the `schedulerDriver`
696
+ * event-loop that actually RUNS due jobs — reactive on commits touching `scheduler/*` plus a
697
+ * wall-clock timer re-armed to the earliest future job (see `./driver.ts`).
698
+ *
699
+ * `contextWrite: true` is load-bearing: it's what lets the facade write (via the calling
700
+ * mutation's own transaction) instead of only reading — see `schedulerContext` in `./facade.ts`
701
+ * and the `ContextProvider.write` opt-in on `@helipod/executor`.
702
+ *
703
+ * `opts.crons` — an app's `crons.ts` (`export default crons` from `cronJobs()` + `.interval()`/
704
+ * `.cron()`/etc.) — is reconciled into the `crons` table once at boot (`reconcileCrons`, see its
705
+ * doc comment in `./crons.ts` for why this is a config value rather than file-discovery magic).
706
+ */
707
+ declare function defineScheduler(opts?: {
708
+ crons?: CronJobs;
709
+ }): ComponentDefinition;
710
+
711
+ export { BATCH_CAP, type BackoffOptions, CATCHUP_CAP, type CatchUpPolicy, type ClaimResult, type CronJobs, type CronOpts, type CronRegistryEntry, type CronSpec, type CronUtcOpts, DEFAULT_BACKOFF_OPTIONS, type DailyAt, type DayOfWeek, type DueJob, type EnqueueOpts, type EnqueueTables, type FnRef, type FunctionReference, type HourlyAt, type IntervalPeriod, type JobResult, type JobState, LEASE_MS, type MonthlyAt, type OnCompleteResult, type PeekDueResult, SWEEP_MS, type SchedulerActionContext, type SchedulerContext, type SchedulerDriver, type WeeklyAt, computeBackoff, computeNextRun, computePrevRun, cronJobs, defineScheduler, enqueueCadenceJob, enqueueInternal, fireOnComplete, getFunctionPath, schedulerActionContext, schedulerDriver, schedulerSchema };