@mason-data/cli 0.7.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (155) hide show
  1. package/README.md +101 -73
  2. package/dist/src/api/error.js +3 -0
  3. package/dist/src/api/postgres/catalog-worker.js +49 -2
  4. package/dist/src/api/postgres/catalog.js +368 -25
  5. package/dist/src/api/postgres/encode.js +3 -1
  6. package/dist/src/api/postgres/explain.js +3 -2
  7. package/dist/src/api/postgres/listener.js +11 -3
  8. package/dist/src/api/postgres/protocol.js +39 -23
  9. package/dist/src/api/postgres/proxy.js +283 -0
  10. package/dist/src/api/postgres/schema.js +6 -0
  11. package/dist/src/api/postgres/server.js +1617 -245
  12. package/dist/src/api/postgres/translate.js +127 -10
  13. package/dist/src/api/request-log.js +18 -5
  14. package/dist/src/cdc/poll.js +5 -5
  15. package/dist/src/cli/background.js +47 -25
  16. package/dist/src/cli/bridge-stdin.js +101 -0
  17. package/dist/src/cli/cancel.js +75 -0
  18. package/dist/src/cli/command.js +170 -11
  19. package/dist/src/cli/commands.js +804 -209
  20. package/dist/src/cli/control.js +59 -0
  21. package/dist/src/cli/credentials.js +182 -0
  22. package/dist/src/cli/demo.js +20 -11
  23. package/dist/src/cli/deploy.js +574 -0
  24. package/dist/src/cli/dev.js +491 -130
  25. package/dist/src/cli/doctor.js +131 -38
  26. package/dist/src/cli/generated-files.js +143 -0
  27. package/dist/src/cli/guides.js +175 -36
  28. package/dist/src/cli/hosted.js +850 -0
  29. package/dist/src/cli/init.js +162 -65
  30. package/dist/src/cli/login.js +289 -0
  31. package/dist/src/cli/machine.js +13 -12
  32. package/dist/src/cli/main.js +11 -5
  33. package/dist/src/cli/mason.cjs +17 -0
  34. package/dist/src/cli/mcp.js +31 -22
  35. package/dist/src/cli/metrics.js +79 -0
  36. package/dist/src/cli/output-gate.js +176 -0
  37. package/dist/src/cli/page-share.js +96 -0
  38. package/dist/src/cli/pg-tls.js +14 -4
  39. package/dist/src/cli/pg-url.js +4 -0
  40. package/dist/src/cli/pin.js +19 -14
  41. package/dist/src/cli/pool-size.cjs +85 -0
  42. package/dist/src/cli/ports.js +2 -0
  43. package/dist/src/cli/preflight.js +138 -46
  44. package/dist/src/cli/project.js +142 -4
  45. package/dist/src/cli/readiness.js +76 -6
  46. package/dist/src/cli/registry.js +65 -38
  47. package/dist/src/cli/replica-hosted.js +665 -0
  48. package/dist/src/cli/replica.js +76 -0
  49. package/dist/src/cli/replication-checks.js +7 -5
  50. package/dist/src/cli/replication-mode.js +105 -26
  51. package/dist/src/cli/request-filter.js +134 -0
  52. package/dist/src/cli/roles.js +585 -0
  53. package/dist/src/cli/run-server.js +637 -48
  54. package/dist/src/cli/runs.js +1 -1
  55. package/dist/src/cli/security.js +67 -18
  56. package/dist/src/cli/serve.js +204 -46
  57. package/dist/src/cli/share-run.js +329 -0
  58. package/dist/src/cli/share.js +286 -0
  59. package/dist/src/cli/source-edit.js +75 -17
  60. package/dist/src/cli/source-role.js +344 -0
  61. package/dist/src/cli/sources.js +306 -47
  62. package/dist/src/cli/style.js +2 -1
  63. package/dist/src/cli/supervise.js +27 -24
  64. package/dist/src/cli/sync.js +55 -25
  65. package/dist/src/cli/token-hosted.js +337 -0
  66. package/dist/src/cli/tunnel.js +200 -0
  67. package/dist/src/cli/ui-ssh.js +460 -0
  68. package/dist/src/cli/ui.js +124 -17
  69. package/dist/src/cli/usage.js +128 -0
  70. package/dist/src/cli/user-hosted.js +299 -0
  71. package/dist/src/cli/user.js +573 -65
  72. package/dist/src/cli/version.js +9 -7
  73. package/dist/src/demo/seed.js +29 -0
  74. package/dist/src/duck/analyze.js +36 -20
  75. package/dist/src/duck/cancel.js +10 -1
  76. package/dist/src/duck/collation.js +266 -0
  77. package/dist/src/duck/constant-key.js +94 -0
  78. package/dist/src/duck/distinct-key.js +283 -26
  79. package/dist/src/duck/events.js +165 -0
  80. package/dist/src/duck/executor-worker.js +20 -11
  81. package/dist/src/duck/executor.js +47 -33
  82. package/dist/src/duck/explain.js +60 -46
  83. package/dist/src/duck/extensions.js +160 -0
  84. package/dist/src/duck/functions.js +461 -63
  85. package/dist/src/duck/gaps.js +48 -21
  86. package/dist/src/duck/generated-plan.js +1070 -0
  87. package/dist/src/duck/generated.js +906 -0
  88. package/dist/src/duck/grants.js +176 -0
  89. package/dist/src/duck/held-types.js +64 -0
  90. package/dist/src/duck/history.js +127 -0
  91. package/dist/src/duck/jsonb-order.js +9 -12
  92. package/dist/src/duck/kept-route.js +6 -5
  93. package/dist/src/duck/kept.js +15 -13
  94. package/dist/src/duck/late.js +1469 -40
  95. package/dist/src/duck/libc-bound.js +341 -0
  96. package/dist/src/duck/libc-order.js +892 -126
  97. package/dist/src/duck/pg-eval-worker.js +7 -1
  98. package/dist/src/duck/pg-eval.js +6 -4
  99. package/dist/src/duck/pgtree.js +774 -23
  100. package/dist/src/duck/plan.js +369 -47
  101. package/dist/src/duck/publication.js +20 -6
  102. package/dist/src/duck/requests.js +269 -93
  103. package/dist/src/duck/resolved.js +117 -2
  104. package/dist/src/duck/rewrite.js +4584 -580
  105. package/dist/src/duck/row-filter.js +655 -0
  106. package/dist/src/duck/statement.js +19 -0
  107. package/dist/src/duck/store.js +439 -177
  108. package/dist/src/duck/stream.js +19 -10
  109. package/dist/src/duck/sync.js +1411 -368
  110. package/dist/src/duck/token.js +4 -1
  111. package/dist/src/duck/types.js +69 -12
  112. package/dist/src/duck/usage.js +326 -0
  113. package/dist/src/duck/users.js +69 -15
  114. package/dist/src/duck/verified.js +161 -1
  115. package/dist/src/duck/views.js +5 -5
  116. package/dist/src/lib/database-name.js +62 -0
  117. package/dist/src/lib/database-url.js +55 -10
  118. package/dist/src/lib/dotenv.js +17 -4
  119. package/dist/src/lib/instant.js +50 -0
  120. package/dist/src/lib/log-buffer.js +77 -0
  121. package/dist/src/lib/log-read.js +196 -0
  122. package/dist/src/lib/pg-numeric.js +390 -49
  123. package/dist/src/lib/project-version.js +23 -23
  124. package/dist/src/lib/secret-mask.js +142 -0
  125. package/dist/src/lib/table-sync.js +282 -0
  126. package/dist/src/web/app.css +706 -66
  127. package/dist/src/web/app.js +331 -409
  128. package/dist/src/web/charts.js +229 -0
  129. package/dist/src/web/index.html +82 -18
  130. package/dist/src/web/pages/queries.js +192 -0
  131. package/dist/src/web/pages/status.js +611 -0
  132. package/dist/src/web/pages/syncs.js +155 -0
  133. package/dist/src/web/pages/tables.js +439 -0
  134. package/dist/src/web/pages/usage.js +397 -0
  135. package/dist/src/web/pages/users.js +219 -0
  136. package/dist/src/web/shared.js +584 -0
  137. package/dist/src/web/vendor/README.md +25 -0
  138. package/dist/src/web/vendor/geist-1.7.2/Geist-Variable.woff2 +0 -0
  139. package/dist/src/web/vendor/geist-1.7.2/GeistMono-Variable.woff2 +0 -0
  140. package/dist/src/web/vendor/geist-1.7.2/LICENSE.txt +92 -0
  141. package/dist/src/web/vendor/lucide-1.56.0/LICENSE.txt +43 -0
  142. package/dist/src/web/vendor/lucide-1.56.0/chart-column.mjs +29 -0
  143. package/dist/src/web/vendor/lucide-1.56.0/layout-dashboard.mjs +29 -0
  144. package/dist/src/web/vendor/lucide-1.56.0/logs.mjs +34 -0
  145. package/dist/src/web/vendor/lucide-1.56.0/menu.mjs +28 -0
  146. package/dist/src/web/vendor/lucide-1.56.0/square-terminal.mjs +28 -0
  147. package/dist/src/web/vendor/lucide-1.56.0/table.mjs +29 -0
  148. package/dist/src/web/vendor/lucide-1.56.0/users.mjs +29 -0
  149. package/dist/src/web/vendor/lucide-1.56.0/x.mjs +27 -0
  150. package/dist/src/web/vendor/uplot-1.6.32/LICENSE.txt +21 -0
  151. package/dist/src/web/vendor/uplot-1.6.32/uPlot.esm.js +6140 -0
  152. package/dist/src/web/vendor/uplot-1.6.32/uPlot.min.css +1 -0
  153. package/dist/src/web/vendor/zilla-slab-5.3.0/LICENSE.txt +93 -0
  154. package/dist/src/web/vendor/zilla-slab-5.3.0/zilla-slab-latin-700-normal.woff2 +0 -0
  155. package/package.json +12 -4
