@mason-data/cli 0.8.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 (109) hide show
  1. package/README.md +13 -4
  2. package/dist/src/api/postgres/catalog-worker.js +49 -2
  3. package/dist/src/api/postgres/catalog.js +166 -32
  4. package/dist/src/api/postgres/listener.js +11 -3
  5. package/dist/src/api/postgres/proxy.js +283 -0
  6. package/dist/src/api/postgres/schema.js +6 -0
  7. package/dist/src/api/postgres/server.js +505 -99
  8. package/dist/src/api/postgres/translate.js +47 -2
  9. package/dist/src/cli/background.js +18 -19
  10. package/dist/src/cli/bridge-stdin.js +101 -0
  11. package/dist/src/cli/command.js +169 -11
  12. package/dist/src/cli/commands.js +207 -51
  13. package/dist/src/cli/credentials.js +182 -0
  14. package/dist/src/cli/demo.js +2 -2
  15. package/dist/src/cli/deploy.js +574 -0
  16. package/dist/src/cli/dev.js +148 -27
  17. package/dist/src/cli/doctor.js +116 -33
  18. package/dist/src/cli/generated-files.js +143 -0
  19. package/dist/src/cli/guides.js +97 -6
  20. package/dist/src/cli/hosted.js +850 -0
  21. package/dist/src/cli/init.js +47 -11
  22. package/dist/src/cli/login.js +289 -0
  23. package/dist/src/cli/machine.js +1 -1
  24. package/dist/src/cli/main.js +8 -2
  25. package/dist/src/cli/mason.cjs +17 -0
  26. package/dist/src/cli/mcp.js +2 -2
  27. package/dist/src/cli/output-gate.js +176 -0
  28. package/dist/src/cli/page-share.js +96 -0
  29. package/dist/src/cli/pg-tls.js +1 -1
  30. package/dist/src/cli/pin.js +6 -2
  31. package/dist/src/cli/pool-size.cjs +85 -0
  32. package/dist/src/cli/ports.js +2 -0
  33. package/dist/src/cli/preflight.js +130 -39
  34. package/dist/src/cli/project.js +99 -0
  35. package/dist/src/cli/readiness.js +42 -6
  36. package/dist/src/cli/registry.js +12 -4
  37. package/dist/src/cli/replica-hosted.js +665 -0
  38. package/dist/src/cli/replica.js +76 -0
  39. package/dist/src/cli/roles.js +2 -1
  40. package/dist/src/cli/run-server.js +154 -7
  41. package/dist/src/cli/security.js +57 -10
  42. package/dist/src/cli/serve.js +63 -7
  43. package/dist/src/cli/share-run.js +51 -8
  44. package/dist/src/cli/share.js +3 -3
  45. package/dist/src/cli/source-edit.js +10 -4
  46. package/dist/src/cli/source-role.js +344 -0
  47. package/dist/src/cli/sources.js +30 -20
  48. package/dist/src/cli/style.js +2 -1
  49. package/dist/src/cli/supervise.js +15 -23
  50. package/dist/src/cli/token-hosted.js +337 -0
  51. package/dist/src/cli/ui-ssh.js +460 -0
  52. package/dist/src/cli/ui.js +115 -11
  53. package/dist/src/cli/user-hosted.js +299 -0
  54. package/dist/src/cli/user.js +126 -74
  55. package/dist/src/demo/seed.js +26 -0
  56. package/dist/src/duck/analyze.js +36 -20
  57. package/dist/src/duck/collation.js +266 -0
  58. package/dist/src/duck/constant-key.js +94 -0
  59. package/dist/src/duck/distinct-key.js +283 -26
  60. package/dist/src/duck/events.js +39 -8
  61. package/dist/src/duck/executor-worker.js +2 -2
  62. package/dist/src/duck/executor.js +6 -5
  63. package/dist/src/duck/explain.js +4 -0
  64. package/dist/src/duck/functions.js +448 -49
  65. package/dist/src/duck/gaps.js +34 -16
  66. package/dist/src/duck/generated-plan.js +1070 -0
  67. package/dist/src/duck/generated.js +906 -0
  68. package/dist/src/duck/grants.js +1 -1
  69. package/dist/src/duck/held-types.js +64 -0
  70. package/dist/src/duck/history.js +4 -0
  71. package/dist/src/duck/jsonb-order.js +9 -12
  72. package/dist/src/duck/kept-route.js +6 -5
  73. package/dist/src/duck/kept.js +9 -7
  74. package/dist/src/duck/late.js +1469 -40
  75. package/dist/src/duck/libc-bound.js +341 -0
  76. package/dist/src/duck/libc-order.js +892 -126
  77. package/dist/src/duck/pg-eval-worker.js +7 -1
  78. package/dist/src/duck/pg-eval.js +6 -4
  79. package/dist/src/duck/pgtree.js +774 -23
  80. package/dist/src/duck/plan.js +275 -30
  81. package/dist/src/duck/publication.js +14 -0
  82. package/dist/src/duck/requests.js +9 -5
  83. package/dist/src/duck/resolved.js +117 -2
  84. package/dist/src/duck/rewrite.js +4567 -583
  85. package/dist/src/duck/row-filter.js +12 -6
  86. package/dist/src/duck/statement.js +19 -0
  87. package/dist/src/duck/store.js +294 -68
  88. package/dist/src/duck/stream.js +9 -5
  89. package/dist/src/duck/sync.js +602 -126
  90. package/dist/src/duck/token.js +4 -1
  91. package/dist/src/duck/types.js +63 -7
  92. package/dist/src/duck/usage.js +6 -4
  93. package/dist/src/duck/users.js +5 -0
  94. package/dist/src/duck/verified.js +159 -2
  95. package/dist/src/duck/views.js +4 -3
  96. package/dist/src/lib/database-url.js +55 -10
  97. package/dist/src/lib/instant.js +50 -0
  98. package/dist/src/lib/log-buffer.js +13 -4
  99. package/dist/src/lib/log-read.js +196 -0
  100. package/dist/src/lib/pg-numeric.js +387 -49
  101. package/dist/src/lib/secret-mask.js +142 -0
  102. package/dist/src/lib/table-sync.js +24 -9
  103. package/dist/src/web/app.js +9 -4
  104. package/dist/src/web/pages/status.js +178 -13
  105. package/dist/src/web/pages/syncs.js +1 -1
  106. package/dist/src/web/pages/tables.js +8 -3
  107. package/dist/src/web/pages/users.js +4 -3
  108. package/dist/src/web/shared.js +19 -3
  109. package/package.json +8 -7
