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