@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
@@ -9,8 +9,8 @@
9
9
  * a database on another machine only when the project was made with --remote. A database restored to another server
10
10
  * is read again (its rows compared, the unchanged kept); another database is refused (mason destroy, then mason start).
11
11
  * 2. THE COPY (src/duck/store.ts, src/duck/sync.ts): DuckDB in this process, file-backed in `.mason/duck.db`; each
12
- * added table copied whole from the database and kept current by the xmin poll, or by logical replication
13
- * (--replication, src/duck/stream.ts).
12
+ * added table copied whole from the database and kept current as its sync: in the sources file says: by the
13
+ * xmin poll, by logical replication (src/duck/stream.ts), or only when mason sync asks (off).
14
14
  * 3. THE COMMAND SERVER (src/cli/run-server.ts) on 127.0.0.1, which `mason tables`, `mason add`, `mason status` …
15
15
  * ask with the run's token from the project's config (src/cli/control.ts, src/cli/security.ts).
16
16
  * 4. THE POSTGRES LISTENER (src/api/postgres/server.ts): a BI tool or an app connects as to a read replica, as a
@@ -29,27 +29,36 @@ import * as path from "node:path";
29
29
  import { choosePgPort, freePort } from "./ports.js";
30
30
  import { failed } from "./command.js";
31
31
  import { checkSource, connectSource, maskedUrl, readSourceFacts, remoteConfirmation, remoteWarning } from "./preflight.js";
32
- import { createRunServer } from "./run-server.js";
33
- import { addedTables, rereadSources } from "./sources.js";
32
+ import { createRunServer, metricsFacts, usageFacts } from "./run-server.js";
33
+ import { addedTables, effectiveSync, entryName, replicatedEntries, rereadSources, shownPath, syncWritten } from "./sources.js";
34
34
  import { newToken } from "./security.js";
35
35
  import { liveRun } from "./runs.js";
36
- import { DEMO_DIR, DUCK_FILE, projectSource, theProject, writeConfig } from "./project.js";
36
+ import { DEMO_DIR, DUCK_FILE, projectSource, startSettingRefusal, theProject, writeConfig } from "./project.js";
37
37
  import { closedWithin, closingOnce, DEMO_STILL_CLOSING, demoChanges, demoChangesRefusal, demoPort, demoSettings, openDemo } from "./demo.js";
38
38
  import { postgresApiUrl, readPgTls } from "../api/postgres/listener.js";
39
- import { certificateFingerprint, elsewhereTip, ensureSelfSigned, listenerText, runListenerSettings, SELF_SIGNED_FILES } from "./pg-tls.js";
39
+ import { connectDatabase } from "../lib/database-name.js";
40
+ import { certificateFingerprint, elsewhereTip, ensureSelfSigned, listenerText, runListenerSettings, selfSignedPair, SELF_SIGNED_FILES } from "./pg-tls.js";
41
+ import { RunShares } from "./share-run.js";
40
42
  import { startPostgresApi, timeoutMs } from "../api/postgres/server.js";
41
- import { RequestLedger, REQUESTS_TABLE, requestLogDays } from "../duck/requests.js";
43
+ import { RequestLedger, REQUESTS_TABLE } from "../duck/requests.js";
44
+ import { historyDaysSetting } from "../duck/history.js";
45
+ import { EVENTS_TABLE, SyncEvents } from "../duck/events.js";
46
+ import { USAGE_TABLE, UsageSampler } from "../duck/usage.js";
42
47
  import { withPlaceholders } from "./pg-url.js";
48
+ import { UI } from "./ui.js";
43
49
  import { cliPinProblem, ensurePin } from "./pin.js";
44
50
  import { DEFAULT_MEMORY_LIMIT, DuckStore, DUCKDB_VERSION } from "../duck/store.js";
45
51
  import { copyChunkSecondsOf, copyParallelOf, Sync, syncModeOf } from "../duck/sync.js";
46
52
  import { storeJsonPathsOf } from "../duck/kept.js";
47
53
  import { readLibcOrder } from "../duck/libc-order.js";
48
54
  import { isLibcCollation } from "../duck/rewrite.js";
49
- import { listenerLogins, LiveUsers, USERS_FILE } from "../duck/users.js";
50
- import { READY_LINE } from "./supervise.js";
51
- import { changesOf, dropOwnReplication, dropStatements, madeReplication, namedPublication, projectReplication, replicationReadiness } from "./replication-mode.js";
55
+ import { listenerLogins, LiveUsers, readUsers, USERS_FILE } from "../duck/users.js";
56
+ import { exampleRole, LiveRoles, missingAttributesText, noRoleWayOut, readRoles, resolveRoles, rolesDefaultLine, rolesFile, usersMissingAttributes, usersWithNoRole, usersWithoutRole } from "./roles.js";
57
+ import { READY_LINE, readRestarts } from "./supervise.js";
58
+ import { dropOwnReplication, dropStatements, liveReadinessFailure, madeReplication, namedPublication, projectReplication, recordRefusal, replicationReadiness, staleReplication } from "./replication-mode.js";
52
59
  import { textEntry } from "../lib/log.js";
60
+ import { LogBuffer } from "../lib/log-buffer.js";
61
+ import { BIG_TABLE_ROWS, syncText } from "../lib/table-sync.js";
53
62
  const say = (m) => { process.stdout.write(`${m}\n`); };
