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