@mason-data/cli 0.7.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (155) hide show
  1. package/README.md +101 -73
  2. package/dist/src/api/error.js +3 -0
  3. package/dist/src/api/postgres/catalog-worker.js +49 -2
  4. package/dist/src/api/postgres/catalog.js +368 -25
  5. package/dist/src/api/postgres/encode.js +3 -1
  6. package/dist/src/api/postgres/explain.js +3 -2
  7. package/dist/src/api/postgres/listener.js +11 -3
  8. package/dist/src/api/postgres/protocol.js +39 -23
  9. package/dist/src/api/postgres/proxy.js +283 -0
  10. package/dist/src/api/postgres/schema.js +6 -0
  11. package/dist/src/api/postgres/server.js +1617 -245
  12. package/dist/src/api/postgres/translate.js +127 -10
  13. package/dist/src/api/request-log.js +18 -5
  14. package/dist/src/cdc/poll.js +5 -5
  15. package/dist/src/cli/background.js +47 -25
  16. package/dist/src/cli/bridge-stdin.js +101 -0
  17. package/dist/src/cli/cancel.js +75 -0
  18. package/dist/src/cli/command.js +170 -11
  19. package/dist/src/cli/commands.js +804 -209
  20. package/dist/src/cli/control.js +59 -0
  21. package/dist/src/cli/credentials.js +182 -0
  22. package/dist/src/cli/demo.js +20 -11
  23. package/dist/src/cli/deploy.js +574 -0
  24. package/dist/src/cli/dev.js +491 -130
  25. package/dist/src/cli/doctor.js +131 -38
  26. package/dist/src/cli/generated-files.js +143 -0
  27. package/dist/src/cli/guides.js +175 -36
  28. package/dist/src/cli/hosted.js +850 -0
  29. package/dist/src/cli/init.js +162 -65
  30. package/dist/src/cli/login.js +289 -0
  31. package/dist/src/cli/machine.js +13 -12
  32. package/dist/src/cli/main.js +11 -5
  33. package/dist/src/cli/mason.cjs +17 -0
  34. package/dist/src/cli/mcp.js +31 -22
  35. package/dist/src/cli/metrics.js +79 -0
  36. package/dist/src/cli/output-gate.js +176 -0
  37. package/dist/src/cli/page-share.js +96 -0
  38. package/dist/src/cli/pg-tls.js +14 -4
  39. package/dist/src/cli/pg-url.js +4 -0
  40. package/dist/src/cli/pin.js +19 -14
  41. package/dist/src/cli/pool-size.cjs +85 -0
  42. package/dist/src/cli/ports.js +2 -0
  43. package/dist/src/cli/preflight.js +138 -46
  44. package/dist/src/cli/project.js +142 -4
  45. package/dist/src/cli/readiness.js +76 -6
  46. package/dist/src/cli/registry.js +65 -38
  47. package/dist/src/cli/replica-hosted.js +665 -0
  48. package/dist/src/cli/replica.js +76 -0
  49. package/dist/src/cli/replication-checks.js +7 -5
  50. package/dist/src/cli/replication-mode.js +105 -26
  51. package/dist/src/cli/request-filter.js +134 -0
  52. package/dist/src/cli/roles.js +585 -0
  53. package/dist/src/cli/run-server.js +637 -48
  54. package/dist/src/cli/runs.js +1 -1
  55. package/dist/src/cli/security.js +67 -18
  56. package/dist/src/cli/serve.js +204 -46
  57. package/dist/src/cli/share-run.js +329 -0
  58. package/dist/src/cli/share.js +286 -0
  59. package/dist/src/cli/source-edit.js +75 -17
  60. package/dist/src/cli/source-role.js +344 -0
  61. package/dist/src/cli/sources.js +306 -47
  62. package/dist/src/cli/style.js +2 -1
  63. package/dist/src/cli/supervise.js +27 -24
  64. package/dist/src/cli/sync.js +55 -25
  65. package/dist/src/cli/token-hosted.js +337 -0
  66. package/dist/src/cli/tunnel.js +200 -0
  67. package/dist/src/cli/ui-ssh.js +460 -0
  68. package/dist/src/cli/ui.js +124 -17
  69. package/dist/src/cli/usage.js +128 -0
  70. package/dist/src/cli/user-hosted.js +299 -0
  71. package/dist/src/cli/user.js +573 -65
  72. package/dist/src/cli/version.js +9 -7
  73. package/dist/src/demo/seed.js +29 -0
  74. package/dist/src/duck/analyze.js +36 -20
  75. package/dist/src/duck/cancel.js +10 -1
  76. package/dist/src/duck/collation.js +266 -0
  77. package/dist/src/duck/constant-key.js +94 -0
  78. package/dist/src/duck/distinct-key.js +283 -26
  79. package/dist/src/duck/events.js +165 -0
  80. package/dist/src/duck/executor-worker.js +20 -11
  81. package/dist/src/duck/executor.js +47 -33
  82. package/dist/src/duck/explain.js +60 -46
  83. package/dist/src/duck/extensions.js +160 -0
  84. package/dist/src/duck/functions.js +461 -63
  85. package/dist/src/duck/gaps.js +48 -21
  86. package/dist/src/duck/generated-plan.js +1070 -0
  87. package/dist/src/duck/generated.js +906 -0
  88. package/dist/src/duck/grants.js +176 -0
  89. package/dist/src/duck/held-types.js +64 -0
  90. package/dist/src/duck/history.js +127 -0
  91. package/dist/src/duck/jsonb-order.js +9 -12
  92. package/dist/src/duck/kept-route.js +6 -5
  93. package/dist/src/duck/kept.js +15 -13
  94. package/dist/src/duck/late.js +1469 -40
  95. package/dist/src/duck/libc-bound.js +341 -0
  96. package/dist/src/duck/libc-order.js +892 -126
  97. package/dist/src/duck/pg-eval-worker.js +7 -1
  98. package/dist/src/duck/pg-eval.js +6 -4
  99. package/dist/src/duck/pgtree.js +774 -23
  100. package/dist/src/duck/plan.js +369 -47
  101. package/dist/src/duck/publication.js +20 -6
  102. package/dist/src/duck/requests.js +269 -93
  103. package/dist/src/duck/resolved.js +117 -2
  104. package/dist/src/duck/rewrite.js +4584 -580
  105. package/dist/src/duck/row-filter.js +655 -0
  106. package/dist/src/duck/statement.js +19 -0
  107. package/dist/src/duck/store.js +439 -177
  108. package/dist/src/duck/stream.js +19 -10
  109. package/dist/src/duck/sync.js +1411 -368
  110. package/dist/src/duck/token.js +4 -1
  111. package/dist/src/duck/types.js +69 -12
  112. package/dist/src/duck/usage.js +326 -0
  113. package/dist/src/duck/users.js +69 -15
  114. package/dist/src/duck/verified.js +161 -1
  115. package/dist/src/duck/views.js +5 -5
  116. package/dist/src/lib/database-name.js +62 -0
  117. package/dist/src/lib/database-url.js +55 -10
  118. package/dist/src/lib/dotenv.js +17 -4
  119. package/dist/src/lib/instant.js +50 -0
  120. package/dist/src/lib/log-buffer.js +77 -0
  121. package/dist/src/lib/log-read.js +196 -0
  122. package/dist/src/lib/pg-numeric.js +390 -49
  123. package/dist/src/lib/project-version.js +23 -23
  124. package/dist/src/lib/secret-mask.js +142 -0
  125. package/dist/src/lib/table-sync.js +282 -0
  126. package/dist/src/web/app.css +706 -66
  127. package/dist/src/web/app.js +331 -409
  128. package/dist/src/web/charts.js +229 -0
  129. package/dist/src/web/index.html +82 -18
  130. package/dist/src/web/pages/queries.js +192 -0
  131. package/dist/src/web/pages/status.js +611 -0
  132. package/dist/src/web/pages/syncs.js +155 -0
  133. package/dist/src/web/pages/tables.js +439 -0
  134. package/dist/src/web/pages/usage.js +397 -0
  135. package/dist/src/web/pages/users.js +219 -0
  136. package/dist/src/web/shared.js +584 -0
  137. package/dist/src/web/vendor/README.md +25 -0
  138. package/dist/src/web/vendor/geist-1.7.2/Geist-Variable.woff2 +0 -0
  139. package/dist/src/web/vendor/geist-1.7.2/GeistMono-Variable.woff2 +0 -0
  140. package/dist/src/web/vendor/geist-1.7.2/LICENSE.txt +92 -0
  141. package/dist/src/web/vendor/lucide-1.56.0/LICENSE.txt +43 -0
  142. package/dist/src/web/vendor/lucide-1.56.0/chart-column.mjs +29 -0
  143. package/dist/src/web/vendor/lucide-1.56.0/layout-dashboard.mjs +29 -0
  144. package/dist/src/web/vendor/lucide-1.56.0/logs.mjs +34 -0
  145. package/dist/src/web/vendor/lucide-1.56.0/menu.mjs +28 -0
  146. package/dist/src/web/vendor/lucide-1.56.0/square-terminal.mjs +28 -0
  147. package/dist/src/web/vendor/lucide-1.56.0/table.mjs +29 -0
  148. package/dist/src/web/vendor/lucide-1.56.0/users.mjs +29 -0
  149. package/dist/src/web/vendor/lucide-1.56.0/x.mjs +27 -0
  150. package/dist/src/web/vendor/uplot-1.6.32/LICENSE.txt +21 -0
  151. package/dist/src/web/vendor/uplot-1.6.32/uPlot.esm.js +6140 -0
  152. package/dist/src/web/vendor/uplot-1.6.32/uPlot.min.css +1 -0
  153. package/dist/src/web/vendor/zilla-slab-5.3.0/LICENSE.txt +93 -0
  154. package/dist/src/web/vendor/zilla-slab-5.3.0/zilla-slab-latin-700-normal.woff2 +0 -0
  155. package/package.json +12 -4
