@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.
- package/README.md +130 -93
- package/package.json +4 -2
- package/src/database/bun.ts +33 -0
- package/src/database/compile.ts +15 -9
- package/src/database/driver.ts +55 -29
- package/src/database/errors.ts +2 -7
- package/src/database/fixtures.ts +3 -3
- package/src/database/index.ts +2 -1
- package/src/database/normalize.ts +1 -1
- package/src/database/schema-rest.ts +54 -0
- package/src/database/wards.ts +10 -10
- package/src/identity/driver.ts +298 -19
- package/src/identity/errors.ts +230 -0
- package/src/identity/index.ts +1 -0
- package/src/identity/local-auth.ts +145 -23
- package/src/queue/driver.ts +10 -6
- package/src/social/driver.ts +6 -9
- package/src/storage/driver.ts +3 -4
- package/src/tokens/driver.ts +10 -6
package/src/database/driver.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
/**
|
|
50
|
-
|
|
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(
|
|
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 #
|
|
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.#
|
|
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.#
|
|
157
|
+
await resetSupabaseAssayFixtures(this.#requireAdmin('resetFixtures'))
|
|
151
158
|
this.#schema = undefined
|
|
152
159
|
await this.#waitForSchema()
|
|
153
160
|
}
|
|
154
161
|
|
|
155
|
-
/**
|
|
162
|
+
/** Releases the direct Postgres admin client when one was configured. */
|
|
156
163
|
async close(): Promise<void> {
|
|
157
|
-
await this.#
|
|
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.#
|
|
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.#
|
|
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.#
|
|
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.#
|
|
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
|
|
226
|
-
for (const statement of
|
|
227
|
-
await
|
|
244
|
+
const admin = this.#requireAdmin('syncWards')
|
|
245
|
+
for (const statement of compileAllWardPolicies(this.#wards)) {
|
|
246
|
+
await admin.unsafe(statement)
|
|
228
247
|
}
|
|
229
|
-
await
|
|
248
|
+
await admin.unsafe(`NOTIFY pgrst, 'reload schema'`)
|
|
230
249
|
}
|
|
231
250
|
|
|
232
251
|
async #schemaCache(): Promise<SchemaCache> {
|
|
233
|
-
if (this.#schema === undefined)
|
|
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(
|
|
248
|
-
|
|
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(
|
|
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, {
|
package/src/database/errors.ts
CHANGED
|
@@ -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, {
|
package/src/database/fixtures.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type {
|
|
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(
|
|
205
|
-
await
|
|
204
|
+
export async function resetSupabaseAssayFixtures(runner: SqlRunner): Promise<void> {
|
|
205
|
+
await runner.unsafe(SUPABASE_ASSAY_FIXTURE_SQL)
|
|
206
206
|
}
|
package/src/database/index.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/database/wards.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
|