@mason-data/cli 0.8.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 (109) hide show
  1. package/README.md +13 -4
  2. package/dist/src/api/postgres/catalog-worker.js +49 -2
  3. package/dist/src/api/postgres/catalog.js +166 -32
  4. package/dist/src/api/postgres/listener.js +11 -3
  5. package/dist/src/api/postgres/proxy.js +283 -0
  6. package/dist/src/api/postgres/schema.js +6 -0
  7. package/dist/src/api/postgres/server.js +505 -99
  8. package/dist/src/api/postgres/translate.js +47 -2
  9. package/dist/src/cli/background.js +18 -19
  10. package/dist/src/cli/bridge-stdin.js +101 -0
  11. package/dist/src/cli/command.js +169 -11
  12. package/dist/src/cli/commands.js +207 -51
  13. package/dist/src/cli/credentials.js +182 -0
  14. package/dist/src/cli/demo.js +2 -2
  15. package/dist/src/cli/deploy.js +574 -0
  16. package/dist/src/cli/dev.js +148 -27
  17. package/dist/src/cli/doctor.js +116 -33
  18. package/dist/src/cli/generated-files.js +143 -0
  19. package/dist/src/cli/guides.js +97 -6
  20. package/dist/src/cli/hosted.js +850 -0
  21. package/dist/src/cli/init.js +47 -11
  22. package/dist/src/cli/login.js +289 -0
  23. package/dist/src/cli/machine.js +1 -1
  24. package/dist/src/cli/main.js +8 -2
  25. package/dist/src/cli/mason.cjs +17 -0
  26. package/dist/src/cli/mcp.js +2 -2
  27. package/dist/src/cli/output-gate.js +176 -0
  28. package/dist/src/cli/page-share.js +96 -0
  29. package/dist/src/cli/pg-tls.js +1 -1
  30. package/dist/src/cli/pin.js +6 -2
  31. package/dist/src/cli/pool-size.cjs +85 -0
  32. package/dist/src/cli/ports.js +2 -0
  33. package/dist/src/cli/preflight.js +130 -39
  34. package/dist/src/cli/project.js +99 -0
  35. package/dist/src/cli/readiness.js +42 -6
  36. package/dist/src/cli/registry.js +12 -4
  37. package/dist/src/cli/replica-hosted.js +665 -0
  38. package/dist/src/cli/replica.js +76 -0
  39. package/dist/src/cli/roles.js +2 -1
  40. package/dist/src/cli/run-server.js +154 -7
  41. package/dist/src/cli/security.js +57 -10
  42. package/dist/src/cli/serve.js +63 -7
  43. package/dist/src/cli/share-run.js +51 -8
  44. package/dist/src/cli/share.js +3 -3
  45. package/dist/src/cli/source-edit.js +10 -4
  46. package/dist/src/cli/source-role.js +344 -0
  47. package/dist/src/cli/sources.js +30 -20
  48. package/dist/src/cli/style.js +2 -1
  49. package/dist/src/cli/supervise.js +15 -23
  50. package/dist/src/cli/token-hosted.js +337 -0
  51. package/dist/src/cli/ui-ssh.js +460 -0
  52. package/dist/src/cli/ui.js +115 -11
  53. package/dist/src/cli/user-hosted.js +299 -0
  54. package/dist/src/cli/user.js +126 -74
  55. package/dist/src/demo/seed.js +26 -0
  56. package/dist/src/duck/analyze.js +36 -20
  57. package/dist/src/duck/collation.js +266 -0
  58. package/dist/src/duck/constant-key.js +94 -0
  59. package/dist/src/duck/distinct-key.js +283 -26
  60. package/dist/src/duck/events.js +39 -8
  61. package/dist/src/duck/executor-worker.js +2 -2
  62. package/dist/src/duck/executor.js +6 -5
  63. package/dist/src/duck/explain.js +4 -0
  64. package/dist/src/duck/functions.js +448 -49
  65. package/dist/src/duck/gaps.js +34 -16
  66. package/dist/src/duck/generated-plan.js +1070 -0
  67. package/dist/src/duck/generated.js +906 -0
  68. package/dist/src/duck/grants.js +1 -1
  69. package/dist/src/duck/held-types.js +64 -0
  70. package/dist/src/duck/history.js +4 -0
  71. package/dist/src/duck/jsonb-order.js +9 -12
  72. package/dist/src/duck/kept-route.js +6 -5
  73. package/dist/src/duck/kept.js +9 -7
  74. package/dist/src/duck/late.js +1469 -40
  75. package/dist/src/duck/libc-bound.js +341 -0
  76. package/dist/src/duck/libc-order.js +892 -126
  77. package/dist/src/duck/pg-eval-worker.js +7 -1
  78. package/dist/src/duck/pg-eval.js +6 -4
  79. package/dist/src/duck/pgtree.js +774 -23
  80. package/dist/src/duck/plan.js +275 -30
  81. package/dist/src/duck/publication.js +14 -0
  82. package/dist/src/duck/requests.js +9 -5
  83. package/dist/src/duck/resolved.js +117 -2
  84. package/dist/src/duck/rewrite.js +4567 -583
  85. package/dist/src/duck/row-filter.js +12 -6
  86. package/dist/src/duck/statement.js +19 -0
  87. package/dist/src/duck/store.js +294 -68
  88. package/dist/src/duck/stream.js +9 -5
  89. package/dist/src/duck/sync.js +602 -126
  90. package/dist/src/duck/token.js +4 -1
  91. package/dist/src/duck/types.js +63 -7
  92. package/dist/src/duck/usage.js +6 -4
  93. package/dist/src/duck/users.js +5 -0
  94. package/dist/src/duck/verified.js +159 -2
  95. package/dist/src/duck/views.js +4 -3
  96. package/dist/src/lib/database-url.js +55 -10
  97. package/dist/src/lib/instant.js +50 -0
  98. package/dist/src/lib/log-buffer.js +13 -4
  99. package/dist/src/lib/log-read.js +196 -0
  100. package/dist/src/lib/pg-numeric.js +387 -49
  101. package/dist/src/lib/secret-mask.js +142 -0
  102. package/dist/src/lib/table-sync.js +24 -9
  103. package/dist/src/web/app.js +9 -4
  104. package/dist/src/web/pages/status.js +178 -13
  105. package/dist/src/web/pages/syncs.js +1 -1
  106. package/dist/src/web/pages/tables.js +8 -3
  107. package/dist/src/web/pages/users.js +4 -3
  108. package/dist/src/web/shared.js +19 -3
  109. package/package.json +8 -7
