@mason-data/cli 0.8.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (118) hide show
  1. package/README.md +19 -8
  2. package/dist/src/api/error.js +4 -0
  3. package/dist/src/api/postgres/catalog-worker.js +49 -2
  4. package/dist/src/api/postgres/catalog.js +166 -32
  5. package/dist/src/api/postgres/listener.js +11 -3
  6. package/dist/src/api/postgres/proxy.js +129 -0
  7. package/dist/src/api/postgres/schema.js +6 -0
  8. package/dist/src/api/postgres/server.js +527 -103
  9. package/dist/src/api/postgres/translate.js +47 -2
  10. package/dist/src/cli/background.js +18 -19
  11. package/dist/src/cli/bridge-stdin.js +101 -0
  12. package/dist/src/cli/command.js +337 -15
  13. package/dist/src/cli/commands.js +458 -169
  14. package/dist/src/cli/credentials.js +182 -0
  15. package/dist/src/cli/demo.js +2 -2
  16. package/dist/src/cli/deploy.js +680 -0
  17. package/dist/src/cli/dev.js +148 -27
  18. package/dist/src/cli/doctor.js +122 -36
  19. package/dist/src/cli/generated-files.js +143 -0
  20. package/dist/src/cli/guides.js +116 -16
  21. package/dist/src/cli/hosted.js +850 -0
  22. package/dist/src/cli/init.js +154 -33
  23. package/dist/src/cli/login.js +292 -0
  24. package/dist/src/cli/machine.js +125 -40
  25. package/dist/src/cli/main.js +59 -10
  26. package/dist/src/cli/mason.cjs +17 -0
  27. package/dist/src/cli/mcp.js +17 -14
  28. package/dist/src/cli/output-gate.js +176 -0
  29. package/dist/src/cli/page-share.js +96 -0
  30. package/dist/src/cli/pg-tls.js +1 -1
  31. package/dist/src/cli/pin.js +6 -2
  32. package/dist/src/cli/pool-size.cjs +85 -0
  33. package/dist/src/cli/ports.js +2 -0
  34. package/dist/src/cli/preflight.js +130 -39
  35. package/dist/src/cli/project.js +100 -1
  36. package/dist/src/cli/readiness.js +42 -6
  37. package/dist/src/cli/registry.js +89 -36
  38. package/dist/src/cli/replica-hosted.js +1324 -0
  39. package/dist/src/cli/replica.js +114 -0
  40. package/dist/src/cli/replication-mode.js +5 -0
  41. package/dist/src/cli/request-filter.js +16 -6
  42. package/dist/src/cli/roles.js +2 -1
  43. package/dist/src/cli/run-server.js +179 -9
  44. package/dist/src/cli/secret-input.js +193 -0
  45. package/dist/src/cli/security.js +57 -10
  46. package/dist/src/cli/serve.js +65 -9
  47. package/dist/src/cli/share-run.js +51 -8
  48. package/dist/src/cli/share.js +23 -18
  49. package/dist/src/cli/source-edit.js +10 -4
  50. package/dist/src/cli/source-role.js +343 -0
  51. package/dist/src/cli/sources.js +50 -23
  52. package/dist/src/cli/style.js +16 -1
  53. package/dist/src/cli/supervise.js +15 -23
  54. package/dist/src/cli/sync.js +17 -8
  55. package/dist/src/cli/token-hosted.js +343 -0
  56. package/dist/src/cli/tunnel.js +1 -1
  57. package/dist/src/cli/ui-hosted.js +163 -0
  58. package/dist/src/cli/ui-ssh.js +460 -0
  59. package/dist/src/cli/ui.js +162 -17
  60. package/dist/src/cli/usage.js +19 -15
  61. package/dist/src/cli/user-hosted.js +364 -0
  62. package/dist/src/cli/user.js +275 -105
  63. package/dist/src/demo/seed.js +26 -0
  64. package/dist/src/duck/analyze.js +36 -20
  65. package/dist/src/duck/collation.js +266 -0
  66. package/dist/src/duck/constant-key.js +94 -0
  67. package/dist/src/duck/distinct-key.js +283 -26
  68. package/dist/src/duck/events.js +39 -8
  69. package/dist/src/duck/executor-worker.js +2 -2
  70. package/dist/src/duck/executor.js +6 -5
  71. package/dist/src/duck/explain.js +4 -0
  72. package/dist/src/duck/functions.js +448 -49
  73. package/dist/src/duck/gaps.js +34 -16
  74. package/dist/src/duck/generated-plan.js +1070 -0
  75. package/dist/src/duck/generated.js +906 -0
  76. package/dist/src/duck/grants.js +1 -1
  77. package/dist/src/duck/held-types.js +64 -0
  78. package/dist/src/duck/history.js +4 -0
  79. package/dist/src/duck/jsonb-order.js +9 -12
  80. package/dist/src/duck/kept-route.js +6 -5
  81. package/dist/src/duck/kept.js +9 -7
  82. package/dist/src/duck/late.js +1469 -40
  83. package/dist/src/duck/libc-bound.js +341 -0
  84. package/dist/src/duck/libc-order.js +892 -126
  85. package/dist/src/duck/pg-eval-worker.js +7 -1
  86. package/dist/src/duck/pg-eval.js +6 -4
  87. package/dist/src/duck/pgtree.js +774 -23
  88. package/dist/src/duck/plan.js +275 -30
  89. package/dist/src/duck/publication.js +14 -0
  90. package/dist/src/duck/requests.js +9 -5
  91. package/dist/src/duck/resolved.js +117 -2
  92. package/dist/src/duck/rewrite.js +4567 -583
  93. package/dist/src/duck/row-filter.js +12 -6
  94. package/dist/src/duck/statement.js +19 -0
  95. package/dist/src/duck/store.js +294 -68
  96. package/dist/src/duck/stream.js +9 -5
  97. package/dist/src/duck/sync.js +602 -126
  98. package/dist/src/duck/token.js +4 -1
  99. package/dist/src/duck/types.js +63 -7
  100. package/dist/src/duck/usage.js +6 -4
  101. package/dist/src/duck/users.js +110 -9
  102. package/dist/src/duck/verified.js +159 -2
  103. package/dist/src/duck/views.js +4 -3
  104. package/dist/src/lib/cidr.js +177 -0
  105. package/dist/src/lib/database-url.js +55 -10
  106. package/dist/src/lib/instant.js +50 -0
  107. package/dist/src/lib/log-buffer.js +13 -4
  108. package/dist/src/lib/log-read.js +196 -0
  109. package/dist/src/lib/pg-numeric.js +387 -49
  110. package/dist/src/lib/secret-mask.js +142 -0
  111. package/dist/src/lib/table-sync.js +84 -19
  112. package/dist/src/web/app.js +9 -4
  113. package/dist/src/web/pages/status.js +178 -13
  114. package/dist/src/web/pages/syncs.js +1 -1
  115. package/dist/src/web/pages/tables.js +8 -3
  116. package/dist/src/web/pages/users.js +4 -3
  117. package/dist/src/web/shared.js +19 -3
  118. package/package.json +8 -7
