@mason-data/cli 0.6.2 → 0.8.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 (119) hide show
  1. package/README.md +147 -69
  2. package/dist/src/api/error.js +3 -0
  3. package/dist/src/api/postgres/catalog.js +258 -14
  4. package/dist/src/api/postgres/encode.js +3 -1
  5. package/dist/src/api/postgres/explain.js +3 -2
  6. package/dist/src/api/postgres/protocol.js +39 -23
  7. package/dist/src/api/postgres/server.js +1290 -223
  8. package/dist/src/api/postgres/translate.js +81 -9
  9. package/dist/src/api/request-log.js +18 -5
  10. package/dist/src/cdc/pg-wire.js +144 -38
  11. package/dist/src/cdc/poll.js +5 -5
  12. package/dist/src/cli/background.js +55 -8
  13. package/dist/src/cli/cancel.js +75 -0
  14. package/dist/src/cli/command.js +5 -3
  15. package/dist/src/cli/commands.js +843 -272
  16. package/dist/src/cli/control.js +59 -0
  17. package/dist/src/cli/demo.js +158 -0
  18. package/dist/src/cli/dev.js +458 -125
  19. package/dist/src/cli/doctor.js +25 -12
  20. package/dist/src/cli/guides.js +103 -31
  21. package/dist/src/cli/init.js +159 -58
  22. package/dist/src/cli/machine.js +18 -12
  23. package/dist/src/cli/main.js +5 -5
  24. package/dist/src/cli/mcp.js +735 -0
  25. package/dist/src/cli/metrics.js +79 -0
  26. package/dist/src/cli/pg-tls.js +13 -3
  27. package/dist/src/cli/pg-url.js +4 -0
  28. package/dist/src/cli/pin.js +13 -12
  29. package/dist/src/cli/preflight.js +24 -14
  30. package/dist/src/cli/project.js +48 -7
  31. package/dist/src/cli/readiness.js +38 -4
  32. package/dist/src/cli/registry.js +57 -35
  33. package/dist/src/cli/replication-checks.js +7 -5
  34. package/dist/src/cli/replication-mode.js +105 -26
  35. package/dist/src/cli/request-filter.js +134 -0
  36. package/dist/src/cli/roles.js +584 -0
  37. package/dist/src/cli/run-server.js +498 -133
  38. package/dist/src/cli/runs.js +1 -1
  39. package/dist/src/cli/security.js +10 -8
  40. package/dist/src/cli/serve.js +147 -40
  41. package/dist/src/cli/share-run.js +286 -0
  42. package/dist/src/cli/share.js +286 -0
  43. package/dist/src/cli/source-edit.js +168 -0
  44. package/dist/src/cli/sources.js +290 -39
  45. package/dist/src/cli/supervise.js +12 -1
  46. package/dist/src/cli/sync.js +55 -25
  47. package/dist/src/cli/tunnel.js +200 -0
  48. package/dist/src/cli/ui.js +9 -6
  49. package/dist/src/cli/usage.js +128 -0
  50. package/dist/src/cli/user.js +496 -40
  51. package/dist/src/cli/version.js +9 -7
  52. package/dist/src/demo/seed.js +240 -0
  53. package/dist/src/demo/source.js +655 -0
  54. package/dist/src/duck/cancel.js +10 -1
  55. package/dist/src/duck/distinct-key.js +228 -0
  56. package/dist/src/duck/events.js +134 -0
  57. package/dist/src/duck/executor-worker.js +25 -11
  58. package/dist/src/duck/executor.js +107 -53
  59. package/dist/src/duck/explain.js +158 -29
  60. package/dist/src/duck/extensions.js +160 -0
  61. package/dist/src/duck/functions.js +73 -19
  62. package/dist/src/duck/gaps.js +25 -11
  63. package/dist/src/duck/grants.js +176 -0
  64. package/dist/src/duck/history.js +123 -0
  65. package/dist/src/duck/jsonb-order.js +175 -0
  66. package/dist/src/duck/jsonb.js +10 -8
  67. package/dist/src/duck/kept-route.js +82 -2
  68. package/dist/src/duck/kept.js +211 -44
  69. package/dist/src/duck/pgtree.js +40 -0
  70. package/dist/src/duck/plan.js +120 -30
  71. package/dist/src/duck/publication.js +6 -6
  72. package/dist/src/duck/requests.js +265 -93
  73. package/dist/src/duck/rewrite.js +1193 -73
  74. package/dist/src/duck/row-filter.js +649 -0
  75. package/dist/src/duck/store.js +271 -159
  76. package/dist/src/duck/stream.js +10 -5
  77. package/dist/src/duck/sync.js +907 -275
  78. package/dist/src/duck/types.js +6 -5
  79. package/dist/src/duck/usage.js +324 -0
  80. package/dist/src/duck/users.js +64 -15
  81. package/dist/src/duck/verified.js +9 -0
  82. package/dist/src/duck/views.js +2 -3
  83. package/dist/src/lib/database-name.js +62 -0
  84. package/dist/src/lib/dotenv.js +17 -4
  85. package/dist/src/lib/log-buffer.js +68 -0
  86. package/dist/src/lib/pg-numeric.js +5 -2
  87. package/dist/src/lib/project-version.js +23 -23
  88. package/dist/src/lib/table-sync.js +267 -0
  89. package/dist/src/lib/timezone.js +3 -2
  90. package/dist/src/web/app.css +706 -55
  91. package/dist/src/web/app.js +330 -258
  92. package/dist/src/web/charts.js +229 -0
  93. package/dist/src/web/index.html +84 -15
  94. package/dist/src/web/pages/queries.js +192 -0
  95. package/dist/src/web/pages/status.js +446 -0
  96. package/dist/src/web/pages/syncs.js +155 -0
  97. package/dist/src/web/pages/tables.js +434 -0
  98. package/dist/src/web/pages/usage.js +397 -0
  99. package/dist/src/web/pages/users.js +218 -0
  100. package/dist/src/web/shared.js +568 -0
  101. package/dist/src/web/vendor/README.md +25 -0
  102. package/dist/src/web/vendor/geist-1.7.2/Geist-Variable.woff2 +0 -0
  103. package/dist/src/web/vendor/geist-1.7.2/GeistMono-Variable.woff2 +0 -0
  104. package/dist/src/web/vendor/geist-1.7.2/LICENSE.txt +92 -0
  105. package/dist/src/web/vendor/lucide-1.56.0/LICENSE.txt +43 -0
  106. package/dist/src/web/vendor/lucide-1.56.0/chart-column.mjs +29 -0
  107. package/dist/src/web/vendor/lucide-1.56.0/layout-dashboard.mjs +29 -0
  108. package/dist/src/web/vendor/lucide-1.56.0/logs.mjs +34 -0
  109. package/dist/src/web/vendor/lucide-1.56.0/menu.mjs +28 -0
  110. package/dist/src/web/vendor/lucide-1.56.0/square-terminal.mjs +28 -0
  111. package/dist/src/web/vendor/lucide-1.56.0/table.mjs +29 -0
  112. package/dist/src/web/vendor/lucide-1.56.0/users.mjs +29 -0
  113. package/dist/src/web/vendor/lucide-1.56.0/x.mjs +27 -0
  114. package/dist/src/web/vendor/uplot-1.6.32/LICENSE.txt +21 -0
  115. package/dist/src/web/vendor/uplot-1.6.32/uPlot.esm.js +6140 -0
  116. package/dist/src/web/vendor/uplot-1.6.32/uPlot.min.css +1 -0
  117. package/dist/src/web/vendor/zilla-slab-5.3.0/LICENSE.txt +93 -0
  118. package/dist/src/web/vendor/zilla-slab-5.3.0/zilla-slab-latin-700-normal.woff2 +0 -0
  119. package/package.json +10 -3
