cloudflare-next-intl 0.8.30 → 0.8.32

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,11 @@ export default setIntlConfig({
467
467
  server-side connection.
468
468
  - `authenticatedRole` — Postgres role `withUserDb` switches its
469
469
  call-scoped session to for the duration of your callback (`set role`, no
470
- transaction involved; the session is closed when the call ends). Defaults
471
- to `'authenticated'` (the Supabase RLS convention).
470
+ transaction involved; the session is closed when the call ends). May be a string or
471
+ a sync/async function returning a string. Defaults to `'authenticated'` (the Supabase RLS convention).
472
+ - `authenticatedRoleClaim` — Firebase custom claims field (default `'role'`) read
473
+ to determine the Postgres role inside `withUserDb` when `firebaseAuth` is enabled.
474
+ Set `false` to disable custom claim inspection and rely on `authenticatedRole`.
472
475
  - `getUserId` — resolves the user id injected as
473
476
  `request.jwt.claims->>'sub'` inside `withUserDb`. Omit when
474
477
  `firebaseAuth` is configured — the uid then comes from this package's own
@@ -31,6 +31,30 @@ async function resolveUserId(uid) {
31
31
  throw new Error('db: withUserDb could not resolve a user id. Pass one explicitly, set ' +
32
32
  '`db.getUserId`, or configure `firebaseAuth` so the signed-in Firebase uid is used.');
33
33
  }
34
+ /**
35
+ * Resolves the Postgres role for `withUserDb`'s session. When `firebaseAuth`
36
+ * is configured and `db.authenticatedRoleClaim` isn't `false`, the signed-in
37
+ * user's Firebase ID token claim (default field `'role'`) wins when present;
38
+ * otherwise falls back to `db.authenticatedRole` (string or sync/async
39
+ * function), then `DEFAULT_ROLE`.
40
+ */
41
+ async function resolveAuthenticatedRole(db) {
42
+ const claimField = db.authenticatedRoleClaim;
43
+ if (config.firebaseAuth && claimField !== false) {
44
+ const { getAuthUser } = await import('../firebase_auth/server/use_auth_user_server');
45
+ const { user } = await getAuthUser();
46
+ if (user && typeof user.getIdTokenResult === 'function') {
47
+ const { claims } = await user.getIdTokenResult();
48
+ const claimValue = claims[claimField ?? 'role'];
49
+ if (typeof claimValue === 'string' && claimValue)
50
+ return claimValue;
51
+ }
52
+ }
53
+ if (db.authenticatedRole) {
54
+ return typeof db.authenticatedRole === 'function' ? await db.authenticatedRole() : db.authenticatedRole;
55
+ }
56
+ return DEFAULT_ROLE;
57
+ }
34
58
  /**
35
59
  * Builds a Drizzle handle backed by PostgREST. `bearerToken` decides the role
36
60
  * Postgres sees: the anon key for public access, a user JWT for `withUserDb`.
@@ -134,6 +158,21 @@ export async function withPublicDb(fn) {
134
158
  return await fn(await postgresDb(drizzleHandle, client));
135
159
  });
136
160
  }
161
+ function injectUidComment(sql, userId) {
162
+ if (typeof sql === 'string') {
163
+ if (/^(select|with)\b/i.test(sql.trimStart())) {
164
+ return `/* uid:${userId} */ ${sql}`;
165
+ }
166
+ return sql;
167
+ }
168
+ if (sql && typeof sql === 'object' && typeof sql.text === 'string') {
169
+ const obj = sql;
170
+ if (/^(select|with)\b/i.test(obj.text.trimStart())) {
171
+ return { ...obj, text: `/* uid:${userId} */ ${obj.text}` };
172
+ }
173
+ }
174
+ return sql;
175
+ }
137
176
  /**
138
177
  * Runs a query as the **signed-in user**, with `request.jwt.claims` and the
139
178
  * authenticated role set on the session so RLS policies apply to their id.
@@ -153,14 +192,101 @@ export async function withUserDb(fn, uid) {
153
192
  return fn(await supabaseDb(resolved.supabase, token));
154
193
  }
155
194
  const userId = await resolveUserId(uid);
156
- const role = db.authenticatedRole ?? DEFAULT_ROLE;
195
+ const role = await resolveAuthenticatedRole(db);
157
196
  return await withDbClient(config, async (client) => {
158
197
  const rawClient = client;
159
- await rawClient.query(`select set_config('request.jwt.claims', $1, false)`, [JSON.stringify({ sub: userId })]);
160
- await rawClient.query(`set role "${role}"`);
198
+ const setSessionState = async () => {
199
+ await rawClient.query(`select set_config('request.jwt.claims', $1, false)`, [JSON.stringify({ sub: userId })]);
200
+ await rawClient.query(`set role "${role.replace(/"/g, '""')}"`);
201
+ };
202
+ const isSelectOnly = (sql) => {
203
+ const text = typeof sql === 'string' ? sql : sql?.text;
204
+ return typeof text === 'string' && /^(select|with)\b/i.test(text.trimStart());
205
+ };
206
+ let inTransaction = false;
207
+ let sessionStateSet = false;
208
+ let gate = Promise.resolve();
209
+ const serialize = (op) => {
210
+ const run = gate.then(op, op);
211
+ gate = run.catch(() => undefined);
212
+ return run;
213
+ };
214
+ const interceptingClient = new Proxy(client, {
215
+ get(target, prop) {
216
+ if (prop === 'query') {
217
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
218
+ return async (sql, ...args) => {
219
+ const text = typeof sql === 'string' ? sql : (typeof sql?.text === 'string' ? sql.text : '');
220
+ const isBegin = /^begin\b/i.test(text.trimStart());
221
+ const isCommitOrRollback = /^(commit|rollback)\b/i.test(text.trimStart());
222
+ if (isBegin) {
223
+ return await serialize(async () => {
224
+ if (!sessionStateSet) {
225
+ await rawClient.query('begin');
226
+ inTransaction = true;
227
+ await setSessionState();
228
+ sessionStateSet = true;
229
+ }
230
+ else if (!inTransaction) {
231
+ await rawClient.query('begin');
232
+ inTransaction = true;
233
+ }
234
+ return { rows: [], rowCount: 0 };
235
+ });
236
+ }
237
+ if (isCommitOrRollback) {
238
+ return await serialize(async () => {
239
+ if (inTransaction) {
240
+ const res = await rawClient.query(text);
241
+ inTransaction = false;
242
+ return res;
243
+ }
244
+ return { rows: [], rowCount: 0 };
245
+ });
246
+ }
247
+ await serialize(async () => {
248
+ if (!sessionStateSet) {
249
+ if (isSelectOnly(sql)) {
250
+ await rawClient.query('begin');
251
+ inTransaction = true;
252
+ await setSessionState();
253
+ await rawClient.query('commit');
254
+ inTransaction = false;
255
+ }
256
+ else {
257
+ await rawClient.query('begin');
258
+ inTransaction = true;
259
+ await setSessionState();
260
+ }
261
+ sessionStateSet = true;
262
+ }
263
+ else if (!inTransaction && !isSelectOnly(sql)) {
264
+ await rawClient.query('begin');
265
+ inTransaction = true;
266
+ }
267
+ });
268
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
269
+ return target.query(injectUidComment(sql, userId), ...args);
270
+ };
271
+ }
272
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
273
+ const value = target[prop];
274
+ return typeof value === 'function' ? value.bind(target) : value;
275
+ }
276
+ });
161
277
  const { drizzle } = await import('drizzle-orm/node-postgres');
