cloudflare-next-intl 0.8.17 → 0.8.18

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.
@@ -1,6 +1,19 @@
1
1
  import type { Client } from 'pg';
2
2
  import type { LocalePrefixMode, Locales, RoutingConfig } from '../types/types';
3
3
  export type DbConfig = RoutingConfig<Locales, LocalePrefixMode>;
4
+ /**
5
+ * Runs `fn` exclusively against the shared client: no other
6
+ * `withSessionLock` caller's queries can interleave with `fn`'s until it
7
+ * settles. `serializeQueries` alone only orders individual `.query()` calls —
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.
15
+ */
16
+ export declare function withSessionLock<T>(fn: () => Promise<T>): Promise<T>;
4
17
  /**
5
18
  * Forgets the cached client and connection string so the next
6
19
  * `connectToPostgres` call builds both from scratch. Intended for tests and
@@ -5,9 +5,33 @@ const DEFAULT_DISCONNECT_TIMEOUT_MS = 2000;
5
5
  let connectionString = null;
6
6
  let client = null;
7
7
  let connectionPromise = null;
8
- let connectingPromise = null;
9
8
  let disconnectionPromise = null;
10
9
  let activeUsers = 0;
10
+ let sessionLock = Promise.resolve();
11
+ /**
12
+ * Runs `fn` exclusively against the shared client: no other
13
+ * `withSessionLock` caller's queries can interleave with `fn`'s until it
14
+ * settles. `serializeQueries` alone only orders individual `.query()` calls —
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.
22
+ */
23
+ export async function withSessionLock(fn) {
24
+ const previous = sessionLock;
25
+ let release;
26
+ sessionLock = new Promise((resolve) => { release = resolve; });
27
+ await previous;
28
+ try {
29
+ return await fn();
30
+ }
31
+ finally {
32
+ release();
33
+ }
34
+ }
11
35
  /**
12
36
  * Forgets the cached client and connection string so the next
13
37
  * `connectToPostgres` call builds both from scratch. Intended for tests and
@@ -21,9 +45,9 @@ export function resetConnectionState() {
21
45
  connectionString = null;
22
46
  client = null;
23
47
  connectionPromise = null;
24
- connectingPromise = null;
25
48
  disconnectionPromise = null;
26
49
  activeUsers = 0;
50
+ sessionLock = Promise.resolve();
27
51
  }
28
52
  /**
29
53
  * Resolves the connection string from `db.connectionString`, awaiting it when
@@ -76,31 +100,35 @@ export default async function connectToPostgres(config, resolved) {
76
100
  const db = config.db;
77
101
  requireDbConfig(db);
78
102
  await disconnectionPromise;
79
- if (connectingPromise === null) {
80
- connectingPromise = (async () => {
81
- try {
82
- connectionString ?? (connectionString = resolved ?? await resolveConnectionString(db));
83
- const { Client } = await import('pg');
84
- const created = serializeQueries(new Client({ connectionString }));
85
- client = created;
86
- connectionPromise = created.connect();
87
- await connectionPromise;
88
- return created;
89
- }
90
- catch (error) {
91
- // A failed connect must not be cached forever — clear state so the
92
- // next call retries instead of replaying the same rejection for the
93
- // life of the Worker isolate.
94
- connectingPromise = null;
95
- connectionString = null;
96
- client = null;
97
- connectionPromise = null;
98
- throw error;
99
- }
103
+ // Guards the race between concurrent callers that both see `client ===
104
+ // null` before either has awaited anything: `connectionPromise` is set
105
+ // synchronously (before the `await`s below) so every caller in the same
106
+ // microtask tick shares the one client being created instead of each
107
+ // starting its own.
108
+ if (connectionPromise === null) {
109
+ connectionPromise = (async () => {
110
+ connectionString = resolved ?? await resolveConnectionString(db);
111
+ const { Client } = await import('pg');
112
+ const created = serializeQueries(new Client({ connectionString }));
113
+ client = created;
114
+ await created.connect();
115
+ return created;
100
116
  })();
101
117
  }
102
118
  activeUsers++;
103
- return connectingPromise;
119
+ try {
120
+ return await connectionPromise;
121
+ }
122
+ catch (error) {
123
+ // A failed connect must not be cached forever — clear state so the
124
+ // next call retries instead of replaying the same rejection for the
125
+ // life of the Worker isolate.
126
+ activeUsers = Math.max(0, activeUsers - 1);
127
+ connectionString = null;
128
+ client = null;
129
+ connectionPromise = null;
130
+ throw error;
131
+ }
104
132
  }
105
133
  /**
106
134
  * Releases one caller's hold on the shared client, closing it once the last
@@ -127,7 +155,6 @@ export function disconnectPostgres(config) {
127
155
  // instead of reusing one that is about to close.
128
156
  client = null;
129
157
  connectionPromise = null;
130
- connectingPromise = null;
131
158
  const endPromise = closing.end();
132
159
  disconnectionPromise = endPromise;
133
160
  const timeoutMs = db.disconnectTimeoutMs ?? DEFAULT_DISCONNECT_TIMEOUT_MS;
@@ -1,6 +1,6 @@
1
1
  import config from '../config/intl_config';
2
2
  import requireDbConfig from './require_config';
3
- import connectToPostgres, { disconnectPostgres } from './connection';
3
+ import connectToPostgres, { disconnectPostgres, withSessionLock } from './connection';
4
4
  import resolveDbMode from './resolve_mode';
5
5
  import resolveSupabaseEndpoint from './supabase_config';
6
6
  import createSupabaseTransport from './supabase_transport';
@@ -131,10 +131,12 @@ export async function withPublicDb(fn) {
131
131
  }
132
132
  const client = await connectToPostgres(config, resolved.connectionString);
133
133
  try {
134
- const { drizzle } = await import('drizzle-orm/node-postgres');
135
- const drizzleHandle = drizzle(client);
136
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
137
- return await fn(await postgresDb(drizzleHandle, client));
134
+ return await withSessionLock(async () => {
135
+ const { drizzle } = await import('drizzle-orm/node-postgres');
136
+ const drizzleHandle = drizzle(client);
137
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
138
+ return await fn(await postgresDb(drizzleHandle, client));
139
+ });
138
140
  }
139
141
  finally {
140
142
  disconnectPostgres(config);
@@ -186,15 +188,17 @@ export async function withUserDb(fn, uid) {
186
188
  const client = await connectToPostgres(config, resolved.connectionString);
187
189
  const role = db.authenticatedRole ?? DEFAULT_ROLE;
188
190
  try {
189
- const { drizzle } = await import('drizzle-orm/node-postgres');
190
- const { sql } = await import('drizzle-orm');
191
- return await drizzle(client).transaction(async (transaction) => {
192
- await transaction.execute(sql `select set_config('request.jwt.claims', ${JSON.stringify({ sub: userId })}, true)`);
193
- await transaction.execute(sql `set local role ${sql.raw(role)}`);
194
- // The transaction handle's session.client is the live pg socket — use it directly.
195
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
196
- const txClient = transaction.session?.client ?? client;
197
- return fn(await postgresDb(transaction, txClient));
191
+ return await withSessionLock(async () => {
192
+ 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
+ });
198
202
  });
199
203
  }
200
204
  finally {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cloudflare-next-intl",
3
- "version": "0.8.17",
3
+ "version": "0.8.18",
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",