@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.
Files changed (111) hide show
  1. package/README.md +90 -71
  2. package/dist/src/api/error.js +3 -0
  3. package/dist/src/api/postgres/catalog.js +222 -13
  4. package/dist/src/api/postgres/encode.js +3 -1
  5. package/dist/src/api/postgres/explain.js +3 -2
  6. package/dist/src/api/postgres/protocol.js +39 -23
  7. package/dist/src/api/postgres/server.js +1173 -207
  8. package/dist/src/api/postgres/translate.js +81 -9
  9. package/dist/src/api/request-log.js +18 -5
  10. package/dist/src/cdc/poll.js +5 -5
  11. package/dist/src/cli/background.js +29 -6
  12. package/dist/src/cli/cancel.js +75 -0
  13. package/dist/src/cli/command.js +1 -0
  14. package/dist/src/cli/commands.js +622 -183
  15. package/dist/src/cli/control.js +59 -0
  16. package/dist/src/cli/demo.js +19 -10
  17. package/dist/src/cli/dev.js +351 -111
  18. package/dist/src/cli/doctor.js +20 -10
  19. package/dist/src/cli/guides.js +79 -31
  20. package/dist/src/cli/init.js +119 -58
  21. package/dist/src/cli/machine.js +12 -11
  22. package/dist/src/cli/main.js +3 -3
  23. package/dist/src/cli/mcp.js +29 -20
  24. package/dist/src/cli/metrics.js +79 -0
  25. package/dist/src/cli/pg-tls.js +13 -3
  26. package/dist/src/cli/pg-url.js +4 -0
  27. package/dist/src/cli/pin.js +13 -12
  28. package/dist/src/cli/preflight.js +8 -7
  29. package/dist/src/cli/project.js +43 -4
  30. package/dist/src/cli/readiness.js +38 -4
  31. package/dist/src/cli/registry.js +53 -34
  32. package/dist/src/cli/replication-checks.js +7 -5
  33. package/dist/src/cli/replication-mode.js +105 -26
  34. package/dist/src/cli/request-filter.js +134 -0
  35. package/dist/src/cli/roles.js +584 -0
  36. package/dist/src/cli/run-server.js +483 -41
  37. package/dist/src/cli/runs.js +1 -1
  38. package/dist/src/cli/security.js +10 -8
  39. package/dist/src/cli/serve.js +144 -42
  40. package/dist/src/cli/share-run.js +286 -0
  41. package/dist/src/cli/share.js +286 -0
  42. package/dist/src/cli/source-edit.js +65 -13
  43. package/dist/src/cli/sources.js +287 -38
  44. package/dist/src/cli/supervise.js +12 -1
  45. package/dist/src/cli/sync.js +55 -25
  46. package/dist/src/cli/tunnel.js +200 -0
  47. package/dist/src/cli/ui.js +9 -6
  48. package/dist/src/cli/usage.js +128 -0
  49. package/dist/src/cli/user.js +496 -40
  50. package/dist/src/cli/version.js +9 -7
  51. package/dist/src/demo/seed.js +3 -0
  52. package/dist/src/duck/cancel.js +10 -1
  53. package/dist/src/duck/events.js +134 -0
  54. package/dist/src/duck/executor-worker.js +18 -9
  55. package/dist/src/duck/executor.js +43 -30
  56. package/dist/src/duck/explain.js +56 -46
  57. package/dist/src/duck/extensions.js +160 -0
  58. package/dist/src/duck/functions.js +13 -14
  59. package/dist/src/duck/gaps.js +17 -8
  60. package/dist/src/duck/grants.js +176 -0
  61. package/dist/src/duck/history.js +123 -0
  62. package/dist/src/duck/kept.js +6 -6
  63. package/dist/src/duck/plan.js +102 -25
  64. package/dist/src/duck/publication.js +6 -6
  65. package/dist/src/duck/requests.js +265 -93
  66. package/dist/src/duck/rewrite.js +25 -5
  67. package/dist/src/duck/row-filter.js +649 -0
  68. package/dist/src/duck/store.js +156 -120
  69. package/dist/src/duck/stream.js +10 -5
  70. package/dist/src/duck/sync.js +835 -268
  71. package/dist/src/duck/types.js +6 -5
  72. package/dist/src/duck/usage.js +324 -0
  73. package/dist/src/duck/users.js +64 -15
  74. package/dist/src/duck/verified.js +3 -0
  75. package/dist/src/duck/views.js +2 -3
  76. package/dist/src/lib/database-name.js +62 -0
  77. package/dist/src/lib/dotenv.js +17 -4
  78. package/dist/src/lib/log-buffer.js +68 -0
  79. package/dist/src/lib/pg-numeric.js +5 -2
  80. package/dist/src/lib/project-version.js +23 -23
  81. package/dist/src/lib/table-sync.js +267 -0
  82. package/dist/src/web/app.css +706 -66
  83. package/dist/src/web/app.js +327 -410
  84. package/dist/src/web/charts.js +229 -0
  85. package/dist/src/web/index.html +82 -18
  86. package/dist/src/web/pages/queries.js +192 -0
  87. package/dist/src/web/pages/status.js +446 -0
  88. package/dist/src/web/pages/syncs.js +155 -0
  89. package/dist/src/web/pages/tables.js +434 -0
  90. package/dist/src/web/pages/usage.js +397 -0
  91. package/dist/src/web/pages/users.js +218 -0
  92. package/dist/src/web/shared.js +568 -0
  93. package/dist/src/web/vendor/README.md +25 -0
  94. package/dist/src/web/vendor/geist-1.7.2/Geist-Variable.woff2 +0 -0
  95. package/dist/src/web/vendor/geist-1.7.2/GeistMono-Variable.woff2 +0 -0
  96. package/dist/src/web/vendor/geist-1.7.2/LICENSE.txt +92 -0
  97. package/dist/src/web/vendor/lucide-1.56.0/LICENSE.txt +43 -0
  98. package/dist/src/web/vendor/lucide-1.56.0/chart-column.mjs +29 -0
  99. package/dist/src/web/vendor/lucide-1.56.0/layout-dashboard.mjs +29 -0
  100. package/dist/src/web/vendor/lucide-1.56.0/logs.mjs +34 -0
  101. package/dist/src/web/vendor/lucide-1.56.0/menu.mjs +28 -0
  102. package/dist/src/web/vendor/lucide-1.56.0/square-terminal.mjs +28 -0
  103. package/dist/src/web/vendor/lucide-1.56.0/table.mjs +29 -0
  104. package/dist/src/web/vendor/lucide-1.56.0/users.mjs +29 -0
  105. package/dist/src/web/vendor/lucide-1.56.0/x.mjs +27 -0
  106. package/dist/src/web/vendor/uplot-1.6.32/LICENSE.txt +21 -0
  107. package/dist/src/web/vendor/uplot-1.6.32/uPlot.esm.js +6140 -0
  108. package/dist/src/web/vendor/uplot-1.6.32/uPlot.min.css +1 -0
  109. package/dist/src/web/vendor/zilla-slab-5.3.0/LICENSE.txt +93 -0
  110. package/dist/src/web/vendor/zilla-slab-5.3.0/zilla-slab-latin-700-normal.woff2 +0 -0
  111. package/package.json +10 -3
