@mason-data/cli 0.8.0 → 0.10.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 +19 -8
- package/dist/src/api/error.js +4 -0
- package/dist/src/api/postgres/catalog-worker.js +49 -2
- package/dist/src/api/postgres/catalog.js +166 -32
- package/dist/src/api/postgres/listener.js +11 -3
- package/dist/src/api/postgres/proxy.js +129 -0
- package/dist/src/api/postgres/schema.js +6 -0
- package/dist/src/api/postgres/server.js +527 -103
- package/dist/src/api/postgres/translate.js +47 -2
- package/dist/src/cli/background.js +18 -19
- package/dist/src/cli/bridge-stdin.js +101 -0
- package/dist/src/cli/command.js +337 -15
- package/dist/src/cli/commands.js +458 -169
- package/dist/src/cli/credentials.js +182 -0
- package/dist/src/cli/demo.js +2 -2
- package/dist/src/cli/deploy.js +680 -0
- package/dist/src/cli/dev.js +148 -27
- package/dist/src/cli/doctor.js +122 -36
- package/dist/src/cli/generated-files.js +143 -0
- package/dist/src/cli/guides.js +116 -16
- package/dist/src/cli/hosted.js +850 -0
- package/dist/src/cli/init.js +154 -33
- package/dist/src/cli/login.js +292 -0
- package/dist/src/cli/machine.js +125 -40
- package/dist/src/cli/main.js +59 -10
- package/dist/src/cli/mason.cjs +17 -0
- package/dist/src/cli/mcp.js +17 -14
- package/dist/src/cli/output-gate.js +176 -0
- package/dist/src/cli/page-share.js +96 -0
- package/dist/src/cli/pg-tls.js +1 -1
- package/dist/src/cli/pin.js +6 -2
- package/dist/src/cli/pool-size.cjs +85 -0
- package/dist/src/cli/ports.js +2 -0
- package/dist/src/cli/preflight.js +130 -39
- package/dist/src/cli/project.js +100 -1
- package/dist/src/cli/readiness.js +42 -6
- package/dist/src/cli/registry.js +89 -36
- package/dist/src/cli/replica-hosted.js +1324 -0
- package/dist/src/cli/replica.js +114 -0
- package/dist/src/cli/replication-mode.js +5 -0
- package/dist/src/cli/request-filter.js +16 -6
- package/dist/src/cli/roles.js +2 -1
- package/dist/src/cli/run-server.js +179 -9
- package/dist/src/cli/secret-input.js +193 -0
- package/dist/src/cli/security.js +57 -10
- package/dist/src/cli/serve.js +65 -9
- package/dist/src/cli/share-run.js +51 -8
- package/dist/src/cli/share.js +23 -18
- package/dist/src/cli/source-edit.js +10 -4
- package/dist/src/cli/source-role.js +343 -0
- package/dist/src/cli/sources.js +50 -23
- package/dist/src/cli/style.js +16 -1
- package/dist/src/cli/supervise.js +15 -23
- package/dist/src/cli/sync.js +17 -8
- package/dist/src/cli/token-hosted.js +343 -0
- package/dist/src/cli/tunnel.js +1 -1
- package/dist/src/cli/ui-hosted.js +163 -0
- package/dist/src/cli/ui-ssh.js +460 -0
- package/dist/src/cli/ui.js +162 -17
- package/dist/src/cli/usage.js +19 -15
- package/dist/src/cli/user-hosted.js +364 -0
- package/dist/src/cli/user.js +275 -105
- package/dist/src/demo/seed.js +26 -0
- package/dist/src/duck/analyze.js +36 -20
- package/dist/src/duck/collation.js +266 -0
- package/dist/src/duck/constant-key.js +94 -0
- package/dist/src/duck/distinct-key.js +283 -26
- package/dist/src/duck/events.js +39 -8
- package/dist/src/duck/executor-worker.js +2 -2
- package/dist/src/duck/executor.js +6 -5
- package/dist/src/duck/explain.js +4 -0
- package/dist/src/duck/functions.js +448 -49
- package/dist/src/duck/gaps.js +34 -16
- package/dist/src/duck/generated-plan.js +1070 -0
- package/dist/src/duck/generated.js +906 -0
- package/dist/src/duck/grants.js +1 -1
- package/dist/src/duck/held-types.js +64 -0
- package/dist/src/duck/history.js +4 -0
- package/dist/src/duck/jsonb-order.js +9 -12
- package/dist/src/duck/kept-route.js +6 -5
- package/dist/src/duck/kept.js +9 -7
- package/dist/src/duck/late.js +1469 -40
- package/dist/src/duck/libc-bound.js +341 -0
- package/dist/src/duck/libc-order.js +892 -126
- package/dist/src/duck/pg-eval-worker.js +7 -1
- package/dist/src/duck/pg-eval.js +6 -4
- package/dist/src/duck/pgtree.js +774 -23
- package/dist/src/duck/plan.js +275 -30
- package/dist/src/duck/publication.js +14 -0
- package/dist/src/duck/requests.js +9 -5
- package/dist/src/duck/resolved.js +117 -2
- package/dist/src/duck/rewrite.js +4567 -583
- package/dist/src/duck/row-filter.js +12 -6
- package/dist/src/duck/statement.js +19 -0
- package/dist/src/duck/store.js +294 -68
- package/dist/src/duck/stream.js +9 -5
- package/dist/src/duck/sync.js +602 -126
- package/dist/src/duck/token.js +4 -1
- package/dist/src/duck/types.js +63 -7
- package/dist/src/duck/usage.js +6 -4
- package/dist/src/duck/users.js +110 -9
- package/dist/src/duck/verified.js +159 -2
- package/dist/src/duck/views.js +4 -3
- package/dist/src/lib/cidr.js +177 -0
- package/dist/src/lib/database-url.js +55 -10
- package/dist/src/lib/instant.js +50 -0
- package/dist/src/lib/log-buffer.js +13 -4
- package/dist/src/lib/log-read.js +196 -0
- package/dist/src/lib/pg-numeric.js +387 -49
- package/dist/src/lib/secret-mask.js +142 -0
- package/dist/src/lib/table-sync.js +84 -19
- package/dist/src/web/app.js +9 -4
- package/dist/src/web/pages/status.js +178 -13
- package/dist/src/web/pages/syncs.js +1 -1
- package/dist/src/web/pages/tables.js +8 -3
- package/dist/src/web/pages/users.js +4 -3
- package/dist/src/web/shared.js +19 -3
- package/package.json +8 -7
package/dist/src/duck/plan.js
CHANGED
|
@@ -6,16 +6,20 @@
|
|
|
6
6
|
* per text until the schema changes.
|
|
7
7
|
*/
|
|
8
8
|
import { deparseSync, QuoteUtils } from "pgsql-deparser";
|
|
9
|
-
import {
|
|
9
|
+
import { HELD_ROW_INPUT, HELD_TYPE_INPUT } from "../api/postgres/schema.js";
|
|
10
|
+
import { heldName, heldType, isHeldArray } from "./held-types.js";
|
|
11
|
+
import { collationOids, functionsCalled, outputExpressions, textOrdering } from "./pgtree.js";
|
|
12
|
+
import { gapRefusal, StatementError } from "./statement.js";
|
|
10
13
|
import { parseSql } from "../api/postgres/parser.js";
|
|
11
14
|
import { analyze, catalogRows, readParameters } from "./analyze.js";
|
|
12
|
-
import { describeKeys, nameUserKeys, resolvedKeys } from "./resolved.js";
|
|
15
|
+
import { argumentTypes, describeKeys, nameUserKeys, heldUses, nullTested, resolvedKeys } from "./resolved.js";
|
|
13
16
|
import { expandViews, MAX_VIEW_DEPTH, viewRefused } from "./views.js";
|
|
14
17
|
import { selectListAfterLimit } from "./late.js";
|
|
18
|
+
import { libcBounded } from "./libc-bound.js";
|
|
15
19
|
import { pgTyplen } from "./types.js";
|
|
16
|
-
import { datumText, masonCalls, rewrite, treeDifference, Unrewritable } from "./rewrite.js";
|
|
20
|
+
import { giveTurns, datumText, masonCalls, rewrite, treeDifference, Unrewritable } from "./rewrite.js";
|
|
17
21
|
import { exposes, hideKept, readKey, searchSchemas } from "./kept-route.js";
|
|
18
|
-
import { distinctCountTables, distinctKeyCounts, uniqueColumnsOf, uniqueColumnsSql } from "./distinct-key.js";
|
|
22
|
+
import { distinctCountTables, distinctKeyCounts, fanOutTables, groupedLimitTables, primaryKeysOf, uniqueColumnsOf, uniqueColumnsSql } from "./distinct-key.js";
|
|
19
23
|
import { enforceGrants } from "./grants.js";
|
|
20
24
|
import { attributeRefused, expandRowFilters, wallStatistics, wallSwitch } from "./row-filter.js";
|
|
21
25
|
const JSON_OID = 114, JSONB_OID = 3802;
|
|
@@ -44,6 +48,20 @@ async function distinctKeys(catalog, tree) {
|
|
|
44
48
|
return new Set();
|
|
45
49
|
return distinctKeyCounts(tree, uniqueColumnsOf(await catalogRows(catalog, uniqueColumnsSql(relids))));
|
|
46
50
|
}
|
|
51
|
+
/** the primary keys that may prove a LIMIT with no ORDER BY's joins bounded, or a grouped LIMIT grouped by a whole key
|
|
52
|
+
* (src/duck/late.ts), read only for a statement that has one */
|
|
53
|
+
async function joinKeys(catalog, tree) {
|
|
54
|
+
const relids = [...new Set([...fanOutTables(tree), ...groupedLimitTables(tree)])].sort((a, b) => a - b);
|
|
55
|
+
return relids.length === 0 ? new Map() : primaryKeysOf(await catalogRows(catalog, uniqueColumnsSql(relids)));
|
|
56
|
+
}
|
|
57
|
+
/** which of `functions` the catalog says are volatile: asked only of a statement with a derived read the list after the
|
|
58
|
+
* limit may read twice (src/duck/late.ts) */
|
|
59
|
+
async function volatileFunctions(catalog, functions) {
|
|
60
|
+
if (functions.length === 0)
|
|
61
|
+
return new Set();
|
|
62
|
+
const rows = await catalogRows(catalog, `SELECT oid::text FROM pg_proc WHERE provolatile = 'v' AND oid IN (${functions.map((f) => String(Math.trunc(f))).join(", ")})`);
|
|
63
|
+
return new Set(rows.map(([oid]) => Number(oid)));
|
|
64
|
+
}
|
|
47
65
|
/**
|
|
48
66
|
* DuckDB's keywords that Postgres's are not: a column named `at` (DuckDB's AT TIME ZONE) or `qualify` is an identifier
|
|
49
67
|
* the deparser writes bare, as Postgres reads it, and DuckDB refuses as a syntax error. The deparser's one quoting
|
|
@@ -60,9 +78,17 @@ export function quoteDuckKeywords(words) {
|
|
|
60
78
|
return q === ident && duckKeywords.has(ident) ? `"${ident}"` : q;
|
|
61
79
|
};
|
|
62
80
|
}
|
|
63
|
-
/**
|
|
64
|
-
|
|
65
|
-
|
|
81
|
+
/**
|
|
82
|
+
* EVERY TYPE THAT HOLDS A CITEXT, to any depth, by the catalog's structure: citext itself; an array by its element
|
|
83
|
+
* (`typelem`, a nested array is the same type); a domain by its base (`typbasetype`); a composite by its attributes; a
|
|
84
|
+
* range by its subtype and a multirange by its range (PR 674 shrink 2: an array of citext compared by bytes in a
|
|
85
|
+
* quantified subquery). One recursive term, since Postgres takes the recursive reference once.
|
|
86
|
+
*/
|
|
87
|
+
export const CITEXT_HELD = "WITH RECURSIVE held(oid) AS (SELECT oid FROM pg_type WHERE typname = 'citext' UNION " +
|
|
88
|
+
"SELECT t.oid FROM pg_type t, held h WHERE t.typelem = h.oid OR t.typbasetype = h.oid " +
|
|
89
|
+
"OR (t.typrelid <> 0 AND EXISTS (SELECT 1 FROM pg_attribute a WHERE a.attrelid = t.typrelid AND a.attnum > 0 AND NOT a.attisdropped AND a.atttypid = h.oid)) " +
|
|
90
|
+
"OR EXISTS (SELECT 1 FROM pg_range r WHERE (r.rngtypid = t.oid AND r.rngsubtype = h.oid) OR (r.rngmultitypid = t.oid AND r.rngtypid = h.oid))) " +
|
|
91
|
+
"SELECT oid::text FROM held";
|
|
66
92
|
/**
|
|
67
93
|
* `verified`: what the oracle has held to Postgres (src/duck/verified.ts). A statement using anything else is REFUSED,
|
|
68
94
|
* by name — never answered by a rewrite nobody checked. null only for the oracle itself, which records what it verifies.
|
|
@@ -72,12 +98,18 @@ const pathSql = (searchPath) => searchPath.split(",").map((s) => s.trim()).filte
|
|
|
72
98
|
* src/duck/analyze.ts), written back in a form DuckDB reads to the same value. A value Postgres refuses is refused with
|
|
73
99
|
* its error (`'1.5'` for a smallint: 22P02).
|
|
74
100
|
*/
|
|
75
|
-
export async function duckParameters(catalog, oids, values,
|
|
76
|
-
const read = await readParameters(catalog, oids, values,
|
|
101
|
+
export async function duckParameters(catalog, oids, values, under) {
|
|
102
|
+
const read = await readParameters(catalog, oids, values, under);
|
|
77
103
|
if ("refused" in read) {
|
|
78
104
|
const f = read.refused;
|
|
79
105
|
throw new StatementError({ code: f.C ?? "22P02", message: f.M ?? "invalid parameter value", ...(f.D ? { detail: f.D } : {}), ...(f.H ? { hint: f.H } : {}) }, "");
|
|
80
106
|
}
|
|
107
|
+
// a text[] value DuckDB's list cannot hold as Postgres read it: refused by name, as its literal is
|
|
108
|
+
if ("gap" in read) {
|
|
109
|
+
throw read.gap === "multidim-array"
|
|
110
|
+
? gapRefusal("multidim-array", `a multi-dimensional array as parameter $${read.parameter} is not supported by Mason`, "bind a one-dimensional array")
|
|
111
|
+
: gapRefusal("array-lower-bound", `an array whose subscripts do not start at 1 as parameter $${read.parameter} is not supported by Mason`, "bind the array without its bounds ('{a,b}', not '[2:3]={a,b}')");
|
|
112
|
+
}
|
|
81
113
|
return values.map((v, i) => {
|
|
82
114
|
const r = read.values[i];
|
|
83
115
|
if (v === null || r === undefined || r === null)
|
|
@@ -167,31 +199,213 @@ function printed(tree, statement) {
|
|
|
167
199
|
}
|
|
168
200
|
throw new StatementError({ code: "0A000", message: "Mason cannot print this statement for the engine (a Mason gap: report it, with the statement)" }, statement);
|
|
169
201
|
}
|
|
202
|
+
/**
|
|
203
|
+
* A CHAIN'S CHILD READS ITS PARENT'S SIDE TABLE (src/duck/generated.ts, issue 124 stage 2): on the finished tree, each
|
|
204
|
+
* read of a table `relations` names is swapped for the table it maps to, in the same schema, under the name the statement
|
|
205
|
+
* read it by (its alias, else the table's own), so every column the rewrite qualified still binds. A bare name is the
|
|
206
|
+
* search path's first schema's (a generated table is planned under `public`), and a bare name a WITH in scope holds is a
|
|
207
|
+
* CTE's, never swapped. Every table named must be met: a read left on the served table would read the old parent, so
|
|
208
|
+
* one not met throws. The read-back below holds the swapped text to the tree, as any other.
|
|
209
|
+
*/
|
|
210
|
+
function swapRelations(tree, relations, schemas) {
|
|
211
|
+
const met = new Set();
|
|
212
|
+
const walk = (v, ctes) => {
|
|
213
|
+
if (Array.isArray(v)) {
|
|
214
|
+
for (const x of v)
|
|
215
|
+
walk(x, ctes);
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
if (typeof v !== "object" || v === null)
|
|
219
|
+
return;
|
|
220
|
+
const o = v;
|
|
221
|
+
const r = o.RangeVar;
|
|
222
|
+
if (r !== undefined) {
|
|
223
|
+
if (r.schemaname === undefined && ctes.has(r.relname))
|
|
224
|
+
return;
|
|
225
|
+
const key = `${r.schemaname ?? schemas[0] ?? "public"}.${r.relname}`;
|
|
226
|
+
const to = relations.get(key);
|
|
227
|
+
if (to !== undefined) {
|
|
228
|
+
r.alias ??= { aliasname: r.relname };
|
|
229
|
+
r.relname = to;
|
|
230
|
+
met.add(key);
|
|
231
|
+
}
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
// a column by the table's schema-qualified name (`public.counts.tenant_id`, valid Postgres): the swapped table is
|
|
235
|
+
// read under its own name as an alias, which binds no schema, so the column is named by that alias alone
|
|
236
|
+
const fields = o.ColumnRef?.fields;
|
|
237
|
+
if (fields !== undefined && fields.length >= 3) {
|
|
238
|
+
const sval = (x) => x?.String?.sval;
|
|
239
|
+
const schema = sval(fields[fields.length - 3]), table = sval(fields[fields.length - 2]);
|
|
240
|
+
if (schema !== undefined && table !== undefined && relations.has(`${schema}.${table}`))
|
|
241
|
+
o.ColumnRef.fields = fields.slice(-2);
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
const list = o.withClause?.ctes;
|
|
245
|
+
const scope = list === undefined ? ctes : new Set([...ctes, ...list.map((c) => c.CommonTableExpr?.ctename ?? "")]);
|
|
246
|
+
for (const x of Object.values(o))
|
|
247
|
+
walk(x, scope);
|
|
248
|
+
};
|
|
249
|
+
walk(tree, new Set());
|
|
250
|
+
const missed = [...relations.keys()].filter((k) => !met.has(k));
|
|
251
|
+
if (missed.length > 0)
|
|
252
|
+
throw new Error(`a generated table's plan does not read ${missed.join(", ")} by a name it can swap (Mason bug: report it)`);
|
|
253
|
+
}
|
|
254
|
+
/** The catalog declares a type the source made with nothing over it (`PgColumn.made`), so what its analysis says is not
|
|
255
|
+
* there (an `=`, an order, a field of a composite) the source may have: Mason's gap, never Postgres's refusal. */
|
|
256
|
+
async function madeTypeRefusal(catalog, code, message, text) {
|
|
257
|
+
if (message === undefined || (code !== "42883" && code !== "42809" && code !== "42804" && code !== "42846"))
|
|
258
|
+
return null;
|
|
259
|
+
const types = (await catalogRows(catalog, `SELECT t.typname FROM pg_type t WHERE t.typinput::text LIKE '%${HELD_TYPE_INPUT}%'`)).map((r) => r[0]);
|
|
260
|
+
const name = types.find((t) => new RegExp(`\\b${t.replace(/[^\w]/g, ".")}\\b`).test(message));
|
|
261
|
+
if (name === undefined || /^function /.test(message) && !/\(.*\)/.test(message))
|
|
262
|
+
return null;
|
|
263
|
+
return gapRefusal("held-type-compare", `Mason cannot answer this over a column of the type ${name}: it holds the value's text, and has no operator of the type`, "read the value, or its text (x::text); compare, sort or take a field on the source's own columns", text);
|
|
264
|
+
}
|
|
265
|
+
/**
|
|
266
|
+
* A STATEMENT OF NO TABLE THAT ORDERS TEXT UNDER A COLLATION THAT IS NOT BYTE ORDER (issue 122): the catalog's database
|
|
267
|
+
* is C, so `SELECT 'a' < 'B'` answered there is byte order where the source's libc or ICU says otherwise. Its analysis
|
|
268
|
+
* (under the session's state) says where it orders a collatable value and under which collation (`textOrdering`); an
|
|
269
|
+
* operator or a function counts as ordering by the catalog's own name of it (`<`, `max` …, never `=` or `count`), and
|
|
270
|
+
* the resolver says whether that collation is byte order. "order": the statement is planned for the synced data, made
|
|
271
|
+
* and refused like any other. A statement the analysis refuses stays the catalog's, which raises Postgres's own error.
|
|
272
|
+
* "case" (issue 406): it calls upper, lower or initcap of text, under any collation (the catalog maps case by its own
|
|
273
|
+
* C.UTF-8's rules, never the source's ctype): the synced data's where its RUN answers the statement, which the caller
|
|
274
|
+
* decides (src/api/postgres/server.ts `answersCase`), since what the catalog answered before is kept where the synced
|
|
275
|
+
* data's plan or run fails.
|
|
276
|
+
*/
|
|
277
|
+
export async function ordersText(o) {
|
|
278
|
+
const analysis = await analyze(o.catalog, o.text, o.paramOids, o.under);
|
|
279
|
+
if ("refused" in analysis)
|
|
280
|
+
return null;
|
|
281
|
+
// the analysis as the session runs the statement: a simple Query binds no parameter, so one naming `$1` is Postgres's
|
|
282
|
+
// 42P02, which the catalog answers (the analysis above is a Parse's, which would infer its type)
|
|
283
|
+
if (!o.bindable && analysis.params.length > 0)
|
|
284
|
+
return null;
|
|
285
|
+
const oids = collationOids(analysis.tree);
|
|
286
|
+
if (oids.size === 0)
|
|
287
|
+
return null;
|
|
288
|
+
let resolved;
|
|
289
|
+
// (a collation the source lacks, or one Mason cannot follow: the synced data's plan refuses it by name, never the
|
|
290
|
+
// catalog's byte order; a nondeterministic one wherever it is named, equality and GROUP BY included)
|
|
291
|
+
try {
|
|
292
|
+
resolved = await o.collations.prepare((sql) => catalogRows(o.catalog, sql), oids);
|
|
293
|
+
}
|
|
294
|
+
catch (e) {
|
|
295
|
+
if (e instanceof Unrewritable)
|
|
296
|
+
return "order";
|
|
297
|
+
throw e;
|
|
298
|
+
}
|
|
299
|
+
// a statement of no table that holds a numeric (a literal such as 1.5 included) stays the catalog's, which computes it exactly, its text in byte
|
|
300
|
+
// order and its case as before (Mason gap fromless-numeric-text-order): the synced data holds a VALUES list's numeric
|
|
301
|
+
// otherwise than Postgres (issue 342's class), and the catalog answered it right
|
|
302
|
+
if ([...analysis.types.values()].some((ts) => ts.some((t) => t.type === NUMERIC || t.type === NUMERIC_ARRAY)))
|
|
303
|
+
return null;
|
|
304
|
+
const ordering = (coll) => {
|
|
305
|
+
try {
|
|
306
|
+
const c = resolved.resolve(coll);
|
|
307
|
+
return c !== null && c.kind !== "bytes";
|
|
308
|
+
}
|
|
309
|
+
catch (e) {
|
|
310
|
+
if (e instanceof Unrewritable)
|
|
311
|
+
return true;
|
|
312
|
+
throw e;
|
|
313
|
+
}
|
|
314
|
+
};
|
|
315
|
+
const { orders, operators, functions, calls } = textOrdering(analysis.tree);
|
|
316
|
+
if ([...orders].some(ordering))
|
|
317
|
+
return "order";
|
|
318
|
+
const ops = [...operators].filter(([, cs]) => [...cs].some(ordering)).map(([oid]) => oid);
|
|
319
|
+
const fns = [...functions].filter(([, cs]) => [...cs].some(ordering)).map(([oid]) => oid);
|
|
320
|
+
const cased = [...calls.keys()];
|
|
321
|
+
if (ops.length + fns.length + cased.length === 0)
|
|
322
|
+
return null;
|
|
323
|
+
const named = (await catalogRows(o.catalog, [
|
|
324
|
+
...(ops.length > 0 ? [`SELECT 'order' FROM pg_catalog.pg_operator WHERE oid IN (${ops.join(", ")}) AND oprname IN ('<', '>', '<=', '>=')`] : []),
|
|
325
|
+
...(fns.length > 0 ? [`SELECT 'order' FROM pg_catalog.pg_proc WHERE oid IN (${fns.join(", ")}) AND proname IN ('min', 'max')`] : []),
|
|
326
|
+
...(cased.length > 0 ? [`SELECT 'case' FROM pg_catalog.pg_proc WHERE oid IN (${cased.join(", ")}) AND proname IN ('upper', 'lower', 'initcap') AND pronamespace = 'pg_catalog'::regnamespace`] : [])
|
|
327
|
+
].join(" UNION ALL "))).map((r) => r[0]);
|
|
328
|
+
if (named.includes("order"))
|
|
329
|
+
return "order";
|
|
330
|
+
// a case mapping alone moves only a statement of text (and the booleans and integers text answers: a comparison, a
|
|
331
|
+
// length), which the synced data computes as the catalog does but for the case; one that also holds a date, a time,
|
|
332
|
+
// a json or a session's value stays the catalog's, as before, which answers what the synced data may refuse
|
|
333
|
+
// (`lower('now')::timestamptz`, a relative time word; Mason gap fromless-numeric-text-order)
|
|
334
|
+
if (!named.includes("case"))
|
|
335
|
+
return null;
|
|
336
|
+
if (![...analysis.types.values()].every((ts) => ts.every((t) => CASE_ROUTED_TYPES.has(t.type))))
|
|
337
|
+
return null;
|
|
338
|
+
// and only one whose every value is its own arguments' (review r2 of PR 568, fromless-case-route-run-success-read-as-
|
|
339
|
+
// sameness): a value of the server or the session (`version()`, `format_type(…)`, `current_database()`,
|
|
340
|
+
// `current_user`) the synced data answers with ITS OWN, so a run that succeeds says nothing of it. Postgres's analysis
|
|
341
|
+
// says which: a session keyword or sequence (`functionsCalled`), and a function it does not call immutable
|
|
342
|
+
// (pg_proc.provolatile: pg_catalog.version() and format_type() are stable); such a statement stays the
|
|
343
|
+
// catalog's, which answers it as this session's, as before (Mason gap fromless-numeric-text-order)
|
|
344
|
+
const called = functionsCalled(analysis.tree);
|
|
345
|
+
if (called.session || called.oids.has(0))
|
|
346
|
+
return null;
|
|
347
|
+
const unpure = await catalogRows(o.catalog, `SELECT 1 FROM pg_catalog.pg_proc WHERE oid IN (${[...called.oids].join(", ")}) AND provolatile <> 'i' LIMIT 1`);
|
|
348
|
+
return unpure.length === 0 ? "case" : null;
|
|
349
|
+
}
|
|
350
|
+
const NUMERIC = 1700, NUMERIC_ARRAY = 1231;
|
|
351
|
+
/** the types a statement of no table that maps case may hold and still be the synced data's: text's, an unknown
|
|
352
|
+
* literal's, a name's, a boolean's, an integer's and a text array's */
|
|
353
|
+
const CASE_ROUTED_TYPES = new Set([25, 1043, 1042, 705, 19, 16, 21, 23, 20, 1009, 1015]);
|
|
354
|
+
/** a rewrite's refusal as the statement's: a gap says its id (docs/SQL-SUPPORT.md); Postgres's own refusal is its
|
|
355
|
+
* SQLSTATE, and no gap */
|
|
356
|
+
function unrewritableError(e, text) {
|
|
357
|
+
return typeof e.gap === "string" ? gapRefusal(e.gap, e.message, e.hint, text)
|
|
358
|
+
: new StatementError({ code: e.gap.postgres, message: e.message, ...(e.hint !== "" ? { hint: e.hint } : {}) }, text);
|
|
359
|
+
}
|
|
360
|
+
/** the functions that read a held value's position, not its value: its type, a count, an array's shape and rows */
|
|
361
|
+
const HELD_READERS = new Set(["1619", "2147", "2803", "3179", "2176", "2091", "2092", "2331", "2334", "730", "2922"]);
|
|
362
|
+
/** What a statement does with a value the copy holds as its text that the text cannot answer as the type would: an
|
|
363
|
+
* operator but `=` and `<>` of a type whose equal values have one text, a function, a cast to another type. */
|
|
364
|
+
async function heldRefusal(catalog, tree, text) {
|
|
365
|
+
const uses = heldUses(tree).filter((u) => !(u.kind === "function" && HELD_READERS.has(u.oid)));
|
|
366
|
+
if (uses.length === 0)
|
|
367
|
+
return null;
|
|
368
|
+
const ops = uses.filter((u) => u.kind === "operator");
|
|
369
|
+
const equality = new Set(ops.length === 0 ? [] : (await catalogRows(catalog, `SELECT oid::text FROM pg_operator WHERE oid IN (${ops.map((u) => Number(u.oid)).join(", ")}) AND oprname IN ('=', '<>')`)).map((r) => r[0]));
|
|
370
|
+
const bad = uses.find((u) => !(u.kind === "operator" && equality.has(u.oid) && heldType(u.type)?.textEqual === true && heldType(u.type)?.literal === true && !isHeldArray(u.type)));
|
|
371
|
+
if (bad === undefined)
|
|
372
|
+
return null;
|
|
373
|
+
return gapRefusal("held-type-compare", `Mason cannot answer this over a value of the type ${heldName(bad.type)}: it holds the value's text, and this needs the type's own value`, "read the value, or its text (x::text); = and GROUP BY are answered where equal values have one text", text);
|
|
374
|
+
}
|
|
170
375
|
/** A statement's plan. `wireBound`: its rows are bounded by a row count an Execute sets on the wire, which its SQL
|
|
171
376
|
* does not say (src/duck/rewrite.ts markBounds; the listener asks for it where PlannedSql.unboundedLateral says so).
|
|
172
377
|
* `grants`: the session's role (src/duck/grants.ts), held after the analysis; null for a session with no role where
|
|
173
378
|
* roles exist (the plan still names its reads, for an Execute under a role given later); undefined with no roles at all.
|
|
174
379
|
* `walls`: `text` is the statement with its filtered reads in place (expandRowFilters), each of which the wall ends */
|
|
175
|
-
export async function planDuck(
|
|
380
|
+
export async function planDuck(options) {
|
|
381
|
+
const { catalog, text, stmt, base, paramOids, searchPath, timeZone, dateStyle = "ISO, MDY", collations: resolver, verified, userTypes = new Map(), copies = () => [], depth = 0, kept = null, wireBound = false, grants, walls = null, relations, outputs = false } = options;
|
|
176
382
|
const started = performance.now();
|
|
177
|
-
const
|
|
383
|
+
const under = { searchPath, timeZone, dateStyle };
|
|
384
|
+
const analysis = await analyze(catalog, text, paramOids, under);
|
|
178
385
|
if ("refused" in analysis) {
|
|
179
386
|
const f = analysis.refused;
|
|
180
387
|
// a table the catalog does not hold: where its copy is, or the `mason add` away
|
|
181
388
|
const missing = f.C === "42P01" ? /^relation "([^"]+)" does not exist$/.exec(f.M ?? "")?.[1] : undefined;
|
|
389
|
+
// a generated table not built yet (src/duck/generated.ts): by name, and what it is doing
|
|
390
|
+
const unbuilt = missing === undefined ? undefined : copies().find((c) => c.kept?.by === "generated" && c.phase !== "copied" && (c.source === missing || c.source === `public.${missing}`));
|
|
391
|
+
if (unbuilt?.kept?.by === "generated")
|
|
392
|
+
throw gapRefusal("generated-not-built", `the generated table ${unbuilt.source} is not built yet: ${unbuilt.kept.text}`, "mason status says how far it is", text);
|
|
182
393
|
const hint = missing !== undefined ? missingRelationHint(missing, copies()) : f.H;
|
|
394
|
+
const made = await madeTypeRefusal(catalog, f.C, f.M, text);
|
|
395
|
+
if (made !== null)
|
|
396
|
+
throw made;
|
|
183
397
|
throw new StatementError({ code: f.C ?? "XX000", message: f.M ?? "Postgres refused the statement", ...(f.D ? { detail: f.D } : {}), ...(hint ? { hint } : {}),
|
|
184
398
|
...(f.P ? { position: Number(f.P) } : {}) }, text);
|
|
185
399
|
}
|
|
186
400
|
// A VIEW is answered as Postgres answers it, by its definition in its place (src/duck/views.ts): the statement so
|
|
187
401
|
// written is planned from the start, its parameters typed as this one's were. Its errors are the expanded text's, so
|
|
188
402
|
// they carry no position into this one
|
|
189
|
-
const expanded = await expandViews(catalog, stmt, analysis,
|
|
403
|
+
const expanded = await expandViews(catalog, stmt, analysis, under);
|
|
190
404
|
if (expanded !== null) {
|
|
191
405
|
if (depth >= MAX_VIEW_DEPTH)
|
|
192
406
|
throw viewRefused(`Mason cannot yet answer views read through more than ${MAX_VIEW_DEPTH} other views`, "read the tables the views read");
|
|
193
407
|
try {
|
|
194
|
-
return await planDuck(
|
|
408
|
+
return await planDuck({ ...options, text: expanded.text, stmt: expanded.stmt, base: 0, paramOids: analysis.params, depth: depth + 1 });
|
|
195
409
|
}
|
|
196
410
|
catch (e) {
|
|
197
411
|
if (!(e instanceof StatementError))
|
|
@@ -200,6 +414,9 @@ export async function planDuck(catalog, text, stmt, base, paramOids, searchPath,
|
|
|
200
414
|
throw new StatementError(fields, text, e.gap);
|
|
201
415
|
}
|
|
202
416
|
}
|
|
417
|
+
const held = await heldRefusal(catalog, analysis.tree, text);
|
|
418
|
+
if (held !== null)
|
|
419
|
+
throw held;
|
|
203
420
|
// THE ROLE (src/duck/grants.ts): the tables and columns Postgres's analysis says the statement reads, held to the
|
|
204
421
|
// session's role — after Postgres's own refusal and the views' expansion (the check sees the tables a view reads),
|
|
205
422
|
// before the rewrite (a role's refusal before a Mason gap's). The reads stay on the plan: an Execute under a role
|
|
@@ -210,11 +427,11 @@ export async function planDuck(catalog, text, stmt, base, paramOids, searchPath,
|
|
|
210
427
|
// to nothing more (the grants were held above, on the statement as written: a filter may read a column the role
|
|
211
428
|
// may not, as a policy may on Postgres). Its errors are the expanded text's, so they carry no position into this one
|
|
212
429
|
if (grants?.rows !== undefined && grants.rows.size > 0) {
|
|
213
|
-
const filtered = await expandRowFilters(catalog, stmt, analysis,
|
|
430
|
+
const filtered = await expandRowFilters(catalog, stmt, analysis, under, grants.rows);
|
|
214
431
|
if (filtered !== null) {
|
|
215
432
|
let inner;
|
|
216
433
|
try {
|
|
217
|
-
inner = await planDuck(
|
|
434
|
+
inner = await planDuck({ ...options, text: filtered.text, stmt: filtered.stmt, base: 0, paramOids: analysis.params, depth: depth + 1, grants: undefined, walls: filtered.walls });
|
|
218
435
|
}
|
|
219
436
|
catch (e) {
|
|
220
437
|
if (!(e instanceof StatementError))
|
|
@@ -242,6 +459,9 @@ export async function planDuck(catalog, text, stmt, base, paramOids, searchPath,
|
|
|
242
459
|
// the SOURCE's OID for them (`userTypes`, from the copy), never the catalog's
|
|
243
460
|
let resolved = resolvedKeys(analysis.tree);
|
|
244
461
|
const catalogTypes = new Map();
|
|
462
|
+
// every type that holds a citext anywhere in its structure (`CITEXT_HELD`): the rewrite refuses one where it would
|
|
463
|
+
// compare it by bytes (rewrite.ts `holdsCitext`)
|
|
464
|
+
const citextHeld = new Set();
|
|
245
465
|
const enumLabels = new Map(), enumArrays = new Map();
|
|
246
466
|
const enums = { labels: enumLabels, arrays: enumArrays };
|
|
247
467
|
const fields = analysis.fields === null ? null : analysis.fields.map((f) => ({ ...f }));
|
|
@@ -252,6 +472,8 @@ export async function planDuck(catalog, text, stmt, base, paramOids, searchPath,
|
|
|
252
472
|
if ([...resolved].some((k) => /\bu\d+/.test(k)) || fields?.some((f) => f.oid >= 16384) || used.size > 0) {
|
|
253
473
|
for (const [oid, name] of await catalogRows(catalog, "SELECT oid::text, typname FROM pg_type WHERE oid >= 16384 AND typtype IN ('b', 'd', 'e')"))
|
|
254
474
|
catalogTypes.set(Number(oid), name);
|
|
475
|
+
for (const [oid] of await catalogRows(catalog, CITEXT_HELD))
|
|
476
|
+
citextHeld.add(Number(oid));
|
|
255
477
|
// an enum's labels in their declared order, by its OID and its array type's: DuckDB is handed each value as its ENUM
|
|
256
478
|
for (const [oid, array, label] of await catalogRows(catalog, "SELECT t.oid::text, t.typarray::text, e.enumlabel FROM pg_type t JOIN pg_enum e ON e.enumtypid = t.oid ORDER BY t.oid, e.enumsortorder")) {
|
|
257
479
|
const labels = enumLabels.get(Number(oid)) ?? [];
|
|
@@ -264,8 +486,8 @@ export async function planDuck(catalog, text, stmt, base, paramOids, searchPath,
|
|
|
264
486
|
catalogTypes.set(Number(oid), name);
|
|
265
487
|
if (!used.has(Number(oid)))
|
|
266
488
|
continue;
|
|
267
|
-
const columns = await catalogRows(catalog, `SELECT a.attname, a.atttypid::text, a.atttypmod::text FROM pg_attribute a JOIN pg_type t ON t.typrelid = a.attrelid WHERE t.oid = ${Number(oid)} AND a.attnum > 0 AND NOT a.attisdropped ORDER BY a.attnum`);
|
|
268
|
-
rowTypes.set(Number(oid), columns.map(([n, type, typmod]) => ({ name: n, type: Number(type), typmod: Number(typmod) })));
|
|
489
|
+
const columns = await catalogRows(catalog, `SELECT a.attname, a.atttypid::text, a.atttypmod::text, a.attcollation::text FROM pg_attribute a JOIN pg_type t ON t.typrelid = a.attrelid WHERE t.oid = ${Number(oid)} AND a.attnum > 0 AND NOT a.attisdropped ORDER BY a.attnum`);
|
|
490
|
+
rowTypes.set(Number(oid), columns.map(([n, type, typmod, collation]) => ({ name: n, type: Number(type), typmod: Number(typmod), collation: Number(collation ?? 0) })));
|
|
269
491
|
}
|
|
270
492
|
resolved = await nameUserKeys((sql) => catalogRows(catalog, sql), resolved);
|
|
271
493
|
for (const f of fields ?? []) {
|
|
@@ -274,32 +496,45 @@ export async function planDuck(catalog, text, stmt, base, paramOids, searchPath,
|
|
|
274
496
|
f.oid = source;
|
|
275
497
|
}
|
|
276
498
|
}
|
|
277
|
-
let tree,
|
|
499
|
+
let tree, withTies, exact, clock, jsonReads, unboundedLateral;
|
|
278
500
|
// a table's row the source has otherwise (copied in part: no source OID for it) is not the source's row
|
|
279
501
|
const sourceRows = new Map([...rowTypes].filter(([oid]) => userTypes.has(catalogTypes.get(oid) ?? "")));
|
|
502
|
+
// IS [NOT] NULL of a composite reads its fields (a row of NULLs IS NULL), which the copy's text does not
|
|
503
|
+
if (used.size > 0) {
|
|
504
|
+
const rows = await catalogRows(catalog, `SELECT oid::text FROM pg_type WHERE typinput::text LIKE '${HELD_ROW_INPUT}%'`);
|
|
505
|
+
if (rows.length > 0 && nullTested(analysis.tree, new Set(rows.map((r) => Number(r[0]))))) {
|
|
506
|
+
throw gapRefusal("held-type-compare", "IS [NOT] NULL of a composite column is not supported by Mason: it holds the value's text, and Postgres reads each of its fields", "read x::text, or the source's own columns", text);
|
|
507
|
+
}
|
|
508
|
+
}
|
|
280
509
|
// the stored JSON paths the statement's JSON reads may be answered from (src/duck/kept-route.ts)
|
|
281
510
|
const lookup = kept === null ? null : await keptLookup(catalog, analysis.types, kept);
|
|
282
511
|
const keyCounts = await distinctKeys(catalog, analysis.tree);
|
|
512
|
+
// WHAT EACH COLLATION OF THE ANALYSIS MEANS (src/duck/collation.ts), resolved before the rewrite reads any: a libc
|
|
513
|
+
// order not read yet is read here, before the plan is made; a collation the source lacks or Mason cannot follow refused
|
|
514
|
+
let collations;
|
|
515
|
+
try {
|
|
516
|
+
collations = await resolver.prepare((sql) => catalogRows(catalog, sql), collationOids(analysis.tree));
|
|
517
|
+
}
|
|
518
|
+
catch (e) {
|
|
519
|
+
throw e instanceof Unrewritable ? unrewritableError(e, text) : e;
|
|
520
|
+
}
|
|
283
521
|
const analysed = performance.now();
|
|
284
|
-
const rewritten = (l = lookup) => rewrite(stmt, base, analysis,
|
|
522
|
+
const rewritten = (l = lookup) => rewrite({ stmt: stmt, base, analysis, collations, userTypes: catalogTypes, citextHeld, rowTypes: sourceRows, timeZone, dateStyle, enums, kept: l, wireBound, keyCounts });
|
|
285
523
|
try {
|
|
286
|
-
({ tree,
|
|
524
|
+
({ tree, withTies, exact, clock, jsonReads, unboundedLateral } = rewritten());
|
|
287
525
|
// the oracle's (`eager`): every read stored before the plan is made, and the plan made again over them
|
|
288
526
|
if (kept?.keepNow != null && lookup !== null && jsonReads.some((r) => kept.ready(r) === null)) {
|
|
289
527
|
await kept.keepNow(jsonReads);
|
|
290
|
-
({ tree,
|
|
528
|
+
({ tree, withTies, exact, clock, jsonReads, unboundedLateral } = rewritten());
|
|
291
529
|
}
|
|
292
530
|
// a statement that lists a copy's columns without naming them reads none from a stored column (hideKept): made
|
|
293
531
|
// again with every read its call, the reads still recorded
|
|
294
532
|
if (lookup !== null && exposes(tree))
|
|
295
|
-
({ tree,
|
|
533
|
+
({ tree, withTies, exact, clock, jsonReads, unboundedLateral } = rewritten({ at: (relid, attno) => lookup.at(relid, attno), ready: () => null }));
|
|
296
534
|
}
|
|
297
535
|
catch (e) {
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
throw typeof e.gap === "string" ? gapRefusal(e.gap, e.message, e.hint, text)
|
|
301
|
-
: new StatementError({ code: e.gap.postgres, message: e.message, hint: e.hint }, text);
|
|
302
|
-
}
|
|
536
|
+
if (e instanceof Unrewritable)
|
|
537
|
+
throw unrewritableError(e, text);
|
|
303
538
|
throw e;
|
|
304
539
|
}
|
|
305
540
|
// a statement that lists a copy's columns without naming them reads its visible ones (src/duck/kept-route.ts); before
|
|
@@ -307,7 +542,13 @@ export async function planDuck(catalog, text, stmt, base, paramOids, searchPath,
|
|
|
307
542
|
if (kept !== null)
|
|
308
543
|
hideKept(tree, { visible: kept.visible(), searchPath: searchSchemas(searchPath) });
|
|
309
544
|
// a sorted, limited select list is computed over the rows the limit keeps (src/duck/late.ts)
|
|
310
|
-
|
|
545
|
+
// (a role's walled read it fetches through itself is one more wall: counted, so `wallStatistics` still walls every one)
|
|
546
|
+
const lateWall = walls !== null ? { alias: walls.alias, added: 0 } : null;
|
|
547
|
+
tree = await selectListAfterLimit(tree, analysis.tree, () => joinKeys(catalog, analysis.tree), { volatile: (functions) => volatileFunctions(catalog, functions), wall: lateWall }) ?? tree;
|
|
548
|
+
// a top N and a min or max under a libc order keyed for their candidates alone (src/duck/libc-bound.ts): after the
|
|
549
|
+
// late select list, whose inner query holds the sort and the limit; a walled read it reads again is one more wall
|
|
550
|
+
tree = await libcBounded(tree, analysis.tree, { order: (collate) => resolver.libcOrder(collate),
|
|
551
|
+
volatile: (functions) => volatileFunctions(catalog, functions), wall: lateWall }) ?? tree;
|
|
311
552
|
// ONLY WHAT THE ORACLE VERIFIED: after the rewrite, so a construct the rewrite refuses by name (with its own way out)
|
|
312
553
|
// says so first
|
|
313
554
|
const keys = [...resolved].sort();
|
|
@@ -319,7 +560,11 @@ export async function planDuck(catalog, text, stmt, base, paramOids, searchPath,
|
|
|
319
560
|
}
|
|
320
561
|
// only a statement with its filtered reads in place: no other statement's text changes, whatever it names its subqueries
|
|
321
562
|
if (walls !== null && wallSwitch.statistics)
|
|
322
|
-
wallStatistics(tree, walls);
|
|
563
|
+
wallStatistics(tree, { alias: walls.alias, reads: walls.reads + (lateWall?.added ?? 0) });
|
|
564
|
+
// on the finished tree, so no rule above meets it; `masonCalls` below does not count it as a function called
|
|
565
|
+
giveTurns(tree);
|
|
566
|
+
if (relations !== undefined && relations.size > 0)
|
|
567
|
+
swapRelations(tree, relations, searchSchemas(searchPath));
|
|
323
568
|
const sql = printed(tree, text);
|
|
324
569
|
// THE PRINTED TEXT MUST READ BACK AS THE TREE: a printing gap (the deparser drops or respells something) would run
|
|
325
570
|
// another statement than the one Postgres analysed — refused instead, by name
|
|
@@ -330,7 +575,7 @@ export async function planDuck(catalog, text, stmt, base, paramOids, searchPath,
|
|
|
330
575
|
detail: `read back: ${difference}; the rewritten SQL: ${sql}` }, text);
|
|
331
576
|
}
|
|
332
577
|
const planned = { text: sql, functions: masonCalls(tree), ...(unboundedLateral ? { unboundedLateral: true } : {}) };
|
|
333
|
-
return { sql: planned, params: analysis.params, fields: fields?.map((f) => ({ ...f, size: pgTyplen(f.oid) })) ?? null,
|
|
578
|
+
return { sql: planned, params: analysis.params, fields: fields?.map((f) => ({ ...f, size: pgTyplen(f.oid) })) ?? null, withTies, ...(exact.length > 0 ? { exact } : {}), resolved: keys, argumentTypes: argumentTypes(analysis.tree), ...(outputs ? { outputs: outputExpressions(analysis.tree) ?? [] } : {}),
|
|
334
579
|
...(Object.keys(clock).length > 0 ? { clock } : {}), ...(jsonReads.length > 0 ? { jsonReads: jsonReads.map(readKey) } : {}), ...(reads !== undefined ? { reads } : {}),
|
|
335
580
|
timing: { analysisMs: analysed - started, rewriteMs: performance.now() - analysed } };
|
|
336
581
|
}
|
|
@@ -25,6 +25,20 @@ export function namedPublicationVerdict(described, tables, identities) {
|
|
|
25
25
|
}
|
|
26
26
|
return { refused: null, tables: out, noTruncate };
|
|
27
27
|
}
|
|
28
|
+
/**
|
|
29
|
+
* A TABLE'S GAP ON ONE LINE, for its line in `mason status` (src/duck/sync.ts `notStreamedReason`): what is wrong, the
|
|
30
|
+
* statements its owner runs (this module's own "Expected" block, joined), and the way that needs no publication. A gap
|
|
31
|
+
* written otherwise (one line) is its own first line.
|
|
32
|
+
*/
|
|
33
|
+
export function gapLine(gap) {
|
|
34
|
+
const lines = gap.split("\n");
|
|
35
|
+
const at = lines.findIndex((l) => l.startsWith("Expected ("));
|
|
36
|
+
if (at < 0)
|
|
37
|
+
return `${lines[0]} — or give its entry sync: poll <interval> in the sources file, which needs no publication`;
|
|
38
|
+
const end = lines.findIndex((l, i) => i > at && l.trim() === "");
|
|
39
|
+
const sql = lines.slice(at + 1, end < 0 ? undefined : end).map((l) => l.trim()).filter((l) => !l.startsWith("(")).join(" ");
|
|
40
|
+
return `${lines[0]} — the database's owner runs ${sql} — or give its entry sync: poll <interval> in the sources file, which needs no publication`;
|
|
41
|
+
}
|
|
28
42
|
// ------------------------------------------------------------------ the check
|
|
29
43
|
const q = (s) => /^[a-z_][a-z0-9_]*$/.test(s) ? s : `"${s.replace(/"/g, '""')}"`;
|
|
30
44
|
const qualified = (t) => t.split(".").map(q).join(".");
|
|
@@ -9,7 +9,7 @@ const BATCH_ROWS = 1000;
|
|
|
9
9
|
/** A request's outcome in a word, from its SQLSTATE (src/api/request-log.ts: the listener's refusal line reads it too). */
|
|
10
10
|
export { outcomeOf };
|
|
11
11
|
const COLUMNS = ["request_id", "started_at", "session", "application_name", "token_name", "user_name", "method", "sqlstate", "message", "detail", "hint",
|
|
12
|
-
"rows", "duration_ms", "query", "query_truncated", "seq", "json_reads", "bytes_sent", "gap", "role", "attributes"];
|
|
12
|
+
"rows", "duration_ms", "query", "query_truncated", "seq", "json_reads", "bytes_sent", "gap", "role", "attributes", "client_addr"];
|
|
13
13
|
/** the ledger's DDL: one row per request, named for a developer who knows pg_stat_activity */
|
|
14
14
|
/** a ledger made before `mason user` gains its column; a column added last is named in every INSERT */
|
|
15
15
|
export const REQUESTS_USER_DDL = `ALTER TABLE ${REQUESTS_TABLE} ADD COLUMN IF NOT EXISTS user_name VARCHAR`;
|
|
@@ -39,9 +39,13 @@ export const REQUESTS_BYTES_DDL = `ALTER TABLE ${REQUESTS_TABLE} ADD COLUMN IF N
|
|
|
39
39
|
/** THE REGISTRY ENTRY THAT REFUSED EACH STATEMENT (ApiLogRow.gap, src/duck/gaps.ts): NULL for an answer and for any
|
|
40
40
|
* other refusal; what `unsupported` groups by. A ledger made before gains it; its old rows are NULL. */
|
|
41
41
|
export const REQUESTS_GAP_DDL = `ALTER TABLE ${REQUESTS_TABLE} ADD COLUMN IF NOT EXISTS gap VARCHAR`;
|
|
42
|
+
/** THE CLIENT'S ADDRESS (ApiLogRow.clientAddress), named as pg_stat_activity names it: behind a TCP proxy the client its
|
|
43
|
+
* PROXY header carries, so a deployment behind one can tell who sent what. A ledger made before gains it; its old rows
|
|
44
|
+
* are NULL. */
|
|
45
|
+
export const REQUESTS_CLIENT_ADDR_DDL = `ALTER TABLE ${REQUESTS_TABLE} ADD COLUMN IF NOT EXISTS client_addr VARCHAR`;
|
|
42
46
|
export const REQUESTS_DDL = `CREATE TABLE IF NOT EXISTS ${REQUESTS_TABLE} (request_id VARCHAR, started_at TIMESTAMPTZ, session INTEGER, ` +
|
|
43
47
|
`application_name VARCHAR, token_name VARCHAR, user_name VARCHAR, method VARCHAR, sqlstate VARCHAR, message VARCHAR, detail VARCHAR, hint VARCHAR, ` +
|
|
44
|
-
`"rows" BIGINT, duration_ms INTEGER, query VARCHAR, query_truncated BOOLEAN, seq BIGINT, json_reads VARCHAR, bytes_sent BIGINT, gap VARCHAR, role VARCHAR, attributes VARCHAR)`;
|
|
48
|
+
`"rows" BIGINT, duration_ms INTEGER, query VARCHAR, query_truncated BOOLEAN, seq BIGINT, json_reads VARCHAR, bytes_sent BIGINT, gap VARCHAR, role VARCHAR, attributes VARCHAR, client_addr VARCHAR)`;
|
|
45
49
|
/** an ApiLogRow's values in COLUMNS' order */
|
|
46
50
|
function valuesOf({ row: r, seq }) {
|
|
47
51
|
const app = (r.client ?? "").replace(/^postgres:/, "");
|
|
@@ -49,7 +53,7 @@ function valuesOf({ row: r, seq }) {
|
|
|
49
53
|
r.sqlstate ?? null, r.error === undefined || r.error === "" ? null : r.error, r.detail ?? null, r.hint ?? null,
|
|
50
54
|
r.rows === undefined ? null : r.rows, Math.round(r.durationMs), r.payload ?? "", r.payloadTruncated === true, seq,
|
|
51
55
|
r.jsonReads === undefined || r.jsonReads.length === 0 ? null : JSON.stringify(r.jsonReads), r.bytesSent === undefined ? null : r.bytesSent, r.gap ?? null, r.role ?? "",
|
|
52
|
-
r.attributes === undefined || Object.keys(r.attributes).length === 0 ? null : JSON.stringify(r.attributes)];
|
|
56
|
+
r.attributes === undefined || Object.keys(r.attributes).length === 0 ? null : JSON.stringify(r.attributes), r.clientAddress ?? null];
|
|
53
57
|
}
|
|
54
58
|
/** A ledger row as the commands read it. */
|
|
55
59
|
function requestOf(o) {
|
|
@@ -59,7 +63,7 @@ function requestOf(o) {
|
|
|
59
63
|
const startedAt = new Date(String(o.started_at));
|
|
60
64
|
return {
|
|
61
65
|
requestId: String(o.request_id), startedAt: startedAt.toISOString(), session: o.session === null ? null : Number(o.session),
|
|
62
|
-
applicationName: String(o.application_name ?? ""), tokenName: String(o.token_name ?? ""), user: String(o.user_name ?? ""), role: String(o.role ?? ""),
|
|
66
|
+
applicationName: String(o.application_name ?? ""), clientAddress: typeof o.client_addr === "string" ? o.client_addr : null, tokenName: String(o.token_name ?? ""), user: String(o.user_name ?? ""), role: String(o.role ?? ""),
|
|
63
67
|
attributes: typeof o.attributes === "string" && o.attributes !== "" ? JSON.parse(o.attributes) : null, method: String(o.method),
|
|
64
68
|
outcome, status: statusOf(outcome), sqlstate, message: text(o.message), detail: text(o.detail), hint: text(o.hint),
|
|
65
69
|
rows: o.rows === null ? null : Number(o.rows), durationMs: Number(o.duration_ms),
|
|
@@ -131,7 +135,7 @@ export class RequestLedger {
|
|
|
131
135
|
}
|
|
132
136
|
/** The ledger of a store: its table made, the rows past the retention deleted, and a trim every hour. */
|
|
133
137
|
static async open(store, log, options) {
|
|
134
|
-
const history = await History.open(store, [REQUESTS_DDL, REQUESTS_USER_DDL, REQUESTS_SEQ_DDL, REQUESTS_JSON_DDL, REQUESTS_BYTES_DDL, REQUESTS_GAP_DDL, REQUESTS_ROLE_DDL, REQUESTS_ATTRIBUTES_DDL]);
|
|
138
|
+
const history = await History.open(store, [REQUESTS_DDL, REQUESTS_USER_DDL, REQUESTS_SEQ_DDL, REQUESTS_JSON_DDL, REQUESTS_BYTES_DDL, REQUESTS_GAP_DDL, REQUESTS_ROLE_DDL, REQUESTS_ATTRIBUTES_DDL, REQUESTS_CLIENT_ADDR_DDL]);
|
|
135
139
|
const ledger = new RequestLedger(history, log, options);
|
|
136
140
|
ledger.next = Number((await history.reader.runAndReadAll(`SELECT coalesce(max(seq), -1) + 1 FROM ${REQUESTS_TABLE}`)).getRowsJS()[0][0]);
|
|
137
141
|
await history.keepTrimmed(() => ledger.trim(), (e) => log(`request log: deleting the requests older than ${ledger.days} days failed: ${e.message}`));
|