@@ -19,16 +19,16 @@ import * as os from "node:os";
19
19
  import { DuckDBInstance, version } from "@duckdb/node-api";
20
20
  import { Executor } from "./executor.js";
21
21
  import { beginStatement, cancelStatement, slotName, statementCancelled } from "./cancel.js";
22
- import { StatementError } from "../api/postgres/server.js";
23
- import { duckParameters, missingRelationHint, planDuck, quoteDuckKeywords } from "./plan.js";
22
+ import { StatementError } from "./statement.js";
23
+ import { duckParameters, missingRelationHint, ordersText, planDuck, quoteDuckKeywords } from "./plan.js";
24
24
  import { VERIFIED } from "./verified.js";
25
25
  import { registerFunctions, SESSION_VARIABLE, SETTINGS_VARIABLE } from "./functions.js";
26
- import { duckCollation } from "./rewrite.js";
27
- import { decodeLibcOrder, encodeLibcOrder } from "./libc-order.js";
26
+ import { caseMappingOf, CollationResolver, duckCollation, isLibcCollation } from "./collation.js";
27
+ import { decodeLibcOrder, encodeLibcOrder, isCurrentLibcOrder } from "./libc-order.js";
28
28
  import { dataRow } from "../api/postgres/protocol.js";
29
29
  import { cellText, exactColumn, pgTypedColumn, wireColumn } from "./types.js";