162
- const drizzleHandle = drizzle(client);
163
- return await fn(await postgresDb(drizzleHandle, rawClient));
278
+ const drizzleHandle = drizzle(interceptingClient);
279
+ try {
280
+ const result = await fn(await postgresDb(drizzleHandle, interceptingClient));
281
+ if (inTransaction)
282
+ await rawClient.query('commit');
283
+ return result;
284
+ }
285
+ catch (err) {
286
+ if (inTransaction)
287
+ await rawClient.query('rollback').catch(() => undefined);
288
+ throw err;
289
+ }
164
290
  });
165
291
  }
166
292
  /**
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,116 @@
1
+ import { bench, describe } from 'vitest';
2
+ import tokenizeSql from './sql_tokens';
3
+ import parseStatement from './parse_statement';
4
+ import inlineParams from './inline_params';
5
+ import encodeParam from './encode_param';
6
+ import parseWhere from './parse_where';
7
+ import parseComposite from './parse_composite';
8
+ import buildRestFilters from './rest_filters';
9
+ import resolveRawSql from './resolve_raw_sql';
10
+ import { parseExecResult } from './supabase_transport';
11
+ import { excluded, onConflictSet } from './helpers';
12
+ import { pgTable, text, integer, timestamp } from 'drizzle-orm/pg-core';
13
+ describe('DB Module Branch Benchmarks', () => {
14
+ const complexSql = 'SELECT id, name, email, created_at FROM users WHERE status = $1 AND age >= $2 AND role IN ($3, $4) ORDER BY created_at DESC LIMIT $5 OFFSET $6';
15
+ const insertSql = 'INSERT INTO users ("name", "email", "age", "status") VALUES ($1, $2, $3, $4), ($5, $6, $7, $8) ON CONFLICT ("email") DO UPDATE SET "name" = excluded."name", "status" = $9 RETURNING "id", "name"';
16
+ const updateSql = 'UPDATE users SET name = $1, status = $2 WHERE id = $3 AND active = $4 RETURNING id';
17
+ const deleteSql = 'DELETE FROM users WHERE tenant_id = $1 AND status = $2 AND created_at < $3 RETURNING id';
18
+ const sampleParams = ['active', 21, 'admin', 'user', 50, 10, 'John Doe', 'john@example.com', 30, 'pending'];
19
+ // Schema for helpers benchmarks
20
+ const usersTable = pgTable('users', {
21
+ id: integer('id').primaryKey(),
22
+ name: text('name').notNull(),
23
+ email: text('email').notNull(),
24
+ updatedAt: timestamp('updated_at'),
25
+ });
26
+ const mockTarget = {
27
+ eq: () => mockTarget,
28
+ gte: () => mockTarget,
29
+ in: () => mockTarget,
30
+ or: () => mockTarget,
31
+ };
32
+ const sampleWhereTree = {
33
+ kind: 'and',
34
+ children: [
35
+ { kind: 'compare', column: 'status', operator: 'eq', value: { kind: 'param', index: 1 } },
36
+ { kind: 'compare', column: 'age', operator: 'gte', value: { kind: 'param', index: 2 } },
37
+ { kind: 'in', column: 'role', values: [{ kind: 'param', index: 3 }, { kind: 'param', index: 4 }], negated: false }
38
+ ]
39
+ };
40
+ // 1. Tokenizer benchmarks
41
+ describe('tokenizeSql', () => {
42
+ bench('Select statement', () => { tokenizeSql(complexSql); });
43
+ bench('Insert statement', () => { tokenizeSql(insertSql); });
44
+ bench('Update statement', () => { tokenizeSql(updateSql); });
45
+ bench('Delete statement', () => { tokenizeSql(deleteSql); });
46
+ });
47
+ // 2. Parser benchmarks
48
+ describe('parseStatement', () => {
49
+ bench('Select statement', () => { parseStatement(complexSql); });
50
+ bench('Insert statement', () => { parseStatement(insertSql); });
51
+ bench('Update statement', () => { parseStatement(updateSql); });
52
+ bench('Delete statement', () => { parseStatement(deleteSql); });
53
+ });
54
+ // 3. Where Clause parser
55
+ describe('parseWhere', () => {
56
+ const tokens = tokenizeSql(complexSql);
57
+ // "WHERE status = $1 AND age >= $2 AND role IN ($3, $4)" starts after WHERE token (index 8)
58
+ bench('Where clause parsing', () => {
59
+ parseWhere(tokens, 9);
60
+ });
61
+ });
62
+ // 4. Parameter inlining
63
+ describe('inlineParams', () => {
64
+ bench('Select with 6 params', () => { inlineParams(complexSql, sampleParams); });
65
+ bench('Insert with 9 params', () => { inlineParams(insertSql, sampleParams); });
66
+ });
67
+ // 5. Parameter encoding
68
+ describe('encodeParam', () => {
69
+ bench('String encoding', () => { encodeParam('test string with \'quotes\' and \\ backslashes'); });
70
+ bench('Number encoding', () => { encodeParam(12345.678); });
71
+ bench('Boolean encoding', () => { encodeParam(true); });
72
+ bench('Date encoding', () => { encodeParam(new Date('2026-01-01T12:00:00Z')); });
73
+ bench('JSON Object encoding', () => { encodeParam({ a: 1, b: 'hello', c: [true, false] }); });
74
+ bench('Array encoding', () => { encodeParam(['foo', 'bar', 'baz']); });
75
+ bench('Uint8Array encoding', () => { encodeParam(new Uint8Array([1, 2, 3, 4, 255])); });
76
+ });
77
+ // 6. Composite type parsing
78
+ describe('parseComposite', () => {
79
+ bench('Simple composite row', () => { parseComposite('(123,"hello",t,"2026-01-01")'); });
80
+ bench('Escaped quoted composite row', () => { parseComposite('(123,"hello ""world""",t,"2026-01-01")'); });
81
+ });
82
+ // 7. REST Filters builder
83
+ describe('buildRestFilters', () => {
84
+ bench('Where tree translation', () => {
85
+ buildRestFilters(mockTarget, sampleWhereTree, sampleParams);
86
+ });
87
+ });
88
+ // 8. Result Decoder
89
+ describe('parseExecResult', () => {
90
+ const sampleResult = [
91
+ '(1,"John Doe","john@example.com","2026-01-01")',
92
+ '(2,"Jane Doe","jane@example.com","2026-01-02")',
93
+ '(3,"Bob Smith","bob@example.com","2026-01-03")',
94
+ ];
95
+ bench('Parse RPC array result', () => {
96
+ parseExecResult(sampleResult);
97
+ });
98
+ });
99
+ // 9. Drizzle Helpers
100
+ describe('helpers', () => {
101
+ const exUsers = excluded(usersTable);
102
+ bench('excluded column lookup', () => {
103
+ const _a = exUsers.name;
104
+ const _b = exUsers.email;
105
+ });
106
+ bench('onConflictSet generation', () => {
107
+ onConflictSet(usersTable, ['name', 'email']);
108
+ });
109
+ });
110
+ // 10. Raw SQL Resolution
111
+ describe('resolveRawSql', () => {
112
+ bench('Next.config lookup', () => {
113
+ resolveRawSql('/Volumes/External/own_projects/cloudflare-next-intl');
114
+ });
115
+ });
116
+ });
@@ -34,11 +34,19 @@ export default function encodeParam(value) {
34
34
  // Plain objects (jsonb columns) — pg sends these JSON-stringified.
35
35
  return quoteLiteral(JSON.stringify(value));
36
36
  }
37
+ const HEX_TABLE = Array.from({ length: 256 }, (_, i) => i.toString(16).padStart(2, '0'));
37
38
  function quoteLiteral(text) {
39
+ if (text.indexOf("'") === -1)
40
+ return `'${text}'`;
38
41
  return `'${text.replace(/'/g, "''")}'`;
39
42
  }
40
43
  function bytesToHex(bytes) {
41
- return Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
44
+ let hex = '';
45
+ const len = bytes.length;
46
+ for (let i = 0; i < len; i++) {
47
+ hex += HEX_TABLE[bytes[i]];
48
+ }
49
+ return hex;
42
50
  }
43
51
  /** Encodes a JS array as a Postgres array literal body, e.g. `{1,2,"a,b"}`. */
