@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
@@ -1,4 +1,8 @@
1
- import { MASON_SCHEMA } from "./store.js";
1
+ import { DEFAULT_MEMORY_RULE, freeBytesAt, MASON_SCHEMA, NO_ROLE_SESSION, pgErrorOf, SessionSetup } from "./store.js";
2
+ /** the session the generated tables' run passes the Mason functions: no slot of the cancel table (src/duck/cancel.ts
3
+ * `slotOf`), so never cancelled through it; a stop interrupts the run's connection instead */
4
+ const GENERATED_SESSION = "generated";
5
+ import { GeneratedTables } from "./generated.js";
2
6
  import { KeptJsonPaths } from "./kept.js";
3
7
  import { DuckDBEnumType, DuckDBListType } from "@duckdb/node-api";
4
8
  import { wireColumn } from "./types.js";
@@ -8,11 +12,14 @@ import * as fs from "node:fs";
8
12
  import * as path from "node:path";
9
13
  import { PgConnection, formatLsn, parseLsn, pgLiteral } from "../cdc/pg-wire.js";
10
14
  import { createSlot, currentLsn, dropSlot, readSlot } from "../cdc/source.js";
15
+ import { holdOf } from "./held-types.js";
11
16
  import { addCounts, countsText, duckValue, foldInto, noChanges, noCounts, ReplicationStream } from "./stream.js";
12
17
  import { didYouMean } from "../lib/did-you-mean.js";
13
- import { namedPublicationVerdict, readNamedPublication } from "./publication.js";
18
+ import { numericWiderThan } from "../lib/pg-numeric.js";
19
+ import { gapLine, namedPublicationVerdict, readNamedPublication } from "./publication.js";
14
20
  import { extensionFailure, loadPostgresSql } from "./extensions.js";
