@mason-data/cli 0.7.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (155) hide show
  1. package/README.md +101 -73
  2. package/dist/src/api/error.js +3 -0
  3. package/dist/src/api/postgres/catalog-worker.js +49 -2
  4. package/dist/src/api/postgres/catalog.js +368 -25
  5. package/dist/src/api/postgres/encode.js +3 -1
  6. package/dist/src/api/postgres/explain.js +3 -2
  7. package/dist/src/api/postgres/listener.js +11 -3
  8. package/dist/src/api/postgres/protocol.js +39 -23
  9. package/dist/src/api/postgres/proxy.js +283 -0
  10. package/dist/src/api/postgres/schema.js +6 -0
  11. package/dist/src/api/postgres/server.js +1617 -245
  12. package/dist/src/api/postgres/translate.js +127 -10
  13. package/dist/src/api/request-log.js +18 -5
  14. package/dist/src/cdc/poll.js +5 -5
  15. package/dist/src/cli/background.js +47 -25
  16. package/dist/src/cli/bridge-stdin.js +101 -0
  17. package/dist/src/cli/cancel.js +75 -0
  18. package/dist/src/cli/command.js +170 -11
  19. package/dist/src/cli/commands.js +804 -209
  20. package/dist/src/cli/control.js +59 -0
  21. package/dist/src/cli/credentials.js +182 -0
  22. package/dist/src/cli/demo.js +20 -11
  23. package/dist/src/cli/deploy.js +574 -0
  24. package/dist/src/cli/dev.js +491 -130
  25. package/dist/src/cli/doctor.js +131 -38
  26. package/dist/src/cli/generated-files.js +143 -0
  27. package/dist/src/cli/guides.js +175 -36
  28. package/dist/src/cli/hosted.js +850 -0
  29. package/dist/src/cli/init.js +162 -65
  30. package/dist/src/cli/login.js +289 -0
  31. package/dist/src/cli/machine.js +13 -12
  32. package/dist/src/cli/main.js +11 -5
  33. package/dist/src/cli/mason.cjs +17 -0
  34. package/dist/src/cli/mcp.js +31 -22
  35. package/dist/src/cli/metrics.js +79 -0
  36. package/dist/src/cli/output-gate.js +176 -0
  37. package/dist/src/cli/page-share.js +96 -0
  38. package/dist/src/cli/pg-tls.js +14 -4
  39. package/dist/src/cli/pg-url.js +4 -0
  40. package/dist/src/cli/pin.js +19 -14
  41. package/dist/src/cli/pool-size.cjs +85 -0
  42. package/dist/src/cli/ports.js +2 -0
  43. package/dist/src/cli/preflight.js +138 -46
  44. package/dist/src/cli/project.js +142 -4
  45. package/dist/src/cli/readiness.js +76 -6
  46. package/dist/src/cli/registry.js +65 -38
  47. package/dist/src/cli/replica-hosted.js +665 -0
  48. package/dist/src/cli/replica.js +76 -0
  49. package/dist/src/cli/replication-checks.js +7 -5
  50. package/dist/src/cli/replication-mode.js +105 -26
  51. package/dist/src/cli/request-filter.js +134 -0
  52. package/dist/src/cli/roles.js +585 -0
  53. package/dist/src/cli/run-server.js +637 -48
  54. package/dist/src/cli/runs.js +1 -1
  55. package/dist/src/cli/security.js +67 -18
  56. package/dist/src/cli/serve.js +204 -46
  57. package/dist/src/cli/share-run.js +329 -0
  58. package/dist/src/cli/share.js +286 -0
  59. package/dist/src/cli/source-edit.js +75 -17
  60. package/dist/src/cli/source-role.js +344 -0
  61. package/dist/src/cli/sources.js +306 -47
  62. package/dist/src/cli/style.js +2 -1
  63. package/dist/src/cli/supervise.js +27 -24
  64. package/dist/src/cli/sync.js +55 -25
  65. package/dist/src/cli/token-hosted.js +337 -0
  66. package/dist/src/cli/tunnel.js +200 -0
  67. package/dist/src/cli/ui-ssh.js +460 -0
  68. package/dist/src/cli/ui.js +124 -17
  69. package/dist/src/cli/usage.js +128 -0
  70. package/dist/src/cli/user-hosted.js +299 -0
  71. package/dist/src/cli/user.js +573 -65
  72. package/dist/src/cli/version.js +9 -7
  73. package/dist/src/demo/seed.js +29 -0
  74. package/dist/src/duck/analyze.js +36 -20
  75. package/dist/src/duck/cancel.js +10 -1
  76. package/dist/src/duck/collation.js +266 -0
  77. package/dist/src/duck/constant-key.js +94 -0
  78. package/dist/src/duck/distinct-key.js +283 -26
  79. package/dist/src/duck/events.js +165 -0
  80. package/dist/src/duck/executor-worker.js +20 -11
  81. package/dist/src/duck/executor.js +47 -33
  82. package/dist/src/duck/explain.js +60 -46
  83. package/dist/src/duck/extensions.js +160 -0
  84. package/dist/src/duck/functions.js +461 -63
  85. package/dist/src/duck/gaps.js +48 -21
  86. package/dist/src/duck/generated-plan.js +1070 -0
  87. package/dist/src/duck/generated.js +906 -0
  88. package/dist/src/duck/grants.js +176 -0
  89. package/dist/src/duck/held-types.js +64 -0
  90. package/dist/src/duck/history.js +127 -0
  91. package/dist/src/duck/jsonb-order.js +9 -12
  92. package/dist/src/duck/kept-route.js +6 -5
  93. package/dist/src/duck/kept.js +15 -13
  94. package/dist/src/duck/late.js +1469 -40
  95. package/dist/src/duck/libc-bound.js +341 -0
  96. package/dist/src/duck/libc-order.js +892 -126
  97. package/dist/src/duck/pg-eval-worker.js +7 -1
  98. package/dist/src/duck/pg-eval.js +6 -4
  99. package/dist/src/duck/pgtree.js +774 -23
  100. package/dist/src/duck/plan.js +369 -47
  101. package/dist/src/duck/publication.js +20 -6
  102. package/dist/src/duck/requests.js +269 -93
  103. package/dist/src/duck/resolved.js +117 -2
  104. package/dist/src/duck/rewrite.js +4584 -580
  105. package/dist/src/duck/row-filter.js +655 -0
  106. package/dist/src/duck/statement.js +19 -0
  107. package/dist/src/duck/store.js +439 -177
  108. package/dist/src/duck/stream.js +19 -10
  109. package/dist/src/duck/sync.js +1411 -368
  110. package/dist/src/duck/token.js +4 -1
  111. package/dist/src/duck/types.js +69 -12
  112. package/dist/src/duck/usage.js +326 -0
  113. package/dist/src/duck/users.js +69 -15
  114. package/dist/src/duck/verified.js +161 -1
  115. package/dist/src/duck/views.js +5 -5
  116. package/dist/src/lib/database-name.js +62 -0
  117. package/dist/src/lib/database-url.js +55 -10
  118. package/dist/src/lib/dotenv.js +17 -4
  119. package/dist/src/lib/instant.js +50 -0
  120. package/dist/src/lib/log-buffer.js +77 -0
  121. package/dist/src/lib/log-read.js +196 -0
  122. package/dist/src/lib/pg-numeric.js +390 -49
  123. package/dist/src/lib/project-version.js +23 -23
  124. package/dist/src/lib/secret-mask.js +142 -0
  125. package/dist/src/lib/table-sync.js +282 -0
  126. package/dist/src/web/app.css +706 -66
  127. package/dist/src/web/app.js +331 -409
  128. package/dist/src/web/charts.js +229 -0
  129. package/dist/src/web/index.html +82 -18
  130. package/dist/src/web/pages/queries.js +192 -0
  131. package/dist/src/web/pages/status.js +611 -0
  132. package/dist/src/web/pages/syncs.js +155 -0
  133. package/dist/src/web/pages/tables.js +439 -0
  134. package/dist/src/web/pages/usage.js +397 -0
  135. package/dist/src/web/pages/users.js +219 -0
  136. package/dist/src/web/shared.js +584 -0
  137. package/dist/src/web/vendor/README.md +25 -0
  138. package/dist/src/web/vendor/geist-1.7.2/Geist-Variable.woff2 +0 -0
  139. package/dist/src/web/vendor/geist-1.7.2/GeistMono-Variable.woff2 +0 -0
  140. package/dist/src/web/vendor/geist-1.7.2/LICENSE.txt +92 -0
  141. package/dist/src/web/vendor/lucide-1.56.0/LICENSE.txt +43 -0
  142. package/dist/src/web/vendor/lucide-1.56.0/chart-column.mjs +29 -0
  143. package/dist/src/web/vendor/lucide-1.56.0/layout-dashboard.mjs +29 -0
  144. package/dist/src/web/vendor/lucide-1.56.0/logs.mjs +34 -0
  145. package/dist/src/web/vendor/lucide-1.56.0/menu.mjs +28 -0
  146. package/dist/src/web/vendor/lucide-1.56.0/square-terminal.mjs +28 -0
  147. package/dist/src/web/vendor/lucide-1.56.0/table.mjs +29 -0
  148. package/dist/src/web/vendor/lucide-1.56.0/users.mjs +29 -0
  149. package/dist/src/web/vendor/lucide-1.56.0/x.mjs +27 -0
  150. package/dist/src/web/vendor/uplot-1.6.32/LICENSE.txt +21 -0
  151. package/dist/src/web/vendor/uplot-1.6.32/uPlot.esm.js +6140 -0
  152. package/dist/src/web/vendor/uplot-1.6.32/uPlot.min.css +1 -0
  153. package/dist/src/web/vendor/zilla-slab-5.3.0/LICENSE.txt +93 -0
  154. package/dist/src/web/vendor/zilla-slab-5.3.0/zilla-slab-latin-700-normal.woff2 +0 -0
  155. package/package.json +12 -4
