@volter/world-runtime 3.0.48 → 3.0.49

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.
@@ -10,13 +10,11 @@
10
10
  // server starts listening only once the backend is ready, so a port that
11
11
  // answers is a port that serves.
12
12
  //
13
- // PGlite is a SINGLE-SESSION backend, so this host serializes connections
14
- // with a transaction-affinity lock: a connection acquires the global lock at
15
- // its first protocol message and keeps it for as long as the backend reports
16
- // an open transaction (ReadyForQuery status != 'I'). Without the hold, two
17
- // pooled clients interleave into one session and a ROLLBACK on one swallows
18
- // the other's writes. A connection that dies mid-transaction is rolled back
19
- // before the lock is released.
13
+ // PGlite owns one backend session. Admit one authenticated connection only,
14
+ // keep it until disconnect cleanup finishes, and refuse concurrent sessions
15
+ // with FATAL 53300. The native/container backing supplies independent sessions.
16
+ // The backend performs Sync, ROLLBACK and DISCARD ALL before another client
17
+ // enters; no isolation, advisory-lock or prepared-name behavior is simulated.
20
18
  //
21
19
  // Extensions: every contrib extension PGlite 0.5.8 ships (CONTRIB below) plus
22
20
  // pgvector (`vector`, from @electric-sql/pglite-pgvector, pinned to the
@@ -40,43 +38,11 @@
40
38
  // multiple databases require the native or container backing.
41
39
  // - No COPY … FROM STDIN: refused with 0A000 (it aborts PGlite's backend);
42
40
  // COPY … TO STDOUT works.
43
- // - ONE serialized session. Connections are accepted concurrently but run
44
- // one at a time under the lock below; a client holding a transaction open
45
- // blocks every other client until it ends — or until it has sat idle in
46
- // that transaction (ReadyForQuery 'T' or 'E') for IDLE_IN_TRANSACTION_MS
47
- // while another client waits: then it is terminated with the FATAL Postgres
48
- // sends a session past idle_in_transaction_session_timeout (25P03), its
49
- // transaction rolled back (writes made through it are lost, as on Postgres),
50
- // and the next client runs. This DEVIATES from Postgres, whose timeout is off
51
- // by default and where an idle open transaction holds only its row locks:
52
- // here it would hold the whole database, so a transaction that waits on
53
- // something else (an HTTP call inside it) for longer than the limit while
54
- // another client queues is cut. A READ-ONLY batch another connection sends
55
- // while the holder sits idle in its transaction does not wait: it runs as a
56
- // guest in the holder's session, inside a savepoint (see enter()), and sees
57
- // the holder's uncommitted rows — Postgres would show it only committed ones.
58
- // While a guest's batch runs, the holder waits for it (its idle clock
59
- // stopped); a guest batch still open after GUEST_TURN_MS while anyone waits
60
- // (a cursor that has not sent its Sync) is cancelled (57014), its savepoint
61
- // undone;
62
- // a batch that begins with a read and goes on to write is refused (0A000).
63
- // A connection that closes while it waits
64
- // for the lock gives it up unrun (rallly walk 2: pg-pool evicted an idle
65
- // client — its Terminate queued behind another connection's transaction,
66
- // ran once that ended, left no ReadyForQuery, and held the lock forever). All connections share the one
67
- // backend session, so session state leaks between them and outlives the
68
- // connection that set it: SET (search_path, timezone…), temp tables and
69
- // SQL PREPAREd statements are visible to every other connection (a
70
- // statement prepared over the protocol is the connection's own: a name
71
- // another live connection holds is renamed for it, and its own SQL
72
- // EXECUTE/DEALLOCATE of that name is renamed with it; scopeNames — except
73
- // in CREATE TABLE … AS EXECUTE, a comment between the verb and the name,
74
- // a Query holding a backslash, and a DEALLOCATE sent through Parse, which
75
- // reach the name as the backend holds it); session
76
- // advisory locks do NOT exclude (a second connection's
77
- // pg_try_advisory_lock on a held key returns true); a NOTIFY is delivered
78
- // to the connection that sent it, not to the one that ran LISTEN.
79
- // CREATE ROLE succeeds but connections never run as that role.
41
+ // - ONE wire session, including idle connections. A second authenticated
42
+ // connection fails FATAL 53300. Concurrent pools and distinct backend
43
+ // identities require the native or container backing. Session settings,
44
+ // temp objects, prepared statements, transactions and locks are the backend's
45
+ // own and are reset before its session is reused.
80
46
  //
81
47
  // Database timestamps use the World clock: PGlite reads Date.now() for now(),
82
48
  // CURRENT_TIMESTAMP, clock_timestamp() and transaction starts. Refresh the file
@@ -186,462 +152,14 @@ dbReady.catch((error) => {
186
152
  process.exit(1);
187
153
  });
188
154
 
