@mason-data/cli 0.7.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +90 -71
- package/dist/src/api/error.js +3 -0
- package/dist/src/api/postgres/catalog.js +222 -13
- package/dist/src/api/postgres/encode.js +3 -1
- package/dist/src/api/postgres/explain.js +3 -2
- package/dist/src/api/postgres/protocol.js +39 -23
- package/dist/src/api/postgres/server.js +1173 -207
- package/dist/src/api/postgres/translate.js +81 -9
- package/dist/src/api/request-log.js +18 -5
- package/dist/src/cdc/poll.js +5 -5
- package/dist/src/cli/background.js +29 -6
- package/dist/src/cli/cancel.js +75 -0
- package/dist/src/cli/command.js +1 -0
- package/dist/src/cli/commands.js +622 -183
- package/dist/src/cli/control.js +59 -0
- package/dist/src/cli/demo.js +19 -10
- package/dist/src/cli/dev.js +351 -111
- package/dist/src/cli/doctor.js +20 -10
- package/dist/src/cli/guides.js +79 -31
- package/dist/src/cli/init.js +119 -58
- package/dist/src/cli/machine.js +12 -11
- package/dist/src/cli/main.js +3 -3
- package/dist/src/cli/mcp.js +29 -20
- package/dist/src/cli/metrics.js +79 -0
- package/dist/src/cli/pg-tls.js +13 -3
- package/dist/src/cli/pg-url.js +4 -0
- package/dist/src/cli/pin.js +13 -12
- package/dist/src/cli/preflight.js +8 -7
- package/dist/src/cli/project.js +43 -4
- package/dist/src/cli/readiness.js +38 -4
- package/dist/src/cli/registry.js +53 -34
- package/dist/src/cli/replication-checks.js +7 -5
- package/dist/src/cli/replication-mode.js +105 -26
- package/dist/src/cli/request-filter.js +134 -0
- package/dist/src/cli/roles.js +584 -0
- package/dist/src/cli/run-server.js +483 -41
- package/dist/src/cli/runs.js +1 -1
- package/dist/src/cli/security.js +10 -8
- package/dist/src/cli/serve.js +144 -42
- package/dist/src/cli/share-run.js +286 -0
- package/dist/src/cli/share.js +286 -0
- package/dist/src/cli/source-edit.js +65 -13
- package/dist/src/cli/sources.js +287 -38
- package/dist/src/cli/supervise.js +12 -1
- package/dist/src/cli/sync.js +55 -25
- package/dist/src/cli/tunnel.js +200 -0
- package/dist/src/cli/ui.js +9 -6
- package/dist/src/cli/usage.js +128 -0
- package/dist/src/cli/user.js +496 -40
- package/dist/src/cli/version.js +9 -7
- package/dist/src/demo/seed.js +3 -0
- package/dist/src/duck/cancel.js +10 -1
- package/dist/src/duck/events.js +134 -0
- package/dist/src/duck/executor-worker.js +18 -9
- package/dist/src/duck/executor.js +43 -30
- package/dist/src/duck/explain.js +56 -46
- package/dist/src/duck/extensions.js +160 -0
- package/dist/src/duck/functions.js +13 -14
- package/dist/src/duck/gaps.js +17 -8
- package/dist/src/duck/grants.js +176 -0
- package/dist/src/duck/history.js +123 -0
- package/dist/src/duck/kept.js +6 -6
- package/dist/src/duck/plan.js +102 -25
- package/dist/src/duck/publication.js +6 -6
- package/dist/src/duck/requests.js +265 -93
- package/dist/src/duck/rewrite.js +25 -5
- package/dist/src/duck/row-filter.js +649 -0
- package/dist/src/duck/store.js +156 -120
- package/dist/src/duck/stream.js +10 -5
- package/dist/src/duck/sync.js +835 -268
- package/dist/src/duck/types.js +6 -5
- package/dist/src/duck/usage.js +324 -0
- package/dist/src/duck/users.js +64 -15
- package/dist/src/duck/verified.js +3 -0
- package/dist/src/duck/views.js +2 -3
- package/dist/src/lib/database-name.js +62 -0
- package/dist/src/lib/dotenv.js +17 -4
- package/dist/src/lib/log-buffer.js +68 -0
- package/dist/src/lib/pg-numeric.js +5 -2
- package/dist/src/lib/project-version.js +23 -23
- package/dist/src/lib/table-sync.js +267 -0
- package/dist/src/web/app.css +706 -66
- package/dist/src/web/app.js +327 -410
- package/dist/src/web/charts.js +229 -0
- package/dist/src/web/index.html +82 -18
- package/dist/src/web/pages/queries.js +192 -0
- package/dist/src/web/pages/status.js +446 -0
- package/dist/src/web/pages/syncs.js +155 -0
- package/dist/src/web/pages/tables.js +434 -0
- package/dist/src/web/pages/usage.js +397 -0
- package/dist/src/web/pages/users.js +218 -0
- package/dist/src/web/shared.js +568 -0
- package/dist/src/web/vendor/README.md +25 -0
- package/dist/src/web/vendor/geist-1.7.2/Geist-Variable.woff2 +0 -0
- package/dist/src/web/vendor/geist-1.7.2/GeistMono-Variable.woff2 +0 -0
- package/dist/src/web/vendor/geist-1.7.2/LICENSE.txt +92 -0
- package/dist/src/web/vendor/lucide-1.56.0/LICENSE.txt +43 -0
- package/dist/src/web/vendor/lucide-1.56.0/chart-column.mjs +29 -0
- package/dist/src/web/vendor/lucide-1.56.0/layout-dashboard.mjs +29 -0
- package/dist/src/web/vendor/lucide-1.56.0/logs.mjs +34 -0
- package/dist/src/web/vendor/lucide-1.56.0/menu.mjs +28 -0
- package/dist/src/web/vendor/lucide-1.56.0/square-terminal.mjs +28 -0
- package/dist/src/web/vendor/lucide-1.56.0/table.mjs +29 -0
- package/dist/src/web/vendor/lucide-1.56.0/users.mjs +29 -0
- package/dist/src/web/vendor/lucide-1.56.0/x.mjs +27 -0
- package/dist/src/web/vendor/uplot-1.6.32/LICENSE.txt +21 -0
- package/dist/src/web/vendor/uplot-1.6.32/uPlot.esm.js +6140 -0
- package/dist/src/web/vendor/uplot-1.6.32/uPlot.min.css +1 -0
- package/dist/src/web/vendor/zilla-slab-5.3.0/LICENSE.txt +93 -0
- package/dist/src/web/vendor/zilla-slab-5.3.0/zilla-slab-latin-700-normal.woff2 +0 -0
- package/package.json +10 -3
package/dist/src/duck/store.js
CHANGED
|
@@ -16,23 +16,23 @@
|
|
|
16
16
|
* (src/duck/sync.ts attach), so the store itself opens offline.
|
|
17
17
|
*/
|
|
18
18
|
import * as os from "node:os";
|
|
19
|
-
import { setTimeout as sleep } from "node:timers/promises";
|
|
20
19
|
import { DuckDBInstance, version } from "@duckdb/node-api";
|
|
21
20
|
import { Executor } from "./executor.js";
|
|
22
21
|
import { beginStatement, cancelStatement, slotName, statementCancelled } from "./cancel.js";
|
|
23
22
|
import { StatementError } from "../api/postgres/server.js";
|
|
24
23
|
import { duckParameters, missingRelationHint, planDuck, quoteDuckKeywords } from "./plan.js";
|
|
25
24
|
import { VERIFIED } from "./verified.js";
|
|
26
|
-
import {
|
|
25
|
+
import { registerFunctions, SESSION_VARIABLE, SETTINGS_VARIABLE } from "./functions.js";
|
|
27
26
|
import { duckCollation } from "./rewrite.js";
|
|
28
27
|
import { decodeLibcOrder, encodeLibcOrder } from "./libc-order.js";
|
|
29
28
|
import { dataRow } from "../api/postgres/protocol.js";
|
|
30
29
|
import { cellText, exactColumn, pgTypedColumn, wireColumn } from "./types.js";
|
|
31
|
-
import { gapMark } from "./gaps.js";
|
|
30
|
+
import { gapIn, gapMark } from "./gaps.js";
|
|
32
31
|
import { cellsOf, evaluateText, NumericError } from "../lib/pg-numeric.js";
|
|
33
32
|
import { explainJson, explainText, planNodes, profiled, qualifiedName, scannedTables } from "./explain.js";
|
|
34
33
|
import * as fs from "node:fs";
|
|
35
34
|
import * as path from "node:path";
|
|
35
|
+
import { extensionSettings, findExtensions } from "./extensions.js";
|
|
36
36
|
export const MASON_SCHEMA = "_mason";
|
|
37
37
|
/** the DuckDB the prototype runs (`@duckdb/node-api`'s bundled library) */
|
|
38
38
|
export const DUCKDB_VERSION = version();
|
|
@@ -57,6 +57,9 @@ export const DUCKDB_VERSION = version();
|
|
|
57
57
|
* holds the key under every collation), and a LEFT lateral's unnest of an expression, which computes the expression
|
|
58
58
|
* once with `list_transform(list_value(e), l -> CASE … l … END)[1]` (`rangeFunction`; tests/duck-wire.test.ts). A `->`
|
|
59
59
|
* that is not a lambda function's argument is still the json operator.
|
|
60
|
+
*
|
|
61
|
+
* Beside them, the copy's extensions (src/duck/extensions.ts `extensionSettings`): with Mason's package for this
|
|
62
|
+
* platform, `extension_directory` is that package and nothing is ever downloaded; without one, DuckDB's defaults.
|
|
60
63
|
*/
|
|
61
64
|
const INSTANCE_SETTINGS = { late_materialization_max_rows: "10000", force_compression: "zstd", lambda_syntax: "ENABLE_SINGLE_ARROW" };
|
|
62
65
|
/**
|
|
@@ -117,8 +120,8 @@ export async function openDuck(file, settings) {
|
|
|
117
120
|
catch (e) {
|
|
118
121
|
const m = e.message;
|
|
119
122
|
throw new Error(/lock/i.test(m)
|
|
120
|
-
? `
|
|
121
|
-
: `
|
|
123
|
+
? `Mason could not open the synced data ${file}: another process holds it (${m}). Is another mason start running for this project? mason status says`
|
|
124
|
+
: `Mason could not open the synced data ${file}: ${m}. mason destroy deletes the synced data, and the next mason start syncs again`);
|
|
122
125
|
}
|
|
123
126
|
const writer = await instance.connect();
|
|
124
127
|
await writer.run(`CREATE SCHEMA IF NOT EXISTS ${MASON_SCHEMA}`);
|
|
@@ -139,27 +142,32 @@ export class DuckStore {
|
|
|
139
142
|
executor;
|
|
140
143
|
closed = null;
|
|
141
144
|
closeGraceMs;
|
|
142
|
-
|
|
145
|
+
/** where the copy's extensions come from: Mason's package for this platform, or DuckDB's install (src/duck/extensions.ts) */
|
|
146
|
+
extensions;
|
|
147
|
+
constructor(file, executor, memoryLimit, extensions, closeGraceMs) {
|
|
143
148
|
this.file = file;
|
|
144
149
|
this.executor = executor;
|
|
145
150
|
this.memoryLimitBytes = memoryLimit;
|
|
151
|
+
this.extensions = extensions;
|
|
146
152
|
this.closeGraceMs = closeGraceMs;
|
|
147
153
|
this.writer = executor.writer();
|
|
148
154
|
this.instance = { connect: () => executor.connect() };
|
|
149
155
|
}
|
|
150
156
|
/** `silentMs` and `closeGraceMs`: the executor's bounds (src/duck/executor.ts SILENT_MS, CLOSE_GRACE_MS), shorter only
|
|
151
|
-
* in a test; `workerFile`, the executor worker's module, another only in a test
|
|
157
|
+
* in a test; `workerFile`, the executor worker's module, another only in a test; `extensions`, this machine's
|
|
158
|
+
* (`findExtensions`) unless a test names a package */
|
|
152
159
|
static async open(file, log, options = {}) {
|
|
160
|
+
const extensions = options.extensions ?? findExtensions();
|
|
153
161
|
const memoryLimit = options.memoryLimit ?? DEFAULT_MEMORY_LIMIT;
|
|
154
162
|
if (!MEMORY_LIMIT_RE.test(memoryLimit))
|
|
155
163
|
throw new Error(`MASON_DUCKDB_MEMORY=${JSON.stringify(memoryLimit)} is not an amount of memory: write it as 8GB or 512MiB (unset, half the machine's RAM: ${DEFAULT_MEMORY_LIMIT})`);
|
|
156
164
|
const where = path.resolve(file);
|
|
157
165
|
const before = opened.get(where);
|
|
158
166
|
if (before !== undefined && before.closed === null)
|
|
159
|
-
throw new Error(`
|
|
167
|
+
throw new Error(`Mason could not open the synced data ${file}: this process has it open already (Mason bug: report it)`);
|
|
160
168
|
// a copy whose thread was given up on may still hold the file: a second instance of it would lose writes
|
|
161
169
|
if (before?.abandoned != null)
|
|
162
|
-
throw new Error(`
|
|
170
|
+
throw new Error(`Mason could not open the synced data ${file}: this process gave up on the engine's thread before (${before.abandoned}), which may still hold it; start mason again (a new process opens it)`);
|
|
163
171
|
const mine = { closed: null, abandoned: null };
|
|
164
172
|
opened.set(where, mine);
|
|
165
173
|
let executor;
|
|
@@ -167,7 +175,7 @@ export class DuckStore {
|
|
|
167
175
|
if (before !== undefined)
|
|
168
176
|
await before.closed;
|
|
169
177
|
// every connection's, the sync's and each session's
|
|
170
|
-
executor = await Executor.start({ file, settings: { ...INSTANCE_SETTINGS, memory_limit: memoryLimit, temp_directory: `${file}.tmp` }, log,
|
|
178
|
+
executor = await Executor.start({ file, settings: { ...INSTANCE_SETTINGS, ...extensionSettings(extensions), memory_limit: memoryLimit, temp_directory: `${file}.tmp` }, log,
|
|
171
179
|
...(options.silentMs !== undefined ? { silentMs: options.silentMs } : {}), ...(options.workerFile !== undefined ? { workerFile: options.workerFile } : {}) });
|
|
172
180
|
}
|
|
173
181
|
catch (e) {
|
|
@@ -175,7 +183,7 @@ export class DuckStore {
|
|
|
175
183
|
opened.delete(where);
|
|
176
184
|
throw e;
|
|
177
185
|
}
|
|
178
|
-
const store = new DuckStore(file, executor, memoryLimitBytes(memoryLimit), options.closeGraceMs);
|
|
186
|
+
const store = new DuckStore(file, executor, memoryLimitBytes(memoryLimit), extensions, options.closeGraceMs);
|
|
179
187
|
// closed: the file may be opened again — unless its thread was given up on (`abandoned`), which keeps it here
|
|
180
188
|
store.closing = (p) => { mine.closed = p; void p.then(() => { if (executor.abandoned !== null)
|
|
181
189
|
mine.abandoned = executor.abandoned;
|
|
@@ -184,11 +192,11 @@ export class DuckStore {
|
|
|
184
192
|
return store;
|
|
185
193
|
}
|
|
186
194
|
closing = () => undefined;
|
|
187
|
-
/** called once if the
|
|
195
|
+
/** called once if the engine's thread dies while the run uses it (src/duck/executor.ts): the run exits with the reason */
|
|
188
196
|
onCrash(f) { this.executor.onCrash(f); }
|
|
189
|
-
/** how many vectors each Mason function was called with so far, on the
|
|
197
|
+
/** how many vectors each Mason function was called with so far, on the engine's thread (a test's, a measurement's) */
|
|
190
198
|
functionCalls() { return this.executor.functionCalls(); }
|
|
191
|
-
/** how many rows each Mason function was handed so far, on the
|
|
199
|
+
/** how many rows each Mason function was handed so far, on the engine's thread (a plan's cost in calls) */
|
|
192
200
|
functionRows() { return this.executor.functionRows(); }
|
|
193
201
|
/** a connection for one Postgres session (on the executor worker: SessionRunner) */
|
|
194
202
|
runner() { return this.executor.runner(); }
|
|
@@ -226,7 +234,7 @@ export class DuckStore {
|
|
|
226
234
|
return {
|
|
227
235
|
collation: c,
|
|
228
236
|
open: () => this.runner(),
|
|
229
|
-
plan: (catalog, text, stmt, base, paramOids, searchPath, timeZone, userTypes, wireBound) => planDuck(catalog, text, stmt, base, paramOids, searchPath, timeZone, c, verified, userTypes, copies, 0, kept ?? null, wireBound ?? false),
|
|
237
|
+
plan: (catalog, text, stmt, base, paramOids, searchPath, timeZone, userTypes, wireBound, grants) => planDuck(catalog, text, stmt, base, paramOids, searchPath, timeZone, c, verified, userTypes, copies, 0, kept ?? null, wireBound ?? false, grants),
|
|
230
238
|
parameters: duckParameters,
|
|
231
239
|
// the sync's state, which EXPLAIN says how each copy is kept current from (src/duck/sync.ts KeptCurrent)
|
|
232
240
|
copies,
|
|
@@ -274,21 +282,23 @@ export function pgErrorOf(err, sql) {
|
|
|
274
282
|
const kind = /^([A-Za-z ]+) Error: /.exec(full)?.[1] ?? "";
|
|
275
283
|
const message = full.replace(/^[A-Za-z ]+ Error: /, "").replace(/\n\n?LINE \d+:[\s\S]*$/, "").trim();
|
|
276
284
|
const pg = postgresWording(kind, message);
|
|
285
|
+
// a refusal raised inside DuckDB (a Mason function's, the rewrite's error() call) crossed it as its text alone: the
|
|
286
|
+
// entry its text names (gapIn), read here, the one place that text becomes a statement's error
|
|
277
287
|
if (pg !== null)
|
|
278
|
-
return new StatementError({
|
|
288
|
+
return new StatementError({ code: pg.code, message: pg.message, detail: `the engine: ${message}` }, sql, pg.gap ?? (pg.code === "0A000" ? gapIn(pg.message) : null));
|
|
279
289
|
const fn = /(?:Scalar|Aggregate|Table) Function with name "?([A-Za-z_][\w]*)"? does not exist/.exec(message)?.[1];
|
|
280
290
|
if (fn !== undefined) {
|
|
281
291
|
// what reaches DuckDB was verified against Postgres (src/duck/verified.ts), so a function DuckDB lacks is a gap in
|
|
282
292
|
// Mason's rewrite, never something for the client to write in DuckDB's dialect
|
|
283
|
-
return new StatementError({ code: "0A000", message: `function ${fn}() is not supported: Mason
|
|
284
|
-
hint: "this is a gap in Mason's translation: report it with the statement (docs/SQL-SUPPORT.md lists what is known)", detail: `
|
|
293
|
+
return new StatementError({ code: "0A000", message: `function ${fn}() is not supported: Mason's engine has no ${fn}`,
|
|
294
|
+
hint: "this is a gap in Mason's translation: report it with the statement (docs/SQL-SUPPORT.md lists what is known)", detail: `the engine: ${message}` });
|
|
285
295
|
}
|
|
286
296
|
const table = /Table with name "?([^"!\s]+)"? does not exist/.exec(message)?.[1];
|
|
287
297
|
if (table !== undefined) {
|
|
288
298
|
// DuckDB's miss comes after the catalog held the table (the plan passed): one dropped as no longer added, whose way
|
|
289
299
|
// back is `mason add`, so no copy state is read
|
|
290
300
|
return new StatementError({ code: "42P01", message: `relation "${table}" does not exist`,
|
|
291
|
-
hint: missingRelationHint(table, []), detail: `
|
|
301
|
+
hint: missingRelationHint(table, []), detail: `the engine: ${message}` });
|
|
292
302
|
}
|
|
293
303
|
const code = kind === "Parser" ? "42601"
|
|
294
304
|
: kind === "Catalog" ? "42P01"
|
|
@@ -308,13 +318,13 @@ export function pgErrorOf(err, sql) {
|
|
|
308
318
|
: kind === "Invalid Input" ? "22023"
|
|
309
319
|
: "XX000";
|
|
310
320
|
return new StatementError({ code, message: code === "57014" ? "canceling statement due to user request"
|
|
311
|
-
: code === "53200" ? "out of memory: this statement needs more than the memory Mason's
|
|
321
|
+
: code === "53200" ? "out of memory: this statement needs more than the memory Mason's engine may use, in a part that cannot spill to disk" : message,
|
|
312
322
|
// DuckDB's own advice is SET, which a client of Mason cannot do: Mason's way out instead
|
|
313
323
|
...(code === "53200" ? { hint: "ask for less at once (a filter, a LIMIT, fewer or narrower columns, a coarser GROUP BY), or raise MASON_DUCKDB_MEMORY (8GB, 24GB) and start mason again; nothing else stopped" } : {}),
|
|
314
324
|
// what Mason ran (the statement as rewritten for DuckDB, src/duck/rewrite.ts): a report of the error needs it
|
|
315
|
-
detail: `
|
|
325
|
+
detail: `the engine${kind ? ` ${kind} error` : ""}: ${message}${sql === "" ? "" : `; Mason ran: ${oneLine(sql, 2000)}`}`,
|
|
316
326
|
// Postgres accepted the GROUP BY (it analysed the statement first): DuckDB refusing it is Mason's rewrite's gap
|
|
317
|
-
...(code === "42803" ? { hint: "Postgres accepts this GROUP BY and Mason's rewrite did not carry it to
|
|
327
|
+
...(code === "42803" ? { hint: "Postgres accepts this GROUP BY and Mason's rewrite did not carry it to the engine: report it with the statement; adding the column to the GROUP BY answers meanwhile" } : {}),
|
|
318
328
|
}, sql);
|
|
319
329
|
}
|
|
320
330
|
/** The errors DuckDB raises for what Postgres raises too, in Postgres's code and words (a tool or a person matches them). */
|
|
@@ -340,11 +350,11 @@ function postgresWording(kind, message) {
|
|
|
340
350
|
return { code: "22003", message: "numeric field overflow" };
|
|
341
351
|
// an exact sum past DuckDB's 38 digits (a text read as numeric is summed as a DECIMAL(38,18), src/duck/rewrite.ts summed)
|
|
342
352
|
if (/^Overflow in HUGEINT addition\b/.test(message))
|
|
343
|
-
return { code: "0A000", message: `a sum past
|
|
353
|
+
return { code: "0A000", message: `a sum past the engine's 38 digits is not supported by Mason (${gapMark("numeric-exact-sum")})`, gap: "numeric-exact-sum" };
|
|
344
354
|
// a numeric(p, s) read from a text holding NaN or Infinity (src/duck/rewrite.ts pgInput): DuckDB's decimal has none
|
|
345
355
|
const special = /Could not convert string ['"](NaN|-?Infinity)['"] to DECIMAL/.exec(message);
|
|
346
356
|
if (special)
|
|
347
|
-
return { code: "0A000", message: `a numeric ${special[1]} read from a text is not supported by Mason (${gapMark("numeric-special")})
|
|
357
|
+
return { code: "0A000", message: `a numeric ${special[1]} read from a text is not supported by Mason (${gapMark("numeric-special")})`, gap: "numeric-special" };
|
|
348
358
|
const conv = /Could not convert string '((?:[^']|'')*)' to (INT16|INT32|INT64|DOUBLE|FLOAT|DATE|TIMESTAMP|UUID|BOOL)/.exec(message);
|
|
349
359
|
if (conv) {
|
|
350
360
|
const t = { INT16: "smallint", INT32: "integer", INT64: "bigint", DOUBLE: "double precision", FLOAT: "real", DATE: "date", TIMESTAMP: "timestamp", UUID: "uuid", BOOL: "boolean" }[conv[2]];
|
|
@@ -363,12 +373,8 @@ function postgresWording(kind, message) {
|
|
|
363
373
|
return { code: "22P02", message };
|
|
364
374
|
return null;
|
|
365
375
|
}
|
|
366
|
-
/**
|
|
367
|
-
|
|
368
|
-
* does between two calls is one vector's (2,048 rows) — milliseconds; 50 ms leaves an order of magnitude over both */
|
|
369
|
-
const CALLS_QUIET_MS = 50;
|
|
370
|
-
/** when the wait for a statement's calls to stop is said in the log */
|
|
371
|
-
const CALLS_SLOW_MS = 5_000;
|
|
376
|
+
/** when the wait for a statement to end is said in the log */
|
|
377
|
+
const ENDING_SLOW_MS = 5_000;
|
|
372
378
|
/** One wire session's DuckDB connection, with the session's zone and search_path applied before each statement. Runs
|
|
373
379
|
* on the executor worker (src/duck/executor-worker.ts), its statements asked by the listener's RemoteRunner. */
|
|
374
380
|
export class SessionRunner {
|
|
@@ -388,6 +394,7 @@ export class SessionRunner {
|
|
|
388
394
|
log;
|
|
389
395
|
zone = null;
|
|
390
396
|
path = null;
|
|
397
|
+
settings = null;
|
|
391
398
|
constructor(conn, log, slot, cancels) {
|
|
392
399
|
this.conn = conn;
|
|
393
400
|
this.log = log;
|
|
@@ -419,6 +426,13 @@ export class SessionRunner {
|
|
|
419
426
|
await this.conn.run(`SET search_path = ${quoteLiteral(path)}`);
|
|
420
427
|
this.path = path;
|
|
421
428
|
}
|
|
429
|
+
// what `current_setting(name)` reads (src/duck/rewrite.ts `settingRead`): the session's settings, as one map
|
|
430
|
+
const settings = JSON.stringify(session.settings);
|
|
431
|
+
if (settings !== this.settings) {
|
|
432
|
+
const entries = Object.entries(session.settings).map(([k, v]) => `${quoteLiteral(k)}: ${quoteLiteral(v)}`).join(", ");
|
|
433
|
+
await this.conn.run(`SET VARIABLE ${SETTINGS_VARIABLE} = MAP {${entries}}::MAP(VARCHAR, VARCHAR)`);
|
|
434
|
+
this.settings = settings;
|
|
435
|
+
}
|
|
422
436
|
}
|
|
423
437
|
async prepare(sql) {
|
|
424
438
|
try {
|
|
@@ -478,13 +492,10 @@ export class SessionRunner {
|
|
|
478
492
|
const p = await this.prepare(sql);
|
|
479
493
|
timing.prepareMs = performance.now() - t0;
|
|
480
494
|
// HOW A STATEMENT IS CANCELLED (src/duck/functions.ts "every call carries its session"; the test
|
|
481
|
-
// server's hung process).
|
|
482
|
-
//
|
|
483
|
-
//
|
|
484
|
-
//
|
|
485
|
-
// raises — AND interrupted (its tasks stop at their next chunk, so a call-free rest past its last call stops too),
|
|
486
|
-
// and its result is closed only once DuckDB has handed it back (the fetch's own path, or `drain`) and its calls
|
|
487
|
-
// have stopped (`cleanUp`: a task still inside a call waits for this thread, which a close would hold). Whether it calls
|
|
495
|
+
// server's hung process). It is interrupted — its tasks stop at their next chunk — and, if it calls a Mason
|
|
496
|
+
// function, cancelled through its session as well: its next call raises, so a task of it does not call on. Its
|
|
497
|
+
// result is closed only once DuckDB ended the query, on a thread that is not this one (`cleanUp`: a task still
|
|
498
|
+
// inside a call waits for this thread, which a close would hold). Whether it calls
|
|
488
499
|
// one is the PLAN's (src/duck/plan.ts PlannedSql `functions`, read off the rewritten tree), never a match on its
|
|
489
500
|
// text: a stored JSON path's column and the view of a copy's visible columns are named `__mason_…` too, and
|
|
490
501
|
// read as calls, every `SELECT *` over such a copy was cancelled only by a call it never made (a review
|
|
@@ -509,7 +520,20 @@ export class SessionRunner {
|
|
|
509
520
|
const started = Date.now();
|
|
510
521
|
let total = 0;
|
|
511
522
|
let result = null;
|
|
512
|
-
let closed = false, fetching = false, released = false;
|
|
523
|
+
let closed = false, fetching = false, released = false, said = false;
|
|
524
|
+
/** DuckDB ended the query: a fetch failed, or read its end (`cleanUp` says why that is what `release` waits for) */
|
|
525
|
+
let ended = false;
|
|
526
|
+
let ending = false;
|
|
527
|
+
// ITS LINE IS SAID WHEN IT ENDS, before its client hears the end, never when its result is released: that waits for
|
|
528
|
+
// DuckDB to end it (`cleanUp`), by when its client is gone and the next session's statements are running, and a
|
|
529
|
+
// reader of the log took the late line for theirs (`tests/duck-wire.test.ts`, 1 run in 100 under load). The
|
|
530
|
+
// worker's port keeps the order: a line posted before the answer is read before it
|
|
531
|
+
const say = () => {
|
|
532
|
+
if (said)
|
|
533
|
+
return;
|
|
534
|
+
said = true;
|
|
535
|
+
this.log(`statement: ${total} row(s) in ${Date.now() - started} ms — ${oneLine(sql)}`);
|
|
536
|
+
};
|
|
513
537
|
const release = () => {
|
|
514
538
|
if (released)
|
|
515
539
|
return;
|
|
@@ -519,60 +543,32 @@ export class SessionRunner {
|
|
|
519
543
|
signal.removeEventListener("abort", cancel);
|
|
520
544
|
result?.closeSync();
|
|
521
545
|
p.destroySync();
|
|
522
|
-
|
|
546
|
+
say();
|
|
523
547
|
};
|
|
524
|
-
// A
|
|
525
|
-
//
|
|
526
|
-
//
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
// or one read to its end, has no task left to wait for and is released at once
|
|
530
|
-
const releaseAfterCleanup = () => {
|
|
531
|
-
if (!callsFunctions || released) {
|
|
532
|
-
release();
|
|
548
|
+
// A STATEMENT THAT DID NOT RUN TO ITS END (it failed, its cancel raised, it was closed early) IS CLOSED ONLY ONCE
|
|
549
|
+
// DuckDB ENDED IT OFF THIS THREAD (`cleanUp`), the session's next statement waiting for that (`releasing`). One read
|
|
550
|
+
// to its end has no task left, and is released at once
|
|
551
|
+
const endOffThisThread = () => {
|
|
552
|
+
if (released || ending)
|
|
533
553
|
return;
|
|
534
|
-
|
|
535
|
-
const done = (async () => { await this.cleanUp(seq, sql); release(); })();
|
|
554
|
+
ending = true;
|
|
555
|
+
const done = (async () => { await this.cleanUp(seq, sql, callsFunctions, ended ? null : result); release(); })();
|
|
536
556
|
this.releasing.add(done);
|
|
537
557
|
void done.finally(() => this.releasing.delete(done));
|
|
538
558
|
};
|
|
539
|
-
//
|
|
540
|
-
|
|
541
|
-
|
|
559
|
+
// it ended with an answer its client is about to hear: said now
|
|
560
|
+
const releaseAfterCleanup = () => { if (!released)
|
|
561
|
+
say(); endOffThisThread(); };
|
|
562
|
+
// a result closed before its end while no fetch is in flight: no end its client hears, so its line is its release's
|
|
542
563
|
const drain = () => {
|
|
543
|
-
const r = result;
|
|
544
564
|
// CLOSED WHILE DuckDB STILL STARTS IT (its `stream()` pending: an aggregate computes there whole): nothing is
|
|
545
565
|
// released under the pending start — that destroyed the prepared statement under it and, its release done, let the
|
|
546
566
|
// start's own failure skip `cleanUp`, so the connection and the instance closed while its other tasks still had
|
|
547
567
|
// calls queued for this thread, and the thread's end met them (Linux CI: `FATAL ERROR: Error::Error
|
|
548
568
|
// napi_define_properties`, 7 runs in 30 of a stop under a calling count). The start's own path releases it: its
|
|
549
569
|
// failure through `releaseAfterCleanup`, its answer drained once handed back (below `stream()`)
|
|
550
|
-
if (
|
|
551
|
-
|
|
552
|
-
if (!callsFunctions) {
|
|
553
|
-
release();
|
|
554
|
-
return;
|
|
555
|
-
}
|
|
556
|
-
const done = (async () => {
|
|
557
|
-
// read on until the next call raises or the result ends — an EMPTY chunk, as `fetch` reads it: past its end a
|
|
558
|
-
// streamed result hands back empty chunks for ever, never null, and a result closed after its last row but
|
|
559
|
-
// before its end was read here without end, a thread spinning and the session's close waiting on it
|
|
560
|
-
try {
|
|
561
|
-
for (let c = await r.fetchChunk(); c !== null && c.rowCount > 0; c = await r.fetchChunk()) { /* its rows are nobody's */ }
|
|
562
|
-
}
|
|
563
|
-
catch (e) {
|
|
564
|
-
// the cancel's own refusal (its next call raised, 57014) is what the drain waits for. Anything else is the
|
|
565
|
-
// statement's real error, met after the listener let go of its answer: no one reads it any more, and a
|
|
566
|
-
// rethrow here would reach no caller (it would end this thread, and with it every session), so it is said
|
|
567
|
-
if (pgErrorOf(e, sql).fields.code !== "57014")
|
|
568
|
-
this.log(`a statement closed before its end failed while it was drained: ${e instanceof Error ? e.message : String(e)} — ${oneLine(sql)}`);
|
|
569
|
-
// it failed, so a task of it may still be inside a call: closed once its calls have stopped
|
|
570
|
-
await this.cleanUp(seq, sql);
|
|
571
|
-
}
|
|
572
|
-
release();
|
|
573
|
-
})();
|
|
574
|
-
this.releasing.add(done);
|
|
575
|
-
void done.finally(() => this.releasing.delete(done));
|
|
570
|
+
if (result !== null)
|
|
571
|
+
endOffThisThread();
|
|
576
572
|
};
|
|
577
573
|
// an early end by the listener (a portal closed half read, the session gone): cancelled, and released — by the
|
|
578
574
|
// fetch in flight once DuckDB hands the chunk back, else by `drain`
|
|
@@ -601,22 +597,22 @@ export class SessionRunner {
|
|
|
601
597
|
catch (e) {
|
|
602
598
|
throw failure(e);
|
|
603
599
|
}
|
|
604
|
-
// CLOSED WHILE IT STARTED (`drain` above), now that DuckDB handed it back
|
|
605
|
-
//
|
|
606
|
-
//
|
|
607
|
-
//
|
|
608
|
-
//
|
|
600
|
+
// CLOSED WHILE IT STARTED (`drain` above), now that DuckDB handed it back, and drained. One that calls no function
|
|
601
|
+
// is told the cancel here, its release done first, before a column of a destroyed result is asked (DuckDB
|
|
602
|
+
// answers 0 columns for one: a review finding, an XX000 "report it" where the session had closed); one that calls
|
|
603
|
+
// holds its result open until DuckDB ended it, so its answer below is built from a live result and its fetch
|
|
604
|
+
// tells the cancel
|
|
609
605
|
if (closed) {
|
|
606
|
+
drain();
|
|
610
607
|
if (!callsFunctions) {
|
|
611
|
-
|
|
608
|
+
await Promise.all([...this.releasing]);
|
|
612
609
|
throw cancelled();
|
|
613
610
|
}
|
|
614
|
-
drain();
|
|
615
611
|
}
|
|
616
612
|
timing.duckdbMs += performance.now() - t1;
|
|
617
613
|
const r = result;
|
|
618
614
|
if (fields !== null && r.columnCount < fields.length)
|
|
619
|
-
throw new StatementError({ code: "XX000", message: `
|
|
615
|
+
throw new StatementError({ code: "XX000", message: `the engine answered ${r.columnCount} column(s) where Postgres's analysis has ${fields.length}: report it (Mason's rewrite)` }, sql);
|
|
620
616
|
// Postgres's columns where its analysis gave them (a WITH TIES rank DuckDB adds after them is not sent)
|
|
621
617
|
const columns = fields !== null
|
|
622
618
|
? fields.map((f, i) => pgTypedColumn(f.name, f.oid, r.columnType(i), f.typmod))
|
|
@@ -646,11 +642,13 @@ export class SessionRunner {
|
|
|
646
642
|
chunk = await this.track(r.fetchChunk());
|
|
647
643
|
}
|
|
648
644
|
catch (e) {
|
|
645
|
+
ended = true;
|
|
649
646
|
throw failure(e);
|
|
650
647
|
}
|
|
651
648
|
finally {
|
|
652
649
|
fetching = false;
|
|
653
650
|
}
|
|
651
|
+
ended = chunk === null || chunk.rowCount === 0;
|
|
654
652
|
if (closed) {
|
|
655
653
|
drain();
|
|
656
654
|
return null;
|
|
@@ -703,8 +701,17 @@ export class SessionRunner {
|
|
|
703
701
|
return this.track(this.explained(sql, params, session, signal, how));
|
|
704
702
|
}
|
|
705
703
|
async explained(sql, params, session, signal, how) {
|
|
706
|
-
await Promise.all([...this.releasing]);
|
|
707
704
|
const o = how.options;
|
|
705
|
+
// A STATEMENT THAT READS A FILTERED TABLE IS NEVER SHOWN TO THE ENGINE: its plan says what it proved from the table's
|
|
706
|
+
// statistics (docs/DESIGN.md "Roles: rows per user"), so the tables are the statement's and its filters' (a through:'s
|
|
707
|
+
// parents), and the rest is Mason's plan
|
|
708
|
+
if ((how.rowFilters ?? []).length > 0) {
|
|
709
|
+
const tables = [...new Set([...(how.reads ?? []), ...(how.rowFilters ?? []).map((f) => f.table)])].map((t) => { const dot = t.indexOf("."); return { schema: t.slice(0, dot), name: t.slice(dot + 1) }; });
|
|
710
|
+
const explained = { copies: await this.copies(tables, how.copies, how.rowFilters ?? []), sql: oneLine(sql.text), computed: computedOf(how.exact, how.fields),
|
|
711
|
+
functions: [...sql.functions], plan: [], filtered: true };
|
|
712
|
+
return o.format === "json" ? [explainJson(explained, o)] : explainText(explained, o);
|
|
713
|
+
}
|
|
714
|
+
await Promise.all([...this.releasing]);
|
|
708
715
|
let plan;
|
|
709
716
|
let analyzed;
|
|
710
717
|
if (!o.analyze)
|
|
@@ -727,6 +734,7 @@ export class SessionRunner {
|
|
|
727
734
|
}
|
|
728
735
|
}
|
|
729
736
|
finally {
|
|
737
|
+
await Promise.all([...this.releasing]);
|
|
730
738
|
await this.conn.run("PRAGMA disable_profiling");
|
|
731
739
|
}
|
|
732
740
|
// THE PROFILE IS HELD TO THE STATEMENT, never assumed its: DuckDB writes one when the result is read to its end,
|
|
@@ -734,7 +742,7 @@ export class SessionRunner {
|
|
|
734
742
|
// whole table is the table's row count), and its plan is its EXPLAIN, the rows the ones it answered
|
|
735
743
|
const profile = fs.existsSync(file) ? profiled(JSON.parse(fs.readFileSync(file, "utf8"))) : null;
|
|
736
744
|
if (profile !== null && profile.sql !== sql.text) {
|
|
737
|
-
throw new StatementError({ code: "XX000", message: `
|
|
745
|
+
throw new StatementError({ code: "XX000", message: `the engine's profile of an EXPLAIN ANALYZE is of another statement (${oneLine(profile.sql ?? "none named")}): report it (Mason's EXPLAIN)` }, sql.text);
|
|
738
746
|
}
|
|
739
747
|
if (profile !== null)
|
|
740
748
|
plan = profile.plan;
|
|
@@ -750,7 +758,7 @@ export class SessionRunner {
|
|
|
750
758
|
}
|
|
751
759
|
}
|
|
752
760
|
// what it calls is the plan's (src/duck/plan.ts PlannedSql), the list that decides how it is cancelled
|
|
753
|
-
const explained = { copies: await this.copies(scannedTables(plan), how.copies), sql: oneLine(sql.text), computed: computedOf(how.exact, how.fields),
|
|
761
|
+
const explained = { copies: await this.copies(scannedTables(plan), how.copies, how.rowFilters ?? []), sql: oneLine(sql.text), computed: computedOf(how.exact, how.fields),
|
|
754
762
|
functions: [...sql.functions], plan, ...(analyzed === undefined ? {} : { analyzed }) };
|
|
755
763
|
return o.format === "json" ? [explainJson(explained, o)] : explainText(explained, o);
|
|
756
764
|
}
|
|
@@ -788,43 +796,71 @@ export class SessionRunner {
|
|
|
788
796
|
}
|
|
789
797
|
/** each copy's rows, and the sync's facts of it (how it is kept current: src/duck/sync.ts `KeptCurrent`, never the
|
|
790
798
|
* poll's ledger read here — a project switched to logical replication still holds its last poll there) */
|
|
791
|
-
async copies(tables, facts) {
|
|
799
|
+
async copies(tables, facts, filters = []) {
|
|
792
800
|
const out = [];
|
|
793
801
|
for (const { schema, name } of tables) {
|
|
794
|
-
const rows = (await this.conn.runAndReadAll("SELECT estimated_size FROM duckdb_tables() WHERE database_name = current_database() AND schema_name = $1 AND table_name = $2", [schema, name])).getRowsJS()[0]?.[0];
|
|
795
802
|
// the sync's source is the two names as they are; EXPLAIN says the table as a statement writes it (qualifiedName)
|
|
796
|
-
|
|
803
|
+
const filter = filters.find((f) => f.table === `${schema}.${name}`)?.words;
|
|
804
|
+
// a table the role filters: its counts are every row's, not the role's, and are never read or said here
|
|
805
|
+
const rows = filter !== undefined ? null : (await this.conn.runAndReadAll("SELECT estimated_size FROM duckdb_tables() WHERE database_name = current_database() AND schema_name = $1 AND table_name = $2", [schema, name])).getRowsJS()[0]?.[0];
|
|
806
|
+
out.push({ table: qualifiedName({ schema, name }), rows: rows === undefined || rows === null ? null : Number(rows), facts: facts.find((f) => f.source === `${schema}.${name}`) ?? null,
|
|
807
|
+
...(filter !== undefined ? { filter } : {}) });
|
|
797
808
|
}
|
|
798
809
|
return out;
|
|
799
810
|
}
|
|
800
811
|
/**
|
|
801
|
-
* A
|
|
802
|
-
*
|
|
803
|
-
*
|
|
804
|
-
*
|
|
805
|
-
*
|
|
806
|
-
*
|
|
807
|
-
*
|
|
808
|
-
* `
|
|
812
|
+
* A STATEMENT THAT DID NOT RUN TO ITS END, ENDED BY DuckDB OFF THIS THREAD before its result is closed. THIS THREAD
|
|
813
|
+
* NEVER WAITS FOR THE ENGINE'S TASKS: every close is synchronous here, a close of an open result waits for the query's
|
|
814
|
+
* tasks (Executor::CancelTasks), and this thread runs every Mason function, so a task inside a call waits for it (the
|
|
815
|
+
* suite hung for good at 0 % CPU; `tests/duck-executor.test.ts` "a vector slower than"). Waiting for quiet instead —
|
|
816
|
+
* no call for 50 ms, as this did — was no proof: a vector whose native work outlasts the window has made no call yet.
|
|
817
|
+
* And a call-free statement's tasks hold this thread as long as DuckDB's vector takes, with every session behind it.
|
|
818
|
+
*
|
|
819
|
+
* WHAT WAITS IS A FETCH (read in DuckDB 2.0's source, `ClientContext::EndQueryInternal`, and held by the test above).
|
|
820
|
+
* Every end of a query runs `CancelTasks` on the thread that ends it, and a fetch runs on libuv's pool. Interrupted
|
|
821
|
+
* first, the next fetch does not compute: it fails at once (`BufferedData::Participate`), ending the query there, its
|
|
822
|
+
* tasks waited for while this thread answers their calls; or it reads the end, or the end was read already (`r`
|
|
823
|
+
* null). So the wait is bounded by DuckDB's next chunk, on a statement that never ends by itself too, and an EMPTY chunk
|
|
824
|
+
* is the end, as `fetch` reads it: past its end a streamed result hands back empty chunks for ever, never null.
|
|
825
|
+
*
|
|
826
|
+
* THE PROBE PROVES IT (a statement performs no wait: while a result holds the connection DuckDB refuses it, "connection
|
|
827
|
+
* has an open result", before anything else). So a `SELECT 1` that ran is proof that nothing of the statement is left,
|
|
828
|
+
* and the close finds nothing to wait for. Refused, the reading above no longer holds: said as a Mason bug, and closed
|
|
829
|
+
* all the same, the watch (src/duck/executor.ts SILENT_MS) catching the deadlock if there is one. A wait past
|
|
830
|
+
* `ENDING_SLOW_MS` is said once.
|
|
809
831
|
*/
|
|
810
|
-
async cleanUp(seq, sql) {
|
|
811
|
-
|
|
832
|
+
async cleanUp(seq, sql, calls, r) {
|
|
833
|
+
if (calls)
|
|
834
|
+
cancelStatement(this.cancels, this.slot, seq);
|
|
812
835
|
this.conn.interrupt();
|
|
813
|
-
const
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
836
|
+
const slow = setTimeout(() => this.log(`a statement that did not run to its end has not stopped after ${ENDING_SLOW_MS / 1000} s; its result is closed once the engine ended it — ${oneLine(sql)}`), ENDING_SLOW_MS);
|
|
837
|
+
try {
|
|
838
|
+
if (r !== null) {
|
|
839
|
+
try {
|
|
840
|
+
for (let c = await r.fetchChunk(); c !== null && c.rowCount > 0; c = await r.fetchChunk()) { /* its rows are nobody's */ }
|
|
841
|
+
}
|
|
842
|
+
catch (e) {
|
|
843
|
+
// the cancel's own refusal (the interrupt, or its next call raised, 57014) is what this waits for. Anything else
|
|
844
|
+
// is the statement's real error, met after the listener let go of its answer: no one reads it any more, and a
|
|
845
|
+
// rethrow here would reach no caller (it would end this thread, and with it every session), so it is said
|
|
846
|
+
if (pgErrorOf(e, sql).fields.code !== "57014")
|
|
847
|
+
this.log(`a statement closed before its end failed while it was drained: ${e instanceof Error ? e.message : String(e)} — ${oneLine(sql)}`);
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
// an abort landing now interrupts the probe past DuckDB's check, which is its proof all the same
|
|
851
|
+
try {
|
|
852
|
+
await this.conn.run("SELECT 1");
|
|
853
|
+
}
|
|
854
|
+
catch (e) {
|
|
855
|
+
if (/open result/.test(String(e)))
|
|
856
|
+
this.log(`a statement's result is still open after the engine failed or ended it (Mason bug, report it with mason logs): closed all the same — ${oneLine(sql)}`);
|
|
857
|
+
else if (pgErrorOf(e, "SELECT 1").fields.code !== "57014")
|
|
858
|
+
this.log(`the check that a statement ended failed: ${e instanceof Error ? e.message : String(e)} — ${oneLine(sql)}`);
|
|
826
859
|
}
|
|
827
860
|
}
|
|
861
|
+
finally {
|
|
862
|
+
clearTimeout(slow);
|
|
863
|
+
}
|
|
828
864
|
}
|
|
829
865
|
/** Closes the connection once nothing runs on it: an answer left open drained (its statement cancelled), every
|
|
830
866
|
* answer released and every call of DuckDB settled (never under the executor: `drain`; a connection
|
|
@@ -858,7 +894,7 @@ function exactColumns(columns, fields, exact, typeAt) {
|
|
|
858
894
|
const computed = exact.filter((e) => {
|
|
859
895
|
const unreadable = readers.some((r, j) => r === null && programReads(e, j));
|
|
860
896
|
if (unreadable)
|
|
861
|
-
columns[e.column] = { ...columns[e.column], divergence: `Postgres computes this numeric exactly; a part of it
|
|
897
|
+
columns[e.column] = { ...columns[e.column], divergence: `Postgres computes this numeric exactly; a part of it the engine computed as a double` };
|
|
862
898
|
return !unreadable;
|
|
863
899
|
});
|
|
864
900
|
if (computed.length === 0)
|
|
@@ -882,7 +918,7 @@ function exactRows(computed, rows, base, sql, params) {
|
|
|
882
918
|
}
|
|
883
919
|
catch (err) {
|
|
884
920
|
if (err instanceof NumericError)
|
|
885
|
-
throw new StatementError({ code: err.sqlstate, message: err.message }, sql);
|
|
921
|
+
throw new StatementError({ code: err.sqlstate, message: err.message }, sql, err.gap);
|
|
886
922
|
throw err;
|
|
887
923
|
}
|
|
888
924
|
}
|
|
@@ -893,7 +929,7 @@ function exactRows(computed, rows, base, sql, params) {
|
|
|
893
929
|
function computedOf(exact, fields) {
|
|
894
930
|
return exact.map((e) => {
|
|
895
931
|
const cells = new Set(cellsOf(e.program).filter((k) => !k.startsWith("$"))).size;
|
|
896
|
-
return `column ${e.column + 1}${fields?.[e.column] === undefined ? "" : ` "${fields[e.column].name}"`}: Postgres's exact numeric${e.text ? " as text" : ""}, from ${cells} of
|
|
932
|
+
return `column ${e.column + 1}${fields?.[e.column] === undefined ? "" : ` "${fields[e.column].name}"`}: Postgres's exact numeric${e.text ? " as text" : ""}, from ${cells} of the engine's value(s)`;
|
|
897
933
|
});
|
|
898
934
|
}
|
|
899
935
|
/** whether the column's program reads cell j */
|