cloudflare-next-intl 0.8.18 → 0.8.20

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 CHANGED
@@ -467,8 +467,10 @@ export default setIntlConfig({
467
467
  Hyperdrive immediately). Set `false` to keep the connection open for the
468
468
  lifetime of the isolate — faster for a long-lived server, but it holds a
469
469
  Hyperdrive connection slot between requests.
470
- - `authenticatedRole` — Postgres role assumed inside `withUserDb`'s
471
- transaction. Defaults to `'authenticated'` (the Supabase RLS convention).
470
+ - `authenticatedRole` — Postgres role `withUserDb` switches the shared
471
+ session to for the duration of your callback (`set role`, reset once it
472
+ settles — no transaction involved). Defaults to `'authenticated'` (the
473
+ Supabase RLS convention).
472
474
  - `getUserId` — resolves the user id injected as
473
475
  `request.jwt.claims->>'sub'` inside `withUserDb`. Omit when
474
476
  `firebaseAuth` is configured — the uid then comes from this package's own
@@ -600,9 +602,9 @@ const [invitationResult, grantResult] = await withUserDb((db) =>
600
602
  );
601
603
  ```
602
604
 
603
- Every query in the array is executed sequentially in a single transaction blocks/batch:
605
+ Every query in the array is executed sequentially in a single transaction block/batch:
604
606
  - In **Supabase mode**, they are sent in one round-trip to `cfni_exec_batch` which runs them inside a single `plpgsql` block.
605
- - In **connection-string mode**, they are run sequentially over the Postgres client inside a standard Drizzle transaction.
607
+ - In **connection-string mode**, they are run sequentially over the Postgres client inside a real `BEGIN`/`COMMIT` transaction. Note this is separate from — and does not run inside — the `SET`/role switch `withUserDb` itself applies to the shared session; see [Choosing a transport](#choosing-a-transport) above.
606
608
 
607
609
  Either way, a failure on any statement rolls back every statement that ran before it in the transaction.
608
610
 
@@ -6,12 +6,17 @@ export type DbConfig = RoutingConfig<Locales, LocalePrefixMode>;
6
6
  * `withSessionLock` caller's queries can interleave with `fn`'s until it
7
7
  * settles. `serializeQueries` alone only orders individual `.query()` calls —
8
8
  * it does nothing to stop a *different* concurrent request's queries from
9
- * landing between, say, a transaction's `BEGIN`/`SET LOCAL ROLE` and its
10
- * `COMMIT` on the one `pg.Client` every request in the isolate shares. That
11
- * gap let one request's role/RLS identity leak into another's queries
12
- * whenever two requests overlapped in the same Worker isolate. Every caller
13
- * that opens a transaction, or otherwise depends on session-scoped state
14
- * (`SET LOCAL`, `set_config(..., true)`), MUST run inside this lock.
9
+ * landing between, say, `set role`/`set_config(...)` and the query that
10
+ * depends on it, on the one `pg.Client` every request in the isolate shares.
11
+ * That gap let one request's role/RLS identity leak into another's queries
12
+ * whenever two requests overlapped in the same Worker isolate and, when
13
+ * `withUserDb` used to wrap its call in a real `BEGIN`/`COMMIT` transaction,
14
+ * interleaved transaction boundaries from two overlapping callers made
15
+ * Postgres itself reject statements ("already a transaction in progress").
16
+ * `withUserDb` no longer opens a transaction on this shared client for
17
+ * exactly that reason (session-scoped `set role`/`set_config(..., false)`
18
+ * need no transaction to apply) — but every caller that still depends on
19
+ * session-scoped state on the shared client MUST run inside this lock.
15
20
  */
16
21
  export declare function withSessionLock<T>(fn: () => Promise<T>): Promise<T>;
17
22
  /**
@@ -13,12 +13,17 @@ let sessionLock = Promise.resolve();
13
13
  * `withSessionLock` caller's queries can interleave with `fn`'s until it
14
14
  * settles. `serializeQueries` alone only orders individual `.query()` calls —
15
15
  * it does nothing to stop a *different* concurrent request's queries from
16
- * landing between, say, a transaction's `BEGIN`/`SET LOCAL ROLE` and its
17
- * `COMMIT` on the one `pg.Client` every request in the isolate shares. That
18
- * gap let one request's role/RLS identity leak into another's queries
19
- * whenever two requests overlapped in the same Worker isolate. Every caller
20
- * that opens a transaction, or otherwise depends on session-scoped state
21
- * (`SET LOCAL`, `set_config(..., true)`), MUST run inside this lock.
16
+ * landing between, say, `set role`/`set_config(...)` and the query that
17
+ * depends on it, on the one `pg.Client` every request in the isolate shares.
18
+ * That gap let one request's role/RLS identity leak into another's queries
19
+ * whenever two requests overlapped in the same Worker isolate and, when
20
+ * `withUserDb` used to wrap its call in a real `BEGIN`/`COMMIT` transaction,
21
+ * interleaved transaction boundaries from two overlapping callers made
22
+ * Postgres itself reject statements ("already a transaction in progress").
23
+ * `withUserDb` no longer opens a transaction on this shared client for
24
+ * exactly that reason (session-scoped `set role`/`set_config(..., false)`
25
+ * need no transaction to apply) — but every caller that still depends on
26
+ * session-scoped state on the shared client MUST run inside this lock.
22
27
  */
23
28
  export async function withSessionLock(fn) {
24
29
  const previous = sessionLock;
@@ -110,6 +115,29 @@ export default async function connectToPostgres(config, resolved) {
110
115
  connectionString = resolved ?? await resolveConnectionString(db);
111
116
  const { Client } = await import('pg');
112
117
  const created = serializeQueries(new Client({ connectionString }));
118
+ // The shared client outlives a single request (see the module doc)
119
+ // and Hyperdrive/Postgres can close its idle socket at any time. `pg`
120
+ // surfaces that as an `'error'` event on the `Client`, which is an
121
+ // `EventEmitter` — with no listener, Node treats it as unhandled and
122
+ // throws, crashing whatever unrelated request happens to be running
123
+ // in the isolate at that moment. Listening here converts it into a
124
+ // clean reset so the next call reconnects instead.
125
+ created.on('error', (error) => {
126
+ if (client === created) {
127
+ client = null;
128
+ connectionString = null;
129
+ connectionPromise = null;
130
+ }
131
+ // `pg` emits "Connection terminated"/"Connection terminated
132
+ // unexpectedly" (lib/client.js) — never "Connection closed" —
133
+ // when the idle socket dies outside a query. That's the
134
+ // expected shape of this event (Hyperdrive/Postgres recycling
135
+ // the connection, not a query failure): swallow it entirely,
136
+ // don't report or log it.
137
+ if (/connection terminated/i.test(error.message))
138
+ return;
139
+ void reportError({ errorHandling: config.errorHandling, generate: config.generate }, { error, classOrMethodName: 'db.connectToPostgres.clientError' });
140
+ });
113
141
  client = created;
114
142
  await created.connect();
115
143
  return created;
@@ -31,22 +31,28 @@ export declare function withPublicDb<T>(fn: (db: DrizzleDb) => Promise<T>): Prom
31
31
  /**
32
32
  * Runs a query as the **signed-in user**.
33
33
  *
34
- * In connection-string mode this runs inside a transaction where Postgres
35
- * sees the resolved user id as `auth.jwt()->>'sub'` under
36
- * `db.authenticatedRole`, so RLS policies behave exactly as they do for a
37
- * PostgREST-issued call. In Supabase mode identity instead rides on the JWT
38
- * sent as `Authorization: Bearer` PostgREST resolves the `authenticated`
39
- * role and populates `request.jwt.claims` itself, and each statement is its
40
- * own round-trip with no cross-statement transaction unless you call
41
- * `.transaction(...)` on the handle (the Postgres proxy Drizzle uses in this
42
- * mode cannot open a real session, so that runs as one atomic
34
+ * In connection-string mode this sets the resolved user id as
35
+ * `auth.jwt()->>'sub'` and switches to `db.authenticatedRole` on the
36
+ * request's shared session (via `set_config(..., false)`/`set role`, reset
37
+ * once `fn` settles), so RLS policies behave exactly as they do for a
38
+ * PostgREST-issued call but it does NOT open a `BEGIN`/`COMMIT`
39
+ * transaction: that client is shared across every concurrent caller in the
40
+ * isolate (see `connection.ts`), and a live transaction is itself
41
+ * session-scoped state that a second overlapping caller's transaction would
42
+ * collide with. Call `fn`'s own `.transaction(...)` (below) for atomicity
43
+ * across statements instead. In Supabase mode identity instead rides on the
44
+ * JWT sent as `Authorization: Bearer` — PostgREST resolves the
45
+ * `authenticated` role and populates `request.jwt.claims` itself, and each
46
+ * statement is its own round-trip with no cross-statement transaction unless
47
+ * you call `.transaction(...)` on the handle (the Postgres proxy Drizzle
48
+ * uses in this mode cannot open a real session, so that runs as one atomic
43
49
  * `cfni_exec_batch` call instead — see the module doc). Either way this is
44
50
  * the wrapper to use for anything user-owned.
45
51
  *
46
- * @param fn Receives the Drizzle handle. In connection-string mode it is
47
- * also bound to a transaction; in Supabase mode it is not, but its own
48
- * `.transaction(...)` still provides atomicity across statements there
49
- * (build-and-return shape, not a live session — see the module doc).
52
+ * @param fn Receives the Drizzle handle, scoped to the caller's identity/role
53
+ * but not wrapped in a transaction call its own `.transaction(...)` for
54
+ * atomicity across statements (build-and-return shape in both modes, not a
55
+ * live session — see the module doc).
50
56
  * @param uid Connection-string mode only: overrides the user id. Omit it, or
51
57
  * pass `null`, in normal use — either way the id then comes from
52
58
  * `db.getUserId()` when set, otherwise from the signed-in Firebase user when
@@ -70,17 +70,32 @@ async function postgresDb(drizzleHandle, rawClient) {
70
70
  /**
71
71
  * Postgres-mode equivalent of `runTransaction`: calls `build` with a
72
72
  * build-only handle, then executes each returned query on the raw pg client
73
- * via an inline-parameterised `query()` call and returns `ExecResult[]`.
73
+ * via an inline-parameterised `query()` call, wrapped in a real
74
+ * `BEGIN`/`COMMIT` for atomicity, and returns `ExecResult[]`.
75
+ *
76
+ * Safe to open a transaction here — unlike `withUserDb`'s own body, this
77
+ * only ever runs from inside `fn`, which `withUserDb`/`withPublicDb` already
78
+ * call from within `withSessionLock` (`connection.ts`). That lock is what
79
+ * keeps this `BEGIN`...`COMMIT` from overlapping with another caller's on the
80
+ * same shared client, so nothing here needs to reason about interleaving.
74
81
  */
75
82
  async function runPostgresTransaction(rawClient, build) {
76
83
  const queries = await build(buildOnlyDb());
77
- const results = [];
78
- for (const q of queries) {
79
- const statement = inlineParams(q.sql, q.params);
80
- const res = await rawClient.query(statement);
81
- results.push({ rows: res.rows ?? [], rowCount: res.rowCount ?? null });
84
+ await rawClient.query('begin');
85
+ try {
86
+ const results = [];
87
+ for (const q of queries) {
88
+ const statement = inlineParams(q.sql, q.params);
89
+ const res = await rawClient.query(statement);
90
+ results.push({ rows: res.rows ?? [], rowCount: res.rowCount ?? null });
91
+ }
92
+ await rawClient.query('commit');
93
+ return results;
94
+ }
95
+ catch (error) {
96
+ await rawClient.query('rollback');
97
+ throw error;
82
98
  }
83
- return results;
84
99
  }
85
100
  /**
86
101
  * Builds a Drizzle handle with no working transport, for Supabase-mode
@@ -145,22 +160,28 @@ export async function withPublicDb(fn) {
145
160
  /**
146
161
  * Runs a query as the **signed-in user**.
147
162
  *
148
- * In connection-string mode this runs inside a transaction where Postgres
149
- * sees the resolved user id as `auth.jwt()->>'sub'` under
150
- * `db.authenticatedRole`, so RLS policies behave exactly as they do for a
151
- * PostgREST-issued call. In Supabase mode identity instead rides on the JWT
152
- * sent as `Authorization: Bearer` PostgREST resolves the `authenticated`
153
- * role and populates `request.jwt.claims` itself, and each statement is its
154
- * own round-trip with no cross-statement transaction unless you call
155
- * `.transaction(...)` on the handle (the Postgres proxy Drizzle uses in this
156
- * mode cannot open a real session, so that runs as one atomic
163
+ * In connection-string mode this sets the resolved user id as
164
+ * `auth.jwt()->>'sub'` and switches to `db.authenticatedRole` on the
165
+ * request's shared session (via `set_config(..., false)`/`set role`, reset
166
+ * once `fn` settles), so RLS policies behave exactly as they do for a
167
+ * PostgREST-issued call but it does NOT open a `BEGIN`/`COMMIT`
168
+ * transaction: that client is shared across every concurrent caller in the
169
+ * isolate (see `connection.ts`), and a live transaction is itself
170
+ * session-scoped state that a second overlapping caller's transaction would
171
+ * collide with. Call `fn`'s own `.transaction(...)` (below) for atomicity
172
+ * across statements instead. In Supabase mode identity instead rides on the
173
+ * JWT sent as `Authorization: Bearer` — PostgREST resolves the
174
+ * `authenticated` role and populates `request.jwt.claims` itself, and each
175
+ * statement is its own round-trip with no cross-statement transaction unless
176
+ * you call `.transaction(...)` on the handle (the Postgres proxy Drizzle
177
+ * uses in this mode cannot open a real session, so that runs as one atomic
157
178
  * `cfni_exec_batch` call instead — see the module doc). Either way this is
158
179
  * the wrapper to use for anything user-owned.
159
180
  *
160
- * @param fn Receives the Drizzle handle. In connection-string mode it is
161
- * also bound to a transaction; in Supabase mode it is not, but its own
162
- * `.transaction(...)` still provides atomicity across statements there
163
- * (build-and-return shape, not a live session — see the module doc).
181
+ * @param fn Receives the Drizzle handle, scoped to the caller's identity/role
182
+ * but not wrapped in a transaction call its own `.transaction(...)` for
183
+ * atomicity across statements (build-and-return shape in both modes, not a
184
+ * live session — see the module doc).
164
185
  * @param uid Connection-string mode only: overrides the user id. Omit it, or
165
186
  * pass `null`, in normal use — either way the id then comes from
166
187
  * `db.getUserId()` when set, otherwise from the signed-in Firebase user when
@@ -189,16 +210,34 @@ export async function withUserDb(fn, uid) {
189
210
  const role = db.authenticatedRole ?? DEFAULT_ROLE;
190
211
  try {
191
212
  return await withSessionLock(async () => {
213
+ // No `db.transaction()`/BEGIN here on purpose: this client is shared
214
+ // across every concurrent caller in the isolate (see connection.ts),
215
+ // and a live Postgres transaction is session state — `BEGIN`,
216
+ // `SET LOCAL ROLE`, and `COMMIT`/`ROLLBACK` all apply to whichever
217
+ // statement runs next on the socket, not to `fn`'s call site. Two
218
+ // overlapping `withUserDb` calls each opening their own transaction
219
+ // interleaved their BEGIN/SET LOCAL/COMMIT on one socket — Postgres
220
+ // then rejected the interleaved statements ("already a transaction
221
+ // in progress", "no transaction in progress"), which is exactly the
222
+ // bug `withSessionLock` alone could not fully close. Session-scoped
223
+ // `set_config(..., true)` (`true` = for the rest of the session, not
224
+ // just a transaction) and a plain `set role` need no transaction to
225
+ // apply, and `withSessionLock` already keeps this whole block
226
+ // exclusive against every other caller, so atomicity is unaffected.
227
+ const rawClient = client;
228
+ await rawClient.query(`select set_config('request.jwt.claims', $1, false)`, [JSON.stringify({ sub: userId })]);
229
+ await rawClient.query(`set role "${role}"`);
192
230
  const { drizzle } = await import('drizzle-orm/node-postgres');
193
- const { sql } = await import('drizzle-orm');
194
- return await drizzle(client).transaction(async (transaction) => {
195
- await transaction.execute(sql `select set_config('request.jwt.claims', ${JSON.stringify({ sub: userId })}, true)`);
196
- await transaction.execute(sql `set local role ${sql.raw(role)}`);
197
- // The transaction handle's session.client is the live pg socket — use it directly.
198
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
199
- const txClient = transaction.session?.client ?? client;
200
- return fn(await postgresDb(transaction, txClient));
201
- });
231
+ const drizzleHandle = drizzle(client);
232
+ try {
233
+ return await fn(await postgresDb(drizzleHandle, rawClient));
234
+ }
235
+ finally {
236
+ // Always hand the shared socket back to the default role —
237
+ // otherwise the next caller (public or a different user) could
238
+ // inherit this request's elevated/authenticated role.
239
+ await rawClient.query('reset role');
240
+ }
202
241
  });
203
242
  }
204
243
  finally {
@@ -34,7 +34,8 @@ import { type ReportErrorConfig } from './report_error';
34
34
  * `config.errorHandling.ignoreConsoleErrors` (default
35
35
  * `defaultIgnoredConsoleErrors` — this package's own Firebase Auth error
36
36
  * codes for expected user-input failures) and `ignoreConsoleError` both
37
- * skip reporting a matching call while still logging it normally.
37
+ * skip reporting a matching call while still logging it normally — checked
38
+ * inside `reportError` itself, so this override doesn't duplicate the check.
38
39
  *
39
40
  * @param config Pass the relevant slices of your `RoutingConfig` directly —
40
41
  * `{ errorHandling: config.errorHandling, generate: config.generate }`.
@@ -1,6 +1,4 @@
1
1
  import reportError, { consoleOverrideState } from './report_error';
2
- import stringifyUnknown from './stringify_unknown';
3
- import { defaultIgnoredConsoleErrors } from './default_ignored_console_errors';
4
2
  /**
5
3
  * Replaces the global `console.error` so every `console.error(...)` call is
6
4
  * also routed through `config.errorHandling.onError`/`reportError` — the
@@ -36,7 +34,8 @@ import { defaultIgnoredConsoleErrors } from './default_ignored_console_errors';
36
34
  * `config.errorHandling.ignoreConsoleErrors` (default
37
35
  * `defaultIgnoredConsoleErrors` — this package's own Firebase Auth error
38
36
  * codes for expected user-input failures) and `ignoreConsoleError` both
39
- * skip reporting a matching call while still logging it normally.
37
+ * skip reporting a matching call while still logging it normally — checked
38
+ * inside `reportError` itself, so this override doesn't duplicate the check.
40
39
  *
41
40
  * @param config Pass the relevant slices of your `RoutingConfig` directly —
42
41
  * `{ errorHandling: config.errorHandling, generate: config.generate }`.
@@ -58,12 +57,6 @@ export default function installConsoleErrorOverride(config, isClient) {
58
57
  if (!suppressOnClient) {
59
58
  originalConsoleError(message, ...optionalParams);
60
59
  }
61
- const stringified = stringifyUnknown(message, isClient);
62
- const ignoreList = config.errorHandling?.ignoreConsoleErrors ?? defaultIgnoredConsoleErrors;
63
- if (ignoreList.some((ignored) => stringified.includes(ignored)))
64
- return;
65
- if (config.errorHandling?.ignoreConsoleError?.(stringified))
66
- return;
67
60
  void reportError(config, { error: message, classOrMethodName: 'Global Console Error Handler', params: optionalParams, isClient });
68
61
  };
69
62
  override.__isErrorHandlingOverride = true;
@@ -1,5 +1,6 @@
1
1
  import formatErrorMessage from './format_error_message';
2
2
  import stringifyUnknown from './stringify_unknown';
3
+ import { defaultIgnoredConsoleErrors } from './default_ignored_console_errors';
3
4
  const DEFAULT_THROTTLE_MS = 5000;
4
5
  // Set by `installConsoleErrorOverride` once it patches `console.error`.
5
6
  // When active, THAT override is the sole place that ever calls the real
@@ -105,6 +106,18 @@ export default async function reportError(config, params) {
105
106
  return;
106
107
  if (params.consent !== undefined && params.consent !== true)
107
108
  return;
109
+ // `ignoreConsoleErrors`/`ignoreConsoleError` used to only be consulted by
110
+ // `installConsoleErrorOverride`'s patched `console.error` — any direct
111
+ // `reportError`/`reportClientError` call (a caught DB/query error, an
112
+ // error boundary's `reportClientError(error, ...)`, etc.) skipped this
113
+ // check entirely and always reached `onError`. Checking it here instead
114
+ // makes every path share the one ignore list.
115
+ const stringified = stringifyUnknown(params.error, params.isClient);
116
+ const ignoreList = errorHandling?.ignoreConsoleErrors ?? defaultIgnoredConsoleErrors;
117
+ if (ignoreList.some((ignored) => stringified.includes(ignored)))
118
+ return;
119
+ if (errorHandling?.ignoreConsoleError?.(stringified))
120
+ return;
108
121
  if (errorHandling?.dedup !== false) {
109
122
  const throttleMs = errorHandling?.throttleMs ?? DEFAULT_THROTTLE_MS;
110
123
  const dedupKey = buildDedupKey(params);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cloudflare-next-intl",
3
- "version": "0.8.18",
3
+ "version": "0.8.20",
4
4
  "description": "Optimized Next Intl Package Special for App Router and Cloudflare",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",