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.
- package/README.md +25 -138
- package/package.json +24 -14
- package/src/access.ts +6 -21
- package/src/api/builder.ts +52 -0
- package/src/api/context.ts +83 -0
- package/src/api/crud-router.ts +386 -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 +105 -0
- package/src/auth.ts +5 -0
- package/src/blueprint.ts +88 -105
- package/src/config.ts +57 -39
- package/src/crud-operations.ts +478 -0
- package/src/dialect.ts +1 -1
- package/src/email/smtp.ts +1 -1
- package/src/env.ts +19 -13
- package/src/errors.ts +98 -26
- package/src/handler.ts +16 -44
- package/src/index.ts +248 -247
- package/src/jobs/define.ts +8 -10
- package/src/jobs/queue.ts +6 -2
- package/src/jobs/worker.ts +4 -1
- package/src/list-query.ts +50 -133
- package/src/manifest.ts +84 -87
- package/src/rate-limit.ts +1 -1
- package/src/realtime/facade.ts +36 -14
- package/src/realtime/filter.ts +81 -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,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
|
+
>
|