@mason-data/cli 0.7.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (111) hide show
  1. package/README.md +90 -71
  2. package/dist/src/api/error.js +3 -0
  3. package/dist/src/api/postgres/catalog.js +222 -13
  4. package/dist/src/api/postgres/encode.js +3 -1
  5. package/dist/src/api/postgres/explain.js +3 -2
  6. package/dist/src/api/postgres/protocol.js +39 -23
  7. package/dist/src/api/postgres/server.js +1173 -207
  8. package/dist/src/api/postgres/translate.js +81 -9
  9. package/dist/src/api/request-log.js +18 -5
  10. package/dist/src/cdc/poll.js +5 -5
  11. package/dist/src/cli/background.js +29 -6
  12. package/dist/src/cli/cancel.js +75 -0
  13. package/dist/src/cli/command.js +1 -0
  14. package/dist/src/cli/commands.js +622 -183
  15. package/dist/src/cli/control.js +59 -0
  16. package/dist/src/cli/demo.js +19 -10
  17. package/dist/src/cli/dev.js +351 -111
  18. package/dist/src/cli/doctor.js +20 -10
  19. package/dist/src/cli/guides.js +79 -31
  20. package/dist/src/cli/init.js +119 -58
  21. package/dist/src/cli/machine.js +12 -11
  22. package/dist/src/cli/main.js +3 -3
  23. package/dist/src/cli/mcp.js +29 -20
  24. package/dist/src/cli/metrics.js +79 -0
  25. package/dist/src/cli/pg-tls.js +13 -3
  26. package/dist/src/cli/pg-url.js +4 -0
  27. package/dist/src/cli/pin.js +13 -12
  28. package/dist/src/cli/preflight.js +8 -7
  29. package/dist/src/cli/project.js +43 -4
  30. package/dist/src/cli/readiness.js +38 -4
  31. package/dist/src/cli/registry.js +53 -34
  32. package/dist/src/cli/replication-checks.js +7 -5
  33. package/dist/src/cli/replication-mode.js +105 -26
  34. package/dist/src/cli/request-filter.js +134 -0
  35. package/dist/src/cli/roles.js +584 -0
  36. package/dist/src/cli/run-server.js +483 -41
  37. package/dist/src/cli/runs.js +1 -1
  38. package/dist/src/cli/security.js +10 -8
  39. package/dist/src/cli/serve.js +144 -42
  40. package/dist/src/cli/share-run.js +286 -0
  41. package/dist/src/cli/share.js +286 -0
  42. package/dist/src/cli/source-edit.js +65 -13
  43. package/dist/src/cli/sources.js +287 -38
  44. package/dist/src/cli/supervise.js +12 -1
  45. package/dist/src/cli/sync.js +55 -25
  46. package/dist/src/cli/tunnel.js +200 -0
  47. package/dist/src/cli/ui.js +9 -6
  48. package/dist/src/cli/usage.js +128 -0
  49. package/dist/src/cli/user.js +496 -40
  50. package/dist/src/cli/version.js +9 -7
  51. package/dist/src/demo/seed.js +3 -0
  52. package/dist/src/duck/cancel.js +10 -1
  53. package/dist/src/duck/events.js +134 -0
  54. package/dist/src/duck/executor-worker.js +18 -9
  55. package/dist/src/duck/executor.js +43 -30
  56. package/dist/src/duck/explain.js +56 -46
  57. package/dist/src/duck/extensions.js +160 -0
  58. package/dist/src/duck/functions.js +13 -14
  59. package/dist/src/duck/gaps.js +17 -8
  60. package/dist/src/duck/grants.js +176 -0
  61. package/dist/src/duck/history.js +123 -0
  62. package/dist/src/duck/kept.js +6 -6
  63. package/dist/src/duck/plan.js +102 -25
  64. package/dist/src/duck/publication.js +6 -6
  65. package/dist/src/duck/requests.js +265 -93
  66. package/dist/src/duck/rewrite.js +25 -5
  67. package/dist/src/duck/row-filter.js +649 -0
  68. package/dist/src/duck/store.js +156 -120
  69. package/dist/src/duck/stream.js +10 -5
  70. package/dist/src/duck/sync.js +835 -268
  71. package/dist/src/duck/types.js +6 -5
  72. package/dist/src/duck/usage.js +324 -0
  73. package/dist/src/duck/users.js +64 -15
  74. package/dist/src/duck/verified.js +3 -0
  75. package/dist/src/duck/views.js +2 -3
  76. package/dist/src/lib/database-name.js +62 -0
  77. package/dist/src/lib/dotenv.js +17 -4
  78. package/dist/src/lib/log-buffer.js +68 -0
  79. package/dist/src/lib/pg-numeric.js +5 -2
  80. package/dist/src/lib/project-version.js +23 -23
  81. package/dist/src/lib/table-sync.js +267 -0
  82. package/dist/src/web/app.css +706 -66
  83. package/dist/src/web/app.js +327 -410
  84. package/dist/src/web/charts.js +229 -0
  85. package/dist/src/web/index.html +82 -18
  86. package/dist/src/web/pages/queries.js +192 -0
  87. package/dist/src/web/pages/status.js +446 -0
  88. package/dist/src/web/pages/syncs.js +155 -0
  89. package/dist/src/web/pages/tables.js +434 -0
  90. package/dist/src/web/pages/usage.js +397 -0
  91. package/dist/src/web/pages/users.js +218 -0
  92. package/dist/src/web/shared.js +568 -0
  93. package/dist/src/web/vendor/README.md +25 -0
  94. package/dist/src/web/vendor/geist-1.7.2/Geist-Variable.woff2 +0 -0
  95. package/dist/src/web/vendor/geist-1.7.2/GeistMono-Variable.woff2 +0 -0
  96. package/dist/src/web/vendor/geist-1.7.2/LICENSE.txt +92 -0
  97. package/dist/src/web/vendor/lucide-1.56.0/LICENSE.txt +43 -0
  98. package/dist/src/web/vendor/lucide-1.56.0/chart-column.mjs +29 -0
  99. package/dist/src/web/vendor/lucide-1.56.0/layout-dashboard.mjs +29 -0
  100. package/dist/src/web/vendor/lucide-1.56.0/logs.mjs +34 -0
  101. package/dist/src/web/vendor/lucide-1.56.0/menu.mjs +28 -0
  102. package/dist/src/web/vendor/lucide-1.56.0/square-terminal.mjs +28 -0
  103. package/dist/src/web/vendor/lucide-1.56.0/table.mjs +29 -0
  104. package/dist/src/web/vendor/lucide-1.56.0/users.mjs +29 -0
  105. package/dist/src/web/vendor/lucide-1.56.0/x.mjs +27 -0
  106. package/dist/src/web/vendor/uplot-1.6.32/LICENSE.txt +21 -0
  107. package/dist/src/web/vendor/uplot-1.6.32/uPlot.esm.js +6140 -0
  108. package/dist/src/web/vendor/uplot-1.6.32/uPlot.min.css +1 -0
  109. package/dist/src/web/vendor/zilla-slab-5.3.0/LICENSE.txt +93 -0
  110. package/dist/src/web/vendor/zilla-slab-5.3.0/zilla-slab-latin-700-normal.woff2 +0 -0
  111. package/package.json +10 -3