189
- // ---- transaction-affinity lock ---------------------------------------------
190
- // How long the holder may sit idle inside its transaction while another client
191
- // waits before it is terminated (see the limits above). Prisma's own default
192
- // interactive-transaction timeout is 5s; a session idle that long with others
193
- // queued is one no client is still driving.
194
- const IDLE_IN_TRANSACTION_MS = 5000;
195
- let holder = null;
196
- const waiters = [];
197
- /** Each connection's socket, whether a message of it is running, and since
198
- * when it has been idle. */
199
- const sessions = new Map();
200
- // A GUEST batch: while the holder sits idle inside its transaction (its last
201
- // ReadyForQuery 'T', no message of its own running, none half-sent), another
202
- // connection's READ-ONLY batch runs in the holder's session, inside a savepoint
203
- // released after it (rolled back to on an error, so the holder's transaction is
204
- // never aborted by it), and is answered ReadyForQuery 'I' as its own session
205
- // would be. An application that reads on a second pooled connection while its
206
- // first holds a transaction open (twenty's sign-up: a TypeORM transaction that
207
- // awaits a repository read on another connection) otherwise waits for a lock
208
- // the transaction never gives up. The read sees the holder's uncommitted rows —
209
- // a deviation from Postgres's isolation, stated in the limits above. Writes,
210
- // and a guest's own BEGIN, wait for the lock as before.
211
- let guestBatch = null;
212
- let guestStartedAt = 0;
213
- // How long a guest batch may hold its turn while the holder or a writer waits (a cursor that never sends its Sync
214
- // would otherwise hold every connection up): past it the guest's batch is refused and the waiters run.
215
- const GUEST_TURN_MS = 2000;
216
- function wakeAll() {
217
- for (const wake of waiters.splice(0)) wake();
218
- }
219
- /** How `token` may run its next message: 'own' (it holds the lock, and its turn is claimed here, before any other
220
- * waiter wakes to look) or 'guest'. A holder waiting for its turn is `wanting`: no new guest starts ahead of it. */
221
- async function enter(token, session, data) {
222
- for (;;) {
223
- if (guestBatch === token) return 'guest';
224
- if (guestBatch === null) {
225
- if (holder === null || holder === token) {
226
- holder = token;
227
- session.busy = true;
228
- session.wanting = false;
229
- return 'own';
230
- }
231
- const host = sessions.get(holder);
232
- if (host && !host.busy && !host.wanting && !host.midBatch && host.status === 'T'
233
- && session.status !== 'T' && session.status !== 'E' && guestMessageAllowed(session, data)) {
234
- guestBatch = token;
235
- guestStartedAt = machineNow();
236
- return 'guest';
237
- }
238
- }
239
- if (holder === token) session.wanting = true;
240
- await new Promise((resolve) => waiters.push(resolve));
241
- }
242
- }
243
- function release(token) {
244
- if (holder !== token) return;
245
- holder = null;
246
- wakeAll();
247
- }
248
-
249
- /** A statement's text with its string literals, quoted identifiers and comments blanked, or null when it cannot be
250
- * read safely (a dollar-quoted body, an unterminated literal): the caller then treats it as not read-only. */
251
- function statementSkeleton(text) {
252
- // dollar quoting and backslash-escape strings (E'…', where \' does not close) are not read here: not read-only
253
- // (a backslash anywhere, too: with standard_conforming_strings off, which one connection can set for the shared
254
- // session, a plain '…' takes backslash escapes)
255
- if (/\$[A-Za-z_0-9]*\$/.test(text) || text.includes('\\')) return null;
256
- let out = '';
257
- for (let i = 0; i < text.length;) {
258
- const c = text[i];
259
- if (c === "'" || c === '"') {
260
- const end = text.indexOf(c, i + 1);
261
- if (end < 0) return null;
262
- let j = end;
263
- while (text[j + 1] === c) { const next = text.indexOf(c, j + 2); if (next < 0) return null; j = next; }
264
- out += ' ';
265
- i = j + 1;
266
- } else if (c === '-' && text[i + 1] === '-') {
267
- const end = text.indexOf('\n', i);
268
- i = end < 0 ? text.length : end;
269
- out += ' ';
270
- } else if (c === '/' && text[i + 1] === '*') {
271
- const end = text.indexOf('*/', i + 2);
272
- if (end < 0) return null;
273
- i = end + 2;
274
- out += ' ';
275
- } else { out += c; i += 1; }
276
- }
277
- return out.trim();
278
- }
279
-
280
- /** Whether a statement only reads: one SELECT (or WITH/SHOW/VALUES/TABLE/EXPLAIN without ANALYZE) that writes,
281
- * locks or advances nothing, judged on its text with literals and comments removed. A false "no" only waits. */
282
- function readOnlySql(text) {
283
- const sql = statementSkeleton(text);
284
- if (sql === null) return false;
285
- if (sql.replace(/;\s*$/, '').includes(';')) return false; // one statement only
286
- if (!/^(select|with|show|values|table|explain)\b/i.test(sql)) return false;
287
- return !/\b(insert|update|delete|merge|into|copy|analyze|call|do|set_config|nextval|setval|pg_(try_)?advisory\w*|pg_notify|lo_\w+|for\s+(update|share|no\s+key\s+update|key\s+share))\b/i.test(sql);
288
- }
289
-
290
- const text = (data, from) => data.subarray(from, data.indexOf(0, from)).toString('utf8');
291
- const asBuffer = (m) => Buffer.from(m.buffer, m.byteOffset, m.byteLength);
292
-
293
- /** Prepared statements' protocol names each connection gives, kept apart where they would collide.
294
- * Every connection shares the one backend session, and a client names its statements per connection: Prisma's engine
295
- * starts each process at `s0`, so a second process's first Parse met "prepared statement "s0" already exists" (Postiz
296
- * walk postiz-2: sign-up, and the post workflow's first read, which left no timer to publish the post). A name no other
297
- * live connection holds goes to the backend as the client gave it, so SQL on it works as on Postgres; one another
298
- * live connection holds goes as a short name of the host's own (`volter_s<N>`), and the connection's own simple-Query
299
- * DEALLOCATE or EXECUTE of it is rewritten to match (sqlNames). Names are compared on their first 63 bytes, as Postgres compares them
300
- * (NAMEDATALEN), and handled as bytes, never decoded. A closed connection's statements are closed on the next turn
301
- * that begins outside any transaction (flushClosed). */
302
- const NAMEDATALEN = 63;
303
- /** backend statement name (latin1 of its bytes, first 63) -> the session holding it */
304
- const heldStatements = new Map();
305
- /** backend statement names of closed connections, closed at the next clean turn */
306
- const closedStatements = [];
307
- let renamed = 0;
308
- /** A name no statement has: what another connection's statement name is, to a connection that never prepared it. */
309
- const UNPREPARED = Buffer.from('volter_unprepared', 'latin1');
310
- const nameKey = (bytes) => bytes.subarray(0, NAMEDATALEN).toString('latin1');
311
-
312
- /** The name at `from` (its bytes, to the NUL) and where the field after it starts. */
313
- function cstring(data, from) {
314
- const end = data.indexOf(0, from);
315
- return { bytes: data.subarray(from, end), next: end + 1 };
316
- }
317
-
318
- /** The backend's name for a statement this connection names `bytes`; `claim` (a Parse on the connection's turn) takes
319
- * one for a new statement, anything else only looks its name up. */
320
- function statementName(session, bytes, claim) {
321
- if (bytes.length === 0) return bytes;
322
- const own = session.names.get(bytes.toString('latin1'));
323
- if (own) return own;
324
- if (!claim) {
325
- // a statement it never prepared: the backend answers as Postgres does, never with another live connection's
326
- // statement of that name (it would not exist in this connection's own session)
327
- const holder = heldStatements.get(nameKey(bytes));
328
- return holder && holder !== session && !holder.closed ? UNPREPARED : bytes;
329
- }
330
- const key = nameKey(bytes);
331
- const holder = heldStatements.get(key);
332
- const backend = holder && holder !== session ? Buffer.from(`volter_s${(renamed += 1)}`, 'latin1') : bytes;
333
- heldStatements.set(nameKey(backend), session);
334
- session.names.set(bytes.toString('latin1'), backend);
335
- return backend;
336
- }
337
-
338
- /** The message with its statement names as the backend knows them. Portals keep the client's names: a named portal
339
- * lives only inside its transaction, and one transaction runs at a time. */
340
- function scopeNames(session, message, claim) {
341
- const data = asBuffer(message);
342
- const kind = data[0];
343
- let parts;
344
- if (kind === 0x50 /* P */) {
345
- const name = cstring(data, 5);
346
- parts = [statementName(session, name.bytes, claim), Buffer.from([0]), data.subarray(name.next)];
347
- } else if (kind === 0x42 /* B */) {
348
- const portal = cstring(data, 5);
349
- const statement = cstring(data, portal.next);
350
- parts = [data.subarray(5, portal.next), statementName(session, statement.bytes, false), Buffer.from([0]), data.subarray(statement.next)];
351
- } else if ((kind === 0x44 /* D */ || kind === 0x43 /* C */) && data[5] === 0x53 /* S */) {
352
- const name = cstring(data, 6);
353
- const backend = statementName(session, name.bytes, false);
354
- parts = [data.subarray(5, 6), backend, Buffer.from([0]), data.subarray(name.next)];
355
- } else if (kind === 0x51 /* Q */ && claim) {
356
- return sqlNames(session, message);
357
- } else {
358
- return message;
359
- }
360
- const body = Buffer.concat(parts);
361
- if (body.length === data.length - 5 && body.equals(data.subarray(5))) return message;
362
- const out = Buffer.alloc(5 + body.length);
363
- out[0] = kind;
364
- out.writeInt32BE(4 + body.length, 1);
365
- body.copy(out, 5);
366
- return new Uint8Array(out.buffer, out.byteOffset, out.byteLength);
367
- }
368
-
369
- /** A statement of a simple Query that DEALLOCATEs or EXECUTEs one statement, at the statement's start: the verb
370
- * (EXPLAIN's options before EXECUTE included) and the name, quoted or not. */
371
- 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;
372
- /** a quoted name as it is; an unquoted one folds to lower case, as Postgres folds it */
373
- const folded = (quoted, name) => (quoted !== undefined ? quoted.replace(/""/g, '"') : name.toLowerCase());
374
- const EMPTIES_ALL = /^(?:DEALLOCATE\s+(?:PREPARE\s+)?ALL|DISCARD\s+ALL)\s*;?\s*$/i;
375
-
376
- /** Where each statement of a Query begins, outside string literals, quoted identifiers, dollar-quoted bodies and
377
- * comments (a statement's leading comments skipped), so a rewrite never touches a literal's text. */
378
- function statementStarts(sql) {
379
- const starts = [];
380
- let atStart = true;
381
- for (let i = 0; i < sql.length;) {
382
- const c = sql[i];
383
- if (/\s/.test(c)) { i += 1; continue; }
384
- if (c === '-' && sql[i + 1] === '-') { const end = sql.indexOf('\n', i); i = end < 0 ? sql.length : end + 1; continue; }
385
- if (c === '/' && sql[i + 1] === '*') {
386
- let depth = 1; let j = i + 2; // Postgres's block comments nest
387
- 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; }
388
- i = j; continue;
389
- }
390
- if (atStart) { starts.push(i); atStart = false; }
391
- if (c === ';') { atStart = true; i += 1; continue; }
392
- if (c === "'" || c === '"') {
393
- // E'…' takes backslash escapes; a plain '…' and "…" end at their quote, a doubled one inside
394
- const escapes = c === "'" && /[Ee]/.test(sql[i - 1] ?? '') && !/[A-Za-z0-9_]/.test(sql[i - 2] ?? '');
395
- let j = i + 1;
396
- while (j < sql.length) {
397
- if (escapes && sql[j] === '\\') { j += 2; continue; }
398
- if (sql[j] === c) { if (sql[j + 1] === c) { j += 2; continue; } break; }
399
- j += 1;
400
- }
401
- i = j + 1; continue;
402
- }
403
- // a `$` inside an identifier (`a$b$c`) opens nothing, as Postgres's lexer reads it
404
- const dollar = c === '$' && !/[A-Za-z0-9_$]/.test(sql[i - 1] ?? '') ? /^\$([A-Za-z_][A-Za-z0-9_]*)?\$/.exec(sql.slice(i)) : null;
405
- if (dollar) { const end = sql.indexOf(dollar[0], i + dollar[0].length); i = end < 0 ? sql.length : end + dollar[0].length; continue; }
406
- i += 1;
407
- }
408
- return starts;
409
- }
410
-
411
- /** Each statement of the Query that DEALLOCATEs or EXECUTEs one name: where it is, its verb and the name. */
412
- function namedStatements(sql) {
413
- const out = [];
414
- for (const at of statementStarts(sql)) {
415
- const m = STATEMENT_NAMED.exec(sql.slice(at));
416
- if (m) out.push({ at, length: m[0].length, verb: m[1], name: folded(m[2], m[3]) });
417
- }
418
- return out;
419
- }
420
-
421
- /** A simple Query's DEALLOCATE or EXECUTE of a statement this connection prepared under a name of the host's own
422
- * (another connection held the client's), or of a name another live connection holds: the statement it means, never
423
- * the other connection's. Only statements' own text is rewritten (statementStarts); CREATE TABLE … AS EXECUTE and
424
- * a DEALLOCATE sent through Parse are not. */
425
- function sqlNames(session, message) {
426
- const data = asBuffer(message);
427
- const sql = data.subarray(5, data.indexOf(0, 5)).toString('latin1');
428
- // a backslash can end a plain '…' literal (standard_conforming_strings off, which one connection can set for the
429
- // shared session): such a Query is not read, as statementSkeleton does not read one
430
- if (sql.includes('\\')) return message;
431
- let text = sql;
432
- for (const n of namedStatements(sql).reverse()) {
433
- const backend = statementName(session, Buffer.from(n.name, 'latin1'), false).toString('latin1');
434
- if (backend !== n.name) text = `${text.slice(0, n.at)}${n.verb}"${backend}"${text.slice(n.at + n.length)}`;
435
- }
436
- if (text === sql) return message;
437
- const body = Buffer.concat([Buffer.from(text, 'latin1'), Buffer.from([0])]);
438
- const out = Buffer.alloc(5 + body.length);
439
- out[0] = 0x51;
440
- out.writeInt32BE(4 + body.length, 1);
441
- body.copy(out, 5);
442
- return new Uint8Array(out.buffer, out.byteOffset, out.byteLength);
443
- }
444
-
445
- /** What a client message closes: a statement (Close 'S', or a Query that is one DEALLOCATE), by the client's name,
446
- * or ALL (a Query that is one DEALLOCATE ALL or DISCARD ALL, emptying the one session's statements, every
447
- * connection's, as they did before scoping). */
448
- const ALL = Symbol('all');
449
- function closing(message) {
450
- const data = asBuffer(message);
451
- if (data[0] === 0x43 && data[5] === 0x53) { const name = cstring(data, 6).bytes; return name.length ? name : undefined; }
452
- if (data[0] === 0x51) {
453
- // one statement alone in its Query (one inside a longer Query settles when the connection closes)
454
- const sql = data.subarray(5, data.indexOf(0, 5)).toString('latin1');
455
- const starts = statementStarts(sql);
456
- if (starts.length !== 1 && !(starts.length === 2 && /^;\s*$/.test(sql.slice(starts[1])))) return undefined;
457
- if (EMPTIES_ALL.test(sql.slice(starts[0]))) return ALL;
458
- const named = namedStatements(sql);
459
- if (named.length === 1 && /^DEALLOCATE/i.test(named[0].verb)) return Buffer.from(named[0].name, 'latin1');
460
- }
461
- return undefined;
462
- }
463
-
464
- /** Free what a message closed once the backend has answered it: CloseComplete ('3') or the DEALLOCATE's
465
- * CommandComplete ('C') first; a Close Postgres skipped (an earlier error in its batch) frees nothing. */
466
- function settleClose(session, name, response) {
467
- if (!name || !response || !response.length || (response[0] !== 0x33 && response[0] !== 0x43)) return;
468
- if (name === ALL) {
469
- heldStatements.clear();
470
- for (const other of sessions.values()) other.names.clear();
471
- } else forget(session, name);
472
- }
473
-
474
- /** A statement the connection closed (Close 'S'): its backend name is free again. */
475
- function forget(session, bytes) {
476
- const backend = session.names.get(bytes.toString('latin1'));
477
- if (!backend) return;
478
- session.names.delete(bytes.toString('latin1'));
479
- if (heldStatements.get(nameKey(backend)) === session) heldStatements.delete(nameKey(backend));
480
- }
481
-
482
- /** A closed connection's statements, to close on the next clean turn (a leak otherwise: each connection's statements
483
- * outlived it in the one session, 300 short connections x 20 statements aborted the backend). */
484
- function releaseStatements(session) {
485
- for (const backend of session.names.values()) if (heldStatements.get(nameKey(backend)) === session) closedStatements.push(backend);
486
- session.names.clear();
487
- }
488
-
489
- /** Close closed connections' statements with the protocol's Close (a SQL DEALLOCATE would replace the unnamed
490
- * statement a batch may still Bind), at the start of a turn outside any transaction. */
491
- async function flushClosed(db) {
492
- while (closedStatements.length) {
493
- const backend = closedStatements.shift();
494
- const body = Buffer.concat([Buffer.from('S', 'latin1'), backend, Buffer.from([0])]);
495
- const close = Buffer.alloc(5 + body.length);
496
- close[0] = 0x43;
497
- close.writeInt32BE(4 + body.length, 1);
498
- body.copy(close, 5);
499
- try {
500
- await db.execProtocolRaw(new Uint8Array(close));
501
- await db.execProtocolRaw(new Uint8Array([0x53, 0, 0, 0, 4])); // Sync
502
- } catch (error) { process.stderr.write(`pglite-host: statement cleanup failed: ${errorMessage(error)}\n`); }
503
- if (heldStatements.get(nameKey(backend))?.closed) heldStatements.delete(nameKey(backend));
504
- }
505
- }
506
-
507
- /** Record, per connection, which named statements it prepared read-only (a Parse of its own or a guest's); a
508
- * PREPARE/DEALLOCATE/DISCARD sent as SQL forgets them all. */
509
- function noteStatement(session, message) {
510
- const data = asBuffer(message);
511
- if (data[0] === 0x51 /* Q */ && /\b(prepare|deallocate|discard)\b/i.test(text(data, 5))) session.statements.clear();
512
- else if (data[0] === 0x50 /* P */) {
513
- const name = text(data, 5);
514
- session.statements.set(name, readOnlySql(text(data, 5 + Buffer.byteLength(name) + 1)));
515
- } else if (data[0] === 0x43 /* C */ && data[5] === 0x53 /* S */) {
516
- session.statements.delete(text(data, 6));
517
- }
518
- }
519
-
520
- /** Whether a guest may run this message: a Query or Parse of read-only SQL, a Bind of a statement this connection
521
- * prepared read-only (the unnamed one included), and the batch's Describe/Execute/Sync/Flush/Close. */
522
- function guestMessageAllowed(session, message) {
523
- const data = asBuffer(message);
524
- switch (data[0]) {
525
- case 0x51: return readOnlySql(text(data, 5)); // Q
526
- case 0x50: { const name = text(data, 5); return readOnlySql(text(data, 5 + Buffer.byteLength(name) + 1)); } // P
527
- case 0x42: { // B: portal name, then statement name; the unnamed one only as this batch parsed it
528
- const portal = text(data, 5);
529
- const statement = text(data, 5 + Buffer.byteLength(portal) + 1);
530
- if (statement === '') return session.guestOpen && session.guestUnnamed === true;
531
- return session.statements.get(statement) === true;
532
- }
533
- case 0x44: case 0x45: case 0x53: case 0x48: case 0x43: return session.guestOpen; // D E S H C, inside its batch
534
- default: return false;
535
- }
536
- }
537
-
538
- /** An ErrorResponse (and nothing else): the guest batch refused mid-way. */
539
- function errorResponse(code, message) {
540
- const fields = ['SERROR', 'VERROR', `C${code}`, `M${message}`];
541
- const body = Buffer.concat([...fields.map((f) => Buffer.from(`${f}\0`)), Buffer.from([0])]);
542
- const head = Buffer.alloc(5);
543
- head[0] = 0x45;
544
- head.writeInt32BE(body.length + 4, 1);
545
- return Buffer.concat([head, body]);
546
- }
547
- const READY_IDLE = Buffer.from([0x5a, 0, 0, 0, 5, 0x49]);
548
-
549
- /** A statement of the host's own around a guest; its failure (the holder's transaction already gone) is not fatal. */
550
- async function quiet(db, sql) {
551
- try { await db.query(sql); } catch (error) {
552
- if (error?.code !== '3B001' && error?.code !== '25P01') throw error;
553
- }
554
- }
555
-
556
- /** `response` with its last ReadyForQuery reporting `status`. */
557
- function withReadyStatus(response, status) {
558
- const out = Buffer.from(response);
559
- let offset = 0;
560
- let at = -1;
561
- while (offset + 5 <= out.length) {
562
- const length = out.readInt32BE(offset + 1);
563
- if (length < 4) break;
564
- if (out[offset] === 0x5a && offset + 5 < out.length) at = offset + 5;
565
- offset += 1 + length;
566
- }
567
- if (at >= 0) out[at] = status.charCodeAt(0);
568
- return out;
569
- }
570
-
571
- /** Postgres's FATAL for a session past idle_in_transaction_session_timeout
572
- * (postgres.c: ERRCODE_IDLE_IN_TRANSACTION_SESSION_TIMEOUT, 25P03). */
573
- function idleInTransactionFatal() {
574
- const fields = ['SFATAL', 'VFATAL', 'C25P03', 'Mterminating connection due to idle-in-transaction timeout'];
575
- const body = Buffer.concat([...fields.map((f) => Buffer.from(`${f}\0`)), Buffer.from([0])]);
576
- const head = Buffer.alloc(5);
577
- head[0] = 0x45; // 'E'
578
- head.writeInt32BE(body.length + 4, 1);
579
- return Buffer.concat([head, body]);
580
- }
581
-
582
- // the holder idle in its transaction past the limit while others wait: end it
583
- const idleWatch = setInterval(() => {
584
- // a guest holding its turn past GUEST_TURN_MS while others wait: its batch is refused, its savepoint undone
585
- if (guestBatch !== null && waiters.length > 0 && machineNow() - guestStartedAt > GUEST_TURN_MS) {
586
- const guest = sessions.get(guestBatch);
587
- const expired = guestBatch;
588
- if (guest && guest.guestRefused && !guest.guestRunning) {
589
- // a refused batch that never sends its Sync: its savepoint is already undone; its turn ends
590
- guest.guestRefused = false;
591
- guest.guestExpired = true;
592
- guest.guestErrorSent = true;
593
- const host = sessions.get(holder);
594
- if (host) host.idleSince += machineNow() - guestStartedAt;
595
- guestBatch = null;
596
- wakeAll();
597
- } else if (guest && guest.guestOpen && !guest.guestRunning) {
598
- guest.guestOpen = false;
599
- guest.guestUnnamed = false;
600
- guest.guestExpired = true;
601
- void dbReady.then(async (db) => {
602
- await quiet(db, 'ROLLBACK TO SAVEPOINT volter_guest');
603
- await quiet(db, 'RELEASE SAVEPOINT volter_guest');
604
- }).then(() => {
605
- if (guestBatch !== expired) return;
606
- const host = sessions.get(holder);
607
- if (host) host.idleSince += machineNow() - guestStartedAt;
608
- guestBatch = null;
609
- wakeAll();
610
- });
611
- }
612
- }
613
- if (holder === null || waiters.length === 0) return;
614
- const session = sessions.get(holder);
615
- // armed at ReadyForQuery inside a transaction, as Postgres arms it — never mid-pipeline
616
- // a holder waiting for its turn (behind a guest) is sending, not idle; a guest's turn is not the holder's idleness
617
- if (!session || session.busy || session.wanting || guestBatch !== null || session.terminated || (session.status !== 'T' && session.status !== 'E')) return;
618
- if (machineNow() - session.idleSince < IDLE_IN_TRANSACTION_MS) return;
619
- session.terminated = true;
620
- process.stderr.write('pglite-host: terminated a connection idle in its transaction while others waited\n');
621
- // the socket's close handler rolls the transaction back and releases the lock
622
- session.socket.end(idleInTransactionFatal());
623
- setTimeout(() => session.socket.destroy(), 500).unref();
624
- }, 250);
625
- idleWatch.unref();
626
-
627
- /** The transaction status of the LAST ReadyForQuery ('Z') message in a raw
628
- * protocol response, or null when the response carries none (mid-pipeline:
629
- * the sender keeps the lock). 'I' = idle, 'T' = in transaction, 'E' = failed
630
- * transaction. */
631
- function lastReadyStatus(response) {
632
- let status = null;
633
- let offset = 0;
634
- while (offset + 5 <= response.length) {
635
- const type = response[offset];
636
- const length = (response[offset + 1] << 24) | (response[offset + 2] << 16)
637
- | (response[offset + 3] << 8) | response[offset + 4];
638
- if (length < 4) break; // malformed; stop scanning rather than loop
639
- if (type === 0x5a /* 'Z' */ && offset + 5 < response.length) {
640
- status = String.fromCharCode(response[offset + 5]);
641
- }
642
- offset += 1 + length;
643
- }
644
- return status;
155
+ // ---- exclusive backend session ---------------------------------------------
156
+ let activeSession = null;
157
+ const SYNC = new Uint8Array([0x53, 0, 0, 0, 4]);
158
+ const SESSION_GAP = 'World PGlite supports one PostgreSQL session; select the native or container backing for concurrent connections';
159
+ function concurrentSession() {
160
+ return BackendError.create({ severity: 'FATAL', code: '53300', message: SESSION_GAP,
161
+ detail: 'PGlite has one PostgreSQL backend session; separate wire connections cannot share its transactions or session state faithfully.',
162
+ hint: 'Select VOLTER_WORLD_INFRA_BACKING=native with PostgreSQL installed, or use the container backing.' });
645
163
  }
646
164
 
647
165
  // ---- CREATE DATABASE refusal -----------------------------------------------
@@ -736,7 +254,7 @@ function refuseStatements(message, own) {
736
254
  // then answers the Sync with a second one. The client counts two and every
737
255
  // later result shifts by one (pg then crashes on a null handler). So the
738
256
  // ReadyForQuery that follows an error from an extended-protocol message is
739
- // dropped; the Sync's own one ends the batch (and releases the lock). Messages
257
+ // dropped; the Sync's own one ends the batch. Messages
740
258
  // that end a simple cycle — Query, Sync, FunctionCall, CopyDone, CopyFail —
741
259
  // keep theirs: Postgres always answers them with one.
742
260
  // Measured on PGlite 0.5.8 by feeding execProtocolRaw directly: Parse of
@@ -766,37 +284,28 @@ function withoutEarlyReady(message, response) {
766
284
  return response;
767
285
  }
768
286
 
769
- const server = net.createServer(async (socket) => {
770
- const token = {};
771
- let holding = false;
772
- const session = { socket, busy: false, idleSince: machineNow(), terminated: false, closed: false, status: null, midBatch: false,
773
- wanting: false, guestOpen: false, guestRefused: false, guestUnnamed: false, guestExpired: false, guestErrorSent: false, guestRunning: false, statements: new Map(),
774
- names: new Map() };
775
- sessions.set(token, session);
287
+ const server = net.createServer((socket) => {
288
+ const session = { closed: false, pending: Promise.resolve() };
776
289
  socket.on('close', () => {
777
290
  session.closed = true;
778
- sessions.delete(token);
779
- releaseStatements(session);
780
- if (guestBatch === token) {
781
- // a guest gone mid-batch: undo what it did in the holder's transaction
782
- void dbReady
783
- .then(async (db) => { if (session.guestOpen) { await quiet(db, 'ROLLBACK TO SAVEPOINT volter_guest'); await quiet(db, 'RELEASE SAVEPOINT volter_guest'); } })
784
- .then(() => { session.guestOpen = false; guestBatch = null; wakeAll(); });
785
- return;
786
- }
787
- if (!holding) return;
788
- // the client vanished inside its transaction: roll it back, then let the
789
- // next connection in — never leak a half-open transaction into a stranger
790
- void dbReady
791
- .then((db) => db.query('ROLLBACK'))
792
- .catch(error => { process.stderr.write(`pglite-host: connection cleanup failed: ${errorMessage(error)}\n`); })
793
- .then(() => {
794
- holding = false;
795
- release(token);
796
- });
291
+ if (activeSession !== session) return;
292
+ // Admission stays reserved through pending messages and confirmed reset.
293
+ // Sync also clears a disconnected extended-protocol batch's error state.
294
+ void session.pending.then(async () => {
295
+ const db = await dbReady;
296
+ readWorldClock();
297
+ await db.execProtocolRaw(SYNC);
298
+ await db.exec('ROLLBACK');
299
+ await db.exec('DISCARD ALL');
300
+ await db.exec(`SET SESSION AUTHORIZATION ${quoteIdentifier(databaseUser)}`);
301
+ activeSession = null;
302
+ }).catch(error => {
303
+ process.stderr.write(`pglite-host: session cleanup failed: ${errorMessage(error)}\n`);
304
+ process.exit(1);
305
+ });
797
306
  });
798
307
  socket.on('error', () => { /* close handler owns cleanup */ });
799
- await fromNodeSocket(socket, {
308
+ void fromNodeSocket(socket, {
800
309
  serverVersion: '18.3 (PGlite)',
801
310
  auth: { method: 'password', getClearTextPassword: () => databasePassword,
802
311
  validateCredentials: ({ username, password }) => username === databaseUser && password === databasePassword },
@@ -804,115 +313,35 @@ const server = net.createServer(async (socket) => {
804
313
  const requested = clientParams?.database || clientParams?.user;
805
314
  if (requested !== databaseName) throw BackendError.create({ severity: 'FATAL', code: '3D000', message: `database "${requested}" does not exist` });
806
315
  },
807
- async onMessage(data, { isAuthenticated, clientParams }) {
316
+ onAuthenticated() {
317
+ if (activeSession !== null) throw concurrentSession();
318
+ if (!session.closed) activeSession = session;
319
+ },
320
+ onMessage(data, { isAuthenticated }) {
808
321
  if (!isAuthenticated) return undefined; // gateway owns startup/auth traffic
809
- const client = data;
810
- const closes = closing(client);
811
- // looked up, not claimed, while the turn is decided: a Parse claims its name on the connection's own turn,
812
- // after the closed connections' names are released (a reconnect's `s0` is its own again)
813
- data = scopeNames(session, client, false);
814
- const db = await dbReady;
815
- readWorldClock();
816
- // a guest whose turn ran out: told once, the rest of its batch dropped until its Sync
817
- if (session.guestExpired) {
818
- if (data[0] === 0x53) { session.guestExpired = false; session.guestErrorSent = false; session.status = 'I'; return READY_IDLE; }
819
- if (session.guestErrorSent) return new Uint8Array(0);
820
- session.guestErrorSent = true;
821
- return errorResponse('57014', 'the containerless Postgres (one session) cancelled this read: it ran beside another connection\'s open transaction longer than its turn allows');
822
- }
823
- const role = await enter(token, session, data);
824
- if (role === 'guest') {
322
+ if (activeSession !== session) throw concurrentSession();
323
+ // Gateway owns Terminate; sending it to the shared backend would end the
324
+ // backend itself. Disconnect cleanup resets its session instead.
325
+ if (data[0] === 0x58) return undefined;
326
+ const pending = session.pending.then(async () => {
825
327
  if (session.closed) return undefined;
826
- const own = clientParams?.database || clientParams?.user;
827
- const sync = data[0] === 0x53;
828
- const endGuest = () => {
829
- session.guestOpen = false;
830
- session.guestRefused = false;
831
- session.status = 'I';
832
- guestBatch = null;
833
- session.guestUnnamed = false;
834
- // the holder's idle clock stops while a guest runs, and goes on from where it was
835
- const host = sessions.get(holder);
836
- if (host) host.idleSince += machineNow() - guestStartedAt;
837
- wakeAll();
838
- };
839
- // a batch refused mid-way: its remaining messages are dropped until its Sync, as Postgres skips after an error
840
- if (session.guestRefused) {
841
- if (sync) { endGuest(); return READY_IDLE; }
842
- return new Uint8Array(0);
843
- }
844
- if (session.guestOpen && !guestMessageAllowed(session, data)) {
845
- session.guestRunning = true; // the expiry waits until this refusal is done
846
- await quiet(db, 'ROLLBACK TO SAVEPOINT volter_guest');
847
- await quiet(db, 'RELEASE SAVEPOINT volter_guest');
848
- session.guestOpen = false;
849
- session.guestRefused = true;
850
- session.guestRunning = false;
851
- 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');
852
- }
853
- if (!session.guestOpen) { await quiet(db, 'SAVEPOINT volter_guest'); session.guestOpen = true; }
854
- data = scopeNames(session, client, true);
855
- noteStatement(session, data);
856
- if (data[0] === 0x50 && text(asBuffer(data), 5) === '') session.guestUnnamed = true;
857
- let raw;
858
- session.guestRunning = true;
328
+ const db = await dbReady;
329
+ readWorldClock();
859
330
  try {
860
- raw = await db.execProtocolRaw(refuseStatements(data, own));
331
+ const raw = await db.execProtocolRaw(refuseStatements(data, databaseName));
332
+ return withoutEarlyReady(data, raw);
861
333
  } catch (error) {
334
+ // SQL errors arrive as protocol bytes. A throw is a backend failure.
862
335
  process.stderr.write(`pglite-host: backend aborted: ${errorMessage(error)}\n`);
863
336
  process.exit(1);
864
337
  }
865
- const response = withoutEarlyReady(data, raw);
866
- settleClose(session, closes, response);
867
- const status = lastReadyStatus(response);
868
- if (status === null) { session.guestRunning = false; return response; } // mid-batch: the guest keeps its turn until its Sync
869
- // the savepoint is gone if the holder's transaction ended meanwhile (its socket closed): quiet, not fatal
870
- if (status === 'E') await quiet(db, 'ROLLBACK TO SAVEPOINT volter_guest');
871
- await quiet(db, 'RELEASE SAVEPOINT volter_guest');
872
- session.guestRunning = false; // the batch has ended: nothing left for the expiry to cancel
873
- endGuest();
874
- return withReadyStatus(response, 'I');
875
- }
876
- // closed (or cut) while it waited: give the lock up without running the
877
- // message — a Terminate run now would leave no ReadyForQuery to release it
878
- if (session.closed || session.terminated) {
879
- session.busy = false;
880
- if (!holding) release(token);
881
- return undefined;
882
- }
883
- holding = true;
884
- session.busy = true;
885
- // a turn that begins outside any transaction first closes what closed connections left
886
- if (closedStatements.length && !session.midBatch && session.status !== 'T' && session.status !== 'E') await flushClosed(await dbReady);
887
- data = scopeNames(session, client, true);
888
- noteStatement(session, data);
889
- // libpq's default: no database parameter means the user's name
890
- const own = clientParams?.database || clientParams?.user;
891
- let raw;
892
- try {
893
- raw = await db.execProtocolRaw(refuseStatements(data, own));
894
- } catch (error) {
895
- // SQL errors arrive as ErrorResponse bytes; a throw means the WASM
896
- // backend itself aborted and every later call would hang. Exit so the
897
- // World sees the service down (status fails) instead of a wedged port.
898
- process.stderr.write(`pglite-host: backend aborted: ${errorMessage(error)}\n`);
899
- process.exit(1);
900
- }
901
- session.busy = false;
902
- session.idleSince = machineNow();
903
- const response = withoutEarlyReady(data, raw);
904
- settleClose(session, closes, response);
905
- const status = lastReadyStatus(response);
906
- session.midBatch = status === null;
907
- if (status !== null) session.status = status;
908
- if (status === 'I') {
909
- holding = false;
910
- release(token);
911
- } else if (status === 'T') {
912
- wakeAll(); // idle in its transaction: a waiting reader may now run as a guest
913
- }
914
- return response;
338
+ });
339
+ session.pending = pending;
340
+ return pending;
915
341
  },
342
+ }).catch(error => {
343
+ process.stderr.write(`pglite-host: wire connection failed: ${errorMessage(error)}\n`);
344
+ socket.destroy();
916
345
  });
917
346
  });
918
347