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.
@@ -0,0 +1,478 @@
1
+ import {
2
+ eq,
3
+ getTableColumns,
4
+ getTableName,
5
+ isTable,
6
+ type Table,
7
+ } from 'drizzle-orm'
8
+
9
+ import type { AnyDb } from './dialect'
10
+ import type { RealtimeFacade } from './realtime/facade'
11
+
12
+ import {
13
+ checkAccess,
14
+ rowMatchesScope,
15
+ sanitizeWriteBody,
16
+ stampScope,
17
+ tableEntryForName,
18
+ type AccessUser,
19
+ type ResolvedAccess,
20
+ type ScopeMap,
21
+ type ScopeResolver,
22
+ } from './access'
23
+ import {
24
+ BunderstackError,
25
+ ErrorCode,
26
+ ListQueryError,
27
+ type BunderstackErrorCode,
28
+ type ErrorCodeValue,
29
+ } from './errors'
30
+ import {
31
+ lookupIdempotency,
32
+ resolveIdempotencyConfig,
33
+ storeIdempotency,
34
+ type IdempotencyConfig,
35
+ } from './idempotency'
36
+ import {
37
+ executeList,
38
+ resolveListParams,
39
+ type ListParamsInput,
40
+ type ListResult,
41
+ } from './list-query'
42
+ import { buildScopeWhere } from './scope'
43
+
44
+ export interface CrudExecutionContext {
45
+ request: Request
46
+ user: AccessUser | null
47
+ session: { activeOrganizationId: string | null }
48
+ }
49
+
50
+ function errorCodeForStatus(status: number): BunderstackErrorCode {
51
+ if (status === 401) return 'UNAUTHORIZED'
52
+ if (status === 403) return 'FORBIDDEN'
53
+ if (status === 404) return 'NOT_FOUND'
54
+ if (status === 409) return 'CONFLICT'
55
+ if (status === 413) return 'PAYLOAD_TOO_LARGE'
56
+ if (status === 429) return 'TOO_MANY_REQUESTS'
57
+ return 'BAD_REQUEST'
58
+ }
59
+
60
+ export class CrudOperationError extends BunderstackError {
61
+ constructor(
62
+ status: number,
63
+ readonly legacyCode: ErrorCodeValue,
64
+ message: string,
65
+ details?: unknown,
66
+ ) {
67
+ const code = errorCodeForStatus(status)
68
+ super(
69
+ code,
70
+ message,
71
+ legacyCode === code
72
+ ? details
73
+ : {
74
+ code: legacyCode,
75
+ ...(details === undefined ? {} : { details }),
76
+ },
77
+ )
78
+ this.name = 'CrudOperationError'
79
+ }
80
+ }
81
+
82
+ export type CrudOperationsDeps<
83
+ TSchema extends Record<string, unknown> = Record<string, unknown>,
84
+ > = {
85
+ schema: TSchema
86
+ db: AnyDb
87
+ access: ResolvedAccess
88
+ idempotency?: boolean | IdempotencyConfig
89
+ realtime?: RealtimeFacade<TSchema>
90
+ }
91
+
92
+ export type CreateResult =
93
+ | { type: 'created'; status: 201; record: Record<string, unknown> }
94
+ | {
95
+ type: 'replay'
96
+ status: number
97
+ body: string
98
+ record: Record<string, unknown>
99
+ }
100
+
101
+ function isRecord(value: unknown): value is Record<string, unknown> {
102
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
103
+ }
104
+
105
+ function isUniqueConstraintError(error: unknown): boolean {
106
+ const seen = new Set<unknown>()
107
+ let current = error
108
+
109
+ while (isRecord(current) && !seen.has(current)) {
110
+ seen.add(current)
111
+ const code = current['code']
112
+ if (
113
+ code === '23505' ||
114
+ code === 'SQLITE_CONSTRAINT_UNIQUE' ||
115
+ code === 'SQLITE_CONSTRAINT_PRIMARYKEY'
116
+ ) {
117
+ return true
118
+ }
119
+
120
+ const message = current['message']
121
+ if (
122
+ typeof message === 'string' &&
123
+ (/duplicate key value violates unique constraint/i.test(message) ||
124
+ /unique constraint failed/i.test(message))
125
+ ) {
126
+ return true
127
+ }
128
+
129
+ current = current['cause']
130
+ }
131
+
132
+ return false
133
+ }
134
+
135
+ function coerceId(rawId: string | number): string | number {
136
+ if (typeof rawId === 'number') return rawId
137
+ return isNaN(Number(rawId)) ? rawId : Number(rawId)
138
+ }
139
+
140
+ export function createCrudOperations<
141
+ TSchema extends Record<string, unknown> = Record<string, unknown>,
142
+ >(deps: CrudOperationsDeps<TSchema>) {
143
+ const { schema, db, access, realtime } = deps
144
+ const idempotency = resolveIdempotencyConfig(deps.idempotency)
145
+
146
+ const scopeFor = (
147
+ resolver: ScopeResolver | undefined,
148
+ ctx: {
149
+ user: AccessUser | null
150
+ session: { activeOrganizationId: string | null }
151
+ request: Request
152
+ row?: Record<string, unknown>
153
+ body?: Record<string, unknown>
154
+ },
155
+ ): ScopeMap | undefined => (resolver ? resolver(ctx) : undefined)
156
+
157
+ function resolveTable(tableName: string) {
158
+ const table = Object.values(schema).find(
159
+ (t) => isTable(t) && getTableName(t) === tableName,
160
+ ) as Table | undefined
161
+ if (!table) {
162
+ throw new CrudOperationError(404, ErrorCode.NOT_FOUND, 'Not found')
163
+ }
164
+ const tableAccess = tableEntryForName(access, tableName)
165
+ if (!tableAccess || !tableAccess.enabled) {
166
+ throw new CrudOperationError(404, ErrorCode.NOT_FOUND, 'Not found')
167
+ }
168
+ const idCol = getTableColumns(table)['id']
169
+ if (!idCol) {
170
+ throw new CrudOperationError(
171
+ 400,
172
+ ErrorCode.BAD_REQUEST,
173
+ `Table ${tableName} has no id column`,
174
+ )
175
+ }
176
+ return { table, tableAccess, idCol }
177
+ }
178
+
179
+ return {
180
+ async list(
181
+ tableName: string,
182
+ params: ListParamsInput | undefined,
183
+ ctx: CrudExecutionContext,
184
+ ): Promise<ListResult<Record<string, unknown>>> {
185
+ const { table, tableAccess, idCol } = resolveTable(tableName)
186
+
187
+ const denied = await checkAccess(
188
+ tableAccess.list,
189
+ ctx,
190
+ tableAccess.ownerColumn,
191
+ )
192
+ if (!denied.allowed) {
193
+ throw new CrudOperationError(
194
+ denied.status === 401 ? 401 : 403,
195
+ ErrorCode.FORBIDDEN,
196
+ 'Forbidden',
197
+ )
198
+ }
199
+
200
+ try {
201
+ const resolved = resolveListParams(params ?? {}, tableAccess)
202
+ const scope = scopeFor(tableAccess.readScope, ctx)
203
+ const scopeWhere = scope ? buildScopeWhere(table, scope) : undefined
204
+ return await executeList(
205
+ db,
206
+ table,
207
+ tableAccess,
208
+ resolved,
209
+ idCol,
210
+ scopeWhere,
211
+ )
212
+ } catch (err) {
213
+ if (err instanceof ListQueryError) {
214
+ throw new CrudOperationError(400, err.code, err.message, err.details)
215
+ }
216
+ throw err
217
+ }
218
+ },
219
+
220
+ async get(
221
+ tableName: string,
222
+ rawId: string | number,
223
+ ctx: CrudExecutionContext,
224
+ ): Promise<Record<string, unknown>> {
225
+ const { table, tableAccess, idCol } = resolveTable(tableName)
226
+ const id = coerceId(rawId)
227
+
228
+ const rows = await db
229
+ .select()
230
+ .from(table)
231
+ .where(eq(idCol, id))
232
+ if (!rows[0]) {
233
+ throw new CrudOperationError(404, ErrorCode.NOT_FOUND, 'Not found')
234
+ }
235
+ const row = rows[0] as Record<string, unknown>
236
+
237
+ const denied = await checkAccess(
238
+ tableAccess.get,
239
+ { ...ctx, row },
240
+ tableAccess.ownerColumn,
241
+ )
242
+ if (!denied.allowed) {
243
+ throw new CrudOperationError(
244
+ denied.status === 401 ? 401 : 403,
245
+ ErrorCode.FORBIDDEN,
246
+ 'Forbidden',
247
+ )
248
+ }
249
+
250
+ const scope = scopeFor(tableAccess.readScope, ctx)
251
+ if (scope && !rowMatchesScope(row, scope)) {
252
+ throw new CrudOperationError(404, ErrorCode.NOT_FOUND, 'Not found')
253
+ }
254
+
255
+ return row
256
+ },
257
+
258
+ async create(
259
+ tableName: string,
260
+ body: unknown,
261
+ rawBody: string | undefined,
262
+ idempotencyKey: string | undefined,
263
+ ctx: CrudExecutionContext,
264
+ ): Promise<CreateResult> {
265
+ const { table, tableAccess } = resolveTable(tableName)
266
+
267
+ const denied = await checkAccess(
268
+ tableAccess.create,
269
+ ctx,
270
+ tableAccess.ownerColumn,
271
+ )
272
+ if (!denied.allowed) {
273
+ throw new CrudOperationError(
274
+ denied.status === 401 ? 401 : 403,
275
+ ErrorCode.FORBIDDEN,
276
+ 'Forbidden',
277
+ )
278
+ }
279
+
280
+ if (!isRecord(body)) {
281
+ throw new CrudOperationError(
282
+ 400,
283
+ ErrorCode.BAD_REQUEST,
284
+ 'Invalid JSON body',
285
+ )
286
+ }
287
+
288
+ const trimmedKey = idempotencyKey?.trim()
289
+ const effectiveRawBody = rawBody ?? JSON.stringify(body)
290
+
291
+ if (idempotency && trimmedKey) {
292
+ const lookup = await lookupIdempotency(
293
+ db,
294
+ tableName,
295
+ trimmedKey,
296
+ effectiveRawBody,
297
+ idempotency,
298
+ )
299
+ if (lookup.type === 'conflict') {
300
+ throw new CrudOperationError(
301
+ 409,
302
+ ErrorCode.IDEMPOTENCY_CONFLICT,
303
+ 'Idempotency key reused with different body',
304
+ )
305
+ }
306
+ if (lookup.type === 'replay') {
307
+ return {
308
+ type: 'replay',
309
+ status: lookup.status,
310
+ body: lookup.response,
311
+ record: JSON.parse(lookup.response) as Record<string, unknown>,
312
+ }
313
+ }
314
+ }
315
+
316
+ const values = sanitizeWriteBody(
317
+ body,
318
+ tableAccess,
319
+ 'create',
320
+ ctx.user?.id ?? null,
321
+ )
322
+
323
+ const scope = scopeFor(tableAccess.writeScope, { ...ctx, body })
324
+ const stamped = scope ? stampScope(values, scope) : values
325
+
326
+ let rows: Record<string, unknown>[]
327
+ try {
328
+ rows = await db.insert(table).values(stamped).returning()
329
+ } catch (error) {
330
+ if (isUniqueConstraintError(error)) {
331
+ throw new CrudOperationError(
332
+ 409,
333
+ ErrorCode.CONFLICT,
334
+ 'Record already exists',
335
+ )
336
+ }
337
+ throw error
338
+ }
339
+ const created = rows[0] as Record<string, unknown>
340
+ void realtime?.publish(table as never, 'create', created as never)
341
+
342
+ if (idempotency && trimmedKey) {
343
+ await storeIdempotency(
344
+ db,
345
+ tableName,
346
+ trimmedKey,
347
+ effectiveRawBody,
348
+ 201,
349
+ created,
350
+ idempotency,
351
+ )
352
+ }
353
+
354
+ return {
355
+ type: 'created',
356
+ status: 201,
357
+ record: created,
358
+ }
359
+ },
360
+
361
+ async update(
362
+ tableName: string,
363
+ rawId: string | number,
364
+ body: unknown,
365
+ ctx: CrudExecutionContext,
366
+ ): Promise<Record<string, unknown>> {
367
+ const { table, tableAccess, idCol } = resolveTable(tableName)
368
+ const id = coerceId(rawId)
369
+
370
+ const existing = await db
371
+ .select()
372
+ .from(table)
373
+ .where(eq(idCol, id))
374
+ if (!existing[0]) {
375
+ throw new CrudOperationError(404, ErrorCode.NOT_FOUND, 'Not found')
376
+ }
377
+ const existingRow = existing[0] as Record<string, unknown>
378
+
379
+ const readScope = scopeFor(tableAccess.readScope, ctx)
380
+ if (readScope && !rowMatchesScope(existingRow, readScope)) {
381
+ throw new CrudOperationError(404, ErrorCode.NOT_FOUND, 'Not found')
382
+ }
383
+
384
+ const denied = await checkAccess(
385
+ tableAccess.update,
386
+ { ...ctx, row: existingRow },
387
+ tableAccess.ownerColumn,
388
+ )
389
+ if (!denied.allowed) {
390
+ throw new CrudOperationError(
391
+ denied.status === 401 ? 401 : 403,
392
+ ErrorCode.FORBIDDEN,
393
+ 'Forbidden',
394
+ )
395
+ }
396
+
397
+ if (!isRecord(body)) {
398
+ throw new CrudOperationError(
399
+ 400,
400
+ ErrorCode.BAD_REQUEST,
401
+ 'Invalid JSON body',
402
+ )
403
+ }
404
+
405
+ const values = sanitizeWriteBody(
406
+ body,
407
+ tableAccess,
408
+ 'update',
409
+ ctx.user?.id ?? null,
410
+ )
411
+
412
+ if (Object.keys(values).length === 0) {
413
+ throw new CrudOperationError(
414
+ 400,
415
+ ErrorCode.BAD_REQUEST,
416
+ 'No writable fields to update',
417
+ )
418
+ }
419
+
420
+ const writeScope = scopeFor(tableAccess.writeScope, { ...ctx, body })
421
+ const stamped = writeScope ? stampScope(values, writeScope) : values
422
+
423
+ const rows = await db
424
+ .update(table)
425
+ .set(stamped)
426
+ .where(eq(idCol, id))
427
+ .returning()
428
+
429
+ if (!rows[0]) {
430
+ throw new CrudOperationError(404, ErrorCode.NOT_FOUND, 'Not found')
431
+ }
432
+ const updated = rows[0] as Record<string, unknown>
433
+ void realtime?.publish(table as never, 'update', updated as never)
434
+ return updated
435
+ },
436
+
437
+ async delete(
438
+ tableName: string,
439
+ rawId: string | number,
440
+ ctx: CrudExecutionContext,
441
+ ): Promise<void> {
442
+ const { table, tableAccess, idCol } = resolveTable(tableName)
443
+ const id = coerceId(rawId)
444
+
445
+ const existing = await db
446
+ .select()
447
+ .from(table)
448
+ .where(eq(idCol, id))
449
+ if (!existing[0]) {
450
+ throw new CrudOperationError(404, ErrorCode.NOT_FOUND, 'Not found')
451
+ }
452
+ const existingRow = existing[0] as Record<string, unknown>
453
+
454
+ const readScope = scopeFor(tableAccess.readScope, ctx)
455
+ if (readScope && !rowMatchesScope(existingRow, readScope)) {
456
+ throw new CrudOperationError(404, ErrorCode.NOT_FOUND, 'Not found')
457
+ }
458
+
459
+ const denied = await checkAccess(
460
+ tableAccess.delete,
461
+ { ...ctx, row: existingRow },
462
+ tableAccess.ownerColumn,
463
+ )
464
+ if (!denied.allowed) {
465
+ throw new CrudOperationError(
466
+ denied.status === 401 ? 401 : 403,
467
+ ErrorCode.FORBIDDEN,
468
+ 'Forbidden',
469
+ )
470
+ }
471
+
472
+ await db.delete(table).where(eq(idCol, id))
473
+ void realtime?.publish(table as never, 'delete', existingRow as never)
474
+ },
475
+ }
476
+ }
477
+
478
+ export type CrudOperations = ReturnType<typeof createCrudOperations>
package/src/dialect.ts CHANGED
@@ -10,7 +10,7 @@ export type Dialect = 'sqlite' | 'pg'
10
10
  * Minimal structural view of a drizzle db shared by both dialects. Internal
