@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
@@ -1,8 +1,8 @@
1
1
  const requiredOf = (t) => t.columns.filter((c) => !c.generated).map((c) => c.name);
2
2
  /** what follows every refusal: Mason waits for the owner, and when it looks again */
3
3
  const NEVER_ALTERED = "Mason reads a publication it is given as it is, never altering it: once its owner has run that, the next mason start " +
4
- "(or mason add) checks it again. Or mason start --replication without --publication, where Mason makes its own (not on a read " +
5
- "replica), or mason start --poll, which needs no publication.";
4
+ "(or mason add) checks it again. Or give the table's entry sync: poll <interval> in the sources file, which needs no publication; " +
5
+ "or write publication: null in it, where Mason makes its own, carrying the replicated tables alone (not on a read replica).";
6
6
  /**
7
7
  * The verdict over a described publication (pure: `described` is what `readNamedPublication` read). `identities` are
8
8
  * the tables' `relreplident`: a table whose identity is not its key is expected WHOLE (a column list missing an
@@ -25,6 +25,20 @@ export function namedPublicationVerdict(described, tables, identities) {
25
25
  }
26
26
  return { refused: null, tables: out, noTruncate };
27
27
  }
28
+ /**
29
+ * A TABLE'S GAP ON ONE LINE, for its line in `mason status` (src/duck/sync.ts `notStreamedReason`): what is wrong, the
30
+ * statements its owner runs (this module's own "Expected" block, joined), and the way that needs no publication. A gap
31
+ * written otherwise (one line) is its own first line.
32
+ */
33
+ export function gapLine(gap) {
34
+ const lines = gap.split("\n");
35
+ const at = lines.findIndex((l) => l.startsWith("Expected ("));
36
+ if (at < 0)
37
+ return `${lines[0]} — or give its entry sync: poll <interval> in the sources file, which needs no publication`;
38
+ const end = lines.findIndex((l, i) => i > at && l.trim() === "");
39
+ const sql = lines.slice(at + 1, end < 0 ? undefined : end).map((l) => l.trim()).filter((l) => !l.startsWith("(")).join(" ");
40
+ return `${lines[0]} — the database's owner runs ${sql} — or give its entry sync: poll <interval> in the sources file, which needs no publication`;
41
+ }
28
42
  // ------------------------------------------------------------------ the check
29
43
  const q = (s) => /^[a-z_][a-z0-9_]*$/.test(s) ? s : `"${s.replace(/"/g, '""')}"`;
30
44
  const qualified = (t) => t.split(".").map(q).join(".");