@@ -12,10 +12,14 @@
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
18
- * shape (message, why, fix, next), so the page says it as the command would. `mason serve`'s run serves no page.
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
+ * shape (message, why, fix, next), so the page says it as the command would. Under `mason serve` it serves the same page,
19
+ * on loopback alone, to a browser on another machine through `mason ui --ssh` (src/cli/ui-ssh.ts, `openTunnel` below).
20
+ *
21
+ * LOOPBACK BY CONSTRUCTION: `listenOnLoopback` is how this server listens, and it takes no address. No setting names
22
+ * one, and a container's page is reached from inside the container, never published (docs/WEB-UI.md).
19
23
  */
20
24
  import * as fs from "node:fs";
21
25
  import * as http from "node:http";
@@ -23,32 +27,49 @@ import * as path from "node:path";
23
27
  import { fileURLToPath } from "node:url";
24
28
  import { readBody, sendJson } from "../lib/http-json.js";
25
29
  import { projectFingerprint } from "../lib/project-files.js";
26
- import { guard, SECURITY_HEADERS } from "./security.js";
30
+ import { guard, newTunnel, SECURITY_HEADERS } from "./security.js";
27
31
  import { maskedUrl } from "./preflight.js";
28
32
  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 { catalogTable, shownPath } from "./sources.js";
33
+ import { phaseCounts, readiness, syncTotals, tableSync } from "./readiness.js";
34
+ import { restartsFact } from "./supervise.js";
35
+ import { withRestarts } from "../duck/events.js";
36
+ import { NO_ROLES } from "../duck/grants.js";
37
+ import { addedNext, addUser, attributesToAdd, disabledNext, disableUser, enabledNext, enableUser, listUsers, removeUser, REMOVED_NEXT, roleToAdd, withLastUsed } from "./user.js";
38
+ import { userRole } from "./roles.js";
39
+ import { postgresFacts, postgresLink } from "./pg-url.js";
40
+ import { catalogTable, parseSourceText, shownPath } from "./sources.js";
41
+ import { projectWriteRefusal } from "./project.js";
33
42
  import { addEntry, Refusal, removeEntries, sourceToEdit } from "./source-edit.js";
34
43
  import { PollRefused } from "../duck/sync.js";
35
- /** A statement answered in this many milliseconds or more is slow: what mason status lists (src/duck/requests.ts slow). */
36
- export const SLOW_MS = 1000;
44
+ import { DEFAULT_SYNC, parseSync, syncText } from "../lib/table-sync.js";
45
+ import { eventLimit, requestFilter, requestFilterText, SLOW_MS, timeOf } from "./request-filter.js";
46
+ import { tunnelReport } from "./share-run.js";
47
+ import { FRESH_SAMPLES } from "./metrics.js";
48
+ /** A statement answered in this many milliseconds or more is slow (one constant: src/cli/request-filter.ts SLOW_MS, the
49
+ * request log's `slow` filter's too): what mason status lists (src/duck/requests.ts slow). */
50
+ export { SLOW_MS };
37
51
  /**
38
52
  * The run's state, in the shape mason status / wait read (`RunState`, src/cli/commands.ts): each table of the database,
39
53
  * 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
40
- * copied.
54
+ * copied. LEAN unless `columns` is asked: each table's columns are most of its bytes (800 tables of 50 columns, 3.4 MB
55
+ * with them, 0.3 MB without), and the page polls the state every 2 s for none of them — its Columns… form reads one
56
+ * table's on opening (`GET /api/tables/<table>/columns`, `tableColumns`); `mason status --json` asks with them
57
+ * (`?columns=1`), its shape unchanged. One computation either way: the columns are left out, never built apart.
41
58
  */
