@volter/world-runtime 2.0.6 → 2.0.7

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.
@@ -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; session
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.6",
3
+ "version": "2.0.7",
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",
@@ -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; session
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;