@volter/world-runtime 2.0.0 → 2.0.2

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.
Files changed (78) hide show
  1. package/dist/known-external-services.json +0 -8
  2. package/dist/src/app-url.js +1 -1
  3. package/dist/src/branch.js +79 -2
  4. package/dist/src/catalog.js +1 -1
  5. package/dist/src/cli.js +50 -22
  6. package/dist/src/console-apart.d.ts +1 -0
  7. package/dist/src/console-apart.js +7 -0
  8. package/dist/src/covers.js +4 -2
  9. package/dist/src/fixture-env.d.ts +3 -0
  10. package/dist/src/fixture-env.js +24 -0
  11. package/dist/src/host-worker.js +2 -9
  12. package/dist/src/host.js +2 -9
  13. package/dist/src/import-module.d.ts +1 -0
  14. package/dist/src/import-module.js +16 -0
  15. package/dist/src/index.d.ts +7 -2
  16. package/dist/src/index.js +4 -1
  17. package/dist/src/infra-cli.js +61 -12
  18. package/dist/src/init.d.ts +1 -1
  19. package/dist/src/init.js +34 -5
  20. package/dist/src/local-branches.d.ts +37 -0
  21. package/dist/src/local-branches.js +193 -0
  22. package/dist/src/pglite-backing.d.ts +8 -0
  23. package/dist/src/pglite-backing.js +121 -35
  24. package/dist/src/pglite-host.mjs +520 -14
  25. package/dist/src/prerequisites.js +1 -1
  26. package/dist/src/process-groups.js +1 -1
  27. package/dist/src/redirect-proxy.d.ts +1 -1
  28. package/dist/src/redirect-proxy.js +4 -4
  29. package/dist/src/redis-backing.d.ts +8 -0
  30. package/dist/src/redis-backing.js +120 -0
  31. package/dist/src/root.d.ts +23 -0
  32. package/dist/src/root.js +22 -10
  33. package/dist/src/run-task.js +1 -1
  34. package/dist/src/runtime.d.ts +1 -0
  35. package/dist/src/runtime.js +37 -20
  36. package/dist/src/schema.d.ts +5 -0
  37. package/dist/src/schema.js +10 -1
  38. package/dist/src/served-world.d.ts +196 -9
  39. package/dist/src/served-world.js +847 -103
  40. package/dist/src/service-recorder.js +1 -1
  41. package/dist/src/storage-capacity.js +2 -2
  42. package/dist/src/up-task.js +1 -1
  43. package/dist/src/world-origins.d.ts +13 -0
  44. package/dist/src/world-origins.js +37 -0
  45. package/dist/src/world-view.d.ts +25 -0
  46. package/dist/src/world-view.js +110 -0
  47. package/known-external-services.json +0 -8
  48. package/package.json +10 -4
  49. package/src/app-url.ts +1 -1
  50. package/src/branch.ts +66 -2
  51. package/src/catalog.ts +1 -1
  52. package/src/cli.ts +43 -21
  53. package/src/console-apart.ts +7 -1
  54. package/src/covers.ts +4 -2
  55. package/src/fixture-env.ts +25 -0
  56. package/src/host-worker.ts +2 -1
  57. package/src/host.ts +2 -1
  58. package/src/import-module.ts +9 -0
  59. package/src/index.ts +7 -2
  60. package/src/infra-cli.ts +56 -12
  61. package/src/init.ts +34 -5
  62. package/src/local-branches.ts +173 -0
  63. package/src/pglite-backing.ts +112 -36
  64. package/src/pglite-host.mjs +520 -14
  65. package/src/prerequisites.ts +1 -1
  66. package/src/process-groups.ts +1 -1
  67. package/src/redirect-proxy.ts +4 -4
  68. package/src/redis-backing.ts +107 -0
  69. package/src/root.ts +27 -2
  70. package/src/run-task.ts +1 -1
  71. package/src/runtime.ts +38 -20
  72. package/src/schema.ts +11 -1
  73. package/src/served-world.ts +762 -85
  74. package/src/service-recorder.ts +1 -1
  75. package/src/storage-capacity.ts +2 -2
  76. package/src/up-task.ts +1 -1
  77. package/src/world-origins.ts +38 -0
  78. package/src/world-view.ts +100 -0
@@ -18,16 +18,90 @@
18
18
  // the other's writes. A connection that dies mid-transaction is rolled back
19
19
  // before the lock is released.
20
20
  //
