@mason-data/cli 0.7.0 → 0.9.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 (155) hide show
  1. package/README.md +101 -73
  2. package/dist/src/api/error.js +3 -0
  3. package/dist/src/api/postgres/catalog-worker.js +49 -2
  4. package/dist/src/api/postgres/catalog.js +368 -25
  5. package/dist/src/api/postgres/encode.js +3 -1
  6. package/dist/src/api/postgres/explain.js +3 -2
  7. package/dist/src/api/postgres/listener.js +11 -3
  8. package/dist/src/api/postgres/protocol.js +39 -23
  9. package/dist/src/api/postgres/proxy.js +283 -0
  10. package/dist/src/api/postgres/schema.js +6 -0
  11. package/dist/src/api/postgres/server.js +1617 -245
  12. package/dist/src/api/postgres/translate.js +127 -10
  13. package/dist/src/api/request-log.js +18 -5
  14. package/dist/src/cdc/poll.js +5 -5
  15. package/dist/src/cli/background.js +47 -25
  16. package/dist/src/cli/bridge-stdin.js +101 -0
  17. package/dist/src/cli/cancel.js +75 -0
  18. package/dist/src/cli/command.js +170 -11
  19. package/dist/src/cli/commands.js +804 -209
  20. package/dist/src/cli/control.js +59 -0
  21. package/dist/src/cli/credentials.js +182 -0
  22. package/dist/src/cli/demo.js +20 -11
  23. package/dist/src/cli/deploy.js +574 -0
  24. package/dist/src/cli/dev.js +491 -130
  25. package/dist/src/cli/doctor.js +131 -38
  26. package/dist/src/cli/generated-files.js +143 -0
  27. package/dist/src/cli/guides.js +175 -36
  28. package/dist/src/cli/hosted.js +850 -0
  29. package/dist/src/cli/init.js +162 -65
  30. package/dist/src/cli/login.js +289 -0
  31. package/dist/src/cli/machine.js +13 -12
  32. package/dist/src/cli/main.js +11 -5
  33. package/dist/src/cli/mason.cjs +17 -0
  34. package/dist/src/cli/mcp.js +31 -22
  35. package/dist/src/cli/metrics.js +79 -0
  36. package/dist/src/cli/output-gate.js +176 -0
  37. package/dist/src/cli/page-share.js +96 -0
  38. package/dist/src/cli/pg-tls.js +14 -4
  39. package/dist/src/cli/pg-url.js +4 -0
  40. package/dist/src/cli/pin.js +19 -14
  41. package/dist/src/cli/pool-size.cjs +85 -0
  42. package/dist/src/cli/ports.js +2 -0
  43. package/dist/src/cli/preflight.js +138 -46
  44. package/dist/src/cli/project.js +142 -4
  45. package/dist/src/cli/readiness.js +76 -6
  46. package/dist/src/cli/registry.js +65 -38
  47. package/dist/src/cli/replica-hosted.js +665 -0
  48. package/dist/src/cli/replica.js +76 -0
  49. package/dist/src/cli/replication-checks.js +7 -5
  50. package/dist/src/cli/replication-mode.js +105 -26
  51. package/dist/src/cli/request-filter.js +134 -0
  52. package/dist/src/cli/roles.js +585 -0
  53. package/dist/src/cli/run-server.js +637 -48
  54. package/dist/src/cli/runs.js +1 -1
  55. package/dist/src/cli/security.js +67 -18
  56. package/dist/src/cli/serve.js +204 -46
  57. package/dist/src/cli/share-run.js +329 -0
  58. package/dist/src/cli/share.js +286 -0
  59. package/dist/src/cli/source-edit.js +75 -17
  60. package/dist/src/cli/source-role.js +344 -0
  61. package/dist/src/cli/sources.js +306 -47
  62. package/dist/src/cli/style.js +2 -1
  63. package/dist/src/cli/supervise.js +27 -24
  64. package/dist/src/cli/sync.js +55 -25
  65. package/dist/src/cli/token-hosted.js +337 -0
  66. package/dist/src/cli/tunnel.js +200 -0
  67. package/dist/src/cli/ui-ssh.js +460 -0
  68. package/dist/src/cli/ui.js +124 -17
  69. package/dist/src/cli/usage.js +128 -0
  70. package/dist/src/cli/user-hosted.js +299 -0
  71. package/dist/src/cli/user.js +573 -65
  72. package/dist/src/cli/version.js +9 -7
  73. package/dist/src/demo/seed.js +29 -0
  74. package/dist/src/duck/analyze.js +36 -20
  75. package/dist/src/duck/cancel.js +10 -1
  76. package/dist/src/duck/collation.js +266 -0
  77. package/dist/src/duck/constant-key.js +94 -0
  78. package/dist/src/duck/distinct-key.js +283 -26
  79. package/dist/src/duck/events.js +165 -0
  80. package/dist/src/duck/executor-worker.js +20 -11
  81. package/dist/src/duck/executor.js +47 -33
  82. package/dist/src/duck/explain.js +60 -46
  83. package/dist/src/duck/extensions.js +160 -0
  84. package/dist/src/duck/functions.js +461 -63
  85. package/dist/src/duck/gaps.js +48 -21
  86. package/dist/src/duck/generated-plan.js +1070 -0
  87. package/dist/src/duck/generated.js +906 -0
  88. package/dist/src/duck/grants.js +176 -0
  89. package/dist/src/duck/held-types.js +64 -0
  90. package/dist/src/duck/history.js +127 -0
  91. package/dist/src/duck/jsonb-order.js +9 -12
  92. package/dist/src/duck/kept-route.js +6 -5
  93. package/dist/src/duck/kept.js +15 -13
  94. package/dist/src/duck/late.js +1469 -40
  95. package/dist/src/duck/libc-bound.js +341 -0
  96. package/dist/src/duck/libc-order.js +892 -126
  97. package/dist/src/duck/pg-eval-worker.js +7 -1
  98. package/dist/src/duck/pg-eval.js +6 -4
  99. package/dist/src/duck/pgtree.js +774 -23
  100. package/dist/src/duck/plan.js +369 -47
  101. package/dist/src/duck/publication.js +20 -6
  102. package/dist/src/duck/requests.js +269 -93
  103. package/dist/src/duck/resolved.js +117 -2
  104. package/dist/src/duck/rewrite.js +4584 -580
  105. package/dist/src/duck/row-filter.js +655 -0
  106. package/dist/src/duck/statement.js +19 -0
  107. package/dist/src/duck/store.js +439 -177
  108. package/dist/src/duck/stream.js +19 -10
  109. package/dist/src/duck/sync.js +1411 -368
  110. package/dist/src/duck/token.js +4 -1
  111. package/dist/src/duck/types.js +69 -12
  112. package/dist/src/duck/usage.js +326 -0
  113. package/dist/src/duck/users.js +69 -15
  114. package/dist/src/duck/verified.js +161 -1
  115. package/dist/src/duck/views.js +5 -5
  116. package/dist/src/lib/database-name.js +62 -0
  117. package/dist/src/lib/database-url.js +55 -10
  118. package/dist/src/lib/dotenv.js +17 -4
  119. package/dist/src/lib/instant.js +50 -0
  120. package/dist/src/lib/log-buffer.js +77 -0
  121. package/dist/src/lib/log-read.js +196 -0
  122. package/dist/src/lib/pg-numeric.js +390 -49
  123. package/dist/src/lib/project-version.js +23 -23
  124. package/dist/src/lib/secret-mask.js +142 -0
  125. package/dist/src/lib/table-sync.js +282 -0
  126. package/dist/src/web/app.css +706 -66
  127. package/dist/src/web/app.js +331 -409
  128. package/dist/src/web/charts.js +229 -0
  129. package/dist/src/web/index.html +82 -18
  130. package/dist/src/web/pages/queries.js +192 -0
  131. package/dist/src/web/pages/status.js +611 -0
  132. package/dist/src/web/pages/syncs.js +155 -0
  133. package/dist/src/web/pages/tables.js +439 -0
  134. package/dist/src/web/pages/usage.js +397 -0
  135. package/dist/src/web/pages/users.js +219 -0
  136. package/dist/src/web/shared.js +584 -0
  137. package/dist/src/web/vendor/README.md +25 -0
  138. package/dist/src/web/vendor/geist-1.7.2/Geist-Variable.woff2 +0 -0
  139. package/dist/src/web/vendor/geist-1.7.2/GeistMono-Variable.woff2 +0 -0
  140. package/dist/src/web/vendor/geist-1.7.2/LICENSE.txt +92 -0
  141. package/dist/src/web/vendor/lucide-1.56.0/LICENSE.txt +43 -0
  142. package/dist/src/web/vendor/lucide-1.56.0/chart-column.mjs +29 -0
  143. package/dist/src/web/vendor/lucide-1.56.0/layout-dashboard.mjs +29 -0
  144. package/dist/src/web/vendor/lucide-1.56.0/logs.mjs +34 -0
  145. package/dist/src/web/vendor/lucide-1.56.0/menu.mjs +28 -0
  146. package/dist/src/web/vendor/lucide-1.56.0/square-terminal.mjs +28 -0
  147. package/dist/src/web/vendor/lucide-1.56.0/table.mjs +29 -0
  148. package/dist/src/web/vendor/lucide-1.56.0/users.mjs +29 -0
  149. package/dist/src/web/vendor/lucide-1.56.0/x.mjs +27 -0
  150. package/dist/src/web/vendor/uplot-1.6.32/LICENSE.txt +21 -0
  151. package/dist/src/web/vendor/uplot-1.6.32/uPlot.esm.js +6140 -0
  152. package/dist/src/web/vendor/uplot-1.6.32/uPlot.min.css +1 -0
  153. package/dist/src/web/vendor/zilla-slab-5.3.0/LICENSE.txt +93 -0
  154. package/dist/src/web/vendor/zilla-slab-5.3.0/zilla-slab-latin-700-normal.woff2 +0 -0
  155. package/package.json +12 -4
