@mason-data/cli 0.6.2 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (119) hide show
  1. package/README.md +147 -69
  2. package/dist/src/api/error.js +3 -0
  3. package/dist/src/api/postgres/catalog.js +258 -14
  4. package/dist/src/api/postgres/encode.js +3 -1
  5. package/dist/src/api/postgres/explain.js +3 -2
  6. package/dist/src/api/postgres/protocol.js +39 -23
  7. package/dist/src/api/postgres/server.js +1290 -223
  8. package/dist/src/api/postgres/translate.js +81 -9
  9. package/dist/src/api/request-log.js +18 -5
  10. package/dist/src/cdc/pg-wire.js +144 -38
  11. package/dist/src/cdc/poll.js +5 -5
  12. package/dist/src/cli/background.js +55 -8
  13. package/dist/src/cli/cancel.js +75 -0
  14. package/dist/src/cli/command.js +5 -3
  15. package/dist/src/cli/commands.js +843 -272
  16. package/dist/src/cli/control.js +59 -0
  17. package/dist/src/cli/demo.js +158 -0
  18. package/dist/src/cli/dev.js +458 -125
  19. package/dist/src/cli/doctor.js +25 -12
  20. package/dist/src/cli/guides.js +103 -31
  21. package/dist/src/cli/init.js +159 -58
  22. package/dist/src/cli/machine.js +18 -12
  23. package/dist/src/cli/main.js +5 -5
  24. package/dist/src/cli/mcp.js +735 -0
  25. package/dist/src/cli/metrics.js +79 -0
  26. package/dist/src/cli/pg-tls.js +13 -3
  27. package/dist/src/cli/pg-url.js +4 -0
  28. package/dist/src/cli/pin.js +13 -12
  29. package/dist/src/cli/preflight.js +24 -14
  30. package/dist/src/cli/project.js +48 -7
  31. package/dist/src/cli/readiness.js +38 -4
  32. package/dist/src/cli/registry.js +57 -35
  33. package/dist/src/cli/replication-checks.js +7 -5
  34. package/dist/src/cli/replication-mode.js +105 -26
  35. package/dist/src/cli/request-filter.js +134 -0
  36. package/dist/src/cli/roles.js +584 -0
  37. package/dist/src/cli/run-server.js +498 -133
  38. package/dist/src/cli/runs.js +1 -1
  39. package/dist/src/cli/security.js +10 -8
  40. package/dist/src/cli/serve.js +147 -40
  41. package/dist/src/cli/share-run.js +286 -0
  42. package/dist/src/cli/share.js +286 -0
  43. package/dist/src/cli/source-edit.js +168 -0
  44. package/dist/src/cli/sources.js +290 -39
  45. package/dist/src/cli/supervise.js +12 -1
  46. package/dist/src/cli/sync.js +55 -25
  47. package/dist/src/cli/tunnel.js +200 -0
  48. package/dist/src/cli/ui.js +9 -6
  49. package/dist/src/cli/usage.js +128 -0
  50. package/dist/src/cli/user.js +496 -40
  51. package/dist/src/cli/version.js +9 -7
  52. package/dist/src/demo/seed.js +240 -0
  53. package/dist/src/demo/source.js +655 -0
  54. package/dist/src/duck/cancel.js +10 -1
  55. package/dist/src/duck/distinct-key.js +228 -0
  56. package/dist/src/duck/events.js +134 -0
  57. package/dist/src/duck/executor-worker.js +25 -11
  58. package/dist/src/duck/executor.js +107 -53
  59. package/dist/src/duck/explain.js +158 -29
  60. package/dist/src/duck/extensions.js +160 -0
  61. package/dist/src/duck/functions.js +73 -19
  62. package/dist/src/duck/gaps.js +25 -11
  63. package/dist/src/duck/grants.js +176 -0
  64. package/dist/src/duck/history.js +123 -0
  65. package/dist/src/duck/jsonb-order.js +175 -0
  66. package/dist/src/duck/jsonb.js +10 -8
  67. package/dist/src/duck/kept-route.js +82 -2
  68. package/dist/src/duck/kept.js +211 -44
  69. package/dist/src/duck/pgtree.js +40 -0
  70. package/dist/src/duck/plan.js +120 -30
  71. package/dist/src/duck/publication.js +6 -6
  72. package/dist/src/duck/requests.js +265 -93
  73. package/dist/src/duck/rewrite.js +1193 -73
  74. package/dist/src/duck/row-filter.js +649 -0
  75. package/dist/src/duck/store.js +271 -159
  76. package/dist/src/duck/stream.js +10 -5
  77. package/dist/src/duck/sync.js +907 -275
  78. package/dist/src/duck/types.js +6 -5
  79. package/dist/src/duck/usage.js +324 -0
  80. package/dist/src/duck/users.js +64 -15
  81. package/dist/src/duck/verified.js +9 -0
  82. package/dist/src/duck/views.js +2 -3
  83. package/dist/src/lib/database-name.js +62 -0
  84. package/dist/src/lib/dotenv.js +17 -4
  85. package/dist/src/lib/log-buffer.js +68 -0
  86. package/dist/src/lib/pg-numeric.js +5 -2
  87. package/dist/src/lib/project-version.js +23 -23
  88. package/dist/src/lib/table-sync.js +267 -0
  89. package/dist/src/lib/timezone.js +3 -2
  90. package/dist/src/web/app.css +706 -55
  91. package/dist/src/web/app.js +330 -258
  92. package/dist/src/web/charts.js +229 -0
  93. package/dist/src/web/index.html +84 -15
  94. package/dist/src/web/pages/queries.js +192 -0
  95. package/dist/src/web/pages/status.js +446 -0
  96. package/dist/src/web/pages/syncs.js +155 -0
  97. package/dist/src/web/pages/tables.js +434 -0
  98. package/dist/src/web/pages/usage.js +397 -0
  99. package/dist/src/web/pages/users.js +218 -0
  100. package/dist/src/web/shared.js +568 -0
  101. package/dist/src/web/vendor/README.md +25 -0
  102. package/dist/src/web/vendor/geist-1.7.2/Geist-Variable.woff2 +0 -0
  103. package/dist/src/web/vendor/geist-1.7.2/GeistMono-Variable.woff2 +0 -0
  104. package/dist/src/web/vendor/geist-1.7.2/LICENSE.txt +92 -0
  105. package/dist/src/web/vendor/lucide-1.56.0/LICENSE.txt +43 -0
  106. package/dist/src/web/vendor/lucide-1.56.0/chart-column.mjs +29 -0
  107. package/dist/src/web/vendor/lucide-1.56.0/layout-dashboard.mjs +29 -0
  108. package/dist/src/web/vendor/lucide-1.56.0/logs.mjs +34 -0
  109. package/dist/src/web/vendor/lucide-1.56.0/menu.mjs +28 -0
  110. package/dist/src/web/vendor/lucide-1.56.0/square-terminal.mjs +28 -0
  111. package/dist/src/web/vendor/lucide-1.56.0/table.mjs +29 -0
  112. package/dist/src/web/vendor/lucide-1.56.0/users.mjs +29 -0
  113. package/dist/src/web/vendor/lucide-1.56.0/x.mjs +27 -0
  114. package/dist/src/web/vendor/uplot-1.6.32/LICENSE.txt +21 -0
  115. package/dist/src/web/vendor/uplot-1.6.32/uPlot.esm.js +6140 -0
  116. package/dist/src/web/vendor/uplot-1.6.32/uPlot.min.css +1 -0
  117. package/dist/src/web/vendor/zilla-slab-5.3.0/LICENSE.txt +93 -0
  118. package/dist/src/web/vendor/zilla-slab-5.3.0/zilla-slab-latin-700-normal.woff2 +0 -0
  119. package/package.json +10 -3