21
- // Identity is aliased, not faked: trust auth accepts the world's declared
22
- // user/password/database, but the backend is PGlite's single `postgres`
23
- // database — apps that introspect current_database() will see that. Worlds
24
- // accept this; an app needing true multi-database Postgres needs the
25
- // container backing.
21
+ // Extensions: every contrib extension PGlite 0.5.8 ships (CONTRIB below) plus
22
+ // pgvector (`vector`, from @electric-sql/pglite-pgvector, pinned to the
23
+ // PGlite version) is loaded at create(), so `CREATE EXTENSION [IF NOT EXISTS]
24
+ // pgcrypto | citext | "uuid-ossp" | unaccent | pg_trgm | btree_gist | hstore |
25
+ // ltree | vector | …` behaves as on a Postgres with contrib installed. Nothing
26
+ // is created up front — the app's migrations create what they use. An
27
+ // extension outside this set (postgis, pg_cron, timescaledb, …) is absent
28
+ // from pg_available_extensions and CREATE EXTENSION fails with Postgres's own
29
+ // "extension … is not available" error. Loading the set costs boot time:
30
+ // PGlite.create() in-memory, node 22 on darwin arm64 (this repo's dev box,
31
+ // 2026-09-27, nothing else running in the process) took 1.66s bare and
32
+ // 2.47s with the set; RSS was within 50 MB either way.
33
+ //
34
+ // Limits, stated plainly (measured on 0.5.8 through this gateway with `pg`
35
+ // 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.
48
+ // - No COPY … FROM STDIN: refused with 0A000 (it aborts PGlite's backend);
49
+ // 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
+ // PREPAREd statements are visible to every other connection; session
77
+ // advisory locks do NOT exclude (a second connection's
78
+ // pg_try_advisory_lock on a held key returns true); a NOTIFY is delivered
79
+ // to the connection that sent it, not to the one that ran LISTEN.
80
+ // CREATE ROLE succeeds but connections never run as that role.
26
81
  import net from 'node:net';
27
82
  import { mkdirSync } from 'node:fs';
28
83
  import { PGlite } from '@electric-sql/pglite';
84
+ import { vector } from '@electric-sql/pglite-pgvector';
29
85
  import { fromNodeSocket } from 'pg-gateway/node';
30
86
 
