@volter/world-runtime 3.0.49 → 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.
@@ -10,11 +10,13 @@
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 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.
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.
18
20
  //
19
21
  // Extensions: every contrib extension PGlite 0.5.8 ships (CONTRIB below) plus
20
22
  // pgvector (`vector`, from @electric-sql/pglite-pgvector, pinned to the
@@ -38,11 +40,20 @@
38
40
  // multiple databases require the native or container backing.
39
41
  // - No COPY … FROM STDIN: refused with 0A000 (it aborts PGlite's backend);
40
42
  // COPY … TO STDOUT works.
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.
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.
46
57
  //
47
58
  // Database timestamps use the World clock: PGlite reads Date.now() for now(),
48
59
  // CURRENT_TIMESTAMP, clock_timestamp() and transaction starts. Refresh the file
@@ -152,14 +163,272 @@ dbReady.catch((error) => {
152
163
  process.exit(1);
153
164
  });
154
165
 
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.' });
166
+ // ---- transaction-affinity lock ---------------------------------------------
167
+ let holder = null;
168
+ const waiters = [];
169
+ const sessions = new Map();
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);
177
+ }
178
+ return new Promise(resolve => waiters.push({ token, session, resolve }));
179
+ }
180
+ function release(token) {
181
+ if (holder !== token) return;
182
+ holder = null;
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;
189
+ }
190
+ }
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);
195
+ }
196
+
197
+ const text = (data, from) => data.subarray(from, data.indexOf(0, from)).toString('utf8');
198
+ const asBuffer = (m) => Buffer.from(m.buffer, m.byteOffset, m.byteLength);
199
+
200
+ /** Prepared statements' protocol names each connection gives, kept apart where they would collide.
201
+ * Every connection shares the one backend session, and a client names its statements per connection: Prisma's engine
202
+ * starts each process at `s0`, so a second process's first Parse met "prepared statement "s0" already exists" (Postiz
203
+ * walk postiz-2: sign-up, and the post workflow's first read, which left no timer to publish the post). A name no other
204
+ * live connection holds goes to the backend as the client gave it, so SQL on it works as on Postgres; one another
205
+ * live connection holds goes as a short name of the host's own (`volter_s<N>`), and the connection's own simple-Query
206
+ * DEALLOCATE or EXECUTE of it is rewritten to match (sqlNames). Names are compared on their first 63 bytes, as Postgres compares them
207
+ * (NAMEDATALEN), and handled as bytes, never decoded. A closed connection's statements are closed on the next turn
208
+ * that begins outside any transaction (flushClosed). */
209
+ const NAMEDATALEN = 63;
210
+ /** backend statement name (latin1 of its bytes, first 63) -> the session holding it */
211
+ const heldStatements = new Map();
212
+ /** backend statement names of closed connections, closed at the next clean turn */
213
+ const closedStatements = [];
214
+ let renamed = 0;
215
+ /** A name no statement has: what another connection's statement name is, to a connection that never prepared it. */
216
+ const UNPREPARED = Buffer.from('volter_unprepared', 'latin1');
217
+ const nameKey = (bytes) => bytes.subarray(0, NAMEDATALEN).toString('latin1');
218
+
219
+ /** The name at `from` (its bytes, to the NUL) and where the field after it starts. */
220
+ function cstring(data, from) {
221
+ const end = data.indexOf(0, from);
222
+ return { bytes: data.subarray(from, end), next: end + 1 };
223
+ }
224
+
225
+ /** The backend's name for a statement this connection names `bytes`; `claim` (a Parse on the connection's turn) takes
226
+ * one for a new statement, anything else only looks its name up. */
227
+ function statementName(session, bytes, claim) {
228
+ if (bytes.length === 0) return bytes;
229
+ const own = session.names.get(bytes.toString('latin1'));
230
+ if (own) return own;
231
+ if (!claim) {
232
+ // a statement it never prepared: the backend answers as Postgres does, never with another live connection's
233
+ // statement of that name (it would not exist in this connection's own session)
234
+ const holder = heldStatements.get(nameKey(bytes));
235
+ return holder && holder !== session && !holder.closed ? UNPREPARED : bytes;
236
+ }
237
+ const key = nameKey(bytes);
238
+ const holder = heldStatements.get(key);
239
+ const backend = holder && holder !== session ? Buffer.from(`volter_s${(renamed += 1)}`, 'latin1') : bytes;
240
+ heldStatements.set(nameKey(backend), session);
241
+ session.names.set(bytes.toString('latin1'), backend);
242
+ return backend;
243
+ }
244
+
245
+ /** The message with its statement names as the backend knows them. Portals keep the client's names: a named portal
246
+ * lives only inside its transaction, and one transaction runs at a time. */
247
+ function scopeNames(session, message, claim) {
248
+ const data = asBuffer(message);
249
+ const kind = data[0];
250
+ let parts;
251
+ if (kind === 0x50 /* P */) {
252
+ const name = cstring(data, 5);
253
+ parts = [statementName(session, name.bytes, claim), Buffer.from([0]), data.subarray(name.next)];
254
+ } else if (kind === 0x42 /* B */) {
255
+ const portal = cstring(data, 5);
256
+ const statement = cstring(data, portal.next);
257
+ parts = [data.subarray(5, portal.next), statementName(session, statement.bytes, false), Buffer.from([0]), data.subarray(statement.next)];
258
+ } else if ((kind === 0x44 /* D */ || kind === 0x43 /* C */) && data[5] === 0x53 /* S */) {
259
+ const name = cstring(data, 6);
260
+ const backend = statementName(session, name.bytes, false);
261
+ parts = [data.subarray(5, 6), backend, Buffer.from([0]), data.subarray(name.next)];
262
+ } else if (kind === 0x51 /* Q */ && claim) {
263
+ return sqlNames(session, message);
264
+ } else {
265
+ return message;
266
+ }
267
+ const body = Buffer.concat(parts);
268
+ if (body.length === data.length - 5 && body.equals(data.subarray(5))) return message;
269
+ const out = Buffer.alloc(5 + body.length);
270
+ out[0] = kind;
271
+ out.writeInt32BE(4 + body.length, 1);
272
+ body.copy(out, 5);
273
+ return new Uint8Array(out.buffer, out.byteOffset, out.byteLength);
274
+ }
275
+
276
+ /** A statement of a simple Query that DEALLOCATEs or EXECUTEs one statement, at the statement's start: the verb
277
+ * (EXPLAIN's options before EXECUTE included) and the name, quoted or not. */
278
+ 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;
279
+ /** a quoted name as it is; an unquoted one folds to lower case, as Postgres folds it */
280
+ const folded = (quoted, name) => (quoted !== undefined ? quoted.replace(/""/g, '"') : name.toLowerCase());
281
+ const EMPTIES_ALL = /^(?:DEALLOCATE\s+(?:PREPARE\s+)?ALL|DISCARD\s+ALL)\s*;?\s*$/i;
282
+
283
+ /** Where each statement of a Query begins, outside string literals, quoted identifiers, dollar-quoted bodies and
284
+ * comments (a statement's leading comments skipped), so a rewrite never touches a literal's text. */
285
+ function statementStarts(sql) {
286
+ const starts = [];
287
+ let atStart = true;
288
+ for (let i = 0; i < sql.length;) {
289
+ const c = sql[i];
290
+ if (/\s/.test(c)) { i += 1; continue; }
291
+ if (c === '-' && sql[i + 1] === '-') { const end = sql.indexOf('\n', i); i = end < 0 ? sql.length : end + 1; continue; }
292
+ if (c === '/' && sql[i + 1] === '*') {
293
+ let depth = 1; let j = i + 2; // Postgres's block comments nest
294
+ 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; }
295
+ i = j; continue;
296
+ }
297
+ if (atStart) { starts.push(i); atStart = false; }
298
+ if (c === ';') { atStart = true; i += 1; continue; }
299
+ if (c === "'" || c === '"') {
300
+ // E'…' takes backslash escapes; a plain '…' and "…" end at their quote, a doubled one inside
301
+ const escapes = c === "'" && /[Ee]/.test(sql[i - 1] ?? '') && !/[A-Za-z0-9_]/.test(sql[i - 2] ?? '');
302
+ let j = i + 1;
303
+ while (j < sql.length) {
304
+ if (escapes && sql[j] === '\\') { j += 2; continue; }
305
+ if (sql[j] === c) { if (sql[j + 1] === c) { j += 2; continue; } break; }
306
+ j += 1;
307
+ }
308
+ i = j + 1; continue;
309
+ }
310
+ // a `$` inside an identifier (`a$b$c`) opens nothing, as Postgres's lexer reads it
311
+ const dollar = c === '$' && !/[A-Za-z0-9_$]/.test(sql[i - 1] ?? '') ? /^\$([A-Za-z_][A-Za-z0-9_]*)?\$/.exec(sql.slice(i)) : null;
312
+ if (dollar) { const end = sql.indexOf(dollar[0], i + dollar[0].length); i = end < 0 ? sql.length : end + dollar[0].length; continue; }
313
+ i += 1;
314
+ }
315
+ return starts;
316
+ }
317
+
318
+ /** Each statement of the Query that DEALLOCATEs or EXECUTEs one name: where it is, its verb and the name. */
319
+ function namedStatements(sql) {
320
+ const out = [];
321
+ for (const at of statementStarts(sql)) {
322
+ const m = STATEMENT_NAMED.exec(sql.slice(at));
323
+ if (m) out.push({ at, length: m[0].length, verb: m[1], name: folded(m[2], m[3]) });
324
+ }
325
+ return out;
326
+ }
327
+
328
+ /** A simple Query's DEALLOCATE or EXECUTE of a statement this connection prepared under a name of the host's own
329
+ * (another connection held the client's), or of a name another live connection holds: the statement it means, never
330
+ * the other connection's. Only statements' own text is rewritten (statementStarts); CREATE TABLE … AS EXECUTE and
331
+ * a DEALLOCATE sent through Parse are not. */
332
+ function sqlNames(session, message) {
333
+ const data = asBuffer(message);
334
+ const sql = data.subarray(5, data.indexOf(0, 5)).toString('latin1');
335
+ // a backslash can end a plain '…' literal (standard_conforming_strings off, which one connection can set for the
336
+ // shared session): such a Query is not read, as statementSkeleton does not read one
337
+ if (sql.includes('\\')) return message;
338
+ let text = sql;
339
+ for (const n of namedStatements(sql).reverse()) {
340
+ const backend = statementName(session, Buffer.from(n.name, 'latin1'), false).toString('latin1');
341
+ if (backend !== n.name) text = `${text.slice(0, n.at)}${n.verb}"${backend}"${text.slice(n.at + n.length)}`;
342
+ }
343
+ if (text === sql) return message;
344
+ const body = Buffer.concat([Buffer.from(text, 'latin1'), Buffer.from([0])]);
345
+ const out = Buffer.alloc(5 + body.length);
346
+ out[0] = 0x51;
347
+ out.writeInt32BE(4 + body.length, 1);
348
+ body.copy(out, 5);
349
+ return new Uint8Array(out.buffer, out.byteOffset, out.byteLength);
350
+ }
351
+
352
+ /** What a client message closes: a statement (Close 'S', or a Query that is one DEALLOCATE), by the client's name,
353
+ * or ALL (a Query that is one DEALLOCATE ALL or DISCARD ALL, emptying the one session's statements, every
354
+ * connection's, as they did before scoping). */
355
+ const ALL = Symbol('all');
356
+ function closing(message) {
357
+ const data = asBuffer(message);
358
+ if (data[0] === 0x43 && data[5] === 0x53) { const name = cstring(data, 6).bytes; return name.length ? name : undefined; }
359
+ if (data[0] === 0x51) {
360
+ // one statement alone in its Query (one inside a longer Query settles when the connection closes)
361
+ const sql = data.subarray(5, data.indexOf(0, 5)).toString('latin1');
362
+ const starts = statementStarts(sql);
363
+ if (starts.length !== 1 && !(starts.length === 2 && /^;\s*$/.test(sql.slice(starts[1])))) return undefined;
364
+ if (EMPTIES_ALL.test(sql.slice(starts[0]))) return ALL;
365
+ const named = namedStatements(sql);
366
+ if (named.length === 1 && /^DEALLOCATE/i.test(named[0].verb)) return Buffer.from(named[0].name, 'latin1');
367
+ }
368
+ return undefined;
369
+ }
370
+
371
+ /** Free what a message closed once the backend has answered it: CloseComplete ('3') or the DEALLOCATE's
372
+ * CommandComplete ('C') first; a Close Postgres skipped (an earlier error in its batch) frees nothing. */
373
+ function settleClose(session, name, response) {
374
+ if (!name || !response || !response.length || (response[0] !== 0x33 && response[0] !== 0x43)) return;
375
+ if (name === ALL) {
376
+ heldStatements.clear();
377
+ for (const other of sessions.values()) other.names.clear();
378
+ } else forget(session, name);
379
+ }
380
+
381
+ /** A statement the connection closed (Close 'S'): its backend name is free again. */
382
+ function forget(session, bytes) {
383
+ const backend = session.names.get(bytes.toString('latin1'));
384
+ if (!backend) return;
385
+ session.names.delete(bytes.toString('latin1'));
386
+ if (heldStatements.get(nameKey(backend)) === session) heldStatements.delete(nameKey(backend));
387
+ }
388
+
389
+ /** A closed connection's statements, to close on the next clean turn (a leak otherwise: each connection's statements
390
+ * outlived it in the one session, 300 short connections x 20 statements aborted the backend). */
391
+ function releaseStatements(session) {
392
+ for (const backend of session.names.values()) if (heldStatements.get(nameKey(backend)) === session) closedStatements.push(backend);
393
+ session.names.clear();
394
+ }
395
+
396
+ /** Close closed connections' statements with the protocol's Close (a SQL DEALLOCATE would replace the unnamed
397
+ * statement a batch may still Bind), at the start of a turn outside any transaction. */
398
+ async function flushClosed(db) {
399
+ while (closedStatements.length) {
400
+ const backend = closedStatements.shift();
401
+ const body = Buffer.concat([Buffer.from('S', 'latin1'), backend, Buffer.from([0])]);
402
+ const close = Buffer.alloc(5 + body.length);
403
+ close[0] = 0x43;
404
+ close.writeInt32BE(4 + body.length, 1);
405
+ body.copy(close, 5);
406
+ try {
407
+ await db.execProtocolRaw(new Uint8Array(close));
408
+ await db.execProtocolRaw(new Uint8Array([0x53, 0, 0, 0, 4])); // Sync
409
+ } catch (error) { process.stderr.write(`pglite-host: statement cleanup failed: ${errorMessage(error)}\n`); }
410
+ if (heldStatements.get(nameKey(backend))?.closed) heldStatements.delete(nameKey(backend));
411
+ }
412
+ }
413
+
414
+ /** The transaction status of the LAST ReadyForQuery ('Z') message in a raw
415
+ * protocol response, or null when the response carries none (mid-pipeline:
416
+ * the sender keeps the lock). 'I' = idle, 'T' = in transaction, 'E' = failed
417
+ * transaction. */
418
+ function lastReadyStatus(response) {
419
+ let status = null;
420
+ let offset = 0;
421
+ while (offset + 5 <= response.length) {
422
+ const type = response[offset];
423
+ const length = (response[offset + 1] << 24) | (response[offset + 2] << 16)
424
+ | (response[offset + 3] << 8) | response[offset + 4];
425
+ if (length < 4) break; // malformed; stop scanning rather than loop
426
+ if (type === 0x5a /* 'Z' */ && offset + 5 < response.length) {
427
+ status = String.fromCharCode(response[offset + 5]);
428
+ }
429
+ offset += 1 + length;
430
+ }
431
+ return status;
163
432
  }