42
- export function runState(ctx, copies, stream) {
59
+ export function runState(ctx, copies, stream, now = Date.now(), { columns = false } = {}) {
43
60
  const folder = ctx.folder();
44
61
  const sources = ctx.sources();
45
62
  const by = new Map(copies.map((c) => [c.source, c]));
46
63
  const project = folder && fs.existsSync(folder) ? projectFingerprint(folder) : null;
47
64
  const facts = ctx.facts();
65
+ const repl = ctx.replication();
66
+ const bytes = fileBytes(ctx.duck.file);
67
+ const disk = ctx.duck.disk();
48
68
  const tables = facts.tables.map((t) => {
49
- const c = by.get(t.source);
69
+ const c = by.get(t.source), entry = sources.file?.tables.find((e) => e.table === t.source);
70
+ const { columns: cols, ...catalog } = catalogTable(t, entry);
50
71
  return {
51
- ...catalogTable(t, sources.file?.tables.find((e) => e.table === t.source)),
72
+ ...catalog, ...(columns ? { columns: cols } : {}),
52
73
  // a table the sync has not reached is waiting for its copy (no copy yet), never "copying 0"
53
74
  // a copy in a retired layout (src/duck/sync.ts RETIRED_LAYOUT) serves until it is copied again: done, and said
54
75
  copy: c && c.phase !== "waiting" ? { phase: (c.phase === "copied" ? "done" : c.phase === "failed" ? "failed" : "copying"),
@@ -57,33 +78,90 @@ export function runState(ctx, copies, stream) {
57
78
  // copyProgressNow), so mason wait and status move between two commits
58
79
  ...(c.phase === "copying" && c.progress !== undefined ? { progress: c.progress } : {}),
59
80
  ...(c.phase === "copied" && c.retired !== undefined ? { retired: c.retired } : {}) } : null,
60
- error: c?.phase === "failed" ? c.error : null,
61
- // copied and served, but not kept current by the poll (src/duck/sync.ts POLL_ROW_LIMIT): mason status and wait say so
62
- ...(c?.unsynced ? { unsynced: c.unsynced } : {}),
63
- // how the run keeps the copy current NOW, as the sync derives it once (src/duck/sync.ts KeptCurrent, `keptOf`):
64
- // what `unsynced` and the stream's tables say for mason status, and EXPLAIN says over the wire, as data
81
+ // its sync's error, whatever its phase: a copied table whose poll, catch-up, stream or publication check failed
82
+ // carries it on its own row, as its sync's state says failed (issue 100)
83
+ error: c?.error ?? null,
84
+ // how the run keeps the copy current NOW, as the sync derives it once (src/duck/sync.ts KeptCurrent, `keptOf`): its
85
+ // sync and when it was last synced, what mason status and the stream's tables say, and EXPLAIN says over the wire
65
86
  ...(c?.kept ? { kept: c.kept } : {}),
66
87
  ...(c ? { duck: { rows: c.rows, bytes: c.bytes, seconds: c.seconds, copiedAt: c.copiedAt, error: c.error } } : {}),
88
+ // an added table's sync: synced, syncing, failed or waiting (src/cli/readiness.ts syncStateOf), when it was last
89
+ // brought current, how long that took and by what, and how old that is now
90
+ ...(c !== undefined || entry !== undefined ? { sync: tableSync(c, now) } : {}),
67
91
  };
68
92
  });
69
93
  return {
70
94
  // what mason wait waits on and mason status prints last (src/cli/readiness.ts): said by the run, so the page and the
71
95
  // commands say one thing
72
96
  readiness: readiness({ tables }),
73
- // the run serves the web page (mason ui): mason status prints its URL
97
+ // the run serves the web page (mason ui): mason status prints its URL, or on a server how it is opened
74
98
  ui: ctx.page,
99
+ // mason serve's run: the page's words for a tunnel that closed, and for the Postgres URL's host, follow from it
100
+ served: ctx.served === true,
101
+ // why the page offers no Add or Remove, or null: the project's files deployed read-only (MASON_PROJECT_READONLY), or
102
+ // a server's project folder mounted read-only — a control that can only fail is not offered
103
+ sourcesReadOnly: sourcesReadOnly(ctx, sources.file?.file ?? null),
75
104
  source: { url: maskedUrl(ctx.source.url), host: ctx.source.host, database: ctx.source.database, remote: ctx.source.remote, demo: ctx.demo === true,
76
- version: facts.versionText, standby: facts.standby, changes: ctx.changes, name: sources.file?.name ?? null, file: sources.file?.file ?? null,
77
- ...(ctx.publication ? { replication: { slot: ctx.slot, publication: ctx.publication.name, owned: ctx.publication.owned } } : {}) },
105
+ // `replication` while a table's sync is replication (the run streams), else `poll`: each table's own is its kept
106
+ version: facts.versionText, standby: facts.standby, changes: repl !== null ? "replication" : "poll", name: sources.file?.name ?? null, file: sources.file?.file ?? null,
107
+ shownFile: sources.file ? shownPath(ctx.root, sources.file.file) : null,
108
+ ...(ctx.textOrder ? { textOrder: ctx.textOrder } : {}),
109
+ // every table's sync unless its entry says its own: the file's top `sync:`, or the default when it writes none
110
+ sync: { default: syncText(sources.file?.sync ?? DEFAULT_SYNC), written: sources.file?.sync != null },
111
+ ...(repl !== null ? { replication: { slot: repl.slot, publication: repl.publication, owned: repl.owned } } : {}) },
78
112
  // a sources file edited into a mistake while the run is up: the run copies what it read last, and says so
79
- warnings: [...ctx.warnings, ...(sources.problem ? [`${sources.problem} (Mason copies the tables it read before; mason start reads the file again)`] : [])],
113
+ warnings: [...ctx.warnings, ...(sources.problem ? [`${sources.problem} (Mason syncs the tables it read before; mason start reads the file again)`] : [])],
80
114
  folder, project, tables,
81
- engine: { name: "duckdb", file: ctx.duck.file, bytes: fileBytes(ctx.duck.file), pollSeconds: ctx.duck.pollSeconds },
115
+ // where a tool connects (the Postgres API's URL, the credentials' place shown, and its psql line): the web page's
116
+ // Connect panel, as mason status --json says it (src/cli/pg-url.ts postgresFacts)
117
+ postgres: postgresFacts(ctx.users.postgresUrl),
118
+ // the copies in all: tables added, copied, failed, their rows, the latest sync (the file's size is engine.bytes)
119
+ totals: syncTotals(tables.filter((t) => t.added).length, copies),
120
+ // the run itself: its version, since when it is up, and its supervisor's restarts (ONE reader, restartsFact, which
121
+ // mason status reads with Mason stopped too)
122
+ // keptDays: how long the request log, the events and the usage samples are kept (MASON_HISTORY_DAYS): the page
123
+ // offers a time range past the week only when it is kept
124
+ engine: { file: ctx.duck.file, bytes, keptDays: ctx.duck.requests.days,
125
+ version: ctx.version, startedAt: new Date(ctx.startedAt).toISOString(), uptimeSeconds: Math.max(0, Math.round((now - ctx.startedAt) / 1000)),
126
+ restarts: restartsFact(ctx.home, ctx.duck.restarts) },
127
+ // the disk under the copy: free where its file is (the sync's own measure, the one a copy is held to), the file
128
+ // (engine.bytes), and what the first syncs running are projected to add (copyDiskProjection) — mason status says it
129
+ // the sessions logged in now, by user and client (the listener's own, in memory): mason status says how many
130
+ sessions: ctx.listener.openSessions(),
131
+ disk: { path: disk.path, freeBytes: disk.freeBytes, copyBytes: bytes, projectedBytes: disk.projectedBytes, ...(disk.error !== undefined ? { error: disk.error } : {}) },
82
132
  jsonPaths: ctx.duck.jsonPaths?.() ?? [],
133
+ generatedTables: ctx.duck.generated?.() ?? [],
134
+ // the roles file as the listener reads it now: its roles by name, and what is wrong with it (every statement of a
135
+ // session with a role is refused while there is)
136
+ ...(ctx.roles ? { roles: rolesState(ctx.roles()) } : {}),
137
+ // the share open now (mason share): where, who, and what was refused there; nothing in it logs anyone in
138
+ share: ctx.shares?.state() ?? null,
139
+ // the last share that ended and why: a page that held it says so (null before any, and on a server)
140
+ shareEnded: ctx.shares?.ended ?? null,
83
141
  // replication mode: where the stream is, its lag and what it applied (mason status and wait render it, streamLines)
84
142
  ...(stream !== null ? { stream } : {}),
85
143
  };
86
144
  }
145
+ /**
146
+ * Why the run could not write its sources file now, or null — ONE fact for the page and `mason status --json`, computed
147
+ * here: first the project's own word (src/cli/project.ts `projectWriteRefusal`, the authority every edit asks, so the page
148
+ * offers no Add or Remove exactly when `/api/pick` and `/api/remove` would refuse them); then, on a server, a file it
149
+ * cannot write (a folder mounted read-only). `mason start`'s file is the developer's, and never asked of the disk.
150
+ */
151
+ function sourcesReadOnly(ctx, file) {
152
+ const readOnly = projectWriteRefusal(ctx.root, process.env);
153
+ if (readOnly !== null)
154
+ return `${readOnly.message}; ${readOnly.fix}`;
155
+ if (ctx.served !== true || file === null)
156
+ return null;
157
+ try {
158
+ fs.accessSync(file, fs.constants.W_OK);
159
+ return null;
160
+ }
161
+ catch (e) {
162
+ return `the sources file cannot be written on this server (${e.code ?? e.message}): commit a change to it, and deploy`;
163
+ }
164
+ }
87
165
  /** a file's size, or null where it cannot be read (the run's own DuckDB file: it exists while the run does) */
88
166
  function fileBytes(file) {
89
167
  try {
@@ -93,27 +171,171 @@ function fileBytes(file) {
93
171
  return null;
94
172
  } // a size is a nicety of mason status: unknown, not an error
95
173
  }
96
- async function state(ctx) {
97
- return runState(ctx, ctx.duck.copies(), await ctx.duck.stream());
174
+ /** `RunState.roles` (src/cli/commands.ts): the file, the roles it names and what is wrong with it now */
175
+ export const rolesState = (r) => {
176
+ const attributes = Object.fromEntries([...r.grants.values()].filter((g) => g.attributes !== undefined).map((g) => [g.name, Object.fromEntries(g.attributes)]));
177
+ const wheres = Object.fromEntries([...r.grants.values()].filter((g) => g.filters !== undefined).map((g) => [g.name,
178
+ [...g.filters.values()].flatMap((f) => f.where === undefined ? [] : [{ table: f.table, at: f.at, where: f.where.text }])]));
179
+ 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),
180
+ ...(Object.keys(attributes).length > 0 ? { attributes } : {}), ...(Object.keys(wheres).length > 0 ? { wheres } : {}) };
181
+ };
182
+ async function state(ctx, columns = false) {
183
+ return runState(ctx, ctx.duck.copies(), await ctx.duck.stream(), Date.now(), { columns });
184
+ }
185
+ /**
186
+ * `GET /api/tables/<table>/columns`: one table's columns as the state says them with `?columns=1` (sources.ts
187
+ * catalogTable: name, type, nullable, comment, references) — what the page's Columns… form ticks, read when it opens.
188
+ * A table the catalog lacks (dropped since the run read it) is a 404 in the failure shape.
189
+ */
190
+ export function tableColumns(ctx, table) {
191
+ const t = ctx.facts().tables.find((x) => x.source === table);
192
+ if (t === undefined) {
193
+ const message = `the database has no table ${JSON.stringify(table)} this user can read`;
194
+ 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"]] });
195
+ }
196
+ return { table: t.source, columns: catalogTable(t, undefined).columns };
197
+ }
198
+ /**
199
+ * WHAT A USAGE SAMPLE READS OF THE RUN (src/duck/usage.ts): `/api/state` at that moment, never a second computation of
200
+ * any of it — the copy file's bytes (`engine.bytes`), `totals`, the stalest added table's `sync.ageSeconds`, the
201
+ * 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
202
+ * the copies by phase (`phaseCounts`, the count `/healthz` says) for `GET /metrics`.
203
+ */
204
+ export async function usageFacts(ctx, now = Date.now(), { refresh = true } = {}) {
205
+ const copies = ctx.duck.copies();
206
+ const s = runState(ctx, copies, await ctx.duck.stream({ refresh }), now);
207
+ const ages = s.tables.flatMap((t) => (t.added && t.sync?.ageSeconds != null ? [t.sync.ageSeconds] : []));
208
+ const slot = s.stream?.slotWal;
209
+ return { copyBytes: s.engine.bytes, totals: s.totals, oldestSyncSeconds: ages.length ? Math.max(...ages) : null, streamLag: s.stream?.lag ?? null,
210
+ copied: copies.filter((c) => c.phase === "copied").map((c) => c.source), slotWalHeldBytes: slot !== undefined && "walHeldBytes" in slot ? slot.walHeldBytes : null,
211
+ diskFreeBytes: s.disk.freeBytes, sessionsOpen: s.sessions.open, tables: phaseCounts(copies),
212
+ streamLagReadAt: s.stream !== undefined && "lsn" in s.stream.database ? s.stream.database.at : null,
213
+ slotWalReadAt: slot !== undefined && "walHeldBytes" in slot ? slot.at : null };
214
+ }
215
+ /**
216
+ * WHAT `GET /metrics` READS OF THE RUN (src/cli/metrics.ts, `mason serve`'s health port): a usage sample's own facts
217
+ * (`usageFacts`, above: no number computed a second way), the statements since the process started as the sampler counts
218
+ * them as they are recorded (`statementTotals`), and the last sample's p50 / p95 (`lastDurations`).
219
+ *
220
+ * A SCRAPE NEVER REACHES THE SOURCE (the review of PR 50: the endpoint takes no credential, so its rate is anyone's):
221
+ * the stream's position and the slot are read as cached (`refresh: false`), never asked of the database; the sampler
222
+ * keeps them fresh once a minute, and a number read longer ago than `FRESH_SAMPLES` samples is said as unknown
223
+ * (`freshSeconds`, src/cli/metrics.ts).
224
+ */
225
+ export async function metricsFacts(ctx, now = Date.now()) {
226
+ return { usage: await usageFacts(ctx, now, { refresh: false }), statements: ctx.duck.usage.statementTotals(), durations: ctx.duck.usage.lastDurations(),
227
+ at: now, freshSeconds: FRESH_SAMPLES * ctx.duck.usage.sampleSeconds };
228
+ }
229
+ /** A `since` / `until` of a query string, read by the one time grammar (src/cli/request-filter.ts timeOf), as an ISO time. */
230
+ function timeParam(key, text, now) {
231
+ const t = timeOf(key, text, now);
232
+ if (typeof t !== "string")
233
+ throw new Refusal(400, t.message, t);
234
+ return t;
235
+ }
236
+ /** why `lastUsedAt` is null in a `GET /api/users` that did not ask for it */
237
+ const LAST_USED_NOT_ASKED = "not asked: GET /api/users?lastUsed=1 reads it from the request log";
238
+ const DAY_S = 86_400;
239
+ /**
240
+ * `GET /api/usage?since=&until=&step=`: the samples from `since` (default an hour ago) until `until` (default
241
+ * now), oldest first; with `step` (seconds, at least a sample's interval) re-bucketed in one query, each count summed and
242
+ * each level at its highest — but the p50 and p95 of a point of more than a minute are its statements', read from the
243
+ * 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
244
+ * kept. `mason usage --json` is this.
245
+ */
246
+ export async function usage(ctx, q, now = Date.now()) {
247
+ const time = (k, fallback) => {
248
+ const text = q.get(k);
249
+ if (text === null)
250
+ return new Date(fallback).toISOString();
251
+ return timeParam(k, text, now);
252
+ };
253
+ const since = time("since", now - 3_600_000), until = time("until", now);
254
+ if (since >= until)
255
+ throw new Refusal(400, `since (${since}) must be before until (${until})`);
256
+ const u = ctx.duck.usage;
257
+ const stepText = q.get("step");
258
+ const step = stepText === null ? null : Number(stepText);
259
+ if (step !== null && (!Number.isInteger(step) || step < u.sampleSeconds || step > u.days * DAY_S)) {
260
+ 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)}`);
261
+ }
262
+ const read = await u.samples({ since, until, ...(step === null ? {} : { stepSeconds: step }) });
263
+ // a point of more than a minute: its p50 and p95 are its statements', from the request log (`durationsBy`), never
264
+ // the highest of its minutes' (a sample keeps its minute's alone)
265
+ const durations = step !== null && step > u.sampleSeconds ? await ctx.duck.requests.durationsBy(since, until, step) : null;
266
+ const samples = durations === null ? read : read.map((s) => {
267
+ const d = durations.get(Date.parse(s.at));
268
+ return { ...s, durationP50Ms: d?.p50 ?? null, durationP95Ms: d?.p95 ?? null };
269
+ });
270
+ return { since, until, stepSeconds: step, sampleSeconds: u.sampleSeconds, keptDays: u.days, samples, copySizes: await u.copySizes() };
271
+ }
272
+ /**
273
+ * `GET /api/activity?since=`: what the Postgres API answered from `since` on (default an hour ago; the one time grammar,
274
+ * src/cli/request-filter.ts `timeOf`), for the Usage page's time range — the window's statements (src/duck/requests.ts
275
+ * `window`), who sent them (`clients`: a row per user and client, the 50 with the most statements; `clientsTotal`:
276
+ * how many clients and sessions in all) and the slowest that completed (`slowest`); and the
277
+ * days the request log keeps. Read once per range chosen, every 5 s while the range is Now.
278
+ */
279
+ export async function activity(ctx, q, now = Date.now()) {
280
+ const sinceText = q.get("since");
281
+ const since = sinceText === null ? new Date(now - 3_600_000).toISOString() : timeParam("since", sinceText, now);
282
+ const r = ctx.duck.requests;
283
+ const [window, who, slowest] = await Promise.all([r.window(since, now), r.clients(since), r.slowest(since)]);
284
+ return { since, window, clients: who.clients, clientsTotal: who.total, slowest, keptDays: r.days };
285
+ }
286
+ /** how many registry entries `/api/unsupported` answers unless `limit` says, and at most */
287
+ export const DEFAULT_UNSUPPORTED_LIMIT = 10;
288
+ export const MAX_UNSUPPORTED_LIMIT = 100;
289
+ /**
290
+ * `GET /api/unsupported?since=&limit=`: the statements refused from `since` on (default a day ago; read by the one time
291
+ * grammar, src/cli/request-filter.ts `timeOf`) grouped by the registry entry that refused them (src/duck/requests.ts
292
+ * `unsupported`), the most refused first, `limit` (1 to 100, default 10) of them; and the days the request log keeps.
293
+ * `mason status --json` `unsupported` is this.
294
+ */
295
+ export async function unsupported(ctx, q, now = Date.now()) {
296
+ const sinceText = q.get("since");
297
+ const since = sinceText === null ? new Date(now - DAY_S * 1000).toISOString() : timeParam("since", sinceText, now);
298
+ const limitText = q.get("limit");
299
+ const limit = limitText === null ? DEFAULT_UNSUPPORTED_LIMIT : Number(limitText);
300
+ if (!Number.isInteger(limit) || limit < 1 || limit > MAX_UNSUPPORTED_LIMIT) {
301
+ 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)}`);
302
+ }
303
+ return { since, unsupported: await ctx.duck.requests.unsupported({ since, limit }), keptDays: ctx.duck.requests.days };
304
+ }
305
+ /**
306
+ * `GET /api/events?limit=&since=&failed=1`: the newest `limit` (1 to 10000, default 40) of the sync's events merged with
307
+ * 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
308
+ * they are kept. `mason logs --events --json` is this; `mason status --json` `recentErrors` its latest failures.
309
+ */
310
+ export async function events(ctx, q) {
311
+ const limit = eventLimit(q.get("limit") ?? undefined, "limit");
312
+ if (typeof limit !== "number")
313
+ throw new Refusal(400, limit.message, limit);
314
+ const sinceText = q.get("since");
315
+ const since = sinceText === null ? undefined : timeParam("since", sinceText, Date.now());
316
+ const o = { limit, failed: q.get("failed") === "1", ...(since === undefined ? {} : { since }) };
317
+ const found = await ctx.duck.events.recent(o);
318
+ return { events: withRestarts(found, ctx.duck.restarts(), { ...o, days: ctx.duck.events.days }), keptDays: ctx.duck.events.days };
98
319
  }