54
63
  /**
55
64
  * THE RUN'S STEPS, as lines (an opening block of prose was too much to read, as `mason init`'s had been): a step begun,
@@ -70,8 +79,8 @@ export function sourceMove(recorded, now) {
70
79
  if (!recorded)
71
80
  return { action: "record" };
72
81
  if (recorded.database !== now.database) {
73
- return { refuse: `this project's copy is of the database ${recorded.database}, and .mason/config.json now names ${now.database}. ` +
74
- "Its copied rows are not that database's: mason destroy (removes the copy; your files stay), then mason start copies it" };
82
+ return { refuse: `this project's synced data is of the database ${recorded.database}, and .mason/config.json now names ${now.database}. ` +
83
+ "Its synced rows are not that database's: mason destroy (removes the synced data; your files stay), then mason start syncs it" };
75
84
  }
76
85
  if (recorded.systemIdentifier !== null && now.systemIdentifier !== null && recorded.systemIdentifier !== now.systemIdentifier)
77
86
  return { action: "reread" };
@@ -104,7 +113,55 @@ async function closeDemo(hosted, closed) {
104
113
  else if (closed !== null)
105
114
  say(` ${closed}`);
106
115
  }
116
+ /** how soon a pass that failed (the database away) is tried again, and replication mode's pass when a table waits */
117
+ const PASS_RETRY_MS = 5000;
118
+ /** the tables by their sync, as the start line says them: ` (2 polled, every 5s and 30s; 1 replicated; 1 off)` */
119
+ function syncCounts(tables) {
120
+ const polled = tables.flatMap((t) => t.sync?.mode === "poll" ? [t.sync.every] : []);
121
+ const replicated = tables.filter((t) => t.sync?.mode === "replication").length;
122
+ const off = tables.length - polled.length - replicated;
123
+ const parts = [...(polled.length ? [`${polled.length} polled, every ${[...new Set(polled)].join(" and ")}`] : []), ...(replicated ? [`${replicated} replicated`] : []), ...(off ? [`${off} off`] : [])];
124
+ return parts.length ? ` (${parts.join("; ")})` : "";
125
+ }
126
+ /**
127
+ * THE SOURCES FILE'S DEFAULT SYNC, SAID ONCE AT THE START: what every table without its own `sync:` gets — `off` (the
128
+ * default, said as such when the file writes none), the file's `poll <interval>` or `replication` — and how to change it.
129
+ *
130
+ * AN UPGRADE THAT STOPS THE POLLS IS SAID LOUDLY (issue 33's review): a file an older Mason wrote has no `sync:`
131
+ * anywhere, and its tables were polled every 5 s; from this release they are `off`. When the copy already holds
132
+ * `copiedBefore` of its tables and no `sync:` is written at all, the line says how many are not polled, that an older
133
+ * Mason polled them, and the exact line that keeps them current as before — true too of a hand-written file on a
134
+ * project that never polled.
135
+ */
136
+ export function syncDefaultLine(file, shown, copiedBefore = 0) {
137
+ const d = effectiveSync(file, undefined);
138
+ if (!syncWritten(file) && copiedBefore > 0) {
139
+ // true of a file an older Mason polled and of one written by hand that never was: "not polled", the old way said.
140
+ // That Mason left a big table out of its polls, and the line that keeps the rest as before does not (BIG_TABLE_ROWS)
141
+ return `sync: off (the default): ${copiedBefore === 1 ? "1 table is" : `${copiedBefore} tables are`} not polled (an older Mason polled every table under ${BIG_TABLE_ROWS.toLocaleString("en-US")} rows every 5 s); ` +
142
+ `\`sync: poll 5s\` at the top of ${shown} keeps them current as before, but polls the bigger ones too, each poll a full scan on your database: ` +
143
+ "give those `sync: off` on their entry (mason tables shows the rows), or use `mason sync <table>` when you need it";
144
+ }
145
+ if (d.from === "default")
146
+ return `sync: off (the default): after its first sync, a table is read again only by mason sync <table> — sync: poll 5m at the top of ${shown} polls every table, or on an entry that one`;
147
+ return d.sync.mode === "off" ? `sync: off (${shown}): after its first sync, a table is read again only by mason sync <table>, unless its entry says sync: poll <interval>`
148
+ : d.sync.mode === "replication" ? `sync: replication (${shown}): every table without a sync: of its own is kept current from the database's change log`
149
+ : `sync: ${syncText(d.sync)} (${shown}): every table without a sync: of its own is polled every ${d.sync.every}`;
150
+ }
151
+ /** WHAT REPLICATION MAKES ON THE DATABASE, recorded in the project's config as it is (at the start that replicates a
152
+ * table, and when the run makes the slot later): the names `mason destroy`, and a start whose file replicates nothing,
153
+ * drop — never computed again (src/cli/replication-mode.ts madeReplication) */
154
+ function recordReplication(cfg, repl, home) {
155
+ const recorded = repl.owned ? null : repl.publication;
156
+ if ((cfg.publication ?? null) === recorded && JSON.stringify(cfg.replication ?? null) === JSON.stringify(repl))
157
+ return;
158
+ cfg.publication = recorded;
159
+ cfg.replication = repl;
160
+ writeConfig(home, cfg);
161
+ }
107
162
  async function run(o, hosted) {
163
+ // when this run began: the uptime /api/state says (engine.startedAt, a supervisor's restart is a new run)
164
+ const startedAt = Date.now();
108
165
  const t = theProject(process.cwd());
109
166
  if ("problem" in t) {
110
167
  say(`mason start: ${t.problem}`);
@@ -121,7 +178,7 @@ async function run(o, hosted) {
121
178
  return 1;
122
179
  }
123
180
  // A DEMO PROJECT IS POLLED (src/cli/demo.ts): its database has no change log, so replication is refused before anything starts
124
- const demoRefused = demoChangesRefusal(cfg, o.changes);
181
+ const demoRefused = demoChangesRefusal(cfg);
125
182
  if (demoRefused)
126
183
  return failed("start", demoRefused, { json: false });
127
184
  let demoChangeMs = null;
@@ -134,16 +191,31 @@ async function run(o, hosted) {
134
191
  return 1;
135
192
  }
136
193
  }
137
- const pinned = cliPinProblem(folder, o.version);
194
+ // mason serve has held the project to its pin already, before it wrote anything (src/cli/serve.ts servePin)
195
+ const pinned = o.serve ? null : cliPinProblem(folder, o.version);
138
196
  if (pinned) {
139
197
  say(`mason start: ${pinned}`);
140
198
  return 1;
141
199
  }
200
+ // a setting a start refuses (the parent of a background start has asked the same list: src/cli/project.ts)
201
+ const setting = startSettingRefusal(project, process.env, { serve: o.serve !== undefined });
202
+ if (setting !== null)
203
+ return failed("start", setting, { json: false });
204
+ // the history's days (MASON_HISTORY_DAYS, a number of days; the old MASON_REQUEST_LOG_DAYS refused, named), read
205
+ // before anything opens (src/duck/history.ts); every history below keeps them
206
+ const keptDays = historyDaysSetting(process.env);
207
+ if (typeof keptDays !== "number")
208
+ return failed("start", { code: "refused", ...keptDays }, { json: false });
142
209
  // THE SOURCE (src/cli/sources.ts): the database and the tables to copy, from the project's sources file; a server's
143
210
  // URL from its environment first (mason serve is configured by it alone). Read here, its failure said below where it
144
211
  // always was (after the listener's checks): a demo project's port is needed first
145
212
  const readSources = () => projectSource(project, process.env, { shellFirst: o.serve !== undefined });
146
213
  const src = readSources();
214
+ // what the config records and the file does not say (an older Mason's replication): refused before the port below is
215
+ // recorded or the pin written, and long before anything on the database is dropped or made
216
+ const unsaid = "message" in src ? null : recordRefusal(cfg, src, process.env, shownPath(project.root, src.file), o.serve ? "mason serve" : "mason start");
217
+ if (unsaid !== null)
218
+ return failed("start", unsaid, { json: false });
147
219
  // a demo project's database listens on the port its URL names (the one place it is kept), which no listener of the
148
220
  // run's may take: read BEFORE the Postgres API's port is chosen and recorded, so the choice avoids it (a recorded or
149
221
  // named port that is the demo's is refused by choosePgPort, nothing recorded), and the command server is kept off it
@@ -172,13 +244,21 @@ async function run(o, hosted) {
172
244
  say(` wrote ${path.relative(project.root, wrotePin)}: version ${o.version}, the Mason this project is written for — commit it`);
173
245
  if ("message" in src)
174
246
  return failed("start", src, { json: false });
247
+ // a demo project's file naming sync: replication (its database has no change log), refused before anything opens
248
+ const demoFile = demoChangesRefusal(cfg, src, shownPath(project.root, src.file));
249
+ if (demoFile)
250
+ return failed("start", demoFile, { json: false });
175
251
  const logFile = path.join(home, "mason.log");
176
252
  const log = fs.createWriteStream(logFile, { flags: "a" });
177
253
  // one entry a line: a multi-line message's further lines indented under it (src/lib/log.ts `textEntry`);
178
254
  // mason serve gets the same entry, for the container's stdout (src/cli/serve.ts)
255
+ // the last entries are kept in memory too (src/lib/log-buffer.ts), for the command server's /api/logs: the one writer
256
+ // feeds the file, stdout and the buffer, so a mode with no file (mason serve) has the log as well
257
+ const logs = new LogBuffer();
179
258
  const logLine = (l) => {
180
259
  const entry = textEntry(`${new Date().toISOString()} `, l);
181
260
  log.write(entry);
261
+ logs.push(entry);
182
262
  o.serve?.log(entry.trimEnd());
183
263
  };
184
264
  // THE DEMO DATABASE (src/cli/demo.ts), hosted here: opened before it is connected to, as any database is
@@ -214,7 +294,7 @@ async function run(o, hosted) {
214
294
  say(` ! ${w}`);
215
295
  const config = path.join(home, "config.json");
216
296
  return failed("start", remoteConfirmation(source.host, { cmd: "mason start",
217
- wayOut: `this project was not made with --remote: if you mean to copy from that machine, set "remote": true in ${config} and start again`,
297
+ wayOut: `this project was not made with --remote: if you mean to sync from that machine, set "remote": true in ${config} and start again`,
218
298
  next: [["mason start", `once ${config} says "remote": true`]] }), { json: false });
219
299
  }
220
300
  if ("exit" in checked) {
@@ -226,79 +306,79 @@ async function run(o, hosted) {
226
306
  return checked.exit === 2 ? 1 : checked.exit;
227
307
  }
228
308
  const { source, warnings } = checked;
309
+ // THE DATABASE A TOOL CONNECTS WITH (src/lib/database-name.ts): never the project's name, which names the slot below
310
+ const database = connectDatabase(src, cfg, process.env);
229
311
  done(`Connected to ${db}: Postgres ${checked.facts.versionText}${checked.facts.standby ? " (a read replica)" : ""}, ${checked.facts.tables.length} table(s) this user can read`);
230
312
  for (const w of warnings)
231
313
  say(` ! ${w}`);
232
- // HOW THE CHANGES ARE READ (src/cli/replication-mode.ts): polling with SELECTs, or the change log as production reads
233
- // it (src/duck/stream.ts). A switch copies nothing again here: polling goes on from each table's watermark, and a
234
- // new slot's tables are caught up by one poll (Sync prepareReplication)
235
- const changes = changesOf(o.changes, cfg.changes);
236
- if (o.publication !== null && changes !== "replication") {
237
- say("mason start: --publication names the publication logical replication reads, and this project polls: mason start --replication --publication <name>");
238
- return 1;
239
- }
240
- // THE PUBLICATION: one the operator named (the flag, PG_PUBLICATION, or the one an earlier start recorded) is read as it
241
- // is, never altered (src/duck/publication.ts); else Mason makes its own. What the run before made, as it recorded it,
242
- // is what --poll drops (madeReplication)
243
- const named = changes === "replication" ? namedPublication(o.publication, process.env, cfg.publication) : null;
314
+ // HOW THE CHANGES ARE READ (src/cli/replication-mode.ts): each table as its sync: in the sources file says — the tables
315
+ // whose sync is replication through the project's ONE slot and publication, the others polled with SELECTs beside the
316
+ // stream. A switch copies nothing again (src/duck/sync.ts setTables: one poll catches a table up)
317
+ const shownFile = shownPath(project.root, src.file);
318
+ const replicating = replicatedEntries(src).length > 0;
319
+ // whether this run has held the database to logical replication's checks: at this start when the file replicates a
320
+ // table, else when an edit first sets one to replication (checkReplication below)
321
+ let replicationChecked = replicating;
322
+ // THE PUBLICATION: one the operator named (the file's publication:, else PG_PUBLICATION) is read as it is, never
323
+ // altered (src/duck/publication.ts); else Mason makes its own, carrying the replicated tables alone. What the run
324
+ // before made, as it recorded it, is what goes when no table is replicated (madeReplication)
325
+ const named = namedPublication(src, process.env);
244
326
  const repl = projectReplication(cfg.name, src.name, named?.name ?? null);
245
327
  const before = madeReplication(cfg, src.name);
246
- if (changes === "replication") {
328
+ if (replicating) {
247
329
  begin("Checking logical replication");
248
330
  const ready = await replicationReadiness(source, repl);
249
331
  if ("failure" in ready || ready.refused.length) {
250
332
  fail("Checking logical replication");
251
333
  for (const c of "failure" in ready ? [{ name: "database", detail: ready.failure }] : ready.refused)
252
334
  say(` ${c.name}: ${c.detail}`);
253
- say(" (mason start --poll reads the database with SELECTs instead, which needs none of this)");
335
+ say(` (${replicatedEntries(src).map((e) => entryName(e.table)).join(", ")} ${replicatedEntries(src).length === 1 ? "is" : "are"} sync: replication in ${shownFile}; sync: poll 5m reads a table with SELECTs instead, which needs none of this)`);
254
336
  return 1;
255
337
  }
256
- done(repl.owned ? `Logical replication: Mason makes publication ${repl.publication} and slot ${repl.slot} on ${source.database} (mason destroy, or mason start --poll, drops them)`
257
- : `Logical replication: publication ${repl.publication} is read as it is, never altered; Mason makes slot ${repl.slot} on ${source.database} (mason destroy, or mason start --poll, drops the slot alone)`);
258
- if (named?.from === "recorded")
259
- say(` publication ${repl.publication} was named at an earlier start of this copy, and is read again (mason start --replication --publication <name> names another)`);
338
+ done(repl.owned ? `Logical replication for ${replicatedEntries(src).length} table(s): Mason makes publication ${repl.publication} and slot ${repl.slot} on ${source.database} (mason destroy drops them, and so does a sources file in which no table's sync is replication)`
339
+ : `Logical replication for ${replicatedEntries(src).length} table(s): publication ${repl.publication} is read as it is, never altered; Mason makes slot ${repl.slot} on ${source.database} (mason destroy drops the slot alone, and so does a sources file in which no table's sync is replication)`);
260
340
  for (const w of ready.warnings)
261
341
  say(` ! ${w.name}: ${w.detail}`);
262
- // A SLOT RECORDED UNDER ANOTHER NAME (the sources file renamed since) is one this run no longer reads, and it holds
263
- // the database's WAL while it exists: it goes as at --poll, verified, before the record names the new one
264
- const was = cfg.replication ?? null;
265
- if (was !== null && was.slot !== repl.slot) {
342
+ // WHAT THE RECORD NAMES AND THIS RUN NO LONGER READS — a slot under another name (the sources file renamed since),
343
+ // or Mason's own publication once another is named — goes, verified, before the record names the new one
344
+ const stale = staleReplication(cfg.replication ?? null, repl);
345
+ if (stale !== null) {
266
346
  try {
267
- for (const d of await dropOwnReplication(source, was))
268
- say(` dropped ${d} from ${source.database}: this source's slot is ${repl.slot} now`);
347
+ for (const d of await dropOwnReplication(source, stale.drop, null, stale.what))
348
+ say(` dropped ${d} from ${source.database}: ${stale.why}`);
269
349
  }
270
350
  catch (e) {
271
- say(`mason start: ${e.message}. They hold the database's WAL while nobody reads them: drop them by hand — ${dropStatements(was)}`);
351
+ const by = stale.what === "both" ? dropStatements(stale.drop) : `DROP PUBLICATION IF EXISTS ${stale.drop.publication};`;
352
+ say(`mason start: ${e.message}. Mason made ${stale.what === "both" ? "them" : "it"} and nothing reads ${stale.what === "both" ? "them" : "it"} now: drop ${stale.what === "both" ? "them" : "it"} by hand — ${by}`);
272
353
  return 1;
273
354
  }
274
355
  }
275
- // recorded with the run, so mason status and mason stop say it, the next start reads the same one, and --poll and
276
- // mason destroy drop what was made by its own name
277
- const recorded = repl.owned ? null : repl.publication;
278
- if ((cfg.publication ?? null) !== recorded || JSON.stringify(cfg.replication ?? null) !== JSON.stringify(repl)) {
279
- cfg.publication = recorded;
280
- cfg.replication = repl;
281
- writeConfig(home, cfg);
356
+ // recorded with the run, so mason status and mason stop say it, the next start reads the same one, and mason
357
+ // destroy drops what was made by its own name
358
+ recordReplication(cfg, repl, home);
359
+ }
360
+ else if ((cfg.replication ?? null) !== null || cfg.changes === "replication") {
361
+ // THE SLOT GOES WHEN THE FILE SAYS NO TABLE IS REPLICATED: it holds the database's WAL while nobody reads it. A file
362
+ // naming tables and silent on sync: was refused above (recordRefusal)
363
+ if (cfg.changes === "replication")
364
+ say(` an older Mason streamed every table of this project; ${shownFile} says otherwise now, so what it made goes`);
365
+ try {
366
+ for (const d of await dropOwnReplication(source, before))
367
+ say(` dropped ${d} from ${source.database}: no table's sync is replication`);
282
368
  }
283
- }
284
- const previous = cfg.changes ?? "poll";
285
- if (previous !== changes) {
286
- // THE SLOT GOES WHENEVER THE PROJECT LEAVES REPLICATION: it holds the database's WAL while nobody reads it
287
- if (previous === "replication") {
288
- try {
289
- for (const d of await dropOwnReplication(source, before))
290
- say(` dropped ${d} from ${source.database}`);
291
- }
292
- catch (e) {
293
- say(`mason start: ${e.message}. They hold the database's WAL while nobody reads them: drop them by hand — ${dropStatements(before)}`);
294
- return 1;
295
- }
296
- if (!before.owned)
297
- say(` publication ${before.publication} is its owner's: left as it is`);
298
- cfg.publication = null;
299
- cfg.replication = null;
369
+ catch (e) {
370
+ say(`mason start: ${e.message}. They hold the database's WAL while nobody reads them: drop them by hand — ${dropStatements(before)}`);
371
+ return 1;
300
372
  }
301
- cfg.changes = changes;
373
+ if (!before.owned)
374
+ say(` publication ${before.publication} is its owner's: left as it is`);
375
+ cfg.publication = null;
376
+ cfg.replication = null;
377
+ writeConfig(home, cfg);
378
+ }
379
+ // the project's mode, as an older start recorded it: the sources file says it per table now
380
+ if (cfg.changes !== undefined) {
381
+ delete cfg.changes;
302
382
  writeConfig(home, cfg);
303
383
  }
304
384
  let facts = checked.facts;
@@ -308,11 +388,22 @@ async function run(o, hosted) {
308
388
  return 1;
309
389
  }
310
390
  // THE TABLES THE SOURCES FILE NAMES, against the database's catalog: every mistake said at once, before anything opens
311
- const firstTables = addedTables(src, facts.tables, source.database, project.root, { replication: changes === "replication" });
391
+ const firstTables = addedTables(src, facts.tables, source.database, project.root);
312
392
  if (!Array.isArray(firstTables))
313
393
  return failed("start", firstTables, { json: false });
314
394
  // what the run copies: the sources file as last read, and what is wrong with it now (an edit the run cannot take)
315
395
  let sources = { file: src, tables: firstTables, problem: null };
396
+ // THE ROLES FILE (mason/roles.yml, src/cli/roles.ts): what each role may read, held against the tables the sources
397
+ // file copies before anything opens — a table it does not copy or a column the copy lacks refuses the start by name;
398
+ // the listener reads the file again whenever it changes (LiveRoles), no restart
399
+ const rolesRead = readRoles(folder, project.root);
400
+ if (rolesRead !== null && "message" in rolesRead)
401
+ return failed("start", rolesRead, { json: false });
402
+ if (rolesRead !== null) {
403
+ const resolved = resolveRoles(rolesRead, firstTables, project.root);
404
+ if ("message" in resolved)
405
+ return failed("start", resolved, { json: false });
406
+ }
316
407
  // how many of a copy's chunks are read at once, each a backend on the database (src/duck/sync.ts COPY_PARALLEL)
317
408
  let copyParallel;
318
409
  try {
@@ -340,16 +431,16 @@ async function run(o, hosted) {
340
431
  say(`mason start: ${e.message}`);
341
432
  return 1;
342
433
  }
343
- begin("Opening the copy");
434
+ begin("Opening the synced data");
344
435
  const duckFile = path.join(home, DUCK_FILE);
345
436
  let store;
346
437
  // its memory bounded (src/duck/store.ts DEFAULT_MEMORY_LIMIT): past it a statement spills, or fails alone
347
438
  const memoryLimit = process.env.MASON_DUCKDB_MEMORY || undefined;
348
439
  try {
349
- store = await DuckStore.open(duckFile, (l) => logLine(`[duckdb] ${l}`), memoryLimit === undefined ? {} : { memoryLimit });
440
+ store = await DuckStore.open(duckFile, (l) => logLine(`[engine] ${l}`), memoryLimit === undefined ? {} : { memoryLimit });
350
441
  }
351
442
  catch (e) {
352
- fail("Opening the copy");
443
+ fail("Opening the synced data");
353
444
  say(` ${e.message}`);
354
445
  return 1;
355
446
  }
@@ -357,22 +448,49 @@ async function run(o, hosted) {
357
448
  // supervisor to start it again: every session's statement in flight was told 57P02, and nothing can be answered after
358
449
  let crash = null;
359
450
  store.onCrash((reason) => {
360
- logLine(`[duckdb] the copy's thread stopped: ${reason}`);
451
+ logLine(`[engine] the engine's thread stopped: ${reason}`);
361
452
  if (crash !== null)
362
453
  crash(reason);
363
454
  else
364
- say(` ! the copy's thread stopped: ${reason}`);
455
+ say(` ! the engine's thread stopped: ${reason}`);
365
456
  });
366
- done(`the copy in ${path.relative(project.root, duckFile)} (mason destroy deletes it; engine ${DUCKDB_VERSION}), at most ${(memoryLimit ?? DEFAULT_MEMORY_LIMIT).trim()} of memory (MASON_DUCKDB_MEMORY), spilling to ${path.relative(project.root, duckFile)}.tmp`);
367
- const pollMs = Number(process.env.MASON_POLL_MS || 5000);
368
- const syncMode = changes === "replication" ? "replication" : syncModeOf(process.env);
369
- const sync = new Sync(store, source, cfg.name, (l) => { logLine(`[sync] ${l}`); if (!o.serve && /^copied |failed/.test(l))
370
- say(textEntry(" ", l).trimEnd()); }, pollMs, syncMode, syncMode === "replication" ? repl : null, { copyParallel, copyChunkSeconds, json: { enabled: storeJsonPaths } });
457
+ done(`the synced data in ${path.relative(project.root, duckFile)} (mason destroy deletes it; engine ${DUCKDB_VERSION}), at most ${(memoryLimit ?? DEFAULT_MEMORY_LIMIT).trim()} of memory (MASON_DUCKDB_MEMORY), spilling to ${path.relative(project.root, duckFile)}.tmp`);
458
+ const syncMode = syncModeOf(process.env);
459
+ // THE SYNC'S EVENTS (src/duck/events.ts): its failures and notable events, kept as the request log is, before the sync
460
+ // that records them starts
461
+ let events;
462
+ try {
463
+ events = await SyncEvents.open(store, logLine, { days: keptDays });
464
+ }
465
+ catch (e) {
466
+ fail("Opening the synced data");
467
+ say(` the sync's events (${EVENTS_TABLE}) could not be opened: ${e.message}`);
468
+ await store.close();
469
+ return 1;
470
+ }
471
+ // each table is kept current as its own sync: says (the sources file's); the sync's interval is how soon a failed pass
472
+ // is tried again (and MASON_DUCK_SYNC=recopy's whole copies). The slot and publication are the run's whenever a table
473
+ // is replicated, now or after a mason add --sync replication (the demo database has no change log: none)
474
+ const sync = new Sync(store, source, database, (l) => { logLine(`[sync] ${l}`); if (!o.serve && /^synced |failed/.test(l))
475
+ say(textEntry(" ", l).trimEnd()); }, PASS_RETRY_MS, syncMode, cfg.demo === true ? null : repl, { copyParallel, copyChunkSeconds, json: { enabled: storeJsonPaths }, onEvent: (e) => events.record(e),
476
+ // what the run made on the database, recorded as it is made and dropped: mason destroy drops it by that name
477
+ onReplication: (made) => {
478
+ if (made)
479
+ recordReplication(cfg, repl, home);
480
+ else if ((cfg.replication ?? null) !== null) {
481
+ cfg.publication = null;
482
+ cfg.replication = null;
483
+ writeConfig(home, cfg);
484
+ }
485
+ } });
371
486
  await sync.open();
487
+ // the tables this copy already holds, before this start copies anything: an upgrade from a Mason that polled every
488
+ // table says how many are not polled (`syncDefaultLine`)
489
+ const copiedBefore = sync.state().filter((s) => s.phase === "copied" && sources.tables.some((t) => t.source === s.source)).length;
372
490
  // a database restored to another server: its transaction ids are not the watermarks', so every table is read again
373
491
  if (move.action === "reread") {
374
492
  await sync.rereadAll();
375
- say(` ${source.database} is on another server than the copy was taken from: reading its tables again`);
493
+ say(` ${source.database} is on another server than the data was synced from: reading its tables again`);
376
494
  }
377
495
  if (move.action !== "same") {
378
496
  cfg.sourceServer = { systemIdentifier: facts.systemIdentifier, database: source.database };
@@ -381,7 +499,7 @@ async function run(o, hosted) {
381
499
  // the views first: setting the tables rebuilds the catalog, which reads them. The sources file read again: a mistake in it
382
500
  // keeps the tables read before (a typo never drops a copy), said in the log and by mason status
383
501
  const added = () => {
384
- const r = rereadSources(sources, readSources(), src.resolved, facts.tables, source.database, project.root, { replication: changes === "replication" });
502
+ const r = rereadSources(sources, readSources(), src.resolved, facts.tables, source.database, project.root, { replicationChecked, slot: cfg.replication?.slot ?? null });
385
503
  if (r.said !== null)
386
504
  logLine(`[sync] ${r.said}`);
387
505
  sources = { file: r.file, tables: r.tables, problem: r.problem };
@@ -391,7 +509,7 @@ async function run(o, hosted) {
391
509
  if (pg.said)
392
510
  say(` ${pg.said}`);
393
511
  const pgAddress = { host: listen.host, port: listen.port };
394
- const postgresUrl = postgresApiUrl(pgAddress, cfg.name, { tls: pgTls.tls !== null });
512
+ const postgresUrl = postgresApiUrl(pgAddress, database, { tls: pgTls.tls !== null });
395
513
  // A LIBC COLLATION'S ORDER IS READ FROM THE DATABASE (src/duck/libc-order.ts): once, a few read-only SELECTs, kept in
396
514
  // the copy and read again when the database's collation version moves (an OS upgrade's glibc)
397
515
  let libc;
@@ -413,12 +531,13 @@ async function run(o, hosted) {
413
531
  });
414
532
  libc = r.order;
415
533
  if (r.read)
416
- done(`Read how ${source.database} orders text (${coll.collate}): kept in the copy, read again only when its collation version moves`);
534
+ done(`Read how ${source.database} orders text (${coll.collate}): kept with the synced data, read again only when its collation version moves`);
417
535
  }
418
536
  catch (e) {
419
537
  fail(reading);
420
538
  say(` ${e.message}`);
421
539
  await sync.stop();
540
+ await events.close();
422
541
  await store.close();
423
542
  return 1;
424
543
  }
@@ -432,26 +551,36 @@ async function run(o, hosted) {
432
551
  fail("Starting the Postgres listener");
433
552
  say(` MASON_STATEMENT_TIMEOUT=${JSON.stringify(timeoutSetting)} is not a duration: write it as Postgres's statement_timeout is written (5min, 90s, 300000 for ms; 0 for none)`);
434
553
  await sync.stop();
554
+ await events.close();
435
555
  await store.close();
436
556
  return 1;
437
557
  }
438
558
  // THE REQUEST LOG (src/duck/requests.ts): every statement the listener answers or refuses, kept in the copy's own file
439
- const keptDays = requestLogDays(process.env.MASON_REQUEST_LOG_DAYS);
440
- if (typeof keptDays !== "number") {
559
+ // for MASON_HISTORY_DAYS (read above, before anything opened)
560
+ let requests;
561
+ try {
562
+ requests = await RequestLedger.open(store, logLine, { days: keptDays });
563
+ }
564
+ catch (e) {
441
565
  fail("Starting the Postgres listener");
442
- say(` ${keptDays.problem}`);
566
+ say(` the request log (${REQUESTS_TABLE}) could not be opened: ${e.message}`);
443
567
  await sync.stop();
568
+ await events.close();
444
569
  await store.close();
445
570
  return 1;
446
571
  }
447
- let requests;
572
+ // USAGE OVER TIME (src/duck/usage.ts): a sample a minute, kept as the request log is; it counts the listener's requests
573
+ // as they are recorded, and samples the run's state once the command server's context exists (below)
574
+ let usage;
448
575
  try {
449
- requests = await RequestLedger.open(store, logLine, { days: keptDays });
576
+ usage = await UsageSampler.open(store, logLine, { days: keptDays });
450
577
  }
451
578
  catch (e) {
452
579
  fail("Starting the Postgres listener");
453
- say(` the request log (${REQUESTS_TABLE}) could not be opened: ${e.message}`);
580
+ say(` the usage samples (${USAGE_TABLE}) could not be opened: ${e.message}`);
581
+ await requests.close();
454
582
  await sync.stop();
583
+ await events.close();
455
584
  await store.close();
456
585
  return 1;
457
586
  }
@@ -464,20 +593,27 @@ async function run(o, hosted) {
464
593
  say(` ${e.message}`);
465
594
  await requests.close();
466
595
  await sync.stop();
596
+ await usage.close();
597
+ await events.close();
467
598
  await store.close();
468
599
  return 1;
469
600
  }
470
- logLine(`[duckdb] text sorts as the source's collation does: ${JSON.stringify(duckListener.collation)}`);
601
+ logLine(`[engine] text sorts as the source's collation does: ${JSON.stringify(duckListener.collation)}`);
471
602
  // THE USERS (src/duck/users.ts): `mason user` edits the file beside this process; the listener re-reads it at every
472
603
  // connection and before every statement of a user's session, so a removal takes effect without a restart
473
604
  const users = new LiveUsers(path.join(home, USERS_FILE), cfg.apiKey);
605
+ // THE ROLES (src/cli/roles.ts): the file read again when it or the copied tables change; a session with a role is
606
+ // held to it at every statement, and the catalog is rebuilt with the roles (src/api/postgres/server.ts)
607
+ const liveRoles = new LiveRoles(folder, project.root, () => sources.tables);
474
608
  let api;
475
609
  try {
476
610
  api = await startPostgresApi({
477
- port: pgAddress.port, host: pgAddress.host, tls: pgTls.tls, database: cfg.name, masonVersion: o.version, product: "mason-data", statementTimeoutMs,
611
+ port: pgAddress.port, host: pgAddress.host, tls: pgTls.tls, database, masonVersion: o.version, product: "mason-data", statementTimeoutMs,
478
612
  ...listenerLogins(users, cfg.apiKey),
613
+ roles: () => liveRoles.now(),
479
614
  schema: () => sync.schema(),
480
- record: (row) => requests.record(row),
615
+ // the one place a request is recorded: the request log's row, and the usage sample's count of it
616
+ record: (row) => { requests.record(row); usage.count(row); },
481
617
  log: logLine,
482
618
  trace: process.env.MASON_PG_TRACE === "1",
483
619
  duck: duckListener,
@@ -488,27 +624,104 @@ async function run(o, hosted) {
488
624
  say(` ${e.message}`);
489
625
  await requests.close();
490
626
  await sync.stop();
627
+ await usage.close();
628
+ await events.close();
491
629
  await store.close();
492
630
  return 1;
493
631
  }
494
632
  done(`Postgres listener on ${listenerText(pgTls.listener, api.port)}; ${statementTimeoutMs === 0 ? "statements without a time limit" : `each statement at most ${timeoutSetting.trim()}`} (MASON_STATEMENT_TIMEOUT); ` +
495
- `every request recorded for ${keptDays} day${keptDays === 1 ? "" : "s"} (MASON_REQUEST_LOG_DAYS; mason logs --requests)`);
633
+ `every request recorded for ${keptDays} day${keptDays === 1 ? "" : "s"} (MASON_HISTORY_DAYS; mason logs --requests)`);
634
+ {
635
+ // a user whose role the file no longer names reads nothing until it is added again (fail closed): said here, by name
636
+ // the row filters checked by the catalog Postgres here (on the tables copied already): a where: Postgres refuses is
637
+ // said now, at its key, before any user meets it
638
+ const roles = await api.checkRoles();
639
+ const list = readUsers(path.join(home, USERS_FILE));
640
+ const orphans = "users" in list ? usersWithoutRole(list.users, new Set(roles.grants.keys())) : [];
641
+ // WHAT A USER ADDED WITHOUT --role READS, SAID ONCE (stage 1b: the file decides it, never the code), and each user
642
+ // the file holds to nothing (no default:) by name, with the way out
643
+ const rolesLine = rolesDefaultLine(roles, shownPath(project.root, rolesFile(folder)));
644
+ say(` ${rolesLine}`);
645
+ logLine(`[postgres] ${rolesLine}`);
646
+ for (const name of "users" in list ? usersWithNoRole(list.users, roles) : []) {
647
+ say(` ! the user ${name} has no role, and ${roles.file ?? ""} names no default: its statements are refused — ${noRoleWayOut(name, roles.file ?? "", exampleRole(roles))}`);
648
+ }
649
+ // a user without a value its role's row filters read: refused at every statement until it is made again with it
650
+ for (const u of "users" in list ? usersMissingAttributes(list.users.filter((x) => x.removedAt === undefined), roles) : []) {
651
+ say(` ! ${missingAttributesText(u)}`);
652
+ }
653
+ for (const u of orphans) {
654
+ say(` ! the user ${u.name} has the role ${u.role}, which ${roles.file ?? "no roles file (mason/roles.yml)"} ${roles.file === null ? "exists to name" : "does not name"}: ` +
655
+ `its statements are refused until the role is in the file, or mason user remove ${u.name} and mason user add ${u.name}${roles.grants.size ? ` --role ${[...roles.grants.keys()][0]}` : ""}`);
656
+ }
657
+ }
658
+ // WHAT mason share OPENS (src/cli/share-run.ts): a second socket of this listener, on a run on a laptop alone
659
+ const shares = o.serve !== undefined ? undefined : new RunShares({
660
+ listener: api, database, log: logLine,
661
+ // the project's own certificate, as --self-signed-tls serves it: made once, and recorded as that flag records it
662
+ certificate: () => {
663
+ try {
664
+ const pair = selfSignedPair(home);
665
+ if (pair.said)
666
+ logLine(`[postgres] ${pair.said} (SHA-256 ${pair.fingerprint})`);
667
+ const recorded = { cert: SELF_SIGNED_FILES.cert, key: SELF_SIGNED_FILES.key, fingerprint: pair.fingerprint };
668
+ if (JSON.stringify(cfg.selfSignedTls ?? null) !== JSON.stringify(recorded)) {
669
+ cfg.selfSignedTls = recorded;
670
+ writeConfig(home, cfg);
671
+ }
672
+ return pair;
673
+ }
674
+ catch (e) {
675
+ return { message: `the self-signed certificate could not be written to ${home}: ${e.message}`, code: "failed", fix: "this folder must be writable: mason doctor checks the machine" };
676
+ }
677
+ },
678
+ });
496
679
  const setupPort = await freePort(o.port, chosen);
497
680
  const token = newToken();
498
- const server = createRunServer({
681
+ const runCtx = {
499
682
  source, facts: () => facts, warnings,
500
- slot: changes === "replication" ? repl.slot : "", changes, port: setupPort, token, folder: () => folder, root: project.root,
683
+ // the slot and publication while a table's sync is replication (the run streams), as the sync says it now
684
+ replication: () => sync.streams() ? repl : null, port: setupPort, token, folder: () => folder, root: project.root, home,
685
+ version: o.version, startedAt,
501
686
  // the web page (mason ui) is mason start's alone: mason serve's run serves none (src/cli/run-server.ts RunContext.page)
502
687
  page: o.serve === undefined,
503
688
  demo: cfg.demo === true,
504
- users: { file: path.join(home, USERS_FILE), apiKey: cfg.apiKey, database: cfg.name, postgresUrl },
505
- ...(changes === "replication" ? { publication: { name: repl.publication, owned: repl.owned } } : {}),
689
+ users: { file: path.join(home, USERS_FILE), apiKey: cfg.apiKey, database, postgresUrl },
690
+ // the roles as the listener holds them: their row filters checked by its catalog (a filter Postgres refuses is a problem)
691
+ roles: () => api.roles(),
692
+ ...(shares !== undefined ? { shares } : {}),
506
693
  // the sources file is the list of what is added: a change to it is read by the sync at once
507
694
  sources: () => sources, sourcesChanged: () => { added(); }, readSource: readSources, runUrl: src.resolved,
695
+ // a table set to sync: replication while the run is up, in a run that never checked the database for it: the
696
+ // start's own checks, now, and the edit refused when they fail (src/cli/run-server.ts /api/pick)
697
+ checkReplication: async (file) => {
698
+ const tables = replicatedEntries(file).map((e) => entryName(e.table));
699
+ if (replicationChecked || tables.length === 0)
700
+ return null;
701
+ const shown = shownPath(project.root, file.file);
702
+ const demo = demoChangesRefusal(cfg, file, shown);
703
+ if (demo !== null)
704
+ return { ...demo, fix: `mason add ${tables[0]} --sync "poll 5s" polls it`, next: [[`mason add ${tables[0]} --sync "poll 5s"`, "polls it"]] };
705
+ const ready = await replicationReadiness(source, repl);
706
+ const refused = liveReadinessFailure(ready, tables, shown, source.database);
707
+ if (refused !== null)
708
+ return refused;
709
+ if (!("failure" in ready))
710
+ for (const w of ready.warnings)
711
+ say(` ! ${w.name}: ${w.detail}`);
712
+ replicationChecked = true;
713
+ return null;
714
+ },
508
715
  log: (m) => say(` ${m}`),
509
- duck: { copies: () => sync.state(), file: duckFile, pollSeconds: pollMs / 1000, poll: (sources) => sync.pollNow(sources), stream: () => sync.streamStatus(), requests,
510
- jsonPaths: () => sync.kept.status() },
511
- });
716
+ logs,
717
+ // the statements running now, and a cancel of one: the listener's own (a CancelRequest's path)
718
+ listener: api,
719
+ duck: { copies: () => sync.state(), file: duckFile, poll: (sources) => sync.pollNow(sources), stream: (o) => sync.streamStatus(o), measured: (table) => sync.measured(table), disk: () => sync.diskStatus(), requests, events, usage,
720
+ restarts: () => readRestarts(home), jsonPaths: () => sync.kept.status() },
721
+ };
722
+ const server = createRunServer(runCtx);
723
+ // a sample is /api/state's facts at its moment (src/cli/run-server.ts usageFacts): one computation of each
724
+ usage.start(() => usageFacts(runCtx));
512
725
  await new Promise((resolve) => server.listen(setupPort, "127.0.0.1", resolve));
513
726
  const refreshFacts = setInterval(() => {
514
727
  void (async () => {
@@ -527,8 +740,13 @@ async function run(o, hosted) {
527
740
  cfg.running = { pid: process.pid, controlUrl: `http://127.0.0.1:${setupPort}`, token, postgresUrl, postgresListener: pgTls.listener };
528
741
  cfg.lastRun = new Date().toISOString();
529
742
  writeConfig(home, cfg);
530
- done(`Mason is up: ${sync.state().length} table(s) added, kept current ${syncMode === "replication" ? `by logical replication (slot ${repl.slot}, publication ${repl.publication}${repl.owned ? "" : ", read as it is"})`
531
- : `every ${pollMs / 1000} s ${syncMode === "poll" ? "by polling with SELECTs (xmin)" : "by copying each table again whole (MASON_DUCK_SYNC=recopy)"}`}`);
743
+ done(`Mason is up: ${sync.state().length} table(s) added, kept current: ${syncMode === "poll" ? `as each table's sync: says${syncCounts(sources.tables)}` +
744
+ `${sync.streams() ? `; replicated tables streamed as they change (logical replication: slot ${repl.slot}, publication ${repl.publication}${repl.owned ? "" : ", read as it is"})` : ""}`
745
+ : `each table read again whole every ${PASS_RETRY_MS / 1000} s (MASON_DUCK_SYNC=recopy)`}`);
746
+ // THE FILE'S DEFAULT, SAID ONCE (issue 33: nothing Mason does to your database is a hidden default)
747
+ const defaultLine = syncDefaultLine(sources.file, shownPath(project.root, sources.file.file), syncMode === "poll" ? copiedBefore : 0);
748
+ say(` ${defaultLine}`);
749
+ logLine(`[sync] ${defaultLine}`);
532
750
  // THE DEMO'S WRITER: a round of changes every MASON_DEMO_CHANGE_MS, begun once the run is up, said in the log
533
751
  if (demo !== null && demoChangeMs !== null)
534
752
  hosted.stopChanges = demoChanges(demo, demoChangeMs, (l) => logLine(`[demo] ${l}`));
@@ -536,13 +754,13 @@ async function run(o, hosted) {
536
754
  // no handler would end the run with the default action, never recording `running: null`
537
755
  let stopping = false;
538
756
  const stopped = new Promise((resolve) => {
539
- // a crash of the copy's thread: the run exits 1 with its reason in V8's fatal form, which the supervisor reads
757
+ // a crash of the engine's thread: the run exits 1 with its reason in V8's fatal form, which the supervisor reads
540
758
  // (src/cli/supervise.ts crashReason), and its config still says it runs, so the supervisor starts it again
541
759
  crash = (reason) => {
542
760
  if (stopping)
543
761
  return;
544
762
  stopping = true;
545
- say(`FATAL ERROR: Mason: the copy's thread stopped (${reason})`);
763
+ say(`FATAL ERROR: Mason: the engine's thread stopped (${reason})`);
546
764
  clearInterval(refreshFacts);
547
765
  hosted.stopChanges?.();
548
766
  server.close();
@@ -567,6 +785,13 @@ async function run(o, hosted) {
567
785
  hosted.stopChanges?.();
568
786
  server.close();
569
787
  void (async () => {
788
+ // before the listener: a share's command is told why, and its tunnel client stopped
789
+ try {
790
+ await shares?.closeAll("Mason is stopping");
791
+ }
792
+ catch (e) {
793
+ say(` closing the share: ${e.message}`);
794
+ }
570
795
  try {
571
796
  await api.close();
572
797
  }
@@ -587,20 +812,34 @@ async function run(o, hosted) {
587
812
  catch (e) {
588
813
  syncStop = e;
589
814
  }
815
+ // after the sync: a sample in flight ends before the file closes (it reads the sync's state)
816
+ try {
817
+ await usage.close();
818
+ }
819
+ catch (e) {
820
+ say(` closing the usage samples: ${e.message}`);
821
+ }
822
+ // after the sync: its last events are written before the file closes
823
+ try {
824
+ await events.close();
825
+ }
826
+ catch (e) {
827
+ say(` closing the sync's events: ${e.message}`);
828
+ }
590
829
  // the demo database after the sync (nothing reads it any more) and before the copy closes
591
830
  await closeDemo(hosted, `closed the demo database (it stays in ${path.join(home, DEMO_DIR)}; mason destroy removes it)`);
592
831
  if (syncStop !== null)
593
- say(` closing the copy: ${syncStop.message}`);
832
+ say(` closing the synced data: ${syncStop.message}`);
594
833
  else {
595
834
  try {
596
835
  await store.close();
597
- say(` closed the copy (it stays in ${duckFile})`);
836
+ say(` closed the synced data (it stays in ${duckFile})`);
598
837
  }
599
838
  catch (e) {
600
- say(` closing the copy: ${e.message}`);
839
+ say(` closing the synced data: ${e.message}`);
601
840
  }
602
841
  }
603
- if (syncMode === "replication")
842
+ if ((cfg.replication ?? null) !== null)
604
843
  say(` replication slot ${repl.slot} stays on ${source.database}, holding its WAL until the next mason start reads it (mason destroy drops it)` +
605
844
  (repl.owned ? "" : `; publication ${repl.publication} is its owner's, left as it is`));
606
845
  cfg.running = null;
@@ -617,16 +856,17 @@ async function run(o, hosted) {
617
856
  say("");
618
857
  say(` Postgres ${withPlaceholders(postgresUrl)} (mason user add <name> makes a user)`);
619
858
  // the web page by its command, never its URL: the URL carries the run's token, and this goes to dev.log
859
+ // its words are mason ui's summary (mason --help's), so the two never drift
620
860
  if (o.serve === undefined)
621
- say(" Web UI mason ui (the status page and the users, in your browser)");
861
+ say(` Web UI mason ui (${UI.summary})`);
622
862
  if (demoChangeMs !== null)
623
- say(` Demo a new order lands every ${demoChangeMs / 1000} s: mason status shows the copy keeping up`);
863
+ say(` Demo a new order lands every ${demoChangeMs / 1000} s: mason status shows the synced data keeping up`);
624
864
  const elsewhere = elsewhereTip(pgTls.listener);
625
865
  if (elsewhere)
626
866
  say(` ${elsewhere}`);
627
867
  say(READY);
628
868
  say("");
629
- o.serve?.ready({ tables: () => sync.state() });
869
+ o.serve?.ready({ tables: () => sync.state(), metrics: () => metricsFacts(runCtx) });
630
870
  return await stopped;
631
871
  }
632
872
  /**
@@ -640,7 +880,7 @@ function listenerTls(listen, selfSigned, home, cfg) {
640
880
  if (selfSigned) {
641
881
  let made;
642
882
  try {
643
- made = ensureSelfSigned(home, cfg.name);
883
+ made = ensureSelfSigned(home);
644
884
  }
645
885
  catch (e) {
646
886
  return { problem: `the self-signed certificate could not be written to ${home}: ${e.message}`, code: "failed" };
@@ -665,5 +905,5 @@ function listenerTls(listen, selfSigned, home, cfg) {
665
905
  * and what tells its supervisor a death after it is a crash to restart (src/cli/supervise.ts, which defines it). */
666
906
  export const READY = READY_LINE;
667
907
  /** The one sentence of how the pieces fit, printed first by `mason start` and at the top of its help. */
668
- export const PROJECT_LINE = "A Mason project is your app's folder: mason/sources/ names the database and the tables to copy, yours to commit; " +
669
- "this machine's copy of your data and Mason's state are in .mason/, rebuilt from that file.";
908
+ export const PROJECT_LINE = "A Mason project is your app's folder: mason/sources/ names the database and the tables to sync, yours to commit; " +
909
+ "this machine's synced data and Mason's state are in .mason/, rebuilt from that file.";