@@ -7,10 +7,11 @@
7
7
  *
8
8
  * Before 1.0 a minor version may change the project's format with no compatibility (CLAUDE.md "No backwards
9
9
  * compatibility before v1.0"), so a project written for 0.5 and run by 0.6 would fail on whatever key moved, in words
10
- * that never say the versions differ. The pin turns that into one refusal that does: the image of the ClickHouse stack
11
- * this was cut from refused to start on a project of another minor version, or of a newer patch than itself
12
- * (`imagePinProblem` below), and `mason init` / `mason start` refuse a CLI that is (src/cli/pin.ts). The way out is
13
- * always the same pair: run the version the project names, or read the release notes and move the pin.
10
+ * that never say the versions differ. The pin turns that into one refusal that does: the image refuses to start on a
11
+ * project of another release line, or of a newer patch than itself (`imagePinProblem` below), and the mason command
12
+ * refuses one too (src/cli/pin.ts: `mason init`, `mason start`, and `mason serve` run from the package). ONE rule (`pinProblem`), in
13
+ * each reader's words. The way out is always the same pair: run the version the project names, or read the release notes
14
+ * and move the pin.
14
15
  *
15
16
  * A build that is no release (`dev` from a checkout, `main-<sha>` from main) checks nothing: it has no version to hold
