bunderstack 0.7.0 → 0.8.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 +23 -12
- package/package.json +23 -10
- package/src/config.ts +36 -6
- package/src/database/adapter.ts +26 -0
- package/src/database/bun-sql.ts +23 -0
- package/src/database/libsql.ts +28 -0
- package/src/database/pglite.ts +33 -0
- package/src/database/postgres-js.ts +33 -0
- package/src/db.ts +32 -74
- package/src/email/smtp.ts +45 -0
- package/src/email.ts +13 -59
- package/src/index.ts +359 -297
- package/src/lifecycle.ts +7 -2
- package/src/provision-internals.ts +2 -1
- package/src/provision.ts +7 -18
- package/src/typeid.ts +2 -2
package/src/index.ts
CHANGED
|
@@ -4,25 +4,36 @@ import type { Hono as HonoType } from 'hono'
|
|
|
4
4
|
|
|
5
5
|
import { fetchRequestHandler } from '@trpc/server/adapters/fetch'
|
|
6
6
|
|
|
7
|
-
import type { StorageAdapter } from './storage/index'
|
|
8
7
|
import type { TableAccessInput } from './access'
|
|
9
8
|
import type { DbFor } from './db'
|
|
9
|
+
import type {
|
|
10
|
+
BunderstackJobsBuilder,
|
|
11
|
+
EnqueueOptions,
|
|
12
|
+
JobsDefs,
|
|
13
|
+
JobsFacade,
|
|
14
|
+
LocalCronScheduler,
|
|
15
|
+
LocalCronSchedulerOptions,
|
|
16
|
+
StartWorkerOptions,
|
|
17
|
+
WorkerHandle,
|
|
18
|
+
} from './jobs/index'
|
|
10
19
|
import type { StorageConfigInput } from './storage/buckets'
|
|
20
|
+
import type { StorageAdapter } from './storage/index'
|
|
11
21
|
|
|
12
22
|
import { resolveAccessUser, validateAndResolveAccess } from './access'
|
|
13
|
-
import {
|
|
23
|
+
import {
|
|
24
|
+
createAuth,
|
|
25
|
+
toAuthSessionResolver,
|
|
26
|
+
withEmailAuthDefaults,
|
|
27
|
+
} from './auth'
|
|
14
28
|
import { resolveConfig, type BunderstackConfig } from './config'
|
|
15
29
|
import { resolveRealtimeRedisUrl } from './config'
|
|
30
|
+
import { buildCrudRouter } from './crud'
|
|
31
|
+
import { createDb } from './db'
|
|
16
32
|
import { detectDialect } from './dialect'
|
|
17
33
|
import { createEmail, emailProviderTag, type EmailFacade } from './email'
|
|
18
34
|
import { validateEnv, type EnvConfigInput, type ValidatedEnv } from './env'
|
|
19
|
-
import { buildManifest, type BunderstackManifest } from './manifest'
|
|
20
|
-
import { createTRPC, type BunderstackTRPC } from './trpc'
|
|
21
|
-
import { buildCrudRouter } from './crud'
|
|
22
|
-
import { createDb } from './db'
|
|
23
35
|
import { buildHandler } from './handler'
|
|
24
36
|
import { withInternalTables } from './internal-tables'
|
|
25
|
-
import { Lifecycle, type LifecycleStatus } from './lifecycle'
|
|
26
37
|
import {
|
|
27
38
|
createJobsBuilder,
|
|
28
39
|
createJobRunner,
|
|
@@ -33,28 +44,21 @@ import {
|
|
|
33
44
|
startJobWorker,
|
|
34
45
|
validateJobsDefs,
|
|
35
46
|
} from './jobs/index'
|
|
36
|
-
import type
|
|
37
|
-
|
|
38
|
-
EnqueueOptions,
|
|
39
|
-
JobsDefs,
|
|
40
|
-
JobsFacade,
|
|
41
|
-
LocalCronScheduler,
|
|
42
|
-
LocalCronSchedulerOptions,
|
|
43
|
-
StartWorkerOptions,
|
|
44
|
-
WorkerHandle,
|
|
45
|
-
} from './jobs/index'
|
|
47
|
+
import { Lifecycle, type LifecycleStatus } from './lifecycle'
|
|
48
|
+
import { buildManifest, type BunderstackManifest } from './manifest'
|
|
46
49
|
import {
|
|
47
50
|
PROVISION_INTERNALS,
|
|
48
51
|
type WithProvisionInternals,
|
|
49
52
|
} from './provision-internals'
|
|
53
|
+
import { createRealtimeFacade, type RealtimeFacade } from './realtime/facade'
|
|
50
54
|
import { createRealtimeBroker, buildRealtimeRouter } from './realtime/index'
|
|
51
55
|
import { createRedisRealtimeBroker } from './realtime/redis'
|
|
52
|
-
import { createRealtimeFacade, type RealtimeFacade } from './realtime/facade'
|
|
53
56
|
import { deleteFileWithDerivatives } from './storage/delete'
|
|
54
57
|
import { deleteFileMetaRow } from './storage/file-meta'
|
|
55
58
|
import { createBucketStorages } from './storage/registry'
|
|
56
59
|
import { buildBucketStorageRouter } from './storage/router'
|
|
57
60
|
import { sweepOrphans } from './storage/sweep'
|
|
61
|
+
import { createTRPC, type BunderstackTRPC } from './trpc'
|
|
58
62
|
|
|
59
63
|
type AuthInstance = ReturnType<typeof createAuth>
|
|
60
64
|
|
|
@@ -115,7 +119,6 @@ export type BucketNamesOf<TStorage> = TStorage extends {
|
|
|
115
119
|
? keyof B & string
|
|
116
120
|
: string
|
|
117
121
|
|
|
118
|
-
|
|
119
122
|
export type BunderstackApp<
|
|
120
123
|
TSchema extends Record<string, unknown>,
|
|
121
124
|
TAccess extends Record<string, TableAccessInput> | undefined = undefined,
|
|
@@ -136,7 +139,9 @@ export type BunderstackApp<
|
|
|
136
139
|
/** Email facade; always present — send() throws when email isn't configured. */
|
|
137
140
|
email: EmailFacade
|
|
138
141
|
/** Job queue facade; always present — enqueue throws when jobs aren't configured. */
|
|
139
|
-
jobs: JobsFacade<
|
|
142
|
+
jobs: JobsFacade<
|
|
143
|
+
TJobsDefs extends JobsDefs ? TJobsDefs : Record<never, never>
|
|
144
|
+
>
|
|
140
145
|
/** Typed custom row publication; enabled=false/no-op when realtime is off. */
|
|
141
146
|
realtime: RealtimeFacade<TSchema>
|
|
142
147
|
startWorker(options?: AppStartWorkerOptions): Promise<WorkerHandle>
|
|
@@ -186,7 +191,16 @@ export function createBunderstack<
|
|
|
186
191
|
/** Builder callback receiving the pre-wired `j` instance. */
|
|
187
192
|
jobs: (j: BunderstackJobsBuilder<TSchema, ValidatedEnv<TEnv>>) => TJobsDefs
|
|
188
193
|
},
|
|
189
|
-
): Promise<
|
|
194
|
+
): Promise<
|
|
195
|
+
BunderstackApp<
|
|
196
|
+
TSchema,
|
|
197
|
+
TAccess,
|
|
198
|
+
BucketNamesOf<TStorage>,
|
|
199
|
+
TEnv,
|
|
200
|
+
TRouter,
|
|
201
|
+
TJobsDefs
|
|
202
|
+
>
|
|
203
|
+
>
|
|
190
204
|
export function createBunderstack<
|
|
191
205
|
TSchema extends Record<string, unknown>,
|
|
192
206
|
const TAccess extends Record<string, TableAccessInput> | undefined =
|
|
@@ -202,7 +216,16 @@ export function createBunderstack<
|
|
|
202
216
|
/** Prebuilt job definitions (escape hatch for multi-file setups). */
|
|
203
217
|
jobs?: TJobsDefs
|
|
204
218
|
},
|
|
205
|
-
): Promise<
|
|
219
|
+
): Promise<
|
|
220
|
+
BunderstackApp<
|
|
221
|
+
TSchema,
|
|
222
|
+
TAccess,
|
|
223
|
+
BucketNamesOf<TStorage>,
|
|
224
|
+
TEnv,
|
|
225
|
+
TRouter,
|
|
226
|
+
TJobsDefs
|
|
227
|
+
>
|
|
228
|
+
>
|
|
206
229
|
export function createBunderstack<
|
|
207
230
|
TSchema extends Record<string, unknown>,
|
|
208
231
|
const TAccess extends Record<string, TableAccessInput> | undefined =
|
|
@@ -218,7 +241,16 @@ export function createBunderstack<
|
|
|
218
241
|
/** Builder callback receiving the pre-wired `j` instance. */
|
|
219
242
|
jobs: (j: BunderstackJobsBuilder<TSchema, ValidatedEnv<TEnv>>) => TJobsDefs
|
|
220
243
|
},
|
|
221
|
-
): Promise<
|
|
244
|
+
): Promise<
|
|
245
|
+
BunderstackApp<
|
|
246
|
+
TSchema,
|
|
247
|
+
TAccess,
|
|
248
|
+
BucketNamesOf<TStorage>,
|
|
249
|
+
TEnv,
|
|
250
|
+
TRouter,
|
|
251
|
+
TJobsDefs
|
|
252
|
+
>
|
|
253
|
+
>
|
|
222
254
|
export function createBunderstack<
|
|
223
255
|
TSchema extends Record<string, unknown>,
|
|
224
256
|
const TAccess extends Record<string, TableAccessInput> | undefined =
|
|
@@ -234,7 +266,16 @@ export function createBunderstack<
|
|
|
234
266
|
/** Prebuilt job definitions (escape hatch for multi-file setups). */
|
|
235
267
|
jobs?: TJobsDefs
|
|
236
268
|
},
|
|
237
|
-
): Promise<
|
|
269
|
+
): Promise<
|
|
270
|
+
BunderstackApp<
|
|
271
|
+
TSchema,
|
|
272
|
+
TAccess,
|
|
273
|
+
BucketNamesOf<TStorage>,
|
|
274
|
+
TEnv,
|
|
275
|
+
TRouter,
|
|
276
|
+
TJobsDefs
|
|
277
|
+
>
|
|
278
|
+
>
|
|
238
279
|
export async function createBunderstack<
|
|
239
280
|
TSchema extends Record<string, unknown>,
|
|
240
281
|
const TAccess extends Record<string, TableAccessInput> | undefined =
|
|
@@ -278,243 +319,246 @@ export async function createBunderstack<
|
|
|
278
319
|
cronConfigured: true,
|
|
279
320
|
})
|
|
280
321
|
const config = resolveConfig(options, env)
|
|
281
|
-
//
|
|
282
|
-
//
|
|
283
|
-
// the outside world — force an in-memory db (':memory:' is valid for both
|
|
284
|
-
// dialects) and skip Redis below. Env validation is already lenient (env.ts).
|
|
322
|
+
// Adapters use Drizzle mocks during deployment introspection, so the database
|
|
323
|
+
// and Redis below never touch external services.
|
|
285
324
|
const introspect = process.env.BUNDERSTACK_INTROSPECT === '1'
|
|
286
|
-
if (introspect) {
|
|
287
|
-
config.database.url = ':memory:'
|
|
288
|
-
config.database.authToken = undefined
|
|
289
|
-
}
|
|
290
325
|
const email = createEmail(options.email, { env })
|
|
291
326
|
// Merge bunderstack's internal tables (file-meta, idempotency) into the
|
|
292
327
|
// schema used for the db client + provisioning. CRUD/access stay on the USER
|
|
293
328
|
// schema so internal tables never get a CRUD route.
|
|
294
329
|
const mergedSchema = withInternalTables(options.schema)
|
|
295
|
-
const
|
|
330
|
+
const lifecycle = new Lifecycle()
|
|
331
|
+
const {
|
|
332
|
+
db,
|
|
333
|
+
driver,
|
|
334
|
+
close: closeDatabase,
|
|
335
|
+
} = await createDb(mergedSchema, {
|
|
296
336
|
...config.database,
|
|
297
337
|
dialect,
|
|
338
|
+
introspect,
|
|
298
339
|
})
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
db
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
340
|
+
if (closeDatabase) lifecycle.add(closeDatabase)
|
|
341
|
+
try {
|
|
342
|
+
// `db` is typed with the merged schema (user tables + internal tables) so the
|
|
343
|
+
// storage/idempotency code can query the internal tables. The public surface
|
|
344
|
+
// and CRUD only expose the USER schema. TS can widen the merged-schema db type
|
|
345
|
+
// on its own (storage/auth pass `db` directly), but it can't *narrow* a
|
|
346
|
+
// generic schema view, so this single intentional cast produces the
|
|
347
|
+
// user-facing, per-dialect db type. See `app.db` / crud below.
|
|
348
|
+
const userDb = db as unknown as DbFor<TSchema>
|
|
349
|
+
const auth = createAuth(
|
|
350
|
+
db,
|
|
351
|
+
withEmailAuthDefaults(config.auth, email, Boolean(options.email)),
|
|
352
|
+
dialect,
|
|
353
|
+
)
|
|
354
|
+
// Internal routers consume the narrow AuthSessionResolver contract, not the
|
|
355
|
+
// raw better-auth instance. app.auth still exposes `auth` unchanged.
|
|
356
|
+
const authResolver = toAuthSessionResolver(auth)
|
|
357
|
+
const resolvedAccess = validateAndResolveAccess(
|
|
358
|
+
options.schema,
|
|
359
|
+
options.access,
|
|
360
|
+
)
|
|
361
|
+
const realtimeBufferSize =
|
|
362
|
+
typeof config.realtime === 'object'
|
|
363
|
+
? config.realtime.bufferSize
|
|
364
|
+
: undefined
|
|
365
|
+
const redisUrl =
|
|
366
|
+
config.realtime && !introspect
|
|
367
|
+
? resolveRealtimeRedisUrl(config.realtime, env)
|
|
368
|
+
: undefined
|
|
369
|
+
const broker = config.realtime
|
|
370
|
+
? redisUrl
|
|
371
|
+
? createRedisRealtimeBroker({
|
|
372
|
+
access: resolvedAccess,
|
|
373
|
+
redis: () => {
|
|
374
|
+
// Redis pub/sub requires a dedicated connection (subscribe puts the client into
|
|
375
|
+
// a restricted state). We use one client for commands and a second for subscribe.
|
|
376
|
+
const cmdClient = new Bun.RedisClient(redisUrl)
|
|
377
|
+
const subClient = new Bun.RedisClient(redisUrl)
|
|
378
|
+
return {
|
|
379
|
+
incr: (key: string) => cmdClient.incr(key),
|
|
380
|
+
publish: (channel: string, message: string) =>
|
|
381
|
+
cmdClient.publish(channel, message),
|
|
382
|
+
subscribe: (channel: string, listener: (msg: string) => void) =>
|
|
383
|
+
subClient.subscribe(channel, listener),
|
|
384
|
+
lpush: (key: string, value: string) =>
|
|
385
|
+
cmdClient.lpush(key, value),
|
|
386
|
+
ltrim: (key: string, start: number, stop: number) =>
|
|
387
|
+
cmdClient.ltrim(key, start, stop),
|
|
388
|
+
lrange: (key: string, start: number, stop: number) =>
|
|
389
|
+
cmdClient.lrange(key, start, stop),
|
|
390
|
+
close: () => {
|
|
391
|
+
cmdClient.close()
|
|
392
|
+
subClient.close()
|
|
393
|
+
},
|
|
394
|
+
}
|
|
395
|
+
},
|
|
396
|
+
bufferSize: realtimeBufferSize,
|
|
397
|
+
})
|
|
398
|
+
: createRealtimeBroker({
|
|
399
|
+
access: resolvedAccess,
|
|
400
|
+
bufferSize: realtimeBufferSize,
|
|
401
|
+
})
|
|
323
402
|
: undefined
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
subClient.subscribe(channel, listener),
|
|
339
|
-
lpush: (key: string, value: string) =>
|
|
340
|
-
cmdClient.lpush(key, value),
|
|
341
|
-
ltrim: (key: string, start: number, stop: number) =>
|
|
342
|
-
cmdClient.ltrim(key, start, stop),
|
|
343
|
-
lrange: (key: string, start: number, stop: number) =>
|
|
344
|
-
cmdClient.lrange(key, start, stop),
|
|
345
|
-
close: () => {
|
|
346
|
-
cmdClient.close()
|
|
347
|
-
subClient.close()
|
|
348
|
-
},
|
|
349
|
-
}
|
|
350
|
-
},
|
|
351
|
-
bufferSize: realtimeBufferSize,
|
|
352
|
-
})
|
|
353
|
-
: createRealtimeBroker({
|
|
354
|
-
access: resolvedAccess,
|
|
355
|
-
bufferSize: realtimeBufferSize,
|
|
403
|
+
const realtime = createRealtimeFacade<TSchema>(broker)
|
|
404
|
+
const crudRouter = buildCrudRouter(options.schema, userDb, {
|
|
405
|
+
auth: authResolver,
|
|
406
|
+
access: resolvedAccess,
|
|
407
|
+
idempotency: options.idempotency,
|
|
408
|
+
realtime,
|
|
409
|
+
})
|
|
410
|
+
const realtimeRouter = broker
|
|
411
|
+
? buildRealtimeRouter(broker, {
|
|
412
|
+
auth: authResolver,
|
|
413
|
+
keepaliveMs:
|
|
414
|
+
typeof config.realtime === 'object'
|
|
415
|
+
? config.realtime.keepaliveMs
|
|
416
|
+
: undefined,
|
|
356
417
|
})
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
})
|
|
365
|
-
const realtimeRouter = broker
|
|
366
|
-
? buildRealtimeRouter(broker, {
|
|
367
|
-
auth: authResolver,
|
|
368
|
-
keepaliveMs:
|
|
369
|
-
typeof config.realtime === 'object'
|
|
370
|
-
? config.realtime.keepaliveMs
|
|
371
|
-
: undefined,
|
|
372
|
-
})
|
|
373
|
-
: undefined
|
|
374
|
-
const registry = createBucketStorages(config.storage)
|
|
375
|
-
const lifecycle = new Lifecycle()
|
|
376
|
-
if (broker) lifecycle.add(() => broker.close())
|
|
377
|
-
const storageRouter = buildBucketStorageRouter({
|
|
378
|
-
registry,
|
|
379
|
-
db,
|
|
380
|
-
auth: authResolver,
|
|
381
|
-
})
|
|
382
|
-
const storage: StorageFacade = {
|
|
383
|
-
async delete(fileId) {
|
|
384
|
-
const bucketName = fileId.split('/')[0] ?? ''
|
|
385
|
-
const entry = registry.get(bucketName)
|
|
386
|
-
if (entry) {
|
|
387
|
-
await deleteFileWithDerivatives(entry.adapter, db, fileId)
|
|
388
|
-
} else {
|
|
389
|
-
// Unknown bucket: no adapter to clean, but still drop the meta row.
|
|
390
|
-
await deleteFileMetaRow(db, fileId)
|
|
391
|
-
}
|
|
392
|
-
},
|
|
393
|
-
bucket(name) {
|
|
394
|
-
return registry.get(name)?.adapter
|
|
395
|
-
},
|
|
396
|
-
sweep(olderThanMs = DEFAULT_PENDING_TTL_MS) {
|
|
397
|
-
return sweepOrphans(registry, db, olderThanMs)
|
|
398
|
-
},
|
|
399
|
-
}
|
|
400
|
-
const jobRunner = jobsDefs
|
|
401
|
-
? createJobRunner({
|
|
402
|
-
db,
|
|
403
|
-
defs: jobsDefs,
|
|
404
|
-
ctx: { db: userDb, env, email, storage, realtime },
|
|
405
|
-
})
|
|
406
|
-
: undefined
|
|
407
|
-
const jobs = {
|
|
408
|
-
async enqueue(name: string, input?: unknown, opts?: EnqueueOptions) {
|
|
409
|
-
if (!jobsDefs) {
|
|
410
|
-
throw new Error(
|
|
411
|
-
'[bunderstack] no jobs configured — add a `jobs` key to createBunderstack',
|
|
412
|
-
)
|
|
413
|
-
}
|
|
414
|
-
const result = await enqueueJob(db, jobsDefs, name, input, opts)
|
|
415
|
-
return result
|
|
416
|
-
},
|
|
417
|
-
tick(now?: number) {
|
|
418
|
-
return jobRunner ? jobRunner.tick(now) : Promise.resolve()
|
|
419
|
-
},
|
|
420
|
-
}
|
|
421
|
-
if (jobRunner) jobRunner.setJobsFacade(jobs)
|
|
422
|
-
const startWorker = async (
|
|
423
|
-
options: AppStartWorkerOptions = {},
|
|
424
|
-
): Promise<WorkerHandle> => {
|
|
425
|
-
if (!jobRunner) {
|
|
426
|
-
throw new Error('[bunderstack] no queue jobs configured')
|
|
427
|
-
}
|
|
428
|
-
if (lifecycle.status !== 'ready') {
|
|
429
|
-
throw new Error('[bunderstack] application lifecycle is closed')
|
|
430
|
-
}
|
|
431
|
-
const signal = options.signal
|
|
432
|
-
? AbortSignal.any([lifecycle.signal, options.signal])
|
|
433
|
-
: lifecycle.signal
|
|
434
|
-
const handle = startJobWorker({
|
|
435
|
-
...options,
|
|
436
|
-
signal,
|
|
437
|
-
tick: (now) => jobRunner.tick(now),
|
|
418
|
+
: undefined
|
|
419
|
+
const registry = createBucketStorages(config.storage)
|
|
420
|
+
if (broker) lifecycle.add(() => broker.close())
|
|
421
|
+
const storageRouter = buildBucketStorageRouter({
|
|
422
|
+
registry,
|
|
423
|
+
db,
|
|
424
|
+
auth: authResolver,
|
|
438
425
|
})
|
|
439
|
-
const
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
426
|
+
const storage: StorageFacade = {
|
|
427
|
+
async delete(fileId) {
|
|
428
|
+
const bucketName = fileId.split('/')[0] ?? ''
|
|
429
|
+
const entry = registry.get(bucketName)
|
|
430
|
+
if (entry) {
|
|
431
|
+
await deleteFileWithDerivatives(entry.adapter, db, fileId)
|
|
432
|
+
} else {
|
|
433
|
+
// Unknown bucket: no adapter to clean, but still drop the meta row.
|
|
434
|
+
await deleteFileMetaRow(db, fileId)
|
|
435
|
+
}
|
|
436
|
+
},
|
|
437
|
+
bucket(name) {
|
|
438
|
+
return registry.get(name)?.adapter
|
|
439
|
+
},
|
|
440
|
+
sweep(olderThanMs = DEFAULT_PENDING_TTL_MS) {
|
|
441
|
+
return sweepOrphans(registry, db, olderThanMs)
|
|
442
|
+
},
|
|
456
443
|
}
|
|
457
|
-
const
|
|
458
|
-
|
|
459
|
-
onError: options.onError,
|
|
460
|
-
runSlot: async (name, slot) => {
|
|
461
|
-
await runCronSlot({
|
|
444
|
+
const jobRunner = jobsDefs
|
|
445
|
+
? createJobRunner({
|
|
462
446
|
db,
|
|
463
|
-
defs: jobsDefs
|
|
447
|
+
defs: jobsDefs,
|
|
464
448
|
ctx: { db: userDb, env, email, storage, realtime },
|
|
465
|
-
name,
|
|
466
|
-
slot,
|
|
467
|
-
now: Date.now(),
|
|
468
449
|
})
|
|
450
|
+
: undefined
|
|
451
|
+
const jobs = {
|
|
452
|
+
async enqueue(name: string, input?: unknown, opts?: EnqueueOptions) {
|
|
453
|
+
if (!jobsDefs) {
|
|
454
|
+
throw new Error(
|
|
455
|
+
'[bunderstack] no jobs configured — add a `jobs` key to createBunderstack',
|
|
456
|
+
)
|
|
457
|
+
}
|
|
458
|
+
const result = await enqueueJob(db, jobsDefs, name, input, opts)
|
|
459
|
+
return result
|
|
460
|
+
},
|
|
461
|
+
tick(now?: number) {
|
|
462
|
+
return jobRunner ? jobRunner.tick(now) : Promise.resolve()
|
|
469
463
|
},
|
|
470
|
-
})
|
|
471
|
-
const unregister = lifecycle.add(() => scheduler.close())
|
|
472
|
-
try {
|
|
473
|
-
await scheduler.tick()
|
|
474
|
-
} catch (error) {
|
|
475
|
-
unregister()
|
|
476
|
-
await scheduler.close()
|
|
477
|
-
throw error
|
|
478
464
|
}
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
465
|
+
if (jobRunner) jobRunner.setJobsFacade(jobs)
|
|
466
|
+
const startWorker = async (
|
|
467
|
+
options: AppStartWorkerOptions = {},
|
|
468
|
+
): Promise<WorkerHandle> => {
|
|
469
|
+
if (!jobRunner) {
|
|
470
|
+
throw new Error('[bunderstack] no queue jobs configured')
|
|
471
|
+
}
|
|
472
|
+
if (lifecycle.status !== 'ready') {
|
|
473
|
+
throw new Error('[bunderstack] application lifecycle is closed')
|
|
474
|
+
}
|
|
486
475
|
const signal = options.signal
|
|
487
476
|
? AbortSignal.any([lifecycle.signal, options.signal])
|
|
488
477
|
: lifecycle.signal
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
478
|
+
const handle = startJobWorker({
|
|
479
|
+
...options,
|
|
480
|
+
signal,
|
|
481
|
+
tick: (now) => jobRunner.tick(now),
|
|
482
|
+
})
|
|
483
|
+
const unregister = lifecycle.add(() => handle.close())
|
|
484
|
+
void handle.closed.finally(unregister)
|
|
485
|
+
return handle
|
|
493
486
|
}
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
487
|
+
const startCronScheduler = async (
|
|
488
|
+
options: AppStartCronSchedulerOptions = {},
|
|
489
|
+
): Promise<LocalCronScheduler> => {
|
|
490
|
+
const cron = Object.entries(jobsDefs ?? {}).flatMap(
|
|
491
|
+
([name, definition]) =>
|
|
492
|
+
definition.kind === 'cron'
|
|
493
|
+
? [{ name, schedule: definition.schedule }]
|
|
494
|
+
: [],
|
|
495
|
+
)
|
|
496
|
+
if (cron.length === 0) {
|
|
497
|
+
throw new Error('[bunderstack] no cron tasks configured')
|
|
498
|
+
}
|
|
499
|
+
if (lifecycle.status !== 'ready') {
|
|
500
|
+
throw new Error('[bunderstack] application lifecycle is closed')
|
|
501
|
+
}
|
|
502
|
+
const scheduler = startLocalCronScheduler({
|
|
503
|
+
cron,
|
|
504
|
+
onError: options.onError,
|
|
505
|
+
runSlot: async (name, slot) => {
|
|
506
|
+
await runCronSlot({
|
|
507
|
+
db,
|
|
508
|
+
defs: jobsDefs!,
|
|
509
|
+
ctx: { db: userDb, env, email, storage, realtime },
|
|
510
|
+
name,
|
|
511
|
+
slot,
|
|
512
|
+
now: Date.now(),
|
|
513
|
+
})
|
|
514
|
+
},
|
|
515
|
+
})
|
|
516
|
+
const unregister = lifecycle.add(() => scheduler.close())
|
|
517
|
+
try {
|
|
518
|
+
await scheduler.tick()
|
|
519
|
+
} catch (error) {
|
|
520
|
+
unregister()
|
|
521
|
+
await scheduler.close()
|
|
522
|
+
throw error
|
|
523
|
+
}
|
|
524
|
+
return scheduler
|
|
525
|
+
}
|
|
526
|
+
const runWorker = async (
|
|
527
|
+
options: AppRunWorkerOptions = {},
|
|
528
|
+
): Promise<void> => {
|
|
529
|
+
const handle = await startWorker(options)
|
|
530
|
+
try {
|
|
531
|
+
const signal = options.signal
|
|
532
|
+
? AbortSignal.any([lifecycle.signal, options.signal])
|
|
533
|
+
: lifecycle.signal
|
|
534
|
+
await waitForWorkerShutdown(signal, !options.signal)
|
|
535
|
+
} finally {
|
|
536
|
+
await handle.close()
|
|
537
|
+
await lifecycle.close()
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
const trpcRouter: AnyRouter | undefined =
|
|
541
|
+
typeof options.trpc === 'function'
|
|
542
|
+
? options.trpc(createTRPC<TSchema, ValidatedEnv<TEnv>>())
|
|
543
|
+
: options.trpc
|
|
544
|
+
const trpcHandler = trpcRouter
|
|
545
|
+
? (req: Request) =>
|
|
546
|
+
fetchRequestHandler({
|
|
547
|
+
endpoint: '/api/trpc',
|
|
512
548
|
req,
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
549
|
+
router: trpcRouter,
|
|
550
|
+
createContext: async () => ({
|
|
551
|
+
db: userDb,
|
|
552
|
+
user: await resolveAccessUser(authResolver, req.headers),
|
|
553
|
+
env,
|
|
554
|
+
email,
|
|
555
|
+
jobs,
|
|
556
|
+
realtime,
|
|
557
|
+
req,
|
|
558
|
+
}),
|
|
559
|
+
})
|
|
560
|
+
: undefined
|
|
561
|
+
const cronRouter = env.BUNDERSTACK_CRON_SECRET
|
|
518
562
|
? buildCronRouter({
|
|
519
563
|
db,
|
|
520
564
|
defs: jobsDefs ?? {},
|
|
@@ -523,69 +567,81 @@ export async function createBunderstack<
|
|
|
523
567
|
storage,
|
|
524
568
|
})
|
|
525
569
|
: undefined
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
570
|
+
const { handler, router } = buildHandler({
|
|
571
|
+
crudRouter,
|
|
572
|
+
authHandler: (req) => auth.handler(req),
|
|
573
|
+
storageRouter,
|
|
574
|
+
realtimeRouter,
|
|
575
|
+
trpcHandler,
|
|
576
|
+
cronRouter,
|
|
577
|
+
rateLimit: options.rateLimit,
|
|
578
|
+
})
|
|
535
579
|
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
580
|
+
const app: BunderstackApp<
|
|
581
|
+
TSchema,
|
|
582
|
+
TAccess,
|
|
583
|
+
BucketNamesOf<TStorage>,
|
|
584
|
+
TEnv,
|
|
585
|
+
AnyRouter | undefined,
|
|
586
|
+
JobsDefs | undefined
|
|
587
|
+
> = {
|
|
588
|
+
handler,
|
|
589
|
+
// Internal tables live on the runtime db but stay out of the public type.
|
|
590
|
+
db: userDb,
|
|
591
|
+
auth,
|
|
592
|
+
storage,
|
|
593
|
+
router,
|
|
594
|
+
env,
|
|
595
|
+
email,
|
|
596
|
+
realtime,
|
|
597
|
+
// Runtime facade is untyped (JobsRuntimeFacade); the generic-typed field
|
|
598
|
+
// narrows `enqueue` per-app from the declared job defs — same relationship
|
|
599
|
+
// as `userDb` above.
|
|
600
|
+
jobs: jobs as never,
|
|
601
|
+
startWorker,
|
|
602
|
+
runWorker,
|
|
603
|
+
startCronScheduler,
|
|
604
|
+
close: () => lifecycle.close(),
|
|
605
|
+
get status() {
|
|
606
|
+
return lifecycle.status
|
|
607
|
+
},
|
|
608
|
+
signal: lifecycle.signal,
|
|
609
|
+
trpcRouter,
|
|
610
|
+
manifest: buildManifest({
|
|
611
|
+
schema: options.schema,
|
|
612
|
+
dialect,
|
|
613
|
+
storage: config.storage,
|
|
614
|
+
envConfig: options.env as EnvConfigInput | undefined,
|
|
615
|
+
realtime: Boolean(config.realtime),
|
|
616
|
+
jobs: jobsDefs,
|
|
617
|
+
}),
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
// Hidden handle for the optional `bunderstack/provision` entry. Kept off the
|
|
621
|
+
// public type so provisioning stays opt-in (and drizzle-kit out of this
|
|
622
|
+
// module graph).
|
|
623
|
+
;(app as WithProvisionInternals)[PROVISION_INTERNALS] = {
|
|
624
|
+
db,
|
|
625
|
+
schema: mergedSchema,
|
|
626
|
+
databaseUrl: config.database.url,
|
|
627
|
+
migrationsFolder: config.database.migrations,
|
|
568
628
|
dialect,
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
jobs: jobsDefs,
|
|
573
|
-
}),
|
|
574
|
-
}
|
|
629
|
+
driver,
|
|
630
|
+
adapter: config.database.adapter,
|
|
631
|
+
}
|
|
575
632
|
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
633
|
+
return app
|
|
634
|
+
} catch (cause) {
|
|
635
|
+
try {
|
|
636
|
+
await lifecycle.close()
|
|
637
|
+
} catch (cleanupCause) {
|
|
638
|
+
throw new AggregateError(
|
|
639
|
+
[cause, cleanupCause],
|
|
640
|
+
'[bunderstack] application initialization failed and cleanup failed',
|
|
641
|
+
)
|
|
642
|
+
}
|
|
643
|
+
throw cause
|
|
586
644
|
}
|
|
587
|
-
|
|
588
|
-
return app
|
|
589
645
|
}
|
|
590
646
|
|
|
591
647
|
export { MAX_LIST_LIMIT } from './list-query'
|
|
@@ -652,6 +708,12 @@ export {
|
|
|
652
708
|
asTypeId,
|
|
653
709
|
} from './typeid'
|
|
654
710
|
export type { TypeId } from './typeid'
|
|
711
|
+
export type {
|
|
712
|
+
DatabaseAdapter,
|
|
713
|
+
DatabaseConnectOptions,
|
|
714
|
+
DatabaseConnection,
|
|
715
|
+
DatabaseConnectionResult,
|
|
716
|
+
} from './database/adapter'
|
|
655
717
|
export type { StorageAdapter } from './storage/index'
|
|
656
718
|
export type {
|
|
657
719
|
StorageConfigInput,
|