@mason-data/cli 0.0.0-stage → 0.7.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 (109) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +268 -2
  3. package/dist/src/api/error.js +18 -0
  4. package/dist/src/api/postgres/catalog-worker.js +92 -0
  5. package/dist/src/api/postgres/catalog.js +477 -0
  6. package/dist/src/api/postgres/encode.js +541 -0
  7. package/dist/src/api/postgres/explain.js +143 -0
  8. package/dist/src/api/postgres/listener.js +60 -0
  9. package/dist/src/api/postgres/parser-worker.js +58 -0
  10. package/dist/src/api/postgres/parser.js +121 -0
  11. package/dist/src/api/postgres/plain.js +33 -0
  12. package/dist/src/api/postgres/protocol.js +404 -0
  13. package/dist/src/api/postgres/schema.js +54 -0
  14. package/dist/src/api/postgres/server.js +1771 -0
  15. package/dist/src/api/postgres/translate.js +248 -0
  16. package/dist/src/api/request-log.js +50 -0
  17. package/dist/src/cdc/pg-wire.js +588 -0
  18. package/dist/src/cdc/pgoutput.js +107 -0
  19. package/dist/src/cdc/poll.js +67 -0
  20. package/dist/src/cdc/source.js +65 -0
  21. package/dist/src/cli/background.js +198 -0
  22. package/dist/src/cli/command.js +173 -0
  23. package/dist/src/cli/commands.js +1277 -0
  24. package/dist/src/cli/control.js +36 -0
  25. package/dist/src/cli/demo.js +149 -0
  26. package/dist/src/cli/dev.js +669 -0
  27. package/dist/src/cli/doctor.js +260 -0
  28. package/dist/src/cli/guides.js +158 -0
  29. package/dist/src/cli/init.js +366 -0
  30. package/dist/src/cli/machine.js +162 -0
  31. package/dist/src/cli/main.js +83 -0
  32. package/dist/src/cli/mcp.js +726 -0
  33. package/dist/src/cli/pg-tls.js +100 -0
  34. package/dist/src/cli/pg-url.js +52 -0
  35. package/dist/src/cli/pin.js +36 -0
  36. package/dist/src/cli/ports.js +118 -0
  37. package/dist/src/cli/preflight.js +347 -0
  38. package/dist/src/cli/project.js +129 -0
  39. package/dist/src/cli/readiness.js +76 -0
  40. package/dist/src/cli/registry.js +220 -0
  41. package/dist/src/cli/replication-checks.js +120 -0
  42. package/dist/src/cli/replication-mode.js +148 -0
  43. package/dist/src/cli/run-server.js +311 -0
  44. package/dist/src/cli/runs.js +120 -0
  45. package/dist/src/cli/security.js +86 -0
  46. package/dist/src/cli/self-signed-cert.js +64 -0
  47. package/dist/src/cli/serve.js +301 -0
  48. package/dist/src/cli/source-edit.js +116 -0
  49. package/dist/src/cli/sources.js +564 -0
  50. package/dist/src/cli/state.js +58 -0
  51. package/dist/src/cli/style.js +187 -0
  52. package/dist/src/cli/supervise.js +248 -0
  53. package/dist/src/cli/sync.js +94 -0
  54. package/dist/src/cli/ui.js +107 -0
  55. package/dist/src/cli/user.js +229 -0
  56. package/dist/src/cli/version.js +23 -0
  57. package/dist/src/demo/seed.js +237 -0
  58. package/dist/src/demo/source.js +655 -0
  59. package/dist/src/duck/analyze.js +113 -0
  60. package/dist/src/duck/cancel.js +49 -0
  61. package/dist/src/duck/distinct-key.js +228 -0
  62. package/dist/src/duck/executor-worker.js +268 -0
  63. package/dist/src/duck/executor.js +567 -0
  64. package/dist/src/duck/explain.js +238 -0
  65. package/dist/src/duck/functions.js +819 -0
  66. package/dist/src/duck/gaps.js +94 -0
  67. package/dist/src/duck/jsonb-order.js +175 -0
  68. package/dist/src/duck/jsonb.js +188 -0
  69. package/dist/src/duck/kept-route.js +299 -0
  70. package/dist/src/duck/kept.js +964 -0
  71. package/dist/src/duck/late.js +221 -0
  72. package/dist/src/duck/libc-order.js +190 -0
  73. package/dist/src/duck/pg-eval-worker.js +34 -0
  74. package/dist/src/duck/pg-eval.js +59 -0
  75. package/dist/src/duck/pgtree.js +378 -0
  76. package/dist/src/duck/plan.js +259 -0
  77. package/dist/src/duck/publication.js +207 -0
  78. package/dist/src/duck/requests.js +209 -0
  79. package/dist/src/duck/resolved.js +248 -0
  80. package/dist/src/duck/rewrite.js +5101 -0
  81. package/dist/src/duck/store.js +907 -0
  82. package/dist/src/duck/stream.js +518 -0
  83. package/dist/src/duck/sync.js +2929 -0
  84. package/dist/src/duck/to-char.js +482 -0
  85. package/dist/src/duck/token.js +31 -0
  86. package/dist/src/duck/types.js +427 -0
  87. package/dist/src/duck/users.js +231 -0
  88. package/dist/src/duck/verified.js +1001 -0
  89. package/dist/src/duck/views.js +150 -0
  90. package/dist/src/lib/database-url.js +74 -0
  91. package/dist/src/lib/did-you-mean.js +43 -0
  92. package/dist/src/lib/dotenv.js +72 -0
  93. package/dist/src/lib/exit-when-written.js +20 -0
  94. package/dist/src/lib/float-equal.js +69 -0
  95. package/dist/src/lib/http-json.js +22 -0
  96. package/dist/src/lib/log.js +161 -0
  97. package/dist/src/lib/pg-numeric.js +489 -0
  98. package/dist/src/lib/port.js +22 -0
  99. package/dist/src/lib/project-file.js +49 -0
  100. package/dist/src/lib/project-files.js +43 -0
  101. package/dist/src/lib/project-version.js +76 -0
  102. package/dist/src/lib/runnable.js +14 -0
  103. package/dist/src/lib/shell-word.js +4 -0
  104. package/dist/src/lib/timezone.js +237 -0
  105. package/dist/src/lib/zone-names.js +114 -0
  106. package/dist/src/web/app.css +76 -0
  107. package/dist/src/web/app.js +491 -0
  108. package/dist/src/web/index.html +44 -0
  109. package/package.json +22 -4
