@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
@@ -18,10 +18,13 @@ import * as tls from "node:tls";
18
18
  export class PgError extends Error {
19
19
  code;
20
20
  detail;
21
+ /** the ErrorResponse's own fields, by their one-letter codes (M the message alone, H the hint, P the position) */
22
+ fields;
21
23
  constructor(fields) {
22
24
  super(`${fields.S ?? "ERROR"}: ${fields.M ?? "unknown error"}${fields.D ? ` (${fields.D})` : ""}`);
23
25
  this.code = fields.C ?? "";
24
26
  this.detail = fields.D;
27
+ this.fields = fields;
25
28
  }
26
29
  }
27
30
  /** Microseconds between the Unix epoch and Postgres's (2000-01-01). */
@@ -72,45 +75,39 @@ export class PgConnection {
72
75
  }
73
76
  /** TCP connect, the SSL negotiation and the startup exchange; `hold` is told the live socket, for a timeout to destroy. */
74
77
  async handshake(hold) {
75
- const o = this.opts;
76
- const mode = o.sslmode ?? "prefer";
77
- const raw = await new Promise((resolve, reject) => {
78
- const s = net.connect({ host: o.host, port: o.port });
79
- hold(s);
80
- s.once("connect", () => resolve(s));
81
- s.once("error", (e) => reject(new Error(`connecting to ${o.host}:${o.port}: ${socketErrorText(e)}`)));
82
- });
83
- let sock = raw;
84
- if (mode !== "disable" && mode !== "allow") {
85
- // SSLRequest: length 8, code 80877103; the server answers one byte, S or N
86
- raw.write(Buffer.from([0, 0, 0, 8, 0x04, 0xd2, 0x16, 0x2f]));
87
- const answer = await new Promise((resolve, reject) => {
88
- raw.once("data", (d) => resolve(String.fromCharCode(d[0])));
89
- raw.once("error", reject);
90
- });
91
- if (answer === "S") {
92
- const verify = mode === "verify-ca" || mode === "verify-full";
93
- sock = await new Promise((resolve, reject) => {
94
- const t = tls.connect({
95
- socket: raw, rejectUnauthorized: verify,
96
- ...(mode === "verify-full" ? { servername: o.host } : { checkServerIdentity: () => undefined }),
97
- });
98
- hold(t);
99
- t.once("secureConnect", () => resolve(t));
100
- t.once("error", reject);
101
- });
102
- }
103
- else if (mode !== "prefer") {
104
- raw.destroy();
105
- throw new Error(`sslmode=${mode}, but ${o.host} does not accept TLS`);
106
- }
107
- }
78
+ const sock = await transport(this.opts, hold);
108
79
  this.socket = sock;
109
80
  sock.on("data", (d) => this.onData(d));
110
81
  sock.on("error", (e) => this.fail(e));
111
82
  sock.on("close", () => this.fail(new Error("connection closed by the server")));
112
83
  await this.startup();
113
84
  }
