@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 +3 -0
- package/package.json +51 -0
- package/src/casing.test.ts +264 -0
- package/src/clients.test.ts +94 -0
- package/src/clients.ts +213 -0
- package/src/config.ts +28 -0
- package/src/index.ts +11 -0
- package/src/rls-query-client.ts +100 -0
- package/src/schema.ts +187 -0
package/README.md
ADDED
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
|