@zeno-lib/db 0.3.2 → 0.5.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.
@@ -0,0 +1,296 @@
1
+ import { AuthError, type JwtPayload } from "@supabase/supabase-js"
2
+ import { beforeEach, describe, expect, it, vi } from "vitest"
3
+ import { z } from "zod"
4
+ import { FieldValidationError } from "./action-result.ts"
5
+ import { createDefineAction, createDefineFormAction } from "./define-action.ts"
6
+
7
+ // No database: the RLS client factory is replaced by a stub that returns a
8
+ // marker object, so nothing here builds a pool or opens a connection. What
9
+ // that client does with the claims is covered by test/rls.integration.test.ts.
10
+ const { createSupabaseClientMock } = vi.hoisted(() => ({
11
+ createSupabaseClientMock: vi.fn((claims: unknown) => ({
12
+ claims,
13
+ fake: true,
14
+ })),
15
+ }))
16
+
17
+ vi.mock("./clients.ts", () => ({
18
+ createSupabaseClient: createSupabaseClientMock,
19
+ }))
20
+
21
+ import {
22
+ type ClaimsSource,
23
+ createRequestDb,
24
+ UnauthenticatedError,
25
+ } from "./next.ts"
26
+
27
+ const claims: JwtPayload = {
28
+ aal: "aal1",
29
+ aud: "authenticated",
30
+ exp: 0,
31
+ iat: 0,
32
+ iss: "test",
33
+ role: "authenticated",
34
+ roles: ["admin"],
35
+ session_id: "session",
36
+ sub: "11111111-1111-1111-1111-111111111111",
37
+ }
38
+
39
+ type GetClaimsResult = Awaited<ReturnType<ClaimsSource["auth"]["getClaims"]>>
40
+
41
+ function getClaimsResult(
42
+ payload: JwtPayload | null,
43
+ error: AuthError | null
44
+ ): GetClaimsResult {
45
+ if (payload) {
46
+ return {
47
+ data: {
48
+ claims: payload,
49
+ header: { alg: "HS256", kid: "test", typ: "JWT" },
50
+ signature: new Uint8Array(),
51
+ },
52
+ error: null,
53
+ }
54
+ }
55
+ return error ? { data: null, error } : { data: null, error: null }
56
+ }
57
+
58
+ function supabaseReturning(result: GetClaimsResult): () => ClaimsSource {
59
+ return () => ({ auth: { getClaims: () => Promise.resolve(result) } })
60
+ }
61
+
62
+ beforeEach(() => {
63
+ createSupabaseClientMock.mockClear()
64
+ })
65
+
66
+ describe("defineAction", () => {
67
+ const schema = z.object({ id: z.number().int().positive() }).strict()
68
+
69
+ it("parses the input, then calls the handler with db, parsed input and context", async () => {
70
+ const context = { claims: { sub: "user-1" }, db: { name: "db" } }
71
+ const getContext = vi.fn(() => Promise.resolve(context))
72
+ const handler = vi.fn((db: { name: string }, input: { id: number }) =>
73
+ Promise.resolve(`${db.name}:${input.id}`)
74
+ )
75
+
76
+ const action = createDefineAction(getContext)(schema, handler)
77
+
78
+ await expect(action({ id: 7 })).resolves.toBe("db:7")
79
+ expect(handler).toHaveBeenCalledWith(context.db, { id: 7 }, context)
80
+ })
81
+
82
+ it("hands the handler the claims for authorship", async () => {
83
+ const context = { claims: { sub: "user-1" }, db: {} }
84
+ const action = createDefineAction(() => Promise.resolve(context))(
85
+ schema,
86
+ (_db, input, { claims: { sub } }) => ({ createdBy: sub, ...input })
87
+ )
88
+
89
+ await expect(action({ id: 1 })).resolves.toEqual({
90
+ createdBy: "user-1",
91
+ id: 1,
92
+ })
93
+ })
94
+
95
+ it("rejects invalid input before resolving the context or calling the handler", async () => {
96
+ const getContext = vi.fn(() => Promise.resolve({ db: {} }))
97
+ const handler = vi.fn()
98
+ const action = createDefineAction(getContext)(schema, handler)
99
+
100
+ await expect(action({ id: -1 })).rejects.toBeInstanceOf(z.ZodError)
101
+ expect(getContext).not.toHaveBeenCalled()
102
+ expect(handler).not.toHaveBeenCalled()
103
+ })
104
+
105
+ it("propagates a context failure without calling the handler", async () => {
106
+ const handler = vi.fn()
107
+ const action = createDefineAction(() =>
108
+ Promise.reject(new UnauthenticatedError())
109
+ )(schema, handler)
110
+
111
+ await expect(action({ id: 1 })).rejects.toBeInstanceOf(UnauthenticatedError)
112
+ expect(handler).not.toHaveBeenCalled()
113
+ })
114
+ })
115
+
116
+ describe("defineFormAction", () => {
117
+ const schema = z.object({
118
+ owners: z.array(
119
+ z.object({ percentage: z.number().max(100, "At most 100") })
120
+ ),
121
+ })
122
+ const context = { claims: { sub: "user-1" }, db: { name: "db" } }
123
+
124
+ it("resolves to { ok: true, data } with the handler's result", async () => {
125
+ const handler = vi.fn(
126
+ (_db: { name: string }, input: z.output<typeof schema>) =>
127
+ Promise.resolve(input.owners.length)
128
+ )
129
+ const action = createDefineFormAction(() => Promise.resolve(context))(
130
+ schema,
131
+ handler
132
+ )
133
+
134
+ await expect(action({ owners: [{ percentage: 50 }] })).resolves.toEqual({
135
+ data: 1,
136
+ ok: true,
137
+ })
138
+ expect(handler).toHaveBeenCalledWith(
139
+ context.db,
140
+ { owners: [{ percentage: 50 }] },
141
+ context
142
+ )
143
+ })
144
+
145
+ it("returns schema issues keyed by field name, before resolving the context", async () => {
146
+ const getContext = vi.fn(() => Promise.resolve(context))
147
+ const handler = vi.fn()
148
+ const action = createDefineFormAction(getContext)(schema, handler)
149
+
150
+ await expect(
151
+ action({ owners: [{ percentage: 10 }, { percentage: 150 }] })
152
+ ).resolves.toEqual({
153
+ error: {
154
+ fieldErrors: { "owners[1].percentage": ["At most 100"] },
155
+ formErrors: [],
156
+ },
157
+ ok: false,
158
+ })
159
+ expect(getContext).not.toHaveBeenCalled()
160
+ expect(handler).not.toHaveBeenCalled()
161
+ })
162
+
163
+ it("returns a FieldValidationError thrown by the handler in the same shape", async () => {
164
+ const action = createDefineFormAction(() => Promise.resolve(context))(
165
+ schema,
166
+ () => {
167
+ throw new FieldValidationError(
168
+ { "owners[0].percentage": "Already allocated" },
169
+ { formErrors: "Record is locked" }
170
+ )
171
+ }
172
+ )
173
+
174
+ await expect(action({ owners: [{ percentage: 1 }] })).resolves.toEqual({
175
+ error: {
176
+ fieldErrors: { "owners[0].percentage": ["Already allocated"] },
177
+ formErrors: ["Record is locked"],
178
+ },
179
+ ok: false,
180
+ })
181
+ })
182
+
183
+ it("keeps a failure that is not a validation error throwing", async () => {
184
+ const boom = new Error("connection reset")
185
+ const action = createDefineFormAction(() => Promise.resolve(context))(
186
+ schema,
187
+ () => Promise.reject(boom)
188
+ )
189
+
190
+ await expect(action({ owners: [] })).rejects.toBe(boom)
191
+ })
192
+
193
+ it("keeps a context failure throwing", async () => {
194
+ const handler = vi.fn()
195
+ const action = createDefineFormAction(() =>
196
+ Promise.reject(new UnauthenticatedError())
197
+ )(schema, handler)
198
+
199
+ await expect(action({ owners: [] })).rejects.toBeInstanceOf(
200
+ UnauthenticatedError
201
+ )
202
+ expect(handler).not.toHaveBeenCalled()
203
+ })
204
+
205
+ it("returns a result that survives a JSON round trip unchanged", async () => {
206
+ const action = createDefineFormAction(() => Promise.resolve(context))(
207
+ schema,
208
+ () => ({ id: 1 })
209
+ )
210
+ const failure = await action({ owners: [{ percentage: 101 }] })
211
+ const success = await action({ owners: [] })
212
+
213
+ expect(JSON.parse(JSON.stringify(failure))).toEqual(failure)
214
+ expect(JSON.parse(JSON.stringify(success))).toEqual(success)
215
+ })
216
+ })
217
+
218
+ describe("createRequestDb", () => {
219
+ it("builds the RLS client from the whole verified claims object", async () => {
220
+ const { getRequestContext, getRequestDb } = createRequestDb({
221
+ connectionString: () => "postgresql://lazy",
222
+ supabase: supabaseReturning(getClaimsResult(claims, null)),
223
+ })
224
+
225
+ const context = await getRequestContext()
226
+
227
+ expect(context.claims).toBe(claims)
228
+ expect(createSupabaseClientMock).toHaveBeenCalledWith(claims, {
229
+ connectionString: "postgresql://lazy",
230
+ })
231
+ await expect(getRequestDb()).resolves.toEqual({ claims, fake: true })
232
+ })
233
+
234
+ it("throws UnauthenticatedError when there is no session", async () => {
235
+ const { getRequestDb } = createRequestDb({
236
+ supabase: supabaseReturning(getClaimsResult(null, null)),
237
+ })
238
+
239
+ const error = await getRequestDb().catch((caught: unknown) => caught)
240
+
241
+ expect(error).toBeInstanceOf(UnauthenticatedError)
242
+ expect(error).toHaveProperty("name", "UnauthenticatedError")
243
+ expect(createSupabaseClientMock).not.toHaveBeenCalled()
244
+ })
245
+
246
+ it("throws UnauthenticatedError for claims without a subject", async () => {
247
+ const { getRequestDb } = createRequestDb({
248
+ supabase: supabaseReturning(
249
+ getClaimsResult({ ...claims, sub: "" }, null)
250
+ ),
251
+ })
252
+
253
+ await expect(getRequestDb()).rejects.toBeInstanceOf(UnauthenticatedError)
254
+ })
255
+
256
+ it("rethrows a getClaims error as is", async () => {
257
+ const failure = new AuthError("jwks unreachable")
258
+ const { getRequestDb } = createRequestDb({
259
+ supabase: supabaseReturning(getClaimsResult(null, failure)),
260
+ })
261
+
262
+ await expect(getRequestDb()).rejects.toBe(failure)
263
+ })
264
+
265
+ it("binds defineAction to the request context", async () => {
266
+ const { defineAction } = createRequestDb({
267
+ supabase: supabaseReturning(getClaimsResult(claims, null)),
268
+ })
269
+ const action = defineAction(z.string(), (db, input, context) => ({
270
+ db,
271
+ input,
272
+ sub: context.claims.sub,
273
+ }))
274
+
275
+ await expect(action("hello")).resolves.toEqual({
276
+ db: { claims, fake: true },
277
+ input: "hello",
278
+ sub: claims.sub,
279
+ })
280
+ })
281
+
282
+ it("binds defineFormAction to the request context", async () => {
283
+ const { defineFormAction } = createRequestDb({
284
+ supabase: supabaseReturning(getClaimsResult(claims, null)),
285
+ })
286
+ const action = defineFormAction(z.string(), (_db, input, context) => ({
287
+ input,
288
+ sub: context.claims.sub,
289
+ }))
290
+
291
+ await expect(action("hello")).resolves.toEqual({
292
+ data: { input: "hello", sub: claims.sub },
293
+ ok: true,
294
+ })
295
+ })
296
+ })
package/src/next.ts ADDED
@@ -0,0 +1,134 @@
1
+ import type { JwtPayload, SupabaseClient } from "@supabase/supabase-js"
2
+ import type { AnyRelations, EmptyRelations } from "drizzle-orm"
3
+ import { cache } from "react"
4
+ import {
5
+ type CreateClientConfig,
6
+ createSupabaseClient,
7
+ type DrizzleClient,
8
+ } from "./clients.ts"
9
+ import {
10
+ createDefineAction,
11
+ createDefineFormAction,
12
+ type DefineAction,
13
+ type DefineFormAction,
14
+ } from "./define-action.ts"
15
+
16
+ // biome-ignore lint/performance/noBarrelFile: `/next` is the one entry server actions import; the result types and error class belong on it.
17
+ export {
18
+ type ActionError,
19
+ type ActionIssue,
20
+ type ActionResult,
21
+ FieldValidationError,
22
+ toActionError,
23
+ toFieldName,
24
+ } from "./action-result.ts"
25
+ export type {
26
+ ActionContext,
27
+ ActionHandler,
28
+ ActionSchema,
29
+ DefineAction,
30
+ DefineFormAction,
31
+ FormActionSchema,
32
+ } from "./define-action.ts"
33
+
34
+ /**
35
+ * Thrown when a request carries no verified session. A named class so a caller
36
+ * with a legitimate answer for that case (a permission check, where "not signed
37
+ * in" means "no") can tell it apart from a real failure with `instanceof`.
38
+ */
39
+ export class UnauthenticatedError extends Error {
40
+ constructor(message = "Unauthorized: no verified session on this request") {
41
+ super(message)
42
+ this.name = "UnauthenticatedError"
43
+ }
44
+ }
45
+
46
+ /** The only part of a Supabase client the request context reads. */
47
+ export type ClaimsSource = { auth: Pick<SupabaseClient["auth"], "getClaims"> }
48
+
49
+ /** A verified request: the full `getClaims()` payload and the RLS client. */
50
+ export type RequestContext<TRelations extends AnyRelations = EmptyRelations> = {
51
+ readonly claims: JwtPayload
52
+ readonly db: DrizzleClient<TRelations>
53
+ }
54
+
55
+ export type CreateRequestDbOptions<
56
+ TRelations extends AnyRelations = EmptyRelations,
57
+ > = Omit<CreateClientConfig<TRelations>, "connectionString"> & {
58
+ /**
59
+ * Builds the request's Supabase client, e.g. `createClient` from
60
+ * `@zeno-lib/supabase/next-server` (cookie session via `next/headers`).
61
+ */
62
+ supabase: () => ClaimsSource | Promise<ClaimsSource>
63
+ /**
64
+ * Overrides `SUPABASE_DATABASE_URL`. A function is resolved per request, not
65
+ * at import, so a build without server secrets can still import the module.
66
+ */
67
+ connectionString?: string | (() => string)
68
+ }
69
+
70
+ export type RequestDb<TRelations extends AnyRelations = EmptyRelations> = {
71
+ /** Memoised per render: `{ claims, db }` for the verified caller. */
72
+ getRequestContext: () => Promise<RequestContext<TRelations>>
73
+ /** The common case: just the RLS-bound client. */
74
+ getRequestDb: () => Promise<DrizzleClient<TRelations>>
75
+ /** Wraps a handler into a `"use server"` export; see `DefineAction`. */
76
+ defineAction: DefineAction<RequestContext<TRelations>>
77
+ /**
78
+ * `defineAction` for forms: resolves to an `ActionResult` instead of
79
+ * throwing on invalid input; see `DefineFormAction`.
80
+ */
81
+ defineFormAction: DefineFormAction<RequestContext<TRelations>>
82
+ }
83
+
84
+ /**
85
+ * Request-scoped, RLS-bound Drizzle access for Next.js. Verifies the session
86
+ * with `getClaims()` (which checks the signature) and installs the **whole**
87
+ * claims object, so custom claims reach `auth.jwt()` in policies. There is no
88
+ * fallback to `anon`: a caller without a verified `sub` gets
89
+ * `UnauthenticatedError` rather than the empty result of a policy it never
90
+ * satisfied.
91
+ *
92
+ * `getRequestContext` is wrapped in React's `cache`, so one server render shares
93
+ * one verification and one handle. A server action invoked from the browser
94
+ * runs outside a render and resolves afresh on every call.
95
+ *
96
+ * Do not `close()` the handle: pools are shared and reference-counted.
97
+ */
98
+ export function createRequestDb<
99
+ TRelations extends AnyRelations = EmptyRelations,
100
+ >(options: CreateRequestDbOptions<TRelations>): RequestDb<TRelations> {
101
+ const { connectionString, supabase, ...config } = options
102
+
103
+ const getRequestContext = cache(
104
+ async (): Promise<RequestContext<TRelations>> => {
105
+ const client = await supabase()
106
+ const { data, error } = await client.auth.getClaims()
107
+
108
+ if (error) {
109
+ throw error
110
+ }
111
+ if (!data?.claims.sub) {
112
+ throw new UnauthenticatedError()
113
+ }
114
+
115
+ const url =
116
+ typeof connectionString === "function"
117
+ ? connectionString()
118
+ : connectionString
119
+ const db = createSupabaseClient<TRelations>(
120
+ data.claims,
121
+ url === undefined ? config : { ...config, connectionString: url }
122
+ )
123
+
124
+ return { claims: data.claims, db }
125
+ }
126
+ )
127
+
128
+ return {
129
+ defineAction: createDefineAction(getRequestContext),
130
+ defineFormAction: createDefineFormAction(getRequestContext),
131
+ getRequestContext,
132
+ getRequestDb: async () => (await getRequestContext()).db,
133
+ }
134
+ }
@@ -0,0 +1,20 @@
1
+ import { defineRelations } from "drizzle-orm"
2
+ import type { PgAsyncDatabase, PgAsyncTransaction } from "drizzle-orm/pg-core"
3
+ import type { PostgresJsQueryResultHKT } from "drizzle-orm/postgres-js"
4
+ import { expectTypeOf, test } from "vitest"
5
+ import type { DrizzleClient } from "./clients.ts"
6
+ import type { QueryExecutor } from "./query.ts"
7
+ import { primaryId, table } from "./schema.ts"
8
+
9
+ const posts = table("posts", { id: primaryId("sequential") })
10
+ const relations = defineRelations({ posts })
11
+
12
+ test("selectFunctionPermissions takes every client and transaction shape", () => {
13
+ expectTypeOf<DrizzleClient<typeof relations>>().toExtend<QueryExecutor>()
14
+ expectTypeOf<
15
+ PgAsyncDatabase<PostgresJsQueryResultHKT, typeof relations>
16
+ >().toExtend<QueryExecutor>()
17
+ expectTypeOf<
18
+ PgAsyncTransaction<PostgresJsQueryResultHKT, typeof relations>
19
+ >().toExtend<QueryExecutor>()
20
+ })