cloudflare-next-intl 0.8.4 → 0.8.6

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,262 @@
1
+ import config from '../config/intl_config';
2
+ import requireDbConfig from './require_config';
3
+ import resolveSupabaseEndpoint from './supabase_config';
4
+ import resolveAccessToken from './access_token';
5
+ function describeQueryFailure(error) {
6
+ return `db: Supabase rejected the request — ${error.message}.`;
7
+ }
8
+ async function buildClient(supabase, bearerToken) {
9
+ const { url, anonKey } = await resolveSupabaseEndpoint(supabase);
10
+ const { createClient } = await import('@supabase/supabase-js');
11
+ return createClient(url, anonKey, { accessToken: async () => bearerToken });
12
+ }
13
+ function applyFilters(query, where) {
14
+ let result = query;
15
+ if (!where)
16
+ return result;
17
+ for (const [column, filter] of Object.entries(where)) {
18
+ if (Array.isArray(filter) && filter[0] === 'not') {
19
+ const [, operator, value] = filter;
20
+ result = result.not(column, operator, value);
21
+ continue;
22
+ }
23
+ const [operator, value] = Array.isArray(filter)
24
+ ? [filter[0], filter[1]]
25
+ : ['eq', filter];
26
+ result = result[operator](column, value);
27
+ }
28
+ return result;
29
+ }
30
+ function applyExtras(query, extras) {
31
+ let result = query;
32
+ if (extras.match)
33
+ result = result.match(extras.match);
34
+ if (extras.or) {
35
+ const or = typeof extras.or === 'string' ? { filters: extras.or, referencedTable: undefined } : extras.or;
36
+ result = result.or(or.filters, { referencedTable: or.referencedTable });
37
+ }
38
+ if (extras.textSearch) {
39
+ result = result.textSearch(extras.textSearch.column, extras.textSearch.query, {
40
+ type: extras.textSearch.type,
41
+ config: extras.textSearch.config,
42
+ });
43
+ }
44
+ return result;
45
+ }
46
+ function applyOrder(query, orderBy) {
47
+ if (!orderBy)
48
+ return query;
49
+ let result = query;
50
+ for (const clause of Array.isArray(orderBy) ? orderBy : [orderBy]) {
51
+ result = result.order(clause.column, { ascending: clause.ascending, nullsFirst: clause.nullsFirst });
52
+ }
53
+ return result;
54
+ }
55
+ async function requireSupabaseConfig() {
56
+ const db = config.db;
57
+ requireDbConfig(db);
58
+ if (!db.supabase) {
59
+ throw new Error('db: supabaseSelect/supabaseInsert/supabaseUpsert/supabaseUpdate/supabaseDelete/' +
60
+ 'supabaseRpc need `db.supabase` (a project URL and anon key) on your RoutingConfig, ' +
61
+ 'even in connection-string mode.');
62
+ }
63
+ return db.supabase;
64
+ }
65
+ async function anonClient() {
66
+ const supabase = await requireSupabaseConfig();
67
+ const { anonKey } = await resolveSupabaseEndpoint(supabase);
68
+ return buildClient(supabase, anonKey);
69
+ }
70
+ async function userClient() {
71
+ const supabase = await requireSupabaseConfig();
72
+ const token = await resolveAccessToken(config);
73
+ return buildClient(supabase, token);
74
+ }
75
+ /**
76
+ * Selects rows from `table` through the Supabase REST API
77
+ * (`@supabase/supabase-js`'s `.from(table).select()`), as the **anonymous**
78
+ * role — no `cfni_exec`, no raw SQL, only what PostgREST's REST API itself
79
+ * supports: a column list, filters (equality or a named operator), one or
80
+ * more `order by` clauses, `limit`/`range` pagination, `single`/`maybeSingle`,
81
+ * and an optional row count.
82
+ *
83
+ * Reach for this — or {@link supabaseSelectAsUser} — instead of
84
+ * `withPublicDb`/`withUserDb` when `db.supabase.rawSql` is `false`, or
85
+ * whenever a query is simple enough that avoiding `cfni_exec` entirely is
86
+ * preferable. Cross-table joins/aggregates spanning more than an embedded
87
+ * resource (`columns: '*, related(*)'`) still need `withPublicDb`/
88
+ * `withUserDb`, or a Postgres function called via {@link supabaseRpc}.
89
+ *
90
+ * @param table Table (or view) name.
91
+ * @param options Column list, filters, ordering, pagination, count.
92
+ * @returns The matching rows, and the total count when `options.count` is set.
93
+ * @throws If `db`/`db.supabase` is not set, or PostgREST rejects the request.
94
+ */
95
+ export async function supabaseSelect(table, options = {}) {
96
+ return runSelect(await anonClient(), table, options);
97
+ }
98
+ /**
99
+ * Same as {@link supabaseSelect}, but authenticated as the **signed-in
100
+ * user**: the bearer token comes from `db.getAccessToken`, or the signed-in
101
+ * Firebase user's ID token when `firebaseAuth` is configured — see
102
+ * {@link resolveAccessToken}. PostgREST resolves the caller as
103
+ * `authenticated` and RLS applies exactly as it would for `withUserDb`.
104
+ */
105
+ export async function supabaseSelectAsUser(table, options = {}) {
106
+ return runSelect(await userClient(), table, options);
107
+ }
108
+ async function runSelect(client, table, options) {
109
+ let query = client.from(table).select(options.columns ?? '*', options.count ? { count: options.count } : undefined);
110
+ query = applyFilters(query, options.where);
111
+ query = applyExtras(query, options);
112
+ query = applyOrder(query, options.orderBy);
113
+ if (options.limit !== undefined)
114
+ query = query.limit(options.limit);
115
+ if (options.range)
116
+ query = query.range(options.range[0], options.range[1]);
117
+ if (options.single)
118
+ query = query.single();
119
+ else if (options.maybeSingle)
120
+ query = query.maybeSingle();
121
+ const { data, error, count } = (await query);
122
+ if (error)
123
+ throw new Error(describeQueryFailure(error));
124
+ const rows = options.single || options.maybeSingle ? (data === null ? [] : [data]) : (data ?? []);
125
+ return { rows, count };
126
+ }
127
+ /**
128
+ * Inserts one or more rows into `table` through the Supabase REST API, as
129
+ * the **anonymous** role. See {@link supabaseSelect} for when to use this
130
+ * over `withPublicDb`.
131
+ *
132
+ * @param table Table name.
133
+ * @param values A single row, or an array of rows, to insert.
134
+ * @returns The inserted row(s) as returned by PostgREST.
135
+ */
136
+ export async function supabaseInsert(table, values) {
137
+ return runInsert(await anonClient(), table, values);
138
+ }
139
+ /** Same as {@link supabaseInsert}, authenticated as the signed-in user — see {@link supabaseSelectAsUser}. */
140
+ export async function supabaseInsertAsUser(table, values) {
141
+ return runInsert(await userClient(), table, values);
142
+ }
143
+ async function runInsert(client, table, values) {
144
+ const { data, error } = (await client.from(table).insert(values).select());
145
+ if (error)
146
+ throw new Error(describeQueryFailure(error));
147
+ return data ?? [];
148
+ }
149
+ /**
150
+ * Inserts one or more rows into `table`, or updates them on a conflict —
151
+ * PostgREST's `upsert`, i.e. `insert ... on conflict (...) do update`. As
152
+ * the **anonymous** role.
153
+ *
154
+ * @param table Table name.
155
+ * @param values A single row, or an array of rows, to upsert.
156
+ * @param options `onConflict` (defaults to the table's primary key) and
157
+ * `ignoreDuplicates` (skip instead of update on conflict).
158
+ * @returns The upserted row(s) as returned by PostgREST.
159
+ */
160
+ export async function supabaseUpsert(table, values, options = {}) {
161
+ return runUpsert(await anonClient(), table, values, options);
162
+ }
163
+ /** Same as {@link supabaseUpsert}, authenticated as the signed-in user — see {@link supabaseSelectAsUser}. */
164
+ export async function supabaseUpsertAsUser(table, values, options = {}) {
165
+ return runUpsert(await userClient(), table, values, options);
166
+ }
167
+ async function runUpsert(client, table, values, options) {
168
+ const { data, error } = (await client
169
+ .from(table)
170
+ .upsert(values, { onConflict: options.onConflict, ignoreDuplicates: options.ignoreDuplicates })
171
+ .select());
172
+ if (error)
173
+ throw new Error(describeQueryFailure(error));
174
+ return data ?? [];
175
+ }
176
+ /**
177
+ * Updates rows in `table` matching `options.where` through the Supabase
178
+ * REST API, as the **anonymous** role. See {@link supabaseSelect} for when
179
+ * to use this over `withPublicDb`.
180
+ *
181
+ * @param table Table name.
182
+ * @param values Columns to set.
183
+ * @param options `where` — filters selecting which rows to update; required,
184
+ * since an unfiltered update would touch every row.
185
+ * @returns The updated rows as returned by PostgREST.
186
+ */
187
+ export async function supabaseUpdate(table, values, options) {
188
+ return runUpdate(await anonClient(), table, values, options);
189
+ }
190
+ /** Same as {@link supabaseUpdate}, authenticated as the signed-in user — see {@link supabaseSelectAsUser}. */
191
+ export async function supabaseUpdateAsUser(table, values, options) {
192
+ return runUpdate(await userClient(), table, values, options);
193
+ }
194
+ async function runUpdate(client, table, values, options) {
195
+ requireMutationFilter(options);
196
+ let query = client.from(table).update(values);
197
+ query = applyFilters(query, options.where);
198
+ query = applyExtras(query, options);
199
+ const { data, error } = (await query.select());
200
+ if (error)
201
+ throw new Error(describeQueryFailure(error));
202
+ return data ?? [];
203
+ }
204
+ /**
205
+ * Deletes rows in `table` matching `options.where` through the Supabase REST
206
+ * API, as the **anonymous** role. See {@link supabaseSelect} for when to
207
+ * use this over `withPublicDb`.
208
+ *
209
+ * @param table Table name.
210
+ * @param options `where` — filters selecting which rows to delete; required,
211
+ * since an unfiltered delete would remove every row.
212
+ * @returns The deleted rows as returned by PostgREST.
213
+ */
214
+ export async function supabaseDelete(table, options) {
215
+ return runDelete(await anonClient(), table, options);
216
+ }
217
+ /** Same as {@link supabaseDelete}, authenticated as the signed-in user — see {@link supabaseSelectAsUser}. */
218
+ export async function supabaseDeleteAsUser(table, options) {
219
+ return runDelete(await userClient(), table, options);
220
+ }
221
+ async function runDelete(client, table, options) {
222
+ requireMutationFilter(options);
223
+ let query = client.from(table).delete();
224
+ query = applyFilters(query, options.where);
225
+ query = applyExtras(query, options);
226
+ const { data, error } = (await query.select());
227
+ if (error)
228
+ throw new Error(describeQueryFailure(error));
229
+ return data ?? [];
230
+ }
231
+ /**
232
+ * Calls a Postgres function through PostgREST's `.rpc()` — the same
233
+ * mechanism `cfni_exec` itself uses, but for a function you define yourself.
234
+ * This is how to run anything the `select`/`insert`/`upsert`/`update`/
235
+ * `delete` helpers above can't express (joins, aggregates, custom logic)
236
+ * without needing `cfni_exec`/raw SQL: write a regular Postgres function,
237
+ * `grant execute` to `anon`/`authenticated`, and call it by name.
238
+ *
239
+ * As the **anonymous** role.
240
+ *
241
+ * @param fn Function name.
242
+ * @param args Named arguments, matching the function's parameter names.
243
+ */
244
+ export async function supabaseRpc(fn, args) {
245
+ return runRpc(await anonClient(), fn, args);
246
+ }
247
+ /** Same as {@link supabaseRpc}, authenticated as the signed-in user — see {@link supabaseSelectAsUser}. */
248
+ export async function supabaseRpcAsUser(fn, args) {
249
+ return runRpc(await userClient(), fn, args);
250
+ }
251
+ async function runRpc(client, fn, args) {
252
+ const { data, error } = (await client.rpc(fn, args));
253
+ if (error)
254
+ throw new Error(describeQueryFailure(error));
255
+ return data;
256
+ }
257
+ function requireMutationFilter(options) {
258
+ const hasWhere = options.where && Object.keys(options.where).length > 0;
259
+ if (!hasWhere && !options.match && !options.or) {
260
+ throw new Error('db: one of `where`/`match`/`or` is required — an unfiltered update/delete would affect every row.');
261
+ }
262
+ }
@@ -5,6 +5,7 @@ import type { SupabaseDbConfig } from '../types/types';
5
5
  */