@@ -1,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"];
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,114 @@ 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`;
43
42
  export const REQUESTS_DDL = `CREATE TABLE IF NOT EXISTS ${REQUESTS_TABLE} (request_id VARCHAR, started_at TIMESTAMPTZ, session INTEGER, ` +
44
43
  `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)`;
44
+ `"rows" BIGINT, duration_ms INTEGER, query VARCHAR, query_truncated BOOLEAN, seq BIGINT, json_reads VARCHAR, bytes_sent BIGINT, gap VARCHAR, role VARCHAR, attributes VARCHAR)`;
46
45
  /** an ApiLogRow's values in COLUMNS' order */
47
46
  function valuesOf({ row: r, seq }) {
48
47
  const app = (r.client ?? "").replace(/^postgres:/, "");
49
48
  return [r.requestId, r.at.toISOString(), r.session ?? null, app === "unnamed" ? "" : app, r.tokenName ?? "", r.user ?? "", r.method,
50
49
  r.sqlstate ?? null, r.error === undefined || r.error === "" ? null : r.error, r.detail ?? null, r.hint ?? null,
51
50
  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)];
51
+ r.jsonReads === undefined || r.jsonReads.length === 0 ? null : JSON.stringify(r.jsonReads), r.bytesSent === undefined ? null : r.bytesSent, r.gap ?? null, r.role ?? "",
52
+ r.attributes === undefined || Object.keys(r.attributes).length === 0 ? null : JSON.stringify(r.attributes)];
53
53
  }
54
54
  /** A ledger row as the commands read it. */
55
55
  function requestOf(o) {
56
56
  const text = (v) => v === null || v === undefined ? null : String(v);
57
57
  const sqlstate = text(o.sqlstate);
58
+ const outcome = outcomeOf(sqlstate);
59
+ const startedAt = new Date(String(o.started_at));
58
60
  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,
61
+ requestId: String(o.request_id), startedAt: startedAt.toISOString(), session: o.session === null ? null : Number(o.session),
62
+ applicationName: String(o.application_name ?? ""), tokenName: String(o.token_name ?? ""), user: String(o.user_name ?? ""), role: String(o.role ?? ""),
63
+ attributes: typeof o.attributes === "string" && o.attributes !== "" ? JSON.parse(o.attributes) : null, method: String(o.method),
64
+ outcome, status: statusOf(outcome), sqlstate, message: text(o.message), detail: text(o.detail), hint: text(o.hint),
65
+ rows: o.rows === null ? null : Number(o.rows), durationMs: Number(o.duration_ms),
66
+ endedAt: new Date(startedAt.getTime() + Number(o.duration_ms)).toISOString(), bytesSent: o.bytes_sent === null || o.bytes_sent === undefined ? null : Number(o.bytes_sent),
67
+ query: String(o.query ?? ""), queryTruncated: o.query_truncated === true,
63
68
  };
64
69
  }
70
+ /**
71
+ * 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
72
+ * for a row recorded before rows were numbered) — `2026-10-28T10:00:00.123Z~42`. A reader passes it back as it came.
73
+ */
74
+ export const requestCursor = (at, seq) => `${at}~${seq}`;
75
+ /** A cursor read back (requestCursor), or null when the text is not one. */
76
+ export function parseRequestCursor(text) {
77
+ const m = /^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z)~(-?\d+)$/.exec(text);
78
+ if (m === null || Number.isNaN(Date.parse(m[1])))
79
+ return null;
80
+ return { at: m[1], seq: Number(m[2]) };
81
+ }
82
+ /** how much of a statement `unsupported` shows as its example */
83
+ export const UNSUPPORTED_EXAMPLE_CHARS = 1000;
84
+ /**
85
+ * A WINDOW FOLDED FROM THE GROUPS of a scan by SQLSTATE and a completed one's duration (`summary`, `window`): each
86
+ * outcome by `outcomeOf` (the one rule), the average from the durations' sum, the p50 / p95 from the durations'
87
+ * histogram as quantile_cont interpolates them (`quantileContOf`). ONE place adds a window up.
88
+ */
89
+ function foldWindow(groups, days, keptDays) {
90
+ const w = { days, covered: Math.min(days, keptDays), statements: 0, completed: 0, refused: 0, failed: 0, cancelled: 0, rows: 0, bytesSent: 0, durationMs: null };
91
+ const histogram = [];
92
+ let duration = 0;
93
+ for (const g of groups) {
94
+ const n = Number(g.n);
95
+ w.statements += n;
96
+ w.rows += Number(g.sent_rows);
97
+ w.bytesSent += Number(g.sent_bytes);
98
+ const outcome = outcomeOf(g.sqlstate === null ? null : String(g.sqlstate));
99
+ if (outcome === "ok") {
100
+ w.completed += n;
101
+ duration += Number(g.duration);
102
+ histogram.push([Number(g.ms), n]);
103
+ }
104
+ else
105
+ w[outcome] += n;
106
+ }
107
+ histogram.sort((a, b) => a[0] - b[0]);
108
+ const p50 = quantileContOf(histogram, 0.5), p95 = quantileContOf(histogram, 0.95);
109
+ if (w.completed > 0 && p50 !== null && p95 !== null)
110
+ w.durationMs = { avg: Math.round(duration / w.completed), p50, p95 };
111
+ return w;
112
+ }
65
113
  export class RequestLedger {
66
- buffer = [];
67
114
  /** the next row's number (REQUESTS_SEQ_DDL): one past the ledger's last at open, then one per request recorded */
68
115
  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
116
  /** rows lost since the last flush that succeeded: said once when the streak begins, and when it ends */
74
- lost = 0;
117
+ lost = new Streak();
75
118
  closed = false;
76
- writer;
77
- reader;
119
+ history;
120
+ batches;
78
121
  log;
79
122
  options;
80
123
  /** days kept */
81
124
  days;
82
- constructor(writer, reader, log, options) {
83
- this.writer = writer;
84
- this.reader = reader;
125
+ constructor(history, log, options) {
126
+ this.history = history;
85
127
  this.log = log;
86
128
  this.options = options;
87
129
  this.days = options.days;
130
+ this.batches = new Batches({ rows: BATCH_ROWS, ms: options.flushMs ?? FLUSH_MS, write: (rows) => this.write(rows) });
88
131
  }
89
132
  /** The ledger of a store: its table made, the rows past the retention deleted, and a trim every hour. */
90
133
  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();
134
+ 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]);
135
+ const ledger = new RequestLedger(history, log, options);
136
+ ledger.next = Number((await history.reader.runAndReadAll(`SELECT coalesce(max(seq), -1) + 1 FROM ${REQUESTS_TABLE}`)).getRowsJS()[0][0]);
137
+ await history.keepTrimmed(() => ledger.trim(), (e) => log(`request log: deleting the requests older than ${ledger.days} days failed: ${e.message}`));
105
138
  return ledger;
106
139
  }
107
140
  /** One request, written with the next batch. Never throws: the statement it records was already answered. */
@@ -111,26 +144,10 @@ export class RequestLedger {
111
144
  this.log(`request log: a request after the log closed, not recorded (${row.requestId})`);
112
145
  return;
113
146
  }
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);
147
+ this.batches.push({ row, seq: this.next++ });
120
148
  }
121
149
  /** 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
- }
150
+ flush() { return this.batches.flush(); }
134
151
  async write(rows) {
135
152
  const width = COLUMNS.length;
136
153
  const sql = `INSERT INTO ${REQUESTS_TABLE} (${COLUMNS.map((c) => c === "rows" ? `"rows"` : c).join(", ")}) VALUES ` +
@@ -140,45 +157,162 @@ export class RequestLedger {
140
157
  if (this.options.insert !== undefined)
141
158
  await this.options.insert(sql, values);
142
159
  else
143
- await this.writer.run(sql, values);
160
+ await this.history.writer.run(sql, values);
144
161
  }
145
162
  catch (e) {
146
163
  // Tolerated, narrowly, as the ClickHouse writer tolerates it: the answers are sent, so there is no statement left
147
164
  // to fail, and a ledger that cannot write must not take the listener down. SAID, once per streak with the rows it
148
165
  // 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;
166
+ this.lost.failed(rows.length, () => this.log(`request log: could not record ${rows.length} request(s) in ${REQUESTS_TABLE} (${rows[0].row.requestId}` +
167
+ `${rows.length > 1 ? ` … ${rows[rows.length - 1].row.requestId}` : ""}): ${e.message}; said once until a write succeeds`));
153
168
  return;
154
169
  }
155
- if (this.lost > 0) {
156
- this.log(`request log: recording again; ${this.lost} request(s) were not recorded`);
157
- this.lost = 0;
158
- }
170
+ this.lost.succeeded((n) => this.log(`request log: recording again; ${n} request(s) were not recorded`));
159
171
  }
160
172
  /** Deletes the requests older than the retention; the count is said when there were any. */
161
173
  async trim() {
162
- const r = await this.writer.runAndReadAll(`DELETE FROM ${REQUESTS_TABLE} WHERE started_at < now() - INTERVAL (${this.days}) DAY`);
174
+ const r = await this.history.writer.runAndReadAll(`DELETE FROM ${REQUESTS_TABLE} WHERE started_at < now() - INTERVAL (${this.days}) DAY`);
163
175
  const n = Number(r.getRowsJS()[0]?.[0] ?? 0);
164
176
  if (n > 0)
165
- this.log(`request log: deleted ${n} request(s) older than ${this.days} days (MASON_REQUEST_LOG_DAYS)`);
177
+ this.log(`request log: deleted ${n} request(s) older than ${this.days} days (MASON_HISTORY_DAYS)`);
166
178
  return n;
167
179
  }
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();
180
+ /**
181
+ * The newest `limit` requests that pass the filters, newest LAST. `failed`: the ones not answered (refused, failed or
182
+ * cancelled); `outcomes`, `since` / `until` (started at or after / before), `user`, `client` (application_name) and
183
+ * `before` (strictly older than that position: a page's `before`) narrow it further. Committed rows only.
184
+ */
185
+ async recent(q) {
186
+ return (await this.rows(q)).map((r) => requestOf(r));
187
+ }
188
+ /**
189
+ * ONE PAGE of the request log (`/api/requests`, `mason logs --requests`): `recent`, and `before`, the cursor of the
190
+ * page's oldest row when an older one passes the filters (null: this is the last page). Pages follow the ledger's
191
+ * order (started_at, then seq), so the page asked with `before` holds the rows older than the page before it, whatever
192
+ * was recorded since — but a statement still running when a page was read is recorded at its START, so it may land in
193
+ * a page already read.
194
+ */
195
+ async page(q) {
196
+ const rows = await this.rows({ ...q, limit: q.limit + 1 });
197
+ const more = rows.length > q.limit;
198
+ const shown = more ? rows.slice(1) : rows;
199
+ const first = shown[0];
200
+ return { requests: shown.map((r) => requestOf(r)),
201
+ before: more && first !== undefined ? requestCursor(new Date(String(first.started_at)).toISOString(), first.seq === null || first.seq === undefined ? -1 : Number(first.seq)) : null };
202
+ }
203
+ async rows(q) {
204
+ const where = [];
205
+ const values = [];
206
+ const arg = (v, cast = "") => { values.push(v); return `$${values.length}${cast}`; };
207
+ if (q.failed === true)
208
+ where.push("sqlstate IS NOT NULL");
209
+ if (q.outcomes !== undefined && q.outcomes.length > 0)
210
+ where.push(`(${outcomeSql("sqlstate")}) IN (${q.outcomes.map((o) => arg(o)).join(", ")})`);
211
+ if (q.since !== undefined)
212
+ where.push(`started_at >= ${arg(q.since, "::TIMESTAMPTZ")}`);
213
+ if (q.until !== undefined)
214
+ where.push(`started_at < ${arg(q.until, "::TIMESTAMPTZ")}`);
215
+ if (q.user !== undefined)
216
+ where.push(`user_name = ${arg(q.user)}`);
217
+ if (q.client !== undefined)
218
+ where.push(`application_name = ${arg(q.client)}`);
219
+ if (q.minMs !== undefined)
220
+ where.push(`duration_ms >= ${arg(q.minMs)}`);
221
+ // 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
222
+ if (q.search !== undefined)
223
+ where.push(`contains(lower(coalesce(query, '')), lower(${arg(q.search)}))`);
224
+ if (q.before !== undefined) {
225
+ const at = arg(q.before.at, "::TIMESTAMPTZ");
226
+ where.push(`(started_at < ${at} OR (started_at = ${at} AND coalesce(seq, -1) < ${arg(q.before.seq)}))`);
227
+ }
228
+ const limit = arg(q.limit);
229
+ const rows = (await this.history.reader.runAndReadAll(`SELECT * FROM (SELECT * FROM ${REQUESTS_TABLE}${where.length ? ` WHERE ${where.join(" AND ")}` : ""} ` +
230
+ `ORDER BY started_at DESC, coalesce(seq, -1) DESC LIMIT ${limit}) ORDER BY started_at, coalesce(seq, -1)`, values)).getRowObjectsJson();
231
+ return rows;
232
+ }
233
+ /**
234
+ * THE QUERY SUMMARY of `mason status` (`/api/queries`): the statements of the last day and of the last 7 days — how
235
+ * many, how many completed, refused, failed and cancelled, how long the completed ones took (average, p50, p95), the
236
+ * rows and the bytes sent — in ONE scan of the ledger. A refused login (CONNECT) is not a statement. The log keeps
237
+ * `days` days, so a window longer than that is `covered` only as far as it keeps (said, never a short week passed off
238
+ * as a whole one).
239
+ *
240
+ * The scan is ONE GROUP BY — by the window (the last day or before it), the SQLSTATE, and a completed one's duration
241
+ * (whole ms) — whose few groups are added up here: each outcome by `outcomeOf` (the one rule), the average from the
242
+ * durations' sum, and the p50 / p95 from the durations' histogram as quantile_cont interpolates them
243
+ * (`quantileContOf`), exact. Measured over a week of 50 statements a second (30 M rows): four quantile_cont and
244
+ * 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
245
+ * (docs/OBSERVABILITY.md "Architecture review").
246
+ */
247
+ async summary() {
248
+ const groups = (await this.history.reader.runAndReadAll(`SELECT started_at > now() - INTERVAL 1 DAY AS in_day, sqlstate, ` +
249
+ `CASE WHEN ${outcomeSql("sqlstate")} = 'ok' THEN duration_ms END AS ms, count(*) AS n, sum(duration_ms) AS duration, ` +
250
+ `coalesce(sum("rows"), 0) AS sent_rows, coalesce(sum(bytes_sent), 0) AS sent_bytes ` +
251
+ `FROM ${REQUESTS_TABLE} WHERE started_at > now() - INTERVAL 7 DAY AND method <> 'CONNECT' GROUP BY ALL`)).getRowObjectsJson();
252
+ const windowOf = (days) => foldWindow(days === 1 ? groups.filter((g) => g.in_day === true) : groups, days, this.days);
253
+ return { keptDays: this.days, day: windowOf(1), week: windowOf(7) };
254
+ }
255
+ /**
256
+ * ONE WINDOW OF THE REQUEST LOG from `since` (ISO 8601) to now — what the Usage page's cards say for its time range:
257
+ * the statements, each outcome, the rows and bytes sent and the completed ones' durations, as `summary` folds a day,
258
+ * by the same GROUP BY (`foldWindow`). `days` is the window's length in days (a fraction under one), `covered` as far
259
+ * as the log keeps it.
260
+ */
261
+ async window(since, now = Date.now()) {
262
+ const groups = (await this.history.reader.runAndReadAll(`SELECT sqlstate, CASE WHEN ${outcomeSql("sqlstate")} = 'ok' THEN duration_ms END AS ms, count(*) AS n, ` +
263
+ `sum(duration_ms) AS duration, coalesce(sum("rows"), 0) AS sent_rows, coalesce(sum(bytes_sent), 0) AS sent_bytes ` +
264
+ `FROM ${REQUESTS_TABLE} WHERE started_at >= $1::TIMESTAMPTZ AND method <> 'CONNECT' GROUP BY ALL`, [since])).getRowObjectsJson();
265
+ return foldWindow(groups, Math.max(0, (now - Date.parse(since)) / 86_400_000), this.days);
266
+ }
267
+ /**
268
+ * WHO CONNECTED from `since` on, by the statements they sent: a row per user and client (application_name) — its
269
+ * 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 —
270
+ * the most statements first, `limit` of them, and the totals over every one (`total`: how many clients, how many
271
+ * 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
272
+ * catalog query) is not here; the live sessions are `/api/state`'s. Committed rows only.
273
+ */
274
+ async clients(since, limit = 50) {
275
+ // the totals over EVERY client, before the LIMIT (a window over the groups): a card says them, never the first `limit`'s
276
+ const rows = (await this.history.reader.runAndReadAll(`SELECT user_name, token_name, application_name, count(*) AS statements, ` +
277
+ `count(*) OVER () AS total_clients, sum(count(DISTINCT session)) OVER () AS total_sessions, ` +
278
+ `count(*) FILTER (WHERE (${outcomeSql("sqlstate")}) IN ('refused', 'failed')) AS failed, count(DISTINCT session) AS sessions, max(started_at) AS last ` +
279
+ `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();
280
+ const first = rows[0];
281
+ return { total: { clients: Number(first?.total_clients ?? 0), sessions: Number(first?.total_sessions ?? 0) },
282
+ clients: rows.map((r) => ({ user: String(r.user_name ?? ""), tokenName: String(r.token_name ?? ""), client: String(r.application_name ?? ""),
283
+ statements: Number(r.statements), failed: Number(r.failed), sessions: Number(r.sessions), last: new Date(String(r.last)).toISOString() })) };
284
+ }
285
+ /**
286
+ * THE COMPLETED STATEMENTS' p50 AND p95 IN BUCKETS of `stepSeconds` from `since` until `until`, by when each started
287
+ * (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
288
+ * statements, never the highest of its minutes' p95s — one slow minute in an hour was the hour's "p95". By bucket
289
+ * start, epoch ms; a bucket with no completed statement is absent. Committed rows only.
290
+ */
291
+ async durationsBy(since, until, stepSeconds) {
292
+ const rows = (await this.history.reader.runAndReadAll(`SELECT epoch_ms(time_bucket(to_seconds($3::BIGINT), started_at, $1::TIMESTAMPTZ)) AS at, ` +
293
+ `quantile_cont(duration_ms, 0.5) AS p50, quantile_cont(duration_ms, 0.95) AS p95 FROM ${REQUESTS_TABLE} ` +
294
+ `WHERE started_at >= $1::TIMESTAMPTZ AND started_at < $2::TIMESTAMPTZ AND method <> 'CONNECT' AND (${outcomeSql("sqlstate")}) = 'ok' GROUP BY 1`, [since, until, stepSeconds])).getRowsJS();
295
+ // rounded as the summary's and a sample's are (src/duck/usage.ts quantileCont): one ms, one definition
296
+ return new Map(rows.map(([at, p50, p95]) => [Number(at), { p50: Math.round(Number(p50)), p95: Math.round(Number(p95)) }]));
297
+ }
298
+ /** The slowest `limit` completed statements started from `since` on, slowest first: what ran longest in a time range
299
+ * that is past (the Usage page's analogue of what runs now). Committed rows only. */
300
+ async slowest(since, limit = 10) {
301
+ const rows = (await this.history.reader.runAndReadAll(`SELECT * FROM ${REQUESTS_TABLE} WHERE started_at >= $1::TIMESTAMPTZ AND sqlstate IS NULL AND method <> 'CONNECT' ` +
302
+ `ORDER BY duration_ms DESC, started_at DESC LIMIT $2`, [since, limit])).getRowObjectsJson();
172
303
  return rows.map((r) => requestOf(r));
173
304
  }
