@mason-data/cli 0.8.0 → 0.10.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 (118) hide show
  1. package/README.md +19 -8
  2. package/dist/src/api/error.js +4 -0
  3. package/dist/src/api/postgres/catalog-worker.js +49 -2
  4. package/dist/src/api/postgres/catalog.js +166 -32
  5. package/dist/src/api/postgres/listener.js +11 -3
  6. package/dist/src/api/postgres/proxy.js +129 -0
  7. package/dist/src/api/postgres/schema.js +6 -0
  8. package/dist/src/api/postgres/server.js +527 -103
  9. package/dist/src/api/postgres/translate.js +47 -2
  10. package/dist/src/cli/background.js +18 -19
  11. package/dist/src/cli/bridge-stdin.js +101 -0
  12. package/dist/src/cli/command.js +337 -15
  13. package/dist/src/cli/commands.js +458 -169
  14. package/dist/src/cli/credentials.js +182 -0
  15. package/dist/src/cli/demo.js +2 -2
  16. package/dist/src/cli/deploy.js +680 -0
  17. package/dist/src/cli/dev.js +148 -27
  18. package/dist/src/cli/doctor.js +122 -36
  19. package/dist/src/cli/generated-files.js +143 -0
  20. package/dist/src/cli/guides.js +116 -16
  21. package/dist/src/cli/hosted.js +850 -0
  22. package/dist/src/cli/init.js +154 -33
  23. package/dist/src/cli/login.js +292 -0
  24. package/dist/src/cli/machine.js +125 -40
  25. package/dist/src/cli/main.js +59 -10
  26. package/dist/src/cli/mason.cjs +17 -0
  27. package/dist/src/cli/mcp.js +17 -14
  28. package/dist/src/cli/output-gate.js +176 -0
  29. package/dist/src/cli/page-share.js +96 -0
  30. package/dist/src/cli/pg-tls.js +1 -1
  31. package/dist/src/cli/pin.js +6 -2
  32. package/dist/src/cli/pool-size.cjs +85 -0
  33. package/dist/src/cli/ports.js +2 -0
  34. package/dist/src/cli/preflight.js +130 -39
  35. package/dist/src/cli/project.js +100 -1
  36. package/dist/src/cli/readiness.js +42 -6
  37. package/dist/src/cli/registry.js +89 -36
  38. package/dist/src/cli/replica-hosted.js +1324 -0
  39. package/dist/src/cli/replica.js +114 -0
  40. package/dist/src/cli/replication-mode.js +5 -0
  41. package/dist/src/cli/request-filter.js +16 -6
  42. package/dist/src/cli/roles.js +2 -1
  43. package/dist/src/cli/run-server.js +179 -9
  44. package/dist/src/cli/secret-input.js +193 -0
  45. package/dist/src/cli/security.js +57 -10
  46. package/dist/src/cli/serve.js +65 -9
  47. package/dist/src/cli/share-run.js +51 -8
  48. package/dist/src/cli/share.js +23 -18
  49. package/dist/src/cli/source-edit.js +10 -4
  50. package/dist/src/cli/source-role.js +343 -0
  51. package/dist/src/cli/sources.js +50 -23
  52. package/dist/src/cli/style.js +16 -1
  53. package/dist/src/cli/supervise.js +15 -23
  54. package/dist/src/cli/sync.js +17 -8
  55. package/dist/src/cli/token-hosted.js +343 -0
  56. package/dist/src/cli/tunnel.js +1 -1
  57. package/dist/src/cli/ui-hosted.js +163 -0
  58. package/dist/src/cli/ui-ssh.js +460 -0
  59. package/dist/src/cli/ui.js +162 -17
  60. package/dist/src/cli/usage.js +19 -15
  61. package/dist/src/cli/user-hosted.js +364 -0
  62. package/dist/src/cli/user.js +275 -105
  63. package/dist/src/demo/seed.js +26 -0
  64. package/dist/src/duck/analyze.js +36 -20
  65. package/dist/src/duck/collation.js +266 -0
  66. package/dist/src/duck/constant-key.js +94 -0
  67. package/dist/src/duck/distinct-key.js +283 -26
  68. package/dist/src/duck/events.js +39 -8
  69. package/dist/src/duck/executor-worker.js +2 -2
  70. package/dist/src/duck/executor.js +6 -5
  71. package/dist/src/duck/explain.js +4 -0
  72. package/dist/src/duck/functions.js +448 -49
  73. package/dist/src/duck/gaps.js +34 -16
  74. package/dist/src/duck/generated-plan.js +1070 -0
  75. package/dist/src/duck/generated.js +906 -0
  76. package/dist/src/duck/grants.js +1 -1
  77. package/dist/src/duck/held-types.js +64 -0
  78. package/dist/src/duck/history.js +4 -0
  79. package/dist/src/duck/jsonb-order.js +9 -12
  80. package/dist/src/duck/kept-route.js +6 -5
  81. package/dist/src/duck/kept.js +9 -7
  82. package/dist/src/duck/late.js +1469 -40
  83. package/dist/src/duck/libc-bound.js +341 -0
  84. package/dist/src/duck/libc-order.js +892 -126
  85. package/dist/src/duck/pg-eval-worker.js +7 -1
  86. package/dist/src/duck/pg-eval.js +6 -4
  87. package/dist/src/duck/pgtree.js +774 -23
  88. package/dist/src/duck/plan.js +275 -30
  89. package/dist/src/duck/publication.js +14 -0
  90. package/dist/src/duck/requests.js +9 -5
  91. package/dist/src/duck/resolved.js +117 -2
  92. package/dist/src/duck/rewrite.js +4567 -583
  93. package/dist/src/duck/row-filter.js +12 -6
  94. package/dist/src/duck/statement.js +19 -0
  95. package/dist/src/duck/store.js +294 -68
  96. package/dist/src/duck/stream.js +9 -5
  97. package/dist/src/duck/sync.js +602 -126
  98. package/dist/src/duck/token.js +4 -1
  99. package/dist/src/duck/types.js +63 -7
  100. package/dist/src/duck/usage.js +6 -4
  101. package/dist/src/duck/users.js +110 -9
  102. package/dist/src/duck/verified.js +159 -2
  103. package/dist/src/duck/views.js +4 -3
  104. package/dist/src/lib/cidr.js +177 -0
  105. package/dist/src/lib/database-url.js +55 -10
  106. package/dist/src/lib/instant.js +50 -0
  107. package/dist/src/lib/log-buffer.js +13 -4
  108. package/dist/src/lib/log-read.js +196 -0
  109. package/dist/src/lib/pg-numeric.js +387 -49
  110. package/dist/src/lib/secret-mask.js +142 -0
  111. package/dist/src/lib/table-sync.js +84 -19
  112. package/dist/src/web/app.js +9 -4
  113. package/dist/src/web/pages/status.js +178 -13
  114. package/dist/src/web/pages/syncs.js +1 -1
  115. package/dist/src/web/pages/tables.js +8 -3
  116. package/dist/src/web/pages/users.js +4 -3
  117. package/dist/src/web/shared.js +19 -3
  118. 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
