bunderstack 0.15.2 → 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.
Files changed (47) hide show
  1. package/README.md +25 -138
  2. package/package.json +22 -14
  3. package/src/access.ts +24 -1
  4. package/src/api/api-types.types.ts +106 -0
  5. package/src/api/builder.ts +52 -0
  6. package/src/api/context.ts +83 -0
  7. package/src/api/crud-router.ts +321 -0
  8. package/src/api/openapi.ts +184 -0
  9. package/src/api/realtime-router.ts +75 -0
  10. package/src/api/registry.ts +338 -0
  11. package/src/api/router.ts +34 -0
  12. package/src/api/storage-router.ts +224 -0
  13. package/src/api/types.ts +84 -0
  14. package/src/auth.ts +5 -0
  15. package/src/blueprint.ts +88 -105
  16. package/src/config.ts +73 -77
  17. package/src/cron.ts +2 -1
  18. package/src/crud-operations.ts +488 -0
  19. package/src/dialect.ts +1 -1
  20. package/src/env.ts +28 -21
  21. package/src/errors.ts +90 -23
  22. package/src/handler.ts +16 -44
  23. package/src/index.ts +283 -294
  24. package/src/internal-tables-pg.ts +1 -17
  25. package/src/internal-tables.ts +0 -31
  26. package/src/jobs/define.ts +75 -21
  27. package/src/jobs/index.ts +3 -9
  28. package/src/jobs/queue.ts +14 -6
  29. package/src/jobs/slots.ts +52 -0
  30. package/src/jobs/worker.ts +142 -42
  31. package/src/manifest.ts +84 -93
  32. package/src/realtime/facade.ts +16 -13
  33. package/src/realtime/filter.ts +77 -0
  34. package/src/realtime/heartbeat.ts +80 -0
  35. package/src/realtime/publisher.ts +46 -0
  36. package/src/standard-schema.ts +59 -0
  37. package/src/storage/index.ts +8 -0
  38. package/src/storage/operations.ts +398 -0
  39. package/src/crud.ts +0 -408
  40. package/src/jobs/cron-auth.ts +0 -28
  41. package/src/jobs/cron-router.ts +0 -135
  42. package/src/jobs/cron-runner.ts +0 -224
  43. package/src/jobs/local-cron.ts +0 -78
  44. package/src/realtime/index.ts +0 -250
  45. package/src/realtime/redis.ts +0 -228
  46. package/src/storage/router.ts +0 -531
  47. package/src/trpc.ts +0 -57