@@ -13,6 +13,7 @@
13
13
  import { Buffer } from "node:buffer";
14
14
  import { DuckDBTypeId, DuckDBDateValue, DuckDBTimestampValue, DuckDBTimestampTZValue, DuckDBDecimalValue, DuckDBUUIDValue, DuckDBListValue, DuckDBArrayValue, DuckDBIntervalValue, DuckDBBlobValue, DuckDBTimeValue, DuckDBListType, DuckDBArrayType, DuckDBTimestampSecondsValue, DuckDBTimestampMillisecondsValue, DuckDBTimestampNanosecondsValue, } from "@duckdb/node-api";
15
15
  import { jsonbText } from "./jsonb.js";
16
+ import { gapMark } from "./gaps.js";
16
17
  import { EncodeError, encodeValue, OID, TIMESTAMP } from "../api/postgres/encode.js";
17
18
  const MICROS_PER_DAY = 86400000000n;
18
19
  const DATE_INFINITY = 2_147_483_647, DATE_NEG_INFINITY = -2_147_483_647;
@@ -124,7 +125,7 @@ function timestampColumn(name, tz) {
124
125
  }
125
126
  // a zone's offset before year 1 is its local mean time, which Mason does not compute
126
127
  if (tz && s.timeZone.toUpperCase() !== "UTC")
127
- throw new EncodeError(`Mason writes a timestamptz before year 1 only in UTC, not in ${s.timeZone} (Mason gap bc-timestamptz-zone)`, "0A000");
128
+ throw new EncodeError(`Mason writes a timestamptz before year 1 only in UTC, not in ${s.timeZone} (${gapMark("bc-timestamptz-zone")})`, "0A000", "bc-timestamptz-zone");
128
129
  return Buffer.from(bcTimestampText(m, tz), "ascii");
129
130
  }
130
131
  return base.encode(v, format, s);
@@ -267,7 +268,7 @@ export function wireColumn(name, t) {
267
268
  case DuckDBTypeId.UHUGEINT:
268
269
  case DuckDBTypeId.UBIGINT:
269
270
  case DuckDBTypeId.BIGNUM:
270
- return viaApi(name, "numeric", bigintText, `DuckDB's ${t.toString()} (a sum of integers is one), sent as numeric: Postgres says bigint for a sum of int4 and numeric for a sum of int8`);
271
+ return viaApi(name, "numeric", bigintText, `the engine's ${t.toString()} (a sum of integers is one), sent as numeric: Postgres says bigint for a sum of int4 and numeric for a sum of int8`);
271
272
  case DuckDBTypeId.FLOAT: return viaApi(name, "float4", (v) => v);
272
273
  case DuckDBTypeId.DOUBLE: return viaApi(name, "float8", (v) => v);
273
274
  case DuckDBTypeId.DECIMAL: return viaApi(name, "numeric", (v) => v.toString());
@@ -303,7 +304,7 @@ export function wireColumn(name, t) {
303
304
  }
304
305
  default: break;
305
306
  }
306
- return custom(name, 25, -1, (v) => String(v), null, `DuckDB's ${t.toString()} has no Postgres type here: sent as its text`);
307
+ return custom(name, 25, -1, (v) => String(v), null, `the engine's ${t.toString()} has no Postgres type here: sent as its text`);
307
308
  }
308
309
  // ---------------------------------------------------------------------------------------------------------------------
309
310
  // A COLUMN OF POSTGRES'S TYPE (docs/DESIGN.md "Postgres decides, DuckDB computes"): the catalog Postgres's
@@ -399,9 +400,9 @@ export function pgTypedColumn(name, oid, t, typmod = -1) {
399
400
  if (oid === 1700 && DUCK_FLOAT.has(t.typeId)) {
400
401
  // a double that overflowed is Postgres's numeric overflow (its exact arithmetic raises it)
401
402
  return { ...retyped(viaApi(name, "numeric", (v) => { if (v === Infinity || v === -Infinity)
402
- throw new EncodeError("value overflows numeric format", "22003"); return floatAsNumeric(v); })), divergence: `Postgres computes this numeric exactly; DuckDB computed it as a double (${t.toString()}), sent with 15 significant digits` };
403
+ throw new EncodeError("value overflows numeric format", "22003"); return floatAsNumeric(v); })), divergence: `Postgres computes this numeric exactly; the engine computed it as a double (${t.toString()}), sent with 15 significant digits` };
403
404
  }
404
- return { ...own, divergence: `Postgres says type OID ${oid}; DuckDB computed ${t.toString()}, sent as its own type` };
405
+ return { ...own, divergence: `Postgres says type OID ${oid}; the engine computed ${t.toString()}, sent as its own type` };
405
406
  }
