@mason-data/cli 0.7.0 → 0.9.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 (155) hide show
  1. package/README.md +101 -73
  2. package/dist/src/api/error.js +3 -0
  3. package/dist/src/api/postgres/catalog-worker.js +49 -2
  4. package/dist/src/api/postgres/catalog.js +368 -25
  5. package/dist/src/api/postgres/encode.js +3 -1
  6. package/dist/src/api/postgres/explain.js +3 -2
  7. package/dist/src/api/postgres/listener.js +11 -3
  8. package/dist/src/api/postgres/protocol.js +39 -23
  9. package/dist/src/api/postgres/proxy.js +283 -0
  10. package/dist/src/api/postgres/schema.js +6 -0
  11. package/dist/src/api/postgres/server.js +1617 -245
  12. package/dist/src/api/postgres/translate.js +127 -10
  13. package/dist/src/api/request-log.js +18 -5
  14. package/dist/src/cdc/poll.js +5 -5
  15. package/dist/src/cli/background.js +47 -25
  16. package/dist/src/cli/bridge-stdin.js +101 -0
  17. package/dist/src/cli/cancel.js +75 -0
  18. package/dist/src/cli/command.js +170 -11
  19. package/dist/src/cli/commands.js +804 -209
  20. package/dist/src/cli/control.js +59 -0
  21. package/dist/src/cli/credentials.js +182 -0
  22. package/dist/src/cli/demo.js +20 -11
  23. package/dist/src/cli/deploy.js +574 -0
  24. package/dist/src/cli/dev.js +491 -130
  25. package/dist/src/cli/doctor.js +131 -38
  26. package/dist/src/cli/generated-files.js +143 -0
  27. package/dist/src/cli/guides.js +175 -36
  28. package/dist/src/cli/hosted.js +850 -0
  29. package/dist/src/cli/init.js +162 -65
  30. package/dist/src/cli/login.js +289 -0
  31. package/dist/src/cli/machine.js +13 -12
  32. package/dist/src/cli/main.js +11 -5
  33. package/dist/src/cli/mason.cjs +17 -0
  34. package/dist/src/cli/mcp.js +31 -22
  35. package/dist/src/cli/metrics.js +79 -0
  36. package/dist/src/cli/output-gate.js +176 -0
  37. package/dist/src/cli/page-share.js +96 -0
  38. package/dist/src/cli/pg-tls.js +14 -4
  39. package/dist/src/cli/pg-url.js +4 -0
  40. package/dist/src/cli/pin.js +19 -14
  41. package/dist/src/cli/pool-size.cjs +85 -0
  42. package/dist/src/cli/ports.js +2 -0
  43. package/dist/src/cli/preflight.js +138 -46
  44. package/dist/src/cli/project.js +142 -4
  45. package/dist/src/cli/readiness.js +76 -6
  46. package/dist/src/cli/registry.js +65 -38
  47. package/dist/src/cli/replica-hosted.js +665 -0
  48. package/dist/src/cli/replica.js +76 -0
  49. package/dist/src/cli/replication-checks.js +7 -5
  50. package/dist/src/cli/replication-mode.js +105 -26
  51. package/dist/src/cli/request-filter.js +134 -0
  52. package/dist/src/cli/roles.js +585 -0
  53. package/dist/src/cli/run-server.js +637 -48
  54. package/dist/src/cli/runs.js +1 -1
  55. package/dist/src/cli/security.js +67 -18
  56. package/dist/src/cli/serve.js +204 -46
  57. package/dist/src/cli/share-run.js +329 -0
  58. package/dist/src/cli/share.js +286 -0
  59. package/dist/src/cli/source-edit.js +75 -17
  60. package/dist/src/cli/source-role.js +344 -0
  61. package/dist/src/cli/sources.js +306 -47
  62. package/dist/src/cli/style.js +2 -1
  63. package/dist/src/cli/supervise.js +27 -24
  64. package/dist/src/cli/sync.js +55 -25
  65. package/dist/src/cli/token-hosted.js +337 -0
  66. package/dist/src/cli/tunnel.js +200 -0
  67. package/dist/src/cli/ui-ssh.js +460 -0
  68. package/dist/src/cli/ui.js +124 -17
  69. package/dist/src/cli/usage.js +128 -0
  70. package/dist/src/cli/user-hosted.js +299 -0
  71. package/dist/src/cli/user.js +573 -65
  72. package/dist/src/cli/version.js +9 -7
  73. package/dist/src/demo/seed.js +29 -0
  74. package/dist/src/duck/analyze.js +36 -20
  75. package/dist/src/duck/cancel.js +10 -1
  76. package/dist/src/duck/collation.js +266 -0
  77. package/dist/src/duck/constant-key.js +94 -0
  78. package/dist/src/duck/distinct-key.js +283 -26
  79. package/dist/src/duck/events.js +165 -0
  80. package/dist/src/duck/executor-worker.js +20 -11
  81. package/dist/src/duck/executor.js +47 -33
  82. package/dist/src/duck/explain.js +60 -46
  83. package/dist/src/duck/extensions.js +160 -0
  84. package/dist/src/duck/functions.js +461 -63
  85. package/dist/src/duck/gaps.js +48 -21
  86. package/dist/src/duck/generated-plan.js +1070 -0
  87. package/dist/src/duck/generated.js +906 -0
  88. package/dist/src/duck/grants.js +176 -0
  89. package/dist/src/duck/held-types.js +64 -0
  90. package/dist/src/duck/history.js +127 -0
  91. package/dist/src/duck/jsonb-order.js +9 -12
  92. package/dist/src/duck/kept-route.js +6 -5
  93. package/dist/src/duck/kept.js +15 -13
  94. package/dist/src/duck/late.js +1469 -40
  95. package/dist/src/duck/libc-bound.js +341 -0
  96. package/dist/src/duck/libc-order.js +892 -126
  97. package/dist/src/duck/pg-eval-worker.js +7 -1
  98. package/dist/src/duck/pg-eval.js +6 -4
  99. package/dist/src/duck/pgtree.js +774 -23
  100. package/dist/src/duck/plan.js +369 -47
  101. package/dist/src/duck/publication.js +20 -6
  102. package/dist/src/duck/requests.js +269 -93
  103. package/dist/src/duck/resolved.js +117 -2
  104. package/dist/src/duck/rewrite.js +4584 -580
  105. package/dist/src/duck/row-filter.js +655 -0
  106. package/dist/src/duck/statement.js +19 -0
  107. package/dist/src/duck/store.js +439 -177
  108. package/dist/src/duck/stream.js +19 -10
  109. package/dist/src/duck/sync.js +1411 -368
  110. package/dist/src/duck/token.js +4 -1
  111. package/dist/src/duck/types.js +69 -12
  112. package/dist/src/duck/usage.js +326 -0
  113. package/dist/src/duck/users.js +69 -15
  114. package/dist/src/duck/verified.js +161 -1
  115. package/dist/src/duck/views.js +5 -5
  116. package/dist/src/lib/database-name.js +62 -0
  117. package/dist/src/lib/database-url.js +55 -10
  118. package/dist/src/lib/dotenv.js +17 -4
  119. package/dist/src/lib/instant.js +50 -0
  120. package/dist/src/lib/log-buffer.js +77 -0
  121. package/dist/src/lib/log-read.js +196 -0
  122. package/dist/src/lib/pg-numeric.js +390 -49
  123. package/dist/src/lib/project-version.js +23 -23
  124. package/dist/src/lib/secret-mask.js +142 -0
  125. package/dist/src/lib/table-sync.js +282 -0
  126. package/dist/src/web/app.css +706 -66
  127. package/dist/src/web/app.js +331 -409
  128. package/dist/src/web/charts.js +229 -0
  129. package/dist/src/web/index.html +82 -18
  130. package/dist/src/web/pages/queries.js +192 -0
  131. package/dist/src/web/pages/status.js +611 -0
  132. package/dist/src/web/pages/syncs.js +155 -0
  133. package/dist/src/web/pages/tables.js +439 -0
  134. package/dist/src/web/pages/usage.js +397 -0
  135. package/dist/src/web/pages/users.js +219 -0
  136. package/dist/src/web/shared.js +584 -0
  137. package/dist/src/web/vendor/README.md +25 -0
  138. package/dist/src/web/vendor/geist-1.7.2/Geist-Variable.woff2 +0 -0
  139. package/dist/src/web/vendor/geist-1.7.2/GeistMono-Variable.woff2 +0 -0
  140. package/dist/src/web/vendor/geist-1.7.2/LICENSE.txt +92 -0
  141. package/dist/src/web/vendor/lucide-1.56.0/LICENSE.txt +43 -0
  142. package/dist/src/web/vendor/lucide-1.56.0/chart-column.mjs +29 -0
  143. package/dist/src/web/vendor/lucide-1.56.0/layout-dashboard.mjs +29 -0
  144. package/dist/src/web/vendor/lucide-1.56.0/logs.mjs +34 -0
  145. package/dist/src/web/vendor/lucide-1.56.0/menu.mjs +28 -0
  146. package/dist/src/web/vendor/lucide-1.56.0/square-terminal.mjs +28 -0
  147. package/dist/src/web/vendor/lucide-1.56.0/table.mjs +29 -0
  148. package/dist/src/web/vendor/lucide-1.56.0/users.mjs +29 -0
  149. package/dist/src/web/vendor/lucide-1.56.0/x.mjs +27 -0
  150. package/dist/src/web/vendor/uplot-1.6.32/LICENSE.txt +21 -0
  151. package/dist/src/web/vendor/uplot-1.6.32/uPlot.esm.js +6140 -0
  152. package/dist/src/web/vendor/uplot-1.6.32/uPlot.min.css +1 -0
  153. package/dist/src/web/vendor/zilla-slab-5.3.0/LICENSE.txt +93 -0
  154. package/dist/src/web/vendor/zilla-slab-5.3.0/zilla-slab-latin-700-normal.woff2 +0 -0
  155. package/package.json +12 -4
