bunderstack 0.2.0 → 0.4.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
@@ -27,6 +27,39 @@ Bun.serve({ fetch: app.handler })
27
27
  Full documentation and examples:
28
28
  [github.com/kirill-dev-pro/bunderstack](https://github.com/kirill-dev-pro/bunderstack)
29
29
 
30
+ ## Platform deployment contract
31
+
32
+ Deployment platforms (like Bunderhost) integrate with any bunderstack app
33
+ through env vars alone — no code changes required.
34
+
35
+ ### Overrides (beat code-level config)
36
+
37
+ | Var | Effect |
38
+ | --- | --- |
39
+ | `BUNDERSTACK_DATABASE_URL` | Database URL; wins over `database.url` in code |
40
+ | `BUNDERSTACK_DATABASE_AUTH_TOKEN` | Auth token for the database |
41
+ | `BUNDERSTACK_S3_ENDPOINT` | Forces ALL buckets onto this S3 backend (code-level `local`/per-bucket `s3` blocks are ignored) |
42
+ | `BUNDERSTACK_S3_BUCKET` | Physical bucket name (logical buckets become key prefixes) |
43
+ | `BUNDERSTACK_S3_ACCESS_KEY_ID` / `BUNDERSTACK_S3_SECRET_ACCESS_KEY` | Credentials |
44
+ | `BUNDERSTACK_S3_REGION` | Region (default `auto`) |
45
+ | `BUNDERSTACK_S3_PUBLIC_URL` | Public base URL for `visibility: 'public'` buckets |
46
+
47
+ Plain `DATABASE_URL` / `S3_*` vars keep their usual role: fallbacks that
48
+ code-level config wins over.
49
+
50
+ ### Introspection
51
+
52
+ Set `BUNDERSTACK_INTROSPECT=1` and import the app declaration: the boot is
53
+ guaranteed offline (in-memory database, no Redis) and missing user env vars
54
+ don't throw. Then read `app.manifest`:
55
+
56
+ ```ts
57
+ process.env.BUNDERSTACK_INTROSPECT = '1'
58
+ const { app } = await import('./src/bunderstack')
59
+ console.log(JSON.stringify(app.manifest))
60
+ // { dialect, tables, defaultBucket, buckets, realtime, env: { server, client } }
61
+ ```
62
+
30
63
  ## Shipping TypeScript source
31
64
 
32
65
  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.2.0",
3
+ "version": "0.4.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",
package/src/config.ts CHANGED
@@ -40,6 +40,8 @@ export const BunderstackOptionsSchema = z.object({
40
40
  email: z.unknown().optional(),
41
41
  // Loose: a tRPC router or builder callback. Resolved in createBunderstack.
42
42
  trpc: z.unknown().optional(),
43
+ // Loose: holds handler functions and zod schemas. Resolved in createBunderstack.
44
+ jobs: z.unknown().optional(),
43
45
  rateLimit: z
44
46
  .union([
45
47
  z.boolean(),
@@ -80,7 +82,7 @@ export type BunderstackConfig<
80
82
  TEnv extends EnvConfigInput | undefined = EnvConfigInput | undefined,
81
83
  > = Omit<
82
84
  z.input<typeof BunderstackOptionsSchema>,
83
- 'schema' | 'access' | 'auth' | 'storage' | 'env' | 'email' | 'trpc'
85
+ 'schema' | 'access' | 'auth' | 'storage' | 'env' | 'email' | 'trpc' | 'jobs'
84
86
  > & {
85
87
  schema: TSchema
86
88
  access?: TAccess
@@ -90,7 +92,8 @@ export type BunderstackConfig<
90
92
  email?: EmailConfigInput
91
93
  // `trpc` is intentionally NOT declared here: createBunderstack intersects
92
94
  // its own inference-friendly `trpc` declaration (router | builder callback)
93
- // so the callback's `t` parameter gets contextual typing.
95
+ // so the callback's `t` parameter gets contextual typing. `jobs` follows the
96
+ // same pattern (defs map | builder callback receiving `j`).
94
97
  rateLimit?: boolean | RateLimitConfig
95
98
  idempotency?: boolean | IdempotencyConfig
96
99
  realtime?:
@@ -118,6 +121,12 @@ export type ResolvedConfig = {
118
121
  export function resolveConfig<TSchema extends Record<string, unknown>>(
119
122
  options: BunderstackConfig<TSchema>,
120
123
  env?: BaseEnv,
124
+ // Platform-injected overrides (Bunderhost & co.) beat code-level config so
125
+ // apps with hardcoded local urls deploy unchanged.
126
+ platformSource: Record<string, string | undefined> = process.env as Record<
127
+ string,
128
+ string | undefined
129
+ >,
121
130
  ): ResolvedConfig {
122
131
  const parsed = BunderstackOptionsSchema.parse(options)
123
132
  // Self-validate when the caller didn't pass a pre-validated env, so
@@ -127,8 +136,14 @@ export function resolveConfig<TSchema extends Record<string, unknown>>(
127
136
 
128
137
  return {
129
138
  database: {
130
- url: parsed.database?.url ?? resolvedEnv.DATABASE_URL,
131
- authToken: parsed.database?.authToken ?? resolvedEnv.DATABASE_AUTH_TOKEN,
139
+ url:
140
+ platformSource['BUNDERSTACK_DATABASE_URL'] ??
141
+ parsed.database?.url ??
142
+ resolvedEnv.DATABASE_URL,
143
+ authToken:
144
+ platformSource['BUNDERSTACK_DATABASE_AUTH_TOKEN'] ??
145
+ parsed.database?.authToken ??
146
+ resolvedEnv.DATABASE_AUTH_TOKEN,
132
147
  migrations: parsed.database?.migrations ?? './migrations',
133
148
  },
134
149
  auth: (() => {
@@ -138,7 +153,7 @@ export function resolveConfig<TSchema extends Record<string, unknown>>(
138
153
  secret: authInput.secret ?? resolvedEnv.AUTH_SECRET,
139
154
  }
140
155
  })(),
141
- storage: resolveBuckets(options.storage),
156
+ storage: resolveBuckets(options.storage, platformSource),
142
157
  realtime: parsed.realtime,
143
158
  }
144
159
  }
package/src/env.ts CHANGED
@@ -120,7 +120,10 @@ export function validateEnv<TEnv extends EnvConfigInput | undefined>(
120
120
  validateSection(envConfig?.server, 'server', source, issues, userVars)
121
121
  validateSection(envConfig?.client, 'client', source, issues, userVars)
122
122
 
123
- if (issues.length > 0) throw new BunderstackEnvError(issues)
123
+ // Introspection (Bunderhost builder) imports the app declaration to read
124
+ // its manifest; missing user env must not kill the boot there.
125
+ const lenient = source.BUNDERSTACK_INTROSPECT === '1'
126
+ if (issues.length > 0 && !lenient) throw new BunderstackEnvError(issues)
124
127
  return { ...base, ...userVars } as ValidatedEnv<TEnv>
125
128
  }
126
129
 
package/src/index.ts CHANGED
@@ -16,11 +16,24 @@ import { resolveRealtimeRedisUrl } from './config'
16
16
  import { detectDialect } from './dialect'
17
17
  import { createEmail, emailProviderTag, type EmailFacade } from './email'
18
18
  import { validateEnv, type EnvConfigInput, type ValidatedEnv } from './env'
19
+ import { buildManifest, type BunderstackManifest } from './manifest'
19
20
  import { createTRPC, type BunderstackTRPC } from './trpc'
20
21
  import { buildCrudRouter } from './crud'
21
22
  import { createDb } from './db'
22
23
  import { buildHandler } from './handler'
23
24
  import { withInternalTables } from './internal-tables'
25
+ import {
26
+ createJobsBuilder,
27
+ createJobRunner,
28
+ enqueueJob,
29
+ validateJobsDefs,
30
+ } from './jobs/index'
31
+ import type {
32
+ BunderstackJobsBuilder,
33
+ EnqueueOptions,
34
+ JobsDefs,
35
+ JobsFacade,
36
+ } from './jobs/index'
24
37
  import {
25
38
  PROVISION_INTERNALS,
26
39
  type WithProvisionInternals,
@@ -39,6 +52,8 @@ type AuthInstance = ReturnType<typeof createAuth>
39
52
  const DEFAULT_PENDING_TTL_MS = 30 * 60_000
40
53
  /** How often the auto-started orphan sweep runs. */
41
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
42
57
 
43
58
  /**
44
59
  * Public storage facade exposed as `app.storage`. Object-level operations live
@@ -72,6 +87,7 @@ export type BunderstackApp<
72
87
  TBuckets extends string = string,
73
88
  TEnv extends EnvConfigInput | undefined = undefined,
74
89
  TRouter = undefined,
90
+ TJobsDefs extends JobsDefs | undefined = undefined,
75
91
  > = {
76
92
  handler: (req: Request) => Promise<Response>
77
93
  db: DbFor<TSchema>
@@ -84,6 +100,10 @@ export type BunderstackApp<
84
100
  env: ValidatedEnv<TEnv>
85
101
  /** Email facade; always present — send() throws when email isn't configured. */
86
102
  email: EmailFacade
103
+ /** Job queue facade; always present — enqueue throws when jobs aren't configured. */
104
+ jobs: JobsFacade<TJobsDefs extends JobsDefs ? TJobsDefs : Record<never, never>>
105
+ /** Deploy-time introspection: what this app needs provisioned. */
106
+ manifest: BunderstackManifest
87
107
  /**
88
108
  * Type-only carrier for client inference (`createClient<typeof app>()`).
89
109
  * Never assigned at runtime.
@@ -99,7 +119,27 @@ export type BunderstackApp<
99
119
  // Overloads: the builder-callback form and the prebuilt-router/none form are
100
120
  // separate signatures so the callback's `t` parameter gets contextual typing
101
121
  // and the router type lands on `$inferClient` without conditional-type
102
- // inference (which breaks under contextual return types).
122
+ // inference (which breaks under contextual return types). `jobs` needs the
123
+ // same split against BOTH trpc forms — a union parameter type (`TJobsDefs |
124
+ // (callback => TJobsDefs)`) defeats inference (TS widens TJobsDefs to its
125
+ // constraint when a function literal could match either union arm) — hence
126
+ // four overloads covering the trpc × jobs option cross product.
127
+ export function createBunderstack<
128
+ TSchema extends Record<string, unknown>,
129
+ const TAccess extends Record<string, TableAccessInput> | undefined =
130
+ undefined,
131
+ const TStorage extends StorageConfigInput | undefined = undefined,
132
+ const TEnv extends EnvConfigInput | undefined = undefined,
133
+ TRouter extends AnyRouter = AnyRouter,
134
+ const TJobsDefs extends JobsDefs | undefined = undefined,
135
+ >(
136
+ options: BunderstackConfig<TSchema, TAccess, TStorage, TEnv> & {
137
+ /** Builder callback receiving the pre-wired `t` instance. */
138
+ trpc: (t: BunderstackTRPC<TSchema, ValidatedEnv<TEnv>>) => TRouter
139
+ /** Builder callback receiving the pre-wired `j` instance. */
140
+ jobs: (j: BunderstackJobsBuilder<TSchema, ValidatedEnv<TEnv>>) => TJobsDefs
141
+ },
142
+ ): Promise<BunderstackApp<TSchema, TAccess, BucketNamesOf<TStorage>, TEnv, TRouter, TJobsDefs>>
103
143
  export function createBunderstack<
104
144
  TSchema extends Record<string, unknown>,
105
145
  const TAccess extends Record<string, TableAccessInput> | undefined =
@@ -107,12 +147,31 @@ export function createBunderstack<
107
147
  const TStorage extends StorageConfigInput | undefined = undefined,
108
148
  const TEnv extends EnvConfigInput | undefined = undefined,
109
149
  TRouter extends AnyRouter = AnyRouter,
150
+ const TJobsDefs extends JobsDefs | undefined = undefined,
110
151
  >(
111
152
  options: BunderstackConfig<TSchema, TAccess, TStorage, TEnv> & {
112
153
  /** Builder callback receiving the pre-wired `t` instance. */
113
154
  trpc: (t: BunderstackTRPC<TSchema, ValidatedEnv<TEnv>>) => TRouter
155
+ /** Prebuilt job definitions (escape hatch for multi-file setups). */
156
+ jobs?: TJobsDefs
157
+ },
158
+ ): Promise<BunderstackApp<TSchema, TAccess, BucketNamesOf<TStorage>, TEnv, TRouter, TJobsDefs>>
159
+ export function createBunderstack<
160
+ TSchema extends Record<string, unknown>,
161
+ const TAccess extends Record<string, TableAccessInput> | undefined =
162
+ undefined,
163
+ const TStorage extends StorageConfigInput | undefined = undefined,
164
+ const TEnv extends EnvConfigInput | undefined = undefined,
165
+ TRouter extends AnyRouter | undefined = undefined,
166
+ const TJobsDefs extends JobsDefs | undefined = undefined,
167
+ >(
168
+ options: BunderstackConfig<TSchema, TAccess, TStorage, TEnv> & {
169
+ /** Prebuilt tRPC router (escape hatch for multi-file setups). */
170
+ trpc?: TRouter
171
+ /** Builder callback receiving the pre-wired `j` instance. */
172
+ jobs: (j: BunderstackJobsBuilder<TSchema, ValidatedEnv<TEnv>>) => TJobsDefs
114
173
  },
115
- ): Promise<BunderstackApp<TSchema, TAccess, BucketNamesOf<TStorage>, TEnv, TRouter>>
174
+ ): Promise<BunderstackApp<TSchema, TAccess, BucketNamesOf<TStorage>, TEnv, TRouter, TJobsDefs>>
116
175
  export function createBunderstack<
117
176
  TSchema extends Record<string, unknown>,
118
177
  const TAccess extends Record<string, TableAccessInput> | undefined =
@@ -120,12 +179,15 @@ export function createBunderstack<
120
179
  const TStorage extends StorageConfigInput | undefined = undefined,
121
180
  const TEnv extends EnvConfigInput | undefined = undefined,
122
181
  TRouter extends AnyRouter | undefined = undefined,
182
+ const TJobsDefs extends JobsDefs | undefined = undefined,
123
183
  >(
124
184
  options: BunderstackConfig<TSchema, TAccess, TStorage, TEnv> & {
125
185
  /** Prebuilt tRPC router (escape hatch for multi-file setups). */
126
186
  trpc?: TRouter
187
+ /** Prebuilt job definitions (escape hatch for multi-file setups). */
188
+ jobs?: TJobsDefs
127
189
  },
128
- ): Promise<BunderstackApp<TSchema, TAccess, BucketNamesOf<TStorage>, TEnv, TRouter>>
190
+ ): Promise<BunderstackApp<TSchema, TAccess, BucketNamesOf<TStorage>, TEnv, TRouter, TJobsDefs>>
129
191
  export async function createBunderstack<
130
192
  TSchema extends Record<string, unknown>,
131
193
  const TAccess extends Record<string, TableAccessInput> | undefined =
@@ -137,9 +199,19 @@ export async function createBunderstack<
137
199
  trpc?:
138
200
  | AnyRouter
139
201
  | ((t: BunderstackTRPC<TSchema, ValidatedEnv<TEnv>>) => AnyRouter)
202
+ jobs?:
203
+ | JobsDefs
204
+ | ((j: BunderstackJobsBuilder<TSchema, ValidatedEnv<TEnv>>) => JobsDefs)
140
205
  },
141
206
  ): Promise<
142
- BunderstackApp<TSchema, TAccess, BucketNamesOf<TStorage>, TEnv, AnyRouter | undefined>
207
+ BunderstackApp<
208
+ TSchema,
209
+ TAccess,
210
+ BucketNamesOf<TStorage>,
211
+ TEnv,
212
+ AnyRouter | undefined,
213
+ JobsDefs | undefined
214
+ >
143
215
  > {
144
216
  const dialect = detectDialect(options.schema)
145
217
  // Env is validated FIRST: the app refuses to boot on missing/invalid vars,
@@ -150,6 +222,15 @@ export async function createBunderstack<
150
222
  dialect === 'pg' ? 'file:./data.pglite' : 'file:./data.db',
151
223
  })
152
224
  const config = resolveConfig(options, env)
225
+ // Introspection mode (BUNDERSTACK_INTROSPECT=1): deployment platforms import
226
+ // the app declaration only to read `app.manifest`. The boot must never touch
227
+ // the outside world — force an in-memory db (':memory:' is valid for both
228
+ // dialects) and skip Redis below. Env validation is already lenient (env.ts).
229
+ const introspect = process.env.BUNDERSTACK_INTROSPECT === '1'
230
+ if (introspect) {
231
+ config.database.url = ':memory:'
232
+ config.database.authToken = undefined
233
+ }
153
234
  const email = createEmail(options.email, { env })
154
235
  // Merge bunderstack's internal tables (file-meta, idempotency) into the
155
236
  // schema used for the db client + provisioning. CRUD/access stay on the USER
@@ -180,9 +261,10 @@ export async function createBunderstack<
180
261
  )
181
262
  const realtimeBufferSize =
182
263
  typeof config.realtime === 'object' ? config.realtime.bufferSize : undefined
183
- const redisUrl = config.realtime
184
- ? resolveRealtimeRedisUrl(config.realtime, env)
185
- : undefined
264
+ const redisUrl =
265
+ config.realtime && !introspect
266
+ ? resolveRealtimeRedisUrl(config.realtime, env)
267
+ : undefined
186
268
  const broker = config.realtime
187
269
  ? redisUrl
188
270
  ? createRedisRealtimeBroker({
@@ -258,6 +340,42 @@ export async function createBunderstack<
258
340
  void sweepOrphans(registry, db, DEFAULT_PENDING_TTL_MS).catch(() => {})
259
341
  }, SWEEP_INTERVAL_MS)
260
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
+ const jobRunner = jobsDefs
350
+ ? createJobRunner({
351
+ db,
352
+ defs: jobsDefs,
353
+ ctx: { db: userDb, env, email, storage },
354
+ })
355
+ : undefined
356
+ const jobs = {
357
+ async enqueue(name: string, input?: unknown, opts?: EnqueueOptions) {
358
+ if (!jobsDefs) {
359
+ throw new Error(
360
+ '[bunderstack] no jobs configured — add a `jobs` key to createBunderstack',
361
+ )
362
+ }
363
+ 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
+ return result
367
+ },
368
+ tick(now?: number) {
369
+ return jobRunner ? jobRunner.tick(now) : Promise.resolve()
370
+ },
371
+ }
372
+ 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?.()
378
+ }
261
379
  const trpcRouter: AnyRouter | undefined =
