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 +33 -0
- package/package.json +1 -1
- package/src/config.ts +20 -5
- package/src/env.ts +4 -1
- package/src/index.ts +152 -8
- package/src/internal-tables-pg.ts +23 -0
- package/src/internal-tables.ts +34 -0
- package/src/jobs/cron.ts +87 -0
- package/src/jobs/define.ts +171 -0
- package/src/jobs/index.ts +20 -0
- package/src/jobs/queue.ts +57 -0
- package/src/jobs/worker.ts +290 -0
- package/src/manifest.ts +59 -0
- package/src/schema-export-pg.ts +1 -0
- package/src/schema-export.ts +1 -0
- package/src/storage/buckets.ts +30 -1
- package/src/trpc.ts +2 -0
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.
|
|
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:
|
|
131
|
-
|
|
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
|
-
|
|
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<
|
|
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 =
|
|
184
|
-
|
|
185
|
-
|
|
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
|
+
)
|
package/src/internal-tables.ts
CHANGED
|
@@ -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 {
|
package/src/jobs/cron.ts
ADDED
|
@@ -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
|
+
}
|
package/src/manifest.ts
ADDED
|
@@ -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
|
+
}
|
package/src/schema-export-pg.ts
CHANGED
package/src/schema-export.ts
CHANGED
package/src/storage/buckets.ts
CHANGED
|
@@ -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
|
|