@@ -32,7 +32,7 @@ export function readEnv(root, processEnv = process.env) {
32
32
  return env;
33
33
  }
34
34
  /**
35
- * A VARIABLE OF AN APP'S FOLDER (`mason init`'s DATABASE_URL, and a source file's `url: ${NAME}`,
35
+ * A VARIABLE OF AN APP'S FOLDER (`mason init`'s MASON_DATABASE_URL or DATABASE_URL, and a source file's `url: ${NAME}`,
36
36
  * src/cli/sources.ts): `name` from the folder's `.env.local`, then `.env` (an `export ` before a line is
37
37
  * allowed, as shells write it), then the environment — the order web frameworks read them in, the app's own files
38
38
  * first because they name the database this folder's app uses. A different question from readEnv's "this process's
@@ -56,10 +56,23 @@ export function appEnvValue(root, name, processEnv = process.env, o = {}) {
56
56
  const value = processEnv[name];
57
57
  return value ? { value, from: "the shell", file: null } : null;
58
58
  }
59
- /** The app's DATABASE_URL (`appEnvValue`): what `mason init` reads when no URL is given. */
59
+ /**
60
+ * The variables `mason init` reads a database's URL from when none is given, in the order it reads them: Mason's own
61
+ * first, so a developer whose app's DATABASE_URL names another database can aim Mason elsewhere; then the app's.
62
+ */
63
+ export const DATABASE_URL_VARIABLES = ["MASON_DATABASE_URL", "DATABASE_URL"];
64
+ /**
65
+ * What `mason init` reads when no URL is given: the first of `DATABASE_URL_VARIABLES` that is set, each read as
66
+ * `appEnvValue` reads it (`.env.local`, `.env`, then the shell). `variable` is the one read — the variable the sources
67
+ * file then names; `from` says it with where it was found (`MASON_DATABASE_URL in .env`, `DATABASE_URL in the shell`).
68
+ */
60
69
  export function appDatabaseUrl(root, processEnv = process.env) {
61
- const v = appEnvValue(root, "DATABASE_URL", processEnv);
62
- return v === null ? null : { url: v.value, from: v.file ?? "the shell's DATABASE_URL", file: v.file };
70
+ for (const variable of DATABASE_URL_VARIABLES) {
71
+ const v = appEnvValue(root, variable, processEnv);
72
+ if (v !== null)
73
+ return { url: v.value, variable, from: `${variable} in ${v.from}`, file: v.file };
74
+ }
75
+ return null;
63
76
  }