164
433
 
165
434
  // ---- CREATE DATABASE refusal -----------------------------------------------
@@ -254,7 +523,7 @@ function refuseStatements(message, own) {
254
523
  // then answers the Sync with a second one. The client counts two and every
255
524
  // later result shifts by one (pg then crashes on a null handler). So the
256
525
  // ReadyForQuery that follows an error from an extended-protocol message is
257
- // dropped; the Sync's own one ends the batch. Messages
526
+ // dropped; the Sync's own one ends the batch (and releases the lock). Messages
258
527
  // that end a simple cycle — Query, Sync, FunctionCall, CopyDone, CopyFail —
259
528
  // keep theirs: Postgres always answers them with one.
260
529
  // Measured on PGlite 0.5.8 by feeding execProtocolRaw directly: Parse of
@@ -285,22 +554,25 @@ function withoutEarlyReady(message, response) {
285
554
  }
286
555
 
287
556
  const server = net.createServer((socket) => {
288
- const session = { closed: false, pending: Promise.resolve() };
557
+ const token = {};
558
+ const session = { closed: false, status: null, midBatch: false, names: new Map(), work: Promise.resolve() };
559
+ sessions.set(token, session);
289
560
  socket.on('close', () => {
290
561
  session.closed = true;
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 () => {
562
+ sessions.delete(token);
563
+ releaseStatements(session);
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 () => {
295
570
  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;
571
+ await db.execProtocolRaw(new Uint8Array([0x53, 0, 0, 0, 4]));
572
+ await db.query('ROLLBACK');
573
+ release(token);
302
574
  }).catch(error => {
303
- process.stderr.write(`pglite-host: session cleanup failed: ${errorMessage(error)}\n`);
575
+ process.stderr.write(`pglite-host: connection cleanup failed: ${errorMessage(error)}\n`);
304
576
  process.exit(1);
305
577
  });
306
578
  });
@@ -313,31 +585,40 @@ const server = net.createServer((socket) => {
313
585
  const requested = clientParams?.database || clientParams?.user;
314
586
  if (requested !== databaseName) throw BackendError.create({ severity: 'FATAL', code: '3D000', message: `database "${requested}" does not exist` });
315
587
  },
316
- onAuthenticated() {
317
- if (activeSession !== null) throw concurrentSession();
318
- if (!session.closed) activeSession = session;
319
- },
320
- onMessage(data, { isAuthenticated }) {
588
+ async onMessage(data, { isAuthenticated, clientParams }) {
321
589
  if (!isAuthenticated) return undefined; // gateway owns startup/auth traffic
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.
590
+ // Gateway owns Terminate; it must neither wait for a turn nor terminate
591
+ // the shared PostgreSQL backend. The close handler owns rollback.
325
592
  if (data[0] === 0x58) return undefined;
326
- const pending = session.pending.then(async () => {
593
+ if (!await enter(token, session)) return undefined;
594
+ const work = (async () => {
327
595
  if (session.closed) return undefined;
328
596
  const db = await dbReady;
329
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);
602
+ let raw;
330
603
  try {
331
- const raw = await db.execProtocolRaw(refuseStatements(data, databaseName));
332
- return withoutEarlyReady(data, raw);
604
+ raw = await db.execProtocolRaw(refuseStatements(data, clientParams?.database || clientParams?.user));
333
605
  } catch (error) {
334
606
  // SQL errors arrive as protocol bytes. A throw is a backend failure.
335
607
  process.stderr.write(`pglite-host: backend aborted: ${errorMessage(error)}\n`);
336
608
  process.exit(1);
337
609
  }
338
- });
339
- session.pending = pending;
340
- return pending;
610
+ const response = withoutEarlyReady(data, raw);
611
+ settleClose(session, closes, response);
612
+ const status = lastReadyStatus(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;
341
622
  },
342
623
  }).catch(error => {
343
624
  process.stderr.write(`pglite-host: wire connection failed: ${errorMessage(error)}\n`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@volter/world-runtime",
3
- "version": "3.0.49",
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.49",
73
- "@volter/world-console": "3.0.44"
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.49",
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",
@@ -10,11 +10,13 @@
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 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.
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.
18
20
  //
19
21
  // Extensions: every contrib extension PGlite 0.5.8 ships (CONTRIB below) plus
20
22
  // pgvector (`vector`, from @electric-sql/pglite-pgvector, pinned to the
@@ -38,11 +40,20 @@
38
40
  // multiple databases require the native or container backing.
39
41
  // - No COPY … FROM STDIN: refused with 0A000 (it aborts PGlite's backend);
40
42
  // COPY … TO STDOUT works.
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.
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.
46
57
  //
47
58
  // Database timestamps use the World clock: PGlite reads Date.now() for now(),
48
59
  // CURRENT_TIMESTAMP, clock_timestamp() and transaction starts. Refresh the file
@@ -152,14 +163,272 @@ dbReady.catch((error) => {
152
163
  process.exit(1);
153
164
  });
154
165
 
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.' });
166
+ // ---- transaction-affinity lock ---------------------------------------------
167
+ let holder = null;
168
+ const waiters = [];
169
+ const sessions = new Map();
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);
177
+ }
178
+ return new Promise(resolve => waiters.push({ token, session, resolve }));
179
+ }
180
+ function release(token) {
181
+ if (holder !== token) return;
182
+ holder = null;
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;
189
+ }
190
+ }
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);
195
+ }
196
+
197
+ const text = (data, from) => data.subarray(from, data.indexOf(0, from)).toString('utf8');
198
+ const asBuffer = (m) => Buffer.from(m.buffer, m.byteOffset, m.byteLength);
199
+
200
+ /** Prepared statements' protocol names each connection gives, kept apart where they would collide.
201
+ * Every connection shares the one backend session, and a client names its statements per connection: Prisma's engine
202
+ * starts each process at `s0`, so a second process's first Parse met "prepared statement "s0" already exists" (Postiz
203
+ * walk postiz-2: sign-up, and the post workflow's first read, which left no timer to publish the post). A name no other
204
+ * live connection holds goes to the backend as the client gave it, so SQL on it works as on Postgres; one another
205
+ * live connection holds goes as a short name of the host's own (`volter_s<N>`), and the connection's own simple-Query
206
+ * DEALLOCATE or EXECUTE of it is rewritten to match (sqlNames). Names are compared on their first 63 bytes, as Postgres compares them
207
+ * (NAMEDATALEN), and handled as bytes, never decoded. A closed connection's statements are closed on the next turn
208
+ * that begins outside any transaction (flushClosed). */
209
+ const NAMEDATALEN = 63;
210
+ /** backend statement name (latin1 of its bytes, first 63) -> the session holding it */
211
+ const heldStatements = new Map();
212
+ /** backend statement names of closed connections, closed at the next clean turn */
213
+ const closedStatements = [];
214
+ let renamed = 0;
215
+ /** A name no statement has: what another connection's statement name is, to a connection that never prepared it. */
216
+ const UNPREPARED = Buffer.from('volter_unprepared', 'latin1');
217
+ const nameKey = (bytes) => bytes.subarray(0, NAMEDATALEN).toString('latin1');
218
+
219
+ /** The name at `from` (its bytes, to the NUL) and where the field after it starts. */
220
+ function cstring(data, from) {
221
+ const end = data.indexOf(0, from);
222
+ return { bytes: data.subarray(from, end), next: end + 1 };
223
+ }
224
+
225
+ /** The backend's name for a statement this connection names `bytes`; `claim` (a Parse on the connection's turn) takes
226
+ * one for a new statement, anything else only looks its name up. */
227
+ function statementName(session, bytes, claim) {
228
+ if (bytes.length === 0) return bytes;
229
+ const own = session.names.get(bytes.toString('latin1'));
230
+ if (own) return own;
231
+ if (!claim) {
232
+ // a statement it never prepared: the backend answers as Postgres does, never with another live connection's
233
+ // statement of that name (it would not exist in this connection's own session)
234
+ const holder = heldStatements.get(nameKey(bytes));
235
+ return holder && holder !== session && !holder.closed ? UNPREPARED : bytes;
236
+ }
237
+ const key = nameKey(bytes);
238
+ const holder = heldStatements.get(key);
239
+ const backend = holder && holder !== session ? Buffer.from(`volter_s${(renamed += 1)}`, 'latin1') : bytes;
240
+ heldStatements.set(nameKey(backend), session);
241
+ session.names.set(bytes.toString('latin1'), backend);
242
+ return backend;
243
+ }
244
+
245
+ /** The message with its statement names as the backend knows them. Portals keep the client's names: a named portal
246
+ * lives only inside its transaction, and one transaction runs at a time. */
247
+ function scopeNames(session, message, claim) {
248
+ const data = asBuffer(message);
249
+ const kind = data[0];
250
+ let parts;
251
+ if (kind === 0x50 /* P */) {
252
+ const name = cstring(data, 5);
253
+ parts = [statementName(session, name.bytes, claim), Buffer.from([0]), data.subarray(name.next)];
254
+ } else if (kind === 0x42 /* B */) {
255
+ const portal = cstring(data, 5);
256
+ const statement = cstring(data, portal.next);
257
+ parts = [data.subarray(5, portal.next), statementName(session, statement.bytes, false), Buffer.from([0]), data.subarray(statement.next)];
258
+ } else if ((kind === 0x44 /* D */ || kind === 0x43 /* C */) && data[5] === 0x53 /* S */) {
259
+ const name = cstring(data, 6);
260
+ const backend = statementName(session, name.bytes, false);
261
+ parts = [data.subarray(5, 6), backend, Buffer.from([0]), data.subarray(name.next)];
262
+ } else if (kind === 0x51 /* Q */ && claim) {
263
+ return sqlNames(session, message);
264
+ } else {
265
+ return message;
266
+ }
267
+ const body = Buffer.concat(parts);
268
+ if (body.length === data.length - 5 && body.equals(data.subarray(5))) return message;
269
+ const out = Buffer.alloc(5 + body.length);
270
+ out[0] = kind;
271
+ out.writeInt32BE(4 + body.length, 1);
272
+ body.copy(out, 5);
273
+ return new Uint8Array(out.buffer, out.byteOffset, out.byteLength);
274
+ }
275
+
276
+ /** A statement of a simple Query that DEALLOCATEs or EXECUTEs one statement, at the statement's start: the verb
277
+ * (EXPLAIN's options before EXECUTE included) and the name, quoted or not. */
278
+ 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;
279
+ /** a quoted name as it is; an unquoted one folds to lower case, as Postgres folds it */
280
+ const folded = (quoted, name) => (quoted !== undefined ? quoted.replace(/""/g, '"') : name.toLowerCase());
281
+ const EMPTIES_ALL = /^(?:DEALLOCATE\s+(?:PREPARE\s+)?ALL|DISCARD\s+ALL)\s*;?\s*$/i;
282
+
283
+ /** Where each statement of a Query begins, outside string literals, quoted identifiers, dollar-quoted bodies and
284
+ * comments (a statement's leading comments skipped), so a rewrite never touches a literal's text. */
285
+ function statementStarts(sql) {
286
+ const starts = [];
287
+ let atStart = true;
288
+ for (let i = 0; i < sql.length;) {
289
+ const c = sql[i];
290
+ if (/\s/.test(c)) { i += 1; continue; }
291
+ if (c === '-' && sql[i + 1] === '-') { const end = sql.indexOf('\n', i); i = end < 0 ? sql.length : end + 1; continue; }
292
+ if (c === '/' && sql[i + 1] === '*') {
293
+ let depth = 1; let j = i + 2; // Postgres's block comments nest
294
+ 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; }
295
+ i = j; continue;
296
+ }
297
+ if (atStart) { starts.push(i); atStart = false; }
298
+ if (c === ';') { atStart = true; i += 1; continue; }
299
+ if (c === "'" || c === '"') {
300
+ // E'…' takes backslash escapes; a plain '…' and "…" end at their quote, a doubled one inside
301
+ const escapes = c === "'" && /[Ee]/.test(sql[i - 1] ?? '') && !/[A-Za-z0-9_]/.test(sql[i - 2] ?? '');
302
+ let j = i + 1;
303
+ while (j < sql.length) {
304
+ if (escapes && sql[j] === '\\') { j += 2; continue; }
305
+ if (sql[j] === c) { if (sql[j + 1] === c) { j += 2; continue; } break; }
306
+ j += 1;
307
+ }
308
+ i = j + 1; continue;
309
+ }
310
+ // a `$` inside an identifier (`a$b$c`) opens nothing, as Postgres's lexer reads it
311
+ const dollar = c === '$' && !/[A-Za-z0-9_$]/.test(sql[i - 1] ?? '') ? /^\$([A-Za-z_][A-Za-z0-9_]*)?\$/.exec(sql.slice(i)) : null;
312
+ if (dollar) { const end = sql.indexOf(dollar[0], i + dollar[0].length); i = end < 0 ? sql.length : end + dollar[0].length; continue; }
313
+ i += 1;
314
+ }
315
+ return starts;
316
+ }
317
+
318
+ /** Each statement of the Query that DEALLOCATEs or EXECUTEs one name: where it is, its verb and the name. */
319
+ function namedStatements(sql) {
320
+ const out = [];
321
+ for (const at of statementStarts(sql)) {
322
+ const m = STATEMENT_NAMED.exec(sql.slice(at));
323
+ if (m) out.push({ at, length: m[0].length, verb: m[1], name: folded(m[2], m[3]) });
324
+ }
325
+ return out;
326
+ }
327
+
328
+ /** A simple Query's DEALLOCATE or EXECUTE of a statement this connection prepared under a name of the host's own
329
+ * (another connection held the client's), or of a name another live connection holds: the statement it means, never
330
+ * the other connection's. Only statements' own text is rewritten (statementStarts); CREATE TABLE … AS EXECUTE and
331
+ * a DEALLOCATE sent through Parse are not. */
332
+ function sqlNames(session, message) {
333
+ const data = asBuffer(message);
334
+ const sql = data.subarray(5, data.indexOf(0, 5)).toString('latin1');
335
+ // a backslash can end a plain '…' literal (standard_conforming_strings off, which one connection can set for the
336
+ // shared session): such a Query is not read, as statementSkeleton does not read one
337
+ if (sql.includes('\\')) return message;
338
+ let text = sql;
339
+ for (const n of namedStatements(sql).reverse()) {
340
+ const backend = statementName(session, Buffer.from(n.name, 'latin1'), false).toString('latin1');
341
+ if (backend !== n.name) text = `${text.slice(0, n.at)}${n.verb}"${backend}"${text.slice(n.at + n.length)}`;
342
+ }
343
+ if (text === sql) return message;
344
+ const body = Buffer.concat([Buffer.from(text, 'latin1'), Buffer.from([0])]);
345
+ const out = Buffer.alloc(5 + body.length);
346
+ out[0] = 0x51;
347
+ out.writeInt32BE(4 + body.length, 1);
348
+ body.copy(out, 5);
349
+ return new Uint8Array(out.buffer, out.byteOffset, out.byteLength);
350
+ }
351
+
352
+ /** What a client message closes: a statement (Close 'S', or a Query that is one DEALLOCATE), by the client's name,
353
+ * or ALL (a Query that is one DEALLOCATE ALL or DISCARD ALL, emptying the one session's statements, every
354
+ * connection's, as they did before scoping). */
355
+ const ALL = Symbol('all');
356
+ function closing(message) {
357
+ const data = asBuffer(message);
358
+ if (data[0] === 0x43 && data[5] === 0x53) { const name = cstring(data, 6).bytes; return name.length ? name : undefined; }
359
+ if (data[0] === 0x51) {
360
+ // one statement alone in its Query (one inside a longer Query settles when the connection closes)
361
+ const sql = data.subarray(5, data.indexOf(0, 5)).toString('latin1');
362
+ const starts = statementStarts(sql);
363
+ if (starts.length !== 1 && !(starts.length === 2 && /^;\s*$/.test(sql.slice(starts[1])))) return undefined;
364
+ if (EMPTIES_ALL.test(sql.slice(starts[0]))) return ALL;
365
+ const named = namedStatements(sql);
366
+ if (named.length === 1 && /^DEALLOCATE/i.test(named[0].verb)) return Buffer.from(named[0].name, 'latin1');
367
+ }
368
+ return undefined;
369
+ }
370
+
371
+ /** Free what a message closed once the backend has answered it: CloseComplete ('3') or the DEALLOCATE's
372
+ * CommandComplete ('C') first; a Close Postgres skipped (an earlier error in its batch) frees nothing. */
373
+ function settleClose(session, name, response) {
374
+ if (!name || !response || !response.length || (response[0] !== 0x33 && response[0] !== 0x43)) return;
375
+ if (name === ALL) {
376
+ heldStatements.clear();
377
+ for (const other of sessions.values()) other.names.clear();
378
+ } else forget(session, name);
379
+ }
380
+
381
+ /** A statement the connection closed (Close 'S'): its backend name is free again. */
382
+ function forget(session, bytes) {
383
+ const backend = session.names.get(bytes.toString('latin1'));
384
+ if (!backend) return;
385
+ session.names.delete(bytes.toString('latin1'));
386
+ if (heldStatements.get(nameKey(backend)) === session) heldStatements.delete(nameKey(backend));
387
+ }
388
+
389
+ /** A closed connection's statements, to close on the next clean turn (a leak otherwise: each connection's statements
390
+ * outlived it in the one session, 300 short connections x 20 statements aborted the backend). */
391
+ function releaseStatements(session) {
392
+ for (const backend of session.names.values()) if (heldStatements.get(nameKey(backend)) === session) closedStatements.push(backend);
393
+ session.names.clear();
394
+ }
395
+
396
+ /** Close closed connections' statements with the protocol's Close (a SQL DEALLOCATE would replace the unnamed
397
+ * statement a batch may still Bind), at the start of a turn outside any transaction. */
398
+ async function flushClosed(db) {
399
+ while (closedStatements.length) {
400
+ const backend = closedStatements.shift();
401
+ const body = Buffer.concat([Buffer.from('S', 'latin1'), backend, Buffer.from([0])]);
402
+ const close = Buffer.alloc(5 + body.length);
403
+ close[0] = 0x43;
404
+ close.writeInt32BE(4 + body.length, 1);
405
+ body.copy(close, 5);
406
+ try {
407
+ await db.execProtocolRaw(new Uint8Array(close));
408
+ await db.execProtocolRaw(new Uint8Array([0x53, 0, 0, 0, 4])); // Sync
409
+ } catch (error) { process.stderr.write(`pglite-host: statement cleanup failed: ${errorMessage(error)}\n`); }
410
+ if (heldStatements.get(nameKey(backend))?.closed) heldStatements.delete(nameKey(backend));
411
+ }
412
+ }
413
+
414
+ /** The transaction status of the LAST ReadyForQuery ('Z') message in a raw
415
+ * protocol response, or null when the response carries none (mid-pipeline:
416
+ * the sender keeps the lock). 'I' = idle, 'T' = in transaction, 'E' = failed
417
+ * transaction. */
418
+ function lastReadyStatus(response) {
419
+ let status = null;
420
+ let offset = 0;
421
+ while (offset + 5 <= response.length) {
422
+ const type = response[offset];
423
+ const length = (response[offset + 1] << 24) | (response[offset + 2] << 16)
424
+ | (response[offset + 3] << 8) | response[offset + 4];
425
+ if (length < 4) break; // malformed; stop scanning rather than loop
426
+ if (type === 0x5a /* 'Z' */ && offset + 5 < response.length) {
427
+ status = String.fromCharCode(response[offset + 5]);
428
+ }
429
+ offset += 1 + length;
430
+ }
431
+ return status;
163
432
  }
164
433
 
165
434
  // ---- CREATE DATABASE refusal -----------------------------------------------
@@ -254,7 +523,7 @@ function refuseStatements(message, own) {
254
523
  // then answers the Sync with a second one. The client counts two and every
255
524
  // later result shifts by one (pg then crashes on a null handler). So the
256
525
  // ReadyForQuery that follows an error from an extended-protocol message is
257
- // dropped; the Sync's own one ends the batch. Messages
526
+ // dropped; the Sync's own one ends the batch (and releases the lock). Messages
258
527
  // that end a simple cycle — Query, Sync, FunctionCall, CopyDone, CopyFail —
259
528
  // keep theirs: Postgres always answers them with one.
260
529
  // Measured on PGlite 0.5.8 by feeding execProtocolRaw directly: Parse of
@@ -285,22 +554,25 @@ function withoutEarlyReady(message, response) {
285
554
  }
286
555
 
287
556
  const server = net.createServer((socket) => {
288
- const session = { closed: false, pending: Promise.resolve() };
557
+ const token = {};
558
+ const session = { closed: false, status: null, midBatch: false, names: new Map(), work: Promise.resolve() };
559
+ sessions.set(token, session);
289
560
  socket.on('close', () => {
290
561
  session.closed = true;
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 () => {
562
+ sessions.delete(token);
563
+ releaseStatements(session);
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 () => {
295
570
  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;
571
+ await db.execProtocolRaw(new Uint8Array([0x53, 0, 0, 0, 4]));
572
+ await db.query('ROLLBACK');
573
+ release(token);
302
574
  }).catch(error => {
303
- process.stderr.write(`pglite-host: session cleanup failed: ${errorMessage(error)}\n`);
575
+ process.stderr.write(`pglite-host: connection cleanup failed: ${errorMessage(error)}\n`);
304
576
  process.exit(1);
305
577
  });
306
578
  });
@@ -313,31 +585,40 @@ const server = net.createServer((socket) => {
313
585
  const requested = clientParams?.database || clientParams?.user;
314
586
  if (requested !== databaseName) throw BackendError.create({ severity: 'FATAL', code: '3D000', message: `database "${requested}" does not exist` });
315
587
  },
316
- onAuthenticated() {
317
- if (activeSession !== null) throw concurrentSession();
318
- if (!session.closed) activeSession = session;
319
- },
320
- onMessage(data, { isAuthenticated }) {
588
+ async onMessage(data, { isAuthenticated, clientParams }) {
321
589
  if (!isAuthenticated) return undefined; // gateway owns startup/auth traffic
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.
590
+ // Gateway owns Terminate; it must neither wait for a turn nor terminate
591
+ // the shared PostgreSQL backend. The close handler owns rollback.
325
592
  if (data[0] === 0x58) return undefined;
326
- const pending = session.pending.then(async () => {
593
+ if (!await enter(token, session)) return undefined;
594
+ const work = (async () => {
327
595
  if (session.closed) return undefined;
328
596
  const db = await dbReady;
329
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);
602
+ let raw;
330
603
  try {
331
- const raw = await db.execProtocolRaw(refuseStatements(data, databaseName));
332
- return withoutEarlyReady(data, raw);
604
+ raw = await db.execProtocolRaw(refuseStatements(data, clientParams?.database || clientParams?.user));
333
605
  } catch (error) {
334
606
  // SQL errors arrive as protocol bytes. A throw is a backend failure.
335
607
  process.stderr.write(`pglite-host: backend aborted: ${errorMessage(error)}\n`);
336
608
  process.exit(1);
337
609
  }
338
- });
339
- session.pending = pending;
340
- return pending;
610
+ const response = withoutEarlyReady(data, raw);
611
+ settleClose(session, closes, response);
612
+ const status = lastReadyStatus(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;
341
622
  },
342
623
  }).catch(error => {
343
624
  process.stderr.write(`pglite-host: wire connection failed: ${errorMessage(error)}\n`);