@mason-data/cli 0.7.0 → 0.8.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 (111) hide show
  1. package/README.md +90 -71
  2. package/dist/src/api/error.js +3 -0
  3. package/dist/src/api/postgres/catalog.js +222 -13
  4. package/dist/src/api/postgres/encode.js +3 -1
  5. package/dist/src/api/postgres/explain.js +3 -2
  6. package/dist/src/api/postgres/protocol.js +39 -23
  7. package/dist/src/api/postgres/server.js +1173 -207
  8. package/dist/src/api/postgres/translate.js +81 -9
  9. package/dist/src/api/request-log.js +18 -5
  10. package/dist/src/cdc/poll.js +5 -5
  11. package/dist/src/cli/background.js +29 -6
  12. package/dist/src/cli/cancel.js +75 -0
  13. package/dist/src/cli/command.js +1 -0
  14. package/dist/src/cli/commands.js +622 -183
  15. package/dist/src/cli/control.js +59 -0
  16. package/dist/src/cli/demo.js +19 -10
  17. package/dist/src/cli/dev.js +351 -111
  18. package/dist/src/cli/doctor.js +20 -10
  19. package/dist/src/cli/guides.js +79 -31
  20. package/dist/src/cli/init.js +119 -58
  21. package/dist/src/cli/machine.js +12 -11
  22. package/dist/src/cli/main.js +3 -3
  23. package/dist/src/cli/mcp.js +29 -20
  24. package/dist/src/cli/metrics.js +79 -0
  25. package/dist/src/cli/pg-tls.js +13 -3
  26. package/dist/src/cli/pg-url.js +4 -0
  27. package/dist/src/cli/pin.js +13 -12
  28. package/dist/src/cli/preflight.js +8 -7
  29. package/dist/src/cli/project.js +43 -4
  30. package/dist/src/cli/readiness.js +38 -4
  31. package/dist/src/cli/registry.js +53 -34
  32. package/dist/src/cli/replication-checks.js +7 -5
  33. package/dist/src/cli/replication-mode.js +105 -26
  34. package/dist/src/cli/request-filter.js +134 -0
  35. package/dist/src/cli/roles.js +584 -0
  36. package/dist/src/cli/run-server.js +483 -41
  37. package/dist/src/cli/runs.js +1 -1
  38. package/dist/src/cli/security.js +10 -8
  39. package/dist/src/cli/serve.js +144 -42
  40. package/dist/src/cli/share-run.js +286 -0
  41. package/dist/src/cli/share.js +286 -0
  42. package/dist/src/cli/source-edit.js +65 -13
  43. package/dist/src/cli/sources.js +287 -38
  44. package/dist/src/cli/supervise.js +12 -1
  45. package/dist/src/cli/sync.js +55 -25
  46. package/dist/src/cli/tunnel.js +200 -0
  47. package/dist/src/cli/ui.js +9 -6
  48. package/dist/src/cli/usage.js +128 -0
  49. package/dist/src/cli/user.js +496 -40
  50. package/dist/src/cli/version.js +9 -7
  51. package/dist/src/demo/seed.js +3 -0
  52. package/dist/src/duck/cancel.js +10 -1
  53. package/dist/src/duck/events.js +134 -0
  54. package/dist/src/duck/executor-worker.js +18 -9
  55. package/dist/src/duck/executor.js +43 -30
  56. package/dist/src/duck/explain.js +56 -46
  57. package/dist/src/duck/extensions.js +160 -0
  58. package/dist/src/duck/functions.js +13 -14
  59. package/dist/src/duck/gaps.js +17 -8
  60. package/dist/src/duck/grants.js +176 -0
  61. package/dist/src/duck/history.js +123 -0
  62. package/dist/src/duck/kept.js +6 -6
  63. package/dist/src/duck/plan.js +102 -25
  64. package/dist/src/duck/publication.js +6 -6
  65. package/dist/src/duck/requests.js +265 -93
  66. package/dist/src/duck/rewrite.js +25 -5
  67. package/dist/src/duck/row-filter.js +649 -0
  68. package/dist/src/duck/store.js +156 -120
  69. package/dist/src/duck/stream.js +10 -5
  70. package/dist/src/duck/sync.js +835 -268
  71. package/dist/src/duck/types.js +6 -5
  72. package/dist/src/duck/usage.js +324 -0
  73. package/dist/src/duck/users.js +64 -15
  74. package/dist/src/duck/verified.js +3 -0
  75. package/dist/src/duck/views.js +2 -3
  76. package/dist/src/lib/database-name.js +62 -0
  77. package/dist/src/lib/dotenv.js +17 -4
  78. package/dist/src/lib/log-buffer.js +68 -0
  79. package/dist/src/lib/pg-numeric.js +5 -2
  80. package/dist/src/lib/project-version.js +23 -23
  81. package/dist/src/lib/table-sync.js +267 -0
  82. package/dist/src/web/app.css +706 -66
  83. package/dist/src/web/app.js +327 -410
  84. package/dist/src/web/charts.js +229 -0
  85. package/dist/src/web/index.html +82 -18
  86. package/dist/src/web/pages/queries.js +192 -0
  87. package/dist/src/web/pages/status.js +446 -0
  88. package/dist/src/web/pages/syncs.js +155 -0
  89. package/dist/src/web/pages/tables.js +434 -0
  90. package/dist/src/web/pages/usage.js +397 -0
  91. package/dist/src/web/pages/users.js +218 -0
  92. package/dist/src/web/shared.js +568 -0
  93. package/dist/src/web/vendor/README.md +25 -0
  94. package/dist/src/web/vendor/geist-1.7.2/Geist-Variable.woff2 +0 -0
  95. package/dist/src/web/vendor/geist-1.7.2/GeistMono-Variable.woff2 +0 -0
  96. package/dist/src/web/vendor/geist-1.7.2/LICENSE.txt +92 -0
  97. package/dist/src/web/vendor/lucide-1.56.0/LICENSE.txt +43 -0
  98. package/dist/src/web/vendor/lucide-1.56.0/chart-column.mjs +29 -0
  99. package/dist/src/web/vendor/lucide-1.56.0/layout-dashboard.mjs +29 -0
  100. package/dist/src/web/vendor/lucide-1.56.0/logs.mjs +34 -0
  101. package/dist/src/web/vendor/lucide-1.56.0/menu.mjs +28 -0
  102. package/dist/src/web/vendor/lucide-1.56.0/square-terminal.mjs +28 -0
  103. package/dist/src/web/vendor/lucide-1.56.0/table.mjs +29 -0
  104. package/dist/src/web/vendor/lucide-1.56.0/users.mjs +29 -0
  105. package/dist/src/web/vendor/lucide-1.56.0/x.mjs +27 -0
  106. package/dist/src/web/vendor/uplot-1.6.32/LICENSE.txt +21 -0
  107. package/dist/src/web/vendor/uplot-1.6.32/uPlot.esm.js +6140 -0
  108. package/dist/src/web/vendor/uplot-1.6.32/uPlot.min.css +1 -0
  109. package/dist/src/web/vendor/zilla-slab-5.3.0/LICENSE.txt +93 -0
  110. package/dist/src/web/vendor/zilla-slab-5.3.0/zilla-slab-latin-700-normal.woff2 +0 -0
  111. package/package.json +10 -3
