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.
- package/README.md +25 -138
- package/package.json +21 -14
- package/src/access.ts +6 -1
- package/src/api/api-types.types.ts +106 -0
- package/src/api/builder.ts +52 -0
- package/src/api/context.ts +83 -0
- package/src/api/crud-router.ts +321 -0
- package/src/api/openapi.ts +184 -0
- package/src/api/realtime-router.ts +75 -0
- package/src/api/registry.ts +338 -0
- package/src/api/router.ts +34 -0
- package/src/api/storage-router.ts +224 -0
- package/src/api/types.ts +84 -0
- package/src/auth.ts +5 -0
- package/src/blueprint.ts +88 -105
- package/src/config.ts +57 -39
- package/src/crud-operations.ts +488 -0
- package/src/dialect.ts +1 -1
- package/src/env.ts +19 -13
- package/src/errors.ts +90 -23
- package/src/handler.ts +16 -44
- package/src/index.ts +235 -246
- package/src/jobs/define.ts +8 -10
- package/src/jobs/queue.ts +6 -2
- package/src/jobs/worker.ts +4 -1
- package/src/manifest.ts +84 -87
- package/src/realtime/facade.ts +16 -13
- package/src/realtime/filter.ts +77 -0
- package/src/realtime/heartbeat.ts +80 -0
- package/src/realtime/publisher.ts +46 -0
- package/src/standard-schema.ts +59 -0
- package/src/storage/index.ts +8 -0
- package/src/storage/operations.ts +398 -0
- package/src/crud.ts +0 -400
- package/src/realtime/index.ts +0 -242
- package/src/realtime/redis.ts +0 -219
- package/src/routes.ts +0 -137
- package/src/storage/router.ts +0 -531
- package/src/trpc.ts +0 -57
|
@@ -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
|
+
>
|