@@ -0,0 +1,79 @@
1
+ /** The content type of the text exposition format, version 0.0.4. */
2
+ export const METRICS_CONTENT_TYPE = "text/plain; version=0.0.4; charset=utf-8";
3
+ /**
4
+ * HOW MANY SAMPLES OLD A NUMBER READ FROM THE DATABASE (the stream's position, the slot) or THE SAMPLER (p50 / p95) may
5
+ * be and still be said: the sampler refreshes each once a minute (src/duck/usage.ts SAMPLE_SECONDS), and a scrape never
6
+ * reads them itself (src/cli/run-server.ts `metricsFacts`), so one older than two samples means the sampler could not
7
+ * take them — said as unknown (absent), never as a stale number that looks current.
8
+ */
9
+ export const FRESH_SAMPLES = 2;
10
+ /** a label value escaped as the format says: backslash, double quote and line feed */
11
+ const labelValue = (v) => v.replaceAll("\\", "\\\\").replaceAll('"', '\\"').replaceAll("\n", "\\n");
12
+ /** a sample's value: an integer as written, a fraction as JavaScript prints it (Go's ParseFloat reads both) */
13
+ const valueText = (n) => (Number.isInteger(n) ? n.toFixed(0) : String(n));
14
+ /** a sample that must be a count: a whole number, at least 0 */
15
+ const isCount = (s, kind) => kind === "counter" || s.suffix === "_count";
16
+ /** One metric's lines, its HELP and TYPE first — or none, the WHOLE family absent, when a sample it needs is unknown
17
+ * or any value is not finite (or a count not a whole number). */
18
+ function metric(name, kind, help, samples) {
19
+ const whole = samples.every((s) => s.value === null ? s.optional === true
20
+ : Number.isFinite(s.value) && (!isCount(s, kind) || (Number.isInteger(s.value) && s.value >= 0)));
21
+ const kept = samples.filter((s) => s.value !== null);
22
+ if (!whole || kept.length === 0)
23
+ return [];
24
+ const lines = [`# HELP ${name} ${help.replaceAll("\\", "\\\\").replaceAll("\n", "\\n")}`, `# TYPE ${name} ${kind}`];
25
+ for (const s of kept) {
26
+ const labels = s.labels ? Object.entries(s.labels).map(([k, v]) => `${k}="${labelValue(v ?? "")}"`).join(",") : "";
27
+ lines.push(`${name}${s.suffix ?? ""}${labels ? `{${labels}}` : ""} ${valueText(s.value)}`);
28
+ }
29
+ return lines;
30
+ }
31
+ /** The PHASES `mason_tables` says, in order: the sync's (src/duck/sync.ts `CopyState.phase`), as `/healthz` counts them. */
32
+ export const TABLE_PHASES = ["copied", "copying", "waiting", "failed"];
33
+ /** The OUTCOMES `mason_statements_total` says: the request log's (`outcomeOf`), `ok` said as `completed`. */
34
+ export const STATEMENT_OUTCOMES = ["completed", "failed", "refused", "cancelled"];
35
+ /**
36
+ * THE BODY OF `GET /metrics`. Starting or stopping: `mason_ready 0` and `mason_info` alone (a scrape must not fail
37
+ * while Mason starts; whether it is up is `/healthz`'s to say). Serving: `mason_ready 1` and the run's numbers.
38
+ */
39
+ export function metricsText(f) {
40
+ const lines = [
41
+ ...metric("mason_ready", "gauge", "1 once Mason serves the synced data; 0 while it starts or stops (/healthz answers 503 then).", [{ value: f.run === null ? 0 : 1 }]),
42
+ ...metric("mason_info", "gauge", "Mason's version, as a label; the value is always 1.", [{ labels: { version: f.version }, value: 1 }]),
43
+ ];
44
+ const r = f.run;
45
+ if (r !== null) {
46
+ const u = r.usage, st = r.statements;
47
+ // a number read from the database (the stream's position, the slot) as of `readAt`: said only while fresh
48
+ const fresh = (value, readAt) => readAt !== null && isFresh(r, readAt) ? value : null;
49
+ lines.push(...metric("mason_uptime_seconds", "gauge", "Seconds since mason serve started.", [{ value: r.uptimeSeconds }]), ...metric("mason_tables", "gauge", "The synced tables the run holds, by phase (a removed table's data is held until dropped).", TABLE_PHASES.map((phase) => ({ labels: { phase }, value: u.tables[phase] }))), ...metric("mason_copy_bytes", "gauge", "The size of the synced data's file on disk, bytes.", [{ value: u.copyBytes }]), ...metric("mason_copy_rows", "gauge", "The rows of the synced tables served.", [{ value: u.totals.rows }]),
50
+ // absent, not NaN, before any added table was synced: nothing has an age yet (see the module's comment)
51
+ ...metric("mason_sync_oldest_age_seconds", "gauge", "Seconds since the stalest added table was last brought current; absent before any sync.", [{ value: u.oldestSyncSeconds }]),
52
+ // absent where no table is replicated (the run polls): a 0 would claim a stream that is not there. The database's
53
+ // position under the bytes, and the slot, are as the sampler last read them (a scrape never asks the database):
54
+ // absent when that is more than FRESH_SAMPLES samples old. The seconds are the run's own clock against the last
55
+ // batch's commit time, read from nothing: current at every scrape
56
+ ...metric("mason_stream_lag_bytes", "gauge", "Replication: WAL bytes between the database's position (as last sampled, at most two samples old) and what Mason has applied; absent when nothing is replicated or not known.", [{ value: fresh(u.streamLag?.bytes ?? null, u.streamLagReadAt) }]), ...metric("mason_stream_lag_seconds", "gauge", "Replication: seconds since the newest change applied was committed on the database; absent when nothing is replicated.", [{ value: u.streamLag?.seconds ?? null }]), ...metric("mason_slot_wal_held_bytes", "gauge", "Replication: WAL bytes the replication slot holds on the database (as last sampled, at most two samples old); absent when nothing is replicated or not known.", [{ value: fresh(u.slotWalHeldBytes, u.slotWalReadAt) }]), ...metric("mason_disk_free_bytes", "gauge", "Bytes free on the disk the synced data is on.", [{ value: u.diskFreeBytes }]), ...metric("mason_sessions_open", "gauge", "Sessions logged in to Mason's Postgres API now.", [{ value: u.sessionsOpen }]), ...metric("mason_statements_total", "counter", "Statements the listener answered since the process started, whether or not the request log kept each, by outcome (refused: the statement's fault; failed: Mason's or the machine's).", STATEMENT_OUTCOMES.map((outcome) => ({ labels: { outcome }, value: outcome === "completed" ? st.completed : st[outcome] }))), ...durationSummary(r));
57
+ }
58
+ return `${lines.join("\n")}\n`;
59
+ }
60
+ /** a time `readAt` (ISO) at most `freshSeconds` before the facts were read */
61
+ const isFresh = (r, readAt) => (r.at - Date.parse(readAt)) / 1000 <= r.freshSeconds;
62
+ /**
63
+ * `mason_statement_duration_seconds`, a summary: the quantiles are the last usage sample's (its minute's completed
64
+ * statements, as `mason usage` says them: src/duck/usage.ts `lastDurations`; past DURATIONS_KEPT in a minute, of a
65
+ * uniform sample of them), absent before the first sample, for a minute none completed, and when the last sample is
66
+ * more than FRESH_SAMPLES samples old (a sample that could not be taken keeps the one before); `_sum` and `_count` are
67
+ * the completed statements since the process started (the counter), as a Prometheus client's summary is: quantiles
68
+ * over a recent window, sum and count cumulative.
69
+ */
70
+ function durationSummary(r) {
71
+ const d = r.durations !== null && isFresh(r, r.durations.at) ? r.durations : null;
72
+ const seconds = (ms) => (ms === null || ms === undefined ? null : ms / 1000);
73
+ return metric("mason_statement_duration_seconds", "summary", "Completed statements' duration, seconds: p50 and p95 of the last minute's usage sample (sampled: a busy minute's of a uniform sample); sum and count since the process started.", [
74
+ { labels: { quantile: "0.5" }, value: seconds(d?.p50Ms), optional: true },
75
+ { labels: { quantile: "0.95" }, value: seconds(d?.p95Ms), optional: true },
76
+ { suffix: "_sum", value: r.statements.completedMs / 1000 },
77
+ { suffix: "_count", value: r.statements.completed },
78
+ ]);
79
+ }
@@ -0,0 +1,176 @@
1
+ /**
2
+ * THE OUTPUT'S GATE: every byte a hosted command writes, on stdout and on stderr, put to the exact test at the point it
3
+ * is written (PR 645's class round: what was tested was not what was printed — a page tested whole and printed cut
4
+ * inside a `%XX` escape, a name tested with a lone surrogate read as a separator and printed by --json as `\udc00`, a
5
+ * page tested as it came and printed as the URL parser rewrote it). The per-value tests of src/cli/hosted.ts stay the
6
+ * first line (`carriesNoSecret`, `pageUrl`, `serviceText`); this is the last, and the authority: the bytes written.
7
+ *
8
+ * WHAT IS READ: each stream's text as written, after every transformation the CLI makes — styling (colour, the CLI's
9
+ * own line breaks: src/cli/command.ts serviceStyle), `JSON.stringify`'s escapes, a value's cut, the masks, the
10
+ * spinner's redraws — and the two streams together (a log that takes `2>&1`). Each is read in eight ways: with its
11
+ * terminal escapes and without them (what a log keeps; what a person sees), keeping `-` and `_` or not, and a `%XX`
12
+ * escape decoded or read as the characters it is written with — each as src/cli/hosted.ts reads a value (`bodyRead`:
13
+ * re-cased, in its compatibility form, every other character skipped). A run that crosses two writes is read whole:
14
+ * each reading keeps the last `ECHO_RUN - 1` characters it read, and a raw tail is kept for the shapes.
15
+ *
16
+ * WHAT FIRES IT: `ECHO_RUN` of a held secret's body in a row in any reading, or anything shaped as a documented token
17
+ * or (unless the command prints a password it made: `mason user add --replica`) a Mason user's password. Then the
18
+ * write that would complete it is NOT written, nor anything after it: the command's refusal is written instead, in the
19
+ * CLI's words (on stdout under --json, as its one object), and the process exits 1 (`gatedExit`). What was written
20
+ * before showed at most `ECHO_RUN - 1` in a row.
21
+ *
22
+ * HONEST OUTPUT IS NEVER REFUSED BY CHANCE, by `ECHO_RUN`'s derivation (src/cli/hosted.ts): an honest output is
23
+ * independent of the token, and a command writes a few KiB; read 8 ways and over 3 streams it is under 2^20
24
+ * characters read, and the bound W × 43 × 2 × 31^-11 is under 10^-8. tests/cli-hosted.test.ts measures it with a fixed
25
+ * seed over the honest outputs of deploy, login, logout and mason user --replica.
26
+ *
27
+ * ONE VALUE MAY BE PRINTED ONCE (`holdOutput`'s `once`: the CI token `mason token create` made, the one answer that
28
+ * carries a token by design): its FIRST whole occurrence in a write (`onceAt`: exactly as it is; the CLI writes it on a
29
+ * line of its own, never broken) is read as a separator, never as the secret it is, and is written; every other byte is read as before, the value held among the secrets, so a second
30
+ * occurrence, a piece of it elsewhere (11 of its body in a row) or anything else shaped as a token is refused as any
31
+ * secret is. What it allows is exactly that value, whole, one time, in one write.
32
+ *
33
+ * INERT until a command holds a secret (`holdOutput`): every other command writes as it always did.
34
+ */
35
+ import { failed, serviceStyle } from "./command.js";
36
+ import { bodyRead, canonicalTexts, ECHO_RUN, echoViews, ESCAPES, shapedSecret, wholeInCanonical } from "./hosted.js";
37
+ import { err as styleErr } from "./style.js";
38
+ /** The eight readings: [with escapes, without] × [keeping `-` and `_`, not] × [a `%XX` decoded, not]. */
39
+ const READINGS = [true, false].flatMap((escapes) => [true, false].flatMap((keep) => [true, false].map((percent) => ({ escapes, keep, percent }))));
40
+ const TAIL = ECHO_RUN - 1;
41
+ /** The raw tail kept for the shapes and the secrets read whole: longer than any shape (a token is 56 characters, a
42
+ * password 55), and than any held secret written `%XX`-encoded (`rawTail`). */
43
+ const RAW_TAIL = 80;
44
+ const rawTail = (secrets) => Math.max(RAW_TAIL, ...secrets.map((x) => 3 * x.length));
45
+ export const nothingWritten = () => ({ reads: READINGS.map(() => ""), raw: "" });
46
+ /**
47
+ * WHETHER WRITING `text` AFTER `before` WOULD WRITE A SECRET (the gate's test, pure): `ECHO_RUN` of a held secret's body
48
+ * in a row in any reading of the two together, or a shape. The stream as it would stand after it, either way.
49
+ */
50
+ export function writes(before, text, secrets, o = {}) {
51
+ return writesAfter([before], readingsOf(text), secrets, o)[0];
52
+ }
53
+ /**
54
+ * `fresh` (a text's readings, `readingsOf(text)`) written after each of `befores`: the text is read and scanned once,
55
+ * and only where it meets each stream's tail again (a window of `ECHO_RUN` crossing into it lies within the tail and
56
+ * the text's first `TAIL` characters).
57
+ */
58
+ function writesAfter(befores, fresh, secrets, o) {
59
+ const inside = readingsCarry(fresh, secrets, o);
60
+ const tail = rawTail(secrets);
61
+ return befores.map((before) => {
62
+ const meet = { reads: before.reads.map((r, i) => r + fresh.reads[i].slice(0, TAIL)), raw: before.raw + fresh.raw.slice(0, tail) };
63
+ const after = { reads: before.reads.map((r, i) => (r + fresh.reads[i]).slice(-TAIL)), raw: (before.raw + fresh.raw).slice(-tail) };
64
+ return { secret: inside || readingsCarry(meet, secrets, o), after };
65
+ });
66
+ }
67
+ /** `text`'s readings, after what was written before it (its tails): what `readingsCarry` tests. */
68
+ export function readingsOf(text, before = nothingWritten()) {
69
+ // a text with no escape, or no `%`, reads the same either way: each distinct reading is made once
70
+ const stripped = text.includes("\x1b") ? text.replace(ESCAPES, "") : text, percents = text.includes("%");
71
+ const made = new Map();
72
+ const read = (escapes, keep, percent) => {
73
+ const key = `${escapes && stripped !== text}${keep}${percent && percents}`;
74
+ let r = made.get(key);
75
+ if (r === undefined) {
76
+ r = bodyRead(escapes ? text : stripped, keep, percent);
77
+ made.set(key, r);
78
+ }
79
+ return r;
80
+ };
81
+ return { reads: READINGS.map(({ escapes, keep, percent }, i) => before.reads[i] + read(escapes, keep, percent)), raw: before.raw + text };
82
+ }
83
+ /** Whether readings hold a secret: `run` of a held secret's body in a row in any of them, the secret whole, or a shape. */
84
+ export function readingsCarry(read, secrets, o = {}) {
85
+ if (shapedSecret(read.raw, o) || shapedSecret(read.raw.replace(ESCAPES, ""), o))
86
+ return true;
87
+ for (const secret of secrets) {
88
+ if (!secret)
89
+ continue;
90
+ // whole, in the one reading, with its terminal escapes and without them (src/cli/hosted.ts wholeIn)
91
+ read.canonical ??= canonicalTexts(read.raw);
92
+ if (read.canonical.some((t) => wholeInCanonical(t, secret)))
93
+ return true;
94
+ for (const { keep, n, grams } of echoViews(secret, o.run ?? ECHO_RUN)) {
95
+ if (n === 0)
96
+ continue;
97
+ // a text with no escape and no `%` reads the same in several ways: each distinct reading is scanned once
98
+ const distinct = new Set(READINGS.flatMap((r, i) => r.keep === keep ? [read.reads[i]] : []));
99
+ for (const text of distinct)
100
+ for (let j = 0; j + n <= text.length; j++)
101
+ if (grams.has(text.slice(j, j + n)))
102
+ return true;
103
+ }
104
+ }
105
+ return false;
106
+ }
107
+ /** WHERE `value` IS WRITTEN WHOLE in `text`, exactly as it is (the CLI writes it alone, never broken: src/cli/token-hosted.ts);
108
+ * null when it is not. The span, from its first character to its last. */
109
+ export function onceAt(text, value) {
110
+ const i = text.indexOf(value);
111
+ return i < 0 ? null : [i, i + value.length];
112
+ }
113
+ let held = null;
114
+ let refused = false;
115
+ let originals = null;
116
+ const state = { out: nothingWritten(), err: nothingWritten(), both: nothingWritten() };
117
+ /** The refusal written in place of what the gate held back: the CLI's words alone. */
118
+ const REFUSAL = {
119
+ message: "the service's answer would print a secret this command holds, so nothing more of it is printed",
120
+ code: "refused",
121
+ why: "what was about to be written held a secret this command holds (11 or more characters of a token's body in a row, or the database's password it sent), or something shaped as a token or a password: a hosted service's text is printed only when it carries none",
122
+ fix: "a service that repeats the token it was sent is answering outside its documented API: sign in again (mason login), so this token is replaced, and revoke it in the service's web app if it was shared",
123
+ next: [["mason login", "sign in again"]],
124
+ };
125
+ /**
126
+ * Hold `secrets` from here on, for `command`, whose answer is `--json` or not; again with more (login's new token)
127
+ * adds them. `passwords` false: this command prints a password it made (its answer's), so a password's shape is no
128
+ * secret in its output. `once`: a secret this command prints one time, whole (above), held as a secret beside it.
129
+ */
130
+ export function holdOutput(o) {
131
+ const secrets = [...(held?.secrets ?? []), ...o.secrets.filter(Boolean), ...(o.once ? [o.once] : [])];
132
+ held = { command: o.command, json: o.json, secrets, passwords: o.passwords ?? held?.passwords ?? true, once: o.once || (held?.once ?? null) };
133
+ }
134
+ /** Whether the gate held output back: the process then exits 1, whatever the command returned. */
135
+ export const outputRefused = () => refused;
136
+ export const gatedExit = (code) => refused ? 1 : code;
137
+ /** The gate, on process.stdout and process.stderr, once, before anything else wraps them (src/cli/main.ts). */
138
+ export function installOutputGate() {
139
+ if (originals !== null)
140
+ return;
141
+ originals = { out: process.stdout.write.bind(process.stdout), err: process.stderr.write.bind(process.stderr) };
142
+ const gate = (which) => ((chunk, ...rest) => {
143
+ const original = originals[which];
144
+ const text = typeof chunk === "string" ? chunk : chunk instanceof Uint8Array ? Buffer.from(chunk).toString("utf8") : String(chunk);
145
+ const done = rest.find((x) => typeof x === "function");
146
+ // nothing to write (exitWhenWritten's flush), or nothing held: as it always was; once refused, nothing more
147
+ if (text === "")
148
+ return original(chunk, ...rest);
149
+ if (refused) {
150
+ if (done)
151
+ setImmediate(done);
152
+ return true;
153
+ }
154
+ if (held === null)
155
+ return original(chunk, ...rest);
156
+ // the one value printed once, whole, the first time it is written: read as a separator (above)
157
+ const once = held.once === null ? null : onceAt(text, held.once);
158
+ const tested = once === null ? text : `${text.slice(0, once[0])} ${text.slice(once[1])}`;
159
+ const [own, both] = writesAfter([state[which], state.both], readingsOf(tested), held.secrets, { passwords: held.passwords });
160
+ if (!own.secret && !both.secret) {
161
+ state[which] = own.after;
162
+ state.both = both.after;
163
+ if (once !== null)
164
+ held = { ...held, once: null };
165
+ return original(chunk, ...rest);
166
+ }
167
+ refused = true;
168
+ const io = { out: (m) => { originals.out(`${m}\n`); }, err: (m) => { originals.err(`\n${m}\n`); } };
169
+ failed(held.command, REFUSAL, { json: held.json, io, style: serviceStyle(styleErr()) });
170
+ if (done)
171
+ setImmediate(done);
172
+ return true;
173
+ });
174
+ process.stdout.write = gate("out");
175
+ process.stderr.write = gate("err");
176
+ }
@@ -0,0 +1,96 @@
1
+ import { newSharePassword, shareSecret } from "./share-run.js";
2
+ import { probeShare, startTunnel, tunnelPlan } from "./tunnel.js";
3
+ /** How often the page beats, and how long a share waits for a beat before it ends (the header says why each). */
4
+ export const BEAT_MS = 15_000;
5
+ export const BOUND_MS = 120_000;
6
+ /** How long the tunnel has to give its address: `mason share`'s default. */
7
+ const TIMEOUT_MS = 30_000;
8
+ /** A tunnel's failure in the page's words: what to do there, never a flag of the command's. */
9
+ function pageFailure(f, plan) {
10
+ if (f.code === "not_found") {
11
+ return { message: `${plan.bin} is not on this machine, and Connect from anywhere needs it`, code: "not_found", why: "the temporary address is a tunnel opened over ssh",
12
+ fix: `install ${plan.install}, then Connect from anywhere again`, next: [["mason doctor", "checks this machine"]] };
13
+ }
14
+ return { message: f.message, code: f.code, ...(f.why !== undefined ? { why: f.why } : {}),
15
+ fix: f.message.startsWith("the tunnel gave no address") ? "the tunnel's service answered in a way this Mason cannot read: nothing was left open; mason share, in a terminal, offers another tunnel"
16
+ : `${f.fix ?? "is this machine online? Port 443 outbound is all it needs"}; nothing was left open`,
17
+ next: [["mason logs", "the run's log"]] };
18
+ }
19
+ export class PageShares {
20
+ deps;
21
+ /** the page's share open now: its user, the tunnel once up, and the timer its beats reset */
22
+ current = null;
23
+ constructor(deps) { this.deps = deps; }
24
+ get bound() { return this.deps.boundMs ?? BOUND_MS; }
25
+ /** The roles the page offers before it asks (`RunShares.choices`). */
26
+ choices() { return this.deps.shares.choices(); }
27
+ /** Opens the page's share: the login, the tunnel, the probe; refused, the failure, and nothing left open. */
28
+ async open(o) {
29
+ const { shares } = this.deps;
30
+ const password = newSharePassword();
31
+ const opened = await shares.open({ secret: shareSecret(password), role: o.role, from: "page" });
32
+ if ("failure" in opened)
33
+ return opened;
34
+ const user = opened.user;
35
+ const mine = { user, tunnel: null, timer: this.lapse(user) };
36
+ this.current = mine;
37
+ // however it ends (Stop sharing, no beat, its tunnel, the run stopping), the tunnel's client goes with it
38
+ shares.onEnd(user, () => { clearTimeout(mine.timer); mine.tunnel?.stop(); if (this.current === mine)
39
+ this.current = null; });
40
+ const plan = tunnelPlan("pinggy", { port: opened.port, home: this.deps.home, token: null });
41
+ const t = await (this.deps.start ?? startTunnel)(plan, { timeoutMs: this.deps.timeoutMs ?? TIMEOUT_MS, onPid: (pid) => { shares.update(user, { pid, via: plan.via, state: "opening" }); } });
42
+ if ("message" in t) {
43
+ await shares.close(user, "its tunnel did not open");
44
+ return { failure: pageFailure(t, plan) };
45
+ }
46
+ if (this.current !== mine) {
47
+ t.stop();
48
+ return { failure: this.endedWhileOpening(user) };
49
+ }
50
+ mine.tunnel = t;
51
+ const probe = await (this.deps.probe ?? probeShare)(t, opened.fingerprint);
52
+ if (this.current !== mine) {
53
+ t.stop();
54
+ return { failure: this.endedWhileOpening(user) };
55
+ }
56
+ if (probe === "other") {
57
+ await shares.close(user, "its address answered with another certificate");
58
+ return { failure: { message: `${t.host}:${t.port} is not this share: something else answers there`, code: "failed", why: "the tunnel gave that address, and the certificate there is not this share's",
59
+ fix: "nothing was left open. Do not connect a tool to that address", next: [["mason logs", "the run's log"]] } };
60
+ }
61
+ shares.update(user, { host: t.host, port: t.port, endsAt: t.endsAt, state: "up", pid: t.pid, via: plan.via });
62
+ void t.ended.then((why) => shares.close(user, `its tunnel closed (${why})`)).catch((e) => this.deps.log(`share: closing ${user} after its tunnel: ${e.message}`));
63
+ this.deps.log(`share: ${user} opened from the web page, at ${t.host}:${t.port}`);
64
+ return { user, host: t.host, port: t.port, database: opened.database, password, sslmode: "require", fingerprint: opened.fingerprint, role: opened.role, reads: opened.reads,
65
+ openedAt: new Date().toISOString(), endsAt: t.endsAt, verified: probe === "same", beatMs: BEAT_MS, boundMs: this.bound };
66
+ }
67
+ endedWhileOpening(user) {
68
+ const why = this.deps.shares.ended?.user === user ? this.deps.shares.ended.why : "it was stopped";
69
+ return { message: `the share ended while its tunnel opened: ${why}`, code: "failed", fix: "nothing was left open: Connect from anywhere again" };
70
+ }
71
+ /** The timer a beat resets: past the bound with no beat, the share ends. */
72
+ lapse(user) {
73
+ const t = setTimeout(() => {
74
+ void this.deps.shares.close(user, `the web page stopped checking in for ${Math.round(this.bound / 1000)} s (closed, asleep or gone)`)
75
+ .catch((e) => this.deps.log(`share: closing ${user}: ${e.message}`));
76
+ }, this.bound);
77
+ t.unref();
78
+ return t;
79
+ }
80
+ /** A beat from the page: true when `user` is the page's share and still open. */
81
+ beat(user) {
82
+ const c = this.current;
83
+ if (c === null || c.user !== user || this.deps.shares.fromOf(user) !== "page")
84
+ return false;
85
+ clearTimeout(c.timer);
86
+ c.timer = this.lapse(user);
87
+ return true;
88
+ }
89
+ /** Stop sharing: the page's share ended now. false: no such share of the page's is open. */
90
+ async stop(user) {
91
+ if (this.deps.shares.fromOf(user) !== "page")
92
+ return false;
93
+ await this.deps.shares.close(user, "Stop sharing, on the web page");
94
+ return true;
95
+ }
96
+ }
@@ -31,7 +31,7 @@ export function runListenerSettings(shell, o) {
31
31
  fix: "drop --self-signed-tls to serve your certificate, or unset MASON_PG_TLS_CERT and MASON_PG_TLS_KEY to serve a self-signed one" };
