@mason-data/cli 0.6.2 → 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 (119) hide show
  1. package/README.md +147 -69
  2. package/dist/src/api/error.js +3 -0
  3. package/dist/src/api/postgres/catalog.js +258 -14
  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 +1290 -223
  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/pg-wire.js +144 -38
  11. package/dist/src/cdc/poll.js +5 -5
  12. package/dist/src/cli/background.js +55 -8
  13. package/dist/src/cli/cancel.js +75 -0
  14. package/dist/src/cli/command.js +5 -3
  15. package/dist/src/cli/commands.js +843 -272
  16. package/dist/src/cli/control.js +59 -0
  17. package/dist/src/cli/demo.js +158 -0
  18. package/dist/src/cli/dev.js +458 -125
  19. package/dist/src/cli/doctor.js +25 -12
  20. package/dist/src/cli/guides.js +103 -31
  21. package/dist/src/cli/init.js +159 -58
  22. package/dist/src/cli/machine.js +18 -12
  23. package/dist/src/cli/main.js +5 -5
  24. package/dist/src/cli/mcp.js +735 -0
  25. package/dist/src/cli/metrics.js +79 -0
  26. package/dist/src/cli/pg-tls.js +13 -3
  27. package/dist/src/cli/pg-url.js +4 -0
  28. package/dist/src/cli/pin.js +13 -12
  29. package/dist/src/cli/preflight.js +24 -14
  30. package/dist/src/cli/project.js +48 -7
  31. package/dist/src/cli/readiness.js +38 -4
  32. package/dist/src/cli/registry.js +57 -35
  33. package/dist/src/cli/replication-checks.js +7 -5
  34. package/dist/src/cli/replication-mode.js +105 -26
  35. package/dist/src/cli/request-filter.js +134 -0
  36. package/dist/src/cli/roles.js +584 -0
  37. package/dist/src/cli/run-server.js +498 -133
  38. package/dist/src/cli/runs.js +1 -1
  39. package/dist/src/cli/security.js +10 -8
  40. package/dist/src/cli/serve.js +147 -40
  41. package/dist/src/cli/share-run.js +286 -0
  42. package/dist/src/cli/share.js +286 -0
  43. package/dist/src/cli/source-edit.js +168 -0
  44. package/dist/src/cli/sources.js +290 -39
  45. package/dist/src/cli/supervise.js +12 -1
  46. package/dist/src/cli/sync.js +55 -25
  47. package/dist/src/cli/tunnel.js +200 -0
  48. package/dist/src/cli/ui.js +9 -6
  49. package/dist/src/cli/usage.js +128 -0
  50. package/dist/src/cli/user.js +496 -40
  51. package/dist/src/cli/version.js +9 -7
  52. package/dist/src/demo/seed.js +240 -0
  53. package/dist/src/demo/source.js +655 -0
  54. package/dist/src/duck/cancel.js +10 -1
  55. package/dist/src/duck/distinct-key.js +228 -0
  56. package/dist/src/duck/events.js +134 -0
  57. package/dist/src/duck/executor-worker.js +25 -11
  58. package/dist/src/duck/executor.js +107 -53
  59. package/dist/src/duck/explain.js +158 -29
  60. package/dist/src/duck/extensions.js +160 -0
  61. package/dist/src/duck/functions.js +73 -19
  62. package/dist/src/duck/gaps.js +25 -11
  63. package/dist/src/duck/grants.js +176 -0
  64. package/dist/src/duck/history.js +123 -0
  65. package/dist/src/duck/jsonb-order.js +175 -0
  66. package/dist/src/duck/jsonb.js +10 -8
  67. package/dist/src/duck/kept-route.js +82 -2
  68. package/dist/src/duck/kept.js +211 -44
  69. package/dist/src/duck/pgtree.js +40 -0
  70. package/dist/src/duck/plan.js +120 -30
  71. package/dist/src/duck/publication.js +6 -6
  72. package/dist/src/duck/requests.js +265 -93
  73. package/dist/src/duck/rewrite.js +1193 -73
  74. package/dist/src/duck/row-filter.js +649 -0
  75. package/dist/src/duck/store.js +271 -159
  76. package/dist/src/duck/stream.js +10 -5
  77. package/dist/src/duck/sync.js +907 -275
  78. package/dist/src/duck/types.js +6 -5
  79. package/dist/src/duck/usage.js +324 -0
  80. package/dist/src/duck/users.js +64 -15
  81. package/dist/src/duck/verified.js +9 -0
  82. package/dist/src/duck/views.js +2 -3
  83. package/dist/src/lib/database-name.js +62 -0
  84. package/dist/src/lib/dotenv.js +17 -4
  85. package/dist/src/lib/log-buffer.js +68 -0
  86. package/dist/src/lib/pg-numeric.js +5 -2
  87. package/dist/src/lib/project-version.js +23 -23
  88. package/dist/src/lib/table-sync.js +267 -0
  89. package/dist/src/lib/timezone.js +3 -2
  90. package/dist/src/web/app.css +706 -55
  91. package/dist/src/web/app.js +330 -258
  92. package/dist/src/web/charts.js +229 -0
  93. package/dist/src/web/index.html +84 -15
  94. package/dist/src/web/pages/queries.js +192 -0
  95. package/dist/src/web/pages/status.js +446 -0
  96. package/dist/src/web/pages/syncs.js +155 -0
  97. package/dist/src/web/pages/tables.js +434 -0
  98. package/dist/src/web/pages/usage.js +397 -0
  99. package/dist/src/web/pages/users.js +218 -0
  100. package/dist/src/web/shared.js +568 -0
  101. package/dist/src/web/vendor/README.md +25 -0
  102. package/dist/src/web/vendor/geist-1.7.2/Geist-Variable.woff2 +0 -0
  103. package/dist/src/web/vendor/geist-1.7.2/GeistMono-Variable.woff2 +0 -0
  104. package/dist/src/web/vendor/geist-1.7.2/LICENSE.txt +92 -0
  105. package/dist/src/web/vendor/lucide-1.56.0/LICENSE.txt +43 -0
  106. package/dist/src/web/vendor/lucide-1.56.0/chart-column.mjs +29 -0
  107. package/dist/src/web/vendor/lucide-1.56.0/layout-dashboard.mjs +29 -0
  108. package/dist/src/web/vendor/lucide-1.56.0/logs.mjs +34 -0
  109. package/dist/src/web/vendor/lucide-1.56.0/menu.mjs +28 -0
  110. package/dist/src/web/vendor/lucide-1.56.0/square-terminal.mjs +28 -0
  111. package/dist/src/web/vendor/lucide-1.56.0/table.mjs +29 -0
  112. package/dist/src/web/vendor/lucide-1.56.0/users.mjs +29 -0
  113. package/dist/src/web/vendor/lucide-1.56.0/x.mjs +27 -0
  114. package/dist/src/web/vendor/uplot-1.6.32/LICENSE.txt +21 -0
  115. package/dist/src/web/vendor/uplot-1.6.32/uPlot.esm.js +6140 -0
  116. package/dist/src/web/vendor/uplot-1.6.32/uPlot.min.css +1 -0
  117. package/dist/src/web/vendor/zilla-slab-5.3.0/LICENSE.txt +93 -0
  118. package/dist/src/web/vendor/zilla-slab-5.3.0/zilla-slab-latin-700-normal.woff2 +0 -0
  119. package/package.json +10 -3
@@ -1,9 +1,9 @@
1
1
  /**
2
2
  * THE RUN'S COMMAND SERVER (`mason start`; a server for the `mason` command alone, the CLI's UI part dropped
3
- * completely). `mason tables`, `mason add`, `mason status`, `mason wait`, `mason sync` and
4
- * `mason logs --requests` ask it (src/cli/control.ts): it reads the database's catalog, writes the tables added and
5
- * removed into the project's sources file (src/cli/sources.ts) — the sync, told of the change, starts copying a table at
6
- * once — and says where everything is.
3
+ * completely). `mason status`, `mason wait`, `mason sync`, `mason logs --requests`, and `mason add` and `mason remove`
4
+ * while it is up, ask it (src/cli/control.ts): it reads the database's catalog, writes the tables added and removed into
5
+ * the project's sources file (src/cli/source-edit.ts, the check-and-write the commands call themselves while Mason is
6
+ * down) — the sync, told of the change, starts copying a table at once — and says where everything is.
7
7
  *
8
8
  * Every request passes the run's guard (src/cli/security.ts): loopback, the Host header, and the run's token, which
9
9
  * the commands send as `Authorization: Bearer` from the run's settings file.
@@ -12,9 +12,9 @@
12
12
  * request log (src/duck/requests.ts). Added is a fact about the project's files: a table is added when its sources file
13
13
  * names it.
14
14
  *
15
- * THE WEB PAGE (src/web/, `mason ui`, docs/WEB-UI.md): under `mason start` it also serves one page and its two files,
16
- * from an allowlist read when it starts (`pageFiles`), and the users the page manages (`/api/users`, the functions
17
- * `mason user` calls). The page reads the same JSON the commands read; a refusal carries `failure`, the CLI's Failure
15
+ * THE WEB PAGE (src/web/, `mason ui`, docs/WEB-UI.md): under `mason start` it also serves one page and its files (the
16
+ * shell, its pages' modules, the stylesheet), from an allowlist read when it starts (`pageFiles`), and the users the
17
+ * page manages (`/api/users`, the functions `mason user` calls). The page reads the same JSON the commands read; a refusal carries `failure`, the CLI's Failure
18
18
  * shape (message, why, fix, next), so the page says it as the command would. `mason serve`'s run serves no page.
19
19
  */
