@mason-data/cli 0.6.2 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/README.md +71 -12
  2. package/dist/src/api/postgres/catalog.js +36 -1
  3. package/dist/src/api/postgres/server.js +133 -32
  4. package/dist/src/cdc/pg-wire.js +144 -38
  5. package/dist/src/cli/background.js +29 -5
  6. package/dist/src/cli/command.js +4 -3
  7. package/dist/src/cli/commands.js +259 -127
  8. package/dist/src/cli/demo.js +149 -0
  9. package/dist/src/cli/dev.js +113 -20
  10. package/dist/src/cli/doctor.js +6 -3
  11. package/dist/src/cli/guides.js +32 -8
  12. package/dist/src/cli/init.js +58 -18
  13. package/dist/src/cli/machine.js +8 -3
  14. package/dist/src/cli/main.js +2 -2
  15. package/dist/src/cli/mcp.js +726 -0
  16. package/dist/src/cli/preflight.js +16 -7
  17. package/dist/src/cli/project.js +5 -3
  18. package/dist/src/cli/registry.js +8 -5
  19. package/dist/src/cli/run-server.js +29 -106
  20. package/dist/src/cli/serve.js +6 -1
  21. package/dist/src/cli/source-edit.js +116 -0
  22. package/dist/src/cli/sources.js +6 -4
  23. package/dist/src/demo/seed.js +237 -0
  24. package/dist/src/demo/source.js +655 -0
  25. package/dist/src/duck/distinct-key.js +228 -0
  26. package/dist/src/duck/executor-worker.js +7 -2
  27. package/dist/src/duck/executor.js +67 -26
  28. package/dist/src/duck/explain.js +128 -9
  29. package/dist/src/duck/functions.js +60 -5
  30. package/dist/src/duck/gaps.js +9 -4
  31. package/dist/src/duck/jsonb-order.js +175 -0
  32. package/dist/src/duck/jsonb.js +10 -8
  33. package/dist/src/duck/kept-route.js +82 -2
  34. package/dist/src/duck/kept.js +206 -39
  35. package/dist/src/duck/pgtree.js +40 -0
  36. package/dist/src/duck/plan.js +21 -8
  37. package/dist/src/duck/rewrite.js +1168 -68
  38. package/dist/src/duck/store.js +132 -56
  39. package/dist/src/duck/sync.js +90 -25
  40. package/dist/src/duck/verified.js +6 -0
  41. package/dist/src/lib/timezone.js +3 -2
  42. package/dist/src/web/app.css +11 -0
  43. package/dist/src/web/app.js +167 -12
  44. package/dist/src/web/index.html +6 -1
  45. package/package.json +1 -1