@@ -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
+ }
@@ -52,33 +52,48 @@ export function intervalOf(seconds) {
52
52
  const s = Math.max(MIN_POLL_SECONDS, Math.round(seconds));
53
53
  return s % 3600 === 0 ? `${s / 3600}h` : s % 60 === 0 ? `${s / 60}m` : `${s}s`;
54
54
  }
55
- /** an interval (`<n>s|m|h`) read, or why it is not one, with the interval to write when there is one */
56
- function parseInterval(v) {
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 ") {
57
59
  const m = /^(\d+(?:\.\d+)?)\s*([a-z]+)$/i.exec(v);
58
- const how = `an interval is <n>s, <n>m or <n>h, a whole number, at least ${MIN_POLL_SECONDS}s (${EXAMPLES})`;
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})`;
59
62
  if (m === null)
60
63
  return { problem: `${JSON.stringify(v)} is not an interval: ${how}` };
61
64
  const n = Number(m[1]), word = m[2].toLowerCase();
62
65
  if (word === "ms")
63
- return { problem: `${v} is under the shortest interval, ${MIN_POLL_SECONDS}s (a poll scans the table whole on your database): write poll ${MIN_POLL_SECONDS}s or longer` };
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` };
64
67
  const unit = UNIT_WORDS[word];
65
68
  if (unit === undefined) {
66
- const days = /^(d|day|days)$/.test(word) ? `: write poll ${intervalOf(n * 86_400)}` : "";
69
+ const days = /^(d|day|days)$/.test(word) ? `: write ${write}${intervalOf(n * 86_400)}` : "";
67
70
  return { problem: `${v}: ${word} is not a unit of an interval — ${days ? `days are hours${days}` : how}` };
68
71
  }
69
72
  const seconds = n * UNIT_SECONDS[unit];
70
73
  if (!Number.isInteger(n))
71
- return { problem: `${v}: an interval is a whole number — write poll ${intervalOf(seconds)}` };
74
+ return { problem: `${v}: an interval is a whole number — write ${write}${intervalOf(seconds)}` };
72
75
  if (seconds < MIN_POLL_SECONDS)
73
- return { problem: `${v} is under the shortest interval, ${MIN_POLL_SECONDS}s: write poll ${MIN_POLL_SECONDS}s or longer` };
76
+ return { problem: `${v} is under the shortest interval, ${MIN_POLL_SECONDS}s: write ${write}${MIN_POLL_SECONDS}s or longer` };
74
77
  if (seconds > MAX_POLL_SECONDS)
75
- return { problem: `${v} is over the longest interval, ${intervalOf(MAX_POLL_SECONDS)}: write poll ${intervalOf(MAX_POLL_SECONDS)}, or off with mason sync <table> run by a scheduler` };
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"}` };
76
79
  // the number as a number is written (`05s` is `5s`): two spellings of one interval would read as a change of sync
77
80
  const canonical = `${n}${unit}`;
78
81
  if (canonical !== v)
79
- return { problem: `${JSON.stringify(v)}: write poll ${canonical}` };
82
+ return { problem: `${JSON.stringify(v)}: write ${write}${canonical}` };
80
83
  return { every: canonical, seconds };
81
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
+ }
82
97
  /**
83
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):
84
99
  * the line to write, a `did you mean` for a misspelt mode, the floor for a short interval. Whether a table CAN be
@@ -1,5 +1,5 @@
1
1
  /**
2
- * THE WEB PAGE OF `mason start` (`mason ui`; docs/WEB-UI.md, and .claude/rules/web-ui.md for whoever builds it out):
2
+ * THE WEB PAGE OF A RUN (`mason ui`, and `mason ui --ssh` for a server's; docs/WEB-UI.md, and .claude/rules/web-ui.md for whoever builds it out):
3
3
  * the SHELL — a left sidebar (the project, then a nav of links with icons: Overview · Tables · Users, and the
4
4
  * Observability group: Queries · Usage · Logs; at its foot when it was updated and the version; an icon rail on a narrow
5
5
  * window, a menu on a phone) and one section per page, the one shown chosen by the URL's hash (`#overview`, the
@@ -52,7 +52,7 @@
52
52
  * A page is registered by its import and its place in PAGES below, its route in PAGE_ROUTES, and its section in
53
53
  * index.html.
54
54
  */