@@ -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
- import { StatementError } from "../api/postgres/server.js";
24
- import { duckParameters, missingRelationHint, planDuck, quoteDuckKeywords } from "./plan.js";
22
+ import { StatementError } from "./statement.js";
23
+ import { duckParameters, missingRelationHint, ordersText, planDuck, quoteDuckKeywords } from "./plan.js";
25
24
  import { VERIFIED } from "./verified.js";
26
- import { msSinceCall, registerFunctions, SESSION_VARIABLE } from "./functions.js";
27
- import { duckCollation } from "./rewrite.js";
28
- import { decodeLibcOrder, encodeLibcOrder } from "./libc-order.js";
25
+ import { registerFunctions, SESSION_VARIABLE, SETTINGS_VARIABLE } from "./functions.js";
26
+ import { caseMappingOf, CollationResolver, duckCollation, isLibcCollation } from "./collation.js";
27
+ import { decodeLibcOrder, encodeLibcOrder, isCurrentLibcOrder } 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";
32
- import { cellsOf, evaluateText, NumericError } from "../lib/pg-numeric.js";
30
+ import { gapIn, gapMark } from "./gaps.js";
31
+ import { cellsOf, evaluateText, fieldOverflow, 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,25 +57,102 @@ 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
  /**
63
66
  * DUCKDB'S MEMORY, BOUNDED FOR THIS PROCESS: DuckDB's default is 80% of the machine's RAM, in a process
64
67
  * that also holds V8's heap and two PGlites, which ends with the system killing every session and the sync together.
65
- * Half the RAM unless `MASON_DUCKDB_MEMORY` says (`8GB`, `512MiB`); past it a sort, a join or an aggregate spills to
66
- * `<file>.tmp` (DuckDB's own default, named here so it is the copy's folder), and what cannot spill fails ITS statement
67
- * with 53200 and the way out (pgErrorOf), the run going on.
68
+ * Unless `MASON_DUCKDB_MEMORY` says (`8GB`, `512MiB`), half the memory, or what leaves `OUTSIDE_RESERVE_BYTES` beside
69
+ * it when that is less (`defaultMemoryLimit`); past it a sort, a join or an aggregate spills to `<file>.tmp` (DuckDB's
70
+ * own default, named here so it is the copy's folder), and what cannot spill fails ITS statement with 53200 and the way
71
+ * out (pgErrorOf), the run going on.
72
+ *
73
+ * THE ROOM OUTSIDE THE LIMIT (issue 301, docs/DESIGN.md "Memory: the room outside the engine's limit"): the limit counts
74
+ * DuckDB's buffers, never Node's heap, the catalog's PGlite, the pg-eval PGlite nor what DuckDB allocates outside its
75
+ * buffer manager, and with half of a 1, 1.5, 2 or 3 GiB container that room was too small: the kernel killed the run in
76
+ * each. Measured with the limit at 256MiB, the process reached 2.3 GiB beside it (the pg-eval PGlite alone ~0.5 GiB of
77
+ * WebAssembly memory once a statement handed it 1.5 M values, the catalog's ~0.26 GiB). `OUTSIDE_RESERVE_BYTES` is that
78
+ * peak with a margin; `MEMORY_FLOOR_BYTES` the smallest container the whole path (a first sync, a generated table's
79
+ * build, writes on the stream, statements through the listener) ran in under this default. Under the floor, with
80
+ * `MASON_DUCKDB_MEMORY` unset, a start goes on and WARNS (`memoryWarning`: the run's start, mason status, mason doctor),
81
+ * its statements given MIN_SHARE_BYTES, the least room for them and the most for the rest (Tom's decision on PR 382: a
82
+ * warning, never a refusal); set, the variable is the operator's word at any size.
83
+ */
84
+ export const OUTSIDE_RESERVE_BYTES = 2560 * 2 ** 20;
85
+ export const MEMORY_FLOOR_BYTES = 3072 * 2 ** 20;
86
+ /** the statements' share where the reserve leaves less (under the floor, where a start warns): what let the run last
87
+ * longest, measured (docs/DESIGN.md): at 2 GiB the whole path ran with it, as with 256MiB, and at 1.5 GiB it answered
88
+ * every statement before the kill where 256MiB was killed in the writes; the same statements answered at both, one
89
+ * wide aggregate failing alone (53200), and the old quarter, 512MiB, was killed at 2 GiB */
90
+ export const MIN_SHARE_BYTES = 128 * 2 ** 20;
91
+ /**
92
+ * The default memory limit for `totalBytes` of memory, in whole MiB (mason doctor says it for any machine): half of it,
93
+ * the page cache keeping the other half, or what leaves OUTSIDE_RESERVE_BYTES beside it when that is less (from 5 GiB
94
+ * up it is half), never under MIN_SHARE_BYTES (from 2.625 GiB down: under the floor, where a start warns), nor past half.
95
+ */
96
+ export function defaultMemoryLimit(totalBytes) {
97
+ const mib = 2 ** 20;
98
+ const half = Math.floor(totalBytes / 2 / mib), room = Math.floor((totalBytes - OUTSIDE_RESERVE_BYTES) / mib);
99
+ return `${Math.min(half, Math.max(MIN_SHARE_BYTES / mib, room))}MiB`;
100
+ }
101
+ /** A size as a reader recognises it: GiB to one decimal from 1 GiB up, else MiB. */
102
+ export function memorySize(bytes) {
103
+ return bytes >= 2 ** 30 ? `${(bytes / 2 ** 30).toFixed(1)} GiB` : `${Math.round(bytes / 2 ** 20)} MiB`;
104
+ }
105
+ /** `MASON_DUCKDB_MEMORY` as it is set: null unset (or set empty, as the run reads it) */
106
+ function memorySet(env) {
107
+ const v = env.MASON_DUCKDB_MEMORY;
108
+ return v === undefined || v === "" ? null : v;
109
+ }
110
+ /** `MASON_DUCKDB_MEMORY` that is not an amount: the one text, for the store's open, mason serve, mason start and doctor */
111
+ export function memorySettingWrong(text) {
112
+ return { message: `MASON_DUCKDB_MEMORY=${JSON.stringify(text)} is not an amount of memory`,
113
+ why: "it is the memory the synced data's statements may use before they spill to disk",
114
+ fix: `write it as 8GB or 512MiB, or unset it for the default (${DEFAULT_MEMORY_LIMIT} here: ${DEFAULT_MEMORY_RULE})` };
115
+ }
116
+ /** THE DEFAULT'S RULE IN WORDS, the one text every message, help and guide that states the default says (a fact derived
117
+ * in several places: PR 380's first-sync refusal said "half the machine's RAM" after the default had changed);
118
+ * `defaultMemoryLimit` is the rule, and `tests/conventions.test.ts` holds src/ to saying it only through this */
119
+ export const DEFAULT_MEMORY_RULE = `half the container's memory limit, else of the machine's RAM, or what leaves ${memorySize(OUTSIDE_RESERVE_BYTES)} ` +
120
+ `beside it when that is less, never under ${memorySize(MIN_SHARE_BYTES)}`;
121
+ /**
122
+ * WHAT A START REFUSES OF THE MEMORY (mason serve's one report; mason start and mason doctor take the first):
123
+ * `MASON_DUCKDB_MEMORY` that is not an amount. A size under the floor is never refused: it is `memoryWarning`'s.
124
+ */
125
+ export function memoryProblems(env) {
126
+ const set = memorySet(env);
127
+ return set !== null && !MEMORY_LIMIT_RE.test(set) ? [memorySettingWrong(set)] : [];
128
+ }
129
+ /**
130
+ * THE ONE WARNING OF THE MEMORY: `memory` under MEMORY_FLOOR_BYTES while `MASON_DUCKDB_MEMORY` is unset (or not an
131
+ * amount, which is refused beside it). The start goes on; the run says it at its start and in what mason status reads
132
+ * (its warnings), and mason doctor's memory line says it, each from this text alone. `memory` is `machineMemory()`.
68
133
  */
69
- /** The default memory limit for a machine with `totalBytes` of RAM: half of it, in MiB (mason doctor says it for any machine). */
70
- export function defaultMemoryLimit(totalBytes) { return `${Math.floor(totalBytes / 2 / 2 ** 20)}MiB`; }
134
+ export function memoryWarning(memory, env) {
135
+ const set = memorySet(env);
136
+ if ((set !== null && MEMORY_LIMIT_RE.test(set)) || memory.bytes >= MEMORY_FLOOR_BYTES)
137
+ return null;
138
+ const size = memorySize(memory.bytes);
139
+ return { message: `${size} of memory is under the ${memorySize(MEMORY_FLOOR_BYTES)} Mason needs`,
140
+ why: `${size} is ${memory.from === "container" ? "the container's memory limit" : "the machine's RAM"}; ` +
141
+ `beside what the synced data's statements may use, Mason itself needs up to ${memorySize(OUTSIDE_RESERVE_BYTES)} (measured, with a margin), ` +
142
+ "and with less the system may stop the whole run, every session with it, when a first sync, a burst of writes or a statement needs the memory",
143
+ fix: `give it ${memorySize(MEMORY_FLOOR_BYTES)} or more (the container's memory limit), or set MASON_DUCKDB_MEMORY=<n> ` +
144
+ "to choose the statements' share yourself, the rest left to Mason (mason doctor says what it leaves)" };
145
+ }
146
+ /** a problem or the warning as one line: mason serve's settings report, the run's warning, mason doctor's detail */
147
+ export function memoryProblemText(p) { return `${p.message}: ${p.why} — ${p.fix}`; }
71
148
  /**
72
149
  * THE MEMORY THIS PROCESS MAY USE: the machine's RAM, or the container's limit where one is set — a cgroup v2
73
150
  * `memory.max` or a cgroup v1 `memory.limit_in_bytes` below it. `os.totalmem()` is the HOST's RAM inside a container,
74
151
  * so half of it was more than a limited container had, and the kernel killed the run instead of DuckDB spilling
75
152
  * (the review of the cut). `read` is the test's.
76
153
  */
77
- export function machineMemoryBytes(read = (f) => fs.readFileSync(f, "utf8"), total = os.totalmem()) {
78
- let limit = total;
154
+ export function machineMemory(read = (f) => fs.readFileSync(f, "utf8"), total = os.totalmem()) {
155
+ let limit = { bytes: total, from: "machine" };
79
156
  for (const file of ["/sys/fs/cgroup/memory.max", "/sys/fs/cgroup/memory/memory.limit_in_bytes"]) {
80
157
  let text;
81
158
  try {
@@ -86,12 +163,35 @@ export function machineMemoryBytes(read = (f) => fs.readFileSync(f, "utf8"), tot
86
163
  } // not a cgroup of that version, or not a container: the machine's RAM
87
164
  // v2 says `max` for no limit; v1 says a number near 2^63; either way a number below the RAM is the limit
88
165
  const n = Number(text);
89
- if (text !== "max" && Number.isFinite(n) && n > 0 && n < limit)
90
- limit = n;
166
+ if (text !== "max" && Number.isFinite(n) && n > 0 && n < limit.bytes)
167
+ limit = { bytes: n, from: "container" };
91
168
  }
92
169
  return limit;
93
170
  }
171
+ /** `machineMemory`'s bytes: the one size the default limit and the floor are read against */
172
+ export function machineMemoryBytes(read, total) { return machineMemory(read, total).bytes; }
94
173
  export const DEFAULT_MEMORY_LIMIT = defaultMemoryLimit(machineMemoryBytes());
174
+ /**
175
+ * WHAT STATEMENTS MAY SPILL (issue 105: a 100-row preview's sort took every free byte of a volume, and the sync's room
176
+ * with it). DuckDB's default ceiling is "90% of available disk space", and what it answered when the disk ran out first
177
+ * was `XX000` with a path. Here the ceiling is HALF of the room statements have, what is free where the copy is and what
178
+ * they have spilled there (the disk's free bytes exclude the spill, so a ceiling of the free bytes alone would grow with
179
+ * each read as the spill grows, and end near the whole disk). The sync's writes keep the other half whatever statements
180
+ * do, and it follows the disk (`DuckStore.keepSpillCeiling`, every `SPILL_REFRESH_MS`): a copy that grows narrows it.
181
+ * `MASON_SPILL_LIMIT` sets the amount instead, never past 90% of that room. Spilled files are shared by the statements
182
+ * running, so two at once share the ceiling. Past it the statement that needed the room fails alone, 53400 as past
183
+ * Postgres's temp_file_limit, with the way out (pgErrorOf), and its files are gone with it; a disk that is really full
184
+ * is 53100. A sizing decision reads `store.spillLimitBytes`, as it reads `store.memoryLimitBytes`.
185
+ */
186
+ const SPILL_FREE_SHARE = 0.5, SPILL_FREE_MOST = 0.9;
187
+ const SPILL_REFRESH_MS = 15_000;
188
+ export function spillCeiling(freeBytes, spilledBytes, setBytes) {
189
+ const room = freeBytes + spilledBytes;
190
+ return Math.max(2 ** 20, Math.floor(setBytes === null ? room * SPILL_FREE_SHARE : Math.min(setBytes, room * SPILL_FREE_MOST)));
191
+ }
192
+ /** the bytes free where `dir` is: the one reading of it (the sync's disk check, the spill's ceiling) */
193
+ export function freeBytesAt(dir) { const st = fs.statfsSync(dir); return st.bavail * st.bsize; }
194
+ const spillSetting = (bytes) => `${Math.floor(bytes / 2 ** 20)}MiB`;
95
195
  export const MEMORY_LIMIT_RE = /^\s*(\d+(?:\.\d+)?)\s*(KB|MB|GB|TB|KiB|MiB|GiB|TiB)\s*$/i;
96
196
  /** DuckDB's memory limit in bytes, as DuckDB reads the text: KB / MB / GB / TB are powers of 1000, KiB / MiB / GiB / TiB of 1024 */
97
197
  export function memoryLimitBytes(text) {
@@ -117,8 +217,8 @@ export async function openDuck(file, settings) {
117
217
  catch (e) {
118
218
  const m = e.message;
119
219
  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`);
220
+ ? `Mason could not open the synced data ${file}: another process holds it (${m}). Is another mason start running for this project? mason status says`
221
+ : `Mason could not open the synced data ${file}: ${m}. mason destroy deletes the synced data, and the next mason start syncs again`);
122
222
  }
123
223
  const writer = await instance.connect();
124
224
  await writer.run(`CREATE SCHEMA IF NOT EXISTS ${MASON_SCHEMA}`);
@@ -136,30 +236,54 @@ export class DuckStore {
136
236
  file;
137
237
  /** the memory limit every connection shares, in bytes */
138
238
  memoryLimitBytes;
239
+ /** what statements may spill now, in bytes (`spillCeiling`): it follows the disk */
240
+ spillLimitBytes;
241
+ spillSet;
242
+ diskFree;
243
+ spillTimer;
244
+ spillLane = null;
245
+ spillKept = null;
246
+ log;
139
247
  executor;
140
248
  closed = null;
141
249
  closeGraceMs;
142
- constructor(file, executor, memoryLimit, closeGraceMs) {
250
+ /** where the copy's extensions come from: Mason's package for this platform, or DuckDB's install (src/duck/extensions.ts) */
251
+ extensions;
252
+ constructor(file, executor, memoryLimit, extensions, spill, log, closeGraceMs) {
143
253
  this.file = file;
144
254
  this.executor = executor;
145
255
  this.memoryLimitBytes = memoryLimit;
256
+ this.extensions = extensions;
146
257
  this.closeGraceMs = closeGraceMs;
258
+ this.spillLimitBytes = spill.limit;
259
+ this.spillSet = spill.set;
260
+ this.diskFree = spill.diskFree;
261
+ this.log = log;
262
+ this.spillTimer = setInterval(() => { void this.keepSpillCeiling(); }, SPILL_REFRESH_MS);
263
+ this.spillTimer.unref();
147
264
  this.writer = executor.writer();
148
265
  this.instance = { connect: () => executor.connect() };
149
266
  }
150
267
  /** `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 */
268
+ * in a test; `workerFile`, the executor worker's module, another only in a test; `extensions`, this machine's
269
+ * (`findExtensions`) unless a test names a package */
152
270
  static async open(file, log, options = {}) {
271
+ const extensions = options.extensions ?? findExtensions();
153
272
  const memoryLimit = options.memoryLimit ?? DEFAULT_MEMORY_LIMIT;
154
273
  if (!MEMORY_LIMIT_RE.test(memoryLimit))
155
- 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})`);
274
+ throw new Error(memoryProblemText(memorySettingWrong(memoryLimit)));
275
+ if (options.spillLimit !== undefined && !MEMORY_LIMIT_RE.test(options.spillLimit))
276
+ throw new Error(`MASON_SPILL_LIMIT=${JSON.stringify(options.spillLimit)} is not an amount of disk: write it as 40GB or 512MiB (unset, half of what is free on the disk)`);
277
+ const spillSet = options.spillLimit === undefined ? null : memoryLimitBytes(options.spillLimit);
278
+ const diskFree = options.diskFree ?? freeBytesAt;
156
279
  const where = path.resolve(file);
280
+ const spillLimit = spillCeiling(diskFree(path.dirname(where)), 0, spillSet);
157
281
  const before = opened.get(where);
158
282
  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)`);
283
+ throw new Error(`Mason could not open the synced data ${file}: this process has it open already (Mason bug: report it)`);
160
284
  // a copy whose thread was given up on may still hold the file: a second instance of it would lose writes
161
285
  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)`);
286
+ 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
287
  const mine = { closed: null, abandoned: null };
164
288
  opened.set(where, mine);
165
289
  let executor;
@@ -167,7 +291,7 @@ export class DuckStore {
167
291
  if (before !== undefined)
168
292
  await before.closed;
169
293
  // 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,
294
+ executor = await Executor.start({ file, settings: { ...INSTANCE_SETTINGS, ...extensionSettings(extensions), memory_limit: memoryLimit, temp_directory: `${file}.tmp`, max_temp_directory_size: spillSetting(spillLimit) }, log,
171
295
  ...(options.silentMs !== undefined ? { silentMs: options.silentMs } : {}), ...(options.workerFile !== undefined ? { workerFile: options.workerFile } : {}) });
172
296
  }
173
297
  catch (e) {
@@ -175,7 +299,7 @@ export class DuckStore {
175
299
  opened.delete(where);
176
300
  throw e;
177
301
  }
178
- const store = new DuckStore(file, executor, memoryLimitBytes(memoryLimit), options.closeGraceMs);
302
+ const store = new DuckStore(file, executor, memoryLimitBytes(memoryLimit), extensions, { limit: spillLimit, set: spillSet, diskFree }, log, options.closeGraceMs);
179
303
  // closed: the file may be opened again — unless its thread was given up on (`abandoned`), which keeps it here
180
304
  store.closing = (p) => { mine.closed = p; void p.then(() => { if (executor.abandoned !== null)
181
305
  mine.abandoned = executor.abandoned;
@@ -184,23 +308,63 @@ export class DuckStore {
184
308
  return store;
185
309
  }
186
310
  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 */
311
+ /**
312
+ * THE SPILL'S CEILING MOVED WITH THE DISK (`spillCeiling`): the bytes free and spilled now, set on the instance on a
313
+ * connection of its own, never the writer's. One at a time; a move under 1 % is none. DuckDB refuses a ceiling under
314
+ * what is spilled at that moment (more may have spilled since the read): it stays where it was until the next turn.
315
+ * Any other failure is said; the ceiling stands, which is the last one set.
316
+ */
317
+ keepSpillCeiling() {
318
+ if (this.closed !== null)
319
+ return Promise.resolve();
320
+ this.spillKept ??= this.spillTurn().catch((e) => {
321
+ if (this.closed === null)
322
+ this.log(`the spill's ceiling was not moved: ${e instanceof Error ? e.message : String(e)}`);
323
+ }).finally(() => { this.spillKept = null; });
324
+ return this.spillKept;
325
+ }
326
+ async spillTurn() {
327
+ let c;
328
+ // a connection that failed is asked for again next turn, never kept as the answer
329
+ try {
330
+ c = await (this.spillLane ??= this.executor.connect());
331
+ }
332
+ catch (e) {
333
+ this.spillLane = null;
334
+ throw e;
335
+ }
336
+ const spilled = Number((await c.runAndReadAll("SELECT coalesce(sum(size), 0) FROM duckdb_temporary_files()")).getRowsJS()[0][0]);
337
+ const next = spillCeiling(this.diskFree(path.dirname(path.resolve(this.file))), spilled, this.spillSet);
338
+ if (Math.abs(next - this.spillLimitBytes) < this.spillLimitBytes * 0.01)
339
+ return;
340
+ try {
341
+ await c.run(`SET GLOBAL max_temp_directory_size = '${spillSetting(next)}'`);
342
+ }
343
+ catch (e) {
344
+ if (e instanceof Error && e.message.includes("failed to adjust"))
345
+ return;
346
+ throw e;
347
+ }
348
+ this.spillLimitBytes = next;
349
+ }
350
+ /** called once if the engine's thread dies while the run uses it (src/duck/executor.ts): the run exits with the reason */
188
351
  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) */
