@avelonjs/supabase 0.1.0 → 0.3.1

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.
@@ -1,6 +1,6 @@
1
- import { SQL } from 'bun'
2
1
  import {
3
2
  Invalid,
3
+ Unavailable,
4
4
  type DatabaseDriver,
5
5
  type MigrationPlan,
6
6
  type MigrationStatus,
@@ -14,17 +14,18 @@ import {
14
14
  applyMigrations,
15
15
  assertQueryAgainstSchema,
16
16
  combinedPredicate,
17
- loadSchemaCache,
18
17
  planMigrations,
19
18
  rollbackMigrations,
20
19
  statusMigrations,
21
20
  validateQueryIR,
22
21
  type PostgresMigration,
23
22
  type SchemaCache,
24
- } from '@avelonjs/postgres'
23
+ type SqlBatchRunner,
24
+ } from '@avelonjs/postgres/sql'
25
25
  import { compilePostgrest } from './compile'
26
26
  import { mapPostgrestError } from './errors'
27
27
  import { resetSupabaseAssayFixtures } from './fixtures'
28
+ import { loadSchemaCacheFromRest } from './schema-rest'
28
29
  import { compileAllWardPolicies, type WardPolicy } from './wards'
29
30
 
30
31
  /** Exact capability declaration for the Supabase/PostgREST database driver. */
