@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,15 +1,17 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
* change log as production does — a publication, a replication slot, `wal_level = logical` —
|
|
4
|
-
*
|
|
5
|
-
*
|
|
2
|
+
* LOGICAL REPLICATION FOR THE TABLES WHOSE `sync:` SAYS SO (the journey to production, item 6; issue 33): a project's run
|
|
3
|
+
* reading its database's change log as production does — a publication, a replication slot, `wal_level = logical` —
|
|
4
|
+
* for each table whose sync is `replication`, beside the polled ones (src/cdc/poll.ts). A developer then meets the setup
|
|
5
|
+
* production needs on their own database first. The sources file decides it, per table, and nothing else does
|
|
6
|
+
* (`mason start --replication`, `--poll`, `--publication` and `MASON_CHANGES` are gone: src/lib/table-sync.ts
|
|
7
|
+
* `removedStartFlags`, `changesRetired`).
|
|
6
8
|
*
|
|
7
9
|
* What it costs the developer's database, said where it happens: Mason creates its slot there (`mason_<deployment>`), and
|
|
8
|
-
* a slot holds WAL while nobody reads it — so `mason stop` says so, and `mason destroy`, or
|
|
10
|
+
* a slot holds WAL while nobody reads it — so `mason stop` says so, and `mason destroy`, or no table replicated any more, drops
|
|
9
11
|
* it (`dropOwnReplication`), verified gone, by the name the start that made it RECORDED (`madeReplication`), never one
|
|
10
12
|
* computed again. The PUBLICATION is Mason's own (`mason_<deployment>`, made and dropped with
|
|
11
|
-
* the slot) unless the operator names one (
|
|
12
|
-
*
|
|
13
|
+
* the slot) unless the operator names one (`publication:` at the top of the sources file, else `PG_PUBLICATION`):
|
|
14
|
+
* that one is the database owner's, read as it is and never created, altered or dropped
|
|
13
15
|
* (src/duck/publication.ts) — on a read replica it is the only way, since CREATE PUBLICATION is refused on a standby.
|
|
14
16
|
*
|
|
15
17
|
* In the ClickHouse service this was cut from, the two modes kept row versions that could not continue each other (a
|
|
@@ -20,6 +22,8 @@
|
|
|
20
22
|
import { createHash } from "node:crypto";
|
|
21
23
|
import { replicationNames } from "../cdc/source.js";
|
|
22
24
|
import { connectSource, systemIdentifier } from "./preflight.js";
|
|
25
|
+
import { STATE_DIR } from "./project.js";
|
|
26
|
+
import { replicatedEntries, silentOverSlot, syncWritten } from "./sources.js";
|
|
23
27
|
import { readServerFacts, readSlotFacts, replicationChecks } from "./replication-checks.js";
|
|
24
28
|
/** Postgres's longest identifier (NAMEDATALEN - 1): a longer slot name is refused, a longer publication's cut with a notice. */
|
|
25
29
|
export const PG_NAME_BYTES = 63;
|
|
@@ -38,8 +42,8 @@ export function deploymentOf(project, source) {
|
|
|
38
42
|
return `${full.slice(0, room - 9)}_${createHash("sha256").update(full).digest("hex").slice(0, 8)}`;
|
|
39
43
|
}
|
|
40
44
|
/**
|
|
41
|
-
* The publication and slot a project's run reads: the publication `named` (the
|
|
42
|
-
*
|
|
45
|
+
* The publication and slot a project's run reads: the publication `named` (the sources file's or `PG_PUBLICATION`:
|
|
46
|
+
* `namedPublication`, or the recorded one), else Mason's own — by the change reader's own rule (src/cdc/source.ts `replicationNames`),
|
|
43
47
|
* which also says whether it is Mason's to make. The slot is always the project's own.
|
|
44
48
|
*/
|
|
45
49
|
export function projectReplication(name, source, named = null) {
|
|
@@ -48,27 +52,69 @@ export function projectReplication(name, source, named = null) {
|
|
|
48
52
|
}
|
|
49
53
|
/**
|
|
50
54
|
* WHAT REPLICATION MODE MADE ON THE DATABASE, as the start that made it recorded it (`config.json` `replication`): what
|
|
51
|
-
*
|
|
55
|
+
* a start whose sources file replicates no table, and `mason destroy`, drop. Recorded, never computed again, so a name that changes since — the sources file
|
|
52
56
|
* renamed, the naming rule — never leaves a slot that nobody drops, holding the database's WAL for ever. A project with
|
|
53
57
|
* no record (none started in replication mode since it was kept) has the names the rule gives now.
|
|
54
58
|
*/
|
|
55
59
|
export function madeReplication(cfg, source) {
|
|
56
60
|
return cfg.replication ?? projectReplication(cfg.name, source, cfg.publication ?? null);
|
|
57
61
|
}
|
|
58
|
-
/**
|
|
59
|
-
*
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
62
|
+
/**
|
|
63
|
+
* THE PUBLICATION A START READS, and where it is named: the sources file's `publication:` (its `${PG_PUBLICATION}`
|
|
64
|
+
* resolved), else `PG_PUBLICATION` in the environment, else none — Mason's own, which `publication: null` asks for past
|
|
65
|
+
* the environment too. NEVER the one an earlier start recorded, read silently: a record the file does not take up is
|
|
66
|
+
* refused (`recordRefusal`), so what runs is what is committed.
|
|
67
|
+
*/
|
|
68
|
+
export function namedPublication(file, env) {
|
|
69
|
+
if (file.publicationName !== null)
|
|
70
|
+
return { name: file.publicationName, from: "file" };
|
|
71
|
+
if (file.publication && "own" in file.publication)
|
|
72
|
+
return null;
|
|
63
73
|
if (env.PG_PUBLICATION)
|
|
64
74
|
return { name: env.PG_PUBLICATION, from: "env" };
|
|
65
|
-
if (recorded)
|
|
66
|
-
return { name: recorded, from: "recorded" };
|
|
67
75
|
return null;
|
|
68
76
|
}
|
|
69
|
-
/**
|
|
70
|
-
|
|
71
|
-
|
|
77
|
+
/**
|
|
78
|
+
* WHAT `.mason/config.json` RECORDS AND THE SOURCES FILE DOES NOT SAY, refused before anything is written or connected
|
|
79
|
+
* (one report, for the start that is typed, the run it spawns and `mason serve`). The file says how a project replicates,
|
|
80
|
+
* and a file that is silent is an omission, not a decision, whose price is on someone's production database:
|
|
81
|
+
* - a project that streams (a slot recorded, of any release, or an older Mason's `changes: "replication"`) under a file
|
|
82
|
+
* naming tables with no `sync:` at its top or on any entry would have its slot dropped, and streaming again copies
|
|
83
|
+
* every table whole. Any `sync:` written is the file saying so: `off` or `poll <interval>` confirms the drop,
|
|
84
|
+
* `replication` keeps the slot. The run's re-read holds the same line (`silentOverSlot`, `rereadSources`).
|
|
85
|
+
* - a project that replicates through a publication it recorded, with no `publication:` and no `PG_PUBLICATION`,
|
|
86
|
+
* would silently make and read a publication of Mason's own. The literal `publication: null` asks for that on purpose.
|
|
87
|
+
* - `publication: null` beside a set `PG_PUBLICATION` says two things, and one of them is a CREATE on the source.
|
|
88
|
+
* `file` null: there is no sources file (a serve would write one saying `sync: off`, which must not read as a choice). A
|
|
89
|
+
* project is told the first two at once: the first edit must not meet the second refusal.
|
|
90
|
+
*/
|
|
91
|
+
export function recordRefusal(cfg, file, env, shown, cmd) {
|
|
92
|
+
const recorded = cfg.publication ?? null;
|
|
93
|
+
const declined = file?.publication != null && "own" in file.publication;
|
|
94
|
+
const unnamed = recorded !== null && !declined && namedPublication(file ?? { publicationName: null }, env) === null;
|
|
95
|
+
const next = [[cmd, "once the file says it; nothing was dropped or changed"]];
|
|
96
|
+
if (declined && env.PG_PUBLICATION) {
|
|
97
|
+
return { message: `${shown} says publication: null, and PG_PUBLICATION names ${env.PG_PUBLICATION}`, code: "refused",
|
|
98
|
+
why: "one asks Mason to make a publication of its own on your database, the other to read that one, and the file would win by saying nothing of the setting",
|
|
99
|
+
fix: `for Mason's own, unset PG_PUBLICATION; to read ${env.PG_PUBLICATION}, take publication: null out of ${shown}`, next };
|
|
100
|
+
}
|
|
101
|
+
const slotRecorded = (cfg.replication ?? null) !== null;
|
|
102
|
+
const older = cfg.changes === "replication";
|
|
103
|
+
if (file === null ? slotRecorded || older : older ? !syncWritten(file) : silentOverSlot(slotRecorded, file)) {
|
|
104
|
+
const slot = cfg.replication?.slot;
|
|
105
|
+
const made = `${slot === undefined ? "its replication slot" : `replication slot ${slot}`}${(cfg.replication?.owned ?? recorded === null) ? " and Mason's publication" : ""}`;
|
|
106
|
+
return { message: `this project streams, and ${file === null ? "it has no sources file to say so" : `${shown} does not say so: no sync: at its top or on an entry`}`, code: "refused",
|
|
107
|
+
why: `${older ? `an older Mason recorded that this project streams every table (changes: replication in ${STATE_DIR}/config.json)` : `${STATE_DIR}/config.json records that this project streams`}; left unsaid, sync is off, which drops ${made}, and streaming again reads every table whole from your database`,
|
|
108
|
+
fix: file === null
|
|
109
|
+
? `mount the project folder with its sources file (MASON_PROJECT), or make ${shown} there with kind: postgres, url: \${DATABASE_URL}, tables: and the tables it streams, and sync: replication${unnamed ? ` and publication: ${recorded}` : ""} to keep streaming; sync: off or sync: poll <interval> in it instead confirms dropping ${made}`
|
|
110
|
+
: `to keep streaming, write sync: replication${unnamed ? ` and publication: ${recorded}` : ""} at the top of ${shown}; to drop ${made}, confirm it with sync: off or sync: poll <interval> there`, next };
|
|
111
|
+
}
|
|
112
|
+
if (unnamed && file !== null && replicatedEntries(file).length > 0) {
|
|
113
|
+
return { message: `this project reads publication ${recorded}, and ${shown} does not name it`, code: "refused",
|
|
114
|
+
why: `${STATE_DIR}/config.json records ${recorded}, its owner's, named at an earlier start; with no publication: line and no PG_PUBLICATION, Mason would make and read a publication of its own on your database instead`,
|
|
115
|
+
fix: `to keep reading it, write publication: ${recorded} at the top of ${shown}; for a publication of Mason's own, write publication: null there`, next };
|
|
116
|
+
}
|
|
117
|
+
return null;
|
|
72
118
|
}
|
|
73
119
|
/**
|
|
74
120
|
* Whether the developer's database can be read by replication: what refuses it (wal_level, the role — REPLICATION, a
|
|
@@ -81,7 +127,7 @@ export function readinessVerdict(facts, slot, slotFacts, repl) {
|
|
|
81
127
|
// a standby takes no CREATE PUBLICATION (it is read-only): the publication must be the primary's, named
|
|
82
128
|
if (facts.standby && repl.owned) {
|
|
83
129
|
checks.push({ name: "publication", ok: false, detail: `this database is a read replica, where Mason cannot make its publication ${repl.publication} (CREATE ` +
|
|
84
|
-
"PUBLICATION is refused on a standby). Create one on the primary for the tables
|
|
130
|
+
"PUBLICATION is refused on a standby). Create one on the primary for the tables whose sync: is replication, then name it: publication: <name> at the top of the sources file" });
|
|
85
131
|
}
|
|
86
132
|
// a laptop's database has no cap on its slots by default: worth saying, never a reason to refuse a dev run
|
|
87
133
|
return { refused: checks.filter((x) => x.ok === false && x.name !== "WAL cap"), warnings: checks.filter((x) => (x.name === "WAL cap" && x.ok === false) || x.warn === true) };
|
|
@@ -97,18 +143,51 @@ export async function replicationReadiness(source, repl) {
|
|
|
97
143
|
c.pg.close();
|
|
98
144
|
}
|
|
99
145
|
}
|
|
146
|
+
/**
|
|
147
|
+
* A TABLE SET TO `sync: replication` WHILE THE RUN IS UP, in a run that replicated nothing when it started (so the
|
|
148
|
+
* database was never checked for it): the start's own checks, as the edit's refusal — what the database lacks, and the
|
|
149
|
+
* line that keeps the table polled — or null when it may replicate. `tables` are the entries' names (`orders`).
|
|
150
|
+
*/
|
|
151
|
+
export function liveReadinessFailure(ready, tables, shown, database) {
|
|
152
|
+
const refused = "failure" in ready ? [{ name: "database", ok: false, detail: ready.failure }] : ready.refused;
|
|
153
|
+
if (!refused.length)
|
|
154
|
+
return null;
|
|
155
|
+
const one = tables.length === 1;
|
|
156
|
+
return { message: `${tables.join(", ")} cannot be kept current by logical replication from ${database}: ${refused.map((c) => c.name).join(", ")}`, code: "refused",
|
|
157
|
+
why: refused.map((c) => `${c.name}: ${c.detail}${c.sql ? ` (${c.sql})` : ""}`).join("\n"),
|
|
158
|
+
fix: `poll ${one ? "it" : "them"} instead: mason add ${tables[0]} --sync "poll 5m"${one ? "" : " (and each of the others)"}, or sync: poll 5m on ${one ? "its entry" : "their entries"} in ${shown}, ` +
|
|
159
|
+
"which needs none of this — or fix the database as said, then mason stop && mason start",
|
|
160
|
+
next: [[`mason add ${tables[0]} --sync "poll 5m"`, "polls it with SELECTs instead"]] };
|
|
161
|
+
}
|
|
100
162
|
/** The statements that remove what replication mode made, for a person to run when Mason could not: the slot, and the
|
|
101
163
|
* publication only when it is Mason's own (a named one is its owner's). */
|
|
102
164
|
export function dropStatements(o) {
|
|
103
165
|
return `SELECT pg_drop_replication_slot('${o.slot}') FROM pg_replication_slots WHERE slot_name = '${o.slot}' AND NOT active;` +
|
|
104
166
|
(o.owned ? ` DROP PUBLICATION IF EXISTS ${o.publication};` : "");
|
|
105
167
|
}
|
|
168
|
+
/**
|
|
169
|
+
* WHAT THE RECORD NAMES THAT THIS START NO LONGER READS, to drop before the record names the new one (each holds what
|
|
170
|
+
* Mason made on a database nothing would drop otherwise): a slot under another name (the sources file renamed) goes
|
|
171
|
+
* with Mason's own publication; Mason's own publication alone when the project now reads another (one named), on the
|
|
172
|
+
* same slot — a review finding, the own one was left on the database after the switch and after mason destroy.
|
|
173
|
+
*/
|
|
174
|
+
export function staleReplication(was, now) {
|
|
175
|
+
if (was === null)
|
|
176
|
+
return null;
|
|
177
|
+
if (was.slot !== now.slot)
|
|
178
|
+
return { drop: was, what: "both", why: `this source's slot is ${now.slot} now` };
|
|
179
|
+
if (was.owned && (was.publication !== now.publication || !now.owned)) {
|
|
180
|
+
return { drop: was, what: "publication", why: `the replicated tables are read through publication ${now.publication} now${now.owned ? "" : ", its owner's"}` };
|
|
181
|
+
}
|
|
182
|
+
return null;
|
|
183
|
+
}
|
|
106
184
|
/**
|
|
107
185
|
* Drop what replication mode made on the developer's database — the slot, and the publication when it is Mason's own,
|
|
108
186
|
* never one the operator named — and verify it is gone: what was dropped, in words. A slot a reader still holds is never
|
|
109
|
-
* dropped (the run must be stopped first): that is a failure.
|
|
187
|
+
* dropped (the run must be stopped first): that is a failure. `what: "publication"` drops Mason's own publication alone
|
|
188
|
+
* (the slot is still read).
|
|
110
189
|
*/
|
|
111
|
-
export async function dropOwnReplication(source, o, from = null) {
|
|
190
|
+
export async function dropOwnReplication(source, o, from = null, what = "both") {
|
|
112
191
|
const c = await connectSource(source);
|
|
113
192
|
if ("failure" in c)
|
|
114
193
|
throw new Error(`could not connect to drop them: ${c.failure.message}`);
|
|
@@ -119,11 +198,11 @@ export async function dropOwnReplication(source, o, from = null) {
|
|
|
119
198
|
if (from?.systemIdentifier) {
|
|
120
199
|
const now = await systemIdentifier(pg);
|
|
121
200
|
if (now !== null && now !== from.systemIdentifier) {
|
|
122
|
-
throw new Error(`${source.database} on ${source.host} is another server than the
|
|
201
|
+
throw new Error(`${source.database} on ${source.host} is another server than the synced data came from (system identifier ${now}, the synced data's ${from.systemIdentifier}), where the slot is: put that server's URL back in the sources file and run mason destroy again, or drop them there by hand`);
|
|
123
202
|
}
|
|
124
203
|
}
|
|
125
204
|
const dropped = [];
|
|
126
|
-
const slot = await readSlotFacts(pg, o.slot);
|
|
205
|
+
const slot = what === "both" ? await readSlotFacts(pg, o.slot) : null;
|
|
127
206
|
if (slot) {
|
|
128
207
|
if (slot.active)
|
|
129
208
|
throw new Error(`replication slot ${o.slot} is in use by a reader: stop it first`);
|
|
@@ -136,7 +215,7 @@ export async function dropOwnReplication(source, o, from = null) {
|
|
|
136
215
|
dropped.push(`publication ${o.publication}`);
|
|
137
216
|
}
|
|
138
217
|
// verified, never assumed: a slot left behind holds the database's WAL for ever
|
|
139
|
-
if (await readSlotFacts(pg, o.slot))
|
|
218
|
+
if (what === "both" && await readSlotFacts(pg, o.slot))
|
|
140
219
|
throw new Error(`replication slot ${o.slot} is still there after pg_drop_replication_slot`);
|
|
141
220
|
if (o.owned && await hasPub())
|
|
142
221
|
throw new Error(`publication ${o.publication} is still there after DROP PUBLICATION`);
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { OUTCOMES } from "../api/request-log.js";
|
|
2
|
+
import { parseRequestCursor, requestCursor } from "../duck/requests.js";
|
|
3
|
+
import { didYouMean } from "../lib/did-you-mean.js";
|
|
4
|
+
/** How many requests a read returns unless it says. */
|
|
5
|
+
export const DEFAULT_REQUEST_LIMIT = 40;
|
|
6
|
+
export const MAX_REQUEST_LIMIT = 10_000;
|
|
7
|
+
/** A statement answered in this many milliseconds or more is slow: what mason status lists, and what the `slow` filter
|
|
8
|
+
* keeps (`--slow`, `slow=1`). One constant, here, where the filter is read; src/cli/run-server.ts re-exports it. */
|
|
9
|
+
export const SLOW_MS = 1000;
|
|
10
|
+
/** The longest text `search` takes: a fragment of a statement, never a statement pasted whole. */
|
|
11
|
+
export const MAX_SEARCH = 200;
|
|
12
|
+
/** How many sync events a read returns unless it says, and the most it may. */
|
|
13
|
+
export const DEFAULT_EVENT_LIMIT = 40;
|
|
14
|
+
export const MAX_EVENT_LIMIT = MAX_REQUEST_LIMIT;
|
|
15
|
+
const AGE = /^(\d+)(s|m|h|d)$/;
|
|
16
|
+
const AGE_MS = { s: 1000, m: 60_000, h: 3_600_000, d: 86_400_000 };
|
|
17
|
+
/** a date, or a date and time with its zone (a time without one would be read in this machine's zone: refused) */
|
|
18
|
+
const ISO = /^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2}(:\d{2}(\.\d{1,6})?)?(Z|[+-]\d{2}:?\d{2}))?$/;
|
|
19
|
+
const usage = (message, fix) => ({ message, code: "usage", ...(fix !== undefined ? { fix } : {}) });
|
|
20
|
+
export const QUERY_NAMES = (k) => k;
|
|
21
|
+
/**
|
|
22
|
+
* THE TIME GRAMMAR, ONE FOR EVERY ENTRANCE (`--since` / `--until` of mason logs and mason usage, and the run's `since` and
|
|
23
|
+
* `until` of /api/requests, /api/events, /api/usage and /api/logs): an ISO 8601 date or time with its zone, or an age
|
|
24
|
+
* before `now` (30s, 30m, 2h, 7d), as an ISO time; a time with no zone is refused, as is anything else.
|
|
25
|
+
*/
|
|
26
|
+
export function timeOf(name, text, now) {
|
|
27
|
+
const age = AGE.exec(text.trim());
|
|
28
|
+
if (age !== null)
|
|
29
|
+
return new Date(now - Number(age[1]) * AGE_MS[age[2]]).toISOString();
|
|
30
|
+
const t = ISO.test(text.trim()) ? Date.parse(text.trim()) : NaN;
|
|
31
|
+
if (Number.isNaN(t)) {
|
|
32
|
+
return usage(`${name} ${JSON.stringify(text)} is not a time`, "an age (30m, 2h, 7d) or an ISO 8601 date or time with its zone (2026-10-28, 2026-10-28T09:00:00Z)");
|
|
33
|
+
}
|
|
34
|
+
return new Date(t).toISOString();
|
|
35
|
+
}
|
|
36
|
+
/** `-n` / `limit` of the sync's events: a whole number, 1 to the cap, said in the entrance's own spelling of the key. */
|
|
37
|
+
export function eventLimit(text, key) {
|
|
38
|
+
const limit = text === undefined ? DEFAULT_EVENT_LIMIT : Number(text);
|
|
39
|
+
if (!Number.isInteger(limit) || limit < 1 || limit > MAX_EVENT_LIMIT)
|
|
40
|
+
return usage(`${key} is a whole number of events, 1 to ${MAX_EVENT_LIMIT}`);
|
|
41
|
+
return limit;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* The filters read, or the first thing wrong with them, its key spelled as the entrance spells it (`name`). `failed`
|
|
45
|
+
* is every outcome but ok, so it is never named beside `outcome`; `slow` keeps the ones that took SLOW_MS or more, and
|
|
46
|
+
* `search` the statements whose text has it (any case, at most MAX_SEARCH characters); `since` must come before `until`; `before` is a
|
|
47
|
+
* cursor a page handed on, passed back as it came.
|
|
48
|
+
*/
|
|
49
|
+
export function requestFilter(f, now = Date.now(), name = QUERY_NAMES) {
|
|
50
|
+
const limit = f.limit === undefined ? DEFAULT_REQUEST_LIMIT : Number(f.limit);
|
|
51
|
+
if (!Number.isInteger(limit) || limit < 1 || limit > MAX_REQUEST_LIMIT)
|
|
52
|
+
return usage(`${name("limit")} is a whole number of requests, 1 to ${MAX_REQUEST_LIMIT}`);
|
|
53
|
+
const q = { limit };
|
|
54
|
+
if (f.outcome !== undefined) {
|
|
55
|
+
if (f.failed === true) {
|
|
56
|
+
return usage(`${name("failed")} is every outcome but ok: name it or ${name("outcome")}, never both`, `${name("outcome")} refused,failed,cancelled is ${name("failed")}`);
|
|
57
|
+
}
|
|
58
|
+
const names = f.outcome.split(",").map((o) => o.trim()).filter((o) => o !== "");
|
|
59
|
+
if (!names.length)
|
|
60
|
+
return usage(`${name("outcome")} names one or more of ${OUTCOMES.join(", ")}`);
|
|
61
|
+
for (const o of names) {
|
|
62
|
+
if (!OUTCOMES.includes(o))
|
|
63
|
+
return usage(`${name("outcome")} ${JSON.stringify(o)} is none of ${OUTCOMES.join(", ")}${didYouMean(o, OUTCOMES)}`);
|
|
64
|
+
}
|
|
65
|
+
q.outcomes = [...new Set(names)];
|
|
66
|
+
}
|
|
67
|
+
if (f.failed === true)
|
|
68
|
+
q.failed = true;
|
|
69
|
+
for (const k of ["since", "until"]) {
|
|
70
|
+
const text = f[k];
|
|
71
|
+
if (text === undefined)
|
|
72
|
+
continue;
|
|
73
|
+
const t = timeOf(name(k), text, now);
|
|
74
|
+
if (typeof t !== "string")
|
|
75
|
+
return t;
|
|
76
|
+
q[k] = t;
|
|
77
|
+
}
|
|
78
|
+
if (q.since !== undefined && q.until !== undefined && q.since >= q.until) {
|
|
79
|
+
return usage(`${name("since")} (${q.since}) is not before ${name("until")} (${q.until}): no request started in between`);
|
|
80
|
+
}
|
|
81
|
+
for (const k of ["user", "client"]) {
|
|
82
|
+
const text = f[k];
|
|
83
|
+
if (text === undefined)
|
|
84
|
+
continue;
|
|
85
|
+
if (text === "")
|
|
86
|
+
return usage(`${name(k)} names ${k === "user" ? "a mason user" : "a client's application_name"}: it is empty`);
|
|
87
|
+
q[k] = text;
|
|
88
|
+
}
|
|
89
|
+
if (f.slow === true)
|
|
90
|
+
q.minMs = SLOW_MS;
|
|
91
|
+
if (f.search !== undefined) {
|
|
92
|
+
if (f.search.trim() === "")
|
|
93
|
+
return usage(`${name("search")} is a piece of a statement's text: it is empty`);
|
|
94
|
+
if (f.search.length > MAX_SEARCH)
|
|
95
|
+
return usage(`${name("search")} is a piece of a statement's text, at most ${MAX_SEARCH} characters: it has ${f.search.length}`);
|
|
96
|
+
q.search = f.search;
|
|
97
|
+
}
|
|
98
|
+
if (f.before !== undefined) {
|
|
99
|
+
const c = parseRequestCursor(f.before);
|
|
100
|
+
if (c === null)
|
|
101
|
+
return usage(`${name("before")} ${JSON.stringify(f.before)} is not a position in the request log`, `pass back the before a page answered with, as it came`);
|
|
102
|
+
q.before = c;
|
|
103
|
+
}
|
|
104
|
+
return q;
|
|
105
|
+
}
|
|
106
|
+
/** The query string the run reads a RequestQuery from (requestFilter again, on its side): its times as resolved. */
|
|
107
|
+
export function requestFilterParams(q) {
|
|
108
|
+
const p = new URLSearchParams({ limit: String(q.limit) });
|
|
109
|
+
if (q.failed === true)
|
|
110
|
+
p.set("failed", "1");
|
|
111
|
+
if (q.outcomes !== undefined)
|
|
112
|
+
p.set("outcome", q.outcomes.join(","));
|
|
113
|
+
if (q.since !== undefined)
|
|
114
|
+
p.set("since", q.since);
|
|
115
|
+
if (q.until !== undefined)
|
|
116
|
+
p.set("until", q.until);
|
|
117
|
+
if (q.user !== undefined)
|
|
118
|
+
p.set("user", q.user);
|
|
119
|
+
if (q.client !== undefined)
|
|
120
|
+
p.set("client", q.client);
|
|
121
|
+
if (q.minMs !== undefined)
|
|
122
|
+
p.set("slow", "1");
|
|
123
|
+
if (q.search !== undefined)
|
|
124
|
+
p.set("search", q.search);
|
|
125
|
+
if (q.before !== undefined)
|
|
126
|
+
p.set("before", requestCursor(q.before.at, q.before.seq));
|
|
127
|
+
return p;
|
|
128
|
+
}
|
|
129
|
+
/** A query string's filters as text (`GET /api/requests`): what requestFilter reads. */
|
|
130
|
+
export function requestFilterText(p) {
|
|
131
|
+
const get = (k) => p.get(k) ?? undefined;
|
|
132
|
+
return { limit: get("limit"), outcome: get("outcome"), failed: p.get("failed") === "1", since: get("since"), until: get("until"),
|
|
133
|
+
user: get("user"), client: get("client"), slow: p.get("slow") === "1", search: get("search"), before: get("before") };
|
|
134
|
+
}
|