32
32
  }
33
33
  const s = pgListenerSettings({
34
- MASON_PG_HOST: shell.MASON_PG_HOST, MASON_PG_PORT: String(o.port), MASON_PG_PLAIN: shell.MASON_PG_PLAIN,
34
+ MASON_PG_HOST: shell.MASON_PG_HOST, MASON_PG_PORT: String(o.port), MASON_PG_PLAIN: shell.MASON_PG_PLAIN, MASON_PG_PROXY_FROM: shell.MASON_PG_PROXY_FROM,
35
35
  MASON_PG_TLS_CERT: o.selfSigned ? path.join(o.home, SELF_SIGNED_FILES.cert) : shell.MASON_PG_TLS_CERT,
36
36
  MASON_PG_TLS_KEY: o.selfSigned ? path.join(o.home, SELF_SIGNED_FILES.key) : shell.MASON_PG_TLS_KEY,
37
37
  }, "127.0.0.1");
@@ -42,22 +42,30 @@ export function runListenerSettings(shell, o) {
42
42
  ...(s.fix ? { fix: s.kind === "plain" ? `${s.fix}. Or mason start --self-signed-tls: a certificate made for this project in .mason/` : s.fix } : {}),
43
43
  ...(s.kind === "plain" ? { next: [["mason start --self-signed-tls", "TLS from a certificate made in .mason/ (clients connect with sslmode=require)"]] } : {}) };