16
17
  * a project to. A project with no `mason.yml` (made by an older `mason init`) is unpinned and runs; `mason start` writes one.
@@ -40,37 +41,36 @@ export function sameLine(a, b) {
40
41
  const older = (a, b) => a.major !== b.major ? a.major < b.major : a.minor !== b.minor ? a.minor < b.minor : a.patch < b.patch;
41
42
  /**
42
43
  * Why `running` (what reads the project: the image, or the CLI) may not run a project pinned to `pin`, or null.
43
- * Allowed: the same release line, at the pin's patch or later. `what` names the reader in the message ("the mason
44
- * image", "this mason command"); `run` says how to run the pinned version instead.
44
+ * Allowed: the same release line, at the pin's patch or later.
45
45
  */
46
- export function pinProblem(pin, running, file, what, run) {
46
+ function pinProblem(pin, running, file, reader) {
47
47
  if (sameLine(pin, running) && !older(running, pin))
48
48
  return null;
49
- const p = versionText(pin), r = versionText(running);
49
+ const p = versionText(pin), r = versionText(running), { what } = reader, run = reader.run(p);
50
50
  if (sameLine(pin, running)) {
51
- return `${file} pins Mason ${p}, and ${what} is ${r}, older than it: the project may use a fix or a key ${r} does not have. ${run}`;
51
+ return { problem: `${file} pins Mason ${p}, and ${what} is ${r}, older than it: the project may use a fix or a key ${r} does not have`, ways: [run] };
52
52
  }
53
53
  const line = pin.major > 0 ? `${pin.major}.x` : `${pin.major}.${pin.minor}.x`;
54
54
  return older(running, pin)
55
- ? `${file} pins Mason ${p}, and ${what} is ${r}: the project is written for a newer Mason. ${run}`
56
- : `${file} pins Mason ${p}, and ${what} is ${r}, a newer release line than ${line}, which may read the project differently. ` +
57
- `${run} Or upgrade the project: read the release notes of every version after ${p} up to ${r} (their "Before you upgrade"), ` +
58
- `change what they say, then set version: ${r} in ${path.basename(file)}.`;
55
+ ? { problem: `${file} pins Mason ${p}, and ${what} is ${r}: the project is written for a newer Mason`, ways: [run] }
56
+ : { problem: `${file} pins Mason ${p}, and ${what} is ${r}, a newer release line than ${line}, which may read the project differently`,
57
+ ways: [run, `upgrade the project: read the release notes of every version after ${p} up to ${r} (their "Before you upgrade"), ` +
58
+ `change what they say, then set version: ${r} in ${path.basename(file)}`] };
59
59
  }
60
60
  /**
61
- * The image's check, as the ClickHouse stack's service ran it at its start: the project's pin against the image's
62
- * MASON_VERSION, or null.
63
- * An image that is no release (`dev`, `main-<sha>`) holds no project to a version.
61
+ * THE ONE CHECK: the project's pin against the version of what reads it, or null. A build that is no release (`dev`,
62
+ * `main-<sha>`) holds no project to a version, and reads no mason.yml.
64
63
  */
65
- export function imagePinProblem(project, imageVersion) {
66
- const running = parseVersion(imageVersion ?? "");
64
+ export function projectPinProblem(project, version, reader) {
65
+ const running = parseVersion(version);
67
66
  if (running === null)
68
67
  return null;
69
68
  const read = readPin(project);
70
69
  if ("problem" in read)
71
- return read.problem;
72
- if (read.pin === null)
73
- return null;
74
- const p = versionText(read.pin);
75
- return pinProblem(read.pin, running, read.file, "this mason image", `Run the image the project names (ghcr.io/embeddable-hq/mason-data:${p}).`);
70
+ return { problem: read.problem, ways: [] };
71
+ return read.pin === null ? null : pinProblem(read.pin, running, read.file, reader);
72
+ }
73
+ /** The image's check: the words of someone running a container. */
74
+ export function imagePinProblem(project, imageVersion) {
75
+ return projectPinProblem(project, imageVersion, { what: "this mason image", run: (p) => `run the image the project names (ghcr.io/embeddable-hq/mason-data:${p})` });
76
76
  }
@@ -0,0 +1,142 @@
1
+ /**
2
+ * AN ERROR'S WORDS, AS MASON REPEATS THEM (issue 599: the engine's error repeated the connection string, `password='…'`
3
+ * included, into the log, the ledger, `mason status` and the web page).
4
+ *
5
+ * WHERE THE PASSWORD CAN STILL REACH AN ERROR decides how wide this is. The engine is given the password in ONE
6
+ * statement, the TEMPORARY SECRET (src/duck/sync.ts `sourceSecretSql`), whose own error is never repeated (its code
7
+ * alone); the ATTACH's path holds no credential (`attachPath`), so the extension's `Unable to connect to Postgres at
8
+ * "<path>"` cannot hold it; the sync's own Postgres client sends it only inside its auth messages (src/cdc/pg-wire.ts),
9
+ * never in a string. So no error the sync meets holds the password, and the one form a regression would bring back is
10
+ * the one the old string leaked: a libpq `password=` keyword's value. That alone is masked.
11
+ *
12
+ * NEVER A SUBSTRING OF THE PASSWORD (the reviews of PR 620): an error's words carry identifiers — a table's name, the
13
+ * user's, the database's, a slot's, a row's value — and a mask that cut the password out of them showed where it sat
14
+ * (`public.****_archive` under `orders2024`, `for user "****"` under a password equal to the user's name), while a
15
+ * branch matching the words flipped (a catch-up's wider numeric, a ROLLBACK's `no transaction is active`). A
16
+ * `password=` value is no identifier, so masking it shows nothing of the password and changes no word a branch reads;
17
+ * and no branch reads these words at all: a decision reads `messageOf`, the raw words, which are never shown.
18
+ * tests/secret-mask.test.ts holds both rules over src/duck/sync.ts and src/duck/stream.ts.
19
+ */
20
+ /** What a masked value reads as. */
21
+ export const MASKED = "****";
22
+ /** libpq's `password=` keyword and its value: quoted (`'…'`, a `\` escaping the next character, to the closing quote
23
+ * or the text's end, a `\` that ends the text included: issue 621, a text cut there was returned whole) or bare (to the
24
+ * next space or quote); any case, spaces allowed around the `=` as libpq allows */
25
+ const PASSWORD_KEYWORD = /\b(password\s*=\s*)(?:'(?:[^'\\]|\\[\s\S]?)*(?:'|$)|[^\s'"]+)/gi;
26
+ /** `text` with every `password=` keyword's value masked, and nothing else changed: the leaked form alone, safe for any
27
+ * stored free text (an error's words, the ledger read back, the scrub at open). */
28
+ export function maskPasswordKeyword(text) {
29
+ return text.replace(PASSWORD_KEYWORD, (_m, key) => `${key}${MASKED}`);
30
+ }
31
+ /** The keyword with its value not begun at the text's end (`password`, `password =`): what follows on the next line may
32
+ * be its value, as libpq allows a newline among the spaces around the `=` */
33
+ const KEYWORD_OPEN = /\bpassword\s*(?:=\s*)?$/i;
34
+ /** Whether a quoted value (its opening `'` first) has its closing quote, by the keyword's own escapes */
35
+ function closed(value) {
36
+ let escaped = false;
37
+ for (let i = 1; i < value.length; i++) {
38
+ const c = value[i];
39
+ if (escaped)
40
+ escaped = false;
41
+ else if (c === "\\")
42
+ escaped = true;
43
+ else if (c === "'")
44
+ return true;
45
+ }
46
+ return false;
47
+ }
48
+ /**
49
+ * A TEXT READ A LINE AT A TIME, MASKED AS `maskPasswordKeyword` MASKS IT WHOLE (issue 621: a log file an older Mason
50
+ * wrote, read by `mason logs`, holds the leaked form in its lines): the same keyword, the same value, masked the same way,
51
+ * and nothing else changed — never a substring of the password (the reviews of PR 620: an identifier holding it is
52
+ * structure, and a mask cut into it showed where the password sat).
53
+ *
54
+ * A LINE IS ITS UNIT, AND AN ENTRY ITS BOUND. A log entry is a line at the margin and the indented lines under it
55
+ * (src/lib/log.ts `textEntry`), so a value can run on past its line only inside its entry: a quote open at a line's end
56
+ * masks the next indented lines to its closing quote (a password holding a newline), and a keyword at a line's end
57
+ * (`password`, `password =`) takes its value from the next indented line. A line at the margin, or a blank one, starts
58
+ * afresh: a quote an older Mason's line left open (a message cut off) masks its own entry, never the rest of the file.
59
+ * Each line's answer is decided by it and the lines before it, so a reader releases a line as soon as it has it whole,
60
+ * whatever its own chunks (src/lib/log-read.ts), and the answer is the same for every way the file is cut.
61
+ */
62
+ export class PasswordLineMask {
63
+ /** what the previous line left open: a quoted value (to its closing quote), or the keyword with its value not begun */
64
+ open = null;
65
+ /** A line not ended yet, masked as it stands, the state left as it was: what a follow shows of a line still being
66
+ * written (src/lib/log-read.ts `follow`), the whole line read by `line` once its newline comes. */
67
+ peek(raw) {
68
+ const open = this.open;
69
+ try {
70
+ return this.line(raw);
71
+ }
72
+ finally {
73
+ this.open = open;
74
+ }
75
+ }
76
+ /** One line (no newline), masked. */
77
+ line(raw) {
78
+ if (raw.trim() === "" || /^\S/.test(raw))
79
+ this.open = null; // a new entry: nothing carries into it
80
+ const open = this.open;
81
+ this.open = null;
82
+ if (open !== null && "quote" in open) {
83
+ // the value runs on from the line before: masked to its closing quote (the newline ended any escape), the rest read as any line
84
+ const indent = /^\s*/.exec(raw)[0];
85
+ let escaped = false;
86
+ for (let i = indent.length; i < raw.length; i++) {
87
+ const c = raw[i];
88
+ if (escaped)
89
+ escaped = false;
90
+ else if (c === "\\")
91
+ escaped = true;
92
+ else if (c === "'")
93
+ return `${indent}${MASKED}${this.alone(raw.slice(i + 1))}`;
94
+ }
95
+ this.open = { quote: true };
96
+ return `${indent}${MASKED}`;
97
+ }
98
+ if (open !== null) {
99
+ // the keyword ended the line before: read with it, and its own text taken off again (the key is kept as written)
100
+ return this.alone(`${open.keyword}\n${raw}`).slice(open.keyword.length + 1);
101
+ }
102
+ return this.alone(raw);
103
+ }
104
+ /** `text` masked whole, and what it leaves open at its end noted for the next line */
105
+ alone(text) {
106
+ let atEnd = null; // the value of a match that runs to the text's end
107
+ const masked = text.replace(PASSWORD_KEYWORD, (match, key, index) => {
108
+ if (index + match.length === text.length)
109
+ atEnd = match.slice(key.length);
110
+ return `${key}${MASKED}`;
111
+ });
112
+ const value = atEnd;
113
+ if (value !== null) {
114
+ if (value.startsWith("'") && !closed(value))
115
+ this.open = { quote: true };
116
+ }
117
+ else {
118
+ const k = KEYWORD_OPEN.exec(text);
119
+ if (k !== null)
120
+ this.open = { keyword: text.slice(k.index) };
121
+ }
122
+ return masked;
123
+ }
124
+ }
125
+ /** An error's code: a Postgres SQLSTATE or a system error's (`ECONNREFUSED`), the kind the engine names its error by
126
+ * (`IO Error`), else none — what is said of an error whose words are never repeated (the secret's statement). */
127
+ export function errorCode(e) {
128
+ if (e !== null && typeof e === "object" && typeof e.code === "string" && e.code !== "")
129
+ return e.code;
130
+ const m = e instanceof Error ? /^([A-Z][A-Za-z]*(?: [A-Za-z]+)* Error):/.exec(e.message) : null;
131
+ return m?.[1] ?? null;
132
+ }
133
+ /** A CAUGHT ERROR'S WORDS, AS MASON REPEATS THEM (a log line, an event, a table's `error`, an answer): its message (or,
134
+ * `stack`, its stack), every `password=` value masked. Never matched by a branch: that reads `messageOf`. */
135
+ export function errorText(e, o = {}) {
136
+ return maskPasswordKeyword(e instanceof Error ? (o.stack === true ? e.stack ?? e.message : e.message) : String(e));
137
+ }
138
+ /** A CAUGHT ERROR'S RAW WORDS, FOR A DECISION ALONE (which failure it is: a wider numeric, a transaction already ended):
139
+ * never shown — said, logged or stored, an error's words are `errorText`'s. */
140
+ export function messageOf(e) {
141
+ return e instanceof Error ? e.message : String(e);
142
+ }
@@ -0,0 +1,282 @@
1
+ /**
2
+ * HOW A TABLE IS KEPT CURRENT AFTER ITS FIRST COPY: the sources file's `sync:` (GitHub issue 33), at the top of the
3
+ * file as every table's default and on an entry for that table alone. ONE grammar, read by the one reader of the file
4
+ * (src/cli/sources.ts `parseSourceText`) and by `mason add --sync` (src/cli/commands.ts), so a value is taken or refused
5
+ * in the same words at both entrances:
6
+ *
7
+ * off nothing after the first copy until `mason sync <table>`, which polls it once, now (the default)
8
+ * poll <interval> the xmin poll every <interval>: <n>s, <n>m or <n>h, a whole number, at least 1s, at most 24h
9
+ * replication the database's change log, through the project's ONE slot and publication (src/duck/stream.ts):
10
+ * the publication carries the replicated tables only, and the polled ones are polled beside it
11
+ *
12
+ * The engine never decides how a table is kept current by its size: the row count matters only to `mason add`, which
13
+ * writes `sync: off` for a table over `BIG_TABLE_ROWS` when the file's default polls (src/cli/source-edit.ts).
14
+ */
15
+ import { closest } from "./did-you-mean.js";
16
+ /** the sync a table has when neither its entry nor the file says one: `off` (issue 33: Mason reads each table once,
17
+ * then only what you tell it to) */
18
+ export const DEFAULT_SYNC = { mode: "off" };
19
+ /** the shortest interval a poll takes: below a second a full scan of the table on the source is the source's whole time */
20
+ export const MIN_POLL_SECONDS = 1;
21
+ /** the longest: a day. Longer is `off` with `mason sync <table>` from a scheduler (and an instant past a Date's range,
22
+ * a review finding: `poll 9999999999999s` broke every status line) */
23
+ export const MAX_POLL_SECONDS = 86_400;
24
+ /**
25
+ * A TABLE THIS BIG IS NOT POLLED UNLESS ITS ENTRY SAYS SO (it was the run's POLL_ROW_LIMIT, which left such a table
26
+ * copied and never polled, a rule found only by its effect): a poll scans its table whole on the source (no index covers
27
+ * xmin) and counts it, and on a test server a 22 M-row table cost the production replica 12 s of scan and 2 s of count
28
+ * at every poll. So `mason add` of a table the catalog estimates over this many rows, under a file whose default polls,
29
+ * writes `sync: off` on its entry and says so with the alternatives. The stream applies a batch that changes a table
30
+ * this big by time as well (src/duck/stream.ts `applySize`): each write by key is a scan of its key column.
31
+ */
32
+ export const BIG_TABLE_ROWS = 1_000_000;
33
+ /** above this many rows a poll under `COSTLY_POLL_SECONDS` is warned of at `mason add`, with an estimate of its cost */
34
+ export const COSTLY_POLL_ROWS = 10_000_000;
35
+ export const COSTLY_POLL_SECONDS = 60;
36
+ const MODES = ["off", "poll", "replication"];
37
+ const UNIT_SECONDS = { s: 1, m: 60, h: 3600 };
38
+ /** the units a person may write for one of ours, said back as the one to write */
39
+ const UNIT_WORDS = {
40
+ s: "s", sec: "s", secs: "s", second: "s", seconds: "s", m: "m", min: "m", mins: "m", minute: "m", minutes: "m",
41
+ h: "h", hr: "h", hrs: "h", hour: "h", hours: "h",
42
+ };
43
+ const EXAMPLES = "poll 5s, poll 5m or poll 1h";
44
+ /** what a sync is, said where one is wrong */
45
+ export const SYNC_FORMS = `off, poll <interval> (${EXAMPLES}) or replication`;
46
+ /** the value as the file writes it: `off`, `poll 30s` */
47
+ export function syncText(s) {
48
+ return s.mode === "poll" ? `poll ${s.every}` : s.mode;
49
+ }
50
+ /** a span of seconds as an interval is written, in the largest unit it is a whole number of: 90 is `90s`, 120 `2m` */
51
+ export function intervalOf(seconds) {
52
+ const s = Math.max(MIN_POLL_SECONDS, Math.round(seconds));
53
+ return s % 3600 === 0 ? `${s / 3600}h` : s % 60 === 0 ? `${s / 60}m` : `${s}s`;
54
+ }
55
+ /** an interval (`<n>s|m|h`) read, or why it is not one, with the interval to write when there is one. ONE grammar with three
56
+ * entrances, each worded as it is written: a `sync:`'s `poll <interval>`, a generated table's `target_lag:`, and a
57
+ * command's wait (`parseDuration`) */
58
+ function parseInterval(v, write = "poll ") {
59
+ const m = /^(\d+(?:\.\d+)?)\s*([a-z]+)$/i.exec(v);
60
+ const examples = write === "" ? "5s, 5m or 1h" : EXAMPLES;
61
+ const how = `an interval is <n>s, <n>m or <n>h, a whole number, at least ${MIN_POLL_SECONDS}s (${examples})`;
62
+ if (m === null)
63
+ return { problem: `${JSON.stringify(v)} is not an interval: ${how}` };
64
+ const n = Number(m[1]), word = m[2].toLowerCase();
65
+ if (word === "ms")
66
+ return { problem: `${v} is under the shortest interval, ${MIN_POLL_SECONDS}s${write === "" ? "" : " (a poll scans the table whole on your database)"}: write ${write}${MIN_POLL_SECONDS}s or longer` };
67
+ const unit = UNIT_WORDS[word];
68
+ if (unit === undefined) {
69
+ const days = /^(d|day|days)$/.test(word) ? `: write ${write}${intervalOf(n * 86_400)}` : "";
70
+ return { problem: `${v}: ${word} is not a unit of an interval — ${days ? `days are hours${days}` : how}` };
71
+ }
72
+ const seconds = n * UNIT_SECONDS[unit];
73
+ if (!Number.isInteger(n))
74
+ return { problem: `${v}: an interval is a whole number — write ${write}${intervalOf(seconds)}` };
75
+ if (seconds < MIN_POLL_SECONDS)
76
+ return { problem: `${v} is under the shortest interval, ${MIN_POLL_SECONDS}s: write ${write}${MIN_POLL_SECONDS}s or longer` };
77
+ if (seconds > MAX_POLL_SECONDS)
78
+ return { problem: `${v} is over the longest interval, ${intervalOf(MAX_POLL_SECONDS)}: write ${write}${intervalOf(MAX_POLL_SECONDS)}${write === "" ? "" : ", or off with mason sync <table> run by a scheduler"}` };
79
+ // the number as a number is written (`05s` is `5s`): two spellings of one interval would read as a change of sync
80
+ const canonical = `${n}${unit}`;
81
+ if (canonical !== v)
82
+ return { problem: `${JSON.stringify(v)}: write ${write}${canonical}` };
83
+ return { every: canonical, seconds };
84
+ }
85
+ /** A generated table's `target_lag:` read (src/cli/generated-files.ts): an interval alone, in the grammar and the range a
86
+ * poll's takes, or what is wrong with it in the words a person fixes it from. */
87
+ export function parseLag(v) {
88
+ if (typeof v !== "string" || v.trim() === "")
89
+ return { problem: `is ${v === null || v === undefined ? "nothing" : typeof v === "number" ? `the number ${String(v)}` : "not an interval"}: write how far behind the table may be, like 30s, 5m or 1h` };
90
+ return parseInterval(v.trim(), "");
91
+ }
92
+ /** A command's own wait, as an interval (`mason deploy --timeout 10m`): the grammar and range a poll's and a `target_lag:`
93
+ * take, so a span of time is written one way at every entrance. */
94
+ export function parseDuration(v) {
95
+ return parseInterval(v.trim(), "");
96
+ }
97
+ /**
98
+ * A `sync:` VALUE READ, or what is wrong with it in the words a person fixes it from (the key is the caller's to say):
99
+ * the line to write, a `did you mean` for a misspelt mode, the floor for a short interval. Whether a table CAN be
100
+ * replicated (a primary key, a replica identity) is the catalog's, said at the entry (src/cli/sources.ts entryProblems).
101
+ */
102
+ export function parseSync(v) {
103
+ if (typeof v !== "string" || v.trim() === "") {
104
+ const read = v === null || v === undefined ? "nothing" : typeof v === "boolean" ? `the boolean ${String(v)}` : typeof v === "number" ? `the number ${String(v)}` : "a list or a map";
105
+ return { problem: `is ${read}: write ${SYNC_FORMS}` };
106
+ }
107
+ const words = v.trim().split(/\s+/);
108
+ const mode = words[0], rest = words.slice(1).join(" ");
109
+ if (mode === "off")
110
+ return rest === "" ? { mode: "off" } : { problem: `off takes no interval: write off, or poll ${rest}` };
111
+ if (mode === "replication")
112
+ return rest === "" ? { mode: "replication" } : { problem: `replication takes no interval: write replication, or poll ${rest}` };
113
+ if (mode === "poll") {
114
+ if (rest === "")
115
+ return { problem: `poll needs its interval: write ${EXAMPLES} (at least ${MIN_POLL_SECONDS}s)` };
116
+ const i = parseInterval(rest);
117
+ return "problem" in i ? i : { mode: "poll", ...i };
118
+ }
119
+ // a misspelt mode: the line it may have meant, its interval kept when it reads as one
120
+ const near = closest(mode, MODES);
121
+ const meant = near === "poll" && rest !== "" && !("problem" in parseInterval(rest)) ? `poll ${rest}` : near;
122
+ // a question ends its sentence: "did you mean `poll 5s`? Write off, …"
123
+ return { problem: meant !== null ? `${mode} is not a sync — did you mean \`${meant}\`? Write ${SYNC_FORMS}` : `${mode} is not a sync: write ${SYNC_FORMS}` };
124
+ }
125
+ /** a sync for a person: `off`, `polled every 30s`, `replicated` */
126
+ export function syncWords(s) {
127
+ return s.mode === "off" ? "off" : s.mode === "replication" ? "replicated" : `polled every ${s.every}`;
128
+ }
129
+ /** how long ago an instant was, as a status line reads: `4 s ago`, `12 min ago`, `5 h ago`, `3 days ago` */
130
+ export function agoText(iso, now = Date.now()) {
131
+ const s = Math.max(0, Math.round((now - Date.parse(iso)) / 1000));
132
+ if (s < 90)
133
+ return `${s} s ago`;
134
+ if (s < 90 * 60)
135
+ return `${Math.round(s / 60)} min ago`;
136
+ if (s < 36 * 3600)
137
+ return `${Math.round(s / 3600)} h ago`;
138
+ const d = Math.round(s / 86_400);
139
+ return `${d} day${d === 1 ? "" : "s"} ago`;
140
+ }
141
+ /**
142
+ * THE EDIT THAT MAKES THE TOP'S `key:` SAY ONE OF `values`, in words a person (or an agent) applies as written: change
143
+ * the line the file has, keep one that already says it, or write it when the file has none. `anyPoll`: any
144
+ * `poll <interval>` already written is kept (for "poll it", whatever the interval).
145
+ */
146
+ export function topEdit(top, key, values, anyPoll = false) {
147
+ const now = top[key] === null ? null : top[key].trim().replace(/\s+/g, " ");
148
+ const said = (v) => now !== null && (now === v || (anyPoll && v.startsWith("poll ") && now.startsWith("poll ")));
149
+ const kept = values.find(said);
150
+ if (kept !== undefined && now !== null)
151
+ return `keep ${key}: ${now} at the top of ${top.shown}`;
152
+ const to = values.map((v) => `${key}: ${v}`).join(" or ");
153
+ return now === null ? `write ${to} at the top of ${top.shown}` : `change ${key}: ${now} at the top of ${top.shown} to ${to}`;
154
+ }
155
+ /**
156
+ * `MASON_POLL_MS` SET IS REFUSED, never run on a default (CLAUDE.md: a renamed setting refuses the start, naming the
157
+ * line to write): how often a table is polled is the sources file's `sync:`, per table. Null when it is unset; else what
158
+ * is wrong, why and the way out — a Failure's fields for `mason start` (before it spawns, and the run itself), joined on
159
+ * one line of `mason serve`'s settings report (`pollMsRetiredText`). `top` is read only when it is set.
160
+ */
161
+ export function pollMsRetired(env, top) {
162
+ const v = env.MASON_POLL_MS;
163
+ if (v === undefined || v === "")
164
+ return null;
165
+ const ms = Number(v);
166
+ const every = `poll ${Number.isFinite(ms) && ms >= MIN_POLL_SECONDS * 1000 ? intervalOf(Math.min(ms / 1000, MAX_POLL_SECONDS)) : "5s"}`;
167
+ return { message: `MASON_POLL_MS=${v} is no longer read`, why: "how often each table is polled is its sync: in the sources file",
168
+ fix: `${topEdit(top(), "sync", [every])} (every table; a table's entry may say its own), then unset MASON_POLL_MS` };
169
+ }
170
+ /** the same, as one line */
171
+ export function pollMsRetiredText(env, top) {
172
+ const r = pollMsRetired(env, top);
173
+ return r === null ? null : `${r.message}: ${r.why} — ${r.fix}`;
174
+ }
175
+ /** what every retired way of choosing a whole project's changes is told: where it is said now, so what runs is what is
176
+ * committed — how each table is kept current (`sync`), the publication replication reads (`publication`), or both */
177
+ function saidInTheFile(what) {
178
+ const said = [...(what.sync ? ["how each table is kept current is its sync: in the sources file (at the top for every table, on an entry for that one)"] : []),
179
+ ...(what.publication ? [`the publication replication reads is ${what.sync ? "its" : "the sources file's"} publication:`] : [])];
180
+ return `${said.join(", and ")}, so what runs is what is committed`;
181
+ }
182
+ /**
183
+ * `mason start --replication`, `--poll` AND `--publication <name>` ARE GONE (issue 33, PR 3; no backwards compatibility
184
+ * before v1.0): each answers, without starting, with the edit to make at the top of the sources file (`top`, read only
185
+ * when a removed flag is given) — its `sync:` line to `replication` or `poll 5m`, its `publication:` to the name —
186
+ * every one given on ONE report, one coherent way out (a publication goes with replication). Null when none is given.
187
+ * Read from the raw command line, before the options are parsed, so a removed flag is answered as removed and never
188
+ * as an unknown option.
189
+ */
190
+ export function removedStartFlags(argv, top) {
191
+ let replication = false, poll = false, publication;
192
+ for (let i = 0; i < argv.length; i++) {
193
+ const a = argv[i];
194
+ if (a === "--")
195
+ break;
196
+ if (a === "--replication" || a.startsWith("--replication="))
197
+ replication = true;
198
+ else if (a === "--poll" || a.startsWith("--poll="))
199
+ poll = true;
200
+ else if (a.startsWith("--publication="))
201
+ publication = a.slice("--publication=".length) || null;
202
+ else if (a === "--publication") {
203
+ const v = argv[i + 1];
204
+ publication = v !== undefined && !v.startsWith("-") ? v : null;
205
+ }
206
+ }
207
+ const given = [...(replication ? ["--replication"] : []), ...(poll ? ["--poll"] : []), ...(publication !== undefined ? ["--publication"] : [])];
208
+ if (given.length === 0)
209
+ return null;
210
+ const t = top();
211
+ const named = given.length === 1 ? `${given[0]} is gone` : `${given.slice(0, -1).join(", ")} and ${given.at(-1)} are gone`;
212
+ const pub = publication === undefined ? null : topEdit(t, "publication", [publication ?? "<name>"]);
213
+ let fix;
214
+ if (replication) {
215
+ // every table from the change log (or polled, when --poll said that too), and the publication beside it
216
+ const sync = topEdit(t, "sync", poll ? ["replication", "poll 5m"] : ["replication"]);
217
+ // two lines to write at the same top are one instruction: "write sync: replication and publication: app_pub at the top of …"
218
+ const at = ` at the top of ${t.shown}`;
219
+ const both = pub !== null && !poll && sync.startsWith("write ") && pub.startsWith("write ") && sync.endsWith(at) && pub.endsWith(at);
220
+ fix = both ? `${sync.slice(0, -at.length)} and ${pub.slice("write ".length)}`
221
+ : `${sync}${poll ? " (or each table's own on its entry)" : ""}${pub ? `, and ${pub}` : ""}`;
222
+ }
223
+ else if (pub !== null) {
224
+ // a publication is read only by replication: it names the tables that are replicated, the others are polled or off
225
+ fix = `${poll ? "a publication goes with replication: " : ""}${pub}, with sync: replication on the entries of the tables it carries` +
226
+ `${poll ? " and sync: poll 5m (any interval) on the others" : ""}`;
227
+ }
228
+ else {
229
+ const edit = topEdit(t, "sync", ["poll 5m"], true);
230
+ fix = `${edit} (${edit.startsWith("keep ") ? "every table is polled already; a table's entry may say its own"
231
+ : "any interval: poll 5s, poll 1h; or sync: off, the default, and mason sync <table> when you need it"})`;
232
+ }
233
+ return { message: `${named}: the sources file says it now`, why: saidInTheFile({ sync: replication || poll, publication: publication !== undefined }),
234
+ fix: `${fix}, then mason start` };
235
+ }
236
+ /**
237
+ * `MASON_CHANGES` SET IS REFUSED, never run on a default (CLAUDE.md: a removed setting refuses the start, naming the
238
+ * edit to make): how each table is kept current is the sources file's `sync:` (`top`, read only when it is set). Null
239
+ * when it is unset; else a Failure's fields, joined on one line of `mason serve`'s settings report
240
+ * (`changesRetiredText`).
241
+ */
242
+ export function changesRetired(env, top) {
243
+ const v = env.MASON_CHANGES;
244
+ if (v === undefined || v === "")
245
+ return null;
246
+ const edit = v === "replication" ? topEdit(top(), "sync", ["replication"]) : v === "poll" ? topEdit(top(), "sync", ["poll 5m"], true)
247
+ : topEdit(top(), "sync", ["replication", "poll 5m"]);
248
+ return { message: `MASON_CHANGES=${v} is no longer read`, why: saidInTheFile({ sync: true, publication: false }),
249
+ fix: `${edit} (every table; a table's entry may say its own), then unset MASON_CHANGES` };
250
+ }
251
+ /** the same, as one line */
252
+ export function changesRetiredText(env, top) {
253
+ const r = changesRetired(env, top);
254
+ return r === null ? null : `${r.message}: ${r.why} — ${r.fix}`;
255
+ }
256
+ /**
257
+ * A DURATION IN MILLISECONDS as a person reads it, the one formatter of a statement's, a sync's or a percentile's time
258
+ * (the web page's `msText`, src/web/shared.js, held equal by the tests): `512 ms` under a second, then `13.7 s`, then
259
+ * `2 min 5 s`, then `1 h 4 min` (a 13,703 ms statement read as thirteen thousand of something).
260
+ */
261
+ export function msText(ms) {
262
+ const v = Math.max(0, ms);
263
+ if (Math.round(v) < 1000)
264
+ return `${Math.round(v)} ms`;
265
+ const tenths = Math.round(v / 100);
266
+ if (tenths < 600)
267
+ return `${(tenths / 10).toFixed(1)} s`;
268
+ const s = Math.round(v / 1000);
269
+ if (s < 3600)
270
+ return `${Math.floor(s / 60)} min${s % 60 ? ` ${s % 60} s` : ""}`;
271
+ const m = Math.round(s / 60);
272
+ return `${Math.floor(m / 60)} h${m % 60 ? ` ${m % 60} min` : ""}`;
273
+ }
274
+ /**
275
+ * WHAT A SYNC DID TO ONE TABLE, in the words `mason sync` prints on its line and the web page's Sync now says
276
+ * (src/cli/sync.ts `syncedReport`, src/web/pages/tables.js, held equal) — and a sync by hand records as its `synced`
277
+ * event (src/duck/sync.ts pollNow), so the event and the answer the person read are one sentence.
278
+ */
279
+ export function syncedText(t) {
280
+ const n = (x) => x.toLocaleString("en-US");
281
+ return `${n(t.changed)} changed, ${n(t.deleted)} deleted, ${n(t.rows)} rows now · ${msText(t.ms)}`;
282
+ }