64
77
  /**
65
78
  * `root/.env` with `line` (`NAME=value`) added under a `# comment`, the file made when there is none — owner-only
@@ -0,0 +1,68 @@
1
+ /**
2
+ * THE RUN'S LOG AS ENTRIES, kept in memory and read back from the file: ONE shape, `{ at, text }`, for both.
3
+ *
4
+ * An entry is what `textEntry` (src/lib/log.ts) writes: a head line (the ISO time, then `[component] message`) and the
5
+ * message's further lines INDENTED under it. A line at the margin starts an entry, an indented one continues it, so a
6
+ * multi-line reason (a stack, a DuckDB error's candidates) is ONE entry here, never its first line and a pile of
7
+ * orphans. `at` is the head's time (null for a line that has none: `mason start`'s terminal, dev.log); `text` is the rest
8
+ * as written, continuation lines included.
9
+ *
10
+ * `LogBuffer` is fed by the one place the run writes its log (src/cli/dev.ts `logLine`: mason.log under `mason start`,
11
+ * stdout under `mason serve`), so both modes have it; the command server serves it (`GET /api/logs`). `mason logs` reads
12
+ * the FILE with `logEntries`, the same parser, so a line is the same `{ at, text }` from either.
13
+ */
14
+ /**
15
+ * How many entries the run keeps: a startup and a copy of a hundred tables log a few thousand lines, and an agent asking
16
+ * "what just happened" wants the last minutes, not the file (which `mason logs` reads whole). 2000 entries at a few hundred
17
+ * bytes is under a megabyte of memory, however long the run lives.
18
+ */
19
+ export const LOG_KEPT = 2000;
20
+ const HEAD = /^(\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d(?:\.\d+)?Z) /;
21
+ /**
22
+ * Text (a file's, or one entry as `textEntry` wrote it) as entries: a margin line starts one, an indented line continues it.
23
+ * `perLine` is a terminal's text (dev.log, `mason start`'s): a line each, a BLANK one kept as `{ at: null, text: "" }` so it
24
+ * prints as written. Without it (mason.log, the buffer) a blank line is no entry: `textEntry` writes none, and one would
25
+ * be an empty entry counted by `-n`.
26
+ */
27
+ export function logEntries(text, o = {}) {
28
+ const out = [];
29
+ const lines = text.split("\n");
30
+ if (lines[lines.length - 1] === "")
31
+ lines.pop(); // the file's last newline ends its last line, it does not start another
32
+ for (const line of lines) {
33
+ if (line.trim() === "") {
34
+ if (o.perLine)
35
+ out.push({ at: null, text: "" });
36
+ continue;
37
+ }
38
+ const last = out[out.length - 1];
39
+ if (!o.perLine && last !== undefined && /^\s/.test(line)) {
40
+ last.text += `\n${line.trimEnd()}`;
41
+ continue;
42
+ }
43
+ const m = HEAD.exec(line);
44
+ out.push(m ? { at: m[1], text: line.slice(m[0].length).trimEnd() } : { at: null, text: line.trimEnd() });
45
+ }
46
+ return out;
47
+ }
48
+ /** An entry as the file has it: the time, a space, the text. */
49
+ export const logLineText = (l) => (l.at === null ? l.text : `${l.at} ${l.text}`);
50
+ /** The last `capacity` entries, oldest dropped first. */
51
+ export class LogBuffer {
52
+ capacity;
53
+ kept = [];
54
+ constructor(capacity = LOG_KEPT) { this.capacity = capacity; }
55
+ /** An entry as `textEntry` wrote it (one head line, the rest indented): kept as ONE entry. */
56
+ push(entry) {
57
+ for (const l of logEntries(entry)) {
58
+ this.kept.push(l);
59
+ if (this.kept.length > this.capacity)
60
+ this.kept.shift();
61
+ }
62
+ }
63
+ /** The last `lines` entries, oldest first; with `since` (an ISO time) only those after it. */
64
+ recent(o) {
65
+ const from = o.since === undefined ? this.kept : this.kept.filter((l) => l.at !== null && l.at > o.since);
66
+ return o.lines <= 0 ? [] : from.slice(-o.lines);
67
+ }
68
+ }
@@ -25,10 +25,13 @@
25
25
  * Pure and dependency-free: tests/pg-numeric.test.ts holds every operation to PGlite's own numeric over generated values.
26
26
  */
27
27
  import { Buffer } from "node:buffer";
28
+ import { gapMark } from "../duck/gaps.js";
28
29
  /** A value this module cannot compute as Postgres does, or an operation Postgres refuses: with Postgres's SQLSTATE. */
29
30
  export class NumericError extends Error {
30
31
  sqlstate;
31
- constructor(message, sqlstate) { super(message); this.sqlstate = sqlstate; }
32
+ /** the registry entry that refused it (src/duck/gaps.ts): the request log's `gap`; null for Postgres's own error */
33
+ gap;
34
+ constructor(message, sqlstate, gap = null) { super(message); this.sqlstate = sqlstate; this.gap = gap; }
32
35
  }
