@avelonjs/supabase 0.1.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/LICENSE +21 -0
- package/README.md +270 -0
- package/package.json +53 -0
- package/src/database/compile.ts +198 -0
- package/src/database/driver.ts +437 -0
- package/src/database/errors.ts +93 -0
- package/src/database/fixtures.ts +206 -0
- package/src/database/index.ts +17 -0
- package/src/database/normalize.ts +1 -0
- package/src/database/wards.ts +124 -0
- package/src/identity/cookies.ts +57 -0
- package/src/identity/driver.ts +251 -0
- package/src/identity/index.ts +14 -0
- package/src/identity/local-auth.ts +209 -0
- package/src/identity/types.ts +19 -0
- package/src/index.ts +6 -0
- package/src/queue/driver.ts +268 -0
- package/src/queue/index.ts +6 -0
- package/src/social/driver.ts +130 -0
- package/src/social/index.ts +8 -0
- package/src/social/local-auth.ts +56 -0
- package/src/social/types.ts +7 -0
- package/src/storage/driver.ts +228 -0
- package/src/storage/index.ts +6 -0
- package/src/tokens/driver.ts +216 -0
- package/src/tokens/index.ts +6 -0
|
@@ -0,0 +1,437 @@
|
|
|
1
|
+
import { SQL } from 'bun'
|
|
2
|
+
import {
|
|
3
|
+
Invalid,
|
|
4
|
+
type DatabaseDriver,
|
|
5
|
+
type MigrationPlan,
|
|
6
|
+
type MigrationStatus,
|
|
7
|
+
type Predicate,
|
|
8
|
+
type QueryIR,
|
|
9
|
+
type QueryResult,
|
|
10
|
+
type RelationLoad,
|
|
11
|
+
type RowSecuritySurface,
|
|
12
|
+
} from '@avelonjs/core'
|
|
13
|
+
import {
|
|
14
|
+
applyMigrations,
|
|
15
|
+
assertQueryAgainstSchema,
|
|
16
|
+
combinedPredicate,
|
|
17
|
+
loadSchemaCache,
|
|
18
|
+
planMigrations,
|
|
19
|
+
rollbackMigrations,
|
|
20
|
+
statusMigrations,
|
|
21
|
+
validateQueryIR,
|
|
22
|
+
type PostgresMigration,
|
|
23
|
+
type SchemaCache,
|
|
24
|
+
} from '@avelonjs/postgres'
|
|
25
|
+
import { compilePostgrest } from './compile'
|
|
26
|
+
import { mapPostgrestError } from './errors'
|
|
27
|
+
import { resetSupabaseAssayFixtures } from './fixtures'
|
|
28
|
+
import { compileAllWardPolicies, type WardPolicy } from './wards'
|
|
29
|
+
|
|
30
|
+
/** Exact capability declaration for the Supabase/PostgREST database driver. */
|
|
31
|
+
export const supabaseDatabaseCapabilities = {
|
|
32
|
+
transactions: false,
|
|
33
|
+
rowSecurity: true,
|
|
34
|
+
/** Measured against live PostgREST embed/relation loading in M0; nested loads are application-side. */
|
|
35
|
+
maxRelationDepth: 2,
|
|
36
|
+
fullTextSearch: false,
|
|
37
|
+
upsert: true,
|
|
38
|
+
returning: true,
|
|
39
|
+
windowFunctions: false,
|
|
40
|
+
jsonOperators: true,
|
|
41
|
+
} as const
|
|
42
|
+
|
|
43
|
+
/** Construction options for {@link SupabaseDatabase}. */
|
|
44
|
+
export interface SupabaseDatabaseOptions {
|
|
45
|
+
/** PostgREST REST root, e.g. `http://127.0.0.1:3001`. */
|
|
46
|
+
restUrl?: string
|
|
47
|
+
/** Service-role JWT used for privileged conformance and sync operations. */
|
|
48
|
+
serviceRoleKey?: string
|
|
49
|
+
/** Direct Postgres URL for fixtures, schema cache, migrations, and RLS sync. */
|
|
50
|
+
databaseUrl?: string
|
|
51
|
+
/** Configured connection name. */
|
|
52
|
+
instance?: string
|
|
53
|
+
/** Ward policies applied by `syncWards()`. */
|
|
54
|
+
wards?: readonly WardPolicy[]
|
|
55
|
+
/** Driver-owned SQL migrations applied over the direct Postgres admin connection. */
|
|
56
|
+
migrations?: readonly PostgresMigration[]
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
type Row = Record<string, unknown>
|
|
60
|
+
|
|
61
|
+
function defaultRestUrl(): string {
|
|
62
|
+
return process.env.SUPABASE_REST_URL ?? process.env.REST_URL ?? 'http://127.0.0.1:3001'
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function defaultServiceRoleKey(): string {
|
|
66
|
+
return (
|
|
67
|
+
process.env.SUPABASE_SERVICE_ROLE_KEY ??
|
|
68
|
+
process.env.SERVICE_ROLE_KEY ??
|
|
69
|
+
// Local PostgREST test JWT for role=service_role (see packages/supabase/README.md).
|
|
70
|
+
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoic2VydmljZV9yb2xlIiwiaXNzIjoiYXZlbG9uIn0.pXO2ckfFU75vmRms3ufo5B8Vto-b2ESmMt4wnyj9GYY'
|
|
71
|
+
)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function defaultDatabaseUrl(): string {
|
|
75
|
+
return (
|
|
76
|
+
process.env.SUPABASE_DB_URL ??
|
|
77
|
+
process.env.DATABASE_URL ??
|
|
78
|
+
'postgresql://postgres:avelon@127.0.0.1:5432/avelon_supabase'
|
|
79
|
+
)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function includeColumns(select: string[] | '*', columns: string[]): string[] | '*' {
|
|
83
|
+
if (select === '*') return '*'
|
|
84
|
+
return [...new Set([...select, ...columns])]
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function projectRow(row: Row, select: string[] | '*', relations: readonly RelationLoad[]): Row {
|
|
88
|
+
const output: Row = {}
|
|
89
|
+
const columns =
|
|
90
|
+
select === '*'
|
|
91
|
+
? Object.keys(row).filter((column) => !relations.some((relation) => relation.relation === column))
|
|
92
|
+
: select
|
|
93
|
+
for (const column of columns) output[column] = row[column]
|
|
94
|
+
for (const relation of relations) output[relation.relation] = row[relation.relation]
|
|
95
|
+
return output
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function unique(values: unknown[]): unknown[] {
|
|
99
|
+
const output: unknown[] = []
|
|
100
|
+
for (const value of values) {
|
|
101
|
+
if (!output.some((existing) => existing === value)) output.push(value)
|
|
102
|
+
}
|
|
103
|
+
return output
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Supabase/PostgREST implementation of the frozen database contract.
|
|
108
|
+
*
|
|
109
|
+
* Query traffic goes through PostgREST. Fixture reset, schema introspection, migrations, and ward
|
|
110
|
+
* sync use a direct Postgres connection because those operations are not portable over REST.
|
|
111
|
+
*/
|
|
112
|
+
export class SupabaseDatabase
|
|
113
|
+
implements
|
|
114
|
+
DatabaseDriver<typeof supabaseDatabaseCapabilities, { restUrl: string }>,
|
|
115
|
+
RowSecuritySurface
|
|
116
|
+
{
|
|
117
|
+
readonly name = 'supabase'
|
|
118
|
+
readonly instance: string
|
|
119
|
+
readonly capabilities = supabaseDatabaseCapabilities
|
|
120
|
+
|
|
121
|
+
readonly #restUrl: string
|
|
122
|
+
readonly #serviceRoleKey: string
|
|
123
|
+
readonly #sql: SQL
|
|
124
|
+
readonly #wards: readonly WardPolicy[]
|
|
125
|
+
readonly #migrations: readonly PostgresMigration[]
|
|
126
|
+
#schema: SchemaCache | undefined
|
|
127
|
+
#roundTrips = 0
|
|
128
|
+
|
|
129
|
+
constructor(options: SupabaseDatabaseOptions = {}) {
|
|
130
|
+
this.instance = options.instance ?? 'default'
|
|
131
|
+
this.#restUrl = (options.restUrl ?? defaultRestUrl()).replace(/\/$/, '')
|
|
132
|
+
this.#serviceRoleKey = options.serviceRoleKey ?? defaultServiceRoleKey()
|
|
133
|
+
this.#sql = new SQL(options.databaseUrl ?? defaultDatabaseUrl())
|
|
134
|
+
this.#wards = options.wards ?? []
|
|
135
|
+
this.#migrations = options.migrations ?? []
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Number of PostgREST round trips. Used by conformance short-circuit checks. */
|
|
139
|
+
get roundTrips(): number {
|
|
140
|
+
return this.#roundTrips
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Returns the REST configuration at the vendor boundary. */
|
|
144
|
+
raw(): { restUrl: string } {
|
|
145
|
+
return { restUrl: this.#restUrl }
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Recreates assay fixtures and reloads the PostgREST schema cache. */
|
|
149
|
+
async resetFixtures(): Promise<void> {
|
|
150
|
+
await resetSupabaseAssayFixtures(this.#sql)
|
|
151
|
+
this.#schema = undefined
|
|
152
|
+
await this.#waitForSchema()
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Closes the direct Postgres admin client. */
|
|
156
|
+
async close(): Promise<void> {
|
|
157
|
+
await this.#sql.close()
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
async execute<TRow = Record<string, unknown>>(query: QueryIR): Promise<QueryResult<TRow>> {
|
|
161
|
+
return this.#execute(query, this.#serviceRoleKey) as Promise<QueryResult<TRow>>
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Executes a query as a specific bearer token.
|
|
166
|
+
*
|
|
167
|
+
* Used by the live RLS denial test so two authenticated identities can be compared.
|
|
168
|
+
*/
|
|
169
|
+
async executeAs<TRow = Record<string, unknown>>(
|
|
170
|
+
bearerToken: string,
|
|
171
|
+
query: QueryIR,
|
|
172
|
+
): Promise<QueryResult<TRow>> {
|
|
173
|
+
return this.#execute(query, bearerToken) as Promise<QueryResult<TRow>>
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
async rpc<TResult = unknown>(
|
|
177
|
+
routine: string,
|
|
178
|
+
args: Readonly<Record<string, unknown>>,
|
|
179
|
+
): Promise<TResult> {
|
|
180
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(routine)) {
|
|
181
|
+
throw new Invalid('Routine name must be a simple identifier.', {
|
|
182
|
+
metadata: { fields: { routine: [routine] } },
|
|
183
|
+
})
|
|
184
|
+
}
|
|
185
|
+
this.#roundTrips += 1
|
|
186
|
+
const response = await fetch(`${this.#restUrl}/rpc/${routine}`, {
|
|
187
|
+
method: 'POST',
|
|
188
|
+
headers: {
|
|
189
|
+
...this.#authHeaders(this.#serviceRoleKey),
|
|
190
|
+
'Content-Type': 'application/json',
|
|
191
|
+
Accept: 'application/json',
|
|
192
|
+
},
|
|
193
|
+
// PostgREST matches JSON keys to function argument names; assay_echo(args jsonb).
|
|
194
|
+
body: JSON.stringify({ args }),
|
|
195
|
+
})
|
|
196
|
+
const text = await response.text()
|
|
197
|
+
if (!response.ok) mapPostgrestError(response.status, text, 'rpc')
|
|
198
|
+
if (text.length === 0) return undefined as TResult
|
|
199
|
+
const parsed = JSON.parse(text) as unknown
|
|
200
|
+
return decodeJsonResult(parsed) as TResult
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** Produces the pending migration plan from the direct Postgres connection. */
|
|
204
|
+
async plan(): Promise<MigrationPlan> {
|
|
205
|
+
return planMigrations(this.#sql, this.#migrations)
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** Applies pending driver-owned migrations. */
|
|
209
|
+
async apply(): Promise<readonly MigrationStatus[]> {
|
|
210
|
+
return applyMigrations(this.#sql, this.#migrations)
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** Rolls back applied migration batches. */
|
|
214
|
+
async rollback(steps?: number): Promise<readonly MigrationStatus[]> {
|
|
215
|
+
return rollbackMigrations(this.#sql, this.#migrations, steps)
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** Returns all migration states. */
|
|
219
|
+
async status(): Promise<readonly MigrationStatus[]> {
|
|
220
|
+
return statusMigrations(this.#sql, this.#migrations)
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** Compiles registered wards into Postgres RLS policies and applies them. */
|
|
224
|
+
async syncWards(): Promise<void> {
|
|
225
|
+
const statements = compileAllWardPolicies(this.#wards)
|
|
226
|
+
for (const statement of statements) {
|
|
227
|
+
await this.#sql.unsafe(statement)
|
|
228
|
+
}
|
|
229
|
+
await this.#sql.unsafe(`NOTIFY pgrst, 'reload schema'`)
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
async #schemaCache(): Promise<SchemaCache> {
|
|
233
|
+
if (this.#schema === undefined) this.#schema = await loadSchemaCache(this.#sql)
|
|
234
|
+
return this.#schema
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
async #execute(ir: QueryIR, bearerToken: string): Promise<QueryResult> {
|
|
238
|
+
validateQueryIR(ir, this.capabilities.maxRelationDepth)
|
|
239
|
+
assertQueryAgainstSchema(await this.#schemaCache(), ir)
|
|
240
|
+
const predicate = combinedPredicate(ir)
|
|
241
|
+
|
|
242
|
+
if (predicate.kind === 'const' && !predicate.value) {
|
|
243
|
+
return ir.mode === 'count' ? { rows: [], affected: 0, count: 0 } : { rows: [], affected: 0 }
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
if ((ir.mode === 'insert' || ir.mode === 'upsert') && predicate.kind !== 'const') {
|
|
247
|
+
throw new Invalid(`${ir.mode} with a non-constant where or ward has no documented row scope.`, {
|
|
248
|
+
metadata: { fields: { where: [`${ir.mode} predicates are not documented`] } },
|
|
249
|
+
})
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
if (ir.mode === 'select') {
|
|
253
|
+
const primitive: QueryIR = {
|
|
254
|
+
...ir,
|
|
255
|
+
select: includeColumns(
|
|
256
|
+
ir.select,
|
|
257
|
+
ir.relations.map((relation) => relation.localKey),
|
|
258
|
+
),
|
|
259
|
+
relations: [],
|
|
260
|
+
}
|
|
261
|
+
const rows = await this.#executePrimitive(primitive, predicate, bearerToken)
|
|
262
|
+
await this.#loadRelations(rows, ir.relations, bearerToken)
|
|
263
|
+
return {
|
|
264
|
+
rows: rows.map((row) => projectRow(row, ir.select, ir.relations)),
|
|
265
|
+
affected: 0,
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
if (ir.mode === 'count') {
|
|
270
|
+
const count = await this.#executeCount(ir, predicate, bearerToken)
|
|
271
|
+
return { rows: [], affected: 0, count }
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const rows = await this.#executePrimitive(ir, predicate, bearerToken)
|
|
275
|
+
return {
|
|
276
|
+
rows: ir.returning === undefined ? [] : projectReturning(rows, ir.returning),
|
|
277
|
+
affected: rows.length,
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
async #executeCount(ir: QueryIR, predicate: Predicate, bearerToken: string): Promise<number> {
|
|
282
|
+
const compiled = compilePostgrest({ ...ir, mode: 'count', select: [], order: [], relations: [] }, predicate)
|
|
283
|
+
const url = new URL(`${this.#restUrl}${compiled.path}`)
|
|
284
|
+
if (compiled.query) url.search = compiled.query
|
|
285
|
+
this.#roundTrips += 1
|
|
286
|
+
const response = await fetch(url, {
|
|
287
|
+
method: 'HEAD',
|
|
288
|
+
headers: { ...this.#authHeaders(bearerToken), ...compiled.headers },
|
|
289
|
+
})
|
|
290
|
+
if (!response.ok) {
|
|
291
|
+
mapPostgrestError(response.status, await response.text(), 'count')
|
|
292
|
+
}
|
|
293
|
+
const range = response.headers.get('content-range')
|
|
294
|
+
if (range === null) {
|
|
295
|
+
throw new Invalid('PostgREST returned no content-range for a count.', {
|
|
296
|
+
metadata: { fields: { mode: ['Expected exact count'] } },
|
|
297
|
+
})
|
|
298
|
+
}
|
|
299
|
+
const total = range.split('/')[1]
|
|
300
|
+
if (total === undefined || total === '*') {
|
|
301
|
+
throw new Invalid(`PostgREST count was not exact: ${range}`, {
|
|
302
|
+
metadata: { fields: { mode: [range] } },
|
|
303
|
+
})
|
|
304
|
+
}
|
|
305
|
+
return Number(total)
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
async #executePrimitive(
|
|
309
|
+
ir: QueryIR,
|
|
310
|
+
predicate: Predicate,
|
|
311
|
+
bearerToken: string,
|
|
312
|
+
): Promise<Row[]> {
|
|
313
|
+
const compiled = compilePostgrest(ir, predicate)
|
|
314
|
+
const path = compiled.query ? `${compiled.path}?${compiled.query}` : compiled.path
|
|
315
|
+
const rows = await this.#requestRows(path, {
|
|
316
|
+
method: compiled.method,
|
|
317
|
+
headers: { ...this.#authHeaders(bearerToken), ...compiled.headers },
|
|
318
|
+
body: compiled.body,
|
|
319
|
+
})
|
|
320
|
+
if (compiled.path === '/rpc/avelon_upsert_subset') {
|
|
321
|
+
// RPC returns a JSON array value; PostgREST may wrap it as [{ avelon_upsert_subset: [...] }].
|
|
322
|
+
if (rows.length === 1 && Array.isArray(rows[0]?.avelon_upsert_subset)) {
|
|
323
|
+
return rows[0].avelon_upsert_subset as Row[]
|
|
324
|
+
}
|
|
325
|
+
return rows
|
|
326
|
+
}
|
|
327
|
+
return rows
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
async #loadRelations(
|
|
331
|
+
parents: Row[],
|
|
332
|
+
relations: readonly RelationLoad[],
|
|
333
|
+
bearerToken: string,
|
|
334
|
+
): Promise<void> {
|
|
335
|
+
for (const relation of relations) {
|
|
336
|
+
const keys = unique(
|
|
337
|
+
parents
|
|
338
|
+
.map((parent) => parent[relation.localKey])
|
|
339
|
+
.filter((value) => value !== null && value !== undefined),
|
|
340
|
+
)
|
|
341
|
+
if (keys.length === 0) {
|
|
342
|
+
for (const parent of parents) {
|
|
343
|
+
parent[relation.relation] = relation.kind === 'hasMany' ? [] : null
|
|
344
|
+
}
|
|
345
|
+
continue
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
const childSelect = includeColumns(relation.select, [
|
|
349
|
+
relation.foreignKey,
|
|
350
|
+
...relation.relations.map((nested) => nested.localKey),
|
|
351
|
+
])
|
|
352
|
+
const childWhere: Predicate[] = [
|
|
353
|
+
...(relation.where === null ? [] : [relation.where]),
|
|
354
|
+
{ kind: 'in', column: relation.foreignKey, values: keys, negated: false },
|
|
355
|
+
]
|
|
356
|
+
const childIR: QueryIR = {
|
|
357
|
+
table: relation.table,
|
|
358
|
+
mode: 'select',
|
|
359
|
+
select: childSelect,
|
|
360
|
+
where: childWhere,
|
|
361
|
+
relations: [],
|
|
362
|
+
order: relation.order,
|
|
363
|
+
}
|
|
364
|
+
const predicate = combinedPredicate(childIR)
|
|
365
|
+
const children =
|
|
366
|
+
predicate.kind === 'const' && !predicate.value
|
|
367
|
+
? []
|
|
368
|
+
: await this.#executePrimitive(childIR, predicate, bearerToken)
|
|
369
|
+
await this.#loadRelations(children, relation.relations, bearerToken)
|
|
370
|
+
|
|
371
|
+
for (const parent of parents) {
|
|
372
|
+
const matches = children.filter(
|
|
373
|
+
(child) => child[relation.foreignKey] === parent[relation.localKey],
|
|
374
|
+
)
|
|
375
|
+
const limited = relation.limit === undefined ? matches : matches.slice(0, relation.limit)
|
|
376
|
+
const projected = limited.map((child) =>
|
|
377
|
+
projectRow(child, relation.select, relation.relations),
|
|
378
|
+
)
|
|
379
|
+
parent[relation.relation] = relation.kind === 'hasMany' ? projected : (projected[0] ?? null)
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
async #requestRows(path: string, init: RequestInit): Promise<Row[]> {
|
|
385
|
+
this.#roundTrips += 1
|
|
386
|
+
const response = await fetch(`${this.#restUrl}${path}`, init)
|
|
387
|
+
const text = await response.text()
|
|
388
|
+
if (!response.ok) mapPostgrestError(response.status, text, 'execute')
|
|
389
|
+
if (text.length === 0) return []
|
|
390
|
+
const parsed = JSON.parse(text) as unknown
|
|
391
|
+
if (Array.isArray(parsed)) return parsed.map((row) => ({ ...(row as Row) }))
|
|
392
|
+
if (typeof parsed === 'object' && parsed !== null) return [{ ...(parsed as Row) }]
|
|
393
|
+
return [{ value: parsed }]
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
#authHeaders(bearerToken: string): Record<string, string> {
|
|
397
|
+
return {
|
|
398
|
+
apikey: bearerToken,
|
|
399
|
+
Authorization: `Bearer ${bearerToken}`,
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
async #waitForSchema(): Promise<void> {
|
|
404
|
+
for (let attempt = 0; attempt < 20; attempt += 1) {
|
|
405
|
+
const response = await fetch(`${this.#restUrl}/assay_users?select=id&limit=0`, {
|
|
406
|
+
headers: this.#authHeaders(this.#serviceRoleKey),
|
|
407
|
+
})
|
|
408
|
+
if (response.ok || response.status === 200 || response.status === 206) return
|
|
409
|
+
await Bun.sleep(100)
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/** Creates a Supabase database driver from options or environment defaults. */
|
|
415
|
+
export function createSupabaseDatabase(options: SupabaseDatabaseOptions = {}): SupabaseDatabase {
|
|
416
|
+
return new SupabaseDatabase(options)
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
function projectReturning(rows: Row[], returning: string[] | '*'): Row[] {
|
|
420
|
+
if (returning === '*') return rows
|
|
421
|
+
return rows.map((row) => {
|
|
422
|
+
const projected: Row = {}
|
|
423
|
+
for (const column of returning) projected[column] = row[column]
|
|
424
|
+
return projected
|
|
425
|
+
})
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
function decodeJsonResult(value: unknown): unknown {
|
|
429
|
+
if (typeof value === 'string') {
|
|
430
|
+
try {
|
|
431
|
+
return JSON.parse(value)
|
|
432
|
+
} catch {
|
|
433
|
+
return value
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
return value
|
|
437
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { Conflict, DriverFault, Invalid, Unavailable } from '@avelonjs/core'
|
|
2
|
+
|
|
3
|
+
interface PostgrestErrorBody {
|
|
4
|
+
readonly code?: string
|
|
5
|
+
readonly message?: string
|
|
6
|
+
readonly details?: string | null
|
|
7
|
+
readonly hint?: string | null
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
11
|
+
return typeof value === 'object' && value !== null
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Maps a PostgREST HTTP failure into the framework error taxonomy.
|
|
16
|
+
*
|
|
17
|
+
* Unique violations become `Conflict`. Missing tables, columns, and routines become `Invalid`.
|
|
18
|
+
*/
|
|
19
|
+
export function mapPostgrestError(
|
|
20
|
+
status: number,
|
|
21
|
+
bodyText: string,
|
|
22
|
+
operation: string,
|
|
23
|
+
): never {
|
|
24
|
+
let parsed: PostgrestErrorBody | undefined
|
|
25
|
+
try {
|
|
26
|
+
const value = JSON.parse(bodyText) as unknown
|
|
27
|
+
if (isRecord(value)) parsed = value as PostgrestErrorBody
|
|
28
|
+
} catch {
|
|
29
|
+
parsed = undefined
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const code = parsed?.code
|
|
33
|
+
const message =
|
|
34
|
+
parsed?.message ?? (bodyText || `PostgREST ${operation} failed (${status})`)
|
|
35
|
+
|
|
36
|
+
if (code === '23505' || /duplicate key value/i.test(message)) {
|
|
37
|
+
throw new Conflict(message, {
|
|
38
|
+
metadata: { resource: undefined, key: code },
|
|
39
|
+
cause: { status, bodyText },
|
|
40
|
+
})
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (
|
|
44
|
+
status === 404 ||
|
|
45
|
+
code === '42P01' ||
|
|
46
|
+
code === '42703' ||
|
|
47
|
+
code === 'PGRST205' ||
|
|
48
|
+
code === 'PGRST204' ||
|
|
49
|
+
/does not exist/i.test(message) ||
|
|
50
|
+
/could not find/i.test(message)
|
|
51
|
+
) {
|
|
52
|
+
const field = /function|routine|rpc/i.test(message)
|
|
53
|
+
? 'routine'
|
|
54
|
+
: /column/i.test(message)
|
|
55
|
+
? 'select'
|
|
56
|
+
: 'table'
|
|
57
|
+
const routine = field === 'routine' ? (extractRoutine(message) ?? message) : message
|
|
58
|
+
throw new Invalid(message, {
|
|
59
|
+
metadata: { fields: { [field]: [routine] } },
|
|
60
|
+
cause: { status, bodyText },
|
|
61
|
+
})
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (status === 503 || status === 502) {
|
|
65
|
+
throw new Unavailable(message, {
|
|
66
|
+
metadata: { service: 'supabase-postgrest' },
|
|
67
|
+
cause: { status, bodyText },
|
|
68
|
+
})
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
throw new DriverFault(message, {
|
|
72
|
+
metadata: { driver: 'database', operation },
|
|
73
|
+
cause: { status, bodyText, code },
|
|
74
|
+
})
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function extractRoutine(message: string): string | undefined {
|
|
78
|
+
const match =
|
|
79
|
+
/function ([a-zA-Z0-9_.]+)/i.exec(message) ?? /rpc[_/ ]([a-zA-Z0-9_]+)/i.exec(message)
|
|
80
|
+
const name = match?.[1]
|
|
81
|
+
if (name === undefined) return undefined
|
|
82
|
+
// Conformance asserts the bare routine name; PostgREST may qualify it with a schema.
|
|
83
|
+
return name.includes('.') ? (name.split('.').at(-1) as string) : name
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Documented PostgREST/Postgres codes this driver maps. */
|
|
87
|
+
export const SUPABASE_DATABASE_ERROR_MAP = [
|
|
88
|
+
{ code: '23505', framework: 'Conflict', meaning: 'unique_violation' },
|
|
89
|
+
{ code: '42P01', framework: 'Invalid', meaning: 'undefined_table' },
|
|
90
|
+
{ code: '42703', framework: 'Invalid', meaning: 'undefined_column' },
|
|
91
|
+
{ code: 'PGRST205', framework: 'Invalid', meaning: 'table_not_found_in_schema_cache' },
|
|
92
|
+
{ code: 'PGRST204', framework: 'Invalid', meaning: 'column_not_found_in_schema_cache' },
|
|
93
|
+
] as const
|