bunderstack 0.4.0 → 0.6.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.
- package/README.md +21 -2
- package/package.json +3 -2
- package/src/access.ts +9 -4
- package/src/cron.ts +2 -0
- package/src/crud.ts +16 -16
- package/src/env.ts +7 -0
- package/src/handler.ts +5 -0
- package/src/index.ts +175 -29
- package/src/internal-tables-pg.ts +18 -0
- package/src/internal-tables.ts +28 -0
- package/src/jobs/cron-auth.ts +28 -0
- package/src/jobs/cron-router.ts +131 -0
- package/src/jobs/cron-runner.ts +112 -0
- package/src/jobs/define.ts +58 -22
- package/src/jobs/index.ts +18 -0
- package/src/jobs/local-cron.ts +76 -0
- package/src/jobs/queue.ts +3 -1
- package/src/jobs/runtime.ts +58 -0
- package/src/jobs/worker.ts +13 -37
- package/src/lifecycle.ts +44 -0
- package/src/manifest.ts +46 -6
- package/src/realtime/index.ts +8 -3
- package/src/realtime/redis.ts +36 -16
- package/src/storage/buckets.ts +8 -3
- package/src/storage/router.ts +4 -4
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { createHmac, timingSafeEqual } from 'node:crypto'
|
|
2
|
+
|
|
3
|
+
function canonical(taskId: string, slot: number): string {
|
|
4
|
+
return `${taskId}\n${slot}`
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function signScheduleRequest(
|
|
8
|
+
secret: string,
|
|
9
|
+
taskId: string,
|
|
10
|
+
slot: number,
|
|
11
|
+
): string {
|
|
12
|
+
const digest = createHmac('sha256', secret)
|
|
13
|
+
.update(canonical(taskId, slot))
|
|
14
|
+
.digest('hex')
|
|
15
|
+
return `sha256=${digest}`
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function verifyScheduleRequest(
|
|
19
|
+
secret: string,
|
|
20
|
+
taskId: string,
|
|
21
|
+
slot: number,
|
|
22
|
+
signature: string,
|
|
23
|
+
): boolean {
|
|
24
|
+
if (!/^sha256=[0-9a-f]{64}$/.test(signature)) return false
|
|
25
|
+
const expected = Buffer.from(signScheduleRequest(secret, taskId, slot))
|
|
26
|
+
const received = Buffer.from(signature)
|
|
27
|
+
return timingSafeEqual(expected, received)
|
|
28
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { Hono } from 'hono'
|
|
2
|
+
import { and, eq, lt } from 'drizzle-orm'
|
|
3
|
+
|
|
4
|
+
import type { AnyDb } from '../dialect'
|
|
5
|
+
import type { BackgroundDefs } from './define'
|
|
6
|
+
|
|
7
|
+
import { verifyScheduleRequest } from './cron-auth'
|
|
8
|
+
import { runCronSlot, runScheduledSlot } from './cron-runner'
|
|
9
|
+
import { cronRunsTableFor } from '../internal-tables'
|
|
10
|
+
|
|
11
|
+
const MAX_SLOT_AGE_MS = 60 * 60_000
|
|
12
|
+
const MAX_FUTURE_SLOT_MS = 60_000
|
|
13
|
+
const STORAGE_SWEEP_SCHEDULE = '0 4 * * *'
|
|
14
|
+
const SCHEDULED_RUN_RETENTION_MS = 30 * 24 * 60 * 60_000
|
|
15
|
+
|
|
16
|
+
export function buildCronRouter(args: {
|
|
17
|
+
db: AnyDb
|
|
18
|
+
defs: BackgroundDefs
|
|
19
|
+
ctx: Record<string, unknown>
|
|
20
|
+
secret: string
|
|
21
|
+
storage: { sweep: () => Promise<unknown> }
|
|
22
|
+
now?: () => number
|
|
23
|
+
}): Hono {
|
|
24
|
+
const app = new Hono()
|
|
25
|
+
const now = args.now ?? Date.now
|
|
26
|
+
|
|
27
|
+
app.post('/cron/:name', async (c) => {
|
|
28
|
+
const name = c.req.param('name')
|
|
29
|
+
const slotText = c.req.header('X-Bunderstack-Cron-Slot')
|
|
30
|
+
const signature = c.req.header('X-Bunderstack-Cron-Signature')
|
|
31
|
+
const slot = Number(slotText)
|
|
32
|
+
if (!slotText || !Number.isSafeInteger(slot) || !signature) {
|
|
33
|
+
return c.json({ error: 'invalid schedule signature' }, 401)
|
|
34
|
+
}
|
|
35
|
+
if (!verifyScheduleRequest(args.secret, `cron:${name}`, slot, signature)) {
|
|
36
|
+
return c.json({ error: 'invalid schedule signature' }, 401)
|
|
37
|
+
}
|
|
38
|
+
const definition = args.defs[name]
|
|
39
|
+
if (!definition || definition.kind !== 'cron') {
|
|
40
|
+
return c.json({ error: 'unknown cron' }, 404)
|
|
41
|
+
}
|
|
42
|
+
const current = now()
|
|
43
|
+
if (
|
|
44
|
+
slot % 60_000 !== 0 ||
|
|
45
|
+
slot < current - MAX_SLOT_AGE_MS ||
|
|
46
|
+
slot > current + MAX_FUTURE_SLOT_MS
|
|
47
|
+
) {
|
|
48
|
+
return c.json({ error: 'invalid cron slot' }, 400)
|
|
49
|
+
}
|
|
50
|
+
try {
|
|
51
|
+
const result = await runCronSlot({
|
|
52
|
+
db: args.db,
|
|
53
|
+
defs: args.defs,
|
|
54
|
+
ctx: args.ctx,
|
|
55
|
+
name,
|
|
56
|
+
slot,
|
|
57
|
+
now: current,
|
|
58
|
+
})
|
|
59
|
+
return c.json(
|
|
60
|
+
result,
|
|
61
|
+
result.status === 'running' ? 202 : 200,
|
|
62
|
+
)
|
|
63
|
+
} catch (error) {
|
|
64
|
+
if (
|
|
65
|
+
error instanceof Error &&
|
|
66
|
+
error.message === '[bunderstack] cron slot does not match its schedule'
|
|
67
|
+
) {
|
|
68
|
+
return c.json({ error: 'invalid cron slot' }, 400)
|
|
69
|
+
}
|
|
70
|
+
return c.json({ error: 'cron handler failed' }, 500)
|
|
71
|
+
}
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
app.post('/maintenance/:name', async (c) => {
|
|
75
|
+
const name = c.req.param('name')
|
|
76
|
+
const slotText = c.req.header('X-Bunderstack-Cron-Slot')
|
|
77
|
+
const signature = c.req.header('X-Bunderstack-Cron-Signature')
|
|
78
|
+
const slot = Number(slotText)
|
|
79
|
+
if (!slotText || !Number.isSafeInteger(slot) || !signature) {
|
|
80
|
+
return c.json({ error: 'invalid schedule signature' }, 401)
|
|
81
|
+
}
|
|
82
|
+
if (!verifyScheduleRequest(args.secret, `maintenance:${name}`, slot, signature)) {
|
|
83
|
+
return c.json({ error: 'invalid schedule signature' }, 401)
|
|
84
|
+
}
|
|
85
|
+
if (name !== 'storage-sweep') {
|
|
86
|
+
return c.json({ error: 'unknown maintenance task' }, 404)
|
|
87
|
+
}
|
|
88
|
+
const current = now()
|
|
89
|
+
if (
|
|
90
|
+
slot % 60_000 !== 0 ||
|
|
91
|
+
slot < current - MAX_SLOT_AGE_MS ||
|
|
92
|
+
slot > current + MAX_FUTURE_SLOT_MS
|
|
93
|
+
) {
|
|
94
|
+
return c.json({ error: 'invalid cron slot' }, 400)
|
|
95
|
+
}
|
|
96
|
+
try {
|
|
97
|
+
const result = await runScheduledSlot({
|
|
98
|
+
db: args.db,
|
|
99
|
+
taskId: 'maintenance:storage-sweep',
|
|
100
|
+
schedule: STORAGE_SWEEP_SCHEDULE,
|
|
101
|
+
slot,
|
|
102
|
+
now: current,
|
|
103
|
+
run: async () => {
|
|
104
|
+
await args.storage.sweep()
|
|
105
|
+
},
|
|
106
|
+
})
|
|
107
|
+
if (result.status === 'succeeded') {
|
|
108
|
+
const t = cronRunsTableFor(args.db)
|
|
109
|
+
await args.db
|
|
110
|
+
.delete(t)
|
|
111
|
+
.where(
|
|
112
|
+
and(
|
|
113
|
+
eq(t.status, 'succeeded'),
|
|
114
|
+
lt(t.finishedAt, current - SCHEDULED_RUN_RETENTION_MS),
|
|
115
|
+
),
|
|
116
|
+
)
|
|
117
|
+
}
|
|
118
|
+
return c.json(result, result.status === 'running' ? 202 : 200)
|
|
119
|
+
} catch (error) {
|
|
120
|
+
if (
|
|
121
|
+
error instanceof Error &&
|
|
122
|
+
error.message === '[bunderstack] cron slot does not match its schedule'
|
|
123
|
+
) {
|
|
124
|
+
return c.json({ error: 'invalid cron slot' }, 400)
|
|
125
|
+
}
|
|
126
|
+
return c.json({ error: 'maintenance handler failed' }, 500)
|
|
127
|
+
}
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
return app
|
|
131
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { and, eq, lt, or, sql } from 'drizzle-orm'
|
|
2
|
+
|
|
3
|
+
import type { AnyDb } from '../dialect'
|
|
4
|
+
import type { BackgroundDefs } from './define'
|
|
5
|
+
|
|
6
|
+
import { cronRunsTableFor } from '../internal-tables'
|
|
7
|
+
import { cronMatches, parseCron } from './cron'
|
|
8
|
+
|
|
9
|
+
const LEASE_MS = 60_000
|
|
10
|
+
|
|
11
|
+
export type CronRunResult =
|
|
12
|
+
| { status: 'succeeded' }
|
|
13
|
+
| { status: 'duplicate' }
|
|
14
|
+
| { status: 'running' }
|
|
15
|
+
|
|
16
|
+
export async function runScheduledSlot(args: {
|
|
17
|
+
db: AnyDb
|
|
18
|
+
taskId: string
|
|
19
|
+
schedule: string
|
|
20
|
+
slot: number
|
|
21
|
+
now: number
|
|
22
|
+
run: (scheduledFor: Date) => Promise<void> | void
|
|
23
|
+
}): Promise<CronRunResult> {
|
|
24
|
+
const { db, taskId, schedule, slot, now, run } = args
|
|
25
|
+
if (slot % 60_000 !== 0 || !cronMatches(parseCron(schedule), slot)) {
|
|
26
|
+
throw new Error('[bunderstack] cron slot does not match its schedule')
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const t = cronRunsTableFor(db)
|
|
30
|
+
const leaseUntil = now + LEASE_MS
|
|
31
|
+
const inserted = await db
|
|
32
|
+
.insert(t)
|
|
33
|
+
.values({
|
|
34
|
+
taskId,
|
|
35
|
+
scheduledAt: slot,
|
|
36
|
+
status: 'running',
|
|
37
|
+
attempts: 1,
|
|
38
|
+
lockedUntil: leaseUntil,
|
|
39
|
+
startedAt: now,
|
|
40
|
+
})
|
|
41
|
+
.onConflictDoNothing({ target: [t.taskId, t.scheduledAt] })
|
|
42
|
+
.returning({ taskId: t.taskId })
|
|
43
|
+
|
|
44
|
+
if (!inserted[0]) {
|
|
45
|
+
const existing = await db
|
|
46
|
+
.select({ status: t.status, lockedUntil: t.lockedUntil })
|
|
47
|
+
.from(t)
|
|
48
|
+
.where(and(eq(t.taskId, taskId), eq(t.scheduledAt, slot)))
|
|
49
|
+
.limit(1)
|
|
50
|
+
const row = existing[0]
|
|
51
|
+
if (!row || row.status === 'succeeded') return { status: 'duplicate' }
|
|
52
|
+
if (row.status === 'running' && Number(row.lockedUntil) >= now) {
|
|
53
|
+
return { status: 'running' }
|
|
54
|
+
}
|
|
55
|
+
const reclaimed = await db
|
|
56
|
+
.update(t)
|
|
57
|
+
.set({
|
|
58
|
+
status: 'running',
|
|
59
|
+
lockedUntil: leaseUntil,
|
|
60
|
+
startedAt: now,
|
|
61
|
+
attempts: sql`${t.attempts} + 1`,
|
|
62
|
+
lastError: null,
|
|
63
|
+
})
|
|
64
|
+
.where(
|
|
65
|
+
and(
|
|
66
|
+
eq(t.taskId, taskId),
|
|
67
|
+
eq(t.scheduledAt, slot),
|
|
68
|
+
or(eq(t.status, 'failed'), lt(t.lockedUntil, now)),
|
|
69
|
+
),
|
|
70
|
+
)
|
|
71
|
+
.returning({ taskId: t.taskId })
|
|
72
|
+
if (!reclaimed[0]) return { status: 'running' }
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
try {
|
|
76
|
+
await run(new Date(slot))
|
|
77
|
+
await db
|
|
78
|
+
.update(t)
|
|
79
|
+
.set({ status: 'succeeded', lockedUntil: null, finishedAt: Date.now() })
|
|
80
|
+
.where(and(eq(t.taskId, taskId), eq(t.scheduledAt, slot)))
|
|
81
|
+
return { status: 'succeeded' }
|
|
82
|
+
} catch (error) {
|
|
83
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
84
|
+
await db
|
|
85
|
+
.update(t)
|
|
86
|
+
.set({ status: 'failed', lockedUntil: null, lastError: message, finishedAt: Date.now() })
|
|
87
|
+
.where(and(eq(t.taskId, taskId), eq(t.scheduledAt, slot)))
|
|
88
|
+
throw error
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export async function runCronSlot(args: {
|
|
93
|
+
db: AnyDb
|
|
94
|
+
defs: BackgroundDefs
|
|
95
|
+
ctx: Record<string, unknown>
|
|
96
|
+
name: string
|
|
97
|
+
slot: number
|
|
98
|
+
now: number
|
|
99
|
+
}): Promise<CronRunResult> {
|
|
100
|
+
const definition = args.defs[args.name]
|
|
101
|
+
if (!definition || definition.kind !== 'cron') {
|
|
102
|
+
throw new Error(`[bunderstack] unknown cron "${args.name}"`)
|
|
103
|
+
}
|
|
104
|
+
return runScheduledSlot({
|
|
105
|
+
db: args.db,
|
|
106
|
+
taskId: `cron:${args.name}`,
|
|
107
|
+
schedule: definition.schedule,
|
|
108
|
+
slot: args.slot,
|
|
109
|
+
now: args.now,
|
|
110
|
+
run: (scheduledFor) => definition.handler({ scheduledFor }, args.ctx as never),
|
|
111
|
+
})
|
|
112
|
+
}
|
package/src/jobs/define.ts
CHANGED
|
@@ -13,7 +13,7 @@ export const DEFAULT_RETRIES = 3
|
|
|
13
13
|
export const DEFAULT_TIMEOUT_MS = 60_000
|
|
14
14
|
|
|
15
15
|
export type EnqueueOptions = {
|
|
16
|
-
/** Collapse duplicate enqueues
|
|
16
|
+
/** Collapse duplicate enqueues while the queue row is non-terminal. */
|
|
17
17
|
dedupeKey?: string
|
|
18
18
|
/** Milliseconds from now until the job becomes claimable. */
|
|
19
19
|
delay?: number
|
|
@@ -46,11 +46,12 @@ export type JobContext<
|
|
|
46
46
|
jobs: JobsRuntimeFacade
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
-
export type
|
|
49
|
+
export type QueueJobDefinition<
|
|
50
50
|
TInput,
|
|
51
51
|
TSchema extends Record<string, unknown> = Record<string, unknown>,
|
|
52
52
|
TEnvResult = Record<string, unknown>,
|
|
53
53
|
> = {
|
|
54
|
+
kind: 'job'
|
|
54
55
|
/** zod schema for the payload; parsed at enqueue AND before the handler runs. */
|
|
55
56
|
input?: ZodType<TInput>
|
|
56
57
|
/** Attempts after the first failure. Default 3 (so 4 total attempts). */
|
|
@@ -61,8 +62,6 @@ export type JobDefinition<
|
|
|
61
62
|
concurrency?: number
|
|
62
63
|
/** Lease duration in ms; an expired lease sends the job back to pending. */
|
|
63
64
|
timeout?: number
|
|
64
|
-
/** 5-field UTC cron expression. Cron jobs cannot declare `input`. */
|
|
65
|
-
cron?: string
|
|
66
65
|
handler: (
|
|
67
66
|
input: TInput,
|
|
68
67
|
ctx: JobContext<TSchema, TEnvResult>,
|
|
@@ -75,23 +74,51 @@ export type JobDefinition<
|
|
|
75
74
|
) => Promise<void> | void
|
|
76
75
|
}
|
|
77
76
|
|
|
77
|
+
export type CronInvocation = { scheduledFor: Date }
|
|
78
|
+
|
|
79
|
+
export type CronDefinition<
|
|
80
|
+
TSchema extends Record<string, unknown> = Record<string, unknown>,
|
|
81
|
+
TEnvResult = Record<string, unknown>,
|
|
82
|
+
> = {
|
|
83
|
+
kind: 'cron'
|
|
84
|
+
schedule: string
|
|
85
|
+
handler: (
|
|
86
|
+
invocation: CronInvocation,
|
|
87
|
+
ctx: JobContext<TSchema, TEnvResult>,
|
|
88
|
+
) => Promise<void> | void
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export type BackgroundDefinition =
|
|
92
|
+
| QueueJobDefinition<any, any, any>
|
|
93
|
+
| CronDefinition<any, any>
|
|
94
|
+
export type BackgroundDefs = Record<string, BackgroundDefinition>
|
|
95
|
+
|
|
96
|
+
/** @deprecated Use QueueJobDefinition. */
|
|
97
|
+
export type JobDefinition<TInput, TSchema extends Record<string, unknown> = Record<string, unknown>, TEnvResult = Record<string, unknown>> = QueueJobDefinition<
|
|
98
|
+
TInput,
|
|
99
|
+
TSchema,
|
|
100
|
+
TEnvResult
|
|
101
|
+
>
|
|
102
|
+
|
|
78
103
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
79
|
-
export type AnyJobDefinition =
|
|
80
|
-
export type JobsDefs =
|
|
104
|
+
export type AnyJobDefinition = QueueJobDefinition<any, any, any>
|
|
105
|
+
export type JobsDefs = BackgroundDefs
|
|
106
|
+
|
|
107
|
+
export type QueueJobKeys<TDefs extends BackgroundDefs> = {
|
|
108
|
+
[K in keyof TDefs & string]: TDefs[K] extends QueueJobDefinition<any, any, any>
|
|
109
|
+
? K
|
|
110
|
+
: never
|
|
111
|
+
}[keyof TDefs & string]
|
|
81
112
|
|
|
82
113
|
/** Throws when a definition is unusable. Safe to call more than once. */
|
|
83
|
-
export function
|
|
114
|
+
export function validateBackgroundDefs(defs: BackgroundDefs): void {
|
|
84
115
|
for (const [name, def] of Object.entries(defs)) {
|
|
85
116
|
if (typeof def.handler !== 'function') {
|
|
86
|
-
throw new Error(`[bunderstack]
|
|
117
|
+
throw new Error(`[bunderstack] background task "${name}" has no handler`)
|
|
87
118
|
}
|
|
88
|
-
if (def.
|
|
89
|
-
parseCron(def.
|
|
90
|
-
|
|
91
|
-
throw new Error(
|
|
92
|
-
`[bunderstack] job "${name}": cron jobs cannot declare input (nothing enqueues a payload for a schedule)`,
|
|
93
|
-
)
|
|
94
|
-
}
|
|
119
|
+
if (def.kind === 'cron') {
|
|
120
|
+
parseCron(def.schedule)
|
|
121
|
+
continue
|
|
95
122
|
}
|
|
96
123
|
if (def.retries !== undefined && (def.retries < 0 || !Number.isInteger(def.retries))) {
|
|
97
124
|
throw new Error(`[bunderstack] job "${name}": retries must be a non-negative integer`)
|
|
@@ -105,6 +132,9 @@ export function validateJobsDefs(defs: JobsDefs): void {
|
|
|
105
132
|
}
|
|
106
133
|
}
|
|
107
134
|
|
|
135
|
+
/** @deprecated Use validateBackgroundDefs. */
|
|
136
|
+
export const validateJobsDefs = validateBackgroundDefs
|
|
137
|
+
|
|
108
138
|
/** Delay in ms before retry `attempt` (1-based = the attempt that just failed). */
|
|
109
139
|
export function backoffMs(def: AnyJobDefinition, attempt: number): number {
|
|
110
140
|
const b = def.backoff
|
|
@@ -125,13 +155,19 @@ export function createJobsBuilder<
|
|
|
125
155
|
return {
|
|
126
156
|
/** Identity with inference: pins TInput from the zod schema. */
|
|
127
157
|
job<TInput = undefined>(
|
|
128
|
-
def:
|
|
129
|
-
):
|
|
130
|
-
return def
|
|
158
|
+
def: Omit<QueueJobDefinition<TInput, TSchema, TEnvResult>, 'kind'>,
|
|
159
|
+
): QueueJobDefinition<TInput, TSchema, TEnvResult> {
|
|
160
|
+
return { kind: 'job', ...def }
|
|
161
|
+
},
|
|
162
|
+
cron(
|
|
163
|
+
def: Omit<CronDefinition<TSchema, TEnvResult>, 'kind'>,
|
|
164
|
+
): CronDefinition<TSchema, TEnvResult> {
|
|
165
|
+
parseCron(def.schedule)
|
|
166
|
+
return { kind: 'cron', ...def }
|
|
131
167
|
},
|
|
132
168
|
/** Identity with validation: returns the defs map, typed. */
|
|
133
|
-
define<TDefs extends
|
|
134
|
-
|
|
169
|
+
define<TDefs extends BackgroundDefs>(defs: TDefs): TDefs {
|
|
170
|
+
validateBackgroundDefs(defs)
|
|
135
171
|
return defs
|
|
136
172
|
},
|
|
137
173
|
}
|
|
@@ -148,7 +184,7 @@ export type BunderstackJobsBuilder<
|
|
|
148
184
|
// `TDef extends { input: ZodType<infer I> }` fails structurally because
|
|
149
185
|
// `input?: ZodType<TInput>` desugars to `ZodType<TInput> | undefined`, which
|
|
150
186
|
// can never satisfy a required-property pattern.
|
|
151
|
-
type JobInputOf<TDef> = TDef extends
|
|
187
|
+
type JobInputOf<TDef> = TDef extends QueueJobDefinition<infer TInput, any, any>
|
|
152
188
|
? TInput
|
|
153
189
|
: undefined
|
|
154
190
|
|
|
@@ -162,7 +198,7 @@ export type JobsFacade<TDefs extends JobsDefs> = Omit<
|
|
|
162
198
|
JobsRuntimeFacade,
|
|
163
199
|
'enqueue'
|
|
164
200
|
> & {
|
|
165
|
-
enqueue<K extends
|
|
201
|
+
enqueue<K extends QueueJobKeys<TDefs>>(
|
|
166
202
|
name: K,
|
|
167
203
|
...rest: JobInputOf<TDefs[K]> extends undefined
|
|
168
204
|
? [input?: undefined, opts?: EnqueueOptions]
|
package/src/jobs/index.ts
CHANGED
|
@@ -1,20 +1,38 @@
|
|
|
1
1
|
// src/jobs/index.ts — module surface consumed by createBunderstack.
|
|
2
2
|
export {
|
|
3
3
|
createJobsBuilder,
|
|
4
|
+
validateBackgroundDefs,
|
|
4
5
|
validateJobsDefs,
|
|
5
6
|
DEFAULT_RETRIES,
|
|
6
7
|
DEFAULT_TIMEOUT_MS,
|
|
7
8
|
} from './define'
|
|
8
9
|
export type {
|
|
9
10
|
AnyJobDefinition,
|
|
11
|
+
BackgroundDefinition,
|
|
12
|
+
BackgroundDefs,
|
|
10
13
|
BunderstackJobsBuilder,
|
|
11
14
|
EnqueueOptions,
|
|
12
15
|
JobContext,
|
|
13
16
|
JobDefinition,
|
|
17
|
+
QueueJobDefinition,
|
|
18
|
+
CronDefinition,
|
|
19
|
+
CronInvocation,
|
|
20
|
+
QueueJobKeys,
|
|
14
21
|
JobsDefs,
|
|
15
22
|
JobsFacade,
|
|
16
23
|
JobsRuntimeFacade,
|
|
17
24
|
} from './define'
|
|
18
25
|
export { enqueueJob } from './queue'
|
|
19
26
|
export { createJobRunner } from './worker'
|
|
27
|
+
export { runCronSlot, runScheduledSlot } from './cron-runner'
|
|
28
|
+
export type { CronRunResult } from './cron-runner'
|
|
29
|
+
export { buildCronRouter } from './cron-router'
|
|
30
|
+
export { signScheduleRequest, verifyScheduleRequest } from './cron-auth'
|
|
31
|
+
export { startJobWorker } from './runtime'
|
|
32
|
+
export type { StartWorkerOptions, RunWorkerOptions, WorkerHandle } from './runtime'
|
|
33
|
+
export { startLocalCronScheduler } from './local-cron'
|
|
34
|
+
export type {
|
|
35
|
+
LocalCronScheduler,
|
|
36
|
+
LocalCronSchedulerOptions,
|
|
37
|
+
} from './local-cron'
|
|
20
38
|
export { parseCron, cronMatches } from './cron'
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { cronMatches, parseCron } from './cron'
|
|
2
|
+
|
|
3
|
+
type Timer = ReturnType<typeof setTimeout>
|
|
4
|
+
|
|
5
|
+
export type LocalCronDefinition = {
|
|
6
|
+
name: string
|
|
7
|
+
schedule: string
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export type LocalCronSchedulerOptions = {
|
|
11
|
+
cron: readonly LocalCronDefinition[]
|
|
12
|
+
runSlot: (name: string, scheduledFor: number) => Promise<void>
|
|
13
|
+
now?: () => number
|
|
14
|
+
setTimer?: (callback: () => void, delayMs: number) => Timer
|
|
15
|
+
clearTimer?: (timer: Timer) => void
|
|
16
|
+
onError?: (error: Error) => void
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export type LocalCronScheduler = {
|
|
20
|
+
tick: () => Promise<void>
|
|
21
|
+
close: () => Promise<void>
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Runs declared cron handlers locally. Production schedules should be delivered
|
|
26
|
+
* by the hosting platform through the signed cron endpoint instead.
|
|
27
|
+
*/
|
|
28
|
+
export function startLocalCronScheduler(
|
|
29
|
+
options: LocalCronSchedulerOptions,
|
|
30
|
+
): LocalCronScheduler {
|
|
31
|
+
const cron = options.cron.map((definition) => ({
|
|
32
|
+
...definition,
|
|
33
|
+
expression: parseCron(definition.schedule),
|
|
34
|
+
}))
|
|
35
|
+
const now = options.now ?? Date.now
|
|
36
|
+
const setTimer = options.setTimer ?? setTimeout
|
|
37
|
+
const clearTimer = options.clearTimer ?? clearTimeout
|
|
38
|
+
let timer: Timer | undefined
|
|
39
|
+
let closed = false
|
|
40
|
+
|
|
41
|
+
const scheduleNextTick = () => {
|
|
42
|
+
if (closed || timer) return
|
|
43
|
+
|
|
44
|
+
const delay = 60_000 - (now() % 60_000)
|
|
45
|
+
timer = setTimer(() => {
|
|
46
|
+
timer = undefined
|
|
47
|
+
void tick().catch((error: unknown) => {
|
|
48
|
+
options.onError?.(error instanceof Error ? error : new Error(String(error)))
|
|
49
|
+
})
|
|
50
|
+
}, delay)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const tick = async () => {
|
|
54
|
+
if (closed) return
|
|
55
|
+
|
|
56
|
+
const scheduledFor = Math.floor(now() / 60_000) * 60_000
|
|
57
|
+
for (const definition of cron) {
|
|
58
|
+
if (cronMatches(definition.expression, scheduledFor)) {
|
|
59
|
+
await options.runSlot(definition.name, scheduledFor)
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
scheduleNextTick()
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return {
|
|
67
|
+
tick,
|
|
68
|
+
async close() {
|
|
69
|
+
closed = true
|
|
70
|
+
if (timer) {
|
|
71
|
+
clearTimer(timer)
|
|
72
|
+
timer = undefined
|
|
73
|
+
}
|
|
74
|
+
},
|
|
75
|
+
}
|
|
76
|
+
}
|
package/src/jobs/queue.ts
CHANGED
|
@@ -15,7 +15,9 @@ export async function enqueueJob(
|
|
|
15
15
|
opts: EnqueueOptions = {},
|
|
16
16
|
): Promise<{ id: string }> {
|
|
17
17
|
const def = defs[name]
|
|
18
|
-
if (!def
|
|
18
|
+
if (!def || def.kind !== 'job') {
|
|
19
|
+
throw new Error(`[bunderstack] unknown queue job "${name}"`)
|
|
20
|
+
}
|
|
19
21
|
// Fail fast: a bad payload should throw at the call site, not in the worker.
|
|
20
22
|
const parsed = def.input ? def.input.parse(input) : null
|
|
21
23
|
const t = jobsTableFor(db)
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
export type WorkerHandle = {
|
|
2
|
+
readonly closed: Promise<void>
|
|
3
|
+
close(): Promise<void>
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export type StartWorkerOptions = {
|
|
7
|
+
signal?: AbortSignal
|
|
8
|
+
pollIntervalMs?: number
|
|
9
|
+
tick: (now: number) => Promise<void>
|
|
10
|
+
onError?: (error: Error) => void
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export type RunWorkerOptions = StartWorkerOptions
|
|
14
|
+
|
|
15
|
+
function toError(error: unknown): Error {
|
|
16
|
+
return error instanceof Error ? error : new Error(String(error))
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function wait(ms: number, signal: AbortSignal): Promise<void> {
|
|
20
|
+
return new Promise((resolve) => {
|
|
21
|
+
if (signal.aborted) return resolve()
|
|
22
|
+
const timer = setTimeout(done, ms)
|
|
23
|
+
function done() {
|
|
24
|
+
clearTimeout(timer)
|
|
25
|
+
signal.removeEventListener('abort', done)
|
|
26
|
+
resolve()
|
|
27
|
+
}
|
|
28
|
+
signal.addEventListener('abort', done, { once: true })
|
|
29
|
+
})
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function startJobWorker(options: StartWorkerOptions): WorkerHandle {
|
|
33
|
+
const controller = new AbortController()
|
|
34
|
+
const pollIntervalMs = options.pollIntervalMs ?? 1_000
|
|
35
|
+
const abort = () => controller.abort()
|
|
36
|
+
options.signal?.addEventListener('abort', abort, { once: true })
|
|
37
|
+
if (options.signal?.aborted) abort()
|
|
38
|
+
|
|
39
|
+
const closed = (async () => {
|
|
40
|
+
try {
|
|
41
|
+
while (!controller.signal.aborted) {
|
|
42
|
+
try {
|
|
43
|
+
await options.tick(Date.now())
|
|
44
|
+
} catch (error) {
|
|
45
|
+
options.onError?.(toError(error))
|
|
46
|
+
}
|
|
47
|
+
await wait(pollIntervalMs, controller.signal)
|
|
48
|
+
}
|
|
49
|
+
} finally {
|
|
50
|
+
options.signal?.removeEventListener('abort', abort)
|
|
51
|
+
}
|
|
52
|
+
})()
|
|
53
|
+
const close = () => {
|
|
54
|
+
controller.abort()
|
|
55
|
+
return closed
|
|
56
|
+
}
|
|
57
|
+
return { closed, close }
|
|
58
|
+
}
|