44
44
  }
45
+ /** The name in every self-signed certificate: a certificate is handed to whoever connects, before any login, so it
46
+ * names no project. */
47
+ const SELF_SIGNED_NAME = "mason";
45
48
  /**
46
49
  * The project's self-signed certificate: the one in `.mason/` when it is still good (it parses, its key is its own, it
47
- * has not expired), else a new one, both files 0600. `said` is why a new one was made, for the run to print.
50
+ * has not expired, it names no project), else a new one, both files 0600. `said` is why a new one was made, for the run to print.
48
51
  */
49
- export function ensureSelfSigned(home, project, now = new Date()) {
52
+ export function ensureSelfSigned(home, now = new Date()) {
50
53
  const certFile = path.join(home, SELF_SIGNED_FILES.cert), keyFile = path.join(home, SELF_SIGNED_FILES.key);
51
54
  const kept = keptCertificate(certFile, keyFile, now);
52
55
  if ("fingerprint" in kept)
53
56
  return { fingerprint: kept.fingerprint, said: null };
54
- const made = selfSignedCertificate(`mason-${project}`, { now });
57
+ const made = selfSignedCertificate(SELF_SIGNED_NAME, { now });
55
58
  for (const [file, text] of [[keyFile, made.key], [certFile, made.cert]]) {
56
59
  fs.writeFileSync(file, text, { mode: 0o600 });
57
60
  fs.chmodSync(file, 0o600); // a file that was there keeps its mode through writeFileSync
58
61
  }
59
62
  return { fingerprint: new crypto.X509Certificate(made.cert).fingerprint256, said: kept.why };
60
63
  }
64
+ /** The project's self-signed certificate as a listener reads it: the two files of `ensureSelfSigned`, and what it said. */
65
+ export function selfSignedPair(home) {
66
+ const { fingerprint, said } = ensureSelfSigned(home);
67
+ return { cert: fs.readFileSync(path.join(home, SELF_SIGNED_FILES.cert)), key: fs.readFileSync(path.join(home, SELF_SIGNED_FILES.key)), fingerprint, said };
68
+ }
61
69
  function keptCertificate(certFile, keyFile, now) {
62
70
  if (!fs.existsSync(certFile) || !fs.existsSync(keyFile))
63
71
  return { why: `made a self-signed certificate: .mason/${SELF_SIGNED_FILES.cert}` };
@@ -65,6 +73,8 @@ function keptCertificate(certFile, keyFile, now) {
65
73
  const x = new crypto.X509Certificate(fs.readFileSync(certFile));
66
74
  if (!x.checkPrivateKey(crypto.createPrivateKey(fs.readFileSync(keyFile))))
67
75
  return { why: `made a new self-signed certificate: .mason/${SELF_SIGNED_FILES.key} was not the key of .mason/${SELF_SIGNED_FILES.cert}` };
76
+ if (x.subject !== `CN=${SELF_SIGNED_NAME}` || x.issuer !== `CN=${SELF_SIGNED_NAME}`)
77
+ return { why: "made a new self-signed certificate: the one in .mason/ named this project to whoever connected" };
68
78
  if (new Date(x.validTo).getTime() <= now.getTime())
69
79
  return { why: `made a new self-signed certificate: the one in .mason/ expired ${x.validTo}` };
70
80
  return { fingerprint: x.fingerprint256 };
@@ -40,6 +40,10 @@ export const withPlaceholders = (recorded) => recorded.replace(/^postgresql:\/\/
40
40
  export const withoutPlaceholders = (shown) => shown.replace(/^postgres:\/\/<user>:<password>@/, "postgresql://");
41
41
  /** The psql line for a URL, quoted for a shell. */
42
42
  export const psqlLine = (url) => `psql ${shellWord(url)}`;
43
+ /** WHERE A TOOL CONNECTS, as `mason status --json` and `/api/state` say it (`postgres`): the recorded URL's parts, the
44
+ * URL with the credentials' place shown (`shown`, what `mason start` and `mason status` print) and its psql line — one
45
+ * builder, so the web page's Connect panel and the commands print the same line. */
46
+ export const postgresFacts = (recorded) => ({ ...postgresLink(recorded), shown: withPlaceholders(recorded), psql: psqlLine(withPlaceholders(recorded)) });
43
47
  /** What a tool connecting to the listener should know: it listens on every address; its certificate is self-signed. */
44
48
  export function listenerTips(l) {
45
49
  if (l === null)
@@ -1,32 +1,37 @@
1
1
  /**
2
2
  * THE PROJECT'S VERSION PIN, as the mason command holds it (src/lib/project-version.ts has the rule and the image's
3
3
  * side): `mason init` writes `mason.yml` with this command's version, `mason start` writes it into a project made
4
- * before there was one, and `mason init` and `mason start` refuse a command of another release line — what the
5
- * developer runs is then what production's image is held to.
4
+ * before there was one, and `mason init`, `mason start` and a `mason serve` of the package refuse a command of another
5
+ * release line — what the developer runs is then what production is held to.
6
6
  */
7
7
  import * as fs from "node:fs";
8
8
  import * as path from "node:path";
9
- import { parseVersion, pinFileText, pinProblem, readPin, versionText, PIN_FILE } from "../lib/project-version.js";
9
+ import { parseVersion, pinFileText, projectPinProblem, PIN_FILE } from "../lib/project-version.js";
10
+ import { projectWriteRefusal } from "./project.js";
10
11
  /** Why this mason command may not run the project in `folder`, or null (a checkout's `dev` checks nothing). */
12
+ export function commandPinProblem(folder, cliVersion) {
13
+ return projectPinProblem(folder, cliVersion, { what: "this mason command",
14
+ run: (p) => `run the mason command it names: npm i -g @mason-data/cli@${p} (or npx @mason-data/cli@${p} …)` });
15
+ }
16
+ const sentence = (clause) => `${clause[0].toUpperCase()}${clause.slice(1)}.`;
17
+ /** The refusal as `mason init` and `mason start` say it: one line of sentences. */
11
18
  export function cliPinProblem(folder, cliVersion) {
12
- const running = parseVersion(cliVersion);
13
- if (running === null)
14
- return null;
15
- const read = readPin(folder);
16
- if ("problem" in read)
17
- return read.problem;
18
- if (read.pin === null)
19
+ const r = commandPinProblem(folder, cliVersion);
20
+ if (r === null)
19
21
  return null;
20
- const p = versionText(read.pin);
21
- return pinProblem(read.pin, running, read.file, "this mason command", `Run the mason command it names: npm i -g @mason-data/cli@${p} (or npx @mason-data/cli@${p} …).`);
22
+ const [run, ...others] = r.ways;
23
+ return run === undefined ? r.problem : [`${r.problem}.`, sentence(run), ...others.map((w) => sentence(`or ${w}`))].join(" ");
22
24
  }
23
25
  /**
24
26
  * Write `folder`'s mason.yml with this command's version when it has none; the file written, or null when there was one
25
- * already (or this is a checkout's `dev`, which has no version to write).
27
+ * already (or this is a checkout's `dev`, which has no version to write), or when the project's files are read-only here
28
+ * (src/cli/project.ts `projectWriteRefusal`, asked of `root`, the project's: a deployed project's pin is the one committed).
26
29
  */
27
- export function ensurePin(folder, cliVersion) {
30
+ export function ensurePin(folder, cliVersion, root, env = process.env) {
28
31
  if (parseVersion(cliVersion) === null)
29
32
  return null;
33
+ if (projectWriteRefusal(root, env) !== null)
34
+ return null;
30
35
  const file = path.join(folder, PIN_FILE);
31
36
  if (fs.existsSync(file))
32
37
  return null;
@@ -0,0 +1,85 @@
1
+ /**
2
+ * THE SIZE OF LIBUV'S POOL (issue 88). A statement in flight holds one of its threads for as long as DuckDB computes
3
+ * (docs/FLOWS.md "What waits, and where"), and at Node's four a fifth statement waits behind four heavy ones: 10 s to 50 s
4
+ * for a `SELECT 1`. libuv reads UV_THREADPOOL_SIZE once, when the first task reaches the pool, so this must run before
5
+ * anything uses it: CommonJS, loaded synchronously, by src/cli/mason.cjs and as main.ts's FIRST import. Nothing here may
6
+ * use the pool: every read is synchronous.
7
+ *
8
+ * WHY 32, MEASURED AND NOT DERIVED (docs/DESIGN.md "The pool"): Mason admits no fixed number of running statements. A
9
+ * session runs one at a time and CANCEL_SLOTS allows 4,096 sessions, past the 1,024 threads libuv allows, so no pool
10
+ * sits above that limit. The 1,024 itself is refused: libuv starts every thread at once and ABORTS the process with no
11
+ * message when one cannot be made (exit 133 under `--pids-limit=300`). 16 fixed five statements and 32 fixed eight; past
12
+ * eight there is no data, and the old queue was the only limit on how much memory statements take at once.
13
+ *
14
+ * A LIMIT ON THREADS IS READ FIRST, because that abort under a platform's restart is a silent loop. Below what Mason
15
+ * needs the size is lowered to fit and said; an operator's own size that does not fit refuses the start, as an invalid
16
+ * one does (`abc` would run 1 thread, `-1` 1,024). A size of 4 or less is never refused: it is Node's own, and what a
17
+ * run inherits from the `mason start` that lowered it.
18
+ *
19
+ * ON WINDOWS `process.env` never reaches libuv's getenv, so what this sets does nothing and the pool stays at 4:
20
+ * UV_THREADPOOL_SIZE must be set in the shell before `mason`.
21
+ */
22
+ "use strict";
23
+ const fs = require("node:fs");
24
+ const os = require("node:os");
25
+
26
+ const POOL_THREADS = 32;
27
+ const LIBUV_MAX = 1024;
28
+ /** Node's own size: never lowered past it */
29
+ const FLOOR = 4;
30
+ const CGROUP_LIMITS = ["/sys/fs/cgroup/pids.max", "/sys/fs/cgroup/pids/pids.max"];
31
+ const PROCESS_LIMITS = "/proc/self/limits";
32
+
33
+ /** A file's text; null when this machine has no such file, or it cannot be read: then it says no limit. */
34
+ function read(file) {
35
+ try { return fs.readFileSync(file, "utf8"); }
36
+ catch { return null; }
37
+ }
38
+
39
+ /** The lowest numeric limit on threads the files say; null when none does (`max`, `unlimited`, not Linux). */
40
+ function threadLimit(cgroupFiles, processLimits) {
41
+ const said = cgroupFiles.map((f) => /^\s*(\d+)\s*$/.exec(read(f) ?? "")?.[1]);
42
+ said.push(/^Max processes\s+(\d+)/m.exec(read(processLimits) ?? "")?.[1]);
43
+ const numbers = said.filter((n) => n !== undefined).map(Number);
44
+ return numbers.length === 0 ? null : Math.min(...numbers);
45
+ }
46
+
47
+ /**
48
+ * Sets UV_THREADPOOL_SIZE in `o.env` and returns the size, or refuses by `o.refuse`. Everything it reads is a parameter,
49
+ * for tests/cli-pool-size.test.ts. `o.announce`: say a lowered size (the commands that start a run; any other would
50
+ * repeat it on every call).
51
+ */
52
+ function sizePool(o = {}) {
53
+ const env = o.env ?? process.env;
54
+ const platform = o.platform ?? process.platform;
55
+ const cpus = o.cpus ?? os.availableParallelism();
56
+ const say = o.say ?? ((line) => process.stderr.write(`${line}\n`));
57
+ const refuse = o.refuse ?? ((line) => { say(line); process.exit(1); });
58
+ const limit = platform !== "linux" ? null : threadLimit(o.cgroupFiles ?? CGROUP_LIMITS, o.processLimits ?? PROCESS_LIMITS);
59
+ /** the threads of a run with a pool of `size`: its other threads are about 20 plus five a CPU (read off real runs) */
60
+ const needs = (size) => size + 20 + 5 * cpus;
61
+ const fitting = limit === null ? POOL_THREADS : Math.min(POOL_THREADS, Math.max(FLOOR, limit - needs(0)));
62
+
63
+ const own = env.UV_THREADPOOL_SIZE;
64
+ if (own) {
65
+ if (!/^[1-9]\d*$/.test(own) || Number(own) > LIBUV_MAX) {
66
+ refuse(`UV_THREADPOOL_SIZE=${JSON.stringify(own)} is not a number of threads from 1 to ${LIBUV_MAX}: set one, or unset it for Mason's ${POOL_THREADS}`);
67
+ return null;
68
+ }
69
+ const size = Number(own);
70
+ if (limit !== null && size > FLOOR && limit < needs(size)) {
71
+ refuse(`UV_THREADPOOL_SIZE=${own} does not fit: this machine allows ${limit} threads, Mason would need about ${needs(size)}, and Node exits with no message past the limit: set UV_THREADPOOL_SIZE=${fitting}`);
72
+ return null;
73
+ }
74
+ return size;
75
+ }
76
+ if (fitting < POOL_THREADS && o.announce === true) {
77
+ say(`this machine allows ${limit} threads and Mason wants about ${needs(POOL_THREADS)}, so it uses a pool of ${fitting}: set UV_THREADPOOL_SIZE=${fitting} to choose it, or another that fits`);
78
+ }
79
+ env.UV_THREADPOOL_SIZE = String(fitting);
80
+ return fitting;
81
+ }
82
+
83
+ sizePool({ announce: ["start", "serve"].includes(process.argv[2] ?? "") });
84
+
85
+ module.exports = { sizePool, POOL_THREADS };
@@ -11,6 +11,8 @@
11
11
  */
12
12
  import * as net from "node:net";
13
13
  import { DEFAULT_PG_PORT, pgListenerAddress } from "../api/postgres/listener.js";
14
+ /** The port the commands and the web page reach a run on, unless a flag or a setting says another. */
15
+ export const DEFAULT_UI_PORT = 4039;
14
16
  export const PROBE_ADDRESSES = ["127.0.0.1", "0.0.0.0", "::", "::1"];
15
17
  /** The address that holds `port`, or null when every address binds. */
16
18
  export async function portHolder(port) {