bunderstack 0.6.1 → 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 +49 -12
- package/package.json +23 -10
- package/src/config.ts +36 -6
- package/src/crud.ts +10 -12
- 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 +366 -295
- package/src/jobs/define.ts +3 -0
- package/src/lifecycle.ts +7 -2
- package/src/provision-internals.ts +2 -1
- package/src/provision.ts +7 -18
- package/src/realtime/facade.ts +36 -0
- package/src/trpc.ts +2 -0
- 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,20 +44,13 @@ 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
56
|
import { deleteFileWithDerivatives } from './storage/delete'
|
|
@@ -54,6 +58,7 @@ import { deleteFileMetaRow } from './storage/file-meta'
|
|
|
54
58
|
import { createBucketStorages } from './storage/registry'
|
|
55
59
|
import { buildBucketStorageRouter } from './storage/router'
|
|
56
60
|
import { sweepOrphans } from './storage/sweep'
|
|
61
|
+
import { createTRPC, type BunderstackTRPC } from './trpc'
|
|
57
62
|
|
|
58
63
|
type AuthInstance = ReturnType<typeof createAuth>
|
|
59
64
|
|
|
@@ -114,7 +119,6 @@ export type BucketNamesOf<TStorage> = TStorage extends {
|
|
|
114
119
|
? keyof B & string
|
|
115
120
|
: string
|
|
116
121
|
|
|
117
|
-
|
|
118
122
|
export type BunderstackApp<
|
|
119
123
|
TSchema extends Record<string, unknown>,
|
|
120
124
|
TAccess extends Record<string, TableAccessInput> | undefined = undefined,
|
|
@@ -135,7 +139,11 @@ export type BunderstackApp<
|
|
|
135
139
|
/** Email facade; always present — send() throws when email isn't configured. */
|
|
136
140
|
email: EmailFacade
|
|
137
141
|
/** Job queue facade; always present — enqueue throws when jobs aren't configured. */
|
|
138
|
-
jobs: JobsFacade<
|
|
142
|
+
jobs: JobsFacade<
|
|
143
|
+
TJobsDefs extends JobsDefs ? TJobsDefs : Record<never, never>
|
|
144
|
+
>
|
|
145
|
+
/** Typed custom row publication; enabled=false/no-op when realtime is off. */
|
|
146
|
+
realtime: RealtimeFacade<TSchema>
|
|
139
147
|
startWorker(options?: AppStartWorkerOptions): Promise<WorkerHandle>
|
|
140
148
|
/** Run a queue worker until aborted, then close the application. */
|
|
141
149
|
runWorker(options?: AppRunWorkerOptions): Promise<void>
|
|
@@ -183,7 +191,16 @@ export function createBunderstack<
|
|
|
183
191
|
/** Builder callback receiving the pre-wired `j` instance. */
|
|
184
192
|
jobs: (j: BunderstackJobsBuilder<TSchema, ValidatedEnv<TEnv>>) => TJobsDefs
|
|
185
193
|
},
|
|
186
|
-
): Promise<
|
|
194
|
+
): Promise<
|
|
195
|
+
BunderstackApp<
|
|
196
|
+
TSchema,
|
|
197
|
+
TAccess,
|
|
198
|
+
BucketNamesOf<TStorage>,
|
|
199
|
+
TEnv,
|
|
200
|
+
TRouter,
|
|
201
|
+
TJobsDefs
|
|
202
|
+
>
|
|
203
|
+
>
|
|
187
204
|
export function createBunderstack<
|
|
188
205
|
TSchema extends Record<string, unknown>,
|
|
189
206
|
const TAccess extends Record<string, TableAccessInput> | undefined =
|
|
@@ -199,7 +216,16 @@ export function createBunderstack<
|
|
|
199
216
|
/** Prebuilt job definitions (escape hatch for multi-file setups). */
|
|
200
217
|
jobs?: TJobsDefs
|
|
201
218
|
},
|
|
202
|
-
): Promise<
|
|
219
|
+
): Promise<
|
|
220
|
+
BunderstackApp<
|
|
221
|
+
TSchema,
|
|
222
|
+
TAccess,
|
|
223
|
+
BucketNamesOf<TStorage>,
|
|
224
|
+
TEnv,
|
|
225
|
+
TRouter,
|
|
226
|
+
TJobsDefs
|
|
227
|
+
>
|
|
228
|
+
>
|
|
203
229
|
export function createBunderstack<
|
|
204
230
|
TSchema extends Record<string, unknown>,
|
|
205
231
|
const TAccess extends Record<string, TableAccessInput> | undefined =
|
|
@@ -215,7 +241,16 @@ export function createBunderstack<
|
|
|
215
241
|
/** Builder callback receiving the pre-wired `j` instance. */
|
|
216
242
|
jobs: (j: BunderstackJobsBuilder<TSchema, ValidatedEnv<TEnv>>) => TJobsDefs
|
|
217
243
|
},
|
|
218
|
-
): Promise<
|
|
244
|
+
): Promise<
|
|
245
|
+
BunderstackApp<
|
|
246
|
+
TSchema,
|
|
247
|
+
TAccess,
|
|
248
|
+
BucketNamesOf<TStorage>,
|
|
249
|
+
TEnv,
|
|
250
|
+
TRouter,
|
|
251
|
+
TJobsDefs
|
|
252
|
+
>
|
|
253
|
+
>
|
|
219
254
|
export function createBunderstack<
|
|
220
255
|
TSchema extends Record<string, unknown>,
|
|
221
256
|
const TAccess extends Record<string, TableAccessInput> | undefined =
|
|
@@ -231,7 +266,16 @@ export function createBunderstack<
|
|
|
231
266
|
/** Prebuilt job definitions (escape hatch for multi-file setups). */
|
|
232
267
|
jobs?: TJobsDefs
|
|
233
268
|
},
|
|
234
|
-
): Promise<
|
|
269
|
+
): Promise<
|
|
270
|
+
BunderstackApp<
|
|
271
|
+
TSchema,
|
|
272
|
+
TAccess,
|
|
273
|
+
BucketNamesOf<TStorage>,
|
|
274
|
+
TEnv,
|
|
275
|
+
TRouter,
|
|
276
|
+
TJobsDefs
|
|
277
|
+
>
|
|
278
|
+
>
|
|
235
279
|
export async function createBunderstack<
|
|
236
280
|
TSchema extends Record<string, unknown>,
|
|
237
281
|
const TAccess extends Record<string, TableAccessInput> | undefined =
|
|
@@ -275,311 +319,329 @@ export async function createBunderstack<
|
|
|
275
319
|
cronConfigured: true,
|
|
276
320
|
})
|
|
277
321
|
const config = resolveConfig(options, env)
|
|
278
|
-
//
|
|
279
|
-
//
|
|
280
|
-
// the outside world — force an in-memory db (':memory:' is valid for both
|
|
281
|
-
// 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.
|
|
282
324
|
const introspect = process.env.BUNDERSTACK_INTROSPECT === '1'
|
|
283
|
-
if (introspect) {
|
|
284
|
-
config.database.url = ':memory:'
|
|
285
|
-
config.database.authToken = undefined
|
|
286
|
-
}
|
|
287
325
|
const email = createEmail(options.email, { env })
|
|
288
326
|
// Merge bunderstack's internal tables (file-meta, idempotency) into the
|
|
289
327
|
// schema used for the db client + provisioning. CRUD/access stay on the USER
|
|
290
328
|
// schema so internal tables never get a CRUD route.
|
|
291
329
|
const mergedSchema = withInternalTables(options.schema)
|
|
292
|
-
const
|
|
330
|
+
const lifecycle = new Lifecycle()
|
|
331
|
+
const {
|
|
332
|
+
db,
|
|
333
|
+
driver,
|
|
334
|
+
close: closeDatabase,
|
|
335
|
+
} = await createDb(mergedSchema, {
|
|
293
336
|
...config.database,
|
|
294
337
|
dialect,
|
|
338
|
+
introspect,
|
|
295
339
|
})
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
db
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
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
|
+
})
|
|
320
402
|
: undefined
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
subClient.subscribe(channel, listener),
|
|
336
|
-
lpush: (key: string, value: string) =>
|
|
337
|
-
cmdClient.lpush(key, value),
|
|
338
|
-
ltrim: (key: string, start: number, stop: number) =>
|
|
339
|
-
cmdClient.ltrim(key, start, stop),
|
|
340
|
-
lrange: (key: string, start: number, stop: number) =>
|
|
341
|
-
cmdClient.lrange(key, start, stop),
|
|
342
|
-
close: () => {
|
|
343
|
-
cmdClient.close()
|
|
344
|
-
subClient.close()
|
|
345
|
-
},
|
|
346
|
-
}
|
|
347
|
-
},
|
|
348
|
-
bufferSize: realtimeBufferSize,
|
|
349
|
-
})
|
|
350
|
-
: createRealtimeBroker({
|
|
351
|
-
access: resolvedAccess,
|
|
352
|
-
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,
|
|
353
417
|
})
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
const realtimeRouter = broker
|
|
362
|
-
? buildRealtimeRouter(broker, {
|
|
363
|
-
auth: authResolver,
|
|
364
|
-
keepaliveMs:
|
|
365
|
-
typeof config.realtime === 'object'
|
|
366
|
-
? config.realtime.keepaliveMs
|
|
367
|
-
: undefined,
|
|
368
|
-
})
|
|
369
|
-
: undefined
|
|
370
|
-
const registry = createBucketStorages(config.storage)
|
|
371
|
-
const lifecycle = new Lifecycle()
|
|
372
|
-
if (broker) lifecycle.add(() => broker.close())
|
|
373
|
-
const storageRouter = buildBucketStorageRouter({
|
|
374
|
-
registry,
|
|
375
|
-
db,
|
|
376
|
-
auth: authResolver,
|
|
377
|
-
})
|
|
378
|
-
const storage: StorageFacade = {
|
|
379
|
-
async delete(fileId) {
|
|
380
|
-
const bucketName = fileId.split('/')[0] ?? ''
|
|
381
|
-
const entry = registry.get(bucketName)
|
|
382
|
-
if (entry) {
|
|
383
|
-
await deleteFileWithDerivatives(entry.adapter, db, fileId)
|
|
384
|
-
} else {
|
|
385
|
-
// Unknown bucket: no adapter to clean, but still drop the meta row.
|
|
386
|
-
await deleteFileMetaRow(db, fileId)
|
|
387
|
-
}
|
|
388
|
-
},
|
|
389
|
-
bucket(name) {
|
|
390
|
-
return registry.get(name)?.adapter
|
|
391
|
-
},
|
|
392
|
-
sweep(olderThanMs = DEFAULT_PENDING_TTL_MS) {
|
|
393
|
-
return sweepOrphans(registry, db, olderThanMs)
|
|
394
|
-
},
|
|
395
|
-
}
|
|
396
|
-
const jobRunner = jobsDefs
|
|
397
|
-
? createJobRunner({
|
|
398
|
-
db,
|
|
399
|
-
defs: jobsDefs,
|
|
400
|
-
ctx: { db: userDb, env, email, storage },
|
|
401
|
-
})
|
|
402
|
-
: undefined
|
|
403
|
-
const jobs = {
|
|
404
|
-
async enqueue(name: string, input?: unknown, opts?: EnqueueOptions) {
|
|
405
|
-
if (!jobsDefs) {
|
|
406
|
-
throw new Error(
|
|
407
|
-
'[bunderstack] no jobs configured — add a `jobs` key to createBunderstack',
|
|
408
|
-
)
|
|
409
|
-
}
|
|
410
|
-
const result = await enqueueJob(db, jobsDefs, name, input, opts)
|
|
411
|
-
return result
|
|
412
|
-
},
|
|
413
|
-
tick(now?: number) {
|
|
414
|
-
return jobRunner ? jobRunner.tick(now) : Promise.resolve()
|
|
415
|
-
},
|
|
416
|
-
}
|
|
417
|
-
if (jobRunner) jobRunner.setJobsFacade(jobs)
|
|
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),
|
|
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,
|
|
434
425
|
})
|
|
435
|
-
const
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
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
|
+
},
|
|
452
443
|
}
|
|
453
|
-
const
|
|
454
|
-
|
|
455
|
-
onError: options.onError,
|
|
456
|
-
runSlot: async (name, slot) => {
|
|
457
|
-
await runCronSlot({
|
|
444
|
+
const jobRunner = jobsDefs
|
|
445
|
+
? createJobRunner({
|
|
458
446
|
db,
|
|
459
|
-
defs: jobsDefs
|
|
460
|
-
ctx: { db: userDb, env, email, storage },
|
|
461
|
-
name,
|
|
462
|
-
slot,
|
|
463
|
-
now: Date.now(),
|
|
447
|
+
defs: jobsDefs,
|
|
448
|
+
ctx: { db: userDb, env, email, storage, realtime },
|
|
464
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()
|
|
465
463
|
},
|
|
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
464
|
}
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
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
|
+
}
|
|
482
475
|
const signal = options.signal
|
|
483
476
|
? AbortSignal.any([lifecycle.signal, options.signal])
|
|
484
477
|
: lifecycle.signal
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
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
|
|
489
486
|
}
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
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',
|
|
507
548
|
req,
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
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
|
|
513
562
|
? buildCronRouter({
|
|
514
563
|
db,
|
|
515
564
|
defs: jobsDefs ?? {},
|
|
516
|
-
ctx: { db: userDb, env, email, storage },
|
|
565
|
+
ctx: { db: userDb, env, email, storage, realtime },
|
|
517
566
|
secret: env.BUNDERSTACK_CRON_SECRET,
|
|
518
567
|
storage,
|
|
519
568
|
})
|
|
520
569
|
: undefined
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
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
|
+
})
|
|
530
579
|
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
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
|
-
|
|
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,
|
|
562
628
|
dialect,
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
jobs: jobsDefs,
|
|
567
|
-
}),
|
|
568
|
-
}
|
|
629
|
+
driver,
|
|
630
|
+
adapter: config.database.adapter,
|
|
631
|
+
}
|
|
569
632
|
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
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
|
|
580
644
|
}
|
|
581
|
-
|
|
582
|
-
return app
|
|
583
645
|
}
|
|
584
646
|
|
|
585
647
|
export { MAX_LIST_LIMIT } from './list-query'
|
|
@@ -646,6 +708,12 @@ export {
|
|
|
646
708
|
asTypeId,
|
|
647
709
|
} from './typeid'
|
|
648
710
|
export type { TypeId } from './typeid'
|
|
711
|
+
export type {
|
|
712
|
+
DatabaseAdapter,
|
|
713
|
+
DatabaseConnectOptions,
|
|
714
|
+
DatabaseConnection,
|
|
715
|
+
DatabaseConnectionResult,
|
|
716
|
+
} from './database/adapter'
|
|
649
717
|
export type { StorageAdapter } from './storage/index'
|
|
650
718
|
export type {
|
|
651
719
|
StorageConfigInput,
|
|
@@ -654,3 +722,6 @@ export type {
|
|
|
654
722
|
} from './storage/buckets'
|
|
655
723
|
// StorageFacade is declared+exported inline above.
|
|
656
724
|
export type { TransformSpec } from './storage/thumbnails'
|
|
725
|
+
|
|
726
|
+
export type { RealtimeAction } from './realtime/index'
|
|
727
|
+
export type { RealtimeFacade, SchemaTable } from './realtime/facade'
|