@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.
- package/dist/src/pglite-backing.d.ts +5 -0
- package/dist/src/pglite-backing.js +18 -2
- package/dist/src/pglite-host.mjs +104 -657
- package/package.json +4 -4
- package/src/pglite-backing.ts +18 -4
- package/src/pglite-host.mjs +104 -657
package/dist/src/pglite-host.mjs
CHANGED
|
@@ -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
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
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
|
|
37
|
-
//
|
|
38
|
-
//
|
|
39
|
-
//
|
|
40
|
-
//
|
|
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
|
|
51
|
-
//
|
|
52
|
-
//
|
|
53
|
-
//
|
|
54
|
-
//
|
|
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
|
|
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
|
-
// ----
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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
|
-
//
|
|
628
|
-
//
|
|
629
|
-
//
|
|
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 &&
|
|
666
|
-
const detail = "The World's PGlite backing serves ONE database;
|
|
667
|
-
const hint = 'Use the database named in DATABASE_URL, or
|
|
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
|
|
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(
|
|
757
|
-
const
|
|
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
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
.
|
|
779
|
-
|
|
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
|
-
|
|
308
|
+
void fromNodeSocket(socket, {
|
|
787
309
|
serverVersion: '18.3 (PGlite)',
|
|
788
|
-
auth: { method: '
|
|
789
|
-
|
|
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
|
-
|
|
792
|
-
|
|
793
|
-
//
|
|
794
|
-
|
|
795
|
-
|
|
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
|
|
809
|
-
|
|
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,
|
|
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
|
-
|
|
848
|
-
|
|
849
|
-
|
|
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
|
|