+ }
@@ -5,7 +5,7 @@
5
5
  * in the same words at both entrances:
6
6
  *
7
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
8
+ * poll <interval> the xmin poll every <interval>: <n>s, <n>m, <n>h or <n>d, a whole number, at least 1s, at most 24h
9
9
  * replication the database's change log, through the project's ONE slot and publication (src/duck/stream.ts):
10
10
  * the publication carries the replicated tables only, and the polled ones are polled beside it
11
11
  *
@@ -34,11 +34,11 @@ export const BIG_TABLE_ROWS = 1_000_000;
34
34
  export const COSTLY_POLL_ROWS = 10_000_000;
35
35
  export const COSTLY_POLL_SECONDS = 60;
36
36
  const MODES = ["off", "poll", "replication"];
37
- const UNIT_SECONDS = { s: 1, m: 60, h: 3600 };
37
+ const UNIT_SECONDS = { s: 1, m: 60, h: 3600, d: 86_400 };
38
38
  /** the units a person may write for one of ours, said back as the one to write */
39
39
  const UNIT_WORDS = {
40
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",
41
+ h: "h", hr: "h", hrs: "h", hour: "h", hours: "h", d: "d", day: "d", days: "d",
42
42
  };
43
43
  const EXAMPLES = "poll 5s, poll 5m or poll 1h";
44
44
  /** what a sync is, said where one is wrong */
@@ -52,33 +52,80 @@ 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|d`) read, or why it is not one, with the interval to write when there is one. ONE grammar with
56
+ * two entrances, each worded as it is written: a `sync:`'s `poll <interval>` and a generated table's `target_lag:`. Its
57
+ * units are a command line's duration's too (`parseDuration`), which also takes a bare number of seconds: a file writes
58
+ * the unit, one spelling of each interval. */
59
+ function parseInterval(v, write = "poll ") {
57
60
  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})`;