406
407
  /** How a cell of an exact numeric (src/duck/rewrite.ts ExactColumn) reads as a decimal text, by DuckDB's type: an exact
407
408
  * number's digits, a float as Postgres's cast to numeric writes it — or null where the cell needs digits DuckDB did not
@@ -0,0 +1,324 @@
1
+ import { MASON_SCHEMA } from "./store.js";
2
+ import { History, Streak } from "./history.js";
3
+ import { outcomeOf } from "../api/request-log.js";
4
+ export const USAGE_TABLE = `${MASON_SCHEMA}.usage`;
5
+ export const SIZES_TABLE = `${MASON_SCHEMA}.copy_sizes`;
6
+ /**
7
+ * HOW OFTEN A SAMPLE IS TAKEN: a minute. A chart of a day is 1,440 points and of the retention's 7 days 10,080 (a few
8
+ * hundred KB of rows); a statement's p95 over a minute of a BI tool's traffic is a number, over 5 s mostly one statement;
9
+ * and a sample is a read of the state the commands already ask every few seconds. No setting: nothing an operator
10
+ * would tune it for has been met.
11
+ */
12
+ export const SAMPLE_SECONDS = 60;
13
+ /** how often each copy's own bytes are measured (`copySizes`): its cost grows with the copy, see above */
14
+ export const SIZE_EVERY_MS = 10 * 60_000;
15
+ const COLUMNS = ["sampled_at", "seconds", "copy_bytes", "copy_rows", "tables_added", "tables_copied", "tables_failed", "oldest_sync_seconds",
16
+ "statements", "statements_refused", "statements_failed", "statements_cancelled", "rows_sent", "bytes_sent", "duration_p50_ms", "duration_p95_ms",
17
+ "stream_lag_bytes", "stream_lag_seconds", "rss_bytes", "heap_used_bytes", "copy_memory_bytes", "copy_memory_limit_bytes", "slot_wal_held_bytes", "disk_free_bytes", "sessions_open"];
18
+ /** the samples' DDL: one row a sample; a level is as of `sampled_at`, a count is of the `seconds` before it */
19
+ export const USAGE_DDL = `CREATE TABLE IF NOT EXISTS ${USAGE_TABLE} (sampled_at TIMESTAMPTZ, seconds DOUBLE, copy_bytes BIGINT, copy_rows BIGINT, ` +
20
+ `tables_added INTEGER, tables_copied INTEGER, tables_failed INTEGER, oldest_sync_seconds DOUBLE, statements BIGINT, statements_refused BIGINT, ` +
21
+ `statements_failed BIGINT, statements_cancelled BIGINT, rows_sent BIGINT, bytes_sent BIGINT, duration_p50_ms INTEGER, duration_p95_ms INTEGER, stream_lag_bytes BIGINT, ` +
22
+ `stream_lag_seconds DOUBLE, rss_bytes BIGINT, heap_used_bytes BIGINT, copy_memory_bytes BIGINT, copy_memory_limit_bytes BIGINT)`;
23
+ /** the columns added since the first samples (a file a run before them made): each added where it is missing */
24
+ export const USAGE_ADDED = ["slot_wal_held_bytes BIGINT", "disk_free_bytes BIGINT", "sessions_open INTEGER"];
25
+ /** each copy's own bytes, every SIZE_EVERY_MS */
26
+ export const SIZES_DDL = `CREATE TABLE IF NOT EXISTS ${SIZES_TABLE} (sampled_at TIMESTAMPTZ, table_name VARCHAR, bytes BIGINT)`;
27
+ const noInterval = () => ({ statements: 0, outcomes: { refused: 0, failed: 0, cancelled: 0 }, rowsSent: 0, bytesSent: 0, durations: [], completed: 0 });
28
+ /**
29
+ * THE COMPLETED STATEMENTS' DURATIONS ONE INTERVAL KEEPS, at most: an interval is a minute, but one whose sample cannot
30
+ * be taken is carried into the next (`sample`), so while samples keep failing it would otherwise keep every statement's
31
+ * duration for as long as they fail. Up to this many the p50 / p95 are exact; past it the interval keeps a uniform
32
+ * random sample of this many (a reservoir: the n-th duration replaces a kept one with probability KEPT / n), whose
33
+ * quantiles estimate the interval's — 100,000 numbers are ~800 KB, ~1,700 statements a second for a minute.
34
+ */
35
+ export const DURATIONS_KEPT = 100_000;
36
+ /**
37
+ * The q-quantile of sorted values as DuckDB's `quantile_cont` gives it (linear between the two nearest ranks), rounded
38
+ * as the request log's summary rounds it, or null for none: the summary's own definition of a p50 and a p95
39
+ * (tests/duck-usage.test.ts holds the two equal).
40
+ */
41
+ export function quantileCont(sorted, q) {
42
+ if (sorted.length === 0)
43
+ return null;
44
+ const at = (sorted.length - 1) * q, lo = Math.floor(at), hi = Math.ceil(at);
45
+ return Math.round(sorted[lo] + (sorted[hi] - sorted[lo]) * (at - lo));
46
+ }
47
+ /**
48
+ * `quantileCont` over a HISTOGRAM: `[value, count]` pairs sorted by value (each count at least 0), as though every
49
+ * value were listed its count of times — the same ranks and the same interpolation, rounded the same way, without
50
+ * listing them (the request log's summary reads a week of durations this way: src/duck/requests.ts `summary`). Null
51
+ * for none; tests/duck-usage.test.ts holds it equal to DuckDB's `quantile_cont`.
52
+ */
53
+ export function quantileContOf(histogram, q) {
54
+ const n = histogram.reduce((s, [, c]) => s + c, 0);
55
+ if (n === 0)
56
+ return null;
57
+ const at = (n - 1) * q, lo = Math.floor(at), hi = Math.ceil(at);
58
+ // the value at a 0-based rank: the first whose running count passes it
59
+ const valueAt = (rank) => {
60
+ let seen = 0;
61
+ for (const [v, c] of histogram) {
62
+ seen += c;
63
+ if (rank < seen)
64
+ return v;
65
+ }
66
+ return histogram[histogram.length - 1][0];
67
+ };
68
+ const a = valueAt(lo), b = hi === lo ? a : valueAt(hi);
69
+ return Math.round(a + (b - a) * (at - lo));
70
+ }
71
+ export class UsageSampler {
72
+ interval = noInterval();
73
+ totals = { completed: 0, refused: 0, failed: 0, cancelled: 0, completedMs: 0 };
74
+ latest = null;
75
+ last;
76
+ timer = null;
77
+ sampling = Promise.resolve();
78
+ take = null;
79
+ lastSized = -Infinity;
80
+ sizedOnce = false;
81
+ /** a streak of failures, said once: what failed (a sample taken, written, sized), and how many */
82
+ lost = new Map();
83
+ closed = false;
84
+ history;
85
+ log;
86
+ options;
87
+ days;
88
+ memoryLimitBytes;
89
+ sampleSeconds;
90
+ constructor(history, log, memoryLimitBytes, options) {
91
+ this.history = history;
92
+ this.log = log;
93
+ this.options = options;
94
+ this.days = options.days;
95
+ this.memoryLimitBytes = memoryLimitBytes;
96
+ this.sampleSeconds = (options.sampleMs ?? SAMPLE_SECONDS * 1000) / 1000;
97
+ this.last = this.now();
98
+ }
99
+ /** The samples of a store: their tables made, the rows past the retention deleted, and a trim every hour. Sampling
100
+ * begins with `start`, once the run's facts can be read. */
101
+ static async open(store, log, options) {
102
+ const history = await History.open(store, [USAGE_DDL, ...USAGE_ADDED.map((c) => `ALTER TABLE ${USAGE_TABLE} ADD COLUMN IF NOT EXISTS ${c}`), SIZES_DDL]);
103
+ const u = new UsageSampler(history, log, store.memoryLimitBytes, options);
104
+ await history.keepTrimmed(() => u.trim(), (e) => log(`usage: deleting the samples older than ${u.days} days failed: ${e.message}`));
105
+ return u;
106
+ }
107
+ now() { return this.options.now?.() ?? Date.now(); }
108
+ /** One row of the listener's, as the request log records it: counted in the interval it ends, by the summary's rule
109
+ * (a refused login is no statement; `outcomeOf`, the one outcome rule). Never throws. */
110
+ count(row) {
111
+ if (row.method === "CONNECT")
112
+ return;
113
+ const i = this.interval;
114
+ i.statements++;
115
+ const outcome = outcomeOf(row.sqlstate);
116
+ if (outcome === "ok") {
117
+ this.totals.completed++;
118
+ this.totals.completedMs += row.durationMs;
119
+ }
120
+ else
121
+ this.totals[outcome]++;
122
+ if (outcome === "ok") {
123
+ i.completed++;
124
+ const kept = this.options.durationsKept ?? DURATIONS_KEPT;
125
+ if (i.durations.length < kept)
126
+ i.durations.push(row.durationMs);
127
+ else {
128
+ const j = Math.floor((this.options.random?.() ?? Math.random()) * i.completed);
129
+ if (j < kept)
130
+ i.durations[j] = row.durationMs;
131
+ }
132
+ }
133
+ else
134
+ i.outcomes[outcome]++;
135
+ i.rowsSent += row.rows ?? 0;
136
+ i.bytesSent += row.bytesSent ?? 0;
137
+ }
138
+ /** The statements since the process started, by outcome (a copy: the counter itself is never handed out). */
139
+ statementTotals() { return { ...this.totals }; }
140
+ /** The last sample's p50 and p95 of its completed statements; null before the first sample. */
141
+ lastDurations() { return this.latest; }
142
+ /** Takes a sample every interval from `take` (the run's facts), and each copy's bytes every SIZE_EVERY_MS. */
143
+ start(take) {
144
+ this.take = take;
145
+ this.schedule();
146
+ }
147
+ schedule() {
148
+ if (this.closed)
149
+ return;
150
+ // one after the other, never two at once: a sample slower than its interval delays the next
151
+ this.timer = setTimeout(() => { this.timer = null; this.sampling = this.sample().then(() => this.schedule()); }, this.options.sampleMs ?? SAMPLE_SECONDS * 1000);
152
+ this.timer.unref();
153
+ }
154
+ /** a failure of `what`, said once per streak; `ok` ends the streak, said with what it cost */
155
+ failure(what, e) {
156
+ const streak = this.lost.get(what) ?? new Streak();
157
+ this.lost.set(what, streak);
158
+ streak.failed(1, () => this.log(`usage: ${what} failed: ${e.message}; said once until one succeeds`));
159
+ }
160
+ ok(what) {
161
+ this.lost.get(what)?.succeeded((n) => this.log(`usage: ${what} again; ${n} time(s) it was not`));
162
+ }
163
+ /** One sample now: the run's facts, the interval's requests, the memory; written. Resolves whatever happens (a failure is said). */
164
+ async sample() {
165
+ if (this.take === null)
166
+ return;
167
+ let facts;
168
+ let copyMemory;
169
+ try {
170
+ facts = await this.take();
171
+ copyMemory = await this.copyMemory();
172
+ }
173
+ catch (e) {
174
+ // Tolerated, narrowly: a sample is history, and the run goes on without it. The interval's counts are KEPT for
175
+ // the next sample, whose `seconds` covers both, so no request goes uncounted.
176
+ this.failure("taking a sample", e);
177
+ return;
178
+ }
179
+ this.ok("taking a sample");
180
+ const now = this.now();
181
+ const i = this.interval;
182
+ this.interval = noInterval();
183
+ const seconds = (now - this.last) / 1000;
184
+ this.last = now;
185
+ const sorted = i.durations.sort((a, b) => a - b);
186
+ const mem = this.options.memory?.() ?? process.memoryUsage();
187
+ // the p50 and p95 written are the ones GET /metrics says (lastDurations): one computation
188
+ this.latest = { at: new Date(now).toISOString(), p50Ms: quantileCont(sorted, 0.5), p95Ms: quantileCont(sorted, 0.95) };
189
+ const values = [this.latest.at, seconds, facts.copyBytes, facts.totals.rows, facts.totals.added, facts.totals.copied, facts.totals.failed,
190
+ facts.oldestSyncSeconds, i.statements, i.outcomes.refused, i.outcomes.failed, i.outcomes.cancelled, i.rowsSent, i.bytesSent, this.latest.p50Ms, this.latest.p95Ms,
191
+ facts.streamLag?.bytes ?? null, facts.streamLag?.seconds ?? null, mem.rss, mem.heapUsed, copyMemory, this.memoryLimitBytes, facts.slotWalHeldBytes, facts.diskFreeBytes, facts.sessionsOpen];
192
+ const sql = `INSERT INTO ${USAGE_TABLE} (${COLUMNS.join(", ")}) VALUES (${COLUMNS.map((c, j) => `$${j + 1}${c === "sampled_at" ? "::TIMESTAMPTZ" : ""}`).join(", ")})`;
193
+ try {
194
+ if (this.options.insert !== undefined)
195
+ await this.options.insert(sql, values);
196
+ else
197
+ await this.history.writer.run(sql, values);
198
+ this.ok(`writing a sample to ${USAGE_TABLE}`);
199
+ }
200
+ catch (e) {
201
+ // Tolerated, narrowly, as the request log's: a sample that cannot be written must not stop the run. SAID.
202
+ this.failure(`writing a sample to ${USAGE_TABLE}`, e);
203
+ }
204
+ if (now - this.lastSized >= (this.options.sizeEveryMs ?? SIZE_EVERY_MS)) {
205
+ this.lastSized = now;
206
+ await this.size(facts.copied, now);
207
+ }
208
+ }
209
+ /** the copy's memory in use: DuckDB's buffer manager's counters, summed */
210
+ async copyMemory() {
211
+ const v = (await this.history.reader.runAndReadAll("SELECT sum(memory_usage_bytes) FROM duckdb_memory()")).getRowsJS()[0]?.[0];
212
+ return v === null || v === undefined ? null : Number(v);
213
+ }
214
+ /** Each served copy's own bytes, written as one batch (see the module's comment for how, and what it costs). */
215
+ async size(copied, now) {
216
+ const t0 = performance.now();
217
+ const rows = [];
218
+ try {
219
+ const want = new Set(copied);
220
+ const tables = (await this.history.reader.runAndReadAll(`SELECT schema_name, table_name FROM duckdb_tables() WHERE NOT internal AND database_name = current_database() AND schema_name <> '${MASON_SCHEMA}'`))
221
+ .getRowsJS();
222
+ // THE COPY'S OWN database: pragma_database_size() has a row for every attached one, and the in-memory catalog the
223
+ // visible names live in (src/duck/kept.ts) has block_size 0 — read first, it made every copy "0 B"
224
+ const block = Number((await this.history.reader.runAndReadAll("SELECT block_size FROM pragma_database_size() WHERE database_name = current_database()")).getRowsJS()[0][0]);
225
+ for (const [schema, table] of tables) {
226
+ const name = `${schema}.${table}`;
227
+ if (!want.has(name))
228
+ continue;
229
+ const q = `${quote(schema)}.${quote(table)}`;
230
+ let blocks;
231
+ try {
232
+ blocks = Number((await this.history.reader.runAndReadAll(`WITH s AS (SELECT block_id, additional_block_ids FROM pragma_storage_info($1) WHERE persistent), ` +
233
+ `b AS (SELECT block_id FROM s WHERE block_id >= 0 UNION SELECT unnest(additional_block_ids) FROM s) SELECT count(*) FROM b`, [q])).getRowsJS()[0][0]);
234
+ }
235
+ catch (e) {
236
+ // a copy dropped since the list was read (mason remove; a copy swapped in again): not sized this time, by design.
237
+ // Anything else is the sizing's failure, said
238
+ if (/does not exist/i.test(e.message))
239
+ continue;
240
+ throw e;
241
+ }
242
+ // NO BLOCK of its own yet is not 0 bytes: its rows are still in the write-ahead log (a small copy is written to
243
+ // the file only at a checkpoint), so it is not measured this time — a page said "0.0 KiB" for a 5,000-row copy
244
+ if (blocks === 0)
245
+ continue;
246
+ rows.push([name, blocks * block]);
247
+ }
248
+ if (rows.length > 0) {
249
+ const sql = `INSERT INTO ${SIZES_TABLE} (sampled_at, table_name, bytes) VALUES ` + rows.map((_, i) => `($1::TIMESTAMPTZ, $${i * 2 + 2}, $${i * 2 + 3})`).join(", ");
250
+ const values = [new Date(now).toISOString(), ...rows.flat()];
251
+ if (this.options.insert !== undefined)
252
+ await this.options.insert(sql, values);
253
+ else
254
+ await this.history.writer.run(sql, values);
255
+ }
256
+ }
257
+ catch (e) {
258
+ this.failure("measuring each synced table's size", e);
259
+ return;
260
+ }
261
+ this.ok("measuring each synced table's size");
262
+ // the first measure of a run is said with its time: what it costs on this machine's copies, read from the log
263
+ if (!this.sizedOnce) {
264
+ this.sizedOnce = true;
265
+ this.log(`usage: measured ${rows.length} synced tables' own size in ${Math.round(performance.now() - t0)} ms (every ${Math.round((this.options.sizeEveryMs ?? SIZE_EVERY_MS) / 60_000)} min)`);
266
+ }
267
+ }
268
+ /** Deletes the samples and sizes older than the retention. */
269
+ async trim() {
270
+ await this.history.writer.run(`DELETE FROM ${USAGE_TABLE} WHERE sampled_at < now() - INTERVAL (${this.days}) DAY`);
271
+ await this.history.writer.run(`DELETE FROM ${SIZES_TABLE} WHERE sampled_at < now() - INTERVAL (${this.days}) DAY`);
272
+ }
273
+ /**
274
+ * The samples from `since` until `until`, oldest first; with `stepSeconds`, re-bucketed in ONE query: buckets of that
275
+ * many seconds from `since`, each count summed and each level at its highest (the disk free at its lowest). Committed rows only.
276
+ */
277
+ async samples(o) {
278
+ const values = [o.since, o.until];
279
+ const range = `sampled_at >= $1::TIMESTAMPTZ AND sampled_at < $2::TIMESTAMPTZ`;
280
+ let sql;
281
+ if (o.stepSeconds === undefined) {
282
+ sql = `SELECT epoch_ms(sampled_at) AS at, ${COLUMNS.slice(1).join(", ")} FROM ${USAGE_TABLE} WHERE ${range} ORDER BY sampled_at`;
283
+ }
284
+ else {
285
+ values.push(o.stepSeconds);
286
+ const sum = new Set(["seconds", "statements", "statements_refused", "statements_failed", "statements_cancelled", "rows_sent", "bytes_sent"]);
287
+ sql = `SELECT epoch_ms(time_bucket(to_seconds($3::BIGINT), sampled_at, $1::TIMESTAMPTZ)) AS at, ` +
288
+ COLUMNS.slice(1).map((c) => `${sum.has(c) ? "sum" : c === "disk_free_bytes" ? "min" : "max"}(${c}) AS ${c}`).join(", ") +
289
+ ` FROM ${USAGE_TABLE} WHERE ${range} GROUP BY 1 ORDER BY 1`;
290
+ }
291
+ const rows = (await this.history.reader.runAndReadAll(sql, values)).getRowObjectsJson();
292
+ return rows.map(sampleOf);
293
+ }
294
+ /** Each copy's own bytes as last measured within the retention, by table. */
295
+ async copySizes() {
296
+ const rows = (await this.history.reader.runAndReadAll(`SELECT table_name, arg_max(bytes, sampled_at) AS bytes, epoch_ms(max(sampled_at)) AS at FROM ${SIZES_TABLE} WHERE bytes > 0 GROUP BY table_name ORDER BY table_name`))
297
+ .getRowsJS();
298
+ return rows.map(([table, bytes, at]) => ({ table, bytes: Number(bytes), at: new Date(Number(at)).toISOString() }));
299
+ }
300
+ /** Stops sampling, waits for a sample in flight, and closes the samples' connections (mason stop). */
301
+ async close() {
302
+ this.closed = true;
303
+ if (this.timer !== null) {
304
+ clearTimeout(this.timer);
305
+ this.timer = null;
306
+ }
307
+ this.history.stopTrimming();
308
+ await this.sampling;
309
+ this.history.closeConnections();
310
+ }
311
+ }
312
+ const quote = (name) => `"${name.replaceAll('"', '""')}"`;
313
+ const num = (v) => (v === null || v === undefined ? null : Number(v));
314
+ function sampleOf(r) {
315
+ return {
316
+ at: new Date(Number(r.at)).toISOString(), seconds: Number(r.seconds), copyBytes: num(r.copy_bytes), copyRows: Number(r.copy_rows ?? 0),
317
+ tablesAdded: Number(r.tables_added ?? 0), tablesCopied: Number(r.tables_copied ?? 0), tablesFailed: Number(r.tables_failed ?? 0), oldestSyncSeconds: num(r.oldest_sync_seconds),
318
+ statements: Number(r.statements ?? 0), statementsRefused: Number(r.statements_refused ?? 0), statementsFailed: Number(r.statements_failed ?? 0),
319
+ statementsCancelled: Number(r.statements_cancelled ?? 0), rowsSent: Number(r.rows_sent ?? 0), bytesSent: Number(r.bytes_sent ?? 0), durationP50Ms: num(r.duration_p50_ms), durationP95Ms: num(r.duration_p95_ms),
320
+ streamLagBytes: num(r.stream_lag_bytes), streamLagSeconds: num(r.stream_lag_seconds),
321
+ rssBytes: Number(r.rss_bytes), heapUsedBytes: Number(r.heap_used_bytes), copyMemoryBytes: num(r.copy_memory_bytes), copyMemoryLimitBytes: Number(r.copy_memory_limit_bytes),
322
+ slotWalHeldBytes: num(r.slot_wal_held_bytes), diskFreeBytes: num(r.disk_free_bytes), sessionsOpen: num(r.sessions_open),
323
+ };
324
+ }
@@ -11,10 +11,14 @@
11
11
  * statement of its session is attributed to that user, and rotating the API key removes every user at once (no MAC
12
12
  * verifies under the new key). It never collides with a token: a token's MAC is 43 base64url characters.
