bunderstack 0.15.1 → 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/src/manifest.ts CHANGED
@@ -5,13 +5,13 @@ import type { Dialect } from './dialect'
5
5
  import type { EnvConfigInput } from './env'
6
6
  import type { JobsDefs } from './jobs/define'
7
7
  import type { ResolvedBucket, ResolvedStorageBuckets } from './storage/buckets'
8
- import { parseCron } from './jobs/cron'
8
+
9
9
  import {
10
- bunderstackCronRuns,
11
10
  bunderstackFiles,
12
11
  bunderstackIdempotency,
13
12
  bunderstackJobs,
14
13
  } from './internal-tables'
14
+ import { parseCron } from './jobs/cron'
15
15
 
16
16
  export type ManifestEnvVar = {
17
17
  key: string
@@ -47,8 +47,12 @@ const nonEmpty = z.string().min(1)
47
47
  const migrationDirectory = nonEmpty.refine(
48
48
  (value) =>
49
49
  value.startsWith('/') ||
50
- (!value.includes('\\') && value.split('/').every((part) => part !== '..' && part !== '')),
51
- { message: 'migrationsDirectory must be an absolute path or a relative path without traversal' },
50
+ (!value.includes('\\') &&
51
+ value.split('/').every((part) => part !== '..' && part !== '')),
52
+ {
53
+ message:
54
+ 'migrationsDirectory must be an absolute path or a relative path without traversal',
55
+ },
52
56
  )
53
57
  const cronSchedule = nonEmpty.refine(
54
58
  (value) => {
@@ -85,7 +89,10 @@ const manifestSchema = z
85
89
  defaultBucket: nonEmpty,
86
90
  buckets: z.array(
87
91
  z
88
- .object({ name: nonEmpty, visibility: z.enum(['public', 'private']) })
92
+ .object({
93
+ name: nonEmpty,
94
+ visibility: z.enum(['public', 'private']),
95
+ })
89
96
  .strict(),
90
97
  ),
91
98
  })
@@ -133,13 +140,16 @@ function sortBy<T>(entries: readonly T[], key: (entry: T) => string): T[] {
133
140
  function rejectDuplicates(collection: string, values: readonly string[]): void {
134
141
  const seen = new Set<string>()
135
142
  for (const value of values) {
136
- if (seen.has(value)) throw new Error(`[bunderstack] duplicate ${collection} "${value}"`)
143
+ if (seen.has(value))
144
+ throw new Error(`[bunderstack] duplicate ${collection} "${value}"`)
137
145
  seen.add(value)
138
146
  }
139
147
  }
140
148
 
141
149
  function describeTables(schema: Record<string, unknown>) {
142
- const systemNames = new Set<string>(systemTables().map((table) => table.physicalName))
150
+ const systemNames = new Set<string>(
151
+ systemTables().map((table) => table.physicalName),
152
+ )
143
153
  return sortBy(
144
154
  Object.entries(schema).flatMap(([exportName, value]) =>
145
155
  isTable(value) && !systemNames.has(getTableName(value))
@@ -163,16 +173,19 @@ function describeSection(
163
173
 
164
174
  function systemTables() {
165
175
  return [
166
- { exportName: '_system.files', physicalName: getTableName(bunderstackFiles), system: true },
176
+ {
177
+ exportName: '_system.files',
178
+ physicalName: getTableName(bunderstackFiles),
179
+ system: true,
180
+ },
167
181
  {
168
182
  exportName: '_system.idempotency',
169
183
  physicalName: getTableName(bunderstackIdempotency),
170
184
  system: true,
171
185
  },
172
- { exportName: '_system.jobs', physicalName: getTableName(bunderstackJobs), system: true },
173
186
  {
174
- exportName: '_system.scheduledRuns',
175
- physicalName: getTableName(bunderstackCronRuns),
187
+ exportName: '_system.jobs',
188
+ physicalName: getTableName(bunderstackJobs),
176
189
  system: true,
177
190
  },
178
191
  ]
@@ -180,13 +193,34 @@ function systemTables() {
180
193
 
181
194
  export function parseManifest(value: unknown): BunderstackManifest {
182
195
  const manifest = manifestSchema.parse(value) as BunderstackManifest
183
- rejectDuplicates('database physical table', manifest.database.tables.map((entry) => entry.physicalName))
184
- rejectDuplicates('database export table', manifest.database.tables.map((entry) => entry.exportName))
185
- rejectDuplicates('storage bucket', manifest.storage.buckets.map((entry) => entry.name))
186
- rejectDuplicates('environment key', manifest.environment.map((entry) => entry.key))
187
- rejectDuplicates('background job', manifest.background.jobs.map((entry) => entry.name))
188
- rejectDuplicates('background cron', manifest.background.cron.map((entry) => entry.name))
189
- rejectDuplicates('background maintenance', manifest.background.maintenance.map((entry) => entry.name))
196
+ rejectDuplicates(
197
+ 'database physical table',
198
+ manifest.database.tables.map((entry) => entry.physicalName),
199
+ )
200
+ rejectDuplicates(
201
+ 'database export table',
202
+ manifest.database.tables.map((entry) => entry.exportName),
203
+ )
204
+ rejectDuplicates(
205
+ 'storage bucket',
206
+ manifest.storage.buckets.map((entry) => entry.name),
207
+ )
208
+ rejectDuplicates(
209
+ 'environment key',
210
+ manifest.environment.map((entry) => entry.key),
211
+ )
212
+ rejectDuplicates(
213
+ 'background job',
214
+ manifest.background.jobs.map((entry) => entry.name),
215
+ )
216
+ rejectDuplicates(
217
+ 'background cron',
218
+ manifest.background.cron.map((entry) => entry.name),
219
+ )
220
+ rejectDuplicates(
221
+ 'background maintenance',
222
+ manifest.background.maintenance.map((entry) => entry.name),
223
+ )
190
224
  return manifest
191
225
  }
192
226
 
@@ -210,14 +244,20 @@ export function buildManifest(args: {
210
244
  ? [{ key: 'SMTP_URL', required: true, scope: 'server' as const }]
211
245
  : []),
212
246
  ]
213
- rejectDuplicates('environment key', environment.map((entry) => entry.key))
247
+ rejectDuplicates(
248
+ 'environment key',
249
+ environment.map((entry) => entry.key),
250
+ )
214
251
 
215
252
  return parseManifest({
216
253
  version: 3,
217
254
  database: {
218
255
  dialect: args.dialect,
219
256
  migrationsDirectory: args.migrationsDirectory,
220
- tables: sortBy([...systemTables(), ...describeTables(args.schema)], (entry) => entry.physicalName),
257
+ tables: sortBy(
258
+ [...systemTables(), ...describeTables(args.schema)],
259
+ (entry) => entry.physicalName,
260
+ ),
221
261
  },
222
262
  storage: {
223
263
  defaultBucket: args.storage.defaultBucket,
@@ -249,7 +289,11 @@ export function buildManifest(args: {
249
289
  (entry) => entry.name,
250
290
  ),
251
291
  maintenance: [
252
- { name: 'storage-sweep', schedule: '0 4 * * *', timezone: 'UTC' as const },
292
+ {
293
+ name: 'storage-sweep',
294
+ schedule: '0 4 * * *',
295
+ timezone: 'UTC' as const,
296
+ },
253
297
  ],
254
298
  },
255
299
  })
@@ -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 = tableEntry(opts.access, table)
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 = tableEntry(opts.access, table)
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 })
@@ -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 = tableEntry(opts.access, table)
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 (!tableEntry(opts.access, table)) return
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
+
@@ -266,7 +266,8 @@ export function resolveBuckets(
266
266
  input: StorageConfigInput | undefined,
267
267
  env: Record<string, string | undefined> = process.env,
268
268
  ): ResolvedStorageBuckets {
269
- const sharedBackend = platformS3Backend(env) ?? resolveSharedBackend(input, env)
269
+ const sharedBackend =
270
+ platformS3Backend(env) ?? resolveSharedBackend(input, env)
270
271
  const bucketsInput = input?.buckets
271
272
 
272
273
  const declaredNames = bucketsInput ? Object.keys(bucketsInput) : []
@@ -29,8 +29,8 @@
29
29
 
30
30
  import { eq, and, lt, sql } from 'drizzle-orm'
31
31
 
32
- import type { AnyDb } from '../dialect'
33
32
  import type { ScopeMap } from '../access'
33
+ import type { AnyDb } from '../dialect'
34
34
 
35
35
  import { rowMatchesScope } from '../access'
36
36
  import { bunderstackFiles, filesTableFor } from '../internal-tables'
@@ -27,11 +27,7 @@ import {
27
27
  sumReadySize,
28
28
  type FileMetaRow,
29
29
  } from './file-meta'
30
- import {
31
- parseTransformSpec,
32
- transformHash,
33
- transformImage,
34
- } from './thumbnails'
30
+ import { parseTransformSpec, transformHash, transformImage } from './thumbnails'
35
31
 
36
32
  export interface BucketStorageRouterOptions {
37
33
  registry: BucketStorageRegistry
@@ -379,7 +375,7 @@ export function buildBucketStorageRouter(
379
375
  if (!entry) return apiError(c, ErrorCode.NOT_FOUND, 'Unknown bucket', 404)
380
376
  const { bucket, adapter } = entry
381
377
 
382
- const mountPrefix = c.req.routePath.replace(/\/:[^\/]+\/\*$/, '')
378
+ const mountPrefix = c.req.routePath.replace(/\/:[^/]+\/\*$/, '')
383
379
  const id = c.req.path.slice(`${mountPrefix}/${bucketName}/`.length)
384
380
  const fileId = `${bucketName}/${id}`
385
381
 
package/src/storage/s3.ts CHANGED
@@ -39,11 +39,17 @@ export class S3StorageAdapter implements StorageAdapter {
39
39
  }
40
40
 
41
41
  async get(fileId: string): Promise<Response> {
42
- const exists = await this.client.exists(fileId)
43
- if (!exists) return new Response('Not found', { status: 404 })
42
+ // `stat` (a HEAD, same cost as `exists`) is the only way to learn the
43
+ // stored Content-Type: a lazily-constructed `client.file(key)` reports
44
+ // `type: ""` — it never stats, and unlike `Bun.file` it does not guess
45
+ // from the extension either.
46
+ const info = await this.stat(fileId)
47
+ if (!info) return new Response('Not found', { status: 404 })
44
48
  const file = this.client.file(fileId)
45
49
  return new Response(file.stream(), {
46
- headers: { 'Content-Type': file.type ?? 'application/octet-stream' },
50
+ headers: {
51
+ 'Content-Type': info.contentType || 'application/octet-stream',
52
+ },
47
53
  })
48
54
  }
49
55
 
package/src/trpc.ts CHANGED
@@ -5,9 +5,9 @@ import superjson from 'superjson'
5
5
  import type { AccessUser } from './access'
6
6
  import type { DbFor } from './db'
7
7
  import type { EmailFacade } from './email'
8
+ import type { StorageFacade } from './index'
8
9
  import type { JobsRuntimeFacade } from './jobs/index'
9
10
  import type { RealtimeFacade } from './realtime/facade'
10
- import type { StorageFacade } from './index'
11
11
 
12
12
  export type TRPCContext<
13
13
  TSchema extends Record<string, unknown>,
@@ -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
- }
@@ -1,131 +0,0 @@
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
- }