33
36
  /** Postgres's limits on a numeric's scale (numeric.h): a quotient's scale is held between these */
34
37
  const NUMERIC_MIN_SIG_DIGITS = 16;
@@ -475,7 +478,7 @@ function specialOf(p, row) {
475
478
  for (const key of cellsOf(p)) {
476
479
  const v = row[key];
477
480
  if (typeof v === "string" && SPECIAL.test(v))
478
- throw new NumericError(`arithmetic on a numeric ${v} is not supported by Mason (Mason gap numeric-special)`, "0A000");
481
+ throw new NumericError(`arithmetic on a numeric ${v} is not supported by Mason (${gapMark("numeric-special")})`, "0A000", "numeric-special");
479
482
  }
480
483
  return undefined;
481
484
  }
@@ -7,10 +7,11 @@
7
7
  *
8
8
  * Before 1.0 a minor version may change the project's format with no compatibility (CLAUDE.md "No backwards
9
9
  * compatibility before v1.0"), so a project written for 0.5 and run by 0.6 would fail on whatever key moved, in words
10
- * that never say the versions differ. The pin turns that into one refusal that does: the image of the ClickHouse stack
11
- * this was cut from refused to start on a project of another minor version, or of a newer patch than itself
12
- * (`imagePinProblem` below), and `mason init` / `mason start` refuse a CLI that is (src/cli/pin.ts). The way out is
13
- * always the same pair: run the version the project names, or read the release notes and move the pin.
10
+ * that never say the versions differ. The pin turns that into one refusal that does: the image refuses to start on a
11
+ * project of another release line, or of a newer patch than itself (`imagePinProblem` below), and the mason command
12
+ * refuses one too (src/cli/pin.ts: `mason init`, `mason start`, and `mason serve` run from the package). ONE rule (`pinProblem`), in
13
+ * each reader's words. The way out is always the same pair: run the version the project names, or read the release notes
14
+ * and move the pin.
14
15
  *
15
16
  * A build that is no release (`dev` from a checkout, `main-<sha>` from main) checks nothing: it has no version to hold
16
17
  * a project to. A project with no `mason.yml` (made by an older `mason init`) is unpinned and runs; `mason start` writes one.
