@mason-data/cli 0.7.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +90 -71
- package/dist/src/api/error.js +3 -0
- package/dist/src/api/postgres/catalog.js +222 -13
- package/dist/src/api/postgres/encode.js +3 -1
- package/dist/src/api/postgres/explain.js +3 -2
- package/dist/src/api/postgres/protocol.js +39 -23
- package/dist/src/api/postgres/server.js +1173 -207
- package/dist/src/api/postgres/translate.js +81 -9
- package/dist/src/api/request-log.js +18 -5
- package/dist/src/cdc/poll.js +5 -5
- package/dist/src/cli/background.js +29 -6
- package/dist/src/cli/cancel.js +75 -0
- package/dist/src/cli/command.js +1 -0
- package/dist/src/cli/commands.js +622 -183
- package/dist/src/cli/control.js +59 -0
- package/dist/src/cli/demo.js +19 -10
- package/dist/src/cli/dev.js +351 -111
- package/dist/src/cli/doctor.js +20 -10
- package/dist/src/cli/guides.js +79 -31
- package/dist/src/cli/init.js +119 -58
- package/dist/src/cli/machine.js +12 -11
- package/dist/src/cli/main.js +3 -3
- package/dist/src/cli/mcp.js +29 -20
- package/dist/src/cli/metrics.js +79 -0
- package/dist/src/cli/pg-tls.js +13 -3
- package/dist/src/cli/pg-url.js +4 -0
- package/dist/src/cli/pin.js +13 -12
- package/dist/src/cli/preflight.js +8 -7
- package/dist/src/cli/project.js +43 -4
- package/dist/src/cli/readiness.js +38 -4
- package/dist/src/cli/registry.js +53 -34
- package/dist/src/cli/replication-checks.js +7 -5
- package/dist/src/cli/replication-mode.js +105 -26
- package/dist/src/cli/request-filter.js +134 -0
- package/dist/src/cli/roles.js +584 -0
- package/dist/src/cli/run-server.js +483 -41
- package/dist/src/cli/runs.js +1 -1
- package/dist/src/cli/security.js +10 -8
- package/dist/src/cli/serve.js +144 -42
- package/dist/src/cli/share-run.js +286 -0
- package/dist/src/cli/share.js +286 -0
- package/dist/src/cli/source-edit.js +65 -13
- package/dist/src/cli/sources.js +287 -38
- package/dist/src/cli/supervise.js +12 -1
- package/dist/src/cli/sync.js +55 -25
- package/dist/src/cli/tunnel.js +200 -0
- package/dist/src/cli/ui.js +9 -6
- package/dist/src/cli/usage.js +128 -0
- package/dist/src/cli/user.js +496 -40
- package/dist/src/cli/version.js +9 -7
- package/dist/src/demo/seed.js +3 -0
- package/dist/src/duck/cancel.js +10 -1
- package/dist/src/duck/events.js +134 -0
- package/dist/src/duck/executor-worker.js +18 -9
- package/dist/src/duck/executor.js +43 -30
- package/dist/src/duck/explain.js +56 -46
- package/dist/src/duck/extensions.js +160 -0
- package/dist/src/duck/functions.js +13 -14
- package/dist/src/duck/gaps.js +17 -8
- package/dist/src/duck/grants.js +176 -0
- package/dist/src/duck/history.js +123 -0
- package/dist/src/duck/kept.js +6 -6
- package/dist/src/duck/plan.js +102 -25
- package/dist/src/duck/publication.js +6 -6
- package/dist/src/duck/requests.js +265 -93
- package/dist/src/duck/rewrite.js +25 -5
- package/dist/src/duck/row-filter.js +649 -0
- package/dist/src/duck/store.js +156 -120
- package/dist/src/duck/stream.js +10 -5
- package/dist/src/duck/sync.js +835 -268
- package/dist/src/duck/types.js +6 -5
- package/dist/src/duck/usage.js +324 -0
- package/dist/src/duck/users.js +64 -15
- package/dist/src/duck/verified.js +3 -0
- package/dist/src/duck/views.js +2 -3
- package/dist/src/lib/database-name.js +62 -0
- package/dist/src/lib/dotenv.js +17 -4
- package/dist/src/lib/log-buffer.js +68 -0
- package/dist/src/lib/pg-numeric.js +5 -2
- package/dist/src/lib/project-version.js +23 -23
- package/dist/src/lib/table-sync.js +267 -0
- package/dist/src/web/app.css +706 -66
- package/dist/src/web/app.js +327 -410
- package/dist/src/web/charts.js +229 -0
- package/dist/src/web/index.html +82 -18
- package/dist/src/web/pages/queries.js +192 -0
- package/dist/src/web/pages/status.js +446 -0
- package/dist/src/web/pages/syncs.js +155 -0
- package/dist/src/web/pages/tables.js +434 -0
- package/dist/src/web/pages/usage.js +397 -0
- package/dist/src/web/pages/users.js +218 -0
- package/dist/src/web/shared.js +568 -0
- package/dist/src/web/vendor/README.md +25 -0
- package/dist/src/web/vendor/geist-1.7.2/Geist-Variable.woff2 +0 -0
- package/dist/src/web/vendor/geist-1.7.2/GeistMono-Variable.woff2 +0 -0
- package/dist/src/web/vendor/geist-1.7.2/LICENSE.txt +92 -0
- package/dist/src/web/vendor/lucide-1.56.0/LICENSE.txt +43 -0
- package/dist/src/web/vendor/lucide-1.56.0/chart-column.mjs +29 -0
- package/dist/src/web/vendor/lucide-1.56.0/layout-dashboard.mjs +29 -0
- package/dist/src/web/vendor/lucide-1.56.0/logs.mjs +34 -0
- package/dist/src/web/vendor/lucide-1.56.0/menu.mjs +28 -0
- package/dist/src/web/vendor/lucide-1.56.0/square-terminal.mjs +28 -0
- package/dist/src/web/vendor/lucide-1.56.0/table.mjs +29 -0
- package/dist/src/web/vendor/lucide-1.56.0/users.mjs +29 -0
- package/dist/src/web/vendor/lucide-1.56.0/x.mjs +27 -0
- package/dist/src/web/vendor/uplot-1.6.32/LICENSE.txt +21 -0
- package/dist/src/web/vendor/uplot-1.6.32/uPlot.esm.js +6140 -0
- package/dist/src/web/vendor/uplot-1.6.32/uPlot.min.css +1 -0
- package/dist/src/web/vendor/zilla-slab-5.3.0/LICENSE.txt +93 -0
- package/dist/src/web/vendor/zilla-slab-5.3.0/zilla-slab-latin-700-normal.woff2 +0 -0
- 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:
|
|
60
|
-
applicationName: String(o.application_name ?? ""), tokenName: String(o.token_name ?? ""), user: String(o.user_name ?? ""),
|
|
61
|
-
|
|
62
|
-
|
|
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 =
|
|
117
|
+
lost = new Streak();
|
|
75
118
|
closed = false;
|
|
76
|
-
|
|
77
|
-
|
|
119
|
+
history;
|
|
120
|
+
batches;
|
|
78
121
|
log;
|
|
79
122
|
options;
|
|
80
123
|
/** days kept */
|
|
81
124
|
days;
|
|
82
|
-
constructor(
|
|
83
|
-
this.
|
|
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
|
|
92
|
-
|
|
93
|
-
await
|
|
94
|
-
await
|
|
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.
|
|
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
|
-
|
|
150
|
-
|
|
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
|
-
|
|
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 (
|
|
177
|
+
this.log(`request log: deleted ${n} request(s) older than ${this.days} days (MASON_HISTORY_DAYS)`);
|
|
166
178
|
return n;
|
|
167
179
|
}
|
|
168
|
-
/**
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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.
|
|
207
|
-
|
|
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
|
}
|
package/dist/src/duck/rewrite.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { pathIndex, SESSION_VARIABLE } from "./functions.js";
|
|
1
|
+
import { pathIndex, SESSION_VARIABLE, SETTINGS_VARIABLE } from "./functions.js";
|
|
2
2
|
import { cellsOf as cellsOfProgram, numericLiteral } from "../lib/pg-numeric.js";
|
|
3
3
|
import { caseTwins, joinsUsing } from "./pgtree.js";
|
|
4
4
|
import { canonicalRead, EQUAL_STEPS, KEY_NULL_HEX, KEY_RECOMPUTE_HEX, MARKED_FUNCTIONS, markedError, ORDER_STEPS, readKey } from "./kept-route.js";
|
|
@@ -26,12 +26,12 @@ export function duckCollation(provider, collate, locale, known) {
|
|
|
26
26
|
if (provider === "i") {
|
|
27
27
|
const name = icuName(locale ?? collate);
|
|
28
28
|
if (name === null)
|
|
29
|
-
throw new Error(`the source database's ICU locale ${JSON.stringify(locale ?? collate)} has no
|
|
29
|
+
throw new Error(`the source database's ICU locale ${JSON.stringify(locale ?? collate)} has no collation in the engine: Mason cannot sort text as it does`);
|
|
30
30
|
return { kind: "icu", name };
|
|
31
31
|
}
|
|
32
32
|
const name = icuName(collate);
|
|
33
33
|
if (name === null)
|
|
34
|
-
throw new Error(`the source database's collation ${JSON.stringify(collate)} has no
|
|
34
|
+
throw new Error(`the source database's collation ${JSON.stringify(collate)} has no collation in the engine to follow: Mason cannot sort text as it does`);
|
|
35
35
|
return { kind: "libc", icu: name };
|
|
36
36
|
}
|
|
37
37
|
export class Unrewritable extends Error {
|
|
@@ -739,7 +739,7 @@ class Rewriter {
|
|
|
739
739
|
if (table !== undefined)
|
|
740
740
|
return table.map((c) => ({ name: c.name, value: this.typedRef([alias, c.name], c.type, c.typmod), type: c.type }));
|
|
741
741
|
if (t.type !== RECORD)
|
|
742
|
-
throw new Unrewritable(`a row of ${this.userTypes.get(t.type)?.replace(/^rowtype:/, "") ?? "a table"} is not supported by Mason: Mason
|
|
742
|
+
throw new Unrewritable(`a row of ${this.userTypes.get(t.type)?.replace(/^rowtype:/, "") ?? "a table"} is not supported by Mason: Mason synced the table in part`, "name its columns instead", "row-value");
|
|
743
743
|
const source = this.fromSources(select).filter((q) => q.alias === alias);
|
|
744
744
|
if (source.length !== 1)
|
|
745
745
|
throw new Unrewritable("a row of this FROM item is not supported by Mason", "name its columns instead", "row-value");
|
|
@@ -1045,7 +1045,7 @@ class Rewriter {
|
|
|
1045
1045
|
*/
|
|
1046
1046
|
withTies(b) {
|
|
1047
1047
|
if (b.distinctClause !== undefined)
|
|
1048
|
-
throw new Unrewritable("FETCH … WITH TIES over a SELECT DISTINCT is not supported by Mason (
|
|
1048
|
+
throw new Unrewritable("FETCH … WITH TIES over a SELECT DISTINCT is not supported by Mason (the engine has no WITH TIES)", "select the DISTINCT rows in a subquery and FETCH … WITH TIES from it", "with-ties-distinct");
|
|
1049
1049
|
const order = b.sortClause ?? [];
|
|
1050
1050
|
const count = b.limitCount;
|
|
1051
1051
|
delete b.limitCount;
|
|
@@ -2922,6 +2922,8 @@ class Rewriter {
|
|
|
2922
2922
|
case "family": return argType(0) === INET && args.length === 1
|
|
2923
2923
|
? caseWhen([[{ NullTest: { arg: this.cp(args[0]), nulltesttype: "IS_NULL" } }, nullConst()], [fn("contains", [args[0], strConst(":")]), intConst(6)]], intConst(4)) : null;
|
|
2924
2924
|
case "abbrev": return argType(0) === INET && args.length === 1 ? inetOut(args[0]) : null;
|
|
2925
|
+
// the session's setting, never DuckDB's own (`settingRead`)
|
|
2926
|
+
case "current_setting": return args.length === 1 || args.length === 2 ? settingRead(this.cp, args) : null;
|
|
2925
2927
|
// the transaction's start, as of the execution (sqlValueFunction says why)
|
|
2926
2928
|
case "now":
|
|
2927
2929
|
case "transaction_timestamp": return args.length === 0 ? this.now("exec") : null;
|
|
@@ -4713,6 +4715,24 @@ function replacement(r) {
|
|
|
4713
4715
|
return strConst(s.replace(/\\(.)/g, (_m, c) => c === "&" ? "\\0" : `\\${c}`));
|
|
4714
4716
|
}
|
|
4715
4717
|
/** substring(text FROM pattern): the first parenthesised part when there is one, else the whole match; NULL without one */
|
|
4718
|
+
/**
|
|
4719
|
+
* `current_setting(name[, missing_ok])` BESIDE A COPIED TABLE IS THE SESSION'S (GitHub issue 40): the name read, in any
|
|
4720
|
+
* case, from the settings the session's statement runs under (`SETTINGS_VARIABLE`: src/api/postgres/server.ts
|
|
4721
|
+
* `duckSession`, the listener's `sessionSetting` laid over the catalog Postgres's own, set by src/duck/store.ts
|
|
4722
|
+
* `settle`) — never DuckDB's own `current_setting`, which answered DuckDB's settings (`search_path` its schemas,
|
|
4723
|
+
* `work_mem` an error). A name neither holds is Postgres's 42704, or NULL under missing_ok; strict, as Postgres's.
|
|
4724
|
+
*/
|
|
4725
|
+
function settingRead(copy, args) {
|
|
4726
|
+
const [name, missingOk] = args;
|
|
4727
|
+
const settings = () => fn("getvariable", [strConst(SETTINGS_VARIABLE)]);
|
|
4728
|
+
const key = () => fn("lower", [copy(name)]);
|
|
4729
|
+
const unknown = fn("error", [concat(strConst('mason[42704]: unrecognized configuration parameter "'), copy(name), strConst('"'))]);
|
|
4730
|
+
return caseWhen([
|
|
4731
|
+
[missingOk === undefined ? isNull(name) : or([isNull(name), isNull(missingOk)]), nullConst()],
|
|
4732
|
+
[fn("map_contains", [settings(), key()]), fn("map_extract_value", [settings(), key()])],
|
|
4733
|
+
...(missingOk === undefined ? [] : [[copy(missingOk), nullConst()]]),
|
|
4734
|
+
], unknown);
|
|
4735
|
+
}
|
|
4716
4736
|
function regexSubstring(copy, s, p) {
|
|
4717
4737
|
const pattern = regexPattern(p, true);
|
|
4718
4738
|
const text = constString(p);
|