@volter/world-runtime 3.0.50 → 3.0.51

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.
@@ -40,43 +40,20 @@
40
40
  // multiple databases require the native or container backing.
41
41
  // - No COPY … FROM STDIN: refused with 0A000 (it aborts PGlite's backend);
42
42
  // 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.
43
+ // - ONE backend session, with concurrent wire connections and driver pools.
44
+ // A complete implicit statement or explicit transaction owns it until the
45
+ // backend reports idle; other connections' reads and writes wait their turn.
46
+ // No reader borrows another connection's open transaction. An idle holder
47
+ // is not terminated to admit a waiter. Cross-connection work awaited inside
48
+ // a transaction must wait for that transaction to finish.
49
+ // Session SET, temp tables and session advisory locks remain shared across
50
+ // transactions and outlive their connection; pg_try_advisory_lock on a key
51
+ // another connection holds sees the same session and returns true. NOTIFY
52
+ // answers its sender, not an independent listener, and pg_backend_pid is
53
+ // shared. Existing prepared-name scoping supports pooled Prisma clients;
54
+ // SQL spellings outside its handled forms remain shared. Select native or
55
+ // container PostgreSQL for independent sessions; no gateway emulation of
56
+ // locks, isolation or session state supplies those semantics.
80
57
  //
81
58
  // Database timestamps use the World clock: PGlite reads Date.now() for now(),
82
59
  // CURRENT_TIMESTAMP, clock_timestamp() and transaction starts. Refresh the file
@@ -187,104 +164,34 @@ dbReady.catch((error) => {
187
164
  });
188
165
 
189
166
  // ---- 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
167
  let holder = null;
196
168
  const waiters = [];
197
- /** Each connection's socket, whether a message of it is running, and since
198
- * when it has been idle. */
199
169
  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));
170
+ /** Reserve the next complete protocol cycle/transaction in arrival order. */
171
+ function enter(token, session) {
172
+ if (session.closed) return Promise.resolve(false);
173
+ if (holder === token) return Promise.resolve(true);
174
+ if (holder === null && !waiters.length) {
175
+ holder = token;
176
+ return Promise.resolve(true);
241
177
  }
178
+ return new Promise(resolve => waiters.push({ token, session, resolve }));
242
179
  }
243
180
  function release(token) {
244
181
  if (holder !== token) return;
245
182
  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; }
183
+ while (waiters.length) {
184
+ const next = waiters.shift();
185
+ if (next.session.closed) { next.resolve(false); continue; }
186
+ holder = next.token;
187
+ next.resolve(true);
188
+ break;
276
189
  }
277
- return out.trim();
278
190
  }
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);
191
+ function cancelWait(token) {
192
+ const index = waiters.findIndex(waiter => waiter.token === token);
193
+ if (index < 0) return;
194
+ waiters.splice(index, 1)[0].resolve(false);
288
195
  }
289
196
 
290
197
  const text = (data, from) => data.subarray(from, data.indexOf(0, from)).toString('utf8');
@@ -504,126 +411,6 @@ async function flushClosed(db) {
504
411
  }
505
412
  }
506
413
 
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
414
  /** The transaction status of the LAST ReadyForQuery ('Z') message in a raw
628
415
  * protocol response, or null when the response carries none (mid-pipeline:
629
416
  * the sender keeps the lock). 'I' = idle, 'T' = in transaction, 'E' = failed
@@ -766,37 +553,31 @@ function withoutEarlyReady(message, response) {
766
553
  return response;
767
554
  }
768
555
 
769
- const server = net.createServer(async (socket) => {
556
+ const server = net.createServer((socket) => {
770
557
  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() };
558
+ const session = { closed: false, status: null, midBatch: false, names: new Map(), work: Promise.resolve() };
775
559
  sessions.set(token, session);
776
560
  socket.on('close', () => {
777
561
  session.closed = true;
778
562
  sessions.delete(token);
779
563
  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
- });
564
+ cancelWait(token);
565
+ if (holder !== token) return;
566
+ // Keep ownership while any in-flight message finishes. Sync clears a
567
+ // disconnected extended-protocol error state, then the real backend rolls
568
+ // back the holder before another connection can enter its transaction.
569
+ void session.work.then(async () => {
570
+ const db = await dbReady;
571
+ await db.execProtocolRaw(new Uint8Array([0x53, 0, 0, 0, 4]));
572
+ await db.query('ROLLBACK');
573
+ release(token);
574
+ }).catch(error => {
575
+ process.stderr.write(`pglite-host: connection cleanup failed: ${errorMessage(error)}\n`);
576
+ process.exit(1);
577
+ });
797
578
  });
798
579
  socket.on('error', () => { /* close handler owns cleanup */ });
