@volter/world-runtime 2.0.6 → 2.0.8
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/dist/src/pglite-host.mjs +235 -2
- package/package.json +4 -4
- package/src/pglite-host.mjs +235 -2
package/dist/src/pglite-host.mjs
CHANGED
|
@@ -73,7 +73,13 @@
|
|
|
73
73
|
// ran once that ended, left no ReadyForQuery, and held the lock forever). All connections share the one
|
|
74
74
|
// backend session, so session state leaks between them and outlives the
|
|
75
75
|
// connection that set it: SET (search_path, timezone…), temp tables and
|
|
76
|
-
// PREPAREd statements are visible to every other connection
|
|
76
|
+
// SQL PREPAREd statements are visible to every other connection (a
|
|
77
|
+
// statement prepared over the protocol is the connection's own: a name
|
|
78
|
+
// another live connection holds is renamed for it, and its own SQL
|
|
79
|
+
// EXECUTE/DEALLOCATE of that name is renamed with it; scopeNames — except
|
|
80
|
+
// in CREATE TABLE … AS EXECUTE, a comment between the verb and the name,
|
|
81
|
+
// a Query holding a backslash, and a DEALLOCATE sent through Parse, which
|
|
82
|
+
// reach the name as the backend holds it); session
|
|
77
83
|
// advisory locks do NOT exclude (a second connection's
|
|
78
84
|
// pg_try_advisory_lock on a held key returns true); a NOTIFY is delivered
|
|
79
85
|
// to the connection that sent it, not to the one that ran LISTEN.
|
|
@@ -236,6 +242,220 @@ function readOnlySql(text) {
|
|
|
236
242
|
const text = (data, from) => data.subarray(from, data.indexOf(0, from)).toString('utf8');
|
|
237
243
|
const asBuffer = (m) => Buffer.from(m.buffer, m.byteOffset, m.byteLength);
|
|
238
244
|
|
|
245
|
+
/** Prepared statements' protocol names each connection gives, kept apart where they would collide.
|
|
246
|
+
* Every connection shares the one backend session, and a client names its statements per connection: Prisma's engine
|
|
247
|
+
* starts each process at `s0`, so a second process's first Parse met "prepared statement "s0" already exists" (Postiz
|
|
248
|
+
* walk postiz-2: sign-up, and the post workflow's first read, which left no timer to publish the post). A name no other
|
|
249
|
+
* live connection holds goes to the backend as the client gave it, so SQL on it works as on Postgres; one another
|
|
250
|
+
* live connection holds goes as a short name of the host's own (`volter_s<N>`), and the connection's own simple-Query
|
|
251
|
+
* DEALLOCATE or EXECUTE of it is rewritten to match (sqlNames). Names are compared on their first 63 bytes, as Postgres compares them
|
|
252
|
+
* (NAMEDATALEN), and handled as bytes, never decoded. A closed connection's statements are closed on the next turn
|
|
253
|
+
* that begins outside any transaction (flushClosed). */
|
|
254
|
+
const NAMEDATALEN = 63;
|
|
255
|
+
/** backend statement name (latin1 of its bytes, first 63) -> the session holding it */
|
|
256
|
+
const heldStatements = new Map();
|
|
257
|
+
/** backend statement names of closed connections, closed at the next clean turn */
|
|
258
|
+
const closedStatements = [];
|
|
259
|
+
let renamed = 0;
|
|
260
|
+
/** A name no statement has: what another connection's statement name is, to a connection that never prepared it. */
|
|
261
|
+
const UNPREPARED = Buffer.from('volter_unprepared', 'latin1');
|
|
262
|
+
const nameKey = (bytes) => bytes.subarray(0, NAMEDATALEN).toString('latin1');
|
|
263
|
+
|
|
264
|
+
/** The name at `from` (its bytes, to the NUL) and where the field after it starts. */
|
|
265
|
+
function cstring(data, from) {
|
|
266
|
+
const end = data.indexOf(0, from);
|
|
267
|
+
return { bytes: data.subarray(from, end), next: end + 1 };
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** The backend's name for a statement this connection names `bytes`; `claim` (a Parse on the connection's turn) takes
|
|
271
|
+
* one for a new statement, anything else only looks its name up. */
|
|
272
|
+
function statementName(session, bytes, claim) {
|
|
273
|
+
if (bytes.length === 0) return bytes;
|
|
274
|
+
const own = session.names.get(bytes.toString('latin1'));
|
|
275
|
+
if (own) return own;
|
|
276
|
+
if (!claim) {
|
|
277
|
+
// a statement it never prepared: the backend answers as Postgres does, never with another live connection's
|
|
278
|
+
// statement of that name (it would not exist in this connection's own session)
|
|
279
|
+
const holder = heldStatements.get(nameKey(bytes));
|
|
280
|
+
return holder && holder !== session && !holder.closed ? UNPREPARED : bytes;
|
|
281
|
+
}
|
|
282
|
+
const key = nameKey(bytes);
|
|
283
|
+
const holder = heldStatements.get(key);
|
|
284
|
+
const backend = holder && holder !== session ? Buffer.from(`volter_s${(renamed += 1)}`, 'latin1') : bytes;
|
|
285
|
+
heldStatements.set(nameKey(backend), session);
|
|
286
|
+
session.names.set(bytes.toString('latin1'), backend);
|
|
287
|
+
return backend;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/** The message with its statement names as the backend knows them. Portals keep the client's names: a named portal
|
|
291
|
+
* lives only inside its transaction, and one transaction runs at a time. */
|
|
292
|
+
function scopeNames(session, message, claim) {
|
|
293
|
+
const data = asBuffer(message);
|
|
294
|
+
const kind = data[0];
|
|
295
|
+
let parts;
|
|
296
|
+
if (kind === 0x50 /* P */) {
|
|
297
|
+
const name = cstring(data, 5);
|
|
298
|
+
parts = [statementName(session, name.bytes, claim), Buffer.from([0]), data.subarray(name.next)];
|
|
299
|
+
} else if (kind === 0x42 /* B */) {
|
|
300
|
+
const portal = cstring(data, 5);
|
|
301
|
+
const statement = cstring(data, portal.next);
|
|
302
|
+
parts = [data.subarray(5, portal.next), statementName(session, statement.bytes, false), Buffer.from([0]), data.subarray(statement.next)];
|
|
303
|
+
} else if ((kind === 0x44 /* D */ || kind === 0x43 /* C */) && data[5] === 0x53 /* S */) {
|
|
304
|
+
const name = cstring(data, 6);
|
|
305
|
+
const backend = statementName(session, name.bytes, false);
|
|
306
|
+
parts = [data.subarray(5, 6), backend, Buffer.from([0]), data.subarray(name.next)];
|
|
307
|
+
} else if (kind === 0x51 /* Q */ && claim) {
|
|
308
|
+
return sqlNames(session, message);
|
|
309
|
+
} else {
|
|
310
|
+
return message;
|
|
311
|
+
}
|
|
312
|
+
const body = Buffer.concat(parts);
|
|
313
|
+
if (body.length === data.length - 5 && body.equals(data.subarray(5))) return message;
|
|
314
|
+
const out = Buffer.alloc(5 + body.length);
|
|
315
|
+
out[0] = kind;
|
|
316
|
+
out.writeInt32BE(4 + body.length, 1);
|
|
317
|
+
body.copy(out, 5);
|
|
318
|
+
return new Uint8Array(out.buffer, out.byteOffset, out.byteLength);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/** A statement of a simple Query that DEALLOCATEs or EXECUTEs one statement, at the statement's start: the verb
|
|
322
|
+
* (EXPLAIN's options before EXECUTE included) and the name, quoted or not. */
|
|
323
|
+
const STATEMENT_NAMED = /^((?:EXPLAIN\s+(?:\([^)]*\)\s*)?(?:ANALYZE\s+)?(?:VERBOSE\s+)?)?(?:DEALLOCATE\s+(?:PREPARE\s+)?|EXECUTE\s+))(?:"((?:[^"]|"")+)"|([A-Za-z_][A-Za-z0-9_$]*))(?=[\s(;]|$)/i;
|
|
324
|
+
/** a quoted name as it is; an unquoted one folds to lower case, as Postgres folds it */
|
|
325
|
+
const folded = (quoted, name) => (quoted !== undefined ? quoted.replace(/""/g, '"') : name.toLowerCase());
|
|
326
|
+
const EMPTIES_ALL = /^(?:DEALLOCATE\s+(?:PREPARE\s+)?ALL|DISCARD\s+ALL)\s*;?\s*$/i;
|
|
327
|
+
|
|
328
|
+
/** Where each statement of a Query begins, outside string literals, quoted identifiers, dollar-quoted bodies and
|
|
329
|
+
* comments (a statement's leading comments skipped), so a rewrite never touches a literal's text. */
|
|
330
|
+
function statementStarts(sql) {
|
|
331
|
+
const starts = [];
|
|
332
|
+
let atStart = true;
|
|
333
|
+
for (let i = 0; i < sql.length;) {
|
|
334
|
+
const c = sql[i];
|
|
335
|
+
if (/\s/.test(c)) { i += 1; continue; }
|
|
336
|
+
if (c === '-' && sql[i + 1] === '-') { const end = sql.indexOf('\n', i); i = end < 0 ? sql.length : end + 1; continue; }
|
|
337
|
+
if (c === '/' && sql[i + 1] === '*') {
|
|
338
|
+
let depth = 1; let j = i + 2; // Postgres's block comments nest
|
|
339
|
+
while (j < sql.length && depth > 0) { if (sql.startsWith('/*', j)) { depth += 1; j += 2; } else if (sql.startsWith('*/', j)) { depth -= 1; j += 2; } else j += 1; }
|
|
340
|
+
i = j; continue;
|
|
341
|
+
}
|
|
342
|
+
if (atStart) { starts.push(i); atStart = false; }
|
|
343
|
+
if (c === ';') { atStart = true; i += 1; continue; }
|
|
344
|
+
if (c === "'" || c === '"') {
|
|
345
|
+
// E'…' takes backslash escapes; a plain '…' and "…" end at their quote, a doubled one inside
|
|
346
|
+
const escapes = c === "'" && /[Ee]/.test(sql[i - 1] ?? '') && !/[A-Za-z0-9_]/.test(sql[i - 2] ?? '');
|
|
347
|
+
let j = i + 1;
|
|
348
|
+
while (j < sql.length) {
|
|
349
|
+
if (escapes && sql[j] === '\\') { j += 2; continue; }
|
|
350
|
+
if (sql[j] === c) { if (sql[j + 1] === c) { j += 2; continue; } break; }
|
|
351
|
+
j += 1;
|
|
352
|
+
}
|
|
353
|
+
i = j + 1; continue;
|
|
354
|
+
}
|
|
355
|
+
// a `$` inside an identifier (`a$b$c`) opens nothing, as Postgres's lexer reads it
|
|
356
|
+
const dollar = c === '$' && !/[A-Za-z0-9_$]/.test(sql[i - 1] ?? '') ? /^\$([A-Za-z_][A-Za-z0-9_]*)?\$/.exec(sql.slice(i)) : null;
|
|
357
|
+
if (dollar) { const end = sql.indexOf(dollar[0], i + dollar[0].length); i = end < 0 ? sql.length : end + dollar[0].length; continue; }
|
|
358
|
+
i += 1;
|
|
359
|
+
}
|
|
360
|
+
return starts;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/** Each statement of the Query that DEALLOCATEs or EXECUTEs one name: where it is, its verb and the name. */
|
|
364
|
+
function namedStatements(sql) {
|
|
365
|
+
const out = [];
|
|
366
|
+
for (const at of statementStarts(sql)) {
|
|
367
|
+
const m = STATEMENT_NAMED.exec(sql.slice(at));
|
|
368
|
+
if (m) out.push({ at, length: m[0].length, verb: m[1], name: folded(m[2], m[3]) });
|
|
369
|
+
}
|
|
370
|
+
return out;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/** A simple Query's DEALLOCATE or EXECUTE of a statement this connection prepared under a name of the host's own
|
|
374
|
+
* (another connection held the client's), or of a name another live connection holds: the statement it means, never
|
|
375
|
+
* the other connection's. Only statements' own text is rewritten (statementStarts); CREATE TABLE … AS EXECUTE and
|
|
376
|
+
* a DEALLOCATE sent through Parse are not. */
|
|
377
|
+
function sqlNames(session, message) {
|
|
378
|
+
const data = asBuffer(message);
|
|
379
|
+
const sql = data.subarray(5, data.indexOf(0, 5)).toString('latin1');
|
|
380
|
+
// a backslash can end a plain '…' literal (standard_conforming_strings off, which one connection can set for the
|
|
381
|
+
// shared session): such a Query is not read, as statementSkeleton does not read one
|
|
382
|
+
if (sql.includes('\\')) return message;
|
|
383
|
+
let text = sql;
|
|
384
|
+
for (const n of namedStatements(sql).reverse()) {
|
|
385
|
+
const backend = statementName(session, Buffer.from(n.name, 'latin1'), false).toString('latin1');
|
|
386
|
+
if (backend !== n.name) text = `${text.slice(0, n.at)}${n.verb}"${backend}"${text.slice(n.at + n.length)}`;
|
|
387
|
+
}
|
|
388
|
+
if (text === sql) return message;
|
|
389
|
+
const body = Buffer.concat([Buffer.from(text, 'latin1'), Buffer.from([0])]);
|
|
390
|
+
const out = Buffer.alloc(5 + body.length);
|
|
391
|
+
out[0] = 0x51;
|
|
392
|
+
out.writeInt32BE(4 + body.length, 1);
|
|
393
|
+
body.copy(out, 5);
|
|
394
|
+
return new Uint8Array(out.buffer, out.byteOffset, out.byteLength);
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/** What a client message closes: a statement (Close 'S', or a Query that is one DEALLOCATE), by the client's name,
|
|
398
|
+
* or ALL (a Query that is one DEALLOCATE ALL or DISCARD ALL, emptying the one session's statements, every
|
|
399
|
+
* connection's, as they did before scoping). */
|
|
400
|
+
const ALL = Symbol('all');
|
|
401
|
+
function closing(message) {
|
|
402
|
+
const data = asBuffer(message);
|
|
403
|
+
if (data[0] === 0x43 && data[5] === 0x53) { const name = cstring(data, 6).bytes; return name.length ? name : undefined; }
|
|
404
|
+
if (data[0] === 0x51) {
|
|
405
|
+
// one statement alone in its Query (one inside a longer Query settles when the connection closes)
|
|
406
|
+
const sql = data.subarray(5, data.indexOf(0, 5)).toString('latin1');
|
|
407
|
+
const starts = statementStarts(sql);
|
|
408
|
+
if (starts.length !== 1 && !(starts.length === 2 && /^;\s*$/.test(sql.slice(starts[1])))) return undefined;
|
|
409
|
+
if (EMPTIES_ALL.test(sql.slice(starts[0]))) return ALL;
|
|
410
|
+
const named = namedStatements(sql);
|
|
411
|
+
if (named.length === 1 && /^DEALLOCATE/i.test(named[0].verb)) return Buffer.from(named[0].name, 'latin1');
|
|
412
|
+
}
|
|
413
|
+
return undefined;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/** Free what a message closed once the backend has answered it: CloseComplete ('3') or the DEALLOCATE's
|
|
417
|
+
* CommandComplete ('C') first; a Close Postgres skipped (an earlier error in its batch) frees nothing. */
|
|
418
|
+
function settleClose(session, name, response) {
|
|
419
|
+
if (!name || !response || !response.length || (response[0] !== 0x33 && response[0] !== 0x43)) return;
|
|
420
|
+
if (name === ALL) {
|
|
421
|
+
heldStatements.clear();
|
|
422
|
+
for (const other of sessions.values()) other.names.clear();
|
|
423
|
+
} else forget(session, name);
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/** A statement the connection closed (Close 'S'): its backend name is free again. */
|
|
427
|
+
function forget(session, bytes) {
|
|
428
|
+
const backend = session.names.get(bytes.toString('latin1'));
|
|
429
|
+
if (!backend) return;
|
|
430
|
+
session.names.delete(bytes.toString('latin1'));
|
|
431
|
+
if (heldStatements.get(nameKey(backend)) === session) heldStatements.delete(nameKey(backend));
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/** A closed connection's statements, to close on the next clean turn (a leak otherwise: each connection's statements
|
|
435
|
+
* outlived it in the one session, 300 short connections x 20 statements aborted the backend). */
|
|
436
|
+
function releaseStatements(session) {
|
|
437
|
+
for (const backend of session.names.values()) if (heldStatements.get(nameKey(backend)) === session) closedStatements.push(backend);
|
|
438
|
+
session.names.clear();
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/** Close closed connections' statements with the protocol's Close (a SQL DEALLOCATE would replace the unnamed
|
|
442
|
+
* statement a batch may still Bind), at the start of a turn outside any transaction. */
|
|
443
|
+
async function flushClosed(db) {
|
|
444
|
+
while (closedStatements.length) {
|
|
445
|
+
const backend = closedStatements.shift();
|
|
446
|
+
const body = Buffer.concat([Buffer.from('S', 'latin1'), backend, Buffer.from([0])]);
|
|
447
|
+
const close = Buffer.alloc(5 + body.length);
|
|
448
|
+
close[0] = 0x43;
|
|
449
|
+
close.writeInt32BE(4 + body.length, 1);
|
|
450
|
+
body.copy(close, 5);
|
|
451
|
+
try {
|
|
452
|
+
await db.execProtocolRaw(new Uint8Array(close));
|
|
453
|
+
await db.execProtocolRaw(new Uint8Array([0x53, 0, 0, 0, 4])); // Sync
|
|
454
|
+
} catch { /* the backend's own failure surfaces on the next real message */ }
|
|
455
|
+
if (heldStatements.get(nameKey(backend))?.closed) heldStatements.delete(nameKey(backend));
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
|
|
239
459
|
/** Record, per connection, which named statements it prepared read-only (a Parse of its own or a guest's); a
|
|
240
460
|
* PREPARE/DEALLOCATE/DISCARD sent as SQL forgets them all. */
|
|
241
461
|
function noteStatement(session, message) {
|
|
@@ -508,11 +728,13 @@ const server = net.createServer(async (socket) => {
|
|
|
508
728
|
const token = {};
|
|
509
729
|
let holding = false;
|
|
510
730
|
const session = { socket, busy: false, idleSince: Date.now(), terminated: false, closed: false, status: null, midBatch: false,
|
|
511
|
-
wanting: false, guestOpen: false, guestRefused: false, guestUnnamed: false, guestExpired: false, guestErrorSent: false, guestRunning: false, statements: new Map()
|
|
731
|
+
wanting: false, guestOpen: false, guestRefused: false, guestUnnamed: false, guestExpired: false, guestErrorSent: false, guestRunning: false, statements: new Map(),
|
|
732
|
+
names: new Map() };
|
|
512
733
|
sessions.set(token, session);
|
|
513
734
|
socket.on('close', () => {
|
|
514
735
|
session.closed = true;
|
|
515
736
|
sessions.delete(token);
|
|
737
|
+
releaseStatements(session);
|
|
516
738
|
if (guestBatch === token) {
|
|
517
739
|
// a guest gone mid-batch: undo what it did in the holder's transaction
|
|
518
740
|
void dbReady
|
|
@@ -537,6 +759,11 @@ const server = net.createServer(async (socket) => {
|
|
|
537
759
|
auth: { method: 'trust' },
|
|
538
760
|
async onMessage(data, { isAuthenticated, clientParams }) {
|
|
539
761
|
if (!isAuthenticated) return undefined; // gateway owns startup/auth traffic
|
|
762
|
+
const client = data;
|
|
763
|
+
const closes = closing(client);
|
|
764
|
+
// looked up, not claimed, while the turn is decided: a Parse claims its name on the connection's own turn,
|
|
765
|
+
// after the closed connections' names are released (a reconnect's `s0` is its own again)
|
|
766
|
+
data = scopeNames(session, client, false);
|
|
540
767
|
const db = await dbReady;
|
|
541
768
|
// a guest whose turn ran out: told once, the rest of its batch dropped until its Sync
|
|
542
769
|
if (session.guestExpired) {
|
|
@@ -576,6 +803,7 @@ const server = net.createServer(async (socket) => {
|
|
|
576
803
|
return errorResponse('0A000', 'the containerless Postgres (one session) runs only reads beside another connection\'s open transaction; this batch began with a read and went on to write');
|
|
577
804
|
}
|
|
578
805
|
if (!session.guestOpen) { await quiet(db, 'SAVEPOINT volter_guest'); session.guestOpen = true; }
|
|
806
|
+
data = scopeNames(session, client, true);
|
|
579
807
|
noteStatement(session, data);
|
|
580
808
|
if (data[0] === 0x50 && text(asBuffer(data), 5) === '') session.guestUnnamed = true;
|
|
581
809
|
let raw;
|
|
@@ -587,6 +815,7 @@ const server = net.createServer(async (socket) => {
|
|
|
587
815
|
process.exit(1);
|
|
588
816
|
}
|
|
589
817
|
const response = withoutEarlyReady(data, raw);
|
|
818
|
+
settleClose(session, closes, response);
|
|
590
819
|
const status = lastReadyStatus(response);
|
|
591
820
|
if (status === null) { session.guestRunning = false; return response; } // mid-batch: the guest keeps its turn until its Sync
|
|
592
821
|
// the savepoint is gone if the holder's transaction ended meanwhile (its socket closed): quiet, not fatal
|
|
@@ -605,6 +834,9 @@ const server = net.createServer(async (socket) => {
|
|
|
605
834
|
}
|
|
606
835
|
holding = true;
|
|
607
836
|
session.busy = true;
|
|
837
|
+
// a turn that begins outside any transaction first closes what closed connections left
|
|
838
|
+
if (closedStatements.length && !session.midBatch && session.status !== 'T' && session.status !== 'E') await flushClosed(await dbReady);
|
|
839
|
+
data = scopeNames(session, client, true);
|
|
608
840
|
noteStatement(session, data);
|
|
609
841
|
// libpq's default: no database parameter means the user's name
|
|
610
842
|
const own = clientParams?.database || clientParams?.user;
|
|
@@ -621,6 +853,7 @@ const server = net.createServer(async (socket) => {
|
|
|
621
853
|
session.busy = false;
|
|
622
854
|
session.idleSince = Date.now();
|
|
623
855
|
const response = withoutEarlyReady(data, raw);
|
|
856
|
+
settleClose(session, closes, response);
|
|
624
857
|
const status = lastReadyStatus(response);
|
|
625
858
|
session.midBatch = status === null;
|
|
626
859
|
if (status !== null) session.status = status;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@volter/world-runtime",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.8",
|
|
4
4
|
"description": "World configs for twins: boot named local runtimes, allocate ports, generate world.env/instance.json, and run apps against fake-key twin worlds.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"twin",
|
|
@@ -67,14 +67,14 @@
|
|
|
67
67
|
"node": ">=22.3"
|
|
68
68
|
},
|
|
69
69
|
"peerDependencies": {
|
|
70
|
-
"@volter/world-core": "2.0.
|
|
70
|
+
"@volter/world-core": "2.0.5",
|
|
71
71
|
"@volter/world-console": "2.0.6"
|
|
72
72
|
},
|
|
73
73
|
"dependencies": {
|
|
74
74
|
"@electric-sql/pglite": "0.5.8",
|
|
75
75
|
"@electric-sql/pglite-pgvector": "0.0.9",
|
|
76
76
|
"@volter/world-access": "2.0.1",
|
|
77
|
-
"@volter/world-core": "2.0.
|
|
77
|
+
"@volter/world-core": "2.0.5",
|
|
78
78
|
"pg-gateway": "0.3.0-beta.4",
|
|
79
79
|
"smol-toml": "^1.8.0"
|
|
80
80
|
},
|
|
@@ -84,6 +84,6 @@
|
|
|
84
84
|
}
|
|
85
85
|
},
|
|
86
86
|
"optionalDependencies": {
|
|
87
|
-
"@volter/twin-mongodb": "0.1.
|
|
87
|
+
"@volter/twin-mongodb": "0.1.5"
|
|
88
88
|
}
|
|
89
89
|
}
|
package/src/pglite-host.mjs
CHANGED
|
@@ -73,7 +73,13 @@
|
|
|
73
73
|
// ran once that ended, left no ReadyForQuery, and held the lock forever). All connections share the one
|
|
74
74
|
// backend session, so session state leaks between them and outlives the
|
|
75
75
|
// connection that set it: SET (search_path, timezone…), temp tables and
|
|
76
|
-
// PREPAREd statements are visible to every other connection
|
|
76
|
+
// SQL PREPAREd statements are visible to every other connection (a
|
|
77
|
+
// statement prepared over the protocol is the connection's own: a name
|
|
78
|
+
// another live connection holds is renamed for it, and its own SQL
|
|
79
|
+
// EXECUTE/DEALLOCATE of that name is renamed with it; scopeNames — except
|
|
80
|
+
// in CREATE TABLE … AS EXECUTE, a comment between the verb and the name,
|
|
81
|
+
// a Query holding a backslash, and a DEALLOCATE sent through Parse, which
|
|
82
|
+
// reach the name as the backend holds it); session
|
|
77
83
|
// advisory locks do NOT exclude (a second connection's
|
|
78
84
|
// pg_try_advisory_lock on a held key returns true); a NOTIFY is delivered
|
|
79
85
|
// to the connection that sent it, not to the one that ran LISTEN.
|
|
@@ -236,6 +242,220 @@ function readOnlySql(text) {
|
|
|
236
242
|
const text = (data, from) => data.subarray(from, data.indexOf(0, from)).toString('utf8');
|
|
237
243
|
const asBuffer = (m) => Buffer.from(m.buffer, m.byteOffset, m.byteLength);
|
|
238
244
|
|
|
245
|
+
/** Prepared statements' protocol names each connection gives, kept apart where they would collide.
|
|
246
|
+
* Every connection shares the one backend session, and a client names its statements per connection: Prisma's engine
|
|
247
|
+
* starts each process at `s0`, so a second process's first Parse met "prepared statement "s0" already exists" (Postiz
|
|
248
|
+
* walk postiz-2: sign-up, and the post workflow's first read, which left no timer to publish the post). A name no other
|
|
249
|
+
* live connection holds goes to the backend as the client gave it, so SQL on it works as on Postgres; one another
|
|
250
|
+
* live connection holds goes as a short name of the host's own (`volter_s<N>`), and the connection's own simple-Query
|
|
251
|
+
* DEALLOCATE or EXECUTE of it is rewritten to match (sqlNames). Names are compared on their first 63 bytes, as Postgres compares them
|
|
252
|
+
* (NAMEDATALEN), and handled as bytes, never decoded. A closed connection's statements are closed on the next turn
|
|
253
|
+
* that begins outside any transaction (flushClosed). */
|
|
254
|
+
const NAMEDATALEN = 63;
|
|
255
|
+
/** backend statement name (latin1 of its bytes, first 63) -> the session holding it */
|
|
256
|
+
const heldStatements = new Map();
|
|
257
|
+
/** backend statement names of closed connections, closed at the next clean turn */
|
|
258
|
+
const closedStatements = [];
|
|
259
|
+
let renamed = 0;
|
|
260
|
+
/** A name no statement has: what another connection's statement name is, to a connection that never prepared it. */
|
|
261
|
+
const UNPREPARED = Buffer.from('volter_unprepared', 'latin1');
|
|
262
|
+
const nameKey = (bytes) => bytes.subarray(0, NAMEDATALEN).toString('latin1');
|
|
263
|
+
|
|
264
|
+
/** The name at `from` (its bytes, to the NUL) and where the field after it starts. */
|
|
265
|
+
function cstring(data, from) {
|
|
266
|
+
const end = data.indexOf(0, from);
|
|
267
|
+
return { bytes: data.subarray(from, end), next: end + 1 };
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** The backend's name for a statement this connection names `bytes`; `claim` (a Parse on the connection's turn) takes
|
|
271
|
+
* one for a new statement, anything else only looks its name up. */
|
|
272
|
+
function statementName(session, bytes, claim) {
|
|
273
|
+
if (bytes.length === 0) return bytes;
|
|
274
|
+
const own = session.names.get(bytes.toString('latin1'));
|
|
275
|
+
if (own) return own;
|
|
276
|
+
if (!claim) {
|
|
277
|
+
// a statement it never prepared: the backend answers as Postgres does, never with another live connection's
|
|
278
|
+
// statement of that name (it would not exist in this connection's own session)
|
|
279
|
+
const holder = heldStatements.get(nameKey(bytes));
|
|
280
|
+
return holder && holder !== session && !holder.closed ? UNPREPARED : bytes;
|
|
281
|
+
}
|
|
282
|
+
const key = nameKey(bytes);
|
|
283
|
+
const holder = heldStatements.get(key);
|
|
284
|
+
const backend = holder && holder !== session ? Buffer.from(`volter_s${(renamed += 1)}`, 'latin1') : bytes;
|
|
285
|
+
heldStatements.set(nameKey(backend), session);
|
|
286
|
+
session.names.set(bytes.toString('latin1'), backend);
|
|
287
|
+
return backend;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/** The message with its statement names as the backend knows them. Portals keep the client's names: a named portal
|
|
291
|
+
* lives only inside its transaction, and one transaction runs at a time. */
|
|
292
|
+
function scopeNames(session, message, claim) {
|
|
293
|
+
const data = asBuffer(message);
|
|
294
|
+
const kind = data[0];
|
|
295
|
+
let parts;
|
|
296
|
+
if (kind === 0x50 /* P */) {
|
|
297
|
+
const name = cstring(data, 5);
|
|
298
|
+
parts = [statementName(session, name.bytes, claim), Buffer.from([0]), data.subarray(name.next)];
|
|
299
|
+
} else if (kind === 0x42 /* B */) {
|
|
300
|
+
const portal = cstring(data, 5);
|
|
301
|
+
const statement = cstring(data, portal.next);
|
|
302
|
+
parts = [data.subarray(5, portal.next), statementName(session, statement.bytes, false), Buffer.from([0]), data.subarray(statement.next)];
|
|
303
|
+
} else if ((kind === 0x44 /* D */ || kind === 0x43 /* C */) && data[5] === 0x53 /* S */) {
|
|
304
|
+
const name = cstring(data, 6);
|
|
305
|
+
const backend = statementName(session, name.bytes, false);
|
|
306
|
+
parts = [data.subarray(5, 6), backend, Buffer.from([0]), data.subarray(name.next)];
|
|
307
|
+
} else if (kind === 0x51 /* Q */ && claim) {
|
|
308
|
+
return sqlNames(session, message);
|
|
309
|
+
} else {
|
|
310
|
+
return message;
|
|
311
|
+
}
|
|
312
|
+
const body = Buffer.concat(parts);
|
|
313
|
+
if (body.length === data.length - 5 && body.equals(data.subarray(5))) return message;
|
|
314
|
+
const out = Buffer.alloc(5 + body.length);
|
|
315
|
+
out[0] = kind;
|
|
316
|
+
out.writeInt32BE(4 + body.length, 1);
|
|
317
|
+
body.copy(out, 5);
|
|
318
|
+
return new Uint8Array(out.buffer, out.byteOffset, out.byteLength);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/** A statement of a simple Query that DEALLOCATEs or EXECUTEs one statement, at the statement's start: the verb
|
|
322
|
+
* (EXPLAIN's options before EXECUTE included) and the name, quoted or not. */
|
|
323
|
+
const STATEMENT_NAMED = /^((?:EXPLAIN\s+(?:\([^)]*\)\s*)?(?:ANALYZE\s+)?(?:VERBOSE\s+)?)?(?:DEALLOCATE\s+(?:PREPARE\s+)?|EXECUTE\s+))(?:"((?:[^"]|"")+)"|([A-Za-z_][A-Za-z0-9_$]*))(?=[\s(;]|$)/i;
|
|
324
|
+
/** a quoted name as it is; an unquoted one folds to lower case, as Postgres folds it */
|
|
325
|
+
const folded = (quoted, name) => (quoted !== undefined ? quoted.replace(/""/g, '"') : name.toLowerCase());
|
|
326
|
+
const EMPTIES_ALL = /^(?:DEALLOCATE\s+(?:PREPARE\s+)?ALL|DISCARD\s+ALL)\s*;?\s*$/i;
|
|
327
|
+
|
|
328
|
+
/** Where each statement of a Query begins, outside string literals, quoted identifiers, dollar-quoted bodies and
|
|
329
|
+
* comments (a statement's leading comments skipped), so a rewrite never touches a literal's text. */
|
|
330
|
+
function statementStarts(sql) {
|
|
331
|
+
const starts = [];
|
|
332
|
+
let atStart = true;
|
|
333
|
+
for (let i = 0; i < sql.length;) {
|
|
334
|
+
const c = sql[i];
|
|
335
|
+
if (/\s/.test(c)) { i += 1; continue; }
|
|
336
|
+
if (c === '-' && sql[i + 1] === '-') { const end = sql.indexOf('\n', i); i = end < 0 ? sql.length : end + 1; continue; }
|
|
337
|
+
if (c === '/' && sql[i + 1] === '*') {
|
|
338
|
+
let depth = 1; let j = i + 2; // Postgres's block comments nest
|
|
339
|
+
while (j < sql.length && depth > 0) { if (sql.startsWith('/*', j)) { depth += 1; j += 2; } else if (sql.startsWith('*/', j)) { depth -= 1; j += 2; } else j += 1; }
|
|
340
|
+
i = j; continue;
|
|
341
|
+
}
|
|
342
|
+
if (atStart) { starts.push(i); atStart = false; }
|
|
343
|
+
if (c === ';') { atStart = true; i += 1; continue; }
|
|
344
|
+
if (c === "'" || c === '"') {
|
|
345
|
+
// E'…' takes backslash escapes; a plain '…' and "…" end at their quote, a doubled one inside
|
|
346
|
+
const escapes = c === "'" && /[Ee]/.test(sql[i - 1] ?? '') && !/[A-Za-z0-9_]/.test(sql[i - 2] ?? '');
|
|
347
|
+
let j = i + 1;
|
|
348
|
+
while (j < sql.length) {
|
|
349
|
+
if (escapes && sql[j] === '\\') { j += 2; continue; }
|
|
350
|
+
if (sql[j] === c) { if (sql[j + 1] === c) { j += 2; continue; } break; }
|
|
351
|
+
j += 1;
|
|
352
|
+
}
|
|
353
|
+
i = j + 1; continue;
|
|
354
|
+
}
|
|
355
|
+
// a `$` inside an identifier (`a$b$c`) opens nothing, as Postgres's lexer reads it
|
|
356
|
+
const dollar = c === '$' && !/[A-Za-z0-9_$]/.test(sql[i - 1] ?? '') ? /^\$([A-Za-z_][A-Za-z0-9_]*)?\$/.exec(sql.slice(i)) : null;
|
|
357
|
+
if (dollar) { const end = sql.indexOf(dollar[0], i + dollar[0].length); i = end < 0 ? sql.length : end + dollar[0].length; continue; }
|
|
358
|
+
i += 1;
|
|
359
|
+
}
|
|
360
|
+
return starts;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/** Each statement of the Query that DEALLOCATEs or EXECUTEs one name: where it is, its verb and the name. */
|
|
364
|
+
function namedStatements(sql) {
|
|
365
|
+
const out = [];
|
|
366
|
+
for (const at of statementStarts(sql)) {
|
|
367
|
+
const m = STATEMENT_NAMED.exec(sql.slice(at));
|
|
368
|
+
if (m) out.push({ at, length: m[0].length, verb: m[1], name: folded(m[2], m[3]) });
|
|
369
|
+
}
|
|
370
|
+
return out;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/** A simple Query's DEALLOCATE or EXECUTE of a statement this connection prepared under a name of the host's own
|
|
374
|
+
* (another connection held the client's), or of a name another live connection holds: the statement it means, never
|
|
375
|
+
* the other connection's. Only statements' own text is rewritten (statementStarts); CREATE TABLE … AS EXECUTE and
|
|
376
|
+
* a DEALLOCATE sent through Parse are not. */
|
|
377
|
+
function sqlNames(session, message) {
|
|
378
|
+
const data = asBuffer(message);
|
|
379
|
+
const sql = data.subarray(5, data.indexOf(0, 5)).toString('latin1');
|
|
380
|
+
// a backslash can end a plain '…' literal (standard_conforming_strings off, which one connection can set for the
|
|
381
|
+
// shared session): such a Query is not read, as statementSkeleton does not read one
|
|
382
|
+
if (sql.includes('\\')) return message;
|
|
383
|
+
let text = sql;
|
|
384
|
+
for (const n of namedStatements(sql).reverse()) {
|
|
385
|
+
const backend = statementName(session, Buffer.from(n.name, 'latin1'), false).toString('latin1');
|
|
386
|
+
if (backend !== n.name) text = `${text.slice(0, n.at)}${n.verb}"${backend}"${text.slice(n.at + n.length)}`;
|
|
387
|
+
}
|
|
388
|
+
if (text === sql) return message;
|
|
389
|
+
const body = Buffer.concat([Buffer.from(text, 'latin1'), Buffer.from([0])]);
|
|
390
|
+
const out = Buffer.alloc(5 + body.length);
|
|
391
|
+
out[0] = 0x51;
|
|
392
|
+
out.writeInt32BE(4 + body.length, 1);
|
|
393
|
+
body.copy(out, 5);
|
|
394
|
+
return new Uint8Array(out.buffer, out.byteOffset, out.byteLength);
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/** What a client message closes: a statement (Close 'S', or a Query that is one DEALLOCATE), by the client's name,
|
|
398
|
+
* or ALL (a Query that is one DEALLOCATE ALL or DISCARD ALL, emptying the one session's statements, every
|
|
399
|
+
* connection's, as they did before scoping). */
|
|
400
|
+
const ALL = Symbol('all');
|
|
401
|
+
function closing(message) {
|
|
402
|
+
const data = asBuffer(message);
|
|
403
|
+
if (data[0] === 0x43 && data[5] === 0x53) { const name = cstring(data, 6).bytes; return name.length ? name : undefined; }
|
|
404
|
+
if (data[0] === 0x51) {
|
|
405
|
+
// one statement alone in its Query (one inside a longer Query settles when the connection closes)
|
|
406
|
+
const sql = data.subarray(5, data.indexOf(0, 5)).toString('latin1');
|
|
407
|
+
const starts = statementStarts(sql);
|
|
408
|
+
if (starts.length !== 1 && !(starts.length === 2 && /^;\s*$/.test(sql.slice(starts[1])))) return undefined;
|
|
409
|
+
if (EMPTIES_ALL.test(sql.slice(starts[0]))) return ALL;
|
|
410
|
+
const named = namedStatements(sql);
|
|
411
|
+
if (named.length === 1 && /^DEALLOCATE/i.test(named[0].verb)) return Buffer.from(named[0].name, 'latin1');
|
|
412
|
+
}
|
|
413
|
+
return undefined;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/** Free what a message closed once the backend has answered it: CloseComplete ('3') or the DEALLOCATE's
|
|
417
|
+
* CommandComplete ('C') first; a Close Postgres skipped (an earlier error in its batch) frees nothing. */
|
|
418
|
+
function settleClose(session, name, response) {
|
|
419
|
+
if (!name || !response || !response.length || (response[0] !== 0x33 && response[0] !== 0x43)) return;
|
|
420
|
+
if (name === ALL) {
|
|
421
|
+
heldStatements.clear();
|
|
422
|
+
for (const other of sessions.values()) other.names.clear();
|
|
423
|
+
} else forget(session, name);
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/** A statement the connection closed (Close 'S'): its backend name is free again. */
|
|
427
|
+
function forget(session, bytes) {
|
|
428
|
+
const backend = session.names.get(bytes.toString('latin1'));
|
|
429
|
+
if (!backend) return;
|
|
430
|
+
session.names.delete(bytes.toString('latin1'));
|
|
431
|
+
if (heldStatements.get(nameKey(backend)) === session) heldStatements.delete(nameKey(backend));
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/** A closed connection's statements, to close on the next clean turn (a leak otherwise: each connection's statements
|
|
435
|
+
* outlived it in the one session, 300 short connections x 20 statements aborted the backend). */
|
|
436
|
+
function releaseStatements(session) {
|
|
437
|
+
for (const backend of session.names.values()) if (heldStatements.get(nameKey(backend)) === session) closedStatements.push(backend);
|
|
438
|
+
session.names.clear();
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/** Close closed connections' statements with the protocol's Close (a SQL DEALLOCATE would replace the unnamed
|
|
442
|
+
* statement a batch may still Bind), at the start of a turn outside any transaction. */
|
|
443
|
+
async function flushClosed(db) {
|
|
444
|
+
while (closedStatements.length) {
|
|
445
|
+
const backend = closedStatements.shift();
|
|
446
|
+
const body = Buffer.concat([Buffer.from('S', 'latin1'), backend, Buffer.from([0])]);
|
|
447
|
+
const close = Buffer.alloc(5 + body.length);
|
|
448
|
+
close[0] = 0x43;
|
|
449
|
+
close.writeInt32BE(4 + body.length, 1);
|
|
450
|
+
body.copy(close, 5);
|
|
451
|
+
try {
|
|
452
|
+
await db.execProtocolRaw(new Uint8Array(close));
|
|
453
|
+
await db.execProtocolRaw(new Uint8Array([0x53, 0, 0, 0, 4])); // Sync
|
|
454
|
+
} catch { /* the backend's own failure surfaces on the next real message */ }
|
|
455
|
+
if (heldStatements.get(nameKey(backend))?.closed) heldStatements.delete(nameKey(backend));
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
|
|
239
459
|
/** Record, per connection, which named statements it prepared read-only (a Parse of its own or a guest's); a
|
|
240
460
|
* PREPARE/DEALLOCATE/DISCARD sent as SQL forgets them all. */
|
|
241
461
|
function noteStatement(session, message) {
|
|
@@ -508,11 +728,13 @@ const server = net.createServer(async (socket) => {
|
|
|
508
728
|
const token = {};
|
|
509
729
|
let holding = false;
|
|
510
730
|
const session = { socket, busy: false, idleSince: Date.now(), terminated: false, closed: false, status: null, midBatch: false,
|
|
511
|
-
wanting: false, guestOpen: false, guestRefused: false, guestUnnamed: false, guestExpired: false, guestErrorSent: false, guestRunning: false, statements: new Map()
|
|
731
|
+
wanting: false, guestOpen: false, guestRefused: false, guestUnnamed: false, guestExpired: false, guestErrorSent: false, guestRunning: false, statements: new Map(),
|
|
732
|
+
names: new Map() };
|
|
512
733
|
sessions.set(token, session);
|
|
513
734
|
socket.on('close', () => {
|
|
514
735
|
session.closed = true;
|
|
515
736
|
sessions.delete(token);
|
|
737
|
+
releaseStatements(session);
|
|
516
738
|
if (guestBatch === token) {
|
|
517
739
|
// a guest gone mid-batch: undo what it did in the holder's transaction
|
|
518
740
|
void dbReady
|
|
@@ -537,6 +759,11 @@ const server = net.createServer(async (socket) => {
|
|
|
537
759
|
auth: { method: 'trust' },
|
|
538
760
|
async onMessage(data, { isAuthenticated, clientParams }) {
|
|
539
761
|
if (!isAuthenticated) return undefined; // gateway owns startup/auth traffic
|
|
762
|
+
const client = data;
|
|
763
|
+
const closes = closing(client);
|
|
764
|
+
// looked up, not claimed, while the turn is decided: a Parse claims its name on the connection's own turn,
|
|
765
|
+
// after the closed connections' names are released (a reconnect's `s0` is its own again)
|
|
766
|
+
data = scopeNames(session, client, false);
|
|
540
767
|
const db = await dbReady;
|
|
541
768
|
// a guest whose turn ran out: told once, the rest of its batch dropped until its Sync
|
|
542
769
|
if (session.guestExpired) {
|
|
@@ -576,6 +803,7 @@ const server = net.createServer(async (socket) => {
|
|
|
576
803
|
return errorResponse('0A000', 'the containerless Postgres (one session) runs only reads beside another connection\'s open transaction; this batch began with a read and went on to write');
|
|
577
804
|
}
|
|
578
805
|
if (!session.guestOpen) { await quiet(db, 'SAVEPOINT volter_guest'); session.guestOpen = true; }
|
|
806
|
+
data = scopeNames(session, client, true);
|
|
579
807
|
noteStatement(session, data);
|
|
580
808
|
if (data[0] === 0x50 && text(asBuffer(data), 5) === '') session.guestUnnamed = true;
|
|
581
809
|
let raw;
|
|
@@ -587,6 +815,7 @@ const server = net.createServer(async (socket) => {
|
|
|
587
815
|
process.exit(1);
|
|
588
816
|
}
|
|
589
817
|
const response = withoutEarlyReady(data, raw);
|
|
818
|
+
settleClose(session, closes, response);
|
|
590
819
|
const status = lastReadyStatus(response);
|
|
591
820
|
if (status === null) { session.guestRunning = false; return response; } // mid-batch: the guest keeps its turn until its Sync
|
|
592
821
|
// the savepoint is gone if the holder's transaction ended meanwhile (its socket closed): quiet, not fatal
|
|
@@ -605,6 +834,9 @@ const server = net.createServer(async (socket) => {
|
|
|
605
834
|
}
|
|
606
835
|
holding = true;
|
|
607
836
|
session.busy = true;
|
|
837
|
+
// a turn that begins outside any transaction first closes what closed connections left
|
|
838
|
+
if (closedStatements.length && !session.midBatch && session.status !== 'T' && session.status !== 'E') await flushClosed(await dbReady);
|
|
839
|
+
data = scopeNames(session, client, true);
|
|
608
840
|
noteStatement(session, data);
|
|
609
841
|
// libpq's default: no database parameter means the user's name
|
|
610
842
|
const own = clientParams?.database || clientParams?.user;
|
|
@@ -621,6 +853,7 @@ const server = net.createServer(async (socket) => {
|
|
|
621
853
|
session.busy = false;
|
|
622
854
|
session.idleSince = Date.now();
|
|
623
855
|
const response = withoutEarlyReady(data, raw);
|
|
856
|
+
settleClose(session, closes, response);
|
|
624
857
|
const status = lastReadyStatus(response);
|
|
625
858
|
session.midBatch = status === null;
|
|
626
859
|
if (status !== null) session.status = status;
|