55
- import { esc, badge, icon, createAsk, renderFailure, updatedText, placePops, RANGES, DEFAULT_RANGE, rangeOf, rangeOffered, isLive, POLL_MS } from "./shared.js";
55
+ import { esc, badge, icon, createAsk, overTunnel, renderFailure, updatedText, placePops, RANGES, DEFAULT_RANGE, rangeOf, rangeOffered, isLive, POLL_MS } from "./shared.js";
56
56
  import { createCharts } from "./charts.js";
57
57
  import { page as statusPage } from "./pages/status.js";
58
58
  import { page as tablesPage } from "./pages/tables.js";
@@ -74,7 +74,7 @@ export const PRESS_HOLD_MS = 2000;
74
74
 
75
75
  // every renderer the tests import from here, wherever it lives now
76
76
  export * from "./shared.js";
77
- export { copyingText, tableLine, keptAge, sourceRows, renderStatus } from "./pages/status.js";
77
+ export { copyingText, tableLine, keptAge, keptWords, sourceRows, renderStatus, renderShare, createShare } from "./pages/status.js";
78
78
  export { tableFacts, addedLines, removedReport, pickBody, columnsPath, renderTableAdded, renderTablesRemoved, renderTables } from "./pages/tables.js";
79
79
  export { renderUsers, renderAdded, attributesText } from "./pages/users.js";
80
80
  export { renderRequests } from "./pages/queries.js";
