@zeno-lib/db 0.3.1 → 0.4.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/src/errors.ts ADDED
@@ -0,0 +1,133 @@
1
+ // Recognising Postgres errors through Drizzle. Runtime code, not a test helper:
2
+ // mapping a unique violation to a form error is ordinary request handling.
3
+ import type { PostgresError } from "postgres"
4
+
5
+ /**
6
+ * SQLSTATE codes worth naming. The full list is Postgres's "Error Codes"
7
+ * appendix; these are the ones an application branches on.
8
+ */
9
+ export const SqlState = {
10
+ /** `check_violation`. */
11
+ checkViolation: "23514",
12
+ /** `exclusion_violation`. */
13
+ exclusionViolation: "23P01",
14
+ /** `foreign_key_violation`. */
15
+ foreignKeyViolation: "23503",
16
+ /** `insufficient_privilege`: an RLS policy or a missing grant refused it. */
17
+ insufficientPrivilege: "42501",
18
+ /**
19
+ * `invalid_column_reference`, which is what an `ON CONFLICT` target that no
20
+ * unique index can arbitrate raises. It is raised while the statement is
21
+ * planned, so it fires on the first insert, not on the first conflict.
22
+ */
23
+ invalidColumnReference: "42P10",
24
+ /** `not_null_violation`. */
25
+ notNullViolation: "23502",
26
+ /** `undefined_function`: a function missing, or called with the wrong signature. */
27
+ undefinedFunction: "42883",
28
+ /** `unique_violation`. */
29
+ uniqueViolation: "23505",
30
+ } as const
31
+
32
+ export type SqlStateCode = (typeof SqlState)[keyof typeof SqlState]
33
+
34
+ /** SQLSTATE class 23, every `integrity_constraint_violation`. */
35
+ const INTEGRITY_CONSTRAINT_CLASS = "23"
36
+
37
+ // Drizzle wraps once, and a failure inside `db.transaction()` can nest one
38
+ // more level. The bound only stops a cyclic `cause` chain.
39
+ const MAX_CAUSE_DEPTH = 5
40
+
41
+ /**
42
+ * The postgres.js error underneath whatever Drizzle threw, or `undefined`.
43
+ *
44
+ * Drizzle wraps **every** driver error in a `DrizzleQueryError` whose own
45
+ * `code` is `undefined`, so `error.code === "23505"` is always false:
46
+ *
47
+ * ```
48
+ * depth 0: DrizzleQueryError code=undefined "Failed query: insert into …"
49
+ * depth 1: PostgresError code=23505 "duplicate key value violates …"
50
+ * ```
51
+ *
52
+ * Matched by `name` rather than `instanceof`, so a second copy of `postgres`
53
+ * in the tree cannot make it miss.
54
+ */
55
+ export const toPostgresError = (error: unknown): PostgresError | undefined => {
56
+ let current: unknown = error
57
+
58
+ for (
59
+ let depth = 0;
60
+ current instanceof Error && depth < MAX_CAUSE_DEPTH;
61
+ depth += 1
62
+ ) {
63
+ if (current.name === "PostgresError") {
64
+ return current as PostgresError
65
+ }
66
+ // `Error.cause` is ES2022, past this package's `lib`.
67
+ current = (current as Error & { cause?: unknown }).cause
68
+ }
69
+
70
+ return
71
+ }
72
+
73
+ export type ConstraintViolationOptions = {
74
+ /**
75
+ * The SQLSTATE to require, e.g. `SqlState.uniqueViolation`. Defaults to any
76
+ * code in class 23 (`integrity_constraint_violation`).
77
+ */
78
+ code?: string
79
+ }
80
+
81
+ /**
82
+ * Whether `error` is an integrity-constraint violation, optionally one of the
83
+ * named constraints. Returns the `PostgresError` so a caller can read its
84
+ * `constraint_name` or `detail`, or `undefined` when it does not match.
85
+ *
86
+ * ```ts
87
+ * try {
88
+ * await db.insert(contacts).values(row)
89
+ * } catch (error) {
90
+ * if (isConstraintViolation(error, ["contacts_email_key"], { code: SqlState.uniqueViolation })) {
91
+ * return { error: "That email is already a contact" }
92
+ * }
93
+ * throw error
94
+ * }
95
+ * ```
96
+ */
97
+ export const isConstraintViolation = (
98
+ error: unknown,
99
+ constraints?: Iterable<string>,
100
+ { code }: ConstraintViolationOptions = {}
101
+ ): PostgresError | undefined => {
102
+ const postgresError = toPostgresError(error)
103
+
104
+ if (!postgresError) {
105
+ return
106
+ }
107
+
108
+ const matchesCode = code
109
+ ? postgresError.code === code
110
+ : postgresError.code.startsWith(INTEGRITY_CONSTRAINT_CLASS)
111
+
112
+ if (!matchesCode) {
113
+ return
114
+ }
115
+
116
+ if (constraints === undefined) {
117
+ return postgresError
118
+ }
119
+
120
+ const name = postgresError.constraint_name
121
+
122
+ if (name === undefined) {
123
+ return
124
+ }
125
+
126
+ for (const constraint of constraints) {
127
+ if (constraint === name) {
128
+ return postgresError
129
+ }
130
+ }
131
+
132
+ return
133
+ }
@@ -0,0 +1,21 @@
1
+ // The naming convention `functionPolicies` writes policies against and
2
+ // `selectFunctionPermissions` calls: `<prefix>_<operation>_<table>`. Shared so
3
+ // the two cannot drift apart.
4
+
5
+ export const FUNCTION_POLICY_OPERATIONS = [
6
+ "select",
7
+ "insert",
8
+ "update",
9
+ "delete",
10
+ ] as const
11
+
12
+ export type FunctionPolicyOperation =
13
+ (typeof FUNCTION_POLICY_OPERATIONS)[number]
14
+
15
+ export const DEFAULT_FUNCTION_PREFIX = "can"
16
+
17
+ export const functionPolicyName = (
18
+ prefix: string,
19
+ operation: FunctionPolicyOperation,
20
+ tableName: string
21
+ ) => `${prefix}_${operation}_${tableName}`
package/src/index.ts CHANGED
@@ -7,5 +7,7 @@ export {
7
7
  createServiceClient,
8
8
  createSupabaseClient,
9
9
  type DrizzleClient,
10
+ type ResolveDatabaseUrlOptions,
11
+ resolveDatabaseUrl,
10
12
  type SupabaseToken,
11
13
  } from "./clients.ts"
