@volter/world-runtime 3.0.47 → 3.0.49

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