bunderstack 0.5.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 CHANGED
@@ -13,7 +13,7 @@ bun add bunderstack
13
13
  import { createBunderstack } from 'bunderstack'
14
14
  import * as schema from './schema'
15
15
 
16
- const app = createBunderstack({
16
+ const app = await createBunderstack({
17
17
  schema,
18
18
  auth: { emailAndPassword: { enabled: true } },
19
19
  access: {
@@ -57,9 +57,28 @@ don't throw. Then read `app.manifest`:
57
57
  process.env.BUNDERSTACK_INTROSPECT = '1'
58
58
  const { app } = await import('./src/bunderstack')
59
59
  console.log(JSON.stringify(app.manifest))
60
- // { dialect, tables, defaultBucket, buckets, realtime, env: { server, client } }
60
+ // { version: 2, dialect, tables, tableMap, systemTables, background, ... }
61
61
  ```
62
62
 
63
+ ### Background runtime
64
+
65
+ Declaring jobs does not start a worker. Queue jobs (`j.job()`) are processed by
66
+ an explicit worker process:
67
+
68
+ ```ts
69
+ import { app } from './bunderstack'
70
+
71
+ await app.runWorker()
72
+ ```
73
+
74
+ Cron tasks (`j.cron()`) are delivered by the host to
75
+ `POST /api/_bunderstack/cron/:name`; storage maintenance uses
76
+ `POST /api/_bunderstack/maintenance/storage-sweep`. Production requires the
77
+ injected `BUNDERSTACK_CRON_SECRET`. Use `await app.startCronScheduler()` only
78
+ for local standalone development. `app.manifest.background` tells Bunderhost
79
+ whether to deploy an always-on worker (queue jobs) or only HTTP-delivered cron
80
+ (cron-only).
81
+
63
82
  ## Shipping TypeScript source
64
83
 
65
84
  This package publishes raw TypeScript (`exports` point at `.ts` files). Bun
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bunderstack",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "description": "Batteries-included backend framework for Bun: CRUD APIs, auth, file storage, realtime, tRPC, email, and validated env from a single Drizzle schema and config object.",
5
5
  "keywords": [
6
6
  "backend",
@@ -39,7 +39,8 @@
39
39
  "./typeid": "./src/typeid.ts",
40
40
  "./typeid/pg": "./src/typeid-pg.ts",
41
41
  "./env": "./src/env.ts",
42
- "./trpc": "./src/trpc.ts"
42
+ "./trpc": "./src/trpc.ts",
43
+ "./cron": "./src/cron.ts"
43
44
  },
44
45
  "scripts": {
45
46
  "test": "bun test",
package/src/cron.ts ADDED
@@ -0,0 +1,2 @@
1
+ export { cronMatches, parseCron } from './jobs/cron'
2
+ export { signScheduleRequest, verifyScheduleRequest } from './jobs/cron-auth'
package/src/env.ts CHANGED
@@ -19,6 +19,7 @@ export type BaseEnv = {
19
19
  REDIS_URL?: string
20
20
  RESEND_API_KEY?: string
21
21
  SMTP_URL?: string
22
+ BUNDERSTACK_CRON_SECRET?: string
22
23
  }
23
24
 
24
25
  type InferVars<T> = T extends Record<string, ZodType>
@@ -51,6 +52,8 @@ export type ValidateEnvOptions = {
51
52
  source?: Record<string, string | undefined>
52
53
  /** Dialect-aware DATABASE_URL fallback; createBunderstack passes it. */
53
54
  defaultDatabaseUrl?: string
55
+ /** Require the platform schedule secret for an application with cron work. */
56
+ cronConfigured?: boolean
54
57
  }
55
58
 
56
59
  const DEV_AUTH_SECRET = 'dev-secret-change-in-prod'
@@ -105,10 +108,14 @@ export function validateEnv<TEnv extends EnvConfigInput | undefined>(
105
108
  REDIS_URL: source.REDIS_URL,
106
109
  RESEND_API_KEY: source.RESEND_API_KEY,
107
110
  SMTP_URL: source.SMTP_URL,
111
+ BUNDERSTACK_CRON_SECRET: source.BUNDERSTACK_CRON_SECRET,
108
112
  }
109
113
  if (isProduction && !source.AUTH_SECRET) {
110
114
  issues.push('AUTH_SECRET: required in production')
111
115
  }
116
+ if (isProduction && options.cronConfigured && !source.BUNDERSTACK_CRON_SECRET) {
117
+ issues.push('BUNDERSTACK_CRON_SECRET: required when cron is configured in production')
118
+ }
112
119
  if (options.emailProvider === 'resend' && !source.RESEND_API_KEY) {
113
120
  issues.push("RESEND_API_KEY: required when email provider is 'resend'")
114
121
  }
package/src/handler.ts CHANGED
@@ -8,6 +8,7 @@ interface HandlerParts {
8
8
  authHandler?: (req: Request) => Promise<Response>
9
9
  storageRouter?: Hono
10
10
  realtimeRouter?: Hono
11
+ cronRouter?: Hono
11
12
  trpcHandler?: (req: Request) => Promise<Response>
12
13
  rateLimit?: boolean | RateLimitConfig
13
14
  }
@@ -25,6 +26,10 @@ export function buildHandler(parts: HandlerParts): {
25
26
  app.get('/api/health', health)
26
27
  app.route('/api', parts.crudRouter)
27
28
 
29
+ if (parts.cronRouter) {
30
+ app.route('/api/_bunderstack', parts.cronRouter)
31
+ }
32
+
28
33
  if (parts.authHandler) {
29
34
  app.all('/api/auth/*', (c) => parts.authHandler!(c.req.raw))
30
35
  }
package/src/index.ts CHANGED
@@ -22,10 +22,15 @@ import { buildCrudRouter } from './crud'
22
22
  import { createDb } from './db'
23
23
  import { buildHandler } from './handler'
24
24
  import { withInternalTables } from './internal-tables'
25
+ import { Lifecycle, type LifecycleStatus } from './lifecycle'
25
26
  import {
26
27
  createJobsBuilder,
27
28
  createJobRunner,
29
+ buildCronRouter,
28
30
  enqueueJob,
31
+ runCronSlot,
32
+ startLocalCronScheduler,
33
+ startJobWorker,
29
34
  validateJobsDefs,
30
35
  } from './jobs/index'
31
36
  import type {
@@ -33,6 +38,10 @@ import type {
33
38
  EnqueueOptions,
34
39
  JobsDefs,
35
40
  JobsFacade,
41
+ LocalCronScheduler,
42
+ LocalCronSchedulerOptions,
43
+ StartWorkerOptions,
44
+ WorkerHandle,
36
45
  } from './jobs/index'
37
46
  import {
38
47
  PROVISION_INTERNALS,
@@ -48,13 +57,31 @@ import { sweepOrphans } from './storage/sweep'
48
57
 
49
58
  type AuthInstance = ReturnType<typeof createAuth>
50
59
 
60
+ function waitForWorkerShutdown(
61
+ signal: AbortSignal,
62
+ installProcessListeners: boolean,
63
+ ): Promise<void> {
64
+ if (signal.aborted) return Promise.resolve()
65
+
66
+ return new Promise((resolve) => {
67
+ const done = () => {
68
+ signal.removeEventListener('abort', done)
69
+ if (installProcessListeners) {
70
+ process.removeListener('SIGINT', done)
71
+ process.removeListener('SIGTERM', done)
72
+ }
73
+ resolve()
74
+ }
75
+ signal.addEventListener('abort', done, { once: true })
76
+ if (installProcessListeners) {
77
+ process.once('SIGINT', done)
78
+ process.once('SIGTERM', done)
79
+ }
80
+ })
81
+ }
82
+
51
83
  /** Default age before an unconfirmed `pending` file is treated as an orphan. */
52
84
  const DEFAULT_PENDING_TTL_MS = 30 * 60_000
53
- /** How often the auto-started orphan sweep runs. */
54
- const SWEEP_INTERVAL_MS = 10 * 60_000
55
- /** How often the in-process job worker polls for claimable jobs. */
56
- const JOBS_POLL_INTERVAL_MS = 1000
57
-
58
85
  /**
59
86
  * Public storage facade exposed as `app.storage`. Object-level operations live
60
87
  * on the per-bucket adapters; this surface offers the app-wide deletes that
@@ -67,12 +94,19 @@ export interface StorageFacade {
67
94
  bucket(name: string): StorageAdapter | undefined
68
95
  /**
69
96
  * Reap stale `pending` uploads older than `olderThanMs` (default 30m). Runs
70
- * automatically on an interval; exposed for manual/test invocation. Returns
97
+ * only when explicitly invoked by the host or local scheduler. Returns
71
98
  * the count reaped.
72
99
  */
73
100
  sweep(olderThanMs?: number): Promise<number>
74
101
  }
75
102
 
103
+ export type AppStartWorkerOptions = Omit<StartWorkerOptions, 'tick'>
104
+ export type AppRunWorkerOptions = AppStartWorkerOptions
105
+ export type AppStartCronSchedulerOptions = Pick<
106
+ LocalCronSchedulerOptions,
107
+ 'onError'
108
+ >
109
+
76
110
  /** Bucket names declared in a storage config; `string` when unknowable. */
77
111
  export type BucketNamesOf<TStorage> = TStorage extends {
78
112
  buckets: infer B extends Record<string, unknown>
@@ -102,6 +136,16 @@ export type BunderstackApp<
102
136
  email: EmailFacade
103
137
  /** Job queue facade; always present — enqueue throws when jobs aren't configured. */
104
138
  jobs: JobsFacade<TJobsDefs extends JobsDefs ? TJobsDefs : Record<never, never>>
139
+ startWorker(options?: AppStartWorkerOptions): Promise<WorkerHandle>
140
+ /** Run a queue worker until aborted, then close the application. */
141
+ runWorker(options?: AppRunWorkerOptions): Promise<void>
142
+ /** Start local delivery for declared cron tasks (for development only). */
143
+ startCronScheduler(
144
+ options?: AppStartCronSchedulerOptions,
145
+ ): Promise<LocalCronScheduler>
146
+ close(): Promise<void>
147
+ readonly status: LifecycleStatus
148
+ readonly signal: AbortSignal
105
149
  /** Deploy-time introspection: what this app needs provisioned. */
106
150
  manifest: BunderstackManifest
107
151
  /**
@@ -214,12 +258,21 @@ export async function createBunderstack<
214
258
  >
215
259
  > {
216
260
  const dialect = detectDialect(options.schema)
261
+ const jobsDefs: JobsDefs | undefined = options.jobs
262
+ ? typeof options.jobs === 'function'
263
+ ? options.jobs(createJobsBuilder<TSchema, ValidatedEnv<TEnv>>())
264
+ : (options.jobs as JobsDefs)
265
+ : undefined
266
+ if (jobsDefs) validateJobsDefs(jobsDefs)
217
267
  // Env is validated FIRST: the app refuses to boot on missing/invalid vars,
218
268
  // and everything downstream (config, email, trpc ctx) consumes the result.
219
269
  const env = validateEnv(options.env, {
220
270
  emailProvider: emailProviderTag(options.email),
221
271
  defaultDatabaseUrl:
222
272
  dialect === 'pg' ? 'file:./data.pglite' : 'file:./data.db',
273
+ // Storage orphan cleanup is also a signed platform schedule, so every
274
+ // production app has scheduled delivery even without user-defined cron.
275
+ cronConfigured: true,
223
276
  })
224
277
  const config = resolveConfig(options, env)
225
278
  // Introspection mode (BUNDERSTACK_INTROSPECT=1): deployment platforms import
@@ -269,7 +322,7 @@ export async function createBunderstack<
269
322
  ? redisUrl
270
323
  ? createRedisRealtimeBroker({
271
324
  access: resolvedAccess,
272
- redis: (() => {
325
+ redis: () => {
273
326
  // Redis pub/sub requires a dedicated connection (subscribe puts the client into
274
327
  // a restricted state). We use one client for commands and a second for subscribe.
275
328
  const cmdClient = new Bun.RedisClient(redisUrl)
@@ -286,8 +339,12 @@ export async function createBunderstack<
286
339
  cmdClient.ltrim(key, start, stop),
287
340
  lrange: (key: string, start: number, stop: number) =>
288
341
  cmdClient.lrange(key, start, stop),
342
+ close: () => {
343
+ cmdClient.close()
344
+ subClient.close()
345
+ },
289
346
  }
290
- })(),
347
+ },
291
348
  bufferSize: realtimeBufferSize,
292
349
  })
293
350
  : createRealtimeBroker({
@@ -311,6 +368,8 @@ export async function createBunderstack<
311
368
  })
312
369
  : undefined
313
370
  const registry = createBucketStorages(config.storage)
371
+ const lifecycle = new Lifecycle()
372
+ if (broker) lifecycle.add(() => broker.close())
314
373
  const storageRouter = buildBucketStorageRouter({
315
374
  registry,
316
375
  db,
@@ -334,18 +393,6 @@ export async function createBunderstack<
334
393
  return sweepOrphans(registry, db, olderThanMs)
335
394
  },
336
395
  }
337
- // Auto-reap orphaned `pending` uploads. `unref()` keeps this from holding the
338
- // process (and test runners) open.
339
- const sweepTimer = setInterval(() => {
340
- void sweepOrphans(registry, db, DEFAULT_PENDING_TTL_MS).catch(() => {})
341
- }, SWEEP_INTERVAL_MS)
342
- sweepTimer.unref?.()
343
- const jobsDefs: JobsDefs | undefined = options.jobs
344
- ? typeof options.jobs === 'function'
345
- ? options.jobs(createJobsBuilder<TSchema, ValidatedEnv<TEnv>>())
346
- : (options.jobs as JobsDefs)
347
- : undefined
348
- if (jobsDefs) validateJobsDefs(jobsDefs)
349
396
  const jobRunner = jobsDefs
350
397
  ? createJobRunner({
351
398
  db,
@@ -361,8 +408,6 @@ export async function createBunderstack<
361
408
  )
362
409
  }
363
410
  const result = await enqueueJob(db, jobsDefs, name, input, opts)
364
- // Wake the local worker so same-process jobs start without poll latency.
365
- if (jobRunner && !introspect) void jobRunner.tick().catch(() => {})
366
411
  return result
367
412
  },
368
413
  tick(now?: number) {
@@ -370,11 +415,78 @@ export async function createBunderstack<
370
415
  },
371
416
  }
372
417
  if (jobRunner) jobRunner.setJobsFacade(jobs)
373
- if (jobRunner && !introspect) {
374
- const jobsTimer = setInterval(() => {
375
- void jobRunner.tick().catch(() => {})
376
- }, JOBS_POLL_INTERVAL_MS)
377
- jobsTimer.unref?.()
418
+ const startWorker = async (
419
+ options: AppStartWorkerOptions = {},
420
+ ): Promise<WorkerHandle> => {
421
+ if (!jobRunner) {
422
+ throw new Error('[bunderstack] no queue jobs configured')
423
+ }
424
+ if (lifecycle.status !== 'ready') {
425
+ throw new Error('[bunderstack] application lifecycle is closed')
426
+ }
427
+ const signal = options.signal
428
+ ? AbortSignal.any([lifecycle.signal, options.signal])
429
+ : lifecycle.signal
430
+ const handle = startJobWorker({
431
+ ...options,
432
+ signal,
433
+ tick: (now) => jobRunner.tick(now),
434
+ })
435
+ const unregister = lifecycle.add(() => handle.close())
436
+ void handle.closed.finally(unregister)
437
+ return handle
438
+ }
439
+ const startCronScheduler = async (
440
+ options: AppStartCronSchedulerOptions = {},
441
+ ): Promise<LocalCronScheduler> => {
442
+ const cron = Object.entries(jobsDefs ?? {}).flatMap(([name, definition]) =>
443
+ definition.kind === 'cron'
444
+ ? [{ name, schedule: definition.schedule }]
445
+ : [],
446
+ )
447
+ if (cron.length === 0) {
448
+ throw new Error('[bunderstack] no cron tasks configured')
449
+ }
450
+ if (lifecycle.status !== 'ready') {
451
+ throw new Error('[bunderstack] application lifecycle is closed')
452
+ }
453
+ const scheduler = startLocalCronScheduler({
454
+ cron,
455
+ onError: options.onError,
456
+ runSlot: async (name, slot) => {
457
+ await runCronSlot({
458
+ db,
459
+ defs: jobsDefs!,
460
+ ctx: { db: userDb, env, email, storage },
461
+ name,
462
+ slot,
463
+ now: Date.now(),
464
+ })
465
+ },
466
+ })
467
+ const unregister = lifecycle.add(() => scheduler.close())
468
+ try {
469
+ await scheduler.tick()
470
+ } catch (error) {
471
+ unregister()
472
+ await scheduler.close()
473
+ throw error
474
+ }
475
+ return scheduler
476
+ }
477
+ const runWorker = async (
478
+ options: AppRunWorkerOptions = {},
479
+ ): Promise<void> => {
480
+ const handle = await startWorker(options)
481
+ try {
482
+ const signal = options.signal
483
+ ? AbortSignal.any([lifecycle.signal, options.signal])
484
+ : lifecycle.signal
485
+ await waitForWorkerShutdown(signal, !options.signal)
486
+ } finally {
487
+ await handle.close()
488
+ await lifecycle.close()
489
+ }
378
490
  }
379
491
  const trpcRouter: AnyRouter | undefined =
380
492
  typeof options.trpc === 'function'
@@ -396,12 +508,23 @@ export async function createBunderstack<
396
508
  }),
397
509
  })
398
510
  : undefined
511
+ const cronRouter =
512
+ env.BUNDERSTACK_CRON_SECRET
513
+ ? buildCronRouter({
514
+ db,
515
+ defs: jobsDefs ?? {},
516
+ ctx: { db: userDb, env, email, storage },
517
+ secret: env.BUNDERSTACK_CRON_SECRET,
518
+ storage,
519
+ })
520
+ : undefined
399
521
  const { handler, router } = buildHandler({
400
522
  crudRouter,
401
523
  authHandler: (req) => auth.handler(req),
402
524
  storageRouter,
403
525
  realtimeRouter,
404
526
  trpcHandler,
527
+ cronRouter,
405
528
  rateLimit: options.rateLimit,
406
529
  })
407
530
 
@@ -425,6 +548,14 @@ export async function createBunderstack<
425
548
  // narrows `enqueue` per-app from the declared job defs — same relationship
426
549
  // as `userDb` above.
427
550
  jobs: jobs as never,
551
+ startWorker,
552
+ runWorker,
553
+ startCronScheduler,
554
+ close: () => lifecycle.close(),
555
+ get status() {
556
+ return lifecycle.status
557
+ },
558
+ signal: lifecycle.signal,
428
559
  trpcRouter,
429
560
  manifest: buildManifest({
430
561
  schema: options.schema,
@@ -461,7 +592,7 @@ export type {
461
592
  export { validateEnv, createClientEnv, BunderstackEnvError } from './env'
462
593
  export type { EnvConfigInput, BaseEnv, ValidatedEnv } from './env'
463
594
  export { buildManifest } from './manifest'
464
- export type { BunderstackManifest, ManifestEnvVar, ManifestJob } from './manifest'
595
+ export type { BunderstackManifest, ManifestEnvVar } from './manifest'
465
596
  export { createEmail } from './email'
466
597
  export type {
467
598
  EmailMessage,
@@ -471,15 +602,30 @@ export type {
471
602
  } from './email'
472
603
  export { createTRPC } from './trpc'
473
604
  export type { BunderstackTRPC, TRPCContext } from './trpc'
474
- export { createJobsBuilder } from './jobs/index'
605
+ export {
606
+ createJobsBuilder,
607
+ signScheduleRequest,
608
+ verifyScheduleRequest,
609
+ } from './jobs/index'
475
610
  export type {
476
611
  BunderstackJobsBuilder,
612
+ BackgroundDefinition,
613
+ BackgroundDefs,
614
+ CronDefinition,
615
+ CronInvocation,
477
616
  EnqueueOptions,
478
617
  JobContext,
479
618
  JobDefinition,
480
619
  JobsDefs,
481
620
  JobsFacade,
482
621
  JobsRuntimeFacade,
622
+ QueueJobDefinition,
623
+ QueueJobKeys,
624
+ LocalCronScheduler,
625
+ LocalCronSchedulerOptions,
626
+ RunWorkerOptions,
627
+ StartWorkerOptions,
628
+ WorkerHandle,
483
629
  } from './jobs/index'
484
630
  export {
485
631
  defineAccess,
@@ -66,3 +66,21 @@ export const bunderstackJobsPg = pgTable(
66
66
  uniqueIndex('bjq_dedupe').on(t.type, t.dedupeKey),
67
67
  ],
68
68
  )
69
+
70
+ export const bunderstackCronRunsPg = pgTable(
71
+ '_bunderstack_cron_runs',
72
+ {
73
+ taskId: text('task_id').notNull(),
74
+ scheduledAt: bigint('scheduled_at', { mode: 'number' }).notNull(),
75
+ status: text('status').notNull(),
76
+ attempts: integer('attempts').notNull().default(0),
77
+ lockedUntil: bigint('locked_until', { mode: 'number' }),
78
+ lastError: text('last_error'),
79
+ startedAt: bigint('started_at', { mode: 'number' }),
80
+ finishedAt: bigint('finished_at', { mode: 'number' }),
81
+ },
82
+ (t) => [
83
+ primaryKey({ columns: [t.taskId, t.scheduledAt] }),
84
+ index('bcr_claim').on(t.status, t.lockedUntil),
85
+ ],
86
+ )
@@ -12,6 +12,7 @@ import {
12
12
  import { detectDialect } from './dialect'
13
13
  import {
14
14
  bunderstackFilesPg,
15
+ bunderstackCronRunsPg,
15
16
  bunderstackIdempotencyPg,
16
17
  bunderstackJobsPg,
17
18
  } from './internal-tables-pg'
@@ -73,22 +74,43 @@ export const bunderstackJobs = sqliteTable(
73
74
  ],
74
75
  )
75
76
 
77
+ export const bunderstackCronRuns = sqliteTable(
78
+ '_bunderstack_cron_runs',
79
+ {
80
+ taskId: text('task_id').notNull(),
81
+ scheduledAt: integer('scheduled_at').notNull(),
82
+ status: text('status').notNull(),
83
+ attempts: integer('attempts').notNull().default(0),
84
+ lockedUntil: integer('locked_until'),
85
+ lastError: text('last_error'),
86
+ startedAt: integer('started_at'),
87
+ finishedAt: integer('finished_at'),
88
+ },
89
+ (t) => [
90
+ primaryKey({ columns: [t.taskId, t.scheduledAt] }),
91
+ index('bcr_claim').on(t.status, t.lockedUntil),
92
+ ],
93
+ )
94
+
76
95
  export const INTERNAL_TABLES = {
77
96
  bunderstackFiles,
78
97
  bunderstackIdempotency,
79
98
  bunderstackJobs,
99
+ bunderstackCronRuns,
80
100
  } as const
81
101
 
82
102
  export const INTERNAL_TABLE_NAMES: ReadonlySet<string> = new Set([
83
103
  'bunderstack_file_meta',
84
104
  '_bunderstack_idempotency',
85
105
  '_bunderstack_jobs',
106
+ '_bunderstack_cron_runs',
86
107
  ])
87
108
 
88
109
  export const INTERNAL_TABLES_PG = {
89
110
  bunderstackFiles: bunderstackFilesPg,
90
111
  bunderstackIdempotency: bunderstackIdempotencyPg,
91
112
  bunderstackJobs: bunderstackJobsPg,
113
+ bunderstackCronRuns: bunderstackCronRunsPg,
92
114
  } as const
93
115
 
94
116
  // Both dialect twins count as "ours" for the re-export identity check.
@@ -99,6 +121,7 @@ const INTERNAL_TABLE_CANDIDATES = new Map<string, readonly unknown[]>([
99
121
  [bunderstackIdempotency, bunderstackIdempotencyPg],
100
122
  ],
101
123
  [getTableName(bunderstackJobs), [bunderstackJobs, bunderstackJobsPg]],
124
+ [getTableName(bunderstackCronRuns), [bunderstackCronRuns, bunderstackCronRunsPg]],
102
125
  ])
103
126
 
104
127
  /** Internal file-meta table matching the db's dialect. */
@@ -116,6 +139,11 @@ export function jobsTableFor(db: unknown) {
116
139
  return is(db, PgDatabase) ? bunderstackJobsPg : bunderstackJobs
117
140
  }
118
141
 
142
+ /** Internal cron-run table matching the db's dialect. */
143
+ export function cronRunsTableFor(db: unknown) {
144
+ return is(db, PgDatabase) ? bunderstackCronRunsPg : bunderstackCronRuns
145
+ }
146
+
119
147
  export function withInternalTables<TSchema extends Record<string, unknown>>(
120
148
  schema: TSchema,
121
149
  ): TSchema & typeof INTERNAL_TABLES {
@@ -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
+ }