@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
|
@@ -10,49 +10,103 @@
|
|
|
10
10
|
*/
|
|
11
11
|
import * as os from "node:os";
|
|
12
12
|
import { PgConnection, PgError } from "../cdc/pg-wire.js";
|
|
13
|
-
import { parseDatabaseUrl } from "../lib/database-url.js";
|
|
13
|
+
import { parseDatabaseUrl, queryProblem } from "../lib/database-url.js";
|
|
14
14
|
/** A host on this machine: polling it costs nothing that leaves the machine. */
|
|
15
15
|
export function isLocalHost(host) {
|
|
16
16
|
const h = host.toLowerCase();
|
|
17
17
|
return h === "localhost" || h === "::1" || h === "0.0.0.0" || /^127\./.test(h) || h === "host.docker.internal" || h.endsWith(".localhost");
|
|
18
18
|
}
|
|
19
|
-
/**
|
|
20
|
-
|
|
19
|
+
/**
|
|
20
|
+
* THE ONE WAY A VALUE OF THE SOURCE'S URL SETTING IS SHOWN (issue 541), wherever Mason prints, logs or answers it: the URL
|
|
21
|
+
* with its password replaced by `****`, or null — never shown. It fails closed, so a secret masked by recognising its
|
|
22
|
+
* shape cannot leak in another shape: shown only when the value parses WHOLE as a postgres:// URL with a host, nothing
|
|
23
|
+
* dropped or rewritten by the parse (no space or control character inside it, which a parse strips or encodes; one at
|
|
24
|
+
* either end, a secret file's last newline, is trimmed as the parse trims it), no `#` at all and no `@` after the host
|
|
25
|
+
* (a password holding `/`, `?` or `#` unencoded splits the authority there, and its tail would be shown as a path, a
|
|
26
|
+
* parameter or a fragment), and a query the URL is read by whole (src/lib/database-url.ts queryProblem: only the
|
|
27
|
+
* parameters read, each once, each value one the reading accepts — libpq takes any connection keyword there,
|
|
28
|
+
* `?password=…` included, and a value can hold one, `sslmode=prefer%26password=…`), so no part shown is one not read;
|
|
29
|
+
* the query shown is the one read, written out again. A keyword/value string, a bare password, any other URL: null.
|
|
30
|
+
*/
|
|
31
|
+
export function shownUrl(value) {
|
|
32
|
+
const v = value.trim();
|
|
33
|
+
if (/[\s\x00-\x1f\x7f#]/.test(v))
|
|
34
|
+
return null;
|
|
35
|
+
let u;
|
|
21
36
|
try {
|
|
22
|
-
|
|
23
|
-
if (u.password)
|
|
24
|
-
u.password = "****";
|
|
25
|
-
return u.toString();
|
|
37
|
+
u = new URL(v);
|
|
26
38
|
}
|
|
27
39
|
catch {
|
|
28
|
-
|
|
29
|
-
// was not encoded) — a host never holds an @
|
|
30
|
-
return url.replace(/:\/\/([^:@/]*):.*@/, "://$1:****@");
|
|
40
|
+
return null;
|
|
31
41
|
}
|
|
42
|
+
if (u.protocol !== "postgres:" && u.protocol !== "postgresql:")
|
|
43
|
+
return null;
|
|
44
|
+
if (!u.hostname || /@/.test(u.pathname + u.search))
|
|
45
|
+
return null;
|
|
46
|
+
if (queryProblem(u.searchParams) !== null)
|
|
47
|
+
return null;
|
|
48
|
+
u.search = u.searchParams.toString();
|
|
49
|
+
if (u.password)
|
|
50
|
+
u.password = "****";
|
|
51
|
+
return u.toString();
|
|
32
52
|
}
|
|
33
|
-
/**
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
53
|
+
/** A value of the source's URL setting in a sentence: `shownUrl`'s, or words that say it is not shown, never the value. */
|
|
54
|
+
export function maskedUrl(value) {
|
|
55
|
+
return shownUrl(value) ?? "a value that is not a postgres:// URL (not shown)";
|
|
56
|
+
}
|
|
57
|
+
const URL_FORM = "postgres://user:password@host:5432/your_app";
|
|
58
|
+
/** The schemes a wrong URL is named by: Mason's own list, so a scheme said is never a part of the value Mason did not know. */
|
|
59
|
+
const KNOWN_SCHEMES = new Set(["mysql", "mariadb", "mssql", "sqlserver", "oracle", "mongodb", "mongodb+srv", "redis", "rediss", "sqlite", "clickhouse",
|
|
60
|
+
"http", "https", "file", "pg", "pgsql", "psql", "postgis", "postgre", "postgress", "postgresq"]);
|
|
61
|
+
const ENCODE = "a password holding @ : / # ? % or a space is URL-encoded (encodeURIComponent; @ is %40, / is %2F, # is %23)";
|
|
62
|
+
/** What is wrong with the query of a postgres:// URL that reads whole but for it (queryProblem's words), else null. */
|
|
63
|
+
function queryOf(t) {
|
|
64
|
+
if (!/^postgres(ql)?:\/\//i.test(t) || /[\s\x00-\x1f\x7f#]/.test(t))
|
|
65
|
+
return null;
|
|
38
66
|
let u;
|
|
39
67
|
try {
|
|
40
|
-
u = new URL(
|
|
68
|
+
u = new URL(t);
|
|
41
69
|
}
|
|
42
70
|
catch {
|
|
43
|
-
|
|
44
|
-
return { problem: `${JSON.stringify(maskedUrl(url))} is not a URL. The form is postgres://user:password@localhost:5432/your_app — a password ` +
|
|
45
|
-
`containing @ : / # ? or % must be URL-encoded (encodeURIComponent; @ is %40).` };
|
|
71
|
+
return null;
|
|
46
72
|
}
|
|
47
|
-
|
|
48
|
-
|
|
73
|
+
// an @ after the host is a password split there (said as such), not a parameter
|
|
74
|
+
return u.hostname === "" || /@/.test(u.pathname + u.search) ? null : queryProblem(u.searchParams);
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Why a value `shownUrl` will not show is not a URL Mason reads, told from its SHAPE alone (`name` says where it is):
|
|
78
|
+
* what it looks like and how to write it, never a part of it, so a coding agent fixes it from the message alone.
|
|
79
|
+
*/
|
|
80
|
+
function notShownProblem(value, name) {
|
|
81
|
+
const t = value.trim();
|
|
82
|
+
const why = t === "" ? "it is empty"
|
|
83
|
+
: /^[A-Za-z_][A-Za-z0-9_]*\s*=/.test(t) ? "it looks like a keyword/value connection string (host=… user=… password=…)"
|
|
84
|
+
: /^jdbc:/i.test(t) ? "it looks like a JDBC URL: drop jdbc:, and give the user and password before the host"
|
|
85
|
+
: !/^[A-Za-z][A-Za-z0-9+.-]*:\/\//.test(t) ? "it is not a URL"
|
|
86
|
+
: queryOf(t) !== null ? queryOf(t)
|
|
87
|
+
: !/^postgres(ql)?:\/\//i.test(t) ? `its scheme is ${KNOWN_SCHEMES.has(t.slice(0, t.indexOf(":")).toLowerCase()) ? `${t.slice(0, t.indexOf(":")).toLowerCase()}, not` : "not"} postgres:// or postgresql://`
|
|
88
|
+
: "it does not read as one URL (a character that is not URL-encoded splits it: an @ : / ? # % or a space in the password, an @ or # in the database " +
|
|
89
|
+
"or a parameter; or the port is not a number up to 65535)";
|
|
90
|
+
return `${name} is not a postgres:// URL: ${why}. Write it as ${URL_FORM}, where ${ENCODE}`;
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Parse the URL `mason start` was given (`name` says where it is, `DATABASE_URL in .env`: a problem names it, never the
|
|
94
|
+
* value). The user defaults to the OS user (libpq's default, and Homebrew's and Postgres.app's superuser); the database
|
|
95
|
+
* never defaults — guessing it would read the wrong one. Only a value `shownUrl` shows is read, so every URL a run
|
|
96
|
+
* connects with is one its messages can show.
|
|
97
|
+
*/
|
|
98
|
+
export function parseSource(url, name = "the URL") {
|
|
99
|
+
if (shownUrl(url) === null)
|
|
100
|
+
return { problem: notShownProblem(url, name) };
|
|
101
|
+
const u = new URL(url.trim());
|
|
49
102
|
if (!u.username)
|
|
50
103
|
u.username = encodeURIComponent(os.userInfo().username);
|
|
51
|
-
if (!u.pathname.replace(/^\//, "")) {
|
|
52
|
-
return { problem: `the URL names no database. Add it after the port: postgres://${decodeURIComponent(u.username)}@${u.host || "localhost:5432"}/your_app ` +
|
|
53
|
-
`(the one your app uses — its DATABASE_URL usually says).` };
|
|
54
|
-
}
|
|
55
104
|
try {
|
|
105
|
+
// inside the try: a user name with a malformed %-escape is refused (decodeURIComponent throws), never a crash
|
|
106
|
+
if (!u.pathname.replace(/^\//, "")) {
|
|
107
|
+
return { problem: `${name} names no database. Add it after the port: postgres://${decodeURIComponent(u.username)}@${u.host || "localhost:5432"}/your_app ` +
|
|
108
|
+
`(the one your app uses — its DATABASE_URL usually says).` };
|
|
109
|
+
}
|
|
56
110
|
const parts = parseDatabaseUrl(u.toString());
|
|
57
111
|
const remote = !isLocalHost(parts.host);
|
|
58
112
|
// a database elsewhere is read over the network: never in the clear unless the URL says so outright
|
|
@@ -61,7 +115,9 @@ export function parseSource(url) {
|
|
|
61
115
|
return { source: { ...parts, url: u.toString(), remote } };
|
|
62
116
|
}
|
|
63
117
|
catch (e) {
|
|
64
|
-
|
|
118
|
+
if (e instanceof URIError)
|
|
119
|
+
return { problem: `${name} holds a % that starts no %-escape: a % in the user or the password is written %25. Write it as ${URL_FORM}, where ${ENCODE}` };
|
|
120
|
+
return { problem: e.message.replace(/^DATABASE_URL/, name) };
|
|
65
121
|
}
|
|
66
122
|
}
|
|
67
123
|
/** What a failed connection means, and what to change. */
|
|
@@ -80,8 +136,9 @@ export function explainConnectError(e, s, o = {}) {
|
|
|
80
136
|
],
|
|
81
137
|
};
|
|
82
138
|
}
|
|
139
|
+
// the database is not repeated (issue 541: a refusal echoes no part of the value); the hint lists the ones there are
|
|
83
140
|
if (e.code === "3D000")
|
|
84
|
-
return { message: `${where} has no database
|
|
141
|
+
return { message: `${where} has no database by the name the URL gives (the part after the port)`, hints: [] };
|
|
85
142
|
return { message: `${where}: ${msg}`, hints: [] };
|
|
86
143
|
}
|
|
87
144
|
if (/ECONNREFUSED/.test(msg)) {
|
|
@@ -138,6 +195,18 @@ export async function connectSource(s, o = {}) {
|
|
|
138
195
|
return { failure };
|
|
139
196
|
}
|
|
140
197
|
}
|
|
198
|
+
/** pg_collation_actual_version (a libc collation's glibc now) exists from this server_version_num */
|
|
199
|
+
const COLLATION_ACTUAL_NUM = 130000;
|
|
200
|
+
/** The database's collations of its encoding, each as src/duck/collation.ts reads it. The locale's column differs by
|
|
201
|
+
* version (colllocale from 17, colliculocale 15–16, none before: collcollate holds ICU's), so the row is read whole. */
|
|
202
|
+
export async function readCollations(pg, versionNum) {
|
|
203
|
+
const version = versionNum >= COLLATION_ACTUAL_NUM ? "CASE WHEN c.collprovider = 'c' THEN pg_collation_actual_version(c.oid) END" : "NULL::text";
|
|
204
|
+
const rows = JSON.parse((await pg.query(`SELECT coalesce(json_agg(to_jsonb(c) || jsonb_build_object('schema', n.nspname, 'actual', ${version}) ORDER BY c.oid), '[]')::text ` +
|
|
205
|
+
"FROM pg_collation c JOIN pg_namespace n ON n.oid = c.collnamespace WHERE c.collencoding IN (-1, pg_char_to_encoding(getdatabaseencoding()))")).rows[0]?.[0] ?? "[]");
|
|
206
|
+
return rows.map((r) => ({ schema: String(r.schema), name: String(r.collname), provider: String(r.collprovider ?? "c"), collate: (r.collcollate ?? null), ctype: (r.collctype ?? null),
|
|
207
|
+
locale: (r.colllocale ?? r.colliculocale ?? (r.collprovider === "i" ? r.collcollate : null) ?? null), deterministic: r.collisdeterministic !== false,
|
|
208
|
+
version: (r.actual ?? r.collversion ?? null) }));
|
|
209
|
+
}
|
|
141
210
|
/** The source's plain views (never a materialized one: that is a table of its own), each definition printed under a
|
|
142
211
|
* search_path of pg_catalog alone, which qualifies every table, type and function a view reads (`public.orders`):
|
|
143
212
|
* `SET LOCAL` holds for the rest of this one Query and ends with it, whatever happens */
|
|
@@ -149,7 +218,7 @@ export const VIEWS_SQL = `SET LOCAL search_path = pg_catalog; SELECT coalesce(js
|
|
|
149
218
|
) v`;
|
|
150
219
|
/** One catalog read: every ordinary or partitioned table outside the system schemas, its key, size, comments and FKs. */
|
|
151
220
|
export const TABLES_SQL = `SELECT json_agg(t ORDER BY t.schema, t.table)::text FROM (
|
|
152
|
-
SELECT n.nspname AS schema, c.relname AS table, c.oid, c.reltype AS row_type_oid,
|
|
221
|
+
SELECT n.nspname AS schema, c.relname AS table, c.oid, c.reltype AS row_type_oid, c.relreplident AS replica_identity,
|
|
153
222
|
-- a PARTITIONED table's rows and bytes are its leaves' (its own are -1 / 0 and ~0 bytes); "never analysed" is
|
|
154
223
|
-- relpages = 0 AND reltuples <= 0 on any leaf, which Postgres 13 (reltuples 0) and 14+ (-1) both read
|
|
155
224
|
CASE WHEN c.relkind = 'p'
|
|
@@ -177,13 +246,21 @@ export const TABLES_SQL = `SELECT json_agg(t ORDER BY t.schema, t.table)::text F
|
|
|
177
246
|
-- nothing: never declared to the catalog, never a key: Mason accepts
|
|
178
247
|
WHERE i.indrelid = c.oid AND i.indisunique AND i.indisvalid AND NOT i.indisprimary AND i.indpred IS NULL AND i.indexprs IS NULL
|
|
179
248
|
AND i.indnkeyatts = i.indnatts) AS unique_keys,
|
|
180
|
-
(SELECT coalesce(json_agg(json_build_object('name', a.attname, 'type', format_type(
|
|
181
|
-
'not_null', a.attnotnull, 'comment', col_description(c.oid, a.attnum), 'type_oid',
|
|
182
|
-
'type_array_oid', (SELECT typarray FROM pg_type WHERE oid =
|
|
249
|
+
(SELECT coalesce(json_agg(json_build_object('name', a.attname, 'type', format_type(b.oid, b.mod), 'domain', b.oid <> a.atttypid,
|
|
250
|
+
'not_null', a.attnotnull, 'comment', col_description(c.oid, a.attnum), 'type_oid', b.oid,
|
|
251
|
+
'type_array_oid', (SELECT typarray FROM pg_type WHERE oid = b.oid),
|
|
252
|
+
'type_kind', (SELECT coalesce(e.typtype, t.typtype) FROM pg_type t LEFT JOIN pg_type e ON e.typarray = t.oid WHERE t.oid = b.oid),
|
|
183
253
|
'default', pg_get_expr(d.adbin, d.adrelid), 'identity', a.attidentity, 'generated', a.attgenerated,
|
|
254
|
+
-- the column's own collation, where it is not its (base) type's: the catalog declares it (src/duck/collation.ts)
|
|
255
|
+
'collation', (SELECT json_build_object('schema', cn.nspname, 'name', co.collname) FROM pg_collation co JOIN pg_namespace cn ON cn.oid = co.collnamespace
|
|
256
|
+
WHERE co.oid = a.attcollation AND a.attcollation <> 0 AND a.attcollation <> (SELECT typcollation FROM pg_type WHERE oid = b.oid)),
|
|
184
257
|
-- the sequence a serial or identity column owns: asked only of a column with a default or an identity
|
|
185
258
|
'sequence', CASE WHEN d.adbin IS NOT NULL OR a.attidentity <> '' THEN pg_get_serial_sequence(format('%I.%I', n.nspname, c.relname), a.attname) END) ORDER BY a.attnum), '[]'::json)
|
|
186
259
|
FROM pg_attribute a LEFT JOIN pg_attrdef d ON d.adrelid = a.attrelid AND d.adnum = a.attnum
|
|
260
|
+
-- a domain's column is read and declared as its base type (a domain of a domain too)
|
|
261
|
+
CROSS JOIN LATERAL (WITH RECURSIVE base(oid, mod) AS (SELECT a.atttypid, a.atttypmod UNION ALL
|
|
262
|
+
SELECT t.typbasetype, t.typtypmod FROM base JOIN pg_type t ON t.oid = base.oid WHERE t.typtype = 'd')
|
|
263
|
+
SELECT base.oid, base.mod FROM base JOIN pg_type t ON t.oid = base.oid WHERE t.typtype <> 'd') b
|
|
187
264
|
WHERE a.attrelid = c.oid AND a.attnum > 0 AND NOT a.attisdropped) AS columns,
|
|
188
265
|
(SELECT coalesce(json_agg(json_build_object('column', a.attname, 'target', tn.nspname || '.' || tc.relname, 'target_column', ta.attname)), '[]'::json)
|
|
189
266
|
FROM pg_constraint f
|
|
@@ -199,8 +276,18 @@ export const TABLES_SQL = `SELECT json_agg(t ORDER BY t.schema, t.table)::text F
|
|
|
199
276
|
/** What a never-analysed table counted to, by oid: `mason start` reads the catalog every 10 s, and a table is counted
|
|
200
277
|
* once per run, never per read (review of #404: a remote one over the network each time). */
|
|
201
278
|
const COUNTED = new Map();
|
|
279
|
+
/** the server_version_num from which `pg_database_collation_actual_version` exists */
|
|
280
|
+
const COLLATION_ACTUAL_VERSION_NUM = 150000;
|
|
281
|
+
const COLLATION_ACTUAL_VERSION_SQL = "SELECT pg_database_collation_actual_version(oid) FROM pg_database WHERE datname = current_database()";
|
|
282
|
+
/** The collation library's version now, else what the database recorded (`recorded`, datcollversion; before Postgres 15, none). */
|
|
283
|
+
export async function collationVersion(pg, versionNum, recorded) {
|
|
284
|
+
if (versionNum < COLLATION_ACTUAL_VERSION_NUM)
|
|
285
|
+
return recorded;
|
|
286
|
+
return (await pg.query(COLLATION_ACTUAL_VERSION_SQL)).rows[0]?.[0] ?? recorded;
|
|
287
|
+
}
|
|
202
288
|
/** The database's facts: its version, whether it is a standby, and every table this user can read. */
|
|
203
|
-
|
|
289
|
+
/** `collations: false` (the run's 10-second refresh) leaves the database's collations unread: they were read at the start */
|
|
290
|
+
export async function readSourceFacts(pg, counted = COUNTED, o = {}) {
|
|
204
291
|
const r = (await pg.query("SELECT current_setting('server_version_num'), current_setting('server_version'), pg_is_in_recovery()::text")).rows[0];
|
|
205
292
|
const rows = JSON.parse((await pg.query(TABLES_SQL)).rows[0]?.[0] ?? "null");
|
|
206
293
|
const tables = [];
|
|
@@ -213,23 +300,28 @@ export async function readSourceFacts(pg, counted = COUNTED) {
|
|
|
213
300
|
}
|
|
214
301
|
tables.push({
|
|
215
302
|
source: `${t.schema}.${t.table}`, schema: t.schema, table: t.table, key: t.key, rows: count, bytes: Number(t.bytes), comment: t.comment,
|
|
216
|
-
columns: t.columns.map((c) => ({ name: c.name, type: c.type, notNull: c.not_null, comment: c.comment,
|
|
217
|
-
|
|
303
|
+
columns: t.columns.map((c) => ({ name: c.name, type: c.type, typeKind: c.type_kind, ...(c.domain ? { domain: true } : {}), notNull: c.not_null, comment: c.comment,
|
|
304
|
+
// an oid is a STRING in Postgres's JSON (only the int, float and numeric types are numbers): one spelling, here
|
|
305
|
+
...(Number(c.type_oid) >= 16384 ? { typeOid: Number(c.type_oid), ...(Number(c.type_array_oid) > 0 ? { typeArrayOid: Number(c.type_array_oid) } : {}) } : {}),
|
|
218
306
|
// a generated column's pg_attrdef row is its expression, never a default
|
|
219
307
|
...(c.default !== null && c.generated === "" ? { default: c.default } : {}), ...(c.identity === "a" || c.identity === "d" ? { identity: c.identity } : {}),
|
|
220
|
-
...(c.generated === "s" ? { generated: true } : {}), ...(c.sequence !== null ? { sequence: c.sequence } : {})
|
|
308
|
+
...(c.generated === "s" ? { generated: true } : {}), ...(c.sequence !== null ? { sequence: c.sequence } : {}),
|
|
309
|
+
...(c.collation !== null && c.collation !== undefined ? { collation: c.collation } : {}) })),
|
|
221
310
|
foreignKeys: t.foreign_keys.map((f) => ({ column: f.column, target: f.target, targetColumn: f.target_column })),
|
|
222
311
|
uniqueKeys: t.unique_keys.map((u) => ({ name: u.name, columns: u.columns, constraint: u.constraint, nullsNotDistinct: u.nulls_not_distinct })),
|
|
223
|
-
...(t.row_type_oid > 0 ? { rowTypeOid: t.row_type_oid } : {}),
|
|
312
|
+
...(Number(t.row_type_oid) > 0 ? { rowTypeOid: Number(t.row_type_oid) } : {}),
|
|
313
|
+
...(t.replica_identity === "d" || t.replica_identity === "n" || t.replica_identity === "f" || t.replica_identity === "i" ? { replicaIdentity: t.replica_identity } : {}),
|
|
224
314
|
});
|
|
225
315
|
}
|
|
226
316
|
// the columns differ by version (datlocprovider from 15, daticulocale 15–16, datlocale from 17): read the row whole
|
|
227
317
|
const db = JSON.parse((await pg.query("SELECT to_jsonb(d)::text FROM pg_database d WHERE datname = current_database()")).rows[0]?.[0] ?? "{}");
|
|
228
|
-
//
|
|
229
|
-
|
|
318
|
+
// the library's version of it NOW (Postgres 15+; glibc's for libc): datcollversion is what the database was created or
|
|
319
|
+
// last refreshed under, so it stays where it was through an OS upgrade, which is what must read the order again
|
|
320
|
+
const collation = { provider: db.datlocprovider ?? "c", collate: db.datcollate ?? "C", ctype: db.datctype ?? db.datcollate ?? "C", locale: db.datlocale ?? db.daticulocale ?? null, version: await collationVersion(pg, Number(r[0]), db.datcollversion ?? null) };
|
|
230
321
|
const views = JSON.parse((await pg.query(VIEWS_SQL)).rows[0]?.[0] ?? "[]")
|
|
231
322
|
.map((v) => ({ schema: v.schema, name: v.name, definition: v.definition, comment: v.comment }));
|
|
232
|
-
|
|
323
|
+
const collations = o.collations === false ? undefined : await readCollations(pg, Number(r[0]));
|
|
324
|
+
return { version: Number(r[0]), versionText: r[1] ?? "", standby: r[2] === "true", tables, systemIdentifier: await systemIdentifier(pg), collation, ...(collations !== undefined ? { collations } : {}), views };
|
|
233
325
|
}
|
|
234
326
|
/** The server's system identifier, or null when this user may not read it (only that refusal: any other error is real). */
|
|
235
327
|
export async function systemIdentifier(pg) {
|
|
@@ -261,7 +353,7 @@ export function serverlessHost(host) {
|
|
|
261
353
|
* on a database elsewhere (the tables worth copying are the big ones). */
|
|
262
354
|
export function unpickableReason(t) {
|
|
263
355
|
if (t.key.length === 0)
|
|
264
|
-
return "no primary key: Mason keeps one row per key, so it can only
|
|
356
|
+
return "no primary key: Mason keeps one row per key, so it can only sync a table that has one";
|
|
265
357
|
return null;
|
|
266
358
|
}
|
|
267
359
|
/** The warnings `mason start` prints, and `mason status` shows, before anything is copied. */
|
|
@@ -280,8 +372,8 @@ export function sourceWarnings(s, f) {
|
|
|
280
372
|
/** The warning a database on another machine gets at every start: what the copy is and how it is kept current (true of
|
|
281
373
|
* both modes — the mode is the start's, not known here: a poll scans each table, replication reads the change log). */
|
|
282
374
|
export function remoteWarning(s) {
|
|
283
|
-
return `${s.host} is not this machine. mason start will
|
|
284
|
-
"with no access control beyond this machine's), and keep them current while it runs: a scan of each table at every poll, or its change log
|
|
375
|
+
return `${s.host} is not this machine. mason start will sync the tables you pick to this machine (into its synced data's file, ` +
|
|
376
|
+
"with no access control beyond this machine's), and keep them current while it runs: a scan of each table at every poll, or its change log for a table whose sync: is replication";
|
|
285
377
|
}
|
|
286
378
|
/**
|
|
287
379
|
* THE CONFIRMATION A DATABASE ON ANOTHER MACHINE NEEDS (the refusal read as a failed connection and then a fix: only a
|
|
@@ -291,9 +383,9 @@ export function remoteWarning(s) {
|
|
|
291
383
|
*/
|
|
292
384
|
export function remoteConfirmation(host, o) {
|
|
293
385
|
return { code: "failed", exit: 1,
|
|
294
|
-
message: `stopped before ${o.cmd === "mason init" ? "writing anything" : "starting"}: ${host} is on another machine, and
|
|
295
|
-
why: "mason start would
|
|
296
|
-
"and keep them current while it runs by reading that database: a scan of each table at every poll, or its change log
|
|
386
|
+
message: `stopped before ${o.cmd === "mason init" ? "writing anything" : "starting"}: ${host} is on another machine, and syncing from it needs your say-so`,
|
|
387
|
+
why: "mason start would sync the tables you pick to this machine: a file under .mason/, with no access control beyond this machine's\n" +
|
|
388
|
+
"and keep them current while it runs by reading that database: a scan of each table at every poll, or its change log for a table whose sync: is replication",
|
|
297
389
|
fix: o.wayOut, next: o.next };
|
|
298
390
|
}
|
|
299
391
|
/** `pg_current_snapshot()` and xid8, which polling reads, arrived in Postgres 13. */
|
|
@@ -309,7 +401,7 @@ export function versionProblem(f) {
|
|
|
309
401
|
* after the check's facts.
|
|
310
402
|
*/
|
|
311
403
|
export async function checkSource(url, o) {
|
|
312
|
-
const parsed = parseSource(url);
|
|
404
|
+
const parsed = parseSource(url, o.name);
|
|
313
405
|
if ("problem" in parsed) {
|
|
314
406
|
o.say(`${o.cmd}: ${parsed.problem}`);
|
|
315
407
|
return { exit: 2 };
|
package/dist/src/cli/project.js
CHANGED
|
@@ -18,11 +18,13 @@
|
|
|
18
18
|
import * as fs from "node:fs";
|
|
19
19
|
import * as path from "node:path";
|
|
20
20
|
import { randomBytes } from "node:crypto";
|
|
21
|
-
import {
|
|
21
|
+
import { connectDatabase, databaseSettingRefusal } from "../lib/database-name.js";
|
|
22
|
+
import { changesRetired, pollMsRetired } from "../lib/table-sync.js";
|
|
23
|
+
import { readSource, sourcesTop, writtenDatabase } from "./sources.js";
|
|
22
24
|
export const STATE_DIR = ".mason";
|
|
23
25
|
const CONFIG = "config.json";
|
|
24
|
-
/** A project's name:
|
|
25
|
-
*
|
|
26
|
+
/** A project's name: what its commands call it and (with the source's) what its replication slot is named for. Never
|
|
27
|
+
* the database name a tool connects with: src/lib/database-name.ts. */
|
|
26
28
|
export const NAME_RE = /^[a-z0-9][a-z0-9_-]{0,47}$/;
|
|
27
29
|
/** The copy of the data, a DuckDB file in the project's `.mason/`: kept by mason stop, deleted by mason destroy. */
|
|
28
30
|
export const DUCK_FILE = "duck.db";
|
|
@@ -74,6 +76,53 @@ export function readProject(root) {
|
|
|
74
76
|
}
|
|
75
77
|
return { root, dir, folder: path.resolve(root, config.folder), config };
|
|
76
78
|
}
|
|
79
|
+
/** Why a path could not be resolved, in a person's words (the system's code beside them). */
|
|
80
|
+
export function unresolvedWhy(e) {
|
|
81
|
+
const code = e.code;
|
|
82
|
+
const words = {
|
|
83
|
+
ELOOP: "a symbolic link on the way leads back to itself (a loop)",
|
|
84
|
+
ENOTDIR: "a part of the path is a file, not a folder",
|
|
85
|
+
EACCES: "a folder on the way may not be read by this user",
|
|
86
|
+
EPERM: "a folder on the way may not be read by this user",
|
|
87
|
+
ENAMETOOLONG: "the path is longer than this system takes",
|
|
88
|
+
};
|
|
89
|
+
return code !== undefined && words[code] !== undefined ? `${words[code]} (${code})` : `the system could not resolve it (${code ?? e.message})`;
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* WHERE A PROJECT'S FILES ARE, against its root (the folder `.mason/` is in): strictly `inside` it (a folder of their own,
|
|
93
|
+
* `mason/`), the `root` itself (`mason init --folder .`), or `outside` it (`..`, `../app`, an absolute path elsewhere, a
|
|
94
|
+
* link that leaves it). Read from the real paths — a folder not made yet by its nearest folder that is — so every
|
|
95
|
+
* spelling of one place is one answer. A path that cannot be resolved (a loop, a file as a folder, a folder this user
|
|
96
|
+
* may not read, a path too long) is a fourth answer, `unknown` with why, never an exception: fail closed, each caller
|
|
97
|
+
* refuses it with the way out. The one authority: `mason init` refuses outside and unknown, `mason deploy` sends only
|
|
98
|
+
* from inside (src/cli/deploy.ts: a root's or an outside folder's YAML is the application's, or anyone's).
|
|
99
|
+
*/
|
|
100
|
+
export function folderPlace(root, folder) {
|
|
101
|
+
const real = (p) => {
|
|
102
|
+
const rest = [];
|
|
103
|
+
for (let d = path.resolve(p);; d = path.dirname(d)) {
|
|
104
|
+
try {
|
|
105
|
+
return path.join(fs.realpathSync(d), ...rest.reverse());
|
|
106
|
+
}
|
|
107
|
+
catch (e) {
|
|
108
|
+
// a part not made yet: the nearest one that is decides (a folder mason init will make); anything else is unknown
|
|
109
|
+
if (e.code !== "ENOENT" || path.dirname(d) === d)
|
|
110
|
+
throw e;
|
|
111
|
+
rest.push(path.basename(d));
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
};
|
|
115
|
+
let rel;
|
|
116
|
+
try {
|
|
117
|
+
rel = path.relative(real(root), real(folder));
|
|
118
|
+
}
|
|
119
|
+
catch (e) {
|
|
120
|
+
return { place: "unknown", why: unresolvedWhy(e) };
|
|
121
|
+
}
|
|
122
|
+
if (rel === "")
|
|
123
|
+
return { place: "root" };
|
|
124
|
+
return { place: rel === ".." || rel.startsWith(`..${path.sep}`) || path.isAbsolute(rel) ? "outside" : "inside" };
|
|
125
|
+
}
|
|
77
126
|
/** Whether a lookup found a project it could read. */
|
|
78
127
|
export const isProject = (p) => p !== null && !("broken" in p);
|
|
79
128
|
/** What a broken project's commands say: the file, what is wrong with it, and the two ways out. */
|
|
@@ -113,7 +162,7 @@ export function theProject(cwd) {
|
|
|
113
162
|
if (p)
|
|
114
163
|
return { project: p };
|
|
115
164
|
return { problem: "no Mason project here or above", code: "no_project", fix: "cd into a project's folder, or make this folder one (mason init demo: a demo database, nothing to install)",
|
|
116
|
-
next: [["mason init", "make this folder a project (DATABASE_URL from .env, or mason init postgres://…)"]] };
|
|
165
|
+
next: [["mason init", "make this folder a project (MASON_DATABASE_URL or DATABASE_URL from .env, or mason init postgres://…)"]] };
|
|
117
166
|
}
|
|
118
167
|
/** Whether `root`'s .gitignore already ignores `.mason/`. */
|
|
119
168
|
export function ignoresStateDir(gitignore) {
|
|
@@ -127,3 +176,92 @@ export function ignoresStateDir(gitignore) {
|
|
|
127
176
|
export function projectSource(p, env = process.env, o = {}) {
|
|
128
177
|
return readSource(p.folder, p.root, env, o);
|
|
129
178
|
}
|
|
179
|
+
/**
|
|
180
|
+
* THE DATABASE NAME THE PROJECT'S NEXT RUN WILL ANSWER AS, with Mason stopped (a run's is in its URL: `postgresLink`): by
|
|
181
|
+
* the one rule, from the sources file's one key, so it needs neither the database's URL nor the rest of the file right.
|
|
182
|
+
*/
|
|
183
|
+
export function projectDatabase(p, env = process.env) {
|
|
184
|
+
const written = writtenDatabase(p.folder, p.root);
|
|
185
|
+
return "message" in written ? written : connectDatabase(written, p.config, env);
|
|
186
|
+
}
|
|
187
|
+
/**
|
|
188
|
+
* THE NAME THE SOURCES FILE HAS MOVED TO SINCE A RUN STARTED answering as `answersAs` (the name is read at the start), or
|
|
189
|
+
* null. Only what the file says: a server's run may be named by its setting, in an environment the shell asking need
|
|
190
|
+
* not have, so a served project whose file writes no name has nothing to compare; a file that cannot be read is said
|
|
191
|
+
* as such by the commands that read it.
|
|
192
|
+
*/
|
|
193
|
+
export function databaseMoved(p, answersAs) {
|
|
194
|
+
const written = writtenDatabase(p.folder, p.root);
|
|
195
|
+
if ("message" in written || (written.database === null && p.config.served === true))
|
|
196
|
+
return null;
|
|
197
|
+
const says = connectDatabase(written, {}, {});
|
|
198
|
+
return says === answersAs ? null : says;
|
|
199
|
+
}
|
|
200
|
+
/** The setting that says the project's files are deployed, and read-only where Mason runs (`projectWriteRefusal`). */
|
|
201
|
+
export const READONLY_SETTING = "MASON_PROJECT_READONLY";
|
|
202
|
+
/** MASON_PROJECT_READONLY as the environment says it: 1 true, 0 or unset false, anything else what is wrong with it. */
|
|
203
|
+
export function projectReadOnlySetting(env) {
|
|
204
|
+
const v = env[READONLY_SETTING];
|
|
205
|
+
if (v === undefined || v === "" || v === "0")
|
|
206
|
+
return false;
|
|
207
|
+
if (v === "1")
|
|
208
|
+
return true;
|
|
209
|
+
return { problem: `${READONLY_SETTING}=${JSON.stringify(v)}: 1 (the project's files are deployed, and read-only here) or 0 (the default)` };
|
|
210
|
+
}
|
|
211
|
+
/** what a refused edit of a deployed project says to run next */
|
|
212
|
+
const READONLY_NEXT = [["mason status", "what Mason syncs here, as the deployed files say"]];
|
|
213
|
+
/**
|
|
214
|
+
* WHETHER THE PROJECT'S FILES MAY BE WRITTEN HERE — THE ONE AUTHORITY (issue 473): null when they may, else the refusal.
|
|
215
|
+
* A deployment that ships its committed project folder (CI, GitOps, a platform that deploys the folder) runs the files
|
|
216
|
+
* as committed: an edit on the server would run what nobody committed, and the next deploy would undo it without a word.
|
|
217
|
+
* MASON_PROJECT_READONLY=1 says so, and EVERY WRITER OF A PROJECT FILE ASKS THIS FIRST and writes nothing on a refusal:
|
|
218
|
+
* the sources file (`mason add` and `mason remove` at both entrances, the page's Add and Remove: src/cli/source-edit.ts
|
|
219
|
+
* `sourceToEdit`; `mason serve`'s first file, `serveSource`), `mason.yml` (src/cli/pin.ts `ensurePin`) and what `mason
|
|
220
|
+
* init` writes (the sources, roles and generated tables' files, `.env`, `.gitignore`). The users (`.mason/users.json`),
|
|
221
|
+
* the sync and every read are not the project's files, and are unchanged.
|
|
222
|
+
*
|
|
223
|
+
* Read from two places, either refusing: this process's environment, and THE RECORD the run makes in the project's
|
|
224
|
+
* config at its start (`projectReadOnly`, src/cli/dev.ts) — so a command beside the run (`docker compose exec mason
|
|
225
|
+
* mason add …`), with the run up or down, is refused whatever its own environment says (`MASON_PROJECT_READONLY=0
|
|
226
|
+
* mason add` does not un-say the run's). `root` is the project's (where `.mason/` is), or null where no record is to be
|
|
227
|
+
* read (`mason serve` before it writes the config: its own environment decides). Fail closed: a value that is not 0 or
|
|
228
|
+
* 1, or a config that cannot be read, cannot tell, and refuses.
|
|
229
|
+
*/
|
|
230
|
+
export function projectWriteRefusal(root, env) {
|
|
231
|
+
const set = projectReadOnlySetting(env);
|
|
232
|
+
if (typeof set !== "boolean") {
|
|
233
|
+
return { message: `${set.problem}, so whether the project's files may be written here cannot be told`, code: "refused",
|
|
234
|
+
why: "Mason writes none of them until it can tell", fix: `set ${READONLY_SETTING}=1 or ${READONLY_SETTING}=0, or unset it`, next: READONLY_NEXT };
|
|
235
|
+
}
|
|
236
|
+
const p = root === null ? null : readProject(root);
|
|
237
|
+
if (p !== null && "broken" in p) {
|
|
238
|
+
return { message: `whether the project's files may be written here cannot be told: ${p.file}: ${p.broken}`, code: "refused",
|
|
239
|
+
why: "the config records whether Mason runs the project read-only, and Mason writes none of its files until it can tell", fix: brokenProblem(p).fix, next: READONLY_NEXT };
|
|
240
|
+
}
|
|
241
|
+
const recorded = p?.config.projectReadOnly === true;
|
|
242
|
+
if (!set && !recorded)
|
|
243
|
+
return null;
|
|
244
|
+
return { message: `the project's files are read-only here (${READONLY_SETTING}=1${set ? "" : " when Mason last started"})`, code: "refused",
|
|
245
|
+
why: "Mason runs them as they were deployed: an edit here would run what nobody committed, and the next deploy would undo it",
|
|
246
|
+
fix: "edit the files and deploy them: mason add or mason remove where the project is committed, then commit and deploy",
|
|
247
|
+
next: READONLY_NEXT };
|
|
248
|
+
}
|
|
249
|
+
/**
|
|
250
|
+
* A SETTING A START REFUSES, for the start that is typed and the run it spawns alike: read by the parent where the person
|
|
251
|
+
* (or --json) reads the answer, and by the run, which `mason start --attach` and `mason serve` reach without the parent.
|
|
252
|
+
* One list, because a check in the run alone reaches a background start as "the run stopped", its way out in a log.
|
|
253
|
+
*/
|
|
254
|
+
export function startSettingRefusal(p, env, o) {
|
|
255
|
+
const readOnly = projectReadOnlySetting(env);
|
|
256
|
+
if (typeof readOnly !== "boolean")
|
|
257
|
+
return { message: readOnly.problem, code: "refused", fix: `set ${READONLY_SETTING}=1 or ${READONLY_SETTING}=0, or unset it` };
|
|
258
|
+
const top = () => sourcesTop(p.root, p.folder);
|
|
259
|
+
const pollMs = pollMsRetired(env, top);
|
|
260
|
+
if (pollMs !== null)
|
|
261
|
+
return { code: "refused", ...pollMs };
|
|
262
|
+
const changes = changesRetired(env, top);
|
|
263
|
+
if (changes !== null)
|
|
264
|
+
return { code: "refused", ...changes };
|
|
265
|
+
const database = o.serve ? null : databaseSettingRefusal(env, top().shown);
|
|
266
|
+
return database === null ? null : { code: "refused", ...database };
|
|
267
|
+
}
|
|
@@ -8,11 +8,13 @@ export function duration(seconds) {
|
|
|
8
8
|
const m = Math.round(s / 60);
|
|
9
9
|
return m < 90 ? `${m} min` : `${Math.floor(m / 60)} h ${m % 60} min`;
|
|
10
10
|
}
|
|
11
|
+
/** A duration in milliseconds as a person reads it (src/lib/table-sync.ts, where the sync reads it too). */
|
|
12
|
+
export { msText } from "../lib/table-sync.js";
|
|
11
13
|
/**
|
|
12
14
|
* WHAT A COPYING TABLE'S LINE SAYS, AND HOW FULL ITS BAR IS (a 20 M-row copy over the internet showed no progress: its
|
|
13
15
|
* first line waited on 0 for minutes, then jumped to 100%). A copy's rows
|
|
14
16
|
* land a chunk at a time, so the count is the committed chunks' rows PLUS what the chunks being read have read by now
|
|
15
|
-
* at the rate the copy measured (`progress.rowsReading`, held under each chunk's expected rows, so a commit moves it
|
|
17
|
+
* at the rate the copy measured (`progress.rowsReading`, held under each chunk's expected rows, so a commit that holds them moves it
|
|
16
18
|
* forward), and the text says what is happening: the chunks reading and for how long, the rate, and the time left at
|
|
17
19
|
* it — never from the row estimate alone. Before anything is measured it says the first chunk is being read, never
|
|
18
20
|
* `0 of`. The share is of the heap's PAGES read (`progress.share`: the ranges are exact), and only without the chunks'
|
|
@@ -35,7 +37,7 @@ export function copyingText(copied, rowsEstimate, p) {
|
|
|
35
37
|
if (p.rowsPerSecond === null) {
|
|
36
38
|
// nothing measured this attempt: its first chunks are being read (a copy resumed has rows from before)
|
|
37
39
|
const first = p.reading === 0 ? "about to read the first chunk" : `reading the first ${p.reading === 1 ? "chunk" : chunks(p.reading)}${since}`;
|
|
38
|
-
return { fraction, text: copied === 0 ? `${first}${estimate === null ? "" : ` · ~${n(estimate)} rows to
|
|
40
|
+
return { fraction, text: copied === 0 ? `${first}${estimate === null ? "" : ` · ~${n(estimate)} rows to sync`}` : `${rowsText(copied)} · resumed, ${first}` };
|
|
39
41
|
}
|
|
40
42
|
const parts = [rowsText(shown)];
|
|
41
43
|
const allIn = p.reading === 0 && p.chunksDone >= p.chunksTotal;
|
|
@@ -51,7 +53,50 @@ export function copyingText(copied, rowsEstimate, p) {
|
|
|
51
53
|
/** A copy's progress on one line, as `mason wait` lists what it waits for: `public.orders: copying 12% · …` (`copyingText`). */
|
|
52
54
|
export function copyProgress(source, copied, rowsEstimate, p) {
|
|
53
55
|
const c = copyingText(copied, rowsEstimate, p);
|
|
54
|
-
return `${source}: ${c.fraction !== null ? `
|
|
56
|
+
return `${source}: ${c.fraction !== null ? `syncing ${Math.floor(c.fraction * 100)}% · ` : /^\d/.test(c.text) ? "syncing " : ""}${c.text}`;
|
|
57
|
+
}
|
|
58
|
+
export function syncStateOf(c, now = Date.now()) {
|
|
59
|
+
if (c === undefined || c.phase === "waiting")
|
|
60
|
+
return "waiting";
|
|
61
|
+
if (c.phase === "failed" || c.error !== null)
|
|
62
|
+
return "failed";
|
|
63
|
+
if (c.phase === "copying" || c.rebuilding !== undefined || c.behind !== undefined)
|
|
64
|
+
return "syncing";
|
|
65
|
+
return (behindOf(c.kept, now).behindBySeconds ?? 0) > 0 ? "behind" : "synced";
|
|
66
|
+
}
|
|
67
|
+
export function behindOf(k, now) {
|
|
68
|
+
if (k === undefined || k.by === "generated")
|
|
69
|
+
return { asOf: null, promisedSeconds: null, behindBySeconds: null, behindWhy: null };
|
|
70
|
+
const asOf = k.by === "recopy" ? k.syncedAt ?? k.copiedAt : k.syncedAt;
|
|
71
|
+
const promisedSeconds = k.promisedSeconds ?? null;
|
|
72
|
+
if (promisedSeconds === null || asOf === null)
|
|
73
|
+
return { asOf, promisedSeconds, behindBySeconds: null, behindWhy: null };
|
|
74
|
+
const over = (now - Date.parse(asOf)) / 1000 - promisedSeconds;
|
|
75
|
+
return over > 0 ? { asOf, promisedSeconds, behindBySeconds: Math.round(over * 10) / 10, behindWhy: k.lateWhy ?? "its next sync is late" }
|
|
76
|
+
: { asOf, promisedSeconds, behindBySeconds: 0, behindWhy: null };
|
|
77
|
+
}
|
|
78
|
+
export function tableSync(c, now) {
|
|
79
|
+
const s = c?.synced;
|
|
80
|
+
return { state: syncStateOf(c, now), syncedAt: s?.at ?? null, syncSeconds: s?.seconds ?? null, syncedBy: s?.by ?? null,
|
|
81
|
+
ageSeconds: s === undefined ? null : Math.max(0, Math.round((now - Date.parse(s.at)) / 100) / 10), ...behindOf(c?.kept, now) };
|
|
82
|
+
}
|
|
83
|
+
export function syncTotals(added, copies) {
|
|
84
|
+
const copied = copies.filter((c) => c.phase === "copied");
|
|
85
|
+
const latest = copies.flatMap((c) => c.synced === undefined ? [] : [c.synced.at]).sort().pop() ?? null;
|
|
86
|
+
return { added, copied: copied.length, failed: copies.filter((c) => syncStateOf(c) === "failed").length, rows: copied.reduce((n, c) => n + (c.rows ?? 0), 0), syncedAt: latest };
|
|
87
|
+
}
|
|
88
|
+
export function phaseCounts(copies) {
|
|
89
|
+
const counts = { total: 0, copied: 0, copying: 0, waiting: 0, failed: 0 };
|
|
90
|
+
for (const c of copies) {
|
|
91
|
+
counts.total++;
|
|
92
|
+
counts[c.phase]++;
|
|
93
|
+
}
|
|
94
|
+
return counts;
|
|
95
|
+
}
|
|
96
|
+
/** WHERE A RUN IS IN ONE WORD, as `mason status` heads its last line and the web page's Status says it: every added
|
|
97
|
+
* table copied and served, a failure, or still under way. */
|
|
98
|
+
export function readinessWord(r) {
|
|
99
|
+
return r.ready ? "Ready" : r.failed.length ? "Failed" : "Under way";
|
|
55
100
|
}
|
|
56
101
|
/**
|
|
57
102
|
* WHERE A RUN IS. Ready when every added table's copy has ended well: `mason add` followed by `mason wait` returns
|
|
@@ -61,8 +106,12 @@ export function copyProgress(source, copied, rowsEstimate, p) {
|
|
|
61
106
|
export function readiness(s) {
|
|
62
107
|
const failed = [], waiting = [];
|
|
63
108
|
for (const t of s.tables.filter((t) => t.added)) {
|
|
64
|
-
|
|
65
|
-
|
|
109
|
+
// the table's state in one word (`syncStateOf`), never a second reading of its error or phase: a copied table whose
|
|
110
|
+
// poll, catch-up, stream or publication check failed is failed here as it is there (issues 100, 82). Behind is said
|
|
111
|
+
// on its line, never a failure: it is served and being kept. A run from before `sync` was said: its error and phase.
|
|
112
|
+
const state = t.sync?.state ?? (t.error ? "failed" : t.copy?.phase === "copying" ? "syncing" : !t.copy ? "waiting" : "synced");
|
|
113
|
+
if (state === "failed") {
|
|
114
|
+
failed.push(`${t.table}: ${firstLine(t.error ?? "its sync failed (mason status says why)")}`);
|
|
66
115
|
continue;
|
|
67
116
|
}
|
|
68
117
|
if (t.copy?.phase === "copying") {
|
|
@@ -70,7 +119,28 @@ export function readiness(s) {
|
|
|
70
119
|
continue;
|
|
71
120
|
}
|
|
72
121
|
if (!t.copy)
|
|
73
|
-
waiting.push(`${t.table}: waiting for its
|
|
122
|
+
waiting.push(`${t.table}: waiting for its first sync to start`);
|
|
74
123
|
}
|
|
75
124
|
return { ready: failed.length === 0 && waiting.length === 0, failed, waiting };
|
|
76
125
|
}
|
|
126
|
+
/**
|
|
127
|
+
* WHERE A GENERATED TABLE IS, for `mason wait <table>`: ready once it is served and holds every base change Mason had
|
|
128
|
+
* synced when the wait began (`since`, ms), across its chain — nothing behind (`behindSince` null), or only changes
|
|
129
|
+
* committed after it began. A last run that failed, or a parent that blocks it, is a failure, so an agent is never left
|
|
130
|
+
* waiting for a run that cannot come. The marks are the run's clock and `since` this machine's: `mason wait` reads a run
|
|
131
|
+
* on this machine.
|
|
132
|
+
*/
|
|
133
|
+
export function generatedReadiness(g, since) {
|
|
134
|
+
const caught = g.serving && (g.behindSince === null || Date.parse(g.behindSince) > since);
|
|
135
|
+
if (caught)
|
|
136
|
+
return { ready: true, failed: [], waiting: [] };
|
|
137
|
+
const fail = (why) => ({ ready: false, failed: [`${g.table}: ${why}`], waiting: [] });
|
|
138
|
+
if (g.error !== null)
|
|
139
|
+
return fail(`its last run failed: ${firstLine(g.error)}`);
|
|
140
|
+
if (g.state === "refused" && !g.serving)
|
|
141
|
+
return fail(`refused: ${g.refusals[0]?.message ?? "mason status says why"}`);
|
|
142
|
+
if (g.waitingFor !== null && /(failed its last run|is refused)$/.test(g.waitingFor))
|
|
143
|
+
return fail(`not run, because ${g.waitingFor}`);
|
|
144
|
+
return { ready: false, failed: [], waiting: [`${g.table}: ${!g.serving ? g.waitingFor === null ? "being built" : `waiting for ${g.waitingFor}` :
|
|
145
|
+
`${Math.round(g.behindSeconds)} s behind its bases; its next run brings in what was synced before the wait`}`] };
|
|
146
|
+
}
|