174
305
  /**
175
306
  * 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.
307
+ * whose LATEST answer still took `slowMs` or more, slowest first. Only a statement that took it at least once can,
308
+ * 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
309
+ * grouping every statement's text, 0.07 s this way).
177
310
  */
178
311
  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, ` +
312
+ const day = `sqlstate IS NULL AND started_at > now() - INTERVAL 1 DAY AND query <> '' AND NOT query_truncated`;
313
+ 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
314
  `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 ` +
315
+ `WHERE ${day} AND query IN (SELECT query FROM ${REQUESTS_TABLE} WHERE ${day} AND duration_ms >= $1) ` +
182
316
  `GROUP BY query HAVING arg_max(duration_ms, started_at) >= $1 ORDER BY latest_ms DESC LIMIT $2`, [slowMs, limit])).getRowObjectsJson();
183
317
  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
318
  latestMs: Number(r.latest_ms), last: new Date(String(r.last)).toISOString() }));
@@ -189,21 +323,59 @@ export class RequestLedger {
189
323
  */
190
324
  async refused(limit = 8) {
191
325
  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 ` +
326
+ 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
327
  `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);
328
+ const total = Number((await this.history.reader.runAndReadAll(`SELECT count(*) FROM ${REQUESTS_TABLE} WHERE ${day}`)).getRowsJS()[0]?.[0] ?? 0);
195
329
  return { total, refused: rows.map((r) => ({ route: "postgres", payload: String(r.query), runs: Number(r.runs), last: new Date(String(r.last)).toISOString(),
196
330
  code: String(r.code), message: String(r.message ?? "") })) };
197
331
  }
332
+ /**
333
+ * THE REFUSED STATEMENTS GROUPED BY THE UNSUPPORTED SQL THEY USED (`/api/unsupported`, `mason status`): for each
334
+ * registry entry that refused a statement started at or after `since`, how many it refused, by whom (users and
335
+ * clients), when last, and the latest of them — the most refused first, `limit` entries, in ONE query. A statement
336
+ * refused for any other reason (a missing table, a syntax error) has no entry, so it is not here. Committed rows only.
337
+ */
338
+ async unsupported(q) {
339
+ const rows = (await this.history.reader.runAndReadAll(`SELECT gap, count(*) AS runs, ` +
340
+ `coalesce(list_sort(list(DISTINCT user_name) FILTER (WHERE user_name <> '')), []) AS users, ` +
341
+ `coalesce(list_sort(list(DISTINCT application_name) FILTER (WHERE application_name <> '')), []) AS clients, ` +
342
+ `max(started_at) AS last, arg_max(query, started_at) AS example ` +
343
+ `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();
344
+ return rows.map((r) => {
345
+ const gap = String(r.gap);
346
+ const query = String(r.example ?? "");
347
+ return { gap, title: Object.hasOwn(GAPS, gap) ? GAPS[gap].what : null, runs: Number(r.runs),
348
+ users: r.users.map(String), clients: r.clients.map(String), last: new Date(String(r.last)).toISOString(),
349
+ example: query.length > UNSUPPORTED_EXAMPLE_CHARS ? `${query.slice(0, UNSUPPORTED_EXAMPLE_CHARS)}…` : query };
350
+ });
351
+ }
198
352
  /** Writes what is waiting and closes the ledger's connections (mason stop). */
