bunderstack 0.17.0-beta.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/package.json +6 -3
- package/src/access.ts +0 -20
- package/src/api/crud-router.ts +89 -24
- package/src/api/types.ts +22 -1
- package/src/crud-operations.ts +15 -25
- package/src/email/smtp.ts +1 -1
- package/src/errors.ts +27 -22
- package/src/index.ts +20 -8
- package/src/list-query.ts +50 -133
- package/src/rate-limit.ts +1 -1
- package/src/realtime/facade.ts +21 -2
- package/src/realtime/filter.ts +5 -1
- package/src/storage/operations.ts +3 -3
- package/src/api/api-types.types.ts +0 -106
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "bunderstack",
|
|
3
|
-
"version": "0.17.0-beta.
|
|
3
|
+
"version": "0.17.0-beta.2",
|
|
4
4
|
"description": "Batteries-included backend framework for Bun: type-safe oRPC APIs, auth, storage, realtime, jobs, email, and validated env from one config.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"backend",
|
|
@@ -26,6 +26,7 @@
|
|
|
26
26
|
"files": [
|
|
27
27
|
"src",
|
|
28
28
|
"!src/**/*.test.ts",
|
|
29
|
+
"!src/**/*.types.ts",
|
|
29
30
|
"README.md",
|
|
30
31
|
"LICENSE"
|
|
31
32
|
],
|
|
@@ -66,8 +67,9 @@
|
|
|
66
67
|
"devDependencies": {
|
|
67
68
|
"@electric-sql/pglite": ">=0.3.0",
|
|
68
69
|
"@libsql/client": ">=0.14.0",
|
|
69
|
-
"@orpc/openapi": "2.0.0-beta.26",
|
|
70
70
|
"@orpc/bun": "2.0.0-beta.26",
|
|
71
|
+
"@orpc/json-schema": "2.0.0-beta.26",
|
|
72
|
+
"@orpc/openapi": "2.0.0-beta.26",
|
|
71
73
|
"@orpc/publisher": "2.0.0-beta.26",
|
|
72
74
|
"@orpc/server": "2.0.0-beta.26",
|
|
73
75
|
"@orpc/valibot": "2.0.0-beta.26",
|
|
@@ -80,8 +82,9 @@
|
|
|
80
82
|
"peerDependencies": {
|
|
81
83
|
"@electric-sql/pglite": ">=0.3.0",
|
|
82
84
|
"@libsql/client": ">=0.14.0",
|
|
83
|
-
"@orpc/openapi": "2.0.0-beta.26",
|
|
84
85
|
"@orpc/bun": "2.0.0-beta.26",
|
|
86
|
+
"@orpc/json-schema": "2.0.0-beta.26",
|
|
87
|
+
"@orpc/openapi": "2.0.0-beta.26",
|
|
85
88
|
"@orpc/publisher": "2.0.0-beta.26",
|
|
86
89
|
"@orpc/server": "2.0.0-beta.26",
|
|
87
90
|
"@orpc/valibot": "2.0.0-beta.26",
|
package/src/access.ts
CHANGED
|
@@ -38,16 +38,6 @@ export type ScopeResolver = (ctx: AccessContext) => ScopeMap
|
|
|
38
38
|
|
|
39
39
|
export type CrudOperation = 'list' | 'get' | 'create' | 'update' | 'delete'
|
|
40
40
|
|
|
41
|
-
const RESERVED_LIST_PARAMS = new Set([
|
|
42
|
-
'limit',
|
|
43
|
-
'offset',
|
|
44
|
-
'sort',
|
|
45
|
-
'order',
|
|
46
|
-
'q',
|
|
47
|
-
'cursor',
|
|
48
|
-
'count',
|
|
49
|
-
])
|
|
50
|
-
|
|
51
41
|
export type SortOrder = 'asc' | 'desc'
|
|
52
42
|
|
|
53
43
|
export type DefaultSort = {
|
|
@@ -176,11 +166,6 @@ function resolveListAccess(
|
|
|
176
166
|
|
|
177
167
|
const filterableColumns = input.filterableColumns ?? []
|
|
178
168
|
for (const col of filterableColumns) {
|
|
179
|
-
if (RESERVED_LIST_PARAMS.has(col)) {
|
|
180
|
-
throw new Error(
|
|
181
|
-
`[bunderstack] filterableColumns cannot include reserved query param "${col}"`,
|
|
182
|
-
)
|
|
183
|
-
}
|
|
184
169
|
if (!columns.includes(col)) {
|
|
185
170
|
throw new Error(
|
|
186
171
|
`[bunderstack] filterableColumns references unknown column "${col}"`,
|
|
@@ -189,11 +174,6 @@ function resolveListAccess(
|
|
|
189
174
|
}
|
|
190
175
|
|
|
191
176
|
for (const col of sortableColumns) {
|
|
192
|
-
if (RESERVED_LIST_PARAMS.has(col)) {
|
|
193
|
-
throw new Error(
|
|
194
|
-
`[bunderstack] sortableColumns cannot include reserved query param "${col}"`,
|
|
195
|
-
)
|
|
196
|
-
}
|
|
197
177
|
if (!columns.includes(col)) {
|
|
198
178
|
throw new Error(
|
|
199
179
|
`[bunderstack] sortableColumns references unknown column "${col}"`,
|
package/src/api/crud-router.ts
CHANGED
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
type TableAccessInput,
|
|
20
20
|
} from '../access'
|
|
21
21
|
import { createCrudOperations, type CrudOperations } from '../crud-operations'
|
|
22
|
+
import { MAX_LIST_LIMIT } from '../list-query'
|
|
22
23
|
import { createApiBuilder } from './builder'
|
|
23
24
|
|
|
24
25
|
export type CrudApiRouterOptions<
|
|
@@ -37,6 +38,40 @@ function strictObject<TEntries extends v.ObjectEntries>(schema: {
|
|
|
37
38
|
|
|
38
39
|
type CrudInsert<TTable extends Table> = Partial<TTable['$inferInsert']>
|
|
39
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
|
+
|
|
40
75
|
export type BuildTableCrudProceduresArgs<
|
|
41
76
|
TSchema extends Record<string, unknown>,
|
|
42
77
|
TTable extends Table,
|
|
@@ -50,6 +85,8 @@ export type BuildTableCrudProceduresArgs<
|
|
|
50
85
|
export function buildTableCrudProcedures<
|
|
51
86
|
TSchema extends Record<string, unknown>,
|
|
52
87
|
TTable extends Table,
|
|
88
|
+
TFilterable extends string = string,
|
|
89
|
+
TSortable extends string = string,
|
|
53
90
|
>(args: BuildTableCrudProceduresArgs<TSchema, TTable>) {
|
|
54
91
|
const { table, operations, builder, access } = args
|
|
55
92
|
const name = getTableName(table)
|
|
@@ -89,24 +126,52 @@ export function buildTableCrudProcedures<
|
|
|
89
126
|
body: strictObject(updateBodySchema),
|
|
90
127
|
})
|
|
91
128
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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.
|
|
95
144
|
v.pipe(
|
|
96
|
-
v.
|
|
97
|
-
v.transform(
|
|
98
|
-
v.number(),
|
|
145
|
+
v.literal('null'),
|
|
146
|
+
v.transform(() => null),
|
|
99
147
|
),
|
|
100
|
-
|
|
101
|
-
|
|
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))),
|
|
102
162
|
cursor: v.optional(v.string()),
|
|
103
|
-
sort: v.optional(v.
|
|
163
|
+
sort: v.optional(v.picklist(access.sortableColumns)),
|
|
104
164
|
order: v.optional(v.picklist(['asc', 'desc'])),
|
|
105
|
-
q: v.optional(v.string()),
|
|
165
|
+
q: v.optional(v.pipe(v.string(), v.maxLength(100))),
|
|
106
166
|
count: v.optional(v.boolean()),
|
|
107
|
-
|
|
167
|
+
// Always present, even with no filterable columns: clients send `{}`.
|
|
168
|
+
filters: v.optional(v.strictObject(filterEntries)),
|
|
108
169
|
}),
|
|
109
|
-
)
|
|
170
|
+
) as unknown as v.GenericSchema<
|
|
171
|
+
ListInputFor<TTable, TFilterable, TSortable>,
|
|
172
|
+
ListInputFor<TTable, TFilterable, TSortable>
|
|
173
|
+
>
|
|
174
|
+
|
|
110
175
|
|
|
111
176
|
const listOutputSchema = v.strictObject({
|
|
112
177
|
items: v.array(selectSchema),
|
|
@@ -138,16 +203,7 @@ export function buildTableCrudProcedures<
|
|
|
138
203
|
user: session.user,
|
|
139
204
|
session: { activeOrganizationId: session.activeOrganizationId },
|
|
140
205
|
}
|
|
141
|
-
const
|
|
142
|
-
const result = await operations.list(
|
|
143
|
-
name,
|
|
144
|
-
{
|
|
145
|
-
...query,
|
|
146
|
-
...(filters ?? {}),
|
|
147
|
-
...(count === undefined ? {} : { count: String(count) }),
|
|
148
|
-
},
|
|
149
|
-
execCtx,
|
|
150
|
-
)
|
|
206
|
+
const result = await operations.list(name, input ?? {}, execCtx)
|
|
151
207
|
return {
|
|
152
208
|
...result,
|
|
153
209
|
items: result.items as TTable['$inferSelect'][],
|
|
@@ -273,8 +329,17 @@ export function buildTableCrudProcedures<
|
|
|
273
329
|
}
|
|
274
330
|
}
|
|
275
331
|
|
|
276
|
-
export type TableCrudProcedures<
|
|
277
|
-
|
|
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
|
+
>
|
|
278
343
|
>
|
|
279
344
|
|
|
280
345
|
export function buildCrudApiRouter<
|
package/src/api/types.ts
CHANGED
|
@@ -49,13 +49,34 @@ export type ExposedApiTables<TSchema, TAccess> = [TAccess] extends [undefined]
|
|
|
49
49
|
DisabledKeys<TAccess> | (keyof TAccess & string)
|
|
50
50
|
>
|
|
51
51
|
|
|
52
|
+
/**
|
|
53
|
+
* Column allowlists declared in `access` reach the client as literal unions, so
|
|
54
|
+
* `list` can type `filters` and `sort` per table. Tables that declare nothing
|
|
55
|
+
* get `never` filters and `'id'` sorting, matching the runtime defaults.
|
|
56
|
+
*/
|
|
57
|
+
type FilterableOf<TAccess, K extends string> = K extends keyof TAccess
|
|
58
|
+
? TAccess[K] extends { filterableColumns: readonly (infer F extends string)[] }
|
|
59
|
+
? F
|
|
60
|
+
: never
|
|
61
|
+
: never
|
|
62
|
+
|
|
63
|
+
type SortableOf<TAccess, K extends string> = K extends keyof TAccess
|
|
64
|
+
? TAccess[K] extends { sortableColumns: readonly (infer S extends string)[] }
|
|
65
|
+
? S
|
|
66
|
+
: 'id'
|
|
67
|
+
: 'id'
|
|
68
|
+
|
|
52
69
|
export type CrudApiRouterFor<
|
|
53
70
|
TSchema extends Record<string, unknown>,
|
|
54
71
|
TAccess = undefined,
|
|
55
72
|
> = {
|
|
56
73
|
[K in ExposedApiTables<TSchema, TAccess> as TSchema[K] extends Table
|
|
57
74
|
? K
|
|
58
|
-
: never]: TableCrudProcedures<
|
|
75
|
+
: never]: TableCrudProcedures<
|
|
76
|
+
Extract<TSchema[K], Table>,
|
|
77
|
+
FilterableOf<TAccess, K>,
|
|
78
|
+
SortableOf<TAccess, K>
|
|
79
|
+
>
|
|
59
80
|
}
|
|
60
81
|
|
|
61
82
|
type IsProcedure<T> = T extends { '~orpc': unknown } ? true : false
|
package/src/crud-operations.ts
CHANGED
|
@@ -17,7 +17,6 @@ import {
|
|
|
17
17
|
tableEntryForName,
|
|
18
18
|
type AccessUser,
|
|
19
19
|
type ResolvedAccess,
|
|
20
|
-
type ResolvedTableAccess,
|
|
21
20
|
type ScopeMap,
|
|
22
21
|
type ScopeResolver,
|
|
23
22
|
} from './access'
|
|
@@ -34,7 +33,12 @@ import {
|
|
|
34
33
|
storeIdempotency,
|
|
35
34
|
type IdempotencyConfig,
|
|
36
35
|
} from './idempotency'
|
|
37
|
-
import {
|
|
36
|
+
import {
|
|
37
|
+
executeList,
|
|
38
|
+
resolveListParams,
|
|
39
|
+
type ListParamsInput,
|
|
40
|
+
type ListResult,
|
|
41
|
+
} from './list-query'
|
|
38
42
|
import { buildScopeWhere } from './scope'
|
|
39
43
|
|
|
40
44
|
export interface CrudExecutionContext {
|
|
@@ -49,8 +53,8 @@ function errorCodeForStatus(status: number): BunderstackErrorCode {
|
|
|
49
53
|
if (status === 404) return 'NOT_FOUND'
|
|
50
54
|
if (status === 409) return 'CONFLICT'
|
|
51
55
|
if (status === 413) return 'PAYLOAD_TOO_LARGE'
|
|
52
|
-
if (status === 429) return '
|
|
53
|
-
return '
|
|
56
|
+
if (status === 429) return 'TOO_MANY_REQUESTS'
|
|
57
|
+
return 'BAD_REQUEST'
|
|
54
58
|
}
|
|
55
59
|
|
|
56
60
|
export class CrudOperationError extends BunderstackError {
|
|
@@ -165,7 +169,7 @@ export function createCrudOperations<
|
|
|
165
169
|
if (!idCol) {
|
|
166
170
|
throw new CrudOperationError(
|
|
167
171
|
400,
|
|
168
|
-
ErrorCode.
|
|
172
|
+
ErrorCode.BAD_REQUEST,
|
|
169
173
|
`Table ${tableName} has no id column`,
|
|
170
174
|
)
|
|
171
175
|
}
|
|
@@ -175,7 +179,7 @@ export function createCrudOperations<
|
|
|
175
179
|
return {
|
|
176
180
|
async list(
|
|
177
181
|
tableName: string,
|
|
178
|
-
|
|
182
|
+
params: ListParamsInput | undefined,
|
|
179
183
|
ctx: CrudExecutionContext,
|
|
180
184
|
): Promise<ListResult<Record<string, unknown>>> {
|
|
181
185
|
const { table, tableAccess, idCol } = resolveTable(tableName)
|
|
@@ -193,29 +197,15 @@ export function createCrudOperations<
|
|
|
193
197
|
)
|
|
194
198
|
}
|
|
195
199
|
|
|
196
|
-
let urlObj: URL
|
|
197
|
-
if (paramsInput instanceof URL) {
|
|
198
|
-
urlObj = paramsInput
|
|
199
|
-
} else {
|
|
200
|
-
urlObj = new URL(ctx.request.url || 'http://localhost')
|
|
201
|
-
if (paramsInput) {
|
|
202
|
-
for (const [k, v] of Object.entries(paramsInput)) {
|
|
203
|
-
if (v !== undefined && v !== null) {
|
|
204
|
-
urlObj.searchParams.set(k, String(v))
|
|
205
|
-
}
|
|
206
|
-
}
|
|
207
|
-
}
|
|
208
|
-
}
|
|
209
|
-
|
|
210
200
|
try {
|
|
211
|
-
const
|
|
201
|
+
const resolved = resolveListParams(params ?? {}, tableAccess)
|
|
212
202
|
const scope = scopeFor(tableAccess.readScope, ctx)
|
|
213
203
|
const scopeWhere = scope ? buildScopeWhere(table, scope) : undefined
|
|
214
204
|
return await executeList(
|
|
215
205
|
db,
|
|
216
206
|
table,
|
|
217
207
|
tableAccess,
|
|
218
|
-
|
|
208
|
+
resolved,
|
|
219
209
|
idCol,
|
|
220
210
|
scopeWhere,
|
|
221
211
|
)
|
|
@@ -290,7 +280,7 @@ export function createCrudOperations<
|
|
|
290
280
|
if (!isRecord(body)) {
|
|
291
281
|
throw new CrudOperationError(
|
|
292
282
|
400,
|
|
293
|
-
ErrorCode.
|
|
283
|
+
ErrorCode.BAD_REQUEST,
|
|
294
284
|
'Invalid JSON body',
|
|
295
285
|
)
|
|
296
286
|
}
|
|
@@ -407,7 +397,7 @@ export function createCrudOperations<
|
|
|
407
397
|
if (!isRecord(body)) {
|
|
408
398
|
throw new CrudOperationError(
|
|
409
399
|
400,
|
|
410
|
-
ErrorCode.
|
|
400
|
+
ErrorCode.BAD_REQUEST,
|
|
411
401
|
'Invalid JSON body',
|
|
412
402
|
)
|
|
413
403
|
}
|
|
@@ -422,7 +412,7 @@ export function createCrudOperations<
|
|
|
422
412
|
if (Object.keys(values).length === 0) {
|
|
423
413
|
throw new CrudOperationError(
|
|
424
414
|
400,
|
|
425
|
-
ErrorCode.
|
|
415
|
+
ErrorCode.BAD_REQUEST,
|
|
426
416
|
'No writable fields to update',
|
|
427
417
|
)
|
|
428
418
|
}
|
package/src/email/smtp.ts
CHANGED
package/src/errors.ts
CHANGED
|
@@ -1,30 +1,31 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
COMMON_ERROR_STATUS_MAP,
|
|
3
|
+
ORPCError,
|
|
4
|
+
os,
|
|
5
|
+
type ErrorMap,
|
|
6
|
+
} from '@orpc/server'
|
|
2
7
|
import * as v from 'valibot'
|
|
3
8
|
|
|
4
9
|
import { StandardSchemaValidationError } from './standard-schema'
|
|
5
10
|
|
|
11
|
+
/**
|
|
12
|
+
* Every code we raise is one oRPC already knows, so handlers derive the HTTP
|
|
13
|
+
* status from `COMMON_ERROR_STATUS_MAP` on their own and clients can use the
|
|
14
|
+
* standard `isDefinedError` helpers. Do not add a code oRPC has no status for —
|
|
15
|
+
* it would silently answer 500.
|
|
16
|
+
*/
|
|
6
17
|
export const BUNDERSTACK_ERROR_CODES = [
|
|
7
|
-
'
|
|
18
|
+
'BAD_REQUEST',
|
|
8
19
|
'UNAUTHORIZED',
|
|
9
20
|
'FORBIDDEN',
|
|
10
21
|
'NOT_FOUND',
|
|
11
22
|
'CONFLICT',
|
|
12
23
|
'PAYLOAD_TOO_LARGE',
|
|
13
|
-
'
|
|
14
|
-
] as const
|
|
24
|
+
'TOO_MANY_REQUESTS',
|
|
25
|
+
] as const satisfies readonly (keyof typeof COMMON_ERROR_STATUS_MAP)[]
|
|
15
26
|
|
|
16
27
|
export type BunderstackErrorCode = (typeof BUNDERSTACK_ERROR_CODES)[number]
|
|
17
28
|
|
|
18
|
-
export const BUNDERSTACK_ERROR_STATUS_MAP = {
|
|
19
|
-
VALIDATION_ERROR: 400,
|
|
20
|
-
UNAUTHORIZED: 401,
|
|
21
|
-
FORBIDDEN: 403,
|
|
22
|
-
NOT_FOUND: 404,
|
|
23
|
-
CONFLICT: 409,
|
|
24
|
-
PAYLOAD_TOO_LARGE: 413,
|
|
25
|
-
RATE_LIMITED: 429,
|
|
26
|
-
} as const satisfies Record<BunderstackErrorCode, number>
|
|
27
|
-
|
|
28
29
|
function errorDataSchema<const TCode extends BunderstackErrorCode>(
|
|
29
30
|
code: TCode,
|
|
30
31
|
) {
|
|
@@ -35,13 +36,13 @@ function errorDataSchema<const TCode extends BunderstackErrorCode>(
|
|
|
35
36
|
}
|
|
36
37
|
|
|
37
38
|
export const BUNDERSTACK_ERRORS = {
|
|
38
|
-
|
|
39
|
+
BAD_REQUEST: { data: errorDataSchema('BAD_REQUEST') },
|
|
39
40
|
UNAUTHORIZED: { data: errorDataSchema('UNAUTHORIZED') },
|
|
40
41
|
FORBIDDEN: { data: errorDataSchema('FORBIDDEN') },
|
|
41
42
|
NOT_FOUND: { data: errorDataSchema('NOT_FOUND') },
|
|
42
43
|
CONFLICT: { data: errorDataSchema('CONFLICT') },
|
|
43
44
|
PAYLOAD_TOO_LARGE: { data: errorDataSchema('PAYLOAD_TOO_LARGE') },
|
|
44
|
-
|
|
45
|
+
TOO_MANY_REQUESTS: { data: errorDataSchema('TOO_MANY_REQUESTS') },
|
|
45
46
|
} as const satisfies ErrorMap
|
|
46
47
|
|
|
47
48
|
export class BunderstackError extends Error {
|
|
@@ -55,7 +56,7 @@ export class BunderstackError extends Error {
|
|
|
55
56
|
) {
|
|
56
57
|
super(message, options)
|
|
57
58
|
this.name = 'BunderstackError'
|
|
58
|
-
this.status =
|
|
59
|
+
this.status = COMMON_ERROR_STATUS_MAP[code]
|
|
59
60
|
}
|
|
60
61
|
}
|
|
61
62
|
|
|
@@ -68,7 +69,7 @@ export const mapBunderstackErrors = os
|
|
|
68
69
|
const mapped =
|
|
69
70
|
error instanceof StandardSchemaValidationError
|
|
70
71
|
? new BunderstackError(
|
|
71
|
-
'
|
|
72
|
+
'BAD_REQUEST',
|
|
72
73
|
error.message,
|
|
73
74
|
error.issues,
|
|
74
75
|
{ cause: error },
|
|
@@ -87,14 +88,18 @@ export const mapBunderstackErrors = os
|
|
|
87
88
|
}
|
|
88
89
|
})
|
|
89
90
|
|
|
90
|
-
/**
|
|
91
|
+
/**
|
|
92
|
+
* Sub-codes carried in `data.details.code` when the oRPC code alone loses
|
|
93
|
+
* information the client may want to branch on. Codes that duplicate an oRPC
|
|
94
|
+
* code are suppressed as redundant, see {@link CrudOperationError}.
|
|
95
|
+
*/
|
|
91
96
|
export const ErrorCode = {
|
|
92
|
-
|
|
97
|
+
BAD_REQUEST: 'BAD_REQUEST',
|
|
93
98
|
FORBIDDEN: 'FORBIDDEN',
|
|
94
99
|
NOT_FOUND: 'NOT_FOUND',
|
|
95
100
|
CONFLICT: 'CONFLICT',
|
|
96
101
|
INVALID_CURSOR: 'INVALID_CURSOR',
|
|
97
|
-
|
|
102
|
+
TOO_MANY_REQUESTS: 'TOO_MANY_REQUESTS',
|
|
98
103
|
IDEMPOTENCY_REPLAY: 'IDEMPOTENCY_REPLAY',
|
|
99
104
|
IDEMPOTENCY_CONFLICT: 'IDEMPOTENCY_CONFLICT',
|
|
100
105
|
} as const
|
|
@@ -107,7 +112,7 @@ export class ListQueryError extends Error {
|
|
|
107
112
|
|
|
108
113
|
constructor(
|
|
109
114
|
message: string,
|
|
110
|
-
code: ErrorCodeValue = ErrorCode.
|
|
115
|
+
code: ErrorCodeValue = ErrorCode.BAD_REQUEST,
|
|
111
116
|
details?: unknown,
|
|
112
117
|
) {
|
|
113
118
|
super(message)
|
package/src/index.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { AnyRouter as AnyORPCRouter } from '@orpc/server'
|
|
2
2
|
// src/index.ts
|
|
3
3
|
|
|
4
|
+
import { SmartCoercionHandlerPlugin } from '@orpc/json-schema'
|
|
4
5
|
import { OpenAPIGenerator, OpenAPIGeneratorError } from '@orpc/openapi'
|
|
5
6
|
import { OpenAPIHandler } from '@orpc/openapi/fetch'
|
|
6
7
|
import { RPCHandler } from '@orpc/server/fetch'
|
|
@@ -9,7 +10,6 @@ import { ValibotToJsonSchemaConverter } from '@orpc/valibot'
|
|
|
9
10
|
import type { TableAccessInput } from './access'
|
|
10
11
|
import type {
|
|
11
12
|
CrudApiRouterFor,
|
|
12
|
-
ExposedApiTables,
|
|
13
13
|
MergeApiRouterTypes,
|
|
14
14
|
UnifiedApiRouter,
|
|
15
15
|
} from './api/types'
|
|
@@ -36,8 +36,6 @@ import { buildApiRouter } from './api/router'
|
|
|
36
36
|
import { buildStorageApiRouter } from './api/storage-router'
|
|
37
37
|
import {
|
|
38
38
|
buildApiRegistry,
|
|
39
|
-
mergeApiRoutersStrict,
|
|
40
|
-
normalizeApiPath,
|
|
41
39
|
normalizeForeignOpenAPISpec,
|
|
42
40
|
} from './api/registry'
|
|
43
41
|
import {
|
|
@@ -51,7 +49,6 @@ import { createDb } from './db'
|
|
|
51
49
|
import { detectDialect } from './dialect'
|
|
52
50
|
import { createEmail, emailProviderTag, type EmailFacade } from './email'
|
|
53
51
|
import { validateEnv, type EnvConfigInput, type ValidatedEnv } from './env'
|
|
54
|
-
import { BUNDERSTACK_ERROR_STATUS_MAP } from './errors'
|
|
55
52
|
import { buildHandler } from './handler'
|
|
56
53
|
import { withInternalTables } from './internal-tables'
|
|
57
54
|
import {
|
|
@@ -348,6 +345,7 @@ export async function createBunderstack<
|
|
|
348
345
|
const realtime = createRealtimeFacade<TSchema>(
|
|
349
346
|
publisher,
|
|
350
347
|
runtimeRealtimeTransport,
|
|
348
|
+
options.schema,
|
|
351
349
|
)
|
|
352
350
|
const registry = createBucketStorages(config.storage)
|
|
353
351
|
const storageOperations = createStorageOperations({
|
|
@@ -410,7 +408,7 @@ export async function createBunderstack<
|
|
|
410
408
|
// ordinary cron now, so it inherits retries, timeout and onFailed.
|
|
411
409
|
const resolvedDefs: JobsDefs | undefined = storageConfigured
|
|
412
410
|
? {
|
|
413
|
-
...
|
|
411
|
+
...jobsDefs,
|
|
414
412
|
'bunderstack:storage-sweep': {
|
|
415
413
|
kind: 'cron',
|
|
416
414
|
schedule: '0 4 * * *',
|
|
@@ -546,11 +544,13 @@ export async function createBunderstack<
|
|
|
546
544
|
]),
|
|
547
545
|
})
|
|
548
546
|
|
|
547
|
+
const valibotConverter = new ValibotToJsonSchemaConverter()
|
|
548
|
+
|
|
549
549
|
const combinedOpenAPISpec = options.openapi
|
|
550
550
|
? mergeOpenAPISpecs({
|
|
551
551
|
nativeSpec: await new OpenAPIGenerator({
|
|
552
552
|
converters: [
|
|
553
|
-
|
|
553
|
+
valibotConverter,
|
|
554
554
|
{
|
|
555
555
|
condition: (schema: any) =>
|
|
556
556
|
Boolean(
|
|
@@ -571,13 +571,22 @@ export async function createBunderstack<
|
|
|
571
571
|
: undefined
|
|
572
572
|
|
|
573
573
|
const openapiHandler = new OpenAPIHandler(nativeRouter, {
|
|
574
|
-
|
|
574
|
+
// Query strings and form bodies are strings; this coerces them to the
|
|
575
|
+
// types each procedure's input schema declares, so schemas stay honest
|
|
576
|
+
// (`v.number()`, not a string-union pipe) and REST matches RPC.
|
|
577
|
+
plugins: [
|
|
578
|
+
new SmartCoercionHandlerPlugin({ converters: [valibotConverter] }),
|
|
579
|
+
],
|
|
575
580
|
customErrorResponseBodyEncoder: (error: any) => ({
|
|
576
581
|
error: error.message,
|
|
577
582
|
code: error.data?.code ?? error.code,
|
|
583
|
+
// oRPC reports schema failures as `data.issues`; forwarding them tells
|
|
584
|
+
// the client which field was rejected instead of just "invalid".
|
|
578
585
|
...(error.data?.details !== undefined
|
|
579
586
|
? { details: error.data.details }
|
|
580
|
-
:
|
|
587
|
+
: error.data?.issues !== undefined
|
|
588
|
+
? { details: error.data.issues }
|
|
589
|
+
: {}),
|
|
581
590
|
}),
|
|
582
591
|
fetchInterceptors: [
|
|
583
592
|
async (options) => {
|
|
@@ -806,6 +815,9 @@ export type {
|
|
|
806
815
|
|
|
807
816
|
export { createApiBuilder } from './api/builder'
|
|
808
817
|
export type { BunderstackApiBuilder, ApiFactory } from './api/builder'
|
|
818
|
+
// Needed to declare shared middleware over the app's context, e.g.
|
|
819
|
+
// `os.$context<ApiContext<typeof schema>>().middleware(...)`.
|
|
820
|
+
export type { ApiContext } from './api/context'
|
|
809
821
|
export type {
|
|
810
822
|
CrudApiRouterFor,
|
|
811
823
|
ExposedApiTables,
|
package/src/list-query.ts
CHANGED
|
@@ -21,18 +21,28 @@ import type { AnyDb } from './dialect'
|
|
|
21
21
|
|
|
22
22
|
import { ErrorCode, ListQueryError } from './errors'
|
|
23
23
|
|
|
24
|
-
/** Caps both `?limit=` and the number of values in
|
|
24
|
+
/** Caps both `?limit=` and the number of values in an `IN` filter. */
|
|
25
25
|
export const MAX_LIST_LIMIT = 200
|
|
26
26
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
27
|
+
/** Default page size when a request omits `limit`. */
|
|
28
|
+
export const DEFAULT_LIST_LIMIT = 20
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* What a list procedure accepts. Shape and column types are enforced by the
|
|
32
|
+
* generated input schema, so by the time these params arrive they are already
|
|
33
|
+
* validated and coerced — this module only applies policy (defaults, the limit
|
|
34
|
+
* cap, cursor rules).
|
|
35
|
+
*/
|
|
36
|
+
export type ListParamsInput = {
|
|
37
|
+
limit?: number
|
|
38
|
+
offset?: number
|
|
39
|
+
cursor?: string
|
|
40
|
+
sort?: string
|
|
41
|
+
order?: SortOrder
|
|
42
|
+
q?: string
|
|
43
|
+
count?: boolean
|
|
44
|
+
filters?: Record<string, unknown>
|
|
45
|
+
}
|
|
36
46
|
|
|
37
47
|
export type ParsedListParams = {
|
|
38
48
|
limit: number
|
|
@@ -81,76 +91,22 @@ function isCursorPayload(value: unknown): value is CursorPayload {
|
|
|
81
91
|
)
|
|
82
92
|
}
|
|
83
93
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
if (raw === undefined || raw === '') return 20
|
|
92
|
-
const n = Number(raw)
|
|
93
|
-
if (!Number.isInteger(n) || n < 1) {
|
|
94
|
-
throw new ListQueryError(
|
|
95
|
-
`limit must be an integer between 1 and ${MAX_LIST_LIMIT}`,
|
|
96
|
-
)
|
|
97
|
-
}
|
|
98
|
-
return Math.min(n, MAX_LIST_LIMIT)
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
function parseOffset(raw: string | undefined): number {
|
|
102
|
-
if (raw === undefined || raw === '') return 0
|
|
103
|
-
const n = Number(raw)
|
|
104
|
-
if (!Number.isInteger(n) || n < 0) {
|
|
105
|
-
throw new ListQueryError('offset must be a non-negative integer')
|
|
106
|
-
}
|
|
107
|
-
return n
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
function parseOrder(raw: string | undefined): SortOrder {
|
|
111
|
-
if (!raw || raw === 'asc') return 'asc'
|
|
112
|
-
if (raw === 'desc') return 'desc'
|
|
113
|
-
throw new ListQueryError('order must be "asc" or "desc"')
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
export function parseListParams(
|
|
117
|
-
url: URL,
|
|
94
|
+
/**
|
|
95
|
+
* Applies list policy to already-validated input: defaults, the limit cap, and
|
|
96
|
+
* the rules a schema cannot express (cursor excludes offset, and a cursor must
|
|
97
|
+
* agree with the sort it was minted for).
|
|
98
|
+
*/
|
|
99
|
+
export function resolveListParams(
|
|
100
|
+
input: ListParamsInput,
|
|
118
101
|
access: ResolvedTableAccess,
|
|
119
102
|
): ParsedListParams {
|
|
120
|
-
const
|
|
121
|
-
|
|
122
|
-
const cursor = params.get('cursor')?.trim() || undefined
|
|
123
|
-
const hasOffset = params.has('offset') && params.get('offset') !== ''
|
|
124
|
-
const offset = hasOffset
|
|
125
|
-
? parseOffset(params.get('offset') ?? undefined)
|
|
126
|
-
: undefined
|
|
127
|
-
|
|
128
|
-
if (cursor && hasOffset) {
|
|
103
|
+
const cursor = input.cursor?.trim() || undefined
|
|
104
|
+
if (cursor && input.offset !== undefined) {
|
|
129
105
|
throw new ListQueryError('cursor and offset cannot be used together')
|
|
130
106
|
}
|
|
131
107
|
|
|
132
|
-
const sort =
|
|
133
|
-
const order =
|
|
134
|
-
? parseOrder(params.get('order') ?? undefined)
|
|
135
|
-
: params.has('sort')
|
|
136
|
-
? 'asc'
|
|
137
|
-
: access.defaultSort.order
|
|
138
|
-
|
|
139
|
-
if (!access.sortableColumns.includes(sort)) {
|
|
140
|
-
throw new ListQueryError(`sort column "${sort}" is not allowed`)
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
const filters: Record<string, unknown> = {}
|
|
144
|
-
for (const [key, value] of params.entries()) {
|
|
145
|
-
if (RESERVED_LIST_PARAMS.has(key)) continue
|
|
146
|
-
if (!access.filterableColumns.includes(key)) {
|
|
147
|
-
throw new ListQueryError(`filter column "${key}" is not allowed`)
|
|
148
|
-
}
|
|
149
|
-
filters[key] = value === 'null' ? null : value
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
const q = params.get('q')?.trim().slice(0, 100) ?? ''
|
|
153
|
-
const count = parseBoolean(params.get('count') ?? undefined)
|
|
108
|
+
const sort = input.sort ?? access.defaultSort.column
|
|
109
|
+
const order = input.order ?? (input.sort ? 'asc' : access.defaultSort.order)
|
|
154
110
|
|
|
155
111
|
if (cursor) {
|
|
156
112
|
const decoded = decodeCursor(cursor)
|
|
@@ -163,14 +119,14 @@ export function parseListParams(
|
|
|
163
119
|
}
|
|
164
120
|
|
|
165
121
|
return {
|
|
166
|
-
limit,
|
|
167
|
-
offset: cursor ? undefined : (offset ?? 0),
|
|
122
|
+
limit: Math.min(input.limit ?? DEFAULT_LIST_LIMIT, MAX_LIST_LIMIT),
|
|
123
|
+
offset: cursor ? undefined : (input.offset ?? 0),
|
|
168
124
|
sort,
|
|
169
125
|
order,
|
|
170
|
-
q,
|
|
126
|
+
q: input.q?.trim() ?? '',
|
|
171
127
|
cursor,
|
|
172
|
-
count,
|
|
173
|
-
filters,
|
|
128
|
+
count: input.count ?? false,
|
|
129
|
+
filters: input.filters ?? {},
|
|
174
130
|
}
|
|
175
131
|
}
|
|
176
132
|
|
|
@@ -191,41 +147,20 @@ function buildSearchWhere(
|
|
|
191
147
|
return conditions.length ? or(...conditions) : undefined
|
|
192
148
|
}
|
|
193
149
|
|
|
194
|
-
|
|
150
|
+
/**
|
|
151
|
+
* Cursors carry their sort value as JSON, so a date column arrives as a string
|
|
152
|
+
* and has to be rebuilt. Filter values need no such repair — the generated
|
|
153
|
+
* input schema already types them.
|
|
154
|
+
*/
|
|
155
|
+
function coerceCursorValue(
|
|
195
156
|
table: Parameters<typeof getTableColumns>[0],
|
|
196
157
|
columnName: string,
|
|
197
|
-
raw:
|
|
158
|
+
raw: string | number | null,
|
|
198
159
|
): unknown {
|
|
199
160
|
if (raw === null) return null
|
|
200
161
|
const col = getTableColumns(table)[columnName]
|
|
201
|
-
if (
|
|
202
|
-
|
|
203
|
-
const dataType = col.dataType
|
|
204
|
-
if (
|
|
205
|
-
dataType === 'number' ||
|
|
206
|
-
dataType === 'integer' ||
|
|
207
|
-
dataType === 'bigint'
|
|
208
|
-
) {
|
|
209
|
-
const n = Number(raw)
|
|
210
|
-
if (Number.isNaN(n)) {
|
|
211
|
-
throw new ListQueryError(`filter "${columnName}" must be a number`)
|
|
212
|
-
}
|
|
213
|
-
return n
|
|
214
|
-
}
|
|
215
|
-
if (dataType === 'boolean') {
|
|
216
|
-
const s = String(raw).toLowerCase()
|
|
217
|
-
if (s === 'true' || s === '1') return true
|
|
218
|
-
if (s === 'false' || s === '0') return false
|
|
219
|
-
throw new ListQueryError(`filter "${columnName}" must be a boolean`)
|
|
220
|
-
}
|
|
221
|
-
if (dataType === 'date') {
|
|
222
|
-
const d = new Date(raw as string | number)
|
|
223
|
-
if (Number.isNaN(d.getTime())) {
|
|
224
|
-
throw new ListQueryError(`filter "${columnName}" must be a valid date`)
|
|
225
|
-
}
|
|
226
|
-
return d
|
|
227
|
-
}
|
|
228
|
-
return String(raw)
|
|
162
|
+
if (col?.dataType === 'date') return new Date(raw)
|
|
163
|
+
return raw
|
|
229
164
|
}
|
|
230
165
|
|
|
231
166
|
function buildFilterWhere(
|
|
@@ -235,32 +170,14 @@ function buildFilterWhere(
|
|
|
235
170
|
const columns = getTableColumns(table)
|
|
236
171
|
const conditions: SQL[] = []
|
|
237
172
|
|
|
238
|
-
for (const [name,
|
|
173
|
+
for (const [name, value] of Object.entries(filters)) {
|
|
239
174
|
const col = columns[name]
|
|
240
|
-
if (!col) continue
|
|
241
|
-
|
|
242
|
-
if (raw === null) {
|
|
243
|
-
conditions.push(sql`${col} IS NULL`)
|
|
244
|
-
continue
|
|
245
|
-
}
|
|
246
|
-
|
|
247
|
-
// `?column=a,b,c` — TypeIDs and other filterable values never contain
|
|
248
|
-
// commas, so this is a safe, zero-syntax way to do `column IN (...)`.
|
|
249
|
-
if (typeof raw === 'string' && raw.includes(',')) {
|
|
250
|
-
const parts = raw.split(',').filter((p) => p.length > 0)
|
|
251
|
-
if (parts.length > MAX_LIST_LIMIT) {
|
|
252
|
-
throw new ListQueryError(
|
|
253
|
-
`filter "${name}" accepts at most ${MAX_LIST_LIMIT} comma-separated values`,
|
|
254
|
-
)
|
|
255
|
-
}
|
|
256
|
-
const values = parts.map((p) => coerceFilterValue(table, name, p))
|
|
257
|
-
conditions.push(inArray(col, values))
|
|
258
|
-
continue
|
|
259
|
-
}
|
|
175
|
+
if (!col || value === undefined) continue
|
|
260
176
|
|
|
261
|
-
const value = coerceFilterValue(table, name, raw)
|
|
262
177
|
if (value === null) {
|
|
263
178
|
conditions.push(sql`${col} IS NULL`)
|
|
179
|
+
} else if (Array.isArray(value)) {
|
|
180
|
+
if (value.length) conditions.push(inArray(col, value))
|
|
264
181
|
} else {
|
|
265
182
|
conditions.push(eq(col, value))
|
|
266
183
|
}
|
|
@@ -301,7 +218,7 @@ function buildCursorWhere(
|
|
|
301
218
|
): SQL {
|
|
302
219
|
const columns = getTableColumns(table)
|
|
303
220
|
const sortCol = columns[sortColName]!
|
|
304
|
-
const sortValue =
|
|
221
|
+
const sortValue = coerceCursorValue(table, sortColName, cursor.v)
|
|
305
222
|
|
|
306
223
|
if (order === 'desc') {
|
|
307
224
|
return or(
|
package/src/rate-limit.ts
CHANGED
package/src/realtime/facade.ts
CHANGED
|
@@ -1,4 +1,9 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
getTableName,
|
|
3
|
+
isTable,
|
|
4
|
+
type InferSelectModel,
|
|
5
|
+
type Table,
|
|
6
|
+
} from 'drizzle-orm'
|
|
2
7
|
|
|
3
8
|
import type {
|
|
4
9
|
RealtimeAction,
|
|
@@ -25,9 +30,17 @@ export interface RealtimeFacade<
|
|
|
25
30
|
): Promise<void>
|
|
26
31
|
}
|
|
27
32
|
|
|
33
|
+
/**
|
|
34
|
+
* One name for a table everywhere: events, subscriptions, and the CRUD router
|
|
35
|
+
* all use the schema key. Pass the schema so a key like `creditBalances` is not
|
|
36
|
+
* published as its SQL name `credit_balances` — clients subscribe with the key
|
|
37
|
+
* they call procedures with. Without a schema, the SQL name is the only name
|
|
38
|
+
* available and is used as-is.
|
|
39
|
+
*/
|
|
28
40
|
export function createRealtimeFacade<TSchema extends Record<string, unknown>>(
|
|
29
41
|
publisher?: RealtimePublisher,
|
|
30
42
|
transport: RealtimeTransport = publisher ? 'memory' : 'disabled',
|
|
43
|
+
schema?: TSchema,
|
|
31
44
|
): RealtimeFacade<TSchema> {
|
|
32
45
|
if (!publisher && transport !== 'disabled') {
|
|
33
46
|
throw new Error(
|
|
@@ -40,13 +53,19 @@ export function createRealtimeFacade<TSchema extends Record<string, unknown>>(
|
|
|
40
53
|
)
|
|
41
54
|
}
|
|
42
55
|
|
|
56
|
+
const keyByTableName = new Map<string, string>()
|
|
57
|
+
for (const [key, value] of Object.entries(schema ?? {})) {
|
|
58
|
+
if (isTable(value)) keyByTableName.set(getTableName(value), key)
|
|
59
|
+
}
|
|
60
|
+
|
|
43
61
|
return {
|
|
44
62
|
enabled: publisher !== undefined,
|
|
45
63
|
transport,
|
|
46
64
|
async publish(table, action, record) {
|
|
47
65
|
if (!publisher) return
|
|
66
|
+
const tableName = getTableName(table)
|
|
48
67
|
await publisher.publish('change', {
|
|
49
|
-
table:
|
|
68
|
+
table: keyByTableName.get(tableName) ?? tableName,
|
|
50
69
|
action,
|
|
51
70
|
record: record as unknown as Record<string, unknown>,
|
|
52
71
|
})
|
package/src/realtime/filter.ts
CHANGED
|
@@ -33,7 +33,11 @@ export async function* filterRealtimeChanges(
|
|
|
33
33
|
const getSession = () => (sessionPromise ??= options.getSession())
|
|
34
34
|
|
|
35
35
|
for await (const change of source) {
|
|
36
|
-
|
|
36
|
+
// Events name tables by schema key; the SQL-name lookup stays as a fallback
|
|
37
|
+
// for publishers outside the CRUD path that only know the physical name.
|
|
38
|
+
const entry =
|
|
39
|
+
options.access.get(change.table) ??
|
|
40
|
+
tableEntryForName(options.access, change.table)
|
|
37
41
|
if (!entry?.enabled) continue
|
|
38
42
|
|
|
39
43
|
const recordId = change.record.id
|
|
@@ -187,7 +187,7 @@ export function createStorageOperations(options: StorageOperationsOptions) {
|
|
|
187
187
|
|
|
188
188
|
if (!matchMime(file.type, bucket.upload?.accept)) {
|
|
189
189
|
throw new BunderstackError(
|
|
190
|
-
'
|
|
190
|
+
'BAD_REQUEST',
|
|
191
191
|
`Content type ${file.type || '(none)'} not allowed`,
|
|
192
192
|
)
|
|
193
193
|
}
|
|
@@ -255,7 +255,7 @@ export function createStorageOperations(options: StorageOperationsOptions) {
|
|
|
255
255
|
await adapter.delete(fileId)
|
|
256
256
|
await deleteFileMetaRow(db, fileId)
|
|
257
257
|
throw new BunderstackError(
|
|
258
|
-
validation ? '
|
|
258
|
+
validation ? 'BAD_REQUEST' : 'PAYLOAD_TOO_LARGE',
|
|
259
259
|
message,
|
|
260
260
|
)
|
|
261
261
|
}
|
|
@@ -308,7 +308,7 @@ export function createStorageOperations(options: StorageOperationsOptions) {
|
|
|
308
308
|
if (spec) {
|
|
309
309
|
if (!bucket.transforms) {
|
|
310
310
|
throw new BunderstackError(
|
|
311
|
-
'
|
|
311
|
+
'BAD_REQUEST',
|
|
312
312
|
'Transforms not enabled for this bucket',
|
|
313
313
|
)
|
|
314
314
|
}
|
|
@@ -1,106 +0,0 @@
|
|
|
1
|
-
import type { InferRouterInputs, InferRouterOutputs } from '@orpc/server'
|
|
2
|
-
|
|
3
|
-
import { pgTable, text } from 'drizzle-orm/pg-core'
|
|
4
|
-
import * as v from 'valibot'
|
|
5
|
-
|
|
6
|
-
import type { ExposedApiTables } from './types'
|
|
7
|
-
|
|
8
|
-
import { pglite } from '../database/pglite'
|
|
9
|
-
import { createBunderstack } from '../index'
|
|
10
|
-
|
|
11
|
-
type Equal<A, B> =
|
|
12
|
-
(<T>() => T extends A ? 1 : 2) extends <T>() => T extends B ? 1 : 2
|
|
13
|
-
? true
|
|
14
|
-
: false
|
|
15
|
-
type Expect<T extends true> = T
|
|
16
|
-
|
|
17
|
-
const posts = pgTable('posts', {
|
|
18
|
-
id: text('id').primaryKey(),
|
|
19
|
-
title: text('title').notNull(),
|
|
20
|
-
})
|
|
21
|
-
|
|
22
|
-
const privateNotes = pgTable('private_notes', {
|
|
23
|
-
id: text('id').primaryKey(),
|
|
24
|
-
content: text('content').notNull(),
|
|
25
|
-
})
|
|
26
|
-
|
|
27
|
-
const ownedPosts = pgTable('owned_posts', {
|
|
28
|
-
id: text('id').primaryKey(),
|
|
29
|
-
userId: text('user_id').notNull(),
|
|
30
|
-
})
|
|
31
|
-
|
|
32
|
-
type ImplicitTables = ExposedApiTables<
|
|
33
|
-
{ posts: typeof posts; ownedPosts: typeof ownedPosts },
|
|
34
|
-
undefined
|
|
35
|
-
>
|
|
36
|
-
type _ImplicitAccessHidesUnownedTable = Expect<
|
|
37
|
-
Equal<'posts' extends ImplicitTables ? true : false, false>
|
|
38
|
-
>
|
|
39
|
-
type _ImplicitAccessIncludesConventionTable = Expect<
|
|
40
|
-
Equal<'ownedPosts' extends ImplicitTables ? true : false, true>
|
|
41
|
-
>
|
|
42
|
-
|
|
43
|
-
const typedApp = await createBunderstack({
|
|
44
|
-
schema: { posts, privateNotes },
|
|
45
|
-
database: { adapter: pglite() },
|
|
46
|
-
processEnv: {
|
|
47
|
-
DATABASE_URL: 'memory://',
|
|
48
|
-
BUNDERSTACK_ROLE: 'web',
|
|
49
|
-
},
|
|
50
|
-
access: {
|
|
51
|
-
posts: { crud: true, list: 'public', create: 'public' },
|
|
52
|
-
privateNotes: { crud: false },
|
|
53
|
-
},
|
|
54
|
-
api: (o) => ({
|
|
55
|
-
stats: o.protected
|
|
56
|
-
.input(v.object({ period: v.picklist(['day', 'week']) }))
|
|
57
|
-
.output(
|
|
58
|
-
v.object({ period: v.picklist(['day', 'week']), userId: v.string() }),
|
|
59
|
-
)
|
|
60
|
-
.handler(async ({ input, context }) => {
|
|
61
|
-
const _userId: string = context.user.id
|
|
62
|
-
const _db = context.db
|
|
63
|
-
const _env = context.env
|
|
64
|
-
return {
|
|
65
|
-
period: input.period,
|
|
66
|
-
userId: context.user.id,
|
|
67
|
-
}
|
|
68
|
-
}),
|
|
69
|
-
}),
|
|
70
|
-
})
|
|
71
|
-
|
|
72
|
-
type Api = NonNullable<typeof typedApp.$inferClient>['api']
|
|
73
|
-
|
|
74
|
-
type _HasPosts = Expect<Equal<'posts' extends keyof Api ? true : false, true>>
|
|
75
|
-
type _HidesPrivateNotes = Expect<
|
|
76
|
-
Equal<'privateNotes' extends keyof Api ? true : false, false>
|
|
77
|
-
>
|
|
78
|
-
type _HasStats = Expect<Equal<'stats' extends keyof Api ? true : false, true>>
|
|
79
|
-
|
|
80
|
-
type PostsInputs = InferRouterInputs<Api>['posts']
|
|
81
|
-
type PostsOutputs = InferRouterOutputs<Api>['posts']
|
|
82
|
-
type IsAny<T> = 0 extends 1 & T ? true : false
|
|
83
|
-
type ExpectedUpdateInput = {
|
|
84
|
-
params: { id: string }
|
|
85
|
-
query?: Record<string, unknown>
|
|
86
|
-
headers?: Record<string, unknown>
|
|
87
|
-
body: { title?: string }
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
type _CreateInput = Expect<
|
|
91
|
-
Equal<
|
|
92
|
-
PostsInputs['create'],
|
|
93
|
-
Partial<typeof posts.$inferInsert>
|
|
94
|
-
>
|
|
95
|
-
>
|
|
96
|
-
type _GetInput = Expect<Equal<PostsInputs['get'], { id: string }>>
|
|
97
|
-
type _UpdateInputToExpected = Expect<
|
|
98
|
-
PostsInputs['update'] extends ExpectedUpdateInput ? true : false
|
|
99
|
-
>
|
|
100
|
-
type _ExpectedToUpdateInput = Expect<
|
|
101
|
-
ExpectedUpdateInput extends PostsInputs['update'] ? true : false
|
|
102
|
-
>
|
|
103
|
-
type _ListItems = Expect<
|
|
104
|
-
Equal<PostsOutputs['list']['items'], Array<{ id: string; title: string }>>
|
|
105
|
-
>
|
|
106
|
-
type _GetOutputIsTyped = Expect<Equal<IsAny<PostsOutputs['get']>, false>>
|