@@ -176,9 +176,14 @@ function renderWhere(s) {
176
176
  * `view()` is the HTML of every part of the page, from what it knows now. Nothing here touches a document.
177
177
  */
178
178
  export function createApp(fetchFn, now = () => new Date(), shown = "status") {
179
- const ask = createAsk(fetchFn);
179
+ const asked = createAsk(fetchFn);
180
180
  // what was read, by path: the last body, this read's failure (null when it answered), when it was asked
181
181
  const store = new Map();
182
+ // every answer passes here: on a server's page a failure the page words itself is the tunnel's (`overTunnel`)
183
+ const ask = async (path, init) => {
184
+ const r = await asked(path, init);
185
+ return "failure" in r && store.get("/api/state")?.body?.served === true ? { failure: overTunnel(r.failure) } : r;
186
+ };
182
187
  const first = parseHash(shown);
183
188
  const m = { shown: first.id, range: rangeOf(first.params.range), pollFailure: null, updatedAt: null, rangeReadAt: null };
184
189
  const put = (path, at, r) => {
@@ -20,7 +20,8 @@
20
20
  const LINKS = { tables: "#tables", failedTables: "#tables?show=failed", usage: "#observability/usage?range=24h", queries: "#observability/queries?range=24h",
21
21
  slow: "#observability/queries?slow=1&range=24h", failedQueries: "#observability/queries?status=refused-or-failed&range=24h", failedEvents: "#observability/syncs?failures=1&range=7d" };
22
22
  import { msText, bytesText, code, esc, fileSize, firstLine, n, percent, leftText, spanText, duration, entryName, streamRows, slotWarns, masonRow, sessionsRows, diskRows, restartsRows, utcText,
23
- utcMinute, oneLine, clientText, eventCells, unsupportedEntry, usagePath, POLL_MS, card, cards, details, createOpenSet, skeleton, renderConnect, copyClick } from "../shared.js";
23
+ utcMinute, oneLine, clientText, eventCells, unsupportedEntry, usagePath, POLL_MS, card, cards, details, createOpenSet, skeleton, renderConnect, copyClick,
24
+ badge, button, copyable, pop, renderFailure, json } from "../shared.js";
24
25
  import { spark, seriesOf, drawable } from "../charts.js";
25
26
 
26
27
  /** How many recent errors of each kind are shown: mason status's (src/cli/commands.ts `RECENT_ERRORS`, held equal by the tests). */
@@ -62,18 +63,29 @@ export function copyingText(copied, rowsEstimate, p) {
62
63
  * current and how old its copy is (`keptAge`).
63
64
  */
64
65
  export function tableLine(t, now = Date.now()) {
65
- if (t.error) return { state: "failed", fraction: 1, what: `its first sync failed: ${firstLine(t.error)}` };
66
+ if (t.error) return { state: "failed", fraction: 1, what: t.copy?.phase === "done" ? copiedFailedText(t, now) : `its first sync failed: ${firstLine(t.error)}` };
66
67
  if (t.copy?.phase === "copying") {
67
68
  const c = copyingText(t.copy.rowsCopied, t.rows, t.copy.progress);
68
69
  return { state: "copying", fraction: c.fraction ?? 0, what: /^\d/.test(c.text) ? `syncing ${c.text}` : c.text };
69
70
  }
70
71
  if (!t.copy) return { state: "waiting", fraction: 0, what: "waiting for its first sync to start" };
71
- const age = keptAge(t, now);
72
+ const age = keptWords(t, now);
73
+ const what = `${n(t.copy.rowsCopied ?? 0)} rows · synced${t.copy.retired ? ` · ${t.copy.retired}` : ""}${age ? ` · ${age}` : ""}`;
72
74
  // a copy the poll does not keep current (it waits for its catch-up) is a warning, as mason status paints it (paintBar)
73
- if ((t.kept?.by === "poll" && t.kept.pending !== undefined) || (t.kept?.by === "replication" && (t.kept.state !== "streamed" || t.kept.notRunning !== null))) {
74
- return { state: "unsynced", fraction: 1, what: `${n(t.copy.rowsCopied ?? 0)} rows · synced${t.copy.retired ? ` · ${t.copy.retired}` : ""} · ${age}` };
75
+ // and so is one behind what its sync promises (`tables[].sync.state`, src/cli/readiness.ts behindOf), of any kind
76
+ if ((t.kept?.by === "poll" && t.kept.pending !== undefined) || (t.kept?.by === "replication" && (t.kept.state !== "streamed" || t.kept.notRunning !== null)) || t.sync?.state === "behind") {
77
+ return { state: "unsynced", fraction: 1, what };
75
78
  }
76
- return { state: "done", fraction: 1, what: `${n(t.copy.rowsCopied ?? 0)} rows · synced${t.copy.retired ? ` · ${t.copy.retired}` : ""}${age ? ` · ${age}` : ""}` };
79
+ return { state: "done", fraction: 1, what };
80
+ }
81
+
82
+ /** What a served table's line says after `synced`, for every kind: its keeping and age, then behind (src/cli/commands.ts `keptWords`). */
83
+ export function keptWords(t, now = Date.now()) {
84
+ const age = keptAge(t, now), s = t.sync;
85
+ if (s?.state !== "behind" || s.behindBySeconds == null) return age;
86
+ const why = s.behindWhy ?? "its next sync is late";
87
+ const by = `behind by ${duration(s.behindBySeconds)}${age !== null && age.includes(why) ? "" : `: ${why}`}`;
88
+ return age === null ? by : `${age} — ${by}`;
77
89
  }
78
90
 
79
91
  /** How long ago a table was synced, as `mason status` says it on its line (src/lib/table-sync.ts `agoText`, which
@@ -93,7 +105,7 @@ export function keptAge(t, now = Date.now()) {
93
105
  if (k?.by === "replication") {
94
106
  if (k.state !== "streamed") return `NOT kept current (${k.syncedAt === null ? "never synced" : `last synced ${syncedAgo(k.syncedAt, now)}`}): ${k.why ?? "it is not streamed yet"}`;
95
107
  const at = `replicated, at ${k.position}, ${k.syncedAt === null ? "not synced yet" : syncedAgo(k.syncedAt, now)}`;
96
- return k.notRunning === null ? at : `${at} — NOT kept current now, the stream is not running: ${k.notRunning}`;
108
+ return k.notRunning === null || t.sync?.state === "behind" ? at : `${at} — NOT kept current now, the stream is not running: ${k.notRunning}`;
97
109
  }
98
110
  if (k?.by !== "poll") return null;
99
111
  const synced = k.syncedAt === null ? "never synced" : `last synced ${syncedAgo(k.syncedAt, now)}`;
@@ -102,6 +114,15 @@ export function keptAge(t, now = Date.now()) {
102
114
  return `polled every ${k.sync.every}, ${k.syncedAt === null ? "not polled yet" : `last ${syncedAgo(k.syncedAt, now)}`}`;
103
115
  }
104
116
 
117
+ /** A served table whose sync failed, as mason status says it (src/cli/commands.ts `copiedFailedText`). */
118
+ function copiedFailedText(t, now) {
119
+ const k = t.kept;
120
+ const reason = k?.by === "replication" ? (k.state !== "streamed" ? k.why : k.notRunning === null ? null : `the stream is not running: ${k.notRunning}`)
121
+ : k?.by === "poll" || k?.by === "recopy" ? k.pending ?? null : null;
122
+ const asOf = t.sync?.asOf ?? (k && k.by !== "generated" && k.by !== "recopy" ? k.syncedAt : null) ?? t.sync?.syncedAt ?? null;
123
+ return `${n(t.copy?.rowsCopied ?? 0)} rows · synced · NOT kept current, its sync failed (${asOf === null ? "never synced" : `last synced ${syncedAgo(asOf, now)}`}): ${reason ?? firstLine(t.error)}`;
124
+ }
125
+
105
126
  /**
106
127
  * A COPY'S BAR, the one renderer of the Overview's "copies not finished" and the Tables page's rows: a thin rounded
107
128
  * track and a solid fill to the share drawn, its % as text at the bar's END (as `mason wait` prints it beside its bar),
@@ -162,6 +183,7 @@ export function sourceRows(s) {
162
183
  `${s.source.replication.owned ? "Mason's own, carrying them alone" : "named: read as it is, never altered"}); the others by a SELECT of their changed rows`
163
184
  : s.source.demo ? "the demo database is checked this way only"
164
185
  : "sync: replication on an entry streams that table as it happens instead")],
186
+ ...(s.source.textOrder ? [["Text order", s.source.textOrder]] : []),
165
187
  ];
166
188
  }
167
189
 
@@ -400,11 +422,58 @@ export function renderStatus(s, o = {}) {
400
422
  // how to connect, once a table is added: first under the cards while nothing has asked Mason anything (the last
401
423
  // day's and week's statements, read as none), else after the facts, for the next tool
402
424
  const first = isFirstConnect(o.queries);
403
- const connect = added ? renderConnect(s.postgres, { first, table: s.tables.find((t) => t.added && t.copy?.phase === "done")?.table ?? null }) : "";
425
+ const connect = added ? renderConnect(s.postgres, { first, served: s.served === true, table: s.tables.find((t) => t.added && t.copy?.phase === "done")?.table ?? null,
426
+ share: renderShare(s, o.share ?? NO_SHARE, now) }) : "";
404
427
  return renderHealth(s) + renderChecklist(s, o.queries, o.users) + renderKpis(s, { ...o, now }) + (first ? connect : "") + renderProgress(s, o.open, o.shares, o.before) + renderFacts(s, now) + (first ? "" : connect) + renderStream(s, now) +
405
428
  (added ? renderQueries(o.queries) : "") + (added ? renderRecentErrors(o.events, o.requests, o.unsupported) : "");
406
429
  }
407
430
 
431
+ // ------------------------------------------------------------------ Connect from anywhere (mason share, from the page)
432
+
433
+ /** The page's share before anything was asked: what `renderShare` and the page's model start from. */
434
+ export const NO_SHARE = Object.freeze({ phase: "none", choices: null, reads: null, role: null, opened: null, ended: null, failure: null });
435
+
436
+ /** The share the page holds, as `/api/state` `share` lists it (`from: "page"`), or null. */
437
+ export const pageShareOf = (s) => s?.share?.shares?.find((x) => x.from === "page") ?? null;
438
+
439
+ /** When a free share ends, as `mason share` says it: about an hour, and the time. */
440
+ const endsLine = (endsAt, now) => endsAt === null || endsAt === undefined ? "It ends when you Stop sharing, or when this page closes."
441
+ : `A free share ends within about an hour: at ${new Date(endsAt).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}, in about ${Math.max(0, Math.round((Date.parse(endsAt) - now) / 60_000))} minutes. Then Connect from anywhere again gives a new address and password.`;
442
+
443
+ /**
444
+ * CONNECT FROM ANYWHERE, the Connect card's part (src/cli/page-share.ts; `mason share`'s, from the page): a pure function
445
+ * of the state (`s`) and the page's own model (`m`: `phase` none · asking · opening · open · ended · refused, the roles
446
+ * offered, the answer that opened it — the password is in it alone — and the failure). Nothing under `mason serve`.
447
+ */
448
+ export function renderShare(s, m = NO_SHARE, now = Date.now()) {
449
+ if (s?.served === true) return "";
450
+ const open = pageShareOf(s);
451
+ const head = `<p class="connect-title share-title">Connect from anywhere</p>`;
452
+ const ask = button("Connect from anywhere", { shareAsk: "" }, "", { disabled: m.phase === "asking" || m.phase === "opening" });
453
+ const askLine = `<p class="tip" id="share-anchor">${ask} <span>For a tool in the cloud, which cannot reach this machine: a temporary public address and a password, as ${code("mason share")} opens. TLS is required of every connection.</span></p>`;
454
+ if (open !== null && m.phase !== "ended") {
455
+ const mine = m.opened !== null && m.opened.user === open.user ? m.opened : null;
456
+ const rows = [["Host", open.host ?? "opening…", "share-host"], ["Port", open.port === null ? "…" : String(open.port), "share-port"], ["Database", mine?.database ?? s.postgres?.database ?? "", null],
457
+ ["User", open.user, "share-user"], ...(mine !== null ? [["Password", mine.password, "share-password"]] : []), ["SSL mode", "require", null]];
458
+ return `<div class="share open" role="status">${head}<p class="headline">Shared ${badge("Public", "warn")} for <time data-tick datetime="${esc(open.openedAt)}">${esc(spanText((now - Date.parse(open.openedAt)) / 1000))}</time>` +
459
+ `${open.role !== null ? `, reading as the role ${esc(open.role)}` : ""}: ${esc(open.reads)}</p>` +
460
+ `<dl class="facts">${rows.map(([k, v, id]) => `<dt>${esc(k)}</dt><dd>${id ? copyable(id, v) : esc(v)}</dd>`).join("")}</dl>` +
461
+ (mine !== null ? `<p class="tip">Paste it into the tool now: the password is shown this once, is kept nowhere, and ends with the share.</p>`
462
+ : `<p class="tip">The password was shown once, when it opened, and is kept nowhere: Stop sharing and Connect from anywhere again for a new one.</p>`) +
463
+ `<p class="tip">${esc(endsLine(open.endsAt, now))} It also ends within two minutes of this page closing.</p>` +
464
+ `<p class="tip">${button("Stop sharing", { shareStop: open.user }, "danger")}</p></div>`;
465
+ }
466
+ if (m.phase === "opening") return `<div class="share">${head}<p class="dim" aria-busy="true">Opening a temporary public address… (up to 30 s)</p></div>`;
467
+ const before = m.phase === "ended" && m.ended !== null
468
+ ? `<p class="said">The share ended: ${esc(m.ended.why)}. Its address no longer answers, and its password no longer works.</p>`
469
+ : m.phase === "refused" && m.failure !== null ? renderFailure(m.failure) : "";
470
+ const question = m.phase !== "asking" ? "" : pop("share-anchor", m.choices === null ? `<p class="dim">Reading the roles…</p>` :
471
+ `<p>This opens a <strong>temporary public address</strong>: anyone with it and its password reads ${esc(m.reads ?? "what the role you choose reads")}. A free share ends within about an hour, and when this page closes.</p>` +
472
+ (m.choices.length > 0 ? `<label for="share-role">Read as the role</label> <select id="share-role">${m.choices.map((c) => `<option value="${esc(c.role)}"${c.role === m.role ? " selected" : ""}>${esc(`${c.role}: ${c.reads}${c.default ? " (the file's default)" : ""}`)}</option>`).join("")}</select>` : "") +
473
+ `<p>${button("Open the address", { shareOpen: "" }, "primary")} ${button("Cancel", { shareCancel: "", popClose: "" })}</p>`, { kind: "dialog", label: "Connect from anywhere" });
474
+ return `<div class="share">${head}${before}${askLine}${question}</div>`;
475
+ }
476
+
408
477
  /** Nothing has asked Mason anything yet: the last day's statements were read (`/api/queries`' body), and are none. */
409
478
  export const isFirstConnect = (q) => q !== undefined && !("failure" in q) && q.queries.day.statements === 0 && q.queries.week.statements === 0;
410
479
 
@@ -427,20 +496,116 @@ export const page = {
427
496
  // the details open (a copy's whole error), by key: a poll rewrites the section, and they stay open
428
497
  const opened = createOpenSet();
429
498
  const monotone = createMonotone();
499
+ const share = createShare(ctx);
430
500
  return {
431
- model: { open: opened.open },
501
+ model: { open: opened.open, share: share.m },
432
502
  reads: () => [{ path: "/api/state", everyMs: POLL_MS }, ...SLOWER.map((path) => ({ path, everyMs: 30_000 })), { path: HOURLY, everyMs: 60_000 }],
433
- actions: {},
503
+ actions: share.actions,
504
+ afterRead: () => share.afterRead(ctx.read("/api/state")?.body),
505
+ input: (target) => share.input(target),
434
506
  view() {
435
507
  const st = ctx.read("/api/state");
436
508
  if (!st?.body) return { status: st?.failure ? "" : skeleton(4) };
437
509
  const [queries, events, requests, unsupported, users] = SLOWER.map((p) => asRequest(ctx.read(p)));
438
- return { status: renderStatus(st.body, { now: ctx.now().getTime(), queries, events, requests, unsupported, users, usage: ctx.read(HOURLY)?.body, open: opened.has, shares: monotone.of(st.body), before: monotone.before }) };
510
+ return { status: renderStatus(st.body, { now: ctx.now().getTime(), queries, events, requests, unsupported, users, usage: ctx.read(HOURLY)?.body, open: opened.has, shares: monotone.of(st.body), before: monotone.before, share: share.m }) };
439
511
  },
440
512
  toggle: (target) => opened.toggle(target),
441
- // Connect's Copy buttons
442
- click: (d, button, env) => copyClick(d, button, env),
513
+ // Connect's Copy buttons, and Connect from anywhere's
514
+ click: (d, button, env) => share.click(d) ?? copyClick(d, button, env),
443
515
  charts: () => sparkSpecs(ctx.read(HOURLY)?.body),
444
516
  };
445
517
  },
446
518
  };
519
+
520
+ /** What a page beats with, when the answer that opened the share does not say (src/cli/page-share.ts BEAT_MS). */
521
+ const BEAT_MS = 15_000;
522
+
523
+ /**
524
+ * THE PAGE'S SIDE OF CONNECT FROM ANYWHERE (src/cli/page-share.ts has why each choice): ask once, open, BEAT while the
525
+ * share is open — on a timer of its own, which runs while the tab is hidden (the poll does not: the person is in their
526
+ * tool's tab, pasting) —, Stop sharing. The password is in `m.opened` alone, the page's memory, and is dropped the moment
527
+ * the share is seen ended. `ctx.timers` is a test's clock; a browser's own otherwise.
528
+ */
529
+ export function createShare(ctx) {
530
+ const m = { ...NO_SHARE };
531
+ const timers = ctx.timers ?? globalThis;
532
+ let beating = null;
533
+ const stopBeats = () => { if (beating !== null) { timers.clearInterval(beating.id); beating = null; } };
534
+ const ended = (user, why) => { stopBeats(); m.opened = null; m.phase = "ended"; m.ended = { user, why }; };
535
+ const beat = async () => {
536
+ if (beating === null) return;
537
+ const user = beating.user;
538
+ const r = await ctx.ask(`/api/share/${encodeURIComponent(user)}/beat`, json("POST"));
539
+ if ("failure" in r && r.failure.code === "not_found" && beating?.user === user) { ended(user, r.failure.message.replace(/^.*?; it ended: /, "")); await ctx.refresh(["/api/state"]); }
540
+ };
541
+ const startBeats = (user, everyMs = BEAT_MS) => {
542
+ if (beating?.user === user) return;
543
+ stopBeats();
544
+ beating = { user, id: timers.setInterval(() => { void beat(); }, everyMs) };
545
+ };
546
+ const actions = {
547
+ /** Connect from anywhere: what a share would read, then the question */
548
+ async shareAsk() {
549
+ Object.assign(m, NO_SHARE, { phase: "asking" });
550
+ const r = await ctx.ask("/api/share/choices");
551
+ if (m.phase !== "asking") return;
552
+ if ("failure" in r) { m.phase = "refused"; m.failure = r.failure; return; }
553
+ m.choices = r.body.choices;
554
+ m.reads = r.body.reads ?? null;
555
+ m.role = r.body.choices.find((c) => c.default)?.role ?? r.body.choices[0]?.role ?? null;
556
+ },
557
+ shareCancel() { Object.assign(m, NO_SHARE); },
558
+ shareRole(role) { if (m.choices?.some((c) => c.role === role)) m.role = role; },
559
+ /** the person said yes: the run opens the share (confirmed), and its one answer carries the password */
560
+ async shareOpen() {
561
+ if (m.phase !== "asking" || m.choices === null) return;
562
+ const role = m.choices.length > 0 ? m.role : null;
563
+ Object.assign(m, { phase: "opening", failure: null });
564
+ const r = await ctx.ask("/api/share", json("POST", { from: "page", confirmed: true, ...(role !== null ? { role } : {}) }));
565
+ if ("failure" in r) { m.phase = "refused"; m.failure = r.failure; return; }
566
+ Object.assign(m, { phase: "open", opened: { ...r.body, seen: false } });
567
+ startBeats(r.body.user, r.body.beatMs);
568
+ await ctx.refresh(["/api/state"]);
569
+ },
570
+ async shareStop(user) {
571
+ const r = await ctx.ask(`/api/share/${encodeURIComponent(user)}`, json("DELETE"));
572
+ if ("failure" in r && r.failure.code !== "not_found") { m.phase = "refused"; m.failure = r.failure; return; }
573
+ ended(user, "Stop sharing, on this page");
574
+ await ctx.refresh(["/api/state"]);
575
+ },
576
+ shareBeat: beat,
577
+ };
578
+ return {
579
+ m,
580
+ actions,
581
+ /** after each read of the state: a share of the page's open is beaten (a reload's too); the one this page opened,
582
+ * once listed and now gone, ended — said with the run's why, and its password dropped */
583
+ afterRead(s) {
584
+ if (s === undefined || s === null) return;
585
+ const open = pageShareOf(s);
586
+ if (open !== null) {
587
+ if (m.opened?.user === open.user) m.opened.seen = true;
588
+ if (m.phase === "ended" && m.ended?.user === open.user) return;
589
+ if (m.phase === "none" || m.phase === "ended") m.phase = "open";
590
+ startBeats(open.user);
591
+ return;
592
+ }
593
+ if (m.phase === "open" && (m.opened === null || m.opened.seen)) {
594
+ const user = m.opened?.user ?? beating?.user ?? null;
595
+ ended(user, s.shareEnded !== null && s.shareEnded !== undefined && s.shareEnded.user === user ? s.shareEnded.why : "it is no longer open");
596
+ }
597
+ },
598
+ input(target) {
599
+ if (target?.id !== "share-role") return null;
600
+ actions.shareRole(target.value);
601
+ return true;
602
+ },
603
+ click(d) {
604
+ if (d.shareAsk !== undefined) return actions.shareAsk() ?? true;
605
+ if (d.shareOpen !== undefined) return actions.shareOpen();
606
+ if (d.shareCancel !== undefined) return actions.shareCancel() ?? true;
607
+ if (d.shareStop !== undefined) return actions.shareStop(d.shareStop);
608
+ return null;
609
+ },
610
+ };
611
+ }
@@ -59,7 +59,7 @@ export function renderEvents(b, { failuresOnly = false, kind = "", table = "", n
59
59
 
60
60
  /** EVERY KIND OF SYNC EVENT, in its order (src/duck/events.ts `EVENT_KINDS`, held equal by the tests): the Kind filter
61
61
  * offers each, whether the range has one or not. */
62
- export const EVENT_KINDS = ["copy_failed", "poll_failed", "catch_up_failed", "stream_failed", "source_unreachable", "restart", "copied", "recovered", "synced", "sync_changed"];
62
+ export const EVENT_KINDS = ["copy_failed", "poll_failed", "catch_up_failed", "stream_failed", "source_unreachable", "generated_failed", "restart", "copied", "recovered", "synced", "sync_changed"];
63
63
 
64
64
  /** The kind and table filters' controls: EVERY kind (EVENT_KINDS), and EVERY added table (`/api/state` `tables`, `s`) —
65
65
  * the sync's own "(the sync)" first — whether the range has an event of it or not, so the choices never depend on what
@@ -24,7 +24,7 @@ export const syncsHref = (table) => `#observability/syncs?table=${encodeURICompo
24
24
  export const columnsPath = (table) => `/api/tables/${encodeURIComponent(table)}/columns`;
25
25
 
26
26
  /** A table's sync in one word (`/api/state` `tables[].sync.state`, src/cli/readiness.ts `syncStateOf`). */
27
- const SYNC_WORDS = { synced: "Synced", syncing: "Syncing", failed: "Failed", waiting: "Waiting" };
27
+ const SYNC_WORDS = { synced: "Synced", syncing: "Syncing", behind: "Behind", failed: "Failed", waiting: "Waiting" };
28
28
 
29
29
  /** Whether a table's name has the search box's text (any case; nothing typed: every table). */
30
30
  const nameMatches = (table, filter) => !filter || table.toLowerCase().includes(filter.trim().toLowerCase());
@@ -199,6 +199,8 @@ export function renderTables(s, ui = {}) {
199
199
  const { removing = null, choosing = null, columns = null, outcome = null, ticked = null, filter = "", show = "all", sizes, syncing = [], adding = [], shares = new Map(), before = new Map(), synced = null, open = () => false, limit = TABLE_ROWS } = ui;
200
200
  const now = ui.now ?? Date.now();
201
201
  if (!s.tables.length) return empty(`${s.source.database} has no table this user can read.`);
202
+ // why no table can be added or removed from here (a server's sources file this run cannot write), or null
203
+ const readOnly = s.sourcesReadOnly ?? null;
202
204
  const stream = s.stream ? new Map(streamRows(s.stream, now).tables) : null;
203
205
  // SEVEN NARROW COLUMNS OF FIXED WIDTHS, NOTHING LONG IN A CELL, AND NOTHING A CLICK INSERTS: a table's state is its word
204
206
  // over its copy's bar (a line kept for it when it has none); its last sync an age over how long it took; whatever is
@@ -227,7 +229,9 @@ export function renderTables(s, ui = {}) {
227
229
  ? `<a class="btn small" href="${esc(syncsHref(t.table))}">View events</a> ` +
228
230
  (syncing.includes(t.table) ? button("Syncing…", {}, "small", { room: ROOMS.sync, disabled: true }) : button(syncedNow ? "Synced ✓" : "Sync now", { syncTable: t.table }, "small", { room: ROOMS.sync })) + " " +
229
231
  // while it asks, Remove stays where it was, held down: the question beside it answers it
230
- (asking ? button("Remove", {}, "danger small", { disabled: true, expanded: true }) : button("Remove", { removeTable: t.table }, "danger small"))
232
+ (readOnly ? "" : asking ? button("Remove", {}, "danger small", { disabled: true, expanded: true }) : button("Remove", { removeTable: t.table }, "danger small"))
233
+ // a sources file this run cannot write (`s.sourcesReadOnly`, a server's): no control that could only fail
234
+ : readOnly ? ""
231
235
  : t.reason === null
232
236
  ? `${button("Add", { addTable: t.table }, "primary small")} ${choosing === t.table ? button("Columns…", {}, "small", { disabled: true, expanded: true }) : button("Columns…", { choose: t.table }, "small")}`
233
237
  : t.key.length ? "" : choosing === t.table ? button("Name its key…", {}, "small", { disabled: true, expanded: true }) : button("Name its key…", { choose: t.table }, "small");
@@ -284,7 +288,8 @@ export function renderTables(s, ui = {}) {
284
288
  const matching = filter.trim() ? `<p class="count dim">${n(shown.length)} with “${esc(filter.trim())}” in the name</p>` : "";
285
289
  const sizesFailed = added && sizes && "failure" in sizes ? `<p class="count">The synced tables' own sizes: could not read them (${esc(firstLine(sizes.failure.message))}; ${code("mason usage")} has them)</p>` : "";
286
290
  const rowsHtml_ = written.map(line).join("");
287
- return stray + (added ? "" : empty("No table added yet", `Add one from the list, as ${code("mason add <table>")} does.`)) + matching +
291
+ return stray + (readOnly === null ? "" : `<p class="count">Tables are not added or removed from this page: ${esc(readOnly)}.</p>`) +
292
+ (added ? "" : empty("No table added yet", readOnly === null ? `Add one from the list, as ${code("mason add <table>")} does.` : `The sources file names the tables to sync.`)) + matching +
288
293
  panel(`<table class="catalog"><thead><tr>${head.map(([h, c]) => `<th scope="col"${c ? ` class="${c}"` : ""}>${h}</th>`).join("")}</tr></thead>` +
289
294
  `<tbody>${rowsHtml_}</tbody></table>`) + pops.join("") + moreRows("tables", Math.min(limit, shown.length), shown.length, TABLE_ROWS) +
290
295
  (!shown.length ? `<p class="dim">No ${show === "added" ? "added " : show === "failed" ? "failed " : show === "not-added" ? "table that is not added, " : ""}table${filter.trim() ? `'s name has “${esc(filter.trim())}”` : ""}.</p>` : "") + sizesFailed +
@@ -3,7 +3,7 @@
3
3
  * POST /api/users/<name>/disable and /enable, DELETE /api/users/<name>. An added user's password lives in this page's memory until the person dismisses it, never in
4
4
  * storage, a URL or a log line.
5
5
  */
6
- import { actId, badge, button, code, copyable, copyClick, duration, empty, esc, json, panel, pop, renderFailure, ROOMS, status, utcMinute } from "../shared.js";
6
+ import { actId, badge, button, code, copyable, copyClick, duration, empty, esc, json, panel, pop, renderFailure, ROOMS, serverHostNote, status, utcMinute } from "../shared.js";
7
7
 
8
8
  /** The users as the page reads them: with each one's last use (`mason user list` asks the same), then every 30 s. */
9
9
  const USERS = "/api/users?lastUsed=1";
@@ -97,12 +97,13 @@ export function renderUsers(list, confirming = null, now = Date.now(), refused =
97
97
  * state), and a user's password is copied only from the card shown once when it is added (`renderAdded`). "" when the
98
98
  * run did not say.
99
99
  */
100
- export function renderUsersConnect(pg) {
100
+ export function renderUsersConnect(pg, served = false) {
101
101
  if (!pg || typeof pg.shown !== "string") return "";
102
102
  const rows = [["Host", pg.host, "users-connect-host"], ["Port", String(pg.port), "users-connect-port"], ["Database", pg.database, "users-connect-database"],
103
103
  ["SSL mode", pg.sslmode ?? "disable", "users-connect-sslmode"], ["URL", pg.shown, "users-connect-url"], ["psql", pg.psql, "users-connect-psql"]];
104
104
  return `<div class="connect"><p class="connect-title">Connect</p>` +
105
105
  `<dl class="facts">${rows.map(([k, v, id]) => `<dt>${esc(k)}</dt><dd>${copyable(id, v)}</dd>`).join("")}</dl>` +
106
+ (served ? `<p class="tip">${serverHostNote(pg)}</p>` : "") +
106
107
  `<p class="tip">A user's name and password go in place of ${code("<user>")} and ${code("<password>")}. The password is shown once, when the user is added: lost, remove the user and add it again.</p></div>`;
107
108
  }
108
109
 
@@ -192,7 +193,7 @@ export const page = {
192
193
  return {
193
194
  // the roles file's state is the run's (/api/state roles): a file that cannot be read refuses every user held to it
194
195
  users: u ? renderUsers(u, m.confirming, ctx.now().getTime(), m.refused, ctx.read(STATE)?.body?.roles ?? null) : "",
195
- "users-connect": renderUsersConnect(ctx.read(STATE)?.body?.postgres),
196
+ "users-connect": renderUsersConnect(ctx.read(STATE)?.body?.postgres, ctx.read(STATE)?.body?.served === true),
196
197
  added: m.added ? renderAdded(m.added) : "",
197
198
  action: m.actionFailure ? renderFailure(m.actionFailure) : "",
198
199
  };