@@ -61,7 +61,7 @@ export const END_GRACE_MS = 15_000;
61
61
  export function notAnswering(p, run) {
62
62
  return { problem: `Mason for ${p.config.name} is running (pid ${run.pid}) but does not answer its commands (${run.control.url}, 3 s)`, code: "not_answering",
63
63
  why: "a statement may be holding its main thread",
64
- next: [["mason stop", `end it (SIGTERM, then SIGKILL after ${END_GRACE_MS / 1000} s); its copy stays and the next mason start resumes`], ["mason logs", "what it was doing"]] };
64
+ next: [["mason stop", `end it (SIGTERM, then SIGKILL after ${END_GRACE_MS / 1000} s); its synced data stays and the next mason start resumes`], ["mason logs", "what it was doing"]] };
65
65
  }
66
66
  /** Whether `pid` is a Mason run, by its command line (`mason start … --attach`, src/cli/background.ts). ps exits 1 for a pid that is gone. */
67
67
  export function masonProcess(pid) {
@@ -22,19 +22,21 @@
22
22
  * no Origin: a browser sends an Authorization header across sites only after a CORS preflight, which these servers
23
23
  * never approve, so a page on another site cannot send one at all.
24
24
  *
25
- * Cookies are per host, not per port, so one cookie serves every page of a run; a page of another run, or one
26
- * the token does not match, is refused. Pure over the request's headers, so the refusals are unit-tested
25
+ * A browser keeps cookies per host, never per port, so the cookie is NAMED for the run's port (`cookieName`,
26
+ * `mason_dev_<port>`): two runs on one machine each keep their own, and opening one's page never logs the other's out
27
+ * (one name for every run did). A page of another run, or one the token does not match, is refused. Pure over the request's headers, so the refusals are unit-tested
27
28
  * (tests/cli-security.test.ts).