@@ -56,13 +70,13 @@ function checkPublication(required, described, whole) {
56
70
  continue;
57
71
  if (d.published === null) {
58
72
  problems.push(`${table} is not published`);
59
- missingLines.push(` missing: ${table} (read by Mason's copy)`);
73
+ missingLines.push(` missing: ${table} (read by Mason's sync)`);
60
74
  continue;
61
75
  }
62
76
  const lacking = inCatalogOrder(d, cols).filter((c) => !d.published.includes(c));
63
77
  if (lacking.length) {
64
78
  problems.push(`${table} columns ${lacking.join(", ")} are not published`);
65
- missingLines.push(` missing: ${table} columns ${lacking.join(", ")} (read by Mason's copy)`);
79
+ missingLines.push(` missing: ${table} columns ${lacking.join(", ")} (read by Mason's sync)`);
66
80
  }
67
81
  if (d.rowFilter !== null) {
68
82
  problems.push(`${table} is published with a row filter`);
@@ -103,7 +117,7 @@ function checkPublication(required, described, whole) {
103
117
  (found.size ? tableLines(found, columnLists).join(",\n") : " (no tables)")]
104
118
  : [`Found: no publication named ${pub.name}. Publications on this database: ${pub.others.length ? pub.others.join(", ") : "none"}.`];
105
119
  return [
106
- `publication ${pub.name} does not carry what Mason's copy of the table holds.`,
120
+ `publication ${pub.name} does not carry what Mason's synced table holds.`,
107
121
  "",
108
122
  ...(missingLines.length ? [...missingLines, ""] : []),
109
123
  ...(publishes.length && pub.exists ? [` it publishes ${pub.publish.join(", ") || "nothing"}; the change reader needs insert, update and delete`, ""] : []),
@@ -185,7 +199,7 @@ export function describedOf(answer, publication) {
185
199
  }
186
200
  /** The named publication and the copied tables as the live catalog describes them: read-only, two statements.
187
201
  * (`_project`, the project's name, is unused: the tables are found by their own names.) */
188
- export async function readNamedPublication(pg, publication, tables, _project) {
202
+ export async function readNamedPublication(pg, publication, tables) {
189
203
  const one = async (sql) => {
190
204
  const v = (await pg.query(sql)).rows[0]?.[0];
191
205
  if (typeof v !== "string")
@@ -1,28 +1,15 @@
1
- import { outcomeOf } from "../api/request-log.js";
1
+ import { outcomeOf, outcomeSql, statusOf } from "../api/request-log.js";
2
2
  import { MASON_SCHEMA } from "./store.js";
3
+ import { Batches, History, Streak } from "./history.js";
4
+ import { GAPS } from "./gaps.js";
5
+ import { quantileContOf } from "./usage.js";
3
6
  export const REQUESTS_TABLE = `${MASON_SCHEMA}.requests`;
4
- /** How many days of requests are kept unless MASON_REQUEST_LOG_DAYS says. */
5
- export const DEFAULT_REQUEST_LOG_DAYS = 7;
6
7
  const FLUSH_MS = 1000;
7
8
  const BATCH_ROWS = 1000;
8
- /** how often the rows past the retention are deleted (and once at open) */
9
- const TRIM_EVERY_MS = 60 * 60 * 1000;
10
- /**
11
- * The days MASON_REQUEST_LOG_DAYS keeps, or why the text is not a number of days. Unset or empty: the default.
12
- */
13
- export function requestLogDays(text) {
14
- if (text === undefined || text.trim() === "")
15
- return DEFAULT_REQUEST_LOG_DAYS;
16
- const days = Number(text);
17
- if (!Number.isInteger(days) || days < 1) {
18
- return { problem: `MASON_REQUEST_LOG_DAYS=${JSON.stringify(text)} is not a number of days: write a whole number, 1 or more (unset, ${DEFAULT_REQUEST_LOG_DAYS})` };
19
- }
20
- return days;
21
- }
22
9
  /** A request's outcome in a word, from its SQLSTATE (src/api/request-log.ts: the listener's refusal line reads it too). */
23
10
  export { outcomeOf };
24
11
  const COLUMNS = ["request_id", "started_at", "session", "application_name", "token_name", "user_name", "method", "sqlstate", "message", "detail", "hint",
25
- "rows", "duration_ms", "query", "query_truncated", "seq", "json_reads"];
12
+ "rows", "duration_ms", "query", "query_truncated", "seq", "json_reads", "bytes_sent", "gap", "role", "attributes", "client_addr"];
26
13
  /** the ledger's DDL: one row per request, named for a developer who knows pg_stat_activity */
27
14
  /** a ledger made before `mason user` gains its column; a column added last is named in every INSERT */
28
15
  export const REQUESTS_USER_DDL = `ALTER TABLE ${REQUESTS_TABLE} ADD COLUMN IF NOT EXISTS user_name VARCHAR`;
@@ -40,68 +27,118 @@ export const REQUESTS_SEQ_DDL = `ALTER TABLE ${REQUESTS_TABLE} ADD COLUMN IF NOT
40
27
  * `review`: read by several statements, or by a slow one, within the log's retention). A ledger made before gains it.
41
28
  */
42
29
  export const REQUESTS_JSON_DDL = `ALTER TABLE ${REQUESTS_TABLE} ADD COLUMN IF NOT EXISTS json_reads VARCHAR`;
30
+ /** THE ROLE a session read as (src/duck/grants.ts): beside `user_name`, so a refusal (42501) says who was held to what;
31
+ * a ledger made before roles gains the column, its old rows NULL (read as '') */
32
+ export const REQUESTS_ROLE_DDL = `ALTER TABLE ${REQUESTS_TABLE} ADD COLUMN IF NOT EXISTS role VARCHAR`;
33
+ /** THE USER'S ATTRIBUTES beside its role (src/duck/row-filter.ts: the values its role's row filters read), as JSON text,
34
+ * so a statement's rows can be told apart by whose values filtered them; NULL for none, and in a ledger made before */
35
+ export const REQUESTS_ATTRIBUTES_DDL = `ALTER TABLE ${REQUESTS_TABLE} ADD COLUMN IF NOT EXISTS attributes VARCHAR`;
36
+ /** THE BYTES SENT for each statement (ApiLogRow.bytesSent): what the answers cost to send. A ledger made before gains it;
37
+ * its old rows are NULL, never 0. */
38
+ export const REQUESTS_BYTES_DDL = `ALTER TABLE ${REQUESTS_TABLE} ADD COLUMN IF NOT EXISTS bytes_sent BIGINT`;
39
+ /** THE REGISTRY ENTRY THAT REFUSED EACH STATEMENT (ApiLogRow.gap, src/duck/gaps.ts): NULL for an answer and for any
40
+ * other refusal; what `unsupported` groups by. A ledger made before gains it; its old rows are NULL. */
41
+ export const REQUESTS_GAP_DDL = `ALTER TABLE ${REQUESTS_TABLE} ADD COLUMN IF NOT EXISTS gap VARCHAR`;
42
+ /** THE CLIENT'S ADDRESS (ApiLogRow.clientAddress), named as pg_stat_activity names it: behind a TCP proxy the client its
43
+ * PROXY header carries, so a deployment behind one can tell who sent what. A ledger made before gains it; its old rows
44
+ * are NULL. */
45
+ export const REQUESTS_CLIENT_ADDR_DDL = `ALTER TABLE ${REQUESTS_TABLE} ADD COLUMN IF NOT EXISTS client_addr VARCHAR`;
43
46
  export const REQUESTS_DDL = `CREATE TABLE IF NOT EXISTS ${REQUESTS_TABLE} (request_id VARCHAR, started_at TIMESTAMPTZ, session INTEGER, ` +
44
47
  `application_name VARCHAR, token_name VARCHAR, user_name VARCHAR, method VARCHAR, sqlstate VARCHAR, message VARCHAR, detail VARCHAR, hint VARCHAR, ` +
45
- `"rows" BIGINT, duration_ms INTEGER, query VARCHAR, query_truncated BOOLEAN, seq BIGINT, json_reads VARCHAR)`;
48
+ `"rows" BIGINT, duration_ms INTEGER, query VARCHAR, query_truncated BOOLEAN, seq BIGINT, json_reads VARCHAR, bytes_sent BIGINT, gap VARCHAR, role VARCHAR, attributes VARCHAR, client_addr VARCHAR)`;
46
49
  /** an ApiLogRow's values in COLUMNS' order */
47
50
  function valuesOf({ row: r, seq }) {
48
51
  const app = (r.client ?? "").replace(/^postgres:/, "");
49
52
  return [r.requestId, r.at.toISOString(), r.session ?? null, app === "unnamed" ? "" : app, r.tokenName ?? "", r.user ?? "", r.method,
50
53
  r.sqlstate ?? null, r.error === undefined || r.error === "" ? null : r.error, r.detail ?? null, r.hint ?? null,
51
54
  r.rows === undefined ? null : r.rows, Math.round(r.durationMs), r.payload ?? "", r.payloadTruncated === true, seq,
52
- r.jsonReads === undefined || r.jsonReads.length === 0 ? null : JSON.stringify(r.jsonReads)];
55
+ r.jsonReads === undefined || r.jsonReads.length === 0 ? null : JSON.stringify(r.jsonReads), r.bytesSent === undefined ? null : r.bytesSent, r.gap ?? null, r.role ?? "",
56
+ r.attributes === undefined || Object.keys(r.attributes).length === 0 ? null : JSON.stringify(r.attributes), r.clientAddress ?? null];
53
57
  }
54
58
  /** A ledger row as the commands read it. */
55
59
  function requestOf(o) {
56
60
  const text = (v) => v === null || v === undefined ? null : String(v);
57
61
  const sqlstate = text(o.sqlstate);
62
+ const outcome = outcomeOf(sqlstate);
63
+ const startedAt = new Date(String(o.started_at));
58
64
  return {
59
- requestId: String(o.request_id), startedAt: new Date(String(o.started_at)).toISOString(), session: o.session === null ? null : Number(o.session),
60
- applicationName: String(o.application_name ?? ""), tokenName: String(o.token_name ?? ""), user: String(o.user_name ?? ""), method: String(o.method),
61
- outcome: outcomeOf(sqlstate), sqlstate, message: text(o.message), detail: text(o.detail), hint: text(o.hint),
62
- rows: o.rows === null ? null : Number(o.rows), durationMs: Number(o.duration_ms), query: String(o.query ?? ""), queryTruncated: o.query_truncated === true,
65
+ requestId: String(o.request_id), startedAt: startedAt.toISOString(), session: o.session === null ? null : Number(o.session),
66
+ applicationName: String(o.application_name ?? ""), clientAddress: typeof o.client_addr === "string" ? o.client_addr : null, tokenName: String(o.token_name ?? ""), user: String(o.user_name ?? ""), role: String(o.role ?? ""),
67
+ attributes: typeof o.attributes === "string" && o.attributes !== "" ? JSON.parse(o.attributes) : null, method: String(o.method),
68
+ outcome, status: statusOf(outcome), sqlstate, message: text(o.message), detail: text(o.detail), hint: text(o.hint),
69
+ rows: o.rows === null ? null : Number(o.rows), durationMs: Number(o.duration_ms),
70
+ endedAt: new Date(startedAt.getTime() + Number(o.duration_ms)).toISOString(), bytesSent: o.bytes_sent === null || o.bytes_sent === undefined ? null : Number(o.bytes_sent),
71
+ query: String(o.query ?? ""), queryTruncated: o.query_truncated === true,
63
72
  };
64
73
  }
74
+ /**
75
+ * A position in the request log's order, as a page hands it on: the row's start, ISO 8601, and its number (`seq`; -1
76
+ * for a row recorded before rows were numbered) — `2026-10-28T10:00:00.123Z~42`. A reader passes it back as it came.
77
+ */
78
+ export const requestCursor = (at, seq) => `${at}~${seq}`;
79
+ /** A cursor read back (requestCursor), or null when the text is not one. */
80
+ export function parseRequestCursor(text) {
81
+ const m = /^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z)~(-?\d+)$/.exec(text);
82
+ if (m === null || Number.isNaN(Date.parse(m[1])))
83
+ return null;
84
+ return { at: m[1], seq: Number(m[2]) };
85
+ }
86
+ /** how much of a statement `unsupported` shows as its example */
87
+ export const UNSUPPORTED_EXAMPLE_CHARS = 1000;
88
+ /**
89
+ * A WINDOW FOLDED FROM THE GROUPS of a scan by SQLSTATE and a completed one's duration (`summary`, `window`): each
90
+ * outcome by `outcomeOf` (the one rule), the average from the durations' sum, the p50 / p95 from the durations'
91
+ * histogram as quantile_cont interpolates them (`quantileContOf`). ONE place adds a window up.
92
+ */
93
+ function foldWindow(groups, days, keptDays) {
94
+ const w = { days, covered: Math.min(days, keptDays), statements: 0, completed: 0, refused: 0, failed: 0, cancelled: 0, rows: 0, bytesSent: 0, durationMs: null };
95
+ const histogram = [];
96
+ let duration = 0;
97
+ for (const g of groups) {
98
+ const n = Number(g.n);
99
+ w.statements += n;
100
+ w.rows += Number(g.sent_rows);
101
+ w.bytesSent += Number(g.sent_bytes);
102
+ const outcome = outcomeOf(g.sqlstate === null ? null : String(g.sqlstate));
103
+ if (outcome === "ok") {
104
+ w.completed += n;
105
+ duration += Number(g.duration);
106
+ histogram.push([Number(g.ms), n]);
107
+ }
108
+ else
109
+ w[outcome] += n;
110
+ }
111
+ histogram.sort((a, b) => a[0] - b[0]);
112
+ const p50 = quantileContOf(histogram, 0.5), p95 = quantileContOf(histogram, 0.95);
113
+ if (w.completed > 0 && p50 !== null && p95 !== null)
114
+ w.durationMs = { avg: Math.round(duration / w.completed), p50, p95 };
115
+ return w;
116
+ }
65
117
  export class RequestLedger {
66
- buffer = [];
67
118
  /** the next row's number (REQUESTS_SEQ_DDL): one past the ledger's last at open, then one per request recorded */
68
119
  next = 0;
69
- timer = null;
70
- trimTimer = null;
71
- /** the flush in flight: one at a time, so batches commit in the order they were taken */
72
- flushing = Promise.resolve();
73
120
  /** rows lost since the last flush that succeeded: said once when the streak begins, and when it ends */
74
- lost = 0;
121
+ lost = new Streak();
75
122
  closed = false;
76
- writer;
77
- reader;
123
+ history;
124
+ batches;
78
125
  log;
79
126
  options;
80
127
  /** days kept */
81
128
  days;
82
- constructor(writer, reader, log, options) {
83
- this.writer = writer;
84
- this.reader = reader;
129
+ constructor(history, log, options) {
130
+ this.history = history;
85
131
  this.log = log;
86
132
  this.options = options;
87
133
  this.days = options.days;
134
+ this.batches = new Batches({ rows: BATCH_ROWS, ms: options.flushMs ?? FLUSH_MS, write: (rows) => this.write(rows) });
88
135
  }
89
136
  /** The ledger of a store: its table made, the rows past the retention deleted, and a trim every hour. */
90
137
  static async open(store, log, options) {
91
- const writer = await store.instance.connect();
92
- await writer.run(`CREATE SCHEMA IF NOT EXISTS ${MASON_SCHEMA}`);
93
- await writer.run(REQUESTS_DDL);
94
- await writer.run(REQUESTS_USER_DDL);
95
- await writer.run(REQUESTS_SEQ_DDL);
96
- await writer.run(REQUESTS_JSON_DDL);
97
- const reader = await store.instance.connect();
98
- const ledger = new RequestLedger(writer, reader, log, options);
99
- ledger.next = Number((await reader.runAndReadAll(`SELECT coalesce(max(seq), -1) + 1 FROM ${REQUESTS_TABLE}`)).getRowsJS()[0][0]);
100
- await ledger.trim();
101
- ledger.trimTimer = setInterval(() => {
102
- ledger.trim().catch((e) => log(`request log: deleting the requests older than ${ledger.days} days failed: ${e.message}`));
103
- }, TRIM_EVERY_MS);
104
- ledger.trimTimer.unref();
138
+ const history = await History.open(store, [REQUESTS_DDL, REQUESTS_USER_DDL, REQUESTS_SEQ_DDL, REQUESTS_JSON_DDL, REQUESTS_BYTES_DDL, REQUESTS_GAP_DDL, REQUESTS_ROLE_DDL, REQUESTS_ATTRIBUTES_DDL, REQUESTS_CLIENT_ADDR_DDL]);
139
+ const ledger = new RequestLedger(history, log, options);
140
+ ledger.next = Number((await history.reader.runAndReadAll(`SELECT coalesce(max(seq), -1) + 1 FROM ${REQUESTS_TABLE}`)).getRowsJS()[0][0]);
141
+ await history.keepTrimmed(() => ledger.trim(), (e) => log(`request log: deleting the requests older than ${ledger.days} days failed: ${e.message}`));
105
142
  return ledger;
106
143
  }
107
144
  /** One request, written with the next batch. Never throws: the statement it records was already answered. */
@@ -111,26 +148,10 @@ export class RequestLedger {
111
148
  this.log(`request log: a request after the log closed, not recorded (${row.requestId})`);
112
149
  return;
113
150
  }
114
- this.buffer.push({ row, seq: this.next++ });
115
- if (this.buffer.length >= BATCH_ROWS) {
116
- void this.flush();
117
- return;
118
- }
119
- this.timer ??= setTimeout(() => { void this.flush(); }, this.options.flushMs ?? FLUSH_MS);
151
+ this.batches.push({ row, seq: this.next++ });
120
152
  }
121
153
  /** Writes what is waiting, after any flush in flight. Resolves whether it was written or not (a failure is said). */
122
- flush() {
123
- if (this.timer !== null) {
124
- clearTimeout(this.timer);
125
- this.timer = null;
126
- }
127
- const rows = this.buffer;
128
- this.buffer = [];
129
- if (rows.length === 0)
130
- return this.flushing;
131
- this.flushing = this.flushing.then(() => this.write(rows));
132
- return this.flushing;
133
- }
154
+ flush() { return this.batches.flush(); }
134
155
  async write(rows) {
135
156
  const width = COLUMNS.length;
136
157
  const sql = `INSERT INTO ${REQUESTS_TABLE} (${COLUMNS.map((c) => c === "rows" ? `"rows"` : c).join(", ")}) VALUES ` +
@@ -140,45 +161,162 @@ export class RequestLedger {
140
161
  if (this.options.insert !== undefined)
141
162
  await this.options.insert(sql, values);
142
163
  else
143
- await this.writer.run(sql, values);
164
+ await this.history.writer.run(sql, values);
144
165
  }
145
166
  catch (e) {
146
167
  // Tolerated, narrowly, as the ClickHouse writer tolerates it: the answers are sent, so there is no statement left
147
168
  // to fail, and a ledger that cannot write must not take the listener down. SAID, once per streak with the rows it
148
169
  // costs, so a ledger that stopped recording never reads as a quiet listener.
149
- if (this.lost === 0)
150
- this.log(`request log: could not record ${rows.length} request(s) in ${REQUESTS_TABLE} (${rows[0].row.requestId}` +
151
- `${rows.length > 1 ? ` … ${rows[rows.length - 1].row.requestId}` : ""}): ${e.message}; said once until a write succeeds`);
152
- this.lost += rows.length;
170
+ this.lost.failed(rows.length, () => this.log(`request log: could not record ${rows.length} request(s) in ${REQUESTS_TABLE} (${rows[0].row.requestId}` +
171
+ `${rows.length > 1 ? ` … ${rows[rows.length - 1].row.requestId}` : ""}): ${e.message}; said once until a write succeeds`));
153
172
  return;
154
173
  }
155
- if (this.lost > 0) {
156
- this.log(`request log: recording again; ${this.lost} request(s) were not recorded`);
157
- this.lost = 0;
158
- }
174
+ this.lost.succeeded((n) => this.log(`request log: recording again; ${n} request(s) were not recorded`));
159
175
  }
160
176
  /** Deletes the requests older than the retention; the count is said when there were any. */
161
177
  async trim() {
162
- const r = await this.writer.runAndReadAll(`DELETE FROM ${REQUESTS_TABLE} WHERE started_at < now() - INTERVAL (${this.days}) DAY`);
178
+ const r = await this.history.writer.runAndReadAll(`DELETE FROM ${REQUESTS_TABLE} WHERE started_at < now() - INTERVAL (${this.days}) DAY`);
163
179
  const n = Number(r.getRowsJS()[0]?.[0] ?? 0);
164
180
  if (n > 0)
165
- this.log(`request log: deleted ${n} request(s) older than ${this.days} days (MASON_REQUEST_LOG_DAYS)`);
181
+ this.log(`request log: deleted ${n} request(s) older than ${this.days} days (MASON_HISTORY_DAYS)`);
166
182
  return n;
167
183
  }
168
- /** The newest `limit` requests, newest LAST; `failed`: the refused and failed ones only. Committed rows only. */
169
- async recent(o) {
170
- const rows = (await this.reader.runAndReadAll(`SELECT * FROM (SELECT * FROM ${REQUESTS_TABLE}${o.failed ? " WHERE sqlstate IS NOT NULL" : ""} ` +
171
- `ORDER BY started_at DESC, seq DESC LIMIT $1) ORDER BY started_at, seq`, [o.limit])).getRowObjectsJson();
184
+ /**
185
+ * The newest `limit` requests that pass the filters, newest LAST. `failed`: the ones not answered (refused, failed or
186
+ * cancelled); `outcomes`, `since` / `until` (started at or after / before), `user`, `client` (application_name) and
187
+ * `before` (strictly older than that position: a page's `before`) narrow it further. Committed rows only.
188
+ */
189
+ async recent(q) {
190
+ return (await this.rows(q)).map((r) => requestOf(r));
191
+ }
192
+ /**
193
+ * ONE PAGE of the request log (`/api/requests`, `mason logs --requests`): `recent`, and `before`, the cursor of the
194
+ * page's oldest row when an older one passes the filters (null: this is the last page). Pages follow the ledger's
195
+ * order (started_at, then seq), so the page asked with `before` holds the rows older than the page before it, whatever
196
+ * was recorded since — but a statement still running when a page was read is recorded at its START, so it may land in
197
+ * a page already read.
198
+ */
199
+ async page(q) {
200
+ const rows = await this.rows({ ...q, limit: q.limit + 1 });
201
+ const more = rows.length > q.limit;
202
+ const shown = more ? rows.slice(1) : rows;
203
+ const first = shown[0];
204
+ return { requests: shown.map((r) => requestOf(r)),
205
+ before: more && first !== undefined ? requestCursor(new Date(String(first.started_at)).toISOString(), first.seq === null || first.seq === undefined ? -1 : Number(first.seq)) : null };
206
+ }
207
+ async rows(q) {
208
+ const where = [];
209
+ const values = [];
210
+ const arg = (v, cast = "") => { values.push(v); return `$${values.length}${cast}`; };
211
+ if (q.failed === true)
212
+ where.push("sqlstate IS NOT NULL");
213
+ if (q.outcomes !== undefined && q.outcomes.length > 0)
214
+ where.push(`(${outcomeSql("sqlstate")}) IN (${q.outcomes.map((o) => arg(o)).join(", ")})`);
215
+ if (q.since !== undefined)
216
+ where.push(`started_at >= ${arg(q.since, "::TIMESTAMPTZ")}`);
217
+ if (q.until !== undefined)
218
+ where.push(`started_at < ${arg(q.until, "::TIMESTAMPTZ")}`);
219
+ if (q.user !== undefined)
220
+ where.push(`user_name = ${arg(q.user)}`);
221
+ if (q.client !== undefined)
222
+ where.push(`application_name = ${arg(q.client)}`);
223
+ if (q.minMs !== undefined)
224
+ where.push(`duration_ms >= ${arg(q.minMs)}`);
225
+ // a substring, any case: contains() takes the text as it is (no LIKE pattern to escape); a scan of the kept days, as every filter is
226
+ if (q.search !== undefined)
227
+ where.push(`contains(lower(coalesce(query, '')), lower(${arg(q.search)}))`);
228
+ if (q.before !== undefined) {
229
+ const at = arg(q.before.at, "::TIMESTAMPTZ");
230
+ where.push(`(started_at < ${at} OR (started_at = ${at} AND coalesce(seq, -1) < ${arg(q.before.seq)}))`);
231
+ }
232
+ const limit = arg(q.limit);
233
+ const rows = (await this.history.reader.runAndReadAll(`SELECT * FROM (SELECT * FROM ${REQUESTS_TABLE}${where.length ? ` WHERE ${where.join(" AND ")}` : ""} ` +
234
+ `ORDER BY started_at DESC, coalesce(seq, -1) DESC LIMIT ${limit}) ORDER BY started_at, coalesce(seq, -1)`, values)).getRowObjectsJson();
235
+ return rows;
236
+ }
237
+ /**
238
+ * THE QUERY SUMMARY of `mason status` (`/api/queries`): the statements of the last day and of the last 7 days — how
239
+ * many, how many completed, refused, failed and cancelled, how long the completed ones took (average, p50, p95), the
240
+ * rows and the bytes sent — in ONE scan of the ledger. A refused login (CONNECT) is not a statement. The log keeps
241
+ * `days` days, so a window longer than that is `covered` only as far as it keeps (said, never a short week passed off
242
+ * as a whole one).
243
+ *
244
+ * The scan is ONE GROUP BY — by the window (the last day or before it), the SQLSTATE, and a completed one's duration
245
+ * (whole ms) — whose few groups are added up here: each outcome by `outcomeOf` (the one rule), the average from the
246
+ * durations' sum, and the p50 / p95 from the durations' histogram as quantile_cont interpolates them
247
+ * (`quantileContOf`), exact. Measured over a week of 50 statements a second (30 M rows): four quantile_cont and
248
+ * sixteen FILTERed aggregates over the raw rows took 0.71–0.77 s, this ~0.12 s — and the Overview asks it every 30 s
249
+ * (docs/OBSERVABILITY.md "Architecture review").
250
+ */
251
+ async summary() {
252
+ const groups = (await this.history.reader.runAndReadAll(`SELECT started_at > now() - INTERVAL 1 DAY AS in_day, sqlstate, ` +
253
+ `CASE WHEN ${outcomeSql("sqlstate")} = 'ok' THEN duration_ms END AS ms, count(*) AS n, sum(duration_ms) AS duration, ` +
254
+ `coalesce(sum("rows"), 0) AS sent_rows, coalesce(sum(bytes_sent), 0) AS sent_bytes ` +
255
+ `FROM ${REQUESTS_TABLE} WHERE started_at > now() - INTERVAL 7 DAY AND method <> 'CONNECT' GROUP BY ALL`)).getRowObjectsJson();
256
+ const windowOf = (days) => foldWindow(days === 1 ? groups.filter((g) => g.in_day === true) : groups, days, this.days);
257
+ return { keptDays: this.days, day: windowOf(1), week: windowOf(7) };
258
+ }
259
+ /**
260
+ * ONE WINDOW OF THE REQUEST LOG from `since` (ISO 8601) to now — what the Usage page's cards say for its time range:
261
+ * the statements, each outcome, the rows and bytes sent and the completed ones' durations, as `summary` folds a day,
262
+ * by the same GROUP BY (`foldWindow`). `days` is the window's length in days (a fraction under one), `covered` as far
263
+ * as the log keeps it.
264
+ */
265
+ async window(since, now = Date.now()) {
266
+ const groups = (await this.history.reader.runAndReadAll(`SELECT sqlstate, CASE WHEN ${outcomeSql("sqlstate")} = 'ok' THEN duration_ms END AS ms, count(*) AS n, ` +
267
+ `sum(duration_ms) AS duration, coalesce(sum("rows"), 0) AS sent_rows, coalesce(sum(bytes_sent), 0) AS sent_bytes ` +
268
+ `FROM ${REQUESTS_TABLE} WHERE started_at >= $1::TIMESTAMPTZ AND method <> 'CONNECT' GROUP BY ALL`, [since])).getRowObjectsJson();
269
+ return foldWindow(groups, Math.max(0, (now - Date.parse(since)) / 86_400_000), this.days);
270
+ }
271
+ /**
272
+ * WHO CONNECTED from `since` on, by the statements they sent: a row per user and client (application_name) — its
273
+ * statements, the failed ones (refused or failed, as the Failed card counts them: a cancel is not), the sessions they came from and when the latest started —
274
+ * the most statements first, `limit` of them, and the totals over every one (`total`: how many clients, how many
275
+ * sessions — a card says them, so they are never the first `limit`'s). A session that sent only what the log does not list (select 1, SET, a
276
+ * catalog query) is not here; the live sessions are `/api/state`'s. Committed rows only.
277
+ */
278
+ async clients(since, limit = 50) {
279
+ // the totals over EVERY client, before the LIMIT (a window over the groups): a card says them, never the first `limit`'s
280
+ const rows = (await this.history.reader.runAndReadAll(`SELECT user_name, token_name, application_name, count(*) AS statements, ` +
281
+ `count(*) OVER () AS total_clients, sum(count(DISTINCT session)) OVER () AS total_sessions, ` +
282
+ `count(*) FILTER (WHERE (${outcomeSql("sqlstate")}) IN ('refused', 'failed')) AS failed, count(DISTINCT session) AS sessions, max(started_at) AS last ` +
283
+ `FROM ${REQUESTS_TABLE} WHERE started_at >= $1::TIMESTAMPTZ AND method <> 'CONNECT' GROUP BY ALL ORDER BY statements DESC, last DESC LIMIT $2`, [since, limit])).getRowObjectsJson();
284
+ const first = rows[0];
285
+ return { total: { clients: Number(first?.total_clients ?? 0), sessions: Number(first?.total_sessions ?? 0) },
286
+ clients: rows.map((r) => ({ user: String(r.user_name ?? ""), tokenName: String(r.token_name ?? ""), client: String(r.application_name ?? ""),
287
+ statements: Number(r.statements), failed: Number(r.failed), sessions: Number(r.sessions), last: new Date(String(r.last)).toISOString() })) };
288
+ }
289
+ /**
290
+ * THE COMPLETED STATEMENTS' p50 AND p95 IN BUCKETS of `stepSeconds` from `since` until `until`, by when each started
291
+ * (quantile_cont, as `summary`'s): what a chart of more than a minute a point plots. A bucket's p95 is the p95 of its
292
+ * statements, never the highest of its minutes' p95s — one slow minute in an hour was the hour's "p95". By bucket
293
+ * start, epoch ms; a bucket with no completed statement is absent. Committed rows only.
294
+ */
295
+ async durationsBy(since, until, stepSeconds) {
296
+ const rows = (await this.history.reader.runAndReadAll(`SELECT epoch_ms(time_bucket(to_seconds($3::BIGINT), started_at, $1::TIMESTAMPTZ)) AS at, ` +
297
+ `quantile_cont(duration_ms, 0.5) AS p50, quantile_cont(duration_ms, 0.95) AS p95 FROM ${REQUESTS_TABLE} ` +
298
+ `WHERE started_at >= $1::TIMESTAMPTZ AND started_at < $2::TIMESTAMPTZ AND method <> 'CONNECT' AND (${outcomeSql("sqlstate")}) = 'ok' GROUP BY 1`, [since, until, stepSeconds])).getRowsJS();
299
+ // rounded as the summary's and a sample's are (src/duck/usage.ts quantileCont): one ms, one definition
300
+ return new Map(rows.map(([at, p50, p95]) => [Number(at), { p50: Math.round(Number(p50)), p95: Math.round(Number(p95)) }]));
301
+ }
302
+ /** The slowest `limit` completed statements started from `since` on, slowest first: what ran longest in a time range
303
+ * that is past (the Usage page's analogue of what runs now). Committed rows only. */
304
+ async slowest(since, limit = 10) {
305
+ const rows = (await this.history.reader.runAndReadAll(`SELECT * FROM ${REQUESTS_TABLE} WHERE started_at >= $1::TIMESTAMPTZ AND sqlstate IS NULL AND method <> 'CONNECT' ` +
306
+ `ORDER BY duration_ms DESC, started_at DESC LIMIT $2`, [since, limit])).getRowObjectsJson();
172
307
  return rows.map((r) => requestOf(r));
173
308
  }
174
309
  /**
175
310
  * The last day's slow statements in `mason status`'s shape (src/cli/run-server.ts SlowQuery): each distinct statement
176
- * whose LATEST answer still took `slowMs` or more, slowest first.
311
+ * whose LATEST answer still took `slowMs` or more, slowest first. Only a statement that took it at least once can,
312
+ * so those are found first and only their runs are grouped (a day of 50 statements a second, 4.3 M rows: 0.21 s
313
+ * grouping every statement's text, 0.07 s this way).
177
314
  */
178
315
  async slow(slowMs, limit = 5) {
179
- const rows = (await this.reader.runAndReadAll(`SELECT query, count(*) AS runs, CAST(quantile_disc(duration_ms, 0.5) AS INTEGER) AS median_ms, ` +
316
+ const day = `sqlstate IS NULL AND started_at > now() - INTERVAL 1 DAY AND query <> '' AND NOT query_truncated`;
317
+ const rows = (await this.history.reader.runAndReadAll(`SELECT query, count(*) AS runs, CAST(quantile_disc(duration_ms, 0.5) AS INTEGER) AS median_ms, ` +
180
318
  `max(duration_ms) AS max_ms, arg_max(duration_ms, started_at) AS latest_ms, max(started_at) AS last FROM ${REQUESTS_TABLE} ` +
181
- `WHERE sqlstate IS NULL AND started_at > now() - INTERVAL 1 DAY AND query <> '' AND NOT query_truncated ` +
319
+ `WHERE ${day} AND query IN (SELECT query FROM ${REQUESTS_TABLE} WHERE ${day} AND duration_ms >= $1) ` +
182
320
  `GROUP BY query HAVING arg_max(duration_ms, started_at) >= $1 ORDER BY latest_ms DESC LIMIT $2`, [slowMs, limit])).getRowObjectsJson();
183
321
  return rows.map((r) => ({ route: "postgres", payload: String(r.query), runs: Number(r.runs), medianMs: Number(r.median_ms), maxMs: Number(r.max_ms),
184
322
  latestMs: Number(r.latest_ms), last: new Date(String(r.last)).toISOString() }));
@@ -189,21 +327,59 @@ export class RequestLedger {
189
327
  */
190
328
  async refused(limit = 8) {
191
329
  const day = `sqlstate IS NOT NULL AND started_at > now() - INTERVAL 1 DAY`;
192
- const rows = (await this.reader.runAndReadAll(`SELECT query, count(*) AS runs, max(started_at) AS last, arg_max(sqlstate, started_at) AS code, arg_max(message, started_at) AS message ` +
330
+ const rows = (await this.history.reader.runAndReadAll(`SELECT query, count(*) AS runs, max(started_at) AS last, arg_max(sqlstate, started_at) AS code, arg_max(message, started_at) AS message ` +
193
331
  `FROM ${REQUESTS_TABLE} WHERE ${day} GROUP BY query ORDER BY last DESC LIMIT $1`, [limit])).getRowObjectsJson();
194
- const total = Number((await this.reader.runAndReadAll(`SELECT count(*) FROM ${REQUESTS_TABLE} WHERE ${day}`)).getRowsJS()[0]?.[0] ?? 0);
332
+ const total = Number((await this.history.reader.runAndReadAll(`SELECT count(*) FROM ${REQUESTS_TABLE} WHERE ${day}`)).getRowsJS()[0]?.[0] ?? 0);
195
333
  return { total, refused: rows.map((r) => ({ route: "postgres", payload: String(r.query), runs: Number(r.runs), last: new Date(String(r.last)).toISOString(),
196
334
  code: String(r.code), message: String(r.message ?? "") })) };
197
335
  }
336
+ /**
337
+ * THE REFUSED STATEMENTS GROUPED BY THE UNSUPPORTED SQL THEY USED (`/api/unsupported`, `mason status`): for each
338
+ * registry entry that refused a statement started at or after `since`, how many it refused, by whom (users and
339
+ * clients), when last, and the latest of them — the most refused first, `limit` entries, in ONE query. A statement
340
+ * refused for any other reason (a missing table, a syntax error) has no entry, so it is not here. Committed rows only.
341
+ */
342
+ async unsupported(q) {
343
+ const rows = (await this.history.reader.runAndReadAll(`SELECT gap, count(*) AS runs, ` +
344
+ `coalesce(list_sort(list(DISTINCT user_name) FILTER (WHERE user_name <> '')), []) AS users, ` +
345
+ `coalesce(list_sort(list(DISTINCT application_name) FILTER (WHERE application_name <> '')), []) AS clients, ` +
346
+ `max(started_at) AS last, arg_max(query, started_at) AS example ` +
347
+ `FROM ${REQUESTS_TABLE} WHERE gap IS NOT NULL AND started_at >= $1::TIMESTAMPTZ GROUP BY gap ORDER BY runs DESC, last DESC, gap LIMIT $2`, [q.since, q.limit])).getRowObjectsJson();
348
+ return rows.map((r) => {
349
+ const gap = String(r.gap);
350
+ const query = String(r.example ?? "");
351
+ return { gap, title: Object.hasOwn(GAPS, gap) ? GAPS[gap].what : null, runs: Number(r.runs),
352
+ users: r.users.map(String), clients: r.clients.map(String), last: new Date(String(r.last)).toISOString(),
353
+ example: query.length > UNSUPPORTED_EXAMPLE_CHARS ? `${query.slice(0, UNSUPPORTED_EXAMPLE_CHARS)}…` : query };
354
+ });
355
+ }
198
356
  /** Writes what is waiting and closes the ledger's connections (mason stop). */
199
357
  async close() {
200
- if (this.trimTimer !== null) {
201
- clearInterval(this.trimTimer);
202
- this.trimTimer = null;
203
- }
358
+ this.history.stopTrimming();
204
359
  await this.flush();
205
360
  this.closed = true;
206
- this.writer.closeSync();
207
- this.reader.closeSync();
361
+ this.history.closeConnections();
362
+ }
363
+ /**
364
+ * When each `mason user` last ran a statement, within the log's retention: for each of `users` (a name and the window
365
+ * it was a user in: `since` its addedAt, `until` its removedAt or null), the time of its newest request in that
366
+ * window, ISO 8601, or null when the log has none. The window tells a user from an earlier one of the same name. A
367
+ * refused login (28P01, 57P03) or an ended session (28000) is no use, so it does not count. Committed rows only.
368
+ * ONE scan of the log for every user, JOINED to the users' windows (a hash join on the name, the window a residual),
369
+ * never one per user — nor a `max … FILTER` per user, which evaluated every user's filter on every row: on a week of
370
+ * 50 statements a second (30 M rows) 0.18 s for 6 users and 2.2–2.8 s for 66, where the join is ~0.15 s for any
371
+ * number; the Users page asks it every 30 s (docs/OBSERVABILITY.md "Architecture review").
372
+ */
373
+ async lastUsed(users) {
374
+ if (users.length === 0)
375
+ return [];
376
+ const params = [];
377
+ const at = (v) => { params.push(v); return `$${params.length}`; };
378
+ const windows = users.map((u, i) => `(${at(i)}::INTEGER, ${at(u.name)}::VARCHAR, ${at(u.since)}::TIMESTAMPTZ, ${at(u.until)}::TIMESTAMPTZ)`).join(", ");
379
+ const found = (await this.history.reader.runAndReadAll(`SELECT u.i, epoch_ms(max(r.started_at)) AS at FROM (VALUES ${windows}) AS u(i, name, since, until) ` +
380
+ `JOIN ${REQUESTS_TABLE} r ON r.user_name = u.name AND r.started_at >= u.since AND (u.until IS NULL OR r.started_at <= u.until) ` +
381
+ `WHERE r.sqlstate IS NULL OR r.sqlstate NOT IN ('28P01', '57P03', '28000') GROUP BY u.i`, params)).getRowsJS();
382
+ const row = new Map(found.map(([i, ms]) => [Number(i), ms]));
383
+ return users.map((_, i) => { const ms = row.get(i); return ms === null || ms === undefined ? null : new Date(Number(ms)).toISOString(); });
208
384
  }
209
385
  }