262
380
  typeof options.trpc === 'function'
263
381
  ? options.trpc(createTRPC<TSchema, ValidatedEnv<TEnv>>())
@@ -273,6 +391,7 @@ export async function createBunderstack<
273
391
  user: await resolveAccessUser(authResolver, req.headers),
274
392
  env,
275
393
  email,
394
+ jobs,
276
395
  req,
277
396
  }),
278
397
  })
@@ -291,7 +410,8 @@ export async function createBunderstack<
291
410
  TAccess,
292
411
  BucketNamesOf<TStorage>,
293
412
  TEnv,
294
- AnyRouter | undefined
413
+ AnyRouter | undefined,
414
+ JobsDefs | undefined
295
415
  > = {
296
416
  handler,
297
417
  // Internal tables live on the runtime db but stay out of the public type.
@@ -301,7 +421,19 @@ export async function createBunderstack<
301
421
  router,
302
422
  env,
303
423
  email,
424
+ // Runtime facade is untyped (JobsRuntimeFacade); the generic-typed field
425
+ // narrows `enqueue` per-app from the declared job defs — same relationship
426
+ // as `userDb` above.
427
+ jobs: jobs as never,
304
428
  trpcRouter,
429
+ manifest: buildManifest({
430
+ schema: options.schema,
431
+ dialect,
432
+ storage: config.storage,
433
+ envConfig: options.env as EnvConfigInput | undefined,
434
+ realtime: Boolean(config.realtime),
435
+ jobs: jobsDefs,
436
+ }),
305
437
  }
