bunderstack 0.15.2 → 0.17.0-beta.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 +25 -138
- package/package.json +22 -14
- package/src/access.ts +24 -1
- package/src/api/api-types.types.ts +106 -0
- package/src/api/builder.ts +52 -0
- package/src/api/context.ts +83 -0
- package/src/api/crud-router.ts +321 -0
- package/src/api/openapi.ts +184 -0
- package/src/api/realtime-router.ts +75 -0
- package/src/api/registry.ts +338 -0
- package/src/api/router.ts +34 -0
- package/src/api/storage-router.ts +224 -0
- package/src/api/types.ts +84 -0
- package/src/auth.ts +5 -0
- package/src/blueprint.ts +88 -105
- package/src/config.ts +73 -77
- package/src/cron.ts +2 -1
- package/src/crud-operations.ts +488 -0
- package/src/dialect.ts +1 -1
- package/src/env.ts +28 -21
- package/src/errors.ts +90 -23
- package/src/handler.ts +16 -44
- package/src/index.ts +283 -294
- package/src/internal-tables-pg.ts +1 -17
- package/src/internal-tables.ts +0 -31
- package/src/jobs/define.ts +75 -21
- package/src/jobs/index.ts +3 -9
- package/src/jobs/queue.ts +14 -6
- package/src/jobs/slots.ts +52 -0
- package/src/jobs/worker.ts +142 -42
- package/src/manifest.ts +84 -93
- package/src/realtime/facade.ts +16 -13
- package/src/realtime/filter.ts +77 -0
- package/src/realtime/heartbeat.ts +80 -0
- package/src/realtime/publisher.ts +46 -0
- package/src/standard-schema.ts +59 -0
- package/src/storage/index.ts +8 -0
- package/src/storage/operations.ts +398 -0
- package/src/crud.ts +0 -408
- 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 +0 -250
- package/src/realtime/redis.ts +0 -228
- package/src/storage/router.ts +0 -531
- package/src/trpc.ts +0 -57
package/src/crud.ts
DELETED
|
@@ -1,408 +0,0 @@
|
|
|
1
|
-
import { eq, getTableColumns, getTableName, isTable } from 'drizzle-orm'
|
|
2
|
-
import { Hono } from 'hono'
|
|
3
|
-
|
|
4
|
-
import type { AnyDb } from './dialect'
|
|
5
|
-
import type { RealtimeFacade } from './realtime/facade'
|
|
6
|
-
|
|
7
|
-
import {
|
|
8
|
-
checkAccess,
|
|
9
|
-
resolveSession,
|
|
10
|
-
rowMatchesScope,
|
|
11
|
-
stampScope,
|
|
12
|
-
sanitizeWriteBody,
|
|
13
|
-
type AccessUser,
|
|
14
|
-
type AuthSessionResolver,
|
|
15
|
-
type CrudOperation,
|
|
16
|
-
type ResolvedAccess,
|
|
17
|
-
type ResolvedTableAccess,
|
|
18
|
-
type ScopeMap,
|
|
19
|
-
type ScopeResolver,
|
|
20
|
-
type AccessContext,
|
|
21
|
-
} from './access'
|
|
22
|
-
import { ErrorCode, apiError, ListQueryError } from './errors'
|
|
23
|
-
import {
|
|
24
|
-
lookupIdempotency,
|
|
25
|
-
resolveIdempotencyConfig,
|
|
26
|
-
storeIdempotency,
|
|
27
|
-
type IdempotencyConfig,
|
|
28
|
-
} from './idempotency'
|
|
29
|
-
import { executeList, parseListParams } from './list-query'
|
|
30
|
-
import { buildScopeWhere } from './scope'
|
|
31
|
-
|
|
32
|
-
export type CrudRouterOptions<
|
|
33
|
-
TSchema extends Record<string, unknown> = Record<string, unknown>,
|
|
34
|
-
> = {
|
|
35
|
-
auth?: AuthSessionResolver
|
|
36
|
-
access: ResolvedAccess
|
|
37
|
-
idempotency?: boolean | IdempotencyConfig
|
|
38
|
-
realtime?: RealtimeFacade<TSchema>
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
function tableEntryForName(
|
|
42
|
-
access: ResolvedAccess,
|
|
43
|
-
tableName: string,
|
|
44
|
-
): ResolvedTableAccess | undefined {
|
|
45
|
-
for (const entry of access.values()) {
|
|
46
|
-
if (entry.tableName === tableName) return entry
|
|
47
|
-
}
|
|
48
|
-
return undefined
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
52
|
-
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
async function enforce(
|
|
56
|
-
operation: CrudOperation,
|
|
57
|
-
access: ResolvedTableAccess,
|
|
58
|
-
ctx: Parameters<typeof checkAccess>[1],
|
|
59
|
-
) {
|
|
60
|
-
const rule = access[operation]
|
|
61
|
-
const result = await checkAccess(rule, ctx, access.ownerColumn)
|
|
62
|
-
return result
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
export function buildCrudRouter<TSchema extends Record<string, unknown>>(
|
|
66
|
-
schema: TSchema,
|
|
67
|
-
db: AnyDb,
|
|
68
|
-
options: CrudRouterOptions<TSchema>,
|
|
69
|
-
): Hono {
|
|
70
|
-
const router = new Hono()
|
|
71
|
-
const { auth, access, realtime } = options
|
|
72
|
-
const idempotency = resolveIdempotencyConfig(options.idempotency)
|
|
73
|
-
|
|
74
|
-
const scopeFor = (
|
|
75
|
-
resolver: ScopeResolver | undefined,
|
|
76
|
-
ctx: AccessContext,
|
|
77
|
-
): ScopeMap | undefined => (resolver ? resolver(ctx) : undefined)
|
|
78
|
-
|
|
79
|
-
for (const table of Object.values(schema)) {
|
|
80
|
-
if (!isTable(table)) continue
|
|
81
|
-
|
|
82
|
-
const name = getTableName(table)
|
|
83
|
-
const tableAccess = tableEntryForName(access, name)
|
|
84
|
-
if (!tableAccess?.enabled) continue
|
|
85
|
-
|
|
86
|
-
const idCol = getTableColumns(table)['id']
|
|
87
|
-
if (!idCol) continue
|
|
88
|
-
|
|
89
|
-
router.get(`/${name}`, async (c) => {
|
|
90
|
-
const { user, activeOrganizationId } = await resolveSession(
|
|
91
|
-
auth,
|
|
92
|
-
c.req.raw.headers,
|
|
93
|
-
)
|
|
94
|
-
const session = { activeOrganizationId }
|
|
95
|
-
const denied = await enforce('list', tableAccess, {
|
|
96
|
-
user,
|
|
97
|
-
session,
|
|
98
|
-
request: c.req.raw,
|
|
99
|
-
})
|
|
100
|
-
if (!denied.allowed) {
|
|
101
|
-
return apiError(
|
|
102
|
-
c,
|
|
103
|
-
ErrorCode.FORBIDDEN,
|
|
104
|
-
'Forbidden',
|
|
105
|
-
denied.status === 401 ? 401 : 403,
|
|
106
|
-
)
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
try {
|
|
110
|
-
const params = parseListParams(new URL(c.req.url), tableAccess)
|
|
111
|
-
const scope = scopeFor(tableAccess.readScope, {
|
|
112
|
-
user,
|
|
113
|
-
session,
|
|
114
|
-
request: c.req.raw,
|
|
115
|
-
})
|
|
116
|
-
const scopeWhere = scope ? buildScopeWhere(table, scope) : undefined
|
|
117
|
-
const result = await executeList(
|
|
118
|
-
db,
|
|
119
|
-
table,
|
|
120
|
-
tableAccess,
|
|
121
|
-
params,
|
|
122
|
-
idCol,
|
|
123
|
-
scopeWhere,
|
|
124
|
-
)
|
|
125
|
-
return c.json(result)
|
|
126
|
-
} catch (err) {
|
|
127
|
-
if (err instanceof ListQueryError) {
|
|
128
|
-
return apiError(c, err.code, err.message, 400, err.details)
|
|
129
|
-
}
|
|
130
|
-
throw err
|
|
131
|
-
}
|
|
132
|
-
})
|
|
133
|
-
|
|
134
|
-
router.get(`/${name}/:id`, async (c) => {
|
|
135
|
-
const { user, activeOrganizationId } = await resolveSession(
|
|
136
|
-
auth,
|
|
137
|
-
c.req.raw.headers,
|
|
138
|
-
)
|
|
139
|
-
const session = { activeOrganizationId }
|
|
140
|
-
const rawId = c.req.param('id')
|
|
141
|
-
const id = isNaN(Number(rawId)) ? rawId : Number(rawId)
|
|
142
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
143
|
-
const rows = await (db as any)
|
|
144
|
-
.select()
|
|
145
|
-
.from(table)
|
|
146
|
-
.where(eq(idCol as any, id))
|
|
147
|
-
if (!rows[0]) {
|
|
148
|
-
return apiError(c, ErrorCode.NOT_FOUND, 'Not found', 404)
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
const denied = await enforce('get', tableAccess, {
|
|
152
|
-
user,
|
|
153
|
-
session,
|
|
154
|
-
request: c.req.raw,
|
|
155
|
-
row: rows[0] as Record<string, unknown>,
|
|
156
|
-
})
|
|
157
|
-
if (!denied.allowed) {
|
|
158
|
-
return apiError(
|
|
159
|
-
c,
|
|
160
|
-
ErrorCode.FORBIDDEN,
|
|
161
|
-
'Forbidden',
|
|
162
|
-
denied.status === 401 ? 401 : 403,
|
|
163
|
-
)
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
const scope = scopeFor(tableAccess.readScope, {
|
|
167
|
-
user,
|
|
168
|
-
session,
|
|
169
|
-
request: c.req.raw,
|
|
170
|
-
})
|
|
171
|
-
if (
|
|
172
|
-
scope &&
|
|
173
|
-
!rowMatchesScope(rows[0] as Record<string, unknown>, scope)
|
|
174
|
-
) {
|
|
175
|
-
return apiError(c, ErrorCode.NOT_FOUND, 'Not found', 404)
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
return c.json(rows[0])
|
|
179
|
-
})
|
|
180
|
-
|
|
181
|
-
router.post(`/${name}`, async (c) => {
|
|
182
|
-
const { user, activeOrganizationId } = await resolveSession(
|
|
183
|
-
auth,
|
|
184
|
-
c.req.raw.headers,
|
|
185
|
-
)
|
|
186
|
-
const session = { activeOrganizationId }
|
|
187
|
-
const denied = await enforce('create', tableAccess, {
|
|
188
|
-
user,
|
|
189
|
-
session,
|
|
190
|
-
request: c.req.raw,
|
|
191
|
-
})
|
|
192
|
-
if (!denied.allowed) {
|
|
193
|
-
return apiError(
|
|
194
|
-
c,
|
|
195
|
-
ErrorCode.FORBIDDEN,
|
|
196
|
-
'Forbidden',
|
|
197
|
-
denied.status === 401 ? 401 : 403,
|
|
198
|
-
)
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
const rawBody = await c.req.text()
|
|
202
|
-
let body: unknown
|
|
203
|
-
try {
|
|
204
|
-
body = rawBody ? JSON.parse(rawBody) : null
|
|
205
|
-
} catch {
|
|
206
|
-
return apiError(c, ErrorCode.VALIDATION_ERROR, 'Invalid JSON', 400)
|
|
207
|
-
}
|
|
208
|
-
if (!isRecord(body)) {
|
|
209
|
-
return apiError(c, ErrorCode.VALIDATION_ERROR, 'Invalid JSON body', 400)
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
const idempotencyKey = c.req.header('Idempotency-Key')?.trim()
|
|
213
|
-
if (idempotency && idempotencyKey) {
|
|
214
|
-
const lookup = await lookupIdempotency(
|
|
215
|
-
db,
|
|
216
|
-
name,
|
|
217
|
-
idempotencyKey,
|
|
218
|
-
rawBody,
|
|
219
|
-
idempotency,
|
|
220
|
-
)
|
|
221
|
-
if (lookup.type === 'conflict') {
|
|
222
|
-
return apiError(
|
|
223
|
-
c,
|
|
224
|
-
ErrorCode.IDEMPOTENCY_CONFLICT,
|
|
225
|
-
'Idempotency key reused with different body',
|
|
226
|
-
409,
|
|
227
|
-
)
|
|
228
|
-
}
|
|
229
|
-
if (lookup.type === 'replay') {
|
|
230
|
-
return new Response(lookup.response, {
|
|
231
|
-
status: lookup.status,
|
|
232
|
-
headers: {
|
|
233
|
-
'Content-Type': 'application/json',
|
|
234
|
-
'Idempotency-Replayed': 'true',
|
|
235
|
-
},
|
|
236
|
-
})
|
|
237
|
-
}
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
const values = sanitizeWriteBody(
|
|
241
|
-
body,
|
|
242
|
-
tableAccess,
|
|
243
|
-
'create',
|
|
244
|
-
user?.id ?? null,
|
|
245
|
-
)
|
|
246
|
-
|
|
247
|
-
const scope = scopeFor(tableAccess.writeScope, {
|
|
248
|
-
user,
|
|
249
|
-
session,
|
|
250
|
-
request: c.req.raw,
|
|
251
|
-
body: body as Record<string, unknown>,
|
|
252
|
-
})
|
|
253
|
-
const stamped = scope ? stampScope(values, scope) : values
|
|
254
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
255
|
-
const rows = await (db as any).insert(table).values(stamped).returning()
|
|
256
|
-
const created = rows[0]
|
|
257
|
-
void realtime?.publish(table as never, 'create', created as never)
|
|
258
|
-
|
|
259
|
-
if (idempotency && idempotencyKey) {
|
|
260
|
-
await storeIdempotency(
|
|
261
|
-
db,
|
|
262
|
-
name,
|
|
263
|
-
idempotencyKey,
|
|
264
|
-
rawBody,
|
|
265
|
-
201,
|
|
266
|
-
created,
|
|
267
|
-
idempotency,
|
|
268
|
-
)
|
|
269
|
-
}
|
|
270
|
-
|
|
271
|
-
return c.json(created, 201)
|
|
272
|
-
})
|
|
273
|
-
|
|
274
|
-
router.patch(`/${name}/:id`, async (c) => {
|
|
275
|
-
const { user, activeOrganizationId } = await resolveSession(
|
|
276
|
-
auth,
|
|
277
|
-
c.req.raw.headers,
|
|
278
|
-
)
|
|
279
|
-
const session = { activeOrganizationId }
|
|
280
|
-
const rawId = c.req.param('id')
|
|
281
|
-
const id = isNaN(Number(rawId)) ? rawId : Number(rawId)
|
|
282
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
283
|
-
const existing = await (db as any)
|
|
284
|
-
.select()
|
|
285
|
-
.from(table)
|
|
286
|
-
.where(eq(idCol as any, id))
|
|
287
|
-
if (!existing[0]) {
|
|
288
|
-
return apiError(c, ErrorCode.NOT_FOUND, 'Not found', 404)
|
|
289
|
-
}
|
|
290
|
-
|
|
291
|
-
const readScope = scopeFor(tableAccess.readScope, {
|
|
292
|
-
user,
|
|
293
|
-
session,
|
|
294
|
-
request: c.req.raw,
|
|
295
|
-
})
|
|
296
|
-
if (
|
|
297
|
-
readScope &&
|
|
298
|
-
!rowMatchesScope(existing[0] as Record<string, unknown>, readScope)
|
|
299
|
-
) {
|
|
300
|
-
return apiError(c, ErrorCode.NOT_FOUND, 'Not found', 404)
|
|
301
|
-
}
|
|
302
|
-
|
|
303
|
-
const denied = await enforce('update', tableAccess, {
|
|
304
|
-
user,
|
|
305
|
-
session,
|
|
306
|
-
request: c.req.raw,
|
|
307
|
-
row: existing[0] as Record<string, unknown>,
|
|
308
|
-
})
|
|
309
|
-
if (!denied.allowed) {
|
|
310
|
-
return apiError(
|
|
311
|
-
c,
|
|
312
|
-
ErrorCode.FORBIDDEN,
|
|
313
|
-
'Forbidden',
|
|
314
|
-
denied.status === 401 ? 401 : 403,
|
|
315
|
-
)
|
|
316
|
-
}
|
|
317
|
-
|
|
318
|
-
let body: unknown
|
|
319
|
-
try {
|
|
320
|
-
body = await c.req.json()
|
|
321
|
-
} catch {
|
|
322
|
-
return apiError(c, ErrorCode.VALIDATION_ERROR, 'Invalid JSON', 400)
|
|
323
|
-
}
|
|
324
|
-
if (!isRecord(body)) {
|
|
325
|
-
return apiError(c, ErrorCode.VALIDATION_ERROR, 'Invalid JSON body', 400)
|
|
326
|
-
}
|
|
327
|
-
|
|
328
|
-
const values = sanitizeWriteBody(
|
|
329
|
-
body,
|
|
330
|
-
tableAccess,
|
|
331
|
-
'update',
|
|
332
|
-
user?.id ?? null,
|
|
333
|
-
)
|
|
334
|
-
|
|
335
|
-
const writeScope = scopeFor(tableAccess.writeScope, {
|
|
336
|
-
user,
|
|
337
|
-
session,
|
|
338
|
-
request: c.req.raw,
|
|
339
|
-
body: body as Record<string, unknown>,
|
|
340
|
-
})
|
|
341
|
-
const stamped = writeScope ? stampScope(values, writeScope) : values
|
|
342
|
-
|
|
343
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
344
|
-
const rows = await (db as any)
|
|
345
|
-
.update(table)
|
|
346
|
-
.set(stamped)
|
|
347
|
-
.where(eq(idCol as any, id))
|
|
348
|
-
.returning()
|
|
349
|
-
if (!rows[0]) {
|
|
350
|
-
return apiError(c, ErrorCode.NOT_FOUND, 'Not found', 404)
|
|
351
|
-
}
|
|
352
|
-
void realtime?.publish(table as never, 'update', rows[0] as never)
|
|
353
|
-
return c.json(rows[0])
|
|
354
|
-
})
|
|
355
|
-
|
|
356
|
-
router.delete(`/${name}/:id`, async (c) => {
|
|
357
|
-
const { user, activeOrganizationId } = await resolveSession(
|
|
358
|
-
auth,
|
|
359
|
-
c.req.raw.headers,
|
|
360
|
-
)
|
|
361
|
-
const session = { activeOrganizationId }
|
|
362
|
-
const rawId = c.req.param('id')
|
|
363
|
-
const id = isNaN(Number(rawId)) ? rawId : Number(rawId)
|
|
364
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
365
|
-
const existing = await (db as any)
|
|
366
|
-
.select()
|
|
367
|
-
.from(table)
|
|
368
|
-
.where(eq(idCol as any, id))
|
|
369
|
-
if (!existing[0]) {
|
|
370
|
-
return apiError(c, ErrorCode.NOT_FOUND, 'Not found', 404)
|
|
371
|
-
}
|
|
372
|
-
|
|
373
|
-
const scope = scopeFor(tableAccess.readScope, {
|
|
374
|
-
user,
|
|
375
|
-
session,
|
|
376
|
-
request: c.req.raw,
|
|
377
|
-
})
|
|
378
|
-
if (
|
|
379
|
-
scope &&
|
|
380
|
-
!rowMatchesScope(existing[0] as Record<string, unknown>, scope)
|
|
381
|
-
) {
|
|
382
|
-
return apiError(c, ErrorCode.NOT_FOUND, 'Not found', 404)
|
|
383
|
-
}
|
|
384
|
-
|
|
385
|
-
const denied = await enforce('delete', tableAccess, {
|
|
386
|
-
user,
|
|
387
|
-
session,
|
|
388
|
-
request: c.req.raw,
|
|
389
|
-
row: existing[0] as Record<string, unknown>,
|
|
390
|
-
})
|
|
391
|
-
if (!denied.allowed) {
|
|
392
|
-
return apiError(
|
|
393
|
-
c,
|
|
394
|
-
ErrorCode.FORBIDDEN,
|
|
395
|
-
'Forbidden',
|
|
396
|
-
denied.status === 401 ? 401 : 403,
|
|
397
|
-
)
|
|
398
|
-
}
|
|
399
|
-
|
|
400
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
401
|
-
await (db as any).delete(table).where(eq(idCol as any, id))
|
|
402
|
-
void realtime?.publish(table as never, 'delete', existing[0] as never)
|
|
403
|
-
return new Response(null, { status: 204 })
|
|
404
|
-
})
|
|
405
|
-
}
|
|
406
|
-
|
|
407
|
-
return router
|
|
408
|
-
}
|
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
|
-
}
|