@@ -0,0 +1,338 @@
1
+ import { getOpenAPIMeta } from '@orpc/openapi'
2
+
3
+ const RESERVED_EXACT = new Set([
4
+ '/api/health',
5
+ '/api/openapi.json',
6
+ '/api/realtime',
7
+ '/api/rpc',
8
+ '/api/auth',
9
+ '/api/files',
10
+ ])
11
+ const RESERVED_PREFIXES = ['/api/rpc/', '/api/auth/', '/api/files/']
12
+
13
+ function collisionForBunderstackPath(path: string): string | undefined {
14
+ if (RESERVED_EXACT.has(path)) return 'it is reserved by bunderstack'
15
+ const prefix = RESERVED_PREFIXES.find((value) => path.startsWith(value))
16
+ return prefix ? `"${prefix}*" is reserved by bunderstack` : undefined
17
+ }
18
+
19
+ export interface ApiRegistryEntry {
20
+ handle: string
21
+ operationId: string
22
+ method: string
23
+ path: string
24
+ source: string
25
+ }
26
+
27
+ export interface ApiRegistry {
28
+ entries: ApiRegistryEntry[]
29
+ }
30
+
31
+ export interface ForeignSpecEntry {
32
+ spec: Record<string, unknown>
33
+ prefix?: string
34
+ source?: string
35
+ }
36
+
37
+ export interface BuildApiRegistryOptions {
38
+ nativeRouter?: Record<string, unknown>
39
+ foreignSpecs?: Array<Record<string, unknown> | ForeignSpecEntry>
40
+ /** Native procedures generated by Bunderstack itself may own reserved paths. */
41
+ reservedCoreHandles?: ReadonlySet<string>
42
+ }
43
+
44
+ const HTTP_METHODS = new Set([
45
+ 'GET',
46
+ 'POST',
47
+ 'PUT',
48
+ 'PATCH',
49
+ 'DELETE',
50
+ 'HEAD',
51
+ 'OPTIONS',
52
+ ])
53
+
54
+ export function normalizeApiPath(path: string, prefix?: string): string {
55
+ let cleanPath = path.startsWith('/') ? path : '/' + path
56
+ if (prefix) {
57
+ let cleanPrefix = prefix.startsWith('/') ? prefix : '/' + prefix
58
+ if (cleanPrefix.endsWith('/')) {
59
+ cleanPrefix = cleanPrefix.slice(0, -1)
60
+ }
61
+ if (cleanPath === cleanPrefix || cleanPath.startsWith(cleanPrefix + '/')) {
62
+ // Path already starts with prefix
63
+ } else {
64
+ cleanPath = cleanPrefix + cleanPath
65
+ }
66
+ }
67
+ return cleanPath.replace(/\/+/g, '/')
68
+ }
69
+
70
+ export interface ForeignSpecOptions {
71
+ prefix?: string
72
+ source?: string
73
+ }
74
+
75
+ export function normalizeForeignOpenAPISpec(
76
+ spec: Record<string, unknown>,
77
+ options: ForeignSpecOptions = {},
78
+ ): Record<string, unknown> {
79
+ const prefix = options.prefix
80
+ const source = options.source
81
+ const cloned: Record<string, unknown> = JSON.parse(JSON.stringify(spec))
82
+
83
+ if (cloned.paths && typeof cloned.paths === 'object') {
84
+ const normalizedPaths: Record<string, unknown> = {}
85
+ for (const [routePath, pathItem] of Object.entries(
86
+ cloned.paths as Record<string, unknown>,
87
+ )) {
88
+ const canonicalPath = normalizeApiPath(routePath, prefix)
89
+ normalizedPaths[canonicalPath] = pathItem
90
+ }
91
+ cloned.paths = normalizedPaths
92
+ }
93
+
94
+ if (source) {
95
+ cloned['x-bunderstack-source'] = source
96
+ }
97
+
98
+ return cloned
99
+ }
100
+
101
+ export function mergeApiRoutersStrict(
102
+ target: Record<string, unknown>,
103
+ source?: Record<string, unknown>,
104
+ prefix: string[] = [],
105
+ ): Record<string, unknown> {
106
+ if (!source) return { ...target }
107
+ const result: Record<string, unknown> = { ...target }
108
+
109
+ for (const [key, val] of Object.entries(source)) {
110
+ const currentPath = [...prefix, key]
111
+ const handle = currentPath.join('.')
112
+ const targetVal = result[key]
113
+
114
+ if (targetVal !== undefined) {
115
+ const isTargetNamespace =
116
+ typeof targetVal === 'object' &&
117
+ targetVal !== null &&
118
+ !('~orpc' in targetVal)
119
+
120
+ const isSourceNamespace =
121
+ typeof val === 'object' &&
122
+ val !== null &&
123
+ !('~orpc' in val)
124
+
125
+ if (isTargetNamespace && isSourceNamespace) {
126
+ result[key] = mergeApiRoutersStrict(
127
+ targetVal as Record<string, unknown>,
128
+ val as Record<string, unknown>,
129
+ currentPath,
130
+ )
131
+ } else {
132
+ throw new Error(
133
+ `[bunderstack] Router merge collision at handle "${handle}": duplicate procedure or namespace collision`,
134
+ )
135
+ }
136
+ } else {
137
+ result[key] = val
138
+ }
139
+ }
140
+
141
+ return result
142
+ }
143
+
144
+ function normalizePathSegments(path: string): string[] {
145
+ return path.split('/').filter(Boolean)
146
+ }
147
+
148
+ function isParamSegment(segment: string): boolean {
149
+ return (
150
+ (segment.startsWith('{') && segment.endsWith('}')) ||
151
+ segment.startsWith(':')
152
+ )
153
+ }
154
+
155
+ function normalizePathForCollision(path: string): string {
156
+ const segments = normalizePathSegments(path)
157
+ const normalized = segments.map((seg) =>
158
+ isParamSegment(seg) ? '{param}' : seg,
159
+ )
160
+ return '/' + normalized.join('/')
161
+ }
162
+
163
+ function walkNativeRouter(
164
+ obj: Record<string, unknown>,
165
+ pathSegments: string[] = [],
166
+ ): ApiRegistryEntry[] {
167
+ const entries: ApiRegistryEntry[] = []
168
+
169
+ for (const [key, value] of Object.entries(obj)) {
170
+ if (!value || typeof value !== 'object') continue
171
+ const currentSegments = [...pathSegments, key]
172
+
173
+ if ('~orpc' in value) {
174
+ const meta = (getOpenAPIMeta(value as any) || {}) as Record<string, unknown>
175
+ const handle = currentSegments.join('.')
176
+ const method = ((meta.method as string) || 'GET').toUpperCase()
177
+ const routePath = (meta.path as string) || '/' + currentSegments.join('/')
178
+ const operationId =
179
+ typeof meta.operationId === 'string' ? meta.operationId : handle
180
+
181
+ entries.push({
182
+ handle,
183
+ operationId,
184
+ method,
185
+ path: routePath,
186
+ source: 'native',
187
+ })
188
+ } else {
189
+ entries.push(
190
+ ...walkNativeRouter(
191
+ value as Record<string, unknown>,
192
+ currentSegments,
193
+ ),
194
+ )
195
+ }
196
+ }
197
+
198
+ return entries
199
+ }
200
+
201
+ function extractForeignEntries(
202
+ specs: Array<Record<string, unknown> | ForeignSpecEntry>,
203
+ ): ApiRegistryEntry[] {
204
+ const entries: ApiRegistryEntry[] = []
205
+
206
+ for (const item of specs) {
207
+ if (!item) continue
208
+ let specObj: Record<string, unknown>
209
+ let source = 'foreign'
210
+
211
+ if ('spec' in item && typeof item.spec === 'object' && item.spec !== null) {
212
+ const entry = item as ForeignSpecEntry
213
+ source = entry.source || 'foreign'
214
+ specObj = normalizeForeignOpenAPISpec(entry.spec, {
215
+ prefix: entry.prefix,
216
+ source,
217
+ })
218
+ } else {
219
+ const rawSpec = item as Record<string, unknown>
220
+ source = (rawSpec['x-bunderstack-source'] as string) || 'foreign'
221
+ specObj = normalizeForeignOpenAPISpec(rawSpec, { source })
222
+ }
223
+
224
+ if (!specObj || typeof specObj.paths !== 'object' || !specObj.paths) continue
225
+ const paths = specObj.paths as Record<string, Record<string, unknown>>
226
+
227
+ for (const [routePath, pathItem] of Object.entries(paths)) {
228
+ if (!pathItem || typeof pathItem !== 'object') continue
229
+
230
+ for (const [methodKey, operation] of Object.entries(pathItem)) {
231
+ const uppercaseMethod = methodKey.toUpperCase()
232
+ if (!HTTP_METHODS.has(uppercaseMethod)) continue
233
+ if (!operation || typeof operation !== 'object') continue
234
+
235
+ const op = operation as Record<string, unknown>
236
+ const handle = `foreign:${source}:${uppercaseMethod}:${routePath}`
237
+ const operationId =
238
+ typeof op.operationId === 'string'
239
+ ? op.operationId
240
+ : handle
241
+
242
+ entries.push({
243
+ handle,
244
+ operationId,
245
+ method: uppercaseMethod,
246
+ path: routePath,
247
+ source,
248
+ })
249
+ }
250
+ }
251
+ }
252
+
253
+ return entries
254
+ }
255
+
256
+ export async function buildApiRegistry(
257
+ options: BuildApiRegistryOptions = {},
258
+ ): Promise<ApiRegistry> {
259
+ const entries: ApiRegistryEntry[] = []
260
+
261
+ if (options.nativeRouter) {
262
+ entries.push(...walkNativeRouter(options.nativeRouter))
263
+ }
264
+
265
+ if (options.foreignSpecs) {
266
+ entries.push(...extractForeignEntries(options.foreignSpecs))
267
+ }
268
+
269
+ const errors: string[] = []
270
+
271
+ for (const entry of entries) {
272
+ if (entry.source !== 'native') continue
273
+ if (options.reservedCoreHandles?.has(entry.handle)) continue
274
+ const reason = collisionForBunderstackPath(entry.path)
275
+ if (reason) {
276
+ errors.push(
277
+ `Reserved route collision on ${entry.method} ${entry.path}: native handle "${entry.handle}" conflicts because ${reason}`,
278
+ )
279
+ }
280
+ }
281
+
282
+ // Check duplicate handles
283
+ const handleMap = new Map<string, ApiRegistryEntry>()
284
+ for (const entry of entries) {
285
+ const existing = handleMap.get(entry.handle)
286
+ if (existing) {
287
+ errors.push(
288
+ `Duplicate handle "${entry.handle}": collision between ${existing.source} (${existing.method} ${existing.path}) and ${entry.source} (${entry.method} ${entry.path})`,
289
+ )
290
+ } else {
291
+ handleMap.set(entry.handle, entry)
292
+ }
293
+ }
294
+
295
+ // Check duplicate operation IDs
296
+ const opIdMap = new Map<string, ApiRegistryEntry>()
297
+ for (const entry of entries) {
298
+ const existing = opIdMap.get(entry.operationId)
299
+ if (existing) {
300
+ errors.push(
301
+ `Duplicate operation ID "${entry.operationId}": collision between ${existing.source} (${existing.handle}) and ${entry.source} (${entry.handle})`,
302
+ )
303
+ } else {
304
+ opIdMap.set(entry.operationId, entry)
305
+ }
306
+ }
307
+
308
+ // Check method/path collisions (exact and parameter ambiguity)
309
+ const routePatternMap = new Map<string, ApiRegistryEntry>()
310
+ for (const entry of entries) {
311
+ const normalizedPath = normalizePathForCollision(entry.path)
312
+ const routeKey = `${entry.method} ${normalizedPath}`
313
+
314
+ const existing = routePatternMap.get(routeKey)
315
+ if (existing) {
316
+ if (existing.path === entry.path) {
317
+ errors.push(
318
+ `Exact method/path collision on ${entry.method} ${entry.path}: collision between ${existing.source} (${existing.handle}) and ${entry.source} (${entry.handle})`,
319
+ )
320
+ } else {
321
+ errors.push(
322
+ `Ambiguous parameter path collision on ${entry.method} (${existing.path} vs ${entry.path}): collision between ${existing.source} (${existing.handle}) and ${entry.source} (${entry.handle})`,
323
+ )
324
+ }
325
+ } else {
326
+ routePatternMap.set(routeKey, entry)
327
+ }
328
+ }
329
+
330
+ if (errors.length > 0) {
331
+ throw new Error(
332
+ `[bunderstack] Route registry validation failed (${errors.length} error(s)):\n` +
333
+ errors.join('\n'),
334
+ )
335
+ }
336
+
337
+ return { entries }
338
+ }
@@ -0,0 +1,34 @@
1
+ import '@orpc/openapi/extensions/route'
2
+ import * as v from 'valibot'
3
+
4
+ import { createApiBuilder } from './builder'
5
+ import { mergeApiRoutersStrict } from './registry'
6
+
7
+ export interface BuildApiRouterOptions {
8
+ crud: Record<string, unknown>
9
+ storage: Record<string, unknown>
10
+ realtime?: Record<string, unknown>
11
+ custom?: Record<string, unknown>
12
+ }
13
+
14
+ export function buildApiRouter(options: BuildApiRouterOptions) {
15
+ const builder = createApiBuilder<
16
+ Record<string, unknown>,
17
+ Record<string, unknown>
18
+ >()
19
+ const health = builder.public
20
+ .route({ method: 'GET', path: '/api/health', tags: ['system'] })
21
+ .output(v.strictObject({ status: v.literal('ok') }))
22
+ .handler(() => ({ status: 'ok' as const }))
23
+
24
+ return [
25
+ { health },
26
+ options.crud,
27
+ options.storage,
28
+ options.realtime,
29
+ options.custom,
30
+ ].reduce<Record<string, unknown>>(
31
+ (router, addition) => mergeApiRoutersStrict(router, addition),
32
+ {},
33
+ )
34
+ }
@@ -0,0 +1,224 @@
1
+ import '@orpc/openapi/extensions/route'
2
+ import * as v from 'valibot'
3
+
4
+ import type {
5
+ StorageExecutionContext,
6
+ StorageOperations,
7
+ } from '../storage/operations'
8
+ import type { BucketStorageRegistry } from '../storage/registry'
9
+ import type { ApiContext } from './context'
10
+
11
+ import { createApiBuilder } from './builder'
12
+
13
+ const uploadResultSchema = v.strictObject({
14
+ fileId: v.string(),
15
+ url: v.string(),
16
+ })
17
+
18
+ const prepareUploadSchema = v.variant('mode', [
19
+ v.strictObject({
20
+ mode: v.literal('proxy'),
21
+ uploadUrl: v.string(),
22
+ }),
23
+ v.strictObject({
24
+ mode: v.literal('presign'),
25
+ fileId: v.string(),
26
+ uploadUrl: v.string(),
27
+ method: v.literal('PUT'),
28
+ confirmUrl: v.string(),
29
+ }),
30
+ ])
31
+
32
+ const responseHeadersSchema = v.record(
33
+ v.string(),
34
+ v.union([v.string(), v.array(v.string()), v.undefined()]),
35
+ )
36
+
37
+ async function executionContext(
38
+ context: ApiContext<Record<string, unknown>, Record<string, unknown>>,
39
+ ): Promise<StorageExecutionContext> {
40
+ const session = await context.getSession()
41
+ return {
42
+ request: context.request,
43
+ user: session.user,
44
+ session: { activeOrganizationId: session.activeOrganizationId },
45
+ }
46
+ }
47
+
48
+ function headersRecord(headers: Headers): Record<string, string> {
49
+ const result: Record<string, string> = {}
50
+ headers.forEach((value, key) => {
51
+ result[key] = value
52
+ })
53
+ return result
54
+ }
55
+
56
+ function buildBucketProcedures(
57
+ name: string,
58
+ operations: StorageOperations,
59
+ builder: ReturnType<
60
+ typeof createApiBuilder<Record<string, unknown>, Record<string, unknown>>
61
+ >,
62
+ ) {
63
+ const prepareUpload = builder.public
64
+ .route({
65
+ method: 'POST',
66
+ path: `/api/files/${name}/presign`,
67
+ summary: `Prepare upload to ${name}`,
68
+ tags: ['files'],
69
+ })
70
+ .input(
71
+ v.strictObject({
72
+ filename: v.optional(v.string()),
73
+ contentType: v.optional(v.string()),
74
+ }),
75
+ )
76
+ .output(prepareUploadSchema)
77
+ .handler(({ input, context }) =>
78
+ executionContext(context).then((exec) =>
79
+ operations.prepareUpload(name, input, exec),
80
+ ),
81
+ )
82
+
83
+ const upload = builder.public
84
+ .route({
85
+ method: 'POST',
86
+ path: `/api/files/${name}`,
87
+ summary: `Upload to ${name}`,
88
+ tags: ['files'],
89
+ inputStructure: 'detailed',
90
+ outputStructure: 'detailed',
91
+ requestBodyHint: 'form-data',
92
+ })
93
+ .input(
94
+ v.strictObject({
95
+ params: v.optional(v.strictObject({}), {}),
96
+ query: v.optional(v.record(v.string(), v.unknown()), {}),
97
+ headers: v.optional(v.record(v.string(), v.unknown()), {}),
98
+ body: v.strictObject({ file: v.file() }),
99
+ }),
100
+ )
101
+ .output(
102
+ v.strictObject({
103
+ status: v.literal(201),
104
+ body: uploadResultSchema,
105
+ }),
106
+ )
107
+ .handler(async ({ input, context }) => {
108
+ const result = await operations.upload(
109
+ name,
110
+ input.body.file,
111
+ await executionContext(context),
112
+ )
113
+ return {
114
+ status: result.status,
115
+ body: { fileId: result.fileId, url: result.url },
116
+ }
117
+ })
118
+
119
+ const confirmUpload = builder.public
120
+ .route({
121
+ method: 'POST',
122
+ path: `/api/files/${name}/{id}/confirm`,
123
+ summary: `Confirm upload to ${name}`,
124
+ tags: ['files'],
125
+ })
126
+ .input(v.strictObject({ id: v.string() }))
127
+ .output(uploadResultSchema)
128
+ .handler(({ input, context }) =>
129
+ executionContext(context).then((exec) =>
130
+ operations.confirmUpload(name, input.id, exec),
131
+ ),
132
+ )
133
+
134
+ const download = builder.public
135
+ .route({
136
+ method: 'GET',
137
+ path: `/api/files/${name}/{+path}`,
138
+ summary: `Download from ${name}`,
139
+ tags: ['files'],
140
+ inputStructure: 'detailed',
141
+ outputStructure: 'detailed',
142
+ })
143
+ .input(
144
+ v.strictObject({
145
+ params: v.strictObject({ path: v.string() }),
146
+ query: v.optional(v.record(v.string(), v.string()), {}),
147
+ headers: v.optional(v.record(v.string(), v.unknown()), {}),
148
+ body: v.optional(v.undefined()),
149
+ }),
150
+ )
151
+ .output(
152
+ v.union([
153
+ v.strictObject({
154
+ status: v.literal(200),
155
+ headers: responseHeadersSchema,
156
+ body: v.unknown(),
157
+ }),
158
+ v.strictObject({
159
+ status: v.literal(302),
160
+ headers: responseHeadersSchema,
161
+ body: v.optional(v.undefined()),
162
+ }),
163
+ ]),
164
+ )
165
+ .handler(async ({ input, context }) => {
166
+ const result = await operations.download(
167
+ name,
168
+ input.params.path,
169
+ input.query,
170
+ await executionContext(context),
171
+ )
172
+ if (result.kind === 'redirect') {
173
+ return {
174
+ status: 302 as const,
175
+ headers: { Location: result.url },
176
+ body: undefined,
177
+ }
178
+ }
179
+ return {
180
+ status: 200 as const,
181
+ headers: headersRecord(result.headers),
182
+ body: result.body,
183
+ }
184
+ })
185
+
186
+ const deleteFile = builder.public
187
+ .route({
188
+ method: 'DELETE',
189
+ path: `/api/files/${name}/{+path}`,
190
+ summary: `Delete from ${name}`,
191
+ tags: ['files'],
192
+ successStatus: 204,
193
+ })
194
+ .input(v.strictObject({ path: v.string() }))
195
+ .output(v.undefined())
196
+ .handler(async ({ input, context }) => {
197
+ await operations.delete(
198
+ name,
199
+ input.path,
200
+ await executionContext(context),
201
+ )
202
+ return undefined
203
+ })
204
+
205
+ return { prepareUpload, upload, confirmUpload, download, delete: deleteFile }
206
+ }
207
+
208
+ export function buildStorageApiRouter(
209
+ registry: BucketStorageRegistry,
210
+ operations: StorageOperations,
211
+ ) {
212
+ const builder = createApiBuilder<
213
+ Record<string, unknown>,
214
+ Record<string, unknown>
215
+ >()
216
+ return {
217
+ files: Object.fromEntries(
218
+ [...registry.keys()].map((name) => [
219
+ name,
220
+ buildBucketProcedures(name, operations, builder),
221
+ ]),
222
+ ),
223
+ }
224
+ }
@@ -0,0 +1,84 @@
1
+ import type { AnyRouter } from '@orpc/server'
2
+ import type { Table } from 'drizzle-orm'
3
+
4
+ import type { AccessUser } from '../access'
5
+ import type { TableCrudProcedures } from './crud-router'
6
+
7
+ export interface ProtectedContextAdditions {
8
+ user: AccessUser
9
+ session: {
10
+ activeOrganizationId: string | null
11
+ }
12
+ }
13
+
14
+ type AuthTableName = 'user' | 'session' | 'account' | 'verification'
15
+ type InferSelect<T> = T extends { $inferSelect: infer R } ? R : never
16
+
17
+ type DisabledKeys<TAccess> = {
18
+ [K in keyof TAccess & string]: TAccess[K] extends { crud: false } ? K : never
19
+ }[keyof TAccess & string]
20
+
21
+ type ExplicitKeys<TSchema, TAccess> = {
22
+ [K in keyof TAccess & keyof TSchema & string]: TAccess[K] extends {
23
+ crud: false
24
+ }
25
+ ? never
26
+ : K extends AuthTableName
27
+ ? TAccess[K] extends { exposeAuthTable: true }
28
+ ? K extends 'user'
29
+ ? K
30
+ : never
31
+ : never
32
+ : K
33
+ }[keyof TAccess & keyof TSchema & string]
34
+
35
+ type ConventionKeys<TSchema> = {
36
+ [K in keyof TSchema & string]: K extends AuthTableName
37
+ ? never
38
+ : InferSelect<TSchema[K]> extends { userId: unknown }
39
+ ? K
40
+ : never
41
+ }[keyof TSchema & string]
42
+
43
+ export type ExposedApiTables<TSchema, TAccess> = [TAccess] extends [undefined]
44
+ ? ConventionKeys<TSchema>
45
+ :
46
+ | ExplicitKeys<TSchema, TAccess>
47
+ | Exclude<
48
+ ConventionKeys<TSchema>,
49
+ DisabledKeys<TAccess> | (keyof TAccess & string)
50
+ >
51
+
52
+ export type CrudApiRouterFor<
53
+ TSchema extends Record<string, unknown>,
54
+ TAccess = undefined,
55
+ > = {
56
+ [K in ExposedApiTables<TSchema, TAccess> as TSchema[K] extends Table
57
+ ? K
58
+ : never]: TableCrudProcedures<Extract<TSchema[K], Table>>
59
+ }
60
+
61
+ type IsProcedure<T> = T extends { '~orpc': unknown } ? true : false
62
+
63
+ export type MergeApiRouterTypes<A, B> = {
64
+ [K in keyof A | keyof B]: K extends keyof A
65
+ ? K extends keyof B
66
+ ? IsProcedure<A[K]> extends true
67
+ ? never
68
+ : IsProcedure<B[K]> extends true
69
+ ? never
70
+ : A[K] extends Record<string, unknown>
71
+ ? B[K] extends Record<string, unknown>
72
+ ? MergeApiRouterTypes<A[K], B[K]>
73
+ : never
74
+ : never
75
+ : A[K]
76
+ : K extends keyof B
77
+ ? B[K]
78
+ : never
79
+ }
80
+
81
+ export type UnifiedApiRouter<
82
+ TCrud extends AnyRouter,
83
+ TCustom extends AnyRouter | undefined,
84
+ > = TCustom extends AnyRouter ? MergeApiRouterTypes<TCrud, TCustom> : TCrud
package/src/auth.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  // src/auth.ts
2
2
  import { betterAuth } from 'better-auth'
3
3
  import { drizzleAdapter } from 'better-auth/adapters/drizzle'
4
+ import { openAPI } from 'better-auth/plugins'
4
5
 
5
6
  import type { AuthSessionResolver } from './access'
6
7
  import type { BetterAuthConfig } from './config'
@@ -13,8 +14,12 @@ export function createAuth(
13
14
  dialect: Dialect,
14
15
  userSchema?: Record<string, unknown>,
15
16
  ) {
17
+ const hasOpenApi = cfg.plugins?.some((p: any) => p.id === 'open-api')
18
+ const plugins = hasOpenApi ? cfg.plugins : [...(cfg.plugins || []), openAPI()]
19
+
16
20
  return betterAuth({
17
21
  ...cfg,
22
+ plugins,
18
23
  database: drizzleAdapter(db as Parameters<typeof drizzleAdapter>[0], {
19
24
  provider: dialect === 'pg' ? 'pg' : 'sqlite',
20
25
  ...(userSchema ? { schema: userSchema } : {}),