@mason-data/cli 0.6.2 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +147 -69
- package/dist/src/api/error.js +3 -0
- package/dist/src/api/postgres/catalog.js +258 -14
- package/dist/src/api/postgres/encode.js +3 -1
- package/dist/src/api/postgres/explain.js +3 -2
- package/dist/src/api/postgres/protocol.js +39 -23
- package/dist/src/api/postgres/server.js +1290 -223
- package/dist/src/api/postgres/translate.js +81 -9
- package/dist/src/api/request-log.js +18 -5
- package/dist/src/cdc/pg-wire.js +144 -38
- package/dist/src/cdc/poll.js +5 -5
- package/dist/src/cli/background.js +55 -8
- package/dist/src/cli/cancel.js +75 -0
- package/dist/src/cli/command.js +5 -3
- package/dist/src/cli/commands.js +843 -272
- package/dist/src/cli/control.js +59 -0
- package/dist/src/cli/demo.js +158 -0
- package/dist/src/cli/dev.js +458 -125
- package/dist/src/cli/doctor.js +25 -12
- package/dist/src/cli/guides.js +103 -31
- package/dist/src/cli/init.js +159 -58
- package/dist/src/cli/machine.js +18 -12
- package/dist/src/cli/main.js +5 -5
- package/dist/src/cli/mcp.js +735 -0
- package/dist/src/cli/metrics.js +79 -0
- package/dist/src/cli/pg-tls.js +13 -3
- package/dist/src/cli/pg-url.js +4 -0
- package/dist/src/cli/pin.js +13 -12
- package/dist/src/cli/preflight.js +24 -14
- package/dist/src/cli/project.js +48 -7
- package/dist/src/cli/readiness.js +38 -4
- package/dist/src/cli/registry.js +57 -35
- package/dist/src/cli/replication-checks.js +7 -5
- package/dist/src/cli/replication-mode.js +105 -26
- package/dist/src/cli/request-filter.js +134 -0
- package/dist/src/cli/roles.js +584 -0
- package/dist/src/cli/run-server.js +498 -133
- package/dist/src/cli/runs.js +1 -1
- package/dist/src/cli/security.js +10 -8
- package/dist/src/cli/serve.js +147 -40
- package/dist/src/cli/share-run.js +286 -0
- package/dist/src/cli/share.js +286 -0
- package/dist/src/cli/source-edit.js +168 -0
- package/dist/src/cli/sources.js +290 -39
- package/dist/src/cli/supervise.js +12 -1
- package/dist/src/cli/sync.js +55 -25
- package/dist/src/cli/tunnel.js +200 -0
- package/dist/src/cli/ui.js +9 -6
- package/dist/src/cli/usage.js +128 -0
- package/dist/src/cli/user.js +496 -40
- package/dist/src/cli/version.js +9 -7
- package/dist/src/demo/seed.js +240 -0
- package/dist/src/demo/source.js +655 -0
- package/dist/src/duck/cancel.js +10 -1
- package/dist/src/duck/distinct-key.js +228 -0
- package/dist/src/duck/events.js +134 -0
- package/dist/src/duck/executor-worker.js +25 -11
- package/dist/src/duck/executor.js +107 -53
- package/dist/src/duck/explain.js +158 -29
- package/dist/src/duck/extensions.js +160 -0
- package/dist/src/duck/functions.js +73 -19
- package/dist/src/duck/gaps.js +25 -11
- package/dist/src/duck/grants.js +176 -0
- package/dist/src/duck/history.js +123 -0
- package/dist/src/duck/jsonb-order.js +175 -0
- package/dist/src/duck/jsonb.js +10 -8
- package/dist/src/duck/kept-route.js +82 -2
- package/dist/src/duck/kept.js +211 -44
- package/dist/src/duck/pgtree.js +40 -0
- package/dist/src/duck/plan.js +120 -30
- package/dist/src/duck/publication.js +6 -6
- package/dist/src/duck/requests.js +265 -93
- package/dist/src/duck/rewrite.js +1193 -73
- package/dist/src/duck/row-filter.js +649 -0
- package/dist/src/duck/store.js +271 -159
- package/dist/src/duck/stream.js +10 -5
- package/dist/src/duck/sync.js +907 -275
- package/dist/src/duck/types.js +6 -5
- package/dist/src/duck/usage.js +324 -0
- package/dist/src/duck/users.js +64 -15
- package/dist/src/duck/verified.js +9 -0
- package/dist/src/duck/views.js +2 -3
- package/dist/src/lib/database-name.js +62 -0
- package/dist/src/lib/dotenv.js +17 -4
- package/dist/src/lib/log-buffer.js +68 -0
- package/dist/src/lib/pg-numeric.js +5 -2
- package/dist/src/lib/project-version.js +23 -23
- package/dist/src/lib/table-sync.js +267 -0
- package/dist/src/lib/timezone.js +3 -2
- package/dist/src/web/app.css +706 -55
- package/dist/src/web/app.js +330 -258
- package/dist/src/web/charts.js +229 -0
- package/dist/src/web/index.html +84 -15
- package/dist/src/web/pages/queries.js +192 -0
- package/dist/src/web/pages/status.js +446 -0
- package/dist/src/web/pages/syncs.js +155 -0
- package/dist/src/web/pages/tables.js +434 -0
- package/dist/src/web/pages/usage.js +397 -0
- package/dist/src/web/pages/users.js +218 -0
- package/dist/src/web/shared.js +568 -0
- package/dist/src/web/vendor/README.md +25 -0
- package/dist/src/web/vendor/geist-1.7.2/Geist-Variable.woff2 +0 -0
- package/dist/src/web/vendor/geist-1.7.2/GeistMono-Variable.woff2 +0 -0
- package/dist/src/web/vendor/geist-1.7.2/LICENSE.txt +92 -0
- package/dist/src/web/vendor/lucide-1.56.0/LICENSE.txt +43 -0
- package/dist/src/web/vendor/lucide-1.56.0/chart-column.mjs +29 -0
- package/dist/src/web/vendor/lucide-1.56.0/layout-dashboard.mjs +29 -0
- package/dist/src/web/vendor/lucide-1.56.0/logs.mjs +34 -0
- package/dist/src/web/vendor/lucide-1.56.0/menu.mjs +28 -0
- package/dist/src/web/vendor/lucide-1.56.0/square-terminal.mjs +28 -0
- package/dist/src/web/vendor/lucide-1.56.0/table.mjs +29 -0
- package/dist/src/web/vendor/lucide-1.56.0/users.mjs +29 -0
- package/dist/src/web/vendor/lucide-1.56.0/x.mjs +27 -0
- package/dist/src/web/vendor/uplot-1.6.32/LICENSE.txt +21 -0
- package/dist/src/web/vendor/uplot-1.6.32/uPlot.esm.js +6140 -0
- package/dist/src/web/vendor/uplot-1.6.32/uPlot.min.css +1 -0
- package/dist/src/web/vendor/zilla-slab-5.3.0/LICENSE.txt +93 -0
- package/dist/src/web/vendor/zilla-slab-5.3.0/zilla-slab-latin-700-normal.woff2 +0 -0
- package/package.json +10 -3
package/dist/src/duck/types.js
CHANGED
|
@@ -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} (
|
|
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, `
|
|
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, `
|
|
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;
|
|
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};
|
|
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
|
+
}
|
package/dist/src/duck/users.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
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) =>
|
|
277
|
+
recheck: async (t) => users.recheck(t),
|
|
229
278
|
recheckMs: 0,
|
|
230
279
|
};
|
|
231
280
|
}
|
|
@@ -242,6 +242,7 @@ export const VERIFIED = new Map([
|
|
|
242
242
|
["field:SUBLINK.subLinkType=0", "EXISTS (subquery)"],
|
|
243
243
|
["field:SUBLINK.subLinkType=2", "IN / op ANY (subquery)"],
|
|
244
244
|
["field:SUBLINK.subLinkType=4", "a scalar subquery"],
|
|
245
|
+
["field:SUBLINK.subLinkType=6", "ARRAY(subquery)"],
|
|
245
246
|
["field:WINDOWCLAUSE.frameOptions=1058", "a window frame: the default window frame"],
|
|
246
247
|
["field:WINDOWCLAUSE.frameOptions=1059", "a window frame: RANGE UNBOUNDED PRECEDING"],
|
|
247
248
|
["field:WINDOWCLAUSE.frameOptions=1061", "a window frame: ROWS UNBOUNDED PRECEDING"],
|
|
@@ -358,6 +359,7 @@ export const VERIFIED = new Map([
|
|
|
358
359
|
["function:2059", "the function age(timestamp without time zone)"],
|
|
359
360
|
["function:2069", "the function timezone(text,timestamp without time zone)"],
|
|
360
361
|
["function:2073", "the function \"substring\"(text,text)"],
|
|
362
|
+
["function:2077", "the function current_setting(text)"],
|
|
361
363
|
["function:2087", "the function replace(text,text,text)"],
|
|
362
364
|
["function:2088", "the function split_part(text,text,integer)"],
|
|
363
365
|
["function:2089", "the function to_hex(integer)"],
|
|
@@ -413,6 +415,7 @@ export const VERIFIED = new Map([
|
|
|
413
415
|
["function:3271", "the function jsonb_build_array(\"any\")"],
|
|
414
416
|
["function:3273", "the function jsonb_build_object(\"any\")"],
|
|
415
417
|
["function:3277", "the function array_position(anycompatiblearray,anycompatible)"],
|
|
418
|
+
["function:3294", "the function current_setting(text,boolean)"],
|
|
416
419
|
["function:3396", "the function regexp_match(text,text)"],
|
|
417
420
|
["function:3464", "the function make_interval(integer,integer,integer,integer,integer,integer,double precision)"],
|
|
418
421
|
["function:3465", "the function jsonb_array_elements_text(jsonb)"],
|
|
@@ -500,6 +503,7 @@ export const VERIFIED = new Map([
|
|
|
500
503
|
["function:941", "the function mod(integer,integer)"],
|
|
501
504
|
["function:947", "the function mod(bigint,bigint)"],
|
|
502
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)"],
|
|
503
507
|
["node:AGGREF", "an aggregate"],
|
|
504
508
|
["node:ALIAS", "an alias"],
|
|
505
509
|
["node:ARRAYCOERCEEXPR", "a cast of an array's elements"],
|
|
@@ -736,6 +740,11 @@ export const VERIFIED = new Map([
|
|
|
736
740
|
["operator:3212", "the operator ->(jsonb,integer)"],
|
|
737
741
|
["operator:3213", "the operator #>(jsonb,text[])"],
|
|
738
742
|
["operator:3240", "the operator =(jsonb,jsonb)"],
|
|
743
|
+
["operator:3241", "the operator <>(jsonb,jsonb)"],
|
|
744
|
+
["operator:3242", "the operator <(jsonb,jsonb)"],
|
|
745
|
+
["operator:3243", "the operator >(jsonb,jsonb)"],
|
|
746
|
+
["operator:3244", "the operator <=(jsonb,jsonb)"],
|
|
747
|
+
["operator:3245", "the operator >=(jsonb,jsonb)"],
|
|
739
748
|
["operator:3246", "the operator @>(jsonb,jsonb)"],
|
|
740
749
|
["operator:3247", "the operator ?(jsonb,text)"],
|
|
741
750
|
["operator:3248", "the operator ?|(jsonb,text[])"],
|
package/dist/src/duck/views.js
CHANGED
|
@@ -15,10 +15,9 @@
|
|
|
15
15
|
* view's name.
|
|
16
16
|
*/
|
|
17
17
|
import { deparseSync } from "pgsql-deparser";
|
|
18
|
-
import {
|
|
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
|
|
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) {
|