@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.
@@ -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