87
+ // The contrib extensions shipped in @electric-sql/pglite/dist/contrib (the
88
+ // module name; `uuid_ossp` installs as "uuid-ossp"). auto_explain is omitted:
89
+ // it is a preload library with no CREATE EXTENSION control file.
90
+ const CONTRIB = [
91
+ 'amcheck', 'bloom', 'btree_gin', 'btree_gist', 'citext', 'cube', 'dict_int',
92
+ 'dict_xsyn', 'earthdistance', 'file_fdw', 'fuzzystrmatch', 'hstore',
93
+ 'intarray', 'isn', 'lo', 'ltree', 'moddatetime', 'pageinspect',
94
+ 'pg_buffercache', 'pg_freespacemap', 'pg_stat_statements', 'pg_surgery',
95
+ 'pg_trgm', 'pg_visibility', 'pg_walinspect', 'pgcrypto', 'seg', 'tablefunc',
96
+ 'tcn', 'tsm_system_rows', 'tsm_system_time', 'unaccent', 'uuid_ossp',
97
+ ];
98
+ const extensions = { vector };
99
+ for (const name of CONTRIB) {
100
+ const extension = (await import(`@electric-sql/pglite/contrib/${name}`))[name];
101
+ if (!extension) throw new Error(`pglite-host: @electric-sql/pglite/contrib/${name} exports no ${name}`);
102
+ extensions[name] = extension;
103
+ }
104
+
31
105
  function arg(name) {
32
106
  const index = process.argv.indexOf(name);
33
107
  const value = index >= 0 ? process.argv[index + 1] : undefined;
@@ -52,28 +126,234 @@ process.stderr.on('error', () => {});
52
126
  // PGlite's create() only progresses while something holds the event loop;
53
127
  // listening starts AFTER readiness so "port answers" means "serves queries"
54
128
  const initKeepAlive = setInterval(() => {}, 500);
55
- const dbReady = PGlite.create({ dataDir });
129
+ const dbReady = PGlite.create({ dataDir, extensions });
56
130
  dbReady.catch((error) => {
57
131
  process.stderr.write(`pglite-host: backend failed to start: ${error?.message ?? error}\n`);
58
132
  process.exit(1);
59
133
  });
60
134
 
61
135
  // ---- transaction-affinity lock ---------------------------------------------
136
+ // How long the holder may sit idle inside its transaction while another client
137
+ // waits before it is terminated (see the limits above). Prisma's own default
138
+ // interactive-transaction timeout is 5s; a session idle that long with others
139
+ // queued is one no client is still driving.
140
+ const IDLE_IN_TRANSACTION_MS = 5000;
62
141
  let holder = null;
63
142
  const waiters = [];
64
- async function acquire(token) {
65
- while (holder !== null && holder !== token) {
143
+ /** Each connection's socket, whether a message of it is running, and since
144
+ * when it has been idle. */
145
+ const sessions = new Map();
146
+ // A GUEST batch: while the holder sits idle inside its transaction (its last
147
+ // ReadyForQuery 'T', no message of its own running, none half-sent), another
148
+ // connection's READ-ONLY batch runs in the holder's session, inside a savepoint
149
+ // released after it (rolled back to on an error, so the holder's transaction is
150
+ // never aborted by it), and is answered ReadyForQuery 'I' as its own session
151
+ // would be. An application that reads on a second pooled connection while its
152
+ // first holds a transaction open (twenty's sign-up: a TypeORM transaction that
153
+ // awaits a repository read on another connection) otherwise waits for a lock
154
+ // the transaction never gives up. The read sees the holder's uncommitted rows —
155
+ // a deviation from Postgres's isolation, stated in the limits above. Writes,
156
+ // and a guest's own BEGIN, wait for the lock as before.
157
+ let guestBatch = null;
158
+ let guestStartedAt = 0;
159
+ // How long a guest batch may hold its turn while the holder or a writer waits (a cursor that never sends its Sync
160
+ // would otherwise hold every connection up): past it the guest's batch is refused and the waiters run.
161
+ const GUEST_TURN_MS = 2000;
162
+ function wakeAll() {
163
+ for (const wake of waiters.splice(0)) wake();
164
+ }
165
+ /** How `token` may run its next message: 'own' (it holds the lock, and its turn is claimed here, before any other
166
+ * waiter wakes to look) or 'guest'. A holder waiting for its turn is `wanting`: no new guest starts ahead of it. */
167
+ async function enter(token, session, data) {
168
+ for (;;) {
169
+ if (guestBatch === token) return 'guest';
170
+ if (guestBatch === null) {
171
+ if (holder === null || holder === token) {
172
+ holder = token;
173
+ session.busy = true;
174
+ session.wanting = false;
175
+ return 'own';
176
+ }
177
+ const host = sessions.get(holder);
178
+ if (host && !host.busy && !host.wanting && !host.midBatch && host.status === 'T'
179
+ && session.status !== 'T' && session.status !== 'E' && guestMessageAllowed(session, data)) {
180
+ guestBatch = token;
181
+ guestStartedAt = Date.now();
182
+ return 'guest';
183
+ }
184
+ }
185
+ if (holder === token) session.wanting = true;
66
186
  await new Promise((resolve) => waiters.push(resolve));
67
187
  }
68
- holder = token;
69
188
  }
70
189
  function release(token) {
71
190
  if (holder !== token) return;
72
191
  holder = null;
73
- const next = waiters.shift();
74
- if (next) next();
192
+ wakeAll();
193
+ }
194
+
195
+ /** A statement's text with its string literals, quoted identifiers and comments blanked, or null when it cannot be
196
+ * read safely (a dollar-quoted body, an unterminated literal): the caller then treats it as not read-only. */
197
+ function statementSkeleton(text) {
198
+ // dollar quoting and backslash-escape strings (E'…', where \' does not close) are not read here: not read-only
199
+ // (a backslash anywhere, too: with standard_conforming_strings off, which one connection can set for the shared
200
+ // session, a plain '…' takes backslash escapes)
201
+ if (/\$[A-Za-z_0-9]*\$/.test(text) || text.includes('\\')) return null;
202
+ let out = '';
203
+ for (let i = 0; i < text.length;) {
204
+ const c = text[i];
205
+ if (c === "'" || c === '"') {
206
+ const end = text.indexOf(c, i + 1);
207
+ if (end < 0) return null;
208
+ let j = end;
209
+ while (text[j + 1] === c) { const next = text.indexOf(c, j + 2); if (next < 0) return null; j = next; }
210
+ out += ' ';
211
+ i = j + 1;
212
+ } else if (c === '-' && text[i + 1] === '-') {
213
+ const end = text.indexOf('\n', i);
214
+ i = end < 0 ? text.length : end;
215
+ out += ' ';
216
+ } else if (c === '/' && text[i + 1] === '*') {
217
+ const end = text.indexOf('*/', i + 2);
218
+ if (end < 0) return null;
219
+ i = end + 2;
220
+ out += ' ';
221
+ } else { out += c; i += 1; }
222
+ }
223
+ return out.trim();
224
+ }
225
+
226
+ /** Whether a statement only reads: one SELECT (or WITH/SHOW/VALUES/TABLE/EXPLAIN without ANALYZE) that writes,
227
+ * locks or advances nothing, judged on its text with literals and comments removed. A false "no" only waits. */
228
+ function readOnlySql(text) {
229
+ const sql = statementSkeleton(text);
230
+ if (sql === null) return false;
231
+ if (sql.replace(/;\s*$/, '').includes(';')) return false; // one statement only
232
+ if (!/^(select|with|show|values|table|explain)\b/i.test(sql)) return false;
233
+ 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);
234
+ }
235
+
236
+ const text = (data, from) => data.subarray(from, data.indexOf(0, from)).toString('utf8');
237
+ const asBuffer = (m) => Buffer.from(m.buffer, m.byteOffset, m.byteLength);
238
+
239
+ /** Record, per connection, which named statements it prepared read-only (a Parse of its own or a guest's); a
240
+ * PREPARE/DEALLOCATE/DISCARD sent as SQL forgets them all. */
241
+ function noteStatement(session, message) {
242
+ const data = asBuffer(message);
243
+ if (data[0] === 0x51 /* Q */ && /\b(prepare|deallocate|discard)\b/i.test(text(data, 5))) session.statements.clear();
244
+ else if (data[0] === 0x50 /* P */) {
245
+ const name = text(data, 5);
246
+ session.statements.set(name, readOnlySql(text(data, 5 + Buffer.byteLength(name) + 1)));
247
+ } else if (data[0] === 0x43 /* C */ && data[5] === 0x53 /* S */) {
248
+ session.statements.delete(text(data, 6));
249
+ }
250
+ }
251
+
252
+ /** Whether a guest may run this message: a Query or Parse of read-only SQL, a Bind of a statement this connection
253
+ * prepared read-only (the unnamed one included), and the batch's Describe/Execute/Sync/Flush/Close. */
254
+ function guestMessageAllowed(session, message) {
255
+ const data = asBuffer(message);
256
+ switch (data[0]) {
257
+ case 0x51: return readOnlySql(text(data, 5)); // Q
258
+ case 0x50: { const name = text(data, 5); return readOnlySql(text(data, 5 + Buffer.byteLength(name) + 1)); } // P
259
+ case 0x42: { // B: portal name, then statement name; the unnamed one only as this batch parsed it
260
+ const portal = text(data, 5);
261
+ const statement = text(data, 5 + Buffer.byteLength(portal) + 1);
262
+ if (statement === '') return session.guestOpen && session.guestUnnamed === true;
263
+ return session.statements.get(statement) === true;
264
+ }
265
+ case 0x44: case 0x45: case 0x53: case 0x48: case 0x43: return session.guestOpen; // D E S H C, inside its batch
266
+ default: return false;
267
+ }
268
+ }
269
+
270
+ /** An ErrorResponse (and nothing else): the guest batch refused mid-way. */
271
+ function errorResponse(code, message) {
272
+ const fields = ['SERROR', 'VERROR', `C${code}`, `M${message}`];
273
+ const body = Buffer.concat([...fields.map((f) => Buffer.from(`${f}\0`)), Buffer.from([0])]);
274
+ const head = Buffer.alloc(5);
275
+ head[0] = 0x45;
276
+ head.writeInt32BE(body.length + 4, 1);
277
+ return Buffer.concat([head, body]);
278
+ }
279
+ const READY_IDLE = Buffer.from([0x5a, 0, 0, 0, 5, 0x49]);
280
+
281
+ /** A statement of the host's own around a guest; its failure (the holder's transaction already gone) is not fatal. */
282
+ async function quiet(db, sql) {
283
+ try { await db.query(sql); } catch { /* the savepoint went with the transaction */ }
284
+ }
285
+
286
+ /** `response` with its last ReadyForQuery reporting `status`. */
287
+ function withReadyStatus(response, status) {
288
+ const out = Buffer.from(response);
289
+ let offset = 0;
290
+ let at = -1;
291
+ while (offset + 5 <= out.length) {
292
+ const length = out.readInt32BE(offset + 1);
293
+ if (length < 4) break;
294
+ if (out[offset] === 0x5a && offset + 5 < out.length) at = offset + 5;
295
+ offset += 1 + length;
296
+ }
297
+ if (at >= 0) out[at] = status.charCodeAt(0);
298
+ return out;
299
+ }
300
+
301
+ /** Postgres's FATAL for a session past idle_in_transaction_session_timeout
302
+ * (postgres.c: ERRCODE_IDLE_IN_TRANSACTION_SESSION_TIMEOUT, 25P03). */
303
+ function idleInTransactionFatal() {
304
+ const fields = ['SFATAL', 'VFATAL', 'C25P03', 'Mterminating connection due to idle-in-transaction timeout'];
305
+ const body = Buffer.concat([...fields.map((f) => Buffer.from(`${f}\0`)), Buffer.from([0])]);
306
+ const head = Buffer.alloc(5);
307
+ head[0] = 0x45; // 'E'
308
+ head.writeInt32BE(body.length + 4, 1);
309
+ return Buffer.concat([head, body]);
75
310
  }
76
311
 
312
+ // the holder idle in its transaction past the limit while others wait: end it
313
+ const idleWatch = setInterval(() => {
314
+ // a guest holding its turn past GUEST_TURN_MS while others wait: its batch is refused, its savepoint undone
315
+ if (guestBatch !== null && waiters.length > 0 && Date.now() - guestStartedAt > GUEST_TURN_MS) {
316
+ const guest = sessions.get(guestBatch);
317
+ const expired = guestBatch;
318
+ if (guest && guest.guestRefused && !guest.guestRunning) {
319
+ // a refused batch that never sends its Sync: its savepoint is already undone; its turn ends
320
+ guest.guestRefused = false;
321
+ guest.guestExpired = true;
322
+ guest.guestErrorSent = true;
323
+ const host = sessions.get(holder);
324
+ if (host) host.idleSince += Date.now() - guestStartedAt;
325
+ guestBatch = null;
326
+ wakeAll();
327
+ } else if (guest && guest.guestOpen && !guest.guestRunning) {
328
+ guest.guestOpen = false;
329
+ guest.guestUnnamed = false;
330
+ guest.guestExpired = true;
331
+ void dbReady.then(async (db) => {
332
+ await quiet(db, 'ROLLBACK TO SAVEPOINT volter_guest');
333
+ await quiet(db, 'RELEASE SAVEPOINT volter_guest');
334
+ }).then(() => {
335
+ if (guestBatch !== expired) return;
336
+ const host = sessions.get(holder);
337
+ if (host) host.idleSince += Date.now() - guestStartedAt;
338
+ guestBatch = null;
339
+ wakeAll();
340
+ });
341
+ }
342
+ }
343
+ if (holder === null || waiters.length === 0) return;
344
+ const session = sessions.get(holder);
345
+ // armed at ReadyForQuery inside a transaction, as Postgres arms it — never mid-pipeline
346
+ // a holder waiting for its turn (behind a guest) is sending, not idle; a guest's turn is not the holder's idleness
347
+ if (!session || session.busy || session.wanting || guestBatch !== null || session.terminated || (session.status !== 'T' && session.status !== 'E')) return;
348
+ if (Date.now() - session.idleSince < IDLE_IN_TRANSACTION_MS) return;
349
+ session.terminated = true;
350
+ process.stderr.write('pglite-host: terminated a connection idle in its transaction while others waited\n');
351
+ // the socket's close handler rolls the transaction back and releases the lock
352
+ session.socket.end(idleInTransactionFatal());
353
+ setTimeout(() => session.socket.destroy(), 500).unref();
354
+ }, 250);
355
+ idleWatch.unref();
356
+
77
357
  /** The transaction status of the LAST ReadyForQuery ('Z') message in a raw
78
358
  * protocol response, or null when the response carries none (mid-pipeline:
79
359
  * the sender keeps the lock). 'I' = idle, 'T' = in transaction, 'E' = failed
@@ -94,10 +374,152 @@ function lastReadyStatus(response) {
94
374
  return status;
95
375
  }
96
376
 
377
+ // ---- CREATE DATABASE refusal -----------------------------------------------
378
+ // PGlite accepts CREATE DATABASE (a pg_database row appears) but nothing can
379
+ // ever connect to it: every startup database name aliases to `postgres`. A
380
+ // client that "creates" a database and then connects to it would silently land
381
+ // in the one real database (a Prisma shadow database would replay migrations
382
+ // into it; a test runner would treat it as disposable). So the statement fails:
383
+ // - naming the connection's OWN startup database (or `postgres`), with 42P04
384
+ // duplicate_database — true, since that name connects, and the answer
385
+ // check-then-create setup (`rails db:create`) tolerates;
386
+ // - naming any other database, with 0A000 feature_not_supported, because a
387
+ // tool that treats 42P04 as "reuse it" would then connect to the alias of
388
+ // the real database and treat it as disposable.
389
+ // The statement text is rewritten to a DO block that raises, so the BACKEND
390
+ // reports the error and owns the protocol and transaction state
391
+ // (ErrorResponse, aborted block, ReadyForQuery) as for any failing statement.
392
+ //
393
+ // COPY … FROM STDIN is refused the same way (0A000): PGlite 0.5.8 has no
394
+ // copy-in sub-protocol over execProtocolRaw — the statement aborts the WASM
395
+ // backend ("Program terminated with exit(1)") and every later call hangs, so
396
+ // one bulk load would take the World's database down. COPY … TO STDOUT works.
397
+ const LEADING = String.raw`^\s*(?:(?:--[^\n]*(?:\n|$)|\/\*[\s\S]*?\*\/)\s*)*`;
398
+ const CREATE_DATABASE = new RegExp(`${LEADING}create\\s+database\\s+("(?:[^"]|"")+"|[^\\s;]+)?`, 'i');
399
+ // Any statement of the text (a multi-statement Query included), comments
400
+ // removed first, that begins with COPY and reaches FROM STDIN before its `;`.
401
+ // Deliberately broad: a string literal holding such text is refused too,
402
+ // the price of never letting one through.
403
+ const COMMENTS = /--[^\n]*|\/\*[\s\S]*?\*\//g;
404
+ const COPY_FROM_STDIN = /(?:^|;)\s*copy\b[^;]*?\bfrom\s+stdin\b/i;
405
+ const DOLLAR_TAG = '$pglite_host$';
406
+ const quoted = (text) => `'${text.replaceAll("'", "''")}'`;
407
+ const raising = (errcode, message, detail, hint) => `DO ${DOLLAR_TAG} BEGIN RAISE EXCEPTION USING `
408
+ + `ERRCODE = '${errcode}', MESSAGE = ${quoted(message)}, DETAIL = ${quoted(detail)}, HINT = ${quoted(hint)}; `
409
+ + `END ${DOLLAR_TAG}`;
410
+
411
+ /** The DO block refusing CREATE DATABASE `name` (unquoted as Postgres folds
412
+ * it; undefined when unparseable) on a connection whose startup database is
413
+ * `own`, in plain SQL literal quoting. */
414
+ function refusal(name, own) {
415
+ const label = name && !name.includes(DOLLAR_TAG) ? ` "${name}"` : '';
416
+ const exists = name !== undefined && (name === own || name === 'postgres');
417
+ 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.";
418
+ const hint = 'Use the database named in DATABASE_URL, or run the World with a container runtime for multiple databases.';
419
+ return exists
420
+ ? raising('duplicate_database', `database${label} already exists`, detail, hint)
421
+ : raising('feature_not_supported', `CREATE DATABASE${label} is not supported: this World serves one database`, detail, hint);
422
+ }
423
+
424
+ const COPY_REFUSAL = raising('feature_not_supported',
425
+ "COPY FROM STDIN is not supported by the World's PGlite backing",
426
+ 'PGlite has no copy-in protocol over the wire; the statement would abort the one database backend.',
427
+ 'Load rows with INSERT (multi-row VALUES or batched statements), or run the World with a container runtime.');
428
+
429
+ /** The replacement SQL for a statement this host refuses, or null. */
430
+ function refusalFor(sql, own) {
431
+ const database = CREATE_DATABASE.exec(sql);
432
+ if (database) {
433
+ const spelled = database[1];
434
+ return refusal(spelled?.startsWith('"') ? spelled.slice(1, -1).replaceAll('""', '"') : spelled?.toLowerCase(), own);
435
+ }
436
+ return COPY_FROM_STDIN.test(sql.replace(COMMENTS, ' ')) ? COPY_REFUSAL : null;
437
+ }
438
+ const encoder = new TextEncoder();
439
+ const decoder = new TextDecoder();
440
+
441
+ function cstringEnd(bytes, start) {
442
+ const end = bytes.indexOf(0, start);
443
+ return end < 0 ? bytes.length : end;
444
+ }
445
+
446
+ /** A frontend Query ('Q') or Parse ('P') message whose SQL this host refuses
447
+ * (CREATE DATABASE, COPY FROM STDIN), with that SQL replaced by its raising DO
448
+ * block; any other message is returned unchanged. `own` is the connection's
449
+ * startup database name. */
450
+ function refuseStatements(message, own) {
451
+ const type = message[0];
452
+ if (type !== 0x51 /* 'Q' */ && type !== 0x50 /* 'P' */) return message;
453
+ // Parse = name\0 query\0 params…; Query = query\0
454
+ const queryStart = type === 0x50 ? cstringEnd(message, 5) + 1 : 5;
455
+ if (queryStart >= message.length) return message;
456
+ const queryEnd = cstringEnd(message, queryStart);
457
+ const replacement = refusalFor(decoder.decode(message.subarray(queryStart, queryEnd)), own);
458
+ if (replacement === null) return message;
459
+ const sql = encoder.encode(replacement);
460
+ const tail = message.subarray(queryEnd); // the query's NUL and, for Parse, its parameter types
461
+ const out = new Uint8Array(queryStart + sql.length + tail.length);
462
+ out.set(message.subarray(0, queryStart));
463
+ out.set(sql, queryStart);
464
+ out.set(tail, queryStart + sql.length);
465
+ new DataView(out.buffer).setInt32(1, out.length - 1);
466
+ return out;
467
+ }
468
+
469
+ // ---- extended-protocol error recovery --------------------------------------
470
+ // Postgres answers an error inside an extended-protocol batch (Parse / Bind /
471
+ // Describe / Execute) with ErrorResponse alone, discards messages until Sync,
472
+ // and sends ONE ReadyForQuery for the Sync. PGlite's execProtocolRaw, fed one
473
+ // message at a time, appends a ReadyForQuery to the ErrorResponse at once and
474
+ // then answers the Sync with a second one. The client counts two and every
475
+ // later result shifts by one (pg then crashes on a null handler). So the
476
+ // ReadyForQuery that follows an error from an extended-protocol message is
477
+ // dropped; the Sync's own one ends the batch (and releases the lock). Messages
478
+ // that end a simple cycle — Query, Sync, FunctionCall, CopyDone, CopyFail —
479
+ // keep theirs: Postgres always answers them with one.
480
+ // Measured on PGlite 0.5.8 by feeding execProtocolRaw directly: Parse of
481
+ // `SELECT 1/0` -> ParseComplete, Bind -> E Z(I), Execute -> nothing,
482
+ // Sync -> Z(I).
483
+ function withoutEarlyReady(message, response) {
484
+ const type = message[0];
485
+ // Query, Sync, FunctionCall, CopyDone, CopyFail
486
+ if (type === 0x51 || type === 0x53 || type === 0x46 || type === 0x63 || type === 0x66) return response;
487
+ let sawError = false;
488
+ let offset = 0;
489
+ while (offset + 5 <= response.length) {
490
+ const kind = response[offset];
491
+ const length = (response[offset + 1] << 24) | (response[offset + 2] << 16)
492
+ | (response[offset + 3] << 8) | response[offset + 4];
493
+ if (length < 4) return response; // malformed; pass through untouched
494
+ const end = offset + 1 + length;
495
+ if (kind === 0x45 /* 'E' */) sawError = true;
496
+ if (kind === 0x5a /* 'Z' */ && sawError) {
497
+ const out = new Uint8Array(response.length - (end - offset));
498
+ out.set(response.subarray(0, offset));
499
+ out.set(response.subarray(end), offset);
500
+ return out;
501
+ }
502
+ offset = end;
503
+ }
504
+ return response;
505
+ }
506
+
97
507
  const server = net.createServer(async (socket) => {
98
508
  const token = {};
99
509
  let holding = false;
510
+ const session = { socket, busy: false, idleSince: Date.now(), terminated: false, closed: false, status: null, midBatch: false,
511
+ wanting: false, guestOpen: false, guestRefused: false, guestUnnamed: false, guestExpired: false, guestErrorSent: false, guestRunning: false, statements: new Map() };
512
+ sessions.set(token, session);
100
513
  socket.on('close', () => {
514
+ session.closed = true;
515
+ sessions.delete(token);
516
+ if (guestBatch === token) {
517
+ // a guest gone mid-batch: undo what it did in the holder's transaction
518
+ void dbReady
519
+ .then(async (db) => { if (session.guestOpen) { await quiet(db, 'ROLLBACK TO SAVEPOINT volter_guest'); await quiet(db, 'RELEASE SAVEPOINT volter_guest'); } })
520
+ .then(() => { session.guestOpen = false; guestBatch = null; wakeAll(); });
521
+ return;
522
+ }
101
523
  if (!holding) return;
102
524
  // the client vanished inside its transaction: roll it back, then let the
103
525
  // next connection in — never leak a half-open transaction into a stranger
@@ -113,16 +535,100 @@ const server = net.createServer(async (socket) => {
113
535
  await fromNodeSocket(socket, {
114
536
  serverVersion: '18.3 (PGlite)',
115
537
  auth: { method: 'trust' },
116
- async onMessage(data, { isAuthenticated }) {
538
+ async onMessage(data, { isAuthenticated, clientParams }) {
117
539
  if (!isAuthenticated) return undefined; // gateway owns startup/auth traffic
118
540
  const db = await dbReady;
119
- await acquire(token);
541
+ // a guest whose turn ran out: told once, the rest of its batch dropped until its Sync
542
+ if (session.guestExpired) {
543
+ if (data[0] === 0x53) { session.guestExpired = false; session.guestErrorSent = false; session.status = 'I'; return READY_IDLE; }
544
+ if (session.guestErrorSent) return new Uint8Array(0);
545
+ session.guestErrorSent = true;
546
+ return errorResponse('57014', 'the containerless Postgres (one session) cancelled this read: it ran beside another connection\'s open transaction longer than its turn allows');
547
+ }
548
+ const role = await enter(token, session, data);
549
+ if (role === 'guest') {
550
+ if (session.closed) return undefined;
551
+ const own = clientParams?.database || clientParams?.user;
552
+ const sync = data[0] === 0x53;
553
+ const endGuest = () => {
554
+ session.guestOpen = false;
555
+ session.guestRefused = false;
556
+ session.status = 'I';
557
+ guestBatch = null;
558
+ session.guestUnnamed = false;
559
+ // the holder's idle clock stops while a guest runs, and goes on from where it was
560
+ const host = sessions.get(holder);
561
+ if (host) host.idleSince += Date.now() - guestStartedAt;
562
+ wakeAll();
563
+ };
564
+ // a batch refused mid-way: its remaining messages are dropped until its Sync, as Postgres skips after an error
565
+ if (session.guestRefused) {
566
+ if (sync) { endGuest(); return READY_IDLE; }
567
+ return new Uint8Array(0);
568
+ }
569
+ if (session.guestOpen && !guestMessageAllowed(session, data)) {
570
+ session.guestRunning = true; // the expiry waits until this refusal is done
571
+ await quiet(db, 'ROLLBACK TO SAVEPOINT volter_guest');
572
+ await quiet(db, 'RELEASE SAVEPOINT volter_guest');
573
+ session.guestOpen = false;
574
+ session.guestRefused = true;
575
+ session.guestRunning = false;
576
+ return errorResponse('0A000', 'the containerless Postgres (one session) runs only reads beside another connection\'s open transaction; this batch began with a read and went on to write');
577
+ }
578
+ if (!session.guestOpen) { await quiet(db, 'SAVEPOINT volter_guest'); session.guestOpen = true; }
579
+ noteStatement(session, data);
580
+ if (data[0] === 0x50 && text(asBuffer(data), 5) === '') session.guestUnnamed = true;
581
+ let raw;
582
+ session.guestRunning = true;
583
+ try {
584
+ raw = await db.execProtocolRaw(refuseStatements(data, own));
585
+ } catch (error) {
586
+ process.stderr.write(`pglite-host: backend aborted: ${error?.message ?? error}\n`);
587
+ process.exit(1);
588
+ }
589
+ const response = withoutEarlyReady(data, raw);
590
+ const status = lastReadyStatus(response);
591
+ if (status === null) { session.guestRunning = false; return response; } // mid-batch: the guest keeps its turn until its Sync
592
+ // the savepoint is gone if the holder's transaction ended meanwhile (its socket closed): quiet, not fatal
593
+ if (status === 'E') await quiet(db, 'ROLLBACK TO SAVEPOINT volter_guest');
594
+ await quiet(db, 'RELEASE SAVEPOINT volter_guest');
595
+ session.guestRunning = false; // the batch has ended: nothing left for the expiry to cancel
596
+ endGuest();
597
+ return withReadyStatus(response, 'I');
598
+ }
599
+ // closed (or cut) while it waited: give the lock up without running the
600
+ // message — a Terminate run now would leave no ReadyForQuery to release it
601
+ if (session.closed || session.terminated) {
602
+ session.busy = false;
603
+ if (!holding) release(token);
604
+ return undefined;
605
+ }
120
606
  holding = true;
121
- const response = await db.execProtocolRaw(data);
607
+ session.busy = true;
608
+ noteStatement(session, data);
609
+ // libpq's default: no database parameter means the user's name
610
+ const own = clientParams?.database || clientParams?.user;
611
+ let raw;
612
+ try {
613
+ raw = await db.execProtocolRaw(refuseStatements(data, own));
614
+ } catch (error) {
615
+ // SQL errors arrive as ErrorResponse bytes; a throw means the WASM
616
+ // backend itself aborted and every later call would hang. Exit so the
617
+ // World sees the service down (status fails) instead of a wedged port.
618
+ process.stderr.write(`pglite-host: backend aborted: ${error?.message ?? error}\n`);
619
+ process.exit(1);
620
+ }
621
+ session.busy = false;
622
+ session.idleSince = Date.now();
623
+ const response = withoutEarlyReady(data, raw);
122
624
  const status = lastReadyStatus(response);
625
+ session.midBatch = status === null;
626
+ if (status !== null) session.status = status;
123
627
  if (status === 'I') {
124
628
  holding = false;
125
629
  release(token);
630
+ } else if (status === 'T') {
631
+ wakeAll(); // idle in its transaction: a waiting reader may now run as a guest
126
632
  }
127
633
  return response;
128
634
  },
@@ -15,7 +15,7 @@ type RunResult = { status: number | null; stdout: string; stderr: string };
15
15
  type Runner = (cmd: string, args: string[]) => RunResult;
16
16
 
17
17
  const realRunner: Runner = (cmd, args) => {
18
- const result = spawnSync(cmd, args, { encoding: 'utf8' });
18
+ const result = spawnSync(cmd, args, { windowsHide: true, encoding: 'utf8' });
19
19
  return {
20
20
  status: result.status,
21
21
  stdout: result.stdout || '',
@@ -13,7 +13,7 @@ export function survivingOwnedGroups(pids: number[]): number[] {
13
13
  }
14
14
  });
15
15
  if (!candidates.length || process.platform === 'win32') return candidates;
16
- const result = spawnSync('ps', ['-axo', 'pid=,pgid=,stat='], {
16
+ const result = spawnSync('ps', ['-axo', 'pid=,pgid=,stat='], { windowsHide: true,
17
17
  encoding: 'utf8', timeout: 1000, killSignal: 'SIGKILL', maxBuffer: 4 * 1024 * 1024,
18
18
  });
19
19
  if (result.status !== 0 || result.error) return candidates;
@@ -47,7 +47,7 @@ import { sessionTrustEnv, type CaTrustInputs } from './ca-trust.ts';
47
47
  * the host was MITM'd, i.e. the host IS twinned in this world — so the bare "no twin for host"
48
48
  * sent readers hunting for a `*_TWIN_URL` that was already set. On a shared host the path may
49
49
  * belong to a vendor whose twin merely is not configured (youtube vs googleauth), OR to no pack
50
- * at all (Google Calendar on www.googleapis.com has no twin yet) — the message states both,
50
+ * at all (Google Drive on www.googleapis.com has no twin yet) — the message states both,
51
51
  * because this refusal is exactly what replaced the silent mis-route that answered Calendar
52
52
  * calls with plausible Google-shaped 404s from the gemini pack.
53
53
  */
@@ -56,7 +56,7 @@ export function noTwinMessage(host: string, path: string): string {
56
56
  + 'This host is shared between vendors, and no pack in this world serves this path. Either the\n'
57
57
  + 'vendor that owns this path has a twin that is not configured — set its *_TWIN_URL (for\n'
58
58
  + 'www.googleapis.com: YOUTUBE_TWIN_URL serves /youtube/v3/*, GOOGLEAUTH_TWIN_URL serves the\n'
59
- + 'OAuth2 token paths) — or this API has no twin pack yet (e.g. Google Calendar /calendar/v3/*),\n'
59
+ + 'OAuth2 token paths) — or this API has no twin pack yet (e.g. Google Drive /drive/v3/*),\n'
60
60
  + 'in which case this loud refusal is the honest outcome: the request is neither answered by the\n'
61
61
  + 'wrong twin nor leaked to the real vendor.\n';
62
62
  }
@@ -192,12 +192,12 @@ export function opensslAvailable(): boolean {
192
192
  // process start rather than a same-process `process.env.PATH` mutation made mid-test. Never set
193
193
  // this env var in real usage.
194
194
  if (process.env.VOLTER_TEST_NO_OPENSSL === '1') return false;
195
- const r = spawnSync('openssl', ['version'], { stdio: 'ignore' });
195
+ const r = spawnSync('openssl', ['version'], { windowsHide: true, stdio: 'ignore' });
196
196
  return r.status === 0;
197
197
  }
198
198
 
199
199
  function runOpenssl(args: string[]): string {
200
- const r = spawnSync('openssl', args, { encoding: 'utf8' });
200
+ const r = spawnSync('openssl', args, { windowsHide: true, encoding: 'utf8' });
201
201
  if (r.status !== 0) throw new Error(`openssl ${args[0]} failed: ${(r.stderr || r.stdout || '').trim()}`);
202
202
  return r.stdout;
203
203
  }