@@ -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
- /** The URL with its password masked: what is printed and shown, never the secret. */
20
- export function maskedUrl(url) {
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
- const u = new URL(url);
23
- if (u.password)
24
- u.password = "****";
25
- return u.toString();
37
+ u = new URL(v);
26
38
  }
27
39
  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:****@");
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
- * 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) {
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(url);
68
+ u = new URL(t);
41
69
  }
42
70
  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).` };
71
+ return null;
46
72
  }
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://` };
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
- return { problem: e.message.replace(/^DATABASE_URL/, "the URL") };
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 ${JSON.stringify(s.database)}`, hints: [] };
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(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),
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
- export async function readSourceFacts(pg, counted = COUNTED) {
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
- ...(c.type_oid >= 16384 ? { typeOid: c.type_oid, ...(c.type_array_oid > 0 ? { typeArrayOid: c.type_array_oid } : {}) } : {}),
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
- // 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 };
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
- return { version: Number(r[0]), versionText: r[1] ?? "", standby: r[2] === "true", tables, systemIdentifier: await systemIdentifier(pg), collation, views };
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 copy a table that has one";
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 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";
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 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",
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 };
@@ -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 { readSource } from "./sources.js";
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: 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
+ /** 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 copy`}` : `${rowsText(copied)} · resumed, ${first}` };
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 ? `copying ${Math.floor(c.fraction * 100)}% · ` : /^\d/.test(c.text) ? "copying " : ""}${c.text}`;
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
- if (t.error) {
65
- failed.push(`${t.table}: ${firstLine(t.error)}`);
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 copy to start`);
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
+ }