@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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Ryan Yannelli
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,270 @@
1
+ # @avelonjs/supabase
2
+
3
+ `@avelonjs/supabase` implements Supabase-backed drivers for Avelon. The database surface compiles `QueryIR` to PostgREST, declares `transactions: false`, and synchronizes ward predicates into Postgres row-level security. The identity surface talks to GoTrue over HTTP and binds sessions to request-scoped cookies. Reach for this package when your application targets Supabase and needs portable queries, auth, and database-enforced wards.
4
+
5
+ ## Installation
6
+
7
+ ```sh
8
+ bun add @avelonjs/supabase
9
+ ```
10
+
11
+ ```sh
12
+ export SUPABASE_REST_URL=http://127.0.0.1:3001
13
+ export SUPABASE_SERVICE_ROLE_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
14
+ export SUPABASE_DB_URL=postgresql://postgres:avelon@127.0.0.1:5432/avelon_supabase
15
+ export SUPABASE_AUTH_URL=http://127.0.0.1:54321/auth/v1
16
+ export SUPABASE_ANON_KEY=local-anon-key
17
+ ```
18
+
19
+ ## Basic Usage
20
+
21
+ ```ts
22
+ import { createSupabaseDatabase } from '@avelonjs/supabase'
23
+ import type { QueryIR } from '@avelonjs/core'
24
+
25
+ const db = createSupabaseDatabase()
26
+
27
+ const published: QueryIR = {
28
+ table: 'posts',
29
+ mode: 'select',
30
+ select: ['id', 'title'],
31
+ where: [{ kind: 'null', column: 'published_at', negated: true }],
32
+ relations: [],
33
+ order: [{ column: 'published_at', direction: 'desc' }],
34
+ limit: 10,
35
+ }
36
+
37
+ await db.execute(published)
38
+ ```
39
+
40
+ ## Capabilities
41
+
42
+ | Capability | Value | Notes |
43
+ |---|---|---|
44
+ | `transactions` | `false` | PostgREST has no interactive transactions |
45
+ | `rowSecurity` | `true` | `syncWards()` compiles registered wards to RLS |
46
+ | `maxRelationDepth` | `2` | Measured against live PostgREST relation loading |
47
+ | `fullTextSearch` | `false` | No portable `search()` surface in v1 |
48
+ | `upsert` | `true` | Wildcard upserts are native; explicit update lists use `avelon_upsert_subset` |
49
+ | `returning` | `true` | Write queries may project rows |
50
+ | `windowFunctions` | `false` | Not available over PostgREST |
51
+ | `jsonOperators` | `true` | Informational |
52
+
53
+ ## Ward Synchronization
54
+
55
+ ```ts
56
+ import { createSupabaseDatabase } from '@avelonjs/supabase'
57
+
58
+ const db = createSupabaseDatabase({
59
+ wards: [
60
+ {
61
+ name: 'posts_owner_read',
62
+ table: 'posts',
63
+ command: 'select',
64
+ using: {
65
+ kind: 'compare',
66
+ column: 'user_id',
67
+ op: '=',
68
+ value: { claim: 'uid' },
69
+ },
70
+ },
71
+ ],
72
+ })
73
+
74
+ await db.syncWards()
75
+ ```
76
+
77
+ `{ claim: 'uid' }` compiles to `auth.uid()::text`. A live two-identity denial test in this package proves a cross-tenant read is rejected after sync.
78
+
79
+ ## Migrations
80
+
81
+ Query traffic stays on PostgREST. Migration history uses the same driver-owned SQL pairs as `@avelonjs/postgres`, applied over the admin connection.
82
+
83
+ ```ts
84
+ import { createSupabaseDatabase } from '@avelonjs/supabase'
85
+
86
+ const db = createSupabaseDatabase({
87
+ migrations: [
88
+ {
89
+ id: '20260827_create_posts',
90
+ up: ['CREATE TABLE posts (id text PRIMARY KEY, title text NOT NULL)'],
91
+ down: ['DROP TABLE posts'],
92
+ },
93
+ ],
94
+ })
95
+
96
+ await db.plan()
97
+ await db.apply()
98
+ await db.status()
99
+ ```
100
+
101
+ ## Live Conformance
102
+
103
+ ```sh
104
+ postgrest packages/supabase/postgrest.conf
105
+ bun test packages/supabase --max-concurrency=1
106
+ ```
107
+
108
+ Fixture provisioning is owned by this package and reloads the PostgREST schema cache after reset.
109
+
110
+ ## Identity
111
+
112
+ ```ts
113
+ import { createSupabaseIdentity } from '@avelonjs/supabase'
114
+
115
+ const auth = createSupabaseIdentity({
116
+ authUrl: process.env.SUPABASE_AUTH_URL,
117
+ apiKey: process.env.SUPABASE_ANON_KEY,
118
+ })
119
+
120
+ export async function currentUser(cookies: import('@avelonjs/core').RequestCookies) {
121
+ return auth(cookies).user()
122
+ }
123
+ ```
124
+
125
+ Capabilities: `passwords: true`, `magicLinks: true`, `oauth: false`, `organizations: false`, `mfa: []`. OAuth linking and MFA are deferred until a live GoTrue stack is available in CI; declaring them early would hide missing surfaces.
126
+
127
+ ## Social
128
+
129
+ ```ts
130
+ import { createSupabaseSocial } from '@avelonjs/supabase'
131
+
132
+ const social = createSupabaseSocial({
133
+ authUrl: process.env.SUPABASE_AUTH_URL,
134
+ apiKey: process.env.SUPABASE_ANON_KEY,
135
+ })
136
+
137
+ export async function githubRedirect(callbackUrl: string): Promise<string> {
138
+ return social.redirect('github', callbackUrl)
139
+ }
140
+ ```
141
+
142
+ Providers are the literal list `github` and `google`. Undeclared providers raise `Invalid`. A mismatched or missing OAuth `state` raises `Unauthenticated`.
143
+
144
+ ## Tokens
145
+
146
+ Supabase Auth does not issue named API tokens. `@avelonjs/supabase` stores hashed Signets in Postgres so `verify`, `list`, and `revoke` are durable.
147
+
148
+ ```ts
149
+ import { createSupabaseTokens } from '@avelonjs/supabase'
150
+
151
+ const tokens = createSupabaseTokens({
152
+ url: process.env.SUPABASE_DB_URL,
153
+ subject: 'user-1',
154
+ })
155
+
156
+ const issued = await tokens.issue('deployment', { abilities: ['records:read'] })
157
+ await tokens.verify(issued.plainText)
158
+ ```
159
+
160
+ ## Storage
161
+
162
+ Object bytes persist in Postgres. Signed read URLs are HMAC-scoped HTTP URLs served by the driver process so expiry is real and fetchable.
163
+
164
+ ```ts
165
+ import { createSupabaseStorage } from '@avelonjs/supabase'
166
+
167
+ const disk = createSupabaseStorage({ instance: 'default' })
168
+ await disk.put('avatars/me.bin', new Uint8Array([1, 2, 3]), {
169
+ contentType: 'application/octet-stream',
170
+ })
171
+ const url = await disk.signedUrl('avatars/me.bin', 60)
172
+ ```
173
+
174
+ Transforms are undeclared in v1 (D28). Signed uploads and listing are deferred.
175
+
176
+ ## Queue
177
+
178
+ Durable jobs use Postgres `FOR UPDATE SKIP LOCKED`. The Wave A package names pgmq; this environment does not ship that extension, so skip-locked tables carry the same retry, delay, and dead-letter semantics.
179
+
180
+ ```ts
181
+ import { createSupabaseQueue } from '@avelonjs/supabase'
182
+
183
+ const queue = createSupabaseQueue()
184
+ const id = await queue.enqueue({ name: 'GenerateReport', payload: { reportId: 'report-1' } })
185
+ await queue.drain(async (receipt) => {
186
+ if (receipt.id !== id) return
187
+ })
188
+ ```
189
+
190
+ ## Method Reference
191
+
192
+ | Method | Signature | Description |
193
+ |---|---|---|
194
+ | `createSupabaseDatabase` | `(options?: SupabaseDatabaseOptions) => SupabaseDatabase` | Constructs the PostgREST database driver. |
195
+ | `SupabaseDatabase.execute` | `(query: QueryIR) => Promise<QueryResult>` | Executes IR as the service role. |
196
+ | `SupabaseDatabase.executeAs` | `(token: string, query: QueryIR) => Promise<QueryResult>` | Executes IR as an arbitrary bearer token. |
197
+ | `SupabaseDatabase.rpc` | `(routine: string, args: Readonly<Record<string, unknown>>) => Promise<T>` | Invokes a PostgREST RPC; missing routines raise `Invalid`. |
198
+ | `SupabaseDatabase.plan` | `() => Promise<MigrationPlan>` | Returns pending migration identifiers and SQL steps. |
199
+ | `SupabaseDatabase.apply` | `() => Promise<readonly MigrationStatus[]>` | Applies pending migrations over the admin connection. |
200
+ | `SupabaseDatabase.rollback` | `(steps?: number) => Promise<readonly MigrationStatus[]>` | Rolls back the newest applied migration batches. |
201
+ | `SupabaseDatabase.status` | `() => Promise<readonly MigrationStatus[]>` | Lists applied and pending migration states. |
202
+ | `SupabaseDatabase.syncWards` | `() => Promise<void>` | Applies registered ward policies as Postgres RLS. |
203
+ | `SupabaseDatabase.resetFixtures` | `() => Promise<void>` | Recreates assay fixtures, roles, and helper RPCs. |
204
+ | `SupabaseDatabase.raw` | `() => { restUrl: string }` | Returns the REST root at the vendor boundary. |
205
+ | `SupabaseDatabase.close` | `() => Promise<void>` | Closes the direct Postgres admin client. |
206
+ | `createSupabaseIdentity` | `(options?: SupabaseIdentityOptions) => (cookies: RequestCookies) => SupabaseIdentity` | Returns the pinned config-time identity factory. |
207
+ | `SupabaseIdentity.user` | `() => Promise<SupabaseActor \| null>` | Returns the current actor from the request cookie session. |
208
+ | `SupabaseIdentity.session` | `() => Promise<SupabaseSession \| null>` | Returns the current session or null. |
209
+ | `SupabaseIdentity.register` | `(email: string, password: string) => Promise<SupabaseActor>` | Registers and establishes a session cookie. |
210
+ | `SupabaseIdentity.signInWithPassword` | `(email: string, password: string) => Promise<SupabaseActor>` | Signs in and writes the session cookie. |
211
+ | `SupabaseIdentity.signOut` | `() => Promise<void>` | Ends the Auth session and clears the cookie. |
212
+ | `SupabaseIdentity.sendPasswordReset` | `(email: string) => Promise<void>` | Sends a recovery request without revealing account existence. |
213
+ | `SupabaseIdentity.resetPassword` | `(token: string, password: string) => Promise<void>` | Replaces a password after validating a recovery token. |
214
+ | `SupabaseIdentity.updatePassword` | `(password: string) => Promise<void>` | Changes the current actor's password. |
215
+ | `SupabaseIdentity.sendMagicLink` | `(email: string, redirectTo?: string) => Promise<void>` | Sends a passwordless sign-in link. |
216
+ | `SupabaseIdentity.raw` | `() => { authUrl: string }` | Returns the Auth HTTP root at the vendor boundary. |
217
+ | `readSessionCookie` | `(cookies: RequestCookies, cookieName: string) => SupabaseSession \| null` | Parses the request-scoped session cookie. |
218
+ | `writeSessionCookie` | `(cookies: RequestCookies, cookieName: string, session: SupabaseSession) => void` | Writes the session cookie for the current request. |
219
+ | `clearSessionCookie` | `(cookies: RequestCookies, cookieName: string) => void` | Deletes the session cookie for the current request. |
220
+ | `LocalAuthServer.start` | `() => Promise<string>` | Starts a GoTrue-shaped Auth fixture on an ephemeral port. |
221
+ | `LocalAuthServer.reset` | `() => void` | Clears users, sessions, and recovery tokens. |
222
+ | `LocalAuthServer.stop` | `() => Promise<void>` | Stops the fixture listener. |
223
+ | `LocalAuthServer.recoveryToken` | `(email: string) => string \| undefined` | Returns the recovery token issued for an email. |
224
+ | `createSupabaseSocial` | `(options?: SupabaseSocialOptions) => SupabaseSocial` | Constructs the GoTrue social driver. |
225
+ | `SupabaseSocial.redirect` | `(provider: string, callbackUrl: string, state?: string) => Promise<string>` | Builds a GoTrue authorize URL and records CSRF state. |
226
+ | `SupabaseSocial.callback` | `(provider: string, params: Readonly<Record<string, string>>, callbackUrl: string) => Promise<SocialIdentity>` | Verifies state and exchanges the authorization code. |
227
+ | `SupabaseSocial.raw` | `() => { authUrl: string }` | Returns the Auth HTTP root at the vendor boundary. |
228
+ | `LocalSocialServer.start` | `() => Promise<string>` | Starts a GoTrue-shaped token fixture on an ephemeral port. |
229
+ | `LocalSocialServer.stop` | `() => Promise<void>` | Stops the social fixture listener. |
230
+ | `createSupabaseTokens` | `(options?: SupabaseTokenOptions) => SupabaseTokens` | Constructs the Postgres-backed Signet driver. |
231
+ | `SupabaseTokens.issue` | `(name: string, options?: TokenIssueOptions) => Promise<IssuedToken>` | Issues a named token and returns plaintext once. |
232
+ | `SupabaseTokens.verify` | `(plainText: string) => Promise<TokenRecord>` | Authenticates a plaintext token or raises `Unauthenticated`. |
233
+ | `SupabaseTokens.list` | `() => Promise<readonly TokenRecord[]>` | Lists metadata for the current subject without plaintext. |
234
+ | `SupabaseTokens.revoke` | `(id: string) => Promise<void>` | Revokes one token by stable identifier. |
235
+ | `SupabaseTokens.reset` | `() => Promise<void>` | Recreates the empty signet table. |
236
+ | `SupabaseTokens.raw` | `() => SQL` | Returns the Bun SQL client at the vendor boundary. |
237
+ | `SupabaseTokens.close` | `() => Promise<void>` | Closes the SQL client pool. |
238
+ | `createSupabaseStorage` | `(options?: SupabaseStorageOptions) => SupabaseStorage` | Constructs the Postgres-backed storage driver. |
239
+ | `SupabaseStorage.put` | `(path: string, contents: Uint8Array \| AsyncIterable<Uint8Array>, options?: { contentType?: string }) => Promise<StorageObject>` | Stores bytes and returns metadata. |
240
+ | `SupabaseStorage.get` | `(path: string) => Promise<Uint8Array>` | Reads object bytes or raises `NotFound`. |
241
+ | `SupabaseStorage.delete` | `(path: string) => Promise<void>` | Deletes an object if it exists. |
242
+ | `SupabaseStorage.exists` | `(path: string) => Promise<boolean>` | Reports whether an object exists. |
243
+ | `SupabaseStorage.signedUrl` | `(path: string, expiresInSeconds: number) => Promise<string>` | Creates a fetchable HMAC-signed read URL. |
244
+ | `SupabaseStorage.reset` | `() => Promise<void>` | Recreates the empty object table. |
245
+ | `SupabaseStorage.raw` | `() => SQL` | Returns the Bun SQL client at the vendor boundary. |
246
+ | `SupabaseStorage.close` | `() => Promise<void>` | Stops the signed-URL listener and closes SQL. |
247
+ | `createSupabaseQueue` | `(options?: SupabaseQueueOptions) => SupabaseQueue` | Constructs the skip-locked Postgres queue driver. |
248
+ | `SupabaseQueue.enqueue` | `(job: QueueJob) => Promise<string>` | Enqueues a job for immediate delivery. |
249
+ | `SupabaseQueue.enqueueAt` | `(job: QueueJob, availableAt: Date) => Promise<string>` | Enqueues a job no earlier than the supplied time. |
250
+ | `SupabaseQueue.drain` | `(handler: (receipt: QueueReceipt) => Promise<void>, options?: { queue?: string; limit?: number }) => Promise<number>` | Delivers available jobs and counts attempts. |
251
+ | `SupabaseQueue.retry` | `(id: string, delaySeconds?: number) => Promise<void>` | Releases a failed job, optionally after a delay. |
252
+ | `SupabaseQueue.failed` | `(queue?: string) => Promise<readonly FailedQueueJob[]>` | Lists retained terminal failures. |
253
+ | `SupabaseQueue.replay` | `(id: string) => Promise<void>` | Replays a terminal failure with a fresh attempt budget. |
254
+ | `SupabaseQueue.forget` | `(id: string) => Promise<void>` | Permanently removes a terminal failure. |
255
+ | `SupabaseQueue.reset` | `() => Promise<void>` | Recreates empty job tables. |
256
+ | `SupabaseQueue.raw` | `() => SQL` | Returns the Bun SQL client at the vendor boundary. |
257
+ | `SupabaseQueue.close` | `() => Promise<void>` | Closes the SQL client pool. |
258
+ | `compilePostgrest` | `(ir: QueryIR, predicate?: Predicate) => CompiledPostgrestRequest` | Compiles IR to an HTTP request. |
259
+ | `applyPostgrestPredicate` | `(parameters: URLSearchParams, predicate: Predicate) => void` | Writes a normalized predicate into PostgREST `and=` filters. |
260
+ | `compileWardPredicate` | `(predicate: Predicate \| boolean) => string` | Compiles a ward predicate to RLS SQL. |
261
+ | `compileWardPolicySql` | `(policy: WardPolicy) => string[]` | Builds DROP/CREATE POLICY statements for one ward. |
262
+ | `compileAllWardPolicies` | `(policies: readonly WardPolicy[]) => string[]` | Compiles every registered ward into ordered SQL. |
263
+ | `mapPostgrestError` | `(status: number, bodyText: string, operation: string) => never` | Maps PostgREST failures into framework errors. |
264
+ | `resetSupabaseAssayFixtures` | `(sql: SQL) => Promise<void>` | Provisions empty assay fixtures on a SQL client. |
265
+ | `normalizePredicate` | `(predicate: Predicate) => Predicate` | Applies empty-list and constant identities. |
266
+ | `combinedPredicate` | `(ir: Pick<QueryIR, 'where' \| 'ward'>) => Predicate` | ANDs where and ward, then normalizes. |
267
+
268
+ ## Testing
269
+
270
+ Run the shared database conformance suite against live PostgREST and the package-owned RLS denial test. Run identity and social conformance against a GoTrue-shaped Auth server; when the full Docker stack is unavailable, `LocalAuthServer` and `LocalSocialServer` stand in so cookie, password, and OAuth-code flows still exercise the HTTP drivers. Token conformance runs against live Postgres (`avelon_signets`). Storage conformance runs against live Postgres object bytes plus fetchable signed read URLs. Queue conformance runs against live Postgres skip-locked tables because pgmq is not installed in this environment. Unit tests cover PostgREST compilation without a network dependency.
package/package.json ADDED
@@ -0,0 +1,53 @@
1
+ {
2
+ "name": "@avelonjs/supabase",
3
+ "version": "0.1.0",
4
+ "private": false,
5
+ "description": "Supabase drivers for Avelon's database, identity, social, tokens, storage, and queue contracts.",
6
+ "license": "MIT",
7
+ "author": "Ryan Yannelli <ryanyannelli@gmail.com>",
8
+ "homepage": "https://github.com/yannelli/avelon",
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "git+https://github.com/yannelli/avelon.git",
12
+ "directory": "packages/supabase"
13
+ },
14
+ "bugs": {
15
+ "url": "https://github.com/yannelli/avelon/issues"
16
+ },
17
+ "keywords": [
18
+ "avelon",
19
+ "typescript",
20
+ "supabase",
21
+ "postgres"
22
+ ],
23
+ "type": "module",
24
+ "publishConfig": {
25
+ "access": "public"
26
+ },
27
+ "files": [
28
+ "src",
29
+ "README.md",
30
+ "LICENSE"
31
+ ],
32
+ "exports": {
33
+ ".": "./src/index.ts",
34
+ "./database": "./src/database/index.ts"
35
+ },
36
+ "scripts": {
37
+ "test": "bun test",
38
+ "typecheck": "tsc --noEmit",
39
+ "fixtures:reset": "bun ./scripts/reset-fixtures.ts"
40
+ },
41
+ "dependencies": {
42
+ "@avelonjs/core": "workspace:*",
43
+ "@avelonjs/postgres": "workspace:*"
44
+ },
45
+ "devDependencies": {
46
+ "@avelonjs/conformance": "workspace:*",
47
+ "@types/bun": "1.3.14",
48
+ "typescript": "5.9.3"
49
+ },
50
+ "engines": {
51
+ "bun": ">=1.3.14"
52
+ }
53
+ }
@@ -0,0 +1,198 @@
1
+ import { Invalid, type CompareOp, type Predicate, type QueryIR } from '@avelonjs/core'
2
+ import { combinedPredicate } from '@avelonjs/postgres'
3
+
4
+ const POSTGREST_OPERATOR: Record<CompareOp, string> = {
5
+ '=': 'eq',
6
+ '!=': 'neq',
7
+ '<': 'lt',
8
+ '<=': 'lte',
9
+ '>': 'gt',
10
+ '>=': 'gte',
11
+ like: 'like',
12
+ ilike: 'ilike',
13
+ }
14
+
15
+ function invalid(message: string, field: string, detail: string): never {
16
+ throw new Invalid(message, { metadata: { fields: { [field]: [detail] } } })
17
+ }
18
+
19
+ /** Compiled PostgREST request for one IR operation. */
20
+ export interface CompiledPostgrestRequest {
21
+ /** Absolute or path URL relative to the REST root. */
22
+ path: string
23
+ /** HTTP method. */
24
+ method: string
25
+ /** Request headers including Prefer and content type when needed. */
26
+ headers: Record<string, string>
27
+ /** JSON body when present. */
28
+ body?: string
29
+ /** Search parameters excluding the leading `?`. */
30
+ query: string
31
+ }
32
+
33
+ function postgrestProjection(projection: string[] | '*'): string {
34
+ return projection === '*' ? '*' : projection.join(',')
35
+ }
36
+
37
+ function postgrestScalar(value: unknown): string {
38
+ if (typeof value === 'string') return `"${value.replaceAll('\\', '\\\\').replaceAll('"', '\\"')}"`
39
+ if (typeof value === 'number') {
40
+ if (!Number.isFinite(value)) invalid('PostgREST cannot encode a non-finite number.', 'value', 'Invalid number')
41
+ return String(value)
42
+ }
43
+ if (typeof value === 'boolean') return String(value)
44
+ if (value === null) return 'null'
45
+ invalid(`PostgREST scalar type '${typeof value}' is not supported.`, 'value', 'Unsupported scalar')
46
+ }
47
+
48
+ function postgrestLeaf(
49
+ predicate: Exclude<Predicate, { kind: 'const' | 'and' | 'or' | 'not' }>,
50
+ negated: boolean,
51
+ ): { column: string; filter: string } {
52
+ switch (predicate.kind) {
53
+ case 'compare':
54
+ return {
55
+ column: predicate.column,
56
+ filter: `${negated ? 'not.' : ''}${POSTGREST_OPERATOR[predicate.op]}.${postgrestScalar(predicate.value)}`,
57
+ }
58
+ case 'null':
59
+ return {
60
+ column: predicate.column,
61
+ filter: `${predicate.negated !== negated ? 'not.' : ''}is.null`,
62
+ }
63
+ case 'in':
64
+ return {
65
+ column: predicate.column,
66
+ filter: `${predicate.negated !== negated ? 'not.' : ''}in.(${predicate.values.map(postgrestScalar).join(',')})`,
67
+ }
68
+ }
69
+ }
70
+
71
+ function postgrestExpression(predicate: Predicate, negated = false): string {
72
+ if (predicate.kind === 'const') invalid('predicate was not normalized for PostgREST.', 'where', 'Unexpected const')
73
+ if (predicate.kind === 'not') return postgrestExpression(predicate.predicate, !negated)
74
+ if (predicate.kind === 'and' || predicate.kind === 'or') {
75
+ const kind = negated ? (predicate.kind === 'and' ? 'or' : 'and') : predicate.kind
76
+ return `${kind}(${predicate.predicates.map((child) => postgrestExpression(child, negated)).join(',')})`
77
+ }
78
+ const leaf = postgrestLeaf(predicate, negated)
79
+ return `${leaf.column}.${leaf.filter}`
80
+ }
81
+
82
+ /** Applies a normalized predicate as PostgREST `and=(...)` filters. */
83
+ export function applyPostgrestPredicate(parameters: URLSearchParams, predicate: Predicate): void {
84
+ if (predicate.kind === 'const') return
85
+ parameters.set('and', `(${postgrestExpression(predicate)})`)
86
+ }
87
+
88
+ function applyPostgrestOrderAndPage(parameters: URLSearchParams, ir: QueryIR): void {
89
+ if (ir.order.length > 0) {
90
+ parameters.set(
91
+ 'order',
92
+ ir.order
93
+ .map(
94
+ (term) =>
95
+ `${term.column}.${term.direction}${term.nulls === undefined ? '' : `.nulls${term.nulls}`}`,
96
+ )
97
+ .join(','),
98
+ )
99
+ }
100
+ if (ir.limit !== undefined) parameters.set('limit', String(ir.limit))
101
+ if (ir.offset !== undefined) parameters.set('offset', String(ir.offset))
102
+ }
103
+
104
+ function ensureUnscopedWritePredicate(predicate: Predicate, mode: 'insert' | 'upsert'): void {
105
+ if (predicate.kind !== 'const' || !predicate.value) {
106
+ invalid(
107
+ `${mode} with a non-constant where or ward has no documented row scope.`,
108
+ 'where',
109
+ `${mode} predicates are not documented`,
110
+ )
111
+ }
112
+ }
113
+
114
+ /**
115
+ * Compiles IR into a PostgREST HTTP request.
116
+ *
117
+ * Explicit-list upserts are marked with `path: '/rpc/avelon_upsert_subset'` for the driver RPC helper.
118
+ */
119
+ export function compilePostgrest(
120
+ ir: QueryIR,
121
+ predicate = combinedPredicate(ir),
122
+ ): CompiledPostgrestRequest {
123
+ if (ir.mode === 'count') {
124
+ const parameters = new URLSearchParams()
125
+ applyPostgrestPredicate(parameters, predicate)
126
+ return {
127
+ path: `/${ir.table}`,
128
+ method: 'HEAD',
129
+ headers: { Prefer: 'count=exact' },
130
+ query: parameters.toString(),
131
+ }
132
+ }
133
+
134
+ if (ir.mode === 'upsert' && ir.conflict !== undefined && ir.conflict.update !== '*') {
135
+ ensureUnscopedWritePredicate(predicate, 'upsert')
136
+ return {
137
+ path: '/rpc/avelon_upsert_subset',
138
+ method: 'POST',
139
+ headers: { 'Content-Type': 'application/json' },
140
+ body: JSON.stringify({
141
+ target_table: ir.table,
142
+ input_rows: Array.isArray(ir.values) ? ir.values : [ir.values],
143
+ conflict_columns: ir.conflict.columns,
144
+ update_columns: ir.conflict.update,
145
+ returning_columns: ir.returning === '*' || ir.returning === undefined ? null : ir.returning,
146
+ }),
147
+ query: '',
148
+ }
149
+ }
150
+
151
+ const parameters = new URLSearchParams()
152
+ const headers: Record<string, string> = {}
153
+ let method = 'GET'
154
+ let body: string | undefined
155
+
156
+ if (ir.mode === 'select') {
157
+ parameters.set('select', postgrestProjection(ir.select))
158
+ } else if (ir.mode === 'insert') {
159
+ ensureUnscopedWritePredicate(predicate, 'insert')
160
+ method = 'POST'
161
+ body = JSON.stringify(ir.values)
162
+ } else if (ir.mode === 'update') {
163
+ method = 'PATCH'
164
+ body = JSON.stringify(ir.values)
165
+ } else if (ir.mode === 'delete') {
166
+ method = 'DELETE'
167
+ } else {
168
+ ensureUnscopedWritePredicate(predicate, 'upsert')
169
+ method = 'POST'
170
+ body = JSON.stringify(ir.values)
171
+ parameters.set('on_conflict', (ir.conflict as NonNullable<QueryIR['conflict']>).columns.join(','))
172
+ headers.Prefer = 'resolution=merge-duplicates'
173
+ }
174
+
175
+ if (ir.mode === 'select' || ir.mode === 'update' || ir.mode === 'delete') {
176
+ applyPostgrestPredicate(parameters, predicate)
177
+ }
178
+ applyPostgrestOrderAndPage(parameters, ir)
179
+
180
+ if (ir.mode !== 'select') {
181
+ headers['Content-Type'] = 'application/json'
182
+ // Always request representation so `affected` is observable; the driver drops rows when
183
+ // `returning` is absent.
184
+ headers.Prefer = [headers.Prefer, 'return=representation'].filter(Boolean).join(',')
185
+ parameters.set(
186
+ 'select',
187
+ postgrestProjection(ir.returning === undefined ? '*' : ir.returning),
188
+ )
189
+ }
190
+
191
+ return {
192
+ path: `/${ir.table}`,
193
+ method,
194
+ headers,
195
+ body,
196
+ query: parameters.toString(),
197
+ }
198
+ }