30
30
  import { gapIn, gapMark } from "./gaps.js";
31
- import { cellsOf, evaluateText, NumericError } from "../lib/pg-numeric.js";
31
+ import { cellsOf, evaluateText, fieldOverflow, NumericError } from "../lib/pg-numeric.js";
32
32
  import { explainJson, explainText, planNodes, profiled, qualifiedName, scannedTables } from "./explain.js";
33
33
  import * as fs from "node:fs";
34
34
  import * as path from "node:path";
@@ -65,20 +65,94 @@ const INSTANCE_SETTINGS = { late_materialization_max_rows: "10000", force_compre
65
65
  /**
66
66
  * DUCKDB'S MEMORY, BOUNDED FOR THIS PROCESS: DuckDB's default is 80% of the machine's RAM, in a process
67
67
  * that also holds V8's heap and two PGlites, which ends with the system killing every session and the sync together.
68
- * Half the RAM unless `MASON_DUCKDB_MEMORY` says (`8GB`, `512MiB`); past it a sort, a join or an aggregate spills to
69
- * `<file>.tmp` (DuckDB's own default, named here so it is the copy's folder), and what cannot spill fails ITS statement
70
- * 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.
71
124
  */
72
- /** The default memory limit for a machine with `totalBytes` of RAM: half of it, in MiB (mason doctor says it for any machine). */
73
- export function defaultMemoryLimit(totalBytes) { return `${Math.floor(totalBytes / 2 / 2 ** 20)}MiB`; }
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()`.
133
+ */
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}`; }
74
148
  /**
75
149
  * THE MEMORY THIS PROCESS MAY USE: the machine's RAM, or the container's limit where one is set — a cgroup v2
76
150
  * `memory.max` or a cgroup v1 `memory.limit_in_bytes` below it. `os.totalmem()` is the HOST's RAM inside a container,
77
151
  * so half of it was more than a limited container had, and the kernel killed the run instead of DuckDB spilling
78
152
  * (the review of the cut). `read` is the test's.
79
153
  */