99
320
  /** The sources file as it is on disk now (src/cli/source-edit.ts sourceToEdit) — and the database it names NOW must be
100
321
  * the one this run copies: its catalog is what an edit is checked against. */
101
322
  function sourceOnDisk(ctx) {
102
323
  const at = sourceToEdit(ctx.folder(), ctx.root, ctx.readSource);
103
324
  if (at.read.resolved !== ctx.runUrl) {
104
- throw new Refusal(409, `${shownPath(ctx.root, at.file)}: url: now names ${maskedUrl(at.read.resolved)} (${at.read.from}), and this run copies ${maskedUrl(ctx.runUrl)}: ` +
105
- "mason stop && mason start copies from the new one, then add or remove the table");
325
+ 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)}: ` +
326
+ "mason stop && mason start syncs from the new one, then add or remove the table");
106
327
  }
107
328
  return at;
108
329
  }
109
330
  /** The database this run copies, as an edit is checked against it: the catalog it last read, and how it reads changes. */
110
- const catalogOf = (ctx) => ({ tables: ctx.facts().tables, database: ctx.source.database, replication: ctx.changes === "replication" });
331
+ const catalogOf = (ctx) => ({ tables: ctx.facts().tables, database: ctx.source.database,
332
+ ...(ctx.duck.measured ? { measured: ctx.duck.measured } : {}) });
111
333
  /**
112
334
  * ADD A TABLE: the check-and-write every entrance shares (src/cli/source-edit.ts addEntry), against the file on disk and
113
335
  * this run's catalog; the caller tells the run the file changed, and the sync starts the copy.
114
336
  */
115
- export function pick(ctx, table, columns = ["*"], key = null) {
116
- return addEntry(sourceOnDisk(ctx), catalogOf(ctx), table, columns, key);
337
+ export function pick(ctx, table, columns = ["*"], key = null, o = {}, at = sourceOnDisk(ctx)) {
338
+ return addEntry(at, catalogOf(ctx), table, columns, key, o);
117
339
  }
118
340
  /** REMOVE TABLES: the check-and-write every entrance shares (src/cli/source-edit.ts removeEntries); the run then drops their copies. */
119
341
  export function removeTables(ctx, names) {
@@ -129,11 +351,49 @@ const statusOf = (f) => f.code === "usage" ? 400 : f.code === "not_found" ? 404
129
351
  export function sendFailure(res, status, f, extra = {}) {
130
352
  sendJson(res, status, { error: f.message, ...extra, failure: { ...errorJson(f), next: nextJson(f.next) } });
131
353
  }
354
+ /**
355
+ * A VENDORED FILE AT A PINNED VERSION (`/vendor/<name>-<x.y.z>/…`, src/web/vendor/README.md): its bytes are that
356
+ * release's for good — a new version is a new folder — so the browser keeps it a year without asking again
357
+ * (`immutable`): a reload re-reads the page's own modules (no-store, as every answer) but not the fonts, uPlot and
358
+ * the icons — measured with the demo: a reload moved 222 KB where the first load moved 520 KB.
359
+ */
360
+ const PINNED_VENDOR = /^\/vendor\/[a-z][a-z0-9-]*-\d+\.\d+\.\d+\//;
361
+ const IMMUTABLE = "private, max-age=31536000, immutable";
132
362
  /** THE WEB PAGE'S ROUTES, AND THE FILE EACH IS: the only files the server ever reads for a URL. */
133
363
  export const PAGE_ROUTES = {
134
364
  "/": ["index.html", "text/html; charset=utf-8"],
135
365
  "/app.js": ["app.js", "text/javascript; charset=utf-8"],
136
366
  "/app.css": ["app.css", "text/css; charset=utf-8"],
367
+ // the shell's shared helpers and one module per page (src/web/app.js has the page module contract)
368
+ "/shared.js": ["shared.js", "text/javascript; charset=utf-8"],
369
+ "/pages/status.js": ["pages/status.js", "text/javascript; charset=utf-8"],
370
+ "/pages/tables.js": ["pages/tables.js", "text/javascript; charset=utf-8"],
371
+ "/pages/queries.js": ["pages/queries.js", "text/javascript; charset=utf-8"],
372
+ "/pages/users.js": ["pages/users.js", "text/javascript; charset=utf-8"],
373
+ "/pages/usage.js": ["pages/usage.js", "text/javascript; charset=utf-8"],
374
+ "/pages/syncs.js": ["pages/syncs.js", "text/javascript; charset=utf-8"],
375
+ // the charts (src/web/charts.js over uPlot) and what the page vendors whole, pinned, with its licence
376
+ // (src/web/vendor/README.md): served from here, never fetched from elsewhere — a box without internet reads it all
377
+ "/charts.js": ["charts.js", "text/javascript; charset=utf-8"],
378
+ "/vendor/README.md": ["vendor/README.md", "text/plain; charset=utf-8"],
379
+ "/vendor/geist-1.7.2/Geist-Variable.woff2": ["vendor/geist-1.7.2/Geist-Variable.woff2", "font/woff2"],
380
+ "/vendor/geist-1.7.2/GeistMono-Variable.woff2": ["vendor/geist-1.7.2/GeistMono-Variable.woff2", "font/woff2"],
381
+ "/vendor/geist-1.7.2/LICENSE.txt": ["vendor/geist-1.7.2/LICENSE.txt", "text/plain; charset=utf-8"],
382
+ "/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"],
383
+ "/vendor/zilla-slab-5.3.0/LICENSE.txt": ["vendor/zilla-slab-5.3.0/LICENSE.txt", "text/plain; charset=utf-8"],
384
+ "/vendor/uplot-1.6.32/uPlot.esm.js": ["vendor/uplot-1.6.32/uPlot.esm.js", "text/javascript; charset=utf-8"],
385
+ "/vendor/uplot-1.6.32/uPlot.min.css": ["vendor/uplot-1.6.32/uPlot.min.css", "text/css; charset=utf-8"],
386
+ "/vendor/uplot-1.6.32/LICENSE.txt": ["vendor/uplot-1.6.32/LICENSE.txt", "text/plain; charset=utf-8"],
387
+ // the nav's icons (src/web/shared.js ICONS): Lucide's, one ES module per icon, each its SVG as a string
388
+ "/vendor/lucide-1.56.0/chart-column.mjs": ["vendor/lucide-1.56.0/chart-column.mjs", "text/javascript; charset=utf-8"],
389
+ "/vendor/lucide-1.56.0/layout-dashboard.mjs": ["vendor/lucide-1.56.0/layout-dashboard.mjs", "text/javascript; charset=utf-8"],
390
+ "/vendor/lucide-1.56.0/logs.mjs": ["vendor/lucide-1.56.0/logs.mjs", "text/javascript; charset=utf-8"],
391
+ "/vendor/lucide-1.56.0/menu.mjs": ["vendor/lucide-1.56.0/menu.mjs", "text/javascript; charset=utf-8"],
392
+ "/vendor/lucide-1.56.0/square-terminal.mjs": ["vendor/lucide-1.56.0/square-terminal.mjs", "text/javascript; charset=utf-8"],
393
+ "/vendor/lucide-1.56.0/table.mjs": ["vendor/lucide-1.56.0/table.mjs", "text/javascript; charset=utf-8"],
394
+ "/vendor/lucide-1.56.0/users.mjs": ["vendor/lucide-1.56.0/users.mjs", "text/javascript; charset=utf-8"],
395
+ "/vendor/lucide-1.56.0/x.mjs": ["vendor/lucide-1.56.0/x.mjs", "text/javascript; charset=utf-8"],
396
+ "/vendor/lucide-1.56.0/LICENSE.txt": ["vendor/lucide-1.56.0/LICENSE.txt", "text/plain; charset=utf-8"],
137
397
  };
138
398
  /**
139
399
  * The web page's files, READ ONCE when the server starts (src/web/ beside src/cli/, in a checkout and in the package
@@ -150,20 +410,147 @@ export function pageFiles(dir = fileURLToPath(new URL("../web/", import.meta.url
150
410
  catch (e) {
151
411
  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`);
