cloudflare-next-intl 0.7.8 → 0.8.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,125 @@
1
+ import config from '../config/intl_config';
2
+ import requireDbConfig from './require_config';
3
+ import connectToPostgres, { disconnectPostgres } from './connection';
4
+ import resolveDbMode from './resolve_mode';
5
+ import resolveSupabaseEndpoint from './supabase_config';
6
+ import createSupabaseTransport from './supabase_transport';
7
+ import resolveAccessToken from './access_token';
8
+ const DEFAULT_ROLE = 'authenticated';
9
+ /**
10
+ * Resolves the user id for `withUserDb`, trying, in order: the explicit `uid`
11
+ * argument, `db.getUserId()`, then the signed-in Firebase user.
12
+ */
13
+ async function resolveUserId(uid) {
14
+ if (uid)
15
+ return uid;
16
+ const db = config.db;
17
+ requireDbConfig(db);
18
+ const fromConfig = await db.getUserId?.();
19
+ if (fromConfig)
20
+ return fromConfig;
21
+ if (config.firebaseAuth) {
22
+ const { getAuthUser } = await import('../firebase_auth/server/use_auth_user_server');
23
+ const { user } = await getAuthUser();
24
+ if (user?.uid)
25
+ return user.uid;
26
+ }
27
+ throw new Error('db: withUserDb could not resolve a user id. Pass one explicitly, set ' +
28
+ '`db.getUserId`, or configure `firebaseAuth` so the signed-in Firebase uid is used.');
29
+ }
30
+ /**
31
+ * Builds a Drizzle handle backed by PostgREST. `bearerToken` decides the role
32
+ * Postgres sees: the anon key for public access, a user JWT for `withUserDb`.
33
+ */
34
+ async function supabaseDb(supabase, bearerToken) {
35
+ const { drizzle } = await import('drizzle-orm/pg-proxy');
36
+ return drizzle(createSupabaseTransport(supabase, bearerToken));
37
+ }
38
+ /**
39
+ * Runs a query as the **anonymous** role: no transaction, no role switch, no
40
+ * user identity attached. Use this for data any visitor may read.
41
+ *
42
+ * Because no user id is set, RLS policies that test `auth.jwt()->>'sub'` see
43
+ * no user and will deny access — reach for {@link withUserDb} whenever the
44
+ * rows depend on who is asking.
45
+ *
46
+ * In connection-string mode the connection is taken from the request's
47
+ * shared client and released when `fn` settles, even if it throws. In
48
+ * Supabase mode there is no connection to release — each call is one
49
+ * PostgREST round-trip authenticated as the anon key.
50
+ *
51
+ * @param fn Receives the Drizzle handle; return whatever the caller needs.
52
+ * @returns Whatever `fn` resolves to.
53
+ * @throws If `db` is not set on your `RoutingConfig`, or the connection fails.
54
+ *
55
+ * @example
56
+ * const rows = await withPublicDb((db) => db.select().from(bonds).limit(10));
57
+ */
58
+ export async function withPublicDb(fn) {
59
+ const db = config.db;
60
+ requireDbConfig(db);
61
+ if (resolveDbMode(db) === 'supabase') {
62
+ const supabase = db.supabase;
63
+ const { anonKey } = resolveSupabaseEndpoint(supabase);
64
+ return fn(await supabaseDb(supabase, anonKey));
65
+ }
66
+ const client = await connectToPostgres(config);
67
+ try {
68
+ const { drizzle } = await import('drizzle-orm/node-postgres');
69
+ return await fn(drizzle(client));
70
+ }
71
+ finally {
72
+ disconnectPostgres(config);
73
+ }
74
+ }
75
+ /**
76
+ * Runs a query as the **signed-in user**.
77
+ *
78
+ * In connection-string mode this runs inside a transaction where Postgres
79
+ * sees the resolved user id as `auth.jwt()->>'sub'` under
80
+ * `db.authenticatedRole`, so RLS policies behave exactly as they do for a
81
+ * PostgREST-issued call. In Supabase mode identity instead rides on the JWT
82
+ * sent as `Authorization: Bearer` — PostgREST resolves the `authenticated`
83
+ * role and populates `request.jwt.claims` itself, and each statement is its
84
+ * own round-trip with no cross-statement transaction (the Postgres proxy
85
+ * Drizzle uses in this mode cannot open one). Either way this is the wrapper
86
+ * to use for anything user-owned.
87
+ *
88
+ * @param fn Receives the Drizzle handle. In connection-string mode it is
89
+ * bound to a transaction; in Supabase mode it is not — do not rely on
90
+ * multi-statement atomicity there.
91
+ * @param uid Connection-string mode only: overrides the user id. Omit it in
92
+ * normal use — the id then comes from `db.getUserId()` when set, otherwise
93
+ * from the signed-in Firebase user when `firebaseAuth` is configured.
94
+ * Ignored in Supabase mode, which resolves identity via `db.getAccessToken`/
95
+ * Firebase instead — see {@link resolveAccessToken}.
96
+ * @returns Whatever `fn` resolves to.
97
+ * @throws If `db` is not set on your `RoutingConfig`, if no user id/access
98
+ * token can be resolved, or the connection fails.
99
+ *
100
+ * @example
101
+ * const mine = await withUserDb((db) => db.select().from(orders));
102
+ */
103
+ export async function withUserDb(fn, uid) {
104
+ const db = config.db;
105
+ requireDbConfig(db);
106
+ if (resolveDbMode(db) === 'supabase') {
107
+ const token = await resolveAccessToken(config);
108
+ return fn(await supabaseDb(db.supabase, token));
109
+ }
110
+ const userId = await resolveUserId(uid);
111
+ const client = await connectToPostgres(config);
112
+ const role = db.authenticatedRole ?? DEFAULT_ROLE;
113
+ try {
114
+ const { drizzle } = await import('drizzle-orm/node-postgres');
115
+ const { sql } = await import('drizzle-orm');
116
+ return await drizzle(client).transaction(async (transaction) => {
117
+ await transaction.execute(sql `select set_config('request.jwt.claims', ${JSON.stringify({ sub: userId })}, true)`);
118
+ await transaction.execute(sql `set local role ${sql.raw(role)}`);
119
+ return fn(transaction);
120
+ });
121
+ }
122
+ finally {
123
+ disconnectPostgres(config);
124
+ }
125
+ }
@@ -0,0 +1,57 @@
1
+ import { type SQL, type Table } from 'drizzle-orm';
2
+ /**
3
+ * Type-safe helper returning `excluded.<db_column_name>` SQL expressions for a Drizzle table.
4
+ *
5
+ * Restricts property access at compile time to valid table schema keys (camelCase),
6
+ * and validates at runtime by throwing a descriptive error if an unknown property is accessed.
7
+ *
8
+ * @example
9
+ * set: {
10
+ * inflation: excluded(inflation).inflation,
11
+ * yearInflation: excluded(inflation).yearInflation,
12
+ * updatedAt: sql`now()`,
13
+ * }
14
+ */
15
+ export declare function excluded<T extends Table>(table: T): {
16
+ [K in keyof T["_"]["columns"]]: SQL;
17
+ };
18
+ /**
19
+ * Builds a runtime and compile-time validated `set` object for `onConflictDoUpdate`.
20
+ *
21
+ * Only permits valid column keys of the target table. Automatically maps
22
+ * property keys to database column names and appends `updatedAt: sql\`now()\``
23
+ * if the column exists on the table.
24
+ *
25
+ * @example
26
+ * .onConflictDoUpdate({
27
+ * target: inflation.date,
28
+ * set: onConflictSet(inflation, ["inflation", "yearInflation"]),
29
+ * })
30
+ */
31
+ export declare function onConflictSet<T extends Table, K extends keyof T["_"]["columns"]>(table: T, fields: K[]): Record<string, SQL>;
32
+ export type TimeUnit = "days" | "hours" | "minutes" | "months" | "years" | "weeks";
33
+ /** General SQL helper generating a timestamp expression relative to now (`now() - (N unit)::interval`). */
34
+ export declare function ago(amount: number, unit: TimeUnit): SQL;
35
+ /** General SQL helper returning `current_date`. */
36
+ export declare function currentDate(): SQL;
37
+ /** General SQL helper for window function `count(*) over ()`. */
38
+ export declare function windowCount(): SQL<number>;
39
+ /** Helper generating `lateral unnest(...) as alias(alias)`. */
40
+ export declare function unnestLateral(column: unknown, alias: string): SQL;
41
+ /** Helper generating `column asc nulls last`. */
42
+ export declare function ascNullsLast(column: unknown): SQL;
43
+ /** General SQL literal `true`, for lateral joins and empty predicate lists. */
44
+ export declare function alwaysTrue(): SQL;
45
+ /** Wraps an expression as `lateral (<inner>) <alias>`. */
46
+ export declare function lateral(inner: SQL, alias: string): SQL;
47
+ /** References `<alias>.<column>` of a derived table / lateral subquery. */
48
+ export declare function aliasColumn<T = unknown>(alias: string, column: string): SQL<T>;
49
+ /** General aggregate helpers over an arbitrary expression. */
50
+ export declare function minOf<T = unknown>(expression: unknown): SQL<T>;
51
+ export declare function maxOf<T = unknown>(expression: unknown): SQL<T>;
52
+ /** Rounds an expression to `digits` decimals, returning `real`. */
53
+ export declare function roundReal(expression: unknown, digits: number): SQL<number>;
54
+ /** Multiplies an expression by a factor. */
55
+ export declare function multiply(expression: unknown, factor: number): SQL<number>;
56
+ /** Wraps an expression as a scalar subquery over a named CTE: `(select <expr> from <cte>)`. */
57
+ export declare function scalarFromCte<T = unknown>(cte: string, expression: unknown): SQL<T>;
@@ -0,0 +1,112 @@
1
+ import { getTableColumns, getTableName, sql } from 'drizzle-orm';
2
+ /**
3
+ * Type-safe helper returning `excluded.<db_column_name>` SQL expressions for a Drizzle table.
4
+ *
5
+ * Restricts property access at compile time to valid table schema keys (camelCase),
6
+ * and validates at runtime by throwing a descriptive error if an unknown property is accessed.
7
+ *
8
+ * @example
9
+ * set: {
10
+ * inflation: excluded(inflation).inflation,
11
+ * yearInflation: excluded(inflation).yearInflation,
12
+ * updatedAt: sql`now()`,
13
+ * }
14
+ */
15
+ export function excluded(table) {
16
+ const cols = getTableColumns(table);
17
+ const tableName = getTableName(table);
18
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
19
+ return new Proxy({}, {
20
+ get(_, prop) {
21
+ const col = cols[prop];
22
+ if (!col) {
23
+ throw new Error(`[Drizzle Error] Column "${prop}" does not exist on table "${tableName}". ` +
24
+ `Valid columns: ${Object.keys(cols).join(", ")}`);
25
+ }
26
+ return sql.raw(`excluded.${col.name}`);
27
+ },
28
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
29
+ });
30
+ }
31
+ /**
32
+ * Builds a runtime and compile-time validated `set` object for `onConflictDoUpdate`.
33
+ *
34
+ * Only permits valid column keys of the target table. Automatically maps
35
+ * property keys to database column names and appends `updatedAt: sql\`now()\``
36
+ * if the column exists on the table.
37
+ *
38
+ * @example
39
+ * .onConflictDoUpdate({
40
+ * target: inflation.date,
41
+ * set: onConflictSet(inflation, ["inflation", "yearInflation"]),
42
+ * })
43
+ */
44
+ export function onConflictSet(table, fields) {
45
+ const cols = getTableColumns(table);
46
+ const tableName = getTableName(table);
47
+ const setObj = {};
48
+ for (const field of fields) {
49
+ const key = String(field);
50
+ const col = cols[key];
51
+ if (!col) {
52
+ throw new Error(`[Drizzle Error] Column "${key}" does not exist on table "${tableName}". ` +
53
+ `Valid columns: ${Object.keys(cols).join(", ")}`);
54
+ }
55
+ setObj[key] = sql.raw(`excluded.${col.name}`);
56
+ }
57
+ if ("updatedAt" in cols) {
58
+ setObj.updatedAt = sql `now()`;
59
+ }
60
+ return setObj;
61
+ }
62
+ /** General SQL helper generating a timestamp expression relative to now (`now() - (N unit)::interval`). */
63
+ export function ago(amount, unit) {
64
+ return sql `now() - (${amount} || ' ' || ${unit})::interval`;
65
+ }
66
+ /** General SQL helper returning `current_date`. */
67
+ export function currentDate() {
68
+ return sql `current_date`;
69
+ }
70
+ /** General SQL helper for window function `count(*) over ()`. */
71
+ export function windowCount() {
72
+ return sql `count(*) over ()`.mapWith(Number);
73
+ }
74
+ /** Helper generating `lateral unnest(...) as alias(alias)`. */
75
+ export function unnestLateral(column, alias) {
76
+ return sql `lateral unnest(${column}) as ${sql.raw(alias)}(${sql.raw(alias)})`;
77
+ }
78
+ /** Helper generating `column asc nulls last`. */
79
+ export function ascNullsLast(column) {
80
+ return sql `${column} asc nulls last`;
81
+ }
82
+ /** General SQL literal `true`, for lateral joins and empty predicate lists. */
83
+ export function alwaysTrue() {
84
+ return sql `true`;
85
+ }
86
+ /** Wraps an expression as `lateral (<inner>) <alias>`. */
87
+ export function lateral(inner, alias) {
88
+ return sql `lateral (${inner}) ${sql.raw(alias)}`;
89
+ }
90
+ /** References `<alias>.<column>` of a derived table / lateral subquery. */
91
+ export function aliasColumn(alias, column) {
92
+ return sql.raw(`${alias}.${column}`);
93
+ }
94
+ /** General aggregate helpers over an arbitrary expression. */
95
+ export function minOf(expression) {
96
+ return sql `min(${expression})`;
97
+ }
98
+ export function maxOf(expression) {
99
+ return sql `max(${expression})`;
100
+ }
101
+ /** Rounds an expression to `digits` decimals, returning `real`. */
102
+ export function roundReal(expression, digits) {
103
+ return sql `round((${expression})::numeric, ${digits})::real`;
104
+ }
105
+ /** Multiplies an expression by a factor. */
106
+ export function multiply(expression, factor) {
107
+ return sql `${expression} * ${factor}`;
108
+ }
109
+ /** Wraps an expression as a scalar subquery over a named CTE: `(select <expr> from <cte>)`. */
110
+ export function scalarFromCte(cte, expression) {
111
+ return sql `(select ${expression} from ${sql.raw(cte)})`;
112
+ }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Optional Postgres/Drizzle data-access layer, reached from
3
+ * `cloudflare-next-intl/db`. Enable it by setting `db` on your `RoutingConfig`;
4
+ * every export here throws a descriptive error if that config is missing.
5
+ *
6
+ * Pick a wrapper by who is allowed to see the rows:
7
+ * - {@link withPublicDb} — anonymous role, for data any visitor may read.
8
+ * - {@link withUserDb} — the signed-in user, with RLS applied to their id.
9
+ *
10
+ * Two transports reach Postgres behind that same Drizzle query API, chosen by
11
+ * `resolveDbMode` from which `db` config fields are set: `connectionString`/
12
+ * `hyperdriveBinding` for a direct connection (wins if both are configured),
13
+ * or `supabase` for the Supabase Data API when only a project URL and anon
14
+ * key are available. `pg`, `drizzle-orm`, and `@supabase/supabase-js` all
15
+ * load through dynamic `import()` inside these functions, so an app that
16
+ * never calls a `db` export never bundles any of them.
17
+ *
18
+ * Generic Drizzle SQL helpers (`excluded`, `onConflictSet`, `ago`, …) live in
19
+ * the separate `cloudflare-next-intl/dbHelpers` entry point.
20
+ */
21
+ export { withPublicDb, withUserDb } from './context';
22
+ export type { DrizzleDb } from './context';
23
+ export { default as connectToPostgres, disconnectPostgres, resetConnectionState } from './connection';
24
+ export type { DbRoutingConfig } from '../types/types';
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Optional Postgres/Drizzle data-access layer, reached from
3
+ * `cloudflare-next-intl/db`. Enable it by setting `db` on your `RoutingConfig`;
4
+ * every export here throws a descriptive error if that config is missing.
5
+ *
6
+ * Pick a wrapper by who is allowed to see the rows:
7
+ * - {@link withPublicDb} — anonymous role, for data any visitor may read.
8
+ * - {@link withUserDb} — the signed-in user, with RLS applied to their id.
9
+ *
10
+ * Two transports reach Postgres behind that same Drizzle query API, chosen by
11
+ * `resolveDbMode` from which `db` config fields are set: `connectionString`/
12
+ * `hyperdriveBinding` for a direct connection (wins if both are configured),
13
+ * or `supabase` for the Supabase Data API when only a project URL and anon
14
+ * key are available. `pg`, `drizzle-orm`, and `@supabase/supabase-js` all
15
+ * load through dynamic `import()` inside these functions, so an app that
16
+ * never calls a `db` export never bundles any of them.
17
+ *
18
+ * Generic Drizzle SQL helpers (`excluded`, `onConflictSet`, `ago`, …) live in
19
+ * the separate `cloudflare-next-intl/dbHelpers` entry point.
20
+ */
21
+ export { withPublicDb, withUserDb } from './context';
22
+ export { default as connectToPostgres, disconnectPostgres, resetConnectionState } from './connection';
@@ -0,0 +1,15 @@
1
+ import type { DbRoutingConfig } from '../types/types';
2
+ /**
3
+ * Asserts that the optional `db` config is present, narrowing it from
4
+ * `DbRoutingConfig | undefined` to `DbRoutingConfig` for the rest of the
5
+ * caller's scope.
6
+ *
7
+ * Every `db` export calls this before touching `pg`/`drizzle-orm`. It throws
8
+ * rather than silently no-op'ing, so a consumer who calls e.g. `withPublicDb()`
9
+ * without setting `db` on their `RoutingConfig` gets an immediate, actionable
10
+ * error instead of a confusing failed query.
11
+ *
12
+ * @param db The `db` field off your routing config.
13
+ * @throws If `db` is undefined.
14
+ */
15
+ export default function requireDbConfig(db: DbRoutingConfig | undefined): asserts db is DbRoutingConfig;
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Asserts that the optional `db` config is present, narrowing it from
3
+ * `DbRoutingConfig | undefined` to `DbRoutingConfig` for the rest of the
4
+ * caller's scope.
5
+ *
6
+ * Every `db` export calls this before touching `pg`/`drizzle-orm`. It throws
7
+ * rather than silently no-op'ing, so a consumer who calls e.g. `withPublicDb()`
8
+ * without setting `db` on their `RoutingConfig` gets an immediate, actionable
9
+ * error instead of a confusing failed query.
10
+ *
11
+ * @param db The `db` field off your routing config.
12
+ * @throws If `db` is undefined.
13
+ */
14
+ export default function requireDbConfig(db) {
15
+ if (!db) {
16
+ throw new Error('db: `db` is not set on your RoutingConfig. Add a `db` object ' +
17
+ '(connectionString or hyperdriveBinding) to the config passed to ' +
18
+ '`setIntlConfig` before using any db export.');
19
+ }
20
+ }
@@ -0,0 +1,17 @@
1
+ import type { DbRoutingConfig } from '../types/types';
2
+ /** Which transport the `db` exports use for a given config. */
3
+ export type DbMode = 'postgres' | 'supabase';
4
+ /**
5
+ * Decides how to reach the database from the shape of the `db` config.
6
+ *
7
+ * Direct Postgres wins whenever it is configured, so adding a `supabase`
8
+ * block to an existing config never silently reroutes live traffic. With
9
+ * neither set the result is still `'postgres'`, which lets
10
+ * `connectToPostgres` raise its existing, more specific error about the
11
+ * missing Hyperdrive binding.
12
+ *
13
+ * @param db The `db` field off your routing config.
14
+ * @returns `'postgres'` for connection-string/Hyperdrive access, `'supabase'`
15
+ * for PostgREST access.
16
+ */
17
+ export default function resolveDbMode(db: DbRoutingConfig): DbMode;
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Decides how to reach the database from the shape of the `db` config.
3
+ *
4
+ * Direct Postgres wins whenever it is configured, so adding a `supabase`
5
+ * block to an existing config never silently reroutes live traffic. With
6
+ * neither set the result is still `'postgres'`, which lets
7
+ * `connectToPostgres` raise its existing, more specific error about the
8
+ * missing Hyperdrive binding.
9
+ *
10
+ * @param db The `db` field off your routing config.
11
+ * @returns `'postgres'` for connection-string/Hyperdrive access, `'supabase'`
12
+ * for PostgREST access.
13
+ */
14
+ export default function resolveDbMode(db) {
15
+ if (db.connectionString || db.hyperdriveBinding)
16
+ return 'postgres';
17
+ return db.supabase ? 'supabase' : 'postgres';
18
+ }
@@ -0,0 +1,18 @@
1
+ import type { SupabaseDbConfig } from '../types/types';
2
+ /** The Supabase project URL and anon key the transport builds a client from. */
3
+ export interface ResolvedSupabaseEndpoint {
4
+ /** Project URL, trailing slashes stripped. */
5
+ url: string;
6
+ /** Anon key, sent as both `apikey` and the public-mode bearer token. */
7
+ anonKey: string;
8
+ }
9
+ /**
10
+ * Resolves the Supabase project URL and anon key, preferring explicit config
11
+ * over the `NEXT_PUBLIC_SUPABASE_URL`/`NEXT_PUBLIC_SUPABASE_ANON_KEY`
12
+ * environment variables.
13
+ *
14
+ * @param supabase The `db.supabase` config block.
15
+ * @returns The project URL and anon key to build a Supabase client from.
16
+ * @throws If neither config nor environment supplies a URL or an anon key.
17
+ */
18
+ export default function resolveSupabaseEndpoint(supabase: SupabaseDbConfig): ResolvedSupabaseEndpoint;
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Resolves the Supabase project URL and anon key, preferring explicit config
3
+ * over the `NEXT_PUBLIC_SUPABASE_URL`/`NEXT_PUBLIC_SUPABASE_ANON_KEY`
4
+ * environment variables.
5
+ *
6
+ * @param supabase The `db.supabase` config block.
7
+ * @returns The project URL and anon key to build a Supabase client from.
8
+ * @throws If neither config nor environment supplies a URL or an anon key.
9
+ */
10
+ export default function resolveSupabaseEndpoint(supabase) {
11
+ const url = supabase.url ?? process.env.NEXT_PUBLIC_SUPABASE_URL;
12
+ if (!url) {
13
+ throw new Error('db: could not resolve a Supabase project URL. Set `db.supabase.url` ' +
14
+ 'or the NEXT_PUBLIC_SUPABASE_URL environment variable.');
15
+ }
16
+ const anonKey = supabase.anonKey ?? process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
17
+ if (!anonKey) {
18
+ throw new Error('db: could not resolve a Supabase anon key. Set `db.supabase.anonKey` ' +
19
+ 'or the NEXT_PUBLIC_SUPABASE_ANON_KEY environment variable.');
20
+ }
21
+ return { url: url.replace(/\/+$/, ''), anonKey };
22
+ }
@@ -0,0 +1,29 @@
1
+ import type { SupabaseDbConfig } from '../types/types';
2
+ /**
3
+ * The executor shape `drizzle-orm/pg-proxy` calls with each generated
4
+ * statement. Declared structurally so this file never imports `drizzle-orm`.
5
+ */
6
+ export type SupabaseRemoteCallback = (sql: string, params: unknown[], method: 'all' | 'execute') => Promise<{
7
+ rows: unknown[];
8
+ }>;
9
+ /**
10
+ * Builds the transport Drizzle uses in Supabase mode: every generated
11
+ * statement is sent through `@supabase/supabase-js`'s `.rpc()` to the
12
+ * `cfni_exec` function over PostgREST.
13
+ *
14
+ * `bearerToken` decides who Postgres thinks is calling — the anon key for
15
+ * public reads, a user's JWT for `withUserDb` — delivered through the
16
+ * client's `accessToken` option (the same mechanism a signed-in Supabase
17
+ * session would use), so RLS is enforced by the database rather than by
18
+ * anything in this package. The client is created once and reused for every
19
+ * statement this transport is asked to run.
20
+ *
21
+ * Rows come back as positional arrays because `pg-proxy` maps result columns
22
+ * by index; `cfni_exec` is what guarantees that shape.
23
+ *
24
+ * @param supabase The `db.supabase` config block.
25
+ * @param bearerToken Token resolved as the caller's identity — the anon key,
26
+ * or a per-request user JWT.
27
+ * @returns A callback suitable for `drizzle-orm/pg-proxy`'s `drizzle()`.
28
+ */
29
+ export default function createSupabaseTransport(supabase: SupabaseDbConfig, bearerToken: string): SupabaseRemoteCallback;
@@ -0,0 +1,49 @@
1
+ import resolveSupabaseEndpoint from './supabase_config';
2
+ const DEFAULT_EXEC_FUNCTION = 'cfni_exec';
3
+ /**
4
+ * Builds the transport Drizzle uses in Supabase mode: every generated
5
+ * statement is sent through `@supabase/supabase-js`'s `.rpc()` to the
6
+ * `cfni_exec` function over PostgREST.
7
+ *
8
+ * `bearerToken` decides who Postgres thinks is calling — the anon key for
9
+ * public reads, a user's JWT for `withUserDb` — delivered through the
10
+ * client's `accessToken` option (the same mechanism a signed-in Supabase
11
+ * session would use), so RLS is enforced by the database rather than by
12
+ * anything in this package. The client is created once and reused for every
13
+ * statement this transport is asked to run.
14
+ *
15
+ * Rows come back as positional arrays because `pg-proxy` maps result columns
16
+ * by index; `cfni_exec` is what guarantees that shape.
17
+ *
18
+ * @param supabase The `db.supabase` config block.
19
+ * @param bearerToken Token resolved as the caller's identity — the anon key,
20
+ * or a per-request user JWT.
21
+ * @returns A callback suitable for `drizzle-orm/pg-proxy`'s `drizzle()`.
22
+ */
23
+ export default function createSupabaseTransport(supabase, bearerToken) {
24
+ const { url, anonKey } = resolveSupabaseEndpoint(supabase);
25
+ const execFunction = supabase.execFunction ?? DEFAULT_EXEC_FUNCTION;
26
+ let clientPromise = null;
27
+ async function getClient() {
28
+ clientPromise ?? (clientPromise = (async () => {
29
+ const { createClient } = await import('@supabase/supabase-js');
30
+ return createClient(url, anonKey, { accessToken: async () => bearerToken });
31
+ })());
32
+ return clientPromise;
33
+ }
34
+ return async (sql, params) => {
35
+ const client = await getClient();
36
+ const { data, error } = await client.rpc(execFunction, { statement: sql, params });
37
+ if (error)
38
+ throw new Error(describeFailure(error, execFunction));
39
+ return { rows: Array.isArray(data) ? data : [] };
40
+ };
41
+ }
42
+ function describeFailure(error, execFunction) {
43
+ // PGRST202 is PostgREST's "no such function" — by far the most likely
44
+ // first-run failure, so point at the install step instead of the raw code.
45
+ if (error.code === 'PGRST202') {
46
+ return `db: Supabase rejected the query — ${error.message}. Install the ${execFunction} function from supabase/cfni_exec.sql in your database.`;
47
+ }
48
+ return `db: Supabase rejected the query — ${error.message}.`;
49
+ }
@@ -4,3 +4,4 @@ export * from './server';
4
4
  export * from './client';
