@mason-data/cli 0.7.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (111) hide show
  1. package/README.md +90 -71
  2. package/dist/src/api/error.js +3 -0
  3. package/dist/src/api/postgres/catalog.js +222 -13
  4. package/dist/src/api/postgres/encode.js +3 -1
  5. package/dist/src/api/postgres/explain.js +3 -2
  6. package/dist/src/api/postgres/protocol.js +39 -23
  7. package/dist/src/api/postgres/server.js +1173 -207
  8. package/dist/src/api/postgres/translate.js +81 -9
  9. package/dist/src/api/request-log.js +18 -5
  10. package/dist/src/cdc/poll.js +5 -5
  11. package/dist/src/cli/background.js +29 -6
  12. package/dist/src/cli/cancel.js +75 -0
  13. package/dist/src/cli/command.js +1 -0
  14. package/dist/src/cli/commands.js +622 -183
  15. package/dist/src/cli/control.js +59 -0
  16. package/dist/src/cli/demo.js +19 -10
  17. package/dist/src/cli/dev.js +351 -111
  18. package/dist/src/cli/doctor.js +20 -10
  19. package/dist/src/cli/guides.js +79 -31
  20. package/dist/src/cli/init.js +119 -58
  21. package/dist/src/cli/machine.js +12 -11
  22. package/dist/src/cli/main.js +3 -3
  23. package/dist/src/cli/mcp.js +29 -20
  24. package/dist/src/cli/metrics.js +79 -0
  25. package/dist/src/cli/pg-tls.js +13 -3
  26. package/dist/src/cli/pg-url.js +4 -0
  27. package/dist/src/cli/pin.js +13 -12
  28. package/dist/src/cli/preflight.js +8 -7
  29. package/dist/src/cli/project.js +43 -4
  30. package/dist/src/cli/readiness.js +38 -4
  31. package/dist/src/cli/registry.js +53 -34
  32. package/dist/src/cli/replication-checks.js +7 -5
  33. package/dist/src/cli/replication-mode.js +105 -26
  34. package/dist/src/cli/request-filter.js +134 -0
  35. package/dist/src/cli/roles.js +584 -0
  36. package/dist/src/cli/run-server.js +483 -41
  37. package/dist/src/cli/runs.js +1 -1
  38. package/dist/src/cli/security.js +10 -8
  39. package/dist/src/cli/serve.js +144 -42
  40. package/dist/src/cli/share-run.js +286 -0
  41. package/dist/src/cli/share.js +286 -0
  42. package/dist/src/cli/source-edit.js +65 -13
  43. package/dist/src/cli/sources.js +287 -38
  44. package/dist/src/cli/supervise.js +12 -1
  45. package/dist/src/cli/sync.js +55 -25
  46. package/dist/src/cli/tunnel.js +200 -0
  47. package/dist/src/cli/ui.js +9 -6
  48. package/dist/src/cli/usage.js +128 -0
  49. package/dist/src/cli/user.js +496 -40
  50. package/dist/src/cli/version.js +9 -7
  51. package/dist/src/demo/seed.js +3 -0
  52. package/dist/src/duck/cancel.js +10 -1
  53. package/dist/src/duck/events.js +134 -0
  54. package/dist/src/duck/executor-worker.js +18 -9
  55. package/dist/src/duck/executor.js +43 -30
  56. package/dist/src/duck/explain.js +56 -46
  57. package/dist/src/duck/extensions.js +160 -0
  58. package/dist/src/duck/functions.js +13 -14
  59. package/dist/src/duck/gaps.js +17 -8
  60. package/dist/src/duck/grants.js +176 -0
  61. package/dist/src/duck/history.js +123 -0
  62. package/dist/src/duck/kept.js +6 -6
  63. package/dist/src/duck/plan.js +102 -25
  64. package/dist/src/duck/publication.js +6 -6
  65. package/dist/src/duck/requests.js +265 -93
  66. package/dist/src/duck/rewrite.js +25 -5
  67. package/dist/src/duck/row-filter.js +649 -0
  68. package/dist/src/duck/store.js +156 -120
  69. package/dist/src/duck/stream.js +10 -5
  70. package/dist/src/duck/sync.js +835 -268
  71. package/dist/src/duck/types.js +6 -5
  72. package/dist/src/duck/usage.js +324 -0
  73. package/dist/src/duck/users.js +64 -15
  74. package/dist/src/duck/verified.js +3 -0
  75. package/dist/src/duck/views.js +2 -3
  76. package/dist/src/lib/database-name.js +62 -0
  77. package/dist/src/lib/dotenv.js +17 -4
  78. package/dist/src/lib/log-buffer.js +68 -0
  79. package/dist/src/lib/pg-numeric.js +5 -2
  80. package/dist/src/lib/project-version.js +23 -23
  81. package/dist/src/lib/table-sync.js +267 -0
  82. package/dist/src/web/app.css +706 -66
  83. package/dist/src/web/app.js +327 -410
  84. package/dist/src/web/charts.js +229 -0
  85. package/dist/src/web/index.html +82 -18
  86. package/dist/src/web/pages/queries.js +192 -0
  87. package/dist/src/web/pages/status.js +446 -0
  88. package/dist/src/web/pages/syncs.js +155 -0
  89. package/dist/src/web/pages/tables.js +434 -0
  90. package/dist/src/web/pages/usage.js +397 -0
  91. package/dist/src/web/pages/users.js +218 -0
  92. package/dist/src/web/shared.js +568 -0
  93. package/dist/src/web/vendor/README.md +25 -0
  94. package/dist/src/web/vendor/geist-1.7.2/Geist-Variable.woff2 +0 -0
  95. package/dist/src/web/vendor/geist-1.7.2/GeistMono-Variable.woff2 +0 -0
  96. package/dist/src/web/vendor/geist-1.7.2/LICENSE.txt +92 -0
  97. package/dist/src/web/vendor/lucide-1.56.0/LICENSE.txt +43 -0
  98. package/dist/src/web/vendor/lucide-1.56.0/chart-column.mjs +29 -0
  99. package/dist/src/web/vendor/lucide-1.56.0/layout-dashboard.mjs +29 -0
  100. package/dist/src/web/vendor/lucide-1.56.0/logs.mjs +34 -0
  101. package/dist/src/web/vendor/lucide-1.56.0/menu.mjs +28 -0
  102. package/dist/src/web/vendor/lucide-1.56.0/square-terminal.mjs +28 -0
  103. package/dist/src/web/vendor/lucide-1.56.0/table.mjs +29 -0
  104. package/dist/src/web/vendor/lucide-1.56.0/users.mjs +29 -0
  105. package/dist/src/web/vendor/lucide-1.56.0/x.mjs +27 -0
  106. package/dist/src/web/vendor/uplot-1.6.32/LICENSE.txt +21 -0
  107. package/dist/src/web/vendor/uplot-1.6.32/uPlot.esm.js +6140 -0
  108. package/dist/src/web/vendor/uplot-1.6.32/uPlot.min.css +1 -0
  109. package/dist/src/web/vendor/zilla-slab-5.3.0/LICENSE.txt +93 -0
  110. package/dist/src/web/vendor/zilla-slab-5.3.0/zilla-slab-latin-700-normal.woff2 +0 -0
  111. package/package.json +10 -3