61
+ const examples = write === "" ? "5s, 5m or 1h" : EXAMPLES;
62
+ const how = `an interval is <n>s, <n>m, <n>h or <n>d, a whole number, at least ${MIN_POLL_SECONDS}s (${examples})`;
63
+ if (/^\d+$/.test(v))
64
+ return { problem: `${v} has no unit: write ${write}${Number(v)}s for seconds (or m, h, d)` };
59
65
  if (m === null)
60
66
  return { problem: `${JSON.stringify(v)} is not an interval: ${how}` };
61
67
  const n = Number(m[1]), word = m[2].toLowerCase();
62
68
  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` };
69
+ 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
70
  const unit = UNIT_WORDS[word];
65
- if (unit === undefined) {
66
- const days = /^(d|day|days)$/.test(word) ? `: write poll ${intervalOf(n * 86_400)}` : "";
67
- return { problem: `${v}: ${word} is not a unit of an interval — ${days ? `days are hours${days}` : how}` };
68
- }
71
+ if (unit === undefined)
72
+ return { problem: `${v}: ${word} is not a unit of an interval — ${how}` };
69
73
  const seconds = n * UNIT_SECONDS[unit];
70
74
  if (!Number.isInteger(n))
71
- return { problem: `${v}: an interval is a whole number — write poll ${intervalOf(seconds)}` };
75
+ return { problem: `${v}: an interval is a whole number — write ${write}${intervalOf(seconds)}` };
72
76
  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` };
77
+ return { problem: `${v} is under the shortest interval, ${MIN_POLL_SECONDS}s: write ${write}${MIN_POLL_SECONDS}s or longer` };
74
78
  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` };
79
+ 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
80
  // the number as a number is written (`05s` is `5s`): two spellings of one interval would read as a change of sync
77
81
  const canonical = `${n}${unit}`;
78
82
  if (canonical !== v)
79
- return { problem: `${JSON.stringify(v)}: write poll ${canonical}` };
83
+ return { problem: `${JSON.stringify(v)}: write ${write}${canonical}` };
80
84
  return { every: canonical, seconds };
81
85
  }
86
+ /** A generated table's `target_lag:` read (src/cli/generated-files.ts): an interval alone, in the grammar and the range a
87
+ * poll's takes, or what is wrong with it in the words a person fixes it from. */
88
+ export function parseLag(v) {
89
+ if (typeof v !== "string" || v.trim() === "")
90
+ 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` };
91
+ return parseInterval(v.trim(), "");
92
+ }
93
+ /** How a duration on a command line is written: said where one is wrong, and `<duration>` in each usage. */
94
+ export const DURATION_FORMS = "a number of seconds, or <n>s, <n>m, <n>h or <n>d (90, 30s, 10m, 1h, 7d)";
95
+ /**
96
+ * A DURATION ON A COMMAND LINE, ONE GRAMMAR FOR EVERY FLAG THAT TAKES ONE (`--timeout` of mason wait, share, ui --ssh,
97
+ * deploy and replica create; mason usage's `--step`; the age `--since` / `--until` take, src/cli/request-filter.ts
98
+ * `timeOf`, which refuses a bare number as a time): a bare number is seconds, as wait, share and ui read it before; `<n>s|m|h|d`, and the words a sync's
99
+ * interval takes (`5 min`, `2 hours`, `7 days`), as a `sync:` interval and a `target_lag:` are written. A superset of
100
+ * every form each flag took before, so nothing that parsed stops parsing. The range is the flag's (`min`, `max` in
101
+ * seconds; over 0, unless `zero`: an age of now, `--since 0s`, and `--step 0s`, every sample, as each took it before);
102
+ * the caller names the flag.
103
+ */
104
+ export function parseDuration(v, o = {}) {
105
+ const t = v.trim();
106
+ let seconds, every;
107
+ if (t !== "" && Number.isFinite(Number(t))) {
108
+ seconds = Number(t);
109
+ every = `${seconds}s`;
110
+ }
111
+ else {
112
+ const m = /^(\d+(?:\.\d+)?)\s*([a-z]+)$/i.exec(t);
113
+ const unit = m === null ? undefined : UNIT_WORDS[m[2].toLowerCase()];
114
+ if (m === null || unit === undefined)
115
+ return { problem: `${JSON.stringify(v)} is not a duration: write ${DURATION_FORMS}` };
116
+ seconds = Number(m[1]) * UNIT_SECONDS[unit];
117
+ every = `${Number(m[1])}${unit}`;
118
+ }
119
+ if (o.zero === true && seconds === 0 && !Object.is(seconds, -0))
120
+ return { every, seconds: 0 };
121
+ if (!(seconds > 0))
122
+ return { problem: `${JSON.stringify(v)} is not a duration over 0: write ${DURATION_FORMS}` };
123
+ if (o.min !== undefined && seconds < o.min)
124
+ return { problem: `${every} is under the shortest, ${intervalOf(o.min)}` };
125
+ if (o.max !== undefined && seconds > o.max)
126
+ return { problem: `${every} is over the longest, ${intervalOf(o.max)}` };
127
+ return { every, seconds };
128
+ }
82
129
  /**
83
130
  * 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
131
  * the line to write, a `did you mean` for a misspelt mode, the floor for a short interval. Whether a table CAN be
@@ -171,9 +218,14 @@ function saidInTheFile(what) {
171
218
  * every one given on ONE report, one coherent way out (a publication goes with replication). Null when none is given.
172
219
  * Read from the raw command line, before the options are parsed, so a removed flag is answered as removed and never
173
220
  * as an unknown option.
221
+ *
222
+ * `--port` IS NOW `--ui-port` (issue 729: it is the web page's and the commands' port, as MASON_UI_PORT is on a server,
223
+ * and read as the Postgres API's): answered on the same report with the line to run — `start`, built from the flags
224
+ * mason start takes alone, so nothing else of the line is repeated. `sync`: a sources-file flag was given.
174
225
  */
