cloudflare-next-intl 0.8.17 → 0.8.19
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 +6 -4
- package/dist/src/db/connection.d.ts +18 -0
- package/dist/src/db/connection.js +57 -25
- package/dist/src/db/context.d.ts +19 -13
- package/dist/src/db/context.js +77 -34
- package/package.json +1 -1
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
|
|
471
|
-
|
|
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
|
|
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
|
|
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
|
|
|
@@ -1,6 +1,24 @@
|
|
|
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, `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.
|
|
20
|
+
*/
|
|
21
|
+
export declare function withSessionLock<T>(fn: () => Promise<T>): Promise<T>;
|
|
4
22
|
/**
|
|
5
23
|
* Forgets the cached client and connection string so the next
|
|
6
24
|
* `connectToPostgres` call builds both from scratch. Intended for tests and
|
|
@@ -5,9 +5,38 @@ 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, `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.
|
|
27
|
+
*/
|
|
28
|
+
export async function withSessionLock(fn) {
|
|
29
|
+
const previous = sessionLock;
|
|
30
|
+
let release;
|
|
31
|
+
sessionLock = new Promise((resolve) => { release = resolve; });
|
|
32
|
+
await previous;
|
|
33
|
+
try {
|
|
34
|
+
return await fn();
|
|
35
|
+
}
|
|
36
|
+
finally {
|
|
37
|
+
release();
|
|
38
|
+
}
|
|
39
|
+
}
|
|
11
40
|
/**
|
|
12
41
|
* Forgets the cached client and connection string so the next
|
|
13
42
|
* `connectToPostgres` call builds both from scratch. Intended for tests and
|
|
@@ -21,9 +50,9 @@ export function resetConnectionState() {
|
|
|
21
50
|
connectionString = null;
|
|
22
51
|
client = null;
|
|
23
52
|
connectionPromise = null;
|
|
24
|
-
connectingPromise = null;
|
|
25
53
|
disconnectionPromise = null;
|
|
26
54
|
activeUsers = 0;
|
|
55
|
+
sessionLock = Promise.resolve();
|
|
27
56
|
}
|
|
28
57
|
/**
|
|
29
58
|
* Resolves the connection string from `db.connectionString`, awaiting it when
|
|
@@ -76,31 +105,35 @@ export default async function connectToPostgres(config, resolved) {
|
|
|
76
105
|
const db = config.db;
|
|
77
106
|
requireDbConfig(db);
|
|
78
107
|
await disconnectionPromise;
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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
|
-
}
|
|
108
|
+
// Guards the race between concurrent callers that both see `client ===
|
|
109
|
+
// null` before either has awaited anything: `connectionPromise` is set
|
|
110
|
+
// synchronously (before the `await`s below) so every caller in the same
|
|
111
|
+
// microtask tick shares the one client being created instead of each
|
|
112
|
+
// starting its own.
|
|
113
|
+
if (connectionPromise === null) {
|
|
114
|
+
connectionPromise = (async () => {
|
|
115
|
+
connectionString = resolved ?? await resolveConnectionString(db);
|
|
116
|
+
const { Client } = await import('pg');
|
|
117
|
+
const created = serializeQueries(new Client({ connectionString }));
|
|
118
|
+
client = created;
|
|
119
|
+
await created.connect();
|
|
120
|
+
return created;
|
|
100
121
|
})();
|
|
101
122
|
}
|
|
102
123
|
activeUsers++;
|
|
103
|
-
|
|
124
|
+
try {
|
|
125
|
+
return await connectionPromise;
|
|
126
|
+
}
|
|
127
|
+
catch (error) {
|
|
128
|
+
// A failed connect must not be cached forever — clear state so the
|
|
129
|
+
// next call retries instead of replaying the same rejection for the
|
|
130
|
+
// life of the Worker isolate.
|
|
131
|
+
activeUsers = Math.max(0, activeUsers - 1);
|
|
132
|
+
connectionString = null;
|
|
133
|
+
client = null;
|
|
134
|
+
connectionPromise = null;
|
|
135
|
+
throw error;
|
|
136
|
+
}
|
|
104
137
|
}
|
|
105
138
|
/**
|
|
106
139
|
* Releases one caller's hold on the shared client, closing it once the last
|
|
@@ -127,7 +160,6 @@ export function disconnectPostgres(config) {
|
|
|
127
160
|
// instead of reusing one that is about to close.
|
|
128
161
|
client = null;
|
|
129
162
|
connectionPromise = null;
|
|
130
|
-
connectingPromise = null;
|
|
131
163
|
const endPromise = closing.end();
|
|
132
164
|
disconnectionPromise = endPromise;
|
|
133
165
|
const timeoutMs = db.disconnectTimeoutMs ?? DEFAULT_DISCONNECT_TIMEOUT_MS;
|
package/dist/src/db/context.d.ts
CHANGED
|
@@ -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
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
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
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
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
|
package/dist/src/db/context.js
CHANGED
|
@@ -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';
|
|
@@ -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
|
|
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
|
-
|
|
78
|
-
|
|
79
|
-
const
|
|
80
|
-
const
|
|
81
|
-
|
|
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
|
|
@@ -131,10 +146,12 @@ export async function withPublicDb(fn) {
|
|
|
131
146
|
}
|
|
132
147
|
const client = await connectToPostgres(config, resolved.connectionString);
|
|
133
148
|
try {
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
149
|
+
return await withSessionLock(async () => {
|
|
150
|
+
const { drizzle } = await import('drizzle-orm/node-postgres');
|
|
151
|
+
const drizzleHandle = drizzle(client);
|
|
152
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
153
|
+
return await fn(await postgresDb(drizzleHandle, client));
|
|
154
|
+
});
|
|
138
155
|
}
|
|
139
156
|
finally {
|
|
140
157
|
disconnectPostgres(config);
|
|
@@ -143,22 +160,28 @@ export async function withPublicDb(fn) {
|
|
|
143
160
|
/**
|
|
144
161
|
* Runs a query as the **signed-in user**.
|
|
145
162
|
*
|
|
146
|
-
* In connection-string mode this
|
|
147
|
-
*
|
|
148
|
-
*
|
|
149
|
-
*
|
|
150
|
-
*
|
|
151
|
-
*
|
|
152
|
-
*
|
|
153
|
-
*
|
|
154
|
-
*
|
|
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
|
|
155
178
|
* `cfni_exec_batch` call instead — see the module doc). Either way this is
|
|
156
179
|
* the wrapper to use for anything user-owned.
|
|
157
180
|
*
|
|
158
|
-
* @param fn Receives the Drizzle handle
|
|
159
|
-
*
|
|
160
|
-
*
|
|
161
|
-
*
|
|
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).
|
|
162
185
|
* @param uid Connection-string mode only: overrides the user id. Omit it, or
|
|
163
186
|
* pass `null`, in normal use — either way the id then comes from
|
|
164
187
|
* `db.getUserId()` when set, otherwise from the signed-in Firebase user when
|
|
@@ -186,15 +209,35 @@ export async function withUserDb(fn, uid) {
|
|
|
186
209
|
const client = await connectToPostgres(config, resolved.connectionString);
|
|
187
210
|
const role = db.authenticatedRole ?? DEFAULT_ROLE;
|
|
188
211
|
try {
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
//
|
|
195
|
-
//
|
|
196
|
-
|
|
197
|
-
|
|
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}"`);
|
|
230
|
+
const { drizzle } = await import('drizzle-orm/node-postgres');
|
|
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
|
+
}
|
|
198
241
|
});
|
|
199
242
|
}
|
|
200
243
|
finally {
|