cloudflare-next-intl 0.8.16 → 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.
package/README.md CHANGED
@@ -584,17 +584,9 @@ below for the `.transaction()` API that does.
584
584
 
585
585
  Call `.transaction(...)` on the handle `withUserDb`/`withPublicDb` hand your
586
586
  callback whenever a write needs more than one statement to succeed or fail
587
- together — same method name in both transport modes.
587
+ together — same method name and same signature in both transport modes.
588
588
 
589
- In connection-string mode this is a real Drizzle transaction: `await
590
- db.transaction(async (tx) => { await tx.insert(...); await tx.update(...); })`,
591
- and a later statement may use an earlier one's result, exactly like plain
592
- Drizzle usage.
593
-
594
- In Supabase mode there is no shared session for `.transaction()` to open, so
595
- its callback instead **builds** queries rather than executing them — call
596
- `.toSQL()` on each Drizzle query and return the array, rather than `await`ing
597
- the query directly:
589
+ To achieve mode-transparency between Postgres/connection-string mode and Supabase/REST mode, the callback **builds** queries rather than executing them directly: call `.toSQL()` on each Drizzle query and return the array, rather than `await`ing the query directly.
598
590
 
599
591
  ```typescript
600
592
  import { withUserDb } from "cloudflare-next-intl/db";
@@ -608,26 +600,13 @@ const [invitationResult, grantResult] = await withUserDb((db) =>
608
600
  );
609
601
  ```
610
602
 
611
- Every query in the array is rendered and sent to Postgres as **one**
612
- `cfni_exec_batch` call: the function runs each statement in order inside a
613
- single plpgsql call, which is itself an implicit transaction, so a failure on
614
- any statement rolls back every statement that ran before it in the same
615
- batch. `cfni_exec_batch` ships alongside `cfni_exec` in the same
616
- `supabase/cfni_exec.sql` file (installed the same way — see
617
- [Schema codegen](#schema-codegen-cfni-db-codegen) below) and is available
618
- whenever `cfni_exec` is: there is no separate config flag, and
619
- `db.supabase.rawSql: false` turns off both.
620
-
621
- Each result is the same `{ rows, rowCount }` shape a single `cfni_exec` call
622
- returns — decode rows the same way you would from `db.execute(sql\`...\`)`.
623
-
624
- `await`ing a query directly inside the Supabase-mode `.transaction()`
625
- callback (instead of calling `.toSQL()`) throws immediately, naming the
626
- mistake, rather than hanging or silently running that one statement outside
627
- the batch with no atomicity. This also means, unlike connection-string mode,
628
- a later statement in a Supabase-mode `.transaction()` callback cannot read an
629
- earlier one's result — build every statement from arguments/closures you
630
- already have.
603
+ Every query in the array is executed sequentially in a single transaction blocks/batch:
604
+ - 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.
606
+
607
+ Either way, a failure on any statement rolls back every statement that ran before it in the transaction.
608
+
609
+ Each result is the `{ rows, rowCount }` shape. Because the callback only builds queries, a later statement in a `.transaction()` callback cannot read an earlier one's result — build every statement from arguments/closures you already have. `await`ing a query directly inside `.transaction()` throws immediately to prevent running queries outside the transaction boundary.
631
610
 
632
611
  #### Supabase mode and REST translation
633
612
 
@@ -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,11 +1,12 @@
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';
7
7
  import resolveAccessToken from './access_token';
8
8
  import runTransactionBatch from './transaction_batch';
9
+ import inlineParams from './inline_params';
9
10
  const DEFAULT_ROLE = 'authenticated';
10
11
  /**
11
12
  * Resolves the user id for `withUserDb`, trying, in order: the explicit `uid`
@@ -51,6 +52,36 @@ async function supabaseDb(supabase, bearerToken) {
51
52
  },
52
53
  });
53
54
  }
55
+ /**
56
+ * Wraps a live Drizzle postgres transaction handle with a `.transaction()`
57
+ * override that mirrors the Supabase-mode batch API: `build` receives a
58
+ * build-only proxy, must return an array of `.toSQL()` objects (same shape),
59
+ * and this function executes each one sequentially on the real pg session,
60
+ * collecting `ExecResult[]`. Both modes therefore share the identical
61
+ * callback shape — callers never need to detect the transport themselves.
62
+ */
63
+ async function postgresDb(drizzleHandle, rawClient) {
64
+ return Object.assign(drizzleHandle, {
65
+ async transaction(build) {
66
+ return runPostgresTransaction(rawClient, build);
67
+ },
68
+ });
69
+ }
70
+ /**
71
+ * Postgres-mode equivalent of `runTransaction`: calls `build` with a
72
+ * build-only handle, then executes each returned query on the raw pg client
73
+ * via an inline-parameterised `query()` call and returns `ExecResult[]`.
74
+ */
75
+ async function runPostgresTransaction(rawClient, build) {
76
+ 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 });
82
+ }
83
+ return results;
84
+ }
54
85
  /**
55
86
  * Builds a Drizzle handle with no working transport, for Supabase-mode
56
87
  * `db.transaction(...)` callbacks. Query builders' `.toSQL()` never touches
@@ -100,8 +131,12 @@ export async function withPublicDb(fn) {
100
131
  }
101
132
  const client = await connectToPostgres(config, resolved.connectionString);
102
133
  try {
103
- const { drizzle } = await import('drizzle-orm/node-postgres');
104
- return await fn(drizzle(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
+ });
105
140
  }
106
141
  finally {
107
142
  disconnectPostgres(config);
@@ -153,12 +188,17 @@ export async function withUserDb(fn, uid) {
153
188
  const client = await connectToPostgres(config, resolved.connectionString);
154
189
  const role = db.authenticatedRole ?? DEFAULT_ROLE;
155
190
  try {
156
- const { drizzle } = await import('drizzle-orm/node-postgres');
157
- const { sql } = await import('drizzle-orm');
158
- return await drizzle(client).transaction(async (transaction) => {
159
- await transaction.execute(sql `select set_config('request.jwt.claims', ${JSON.stringify({ sub: userId })}, true)`);
160
- await transaction.execute(sql `set local role ${sql.raw(role)}`);
161
- return fn(transaction);
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
+ });
162
202
  });
163
203
  }
164
204
  finally {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cloudflare-next-intl",
3
- "version": "0.8.16",
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",