@@ -40,37 +41,36 @@ export function sameLine(a, b) {
40
41
  const older = (a, b) => a.major !== b.major ? a.major < b.major : a.minor !== b.minor ? a.minor < b.minor : a.patch < b.patch;
41
42
  /**
42
43
  * Why `running` (what reads the project: the image, or the CLI) may not run a project pinned to `pin`, or null.
43
- * Allowed: the same release line, at the pin's patch or later. `what` names the reader in the message ("the mason
44
- * image", "this mason command"); `run` says how to run the pinned version instead.
44
+ * Allowed: the same release line, at the pin's patch or later.
45
45
  */
46
- export function pinProblem(pin, running, file, what, run) {
46
+ function pinProblem(pin, running, file, reader) {
47
47
  if (sameLine(pin, running) && !older(running, pin))
48
48
  return null;
49
- const p = versionText(pin), r = versionText(running);
49
+ const p = versionText(pin), r = versionText(running), { what } = reader, run = reader.run(p);
50
50
  if (sameLine(pin, running)) {
51
- return `${file} pins Mason ${p}, and ${what} is ${r}, older than it: the project may use a fix or a key ${r} does not have. ${run}`;
51
+ return { problem: `${file} pins Mason ${p}, and ${what} is ${r}, older than it: the project may use a fix or a key ${r} does not have`, ways: [run] };
52
52
  }
53
53
  const line = pin.major > 0 ? `${pin.major}.x` : `${pin.major}.${pin.minor}.x`;
54
54
  return older(running, pin)
55
- ? `${file} pins Mason ${p}, and ${what} is ${r}: the project is written for a newer Mason. ${run}`
56
- : `${file} pins Mason ${p}, and ${what} is ${r}, a newer release line than ${line}, which may read the project differently. ` +
57
- `${run} Or upgrade the project: read the release notes of every version after ${p} up to ${r} (their "Before you upgrade"), ` +
58
- `change what they say, then set version: ${r} in ${path.basename(file)}.`;
55
+ ? { problem: `${file} pins Mason ${p}, and ${what} is ${r}: the project is written for a newer Mason`, ways: [run] }
56
+ : { problem: `${file} pins Mason ${p}, and ${what} is ${r}, a newer release line than ${line}, which may read the project differently`,
57
+ ways: [run, `upgrade the project: read the release notes of every version after ${p} up to ${r} (their "Before you upgrade"), ` +
58
+ `change what they say, then set version: ${r} in ${path.basename(file)}`] };
59
59
  }
60
60
  /**
61
- * The image's check, as the ClickHouse stack's service ran it at its start: the project's pin against the image's
62
- * MASON_VERSION, or null.
63
- * An image that is no release (`dev`, `main-<sha>`) holds no project to a version.
61
+ * THE ONE CHECK: the project's pin against the version of what reads it, or null. A build that is no release (`dev`,
62
+ * `main-<sha>`) holds no project to a version, and reads no mason.yml.
64
63
  */
65
- export function imagePinProblem(project, imageVersion) {
66
- const running = parseVersion(imageVersion ?? "");
64
+ export function projectPinProblem(project, version, reader) {
65
+ const running = parseVersion(version);
67
66
  if (running === null)
68
67
  return null;
69
68
  const read = readPin(project);
70
69
  if ("problem" in read)
71
- return read.problem;
72
- if (read.pin === null)
73
- return null;
74
- const p = versionText(read.pin);
75
- return pinProblem(read.pin, running, read.file, "this mason image", `Run the image the project names (ghcr.io/embeddable-hq/mason-data:${p}).`);
70
+ return { problem: read.problem, ways: [] };
71
+ return read.pin === null ? null : pinProblem(read.pin, running, read.file, reader);
72
+ }
73
+ /** The image's check: the words of someone running a container. */
74
+ export function imagePinProblem(project, imageVersion) {
75
+ return projectPinProblem(project, imageVersion, { what: "this mason image", run: (p) => `run the image the project names (ghcr.io/embeddable-hq/mason-data:${p})` });
76
76
  }
@@ -0,0 +1,267 @@
1
+ /**
2
+ * HOW A TABLE IS KEPT CURRENT AFTER ITS FIRST COPY: the sources file's `sync:` (GitHub issue 33), at the top of the
3
+ * file as every table's default and on an entry for that table alone. ONE grammar, read by the one reader of the file
4
+ * (src/cli/sources.ts `parseSourceText`) and by `mason add --sync` (src/cli/commands.ts), so a value is taken or refused
5
+ * in the same words at both entrances:
6
+ *
7
+ * off nothing after the first copy until `mason sync <table>`, which polls it once, now (the default)
8
+ * poll <interval> the xmin poll every <interval>: <n>s, <n>m or <n>h, a whole number, at least 1s, at most 24h
9
+ * replication the database's change log, through the project's ONE slot and publication (src/duck/stream.ts):
10
+ * the publication carries the replicated tables only, and the polled ones are polled beside it
11
+ *
12
+ * The engine never decides how a table is kept current by its size: the row count matters only to `mason add`, which
13
+ * writes `sync: off` for a table over `BIG_TABLE_ROWS` when the file's default polls (src/cli/source-edit.ts).
14
+ */
15
+ import { closest } from "./did-you-mean.js";
16
+ /** the sync a table has when neither its entry nor the file says one: `off` (issue 33: Mason reads each table once,
17
+ * then only what you tell it to) */
18
+ export const DEFAULT_SYNC = { mode: "off" };
19
+ /** the shortest interval a poll takes: below a second a full scan of the table on the source is the source's whole time */
20
+ export const MIN_POLL_SECONDS = 1;
21
+ /** the longest: a day. Longer is `off` with `mason sync <table>` from a scheduler (and an instant past a Date's range,
22
+ * a review finding: `poll 9999999999999s` broke every status line) */
23
+ export const MAX_POLL_SECONDS = 86_400;
24
+ /**
25
+ * A TABLE THIS BIG IS NOT POLLED UNLESS ITS ENTRY SAYS SO (it was the run's POLL_ROW_LIMIT, which left such a table
26
+ * copied and never polled, a rule found only by its effect): a poll scans its table whole on the source (no index covers
27
+ * xmin) and counts it, and on a test server a 22 M-row table cost the production replica 12 s of scan and 2 s of count
28
+ * at every poll. So `mason add` of a table the catalog estimates over this many rows, under a file whose default polls,
29
+ * writes `sync: off` on its entry and says so with the alternatives. The stream applies a batch that changes a table
30
+ * this big by time as well (src/duck/stream.ts `applySize`): each write by key is a scan of its key column.
31
+ */
32
+ export const BIG_TABLE_ROWS = 1_000_000;
33
+ /** above this many rows a poll under `COSTLY_POLL_SECONDS` is warned of at `mason add`, with an estimate of its cost */
34
+ export const COSTLY_POLL_ROWS = 10_000_000;
35
+ export const COSTLY_POLL_SECONDS = 60;
36
+ const MODES = ["off", "poll", "replication"];
37
+ const UNIT_SECONDS = { s: 1, m: 60, h: 3600 };
38
+ /** the units a person may write for one of ours, said back as the one to write */
39
+ const UNIT_WORDS = {
40
+ s: "s", sec: "s", secs: "s", second: "s", seconds: "s", m: "m", min: "m", mins: "m", minute: "m", minutes: "m",
41
+ h: "h", hr: "h", hrs: "h", hour: "h", hours: "h",
42
+ };
43
+ const EXAMPLES = "poll 5s, poll 5m or poll 1h";
44
+ /** what a sync is, said where one is wrong */
45
+ export const SYNC_FORMS = `off, poll <interval> (${EXAMPLES}) or replication`;
46
+ /** the value as the file writes it: `off`, `poll 30s` */
47
+ export function syncText(s) {
48
+ return s.mode === "poll" ? `poll ${s.every}` : s.mode;
49
+ }
50
+ /** a span of seconds as an interval is written, in the largest unit it is a whole number of: 90 is `90s`, 120 `2m` */
51
+ export function intervalOf(seconds) {
52
+ const s = Math.max(MIN_POLL_SECONDS, Math.round(seconds));
53
+ return s % 3600 === 0 ? `${s / 3600}h` : s % 60 === 0 ? `${s / 60}m` : `${s}s`;
54
+ }
55
+ /** an interval (`<n>s|m|h`) read, or why it is not one, with the interval to write when there is one */
56
+ function parseInterval(v) {
57
+ 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})`;
59
+ if (m === null)
60
+ return { problem: `${JSON.stringify(v)} is not an interval: ${how}` };
61
+ const n = Number(m[1]), word = m[2].toLowerCase();
62
+ 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` };
64
+ 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
+ }
69
+ const seconds = n * UNIT_SECONDS[unit];
70
+ if (!Number.isInteger(n))
71
+ return { problem: `${v}: an interval is a whole number — write poll ${intervalOf(seconds)}` };
72
+ 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` };
74
+ 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` };
76
+ // the number as a number is written (`05s` is `5s`): two spellings of one interval would read as a change of sync
77
+ const canonical = `${n}${unit}`;
78
+ if (canonical !== v)
79
+ return { problem: `${JSON.stringify(v)}: write poll ${canonical}` };
80
+ return { every: canonical, seconds };
81
+ }
82
+ /**
83
+ * 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
+ * the line to write, a `did you mean` for a misspelt mode, the floor for a short interval. Whether a table CAN be
85
+ * replicated (a primary key, a replica identity) is the catalog's, said at the entry (src/cli/sources.ts entryProblems).
86
+ */
87
+ export function parseSync(v) {
88
+ if (typeof v !== "string" || v.trim() === "") {
89
+ const read = v === null || v === undefined ? "nothing" : typeof v === "boolean" ? `the boolean ${String(v)}` : typeof v === "number" ? `the number ${String(v)}` : "a list or a map";
90
+ return { problem: `is ${read}: write ${SYNC_FORMS}` };
91
+ }
92
+ const words = v.trim().split(/\s+/);
93
+ const mode = words[0], rest = words.slice(1).join(" ");
94
+ if (mode === "off")
95
+ return rest === "" ? { mode: "off" } : { problem: `off takes no interval: write off, or poll ${rest}` };
96
+ if (mode === "replication")
97
+ return rest === "" ? { mode: "replication" } : { problem: `replication takes no interval: write replication, or poll ${rest}` };
98
+ if (mode === "poll") {
99
+ if (rest === "")
100
+ return { problem: `poll needs its interval: write ${EXAMPLES} (at least ${MIN_POLL_SECONDS}s)` };
101
+ const i = parseInterval(rest);
102
+ return "problem" in i ? i : { mode: "poll", ...i };
103
+ }
104
+ // a misspelt mode: the line it may have meant, its interval kept when it reads as one
105
+ const near = closest(mode, MODES);
106
+ const meant = near === "poll" && rest !== "" && !("problem" in parseInterval(rest)) ? `poll ${rest}` : near;
107
+ // a question ends its sentence: "did you mean `poll 5s`? Write off, …"
108
+ return { problem: meant !== null ? `${mode} is not a sync — did you mean \`${meant}\`? Write ${SYNC_FORMS}` : `${mode} is not a sync: write ${SYNC_FORMS}` };
109
+ }
110
+ /** a sync for a person: `off`, `polled every 30s`, `replicated` */
111
+ export function syncWords(s) {
112
+ return s.mode === "off" ? "off" : s.mode === "replication" ? "replicated" : `polled every ${s.every}`;
113
+ }
114
+ /** how long ago an instant was, as a status line reads: `4 s ago`, `12 min ago`, `5 h ago`, `3 days ago` */
115
+ export function agoText(iso, now = Date.now()) {
116
+ const s = Math.max(0, Math.round((now - Date.parse(iso)) / 1000));
117
+ if (s < 90)
118
+ return `${s} s ago`;
119
+ if (s < 90 * 60)
120
+ return `${Math.round(s / 60)} min ago`;
121
+ if (s < 36 * 3600)
122
+ return `${Math.round(s / 3600)} h ago`;
123
+ const d = Math.round(s / 86_400);
124
+ return `${d} day${d === 1 ? "" : "s"} ago`;
125
+ }
126
+ /**
127
+ * THE EDIT THAT MAKES THE TOP'S `key:` SAY ONE OF `values`, in words a person (or an agent) applies as written: change
128
+ * the line the file has, keep one that already says it, or write it when the file has none. `anyPoll`: any
129
+ * `poll <interval>` already written is kept (for "poll it", whatever the interval).
130
+ */
131
+ export function topEdit(top, key, values, anyPoll = false) {
132
+ const now = top[key] === null ? null : top[key].trim().replace(/\s+/g, " ");
133
+ const said = (v) => now !== null && (now === v || (anyPoll && v.startsWith("poll ") && now.startsWith("poll ")));
134
+ const kept = values.find(said);
135
+ if (kept !== undefined && now !== null)
136
+ return `keep ${key}: ${now} at the top of ${top.shown}`;
137
+ const to = values.map((v) => `${key}: ${v}`).join(" or ");
138
+ return now === null ? `write ${to} at the top of ${top.shown}` : `change ${key}: ${now} at the top of ${top.shown} to ${to}`;
139
+ }
140
+ /**
141
+ * `MASON_POLL_MS` SET IS REFUSED, never run on a default (CLAUDE.md: a renamed setting refuses the start, naming the
142
+ * line to write): how often a table is polled is the sources file's `sync:`, per table. Null when it is unset; else what
143
+ * is wrong, why and the way out — a Failure's fields for `mason start` (before it spawns, and the run itself), joined on
144
+ * one line of `mason serve`'s settings report (`pollMsRetiredText`). `top` is read only when it is set.
145
+ */
146
+ export function pollMsRetired(env, top) {
147
+ const v = env.MASON_POLL_MS;
148
+ if (v === undefined || v === "")
149
+ return null;
150
+ const ms = Number(v);
151
+ const every = `poll ${Number.isFinite(ms) && ms >= MIN_POLL_SECONDS * 1000 ? intervalOf(Math.min(ms / 1000, MAX_POLL_SECONDS)) : "5s"}`;
152
+ return { message: `MASON_POLL_MS=${v} is no longer read`, why: "how often each table is polled is its sync: in the sources file",
153
+ fix: `${topEdit(top(), "sync", [every])} (every table; a table's entry may say its own), then unset MASON_POLL_MS` };
154
+ }
155
+ /** the same, as one line */
156
+ export function pollMsRetiredText(env, top) {
157
+ const r = pollMsRetired(env, top);
158
+ return r === null ? null : `${r.message}: ${r.why} — ${r.fix}`;
159
+ }
160
+ /** what every retired way of choosing a whole project's changes is told: where it is said now, so what runs is what is
161
+ * committed — how each table is kept current (`sync`), the publication replication reads (`publication`), or both */
162
+ function saidInTheFile(what) {
163
+ const said = [...(what.sync ? ["how each table is kept current is its sync: in the sources file (at the top for every table, on an entry for that one)"] : []),
164
+ ...(what.publication ? [`the publication replication reads is ${what.sync ? "its" : "the sources file's"} publication:`] : [])];
165
+ return `${said.join(", and ")}, so what runs is what is committed`;
166
+ }
167
+ /**
168
+ * `mason start --replication`, `--poll` AND `--publication <name>` ARE GONE (issue 33, PR 3; no backwards compatibility
169
+ * before v1.0): each answers, without starting, with the edit to make at the top of the sources file (`top`, read only
170
+ * when a removed flag is given) — its `sync:` line to `replication` or `poll 5m`, its `publication:` to the name —
171
+ * every one given on ONE report, one coherent way out (a publication goes with replication). Null when none is given.
172
+ * Read from the raw command line, before the options are parsed, so a removed flag is answered as removed and never
173
+ * as an unknown option.
174
+ */
175
+ export function removedStartFlags(argv, top) {
176
+ let replication = false, poll = false, publication;
177
+ for (let i = 0; i < argv.length; i++) {
178
+ const a = argv[i];
179
+ if (a === "--")
180
+ break;
181
+ if (a === "--replication" || a.startsWith("--replication="))
182
+ replication = true;
183
+ else if (a === "--poll" || a.startsWith("--poll="))
184
+ poll = true;
185
+ else if (a.startsWith("--publication="))
186
+ publication = a.slice("--publication=".length) || null;
187
+ else if (a === "--publication") {
188
+ const v = argv[i + 1];
189
+ publication = v !== undefined && !v.startsWith("-") ? v : null;
190
+ }
191
+ }
192
+ const given = [...(replication ? ["--replication"] : []), ...(poll ? ["--poll"] : []), ...(publication !== undefined ? ["--publication"] : [])];
193
+ if (given.length === 0)
194
+ return null;
195
+ const t = top();
196
+ const named = given.length === 1 ? `${given[0]} is gone` : `${given.slice(0, -1).join(", ")} and ${given.at(-1)} are gone`;
197
+ const pub = publication === undefined ? null : topEdit(t, "publication", [publication ?? "<name>"]);
198
+ let fix;
199
+ if (replication) {
200
+ // every table from the change log (or polled, when --poll said that too), and the publication beside it
201
+ const sync = topEdit(t, "sync", poll ? ["replication", "poll 5m"] : ["replication"]);
202
+ // two lines to write at the same top are one instruction: "write sync: replication and publication: app_pub at the top of …"
203
+ const at = ` at the top of ${t.shown}`;
204
+ const both = pub !== null && !poll && sync.startsWith("write ") && pub.startsWith("write ") && sync.endsWith(at) && pub.endsWith(at);
205
+ fix = both ? `${sync.slice(0, -at.length)} and ${pub.slice("write ".length)}`
206
+ : `${sync}${poll ? " (or each table's own on its entry)" : ""}${pub ? `, and ${pub}` : ""}`;
207
+ }
208
+ else if (pub !== null) {
209
+ // a publication is read only by replication: it names the tables that are replicated, the others are polled or off
210
+ fix = `${poll ? "a publication goes with replication: " : ""}${pub}, with sync: replication on the entries of the tables it carries` +
211
+ `${poll ? " and sync: poll 5m (any interval) on the others" : ""}`;
212
+ }
213
+ else {
214
+ const edit = topEdit(t, "sync", ["poll 5m"], true);
215
+ fix = `${edit} (${edit.startsWith("keep ") ? "every table is polled already; a table's entry may say its own"
216
+ : "any interval: poll 5s, poll 1h; or sync: off, the default, and mason sync <table> when you need it"})`;
217
+ }
218
+ return { message: `${named}: the sources file says it now`, why: saidInTheFile({ sync: replication || poll, publication: publication !== undefined }),
219
+ fix: `${fix}, then mason start` };
220
+ }
221
+ /**
222
+ * `MASON_CHANGES` SET IS REFUSED, never run on a default (CLAUDE.md: a removed setting refuses the start, naming the
223
+ * edit to make): how each table is kept current is the sources file's `sync:` (`top`, read only when it is set). Null
224
+ * when it is unset; else a Failure's fields, joined on one line of `mason serve`'s settings report
225
+ * (`changesRetiredText`).
226
+ */
227
+ export function changesRetired(env, top) {
228
+ const v = env.MASON_CHANGES;
229
+ if (v === undefined || v === "")
230
+ return null;
231
+ const edit = v === "replication" ? topEdit(top(), "sync", ["replication"]) : v === "poll" ? topEdit(top(), "sync", ["poll 5m"], true)
232
+ : topEdit(top(), "sync", ["replication", "poll 5m"]);
233
+ return { message: `MASON_CHANGES=${v} is no longer read`, why: saidInTheFile({ sync: true, publication: false }),
234
+ fix: `${edit} (every table; a table's entry may say its own), then unset MASON_CHANGES` };
235
+ }
236
+ /** the same, as one line */
237
+ export function changesRetiredText(env, top) {
238
+ const r = changesRetired(env, top);
239
+ return r === null ? null : `${r.message}: ${r.why} — ${r.fix}`;
240
+ }
241
+ /**
242
+ * A DURATION IN MILLISECONDS as a person reads it, the one formatter of a statement's, a sync's or a percentile's time
243
+ * (the web page's `msText`, src/web/shared.js, held equal by the tests): `512 ms` under a second, then `13.7 s`, then
244
+ * `2 min 5 s`, then `1 h 4 min` (a 13,703 ms statement read as thirteen thousand of something).
245
+ */
246
+ export function msText(ms) {
247
+ const v = Math.max(0, ms);
248
+ if (Math.round(v) < 1000)
249
+ return `${Math.round(v)} ms`;
250
+ const tenths = Math.round(v / 100);
251
+ if (tenths < 600)
252
+ return `${(tenths / 10).toFixed(1)} s`;
253
+ const s = Math.round(v / 1000);
254
+ if (s < 3600)
255
+ return `${Math.floor(s / 60)} min${s % 60 ? ` ${s % 60} s` : ""}`;
256
+ const m = Math.round(s / 60);
257
+ return `${Math.floor(m / 60)} h${m % 60 ? ` ${m % 60} min` : ""}`;
258
+ }
259
+ /**
260
+ * WHAT A SYNC DID TO ONE TABLE, in the words `mason sync` prints on its line and the web page's Sync now says
261
+ * (src/cli/sync.ts `syncedReport`, src/web/pages/tables.js, held equal) — and a sync by hand records as its `synced`
262
+ * event (src/duck/sync.ts pollNow), so the event and the answer the person read are one sentence.
263
+ */
264
+ export function syncedText(t) {
265
+ const n = (x) => x.toLocaleString("en-US");
266
+ return `${n(t.changed)} changed, ${n(t.deleted)} deleted, ${n(t.rows)} rows now · ${msText(t.ms)}`;
267
+ }