@zeno-lib/db 0.0.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 ADDED
@@ -0,0 +1,3 @@
1
+ # @zeno-lib/db
2
+
3
+ See [Database](https://www.zeno-lib.com/docs/core-framework/data-management/database).
package/package.json ADDED
@@ -0,0 +1,51 @@
1
+ {
2
+ "name": "@zeno-lib/db",
3
+ "version": "0.0.1",
4
+ "type": "module",
5
+ "scripts": {
6
+ "dev": "pnpm exec supabase start",
7
+ "stop": "pnpm exec supabase stop",
8
+ "reset": "pnpm exec supabase db reset",
9
+ "db:generate": "drizzle-kit generate",
10
+ "db:migrate": "drizzle-kit migrate",
11
+ "db:push": "drizzle-kit push",
12
+ "db:studio": "drizzle-kit studio",
13
+ "test": "vitest --run",
14
+ "test:watch": "vitest watch",
15
+ "types:check": "tsc --noEmit"
16
+ },
17
+ "exports": {
18
+ ".": "./src/index.ts",
19
+ "./config": "./src/config.ts",
20
+ "./schema": "./src/schema.ts"
21
+ },
22
+ "files": [
23
+ "src"
24
+ ],
25
+ "publishConfig": {
26
+ "access": "public"
27
+ },
28
+ "repository": {
29
+ "url": "https://github.com/zeno-lib/zeno.git",
30
+ "directory": "packages/db"
31
+ },
32
+ "peerDependencies": {
33
+ "@supabase/supabase-js": ">=2",
34
+ "drizzle-kit": "1.0.0-rc.3",
35
+ "drizzle-orm": "1.0.0-rc.3",
36
+ "postgres": ">=3.4"
37
+ },
38
+ "devDependencies": {
39
+ "@supabase/supabase-js": "2.106.1",
40
+ "@types/node": "24.10.0",
41
+ "@zeno-lib/test": "workspace:*",
42
+ "@zeno-lib/typescript": "workspace:^",
43
+ "dotenv": "16.4.7",
44
+ "drizzle-kit": "1.0.0-rc.3",
45
+ "drizzle-orm": "1.0.0-rc.3",
46
+ "postgres": "3.4.7",
47
+ "supabase": "2.84.1",
48
+ "vite": "8.0.16",
49
+ "vitest": "4.1.0"
50
+ }
51
+ }
@@ -0,0 +1,264 @@
1
+ import { getTableColumns, sql } from "drizzle-orm"
2
+ import {
3
+ getTableConfig,
4
+ isPgEnum,
5
+ isPgMaterializedView,
6
+ isPgSchema,
7
+ isPgSequence,
8
+ isPgView,
9
+ pgEnum,
10
+ pgMaterializedView,
11
+ pgPolicy,
12
+ pgRole,
13
+ pgSchema,
14
+ pgSequence,
15
+ pgTableCreator,
16
+ pgView,
17
+ text,
18
+ uuid,
19
+ } from "drizzle-orm/pg-core"
20
+ import { camelCase, snakeCase } from "drizzle-orm/pg-core/casing"
21
+ import { describe, expect, it } from "vitest"
22
+ import { createAdminClient } from "./clients.ts"
23
+ import { defineDrizzleConfig } from "./config.ts"
24
+ import {
25
+ allPolicy,
26
+ auditColumns,
27
+ authenticatedOwnerDeletePolicy,
28
+ authenticatedOwnerInsertPolicy,
29
+ authenticatedOwnerSelectPolicy,
30
+ authenticatedOwnerUpdatePolicy,
31
+ authenticatedRole,
32
+ authorship,
33
+ authUid,
34
+ authUserId,
35
+ authUsers,
36
+ createdBy,
37
+ deletePolicy,
38
+ enum as enum_,
39
+ insertPolicy,
40
+ isEnum,
41
+ isMaterializedView,
42
+ isSchema,
43
+ isSequence,
44
+ isView,
45
+ materializedView,
46
+ policy,
47
+ primaryId,
48
+ role,
49
+ schema,
50
+ selectPolicy,
51
+ sequence,
52
+ table,
53
+ tableCreator,
54
+ timestamps,
55
+ unsecureTable,
56
+ updatedBy,
57
+ updatePolicy,
58
+ view,
59
+ } from "./schema.ts"
60
+
61
+ describe("default casing", () => {
62
+ it("leaves casing to Drizzle table constructors", () => {
63
+ expect(defineDrizzleConfig()).not.toHaveProperty("casing")
64
+ })
65
+
66
+ it("works with Drizzle's snake_case table builders", async () => {
67
+ const posts = snakeCase.table("posts", {
68
+ displayName: text(),
69
+ ownerId: uuid(),
70
+ })
71
+ const db = createAdminClient()
72
+
73
+ expect(db.select().from(posts).toSQL().sql).toContain('"display_name"')
74
+ expect(db.select().from(posts).toSQL().sql).toContain('"owner_id"')
75
+
76
+ await db.close()
77
+ })
78
+
79
+ it("allows callers to opt into camelCase table builders", async () => {
80
+ const posts = camelCase.table("posts", {
81
+ displayName: text(),
82
+ })
83
+ const db = createAdminClient()
84
+
85
+ expect(db.select().from(posts).toSQL().sql).toContain('"displayName"')
86
+
87
+ await db.close()
88
+ })
89
+
90
+ it("exports an RLS-enabled snake_case table helper", async () => {
91
+ const posts = table("posts", {
92
+ displayName: text(),
93
+ ownerId: uuid(),
94
+ })
95
+ const db = createAdminClient()
96
+
97
+ expect(db.select().from(posts).toSQL().sql).toContain('"display_name"')
98
+ expect(db.select().from(posts).toSQL().sql).toContain('"owner_id"')
99
+ expect(getTableConfig(posts).enableRLS).toBe(true)
100
+
101
+ await db.close()
102
+ })
103
+
104
+ it("exports an explicit non-RLS snake_case table helper", async () => {
105
+ const auditEvents = unsecureTable("audit_events", {
106
+ displayName: text(),
107
+ ownerId: uuid(),
108
+ })
109
+ const db = createAdminClient()
110
+
111
+ expect(db.select().from(auditEvents).toSQL().sql).toContain(
112
+ '"display_name"'
113
+ )
114
+ expect(db.select().from(auditEvents).toSQL().sql).toContain('"owner_id"')
115
+ expect(getTableConfig(auditEvents).enableRLS).toBe(false)
116
+
117
+ await db.close()
118
+ })
119
+
120
+ it("re-exports likely pg-prefixed schema builders without the pg prefix", () => {
121
+ expect(enum_).toBe(pgEnum)
122
+ expect(isEnum).toBe(isPgEnum)
123
+ expect(isMaterializedView).toBe(isPgMaterializedView)
124
+ expect(isSchema).toBe(isPgSchema)
125
+ expect(isSequence).toBe(isPgSequence)
126
+ expect(isView).toBe(isPgView)
127
+ expect(materializedView).toBe(pgMaterializedView)
128
+ expect(policy).toBe(pgPolicy)
129
+ expect(role).toBe(pgRole)
130
+ expect(schema).toBe(pgSchema)
131
+ expect(sequence).toBe(pgSequence)
132
+ expect(tableCreator).toBe(pgTableCreator)
133
+ expect(view).toBe(pgView)
134
+ })
135
+
136
+ it("exports common ID and Supabase auth-user column helpers", () => {
137
+ const posts = table("posts", {
138
+ id: primaryId("uuid"),
139
+ ownerId: authUserId(),
140
+ sequentialId: primaryId("sequential"),
141
+ })
142
+ const columns = getTableColumns(posts)
143
+ const config = getTableConfig(posts)
144
+
145
+ expect(columns.id.getSQLType()).toBe("uuid")
146
+ expect(columns.id.primary).toBe(true)
147
+ expect(columns.id.default).toBeDefined()
148
+ expect(columns.sequentialId.getSQLType()).toBe("integer")
149
+ expect(columns.sequentialId.primary).toBe(true)
150
+ expect(columns.sequentialId.generatedIdentity?.type).toBe("always")
151
+ expect(columns.ownerId.getSQLType()).toBe("uuid")
152
+ expect(columns.ownerId.notNull).toBe(true)
153
+ expect(config.foreignKeys).toHaveLength(1)
154
+ expect(config.foreignKeys[0]?.reference().foreignTable).toBe(authUsers)
155
+ })
156
+
157
+ it("exports audit timestamp and auth-user column helpers with runtime defaults", () => {
158
+ const posts = table("posts", {
159
+ createdBy,
160
+ updatedBy,
161
+ ...timestamps,
162
+ })
163
+ const columns = getTableColumns(posts)
164
+ const config = getTableConfig(posts)
165
+
166
+ expect(columns.createdAt.name).toBe("created_at")
167
+ expect(columns.createdAt.notNull).toBe(true)
168
+ expect(columns.createdAt.default).toBeDefined()
169
+ expect(columns.createdAt.onUpdateFn).toBeUndefined()
170
+ expect(columns.updatedAt.name).toBe("updated_at")
171
+ expect(columns.updatedAt.notNull).toBe(true)
172
+ expect(columns.updatedAt.default).toBeDefined()
173
+ expect(columns.updatedAt.onUpdateFn?.()).toBeInstanceOf(Date)
174
+ expect(columns.createdBy.name).toBe("created_by")
175
+ expect(columns.createdBy.notNull).toBe(true)
176
+ expect(columns.createdBy.default).toBe(authUid)
177
+ expect(columns.createdBy.onUpdateFn).toBeUndefined()
178
+ expect(columns.updatedBy.name).toBe("updated_by")
179
+ expect(columns.updatedBy.notNull).toBe(true)
180
+ expect(columns.updatedBy.default).toBe(authUid)
181
+ expect(columns.updatedBy.onUpdateFn?.()).toBe(authUid)
182
+ expect(config.foreignKeys).toHaveLength(2)
183
+ expect(
184
+ config.foreignKeys.map(
185
+ (foreignKey) => foreignKey.reference().foreignTable
186
+ )
187
+ ).toEqual([authUsers, authUsers])
188
+ })
189
+
190
+ it("exports grouped audit column mixins", () => {
191
+ expect(Object.keys(timestamps)).toEqual(["createdAt", "updatedAt"])
192
+ expect(Object.keys(authorship)).toEqual(["createdBy", "updatedBy"])
193
+ expect(Object.keys(auditColumns)).toEqual([
194
+ "createdAt",
195
+ "updatedAt",
196
+ "createdBy",
197
+ "updatedBy",
198
+ ])
199
+
200
+ const posts = table("posts", {
201
+ id: primaryId("uuid"),
202
+ ...auditColumns,
203
+ })
204
+ const columns = getTableColumns(posts)
205
+
206
+ expect(columns.createdAt.name).toBe("created_at")
207
+ expect(columns.updatedAt.name).toBe("updated_at")
208
+ expect(columns.createdBy.name).toBe("created_by")
209
+ expect(columns.updatedBy.name).toBe("updated_by")
210
+ })
211
+
212
+ it("exports generic policy helpers that set the policy operation", () => {
213
+ const condition = sql`true`
214
+
215
+ expect(selectPolicy("select_posts", { using: condition }).for).toBe(
216
+ "select"
217
+ )
218
+ expect(insertPolicy("insert_posts", { withCheck: condition }).for).toBe(
219
+ "insert"
220
+ )
221
+ expect(updatePolicy("update_posts", { using: condition }).for).toBe(
222
+ "update"
223
+ )
224
+ expect(deletePolicy("delete_posts", { using: condition }).for).toBe(
225
+ "delete"
226
+ )
227
+ expect(allPolicy("all_posts", { using: condition }).for).toBe("all")
228
+ })
229
+
230
+ it("exports authenticated owner policy helpers for common Supabase RLS", () => {
231
+ const posts = table(
232
+ "posts",
233
+ {
234
+ id: primaryId("uuid"),
235
+ userId: authUserId(),
236
+ },
237
+ (t) => [
238
+ authenticatedOwnerSelectPolicy("posts_owner_select", t.userId),
239
+ authenticatedOwnerInsertPolicy("posts_owner_insert", t.userId),
240
+ authenticatedOwnerUpdatePolicy("posts_owner_update", t.userId),
241
+ authenticatedOwnerDeletePolicy("posts_owner_delete", t.userId),
242
+ ]
243
+ )
244
+ const policies = getTableConfig(posts).policies
245
+
246
+ expect(policies.map((rlsPolicy) => rlsPolicy.for)).toEqual([
247
+ "select",
248
+ "insert",
249
+ "update",
250
+ "delete",
251
+ ])
252
+ expect(policies.map((rlsPolicy) => rlsPolicy.to)).toEqual([
253
+ authenticatedRole,
254
+ authenticatedRole,
255
+ authenticatedRole,
256
+ authenticatedRole,
257
+ ])
258
+ expect(policies[0]?.using).toBeDefined()
259
+ expect(policies[1]?.withCheck).toBeDefined()
260
+ expect(policies[2]?.using).toBeDefined()
261
+ expect(policies[2]?.withCheck).toBeDefined()
262
+ expect(policies[3]?.using).toBeDefined()
263
+ })
264
+ })
@@ -0,0 +1,94 @@
1
+ import { beforeEach, describe, expect, it, vi } from "vitest"
2
+
3
+ // Use the REAL postgres driver and REAL drizzle here — postgres-js connects
4
+ // lazily, so a pool can be built and `end()`ed without a running server. We
5
+ // only intercept the factory (a counting passthrough) to observe the pool
6
+ // caching and reference-counted close() that live in clients.ts. Behavioral
7
+ // concerns (RLS, role/claims, query replay) are covered by the integration
8
+ // suite in test/rls.integration.test.ts, which runs against real Postgres.
9
+ const { postgresSpy, endSpies } = vi.hoisted(() => ({
10
+ endSpies: [] as ReturnType<typeof vi.fn>[],
11
+ postgresSpy: vi.fn(),
12
+ }))
13
+
14
+ vi.mock("postgres", async (importOriginal) => {
15
+ const actual = await importOriginal<{ default: typeof import("postgres") }>()
16
+ return {
17
+ default: (url: string, options?: unknown) => {
18
+ postgresSpy(url, options)
19
+ const client = actual.default(url, options as never)
20
+ const realEnd = client.end.bind(client)
21
+ const end = vi.fn((opts?: { timeout?: number }) => realEnd(opts))
22
+ client.end = end as typeof client.end
23
+ endSpies.push(end)
24
+ return client
25
+ },
26
+ }
27
+ })
28
+
29
+ import { createAdminClient } from "./clients.ts"
30
+
31
+ // Pools are cached by connection string, so each test uses a fresh URL to stay
32
+ // isolated; the pool-sharing tests deliberately reuse one URL.
33
+ let urlCounter = 0
34
+ function url() {
35
+ urlCounter += 1
36
+ return `postgresql://postgres:postgres@localhost/db${urlCounter}`
37
+ }
38
+
39
+ beforeEach(() => {
40
+ postgresSpy.mockClear()
41
+ endSpies.length = 0
42
+ })
43
+
44
+ describe("createAdminClient", () => {
45
+ it("exposes close", () => {
46
+ const db = createAdminClient({ connectionString: url() })
47
+
48
+ expect(db.close).toEqual(expect.any(Function))
49
+ })
50
+
51
+ it("throws when SUPABASE_DATABASE_URL is unset and no override is provided", () => {
52
+ vi.stubEnv("SUPABASE_DATABASE_URL", "")
53
+ expect(() => createAdminClient()).toThrow(
54
+ "Missing SUPABASE_DATABASE_URL environment variable"
55
+ )
56
+ vi.unstubAllEnvs()
57
+ })
58
+ })
59
+
60
+ describe("pool sharing", () => {
61
+ it("creates one pool per connection string", () => {
62
+ const shared = url()
63
+
64
+ createAdminClient({ connectionString: shared })
65
+ createAdminClient({ connectionString: shared })
66
+
67
+ expect(postgresSpy).toHaveBeenCalledTimes(1)
68
+ })
69
+
70
+ it("reference-counts the shared pool so close() ends it once the last handle closes", async () => {
71
+ const shared = url()
72
+
73
+ const first = createAdminClient({ connectionString: shared })
74
+ const second = createAdminClient({ connectionString: shared })
75
+
76
+ // One underlying pool was created for the shared connection string.
77
+ expect(endSpies).toHaveLength(1)
78
+ const end = endSpies[0]
79
+
80
+ expect(end).not.toHaveBeenCalled()
81
+
82
+ await first.close()
83
+ // Second handle still shares the pool — it must stay open.
84
+ expect(end).not.toHaveBeenCalled()
85
+
86
+ await second.close()
87
+ // Last handle closed -> pool ended exactly once.
88
+ expect(end).toHaveBeenCalledTimes(1)
89
+
90
+ // Closing again is a no-op (does not re-end an already-closed pool).
91
+ await second.close()
92
+ expect(end).toHaveBeenCalledTimes(1)
93
+ })
94
+ })
package/src/clients.ts ADDED
@@ -0,0 +1,213 @@
1
+ // https://orm.drizzle.team/docs/rls#using-with-supabase
2
+ import type { JwtPayload, SupabaseClient } from "@supabase/supabase-js"
3
+ import type { AnyRelations, EmptyRelations } from "drizzle-orm"
4
+ import { sql } from "drizzle-orm"
5
+ import type { DrizzlePgConfig } from "drizzle-orm/pg-core"
6
+ import { drizzle, type PostgresJsDatabase } from "drizzle-orm/postgres-js"
7
+ import postgres from "postgres"
8
+ import { createRlsQueryClient } from "./rls-query-client.ts"
9
+
10
+ // Roles a user token may switch into. A forged/unexpected `role` claim is
11
+ // clamped to `anon`; the privileged `service_role` is reachable only via the
12
+ // explicit `createServiceClient`, never from a JWT.
13
+ const ALLOWED_RLS_ROLES = new Set(["anon", "authenticated"])
14
+
15
+ type CloseFn = (options?: { timeout?: number }) => Promise<void>
16
+
17
+ // Drizzle config minus the connection, which the factory resolves from
18
+ // `SUPABASE_DATABASE_URL` (or the optional override).
19
+ export type CreateClientConfig<
20
+ TRelations extends AnyRelations = EmptyRelations,
21
+ > = DrizzlePgConfig<TRelations> & {
22
+ /** Overrides `process.env.SUPABASE_DATABASE_URL`. */
23
+ connectionString?: string
24
+ }
25
+
26
+ // A directly-queryable Drizzle client plus a reference-counted `close()`.
27
+ export type DrizzleClient<TRelations extends AnyRelations = EmptyRelations> =
28
+ PostgresJsDatabase<TRelations> & { close: CloseFn }
29
+
30
+ // The only JWT claims the RLS clients read.
31
+ export type SupabaseToken = Pick<JwtPayload, "role" | "sub">
32
+
33
+ // The role-clamped RLS context installed into each transaction.
34
+ type RlsContext = { claims: string; role: string; sub: string }
35
+
36
+ // Clamps a token's role to the allowlist and re-serializes the claims so a
37
+ // policy reading auth.jwt()->>'role' can never disagree with the role we
38
+ // `set local role` to.
39
+ function clampClaims(token: Partial<SupabaseToken>): RlsContext {
40
+ const role =
41
+ token.role && ALLOWED_RLS_ROLES.has(token.role) ? token.role : "anon"
42
+ return {
43
+ claims: JSON.stringify({ ...token, role }),
44
+ role,
45
+ sub: token.sub ?? "",
46
+ }
47
+ }
48
+
49
+ // Trusted role chosen by the caller (not read from a JWT), so it skips the
50
+ // user-token allowlist.
51
+ function fixedContext(role: string): RlsContext {
52
+ return { claims: JSON.stringify({ role }), role, sub: "" }
53
+ }
54
+
55
+ // Pools are cached by kind + connection string. "admin" and "rls" get separate
56
+ // pools on the same URL so the admin connection is never role-switched. Each
57
+ // handle shares the cached pool and reference-counts it, so a per-request
58
+ // `close()` ends the pool only once the last handle releases it.
59
+ type PoolKind = "admin" | "rls"
60
+ type PoolEntry = {
61
+ readonly client: ReturnType<typeof postgres>
62
+ readonly key: string
63
+ ended: boolean
64
+ refCount: number
65
+ }
66
+ const poolCache = new Map<string, PoolEntry>()
67
+
68
+ function acquirePool(connectionString: string, kind: PoolKind): PoolEntry {
69
+ const key = `${kind} ${connectionString}`
70
+ let entry = poolCache.get(key)
71
+ if (!entry) {
72
+ // `prepare: false` is required for the Supabase transaction-mode pooler
73
+ // (port 6543), which doesn't support prepared statements.
74
+ entry = {
75
+ client: postgres(connectionString, { prepare: false }),
76
+ ended: false,
77
+ key,
78
+ refCount: 0,
79
+ }
80
+ poolCache.set(key, entry)
81
+ }
82
+ entry.refCount += 1
83
+ return entry
84
+ }
85
+
86
+ function makeClose(entry: PoolEntry): CloseFn {
87
+ let released = false
88
+ // Idempotent per handle: closing twice must not double-decrement the pool.
89
+ return async (options) => {
90
+ if (released) {
91
+ return
92
+ }
93
+ released = true
94
+ entry.refCount -= 1
95
+ if (entry.refCount <= 0 && !entry.ended) {
96
+ entry.ended = true
97
+ if (poolCache.get(entry.key) === entry) {
98
+ poolCache.delete(entry.key)
99
+ }
100
+ await entry.client.end(options)
101
+ }
102
+ }
103
+ }
104
+
105
+ // Resolves the connection string, acquires the shared pool, and builds a fresh
106
+ // (cheap) per-handle drizzle instance over it.
107
+ function buildDrizzle<TRelations extends AnyRelations>(
108
+ kind: PoolKind,
109
+ config?: CreateClientConfig<TRelations>
110
+ ): { close: CloseFn; db: PostgresJsDatabase<TRelations> } {
111
+ const { connectionString, ...drizzleConfig } = config ?? {}
112
+ const url = connectionString ?? process.env.SUPABASE_DATABASE_URL ?? ""
113
+ if (!url) {
114
+ throw new Error("Missing SUPABASE_DATABASE_URL environment variable")
115
+ }
116
+ const entry = acquirePool(url, kind)
117
+ const db = drizzle<TRelations>({
118
+ client: entry.client,
119
+ ...(drizzleConfig as DrizzlePgConfig<TRelations>),
120
+ })
121
+ return { close: makeClose(entry), db }
122
+ }
123
+
124
+ // Wraps a drizzle instance in the lazy RLS query proxy. Claims are resolved
125
+ // before each transaction opens (so `createAuthClient` re-checks the live
126
+ // session per query), then installed transaction-locally via
127
+ // `set_config(..., true)` + `set local role`, which auto-reset at commit.
128
+ function buildRlsClient<TRelations extends AnyRelations>(
129
+ resolveContext: () => Promise<RlsContext>,
130
+ config?: CreateClientConfig<TRelations>
131
+ ): DrizzleClient<TRelations> {
132
+ const { close, db } = buildDrizzle("rls", config)
133
+ const runTransaction = async (transaction: (tx: unknown) => unknown) => {
134
+ const { claims, role, sub } = await resolveContext()
135
+ return db.transaction(async (tx) => {
136
+ await tx.execute(
137
+ sql`select set_config('request.jwt.claims', ${claims}, true), set_config('request.jwt.claim.sub', ${sub}, true)`
138
+ )
139
+ await tx.execute(sql`set local role ${sql.raw(role)}`)
140
+ return transaction(tx)
141
+ })
142
+ }
143
+ return createRlsQueryClient(
144
+ runTransaction,
145
+ close
146
+ ) as DrizzleClient<TRelations>
147
+ }
148
+
149
+ /**
150
+ * RLS-bypassing client (the docs' `admin`). Connects with
151
+ * `SUPABASE_DATABASE_URL` and queries drizzle directly. Use for webhooks,
152
+ * background jobs, and seeding, never for user-scoped reads/writes.
153
+ */
154
+ export function createAdminClient<
155
+ TRelations extends AnyRelations = EmptyRelations,
156
+ >(config?: CreateClientConfig<TRelations>): DrizzleClient<TRelations> {
157
+ const { close, db } = buildDrizzle("admin", config)
158
+ return Object.assign(db, { close })
159
+ }
160
+
161
+ /**
162
+ * RLS client scoped to an already-verified, decoded token (e.g. from
163
+ * `supabase.auth.getClaims()`). The role is clamped to the allowlist.
164
+ */
165
+ export function createSupabaseClient<
166
+ TRelations extends AnyRelations = EmptyRelations,
167
+ >(
168
+ accessToken: SupabaseToken,
169
+ config?: CreateClientConfig<TRelations>
170
+ ): DrizzleClient<TRelations> {
171
+ const context = clampClaims(accessToken)
172
+ return buildRlsClient(() => Promise.resolve(context), config)
173
+ }
174
+
175
+ /**
176
+ * RLS client bound to a Supabase client. Verified claims are resolved via
177
+ * `supabase.auth.getClaims()` on every query, so it always reflects the live
178
+ * session.
179
+ */
180
+ export function createAuthClient<
181
+ TRelations extends AnyRelations = EmptyRelations,
182
+ >(
183
+ supabase: SupabaseClient,
184
+ config?: CreateClientConfig<TRelations>
185
+ ): DrizzleClient<TRelations> {
186
+ return buildRlsClient(async () => {
187
+ const { data, error } = await supabase.auth.getClaims()
188
+ if (error) {
189
+ throw error
190
+ }
191
+ return clampClaims(data?.claims ?? {})
192
+ }, config)
193
+ }
194
+
195
+ /** RLS client that runs every query as the `anon` role. */
196
+ export function createAnonClient<
197
+ TRelations extends AnyRelations = EmptyRelations,
198
+ >(config?: CreateClientConfig<TRelations>): DrizzleClient<TRelations> {
199
+ return buildRlsClient(() => Promise.resolve(fixedContext("anon")), config)
200
+ }
201
+
202
+ /**
203
+ * Client that runs every query as `service_role`, which bypasses RLS via
204
+ * Supabase's BYPASSRLS grant. Use for trusted server-side work.
205
+ */
206
+ export function createServiceClient<
207
+ TRelations extends AnyRelations = EmptyRelations,
208
+ >(config?: CreateClientConfig<TRelations>): DrizzleClient<TRelations> {
209
+ return buildRlsClient(
210
+ () => Promise.resolve(fixedContext("service_role")),
211
+ config
212
+ )
213
+ }
package/src/config.ts ADDED
@@ -0,0 +1,28 @@
1
+ // https://orm.drizzle.team/docs/drizzle-config-file
2
+ // https://orm.drizzle.team/docs/rls#migrations (entities.roles.provider: "supabase")
3
+ import { type Config, defineConfig } from "drizzle-kit"
4
+
5
+ export function defineDrizzleConfig(
6
+ overrides: Partial<Config> = {}
7
+ ): ReturnType<typeof defineConfig> {
8
+ const { entities, ...configOverrides } = overrides
9
+ // `entities.roles` may be a boolean (`true`) in drizzle-kit config; in that
10
+ // form there are no role options to preserve, so we only merge the object
11
+ // form. The `provider: "supabase"` flag below is always enforced regardless.
12
+ const roleOverrides =
13
+ typeof entities?.roles === "object" ? entities.roles : {}
14
+
15
+ return defineConfig({
16
+ dbCredentials: { url: process.env.SUPABASE_DATABASE_URL ?? "" },
17
+ dialect: "postgresql",
18
+ // Tells drizzle-kit that Supabase's built-in roles (anon, authenticated,
19
+ // service_role, ...) already exist — don't try to CREATE or DROP them.
20
+ entities: {
21
+ ...entities,
22
+ roles: { ...roleOverrides, provider: "supabase" },
23
+ },
24
+ out: "./supabase/migrations",
25
+ schema: "./src/schema.ts",
26
+ ...configOverrides,
27
+ } as Config)
28
+ }
package/src/index.ts ADDED
@@ -0,0 +1,11 @@
1
+ // biome-ignore lint/performance/noBarrelFile: public package entrypoint
2
+ export {
3
+ type CreateClientConfig,
4
+ createAdminClient,
5
+ createAnonClient,
6
+ createAuthClient,
7
+ createServiceClient,
8
+ createSupabaseClient,
9
+ type DrizzleClient,
10
+ type SupabaseToken,
11
+ } from "./clients.ts"
@@ -0,0 +1,100 @@
1
+ // The lazy "record a query chain, then replay it inside one RLS transaction"
2
+ // client returned by `createSupabaseDrizzle`. Kept separate from `clients.ts`
3
+ // because it depends only on the `runTransaction`/`close` callbacks passed in —
4
+ // it knows nothing about pools, Supabase, or drizzle.
5
+
6
+ // One recorded step of a chained call: `db.select` is a get, the following `()`
7
+ // is an apply.
8
+ type AsUserChainStep =
9
+ | { kind: "get"; prop: PropertyKey }
10
+ | { kind: "apply"; args: unknown[] }
11
+
12
+ // Walk the recorded chain against the live transaction `tx`, tracking the
13
+ // receiver so methods are invoked with the right `this`. Returns whatever the
14
+ // chain produces — a thenable drizzle builder, or a relational query promise.
15
+ function replayAsUserChain(tx: unknown, path: AsUserChainStep[]): unknown {
16
+ let receiver: unknown = tx
17
+ let current: unknown = tx
18
+ for (const step of path) {
19
+ if (step.kind === "get") {
20
+ receiver = current
21
+ current = (current as Record<PropertyKey, unknown>)[step.prop]
22
+ } else {
23
+ current = (current as (...args: unknown[]) => unknown).apply(
24
+ receiver,
25
+ step.args
26
+ )
27
+ }
28
+ }
29
+ return current
30
+ }
31
+
32
+ const PROMISE_METHODS = new Set<PropertyKey>(["then", "catch", "finally"])
33
+
34
+ // Awaiting a recorded chain triggers the transaction + replay. The transaction
35
+ // is opened lazily when the promise method is *called* (not merely accessed),
36
+ // so probing `.then` for thenable-detection never starts a stray transaction.
37
+ // The call returns a real promise, so any further `.then`/`.catch`/`.finally`
38
+ // chaining runs on it.
39
+ function replayPromiseMethod(
40
+ prop: PropertyKey,
41
+ path: AsUserChainStep[],
42
+ runTransaction: (transaction: (tx: unknown) => unknown) => Promise<unknown>
43
+ ) {
44
+ return (...promiseArgs: unknown[]) => {
45
+ const promise = runTransaction((tx) => replayAsUserChain(tx, path))
46
+ return (
47
+ promise[prop as keyof Promise<unknown>] as (...args: unknown[]) => unknown
48
+ ).apply(promise, promiseArgs)
49
+ }
50
+ }
51
+
52
+ // Builds the RLS query client returned by `createSupabaseDrizzle`. Querying it
53
+ // records the get/apply chain lazily; only when the chain is awaited
54
+ // (`.then`/`.catch`/`.finally`) does it open an RLS transaction and replay the
55
+ // chain against that transaction's `tx`. Each awaited chain is its own
56
+ // transaction. `db.transaction(cb)` runs several statements in one transaction,
57
+ // and `db.close()` releases the pools. The root itself is intentionally not
58
+ // thenable and not callable.
59
+ export function createRlsQueryClient(
60
+ runTransaction: (transaction: (tx: unknown) => unknown) => Promise<unknown>,
61
+ close: (...args: never[]) => Promise<void>
62
+ ): unknown {
63
+ const build = (path: AsUserChainStep[], isRoot: boolean): unknown => {
64
+ // The proxy target must be callable so the `apply` trap fires for `()`.
65
+ const target = () => undefined
66
+ return new Proxy(target, {
67
+ apply(_target, _thisArg, args: unknown[]) {
68
+ if (isRoot) {
69
+ throw new Error(
70
+ "The createSupabaseDrizzle() client is queried directly (e.g. db.select().from(table)). Use db.transaction(cb) to run multiple statements in one RLS transaction."
71
+ )
72
+ }
73
+ return build([...path, { args, kind: "apply" }], false)
74
+ },
75
+ get(_target, prop) {
76
+ // Multi-statement RLS transaction and pool release live on the root.
77
+ if (isRoot && prop === "transaction") {
78
+ return runTransaction
79
+ }
80
+ if (isRoot && prop === "close") {
81
+ return close
82
+ }
83
+ if (PROMISE_METHODS.has(prop)) {
84
+ // Root stays a plain (non-thenable) object so `await db` / probes
85
+ // never open a stray transaction; recorded chains are awaitable.
86
+ return isRoot
87
+ ? undefined
88
+ : replayPromiseMethod(prop, path, runTransaction)
89
+ }
90
+ // Ignore symbol probes (inspection, `Symbol.toPrimitive`, etc.) so they
91
+ // are not recorded as part of the query chain.
92
+ if (typeof prop === "symbol") {
93
+ return
94
+ }
95
+ return build([...path, { kind: "get", prop }], false)
96
+ },
97
+ })
98
+ }
99
+ return build([], true)
100
+ }
package/src/schema.ts ADDED
@@ -0,0 +1,187 @@
1
+ // https://orm.drizzle.team/docs/rls#using-with-supabase (re-exported roles, authUsers, authUid, realtimeMessages)
2
+ import { sql } from "drizzle-orm"
3
+ import {
4
+ type AnyPgColumn,
5
+ integer,
6
+ type PgPolicyConfig,
7
+ pgPolicy,
8
+ timestamp,
9
+ uuid,
10
+ } from "drizzle-orm/pg-core"
11
+ import { snakeCase } from "drizzle-orm/pg-core/casing"
12
+ import { authenticatedRole, authUid, authUsers } from "drizzle-orm/supabase"
13
+
14
+ // Curated pg-core aliases for schema primitives that otherwise repeat the pg
15
+ // prefix at every call site. `table` is Zeno's RLS-by-default helper below.
16
+ // biome-ignore lint/performance/noBarrelFile: intentional public re-export surface
17
+ export {
18
+ isPgEnum as isEnum,
19
+ isPgMaterializedView as isMaterializedView,
20
+ isPgSchema as isSchema,
21
+ isPgSequence as isSequence,
22
+ isPgView as isView,
23
+ pgEnum as enum,
24
+ pgMaterializedView as materializedView,
25
+ pgPolicy as policy,
26
+ pgRole as role,
27
+ pgSchema as schema,
28
+ pgSequence as sequence,
29
+ pgTableCreator as tableCreator,
30
+ pgView as view,
31
+ } from "drizzle-orm/pg-core"
32
+
33
+ // Curated Supabase primitives from drizzle-orm/supabase so consumers can import
34
+ // roles, the auth.users table, and helpers from one Zeno-owned schema entrypoint.
35
+ export {
36
+ anonRole,
37
+ authenticatedRole,
38
+ authUid,
39
+ authUsers,
40
+ postgresRole,
41
+ realtimeMessages,
42
+ realtimeTopic,
43
+ serviceRole,
44
+ supabaseAuthAdminRole,
45
+ } from "drizzle-orm/supabase"
46
+
47
+ // Reusable created_at / updated_at columns — spread into a pgTable column map.
48
+ export const timestamps = {
49
+ createdAt: timestamp("created_at", { withTimezone: true })
50
+ .notNull()
51
+ .defaultNow(),
52
+ updatedAt: timestamp("updated_at", { withTimezone: true })
53
+ .notNull()
54
+ .defaultNow()
55
+ .$onUpdate(() => new Date()),
56
+ }
57
+
58
+ export const authUserId = (name?: string) =>
59
+ uuid(name)
60
+ .notNull()
61
+ .references(() => authUsers.id)
62
+
63
+ export const createdBy = authUserId("created_by").default(authUid)
64
+ export const updatedBy = authUserId("updated_by")
65
+ .default(authUid)
66
+ .$onUpdate(() => authUid)
67
+
68
+ export const authorship = {
69
+ createdBy,
70
+ updatedBy,
71
+ }
72
+
73
+ export const auditColumns = {
74
+ ...timestamps,
75
+ ...authorship,
76
+ }
77
+
78
+ const uuidPrimaryId = () => uuid("id").primaryKey().defaultRandom()
79
+ const sequentialPrimaryId = () =>
80
+ integer("id").primaryKey().generatedAlwaysAsIdentity()
81
+
82
+ export function primaryId(kind?: "uuid"): ReturnType<typeof uuidPrimaryId>
83
+ export function primaryId(
84
+ kind: "sequential"
85
+ ): ReturnType<typeof sequentialPrimaryId>
86
+ export function primaryId(kind: "uuid" | "sequential" = "uuid") {
87
+ return kind === "sequential" ? sequentialPrimaryId() : uuidPrimaryId()
88
+ }
89
+
90
+ type PolicyOptions = Omit<PgPolicyConfig, "for">
91
+ type PolicyOperation = NonNullable<PgPolicyConfig["for"]>
92
+
93
+ function operationPolicy(
94
+ name: string,
95
+ operation: PolicyOperation,
96
+ config: PolicyOptions = {}
97
+ ) {
98
+ return pgPolicy(name, { ...config, for: operation })
99
+ }
100
+
101
+ export const selectPolicy = (name: string, config: PolicyOptions = {}) =>
102
+ operationPolicy(name, "select", config)
103
+
104
+ export const insertPolicy = (name: string, config: PolicyOptions = {}) =>
105
+ operationPolicy(name, "insert", config)
106
+
107
+ export const updatePolicy = (name: string, config: PolicyOptions = {}) =>
108
+ operationPolicy(name, "update", config)
109
+
110
+ export const deletePolicy = (name: string, config: PolicyOptions = {}) =>
111
+ operationPolicy(name, "delete", config)
112
+
113
+ export const allPolicy = (name: string, config: PolicyOptions = {}) =>
114
+ operationPolicy(name, "all", config)
115
+
116
+ export const authUserOwns = (ownerColumn: AnyPgColumn) =>
117
+ sql`${ownerColumn} = ${authUid}`
118
+
119
+ type OwnerPolicyOptions = Omit<PgPolicyConfig, "for" | "to">
120
+
121
+ export const authenticatedOwnerSelectPolicy = (
122
+ name: string,
123
+ ownerColumn: AnyPgColumn,
124
+ config: OwnerPolicyOptions = {}
125
+ ) =>
126
+ selectPolicy(name, {
127
+ ...config,
128
+ to: authenticatedRole,
129
+ using: config.using ?? authUserOwns(ownerColumn),
130
+ })
131
+
132
+ export const authenticatedOwnerInsertPolicy = (
133
+ name: string,
134
+ ownerColumn: AnyPgColumn,
135
+ config: OwnerPolicyOptions = {}
136
+ ) =>
137
+ insertPolicy(name, {
138
+ ...config,
139
+ to: authenticatedRole,
140
+ withCheck: config.withCheck ?? authUserOwns(ownerColumn),
141
+ })
142
+
143
+ export const authenticatedOwnerUpdatePolicy = (
144
+ name: string,
145
+ ownerColumn: AnyPgColumn,
146
+ config: OwnerPolicyOptions = {}
147
+ ) => {
148
+ const ownerCondition = authUserOwns(ownerColumn)
149
+ return updatePolicy(name, {
150
+ ...config,
151
+ to: authenticatedRole,
152
+ using: config.using ?? ownerCondition,
153
+ withCheck: config.withCheck ?? ownerCondition,
154
+ })
155
+ }
156
+
157
+ export const authenticatedOwnerDeletePolicy = (
158
+ name: string,
159
+ ownerColumn: AnyPgColumn,
160
+ config: OwnerPolicyOptions = {}
161
+ ) =>
162
+ deletePolicy(name, {
163
+ ...config,
164
+ to: authenticatedRole,
165
+ using: config.using ?? authUserOwns(ownerColumn),
166
+ })
167
+
168
+ export const authenticatedOwnerAllPolicy = (
169
+ name: string,
170
+ ownerColumn: AnyPgColumn,
171
+ config: OwnerPolicyOptions = {}
172
+ ) => {
173
+ const ownerCondition = authUserOwns(ownerColumn)
174
+ return allPolicy(name, {
175
+ ...config,
176
+ to: authenticatedRole,
177
+ using: config.using ?? ownerCondition,
178
+ withCheck: config.withCheck ?? ownerCondition,
179
+ })
180
+ }
181
+
182
+ // Default table helper for application-owned tables: TypeScript columns stay
183
+ // camelCase, database identifiers become snake_case, and RLS is enabled.
184
+ export const table = snakeCase.table.withRLS
185
+
186
+ // Escape hatch for intentionally non-RLS tables such as seed/reference data.
187
+ export const unsecureTable = snakeCase.table