306
438
 
307
439
  // Hidden handle for the optional `bunderstack/provision` entry. Kept off the
@@ -328,6 +460,8 @@ export type {
328
460
  } from './config'
329
461
  export { validateEnv, createClientEnv, BunderstackEnvError } from './env'
330
462
  export type { EnvConfigInput, BaseEnv, ValidatedEnv } from './env'
463
+ export { buildManifest } from './manifest'
464
+ export type { BunderstackManifest, ManifestEnvVar, ManifestJob } from './manifest'
331
465
  export { createEmail } from './email'
332
466
  export type {
333
467
  EmailMessage,
@@ -337,6 +471,16 @@ export type {
337
471
  } from './email'
338
472
  export { createTRPC } from './trpc'
339
473
  export type { BunderstackTRPC, TRPCContext } from './trpc'
474
+ export { createJobsBuilder } from './jobs/index'
475
+ export type {
476
+ BunderstackJobsBuilder,
477
+ EnqueueOptions,
478
+ JobContext,
479
+ JobDefinition,
480
+ JobsDefs,
481
+ JobsFacade,
482
+ JobsRuntimeFacade,
483
+ } from './jobs/index'
340
484
  export {
341
485
  defineAccess,
342
486
  validateAndResolveAccess,
@@ -8,6 +8,7 @@ import {
8
8
  pgTable,
9
9
  primaryKey,
10
10
  text,
11
+ uniqueIndex,
11
12
  } from 'drizzle-orm/pg-core'
12
13
 
13
14
  export const bunderstackFilesPg = pgTable(
@@ -43,3 +44,25 @@ export const bunderstackIdempotencyPg = pgTable(
43
44
  },
44
45
  (t) => [primaryKey({ columns: [t.key, t.tableName] })],
45
46
  )
47
+
48
+ export const bunderstackJobsPg = pgTable(
49
+ '_bunderstack_jobs',
50
+ {
51
+ id: text('id').primaryKey(),
52
+ type: text('type').notNull(),
53
+ payloadJson: text('payload_json').notNull(),
54
+ status: text('status').notNull(),
55
+ attempts: integer('attempts').notNull().default(0),
56
+ runAt: bigint('run_at', { mode: 'number' }).notNull(),
57
+ lockedUntil: bigint('locked_until', { mode: 'number' }),
58
+ dedupeKey: text('dedupe_key'),
59
+ lastError: text('last_error'),
60
+ createdAt: bigint('created_at', { mode: 'number' }).notNull(),
61
+ finishedAt: bigint('finished_at', { mode: 'number' }),
62
+ },
63
+ (t) => [
64
+ index('bjq_claim').on(t.status, t.runAt),
65
+ index('bjq_type_status').on(t.type, t.status),
66
+ uniqueIndex('bjq_dedupe').on(t.type, t.dedupeKey),
67
+ ],
68
+ )
@@ -6,12 +6,14 @@ import {
6
6
  primaryKey,
7
7
  sqliteTable,
8
8
  text,
9
+ uniqueIndex,
9
10
  } from 'drizzle-orm/sqlite-core'
10
11
 
11
12
  import { detectDialect } from './dialect'
12
13
  import {
13
14
  bunderstackFilesPg,
14
15
  bunderstackIdempotencyPg,
16
+ bunderstackJobsPg,
15
17
  } from './internal-tables-pg'
16
18
 
17
19
  export const bunderstackFiles = sqliteTable(
@@ -48,19 +50,45 @@ export const bunderstackIdempotency = sqliteTable(
48
50
  (t) => [primaryKey({ columns: [t.key, t.tableName] })],
49
51
  )
50
52
 
53
+ export const bunderstackJobs = sqliteTable(
54
+ '_bunderstack_jobs',
55
+ {
56
+ id: text('id').primaryKey(),
57
+ type: text('type').notNull(),
58
+ payloadJson: text('payload_json').notNull(),
59
+ status: text('status').notNull(), // pending | running | succeeded | failed
60
+ attempts: integer('attempts').notNull().default(0),
61
+ runAt: integer('run_at').notNull(),
62
+ lockedUntil: integer('locked_until'),
63
+ dedupeKey: text('dedupe_key'),
64
+ lastError: text('last_error'),
65
+ createdAt: integer('created_at').notNull(),
66
+ finishedAt: integer('finished_at'),
67
+ },
68
+ (t) => [
69
+ index('bjq_claim').on(t.status, t.runAt),
70
+ index('bjq_type_status').on(t.type, t.status),
71
+ // NULL dedupe keys are distinct in both dialects, so keyless jobs never collide.
72
+ uniqueIndex('bjq_dedupe').on(t.type, t.dedupeKey),
73
+ ],
74
+ )
75
+
51
76
  export const INTERNAL_TABLES = {
52
77
  bunderstackFiles,
53
78
  bunderstackIdempotency,
79
+ bunderstackJobs,
54
80
  } as const
55
81
 
56
82
  export const INTERNAL_TABLE_NAMES: ReadonlySet<string> = new Set([
57
83
  'bunderstack_file_meta',
58
84
  '_bunderstack_idempotency',
85
+ '_bunderstack_jobs',
59
86
  ])
60
87
 
61
88
  export const INTERNAL_TABLES_PG = {
62
89
  bunderstackFiles: bunderstackFilesPg,
63
90
  bunderstackIdempotency: bunderstackIdempotencyPg,
91
+ bunderstackJobs: bunderstackJobsPg,
64
92
  } as const
65
93
 
66
94
  // Both dialect twins count as "ours" for the re-export identity check.
@@ -70,6 +98,7 @@ const INTERNAL_TABLE_CANDIDATES = new Map<string, readonly unknown[]>([
70
98
  getTableName(bunderstackIdempotency),
71
99
  [bunderstackIdempotency, bunderstackIdempotencyPg],
72
100
  ],
101
+ [getTableName(bunderstackJobs), [bunderstackJobs, bunderstackJobsPg]],
73
102
  ])
74
103
 
75
104
  /** Internal file-meta table matching the db's dialect. */
@@ -82,6 +111,11 @@ export function idempotencyTableFor(db: unknown) {
82
111
  return is(db, PgDatabase) ? bunderstackIdempotencyPg : bunderstackIdempotency
83
112
  }
84
113
 
114
+ /** Internal jobs table matching the db's dialect. */
115
+ export function jobsTableFor(db: unknown) {
116
+ return is(db, PgDatabase) ? bunderstackJobsPg : bunderstackJobs
117
+ }
118
+
85
119
  export function withInternalTables<TSchema extends Record<string, unknown>>(
86
120
  schema: TSchema,
87
121
  ): TSchema & typeof INTERNAL_TABLES {
@@ -0,0 +1,87 @@
1
+ // src/jobs/cron.ts — minimal 5-field cron parser, UTC, minute granularity.
2
+ // Supports: * , lists (a,b) , ranges (a-b) , steps (*/n, a-b/n, a/n). No
3
+ // month/day names, no seconds field, no @-shortcuts — YAGNI for v1.
4
+
5
+ export type CronField = { any: boolean; values: ReadonlySet<number> }
6
+
7
+ export type ParsedCron = {
8
+ minute: CronField
9
+ hour: CronField
10
+ dayOfMonth: CronField
11
+ month: CronField
12
+ dayOfWeek: CronField
13
+ }
14
+
15
+ const PART_RE = /^(\*|\d+(?:-\d+)?)(?:\/(\d+))?$/
16
+
17
+ function parseField(
18
+ spec: string,
19
+ min: number,
20
+ max: number,
21
+ expr: string,
22
+ ): CronField {
23
+ if (spec === '*') return { any: true, values: new Set() }
24
+ const values = new Set<number>()
25
+ for (const part of spec.split(',')) {
26
+ const m = PART_RE.exec(part)
27
+ if (!m) throw new Error(`[bunderstack] invalid cron "${expr}": "${part}"`)
28
+ const step = m[2] !== undefined ? Number(m[2]) : 1
29
+ let lo: number
30
+ let hi: number
31
+ if (m[1] === '*') {
32
+ lo = min
33
+ hi = max
34
+ } else if (m[1]!.includes('-')) {
35
+ const [a, b] = m[1]!.split('-')
36
+ lo = Number(a)
37
+ hi = Number(b)
38
+ } else {
39
+ lo = Number(m[1])
40
+ // "5/15" means "starting at 5, every 15" per cron convention.
41
+ hi = step > 1 ? max : lo
42
+ }
43
+ if (step < 1 || lo < min || hi > max || lo > hi) {
44
+ throw new Error(`[bunderstack] invalid cron "${expr}": "${part}"`)
45
+ }
46
+ for (let v = lo; v <= hi; v += step) values.add(v)
47
+ }
48
+ return { any: false, values }
49
+ }
50
+
51
+ export function parseCron(expr: string): ParsedCron {
52
+ const parts = expr.trim().split(/\s+/)
53
+ if (parts.length !== 5) {
54
+ throw new Error(
55
+ `[bunderstack] invalid cron "${expr}": expected 5 fields (minute hour day-of-month month day-of-week)`,
56
+ )
57
+ }
58
+ const dow = parseField(parts[4]!, 0, 7, expr)
59
+ return {
60
+ minute: parseField(parts[0]!, 0, 59, expr),
61
+ hour: parseField(parts[1]!, 0, 23, expr),
62
+ dayOfMonth: parseField(parts[2]!, 1, 31, expr),
63
+ month: parseField(parts[3]!, 1, 12, expr),
64
+ // 7 is an alias for Sunday (0).
65
+ dayOfWeek: dow.any
66
+ ? dow
67
+ : { any: false, values: new Set([...dow.values].map((v) => v % 7)) },
68
+ }
69
+ }
70
+
71
+ function inField(field: CronField, value: number): boolean {
72
+ return field.any || field.values.has(value)
73
+ }
74
+
75
+ /** Whether the minute containing `epochMs` matches, evaluated in UTC. */
76
+ export function cronMatches(cron: ParsedCron, epochMs: number): boolean {
77
+ const d = new Date(epochMs)
78
+ if (!inField(cron.minute, d.getUTCMinutes())) return false
79
+ if (!inField(cron.hour, d.getUTCHours())) return false
80
+ if (!inField(cron.month, d.getUTCMonth() + 1)) return false
81
+ const domOk = inField(cron.dayOfMonth, d.getUTCDate())
82
+ const dowOk = inField(cron.dayOfWeek, d.getUTCDay())
83
+ // Standard cron rule: when BOTH day fields are restricted, either may match.
84
+ return !cron.dayOfMonth.any && !cron.dayOfWeek.any
85
+ ? domOk || dowOk
86
+ : domOk && dowOk
87
+ }
@@ -0,0 +1,171 @@
1
+ // src/jobs/define.ts — job definition types and the typed builder.
2
+ // `createJobsBuilder` mirrors `createTRPC`: it exists purely to carry
3
+ // TSchema/TEnvResult typing into inline callbacks and extracted files.
4
+ import type { ZodType } from 'zod'
5
+
6
+ import type { DbFor } from '../db'
7
+ import type { EmailFacade } from '../email'
8
+ import type { StorageFacade } from '../index'
9
+
10
+ import { parseCron } from './cron'
11
+
12
+ export const DEFAULT_RETRIES = 3
13
+ export const DEFAULT_TIMEOUT_MS = 60_000
14
+
15
+ export type EnqueueOptions = {
16
+ /** Collapse duplicate enqueues; see spec for cron vs non-cron lifetime. */
17
+ dedupeKey?: string
18
+ /** Milliseconds from now until the job becomes claimable. */
19
+ delay?: number
20
+ /** Absolute time the job becomes claimable; wins over `delay`. */
21
+ runAt?: Date | number
22
+ }
23
+
24
+ /**
25
+ * The untyped runtime facade. Handler ctx and tRPC ctx expose this shape;
26
+ * `app.jobs` narrows `enqueue` to the declared job names/payloads.
27
+ */
28
+ export type JobsRuntimeFacade = {
29
+ enqueue(
30
+ name: string,
31
+ input?: unknown,
32
+ opts?: EnqueueOptions,
33
+ ): Promise<{ id: string }>
34
+ /** Run one poll cycle deterministically (tests). `now` defaults to Date.now(). */
35
+ tick(now?: number): Promise<void>
36
+ }
37
+
38
+ export type JobContext<
39
+ TSchema extends Record<string, unknown> = Record<string, unknown>,
40
+ TEnvResult = Record<string, unknown>,
41
+ > = {
42
+ db: DbFor<TSchema>
43
+ env: TEnvResult
44
+ email: EmailFacade
45
+ storage: StorageFacade
46
+ jobs: JobsRuntimeFacade
47
+ }
48
+
49
+ export type JobDefinition<
50
+ TInput,
51
+ TSchema extends Record<string, unknown> = Record<string, unknown>,
52
+ TEnvResult = Record<string, unknown>,
53
+ > = {
54
+ /** zod schema for the payload; parsed at enqueue AND before the handler runs. */
55
+ input?: ZodType<TInput>
56
+ /** Attempts after the first failure. Default 3 (so 4 total attempts). */
57
+ retries?: number
58
+ /** Delay before retry N (1-based). Default exponential: 1s, 2s, 4s, … */
59
+ backoff?: ((attempt: number) => number) | { baseMs?: number; factor?: number }
60
+ /** Max simultaneous `running` rows of this type, enforced cross-replica. */
61
+ concurrency?: number
62
+ /** Lease duration in ms; an expired lease sends the job back to pending. */
63
+ timeout?: number
64
+ /** 5-field UTC cron expression. Cron jobs cannot declare `input`. */
65
+ cron?: string
66
+ handler: (
67
+ input: TInput,
68
+ ctx: JobContext<TSchema, TEnvResult>,
69
+ ) => Promise<void> | void
70
+ /** Fires once, after the final attempt fails. Errors here are logged, never retried. */
71
+ onFailed?: (
72
+ input: TInput,
73
+ error: Error,
74
+ ctx: JobContext<TSchema, TEnvResult>,
75
+ ) => Promise<void> | void
76
+ }
77
+
78
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
79
+ export type AnyJobDefinition = JobDefinition<any, any, any>
80
+ export type JobsDefs = Record<string, AnyJobDefinition>
81
+
82
+ /** Throws when a definition is unusable. Safe to call more than once. */
83
+ export function validateJobsDefs(defs: JobsDefs): void {
84
+ for (const [name, def] of Object.entries(defs)) {
85
+ if (typeof def.handler !== 'function') {
86
+ throw new Error(`[bunderstack] job "${name}" has no handler`)
87
+ }
88
+ if (def.cron !== undefined) {
89
+ parseCron(def.cron) // throws with a clear message on invalid expressions
90
+ if (def.input !== undefined) {
91
+ throw new Error(
92
+ `[bunderstack] job "${name}": cron jobs cannot declare input (nothing enqueues a payload for a schedule)`,
93
+ )
94
+ }
95
+ }
96
+ if (def.retries !== undefined && (def.retries < 0 || !Number.isInteger(def.retries))) {
97
+ throw new Error(`[bunderstack] job "${name}": retries must be a non-negative integer`)
98
+ }
99
+ if (def.concurrency !== undefined && (def.concurrency < 1 || !Number.isInteger(def.concurrency))) {
100
+ throw new Error(`[bunderstack] job "${name}": concurrency must be a positive integer`)
101
+ }
102
+ if (def.timeout !== undefined && def.timeout <= 0) {
103
+ throw new Error(`[bunderstack] job "${name}": timeout must be positive`)
104
+ }
105
+ }
106
+ }
107
+
108
+ /** Delay in ms before retry `attempt` (1-based = the attempt that just failed). */
109
+ export function backoffMs(def: AnyJobDefinition, attempt: number): number {
110
+ const b = def.backoff
111
+ if (typeof b === 'function') return b(attempt)
112
+ const baseMs = b?.baseMs ?? 1000
113
+ const factor = b?.factor ?? 2
114
+ return baseMs * factor ** (attempt - 1)
115
+ }
116
+
117
+ /**
118
+ * Build the `j` instance bunderstack hands to the config's `jobs` builder
119
+ * callback (and exports for multi-file job setups).
120
+ */
121
+ export function createJobsBuilder<
122
+ TSchema extends Record<string, unknown>,
123
+ TEnvResult = Record<string, unknown>,
124
+ >() {
125
+ return {
126
+ /** Identity with inference: pins TInput from the zod schema. */
127
+ job<TInput = undefined>(
128
+ def: JobDefinition<TInput, TSchema, TEnvResult>,
129
+ ): JobDefinition<TInput, TSchema, TEnvResult> {
130
+ return def
131
+ },
132
+ /** Identity with validation: returns the defs map, typed. */
133
+ define<TDefs extends JobsDefs>(defs: TDefs): TDefs {
134
+ validateJobsDefs(defs)
135
+ return defs
136
+ },
137
+ }
138
+ }
139
+
140
+ /** Type of the `j` instance — for builder callbacks declared in separate files. */
141
+ export type BunderstackJobsBuilder<
142
+ TSchema extends Record<string, unknown>,
143
+ TEnvResult = Record<string, unknown>,
144
+ > = ReturnType<typeof createJobsBuilder<TSchema, TEnvResult>>
145
+
146
+ // Infers TInput from the JobDefinition's own type argument rather than
147
+ // pattern-matching the (optional, so union-with-undefined) `input` property —
148
+ // `TDef extends { input: ZodType<infer I> }` fails structurally because
149
+ // `input?: ZodType<TInput>` desugars to `ZodType<TInput> | undefined`, which
150
+ // can never satisfy a required-property pattern.
151
+ type JobInputOf<TDef> = TDef extends JobDefinition<infer TInput, any, any>
152
+ ? TInput
153
+ : undefined
154
+
155
+ /**
156
+ * `app.jobs`: `enqueue` narrowed to declared names + payloads. `Omit`s the
157
+ * runtime facade's loose `enqueue` first — intersecting two same-named
158
+ * methods instead would make TS treat them as overloaded, so the loose
159
+ * `(name: string, ...)` signature would still accept any name.
160
+ */
161
+ export type JobsFacade<TDefs extends JobsDefs> = Omit<
162
+ JobsRuntimeFacade,
163
+ 'enqueue'
164
+ > & {
165
+ enqueue<K extends keyof TDefs & string>(
166
+ name: K,
167
+ ...rest: JobInputOf<TDefs[K]> extends undefined
168
+ ? [input?: undefined, opts?: EnqueueOptions]
169
+ : [input: JobInputOf<TDefs[K]>, opts?: EnqueueOptions]
170
+ ): Promise<{ id: string }>
171
+ }
@@ -0,0 +1,20 @@
1
+ // src/jobs/index.ts — module surface consumed by createBunderstack.
2
+ export {
3
+ createJobsBuilder,
4
+ validateJobsDefs,
5
+ DEFAULT_RETRIES,
6
+ DEFAULT_TIMEOUT_MS,
7
+ } from './define'
8
+ export type {
9
+ AnyJobDefinition,
10
+ BunderstackJobsBuilder,
11
+ EnqueueOptions,
12
+ JobContext,
13
+ JobDefinition,
14
+ JobsDefs,
15
+ JobsFacade,
16
+ JobsRuntimeFacade,
17
+ } from './define'
18
+ export { enqueueJob } from './queue'
19
+ export { createJobRunner } from './worker'
20
+ export { parseCron, cronMatches } from './cron'
@@ -0,0 +1,57 @@
1
+ // src/jobs/queue.ts — durable enqueue with constraint-backed dedupe.
2
+ import { and, eq } from 'drizzle-orm'
3
+
4
+ import type { AnyDb } from '../dialect'
5
+ import type { EnqueueOptions, JobsDefs } from './define'
6
+
7
+ import { jobsTableFor } from '../internal-tables'
8
+ import { generate } from '../typeid'
9
+
10
+ export async function enqueueJob(
11
+ db: AnyDb,
12
+ defs: JobsDefs,
13
+ name: string,
14
+ input: unknown,
15
+ opts: EnqueueOptions = {},
16
+ ): Promise<{ id: string }> {
17
+ const def = defs[name]
18
+ if (!def) throw new Error(`[bunderstack] unknown job type "${name}"`)
19
+ // Fail fast: a bad payload should throw at the call site, not in the worker.
20
+ const parsed = def.input ? def.input.parse(input) : null
21
+ const t = jobsTableFor(db)
22
+ const now = Date.now()
23
+ const runAt =
24
+ opts.runAt !== undefined
25
+ ? new Date(opts.runAt).getTime()
26
+ : now + (opts.delay ?? 0)
27
+
28
+ // Two rounds cover the race where the deduping row reaches a terminal state
29
+ // (clearing its key) between our failed insert and our read.
30
+ for (let round = 0; round < 2; round++) {
31
+ const id = generate('job')
32
+ const insertedRows = await db
33
+ .insert(t)
34
+ .values({
35
+ id,
36
+ type: name,
37
+ payloadJson: JSON.stringify(parsed),
38
+ status: 'pending',
39
+ attempts: 0,
40
+ runAt,
41
+ dedupeKey: opts.dedupeKey ?? null,
42
+ createdAt: now,
43
+ })
44
+ .onConflictDoNothing({ target: [t.type, t.dedupeKey] })
45
+ .returning({ id: t.id })
46
+ if (insertedRows[0]) return { id: String(insertedRows[0].id) }
47
+ const existing = await db
48
+ .select({ id: t.id })
49
+ .from(t)
50
+ .where(and(eq(t.type, name), eq(t.dedupeKey, opts.dedupeKey ?? '')))
51
+ .limit(1)
52
+ if (existing[0]) return { id: String(existing[0].id) }
53
+ }
54
+ throw new Error(
55
+ `[bunderstack] enqueue of "${name}" lost a dedupe race twice — please retry`,
56
+ )
57
+ }
@@ -0,0 +1,290 @@
1
+ // src/jobs/worker.ts — the in-process worker. One `tick()` is a full cycle:
2
+ // recover expired leases → schedule cron slots → reap old succeeded rows →
3
+ // claim and run claimable jobs (awaiting handlers, so tests drive `tick()`
4
+ // deterministically with an injected `now`). Multiple replicas run the same
5
+ // loop safely: claims are atomic and cron slots dedupe on a unique index.
6
+ import { and, eq, inArray, is, isNotNull, lt, lte, sql } from 'drizzle-orm'
7
+ import { PgDatabase } from 'drizzle-orm/pg-core'
8
+
9
+ import type { AnyDb } from '../dialect'
10
+ import type {
11
+ AnyJobDefinition,
12
+ JobsDefs,
13
+ JobsRuntimeFacade,
14
+ } from './define'
15
+ import type { ParsedCron } from './cron'
16
+
17
+ import { jobsTableFor } from '../internal-tables'
18
+ import { cronMatches, parseCron } from './cron'
19
+ import { backoffMs, DEFAULT_RETRIES, DEFAULT_TIMEOUT_MS } from './define'
20
+ import { enqueueJob } from './queue'
21
+
22
+ const CLAIM_BATCH = 10
23
+ const SUCCEEDED_RETENTION_MS = 24 * 60 * 60 * 1000
24
+
25
+ type JobRow = {
26
+ id: string
27
+ type: string
28
+ payloadJson: string
29
+ attempts: number
30
+ }
31
+
32
+ function toError(err: unknown): Error {
33
+ return err instanceof Error ? err : new Error(String(err))
34
+ }
35
+
36
+ function maxAttempts(def: AnyJobDefinition): number {
37
+ return 1 + (def.retries ?? DEFAULT_RETRIES)
38
+ }
39
+
40
+ /** Terminal-status column patch: non-cron jobs release their dedupe key. */
41
+ function terminalPatch(def: AnyJobDefinition | undefined) {
42
+ return def?.cron ? {} : { dedupeKey: null }
43
+ }
44
+
45
+ export function createJobRunner(deps: {
46
+ db: AnyDb
47
+ defs: JobsDefs
48
+ /** Handler ctx WITHOUT `jobs`; the facade is injected via setJobsFacade. */
49
+ ctx: Record<string, unknown>
50
+ }) {
51
+ const { db, defs } = deps
52
+ const t = jobsTableFor(db)
53
+ const ctx = { ...deps.ctx } as Record<string, unknown>
54
+ const crons = new Map<string, ParsedCron>()
55
+ for (const [name, def] of Object.entries(defs)) {
56
+ if (def.cron) crons.set(name, parseCron(def.cron))
57
+ }
58
+
59
+ async function fireOnFailed(
60
+ def: AnyJobDefinition,
61
+ payloadJson: string,
62
+ error: Error,
63
+ ) {
64
+ if (!def.onFailed) return
65
+ let input: unknown
66
+ try {
67
+ const raw = JSON.parse(payloadJson)
68
+ input = def.input ? def.input.parse(raw) : undefined
69
+ } catch {
70
+ input = undefined // payload unusable; the hook still gets the error
71
+ }
72
+ try {
73
+ await def.onFailed(input, error, ctx as never)
74
+ } catch (hookErr) {
75
+ console.error('[bunderstack] onFailed hook threw:', hookErr)
76
+ }
77
+ }
78
+
79
+ /** running rows whose lease expired → pending (or failed when exhausted). */
80
+ async function recoverExpiredLeases(now: number) {
81
+ const expired: (JobRow & { lastError: string | null })[] = await db
82
+ .select({
83
+ id: t.id,
84
+ type: t.type,
85
+ payloadJson: t.payloadJson,
86
+ attempts: t.attempts,
87
+ lastError: t.lastError,
88
+ })
89
+ .from(t)
90
+ .where(
91
+ and(eq(t.status, 'running'), isNotNull(t.lockedUntil), lt(t.lockedUntil, now)),
92
+ )
93
+ for (const row of expired) {
94
+ const def = defs[row.type]
95
+ const error = new Error('lease expired (worker crashed or timed out)')
96
+ if (!def) {
97
+ await db
98
+ .update(t)
99
+ .set({
100
+ status: 'failed',
101
+ finishedAt: now,
102
+ lockedUntil: null,
103
+ lastError: `unknown job type "${row.type}"`,
104
+ dedupeKey: null,
105
+ })
106
+ .where(eq(t.id, row.id))
107
+ continue
108
+ }
109
+ if (Number(row.attempts) >= maxAttempts(def)) {
110
+ await db
111
+ .update(t)
112
+ .set({
113
+ status: 'failed',
114
+ finishedAt: now,
115
+ lockedUntil: null,
116
+ lastError: error.message,
117
+ ...terminalPatch(def),
118
+ })
119
+ .where(eq(t.id, row.id))
120
+ await fireOnFailed(def, row.payloadJson, error)
121
+ } else {
122
+ await db
123
+ .update(t)
124
+ .set({
125
+ status: 'pending',
126
+ lockedUntil: null,
127
+ runAt: now + backoffMs(def, Number(row.attempts)),
128
+ lastError: error.message,
129
+ })
130
+ .where(eq(t.id, row.id))
131
+ }
132
+ }
133
+ }
134
+
135
+ /** Enqueue the current minute's slot for every cron definition. */
136
+ async function scheduleCronSlots(now: number) {
137
+ const minute = Math.floor(now / 60_000) * 60_000
138
+ for (const [name, cron] of crons) {
139
+ if (!cronMatches(cron, minute)) continue
140
+ // The unique (type, dedupe_key) index collapses concurrent replicas'
141
+ // enqueues of the same slot into one row.
142
+ await enqueueJob(db, defs, name, undefined, {
143
+ dedupeKey: `cron:${name}:${minute}`,
144
+ runAt: minute,
145
+ })
146
+ }
147
+ }
148
+
149
+ async function reapSucceeded(now: number) {
150
+ await db
151
+ .delete(t)
152
+ .where(
153
+ and(
154
+ eq(t.status, 'succeeded'),
155
+ lt(t.finishedAt, now - SUCCEEDED_RETENTION_MS),
156
+ ),
157
+ )
158
+ }
159
+
160
+ /** Atomically claim up to `limit` runnable jobs of one type. */
161
+ async function claim(
162
+ type: string,
163
+ limit: number,
164
+ now: number,
165
+ leaseUntil: number,
166
+ ): Promise<JobRow[]> {
167
+ const pendingIds = db
168
+ .select({ id: t.id })
169
+ .from(t)
170
+ .where(and(eq(t.type, type), eq(t.status, 'pending'), lte(t.runAt, now)))
171
+ .orderBy(t.runAt)
172
+ .limit(limit)
173
+ // PG: lock the selected rows so concurrent replicas skip them. SQLite's
174
+ // single-writer model makes the one-statement UPDATE atomic on its own.
175
+ const sub = is(db, PgDatabase)
176
+ ? (pendingIds as unknown as { for: (m: string, o: object) => typeof pendingIds })
177
+ .for('update', { skipLocked: true })
178
+ : pendingIds
179
+ const rows: JobRow[] = await db
180
+ .update(t)
181
+ .set({
182
+ status: 'running',
183
+ lockedUntil: leaseUntil,
184
+ attempts: sql`${t.attempts} + 1`,
185
+ })
186
+ .where(and(inArray(t.id, sub), eq(t.status, 'pending')))
187
+ .returning({
188
+ id: t.id,
189
+ type: t.type,
190
+ payloadJson: t.payloadJson,
191
+ attempts: t.attempts,
192
+ })
193
+ return rows
194
+ }
195
+
196
+ // `now` is the tick's injected clock: retry runAt math uses it so tests can
197
+ // drive backoff deterministically. finishedAt uses the real clock (a handler
198
+ // may run long past the tick's start).
199
+ async function runJob(row: JobRow, def: AnyJobDefinition, now: number) {
200
+ let input: unknown
201
+ try {
202
+ const raw = JSON.parse(row.payloadJson)
203
+ input = def.input ? def.input.parse(raw) : undefined
204
+ } catch (err) {
205
+ // Stored payload no longer parses (schema drift): retrying can't help.
206
+ const e = toError(err)
207
+ await db
208
+ .update(t)
209
+ .set({
210
+ status: 'failed',
211
+ finishedAt: Date.now(),
212
+ lockedUntil: null,
213
+ lastError: e.message,
214
+ ...terminalPatch(def),
215
+ })
216
+ .where(eq(t.id, row.id))
217
+ await fireOnFailed(def, row.payloadJson, e)
218
+ return
219
+ }
220
+ try {
221
+ await def.handler(input, ctx as never)
222
+ await db
223
+ .update(t)
224
+ .set({
225
+ status: 'succeeded',
226
+ finishedAt: Date.now(),
227
+ lockedUntil: null,
228
+ ...terminalPatch(def),
229
+ })
230
+ .where(eq(t.id, row.id))
231
+ } catch (err) {
232
+ const e = toError(err)
233
+ if (Number(row.attempts) < maxAttempts(def)) {
234
+ await db
235
+ .update(t)
236
+ .set({
237
+ status: 'pending',
238
+ lockedUntil: null,
239
+ runAt: now + backoffMs(def, Number(row.attempts)),
240
+ lastError: e.message,
241
+ })
242
+ .where(eq(t.id, row.id))
243
+ } else {
244
+ await db
245
+ .update(t)
246
+ .set({
247
+ status: 'failed',
248
+ finishedAt: Date.now(),
249
+ lockedUntil: null,
250
+ lastError: e.message,
251
+ ...terminalPatch(def),
252
+ })
253
+ .where(eq(t.id, row.id))
254
+ await fireOnFailed(def, row.payloadJson, e)
255
+ }
256
+ }
257
+ }
258
+
259
+ async function runClaimable(now: number) {
260
+ const work: Promise<void>[] = []
261
+ for (const [type, def] of Object.entries(defs)) {
262
+ let limit = CLAIM_BATCH
263
+ if (def.concurrency !== undefined) {
264
+ const runningRows = await db
265
+ .select({ id: t.id })
266
+ .from(t)
267
+ .where(and(eq(t.type, type), eq(t.status, 'running')))
268
+ const capacity = def.concurrency - runningRows.length
269
+ if (capacity <= 0) continue
270
+ limit = Math.min(limit, capacity)
271
+ }
272
+ const leaseUntil = now + (def.timeout ?? DEFAULT_TIMEOUT_MS)
273
+ const claimed = await claim(type, limit, now, leaseUntil)
274
+ for (const row of claimed) work.push(runJob(row, def, now))
275
+ }
276
+ await Promise.all(work)
277
+ }
278
+
279
+ return {
280
+ async tick(now: number = Date.now()) {
281
+ await recoverExpiredLeases(now)
282
+ await scheduleCronSlots(now)
283
+ await reapSucceeded(now)
284
+ await runClaimable(now)
285
+ },
286
+ setJobsFacade(f: JobsRuntimeFacade) {
287
+ ctx.jobs = f
288
+ },
289
+ }
290
+ }
@@ -0,0 +1,59 @@
1
+ // src/manifest.ts — deploy-time introspection surface. Pure: consumes already
2
+ // resolved config pieces, never reads process.env or touches the network.
3
+ // Deployment platforms (Bunderhost) import the app declaration with
4
+ // BUNDERSTACK_INTROSPECT=1 and read `app.manifest` to learn what to provision.
5
+ import type { ZodType } from 'zod'
6
+
7
+ import type { Dialect } from './dialect'
8
+ import type { EnvConfigInput } from './env'
9
+ import type { JobsDefs } from './jobs/define'
10
+ import type { ResolvedBucket, ResolvedStorageBuckets } from './storage/buckets'
11
+
12
+ export type ManifestEnvVar = { key: string; required: boolean }
13
+ export type ManifestJob = { name: string; cron?: string }
14
+
15
+ export type BunderstackManifest = {
16
+ dialect: Dialect
17
+ tables: string[]
18
+ defaultBucket: string
19
+ buckets: { name: string; visibility: ResolvedBucket['visibility'] }[]
20
+ realtime: boolean
21
+ env: { server: ManifestEnvVar[]; client: ManifestEnvVar[] }
22
+ jobs: ManifestJob[]
23
+ }
24
+
25
+ function describeSection(
26
+ section: Record<string, ZodType> | undefined,
27
+ ): ManifestEnvVar[] {
28
+ return Object.entries(section ?? {}).map(([key, schema]) => ({
29
+ key,
30
+ required: !schema.safeParse(undefined).success,
31
+ }))
32
+ }
33
+
34
+ export function buildManifest(args: {
35
+ schema: Record<string, unknown>
36
+ dialect: Dialect
37
+ storage: ResolvedStorageBuckets
38
+ envConfig: EnvConfigInput | undefined
39
+ realtime: boolean
40
+ jobs: JobsDefs | undefined
41
+ }): BunderstackManifest {
42
+ return {
43
+ dialect: args.dialect,
44
+ tables: Object.keys(args.schema),
45
+ defaultBucket: args.storage.defaultBucket,
46
+ buckets: [...args.storage.buckets.values()].map((bucket) => ({
47
+ name: bucket.name,
48
+ visibility: bucket.visibility,
49
+ })),
50
+ realtime: args.realtime,
51
+ env: {
52
+ server: describeSection(args.envConfig?.server),
53
+ client: describeSection(args.envConfig?.client),
54
+ },
55
+ jobs: Object.entries(args.jobs ?? {}).map(([name, def]) =>
56
+ def.cron !== undefined ? { name, cron: def.cron } : { name },
57
+ ),
58
+ }
59
+ }
@@ -3,4 +3,5 @@
3
3
  export {
4
4
  bunderstackFilesPg as bunderstackFiles,
5
5
  bunderstackIdempotencyPg as bunderstackIdempotency,
6
+ bunderstackJobsPg as bunderstackJobs,
6
7
  } from './internal-tables-pg'
@@ -1,4 +1,5 @@
1
1
  export {
2
2
  bunderstackFiles,
3
3
  bunderstackIdempotency,
4
+ bunderstackJobs,
4
5
  } from './internal-tables'
@@ -108,6 +108,32 @@ export function parseSize(value: string | number): number {
108
108
  return Math.floor(num * multiplier)
109
109
  }
110
110
 
111
+ // ---------------------------------------------------------------------------
112
+ // Platform override (Bunderhost & co.)
113
+ // ---------------------------------------------------------------------------
114
+
115
+ /**
116
+ * A deployment platform that injects BUNDERSTACK_S3_ENDPOINT forces every
117
+ * bucket onto that backend — code-level `local`/per-bucket `s3` blocks are
118
+ * ignored so apps deploy unchanged. Logical buckets already prefix object
119
+ * keys ("<bucket>/<uuid>"), so one physical bucket per environment suffices.
120
+ */
121
+ function platformS3Backend(
122
+ env: Record<string, string | undefined>,
123
+ ): ResolvedBackend | undefined {
124
+ const endpoint = env['BUNDERSTACK_S3_ENDPOINT']
125
+ if (!endpoint) return undefined
126
+ return {
127
+ type: 's3',
128
+ bucket: env['BUNDERSTACK_S3_BUCKET'] ?? '',
129
+ region: env['BUNDERSTACK_S3_REGION'] ?? 'auto',
130
+ endpoint,
131
+ accessKeyId: env['BUNDERSTACK_S3_ACCESS_KEY_ID'] ?? '',
132
+ secretAccessKey: env['BUNDERSTACK_S3_SECRET_ACCESS_KEY'] ?? '',
133
+ publicUrl: env['BUNDERSTACK_S3_PUBLIC_URL'],
134
+ }
135
+ }
136
+
111
137
  // ---------------------------------------------------------------------------
112
138
  // Shared backend resolution
113
139
  // ---------------------------------------------------------------------------
@@ -149,6 +175,9 @@ function resolveBucketBackend(
149
175
  sharedBackend: ResolvedBackend,
150
176
  env: Record<string, string | undefined>,
151
177
  ): ResolvedBackend {
178
+ // Platform override active → sharedBackend IS the platform backend and
179
+ // code-level per-bucket backends are ignored.
180
+ if (env['BUNDERSTACK_S3_ENDPOINT']) return sharedBackend
152
181
  if ('s3' in bucketInput && bucketInput.s3 !== undefined) {
153
182
  const block = bucketInput.s3
154
183
  return {
@@ -232,7 +261,7 @@ export function resolveBuckets(
232
261
  input: StorageConfigInput | undefined,
233
262
  env: Record<string, string | undefined> = process.env,
234
263
  ): ResolvedStorageBuckets {
235
- const sharedBackend = resolveSharedBackend(input, env)
264
+ const sharedBackend = platformS3Backend(env) ?? resolveSharedBackend(input, env)
236
265
  const bucketsInput = input?.buckets
237
266
 
238
267
  const declaredNames = bucketsInput ? Object.keys(bucketsInput) : []
package/src/trpc.ts CHANGED
@@ -5,6 +5,7 @@ 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 { JobsRuntimeFacade } from './jobs/index'
8
9
 
9
10
  export type TRPCContext<
10
11
  TSchema extends Record<string, unknown>,
@@ -14,6 +15,7 @@ export type TRPCContext<
14
15
  user: AccessUser | null
15
16
  env: TEnvResult
16
17
  email: EmailFacade
18
+ jobs: JobsRuntimeFacade
17
19
  req: Request
18
20
  }
19
21