199
353
  async close() {
200
- if (this.trimTimer !== null) {
201
- clearInterval(this.trimTimer);
202
- this.trimTimer = null;
203
- }
354
+ this.history.stopTrimming();
204
355
  await this.flush();
205
356
  this.closed = true;
206
- this.writer.closeSync();
207
- this.reader.closeSync();
357
+ this.history.closeConnections();
358
+ }
359
+ /**
360
+ * When each `mason user` last ran a statement, within the log's retention: for each of `users` (a name and the window
361
+ * it was a user in: `since` its addedAt, `until` its removedAt or null), the time of its newest request in that
362
+ * window, ISO 8601, or null when the log has none. The window tells a user from an earlier one of the same name. A
363
+ * refused login (28P01, 57P03) or an ended session (28000) is no use, so it does not count. Committed rows only.
364
+ * ONE scan of the log for every user, JOINED to the users' windows (a hash join on the name, the window a residual),
365
+ * never one per user — nor a `max … FILTER` per user, which evaluated every user's filter on every row: on a week of
366
+ * 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
367
+ * number; the Users page asks it every 30 s (docs/OBSERVABILITY.md "Architecture review").
368
+ */
369
+ async lastUsed(users) {
370
+ if (users.length === 0)
371
+ return [];
372
+ const params = [];
373
+ const at = (v) => { params.push(v); return `$${params.length}`; };
374
+ const windows = users.map((u, i) => `(${at(i)}::INTEGER, ${at(u.name)}::VARCHAR, ${at(u.since)}::TIMESTAMPTZ, ${at(u.until)}::TIMESTAMPTZ)`).join(", ");
375
+ 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) ` +
376
+ `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) ` +
377
+ `WHERE r.sqlstate IS NULL OR r.sqlstate NOT IN ('28P01', '57P03', '28000') GROUP BY u.i`, params)).getRowsJS();
378
+ const row = new Map(found.map(([i, ms]) => [Number(i), ms]));
379
+ return users.map((_, i) => { const ms = row.get(i); return ms === null || ms === undefined ? null : new Date(Number(ms)).toISOString(); });
208
380
  }
209
381
  }