@@ -0,0 +1,964 @@
1
+ import { DUCKDB_VERSION, MASON_SCHEMA } from "./store.js";
2
+ import { createHash } from "node:crypto";
3
+ import { canonicalRead, EQUAL_STEPS, HIDDEN_PREFIX, HIDDEN_RE, hiddenName, isEqualRead, isKeyRead, isOrderRead, KEY_NULL_HEX, KEY_RECOMPUTE_HEX, knownSteps, ORDER_STEPS, pathKey, pathText, readOfKey, VISIBLE_CATALOG } from "./kept-route.js";
4
+ import { JSONB_KEY_FUNCTIONS, KEY_VERSION, libcUncovered, STORED_SUFFIX } from "./jsonb-order.js";
5
+ import { encodeLibcOrder } from "./libc-order.js";
6
+ import { icuNameOf } from "./rewrite.js";
7
+ /** a path read by this many statements in the request log is stored (a larger table's also needs `KEEP_FILL_SHARE`) */
8
+ export const KEEP_AFTER_STATEMENTS = 3;
9
+ /** a path of a SMALL table (`SMALL_FILL_SECONDS`) read by one statement that took this long (ms, the request log's
10
+ * duration: arrival to last row) is stored at once — the cost the feature removes is per row, so one slow read is the
11
+ * evidence where the fill is cheap; the same second the request log's slow statements are listed from
12
+ * (src/cli/run-server.ts SLOW_MS) */
13
+ export const KEEP_AFTER_MS = 1000;
14
+ /**
15
+ * A FILL IS WEIGHED AGAINST THE READS (the test server: one 3.5 s read of a 7-day window started a fill of
16
+ * a 196.5 M-row table — 1 h 34 min, disk-bound, ~2.5 KB read a row, stalling other work — and one-off checks queued
17
+ * eight paths, two on that table). The evidence was one statement's window; the cost is the whole table. A table whose
18
+ * fill is estimated under `SMALL_FILL_SECONDS` keeps the rule above (`KEEP_AFTER_STATEMENTS` statements, or one slower
19
+ * than `KEEP_AFTER_MS`); any other needs `KEEP_AFTER_STATEMENTS` statements whose reads took `KEEP_FILL_SHARE` of the
20
+ * fill's estimate in all, within the request log's window: what the reads cost by the function, over the window, is the
21
+ * evidence the fill will pay back.
22
+ */
23
+ export const SMALL_FILL_SECONDS = 60;
24
+ export const KEEP_FILL_SHARE = 0.1;
25
+ /**
26
+ * A FILL'S ESTIMATE, MODELLED (`fillEstimate`): `FILL_US_PER_ROW` µs a row plus the JSON column's bytes ON DISK a row
27
+ * at `FILL_DISK_BYTES_PER_US` — what a chunk reads of the column, never the text's average length, which priced the
28
+ * test server's fill (4 vCPU, EBS; ~313 B of text a row by a sample of its first rows, 443–580 B on the source) at
29
+ * ~5 µs a row, 16 min 48 s for 196.6 M rows, where it took 18–29 µs a row (3,606 s with the `->` key, 5,640 s before
30
+ * it), reading ~2.5 KB of disk a row filled. A table whose column is not on disk yet (never checkpointed) is priced by
31
+ * its text's bytes, which overstates it (text is several times its compressed size), the safe side.
32
+ *
33
+ * THE FIT: the two measured fills cost about the same a row for different reasons. A laptop (10 cores, NVMe, the file
34
+ * in the page cache), 30 M rows of ~2.3 KB documents, ~329 B a row on disk: 558 s, 18.6 µs a row; a chunk of it loads
35
+ * exactly its rows' share of the column (330–346 B a row, whatever its alignment to the row groups) and costs 0.8 µs a
36
+ * row without the function, 13–15 µs with it — there the function parsing each document is the cost. The test server:
37
+ * 18.35 µs a row, ~2.5 KB of disk read a row — there the disk is. So most of the cost is per row (`FILL_US_PER_ROW`),
38
+ * and the bytes term spans what the server's column may hold on disk, not known here (~260 B a row if its copy is
39
+ * zstd, ~660 B uncompressed, 2.5 KB if every byte the fill read was the column's): the model gives the laptop 472 s
40
+ * (0.85×) and the server 2,980–6,650 s across that range, within 2× of both its fills (tests/duck-json-columns.test.ts
41
+ * holds it). A fill that ran replaces the model (`MEASURED_MIN_ROWS`).
42
+ */
43
+ export const FILL_US_PER_ROW = 13;
44
+ export const FILL_DISK_BYTES_PER_US = 120;
45
+ /**
46
+ * A MEASURED RATE BEATS THE MODEL: once a fill of a JSON column has run (any path of it: the cost is the column's, not
47
+ * the path's), its rows a second (`KeptPath.filled`, the seconds its chunks held the writer) estimate that column's
48
+ * next fill — while that fill filled at least `MEASURED_MIN_ROWS` (a smaller one measures the transactions, not the
49
+ * rows) and the table has at most `MEASURED_SPAN` times the rows it filled (a table grown past that may no longer fit
50
+ * the memory its rows were read from).
51
+ */
52
+ export const MEASURED_MIN_ROWS = 10_000;
53
+ export const MEASURED_SPAN = 10;
54
+ /** the rows a table's JSON column's text bytes a row are sampled over, where its column is not on disk yet (its first
55
+ * rows: never a scan of the table) */
56
+ const SAMPLE_ROWS = 10_000;
57
+ /** how long a table's measurement is kept before it is measured again (a fill of its column ending forgets it) */
58
+ const MEASURE_AGAIN_MS = 10 * 60_000;
59
+ /** rows filled per transaction of a backfill: ~0.1–0.25 s of the function on a laptop (2.6 s a million rows, measured),
60
+ * so the writer's other turns and the event loop wait at most that long */
61
+ export const BACKFILL_ROWS = 50_000;
62
+ /** how often the request log is read for new paths, and backfills continued */
63
+ export const REVIEW_MS = 10_000;
64
+ /** the marked rows whose documents are read to say what they are (`markedText`) */
65
+ const MARKS_READ = 10_000;
66
+ /** every this many reviews, each READY column's unset rows are counted (the detector) */
67
+ const CHECK_EVERY = 6;
68
+ export const PATHS_TABLE = `${MASON_SCHEMA}.json_paths`;
69
+ const PATHS_DDL = `CREATE TABLE IF NOT EXISTS ${PATHS_TABLE} (name VARCHAR PRIMARY KEY, source VARCHAR, column_name VARCHAR, steps VARCHAR, either BOOLEAN, ` +
70
+ `canonical BOOLEAN, as_text BOOLEAN, state VARCHAR, rows_total BIGINT, rows_done BIGINT, statements BIGINT, slowest_ms BIGINT, kept_at TIMESTAMPTZ, ` +
71
+ `ready_at TIMESTAMPTZ, why VARCHAR)`;
72
+ /** what a ledger from before these columns lacks */
73
+ const PATHS_ADDED = ["marked BIGINT", "total_ms BIGINT", "fill_rows BIGINT", "fill_bytes_per_row BIGINT", "fill_seconds DOUBLE", "fill_basis VARCHAR",
74
+ "fill_rows_per_second DOUBLE", "filled_rows BIGINT", "filled_seconds DOUBLE"];
75
+ const PATHS_COLUMNS = "name, source, column_name, steps, either, canonical, as_text, state, rows_total, rows_done, statements, slowest_ms, kept_at, ready_at, why, " +
76
+ "marked, total_ms, fill_rows, fill_bytes_per_row, fill_seconds, fill_basis, fill_rows_per_second, filled_rows, filled_seconds";
77
+ /** the column's marks (src/duck/functions.ts keptJsonValue), as SQL: a NULL answer, and a value computed at read time */
78
+ const NULL_SQL = "chr(0)";
79
+ const RECOMPUTE_SQL = "chr(0) || '!'";
80
+ /**
81
+ * THE SETTING (`MASON_STORE_JSON_PATHS`, for mason start and mason serve): `on` (the default) stores the JSON paths
82
+ * statements keep reading; `off` stores none, and drops what was stored at the next start.
83
+ */
84
+ export const STORE_JSON_PATHS_SETTING = "MASON_STORE_JSON_PATHS";
85
+ export function storeJsonPathsOf(env) {
86
+ const v = (env[STORE_JSON_PATHS_SETTING] ?? "").trim().toLowerCase();
87
+ if (v === "" || v === "on")
88
+ return true;
89
+ if (v === "off")
90
+ return false;
91
+ throw new Error(`${STORE_JSON_PATHS_SETTING}=${JSON.stringify(env[STORE_JSON_PATHS_SETTING])} is not a setting: on (the default) stores the JSON paths statements keep reading ` +
92
+ "as columns of the copy, off stores none (and drops those stored, at this start)");
93
+ }
94
+ const ident = (name) => `"${name.replaceAll('"', '""')}"`;
95
+ const literal = (s) => `'${s.replaceAll("'", "''")}'`;
96
+ const tableOf = (source) => { const at = source.indexOf("."); return `${ident(source.slice(0, at))}.${ident(source.slice(at + 1))}`; };
97
+ /** the value a stored column takes from its JSON column's `value` (a column reference or an expression): the path's
98
+ * `->` answer, never NULL (a NULL document's is `KEPT_NULL` too: the column's NULL is a row not stored yet) */
99
+ export function keptValueSql(p, value) {
100
+ return `coalesce(__mason_json_kept(NULL, ${value}, ${literal(p.steps)}, ${p.either}, ${p.canonical}, false), ${NULL_SQL})`;
101
+ }
102
+ /**
103
+ * A JSONB'S STORED KEY, from its column's `value` (src/duck/kept-route.ts `ORDER_STEPS`): the key the statement would
104
+ * compute (src/duck/rewrite.ts `jsonbKeyCall`), by the same functions (src/duck/jsonb-order.ts `JSONB_KEY_FUNCTIONS`,
105
+ * their stored twins), never NULL — `KEY_NULL_HEX` for a NULL document (the column's NULL is a row not stored yet), and
106
+ * `KEY_RECOMPUTE_HEX` where the key raised, which its read computes (and raises) again.
107
+ */
108
+ export function orderKeySql(c, value) {
109
+ const call = (name, ...args) => `${name}${STORED_SUFFIX}(NULL, ${args.join(", ")})`;
110
+ const key = c.kind === "bytes" ? call(JSONB_KEY_FUNCTIONS.bytes.key, value)
111
+ : call(JSONB_KEY_FUNCTIONS[c.kind].key, value, `list_transform(${call(JSONB_KEY_FUNCTIONS[c.kind].strings, value)}, __mason_s -> icu_sort_key(__mason_s, ${literal(icuNameOf(c))}))`);
112
+ return `CASE WHEN (${value}) IS NULL THEN unhex('${KEY_NULL_HEX}') ELSE coalesce(${key}, unhex('${KEY_RECOMPUTE_HEX}')) END`;
113
+ }
114
+ /**
115
+ * A STORED KEY'S ID in its `steps` (`ORDER_STEPS:<id>`): what its bytes depend on, so a key is read only where the
116
+ * statement's own key would be the same bytes — its collation (a libc one by its measured order's content: a glibc
117
+ * whose order moved is another collation), the key's encoding (`KEY_VERSION`) and the DuckDB whose ICU made its strings'
118
+ * keys (`DUCKDB_VERSION`: an upgrade may bring another ICU). Another id is another column, dropped at `orderBy`.
119
+ */
120
+ export function orderId(c, libc, versions = `k${KEY_VERSION}:${DUCKDB_VERSION}`) {
121
+ if (c.kind === "bytes")
122
+ return `c:${versions}`;
123
+ if (c.kind === "icu")
124
+ return `icu:${c.name}:${versions}`;
125
+ if (libc === undefined)
126
+ throw new Error("a libc collation's order was not read (Mason bug: report it)");
127
+ return `libc:${c.icu}:${createHash("sha256").update(encodeLibcOrder(libc)).digest("hex").slice(0, 16)}:${versions}`;
128
+ }
129
+ /**
130
+ * THE C KEY'S ID, stored under a libc collation for equality (`EQUAL_STEPS:<id>`, src/duck/kept-route.ts): the C
131
+ * collation's order id (`orderId`), so with the key's encoding (`KEY_VERSION`) and the DuckDB (`DUCKDB_VERSION`) in it,
132
+ * as an order key's — a release that changes either stores it again. The libc order is not in it: the C key reads no
133
+ * collation, so a glibc whose order moved keeps it.
134
+ */
135
+ export function equalId(versions) {
136
+ return versions === undefined ? orderId({ kind: "bytes" }, undefined) : orderId({ kind: "bytes" }, undefined, versions);
137
+ }
138
+ /** a stored column's type, and its mark of a value computed at read time, as SQL */
139
+ const columnType = (p) => isKeyRead(p) ? "BLOB" : "VARCHAR";
140
+ const recomputeSql = (p) => isKeyRead(p) ? `unhex('${KEY_RECOMPUTE_HEX}')` : RECOMPUTE_SQL;
141
+ /** what a fill's measured rate is kept by (`estimate`, `lastRate`): the JSON column, and whether the fill computes a path,
142
+ * a jsonb's order key or its C key — a key's function is not a path's (a list of strings, their ICU keys, the key's
143
+ * bytes), nor a libc order's key the C key's (4.7 µs a row against 1.4, docs/DESIGN.md "jsonb's order"), so one's
144
+ * rate is no evidence for another's */
145
+ const measuredKey = (r) => `${r.source}\u0000${r.column}\u0000${isOrderRead(r) ? "order" : isEqualRead(r) ? "equal" : "path"}`;
146
+ /** a read as its stored path's `->` read (src/duck/kept-route.ts pathKey): what the ledger holds */
147
+ export const pathOf = (r) => ({ ...canonicalRead(r), asText: false });
148
+ /** a span of seconds as a person reads it: `4 s`, `9 min 40 s`, `1 h 28 min` */
149
+ export function spanText(seconds) {
150
+ const s = Math.round(seconds);
151
+ if (s < 60)
152
+ return `${seconds < 10 ? seconds.toFixed(1).replace(/\.0$/, "") : s} s`;
153
+ if (s < 3600)
154
+ return `${Math.floor(s / 60)} min${s % 60 ? ` ${s % 60} s` : ""}`;
155
+ return `${Math.floor(s / 3600)} h${Math.round((s % 3600) / 60) ? ` ${Math.round((s % 3600) / 60)} min` : ""}`;
156
+ }
157
+ /** a fill's estimate MODELLED from the table's rows and its JSON column's bytes a row, on disk unless `basis` says its
158
+ * text (`FILL_US_PER_ROW`, `FILL_DISK_BYTES_PER_US`) */
159
+ export function fillEstimate(rows, bytesPerRow, basis = "disk") {
160
+ return { rows, bytesPerRow, seconds: rows * (FILL_US_PER_ROW + bytesPerRow / FILL_DISK_BYTES_PER_US) / 1e6, basis, rowsPerSecond: null };
161
+ }
162
+ /** a fill's estimate from the rate the last fill of its JSON column MEASURED (rows a second) */
163
+ export function measuredEstimate(rows, rowsPerSecond) {
164
+ return { rows, bytesPerRow: 0, seconds: rows / rowsPerSecond, basis: "measured", rowsPerSecond };
165
+ }
166
+ /** what an estimate says it was made from, in the ledger's `why` and so in `mason status` */
167
+ export function estimateText(fill) {
168
+ const rows = `${Math.round(fill.rows).toLocaleString("en-US")} row(s)`;
169
+ const bytes = `~${Math.round(fill.bytesPerRow).toLocaleString("en-US")} bytes`;
170
+ return fill.basis === "measured"
171
+ ? `its fill estimated at ${spanText(fill.seconds)} from the last fill of its column: ${rows} at ${Math.round(fill.rowsPerSecond ?? 0).toLocaleString("en-US")} rows/s`
172
+ : fill.basis === "disk" ? `its fill estimated at ${spanText(fill.seconds)}: ${rows} of ${bytes} of JSON on disk (modelled)`
173
+ : `its fill estimated at ${spanText(fill.seconds)}: ${rows} of ${bytes} of JSON text, not on disk yet (modelled)`;
174
+ }
175
+ /**
176
+ * THE POLICY, ONE PATH (`review`): whether the request log's reads of it justify its fill. `keep`: store it; `held`:
177
+ * the reads would have stored it on a small table, its fill's cost holds it back (a `waiting` row says so); `why` is
178
+ * what the ledger and `mason status` say either way.
179
+ */
180
+ export function decide(c, fill) {
181
+ const cost = estimateText(fill);
182
+ const read = `read by ${c.statements} statement${c.statements === 1 ? "" : "s"}`;
183
+ const often = c.statements >= KEEP_AFTER_STATEMENTS, slow = c.slowestMs >= KEEP_AFTER_MS;
184
+ if (fill.seconds < SMALL_FILL_SECONDS) {
185
+ if (often)
186
+ return { keep: true, held: false, why: `${read} (${cost})` };
187
+ if (slow)
188
+ return { keep: true, held: false, why: `read by a statement that took ${(c.slowestMs / 1000).toFixed(1)} s (${cost})` };
189
+ return { keep: false, held: false, why: `${read} (${cost})` };
190
+ }
191
+ const needMs = KEEP_FILL_SHARE * fill.seconds * 1000;
192
+ const spent = `${read} taking ${spanText(c.totalMs / 1000)} in all`;
193
+ if (often && c.totalMs >= needMs)
194
+ return { keep: true, held: false, why: `${spent}, over ${KEEP_FILL_SHARE * 100} % of ${cost}` };
195
+ return { keep: false, held: often || slow,
196
+ why: `not stored: ${spent}, and ${cost}; stored once ${KEEP_AFTER_STATEMENTS} statements read it for ${spanText(needMs / 1000)} in all` };
197
+ }
198
+ export class KeptJsonPaths {
199
+ store;
200
+ host;
201
+ enabled;
202
+ eager;
203
+ reviewMs;
204
+ backfillRows;
205
+ estimateFor;
206
+ /** every path with a row, by its hidden column's name */
207
+ paths = new Map();
208
+ /** the copies with a view of their visible columns (`VISIBLE_CATALOG`) */
209
+ views = new Set();
210
+ /** where each backfill is (the rowid it continues at) */
211
+ cursors = new Map();
212
+ gen = 0;
213
+ timer = null;
214
+ reviewing = null;
215
+ reviews = 0;
216
+ stopped = false;
217
+ /** the reads already said as not storable (said once a run) */
218
+ refusedSaid = new Set();
219
+ /** the connection the request log is read on (its own: never the writer's turn) */
220
+ reader = null;
221
+ constructor(store, host, options) {
222
+ this.store = store;
223
+ this.host = host;
224
+ this.enabled = options.enabled;
225
+ this.eager = options.eager === true;
226
+ this.reviewMs = options.reviewMs ?? REVIEW_MS;
227
+ this.backfillRows = options.backfillRows ?? BACKFILL_ROWS;
228
+ this.estimateFor = options.estimate;
229
+ this.keepNow = this.enabled && this.eager ? (reads) => this.keepAll(reads) : null;
230
+ }
231
+ keepNow;
232
+ // --------------------------------------------------------------------------------------------- what a plan reads
233
+ ready(asked) {
234
+ if (!this.enabled)
235
+ return null;
236
+ const read = this.own(asked);
237
+ const p = this.paths.get(hiddenName(read));
238
+ return p !== undefined && p.state === "ready" && pathKey(p) === pathKey(read) ? { name: p.name, exact: p.marked === 0 } : null;
239
+ }
240
+ visible() { return this.views; }
241
+ generation() { return this.gen; }
242
+ // --------------------------------------------------------------------------------------------- a jsonb's order
243
+ /** the collation a jsonb's key is stored under (`orderBy`), and its id (`orderId`); null until told */
244
+ order = null;
245
+ /**
246
+ * A READ AS THIS KEEPER STORES IT: a jsonb's order (`ORDER_STEPS`) under the collation it was told; its equality
247
+ * (`EQUAL_STEPS`) as the C key (`equalId`) under a libc collation, and under C or ICU as the ORDER's read — the order's
248
+ * key equates there, so one column serves both and no second is ever made; any other read as it is.
249
+ */
250
+ own(r) {
251
+ if (isEqualRead(r) && this.order !== null && this.order.collation.kind !== "libc")
252
+ return { ...r, steps: `${ORDER_STEPS}:${this.order.id}` };
253
+ if (isEqualRead(r))
254
+ return { ...r, steps: `${EQUAL_STEPS}:${this.order === null ? "?" : equalId()}` };
255
+ return isOrderRead(r) ? { ...r, steps: `${ORDER_STEPS}:${this.order?.id ?? "?"}` } : r;
256
+ }
257
+ /** the steps a stored key would have now (`orderBy`): the order's under the collation told, the C key's under a libc
258
+ * one (none under C or ICU: the order's key serves); null until told */
259
+ nowSteps(p) {
260
+ if (this.order === null)
261
+ return null;
262
+ if (isEqualRead(p))
263
+ return this.order.collation.kind === "libc" ? `${EQUAL_STEPS}:${equalId()}` : null;
264
+ return `${ORDER_STEPS}:${this.order.id}`;
265
+ }
266
+ /** a stored key of the collation statements sort and equate by now (`orderBy`): computed by writes and read by plans */
267
+ current(p) { const now = this.nowSteps(p); return now !== null && p.steps === now; }
268
+ /**
269
+ * THE COLLATION, TOLD (src/duck/store.ts `listener`, once the source's is known): a jsonb's stored key is that
270
+ * collation's. Until then a stored key is written unset and read by no plan (`open`); now the keys of another
271
+ * collation (the source's changed, or its glibc's order moved) are dropped, and those of this one filled where a
272
+ * write left them unset — the backfill's sweep — and ready again.
273
+ */
274
+ async orderBy(collation, libc) {
275
+ if (!this.enabled)
276
+ return;
277
+ const id = orderId(collation, libc);
278
+ await this.host.onWriter(async () => {
279
+ this.order = { id, collation, libc };
280
+ for (const p of [...this.paths.values()].filter((x) => isKeyRead(x) && !this.current(x) && (x.state === "backfilling" || x.state === "ready"))) {
281
+ const was = p.steps.slice(p.steps.indexOf(":") + 1), now = this.nowSteps(p);
282
+ await this.drop(this.store.writer, p, now === null
283
+ ? `the database's collation is no longer a libc one (now ${id}): its order's key equates jsonb, so equality reads that one`
284
+ : `stored under another collation or version (${was}, now ${now.slice(now.indexOf(":") + 1)}); stored again when its reads ask for it`);
285
+ this.host.log(`${p.source}: ${pathText(p)} is no longer stored: ${p.why}`);
286
+ }
287
+ });
288
+ void this.fills();
289
+ }
290
+ /** what a stored column takes from its JSON column's `value`: a path's `->` answer (`keptValueSql`), a jsonb's order
291
+ * key (`orderKeySql` under the collation told), or its C key (`orderKeySql` under C) — unset (NULL) while the key is
292
+ * not the one told (`orderBy`): the sweep fills it */
293
+ valueSql(p, value) {
294
+ if (!isKeyRead(p))
295
+ return keptValueSql(p, value);
296
+ if (!this.current(p))
297
+ return "NULL";
298
+ return orderKeySql(isEqualRead(p) ? { kind: "bytes" } : this.order.collation, value);
299
+ }
300
+ /** every path with a row, for `mason status` */
301
+ status() {
302
+ return [...this.paths.values()].sort((a, b) => a.source.localeCompare(b.source) || pathText(a).localeCompare(pathText(b)))
303
+ .map(({ source, ...p }) => ({ table: source, ...p, queued: p.state === "backfilling" && this.fillingNow !== p.name }));
304
+ }
305
+ // --------------------------------------------------------------------------------------------- what a write names
306
+ /** a table's stored columns (backfilling or ready), with the value each takes from its JSON column (`valueOf`): what a
307
+ * write of whole rows names after the copy's own columns */
308
+ stored(source, valueOf) {
309
+ const kept = this.physical(source);
310
+ return { names: kept.map((p) => p.name), values: kept.map((p) => this.valueSql(p, valueOf(p.column))) };
311
+ }
312
+ /** what an UPDATE that sets `carried` sets too: each stored column read from one of them, from its new value */
313
+ storedSet(source, carried, valueOf) {
314
+ return this.physical(source).filter((p) => carried.includes(p.column)).map((p) => `${ident(p.name)} = ${this.valueSql(p, valueOf(p.column))}`);
315
+ }
316
+ physical(source) {
317
+ return [...this.paths.values()].filter((p) => p.source === source && (p.state === "backfilling" || p.state === "ready")).sort((a, b) => a.name.localeCompare(b.name));
318
+ }
319
+ // --------------------------------------------------------------------------------------------- open and stop
320
+ /** At the sync's open, before the catalog serves anything: the ledger, the views, and what the file holds reconciled
321
+ * with it — or, turned off, every stored column dropped. */
322
+ async open() {
323
+ const w = this.store.writer;
324
+ await w.run(PATHS_DDL);
325
+ for (const c of PATHS_ADDED)
326
+ await w.run(`ALTER TABLE ${PATHS_TABLE} ADD COLUMN IF NOT EXISTS ${c}`);
327
+ const rows = (await w.runAndReadAll(`SELECT name, source, column_name, steps, either, canonical, as_text, state, rows_total, rows_done, statements, slowest_ms, ` +
328
+ `kept_at::VARCHAR, ready_at::VARCHAR, why, marked, total_ms, fill_rows, fill_bytes_per_row, fill_seconds, fill_basis, fill_rows_per_second, filled_rows, filled_seconds ` +
329
+ `FROM ${PATHS_TABLE}`)).getRowsJS();
330
+ const isBasis = (b) => b === "measured" || b === "disk" || b === "text";
331
+ for (const r of rows) {
332
+ const [name, source, column, steps, either, canonical, asText, state, total, done, statements, slowest, keptAt, readyAt, why, marked, totalMs, fillRows, fillBytes, fillSeconds, fillBasis, fillRate, filledRows, filledSeconds] = r;
333
+ this.paths.set(String(name), { name: String(name), source: String(source), column: String(column), steps: String(steps), either: either === true, canonical: canonical === true,
334
+ asText: asText === true, state: String(state), rowsTotal: Number(total ?? 0), rowsDone: Number(done ?? 0), statements: Number(statements ?? 0),
335
+ slowestMs: slowest === null ? null : Number(slowest), keptAt: String(keptAt), readyAt: readyAt === null ? null : String(readyAt), why: String(why ?? ""),
336
+ marked: Number(marked ?? 0), totalMs: Number(totalMs ?? 0),
337
+ // an estimate recorded before its basis was: the text model's
338
+ fill: fillSeconds === null || fillSeconds === undefined ? null : { rows: Number(fillRows ?? 0), bytesPerRow: Number(fillBytes ?? 0), seconds: Number(fillSeconds),
339
+ basis: isBasis(fillBasis) ? fillBasis : "text", rowsPerSecond: fillRate === null || fillRate === undefined ? null : Number(fillRate) },
340
+ filled: filledRows === null || filledRows === undefined || filledSeconds === null || filledSeconds === undefined ? null : { rows: Number(filledRows), seconds: Number(filledSeconds) } });
341
+ }
342
+ // a row of a kind this Mason does not know (another release's: `knownSteps`) is dropped, its column with it (no
343
+ // write here could compute it, and none names it once dropped), and said with its steps: inert, never fatal
344
+ for (const p of this.paths.values()) {
345
+ if (p.state === "dropped" || knownSteps(p.steps))
346
+ continue;
347
+ await this.drop(w, p, `stored by another release as ${p.steps}, a kind this Mason does not know`);
348
+ this.host.log(`${p.source}: dropped the stored ${p.column} of a kind this Mason does not know (${p.steps}); its column goes at this start`);
349
+ }
350
+ // a path an earlier Mason stored in another form (a column per spelling or per `->>`, its rows' marks other): its
351
+ // name is not its path's (`hiddenName`), so it is dropped with its column, and stored again as the policy says
352
+ const earlier = "stored by an earlier Mason in another form; stored again when its reads ask for it";
353
+ for (const p of this.paths.values()) {
354
+ if (p.state === "dropped" || p.state === "waiting" || (p.name === hiddenName(p) && !p.asText))
355
+ continue;
356
+ await this.drop(w, p, earlier);
357
+ }
358
+ // the hidden columns the file holds, by table (side tables of a copy in progress included)
359
+ const held = (await w.runAndReadAll(`SELECT schema_name || '.' || table_name, column_name FROM duckdb_columns() WHERE database_name = current_database() ` +
360
+ `AND schema_name <> ${literal(MASON_SCHEMA)} AND starts_with(column_name, ${literal(HIDDEN_PREFIX)})`)).getRowsJS();
361
+ if (!this.enabled) {
362
+ for (const [table, column] of held)
363
+ await w.run(`ALTER TABLE ${tableOf(table)} DROP COLUMN ${ident(column)}`);
364
+ const n = Number((await w.run(`UPDATE ${PATHS_TABLE} SET state = 'dropped', why = 'stored JSON paths are turned off (storeJsonPaths)' WHERE state <> 'dropped'`)).rowsChanged);
365
+ for (const p of this.paths.values())
366
+ if (p.state !== "dropped")
367
+ Object.assign(p, { state: "dropped", why: "stored JSON paths are turned off (storeJsonPaths)" });
368
+ if (held.length || n)
369
+ this.host.log(`stored JSON paths are turned off (storeJsonPaths): dropped ${held.length} stored column(s) of ${n} path(s); their reads are computed per row again`);
370
+ return;
371
+ }
372
+ // what the ledger and the file disagree on: a column with no row is dropped, a row whose column is gone is dropped
373
+ const named = new Set(held.filter(([t]) => !t.endsWith("__mason_copy")).map(([t, c]) => `${t}\u0000${c}`));
374
+ const live = (column, table) => { const p = this.paths.get(column); return p !== undefined && p.source === table && (p.state === "backfilling" || p.state === "ready"); };
375
+ for (const [table, column] of held) {
376
+ if (table.endsWith("__mason_copy") && live(column, table.slice(0, -"__mason_copy".length)) || HIDDEN_RE.test(column) && live(column, table))
377
+ continue;
378
+ await w.run(`ALTER TABLE ${tableOf(table)} DROP COLUMN ${ident(column)}`);
379
+ this.host.log(`${table}: dropped the column ${column}: no stored JSON path is recorded for it`);
380
+ }
381
+ for (const p of this.paths.values()) {
382
+ if (p.state === "dropped" || p.state === "waiting" || named.has(`${p.source}\u0000${p.name}`))
383
+ continue;
384
+ await this.drop(w, p, "its column is not in the copy");
385
+ }
386
+ // a jsonb's stored key is its collation's, told after this (`orderBy`): until then a write leaves it unset and no
387
+ // plan reads it, so a ready one is filled again where unset first — the backfill's sweep alone (its ranges are done)
388
+ for (const p of this.paths.values()) {
389
+ if (!isKeyRead(p) || p.state !== "ready")
390
+ continue;
391
+ p.state = "backfilling";
392
+ this.cursors.set(p.name, Number.MAX_SAFE_INTEGER);
393
+ }
394
+ await w.run(`ATTACH IF NOT EXISTS ':memory:' AS ${VISIBLE_CATALOG}`);
395
+ for (const source of this.host.served().keys())
396
+ await this.view(source);
397
+ if (this.reviewMs > 0 && !this.eager) {
398
+ this.timer = setInterval(() => { void this.review(); }, this.reviewMs);
399
+ this.timer.unref();
400
+ }
401
+ }
402
+ /** no review starts from now on; resolves once the one under way has ended (it stops at its next chunk) */
403
+ async stop() {
404
+ this.stopped = true;
405
+ if (this.timer !== null) {
406
+ clearInterval(this.timer);
407
+ this.timer = null;
408
+ }
409
+ await this.reviewing; // never rejects: a review says its own failure
410
+ await this.filling; // nor a fill (`fills`)
411
+ this.reader?.closeSync();
412
+ this.reader = null;
413
+ }
414
+ /** a path's row marked dropped, with why (on the caller's connection and turn). Its COLUMN stays in the table until
415
+ * the next start, whose reconciling of the file with the ledger (`open`, "a column with no row") drops it: every
416
+ * write names only the stored columns (`stored`), and DuckDB's DROP COLUMN rewrites the table */
417
+ async drop(c, p, why) {
418
+ await c.run(`UPDATE ${PATHS_TABLE} SET state = 'dropped', why = ${literal(why)} WHERE name = ${literal(p.name)}`);
419
+ if (p.state === "ready")
420
+ this.gen++;
421
+ Object.assign(p, { state: "dropped", why });
422
+ this.cursors.delete(p.name);
423
+ }
424
+ /**
425
+ * A COPY'S VIEW OF ITS VISIBLE COLUMNS (`VISIBLE_CATALOG`): made for every served copy, before the catalog serves it
426
+ * and before any column is stored in it, and made again after every swap (its columns or their types may be other).
427
+ * DuckDB writes one attached database a transaction, so never inside the swap's. A view that cannot be made is said;
428
+ * its table then gets no stored column (`keep`), so nothing of it is hidden that its statements could list.
429
+ */
430
+ async view(source) {
431
+ if (!this.enabled)
432
+ return;
433
+ const at = source.indexOf(".");
434
+ const [schema, table] = [source.slice(0, at), source.slice(at + 1)];
435
+ const w = this.store.writer;
436
+ try {
437
+ const cols = (await w.runAndReadAll(`SELECT column_name FROM duckdb_columns() WHERE database_name = current_database() AND schema_name = ${literal(schema)} ` +
438
+ `AND table_name = ${literal(table)} ORDER BY column_index`)).getRowsJS().map((r) => String(r[0])).filter((c) => !HIDDEN_RE.test(c));
439
+ if (cols.length === 0) {
440
+ this.views.delete(source);
441
+ return;
442
+ }
443
+ const db = String((await w.runAndReadAll("SELECT current_database()")).getRowsJS()[0][0]);
444
+ await w.run(`CREATE SCHEMA IF NOT EXISTS ${VISIBLE_CATALOG}.${ident(schema)}`);
445
+ await w.run(`CREATE OR REPLACE VIEW ${VISIBLE_CATALOG}.${ident(schema)}.${ident(table)} AS SELECT ${cols.map(ident).join(", ")} FROM ${ident(db)}.${ident(schema)}.${ident(table)}`);
446
+ this.views.add(source);
447
+ }
448
+ catch (e) {
449
+ this.views.delete(source);
450
+ this.host.log(`${source}: the view of its columns for statements that list them all could not be made, so none of its JSON paths is stored: ${e instanceof Error ? e.message : String(e)}`);
451
+ }
452
+ }
453
+ // --------------------------------------------------------------------------------------------- what the sync tells it
454
+ /**
455
+ * THE COPY'S SIDE TABLE, BEFORE ITS SWAP (src/duck/sync.ts `copy`, on the rebuilds' connection): every stored column of
456
+ * its table whose JSON column the copy holds, added and filled, so the swapped-in table has them all, whole — never a
457
+ * moment without the column a plan reads. Filled where unset, so a copy resumed after a stop fills what is left. `run`
458
+ * is the sync's interruptible statement (a `mason stop` interrupts it, and the copy resumes).
459
+ */
460
+ async fillSide(c, source, side, columns, run) {
461
+ if (!this.enabled)
462
+ return;
463
+ for (const p of this.physical(source).filter((x) => columns.includes(x.column))) {
464
+ // no default: its NULL is "not stored yet", and an ADD COLUMN writes nothing into the rows (`keep`)
465
+ await c.run(`ALTER TABLE ${side} ADD COLUMN IF NOT EXISTS ${ident(p.name)} ${columnType(p)}`);
466
+ const end = Number((await c.runAndReadAll(`SELECT coalesce(max(rowid), -1) + 1 FROM ${side}`)).getRowsJS()[0][0]);
467
+ for (let lo = 0; lo < end; lo += this.backfillRows) {
468
+ await run(`UPDATE ${side} SET ${ident(p.name)} = ${this.valueSql(p, ident(p.column))} WHERE rowid >= ${lo} AND rowid < ${lo + this.backfillRows} AND ${ident(p.name)} IS NULL`);
469
+ }
470
+ // rows the column cannot answer alone (KEPT_RECOMPUTE) turn its reads to the CASE before the side table serves;
471
+ // counted again once it does (`swapped`)
472
+ const marked = Number((await c.runAndReadAll(`SELECT count(*) FROM ${side} WHERE ${ident(p.name)} = ${recomputeSql(p)}`)).getRowsJS()[0][0]);
473
+ if (marked > p.marked) {
474
+ if (p.marked === 0 && p.state === "ready")
475
+ this.gen++;
476
+ p.marked = marked;
477
+ }
478
+ }
479
+ }
480
+ /** In the swap's transaction (src/duck/sync.ts `swap`): the paths whose JSON column the new copy does not hold are
481
+ * recorded dropped — their column goes with the old table — and forgotten here once it commits (`swapped`). */
482
+ async swapping(c, source, columns) {
483
+ this.gone = [];
484
+ for (const p of this.physical(source).filter((x) => !columns.includes(x.column))) {
485
+ const why = `${p.column} is no longer a column of the copy of ${source}`;
486
+ await c.run(`UPDATE ${PATHS_TABLE} SET state = 'dropped', why = ${literal(why)} WHERE name = ${literal(p.name)}`);
487
+ this.gone.push({ p, why });
488
+ }
489
+ }
490
+ /** what the swap under way drops (`swapping`), applied when it commits (`swapped`) */
491
+ gone = [];
492
+ /** After the swap committed, on the writer's turn: the paths it dropped forgotten, the table's view made again; a
493
+ * backfill of the old table is over — the side table was filled whole (`fillSide`), so the next review finds it ready. */
494
+ async swapped(source) {
495
+ for (const { p, why } of this.gone) {
496
+ if (p.state === "ready")
497
+ this.gen++;
498
+ Object.assign(p, { state: "dropped", why });
499
+ this.cursors.delete(p.name);
500
+ this.host.log(`${source}: ${pathText(p)} is no longer stored: ${why}`);
501
+ }
502
+ this.gone = [];
503
+ await this.view(source);
504
+ for (const p of this.physical(source)) {
505
+ this.cursors.set(p.name, Number.MAX_SAFE_INTEGER);
506
+ await this.settle(p);
507
+ }
508
+ }
509
+ /** A table no longer added, dropped by the sync on the writer's turn: its paths and view go with it. */
510
+ async removed(source) {
511
+ const w = this.store.writer;
512
+ await w.run(`DELETE FROM ${PATHS_TABLE} WHERE source = ${literal(source)}`);
513
+ for (const p of [...this.paths.values()].filter((x) => x.source === source)) {
514
+ if (p.state === "ready")
515
+ this.gen++;
516
+ this.paths.delete(p.name);
517
+ this.cursors.delete(p.name);
518
+ }
519
+ if (this.views.delete(source)) {
520
+ const at = source.indexOf(".");
521
+ await w.run(`DROP VIEW IF EXISTS ${VISIBLE_CATALOG}.${ident(source.slice(0, at))}.${ident(source.slice(at + 1))}`);
522
+ }
523
+ }
524
+ // --------------------------------------------------------------------------------------------- the policy
525
+ /**
526
+ * The request log's reads (`_mason.requests.json_reads`), counted by stored PATH — its `->` and `->>` reads and its
527
+ * spellings alike, each statement once — with the slowest statement's time and their time in all; none while there
528
+ * is no log (a test's sync).
529
+ */
530
+ async readCounts() {
531
+ this.reader ??= await this.store.instance.connect();
532
+ const r = this.reader;
533
+ const there = (await r.runAndReadAll(`SELECT count(*) FROM duckdb_columns() WHERE database_name = current_database() AND schema_name = ${literal(MASON_SCHEMA)} ` +
534
+ `AND table_name = 'requests' AND column_name = 'json_reads'`)).getRowsJS()[0][0];
535
+ if (there === 0n)
536
+ return [];
537
+ const rows = (await r.runAndReadAll(`SELECT read, statement, duration_ms FROM (SELECT DISTINCT unnest(from_json(json_reads, '["VARCHAR"]')) AS read, rowid AS statement, ` +
538
+ `coalesce(duration_ms, 0) AS duration_ms FROM ${MASON_SCHEMA}.requests WHERE json_reads IS NOT NULL)`)).getRowsJS();
539
+ // by path: a statement that read a path twice (its -> and its ->>) counts once
540
+ const by = new Map();
541
+ for (const [key, statement, ms] of rows) {
542
+ const read = readOfKey(key);
543
+ if (read === null)
544
+ continue;
545
+ const path = this.own(pathOf(read)), k = pathKey(path);
546
+ let e = by.get(k);
547
+ if (e === undefined)
548
+ by.set(k, e = { read: path, statements: new Map() });
549
+ e.statements.set(statement, Number(ms));
550
+ }
551
+ return [...by.values()].map(({ read, statements }) => {
552
+ const ms = [...statements.values()];
553
+ return { read, statements: ms.length, slowestMs: Math.max(0, ...ms), totalMs: ms.reduce((a, b) => a + b, 0) };
554
+ });
555
+ }
556
+ /**
557
+ * A PATH'S FILL ESTIMATED: the table's rows at the rate the last fill of the same JSON column measured (`lastRate`),
558
+ * else modelled from the column's bytes ON DISK a row (`diskBytesPerRow`), else — the column not on disk yet — from
559
+ * its text's bytes a row over its first `SAMPLE_ROWS` (never a scan of the table). Kept `MEASURE_AGAIN_MS`, and
560
+ * forgotten when a fill of the column ends.
561
+ */
562
+ measured = new Map();
563
+ async estimate(read) {
564
+ if (this.estimateFor !== undefined)
565
+ return this.estimateFor(read);
566
+ const key = measuredKey(read);
567
+ const was = this.measured.get(key);
568
+ if (was !== undefined && Date.now() - was.at < MEASURE_AGAIN_MS)
569
+ return was.fill;
570
+ this.reader ??= await this.store.instance.connect();
571
+ const target = tableOf(read.source);
572
+ const rows = Number((await this.reader.runAndReadAll(`SELECT count(*) FROM ${target}`)).getRowsJS()[0][0]);
573
+ const rate = this.lastRate(read, rows);
574
+ let fill;
575
+ if (rate !== null)
576
+ fill = measuredEstimate(rows, rate);
577
+ else {
578
+ const disk = await this.diskBytesPerRow(read.source, read.column);
579
+ if (disk !== null)
580
+ fill = fillEstimate(rows, disk, "disk");
581
+ else {
582
+ const text = Number((await this.reader.runAndReadAll(`SELECT coalesce(avg(coalesce(strlen(${ident(read.column)}::VARCHAR), 0)), 0) FROM ` +
583
+ `(SELECT ${ident(read.column)} FROM ${target} LIMIT ${SAMPLE_ROWS})`)).getRowsJS()[0][0]);
584
+ fill = fillEstimate(rows, text, "text");
585
+ }
586
+ }
587
+ this.measured.set(key, { at: Date.now(), fill });
588
+ return fill;
589
+ }
590
+ /** the rows a second of the last fill of a JSON column (any path of it; a jsonb's key by a key's fill alone) that is
591
+ * evidence for a table of `rows` now (`MEASURED_MIN_ROWS`, `MEASURED_SPAN`), or null */
592
+ lastRate(read, rows) {
593
+ let last = null;
594
+ for (const p of this.paths.values()) {
595
+ if (measuredKey(p) !== measuredKey(read) || p.filled === null || p.filled.seconds <= 0 || p.filled.rows < MEASURED_MIN_ROWS)
596
+ continue;
597
+ if (rows > MEASURED_SPAN * p.filled.rows)
598
+ continue;
599
+ if (last === null || (p.readyAt ?? "") > (last.readyAt ?? ""))
600
+ last = p;
601
+ }
602
+ return last === null || last.filled === null ? null : last.filled.rows / last.filled.seconds;
603
+ }
604
+ /**
605
+ * A JSON column's bytes ON DISK a row: the distinct blocks its checkpointed segments occupy (`pragma_storage_info`:
606
+ * a segment's block and its additional ones; a block shared by two segments counted once) at the file's block size,
607
+ * over the rows those segments hold. A metadata read, never a read of the rows. Null where none of it is on disk.
608
+ */
609
+ async diskBytesPerRow(source, column) {
610
+ const r = this.reader;
611
+ const info = `pragma_storage_info(${literal(tableOf(source))})`;
612
+ const [blocks, held] = (await r.runAndReadAll(`SELECT (SELECT count(DISTINCT b) FROM (SELECT unnest(list_prepend(block_id, coalesce(additional_block_ids, []::BIGINT[]))) AS b ` +
613
+ `FROM ${info} WHERE column_name = ${literal(column)} AND persistent AND block_id >= 0)), ` +
614
+ `(SELECT coalesce(sum(count), 0) FROM ${info} WHERE column_name = ${literal(column)} AND persistent AND NOT contains(column_path, ','))`)).getRowsJS()[0].map(Number);
615
+ if (held === 0 || blocks === 0)
616
+ return null;
617
+ const size = Number((await r.runAndReadAll("SELECT block_size FROM pragma_database_size() WHERE database_name = current_database()")).getRowsJS()[0][0]);
618
+ return blocks * size / held;
619
+ }
620
+ /**
621
+ * ONE REVIEW (every `REVIEW_MS`, one at a time, WHATEVER A FILL IS DOING): the request log read again, its reads
622
+ * weighed against their fills (`decide`) — the first the evidence justifies stored (its column added on the writer's
623
+ * turn, between a running fill's chunks, and its fill queued behind that one: `fills`), those the cost holds back
624
+ * recorded `waiting` with their reads and estimate as they are now; the fills started if none is running, never
625
+ * waited for (a review that waited for an hour's fill left every waiting row an hour stale and decided nothing); and,
626
+ * every `CHECK_EVERY` reviews, each READY column's unset and marked rows found (the detector). A failure is said and
627
+ * tried at the next review: the copies serve as they were.
628
+ */
629
+ review() {
630
+ if (!this.enabled || this.stopped)
631
+ return Promise.resolve();
632
+ this.reviewing ??= (async () => {
633
+ try {
634
+ const counts = await this.readCounts();
635
+ for (const c of counts) {
636
+ const p = this.paths.get(hiddenName(c.read));
637
+ if (p !== undefined && p.state !== "waiting") {
638
+ p.statements = c.statements;
639
+ p.slowestMs = c.slowestMs;
640
+ p.totalMs = c.totalMs;
641
+ }
642
+ }
643
+ const candidates = counts.filter((c) => { const p = this.paths.get(hiddenName(c.read)); return (p === undefined || p.state === "dropped" || p.state === "waiting") && this.storable(c.read); })
644
+ .sort((a, b) => b.totalMs - a.totalMs || b.statements - a.statements || pathKey(a.read).localeCompare(pathKey(b.read)));
645
+ let kept = false;
646
+ for (const c of candidates) {
647
+ // (only the reads that would be stored on a small table are measured: never a table per ad-hoc read)
648
+ if (c.statements < KEEP_AFTER_STATEMENTS && c.slowestMs < KEEP_AFTER_MS)
649
+ continue;
650
+ const fill = await this.estimate(c.read);
651
+ const d = decide(c, fill);
652
+ if (d.keep && !kept) {
653
+ await this.keep(c.read, c, fill, d.why);
654
+ kept = true;
655
+ }
656
+ else if (d.held)
657
+ await this.wait(c.read, c, fill, d.why);
658
+ }
659
+ void this.fills();
660
+ if (++this.reviews % CHECK_EVERY === 0)
661
+ for (const p of [...this.paths.values()].filter((x) => x.state === "ready"))
662
+ await this.check(p);
663
+ }
664
+ catch (e) {
665
+ this.host.log(`stored JSON paths: the review failed, and runs again in ${Math.round(this.reviewMs / 1000)} s (the copies serve as they were): ${e instanceof Error ? e.message : String(e)}`);
666
+ }
667
+ finally {
668
+ this.reviewing = null;
669
+ }
670
+ })();
671
+ return this.reviewing;
672
+ }
673
+ /** the fill loop under way (`fills`), and the path it fills now */
674
+ filling = null;
675
+ fillingNow = null;
676
+ /**
677
+ * THE FILLS, ONE PATH AT A TIME (started by a review, or the oracle's `keepAll`; resolves once none is left to run
678
+ * now): every backfilling path, the first added first, each to its end a chunk a writer's turn (`backfill`) — a path
679
+ * added while one fills waits for it, so the writer is never held by two fills' chunks at once and each chunk stays one
680
+ * short transaction. A path paused by its table's rebuild is tried again by the next review. A failure is said, and
681
+ * the next review starts the fills again.
682
+ */
683
+ fills() {
684
+ if (!this.enabled || this.stopped)
685
+ return Promise.resolve();
686
+ if (this.filling !== null)
687
+ return this.filling;
688
+ // set before it runs and cleared by its own end: a loop with nothing to fill ends before its first await, and its
689
+ // end must not run before it is recorded (it would leave a finished loop recorded as running, and no fill after it)
690
+ const run = this.runFills();
691
+ this.filling = run;
692
+ void run.then(() => { if (this.filling === run)
693
+ this.filling = null; });
694
+ return run;
695
+ }
696
+ /** the loop `fills` records: it never rejects (a failure is said) */
697
+ async runFills() {
698
+ const tried = new Set();
699
+ try {
700
+ for (;;) {
701
+ const next = [...this.paths.values()].filter((p) => p.state === "backfilling" && !tried.has(p.name))
702
+ .sort((a, b) => a.keptAt.localeCompare(b.keptAt) || a.name.localeCompare(b.name))[0];
703
+ if (next === undefined || this.stopped)
704
+ break;
705
+ tried.add(next.name);
706
+ this.fillingNow = next.name;
707
+ await this.backfill(next);
708
+ }
709
+ }
710
+ catch (e) {
711
+ this.host.log(`stored JSON paths: a fill failed, and goes on at the next review in ${Math.round(this.reviewMs / 1000)} s (its rows filled so far are kept): ${e instanceof Error ? e.message : String(e)}`);
712
+ }
713
+ finally {
714
+ this.fillingNow = null;
715
+ }
716
+ }
717
+ /** a read whose column can be stored now: its table served, with its view, the column a json or jsonb, no column of
718
+ * its own of the hidden name, and no rebuild of it under way. One that never can be is said once. */
719
+ storable(read) {
720
+ const cols = this.host.served().get(read.source);
721
+ if (cols === undefined || !this.views.has(read.source) || this.host.rebuilding(read.source))
722
+ return false;
723
+ // a jsonb's key waits for its collation (`orderBy`, at the listener's start), and its C key is stored under a libc
724
+ // one alone (`own` folds it into the order's elsewhere): nothing to say
725
+ if (isKeyRead(read) && !this.current(read))
726
+ return false;
727
+ const name = hiddenName(read);
728
+ const col = cols.find((c) => c.name === read.column);
729
+ const why = col === undefined ? `${read.column} is not a column of the copy`
730
+ : !/^jsonb?$/.test(col.type) ? `${read.column} is a ${col.type}, not a json or jsonb`
731
+ : isKeyRead(read) && col.type !== "jsonb" ? `${read.column} is a ${col.type}, which has no order or equality (a jsonb's is stored)`
732
+ : cols.some((c) => c.name.toLowerCase() === name) ? `the copy has a column named ${name} of its own` : null;
733
+ if (why === null)
734
+ return true;
735
+ const key = pathKey(read);
736
+ if (!this.refusedSaid.has(key)) {
737
+ this.refusedSaid.add(key);
738
+ this.host.log(`${read.source}: ${pathText(read)} is not stored: ${why}`);
739
+ }
740
+ return false;
741
+ }
742
+ /** the ledger's row of a path, written whole (on the caller's connection and turn) */
743
+ async record(c, p) {
744
+ const v = (x) => x === null ? "NULL" : String(x);
745
+ await c.run(`INSERT OR REPLACE INTO ${PATHS_TABLE} (${PATHS_COLUMNS}) VALUES (${literal(p.name)}, ${literal(p.source)}, ${literal(p.column)}, ${literal(p.steps)}, ` +
746
+ `${p.either}, ${p.canonical}, false, ${literal(p.state)}, ${p.rowsTotal}, ${p.rowsDone}, ${p.statements}, ${v(p.slowestMs)}, ${literal(p.keptAt)}::TIMESTAMPTZ, ` +
747
+ `${p.readyAt === null ? "NULL" : `${literal(p.readyAt)}::TIMESTAMPTZ`}, ${literal(p.why)}, ${p.marked}, ${p.totalMs}, ${v(p.fill?.rows ?? null)}, ` +
748
+ `${v(p.fill === null ? null : Math.round(p.fill.bytesPerRow))}, ${v(p.fill?.seconds ?? null)}, ${p.fill === null ? "NULL" : literal(p.fill.basis)}, ` +
749
+ `${v(p.fill?.rowsPerSecond ?? null)}, ${v(p.filled?.rows ?? null)}, ${v(p.filled?.seconds ?? null)})`);
750
+ }
751
+ /** a path the reads would store but its fill's cost holds back: its row, `waiting`, says why (written when it says
752
+ * something new), and no column */
753
+ async wait(read, c, fill, why) {
754
+ const name = hiddenName(read);
755
+ const was = this.paths.get(name);
756
+ if (was !== undefined && was.state === "waiting" && was.why === why)
757
+ return;
758
+ const p = { ...pathOf(read), name, state: "waiting", rowsTotal: fill.rows, rowsDone: 0, statements: c.statements, slowestMs: c.slowestMs, totalMs: c.totalMs, fill,
759
+ filled: null, marked: 0, keptAt: new Date().toISOString(), readyAt: null, why };
760
+ await this.host.onWriter(async () => {
761
+ if (this.stopped)
762
+ return;
763
+ const now = this.paths.get(name);
764
+ if (now !== undefined && now.state !== "waiting" && now.state !== "dropped")
765
+ return;
766
+ await this.record(this.store.writer, p);
767
+ this.paths.set(name, p);
768
+ });
769
+ if (was?.state !== "waiting")
770
+ this.host.log(`${read.source}: ${pathText(read)} is ${why}`);
771
+ }
772
+ /**
773
+ * A PATH'S COLUMN ADDED to its table, on the writer's turn, with its row (backfilling): from this turn on every write
774
+ * computes it; its existing rows are the backfill's. With NO DEFAULT: DuckDB's ADD COLUMN writes its
775
+ * default into every row before the turn ends, a NULL one as validity alone — measured on a laptop over 30 M rows,
776
+ * `DEFAULT chr(0)` held the turn 0.7 - 1.0 s (the ALTER computing it, then its COMMIT writing ~4 bytes a row into the
777
+ * file under force_compression), no default 0.1 s and nothing written until a checkpoint; on the test server's
778
+ * 196.5 M-row table the default held the writer 28 s. Still a known cost nothing here shortens: the ALTER reads no
779
+ * block of the table but builds the new column in memory for every row (~4.5 B a row; 30 M rows: 100–330 ms, 135 MB),
780
+ * which the next checkpoint writes (one block a row group under forced zstd), the same on another connection, as
781
+ * JSON, with IF NOT EXISTS or outside this transaction; the test server's 196.6 M rows held the writer 8–17 s
782
+ * (docs/DESIGN.md "Stored JSON paths" 2.; tests/duck-json-columns.test.ts times it).
783
+ */
784
+ async keep(read, c, fill, why) {
785
+ const name = hiddenName(read);
786
+ const kept = await this.host.onWriter(async () => {
787
+ const was = this.paths.get(name);
788
+ if (this.stopped || this.host.rebuilding(read.source) || (was !== undefined && (was.state === "backfilling" || was.state === "ready")))
789
+ return null;
790
+ const w = this.store.writer;
791
+ const target = tableOf(read.source);
792
+ await w.run("BEGIN TRANSACTION");
793
+ try {
794
+ await w.run(`ALTER TABLE ${target} ADD COLUMN ${ident(name)} ${columnType(read)}`);
795
+ const rows = Number((await w.runAndReadAll(`SELECT count(*) FROM ${target}`)).getRowsJS()[0][0]);
796
+ const p = { ...pathOf(read), name, state: "backfilling", rowsTotal: rows, rowsDone: 0, statements: c.statements, slowestMs: c.slowestMs, totalMs: c.totalMs,
797
+ fill, filled: null, marked: 0, keptAt: new Date().toISOString(), readyAt: null, why };
798
+ await this.record(w, p);
799
+ await w.run("COMMIT");
800
+ this.paths.set(name, p);
801
+ this.cursors.set(name, 0);
802
+ // the plans kept are made again: none of them knew the column (a `*` over the table reads its view regardless)
803
+ this.gen++;
804
+ return p;
805
+ }
806
+ catch (e) {
807
+ await w.run("ROLLBACK");
808
+ throw e;
809
+ }
810
+ });
811
+ if (kept !== null)
812
+ this.host.log(`${read.source}: storing ${pathText(read)} as a column (${kept.why}): filling its ${kept.rowsTotal.toLocaleString("en-US")} row(s), then statements read it from there`);
813
+ }
814
+ /** a backfill continued to its end, a chunk a writer's turn (paused while its table is rebuilt: the next review goes
815
+ * on), then the path made ready once no row of it is unset — and read by its column alone once none is marked */
816
+ async backfill(p) {
817
+ // a jsonb's key waits for its collation (`orderBy`): its rows would be filled unset
818
+ if (isKeyRead(p) && !this.current(p))
819
+ return;
820
+ const target = tableOf(p.source);
821
+ const end = await this.host.onWriter(async () => Number((await this.store.writer.runAndReadAll(`SELECT coalesce(max(rowid), -1) + 1 FROM ${target}`)).getRowsJS()[0][0]));
822
+ // what this fill measures: the rows its chunks filled and the time they held the writer (never the waits for its
823
+ // turns, which are the other work's), the rate the next fill of its column is estimated from (`lastRate`)
824
+ let rows = 0, ms = 0;
825
+ for (let lo = this.cursors.get(p.name) ?? 0; lo < end; lo += this.backfillRows) {
826
+ const done = await this.host.onWriter(async () => {
827
+ if (this.stopped || this.host.rebuilding(p.source) || p.state !== "backfilling")
828
+ return false;
829
+ const at = performance.now();
830
+ rows += await this.fillChunk(p, `rowid >= ${lo} AND rowid < ${lo + this.backfillRows} AND `);
831
+ ms += performance.now() - at;
832
+ this.cursors.set(p.name, lo + this.backfillRows);
833
+ return true;
834
+ });
835
+ if (!done)
836
+ return;
837
+ }
838
+ // THE SWEEP: what the ranges did not reach (a row whose rowid moved, a row a write left unset while a jsonb's
839
+ // collation was not told), by the mark itself — a chunk a writer's turn, as the ranges are; the turn whose chunk
840
+ // finds none unset makes the path ready (every write computes it from then on, so none is left unset after it)
841
+ for (;;) {
842
+ const swept = await this.host.onWriter(async () => {
843
+ if (this.stopped || this.host.rebuilding(p.source) || p.state !== "backfilling")
844
+ return "paused";
845
+ if (await this.fillChunk(p, `rowid IN (SELECT rowid FROM ${target} WHERE ${ident(p.name)} IS NULL LIMIT ${this.backfillRows}) AND `) > 0)
846
+ return "more";
847
+ p.marked = await this.markedRows(p);
848
+ Object.assign(p, { state: "ready", readyAt: new Date().toISOString(), filled: rows > 0 && ms > 0 ? { rows, seconds: ms / 1000 } : p.filled });
849
+ await this.record(this.store.writer, p);
850
+ this.cursors.delete(p.name);
851
+ this.measured.delete(measuredKey(p));
852
+ this.gen++;
853
+ return "ready";
854
+ });
855
+ if (swept !== "more")
856
+ break;
857
+ }
858
+ if (p.state === "ready") {
859
+ const took = rows > 0 && ms > 0 ? `, ${rows.toLocaleString("en-US")} by this fill in ${spanText(ms / 1000)}${p.fill === null ? "" : `, estimated at ${spanText(p.fill.seconds)} (${p.fill.basis === "measured" ? "from the last fill" : "modelled"})`}` : "";
860
+ this.host.log(`${p.source}: ${pathText(p)} is stored: statements read it from its column (${p.rowsDone.toLocaleString("en-US")} row(s) filled${took})` +
861
+ (p.marked > 0 ? `; ${await this.markedText(p, p.marked)}` : ""));
862
+ }
863
+ }
864
+ /**
865
+ * WHAT A PATH'S MARKED ROWS ARE, in words (the fill's end, the detector): a jsonb's key under a libc collation is
866
+ * marked where a string holds a character the measured order does not cover — the gap its sort is refused by
867
+ * (`libc-uncovered`), told apart by reading the marked rows' documents (`libcUncovered`, at most `MARKS_READ` of
868
+ * them); any other mark is a row its function could not read, a Mason bug.
869
+ */
870
+ async markedText(p, marked) {
871
+ let uncovered = 0, read = marked;
872
+ const libc = this.order?.libc;
873
+ if (isOrderRead(p) && this.current(p) && libc !== undefined) {
874
+ const docs = await this.host.onWriter(async () => (await this.store.writer.runAndReadAll(`SELECT ${ident(p.column)}::VARCHAR FROM ${tableOf(p.source)} ` +
875
+ `WHERE ${ident(p.name)} = ${recomputeSql(p)} LIMIT ${MARKS_READ}`)).getRowsJS());
876
+ read = docs.length;
877
+ uncovered = docs.filter(([d]) => d !== null && libcUncovered(libc, d)).length;
878
+ }
879
+ const of = read < marked ? ` (of the first ${read.toLocaleString("en-US")} read, of ${marked.toLocaleString("en-US")})` : "";
880
+ const said = [];
881
+ if (uncovered > 0)
882
+ said.push(`${uncovered.toLocaleString("en-US")} row(s) hold a character the database's libc order is not measured for, so a sort of them is refused by name (Mason gap libc-uncovered)`);
883
+ if (read > uncovered)
884
+ said.push(`${(read - uncovered).toLocaleString("en-US")} row(s) its function could not read are computed from their document when read (a Mason bug: report it, with mason logs)`);
885
+ return said.join("; ") + of;
886
+ }
887
+ /** a column's rows marked to be computed at read time (KEPT_RECOMPUTE), on the caller's turn */
888
+ async markedRows(p) {
889
+ return Number((await this.store.writer.runAndReadAll(`SELECT count(*) FROM ${tableOf(p.source)} WHERE ${ident(p.name)} = ${recomputeSql(p)}`)).getRowsJS()[0][0]);
890
+ }
891
+ /** a READY path's marked rows counted again (its table swapped in, the detector): a change of exactness moves the
892
+ * plans' generation, so a plan that read the column alone is made again as the CASE, and the other way */
893
+ async settle(p) {
894
+ if (p.state !== "ready")
895
+ return;
896
+ const marked = await this.markedRows(p);
897
+ if (marked === p.marked)
898
+ return;
899
+ if ((marked === 0) !== (p.marked === 0))
900
+ this.gen++;
901
+ p.marked = marked;
902
+ await this.store.writer.run(`UPDATE ${PATHS_TABLE} SET marked = ${marked} WHERE name = ${literal(p.name)}`);
903
+ }
904
+ /** one chunk of a fill, in a transaction of its own on the writer's turn (the caller's): the rows `where` picks that
905
+ * are unset, filled, and the path's count with them */
906
+ async fillChunk(p, where) {
907
+ const w = this.store.writer;
908
+ await w.run("BEGIN TRANSACTION");
909
+ try {
910
+ const n = Number((await w.run(`UPDATE ${tableOf(p.source)} SET ${ident(p.name)} = ${this.valueSql(p, ident(p.column))} WHERE ${where}${ident(p.name)} IS NULL`)).rowsChanged);
911
+ await w.run(`UPDATE ${PATHS_TABLE} SET rows_done = rows_done + ${n} WHERE name = ${literal(p.name)}`);
912
+ await w.run("COMMIT");
913
+ p.rowsDone += n;
914
+ return n;
915
+ }
916
+ catch (e) {
917
+ await w.run("ROLLBACK");
918
+ throw e;
919
+ }
920
+ }
921
+ /** THE DETECTOR: a READY column's rows still unset (a write that did not compute it: a Mason bug), filled and said;
922
+ * and its marked rows counted again (`settle`) */
923
+ async check(p) {
924
+ const marked = await this.host.onWriter(async () => {
925
+ if (this.stopped || this.host.rebuilding(p.source) || p.state !== "ready")
926
+ return 0;
927
+ const unset = Number((await this.store.writer.runAndReadAll(`SELECT count(*) FROM ${tableOf(p.source)} WHERE ${ident(p.name)} IS NULL`)).getRowsJS()[0][0]);
928
+ if (unset > 0) {
929
+ this.host.log(`${p.source}: ${unset} row(s) were written without the stored value of ${pathText(p)} (a Mason bug: report it, with mason logs); they are filled now`);
930
+ while (await this.fillChunk(p, `rowid IN (SELECT rowid FROM ${tableOf(p.source)} WHERE ${ident(p.name)} IS NULL LIMIT ${this.backfillRows}) AND `) > 0)
931
+ ;
932
+ }
933
+ const was = p.marked;
934
+ await this.settle(p);
935
+ return p.marked > was ? p.marked : 0;
936
+ });
937
+ // (the words read the table again on a writer's turn of their own, never inside the detector's)
938
+ if (marked > 0)
939
+ this.host.log(`${p.source}: ${pathText(p)} has marked rows, read through their document from now on: ${await this.markedText(p, marked)}`);
940
+ }
941
+ /** THE ORACLE'S (`eager`): every read's path stored and filled now, whatever the request log says */
942
+ async keepAll(reads) {
943
+ for (const r of reads) {
944
+ const read = this.own(pathOf(r));
945
+ const name = hiddenName(read);
946
+ if (this.paths.get(name)?.state === "ready" || !this.storable(read))
947
+ continue;
948
+ if (this.paths.get(name)?.state !== "backfilling")
949
+ await this.keep(read, { statements: 0, slowestMs: 0, totalMs: 0 }, null, "stored at once (eager)");
950
+ // the same fills a review starts, waited for: one fill at a time whoever asks
951
+ for (;;) {
952
+ const p = this.paths.get(name);
953
+ if (p?.state !== "backfilling" || this.stopped)
954
+ break;
955
+ const before = p.rowsDone;
956
+ await this.fills();
957
+ // paused by its table's rebuild, or failed (said): no row moved, and the read is the function's until a later plan
958
+ const now = this.paths.get(name);
959
+ if (now?.state === "backfilling" && now.rowsDone === before)
960
+ break;
961
+ }
962
+ }
963
+ }
964
+ }