@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.
- package/dist/src/pglite-host.mjs +326 -45
- package/package.json +4 -4
- package/src/pglite-host.mjs +326 -45
package/dist/src/pglite-host.mjs
CHANGED
|
@@ -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
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
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
|
|
42
|
-
//
|
|
43
|
-
//
|
|
44
|
-
//
|
|
45
|
-
//
|
|
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
|
-
// ----
|
|
156
|
-
let
|
|
157
|
-
const
|
|
158
|
-
const
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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
|
|
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
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
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
|
-
|
|
297
|
-
await db.
|
|
298
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
323
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
340
|
-
|
|
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.
|
|
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.
|
|
73
|
-
"@volter/world-console": "3.0.
|
|
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.
|
|
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",
|
package/src/pglite-host.mjs
CHANGED
|
@@ -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
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
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
|
|
42
|
-
//
|
|
43
|
-
//
|
|
44
|
-
//
|
|
45
|
-
//
|
|
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
|
-
// ----
|
|
156
|
-
let
|
|
157
|
-
const
|
|
158
|
-
const
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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
|
|
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
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
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
|
-
|
|
297
|
-
await db.
|
|
298
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
323
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
340
|
-
|
|
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`);
|