@@ -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 */
@@ -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,24 +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) } : {}),
224
313
  ...(t.replica_identity === "d" || t.replica_identity === "n" || t.replica_identity === "f" || t.replica_identity === "i" ? { replicaIdentity: t.replica_identity } : {}),
225
314
  });
226
315
  }
227
316
  // the columns differ by version (datlocprovider from 15, daticulocale 15–16, datlocale from 17): read the row whole
228
317
  const db = JSON.parse((await pg.query("SELECT to_jsonb(d)::text FROM pg_database d WHERE datname = current_database()")).rows[0]?.[0] ?? "{}");
229
- // datcollversion (Postgres 15+): the library's version of it (glibc's for libc), which moves when the OS's does
230
- 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) };
231
321
  const views = JSON.parse((await pg.query(VIEWS_SQL)).rows[0]?.[0] ?? "[]")
232
322
  .map((v) => ({ schema: v.schema, name: v.name, definition: v.definition, comment: v.comment }));
233
- 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 };
234
325
  }
235
326
  /** The server's system identifier, or null when this user may not read it (only that refusal: any other error is real). */
236
327
  export async function systemIdentifier(pg) {
@@ -310,7 +401,7 @@ export function versionProblem(f) {
310
401
  * after the check's facts.
311
402
  */
312
403
  export async function checkSource(url, o) {
313
- const parsed = parseSource(url);
404
+ const parsed = parseSource(url, o.name);
314
405
  if ("problem" in parsed) {
315
406
  o.say(`${o.cmd}: ${parsed.problem}`);
316
407
  return { exit: 2 };
@@ -76,6 +76,53 @@ export function readProject(root) {
76
76
  }
77
77
  return { root, dir, folder: path.resolve(root, config.folder), config };
78
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
+ }
79
126
  /** Whether a lookup found a project it could read. */
80
127
  export const isProject = (p) => p !== null && !("broken" in p);
81
128
  /** What a broken project's commands say: the file, what is wrong with it, and the two ways out. */
@@ -150,12 +197,64 @@ export function databaseMoved(p, answersAs) {
150
197
  const says = connectDatabase(written, {}, {});
151
198
  return says === answersAs ? null : says;
152
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
+ }
153
249
  /**
154
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
155
251
  * (or --json) reads the answer, and by the run, which `mason start --attach` and `mason serve` reach without the parent.
156
252
  * One list, because a check in the run alone reaches a background start as "the run stopped", its way out in a log.
157
253
  */
158
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` };
159
258
  const top = () => sourcesTop(p.root, p.folder);
160
259
  const pollMs = pollMsRetired(env, top);
161
260
  if (pollMs !== null)
@@ -55,19 +55,30 @@ export function copyProgress(source, copied, rowsEstimate, p) {
55
55
  const c = copyingText(copied, rowsEstimate, p);
56
56
  return `${source}: ${c.fraction !== null ? `syncing ${Math.floor(c.fraction * 100)}% · ` : /^\d/.test(c.text) ? "syncing " : ""}${c.text}`;
57
57
  }
58
- export function syncStateOf(c) {
58
+ export function syncStateOf(c, now = Date.now()) {
59
59
  if (c === undefined || c.phase === "waiting")
60
60
  return "waiting";
61
61
  if (c.phase === "failed" || c.error !== null)
62
62
  return "failed";
63
63
  if (c.phase === "copying" || c.rebuilding !== undefined || c.behind !== undefined)
64
64
  return "syncing";
65
- return "synced";
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 };
66
77
  }
67
78
  export function tableSync(c, now) {
68
79
  const s = c?.synced;
69
- return { state: syncStateOf(c), syncedAt: s?.at ?? null, syncSeconds: s?.seconds ?? null, syncedBy: s?.by ?? null,
70
- ageSeconds: s === undefined ? null : Math.max(0, Math.round((now - Date.parse(s.at)) / 100) / 10) };
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) };
71
82
  }
72
83
  export function syncTotals(added, copies) {
73
84
  const copied = copies.filter((c) => c.phase === "copied");
@@ -95,8 +106,12 @@ export function readinessWord(r) {
95
106
  export function readiness(s) {
96
107
  const failed = [], waiting = [];
97
108
  for (const t of s.tables.filter((t) => t.added)) {
98
- if (t.error) {
99
- 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)")}`);
100
115
  continue;
101
116
  }
102
117
  if (t.copy?.phase === "copying") {
@@ -108,3 +123,24 @@ export function readiness(s) {
108
123
  }
109
124
  return { ready: failed.length === 0 && waiting.length === 0, failed, waiting };
110
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
+ }
@@ -12,6 +12,7 @@ import { ADD, LOGS, REMOVE, STATUS, STOP, TABLES, WAIT, add, logs, remove, statu
12
12
  import { USER, user } from "./user.js";
13
13
  import { MCP, mcp } from "./mcp.js";
14
14
  import { SHARE, share } from "./share.js";
15
+ import { DEFAULT_UI_PORT } from "./ports.js";
15
16
  import { UI, ui } from "./ui.js";
16
17
  import { GUIDES, guideText } from "./guides.js";
17
18
  import { DOCTOR, NODE_MIN, doctor } from "./doctor.js";
@@ -21,6 +22,10 @@ import { SYNC, sync } from "./sync.js";
21
22
  import { USAGE, usage } from "./usage.js";
22
23
  import { CANCEL, cancel } from "./cancel.js";
23
24
  import { SERVE, serve } from "./serve.js";
25
+ import { LOGIN, LOGOUT, login, logout } from "./login.js";
26
+ import { DEPLOY, deploy } from "./deploy.js";
27
+ import { REPLICA, replica } from "./replica-hosted.js";
28
+ import { TOKEN, token } from "./token-hosted.js";
24
29
  import { PROJECT_LINE } from "./dev.js";
25
30
  import { version } from "./version.js";
26
31
  import { didYouMean } from "../lib/did-you-mean.js";
@@ -68,6 +73,9 @@ export const DEV = {
68
73
  MASON_PG_TLS_KEY (PEM files), or --self-signed-tls, which makes a certificate for the project in .mason/ and serves
69
74
  it again at every later start with the flag. Every client then connects with sslmode=require. A team whose own proxy
70
75
  or load balancer terminates TLS keeps it on 127.0.0.1, or sets MASON_PG_PLAIN=1 for a private network.
76
+ Behind a TCP proxy that sends the PROXY protocol v2, MASON_PG_PROXY_FROM=<cidr>[,<cidr>…] names the proxy's own
77
+ addresses: a connection from one must begin with the header, whose client the request log names, and a header from
78
+ any other address is refused.
71
79
 
72
80
  ${PROJECT_LINE}
73
81
 
@@ -78,7 +86,7 @@ export const DEV = {
78
86
  --attach stay in the foreground (Ctrl-C stops Mason)
79
87
  --self-signed-tls the Postgres API requires TLS, from a certificate made for this project in .mason/ (kept and
80
88
  served again by every later start with the flag); not with MASON_PG_TLS_CERT / _KEY
81
- --port <n> the port the mason commands reach it on (default 4039, or the next free one)
89
+ --port <n> the port the mason commands reach it on (default ${DEFAULT_UI_PORT}, or the next free one)
82
90
  --json as data, once it is up: { running, pid, postgres: { url, host, port, database, sslmode }, next }
83
91
  `,
84
92
  options: {
@@ -103,7 +111,7 @@ async function runDev(argv) {
103
111
  // the database is the project's now: a URL here is the old habit, answered with the new way
104
112
  return failed("start", { message: "it takes no database URL: the project has one", code: "usage", why: "mason init <url> made it; .mason/config.json holds it", usage: DEV.usage }, { json: v.json });
105
113
  }
106
- const port = v.port === undefined ? 4039 : Number(v.port);
114
+ const port = v.port === undefined ? DEFAULT_UI_PORT : Number(v.port);
107
115
  if (!Number.isInteger(port) || port < 1 || port > 65_535)
108
116
  return failed("start", { message: `--port must be a port number (1-65535), not ${JSON.stringify(v.port)}`, code: "usage" }, { json: v.json });
109
117
  if (!v.attach)
@@ -169,7 +177,7 @@ export const STEPS = [
169
177
  ["Add your tables", [{ ...TABLES, run: tables }, { ...ADD, run: add }, { ...REMOVE, run: remove }, { ...WAIT, run: wait }]],
170
178
  ["Connect", [{ ...USER, run: user }, { ...MCP, run: mcp }, { ...SHARE, run: share }]],
171
179
  ["Run", [{ ...STATUS, run: status }, { ...UI, run: ui }, { ...SYNC, run: sync }, { ...LOGS, run: logs }, { ...CANCEL, run: cancel }, { ...USAGE, run: (argv) => usage(argv) }, { ...STOP, run: stop }, { ...DESTROY, run: (argv) => destroy(argv) }]],
172
- ["Go to production", [{ ...SERVE, run: serve }]],
180
+ ["Go to production", [{ ...SERVE, run: serve }, { ...LOGIN, run: login }, { ...LOGOUT, run: logout }, { ...REPLICA, run: replica }, { ...TOKEN, run: token }, { ...DEPLOY, run: deploy }]],
173
181
  ];
174
182
  /** Every command, in the order `mason --help` lists them. */
175
183
  export const COMMANDS = STEPS.flatMap(([, commands]) => commands);
@@ -233,7 +241,7 @@ export function topUsage(s = styleOut()) {
233
241
  para("A command means the project of the folder it runs in (or above it)."),
234
242
  para("Every command but help and mcp (whose stdout is the MCP protocol's) takes --json: one JSON object for a program, always with next (what to run " +
235
243
  "next); a failure is { error: { code, message, why, fix }, next }. The exit code says how it went: 0 done, 1 failed " +
236
- "with the reason, 2 a wrong command line, 124 mason wait timed out. Driving Mason from a coding agent: mason help agent."),
244
+ "with the reason, 2 a wrong command line, 124 a wait timed out (mason wait, mason deploy, mason replica create). Driving Mason from a coding agent: mason help agent."),
237
245
  "",
238
246
  ].join("\n");
239
247
  }