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.
- package/README.md +141 -0
- package/bin/db_codegen.mjs +110 -0
- package/dist/src/db/access_token.d.ts +14 -0
- package/dist/src/db/access_token.js +30 -0
- package/dist/src/db/codegen_paths.d.ts +13 -0
- package/dist/src/db/codegen_paths.js +32 -0
- package/dist/src/db/connection.d.ts +42 -0
- package/dist/src/db/connection.js +178 -0
- package/dist/src/db/context.d.ts +57 -0
- package/dist/src/db/context.js +125 -0
- package/dist/src/db/helpers.d.ts +57 -0
- package/dist/src/db/helpers.js +112 -0
- package/dist/src/db/index.d.ts +24 -0
- package/dist/src/db/index.js +22 -0
- package/dist/src/db/require_config.d.ts +15 -0
- package/dist/src/db/require_config.js +20 -0
- package/dist/src/db/resolve_mode.d.ts +17 -0
- package/dist/src/db/resolve_mode.js +18 -0
- package/dist/src/db/supabase_config.d.ts +18 -0
- package/dist/src/db/supabase_config.js +22 -0
- package/dist/src/db/supabase_transport.d.ts +29 -0
- package/dist/src/db/supabase_transport.js +49 -0
- package/dist/src/index.d.ts +1 -0
- package/dist/src/index.js +1 -0
- package/dist/src/types/types.d.ts +91 -0
- package/llms.txt +35 -0
- package/package.json +22 -5
- package/supabase/cfni_exec.sql +34 -0
|
@@ -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
|
+
}
|
package/dist/src/index.d.ts
CHANGED
package/dist/src/index.js
CHANGED
|
@@ -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
|
+
}
|