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.
@@ -0,0 +1,413 @@
1
+ import type { LibSQLDatabase } from 'drizzle-orm/libsql'
2
+
3
+ import {
4
+ and,
5
+ asc,
6
+ desc,
7
+ eq,
8
+ getTableColumns,
9
+ gt,
10
+ inArray,
11
+ like,
12
+ lt,
13
+ or,
14
+ sql,
15
+ type SQL,
16
+ } from 'drizzle-orm'
17
+
18
+ import type { ResolvedTableAccess, SortOrder } from './access'
19
+
20
+ import { ErrorCode, ListQueryError } from './errors'
21
+
22
+ /** Caps both `?limit=` and the number of values in a comma-separated `IN` filter. */
23
+ export const MAX_LIST_LIMIT = 200
24
+
25
+ export const RESERVED_LIST_PARAMS = new Set([
26
+ 'limit',
27
+ 'offset',
28
+ 'sort',
29
+ 'order',
30
+ 'q',
31
+ 'cursor',
32
+ 'count',
33
+ ])
34
+
35
+ export type ParsedListParams = {
36
+ limit: number
37
+ offset?: number
38
+ sort: string
39
+ order: SortOrder
40
+ q: string
41
+ cursor?: string
42
+ count: boolean
43
+ filters: Record<string, unknown>
44
+ }
45
+
46
+ export type ListResult<T> = {
47
+ items: T[]
48
+ limit: number
49
+ offset?: number
50
+ cursor?: string
51
+ nextCursor?: string
52
+ hasMore: boolean
53
+ total?: number
54
+ q?: string
55
+ sort: string
56
+ order: SortOrder
57
+ }
58
+
59
+ type CursorPayload = {
60
+ sort: string
61
+ order: SortOrder
62
+ v: string | number | null
63
+ id: string | number
64
+ }
65
+
66
+ function isRecord(value: unknown): value is Record<string, unknown> {
67
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
68
+ }
69
+
70
+ function isCursorPayload(value: unknown): value is CursorPayload {
71
+ if (!isRecord(value)) return false
72
+ return (
73
+ typeof value.sort === 'string' &&
74
+ (value.order === 'asc' || value.order === 'desc') &&
75
+ (value.v === null ||
76
+ typeof value.v === 'string' ||
77
+ typeof value.v === 'number') &&
78
+ (typeof value.id === 'string' || typeof value.id === 'number')
79
+ )
80
+ }
81
+
82
+ function parseBoolean(value: string | undefined): boolean {
83
+ if (!value) return false
84
+ const v = value.toLowerCase()
85
+ return v === 'true' || v === '1'
86
+ }
87
+
88
+ function parseLimit(raw: string | undefined): number {
89
+ if (raw === undefined || raw === '') return 20
90
+ const n = Number(raw)
91
+ if (!Number.isInteger(n) || n < 1) {
92
+ throw new ListQueryError(
93
+ `limit must be an integer between 1 and ${MAX_LIST_LIMIT}`,
94
+ )
95
+ }
96
+ return Math.min(n, MAX_LIST_LIMIT)
97
+ }
98
+
99
+ function parseOffset(raw: string | undefined): number {
100
+ if (raw === undefined || raw === '') return 0
101
+ const n = Number(raw)
102
+ if (!Number.isInteger(n) || n < 0) {
103
+ throw new ListQueryError('offset must be a non-negative integer')
104
+ }
105
+ return n
106
+ }
107
+
108
+ function parseOrder(raw: string | undefined): SortOrder {
109
+ if (!raw || raw === 'asc') return 'asc'
110
+ if (raw === 'desc') return 'desc'
111
+ throw new ListQueryError('order must be "asc" or "desc"')
112
+ }
113
+
114
+ export function parseListParams(
115
+ url: URL,
116
+ access: ResolvedTableAccess,
117
+ ): ParsedListParams {
118
+ const params = url.searchParams
119
+ const limit = parseLimit(params.get('limit') ?? undefined)
120
+ const cursor = params.get('cursor')?.trim() || undefined
121
+ const hasOffset = params.has('offset') && params.get('offset') !== ''
122
+ const offset = hasOffset
123
+ ? parseOffset(params.get('offset') ?? undefined)
124
+ : undefined
125
+
126
+ if (cursor && hasOffset) {
127
+ throw new ListQueryError('cursor and offset cannot be used together')
128
+ }
129
+
130
+ const sort = params.get('sort')?.trim() || access.defaultSort.column
131
+ const order = params.has('order')
132
+ ? parseOrder(params.get('order') ?? undefined)
133
+ : params.has('sort')
134
+ ? 'asc'
135
+ : access.defaultSort.order
136
+
137
+ if (!access.sortableColumns.includes(sort)) {
138
+ throw new ListQueryError(`sort column "${sort}" is not allowed`)
139
+ }
140
+
141
+ const filters: Record<string, unknown> = {}
142
+ for (const [key, value] of params.entries()) {
143
+ if (RESERVED_LIST_PARAMS.has(key)) continue
144
+ if (!access.filterableColumns.includes(key)) {
145
+ throw new ListQueryError(`filter column "${key}" is not allowed`)
146
+ }
147
+ filters[key] = value === 'null' ? null : value
148
+ }
149
+
150
+ const q = params.get('q')?.trim().slice(0, 100) ?? ''
151
+ const count = parseBoolean(params.get('count') ?? undefined)
152
+
153
+ if (cursor) {
154
+ const decoded = decodeCursor(cursor)
155
+ if (decoded.sort !== sort || decoded.order !== order) {
156
+ throw new ListQueryError(
157
+ 'cursor does not match sort and order parameters',
158
+ ErrorCode.INVALID_CURSOR,
159
+ )
160
+ }
161
+ }
162
+
163
+ return {
164
+ limit,
165
+ offset: cursor ? undefined : (offset ?? 0),
166
+ sort,
167
+ order,
168
+ q,
169
+ cursor,
170
+ count,
171
+ filters,
172
+ }
173
+ }
174
+
175
+ function buildSearchWhere(
176
+ table: Parameters<typeof getTableColumns>[0],
177
+ searchableColumns: string[] | undefined,
178
+ q: string,
179
+ ): SQL | undefined {
180
+ if (!q || !searchableColumns?.length) return undefined
181
+ const columns = getTableColumns(table)
182
+ const pattern = `%${q.replace(/[%_\\]/g, (ch) => `\\${ch}`)}%`
183
+ const conditions = searchableColumns
184
+ .filter((name) => name in columns)
185
+ .map((name) => like(columns[name]!, pattern))
186
+ return conditions.length ? or(...conditions) : undefined
187
+ }
188
+
189
+ function coerceFilterValue(
190
+ table: Parameters<typeof getTableColumns>[0],
191
+ columnName: string,
192
+ raw: unknown,
193
+ ): unknown {
194
+ if (raw === null) return null
195
+ const col = getTableColumns(table)[columnName]
196
+ if (!col) return raw
197
+
198
+ const dataType = col.dataType
199
+ if (
200
+ dataType === 'number' ||
201
+ dataType === 'integer' ||
202
+ dataType === 'bigint'
203
+ ) {
204
+ const n = Number(raw)
205
+ if (Number.isNaN(n)) {
206
+ throw new ListQueryError(`filter "${columnName}" must be a number`)
207
+ }
208
+ return n
209
+ }
210
+ if (dataType === 'boolean') {
211
+ const s = String(raw).toLowerCase()
212
+ if (s === 'true' || s === '1') return true
213
+ if (s === 'false' || s === '0') return false
214
+ throw new ListQueryError(`filter "${columnName}" must be a boolean`)
215
+ }
216
+ if (dataType === 'date') {
217
+ const d = new Date(raw as string | number)
218
+ if (Number.isNaN(d.getTime())) {
219
+ throw new ListQueryError(`filter "${columnName}" must be a valid date`)
220
+ }
221
+ return d
222
+ }
223
+ return String(raw)
224
+ }
225
+
226
+ function buildFilterWhere(
227
+ table: Parameters<typeof getTableColumns>[0],
228
+ filters: Record<string, unknown>,
229
+ ): SQL | undefined {
230
+ const columns = getTableColumns(table)
231
+ const conditions: SQL[] = []
232
+
233
+ for (const [name, raw] of Object.entries(filters)) {
234
+ const col = columns[name]
235
+ if (!col) continue
236
+
237
+ if (raw === null) {
238
+ conditions.push(sql`${col} IS NULL`)
239
+ continue
240
+ }
241
+
242
+ // `?column=a,b,c` — TypeIDs and other filterable values never contain
243
+ // commas, so this is a safe, zero-syntax way to do `column IN (...)`.
244
+ if (typeof raw === 'string' && raw.includes(',')) {
245
+ const parts = raw.split(',').filter((p) => p.length > 0)
246
+ if (parts.length > MAX_LIST_LIMIT) {
247
+ throw new ListQueryError(
248
+ `filter "${name}" accepts at most ${MAX_LIST_LIMIT} comma-separated values`,
249
+ )
250
+ }
251
+ const values = parts.map((p) => coerceFilterValue(table, name, p))
252
+ conditions.push(inArray(col, values))
253
+ continue
254
+ }
255
+
256
+ const value = coerceFilterValue(table, name, raw)
257
+ if (value === null) {
258
+ conditions.push(sql`${col} IS NULL`)
259
+ } else {
260
+ conditions.push(eq(col, value))
261
+ }
262
+ }
263
+
264
+ return conditions.length ? and(...conditions) : undefined
265
+ }
266
+
267
+ function serializeCursorValue(value: unknown): string | number | null {
268
+ if (value == null) return null
269
+ if (value instanceof Date) return value.toISOString()
270
+ if (typeof value === 'number' || typeof value === 'string') return value
271
+ return String(value)
272
+ }
273
+
274
+ export function encodeCursor(payload: CursorPayload): string {
275
+ return Buffer.from(JSON.stringify(payload)).toString('base64url')
276
+ }
277
+
278
+ export function decodeCursor(cursor: string): CursorPayload {
279
+ try {
280
+ const parsed = JSON.parse(
281
+ Buffer.from(cursor, 'base64url').toString('utf8'),
282
+ )
283
+ if (!isCursorPayload(parsed)) {
284
+ throw new Error('invalid cursor shape')
285
+ }
286
+ return parsed
287
+ } catch {
288
+ throw new ListQueryError('invalid cursor', ErrorCode.INVALID_CURSOR)
289
+ }
290
+ }
291
+
292
+ function buildCursorWhere(
293
+ table: Parameters<typeof getTableColumns>[0],
294
+ sortColName: string,
295
+ order: SortOrder,
296
+ cursor: CursorPayload,
297
+ idCol: unknown,
298
+ ): SQL {
299
+ const columns = getTableColumns(table)
300
+ const sortCol = columns[sortColName]!
301
+ const sortValue = coerceFilterValue(table, sortColName, cursor.v)
302
+
303
+ if (order === 'desc') {
304
+ return or(
305
+ lt(sortCol, sortValue),
306
+ and(eq(sortCol, sortValue), lt(idCol as never, cursor.id)),
307
+ )!
308
+ }
309
+ return or(
310
+ gt(sortCol, sortValue),
311
+ and(eq(sortCol, sortValue), gt(idCol as never, cursor.id)),
312
+ )!
313
+ }
314
+
315
+ function buildOrderBy(
316
+ table: Parameters<typeof getTableColumns>[0],
317
+ sortColName: string,
318
+ order: SortOrder,
319
+ idCol: unknown,
320
+ ) {
321
+ const columns = getTableColumns(table)
322
+ const sortCol = columns[sortColName]!
323
+ const idOrder = order === 'asc' ? asc(idCol as never) : desc(idCol as never)
324
+ return order === 'asc' ? [asc(sortCol), idOrder] : [desc(sortCol), idOrder]
325
+ }
326
+
327
+ export async function executeList<T extends Record<string, unknown>>(
328
+ db: LibSQLDatabase<Record<string, unknown>>,
329
+ table: Parameters<typeof getTableColumns>[0],
330
+ access: ResolvedTableAccess,
331
+ params: ParsedListParams,
332
+ idCol: unknown,
333
+ scopeWhere?: SQL,
334
+ ): Promise<ListResult<T>> {
335
+ const searchWhere = buildSearchWhere(
336
+ table,
337
+ access.searchableColumns,
338
+ params.q,
339
+ )
340
+ const filterWhere = buildFilterWhere(table, params.filters)
341
+ let where = and(
342
+ ...(searchWhere ? [searchWhere] : []),
343
+ ...(filterWhere ? [filterWhere] : []),
344
+ ...(scopeWhere ? [scopeWhere] : []),
345
+ )
346
+
347
+ if (params.cursor) {
348
+ const cursorPayload = decodeCursor(params.cursor)
349
+ const cursorWhere = buildCursorWhere(
350
+ table,
351
+ params.sort,
352
+ params.order,
353
+ cursorPayload,
354
+ idCol,
355
+ )
356
+ where = where ? and(where, cursorWhere) : cursorWhere
357
+ }
358
+
359
+ const orderBy = buildOrderBy(table, params.sort, params.order, idCol)
360
+
361
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
362
+ let query = (db as any).select().from(table)
363
+ if (where) query = query.where(where)
364
+ query = query.orderBy(...orderBy)
365
+
366
+ if (params.offset !== undefined) {
367
+ query = query.limit(params.limit).offset(params.offset)
368
+ } else {
369
+ query = query.limit(params.limit)
370
+ }
371
+
372
+ const items = (await query) as T[]
373
+
374
+ let total: number | undefined
375
+ if (params.count) {
376
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
377
+ let countQuery = (db as any)
378
+ .select({ count: sql<number>`count(*)` })
379
+ .from(table)
380
+ if (where) countQuery = countQuery.where(where)
381
+ const [row] = await countQuery
382
+ total = Number(row?.count ?? 0)
383
+ }
384
+
385
+ const hasMore =
386
+ total !== undefined && params.offset !== undefined
387
+ ? params.offset + items.length < total
388
+ : items.length === params.limit
389
+
390
+ let nextCursor: string | undefined
391
+ if (items.length === params.limit) {
392
+ const last = items[items.length - 1]!
393
+ nextCursor = encodeCursor({
394
+ sort: params.sort,
395
+ order: params.order,
396
+ v: serializeCursorValue(last[params.sort]),
397
+ id: last.id as string | number,
398
+ })
399
+ }
400
+
401
+ return {
402
+ items,
403
+ limit: params.limit,
404
+ ...(params.offset !== undefined ? { offset: params.offset } : {}),
405
+ ...(params.cursor ? { cursor: params.cursor } : {}),
406
+ ...(nextCursor ? { nextCursor } : {}),
407
+ hasMore,
408
+ ...(total !== undefined ? { total } : {}),
409
+ ...(params.q ? { q: params.q } : {}),
410
+ sort: params.sort,
411
+ order: params.order,
412
+ }
413
+ }
@@ -0,0 +1,46 @@
1
+ import type { LibSQLDatabase } from 'drizzle-orm/libsql'
2
+
3
+ import { mkdir } from 'node:fs/promises'
4
+ import { dirname } from 'node:path'
5
+
6
+ async function ensureSqliteFileParent(url: string): Promise<void> {
7
+ const match = /^file:(.+)$/.exec(url)
8
+ if (!match) return
9
+ const filePath = match[1]!
10
+ if (filePath === ':memory:') return
11
+ await mkdir(dirname(filePath), { recursive: true })
12
+ }
13
+
14
+ /** Push the merged schema to the database via drizzle-kit/api. */
15
+ export async function provisionSchema<TSchema extends Record<string, unknown>>(
16
+ db: LibSQLDatabase<TSchema>,
17
+ schema: TSchema,
18
+ options?: { force?: boolean; databaseUrl?: string },
19
+ ): Promise<void> {
20
+ if (options?.databaseUrl) {
21
+ await ensureSqliteFileParent(options.databaseUrl)
22
+ }
23
+
24
+ const { pushSQLiteSchema } = await import('drizzle-kit/api')
25
+ const result = await pushSQLiteSchema(schema, db)
26
+
27
+ if (result.hasDataLoss && !options?.force) {
28
+ throw new Error(
29
+ '[bunderstack] Schema push would cause data loss. Run `bunx drizzle-kit push` or call app.provision({ force: true }).',
30
+ )
31
+ }
32
+
33
+ if (result.warnings.length > 0) {
34
+ for (const warning of result.warnings) {
35
+ console.warn(`[bunderstack] ${warning}`)
36
+ }
37
+ }
38
+
39
+ if (result.statementsToExecute.length === 0) return
40
+
41
+ await result.apply()
42
+
43
+ console.log(
44
+ `[bunderstack] provisioned ${result.statementsToExecute.length} schema change(s)`,
45
+ )
46
+ }
@@ -0,0 +1,71 @@
1
+ export type RateLimitConfig = {
2
+ windowMs?: number
3
+ max?: number
4
+ skip?: (req: Request) => boolean
5
+ }
6
+
7
+ type Bucket = {
8
+ count: number
9
+ resetAt: number
10
+ }
11
+
12
+ const buckets = new Map<string, Bucket>()
13
+
14
+ function resolveConfig(
15
+ config: boolean | RateLimitConfig | undefined,
16
+ ): RateLimitConfig | null {
17
+ if (!config) return null
18
+ if (config === true) return {}
19
+ return config
20
+ }
21
+
22
+ function clientKey(req: Request): string {
23
+ const forwarded = req.headers.get('x-forwarded-for')
24
+ if (forwarded) return forwarded.split(',')[0]!.trim()
25
+ return req.headers.get('x-real-ip') ?? 'local'
26
+ }
27
+
28
+ export function createRateLimiter(
29
+ config: boolean | RateLimitConfig | undefined,
30
+ ): (req: Request) => Promise<Response | null> {
31
+ const resolved = resolveConfig(config)
32
+ if (!resolved) {
33
+ return async (_req: Request) => null
34
+ }
35
+
36
+ const windowMs = resolved.windowMs ?? 60_000
37
+ const max = resolved.max ?? 100
38
+
39
+ return async (req: Request): Promise<Response | null> => {
40
+ if (resolved.skip?.(req)) return null
41
+
42
+ const key = `${clientKey(req)}:${new URL(req.url).pathname}`
43
+ const now = Date.now()
44
+ let bucket = buckets.get(key)
45
+
46
+ if (!bucket || bucket.resetAt <= now) {
47
+ bucket = { count: 0, resetAt: now + windowMs }
48
+ buckets.set(key, bucket)
49
+ }
50
+
51
+ bucket.count += 1
52
+ if (bucket.count > max) {
53
+ const retryAfter = Math.ceil((bucket.resetAt - now) / 1000)
54
+ return new Response(
55
+ JSON.stringify({
56
+ error: 'Too many requests',
57
+ code: 'RATE_LIMITED',
58
+ }),
59
+ {
60
+ status: 429,
61
+ headers: {
62
+ 'Content-Type': 'application/json',
63
+ 'Retry-After': String(retryAfter),
64
+ },
65
+ },
66
+ )
67
+ }
68
+
69
+ return null
70
+ }
71
+ }