152
412
  }
153
- return [route, { body, type }];
413
+ return [route, { body, type, immutable: PINNED_VENDOR.test(route) }];
154
414
  }));
155
415
  }
416
+ /** How long `/api/queries`' summary is answered from the last read: the Overview asks every 30 s, Usage and Queries
417
+ * too, and `mason status` at each run — each a 7-day scan of the request log (~0.15 s at 30 M rows). */
418
+ export const SUMMARY_TTL_MS = 30_000;
419
+ /**
420
+ * ONE READ SHARED FOR `ms`, by time only (`summaryCache`): every ask within `ms` of a read gets that read — the one in
421
+ * 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
422
+ * summary is up to `ms` old (docs/OBSERVABILITY.md says it).
423
+ */
424
+ export function timedCache(read, ms, clock = Date.now) {
425
+ let kept = null;
426
+ return () => {
427
+ const now = clock();
428
+ if (kept !== null && now - kept.at < ms)
429
+ return kept.value;
430
+ const value = read();
431
+ const mine = { at: now, value };
432
+ kept = mine;
433
+ // a failure is the caller's answer (it awaits `value`); here it only means the read is not kept
434
+ value.catch(() => { if (kept === mine)
435
+ kept = null; });
436
+ return value;
437
+ };
438
+ }
439
+ /**
440
+ * `POST /api/share` { secret, role? }: a share opened, its first line of JSON, and THE ANSWER LEFT OPEN: the connection is
441
+ * the share (src/cli/share-run.ts), so a command gone by any means ends it. Only the password's hash arrives here. Past
442
+ * its first line the answer says one thing, why the run ended it.
443
+ */
444
+ async function openShare(ctx, req, res) {
445
+ if (ctx.shares === undefined) {
446
+ 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"]] });
447
+ return;
448
+ }
449
+ const shares = ctx.shares;
450
+ const body = JSON.parse(await readBody(req, 64 * 1024));
451
+ if (body.from === "page") {
452
+ await openPageShare(ctx, body, res);
453
+ return;
454
+ }
455
+ if (typeof body.secret !== "string" || !/^[0-9a-f]{64}$/.test(body.secret))
456
+ throw new Refusal(400, "send { secret, role? }: the password's SHA-256 in hex");
457
+ if (body.role !== undefined && typeof body.role !== "string")
458
+ throw new Refusal(400, "role is a role's name, or left out");
459
+ let gone = false;
460
+ res.once("close", () => { gone = true; });
461
+ const opened = await shares.open({ secret: body.secret, role: body.role });
462
+ if ("failure" in opened) {
463
+ sendFailure(res, statusOf(opened.failure), opened.failure, opened.choices ? { choices: opened.choices } : {});
464
+ return;
465
+ }
466
+ const end = (why) => { void shares.close(opened.user, why).catch((e) => ctx.log(`closing the share ${opened.user} failed: ${e.message}`)); };
467
+ // the command went while the socket opened
468
+ if (gone) {
469
+ end("mason share went away");
470
+ return;
471
+ }
472
+ res.once("close", () => end("mason share went away"));
473
+ shares.onEnd(opened.user, (why) => { res.end(`${JSON.stringify({ ended: why })}\n`); });
474
+ res.writeHead(200, { "Content-Type": "application/x-ndjson" });
475
+ res.write(`${JSON.stringify(opened)}\n`);
476
+ }
477
+ /** The refusal of a page's share where there is none to open (`mason serve`): the command's own words. */
478
+ const SERVED_SHARE = { 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"]] };
479
+ /**
480
+ * `POST /api/share` { from: "page", confirmed: true, role? }: the web page's Connect from anywhere (src/cli/page-share.ts),
481
+ * answered ONCE with the share and its password, which nothing here keeps; the page's beats hold it from then on.
482
+ * `confirmed` is the person's yes to a public address, which the page asks for: a body without it opens nothing.
483
+ */
484
+ async function openPageShare(ctx, body, res) {
485
+ if (ctx.pageShares === undefined) {
486
+ sendFailure(res, 409, SERVED_SHARE);
487
+ return;
488
+ }
489
+ if (body.confirmed !== true) {
490
+ sendFailure(res, 400, { message: "nothing was shared: a temporary public address is opened only once the person said yes to it", code: "usage", fix: "send { from: \"page\", confirmed: true, role? }: the page's Connect from anywhere asks first" });
491
+ return;
492
+ }
493
+ if (body.role !== undefined && typeof body.role !== "string")
494
+ throw new Refusal(400, "role is a role's name, or left out");
495
+ const r = await ctx.pageShares.open({ role: body.role });
496
+ if ("failure" in r) {
497
+ sendFailure(res, statusOf(r.failure), r.failure, r.choices ? { choices: r.choices } : {});
498
+ return;
499
+ }
500
+ sendJson(res, 200, r);
501
+ }
502
+ /**
503
+ * `POST /api/ui/tunnel` { port }: a tunnel to the page (src/cli/security.ts, src/cli/ui-ssh.ts), its ticket in the first
504
+ * line of JSON and THE ANSWER LEFT OPEN, as a share's is: the connection is the tunnel, so its bridge gone by any means
505
+ * takes the Host, the ticket and the cookie with it. A process holding the run's token asks; a page's cookie cannot.
506
+ */
507
+ async function openTunnel(ctx, tunnels, req, res) {
508
+ if (req.headers.authorization === undefined) {
509
+ sendFailure(res, 403, { message: "a tunnel is asked for with the run's token: mason ui --ssh does", code: "refused" });
510
+ return;
511
+ }
512
+ if (!ctx.page) {
513
+ sendFailure(res, 404, { message: "this Mason serves no web page", code: "not_found" });
514
+ return;
515
+ }
516
+ const body = JSON.parse(await readBody(req, 1024));
517
+ const port = body.port;
518
+ if (typeof port !== "number" || !Number.isInteger(port) || port < 1 || port > 65_535)
519
+ throw new Refusal(400, "send { port }: the local port the browser names");
520
+ // its Host would be this server's own, whose cookie is the run's token: rare, and a new local port is one run away
521
+ if (port === ctx.port) {
522
+ sendFailure(res, 409, { message: `local port ${port} is this server's own port on the server`, code: "refused", fix: "run it again: another local port is chosen" });
523
+ return;
524
+ }
525
+ const tunnel = newTunnel(port);
526
+ tunnels.push(tunnel);
527
+ res.once("close", () => {
528
+ tunnels.splice(tunnels.indexOf(tunnel), 1);
529
+ ctx.log(`web page: the tunnel from local port ${port} closed`);
530
+ });
531
+ ctx.log(`web page: a tunnel opened from local port ${port}`);
532
+ res.writeHead(200, { "Content-Type": "application/x-ndjson" });
533
+ res.write(`${JSON.stringify({ ticket: tunnel.ticket })}\n`);
534
+ }
535
+ /** How the run's server listens: on 127.0.0.1, and there is no other way (the header says why). */
536
+ export function listenOnLoopback(server, port) {
537
+ return new Promise((resolve) => server.listen(port, "127.0.0.1", resolve));
538
+ }
156
539
  export function createRunServer(ctx) {
157
540
  const saidErrors = new Set();
158
- // read once, here: nothing a request names is ever read from disk (mason serve's run serves no page)
541
+ // the last day's and week's statements, read at most once every SUMMARY_TTL_MS for every page and command that asks
542
+ const summary = timedCache(() => ctx.duck.requests.summary(), SUMMARY_TTL_MS);
543
+ // the tunnels open now (`openTunnel`): the only Hosts the guard takes beside this server's own
544
+ const tunnels = [];
545
+ // read once, here: nothing a request names is ever read from disk
159
546
  const page = ctx.page ? pageFiles() : new Map();
160
547
  return http.createServer((req, res) => {
161
548
  for (const [k, v] of Object.entries(SECURITY_HEADERS))
162
549
  res.setHeader(k, v);
163
- const v = guard({ method: req.method ?? "GET", url: req.url ?? "/", host: req.headers.host, origin: req.headers.origin, cookie: req.headers.cookie, authorization: req.headers.authorization }, ctx.port, ctx.token);
550
+ const v = guard({ method: req.method ?? "GET", url: req.url ?? "/", host: req.headers.host, origin: req.headers.origin, cookie: req.headers.cookie, authorization: req.headers.authorization }, ctx.port, ctx.token, tunnels);
164
551
  if (!v.ok) {
165
552
  if (v.status === 303) {
166
- res.writeHead(303, { Location: v.location, "Set-Cookie": v.setCookie }).end();
553
+ res.writeHead(303, { Location: v.location, ...(v.setCookie !== undefined ? { "Set-Cookie": v.setCookie } : {}) }).end();
167
554
  return;
168
555
  }
169
556
  res.writeHead(v.status, { "Content-Type": "text/plain; charset=utf-8" }).end(`${v.reason}\n`);
@@ -174,14 +561,27 @@ export function createRunServer(ctx) {
174
561
  try {
175
562
  const file = req.method === "GET" || req.method === "HEAD" ? page.get(url.pathname) : undefined;
176
563
  if (file !== undefined) {
177
- res.writeHead(200, { "Content-Type": file.type, "Content-Length": file.body.length }).end(req.method === "HEAD" ? undefined : file.body);
564
+ res.writeHead(200, { "Content-Type": file.type, "Content-Length": file.body.length, ...(file.immutable ? { "Cache-Control": IMMUTABLE } : {}) })
565
+ .end(req.method === "HEAD" ? undefined : file.body);
178
566
  }
179
567
  else if (req.method === "GET" && url.pathname === "/api/ping") {
180
568
  // what proves a run is up (src/cli/runs.ts answers): this token answered, by this process
181
569
  sendJson(res, 200, { pid: process.pid });
182
570
  }
183
571
  else if (req.method === "GET" && url.pathname === "/api/state") {
184
- sendJson(res, 200, await state(ctx));
572
+ // lean, as the page polls it; ?columns=1 with each table's columns (mason status --json)
573
+ sendJson(res, 200, await state(ctx, url.searchParams.get("columns") === "1"));
574
+ }
575
+ else if (req.method === "GET" && /^\/api\/tables\/[^/]+\/columns$/.test(url.pathname)) {
576
+ // one table's columns, read when the page's Columns… form opens (never with the state, polled every 2 s)
577
+ let table;
578
+ try {
579
+ table = decodeURIComponent(url.pathname.split("/")[3]);
580
+ }
581
+ catch {
582
+ throw new Refusal(400, `${url.pathname} does not name a table: GET /api/tables/<schema.table, URL-encoded>/columns`);
583
+ }
584
+ sendJson(res, 200, tableColumns(ctx, table));
185
585
  }
186
586
  else if (req.method === "GET" && url.pathname === "/api/slow") {
187
587
  // the last day's slow statements, from the request log (src/duck/requests.ts)
@@ -190,22 +590,100 @@ export function createRunServer(ctx) {
190
590
  else if (req.method === "GET" && url.pathname === "/api/refused") {
191
591
  sendJson(res, 200, await ctx.duck.requests.refused());
192
592
  }
593
+ else if (req.method === "GET" && url.pathname === "/api/unsupported") {
594
+ // the refused statements grouped by the unsupported SQL they used (src/duck/gaps.ts): mason status
595
+ sendJson(res, 200, await unsupported(ctx, url.searchParams));
596
+ }
193
597
  else if (req.method === "GET" && url.pathname === "/api/requests") {
194
- const limit = Number(url.searchParams.get("limit") ?? 40);
195
- if (!Number.isInteger(limit) || limit < 1 || limit > 10_000)
196
- throw new Refusal(400, "limit is a whole number of requests, 1 to 10000");
197
- sendJson(res, 200, { requests: await ctx.duck.requests.recent({ limit, failed: url.searchParams.get("failed") === "1" }), keptDays: ctx.duck.requests.days });
598
+ // the filters, read as mason logs --requests reads its options (src/cli/request-filter.ts)
599
+ const q = requestFilter(requestFilterText(url.searchParams));
600
+ if ("message" in q) {
601
+ sendFailure(res, 400, q);
602
+ return;
603
+ }
604
+ sendJson(res, 200, { ...await ctx.duck.requests.page(q), keptDays: ctx.duck.requests.days });
605
+ }
606
+ else if (req.method === "GET" && url.pathname === "/api/queries") {
607
+ // the last day's and week's statements in one scan of the request log (mason status); asked on its own, never
608
+ // with /api/state, which the page asks every 2 s
609
+ // (cached SUMMARY_TTL_MS: up to 30 s old)
610
+ sendJson(res, 200, { queries: await summary() });
611
+ }
612
+ else if (req.method === "GET" && url.pathname === "/api/activity") {
613
+ // one time range's statements, clients and slowest (the Usage page's), from the request log
614
+ sendJson(res, 200, await activity(ctx, url.searchParams));
615
+ }
616
+ else if (req.method === "GET" && url.pathname === "/api/running") {
617
+ sendJson(res, 200, { running: ctx.listener.running() });
618
+ }
619
+ else if (req.method === "POST" && url.pathname === "/api/cancel") {
620
+ const body = JSON.parse(await readBody(req, 64 * 1024));
621
+ const whole = (x) => typeof x === "number" && Number.isInteger(x) && x >= 1;
622
+ if (!whole(body.session))
623
+ throw new Refusal(400, "send { session }: the session's number, as mason logs --running lists it");
624
+ if (body.statement !== undefined && !whole(body.statement))
625
+ throw new Refusal(400, "statement is the statement's number, as mason logs --running lists it, or left out");
626
+ const r = ctx.listener.cancel(body.session, body.statement);
627
+ if ("cancelled" in r) {
628
+ sendJson(res, 200, r);
629
+ return;
630
+ }
631
+ const running = ["mason logs --running", "the statements running now, with their sessions"];
632
+ if ("other" in r) {
633
+ sendFailure(res, 409, { message: `session ${r.other.session} runs another statement now (statement ${r.other.statement}), not statement ${r.asked}: nothing cancelled`, code: "refused",
634
+ 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] });
635
+ return;
636
+ }
637
+ if ("unknown" in r) {
638
+ sendFailure(res, 404, { message: `no session ${r.unknown} is open`, code: "not_found", why: "it ended, or never was", next: [running] });
639
+ return;
640
+ }
641
+ 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] });
642
+ }
643
+ else if (req.method === "GET" && url.pathname === "/api/logs") {
644
+ // the run's last log entries from memory (mason serve has no file to read): { at, text } each, oldest first
645
+ const lines = Number(url.searchParams.get("lines") ?? Math.min(40, ctx.logs.capacity));
646
+ if (!Number.isInteger(lines) || lines < 1 || lines > ctx.logs.capacity)
647
+ throw new Refusal(400, `lines is a whole number of log entries, 1 to ${ctx.logs.capacity} (all the run keeps)`);
648
+ const sinceText = url.searchParams.get("since");
649
+ const since = sinceText === null ? undefined : timeParam("since", sinceText, Date.now());
650
+ sendJson(res, 200, { lines: ctx.logs.recent({ lines, ...(since !== undefined ? { since } : {}) }), kept: ctx.logs.capacity });
651
+ }
652
+ else if (req.method === "GET" && url.pathname === "/api/events") {
653
+ // the sync's events and the run's restarts, newest last (src/duck/events.ts): mason logs --events, mason status
654
+ sendJson(res, 200, await events(ctx, url.searchParams));
655
+ }
656
+ else if (req.method === "GET" && url.pathname === "/api/usage") {
657
+ // usage over time, a sample a minute (src/duck/usage.ts): mason usage
658
+ sendJson(res, 200, await usage(ctx, url.searchParams));
198
659
  }
199
660
  else if (req.method === "POST" && url.pathname === "/api/pick") {
200
661
  const body = JSON.parse(await readBody(req, 64 * 1024));
201
662
  if (typeof body.table !== "string" || typeof body.picked !== "boolean")
202
- throw new Refusal(400, "send { table, picked, columns?, key? }");
663
+ throw new Refusal(400, "send { table, picked, columns?, key?, sync?, keep? }");
664
+ // a sync by the sources file's own grammar (src/lib/table-sync.ts), as mason add --sync reads it
665
+ const sync = body.sync === undefined ? undefined : parseSync(body.sync);
666
+ if (sync !== undefined && "problem" in sync)
667
+ throw new Refusal(400, `sync ${JSON.stringify(body.sync)}: ${sync.problem}`);
203
668
  if (body.columns !== undefined && !(Array.isArray(body.columns) && body.columns.every((c) => typeof c === "string")))
204
669
  throw new Refusal(400, "columns is a list of column names, or [\"*\"]");
205
670
  if (body.key !== undefined && !(Array.isArray(body.key) && body.key.length > 0 && body.key.every((c) => typeof c === "string")))
206
671
  throw new Refusal(400, "key is a list of column names");
207
672
  if (body.picked) {
208
- const r = pick(ctx, body.table, body.columns ?? ["*"], body.key ?? null);
673
+ // the file as it was (its text, read once with the edit): an edit that sets a table to sync: replication the
674
+ // database cannot serve is undone below
675
+ const at = sourceOnDisk(ctx);
676
+ const r = pick(ctx, body.table, body.columns ?? ["*"], body.key ?? null, { ...(sync !== undefined ? { sync } : {}), keep: body.keep === true }, at);
677
+ // A LIVE SWITCH INTO REPLICATION is held to the start's checks (wal_level, the role, a free slot): one the
678
+ // database cannot serve would leave the table, polled until now, NOT kept current for ever — the file is put
679
+ // back as it was, byte for byte, and the refusal says what the database lacks and the line that polls it
680
+ const now = r.already || ctx.checkReplication === undefined ? null : parseSourceText(fs.readFileSync(at.file, "utf8"), at.parsed.name, at.file);
681
+ const refused = now === null || "problems" in now ? null : await ctx.checkReplication(now);
682
+ if (refused !== null) {
683
+ fs.writeFileSync(at.file, at.text);
684
+ sendFailure(res, statusOf(refused), refused);
685
+ return;
686
+ }
209
687
  if (!r.already)
210
688
  ctx.log(`added ${r.table} to ${r.file}`);
211
689
  ctx.sourcesChanged();
@@ -219,21 +697,101 @@ export function createRunServer(ctx) {
219
697
  sendJson(res, 200, { ...r, state: await state(ctx) });
220
698
  }
221
699
  }
700
+ else if (req.method === "POST" && url.pathname === "/api/ui/tunnel") {
701
+ await openTunnel(ctx, tunnels, req, res);
702
+ }
703
+ else if (req.method === "POST" && url.pathname === "/api/share") {
704
+ await openShare(ctx, req, res);
705
+ }
706
+ else if (req.method === "GET" && url.pathname === "/api/share/choices") {
707
+ // what a share with no role named reads, or the roles to choose from: the page asks before it asks the person
708
+ if (ctx.pageShares === undefined) {
709
+ sendFailure(res, 409, SERVED_SHARE);
710
+ return;
711
+ }
712
+ const r = await ctx.pageShares.choices();
713
+ if ("failure" in r && r.choices === undefined) {
714
+ sendFailure(res, statusOf(r.failure), r.failure);
715
+ return;
716
+ }
717
+ sendJson(res, 200, "failure" in r ? { choices: r.choices } : { choices: [], role: r.role, reads: r.reads });
718
+ }
719
+ else if ((req.method === "POST" && /^\/api\/share\/[^/]+\/beat$/.test(url.pathname)) || (req.method === "DELETE" && /^\/api\/share\/[^/]+$/.test(url.pathname))) {
720
+ // the page's share: a beat holds it, a DELETE is Stop sharing; a share of mason share's is its command's to end
721
+ if (ctx.pageShares === undefined || ctx.shares === undefined) {
722
+ sendFailure(res, 409, SERVED_SHARE);
723
+ return;
724
+ }
725
+ let user;
726
+ try {
727
+ user = decodeURIComponent(url.pathname.split("/")[3]);
728
+ }
729
+ catch {
730
+ throw new Refusal(400, `${url.pathname} does not name a share`);
731
+ }
732
+ const from = ctx.shares.fromOf(user);
733
+ if (from === "command") {
734
+ sendFailure(res, 409, { message: `the share ${user} is mason share's: Ctrl-C in its terminal ends it`, code: "refused" });
735
+ return;
736
+ }
737
+ const ended = ctx.shares.ended;
738
+ const why = ended?.user === user ? `: ${ended.why}` : "";
739
+ const ok = req.method === "DELETE" ? await ctx.pageShares.stop(user) : ctx.pageShares.beat(user);
740
+ if (!ok) {
741
+ sendFailure(res, 404, { message: `the share ${user} is not open${why === "" ? "" : `; it ended${why}`}`, code: "not_found", fix: "Connect from anywhere opens a new one, with a new address and password" });
742
+ return;
743
+ }
744
+ sendJson(res, 200, { ok: true });
745
+ }
746
+ else if (req.method === "POST" && /^\/api\/share\/[^/]+$/.test(url.pathname)) {
747
+ // what the command knows of its tunnel, as it knows it: mason status says it, and the run stops the client
748
+ const body = tunnelReport(JSON.parse(await readBody(req, 64 * 1024)));
749
+ if (body === null)
750
+ throw new Refusal(400, "send { via?, host?, port?, endsAt?, state?, pid? }: what the command knows of its tunnel");
751
+ const user = decodeURIComponent(url.pathname.slice("/api/share/".length));
752
+ if (ctx.shares?.update(user, body) !== true) {
753
+ sendFailure(res, 404, { message: `no share ${user} is open`, code: "not_found" });
754
+ return;
755
+ }
756
+ sendJson(res, 200, { ok: true });
757
+ }
222
758
  else if (req.method === "GET" && url.pathname === "/api/users") {
223
- // mason user list --json's shape: the users, and whether each connects
224
- const r = listUsers(ctx.users.file, ctx.users.apiKey);
759
+ // mason user list --json's shape: the users, and whether each connects (and with ?lastUsed=1 when each was last used)
760
+ const r = listUsers(ctx.users.file, ctx.users.apiKey, postgresLink(ctx.users.postgresUrl), ctx.roles?.() ?? NO_ROLES);
225
761
  if ("message" in r) {
226
762
  sendFailure(res, statusOf(r), r);
227
763
  return;
228
764
  }
229
- sendJson(res, 200, { ...r, next: nextJson([["mason user add <name>", "a user for a tool: its password and URL, printed once"]]) });
765
+ // the last use is a scan of the request log (~0.15 s on a week of 50 statements a second): only when asked
766
+ // (?lastUsed=1: mason user list, the Users page every 30 s), never for the Overview's poll, which does not show it
767
+ const used = url.searchParams.get("lastUsed") === "1" ? await withLastUsed(r.users, ctx.duck.requests, ctx.log)
768
+ : { users: r.users, lastUsedNote: LAST_USED_NOT_ASKED, requestLogDays: ctx.duck.requests.days };
769
+ sendJson(res, 200, { ...used, urlNote: null, next: nextJson([["mason user add <name>", "a user for a tool: its password and URL, printed once"]]) });
230
770
  }
231
771
  else if (req.method === "POST" && url.pathname === "/api/users") {
232
772
  const body = JSON.parse(await readBody(req, 64 * 1024));
233
773
  if (typeof body.name !== "string")
234
774
  throw new Refusal(400, "send { name }: the user's name");
775
+ if (body.role !== undefined && typeof body.role !== "string")
776
+ throw new Refusal(400, "send { name, role }: the role's name, a string");
777
+ if (body.set !== undefined && !(Array.isArray(body.set) && body.set.every((x) => typeof x === "string")))
778
+ throw new Refusal(400, "send { name, role, set }: set is a list of \"attribute=value\" strings, as mason user add --set takes them");
779
+ // the role must be one the roles file names NOW (src/cli/user.ts roleToAdd): refused before anything is written
780
+ const roleProblem = body.role === undefined ? null : roleToAdd(body.role, ctx.roles?.() ?? NO_ROLES);
781
+ if (roleProblem !== null) {
782
+ sendFailure(res, statusOf(roleProblem), roleProblem);
783
+ return;
784
+ }
785
+ // its attributes, held to the role as mason user add holds them (src/cli/user.ts attributesToAdd): one grammar
786
+ const rolesNow = ctx.roles?.() ?? NO_ROLES;
787
+ const attributes = attributesToAdd(body.name, typeof body.role === "string" ? body.role : userRole({}, rolesNow).role, body.set ?? [], rolesNow);
788
+ if ("refused" in attributes) {
789
+ sendFailure(res, statusOf(attributes.refused), attributes.refused);
790
+ return;
791
+ }
235
792
  // mason user add --json's shape: its password, once, and the URL to paste (this run is up, so it has one)
236
- const r = addUser(ctx.users.file, ctx.users.apiKey, body.name, { link: postgresLink(ctx.users.postgresUrl), database: ctx.users.database });
793
+ 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,
794
+ ...(typeof body.role === "string" ? { role: body.role } : {}), attributes: attributes.values });
237
795
  if ("message" in r) {
238
796
  sendFailure(res, statusOf(r), r);
239
797
  return;
@@ -242,6 +800,37 @@ export function createRunServer(ctx) {
242
800
  ctx.log(`user ${added.user} added${replaced ? " (in place of one the API key's rotation had removed)" : ""}`);
243
801
  sendJson(res, 200, { ...added, next: nextJson(addedNext(added.user, true)) });
244
802
  }
803
+ else if (req.method === "POST" && /^\/api\/users\/[^/]+\/(disable|enable)$/.test(url.pathname)) {
804
+ // mason user disable|enable --json's shape, through its own functions: kept, its password unchanged
805
+ const [, , , encoded, verb] = url.pathname.split("/");
806
+ let name;
807
+ try {
808
+ name = decodeURIComponent(encoded);
809
+ }
810
+ catch {
811
+ throw new Refusal(400, `${url.pathname} does not name a user: POST /api/users/<name>/${verb}`);
812
+ }
813
+ if (verb === "disable") {
814
+ const r = disableUser(ctx.users.file, name);
815
+ if ("message" in r) {
816
+ sendFailure(res, statusOf(r), r);
817
+ return;
818
+ }
819
+ if (!r.already)
820
+ ctx.log(`user ${r.disabled.name} disabled`);
821
+ sendJson(res, 200, { ...r, next: nextJson(disabledNext(r.disabled.name)) });
822
+ }
823
+ else {
824
+ const r = enableUser(ctx.users.file, name);
825
+ if ("message" in r) {
826
+ sendFailure(res, statusOf(r), r);
827
+ return;
828
+ }
829
+ if (!r.already)
830
+ ctx.log(`user ${r.enabled.name} enabled`);
831
+ sendJson(res, 200, { ...r, next: nextJson(enabledNext(r.enabled.name)) });
832
+ }
833
+ }
245
834
  else if (req.method === "DELETE" && url.pathname.startsWith("/api/users/")) {
246
835
  let name;
247
836
  try {