13
13
  *
14
- * THE LIST OF LIVE USERS is a FILE in the state folder (`.mason/users.json`), never a DuckDB table: `mason user …` runs
14
+ * THE LIST OF USERS is a FILE in the state folder (`.mason/users.json`), never a DuckDB table: `mason user …` runs
15
15
  * as a second process beside the run, which holds DuckDB's file lock. It holds each user's name, id, when it was added
16
16
  * and the API key's fingerprint at the time (`key`, a MAC of a constant: no secret, it says which key a user was added
17
- * under) — never a password. A change is made under a lock file (`users.json.lock`, O_EXCL) and written beside and
17
+ * under) — never a password. A removed user STAYS in it with `removedAt` (so a list can show who was removed, and when):
18
+ * only the entries without one are live, and the listener reads only those. A DISABLED user stays too, with
19
+ * `disabledAt` (`mason user disable`): its password still verifies, but its login is refused as Postgres refuses a role
20
+ * without LOGIN (28000, "is not permitted to log in") and its sessions end at their next statement, until `mason user
21
+ * enable` clears it — the same id, so the same password connects again. A change is made under a lock file (`users.json.lock`, O_EXCL) and written beside and
18
22
  * renamed, so a reader sees the old list or the new one, never half of either, and two `mason user add` at once cannot