6
6
  export type SupabaseRemoteCallback = (sql: string, params: unknown[], method: 'all' | 'execute') => Promise<{
7
7
  rows: unknown[];
8
+ rowCount?: number | null;
8
9
  }>;
9
10
  /**
10
11
  * Builds the transport Drizzle uses in Supabase mode: every generated
@@ -21,6 +22,13 @@ export type SupabaseRemoteCallback = (sql: string, params: unknown[], method: 'a
21
22
  * Rows come back as positional arrays because `pg-proxy` maps result columns
22
23
  * by index; `cfni_exec` is what guarantees that shape.
23
24
  *
25
+ * Parameters are inlined into the statement as Postgres literals (see
26
+ * {@link inlineParams}) before it is sent, rather than passed through to
27
+ * `cfni_exec` for binding — `EXECUTE ... USING` can only bind a single
28
+ * uniformly-typed value, which breaks for anything beyond one string param.
29
+ * Inlining keeps every value's real type inferable by Postgres, the same way
30
+ * a direct `pg` connection would send it.
31
+ *
24
32
  * @param supabase The `db.supabase` config block.
25
33
  * @param bearerToken Token resolved as the caller's identity — the anon key,
26
34
  * or a per-request user JWT.
@@ -1,5 +1,28 @@
1
1
  import resolveSupabaseEndpoint from './supabase_config';
2
+ import inlineParams from './inline_params';
3
+ import parseComposite from './parse_composite';
2
4
  const DEFAULT_EXEC_FUNCTION = 'cfni_exec';
5
+ /**
6
+ * `cfni_exec` returns each row as a Postgres composite-literal string (see
7
+ * {@link parseComposite}), so the JSON-decoded `data.rows` array here is a
8
+ * `string[]`, not already the `(string | null)[][]` `pg-proxy` expects —
9
+ * that positional-array shape is what this reconstructs.
10
+ */
11
+ function parseExecResult(data) {
12
+ if (Array.isArray(data))
13
+ return { rows: data.map(parseRow), rowCount: null };
14
+ if (data && typeof data === 'object' && 'rows' in data) {
15
+ const { rows, rowCount } = data;
16
+ return {
17
+ rows: Array.isArray(rows) ? rows.map(parseRow) : [],
18
+ rowCount: typeof rowCount === 'number' ? rowCount : null,
19
+ };
20
+ }
21
+ return { rows: [], rowCount: null };
22
+ }
23
+ function parseRow(row) {
24
+ return typeof row === 'string' ? parseComposite(row) : row;
25
+ }
3
26
  /**
4
27
  * Builds the transport Drizzle uses in Supabase mode: every generated
5
28
  * statement is sent through `@supabase/supabase-js`'s `.rpc()` to the
@@ -15,6 +38,13 @@ const DEFAULT_EXEC_FUNCTION = 'cfni_exec';
15
38
  * Rows come back as positional arrays because `pg-proxy` maps result columns
16
39
  * by index; `cfni_exec` is what guarantees that shape.
17
40
  *
41
+ * Parameters are inlined into the statement as Postgres literals (see
42
+ * {@link inlineParams}) before it is sent, rather than passed through to
43
+ * `cfni_exec` for binding — `EXECUTE ... USING` can only bind a single
44
+ * uniformly-typed value, which breaks for anything beyond one string param.
45
+ * Inlining keeps every value's real type inferable by Postgres, the same way
46
+ * a direct `pg` connection would send it.
47
+ *
18
48
  * @param supabase The `db.supabase` config block.
19
49
  * @param bearerToken Token resolved as the caller's identity — the anon key,
20
50
  * or a per-request user JWT.
@@ -33,10 +63,11 @@ export default function createSupabaseTransport(supabase, bearerToken) {
33
63
  }
34
64
  return async (sql, params) => {
35
65
  const client = await getClient();
36
- const { data, error } = await client.rpc(execFunction, { statement: sql, params });
66
+ const statement = inlineParams(sql, params);
67
+ const { data, error } = await client.rpc(execFunction, { statement });
37
68
  if (error)
38
69
  throw new Error(describeFailure(error, execFunction));
39
- return { rows: Array.isArray(data) ? data : [] };
70
+ return parseExecResult(data);
40
71
  };
41
72
  }
42
73
  function describeFailure(error, execFunction) {
@@ -45,5 +76,13 @@ function describeFailure(error, execFunction) {
45
76
  if (error.code === 'PGRST202') {
46
77
  return `db: Supabase rejected the query — ${error.message}. Install the ${execFunction} function from supabase/cfni_exec.sql in your database.`;
47
78
  }
79
+ // A 401 here almost always means the bearer token wasn't accepted as a
80
+ // valid JWT by PostgREST — the most common cause is a Firebase ID token
81
+ // reaching a project without Supabase third-party (Firebase) auth set up.
82
+ if (error.code === 'PGRST301' || error.code === '42501') {
83
+ return (`db: Supabase rejected the query — ${error.message}. If you are using a Firebase ` +
84
+ 'ID token as the bearer token, make sure Supabase third-party (Firebase) auth is ' +
85
+ 'configured for this project, or provide `db.getAccessToken` to resolve a Supabase-issued JWT.');
86
+ }
48
87
  return `db: Supabase rejected the query — ${error.message}.`;
49
88
  }
@@ -843,6 +843,19 @@ export interface SupabaseDbConfig {
843
843
  * `'cfni_exec'` — the function shipped in `supabase/cfni_exec.sql`.
844
844
  */