85
+ /** the server's key for this session (BackendKeyData), for a CancelRequest */
86
+ key = null;
87
+ /**
88
+ * ASK THE SERVER TO CANCEL THIS SESSION'S RUNNING STATEMENT (Postgres's CancelRequest, on a connection of its own,
89
+ * TLS as this one's sslmode): the statement ends with 57014 on this connection, which stays usable. Resolves once
90
+ * the request is written.
91
+ */
92
+ async cancel() {
93
+ const key = this.key;
94
+ if (key === null)
95
+ throw new Error("the server sent no key for this session: it cannot be cancelled");
96
+ let held = null;
97
+ const timer = setTimeout(() => { held?.destroy(); }, this.opts.connectTimeoutMs ?? 15_000);
98
+ try {
99
+ const s = await transport(this.opts, (x) => { held = x; });
100
+ const b = Buffer.alloc(16);
101
+ b.writeInt32BE(16, 0);
102
+ b.writeInt32BE(80877102, 4);
103
+ b.writeInt32BE(key.pid, 8);
104
+ b.writeInt32BE(key.secret, 12);
105
+ await new Promise((resolve, reject) => { s.once("error", reject); s.end(b, () => resolve()); });
106
+ }
107
+ finally {
108
+ clearTimeout(timer);
109
+ }
110
+ }
114
111
  fail(e) {
115
112
  if (this.closedWith)
116
113
  return;
@@ -167,6 +164,16 @@ export class PgConnection {
167
164
  if (k !== undefined)
168
165
  this.parameters[k] = v ?? "";
169
166
  }
167
+ /** the next message of a statement's reply; when the server closed the connection after an ErrorResponse it sent
168
+ * (a FATAL: a user removed, a shutdown) and before ReadyForQuery, that error — never the bare "connection closed" */
169
+ async reply(error) {
170
+ try {
171
+ return await this.next();
172
+ }
173
+ catch (e) {
174
+ throw error ?? e;
175
+ }
176
+ }
170
177
  next() {
171
178
  const m = this.queue.shift();
172
179
  if (m) {
@@ -195,7 +202,7 @@ export class PgConnection {
195
202
  const params = [
196
203
  ["user", o.user], ["database", o.database], ["client_encoding", "UTF8"],
197
204
  // Text values in the forms the reader parses: ISO dates in UTC, floats round-trippable
198
- ["DateStyle", "ISO, YMD"], ["TimeZone", "UTC"], ["extra_float_digits", "3"], ["IntervalStyle", "iso_8601"],
205
+ ...(o.textForms ?? [["DateStyle", "ISO, YMD"], ["TimeZone", "UTC"], ["extra_float_digits", "3"], ["IntervalStyle", "iso_8601"]]).map(([k, v]) => [k, v]),
199
206
  ["application_name", o.applicationName ?? "mason"],
200
207
  ];
201
208
  if (o.replication)
@@ -216,8 +223,10 @@ export class PgConnection {
216
223
  await this.auth(m.body);
217
224
  continue;
218
225
  }
219
- if (m.type === "K")
220
- continue; // BackendKeyData
226
+ if (m.type === "K") {
227
+ this.key = { pid: m.body.readInt32BE(0), secret: m.body.readInt32BE(4) };
228
+ continue;
229
+ } // BackendKeyData
221
230
  if (m.type === "Z")
222
231
  return;
223
232
  throw new Error(`unexpected message ${m.type} during startup`);
@@ -288,7 +297,7 @@ export class PgConnection {
288
297
  let result = { fields: [], rows: [], command: "" };
289
298
  let error = null;
290
299
  for (;;) {
291
- const m = await this.next();
300
+ const m = await this.reply(error);
292
301
  switch (m.type) {
293
302
  case "T":
294
303
  result = { fields: rowDescription(m.body), rows: [], command: "" };
@@ -321,7 +330,7 @@ export class PgConnection {
321
330
  this.send("Q", cstr(sql));
322
331
  let error = null;
323
332
  for (;;) {
324
- const m = await this.next();
333
+ const m = await this.reply(error);
325
334
  switch (m.type) {
326
335
  case "D":
327
336
  if (error === null)
@@ -338,6 +347,52 @@ export class PgConnection {
338
347
  }
339
348
  }
340
349
  }
350
+ /**
351
+ * ONE STATEMENT THROUGH THE EXTENDED PROTOCOL (`mason mcp`'s query): Parse, Bind, Describe and Execute of the unnamed
352
+ * portal, then Sync — so the server takes exactly one statement (a second is Postgres's 42601), and `maxRows` is the
353
+ * Execute's row count: the server stops at it and says so (PortalSuspended), so a query an agent leaves unbounded is
354
+ * never sent whole. 0 is every row. Every value is text.
355
+ */
356
+ async extended(sql, maxRows) {
357
+ const int16 = (n) => { const b = Buffer.alloc(2); b.writeInt16BE(n); return b; };
358
+ this.send("P", Buffer.concat([cstr(""), cstr(sql), int16(0)]));
359
+ // no parameters, every result column in text
360
+ this.send("B", Buffer.concat([cstr(""), cstr(""), int16(0), int16(0), int16(0)]));
361
+ this.send("D", Buffer.concat([Buffer.from("P"), cstr("")]));
362
+ this.send("E", Buffer.concat([cstr(""), int32(maxRows)]));
363
+ this.send("S", Buffer.alloc(0));
364
+ const result = { fields: [], rows: [], suspended: false, command: "", empty: false };
365
+ let error = null;
366
+ for (;;) {
367
+ const m = await this.reply(error);
368
+ switch (m.type) {
369
+ case "T":
370
+ result.fields = rowFields(m.body);
371
+ break;
372
+ case "D":
373
+ if (error === null)
374
+ result.rows.push(dataRow(m.body));
375
+ break;
376
+ case "s":
377
+ result.suspended = true;
378
+ break;
379
+ case "C":
380
+ result.command = cstrings(m.body)[0] ?? "";
381
+ break;
382
+ case "E":
383
+ error = new PgError(errorFields(m.body));
384
+ break;
385
+ case "I":
386
+ result.empty = true;
387
+ break;
388
+ case "Z":
389
+ if (error)
390
+ throw error;
391
+ return result;
392
+ default: break; // ParseComplete, BindComplete, NoData
393
+ }
394
+ }
395
+ }
341
396
  // ------------------------------------------------------------------ replication
342
397
  /** START_REPLICATION, then an async stream of XLogData and keepalives until the connection ends. */
343
398
  async *replicate(command) {
@@ -392,6 +447,42 @@ export class PgConnection {
392
447
  }
393
448
  }
394
449
  // ------------------------------------------------------------------ helpers
450
+ /** TCP connect and the SSL negotiation (libpq's sslmode): the socket a startup, or a CancelRequest, is written to. */
451
+ async function transport(o, hold) {
452
+ const mode = o.sslmode ?? "prefer";
453
+ const raw = await new Promise((resolve, reject) => {
454
+ const s = net.connect({ host: o.host, port: o.port });
455
+ hold(s);
456
+ s.once("connect", () => resolve(s));
457
+ s.once("error", (e) => reject(new Error(`connecting to ${o.host}:${o.port}: ${socketErrorText(e)}`)));
458
+ });
459
+ let sock = raw;
460
+ if (mode !== "disable" && mode !== "allow") {
461
+ // SSLRequest: length 8, code 80877103; the server answers one byte, S or N
462
+ raw.write(Buffer.from([0, 0, 0, 8, 0x04, 0xd2, 0x16, 0x2f]));
463
+ const answer = await new Promise((resolve, reject) => {
464
+ raw.once("data", (d) => resolve(String.fromCharCode(d[0])));
465
+ raw.once("error", reject);
466
+ });
467
+ if (answer === "S") {
468
+ const verify = mode === "verify-ca" || mode === "verify-full";
469
+ sock = await new Promise((resolve, reject) => {
470
+ const t = tls.connect({
471
+ socket: raw, rejectUnauthorized: verify,
472
+ ...(mode === "verify-full" ? { servername: o.host } : { checkServerIdentity: () => undefined }),
473
+ });
474
+ hold(t);
475
+ t.once("secureConnect", () => resolve(t));
476
+ t.once("error", reject);
477
+ });
478
+ }
479
+ else if (mode !== "prefer") {
480
+ raw.destroy();
481
+ throw new Error(`sslmode=${mode}, but ${o.host} does not accept TLS`);
482
+ }
483
+ }
484
+ return sock;
485
+ }
395
486
  /** `16/B374D848` -> its 64-bit position; throws on anything else. */
396
487
  export function parseLsn(text) {
397
488
  const m = /^([0-9A-Fa-f]{1,8})\/([0-9A-Fa-f]{1,8})$/.exec(text.trim());
@@ -452,6 +543,21 @@ function rowDescription(b) {
452
543
  }
453
544
  return names;
454
545
  }
546
+ /** A RowDescription's columns with their types (rowDescription keeps the names alone). */
547
+ function rowFields(b) {
548
+ const n = b.readInt16BE(0);
549
+ const out = [];
550
+ let i = 2;
551
+ for (let k = 0; k < n; k++) {
552
+ const end = b.indexOf(0, i);
553
+ const name = b.toString("utf8", i, end);
554
+ i = end + 1;
555
+ // table oid (4), column number (2), type oid (4), type size (2), type modifier (4), format (2)
556
+ out.push({ name, typeOid: b.readUInt32BE(i + 6), typmod: b.readInt32BE(i + 12) });
557
+ i += 18;
558
+ }
559
+ return out;
560
+ }
455
561
  /** onData pauses the socket above this many queued bytes, and next() resumes it below a quarter of it. */
456
562
  const QUEUE_PAUSE_BYTES = 16 * 1024 * 1024;
457
563
  function dataRow(b) {
@@ -19,8 +19,9 @@ import { fileURLToPath } from "node:url";
19
19
  import { runnable } from "../lib/runnable.js";
20
20
  import { READY, STEP_MARK } from "./dev.js";
21
21
  import { liveRun } from "./runs.js";
22
- import { theProject } from "./project.js";
22
+ import { projectSource, theProject } from "./project.js";
23
23
  import { runListenerSettings } from "./pg-tls.js";
24
+ import { demoChangesRefusal, demoPort } from "./demo.js";
24
25
  import { endText, failed, jsonText, nextBlock } from "./command.js";
25
26
  import { DEV } from "./registry.js";
26
27
  import { out as styleOut, stepRenderer } from "./style.js";
@@ -38,6 +39,18 @@ export const NEXT_STEPS = [
38
39
  ["mason logs", "its log: every copy, poll and statement"],
39
40
  ["mason stop", "stop Mason; the next mason start resumes where it stopped"],
40
41
  ];
42
+ /** The ports a demo project's database takes (its URL's port, read as the run reads it), for the API's choice to avoid;
43
+ * none for any other project. A sources file or a URL that cannot be read gives none HERE: the run refuses it in its
44
+ * own words (its source step, its demo step), shown by this start as every other refusal of the run's is. */
45
+ function demoPortsOf(project) {
46
+ if (project.config.demo !== true)
47
+ return new Set();
48
+ const src = projectSource(project, process.env);
49
+ if ("message" in src)
50
+ return new Set();
51
+ const at = demoPort(src.resolved, src.from);
52
+ return new Set("port" in at ? [at.port] : []);
53
+ }
41
54
  /** `args` are `mason start`'s own, passed on to the run with --attach. */
42
55
  export async function devInBackground(args, o) {
43
56
  const t = theProject(process.cwd());
@@ -55,9 +68,14 @@ export async function devInBackground(args, o) {
55
68
  say(nextBlock([["mason status", "what it is doing"], ["mason stop", "stop it"]]));
56
69
  return 0;
57
70
  }
71
+ // a demo project with --replication (src/cli/demo.ts): refused here too, where it is read, before a run is spawned
72
+ const demoRefused = demoChangesRefusal(t.project.config, o.changes ?? null);
73
+ if (demoRefused)
74
+ return failed("start", demoRefused, { json: o.json });
58
75
  // a MASON_PG_PORT that is no port, or is held, is refused HERE, where the person (or --json) reads it, before a run is
59
- // spawned; the run checks again for itself (choosePgPort, src/cli/ports.ts), since a port may be taken in between
60
- const pg = await choosePgPort(process.env.MASON_PG_PORT, new Set(), t.project.config.pgPort ?? null);
76
+ // spawned; the run checks again for itself (choosePgPort, src/cli/ports.ts), since a port may be taken in between.
77
+ // A demo project's database port (its URL's, src/cli/demo.ts demoPort) is avoided as the run avoids it
78
+ const pg = await choosePgPort(process.env.MASON_PG_PORT, demoPortsOf(t.project), t.project.config.pgPort ?? null);
61
79
  if ("problem" in pg)
62
80
  return failed("start", pg, { json: o.json });
63
81
  // and so is a listener off loopback without TLS, by the run's own check (src/cli/pg-tls.ts): read, never written
@@ -140,8 +158,8 @@ export async function devInBackground(args, o) {
140
158
  steps.fail(l.slice(STEP_MARK.fail.length));
141
159
  continue;
142
160
  }
143
- // the run's line (src/cli/dev.ts: "Postgres <url>")
144
- const pgLine = /^\s+Postgres\s+(postgresql:\S+)/.exec(l);
161
+ // the run's line (src/cli/dev.ts: "Postgres <url>", the URL in its shown form, `withPlaceholders`: postgres://<user>:<password>@…)
162
+ const pgLine = /^\s+Postgres\s+(postgres:\/\/<user>:<password>@\S+)/.exec(l);
145
163
  if (pgLine) {
146
164
  postgres = postgresLink(withoutPlaceholders(pgLine[1]));
147
165
  steps.print(` ${st.dim("Postgres API")} ${pgLine[1]} ${st.dim("(mason user add <name> makes a user)")}`);
@@ -151,6 +169,12 @@ export async function devInBackground(args, o) {
151
169
  steps.print(` ${st.dim("Web UI".padEnd("Postgres API".length))} ${st.cyan("mason ui")} ${st.dim("(the status page and the users, in your browser)")}`);
152
170
  continue;
153
171
  }
172
+ // a demo project's line (src/cli/dev.ts: "Demo a new order lands every <n> s: …"), in the same column
173
+ const demoLine = /^\s+Demo\s{2}(.+)$/.exec(l);
174
+ if (demoLine) {
175
+ steps.print(` ${st.dim("Demo".padEnd("Postgres API".length))} ${demoLine[1]}`);
176
+ continue;
177
+ }
154
178
  if (l.trim())
155
179
  since.push(l.trim().replace(/^mason start: /, ""));
156
180
  steps.print(l);
@@ -24,7 +24,7 @@ export function parseCommand(spec, argv) {
24
24
  parsed = parseArgs({ args: argv, options: { ...spec.options, ...HELP }, allowPositionals: true });
25
25
  }
26
26
  catch (e) {
27
- return failed(spec.name, { message: e.message, code: "usage", usage: spec.usage, next: [[`mason ${spec.name} --help`, "its usage, with every option"]] }, { json: wantsJson(argv) });
27
+ return failed(spec.name, { message: e.message, code: "usage", usage: spec.usage, next: [[`mason ${spec.name} --help`, "its usage, with every option"]] }, { json: wantsJson(argv, spec) });
28
28
  }
29
29
  if (parsed.values.help) {
30
30
  process.stdout.write(spec.usage);
@@ -76,8 +76,9 @@ export function nextJson(steps = []) {
76
76
  return steps.map(([command, what]) => what ? { command, what } : { command });
77
77
  }
78
78
  /** Whether a command line asked for data: `--json` anywhere on it (read before the line is parsed, so a wrong line
79
- * is answered in JSON too). */
80
- export const wantsJson = (argv) => argv.includes("--json");
79
+ * is answered in JSON too) — never for a command whose stdout is a protocol's (`CommandSpec.stdout`), whose every
80
+ * failure is said on stderr. */
81
+ export const wantsJson = (argv, spec) => spec?.stdout !== "protocol" && argv.includes("--json");
81
82
  /**
82
83
  * A COMMAND'S --json ANSWER (an agent wants clear, unambiguous context and a machine-readable format, and a person
83
84
  * and an agent both want the next steps): one JSON object on stdout, always with `next` — the same steps the human