28
29
  */
29
30
  import { randomBytes, timingSafeEqual } from "node:crypto";
30
- export const COOKIE = "mason_dev";
31
+ /** The cookie of the run whose server is on `port`: one per run, as a browser shares a host's cookies across ports. */
32
+ export const cookieName = (port) => `mason_dev_${port}`;
31
33
  export function newToken() { return randomBytes(32).toString("hex"); }
32
34
  /** Constant-time string equality (a token compared byte by byte leaks its prefix through timing). */
33
35
  export function sameSecret(a, b) {
34
36
  const x = Buffer.from(a), y = Buffer.from(b);
35
37
  return x.length === y.length && timingSafeEqual(x, y);
36
38
  }
37
- export function cookieOf(header, name = COOKIE) {
39
+ export function cookieOf(header, name) {
38
40
  for (const part of (header ?? "").split(";")) {
39
41
  const eq = part.indexOf("=");
40
42
  if (eq > 0 && part.slice(0, eq).trim() === name)
@@ -42,8 +44,8 @@ export function cookieOf(header, name = COOKIE) {
42
44
  }
43
45
  return null;
44
46
  }
45
- export function setCookieHeader(token) {
46
- return `${COOKIE}=${token}; HttpOnly; SameSite=Strict; Path=/`;
47
+ export function setCookieHeader(token, port) {
48
+ return `${cookieName(port)}=${token}; HttpOnly; SameSite=Strict; Path=/`;
47
49
  }
48
50
  /** Whether `req` may be served by the server on `port` whose run token is `token`. */
49
51
  export function guard(req, port, token) {
@@ -62,9 +64,9 @@ export function guard(req, port, token) {
62
64
  if (!sameSecret(offered, token))
63
65
  return { ok: false, status: 403, reason: "the token in the URL is not this run's" };
64
66
  url.searchParams.delete("token");
65
- return { ok: false, status: 303, location: url.pathname + (url.search || "") + url.hash, setCookie: setCookieHeader(token) };
67
+ return { ok: false, status: 303, location: url.pathname + (url.search || "") + url.hash, setCookie: setCookieHeader(token, port) };
66
68
  }
67
- const cookie = cookieOf(req.cookie);
69
+ const cookie = cookieOf(req.cookie, cookieName(port));
68
70
  if (cookie === null || !sameSecret(cookie, token)) {
69
71
  return { ok: false, status: 403, reason: "this is mason start's command server for the mason command: a request carries the run's token (Authorization: Bearer, from .mason/config.json)" };
70
72
  }
@@ -13,6 +13,8 @@
13
13
  * platform restarts it; there is no supervisor of its own (src/cli/supervise.ts is `mason start`'s).
14
14
  * - A HEALTH ENDPOINT (`healthBody`): `GET /healthz` on MASON_HEALTH_PORT, 503 while it starts and stops, 200 once it
15
15
  * answers. It needs no credential, so it names nothing of the database: counts only.
16
+ * - METRICS BESIDE IT (src/cli/metrics.ts): `GET /metrics` on the same port, Prometheus's text format, numbers only
17
+ * (counts, lag, ages, sizes) for the same reason: never a table's, a user's or a host's name, never a statement.
16
18
  * - A CLEAN STOP ON SIGTERM: the run's own stop (a copy in flight interrupted and rolled back, DuckDB closed).
17
19
  *
18
20
  * The commands beside it work as on a laptop, in the data folder: `docker compose exec mason mason add orders`.
@@ -24,9 +26,17 @@ import { failed, parseCommand } from "./command.js";
24
26
  import { dev } from "./dev.js";
25
27
  import { parseSource } from "./preflight.js";
26
28
  import { NAME_RE, newConfig, readProject, STATE_DIR, writeConfig } from "./project.js";
27
- import { newSourceText, OLD_TABLES_DIR, parseSourceText, readSource, sourceFileNames, sourceNameFor, sourcesDir } from "./sources.js";
29
+ import { newSourceText, OLD_TABLES_DIR, parseSourceText, readSource, sourceFileNames, sourceNameFor, sourcesDir, sourcesTop, writtenDatabase } from "./sources.js";
30
+ import { connectDatabase, DATABASE_SETTING, databaseNameProblem } from "../lib/database-name.js";
28
31
  import { appEnvValue } from "../lib/dotenv.js";
29
- import { version } from "./version.js";
32
+ import { imagePinProblem, PIN_FILE } from "../lib/project-version.js";
33
+ import { commandPinProblem } from "./pin.js";
34
+ import { recordRefusal } from "./replication-mode.js";
35
+ import { changesRetiredText, pollMsRetiredText } from "../lib/table-sync.js";
36
+ import { historyDaysSetting, historyDaysWrongText } from "../duck/history.js";
37
+ import { runningVersion, version } from "./version.js";
38
+ import { phaseCounts } from "./readiness.js";
39
+ import { METRICS_CONTENT_TYPE, metricsText } from "./metrics.js";
30
40
  export const SERVE = {
31
41
  name: "serve",
32
42
  summary: "run Mason on a server or in a container, in the foreground, configured by the environment",
@@ -34,32 +44,38 @@ export const SERVE = {
34
44
 
35
45
  Runs Mason as mason start --attach does, for a server or a container: nothing asked, its settings from the
36
46
  environment, its state in one folder (a volume), its log on stdout, GET /healthz for the platform's health check,
37
- and a clean stop on SIGTERM. The tables it copies are the ones the project folder's sources file names
38
- (sources/<name>.yml): make it with mason init and mason add on a laptop and commit it, or with mason add beside the
39
- running server. A project folder with no sources file gets one, url: \${DATABASE_URL}, with no table yet.
47
+ GET /metrics for Prometheus (numbers only: no name, no statement), and a clean stop on SIGTERM. The tables it
48
+ syncs are the ones the project folder's sources file names (sources/<name>.yml): make it with mason init and mason
49
+ add on a laptop and commit it, or with mason add beside the running server. A project folder with no sources file
50
+ gets one, url: \${DATABASE_URL}, with no table yet.
40
51
 
41
52
  DATABASE_URL=postgres://mason:…@db.internal:5432/app MASON_API_KEY=… mason serve
42
53
  docker compose exec mason mason add public.orders beside it, in its container
43
54
 
44
55
  Settings (the environment):
45
- DATABASE_URL the database to copy from, as the sources file's url: \${DATABASE_URL} names it (the variable
56
+ DATABASE_URL the database to sync from, as the sources file's url: \${DATABASE_URL} names it (the variable
46
57
  the file names is required); read-only: SELECTs, or a replication slot
58
+ PG_PUBLICATION the publication the tables under sync: replication are read through, the database owner's,
59
+ never altered: the sources file's publication: first (publication: \${PG_PUBLICATION} reads
60
+ this one), else this; default none: Mason makes its own
47
61
  MASON_API_KEY what the users' passwords are made with (mason user add), at least 32 characters (required)
48
62
  MASON_PROJECT the project folder: sources/, mason.yml (default ./mason)
49
- MASON_DATA the folder whose .mason/ holds the copy and the state: the volume (default .)
50
- MASON_NAME the project's name, the database name clients connect to (default mason)
51
- MASON_CHANGES poll (SELECTs, the default) or replication (the change log; PG_PUBLICATION names one)
52
- MASON_HEALTH_PORT /healthz's port (default 4140), on MASON_HEALTH_HOST (default 0.0.0.0)
63
+ MASON_DATA the folder whose .mason/ holds the synced data and the state: the volume (default .)
64
+ MASON_NAME the project's name, which the replication slot is named for (default mason); renaming it
65
+ over the same synced data is refused
66
+ MASON_PG_DATABASE the database name a tool connects with, for a sources file with no database: (default mason;
67
+ the two must agree when both are set)
68
+ MASON_HEALTH_PORT /healthz's and /metrics' port (default 4140), on MASON_HEALTH_HOST (default 0.0.0.0)
53
69
  MASON_PG_HOST, MASON_PG_PORT the Postgres API's address (default 127.0.0.1:5433)
54
70
  MASON_PG_TLS_CERT, MASON_PG_TLS_KEY its certificate, required off loopback; or MASON_PG_SELF_SIGNED_TLS=1
55
71
  for one made in .mason/, or MASON_PG_PLAIN=1 behind a TLS proxy of your own
56
- MASON_DUCKDB_MEMORY the copy's memory, 4GB or 512MiB (default half the container's memory limit, else half the
72
+ MASON_DUCKDB_MEMORY the engine's memory, 4GB or 512MiB (default half the container's memory limit, else half the
57
73
  machine's RAM; mason doctor says it); a statement past it fails alone with 53200
58
- MASON_POLL_MS how often the poll reads the tables' changes, in ms (default 5000)
59
74
  MASON_STATEMENT_TIMEOUT each statement's limit, as Postgres's statement_timeout (default 5min; 0 for none)
60
- MASON_REQUEST_LOG_DAYS how long the request log keeps a statement, in days (default 7)
75
+ MASON_HISTORY_DAYS how many days Mason keeps its history: the request log, the sync's events, the usage
76
+ samples and the synced tables' sizes (default 7)
61
77
  MASON_STORE_JSON_PATHS on (the default) stores the JSON paths statements keep reading as columns; off stores none
62
- MASON_COPY_PARALLEL a copy's chunks read at once, each a backend on the database (1 to 8, default 3)
78
+ MASON_COPY_PARALLEL a first sync's chunks read at once, each a backend on the database (1 to 8, default 3)
63
79
  MASON_COPY_CHUNK_SECONDS how long each chunk takes to read, at the rate measured (1 to 300, default 12)
64
80
 
65
81
  --json a refusal to start as data: { error, next }; once it serves, its output is its log
@@ -85,9 +101,21 @@ export function serveSettings(env, cwd) {
85
101
  const name = env.MASON_NAME || "mason";
86
102
  if (!NAME_RE.test(name))
87
103
  wrong.push(`MASON_NAME=${JSON.stringify(name)} is not a project name: lower case letters, digits, - and _, at most 48`);
88
- const changes = env.MASON_CHANGES || null;
89
- if (changes !== null && changes !== "poll" && changes !== "replication")
90
- wrong.push(`MASON_CHANGES=${JSON.stringify(changes)}: poll or replication`);
104
+ // the database name a tool connects with: the sources file's, else this, and never two names at once
105
+ wrong.push(...databaseSettingProblems(project, env));
106
+ // how each table is kept current, and how often a table is polled, are the sources file's sync: now, per table
107
+ // (src/lib/table-sync.ts): the old settings refused HERE with the others, before anything is written or connected
108
+ const top = () => sourcesTop(path.dirname(project), project);
109
+ const changes = changesRetiredText(env, top);
110
+ if (changes !== null)
111
+ wrong.push(changes);
112
+ const pollMs = pollMsRetiredText(env, top);
113
+ if (pollMs !== null)
114
+ wrong.push(pollMs);
115
+ // the history's days: MASON_HISTORY_DAYS, a number of days; the old name refused (src/duck/history.ts)
116
+ const days = historyDaysSetting(env);
117
+ if (typeof days !== "number")
118
+ wrong.push(historyDaysWrongText(days));
91
119
  const selfSigned = env.MASON_PG_SELF_SIGNED_TLS || "0";
92
120
  if (selfSigned !== "0" && selfSigned !== "1")
93
121
  wrong.push(`MASON_PG_SELF_SIGNED_TLS=${JSON.stringify(selfSigned)}: 1, or 0 (the default)`);
@@ -102,11 +130,27 @@ export function serveSettings(env, cwd) {
102
130
  next: [["mason serve --help", "every setting, with its default"]] };
103
131
  }
104
132
  return {
105
- name, apiKey, changes: changes, selfSignedTls: selfSigned === "1",
133
+ name, apiKey, selfSignedTls: selfSigned === "1",
106
134
  project, data,
107
135
  health: { host: env.MASON_HEALTH_HOST || "0.0.0.0", port },
108
136
  };
109
137
  }
138
+ /**
139
+ * THE DATABASE NAME AS A SETTING (src/lib/database-name.ts): a name, and one the sources file does not contradict, since
140
+ * two names for one thing would have to be chosen between. A file that cannot be read is serveSource's to say.
141
+ */
142
+ function databaseSettingProblems(project, env) {
143
+ const set = env[DATABASE_SETTING];
144
+ if (!set)
145
+ return [];
146
+ const bad = databaseNameProblem(set);
147
+ if (bad !== null)
148
+ return [`${DATABASE_SETTING}: ${bad}`];
149
+ const written = writtenDatabase(project, path.dirname(project));
150
+ if ("message" in written || written.database === null || written.database === set)
151
+ return [];
152
+ return [`${DATABASE_SETTING}=${JSON.stringify(set)}, and the sources file says database: ${written.database}: keep one`];
153
+ }
110
154
  /**
111
155
  * THE DATABASE'S URL AS A SETTING (the review of the sources file: an unset URL was found only after the other
112
156
  * settings passed, two deploys for two variables): the variable the project's sources file names — DATABASE_URL when it
@@ -141,25 +185,25 @@ function urlSettingProblems(project, data, env) {
141
185
  return [];
142
186
  const found = appEnvValue(data, variable, env, { shellFirst: true });
143
187
  if (found === null)
144
- return [`${variable} is not set: the database to copy from, as the sources file's url: \${${variable}} names it`];
188
+ return [`${variable} is not set: the database to sync from, as the sources file's url: \${${variable}} names it`];
145
189
  const p = parseSource(found.value);
146
190
  return "problem" in p ? [`${variable}: ${p.problem}`] : [];
147
191
  }
148
192
  /**
149
193
  * THE CONFIG A SERVE RUNS ON, written from its settings at every start, over what the runs before recorded (the server
150
194
  * the copy was taken from, the publication, the certificate): the environment is the operator's, the rest the run's. A
151
- * project `mason init` made is a developer's and is never taken over; a name that moved would be another slot and
152
- * another database name over the same copy, so it is refused.
195
+ * project `mason init` made is a developer's and is never taken over; a name that moved would be another slot over the
196
+ * same copy, so it is refused.
153
197
  */
154
198
  export function serveConfig(s, existing) {
155
199
  const dir = path.join(s.data, STATE_DIR);
156
200
  if (existing === null) {
157
201
  const config = { ...newConfig(s.name, s.project, true), apiKey: s.apiKey, served: true };
158
- return { config, said: [`a new copy in ${dir}`] };
202
+ return { config, said: [`new synced data in ${dir}`] };
159
203
  }
160
204
  if ("broken" in existing) {
161
205
  return { message: `the state in ${dir} cannot be read: ${existing.file}: ${existing.broken}`, code: "bad_config",
162
- fix: `MASON_DATA names a folder of its own for mason serve; move ${dir} aside to copy again` };
206
+ fix: `MASON_DATA names a folder of its own for mason serve; move ${dir} aside to sync again` };
163
207
  }
164
208
  const was = existing.config;
165
209
  if (was.served !== true) {
@@ -168,17 +212,27 @@ export function serveConfig(s, existing) {
168
212
  fix: "point MASON_DATA at an empty folder (the volume), or run mason start here" };
169
213
  }
170
214
  if (was.name !== s.name) {
171
- return { message: `the copy in ${dir} is the project ${was.name}, and MASON_NAME says ${s.name}`, code: "refused",
172
- why: "the name is the database name clients connect to and names the replication slot: another name over the same copy is another project",
173
- fix: `set MASON_NAME=${was.name} again, or give the new name a new MASON_DATA (an empty volume), which copies again` };
215
+ return { message: `the synced data in ${dir} is the project ${was.name}, and MASON_NAME says ${s.name}`, code: "refused",
216
+ why: "the name names the replication slot: another name over the same synced data is another project (the database name a tool connects with is not this: MASON_PG_DATABASE, or database: in the sources file)",
217
+ fix: `set MASON_NAME=${was.name} again, or give the new name a new MASON_DATA (an empty volume), which syncs again` };
174
218
  }
175
- const said = [`the copy in ${dir}, resumed`];
219
+ const said = [`the synced data in ${dir}, resumed`];
176
220
  if (was.apiKey !== s.apiKey)
177
221
  said.push("MASON_API_KEY changed: every mason user is removed (mason user add each again)");
178
222
  // the source is the sources file's; a database restored elsewhere, or another one, is the run's to see (sourceMove)
179
223
  const { source: _old, ...kept } = was;
180
224
  return { config: { ...kept, apiKey: s.apiKey, folder: s.project, remote: true, served: true }, said };
181
225
  }
226
+ /**
227
+ * THE PROJECT'S PIN, read from the project folder (MASON_PROJECT), in the words of what is running (src/lib/project-version.ts):
228
+ * the image names the image to run, the package the npm line to run, each to the reader who can act on it. Before
229
+ * anything is written or connected, so a project of another release line leaves a volume as it found it. A build that
230
+ * is no release checks nothing.
231
+ */
232
+ export function servePin(s, running) {
233
+ const p = (running.from === "image" ? imagePinProblem : commandPinProblem)(s.project, running.text);
234
+ return p && { message: p.problem, code: "refused", fix: p.ways.join(", or ") || `correct ${PIN_FILE} in the project folder (MASON_PROJECT), then start it again` };
235
+ }
182
236
  /**
183
237
  * THE PROJECT FOLDER'S SOURCES FILE, for a serve: one there is checked as the run reads it (src/cli/sources.ts
184
238
  * readSource: its URL resolved from this environment, every mistake on one report); a folder with none gets the file
@@ -206,36 +260,75 @@ export function serveSource(s, env) {
206
260
  wrote = file;
207
261
  }
208
262
  const r = readSource(s.project, s.data, env, { shellFirst: true });
209
- return "message" in r ? r : { wrote };
263
+ return "message" in r ? r : { wrote, database: connectDatabase(r, { served: true }, env) };
264
+ }
265
+ /**
266
+ * WHAT THE VOLUME'S CONFIG RECORDS AND THE SOURCES FILE DOES NOT SAY (src/cli/replication-mode.ts recordRefusal), asked
267
+ * BEFORE `serveSource` and the config are written: a file a serve writes itself says `sync: off`, no one's choice, and a
268
+ * refused serve leaves the volume and the project folder as it found them. A file that cannot be read, and a project a
269
+ * serve does not take over, are said by the steps after this.
270
+ */
271
+ export function serveRecordRefusal(s, existing, env) {
272
+ if (existing === null || "broken" in existing || existing.config.served !== true)
273
+ return null;
274
+ const written = sourceFileNames(s.project).length > 0;
275
+ if (!written && fs.existsSync(path.join(s.project, OLD_TABLES_DIR)))
276
+ return null;
277
+ const file = written ? readSource(s.project, s.data, env, { shellFirst: true }) : null;
278
+ if (file !== null && "message" in file)
279
+ return null;
280
+ return recordRefusal(existing.config, file, env, sourcesTop(path.dirname(s.project), s.project).shown, "mason serve");
210
281
  }
282
+ /** seconds since the serve started: /healthz's `uptimeSeconds` and /metrics' `mason_uptime_seconds`, one number */
283
+ const uptimeSeconds = (startedAt, now) => Math.floor((now - startedAt) / 1000);
211
284
  /**
212
285
  * /healthz's answer: 200 once the run answers, whatever its copies are doing (a table still copying, or one that failed,
213
286
  * leaves the rest served: the counts say it); 503 while it starts and stops. Counts only, never a table's name: the
214
287
  * endpoint takes no credential.
215
288
  */
216
289
  export function healthBody(state, startedAt, now = Date.now()) {
217
- const base = { status: state.phase, version: version(), uptimeSeconds: Math.floor((now - startedAt) / 1000) };
290
+ const base = { status: state.phase, version: version(), uptimeSeconds: uptimeSeconds(startedAt, now) };
218
291
  if (state.phase !== "ready")
219
292
  return { status: 503, body: base };
220
- const counts = { total: 0, copied: 0, copying: 0, waiting: 0, failed: 0 };
221
- for (const t of state.tables()) {
222
- counts.total++;
223
- counts[t.phase]++;
224
- }
225
- return { status: 200, body: { ...base, tables: counts } };
293
+ return { status: 200, body: { ...base, tables: phaseCounts(state.tables()) } };
226
294
  }
227
- /** The health endpoint: GET /healthz, nothing else. */
228
- export function healthServer(state, startedAt) {
295
+ /**
296
+ * /metrics' answer: ALWAYS 200 (a scrape that errors while Mason starts is an alert about nothing; whether it is up is
297
+ * /healthz's to say) — `mason_ready 0` and `mason_info` while it starts or stops, the run's numbers once it serves.
298
+ */
299
+ export async function metricsBody(state, startedAt, now = Date.now()) {
300
+ if (state.phase !== "ready")
301
+ return metricsText({ version: version(), run: null });
302
+ const run = await state.metrics();
303
+ return metricsText({ version: version(), run: { ...run, uptimeSeconds: uptimeSeconds(startedAt, now) } });
304
+ }
305
+ /** The health server: GET /healthz and GET /metrics, nothing else. `log` says a scrape whose numbers could not be read. */
306
+ export function healthServer(state, startedAt, log = say) {
229
307
  return http.createServer((req, res) => {
230
308
  const url = new URL(req.url ?? "/", "http://health");
231
- if (url.pathname !== "/healthz") {
232
- res.writeHead(404, { "content-type": "text/plain" }).end("GET /healthz\n");
309
+ if (url.pathname !== "/healthz" && url.pathname !== "/metrics") {
310
+ res.writeHead(404, { "content-type": "text/plain" }).end("GET /healthz or GET /metrics\n");
233
311
  return;
234
312
  }
235
313
  if (req.method !== "GET" && req.method !== "HEAD") {
236
314
  res.writeHead(405, { allow: "GET, HEAD" }).end();
237
315
  return;
238
316
  }
317
+ if (url.pathname === "/metrics") {
318
+ const asked = state();
319
+ metricsBody(asked, startedAt).then((body) => { res.writeHead(200, { "content-type": METRICS_CONTENT_TYPE, "cache-control": "no-store" }).end(req.method === "HEAD" ? undefined : body); }, (e) => {
320
+ // a run stopping under the scrape (its copy closing): said as stopping, as it now is
321
+ if (state().phase !== "ready") {
322
+ res.writeHead(200, { "content-type": METRICS_CONTENT_TYPE, "cache-control": "no-store" }).end(req.method === "HEAD" ? undefined : metricsText({ version: version(), run: null }));
323
+ return;
324
+ }
325
+ // anything else fails the scrape (Prometheus's `up` turns 0) and is said in the log; the error's text stays in
326
+ // the log, never in the answer, which takes no credential (it could name a table or a host)
327
+ log(` /metrics: the run's numbers could not be read: ${e.message}`);
328
+ res.writeHead(500, { "content-type": "text/plain", "cache-control": "no-store" }).end("the run's numbers could not be read: mason serve's log says why\n");
329
+ });
330
+ return;
331
+ }
239
332
  const h = healthBody(state(), startedAt);
240
333
  res.writeHead(h.status, { "content-type": "application/json", "cache-control": "no-store" }).end(`${JSON.stringify(h.body)}\n`);
241
334
  });
@@ -251,11 +344,18 @@ export async function serve(argv) {
251
344
  const s = serveSettings(process.env, process.cwd());
252
345
  if ("message" in s)
253
346
  return failed("serve", s, { json });
347
+ const pinned = servePin(s, runningVersion());
348
+ if (pinned)
349
+ return failed("serve", pinned, { json });
350
+ const existing = readProject(s.data);
351
+ const unsaid = serveRecordRefusal(s, existing, process.env);
352
+ if (unsaid !== null)
353
+ return failed("serve", unsaid, { json });
254
354
  // the project folder: its sources file is the list of what is copied, one with no table written when there is none
255
355
  const made = serveSource(s, process.env);
256
356
  if ("message" in made)
257
357
  return failed("serve", made, { json });
258
- const c = serveConfig(s, readProject(s.data));
358
+ const c = serveConfig(s, existing);
259
359
  if ("message" in c)
260
360
  return failed("serve", c, { json });
261
361
  try {
@@ -281,13 +381,15 @@ export async function serve(argv) {
281
381
  say(` wrote ${made.wrote}: the database (url: \${DATABASE_URL}), no table yet (mason add beside it adds one)`);
282
382
  for (const l of c.said)
283
383
  say(` ${l}`);
384
+ say(` database ${made.database}, what a tool connects with`);
284
385
  say(` health http://${s.health.host}:${s.health.port}/healthz`);
386
+ say(` metrics http://${s.health.host}:${s.health.port}/metrics`);
285
387
  // the run reads the project of the folder it runs in (src/cli/project.ts): the data folder's
286
388
  process.chdir(s.data);
287
389
  let code;
288
390
  try {
289
- code = await dev({ port: 4039, version: version(), changes: s.changes, publication: null, selfSignedTls: s.selfSignedTls,
290
- serve: { log: say, ready: (run) => { state = { phase: "ready", tables: run.tables }; }, stopping: () => { state = { phase: "stopping" }; } } });
391
+ code = await dev({ port: 4039, version: version(), selfSignedTls: s.selfSignedTls,
392
+ serve: { log: say, ready: (run) => { state = { phase: "ready", tables: run.tables, metrics: run.metrics }; }, stopping: () => { state = { phase: "stopping" }; } } });
291
393
  }
292
394
  finally {
293
395
  // a platform's health check may hold a keep-alive connection open, which close() alone would wait for