@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
@@ -0,0 +1,168 @@
1
+ /**
2
+ * `mason add` AND `mason remove`, CHECKED AND WRITTEN — ONE GRAMMAR AT EVERY ENTRANCE. The run's command server
3
+ * (`POST /api/pick`, `/api/remove`, src/cli/run-server.ts: the commands with Mason up, and the web page) and the commands
4
+ * themselves with Mason down (src/cli/commands.ts: the file is the list, so `mason init && mason add orders && mason
5
+ * start` is a natural order) call these functions, so a refusal is worded ONCE and both entrances say it alike
6
+ * (tests/cli-commands.test.ts holds the two equal). What differs is only what each does after: the run starts or drops the
7
+ * copy and reads the file again; the command says the copy begins at `mason start`.
8
+ *
9
+ * Each takes the sources file as it is on disk now (`sourceToEdit`), the database's catalog as `readSourceFacts` reads it
10
+ * (`EditCatalog`), and the request; it checks the entry and the file's other entries against the catalog (an edit is never
11
+ * written over a file the next read would refuse), splices the one entry in or out (`withTable` / `withoutTable`: every
12
+ * other byte as it was), writes the file, and answers what it did — or throws a `Refusal`, the sentence a command shows.
13
+ */
14
+ import * as fs from "node:fs";
15
+ import * as path from "node:path";
16
+ import { addedTables, chooseColumns, effectiveSync, entryName, entryProblems, findEntry, parseSourceText, sourceFailure, sourceFileNames, sourcesDir, unaddableReason, withEntrySync, withoutTable, withTable } from "./sources.js";
17
+ import { BIG_TABLE_ROWS, COSTLY_POLL_ROWS, COSTLY_POLL_SECONDS, syncText } from "../lib/table-sync.js";
18
+ /** A refusal the command shows as it is; `failure` is how the web page says it (the CLI's Failure: why, fix, next), by
19
+ * default the message alone under the status's code. */
20
+ export class Refusal extends Error {
21
+ status;
22
+ failure;
23
+ constructor(status, message, failure) { super(message); this.status = status; this.failure = failure ?? { message, code: codeOf(status) }; }
24
+ }
25
+ /** The FAILURE_CODES code (src/cli/command.ts) of a refusal by its HTTP status. */
26
+ const codeOf = (status) => status === 400 ? "usage" : status === 404 ? "not_found" : status >= 500 ? "failed" : "refused";
27
+ /**
28
+ * A SCAN'S RATE WHEN NOTHING OF THE TABLE IS MEASURED, rows a second on one backend: the test server's production
29
+ * replica scanned a 22 M-row table in 12 s at every poll (docs/DESIGN.md "The poll") — an assumption, said as one.
30
+ */
31
+ const ASSUMED_SCAN_ROWS_PER_SECOND = 1_800_000;
32
+ /**
33
+ * WHAT A POLL OF A BIG TABLE WOULD COST THE DATABASE, estimated before it runs (issue 33: "a poll never takes more than
34
+ * a fifth of the source's time" bounds the share, not the backend's seconds — a 200 M-row table set to poll 5m is
35
+ * still a standing load): seconds a poll, from the best basis there is — its last poll, measured; else its copy's read
36
+ * rate, a reader's (an upper bound: a poll sends only the changed rows); else the catalog's row estimate at an assumed
37
+ * scan rate — and the basis said. Null for a table under `COSTLY_POLL_ROWS`, or set to poll every minute or slower.
38
+ */
39
+ export function pollCost(t, sync, m) {
40
+ if (sync.mode !== "poll" || sync.seconds >= COSTLY_POLL_SECONDS || t.rows === null || t.rows < COSTLY_POLL_ROWS)
41
+ return null;
42
+ const rows = t.rows.toLocaleString("en-US");
43
+ const [seconds, basis] = m?.lastPollMs != null ? [m.lastPollMs / 1000, "its last poll, measured"]
44
+ : m?.copyRowsPerSecond ? [t.rows / m.copyRowsPerSecond, `its first sync's read rate, ${Math.round(m.copyRowsPerSecond).toLocaleString("en-US")} rows a second a reader (a poll sends only the rows that changed, so it is at most that)`]
45
+ : [t.rows / ASSUMED_SCAN_ROWS_PER_SECOND, `the catalog's ${rows} rows at an assumed ${ASSUMED_SCAN_ROWS_PER_SECOND.toLocaleString("en-US")} rows a second of scan (nothing of it measured yet)`];
46
+ const every = Math.max(sync.seconds, 4 * seconds);
47
+ const s = (x) => x < 90 ? `${Math.round(x)} s` : x < 5400 ? `${Math.round(x / 60)} min` : `${(x / 3600).toFixed(1)} h`;
48
+ return `${entryName(t.source)} has about ${rows} rows, and each poll scans it whole on your database: about ${s(seconds)} a poll, from ${basis}. ` +
49
+ `sync: ${syncText(sync)} is stretched to a poll every ${s(every)} at most (a poll waits four times its own time), one backend of your database ` +
50
+ `busy ${Math.round((seconds / every) * 100)} % of the time — a longer interval (--sync "poll 1h"), or --sync replication (the change log), costs it less`;
51
+ }
52
+ /**
53
+ * The project's sources file as it is on disk NOW, its text and what it says — or why it cannot be edited: no folder, not
54
+ * exactly one file, a file with mistakes of its own, a URL that cannot be resolved (`read`, the file read as the run reads
55
+ * it: src/cli/sources.ts readSource).
56
+ */
57
+ export function sourceToEdit(folder, root, read) {
58
+ if (!folder)
59
+ throw new Refusal(409, "choose the project's folder first");
60
+ const names = sourceFileNames(folder);
61
+ if (names.length !== 1)
62
+ throw new Refusal(409, `${path.relative(root, sourcesDir(folder)) || sourcesDir(folder)}/ has ${names.length ? `${names.length} source files` : "no source file"}: one source per project for now`);
63
+ const file = path.join(sourcesDir(folder), names[0]);
64
+ const text = fs.readFileSync(file, "utf8");
65
+ const parsed = parseSourceText(text, names[0].replace(/\.ya?ml$/, ""), file);
66
+ if ("problems" in parsed) {
67
+ const f = sourceFailure(file, root, parsed.problems);
68
+ throw new Refusal(409, `${f.message}${f.why ? `\n${f.why}` : ""} — fix it first`);
69
+ }
70
+ const r = read();
71
+ if ("message" in r)
72
+ throw new Refusal(409, `${r.message}${r.why ? `\n${r.why}` : ""} — fix it first`);
73
+ return { root, file, text, parsed, read: r };
74
+ }
75
+ /** The file's other entries against the database: an edit is never written over a file the run would not take. */
76
+ function othersHold(at, db, without, then) {
77
+ const others = { ...at.parsed, tables: at.parsed.tables.filter((e) => !without.includes(e.table)) };
78
+ const held = addedTables(others, db.tables, db.database, at.root);
79
+ if (!Array.isArray(held))
80
+ throw new Refusal(409, `${held.message}${held.why ? `\n${held.why}` : ""} — fix it first, then ${then}`);
81
+ }
82
+ /**
83
+ * ADD A TABLE (`table`: schema.table): its entry in the sources file, written — after the last one, or in place of the
84
+ * table's own (`withTable`): every other byte of the file as it was. `columns` are the ones asked (`["*"]`: every one);
85
+ * `key` the columns a table with no primary key is copied by. The entry and the file's other entries must hold against
86
+ * the database first: an edit is never written over a file the next read would refuse.
87
+ *
88
+ * ITS SYNC (`o.sync`, --sync): written on the entry as its own `sync:`; else an entry's own is kept; else a NEW entry
89
+ * follows the file's default — unless the table is over `BIG_TABLE_ROWS` and that default polls, when the entry is
90
+ * written `sync: off` and said (`bigOff`): a poll scans its table whole — or the default is `replication` and the table
91
+ * cannot be (a `--key` table, REPLICA IDENTITY NOTHING), when it is written `sync: poll 5m` and said why (`notReplicated`;
92
+ * an explicit `--sync replication` of such a table is refused, at its entry). `o.keep`: only the sync is asked of a table
93
+ * already added (`mason add orders --sync "poll 30s"`), so its columns and key stay as they are, and only its `sync:`
94
+ * line is written (`withEntrySync`: its comments too).
95
+ */
96
+ export function addEntry(at, db, table, columns = ["*"], key = null, o = {}) {
97
+ const t = db.tables.find((x) => x.source === table);
98
+ if (!t)
99
+ throw new Refusal(404, `${table} is not a table of ${db.database} that this user can read`);
100
+ if (key !== null && t.key.length)
101
+ throw new Refusal(400, `${table} has a primary key (${t.key.join(", ")}), which Mason keys it by: leave --key out`);
102
+ if (key === null && !t.key.length)
103
+ throw new Refusal(409, `${table} cannot be added: ${unaddableReason(t)}`);
104
+ const was = at.parsed.tables.find((e) => e.table === table);
105
+ const keep = o.keep === true && was !== undefined;
106
+ const chosen = keep ? { columns: was.columns, exclude: was.exclude, addedKey: [], skipped: [] } : chooseColumns(t, columns, key);
107
+ if ("problem" in chosen)
108
+ throw new Refusal(400, chosen.problem);
109
+ othersHold(at, db, [table], `add ${entryName(table)}`);
110
+ // its own sync: asked, else the one its entry has, else none (the file's) — but a new big table under a polling default
111
+ const fileSync = effectiveSync(at.parsed, undefined).sync;
112
+ const bigOff = o.sync === undefined && was === undefined && fileSync.mode === "poll" && t.rows !== null && t.rows > BIG_TABLE_ROWS;
113
+ // a table logical replication cannot keep, under a file whose default is replication: polled, said (never refused for
114
+ // the default it did not choose; its own --sync replication is refused at its entry, below)
115
+ const keyed = keep ? was.key !== null : key !== null;
116
+ const unreplicable = keyed ? "it has no primary key, so logical replication cannot apply its changes by its key:"
117
+ : t.replicaIdentity === "n" ? "it has REPLICA IDENTITY NOTHING, and a publication carrying it would make your app's own UPDATEs and DELETEs on it fail" : null;
118
+ const notReplicated = o.sync === undefined && (was?.sync ?? null) === null && fileSync.mode === "replication" ? unreplicable : null;
119
+ // --sync replication asked of one: refused with the COMMAND to run (the person is at it), its own flags kept — the
120
+ // file's words are for an entry written by hand (src/cli/sources.ts entryProblems)
121
+ if (o.sync?.mode === "replication" && unreplicable !== null) {
122
+ const others = columns.filter((c) => c !== "*");
123
+ const flags = keep ? "" : `${columns.includes("*") ? (others.length ? ` --include ${others.join(",")}` : "") : ` --columns ${others.join(",")}`}${key !== null ? ` --key ${key.join(",")}` : ""}`;
124
+ throw new Refusal(400, `${table} cannot be kept current by logical replication: ${unreplicable} — mason add ${entryName(table)}${flags} --sync "poll 5m" polls it instead`);
125
+ }
126
+ const sync = o.sync ?? was?.sync ?? (bigOff ? { mode: "off" } : notReplicated !== null ? { mode: "poll", every: "5m", seconds: 300 } : null);
127
+ const entry = { name: was?.name ?? entryName(table), table, columns: chosen.columns, exclude: chosen.exclude, key: keep ? was.key : key === null ? null : [...key], sync };
128
+ // the entry itself, as the run will read it: a --key that does not tell the rows apart is refused here, not at the copy
129
+ const own = entryProblems(t, entry, db.tables, db.database, effectiveSync(at.parsed, entry).sync);
130
+ if (own.length)
131
+ throw new Refusal(400, own.join("; "));
132
+ const same = was !== undefined && JSON.stringify([was.columns, was.exclude, was.key, was.sync]) === JSON.stringify([entry.columns, entry.exclude, entry.key, entry.sync]);
133
+ if (!same) {
134
+ const r = keep ? withEntrySync(at.text, was, sync) : withTable(at.text, entry);
135
+ if ("problem" in r)
136
+ throw new Refusal(409, `${path.relative(at.root, at.file)} cannot be edited: ${r.problem}`);
137
+ fs.writeFileSync(at.file, r.text);
138
+ }
139
+ const eff = effectiveSync(at.parsed, entry);
140
+ return { table, file: at.file, entry: entry.name, columns: entry.columns, exclude: entry.exclude, key: entry.key, addedKey: chosen.addedKey, skippedSecrets: chosen.skipped, already: same,
141
+ sync: { value: syncText(eff.sync), from: eff.from }, bigOff, notReplicated, pollCost: pollCost(t, eff.sync, db.measured?.(table) ?? null) };
142
+ }
143
+ /**
144
+ * REMOVE TABLES from the sources file: each one's entry cut (`withoutTable`), every other byte of the file as it was. A
145
+ * name the file does not have is refused with the closest one it has; the entries that stay must hold against the
146
+ * database — else the run's re-read refuses the file, keeps the removed table and goes on copying it while the command
147
+ * said "Removed".
148
+ */
149
+ export function removeEntries(at, db, names) {
150
+ const entries = names.map((n) => {
151
+ const e = findEntry(at.parsed, n);
152
+ if ("problem" in e)
153
+ throw new Refusal(e.usage ? 400 : 404, e.problem);
154
+ return e;
155
+ });
156
+ othersHold(at, db, entries.map((e) => e.table), `remove ${names.join(" ")}`);
157
+ let out = at.text;
158
+ for (const e of entries) {
159
+ const r = withoutTable(out, e.table);
160
+ if (r === null)
161
+ continue;
162
+ if ("problem" in r)
163
+ throw new Refusal(409, `${path.relative(at.root, at.file)} cannot be edited: ${r.problem}`);
164
+ out = r.text;
165
+ }
166
+ fs.writeFileSync(at.file, out);
167
+ return { removed: [...new Set(entries.map((e) => e.table))], file: at.file };
168
+ }