@@ -0,0 +1,655 @@
1
+ /**
2
+ * THE DEMO DATABASE (`mason init demo`): a Postgres Mason hosts itself, so a first run needs no Docker and no Postgres
3
+ * install. PGlite (Postgres 18 compiled to WebAssembly, the catalog's own engine, src/api/postgres/catalog.ts) persisted
4
+ * in a folder, seeded at the folder's first open with a small synthetic web shop (src/demo/seed.ts), and served over the
5
+ * Postgres wire protocol on a loopback port, so Mason reads it exactly as it reads an application's database: its own
6
+ * client (src/cdc/pg-wire.ts: the checks, the catalog reads) and DuckDB's `postgres` extension through libpq (the copy's
7
+ * chunks, the poll). `change()` writes a round of changes through PGlite's own API, so the copy visibly keeps current.
8
+ *
9
+ * WHAT IT IS NOT: a server for anything but a demo. It listens on 127.0.0.1 ONLY and accepts any user and any password
10
+ * (no authentication round trip: AuthenticationOk at once), because nothing but this machine can reach it and it holds
11
+ * nothing but the seed. It has no walsender (PGlite has none: `pg_replication_slots` is empty), so it is polled, never
12
+ * streamed; a StartupMessage asking for replication is refused (0A000). COPY FROM STDIN is not supported: a batch ends
13
+ * at a Query, a Sync or a Flush, never at a CopyDone (nothing Mason sends writes through the wire).
14
+ *
15
+ * THE STARTUP PHASE IS OURS, THE QUERY PHASE IS POSTGRES'S. PGlite answers protocol bytes (`execProtocolRaw`) but not
16
+ * the startup phase (SSLRequest, StartupMessage, authentication), so this module does it: an SSLRequest or a
17
+ * GSSENCRequest is told `N` (no TLS on loopback), a CancelRequest closes (nothing to cancel), the StartupMessage is
18
+ * checked (the database must be `demo`, else 3D000; protocol 3.x, a newer minor negotiated down to 3.0) and answered
19
+ * AuthenticationOk, ParameterStatus, BackendKeyData (a pid of this module's numbering) and ReadyForQuery `I`. After it,
20
+ * every frame a client sends is relayed to PGlite AS ITS OWN BYTES, by a framer of this module's (a type byte and a
21
+ * length): the query phase is never decoded and re-encoded, so nothing a client sends (a COPY's binary, a Bind's
22
+ * parameters) can be changed on the way; `FrontendDecoder` (src/api/postgres/protocol.ts) decodes only the startup
23
+ * frames, handed to it one whole frame at a time.
24
+ *
25
+ * ONE SESSION FOR EVERY CLIENT, SO ONE LOCK. PGlite is a single backend: two connections' messages interleaved inside
26
+ * a transaction would run one client's statement in the other's transaction. So a connection takes THE lock when it has
27
+ * a batch to send — the frames up to and including a Query `Q`, a Sync `S` or a Flush `H` — and gives it back only once
28
+ * the reply's last ReadyForQuery says `I` (idle, no transaction open); while it says `T` or `E` (or a Flush left the
29
+ * extended protocol mid-cycle, no ReadyForQuery at all) the connection keeps it, and its next batch runs under it.
30
+ * Terminate `X` ends the connection; one that ends holding the lock (mid-transaction, or gone) is cleaned up through
31
+ * PGlite first — a Sync when it was mid-cycle, a ROLLBACK when a transaction is open — so the next holder starts idle.
32
+ * `change()` and the startup's settings take the same lock. Waiters are served first come, first served.
33
+ *
34
+ * WHAT THE CLIENTS SEND (traced: `trace`): Mason's own client, simple Queries only. libpq under the extension: SSLRequest
35
+ * (sslmode prefer) then a StartupMessage of user, database and application_name alone; then per use of a pooled
36
+ * connection `SELECT 1`, the statements, `DISCARD ALL`; every read inside `BEGIN TRANSACTION ISOLATION LEVEL REPEATABLE
37
+ * READ READ ONLY` … `COMMIT`, each statement an unnamed Parse + Sync, a Describe + Sync, then the statement again as a
38
+ * simple Query `COPY (…) TO STDOUT (FORMAT "binary")`, which PGlite answers (CopyOutResponse, CopyData, CopyDone). An
39
+ * unnamed statement is shared by every client of the one session, so a Parse and its Describe in two batches are safe
40
+ * only inside a transaction, which holds the lock between them — where every one traced was.
41
+ *
42
+ * THE DEADLOCK THIS ALLOWS, reasoned about: a client holding the lock inside a transaction whose own progress waits for
43
+ * ANOTHER client of this database that is queued behind the lock — two DuckDB connections where the first's COMMIT
44
+ * waited on the second's read, or a process whose thread pool is all blocked in libpq reads queued here while the
45
+ * holder's next statement needs one of those threads. Mason's own readers do not do that (each copy chunk and each poll
46
+ * is one DuckDB transaction on one connection, its source transaction ended at its commit, which waits on no other
47
+ * reader), and the test of three parallel readers holds it; but the serialisation makes Mason's parallel readers take
48
+ * turns, so a copy here reads one chunk at a time whatever `MASON_COPY_PARALLEL` says. A wait past `LONG_WAIT_MS` is
49
+ * said in the log, naming who holds the lock and in what state, so a hang is never silent.
50
+ *
51
+ * THE SETTINGS A CLIENT ASKS FOR ARE THE ONE SESSION'S. The StartupMessage's parameters other than user / database are
52
+ * session settings (DateStyle, TimeZone, extra_float_digits, IntervalStyle, client_encoding, application_name,
53
+ * search_path, and every `-c name=value` of libpq's `options`), applied with `set_config` under the lock before the
54
+ * ReadyForQuery, and applied again (one statement) whenever the lock passes to a client from another, so each client
55
+ * meets the settings it asked for at every turn of its own. A DISCARD ALL or a RESET (DuckDB's `postgres` extension sends
56
+ * DISCARD ALL each time its pool takes a connection back) returns the session to PGlite's defaults where Postgres would
57
+ * return it to the startup's, so after one (Postgres's CommandComplete tag says it ran) the client's settings are
58
+ * applied again at its next turn. A client's own SET lasts until another client's turn. Every Mason client asks for
59
+ * nothing that differs between them where it matters: pg-wire.ts asks for ISO dates, UTC and extra_float_digits 3 (and
60
+ * reads JSON text), the extension asks for nothing but its application_name and reads every value in binary.
61
+ *
62
+ * PGLITE'S EXTENDED PROTOCOL DIFFERS FROM POSTGRES'S IN ONE WAY, which the relay corrects: after an error it sends a
63
+ * ReadyForQuery AT ONCE, and another at the Sync (measured: a failed Parse/Bind/Execute/Sync answers `E Z Z`; a failed
64
+ * Parse/Flush `E Z`). Postgres sends one, at the Sync. So a batch that ends in a Sync relays only its last
65
+ * ReadyForQuery, and one that ends in a Flush relays none. What comes after the error up to the Sync PGlite ignores
66
+ * itself, as Postgres's `ignore_till_sync` does (measured: a Bind/Execute/Sync after a failed Parse/Flush answers `Z`
67
+ * alone) — while the client that erred holds the lock, a Flush leaving it mid-cycle, so no other client's message is
68
+ * among the ignored.
69
+ *
70
+ * PGLITE RUNS ON THIS THREAD, not a worker as the catalog's does: the demo's statements are small (its largest is a copy
71
+ * chunk of the 15,000-row table, tens of milliseconds), and `change()` writes through the same instance; a statement
72
+ * here holds the thread for that long, which a demo can afford and a real source never asks of Mason.
73
+ */
74
+ import * as fs from "node:fs";
75
+ import * as net from "node:net";
76
+ import * as path from "node:path";
77
+ import { PGlite } from "@electric-sql/pglite";
78
+ import { FrontendDecoder, MAX_MESSAGE_LENGTH, MAX_STARTUP_LENGTH, authenticationOk, backendKeyData, cstr, errorResponse, frame, int32, parameterStatus, readyForQuery, splitFrames, sslResponse, } from "../api/postgres/protocol.js";
79
+ import { CHANGE_STATE_SQL, DEMO_TABLES, changeSql, seedStatements } from "./seed.js";
80
+ import { DEMO_DATABASE, DEMO_USER, demoUrl } from "./seed.js";
81
+ /** the demo's URL and names are the seed's module's (no PGlite there: `mason init demo` and the run's port check read
82
+ * them without loading this one) */
83
+ export { DEMO_DATABASE, DEMO_USER, demoUrl };
84
+ /** a wait for the lock longer than this is said in the log, once per wait */
85
+ const LONG_WAIT_MS = 10_000;
86
+ /** the parameters a ParameterStatus reports at startup that do not change per client: read from PGlite once, at open */
87
+ const SERVER_PARAMETERS = ["server_version", "server_encoding", "integer_datetimes", "standard_conforming_strings",
88
+ "is_superuser", "session_authorization", "in_hot_standby"];
89
+ /** the ones a client's settings move: read again after each client's startup */
90
+ const SESSION_PARAMETERS = ["client_encoding", "DateStyle", "TimeZone", "IntervalStyle", "application_name", "search_path"];
91
+ /** startup parameters that are not settings */
92
+ const NOT_SETTINGS = new Set(["user", "database", "options", "replication"]);
93
+ /** THE ONE PGLITE SESSION'S LOCK: first come, first served; a holder keeps it across batches until it releases. */
94
+ class SessionLock {
95
+ #holder = null;
96
+ #waiting = [];
97
+ #say;
98
+ constructor(say) { this.#say = say; }
99
+ get holder() { return this.#holder; }
100
+ async acquire(owner) {
101
+ if (this.#holder === owner)
102
+ return;
103
+ if (this.#holder === null) {
104
+ this.#holder = owner;
105
+ return;
106
+ }
107
+ const since = Date.now();
108
+ const timer = setTimeout(() => {
109
+ const h = this.#holder;
110
+ this.#say(`demo database: ${nameOf(owner)} has waited ${Math.round((Date.now() - since) / 1000)} s for ${h === null ? "nobody" : nameOf(h)}` +
111
+ `${h instanceof Connection ? `, which holds a transaction open (${h.stateText()})` : ""}: the demo database runs one statement at a time`);
112
+ }, LONG_WAIT_MS);
113
+ timer.unref();
114
+ await new Promise((grant) => this.#waiting.push({ owner, grant }));
115
+ clearTimeout(timer);
116
+ }
117
+ release(owner) {
118
+ if (this.#holder !== owner)
119
+ return;
120
+ const next = this.#waiting.shift();
121
+ this.#holder = next?.owner ?? null;
122
+ next?.grant();
123
+ }
124
+ /** runs `fn` holding the lock, and releases it whatever `fn` does */
125
+ async exclusive(owner, fn) {
126
+ await this.acquire(owner);
127
+ try {
128
+ return await fn();
129
+ }
130
+ finally {
131
+ this.release(owner);
132
+ }
133
+ }
134
+ }
135
+ const nameOf = (o) => (typeof o === "string" ? o : `connection ${o.pid}${o.application ? ` (${o.application})` : ""}`);
136
+ const errorText = (e) => (e instanceof Error ? e.message : String(e));
137
+ /** a PGlite error's SQLSTATE, when it carries one */
138
+ const sqlstateOf = (e) => {
139
+ const code = e?.code;
140
+ return typeof code === "string" && /^[0-9A-Z]{5}$/.test(code) ? code : "XX000";
141
+ };
142
+ /** libpq's `options` string: its `-c name=value` / `--name=value` settings (a backslash escapes a space, as libpq's) */
143
+ export function optionSettings(options) {
144
+ const words = [];
145
+ let word = "";
146
+ let inWord = false;
147
+ for (let i = 0; i < options.length; i++) {
148
+ const ch = options[i];
149
+ if (ch === "\\" && i + 1 < options.length) {
150
+ word += options[++i];
151
+ inWord = true;
152
+ continue;
153
+ }
154
+ if (/\s/.test(ch)) {
155
+ if (inWord)
156
+ words.push(word);
157
+ word = "";
158
+ inWord = false;
159
+ continue;
160
+ }
161
+ word += ch;
162
+ inWord = true;
163
+ }
164
+ if (inWord)
165
+ words.push(word);
166
+ const out = [];
167
+ for (let i = 0; i < words.length; i++) {
168
+ const w = words[i];
169
+ let setting;
170
+ if (w === "-c")
171
+ setting = words[++i];
172
+ else if (w.startsWith("-c"))
173
+ setting = w.slice(2);
174
+ else if (w.startsWith("--"))
175
+ setting = w.slice(2);
176
+ const eq = setting?.indexOf("=") ?? -1;
177
+ if (setting !== undefined && eq > 0)
178
+ out.push([setting.slice(0, eq).replace(/-/g, "_"), setting.slice(eq + 1)]);
179
+ }
180
+ return out;
181
+ }
182
+ /** One client's connection: the startup phase, then its frames relayed in batches under the lock. */
183
+ class Connection {
184
+ pid;
185
+ application = "";
186
+ /** the settings its StartupMessage asked for: the one session's whenever it is this connection's turn */
187
+ settings = [];
188
+ #socket;
189
+ #source;
190
+ #pending = [];
191
+ #pendingLength = 0;
192
+ #startup = true;
193
+ /** the frames of the batch being gathered */
194
+ #batch = [];
195
+ /** every batch, the startup and the end run one after another */
196
+ #chain = Promise.resolve();
197
+ /** the last ReadyForQuery relayed: null while the extended protocol is mid-cycle (a Flush) */
198
+ #status = "I";
199
+ #closed = false;
200
+ #ended = null;
201
+ constructor(socket, source, pid) {
202
+ this.#socket = socket;
203
+ this.#source = source;
204
+ this.pid = pid;
205
+ socket.setNoDelay(true);
206
+ socket.on("data", (d) => this.#onData(d));
207
+ socket.on("error", (e) => { this.#source.note(`demo database: connection ${this.pid}: ${errorText(e)}`); this.#socket.destroy(); });
208
+ socket.on("close", () => { this.#closed = true; void this.end(); });
209
+ }
210
+ stateText() {
211
+ return this.#status === null ? "mid extended-protocol cycle" : this.#status === "T" ? "in a transaction" : this.#status === "E" ? "in a failed transaction" : "idle";
212
+ }
213
+ #then(step) {
214
+ this.#chain = this.#chain.then(step).catch((e) => {
215
+ // a step's own failure is said and ends the connection; the end's cleanup leaves the session idle
216
+ this.#source.note(`demo database: connection ${this.pid}: ${errorText(e)}`);
217
+ this.#send(errorResponse({ severity: "FATAL", code: sqlstateOf(e), message: errorText(e) }));
218
+ this.#socket.destroy();
219
+ });
220
+ }
221
+ #send(...frames) {
222
+ if (!this.#closed && this.#socket.writable)
223
+ this.#socket.write(frames.length === 1 ? frames[0] : Buffer.concat(frames));
224
+ }
225
+ #fatal(code, message) {
226
+ this.#send(errorResponse({ severity: "FATAL", code, message }));
227
+ this.#socket.end();
228
+ }
229
+ #onData(d) {
230
+ this.#pending.push(d);
231
+ this.#pendingLength += d.length;
232
+ for (;;) {
233
+ const head = this.#startup ? 4 : 5;
234
+ if (this.#pendingLength < head)
235
+ return;
236
+ let buf = this.#pending.length === 1 ? this.#pending[0] : Buffer.concat(this.#pending, this.#pendingLength);
237
+ const len = buf.readInt32BE(head - 4);
238
+ const limit = this.#startup ? MAX_STARTUP_LENGTH : MAX_MESSAGE_LENGTH;
239
+ if (len < 4 || len > limit) {
240
+ this.#source.note(`demo database: connection ${this.pid} sent a frame of length ${len}; closed`);
241
+ this.#pending = [];
242
+ this.#pendingLength = 0;
243
+ this.#socket.destroy();
244
+ return;
245
+ }
246
+ const whole = head - 4 + len;
247
+ if (buf.length < whole) {
248
+ this.#pending = [buf];
249
+ return;
250
+ }
251
+ const f = Buffer.from(buf.subarray(0, whole));
252
+ buf = buf.subarray(whole);
253
+ this.#pending = buf.length > 0 ? [buf] : [];
254
+ this.#pendingLength = buf.length;
255
+ if (this.#startup)
256
+ this.#startupFrame(f);
257
+ else
258
+ this.#queryFrame(f);
259
+ if (this.#closed)
260
+ return;
261
+ }
262
+ }
263
+ #startupFrame(f) {
264
+ const decoder = new FrontendDecoder();
265
+ decoder.push(f);
266
+ let m;
267
+ try {
268
+ m = decoder.next();
269
+ }
270
+ catch (e) {
271
+ this.#source.note(`demo database: connection ${this.pid}: ${errorText(e)}; closed`);
272
+ this.#socket.destroy();
273
+ return;
274
+ }
275
+ if (m === null) {
276
+ this.#socket.destroy();
277
+ return;
278
+ }
279
+ switch (m.type) {
280
+ case "SSLRequest":
281
+ case "GSSENCRequest":
282
+ // no TLS and no GSSAPI on loopback: the client continues in the clear (sslmode prefer, disable, allow)
283
+ this.#send(sslResponse(false));
284
+ return;
285
+ case "CancelRequest":
286
+ // the one session runs one statement at a time, each short: there is nothing to cancel
287
+ this.#socket.end();
288
+ return;
289
+ case "Startup":
290
+ this.#startup = false;
291
+ this.#then(() => this.#open(m.major, m.minor, m.params));
292
+ return;
293
+ default:
294
+ this.#socket.destroy();
295
+ }
296
+ }
297
+ async #open(major, minor, params) {
298
+ if (this.#closed)
299
+ return;
300
+ if (major !== 3) {
301
+ this.#fatal("0A000", `unsupported frontend protocol ${major}.${minor}: the demo database speaks 3.0`);
302
+ return;
303
+ }
304
+ if (params.replication !== undefined && params.replication !== "false" && params.replication !== "0") {
305
+ // nothing a user reads names the engine (CLAUDE.md): the demo has no walsender, said as what it lacks
306
+ this.#fatal("0A000", "the demo database has no change log to stream: Mason polls it");
307
+ return;
308
+ }
309
+ const database = params.database ?? params.user ?? "";
310
+ if (database !== DEMO_DATABASE) {
311
+ this.#source.note(`demo database: connection ${this.pid} asked for database ${JSON.stringify(database)}; refused (only ${DEMO_DATABASE} is served)`);
312
+ this.#fatal("3D000", `database "${database}" does not exist`);
313
+ return;
314
+ }
315
+ // protocol options (_pq_.*) and a newer minor are negotiated away, as Postgres does: this server speaks 3.0
316
+ const options = Object.keys(params).filter((k) => k.startsWith("_pq_."));
317
+ const negotiate = minor > 0 || options.length > 0 ? [frame("v", int32(0), int32(options.length), ...options.map(cstr))] : [];
318
+ const settings = [
319
+ ...Object.entries(params).filter(([k]) => !NOT_SETTINGS.has(k) && !k.startsWith("_pq_.")),
320
+ ...optionSettings(params.options ?? ""),
321
+ ];
322
+ this.application = params.application_name ?? "";
323
+ this.settings = settings;
324
+ this.#source.trace?.(`demo database: connection ${this.pid} starts with ${JSON.stringify(params)}`);
325
+ const session = await this.#source.applySettings(this);
326
+ if (this.#closed)
327
+ return;
328
+ this.#send(...negotiate, authenticationOk(), ...Object.entries({ ...this.#source.serverParameters, ...session }).map(([k, v]) => parameterStatus(k, v)), backendKeyData(this.pid, (Math.random() * 0x7fffffff) | 0), readyForQuery("I"));
329
+ }
330
+ #queryFrame(f) {
331
+ const type = String.fromCharCode(f[0]);
332
+ if (type === "X") {
333
+ this.#socket.end();
334
+ return;
335
+ }
336
+ this.#batch.push(f);
337
+ if (type === "Q" || type === "S" || type === "H") {
338
+ const batch = this.#batch;
339
+ this.#batch = [];
340
+ this.#then(() => this.#run(batch, type));
341
+ }
342
+ }
343
+ async #run(frames, end) {
344
+ if (this.#closed)
345
+ return;
346
+ this.#source.trace?.(`demo database: connection ${this.pid}: ${frames.map(describeFrame).join(" ")}`);
347
+ const held = this.#source.lock.holder === this;
348
+ await this.#source.lock.acquire(this);
349
+ if (this.#closed) {
350
+ if (this.#status === "I")
351
+ this.#source.lock.release(this);
352
+ return;
353
+ }
354
+ // the session's settings are this client's again when another client's turn (or a DISCARD / RESET) moved them
355
+ if (!held)
356
+ await this.#source.reapply(this);
357
+ const reply = splitFrames(await this.#source.exec(Buffer.concat(frames)));
358
+ // a DISCARD ALL or a RESET (the extension's pool sends DISCARD ALL as it puts a connection back) returned the
359
+ // session to PGlite's defaults, where Postgres returns a session to its startup's: this client's are applied again
360
+ // at its next turn (Postgres's own reply says what ran: its CommandComplete tag)
361
+ if (reply.some((r) => r.type === "C" && /^(DISCARD|RESET)\b/.test(r.frame.toString("latin1", 5, r.frame.length - 1))))
362
+ this.#source.settingsOf = null;
363
+ let out = reply;
364
+ if (end === "H") {
365
+ out = reply.filter((r) => r.type !== "Z");
366
+ }
367
+ else if (end === "S") {
368
+ const last = reply.findLastIndex((r) => r.type === "Z");
369
+ out = reply.filter((r, i) => r.type !== "Z" || i === last);
370
+ }
371
+ const z = out.findLast((r) => r.type === "Z");
372
+ this.#status = z === undefined ? (end === "H" ? null : this.#status) : String.fromCharCode(z.frame[5]);
373
+ if (out.length > 0)
374
+ this.#send(...out.map((r) => r.frame));
375
+ if (this.#status === "I")
376
+ this.#source.lock.release(this);
377
+ }
378
+ /** ends the connection (idempotent): once its batches are done, the session it held is left idle and the lock freed */
379
+ end() {
380
+ this.#closed = true;
381
+ this.#ended ??= (async () => {
382
+ // batches already queued run to their end (or see the connection closed), then the cleanup
383
+ await this.#chain;
384
+ const lock = this.#source.lock;
385
+ if (lock.holder === this) {
386
+ try {
387
+ if (this.#status === null)
388
+ this.#status = statusOf(await this.#source.exec(frame("S")));
389
+ if (this.#status === "T" || this.#status === "E") {
390
+ this.#source.note(`demo database: connection ${this.pid} ended inside a transaction; rolled back`);
391
+ this.#status = statusOf(await this.#source.exec(frame("Q", cstr("ROLLBACK"))));
392
+ }
393
+ }
394
+ catch (e) {
395
+ this.#source.note(`demo database: connection ${this.pid}: the rollback after it ended failed: ${errorText(e)}`);
396
+ }
397
+ finally {
398
+ lock.release(this);
399
+ }
400
+ }
401
+ this.#source.forget(this);
402
+ this.#socket.destroy();
403
+ })();
404
+ return this.#ended;
405
+ }
406
+ }
407
+ /** the status byte of a reply's last ReadyForQuery (I when it has none: a cleanup's reply always ends in one) */
408
+ function statusOf(reply) {
409
+ const z = splitFrames(reply).findLast((r) => r.type === "Z");
410
+ return z === undefined ? "I" : String.fromCharCode(z.frame[5]);
411
+ }
412
+ /** a frame as the trace says it: its type, and a Query's or a Parse's statement */
413
+ function describeFrame(f) {
414
+ const type = String.fromCharCode(f[0]);
415
+ if (type === "Q")
416
+ return `Q[${f.toString("utf8", 5, f.length - 1)}]`;
417
+ if (type === "P") {
418
+ const nameEnd = f.indexOf(0, 5);
419
+ const sqlEnd = f.indexOf(0, nameEnd + 1);
420
+ return `P${nameEnd > 5 ? `(${f.toString("utf8", 5, nameEnd)})` : ""}[${f.toString("utf8", nameEnd + 1, sqlEnd)}]`;
421
+ }
422
+ return type;
423
+ }
424
+ export class DemoSource {
425
+ port;
426
+ url;
427
+ /** the schema was seeded at THIS open (the folder was new) */
428
+ seeded;
429
+ /** @internal the lock every use of the one PGlite session takes */
430
+ lock;
431
+ /** @internal the ParameterStatus values that are PGlite's own, read at open */
432
+ serverParameters;
433
+ /** @internal one line per frame batch relayed, when asked for (development: what a client sends) */
434
+ trace;
435
+ #db;
436
+ #server;
437
+ #log;
438
+ #connections = new Set();
439
+ #closing = null;
440
+ constructor(o) {
441
+ this.#db = o.db;
442
+ this.#server = o.server;
443
+ this.port = o.port;
444
+ this.url = demoUrl(o.port);
445
+ this.seeded = o.seeded;
446
+ this.#log = o.log;
447
+ this.lock = o.lock;
448
+ this.serverParameters = o.serverParameters;
449
+ this.trace = o.trace;
450
+ }
451
+ /** boots PGlite at `dir` (made if absent), seeds the schema at the folder's first open, listens on 127.0.0.1:`port`
452
+ * (throws an Error naming the port when it is held); `log` gets one line per notable event. Port 0 takes a free one,
453
+ * which `port` then says. `trace` (development) gets every batch a client sends. */
454
+ static async open(o) {
455
+ // the port first: a port that is held refuses at once, before a new folder is made and seeded for nothing. A
456
+ // connection that arrives before PGlite is up is held until it is (accepted below), or closed when the open fails
457
+ let source = null;
458
+ const early = [];
459
+ let nextPid = 1000;
460
+ const server = net.createServer((socket) => {
461
+ const pid = nextPid++;
462
+ if (source === null) {
463
+ early.push({ socket, pid });
464
+ return;
465
+ }
466
+ source.#accept(socket, pid);
467
+ });
468
+ const port = await new Promise((resolve, reject) => {
469
+ server.once("error", (e) => {
470
+ reject(new Error(e.code === "EADDRINUSE"
471
+ // what failed alone: the way out is the caller's to say, once (src/cli/demo.ts openDemo: the project's own words)
472
+ ? `the demo database cannot listen on 127.0.0.1:${o.port}: port ${o.port} is in use`
473
+ : `the demo database cannot listen on 127.0.0.1:${o.port}: ${e.message}`, { cause: e }));
474
+ });
475
+ server.listen({ host: "127.0.0.1", port: o.port }, () => {
476
+ const a = server.address();
477
+ resolve(typeof a === "object" && a !== null ? a.port : o.port);
478
+ });
479
+ });
480
+ server.on("error", (e) => o.log(`demo database: the listener on 127.0.0.1:${port} failed: ${e.message}`));
481
+ let db = null;
482
+ try {
483
+ fs.mkdirSync(o.dir, { recursive: true });
484
+ const t0 = Date.now();
485
+ db = await boot(path.resolve(o.dir), o.log);
486
+ const lock = new SessionLock(o.log);
487
+ const exists = (await db.query("SELECT to_regclass('public.orders')::text AS t")).rows[0]?.t ?? null;
488
+ let seeded = false;
489
+ if (exists === null) {
490
+ await seed(db);
491
+ seeded = true;
492
+ const counts = await countRows(db);
493
+ o.log(`demo database: seeded ${DEMO_TABLES.map((t) => `${t} ${counts[t].toLocaleString("en-US")}`).join(", ")} rows in ${Date.now() - t0} ms, at ${o.dir}`);
494
+ }
495
+ // ANALYZE AT EVERY OPEN, so the catalog's row estimates are the counts (`mason tables`' rows, the copy's
496
+ // smallest-first order): the seed's rows commit before an ANALYZE can run, and "seeded" is told by the tables
497
+ // alone, so a process ended between the two would leave a folder never analysed; ~20k rows, milliseconds
498
+ await db.exec("ANALYZE");
499
+ const row = (await db.query(`SELECT ${SERVER_PARAMETERS.map((p) => `current_setting('${p}') AS "${p}"`).join(", ")}`)).rows[0] ?? {};
500
+ const serverParameters = {};
501
+ for (const p of SERVER_PARAMETERS)
502
+ serverParameters[p] = String(row[p] ?? "");
503
+ // PGlite's single-user backend leaves session_authorization empty; Postgres reports the session's user
504
+ if (serverParameters.session_authorization === "")
505
+ serverParameters.session_authorization = DEMO_USER;
506
+ source = new DemoSource({ db, server, port, seeded, log: o.log, lock, serverParameters, trace: o.trace });
507
+ for (const e of early.splice(0))
508
+ source.#accept(e.socket, e.pid);
509
+ o.log(`demo database: listening on 127.0.0.1:${port} (${source.url}), data in ${o.dir}`);
510
+ return source;
511
+ }
512
+ catch (e) {
513
+ const err = e instanceof Error ? e : new Error(String(e));
514
+ for (const c of early.splice(0))
515
+ c.socket.destroy();
516
+ await new Promise((resolve) => server.close(() => resolve()));
517
+ if (db !== null) {
518
+ await db.close().catch((ce) => o.log(`demo database: PGlite did not close cleanly after a failed open: ${errorText(ce)}`));
519
+ }
520
+ throw new Error(`the demo database at ${o.dir} did not open: ${err.message}`, { cause: err });
521
+ }
522
+ }
523
+ #accept(socket, pid) {
524
+ if (this.#closing !== null) {
525
+ socket.destroy();
526
+ return;
527
+ }
528
+ this.#connections.add(new Connection(socket, this, pid));
529
+ }
530
+ /** @internal */
531
+ forget(c) {
532
+ this.#connections.delete(c);
533
+ if (this.settingsOf === c)
534
+ this.settingsOf = null;
535
+ }
536
+ /** @internal a notable event, for the log */
537
+ note(line) { this.#log(line); }
538
+ /** @internal protocol bytes through PGlite: only under the lock */
539
+ exec(bytes) {
540
+ if (this.#closing !== null && this.#db.closed)
541
+ return Promise.reject(new Error("the demo database is closed"));
542
+ return this.#db.execProtocolRaw(bytes);
543
+ }
544
+ /** @internal the connection whose settings the one session holds now (null: PGlite's defaults, or unknown) */
545
+ settingsOf = null;
546
+ /** @internal a connection's startup settings, as one statement, under the lock it holds */
547
+ async reapply(c) {
548
+ if (this.settingsOf === c)
549
+ return;
550
+ if (c.settings.length > 0) {
551
+ await this.#db.query(`SELECT ${c.settings.map((_, i) => `set_config($${2 * i + 1}, $${2 * i + 2}, false)`).join(", ")}`, c.settings.flat());
552
+ }
553
+ this.settingsOf = c;
554
+ }
555
+ /** @internal a client's startup settings applied to the one session, and the values it then reports */
556
+ applySettings(c) {
557
+ return this.lock.exclusive("a client's startup", async () => {
558
+ this.settingsOf = null;
559
+ await this.reapply(c);
560
+ const row = (await this.#db.query(`SELECT ${SESSION_PARAMETERS.map((p) => `current_setting('${p}') AS "${p}"`).join(", ")}`)).rows[0] ?? {};
561
+ const out = {};
562
+ for (const p of SESSION_PARAMETERS)
563
+ out[p] = String(row[p] ?? "");
564
+ return out;
565
+ });
566
+ }
567
+ /** one round of changes (a new order with its items, an older order's status moved on): what it did, one line */
568
+ change() {
569
+ if (this.#closing !== null)
570
+ return Promise.reject(new Error("the demo database is closed"));
571
+ return this.lock.exclusive("change()", async () => {
572
+ const r = (await this.#db.query(CHANGE_STATE_SQL)).rows[0];
573
+ if (r === undefined)
574
+ throw new Error("the demo database's change could not read its state (no row)");
575
+ const state = {
576
+ newest: Number(r.newest), customers: Number(r.customers),
577
+ prices: JSON.parse(r.prices),
578
+ moving: r.moving === null ? null : JSON.parse(r.moving),
579
+ };
580
+ const c = changeSql(state);
581
+ const id = await this.#db.transaction(async (tx) => {
582
+ let last;
583
+ for (const s of c.statements)
584
+ last = (await tx.query(s)).rows[0]?.id ?? last;
585
+ return last ?? "?";
586
+ });
587
+ return c.said(id);
588
+ });
589
+ }
590
+ /** ends every connection, the listener and PGlite, its data flushed to `dir`; idempotent */
591
+ close() {
592
+ this.#closing ??= (async () => {
593
+ const listening = new Promise((resolve) => this.#server.close(() => resolve()));
594
+ // every connection's socket goes; each one's end runs its cleanup (a rollback under the lock) before it settles
595
+ await Promise.all([...this.#connections].map((c) => c.end()));
596
+ await listening;
597
+ // the lock's last holder (a change() in flight) finishes before PGlite closes
598
+ await this.lock.exclusive("close()", async () => { await this.#db.close(); });
599
+ })();
600
+ return this.#closing;
601
+ }
602
+ }
603
+ /**
604
+ * PGLITE AS DATABASE `demo` AND USER `demo`, so `current_database()` and `current_user` are what the URL says (PGlite
605
+ * boots into `postgres` as `postgres`, and cannot boot into a database that does not exist yet). A folder without them
606
+ * — a new one, or one whose first open stopped before they were made — is booted once as `postgres` to make them (the
607
+ * role a superuser, as an app's owner often is: every catalog read and `pg_control_system()` are its), then booted
608
+ * again as `demo`.
609
+ */
610
+ async function boot(dir, log) {
611
+ const asDemo = async () => {
612
+ const db = new PGlite({ dataDir: dir, database: DEMO_DATABASE, username: DEMO_USER });
613
+ await db.waitReady;
614
+ return db;
615
+ };
616
+ if (fs.existsSync(path.join(dir, "PG_VERSION"))) {
617
+ try {
618
+ return await asDemo();
619
+ }
620
+ catch (e) {
621
+ // the folder is a Postgres without the demo database or role yet: made below, then booted again; any other
622
+ // failure fails that boot too, and is thrown from it
623
+ log(`demo database: ${dir} did not boot as database ${DEMO_DATABASE} (${errorText(e)}); making it`);
624
+ }
625
+ }
626
+ const admin = new PGlite({ dataDir: dir });
627
+ try {
628
+ await admin.waitReady;
629
+ const has = (await admin.query(`SELECT EXISTS (SELECT FROM pg_database WHERE datname = '${DEMO_DATABASE}') AS db, EXISTS (SELECT FROM pg_roles WHERE rolname = '${DEMO_USER}') AS role`)).rows[0];
630
+ if (has?.role !== true)
631
+ await admin.exec(`CREATE ROLE ${DEMO_USER} SUPERUSER LOGIN`);
632
+ // CREATE DATABASE refuses a transaction block: a statement of its own
633
+ if (has?.db !== true)
634
+ await admin.exec(`CREATE DATABASE ${DEMO_DATABASE} OWNER ${DEMO_USER}`);
635
+ }
636
+ finally {
637
+ await admin.close();
638
+ }
639
+ return asDemo();
640
+ }
641
+ /** the seed, in one transaction (the ANALYZE that makes the row estimates the counts is every open's, after it) */
642
+ async function seed(db) {
643
+ const s = seedStatements(new Date());
644
+ await db.transaction(async (tx) => {
645
+ await tx.exec(s.ddl);
646
+ for (const statement of s.rows)
647
+ await tx.exec(statement);
648
+ for (const statement of s.after)
649
+ await tx.exec(statement);
650
+ });
651
+ }
652
+ async function countRows(db) {
653
+ const r = (await db.query(`SELECT ${DEMO_TABLES.map((t) => `(SELECT count(*) FROM ${t})::text AS ${t}`).join(", ")}`)).rows[0] ?? {};
654
+ return Object.fromEntries(DEMO_TABLES.map((t) => [t, Number(r[t] ?? 0)]));
655
+ }