799
- await fromNodeSocket(socket, {
580
+ void fromNodeSocket(socket, {
800
581
  serverVersion: '18.3 (PGlite)',
801
582
  auth: { method: 'password', getClearTextPassword: () => databasePassword,
802
583
  validateCredentials: ({ username, password }) => username === databaseUser && password === databasePassword },
@@ -806,113 +587,42 @@ const server = net.createServer(async (socket) => {
806
587
  },
807
588
  async onMessage(data, { isAuthenticated, clientParams }) {
808
589
  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') {
590
+ // Gateway owns Terminate; it must neither wait for a turn nor terminate
591
+ // the shared PostgreSQL backend. The close handler owns rollback.
592
+ if (data[0] === 0x58) return undefined;
593
+ if (!await enter(token, session)) return undefined;
594
+ const work = (async () => {
825
595
  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;
596
+ const db = await dbReady;
597
+ readWorldClock();
598
+ if (closedStatements.length && !session.midBatch && session.status !== 'T' && session.status !== 'E') await flushClosed(db);
599
+ if (session.closed) return undefined;
600
+ const closes = closing(data);
601
+ data = scopeNames(session, data, true);
857
602
  let raw;
858
- session.guestRunning = true;
859
603
  try {
860
- raw = await db.execProtocolRaw(refuseStatements(data, own));
604
+ raw = await db.execProtocolRaw(refuseStatements(data, clientParams?.database || clientParams?.user));
861
605
  } catch (error) {
606
+ // SQL errors arrive as protocol bytes. A throw is a backend failure.
862
607
  process.stderr.write(`pglite-host: backend aborted: ${errorMessage(error)}\n`);
863
608
  process.exit(1);
864
609
  }
865
610
  const response = withoutEarlyReady(data, raw);
866
611
  settleClose(session, closes, response);
867
612
  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;
613
+ session.midBatch = status === null;
614
+ if (status !== null) session.status = status;
615
+ // A closed holder retains admission until its close handler confirms
616
+ // rollback, even if its in-flight message reported idle.
617
+ if (status === 'I' && !session.closed) release(token);
618
+ return response;
619
+ })();
620
+ session.work = work;
621
+ return work;
915
622
  },
623
+ }).catch(error => {
624
+ process.stderr.write(`pglite-host: wire connection failed: ${errorMessage(error)}\n`);
625
+ socket.destroy();
916
626
  });
917
627
  });
918
628
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@volter/world-runtime",
3
- "version": "3.0.50",
3
+ "version": "3.0.51",
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",
@@ -69,14 +69,14 @@
69
69
  "node": ">=22.3"
70
70
  },
71
71
  "peerDependencies": {
72
- "@volter/world-core": "3.0.50",
73
- "@volter/world-console": "3.0.45"
72
+ "@volter/world-core": "3.0.51",
73
+ "@volter/world-console": "3.0.46"
74
74
  },
75
75
  "dependencies": {
76
76
  "@electric-sql/pglite": "0.5.8",
77
77
  "@electric-sql/pglite-pgvector": "0.0.9",
78
78
  "@volter/world-access": "3.0.1",
79
- "@volter/world-core": "3.0.50",
79
+ "@volter/world-core": "3.0.51",
80
80
  "mongodb": "~6.20.0",
81
81
  "pg-gateway": "0.3.0-beta.4",
82
82
  "smol-toml": "^1.8.0",
@@ -40,43 +40,20 @@
40
40
  // multiple databases require the native or container backing.
41
41
  // - No COPY … FROM STDIN: refused with 0A000 (it aborts PGlite's backend);
42
42
  // 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.
43
+ // - ONE backend session, with concurrent wire connections and driver pools.
44
+ // A complete implicit statement or explicit transaction owns it until the
45
+ // backend reports idle; other connections' reads and writes wait their turn.
46
+ // No reader borrows another connection's open transaction. An idle holder
47
+ // is not terminated to admit a waiter. Cross-connection work awaited inside
48
+ // a transaction must wait for that transaction to finish.
49
+ // Session SET, temp tables and session advisory locks remain shared across
50
+ // transactions and outlive their connection; pg_try_advisory_lock on a key
51
+ // another connection holds sees the same session and returns true. NOTIFY
52
+ // answers its sender, not an independent listener, and pg_backend_pid is
53
+ // shared. Existing prepared-name scoping supports pooled Prisma clients;
54
+ // SQL spellings outside its handled forms remain shared. Select native or
55
+ // container PostgreSQL for independent sessions; no gateway emulation of
56
+ // locks, isolation or session state supplies those semantics.
80
57
  //
81
58
  // Database timestamps use the World clock: PGlite reads Date.now() for now(),
82
59
  // CURRENT_TIMESTAMP, clock_timestamp() and transaction starts. Refresh the file
@@ -187,104 +164,34 @@ dbReady.catch((error) => {
187
164
  });
188
165
 
189
166
  // ---- 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
167
  let holder = null;
196
168
  const waiters = [];
197
- /** Each connection's socket, whether a message of it is running, and since
198
- * when it has been idle. */
199
169
  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));
170
+ /** Reserve the next complete protocol cycle/transaction in arrival order. */
171
+ function enter(token, session) {
172
+ if (session.closed) return Promise.resolve(false);
173
+ if (holder === token) return Promise.resolve(true);
174
+ if (holder === null && !waiters.length) {
175
+ holder = token;
176
+ return Promise.resolve(true);
241
177
  }
178
+ return new Promise(resolve => waiters.push({ token, session, resolve }));
242
179
  }