845
845
  execFunction?: string;
846
+ /**
847
+ * Whether `withPublicDb`/`withUserDb` may run arbitrary SQL through the
848
+ * `cfni_exec` Postgres function. Defaults to `true`.
849
+ *
850
+ * Set `false` if you don't want to (or can't) install `cfni_exec` in
851
+ * your database — `withPublicDb`/`withUserDb` then throw immediately in
852
+ * Supabase mode instead of failing later against PostgREST. Use
853
+ * `supabaseSelect`/`supabaseInsert`/`supabaseUpdate`/`supabaseDelete`
854
+ * instead, which call `@supabase/supabase-js`'s `.from(table)` API
855
+ * directly — no Drizzle, no raw SQL, only what PostgREST's REST API
856
+ * itself supports.
857
+ */
858
+ rawSql?: boolean;
846
859
  }
847
860
  export interface DbRoutingConfig {
848
861
  /**
package/llms.txt CHANGED
@@ -23,7 +23,7 @@ other subpath can be used.
23
23
  - `./localeStaticParams` — `generateStaticParams` helper for locale segments.
24
24
  - `./use` — `useLocale`/`useTranslations`; resolves to the RSC or client implementation automatically via the `react-server` export condition. Both throw `"... must be used within an IntlProvider"` if called outside one.
25
25
  - `./ThemeSwitcher` — optional light/dark toggle component.
26
- - `./db` — `withPublicDb(fn)` / `withUserDb(fn, uid?)` server-side Postgres/Drizzle context helpers (require `db` set on your `RoutingConfig`; direct Postgres or Supabase Data API, see below).
26
+ - `./db` — `withPublicDb(fn)` / `withUserDb(fn, uid?)` server-side Postgres/Drizzle context helpers (require `db` set on your `RoutingConfig`; direct Postgres or Supabase Data API, see below), plus `supabaseSelect`/`supabaseInsert`/`supabaseUpsert`/`supabaseUpdate`/`supabaseDelete`/`supabaseRpc` (+ `*AsUser` counterparts) — direct Supabase REST helpers, no raw SQL.
27
27
  - `./dbHelpers` — generic Drizzle SQL helper functions (`excluded`, `onConflictSet`, `ago`, `currentDate`, `windowCount`, `unnestLateral`, `ascNullsLast`, `alwaysTrue`, `lateral`, `aliasColumn`, `minOf`, `maxOf`, `roundReal`, `multiply`, `scalarFromCte`) for use with `./db`.
28
28
 
29
29
  ## `firebaseAuth*` subpaths (require `firebaseAuth` set on your `RoutingConfig`)
@@ -51,7 +51,7 @@ other subpath can be used.
51
51
  Two transports, picked by which `db` fields are set — `pg`/`drizzle-orm`/`@supabase/supabase-js` ship as dependencies and load via dynamic `import()`, so nothing bundles unless a `db` export is called.
52
52
 
53
53
  - Direct Postgres (wins if configured): `db.connectionString` — Postgres connection string, or a sync/async function returning one (resolved on each connect). The function form is the way to read a value unavailable at module scope, e.g. a Cloudflare Hyperdrive binding: `connectionString: async () => (await getCloudflareContext({ async: true })).env.HYPERDRIVE.connectionString`. There is no separate `hyperdriveBinding` option.
54
- - Supabase Data API (used only when neither of the above is set): `db.supabase` — `{ url?, anonKey?, execFunction? }` where `url`/`anonKey` each accept a string or a sync/async function returning one, defaulting `url`/`anonKey` to `NEXT_PUBLIC_SUPABASE_URL`/`NEXT_PUBLIC_SUPABASE_ANON_KEY`. Requires installing `supabase/cfni_exec.sql` (a `security invoker` SQL-exec function) in your database. No multi-statement transactions — each statement in a `withUserDb` callback is its own round-trip.
54
+ - Supabase Data API (used only when neither of the above is set): `db.supabase` — `{ url?, anonKey?, execFunction?, rawSql? }` where `url`/`anonKey` each accept a string or a sync/async function returning one, defaulting `url`/`anonKey` to `NEXT_PUBLIC_SUPABASE_URL`/`NEXT_PUBLIC_SUPABASE_ANON_KEY`. Requires installing `supabase/cfni_exec.sql` (a `security invoker` SQL-exec function, `jsonb`-returning, supports SELECT/INSERT/UPDATE/DELETE incl. RETURNING and writable CTEs) in your database — `cfni-db-codegen`/`cfni-db-install-exec` can install it for you (see below). No multi-statement transactions — each statement in a `withUserDb` callback is its own round-trip; `.transaction()` throws instead of running non-atomically. `rawSql: false` disables `withPublicDb`/`withUserDb` in Supabase mode entirely (they throw) — use the `supabaseSelect`/etc. REST helpers above instead, which never touch `cfni_exec`.
55
55
  - `db.disconnectAfterRequest` — direct-Postgres mode only: closes the pooled client once the last in-flight `withPublicDb`/`withUserDb` call of the request finishes. Defaults to `true`; set `false` to keep the connection open for the isolate's lifetime (holds a Hyperdrive slot between requests).
56
56
  - `db.authenticatedRole` — direct-Postgres mode only: Postgres role assumed inside `withUserDb`'s transaction. Defaults to `'authenticated'` (Supabase RLS convention).
57
57
  - `db.getUserId` — direct-Postgres mode only: resolves the user id injected as `request.jwt.claims->>'sub'` in `withUserDb`. Omit when `firebaseAuth` is configured — the uid is then taken automatically from the signed-in Firebase user via this package's own `getAuthUser()`.
@@ -60,6 +60,7 @@ Two transports, picked by which `db` fields are set — `pg`/`drizzle-orm`/`@sup
60
60
  - `withPublicDb(fn)` — anonymous role. Direct-Postgres mode: the request's pooled connection, no transaction, no role switch. Supabase mode: the anon key as the PostgREST bearer token. Either way, no user id is attached — RLS keyed on `auth.jwt()` denies access.
61
61
  - `withUserDb(fn, uid?)` — signed-in-user role. Direct-Postgres mode: a transaction with `set_config('request.jwt.claims', ...)` + `set local role`, `uid` resolution order explicit arg → `db.getUserId()` → Firebase auth uid → throws. Supabase mode: identity rides on the JWT from `db.getAccessToken`/Firebase instead (`uid` param is ignored), no transaction wraps the call.
62
62
  - `./dbHelpers` functions are plain Drizzle `sql`-building utilities with no config dependency — usable standalone.
63
+ - `cfni-db-codegen` binary — regenerates Drizzle models via `drizzle-kit pull`. Flags/env: `--ddl-dir`/`CFNI_DB_DDL_DIR`, `--out-dir`/`CFNI_DB_OUT_DIR`, `--out-file`/`CFNI_DB_OUT_FILE`, `--db-url`/`CODEGEN_DATABASE_URL`, `--drizzle-config`/`CFNI_DB_DRIZZLE_CONFIG`, `--rpc-dir`/`CFNI_DB_RPC_DIR`, `--tests-dir`/`CFNI_DB_TESTS_DIR`, `--force`/`CFNI_DB_FORCE_EXEC`, `--skip-exec`/`CFNI_DB_SKIP_EXEC`, `--check`. `--out-dir` is repeatable and accepts a comma-separated list, so one run generates the same schema (and `manifest.json`) into several projects; `--check` verifies every target. After a successful run it also copies `supabase/cfni_exec.sql` + its pgTAP test file into `--rpc-dir`/`--tests-dir` (siblings of `--ddl-dir` by default), gated on `db.supabase.rawSql` (read from `next.config.*`'s `@intl-config` alias; unknown → warns and assumes `true`) — skips if `rawSql: false`, skips if `--skip-exec`, skips-with-warning on a differing existing file unless `--force`. `cfni-db-install-exec` runs only this copy step, same flags, no Postgres/drizzle-kit needed.
63
64
 
64
65
  ```typescript
65
66
  // src/i18n/intl_config.ts
@@ -95,3 +96,4 @@ const rows = await withPublicDb((db) => db.select().from(bonds).limit(10));
95
96
  - `useLocale`/`useTranslations` (from `./use`) and `useAuthUser` (from `./useFirebaseAuthUser`) resolve to a Server- or Client-Component implementation automatically via the `react-server` export condition — always import from the subpath, never from an internal file path.
96
97
  - No unified `useFirebaseAuthUser` re-export inside `cloudflare-next-intl/firebaseAuth`'s barrel — that barrel can't replicate the `react-server` condition split, so import `cloudflare-next-intl/useFirebaseAuthUser` directly for the hook.
97
98
  - Missing `db` on `RoutingConfig` throws immediately from `withPublicDb`/`withUserDb`, naming the missing config field. `pg`, `drizzle-orm`, and `@supabase/supabase-js` ship as dependencies and load via dynamic `import()` — nothing to install yourself, and nothing bundles unless a `db` export is called.
99
+ - `db.supabase.rawSql: false` makes `withPublicDb`/`withUserDb` throw in Supabase mode (they need `cfni_exec`) — reach for `supabaseSelect`/`supabaseInsert`/`supabaseUpsert`/`supabaseUpdate`/`supabaseDelete`/`supabaseRpc` instead, which only support what PostgREST's REST API itself supports (no joins beyond one embedded resource, no aggregates, no arbitrary SQL).