@@ -1,3 +1,9 @@
1
+ /**
2
+ * THE COMMANDS' LINE TO A RUN: `mason status`, `mason tables`, `mason add` and `mason wait` ask the run's
3
+ * command server (src/cli/run-server.ts) — with the
4
+ * run's token as `Authorization: Bearer` (src/cli/security.ts), read from the project's .mason/config.json (src/cli/runs.ts).
5
+ */
6
+ import * as http from "node:http";
1
7
  /** The run does not answer: a failure with the reason as `why` and mason logs as the way out (`failure` is what a command
2
8
  * hands src/cli/command.ts failed). */
3
9
  export class RunUnreachable extends Error {
@@ -34,3 +40,56 @@ export async function callRun(run, method, route, body, timeoutMs = 30_000) {
34
40
  }
35
41
  return { status: res.status, body: parsed };
36
42
  }
43
+ /**
44
+ * ONE REQUEST HELD OPEN (src/cli/share.ts): the run answers a line of JSON and keeps the answer open, so the end of the
45
+ * connection is the end of what was asked, however this process ends. On node:http, not fetch: fetch gives up on a body
46
+ * silent for five minutes, and here silence is the normal state. A refusal is answered whole, as `callRun` returns it.
47
+ */
48
+ export function holdRun(run, route, body, timeoutMs = 30_000) {
49
+ return new Promise((resolve, reject) => {
50
+ const unreachable = (why) => new RunUnreachable(`Mason for ${run.name} (pid ${run.pid}) does not answer at ${run.control.url}`, why);
51
+ const text = JSON.stringify(body);
52
+ const req = http.request(`${run.control.url}${route}`, { method: "POST", headers: { Authorization: `Bearer ${run.control.token}`, "Content-Type": "application/json", "Content-Length": Buffer.byteLength(text) } }, (res) => {
53
+ let got = "", first = null, said = null;
54
+ let finish = () => undefined;
55
+ const ended = new Promise((r) => { finish = r; });
56
+ res.setEncoding("utf8");
57
+ res.on("data", (chunk) => {
58
+ got += chunk;
59
+ if (res.statusCode !== 200)
60
+ return;
61
+ for (let at = got.indexOf("\n"); at >= 0; at = got.indexOf("\n")) {
62
+ const line = JSON.parse(got.slice(0, at));
63
+ got = got.slice(at + 1);
64
+ if (first === null) {
65
+ first = line;
66
+ clearTimeout(timer);
67
+ resolve({ first, ended, close: () => req.destroy() });
68
+ }
69
+ else if (typeof line.ended === "string")
70
+ said = line.ended;
71
+ }
72
+ });
73
+ res.on("close", () => {
74
+ clearTimeout(timer);
75
+ if (res.statusCode === 200) {
76
+ if (first === null)
77
+ reject(unreachable("it closed the answer before it said anything"));
78
+ finish(said);
79
+ return;
80
+ }
81
+ let parsed;
82
+ try {
83
+ parsed = JSON.parse(got);
84
+ }
85
+ catch {
86
+ parsed = { error: got.trim() || `HTTP ${res.statusCode}` };
87
+ }
88
+ resolve({ status: res.statusCode ?? 0, body: parsed });
89
+ });
90
+ });
91
+ const timer = setTimeout(() => { req.destroy(); reject(unreachable(`no answer in ${timeoutMs / 1000} s`)); }, timeoutMs);
92
+ req.on("error", (e) => { clearTimeout(timer); reject(unreachable(e.code ?? e.message)); });
93
+ req.end(text);
94
+ });
95
+ }
@@ -0,0 +1,158 @@
1
+ /**
2
+ * THE DEMO PROJECT, THE CLI'S SIDE (`mason init demo`; the database itself is src/demo/source.ts): a project whose
3
+ * database is one Mason hosts itself — a Postgres in this process, persisted in `.mason/demo/`, served on a loopback
4
+ * port over the Postgres wire protocol and seeded with a small shop schema — so a person tries Mason with nothing to
5
+ * install. The project stays an ordinary one: its sources file names `url: ${DATABASE_URL}` and three of the four
6
+ * tables (the fourth, `DEMO_TABLE_TO_ADD`, is the person's to add), `.env` the demo's URL, and `mason start` reads it
7
+ * over the wire as it reads any Postgres, so the demo runs the read path production runs (docs/DESIGN.md "The demo
8
+ * database").
9
+ *
10
+ * - THE PORT LIVES IN THE URL ALONE: `mason init demo` picks one free and writes it into `.env`; `mason start` reads
11
+ * it back from the resolved URL (`demoPort`), never from a second record that could disagree with it.
12
+ * - POLL ONLY: the demo database has no change log (no WAL sender), so a table whose `sync:` is `replication` is
13
+ * refused before anything starts (`demoChangesRefusal`).
14
+ * - A WRITER: every MASON_DEMO_CHANGE_MS (`demoSettings`) the run makes one round of changes (`demoChanges`), so the
15
+ * copy is seen keeping current.
16
+ *
17
+ * The demo's module is imported when it is used (`demoModule`): a command that never hosts it never loads its database;
18
+ * its URL and names are src/demo/seed.ts's, which loads none.
19
+ */
20
+ import * as path from "node:path";
21
+ import { parseSource, isLocalHost } from "./preflight.js";
22
+ import { entryName, replicatedEntries } from "./sources.js";
23
+ import { heldText, portHolder } from "./ports.js";
24
+ import { DEMO_DIR, STATE_DIR } from "./project.js";
25
+ import { DEMO_DATABASE, DEMO_USER, demoUrl } from "../demo/seed.js";
26
+ /** What `mason init` takes in the URL's place to make a demo project. */
27
+ export const DEMO_ARG = "demo";
28
+ /** The port `mason init demo` prefers for the demo database (the next free one when it is held): beside Mason's 5433. */
29
+ export const DEMO_PORT = 5434;
30
+ /** The demo database's tables (the seed's own list), the ones `mason init demo` adds to the sources file and the one it
31
+ * leaves for the person to add, and its URL (src/demo/seed.ts: no PGlite is loaded by it, so `mason init demo` and
32
+ * `demoPort` never load the database). */
33
+ export { DEMO_ADDED_TABLES, DEMO_SYNC, DEMO_TABLES, DEMO_TABLE_TO_ADD, demoUrl } from "../demo/seed.js";
34
+ /** How often the run changes the demo database by default (MASON_DEMO_CHANGE_MS). */
35
+ export const DEMO_CHANGE_MS = 5000;
36
+ const DEMO_CHANGE_MIN_MS = 100;
37
+ /** The demo database's module (`DemoSource`, and PGlite with it), loaded when the run hosts it, never to build or read
38
+ * its URL (src/demo/seed.ts has those). */
39
+ export const demoModule = () => import("../demo/source.js");
40
+ /** What the run reads of the demo from its environment: how often it changes the database. Throws on a value that is no
41
+ * number of milliseconds, said with the way out (as src/duck/sync.ts copyParallelOf). */
42
+ export function demoSettings(env) {
43
+ const v = env.MASON_DEMO_CHANGE_MS;
44
+ if (v === undefined || v === "")
45
+ return { changeMs: DEMO_CHANGE_MS };
46
+ const n = Number(v);
47
+ if (!/^\d+$/.test(v.trim()) || n < DEMO_CHANGE_MIN_MS) {
48
+ throw new Error(`MASON_DEMO_CHANGE_MS=${JSON.stringify(v)} is not a number of milliseconds: how often the demo database changes (a new order, an older one's status moved on); ` +
49
+ `set it to ${DEMO_CHANGE_MIN_MS} or more, or unset it for ${DEMO_CHANGE_MS}`);
50
+ }
51
+ return { changeMs: n };
52
+ }
53
+ /**
54
+ * THE DEMO DATABASE'S PORT, READ FROM THE URL THE SOURCES FILE RESOLVED (`from` says where: `DATABASE_URL in .env`) — or
55
+ * why that URL is not the demo's, with the way out: a host off this machine (the demo database listens on 127.0.0.1
56
+ * alone), or any other change to `postgres://demo@127.0.0.1:<port>/demo`. The port is the one fact that may differ.
57
+ */
58
+ export function demoPort(url, from) {
59
+ const wayOut = "mason destroy and mason init demo again in a new folder, or put the URL back";
60
+ const parsed = parseSource(url);
61
+ if ("problem" in parsed)
62
+ return { problem: `${from} was changed: ${parsed.problem}. The demo database is postgres://${DEMO_USER}@127.0.0.1:<port>/${DEMO_DATABASE}; ${wayOut}` };
63
+ const s = parsed.source;
64
+ // loopback alone: the demo database listens on nothing else (0.0.0.0 or a container's host name is not it)
65
+ if (!isLocalHost(s.host) || s.host === "0.0.0.0" || s.host === "host.docker.internal") {
66
+ return { problem: `${from} names ${s.host}, which is not this machine's loopback: the demo database is hosted by mason start on 127.0.0.1 alone (${demoUrl(s.port)}); ${wayOut}` };
67
+ }
68
+ if (s.url !== demoUrl(s.port))
69
+ return { problem: `${from} was changed: the demo database is ${demoUrl(s.port)}; ${wayOut}` };
70
+ return { port: s.port };
71
+ }
72
+ /** The refusal of logical replication for a demo project (a table whose sync: is replication in its sources file,
73
+ * `shown`, or the mode an older Mason recorded in the config, edited to it): before anything starts, with the way out;
74
+ * null for any other project, or a demo one polled. */
75
+ export function demoChangesRefusal(cfg, file = null, shown = "") {
76
+ if (cfg.demo !== true)
77
+ return null;
78
+ const streamed = file === null ? [] : replicatedEntries(file).map((e) => entryName(e.table));
79
+ if (streamed.length) {
80
+ return { message: `the demo database is read by polling only: sync: replication does not apply to it (${streamed.join(", ")})`, code: "refused",
81
+ why: "the demo database has no change log for logical replication to read; Mason polls it with SELECTs",
82
+ fix: `write sync: poll 5s for ${streamed.length === 1 ? "it" : "them"} in ${shown}, then mason start`, next: [["mason start", "the demo database and Mason, polled"]] };
83
+ }
84
+ if (cfg.changes !== "replication")
85
+ return null;
86
+ return { message: `the demo database is read by polling only: "changes": "replication" in ${STATE_DIR}/config.json does not apply to it`, code: "refused",
87
+ why: "the demo database has no change log for logical replication to read; mason start polls it with SELECTs, every few seconds",
88
+ fix: `remove "changes": "replication" from ${STATE_DIR}/config.json, then mason start`,
89
+ next: [["mason start", "the demo database and Mason, polled"]] };
90
+ }
91
+ /** The step's done line: where the demo database listens, and whether it was seeded now or kept from the last run. */
92
+ export function demoDoneLine(port, seeded) {
93
+ return `Demo database on 127.0.0.1:${port} (Postgres, hosted by this process in ${STATE_DIR}/${DEMO_DIR}; ${seeded ? "seeded with the shop schema" : "kept from the last run"})`;
94
+ }
95
+ /**
96
+ * THE DEMO DATABASE OPENED for the run in `home`/demo (`home` the project's `.mason/`), its events to `log`: the handle and
97
+ * the step's done line, or why it did not open — its own message (what failed), and when something holds the port, the
98
+ * way out ONCE, with its choices: mason stop for a run of this project's that may still hold it, stop what does, or move
99
+ * the demo database by changing the port in its URL, where `from` says the URL is (`DATABASE_URL in .env`: the port
100
+ * lives there alone). The port is free for every other reason, so the probe tells a held port apart without reading
101
+ * the message.
102
+ */
103
+ export async function openDemo(o) {
104
+ const open = o.open ?? (async (a) => (await demoModule()).DemoSource.open(a));
105
+ try {
106
+ const demo = await open({ dir: path.join(o.home, DEMO_DIR), port: o.port, log: o.log });
107
+ return { demo, done: demoDoneLine(demo.port, demo.seeded) };
108
+ }
109
+ catch (e) {
110
+ const problem = `the demo database did not start: ${e.message}`;
111
+ const held = await portHolder(o.port);
112
+ if (held === null)
113
+ return { problem };
114
+ const move = `move the demo database: change the port in ${o.from} (postgres://${DEMO_USER}@127.0.0.1:<another>/${DEMO_DATABASE}) and mason start again`;
115
+ return { problem: held.code === "EACCES"
116
+ ? `${problem} (${heldText(o.port, held)}): ${move}`
117
+ : `${problem}: mason stop ends a run that may still hold it, else stop what does (lsof -nP -iTCP:${o.port} -sTCP:LISTEN names it), or ${move}` };
118
+ }
119
+ }
120
+ /**
121
+ * THE WRITER: one round of changes every `ms`, each said to `log` as the demo says it; a round that fails is said and
122
+ * the next one tried, and a round is never begun while the last one runs. Returns what stops it (idempotent).
123
+ */
124
+ export function demoChanges(demo, ms, log) {
125
+ let busy = false;
126
+ const timer = setInterval(() => {
127
+ if (busy)
128
+ return;
129
+ busy = true;
130
+ demo.change().then((line) => log(line), (e) => log(`a change failed (the next one is tried in ${ms / 1000} s): ${e.message}`))
131
+ .finally(() => { busy = false; });
132
+ }, ms);
133
+ return () => clearInterval(timer);
134
+ }
135
+ /** The demo database closed once, however often it is asked: null when it closed, else what went wrong (said by the
136
+ * caller, never swallowed). */
137
+ export function closingOnce(demo) {
138
+ let closing = null;
139
+ return () => closing ??= demo.close().then(() => null, (e) => e.message);
140
+ }
141
+ /** What the crash path says of a demo database still closing when it stops waiting (`closedWithin`). */
142
+ export const DEMO_STILL_CLOSING = (ms) => `closing the demo database: still closing after ${ms / 1000} s; its data may need seeding again at the next start`;
143
+ /**
144
+ * A close waited for `ms` at most, and SAID when it is not done by then (`late`), never left silent: the crash path
145
+ * stops waiting so the supervisor can start the run again. The timer is cleared when the close wins, and is not
146
+ * unref'd, so a close that hangs is said before the process ends. A close that rejects rejects this.
147
+ */
148
+ export async function closedWithin(closing, ms, late) {
149
+ let timer;
150
+ const timedOut = new Promise((resolve) => { timer = setTimeout(() => resolve(true), ms); });
151
+ try {
152
+ if (await Promise.race([closing.then(() => false), timedOut]))
153
+ late();
154
+ }
155
+ finally {
156
+ clearTimeout(timer);
157
+ }
158
+ }