@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/dist/auth-schema.d.mts +543 -0
- package/dist/auth-schema.mjs +50 -0
- package/dist/clients-BlUEH7QJ.d.mts +58 -0
- package/dist/clients-CdPr3mCZ.mjs +197 -0
- package/dist/config.d.mts +20 -0
- package/dist/config.mjs +64 -0
- package/dist/errors.d.mts +70 -0
- package/dist/errors.mjs +78 -0
- package/dist/function-names-BDc-AuFm.mjs +10 -0
- package/dist/function-names-hv2mj_x1.d.mts +5 -0
- package/dist/index.d.mts +2 -0
- package/dist/index.mjs +2 -0
- package/dist/next.d.mts +87 -0
- package/dist/next.mjs +69 -0
- package/dist/query.d.mts +103 -0
- package/dist/query.mjs +115 -0
- package/dist/schema.d.mts +158 -0
- package/dist/schema.mjs +219 -0
- package/dist/triggers.d.mts +65 -0
- package/dist/triggers.mjs +93 -0
- package/package.json +29 -5
- package/src/casing.test.ts +43 -20
- package/src/clients.ts +60 -5
- package/src/database-url.test.ts +88 -0
- package/src/define-action.ts +63 -0
- package/src/errors.test.ts +95 -0
- package/src/errors.ts +133 -0
- package/src/function-names.ts +21 -0
- package/src/index.ts +2 -0
- package/src/next.test-d.ts +32 -0
- package/src/next.test.ts +178 -0
- package/src/next.ts +112 -0
- package/src/query.test-d.ts +20 -0
- package/src/query.test.ts +261 -0
- package/src/query.ts +252 -0
- package/src/schema.test-d.ts +24 -0
- package/src/schema.ts +51 -21
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import { sql } from "drizzle-orm";
|
|
2
|
+
import { drizzle } from "drizzle-orm/postgres-js";
|
|
3
|
+
import postgres from "postgres";
|
|
4
|
+
//#region src/rls-query-client.ts
|
|
5
|
+
function replayAsUserChain(tx, path) {
|
|
6
|
+
let receiver = tx;
|
|
7
|
+
let current = tx;
|
|
8
|
+
for (const step of path) if (step.kind === "get") {
|
|
9
|
+
receiver = current;
|
|
10
|
+
current = current[step.prop];
|
|
11
|
+
} else current = current.apply(receiver, step.args);
|
|
12
|
+
return current;
|
|
13
|
+
}
|
|
14
|
+
const PROMISE_METHODS = /* @__PURE__ */ new Set([
|
|
15
|
+
"then",
|
|
16
|
+
"catch",
|
|
17
|
+
"finally"
|
|
18
|
+
]);
|
|
19
|
+
function replayPromiseMethod(prop, path, runTransaction) {
|
|
20
|
+
return (...promiseArgs) => {
|
|
21
|
+
const promise = runTransaction((tx) => replayAsUserChain(tx, path));
|
|
22
|
+
return promise[prop].apply(promise, promiseArgs);
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
function createRlsQueryClient(runTransaction, close) {
|
|
26
|
+
const build = (path, isRoot) => {
|
|
27
|
+
const target = () => void 0;
|
|
28
|
+
return new Proxy(target, {
|
|
29
|
+
apply(_target, _thisArg, args) {
|
|
30
|
+
if (isRoot) throw new Error("The createSupabaseDrizzle() client is queried directly (e.g. db.select().from(table)). Use db.transaction(cb) to run multiple statements in one RLS transaction.");
|
|
31
|
+
return build([...path, {
|
|
32
|
+
args,
|
|
33
|
+
kind: "apply"
|
|
34
|
+
}], false);
|
|
35
|
+
},
|
|
36
|
+
get(_target, prop) {
|
|
37
|
+
if (isRoot && prop === "transaction") return runTransaction;
|
|
38
|
+
if (isRoot && prop === "close") return close;
|
|
39
|
+
if (PROMISE_METHODS.has(prop)) return isRoot ? void 0 : replayPromiseMethod(prop, path, runTransaction);
|
|
40
|
+
if (typeof prop === "symbol") return;
|
|
41
|
+
return build([...path, {
|
|
42
|
+
kind: "get",
|
|
43
|
+
prop
|
|
44
|
+
}], false);
|
|
45
|
+
}
|
|
46
|
+
});
|
|
47
|
+
};
|
|
48
|
+
return build([], true);
|
|
49
|
+
}
|
|
50
|
+
//#endregion
|
|
51
|
+
//#region src/clients.ts
|
|
52
|
+
const ALLOWED_RLS_ROLES = /* @__PURE__ */ new Set(["anon", "authenticated"]);
|
|
53
|
+
const POOLER_PORT = "6543";
|
|
54
|
+
const LOCAL_HOSTS = /* @__PURE__ */ new Set([
|
|
55
|
+
"127.0.0.1",
|
|
56
|
+
"localhost",
|
|
57
|
+
"[::1]",
|
|
58
|
+
"0.0.0.0"
|
|
59
|
+
]);
|
|
60
|
+
/**
|
|
61
|
+
* The connection string every factory uses: `connectionString`, else
|
|
62
|
+
* `SUPABASE_DATABASE_URL`. Throws when neither is set, and with
|
|
63
|
+
* `requirePooler` also when the URL is not the transaction pooler. Read per
|
|
64
|
+
* call, never at import, so a build without server secrets does not fail.
|
|
65
|
+
* Errors never echo the URL, which carries the password.
|
|
66
|
+
*/
|
|
67
|
+
function resolveDatabaseUrl({ connectionString, requirePooler = false } = {}) {
|
|
68
|
+
const url = connectionString ?? process.env.SUPABASE_DATABASE_URL ?? "";
|
|
69
|
+
if (!url) throw new Error("Missing SUPABASE_DATABASE_URL environment variable");
|
|
70
|
+
if (!requirePooler) return url;
|
|
71
|
+
let parsed;
|
|
72
|
+
try {
|
|
73
|
+
parsed = new URL(url);
|
|
74
|
+
} catch {
|
|
75
|
+
throw new Error("SUPABASE_DATABASE_URL is not a valid URL");
|
|
76
|
+
}
|
|
77
|
+
if (parsed.protocol !== "postgresql:" && parsed.protocol !== "postgres:") throw new Error(`SUPABASE_DATABASE_URL must be a postgresql:// URL, got ${parsed.protocol}//`);
|
|
78
|
+
if (LOCAL_HOSTS.has(parsed.hostname)) throw new Error("SUPABASE_DATABASE_URL points at a local database, but the transaction pooler is required");
|
|
79
|
+
if (parsed.port !== POOLER_PORT) throw new Error(`SUPABASE_DATABASE_URL must use the transaction pooler (port ${POOLER_PORT}), got port ${parsed.port || "(default)"}. Session mode holds one connection per instance for its whole lifetime.`);
|
|
80
|
+
return url;
|
|
81
|
+
}
|
|
82
|
+
function clampClaims(token) {
|
|
83
|
+
const role = token.role && ALLOWED_RLS_ROLES.has(token.role) ? token.role : "anon";
|
|
84
|
+
return {
|
|
85
|
+
claims: JSON.stringify({
|
|
86
|
+
...token,
|
|
87
|
+
role
|
|
88
|
+
}),
|
|
89
|
+
role,
|
|
90
|
+
sub: token.sub ?? ""
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
function fixedContext(role) {
|
|
94
|
+
return {
|
|
95
|
+
claims: JSON.stringify({ role }),
|
|
96
|
+
role,
|
|
97
|
+
sub: ""
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
const poolCache = /* @__PURE__ */ new Map();
|
|
101
|
+
function acquirePool(connectionString, kind) {
|
|
102
|
+
const key = `${kind} ${connectionString}`;
|
|
103
|
+
let entry = poolCache.get(key);
|
|
104
|
+
if (!entry) {
|
|
105
|
+
entry = {
|
|
106
|
+
client: postgres(connectionString, { prepare: false }),
|
|
107
|
+
ended: false,
|
|
108
|
+
key,
|
|
109
|
+
refCount: 0
|
|
110
|
+
};
|
|
111
|
+
poolCache.set(key, entry);
|
|
112
|
+
}
|
|
113
|
+
entry.refCount += 1;
|
|
114
|
+
return entry;
|
|
115
|
+
}
|
|
116
|
+
function makeClose(entry) {
|
|
117
|
+
let released = false;
|
|
118
|
+
return async (options) => {
|
|
119
|
+
if (released) return;
|
|
120
|
+
released = true;
|
|
121
|
+
entry.refCount -= 1;
|
|
122
|
+
if (entry.refCount <= 0 && !entry.ended) {
|
|
123
|
+
entry.ended = true;
|
|
124
|
+
if (poolCache.get(entry.key) === entry) poolCache.delete(entry.key);
|
|
125
|
+
await entry.client.end(options);
|
|
126
|
+
}
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
function buildDrizzle(kind, config) {
|
|
130
|
+
const { connectionString, requirePooler, ...drizzleConfig } = config ?? {};
|
|
131
|
+
const entry = acquirePool(resolveDatabaseUrl({
|
|
132
|
+
connectionString,
|
|
133
|
+
requirePooler
|
|
134
|
+
}), kind);
|
|
135
|
+
const db = drizzle({
|
|
136
|
+
client: entry.client,
|
|
137
|
+
...drizzleConfig
|
|
138
|
+
});
|
|
139
|
+
return {
|
|
140
|
+
close: makeClose(entry),
|
|
141
|
+
db
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
function buildRlsClient(resolveContext, config) {
|
|
145
|
+
const { close, db } = buildDrizzle("rls", config);
|
|
146
|
+
const runTransaction = async (transaction) => {
|
|
147
|
+
const { claims, role, sub } = await resolveContext();
|
|
148
|
+
return db.transaction(async (tx) => {
|
|
149
|
+
await tx.execute(sql`select set_config('request.jwt.claims', ${claims}, true), set_config('request.jwt.claim.sub', ${sub}, true)`);
|
|
150
|
+
await tx.execute(sql`set local role ${sql.raw(role)}`);
|
|
151
|
+
return transaction(tx);
|
|
152
|
+
});
|
|
153
|
+
};
|
|
154
|
+
return createRlsQueryClient(runTransaction, close);
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* RLS-bypassing client (the docs' `admin`). Connects with
|
|
158
|
+
* `SUPABASE_DATABASE_URL` and queries drizzle directly. Use for webhooks,
|
|
159
|
+
* background jobs, and seeding, never for user-scoped reads/writes.
|
|
160
|
+
*/
|
|
161
|
+
function createAdminClient(config) {
|
|
162
|
+
const { close, db } = buildDrizzle("admin", config);
|
|
163
|
+
return Object.assign(db, { close });
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* RLS client scoped to an already-verified, decoded token (e.g. from
|
|
167
|
+
* `supabase.auth.getClaims()`). The role is clamped to the allowlist.
|
|
168
|
+
*/
|
|
169
|
+
function createSupabaseClient(accessToken, config) {
|
|
170
|
+
const context = clampClaims(accessToken);
|
|
171
|
+
return buildRlsClient(() => Promise.resolve(context), config);
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
174
|
+
* RLS client bound to a Supabase client. Verified claims are resolved via
|
|
175
|
+
* `supabase.auth.getClaims()` on every query, so it always reflects the live
|
|
176
|
+
* session.
|
|
177
|
+
*/
|
|
178
|
+
function createAuthClient(supabase, config) {
|
|
179
|
+
return buildRlsClient(async () => {
|
|
180
|
+
const { data, error } = await supabase.auth.getClaims();
|
|
181
|
+
if (error) throw error;
|
|
182
|
+
return clampClaims(data?.claims ?? {});
|
|
183
|
+
}, config);
|
|
184
|
+
}
|
|
185
|
+
/** RLS client that runs every query as the `anon` role. */
|
|
186
|
+
function createAnonClient(config) {
|
|
187
|
+
return buildRlsClient(() => Promise.resolve(fixedContext("anon")), config);
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* Client that runs every query as `service_role`, which bypasses RLS via
|
|
191
|
+
* Supabase's BYPASSRLS grant. Use for trusted server-side work.
|
|
192
|
+
*/
|
|
193
|
+
function createServiceClient(config) {
|
|
194
|
+
return buildRlsClient(() => Promise.resolve(fixedContext("service_role")), config);
|
|
195
|
+
}
|
|
196
|
+
//#endregion
|
|
197
|
+
export { createSupabaseClient as a, createServiceClient as i, createAnonClient as n, resolveDatabaseUrl as o, createAuthClient as r, createAdminClient as t };
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { Config, defineConfig } from "drizzle-kit";
|
|
2
|
+
//#region src/config.d.ts
|
|
3
|
+
/**
|
|
4
|
+
* Roles a Supabase project ships that drizzle-kit's `provider: "supabase"` does
|
|
5
|
+
* not already exclude. That provider covers eight names (`anon`,
|
|
6
|
+
* `authenticator`, `authenticated`, `service_role`, `supabase_auth_admin`,
|
|
7
|
+
* `supabase_storage_admin`, `dashboard_user`, `supabase_admin`); a real
|
|
8
|
+
* instance has these too, and drizzle-kit manages every role it finds but
|
|
9
|
+
* cannot see declared, which means a `DROP ROLE` for each.
|
|
10
|
+
*
|
|
11
|
+
* The alternative is every consumer keeping a generated `roles.ts` nobody
|
|
12
|
+
* reads, or its own copy of this list, which rots as Supabase adds roles. That
|
|
13
|
+
* is the argument for it living in a versioned dependency.
|
|
14
|
+
*
|
|
15
|
+
* Verified against Supabase CLI 2.84.1 / Postgres 17.
|
|
16
|
+
*/
|
|
17
|
+
declare const supabaseManagedRoles: readonly ["pg_checkpoint", "pg_database_owner", "pg_execute_server_program", "pg_monitor", "pg_read_all_data", "pg_read_all_settings", "pg_read_all_stats", "pg_read_server_files", "pg_signal_backend", "pg_stat_scan_tables", "pg_write_all_data", "pg_write_server_files", "pgbouncer", "postgres", "supabase_functions_admin", "supabase_read_only_user", "supabase_realtime_admin", "supabase_replication_admin"];
|
|
18
|
+
declare function defineDrizzleConfig(overrides?: Partial<Config>): ReturnType<typeof defineConfig>;
|
|
19
|
+
//#endregion
|
|
20
|
+
export { defineDrizzleConfig, supabaseManagedRoles };
|
package/dist/config.mjs
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { defineConfig } from "drizzle-kit";
|
|
2
|
+
//#region src/config.ts
|
|
3
|
+
/**
|
|
4
|
+
* Roles a Supabase project ships that drizzle-kit's `provider: "supabase"` does
|
|
5
|
+
* not already exclude. That provider covers eight names (`anon`,
|
|
6
|
+
* `authenticator`, `authenticated`, `service_role`, `supabase_auth_admin`,
|
|
7
|
+
* `supabase_storage_admin`, `dashboard_user`, `supabase_admin`); a real
|
|
8
|
+
* instance has these too, and drizzle-kit manages every role it finds but
|
|
9
|
+
* cannot see declared, which means a `DROP ROLE` for each.
|
|
10
|
+
*
|
|
11
|
+
* The alternative is every consumer keeping a generated `roles.ts` nobody
|
|
12
|
+
* reads, or its own copy of this list, which rots as Supabase adds roles. That
|
|
13
|
+
* is the argument for it living in a versioned dependency.
|
|
14
|
+
*
|
|
15
|
+
* Verified against Supabase CLI 2.84.1 / Postgres 17.
|
|
16
|
+
*/
|
|
17
|
+
const supabaseManagedRoles = [
|
|
18
|
+
"pg_checkpoint",
|
|
19
|
+
"pg_database_owner",
|
|
20
|
+
"pg_execute_server_program",
|
|
21
|
+
"pg_monitor",
|
|
22
|
+
"pg_read_all_data",
|
|
23
|
+
"pg_read_all_settings",
|
|
24
|
+
"pg_read_all_stats",
|
|
25
|
+
"pg_read_server_files",
|
|
26
|
+
"pg_signal_backend",
|
|
27
|
+
"pg_stat_scan_tables",
|
|
28
|
+
"pg_write_all_data",
|
|
29
|
+
"pg_write_server_files",
|
|
30
|
+
"pgbouncer",
|
|
31
|
+
"postgres",
|
|
32
|
+
"supabase_functions_admin",
|
|
33
|
+
"supabase_read_only_user",
|
|
34
|
+
"supabase_realtime_admin",
|
|
35
|
+
"supabase_replication_admin"
|
|
36
|
+
];
|
|
37
|
+
/**
|
|
38
|
+
* Schemas drizzle-kit is allowed to diff. Supabase owns `auth`, `storage`,
|
|
39
|
+
* `realtime` and the rest, and changes them on upgrades, so migrating them is
|
|
40
|
+
* never right. Add your own schemas here rather than dropping the filter.
|
|
41
|
+
*/
|
|
42
|
+
const DEFAULT_SCHEMA_FILTER = ["public"];
|
|
43
|
+
function defineDrizzleConfig(overrides = {}) {
|
|
44
|
+
const { entities, ...configOverrides } = overrides;
|
|
45
|
+
const roleOverrides = typeof entities?.roles === "object" ? entities.roles : {};
|
|
46
|
+
return defineConfig({
|
|
47
|
+
dbCredentials: { url: process.env.SUPABASE_DATABASE_URL ?? "" },
|
|
48
|
+
dialect: "postgresql",
|
|
49
|
+
entities: {
|
|
50
|
+
...entities,
|
|
51
|
+
roles: {
|
|
52
|
+
...roleOverrides,
|
|
53
|
+
exclude: [...roleOverrides.exclude ?? [], ...supabaseManagedRoles],
|
|
54
|
+
provider: "supabase"
|
|
55
|
+
}
|
|
56
|
+
},
|
|
57
|
+
out: "./supabase/migrations",
|
|
58
|
+
schema: "./src/schema.ts",
|
|
59
|
+
schemaFilter: DEFAULT_SCHEMA_FILTER,
|
|
60
|
+
...configOverrides
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
//#endregion
|
|
64
|
+
export { defineDrizzleConfig, supabaseManagedRoles };
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { PostgresError } from "postgres";
|
|
2
|
+
//#region src/errors.d.ts
|
|
3
|
+
/**
|
|
4
|
+
* SQLSTATE codes worth naming. The full list is Postgres's "Error Codes"
|
|
5
|
+
* appendix; these are the ones an application branches on.
|
|
6
|
+
*/
|
|
7
|
+
declare const SqlState: {
|
|
8
|
+
/** `check_violation`. */
|
|
9
|
+
readonly checkViolation: "23514";
|
|
10
|
+
/** `exclusion_violation`. */
|
|
11
|
+
readonly exclusionViolation: "23P01";
|
|
12
|
+
/** `foreign_key_violation`. */
|
|
13
|
+
readonly foreignKeyViolation: "23503";
|
|
14
|
+
/** `insufficient_privilege`: an RLS policy or a missing grant refused it. */
|
|
15
|
+
readonly insufficientPrivilege: "42501";
|
|
16
|
+
/**
|
|
17
|
+
* `invalid_column_reference`, which is what an `ON CONFLICT` target that no
|
|
18
|
+
* unique index can arbitrate raises. It is raised while the statement is
|
|
19
|
+
* planned, so it fires on the first insert, not on the first conflict.
|
|
20
|
+
*/
|
|
21
|
+
readonly invalidColumnReference: "42P10";
|
|
22
|
+
/** `not_null_violation`. */
|
|
23
|
+
readonly notNullViolation: "23502";
|
|
24
|
+
/** `undefined_function`: a function missing, or called with the wrong signature. */
|
|
25
|
+
readonly undefinedFunction: "42883";
|
|
26
|
+
/** `unique_violation`. */
|
|
27
|
+
readonly uniqueViolation: "23505";
|
|
28
|
+
};
|
|
29
|
+
type SqlStateCode = (typeof SqlState)[keyof typeof SqlState];
|
|
30
|
+
/**
|
|
31
|
+
* The postgres.js error underneath whatever Drizzle threw, or `undefined`.
|
|
32
|
+
*
|
|
33
|
+
* Drizzle wraps **every** driver error in a `DrizzleQueryError` whose own
|
|
34
|
+
* `code` is `undefined`, so `error.code === "23505"` is always false:
|
|
35
|
+
*
|
|
36
|
+
* ```
|
|
37
|
+
* depth 0: DrizzleQueryError code=undefined "Failed query: insert into …"
|
|
38
|
+
* depth 1: PostgresError code=23505 "duplicate key value violates …"
|
|
39
|
+
* ```
|
|
40
|
+
*
|
|
41
|
+
* Matched by `name` rather than `instanceof`, so a second copy of `postgres`
|
|
42
|
+
* in the tree cannot make it miss.
|
|
43
|
+
*/
|
|
44
|
+
declare const toPostgresError: (error: unknown) => PostgresError | undefined;
|
|
45
|
+
type ConstraintViolationOptions = {
|
|
46
|
+
/**
|
|
47
|
+
* The SQLSTATE to require, e.g. `SqlState.uniqueViolation`. Defaults to any
|
|
48
|
+
* code in class 23 (`integrity_constraint_violation`).
|
|
49
|
+
*/
|
|
50
|
+
code?: string;
|
|
51
|
+
};
|
|
52
|
+
/**
|
|
53
|
+
* Whether `error` is an integrity-constraint violation, optionally one of the
|
|
54
|
+
* named constraints. Returns the `PostgresError` so a caller can read its
|
|
55
|
+
* `constraint_name` or `detail`, or `undefined` when it does not match.
|
|
56
|
+
*
|
|
57
|
+
* ```ts
|
|
58
|
+
* try {
|
|
59
|
+
* await db.insert(contacts).values(row)
|
|
60
|
+
* } catch (error) {
|
|
61
|
+
* if (isConstraintViolation(error, ["contacts_email_key"], { code: SqlState.uniqueViolation })) {
|
|
62
|
+
* return { error: "That email is already a contact" }
|
|
63
|
+
* }
|
|
64
|
+
* throw error
|
|
65
|
+
* }
|
|
66
|
+
* ```
|
|
67
|
+
*/
|
|
68
|
+
declare const isConstraintViolation: (error: unknown, constraints?: Iterable<string>, { code }?: ConstraintViolationOptions) => PostgresError | undefined;
|
|
69
|
+
//#endregion
|
|
70
|
+
export { ConstraintViolationOptions, SqlState, SqlStateCode, isConstraintViolation, toPostgresError };
|
package/dist/errors.mjs
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
//#region src/errors.ts
|
|
2
|
+
/**
|
|
3
|
+
* SQLSTATE codes worth naming. The full list is Postgres's "Error Codes"
|
|
4
|
+
* appendix; these are the ones an application branches on.
|
|
5
|
+
*/
|
|
6
|
+
const SqlState = {
|
|
7
|
+
/** `check_violation`. */
|
|
8
|
+
checkViolation: "23514",
|
|
9
|
+
/** `exclusion_violation`. */
|
|
10
|
+
exclusionViolation: "23P01",
|
|
11
|
+
/** `foreign_key_violation`. */
|
|
12
|
+
foreignKeyViolation: "23503",
|
|
13
|
+
/** `insufficient_privilege`: an RLS policy or a missing grant refused it. */
|
|
14
|
+
insufficientPrivilege: "42501",
|
|
15
|
+
/**
|
|
16
|
+
* `invalid_column_reference`, which is what an `ON CONFLICT` target that no
|
|
17
|
+
* unique index can arbitrate raises. It is raised while the statement is
|
|
18
|
+
* planned, so it fires on the first insert, not on the first conflict.
|
|
19
|
+
*/
|
|
20
|
+
invalidColumnReference: "42P10",
|
|
21
|
+
/** `not_null_violation`. */
|
|
22
|
+
notNullViolation: "23502",
|
|
23
|
+
/** `undefined_function`: a function missing, or called with the wrong signature. */
|
|
24
|
+
undefinedFunction: "42883",
|
|
25
|
+
/** `unique_violation`. */
|
|
26
|
+
uniqueViolation: "23505"
|
|
27
|
+
};
|
|
28
|
+
/** SQLSTATE class 23, every `integrity_constraint_violation`. */
|
|
29
|
+
const INTEGRITY_CONSTRAINT_CLASS = "23";
|
|
30
|
+
const MAX_CAUSE_DEPTH = 5;
|
|
31
|
+
/**
|
|
32
|
+
* The postgres.js error underneath whatever Drizzle threw, or `undefined`.
|
|
33
|
+
*
|
|
34
|
+
* Drizzle wraps **every** driver error in a `DrizzleQueryError` whose own
|
|
35
|
+
* `code` is `undefined`, so `error.code === "23505"` is always false:
|
|
36
|
+
*
|
|
37
|
+
* ```
|
|
38
|
+
* depth 0: DrizzleQueryError code=undefined "Failed query: insert into …"
|
|
39
|
+
* depth 1: PostgresError code=23505 "duplicate key value violates …"
|
|
40
|
+
* ```
|
|
41
|
+
*
|
|
42
|
+
* Matched by `name` rather than `instanceof`, so a second copy of `postgres`
|
|
43
|
+
* in the tree cannot make it miss.
|
|
44
|
+
*/
|
|
45
|
+
const toPostgresError = (error) => {
|
|
46
|
+
let current = error;
|
|
47
|
+
for (let depth = 0; current instanceof Error && depth < MAX_CAUSE_DEPTH; depth += 1) {
|
|
48
|
+
if (current.name === "PostgresError") return current;
|
|
49
|
+
current = current.cause;
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
/**
|
|
53
|
+
* Whether `error` is an integrity-constraint violation, optionally one of the
|
|
54
|
+
* named constraints. Returns the `PostgresError` so a caller can read its
|
|
55
|
+
* `constraint_name` or `detail`, or `undefined` when it does not match.
|
|
56
|
+
*
|
|
57
|
+
* ```ts
|
|
58
|
+
* try {
|
|
59
|
+
* await db.insert(contacts).values(row)
|
|
60
|
+
* } catch (error) {
|
|
61
|
+
* if (isConstraintViolation(error, ["contacts_email_key"], { code: SqlState.uniqueViolation })) {
|
|
62
|
+
* return { error: "That email is already a contact" }
|
|
63
|
+
* }
|
|
64
|
+
* throw error
|
|
65
|
+
* }
|
|
66
|
+
* ```
|
|
67
|
+
*/
|
|
68
|
+
const isConstraintViolation = (error, constraints, { code } = {}) => {
|
|
69
|
+
const postgresError = toPostgresError(error);
|
|
70
|
+
if (!postgresError) return;
|
|
71
|
+
if (!(code ? postgresError.code === code : postgresError.code.startsWith(INTEGRITY_CONSTRAINT_CLASS))) return;
|
|
72
|
+
if (constraints === void 0) return postgresError;
|
|
73
|
+
const name = postgresError.constraint_name;
|
|
74
|
+
if (name === void 0) return;
|
|
75
|
+
for (const constraint of constraints) if (constraint === name) return postgresError;
|
|
76
|
+
};
|
|
77
|
+
//#endregion
|
|
78
|
+
export { SqlState, isConstraintViolation, toPostgresError };
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
//#region src/function-names.ts
|
|
2
|
+
const FUNCTION_POLICY_OPERATIONS = [
|
|
3
|
+
"select",
|
|
4
|
+
"insert",
|
|
5
|
+
"update",
|
|
6
|
+
"delete"
|
|
7
|
+
];
|
|
8
|
+
const functionPolicyName = (prefix, operation, tableName) => `${prefix}_${operation}_${tableName}`;
|
|
9
|
+
//#endregion
|
|
10
|
+
export { functionPolicyName as n, FUNCTION_POLICY_OPERATIONS as t };
|
package/dist/index.d.mts
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
import { a as createAdminClient, c as createServiceClient, i as SupabaseToken, l as createSupabaseClient, n as DrizzleClient, o as createAnonClient, r as ResolveDatabaseUrlOptions, s as createAuthClient, t as CreateClientConfig, u as resolveDatabaseUrl } from "./clients-BlUEH7QJ.mjs";
|
|
2
|
+
export { type CreateClientConfig, type DrizzleClient, type ResolveDatabaseUrlOptions, type SupabaseToken, createAdminClient, createAnonClient, createAuthClient, createServiceClient, createSupabaseClient, resolveDatabaseUrl };
|
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
import { a as createSupabaseClient, i as createServiceClient, n as createAnonClient, o as resolveDatabaseUrl, r as createAuthClient, t as createAdminClient } from "./clients-CdPr3mCZ.mjs";
|
|
2
|
+
export { createAdminClient, createAnonClient, createAuthClient, createServiceClient, createSupabaseClient, resolveDatabaseUrl };
|
package/dist/next.d.mts
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { n as DrizzleClient, t as CreateClientConfig } from "./clients-BlUEH7QJ.mjs";
|
|
2
|
+
import { AnyRelations, EmptyRelations } from "drizzle-orm";
|
|
3
|
+
import { JwtPayload, SupabaseClient } from "@supabase/supabase-js";
|
|
4
|
+
//#region src/define-action.d.ts
|
|
5
|
+
/**
|
|
6
|
+
* The slice of a schema `defineAction` needs: a throwing `parse`, plus the
|
|
7
|
+
* Standard Schema `types` marker it reads the caller-facing input type from.
|
|
8
|
+
* Zod 4 schemas satisfy it as they are; so does anything else that implements
|
|
9
|
+
* Standard Schema and exposes `parse`.
|
|
10
|
+
*/
|
|
11
|
+
interface ActionSchema<TInput, TOutput> {
|
|
12
|
+
readonly "~standard": {
|
|
13
|
+
readonly types?: {
|
|
14
|
+
readonly input: TInput;
|
|
15
|
+
readonly output: TOutput;
|
|
16
|
+
} | undefined;
|
|
17
|
+
};
|
|
18
|
+
parse(input: unknown): TOutput;
|
|
19
|
+
}
|
|
20
|
+
/** What a request context must carry for `defineAction` to hand it over. */
|
|
21
|
+
interface ActionContext<TDb> {
|
|
22
|
+
readonly db: TDb;
|
|
23
|
+
}
|
|
24
|
+
/** An action's body: the RLS-bound `db`, the parsed input, and the context. */
|
|
25
|
+
type ActionHandler<TContext extends ActionContext<unknown>, TOutput, TResult> = (db: TContext["db"], input: TOutput, context: TContext) => TResult | Promise<TResult>;
|
|
26
|
+
/**
|
|
27
|
+
* `defineAction(schema, handler)` returns the server action itself: an async
|
|
28
|
+
* function that parses its argument, resolves the request context, then calls
|
|
29
|
+
* `handler(db, input, context)`.
|
|
30
|
+
*/
|
|
31
|
+
type DefineAction<TContext extends ActionContext<unknown>> = <TInput, TOutput, TResult>(schema: ActionSchema<TInput, TOutput>, handler: ActionHandler<TContext, TOutput, TResult>) => (input: TInput) => Promise<TResult>;
|
|
32
|
+
//#endregion
|
|
33
|
+
//#region src/next.d.ts
|
|
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
|
+
declare class UnauthenticatedError extends Error {
|
|
40
|
+
constructor(message?: string);
|
|
41
|
+
}
|
|
42
|
+
/** The only part of a Supabase client the request context reads. */
|
|
43
|
+
type ClaimsSource = {
|
|
44
|
+
auth: Pick<SupabaseClient["auth"], "getClaims">;
|
|
45
|
+
};
|
|
46
|
+
/** A verified request: the full `getClaims()` payload and the RLS client. */
|
|
47
|
+
type RequestContext<TRelations extends AnyRelations = EmptyRelations> = {
|
|
48
|
+
readonly claims: JwtPayload;
|
|
49
|
+
readonly db: DrizzleClient<TRelations>;
|
|
50
|
+
};
|
|
51
|
+
type CreateRequestDbOptions<TRelations extends AnyRelations = EmptyRelations> = Omit<CreateClientConfig<TRelations>, "connectionString"> & {
|
|
52
|
+
/**
|
|
53
|
+
* Builds the request's Supabase client, e.g. `createClient` from
|
|
54
|
+
* `@zeno-lib/supabase/next-server` (cookie session via `next/headers`).
|
|
55
|
+
*/
|
|
56
|
+
supabase: () => ClaimsSource | Promise<ClaimsSource>;
|
|
57
|
+
/**
|
|
58
|
+
* Overrides `SUPABASE_DATABASE_URL`. A function is resolved per request, not
|
|
59
|
+
* at import, so a build without server secrets can still import the module.
|
|
60
|
+
*/
|
|
61
|
+
connectionString?: string | (() => string);
|
|
62
|
+
};
|
|
63
|
+
type RequestDb<TRelations extends AnyRelations = EmptyRelations> = {
|
|
64
|
+
/** Memoised per render: `{ claims, db }` for the verified caller. */
|
|
65
|
+
getRequestContext: () => Promise<RequestContext<TRelations>>;
|
|
66
|
+
/** The common case: just the RLS-bound client. */
|
|
67
|
+
getRequestDb: () => Promise<DrizzleClient<TRelations>>;
|
|
68
|
+
/** Wraps a handler into a `"use server"` export; see `DefineAction`. */
|
|
69
|
+
defineAction: DefineAction<RequestContext<TRelations>>;
|
|
70
|
+
};
|
|
71
|
+
/**
|
|
72
|
+
* Request-scoped, RLS-bound Drizzle access for Next.js. Verifies the session
|
|
73
|
+
* with `getClaims()` (which checks the signature) and installs the **whole**
|
|
74
|
+
* claims object, so custom claims reach `auth.jwt()` in policies. There is no
|
|
75
|
+
* fallback to `anon`: a caller without a verified `sub` gets
|
|
76
|
+
* `UnauthenticatedError` rather than the empty result of a policy it never
|
|
77
|
+
* satisfied.
|
|
78
|
+
*
|
|
79
|
+
* `getRequestContext` is wrapped in React's `cache`, so one server render shares
|
|
80
|
+
* one verification and one handle. A server action invoked from the browser
|
|
81
|
+
* runs outside a render and resolves afresh on every call.
|
|
82
|
+
*
|
|
83
|
+
* Do not `close()` the handle: pools are shared and reference-counted.
|
|
84
|
+
*/
|
|
85
|
+
declare function createRequestDb<TRelations extends AnyRelations = EmptyRelations>(options: CreateRequestDbOptions<TRelations>): RequestDb<TRelations>;
|
|
86
|
+
//#endregion
|
|
87
|
+
export { type ActionContext, type ActionHandler, type ActionSchema, ClaimsSource, CreateRequestDbOptions, type DefineAction, RequestContext, RequestDb, UnauthenticatedError, createRequestDb };
|
package/dist/next.mjs
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { a as createSupabaseClient } from "./clients-CdPr3mCZ.mjs";
|
|
2
|
+
import { cache } from "react";
|
|
3
|
+
//#region src/define-action.ts
|
|
4
|
+
/**
|
|
5
|
+
* Binds `defineAction` to a request-context resolver. Deliberately not a
|
|
6
|
+
* `"use server"` module: it only returns a function, and the app file that
|
|
7
|
+
* exports the result is the one that carries the directive.
|
|
8
|
+
*
|
|
9
|
+
* Input is parsed before the context is resolved, so a malformed call fails
|
|
10
|
+
* without a session round trip, and the handler never sees an unparsed value.
|
|
11
|
+
*/
|
|
12
|
+
function createDefineAction(getContext) {
|
|
13
|
+
return (schema, handler) => async (input) => {
|
|
14
|
+
const parsed = schema.parse(input);
|
|
15
|
+
const context = await getContext();
|
|
16
|
+
return await handler(context.db, parsed, context);
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
//#endregion
|
|
20
|
+
//#region src/next.ts
|
|
21
|
+
/**
|
|
22
|
+
* Thrown when a request carries no verified session. A named class so a caller
|
|
23
|
+
* with a legitimate answer for that case (a permission check, where "not signed
|
|
24
|
+
* in" means "no") can tell it apart from a real failure with `instanceof`.
|
|
25
|
+
*/
|
|
26
|
+
var UnauthenticatedError = class extends Error {
|
|
27
|
+
constructor(message = "Unauthorized: no verified session on this request") {
|
|
28
|
+
super(message);
|
|
29
|
+
this.name = "UnauthenticatedError";
|
|
30
|
+
}
|
|
31
|
+
};
|
|
32
|
+
/**
|
|
33
|
+
* Request-scoped, RLS-bound Drizzle access for Next.js. Verifies the session
|
|
34
|
+
* with `getClaims()` (which checks the signature) and installs the **whole**
|
|
35
|
+
* claims object, so custom claims reach `auth.jwt()` in policies. There is no
|
|
36
|
+
* fallback to `anon`: a caller without a verified `sub` gets
|
|
37
|
+
* `UnauthenticatedError` rather than the empty result of a policy it never
|
|
38
|
+
* satisfied.
|
|
39
|
+
*
|
|
40
|
+
* `getRequestContext` is wrapped in React's `cache`, so one server render shares
|
|
41
|
+
* one verification and one handle. A server action invoked from the browser
|
|
42
|
+
* runs outside a render and resolves afresh on every call.
|
|
43
|
+
*
|
|
44
|
+
* Do not `close()` the handle: pools are shared and reference-counted.
|
|
45
|
+
*/
|
|
46
|
+
function createRequestDb(options) {
|
|
47
|
+
const { connectionString, supabase, ...config } = options;
|
|
48
|
+
const getRequestContext = cache(async () => {
|
|
49
|
+
const { data, error } = await (await supabase()).auth.getClaims();
|
|
50
|
+
if (error) throw error;
|
|
51
|
+
if (!data?.claims.sub) throw new UnauthenticatedError();
|
|
52
|
+
const url = typeof connectionString === "function" ? connectionString() : connectionString;
|
|
53
|
+
const db = createSupabaseClient(data.claims, url === void 0 ? config : {
|
|
54
|
+
...config,
|
|
55
|
+
connectionString: url
|
|
56
|
+
});
|
|
57
|
+
return {
|
|
58
|
+
claims: data.claims,
|
|
59
|
+
db
|
|
60
|
+
};
|
|
61
|
+
});
|
|
62
|
+
return {
|
|
63
|
+
defineAction: createDefineAction(getRequestContext),
|
|
64
|
+
getRequestContext,
|
|
65
|
+
getRequestDb: async () => (await getRequestContext()).db
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
//#endregion
|
|
69
|
+
export { UnauthenticatedError, createRequestDb };
|