19
23
  * both win a name. The listener RE-READS it (`LiveUsers`: a stat at every connection and before every statement of a
20
24
  * user's session, the file read again when its inode, size or mtime moved), so a removal refuses the next connection
@@ -24,13 +28,27 @@
24
28
  import * as crypto from "node:crypto";
25
29
  import * as fs from "node:fs";
26
30
  import * as path from "node:path";
27
- import { ApiError } from "../api/error.js";
31
+ import { ApiError, USER_DISABLED } from "../api/error.js";
28
32
  import { tokenProblem } from "./token.js";
33
+ import { ATTRIBUTE_NAME_RE } from "./row-filter.js";
29
34
  export const USERS_FILE = "users.json";
30
35
  /** A user's name: what a BI tool's form calls the user (Postgres's NAMEDATALEN, 63), lower case so a form that
31
36
  * capitalises it is told so, never another user. */
32
37
  export const USER_NAME_RE = /^[a-z0-9][a-z0-9_.-]{0,62}$/;
33
38
  export const USER_NAME_RULE = "1 to 63 lower case letters, digits, \"_\", \".\" or \"-\", starting with a letter or digit (e.g. metabase)";
39
+ /** a user's attributes as the file keeps them: a name each, a string or a list of strings */
40
+ function attributesValid(v) {
41
+ // a NUL ends the statement it is bound into, wherever it is: every statement of the user would fail as a syntax error
42
+ const value = (x) => typeof x === "string" && !x.includes("\0");
43
+ return typeof v === "object" && v !== null && !Array.isArray(v) &&
44
+ Object.entries(v).every(([k, x]) => ATTRIBUTE_NAME_RE.test(k) && (value(x) || (Array.isArray(x) && x.every(value))));
45
+ }
46
+ /** Whether a user has not been removed (it may still not connect: see `underKey`). */
47
+ export const isActive = (u) => u.removedAt === undefined;
48
+ /** Whether a user is disabled (`mason user disable`): kept, its password unchanged, its logins refused until enabled. */
49
+ export const isDisabled = (u) => u.disabledAt !== undefined;
50
+ /** A role's name as the users file may carry it (src/cli/roles.ts ROLE_NAME_RE, which the roles file holds it to). */
51
+ const ROLE_RE = /^[a-z0-9][a-z0-9_-]{0,62}$/;
34
52
  const PASSWORD_RE = /^mason_([0-9a-f]{16})_([0-9a-f]{32})$/;
35
53
  const mac = (apiKey, name, id) => crypto.createHmac("sha256", apiKey).update(`mason-user:${name}:${id}`).digest("hex").slice(0, 32);
36
54
  /** Which API key a user was added under, without the key: a MAC of a constant, 16 hex digits. */
@@ -71,10 +89,19 @@ function parseUsers(file, text) {
71
89
  for (const [i, u] of list.entries()) {
72
90
  const o = u;
73
91
  if (typeof o !== "object" || o === null || typeof o.name !== "string" || !USER_NAME_RE.test(o.name) || typeof o.id !== "string" || !/^[0-9a-f]{16}$/.test(o.id) ||
74
- typeof o.addedAt !== "string" || typeof o.key !== "string") {
75
- return { problem: `${file}: users[${i}] is not { name, id, addedAt, key } as mason user add writes it` };
92
+ typeof o.addedAt !== "string" || typeof o.key !== "string" || (o.role !== undefined && (typeof o.role !== "string" || !ROLE_RE.test(o.role))) ||
93
+ (o.attributes !== undefined && !attributesValid(o.attributes))) {
94
+ return { problem: `${file}: users[${i}] is not { name, id, addedAt, key, role?, attributes?[, removedAt][, disabledAt] } as mason user add writes it` };
95
+ }
96
+ if (o.removedAt !== undefined && typeof o.removedAt !== "string") {
97
+ return { problem: `${file}: users[${i}].removedAt is not a timestamp as mason user remove writes it` };
98
+ }
99
+ if (o.disabledAt !== undefined && typeof o.disabledAt !== "string") {
100
+ return { problem: `${file}: users[${i}].disabledAt is not a timestamp as mason user disable writes it` };
76
101
  }
77
- users.push({ name: o.name, id: o.id, addedAt: o.addedAt, key: o.key });
102
+ users.push({ name: o.name, id: o.id, addedAt: o.addedAt, key: o.key, ...(typeof o.role === "string" ? { role: o.role } : {}),
103
+ ...(attributesValid(o.attributes) ? { attributes: o.attributes } : {}),
104
+ ...(o.removedAt !== undefined ? { removedAt: o.removedAt } : {}), ...(o.disabledAt !== undefined ? { disabledAt: o.disabledAt } : {}) });
78
105
  }
79
106
  return { users };
80
107
  }
@@ -140,8 +167,8 @@ export function changeUsers(file, change) {
140
167
  }
141
168
  // ------------------------------------------------------------------ the listener's view
142
169
  /** The ApiError a refused user login carries: the user it asked for, so the request log names who still tries. */
143
- function refusal(status, message, user) {
144
- const e = new ApiError(status, message, status === 503 ? "users_unreadable" : "invalid_user");
170
+ function refusal(status, message, user, code = status === 503 ? "users_unreadable" : "invalid_user") {
171
+ const e = new ApiError(status, message, code);
145
172
  e.userName = user;
146
173
  return e;
147
174
  }
@@ -156,7 +183,7 @@ export class LiveUsers {
156
183
  seen = null;
157
184
  read = { users: [] };
158
185
  constructor(file, apiKey) { this.file = file; this.apiKey = apiKey; }
159
- /** the users now; why not, when the file cannot be read */
186
+ /** the users not removed now (disabled ones included: their login is refused by name); why not, when the file cannot be read */
160
187
  now() {
161
188
  let stamp;
162
189
  try {
@@ -169,7 +196,9 @@ export class LiveUsers {
169
196
  stamp = "none";
170
197
  }
171
198
  if (stamp !== this.seen || "problem" in this.read) {
172
- this.read = readUsers(this.file);
199
+ const r = readUsers(this.file);
200
+ // a removed user is kept in the file for the list: it is no user to the listener
201
+ this.read = "problem" in r ? r : { users: r.users.filter(isActive) };
173
202
  this.seen = stamp;
174
203
  }
175
204
  return this.read;
@@ -197,19 +226,39 @@ export class LiveUsers {
197
226
  // is the login — to anyone holding only the password (found on the test server)
198
227
  if (found.name !== user)
199
228
  throw refusal(401, "not a password of this project's users: mason user list shows them", user);
200
- return { filters: [], subject: undefined, user: found.name, jti: found.id };
229
+ // disabled: refused only once its password verified (so the state is told to the holder of the login alone), in
230
+ // Postgres's words for a role without LOGIN, 28000 (src/api/postgres/server.ts reads the code)
231
+ if (isDisabled(found))
232
+ throw refusal(401, disabledMessage(found.name), user, USER_DISABLED);
233
+ return { filters: [], subject: undefined, user: found.name, jti: found.id, ...(found.role !== undefined ? { role: found.role } : {}), ...attributesOf(found) };
201
234
  }
202
- /** A held session's user, asked again before a statement: throws as `login` does when it is no longer live. */
235
+ /**
236
+ * A held session's user, asked again before a statement: throws as `login` does when it is no longer live; else the
237
+ * token as the file has the user NOW — its role as `mason user add --role` wrote it, so a role given or taken
238
+ * applies at the user's next statement, without a restart.
239
+ */
203
240
  recheck(token) {
204
241
  if (token.user === undefined)
205
- return;
242
+ return token;
206
243
  const now = this.now();
207
244
  if ("problem" in now)
208
245
  throw refusal(503, `the list of users cannot be read: ${now.problem}`, token.user);
209
- if (!now.users.some((u) => u.id === token.jti && u.name === token.user))
246
+ const found = now.users.find((u) => u.id === token.jti && u.name === token.user);
247
+ if (found === undefined)
210
248
  throw refusal(401, `the user "${token.user}" was removed`, token.user);
249
+ if (isDisabled(found))
250
+ throw refusal(401, `the user "${token.user}" was disabled`, token.user, USER_DISABLED);
251
+ if (found.role === token.role && JSON.stringify(found.attributes ?? null) === JSON.stringify(token.attributes ?? null))
252
+ return token;
253
+ const { role: _was, attributes: _had, ...rest } = token;
254
+ return { ...rest, ...(found.role !== undefined ? { role: found.role } : {}), ...attributesOf(found) };
211
255
  }
212
256
  }
257
+ /** a user's attributes as its session's token carries them (src/api/postgres/server.ts PgToken) */
258
+ const attributesOf = (u) => u.attributes !== undefined && Object.keys(u.attributes).length > 0 ? { attributes: u.attributes } : {};
259
+ /** A disabled user's refused login: Postgres's sentence for a role without LOGIN, and it alone — why is its DETAIL,
260
+ * the way out its HINT (src/api/postgres/server.ts), as Postgres splits them. */
261
+ export const disabledMessage = (name) => `role "${name}" is not permitted to log in`;
213
262
  /**
214
263
  * HOW THE DUCKDB RUN'S LISTENER TAKES A PASSWORD (src/cli/dev.ts; the tests wire the same): a user's password (its shape)
215
264
  * is the users file's to judge, and its session is asked again before every statement (a stat); anything else is read as a
@@ -225,7 +274,7 @@ export function listenerLogins(users, apiKey) {
225
274
  throw new Error(problem);
226
275
  return { filters: [], subject: undefined };
227
276
  },
228
- recheck: async (t) => { users.recheck(t); },
277
+ recheck: async (t) => users.recheck(t),
229
278
  recheckMs: 0,
230
279
  };
231
280
  }
@@ -359,6 +359,7 @@ export const VERIFIED = new Map([
359
359
  ["function:2059", "the function age(timestamp without time zone)"],
360
360
  ["function:2069", "the function timezone(text,timestamp without time zone)"],
361
361
  ["function:2073", "the function \"substring\"(text,text)"],
362
+ ["function:2077", "the function current_setting(text)"],
362
363
  ["function:2087", "the function replace(text,text,text)"],
363
364
  ["function:2088", "the function split_part(text,text,integer)"],
364
365
  ["function:2089", "the function to_hex(integer)"],
@@ -414,6 +415,7 @@ export const VERIFIED = new Map([
414
415
  ["function:3271", "the function jsonb_build_array(\"any\")"],
415
416
  ["function:3273", "the function jsonb_build_object(\"any\")"],
416
417
  ["function:3277", "the function array_position(anycompatiblearray,anycompatible)"],
418
+ ["function:3294", "the function current_setting(text,boolean)"],
417
419
  ["function:3396", "the function regexp_match(text,text)"],
418
420
  ["function:3464", "the function make_interval(integer,integer,integer,integer,integer,integer,double precision)"],
419
421
  ["function:3465", "the function jsonb_array_elements_text(jsonb)"],
@@ -501,6 +503,7 @@ export const VERIFIED = new Map([
501
503
  ["function:941", "the function mod(integer,integer)"],
502
504
  ["function:947", "the function mod(bigint,bigint)"],
503
505
  ["function:ext:current_setting(text)", "the function current_setting(text) (an extension's)"],
506
+ ["function:ext:current_setting(text,boolean)", "the function current_setting(text,boolean) (an extension's)"],
504
507
  ["node:AGGREF", "an aggregate"],
505
508
  ["node:ALIAS", "an alias"],
506
509
  ["node:ARRAYCOERCEEXPR", "a cast of an array's elements"],
@@ -15,10 +15,9 @@
15
15
  * view's name.
16
16
  */
17
17
  import { deparseSync } from "pgsql-deparser";
18
- import { StatementError } from "../api/postgres/server.js";
18
+ import { gapRefusal } from "../api/postgres/server.js";
19
19
  import { parseSql } from "../api/postgres/parser.js";
20
20
  import { catalogRows } from "./analyze.js";
21
- import { gapMark } from "./gaps.js";
22
21
  import { treeDifference } from "./rewrite.js";
23
22
  /** views read through views read through views…: past this depth the statement is refused (a definition is never
24
23
  * recursive, so only a deep stack of views reaches it) */
@@ -28,7 +27,7 @@ const quote = (name) => `"${name.replace(/"/g, '""')}"`;
28
27
  const literal = (s) => `'${s.replace(/'/g, "''")}'`;
29
28
  const sval = (n) => isObject(n) && isObject(n.String) && typeof n.String.sval === "string" ? n.String.sval : null;
30
29
  export function viewRefused(message, hint) {
31
- return new StatementError({ code: "0A000", message, hint, detail: `${gapMark("view-forms")} (docs/SQL-SUPPORT.md)` });
30
+ return gapRefusal("view-forms", message, hint);
32
31
  }
33
32
  /** the views Postgres's analysis read: every range-table entry of a relation whose kind is a view, by OID */
34
33
  export function viewsRead(tree) {
@@ -0,0 +1,62 @@
1
+ /**
2
+ * THE DATABASE NAME A TOOL CONNECTS WITH, the one place it is decided: what the listener accepts, what `current_database()`
3
+ * and `pg_database` answer, and what every URL Mason prints ends in. It is `mason` (it shows in every connection string),
4
+ * unless the source says otherwise: `database:` in the sources file, committed, so a tool that says `database=production`
5
+ * keeps saying it. On a server whose sources file has no such line, `MASON_PG_DATABASE` says it instead.
6
+ *
7
+ * It is NOT the project's name, which stays what names the replication slot and publication (src/cli/replication-mode.ts
8
+ * `deploymentOf`): a fixed name in that place would give two projects on one source one slot, each reading away the
9
+ * other's changes. Nothing else may derive this name: a run records it in its URL (`postgresApiUrl`), and a command reads
10
+ * that URL (`postgresLink`) or, with Mason stopped, asks here (`projectDatabase`, src/cli/project.ts).
11
+ *
12
+ * When a project has more than one source, a second source with no `database:` would clash on `mason`: it is refused,
13
+ * with the exact line to add.
14
+ */
15
+ /** The name a tool connects with when nothing says another. */
16
+ export const DEFAULT_DATABASE = "mason";
17
+ /** The setting a server names it with, when its sources file does not. */
18
+ export const DATABASE_SETTING = "MASON_PG_DATABASE";
19
+ /** Postgres's longest name (NAMEDATALEN - 1): `pg_database.datname` would cut a longer one, and a tool's would not match. */
20
+ const MAX_NAME_BYTES = 63;
21
+ /**
22
+ * WHY `name` CANNOT BE A DATABASE NAME, or null. As wide as Postgres's, because the point is a tool keeping the name it
23
+ * has: capitals, dashes, dots and spaces are all real databases' (`postgres` and `template1` too: `databaseNameSql` makes
24
+ * room for them). What is refused is what could not be served: nothing, past Postgres's length, a control character,
25
+ * what no URL can carry (`.` and `..` are path steps, whatever their escaping) and a variable, since the name is written
26
+ * out (it is the same on every machine that runs the project).
27
+ */
28
+ export function databaseNameProblem(name) {
29
+ if (name === "")
30
+ return "it is empty: a tool must have a name to connect with";
31
+ const bytes = Buffer.byteLength(name);
32
+ if (bytes > MAX_NAME_BYTES)
33
+ return `${JSON.stringify(name)} is ${bytes} bytes, and a Postgres database name is at most ${MAX_NAME_BYTES}`;
34
+ if (/\p{Cc}/u.test(name))
35
+ return `${JSON.stringify(name)} holds a control character, which no tool's connection form can carry`;
36
+ if (name === "." || name === "..")
37
+ return `${JSON.stringify(name)} cannot be the last part of a connection URL, which reads it as a step in a path`;
38
+ if (name.includes("${"))
39
+ return `${JSON.stringify(name)} is a variable, and the name is written out: it is the same on every machine that runs this project`;
40
+ return null;
41
+ }
42
+ /**
43
+ * THE RULE: the sources file's `database:`, else on a server `MASON_PG_DATABASE`, else `mason`. `mason serve` refuses
44
+ * the two when they disagree, on its settings' one report (src/cli/serve.ts), so a run never has to choose.
45
+ */
46
+ export function connectDatabase(written, config, env) {
47
+ return written.database ?? (config.served === true ? env[DATABASE_SETTING] || null : null) ?? DEFAULT_DATABASE;
48
+ }
49
+ /** The setting where it means nothing (`mason start`, a project's sources file being what names it): refused, never ignored. */
50
+ export const databaseSettingRefusal = (env, shown) => {
51
+ const set = env[DATABASE_SETTING];
52
+ if (!set)
53
+ return null;
54
+ return { message: `${DATABASE_SETTING} is mason serve's setting, and mason start ignores no setting silently`,
55
+ fix: `write database: ${set} in ${shown} (it is committed, so every machine answers as it), and unset ${DATABASE_SETTING}` };
56
+ };
57
+ /**
58
+ * THE HINT OF A `3D000`: the right name, how a tool says it, and where the name is set. A client reads it before any
59
+ * password, so it never names the sources file by its path: that file is named for the source's own database.
60
+ */
61
+ export const wrongDatabaseHint = (database) => `this Mason answers as the database "${database}": connect with database=${database}, the last part of a postgres:// URL. ` +
62
+ `It is set by database: in the project's sources file (sources/<name>.yml), or ${DATABASE_SETTING} on a server`;