352
+ /** how many vectors each Mason function was called with so far, on the engine's thread (a test's, a measurement's) */
190
353
  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) */
354
+ /** how many rows each Mason function was handed so far, on the engine's thread (a plan's cost in calls) */
192
355
  functionRows() { return this.executor.functionRows(); }
193
356
  /** a connection for one Postgres session (on the executor worker: SessionRunner) */
194
357
  runner() { return this.executor.runner(); }
195
358
  /**
196
359
  * The source's order of text under a libc collation (src/duck/libc-order.ts), kept in the copy: the one recorded
197
- * for this collation and version, else `read` from the source (a few seconds) and recorded. `read: true` when it was.
360
+ * for this collation and version, in this release's form, else `read` from the source (a few seconds) and recorded.
361
+ * `read: true` when it was.
198
362
  */
199
363
  async libcOrder(collate, version, read) {
200
364
  await this.writer.run(`CREATE SCHEMA IF NOT EXISTS ${MASON_SCHEMA}`);
201
365
  await this.writer.run(`CREATE TABLE IF NOT EXISTS ${MASON_SCHEMA}.libc_order (collation_name VARCHAR, collation_version VARCHAR, data VARCHAR)`);
202
366
  const kept = (await this.writer.runAndReadAll(`SELECT data FROM ${MASON_SCHEMA}.libc_order WHERE collation_name = $1 AND collation_version IS NOT DISTINCT FROM $2`, [collate, version])).getRowsJS();
203
- if (kept.length > 0)
367
+ if (kept.length > 0 && isCurrentLibcOrder(String(kept[0][0])))
204
368
  return { order: decodeLibcOrder(String(kept[0][0])), read: false };
205
369
  const order = await read();
206
370
  // one order per collation: a version that moved replaces the last one's
@@ -212,22 +376,50 @@ export class DuckStore {
212
376
  * its text sorted as the SOURCE sorts it (`collation`, pg_database's; absent, by bytes; a libc one by `libc`, its
213
377
  * order as `libcOrder` read it), refusing what is not `verified` (null: nothing is refused for it, the oracle's
214
378
  * recording run alone). */
215
- async listener(collation, verified = VERIFIED, libc, copies = () => [], kept) {
379
+ async listener(collation, verified = VERIFIED, libc, copies = () => [], kept, more = {}) {
216
380
  const known = new Set((await this.writer.runAndReadAll("SELECT collname FROM pragma_collations()")).getRowsJson().map((r) => String(r[0])));
217
381
  quoteDuckKeywords((await this.writer.runAndReadAll("SELECT keyword_name FROM duckdb_keywords() WHERE keyword_category <> 'unreserved'")).getRowsJson().map((r) => String(r[0])));
218
- const c = collation === undefined ? { kind: "bytes" } : duckCollation(collation.provider, collation.collate, collation.locale, known);
382
+ const named = collation === undefined ? { kind: "bytes" } : duckCollation(collation.provider, collation.collate, collation.locale, known);
383
+ // (a libc locale whose strcoll is byte order, musl's or macOS's: src/duck/libc-order.ts)
384
+ const c = named.kind === "libc" && libc?.bytes === true ? { kind: "bytes" } : named;
219
385
  if (c.kind === "libc") {
220
386
  if (libc === undefined)
221
387
  throw new Error(`the database orders text by the libc collation ${collation.collate}, and its order was not read (Mason bug: report it)`);
222
- await this.executor.registerLibcOrder(libc);
388
+ await this.executor.registerLibcOrder(c.collate, libc, true);
223
389
  }
390
+ // each other libc collation read at the start (a synced column's): its key's order, by its collcollate
391
+ for (const [collate, order] of more.libcOrders ?? [])
392
+ if (!order.bytes)
393
+ await this.executor.registerLibcOrder(collate, order, false);
394
+ // a libc collation named first by a statement: read from the source on its first use, kept in the copy, then the
395
+ // engine told its order before the plan that needs it is made (src/duck/collation.ts)
396
+ const readLibc = more.readLibc;
397
+ // the database's case mapping is its CTYPE's (datctype; ICU's or builtin's locale), never its order's
398
+ const ctype = collation === undefined ? null : collation.provider === "c" ? collation.ctype ?? collation.collate : collation.locale ?? collation.collate;
399
+ const resolver = new CollationResolver({ database: c, databaseCollate: collation !== undefined && isLibcCollation(collation.provider, collation.collate) ? collation.collate : null,
400
+ ...(collation === undefined ? {} : { databaseCase: { mapping: caseMappingOf(collation.provider, ctype, collation.locale), ctype: ctype ?? "" } }),
401
+ source: more.collations ?? null, known, libc: more.libcOrders ?? new Map(),
402
+ readLibc: readLibc === undefined ? null : async (sc) => {
403
+ const r = await this.libcOrder(sc.collate ?? "C", sc.version ?? null, () => readLibc(sc));
404
+ if (!r.order.bytes)
405
+ await this.executor.registerLibcOrder(sc.collate ?? "C", r.order, false);
406
+ return r.order;
407
+ } });
408
+ // the database's own order, held where a plan reads each order by its collcollate (src/duck/libc-bound.ts)
409
+ if (c.kind === "libc" && libc !== undefined)
410
+ resolver.hold(c.collate, libc);
224
411
  // a jsonb's stored key is this collation's (src/duck/kept.ts `orderBy`)
225
412
  await kept?.orderBy(c, c.kind === "libc" ? libc : undefined);
226
413
  return {
227
414
  collation: c,
415
+ collations: resolver,
228
416
  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),
417
+ plan: (catalog, text, stmt, base, paramOids, under, userTypes, wireBound, grants, relations, outputs) => planDuck({ catalog, text, stmt, base, paramOids, searchPath: under.searchPath, timeZone: under.timeZone, dateStyle: under.dateStyle, collations: resolver, verified, userTypes, copies, kept: kept ?? null, wireBound: wireBound ?? false, grants, ...(relations === undefined ? {} : { relations }), outputs: outputs ?? false }),
230
418
  parameters: duckParameters,
419
+ // a SELECT of no table that orders text under a collation that is not byte order: the synced data's (issue 122);
420
+ // one that maps case (upper, lower, initcap: issue 406) is the synced data's where its run answers it (the
421
+ // listener's `answersCase`), else the catalog's, as before
422
+ ordersText: (catalog, text, paramOids, under, bindable) => ordersText({ catalog, text, paramOids, under, collations: resolver, bindable }),
231
423
  // the sync's state, which EXPLAIN says how each copy is kept current from (src/duck/sync.ts KeptCurrent)
232
424
  copies,
233
425
  // a stored JSON path ready or gone (src/duck/kept.ts): the plans kept are made again
@@ -238,6 +430,7 @@ export class DuckStore {
238
430
  * (a second open of the file waits for it), or once the executor's grace has passed whatever the thread does
239
431
  * (src/duck/executor.ts CLOSE_GRACE_MS); never rejects — a failure is said in the log. */
240
432
  close() {
433
+ clearInterval(this.spillTimer);
241
434
  if (this.closed === null) {
242
435
  this.closed = this.executor.close(this.closeGraceMs);
243
436
  this.closing(this.closed);
@@ -273,22 +466,35 @@ export function pgErrorOf(err, sql) {
273
466
  const full = err instanceof Error ? err.message : String(err);
274
467
  const kind = /^([A-Za-z ]+) Error: /.exec(full)?.[1] ?? "";
275
468
  const message = full.replace(/^[A-Za-z ]+ Error: /, "").replace(/\n\n?LINE \d+:[\s\S]*$/, "").trim();
469
+ // NO ROOM TO SPILL (issue 105): the ceiling, or the disk itself before it. Postgres's class for a full disk, in
470
+ // Mason's words: the engine's text names a path and a setting no client of Mason has, so it stays out of the answer
471
+ // Read by the engine's own kind and sentence: a statement's literal may hold the same words in any other error
472
+ const ceiling = kind === "Out of Memory" && /This limit was set by the 'max_temp_directory_size' setting/.test(full);
473
+ if (ceiling || (kind === "IO" && /No space left on device/.test(full))) {
474
+ return new StatementError({ code: ceiling ? "53400" : "53100", message: ceiling ? "could not write to temporary file: this statement needs more room on disk than statements may take"
475
+ : "could not write to temporary file: No space left on device",
476
+ hint: "ask for less at once (a filter, a LIMIT, fewer or narrower columns, a coarser GROUP BY), free disk where the synced data is, or raise MASON_SPILL_LIMIT (40GB; by default half of what is free) and start mason again; nothing else stopped" }, sql, "spill-limit");
477
+ }
276
478
  const pg = postgresWording(kind, message);
479
+ // a refusal raised inside DuckDB (a Mason function's, the rewrite's error() call) crossed it as its text alone: the
480
+ // entry its text names (gapIn), read here, the one place that text becomes a statement's error
481
+ // (Mason's own refusal keeps the engine's text out of the DETAIL: it may quote the row, tests/conventions.test.ts)
482
+ // (a Mason function's error that carries Postgres's own DETAIL sends it as Postgres does, apart from the message)
277
483
  if (pg !== null)
278
- return new StatementError({ ...pg, detail: `DuckDB: ${message}` }, sql);
484
+ return new StatementError({ code: pg.code, message: pg.message, ...(pg.code === "0A000" ? {} : { detail: pg.detail ?? `the engine: ${message}` }) }, sql, pg.gap ?? (pg.code === "0A000" ? gapIn(pg.message) : null));
279
485
  const fn = /(?:Scalar|Aggregate|Table) Function with name "?([A-Za-z_][\w]*)"? does not exist/.exec(message)?.[1];
280
486
  if (fn !== undefined) {
281
487
  // what reaches DuckDB was verified against Postgres (src/duck/verified.ts), so a function DuckDB lacks is a gap in
282
488
  // 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}` });
489
+ return new StatementError({ code: "0A000", message: `function ${fn}() is not supported: Mason's engine has no ${fn}`,
490
+ 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
491
  }
286
492
  const table = /Table with name "?([^"!\s]+)"? does not exist/.exec(message)?.[1];
287
493
  if (table !== undefined) {
288
494
  // DuckDB's miss comes after the catalog held the table (the plan passed): one dropped as no longer added, whose way
289
495
  // back is `mason add`, so no copy state is read
290
496
  return new StatementError({ code: "42P01", message: `relation "${table}" does not exist`,
291
- hint: missingRelationHint(table, []), detail: `DuckDB: ${message}` });
497
+ hint: missingRelationHint(table, []), detail: `the engine: ${message}` });
292
498
  }
293
499
  const code = kind === "Parser" ? "42601"
294
500
  : kind === "Catalog" ? "42P01"
@@ -304,17 +510,20 @@ export function pgErrorOf(err, sql) {
304
510
  : kind === "Out of Range" ? "22003"
305
511
  : kind === "Interrupt" || /INTERRUPT/i.test(full) ? "57014"
306
512
  : kind === "Out of Memory" ? "53200"
307
- : kind === "Not implemented" ? "0A000"
308
- : kind === "Invalid Input" ? "22023"
309
- : "XX000";
513
+ // a commit that could not pin or allocate a block is the same limit reached, under the transaction's own kind
514
+ // (measured on DuckDB 2.0.0: "Failed to commit: failed to pin block of size 1.0 MiB (365.7 MiB/366.2 MiB used)")
515
+ : kind === "TransactionContext" && /^Failed to commit: failed to (?:pin block|allocate data) of size .* used\)/.test(message) ? "53200"
516
+ : kind === "Not implemented" ? "0A000"
517
+ : kind === "Invalid Input" ? "22023"
518
+ : "XX000";
310
519
  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,
520
+ : 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
521
  // DuckDB's own advice is SET, which a client of Mason cannot do: Mason's way out instead
313
522
  ...(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
523
  // 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)}`}`,
524
+ detail: `the engine${kind ? ` ${kind} error` : ""}: ${message}${sql === "" ? "" : `; Mason ran: ${oneLine(sql, 2000)}`}`,
316
525
  // 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" } : {}),
526
+ ...(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
527
  }, sql);
319
528
  }
320
529
  /** The errors DuckDB raises for what Postgres raises too, in Postgres's code and words (a tool or a person matches them). */
@@ -335,16 +544,23 @@ function postgresWording(kind, message) {
335
544
  const field = /^Date out of range: (.*)$/.exec(message);
336
545
  if (field)
337
546
  return { code: "22008", message: `date field value out of range: ${field[1]}` };
338
- // a cast to numeric(p, s) of a value with more than p - s integer digits
339
- if (/Casting value "[^"]*" to type DECIMAL\(\d+,\s*\d+\) failed: value is out of range/.test(message))
340
- return { code: "22003", message: "numeric field overflow" };
341
547
  // 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
548
  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")})` };
549
+ 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
550
  // a numeric(p, s) read from a text holding NaN or Infinity (src/duck/rewrite.ts pgInput): DuckDB's decimal has none
345
- const special = /Could not convert string ['"](NaN|-?Infinity)['"] to DECIMAL/.exec(message);
346
- if (special)
347
- return { code: "0A000", message: `a numeric ${special[1]} read from a text is not supported by Mason (${gapMark("numeric-special")})` };
551
+ if (/Could not convert string ['"](NaN|-?Infinity)['"] to DECIMAL/.test(message))
552
+ return { code: "0A000", message: `a numeric NaN or Infinity read from a text is not supported by Mason (${gapMark("numeric-special")})`, gap: "numeric-special" };
553
+ // A NUMERIC PAST ITS DIGITS, in every spelling the engine gives (a generated table's run stores one, src/duck/generated.ts
554
+ // failureWords): a cast to numeric(p, s), of a value or a text, with more than p - s digits before the point, said
555
+ // with Postgres's DETAIL (and a number constant that does not fit: `12345::numeric(3)`, DuckDB's "Could not cast value
556
+ // 12345 to DECIMAL(3,0)"); past the 38 digits the engine computes a numeric with, by an operation, a ROUND or a cast
557
+ const typmod = /(?:Casting value "[^"]*" to type|Could not convert string ['"].*['"] to|^Could not cast value \S+ to) DECIMAL\((\d+),\s*(\d+)\)/.exec(message);
558
+ if (typmod !== null && Number(typmod[1]) < 38) {
559
+ return { code: "22003", ...fieldOverflow(Number(typmod[1]), Number(typmod[2])) };
560
+ }
561
+ if (typmod !== null || /Overflow in (?:\w+ )?(?:addition|subtraction|multiplication|negation|ROUND|cast)\b.*DECIMAL|Overflow in HUGEINT \w+|out of range for (?:the desired )?DECIMAL|Could not cast value .* to DECIMAL/i.test(message)) {
562
+ return { code: "22003", message: "numeric field overflow", detail: "The value has more than the 38 digits Mason's engine computes a numeric with." };
563
+ }
348
564
  const conv = /Could not convert string '((?:[^']|'')*)' to (INT16|INT32|INT64|DOUBLE|FLOAT|DATE|TIMESTAMP|UUID|BOOL)/.exec(message);
349
565
  if (conv) {
350
566
  const t = { INT16: "smallint", INT32: "integer", INT64: "bigint", DOUBLE: "double precision", FLOAT: "real", DATE: "date", TIMESTAMP: "timestamp", UUID: "uuid", BOOL: "boolean" }[conv[2]];
@@ -352,9 +568,10 @@ function postgresWording(kind, message) {
352
568
  }
353
569
  // a Mason function's own error (src/duck/functions.ts): Postgres's words after the prefix
354
570
  // Postgres's own error, from its own function (src/duck/pg-eval.ts): its SQLSTATE, said
355
- const pg = /^mason\[([0-9A-Z]{5})\]: (.*)$/s.exec(message);
571
+ // (and its DETAIL on a line of its own, src/duck/functions.ts `raised`)
572
+ const pg = /^mason\[([0-9A-Z]{5})\]: (.*?)(?:\nDETAIL: (.*))?$/s.exec(message);
356
573
  if (pg)
357
- return { code: pg[1], message: pg[2] };
574
+ return { code: pg[1], message: pg[2], ...(pg[3] === undefined ? {} : { detail: pg[3] }) };
358
575
  const own = /^mason: (.*)$/s.exec(message);
359
576
  if (own)
360
577
  return { code: /^cannot |argument list|key must not be null/.test(own[1]) ? "22023" : /^invalid input syntax/.test(own[1]) ? "22P02" : / is not supported| not supported for type /.test(own[1]) ? "0A000" : "XX000", message: own[1] };
@@ -363,12 +580,55 @@ function postgresWording(kind, message) {
363
580
  return { code: "22P02", message };
364
581
  return null;
365
582
  }
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;
583
+ export class SessionSetup {
584
+ conn;
585
+ zone = null;
586
+ path = null;
587
+ settings = null;
588
+ constructor(conn) { this.conn = conn; }
589
+ /** `name`: what the Mason functions are passed as the session: a slot of the cancel table (`s<n>`, src/duck/cancel.ts)
590
+ * for a wire session; any other name is never cancelled through the table (a run is interrupted instead). Set up as a
591
+ * session with no role is: UTC, under `public`, no settings, until `settle` says otherwise. */
592
+ static async open(conn, name) {
593
+ // NULLs sort as Postgres sorts them: last ascending, FIRST descending (DuckDB's default is last both ways). An
594
+ // integer division is the rewrite's `//` where Postgres's operator is integer division (src/duck/rewrite.ts), so
595
+ // DuckDB's `/` stays the float division every other `/` is
596
+ await conn.run("SET default_null_order = 'nulls_last_on_asc_first_on_desc'");
597
+ // every Mason function's first argument (src/duck/functions.ts "every call carries its session")
598
+ await conn.run(`SET VARIABLE ${SESSION_VARIABLE} = ${quoteLiteral(name)}`);
599
+ const setup = new SessionSetup(conn);
600
+ await setup.settle(NO_ROLE_SESSION);
601
+ return setup;
602
+ }
603
+ /** the session's zone, search_path and settings, each set when it changed: before each statement */
604
+ async settle(session) {
605
+ if (session.timeZone !== this.zone) {
606
+ await this.conn.run(`SET TimeZone = ${quoteLiteral(session.timeZone)}`);
607
+ this.zone = session.timeZone;
608
+ }
609
+ // Postgres skips a schema of the path that does not exist; DuckDB refuses the SET (and with it every statement):
610
+ // the path is the schemas DuckDB has (a table added later in a new schema is found at the next statement)
611
+ const have = new Set((await this.conn.runAndReadAll("SELECT schema_name FROM duckdb_schemas() WHERE database_name = current_database()")).getRowsJS().map(([n]) => n));
612
+ const wanted = duckSearchPath(session.searchPath).split(",").filter((n) => have.has(n));
613
+ const path = (wanted.length > 0 ? wanted : ["main"]).join(",");
614
+ if (path !== this.path) {
615
+ await this.conn.run(`SET search_path = ${quoteLiteral(path)}`);
616
+ this.path = path;
617
+ }
618
+ // what `current_setting(name)` reads (src/duck/rewrite.ts `settingRead`): the session's settings, as one map
619
+ const settings = JSON.stringify(session.settings);
620
+ if (settings !== this.settings) {
621
+ const entries = Object.entries(session.settings).map(([k, v]) => `${quoteLiteral(k)}: ${quoteLiteral(v)}`).join(", ");
622
+ await this.conn.run(`SET VARIABLE ${SETTINGS_VARIABLE} = MAP {${entries}}::MAP(VARCHAR, VARCHAR)`);
623
+ this.settings = settings;
624
+ }
625
+ }
626
+ }
627
+ /** a session with no role, as a generated table is planned and run (src/duck/generated-plan.ts): UTC, under `public`, no
628
+ * settings (what reads one is refused there: `current_setting` is "it reads a session") */
629
+ export const NO_ROLE_SESSION = { timeZone: "UTC", searchPath: "public", dateStyle: "ISO, MDY", settings: {} };
630
+ /** when the wait for a statement to end is said in the log */
631
+ const ENDING_SLOW_MS = 5_000;
372
632
  /** One wire session's DuckDB connection, with the session's zone and search_path applied before each statement. Runs
373
633
  * on the executor worker (src/duck/executor-worker.ts), its statements asked by the listener's RemoteRunner. */
374
634
  export class SessionRunner {
@@ -386,40 +646,20 @@ export class SessionRunner {
386
646
  openAnswer = null;
387
647
  conn;
388
648
  log;
389
- zone = null;
390
- path = null;
391
- constructor(conn, log, slot, cancels) {
649
+ setup;
650
+ constructor(conn, log, slot, cancels, setup) {
392
651
  this.conn = conn;
393
652
  this.log = log;
394
653
  this.slot = slot;
395
654
  this.cancels = cancels;
655
+ this.setup = setup;
396
656
  }
397
657
  /** a connection for one Postgres session, in the cancel table's `slot` */
398
658
  static async open(instance, log, slot, cancels) {
399
659
  const conn = await instance.connect();
400
- // NULLs sort as Postgres sorts them: last ascending, FIRST descending (DuckDB's default is last both ways). An
401
- // integer division is the rewrite's `//` where Postgres's operator is integer division (src/duck/rewrite.ts), so
402
- // DuckDB's `/` stays the float division every other `/` is
403
- await conn.run("SET default_null_order = 'nulls_last_on_asc_first_on_desc'");
404
- // every Mason function's first argument (src/duck/functions.ts "every call carries its session")
405
- await conn.run(`SET VARIABLE ${SESSION_VARIABLE} = ${quoteLiteral(slotName(slot))}`);
406
- return new SessionRunner(conn, log, slot, cancels);
407
- }
408
- async settle(session) {
409
- if (session.timeZone !== this.zone) {
410
- await this.conn.run(`SET TimeZone = ${quoteLiteral(session.timeZone)}`);
411
- this.zone = session.timeZone;
412
- }
413
- // Postgres skips a schema of the path that does not exist; DuckDB refuses the SET (and with it every statement):
414
- // the path is the schemas DuckDB has (a table added later in a new schema is found at the next statement)
415
- const have = new Set((await this.conn.runAndReadAll("SELECT schema_name FROM duckdb_schemas() WHERE database_name = current_database()")).getRowsJS().map(([n]) => n));
416
- const wanted = duckSearchPath(session.searchPath).split(",").filter((n) => have.has(n));
417
- const path = (wanted.length > 0 ? wanted : ["main"]).join(",");
418
- if (path !== this.path) {
419
- await this.conn.run(`SET search_path = ${quoteLiteral(path)}`);
420
- this.path = path;
421
- }
660
+ return new SessionRunner(conn, log, slot, cancels, await SessionSetup.open(conn, slotName(slot)));
422
661
  }
662
+ async settle(session) { await this.setup.settle(session); }
423
663
  async prepare(sql) {
424
664
  try {
425
665
  return await this.conn.prepare(sql);
@@ -478,13 +718,10 @@ export class SessionRunner {
478
718
  const p = await this.prepare(sql);
479
719
  timing.prepareMs = performance.now() - t0;
480
720
  // 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
721
+ // server's hung process). It is interrupted — its tasks stop at their next chunk — and, if it calls a Mason
722
+ // function, cancelled through its session as well: its next call raises, so a task of it does not call on. Its
723
+ // result is closed only once DuckDB ended the query, on a thread that is not this one (`cleanUp`: a task still
724
+ // inside a call waits for this thread, which a close would hold). Whether it calls
488
725
  // one is the PLAN's (src/duck/plan.ts PlannedSql `functions`, read off the rewritten tree), never a match on its
489
726
  // text: a stored JSON path's column and the view of a copy's visible columns are named `__mason_…` too, and
490
727
  // read as calls, every `SELECT *` over such a copy was cancelled only by a call it never made (a review
@@ -509,7 +746,20 @@ export class SessionRunner {
509
746
  const started = Date.now();
510
747
  let total = 0;
511
748
  let result = null;
512
- let closed = false, fetching = false, released = false;
749
+ let closed = false, fetching = false, released = false, said = false;
750
+ /** DuckDB ended the query: a fetch failed, or read its end (`cleanUp` says why that is what `release` waits for) */
751
+ let ended = false;
752
+ let ending = false;
753
+ // ITS LINE IS SAID WHEN IT ENDS, before its client hears the end, never when its result is released: that waits for
754
+ // DuckDB to end it (`cleanUp`), by when its client is gone and the next session's statements are running, and a
755
+ // reader of the log took the late line for theirs (`tests/duck-wire.test.ts`, 1 run in 100 under load). The
756
+ // worker's port keeps the order: a line posted before the answer is read before it
757
+ const say = () => {
758
+ if (said)
759
+ return;
760
+ said = true;
761
+ this.log(`statement: ${total} row(s) in ${Date.now() - started} ms — ${oneLine(sql)}`);
762
+ };
513
763
  const release = () => {
514
764
  if (released)
515
765
  return;
@@ -519,60 +769,32 @@ export class SessionRunner {
519
769
  signal.removeEventListener("abort", cancel);
520
770
  result?.closeSync();
521
771
  p.destroySync();
522
- this.log(`statement: ${total} row(s) in ${Date.now() - started} ms — ${oneLine(sql)}`);
772
+ say();
523
773
  };
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();
774
+ // A STATEMENT THAT DID NOT RUN TO ITS END (it failed, its cancel raised, it was closed early) IS CLOSED ONLY ONCE
775
+ // DuckDB ENDED IT OFF THIS THREAD (`cleanUp`), the session's next statement waiting for that (`releasing`). One read
776
+ // to its end has no task left, and is released at once
777
+ const endOffThisThread = () => {
778
+ if (released || ending)
533
779
  return;
534
- }
535
- const done = (async () => { await this.cleanUp(seq, sql); release(); })();
780
+ ending = true;
781
+ const done = (async () => { await this.cleanUp(seq, sql, callsFunctions, ended ? null : result); release(); })();
536
782
  this.releasing.add(done);
537
783
  void done.finally(() => this.releasing.delete(done));
538
784
  };
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`)
785
+ // it ended with an answer its client is about to hear: said now
786
+ const releaseAfterCleanup = () => { if (!released)
787
+ say(); endOffThisThread(); };
788
+ // 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
789
  const drain = () => {
543
- const r = result;
544
790
  // CLOSED WHILE DuckDB STILL STARTS IT (its `stream()` pending: an aggregate computes there whole): nothing is
545
791
  // released under the pending start — that destroyed the prepared statement under it and, its release done, let the
546
792
  // start's own failure skip `cleanUp`, so the connection and the instance closed while its other tasks still had
547
793
  // calls queued for this thread, and the thread's end met them (Linux CI: `FATAL ERROR: Error::Error
548
794
  // napi_define_properties`, 7 runs in 30 of a stop under a calling count). The start's own path releases it: its
549
795
  // 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));
796
+ if (result !== null)
797
+ endOffThisThread();
576
798
  };
577
799
  // an early end by the listener (a portal closed half read, the session gone): cancelled, and released — by the
578
800
  // fetch in flight once DuckDB hands the chunk back, else by `drain`
@@ -601,23 +823,23 @@ export class SessionRunner {
601
823
  catch (e) {
602
824
  throw failure(e);
603
825
  }
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
826
+ // CLOSED WHILE IT STARTED (`drain` above), now that DuckDB handed it back, and drained. One that calls no function
827
+ // is told the cancel here, its release done first, before a column of a destroyed result is asked (DuckDB
828
+ // answers 0 columns for one: a review finding, an XX000 "report it" where the session had closed); one that calls
829
+ // holds its result open until DuckDB ended it, so its answer below is built from a live result and its fetch
830
+ // tells the cancel
609
831
  if (closed) {
832
+ drain();
610
833
  if (!callsFunctions) {
611
- release();
834
+ await Promise.all([...this.releasing]);
612
835
  throw cancelled();
613
836
  }
614
- drain();
615
837
  }
616
838
  timing.duckdbMs += performance.now() - t1;
617
839
  const r = result;
618
840
  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);
620
- // Postgres's columns where its analysis gave them (a WITH TIES rank DuckDB adds after them is not sent)
841
+ 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);
842
+ // Postgres's columns where its analysis gave them (the exact numerics' cells DuckDB answers after them are not sent)
621
843
  const columns = fields !== null
622
844
  ? fields.map((f, i) => pgTypedColumn(f.name, f.oid, r.columnType(i), f.typmod))
623
845
  : Array.from({ length: r.columnCount }, (_, i) => wireColumn(r.columnName(i), r.columnType(i)));
@@ -646,11 +868,13 @@ export class SessionRunner {
646
868
  chunk = await this.track(r.fetchChunk());
647
869
  }
648
870
  catch (e) {
871
+ ended = true;
649
872
  throw failure(e);
650
873
  }
651
874
  finally {
652
875
  fetching = false;
653
876
  }
877
+ ended = chunk === null || chunk.rowCount === 0;
654
878
  if (closed) {
655
879
  drain();
656
880
  return null;
@@ -703,8 +927,17 @@ export class SessionRunner {
703
927
  return this.track(this.explained(sql, params, session, signal, how));
704
928
  }
705
929
  async explained(sql, params, session, signal, how) {
706
- await Promise.all([...this.releasing]);
707
930
  const o = how.options;
931
+ // A STATEMENT THAT READS A FILTERED TABLE IS NEVER SHOWN TO THE ENGINE: its plan says what it proved from the table's
932
+ // statistics (docs/DESIGN.md "Roles: rows per user"), so the tables are the statement's and its filters' (a through:'s
933
+ // parents), and the rest is Mason's plan
934
+ if ((how.rowFilters ?? []).length > 0) {
935
+ 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) }; });
936
+ const explained = { copies: await this.copies(tables, how.copies, how.rowFilters ?? []), sql: oneLine(sql.text), computed: computedOf(how.exact, how.fields),
937
+ functions: [...sql.functions], plan: [], filtered: true };
938
+ return o.format === "json" ? [explainJson(explained, o)] : explainText(explained, o);
939
+ }
940
+ await Promise.all([...this.releasing]);
708
941
  let plan;
709
942
  let analyzed;
710
943
  if (!o.analyze)
@@ -727,6 +960,7 @@ export class SessionRunner {
727
960
  }
728
961
  }
729
962
  finally {
963
+ await Promise.all([...this.releasing]);
730
964
  await this.conn.run("PRAGMA disable_profiling");
731
965
  }
732
966
  // THE PROFILE IS HELD TO THE STATEMENT, never assumed its: DuckDB writes one when the result is read to its end,
@@ -734,7 +968,7 @@ export class SessionRunner {
734
968
  // whole table is the table's row count), and its plan is its EXPLAIN, the rows the ones it answered
735
969
  const profile = fs.existsSync(file) ? profiled(JSON.parse(fs.readFileSync(file, "utf8"))) : null;
736
970
  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);
971
+ 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
972
  }
739
973
  if (profile !== null)
740
974
  plan = profile.plan;
@@ -750,7 +984,7 @@ export class SessionRunner {
750
984
  }
751
985
  }
752
986
  // 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),
987
+ const explained = { copies: await this.copies(scannedTables(plan), how.copies, how.rowFilters ?? []), sql: oneLine(sql.text), computed: computedOf(how.exact, how.fields),
754
988
  functions: [...sql.functions], plan, ...(analyzed === undefined ? {} : { analyzed }) };
755
989
  return o.format === "json" ? [explainJson(explained, o)] : explainText(explained, o);
756
990
  }
@@ -788,42 +1022,70 @@ export class SessionRunner {
788
1022
  }
789
1023
  /** each copy's rows, and the sync's facts of it (how it is kept current: src/duck/sync.ts `KeptCurrent`, never the
790
1024
  * poll's ledger read here — a project switched to logical replication still holds its last poll there) */
791
- async copies(tables, facts) {
1025
+ async copies(tables, facts, filters = []) {
792
1026
  const out = [];
793
1027
  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
1028
  // 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 });
1029
+ const filter = filters.find((f) => f.table === `${schema}.${name}`)?.words;
1030
+ // a table the role filters: its counts are every row's, not the role's, and are never read or said here
1031
+ 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];
1032
+ out.push({ table: qualifiedName({ schema, name }), rows: rows === undefined || rows === null ? null : Number(rows), facts: facts.find((f) => f.source === `${schema}.${name}`) ?? null,
1033
+ ...(filter !== undefined ? { filter } : {}) });
797
1034
  }
798
1035
  return out;
799
1036
  }
800
1037
  /**
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.
1038
+ * A STATEMENT THAT DID NOT RUN TO ITS END, ENDED BY DuckDB OFF THIS THREAD before its result is closed. THIS THREAD
1039
+ * NEVER WAITS FOR THE ENGINE'S TASKS: every close is synchronous here, a close of an open result waits for the query's
1040
+ * tasks (Executor::CancelTasks), and this thread runs every Mason function, so a task inside a call waits for it (the
1041
+ * suite hung for good at 0 % CPU; `tests/duck-executor.test.ts` "a vector slower than"). Waiting for quiet instead —
1042
+ * no call for 50 ms, as this did — was no proof: a vector whose native work outlasts the window has made no call yet.
1043
+ * And a call-free statement's tasks hold this thread as long as DuckDB's vector takes, with every session behind it.
1044
+ *
1045
+ * WHAT WAITS IS A FETCH (read in DuckDB 2.0's source, `ClientContext::EndQueryInternal`, and held by the test above).
1046
+ * Every end of a query runs `CancelTasks` on the thread that ends it, and a fetch runs on libuv's pool. Interrupted
1047
+ * first, the next fetch does not compute: it fails at once (`BufferedData::Participate`), ending the query there, its
1048
+ * tasks waited for while this thread answers their calls; or it reads the end, or the end was read already (`r`
1049
+ * null). So the wait is bounded by DuckDB's next chunk, on a statement that never ends by itself too, and an EMPTY chunk
1050
+ * is the end, as `fetch` reads it: past its end a streamed result hands back empty chunks for ever, never null.
1051
+ *
1052
+ * THE PROBE PROVES IT (a statement performs no wait: while a result holds the connection DuckDB refuses it, "connection
1053
+ * has an open result", before anything else). So a `SELECT 1` that ran is proof that nothing of the statement is left,
1054
+ * and the close finds nothing to wait for. Refused, the reading above no longer holds: said as a Mason bug, and closed
1055
+ * all the same, the watch (src/duck/executor.ts SILENT_MS) catching the deadlock if there is one. A wait past
1056
+ * `ENDING_SLOW_MS` is said once.
809
1057
  */
810
- async cleanUp(seq, sql) {
811
- cancelStatement(this.cancels, this.slot, seq);
1058
+ async cleanUp(seq, sql, calls, r) {
1059
+ if (calls)
1060
+ cancelStatement(this.cancels, this.slot, seq);
812
1061
  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)}`);
1062
+ 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);
1063
+ try {
1064
+ if (r !== null) {
1065
+ try {
1066
+ for (let c = await r.fetchChunk(); c !== null && c.rowCount > 0; c = await r.fetchChunk()) { /* its rows are nobody's */ }
1067
+ }
1068
+ catch (e) {
1069
+ // the cancel's own refusal (the interrupt, or its next call raised, 57014) is what this waits for. Anything else
1070
+ // is the statement's real error, met after the listener let go of its answer: no one reads it any more, and a
1071
+ // rethrow here would reach no caller (it would end this thread, and with it every session), so it is said
1072
+ if (pgErrorOf(e, sql).fields.code !== "57014")
1073
+ this.log(`a statement closed before its end failed while it was drained: ${e instanceof Error ? e.message : String(e)} — ${oneLine(sql)}`);
1074
+ }
1075
+ }
1076
+ // an abort landing now interrupts the probe past DuckDB's check, which is its proof all the same
1077
+ try {
1078
+ await this.conn.run("SELECT 1");
826
1079
  }
1080
+ catch (e) {
1081
+ if (/open result/.test(String(e)))
1082
+ 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)}`);
1083
+ else if (pgErrorOf(e, "SELECT 1").fields.code !== "57014")
1084
+ this.log(`the check that a statement ended failed: ${e instanceof Error ? e.message : String(e)} — ${oneLine(sql)}`);
1085
+ }
1086
+ }
1087
+ finally {
1088
+ clearTimeout(slow);
827
1089
  }
828
1090
  }
829
1091
  /** Closes the connection once nothing runs on it: an answer left open drained (its statement cancelled), every
@@ -858,7 +1120,7 @@ function exactColumns(columns, fields, exact, typeAt) {
858
1120
  const computed = exact.filter((e) => {
859
1121
  const unreadable = readers.some((r, j) => r === null && programReads(e, j));
860
1122
  if (unreadable)
861
- columns[e.column] = { ...columns[e.column], divergence: `Postgres computes this numeric exactly; a part of it DuckDB computed as a double` };
1123
+ columns[e.column] = { ...columns[e.column], divergence: `Postgres computes this numeric exactly; a part of it the engine computed as a double` };
862
1124
  return !unreadable;
863
1125
  });
864
1126
  if (computed.length === 0)
@@ -882,7 +1144,7 @@ function exactRows(computed, rows, base, sql, params) {
882
1144
  }
883
1145
  catch (err) {
884
1146
  if (err instanceof NumericError)
885
- throw new StatementError({ code: err.sqlstate, message: err.message }, sql);
1147
+ throw new StatementError({ code: err.sqlstate, message: err.message, ...(err.detail === null ? {} : { detail: err.detail }) }, sql, err.gap);
886
1148
  throw err;
887
1149
  }
888
1150
  }
@@ -893,11 +1155,11 @@ function exactRows(computed, rows, base, sql, params) {
893
1155
  function computedOf(exact, fields) {
894
1156
  return exact.map((e) => {
895
1157
  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)`;
1158
+ 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
1159
  });
898
1160
  }
899
1161
  /** whether the column's program reads cell j */
900
- const programReads = (e, j) => JSON.stringify(e.program).includes(`"key":"${j}"`);
1162
+ const programReads = (e, j) => cellsOf(e.program).includes(String(j));
901
1163
  /** a statement on one line: whole in the log (its ORDER BY, past 160 characters, was what told a slow preview's
902
1164
  * cause), cut where it goes to a client in an error's detail */
903
1165
  const oneLine = (sql, max = Infinity) => { const t = sql.replace(/\s+/g, " ").trim(); return t.length > max ? `${t.slice(0, max - 3)}…` : t; };