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 +5 -2
- package/dist/src/db/context.js +131 -5
- package/dist/src/db/db_performance.bench.d.ts +1 -0
- package/dist/src/db/db_performance.bench.js +116 -0
- package/dist/src/db/encode_param.js +11 -1
- package/dist/src/db/helpers.js +1 -1
- package/dist/src/db/inline_params.js +32 -24
- package/dist/src/db/parse_composite.js +32 -14
- package/dist/src/db/parse_statement.d.ts +0 -13
- package/dist/src/db/parse_statement.js +19 -8
- package/dist/src/db/sql_tokens.js +155 -37
- package/dist/src/types/types.d.ts +14 -3
- package/llms.txt +2 -1
- package/package.json +1 -1
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).
|
|
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
|
package/dist/src/db/context.js
CHANGED
|
@@ -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
|
|
195
|
+
const role = await resolveAuthenticatedRole(db);
|
|
157
196
|
return await withDbClient(config, async (client) => {
|
|
158
197
|
const rawClient = client;
|
|
159
|
-
|
|
160
|
-
|
|
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(
|
|
163
|
-
|
|
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
|
-
|
|
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
|
}
|
package/dist/src/db/helpers.js
CHANGED
|
@@ -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
|
|
25
|
-
if (
|
|
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 (
|
|
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 (
|
|
40
|
-
const start =
|
|
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 (
|
|
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 (
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
j
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
|
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
|
|
32
|
-
if (literal
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
|
|
43
|
-
|
|
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
|
-
|
|
47
|
-
while (i < len && literal
|
|
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
|
|
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
|
-
|
|
24
|
-
if (first.value === 'insert')
|
|
25
|
-
|
|
26
|
-
if (first.value === 'update')
|
|
27
|
-
|
|
28
|
-
if (first.value === 'delete')
|
|
29
|
-
|
|
30
|
-
|
|
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
|
|
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
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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
|
-
|
|
29
|
+
// Line comment --
|
|
30
|
+
if (code === 45 && sql.charCodeAt(i + 1) === 45) {
|
|
24
31
|
const end = sql.indexOf('\n', i);
|
|
25
|
-
i = end === -1 ?
|
|
32
|
+
i = end === -1 ? len : end + 1;
|
|
26
33
|
continue;
|
|
27
34
|
}
|
|
28
|
-
|
|
35
|
+
// Block comment /* ... */
|
|
36
|
+
if (code === 47 && sql.charCodeAt(i + 1) === 42) {
|
|
29
37
|
const end = sql.indexOf('*/', i + 2);
|
|
30
|
-
i = end === -1 ?
|
|
38
|
+
i = end === -1 ? len : end + 2;
|
|
31
39
|
continue;
|
|
32
40
|
}
|
|
33
|
-
|
|
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
|
-
|
|
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
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
i
|
|
73
|
-
|
|
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:
|
|
174
|
+
tokens.push({ kind: 'punct', value: sql[i] });
|
|
76
175
|
i++;
|
|
77
176
|
}
|
|
78
|
-
|
|
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
|
-
|
|
183
|
+
const delimCode = delimiter.charCodeAt(0);
|
|
82
184
|
let i = from;
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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,
|
|
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
|
|
904
|
-
*
|
|
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.
|