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/LICENSE +21 -0
- package/README.md +44 -0
- package/package.json +69 -0
- package/src/access.ts +516 -0
- package/src/auth.ts +102 -0
- package/src/config.ts +152 -0
- package/src/crud.ts +389 -0
- package/src/db.ts +10 -0
- package/src/email.ts +200 -0
- package/src/env.ts +153 -0
- package/src/errors.ts +51 -0
- package/src/handler.ts +53 -0
- package/src/idempotency.ts +102 -0
- package/src/index.ts +368 -0
- package/src/internal-tables.ts +87 -0
- package/src/list-query.ts +413 -0
- package/src/provision.ts +46 -0
- package/src/rate-limit.ts +71 -0
- package/src/realtime/index.ts +245 -0
- package/src/realtime/redis.ts +204 -0
- package/src/schema-export.ts +4 -0
- package/src/scope.ts +17 -0
- package/src/storage/buckets.ts +273 -0
- package/src/storage/delete.ts +27 -0
- package/src/storage/file-meta.ts +224 -0
- package/src/storage/index.ts +31 -0
- package/src/storage/local.ts +69 -0
- package/src/storage/registry.ts +40 -0
- package/src/storage/router.ts +532 -0
- package/src/storage/s3.ts +93 -0
- package/src/storage/sweep.ts +26 -0
- package/src/storage/thumbnails.ts +65 -0
- package/src/trpc.ts +52 -0
- package/src/typeid.ts +116 -0
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
|
+
}
|