@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
|
@@ -13,7 +13,8 @@
|
|
|
13
13
|
*/
|
|
14
14
|
import * as fs from "node:fs";
|
|
15
15
|
import * as path from "node:path";
|
|
16
|
-
import { addedTables, chooseColumns, entryName, entryProblems, findEntry, parseSourceText, sourceFailure, sourceFileNames, sourcesDir, unaddableReason, withoutTable, withTable } from "./sources.js";
|
|
16
|
+
import { addedTables, chooseColumns, effectiveSync, entryName, entryProblems, findEntry, parseSourceText, sourceFailure, sourceFileNames, sourcesDir, unaddableReason, withEntrySync, withoutTable, withTable } from "./sources.js";
|
|
17
|
+
import { BIG_TABLE_ROWS, COSTLY_POLL_ROWS, COSTLY_POLL_SECONDS, syncText } from "../lib/table-sync.js";
|
|
17
18
|
/** A refusal the command shows as it is; `failure` is how the web page says it (the CLI's Failure: why, fix, next), by
|
|
18
19
|
* default the message alone under the status's code. */
|
|
19
20
|
export class Refusal extends Error {
|
|
@@ -23,6 +24,31 @@ export class Refusal extends Error {
|
|
|
23
24
|
}
|
|
24
25
|
/** The FAILURE_CODES code (src/cli/command.ts) of a refusal by its HTTP status. */
|
|
25
26
|
const codeOf = (status) => status === 400 ? "usage" : status === 404 ? "not_found" : status >= 500 ? "failed" : "refused";
|
|
27
|
+
/**
|
|
28
|
+
* A SCAN'S RATE WHEN NOTHING OF THE TABLE IS MEASURED, rows a second on one backend: the test server's production
|
|
29
|
+
* replica scanned a 22 M-row table in 12 s at every poll (docs/DESIGN.md "The poll") — an assumption, said as one.
|
|
30
|
+
*/
|
|
31
|
+
const ASSUMED_SCAN_ROWS_PER_SECOND = 1_800_000;
|
|
32
|
+
/**
|
|
33
|
+
* WHAT A POLL OF A BIG TABLE WOULD COST THE DATABASE, estimated before it runs (issue 33: "a poll never takes more than
|
|
34
|
+
* a fifth of the source's time" bounds the share, not the backend's seconds — a 200 M-row table set to poll 5m is
|
|
35
|
+
* still a standing load): seconds a poll, from the best basis there is — its last poll, measured; else its copy's read
|
|
36
|
+
* rate, a reader's (an upper bound: a poll sends only the changed rows); else the catalog's row estimate at an assumed
|
|
37
|
+
* scan rate — and the basis said. Null for a table under `COSTLY_POLL_ROWS`, or set to poll every minute or slower.
|
|
38
|
+
*/
|
|
39
|
+
export function pollCost(t, sync, m) {
|
|
40
|
+
if (sync.mode !== "poll" || sync.seconds >= COSTLY_POLL_SECONDS || t.rows === null || t.rows < COSTLY_POLL_ROWS)
|
|
41
|
+
return null;
|
|
42
|
+
const rows = t.rows.toLocaleString("en-US");
|
|
43
|
+
const [seconds, basis] = m?.lastPollMs != null ? [m.lastPollMs / 1000, "its last poll, measured"]
|
|
44
|
+
: m?.copyRowsPerSecond ? [t.rows / m.copyRowsPerSecond, `its first sync's read rate, ${Math.round(m.copyRowsPerSecond).toLocaleString("en-US")} rows a second a reader (a poll sends only the rows that changed, so it is at most that)`]
|
|
45
|
+
: [t.rows / ASSUMED_SCAN_ROWS_PER_SECOND, `the catalog's ${rows} rows at an assumed ${ASSUMED_SCAN_ROWS_PER_SECOND.toLocaleString("en-US")} rows a second of scan (nothing of it measured yet)`];
|
|
46
|
+
const every = Math.max(sync.seconds, 4 * seconds);
|
|
47
|
+
const s = (x) => x < 90 ? `${Math.round(x)} s` : x < 5400 ? `${Math.round(x / 60)} min` : `${(x / 3600).toFixed(1)} h`;
|
|
48
|
+
return `${entryName(t.source)} has about ${rows} rows, and each poll scans it whole on your database: about ${s(seconds)} a poll, from ${basis}. ` +
|
|
49
|
+
`sync: ${syncText(sync)} is stretched to a poll every ${s(every)} at most (a poll waits four times its own time), one backend of your database ` +
|
|
50
|
+
`busy ${Math.round((seconds / every) * 100)} % of the time — a longer interval (--sync "poll 1h"), or --sync replication (the change log), costs it less`;
|
|
51
|
+
}
|
|
26
52
|
/**
|
|
27
53
|
* The project's sources file as it is on disk NOW, its text and what it says — or why it cannot be edited: no folder, not
|
|
28
54
|
* exactly one file, a file with mistakes of its own, a URL that cannot be resolved (`read`, the file read as the run reads
|
|
@@ -49,7 +75,7 @@ export function sourceToEdit(folder, root, read) {
|
|
|
49
75
|
/** The file's other entries against the database: an edit is never written over a file the run would not take. */
|
|
50
76
|
function othersHold(at, db, without, then) {
|
|
51
77
|
const others = { ...at.parsed, tables: at.parsed.tables.filter((e) => !without.includes(e.table)) };
|
|
52
|
-
const held = addedTables(others, db.tables, db.database, at.root
|
|
78
|
+
const held = addedTables(others, db.tables, db.database, at.root);
|
|
53
79
|
if (!Array.isArray(held))
|
|
54
80
|
throw new Refusal(409, `${held.message}${held.why ? `\n${held.why}` : ""} — fix it first, then ${then}`);
|
|
55
81
|
}
|
|
@@ -58,35 +84,61 @@ function othersHold(at, db, without, then) {
|
|
|
58
84
|
* table's own (`withTable`): every other byte of the file as it was. `columns` are the ones asked (`["*"]`: every one);
|
|
59
85
|
* `key` the columns a table with no primary key is copied by. The entry and the file's other entries must hold against
|
|
60
86
|
* the database first: an edit is never written over a file the next read would refuse.
|
|
87
|
+
*
|
|
88
|
+
* ITS SYNC (`o.sync`, --sync): written on the entry as its own `sync:`; else an entry's own is kept; else a NEW entry
|
|
89
|
+
* follows the file's default — unless the table is over `BIG_TABLE_ROWS` and that default polls, when the entry is
|
|
90
|
+
* written `sync: off` and said (`bigOff`): a poll scans its table whole — or the default is `replication` and the table
|
|
91
|
+
* cannot be (a `--key` table, REPLICA IDENTITY NOTHING), when it is written `sync: poll 5m` and said why (`notReplicated`;
|
|
92
|
+
* an explicit `--sync replication` of such a table is refused, at its entry). `o.keep`: only the sync is asked of a table
|
|
93
|
+
* already added (`mason add orders --sync "poll 30s"`), so its columns and key stay as they are, and only its `sync:`
|
|
94
|
+
* line is written (`withEntrySync`: its comments too).
|
|
61
95
|
*/
|
|
62
|
-
export function addEntry(at, db, table, columns = ["*"], key = null) {
|
|
96
|
+
export function addEntry(at, db, table, columns = ["*"], key = null, o = {}) {
|
|
63
97
|
const t = db.tables.find((x) => x.source === table);
|
|
64
98
|
if (!t)
|
|
65
99
|
throw new Refusal(404, `${table} is not a table of ${db.database} that this user can read`);
|
|
66
100
|
if (key !== null && t.key.length)
|
|
67
|
-
throw new Refusal(400, `${table} has a primary key (${t.key.join(", ")}), which Mason
|
|
101
|
+
throw new Refusal(400, `${table} has a primary key (${t.key.join(", ")}), which Mason keys it by: leave --key out`);
|
|
68
102
|
if (key === null && !t.key.length)
|
|
69
103
|
throw new Refusal(409, `${table} cannot be added: ${unaddableReason(t)}`);
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
const chosen = chooseColumns(t, columns, key);
|
|
104
|
+
const was = at.parsed.tables.find((e) => e.table === table);
|
|
105
|
+
const keep = o.keep === true && was !== undefined;
|
|
106
|
+
const chosen = keep ? { columns: was.columns, exclude: was.exclude, addedKey: [], skipped: [] } : chooseColumns(t, columns, key);
|
|
73
107
|
if ("problem" in chosen)
|
|
74
108
|
throw new Refusal(400, chosen.problem);
|
|
75
109
|
othersHold(at, db, [table], `add ${entryName(table)}`);
|
|
76
|
-
|
|
77
|
-
const
|
|
110
|
+
// its own sync: asked, else the one its entry has, else none (the file's) — but a new big table under a polling default
|
|
111
|
+
const fileSync = effectiveSync(at.parsed, undefined).sync;
|
|
112
|
+
const bigOff = o.sync === undefined && was === undefined && fileSync.mode === "poll" && t.rows !== null && t.rows > BIG_TABLE_ROWS;
|
|
113
|
+
// a table logical replication cannot keep, under a file whose default is replication: polled, said (never refused for
|
|
114
|
+
// the default it did not choose; its own --sync replication is refused at its entry, below)
|
|
115
|
+
const keyed = keep ? was.key !== null : key !== null;
|
|
116
|
+
const unreplicable = keyed ? "it has no primary key, so logical replication cannot apply its changes by its key:"
|
|
117
|
+
: t.replicaIdentity === "n" ? "it has REPLICA IDENTITY NOTHING, and a publication carrying it would make your app's own UPDATEs and DELETEs on it fail" : null;
|
|
118
|
+
const notReplicated = o.sync === undefined && (was?.sync ?? null) === null && fileSync.mode === "replication" ? unreplicable : null;
|
|
119
|
+
// --sync replication asked of one: refused with the COMMAND to run (the person is at it), its own flags kept — the
|
|
120
|
+
// file's words are for an entry written by hand (src/cli/sources.ts entryProblems)
|
|
121
|
+
if (o.sync?.mode === "replication" && unreplicable !== null) {
|
|
122
|
+
const others = columns.filter((c) => c !== "*");
|
|
123
|
+
const flags = keep ? "" : `${columns.includes("*") ? (others.length ? ` --include ${others.join(",")}` : "") : ` --columns ${others.join(",")}`}${key !== null ? ` --key ${key.join(",")}` : ""}`;
|
|
124
|
+
throw new Refusal(400, `${table} cannot be kept current by logical replication: ${unreplicable} — mason add ${entryName(table)}${flags} --sync "poll 5m" polls it instead`);
|
|
125
|
+
}
|
|
126
|
+
const sync = o.sync ?? was?.sync ?? (bigOff ? { mode: "off" } : notReplicated !== null ? { mode: "poll", every: "5m", seconds: 300 } : null);
|
|
127
|
+
const entry = { name: was?.name ?? entryName(table), table, columns: chosen.columns, exclude: chosen.exclude, key: keep ? was.key : key === null ? null : [...key], sync };
|
|
78
128
|
// the entry itself, as the run will read it: a --key that does not tell the rows apart is refused here, not at the copy
|
|
79
|
-
const own = entryProblems(t, entry, db.tables, db.database,
|
|
129
|
+
const own = entryProblems(t, entry, db.tables, db.database, effectiveSync(at.parsed, entry).sync);
|
|
80
130
|
if (own.length)
|
|
81
131
|
throw new Refusal(400, own.join("; "));
|
|
82
|
-
const same = was !== undefined && JSON.stringify([was.columns, was.exclude, was.key]) === JSON.stringify([entry.columns, entry.exclude, entry.key]);
|
|
132
|
+
const same = was !== undefined && JSON.stringify([was.columns, was.exclude, was.key, was.sync]) === JSON.stringify([entry.columns, entry.exclude, entry.key, entry.sync]);
|
|
83
133
|
if (!same) {
|
|
84
|
-
const r = withTable(at.text, entry);
|
|
134
|
+
const r = keep ? withEntrySync(at.text, was, sync) : withTable(at.text, entry);
|
|
85
135
|
if ("problem" in r)
|
|
86
136
|
throw new Refusal(409, `${path.relative(at.root, at.file)} cannot be edited: ${r.problem}`);
|
|
87
137
|
fs.writeFileSync(at.file, r.text);
|
|
88
138
|
}
|
|
89
|
-
|
|
139
|
+
const eff = effectiveSync(at.parsed, entry);
|
|
140
|
+
return { table, file: at.file, entry: entry.name, columns: entry.columns, exclude: entry.exclude, key: entry.key, addedKey: chosen.addedKey, skippedSecrets: chosen.skipped, already: same,
|
|
141
|
+
sync: { value: syncText(eff.sync), from: eff.from }, bigOff, notReplicated, pollCost: pollCost(t, eff.sync, db.measured?.(table) ?? null) };
|
|
90
142
|
}
|
|
91
143
|
/**
|
|
92
144
|
* REMOVE TABLES from the sources file: each one's entry cut (`withoutTable`), every other byte of the file as it was. A
|
package/dist/src/cli/sources.js
CHANGED
|
@@ -5,9 +5,16 @@
|
|
|
5
5
|
*
|
|
6
6
|
* kind: postgres
|
|
7
7
|
* url: ${DATABASE_URL} an env var from .env.local, .env or the shell; a literal URL for local dev
|
|
8
|
+
* database: production the database name a tool connects with (none: mason; src/lib/database-name.ts)
|
|
9
|
+
* sync: off how each table is kept current after its first sync (src/lib/table-sync.ts): off
|
|
10
|
+
* (the default: until mason sync <table>), poll <interval> or replication; an entry's
|
|
11
|
+
* own overrides it
|
|
12
|
+
* publication: ${PG_PUBLICATION} the publication `replication` reads, its owner's (never altered); none: Mason's own
|
|
13
|
+
* (the literal `publication: null` says so, past one an earlier start recorded)
|
|
8
14
|
* tables:
|
|
9
15
|
* orders: a bare name is in `public`; `billing.invoices` names its schema
|
|
10
16
|
* columns: [id, customer_id, status]
|
|
17
|
+
* sync: poll 30s this table polled every 30 seconds
|
|
11
18
|
* customers: {} every column
|
|
12
19
|
* events:
|
|
13
20
|
* exclude: [raw_body] every column but these
|
|
@@ -26,13 +33,40 @@
|
|
|
26
33
|
import * as fs from "node:fs";
|
|
27
34
|
import * as path from "node:path";
|
|
28
35
|
import { isMap, isScalar, parseDocument } from "yaml";
|
|
36
|
+
import { databaseNameProblem, DEFAULT_DATABASE } from "../lib/database-name.js";
|
|
29
37
|
import { appEnvValue, ENV_FILE } from "../lib/dotenv.js";
|
|
30
38
|
import { closest, didYouMean } from "../lib/did-you-mean.js";
|
|
31
39
|
import { maskedUrl } from "./preflight.js";
|
|
40
|
+
import { DEFAULT_SYNC, parseSync, syncText } from "../lib/table-sync.js";
|
|
32
41
|
export const SOURCES_DIR = "sources";
|
|
33
|
-
const KEYS = ["kind", "url", "tables"];
|
|
34
|
-
const ENTRY_KEYS = ["columns", "exclude", "key"];
|
|
42
|
+
const KEYS = ["kind", "url", "database", "sync", "publication", "tables"];
|
|
43
|
+
const ENTRY_KEYS = ["columns", "exclude", "key", "sync"];
|
|
35
44
|
const ENV_REF = /^\$\{([A-Za-z_][A-Za-z0-9_]*)\}$/;
|
|
45
|
+
/** The tables a file keeps current by replication, by their effective sync (`effectiveSync`): what streams. */
|
|
46
|
+
export function replicatedEntries(src) {
|
|
47
|
+
return src.tables.filter((e) => effectiveSync(src, e).sync.mode === "replication");
|
|
48
|
+
}
|
|
49
|
+
/** Whether the file says how anything is kept current: a `sync:` at its top or on any entry. A file with none has every
|
|
50
|
+
* table on the default, by no one's choice: what an older Mason's file looks like. */
|
|
51
|
+
export function syncWritten(file) {
|
|
52
|
+
return file.sync !== null || file.tables.some((e) => e.sync !== null);
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* A FILE THAT HAS GONE SILENT OVER A PROJECT THAT STREAMS: it names tables and no `sync:`, where the config records a
|
|
56
|
+
* slot. Silent is no decision (a file with no table has nothing left to stream). One rule for the start, whose refusal
|
|
57
|
+
* is `recordRefusal` (src/cli/replication-mode.ts), and the run's re-read (`rereadSources`).
|
|
58
|
+
*/
|
|
59
|
+
export function silentOverSlot(slotRecorded, file) {
|
|
60
|
+
return slotRecorded && file.tables.length > 0 && !syncWritten(file);
|
|
61
|
+
}
|
|
62
|
+
/** A table's sync: its entry's, else the file's, else the default (`off`) — and which of the three said it. */
|
|
63
|
+
export function effectiveSync(src, e) {
|
|
64
|
+
if (e?.sync)
|
|
65
|
+
return { sync: e.sync, from: "entry" };
|
|
66
|
+
if (src.sync)
|
|
67
|
+
return { sync: src.sync, from: "file" };
|
|
68
|
+
return { sync: DEFAULT_SYNC, from: "default" };
|
|
69
|
+
}
|
|
36
70
|
/** A file as messages name it: relative to the project's root. */
|
|
37
71
|
export const shownPath = (root, file) => path.relative(root, file) || file;
|
|
38
72
|
/** A table as a key of the file: its name alone in public, else schema.table. */
|
|
@@ -90,6 +124,25 @@ function nameList(v, at, what) {
|
|
|
90
124
|
return `${at}: names ${String(twice)} twice`;
|
|
91
125
|
return v;
|
|
92
126
|
}
|
|
127
|
+
/** The file's `database:`: the name it writes, null when it has no such line, or the problem with it (`text`, the file's). */
|
|
128
|
+
function databaseKey(doc, text) {
|
|
129
|
+
const node = isMap(doc.contents) ? doc.contents.get("database", true) : undefined;
|
|
130
|
+
if (node === undefined)
|
|
131
|
+
return { database: null };
|
|
132
|
+
const way = `write database: <name>, or take the line out: tools then connect with ${DEFAULT_DATABASE}`;
|
|
133
|
+
if (!isScalar(node))
|
|
134
|
+
return { problem: `database: is one name, not a list or a map — ${way}` };
|
|
135
|
+
if (node.value === null)
|
|
136
|
+
return { problem: `database: is empty — ${way}` };
|
|
137
|
+
if (typeof node.value !== "string") {
|
|
138
|
+
const written = text.slice(node.range[0], node.range[1]);
|
|
139
|
+
return { problem: `database: ${yamlRead(node.value).replace(/^\S+/, written)} — quote it: "${written}"` };
|
|
140
|
+
}
|
|
141
|
+
const bad = databaseNameProblem(node.value);
|
|
142
|
+
return bad === null ? { database: node.value } : { problem: `database: ${bad} — ${way}` };
|
|
143
|
+
}
|
|
144
|
+
/** A file that is YAML and no mapping, said; `v` is what it read as. */
|
|
145
|
+
const notMapping = (v) => `it is ${v === null || v === undefined ? "empty" : "not a mapping"}: a source file is kind: postgres, url: \${DATABASE_URL} and tables: {}`;
|
|
93
146
|
/**
|
|
94
147
|
* The file's text read as a source file: its shape, never the database (that is `addedTables`'s). `name` is the
|
|
95
148
|
* source's (the file's name); the problems are each at their key, in the order they are in the file.
|
|
@@ -100,7 +153,7 @@ export function parseSourceText(text, name, file) {
|
|
|
100
153
|
return { problems: [`it is not YAML: ${doc.errors[0].message.split("\n")[0]}`] };
|
|
101
154
|
const v = doc.toJS();
|
|
102
155
|
if (!isPlain(v))
|
|
103
|
-
return { problems: [
|
|
156
|
+
return { problems: [notMapping(v)] };
|
|
104
157
|
const problems = [];
|
|
105
158
|
for (const k of Object.keys(v)) {
|
|
106
159
|
if (!KEYS.includes(k))
|
|
@@ -132,6 +185,38 @@ export function parseSourceText(text, name, file) {
|
|
|
132
185
|
else
|
|
133
186
|
url = { literal: v.url.trim() };
|
|
134
187
|
}
|
|
188
|
+
// the name a tool connects with: written out, the same on every machine (src/lib/database-name.ts)
|
|
189
|
+
const named = databaseKey(doc, text);
|
|
190
|
+
if ("problem" in named)
|
|
191
|
+
problems.push(named.problem);
|
|
192
|
+
// the publication a replicated table is read through: a name, or ${NAME} as url: takes one (resolved by readSource)
|
|
193
|
+
let publication = null;
|
|
194
|
+
// the word `null`, as written: Mason's own, on purpose, past a name a start recorded (src/cli/replication-mode.ts
|
|
195
|
+
// recordRefusal). A key left with no value is a line half-written, never that decision; no line at all says nothing
|
|
196
|
+
const publicationNode = doc.get("publication", true);
|
|
197
|
+
if (v.publication === null && isScalar(publicationNode) && publicationNode.source === "null")
|
|
198
|
+
publication = { own: true };
|
|
199
|
+
else if (v.publication !== undefined) {
|
|
200
|
+
const p = typeof v.publication === "string" ? v.publication.trim() : "";
|
|
201
|
+
const m = ENV_REF.exec(p);
|
|
202
|
+
if (p === "")
|
|
203
|
+
problems.push("publication: write publication: ${PG_PUBLICATION}, or a publication's name (publication: null and Mason makes its own)");
|
|
204
|
+
else if (m)
|
|
205
|
+
publication = { env: m[1] };
|
|
206
|
+
else if (p.includes("$"))
|
|
207
|
+
problems.push(`publication: ${JSON.stringify(p)} — write \${NAME} alone, like \${PG_PUBLICATION}, or the publication's name`);
|
|
208
|
+
else
|
|
209
|
+
publication = { literal: p };
|
|
210
|
+
}
|
|
211
|
+
// every table's sync, unless its entry says its own (src/lib/table-sync.ts: one grammar, `mason add --sync` reads it too)
|
|
212
|
+
let sync = null;
|
|
213
|
+
if (v.sync !== undefined) {
|
|
214
|
+
const s = parseSync(v.sync);
|
|
215
|
+
if ("problem" in s)
|
|
216
|
+
problems.push(`sync: ${s.problem}`);
|
|
217
|
+
else
|
|
218
|
+
sync = s;
|
|
219
|
+
}
|
|
135
220
|
const tables = [];
|
|
136
221
|
const tablesNode = isMap(doc.contents) ? doc.contents.items.find((p) => isScalar(p.key) && p.key.value === "tables")?.value : undefined;
|
|
137
222
|
const odd = new Set();
|
|
@@ -167,30 +252,33 @@ export function parseSourceText(text, name, file) {
|
|
|
167
252
|
}
|
|
168
253
|
seen.set(table, key);
|
|
169
254
|
if (raw !== null && !isPlain(raw)) {
|
|
170
|
-
problems.push(`${at}: is {} (every column), { columns: [...] }, { exclude: [...] }
|
|
255
|
+
problems.push(`${at}: is {} (every column), { columns: [...] }, { exclude: [...] }, { key: [...] } or { sync: poll 5m }`);
|
|
171
256
|
continue;
|
|
172
257
|
}
|
|
173
258
|
const e = raw ?? {};
|
|
174
259
|
const stray = Object.keys(e).filter((k) => !ENTRY_KEYS.includes(k));
|
|
175
260
|
for (const k of stray)
|
|
176
|
-
problems.push(`${at}: unexpected key ${k}${didYouMean(k, ENTRY_KEYS)} — a table takes columns, exclude and
|
|
261
|
+
problems.push(`${at}: unexpected key ${k}${didYouMean(k, ENTRY_KEYS)} — a table takes columns, exclude, key and sync`);
|
|
177
262
|
const both = e.columns !== undefined && e.exclude !== undefined;
|
|
178
263
|
if (both)
|
|
179
|
-
problems.push(`${at}: columns and exclude together — columns names what is
|
|
180
|
-
const columns = e.columns === undefined ? null : nameList(e.columns, `${at}.columns`, "the columns
|
|
264
|
+
problems.push(`${at}: columns and exclude together — columns names what is synced, exclude what is left out of every column; keep one`);
|
|
265
|
+
const columns = e.columns === undefined ? null : nameList(e.columns, `${at}.columns`, "the columns synced");
|
|
181
266
|
const exclude = e.exclude === undefined ? [] : nameList(e.exclude, `${at}.exclude`, "the columns left out");
|
|
182
267
|
const ownKey = e.key === undefined ? null : nameList(e.key, `${at}.key`, "the columns that tell its rows apart");
|
|
183
268
|
for (const x of [columns, exclude, ownKey])
|
|
184
269
|
if (typeof x === "string")
|
|
185
270
|
problems.push(x);
|
|
186
|
-
|
|
271
|
+
const ownSync = e.sync === undefined ? null : parseSync(e.sync);
|
|
272
|
+
if (ownSync !== null && "problem" in ownSync)
|
|
273
|
+
problems.push(`${at}.sync: ${ownSync.problem}`);
|
|
274
|
+
if (typeof columns === "string" || typeof exclude === "string" || typeof ownKey === "string" || stray.length || both || (ownSync !== null && "problem" in ownSync))
|
|
187
275
|
continue;
|
|
188
|
-
tables.push({ name: key, table, columns, exclude, key: ownKey });
|
|
276
|
+
tables.push({ name: key, table, columns, exclude, key: ownKey, sync: ownSync });
|
|
189
277
|
}
|
|
190
278
|
}
|
|
191
|
-
if (problems.length || kind === null || url === null)
|
|
279
|
+
if (problems.length || kind === null || url === null || "problem" in named)
|
|
192
280
|
return { problems };
|
|
193
|
-
return { name, file, kind, url, tables };
|
|
281
|
+
return { name, file, kind, url, database: named.database, sync, publication, tables };
|
|
194
282
|
}
|
|
195
283
|
/** Where a project's source files are: `<folder>/sources/`. */
|
|
196
284
|
export const sourcesDir = (folder) => path.join(folder, SOURCES_DIR);
|
|
@@ -201,16 +289,80 @@ export function sourceFileNames(folder) {
|
|
|
201
289
|
return [];
|
|
202
290
|
return fs.readdirSync(dir).filter((f) => /\.ya?ml$/.test(f) && !f.startsWith(".")).sort();
|
|
203
291
|
}
|
|
204
|
-
/**
|
|
205
|
-
*
|
|
206
|
-
|
|
207
|
-
|
|
292
|
+
/**
|
|
293
|
+
* THE TOP OF A PROJECT'S SOURCES FILE, for a retired setting's way out (src/lib/table-sync.ts `topEdit`): its one file
|
|
294
|
+
* as a person names it from `root`, and the values its top-level `sync:` and `publication:` lines hold as written, so
|
|
295
|
+
* the way out changes the line the file has rather than writing a second. No file (or several): the place every
|
|
296
|
+
* project's file has, and no line. A file that cannot be read or parsed is said by the run in its own words
|
|
297
|
+
* (`readSource`); here it only words the edit, as for a file with no such line.
|
|
298
|
+
*/
|
|
299
|
+
export function sourcesTop(root, folder) {
|
|
300
|
+
const names = sourceFileNames(folder);
|
|
301
|
+
if (names.length !== 1)
|
|
302
|
+
return { shown: `${shownPath(root, sourcesDir(folder))}/<name>.yml`, sync: null, publication: null };
|
|
303
|
+
const file = path.join(sourcesDir(folder), names[0]);
|
|
304
|
+
const shown = shownPath(root, file);
|
|
305
|
+
let text;
|
|
306
|
+
try {
|
|
307
|
+
text = fs.readFileSync(file, "utf8");
|
|
308
|
+
}
|
|
309
|
+
catch (e) {
|
|
310
|
+
// the run's own read says it, with the file and the error (readSource); the edit is worded as for no line
|
|
311
|
+
if (e.code === undefined)
|
|
312
|
+
throw e;
|
|
313
|
+
return { shown, sync: null, publication: null };
|
|
314
|
+
}
|
|
315
|
+
const doc = parseDocument(text);
|
|
316
|
+
const at = (key) => {
|
|
317
|
+
if (!isMap(doc.contents))
|
|
318
|
+
return null;
|
|
319
|
+
const node = doc.contents.get(key, true);
|
|
320
|
+
if (node === undefined || node === null)
|
|
321
|
+
return null;
|
|
322
|
+
return isScalar(node) ? String(node.value) : String(node);
|
|
323
|
+
};
|
|
324
|
+
return { shown, sync: at("sync"), publication: at("publication") };
|
|
325
|
+
}
|
|
326
|
+
/**
|
|
327
|
+
* THE `database:` A PROJECT WRITES, from the file alone and only its one key (src/lib/database-name.ts): what `mason user
|
|
328
|
+
* add` says with Mason stopped, which needs neither the database's URL nor the rest of the file right. No file writes
|
|
329
|
+
* none. A file it cannot read this key from is refused, in the failure shape: guessing `mason` would hand a tool a name
|
|
330
|
+
* the run may not answer to.
|
|
331
|
+
*/
|
|
332
|
+
export function writtenDatabase(folder, root) {
|
|
333
|
+
const names = sourceFileNames(folder);
|
|
334
|
+
if (names.length === 0)
|
|
335
|
+
return { database: null };
|
|
336
|
+
if (names.length > 1)
|
|
337
|
+
return tooManySources(root, sourcesDir(folder), names);
|
|
338
|
+
const file = path.join(sourcesDir(folder), names[0]);
|
|
339
|
+
const text = fs.readFileSync(file, "utf8");
|
|
340
|
+
const doc = parseDocument(text, { uniqueKeys: true });
|
|
341
|
+
if (doc.errors.length)
|
|
342
|
+
return sourceFailure(file, root, [`it is not YAML: ${doc.errors[0].message.split("\n")[0]}`]);
|
|
343
|
+
if (!isMap(doc.contents))
|
|
344
|
+
return sourceFailure(file, root, [notMapping(doc.toJS())]);
|
|
345
|
+
const named = databaseKey(doc, text);
|
|
346
|
+
return "problem" in named ? sourceFailure(file, root, [named.problem]) : named;
|
|
347
|
+
}
|
|
348
|
+
/** The file `mason init` writes: the database, how its tables are kept current (`sync`: `off`, the default; the demo's
|
|
349
|
+
* `poll 5s`, so it shows a copy keeping up), then its tables — none yet, but for `mason init demo`'s, which names the
|
|
350
|
+
* demo database's tables (`tables`, each copied whole: `{}`), so its first start copies them and shows something.
|
|
351
|
+
* `database:` only when one is asked for (`--database`): none written, the default stays a default. */
|
|
352
|
+
export function newSourceText(url = "${DATABASE_URL}", tables = [], sync = DEFAULT_SYNC, database = null) {
|
|
353
|
+
return "# The database this project syncs from, and which of its tables. Yours to commit; the URL stays out of it.\n" +
|
|
354
|
+
(database === null ? `# Tools connect with the database name ${DEFAULT_DATABASE}; database: <name> here keeps the name a tool already has.\n` : "") +
|
|
208
355
|
"kind: postgres\n" +
|
|
209
356
|
`url: ${url}${ENV_REF.test(url) ? " # from .env.local, .env or the shell; a literal postgres:// URL works too" : ""}\n` +
|
|
357
|
+
(database === null ? "" : `database: ${yamlName(database)} # the database name a tool connects with\n`) +
|
|
358
|
+
`sync: ${syncText(sync)}${sync.mode === "off" ? " # after its first sync, a table is read again only by mason sync <table> (poll 5m: every 5 minutes)"
|
|
359
|
+
: sync.mode === "replication" ? " # each table kept current from the database's change log after its first sync (poll 5m, or off, on an entry)"
|
|
360
|
+
: ` # each table polled every ${sync.every} after its first sync (off: read again only by mason sync <table>)`}\n` +
|
|
210
361
|
"\n" +
|
|
211
|
-
"# One entry per table, a bare name in public, else schema.table: {}
|
|
362
|
+
"# One entry per table, a bare name in public, else schema.table: {} syncs every column, columns: [id, status]\n" +
|
|
212
363
|
"# those, exclude: [payload] every column but those; key: [a, b] names the columns that tell its rows apart, for a\n" +
|
|
213
|
-
"# table with no primary key. mason add and mason remove
|
|
364
|
+
"# table with no primary key; sync: poll 30s (or off) keeps that table current its own way. mason add and mason remove\n" +
|
|
365
|
+
"# edit this list; so can you.\n" +
|
|
214
366
|
(tables.length ? `tables:\n${tables.map((t) => ` ${t}: {}\n`).join("")}` : "tables: {}\n");
|
|
215
367
|
}
|
|
216
368
|
/** A source's name from its database's: the file is named for it, else `app`. */
|
|
@@ -220,10 +372,15 @@ export function sourceNameFor(database) {
|
|
|
220
372
|
}
|
|
221
373
|
/** The folder an older Mason listed a project's tables in, one file per table: read by nothing, said by `readSource`. */
|
|
222
374
|
export const OLD_TABLES_DIR = "models";
|
|
375
|
+
/** A second source file, refused: one source per project for now. */
|
|
376
|
+
const tooManySources = (root, dir, names) => ({
|
|
377
|
+
message: `${shownPath(root, dir)}/ has ${names.length} source files (${names.join(", ")}): one source per project for now`, code: "bad_source",
|
|
378
|
+
fix: `keep the one this project syncs from, and move the others out of ${shownPath(root, dir)}/`
|
|
379
|
+
});
|
|
223
380
|
/**
|
|
224
381
|
* THE PROJECT'S SOURCE FILE, read: the one file in `sources/` (a second is refused by name: one source per project for
|
|
225
382
|
* now), parsed, its URL resolved — `${NAME}` from the project's .env.local or .env, else the shell (src/lib/dotenv.ts
|
|
226
|
-
* `appEnvValue`, the order `mason init` reads DATABASE_URL in, so the two cannot disagree); `shellFirst` for a server
|
|
383
|
+
* `appEnvValue`, the order `mason init` reads MASON_DATABASE_URL and DATABASE_URL in, so the two cannot disagree); `shellFirst` for a server
|
|
227
384
|
* (`mason serve`), configured by its environment alone. A project with no file is told what to write — one an older
|
|
228
385
|
* Mason made (its tables listed in another folder, which nothing reads) that mason init writes it.
|
|
229
386
|
*/
|
|
@@ -241,23 +398,35 @@ export function readSource(folder, root, env = process.env, o = {}) {
|
|
|
241
398
|
return { message: `this project names no database: ${shownPath(root, dir)}/ has no source file`, code: "bad_source",
|
|
242
399
|
fix: `write ${shownPath(root, file)}, with the database's URL in .env as DATABASE_URL:\n${newSourceText().trimEnd()}` };
|
|
243
400
|
}
|
|
244
|
-
if (names.length > 1)
|
|
245
|
-
return
|
|
246
|
-
fix: `keep the one this project copies from, and move the others out of ${shownPath(root, dir)}/` };
|
|
247
|
-
}
|
|
401
|
+
if (names.length > 1)
|
|
402
|
+
return tooManySources(root, dir, names);
|
|
248
403
|
const file = path.join(dir, names[0]);
|
|
249
404
|
const parsed = parseSourceText(fs.readFileSync(file, "utf8"), names[0].replace(/\.ya?ml$/, ""), file);
|
|
250
405
|
if ("problems" in parsed)
|
|
251
406
|
return sourceFailure(file, root, parsed.problems);
|
|
407
|
+
const where = o.shellFirst ? "the environment, .env.local or .env" : ".env.local, .env or the shell";
|
|
408
|
+
// the publication's name, read as url: is: ${NAME} from the same places, in the same order
|
|
409
|
+
let publicationName = null;
|
|
410
|
+
if (parsed.publication !== null && "literal" in parsed.publication)
|
|
411
|
+
publicationName = parsed.publication.literal;
|
|
412
|
+
else if (parsed.publication !== null && "env" in parsed.publication) {
|
|
413
|
+
const v = parsed.publication.env;
|
|
414
|
+
const p = appEnvValue(root, v, env, { shellFirst: o.shellFirst === true });
|
|
415
|
+
if (p === null || p.value.trim() === "") {
|
|
416
|
+
return { message: `${shownPath(root, file)}: publication: \${${v}} is not set — not in ${where}`, code: "bad_source",
|
|
417
|
+
fix: `put ${v}=<the publication's name> in ${shownPath(root, path.join(root, ENV_FILE))} (or export it), or write publication: null and Mason makes its own` };
|
|
418
|
+
}
|
|
419
|
+
publicationName = p.value.trim();
|
|
420
|
+
}
|
|
252
421
|
if ("literal" in parsed.url)
|
|
253
|
-
return { ...parsed, resolved: parsed.url.literal, from: shownPath(root, file) };
|
|
422
|
+
return { ...parsed, resolved: parsed.url.literal, from: shownPath(root, file), publicationName };
|
|
254
423
|
const found = appEnvValue(root, parsed.url.env, env, { shellFirst: o.shellFirst === true });
|
|
255
424
|
if (found === null) {
|
|
256
425
|
const v = parsed.url.env;
|
|
257
|
-
return { message: `${shownPath(root, file)}: url: \${${v}} is not set — not in ${
|
|
426
|
+
return { message: `${shownPath(root, file)}: url: \${${v}} is not set — not in ${where}`, code: "bad_source",
|
|
258
427
|
fix: `put ${v}=postgres://user:password@host:5432/your_app in ${shownPath(root, path.join(root, ENV_FILE))} (or export it), or write the URL in url:` };
|
|
259
428
|
}
|
|
260
|
-
return { ...parsed, resolved: found.value, from: `${parsed.url.env} in ${found.from}
|
|
429
|
+
return { ...parsed, resolved: found.value, from: `${parsed.url.env} in ${found.from}`, publicationName };
|
|
261
430
|
}
|
|
262
431
|
/** The columns a table's copy keeps, in the source's order: the entry's, its key always among them. */
|
|
263
432
|
export function copiedColumns(t, e) {
|
|
@@ -267,7 +436,7 @@ export function copiedColumns(t, e) {
|
|
|
267
436
|
return t.columns.map((c) => c.name).filter((c) => keep.has(c));
|
|
268
437
|
}
|
|
269
438
|
/** Why a table cannot be copied as its entry says, each at its key, against the database's catalog; none when it can. */
|
|
270
|
-
export function entryProblems(t, e, all, database,
|
|
439
|
+
export function entryProblems(t, e, all, database, sync) {
|
|
271
440
|
const at = `tables.${e.name}`;
|
|
272
441
|
if (t === undefined) {
|
|
273
442
|
const names = all.map((x) => entryName(x.source));
|
|
@@ -278,13 +447,13 @@ export function entryProblems(t, e, all, database, o) {
|
|
|
278
447
|
const out = [...missing(e.columns ?? [], "columns"), ...missing(e.exclude, "exclude")];
|
|
279
448
|
if (e.key !== null) {
|
|
280
449
|
if (t.key.length)
|
|
281
|
-
out.push(`${at}.key: ${t.source} has a primary key (${t.key.join(", ")}), which Mason
|
|
450
|
+
out.push(`${at}.key: ${t.source} has a primary key (${t.key.join(", ")}), which Mason keys it by — remove key`);
|
|
282
451
|
else {
|
|
283
452
|
out.push(...missing(e.key, "key"));
|
|
284
453
|
out.push(...keyProblems(t, e.key, at));
|
|
285
454
|
// its rows have no identity on the database, so logical replication carries no key to apply a change by
|
|
286
|
-
if (
|
|
287
|
-
out.push(`${at}: ${t.source} has no primary key,
|
|
455
|
+
if (sync.mode === "replication")
|
|
456
|
+
out.push(`${at}: ${t.source} has no primary key, so logical replication cannot apply its changes by its key: (sync: replication) — give its entry sync: poll <interval> (poll 5m), or sync: off`);
|
|
288
457
|
}
|
|
289
458
|
}
|
|
290
459
|
else if (!t.key.length) {
|
|
@@ -293,7 +462,13 @@ export function entryProblems(t, e, all, database, o) {
|
|
|
293
462
|
`${unique ? ` (its unique index ${unique.name})` : ""}`);
|
|
294
463
|
}
|
|
295
464
|
for (const k of (e.key ?? t.key).filter((k) => e.exclude.includes(k)))
|
|
296
|
-
out.push(`${at}.exclude: ${k} is ${t.source}'s key, which is always
|
|
465
|
+
out.push(`${at}.exclude: ${k} is ${t.source}'s key, which is always synced — take it out of exclude`);
|
|
466
|
+
// a table that identifies no changed row: a publication carrying its updates would make the app's own UPDATEs and
|
|
467
|
+
// DELETEs on it fail (the catalog says so; the file cannot), so it is never replicated
|
|
468
|
+
if (sync.mode === "replication" && t.replicaIdentity === "n" && e.key === null) {
|
|
469
|
+
out.push(`${at}: ${t.source} has REPLICA IDENTITY NOTHING, and a publication carrying it would make your app's own UPDATEs and DELETEs on it fail (sync: replication) — ` +
|
|
470
|
+
`give its entry sync: poll <interval> (poll 5m), or sync: off; or ALTER TABLE ${t.source} REPLICA IDENTITY DEFAULT on the database`);
|
|
471
|
+
}
|
|
297
472
|
return out;
|
|
298
473
|
}
|
|
299
474
|
/**
|
|
@@ -315,7 +490,7 @@ function keyProblems(t, key, at) {
|
|
|
315
490
|
if (!(t.uniqueKeys ?? []).some((u) => u.columns.every((c) => key.includes(c)))) {
|
|
316
491
|
out.push(`${at}.key: no unique index of ${t.source} is over (${key.join(", ")}), so two of its rows may share them and Mason could not tell them apart — ` +
|
|
317
492
|
(usable.length ? `name the columns of one: ${usable.map((u) => `key: [${u.columns.join(", ")}] (its unique index ${u.name})`).join(", or ")}`
|
|
318
|
-
: `it has no unique index over NOT NULL columns, so Mason cannot
|
|
493
|
+
: `it has no unique index over NOT NULL columns, so Mason cannot sync it until its owner adds one (or a primary key)`));
|
|
319
494
|
}
|
|
320
495
|
return out;
|
|
321
496
|
}
|
|
@@ -325,19 +500,20 @@ function keyProblems(t, key, at) {
|
|
|
325
500
|
* `primaryKey` keeps the source's own (none): the catalog declares only what the source does. Every entry's problems on
|
|
326
501
|
* one report (`sourceFailure`).
|
|
327
502
|
*/
|
|
328
|
-
export function addedTables(src, tables, database, root
|
|
503
|
+
export function addedTables(src, tables, database, root) {
|
|
329
504
|
const problems = [];
|
|
330
505
|
const out = [];
|
|
331
506
|
for (const e of src.tables) {
|
|
332
507
|
const t = tables.find((x) => x.source === e.table);
|
|
333
|
-
const
|
|
508
|
+
const sync = effectiveSync(src, e).sync;
|
|
509
|
+
const p = entryProblems(t, e, tables, database, sync);
|
|
334
510
|
if (p.length || t === undefined) {
|
|
335
511
|
problems.push(...p);
|
|
336
512
|
continue;
|
|
337
513
|
}
|
|
338
514
|
const keep = new Set(copiedColumns(t, e));
|
|
339
515
|
out.push({ ...t, columns: t.columns.filter((c) => keep.has(c.name)), foreignKeys: t.foreignKeys.filter((f) => keep.has(f.column)),
|
|
340
|
-
...(e.key !== null ? { key: e.key, primaryKey: t.key } : {}) });
|
|
516
|
+
...(e.key !== null ? { key: e.key, primaryKey: t.key } : {}), sync });
|
|
341
517
|
}
|
|
342
518
|
return problems.length ? sourceFailure(src.file, root, problems, [["mason tables", "the database's tables and their columns"]]) : out;
|
|
343
519
|
}
|
|
@@ -345,18 +521,30 @@ export function addedTables(src, tables, database, root, o) {
|
|
|
345
521
|
* THE SOURCES FILE READ AGAIN WHILE THE RUN IS UP (mason add, mason remove): its tables now — or, when the file cannot be
|
|
346
522
|
* taken as it is, the tables read before, kept (a typo never drops a copy), with what is wrong; `said` is the log line,
|
|
347
523
|
* once per new problem. A `url:` that resolves to another database than the one the run copies (a .env edited) is such a
|
|
348
|
-
* problem: the run's catalog is the old database's, so it is said, and mason stop && mason start reads the new one.
|
|
524
|
+
* problem: the run's catalog is the old database's, so it is said, and mason stop && mason start reads the new one. So
|
|
525
|
+
* is a file gone silent on sync: while `slot` is recorded: taken, it would retire the slot.
|
|
349
526
|
*/
|
|
350
|
-
export function rereadSources(prev, read, runUrl, catalog, database, root, o) {
|
|
527
|
+
export function rereadSources(prev, read, runUrl, catalog, database, root, o = {}) {
|
|
351
528
|
let problem = null;
|
|
352
529
|
let next = null;
|
|
353
530
|
if ("message" in read)
|
|
354
531
|
problem = `${read.message}${read.why ? `: ${read.why.replace(/\n/g, "; ")}` : ""}`;
|
|
355
532
|
else if (read.resolved !== runUrl) {
|
|
356
|
-
problem = `${shownPath(root, read.file)}: url: now names ${maskedUrl(read.resolved)} (${read.from}), and this run
|
|
533
|
+
problem = `${shownPath(root, read.file)}: url: now names ${maskedUrl(read.resolved)} (${read.from}), and this run syncs ${maskedUrl(runUrl)}: mason stop && mason start syncs from the new one`;
|
|
534
|
+
}
|
|
535
|
+
else if (silentOverSlot((o.slot ?? null) !== null, read)) {
|
|
536
|
+
problem = `${shownPath(root, read.file)}: no sync: at its top or on an entry, and this project streams through replication slot ${o.slot}: ` +
|
|
537
|
+
"write sync: replication at its top to keep streaming, or sync: off or sync: poll <interval> to drop the slot";
|
|
538
|
+
}
|
|
539
|
+
else if (o.replicationChecked === false && replicatedEntries(read).length > 0) {
|
|
540
|
+
// a table set to replication in a run that never checked the database for it (the commands' edits are checked, the
|
|
541
|
+
// run server's checkReplication): kept as it was, never frozen by a slot the database cannot make
|
|
542
|
+
const names = replicatedEntries(read).map((e) => entryName(e.table));
|
|
543
|
+
problem = `${shownPath(root, read.file)}: ${names.join(", ")} ${names.length === 1 ? "is" : "are"} sync: replication, and this run has not checked ${database} for logical replication ` +
|
|
544
|
+
"(no table was replicated when it started): mason stop && mason start checks it and streams them";
|
|
357
545
|
}
|
|
358
546
|
else {
|
|
359
|
-
const t = addedTables(read, catalog, database, root
|
|
547
|
+
const t = addedTables(read, catalog, database, root);
|
|
360
548
|
if (Array.isArray(t))
|
|
361
549
|
next = t;
|
|
362
550
|
else
|
|
@@ -414,6 +602,8 @@ export function entryText(e, indent) {
|
|
|
414
602
|
body.push(`exclude: ${flowList(e.exclude)}`);
|
|
415
603
|
if (e.key !== null)
|
|
416
604
|
body.push(`key: ${flowList(e.key)}`);
|
|
605
|
+
if (e.sync)
|
|
606
|
+
body.push(`sync: ${syncText(e.sync)}`);
|
|
417
607
|
if (!body.length)
|
|
418
608
|
return `${indent}${yamlName(e.name)}: {}\n`;
|
|
419
609
|
return `${indent}${yamlName(e.name)}:\n${body.map((b) => `${indent} ${b}\n`).join("")}`;
|
|
@@ -498,6 +688,65 @@ function withTableLf(text, e) {
|
|
|
498
688
|
const at = lineEnd(lv?.range ? lv.range[1] : last.key.range[1]);
|
|
499
689
|
return { text: `${text.slice(0, at)}${at > 0 && text[at - 1] !== "\n" ? "\n" : ""}${entryText(e, indent)}${text.slice(at)}` };
|
|
500
690
|
}
|
|
691
|
+
/**
|
|
692
|
+
* The file with ONE entry's own `sync:` set (`sync`), or taken out (null: it follows the file's again), and every other
|
|
693
|
+
* byte as it was — the entry's columns, key and comments too (`mason add orders --sync "poll 30s"` on a table already
|
|
694
|
+
* added): the value spliced at its node's range, a line of its own under the entry's last key at its keys' indent, or —
|
|
695
|
+
* an entry written as a flow map (`{}`, `{ columns: [...] }`) — the entry written again a key a line, a comment after it
|
|
696
|
+
* on the key's line kept. `e` is the entry as the file reads (its columns and key, for a flow map written again).
|
|
697
|
+
*/
|
|
698
|
+
export function withEntrySync(text, e, sync) {
|
|
699
|
+
return inOwnLineEndings(text, (t) => withEntrySyncLf(t, e, sync));
|
|
700
|
+
}
|
|
701
|
+
function withEntrySyncLf(text, e, sync) {
|
|
702
|
+
const r = forEdit(text);
|
|
703
|
+
if ("problem" in r)
|
|
704
|
+
return r;
|
|
705
|
+
const { pair, lineStart, lineEnd } = r;
|
|
706
|
+
const map = pair?.value;
|
|
707
|
+
if (!isMap(map))
|
|
708
|
+
return withTableLf(text, { ...e, sync });
|
|
709
|
+
if (map.flow)
|
|
710
|
+
return { problem: FLOW_MAP };
|
|
711
|
+
const same = entryPairs(map).find((x) => x.table === e.table);
|
|
712
|
+
if (same === undefined)
|
|
713
|
+
return withTableLf(text, { ...e, sync });
|
|
714
|
+
const value = same.pair.value;
|
|
715
|
+
if (isMap(value) && !value.flow && value.items.length > 0) {
|
|
716
|
+
const own = value.items.find((p) => isScalar(p.key) && p.key.value === "sync");
|
|
717
|
+
if (own !== undefined) {
|
|
718
|
+
const k = own.key, v = own.value;
|
|
719
|
+
// its line cut, unless it is the entry's only key: then the entry is `{}` again
|
|
720
|
+
if (sync === null && value.items.length === 1)
|
|
721
|
+
return withTableLf(text, { ...e, name: same.name, sync: null });
|
|
722
|
+
if (sync === null)
|
|
723
|
+
return { text: `${text.slice(0, lineStart(k.range[0]))}${text.slice(lineEnd(v?.range ? v.range[1] : k.range[1]))}` };
|
|
724
|
+
if (!v?.range)
|
|
725
|
+
return { problem: `its ${e.name}.sync: has no value to replace` };
|
|
726
|
+
return { text: `${text.slice(0, v.range[0])}${syncText(sync)}${text.slice(v.range[1])}` };
|
|
727
|
+
}
|
|
728
|
+
if (sync === null)
|
|
729
|
+
return { text };
|
|
730
|
+
const first = value.items[0].key;
|
|
731
|
+
const indent = text.slice(lineStart(first.range[0]), first.range[0]);
|
|
732
|
+
const last = value.items[value.items.length - 1];
|
|
733
|
+
const lv = last.value;
|
|
734
|
+
const at = lineEnd(lv?.range ? lv.range[1] : last.key.range[1]);
|
|
735
|
+
return { text: `${text.slice(0, at)}${at > 0 && text[at - 1] !== "\n" ? "\n" : ""}${indent}sync: ${syncText(sync)}\n${text.slice(at)}` };
|
|
736
|
+
}
|
|
737
|
+
if (sync === null)
|
|
738
|
+
return { text };
|
|
739
|
+
// a flow map (`orders: {}`, `orders: { columns: [id] }`) or nothing: the entry written again, a comment on its line kept
|
|
740
|
+
const k = same.pair.key, v = value;
|
|
741
|
+
// an entry with no value (`orders: # c`) has an empty scalar placed AT its comment: the comment is read from the key
|
|
742
|
+
const after = v?.range && v.range[1] > v.range[0] ? v.range[1] : k.range[1];
|
|
743
|
+
const end = lineEnd(after);
|
|
744
|
+
const comment = /(\s+#.*)$/.exec(text.slice(after, end).replace(/\n$/, ""))?.[1] ?? "";
|
|
745
|
+
const indent = text.slice(lineStart(k.range[0]), k.range[0]);
|
|
746
|
+
const lines = entryText({ ...e, name: same.name, sync }, indent).split("\n");
|
|
747
|
+
lines[0] = `${lines[0]}${comment}`;
|
|
748
|
+
return { text: `${text.slice(0, lineStart(k.range[0]))}${lines.join("\n")}${text.slice(end)}` };
|
|
749
|
+
}
|
|
501
750
|
/**
|
|
502
751
|
* The file without its entry for `table`: its lines cut, every other byte as it was; the last entry gone leaves
|
|
503
752
|
* `tables: {}`. Null when no entry names that table.
|