175
226
  export function removedStartFlags(argv, top) {
176
- let replication = false, poll = false, publication;
227
+ let replication = false, poll = false, publication, port = false;
228
+ const kept = [];
177
229
  for (let i = 0; i < argv.length; i++) {
178
230
  const a = argv[i];
179
231
  if (a === "--")
@@ -188,10 +240,21 @@ export function removedStartFlags(argv, top) {
188
240
  const v = argv[i + 1];
189
241
  publication = v !== undefined && !v.startsWith("-") ? v : null;
190
242
  }
243
+ else if (a === "--port" || a.startsWith("--port=")) {
244
+ const v = a === "--port" ? argv[i + 1] : a.slice("--port=".length);
245
+ if (a === "--port" && v !== undefined && !v.startsWith("-"))
246
+ i++;
247
+ port = true;
248
+ kept.push(`--ui-port ${v !== undefined && /^\d{1,5}$/.test(v) ? v : "<n>"}`);
249
+ }
250
+ else if (a === "--attach" || a === "--self-signed-tls" || a === "--json")
251
+ kept.push(a);
191
252
  }
192
253
  const given = [...(replication ? ["--replication"] : []), ...(poll ? ["--poll"] : []), ...(publication !== undefined ? ["--publication"] : [])];
254
+ const start = port ? ["mason start", ...kept].join(" ") : "mason start";
255
+ const portSaid = { message: "--port is now --ui-port", why: "it is the port of the web page and the mason commands (MASON_UI_PORT on a server), never the Postgres API's (MASON_PG_PORT)" };
193
256
  if (given.length === 0)
194
- return null;
257
+ return port ? { ...portSaid, fix: start, start, sync: false } : null;
195
258
  const t = top();
196
259
  const named = given.length === 1 ? `${given[0]} is gone` : `${given.slice(0, -1).join(", ")} and ${given.at(-1)} are gone`;
197
260
  const pub = publication === undefined ? null : topEdit(t, "publication", [publication ?? "<name>"]);
@@ -215,8 +278,10 @@ export function removedStartFlags(argv, top) {
215
278
  fix = `${edit} (${edit.startsWith("keep ") ? "every table is polled already; a table's entry may say its own"
216
279
  : "any interval: poll 5s, poll 1h; or sync: off, the default, and mason sync <table> when you need it"})`;
217
280
  }
218
- return { message: `${named}: the sources file says it now`, why: saidInTheFile({ sync: replication || poll, publication: publication !== undefined }),
219
- fix: `${fix}, then mason start` };
281
+ const why = saidInTheFile({ sync: replication || poll, publication: publication !== undefined });
282
+ if (!port)
283
+ return { message: `${named}: the sources file says it now`, why, fix: `${fix}, then mason start`, start, sync: true };
284
+ return { message: `${named}: the sources file says it now; ${portSaid.message}`, why: `${why}; ${portSaid.why}`, fix: `${fix}, then ${start}`, start, sync: true };
220
285
  }
221
286
  /**
222
287
  * `MASON_CHANGES` SET IS REFUSED, never run on a default (CLAUDE.md: a removed setting refuses the start, naming the
@@ -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