@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.
- package/README.md +147 -69
- package/dist/src/api/error.js +3 -0
- package/dist/src/api/postgres/catalog.js +258 -14
- 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 +1290 -223
- package/dist/src/api/postgres/translate.js +81 -9
- package/dist/src/api/request-log.js +18 -5
- package/dist/src/cdc/pg-wire.js +144 -38
- package/dist/src/cdc/poll.js +5 -5
- package/dist/src/cli/background.js +55 -8
- package/dist/src/cli/cancel.js +75 -0
- package/dist/src/cli/command.js +5 -3
- package/dist/src/cli/commands.js +843 -272
- package/dist/src/cli/control.js +59 -0
- package/dist/src/cli/demo.js +158 -0
- package/dist/src/cli/dev.js +458 -125
- package/dist/src/cli/doctor.js +25 -12
- package/dist/src/cli/guides.js +103 -31
- package/dist/src/cli/init.js +159 -58
- package/dist/src/cli/machine.js +18 -12
- package/dist/src/cli/main.js +5 -5
- package/dist/src/cli/mcp.js +735 -0
- 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 +24 -14
- package/dist/src/cli/project.js +48 -7
- package/dist/src/cli/readiness.js +38 -4
- package/dist/src/cli/registry.js +57 -35
- 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 +498 -133
- package/dist/src/cli/runs.js +1 -1
- package/dist/src/cli/security.js +10 -8
- package/dist/src/cli/serve.js +147 -40
- package/dist/src/cli/share-run.js +286 -0
- package/dist/src/cli/share.js +286 -0
- package/dist/src/cli/source-edit.js +168 -0
- package/dist/src/cli/sources.js +290 -39
- 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 +240 -0
- package/dist/src/demo/source.js +655 -0
- package/dist/src/duck/cancel.js +10 -1
- package/dist/src/duck/distinct-key.js +228 -0
- package/dist/src/duck/events.js +134 -0
- package/dist/src/duck/executor-worker.js +25 -11
- package/dist/src/duck/executor.js +107 -53
- package/dist/src/duck/explain.js +158 -29
- package/dist/src/duck/extensions.js +160 -0
- package/dist/src/duck/functions.js +73 -19
- package/dist/src/duck/gaps.js +25 -11
- package/dist/src/duck/grants.js +176 -0
- package/dist/src/duck/history.js +123 -0
- package/dist/src/duck/jsonb-order.js +175 -0
- package/dist/src/duck/jsonb.js +10 -8
- package/dist/src/duck/kept-route.js +82 -2
- package/dist/src/duck/kept.js +211 -44
- package/dist/src/duck/pgtree.js +40 -0
- package/dist/src/duck/plan.js +120 -30
- package/dist/src/duck/publication.js +6 -6
- package/dist/src/duck/requests.js +265 -93
- package/dist/src/duck/rewrite.js +1193 -73
- package/dist/src/duck/row-filter.js +649 -0
- package/dist/src/duck/store.js +271 -159
- package/dist/src/duck/stream.js +10 -5
- package/dist/src/duck/sync.js +907 -275
- 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 +9 -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/lib/timezone.js +3 -2
- package/dist/src/web/app.css +706 -55
- package/dist/src/web/app.js +330 -258
- package/dist/src/web/charts.js +229 -0
- package/dist/src/web/index.html +84 -15
- 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
package/dist/src/cli/dev.js
CHANGED
|
@@ -2,12 +2,15 @@
|
|
|
2
2
|
* `mason start` (one run per project): Mason for the project of this folder
|
|
3
3
|
* (src/cli/project.ts), on the database its sources file names (src/cli/sources.ts), copying the tables it names.
|
|
4
4
|
*
|
|
5
|
+
* 0. A DEMO PROJECT'S DATABASE (`mason init demo`, src/cli/demo.ts): hosted by this process, opened first on the port
|
|
6
|
+
* the sources file's URL names, then connected to and read as any database is (by polling: it has no change log),
|
|
7
|
+
* and changed every few seconds so the copy is seen keeping current.
|
|
5
8
|
* 1. THE DATABASE (src/cli/preflight.ts checkSource): connect as the run will, say what to change when it cannot;
|
|
6
9
|
* a database on another machine only when the project was made with --remote. A database restored to another server
|
|
7
10
|
* is read again (its rows compared, the unchanged kept); another database is refused (mason destroy, then mason start).
|
|
8
11
|
* 2. THE COPY (src/duck/store.ts, src/duck/sync.ts): DuckDB in this process, file-backed in `.mason/duck.db`; each
|
|
9
|
-
* added table copied whole from the database and kept current
|
|
10
|
-
*
|
|
12
|
+
* added table copied whole from the database and kept current as its sync: in the sources file says: by the
|
|
13
|
+
* xmin poll, by logical replication (src/duck/stream.ts), or only when mason sync asks (off).
|
|
11
14
|
* 3. THE COMMAND SERVER (src/cli/run-server.ts) on 127.0.0.1, which `mason tables`, `mason add`, `mason status` …
|
|
12
15
|
* ask with the run's token from the project's config (src/cli/control.ts, src/cli/security.ts).
|
|
13
16
|
* 4. THE POSTGRES LISTENER (src/api/postgres/server.ts): a BI tool or an app connects as to a read replica, as a
|
|
@@ -26,26 +29,36 @@ import * as path from "node:path";
|
|
|
26
29
|
import { choosePgPort, freePort } from "./ports.js";
|
|
27
30
|
import { failed } from "./command.js";
|
|
28
31
|
import { checkSource, connectSource, maskedUrl, readSourceFacts, remoteConfirmation, remoteWarning } from "./preflight.js";
|
|
29
|
-
import { createRunServer } from "./run-server.js";
|
|
30
|
-
import { addedTables, rereadSources } from "./sources.js";
|
|
32
|
+
import { createRunServer, metricsFacts, usageFacts } from "./run-server.js";
|
|
33
|
+
import { addedTables, effectiveSync, entryName, replicatedEntries, rereadSources, shownPath, syncWritten } from "./sources.js";
|
|
31
34
|
import { newToken } from "./security.js";
|
|
32
35
|
import { liveRun } from "./runs.js";
|
|
33
|
-
import { DUCK_FILE, projectSource, theProject, writeConfig } from "./project.js";
|
|
36
|
+
import { DEMO_DIR, DUCK_FILE, projectSource, startSettingRefusal, theProject, writeConfig } from "./project.js";
|
|
37
|
+
import { closedWithin, closingOnce, DEMO_STILL_CLOSING, demoChanges, demoChangesRefusal, demoPort, demoSettings, openDemo } from "./demo.js";
|
|
34
38
|
import { postgresApiUrl, readPgTls } from "../api/postgres/listener.js";
|
|
35
|
-
import {
|
|
39
|
+
import { connectDatabase } from "../lib/database-name.js";
|
|
40
|
+
import { certificateFingerprint, elsewhereTip, ensureSelfSigned, listenerText, runListenerSettings, selfSignedPair, SELF_SIGNED_FILES } from "./pg-tls.js";
|
|
41
|
+
import { RunShares } from "./share-run.js";
|
|
36
42
|
import { startPostgresApi, timeoutMs } from "../api/postgres/server.js";
|
|
37
|
-
import { RequestLedger, REQUESTS_TABLE
|
|
43
|
+
import { RequestLedger, REQUESTS_TABLE } from "../duck/requests.js";
|
|
44
|
+
import { historyDaysSetting } from "../duck/history.js";
|
|
45
|
+
import { EVENTS_TABLE, SyncEvents } from "../duck/events.js";
|
|
46
|
+
import { USAGE_TABLE, UsageSampler } from "../duck/usage.js";
|
|
38
47
|
import { withPlaceholders } from "./pg-url.js";
|
|
48
|
+
import { UI } from "./ui.js";
|
|
39
49
|
import { cliPinProblem, ensurePin } from "./pin.js";
|
|
40
50
|
import { DEFAULT_MEMORY_LIMIT, DuckStore, DUCKDB_VERSION } from "../duck/store.js";
|
|
41
51
|
import { copyChunkSecondsOf, copyParallelOf, Sync, syncModeOf } from "../duck/sync.js";
|
|
42
52
|
import { storeJsonPathsOf } from "../duck/kept.js";
|
|
43
53
|
import { readLibcOrder } from "../duck/libc-order.js";
|
|
44
54
|
import { isLibcCollation } from "../duck/rewrite.js";
|
|
45
|
-
import { listenerLogins, LiveUsers, USERS_FILE } from "../duck/users.js";
|
|
46
|
-
import {
|
|
47
|
-
import {
|
|
55
|
+
import { listenerLogins, LiveUsers, readUsers, USERS_FILE } from "../duck/users.js";
|
|
56
|
+
import { exampleRole, LiveRoles, missingAttributesText, noRoleWayOut, readRoles, resolveRoles, rolesDefaultLine, rolesFile, usersMissingAttributes, usersWithNoRole, usersWithoutRole } from "./roles.js";
|
|
57
|
+
import { READY_LINE, readRestarts } from "./supervise.js";
|
|
58
|
+
import { dropOwnReplication, dropStatements, liveReadinessFailure, madeReplication, namedPublication, projectReplication, recordRefusal, replicationReadiness, staleReplication } from "./replication-mode.js";
|
|
48
59
|
import { textEntry } from "../lib/log.js";
|
|
60
|
+
import { LogBuffer } from "../lib/log-buffer.js";
|
|
61
|
+
import { BIG_TABLE_ROWS, syncText } from "../lib/table-sync.js";
|
|
49
62
|
const say = (m) => { process.stdout.write(`${m}\n`); };
|
|
50
63
|
/**
|
|
51
64
|
* THE RUN'S STEPS, as lines (an opening block of prose was too much to read, as `mason init`'s had been): a step begun,
|
|
@@ -66,14 +79,89 @@ export function sourceMove(recorded, now) {
|
|
|
66
79
|
if (!recorded)
|
|
67
80
|
return { action: "record" };
|
|
68
81
|
if (recorded.database !== now.database) {
|
|
69
|
-
return { refuse: `this project's
|
|
70
|
-
"Its
|
|
82
|
+
return { refuse: `this project's synced data is of the database ${recorded.database}, and .mason/config.json now names ${now.database}. ` +
|
|
83
|
+
"Its synced rows are not that database's: mason destroy (removes the synced data; your files stay), then mason start syncs it" };
|
|
71
84
|
}
|
|
72
85
|
if (recorded.systemIdentifier !== null && now.systemIdentifier !== null && recorded.systemIdentifier !== now.systemIdentifier)
|
|
73
86
|
return { action: "reread" };
|
|
74
87
|
return { action: "same" };
|
|
75
88
|
}
|
|
89
|
+
/**
|
|
90
|
+
* The run, and whatever it hosts closed however it ends: a demo database that was opened is closed at the stop (after
|
|
91
|
+
* the sync, before the copy), and by this on every other way out — a failed step, a crash — its data flushed to its
|
|
92
|
+
* folder, a close that fails said.
|
|
93
|
+
*/
|
|
76
94
|
export async function dev(o) {
|
|
95
|
+
const hosted = { stopChanges: null, close: null };
|
|
96
|
+
try {
|
|
97
|
+
return await run(o, hosted);
|
|
98
|
+
}
|
|
99
|
+
finally {
|
|
100
|
+
hosted.stopChanges?.();
|
|
101
|
+
await closeDemo(hosted, null);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
/** The demo database closed, if it is open: a failure said, never swallowed; `closed`, when given, said once it closed. */
|
|
105
|
+
async function closeDemo(hosted, closed) {
|
|
106
|
+
const close = hosted.close;
|
|
107
|
+
if (close === null)
|
|
108
|
+
return;
|
|
109
|
+
hosted.close = null;
|
|
110
|
+
const failure = await close();
|
|
111
|
+
if (failure !== null)
|
|
112
|
+
say(` closing the demo database: ${failure}`);
|
|
113
|
+
else if (closed !== null)
|
|
114
|
+
say(` ${closed}`);
|
|
115
|
+
}
|
|
116
|
+
/** how soon a pass that failed (the database away) is tried again, and replication mode's pass when a table waits */
|
|
117
|
+
const PASS_RETRY_MS = 5000;
|
|
118
|
+
/** the tables by their sync, as the start line says them: ` (2 polled, every 5s and 30s; 1 replicated; 1 off)` */
|
|
119
|
+
function syncCounts(tables) {
|
|
120
|
+
const polled = tables.flatMap((t) => t.sync?.mode === "poll" ? [t.sync.every] : []);
|
|
121
|
+
const replicated = tables.filter((t) => t.sync?.mode === "replication").length;
|
|
122
|
+
const off = tables.length - polled.length - replicated;
|
|
123
|
+
const parts = [...(polled.length ? [`${polled.length} polled, every ${[...new Set(polled)].join(" and ")}`] : []), ...(replicated ? [`${replicated} replicated`] : []), ...(off ? [`${off} off`] : [])];
|
|
124
|
+
return parts.length ? ` (${parts.join("; ")})` : "";
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* THE SOURCES FILE'S DEFAULT SYNC, SAID ONCE AT THE START: what every table without its own `sync:` gets — `off` (the
|
|
128
|
+
* default, said as such when the file writes none), the file's `poll <interval>` or `replication` — and how to change it.
|
|
129
|
+
*
|
|
130
|
+
* AN UPGRADE THAT STOPS THE POLLS IS SAID LOUDLY (issue 33's review): a file an older Mason wrote has no `sync:`
|
|
131
|
+
* anywhere, and its tables were polled every 5 s; from this release they are `off`. When the copy already holds
|
|
132
|
+
* `copiedBefore` of its tables and no `sync:` is written at all, the line says how many are not polled, that an older
|
|
133
|
+
* Mason polled them, and the exact line that keeps them current as before — true too of a hand-written file on a
|
|
134
|
+
* project that never polled.
|
|
135
|
+
*/
|
|
136
|
+
export function syncDefaultLine(file, shown, copiedBefore = 0) {
|
|
137
|
+
const d = effectiveSync(file, undefined);
|
|
138
|
+
if (!syncWritten(file) && copiedBefore > 0) {
|
|
139
|
+
// true of a file an older Mason polled and of one written by hand that never was: "not polled", the old way said.
|
|
140
|
+
// That Mason left a big table out of its polls, and the line that keeps the rest as before does not (BIG_TABLE_ROWS)
|
|
141
|
+
return `sync: off (the default): ${copiedBefore === 1 ? "1 table is" : `${copiedBefore} tables are`} not polled (an older Mason polled every table under ${BIG_TABLE_ROWS.toLocaleString("en-US")} rows every 5 s); ` +
|
|
142
|
+
`\`sync: poll 5s\` at the top of ${shown} keeps them current as before, but polls the bigger ones too, each poll a full scan on your database: ` +
|
|
143
|
+
"give those `sync: off` on their entry (mason tables shows the rows), or use `mason sync <table>` when you need it";
|
|
144
|
+
}
|
|
145
|
+
if (d.from === "default")
|
|
146
|
+
return `sync: off (the default): after its first sync, a table is read again only by mason sync <table> — sync: poll 5m at the top of ${shown} polls every table, or on an entry that one`;
|
|
147
|
+
return d.sync.mode === "off" ? `sync: off (${shown}): after its first sync, a table is read again only by mason sync <table>, unless its entry says sync: poll <interval>`
|
|
148
|
+
: d.sync.mode === "replication" ? `sync: replication (${shown}): every table without a sync: of its own is kept current from the database's change log`
|
|
149
|
+
: `sync: ${syncText(d.sync)} (${shown}): every table without a sync: of its own is polled every ${d.sync.every}`;
|
|
150
|
+
}
|
|
151
|
+
/** WHAT REPLICATION MAKES ON THE DATABASE, recorded in the project's config as it is (at the start that replicates a
|
|
152
|
+
* table, and when the run makes the slot later): the names `mason destroy`, and a start whose file replicates nothing,
|
|
153
|
+
* drop — never computed again (src/cli/replication-mode.ts madeReplication) */
|
|
154
|
+
function recordReplication(cfg, repl, home) {
|
|
155
|
+
const recorded = repl.owned ? null : repl.publication;
|
|
156
|
+
if ((cfg.publication ?? null) === recorded && JSON.stringify(cfg.replication ?? null) === JSON.stringify(repl))
|
|
157
|
+
return;
|
|
158
|
+
cfg.publication = recorded;
|
|
159
|
+
cfg.replication = repl;
|
|
160
|
+
writeConfig(home, cfg);
|
|
161
|
+
}
|
|
162
|
+
async function run(o, hosted) {
|
|
163
|
+
// when this run began: the uptime /api/state says (engine.startedAt, a supervisor's restart is a new run)
|
|
164
|
+
const startedAt = Date.now();
|
|
77
165
|
const t = theProject(process.cwd());
|
|
78
166
|
if ("problem" in t) {
|
|
79
167
|
say(`mason start: ${t.problem}`);
|
|
@@ -89,13 +177,52 @@ export async function dev(o) {
|
|
|
89
177
|
say(`mason start: Mason is already running for ${cfg.name} (pid ${other.pid}): mason status says what it is doing`);
|
|
90
178
|
return 1;
|
|
91
179
|
}
|
|
92
|
-
|
|
180
|
+
// A DEMO PROJECT IS POLLED (src/cli/demo.ts): its database has no change log, so replication is refused before anything starts
|
|
181
|
+
const demoRefused = demoChangesRefusal(cfg);
|
|
182
|
+
if (demoRefused)
|
|
183
|
+
return failed("start", demoRefused, { json: false });
|
|
184
|
+
let demoChangeMs = null;
|
|
185
|
+
if (cfg.demo === true) {
|
|
186
|
+
try {
|
|
187
|
+
demoChangeMs = demoSettings(process.env).changeMs;
|
|
188
|
+
}
|
|
189
|
+
catch (e) {
|
|
190
|
+
say(`mason start: ${e.message}`);
|
|
191
|
+
return 1;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
// mason serve has held the project to its pin already, before it wrote anything (src/cli/serve.ts servePin)
|
|
195
|
+
const pinned = o.serve ? null : cliPinProblem(folder, o.version);
|
|
93
196
|
if (pinned) {
|
|
94
197
|
say(`mason start: ${pinned}`);
|
|
95
198
|
return 1;
|
|
96
199
|
}
|
|
200
|
+
// a setting a start refuses (the parent of a background start has asked the same list: src/cli/project.ts)
|
|
201
|
+
const setting = startSettingRefusal(project, process.env, { serve: o.serve !== undefined });
|
|
202
|
+
if (setting !== null)
|
|
203
|
+
return failed("start", setting, { json: false });
|
|
204
|
+
// the history's days (MASON_HISTORY_DAYS, a number of days; the old MASON_REQUEST_LOG_DAYS refused, named), read
|
|
205
|
+
// before anything opens (src/duck/history.ts); every history below keeps them
|
|
206
|
+
const keptDays = historyDaysSetting(process.env);
|
|
207
|
+
if (typeof keptDays !== "number")
|
|
208
|
+
return failed("start", { code: "refused", ...keptDays }, { json: false });
|
|
209
|
+
// THE SOURCE (src/cli/sources.ts): the database and the tables to copy, from the project's sources file; a server's
|
|
210
|
+
// URL from its environment first (mason serve is configured by it alone). Read here, its failure said below where it
|
|
211
|
+
// always was (after the listener's checks): a demo project's port is needed first
|
|
212
|
+
const readSources = () => projectSource(project, process.env, { shellFirst: o.serve !== undefined });
|
|
213
|
+
const src = readSources();
|
|
214
|
+
// what the config records and the file does not say (an older Mason's replication): refused before the port below is
|
|
215
|
+
// recorded or the pin written, and long before anything on the database is dropped or made
|
|
216
|
+
const unsaid = "message" in src ? null : recordRefusal(cfg, src, process.env, shownPath(project.root, src.file), o.serve ? "mason serve" : "mason start");
|
|
217
|
+
if (unsaid !== null)
|
|
218
|
+
return failed("start", unsaid, { json: false });
|
|
219
|
+
// a demo project's database listens on the port its URL names (the one place it is kept), which no listener of the
|
|
220
|
+
// run's may take: read BEFORE the Postgres API's port is chosen and recorded, so the choice avoids it (a recorded or
|
|
221
|
+
// named port that is the demo's is refused by choosePgPort, nothing recorded), and the command server is kept off it
|
|
222
|
+
const demoAt = cfg.demo === true && !("message" in src) ? demoPort(src.resolved, src.from) : null;
|
|
223
|
+
const demoPorts = new Set(demoAt !== null && "port" in demoAt ? [demoAt.port] : []);
|
|
97
224
|
// THE PORT IS THE PROJECT'S (src/cli/ports.ts): the first start's choice recorded, every later start on it or refused
|
|
98
|
-
const pg = await choosePgPort(process.env.MASON_PG_PORT,
|
|
225
|
+
const pg = await choosePgPort(process.env.MASON_PG_PORT, demoPorts, cfg.pgPort ?? null);
|
|
99
226
|
if ("problem" in pg)
|
|
100
227
|
return failed("start", pg, { json: false });
|
|
101
228
|
if (cfg.pgPort !== pg.port) {
|
|
@@ -110,17 +237,49 @@ export async function dev(o) {
|
|
|
110
237
|
const pgTls = listenerTls(listen, o.selfSignedTls, home, cfg);
|
|
111
238
|
if ("problem" in pgTls)
|
|
112
239
|
return failed("start", pgTls, { json: false });
|
|
113
|
-
const chosen = new Set([pg.port]);
|
|
240
|
+
const chosen = new Set([pg.port, ...demoPorts]);
|
|
114
241
|
// a server's project folder is the team's, committed, and may be mounted read-only: mason serve writes nothing there
|
|
115
242
|
const wrotePin = o.serve ? null : ensurePin(folder, o.version);
|
|
116
243
|
if (wrotePin)
|
|
117
244
|
say(` wrote ${path.relative(project.root, wrotePin)}: version ${o.version}, the Mason this project is written for — commit it`);
|
|
118
|
-
// THE SOURCE (src/cli/sources.ts): the database and the tables to copy, from the project's sources file; a server's
|
|
119
|
-
// URL from its environment first (mason serve is configured by it alone)
|
|
120
|
-
const readSources = () => projectSource(project, process.env, { shellFirst: o.serve !== undefined });
|
|
121
|
-
const src = readSources();
|
|
122
245
|
if ("message" in src)
|
|
123
246
|
return failed("start", src, { json: false });
|
|
247
|
+
// a demo project's file naming sync: replication (its database has no change log), refused before anything opens
|
|
248
|
+
const demoFile = demoChangesRefusal(cfg, src, shownPath(project.root, src.file));
|
|
249
|
+
if (demoFile)
|
|
250
|
+
return failed("start", demoFile, { json: false });
|
|
251
|
+
const logFile = path.join(home, "mason.log");
|
|
252
|
+
const log = fs.createWriteStream(logFile, { flags: "a" });
|
|
253
|
+
// one entry a line: a multi-line message's further lines indented under it (src/lib/log.ts `textEntry`);
|
|
254
|
+
// mason serve gets the same entry, for the container's stdout (src/cli/serve.ts)
|
|
255
|
+
// the last entries are kept in memory too (src/lib/log-buffer.ts), for the command server's /api/logs: the one writer
|
|
256
|
+
// feeds the file, stdout and the buffer, so a mode with no file (mason serve) has the log as well
|
|
257
|
+
const logs = new LogBuffer();
|
|
258
|
+
const logLine = (l) => {
|
|
259
|
+
const entry = textEntry(`${new Date().toISOString()} `, l);
|
|
260
|
+
log.write(entry);
|
|
261
|
+
logs.push(entry);
|
|
262
|
+
o.serve?.log(entry.trimEnd());
|
|
263
|
+
};
|
|
264
|
+
// THE DEMO DATABASE (src/cli/demo.ts), hosted here: opened before it is connected to, as any database is
|
|
265
|
+
let demo = null;
|
|
266
|
+
if (demoAt !== null) {
|
|
267
|
+
begin("Starting the demo database");
|
|
268
|
+
if ("problem" in demoAt) {
|
|
269
|
+
fail("Starting the demo database");
|
|
270
|
+
say(` ${demoAt.problem}`);
|
|
271
|
+
return 1;
|
|
272
|
+
}
|
|
273
|
+
const opened = await openDemo({ home, port: demoAt.port, from: src.from, log: (l) => logLine(`[demo] ${l}`) });
|
|
274
|
+
if ("problem" in opened) {
|
|
275
|
+
fail("Starting the demo database");
|
|
276
|
+
say(` ${opened.problem}`);
|
|
277
|
+
return 1;
|
|
278
|
+
}
|
|
279
|
+
demo = opened.demo;
|
|
280
|
+
hosted.close = closingOnce(opened.demo);
|
|
281
|
+
done(opened.done);
|
|
282
|
+
}
|
|
124
283
|
const db = maskedUrl(src.resolved);
|
|
125
284
|
begin(`Connecting to ${db}`);
|
|
126
285
|
const checkSaid = [];
|
|
@@ -135,7 +294,7 @@ export async function dev(o) {
|
|
|
135
294
|
say(` ! ${w}`);
|
|
136
295
|
const config = path.join(home, "config.json");
|
|
137
296
|
return failed("start", remoteConfirmation(source.host, { cmd: "mason start",
|
|
138
|
-
wayOut: `this project was not made with --remote: if you mean to
|
|
297
|
+
wayOut: `this project was not made with --remote: if you mean to sync from that machine, set "remote": true in ${config} and start again`,
|
|
139
298
|
next: [["mason start", `once ${config} says "remote": true`]] }), { json: false });
|
|
140
299
|
}
|
|
141
300
|
if ("exit" in checked) {
|
|
@@ -147,79 +306,79 @@ export async function dev(o) {
|
|
|
147
306
|
return checked.exit === 2 ? 1 : checked.exit;
|
|
148
307
|
}
|
|
149
308
|
const { source, warnings } = checked;
|
|
309
|
+
// THE DATABASE A TOOL CONNECTS WITH (src/lib/database-name.ts): never the project's name, which names the slot below
|
|
310
|
+
const database = connectDatabase(src, cfg, process.env);
|
|
150
311
|
done(`Connected to ${db}: Postgres ${checked.facts.versionText}${checked.facts.standby ? " (a read replica)" : ""}, ${checked.facts.tables.length} table(s) this user can read`);
|
|
151
312
|
for (const w of warnings)
|
|
152
313
|
say(` ! ${w}`);
|
|
153
|
-
// HOW THE CHANGES ARE READ (src/cli/replication-mode.ts):
|
|
154
|
-
//
|
|
155
|
-
//
|
|
156
|
-
const
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
// THE PUBLICATION: one the operator named (the
|
|
162
|
-
//
|
|
163
|
-
// is what
|
|
164
|
-
const named =
|
|
314
|
+
// HOW THE CHANGES ARE READ (src/cli/replication-mode.ts): each table as its sync: in the sources file says — the tables
|
|
315
|
+
// whose sync is replication through the project's ONE slot and publication, the others polled with SELECTs beside the
|
|
316
|
+
// stream. A switch copies nothing again (src/duck/sync.ts setTables: one poll catches a table up)
|
|
317
|
+
const shownFile = shownPath(project.root, src.file);
|
|
318
|
+
const replicating = replicatedEntries(src).length > 0;
|
|
319
|
+
// whether this run has held the database to logical replication's checks: at this start when the file replicates a
|
|
320
|
+
// table, else when an edit first sets one to replication (checkReplication below)
|
|
321
|
+
let replicationChecked = replicating;
|
|
322
|
+
// THE PUBLICATION: one the operator named (the file's publication:, else PG_PUBLICATION) is read as it is, never
|
|
323
|
+
// altered (src/duck/publication.ts); else Mason makes its own, carrying the replicated tables alone. What the run
|
|
324
|
+
// before made, as it recorded it, is what goes when no table is replicated (madeReplication)
|
|
325
|
+
const named = namedPublication(src, process.env);
|
|
165
326
|
const repl = projectReplication(cfg.name, src.name, named?.name ?? null);
|
|
166
327
|
const before = madeReplication(cfg, src.name);
|
|
167
|
-
if (
|
|
328
|
+
if (replicating) {
|
|
168
329
|
begin("Checking logical replication");
|
|
169
330
|
const ready = await replicationReadiness(source, repl);
|
|
170
331
|
if ("failure" in ready || ready.refused.length) {
|
|
171
332
|
fail("Checking logical replication");
|
|
172
333
|
for (const c of "failure" in ready ? [{ name: "database", detail: ready.failure }] : ready.refused)
|
|
173
334
|
say(` ${c.name}: ${c.detail}`);
|
|
174
|
-
say(
|
|
335
|
+
say(` (${replicatedEntries(src).map((e) => entryName(e.table)).join(", ")} ${replicatedEntries(src).length === 1 ? "is" : "are"} sync: replication in ${shownFile}; sync: poll 5m reads a table with SELECTs instead, which needs none of this)`);
|
|
175
336
|
return 1;
|
|
176
337
|
}
|
|
177
|
-
done(repl.owned ? `Logical replication: Mason makes publication ${repl.publication} and slot ${repl.slot} on ${source.database} (mason destroy,
|
|
178
|
-
: `Logical replication: publication ${repl.publication} is read as it is, never altered; Mason makes slot ${repl.slot} on ${source.database} (mason destroy
|
|
179
|
-
if (named?.from === "recorded")
|
|
180
|
-
say(` publication ${repl.publication} was named at an earlier start of this copy, and is read again (mason start --replication --publication <name> names another)`);
|
|
338
|
+
done(repl.owned ? `Logical replication for ${replicatedEntries(src).length} table(s): Mason makes publication ${repl.publication} and slot ${repl.slot} on ${source.database} (mason destroy drops them, and so does a sources file in which no table's sync is replication)`
|
|
339
|
+
: `Logical replication for ${replicatedEntries(src).length} table(s): publication ${repl.publication} is read as it is, never altered; Mason makes slot ${repl.slot} on ${source.database} (mason destroy drops the slot alone, and so does a sources file in which no table's sync is replication)`);
|
|
181
340
|
for (const w of ready.warnings)
|
|
182
341
|
say(` ! ${w.name}: ${w.detail}`);
|
|
183
|
-
//
|
|
184
|
-
//
|
|
185
|
-
const
|
|
186
|
-
if (
|
|
342
|
+
// WHAT THE RECORD NAMES AND THIS RUN NO LONGER READS — a slot under another name (the sources file renamed since),
|
|
343
|
+
// or Mason's own publication once another is named — goes, verified, before the record names the new one
|
|
344
|
+
const stale = staleReplication(cfg.replication ?? null, repl);
|
|
345
|
+
if (stale !== null) {
|
|
187
346
|
try {
|
|
188
|
-
for (const d of await dropOwnReplication(source,
|
|
189
|
-
say(` dropped ${d} from ${source.database}:
|
|
347
|
+
for (const d of await dropOwnReplication(source, stale.drop, null, stale.what))
|
|
348
|
+
say(` dropped ${d} from ${source.database}: ${stale.why}`);
|
|
190
349
|
}
|
|
191
350
|
catch (e) {
|
|
192
|
-
|
|
351
|
+
const by = stale.what === "both" ? dropStatements(stale.drop) : `DROP PUBLICATION IF EXISTS ${stale.drop.publication};`;
|
|
352
|
+
say(`mason start: ${e.message}. Mason made ${stale.what === "both" ? "them" : "it"} and nothing reads ${stale.what === "both" ? "them" : "it"} now: drop ${stale.what === "both" ? "them" : "it"} by hand — ${by}`);
|
|
193
353
|
return 1;
|
|
194
354
|
}
|
|
195
355
|
}
|
|
196
|
-
// recorded with the run, so mason status and mason stop say it, the next start reads the same one, and
|
|
197
|
-
//
|
|
198
|
-
|
|
199
|
-
if ((cfg.publication ?? null) !== recorded || JSON.stringify(cfg.replication ?? null) !== JSON.stringify(repl)) {
|
|
200
|
-
cfg.publication = recorded;
|
|
201
|
-
cfg.replication = repl;
|
|
202
|
-
writeConfig(home, cfg);
|
|
203
|
-
}
|
|
356
|
+
// recorded with the run, so mason status and mason stop say it, the next start reads the same one, and mason
|
|
357
|
+
// destroy drops what was made by its own name
|
|
358
|
+
recordReplication(cfg, repl, home);
|
|
204
359
|
}
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
//
|
|
208
|
-
if (
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
if (!before.owned)
|
|
218
|
-
say(` publication ${before.publication} is its owner's: left as it is`);
|
|
219
|
-
cfg.publication = null;
|
|
220
|
-
cfg.replication = null;
|
|
360
|
+
else if ((cfg.replication ?? null) !== null || cfg.changes === "replication") {
|
|
361
|
+
// THE SLOT GOES WHEN THE FILE SAYS NO TABLE IS REPLICATED: it holds the database's WAL while nobody reads it. A file
|
|
362
|
+
// naming tables and silent on sync: was refused above (recordRefusal)
|
|
363
|
+
if (cfg.changes === "replication")
|
|
364
|
+
say(` an older Mason streamed every table of this project; ${shownFile} says otherwise now, so what it made goes`);
|
|
365
|
+
try {
|
|
366
|
+
for (const d of await dropOwnReplication(source, before))
|
|
367
|
+
say(` dropped ${d} from ${source.database}: no table's sync is replication`);
|
|
368
|
+
}
|
|
369
|
+
catch (e) {
|
|
370
|
+
say(`mason start: ${e.message}. They hold the database's WAL while nobody reads them: drop them by hand — ${dropStatements(before)}`);
|
|
371
|
+
return 1;
|
|
221
372
|
}
|
|
222
|
-
|
|
373
|
+
if (!before.owned)
|
|
374
|
+
say(` publication ${before.publication} is its owner's: left as it is`);
|
|
375
|
+
cfg.publication = null;
|
|
376
|
+
cfg.replication = null;
|
|
377
|
+
writeConfig(home, cfg);
|
|
378
|
+
}
|
|
379
|
+
// the project's mode, as an older start recorded it: the sources file says it per table now
|
|
380
|
+
if (cfg.changes !== undefined) {
|
|
381
|
+
delete cfg.changes;
|
|
223
382
|
writeConfig(home, cfg);
|
|
224
383
|
}
|
|
225
384
|
let facts = checked.facts;
|
|
@@ -229,11 +388,22 @@ export async function dev(o) {
|
|
|
229
388
|
return 1;
|
|
230
389
|
}
|
|
231
390
|
// THE TABLES THE SOURCES FILE NAMES, against the database's catalog: every mistake said at once, before anything opens
|
|
232
|
-
const firstTables = addedTables(src, facts.tables, source.database, project.root
|
|
391
|
+
const firstTables = addedTables(src, facts.tables, source.database, project.root);
|
|
233
392
|
if (!Array.isArray(firstTables))
|
|
234
393
|
return failed("start", firstTables, { json: false });
|
|
235
394
|
// what the run copies: the sources file as last read, and what is wrong with it now (an edit the run cannot take)
|
|
236
395
|
let sources = { file: src, tables: firstTables, problem: null };
|
|
396
|
+
// THE ROLES FILE (mason/roles.yml, src/cli/roles.ts): what each role may read, held against the tables the sources
|
|
397
|
+
// file copies before anything opens — a table it does not copy or a column the copy lacks refuses the start by name;
|
|
398
|
+
// the listener reads the file again whenever it changes (LiveRoles), no restart
|
|
399
|
+
const rolesRead = readRoles(folder, project.root);
|
|
400
|
+
if (rolesRead !== null && "message" in rolesRead)
|
|
401
|
+
return failed("start", rolesRead, { json: false });
|
|
402
|
+
if (rolesRead !== null) {
|
|
403
|
+
const resolved = resolveRoles(rolesRead, firstTables, project.root);
|
|
404
|
+
if ("message" in resolved)
|
|
405
|
+
return failed("start", resolved, { json: false });
|
|
406
|
+
}
|
|
237
407
|
// how many of a copy's chunks are read at once, each a backend on the database (src/duck/sync.ts COPY_PARALLEL)
|
|
238
408
|
let copyParallel;
|
|
239
409
|
try {
|
|
@@ -261,25 +431,16 @@ export async function dev(o) {
|
|
|
261
431
|
say(`mason start: ${e.message}`);
|
|
262
432
|
return 1;
|
|
263
433
|
}
|
|
264
|
-
|
|
265
|
-
const log = fs.createWriteStream(logFile, { flags: "a" });
|
|
266
|
-
// one entry a line: a multi-line message's further lines indented under it (src/lib/log.ts `textEntry`);
|
|
267
|
-
// mason serve gets the same entry, for the container's stdout (src/cli/serve.ts)
|
|
268
|
-
const logLine = (l) => {
|
|
269
|
-
const entry = textEntry(`${new Date().toISOString()} `, l);
|
|
270
|
-
log.write(entry);
|
|
271
|
-
o.serve?.log(entry.trimEnd());
|
|
272
|
-
};
|
|
273
|
-
begin("Opening the copy");
|
|
434
|
+
begin("Opening the synced data");
|
|
274
435
|
const duckFile = path.join(home, DUCK_FILE);
|
|
275
436
|
let store;
|
|
276
437
|
// its memory bounded (src/duck/store.ts DEFAULT_MEMORY_LIMIT): past it a statement spills, or fails alone
|
|
277
438
|
const memoryLimit = process.env.MASON_DUCKDB_MEMORY || undefined;
|
|
278
439
|
try {
|
|
279
|
-
store = await DuckStore.open(duckFile, (l) => logLine(`[
|
|
440
|
+
store = await DuckStore.open(duckFile, (l) => logLine(`[engine] ${l}`), memoryLimit === undefined ? {} : { memoryLimit });
|
|
280
441
|
}
|
|
281
442
|
catch (e) {
|
|
282
|
-
fail("Opening the
|
|
443
|
+
fail("Opening the synced data");
|
|
283
444
|
say(` ${e.message}`);
|
|
284
445
|
return 1;
|
|
285
446
|
}
|
|
@@ -287,22 +448,49 @@ export async function dev(o) {
|
|
|
287
448
|
// supervisor to start it again: every session's statement in flight was told 57P02, and nothing can be answered after
|
|
288
449
|
let crash = null;
|
|
289
450
|
store.onCrash((reason) => {
|
|
290
|
-
logLine(`[
|
|
451
|
+
logLine(`[engine] the engine's thread stopped: ${reason}`);
|
|
291
452
|
if (crash !== null)
|
|
292
453
|
crash(reason);
|
|
293
454
|
else
|
|
294
|
-
say(` ! the
|
|
455
|
+
say(` ! the engine's thread stopped: ${reason}`);
|
|
295
456
|
});
|
|
296
|
-
done(`the
|
|
297
|
-
const
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
457
|
+
done(`the synced data in ${path.relative(project.root, duckFile)} (mason destroy deletes it; engine ${DUCKDB_VERSION}), at most ${(memoryLimit ?? DEFAULT_MEMORY_LIMIT).trim()} of memory (MASON_DUCKDB_MEMORY), spilling to ${path.relative(project.root, duckFile)}.tmp`);
|
|
458
|
+
const syncMode = syncModeOf(process.env);
|
|
459
|
+
// THE SYNC'S EVENTS (src/duck/events.ts): its failures and notable events, kept as the request log is, before the sync
|
|
460
|
+
// that records them starts
|
|
461
|
+
let events;
|
|
462
|
+
try {
|
|
463
|
+
events = await SyncEvents.open(store, logLine, { days: keptDays });
|
|
464
|
+
}
|
|
465
|
+
catch (e) {
|
|
466
|
+
fail("Opening the synced data");
|
|
467
|
+
say(` the sync's events (${EVENTS_TABLE}) could not be opened: ${e.message}`);
|
|
468
|
+
await store.close();
|
|
469
|
+
return 1;
|
|
470
|
+
}
|
|
471
|
+
// each table is kept current as its own sync: says (the sources file's); the sync's interval is how soon a failed pass
|
|
472
|
+
// is tried again (and MASON_DUCK_SYNC=recopy's whole copies). The slot and publication are the run's whenever a table
|
|
473
|
+
// is replicated, now or after a mason add --sync replication (the demo database has no change log: none)
|
|
474
|
+
const sync = new Sync(store, source, database, (l) => { logLine(`[sync] ${l}`); if (!o.serve && /^synced |failed/.test(l))
|
|
475
|
+
say(textEntry(" ", l).trimEnd()); }, PASS_RETRY_MS, syncMode, cfg.demo === true ? null : repl, { copyParallel, copyChunkSeconds, json: { enabled: storeJsonPaths }, onEvent: (e) => events.record(e),
|
|
476
|
+
// what the run made on the database, recorded as it is made and dropped: mason destroy drops it by that name
|
|
477
|
+
onReplication: (made) => {
|
|
478
|
+
if (made)
|
|
479
|
+
recordReplication(cfg, repl, home);
|
|
480
|
+
else if ((cfg.replication ?? null) !== null) {
|
|
481
|
+
cfg.publication = null;
|
|
482
|
+
cfg.replication = null;
|
|
483
|
+
writeConfig(home, cfg);
|
|
484
|
+
}
|
|
485
|
+
} });
|
|
301
486
|
await sync.open();
|
|
487
|
+
// the tables this copy already holds, before this start copies anything: an upgrade from a Mason that polled every
|
|
488
|
+
// table says how many are not polled (`syncDefaultLine`)
|
|
489
|
+
const copiedBefore = sync.state().filter((s) => s.phase === "copied" && sources.tables.some((t) => t.source === s.source)).length;
|
|
302
490
|
// a database restored to another server: its transaction ids are not the watermarks', so every table is read again
|
|
303
491
|
if (move.action === "reread") {
|
|
304
492
|
await sync.rereadAll();
|
|
305
|
-
say(` ${source.database} is on another server than the
|
|
493
|
+
say(` ${source.database} is on another server than the data was synced from: reading its tables again`);
|
|
306
494
|
}
|
|
307
495
|
if (move.action !== "same") {
|
|
308
496
|
cfg.sourceServer = { systemIdentifier: facts.systemIdentifier, database: source.database };
|
|
@@ -311,7 +499,7 @@ export async function dev(o) {
|
|
|
311
499
|
// the views first: setting the tables rebuilds the catalog, which reads them. The sources file read again: a mistake in it
|
|
312
500
|
// keeps the tables read before (a typo never drops a copy), said in the log and by mason status
|
|
313
501
|
const added = () => {
|
|
314
|
-
const r = rereadSources(sources, readSources(), src.resolved, facts.tables, source.database, project.root, {
|
|
502
|
+
const r = rereadSources(sources, readSources(), src.resolved, facts.tables, source.database, project.root, { replicationChecked, slot: cfg.replication?.slot ?? null });
|
|
315
503
|
if (r.said !== null)
|
|
316
504
|
logLine(`[sync] ${r.said}`);
|
|
317
505
|
sources = { file: r.file, tables: r.tables, problem: r.problem };
|
|
@@ -321,7 +509,7 @@ export async function dev(o) {
|
|
|
321
509
|
if (pg.said)
|
|
322
510
|
say(` ${pg.said}`);
|
|
323
511
|
const pgAddress = { host: listen.host, port: listen.port };
|
|
324
|
-
const postgresUrl = postgresApiUrl(pgAddress,
|
|
512
|
+
const postgresUrl = postgresApiUrl(pgAddress, database, { tls: pgTls.tls !== null });
|
|
325
513
|
// A LIBC COLLATION'S ORDER IS READ FROM THE DATABASE (src/duck/libc-order.ts): once, a few read-only SELECTs, kept in
|
|
326
514
|
// the copy and read again when the database's collation version moves (an OS upgrade's glibc)
|
|
327
515
|
let libc;
|
|
@@ -343,12 +531,13 @@ export async function dev(o) {
|
|
|
343
531
|
});
|
|
344
532
|
libc = r.order;
|
|
345
533
|
if (r.read)
|
|
346
|
-
done(`Read how ${source.database} orders text (${coll.collate}): kept
|
|
534
|
+
done(`Read how ${source.database} orders text (${coll.collate}): kept with the synced data, read again only when its collation version moves`);
|
|
347
535
|
}
|
|
348
536
|
catch (e) {
|
|
349
537
|
fail(reading);
|
|
350
538
|
say(` ${e.message}`);
|
|
351
539
|
await sync.stop();
|
|
540
|
+
await events.close();
|
|
352
541
|
await store.close();
|
|
353
542
|
return 1;
|
|
354
543
|
}
|
|
@@ -362,26 +551,36 @@ export async function dev(o) {
|
|
|
362
551
|
fail("Starting the Postgres listener");
|
|
363
552
|
say(` MASON_STATEMENT_TIMEOUT=${JSON.stringify(timeoutSetting)} is not a duration: write it as Postgres's statement_timeout is written (5min, 90s, 300000 for ms; 0 for none)`);
|
|
364
553
|
await sync.stop();
|
|
554
|
+
await events.close();
|
|
365
555
|
await store.close();
|
|
366
556
|
return 1;
|
|
367
557
|
}
|
|
368
558
|
// THE REQUEST LOG (src/duck/requests.ts): every statement the listener answers or refuses, kept in the copy's own file
|
|
369
|
-
|
|
370
|
-
|
|
559
|
+
// for MASON_HISTORY_DAYS (read above, before anything opened)
|
|
560
|
+
let requests;
|
|
561
|
+
try {
|
|
562
|
+
requests = await RequestLedger.open(store, logLine, { days: keptDays });
|
|
563
|
+
}
|
|
564
|
+
catch (e) {
|
|
371
565
|
fail("Starting the Postgres listener");
|
|
372
|
-
say(` ${
|
|
566
|
+
say(` the request log (${REQUESTS_TABLE}) could not be opened: ${e.message}`);
|
|
373
567
|
await sync.stop();
|
|
568
|
+
await events.close();
|
|
374
569
|
await store.close();
|
|
375
570
|
return 1;
|
|
376
571
|
}
|
|
377
|
-
|
|
572
|
+
// USAGE OVER TIME (src/duck/usage.ts): a sample a minute, kept as the request log is; it counts the listener's requests
|
|
573
|
+
// as they are recorded, and samples the run's state once the command server's context exists (below)
|
|
574
|
+
let usage;
|
|
378
575
|
try {
|
|
379
|
-
|
|
576
|
+
usage = await UsageSampler.open(store, logLine, { days: keptDays });
|
|
380
577
|
}
|
|
381
578
|
catch (e) {
|
|
382
579
|
fail("Starting the Postgres listener");
|
|
383
|
-
say(` the
|
|
580
|
+
say(` the usage samples (${USAGE_TABLE}) could not be opened: ${e.message}`);
|
|
581
|
+
await requests.close();
|
|
384
582
|
await sync.stop();
|
|
583
|
+
await events.close();
|
|
385
584
|
await store.close();
|
|
386
585
|
return 1;
|
|
387
586
|
}
|
|
@@ -394,20 +593,27 @@ export async function dev(o) {
|
|
|
394
593
|
say(` ${e.message}`);
|
|
395
594
|
await requests.close();
|
|
396
595
|
await sync.stop();
|
|
596
|
+
await usage.close();
|
|
597
|
+
await events.close();
|
|
397
598
|
await store.close();
|
|
398
599
|
return 1;
|
|
399
600
|
}
|
|
400
|
-
logLine(`[
|
|
601
|
+
logLine(`[engine] text sorts as the source's collation does: ${JSON.stringify(duckListener.collation)}`);
|
|
401
602
|
// THE USERS (src/duck/users.ts): `mason user` edits the file beside this process; the listener re-reads it at every
|
|
402
603
|
// connection and before every statement of a user's session, so a removal takes effect without a restart
|
|
403
604
|
const users = new LiveUsers(path.join(home, USERS_FILE), cfg.apiKey);
|
|
605
|
+
// THE ROLES (src/cli/roles.ts): the file read again when it or the copied tables change; a session with a role is
|
|
606
|
+
// held to it at every statement, and the catalog is rebuilt with the roles (src/api/postgres/server.ts)
|
|
607
|
+
const liveRoles = new LiveRoles(folder, project.root, () => sources.tables);
|
|
404
608
|
let api;
|
|
405
609
|
try {
|
|
406
610
|
api = await startPostgresApi({
|
|
407
|
-
port: pgAddress.port, host: pgAddress.host, tls: pgTls.tls, database
|
|
611
|
+
port: pgAddress.port, host: pgAddress.host, tls: pgTls.tls, database, masonVersion: o.version, product: "mason-data", statementTimeoutMs,
|
|
408
612
|
...listenerLogins(users, cfg.apiKey),
|
|
613
|
+
roles: () => liveRoles.now(),
|
|
409
614
|
schema: () => sync.schema(),
|
|
410
|
-
|
|
615
|
+
// the one place a request is recorded: the request log's row, and the usage sample's count of it
|
|
616
|
+
record: (row) => { requests.record(row); usage.count(row); },
|
|
411
617
|
log: logLine,
|
|
412
618
|
trace: process.env.MASON_PG_TRACE === "1",
|
|
413
619
|
duck: duckListener,
|
|
@@ -418,26 +624,104 @@ export async function dev(o) {
|
|
|
418
624
|
say(` ${e.message}`);
|
|
419
625
|
await requests.close();
|
|
420
626
|
await sync.stop();
|
|
627
|
+
await usage.close();
|
|
628
|
+
await events.close();
|
|
421
629
|
await store.close();
|
|
422
630
|
return 1;
|
|
423
631
|
}
|
|
424
632
|
done(`Postgres listener on ${listenerText(pgTls.listener, api.port)}; ${statementTimeoutMs === 0 ? "statements without a time limit" : `each statement at most ${timeoutSetting.trim()}`} (MASON_STATEMENT_TIMEOUT); ` +
|
|
425
|
-
`every request recorded for ${keptDays} day${keptDays === 1 ? "" : "s"} (
|
|
633
|
+
`every request recorded for ${keptDays} day${keptDays === 1 ? "" : "s"} (MASON_HISTORY_DAYS; mason logs --requests)`);
|
|
634
|
+
{
|
|
635
|
+
// a user whose role the file no longer names reads nothing until it is added again (fail closed): said here, by name
|
|
636
|
+
// the row filters checked by the catalog Postgres here (on the tables copied already): a where: Postgres refuses is
|
|
637
|
+
// said now, at its key, before any user meets it
|
|
638
|
+
const roles = await api.checkRoles();
|
|
639
|
+
const list = readUsers(path.join(home, USERS_FILE));
|
|
640
|
+
const orphans = "users" in list ? usersWithoutRole(list.users, new Set(roles.grants.keys())) : [];
|
|
641
|
+
// WHAT A USER ADDED WITHOUT --role READS, SAID ONCE (stage 1b: the file decides it, never the code), and each user
|
|
642
|
+
// the file holds to nothing (no default:) by name, with the way out
|
|
643
|
+
const rolesLine = rolesDefaultLine(roles, shownPath(project.root, rolesFile(folder)));
|
|
644
|
+
say(` ${rolesLine}`);
|
|
645
|
+
logLine(`[postgres] ${rolesLine}`);
|
|
646
|
+
for (const name of "users" in list ? usersWithNoRole(list.users, roles) : []) {
|
|
647
|
+
say(` ! the user ${name} has no role, and ${roles.file ?? ""} names no default: its statements are refused — ${noRoleWayOut(name, roles.file ?? "", exampleRole(roles))}`);
|
|
648
|
+
}
|
|
649
|
+
// a user without a value its role's row filters read: refused at every statement until it is made again with it
|
|
650
|
+
for (const u of "users" in list ? usersMissingAttributes(list.users.filter((x) => x.removedAt === undefined), roles) : []) {
|
|
651
|
+
say(` ! ${missingAttributesText(u)}`);
|
|
652
|
+
}
|
|
653
|
+
for (const u of orphans) {
|
|
654
|
+
say(` ! the user ${u.name} has the role ${u.role}, which ${roles.file ?? "no roles file (mason/roles.yml)"} ${roles.file === null ? "exists to name" : "does not name"}: ` +
|
|
655
|
+
`its statements are refused until the role is in the file, or mason user remove ${u.name} and mason user add ${u.name}${roles.grants.size ? ` --role ${[...roles.grants.keys()][0]}` : ""}`);
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
// WHAT mason share OPENS (src/cli/share-run.ts): a second socket of this listener, on a run on a laptop alone
|
|
659
|
+
const shares = o.serve !== undefined ? undefined : new RunShares({
|
|
660
|
+
listener: api, database, log: logLine,
|
|
661
|
+
// the project's own certificate, as --self-signed-tls serves it: made once, and recorded as that flag records it
|
|
662
|
+
certificate: () => {
|
|
663
|
+
try {
|
|
664
|
+
const pair = selfSignedPair(home);
|
|
665
|
+
if (pair.said)
|
|
666
|
+
logLine(`[postgres] ${pair.said} (SHA-256 ${pair.fingerprint})`);
|
|
667
|
+
const recorded = { cert: SELF_SIGNED_FILES.cert, key: SELF_SIGNED_FILES.key, fingerprint: pair.fingerprint };
|
|
668
|
+
if (JSON.stringify(cfg.selfSignedTls ?? null) !== JSON.stringify(recorded)) {
|
|
669
|
+
cfg.selfSignedTls = recorded;
|
|
670
|
+
writeConfig(home, cfg);
|
|
671
|
+
}
|
|
672
|
+
return pair;
|
|
673
|
+
}
|
|
674
|
+
catch (e) {
|
|
675
|
+
return { message: `the self-signed certificate could not be written to ${home}: ${e.message}`, code: "failed", fix: "this folder must be writable: mason doctor checks the machine" };
|
|
676
|
+
}
|
|
677
|
+
},
|
|
678
|
+
});
|
|
426
679
|
const setupPort = await freePort(o.port, chosen);
|
|
427
680
|
const token = newToken();
|
|
428
|
-
const
|
|
681
|
+
const runCtx = {
|
|
429
682
|
source, facts: () => facts, warnings,
|
|
430
|
-
slot
|
|
683
|
+
// the slot and publication while a table's sync is replication (the run streams), as the sync says it now
|
|
684
|
+
replication: () => sync.streams() ? repl : null, port: setupPort, token, folder: () => folder, root: project.root, home,
|
|
685
|
+
version: o.version, startedAt,
|
|
431
686
|
// the web page (mason ui) is mason start's alone: mason serve's run serves none (src/cli/run-server.ts RunContext.page)
|
|
432
687
|
page: o.serve === undefined,
|
|
433
|
-
|
|
434
|
-
|
|
688
|
+
demo: cfg.demo === true,
|
|
689
|
+
users: { file: path.join(home, USERS_FILE), apiKey: cfg.apiKey, database, postgresUrl },
|
|
690
|
+
// the roles as the listener holds them: their row filters checked by its catalog (a filter Postgres refuses is a problem)
|
|
691
|
+
roles: () => api.roles(),
|
|
692
|
+
...(shares !== undefined ? { shares } : {}),
|
|
435
693
|
// the sources file is the list of what is added: a change to it is read by the sync at once
|
|
436
694
|
sources: () => sources, sourcesChanged: () => { added(); }, readSource: readSources, runUrl: src.resolved,
|
|
695
|
+
// a table set to sync: replication while the run is up, in a run that never checked the database for it: the
|
|
696
|
+
// start's own checks, now, and the edit refused when they fail (src/cli/run-server.ts /api/pick)
|
|
697
|
+
checkReplication: async (file) => {
|
|
698
|
+
const tables = replicatedEntries(file).map((e) => entryName(e.table));
|
|
699
|
+
if (replicationChecked || tables.length === 0)
|
|
700
|
+
return null;
|
|
701
|
+
const shown = shownPath(project.root, file.file);
|
|
702
|
+
const demo = demoChangesRefusal(cfg, file, shown);
|
|
703
|
+
if (demo !== null)
|
|
704
|
+
return { ...demo, fix: `mason add ${tables[0]} --sync "poll 5s" polls it`, next: [[`mason add ${tables[0]} --sync "poll 5s"`, "polls it"]] };
|
|
705
|
+
const ready = await replicationReadiness(source, repl);
|
|
706
|
+
const refused = liveReadinessFailure(ready, tables, shown, source.database);
|
|
707
|
+
if (refused !== null)
|
|
708
|
+
return refused;
|
|
709
|
+
if (!("failure" in ready))
|
|
710
|
+
for (const w of ready.warnings)
|
|
711
|
+
say(` ! ${w.name}: ${w.detail}`);
|
|
712
|
+
replicationChecked = true;
|
|
713
|
+
return null;
|
|
714
|
+
},
|
|
437
715
|
log: (m) => say(` ${m}`),
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
716
|
+
logs,
|
|
717
|
+
// the statements running now, and a cancel of one: the listener's own (a CancelRequest's path)
|
|
718
|
+
listener: api,
|
|
719
|
+
duck: { copies: () => sync.state(), file: duckFile, poll: (sources) => sync.pollNow(sources), stream: (o) => sync.streamStatus(o), measured: (table) => sync.measured(table), disk: () => sync.diskStatus(), requests, events, usage,
|
|
720
|
+
restarts: () => readRestarts(home), jsonPaths: () => sync.kept.status() },
|
|
721
|
+
};
|
|
722
|
+
const server = createRunServer(runCtx);
|
|
723
|
+
// a sample is /api/state's facts at its moment (src/cli/run-server.ts usageFacts): one computation of each
|
|
724
|
+
usage.start(() => usageFacts(runCtx));
|
|
441
725
|
await new Promise((resolve) => server.listen(setupPort, "127.0.0.1", resolve));
|
|
442
726
|
const refreshFacts = setInterval(() => {
|
|
443
727
|
void (async () => {
|
|
@@ -456,25 +740,37 @@ export async function dev(o) {
|
|
|
456
740
|
cfg.running = { pid: process.pid, controlUrl: `http://127.0.0.1:${setupPort}`, token, postgresUrl, postgresListener: pgTls.listener };
|
|
457
741
|
cfg.lastRun = new Date().toISOString();
|
|
458
742
|
writeConfig(home, cfg);
|
|
459
|
-
done(`Mason is up: ${sync.state().length} table(s) added, kept current ${syncMode === "
|
|
460
|
-
:
|
|
743
|
+
done(`Mason is up: ${sync.state().length} table(s) added, kept current: ${syncMode === "poll" ? `as each table's sync: says${syncCounts(sources.tables)}` +
|
|
744
|
+
`${sync.streams() ? `; replicated tables streamed as they change (logical replication: slot ${repl.slot}, publication ${repl.publication}${repl.owned ? "" : ", read as it is"})` : ""}`
|
|
745
|
+
: `each table read again whole every ${PASS_RETRY_MS / 1000} s (MASON_DUCK_SYNC=recopy)`}`);
|
|
746
|
+
// THE FILE'S DEFAULT, SAID ONCE (issue 33: nothing Mason does to your database is a hidden default)
|
|
747
|
+
const defaultLine = syncDefaultLine(sources.file, shownPath(project.root, sources.file.file), syncMode === "poll" ? copiedBefore : 0);
|
|
748
|
+
say(` ${defaultLine}`);
|
|
749
|
+
logLine(`[sync] ${defaultLine}`);
|
|
750
|
+
// THE DEMO'S WRITER: a round of changes every MASON_DEMO_CHANGE_MS, begun once the run is up, said in the log
|
|
751
|
+
if (demo !== null && demoChangeMs !== null)
|
|
752
|
+
hosted.stopChanges = demoChanges(demo, demoChangeMs, (l) => logLine(`[demo] ${l}`));
|
|
461
753
|
// the stop is in place BEFORE the ready line: a supervisor or a test signals on reading it, and a SIGTERM that found
|
|
462
754
|
// no handler would end the run with the default action, never recording `running: null`
|
|
463
755
|
let stopping = false;
|
|
464
756
|
const stopped = new Promise((resolve) => {
|
|
465
|
-
// a crash of the
|
|
757
|
+
// a crash of the engine's thread: the run exits 1 with its reason in V8's fatal form, which the supervisor reads
|
|
466
758
|
// (src/cli/supervise.ts crashReason), and its config still says it runs, so the supervisor starts it again
|
|
467
759
|
crash = (reason) => {
|
|
468
760
|
if (stopping)
|
|
469
761
|
return;
|
|
470
762
|
stopping = true;
|
|
471
|
-
say(`FATAL ERROR: Mason: the
|
|
763
|
+
say(`FATAL ERROR: Mason: the engine's thread stopped (${reason})`);
|
|
472
764
|
clearInterval(refreshFacts);
|
|
765
|
+
hosted.stopChanges?.();
|
|
473
766
|
server.close();
|
|
474
767
|
void (async () => {
|
|
475
768
|
// the sessions were told; the listener is closed so that none waits on a copy that cannot answer
|
|
476
769
|
const closed = api.close().catch((e) => say(` closing the listener: ${e.message}`));
|
|
477
770
|
await Promise.race([closed, new Promise((r) => setTimeout(r, 2_000).unref())]);
|
|
771
|
+
// the demo database's data flushed to its folder before the log it writes to ends (dev() closes it otherwise)
|
|
772
|
+
// (a close still going after 2 s is said: the process ends without it, and the folder may not be whole)
|
|
773
|
+
await closedWithin(closeDemo(hosted, null), 2_000, () => say(` ${DEMO_STILL_CLOSING(2_000)}`));
|
|
478
774
|
log.end();
|
|
479
775
|
resolve(1);
|
|
480
776
|
})();
|
|
@@ -486,8 +782,16 @@ export async function dev(o) {
|
|
|
486
782
|
o.serve?.stopping();
|
|
487
783
|
say("\nmason start: stopping …");
|
|
488
784
|
clearInterval(refreshFacts);
|
|
785
|
+
hosted.stopChanges?.();
|
|
489
786
|
server.close();
|
|
490
787
|
void (async () => {
|
|
788
|
+
// before the listener: a share's command is told why, and its tunnel client stopped
|
|
789
|
+
try {
|
|
790
|
+
await shares?.closeAll("Mason is stopping");
|
|
791
|
+
}
|
|
792
|
+
catch (e) {
|
|
793
|
+
say(` closing the share: ${e.message}`);
|
|
794
|
+
}
|
|
491
795
|
try {
|
|
492
796
|
await api.close();
|
|
493
797
|
}
|
|
@@ -501,15 +805,41 @@ export async function dev(o) {
|
|
|
501
805
|
catch (e) {
|
|
502
806
|
say(` closing the request log: ${e.message}`);
|
|
503
807
|
}
|
|
808
|
+
let syncStop = null;
|
|
504
809
|
try {
|
|
505
810
|
await sync.stop();
|
|
506
|
-
await store.close();
|
|
507
|
-
say(` closed the copy (it stays in ${duckFile})`);
|
|
508
811
|
}
|
|
509
812
|
catch (e) {
|
|
510
|
-
|
|
813
|
+
syncStop = e;
|
|
814
|
+
}
|
|
815
|
+
// after the sync: a sample in flight ends before the file closes (it reads the sync's state)
|
|
816
|
+
try {
|
|
817
|
+
await usage.close();
|
|
818
|
+
}
|
|
819
|
+
catch (e) {
|
|
820
|
+
say(` closing the usage samples: ${e.message}`);
|
|
821
|
+
}
|
|
822
|
+
// after the sync: its last events are written before the file closes
|
|
823
|
+
try {
|
|
824
|
+
await events.close();
|
|
825
|
+
}
|
|
826
|
+
catch (e) {
|
|
827
|
+
say(` closing the sync's events: ${e.message}`);
|
|
828
|
+
}
|
|
829
|
+
// the demo database after the sync (nothing reads it any more) and before the copy closes
|
|
830
|
+
await closeDemo(hosted, `closed the demo database (it stays in ${path.join(home, DEMO_DIR)}; mason destroy removes it)`);
|
|
831
|
+
if (syncStop !== null)
|
|
832
|
+
say(` closing the synced data: ${syncStop.message}`);
|
|
833
|
+
else {
|
|
834
|
+
try {
|
|
835
|
+
await store.close();
|
|
836
|
+
say(` closed the synced data (it stays in ${duckFile})`);
|
|
837
|
+
}
|
|
838
|
+
catch (e) {
|
|
839
|
+
say(` closing the synced data: ${e.message}`);
|
|
840
|
+
}
|
|
511
841
|
}
|
|
512
|
-
if (
|
|
842
|
+
if ((cfg.replication ?? null) !== null)
|
|
513
843
|
say(` replication slot ${repl.slot} stays on ${source.database}, holding its WAL until the next mason start reads it (mason destroy drops it)` +
|
|
514
844
|
(repl.owned ? "" : `; publication ${repl.publication} is its owner's, left as it is`));
|
|
515
845
|
cfg.running = null;
|
|
@@ -526,14 +856,17 @@ export async function dev(o) {
|
|
|
526
856
|
say("");
|
|
527
857
|
say(` Postgres ${withPlaceholders(postgresUrl)} (mason user add <name> makes a user)`);
|
|
528
858
|
// the web page by its command, never its URL: the URL carries the run's token, and this goes to dev.log
|
|
859
|
+
// its words are mason ui's summary (mason --help's), so the two never drift
|
|
529
860
|
if (o.serve === undefined)
|
|
530
|
-
say(
|
|
861
|
+
say(` Web UI mason ui (${UI.summary})`);
|
|
862
|
+
if (demoChangeMs !== null)
|
|
863
|
+
say(` Demo a new order lands every ${demoChangeMs / 1000} s: mason status shows the synced data keeping up`);
|
|
531
864
|
const elsewhere = elsewhereTip(pgTls.listener);
|
|
532
865
|
if (elsewhere)
|
|
533
866
|
say(` ${elsewhere}`);
|
|
534
867
|
say(READY);
|
|
535
868
|
say("");
|
|
536
|
-
o.serve?.ready({ tables: () => sync.state() });
|
|
869
|
+
o.serve?.ready({ tables: () => sync.state(), metrics: () => metricsFacts(runCtx) });
|
|
537
870
|
return await stopped;
|
|
538
871
|
}
|
|
539
872
|
/**
|
|
@@ -547,7 +880,7 @@ function listenerTls(listen, selfSigned, home, cfg) {
|
|
|
547
880
|
if (selfSigned) {
|
|
548
881
|
let made;
|
|
549
882
|
try {
|
|
550
|
-
made = ensureSelfSigned(home
|
|
883
|
+
made = ensureSelfSigned(home);
|
|
551
884
|
}
|
|
552
885
|
catch (e) {
|
|
553
886
|
return { problem: `the self-signed certificate could not be written to ${home}: ${e.message}`, code: "failed" };
|
|
@@ -571,6 +904,6 @@ function listenerTls(listen, selfSigned, home, cfg) {
|
|
|
571
904
|
/** The line a run writes once it answers commands: what `mason start` in the background waits for (src/cli/background.ts),
|
|
572
905
|
* and what tells its supervisor a death after it is a crash to restart (src/cli/supervise.ts, which defines it). */
|
|
573
906
|
export const READY = READY_LINE;
|
|
574
|
-
/** The one sentence of how the pieces fit, printed first by `mason start` and at the top of its help
|
|
575
|
-
export const PROJECT_LINE = "A Mason project is your app's folder: mason/sources/ names the database and the tables to
|
|
576
|
-
"this machine's
|
|
907
|
+
/** The one sentence of how the pieces fit, printed first by `mason start` and at the top of its help. */
|
|
908
|
+
export const PROJECT_LINE = "A Mason project is your app's folder: mason/sources/ names the database and the tables to sync, yours to commit; " +
|
|
909
|
+
"this machine's synced data and Mason's state are in .mason/, rebuilt from that file.";
|