bunderstack 0.16.0 → 0.17.0-beta.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,321 @@
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 { createApiBuilder } from './builder'
23
+
24
+ export type CrudApiRouterOptions<
25
+ TSchema extends Record<string, unknown> = Record<string, unknown>,
26
+ > = {
27
+ access: ResolvedAccess
28
+ idempotency?: boolean | IdempotencyConfig
29
+ realtime?: RealtimeFacade<TSchema>
30
+ }
31
+
32
+ function strictObject<TEntries extends v.ObjectEntries>(schema: {
33
+ entries: TEntries
34
+ }) {
35
+ return v.strictObject(schema.entries)
36
+ }
37
+
38
+ type CrudInsert<TTable extends Table> = Partial<TTable['$inferInsert']>
39
+
40
+ export type BuildTableCrudProceduresArgs<
41
+ TSchema extends Record<string, unknown>,
42
+ TTable extends Table,
43
+ > = {
44
+ table: TTable
45
+ operations: CrudOperations
46
+ builder: ReturnType<typeof createApiBuilder<TSchema>>
47
+ access: ResolvedTableAccess
48
+ }
49
+
50
+ export function buildTableCrudProcedures<
51
+ TSchema extends Record<string, unknown>,
52
+ TTable extends Table,
53
+ >(args: BuildTableCrudProceduresArgs<TSchema, TTable>) {
54
+ const { table, operations, builder, access } = args
55
+ const name = getTableName(table)
56
+
57
+ const selectSchema = strictObject(createSelectSchema(table))
58
+ const generatedInsertSchema = strictObject(createInsertSchema(table))
59
+ const generatedColumns = Object.keys(generatedInsertSchema.entries)
60
+ const columns = getTableColumns(table)
61
+ const serverManagedColumns = [
62
+ ...access.readonlyColumns,
63
+ ...(access.writeScope && generatedColumns.includes('organizationId')
64
+ ? ['organizationId']
65
+ : []),
66
+ ...Object.entries(columns)
67
+ .filter(([, column]) => !column.notNull || column.hasDefault)
68
+ .map(([column]) => column),
69
+ ].filter((column) => generatedColumns.includes(column))
70
+ const insertEntries: Record<string, v.BaseSchema<unknown, unknown, v.BaseIssue<unknown>>> = {
71
+ ...generatedInsertSchema.entries,
72
+ }
73
+ for (const column of serverManagedColumns) {
74
+ const schema = insertEntries[column]
75
+ if (schema) insertEntries[column] = v.optional(schema)
76
+ }
77
+ const insertSchema = v.strictObject(insertEntries) as unknown as v.GenericSchema<
78
+ CrudInsert<TTable>,
79
+ CrudInsert<TTable>
80
+ >
81
+ const generatedUpdateSchema = createUpdateSchema(table)
82
+ const updateBodySchema = v.omit(generatedUpdateSchema, [
83
+ 'id' as keyof typeof generatedUpdateSchema.entries,
84
+ ])
85
+ const updateInputSchema = v.strictObject({
86
+ params: v.strictObject({ id: v.string() }),
87
+ query: v.optional(v.record(v.string(), v.unknown()), {}),
88
+ headers: v.optional(v.record(v.string(), v.unknown()), {}),
89
+ body: strictObject(updateBodySchema),
90
+ })
91
+
92
+ const listQuerySchema = v.optional(
93
+ v.strictObject({
94
+ limit: v.optional(
95
+ v.pipe(
96
+ v.union([v.string(), v.number()]),
97
+ v.transform(Number),
98
+ v.number(),
99
+ ),
100
+ ),
101
+ offset: v.optional(v.number()),
102
+ cursor: v.optional(v.string()),
103
+ sort: v.optional(v.string()),
104
+ order: v.optional(v.picklist(['asc', 'desc'])),
105
+ q: v.optional(v.string()),
106
+ count: v.optional(v.boolean()),
107
+ filters: v.optional(v.record(v.string(), v.unknown())),
108
+ }),
109
+ )
110
+
111
+ const listOutputSchema = v.strictObject({
112
+ items: v.array(selectSchema),
113
+ nextCursor: v.optional(v.string()),
114
+ hasMore: v.boolean(),
115
+ total: v.optional(v.number()),
116
+ limit: v.optional(v.number()),
117
+ offset: v.optional(v.number()),
118
+ cursor: v.optional(v.string()),
119
+ q: v.optional(v.string()),
120
+ sort: v.optional(v.string()),
121
+ order: v.optional(v.string()),
122
+ })
123
+
124
+ // 1. LIST procedure
125
+ const list = builder.public
126
+ .route({
127
+ method: 'GET',
128
+ path: `/api/${name}`,
129
+ summary: `List ${name}`,
130
+ tags: [name],
131
+ })
132
+ .input(listQuerySchema)
133
+ .output(listOutputSchema)
134
+ .handler(async ({ input, context }) => {
135
+ const session = await context.getSession()
136
+ const execCtx = {
137
+ request: context.request,
138
+ user: session.user,
139
+ session: { activeOrganizationId: session.activeOrganizationId },
140
+ }
141
+ const { filters, count, ...query } = input ?? {}
142
+ const result = await operations.list(
143
+ name,
144
+ {
145
+ ...query,
146
+ ...(filters ?? {}),
147
+ ...(count === undefined ? {} : { count: String(count) }),
148
+ },
149
+ execCtx,
150
+ )
151
+ return {
152
+ ...result,
153
+ items: result.items as TTable['$inferSelect'][],
154
+ }
155
+ })
156
+
157
+ // 2. GET procedure
158
+ const get = builder.public
159
+ .route({
160
+ method: 'GET',
161
+ path: `/api/${name}/{id}`,
162
+ summary: `Get ${name} by ID`,
163
+ tags: [name],
164
+ })
165
+ .input(v.strictObject({ id: v.string() }))
166
+ .output(selectSchema)
167
+ .handler(async ({ input, context }) => {
168
+ const session = await context.getSession()
169
+ const execCtx = {
170
+ request: context.request,
171
+ user: session.user,
172
+ session: { activeOrganizationId: session.activeOrganizationId },
173
+ }
174
+ return (await operations.get(
175
+ name,
176
+ input.id,
177
+ execCtx,
178
+ )) as TTable['$inferSelect']
179
+ })
180
+
181
+ // 3. CREATE procedure
182
+ const create = builder.public
183
+ .route({
184
+ method: 'POST',
185
+ path: `/api/${name}`,
186
+ summary: `Create ${name}`,
187
+ tags: [name],
188
+ successStatus: 201,
189
+ })
190
+ .input(insertSchema)
191
+ .output(selectSchema)
192
+ .handler(async ({ input, context }) => {
193
+ const session = await context.getSession()
194
+ const execCtx = {
195
+ request: context.request,
196
+ user: session.user,
197
+ session: { activeOrganizationId: session.activeOrganizationId },
198
+ }
199
+ const idempotencyKey = context.request.headers
200
+ .get('Idempotency-Key')
201
+ ?.trim()
202
+ const rawBody = await context.getRawBody()
203
+
204
+ const res = await operations.create(
205
+ name,
206
+ input,
207
+ rawBody,
208
+ idempotencyKey,
209
+ execCtx,
210
+ )
211
+ if (res.type === 'replay') {
212
+ context.resHeaders.set('Idempotency-Replayed', 'true')
213
+ return res.record as TTable['$inferSelect']
214
+ }
215
+ return res.record as TTable['$inferSelect']
216
+ })
217
+
218
+ // 4. UPDATE procedure
219
+ const update = builder.public
220
+ .route({
221
+ method: 'PATCH',
222
+ path: `/api/${name}/{id}`,
223
+ summary: `Update ${name}`,
224
+ tags: [name],
225
+ inputStructure: 'detailed',
226
+ })
227
+ .input(updateInputSchema)
228
+ .output(selectSchema)
229
+ .handler(async ({ input, context }) => {
230
+ const session = await context.getSession()
231
+ const execCtx = {
232
+ request: context.request,
233
+ user: session.user,
234
+ session: { activeOrganizationId: session.activeOrganizationId },
235
+ }
236
+
237
+ return (await operations.update(
238
+ name,
239
+ input.params.id,
240
+ input.body,
241
+ execCtx,
242
+ )) as TTable['$inferSelect']
243
+ })
244
+
245
+ // 5. DELETE procedure
246
+ const deleteProc = builder.public
247
+ .route({
248
+ method: 'DELETE',
249
+ path: `/api/${name}/{id}`,
250
+ summary: `Delete ${name}`,
251
+ tags: [name],
252
+ successStatus: 204,
253
+ })
254
+ .input(v.strictObject({ id: v.string() }))
255
+ .output(v.undefined())
256
+ .handler(async ({ input, context }) => {
257
+ const session = await context.getSession()
258
+ const execCtx = {
259
+ request: context.request,
260
+ user: session.user,
261
+ session: { activeOrganizationId: session.activeOrganizationId },
262
+ }
263
+ await operations.delete(name, input.id, execCtx)
264
+ return undefined
265
+ })
266
+
267
+ return {
268
+ list,
269
+ get,
270
+ create,
271
+ update,
272
+ delete: deleteProc,
273
+ }
274
+ }
275
+
276
+ export type TableCrudProcedures<TTable extends Table> = ReturnType<
277
+ typeof buildTableCrudProcedures<Record<string, unknown>, TTable>
278
+ >
279
+
280
+ export function buildCrudApiRouter<
281
+ TSchema extends Record<string, unknown>,
282
+ TAccess extends Record<string, TableAccessInput> | undefined = undefined,
283
+ >(
284
+ schema: TSchema,
285
+ db: AnyDb,
286
+ options: CrudApiRouterOptions<TSchema>,
287
+ ): CrudApiRouterFor<TSchema, TAccess> {
288
+ const { access, realtime, idempotency } = options
289
+ const builder = createApiBuilder<TSchema>()
290
+ const operations = createCrudOperations({
291
+ schema,
292
+ db,
293
+ access,
294
+ idempotency,
295
+ realtime,
296
+ })
297
+
298
+ const routerObj: Record<string, unknown> = {}
299
+
300
+ for (const [tableKey, table] of Object.entries(schema)) {
301
+ if (!isTable(table)) continue
302
+
303
+ const name = getTableName(table)
304
+ const tableAccess = tableEntryForName(access, name)
305
+ if (!tableAccess?.enabled) continue
306
+
307
+ const idCol = getTableColumns(table)['id']
308
+ if (!idCol) continue
309
+
310
+ const procedures = buildTableCrudProcedures({
311
+ table: table as Table,
312
+ operations,
313
+ builder,
314
+ access: tableAccess,
315
+ })
316
+
317
+ routerObj[tableKey] = procedures
318
+ }
319
+
320
+ return routerObj as CrudApiRouterFor<TSchema, TAccess>
321
+ }
@@ -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
+ >