bunderstack 0.16.0 → 0.17.0-beta.2

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,386 @@
1
+ import { getTableColumns, getTableName, isTable, type Table } from 'drizzle-orm'
2
+ import {
3
+ createInsertSchema,
4
+ createSelectSchema,
5
+ createUpdateSchema,
6
+ } from 'drizzle-valibot'
7
+ import '@orpc/openapi/extensions/route'
8
+ import * as v from 'valibot'
9
+
10
+ import type { AnyDb } from '../dialect'
11
+ import type { IdempotencyConfig } from '../idempotency'
12
+ import type { RealtimeFacade } from '../realtime/facade'
13
+ import type { CrudApiRouterFor } from './types'
14
+
15
+ import {
16
+ tableEntryForName,
17
+ type ResolvedAccess,
18
+ type ResolvedTableAccess,
19
+ type TableAccessInput,
20
+ } from '../access'
21
+ import { createCrudOperations, type CrudOperations } from '../crud-operations'
22
+ import { MAX_LIST_LIMIT } from '../list-query'
23
+ import { createApiBuilder } from './builder'
24
+
25
+ export type CrudApiRouterOptions<
26
+ TSchema extends Record<string, unknown> = Record<string, unknown>,
27
+ > = {
28
+ access: ResolvedAccess
29
+ idempotency?: boolean | IdempotencyConfig
30
+ realtime?: RealtimeFacade<TSchema>
31
+ }
32
+
33
+ function strictObject<TEntries extends v.ObjectEntries>(schema: {
34
+ entries: TEntries
35
+ }) {
36
+ return v.strictObject(schema.entries)
37
+ }
38
+
39
+ type CrudInsert<TTable extends Table> = Partial<TTable['$inferInsert']>
40
+
41
+ /** A filter accepts one value (`=`), a list (`IN`), or null (`IS NULL`). */
42
+ export type ListFilterValue<T> = T | readonly T[] | null | 'null'
43
+
44
+ export type ListFilters<
45
+ TTable extends Table,
46
+ TFilterable extends string,
47
+ > = {
48
+ [K in Extract<keyof TTable['$inferSelect'], TFilterable>]?: ListFilterValue<
49
+ TTable['$inferSelect'][K]
50
+ >
51
+ }
52
+
53
+ /**
54
+ * The `list` input as callers see it. Filter columns and sortable columns come
55
+ * from the table's `access` entry, so `filters` autocompletes to real columns
56
+ * and rejects wrong value types at compile time.
57
+ */
58
+ export type ListInputFor<
59
+ TTable extends Table,
60
+ TFilterable extends string,
61
+ TSortable extends string,
62
+ > =
63
+ | {
64
+ limit?: number
65
+ offset?: number
66
+ cursor?: string
67
+ sort?: TSortable
68
+ order?: 'asc' | 'desc'
69
+ q?: string
70
+ count?: boolean
71
+ filters?: ListFilters<TTable, TFilterable>
72
+ }
73
+ | undefined
74
+
75
+ export type BuildTableCrudProceduresArgs<
76
+ TSchema extends Record<string, unknown>,
77
+ TTable extends Table,
78
+ > = {
79
+ table: TTable
80
+ operations: CrudOperations
81
+ builder: ReturnType<typeof createApiBuilder<TSchema>>
82
+ access: ResolvedTableAccess
83
+ }
84
+
85
+ export function buildTableCrudProcedures<
86
+ TSchema extends Record<string, unknown>,
87
+ TTable extends Table,
88
+ TFilterable extends string = string,
89
+ TSortable extends string = string,
90
+ >(args: BuildTableCrudProceduresArgs<TSchema, TTable>) {
91
+ const { table, operations, builder, access } = args
92
+ const name = getTableName(table)
93
+
94
+ const selectSchema = strictObject(createSelectSchema(table))
95
+ const generatedInsertSchema = strictObject(createInsertSchema(table))
96
+ const generatedColumns = Object.keys(generatedInsertSchema.entries)
97
+ const columns = getTableColumns(table)
98
+ const serverManagedColumns = [
99
+ ...access.readonlyColumns,
100
+ ...(access.writeScope && generatedColumns.includes('organizationId')
101
+ ? ['organizationId']
102
+ : []),
103
+ ...Object.entries(columns)
104
+ .filter(([, column]) => !column.notNull || column.hasDefault)
105
+ .map(([column]) => column),
106
+ ].filter((column) => generatedColumns.includes(column))
107
+ const insertEntries: Record<string, v.BaseSchema<unknown, unknown, v.BaseIssue<unknown>>> = {
108
+ ...generatedInsertSchema.entries,
109
+ }
110
+ for (const column of serverManagedColumns) {
111
+ const schema = insertEntries[column]
112
+ if (schema) insertEntries[column] = v.optional(schema)
113
+ }
114
+ const insertSchema = v.strictObject(insertEntries) as unknown as v.GenericSchema<
115
+ CrudInsert<TTable>,
116
+ CrudInsert<TTable>
117
+ >
118
+ const generatedUpdateSchema = createUpdateSchema(table)
119
+ const updateBodySchema = v.omit(generatedUpdateSchema, [
120
+ 'id' as keyof typeof generatedUpdateSchema.entries,
121
+ ])
122
+ const updateInputSchema = v.strictObject({
123
+ params: v.strictObject({ id: v.string() }),
124
+ query: v.optional(v.record(v.string(), v.unknown()), {}),
125
+ headers: v.optional(v.record(v.string(), v.unknown()), {}),
126
+ body: strictObject(updateBodySchema),
127
+ })
128
+
129
+ // One filter field per allowed column, typed by the column itself: a scalar
130
+ // for `=`, a list for `IN`, and `null` for `IS NULL`. Query strings are
131
+ // coerced to these types by SmartCoercionHandlerPlugin, so REST and RPC share
132
+ // one contract and nothing has to re-read the raw URL.
133
+ const selectEntries = createSelectSchema(table).entries as Record<
134
+ string,
135
+ v.BaseSchema<unknown, unknown, v.BaseIssue<unknown>>
136
+ >
137
+ const filterEntries: v.ObjectEntries = {}
138
+ for (const column of access.filterableColumns) {
139
+ const base = selectEntries[column]
140
+ if (!base) continue
141
+ filterEntries[column] = v.optional(
142
+ v.union([
143
+ // `?filters[col]=null` — a query string cannot carry a real null.
144
+ v.pipe(
145
+ v.literal('null'),
146
+ v.transform(() => null),
147
+ ),
148
+ base,
149
+ v.pipe(v.array(base), v.maxLength(MAX_LIST_LIMIT)),
150
+ v.null(),
151
+ ]),
152
+ )
153
+ }
154
+
155
+ // Built from runtime column lists, so the schema's own inferred type cannot
156
+ // name the columns; the cast restates it with the literals the caller's
157
+ // `access` config carries. Runtime shape and this type are the same object.
158
+ const listQuerySchema = v.optional(
159
+ v.strictObject({
160
+ limit: v.optional(v.pipe(v.number(), v.integer(), v.minValue(1))),
161
+ offset: v.optional(v.pipe(v.number(), v.integer(), v.minValue(0))),
162
+ cursor: v.optional(v.string()),
163
+ sort: v.optional(v.picklist(access.sortableColumns)),
164
+ order: v.optional(v.picklist(['asc', 'desc'])),
165
+ q: v.optional(v.pipe(v.string(), v.maxLength(100))),
166
+ count: v.optional(v.boolean()),
167
+ // Always present, even with no filterable columns: clients send `{}`.
168
+ filters: v.optional(v.strictObject(filterEntries)),
169
+ }),
170
+ ) as unknown as v.GenericSchema<
171
+ ListInputFor<TTable, TFilterable, TSortable>,
172
+ ListInputFor<TTable, TFilterable, TSortable>
173
+ >
174
+
175
+
176
+ const listOutputSchema = v.strictObject({
177
+ items: v.array(selectSchema),
178
+ nextCursor: v.optional(v.string()),
179
+ hasMore: v.boolean(),
180
+ total: v.optional(v.number()),
181
+ limit: v.optional(v.number()),
182
+ offset: v.optional(v.number()),
183
+ cursor: v.optional(v.string()),
184
+ q: v.optional(v.string()),
185
+ sort: v.optional(v.string()),
186
+ order: v.optional(v.string()),
187
+ })
188
+
189
+ // 1. LIST procedure
190
+ const list = builder.public
191
+ .route({
192
+ method: 'GET',
193
+ path: `/api/${name}`,
194
+ summary: `List ${name}`,
195
+ tags: [name],
196
+ })
197
+ .input(listQuerySchema)
198
+ .output(listOutputSchema)
199
+ .handler(async ({ input, context }) => {
200
+ const session = await context.getSession()
201
+ const execCtx = {
202
+ request: context.request,
203
+ user: session.user,
204
+ session: { activeOrganizationId: session.activeOrganizationId },
205
+ }
206
+ const result = await operations.list(name, input ?? {}, execCtx)
207
+ return {
208
+ ...result,
209
+ items: result.items as TTable['$inferSelect'][],
210
+ }
211
+ })
212
+
213
+ // 2. GET procedure
214
+ const get = builder.public
215
+ .route({
216
+ method: 'GET',
217
+ path: `/api/${name}/{id}`,
218
+ summary: `Get ${name} by ID`,
219
+ tags: [name],
220
+ })
221
+ .input(v.strictObject({ id: v.string() }))
222
+ .output(selectSchema)
223
+ .handler(async ({ input, context }) => {
224
+ const session = await context.getSession()
225
+ const execCtx = {
226
+ request: context.request,
227
+ user: session.user,
228
+ session: { activeOrganizationId: session.activeOrganizationId },
229
+ }
230
+ return (await operations.get(
231
+ name,
232
+ input.id,
233
+ execCtx,
234
+ )) as TTable['$inferSelect']
235
+ })
236
+
237
+ // 3. CREATE procedure
238
+ const create = builder.public
239
+ .route({
240
+ method: 'POST',
241
+ path: `/api/${name}`,
242
+ summary: `Create ${name}`,
243
+ tags: [name],
244
+ successStatus: 201,
245
+ })
246
+ .input(insertSchema)
247
+ .output(selectSchema)
248
+ .handler(async ({ input, context }) => {
249
+ const session = await context.getSession()
250
+ const execCtx = {
251
+ request: context.request,
252
+ user: session.user,
253
+ session: { activeOrganizationId: session.activeOrganizationId },
254
+ }
255
+ const idempotencyKey = context.request.headers
256
+ .get('Idempotency-Key')
257
+ ?.trim()
258
+ const rawBody = await context.getRawBody()
259
+
260
+ const res = await operations.create(
261
+ name,
262
+ input,
263
+ rawBody,
264
+ idempotencyKey,
265
+ execCtx,
266
+ )
267
+ if (res.type === 'replay') {
268
+ context.resHeaders.set('Idempotency-Replayed', 'true')
269
+ return res.record as TTable['$inferSelect']
270
+ }
271
+ return res.record as TTable['$inferSelect']
272
+ })
273
+
274
+ // 4. UPDATE procedure
275
+ const update = builder.public
276
+ .route({
277
+ method: 'PATCH',
278
+ path: `/api/${name}/{id}`,
279
+ summary: `Update ${name}`,
280
+ tags: [name],
281
+ inputStructure: 'detailed',
282
+ })
283
+ .input(updateInputSchema)
284
+ .output(selectSchema)
285
+ .handler(async ({ input, context }) => {
286
+ const session = await context.getSession()
287
+ const execCtx = {
288
+ request: context.request,
289
+ user: session.user,
290
+ session: { activeOrganizationId: session.activeOrganizationId },
291
+ }
292
+
293
+ return (await operations.update(
294
+ name,
295
+ input.params.id,
296
+ input.body,
297
+ execCtx,
298
+ )) as TTable['$inferSelect']
299
+ })
300
+
301
+ // 5. DELETE procedure
302
+ const deleteProc = builder.public
303
+ .route({
304
+ method: 'DELETE',
305
+ path: `/api/${name}/{id}`,
306
+ summary: `Delete ${name}`,
307
+ tags: [name],
308
+ successStatus: 204,
309
+ })
310
+ .input(v.strictObject({ id: v.string() }))
311
+ .output(v.undefined())
312
+ .handler(async ({ input, context }) => {
313
+ const session = await context.getSession()
314
+ const execCtx = {
315
+ request: context.request,
316
+ user: session.user,
317
+ session: { activeOrganizationId: session.activeOrganizationId },
318
+ }
319
+ await operations.delete(name, input.id, execCtx)
320
+ return undefined
321
+ })
322
+
323
+ return {
324
+ list,
325
+ get,
326
+ create,
327
+ update,
328
+ delete: deleteProc,
329
+ }
330
+ }
331
+
332
+ export type TableCrudProcedures<
333
+ TTable extends Table,
334
+ TFilterable extends string = string,
335
+ TSortable extends string = string,
336
+ > = ReturnType<
337
+ typeof buildTableCrudProcedures<
338
+ Record<string, unknown>,
339
+ TTable,
340
+ TFilterable,
341
+ TSortable
342
+ >
343
+ >
344
+
345
+ export function buildCrudApiRouter<
346
+ TSchema extends Record<string, unknown>,
347
+ TAccess extends Record<string, TableAccessInput> | undefined = undefined,
348
+ >(
349
+ schema: TSchema,
350
+ db: AnyDb,
351
+ options: CrudApiRouterOptions<TSchema>,
352
+ ): CrudApiRouterFor<TSchema, TAccess> {
353
+ const { access, realtime, idempotency } = options
354
+ const builder = createApiBuilder<TSchema>()
355
+ const operations = createCrudOperations({
356
+ schema,
357
+ db,
358
+ access,
359
+ idempotency,
360
+ realtime,
361
+ })
362
+
363
+ const routerObj: Record<string, unknown> = {}
364
+
365
+ for (const [tableKey, table] of Object.entries(schema)) {
366
+ if (!isTable(table)) continue
367
+
368
+ const name = getTableName(table)
369
+ const tableAccess = tableEntryForName(access, name)
370
+ if (!tableAccess?.enabled) continue
371
+
372
+ const idCol = getTableColumns(table)['id']
373
+ if (!idCol) continue
374
+
375
+ const procedures = buildTableCrudProcedures({
376
+ table: table as Table,
377
+ operations,
378
+ builder,
379
+ access: tableAccess,
380
+ })
381
+
382
+ routerObj[tableKey] = procedures
383
+ }
384
+
385
+ return routerObj as CrudApiRouterFor<TSchema, TAccess>
386
+ }
@@ -0,0 +1,184 @@
1
+ const HTTP_METHODS = new Set([
2
+ 'GET',
3
+ 'POST',
4
+ 'PUT',
5
+ 'PATCH',
6
+ 'DELETE',
7
+ 'HEAD',
8
+ 'OPTIONS',
9
+ ])
10
+
11
+ export interface MergeOpenAPISpecsOptions {
12
+ nativeSpec: Record<string, any>
13
+ authSpec?: Record<string, any>
14
+ }
15
+
16
+ export function mergeOpenAPISpecs(
17
+ options: MergeOpenAPISpecsOptions,
18
+ ): Record<string, any> {
19
+ const { nativeSpec, authSpec } = options
20
+
21
+ const merged: Record<string, any> = {
22
+ openapi: nativeSpec.openapi || authSpec?.openapi || '3.1.0',
23
+ info: {
24
+ title: 'Bunderstack API',
25
+ version: '1.0.0',
26
+ ...(nativeSpec.info || {}),
27
+ },
28
+ paths: JSON.parse(JSON.stringify(nativeSpec.paths || {})),
29
+ components: {},
30
+ security: [...(nativeSpec.security || [])],
31
+ tags: [...(nativeSpec.tags || [])],
32
+ }
33
+
34
+ if (authSpec) {
35
+ // Merge paths and check for overwrite collisions
36
+ if (authSpec.paths && typeof authSpec.paths === 'object') {
37
+ for (const [routePath, authPathItem] of Object.entries(authSpec.paths)) {
38
+ if (!authPathItem || typeof authPathItem !== 'object') continue
39
+
40
+ const clonedPathItem = JSON.parse(
41
+ JSON.stringify(authPathItem),
42
+ ) as Record<string, any>
43
+
44
+ // Normalize tags for Better Auth operations (replace generic "Default" with "Auth")
45
+ if (routePath.startsWith('/api/auth')) {
46
+ for (const [methodKey, operation] of Object.entries(clonedPathItem)) {
47
+ if (
48
+ HTTP_METHODS.has(methodKey.toUpperCase()) &&
49
+ operation &&
50
+ typeof operation === 'object'
51
+ ) {
52
+ if (Array.isArray(operation.tags)) {
53
+ operation.tags = operation.tags.map((t: string) =>
54
+ t === 'Default' ? 'Auth' : t,
55
+ )
56
+ if (!operation.tags.includes('Auth')) {
57
+ operation.tags.unshift('Auth')
58
+ }
59
+ } else {
60
+ operation.tags = ['Auth']
61
+ }
62
+ }
63
+ }
64
+ }
65
+
66
+ if (!(routePath in merged.paths)) {
67
+ merged.paths[routePath] = clonedPathItem
68
+ } else {
69
+ const existingPathItem = merged.paths[routePath]
70
+ const incomingPathItem = clonedPathItem
71
+
72
+ for (const [key, authVal] of Object.entries(incomingPathItem)) {
73
+ const upperKey = key.toUpperCase()
74
+ const isOperation = HTTP_METHODS.has(upperKey)
75
+
76
+ if (key in existingPathItem) {
77
+ const existingVal = existingPathItem[key]
78
+ if (JSON.stringify(existingVal) !== JSON.stringify(authVal)) {
79
+ if (isOperation) {
80
+ throw new Error(
81
+ `[bunderstack] OpenAPI path overwrite collision: operation "${upperKey} ${routePath}"`,
82
+ )
83
+ } else {
84
+ throw new Error(
85
+ `[bunderstack] OpenAPI path property collision on "${routePath}": key "${key}"`,
86
+ )
87
+ }
88
+ }
89
+ } else {
90
+ existingPathItem[key] = JSON.parse(JSON.stringify(authVal))
91
+ }
92
+ }
93
+ }
94
+ }
95
+ }
96
+
97
+ // Merge security metadata
98
+ if (Array.isArray(authSpec.security)) {
99
+ for (const sec of authSpec.security) {
100
+ if (
101
+ !merged.security.some(
102
+ (s: any) => JSON.stringify(s) === JSON.stringify(sec),
103
+ )
104
+ ) {
105
+ merged.security.push(sec)
106
+ }
107
+ }
108
+ }
109
+
110
+ // Merge tags
111
+ if (!merged.tags.some((t: any) => t.name === 'Auth')) {
112
+ merged.tags.push({
113
+ name: 'Auth',
114
+ description: 'Authentication and session management',
115
+ })
116
+ }
117
+ if (Array.isArray(authSpec.tags)) {
118
+ for (const tag of authSpec.tags) {
119
+ if (
120
+ tag.name !== 'Default' &&
121
+ !merged.tags.some((t: any) => t.name === tag.name)
122
+ ) {
123
+ merged.tags.push(tag)
124
+ }
125
+ }
126
+ }
127
+ }
128
+
129
+ // Merge components by category
130
+ const categories = new Set([
131
+ ...Object.keys(nativeSpec.components || {}),
132
+ ...Object.keys(authSpec?.components || {}),
133
+ ])
134
+
135
+ for (const category of categories) {
136
+ const nativeCat = nativeSpec.components?.[category] || {}
137
+ const authCat = authSpec?.components?.[category] || {}
138
+
139
+ const mergedCat: Record<string, any> = { ...nativeCat }
140
+
141
+ for (const [key, authVal] of Object.entries(authCat)) {
142
+ if (key in nativeCat) {
143
+ const nativeVal = nativeCat[key]
144
+ if (JSON.stringify(nativeVal) !== JSON.stringify(authVal)) {
145
+ throw new Error(
146
+ `[bunderstack] OpenAPI component collision: category "${category}" component "${key}"`,
147
+ )
148
+ }
149
+ } else {
150
+ mergedCat[key] = authVal
151
+ }
152
+ }
153
+
154
+ merged.components[category] = mergedCat
155
+ }
156
+
157
+ // Ensure all tags used in path operations are declared in merged.tags
158
+ for (const pathItem of Object.values(merged.paths)) {
159
+ if (!pathItem || typeof pathItem !== 'object') continue
160
+ for (const [key, op] of Object.entries(pathItem as Record<string, any>)) {
161
+ if (
162
+ HTTP_METHODS.has(key.toUpperCase()) &&
163
+ op &&
164
+ typeof op === 'object' &&
165
+ Array.isArray(op.tags)
166
+ ) {
167
+ for (const tagName of op.tags) {
168
+ if (
169
+ tagName &&
170
+ tagName !== 'Default' &&
171
+ !merged.tags.some((t: any) => t.name === tagName)
172
+ ) {
173
+ merged.tags.push({
174
+ name: tagName,
175
+ description: `${tagName} operations`,
176
+ })
177
+ }
178
+ }
179
+ }
180
+ }
181
+ }
182
+
183
+ return merged
184
+ }
@@ -0,0 +1,75 @@
1
+ import { eventIterator } from '@orpc/server'
2
+ import '@orpc/openapi/extensions/route'
3
+ import * as v from 'valibot'
4
+
5
+ import type { ResolvedAccess } from '../access'
6
+ import type { RealtimePublisher } from '../realtime/publisher'
7
+
8
+ import { filterRealtimeChanges } from '../realtime/filter'
9
+ import { withRealtimeHeartbeat } from '../realtime/heartbeat'
10
+ import { createApiBuilder } from './builder'
11
+
12
+ const tablesSchema = v.pipe(
13
+ v.union([v.string(), v.array(v.string())]),
14
+ v.transform((value) => (Array.isArray(value) ? value : [value])),
15
+ )
16
+
17
+ const changeSchema = v.strictObject({
18
+ table: v.string(),
19
+ action: v.picklist(['create', 'update', 'delete']),
20
+ record: v.record(v.string(), v.unknown()),
21
+ })
22
+
23
+ const heartbeatSchema = v.strictObject({
24
+ type: v.literal('heartbeat'),
25
+ })
26
+
27
+ type RealtimeRouterOptions = {
28
+ heartbeatMs?: number
29
+ }
30
+
31
+ export function buildRealtimeApiRouter(
32
+ publisher: RealtimePublisher | undefined,
33
+ access: ResolvedAccess,
34
+ options: RealtimeRouterOptions = {},
35
+ ) {
36
+ if (!publisher) return undefined
37
+ const builder = createApiBuilder<
38
+ Record<string, unknown>,
39
+ Record<string, unknown>
40
+ >()
41
+
42
+ const changes = builder.public
43
+ .route({
44
+ method: 'GET',
45
+ path: '/api/realtime',
46
+ summary: 'Subscribe to realtime changes',
47
+ tags: ['realtime'],
48
+ queryStyles: { tables: 'array' },
49
+ })
50
+ .input(v.strictObject({ tables: tablesSchema }))
51
+ .output(eventIterator(v.union([changeSchema, heartbeatSchema])))
52
+ .handler(({ input, context, signal, lastEventId }) =>
53
+ withRealtimeHeartbeat(
54
+ filterRealtimeChanges(
55
+ publisher.subscribe('change', { signal, lastEventId }),
56
+ {
57
+ subscriptions: input.tables,
58
+ access,
59
+ request: context.request,
60
+ getSession: context.getSession,
61
+ },
62
+ ),
63
+ {
64
+ intervalMs: options.heartbeatMs,
65
+ signal,
66
+ },
67
+ ),
68
+ )
69
+
70
+ return { realtime: { changes } }
71
+ }
72
+
73
+ export type RealtimeApiRouter = NonNullable<
74
+ ReturnType<typeof buildRealtimeApiRouter>
75
+ >