@mason-data/cli 0.0.0-stage → 0.7.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/LICENSE +202 -0
- package/README.md +268 -2
- package/dist/src/api/error.js +18 -0
- package/dist/src/api/postgres/catalog-worker.js +92 -0
- package/dist/src/api/postgres/catalog.js +477 -0
- package/dist/src/api/postgres/encode.js +541 -0
- package/dist/src/api/postgres/explain.js +143 -0
- package/dist/src/api/postgres/listener.js +60 -0
- package/dist/src/api/postgres/parser-worker.js +58 -0
- package/dist/src/api/postgres/parser.js +121 -0
- package/dist/src/api/postgres/plain.js +33 -0
- package/dist/src/api/postgres/protocol.js +404 -0
- package/dist/src/api/postgres/schema.js +54 -0
- package/dist/src/api/postgres/server.js +1771 -0
- package/dist/src/api/postgres/translate.js +248 -0
- package/dist/src/api/request-log.js +50 -0
- package/dist/src/cdc/pg-wire.js +588 -0
- package/dist/src/cdc/pgoutput.js +107 -0
- package/dist/src/cdc/poll.js +67 -0
- package/dist/src/cdc/source.js +65 -0
- package/dist/src/cli/background.js +198 -0
- package/dist/src/cli/command.js +173 -0
- package/dist/src/cli/commands.js +1277 -0
- package/dist/src/cli/control.js +36 -0
- package/dist/src/cli/demo.js +149 -0
- package/dist/src/cli/dev.js +669 -0
- package/dist/src/cli/doctor.js +260 -0
- package/dist/src/cli/guides.js +158 -0
- package/dist/src/cli/init.js +366 -0
- package/dist/src/cli/machine.js +162 -0
- package/dist/src/cli/main.js +83 -0
- package/dist/src/cli/mcp.js +726 -0
- package/dist/src/cli/pg-tls.js +100 -0
- package/dist/src/cli/pg-url.js +52 -0
- package/dist/src/cli/pin.js +36 -0
- package/dist/src/cli/ports.js +118 -0
- package/dist/src/cli/preflight.js +347 -0
- package/dist/src/cli/project.js +129 -0
- package/dist/src/cli/readiness.js +76 -0
- package/dist/src/cli/registry.js +220 -0
- package/dist/src/cli/replication-checks.js +120 -0
- package/dist/src/cli/replication-mode.js +148 -0
- package/dist/src/cli/run-server.js +311 -0
- package/dist/src/cli/runs.js +120 -0
- package/dist/src/cli/security.js +86 -0
- package/dist/src/cli/self-signed-cert.js +64 -0
- package/dist/src/cli/serve.js +301 -0
- package/dist/src/cli/source-edit.js +116 -0
- package/dist/src/cli/sources.js +564 -0
- package/dist/src/cli/state.js +58 -0
- package/dist/src/cli/style.js +187 -0
- package/dist/src/cli/supervise.js +248 -0
- package/dist/src/cli/sync.js +94 -0
- package/dist/src/cli/ui.js +107 -0
- package/dist/src/cli/user.js +229 -0
- package/dist/src/cli/version.js +23 -0
- package/dist/src/demo/seed.js +237 -0
- package/dist/src/demo/source.js +655 -0
- package/dist/src/duck/analyze.js +113 -0
- package/dist/src/duck/cancel.js +49 -0
- package/dist/src/duck/distinct-key.js +228 -0
- package/dist/src/duck/executor-worker.js +268 -0
- package/dist/src/duck/executor.js +567 -0
- package/dist/src/duck/explain.js +238 -0
- package/dist/src/duck/functions.js +819 -0
- package/dist/src/duck/gaps.js +94 -0
- package/dist/src/duck/jsonb-order.js +175 -0
- package/dist/src/duck/jsonb.js +188 -0
- package/dist/src/duck/kept-route.js +299 -0
- package/dist/src/duck/kept.js +964 -0
- package/dist/src/duck/late.js +221 -0
- package/dist/src/duck/libc-order.js +190 -0
- package/dist/src/duck/pg-eval-worker.js +34 -0
- package/dist/src/duck/pg-eval.js +59 -0
- package/dist/src/duck/pgtree.js +378 -0
- package/dist/src/duck/plan.js +259 -0
- package/dist/src/duck/publication.js +207 -0
- package/dist/src/duck/requests.js +209 -0
- package/dist/src/duck/resolved.js +248 -0
- package/dist/src/duck/rewrite.js +5101 -0
- package/dist/src/duck/store.js +907 -0
- package/dist/src/duck/stream.js +518 -0
- package/dist/src/duck/sync.js +2929 -0
- package/dist/src/duck/to-char.js +482 -0
- package/dist/src/duck/token.js +31 -0
- package/dist/src/duck/types.js +427 -0
- package/dist/src/duck/users.js +231 -0
- package/dist/src/duck/verified.js +1001 -0
- package/dist/src/duck/views.js +150 -0
- package/dist/src/lib/database-url.js +74 -0
- package/dist/src/lib/did-you-mean.js +43 -0
- package/dist/src/lib/dotenv.js +72 -0
- package/dist/src/lib/exit-when-written.js +20 -0
- package/dist/src/lib/float-equal.js +69 -0
- package/dist/src/lib/http-json.js +22 -0
- package/dist/src/lib/log.js +161 -0
- package/dist/src/lib/pg-numeric.js +489 -0
- package/dist/src/lib/port.js +22 -0
- package/dist/src/lib/project-file.js +49 -0
- package/dist/src/lib/project-files.js +43 -0
- package/dist/src/lib/project-version.js +76 -0
- package/dist/src/lib/runnable.js +14 -0
- package/dist/src/lib/shell-word.js +4 -0
- package/dist/src/lib/timezone.js +237 -0
- package/dist/src/lib/zone-names.js +114 -0
- package/dist/src/web/app.css +76 -0
- package/dist/src/web/app.js +491 -0
- package/dist/src/web/index.html +44 -0
- package/package.json +22 -4
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* BEFORE `mason start` STARTS ANYTHING: can it read the database, and should it? Every failure says what to change,
|
|
3
|
+
* because the landing page's command is meant to be pasted and edited — a wrong password, a
|
|
4
|
+
* database that does not exist, nothing listening, a URL whose password needs encoding. Then, for a database that is
|
|
5
|
+
* not on this machine, what polling it would cost and where (src/cdc/poll.ts): a copy over the network, a scan of
|
|
6
|
+
* every table each poll, a serverless compute kept awake. Read-only: nothing here writes to the database.
|
|
7
|
+
*
|
|
8
|
+
* The connection is the change reader's own (src/cdc/pg-wire.ts: TCP, password / md5 / SCRAM, TLS by sslmode), so
|
|
9
|
+
* what passes here is what the service will use.
|
|
10
|
+
*/
|
|
11
|
+
import * as os from "node:os";
|
|
12
|
+
import { PgConnection, PgError } from "../cdc/pg-wire.js";
|
|
13
|
+
import { parseDatabaseUrl } from "../lib/database-url.js";
|
|
14
|
+
/** A host on this machine: polling it costs nothing that leaves the machine. */
|
|
15
|
+
export function isLocalHost(host) {
|
|
16
|
+
const h = host.toLowerCase();
|
|
17
|
+
return h === "localhost" || h === "::1" || h === "0.0.0.0" || /^127\./.test(h) || h === "host.docker.internal" || h.endsWith(".localhost");
|
|
18
|
+
}
|
|
19
|
+
/** The URL with its password masked: what is printed and shown, never the secret. */
|
|
20
|
+
export function maskedUrl(url) {
|
|
21
|
+
try {
|
|
22
|
+
const u = new URL(url);
|
|
23
|
+
if (u.password)
|
|
24
|
+
u.password = "****";
|
|
25
|
+
return u.toString();
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
// not a URL: everything from the user's colon to the LAST @ is the password, whatever it holds (a / or an @ that
|
|
29
|
+
// was not encoded) — a host never holds an @
|
|
30
|
+
return url.replace(/:\/\/([^:@/]*):.*@/, "://$1:****@");
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Parse the URL `mason start` was given. The user defaults to the OS user (libpq's default, and Homebrew's and
|
|
35
|
+
* Postgres.app's superuser); the database never defaults — guessing it would read the wrong one.
|
|
36
|
+
*/
|
|
37
|
+
export function parseSource(url) {
|
|
38
|
+
let u;
|
|
39
|
+
try {
|
|
40
|
+
u = new URL(url);
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
// its password masked: the problem is printed (and, under mason mcp, kept in the MCP client's log)
|
|
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).` };
|
|
46
|
+
}
|
|
47
|
+
if (u.protocol !== "postgres:" && u.protocol !== "postgresql:")
|
|
48
|
+
return { problem: `the URL's scheme is ${u.protocol.replace(/:$/, "")}; it must be postgres:// or postgresql://` };
|
|
49
|
+
if (!u.username)
|
|
50
|
+
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
|
+
try {
|
|
56
|
+
const parts = parseDatabaseUrl(u.toString());
|
|
57
|
+
const remote = !isLocalHost(parts.host);
|
|
58
|
+
// a database elsewhere is read over the network: never in the clear unless the URL says so outright
|
|
59
|
+
if (remote && !u.searchParams.has("sslmode"))
|
|
60
|
+
parts.sslmode = "require";
|
|
61
|
+
return { source: { ...parts, url: u.toString(), remote } };
|
|
62
|
+
}
|
|
63
|
+
catch (e) {
|
|
64
|
+
return { problem: e.message.replace(/^DATABASE_URL/, "the URL") };
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
/** What a failed connection means, and what to change. */
|
|
68
|
+
export function explainConnectError(e, s, o = {}) {
|
|
69
|
+
const where = `${s.host}:${s.port}`;
|
|
70
|
+
const msg = e?.message ?? String(e);
|
|
71
|
+
if (e instanceof PgError) {
|
|
72
|
+
if (e.code === "28P01" || e.code === "28000") {
|
|
73
|
+
return {
|
|
74
|
+
message: `${where} refused user ${s.user}: ${msg}`,
|
|
75
|
+
hints: [
|
|
76
|
+
"Homebrew and Postgres.app: the user is your macOS username, with no password — postgres://" + os.userInfo().username + "@localhost:5432/your_app",
|
|
77
|
+
"Docker's postgres image: the user is postgres (or POSTGRES_USER), the password POSTGRES_PASSWORD in your docker-compose.yml",
|
|
78
|
+
"A Linux package (apt, dnf): the postgres user has no password until you set one (sudo -u postgres psql -c \"\\password postgres\"); Mason connects over TCP, so peer authentication does not apply",
|
|
79
|
+
"Your app's DATABASE_URL (in .env) is a URL that works: pass that",
|
|
80
|
+
],
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
if (e.code === "3D000")
|
|
84
|
+
return { message: `${where} has no database ${JSON.stringify(s.database)}`, hints: [] };
|
|
85
|
+
return { message: `${where}: ${msg}`, hints: [] };
|
|
86
|
+
}
|
|
87
|
+
if (/ECONNREFUSED/.test(msg)) {
|
|
88
|
+
// the demo database is served by the project's own run: nothing else could be listening there
|
|
89
|
+
if (o.demo)
|
|
90
|
+
return { message: `the demo database is not running on ${where}`, hints: ["the demo database runs inside mason start: start it first"] };
|
|
91
|
+
return {
|
|
92
|
+
message: `nothing is listening on ${where}`,
|
|
93
|
+
hints: [
|
|
94
|
+
"Is Postgres running? (brew services list, docker ps, or Postgres.app's window)",
|
|
95
|
+
"Supabase's local stack listens on 54322; a second Postgres on a machine is often on 5433",
|
|
96
|
+
],
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
if (/ENOTFOUND|EAI_AGAIN/.test(msg))
|
|
100
|
+
return { message: `the host ${s.host} does not resolve`, hints: ["Check the host name in the URL"] };
|
|
101
|
+
if (/timed out|ETIMEDOUT|EHOSTUNREACH|ENETUNREACH/.test(msg)) {
|
|
102
|
+
return {
|
|
103
|
+
message: `${where} did not answer`,
|
|
104
|
+
hints: s.remote
|
|
105
|
+
? ["A database in a private network (a VPC) is not reachable from this machine without a tunnel: ssh -L 5433:<db host>:5432 <bastion>, then postgres://…@localhost:5433/…"]
|
|
106
|
+
: ["Is Postgres running, and listening on that port?"],
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
if (/does not accept TLS/.test(msg)) {
|
|
110
|
+
return { message: msg, hints: ["A database on another machine is read over TLS by default; add ?sslmode=disable to the URL only on a network you trust"] };
|
|
111
|
+
}
|
|
112
|
+
return { message: `${where}: ${msg}`, hints: [] };
|
|
113
|
+
}
|
|
114
|
+
/** Connect as the service will. The failure is explained; for a missing database, the ones that exist are listed. */
|
|
115
|
+
export async function connectSource(s, o = {}) {
|
|
116
|
+
const opts = { host: s.host, port: s.port, database: s.database, user: s.user, password: s.password, sslmode: s.sslmode, connectTimeoutMs: 10_000, applicationName: "mason-dev" };
|
|
117
|
+
try {
|
|
118
|
+
return { pg: await PgConnection.connect(opts) };
|
|
119
|
+
}
|
|
120
|
+
catch (e) {
|
|
121
|
+
const failure = explainConnectError(e, s, o);
|
|
122
|
+
if (e instanceof PgError && e.code === "3D000") {
|
|
123
|
+
try {
|
|
124
|
+
const pg = await PgConnection.connect({ ...opts, database: "postgres" });
|
|
125
|
+
try {
|
|
126
|
+
const names = (await pg.query("SELECT datname FROM pg_database WHERE datallowconn AND NOT datistemplate ORDER BY datname")).rows.map((r) => r[0]);
|
|
127
|
+
failure.hints.push(`The databases on ${s.host}:${s.port}: ${names.join(", ")}`);
|
|
128
|
+
}
|
|
129
|
+
finally {
|
|
130
|
+
pg.close();
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
catch (inner) {
|
|
134
|
+
// listing is a courtesy: the refusal above stands either way, and says why the list is missing
|
|
135
|
+
failure.hints.push(`(could not list the databases there: ${inner.message})`);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
return { failure };
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
/** The source's plain views (never a materialized one: that is a table of its own), each definition printed under a
|
|
142
|
+
* search_path of pg_catalog alone, which qualifies every table, type and function a view reads (`public.orders`):
|
|
143
|
+
* `SET LOCAL` holds for the rest of this one Query and ends with it, whatever happens */
|
|
144
|
+
export const VIEWS_SQL = `SET LOCAL search_path = pg_catalog; SELECT coalesce(json_agg(v ORDER BY v.oid), '[]'::json)::text FROM (
|
|
145
|
+
SELECT c.oid, n.nspname AS schema, c.relname AS name, pg_get_viewdef(c.oid) AS definition, obj_description(c.oid, 'pg_class') AS comment
|
|
146
|
+
FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace
|
|
147
|
+
WHERE c.relkind = 'v' AND n.nspname NOT IN ('pg_catalog', 'information_schema') AND n.nspname NOT LIKE 'pg\\_%'
|
|
148
|
+
AND has_table_privilege(c.oid, 'SELECT')
|
|
149
|
+
) v`;
|
|
150
|
+
/** One catalog read: every ordinary or partitioned table outside the system schemas, its key, size, comments and FKs. */
|
|
151
|
+
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,
|
|
153
|
+
-- a PARTITIONED table's rows and bytes are its leaves' (its own are -1 / 0 and ~0 bytes); "never analysed" is
|
|
154
|
+
-- relpages = 0 AND reltuples <= 0 on any leaf, which Postgres 13 (reltuples 0) and 14+ (-1) both read
|
|
155
|
+
CASE WHEN c.relkind = 'p'
|
|
156
|
+
THEN (SELECT coalesce(sum(greatest(l.reltuples, 0)), 0)::bigint FROM pg_partition_tree(c.oid) pt JOIN pg_class l ON l.oid = pt.relid WHERE pt.isleaf)
|
|
157
|
+
ELSE greatest(c.reltuples, 0)::bigint END AS reltuples,
|
|
158
|
+
CASE WHEN c.relkind = 'p'
|
|
159
|
+
THEN coalesce((SELECT bool_or(l.relpages = 0 AND l.reltuples <= 0) FROM pg_partition_tree(c.oid) pt JOIN pg_class l ON l.oid = pt.relid WHERE pt.isleaf), false)
|
|
160
|
+
ELSE c.relpages = 0 AND c.reltuples <= 0 END AS unanalysed,
|
|
161
|
+
CASE WHEN c.relkind = 'p'
|
|
162
|
+
THEN (SELECT coalesce(sum(pg_total_relation_size(pt.relid)), 0) FROM pg_partition_tree(c.oid) pt WHERE pt.isleaf)
|
|
163
|
+
ELSE pg_total_relation_size(c.oid) END AS bytes,
|
|
164
|
+
obj_description(c.oid, 'pg_class') AS comment,
|
|
165
|
+
-- the primary key: only a valid index enforces it
|
|
166
|
+
(SELECT coalesce(json_agg(a.attname ORDER BY k.n), '[]'::json)
|
|
167
|
+
FROM pg_index i CROSS JOIN LATERAL unnest(i.indkey) WITH ORDINALITY AS k(attnum, n)
|
|
168
|
+
JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = k.attnum
|
|
169
|
+
WHERE i.indrelid = c.oid AND i.indisprimary AND i.indisvalid) AS key,
|
|
170
|
+
(SELECT coalesce(json_agg(json_build_object('name', ic.relname, 'constraint', EXISTS (SELECT FROM pg_constraint u WHERE u.conindid = i.indexrelid AND u.contype = 'u'),
|
|
171
|
+
-- indnullsnotdistinct is Postgres 15's: read through the row, which an older server's lacks
|
|
172
|
+
'nulls_not_distinct', coalesce((to_jsonb(i) ->> 'indnullsnotdistinct')::boolean, false),
|
|
173
|
+
'columns', (SELECT json_agg(a.attname ORDER BY k.n) FROM unnest(i.indkey) WITH ORDINALITY AS k(attnum, n)
|
|
174
|
+
JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = k.attnum)) ORDER BY ic.relname), '[]'::json)
|
|
175
|
+
FROM pg_index i JOIN pg_class ic ON ic.oid = i.indexrelid
|
|
176
|
+
-- an INVALID index (a CREATE UNIQUE INDEX CONCURRENTLY that failed, its duplicates still in the table) enforces
|
|
177
|
+
-- nothing: never declared to the catalog, never a key: Mason accepts
|
|
178
|
+
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
|
+
AND i.indnkeyatts = i.indnatts) AS unique_keys,
|
|
180
|
+
(SELECT coalesce(json_agg(json_build_object('name', a.attname, 'type', format_type(a.atttypid, a.atttypmod),
|
|
181
|
+
'not_null', a.attnotnull, 'comment', col_description(c.oid, a.attnum), 'type_oid', a.atttypid,
|
|
182
|
+
'type_array_oid', (SELECT typarray FROM pg_type WHERE oid = a.atttypid),
|
|
183
|
+
'default', pg_get_expr(d.adbin, d.adrelid), 'identity', a.attidentity, 'generated', a.attgenerated,
|
|
184
|
+
-- the sequence a serial or identity column owns: asked only of a column with a default or an identity
|
|
185
|
+
'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
|
+
FROM pg_attribute a LEFT JOIN pg_attrdef d ON d.adrelid = a.attrelid AND d.adnum = a.attnum
|
|
187
|
+
WHERE a.attrelid = c.oid AND a.attnum > 0 AND NOT a.attisdropped) AS columns,
|
|
188
|
+
(SELECT coalesce(json_agg(json_build_object('column', a.attname, 'target', tn.nspname || '.' || tc.relname, 'target_column', ta.attname)), '[]'::json)
|
|
189
|
+
FROM pg_constraint f
|
|
190
|
+
JOIN pg_attribute a ON a.attrelid = f.conrelid AND a.attnum = f.conkey[1]
|
|
191
|
+
JOIN pg_class tc ON tc.oid = f.confrelid JOIN pg_namespace tn ON tn.oid = tc.relnamespace
|
|
192
|
+
JOIN pg_attribute ta ON ta.attrelid = f.confrelid AND ta.attnum = f.confkey[1]
|
|
193
|
+
WHERE f.conrelid = c.oid AND f.contype = 'f' AND cardinality(f.conkey) = 1) AS foreign_keys
|
|
194
|
+
FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace
|
|
195
|
+
WHERE c.relkind IN ('r', 'p') AND NOT c.relispartition
|
|
196
|
+
AND n.nspname NOT IN ('pg_catalog', 'information_schema') AND n.nspname NOT LIKE 'pg\\_%'
|
|
197
|
+
AND has_table_privilege(c.oid, 'SELECT')
|
|
198
|
+
) t`;
|
|
199
|
+
/** What a never-analysed table counted to, by oid: `mason start` reads the catalog every 10 s, and a table is counted
|
|
200
|
+
* once per run, never per read (review of #404: a remote one over the network each time). */
|
|
201
|
+
const COUNTED = new Map();
|
|
202
|
+
/** The database's facts: its version, whether it is a standby, and every table this user can read. */
|
|
203
|
+
export async function readSourceFacts(pg, counted = COUNTED) {
|
|
204
|
+
const r = (await pg.query("SELECT current_setting('server_version_num'), current_setting('server_version'), pg_is_in_recovery()::text")).rows[0];
|
|
205
|
+
const rows = JSON.parse((await pg.query(TABLES_SQL)).rows[0]?.[0] ?? "null");
|
|
206
|
+
const tables = [];
|
|
207
|
+
for (const t of rows ?? []) {
|
|
208
|
+
let count = t.unanalysed ? counted.get(t.oid) ?? null : Number(t.reltuples);
|
|
209
|
+
// never analysed: count it when it is small enough to count in a moment, once; a larger one's size is unknown (null)
|
|
210
|
+
if (count === null && t.unanalysed && Number(t.bytes) < 64 * 1024 * 1024) {
|
|
211
|
+
count = Number((await pg.query(`SELECT count(*) FROM ${pgQualified(t.schema, t.table)}`)).rows[0]?.[0] ?? "0");
|
|
212
|
+
counted.set(t.oid, count);
|
|
213
|
+
}
|
|
214
|
+
tables.push({
|
|
215
|
+
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
|
+
...(c.type_oid >= 16384 ? { typeOid: c.type_oid, ...(c.type_array_oid > 0 ? { typeArrayOid: c.type_array_oid } : {}) } : {}),
|
|
218
|
+
// a generated column's pg_attrdef row is its expression, never a default
|
|
219
|
+
...(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 } : {}) })),
|
|
221
|
+
foreignKeys: t.foreign_keys.map((f) => ({ column: f.column, target: f.target, targetColumn: f.target_column })),
|
|
222
|
+
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 } : {}),
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
// the columns differ by version (datlocprovider from 15, daticulocale 15–16, datlocale from 17): read the row whole
|
|
227
|
+
const db = JSON.parse((await pg.query("SELECT to_jsonb(d)::text FROM pg_database d WHERE datname = current_database()")).rows[0]?.[0] ?? "{}");
|
|
228
|
+
// datcollversion (Postgres 15+): the library's version of it (glibc's for libc), which moves when the OS's does
|
|
229
|
+
const collation = { provider: db.datlocprovider ?? "c", collate: db.datcollate ?? "C", locale: db.datlocale ?? db.daticulocale ?? null, version: db.datcollversion ?? null };
|
|
230
|
+
const views = JSON.parse((await pg.query(VIEWS_SQL)).rows[0]?.[0] ?? "[]")
|
|
231
|
+
.map((v) => ({ schema: v.schema, name: v.name, definition: v.definition, comment: v.comment }));
|
|
232
|
+
return { version: Number(r[0]), versionText: r[1] ?? "", standby: r[2] === "true", tables, systemIdentifier: await systemIdentifier(pg), collation, views };
|
|
233
|
+
}
|
|
234
|
+
/** The server's system identifier, or null when this user may not read it (only that refusal: any other error is real). */
|
|
235
|
+
export async function systemIdentifier(pg) {
|
|
236
|
+
try {
|
|
237
|
+
return (await pg.query("SELECT system_identifier::text FROM pg_control_system()")).rows[0]?.[0] ?? null;
|
|
238
|
+
}
|
|
239
|
+
catch (e) {
|
|
240
|
+
// 42501 insufficient_privilege: a server that revoked pg_control_system from PUBLIC. The identity is then unknown,
|
|
241
|
+
// which mason start says it cannot check; it is never guessed
|
|
242
|
+
if (e instanceof PgError && e.code === "42501")
|
|
243
|
+
return null;
|
|
244
|
+
throw e;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
const pgQualified = (schema, table) => `"${schema.replace(/"/g, '""')}"."${table.replace(/"/g, '""')}"`;
|
|
248
|
+
/** Hosts whose compute sleeps when idle and bills while awake: polling keeps it awake. */
|
|
249
|
+
export function serverlessHost(host) {
|
|
250
|
+
if (/\.neon\.tech$/i.test(host))
|
|
251
|
+
return "Neon";
|
|
252
|
+
if (/\.aws\.neon\.build$/i.test(host))
|
|
253
|
+
return "Neon";
|
|
254
|
+
if (/\.supabase\.(co|com)$/i.test(host))
|
|
255
|
+
return null; // always on: no sleep to keep awake
|
|
256
|
+
if (/serverless/i.test(host) && /\.rds\.amazonaws\.com$/i.test(host))
|
|
257
|
+
return "Aurora Serverless";
|
|
258
|
+
return null;
|
|
259
|
+
}
|
|
260
|
+
/** Why a table cannot be picked, or null: no primary key (a row version needs one). Its size is never a reason, here or
|
|
261
|
+
* on a database elsewhere (the tables worth copying are the big ones). */
|
|
262
|
+
export function unpickableReason(t) {
|
|
263
|
+
if (t.key.length === 0)
|
|
264
|
+
return "no primary key: Mason keeps one row per key, so it can only copy a table that has one";
|
|
265
|
+
return null;
|
|
266
|
+
}
|
|
267
|
+
/** The warnings `mason start` prints, and `mason status` shows, before anything is copied. */
|
|
268
|
+
export function sourceWarnings(s, f) {
|
|
269
|
+
const out = [];
|
|
270
|
+
if (!s.remote)
|
|
271
|
+
return out;
|
|
272
|
+
out.push(remoteWarning(s));
|
|
273
|
+
if (!f.standby)
|
|
274
|
+
out.push("this is a PRIMARY (pg_is_in_recovery() = false): the database your app writes to. A read replica takes the reads instead");
|
|
275
|
+
const sleeper = serverlessHost(s.host);
|
|
276
|
+
if (sleeper)
|
|
277
|
+
out.push(`${sleeper} suspends an idle compute and bills while it runs: polling every few seconds, or an open replication connection, keeps it running`);
|
|
278
|
+
return out;
|
|
279
|
+
}
|
|
280
|
+
/** The warning a database on another machine gets at every start: what the copy is and how it is kept current (true of
|
|
281
|
+
* both modes — the mode is the start's, not known here: a poll scans each table, replication reads the change log). */
|
|
282
|
+
export function remoteWarning(s) {
|
|
283
|
+
return `${s.host} is not this machine. mason start will copy the tables you pick to this machine (into its copy's file, ` +
|
|
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 with --replication";
|
|
285
|
+
}
|
|
286
|
+
/**
|
|
287
|
+
* THE CONFIRMATION A DATABASE ON ANOTHER MACHINE NEEDS (the refusal read as a failed connection and then a fix: only a
|
|
288
|
+
* careful reading showed it was a warning that wanted a yes, and what the risks were). It is not
|
|
289
|
+
* a failed step — the check passed — but a stop before anything is written or started, said as one failure a reader
|
|
290
|
+
* scans: what stopped (the message), what would happen and the risk (why), the one way to say yes (fix, next).
|
|
291
|
+
*/
|
|
292
|
+
export function remoteConfirmation(host, o) {
|
|
293
|
+
return { code: "failed", exit: 1,
|
|
294
|
+
message: `stopped before ${o.cmd === "mason init" ? "writing anything" : "starting"}: ${host} is on another machine, and copying from it needs your say-so`,
|
|
295
|
+
why: "mason start would copy the tables you pick to this machine: a file under .mason/, with no access control beyond this machine's\n" +
|
|
296
|
+
"and keep them current while it runs by reading that database: a scan of each table at every poll, or its change log with --replication",
|
|
297
|
+
fix: o.wayOut, next: o.next };
|
|
298
|
+
}
|
|
299
|
+
/** `pg_current_snapshot()` and xid8, which polling reads, arrived in Postgres 13. */
|
|
300
|
+
export function versionProblem(f) {
|
|
301
|
+
return f.version >= 130000 ? null : `mason start needs Postgres 13 or later (pg_current_snapshot, which it polls with); this is ${f.versionText}`;
|
|
302
|
+
}
|
|
303
|
+
/**
|
|
304
|
+
* THE ONE CHECK OF A DATABASE (init checks the Postgres connection too, and fails when it cannot connect): connect as
|
|
305
|
+
* the service will, read its version and tables, say the warnings, and refuse a database on another machine unless
|
|
306
|
+
* `remote` says it is meant — `mason init` before it writes anything, `mason start` before it starts anything. Every
|
|
307
|
+
* refusal is said through `say`, with the way out; the result is the exit code then. A database elsewhere that only
|
|
308
|
+
* lacks the confirmation is answered as `NeedsRemote`: the caller says the confirmation, in its own command's words,
|
|
309
|
+
* after the check's facts.
|
|
310
|
+
*/
|
|
311
|
+
export async function checkSource(url, o) {
|
|
312
|
+
const parsed = parseSource(url);
|
|
313
|
+
if ("problem" in parsed) {
|
|
314
|
+
o.say(`${o.cmd}: ${parsed.problem}`);
|
|
315
|
+
return { exit: 2 };
|
|
316
|
+
}
|
|
317
|
+
const source = parsed.source;
|
|
318
|
+
// progress, not a finding: a caller with a checklist of its own (mason start) says it there
|
|
319
|
+
(o.progress ?? o.say)(`${o.cmd}: connecting to ${maskedUrl(source.url)} …`);
|
|
320
|
+
const connected = await connectSource(source);
|
|
321
|
+
if ("failure" in connected) {
|
|
322
|
+
o.say(`${o.cmd}: could not connect: ${connected.failure.message}`);
|
|
323
|
+
for (const h of connected.failure.hints)
|
|
324
|
+
o.say(` - ${h}`);
|
|
325
|
+
return { exit: 1 };
|
|
326
|
+
}
|
|
327
|
+
let facts;
|
|
328
|
+
try {
|
|
329
|
+
facts = await readSourceFacts(connected.pg);
|
|
330
|
+
}
|
|
331
|
+
finally {
|
|
332
|
+
connected.pg.close();
|
|
333
|
+
}
|
|
334
|
+
const tooOld = versionProblem(facts);
|
|
335
|
+
if (tooOld) {
|
|
336
|
+
o.say(`${o.cmd}: ${tooOld}`);
|
|
337
|
+
return { exit: 1 };
|
|
338
|
+
}
|
|
339
|
+
const warnings = sourceWarnings(source, facts);
|
|
340
|
+
o.say(` Postgres ${facts.versionText}${facts.standby ? " (a read replica)" : ""}, ${facts.tables.length} table(s) this user can read. ` +
|
|
341
|
+
"Mason only reads it: no setting, slot or publication is needed or made.");
|
|
342
|
+
for (const w of warnings)
|
|
343
|
+
o.say(` ! ${w}`);
|
|
344
|
+
if (source.remote && !o.remote)
|
|
345
|
+
return { needsRemote: { source, facts, warnings } };
|
|
346
|
+
return { source, facts, warnings };
|
|
347
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A MASON PROJECT IS A FOLDER (the CLI works like docker's or supabase's: `mason init`, `mason ps`, `mason start` and
|
|
3
|
+
* `mason stop`). `mason init` makes one where it is run:
|
|
4
|
+
*
|
|
5
|
+
* <root>/.mason/config.json this machine's: the project's name and the API key (mode 0600, gitignored by init);
|
|
6
|
+
* while Mason runs, how the commands reach it
|
|
7
|
+
* <root>/.mason/ the run's state too: the copy of the data (duck.db), the users, the logs (mason destroy
|
|
8
|
+
* empties it, keeping the config and the users)
|
|
9
|
+
* <root>/<folder>/sources/ the project's files, yours to commit (`mason/` unless init's --folder says otherwise): one
|
|
10
|
+
* source file, the database and the tables it copies (src/cli/sources.ts); its URL is
|
|
11
|
+
* `${DATABASE_URL}`, from the root's .env (gitignored by init) or the shell
|
|
12
|
+
*
|
|
13
|
+
* Every command but init, doctor and help means the project of the folder it is run in: the nearest `.mason/` at or
|
|
14
|
+
* above it, as git finds `.git`. Nothing is kept in the home folder; the project folder is the only place a project
|
|
15
|
+
* lives (`--project <name>` and `mason list` found a project through its container's labels, and there is no
|
|
16
|
+
* container).
|
|
17
|
+
*/
|
|
18
|
+
import * as fs from "node:fs";
|
|
19
|
+
import * as path from "node:path";
|
|
20
|
+
import { randomBytes } from "node:crypto";
|
|
21
|
+
import { readSource } from "./sources.js";
|
|
22
|
+
export const STATE_DIR = ".mason";
|
|
23
|
+
const CONFIG = "config.json";
|
|
24
|
+
/** A project's name: the database name a tool connects to on the Postgres API, and (with the source's) what its
|
|
25
|
+
* replication slot is named for. */
|
|
26
|
+
export const NAME_RE = /^[a-z0-9][a-z0-9_-]{0,47}$/;
|
|
27
|
+
/** The copy of the data, a DuckDB file in the project's `.mason/`: kept by mason stop, deleted by mason destroy. */
|
|
28
|
+
export const DUCK_FILE = "duck.db";
|
|
29
|
+
/** The demo database's folder in the project's `.mason/` (`mason init demo`): hosted by mason start, deleted by mason destroy. */
|
|
30
|
+
export const DEMO_DIR = "demo";
|
|
31
|
+
/** A name from a folder's: lower case, the rest of it dashes. */
|
|
32
|
+
export function nameFromFolder(dir) {
|
|
33
|
+
const n = path.basename(path.resolve(dir)).toLowerCase().replace(/[^a-z0-9_-]+/g, "-").replace(/^[-_]+|[-_]+$/g, "").slice(0, 48);
|
|
34
|
+
return NAME_RE.test(n) ? n : "mason";
|
|
35
|
+
}
|
|
36
|
+
/** What a recorded run must carry besides its pid (src/cli/dev.ts writes all of it): how the commands reach it. */
|
|
37
|
+
const RUNNING = ["controlUrl", "token", "postgresUrl"];
|
|
38
|
+
/** The fields a config must have, each a string: what every command reads. */
|
|
39
|
+
const REQUIRED = ["name", "folder", "apiKey"];
|
|
40
|
+
/** The project whose `.mason/` is `root`'s; a broken one when its config cannot be read; null when there is none. */
|
|
41
|
+
export function readProject(root) {
|
|
42
|
+
const dir = path.join(root, STATE_DIR);
|
|
43
|
+
const file = path.join(dir, CONFIG);
|
|
44
|
+
if (!fs.existsSync(file))
|
|
45
|
+
return null;
|
|
46
|
+
let config;
|
|
47
|
+
try {
|
|
48
|
+
config = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
49
|
+
}
|
|
50
|
+
catch (e) {
|
|
51
|
+
return { root, dir, file, broken: `it is not valid JSON (${e.message})` };
|
|
52
|
+
}
|
|
53
|
+
if (typeof config !== "object" || config === null || Array.isArray(config))
|
|
54
|
+
return { root, dir, file, broken: "it is not a JSON object" };
|
|
55
|
+
const missing = REQUIRED.filter((k) => typeof config[k] !== "string");
|
|
56
|
+
if (missing.length)
|
|
57
|
+
return { root, dir, file, broken: `it has no ${missing.join(", ")} (each a string)` };
|
|
58
|
+
if (!NAME_RE.test(config.name))
|
|
59
|
+
return { root, dir, file, broken: `its name ${JSON.stringify(config.name)} is not a project name (lower case letters, digits, - and _)` };
|
|
60
|
+
// the project's port is what every tool's saved connection names: a value that is no port would make every start refuse
|
|
61
|
+
const pgPort = config.pgPort;
|
|
62
|
+
if (pgPort !== undefined && pgPort !== null && !(Number.isInteger(pgPort) && pgPort >= 1 && pgPort <= 65_535)) {
|
|
63
|
+
return { root, dir, file, broken: `its "pgPort" ${JSON.stringify(pgPort)} is not a port number (1-65535, the Postgres API's port mason start recorded; remove it and the next start chooses one)` };
|
|
64
|
+
}
|
|
65
|
+
// a run recorded without how to reach it would read as "not running" and let mason start launch a second service
|
|
66
|
+
// beside a live one (review of #434): what mason start writes, whole, or nothing
|
|
67
|
+
const r = config.running;
|
|
68
|
+
if (r !== undefined && r !== null) {
|
|
69
|
+
const run = r;
|
|
70
|
+
const bad = typeof r !== "object" || !Number.isInteger(run.pid) ? ["pid"]
|
|
71
|
+
: [...RUNNING.filter((k) => typeof run[k] !== "string"), ...(typeof run.postgresListener?.host !== "string" ? ["postgresListener"] : [])];
|
|
72
|
+
if (bad.length)
|
|
73
|
+
return { root, dir, file, broken: `its "running" has no ${bad.join(", ")} (mason start writes the whole of it, or null when stopped)` };
|
|
74
|
+
}
|
|
75
|
+
return { root, dir, folder: path.resolve(root, config.folder), config };
|
|
76
|
+
}
|
|
77
|
+
/** Whether a lookup found a project it could read. */
|
|
78
|
+
export const isProject = (p) => p !== null && !("broken" in p);
|
|
79
|
+
/** What a broken project's commands say: the file, what is wrong with it, and the two ways out. */
|
|
80
|
+
export function brokenProblem(b) {
|
|
81
|
+
return { problem: `this folder's Mason project cannot be read: ${b.file}: ${b.broken}`, code: "bad_config",
|
|
82
|
+
fix: `repair it (JSON: ${REQUIRED.join(", ")}), or move ${b.dir} aside and run mason init again — your project's files stay where they are`,
|
|
83
|
+
next: [["mason init", `once ${b.dir} is moved aside: a new config for this folder`]] };
|
|
84
|
+
}
|
|
85
|
+
/** The nearest project at or above `cwd`, as git finds a repository (a broken one stops the search: it is the nearest). */
|
|
86
|
+
export function findProject(cwd) {
|
|
87
|
+
for (let d = path.resolve(cwd);; d = path.dirname(d)) {
|
|
88
|
+
const p = readProject(d);
|
|
89
|
+
if (p)
|
|
90
|
+
return p;
|
|
91
|
+
if (path.dirname(d) === d)
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
export function writeConfig(dir, c) {
|
|
96
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
97
|
+
const file = path.join(dir, CONFIG);
|
|
98
|
+
fs.writeFileSync(file, `${JSON.stringify(c, null, 2)}\n`, { mode: 0o600 });
|
|
99
|
+
fs.chmodSync(file, 0o600);
|
|
100
|
+
}
|
|
101
|
+
/** A new project's config: the secrets made here, once. */
|
|
102
|
+
export function newConfig(name, folder, remote, o = {}) {
|
|
103
|
+
return { name, folder, remote, ...(o.demo ? { demo: true } : {}), apiKey: randomBytes(24).toString("hex"), sourceServer: null, running: null, lastRun: null };
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* THE PROJECT A COMMAND MEANS: the nearest at or above `cwd`; or the sentence that says why there is none, with the
|
|
107
|
+
* way out.
|
|
108
|
+
*/
|
|
109
|
+
export function theProject(cwd) {
|
|
110
|
+
const p = findProject(cwd);
|
|
111
|
+
if (p && "broken" in p)
|
|
112
|
+
return brokenProblem(p);
|
|
113
|
+
if (p)
|
|
114
|
+
return { project: p };
|
|
115
|
+
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://…)"]] };
|
|
117
|
+
}
|
|
118
|
+
/** Whether `root`'s .gitignore already ignores `.mason/`. */
|
|
119
|
+
export function ignoresStateDir(gitignore) {
|
|
120
|
+
return gitignore.split("\n").map((l) => l.trim()).some((l) => /^\/?\.mason\/?(\*\*?)?$/.test(l));
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* THE PROJECT'S SOURCE — its sources file read and its URL resolved (src/cli/sources.ts readSource) — or what is wrong,
|
|
124
|
+
* with the way out: what every command that reads the database asks (mason start, tables, doctor, destroy). `shellFirst`
|
|
125
|
+
* for a server's run (mason serve: its environment before any .env).
|
|
126
|
+
*/
|
|
127
|
+
export function projectSource(p, env = process.env, o = {}) {
|
|
128
|
+
return readSource(p.folder, p.root, env, o);
|
|
129
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
const n = (x) => x.toLocaleString("en-US");
|
|
2
|
+
const firstLine = (s) => s.split("\n")[0].trim();
|
|
3
|
+
/** A span of seconds as a person reads it: `45 s`, `12 min`, `2 h 5 min`. */
|
|
4
|
+
export function duration(seconds) {
|
|
5
|
+
const s = Math.max(1, Math.round(seconds));
|
|
6
|
+
if (s < 90)
|
|
7
|
+
return `${s} s`;
|
|
8
|
+
const m = Math.round(s / 60);
|
|
9
|
+
return m < 90 ? `${m} min` : `${Math.floor(m / 60)} h ${m % 60} min`;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* 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
|
+
* first line waited on 0 for minutes, then jumped to 100%). A copy's rows
|
|
14
|
+
* 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
|
|
16
|
+
* forward), and the text says what is happening: the chunks reading and for how long, the rate, and the time left at
|
|
17
|
+
* it — never from the row estimate alone. Before anything is measured it says the first chunk is being read, never
|
|
18
|
+
* `0 of`. The share is of the heap's PAGES read (`progress.share`: the ranges are exact), and only without the chunks'
|
|
19
|
+
* progress of the table's row estimate (Postgres's own); below 100 until the copy says it is done, and NEVER BACKWARDS:
|
|
20
|
+
* a copy that outruns the estimate (23.7 M rows under an estimate of 20.1 M went back to `0% copying
|
|
21
|
+
* 21,385,158 rows`) stays at its share and says it is past the estimate. Null when nothing gives one.
|
|
22
|
+
*/
|
|
23
|
+
export function copyingText(copied, rowsEstimate, p) {
|
|
24
|
+
const shown = copied + (p?.rowsReading ?? 0);
|
|
25
|
+
const estimate = rowsEstimate !== null && rowsEstimate > 0 ? rowsEstimate : null;
|
|
26
|
+
const past = estimate !== null && shown > estimate;
|
|
27
|
+
const fraction = p !== undefined ? p.share : estimate === null ? null : Math.min(0.99, shown / estimate);
|
|
28
|
+
// past the estimate: the count, and that it is past it (the share holds where it was: pages, or capped)
|
|
29
|
+
const of = estimate === null ? "" : past ? ` · past the ~${n(estimate)} estimated` : ` of ~${n(estimate)}`;
|
|
30
|
+
const rowsText = (k) => past ? `${n(k)} rows${of}` : `${n(k)}${of} rows`;
|
|
31
|
+
if (p === undefined)
|
|
32
|
+
return { fraction, text: rowsText(copied) };
|
|
33
|
+
const since = p.readingSeconds === null ? "" : ` (${duration(p.readingSeconds)})`;
|
|
34
|
+
const chunks = (k) => `${k} chunk${k === 1 ? "" : "s"}`;
|
|
35
|
+
if (p.rowsPerSecond === null) {
|
|
36
|
+
// nothing measured this attempt: its first chunks are being read (a copy resumed has rows from before)
|
|
37
|
+
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 copy`}` : `${rowsText(copied)} · resumed, ${first}` };
|
|
39
|
+
}
|
|
40
|
+
const parts = [rowsText(shown)];
|
|
41
|
+
const allIn = p.reading === 0 && p.chunksDone >= p.chunksTotal;
|
|
42
|
+
if (allIn)
|
|
43
|
+
parts.push(`every chunk read (${p.chunksDone}): checking it and swapping it in`);
|
|
44
|
+
else
|
|
45
|
+
parts.push(`${p.reading === 0 ? "starting the next chunk" : `${chunks(p.reading)} reading${since}`}, ${p.chunksDone} of ~${p.chunksTotal} in`);
|
|
46
|
+
parts.push(`${n(Math.round(p.rowsPerSecond))} rows/s`);
|
|
47
|
+
if (!allIn && p.secondsLeft !== null)
|
|
48
|
+
parts.push(`~${duration(p.secondsLeft)} left`);
|
|
49
|
+
return { fraction, text: parts.join(" · ") };
|
|
50
|
+
}
|
|
51
|
+
/** A copy's progress on one line, as `mason wait` lists what it waits for: `public.orders: copying 12% · …` (`copyingText`). */
|
|
52
|
+
export function copyProgress(source, copied, rowsEstimate, p) {
|
|
53
|
+
const c = copyingText(copied, rowsEstimate, p);
|
|
54
|
+
return `${source}: ${c.fraction !== null ? `copying ${Math.floor(c.fraction * 100)}% · ` : /^\d/.test(c.text) ? "copying " : ""}${c.text}`;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* WHERE A RUN IS. Ready when every added table's copy has ended well: `mason add` followed by `mason wait` returns
|
|
58
|
+
* once the table is served. A copy that failed is a failure (the sync tries again by itself, and says so in the log),
|
|
59
|
+
* so an agent is never left waiting for a copy that cannot finish.
|
|
60
|
+
*/
|
|
61
|
+
export function readiness(s) {
|
|
62
|
+
const failed = [], waiting = [];
|
|
63
|
+
for (const t of s.tables.filter((t) => t.added)) {
|
|
64
|
+
if (t.error) {
|
|
65
|
+
failed.push(`${t.table}: ${firstLine(t.error)}`);
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
if (t.copy?.phase === "copying") {
|
|
69
|
+
waiting.push(copyProgress(t.table, t.copy.rowsCopied, t.rows, t.copy.progress));
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
if (!t.copy)
|
|
73
|
+
waiting.push(`${t.table}: waiting for its copy to start`);
|
|
74
|
+
}
|
|
75
|
+
return { ready: failed.length === 0 && waiting.length === 0, failed, waiting };
|
|
76
|
+
}
|