44
52
  function encodeArray(value) {
@@ -56,5 +64,7 @@ function encodeArray(value) {
56
64
  return `{${items.join(',')}}`;
57
65
  }
58
66
  function quoteArrayElement(text) {
67
+ if (text.indexOf('\\') === -1 && text.indexOf('"') === -1)
68
+ return `"${text}"`;
59
69
  return `"${text.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
60
70
  }
@@ -23,7 +23,7 @@ export function excluded(table) {
23
23
  const cols = getTableColumns(table);
24
24
  const tableName = getTableName(table);
25
25
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
26
- return new Proxy({}, {
26
+ return new Proxy(cols, {
27
27
  get(_, prop) {
28
28
  const col = cols[prop];
29
29
  if (!col) {
@@ -19,57 +19,65 @@ import encodeParam from './encode_param';
19
19
  export default function inlineParams(statement, params) {
20
20
  let result = '';
21
21
  let i = 0;
22
+ let lastIndex = 0;
22
23
  const len = statement.length;
23
24
  while (i < len) {
24
- const ch = statement[i];
25
- if (ch === '-' && statement[i + 1] === '-') {
25
+ const code = statement.charCodeAt(i);
26
+ if (code === 45 && statement.charCodeAt(i + 1) === 45) { // --
26
27
  const end = statement.indexOf('\n', i);
27
28
  const stop = end === -1 ? len : end + 1;
28
- result += statement.slice(i, stop);
29
29
  i = stop;
30
30
  continue;
31
31
  }
32
- if (ch === '/' && statement[i + 1] === '*') {
32
+ if (code === 47 && statement.charCodeAt(i + 1) === 42) { // /*
33
33
  const end = statement.indexOf('*/', i + 2);
34
34
  const stop = end === -1 ? len : end + 2;
35
- result += statement.slice(i, stop);
36
35
  i = stop;
37
36
  continue;
38
37
  }
39
- if (ch === "'" || (ch === 'E' && statement[i + 1] === "'")) {
40
- const start = ch === 'E' ? i + 1 : i;
38
+ if (code === 39 || (code === 69 && statement.charCodeAt(i + 1) === 39)) { // ' or E'
39
+ const start = code === 69 ? i + 1 : i;
41
40
  const end = findStringEnd(statement, start + 1);
42
- result += statement.slice(i, end);
43
41
  i = end;
44
42
  continue;
45
43
  }
46
- if (ch === '"') {
44
+ if (code === 34) { // "
47
45
  const end = findQuotedIdentifierEnd(statement, i + 1);
48
- result += statement.slice(i, end);
49
46
  i = end;
50
47
  continue;
51
48
  }
52
- if (ch === '$' && /[0-9]/.test(statement[i + 1] ?? '')) {
53
- let j = i + 1;
54
- while (j < len && /[0-9]/.test(statement[j]))
55
- j++;
56
- const index = Number(statement.slice(i + 1, j));
57
- if (index < 1 || index > params.length) {
58
- throw new Error(`db: statement references $${index} but only ${params.length} param(s) were provided.`);
49
+ if (code === 36) { // $
50
+ const nextCode = statement.charCodeAt(i + 1);
51
+ if (nextCode >= 48 && nextCode <= 57) {
52
+ let j = i + 2;
53
+ while (j < len) {
54
+ const c = statement.charCodeAt(j);
55
+ if (c >= 48 && c <= 57)
56
+ j++;
57
+ else
58
+ break;
59
+ }
60
+ const index = Number(statement.slice(i + 1, j));
61
+ if (index < 1 || index > params.length) {
62
+ throw new Error(`db: statement references $${index} but only ${params.length} param(s) were provided.`);
63
+ }
64
+ if (i > lastIndex)
65
+ result += statement.slice(lastIndex, i);
66
+ result += encodeParam(params[index - 1]);
67
+ i = j;
68
+ lastIndex = j;
69
+ continue;
59
70
  }
60
- result += encodeParam(params[index - 1]);
61
- i = j;
62
- continue;
63
- }
64
- if (ch === '$') {
65
71
  const tagEnd = findDollarQuoteEnd(statement, i);
66
- result += statement.slice(i, tagEnd);
67
72
  i = tagEnd;
68
73
  continue;
69
74
  }
70
- result += ch;
71
75
  i++;
72
76
  }
77
+ if (lastIndex === 0)
78
+ return statement;
79
+ if (lastIndex < len)
80
+ result += statement.slice(lastIndex);
73
81
  return result;
74
82
  }
75
83
  function findStringEnd(statement, from) {
@@ -24,33 +24,51 @@ export default function parseComposite(literal) {
24
24
  if (len === i)
25
25
  return fields;
26
26
  while (true) {
27
- if (literal[i] === '"') {
28
- let value = '';
27
+ if (literal.charCodeAt(i) === 34) { // '"'
29
28
  i++;
29
+ const start = i;
30
+ let hasEscaped = false;
30
31
  while (i < len) {
31
- if (literal[i] === '"') {
32
- if (literal[i + 1] === '"') {
33
- value += '"';
34
- i += 2;
35
- continue;
32
+ if (literal.charCodeAt(i) === 34) {
33
+ if (literal.charCodeAt(i + 1) === 34) {
34
+ hasEscaped = true;
35
+ break;
36
36
  }
37
37
  break;
38
38
  }
39
- value += literal[i];
40
39
  i++;
41
40
  }
42
- i++; // closing quote
43
- fields.push(value);
41
+ if (!hasEscaped) {
42
+ fields.push(literal.slice(start, i));
43
+ i++; // skip closing quote
44
+ }
45
+ else {
46
+ let value = literal.slice(start, i);
47
+ while (i < len) {
48
+ if (literal.charCodeAt(i) === 34) {
49
+ if (literal.charCodeAt(i + 1) === 34) {
50
+ value += '"';
51
+ i += 2;
52
+ continue;
53
+ }
54
+ break;
55
+ }
56
+ value += literal[i];
57
+ i++;
58
+ }
59
+ i++; // skip closing quote
60
+ fields.push(value);
61
+ }
44
62
  }
45
63
  else {
46
- let value = '';
47
- while (i < len && literal[i] !== ',') {
48
- value += literal[i];
64
+ const start = i;
65
+ while (i < len && literal.charCodeAt(i) !== 44) {
49
66
  i++;
50
67
  }
68
+ const value = literal.slice(start, i);
51
69
  fields.push(value === '' ? null : value);
52
70
  }
53
- if (literal[i] === ',') {
71
+ if (literal.charCodeAt(i) === 44) { // ','
54
72
  i++;
55
73
  continue;
56
74
  }
@@ -60,17 +60,4 @@ export interface ParsedDelete {
60
60
  }
61
61
  /** Any statement the REST executor knows how to run. */
62
62
  export type ParsedStatement = ParsedSelect | ParsedInsert | ParsedUpdate | ParsedDelete;
63
- /**
64
- * Parses a generated statement into the smallest description the REST
65
- * executor needs, rejecting anything PostgREST's single-table API cannot do.
66
- *
67
- * The parser is deliberately strict: a statement it does not fully understand
68
- * must raise rather than translate approximately, because the transport reads
69
- * a raise as "send this to `cfni_exec` instead" and a wrong translation would
70
- * silently return wrong rows.
71
- *
72
- * @param sql The generated statement text, `$n` placeholders included.
73
- * @returns The parsed statement.
74
- * @throws {UnsupportedSqlError} If the statement is outside the supported subset.
75
- */
76
63
  export default function parseStatement(sql: string): ParsedStatement;
@@ -14,20 +14,31 @@ import UnsupportedSqlError from './unsupported_sql';
14
14
  * @returns The parsed statement.
15
15
  * @throws {UnsupportedSqlError} If the statement is outside the supported subset.
16
16
  */
17
+ const STATEMENT_CACHE = new Map();
18
+ const MAX_STATEMENT_CACHE = 500;
17
19
  export default function parseStatement(sql) {
20
+ const cached = STATEMENT_CACHE.get(sql);
21
+ if (cached)
22
+ return cached;
18
23
  const tokens = tokenizeSql(sql);
19
24
  const first = tokens[0];
20
25
  if (!first || first.kind !== 'word')
21
26
  throw new UnsupportedSqlError('empty statement');
27
+ let parsed;
22
28
  if (first.value === 'select')
23
- return parseSelect(tokens);
24
- if (first.value === 'insert')
25
- return parseInsert(tokens);
26
- if (first.value === 'update')
27
- return parseUpdate(tokens);
28
- if (first.value === 'delete')
29
- return parseDelete(tokens);
30
- throw new UnsupportedSqlError(`statement type "${first.value}"`);
29
+ parsed = parseSelect(tokens);
30
+ else if (first.value === 'insert')
31
+ parsed = parseInsert(tokens);
32
+ else if (first.value === 'update')
33
+ parsed = parseUpdate(tokens);
34
+ else if (first.value === 'delete')
35
+ parsed = parseDelete(tokens);
36
+ else
37
+ throw new UnsupportedSqlError(`statement type "${first.value}"`);
38
+ if (STATEMENT_CACHE.size >= MAX_STATEMENT_CACHE)
39
+ STATEMENT_CACHE.clear();
40
+ STATEMENT_CACHE.set(sql, parsed);
41
+ return parsed;
31
42
  }
32
43
  function parseSelect(tokens) {
33
44
  let index = 1;
@@ -1,4 +1,5 @@
1
- const MULTI_CHAR_OPERATORS = ['-|-', '<>', '!=', '>=', '<=', '~*', '@>', '<@', '&&', '>>', '<<', '&>', '&<', '@@'];
1
+ const TOKEN_CACHE = new Map();
2
+ const MAX_CACHE_SIZE = 500;
2
3
  /**
3
4
  * Splits a statement generated by `drizzle-orm/pg-proxy` into tokens the
4
5
  * statement parser can walk.
@@ -12,77 +13,194 @@ const MULTI_CHAR_OPERATORS = ['-|-', '<>', '!=', '>=', '<=', '~*', '@>', '<@', '
12
13
  * @returns The token list, in source order.
13
14
  */
14
15
  export default function tokenizeSql(sql) {
16
+ const cached = TOKEN_CACHE.get(sql);
17
+ if (cached)
18
+ return cached.slice();
15
19
  const tokens = [];
16
20
  let i = 0;
17
- while (i < sql.length) {
18
- const char = sql[i];
19
- if (/\s/.test(char)) {
21
+ const len = sql.length;
22
+ while (i < len) {
23
+ const code = sql.charCodeAt(i);
24
+ // Whitespace: space (32), tab (9), LF (10), VT (11), FF (12), CR (13)
25
+ if (code === 32 || (code >= 9 && code <= 13)) {
20
26
  i++;
21
27
  continue;
22
28
  }
23
- if (char === '-' && sql[i + 1] === '-') {
29
+ // Line comment --
30
+ if (code === 45 && sql.charCodeAt(i + 1) === 45) {
24
31
  const end = sql.indexOf('\n', i);
25
- i = end === -1 ? sql.length : end + 1;
32
+ i = end === -1 ? len : end + 1;
26
33
  continue;
27
34
  }
28
- if (char === '/' && sql[i + 1] === '*') {
35
+ // Block comment /* ... */
36
+ if (code === 47 && sql.charCodeAt(i + 1) === 42) {
29
37
  const end = sql.indexOf('*/', i + 2);
30
- i = end === -1 ? sql.length : end + 2;
38
+ i = end === -1 ? len : end + 2;
31
39
  continue;
32
40
  }
33
- if (char === '"') {
41
+ // Quoted identifier "..."
42
+ if (code === 34) {
34
43
  const [value, next] = readDelimited(sql, i + 1, '"');
35
44
  tokens.push({ kind: 'quoted', value });
36
45
  i = next;
37
46
  continue;
38
47
  }
39
- if (char === "'") {
48
+ // String literal '...'
49
+ if (code === 39) {
40
50
  const [value, next] = readDelimited(sql, i + 1, "'");
41
51
  tokens.push({ kind: 'string', value });
42
52
  i = next;
43
53
  continue;
44
54
  }
45
- if (char === '$' && /[0-9]/.test(sql[i + 1] ?? '')) {
46
- let end = i + 1;
47
- while (end < sql.length && /[0-9]/.test(sql[end]))
48
- end++;
49
- tokens.push({ kind: 'param', index: Number(sql.slice(i + 1, end)) });
50
- i = end;
51
- continue;
55
+ // Parameter $1, $2, ...
56
+ if (code === 36) {
57
+ const nextCode = sql.charCodeAt(i + 1);
58
+ if (nextCode >= 48 && nextCode <= 57) {
59
+ let end = i + 2;
60
+ while (end < len) {
61
+ const c = sql.charCodeAt(end);
62
+ if (c >= 48 && c <= 57)
63
+ end++;
64
+ else
65
+ break;
66
+ }
67
+ tokens.push({ kind: 'param', index: Number(sql.slice(i + 1, end)) });
68
+ i = end;
69
+ continue;
70
+ }
52
71
  }
53
- if (/[0-9]/.test(char)) {
54
- let end = i;
55
- while (end < sql.length && /[0-9.]/.test(sql[end]))
56
- end++;
72
+ // Numbers 0-9
73
+ if (code >= 48 && code <= 57) {
74
+ let end = i + 1;
75
+ while (end < len) {
76
+ const c = sql.charCodeAt(end);
77
+ if ((c >= 48 && c <= 57) || c === 46)
78
+ end++; // 0-9 or .
79
+ else
80
+ break;
81
+ }
57
82
  tokens.push({ kind: 'number', value: sql.slice(i, end) });
58
83
  i = end;
59
84
  continue;
60
85
  }
61
- if (/[A-Za-z_]/.test(char)) {
62
- let end = i;
63
- while (end < sql.length && /[A-Za-z0-9_]/.test(sql[end]))
64
- end++;
86
+ // Words A-Z, a-z, _
87
+ if ((code >= 65 && code <= 90) || (code >= 97 && code <= 122) || code === 95) {
88
+ let end = i + 1;
89
+ while (end < len) {
90
+ const c = sql.charCodeAt(end);
91
+ if ((c >= 65 && c <= 90) || (c >= 97 && c <= 122) || (c >= 48 && c <= 57) || c === 95)
92
+ end++;
93
+ else
94
+ break;
95
+ }
65
96
  tokens.push({ kind: 'word', value: sql.slice(i, end).toLowerCase() });
66
97
  i = end;
67
98
  continue;
68
99
  }
69
- const operator = MULTI_CHAR_OPERATORS.find((candidate) => sql.startsWith(candidate, i));
70
- if (operator) {
71
- tokens.push({ kind: 'punct', value: operator });
72
- i += operator.length;
73
- continue;
100
+ // Multi-char operators check
101
+ const nextCode = sql.charCodeAt(i + 1);
102
+ if (nextCode) {
103
+ if (code === 45 && nextCode === 124 && sql.charCodeAt(i + 2) === 45) { // -|-
104
+ tokens.push({ kind: 'punct', value: '-|-' });
105
+ i += 3;
106
+ continue;
107
+ }
108
+ if (code === 60 && nextCode === 62) {
109
+ tokens.push({ kind: 'punct', value: '<>' });
110
+ i += 2;
111
+ continue;
112
+ } // <>
113
+ if (code === 33 && nextCode === 61) {
114
+ tokens.push({ kind: 'punct', value: '!=' });
115
+ i += 2;
116
+ continue;
117
+ } // !=
118
+ if (code === 62 && nextCode === 61) {
119
+ tokens.push({ kind: 'punct', value: '>=' });
120
+ i += 2;
121
+ continue;
122
+ } // >=
123
+ if (code === 60 && nextCode === 61) {
124
+ tokens.push({ kind: 'punct', value: '<=' });
125
+ i += 2;
126
+ continue;
127
+ } // <=
128
+ if (code === 126 && nextCode === 42) {
129
+ tokens.push({ kind: 'punct', value: '~*' });
130
+ i += 2;
131
+ continue;
132
+ } // ~*
133
+ if (code === 64 && nextCode === 62) {
134
+ tokens.push({ kind: 'punct', value: '@>' });
135
+ i += 2;
136
+ continue;
137
+ } // @>
138
+ if (code === 60 && nextCode === 64) {
139
+ tokens.push({ kind: 'punct', value: '<@' });
140
+ i += 2;
141
+ continue;
142
+ } // <@
143
+ if (code === 38 && nextCode === 38) {
144
+ tokens.push({ kind: 'punct', value: '&&' });
145
+ i += 2;
146
+ continue;
147
+ } // &&
148
+ if (code === 62 && nextCode === 62) {
149
+ tokens.push({ kind: 'punct', value: '>>' });
150
+ i += 2;
151
+ continue;
152
+ } // >>
153
+ if (code === 60 && nextCode === 60) {
154
+ tokens.push({ kind: 'punct', value: '<<' });
155
+ i += 2;
156
+ continue;
157
+ } // <<
158
+ if (code === 38 && nextCode === 62) {
159
+ tokens.push({ kind: 'punct', value: '&>' });
160
+ i += 2;
161
+ continue;
162
+ } // &>
163
+ if (code === 38 && nextCode === 60) {
164
+ tokens.push({ kind: 'punct', value: '&<' });
165
+ i += 2;
166
+ continue;
167
+ } // &<
168
+ if (code === 64 && nextCode === 64) {
169
+ tokens.push({ kind: 'punct', value: '@@' });
170
+ i += 2;
171
+ continue;
172
+ } // @@
74
173
  }
75
- tokens.push({ kind: 'punct', value: char });
174
+ tokens.push({ kind: 'punct', value: sql[i] });
76
175
  i++;
77
176
  }
78
- return tokens;
177
+ if (TOKEN_CACHE.size >= MAX_CACHE_SIZE)
178
+ TOKEN_CACHE.clear();
179
+ TOKEN_CACHE.set(sql, tokens);
180
+ return tokens.slice();
79
181
  }
80
182
  function readDelimited(sql, from, delimiter) {
81
- let value = '';
183
+ const delimCode = delimiter.charCodeAt(0);
82
184
  let i = from;
83
- while (i < sql.length) {
84
- if (sql[i] === delimiter) {
85
- if (sql[i + 1] === delimiter) {
185
+ const len = sql.length;
186
+ let hasEscaped = false;
187
+ // Fast check for quotes without escaping
188
+ while (i < len) {
189
+ if (sql.charCodeAt(i) === delimCode) {
190
+ if (sql.charCodeAt(i + 1) === delimCode) {
191
+ hasEscaped = true;
192
+ break;
193
+ }
194
+ return [sql.slice(from, i), i + 1];
195
+ }
196
+ i++;
197
+ }
198
+ if (!hasEscaped)
199
+ return [sql.slice(from, i), len];
200
+ let value = sql.slice(from, i);
201
+ while (i < len) {
202
+ if (sql.charCodeAt(i) === delimCode) {
203
+ if (sql.charCodeAt(i + 1) === delimCode) {
86
204
  value += delimiter;
87
205
  i += 2;
88
206
  continue;
@@ -92,5 +210,5 @@ function readDelimited(sql, from, delimiter) {
92
210
  value += sql[i];
93
211
  i++;
94
212
  }
95
- return [value, sql.length];
213
+ return [value, len];
96
214
  }
@@ -62,7 +62,7 @@ export async function getFirebaseAuthClient() {
62
62
  measurementId: fa.measurementId,
63
63
  };
64
64
  const app = getApps().length ? getApp() : initializeApp(firebaseConfig);
65
- if (fa.appCheck) {
65
+ if (fa.appCheck && typeof window !== 'undefined') {
66
66
  cachedAppCheck = await initializeFirebaseAppCheck(app, fa.appCheck);
67
67
  }
68
68
  if (perfModule) {
@@ -243,7 +243,11 @@ export default async function updateSession(request, baseResponse, locale, rebui
243
243
  const mode = request.nextUrl.searchParams.get('mode');
244
244
  if (mode) {
245
245
  let target = resolveActionModePaths(fa)[mode];
246
- if (fa.followSameOriginContinueUrl !== false) {
246
+ // Already on the page this mode routes to: the link has arrived.
247
+ // Following `continueUrl` from here would send it back to the
248
+ // action URL, which forwards to this same target again — an
249
+ // endless 307 ping-pong.
250
+ if (fa.followSameOriginContinueUrl !== false && target !== path) {
247
251
  const continueUrl = request.nextUrl.searchParams.get('continueUrl');
248
252
  if (continueUrl) {
249
253
  try {
@@ -291,6 +295,15 @@ export default async function updateSession(request, baseResponse, locale, rebui
291
295
  if (target && target !== path) {
292
296
  const url = localeUrl(target);
293
297
  url.search = request.nextUrl.search;
298
+ // Staying on this origin: only `oobCode` (plus anything the app
299
+ // put there itself) is still needed. Dropping Firebase's own
300
+ // routing params keeps the landed URL clean and makes a second
301
+ // forwarding pass impossible.
302
+ if (fa.stripActionLinkQuery !== false) {
303
+ for (const key of ['mode', 'apiKey', 'lang', 'continueUrl']) {
304
+ url.searchParams.delete(key);
305
+ }
306
+ }
294
307
  return buildRedirect(baseResponse, url);
295
308
  }
296
309
  }
@@ -546,14 +546,24 @@ export interface FirebaseAuthRoutingConfig {
546
546
  * auto-corrects a missing leading slash with a warning.
547
547
  */
548
548
  actionLinkPath?: string;
549
+ /**
550
+ * Whether a same-origin emailed-action-link forward strips Firebase's own
551
+ * `mode`/`apiKey`/`lang`/`continueUrl` params, landing the user on a clean
552
+ * `?oobCode=` URL. Defaults to `true`. Set `false` to keep the full query
553
+ * when the destination page reads those params itself. Cross-origin
554
+ * redirects always keep the full query.
555
+ */
556
+ stripActionLinkQuery?: boolean;
549
557
  /**
550
558
  * Whether the middleware's own redirects (`redirectAuthPath`, `homePath`,
551
559
  * `verifyEmailPath`) carry over the original request's query string —
552
560
  * e.g. `/login?ref=abc` stays `/login?ref=abc` after redirecting to
553
561
  * `homePath` for a signed-in user, instead of dropping to `/`. Defaults
554
562
  * to `true`. The emailed-action-link forward (see
555
- * {@link resetPasswordPath}) always preserves its query string
556
- * regardless of this setting, since `oobCode` must survive that hop.
563
+ * {@link resetPasswordPath}) always preserves `oobCode` regardless of
564
+ * this setting, since it must survive that hop; when the forward stays on
565
+ * this origin it drops Firebase's own `mode`/`apiKey`/`lang`/`continueUrl`
566
+ * params, which the destination page no longer needs.
557
567
  */
558
568
  preserveRedirectQuery?: boolean;
559
569
  /** Returns true if the given (locale-stripped) path is an auth page (login/signup/etc). */
@@ -900,10 +910,21 @@ export interface DbRoutingConfig {
900
910
  */
901
911
  disconnectAfterRequest?: boolean;
902
912
  /**
903
- * Postgres role assumed inside `withUserDb`'s transaction. Defaults
904
- * to `'authenticated'` (the Supabase RLS convention).
913
+ * Postgres role assumed inside `withUserDb`'s transaction, used when
914
+ * {@link authenticatedRoleClaim} doesn't resolve one (e.g. `firebaseAuth`
915
+ * isn't configured, or the claim is absent). May be a string or a
916
+ * sync/async function resolved on each call. Defaults to `'authenticated'`
917
+ * (the Supabase RLS convention).
918
+ */
919
+ authenticatedRole?: string | (() => string | Promise<string>);
920
+ /**
921
+ * Name of the Firebase custom-claims field read for the Postgres role
922
+ * inside `withUserDb`, taking priority over {@link authenticatedRole}
923
+ * when present on the signed-in user's ID token. Defaults to `'role'`.
924
+ * Only consulted when `firebaseAuth` is configured; set `false` to skip
925
+ * reading claims entirely and always use `authenticatedRole`.
905
926
  */
906
- authenticatedRole?: string;
927
+ authenticatedRoleClaim?: string | false;
907
928
  /**
908
929
  * Resolves the user id injected as `request.jwt.claims->>'sub'` inside
909
930
  * `withUserDb`. Omit when `firebaseAuth` is configured — the uid then
package/llms.txt CHANGED
@@ -54,7 +54,8 @@ Two transports, picked by which `db` fields are set — `pg`/`drizzle-orm`/`@sup
54
54
  - 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.
55
55
  - 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`. Statements are translated to PostgREST REST calls first; unsupported statements fall back to `supabase/cfni_exec.sql` (a `security invoker` SQL-exec function) 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 `cfni_exec` fallback, throwing an informative error when a query cannot be served over REST.
56
56
  - `db.disconnectAfterRequest` — deprecated, ignored since 0.8.23. Each `withPublicDb`/`withUserDb` call opens and closes its own client; Hyperdrive pools the server-side connection.
57
- - `db.authenticatedRole` — direct-Postgres mode only: Postgres role assumed inside `withUserDb`'s transaction. Defaults to `'authenticated'` (Supabase RLS convention).
57
+ - `db.authenticatedRole` — direct-Postgres mode only: Postgres role assumed inside `withUserDb`'s transaction. Accepts a string or a sync/async function returning one. Defaults to `'authenticated'` (Supabase RLS convention).
58
+ - `db.authenticatedRoleClaim` — direct-Postgres mode only: Firebase custom claim field (default `'role'`) read to determine the Postgres role in `withUserDb` when `firebaseAuth` is enabled. Set `false` to disable.
58
59
  - `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()`.
59
60
  - `db.getAccessToken` — Supabase mode only: resolves the JWT sent as `Authorization: Bearer` in `withUserDb`, which is what makes PostgREST resolve `authenticated` and apply RLS. Omit when `firebaseAuth` is configured — the signed-in user's Firebase ID token is used automatically.
60
61
  - `db.disconnectTimeoutMs` — deprecated, ignored since 0.8.23. Teardown is awaited or deferred to `ctx.waitUntil` without a timeout.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cloudflare-next-intl",
3
- "version": "0.8.30",
3
+ "version": "0.8.32",
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",