20
20
  import * as fs from "node:fs";
@@ -26,28 +26,45 @@ import { projectFingerprint } from "../lib/project-files.js";
26
26
  import { guard, SECURITY_HEADERS } from "./security.js";
27
27
  import { maskedUrl } from "./preflight.js";
28
28
  import { errorJson, nextJson } from "./command.js";
29
- import { readiness } from "./readiness.js";
30
- import { addedNext, addUser, listUsers, removeUser, REMOVED_NEXT } from "./user.js";
31
- import { postgresLink } from "./pg-url.js";
32
- import { addedTables, catalogTable, chooseColumns, entryName, entryProblems, findEntry, parseSourceText, shownPath, sourceFailure, sourceFileNames, sourcesDir, unaddableReason, withoutTable, withTable } from "./sources.js";
29
+ import { phaseCounts, readiness, syncTotals, tableSync } from "./readiness.js";
30
+ import { restartsFact } from "./supervise.js";
31
+ import { withRestarts } from "../duck/events.js";
32
+ import { NO_ROLES } from "../duck/grants.js";
33
+ import { addedNext, addUser, attributesToAdd, disabledNext, disableUser, enabledNext, enableUser, listUsers, removeUser, REMOVED_NEXT, roleToAdd, withLastUsed } from "./user.js";
34
+ import { userRole } from "./roles.js";
35
+ import { postgresFacts, postgresLink } from "./pg-url.js";
36
+ import { catalogTable, parseSourceText, shownPath } from "./sources.js";
37
+ import { addEntry, Refusal, removeEntries, sourceToEdit } from "./source-edit.js";
33
38
  import { PollRefused } from "../duck/sync.js";
34
- /** A statement answered in this many milliseconds or more is slow: what mason status lists (src/duck/requests.ts slow). */
35
- export const SLOW_MS = 1000;
39
+ import { DEFAULT_SYNC, parseSync, syncText } from "../lib/table-sync.js";
40
+ import { eventLimit, requestFilter, requestFilterText, SLOW_MS, timeOf } from "./request-filter.js";
41
+ import { tunnelReport } from "./share-run.js";
42
+ import { FRESH_SAMPLES } from "./metrics.js";
43
+ /** A statement answered in this many milliseconds or more is slow (one constant: src/cli/request-filter.ts SLOW_MS, the
44
+ * request log's `slow` filter's too): what mason status lists (src/duck/requests.ts slow). */
45
+ export { SLOW_MS };
36
46
  /**
37
47
  * The run's state, in the shape mason status / wait read (`RunState`, src/cli/commands.ts): each table of the database,
38
48
  * added or not, and each added one's copy as the sync has it, so `mason wait` is ready exactly when every added table is
39
- * copied.
49
+ * copied. LEAN unless `columns` is asked: each table's columns are most of its bytes (800 tables of 50 columns, 3.4 MB
50
+ * with them, 0.3 MB without), and the page polls the state every 2 s for none of them — its Columns… form reads one
51
+ * table's on opening (`GET /api/tables/<table>/columns`, `tableColumns`); `mason status --json` asks with them
52
+ * (`?columns=1`), its shape unchanged. One computation either way: the columns are left out, never built apart.
40
53
  */