80
- export function machineMemoryBytes(read = (f) => fs.readFileSync(f, "utf8"), total = os.totalmem()) {
81
- let limit = total;
154
+ export function machineMemory(read = (f) => fs.readFileSync(f, "utf8"), total = os.totalmem()) {
155
+ let limit = { bytes: total, from: "machine" };
82
156
  for (const file of ["/sys/fs/cgroup/memory.max", "/sys/fs/cgroup/memory/memory.limit_in_bytes"]) {
83
157
  let text;
84
158
  try {
@@ -89,12 +163,35 @@ export function machineMemoryBytes(read = (f) => fs.readFileSync(f, "utf8"), tot
89
163
  } // not a cgroup of that version, or not a container: the machine's RAM
90
164
  // v2 says `max` for no limit; v1 says a number near 2^63; either way a number below the RAM is the limit
91
165
  const n = Number(text);
92
- if (text !== "max" && Number.isFinite(n) && n > 0 && n < limit)
93
- limit = n;
166
+ if (text !== "max" && Number.isFinite(n) && n > 0 && n < limit.bytes)
167
+ limit = { bytes: n, from: "container" };
94
168
  }
95
169
  return limit;
96
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; }
97
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`;
98
195
  export const MEMORY_LIMIT_RE = /^\s*(\d+(?:\.\d+)?)\s*(KB|MB|GB|TB|KiB|MiB|GiB|TiB)\s*$/i;
99
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 */
100
197
  export function memoryLimitBytes(text) {
@@ -139,17 +236,31 @@ export class DuckStore {
139
236
  file;
140
237
  /** the memory limit every connection shares, in bytes */
141
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;
142
247
  executor;
143
248
  closed = null;
144
249
  closeGraceMs;
145
250
  /** where the copy's extensions come from: Mason's package for this platform, or DuckDB's install (src/duck/extensions.ts) */
146
251
  extensions;
147
- constructor(file, executor, memoryLimit, extensions, closeGraceMs) {
252
+ constructor(file, executor, memoryLimit, extensions, spill, log, closeGraceMs) {
148
253
  this.file = file;
149
254
  this.executor = executor;
150
255
  this.memoryLimitBytes = memoryLimit;
151
256
  this.extensions = extensions;
152
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();
153
264
  this.writer = executor.writer();
154
265
  this.instance = { connect: () => executor.connect() };
155
266
  }
@@ -160,8 +271,13 @@ export class DuckStore {
160
271
  const extensions = options.extensions ?? findExtensions();
161
272
  const memoryLimit = options.memoryLimit ?? DEFAULT_MEMORY_LIMIT;
162
273
  if (!MEMORY_LIMIT_RE.test(memoryLimit))
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})`);
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;
164
279
  const where = path.resolve(file);
280
+ const spillLimit = spillCeiling(diskFree(path.dirname(where)), 0, spillSet);
165
281
  const before = opened.get(where);
166
282
  if (before !== undefined && before.closed === null)
167
283
  throw new Error(`Mason could not open the synced data ${file}: this process has it open already (Mason bug: report it)`);
@@ -175,7 +291,7 @@ export class DuckStore {
175
291
  if (before !== undefined)
176
292
  await before.closed;
177
293
  // every connection's, the sync's and each session's
178
- executor = await Executor.start({ file, settings: { ...INSTANCE_SETTINGS, ...extensionSettings(extensions), 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,
179
295
  ...(options.silentMs !== undefined ? { silentMs: options.silentMs } : {}), ...(options.workerFile !== undefined ? { workerFile: options.workerFile } : {}) });
180
296
  }
181
297
  catch (e) {
@@ -183,7 +299,7 @@ export class DuckStore {
183
299
  opened.delete(where);
184
300
  throw e;
185
301
  }
186
- const store = new DuckStore(file, executor, memoryLimitBytes(memoryLimit), extensions, options.closeGraceMs);
302
+ const store = new DuckStore(file, executor, memoryLimitBytes(memoryLimit), extensions, { limit: spillLimit, set: spillSet, diskFree }, log, options.closeGraceMs);
187
303
  // closed: the file may be opened again — unless its thread was given up on (`abandoned`), which keeps it here
188
304
  store.closing = (p) => { mine.closed = p; void p.then(() => { if (executor.abandoned !== null)
189
305
  mine.abandoned = executor.abandoned;
@@ -192,6 +308,45 @@ export class DuckStore {
192
308
  return store;
193
309
  }
194
310
  closing = () => undefined;
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
+ }
195
350
  /** called once if the engine's thread dies while the run uses it (src/duck/executor.ts): the run exits with the reason */
196
351
  onCrash(f) { this.executor.onCrash(f); }
197
352
  /** how many vectors each Mason function was called with so far, on the engine's thread (a test's, a measurement's) */
@@ -202,13 +357,14 @@ export class DuckStore {
202
357
  runner() { return this.executor.runner(); }
203
358
  /**
204
359
  * The source's order of text under a libc collation (src/duck/libc-order.ts), kept in the copy: the one recorded
205
- * 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.
206
362
  */
207
363
  async libcOrder(collate, version, read) {
208
364
  await this.writer.run(`CREATE SCHEMA IF NOT EXISTS ${MASON_SCHEMA}`);
209
365
  await this.writer.run(`CREATE TABLE IF NOT EXISTS ${MASON_SCHEMA}.libc_order (collation_name VARCHAR, collation_version VARCHAR, data VARCHAR)`);
210
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();
211
- if (kept.length > 0)
367
+ if (kept.length > 0 && isCurrentLibcOrder(String(kept[0][0])))
212
368
  return { order: decodeLibcOrder(String(kept[0][0])), read: false };
213
369
  const order = await read();
214
370
  // one order per collation: a version that moved replaces the last one's
@@ -220,22 +376,50 @@ export class DuckStore {
220
376
  * its text sorted as the SOURCE sorts it (`collation`, pg_database's; absent, by bytes; a libc one by `libc`, its
221
377
  * order as `libcOrder` read it), refusing what is not `verified` (null: nothing is refused for it, the oracle's
222
378
  * recording run alone). */
223
- async listener(collation, verified = VERIFIED, libc, copies = () => [], kept) {
379
+ async listener(collation, verified = VERIFIED, libc, copies = () => [], kept, more = {}) {
224
380
  const known = new Set((await this.writer.runAndReadAll("SELECT collname FROM pragma_collations()")).getRowsJson().map((r) => String(r[0])));
225
381
  quoteDuckKeywords((await this.writer.runAndReadAll("SELECT keyword_name FROM duckdb_keywords() WHERE keyword_category <> 'unreserved'")).getRowsJson().map((r) => String(r[0])));
226
- 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;
227
385
  if (c.kind === "libc") {
228
386
  if (libc === undefined)
229
387
  throw new Error(`the database orders text by the libc collation ${collation.collate}, and its order was not read (Mason bug: report it)`);
230
- await this.executor.registerLibcOrder(libc);
388
+ await this.executor.registerLibcOrder(c.collate, libc, true);
231
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);
232
411
  // a jsonb's stored key is this collation's (src/duck/kept.ts `orderBy`)
233
412
  await kept?.orderBy(c, c.kind === "libc" ? libc : undefined);
234
413
  return {
235
414
  collation: c,
415
+ collations: resolver,
236
416
  open: () => this.runner(),
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),
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 }),
238
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 }),
239
423
  // the sync's state, which EXPLAIN says how each copy is kept current from (src/duck/sync.ts KeptCurrent)
240
424
  copies,
241
425
  // a stored JSON path ready or gone (src/duck/kept.ts): the plans kept are made again
@@ -246,6 +430,7 @@ export class DuckStore {
246
430
  * (a second open of the file waits for it), or once the executor's grace has passed whatever the thread does
247
431
  * (src/duck/executor.ts CLOSE_GRACE_MS); never rejects — a failure is said in the log. */
248
432
  close() {
433
+ clearInterval(this.spillTimer);
249
434
  if (this.closed === null) {
250
435
  this.closed = this.executor.close(this.closeGraceMs);
251
436
  this.closing(this.closed);
@@ -281,11 +466,22 @@ export function pgErrorOf(err, sql) {
281
466
  const full = err instanceof Error ? err.message : String(err);
282
467
  const kind = /^([A-Za-z ]+) Error: /.exec(full)?.[1] ?? "";
283
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
+ }
284
478
  const pg = postgresWording(kind, message);
285
479
  // a refusal raised inside DuckDB (a Mason function's, the rewrite's error() call) crossed it as its text alone: the
286
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)
287
483
  if (pg !== null)
288
- return new StatementError({ code: pg.code, message: pg.message, detail: `the engine: ${message}` }, sql, pg.gap ?? (pg.code === "0A000" ? gapIn(pg.message) : null));
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));
289
485
  const fn = /(?:Scalar|Aggregate|Table) Function with name "?([A-Za-z_][\w]*)"? does not exist/.exec(message)?.[1];
290
486
  if (fn !== undefined) {
291
487
  // what reaches DuckDB was verified against Postgres (src/duck/verified.ts), so a function DuckDB lacks is a gap in
@@ -314,9 +510,12 @@ export function pgErrorOf(err, sql) {
314
510
  : kind === "Out of Range" ? "22003"
315
511
  : kind === "Interrupt" || /INTERRUPT/i.test(full) ? "57014"
316
512
  : kind === "Out of Memory" ? "53200"
317
- : kind === "Not implemented" ? "0A000"
318
- : kind === "Invalid Input" ? "22023"
319
- : "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";
320
519
  return new StatementError({ code, message: code === "57014" ? "canceling statement due to user request"
321
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,
322
521
  // DuckDB's own advice is SET, which a client of Mason cannot do: Mason's way out instead
@@ -345,16 +544,23 @@ function postgresWording(kind, message) {
345
544
  const field = /^Date out of range: (.*)$/.exec(message);
346
545
  if (field)
347
546
  return { code: "22008", message: `date field value out of range: ${field[1]}` };
348
- // a cast to numeric(p, s) of a value with more than p - s integer digits
349
- if (/Casting value "[^"]*" to type DECIMAL\(\d+,\s*\d+\) failed: value is out of range/.test(message))
350
- return { code: "22003", message: "numeric field overflow" };
351
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)
352
548
  if (/^Overflow in HUGEINT addition\b/.test(message))
353
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" };
354
550
  // a numeric(p, s) read from a text holding NaN or Infinity (src/duck/rewrite.ts pgInput): DuckDB's decimal has none
355
- const special = /Could not convert string ['"](NaN|-?Infinity)['"] to DECIMAL/.exec(message);
356
- if (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" };
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
+ }
358
564
  const conv = /Could not convert string '((?:[^']|'')*)' to (INT16|INT32|INT64|DOUBLE|FLOAT|DATE|TIMESTAMP|UUID|BOOL)/.exec(message);
359
565
  if (conv) {
360
566
  const t = { INT16: "smallint", INT32: "integer", INT64: "bigint", DOUBLE: "double precision", FLOAT: "real", DATE: "date", TIMESTAMP: "timestamp", UUID: "uuid", BOOL: "boolean" }[conv[2]];
@@ -362,9 +568,10 @@ function postgresWording(kind, message) {
362
568
  }
363
569
  // a Mason function's own error (src/duck/functions.ts): Postgres's words after the prefix
364
570
  // Postgres's own error, from its own function (src/duck/pg-eval.ts): its SQLSTATE, said
365
- 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);
366
573
  if (pg)
367
- return { code: pg[1], message: pg[2] };
574
+ return { code: pg[1], message: pg[2], ...(pg[3] === undefined ? {} : { detail: pg[3] }) };
368
575
  const own = /^mason: (.*)$/s.exec(message);
369
576
  if (own)
370
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] };
@@ -373,45 +580,27 @@ function postgresWording(kind, message) {
373
580
  return { code: "22P02", message };
374
581
  return null;
375
582
  }
376
- /** when the wait for a statement to end is said in the log */
377
- const ENDING_SLOW_MS = 5_000;
378
- /** One wire session's DuckDB connection, with the session's zone and search_path applied before each statement. Runs
379
- * on the executor worker (src/duck/executor-worker.ts), its statements asked by the listener's RemoteRunner. */
380
- export class SessionRunner {
381
- /** the session's slot of the cancel table (src/duck/cancel.ts), which its connection's `__mason_session` names */
382
- slot;
383
- cancels;
384
- /** the number of the statement `run` or `explain` is asked next (the listener's, carried by the request) */
385
- next = 0;
386
- /** the answers being drained (`streamed`): `close` and the next statement wait for them first */
387
- releasing = new Set();
388
- /** what runs on the connection now: a statement being started, a chunk being read, an EXPLAIN. `close` waits for it
389
- * too — a connection closed under DuckDB's pending call aborted the process (a stop during a drain) */
390
- busy = new Set();
391
- /** the answer left open on the connection, if any: what `close` drains before it closes the connection */
392
- openAnswer = null;
583
+ export class SessionSetup {
393
584
  conn;
394
- log;
395
585
  zone = null;
396
586
  path = null;
397
587
  settings = null;
398
- constructor(conn, log, slot, cancels) {
399
- this.conn = conn;
400
- this.log = log;
401
- this.slot = slot;
402
- this.cancels = cancels;
403
- }
404
- /** a connection for one Postgres session, in the cancel table's `slot` */
405
- static async open(instance, log, slot, cancels) {
406
- const conn = await instance.connect();
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) {
407
593
  // NULLs sort as Postgres sorts them: last ascending, FIRST descending (DuckDB's default is last both ways). An
408
594
  // integer division is the rewrite's `//` where Postgres's operator is integer division (src/duck/rewrite.ts), so
409
595
  // DuckDB's `/` stays the float division every other `/` is
410
596
  await conn.run("SET default_null_order = 'nulls_last_on_asc_first_on_desc'");
411
597
  // every Mason function's first argument (src/duck/functions.ts "every call carries its session")
412
- await conn.run(`SET VARIABLE ${SESSION_VARIABLE} = ${quoteLiteral(slotName(slot))}`);
413
- return new SessionRunner(conn, log, slot, cancels);
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;
414
602
  }
603
+ /** the session's zone, search_path and settings, each set when it changed: before each statement */
415
604
  async settle(session) {
416
605
  if (session.timeZone !== this.zone) {
417
606
  await this.conn.run(`SET TimeZone = ${quoteLiteral(session.timeZone)}`);
@@ -434,6 +623,43 @@ export class SessionRunner {
434
623
  this.settings = settings;
435
624
  }
436
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;
632
+ /** One wire session's DuckDB connection, with the session's zone and search_path applied before each statement. Runs
633
+ * on the executor worker (src/duck/executor-worker.ts), its statements asked by the listener's RemoteRunner. */
634
+ export class SessionRunner {
635
+ /** the session's slot of the cancel table (src/duck/cancel.ts), which its connection's `__mason_session` names */
636
+ slot;
637
+ cancels;
638
+ /** the number of the statement `run` or `explain` is asked next (the listener's, carried by the request) */
639
+ next = 0;
640
+ /** the answers being drained (`streamed`): `close` and the next statement wait for them first */
641
+ releasing = new Set();
642
+ /** what runs on the connection now: a statement being started, a chunk being read, an EXPLAIN. `close` waits for it
643
+ * too — a connection closed under DuckDB's pending call aborted the process (a stop during a drain) */
644
+ busy = new Set();
645
+ /** the answer left open on the connection, if any: what `close` drains before it closes the connection */
646
+ openAnswer = null;
647
+ conn;
648
+ log;
649
+ setup;
650
+ constructor(conn, log, slot, cancels, setup) {
651
+ this.conn = conn;
652
+ this.log = log;
653
+ this.slot = slot;
654
+ this.cancels = cancels;
655
+ this.setup = setup;
656
+ }
657
+ /** a connection for one Postgres session, in the cancel table's `slot` */
658
+ static async open(instance, log, slot, cancels) {
659
+ const conn = await instance.connect();
660
+ return new SessionRunner(conn, log, slot, cancels, await SessionSetup.open(conn, slotName(slot)));
661
+ }
662
+ async settle(session) { await this.setup.settle(session); }
437
663
  async prepare(sql) {
438
664
  try {
439
665
  return await this.conn.prepare(sql);
@@ -613,7 +839,7 @@ export class SessionRunner {
613
839
  const r = result;
614
840
  if (fields !== null && r.columnCount < fields.length)
615
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);
616
- // Postgres's columns where its analysis gave them (a WITH TIES rank DuckDB adds after them is not sent)
842
+ // Postgres's columns where its analysis gave them (the exact numerics' cells DuckDB answers after them are not sent)
617
843
  const columns = fields !== null
618
844
  ? fields.map((f, i) => pgTypedColumn(f.name, f.oid, r.columnType(i), f.typmod))
619
845
  : Array.from({ length: r.columnCount }, (_, i) => wireColumn(r.columnName(i), r.columnType(i)));
@@ -918,7 +1144,7 @@ function exactRows(computed, rows, base, sql, params) {
918
1144
  }
919
1145
  catch (err) {
920
1146
  if (err instanceof NumericError)
921
- throw new StatementError({ code: err.sqlstate, message: err.message }, sql, err.gap);
1147
+ throw new StatementError({ code: err.sqlstate, message: err.message, ...(err.detail === null ? {} : { detail: err.detail }) }, sql, err.gap);
922
1148
  throw err;
923
1149
  }
924
1150
  }
@@ -933,7 +1159,7 @@ function computedOf(exact, fields) {
933
1159
  });
934
1160
  }
935
1161
  /** whether the column's program reads cell j */
936
- const programReads = (e, j) => JSON.stringify(e.program).includes(`"key":"${j}"`);
1162
+ const programReads = (e, j) => cellsOf(e.program).includes(String(j));
937
1163
  /** a statement on one line: whole in the log (its ORDER BY, past 160 characters, was what told a slow preview's
938
1164
  * cause), cut where it goes to a client in an error's detail */
939
1165
  const oneLine = (sql, max = Infinity) => { const t = sql.replace(/\s+/g, " ").trim(); return t.length > max ? `${t.slice(0, max - 3)}…` : t; };
@@ -45,6 +45,7 @@
45
45
  * unchanged-TOAST update) whose earlier row went in an earlier piece is applied as an UPDATE of the columns it carries,
46
46
  * as one whose row the copy holds always is.
47
47
  */
48
+ import { errorText, messageOf } from "../lib/secret-mask.js";
48
49
  import { decode } from "../cdc/pgoutput.js";
49
50
  import { PgConnection, formatLsn, pgIdent, pgLiteral } from "../cdc/pg-wire.js";
50
51
  const lit = (s) => `'${s.replace(/'/g, "''")}'`;
@@ -262,6 +263,7 @@ export class ReplicationStream {
262
263
  publication;
263
264
  target;
264
265
  log;
266
+ errorText;
265
267
  flushMs;
266
268
  flushRows;
267
269
  constructor(o) {
@@ -270,6 +272,7 @@ export class ReplicationStream {
270
272
  this.publication = o.publication;
271
273
  this.target = o.target;
272
274
  this.log = o.log;
275
+ this.errorText = o.errorText ?? ((e) => errorText(e));
273
276
  this.flushMs = o.flushMs ?? 500;
274
277
  this.flushRows = o.flushRows ?? PIECE_CHANGES;
275
278
  this.acked = this.target.applied();
@@ -289,7 +292,7 @@ export class ReplicationStream {
289
292
  this.repl?.sendStatus(this.acked, true);
290
293
  }
291
294
  catch (e) {
292
- this.log(`replication: could not ask the source for its position (${e.message})`);
295
+ this.log(`replication: could not ask the source for its position (${this.errorText(e)})`);
293
296
  }
294
297
  }
295
298
  async run() {
@@ -302,7 +305,7 @@ export class ReplicationStream {
302
305
  if (this.stopping)
303
306
  break;
304
307
  const wait = Math.min(30_000, 1_000 * 2 ** Math.min(attempts++, 5));
305
- this.log(`replication: the source is away (${e.message}); trying again in ${wait / 1000} s`);
308
+ this.log(`replication: the source is away (${this.errorText(e)}); trying again in ${wait / 1000} s`);
306
309
  await new Promise((r) => setTimeout(r, wait));
307
310
  continue;
308
311
  }
@@ -329,7 +332,7 @@ export class ReplicationStream {
329
332
  this.repl = null;
330
333
  return e;
331
334
  }
332
- this.log(`replication: the source went away (${e.message}); reconnecting`);
335
+ this.log(`replication: the source went away (${this.errorText(e)}); reconnecting`);
333
336
  }
334
337
  finally {
335
338
  this.repl?.close();
@@ -387,7 +390,7 @@ export class ReplicationStream {
387
390
  repl.sendStatus(this.acked, true);
388
391
  }
389
392
  catch (e) {
390
- this.log(`replication: the status update could not be sent (${e.message})`);
393
+ this.log(`replication: the status update could not be sent (${this.errorText(e)})`);
391
394
  }
392
395
  }, 10_000);
393
396
  const command = `START_REPLICATION SLOT ${pgIdent(this.slot)} LOGICAL 0/0 (proto_version '1', publication_names ${pgLiteral(`"${this.publication.replace(/"/g, '""')}"`)})`;
@@ -518,6 +521,7 @@ export function pgTime(us) {
518
521
  }
519
522
  /** the source being away — closed, refused, reset, a shutdown — which a reconnect outlasts; anything else is the stream's */
520
523
  function away(e) {
521
- const m = e instanceof Error ? e.message : String(e);
524
+ // a decision: the raw words (src/lib/secret-mask.ts messageOf), never the ones Mason repeats
525
+ const m = messageOf(e);
522
526
  return /connection closed|ECONNREFUSED|ECONNRESET|EPIPE|ETIMEDOUT|timed out|terminating connection|the database system is (shutting down|starting up)|server closed|is active for PID/i.test(m);
523
527
  }