bunderstack 0.15.2 → 0.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -7
- package/package.json +2 -1
- package/src/access.ts +18 -0
- package/src/config.ts +22 -44
- package/src/cron.ts +2 -1
- package/src/crud.ts +1 -9
- package/src/env.ts +9 -8
- package/src/handler.ts +5 -5
- package/src/index.ts +86 -86
- package/src/internal-tables-pg.ts +1 -17
- package/src/internal-tables.ts +0 -31
- package/src/jobs/define.ts +68 -12
- package/src/jobs/index.ts +3 -9
- package/src/jobs/queue.ts +10 -6
- package/src/jobs/slots.ts +52 -0
- package/src/jobs/worker.ts +139 -42
- package/src/manifest.ts +0 -6
- package/src/realtime/index.ts +3 -11
- package/src/realtime/redis.ts +3 -12
- package/src/routes.ts +137 -0
- package/src/jobs/cron-auth.ts +0 -28
- package/src/jobs/cron-router.ts +0 -135
- package/src/jobs/cron-runner.ts +0 -224
- package/src/jobs/local-cron.ts +0 -78
package/README.md
CHANGED
|
@@ -87,6 +87,10 @@ import { app } from './bunderstack'
|
|
|
87
87
|
await app.runWorker()
|
|
88
88
|
```
|
|
89
89
|
|
|
90
|
+
Most applications need none of this: background work runs in-process by
|
|
91
|
+
default. Set `BUNDERSTACK_ROLE` to `web` or `worker` to split it across
|
|
92
|
+
processes without changing code.
|
|
93
|
+
|
|
90
94
|
If a queue handler calls `ctx.realtime.publish()`, the web and worker processes
|
|
91
95
|
must share a realtime transport. Configure `REDIS_URL` (or
|
|
92
96
|
`realtime: { redis: "redis://..." }`). `realtime: true` without Redis uses a
|
|
@@ -114,13 +118,9 @@ REDIS_URL=redis://localhost:6379 bun src/server.ts
|
|
|
114
118
|
REDIS_URL=redis://localhost:6379 bun src/worker.ts
|
|
115
119
|
```
|
|
116
120
|
|
|
117
|
-
Cron tasks (`j.cron()`) are
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
injected `BUNDERSTACK_CRON_SECRET`. Use `await app.startCronScheduler()` only
|
|
121
|
-
for local standalone development. `app.manifest.background` tells Bunderhost
|
|
122
|
-
whether to deploy an always-on worker (queue jobs) or only HTTP-delivered cron
|
|
123
|
-
(cron-only).
|
|
121
|
+
Cron tasks (`j.cron()`) are materialized as job rows keyed by their slot, so
|
|
122
|
+
they run through the same loop, retries, and timeouts as queue jobs. There is
|
|
123
|
+
no separate cron process and no signed dispatch endpoint.
|
|
124
124
|
|
|
125
125
|
### Publishing custom writes to realtime
|
|
126
126
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "bunderstack",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.16.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",
|
|
@@ -53,6 +53,7 @@
|
|
|
53
53
|
},
|
|
54
54
|
"scripts": {
|
|
55
55
|
"test": "bun test",
|
|
56
|
+
"typecheck": "tsc --noEmit",
|
|
56
57
|
"dev": "bun --hot ../../examples/standalone/server.ts",
|
|
57
58
|
"db:push": "drizzle-kit push",
|
|
58
59
|
"db:migrate": "drizzle-kit migrate"
|
package/src/access.ts
CHANGED
|
@@ -103,6 +103,24 @@ export type ResolvedTableAccess = {
|
|
|
103
103
|
|
|
104
104
|
export type ResolvedAccess = Map<string, ResolvedTableAccess>
|
|
105
105
|
|
|
106
|
+
/**
|
|
107
|
+
* Look up a table's resolved access by its physical table name.
|
|
108
|
+
*
|
|
109
|
+
* `ResolvedAccess` is keyed by schema export name, not table name, so every
|
|
110
|
+
* consumer that starts from a physical name needs this scan. It lives here so
|
|
111
|
+
* CRUD, realtime, and route validation cannot drift apart on which tables they
|
|
112
|
+
* consider enabled.
|
|
113
|
+
*/
|
|
114
|
+
export function tableEntryForName(
|
|
115
|
+
access: ResolvedAccess,
|
|
116
|
+
tableName: string,
|
|
117
|
+
): ResolvedTableAccess | undefined {
|
|
118
|
+
for (const entry of access.values()) {
|
|
119
|
+
if (entry.tableName === tableName) return entry
|
|
120
|
+
}
|
|
121
|
+
return undefined
|
|
122
|
+
}
|
|
123
|
+
|
|
106
124
|
const DEFAULT_READONLY = [
|
|
107
125
|
'id',
|
|
108
126
|
'createdAt',
|
package/src/config.ts
CHANGED
|
@@ -20,29 +20,10 @@ export type BetterAuthConfig = Omit<
|
|
|
20
20
|
'database'
|
|
21
21
|
>
|
|
22
22
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
.object({
|
|
28
|
-
adapter: z.unknown(),
|
|
29
|
-
url: z.string().optional(),
|
|
30
|
-
authToken: z.string().optional(),
|
|
31
|
-
migrations: z.string().optional(),
|
|
32
|
-
})
|
|
33
|
-
.optional(),
|
|
34
|
-
auth: z.record(z.string(), z.unknown()).optional(),
|
|
35
|
-
// Loose: bucket access/scope hold functions that can't survive strict zod
|
|
36
|
-
// (mirrors how `access` is loose). Resolution happens in resolveBuckets.
|
|
37
|
-
storage: z.unknown().optional(),
|
|
38
|
-
// Loose: holds zod schemas. Validation happens in validateEnv.
|
|
39
|
-
env: z.unknown().optional(),
|
|
40
|
-
// Loose: provider may be a function/adapter. Resolution happens in createEmail.
|
|
41
|
-
email: z.unknown().optional(),
|
|
42
|
-
// Loose: a tRPC router or builder callback. Resolved in createBunderstack.
|
|
43
|
-
trpc: z.unknown().optional(),
|
|
44
|
-
// Loose: holds handler functions and zod schemas. Resolved in createBunderstack.
|
|
45
|
-
jobs: z.unknown().optional(),
|
|
23
|
+
// Only the union-shaped options need runtime validation: they are the ones a
|
|
24
|
+
// JavaScript caller can plausibly get wrong in a way that fails confusingly
|
|
25
|
+
// downstream. Everything else is either typed-only or read raw from `options`.
|
|
26
|
+
const RuntimeOptionsSchema = z.object({
|
|
46
27
|
rateLimit: z
|
|
47
28
|
.union([
|
|
48
29
|
z.boolean(),
|
|
@@ -81,19 +62,7 @@ export type BunderstackConfig<
|
|
|
81
62
|
| StorageConfigInput
|
|
82
63
|
| undefined,
|
|
83
64
|
TEnv extends EnvConfigInput | undefined = EnvConfigInput | undefined,
|
|
84
|
-
> =
|
|
85
|
-
z.input<typeof BunderstackOptionsSchema>,
|
|
86
|
-
| 'schema'
|
|
87
|
-
| 'access'
|
|
88
|
-
| 'auth'
|
|
89
|
-
| 'authResolver'
|
|
90
|
-
| 'storage'
|
|
91
|
-
| 'env'
|
|
92
|
-
| 'email'
|
|
93
|
-
| 'trpc'
|
|
94
|
-
| 'jobs'
|
|
95
|
-
| 'database'
|
|
96
|
-
> & {
|
|
65
|
+
> = {
|
|
97
66
|
schema: TSchema
|
|
98
67
|
access?: TAccess
|
|
99
68
|
database: {
|
|
@@ -110,11 +79,20 @@ export type BunderstackConfig<
|
|
|
110
79
|
authResolver?: AuthSessionResolver
|
|
111
80
|
storage?: TStorage
|
|
112
81
|
env?: TEnv
|
|
82
|
+
/**
|
|
83
|
+
* Stand-in for `process.env`. Feeds both env validation and platform
|
|
84
|
+
* overrides, so tests and embedders have one injection point instead of
|
|
85
|
+
* three.
|
|
86
|
+
*/
|
|
87
|
+
processEnv?: Record<string, string | undefined>
|
|
88
|
+
background?: { autoStart?: boolean }
|
|
113
89
|
email?: EmailConfigInput
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
90
|
+
/**
|
|
91
|
+
* Custom Hono routes, mounted at root ahead of bunderstack's own. Declared as
|
|
92
|
+
* a callback because routes in a separate file cannot import the app that is
|
|
93
|
+
* still being constructed — the same reason `trpc` takes a builder.
|
|
94
|
+
*/
|
|
95
|
+
routes?: (ctx: never) => unknown
|
|
118
96
|
rateLimit?: boolean | RateLimitConfig
|
|
119
97
|
idempotency?: boolean | IdempotencyConfig
|
|
120
98
|
realtime?:
|
|
@@ -154,7 +132,7 @@ export function resolveConfig<TSchema extends Record<string, unknown>>(
|
|
|
154
132
|
string | undefined
|
|
155
133
|
>,
|
|
156
134
|
): ResolvedConfig {
|
|
157
|
-
const parsed =
|
|
135
|
+
const parsed = RuntimeOptionsSchema.parse(options)
|
|
158
136
|
// Self-validate when the caller didn't pass a pre-validated env, so
|
|
159
137
|
// resolveConfig stays usable standalone.
|
|
160
138
|
const resolvedEnv =
|
|
@@ -173,14 +151,14 @@ export function resolveConfig<TSchema extends Record<string, unknown>>(
|
|
|
173
151
|
adapter,
|
|
174
152
|
url:
|
|
175
153
|
platformSource['BUNDERSTACK_DATABASE_URL'] ??
|
|
176
|
-
|
|
154
|
+
options.database?.url ??
|
|
177
155
|
resolvedEnv.DATABASE_URL ??
|
|
178
156
|
defaultUrl,
|
|
179
157
|
authToken:
|
|
180
158
|
platformSource['BUNDERSTACK_DATABASE_AUTH_TOKEN'] ??
|
|
181
|
-
|
|
159
|
+
options.database?.authToken ??
|
|
182
160
|
resolvedEnv.DATABASE_AUTH_TOKEN,
|
|
183
|
-
migrations:
|
|
161
|
+
migrations: options.database?.migrations ?? './migrations',
|
|
184
162
|
},
|
|
185
163
|
auth: (() => {
|
|
186
164
|
const authInput = options.auth ?? {}
|
package/src/cron.ts
CHANGED
package/src/crud.ts
CHANGED
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
type AuthSessionResolver,
|
|
15
15
|
type CrudOperation,
|
|
16
16
|
type ResolvedAccess,
|
|
17
|
+
tableEntryForName,
|
|
17
18
|
type ResolvedTableAccess,
|
|
18
19
|
type ScopeMap,
|
|
19
20
|
type ScopeResolver,
|
|
@@ -38,15 +39,6 @@ export type CrudRouterOptions<
|
|
|
38
39
|
realtime?: RealtimeFacade<TSchema>
|
|
39
40
|
}
|
|
40
41
|
|
|
41
|
-
function tableEntryForName(
|
|
42
|
-
access: ResolvedAccess,
|
|
43
|
-
tableName: string,
|
|
44
|
-
): ResolvedTableAccess | undefined {
|
|
45
|
-
for (const entry of access.values()) {
|
|
46
|
-
if (entry.tableName === tableName) return entry
|
|
47
|
-
}
|
|
48
|
-
return undefined
|
|
49
|
-
}
|
|
50
42
|
|
|
51
43
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
52
44
|
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
package/src/env.ts
CHANGED
|
@@ -10,6 +10,10 @@ export type EnvConfigInput = {
|
|
|
10
10
|
runtimeEnv?: Record<string, unknown>
|
|
11
11
|
}
|
|
12
12
|
|
|
13
|
+
export type BunderstackRole = 'all' | 'web' | 'worker'
|
|
14
|
+
|
|
15
|
+
const ROLES: readonly BunderstackRole[] = ['all', 'web', 'worker']
|
|
16
|
+
|
|
13
17
|
/** Vars bunderstack itself consumes, always validated. */
|
|
14
18
|
export type BaseEnv = {
|
|
15
19
|
NODE_ENV?: string
|
|
@@ -19,7 +23,7 @@ export type BaseEnv = {
|
|
|
19
23
|
REDIS_URL?: string
|
|
20
24
|
RESEND_API_KEY?: string
|
|
21
25
|
SMTP_URL?: string
|
|
22
|
-
|
|
26
|
+
BUNDERSTACK_ROLE: BunderstackRole
|
|
23
27
|
}
|
|
24
28
|
|
|
25
29
|
type InferVars<T> =
|
|
@@ -53,8 +57,6 @@ export type ValidateEnvOptions = {
|
|
|
53
57
|
source?: Record<string, string | undefined>
|
|
54
58
|
/** Dialect-aware DATABASE_URL fallback; createBunderstack passes it. */
|
|
55
59
|
defaultDatabaseUrl?: string
|
|
56
|
-
/** Require the platform schedule secret for an application with cron work. */
|
|
57
|
-
cronConfigured?: boolean
|
|
58
60
|
}
|
|
59
61
|
|
|
60
62
|
const DEV_AUTH_SECRET = 'dev-secret-change-in-prod'
|
|
@@ -109,18 +111,17 @@ export function validateEnv<TEnv extends EnvConfigInput | undefined>(
|
|
|
109
111
|
REDIS_URL: source.REDIS_URL,
|
|
110
112
|
RESEND_API_KEY: source.RESEND_API_KEY,
|
|
111
113
|
SMTP_URL: source.SMTP_URL,
|
|
112
|
-
|
|
114
|
+
BUNDERSTACK_ROLE: (source.BUNDERSTACK_ROLE ?? 'all') as BunderstackRole,
|
|
113
115
|
}
|
|
114
116
|
if (isProduction && !source.AUTH_SECRET) {
|
|
115
117
|
issues.push('AUTH_SECRET: required in production')
|
|
116
118
|
}
|
|
117
119
|
if (
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
!source.BUNDERSTACK_CRON_SECRET
|
|
120
|
+
source.BUNDERSTACK_ROLE !== undefined &&
|
|
121
|
+
!ROLES.includes(source.BUNDERSTACK_ROLE as BunderstackRole)
|
|
121
122
|
) {
|
|
122
123
|
issues.push(
|
|
123
|
-
|
|
124
|
+
`BUNDERSTACK_ROLE: must be one of ${ROLES.join(', ')} (got "${String(source.BUNDERSTACK_ROLE)}")`,
|
|
124
125
|
)
|
|
125
126
|
}
|
|
126
127
|
if (options.emailProvider === 'resend' && !source.RESEND_API_KEY) {
|
package/src/handler.ts
CHANGED
|
@@ -4,11 +4,11 @@ import { Hono } from 'hono'
|
|
|
4
4
|
import { createRateLimiter, type RateLimitConfig } from './rate-limit'
|
|
5
5
|
|
|
6
6
|
interface HandlerParts {
|
|
7
|
+
customRouter?: Hono
|
|
7
8
|
crudRouter: Hono
|
|
8
9
|
authHandler?: (req: Request) => Promise<Response>
|
|
9
10
|
storageRouter?: Hono
|
|
10
11
|
realtimeRouter?: Hono
|
|
11
|
-
cronRouter?: Hono
|
|
12
12
|
trpcHandler?: (req: Request) => Promise<Response>
|
|
13
13
|
rateLimit?: boolean | RateLimitConfig
|
|
14
14
|
}
|
|
@@ -20,16 +20,16 @@ export function buildHandler(parts: HandlerParts): {
|
|
|
20
20
|
const app = new Hono()
|
|
21
21
|
const checkRateLimit = createRateLimiter(parts.rateLimit)
|
|
22
22
|
|
|
23
|
+
// Registered ahead of everything so custom routes can sit in front of the
|
|
24
|
+
// core app. Collisions are rejected at construction, not silently shadowed.
|
|
25
|
+
if (parts.customRouter) app.route('/', parts.customRouter)
|
|
26
|
+
|
|
23
27
|
const health = (c: { json: (data: unknown) => Response }) =>
|
|
24
28
|
c.json({ status: 'ok' })
|
|
25
29
|
app.get('/health', health)
|
|
26
30
|
app.get('/api/health', health)
|
|
27
31
|
app.route('/api', parts.crudRouter)
|
|
28
32
|
|
|
29
|
-
if (parts.cronRouter) {
|
|
30
|
-
app.route('/api/_bunderstack', parts.cronRouter)
|
|
31
|
-
}
|
|
32
|
-
|
|
33
33
|
if (parts.authHandler) {
|
|
34
34
|
app.all('/api/auth/*', (c) => parts.authHandler!(c.req.raw))
|
|
35
35
|
}
|
package/src/index.ts
CHANGED
|
@@ -4,6 +4,8 @@ import type { Hono as HonoType } from 'hono'
|
|
|
4
4
|
|
|
5
5
|
import { fetchRequestHandler } from '@trpc/server/adapters/fetch'
|
|
6
6
|
|
|
7
|
+
import { getTableName, isTable } from 'drizzle-orm'
|
|
8
|
+
|
|
7
9
|
import type { TableAccessInput } from './access'
|
|
8
10
|
import type { DbFor } from './db'
|
|
9
11
|
import type {
|
|
@@ -11,15 +13,17 @@ import type {
|
|
|
11
13
|
EnqueueOptions,
|
|
12
14
|
JobsDefs,
|
|
13
15
|
JobsFacade,
|
|
14
|
-
LocalCronScheduler,
|
|
15
|
-
LocalCronSchedulerOptions,
|
|
16
16
|
StartWorkerOptions,
|
|
17
17
|
WorkerHandle,
|
|
18
18
|
} from './jobs/index'
|
|
19
19
|
import type { StorageConfigInput } from './storage/buckets'
|
|
20
20
|
import type { StorageAdapter } from './storage/index'
|
|
21
21
|
|
|
22
|
-
import {
|
|
22
|
+
import {
|
|
23
|
+
resolveAccessUser,
|
|
24
|
+
tableEntryForName,
|
|
25
|
+
validateAndResolveAccess,
|
|
26
|
+
} from './access'
|
|
23
27
|
import {
|
|
24
28
|
createAuth,
|
|
25
29
|
toAuthSessionResolver,
|
|
@@ -37,10 +41,7 @@ import { withInternalTables } from './internal-tables'
|
|
|
37
41
|
import {
|
|
38
42
|
createJobsBuilder,
|
|
39
43
|
createJobRunner,
|
|
40
|
-
buildCronRouter,
|
|
41
44
|
enqueueJob,
|
|
42
|
-
runCronSlot,
|
|
43
|
-
startLocalCronScheduler,
|
|
44
45
|
startJobWorker,
|
|
45
46
|
validateJobsDefs,
|
|
46
47
|
} from './jobs/index'
|
|
@@ -57,6 +58,7 @@ import {
|
|
|
57
58
|
} from './realtime/facade'
|
|
58
59
|
import { createRealtimeBroker, buildRealtimeRouter } from './realtime/index'
|
|
59
60
|
import { createRedisRealtimeBroker } from './realtime/redis'
|
|
61
|
+
import { createRouteContext, validateCustomRoutes } from './routes'
|
|
60
62
|
import { deleteFileWithDerivatives } from './storage/delete'
|
|
61
63
|
import { deleteFileMetaRow, insertReadyFile } from './storage/file-meta'
|
|
62
64
|
import { createBucketStorages } from './storage/registry'
|
|
@@ -64,7 +66,8 @@ import { buildBucketStorageRouter } from './storage/router'
|
|
|
64
66
|
import { sweepOrphans } from './storage/sweep'
|
|
65
67
|
import { createTRPC, type BunderstackTRPC } from './trpc'
|
|
66
68
|
|
|
67
|
-
type AuthInstance = ReturnType<typeof createAuth>
|
|
69
|
+
export type AuthInstance = ReturnType<typeof createAuth>
|
|
70
|
+
|
|
68
71
|
|
|
69
72
|
function waitForWorkerShutdown(
|
|
70
73
|
signal: AbortSignal,
|
|
@@ -138,11 +141,6 @@ export type AppRunWorkerOptions = AppStartWorkerOptions & {
|
|
|
138
141
|
*/
|
|
139
142
|
allowProcessLocalRealtime?: boolean
|
|
140
143
|
}
|
|
141
|
-
export type AppStartCronSchedulerOptions = Pick<
|
|
142
|
-
LocalCronSchedulerOptions,
|
|
143
|
-
'onError'
|
|
144
|
-
>
|
|
145
|
-
|
|
146
144
|
/** Bucket names declared in a storage config; `string` when unknowable. */
|
|
147
145
|
export type BucketNamesOf<TStorage> = TStorage extends {
|
|
148
146
|
buckets: infer B extends Record<string, unknown>
|
|
@@ -178,11 +176,9 @@ export type BunderstackApp<
|
|
|
178
176
|
startWorker(options?: AppStartWorkerOptions): Promise<WorkerHandle>
|
|
179
177
|
/** Run a queue worker until aborted, then close the application. */
|
|
180
178
|
runWorker(options?: AppRunWorkerOptions): Promise<void>
|
|
181
|
-
/** Start local delivery for declared cron tasks (for development only). */
|
|
182
|
-
startCronScheduler(
|
|
183
|
-
options?: AppStartCronSchedulerOptions,
|
|
184
|
-
): Promise<LocalCronScheduler>
|
|
185
179
|
close(): Promise<void>
|
|
180
|
+
/** True when this process is running the background tick loop. */
|
|
181
|
+
readonly backgroundRunning: boolean
|
|
186
182
|
readonly status: LifecycleStatus
|
|
187
183
|
readonly signal: AbortSignal
|
|
188
184
|
/** Deploy-time introspection: what this app needs provisioned. */
|
|
@@ -345,11 +341,9 @@ export async function createBunderstack<
|
|
|
345
341
|
emailProvider: emailProviderTag(options.email),
|
|
346
342
|
defaultDatabaseUrl:
|
|
347
343
|
dialect === 'pg' ? 'file:./data.pglite' : 'file:./data.db',
|
|
348
|
-
|
|
349
|
-
// production app has scheduled delivery even without user-defined cron.
|
|
350
|
-
cronConfigured: true,
|
|
344
|
+
source: options.processEnv,
|
|
351
345
|
})
|
|
352
|
-
const config = resolveConfig(options, env)
|
|
346
|
+
const config = resolveConfig(options, env, options.processEnv)
|
|
353
347
|
// Adapters use Drizzle mocks during deployment introspection, so the database
|
|
354
348
|
// and Redis below never touch external services.
|
|
355
349
|
const introspect = process.env.BUNDERSTACK_INTROSPECT === '1'
|
|
@@ -518,25 +512,41 @@ export async function createBunderstack<
|
|
|
518
512
|
})
|
|
519
513
|
},
|
|
520
514
|
}
|
|
521
|
-
const
|
|
515
|
+
const storageConfigured = Boolean(options.storage)
|
|
516
|
+
// The storage sweep used to be a hardcoded maintenance route. It is an
|
|
517
|
+
// ordinary cron now, so it inherits retries, timeout and onFailed.
|
|
518
|
+
const resolvedDefs: JobsDefs | undefined = storageConfigured
|
|
519
|
+
? {
|
|
520
|
+
...(jobsDefs ?? {}),
|
|
521
|
+
'bunderstack:storage-sweep': {
|
|
522
|
+
kind: 'cron',
|
|
523
|
+
schedule: '0 4 * * *',
|
|
524
|
+
handler: async () => {
|
|
525
|
+
await storage.sweep()
|
|
526
|
+
},
|
|
527
|
+
},
|
|
528
|
+
}
|
|
529
|
+
: jobsDefs
|
|
530
|
+
|
|
531
|
+
const jobRunner = resolvedDefs
|
|
522
532
|
? createJobRunner({
|
|
523
533
|
db,
|
|
524
|
-
defs:
|
|
534
|
+
defs: resolvedDefs,
|
|
525
535
|
ctx: { db: userDb, env, email, storage, realtime },
|
|
526
536
|
})
|
|
527
537
|
: undefined
|
|
528
538
|
const jobs = {
|
|
529
539
|
async enqueue(name: string, input?: unknown, opts?: EnqueueOptions) {
|
|
530
|
-
if (!
|
|
540
|
+
if (!resolvedDefs) {
|
|
531
541
|
throw new Error(
|
|
532
542
|
'[bunderstack] no jobs configured — add a `jobs` key to createBunderstack',
|
|
533
543
|
)
|
|
534
544
|
}
|
|
535
|
-
const result = await enqueueJob(db,
|
|
545
|
+
const result = await enqueueJob(db, resolvedDefs, name, input, opts)
|
|
536
546
|
return result
|
|
537
547
|
},
|
|
538
548
|
tick(now?: number) {
|
|
539
|
-
return jobRunner ? jobRunner.tick(now) : Promise.resolve()
|
|
549
|
+
return jobRunner ? jobRunner.tick(now) : Promise.resolve({ claimed: 0, ran: 0, failed: 0 })
|
|
540
550
|
},
|
|
541
551
|
}
|
|
542
552
|
if (jobRunner) jobRunner.setJobsFacade(jobs)
|
|
@@ -558,54 +568,16 @@ export async function createBunderstack<
|
|
|
558
568
|
const handle = startJobWorker({
|
|
559
569
|
...options,
|
|
560
570
|
signal,
|
|
561
|
-
tick
|
|
571
|
+
// The runtime loop only cares that a tick completed; TickResult is for
|
|
572
|
+
// callers that invoke tick() directly.
|
|
573
|
+
tick: async (now) => {
|
|
574
|
+
await jobRunner.tick(now)
|
|
575
|
+
},
|
|
562
576
|
})
|
|
563
577
|
const unregister = lifecycle.add(() => handle.close())
|
|
564
578
|
void handle.closed.finally(unregister)
|
|
565
579
|
return handle
|
|
566
580
|
}
|
|
567
|
-
const startCronScheduler = async (
|
|
568
|
-
options: AppStartCronSchedulerOptions = {},
|
|
569
|
-
): Promise<LocalCronScheduler> => {
|
|
570
|
-
if (introspect) {
|
|
571
|
-
return { tick: async () => {}, close: async () => {} }
|
|
572
|
-
}
|
|
573
|
-
const cron = Object.entries(jobsDefs ?? {}).flatMap(
|
|
574
|
-
([name, definition]) =>
|
|
575
|
-
definition.kind === 'cron'
|
|
576
|
-
? [{ name, schedule: definition.schedule }]
|
|
577
|
-
: [],
|
|
578
|
-
)
|
|
579
|
-
if (cron.length === 0) {
|
|
580
|
-
throw new Error('[bunderstack] no cron tasks configured')
|
|
581
|
-
}
|
|
582
|
-
if (lifecycle.status !== 'ready') {
|
|
583
|
-
throw new Error('[bunderstack] application lifecycle is closed')
|
|
584
|
-
}
|
|
585
|
-
const scheduler = startLocalCronScheduler({
|
|
586
|
-
cron,
|
|
587
|
-
onError: options.onError,
|
|
588
|
-
runSlot: async (name, slot) => {
|
|
589
|
-
await runCronSlot({
|
|
590
|
-
db,
|
|
591
|
-
defs: jobsDefs!,
|
|
592
|
-
ctx: { db: userDb, env, email, storage, realtime },
|
|
593
|
-
name,
|
|
594
|
-
slot,
|
|
595
|
-
now: Date.now(),
|
|
596
|
-
})
|
|
597
|
-
},
|
|
598
|
-
})
|
|
599
|
-
const unregister = lifecycle.add(() => scheduler.close())
|
|
600
|
-
try {
|
|
601
|
-
await scheduler.tick()
|
|
602
|
-
} catch (error) {
|
|
603
|
-
unregister()
|
|
604
|
-
await scheduler.close()
|
|
605
|
-
throw error
|
|
606
|
-
}
|
|
607
|
-
return scheduler
|
|
608
|
-
}
|
|
609
581
|
const runWorker = async (
|
|
610
582
|
options: AppRunWorkerOptions = {},
|
|
611
583
|
): Promise<void> => {
|
|
@@ -655,25 +627,52 @@ export async function createBunderstack<
|
|
|
655
627
|
}),
|
|
656
628
|
})
|
|
657
629
|
: undefined
|
|
658
|
-
const
|
|
659
|
-
?
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
630
|
+
const customRouter = options.routes
|
|
631
|
+
? (() => {
|
|
632
|
+
const routeCtx = createRouteContext({
|
|
633
|
+
db: userDb,
|
|
634
|
+
env,
|
|
635
|
+
storage,
|
|
636
|
+
email,
|
|
637
|
+
jobs,
|
|
638
|
+
realtime,
|
|
639
|
+
auth,
|
|
640
|
+
authResolver,
|
|
641
|
+
})
|
|
642
|
+
const built = (
|
|
643
|
+
options.routes as (ctx: unknown) => import('hono').Hono
|
|
644
|
+
)(routeCtx)
|
|
645
|
+
const enabledTables = Object.values(options.schema)
|
|
646
|
+
.filter((table) => isTable(table))
|
|
647
|
+
.map((table) => getTableName(table))
|
|
648
|
+
.filter((name) => tableEntryForName(resolvedAccess, name)?.enabled)
|
|
649
|
+
validateCustomRoutes(built.routes, enabledTables)
|
|
650
|
+
return built
|
|
651
|
+
})()
|
|
666
652
|
: undefined
|
|
667
653
|
const { handler, router } = buildHandler({
|
|
654
|
+
customRouter,
|
|
668
655
|
crudRouter,
|
|
669
656
|
authHandler: (req) => auth.handler(req),
|
|
670
657
|
storageRouter,
|
|
671
658
|
realtimeRouter,
|
|
672
659
|
trpcHandler,
|
|
673
|
-
cronRouter,
|
|
674
660
|
rateLimit: options.rateLimit,
|
|
675
661
|
})
|
|
676
662
|
|
|
663
|
+
// Topology is a deployment concern: the role decides whether this process
|
|
664
|
+
// runs background work, so application code never has to.
|
|
665
|
+
const roleWantsWorker =
|
|
666
|
+
env.BUNDERSTACK_ROLE === 'all' || env.BUNDERSTACK_ROLE === 'worker'
|
|
667
|
+
const autoStart =
|
|
668
|
+
options.background?.autoStart ??
|
|
669
|
+
(roleWantsWorker && !introspect && resolvedDefs !== undefined)
|
|
670
|
+
let backgroundRunning = false
|
|
671
|
+
if (autoStart) {
|
|
672
|
+
await startWorker()
|
|
673
|
+
backgroundRunning = true
|
|
674
|
+
}
|
|
675
|
+
|
|
677
676
|
const app: BunderstackApp<
|
|
678
677
|
TSchema,
|
|
679
678
|
TAccess,
|
|
@@ -697,8 +696,8 @@ export async function createBunderstack<
|
|
|
697
696
|
jobs: jobs as never,
|
|
698
697
|
startWorker,
|
|
699
698
|
runWorker,
|
|
700
|
-
startCronScheduler,
|
|
701
699
|
close: () => lifecycle.close(),
|
|
700
|
+
backgroundRunning,
|
|
702
701
|
get status() {
|
|
703
702
|
return lifecycle.status
|
|
704
703
|
},
|
|
@@ -712,7 +711,7 @@ export async function createBunderstack<
|
|
|
712
711
|
envConfig: options.env as EnvConfigInput | undefined,
|
|
713
712
|
emailProvider: emailProviderTag(options.email),
|
|
714
713
|
realtime: Boolean(config.realtime),
|
|
715
|
-
jobs:
|
|
714
|
+
jobs: resolvedDefs,
|
|
716
715
|
}),
|
|
717
716
|
}
|
|
718
717
|
|
|
@@ -763,11 +762,7 @@ export type {
|
|
|
763
762
|
} from './email'
|
|
764
763
|
export { createTRPC } from './trpc'
|
|
765
764
|
export type { BunderstackTRPC, TRPCContext } from './trpc'
|
|
766
|
-
export {
|
|
767
|
-
createJobsBuilder,
|
|
768
|
-
signScheduleRequest,
|
|
769
|
-
verifyScheduleRequest,
|
|
770
|
-
} from './jobs/index'
|
|
765
|
+
export { createJobsBuilder } from './jobs/index'
|
|
771
766
|
export type {
|
|
772
767
|
BunderstackJobContext,
|
|
773
768
|
BunderstackJobsBuilder,
|
|
@@ -783,8 +778,6 @@ export type {
|
|
|
783
778
|
JobsRuntimeFacade,
|
|
784
779
|
QueueJobDefinition,
|
|
785
780
|
QueueJobKeys,
|
|
786
|
-
LocalCronScheduler,
|
|
787
|
-
LocalCronSchedulerOptions,
|
|
788
781
|
RunWorkerOptions,
|
|
789
782
|
StartWorkerOptions,
|
|
790
783
|
WorkerHandle,
|
|
@@ -831,3 +824,10 @@ export type {
|
|
|
831
824
|
RealtimeTransport,
|
|
832
825
|
SchemaTable,
|
|
833
826
|
} from './realtime/facade'
|
|
827
|
+
|
|
828
|
+
export type {
|
|
829
|
+
BunderstackRouteContext,
|
|
830
|
+
RouteContext,
|
|
831
|
+
RoutesBuilder,
|
|
832
|
+
} from './routes'
|
|
833
|
+
|
|
@@ -67,20 +67,4 @@ export const bunderstackJobsPg = pgTable(
|
|
|
67
67
|
],
|
|
68
68
|
)
|
|
69
69
|
|
|
70
|
-
|
|
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
|
-
)
|
|
70
|
+
|