@@ -0,0 +1,32 @@
1
+ import type { JwtPayload } from "@supabase/supabase-js"
2
+ import { expectTypeOf, test } from "vitest"
3
+ import { z } from "zod"
4
+ import type { DrizzleClient } from "./clients.ts"
5
+ import { createRequestDb } from "./next.ts"
6
+
7
+ const { defineAction } = createRequestDb({
8
+ supabase: () => ({
9
+ auth: {
10
+ getClaims: () => Promise.resolve({ data: null, error: null }),
11
+ },
12
+ }),
13
+ })
14
+
15
+ test("the action takes the schema's input and resolves to the handler's result", () => {
16
+ const schema = z.object({ id: z.string().transform(Number) })
17
+ const action = defineAction(schema, (db, input, context) => {
18
+ expectTypeOf(db).toEqualTypeOf<DrizzleClient>()
19
+ expectTypeOf(input).toEqualTypeOf<{ id: number }>()
20
+ expectTypeOf(context.claims).toEqualTypeOf<JwtPayload>()
21
+ return Promise.resolve(input.id)
22
+ })
23
+
24
+ expectTypeOf(action).parameter(0).toEqualTypeOf<{ id: string }>()
25
+ expectTypeOf(action).returns.toEqualTypeOf<Promise<number>>()
26
+ })
27
+
28
+ test("a synchronous handler still yields an async action", () => {
29
+ const action = defineAction(z.number(), (_db, input) => input > 0)
30
+
31
+ expectTypeOf(action).returns.toEqualTypeOf<Promise<boolean>>()
32
+ })
@@ -0,0 +1,178 @@
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 { createDefineAction } from "./define-action.ts"
5
+
6
+ // No database: the RLS client factory is replaced by a stub that returns a
7
+ // marker object, so nothing here builds a pool or opens a connection. What
8
+ // that client does with the claims is covered by test/rls.integration.test.ts.
9
+ const { createSupabaseClientMock } = vi.hoisted(() => ({
10
+ createSupabaseClientMock: vi.fn((claims: unknown) => ({
11
+ claims,
12
+ fake: true,
13
+ })),
14
+ }))
15
+
16
+ vi.mock("./clients.ts", () => ({
17
+ createSupabaseClient: createSupabaseClientMock,
18
+ }))
19
+
20
+ import {
21
+ type ClaimsSource,
22
+ createRequestDb,
23
+ UnauthenticatedError,
24
+ } from "./next.ts"
25
+
26
+ const claims: JwtPayload = {
27
+ aal: "aal1",
28
+ aud: "authenticated",
29
+ exp: 0,
30
+ iat: 0,
31
+ iss: "test",
32
+ role: "authenticated",
33
+ roles: ["admin"],
34
+ session_id: "session",
35
+ sub: "11111111-1111-1111-1111-111111111111",
36
+ }
37
+
38
+ type GetClaimsResult = Awaited<ReturnType<ClaimsSource["auth"]["getClaims"]>>
39
+
40
+ function getClaimsResult(
41
+ payload: JwtPayload | null,
42
+ error: AuthError | null
43
+ ): GetClaimsResult {
44
+ if (payload) {
45
+ return {
46
+ data: {
47
+ claims: payload,
48
+ header: { alg: "HS256", kid: "test", typ: "JWT" },
49
+ signature: new Uint8Array(),
50
+ },
51
+ error: null,
52
+ }
53
+ }
54
+ return error ? { data: null, error } : { data: null, error: null }
55
+ }
56
+
57
+ function supabaseReturning(result: GetClaimsResult): () => ClaimsSource {
58
+ return () => ({ auth: { getClaims: () => Promise.resolve(result) } })
59
+ }
60
+
61
+ beforeEach(() => {
62
+ createSupabaseClientMock.mockClear()
63
+ })
64
+
65
+ describe("defineAction", () => {
66
+ const schema = z.object({ id: z.number().int().positive() }).strict()
67
+
68
+ it("parses the input, then calls the handler with db, parsed input and context", async () => {
69
+ const context = { claims: { sub: "user-1" }, db: { name: "db" } }
70
+ const getContext = vi.fn(() => Promise.resolve(context))
71
+ const handler = vi.fn((db: { name: string }, input: { id: number }) =>
72
+ Promise.resolve(`${db.name}:${input.id}`)
73
+ )
74
+
75
+ const action = createDefineAction(getContext)(schema, handler)
76
+
77
+ await expect(action({ id: 7 })).resolves.toBe("db:7")
78
+ expect(handler).toHaveBeenCalledWith(context.db, { id: 7 }, context)
79
+ })
80
+
81
+ it("hands the handler the claims for authorship", async () => {
82
+ const context = { claims: { sub: "user-1" }, db: {} }
83
+ const action = createDefineAction(() => Promise.resolve(context))(
84
+ schema,
85
+ (_db, input, { claims: { sub } }) => ({ createdBy: sub, ...input })
86
+ )
87
+
88
+ await expect(action({ id: 1 })).resolves.toEqual({
89
+ createdBy: "user-1",
90
+ id: 1,
91
+ })
92
+ })
93
+
94
+ it("rejects invalid input before resolving the context or calling the handler", async () => {
95
+ const getContext = vi.fn(() => Promise.resolve({ db: {} }))
96
+ const handler = vi.fn()
97
+ const action = createDefineAction(getContext)(schema, handler)
98
+
99
+ await expect(action({ id: -1 })).rejects.toBeInstanceOf(z.ZodError)
100
+ expect(getContext).not.toHaveBeenCalled()
101
+ expect(handler).not.toHaveBeenCalled()
102
+ })
103
+
104
+ it("propagates a context failure without calling the handler", async () => {
105
+ const handler = vi.fn()
106
+ const action = createDefineAction(() =>
107
+ Promise.reject(new UnauthenticatedError())
108
+ )(schema, handler)
109
+
110
+ await expect(action({ id: 1 })).rejects.toBeInstanceOf(UnauthenticatedError)
111
+ expect(handler).not.toHaveBeenCalled()
112
+ })
113
+ })
114
+
115
+ describe("createRequestDb", () => {
116
+ it("builds the RLS client from the whole verified claims object", async () => {
117
+ const { getRequestContext, getRequestDb } = createRequestDb({
118
+ connectionString: () => "postgresql://lazy",
119
+ supabase: supabaseReturning(getClaimsResult(claims, null)),
120
+ })
121
+
122
+ const context = await getRequestContext()
123
+
124
+ expect(context.claims).toBe(claims)
125
+ expect(createSupabaseClientMock).toHaveBeenCalledWith(claims, {
126
+ connectionString: "postgresql://lazy",
127
+ })
128
+ await expect(getRequestDb()).resolves.toEqual({ claims, fake: true })
129
+ })
130
+
131
+ it("throws UnauthenticatedError when there is no session", async () => {
132
+ const { getRequestDb } = createRequestDb({
133
+ supabase: supabaseReturning(getClaimsResult(null, null)),
134
+ })
135
+
136
+ const error = await getRequestDb().catch((caught: unknown) => caught)
137
+
138
+ expect(error).toBeInstanceOf(UnauthenticatedError)
139
+ expect(error).toHaveProperty("name", "UnauthenticatedError")
140
+ expect(createSupabaseClientMock).not.toHaveBeenCalled()
141
+ })
142
+
143
+ it("throws UnauthenticatedError for claims without a subject", async () => {
144
+ const { getRequestDb } = createRequestDb({
145
+ supabase: supabaseReturning(
146
+ getClaimsResult({ ...claims, sub: "" }, null)
147
+ ),
148
+ })
149
+
150
+ await expect(getRequestDb()).rejects.toBeInstanceOf(UnauthenticatedError)
151
+ })
152
+
153
+ it("rethrows a getClaims error as is", async () => {
154
+ const failure = new AuthError("jwks unreachable")
155
+ const { getRequestDb } = createRequestDb({
156
+ supabase: supabaseReturning(getClaimsResult(null, failure)),
157
+ })
158
+
159
+ await expect(getRequestDb()).rejects.toBe(failure)
160
+ })
161
+
162
+ it("binds defineAction to the request context", async () => {
163
+ const { defineAction } = createRequestDb({
164
+ supabase: supabaseReturning(getClaimsResult(claims, null)),
165
+ })
166
+ const action = defineAction(z.string(), (db, input, context) => ({
167
+ db,
168
+ input,
169
+ sub: context.claims.sub,
170
+ }))
171
+
172
+ await expect(action("hello")).resolves.toEqual({
173
+ db: { claims, fake: true },
174
+ input: "hello",
175
+ sub: claims.sub,
176
+ })
177
+ })
178
+ })
package/src/next.ts ADDED
@@ -0,0 +1,112 @@
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 { createDefineAction, type DefineAction } from "./define-action.ts"
10
+
11
+ export type {
12
+ ActionContext,
13
+ ActionHandler,
14
+ ActionSchema,
15
+ DefineAction,
16
+ } from "./define-action.ts"
17
+
18
+ /**
19
+ * Thrown when a request carries no verified session. A named class so a caller
20
+ * with a legitimate answer for that case (a permission check, where "not signed
21
+ * in" means "no") can tell it apart from a real failure with `instanceof`.
22
+ */
23
+ export class UnauthenticatedError extends Error {
24
+ constructor(message = "Unauthorized: no verified session on this request") {
25
+ super(message)
26
+ this.name = "UnauthenticatedError"
27
+ }
28
+ }
29
+
30
+ /** The only part of a Supabase client the request context reads. */
31
+ export type ClaimsSource = { auth: Pick<SupabaseClient["auth"], "getClaims"> }
32
+
33
+ /** A verified request: the full `getClaims()` payload and the RLS client. */
34
+ export type RequestContext<TRelations extends AnyRelations = EmptyRelations> = {
35
+ readonly claims: JwtPayload
36
+ readonly db: DrizzleClient<TRelations>
37
+ }
38
+
39
+ export type CreateRequestDbOptions<
40
+ TRelations extends AnyRelations = EmptyRelations,
41
+ > = Omit<CreateClientConfig<TRelations>, "connectionString"> & {
42
+ /**
43
+ * Builds the request's Supabase client, e.g. `createClient` from
44
+ * `@zeno-lib/supabase/next-server` (cookie session via `next/headers`).
45
+ */
46
+ supabase: () => ClaimsSource | Promise<ClaimsSource>
47
+ /**
48
+ * Overrides `SUPABASE_DATABASE_URL`. A function is resolved per request, not
49
+ * at import, so a build without server secrets can still import the module.
50
+ */
51
+ connectionString?: string | (() => string)
52
+ }
53
+
54
+ export type RequestDb<TRelations extends AnyRelations = EmptyRelations> = {
55
+ /** Memoised per render: `{ claims, db }` for the verified caller. */
56
+ getRequestContext: () => Promise<RequestContext<TRelations>>
57
+ /** The common case: just the RLS-bound client. */
58
+ getRequestDb: () => Promise<DrizzleClient<TRelations>>
59
+ /** Wraps a handler into a `"use server"` export; see `DefineAction`. */
60
+ defineAction: DefineAction<RequestContext<TRelations>>
61
+ }
62
+
63
+ /**
64
+ * Request-scoped, RLS-bound Drizzle access for Next.js. Verifies the session
65
+ * with `getClaims()` (which checks the signature) and installs the **whole**
66
+ * claims object, so custom claims reach `auth.jwt()` in policies. There is no
67
+ * fallback to `anon`: a caller without a verified `sub` gets
68
+ * `UnauthenticatedError` rather than the empty result of a policy it never
69
+ * satisfied.
70
+ *
71
+ * `getRequestContext` is wrapped in React's `cache`, so one server render shares
72
+ * one verification and one handle. A server action invoked from the browser
73
+ * runs outside a render and resolves afresh on every call.
74
+ *
75
+ * Do not `close()` the handle: pools are shared and reference-counted.
76
+ */
77
+ export function createRequestDb<
78
+ TRelations extends AnyRelations = EmptyRelations,
79
+ >(options: CreateRequestDbOptions<TRelations>): RequestDb<TRelations> {
80
+ const { connectionString, supabase, ...config } = options
81
+
82
+ const getRequestContext = cache(
83
+ async (): Promise<RequestContext<TRelations>> => {
84
+ const client = await supabase()
85
+ const { data, error } = await client.auth.getClaims()
86
+
87
+ if (error) {
88
+ throw error
89
+ }
90
+ if (!data?.claims.sub) {
91
+ throw new UnauthenticatedError()
92
+ }
93
+
94
+ const url =
95
+ typeof connectionString === "function"
96
+ ? connectionString()
97
+ : connectionString
98
+ const db = createSupabaseClient<TRelations>(
99
+ data.claims,
100
+ url === undefined ? config : { ...config, connectionString: url }
101
+ )
102
+
103
+ return { claims: data.claims, db }
104
+ }
105
+ )
106
+
107
+ return {
108
+ defineAction: createDefineAction(getRequestContext),
109
+ getRequestContext,
110
+ getRequestDb: async () => (await getRequestContext()).db,
111
+ }
112
+ }
@@ -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
+ })