11
11
  * modules run dynamic tables (Record<string, unknown> schemas) where drizzle's
12
12
  * generics add no safety, so they accept this instead of a per-dialect union.
13
- * The public surface (`app.db`, tRPC ctx) keeps full per-dialect typing via
13
+ * The public surface (`app.db`, API context) keeps full per-dialect typing via
14
14
  * `DbFor` in db.ts.
15
15
  */
16
16
  export type AnyDb = {
package/src/email/smtp.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import nodemailer from 'nodemailer'
2
2
 
3
- import type { EmailAdapter, EmailMessage } from '../email'
3
+ import type { EmailAdapter } from '../email'
4
4
 
5
5
  type SmtpTransport = {
6
6
  sendMail(message: Record<string, unknown>): Promise<{ messageId?: string }>
package/src/env.ts CHANGED
@@ -1,11 +1,16 @@
1
- // src/env.ts — env validation. Browser-safe: imports zod only.
2
- import { z, type ZodType } from 'zod'
1
+ // src/env.ts — env validation. Browser-safe: type-only Standard Schema import.
2
+ import type { StandardSchemaV1 } from '@standard-schema/spec'
3
+
4
+ import {
5
+ StandardSchemaValidationError,
6
+ validateStandardSchema,
7
+ } from './standard-schema'
3
8
 
4
9
  export const CLIENT_PREFIX = 'PUBLIC_' as const
5
10
 
6
11
  export type EnvConfigInput = {
7
- server?: Record<string, ZodType>
8
- client?: Record<string, ZodType>
12
+ server?: Record<string, StandardSchemaV1>
13
+ client?: Record<string, StandardSchemaV1>
9
14
  /** Explicit value source for client vars (e.g. Vite's import.meta.env). */
10
15
  runtimeEnv?: Record<string, unknown>
11
16
  }
@@ -27,8 +32,8 @@ export type BaseEnv = {
27
32
  }
28
33
 
29
34
  type InferVars<T> =
30
- T extends Record<string, ZodType>
31
- ? { [K in keyof T]: z.output<T[K]> }
35
+ T extends Record<string, StandardSchemaV1>
36
+ ? { [K in keyof T]: StandardSchemaV1.InferOutput<T[K]> }
32
37
  : unknown
33
38
 
34
39
  // Non-distributive so `ValidatedEnv<undefined>` is BaseEnv, not `never`.
@@ -62,7 +67,7 @@ export type ValidateEnvOptions = {
62
67
  const DEV_AUTH_SECRET = 'dev-secret-change-in-prod'
63
68
 
64
69
  function validateSection(
65
- section: Record<string, ZodType> | undefined,
70
+ section: Record<string, StandardSchemaV1> | undefined,
66
71
  kind: 'server' | 'client',
67
72
  source: Record<string, unknown>,
68
73
  issues: string[],
@@ -82,12 +87,13 @@ function validateSection(
82
87
  )
83
88
  continue
84
89
  }
85
- const result = schema.safeParse(source[key])
86
- if (result.success) {
87
- out[key] = result.data
88
- } else {
89
- for (const issue of result.error.issues) {
90
- issues.push(`${key}: ${issue.message}`)
90
+ try {
91
+ out[key] = validateStandardSchema(schema, source[key], 'env')
92
+ } catch (error) {
93
+ if (!(error instanceof StandardSchemaValidationError)) throw error
94
+ for (const issue of error.issues) {
95
+ const path = issue.path.map(String).join('.')
96
+ issues.push(`${key}${path ? `.${path}` : ''}: ${issue.message}`)
91
97
  }
92
98
  }
93
99
  }
package/src/errors.ts CHANGED
@@ -1,46 +1,118 @@
1
- import type { Context } from 'hono'
2
- import type { ContentfulStatusCode } from 'hono/utils/http-status'
1
+ import {
2
+ COMMON_ERROR_STATUS_MAP,
3
+ ORPCError,
4
+ os,
5
+ type ErrorMap,
6
+ } from '@orpc/server'
7
+ import * as v from 'valibot'
3
8
 
9
+ import { StandardSchemaValidationError } from './standard-schema'
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
+ */
17
+ export const BUNDERSTACK_ERROR_CODES = [
18
+ 'BAD_REQUEST',
19
+ 'UNAUTHORIZED',
20
+ 'FORBIDDEN',
21
+ 'NOT_FOUND',
22
+ 'CONFLICT',
23
+ 'PAYLOAD_TOO_LARGE',
24
+ 'TOO_MANY_REQUESTS',
25
+ ] as const satisfies readonly (keyof typeof COMMON_ERROR_STATUS_MAP)[]
26
+
27
+ export type BunderstackErrorCode = (typeof BUNDERSTACK_ERROR_CODES)[number]
28
+
29
+ function errorDataSchema<const TCode extends BunderstackErrorCode>(
30
+ code: TCode,
31
+ ) {
32
+ return v.strictObject({
33
+ code: v.literal(code),
34
+ details: v.optional(v.unknown()),
35
+ })
36
+ }
37
+
38
+ export const BUNDERSTACK_ERRORS = {
39
+ BAD_REQUEST: { data: errorDataSchema('BAD_REQUEST') },
40
+ UNAUTHORIZED: { data: errorDataSchema('UNAUTHORIZED') },
41
+ FORBIDDEN: { data: errorDataSchema('FORBIDDEN') },
42
+ NOT_FOUND: { data: errorDataSchema('NOT_FOUND') },
43
+ CONFLICT: { data: errorDataSchema('CONFLICT') },
44
+ PAYLOAD_TOO_LARGE: { data: errorDataSchema('PAYLOAD_TOO_LARGE') },
45
+ TOO_MANY_REQUESTS: { data: errorDataSchema('TOO_MANY_REQUESTS') },
46
+ } as const satisfies ErrorMap
47
+
48
+ export class BunderstackError extends Error {
49
+ readonly status: number
50
+
51
+ constructor(
52
+ readonly code: BunderstackErrorCode,
53
+ message: string,
54
+ readonly details?: unknown,
55
+ options?: ErrorOptions,
56
+ ) {
57
+ super(message, options)
58
+ this.name = 'BunderstackError'
59
+ this.status = COMMON_ERROR_STATUS_MAP[code]
60
+ }
61
+ }
62
+
63
+ export const mapBunderstackErrors = os
64
+ .errors(BUNDERSTACK_ERRORS)
65
+ .middleware(async ({ next }) => {
66
+ try {
67
+ return await next()
68
+ } catch (error) {
69
+ const mapped =
70
+ error instanceof StandardSchemaValidationError
71
+ ? new BunderstackError(
72
+ 'BAD_REQUEST',
73
+ error.message,
74
+ error.issues,
75
+ { cause: error },
76
+ )
77
+ : error
78
+ if (!(mapped instanceof BunderstackError)) throw error
79
+
80
+ throw new ORPCError(mapped.code, {
81
+ message: mapped.message,
82
+ data:
83
+ mapped.details === undefined
84
+ ? { code: mapped.code }
85
+ : { code: mapped.code, details: mapped.details },
86
+ cause: mapped,
87
+ })
88
+ }
89
+ })
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
+ */
4
96
  export const ErrorCode = {
5
- VALIDATION_ERROR: 'VALIDATION_ERROR',
97
+ BAD_REQUEST: 'BAD_REQUEST',
6
98
  FORBIDDEN: 'FORBIDDEN',
7
99
  NOT_FOUND: 'NOT_FOUND',
100
+ CONFLICT: 'CONFLICT',
8
101
  INVALID_CURSOR: 'INVALID_CURSOR',
9
- RATE_LIMITED: 'RATE_LIMITED',
102
+ TOO_MANY_REQUESTS: 'TOO_MANY_REQUESTS',
10
103
  IDEMPOTENCY_REPLAY: 'IDEMPOTENCY_REPLAY',
11
104
  IDEMPOTENCY_CONFLICT: 'IDEMPOTENCY_CONFLICT',
12
105
  } as const
13
106
 
14
107
  export type ErrorCodeValue = (typeof ErrorCode)[keyof typeof ErrorCode]
15
108
 
16
- export type ApiErrorBody = {
17
- error: string
18
- code?: ErrorCodeValue
19
- details?: unknown
20
- }
21
-
22
- export function apiError(
23
- c: Context,
24
- code: ErrorCodeValue,
25
- message: string,
26
- status: ContentfulStatusCode,
27
- details?: unknown,
28
- ) {
29
- const body: ApiErrorBody = {
30
- error: message,
31
- code,
32
- ...(details ? { details } : {}),
33
- }
34
- return c.json(body, status)
35
- }
36
-
37
109
  export class ListQueryError extends Error {
38
110
  readonly code: ErrorCodeValue
39
111
  readonly details?: unknown
40
112
 
41
113
  constructor(
42
114
  message: string,
43
- code: ErrorCodeValue = ErrorCode.VALIDATION_ERROR,
115
+ code: ErrorCodeValue = ErrorCode.BAD_REQUEST,
44
116
  details?: unknown,
45
117
  ) {
46
118
  super(message)