15
- import { BIG_TABLE_ROWS, intervalOf, syncedText, syncText, syncWords } from "../lib/table-sync.js";
21
+ import { errorCode, errorText, maskPasswordKeyword, messageOf } from "../lib/secret-mask.js";
22
+ import { BIG_TABLE_ROWS, intervalOf, msText, syncedText, syncText, syncWords } from "../lib/table-sync.js";
16
23
  export function syncModeOf(env) {
17
24
  const v = env.MASON_DUCK_SYNC || "poll";
18
25
  if (v !== "poll" && v !== "recopy")
@@ -36,6 +43,54 @@ export function nextPollAt(endedMs, intervalSeconds, pollMs) {
36
43
  export function passDelayMs(earliestDueMs, nowMs, lastPassMs) {
37
44
  return Math.min(2 ** 31 - 1, Math.max(0, earliestDueMs - nowMs, 4 * lastPassMs));
38
45
  }
46
+ /** The least grace a polled or recopied table is given past its interval before it is behind (`keptPromise`): a pass
47
+ * attaches the source and queues on the writer, so a poll due now lands a little later. */
48
+ export const POLL_GRACE_FLOOR_SECONDS = 60;
49
+ /** How old a STREAMED table's synced data may be before it is behind (`keptPromise`): an idle healthy stream says it has
50
+ * everything the database wrote on the keepalive it asks for every 10 s (src/duck/stream.ts `sendStatus(…, true)`),
51
+ * so 120 s is twelve of them, and past `wal_sender_timeout`'s default 60 s: one reconnect never crosses it. */
52
+ export const REPLICATION_BEHIND_SECONDS = 120;
53
+ /**
54
+ * WHAT A TABLE'S `sync:` PROMISES: the age of its synced data past which it is BEHIND, and the facts that would make it
55
+ * so now — the one derivation (`Sync.keptOf` calls it; src/cli/readiness.ts `behindOf` compares it with the age at the
56
+ * moment asked). It reads what the schedule reads (`nextPollAt`'s table poll, `passDelayMs`'s last pass), never a second
57
+ * measure, and changes neither load rule: it says them.
58
+ * - `poll`: its interval plus max(60 s, twice its own last poll this run; 60 s when not measured, as after a start);
59
+ * - `recopy`: the run's interval plus max(60 s, twice the last pass);
60
+ * - `replication`: `REPLICATION_BEHIND_SECONDS`;
61
+ * - `off`: nothing (null): it is as of its last `mason sync`, and never behind.
62
+ */
63
+ export function keptPromise(o) {
64
+ if (o.mode === "off")
65
+ return { promisedSeconds: null, lateWhy: null };
66
+ if (o.mode === "replication") {
67
+ return { promisedSeconds: REPLICATION_BEHIND_SECONDS, lateWhy: o.pending !== null ? o.pending : o.notRunning !== null ? `the stream is not running: ${o.notRunning}`
68
+ : "the stream has not had everything the database wrote since then (the Stream section says its lag)" };
69
+ }
70
+ const own = o.mode === "recopy" ? o.lastPassMs : o.lastPollMs;
71
+ const promisedSeconds = o.intervalSeconds + Math.max(POLL_GRACE_FLOOR_SECONDS, own === null ? 0 : (2 * own) / 1000);
72
+ const interval = o.intervalSeconds * 1000;
73
+ const fifth = "to keep the database's load to a fifth";
74
+ let lateWhy;
75
+ if (o.pending !== null)
76
+ lateWhy = o.pending;
77
+ else if (o.passFailed)
78
+ lateWhy = `the sync's last pass failed (mason logs --events says why): it is tried again every ${msText(interval)}`;
79
+ else if (o.mode === "recopy")
80
+ lateWhy = o.lastPassMs === 0 ? "it is read again whole at the next pass"
81
+ : `the last pass, which reads every table again whole, took ${msText(o.lastPassMs)}, and Mason waits four times a pass between passes ${fifth}`;
82
+ else if (4 * o.lastPassMs > interval && 4 * o.lastPassMs >= 4 * (o.lastPollMs ?? 0)) {
83
+ lateWhy = `every polled table's polls took ${msText(o.lastPassMs)} in the last pass, and Mason waits four times that between passes ${fifth}`;
84
+ }
85
+ else if (o.lastPollMs !== null && 4 * o.lastPollMs > interval) {
86
+ lateWhy = `its own poll took ${msText(o.lastPollMs)}, and Mason waits four times a table's poll before its next ${fifth}`;
87
+ }
88
+ else if (o.lastPollMs === null)
89
+ lateWhy = "it has not been polled since Mason started: it is polled at the next pass";
90
+ else
91
+ lateWhy = o.dueMs !== null && o.dueMs > o.now ? `its next poll is due ${clock(o.dueMs)} UTC` : "its poll is due, after the polls of the pass before it";
92
+ return { promisedSeconds, lateWhy };
93
+ }
39
94
  /** a poll asked for that cannot run (`Sync.pollNow`), its reason in the asker's words: `code` is the CLI's failure code */
40
95
  export class PollRefused extends Error {
41
96
  code;
@@ -111,6 +166,14 @@ export async function writeStaged(w, t, target, stage, where, kept) {
111
166
  * by its constraint (`duckdb_constraints()`), which every copy of that layout carries and no copy of this one does.
112
167
  */
113
168
  export const RETIRED_LAYOUT = "stored in key order with an index, by an older Mason: read again whole, in heap order";
169
+ /**
170
+ * A COPY AN OLDER MASON MADE BEFORE A TYPE WAS HELD AS ITS TEXT (src/duck/held-types.ts): a column this Mason holds as the
171
+ * source's text (a `point`, a `money`, a range) stored as the extension's scan read it (a `point` as a STRUCT). Its
172
+ * record (`copy_state.columns`) was written by code that did not know the column's kind, so it cannot say so: found by
173
+ * what the copy physically is (`duckdb_columns()`), copied again through the normal path (`needsCopy`), serving as it
174
+ * was until its swap; never converted in place.
175
+ */
176
+ export const HELD_LAYOUT = "stored by an older Mason with a column not held as the source's text: read again whole, each such column as its text";
114
177
  /**
115
178
  * How long a failed copy waits before it is tried again: four times the attempt, at least 5 minutes, doubled for each
116
179
  * failure in a row, at most 6 hours (a copy whose sort failed for good read a production replica whole for
@@ -168,6 +231,65 @@ export function chunkPages(pages, rows, targetRows = COPY_CHUNK_ROWS) {
168
231
  return COPY_CHUNK_MAX_PAGES;
169
232
  return Math.min(COPY_CHUNK_MAX_PAGES, Math.max(1, Math.round(targetRows / (rows / pages))));
170
233
  }
234
+ /**
235
+ * A CHUNK'S OUT-OF-LINE BYTES ARE BOUNDED BY THE MEMORY (issue 96: a table of ~20,000 rows of ~68 KB of `jsonb` out of
236
+ * line has a heap of a few hundred pages, so its rows ceiling was the whole table, read as ONE chunk, which ran out of
237
+ * a 7.8 GiB limit). A heap page holds a TOAST pointer of ~20 bytes where the row holds its value, so the rows ceiling
238
+ * (`chunkPages`, measured on heap rows) cannot see what a chunk reads; the source's catalog can: its TOAST table's bytes
239
+ * (`pg_table_size` of `reltoastrelid`), a heap page's share of them, read each attempt (`Sync.outOfLineBytes`). The
240
+ * ceiling is then at most `budget / out-of-line bytes a page` pages, at least one: the budget is the memory limit
241
+ * (`store.memoryLimitBytes`) times `COPY_CHUNK_MEMORY_SHARE`, shared by the readers that may read at once
242
+ * (`MASON_COPY_PARALLEL`). A table with nothing out of line gets exactly the rows ceiling.
243
+ *
244
+ * MEASURED (DuckDB 2.0.0, `duckdb_memory()` sampled every 50 ms; docs/DESIGN.md "Large values out of line"): a chunk
245
+ * of `jsonb` held ~5.5 times its bytes on the source while it read (366 MiB for one 67 MB page), one of uncompressed
246
+ * text 1 to 2 times; so a chunk is given an eighth of the limit, the rest left to the readers beside it, the stream,
247
+ * the polls and the queries, and a chunk that still runs out goes on smaller (`readChunks`).
248
+ */
249
+ export const COPY_CHUNK_MEMORY_SHARE = 1 / 8;
250
+ /** the bytes one reader's chunk may read out of line: the memory limit's share (`COPY_CHUNK_MEMORY_SHARE`), split
251
+ * between the readers that may read at once */
252
+ export function chunkBudgetBytes(memoryLimitBytes, readers) {
253
+ return (memoryLimitBytes * COPY_CHUNK_MEMORY_SHARE) / Math.max(1, readers);
254
+ }
255
+ /** THE CEILING of a chunk of a table of `pages` heap pages, about `rows` rows and `outOfLine` bytes out of line
256
+ * (its TOAST table's; null: unknown): the rows ceiling (`chunkPages`), held to `budget` bytes out of line, at least
257
+ * one page */
258
+ export function chunkCeiling(pages, rows, outOfLine, budget, targetRows = COPY_CHUNK_ROWS) {
259
+ const byRows = chunkPages(pages, rows, targetRows);
260
+ if (outOfLine === null || outOfLine <= 0 || pages <= 0)
261
+ return byRows;
262
+ return Math.min(byRows, Math.max(1, Math.floor(budget / (outOfLine / pages))));
263
+ }
264
+ /**
265
+ * NO TABLE IS REFUSED BY ARITHMETIC (fix round 1 of PR 380): whether the engine can write a table's values is the
266
+ * engine's to say, at the write (53200 at a chunk's read or commit, `readChunks`), never a formula over the source's
267
+ * catalog. A refusal decided up front from the TOAST bytes in a row group, measured on a few rows of ~100 KB, refused
268
+ * 140,000 rows of ~2.6 KB through a 128 MB limit that the base synced in one chunk. What stays open: a checkpoint
269
+ * merging a table's small row groups of LARGE values that do not fit invalidates the database (issue 371).
270
+ */
271
+ /** WHY A TABLE IS NOT SYNCED IN THIS MEMORY (`readChunks`): its rows, the limit as set, and the ways out — in the
272
+ * operator's words, never the engine's own remedies (its threads, its settings) */
273
+ export function tooLargeText(source, what, memoryLimitBytes) {
274
+ return `the rows of ${source} are too large for the memory the synced data may use: ${what}, in ${humanBytes(memoryLimitBytes)} ` +
275
+ `(MASON_DUCKDB_MEMORY; unset, ${DEFAULT_MEMORY_RULE}). Raise MASON_DUCKDB_MEMORY and start mason again, or ` +
276
+ `mason add ${source} --columns … without its widest columns`;
277
+ }
278
+ /** A CHUNK'S RANGE GIVEN BACK to what is left to read (`readChunks`: its chunk ran out of memory and rolled back):
279
+ * put among `gaps` in page order and joined to the gaps it meets, so the gaps still tile what no chunk committed —
280
+ * no page twice, none skipped */
281
+ export function giveBack(gaps, r) {
282
+ gaps.push({ ...r });
283
+ gaps.sort((a, b) => a.first - b.first);
284
+ for (let i = 0; i + 1 < gaps.length;) {
285
+ const a = gaps[i], b = gaps[i + 1];
286
+ if (a.end !== null && a.end === b.first) {
287
+ gaps.splice(i, 2, { first: a.first, end: b.end });
288
+ continue;
289
+ }
290
+ i++;
291
+ }
292
+ }
171
293
  /**
172
294
  * HOW LONG A CHUNK SHOULD TAKE TO READ, in seconds (`MASON_COPY_CHUNK_SECONDS`, 1 to 300, default 12:
173
295
  * a laptop copying a 20 M-row table from a database over the internet showed `0 of ~20,126,276 rows` for minutes, then
@@ -382,14 +504,46 @@ export function streamRetryDelayMs(intervalMs, failures) {
382
504
  }
383
505
  /** what Mason's backends on the source are called (pg_stat_activity.application_name): the copy's readers and the polls */
384
506
  export const SOURCE_APPLICATION_NAME = "mason-data";
385
- /** libpq's keyword form of the source, for DuckDB's ATTACH (a value quoted as libpq reads it) */
386
- export function libpqString(s) {
387
- const v = (x) => `'${String(x).replace(/\\/g, "\\\\").replace(/'/g, "\\'")}'`;
388
- return [`host=${v(s.host)}`, `port=${v(s.port)}`, `dbname=${v(s.database)}`, `user=${v(s.user)}`,
389
- ...(s.password ? [`password=${v(s.password)}`] : []), `sslmode=${v(s.sslmode)}`, `application_name=${v(SOURCE_APPLICATION_NAME)}`].join(" ");
507
+ /**
508
+ * THE ENGINE NEVER HOLDS THE SOURCE'S PASSWORD IN A STRING IT REPEATS (issue 599). DuckDB's `postgres` extension
509
+ * repeats an ATTACH's path whole in its error (`Unable to connect to Postgres at "<path>": …`), and every connection
510
+ * reads it in `duckdb_databases()`; a run whose source restarted under it wrote `password='…'` to its log, its ledger,
511
+ * `mason status` and the web page. So the path is `attachPath`'s, which takes nothing but the sslmode, and the
512
+ * credentials go through the extension's SECRET MANAGER (`sourceSecretSql`): a temporary, in-memory secret of TYPE
513
+ * postgres, made on the writer's turn just before the ATTACH that names it and dropped right after it, whatever the
514
+ * ATTACH did (`attach`). Measured on the extension pinned in cli/extensions.json: its error says the path alone,
515
+ * `duckdb_secrets()` says `password=redacted`, the attachment keeps the credentials it merged at the ATTACH (a copy's
516
+ * readers and a reconnect after a restart connect with the secret gone), and it quotes each value for libpq itself.
517
+ * Not PGPASSWORD: the process's environment is every child's, and setting it races the engine's threads reading it.
518
+ * Whatever a dependency repeats one day, the sync's exits mask it (src/lib/secret-mask.ts).
519
+ */
520
+ export function attachPath(s) {
521
+ const v = (x) => `'${x.replace(/\\/g, "\\\\").replace(/'/g, "\\'")}'`;
522
+ return [`sslmode=${v(s.sslmode)}`, `application_name=${v(SOURCE_APPLICATION_NAME)}`].join(" ");
523
+ }
524
+ /** THE ATTACH THE SYNC RUNS: the secret path's, always. A test that shows the mask alone (tests/integration/duck-secret.itest.ts)
525
+ * replaces it through `attachSeamForTest`, which a run never imports: no option, setting or environment reaches it */
526
+ const secretAttachSql = (s) => `ATTACH ${literal(attachPath(s))} AS ${SRC} (TYPE postgres, SECRET ${SOURCE_SECRET}, READ_ONLY)`;
527
+ let attachSqlOf = secretAttachSql;
528
+ export const attachSeamForTest = {
529
+ /** replaces the ATTACH (null: the secret path's again), for a test's process alone */
530
+ set(f) { attachSqlOf = f ?? secretAttachSql; },
531
+ };
532
+ /** the secret the ATTACH names: an explicit name, so no other ATTACH takes it (only `__default_postgres` is taken
533
+ * unnamed), and a wire session's planned SQL cannot name it */
534
+ export const SOURCE_SECRET = "mason_source_credentials";
535
+ /** the source's credentials as the extension's secret: temporary (in memory, never written to disk) and replaced, its
536
+ * password left out when there is none (libpq then reads PGPASSWORD or a pgpass file, as it did before) */
537
+ export function sourceSecretSql(s) {
538
+ return `CREATE OR REPLACE TEMPORARY SECRET ${SOURCE_SECRET} (TYPE postgres, HOST ${literal(s.host)}, PORT ${literal(String(s.port))}, ` +
539
+ `DATABASE ${literal(s.database)}, USER ${literal(s.user)}${s.password ? `, PASSWORD ${literal(s.password)}` : ""})`;
390
540
  }
391
541
  /** a table's columns as a copy is made with them: their names and source types, in order */
392
- const columnsKey = (columns) => JSON.stringify(columns.map((c) => [c.name, c.type]));
542
+ const columnsKey = (columns) => JSON.stringify(columns.map((c) => {
543
+ // what the copy holds is part of the shape: a copy made before a column's type was held as its text is read again
544
+ const hold = holdOf(c.type, c.typeKind);
545
+ return hold === "same" && c.domain !== true ? [c.name, c.type] : [c.name, c.type, hold, ...(c.domain === true ? ["domain"] : [])];
546
+ }));
393
547
  /** `postgres_query` over the attached source: a read of Postgres's own SQL in the DuckDB transaction's snapshot */
394
548
  const pq = (sql) => `postgres_query(${literal(SRC)}, ${literal(sql)})`;
395
549
  /**
@@ -405,6 +559,16 @@ const pq = (sql) => `postgres_query(${literal(SRC)}, ${literal(sql)})`;
405
559
  */
406
560
  const NUMERIC_TYPE = /^numeric(?:\((\d+)(?:,\s*(\d+))?\))?$/;
407
561
  const WIDER = "mason: a numeric value wider than its synced column";
562
+ /** Whether a ROLLBACK's `e` says the engine had ended the transaction itself ("cannot rollback - no transaction is
563
+ * active", after a COMMIT or a read out of memory): a decision, so it reads the raw words (`messageOf`) */
564
+ export function endedTransaction(e) {
565
+ return /no transaction is active/.test(messageOf(e));
566
+ }
567
+ /** Whether `e` is a numeric of `source` wider than its synced column (`WIDER`): a decision, so it reads the error's raw
568
+ * words (`messageOf`), never the words Mason repeats (`errorText`) — the stream's, the catch-up's and the poll's alike */
569
+ export function widerIn(e, source) {
570
+ return messageOf(e).includes(`${WIDER} [${source}]`);
571
+ }
408
572
  /** a source numeric the extension reads as a DOUBLE: no precision, or more than DuckDB's 38 digits */
409
573
  function readsAsDouble(type) {
410
574
  const m = NUMERIC_TYPE.exec(type);
@@ -418,16 +582,9 @@ function fitsDecimal(column, scale, source, significant) {
418
582
  return `CASE WHEN length(${decimals}) > ${scale} OR length(regexp_extract(${v}, '^-?(\\d+)', 1)) > ${38 - scale} ` +
419
583
  `OR ${v} IN ('NaN', 'Infinity', '-Infinity') THEN error(${literal(`${WIDER} [${source}]`)}) ELSE ${v} END AS ${v}`;
420
584
  }
421
- /** a numeric's text that does not fit DECIMAL(38, scale): `fitsDecimal`'s rule, for a value the stream brings */
422
- function widerThan(text, scale, significant) {
423
- if (text === "NaN" || text === "Infinity" || text === "-Infinity")
424
- return true;
425
- const m = /^-?(\d*)(?:\.(\d+))?$/.exec(text);
426
- if (m === null)
427
- return true;
428
- const decimals = significant ? (m[2] ?? "").replace(/0+$/, "") : m[2] ?? "";
429
- return decimals.length > scale || m[1].length > 38 - scale;
430
- }
585
+ /** a numeric's text that does not fit DECIMAL(38, scale): `fitsDecimal`'s rule, for a value the stream brings (the one
586
+ * rule, src/lib/pg-numeric.ts `numericWiderThan`) */
587
+ const widerThan = (text, scale, significant) => numericWiderThan(text, scale, significant);
431
588
  /** The DuckDB type a copied column is stored as: the extension's own, but a JSON for a json / jsonb (it reads them as text). */
432
589
  /** a char(n) column (format_type's `character(n)`): the extension reads its values with the padding trimmed */
433
590
  const CHAR_TYPE = /^character\((\d+)\)$/;
@@ -437,10 +594,12 @@ function storedValue(c) {
437
594
  return m ? `rpad(${ident(c.name)}, ${m[1]}, ' ')` : ident(c.name);
438
595
  }
439
596
  function storedType(c, scanned) {
440
- return /^jsonb?$/.test(c.type) ? "JSON" : scanned;
597
+ const hold = holdOf(c.type, c.typeKind);
598
+ return hold === "text" ? "VARCHAR" : hold === "text[]" ? "VARCHAR[]" : /^jsonb?$/.test(c.type) ? "JSON" : scanned;
441
599
  }
442
- /** the order the queue takes them in: a catch-up is one poll that makes a copied table current */
443
- const REBUILD_ORDER = ["catch-up", "copy"];
600
+ /** the order the queue takes them in: a catch-up is one poll that makes a copied table current; a generated table's
601
+ * re-run reads what the copies hold, so it goes behind them */
602
+ const REBUILD_ORDER = ["catch-up", "copy", "generated"];
444
603
  export class Sync {
445
604
  store;
446
605
  source;
@@ -466,7 +625,7 @@ export class Sync {
466
625
  /** each table whose copy failed: the failures in a row, and when it is tried again (`copyRetryDelayMs`) */
467
626
  retries = new Map();
468
627
  /** each copied table in a layout this Mason no longer keeps (`RETIRED_LAYOUT`), found at `open`: copied again */
469
- retired = new Set();
628
+ retired = new Map();
470
629
  /** the catalog's own connection (`rebuildSchema`): it sees only what a copy or poll has committed, and is never
471
630
  * held by a copy's statement, so a table is served the moment its copy commits */
472
631
  catalog = null;
@@ -474,6 +633,8 @@ export class Sync {
474
633
  schemaBuild = Promise.resolve();
475
634
  tables = new Map();
476
635
  views = [];
636
+ /** the source's collations (src/cli/preflight.ts), which the catalog declares where it lacks one (`setCollations`) */
637
+ collations = [];
477
638
  states = new Map();
478
639
  /** each copied table's columns as its copy was made with them (`columnsKey`): a change copies it again */
479
640
  copiedAs = new Map();
@@ -599,6 +760,14 @@ export class Sync {
599
760
  dbPositionRead = null;
600
761
  /** THE STORED JSON PATHS (src/duck/kept.ts): every write below computes them; the plans read them */
601
762
  kept;
763
+ /** THE GENERATED TABLES (src/duck/generated.ts): every commit that changes a copy marks them, the rebuild queue runs them */
764
+ generated;
765
+ /** the connection their runs read and write on (`open`), set up as a session: what `stop` interrupts */
766
+ generatedLane = null;
767
+ /** the generated lane's session set-up: settled before each plan and run (`GeneratedHost.settle`) */
768
+ generatedSession = null;
769
+ /** the review's check's own connection and its set-up, never the run's (`GeneratedHost.checkConnection`) */
770
+ generatedCheck = null;
602
771
  constructor(store, source, database, log, intervalMs, mode = "poll", replication = null, options = {}) {
603
772
  this.store = store;
604
773
  this.source = source;
@@ -616,7 +785,7 @@ export class Sync {
616
785
  throw new Error(`a first sync's chunk takes a positive number of seconds, not ${this.copyChunkSeconds} (Mason bug: report it; copyChunkSecondsOf reads MASON_COPY_CHUNK_SECONDS)`);
617
786
  this.onChunk = options.onChunk;
618
787
  this.onEvent = options.onEvent ?? (() => undefined);
619
- this.diskFree = options.diskFree ?? ((dir) => { const st = fs.statfsSync(dir); return st.bavail * st.bsize; });
788
+ this.diskFree = options.diskFree ?? freeBytesAt;
620
789
  if (mode === "replication" && replication === null)
621
790
  throw new Error("replication mode needs its slot and publication (Mason bug: report it)");
622
791
  this.replication = replication;
@@ -631,6 +800,55 @@ export class Sync {
631
800
  rebuilding: (source) => this.rebuildingNow?.source === source,
632
801
  log: (line) => this.log(line),
633
802
  }, { enabled: options.json?.enabled ?? true, ...options.json });
803
+ const generatedLane = () => {
804
+ if (this.generatedLane === null)
805
+ throw new Error("the generated tables' connection is not open (Mason bug: report it)");
806
+ return this.generatedLane;
807
+ };
808
+ this.generated = new GeneratedTables(store, {
809
+ onWriter: (f) => this.onWriter(f),
810
+ connection: () => generatedLane().conn,
811
+ settle: async () => {
812
+ if (this.generatedSession === null)
813
+ throw new Error("the generated tables' connection is not open (Mason bug: report it)");
814
+ await this.generatedSession.settle(NO_ROLE_SESSION);
815
+ },
816
+ checkConnection: () => {
817
+ if (this.generatedCheck === null)
818
+ throw new Error("the generated tables' check connection is not open (Mason bug: report it)");
819
+ return this.generatedCheck.conn;
820
+ },
821
+ settleCheck: async () => {
822
+ if (this.generatedCheck === null)
823
+ throw new Error("the generated tables' check connection is not open (Mason bug: report it)");
824
+ await this.generatedCheck.setup.settle(NO_ROLE_SESSION);
825
+ },
826
+ enqueue: (name) => this.enqueue(name, { kind: "generated" }),
827
+ interruptible: (f) => this.interruptible(generatedLane(), f),
828
+ added: (name) => this.addedAs(name),
829
+ syncedAt: (base) => this.synced.get(base)?.at ?? null,
830
+ rebuildCatalog: () => this.rebuildSchema(),
831
+ schema: () => this.currentSchema,
832
+ retryDelayMs: copyRetryDelayMs,
833
+ stopping: (e) => e instanceof Stopping,
834
+ log: (line) => this.log(line),
835
+ event: (e) => this.event(e),
836
+ }, options.generated ?? {});
837
+ }
838
+ /**
839
+ * A BASE IS THERE WHEN THE CATALOG HOLDS IT (issue 387), the generated tables' `added` (src/duck/generated.ts
840
+ * `GeneratedHost.added`): `copied` only when the copy is recorded copied AND the catalog's schema (`currentSchema`, what
841
+ * the listener's catalog is replaced from and every definition is planned over) holds the table; a table the sources
842
+ * file adds that the catalog does not hold yet is `adding`, whatever its copy's phase. The copy records `copied` before
843
+ * its rebuild of the catalog (which reads that record), and the rebuild waits behind any other: said `copied` from the
844
+ * phase alone, a definition planned in between met 42P01 for a synced table and was refused, and a run could be planned
845
+ * over a catalog without its base. The one place this is decided: the check and `ready` read it.
846
+ */
847
+ addedAs(name) {
848
+ const t = this.tables.get(name);
849
+ if (t !== undefined)
850
+ return this.states.get(name)?.phase === "copied" && this.currentSchema.tables.some((x) => x.schema === t.schema && x.name === t.table) ? "copied" : "adding";
851
+ return this.views.some((v) => `${v.schema ?? "public"}.${v.name}` === name) ? "view" : null;
634
852
  }
635
853
  /** the rebuilds' connection: open from `open` until `stop` */
636
854
  get rebuilder() {
@@ -645,9 +863,15 @@ export class Sync {
645
863
  this.onEvent(e);
646
864
  }
647
865
  catch (x) {
648
- this.log(`the sync's event (${e.kind}${e.table ? ` ${e.table}` : ""}) could not be recorded: ${x instanceof Error ? x.message : String(x)}`);
866
+ this.log(`the sync's event (${e.kind}${e.table ? ` ${e.table}` : ""}) could not be recorded: ${this.errorText(x)}`);
649
867
  }
650
868
  }
869
+ /** A CAUGHT ERROR'S WORDS, AS THE SYNC REPEATS THEM (src/lib/secret-mask.ts `errorText`): every engine or driver
870
+ * error becomes text here, once, where it enters — a log line, an event, a table's `error`, an answer is built from
871
+ * it — a `password=` value masked, nothing else changed (no error the sync meets holds the password: it travels in
872
+ * the temporary secret alone, whose own error is never repeated). A decision never reads these words: it reads
873
+ * `messageOf`, the raw ones. tests/secret-mask.test.ts holds src/duck/sync.ts and stream.ts to both */
874
+ errorText(e, o = {}) { return errorText(e, o); }
651
875
  /** a whole-sync failure (`wholeFailure`) is over: said once, as a `recovered` event of no table */
652
876
  recovered(what, words) {
653
877
  const was = this.wholeFailure[what];
@@ -699,6 +923,7 @@ export class Sync {
699
923
  this.recordedSync.set(source, sync);
700
924
  }
701
925
  await this.readPolledAt();
926
+ await this.scrubLedger();
702
927
  const r = await this.store.writer.runAndReadAll(`SELECT source, phase, "rows", bytes, seconds, epoch_ms(copied_at), error, columns::VARCHAR FROM ${MASON_SCHEMA}.copy_state`);
703
928
  for (const row of r.getRowsJS()) {
704
929
  const [source, phase, rows, bytes, seconds, copiedMs, error, columns] = row;
@@ -716,9 +941,53 @@ export class Sync {
716
941
  this.removeStageLeftovers();
717
942
  // the stored JSON paths, before the catalog serves anything: each served copy's view, what the file holds
718
943
  await this.kept.open();
944
+ // the run's connection is a session's, set up by the one set-up (src/duck/store.ts `SessionSetup`): a plan's SQL reads it
945
+ const conn = await this.store.instance.connect();
946
+ this.generatedSession = await SessionSetup.open(conn, GENERATED_SESSION);
947
+ this.generatedLane = { conn, inFlight: false };
948
+ const checkConn = await this.store.instance.connect();
949
+ this.generatedCheck = { conn: checkConn, setup: await SessionSetup.open(checkConn, GENERATED_SESSION) };
950
+ await this.generated.open();
719
951
  this.catalog = await this.store.instance.connect();
720
952
  await this.rebuildSchema();
721
953
  }
954
+ /**
955
+ * THE LEDGER SCRUBBED OF A PASSWORD AN EARLIER MASON WROTE (issue 599): before the engine was given the credentials
956
+ * as a secret, a failed connection's error held `password='…'`, and every table's error (`copy_state.error`) and every
957
+ * event (`sync_events.message`) written then kept it, shown by mason status and the web page for as long as they stand.
958
+ * At every open, those two free-text fields alone are read where they hold a `password=` keyword, its value masked
959
+ * (`maskPasswordKeyword`: the exact leaked form, never a substring of the password now, which a table's name may hold)
960
+ * and written back by bound parameters where that changed them, said in the log with the way out: rotate the password.
961
+ */
962
+ async scrubLedger() {
963
+ const w = this.store.writer;
964
+ const KEYWORD = "%password%=%";
965
+ let scrubbed = 0;
966
+ const errors = (await w.runAndReadAll(`SELECT source, error FROM ${MASON_SCHEMA}.copy_state WHERE error ILIKE $1`, [KEYWORD])).getRowsJS();
967
+ for (const [source, error] of errors) {
968
+ const masked = maskPasswordKeyword(error);
969
+ if (masked === error)
970
+ continue;
971
+ await w.run(`UPDATE ${MASON_SCHEMA}.copy_state SET error = $1 WHERE source = $2`, [masked, source]);
972
+ scrubbed++;
973
+ }
974
+ // the events' table is SyncEvents' (src/duck/events.ts), opened before the sync by the run; a test's store may have none
975
+ const events = (await w.runAndReadAll(`SELECT count(*) FROM duckdb_tables() WHERE schema_name = $1 AND table_name = 'sync_events'`, [MASON_SCHEMA])).getRowsJS();
976
+ if (Number(events[0][0]) > 0) {
977
+ const rows = (await w.runAndReadAll(`SELECT DISTINCT message FROM ${MASON_SCHEMA}.sync_events WHERE message ILIKE $1`, [KEYWORD])).getRowsJS();
978
+ for (const [message] of rows) {
979
+ const masked = maskPasswordKeyword(message);
980
+ if (masked === message)
981
+ continue;
982
+ const changed = await w.run(`UPDATE ${MASON_SCHEMA}.sync_events SET message = $1 WHERE message = $2`, [masked, message]);
983
+ scrubbed += changed.rowsChanged;
984
+ }
985
+ }
986
+ if (scrubbed > 0) {
987
+ this.log(`removed the database's password from ${scrubbed} error row(s) an earlier Mason wrote to its record of the sync (issue 599): ` +
988
+ `it was shown by mason status, mason logs --events and the web page, and may stand in .mason/*.log files — change the database user's password (ALTER ROLE … PASSWORD), then delete those log files`);
989
+ }
990
+ }
722
991
  /** the tables the project adds now (its sources file): a new one is copied at the next pass unless its copy is already
723
992
  * here and current (said as its records say it, `addedText`), a removed one dropped.
724
993
  * What it returns is the catalog rebuilt over them (never rejected: a failure is said in the log) */
@@ -759,7 +1028,7 @@ export class Sync {
759
1028
  this.behind.set(t.source, mark);
760
1029
  void this.onWriter(() => this.store.writer.run(`INSERT OR REPLACE INTO ${MASON_SCHEMA}.catch_up VALUES (${literal(t.source)}, ${literal(why)}, now())`)).catch((e) => {
761
1030
  // its next start catches it up again only by this row: said, and the mark kept for this run's own catch-up
762
- this.log(`${t.source}: its catch-up could not be recorded (a stop before it ends catches it up again only if its sync changes): ${e instanceof Error ? e.message : String(e)}`);
1031
+ this.log(`${t.source}: its catch-up could not be recorded (a stop before it ends catches it up again only if its sync changes): ${this.errorText(e)}`);
763
1032
  });
764
1033
  }
765
1034
  }
@@ -788,7 +1057,7 @@ export class Sync {
788
1057
  this.retries.delete(t.source);
789
1058
  }
790
1059
  // the tables copied before are served at once, not after this start's first pass (its copies may take hours)
791
- const built = this.rebuildSchema().catch((e) => this.log(`the catalog could not be rebuilt (it is at the next pass): ${e instanceof Error ? e.message : String(e)}`));
1060
+ const built = this.rebuildSchema().catch((e) => this.log(`the catalog could not be rebuilt (it is at the next pass): ${this.errorText(e)}`));
792
1061
  this.kick();
793
1062
  return built;
794
1063
  }
@@ -804,7 +1073,7 @@ export class Sync {
804
1073
  const copy = st === undefined ? "never synced"
805
1074
  : st.phase === "failed" ? `its first sync failed: ${firstLine(st.error)}`
806
1075
  : st.phase !== "copied" ? "its first sync has not finished"
807
- : this.retired.has(t.source) ? "it is in an older Mason's layout, key order with an index"
1076
+ : this.retired.has(t.source) ? `it is ${this.retired.get(t.source).short}`
808
1077
  : this.readAgainWhy(t);
809
1078
  if (copy !== null)
810
1079
  return { source: t.source, copy };
@@ -908,6 +1177,10 @@ export class Sync {
908
1177
  setViews(views) {
909
1178
  this.views = views.map((v) => ({ schema: v.schema, name: v.name, definition: v.definition, ...(v.comment ? { description: v.comment } : {}) }));
910
1179
  }
1180
+ /** the source's collations (src/duck/collation.ts): the catalog declares each it lacks, from the next pass */
1181
+ setCollations(collations) {
1182
+ this.collations = collations.map((c) => ({ schema: c.schema, name: c.name, provider: c.provider, collate: c.collate, locale: c.locale, deterministic: c.deterministic }));
1183
+ }
911
1184
  /** every table read again from the start: the database was restored to another server (src/cli/dev.ts sourceMove) */
912
1185
  async rereadAll() {
913
1186
  // from watermark 0 every row is read and written again; the count check deletes what the new server lacks
@@ -919,7 +1192,7 @@ export class Sync {
919
1192
  const now = Date.now();
920
1193
  // in the project's order, whatever order the copies ran and were recorded in (`copyOrder`)
921
1194
  return [...this.tables.keys()].flatMap((source) => this.states.get(source) ?? [])
922
- .map((s) => this.retired.has(s.source) ? { ...s, retired: RETIRED_LAYOUT } : s)
1195
+ .map((s) => { const r = this.retired.get(s.source); return r !== undefined ? { ...s, retired: r.layout } : s; })
923
1196
  // a copy's chunks as they are NOW: the rows the ones in flight have read by now at the measured rate
924
1197
  .map((s) => { const l = this.copyLive.get(s.source); return s.phase === "copying" && l !== undefined ? { ...s, progress: copyProgressNow(l, Date.now()) } : s; })
925
1198
  // how it is kept current, and a table the passes leave as copied, from ONE derivation (`keptOf`)
@@ -954,20 +1227,29 @@ export class Sync {
954
1227
  // says nothing is pending, `mason sync` of it runs
955
1228
  const pending = this.pollable(t) ? null : this.notStreamedWhy(t.source);
956
1229
  const next = pending === null ? this.dueAt(t) : null;
957
- return { by: "poll", sync, syncedAt: polledAt, nextAt: next === null ? null : new Date(Math.max(next, now)).toISOString(), ...(pending !== null ? { pending } : {}) };
1230
+ const promise = keptPromise({ mode: sync.mode === "off" ? "off" : "poll", intervalSeconds: sync.mode === "poll" ? sync.seconds : 0, lastPollMs: this.lastPollMs.get(t.source) ?? null,
1231
+ lastPassMs: this.lastPassMs, pending, notRunning: null, passFailed: this.passFailed, dueMs: next, now });
1232
+ return { by: "poll", sync, syncedAt: polledAt, nextAt: next === null ? null : new Date(Math.max(next, now)).toISOString(), ...(pending !== null ? { pending } : {}), ...promise };
958
1233
  }
959
1234
  // copied again whole every pass, whatever its watermark: only a table not copied now (waiting, being copied, its copy
960
- // failed) is not kept current
961
- if (this.mode === "recopy")
962
- return { by: "recopy", copiedAt: st?.copiedAt ?? null, ...(st?.phase !== "copied" ? { pending: this.notStreamedWhy(t.source) } : {}) };
1235
+ // failed) is not kept current; its synced data is as of its copy's watermark (`polled_at`, taken before the swap)
1236
+ if (this.mode === "recopy") {
1237
+ const pending = st?.phase !== "copied" ? this.notStreamedWhy(t.source) : null;
1238
+ const promise = keptPromise({ mode: "recopy", intervalSeconds: this.intervalMs / 1000, lastPollMs: null, lastPassMs: this.lastPassMs, pending, notRunning: null,
1239
+ passFailed: this.passFailed, dueMs: null, now });
1240
+ return { by: "recopy", copiedAt: st?.copiedAt ?? null, syncedAt: this.polledAt.get(t.source) ?? null, ...(pending !== null ? { pending } : {}), ...promise };
1241
+ }
963
1242
  const s = this.stream;
964
1243
  const part = this.streamTable(t, now, s);
965
1244
  // current as of its last copy or poll, or — streamed — the last moment the stream had all the database wrote
966
1245
  const polled = this.polledAt.get(t.source) ?? null;
967
1246
  const streamed = part.state === "streamed" && this.streamCurrentAt !== null ? new Date(this.streamCurrentAt).toISOString() : null;
968
1247
  const syncedAt = streamed !== null && (polled === null || streamed > polled) ? streamed : polled;
1248
+ const notRunning = this.streamDownWhy(s, st);
1249
+ const promise = keptPromise({ mode: "replication", intervalSeconds: 0, lastPollMs: null, lastPassMs: this.lastPassMs, pending: part.state === "streamed" ? null : part.why,
1250
+ notRunning, passFailed: false, dueMs: null, now });
969
1251
  return { by: "replication", slot: this.replication?.slot ?? "", state: part.state, why: part.why, position: formatLsn(s?.position() ?? this.appliedLsn),
970
- lastCommit: this.lastBatch === null ? null : new Date(this.lastBatch.committedAt).toISOString(), notRunning: this.streamDownWhy(s, st), syncedAt };
1252
+ lastCommit: this.lastBatch === null ? null : new Date(this.lastBatch.committedAt).toISOString(), notRunning, syncedAt, ...promise };
971
1253
  }
972
1254
  /** why the stream is not running, in the reader's words (null: it runs): its last failure and when it starts again; the
973
1255
  * replication check a pass failed (`prepareReplication`, recorded on each copy's state); a slot not made yet; a sync
@@ -1071,7 +1353,7 @@ export class Sync {
1071
1353
  }
1072
1354
  this.running = this.pass().then(() => { this.passFailed = false; }, (e) => {
1073
1355
  this.passFailed = true;
1074
- this.log(`sync pass failed: ${e instanceof Error ? e.message : String(e)}`);
1356
+ this.log(`sync pass failed: ${this.errorText(e)}`);
1075
1357
  }).finally(() => {
1076
1358
  this.running = null;
1077
1359
  if (this.stopped)
@@ -1174,6 +1456,16 @@ export class Sync {
1174
1456
  * every table again whole. Its failure is its caller's to say: the copies serve as they were, and the passes go on.
1175
1457
  */
1176
1458
  async pollNow(sources) {
1459
+ // its failure is the command server's answer, sent as it is: a refusal is Mason's own words, any other error's are
1460
+ // made Mason's here (`errorText`), where they leave the sync
1461
+ try {
1462
+ return await this.pollNowOf(sources);
1463
+ }
1464
+ catch (e) {
1465
+ throw e instanceof PollRefused || e instanceof Stopping ? e : new Error(this.errorText(e));
1466
+ }
1467
+ }
1468
+ async pollNowOf(sources) {
1177
1469
  if (this.mode === "recopy")
1178
1470
  throw new PollRefused("refused", "this run reads every table again whole at each pass (MASON_DUCK_SYNC=recopy), so there is nothing to poll");
1179
1471
  const tables = sources.map((s) => {
@@ -1202,8 +1494,9 @@ export class Sync {
1202
1494
  const again = this.readAgainWhy(t);
1203
1495
  if (again !== null)
1204
1496
  throw new PollRefused("nothing_yet", `${t.source} is being read again whole (${again}): mason wait waits for it`);
1205
- if (this.retired.has(t.source))
1206
- throw new PollRefused("nothing_yet", `${t.source} is ${RETIRED_LAYOUT} (its synced data serves as it was): it is current once read again — mason wait waits for it`);
1497
+ const r = this.retired.get(t.source);
1498
+ if (r !== undefined)
1499
+ throw new PollRefused("nothing_yet", `${t.source} is ${r.layout} (its synced data serves as it was): it is current once read again — mason wait waits for it`);
1207
1500
  this.refuseRebuilding(t);
1208
1501
  }
1209
1502
  const poll = (async () => {
@@ -1260,14 +1553,14 @@ export class Sync {
1260
1553
  }
1261
1554
  // the stream, the pass and the rebuild end together: a batch waiting for the writer's turn behind a poll must not
1262
1555
  // wait for that poll, so each connection's long statement is interrupted from the start
1263
- const interrupt = () => { for (const lane of [this.writerLane, this.rebuilderLane, ...this.copyLanes])
1556
+ const interrupt = () => { for (const lane of [this.writerLane, this.rebuilderLane, this.generatedLane, ...this.copyLanes])
1264
1557
  if (lane?.inFlight)
1265
1558
  lane.conn.interrupt(); };
1266
1559
  const again = setInterval(interrupt, 50);
1267
1560
  interrupt();
1268
1561
  try {
1269
1562
  // none of them rejects: each says its own failure
1270
- await Promise.all([this.stream?.stop(), this.running, this.rebuilding, this.kept.stop()]);
1563
+ await Promise.all([this.stream?.stop(), this.running, this.rebuilding, this.kept.stop(), this.generated.stop()]);
1271
1564
  await this.writerTurn;
1272
1565
  }
1273
1566
  finally {
@@ -1280,6 +1573,12 @@ export class Sync {
1280
1573
  this.catalog = null;
1281
1574
  this.rebuilderLane?.conn.closeSync();
1282
1575
  this.rebuilderLane = null;
1576
+ this.generatedLane?.conn.closeSync();
1577
+ this.generatedLane = null;
1578
+ this.generatedSession = null;
1579
+ // after `generated.stop`, which waited for the review and so for any check on it
1580
+ this.generatedCheck?.conn.closeSync();
1581
+ this.generatedCheck = null;
1283
1582
  }
1284
1583
  /** a poll asked of a table while it is rebuilt or not yet caught up: refused, by name (it is caught up when that ends) */
1285
1584
  refuseRebuilding(t) {
@@ -1536,6 +1835,33 @@ export class Sync {
1536
1835
  ` (the slot held the database's WAL)${r.owned ? "" : `; publication ${r.publication} is its owner's, left as it is`} — a table set to sync: replication makes the slot again`);
1537
1836
  this.onReplication?.(false);
1538
1837
  }
1838
+ /**
1839
+ * THE STREAM HAS EVERYTHING THE DATABASE WROTE (a keepalive with nothing pending, or a batch flushed): every streamed
1840
+ * table is current as of now (`keptOf`'s syncedAt), and a copied one's replication error (the stream's failure, the
1841
+ * preparation's, from this run or one before) is over — the same proof a batch applied with a position gives
1842
+ * (`applyChanges`), which an idle database never sends: a healthy table kept a run-old stream error for ever, and
1843
+ * `mason wait` read it as failed. Cleared on the writer's turn, one turn at a time.
1844
+ */
1845
+ streamCurrent() {
1846
+ this.streamCurrentAt = Date.now();
1847
+ if (this.clearingStreamErrors)
1848
+ return;
1849
+ const stale = (source) => {
1850
+ const st = this.states.get(source);
1851
+ return st !== undefined && st.phase === "copied" && st.error?.startsWith("replication: ") === true && !this.unpublished.has(source) && !this.held.has(source);
1852
+ };
1853
+ if (![...this.joinedNow].some(stale))
1854
+ return;
1855
+ this.clearingStreamErrors = true;
1856
+ void this.onWriter(async () => {
1857
+ for (const source of this.joinedNow)
1858
+ if (stale(source))
1859
+ await this.record({ ...this.states.get(source), error: null });
1860
+ this.recovered("stream", "the stream has everything the database wrote again");
1861
+ }).catch((e) => this.log(`replication: a table's stream error could not be cleared (it is cleared at the next batch): ${this.errorText(e)}`))
1862
+ .finally(() => { this.clearingStreamErrors = false; });
1863
+ }
1864
+ clearingStreamErrors = false;
1539
1865
  /**
1540
1866
  * The stream started from the slot's acknowledged position, once the slot is known (a pass's `prepareReplication`),
1541
1867
  * and never stopped by a pass: a copy runs beside it. What it applies is `streamed`, read at each change, so a table
@@ -1547,10 +1873,10 @@ export class Sync {
1547
1873
  const r = this.replication;
1548
1874
  this.refreshStreamed();
1549
1875
  const s = new ReplicationStream({
1550
- connect: () => PgConnection.connect({ ...this.pgOptions(), replication: true }), slot: r.slot, publication: r.publication, log: (l) => this.log(l),
1876
+ connect: () => PgConnection.connect({ ...this.pgOptions(), replication: true }), slot: r.slot, publication: r.publication, log: (l) => this.log(l), errorText: (e) => this.errorText(e),
1551
1877
  target: { tables: () => this.streamed, apply: (batch, lsn, committedAt) => this.applyStreamed(batch, lsn, committedAt), applied: () => this.appliedLsn,
1552
1878
  refuse: (source, missing) => this.refuseStreamed(source, missing), current: () => { if (this.stream === s)
1553
- this.streamCurrentAt = Date.now(); } },
1879
+ this.streamCurrent(); } },
1554
1880
  });
1555
1881
  this.stream = s;
1556
1882
  this.streamFailure = null;
@@ -1571,7 +1897,7 @@ export class Sync {
1571
1897
  // a numeric wider than its copy's DECIMAL: that table leaves the stream and is copied again, wider, and the stream
1572
1898
  // goes on at once without it (it joins again caught up from its new copy) — at most three times a run, past which
1573
1899
  // a value the stream keeps replaying is wider than any copy of now
1574
- const wider = [...this.tables.values()].find((t) => e.message.includes(`${WIDER} [${t.source}]`));
1900
+ const wider = [...this.tables.values()].find((t) => widerIn(e, t.source));
1575
1901
  const n = wider === undefined ? 0 : (this.widened.get(wider.source) ?? 0) + 1;
1576
1902
  if (wider !== undefined && n <= 3) {
1577
1903
  this.widened.set(wider.source, n);
@@ -1581,7 +1907,7 @@ export class Sync {
1581
1907
  this.kick();
1582
1908
  return;
1583
1909
  }
1584
- const m = `replication: ${e.message}`;
1910
+ const m = `replication: ${this.errorText(e)}`;
1585
1911
  // a failure that recurs must not restart the stream every second: each restart decodes the slot's WAL again on
1586
1912
  // the source
1587
1913
  this.streamFailures++;
@@ -1707,14 +2033,15 @@ export class Sync {
1707
2033
  return "it is no longer added (its synced data is dropped)";
1708
2034
  const gap = this.unpublished.get(source);
1709
2035
  if (gap !== undefined)
1710
- return gap.split("\n")[0];
2036
+ return gapLine(gap);
1711
2037
  const st = this.states.get(source);
1712
2038
  if (this.copiedAs.has(source) && this.copiedAs.get(source) !== columnsKey(t.columns))
1713
2039
  return "its columns changed: it is read again whole, then caught up by one poll";
1714
2040
  if (st?.phase === "copied" && !this.snapshots.has(source))
1715
2041
  return "a numeric value outgrew its synced DECIMAL column: it is read again whole, wider, then caught up by one poll";
1716
- if (st?.phase === "copied" && this.retired.has(source))
1717
- return "it is in an older Mason's layout, key order with an index: it is read again whole, then caught up by one poll";
2042
+ const r = this.retired.get(source);
2043
+ if (st?.phase === "copied" && r !== undefined)
2044
+ return `it is ${r.short}: it is read again whole, then caught up by one poll`;
1718
2045
  // what follows the copy: a catch-up poll, then the stream or the polls; copied again whole, nothing (`keptOf`'s recopy)
1719
2046
  const joins = this.mode === "recopy" ? "it is kept current once synced" : "it joins once synced and caught up by one poll";
1720
2047
  if (st?.phase === "copying" || this.rebuildingNow?.source === source && this.rebuildingNow.kind === "copy")
@@ -1790,7 +2117,7 @@ export class Sync {
1790
2117
  this.slotWal = { wal: slotWalOf(await readSlot(pg, slot), slot, Date.now()), at: Date.now() };
1791
2118
  }
1792
2119
  catch (e) {
1793
- this.slotWal = { wal: { error: `the slot could not be read: ${e instanceof Error ? e.message : String(e)}` }, at: Date.now() };
2120
+ this.slotWal = { wal: { error: `the slot could not be read: ${this.errorText(e)}` }, at: Date.now() };
1794
2121
  }
1795
2122
  }
1796
2123
  }
@@ -1800,7 +2127,7 @@ export class Sync {
1800
2127
  }
1801
2128
  catch (e) {
1802
2129
  // answered as the status's (and the log line's) own words: the position could not be read, and why
1803
- const m = e instanceof Error ? e.message : String(e);
2130
+ const m = this.errorText(e);
1804
2131
  this.dbPosition = { error: m, at: Date.now() };
1805
2132
  if (slotDue)
1806
2133
  this.slotWal = { wal: { error: `the database could not be reached: ${m}` }, at: Date.now() };
@@ -1967,6 +2294,9 @@ export class Sync {
1967
2294
  for (const [name, changes] of batch) {
1968
2295
  if (!this.tables.has(name))
1969
2296
  continue;
2297
+ // committed: a generated table over it is behind from here (nothing of this is in the transaction above)
2298
+ if (changes.truncated || changes.keys.size > 0)
2299
+ this.generated.changed(name);
1970
2300
  for (const into of [this.appliedCounts, this.sinceLine]) {
1971
2301
  const c = into.get(name) ?? noCounts();
1972
2302
  addCounts(c, changes.counts);
@@ -2149,18 +2479,44 @@ export class Sync {
2149
2479
  await this.store.writer.run(loadPostgresSql(x));
2150
2480
  }
2151
2481
  catch (e) {
2152
- throw new Error(extensionFailure(x, e.message));
2482
+ throw new Error(extensionFailure(x, this.errorText(e)));
2153
2483
  }
2154
2484
  this.loaded = true;
2155
2485
  }
2156
- await this.store.writer.run(`ATTACH ${literal(libpqString(this.source))} AS ${SRC} (TYPE postgres, READ_ONLY)`);
2486
+ const w = this.store.writer;
2487
+ // the engine's own words are never repeated here: its statement held the password (`sourceSecretSql`)
2488
+ try {
2489
+ await w.run(sourceSecretSql(this.source));
2490
+ }
2491
+ // ITS OWN WORDS ARE NEVER REPEATED: the statement held the password, and an error may quote its statement — its
2492
+ // code alone is said (src/lib/secret-mask.ts errorCode)
2493
+ catch (e) {
2494
+ throw new Error(`the database's credentials could not be set up for the sync (Mason bug: report it, with this code; the error's own words are not shown, as its statement held the password): ${errorCode(e) ?? "no code"}`);
2495
+ }
2496
+ // the engine's failure to connect, in Mason's words (`errorText`) where it enters
2497
+ try {
2498
+ await w.run(attachSqlOf(this.source));
2499
+ }
2500
+ catch (e) {
2501
+ throw new Error(this.errorText(e));
2502
+ }
2503
+ finally {
2504
+ // the attachment keeps what it merged; the secret goes at once, whatever the ATTACH did. One left behind is
2505
+ // the instance's, in memory, unreadable (duckdb_secrets() redacts it) and replaced at the next attach: said
2506
+ try {
2507
+ await w.run(`DROP TEMPORARY SECRET IF EXISTS ${SOURCE_SECRET}`);
2508
+ }
2509
+ catch (e) {
2510
+ this.log(`the database's credentials could not be removed from memory once it was attached (they stay there, never on disk, until the next attach replaces them): ${this.errorText(e)}`);
2511
+ }
2512
+ }
2157
2513
  this.attached = true;
2158
2514
  }
2159
2515
  this.sourceUsers++;
2160
2516
  }
2161
2517
  /**
2162
2518
  * THE SOURCE IS ATTACHED ONLY WHILE A PASS OR A REBUILD READS IT. An attached database is every connection's — a wire
2163
- * session's too, and the rebuilds' — and its path is the connection string, password included (`duckdb_databases()`).
2519
+ * session's too, and the rebuilds' — and its path (`duckdb_databases()`) is `attachPath`'s, which holds no credential.
2164
2520
  * A session runs only planned SQL (src/duck/plan.ts PlannedSql), which cannot name it; detached when its last user
2165
2521
  * ends (on the writer's turn, so never under a transaction reading it), it is not there to name either.
2166
2522
  */
@@ -2174,7 +2530,7 @@ export class Sync {
2174
2530
  }
2175
2531
  // the pass's work stands; still attached, the next pass detaches it again (and attach() sees it is attached)
2176
2532
  catch (e) {
2177
- this.log(`the source could not be detached after the pass (tried again after the next): ${e instanceof Error ? e.message : String(e)}`);
2533
+ this.log(`the source could not be detached after the pass (tried again after the next): ${this.errorText(e)}`);
2178
2534
  }
2179
2535
  }
2180
2536
  /** one pass: every removed table dropped, every added table needing it queued for a copy (step 0: whole, again) or
@@ -2229,9 +2585,9 @@ export class Sync {
2229
2585
  catch (e) {
2230
2586
  // the way out on the table's own line: a failure that names none (a slot the database will not make, a role that
2231
2587
  // may not replicate) says the line that keeps the tables polled meanwhile — never a bare SQLSTATE's text
2232
- const said = e instanceof Error ? e.message : String(e);
2588
+ const said = this.errorText(e);
2233
2589
  const names = this.replicatedTables().map((t) => t.source);
2234
- const m = `replication: ${said}${/sync: poll/.test(said) ? "" : ` — ${names.join(", ")} ${names.length === 1 ? "is" : "are"} NOT kept current until it is fixed: ` +
2590
+ const m = `replication: ${said}${/sync: poll/.test(messageOf(e)) ? "" : ` — ${names.join(", ")} ${names.length === 1 ? "is" : "are"} NOT kept current until it is fixed: ` +
2235
2591
  `give ${names.length === 1 ? "its entry" : "their entries"} sync: poll <interval> (poll 5m) in the sources file to poll ${names.length === 1 ? "it" : "them"} instead`}`;
2236
2592
  this.event({ kind: "stream_failed", table: null, message: m });
2237
2593
  this.wholeFailure.prepare = m;
@@ -2254,7 +2610,7 @@ export class Sync {
2254
2610
  await this.retireReplication();
2255
2611
  }
2256
2612
  catch (e) {
2257
- replicationFailed = new Error(`no table's sync is replication now, but replication slot ${this.replication.slot} could not be dropped (it holds the database's WAL; tried again at the next pass): ${e instanceof Error ? e.message : String(e)}`);
2613
+ replicationFailed = new Error(`no table's sync is replication now, but replication slot ${this.replication.slot} could not be dropped (it holds the database's WAL; tried again at the next pass): ${this.errorText(e)}`);
2258
2614
  }
2259
2615
  }
2260
2616
  if (this.tables.size === 0) {
@@ -2267,7 +2623,7 @@ export class Sync {
2267
2623
  await this.onWriter(() => this.attach());
2268
2624
  }
2269
2625
  catch (e) {
2270
- const m = e instanceof Error ? e.message : String(e);
2626
+ const m = this.errorText(e);
2271
2627
  this.event({ kind: "source_unreachable", table: null, message: `could not attach the database: ${m}` });
2272
2628
  this.wholeFailure.attach = m;
2273
2629
  // a table copied before keeps serving its copy (and its place in the catalog): the source being away is its error,
@@ -2350,7 +2706,7 @@ export class Sync {
2350
2706
  this.due.set(t.source, Date.now() + (s.mode === "poll" ? s.seconds * 1000 : this.intervalMs));
2351
2707
  // the table serves its copy as it was, and says why it is not current (mason status reads the ledger); the other
2352
2708
  // tables are polled as ever
2353
- const m = `the poll failed, and is tried again at the next one (the synced data serves as it was): ${e instanceof Error ? e.message : String(e)}`;
2709
+ const m = `the poll failed, and is tried again at the next one (the synced data serves as it was): ${this.errorText(e)}`;
2354
2710
  this.log(`${t.source}: ${m}`);
2355
2711
  this.event({ kind: "poll_failed", table: t.source, message: m });
2356
2712
  await this.onWriter(() => this.record({ ...this.states.get(t.source), error: m }));
@@ -2380,9 +2736,16 @@ export class Sync {
2380
2736
  if (next === undefined)
2381
2737
  return;
2382
2738
  this.queued.delete(next[0]);
2739
+ // a generated table's run reads no source: nothing is attached and no table is marked as rebuilt
2740
+ const kind = next[1].kind;
2741
+ if (kind === "generated") {
2742
+ await this.generated.run(next[0]).catch((e) => { if (!(e instanceof Stopping))
2743
+ throw e; });
2744
+ continue;
2745
+ }
2383
2746
  const t = this.tables.get(next[0]);
2384
2747
  // a catch-up that found a numeric wider than the copy: copied again, next
2385
- if (t !== undefined && await this.rebuild(t, next[1]) === "copy again")
2748
+ if (t !== undefined && await this.rebuild(t, kind) === "copy again")
2386
2749
  this.enqueue(t.source, { kind: "copy" });
2387
2750
  // still needing a copy or a catch-up: a pass now, or when its back-off ends
2388
2751
  else
@@ -2392,7 +2755,7 @@ export class Sync {
2392
2755
  this.kick();
2393
2756
  }
2394
2757
  })().catch((e) => {
2395
- this.log(`a rebuild failed outside its own handling (Mason bug: report it): ${e instanceof Error ? e.stack ?? e.message : String(e)}`);
2758
+ this.log(`a rebuild failed outside its own handling (Mason bug: report it): ${this.errorText(e, { stack: true })}`);
2396
2759
  }).finally(() => {
2397
2760
  this.rebuilding = null;
2398
2761
  // what was queued behind a rebuild that threw is still queued
@@ -2404,9 +2767,9 @@ export class Sync {
2404
2767
  * ONE REBUILD, beside the stream and the polls: the table marked as rebuilt on the writer's turn (from then on no poll
2405
2768
  * starts on it), then its copy on the rebuilds' connection, then — a table left behind by it — its catch-up.
2406
2769
  */
2407
- async rebuild(t, r) {
2770
+ async rebuild(t, kind) {
2408
2771
  await this.onWriter(async () => {
2409
- this.rebuildingNow = { source: t.source, kind: r.kind };
2772
+ this.rebuildingNow = { source: t.source, kind };
2410
2773
  this.refreshStreamed();
2411
2774
  });
2412
2775
  let attached = false;
@@ -2417,14 +2780,14 @@ export class Sync {
2417
2780
  }
2418
2781
  catch (e) {
2419
2782
  // the next pass says the same of every table, and queues it again
2420
- const m = `could not attach the database: ${e instanceof Error ? e.message : String(e)}`;
2421
- this.log(`${r.kind} of ${t.source} waits: ${m}`);
2422
- this.event({ kind: "source_unreachable", table: t.source, message: `its ${r.kind} waits: ${m}` });
2783
+ const m = `could not attach the database: ${this.errorText(e)}`;
2784
+ this.log(`${kind} of ${t.source} waits: ${m}`);
2785
+ this.event({ kind: "source_unreachable", table: t.source, message: `its ${kind} waits: ${m}` });
2423
2786
  const st = this.states.get(t.source);
2424
2787
  await this.onWriter(() => this.record({ ...st, phase: st.phase === "copied" ? "copied" : "failed", error: m }));
2425
2788
  return null;
2426
2789
  }
2427
- if (r.kind === "copy")
2790
+ if (kind === "copy")
2428
2791
  await this.copy(t);
2429
2792
  // the whole read is over, whatever comes next: what the table is said to be doing from here (`notStreamedReason`,
2430
2793
  // `refuseRebuilding`, `state`'s `rebuilding`)
@@ -2470,7 +2833,7 @@ export class Sync {
2470
2833
  // mason status — and is caught up again then: what was held is read again by that poll (still behind, nothing lost)
2471
2834
  const failed = (what, e) => {
2472
2835
  const when = this.backOff(t.source, Date.now() - started);
2473
- const m = `${what} (the synced data serves as it was; it is caught up by one poll again after ${when}): ${e instanceof Error ? e.message : String(e)}`;
2836
+ const m = `${what} (the synced data serves as it was; it is caught up by one poll again after ${when}): ${this.errorText(e)}`;
2474
2837
  this.log(`${t.source}: ${m}`);
2475
2838
  this.event({ kind: "catch_up_failed", table: t.source, message: m });
2476
2839
  return this.onWriter(() => this.record({ ...this.states.get(t.source), error: m }));
@@ -2509,10 +2872,10 @@ export class Sync {
2509
2872
  await this.applyChanges(new Map([[t.source, held]]), "held");
2510
2873
  }
2511
2874
  catch (e) {
2512
- const m = e instanceof Error ? e.message : String(e);
2875
+ const m = this.errorText(e);
2513
2876
  this.refreshStreamed();
2514
2877
  // a numeric wider than its copy: copied again (`needsCopy`), and caught up after that copy
2515
- if (m.includes(`${WIDER} [${t.source}]`)) {
2878
+ if (widerIn(e, t.source)) {
2516
2879
  this.snapshots.delete(t.source);
2517
2880
  this.log(`${t.source}: the changes the stream held during its catch-up could not be applied: it is read again whole, wider (${m})`);
2518
2881
  return "copy again";
@@ -2582,6 +2945,8 @@ export class Sync {
2582
2945
  await w.run("ROLLBACK");
2583
2946
  throw e;
2584
2947
  }
2948
+ // committed: a new copy, so a generated table over it is behind from here
2949
+ this.generated.changed(t.source);
2585
2950
  // the copy's view of its visible columns, made again (its own transaction: DuckDB writes one database a transaction)
2586
2951
  await this.kept.swapped(t.source);
2587
2952
  if (o.snapshot !== null) {
@@ -2717,13 +3082,13 @@ export class Sync {
2717
3082
  await w.run("ROLLBACK");
2718
3083
  }
2719
3084
  catch (r) {
2720
- this.log(`the poll's ROLLBACK failed too: ${r instanceof Error ? r.message : String(r)}`);
3085
+ this.log(`the poll's ROLLBACK failed too: ${this.errorText(r)}`);
2721
3086
  }
2722
3087
  // a stage made in the rolled-back transaction went with it
2723
3088
  pieced.length = 0;
2724
3089
  // a numeric wider than its copy: that table is copied again (wider) at the next pass, the others polled
2725
3090
  // matched against the tables' own names (a name may hold a space)
2726
- const wider = e instanceof Error ? tables.find((t) => e.message.includes(`${WIDER} [${t.source}]`))?.source : undefined;
3091
+ const wider = e instanceof Error ? tables.find((t) => widerIn(e, t.source))?.source : undefined;
2727
3092
  if (wider !== undefined && this.snapshots.delete(wider)) {
2728
3093
  this.log(`poll: ${wider} has a numeric value wider than its synced DECIMAL column: it is read again whole at the next pass`);
2729
3094
  return [];
@@ -2774,6 +3139,10 @@ export class Sync {
2774
3139
  for (const { stage } of pieced)
2775
3140
  await w.run(`DROP TABLE IF EXISTS ${stage}`);
2776
3141
  }
3142
+ // committed: a generated table over a table that changed is behind from here
3143
+ for (const f of found)
3144
+ if (f.changed > 0 || f.deleted > 0)
3145
+ this.generated.changed(f.table);
2777
3146
  return found;
2778
3147
  }
2779
3148
  /**
@@ -2840,7 +3209,8 @@ export class Sync {
2840
3209
  // caught up from the copy's snapshot
2841
3210
  const beside = this.replicated(t) ? "beside the stream, which goes on keeping the other tables current: it is streamed once caught up by one poll from the snapshot it was read at"
2842
3211
  : this.mode !== "recopy" ? "beside the other tables' polls: once read, it is caught up by one poll from the snapshot it was read at" : "beside the other tables";
2843
- const again = this.retired.has(t.source) ? `, again (it is ${RETIRED_LAYOUT})` : "";
3212
+ const r = this.retired.get(t.source);
3213
+ const again = r !== undefined ? `, again (it is ${r.layout})` : "";
2844
3214
  this.log(`syncing ${t.source}${again}: ${t.rows === null ? "rows unknown" : `about ${t.rows} row(s)`}, ${t.bytes} source bytes, ${beside}`);
2845
3215
  }
2846
3216
  let read;
@@ -2851,7 +3221,7 @@ export class Sync {
2851
3221
  if (!(e instanceof StartOver))
2852
3222
  throw e;
2853
3223
  // once: a second reason to start over is the copy's failure
2854
- this.log(`starting the sync of ${t.source} over, from its first page: ${e.message}`);
3224
+ this.log(`starting the sync of ${t.source} over, from its first page: ${this.errorText(e)}`);
2855
3225
  kept = await this.forgetCopy(t, side);
2856
3226
  read = await this.readChunks(t, side, null, (p) => { kept = p; });
2857
3227
  }
@@ -2897,7 +3267,7 @@ export class Sync {
2897
3267
  k = await this.forgetCopy(t, side);
2898
3268
  }
2899
3269
  catch (f) {
2900
- this.log(`the sync of ${t.source} could not drop what it began (its next attempt continues it): ${f instanceof Error ? f.message : String(f)}`);
3270
+ this.log(`the sync of ${t.source} could not drop what it began (its next attempt continues it): ${this.errorText(f)}`);
2901
3271
  }
2902
3272
  }
2903
3273
  const through = k === null ? null : readThrough(k.done);
@@ -2910,7 +3280,7 @@ export class Sync {
2910
3280
  await this.record({ ...was, phase: "waiting", error: null }, undefined, w);
2911
3281
  return;
2912
3282
  }
2913
- const m = e instanceof Error ? e.message : String(e);
3283
+ const m = this.errorText(e);
2914
3284
  // tried again after a wait that grows with each failure in a row: a copy failing for good must not read the
2915
3285
  // source at every pass (a 20-minute read of a production replica every 95 minutes) — and then
2916
3286
  // continued where it was, never read again from its first page
@@ -2934,7 +3304,7 @@ export class Sync {
2934
3304
  return { path: dir, freeBytes: this.diskFree(dir), projectedBytes };
2935
3305
  }
2936
3306
  catch (e) {
2937
- return { path: dir, freeBytes: null, projectedBytes, error: `the free space where the synced data is could not be read: ${e instanceof Error ? e.message : String(e)}` };
3307
+ return { path: dir, freeBytes: null, projectedBytes, error: `the free space where the synced data is could not be read: ${this.errorText(e)}` };
2938
3308
  }
2939
3309
  }
2940
3310
  /** The DuckDB file's bytes with its write-ahead log's (what a copy adds lands in the log until a checkpoint). */
@@ -2994,6 +3364,11 @@ export class Sync {
2994
3364
  const named = t.columns.map((c) => ident(c.name)).join(", ");
2995
3365
  const started = Date.now();
2996
3366
  const lanes = [this.rebuilder];
3367
+ // ITS VALUES OUT OF LINE, from the source's catalog (`chunkCeiling`): read once an attempt, before any transaction
3368
+ // of the copy (a begun copy's first read fixes its snapshot); sizing only, so a resumed copy reads it again
3369
+ const outOfLine = await this.interruptible(this.rebuilder, () => this.outOfLineBytes(pgFrom));
3370
+ const limit = this.store.memoryLimitBytes;
3371
+ const ceilingOf = (pages) => chunkCeiling(pages, t.rows, outOfLine, chunkBudgetBytes(limit, this.copyParallel), this.copyChunkRows);
2997
3372
  if (recopy)
2998
3373
  await w.run("BEGIN TRANSACTION");
2999
3374
  try {
@@ -3002,7 +3377,7 @@ export class Sync {
3002
3377
  if (!recopy)
3003
3378
  await w.run("BEGIN TRANSACTION");
3004
3379
  try {
3005
- begun = await this.interruptible(this.rebuilder, () => this.beginCopy(t, side, pgFrom));
3380
+ begun = await this.interruptible(this.rebuilder, () => this.beginCopy(t, side, pgFrom, ceilingOf, outOfLine));
3006
3381
  if (!recopy) {
3007
3382
  await this.writeBegun(t, begun);
3008
3383
  await w.run("COMMIT");
@@ -3023,7 +3398,7 @@ export class Sync {
3023
3398
  // first chunks are a tenth of it, to measure, and every later one is what a reader reads in copyChunkSeconds at
3024
3399
  // the rate measured since — cut from what no committed chunk covers (`copyGaps`, `takeChunk`), so a copy resumed
3025
3400
  // reads exactly the ranges it did not commit, whatever size its chunks were
3026
- const ceiling = chunkPages(p.pages, t.rows, this.copyChunkRows);
3401
+ const ceiling = ceilingOf(p.pages);
3027
3402
  const gaps = copyGaps(p.done);
3028
3403
  let rate = null;
3029
3404
  // the source's page size, for the bytes a second its readers read (pg_relation_size is in bytes; pages are its unit)
@@ -3056,6 +3431,10 @@ export class Sync {
3056
3431
  if (l.inFlight)
3057
3432
  l.conn.interrupt();
3058
3433
  };
3434
+ // OUT OF MEMORY IN A CHUNK (53200, `pgErrorOf`; never 53400, the spill's ceiling, which is disk): its range is given
3435
+ // back (`giveBack`) and the copy reads on with ONE reader (`sole`), each chunk at most half the one that ran out
3436
+ // (`memoryPages`, which only shrinks in an attempt); a one-page chunk read alone that runs out fails the copy
3437
+ let sole = null, memoryPages = Infinity;
3059
3438
  // the chunks since the last minute's line, and how long they took
3060
3439
  let lineAt = Date.now(), chunks = 0, chunkMs = 0;
3061
3440
  const reader = async (lane, k) => {
@@ -3063,8 +3442,10 @@ export class Sync {
3063
3442
  for (;;) {
3064
3443
  if (failure !== null)
3065
3444
  return;
3445
+ if (sole !== null && sole !== k)
3446
+ return;
3066
3447
  const sizedBy = rate;
3067
- const range = takeChunk(gaps, p.pages, nextChunkPages(ceiling, sizedBy, this.copyChunkSeconds));
3448
+ const range = takeChunk(gaps, p.pages, Math.min(memoryPages, nextChunkPages(ceiling, sizedBy, this.copyChunkSeconds)));
3068
3449
  if (range === undefined)
3069
3450
  return;
3070
3451
  const { first, end } = range;
@@ -3072,6 +3453,8 @@ export class Sync {
3072
3453
  const chunkSize = Math.max(1, (end ?? p.pages) - first);
3073
3454
  const slot = { since: Date.now(), pages: chunkSize };
3074
3455
  live.reading.push(slot);
3456
+ // read with no other chunk in flight, by the one reader the copy reads with
3457
+ const alone = live.reading.length === 1 && (sole === k || readers === 1);
3075
3458
  live.untaken = gapPages(gaps, p.pages);
3076
3459
  live.chunkPages = nextChunkPages(ceiling, rate, this.copyChunkSeconds);
3077
3460
  const told = { source: t.source, first, end, reader: k, pages: chunkSize, ceiling, sizedBy };
@@ -3133,13 +3516,47 @@ export class Sync {
3133
3516
  const at = live.reading.indexOf(slot);
3134
3517
  if (at >= 0)
3135
3518
  live.reading.splice(at, 1);
3136
- if (inTx) {
3519
+ // rolled back, so its connection reads on: a ROLLBACK out of memory itself (measured, with other readers'
3520
+ // chunks in flight) is tried again while the others' memory is given back, for about two seconds
3521
+ let rolledBack = !inTx;
3522
+ for (let attempt = 0; inTx && !rolledBack; attempt++) {
3137
3523
  try {
3138
3524
  await c.run("ROLLBACK");
3525
+ rolledBack = true;
3139
3526
  }
3140
3527
  catch (r) {
3141
- this.log(`a chunk of ${t.source}'s sync could not be rolled back after it failed (its connection is closed with the sync): ${r instanceof Error ? r.message : String(r)}`);
3528
+ const said = this.errorText(r);
3529
+ // the engine ended the transaction itself (measured: "cannot rollback - no transaction is active" after a
3530
+ // COMMIT, or a read, out of memory): nothing of the chunk is left to roll back
3531
+ if (endedTransaction(r)) {
3532
+ rolledBack = true;
3533
+ break;
3534
+ }
3535
+ if (attempt < 20 && pgErrorOf(r, "").fields.code === "53200") {
3536
+ await new Promise((ok) => setTimeout(ok, 100));
3537
+ continue;
3538
+ }
3539
+ this.log(`a chunk of ${t.source}'s sync could not be rolled back after it failed (its connection is closed with the sync): ${said}`);
3540
+ break;
3541
+ }
3542
+ }
3543
+ if (failure === null && !recopy && rolledBack && pgErrorOf(e, "").fields.code === "53200") {
3544
+ giveBack(gaps, range);
3545
+ live.untaken = gapPages(gaps, p.pages);
3546
+ if (chunkSize <= 1 && alone) {
3547
+ const rpp = live.rowsPerPage;
3548
+ fail(new Error(tooLargeText(t.source, `one page of its table${rpp === null ? "" : ` (about ${Math.max(1, Math.round(rpp)).toLocaleString("en-US")} row(s))`}, read alone, ran out of memory`, limit)));
3549
+ return;
3142
3550
  }
3551
+ const was = { sole, memoryPages };
3552
+ sole ??= k;
3553
+ memoryPages = Math.max(1, Math.min(memoryPages, Math.floor(chunkSize / 2)));
3554
+ live.readers = 1;
3555
+ live.chunkPages = Math.min(memoryPages, live.chunkPages);
3556
+ if (was.sole === null || was.memoryPages !== memoryPages) {
3557
+ this.log(`a chunk of ${chunkSize} page(s) of ${t.source} ran out of memory: reading on one chunk at a time, ${memoryPages} page(s) each`);
3558
+ }
3559
+ continue;
3143
3560
  }
3144
3561
  fail(e);
3145
3562
  return;
@@ -3169,12 +3586,17 @@ export class Sync {
3169
3586
  l.conn.interrupt(); }, 50);
3170
3587
  try {
3171
3588
  await Promise.all(lanes.map((lane, k) => reader(lane, k)));
3589
+ // a range given back once the other readers had finished: the one reader left reads it
3590
+ while (failure === null && sole !== null && gaps.length > 0)
3591
+ await reader(lanes[sole], sole);
3172
3592
  }
3173
3593
  finally {
3174
3594
  clearInterval(again);
3175
3595
  }
3176
3596
  if (failure !== null)
3177
3597
  throw failure.error;
3598
+ if (gaps.length > 0)
3599
+ throw new Error(`the sync of ${t.source} ended with pages ${JSON.stringify(gaps)} unread (Mason bug: report it, with mason logs)`);
3178
3600
  if (recopy)
3179
3601
  await w.run("COMMIT");
3180
3602
  return { rows: p.rows, seconds: (Date.now() - started) / 1000, progress: p };
@@ -3197,25 +3619,40 @@ export class Sync {
3197
3619
  * extension's REPEATABLE READ snapshot; its xmin is the copy's watermark, as the change reader records one before its
3198
3620
  * copy), the exact numerics' widths, the side table (NO KEY: no PRIMARY KEY or UNIQUE, so no index), and the heap's
3199
3621
  * pages now (`pg_relation_size`, exact where `relpages` is an estimate) */
3200
- async beginCopy(t, side, pgFrom) {
3622
+ async beginCopy(t, side, pgFrom, ceilingOf, outOfLine) {
3201
3623
  const w = this.rebuilder.conn;
3202
3624
  const snapshot = this.mode !== "recopy" ? (await w.runAndReadAll(`SELECT * FROM ${pq(SNAPSHOT_SQL)}`)).getRowsJS()[0][0] : null;
3203
3625
  const described = await w.runAndReadAll(`DESCRIBE SELECT ${t.columns.map((c) => ident(c.name)).join(", ")} FROM ${SRC}.${ident(t.schema)}.${ident(t.table)}`);
3204
3626
  const scanned = new Map(described.getRowsJS().map((r) => [String(r[0]), String(r[1])]));
3627
+ // a domain's column as its base type reads: the extension's scan of the table does not know a domain
3628
+ const domains = t.columns.filter((c) => c.domain === true);
3629
+ if (domains.length > 0) {
3630
+ const read = await w.runAndReadAll(`DESCRIBE SELECT * FROM ${pq(`SELECT ${domains.map((c) => pgColumnSql(c, new Map(), new Set())).join(", ")} FROM ${pgFrom} LIMIT 0`)}`);
3631
+ for (const r of read.getRowsJS())
3632
+ scanned.set(String(r[0]), String(r[1]));
3633
+ }
3205
3634
  // the numerics the extension would read as doubles: their widths, in the copy's snapshot (NUMERIC_TYPE)
3206
3635
  const exact = await this.exactNumerics(t, scanned, pgFrom);
3207
3636
  const defs = t.columns.map((c) => `${ident(c.name)} ${exact.has(c.name) ? `DECIMAL(38,${exact.get(c.name)})` : storedType(c, scanned.get(c.name) ?? "VARCHAR")}`);
3208
3637
  await w.run(`CREATE SCHEMA IF NOT EXISTS ${ident(t.schema)}`);
3209
3638
  await w.run(`CREATE OR REPLACE TABLE ${side} (${defs.join(", ")})`);
3210
3639
  const pages = Number((await w.runAndReadAll(`SELECT * FROM ${pq(`SELECT (pg_relation_size(${pgLiteral(pgFrom)}::regclass) / current_setting('block_size')::int)::bigint`)}`)).getRowsJS()[0][0]);
3211
- const ceiling = chunkPages(pages, t.rows, this.copyChunkRows);
3640
+ const ceiling = ceilingOf(pages);
3641
+ const byBytes = ceiling < chunkPages(pages, t.rows, this.copyChunkRows) && outOfLine !== null
3642
+ ? ` for its ${humanBytes(outOfLine)} of values out of line in ${humanBytes(this.store.memoryLimitBytes)} of memory` : "";
3212
3643
  const firstSize = nextChunkPages(ceiling, null, this.copyChunkSeconds);
3213
3644
  const readers = this.mode === "recopy" ? 1 : Math.min(this.copyParallel, Math.max(1, Math.ceil(pages / firstSize)));
3214
3645
  this.log(`syncing ${t.source}${snapshot === null ? "" : ` as of the source's snapshot ${snapshot}: what commits after it is caught up by one poll once read`}; ` +
3215
- `${pages} page(s) in chunks sized to ~${this.copyChunkSeconds} s of reading each at the rate measured here (MASON_COPY_CHUNK_SECONDS; at most ${ceiling} page(s)), ` +
3646
+ `${pages} page(s) in chunks sized to ~${this.copyChunkSeconds} s of reading each at the rate measured here (MASON_COPY_CHUNK_SECONDS; at most ${ceiling} page(s)${byBytes}), ` +
3216
3647
  `the first of ${firstSize} page(s) to measure it, ${readers === 1 ? "one at a time" : `${readers} at a time (MASON_COPY_PARALLEL), each a backend on the database`}`);
3217
3648
  return { snapshot, columns: columnsKey(t.columns), exact, filenode: null, pages, done: [], rows: 0 };
3218
3649
  }
3650
+ /** THE SOURCE TABLE'S VALUES OUT OF LINE, in bytes: its TOAST table's (`pg_table_size`, no index), 0 with none; the
3651
+ * one reading of it (`chunkCeiling`) */
3652
+ async outOfLineBytes(pgFrom) {
3653
+ const r = await this.rebuilder.conn.runAndReadAll(`SELECT * FROM ${pq(`SELECT COALESCE((SELECT pg_table_size(reltoastrelid) FROM pg_class WHERE oid = ${pgLiteral(pgFrom)}::regclass AND reltoastrelid <> 0), 0)::bigint`)}`);
3654
+ return Number(r.getRowsJS()[0][0]);
3655
+ }
3219
3656
  /** what a copy was begun with, in its beginning's transaction (`CopyProgress`) */
3220
3657
  async writeBegun(t, p) {
3221
3658
  await this.rebuilder.conn.run(`INSERT OR REPLACE INTO ${MASON_SCHEMA}.copy_progress VALUES (${literal(t.source)}, ${p.snapshot === null ? "NULL" : literal(p.snapshot)}, ` +
@@ -3298,9 +3735,52 @@ export class Sync {
3298
3735
  for (const [source] of r.getRowsJS()) {
3299
3736
  if (this.states.get(source)?.phase !== "copied")
3300
3737
  continue;
3301
- this.retired.add(source);
3738
+ this.retired.set(source, { layout: RETIRED_LAYOUT, short: "in an older Mason's layout, key order with an index" });
3302
3739
  this.log(`${source}: it is ${RETIRED_LAYOUT} — its rows are sorted on disk, and only the source has them in its own order, so it is read again whole; its synced data serves as it was until then`);
3303
3740
  }
3741
+ await this.findHeldLayout();
3742
+ }
3743
+ /**
3744
+ * THE COPIES AN OLDER MASON MADE BEFORE A TYPE WAS HELD AS ITS TEXT (`HELD_LAYOUT`), read at `open` from what each copy
3745
+ * physically is, never from its record alone: a record written before `typeKind` was read says neither that a column
3746
+ * is an enum nor how a held one was stored. Each copied column is held to what `storedType` makes of it now: an ENUM is
3747
+ * an enum (its record completed, so its key is the one a fresh read makes, and it is not read again for nothing); a
3748
+ * column held as its text that is not a VARCHAR (a `point` as the extension's STRUCT) marks the table, said by name and
3749
+ * copied again through the normal path (`needsCopy`), serving as it was until its swap.
3750
+ */
3751
+ async findHeldLayout() {
3752
+ const r = await this.store.writer.runAndReadAll(`SELECT schema_name || '.' || table_name, column_name, data_type FROM duckdb_columns() ` +
3753
+ `WHERE database_name = current_database() AND schema_name <> ${literal(MASON_SCHEMA)}`);
3754
+ const physical = new Map();
3755
+ for (const [source, column, type] of r.getRowsJS()) {
3756
+ const m = physical.get(source) ?? new Map();
3757
+ m.set(column, type);
3758
+ physical.set(source, m);
3759
+ }
3760
+ for (const [source, columns] of this.copiedColumns) {
3761
+ const types = physical.get(source);
3762
+ if (this.states.get(source)?.phase !== "copied" || types === undefined || this.retired.has(source))
3763
+ continue;
3764
+ const mis = [];
3765
+ const completed = columns.map((c) => {
3766
+ const type = types.get(c.name);
3767
+ if (type === undefined)
3768
+ return c;
3769
+ if (c.typeKind === undefined && type.startsWith("ENUM("))
3770
+ return { ...c, typeKind: "e" };
3771
+ const hold = holdOf(c.type, c.typeKind);
3772
+ const want = hold === "text" ? "VARCHAR" : hold === "text[]" ? "VARCHAR[]" : null;
3773
+ if (want !== null && type !== want)
3774
+ mis.push(`${c.name} (${c.type})`);
3775
+ return c;
3776
+ });
3777
+ this.copiedColumns.set(source, completed);
3778
+ this.copiedAs.set(source, columnsKey(completed));
3779
+ if (mis.length === 0)
3780
+ continue;
3781
+ this.retired.set(source, { layout: HELD_LAYOUT, short: `in an older Mason's layout, ${mis.join(", ")} not held as the source's text` });
3782
+ this.log(`${source}: it is ${HELD_LAYOUT} (${mis.join(", ")}) — it is read again whole; its synced data serves as it was until then`);
3783
+ }
3304
3784
  }
3305
3785
  /** what a copy of the retired layout left beside the file (in that layout a copy was staged in a file of its own,
3306
3786
  * `<file>.stage-<table>.db`, and partitioned into parquet parts beside it, `….parts`): removed, said */
@@ -3371,7 +3851,7 @@ export class Sync {
3371
3851
  await c.run(sql);
3372
3852
  }
3373
3853
  catch (e) {
3374
- this.log(`recording ${s.source}'s sync state failed: ${e instanceof Error ? e.message : String(e)}`);
3854
+ this.log(`recording ${s.source}'s sync state failed: ${this.errorText(e)}`);
3375
3855
  }
3376
3856
  }
3377
3857
  /** the catalog rebuilt (`schemaBuilt`), after any rebuild already under way */
@@ -3409,11 +3889,24 @@ export class Sync {
3409
3889
  ...(uniqueKeys.length ? { uniqueKeys } : {}),
3410
3890
  ...(t.rowTypeOid !== undefined && columns.length === cols.length ? { rowTypeOid: t.rowTypeOid } : {}) });
3411
3891
  }
3412
- const views = this.views;
3413
- const key = JSON.stringify([tables, views]);
3892
+ // ONE SOURCE OF AN ENUM'S LABELS, the synced table's ENUM: a generated table stores the ENUM it was built with, which
3893
+ // a value added since (ALTER TYPE … ADD VALUE, re-copied) is not in, so its column declares the type by the synced
3894
+ // labels; its own only where no synced column has the type
3895
+ const synced = new Map(tables.flatMap((t) => t.columns.flatMap((x) => x.enumLabels !== undefined && x.spelled !== undefined ? [[x.spelled.replace(/\[\]$/, ""), x.enumLabels]] : [])));
3896
+ // each generated table served, as a copy of its output's types: no key, no joins
3897
+ for (const g of this.generated.served()) {
3898
+ const types = await c.prepare(`SELECT * FROM ${ident(g.schema)}.${ident(g.table)} LIMIT 0`);
3899
+ const duckTypes = new Map(Array.from({ length: types.columnCount }, (_, i) => [types.columnName(i), types.columnType(i)]));
3900
+ types.destroySync();
3901
+ const columns = g.columns.filter((x) => duckTypes.has(x.name)).map((x) => pgColumn({ name: x.name, type: x.type, notNull: false, comment: null }, duckTypes.get(x.name), undefined))
3902
+ .map((x) => { const labels = x.enumLabels !== undefined && x.spelled !== undefined ? synced.get(x.spelled.replace(/\[\]$/, "")) : undefined; return labels === undefined ? x : { ...x, enumLabels: labels }; });
3903
+ tables.push({ name: g.table, schema: g.schema, primaryKey: [], columns, joins: [], ...(g.comment !== null ? { description: g.comment } : {}) });
3904
+ }
3905
+ const views = this.views, collations = this.collations;
3906
+ const key = JSON.stringify([tables, views, collations]);
3414
3907
  if (key !== this.schemaKey) {
3415
3908
  this.schemaKey = key;
3416
- this.currentSchema = { database: this.currentSchema.database, tables, ...(views.length > 0 ? { views } : {}) };
3909
+ this.currentSchema = { database: this.currentSchema.database, tables, ...(views.length > 0 ? { views } : {}), ...(collations.length > 0 ? { collations } : {}) };
3417
3910
  }
3418
3911
  }
3419
3912
  }
@@ -3432,33 +3925,14 @@ async function enumLists(c, table) {
3432
3925
  function pgColumnSql(c, exact, lists) {
3433
3926
  if (exact.has(c.name))
3434
3927
  return `${pgIdent(c.name)}::text AS ${pgIdent(c.name)}`;
3435
- if (KEPT_ARRAYS.has(c.type) || lists.has(c.name))
3928
+ const hold = holdOf(c.type, c.typeKind);
3929
+ if (KEPT_ARRAYS.has(c.type) || lists.has(c.name) || hold === "text[]")
3436
3930
  return `${pgIdent(c.name)}::text[] AS ${pgIdent(c.name)}`;
3437
- return pgIdent(c.name);
3438
- }
3439
- /** the OID family a source type is sent in (text for every character type: DuckDB has one VARCHAR) */
3440
- function sourceOid(spelled) {
3441
- const array = spelled.endsWith("[]");
3442
- const base = spelled.replace(/\[\]$/, "").replace(/\(.*\)/, "").trim();
3443
- const oid = {
3444
- smallint: 21, integer: 23, bigint: 20, real: 700, "double precision": 701, numeric: 1700, text: 25, "character varying": 25, character: 25,
3445
- boolean: 16, uuid: 2950, date: 1082, "time without time zone": 1083, "timestamp without time zone": 1114, "timestamp with time zone": 1184,
3446
- interval: 1186, json: 3802, jsonb: 3802, bytea: 17,
3447
- // held as DuckDB text, declared as themselves: the catalog Postgres resolves their operators (citext's
3448
- // case-insensitive ones) and the wire sends their OID (src/duck/types.ts)
3449
- inet: 25, citext: 25,
3450
- };
3451
- const o = oid[base];
3452
- if (o === undefined)
3453
- return null;
3454
- return array ? -o : o;
3931
+ if (hold === "text")
3932
+ return `${pgIdent(c.name)}::text AS ${pgIdent(c.name)}`;
3933
+ // a domain's value as its base type, which the extension reads
3934
+ return c.domain === true ? `${pgIdent(c.name)}::${c.type} AS ${pgIdent(c.name)}` : pgIdent(c.name);
3455
3935
  }
3456
- /** the Postgres spelling of the wire's OID: what the catalog says when the source's spelling would disagree with the wire */
3457
- const SPELLING_OF_OID = {
3458
- 21: "smallint", 23: "integer", 20: "bigint", 700: "real", 701: "double precision", 1700: "numeric", 25: "text", 16: "boolean", 2950: "uuid",
3459
- 1082: "date", 1083: "time without time zone", 1114: "timestamp without time zone", 1184: "timestamp with time zone", 1186: "interval", 3802: "jsonb", 17: "bytea",
3460
- };
3461
- const ELEMENT_OF_ARRAY = { 1000: 16, 1001: 17, 1016: 20, 1005: 21, 1007: 23, 1009: 25, 1021: 700, 1022: 701, 1182: 1082, 1183: 1083, 1115: 1114, 1185: 1184, 1187: 1186, 1231: 1700, 2951: 2950, 3807: 3802 };
3462
3936
  const API_TYPE = { 21: "int2", 23: "int4", 20: "int8", 700: "float4", 701: "float8", 1700: "numeric", 25: "text", 16: "bool", 2950: "uuid", 1082: "date", 1184: "timestamptz", 3802: "jsonb" };
3463
3937
  /** an ENUM's labels, of the column or of its list's element; null for any other type */
3464
3938
  function enumLabelsOf(t) {
@@ -3466,28 +3940,30 @@ function enumLabelsOf(t) {
3466
3940
  return own instanceof DuckDBEnumType ? own.values : null;
3467
3941
  }
3468
3942
  /**
3469
- * A copied column in the catalog: the SOURCE's spelling (`numeric(12,2)`, `character varying(255)`) wherever the wire
3470
- * sends that family of type, so a tool sees the source's own types; the wire's type where they differ (an unconstrained
3471
- * `numeric` the extension reads as DOUBLE is `double precision`, an enum is `text`).
3943
+ * A copied column in the catalog: ALWAYS the source's type, as the source spells it (`numeric(12,2)`, `character
3944
+ * varying(255)`, `time with time zone`, `point`), so a tool sees the source's own types whatever the copy holds
3945
+ * (src/duck/held-types.ts) — but for the one the copy computes as another: a `numeric` past DuckDB's DECIMAL, read as
3946
+ * DOUBLE, is `double precision` on the catalog as on the wire (the limits `numeric-over-38-digits`, `numeric-array`).
3472
3947
  */
3473
3948
  function pgColumn(c, duck, now) {
3474
3949
  const wire = wireColumn(c.name, duck).oid;
3475
- const element = ELEMENT_OF_ARRAY[wire];
3476
- const wireFamily = element !== undefined ? -element : wire;
3477
- // an array DuckDB holds as VARCHAR[] whose elements the wire writes as the source's type (src/duck/types.ts: a json's
3478
- // text, a jsonb's canonical text, an inet's output, a citext's bytes): declared as the source spells it
3479
- // an enum (or an array of one) DuckDB holds as its ENUM, in the declared order: declared as the source's type, with
3480
- // its labels, so Postgres orders and compares it by them
3950
+ // an enum (or an array of one) DuckDB holds as its ENUM, in the declared order: declared with its labels, so Postgres
3951
+ // orders and compares it by them
3481
3952
  const labels = enumLabelsOf(duck);
3482
- const spelled = labels !== null || sourceOid(c.type) === wireFamily || (wire === 1009 && KEPT_ARRAYS.has(c.type)) ? c.type
3483
- : element !== undefined ? `${SPELLING_OF_OID[element] ?? "text"}[]` : SPELLING_OF_OID[wire] ?? "text";
3953
+ const double = c.type.startsWith("numeric") ? wire === 701 ? "double precision" : wire === 1022 ? "double precision[]" : null : null;
3954
+ const spelled = double ?? c.type;
3955
+ const kind = (now ?? c).typeKind ?? c.typeKind;
3956
+ // a type a database made, held as its text: the catalog declares it as a type with nothing over it
3957
+ const made = c.typeOid !== undefined && labels === null && holdOf(c.type, kind) !== "same" ? kind === "c" ? "row" : true : undefined;
3484
3958
  // `type` is the ClickHouse path's; the catalog reads `spelled`
3485
3959
  const type = API_TYPE[wire] ?? "text";
3486
3960
  const facts = now ?? c;
3487
- return { name: c.name, type, spelled, ...(c.comment ? { description: c.comment } : {}), ...(labels !== null ? { enumLabels: labels } : {}),
3961
+ return { name: c.name, type, spelled, ...(c.comment ? { description: c.comment } : {}), ...(labels !== null ? { enumLabels: labels } : {}), ...(made !== undefined ? { made } : {}),
3488
3962
  ...(facts.notNull ? { notNull: true } : {}), ...(facts.default !== undefined ? { default: facts.default } : {}),
3489
3963
  ...(facts.identity !== undefined ? { identity: facts.identity } : {}), ...(facts.sequence !== undefined ? { sequence: facts.sequence } : {}),
3490
- ...(c.typeOid !== undefined && spelled === c.type ? { typeOid: c.typeOid, ...(c.typeArrayOid !== undefined ? { typeArrayOid: c.typeArrayOid } : {}) } : {}) };
3964
+ ...(facts.collation !== undefined ? { collation: facts.collation } : {}),
3965
+ // a number, whatever spelling the column's record kept (an oid is a string in Postgres's JSON, and a copy's columns are JSON)
3966
+ ...(c.typeOid !== undefined && double === null ? { typeOid: Number(c.typeOid), ...(c.typeArrayOid !== undefined ? { typeArrayOid: Number(c.typeArrayOid) } : {}) } : {}) };
3491
3967
  }
3492
3968
  /** a span as the stream's line says it: `2.1 s`, `3 min`, `2 h` */
3493
3969
  function secondsText(ms) {