@@ -46,8 +47,12 @@ export interface SupabaseDatabaseOptions {
46
47
  restUrl?: string
47
48
  /** Service-role JWT used for privileged conformance and sync operations. */
48
49
  serviceRoleKey?: string
49
- /** Direct Postgres URL for fixtures, schema cache, migrations, and RLS sync. */
50
- databaseUrl?: string
50
+ /**
51
+ * Statement runner over a direct Postgres connection, used by migrations, fixtures and ward
52
+ * sync. Absent on a runtime with no Postgres socket, where those operations raise
53
+ * {@link Unavailable} and the PostgREST request path still works.
54
+ */
55
+ admin?: SqlBatchRunner
51
56
  /** Configured connection name. */
52
57
  instance?: string
53
58
  /** Ward policies applied by `syncWards()`. */
@@ -88,7 +93,9 @@ function projectRow(row: Row, select: string[] | '*', relations: readonly Relati
88
93
  const output: Row = {}
89
94
  const columns =
90
95
  select === '*'
91
- ? Object.keys(row).filter((column) => !relations.some((relation) => relation.relation === column))
96
+ ? Object.keys(row).filter(
97
+ (column) => !relations.some((relation) => relation.relation === column),
98
+ )
92
99
  : select
93
100
  for (const column of columns) output[column] = row[column]
94
101
  for (const relation of relations) output[relation.relation] = row[relation.relation]
@@ -120,7 +127,7 @@ export class SupabaseDatabase
120
127
 
121
128
  readonly #restUrl: string
122
129
  readonly #serviceRoleKey: string
123
- readonly #sql: SQL
130
+ readonly #admin: SqlBatchRunner | undefined
124
131
  readonly #wards: readonly WardPolicy[]
125
132
  readonly #migrations: readonly PostgresMigration[]
126
133
  #schema: SchemaCache | undefined
@@ -130,7 +137,7 @@ export class SupabaseDatabase
130
137
  this.instance = options.instance ?? 'default'
131
138
  this.#restUrl = (options.restUrl ?? defaultRestUrl()).replace(/\/$/, '')
132
139
  this.#serviceRoleKey = options.serviceRoleKey ?? defaultServiceRoleKey()
133
- this.#sql = new SQL(options.databaseUrl ?? defaultDatabaseUrl())
140
+ this.#admin = options.admin
134
141
  this.#wards = options.wards ?? []
135
142
  this.#migrations = options.migrations ?? []
136
143
  }
@@ -147,14 +154,26 @@ export class SupabaseDatabase
147
154
 
148
155
  /** Recreates assay fixtures and reloads the PostgREST schema cache. */
149
156
  async resetFixtures(): Promise<void> {
150
- await resetSupabaseAssayFixtures(this.#sql)
157
+ await resetSupabaseAssayFixtures(this.#requireAdmin('resetFixtures'))
151
158
  this.#schema = undefined
152
159
  await this.#waitForSchema()
153
160
  }
154
161
 
155
- /** Closes the direct Postgres admin client. */
162
+ /** Releases the direct Postgres admin client when one was configured. */
156
163
  async close(): Promise<void> {
157
- await this.#sql.close()
164
+ await this.#admin?.close?.()
165
+ }
166
+
167
+ #requireAdmin(operation: string): SqlBatchRunner {
168
+ if (this.#admin === undefined) {
169
+ throw new Unavailable(
170
+ `${operation} needs a direct Postgres connection. Build the driver through ` +
171
+ '`@avelonjs/supabase/database/bun` under the Bun CLI; the PostgREST request path ' +
172
+ 'has no socket to run it over.',
173
+ { metadata: { service: 'database' } },
174
+ )
175
+ }
176
+ return this.#admin
158
177
  }
159
178
 
160
179
  async execute<TRow = Record<string, unknown>>(query: QueryIR): Promise<QueryResult<TRow>> {
@@ -202,35 +221,40 @@ export class SupabaseDatabase
202
221
 
203
222
  /** Produces the pending migration plan from the direct Postgres connection. */
204
223
  async plan(): Promise<MigrationPlan> {
205
- return planMigrations(this.#sql, this.#migrations)
224
+ return planMigrations(this.#requireAdmin('plan'), this.#migrations)
206
225
  }
207
226
 
208
227
  /** Applies pending driver-owned migrations. */
209
228
  async apply(): Promise<readonly MigrationStatus[]> {
210
- return applyMigrations(this.#sql, this.#migrations)
229
+ return applyMigrations(this.#requireAdmin('apply'), this.#migrations)
211
230
  }
212
231
 
213
232
  /** Rolls back applied migration batches. */
214
233
  async rollback(steps?: number): Promise<readonly MigrationStatus[]> {
215
- return rollbackMigrations(this.#sql, this.#migrations, steps)
234
+ return rollbackMigrations(this.#requireAdmin('rollback'), this.#migrations, steps)
216
235
  }
217
236
 
218
237
  /** Returns all migration states. */
219
238
  async status(): Promise<readonly MigrationStatus[]> {
220
- return statusMigrations(this.#sql, this.#migrations)
239
+ return statusMigrations(this.#requireAdmin('status'), this.#migrations)
221
240
  }
222
241
 
223
242
  /** Compiles registered wards into Postgres RLS policies and applies them. */
224
243
  async syncWards(): Promise<void> {
225
- const statements = compileAllWardPolicies(this.#wards)
226
- for (const statement of statements) {
227
- await this.#sql.unsafe(statement)
244
+ const admin = this.#requireAdmin('syncWards')
245
+ for (const statement of compileAllWardPolicies(this.#wards)) {
246
+ await admin.unsafe(statement)
228
247
  }
229
- await this.#sql.unsafe(`NOTIFY pgrst, 'reload schema'`)
248
+ await admin.unsafe(`NOTIFY pgrst, 'reload schema'`)
230
249
  }
231
250
 
232
251
  async #schemaCache(): Promise<SchemaCache> {
233
- if (this.#schema === undefined) this.#schema = await loadSchemaCache(this.#sql)
252
+ if (this.#schema === undefined) {
253
+ this.#schema = await loadSchemaCacheFromRest(
254
+ this.#restUrl,
255
+ this.#authHeaders(this.#serviceRoleKey),
256
+ )
257
+ }
234
258
  return this.#schema
235
259
  }
236
260
 
@@ -244,9 +268,12 @@ export class SupabaseDatabase
244
268
  }
245
269
 
246
270
  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
- })
271
+ throw new Invalid(
272
+ `${ir.mode} with a non-constant where or ward has no documented row scope.`,
273
+ {
274
+ metadata: { fields: { where: [`${ir.mode} predicates are not documented`] } },
275
+ },
276
+ )
250
277
  }
251
278
 
252
279
  if (ir.mode === 'select') {
@@ -279,7 +306,10 @@ export class SupabaseDatabase
279
306
  }
280
307
 
281
308
  async #executeCount(ir: QueryIR, predicate: Predicate, bearerToken: string): Promise<number> {
282
- const compiled = compilePostgrest({ ...ir, mode: 'count', select: [], order: [], relations: [] }, predicate)
309
+ const compiled = compilePostgrest(
310
+ { ...ir, mode: 'count', select: [], order: [], relations: [] },
311
+ predicate,
312
+ )
283
313
  const url = new URL(`${this.#restUrl}${compiled.path}`)
284
314
  if (compiled.query) url.search = compiled.query
285
315
  this.#roundTrips += 1
@@ -305,11 +335,7 @@ export class SupabaseDatabase
305
335
  return Number(total)
306
336
  }
307
337
 
308
- async #executePrimitive(
309
- ir: QueryIR,
310
- predicate: Predicate,
311
- bearerToken: string,
312
- ): Promise<Row[]> {
338
+ async #executePrimitive(ir: QueryIR, predicate: Predicate, bearerToken: string): Promise<Row[]> {
313
339
  const compiled = compilePostgrest(ir, predicate)
314
340
  const path = compiled.query ? `${compiled.path}?${compiled.query}` : compiled.path
315
341
  const rows = await this.#requestRows(path, {
@@ -16,11 +16,7 @@ function isRecord(value: unknown): value is Record<string, unknown> {
16
16
  *
17
17
  * Unique violations become `Conflict`. Missing tables, columns, and routines become `Invalid`.
18
18
  */
19
- export function mapPostgrestError(
20
- status: number,
21
- bodyText: string,
22
- operation: string,
23
- ): never {
19
+ export function mapPostgrestError(status: number, bodyText: string, operation: string): never {
24
20
  let parsed: PostgrestErrorBody | undefined
25
21
  try {
26
22
  const value = JSON.parse(bodyText) as unknown
@@ -30,8 +26,7 @@ export function mapPostgrestError(
30
26
  }
31
27
 
32
28
  const code = parsed?.code
33
- const message =
34
- parsed?.message ?? (bodyText || `PostgREST ${operation} failed (${status})`)
29
+ const message = parsed?.message ?? (bodyText || `PostgREST ${operation} failed (${status})`)
35
30
 
36
31
  if (code === '23505' || /duplicate key value/i.test(message)) {
37
32
  throw new Conflict(message, {
@@ -1,4 +1,4 @@
1
- import type { SQL } from 'bun'
1
+ import type { SqlRunner } from '@avelonjs/postgres/sql'
2
2
 
3
3
  /** SQL that provisions assay fixtures, upsert RPC, and a minimal auth.uid() helper. */
4
4
  export const SUPABASE_ASSAY_FIXTURE_SQL = `
@@ -201,6 +201,6 @@ NOTIFY pgrst, 'reload schema';
201
201
  `
202
202
 
203
203
  /** Provisions empty assay fixtures and helper routines for live Supabase database tests. */
204
- export async function resetSupabaseAssayFixtures(sql: SQL): Promise<void> {
205
- await sql.unsafe(SUPABASE_ASSAY_FIXTURE_SQL)
204
+ export async function resetSupabaseAssayFixtures(runner: SqlRunner): Promise<void> {
205
+ await runner.unsafe(SUPABASE_ASSAY_FIXTURE_SQL)
206
206
  }
@@ -6,6 +6,7 @@ export {
6
6
  type SupabaseDatabaseOptions,
7
7
  } from './driver'
8
8
  export { mapPostgrestError, SUPABASE_DATABASE_ERROR_MAP } from './errors'
9
+ export { loadSchemaCacheFromRest } from './schema-rest'
9
10
  export { resetSupabaseAssayFixtures, SUPABASE_ASSAY_FIXTURE_SQL } from './fixtures'
10
11
  export { combinedPredicate, normalizePredicate } from './normalize'
11
12
  export {
@@ -14,4 +15,4 @@ export {
14
15
  compileWardPolicySql,
15
16
  type WardPolicy,
16
17
  } from './wards'
17
- export type { PostgresMigration } from '@avelonjs/postgres'
18
+ export type { PostgresMigration } from '@avelonjs/postgres/sql'
@@ -1 +1 @@
1
- export { combinedPredicate, normalizePredicate } from '@avelonjs/postgres'
1
+ export { combinedPredicate, normalizePredicate } from '@avelonjs/postgres/sql'
@@ -0,0 +1,54 @@
1
+ import type { SchemaCache } from '@avelonjs/postgres/sql'
2
+ import { mapPostgrestError } from './errors'
3
+
4
+ interface OpenApiDefinition {
5
+ properties?: Record<string, unknown>
6
+ }
7
+
8
+ interface OpenApiDocument {
9
+ definitions?: Record<string, OpenApiDefinition>
10
+ }
11
+
12
+ function asDocument(payload: unknown): OpenApiDocument {
13
+ if (payload === null || typeof payload !== 'object') return {}
14
+ const definitions = Reflect.get(payload, 'definitions')
15
+ if (definitions === null || typeof definitions !== 'object') return {}
16
+ return { definitions: definitions as Record<string, OpenApiDefinition> }
17
+ }
18
+
19
+ function columnsOf(definition: OpenApiDefinition): Set<string> {
20
+ const properties = definition.properties
21
+ if (properties === undefined || properties === null) return new Set()
22
+ return new Set(Object.keys(properties))
23
+ }
24
+
25
+ /**
26
+ * Reads the table and column set from PostgREST's OpenAPI document at the REST root.
27
+ *
28
+ * PostgREST filters that document by the role in the bearer token, so callers pass the same token
29
+ * their queries use. A token that cannot see a table would otherwise produce a cache that rejects
30
+ * queries the same token is allowed to run.
31
+ */
32
+ export async function loadSchemaCacheFromRest(
33
+ restUrl: string,
34
+ headers: Readonly<Record<string, string>>,
35
+ ): Promise<SchemaCache> {
36
+ const response = await fetch(`${restUrl}/`, {
37
+ headers: { ...headers, Accept: 'application/openapi+json' },
38
+ })
39
+ const text = await response.text()
40
+ if (!response.ok) mapPostgrestError(response.status, text, 'schema.load')
41
+
42
+ let payload: unknown
43
+ try {
44
+ payload = JSON.parse(text)
45
+ } catch {
46
+ mapPostgrestError(response.status, text, 'schema.load')
47
+ }
48
+
49
+ const cache: SchemaCache = new Map()
50
+ for (const [table, definition] of Object.entries(asDocument(payload).definitions ?? {})) {
51
+ cache.set(table, columnsOf(definition))
52
+ }
53
+ return cache
54
+ }
@@ -1,5 +1,5 @@
1
1
  import { Invalid, type CompareOp, type Predicate } from '@avelonjs/core'
2
- import { normalizePredicate } from '@avelonjs/postgres'
2
+ import { normalizePredicate } from '@avelonjs/postgres/sql'
3
3
 
4
4
  /** One ward policy synchronized into Postgres RLS. */
5
5
  export interface WardPolicy {
@@ -83,7 +83,12 @@ function compileValue(value: unknown): string {
83
83
  }
84
84
 
85
85
  function isClaim(value: unknown): value is { claim: string } {
86
- return typeof value === 'object' && value !== null && 'claim' in value && typeof (value as { claim: unknown }).claim === 'string'
86
+ return (
87
+ typeof value === 'object' &&
88
+ value !== null &&
89
+ 'claim' in value &&
90
+ typeof (value as { claim: unknown }).claim === 'string'
91
+ )
87
92
  }
88
93
 
89
94
  /** Builds DROP/CREATE POLICY statements for one ward policy. */
@@ -94,18 +99,13 @@ export function compileWardPolicySql(policy: WardPolicy): string[] {
94
99
  const command = policy.command.toUpperCase()
95
100
  const statements = [`DROP POLICY IF EXISTS ${name} ON ${table}`]
96
101
 
97
- const using =
98
- policy.using === undefined ? undefined : compileWardPredicate(policy.using)
99
- const check =
100
- policy.check === undefined ? undefined : compileWardPredicate(policy.check)
102
+ const using = policy.using === undefined ? undefined : compileWardPredicate(policy.using)
103
+ const check = policy.check === undefined ? undefined : compileWardPredicate(policy.check)
101
104
 
102
105
  if (policy.command === 'insert' && check === undefined) {
103
106
  invalid(`Insert ward policy '${policy.name}' requires a check predicate.`)
104
107
  }
105
- if (
106
- (policy.command === 'select' || policy.command === 'delete') &&
107
- using === undefined
108
- ) {
108
+ if ((policy.command === 'select' || policy.command === 'delete') && using === undefined) {
109
109
  invalid(`Ward policy '${policy.name}' requires a using predicate.`)
110
110
  }
111
111