41
- export function runState(ctx, copies, stream) {
54
+ export function runState(ctx, copies, stream, now = Date.now(), { columns = false } = {}) {
42
55
  const folder = ctx.folder();
43
56
  const sources = ctx.sources();
44
57
  const by = new Map(copies.map((c) => [c.source, c]));
45
58
  const project = folder && fs.existsSync(folder) ? projectFingerprint(folder) : null;
46
59
  const facts = ctx.facts();
60
+ const repl = ctx.replication();
61
+ const bytes = fileBytes(ctx.duck.file);
62
+ const disk = ctx.duck.disk();
47
63
  const tables = facts.tables.map((t) => {
48
- const c = by.get(t.source);
64
+ const c = by.get(t.source), entry = sources.file?.tables.find((e) => e.table === t.source);
65
+ const { columns: cols, ...catalog } = catalogTable(t, entry);
49
66
  return {
50
- ...catalogTable(t, sources.file?.tables.find((e) => e.table === t.source)),
67
+ ...catalog, ...(columns ? { columns: cols } : {}),
51
68
  // a table the sync has not reached is waiting for its copy (no copy yet), never "copying 0"
52
69
  // a copy in a retired layout (src/duck/sync.ts RETIRED_LAYOUT) serves until it is copied again: done, and said
53
70
  copy: c && c.phase !== "waiting" ? { phase: (c.phase === "copied" ? "done" : c.phase === "failed" ? "failed" : "copying"),
@@ -57,9 +74,13 @@ export function runState(ctx, copies, stream) {
57
74
  ...(c.phase === "copying" && c.progress !== undefined ? { progress: c.progress } : {}),
58
75
  ...(c.phase === "copied" && c.retired !== undefined ? { retired: c.retired } : {}) } : null,
59
76
  error: c?.phase === "failed" ? c.error : null,
60
- // copied and served, but not kept current by the poll (src/duck/sync.ts POLL_ROW_LIMIT): mason status and wait say so
61
- ...(c?.unsynced ? { unsynced: c.unsynced } : {}),
77
+ // how the run keeps the copy current NOW, as the sync derives it once (src/duck/sync.ts KeptCurrent, `keptOf`): its
78
+ // sync and when it was last synced, what mason status and the stream's tables say, and EXPLAIN says over the wire
79
+ ...(c?.kept ? { kept: c.kept } : {}),
62
80
  ...(c ? { duck: { rows: c.rows, bytes: c.bytes, seconds: c.seconds, copiedAt: c.copiedAt, error: c.error } } : {}),
81
+ // an added table's sync: synced, syncing, failed or waiting (src/cli/readiness.ts syncStateOf), when it was last
82
+ // brought current, how long that took and by what, and how old that is now
83
+ ...(c !== undefined || entry !== undefined ? { sync: tableSync(c, now) } : {}),
63
84
  };
64
85
  });
65
86
  return {
@@ -68,14 +89,39 @@ export function runState(ctx, copies, stream) {
68
89
  readiness: readiness({ tables }),
69
90
  // the run serves the web page (mason ui): mason status prints its URL
70
91
  ui: ctx.page,
71
- source: { url: maskedUrl(ctx.source.url), host: ctx.source.host, database: ctx.source.database, remote: ctx.source.remote,
72
- version: facts.versionText, standby: facts.standby, changes: ctx.changes, name: sources.file?.name ?? null, file: sources.file?.file ?? null,
73
- ...(ctx.publication ? { replication: { slot: ctx.slot, publication: ctx.publication.name, owned: ctx.publication.owned } } : {}) },
92
+ source: { url: maskedUrl(ctx.source.url), host: ctx.source.host, database: ctx.source.database, remote: ctx.source.remote, demo: ctx.demo === true,
93
+ // `replication` while a table's sync is replication (the run streams), else `poll`: each table's own is its kept
94
+ version: facts.versionText, standby: facts.standby, changes: repl !== null ? "replication" : "poll", name: sources.file?.name ?? null, file: sources.file?.file ?? null,
95
+ shownFile: sources.file ? shownPath(ctx.root, sources.file.file) : null,
96
+ // every table's sync unless its entry says its own: the file's top `sync:`, or the default when it writes none
97
+ sync: { default: syncText(sources.file?.sync ?? DEFAULT_SYNC), written: sources.file?.sync != null },
98
+ ...(repl !== null ? { replication: { slot: repl.slot, publication: repl.publication, owned: repl.owned } } : {}) },
74
99
  // a sources file edited into a mistake while the run is up: the run copies what it read last, and says so
75
- warnings: [...ctx.warnings, ...(sources.problem ? [`${sources.problem} (Mason copies the tables it read before; mason start reads the file again)`] : [])],
100
+ warnings: [...ctx.warnings, ...(sources.problem ? [`${sources.problem} (Mason syncs the tables it read before; mason start reads the file again)`] : [])],
76
101
  folder, project, tables,
77
- engine: { name: "duckdb", file: ctx.duck.file, bytes: fileBytes(ctx.duck.file), pollSeconds: ctx.duck.pollSeconds },
102
+ // where a tool connects (the Postgres API's URL, the credentials' place shown, and its psql line): the web page's
103
+ // Connect panel, as mason status --json says it (src/cli/pg-url.ts postgresFacts)
104
+ postgres: postgresFacts(ctx.users.postgresUrl),
105
+ // the copies in all: tables added, copied, failed, their rows, the latest sync (the file's size is engine.bytes)
106
+ totals: syncTotals(tables.filter((t) => t.added).length, copies),
107
+ // the run itself: its version, since when it is up, and its supervisor's restarts (ONE reader, restartsFact, which
108
+ // mason status reads with Mason stopped too)
109
+ // keptDays: how long the request log, the events and the usage samples are kept (MASON_HISTORY_DAYS): the page
110
+ // offers a time range past the week only when it is kept
111
+ engine: { file: ctx.duck.file, bytes, keptDays: ctx.duck.requests.days,
112
+ version: ctx.version, startedAt: new Date(ctx.startedAt).toISOString(), uptimeSeconds: Math.max(0, Math.round((now - ctx.startedAt) / 1000)),
113
+ restarts: restartsFact(ctx.home, ctx.duck.restarts) },
114
+ // the disk under the copy: free where its file is (the sync's own measure, the one a copy is held to), the file
115
+ // (engine.bytes), and what the first syncs running are projected to add (copyDiskProjection) — mason status says it
116
+ // the sessions logged in now, by user and client (the listener's own, in memory): mason status says how many
117
+ sessions: ctx.listener.openSessions(),
118
+ disk: { path: disk.path, freeBytes: disk.freeBytes, copyBytes: bytes, projectedBytes: disk.projectedBytes, ...(disk.error !== undefined ? { error: disk.error } : {}) },
78
119
  jsonPaths: ctx.duck.jsonPaths?.() ?? [],
120
+ // the roles file as the listener reads it now: its roles by name, and what is wrong with it (every statement of a
121
+ // session with a role is refused while there is)
122
+ ...(ctx.roles ? { roles: rolesState(ctx.roles()) } : {}),
123
+ // the share open now (mason share): where, who, and what was refused there; nothing in it logs anyone in
124
+ share: ctx.shares?.state() ?? null,
79
125
  // replication mode: where the stream is, its lag and what it applied (mason status and wait render it, streamLines)
80
126
  ...(stream !== null ? { stream } : {}),
81
127
  };
@@ -89,114 +135,177 @@ function fileBytes(file) {
89
135
  return null;
90
136
  } // a size is a nicety of mason status: unknown, not an error
91
137
  }
92
- async function state(ctx) {
93
- return runState(ctx, ctx.duck.copies(), await ctx.duck.stream());
138
+ /** `RunState.roles` (src/cli/commands.ts): the file, the roles it names and what is wrong with it now */
139
+ export const rolesState = (r) => {
140
+ const attributes = Object.fromEntries([...r.grants.values()].filter((g) => g.attributes !== undefined).map((g) => [g.name, Object.fromEntries(g.attributes)]));
141
+ const wheres = Object.fromEntries([...r.grants.values()].filter((g) => g.filters !== undefined).map((g) => [g.name,
142
+ [...g.filters.values()].flatMap((f) => f.where === undefined ? [] : [{ table: f.table, at: f.at, where: f.where.text }])]));
143
+ return { file: r.file, roles: [...r.grants.keys()], problem: r.problem, default: r.default, all: [...r.grants.values()].filter((g) => g.all).map((g) => g.name),
144
+ ...(Object.keys(attributes).length > 0 ? { attributes } : {}), ...(Object.keys(wheres).length > 0 ? { wheres } : {}) };
145
+ };
146
+ async function state(ctx, columns = false) {
147
+ return runState(ctx, ctx.duck.copies(), await ctx.duck.stream(), Date.now(), { columns });
94
148
  }
95
- /** The sources file as it is on disk now, its text and what it says — or why the command server cannot edit it. */
96
- function sourceOnDisk(ctx) {
97
- const folder = ctx.folder();
98
- if (!folder)
99
- throw new Refusal(409, "choose the project's folder first");
100
- const names = sourceFileNames(folder);
101
- if (names.length !== 1)
102
- throw new Refusal(409, `${path.relative(ctx.root, sourcesDir(folder)) || sourcesDir(folder)}/ has ${names.length ? `${names.length} source files` : "no source file"}: one source per project for now`);
103
- const file = path.join(sourcesDir(folder), names[0]);
104
- const text = fs.readFileSync(file, "utf8");
105
- const parsed = parseSourceText(text, names[0].replace(/\.ya?ml$/, ""), file);
106
- if ("problems" in parsed) {
107
- const f = sourceFailure(file, ctx.root, parsed.problems);
108
- throw new Refusal(409, `${f.message}${f.why ? `\n${f.why}` : ""} — fix it first`);
149
+ /**
150
+ * `GET /api/tables/<table>/columns`: one table's columns as the state says them with `?columns=1` (sources.ts
151
+ * catalogTable: name, type, nullable, comment, references) — what the page's Columns… form ticks, read when it opens.
152
+ * A table the catalog lacks (dropped since the run read it) is a 404 in the failure shape.
153
+ */
154
+ export function tableColumns(ctx, table) {
155
+ const t = ctx.facts().tables.find((x) => x.source === table);
156
+ if (t === undefined) {
157
+ const message = `the database has no table ${JSON.stringify(table)} this user can read`;
158
+ throw new Refusal(404, message, { message, code: "not_found", why: "it was dropped since Mason read the catalog, or never was", next: [["mason tables", "the tables it has, as this user reads them"]] });
109
159
  }
110
- // the database the file names NOW must be the one this run copies: its catalog is what an edit is checked against
111
- const read = ctx.readSource();
112
- if ("message" in read)
113
- throw new Refusal(409, `${read.message}${read.why ? `\n${read.why}` : ""} — fix it first`);
114
- if (read.resolved !== ctx.runUrl) {
115
- throw new Refusal(409, `${shownPath(ctx.root, file)}: url: now names ${maskedUrl(read.resolved)} (${read.from}), and this run copies ${maskedUrl(ctx.runUrl)}: ` +
116
- "mason stop && mason start copies from the new one, then add or remove the table");
160
+ return { table: t.source, columns: catalogTable(t, undefined).columns };
161
+ }
162
+ /**
163
+ * WHAT A USAGE SAMPLE READS OF THE RUN (src/duck/usage.ts): `/api/state` at that moment, never a second computation of
164
+ * any of it — the copy file's bytes (`engine.bytes`), `totals`, the stalest added table's `sync.ageSeconds`, the
165
+ * stream's `lag` and its slot's `walHeldBytes`, the bytes free under the copy (`disk.freeBytes`), the sessions open (`sessions.open`), the copies served, and
166
+ * the copies by phase (`phaseCounts`, the count `/healthz` says) for `GET /metrics`.
167
+ */
168
+ export async function usageFacts(ctx, now = Date.now(), { refresh = true } = {}) {
169
+ const copies = ctx.duck.copies();
170
+ const s = runState(ctx, copies, await ctx.duck.stream({ refresh }), now);
171
+ const ages = s.tables.flatMap((t) => (t.added && t.sync?.ageSeconds != null ? [t.sync.ageSeconds] : []));
172
+ const slot = s.stream?.slotWal;
173
+ return { copyBytes: s.engine.bytes, totals: s.totals, oldestSyncSeconds: ages.length ? Math.max(...ages) : null, streamLag: s.stream?.lag ?? null,
174
+ copied: copies.filter((c) => c.phase === "copied").map((c) => c.source), slotWalHeldBytes: slot !== undefined && "walHeldBytes" in slot ? slot.walHeldBytes : null,
175
+ diskFreeBytes: s.disk.freeBytes, sessionsOpen: s.sessions.open, tables: phaseCounts(copies),
176
+ streamLagReadAt: s.stream !== undefined && "lsn" in s.stream.database ? s.stream.database.at : null,
177
+ slotWalReadAt: slot !== undefined && "walHeldBytes" in slot ? slot.at : null };
178
+ }
179
+ /**
180
+ * WHAT `GET /metrics` READS OF THE RUN (src/cli/metrics.ts, `mason serve`'s health port): a usage sample's own facts
181
+ * (`usageFacts`, above: no number computed a second way), the statements since the process started as the sampler counts
182
+ * them as they are recorded (`statementTotals`), and the last sample's p50 / p95 (`lastDurations`).
183
+ *
184
+ * A SCRAPE NEVER REACHES THE SOURCE (the review of PR 50: the endpoint takes no credential, so its rate is anyone's):
185
+ * the stream's position and the slot are read as cached (`refresh: false`), never asked of the database; the sampler
186
+ * keeps them fresh once a minute, and a number read longer ago than `FRESH_SAMPLES` samples is said as unknown
187
+ * (`freshSeconds`, src/cli/metrics.ts).
188
+ */
189
+ export async function metricsFacts(ctx, now = Date.now()) {
190
+ return { usage: await usageFacts(ctx, now, { refresh: false }), statements: ctx.duck.usage.statementTotals(), durations: ctx.duck.usage.lastDurations(),
191
+ at: now, freshSeconds: FRESH_SAMPLES * ctx.duck.usage.sampleSeconds };
192
+ }
193
+ /** A `since` / `until` of a query string, read by the one time grammar (src/cli/request-filter.ts timeOf), as an ISO time. */
194
+ function timeParam(key, text, now) {
195
+ const t = timeOf(key, text, now);
196
+ if (typeof t !== "string")
197
+ throw new Refusal(400, t.message, t);
198
+ return t;
199
+ }
200
+ /** why `lastUsedAt` is null in a `GET /api/users` that did not ask for it */
201
+ const LAST_USED_NOT_ASKED = "not asked: GET /api/users?lastUsed=1 reads it from the request log";
202
+ const DAY_S = 86_400;
203
+ /**
204
+ * `GET /api/usage?since=&until=&step=`: the samples from `since` (default an hour ago) until `until` (default
205
+ * now), oldest first; with `step` (seconds, at least a sample's interval) re-bucketed in one query, each count summed and
206
+ * each level at its highest — but the p50 and p95 of a point of more than a minute are its statements', read from the
207
+ * request log by when each started (`durationsBy`). And each copy's own bytes as last measured, how often a sample is taken, and the days
208
+ * kept. `mason usage --json` is this.
209
+ */
210
+ export async function usage(ctx, q, now = Date.now()) {
211
+ const time = (k, fallback) => {
212
+ const text = q.get(k);
213
+ if (text === null)
214
+ return new Date(fallback).toISOString();
215
+ return timeParam(k, text, now);
216
+ };
217
+ const since = time("since", now - 3_600_000), until = time("until", now);
218
+ if (since >= until)
219
+ throw new Refusal(400, `since (${since}) must be before until (${until})`);
220
+ const u = ctx.duck.usage;
221
+ const stepText = q.get("step");
222
+ const step = stepText === null ? null : Number(stepText);
223
+ if (step !== null && (!Number.isInteger(step) || step < u.sampleSeconds || step > u.days * DAY_S)) {
224
+ throw new Refusal(400, `step is a whole number of seconds, ${u.sampleSeconds} (a sample's interval) to ${u.days * DAY_S} (the ${u.days} days kept), not ${JSON.stringify(stepText)}`);
117
225
  }
118
- return { file, text, parsed };
226
+ const read = await u.samples({ since, until, ...(step === null ? {} : { stepSeconds: step }) });
227
+ // a point of more than a minute: its p50 and p95 are its statements', from the request log (`durationsBy`), never
228
+ // the highest of its minutes' (a sample keeps its minute's alone)
229
+ const durations = step !== null && step > u.sampleSeconds ? await ctx.duck.requests.durationsBy(since, until, step) : null;
230
+ const samples = durations === null ? read : read.map((s) => {
231
+ const d = durations.get(Date.parse(s.at));
232
+ return { ...s, durationP50Ms: d?.p50 ?? null, durationP95Ms: d?.p95 ?? null };
233
+ });
234
+ return { since, until, stepSeconds: step, sampleSeconds: u.sampleSeconds, keptDays: u.days, samples, copySizes: await u.copySizes() };
119
235
  }
120
- /** The file's other entries against the database: an edit is never written over a file the run would not take. */
121
- function othersHold(ctx, parsed, without, then) {
122
- const others = { ...parsed, tables: parsed.tables.filter((e) => !without.includes(e.table)) };
123
- const held = addedTables(others, ctx.facts().tables, ctx.source.database, ctx.root, { replication: ctx.changes === "replication" });
124
- if (!Array.isArray(held))
125
- throw new Refusal(409, `${held.message}${held.why ? `\n${held.why}` : ""} — fix it first, then ${then}`);
236
+ /**
237
+ * `GET /api/activity?since=`: what the Postgres API answered from `since` on (default an hour ago; the one time grammar,
238
+ * src/cli/request-filter.ts `timeOf`), for the Usage page's time range — the window's statements (src/duck/requests.ts
239
+ * `window`), who sent them (`clients`: a row per user and client, the 50 with the most statements; `clientsTotal`:
240
+ * how many clients and sessions in all) and the slowest that completed (`slowest`); and the
241
+ * days the request log keeps. Read once per range chosen, every 5 s while the range is Now.
242
+ */
243
+ export async function activity(ctx, q, now = Date.now()) {
244
+ const sinceText = q.get("since");
245
+ const since = sinceText === null ? new Date(now - 3_600_000).toISOString() : timeParam("since", sinceText, now);
246
+ const r = ctx.duck.requests;
247
+ const [window, who, slowest] = await Promise.all([r.window(since, now), r.clients(since), r.slowest(since)]);
248
+ return { since, window, clients: who.clients, clientsTotal: who.total, slowest, keptDays: r.days };
126
249
  }
250
+ /** how many registry entries `/api/unsupported` answers unless `limit` says, and at most */
251
+ export const DEFAULT_UNSUPPORTED_LIMIT = 10;
252
+ export const MAX_UNSUPPORTED_LIMIT = 100;
127
253
  /**
128
- * ADD A TABLE: its entry in the sources file, written — after the last one, or in place of the table's own (`withTable`):
129
- * every other byte of the file as it was. `columns` are the ones asked (`["*"]`: every one); `key` the columns a table
130
- * with no primary key is copied by. The file's other entries must hold against the database first: an edit is never
131
- * written over a file the next start would refuse.
254
+ * `GET /api/unsupported?since=&limit=`: the statements refused from `since` on (default a day ago; read by the one time
255
+ * grammar, src/cli/request-filter.ts `timeOf`) grouped by the registry entry that refused them (src/duck/requests.ts
256
+ * `unsupported`), the most refused first, `limit` (1 to 100, default 10) of them; and the days the request log keeps.
257
+ * `mason status --json` `unsupported` is this.
132
258
  */
133
- export async function pick(ctx, table, columns = ["*"], key = null) {
134
- const { file, text, parsed } = sourceOnDisk(ctx);
135
- const facts = ctx.facts();
136
- const t = facts.tables.find((x) => x.source === table);
137
- if (!t)
138
- throw new Refusal(404, `${table} is not a table of ${ctx.source.database} that this user can read`);
139
- if (key !== null && t.key.length)
140
- throw new Refusal(400, `${table} has a primary key (${t.key.join(", ")}), which Mason copies it by: leave --key out`);
141
- if (key === null && !t.key.length)
142
- throw new Refusal(409, `${table} cannot be added: ${unaddableReason(t)}`);
143
- if (key !== null && ctx.changes === "replication")
144
- throw new Refusal(409, `${table} has no primary key, and logical replication cannot apply its changes by --key: copy it by polling (mason start --poll), then add it`);
145
- const chosen = chooseColumns(t, columns, key);
146
- if ("problem" in chosen)
147
- throw new Refusal(400, chosen.problem);
148
- othersHold(ctx, parsed, [table], `add ${entryName(table)}`);
149
- const was = parsed.tables.find((e) => e.table === table);
150
- const entry = { name: was?.name ?? entryName(table), table, columns: chosen.columns, exclude: chosen.exclude, key: key === null ? null : [...key] };
151
- // the entry itself, as the run will read it: a --key that does not tell the rows apart is refused here, not at the copy
152
- const own = entryProblems(t, entry, facts.tables, ctx.source.database, { replication: ctx.changes === "replication" });
153
- if (own.length)
154
- throw new Refusal(400, own.join("; "));
155
- const same = was !== undefined && JSON.stringify([was.columns, was.exclude, was.key]) === JSON.stringify([entry.columns, entry.exclude, entry.key]);
156
- if (!same) {
157
- const r = withTable(text, entry);
158
- if ("problem" in r)
159
- throw new Refusal(409, `${path.relative(ctx.root, file)} cannot be edited: ${r.problem}`);
160
- fs.writeFileSync(file, r.text);
259
+ export async function unsupported(ctx, q, now = Date.now()) {
260
+ const sinceText = q.get("since");
261
+ const since = sinceText === null ? new Date(now - DAY_S * 1000).toISOString() : timeParam("since", sinceText, now);
262
+ const limitText = q.get("limit");
263
+ const limit = limitText === null ? DEFAULT_UNSUPPORTED_LIMIT : Number(limitText);
264
+ if (!Number.isInteger(limit) || limit < 1 || limit > MAX_UNSUPPORTED_LIMIT) {
265
+ throw new Refusal(400, `limit is a whole number of entries of docs/SQL-SUPPORT.md, 1 to ${MAX_UNSUPPORTED_LIMIT}, not ${JSON.stringify(limitText)}`);
161
266
  }
162
- return { table, file, entry: entry.name, columns: entry.columns, exclude: entry.exclude, key: entry.key, addedKey: chosen.addedKey, skippedSecrets: chosen.skipped, already: same };
267
+ return { since, unsupported: await ctx.duck.requests.unsupported({ since, limit }), keptDays: ctx.duck.requests.days };
163
268
  }
164
269
  /**
165
- * REMOVE TABLES from the sources file: each one's entry cut (`withoutTable`), every other byte of the file as it was; the
166
- * run then drops its copy. A name the file does not have is refused with the closest one it has.
270
+ * `GET /api/events?limit=&since=&failed=1`: the newest `limit` (1 to 10000, default 40) of the sync's events merged with
271
+ * the run's restarts, newest last, from `since` on (`since` and `until` are read everywhere by one grammar, src/cli/request-filter.ts `timeOf`: an age, or an ISO 8601 time with its zone), the failures only with `failed=1`; and the days
272
+ * they are kept. `mason logs --events --json` is this; `mason status --json` `recentErrors` its latest failures.
167
273
  */
168
- export async function removeTables(ctx, names) {
169
- const { file, text, parsed } = sourceOnDisk(ctx);
170
- const entries = names.map((n) => {
171
- const e = findEntry(parsed, n);
172
- if ("problem" in e)
173
- throw new Refusal(e.usage ? 400 : 404, e.problem);
174
- return e;
175
- });
176
- // what stays must hold: else the run's re-read refuses the file, keeps the removed table and goes on copying it
177
- othersHold(ctx, parsed, entries.map((e) => e.table), `remove ${names.join(" ")}`);
178
- let out = text;
179
- for (const e of entries) {
180
- const r = withoutTable(out, e.table);
181
- if (r === null)
182
- continue;
183
- if ("problem" in r)
184
- throw new Refusal(409, `${path.relative(ctx.root, file)} cannot be edited: ${r.problem}`);
185
- out = r.text;
274
+ export async function events(ctx, q) {
275
+ const limit = eventLimit(q.get("limit") ?? undefined, "limit");
276
+ if (typeof limit !== "number")
277
+ throw new Refusal(400, limit.message, limit);
278
+ const sinceText = q.get("since");
279
+ const since = sinceText === null ? undefined : timeParam("since", sinceText, Date.now());
280
+ const o = { limit, failed: q.get("failed") === "1", ...(since === undefined ? {} : { since }) };
281
+ const found = await ctx.duck.events.recent(o);
282
+ return { events: withRestarts(found, ctx.duck.restarts(), { ...o, days: ctx.duck.events.days }), keptDays: ctx.duck.events.days };
283
+ }
284
+ /** The sources file as it is on disk now (src/cli/source-edit.ts sourceToEdit) — and the database it names NOW must be
285
+ * the one this run copies: its catalog is what an edit is checked against. */
286
+ function sourceOnDisk(ctx) {
287
+ const at = sourceToEdit(ctx.folder(), ctx.root, ctx.readSource);
288
+ if (at.read.resolved !== ctx.runUrl) {
289
+ throw new Refusal(409, `${shownPath(ctx.root, at.file)}: url: now names ${maskedUrl(at.read.resolved)} (${at.read.from}), and this run syncs ${maskedUrl(ctx.runUrl)}: ` +
290
+ "mason stop && mason start syncs from the new one, then add or remove the table");
186
291
  }
187
- fs.writeFileSync(file, out);
188
- return { removed: [...new Set(entries.map((e) => e.table))], file };
292
+ return at;
293
+ }
294
+ /** The database this run copies, as an edit is checked against it: the catalog it last read, and how it reads changes. */
295
+ const catalogOf = (ctx) => ({ tables: ctx.facts().tables, database: ctx.source.database,
296
+ ...(ctx.duck.measured ? { measured: ctx.duck.measured } : {}) });
297
+ /**
298
+ * ADD A TABLE: the check-and-write every entrance shares (src/cli/source-edit.ts addEntry), against the file on disk and
299
+ * this run's catalog; the caller tells the run the file changed, and the sync starts the copy.
300
+ */
301
+ export function pick(ctx, table, columns = ["*"], key = null, o = {}, at = sourceOnDisk(ctx)) {
302
+ return addEntry(at, catalogOf(ctx), table, columns, key, o);
189
303
  }
190
- /** A refusal the command shows as it is; `failure` is how the web page says it (the CLI's Failure: why, fix, next), by
191
- * default the message alone under the status's code. */
192
- export class Refusal extends Error {
193
- status;
194
- failure;
195
- constructor(status, message, failure) { super(message); this.status = status; this.failure = failure ?? { message, code: codeOf(status) }; }
304
+ /** REMOVE TABLES: the check-and-write every entrance shares (src/cli/source-edit.ts removeEntries); the run then drops their copies. */
305
+ export function removeTables(ctx, names) {
306
+ return removeEntries(sourceOnDisk(ctx), catalogOf(ctx), names);
196
307
  }
197
- /** The FAILURE_CODES code (src/cli/command.ts) of a refusal by its HTTP status. */
198
- const codeOf = (status) => status === 400 ? "usage" : status === 404 ? "not_found" : status >= 500 ? "failed" : "refused";
199
- /** The HTTP status of a Failure by its code: the other way round. */
308
+ /** The HTTP status of a Failure by its code: src/cli/source-edit.ts codeOf the other way round. */
200
309
  const statusOf = (f) => f.code === "usage" ? 400 : f.code === "not_found" ? 404 : f.code === "failed" ? 500 : 409;
201
310
  /**
202
311
  * EVERY ERROR ANSWER: `error`, the sentence a command prints (src/cli/control.ts reads it), and `failure`, the same
@@ -206,11 +315,49 @@ const statusOf = (f) => f.code === "usage" ? 400 : f.code === "not_found" ? 404
206
315
  export function sendFailure(res, status, f, extra = {}) {
207
316
  sendJson(res, status, { error: f.message, ...extra, failure: { ...errorJson(f), next: nextJson(f.next) } });
208
317
  }
318
+ /**
319
+ * A VENDORED FILE AT A PINNED VERSION (`/vendor/<name>-<x.y.z>/…`, src/web/vendor/README.md): its bytes are that
320
+ * release's for good — a new version is a new folder — so the browser keeps it a year without asking again
321
+ * (`immutable`): a reload re-reads the page's own modules (no-store, as every answer) but not the fonts, uPlot and
322
+ * the icons — measured with the demo: a reload moved 222 KB where the first load moved 520 KB.
323
+ */
324
+ const PINNED_VENDOR = /^\/vendor\/[a-z][a-z0-9-]*-\d+\.\d+\.\d+\//;
325
+ const IMMUTABLE = "private, max-age=31536000, immutable";
209
326
  /** THE WEB PAGE'S ROUTES, AND THE FILE EACH IS: the only files the server ever reads for a URL. */
210
327
  export const PAGE_ROUTES = {
211
328
  "/": ["index.html", "text/html; charset=utf-8"],
212
329
  "/app.js": ["app.js", "text/javascript; charset=utf-8"],
213
330
  "/app.css": ["app.css", "text/css; charset=utf-8"],
331
+ // the shell's shared helpers and one module per page (src/web/app.js has the page module contract)
332
+ "/shared.js": ["shared.js", "text/javascript; charset=utf-8"],
333
+ "/pages/status.js": ["pages/status.js", "text/javascript; charset=utf-8"],
334
+ "/pages/tables.js": ["pages/tables.js", "text/javascript; charset=utf-8"],
335
+ "/pages/queries.js": ["pages/queries.js", "text/javascript; charset=utf-8"],
336
+ "/pages/users.js": ["pages/users.js", "text/javascript; charset=utf-8"],
337
+ "/pages/usage.js": ["pages/usage.js", "text/javascript; charset=utf-8"],
338
+ "/pages/syncs.js": ["pages/syncs.js", "text/javascript; charset=utf-8"],
339
+ // the charts (src/web/charts.js over uPlot) and what the page vendors whole, pinned, with its licence
340
+ // (src/web/vendor/README.md): served from here, never fetched from elsewhere — a box without internet reads it all
341
+ "/charts.js": ["charts.js", "text/javascript; charset=utf-8"],
342
+ "/vendor/README.md": ["vendor/README.md", "text/plain; charset=utf-8"],
343
+ "/vendor/geist-1.7.2/Geist-Variable.woff2": ["vendor/geist-1.7.2/Geist-Variable.woff2", "font/woff2"],
344
+ "/vendor/geist-1.7.2/GeistMono-Variable.woff2": ["vendor/geist-1.7.2/GeistMono-Variable.woff2", "font/woff2"],
345
+ "/vendor/geist-1.7.2/LICENSE.txt": ["vendor/geist-1.7.2/LICENSE.txt", "text/plain; charset=utf-8"],
346
+ "/vendor/zilla-slab-5.3.0/zilla-slab-latin-700-normal.woff2": ["vendor/zilla-slab-5.3.0/zilla-slab-latin-700-normal.woff2", "font/woff2"],
347
+ "/vendor/zilla-slab-5.3.0/LICENSE.txt": ["vendor/zilla-slab-5.3.0/LICENSE.txt", "text/plain; charset=utf-8"],
348
+ "/vendor/uplot-1.6.32/uPlot.esm.js": ["vendor/uplot-1.6.32/uPlot.esm.js", "text/javascript; charset=utf-8"],
349
+ "/vendor/uplot-1.6.32/uPlot.min.css": ["vendor/uplot-1.6.32/uPlot.min.css", "text/css; charset=utf-8"],
350
+ "/vendor/uplot-1.6.32/LICENSE.txt": ["vendor/uplot-1.6.32/LICENSE.txt", "text/plain; charset=utf-8"],
351
+ // the nav's icons (src/web/shared.js ICONS): Lucide's, one ES module per icon, each its SVG as a string
352
+ "/vendor/lucide-1.56.0/chart-column.mjs": ["vendor/lucide-1.56.0/chart-column.mjs", "text/javascript; charset=utf-8"],
353
+ "/vendor/lucide-1.56.0/layout-dashboard.mjs": ["vendor/lucide-1.56.0/layout-dashboard.mjs", "text/javascript; charset=utf-8"],
354
+ "/vendor/lucide-1.56.0/logs.mjs": ["vendor/lucide-1.56.0/logs.mjs", "text/javascript; charset=utf-8"],
355
+ "/vendor/lucide-1.56.0/menu.mjs": ["vendor/lucide-1.56.0/menu.mjs", "text/javascript; charset=utf-8"],
356
+ "/vendor/lucide-1.56.0/square-terminal.mjs": ["vendor/lucide-1.56.0/square-terminal.mjs", "text/javascript; charset=utf-8"],
357
+ "/vendor/lucide-1.56.0/table.mjs": ["vendor/lucide-1.56.0/table.mjs", "text/javascript; charset=utf-8"],
358
+ "/vendor/lucide-1.56.0/users.mjs": ["vendor/lucide-1.56.0/users.mjs", "text/javascript; charset=utf-8"],
359
+ "/vendor/lucide-1.56.0/x.mjs": ["vendor/lucide-1.56.0/x.mjs", "text/javascript; charset=utf-8"],
360
+ "/vendor/lucide-1.56.0/LICENSE.txt": ["vendor/lucide-1.56.0/LICENSE.txt", "text/plain; charset=utf-8"],
214
361
  };
215
362
  /**
216
363
  * The web page's files, READ ONCE when the server starts (src/web/ beside src/cli/, in a checkout and in the package
@@ -227,11 +374,70 @@ export function pageFiles(dir = fileURLToPath(new URL("../web/", import.meta.url
227
374
  catch (e) {
228
375
  throw new Error(`the web page's ${file} cannot be read (${at}: ${e.message}): this Mason is installed incompletely — npm i -g @mason-data/cli again`);
229
376
  }
230
- return [route, { body, type }];
377
+ return [route, { body, type, immutable: PINNED_VENDOR.test(route) }];
231
378
  }));
232
379
  }
380
+ /** How long `/api/queries`' summary is answered from the last read: the Overview asks every 30 s, Usage and Queries
381
+ * too, and `mason status` at each run — each a 7-day scan of the request log (~0.15 s at 30 M rows). */
382
+ export const SUMMARY_TTL_MS = 30_000;
383
+ /**
384
+ * ONE READ SHARED FOR `ms`, by time only (`summaryCache`): every ask within `ms` of a read gets that read — the one in
385
+ * flight too, so two pages asking at once cost one scan. A read that fails is not kept: the next ask reads again. So the
386
+ * summary is up to `ms` old (docs/OBSERVABILITY.md says it).
387
+ */
388
+ export function timedCache(read, ms, clock = Date.now) {
389
+ let kept = null;
390
+ return () => {
391
+ const now = clock();
392
+ if (kept !== null && now - kept.at < ms)
393
+ return kept.value;
394
+ const value = read();
395
+ const mine = { at: now, value };
396
+ kept = mine;
397
+ // a failure is the caller's answer (it awaits `value`); here it only means the read is not kept
398
+ value.catch(() => { if (kept === mine)
399
+ kept = null; });
400
+ return value;
401
+ };
402
+ }
403
+ /**
404
+ * `POST /api/share` { secret, role? }: a share opened, its first line of JSON, and THE ANSWER LEFT OPEN: the connection is
405
+ * the share (src/cli/share-run.ts), so a command gone by any means ends it. Only the password's hash arrives here. Past
406
+ * its first line the answer says one thing, why the run ended it.
407
+ */
408
+ async function openShare(ctx, req, res) {
409
+ if (ctx.shares === undefined) {
410
+ sendFailure(res, 409, { message: "this run is on a server, and has an address of its own: mason share is for a run on a laptop", code: "refused", next: [["mason help production", "reaching a server's Postgres API"]] });
411
+ return;
412
+ }
413
+ const shares = ctx.shares;
414
+ const body = JSON.parse(await readBody(req, 64 * 1024));
415
+ if (typeof body.secret !== "string" || !/^[0-9a-f]{64}$/.test(body.secret))
416
+ throw new Refusal(400, "send { secret, role? }: the password's SHA-256 in hex");
417
+ if (body.role !== undefined && typeof body.role !== "string")
418
+ throw new Refusal(400, "role is a role's name, or left out");
419
+ let gone = false;
420
+ res.once("close", () => { gone = true; });
421
+ const opened = await shares.open({ secret: body.secret, role: body.role });
422
+ if ("failure" in opened) {
423
+ sendFailure(res, statusOf(opened.failure), opened.failure, opened.choices ? { choices: opened.choices } : {});
424
+ return;
425
+ }
426
+ const end = (why) => { void shares.close(opened.user, why).catch((e) => ctx.log(`closing the share ${opened.user} failed: ${e.message}`)); };
427
+ // the command went while the socket opened
428
+ if (gone) {
429
+ end("mason share went away");
430
+ return;
431
+ }
432
+ res.once("close", () => end("mason share went away"));
433
+ shares.onEnd(opened.user, (why) => { res.end(`${JSON.stringify({ ended: why })}\n`); });
434
+ res.writeHead(200, { "Content-Type": "application/x-ndjson" });
435
+ res.write(`${JSON.stringify(opened)}\n`);
436
+ }
233
437
  export function createRunServer(ctx) {
234
438
  const saidErrors = new Set();
439
+ // the last day's and week's statements, read at most once every SUMMARY_TTL_MS for every page and command that asks
440
+ const summary = timedCache(() => ctx.duck.requests.summary(), SUMMARY_TTL_MS);
235
441
  // read once, here: nothing a request names is ever read from disk (mason serve's run serves no page)
236
442
  const page = ctx.page ? pageFiles() : new Map();
237
443
  return http.createServer((req, res) => {
@@ -251,14 +457,27 @@ export function createRunServer(ctx) {
251
457
  try {
252
458
  const file = req.method === "GET" || req.method === "HEAD" ? page.get(url.pathname) : undefined;
253
459
  if (file !== undefined) {
254
- res.writeHead(200, { "Content-Type": file.type, "Content-Length": file.body.length }).end(req.method === "HEAD" ? undefined : file.body);
460
+ res.writeHead(200, { "Content-Type": file.type, "Content-Length": file.body.length, ...(file.immutable ? { "Cache-Control": IMMUTABLE } : {}) })
461
+ .end(req.method === "HEAD" ? undefined : file.body);
255
462
  }
256
463
  else if (req.method === "GET" && url.pathname === "/api/ping") {
257
464
  // what proves a run is up (src/cli/runs.ts answers): this token answered, by this process
258
465
  sendJson(res, 200, { pid: process.pid });
259
466
  }
260
467
  else if (req.method === "GET" && url.pathname === "/api/state") {
261
- sendJson(res, 200, await state(ctx));
468
+ // lean, as the page polls it; ?columns=1 with each table's columns (mason status --json)
469
+ sendJson(res, 200, await state(ctx, url.searchParams.get("columns") === "1"));
470
+ }
471
+ else if (req.method === "GET" && /^\/api\/tables\/[^/]+\/columns$/.test(url.pathname)) {
472
+ // one table's columns, read when the page's Columns… form opens (never with the state, polled every 2 s)
473
+ let table;
474
+ try {
475
+ table = decodeURIComponent(url.pathname.split("/")[3]);
476
+ }
477
+ catch {
478
+ throw new Refusal(400, `${url.pathname} does not name a table: GET /api/tables/<schema.table, URL-encoded>/columns`);
479
+ }
480
+ sendJson(res, 200, tableColumns(ctx, table));
262
481
  }
263
482
  else if (req.method === "GET" && url.pathname === "/api/slow") {
264
483
  // the last day's slow statements, from the request log (src/duck/requests.ts)
@@ -267,50 +486,165 @@ export function createRunServer(ctx) {
267
486
  else if (req.method === "GET" && url.pathname === "/api/refused") {
268
487
  sendJson(res, 200, await ctx.duck.requests.refused());
269
488
  }
489
+ else if (req.method === "GET" && url.pathname === "/api/unsupported") {
490
+ // the refused statements grouped by the unsupported SQL they used (src/duck/gaps.ts): mason status
491
+ sendJson(res, 200, await unsupported(ctx, url.searchParams));
492
+ }
270
493
  else if (req.method === "GET" && url.pathname === "/api/requests") {
271
- const limit = Number(url.searchParams.get("limit") ?? 40);
272
- if (!Number.isInteger(limit) || limit < 1 || limit > 10_000)
273
- throw new Refusal(400, "limit is a whole number of requests, 1 to 10000");
274
- sendJson(res, 200, { requests: await ctx.duck.requests.recent({ limit, failed: url.searchParams.get("failed") === "1" }), keptDays: ctx.duck.requests.days });
494
+ // the filters, read as mason logs --requests reads its options (src/cli/request-filter.ts)
495
+ const q = requestFilter(requestFilterText(url.searchParams));
496
+ if ("message" in q) {
497
+ sendFailure(res, 400, q);
498
+ return;
499
+ }
500
+ sendJson(res, 200, { ...await ctx.duck.requests.page(q), keptDays: ctx.duck.requests.days });
501
+ }
502
+ else if (req.method === "GET" && url.pathname === "/api/queries") {
503
+ // the last day's and week's statements in one scan of the request log (mason status); asked on its own, never
504
+ // with /api/state, which the page asks every 2 s
505
+ // (cached SUMMARY_TTL_MS: up to 30 s old)
506
+ sendJson(res, 200, { queries: await summary() });
507
+ }
508
+ else if (req.method === "GET" && url.pathname === "/api/activity") {
509
+ // one time range's statements, clients and slowest (the Usage page's), from the request log
510
+ sendJson(res, 200, await activity(ctx, url.searchParams));
511
+ }
512
+ else if (req.method === "GET" && url.pathname === "/api/running") {
513
+ sendJson(res, 200, { running: ctx.listener.running() });
514
+ }
515
+ else if (req.method === "POST" && url.pathname === "/api/cancel") {
516
+ const body = JSON.parse(await readBody(req, 64 * 1024));
517
+ const whole = (x) => typeof x === "number" && Number.isInteger(x) && x >= 1;
518
+ if (!whole(body.session))
519
+ throw new Refusal(400, "send { session }: the session's number, as mason logs --running lists it");
520
+ if (body.statement !== undefined && !whole(body.statement))
521
+ throw new Refusal(400, "statement is the statement's number, as mason logs --running lists it, or left out");
522
+ const r = ctx.listener.cancel(body.session, body.statement);
523
+ if ("cancelled" in r) {
524
+ sendJson(res, 200, r);
525
+ return;
526
+ }
527
+ const running = ["mason logs --running", "the statements running now, with their sessions"];
528
+ if ("other" in r) {
529
+ sendFailure(res, 409, { message: `session ${r.other.session} runs another statement now (statement ${r.other.statement}), not statement ${r.asked}: nothing cancelled`, code: "refused",
530
+ why: `statement ${r.asked} ended, and the session began another`, fix: `mason cancel ${r.other.session} --statement ${r.other.statement} stops the one it runs now`, next: [running] });
531
+ return;
532
+ }
533
+ if ("unknown" in r) {
534
+ sendFailure(res, 404, { message: `no session ${r.unknown} is open`, code: "not_found", why: "it ended, or never was", next: [running] });
535
+ return;
536
+ }
537
+ sendFailure(res, 409, { message: `session ${r.idle} is running no statement: nothing to cancel`, code: "refused", why: "its statement ended, or it is between two", next: [running] });
538
+ }
539
+ else if (req.method === "GET" && url.pathname === "/api/logs") {
540
+ // the run's last log entries from memory (mason serve has no file to read): { at, text } each, oldest first
541
+ const lines = Number(url.searchParams.get("lines") ?? Math.min(40, ctx.logs.capacity));
542
+ if (!Number.isInteger(lines) || lines < 1 || lines > ctx.logs.capacity)
543
+ throw new Refusal(400, `lines is a whole number of log entries, 1 to ${ctx.logs.capacity} (all the run keeps)`);
544
+ const sinceText = url.searchParams.get("since");
545
+ const since = sinceText === null ? undefined : timeParam("since", sinceText, Date.now());
546
+ sendJson(res, 200, { lines: ctx.logs.recent({ lines, ...(since !== undefined ? { since } : {}) }), kept: ctx.logs.capacity });
547
+ }
548
+ else if (req.method === "GET" && url.pathname === "/api/events") {
549
+ // the sync's events and the run's restarts, newest last (src/duck/events.ts): mason logs --events, mason status
550
+ sendJson(res, 200, await events(ctx, url.searchParams));
551
+ }
552
+ else if (req.method === "GET" && url.pathname === "/api/usage") {
553
+ // usage over time, a sample a minute (src/duck/usage.ts): mason usage
554
+ sendJson(res, 200, await usage(ctx, url.searchParams));
275
555
  }
276
556
  else if (req.method === "POST" && url.pathname === "/api/pick") {
277
557
  const body = JSON.parse(await readBody(req, 64 * 1024));
278
558
  if (typeof body.table !== "string" || typeof body.picked !== "boolean")
279
- throw new Refusal(400, "send { table, picked, columns?, key? }");
559
+ throw new Refusal(400, "send { table, picked, columns?, key?, sync?, keep? }");
560
+ // a sync by the sources file's own grammar (src/lib/table-sync.ts), as mason add --sync reads it
561
+ const sync = body.sync === undefined ? undefined : parseSync(body.sync);
562
+ if (sync !== undefined && "problem" in sync)
563
+ throw new Refusal(400, `sync ${JSON.stringify(body.sync)}: ${sync.problem}`);
280
564
  if (body.columns !== undefined && !(Array.isArray(body.columns) && body.columns.every((c) => typeof c === "string")))
281
565
  throw new Refusal(400, "columns is a list of column names, or [\"*\"]");
282
566
  if (body.key !== undefined && !(Array.isArray(body.key) && body.key.length > 0 && body.key.every((c) => typeof c === "string")))
283
567
  throw new Refusal(400, "key is a list of column names");
284
568
  if (body.picked) {
285
- const r = await pick(ctx, body.table, body.columns ?? ["*"], body.key ?? null);
569
+ // the file as it was (its text, read once with the edit): an edit that sets a table to sync: replication the
570
+ // database cannot serve is undone below
571
+ const at = sourceOnDisk(ctx);
572
+ const r = pick(ctx, body.table, body.columns ?? ["*"], body.key ?? null, { ...(sync !== undefined ? { sync } : {}), keep: body.keep === true }, at);
573
+ // A LIVE SWITCH INTO REPLICATION is held to the start's checks (wal_level, the role, a free slot): one the
574
+ // database cannot serve would leave the table, polled until now, NOT kept current for ever — the file is put
575
+ // back as it was, byte for byte, and the refusal says what the database lacks and the line that polls it
576
+ const now = r.already || ctx.checkReplication === undefined ? null : parseSourceText(fs.readFileSync(at.file, "utf8"), at.parsed.name, at.file);
577
+ const refused = now === null || "problems" in now ? null : await ctx.checkReplication(now);
578
+ if (refused !== null) {
579
+ fs.writeFileSync(at.file, at.text);
580
+ sendFailure(res, statusOf(refused), refused);
581
+ return;
582
+ }
286
583
  if (!r.already)
287
584
  ctx.log(`added ${r.table} to ${r.file}`);
288
585
  ctx.sourcesChanged();
289
586
  sendJson(res, 200, { ...r, state: await state(ctx) });
290
587
  }
291
588
  else {
292
- const r = await removeTables(ctx, [body.table]);
589
+ const r = removeTables(ctx, [body.table]);
293
590
  for (const t of r.removed)
294
591
  ctx.log(`removed ${t} from ${r.file}`);
295
592
  ctx.sourcesChanged();
296
593
  sendJson(res, 200, { ...r, state: await state(ctx) });
297
594
  }
298
595
  }
596
+ else if (req.method === "POST" && url.pathname === "/api/share") {
597
+ await openShare(ctx, req, res);
598
+ }
599
+ else if (req.method === "POST" && /^\/api\/share\/[^/]+$/.test(url.pathname)) {
600
+ // what the command knows of its tunnel, as it knows it: mason status says it, and the run stops the client
601
+ const body = tunnelReport(JSON.parse(await readBody(req, 64 * 1024)));
602
+ if (body === null)
603
+ throw new Refusal(400, "send { via?, host?, port?, endsAt?, state?, pid? }: what the command knows of its tunnel");
604
+ const user = decodeURIComponent(url.pathname.slice("/api/share/".length));
605
+ if (ctx.shares?.update(user, body) !== true) {
606
+ sendFailure(res, 404, { message: `no share ${user} is open`, code: "not_found" });
607
+ return;
608
+ }
609
+ sendJson(res, 200, { ok: true });
610
+ }
299
611
  else if (req.method === "GET" && url.pathname === "/api/users") {
300
- // mason user list --json's shape: the users, and whether each connects
301
- const r = listUsers(ctx.users.file, ctx.users.apiKey);
612
+ // mason user list --json's shape: the users, and whether each connects (and with ?lastUsed=1 when each was last used)
613
+ const r = listUsers(ctx.users.file, ctx.users.apiKey, postgresLink(ctx.users.postgresUrl), ctx.roles?.() ?? NO_ROLES);
302
614
  if ("message" in r) {
303
615
  sendFailure(res, statusOf(r), r);
304
616
  return;
305
617
  }
306
- sendJson(res, 200, { ...r, next: nextJson([["mason user add <name>", "a user for a tool: its password and URL, printed once"]]) });
618
+ // the last use is a scan of the request log (~0.15 s on a week of 50 statements a second): only when asked
619
+ // (?lastUsed=1: mason user list, the Users page every 30 s), never for the Overview's poll, which does not show it
620
+ const used = url.searchParams.get("lastUsed") === "1" ? await withLastUsed(r.users, ctx.duck.requests, ctx.log)
621
+ : { users: r.users, lastUsedNote: LAST_USED_NOT_ASKED, requestLogDays: ctx.duck.requests.days };
622
+ sendJson(res, 200, { ...used, urlNote: null, next: nextJson([["mason user add <name>", "a user for a tool: its password and URL, printed once"]]) });
307
623
  }
308
624
  else if (req.method === "POST" && url.pathname === "/api/users") {
309
625
  const body = JSON.parse(await readBody(req, 64 * 1024));
310
626
  if (typeof body.name !== "string")
311
627
  throw new Refusal(400, "send { name }: the user's name");
628
+ if (body.role !== undefined && typeof body.role !== "string")
629
+ throw new Refusal(400, "send { name, role }: the role's name, a string");
630
+ if (body.set !== undefined && !(Array.isArray(body.set) && body.set.every((x) => typeof x === "string")))
631
+ throw new Refusal(400, "send { name, role, set }: set is a list of \"attribute=value\" strings, as mason user add --set takes them");
632
+ // the role must be one the roles file names NOW (src/cli/user.ts roleToAdd): refused before anything is written
633
+ const roleProblem = body.role === undefined ? null : roleToAdd(body.role, ctx.roles?.() ?? NO_ROLES);
634
+ if (roleProblem !== null) {
635
+ sendFailure(res, statusOf(roleProblem), roleProblem);
636
+ return;
637
+ }
638
+ // its attributes, held to the role as mason user add holds them (src/cli/user.ts attributesToAdd): one grammar
639
+ const rolesNow = ctx.roles?.() ?? NO_ROLES;
640
+ const attributes = attributesToAdd(body.name, typeof body.role === "string" ? body.role : userRole({}, rolesNow).role, body.set ?? [], rolesNow);
641
+ if ("refused" in attributes) {
642
+ sendFailure(res, statusOf(attributes.refused), attributes.refused);
643
+ return;
644
+ }
312
645
  // mason user add --json's shape: its password, once, and the URL to paste (this run is up, so it has one)
313
- const r = addUser(ctx.users.file, ctx.users.apiKey, body.name, { link: postgresLink(ctx.users.postgresUrl), database: ctx.users.database });
646
+ const r = addUser(ctx.users.file, ctx.users.apiKey, body.name, { link: postgresLink(ctx.users.postgresUrl), database: ctx.users.database, roles: ctx.roles?.() ?? NO_ROLES,
647
+ ...(typeof body.role === "string" ? { role: body.role } : {}), attributes: attributes.values });
314
648
  if ("message" in r) {
315
649
  sendFailure(res, statusOf(r), r);
316
650
  return;
@@ -319,6 +653,37 @@ export function createRunServer(ctx) {
319
653
  ctx.log(`user ${added.user} added${replaced ? " (in place of one the API key's rotation had removed)" : ""}`);
320
654
  sendJson(res, 200, { ...added, next: nextJson(addedNext(added.user, true)) });
321
655
  }
656
+ else if (req.method === "POST" && /^\/api\/users\/[^/]+\/(disable|enable)$/.test(url.pathname)) {
657
+ // mason user disable|enable --json's shape, through its own functions: kept, its password unchanged
658
+ const [, , , encoded, verb] = url.pathname.split("/");
659
+ let name;
660
+ try {
661
+ name = decodeURIComponent(encoded);
662
+ }
663
+ catch {
664
+ throw new Refusal(400, `${url.pathname} does not name a user: POST /api/users/<name>/${verb}`);
665
+ }
666
+ if (verb === "disable") {
667
+ const r = disableUser(ctx.users.file, name);
668
+ if ("message" in r) {
669
+ sendFailure(res, statusOf(r), r);
670
+ return;
671
+ }
672
+ if (!r.already)
673
+ ctx.log(`user ${r.disabled.name} disabled`);
674
+ sendJson(res, 200, { ...r, next: nextJson(disabledNext(r.disabled.name)) });
675
+ }
676
+ else {
677
+ const r = enableUser(ctx.users.file, name);
678
+ if ("message" in r) {
679
+ sendFailure(res, statusOf(r), r);
680
+ return;
681
+ }
682
+ if (!r.already)
683
+ ctx.log(`user ${r.enabled.name} enabled`);
684
+ sendJson(res, 200, { ...r, next: nextJson(enabledNext(r.enabled.name)) });
685
+ }
686
+ }
322
687
  else if (req.method === "DELETE" && url.pathname.startsWith("/api/users/")) {
323
688
  let name;
324
689
  try {
@@ -354,7 +719,7 @@ export function createRunServer(ctx) {
354
719
  const body = JSON.parse(await readBody(req, 64 * 1024));
355
720
  if (!Array.isArray(body.tables) || !body.tables.length || !body.tables.every((m) => typeof m === "string"))
356
721
  throw new Refusal(400, "send { tables: [name, …] }");
357
- const r = await removeTables(ctx, body.tables);
722
+ const r = removeTables(ctx, body.tables);
358
723
  // the sync stops copying a removed table, and drops its copy
359
724
  ctx.sourcesChanged();
360
725
  for (const t of r.removed)