@@ -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 { msSinceCall, registerFunctions, SESSION_VARIABLE } from "./functions.js";
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
- ? `DuckDB could not open ${file}: another process holds it (${m}). Is another mason start running for this project? mason status says`
121
- : `DuckDB could not open ${file}: ${m}. mason destroy deletes the copy, and the next mason start copies again`);
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
- constructor(file, executor, memoryLimit, closeGraceMs) {
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(`DuckDB could not open ${file}: this process has it open already (Mason bug: report it)`);
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(`DuckDB could not open ${file}: this process gave up on the copy's thread before (${before.abandoned}), which may still hold it; start mason again (a new process opens it)`);
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 copy's thread dies while the run uses it (src/duck/executor.ts): the run exits with the reason */
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 copy's thread (a test's, a measurement's) */
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 copy's thread (a plan's cost in calls) */
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({ ...pg, detail: `DuckDB: ${message}` }, sql);
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 answers SQL with DuckDB, which has no ${fn}`,
284
- hint: "this is a gap in Mason's translation: report it with the statement (docs/SQL-SUPPORT.md lists what is known)", detail: `DuckDB: ${message}` });
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: `DuckDB: ${message}` });
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 DuckDB may use, in a part that cannot spill to disk" : message,
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: `DuckDB${kind ? ` ${kind} Error` : ""} (Mason answers SQL with DuckDB): ${message}${sql === "" ? "" : `; DuckDB ran: ${oneLine(sql, 2000)}`}`,
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 DuckDB: report it with the statement; adding the column to the GROUP BY answers meanwhile" } : {}),
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 DuckDB's 38 digits is not supported by Mason (${gapMark("numeric-exact-sum")})` };
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
- /** how long no call of a session must have been answered before a result of its that did not end is closed: a task
367
- * waiting for a call is answered within a turn of the worker's loop (microseconds when it is free), and the work a task
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). One that calls no Mason function is interrupted, and its result closed at once: the
482
- // close is what cancels the executor's tasks, and a close put off lets the session's next statement take the
483
- // connection first, after which the close waits for the cancelled query to run to its end (38 s, in
484
- // tests/duck-wire.test.ts "statement_timeout"). One that does is cancelled through its session — its next call
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
- this.log(`statement: ${total} row(s) in ${Date.now() - started} ms — ${oneLine(sql)}`);
546
+ say();
523
547
  };
524
- // A CALLING STATEMENT THAT DID NOT RUN TO ITS END (it failed, its cancel raised, it was closed early) IS CLOSED
525
- // ONLY ONCE ITS CALLS HAVE STOPPED (the suite hung for good, 0 % CPU). DuckDB hands back a failed chunk
526
- // while another of the query's tasks may still wait INSIDE a call — for this thread, which runs every Mason
527
- // function — and closing the result waits for that task (Executor::CancelTasks): this thread waited for the task,
528
- // the task for this thread, and every later request to the copy with them (`cleanUp`). One that calls no function,
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
- // a result closed before its end while no fetch is in flight: a statement calling no function is released at
540
- // once (the close cancels the executor); one that does is read on until DuckDB hands it back refused (its next
541
- // call raises) or read to its end, then released — the session's next statement waits for that (`releasing`)
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 (r === null)
551
- return;
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. The two differ: one that calls no
605
- // function is released at once (its close cancels the executor) and so is never read after — told the cancel
606
- // here, before a column of a destroyed result is asked (DuckDB answers 0 columns for one: a review finding, an
607
- // XX000 "report it" where the session had closed); one that calls is drained, which holds the result open until
608
- // its calls stop, so its answer below is built from a live result and its fetch tells the cancel
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
- release();
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: `DuckDB answered ${r.columnCount} column(s) where Postgres's analysis has ${fields.length}: report it (Mason's rewrite)` }, sql);
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: `DuckDB's profile of an EXPLAIN ANALYZE is of another statement (${oneLine(profile.sql ?? "none named")}): report it (Mason's EXPLAIN)` }, sql.text);
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
- out.push({ table: qualifiedName({ schema, name }), rows: rows === undefined || rows === null ? null : Number(rows), facts: facts.find((f) => f.source === `${schema}.${name}`) ?? null });
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 CALLING STATEMENT THAT DID NOT RUN TO ITS END, WAITED FOR UNTIL ITS CALLS HAVE STOPPED, before its result is
802
- * closed (`releaseAfterCleanup` in `streamed` says why). Every close is synchronous on this thread (DuckDB's node API
803
- * has no other, and refuses a second query on a connection with a result open), so the close must find no task of the
804
- * query inside, or waiting for, a call. The statement is marked cancelled (each call it still makes raises at once)
805
- * and interrupted (its tasks stop at their next chunk), and this thread stays free — a task waiting for a call is
806
- * answered within a turn of its loop — until no call of this session has been answered for `CALLS_QUIET_MS`. Never a
807
- * deadline: a close while calls still come is the deadlock itself, and the calls end (each raises); a wait past
808
- * `CALLS_SLOW_MS` is said once.
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
- cancelStatement(this.cancels, this.slot, seq);
832
+ async cleanUp(seq, sql, calls, r) {
833
+ if (calls)
834
+ cancelStatement(this.cancels, this.slot, seq);
812
835
  this.conn.interrupt();
813
- const t0 = performance.now();
814
- let said = false;
815
- // ONE WHOLE WINDOW BEFORE THE FIRST LOOK: a call a task has queued for this thread but that has not run yet leaves
816
- // `msSinceCall` at the previous call's time — long ago, when this thread was busy with another connection's close
817
- // (an interrupt honoured, ~10 s for one join) — so a check made synchronously here could pass with that call still
818
- // waiting, and the close deadlock on it the same way. A sleep of the window lets every queued call run first.
819
- for (;;) {
820
- await sleep(CALLS_QUIET_MS);
821
- if (msSinceCall(this.slot) >= CALLS_QUIET_MS)
822
- break;
823
- if (!said && performance.now() - t0 > CALLS_SLOW_MS) {
824
- said = true;
825
- this.log(`a statement that did not run to its end still calls Mason's functions after ${CALLS_SLOW_MS / 1000} s; its result is closed once they stop — ${oneLine(sql)}`);
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 DuckDB computed as a double` };
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 DuckDB's value(s)`;
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 */