bunderstack 0.15.2 → 0.16.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 +7 -7
- package/package.json +2 -1
- package/src/access.ts +18 -0
- package/src/config.ts +22 -44
- package/src/cron.ts +2 -1
- package/src/crud.ts +1 -9
- package/src/env.ts +9 -8
- package/src/handler.ts +5 -5
- package/src/index.ts +86 -86
- package/src/internal-tables-pg.ts +1 -17
- package/src/internal-tables.ts +0 -31
- package/src/jobs/define.ts +68 -12
- package/src/jobs/index.ts +3 -9
- package/src/jobs/queue.ts +10 -6
- package/src/jobs/slots.ts +52 -0
- package/src/jobs/worker.ts +139 -42
- package/src/manifest.ts +0 -6
- package/src/realtime/index.ts +3 -11
- package/src/realtime/redis.ts +3 -12
- package/src/routes.ts +137 -0
- package/src/jobs/cron-auth.ts +0 -28
- package/src/jobs/cron-router.ts +0 -135
- package/src/jobs/cron-runner.ts +0 -224
- package/src/jobs/local-cron.ts +0 -78
package/src/realtime/index.ts
CHANGED
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
checkAccessSync,
|
|
6
6
|
resolveSession,
|
|
7
7
|
rowMatchesScope,
|
|
8
|
+
tableEntryForName,
|
|
8
9
|
type AccessUser,
|
|
9
10
|
type AuthSessionResolver,
|
|
10
11
|
type ResolvedAccess,
|
|
@@ -55,15 +56,6 @@ export type RealtimeBroker = {
|
|
|
55
56
|
): void | Promise<void>
|
|
56
57
|
}
|
|
57
58
|
|
|
58
|
-
function tableEntry(
|
|
59
|
-
access: ResolvedAccess,
|
|
60
|
-
tableName: string,
|
|
61
|
-
): ResolvedTableAccess | undefined {
|
|
62
|
-
for (const entry of access.values()) {
|
|
63
|
-
if (entry.tableName === tableName) return entry
|
|
64
|
-
}
|
|
65
|
-
return undefined
|
|
66
|
-
}
|
|
67
59
|
|
|
68
60
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
69
61
|
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
@@ -166,7 +158,7 @@ export function createRealtimeBroker(opts: {
|
|
|
166
158
|
record: Record<string, unknown>,
|
|
167
159
|
id: unknown,
|
|
168
160
|
): boolean {
|
|
169
|
-
const entry =
|
|
161
|
+
const entry = tableEntryForName(opts.access, table)
|
|
170
162
|
if (!entry) return false
|
|
171
163
|
const topicMatch =
|
|
172
164
|
s.subscriptions.has(table) ||
|
|
@@ -232,7 +224,7 @@ export function createRealtimeBroker(opts: {
|
|
|
232
224
|
subscribers.delete(id)
|
|
233
225
|
},
|
|
234
226
|
publish(table, action, record) {
|
|
235
|
-
const entry =
|
|
227
|
+
const entry = tableEntryForName(opts.access, table)
|
|
236
228
|
if (!entry) return
|
|
237
229
|
const eventId = nextId++
|
|
238
230
|
buffer.push({ eventId, table, action, record })
|
package/src/realtime/redis.ts
CHANGED
|
@@ -12,9 +12,9 @@ import type { RealtimeAction, RealtimeBroker } from './index'
|
|
|
12
12
|
import {
|
|
13
13
|
checkAccessSync,
|
|
14
14
|
rowMatchesScope,
|
|
15
|
+
tableEntryForName,
|
|
15
16
|
type AccessUser,
|
|
16
17
|
type ResolvedAccess,
|
|
17
|
-
type ResolvedTableAccess,
|
|
18
18
|
} from '../access'
|
|
19
19
|
|
|
20
20
|
export type RedisLike = {
|
|
@@ -45,15 +45,6 @@ type WireEvent = {
|
|
|
45
45
|
record: Record<string, unknown>
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
-
function tableEntry(
|
|
49
|
-
access: ResolvedAccess,
|
|
50
|
-
name: string,
|
|
51
|
-
): ResolvedTableAccess | undefined {
|
|
52
|
-
for (const entry of access.values())
|
|
53
|
-
if (entry.tableName === name) return entry
|
|
54
|
-
return undefined
|
|
55
|
-
}
|
|
56
|
-
|
|
57
48
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
58
49
|
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
59
50
|
}
|
|
@@ -109,7 +100,7 @@ export function createRedisRealtimeBroker(opts: {
|
|
|
109
100
|
table: string,
|
|
110
101
|
record: Record<string, unknown>,
|
|
111
102
|
): boolean {
|
|
112
|
-
const entry =
|
|
103
|
+
const entry = tableEntryForName(opts.access, table)
|
|
113
104
|
if (!entry) return false
|
|
114
105
|
const id = record['id']
|
|
115
106
|
const topicMatch =
|
|
@@ -208,7 +199,7 @@ export function createRedisRealtimeBroker(opts: {
|
|
|
208
199
|
subscribers.delete(id)
|
|
209
200
|
},
|
|
210
201
|
async publish(table, action, record) {
|
|
211
|
-
if (!
|
|
202
|
+
if (!tableEntryForName(opts.access, table)) return
|
|
212
203
|
try {
|
|
213
204
|
const client = getRedis()
|
|
214
205
|
const eventId = await client.incr(counterKey)
|
package/src/routes.ts
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
// src/routes.ts — mounting user-supplied Hono routes inside the app.
|
|
2
|
+
|
|
3
|
+
/** A route as Hono reports it on `app.routes`. */
|
|
4
|
+
export type DeclaredRoute = { method: string; path: string }
|
|
5
|
+
|
|
6
|
+
const RESERVED_EXACT = ['/health', '/api/health', '/api/realtime'] as const
|
|
7
|
+
|
|
8
|
+
const RESERVED_PREFIXES = [
|
|
9
|
+
'/api/auth/',
|
|
10
|
+
'/api/trpc/',
|
|
11
|
+
'/api/files/',
|
|
12
|
+
'/files/',
|
|
13
|
+
] as const
|
|
14
|
+
|
|
15
|
+
/** The first path segment under `/api/`, or undefined when not under it. */
|
|
16
|
+
function apiSegment(path: string): string | undefined {
|
|
17
|
+
if (!path.startsWith('/api/')) return undefined
|
|
18
|
+
return path.slice('/api/'.length).split('/')[0]
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function collisionFor(
|
|
22
|
+
route: DeclaredRoute,
|
|
23
|
+
tableNames: readonly string[],
|
|
24
|
+
): string | undefined {
|
|
25
|
+
const { path } = route
|
|
26
|
+
if (RESERVED_EXACT.includes(path as (typeof RESERVED_EXACT)[number])) {
|
|
27
|
+
return `it is reserved by bunderstack`
|
|
28
|
+
}
|
|
29
|
+
for (const prefix of RESERVED_PREFIXES) {
|
|
30
|
+
if (path.startsWith(prefix)) {
|
|
31
|
+
return `"${prefix}*" is reserved by bunderstack`
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
const segment = apiSegment(path)
|
|
35
|
+
if (segment === undefined) return undefined
|
|
36
|
+
if (segment === '*' || segment.startsWith(':')) {
|
|
37
|
+
return `a parameter or wildcard here would shadow every generated CRUD route`
|
|
38
|
+
}
|
|
39
|
+
if (tableNames.includes(segment)) {
|
|
40
|
+
return `it collides with the generated CRUD route for table "${segment}"`
|
|
41
|
+
}
|
|
42
|
+
return undefined
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Throws when any declared route would collide with a bunderstack route.
|
|
47
|
+
*
|
|
48
|
+
* Custom routes are registered before the built-ins, so a collision silently
|
|
49
|
+
* shadows core behaviour — including authentication. Failing at construction is
|
|
50
|
+
* the cheapest place to find out.
|
|
51
|
+
*/
|
|
52
|
+
export function validateCustomRoutes(
|
|
53
|
+
routes: readonly DeclaredRoute[],
|
|
54
|
+
tableNames: readonly string[],
|
|
55
|
+
): void {
|
|
56
|
+
const problems: string[] = []
|
|
57
|
+
for (const route of routes) {
|
|
58
|
+
const reason = collisionFor(route, tableNames)
|
|
59
|
+
if (reason) {
|
|
60
|
+
problems.push(` ${route.method} ${route.path} — ${reason}`)
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
if (problems.length === 0) return
|
|
64
|
+
throw new Error(
|
|
65
|
+
`[bunderstack] routes: ${problems.length} route(s) collide with bunderstack's own:\n${problems.join('\n')}\nChoose different paths.`,
|
|
66
|
+
)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
import type { Hono } from 'hono'
|
|
70
|
+
|
|
71
|
+
import type { AccessUser, AuthSessionResolver } from './access'
|
|
72
|
+
import type { DbFor } from './db'
|
|
73
|
+
import type { EmailFacade } from './email'
|
|
74
|
+
import type { JobsRuntimeFacade } from './jobs/define'
|
|
75
|
+
import type { RealtimeFacade } from './realtime/facade'
|
|
76
|
+
import type { AuthInstance, StorageFacade } from './index'
|
|
77
|
+
|
|
78
|
+
import { resolveAccessUser, resolveSession } from './access'
|
|
79
|
+
|
|
80
|
+
export type RouteContext<
|
|
81
|
+
TSchema extends Record<string, unknown> = Record<string, unknown>,
|
|
82
|
+
TEnvResult = Record<string, unknown>,
|
|
83
|
+
> = {
|
|
84
|
+
db: DbFor<TSchema>
|
|
85
|
+
env: TEnvResult
|
|
86
|
+
storage: StorageFacade
|
|
87
|
+
email: EmailFacade
|
|
88
|
+
jobs: JobsRuntimeFacade
|
|
89
|
+
realtime: RealtimeFacade<TSchema>
|
|
90
|
+
auth: AuthInstance
|
|
91
|
+
/** Resolve the caller's session. Costs an auth round-trip; call only when needed. */
|
|
92
|
+
getSession(
|
|
93
|
+
request: Request,
|
|
94
|
+
): Promise<{ user: AccessUser | null; activeOrganizationId: string | null }>
|
|
95
|
+
/** Convenience wrapper over getSession when the organization is irrelevant. */
|
|
96
|
+
getUser(request: Request): Promise<AccessUser | null>
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Alias mirroring the JobContext / BunderstackJobContext pair. */
|
|
100
|
+
export type BunderstackRouteContext<
|
|
101
|
+
TSchema extends Record<string, unknown> = Record<string, unknown>,
|
|
102
|
+
TEnvResult = Record<string, unknown>,
|
|
103
|
+
> = RouteContext<TSchema, TEnvResult>
|
|
104
|
+
|
|
105
|
+
export type RoutesBuilder<
|
|
106
|
+
TSchema extends Record<string, unknown> = Record<string, unknown>,
|
|
107
|
+
TEnvResult = Record<string, unknown>,
|
|
108
|
+
> = (ctx: RouteContext<TSchema, TEnvResult>) => Hono
|
|
109
|
+
|
|
110
|
+
export function createRouteContext<
|
|
111
|
+
TSchema extends Record<string, unknown>,
|
|
112
|
+
TEnvResult,
|
|
113
|
+
>(deps: {
|
|
114
|
+
db: DbFor<TSchema>
|
|
115
|
+
env: TEnvResult
|
|
116
|
+
storage: StorageFacade
|
|
117
|
+
email: EmailFacade
|
|
118
|
+
jobs: JobsRuntimeFacade
|
|
119
|
+
realtime: RealtimeFacade<TSchema>
|
|
120
|
+
auth: AuthInstance
|
|
121
|
+
authResolver: AuthSessionResolver | undefined
|
|
122
|
+
}): RouteContext<TSchema, TEnvResult> {
|
|
123
|
+
return {
|
|
124
|
+
db: deps.db,
|
|
125
|
+
env: deps.env,
|
|
126
|
+
storage: deps.storage,
|
|
127
|
+
email: deps.email,
|
|
128
|
+
jobs: deps.jobs,
|
|
129
|
+
realtime: deps.realtime,
|
|
130
|
+
auth: deps.auth,
|
|
131
|
+
// Lazy on purpose: a webhook has no session, and resolving one eagerly
|
|
132
|
+
// would spend an auth round-trip per request on a value nobody reads.
|
|
133
|
+
getSession: (request) => resolveSession(deps.authResolver, request.headers),
|
|
134
|
+
getUser: (request) => resolveAccessUser(deps.authResolver, request.headers),
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
package/src/jobs/cron-auth.ts
DELETED
|
@@ -1,28 +0,0 @@
|
|
|
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
|
-
}
|
package/src/jobs/cron-router.ts
DELETED
|
@@ -1,135 +0,0 @@
|
|
|
1
|
-
import { and, eq, lt } from 'drizzle-orm'
|
|
2
|
-
import { Hono } from 'hono'
|
|
3
|
-
|
|
4
|
-
import type { AnyDb } from '../dialect'
|
|
5
|
-
import type { BackgroundDefs } from './define'
|
|
6
|
-
|
|
7
|
-
import { cronRunsTableFor } from '../internal-tables'
|
|
8
|
-
import { verifyScheduleRequest } from './cron-auth'
|
|
9
|
-
import { runCronSlot, runScheduledSlot } from './cron-runner'
|
|
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(result, result.status === 'running' ? 202 : 200)
|
|
60
|
-
} catch (error) {
|
|
61
|
-
if (
|
|
62
|
-
error instanceof Error &&
|
|
63
|
-
error.message === '[bunderstack] cron slot does not match its schedule'
|
|
64
|
-
) {
|
|
65
|
-
return c.json({ error: 'invalid cron slot' }, 400)
|
|
66
|
-
}
|
|
67
|
-
return c.json({ error: 'cron handler failed' }, 500)
|
|
68
|
-
}
|
|
69
|
-
})
|
|
70
|
-
|
|
71
|
-
app.post('/maintenance/:name', async (c) => {
|
|
72
|
-
const name = c.req.param('name')
|
|
73
|
-
const slotText = c.req.header('X-Bunderstack-Cron-Slot')
|
|
74
|
-
const signature = c.req.header('X-Bunderstack-Cron-Signature')
|
|
75
|
-
const slot = Number(slotText)
|
|
76
|
-
if (!slotText || !Number.isSafeInteger(slot) || !signature) {
|
|
77
|
-
return c.json({ error: 'invalid schedule signature' }, 401)
|
|
78
|
-
}
|
|
79
|
-
if (
|
|
80
|
-
!verifyScheduleRequest(
|
|
81
|
-
args.secret,
|
|
82
|
-
`maintenance:${name}`,
|
|
83
|
-
slot,
|
|
84
|
-
signature,
|
|
85
|
-
)
|
|
86
|
-
) {
|
|
87
|
-
return c.json({ error: 'invalid schedule signature' }, 401)
|
|
88
|
-
}
|
|
89
|
-
if (name !== 'storage-sweep') {
|
|
90
|
-
return c.json({ error: 'unknown maintenance task' }, 404)
|
|
91
|
-
}
|
|
92
|
-
const current = now()
|
|
93
|
-
if (
|
|
94
|
-
slot % 60_000 !== 0 ||
|
|
95
|
-
slot < current - MAX_SLOT_AGE_MS ||
|
|
96
|
-
slot > current + MAX_FUTURE_SLOT_MS
|
|
97
|
-
) {
|
|
98
|
-
return c.json({ error: 'invalid cron slot' }, 400)
|
|
99
|
-
}
|
|
100
|
-
try {
|
|
101
|
-
const result = await runScheduledSlot({
|
|
102
|
-
db: args.db,
|
|
103
|
-
taskId: 'maintenance:storage-sweep',
|
|
104
|
-
schedule: STORAGE_SWEEP_SCHEDULE,
|
|
105
|
-
slot,
|
|
106
|
-
now: current,
|
|
107
|
-
run: async () => {
|
|
108
|
-
await args.storage.sweep()
|
|
109
|
-
},
|
|
110
|
-
})
|
|
111
|
-
if (result.status === 'succeeded') {
|
|
112
|
-
const t = cronRunsTableFor(args.db)
|
|
113
|
-
await args.db
|
|
114
|
-
.delete(t)
|
|
115
|
-
.where(
|
|
116
|
-
and(
|
|
117
|
-
eq(t.status, 'succeeded'),
|
|
118
|
-
lt(t.finishedAt, current - SCHEDULED_RUN_RETENTION_MS),
|
|
119
|
-
),
|
|
120
|
-
)
|
|
121
|
-
}
|
|
122
|
-
return c.json(result, result.status === 'running' ? 202 : 200)
|
|
123
|
-
} catch (error) {
|
|
124
|
-
if (
|
|
125
|
-
error instanceof Error &&
|
|
126
|
-
error.message === '[bunderstack] cron slot does not match its schedule'
|
|
127
|
-
) {
|
|
128
|
-
return c.json({ error: 'invalid cron slot' }, 400)
|
|
129
|
-
}
|
|
130
|
-
return c.json({ error: 'maintenance handler failed' }, 500)
|
|
131
|
-
}
|
|
132
|
-
})
|
|
133
|
-
|
|
134
|
-
return app
|
|
135
|
-
}
|
package/src/jobs/cron-runner.ts
DELETED
|
@@ -1,224 +0,0 @@
|
|
|
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
|
-
leaseMs?: number
|
|
24
|
-
heartbeatIntervalMs?: number
|
|
25
|
-
heartbeatCleanupTimeoutMs?: number
|
|
26
|
-
}): Promise<CronRunResult> {
|
|
27
|
-
const { db, taskId, schedule, slot, now, run } = args
|
|
28
|
-
const leaseMs = args.leaseMs ?? LEASE_MS
|
|
29
|
-
const heartbeatIntervalMs =
|
|
30
|
-
args.heartbeatIntervalMs ?? Math.max(1, Math.floor(leaseMs / 4))
|
|
31
|
-
const heartbeatCleanupTimeoutMs = args.heartbeatCleanupTimeoutMs ?? 1_000
|
|
32
|
-
|
|
33
|
-
if (slot % 60_000 !== 0 || !cronMatches(parseCron(schedule), slot)) {
|
|
34
|
-
throw new Error('[bunderstack] cron slot does not match its schedule')
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
const t = cronRunsTableFor(db)
|
|
38
|
-
const leaseUntil = now + leaseMs
|
|
39
|
-
const inserted = await db
|
|
40
|
-
.insert(t)
|
|
41
|
-
.values({
|
|
42
|
-
taskId,
|
|
43
|
-
scheduledAt: slot,
|
|
44
|
-
status: 'running',
|
|
45
|
-
attempts: 1,
|
|
46
|
-
lockedUntil: leaseUntil,
|
|
47
|
-
startedAt: now,
|
|
48
|
-
})
|
|
49
|
-
.onConflictDoNothing({ target: [t.taskId, t.scheduledAt] })
|
|
50
|
-
.returning({ taskId: t.taskId, attempts: t.attempts })
|
|
51
|
-
|
|
52
|
-
let ownershipAttempt: number
|
|
53
|
-
if (inserted[0]) {
|
|
54
|
-
ownershipAttempt = Number(inserted[0].attempts)
|
|
55
|
-
} else {
|
|
56
|
-
const existing = await db
|
|
57
|
-
.select({ status: t.status, lockedUntil: t.lockedUntil })
|
|
58
|
-
.from(t)
|
|
59
|
-
.where(and(eq(t.taskId, taskId), eq(t.scheduledAt, slot)))
|
|
60
|
-
.limit(1)
|
|
61
|
-
const row = existing[0]
|
|
62
|
-
if (!row || row.status === 'succeeded') return { status: 'duplicate' }
|
|
63
|
-
if (row.status === 'running' && Number(row.lockedUntil) >= now) {
|
|
64
|
-
return { status: 'running' }
|
|
65
|
-
}
|
|
66
|
-
const reclaimed = await db
|
|
67
|
-
.update(t)
|
|
68
|
-
.set({
|
|
69
|
-
status: 'running',
|
|
70
|
-
lockedUntil: leaseUntil,
|
|
71
|
-
startedAt: now,
|
|
72
|
-
attempts: sql`${t.attempts} + 1`,
|
|
73
|
-
lastError: null,
|
|
74
|
-
})
|
|
75
|
-
.where(
|
|
76
|
-
and(
|
|
77
|
-
eq(t.taskId, taskId),
|
|
78
|
-
eq(t.scheduledAt, slot),
|
|
79
|
-
or(eq(t.status, 'failed'), lt(t.lockedUntil, now)),
|
|
80
|
-
),
|
|
81
|
-
)
|
|
82
|
-
.returning({ taskId: t.taskId, attempts: t.attempts })
|
|
83
|
-
const reclaimedRow = reclaimed[0]
|
|
84
|
-
if (!reclaimedRow) return { status: 'running' }
|
|
85
|
-
ownershipAttempt = Number(reclaimedRow.attempts)
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
let heartbeatTimer: Timer | undefined
|
|
89
|
-
let heartbeatInFlight: Promise<void> | undefined
|
|
90
|
-
let heartbeatStopped = false
|
|
91
|
-
|
|
92
|
-
const scheduleHeartbeat = () => {
|
|
93
|
-
heartbeatTimer = setTimeout(() => {
|
|
94
|
-
heartbeatTimer = undefined
|
|
95
|
-
if (heartbeatStopped) return
|
|
96
|
-
|
|
97
|
-
heartbeatInFlight = (async () => {
|
|
98
|
-
try {
|
|
99
|
-
const renewUntil = Date.now() + leaseMs
|
|
100
|
-
await db
|
|
101
|
-
.update(t)
|
|
102
|
-
.set({ lockedUntil: renewUntil })
|
|
103
|
-
.where(
|
|
104
|
-
and(
|
|
105
|
-
eq(t.taskId, taskId),
|
|
106
|
-
eq(t.scheduledAt, slot),
|
|
107
|
-
eq(t.status, 'running'),
|
|
108
|
-
eq(t.startedAt, now),
|
|
109
|
-
eq(t.attempts, ownershipAttempt),
|
|
110
|
-
),
|
|
111
|
-
)
|
|
112
|
-
} catch {
|
|
113
|
-
// Best effort renewal
|
|
114
|
-
}
|
|
115
|
-
})()
|
|
116
|
-
|
|
117
|
-
void heartbeatInFlight.finally(() => {
|
|
118
|
-
heartbeatInFlight = undefined
|
|
119
|
-
if (!heartbeatStopped) scheduleHeartbeat()
|
|
120
|
-
})
|
|
121
|
-
}, heartbeatIntervalMs)
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
const stopHeartbeat = async () => {
|
|
125
|
-
heartbeatStopped = true
|
|
126
|
-
if (heartbeatTimer) {
|
|
127
|
-
clearTimeout(heartbeatTimer)
|
|
128
|
-
heartbeatTimer = undefined
|
|
129
|
-
}
|
|
130
|
-
const inFlight = heartbeatInFlight
|
|
131
|
-
if (!inFlight) return
|
|
132
|
-
|
|
133
|
-
let cleanupTimer: Timer | undefined
|
|
134
|
-
try {
|
|
135
|
-
await Promise.race([
|
|
136
|
-
inFlight,
|
|
137
|
-
new Promise<void>((resolve) => {
|
|
138
|
-
cleanupTimer = setTimeout(resolve, heartbeatCleanupTimeoutMs)
|
|
139
|
-
}),
|
|
140
|
-
])
|
|
141
|
-
} finally {
|
|
142
|
-
if (cleanupTimer) clearTimeout(cleanupTimer)
|
|
143
|
-
}
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
scheduleHeartbeat()
|
|
147
|
-
|
|
148
|
-
try {
|
|
149
|
-
await run(new Date(slot))
|
|
150
|
-
|
|
151
|
-
const updated = await db
|
|
152
|
-
.update(t)
|
|
153
|
-
.set({ status: 'succeeded', lockedUntil: null, finishedAt: Date.now() })
|
|
154
|
-
.where(
|
|
155
|
-
and(
|
|
156
|
-
eq(t.taskId, taskId),
|
|
157
|
-
eq(t.scheduledAt, slot),
|
|
158
|
-
eq(t.status, 'running'),
|
|
159
|
-
eq(t.startedAt, now),
|
|
160
|
-
eq(t.attempts, ownershipAttempt),
|
|
161
|
-
),
|
|
162
|
-
)
|
|
163
|
-
.returning({ taskId: t.taskId })
|
|
164
|
-
|
|
165
|
-
if (!updated[0]) {
|
|
166
|
-
throw new Error(
|
|
167
|
-
'[bunderstack] cron lease ownership was lost during execution',
|
|
168
|
-
)
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
return { status: 'succeeded' }
|
|
172
|
-
} catch (error) {
|
|
173
|
-
const message = error instanceof Error ? error.message : String(error)
|
|
174
|
-
await db
|
|
175
|
-
.update(t)
|
|
176
|
-
.set({
|
|
177
|
-
status: 'failed',
|
|
178
|
-
lockedUntil: null,
|
|
179
|
-
lastError: message,
|
|
180
|
-
finishedAt: Date.now(),
|
|
181
|
-
})
|
|
182
|
-
.where(
|
|
183
|
-
and(
|
|
184
|
-
eq(t.taskId, taskId),
|
|
185
|
-
eq(t.scheduledAt, slot),
|
|
186
|
-
eq(t.status, 'running'),
|
|
187
|
-
eq(t.startedAt, now),
|
|
188
|
-
eq(t.attempts, ownershipAttempt),
|
|
189
|
-
),
|
|
190
|
-
)
|
|
191
|
-
throw error
|
|
192
|
-
} finally {
|
|
193
|
-
await stopHeartbeat()
|
|
194
|
-
}
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
export async function runCronSlot(args: {
|
|
198
|
-
db: AnyDb
|
|
199
|
-
defs: BackgroundDefs
|
|
200
|
-
ctx: Record<string, unknown>
|
|
201
|
-
name: string
|
|
202
|
-
slot: number
|
|
203
|
-
now: number
|
|
204
|
-
leaseMs?: number
|
|
205
|
-
heartbeatIntervalMs?: number
|
|
206
|
-
heartbeatCleanupTimeoutMs?: number
|
|
207
|
-
}): Promise<CronRunResult> {
|
|
208
|
-
const definition = args.defs[args.name]
|
|
209
|
-
if (!definition || definition.kind !== 'cron') {
|
|
210
|
-
throw new Error(`[bunderstack] unknown cron "${args.name}"`)
|
|
211
|
-
}
|
|
212
|
-
return runScheduledSlot({
|
|
213
|
-
db: args.db,
|
|
214
|
-
taskId: `cron:${args.name}`,
|
|
215
|
-
schedule: definition.schedule,
|
|
216
|
-
slot: args.slot,
|
|
217
|
-
now: args.now,
|
|
218
|
-
leaseMs: args.leaseMs,
|
|
219
|
-
heartbeatIntervalMs: args.heartbeatIntervalMs,
|
|
220
|
-
heartbeatCleanupTimeoutMs: args.heartbeatCleanupTimeoutMs,
|
|
221
|
-
run: (scheduledFor) =>
|
|
222
|
-
definition.handler({ scheduledFor }, args.ctx as never),
|
|
223
|
-
})
|
|
224
|
-
}
|
package/src/jobs/local-cron.ts
DELETED
|
@@ -1,78 +0,0 @@
|
|
|
1
|
-
import { cronMatches, parseCron } from './cron'
|
|
2
|
-
|
|
3
|
-
type Timer = ReturnType<typeof setTimeout> | number
|
|
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?.(
|
|
49
|
-
error instanceof Error ? error : new Error(String(error)),
|
|
50
|
-
)
|
|
51
|
-
})
|
|
52
|
-
}, delay)
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
const tick = async () => {
|
|
56
|
-
if (closed) return
|
|
57
|
-
|
|
58
|
-
const scheduledFor = Math.floor(now() / 60_000) * 60_000
|
|
59
|
-
for (const definition of cron) {
|
|
60
|
-
if (cronMatches(definition.expression, scheduledFor)) {
|
|
61
|
-
await options.runSlot(definition.name, scheduledFor)
|
|
62
|
-
}
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
scheduleNextTick()
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
return {
|
|
69
|
-
tick,
|
|
70
|
-
async close() {
|
|
71
|
-
closed = true
|
|
72
|
-
if (timer) {
|
|
73
|
-
clearTimer(timer)
|
|
74
|
-
timer = undefined
|
|
75
|
-
}
|
|
76
|
-
},
|
|
77
|
-
}
|
|
78
|
-
}
|