bunderstack 0.7.0 → 0.9.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 +45 -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 +405 -300
- package/src/lifecycle.ts +7 -2
- package/src/manifest.ts +4 -0
- package/src/provision-internals.ts +2 -1
- package/src/provision.ts +7 -18
- package/src/realtime/facade.ts +16 -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,28 +44,25 @@ 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 {
|
|
54
|
+
createRealtimeFacade,
|
|
55
|
+
type RealtimeFacade,
|
|
56
|
+
type RealtimeTransport,
|
|
57
|
+
} from './realtime/facade'
|
|
50
58
|
import { createRealtimeBroker, buildRealtimeRouter } from './realtime/index'
|
|
51
59
|
import { createRedisRealtimeBroker } from './realtime/redis'
|
|
52
|
-
import { createRealtimeFacade, type RealtimeFacade } from './realtime/facade'
|
|
53
60
|
import { deleteFileWithDerivatives } from './storage/delete'
|
|
54
61
|
import { deleteFileMetaRow } from './storage/file-meta'
|
|
55
62
|
import { createBucketStorages } from './storage/registry'
|
|
56
63
|
import { buildBucketStorageRouter } from './storage/router'
|
|
57
64
|
import { sweepOrphans } from './storage/sweep'
|
|
65
|
+
import { createTRPC, type BunderstackTRPC } from './trpc'
|
|
58
66
|
|
|
59
67
|
type AuthInstance = ReturnType<typeof createAuth>
|
|
60
68
|
|
|
@@ -89,9 +97,9 @@ const DEFAULT_PENDING_TTL_MS = 30 * 60_000
|
|
|
89
97
|
* must also clean the file-meta row.
|
|
90
98
|
*/
|
|
91
99
|
export interface StorageFacade {
|
|
92
|
-
/** Delete
|
|
100
|
+
/** Delete a file row and purge all underlying storage derivatives. */
|
|
93
101
|
delete(fileId: string): Promise<void>
|
|
94
|
-
/**
|
|
102
|
+
/** Low-level access to the underlying storage adapter for a bucket. */
|
|
95
103
|
bucket(name: string): StorageAdapter | undefined
|
|
96
104
|
/**
|
|
97
105
|
* Reap stale `pending` uploads older than `olderThanMs` (default 30m). Runs
|
|
@@ -102,7 +110,15 @@ export interface StorageFacade {
|
|
|
102
110
|
}
|
|
103
111
|
|
|
104
112
|
export type AppStartWorkerOptions = Omit<StartWorkerOptions, 'tick'>
|
|
105
|
-
export type AppRunWorkerOptions = AppStartWorkerOptions
|
|
113
|
+
export type AppRunWorkerOptions = AppStartWorkerOptions & {
|
|
114
|
+
/**
|
|
115
|
+
* Permit process-local realtime in a standalone worker.
|
|
116
|
+
*
|
|
117
|
+
* Use only when job handlers never call ctx.realtime.publish(). Publications
|
|
118
|
+
* made through the memory broker cannot reach SSE clients in another process.
|
|
119
|
+
*/
|
|
120
|
+
allowProcessLocalRealtime?: boolean
|
|
121
|
+
}
|
|
106
122
|
export type AppStartCronSchedulerOptions = Pick<
|
|
107
123
|
LocalCronSchedulerOptions,
|
|
108
124
|
'onError'
|
|
@@ -115,7 +131,6 @@ export type BucketNamesOf<TStorage> = TStorage extends {
|
|
|
115
131
|
? keyof B & string
|
|
116
132
|
: string
|
|
117
133
|
|
|
118
|
-
|
|
119
134
|
export type BunderstackApp<
|
|
120
135
|
TSchema extends Record<string, unknown>,
|
|
121
136
|
TAccess extends Record<string, TableAccessInput> | undefined = undefined,
|
|
@@ -136,7 +151,9 @@ export type BunderstackApp<
|
|
|
136
151
|
/** Email facade; always present — send() throws when email isn't configured. */
|
|
137
152
|
email: EmailFacade
|
|
138
153
|
/** Job queue facade; always present — enqueue throws when jobs aren't configured. */
|
|
139
|
-
jobs: JobsFacade<
|
|
154
|
+
jobs: JobsFacade<
|
|
155
|
+
TJobsDefs extends JobsDefs ? TJobsDefs : Record<never, never>
|
|
156
|
+
>
|
|
140
157
|
/** Typed custom row publication; enabled=false/no-op when realtime is off. */
|
|
141
158
|
realtime: RealtimeFacade<TSchema>
|
|
142
159
|
startWorker(options?: AppStartWorkerOptions): Promise<WorkerHandle>
|
|
@@ -186,7 +203,16 @@ export function createBunderstack<
|
|
|
186
203
|
/** Builder callback receiving the pre-wired `j` instance. */
|
|
187
204
|
jobs: (j: BunderstackJobsBuilder<TSchema, ValidatedEnv<TEnv>>) => TJobsDefs
|
|
188
205
|
},
|
|
189
|
-
): Promise<
|
|
206
|
+
): Promise<
|
|
207
|
+
BunderstackApp<
|
|
208
|
+
TSchema,
|
|
209
|
+
TAccess,
|
|
210
|
+
BucketNamesOf<TStorage>,
|
|
211
|
+
TEnv,
|
|
212
|
+
TRouter,
|
|
213
|
+
TJobsDefs
|
|
214
|
+
>
|
|
215
|
+
>
|
|
190
216
|
export function createBunderstack<
|
|
191
217
|
TSchema extends Record<string, unknown>,
|
|
192
218
|
const TAccess extends Record<string, TableAccessInput> | undefined =
|
|
@@ -202,7 +228,16 @@ export function createBunderstack<
|
|
|
202
228
|
/** Prebuilt job definitions (escape hatch for multi-file setups). */
|
|
203
229
|
jobs?: TJobsDefs
|
|
204
230
|
},
|
|
205
|
-
): Promise<
|
|
231
|
+
): Promise<
|
|
232
|
+
BunderstackApp<
|
|
233
|
+
TSchema,
|
|
234
|
+
TAccess,
|
|
235
|
+
BucketNamesOf<TStorage>,
|
|
236
|
+
TEnv,
|
|
237
|
+
TRouter,
|
|
238
|
+
TJobsDefs
|
|
239
|
+
>
|
|
240
|
+
>
|
|
206
241
|
export function createBunderstack<
|
|
207
242
|
TSchema extends Record<string, unknown>,
|
|
208
243
|
const TAccess extends Record<string, TableAccessInput> | undefined =
|
|
@@ -218,7 +253,16 @@ export function createBunderstack<
|
|
|
218
253
|
/** Builder callback receiving the pre-wired `j` instance. */
|
|
219
254
|
jobs: (j: BunderstackJobsBuilder<TSchema, ValidatedEnv<TEnv>>) => TJobsDefs
|
|
220
255
|
},
|
|
221
|
-
): Promise<
|
|
256
|
+
): Promise<
|
|
257
|
+
BunderstackApp<
|
|
258
|
+
TSchema,
|
|
259
|
+
TAccess,
|
|
260
|
+
BucketNamesOf<TStorage>,
|
|
261
|
+
TEnv,
|
|
262
|
+
TRouter,
|
|
263
|
+
TJobsDefs
|
|
264
|
+
>
|
|
265
|
+
>
|
|
222
266
|
export function createBunderstack<
|
|
223
267
|
TSchema extends Record<string, unknown>,
|
|
224
268
|
const TAccess extends Record<string, TableAccessInput> | undefined =
|
|
@@ -234,7 +278,16 @@ export function createBunderstack<
|
|
|
234
278
|
/** Prebuilt job definitions (escape hatch for multi-file setups). */
|
|
235
279
|
jobs?: TJobsDefs
|
|
236
280
|
},
|
|
237
|
-
): Promise<
|
|
281
|
+
): Promise<
|
|
282
|
+
BunderstackApp<
|
|
283
|
+
TSchema,
|
|
284
|
+
TAccess,
|
|
285
|
+
BucketNamesOf<TStorage>,
|
|
286
|
+
TEnv,
|
|
287
|
+
TRouter,
|
|
288
|
+
TJobsDefs
|
|
289
|
+
>
|
|
290
|
+
>
|
|
238
291
|
export async function createBunderstack<
|
|
239
292
|
TSchema extends Record<string, unknown>,
|
|
240
293
|
const TAccess extends Record<string, TableAccessInput> | undefined =
|
|
@@ -278,243 +331,271 @@ export async function createBunderstack<
|
|
|
278
331
|
cronConfigured: true,
|
|
279
332
|
})
|
|
280
333
|
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).
|
|
334
|
+
// Adapters use Drizzle mocks during deployment introspection, so the database
|
|
335
|
+
// and Redis below never touch external services.
|
|
285
336
|
const introspect = process.env.BUNDERSTACK_INTROSPECT === '1'
|
|
286
|
-
if (introspect) {
|
|
287
|
-
config.database.url = ':memory:'
|
|
288
|
-
config.database.authToken = undefined
|
|
289
|
-
}
|
|
290
337
|
const email = createEmail(options.email, { env })
|
|
291
338
|
// Merge bunderstack's internal tables (file-meta, idempotency) into the
|
|
292
339
|
// schema used for the db client + provisioning. CRUD/access stay on the USER
|
|
293
340
|
// schema so internal tables never get a CRUD route.
|
|
294
341
|
const mergedSchema = withInternalTables(options.schema)
|
|
295
|
-
const
|
|
342
|
+
const lifecycle = new Lifecycle()
|
|
343
|
+
const {
|
|
344
|
+
db,
|
|
345
|
+
driver,
|
|
346
|
+
close: closeDatabase,
|
|
347
|
+
} = await createDb(mergedSchema, {
|
|
296
348
|
...config.database,
|
|
297
349
|
dialect,
|
|
350
|
+
introspect,
|
|
298
351
|
})
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
db
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
352
|
+
if (closeDatabase) lifecycle.add(closeDatabase)
|
|
353
|
+
try {
|
|
354
|
+
// `db` is typed with the merged schema (user tables + internal tables) so the
|
|
355
|
+
// storage/idempotency code can query the internal tables. The public surface
|
|
356
|
+
// and CRUD only expose the USER schema. TS can widen the merged-schema db type
|
|
357
|
+
// on its own (storage/auth pass `db` directly), but it can't *narrow* a
|
|
358
|
+
// generic schema view, so this single intentional cast produces the
|
|
359
|
+
// user-facing, per-dialect db type. See `app.db` / crud below.
|
|
360
|
+
const userDb = db as unknown as DbFor<TSchema>
|
|
361
|
+
const auth = createAuth(
|
|
362
|
+
db,
|
|
363
|
+
withEmailAuthDefaults(config.auth, email, Boolean(options.email)),
|
|
364
|
+
dialect,
|
|
365
|
+
)
|
|
366
|
+
// Internal routers consume the narrow AuthSessionResolver contract, not the
|
|
367
|
+
// raw better-auth instance. app.auth still exposes `auth` unchanged.
|
|
368
|
+
const authResolver = toAuthSessionResolver(auth)
|
|
369
|
+
const resolvedAccess = validateAndResolveAccess(
|
|
370
|
+
options.schema,
|
|
371
|
+
options.access,
|
|
372
|
+
)
|
|
373
|
+
const realtimeBufferSize =
|
|
374
|
+
typeof config.realtime === 'object'
|
|
375
|
+
? config.realtime.bufferSize
|
|
376
|
+
: undefined
|
|
377
|
+
const configuredRedisUrl = config.realtime
|
|
322
378
|
? resolveRealtimeRedisUrl(config.realtime, env)
|
|
323
379
|
: undefined
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
cmdClient.
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
380
|
+
const configuredRealtimeTransport: RealtimeTransport = !config.realtime
|
|
381
|
+
? 'disabled'
|
|
382
|
+
: configuredRedisUrl
|
|
383
|
+
? 'redis'
|
|
384
|
+
: 'memory'
|
|
385
|
+
const redisUrl = introspect ? undefined : configuredRedisUrl
|
|
386
|
+
const broker = config.realtime
|
|
387
|
+
? redisUrl
|
|
388
|
+
? createRedisRealtimeBroker({
|
|
389
|
+
access: resolvedAccess,
|
|
390
|
+
redis: () => {
|
|
391
|
+
// Redis pub/sub requires a dedicated connection (subscribe puts the client into
|
|
392
|
+
// a restricted state). We use one client for commands and a second for subscribe.
|
|
393
|
+
const cmdClient = new Bun.RedisClient(redisUrl)
|
|
394
|
+
const subClient = new Bun.RedisClient(redisUrl)
|
|
395
|
+
return {
|
|
396
|
+
incr: (key: string) => cmdClient.incr(key),
|
|
397
|
+
publish: (channel: string, message: string) =>
|
|
398
|
+
cmdClient.publish(channel, message),
|
|
399
|
+
subscribe: (channel: string, listener: (msg: string) => void) =>
|
|
400
|
+
subClient.subscribe(channel, listener),
|
|
401
|
+
lpush: (key: string, value: string) =>
|
|
402
|
+
cmdClient.lpush(key, value),
|
|
403
|
+
ltrim: (key: string, start: number, stop: number) =>
|
|
404
|
+
cmdClient.ltrim(key, start, stop),
|
|
405
|
+
lrange: (key: string, start: number, stop: number) =>
|
|
406
|
+
cmdClient.lrange(key, start, stop),
|
|
407
|
+
close: () => {
|
|
408
|
+
cmdClient.close()
|
|
409
|
+
subClient.close()
|
|
410
|
+
},
|
|
411
|
+
}
|
|
412
|
+
},
|
|
413
|
+
bufferSize: realtimeBufferSize,
|
|
414
|
+
})
|
|
415
|
+
: createRealtimeBroker({
|
|
416
|
+
access: resolvedAccess,
|
|
417
|
+
bufferSize: realtimeBufferSize,
|
|
418
|
+
})
|
|
419
|
+
: undefined
|
|
420
|
+
const runtimeRealtimeTransport: RealtimeTransport = !broker
|
|
421
|
+
? 'disabled'
|
|
422
|
+
: redisUrl
|
|
423
|
+
? 'redis'
|
|
424
|
+
: 'memory'
|
|
425
|
+
const realtime = createRealtimeFacade<TSchema>(
|
|
426
|
+
broker,
|
|
427
|
+
runtimeRealtimeTransport,
|
|
428
|
+
)
|
|
429
|
+
const crudRouter = buildCrudRouter(options.schema, userDb, {
|
|
430
|
+
auth: authResolver,
|
|
431
|
+
access: resolvedAccess,
|
|
432
|
+
idempotency: options.idempotency,
|
|
433
|
+
realtime,
|
|
434
|
+
})
|
|
435
|
+
const realtimeRouter = broker
|
|
436
|
+
? buildRealtimeRouter(broker, {
|
|
437
|
+
auth: authResolver,
|
|
438
|
+
keepaliveMs:
|
|
439
|
+
typeof config.realtime === 'object'
|
|
440
|
+
? config.realtime.keepaliveMs
|
|
441
|
+
: undefined,
|
|
356
442
|
})
|
|
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),
|
|
443
|
+
: undefined
|
|
444
|
+
const registry = createBucketStorages(config.storage)
|
|
445
|
+
if (broker) lifecycle.add(() => broker.close())
|
|
446
|
+
const storageRouter = buildBucketStorageRouter({
|
|
447
|
+
registry,
|
|
448
|
+
db,
|
|
449
|
+
auth: authResolver,
|
|
438
450
|
})
|
|
439
|
-
const
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
451
|
+
const storage: StorageFacade = {
|
|
452
|
+
async delete(fileId) {
|
|
453
|
+
const bucketName = fileId.split('/')[0] ?? ''
|
|
454
|
+
const entry = registry.get(bucketName)
|
|
455
|
+
if (entry) {
|
|
456
|
+
await deleteFileWithDerivatives(entry.adapter, db, fileId)
|
|
457
|
+
} else {
|
|
458
|
+
// Unknown bucket: no adapter to clean, but still drop the meta row.
|
|
459
|
+
await deleteFileMetaRow(db, fileId)
|
|
460
|
+
}
|
|
461
|
+
},
|
|
462
|
+
bucket(name) {
|
|
463
|
+
return registry.get(name)?.adapter
|
|
464
|
+
},
|
|
465
|
+
sweep(olderThanMs = DEFAULT_PENDING_TTL_MS) {
|
|
466
|
+
return sweepOrphans(registry, db, olderThanMs)
|
|
467
|
+
},
|
|
456
468
|
}
|
|
457
|
-
const
|
|
458
|
-
|
|
459
|
-
onError: options.onError,
|
|
460
|
-
runSlot: async (name, slot) => {
|
|
461
|
-
await runCronSlot({
|
|
469
|
+
const jobRunner = jobsDefs
|
|
470
|
+
? createJobRunner({
|
|
462
471
|
db,
|
|
463
|
-
defs: jobsDefs
|
|
472
|
+
defs: jobsDefs,
|
|
464
473
|
ctx: { db: userDb, env, email, storage, realtime },
|
|
465
|
-
name,
|
|
466
|
-
slot,
|
|
467
|
-
now: Date.now(),
|
|
468
474
|
})
|
|
475
|
+
: undefined
|
|
476
|
+
const jobs = {
|
|
477
|
+
async enqueue(name: string, input?: unknown, opts?: EnqueueOptions) {
|
|
478
|
+
if (!jobsDefs) {
|
|
479
|
+
throw new Error(
|
|
480
|
+
'[bunderstack] no jobs configured — add a `jobs` key to createBunderstack',
|
|
481
|
+
)
|
|
482
|
+
}
|
|
483
|
+
const result = await enqueueJob(db, jobsDefs, name, input, opts)
|
|
484
|
+
return result
|
|
485
|
+
},
|
|
486
|
+
tick(now?: number) {
|
|
487
|
+
return jobRunner ? jobRunner.tick(now) : Promise.resolve()
|
|
469
488
|
},
|
|
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
489
|
}
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
490
|
+
if (jobRunner) jobRunner.setJobsFacade(jobs)
|
|
491
|
+
const startWorker = async (
|
|
492
|
+
options: AppStartWorkerOptions = {},
|
|
493
|
+
): Promise<WorkerHandle> => {
|
|
494
|
+
if (!jobRunner) {
|
|
495
|
+
throw new Error('[bunderstack] no queue jobs configured')
|
|
496
|
+
}
|
|
497
|
+
if (lifecycle.status !== 'ready') {
|
|
498
|
+
throw new Error('[bunderstack] application lifecycle is closed')
|
|
499
|
+
}
|
|
486
500
|
const signal = options.signal
|
|
487
501
|
? AbortSignal.any([lifecycle.signal, options.signal])
|
|
488
502
|
: lifecycle.signal
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
503
|
+
const handle = startJobWorker({
|
|
504
|
+
...options,
|
|
505
|
+
signal,
|
|
506
|
+
tick: (now) => jobRunner.tick(now),
|
|
507
|
+
})
|
|
508
|
+
const unregister = lifecycle.add(() => handle.close())
|
|
509
|
+
void handle.closed.finally(unregister)
|
|
510
|
+
return handle
|
|
493
511
|
}
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
+
const startCronScheduler = async (
|
|
513
|
+
options: AppStartCronSchedulerOptions = {},
|
|
514
|
+
): Promise<LocalCronScheduler> => {
|
|
515
|
+
const cron = Object.entries(jobsDefs ?? {}).flatMap(
|
|
516
|
+
([name, definition]) =>
|
|
517
|
+
definition.kind === 'cron'
|
|
518
|
+
? [{ name, schedule: definition.schedule }]
|
|
519
|
+
: [],
|
|
520
|
+
)
|
|
521
|
+
if (cron.length === 0) {
|
|
522
|
+
throw new Error('[bunderstack] no cron tasks configured')
|
|
523
|
+
}
|
|
524
|
+
if (lifecycle.status !== 'ready') {
|
|
525
|
+
throw new Error('[bunderstack] application lifecycle is closed')
|
|
526
|
+
}
|
|
527
|
+
const scheduler = startLocalCronScheduler({
|
|
528
|
+
cron,
|
|
529
|
+
onError: options.onError,
|
|
530
|
+
runSlot: async (name, slot) => {
|
|
531
|
+
await runCronSlot({
|
|
532
|
+
db,
|
|
533
|
+
defs: jobsDefs!,
|
|
534
|
+
ctx: { db: userDb, env, email, storage, realtime },
|
|
535
|
+
name,
|
|
536
|
+
slot,
|
|
537
|
+
now: Date.now(),
|
|
538
|
+
})
|
|
539
|
+
},
|
|
540
|
+
})
|
|
541
|
+
const unregister = lifecycle.add(() => scheduler.close())
|
|
542
|
+
try {
|
|
543
|
+
await scheduler.tick()
|
|
544
|
+
} catch (error) {
|
|
545
|
+
unregister()
|
|
546
|
+
await scheduler.close()
|
|
547
|
+
throw error
|
|
548
|
+
}
|
|
549
|
+
return scheduler
|
|
550
|
+
}
|
|
551
|
+
const runWorker = async (
|
|
552
|
+
options: AppRunWorkerOptions = {},
|
|
553
|
+
): Promise<void> => {
|
|
554
|
+
if (
|
|
555
|
+
realtime.transport === 'memory' &&
|
|
556
|
+
!options.allowProcessLocalRealtime
|
|
557
|
+
) {
|
|
558
|
+
throw new Error(
|
|
559
|
+
'[bunderstack] runWorker() cannot deliver realtime events through the in-memory broker. Configure REDIS_URL or realtime.redis, embed the worker with startWorker(), or pass allowProcessLocalRealtime: true only when jobs never publish realtime.',
|
|
560
|
+
)
|
|
561
|
+
}
|
|
562
|
+
const {
|
|
563
|
+
allowProcessLocalRealtime: _allowProcessLocalRealtime,
|
|
564
|
+
...workerOptions
|
|
565
|
+
} = options
|
|
566
|
+
const handle = await startWorker(workerOptions)
|
|
567
|
+
try {
|
|
568
|
+
const signal = workerOptions.signal
|
|
569
|
+
? AbortSignal.any([lifecycle.signal, workerOptions.signal])
|
|
570
|
+
: lifecycle.signal
|
|
571
|
+
await waitForWorkerShutdown(signal, !workerOptions.signal)
|
|
572
|
+
} finally {
|
|
573
|
+
await handle.close()
|
|
574
|
+
await lifecycle.close()
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
const trpcRouter: AnyRouter | undefined =
|
|
578
|
+
typeof options.trpc === 'function'
|
|
579
|
+
? options.trpc(createTRPC<TSchema, ValidatedEnv<TEnv>>())
|
|
580
|
+
: options.trpc
|
|
581
|
+
const trpcHandler = trpcRouter
|
|
582
|
+
? (req: Request) =>
|
|
583
|
+
fetchRequestHandler({
|
|
584
|
+
endpoint: '/api/trpc',
|
|
512
585
|
req,
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
586
|
+
router: trpcRouter,
|
|
587
|
+
createContext: async () => ({
|
|
588
|
+
db: userDb,
|
|
589
|
+
user: await resolveAccessUser(authResolver, req.headers),
|
|
590
|
+
env,
|
|
591
|
+
email,
|
|
592
|
+
jobs,
|
|
593
|
+
realtime,
|
|
594
|
+
req,
|
|
595
|
+
}),
|
|
596
|
+
})
|
|
597
|
+
: undefined
|
|
598
|
+
const cronRouter = env.BUNDERSTACK_CRON_SECRET
|
|
518
599
|
? buildCronRouter({
|
|
519
600
|
db,
|
|
520
601
|
defs: jobsDefs ?? {},
|
|
@@ -523,69 +604,82 @@ export async function createBunderstack<
|
|
|
523
604
|
storage,
|
|
524
605
|
})
|
|
525
606
|
: undefined
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
607
|
+
const { handler, router } = buildHandler({
|
|
608
|
+
crudRouter,
|
|
609
|
+
authHandler: (req) => auth.handler(req),
|
|
610
|
+
storageRouter,
|
|
611
|
+
realtimeRouter,
|
|
612
|
+
trpcHandler,
|
|
613
|
+
cronRouter,
|
|
614
|
+
rateLimit: options.rateLimit,
|
|
615
|
+
})
|
|
535
616
|
|
|
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
|
-
|
|
617
|
+
const app: BunderstackApp<
|
|
618
|
+
TSchema,
|
|
619
|
+
TAccess,
|
|
620
|
+
BucketNamesOf<TStorage>,
|
|
621
|
+
TEnv,
|
|
622
|
+
AnyRouter | undefined,
|
|
623
|
+
JobsDefs | undefined
|
|
624
|
+
> = {
|
|
625
|
+
handler,
|
|
626
|
+
// Internal tables live on the runtime db but stay out of the public type.
|
|
627
|
+
db: userDb,
|
|
628
|
+
auth,
|
|
629
|
+
storage,
|
|
630
|
+
router,
|
|
631
|
+
env,
|
|
632
|
+
email,
|
|
633
|
+
realtime,
|
|
634
|
+
// Runtime facade is untyped (JobsRuntimeFacade); the generic-typed field
|
|
635
|
+
// narrows `enqueue` per-app from the declared job defs — same relationship
|
|
636
|
+
// as `userDb` above.
|
|
637
|
+
jobs: jobs as never,
|
|
638
|
+
startWorker,
|
|
639
|
+
runWorker,
|
|
640
|
+
startCronScheduler,
|
|
641
|
+
close: () => lifecycle.close(),
|
|
642
|
+
get status() {
|
|
643
|
+
return lifecycle.status
|
|
644
|
+
},
|
|
645
|
+
signal: lifecycle.signal,
|
|
646
|
+
trpcRouter,
|
|
647
|
+
manifest: buildManifest({
|
|
648
|
+
schema: options.schema,
|
|
649
|
+
dialect,
|
|
650
|
+
storage: config.storage,
|
|
651
|
+
envConfig: options.env as EnvConfigInput | undefined,
|
|
652
|
+
realtime: Boolean(config.realtime),
|
|
653
|
+
realtimeTransport: configuredRealtimeTransport,
|
|
654
|
+
jobs: jobsDefs,
|
|
655
|
+
}),
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
// Hidden handle for the optional `bunderstack/provision` entry. Kept off the
|
|
659
|
+
// public type so provisioning stays opt-in (and drizzle-kit out of this
|
|
660
|
+
// module graph).
|
|
661
|
+
;(app as WithProvisionInternals)[PROVISION_INTERNALS] = {
|
|
662
|
+
db,
|
|
663
|
+
schema: mergedSchema,
|
|
664
|
+
databaseUrl: config.database.url,
|
|
665
|
+
migrationsFolder: config.database.migrations,
|
|
568
666
|
dialect,
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
jobs: jobsDefs,
|
|
573
|
-
}),
|
|
574
|
-
}
|
|
667
|
+
driver,
|
|
668
|
+
adapter: config.database.adapter,
|
|
669
|
+
}
|
|
575
670
|
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
671
|
+
return app
|
|
672
|
+
} catch (cause) {
|
|
673
|
+
try {
|
|
674
|
+
await lifecycle.close()
|
|
675
|
+
} catch (cleanupCause) {
|
|
676
|
+
throw new AggregateError(
|
|
677
|
+
[cause, cleanupCause],
|
|
678
|
+
'[bunderstack] application initialization failed and cleanup failed',
|
|
679
|
+
)
|
|
680
|
+
}
|
|
681
|
+
throw cause
|
|
586
682
|
}
|
|
587
|
-
|
|
588
|
-
return app
|
|
589
683
|
}
|
|
590
684
|
|
|
591
685
|
export { MAX_LIST_LIMIT } from './list-query'
|
|
@@ -652,6 +746,12 @@ export {
|
|
|
652
746
|
asTypeId,
|
|
653
747
|
} from './typeid'
|
|
654
748
|
export type { TypeId } from './typeid'
|
|
749
|
+
export type {
|
|
750
|
+
DatabaseAdapter,
|
|
751
|
+
DatabaseConnectOptions,
|
|
752
|
+
DatabaseConnection,
|
|
753
|
+
DatabaseConnectionResult,
|
|
754
|
+
} from './database/adapter'
|
|
655
755
|
export type { StorageAdapter } from './storage/index'
|
|
656
756
|
export type {
|
|
657
757
|
StorageConfigInput,
|
|
@@ -662,4 +762,9 @@ export type {
|
|
|
662
762
|
export type { TransformSpec } from './storage/thumbnails'
|
|
663
763
|
|
|
664
764
|
export type { RealtimeAction } from './realtime/index'
|
|
665
|
-
export
|
|
765
|
+
export { createRealtimeFacade } from './realtime/facade'
|
|
766
|
+
export type {
|
|
767
|
+
RealtimeFacade,
|
|
768
|
+
RealtimeTransport,
|
|
769
|
+
SchemaTable,
|
|
770
|
+
} from './realtime/facade'
|