cloudflare-next-intl 0.8.30 → 0.8.31

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
  }
@@ -900,10 +900,21 @@ export interface DbRoutingConfig {
900
900
  */
901
901
  disconnectAfterRequest?: boolean;
902
902
  /**
903
- * Postgres role assumed inside `withUserDb`'s transaction. Defaults
904
- * to `'authenticated'` (the Supabase RLS convention).
903
+ * Postgres role assumed inside `withUserDb`'s transaction, used when
904
+ * {@link authenticatedRoleClaim} doesn't resolve one (e.g. `firebaseAuth`
905
+ * isn't configured, or the claim is absent). May be a string or a
906
+ * sync/async function resolved on each call. Defaults to `'authenticated'`
907
+ * (the Supabase RLS convention).
905
908
  */
906
- authenticatedRole?: string;
909
+ authenticatedRole?: string | (() => string | Promise<string>);
910
+ /**
911
+ * Name of the Firebase custom-claims field read for the Postgres role
912
+ * inside `withUserDb`, taking priority over {@link authenticatedRole}
913
+ * when present on the signed-in user's ID token. Defaults to `'role'`.
914
+ * Only consulted when `firebaseAuth` is configured; set `false` to skip
915
+ * reading claims entirely and always use `authenticatedRole`.
916
+ */
917
+ authenticatedRoleClaim?: string | false;
907
918
  /**
908
919
  * Resolves the user id injected as `request.jwt.claims->>'sub'` inside
909
920
  * `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.31",
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",