bunderstack 0.1.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/src/index.ts ADDED
@@ -0,0 +1,368 @@
1
+ // src/index.ts
2
+ import type { AnyRouter } from '@trpc/server'
3
+ import type { LibSQLDatabase } from 'drizzle-orm/libsql'
4
+ import type { Hono as HonoType } from 'hono'
5
+
6
+ import { fetchRequestHandler } from '@trpc/server/adapters/fetch'
7
+
8
+ import type { StorageAdapter } from './storage/index'
9
+ import type { TableAccessInput } from './access'
10
+ import type { StorageConfigInput } from './storage/buckets'
11
+
12
+ import { resolveAccessUser, validateAndResolveAccess } from './access'
13
+ import { createAuth, toAuthSessionResolver, withEmailAuthDefaults } from './auth'
14
+ import { resolveConfig, type BunderstackConfig } from './config'
15
+ import { resolveRealtimeRedisUrl } from './config'
16
+ import { createEmail, emailProviderTag, type EmailFacade } from './email'
17
+ import { validateEnv, type EnvConfigInput, type ValidatedEnv } from './env'
18
+ import { createTRPC, type BunderstackTRPC } from './trpc'
19
+ import { buildCrudRouter } from './crud'
20
+ import { createDb } from './db'
21
+ import { buildHandler } from './handler'
22
+ import { withInternalTables } from './internal-tables'
23
+ import { provisionSchema } from './provision'
24
+ import { createRealtimeBroker, buildRealtimeRouter } from './realtime/index'
25
+ import { createRedisRealtimeBroker } from './realtime/redis'
26
+ import { deleteFileWithDerivatives } from './storage/delete'
27
+ import { deleteFileMetaRow } from './storage/file-meta'
28
+ import { createBucketStorages } from './storage/registry'
29
+ import { buildBucketStorageRouter } from './storage/router'
30
+ import { sweepOrphans } from './storage/sweep'
31
+
32
+ type AuthInstance = ReturnType<typeof createAuth>
33
+
34
+ /** Default age before an unconfirmed `pending` file is treated as an orphan. */
35
+ const DEFAULT_PENDING_TTL_MS = 30 * 60_000
36
+ /** How often the auto-started orphan sweep runs. */
37
+ const SWEEP_INTERVAL_MS = 10 * 60_000
38
+
39
+ /**
40
+ * Public storage facade exposed as `app.storage`. Object-level operations live
41
+ * on the per-bucket adapters; this surface offers the app-wide deletes that
42
+ * must also clean the file-meta row.
43
+ */
44
+ export interface StorageFacade {
45
+ /** Delete an object, its transform derivatives, and its file-meta row. `fileId` is `<bucket>/<id>`. */
46
+ delete(fileId: string): Promise<void>
47
+ /** Get the raw adapter for a bucket, or `undefined` if it isn't declared. */
48
+ bucket(name: string): StorageAdapter | undefined
49
+ /**
50
+ * Reap stale `pending` uploads older than `olderThanMs` (default 30m). Runs
51
+ * automatically on an interval; exposed for manual/test invocation. Returns
52
+ * the count reaped.
53
+ */
54
+ sweep(olderThanMs?: number): Promise<number>
55
+ }
56
+
57
+ /** Bucket names declared in a storage config; `string` when unknowable. */
58
+ export type BucketNamesOf<TStorage> = TStorage extends {
59
+ buckets: infer B extends Record<string, unknown>
60
+ }
61
+ ? keyof B & string
62
+ : string
63
+
64
+
65
+ export type BunderstackApp<
66
+ TSchema extends Record<string, unknown>,
67
+ TAccess extends Record<string, TableAccessInput> | undefined = undefined,
68
+ TBuckets extends string = string,
69
+ TEnv extends EnvConfigInput | undefined = undefined,
70
+ TRouter = undefined,
71
+ > = {
72
+ handler: (req: Request) => Promise<Response>
73
+ db: LibSQLDatabase<TSchema>
74
+ auth: AuthInstance
75
+ storage: StorageFacade
76
+ router: HonoType
77
+ /** Raw tRPC router when the config declared one — escape hatch. */
78
+ trpcRouter?: AnyRouter
79
+ /** Validated env: bunderstack's base vars plus the config's `env` extension. */
80
+ env: ValidatedEnv<TEnv>
81
+ /** Email facade; always present — send() throws when email isn't configured. */
82
+ email: EmailFacade
83
+ /** Push the merged schema (user + internal tables) to the database. */
84
+ provision: (options?: { force?: boolean }) => Promise<void>
85
+ /**
86
+ * Type-only carrier for client inference (`createClient<typeof app>()`).
87
+ * Never assigned at runtime.
88
+ */
89
+ readonly $inferClient?: {
90
+ schema: TSchema
91
+ access: TAccess
92
+ buckets: TBuckets
93
+ trpc: TRouter
94
+ }
95
+ }
96
+
97
+ // Overloads: the builder-callback form and the prebuilt-router/none form are
98
+ // separate signatures so the callback's `t` parameter gets contextual typing
99
+ // and the router type lands on `$inferClient` without conditional-type
100
+ // inference (which breaks under contextual return types).
101
+ export function createBunderstack<
102
+ TSchema extends Record<string, unknown>,
103
+ const TAccess extends Record<string, TableAccessInput> | undefined =
104
+ undefined,
105
+ const TStorage extends StorageConfigInput | undefined = undefined,
106
+ const TEnv extends EnvConfigInput | undefined = undefined,
107
+ TRouter extends AnyRouter = AnyRouter,
108
+ >(
109
+ options: BunderstackConfig<TSchema, TAccess, TStorage, TEnv> & {
110
+ /** Builder callback receiving the pre-wired `t` instance. */
111
+ trpc: (t: BunderstackTRPC<TSchema, ValidatedEnv<TEnv>>) => TRouter
112
+ },
113
+ ): BunderstackApp<TSchema, TAccess, BucketNamesOf<TStorage>, TEnv, TRouter>
114
+ export function createBunderstack<
115
+ TSchema extends Record<string, unknown>,
116
+ const TAccess extends Record<string, TableAccessInput> | undefined =
117
+ undefined,
118
+ const TStorage extends StorageConfigInput | undefined = undefined,
119
+ const TEnv extends EnvConfigInput | undefined = undefined,
120
+ TRouter extends AnyRouter | undefined = undefined,
121
+ >(
122
+ options: BunderstackConfig<TSchema, TAccess, TStorage, TEnv> & {
123
+ /** Prebuilt tRPC router (escape hatch for multi-file setups). */
124
+ trpc?: TRouter
125
+ },
126
+ ): BunderstackApp<TSchema, TAccess, BucketNamesOf<TStorage>, TEnv, TRouter>
127
+ export function createBunderstack<
128
+ TSchema extends Record<string, unknown>,
129
+ const TAccess extends Record<string, TableAccessInput> | undefined =
130
+ undefined,
131
+ const TStorage extends StorageConfigInput | undefined = undefined,
132
+ const TEnv extends EnvConfigInput | undefined = undefined,
133
+ >(
134
+ options: BunderstackConfig<TSchema, TAccess, TStorage, TEnv> & {
135
+ trpc?:
136
+ | AnyRouter
137
+ | ((t: BunderstackTRPC<TSchema, ValidatedEnv<TEnv>>) => AnyRouter)
138
+ },
139
+ ): BunderstackApp<
140
+ TSchema,
141
+ TAccess,
142
+ BucketNamesOf<TStorage>,
143
+ TEnv,
144
+ AnyRouter | undefined
145
+ > {
146
+ // Env is validated FIRST: the app refuses to boot on missing/invalid vars,
147
+ // and everything downstream (config, email, trpc ctx) consumes the result.
148
+ const env = validateEnv(options.env, {
149
+ emailProvider: emailProviderTag(options.email),
150
+ })
151
+ const config = resolveConfig(options, env)
152
+ const email = createEmail(options.email, { env })
153
+ // Merge bunderstack's internal tables (file-meta, idempotency) into the
154
+ // schema used for the db client + provisioning. CRUD/access stay on the USER
155
+ // schema so internal tables never get a CRUD route.
156
+ const mergedSchema = withInternalTables(options.schema)
157
+ const db = createDb(mergedSchema, config.database)
158
+ // `db` is typed with the merged schema (user tables + internal tables) so the
159
+ // storage/idempotency code can query the internal tables. The public surface
160
+ // and CRUD only expose the USER schema. TS can widen `LibSQLDatabase<merged>`
161
+ // to `LibSQLDatabase<Record<string, unknown>>` on its own (storage/auth pass
162
+ // `db` directly), but it can't *narrow* a generic schema view, so this single
163
+ // intentional cast produces the user-facing db type. See `app.db` / crud below.
164
+ const userDb = db as unknown as LibSQLDatabase<TSchema>
165
+ const auth = createAuth(
166
+ db,
167
+ withEmailAuthDefaults(config.auth, email, Boolean(options.email)),
168
+ )
169
+ // Internal routers consume the narrow AuthSessionResolver contract, not the
170
+ // raw better-auth instance. app.auth still exposes `auth` unchanged.
171
+ const authResolver = toAuthSessionResolver(auth)
172
+ const resolvedAccess = validateAndResolveAccess(
173
+ options.schema,
174
+ options.access,
175
+ )
176
+ const realtimeBufferSize =
177
+ typeof config.realtime === 'object' ? config.realtime.bufferSize : undefined
178
+ const redisUrl = config.realtime
179
+ ? resolveRealtimeRedisUrl(config.realtime, env)
180
+ : undefined
181
+ const broker = config.realtime
182
+ ? redisUrl
183
+ ? createRedisRealtimeBroker({
184
+ access: resolvedAccess,
185
+ redis: (() => {
186
+ // Redis pub/sub requires a dedicated connection (subscribe puts the client into
187
+ // a restricted state). We use one client for commands and a second for subscribe.
188
+ const cmdClient = new Bun.RedisClient(redisUrl)
189
+ const subClient = new Bun.RedisClient(redisUrl)
190
+ return {
191
+ incr: (key: string) => cmdClient.incr(key),
192
+ publish: (channel: string, message: string) =>
193
+ cmdClient.publish(channel, message),
194
+ subscribe: (channel: string, listener: (msg: string) => void) =>
195
+ subClient.subscribe(channel, listener),
196
+ lpush: (key: string, value: string) =>
197
+ cmdClient.lpush(key, value),
198
+ ltrim: (key: string, start: number, stop: number) =>
199
+ cmdClient.ltrim(key, start, stop),
200
+ lrange: (key: string, start: number, stop: number) =>
201
+ cmdClient.lrange(key, start, stop),
202
+ }
203
+ })(),
204
+ bufferSize: realtimeBufferSize,
205
+ })
206
+ : createRealtimeBroker({
207
+ access: resolvedAccess,
208
+ bufferSize: realtimeBufferSize,
209
+ })
210
+ : undefined
211
+ const crudRouter = buildCrudRouter(options.schema, userDb, {
212
+ auth: authResolver,
213
+ access: resolvedAccess,
214
+ idempotency: options.idempotency,
215
+ broker,
216
+ })
217
+ const realtimeRouter = broker
218
+ ? buildRealtimeRouter(broker, {
219
+ auth: authResolver,
220
+ keepaliveMs:
221
+ typeof config.realtime === 'object'
222
+ ? config.realtime.keepaliveMs
223
+ : undefined,
224
+ })
225
+ : undefined
226
+ const registry = createBucketStorages(config.storage)
227
+ const storageRouter = buildBucketStorageRouter({
228
+ registry,
229
+ db,
230
+ auth: authResolver,
231
+ })
232
+ const storage: StorageFacade = {
233
+ async delete(fileId) {
234
+ const bucketName = fileId.split('/')[0] ?? ''
235
+ const entry = registry.get(bucketName)
236
+ if (entry) {
237
+ await deleteFileWithDerivatives(entry.adapter, db, fileId)
238
+ } else {
239
+ // Unknown bucket: no adapter to clean, but still drop the meta row.
240
+ await deleteFileMetaRow(db, fileId)
241
+ }
242
+ },
243
+ bucket(name) {
244
+ return registry.get(name)?.adapter
245
+ },
246
+ sweep(olderThanMs = DEFAULT_PENDING_TTL_MS) {
247
+ return sweepOrphans(registry, db, olderThanMs)
248
+ },
249
+ }
250
+ // Auto-reap orphaned `pending` uploads. `unref()` keeps this from holding the
251
+ // process (and test runners) open.
252
+ const sweepTimer = setInterval(() => {
253
+ void sweepOrphans(registry, db, DEFAULT_PENDING_TTL_MS).catch(() => {})
254
+ }, SWEEP_INTERVAL_MS)
255
+ sweepTimer.unref?.()
256
+ const trpcRouter: AnyRouter | undefined =
257
+ typeof options.trpc === 'function'
258
+ ? options.trpc(createTRPC<TSchema, ValidatedEnv<TEnv>>())
259
+ : options.trpc
260
+ const trpcHandler = trpcRouter
261
+ ? (req: Request) =>
262
+ fetchRequestHandler({
263
+ endpoint: '/api/trpc',
264
+ req,
265
+ router: trpcRouter,
266
+ createContext: async () => ({
267
+ db: userDb,
268
+ user: await resolveAccessUser(authResolver, req.headers),
269
+ env,
270
+ email,
271
+ req,
272
+ }),
273
+ })
274
+ : undefined
275
+ const { handler, router } = buildHandler({
276
+ crudRouter,
277
+ authHandler: (req) => auth.handler(req),
278
+ storageRouter,
279
+ realtimeRouter,
280
+ trpcHandler,
281
+ rateLimit: options.rateLimit,
282
+ })
283
+
284
+ const app: BunderstackApp<
285
+ TSchema,
286
+ TAccess,
287
+ BucketNamesOf<TStorage>,
288
+ TEnv,
289
+ AnyRouter | undefined
290
+ > = {
291
+ handler,
292
+ // Internal tables live on the runtime db but stay out of the public type.
293
+ db: userDb,
294
+ auth,
295
+ storage,
296
+ router,
297
+ env,
298
+ email,
299
+ trpcRouter,
300
+ provision: (opts) =>
301
+ provisionSchema(db, mergedSchema, {
302
+ force: opts?.force,
303
+ databaseUrl: config.database.url,
304
+ }),
305
+ }
306
+
307
+ return app
308
+ }
309
+
310
+ export { MAX_LIST_LIMIT } from './list-query'
311
+ export { resolveConfig } from './config'
312
+ export type {
313
+ BetterAuthConfig,
314
+ BunderstackConfig,
315
+ ResolvedConfig,
316
+ } from './config'
317
+ export { provisionSchema } from './provision'
318
+ export { validateEnv, createClientEnv, BunderstackEnvError } from './env'
319
+ export type { EnvConfigInput, BaseEnv, ValidatedEnv } from './env'
320
+ export { createEmail } from './email'
321
+ export type {
322
+ EmailMessage,
323
+ EmailAdapter,
324
+ EmailConfigInput,
325
+ EmailFacade,
326
+ } from './email'
327
+ export { createTRPC } from './trpc'
328
+ export type { BunderstackTRPC, TRPCContext } from './trpc'
329
+ export {
330
+ defineAccess,
331
+ validateAndResolveAccess,
332
+ checkAccess,
333
+ AUTH_TABLE_NAMES,
334
+ } from './access'
335
+ export type {
336
+ TableAccessInput,
337
+ OperationRule,
338
+ AccessContext,
339
+ AccessUser,
340
+ } from './access'
341
+ export {
342
+ typeid,
343
+ generate as generateTypeId,
344
+ parse as parseTypeId,
345
+ asTypeId,
346
+ } from './typeid'
347
+ export type { TypeId } from './typeid'
348
+ export type { StorageAdapter } from './storage/index'
349
+ export type {
350
+ StorageConfigInput,
351
+ BucketConfigInput,
352
+ ResolvedBucket,
353
+ } from './storage/buckets'
354
+ // StorageFacade is declared+exported inline above.
355
+ export type { TransformSpec } from './storage/thumbnails'
356
+
357
+ // Re-export drizzle builders so consumers share bunderstack's drizzle-orm instance
358
+ // and avoid type incompatibilities from duplicate installs.
359
+ export {
360
+ sqliteTable,
361
+ integer,
362
+ text,
363
+ real,
364
+ blob,
365
+ numeric,
366
+ foreignKey,
367
+ } from 'drizzle-orm/sqlite-core'
368
+ export { eq, and, or, not, gt, gte, lt, lte, desc, asc, sql } from 'drizzle-orm'
@@ -0,0 +1,87 @@
1
+ import { getTableName, isTable } from 'drizzle-orm'
2
+ import {
3
+ index,
4
+ integer,
5
+ primaryKey,
6
+ sqliteTable,
7
+ text,
8
+ } from 'drizzle-orm/sqlite-core'
9
+
10
+ export const bunderstackFiles = sqliteTable(
11
+ 'bunderstack_file_meta',
12
+ {
13
+ fileId: text('file_id').primaryKey(),
14
+ bucket: text('bucket').notNull(),
15
+ ownerId: text('owner_id'),
16
+ scopeJson: text('scope_json'),
17
+ status: text('status').notNull(),
18
+ filename: text('filename'),
19
+ contentType: text('content_type'),
20
+ size: integer('size'),
21
+ createdAt: integer('created_at').notNull(),
22
+ confirmedAt: integer('confirmed_at'),
23
+ },
24
+ (t) => [
25
+ index('bfm_owner').on(t.ownerId),
26
+ index('bfm_scope').on(t.bucket, t.scopeJson),
27
+ index('bfm_sweep').on(t.status, t.createdAt),
28
+ ],
29
+ )
30
+
31
+ export const bunderstackIdempotency = sqliteTable(
32
+ '_bunderstack_idempotency',
33
+ {
34
+ key: text('key').notNull(),
35
+ tableName: text('table_name').notNull(),
36
+ bodyHash: text('body_hash').notNull(),
37
+ status: integer('status').notNull(),
38
+ response: text('response').notNull(),
39
+ expiresAt: integer('expires_at').notNull(),
40
+ },
41
+ (t) => [primaryKey({ columns: [t.key, t.tableName] })],
42
+ )
43
+
44
+ export const INTERNAL_TABLES = {
45
+ bunderstackFiles,
46
+ bunderstackIdempotency,
47
+ } as const
48
+
49
+ export const INTERNAL_TABLE_NAMES: ReadonlySet<string> = new Set([
50
+ 'bunderstack_file_meta',
51
+ '_bunderstack_idempotency',
52
+ ])
53
+
54
+ const INTERNAL_TABLE_BY_NAME = new Map<string, (typeof INTERNAL_TABLES)[keyof typeof INTERNAL_TABLES]>([
55
+ [getTableName(bunderstackFiles), bunderstackFiles],
56
+ [getTableName(bunderstackIdempotency), bunderstackIdempotency],
57
+ ])
58
+
59
+ export function withInternalTables<TSchema extends Record<string, unknown>>(
60
+ schema: TSchema,
61
+ ): TSchema & typeof INTERNAL_TABLES {
62
+ const merged = { ...schema } as TSchema & typeof INTERNAL_TABLES
63
+
64
+ for (const value of Object.values(schema)) {
65
+ if (!isTable(value)) continue
66
+ const name = getTableName(value)
67
+ if (!INTERNAL_TABLE_NAMES.has(name)) continue
68
+
69
+ const internal = INTERNAL_TABLE_BY_NAME.get(name)
70
+ if (internal === value) {
71
+ // Re-exported from bunderstack/schema — already in user schema.
72
+ continue
73
+ }
74
+
75
+ throw new Error(
76
+ `[bunderstack] table name "${name}" is reserved by bunderstack`,
77
+ )
78
+ }
79
+
80
+ for (const [key, table] of Object.entries(INTERNAL_TABLES)) {
81
+ if (!(key in merged)) {
82
+ ;(merged as Record<string, unknown>)[key] = table
83
+ }
84
+ }
85
+
86
+ return merged
87
+ }