5
5
  export * from './theme_switcher';
6
6
  export * from './types';
7
+ export * from './db';
package/dist/src/index.js CHANGED
@@ -4,3 +4,4 @@ export * from './server';
4
4
  export * from './client';
5
5
  export * from './theme_switcher';
6
6
  export * from './types';
7
+ export * from './db';
@@ -85,6 +85,14 @@ export interface RoutingConfig<AppLocales extends Locales, AppLocalePrefixMode e
85
85
  * if this field is missing at call time rather than silently no-op'ing.
86
86
  */
87
87
  firebaseAuth?: FirebaseAuthRoutingConfig;
88
+ /**
89
+ * Configures the optional `db` submodule (Postgres/Drizzle access over a
90
+ * Cloudflare Hyperdrive or plain connection string). Omit entirely to keep
91
+ * it fully disabled — no file in this package imports `pg`/`drizzle-orm`
92
+ * unless a `db` export is actually called, and every such export throws a
93
+ * clear error if this field is missing at call time.
94
+ */
95
+ db?: DbRoutingConfig;
88
96
  /**
89
97
  * Configures the optional `cookie_consent` submodule (cookie-consent +
90
98
  * privacy-policy-update banners). Omit entirely to keep it disabled —
@@ -809,3 +817,86 @@ export interface IntlSitemap {
809
817
  lastModified: Date | string | undefined;
810
818
  videos?: Videos[] | undefined;
811
819
  }
820
+ export interface SupabaseDbConfig {
821
+ /**
822
+ * Supabase project URL, e.g. `https://abc.supabase.co`. Defaults to
823
+ * `process.env.NEXT_PUBLIC_SUPABASE_URL`.
824
+ */
825
+ url?: string;
826
+ /**
827
+ * Supabase anon (publishable) key. Defaults to
828
+ * `process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY`. This is the only key the
829
+ * `db` module ever needs — never put a service-role key here.
830
+ */
831
+ anonKey?: string;
832
+ /**
833
+ * Name of the Postgres function that runs the generated SQL. Defaults to
834
+ * `'cfni_exec'` — the function shipped in `supabase/cfni_exec.sql`.
835
+ */
836
+ execFunction?: string;
837
+ }
838
+ export interface DbRoutingConfig {
839
+ /**
840
+ * Postgres connection string. Omit to resolve it from the Cloudflare
841
+ * Hyperdrive binding named by `hyperdriveBinding` instead (the normal
842
+ * production setup); a value here always wins over the binding, which is
843
+ * what makes local dev / build-time evaluation work.
844
+ */
845
+ connectionString?: string;
846
+ /**
847
+ * Name of the Hyperdrive binding on `env` whose `connectionString` is used
848
+ * when `connectionString` is not set. Defaults to `'HYPERDRIVE'`. Requires
849
+ * `generate.getCloudflareContext` to be configured.
850
+ */
851
+ hyperdriveBinding?: string;
852
+ /**
853
+ * Whether the pooled client is closed once the last in-flight
854
+ * `withPublicDb`/`withUserDb` call of the request finishes.
855
+ * Defaults to `true` (one connection per request, released to Hyperdrive
856
+ * immediately). Set `false` to keep the connection open for the lifetime
857
+ * of the isolate — faster for a long-lived server, but it holds a
858
+ * Hyperdrive connection slot between requests.
859
+ */
860
+ disconnectAfterRequest?: boolean;
861
+ /**
862
+ * Postgres role assumed inside `withUserDb`'s transaction. Defaults
863
+ * to `'authenticated'` (the Supabase RLS convention).
864
+ */
865
+ authenticatedRole?: string;
866
+ /**
867
+ * Resolves the user id injected as `request.jwt.claims->>'sub'` inside
868
+ * `withUserDb`. Omit when `firebaseAuth` is configured — the uid then
869
+ * comes from this package's own `getAuthUser()` automatically. Provide it
870
+ * to use a different auth source (or when `firebaseAuth` is absent).
871
+ */
872
+ getUserId?: () => Promise<string | null> | string | null;
873
+ /** Milliseconds `disconnectPostgres` waits for `client.end()` before giving up. Defaults to `2000`. */
874
+ disconnectTimeoutMs?: number;
875
+ /**
876
+ * Reaches Postgres through the Supabase Data API instead of a direct
877
+ * connection, using only your project URL and anon key. Set this when you
878
+ * have no Postgres password to give the package — `withPublicDb` and
879
+ * `withUserDb` behave the same either way, so switching is a config change
880
+ * with no app-code change.
881
+ *
882
+ * Ignored when `connectionString` or `hyperdriveBinding` is set: a direct
883
+ * connection always wins, so adding this block cannot silently reroute
884
+ * live traffic. Requires the `cfni_exec` function from
885
+ * `supabase/cfni_exec.sql` to be installed in your database.
886
+ *
887
+ * No multi-statement transactions: unlike connection-string mode, each
888
+ * statement inside a `withUserDb` callback is its own round-trip. Do not
889
+ * rely on multi-statement atomicity in this mode.
890
+ */
891
+ supabase?: SupabaseDbConfig;
892
+ /**
893
+ * Resolves the JWT sent as `Authorization: Bearer` for `withUserDb` in
894
+ * Supabase mode, which is what makes PostgREST resolve the caller as
895
+ * `authenticated` and apply RLS. Omit when `firebaseAuth` is configured —
896
+ * the signed-in user's Firebase ID token is then used automatically.
897
+ *
898
+ * Unused in connection-string mode, which identifies the user with
899
+ * `getUserId` and `set_config` instead.
900
+ */
901
+ getAccessToken?: () => Promise<string | null> | string | null;
902
+ }