243
180
  function release(token) {
244
181
  if (holder !== token) return;
245
182
  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; }
183
+ while (waiters.length) {
184
+ const next = waiters.shift();
185
+ if (next.session.closed) { next.resolve(false); continue; }
186
+ holder = next.token;
187
+ next.resolve(true);
188
+ break;
276
189
  }
277
- return out.trim();
278
190
  }
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);
191
+ function cancelWait(token) {
192
+ const index = waiters.findIndex(waiter => waiter.token === token);
193
+ if (index < 0) return;
194
+ waiters.splice(index, 1)[0].resolve(false);
288
195
  }
289
196
 
290
197
  const text = (data, from) => data.subarray(from, data.indexOf(0, from)).toString('utf8');
@@ -504,126 +411,6 @@ async function flushClosed(db) {
504
411
  }
505
412
  }
506
413
 
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
414
  /** The transaction status of the LAST ReadyForQuery ('Z') message in a raw
628
415
  * protocol response, or null when the response carries none (mid-pipeline:
629
416
  * the sender keeps the lock). 'I' = idle, 'T' = in transaction, 'E' = failed
@@ -766,37 +553,31 @@ function withoutEarlyReady(message, response) {
766
553
  return response;
767
554
  }
768
555
 
769
- const server = net.createServer(async (socket) => {
556
+ const server = net.createServer((socket) => {
770
557
  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() };
558
+ const session = { closed: false, status: null, midBatch: false, names: new Map(), work: Promise.resolve() };
775
559
  sessions.set(token, session);
776
560
  socket.on('close', () => {
777
561
  session.closed = true;
778
562
  sessions.delete(token);
779
563
  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
- });
564
+ cancelWait(token);
565
+ if (holder !== token) return;
566
+ // Keep ownership while any in-flight message finishes. Sync clears a
567
+ // disconnected extended-protocol error state, then the real backend rolls
568
+ // back the holder before another connection can enter its transaction.
569
+ void session.work.then(async () => {
570
+ const db = await dbReady;
571
+ await db.execProtocolRaw(new Uint8Array([0x53, 0, 0, 0, 4]));
572
+ await db.query('ROLLBACK');
573
+ release(token);
574
+ }).catch(error => {
575
+ process.stderr.write(`pglite-host: connection cleanup failed: ${errorMessage(error)}\n`);
576
+ process.exit(1);
577
+ });
797
578
  });
798
579
  socket.on('error', () => { /* close handler owns cleanup */ });
799
- await fromNodeSocket(socket, {
580
+ void fromNodeSocket(socket, {
800
581
  serverVersion: '18.3 (PGlite)',
801
582
  auth: { method: 'password', getClearTextPassword: () => databasePassword,
802
583
  validateCredentials: ({ username, password }) => username === databaseUser && password === databasePassword },
@@ -806,113 +587,42 @@ const server = net.createServer(async (socket) => {
806
587
  },
807
588
  async onMessage(data, { isAuthenticated, clientParams }) {
808
589
  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') {
590
+ // Gateway owns Terminate; it must neither wait for a turn nor terminate
591
+ // the shared PostgreSQL backend. The close handler owns rollback.
592
+ if (data[0] === 0x58) return undefined;
593
+ if (!await enter(token, session)) return undefined;
594
+ const work = (async () => {
825
595
  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;
596
+ const db = await dbReady;
597
+ readWorldClock();
598
+ if (closedStatements.length && !session.midBatch && session.status !== 'T' && session.status !== 'E') await flushClosed(db);
599
+ if (session.closed) return undefined;
600
+ const closes = closing(data);
601
+ data = scopeNames(session, data, true);
857
602
  let raw;
858
- session.guestRunning = true;
859
603
  try {
860
- raw = await db.execProtocolRaw(refuseStatements(data, own));
604
+ raw = await db.execProtocolRaw(refuseStatements(data, clientParams?.database || clientParams?.user));
861
605
  } catch (error) {
606
+ // SQL errors arrive as protocol bytes. A throw is a backend failure.
862
607
  process.stderr.write(`pglite-host: backend aborted: ${errorMessage(error)}\n`);
863
608
  process.exit(1);
864
609
  }
865
610
  const response = withoutEarlyReady(data, raw);
866
611
  settleClose(session, closes, response);
867
612
  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;
613
+ session.midBatch = status === null;
614
+ if (status !== null) session.status = status;
615
+ // A closed holder retains admission until its close handler confirms
616
+ // rollback, even if its in-flight message reported idle.
617
+ if (status === 'I' && !session.closed) release(token);
618
+ return response;
619
+ })();
620
+ session.work = work;
621
+ return work;
915
622
  },
623
+ }).catch(error => {
624
+ process.stderr.write(`pglite-host: wire connection failed: ${errorMessage(error)}\n`);
625
+ socket.destroy();
916
626
  });
917
627
  });
918
628