@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
@@ -0,0 +1,906 @@
1
+ /**
2
+ * GENERATED TABLES: TABLES DECLARED IN SQL, KEPT CURRENT BY MASON (issue 124, docs/DESIGN.md "Generated tables"). A file in
3
+ * `mason/generated_tables/` (src/cli/generated-files.ts) names a table and the SELECT that gives it, and a dashboard reads
4
+ * that table by name instead of repeating the work. This is `kind: scheduled`, by the whole re-run: the SELECT run over
5
+ * one snapshot into a table beside the old one, swapped in, up to `target_lag:` behind its bases. `kind: live` is refused
6
+ * until its write path exists; nothing here ever moves a table from one kind to the other.
7
+ *
8
+ * - WHAT IS CHECKED, AND WHEN: the file's shape when it is read; its SQL (src/duck/generated-plan.ts) once the listener's
9
+ * catalog holds its bases, again whenever that catalog moves, and before anything is built. A refusal stops nothing
10
+ * else: the other tables run on, and a table already deployed keeps serving AND being re-run under the definition it
11
+ * was built from, which the ledger holds (`Deployed`), so a bad edit costs no freshness.
12
+ * - A RUN (`run`), ONE AT A TIME IN THE ONE REBUILD QUEUE, on a connection of its own set up as a session in UTC (a plan
13
+ * read on a connection with DuckDB's own null order sorts otherwise): its snapshot taken on a writer's turn, the table
14
+ * beside the old one written there, then swapped in on another writer's turn, as a copy's swap is. The source is never
15
+ * touched. A failed run leaves the old table serving, says why (never quoting a row: `withoutValues`), and is tried
16
+ * again after the same back-off as a failed copy.
17
+ * - LAG IS THE AGE OF THE OLDEST BASE CHANGE NOT REFLECTED, never the age of the snapshot: over an idle base nothing is
18
+ * behind. Each commit that changed a base marks it (`changed`), in memory and after that commit, so nothing is added to
19
+ * a batch's transaction. A run takes the marks on the same writer's turn it takes its snapshot, so a commit is either
20
+ * in the snapshot or marked for the next run: DuckDB's snapshot is the transaction's first read of a table, never its
21
+ * BEGIN (measured). Marks die with the process: at a start, a base last synced after the table was built counts as
22
+ * changed since then, which at worst costs one re-run. A run starts early enough to end inside its target, by the last
23
+ * run's length; one that outlasts its target is followed by the next at once, and `mason status` says the target is
24
+ * missed.
25
+ * - A CATALOG TABLE, A TABLE TO ROLES: in the catalog as a copy of its output's types (`served`), so statements, EXPLAIN and
26
+ * a role's `tables:` treat it as any other, with no key. Before it is built the catalog has no such relation, and a
27
+ * statement is told so by name (gap `generated-not-built`, src/duck/plan.ts).
28
+ * - A CHAIN (issue 124 stage 2): a table may read another. Parents are built first, and a run of a parent builds every
29
+ * child after it in the same transaction, each child planned over its parents' SIDE tables (src/duck/plan.ts
30
+ * swapRelations), never the served ones, so parent and child are one snapshot; they swap in on ONE writer's turn. A
31
+ * child that comes due while a parent has changes not in it hands its run to that parent (`rootOf`). A child of a
32
+ * parent failed, or refused with nothing deployed, is not run, and says why (`blocker`); a deployed parent refused in its
33
+ * file is re-run from what it was built from, and its children with it. A cycle is refused by name: from the SQL's names when
34
+ * the folder is read (`findCycles`), else from the plan's reads at the check.
35
+ */
36
+ import { createHash } from "node:crypto";
37
+ import { cycleRefusal, isNumeric, planDefinition, readsByName, refusalOf, refusalText, storedColumns, uncheckedRefusal } from "./generated-plan.js";
38
+ import { MASON_SCHEMA, pgErrorOf } from "./store.js";
39
+ import { StatementError } from "./statement.js";
40
+ export const GENERATED_LEDGER = `${MASON_SCHEMA}.generated_tables`;
41
+ const LEDGER_DDL = `CREATE TABLE IF NOT EXISTS ${GENERATED_LEDGER} (name VARCHAR PRIMARY KEY, file VARCHAR, sql VARCHAR, order_by VARCHAR, target_every VARCHAR, target_seconds BIGINT, comment VARCHAR, ` +
42
+ `fingerprint VARCHAR, columns VARCHAR, bases VARCHAR, snapshot_at TIMESTAMPTZ, built_at TIMESTAMPTZ, "rows" BIGINT, seconds DOUBLE, bases_synced VARCHAR)`;
43
+ /** the table a run writes, beside the one served until the swap */
44
+ const SIDE_SUFFIX = "__mason_generated";
45
+ /** how often the folder is read and each table's due time looked at */
46
+ export const REVIEW_MS = 5_000;
47
+ const ident = (name) => `"${name.replaceAll('"', '""')}"`;
48
+ const literal = (s) => `'${s.replaceAll("'", "''")}'`;
49
+ /** A row's values taken out of an error: what a table's failure says is kept and shown, and a value in it would be a row
50
+ * quoted (`invalid input syntax for type integer: "abc"`): quoted text and every number go. */
51
+ export function withoutValues(message) {
52
+ return message.split("\n")[0].replace(/'(?:[^']|'')*'/g, "…").replace(/"(?:[^"]|"")*"/g, "…").replace(/\b\d[\d.:,-]*\b/g, "…");
53
+ }
54
+ /** a definition the write's engine types refuse (`storedColumns`): the table's refusal, with its ways out */
55
+ class RefusedAtWrite extends Error {
56
+ entry;
57
+ refused;
58
+ constructor(entry, refused) { super(refused.map((r) => r.message).join("; ")); this.entry = entry; this.refused = refused; }
59
+ }
60
+ /**
61
+ * A RUN'S FAILURE IN POSTGRES'S WORDS, never the engine's (CLAUDE.md "Nothing a user sees is named for DuckDB"): the ONE
62
+ * mapping a statement on the wire gets (src/duck/store.ts `pgErrorOf`) gives its SQLSTATE, message and Postgres's own
63
+ * DETAIL, and nothing here reads an engine spelling. Said without the run's values (`withoutValues`: Postgres's message
64
+ * may quote one), with no name Mason keeps for itself (`__mason_cell_0`), and the way out its SQLSTATE has.
65
+ */
66
+ export function failureWords(err) {
67
+ const { code, message, detail } = pgErrorOf(err, "").fields;
68
+ // the mapping's own DETAIL is Postgres's; one that carries the engine's text is the engine's, and stays out
69
+ const theirs = detail === undefined || detail.startsWith("the engine") ? "" : ` ${detail}`;
70
+ const way = WAY_OUT[code];
71
+ return `${withoutValues(message)} (SQLSTATE ${code}).${theirs}${way === undefined ? "" : ` ${way}`}`.replace(/__mason_\w+/g, "a value it computes");
72
+ }
73
+ /** what to do about a run's failure, by its SQLSTATE: the data's, so the run is tried again on the back-off meanwhile */
74
+ const WAY_OUT = {
75
+ "22003": "Give the value more digits (a wider type or typmod), cast it to double precision if it need not be exact, or compute it in the statement that reads the synced table.",
76
+ "22012": "Guard the divisor (x / nullif(y, 0)), or compute it in the statement that reads the synced table.",
77
+ "22P02": "Cast only the values that are of that type (a CASE over the text), or compute it in the statement that reads the synced table.",
78
+ };
79
+ /** the engine type of each column a plan's SQL computes, as DuckDB says when it prepares it (`EngineTypes`) */
80
+ async function engineTypes(c, sql) {
81
+ const p = await c.prepare(sql);
82
+ try {
83
+ return new Map(Array.from({ length: p.columnCount }, (_, i) => { const type = p.columnType(i).toString(); return [[p.columnName(i), type], [`#${i}`, type]]; }).flat());
84
+ }
85
+ finally {
86
+ p.destroySync();
87
+ }
88
+ }
89
+ export class GeneratedTables {
90
+ store;
91
+ host;
92
+ reviewMs;
93
+ onStep;
94
+ entries = new Map();
95
+ reader = null;
96
+ planner = null;
97
+ timer = null;
98
+ reviewing = null;
99
+ /** a run's planning in flight: it plans on the listener's catalog, so a stop waits for it before the listener closes */
100
+ planning = null;
101
+ stopped = false;
102
+ /** the run in flight, ended or not (`run`): the next starts after it */
103
+ runs = Promise.resolve();
104
+ constructor(store, host, options = {}) {
105
+ this.store = store;
106
+ this.host = host;
107
+ this.reviewMs = options.reviewMs ?? REVIEW_MS;
108
+ this.onStep = options.onStep ?? (() => undefined);
109
+ }
110
+ /** the ledger read and what a crash left beside it dropped, before the catalog serves anything */
111
+ async open() {
112
+ const w = this.store.writer;
113
+ await w.run(LEDGER_DDL);
114
+ const rows = (await w.runAndReadAll(`SELECT name, file, sql, order_by, target_every, target_seconds, comment, fingerprint, columns, bases, epoch_ms(snapshot_at), epoch_ms(built_at), "rows", seconds, bases_synced FROM ${GENERATED_LEDGER}`)).getRowsJS();
115
+ for (const [name, file, sql, orderBy, every, seconds, comment, fingerprint, columns, bases, snapshotAt, builtAt, count, took, basesSynced] of rows) {
116
+ const at = name.indexOf(".");
117
+ this.entry(name).deployed = { definition: { name, schema: name.slice(0, at), table: name.slice(at + 1), file, sql, orderBy, targetLag: { every, seconds: Number(seconds) }, comment },
118
+ fingerprint, columns: JSON.parse(columns), bases: JSON.parse(bases), basesSynced: basesSynced === null ? {} : JSON.parse(basesSynced), snapshotAt: Number(snapshotAt), builtAt: Number(builtAt), rows: Number(count), seconds: took };
119
+ }
120
+ // marks die with a run: a base whose last sync is not the one the snapshot saw may have changed since. Never "synced
121
+ // after the snapshot": a poll's time is its start, so one that began before the snapshot and committed after it would
122
+ // be missed. A generated base's "last sync" is its own snapshot (`baseSynced`): a parent swapped in alone since the
123
+ // child's run, a new definition's included, leaves the child stale; one built with the child leaves it current
124
+ for (const entry of this.entries.values()) {
125
+ const d = entry.deployed;
126
+ if (d === null)
127
+ continue;
128
+ if (d.bases.some((b) => { const s = this.baseSynced(b); return s === null || d.basesSynced[b] === undefined || d.basesSynced[b] !== s; }))
129
+ entry.dirtySince = d.snapshotAt;
130
+ }
131
+ const left = (await w.runAndReadAll(`SELECT schema_name, table_name FROM duckdb_tables() WHERE database_name = current_database() AND ends_with(table_name, '${SIDE_SUFFIX}')`)).getRowsJS();
132
+ for (const [schema, table] of left)
133
+ await w.run(`DROP TABLE ${ident(schema)}.${ident(table)}`);
134
+ // the run's connection was set up as a session is, by the one set-up (src/duck/store.ts `SessionSetup`)
135
+ await this.host.settle();
136
+ }
137
+ /** a base's last sync, ms; null: not known */
138
+ syncedMs(base) { const s = this.host.syncedAt(base); return s === null ? null : Date.parse(s); }
139
+ /** what a child's ledger row records of a base: a synced table's last sync, a generated table's snapshot (ms, as served
140
+ * now); null: not known */
141
+ baseSynced(base) {
142
+ const p = this.host.added(base) === null ? this.entries.get(base) : undefined;
143
+ return p === undefined ? this.syncedMs(base) : p.deployed?.snapshotAt ?? null;
144
+ }
145
+ /** From here on the folder is read (`read`) and each definition held to the listener's catalog (`planner`). Resolves once
146
+ * they have been looked at, so a start can say what is refused. */
147
+ async start(read, planner) {
148
+ this.reader = read;
149
+ this.planner = planner;
150
+ await this.review();
151
+ this.arm();
152
+ }
153
+ arm() {
154
+ if (this.stopped || this.reviewMs === 0)
155
+ return;
156
+ this.timer = setTimeout(() => { void this.review().finally(() => this.arm()); }, this.reviewMs);
157
+ this.timer.unref();
158
+ }
159
+ /** the timer stopped, and what it was doing ended: the review, and a run's planning, the two that read the listener's
160
+ * catalog (a run past its plan sees `stopped` and ends; its build is the sync's stop to interrupt) */
161
+ async stop() {
162
+ this.stopped = true;
163
+ if (this.timer !== null)
164
+ clearTimeout(this.timer);
165
+ await this.reviewing;
166
+ // settled, not awaited: its failure is the run's to say, or not, as stopped
167
+ if (this.planning !== null)
168
+ await Promise.allSettled([this.planning]);
169
+ }
170
+ entry(name) {
171
+ let e = this.entries.get(name);
172
+ if (e === undefined) {
173
+ e = { name, wanted: null, fileRefused: null, checked: null, reads: [], cycle: null, blocked: null, seen: null, refusals: [], said: null, warnings: [], warned: null, waitingFor: null, deployed: null, dirtySince: null, taken: null, queued: false, running: false, failures: 0, retryAt: 0, error: null };
174
+ this.entries.set(name, e);
175
+ }
176
+ return e;
177
+ }
178
+ // ------------------------------------------------------------------------------------------- what the sync reads
179
+ /** the tables served, as the catalog declares them (src/duck/sync.ts `schemaBuilt`), but one a copy holds the name of */
180
+ served() {
181
+ return [...this.entries.values()].flatMap((e) => e.deployed === null || this.host.added(e.name) !== null ? [] :
182
+ [{ schema: e.deployed.definition.schema, table: e.deployed.definition.table, columns: e.deployed.columns, comment: e.deployed.definition.comment }]);
183
+ }
184
+ /** the tables a role may name, built or not: `columns` none until they are known (src/cli/roles.ts) */
185
+ named() {
186
+ return [...this.entries.values()].filter((e) => this.defines(e.name)).map((e) => e.deployed !== null ? { source: e.name, columns: e.deployed.columns } : { source: e.name, columns: [], pending: true });
187
+ }
188
+ /** `name` is a generated table this folder defines: a file that names it, or a table built from one; never a name
189
+ * whose file is gone and was never built, nor one a file names that is refused before its SQL is read */
190
+ defines(name) {
191
+ const e = this.entries.get(name);
192
+ return e !== undefined && (e.wanted !== null || e.deployed !== null);
193
+ }
194
+ /** a base's commit changed its rows: called on the writer's turn, after that commit */
195
+ changed(base) {
196
+ const now = Date.now();
197
+ for (const e of this.entries.values())
198
+ if (e.dirtySince === null && e.deployed?.bases.includes(base))
199
+ e.dirtySince = now;
200
+ }
201
+ /** how far behind its bases a table is, seconds, across its chain: 0 when nothing is unreflected */
202
+ behind(e, now) {
203
+ const oldest = this.chainOldest(e);
204
+ return oldest === null ? 0 : Math.max(0, (now - oldest) / 1000);
205
+ }
206
+ /** LAG ACROSS A CHAIN: the oldest base change not in `e`'s rows, its own or one a table it is built from has not
207
+ * reflected yet (that one is not in `e` either), anywhere below it; null: none. Measured against the base, never
208
+ * summed: a `1m` table over a `1m` table aims to be 1 minute behind the base, not 2 */
209
+ chainOldest(e, seen = new Set()) {
210
+ seen.add(e.name);
211
+ let oldest = this.oldest(e);
212
+ for (const n of e.deployed?.bases ?? []) {
213
+ const p = this.entries.get(n);
214
+ if (p === undefined || seen.has(n) || this.host.added(n) !== null)
215
+ continue;
216
+ const o = this.chainOldest(p, seen);
217
+ if (o !== null && (oldest === null || o < oldest))
218
+ oldest = o;
219
+ }
220
+ return oldest;
221
+ }
222
+ /** the oldest change not in the table: a run's, until its swap, or one since */
223
+ oldest(e) { return e.taken === null ? e.dirtySince : e.dirtySince === null ? e.taken : Math.min(e.taken, e.dirtySince); }
224
+ stateOf(e) {
225
+ if (e.fileRefused !== null || e.refusals.length > 0)
226
+ return "refused";
227
+ if (e.deployed === null)
228
+ return e.running || e.queued ? "building" : e.error !== null ? "failed" : e.waitingFor !== null || e.blocked !== null ? "waiting" : "building";
229
+ if (e.error !== null)
230
+ return "failed";
231
+ return this.chainOldest(e) !== null ? "behind" : "current";
232
+ }
233
+ status(now = Date.now()) {
234
+ return [...this.entries.values()].map((e) => {
235
+ const d = e.wanted ?? e.deployed?.definition ?? null;
236
+ const refusals = e.fileRefused?.refusals ?? e.refusals;
237
+ const file = e.fileRefused?.file ?? d?.file ?? e.name;
238
+ const target = e.deployed?.definition.targetLag ?? d?.targetLag ?? null;
239
+ const behindSeconds = this.behind(e, now), since = this.chainOldest(e);
240
+ return { table: e.name, file, kind: e.fileRefused !== null ? e.fileRefused.kind : "scheduled", method: "whole", state: this.stateOf(e), targetLag: target?.every ?? null, behindSeconds,
241
+ behindSince: since === null ? null : new Date(since).toISOString(),
242
+ missingTarget: target !== null && behindSeconds > target.seconds, serving: e.deployed !== null,
243
+ lastRun: e.deployed === null ? null : { at: new Date(e.deployed.builtAt).toISOString(), seconds: e.deployed.seconds, rows: e.deployed.rows },
244
+ error: e.error, retryAt: e.error === null ? null : new Date(e.retryAt).toISOString(), refusals: refusals.map((r) => ({ ...r, text: refusalText(file, r) })), waitingFor: this.waitingWords(e), warnings: e.warnings.map((w) => `${file}: ${w}`) };
245
+ }).sort((a, b) => a.table.localeCompare(b.table));
246
+ }
247
+ /** what `e` waits for, as `GeneratedStatus.waitingFor` says it: the table's name, with why it blocks as that table is
248
+ * NOW (refused with nothing deployed, or its last run failed), read here and never stored, so `mason wait` decides
249
+ * from what is so when it reads (src/cli/readiness.ts `generatedReadiness`) */
250
+ waitingWords(e) {
251
+ if (e.waitingFor !== null) {
252
+ const why = this.refusedOrFailed(e.waitingFor);
253
+ return why === null ? e.waitingFor : `${e.waitingFor}, which ${why}`;
254
+ }
255
+ if (e.blocked !== null) {
256
+ const why = this.refusedOrFailed(e.blocked);
257
+ return why === null ? e.blocked : `${e.blocked} ${why}`;
258
+ }
259
+ return null;
260
+ }
261
+ /** the tables as the listener takes the sync's states (`Sync.state`): how EXPLAIN says each is kept current, and what a
262
+ * statement is told of one not built yet (src/duck/plan.ts `missingRelationHint`) */
263
+ copies(now = Date.now()) {
264
+ return this.status(now).map((s) => {
265
+ const lag = s.behindSeconds === 0 ? "nothing behind" : `${Math.round(s.behindSeconds)} s behind`;
266
+ const last = s.lastRun === null ? "never built" : `last re-run ${s.lastRun.at}`;
267
+ const words = s.serving ? `generated from ${s.file}: scheduled, re-run whole, target ${s.targetLag}, ${lag}; ${last}${s.error === null ? "" : `; its last run failed: ${s.error}`}` :
268
+ s.state === "refused" ? `refused: ${s.refusals[0]?.message ?? ""}` : s.state === "failed" ? `its first build failed: ${s.error ?? ""}` : s.state === "waiting" ? `waiting for ${s.waitingFor ?? "a table"}` : "being built";
269
+ return { source: s.table, phase: s.serving ? "copied" : "copying", rows: s.lastRun?.rows ?? null, bytes: null, seconds: s.lastRun?.seconds ?? null, copiedAt: s.lastRun?.at ?? null, error: s.error, kept: { by: "generated", text: words } };
270
+ });
271
+ }
272
+ // ---------------------------------------------------------------------------------------------------- the review
273
+ /** The folder read, each definition held to the catalog, and what is due queued: every `reviewMs`, one at a time. */
274
+ review() {
275
+ this.reviewing ??= this.reviewed().catch((e) => {
276
+ this.host.log(`generated tables: the review failed, and runs again (Mason bug: report it): ${e instanceof Error ? e.stack ?? e.message : String(e)}`);
277
+ }).finally(() => { this.reviewing = null; });
278
+ return this.reviewing;
279
+ }
280
+ async reviewed() {
281
+ if (this.reader === null || this.stopped)
282
+ return;
283
+ const read = this.reader();
284
+ const wanted = new Map(read.definitions.map((d) => [d.name, d])), refused = new Map(read.refused.map((r) => [r.name, r]));
285
+ for (const name of new Set([...wanted.keys(), ...refused.keys()]))
286
+ this.entry(name);
287
+ for (const e of this.entries.values()) {
288
+ e.wanted = wanted.get(e.name) ?? null;
289
+ e.fileRefused = refused.get(e.name) ?? null;
290
+ e.reads = e.wanted === null ? [] : await readsByName(e.wanted.sql);
291
+ }
292
+ this.findCycles();
293
+ for (const e of [...this.entries.values()]) {
294
+ // a stop mid-review: the listener's catalog may be closing, and nothing is planned on it
295
+ if (this.stopped)
296
+ return;
297
+ this.say(e);
298
+ if (e.wanted === null && e.fileRefused === null) {
299
+ await this.remove(e);
300
+ continue;
301
+ }
302
+ await this.check(e);
303
+ if (!e.running)
304
+ this.schedule(e);
305
+ }
306
+ }
307
+ /** a refusal said once, when it starts or changes */
308
+ say(e) {
309
+ const file = e.fileRefused?.file ?? e.wanted?.file ?? e.name, list = e.fileRefused?.refusals ?? e.refusals;
310
+ const words = list.length === 0 ? null : list.map((r) => refusalText(file, r)).join("\n");
311
+ if (words !== e.said) {
312
+ if (words !== null)
313
+ this.host.log(`${words}${e.deployed !== null ? `\n ${e.name} keeps serving what it was last built from.` : ""}`);
314
+ else
315
+ this.host.log(`${file}: no longer refused`);
316
+ e.said = words;
317
+ }
318
+ const warned = e.warnings.length === 0 ? null : e.warnings.map((w) => `${file}: ${w}`).join("\n");
319
+ if (warned !== null && warned !== e.warned)
320
+ this.host.log(warned);
321
+ e.warned = warned;
322
+ }
323
+ /** its file gone: the table goes, unless a run has it (the run's end looks again) or a copy now holds its name */
324
+ async remove(e) {
325
+ if (e.running || e.queued)
326
+ return;
327
+ this.entries.delete(e.name);
328
+ if (e.deployed === null)
329
+ return;
330
+ const d = e.deployed.definition;
331
+ const copy = this.host.added(e.name) !== null;
332
+ await this.host.onWriter(async () => {
333
+ const w = this.store.writer;
334
+ await w.run("BEGIN TRANSACTION");
335
+ try {
336
+ if (!copy)
337
+ await w.run(`DROP TABLE IF EXISTS ${ident(d.schema)}.${ident(d.table)}`);
338
+ await w.run(`DELETE FROM ${GENERATED_LEDGER} WHERE name = ${literal(e.name)}`);
339
+ await w.run("COMMIT");
340
+ }
341
+ catch (x) {
342
+ await w.run("ROLLBACK");
343
+ throw x;
344
+ }
345
+ });
346
+ this.host.log(`${e.name}: its file is gone, so the generated table is dropped`);
347
+ await this.host.rebuildCatalog();
348
+ }
349
+ /** the generated tables `n` reads: by its SQL's names, and by what the plan said where it was checked; what it was
350
+ * built from where its file is gone or refused */
351
+ readsOf(n, names) {
352
+ const e = this.entries.get(n);
353
+ if (e === undefined)
354
+ return [];
355
+ // what the plan read, only while `wanted` is the definition it planned: an edit's reads are its names until checked
356
+ const planned = e.checked !== null && e.wanted !== null && e.seen?.json === JSON.stringify(e.wanted) ? e.checked.bases : [];
357
+ return [...new Set([...e.reads, ...planned, ...(e.wanted === null ? e.deployed?.bases ?? [] : [])])].filter((x) => names.has(x));
358
+ }
359
+ /** the shortest way from `self` through what each reads back to `self`, as the tables in order (self first and last) */
360
+ cycleOf(self, reads) {
361
+ const from = new Map(), queue = [self];
362
+ while (queue.length > 0) {
363
+ const n = queue.shift();
364
+ for (const d of reads(n)) {
365
+ if (d === self) {
366
+ const back = [];
367
+ for (let at = n; at !== self; at = from.get(at))
368
+ back.push(at);
369
+ return [self, ...back.reverse(), self];
370
+ }
371
+ if (!from.has(d)) {
372
+ from.set(d, n);
373
+ queue.push(d);
374
+ }
375
+ }
376
+ }
377
+ return null;
378
+ }
379
+ /** each definition whose SQL's names read itself, through others or not: refused before its check */
380
+ findCycles() {
381
+ const names = this.names("");
382
+ for (const e of this.entries.values()) {
383
+ const cycle = e.wanted === null || !names.has(e.name) ? null : this.cycleOf(e.name, (n) => this.readsOf(n, names));
384
+ e.cycle = cycle === null ? null : cycleRefusal(cycle);
385
+ }
386
+ }
387
+ /** `wanted` held to the catalog as it is now: once per definition and per catalog */
388
+ async check(e) {
389
+ const planner = this.planner, schema = this.host.schema();
390
+ if (planner === null)
391
+ return;
392
+ const def = e.wanted;
393
+ if (def === null) {
394
+ e.checked = null;
395
+ e.seen = null;
396
+ e.refusals = [];
397
+ e.waitingFor = null;
398
+ return;
399
+ }
400
+ const held = this.host.added(e.name);
401
+ if (held !== null) {
402
+ e.checked = null;
403
+ e.seen = null;
404
+ e.waitingFor = null;
405
+ await this.release(e);
406
+ e.refusals = [{ key: null, message: `${e.name} is a ${held === "view" ? "view of the database" : "table the sources file syncs"}`, why: "A name holds one table.", options: ["rename this file", "or take the table out of the sources file"] }];
407
+ this.say(e);
408
+ return;
409
+ }
410
+ if (e.cycle !== null) {
411
+ e.checked = null;
412
+ e.seen = null;
413
+ e.waitingFor = null;
414
+ e.refusals = [e.cycle];
415
+ this.say(e);
416
+ return;
417
+ }
418
+ // a parent's target_lag is held against this one's: looked at again when any table's target moves
419
+ const json = JSON.stringify([def, [...this.entries.values()].map((p) => [p.name, this.targetOf(p)?.seconds ?? null])]);
420
+ if (e.seen?.json === json && e.seen.schema === schema)
421
+ return;
422
+ e.seen = { json, schema };
423
+ try {
424
+ // on the check's own connection: a run may hold the run's in a transaction now (`checkConnection`)
425
+ const c = this.host.checkConnection();
426
+ await this.host.settleCheck();
427
+ const r = await planDefinition(planner, def.sql, def.orderBy, undefined, (sql) => engineTypes(c, sql));
428
+ e.waitingFor = null;
429
+ e.warnings = "refused" in r ? [] : r.definition.warnings;
430
+ // what its names did not show (a name the plan resolved otherwise): a cycle by what the plan reads
431
+ const names = this.names(e.name);
432
+ const cycle = "refused" in r ? null : this.cycleOf(e.name, (n) => n === e.name ? r.definition.bases.filter((x) => names.has(x)) : this.readsOf(n, names));
433
+ if ("refused" in r) {
434
+ e.checked = null;
435
+ e.refusals = r.refused;
436
+ }
437
+ else if (cycle !== null) {
438
+ e.checked = null;
439
+ e.refusals = [cycleRefusal(cycle)];
440
+ }
441
+ else if (this.fresherThan(def, r.definition.bases, names) !== null) {
442
+ e.checked = null;
443
+ e.refusals = [this.fresherThan(def, r.definition.bases, names)];
444
+ }
445
+ else {
446
+ const fingerprint = createHash("sha256").update(JSON.stringify([def.sql, def.orderBy, r.definition.columns.map((c) => [c.name, c.type, c.duck])])).digest("hex");
447
+ e.checked = { definition: def, fingerprint, bases: r.definition.bases };
448
+ e.refusals = [];
449
+ }
450
+ }
451
+ catch (x) {
452
+ // NO DEFINITION LEAVES THE CHECK WITHOUT A VERDICT: what Mason did not expect while checking one is that table's
453
+ // refusal, in words a user reads, and the review goes on to the others. Thrown up, it stopped the review at this
454
+ // table, ran it again for ever, and left the table `building` with no reason (PR 202 round 8). What it said goes to
455
+ // the log once, for the report; the refusal stands until the definition or the catalog changes (`seen`)
456
+ if (!(x instanceof StatementError)) {
457
+ e.checked = null;
458
+ e.waitingFor = null;
459
+ e.warnings = [];
460
+ e.refusals = [uncheckedRefusal()];
461
+ this.host.log(`${def.file}: Mason could not check this definition (Mason bug: report it, with this line and the file's SQL): ${x instanceof Error ? x.stack ?? x.message : String(x)}`);
462
+ this.say(e);
463
+ return;
464
+ }
465
+ e.checked = null;
466
+ const missing = x.fields.code === "42P01" ? /^relation "([^"]+)" does not exist$/.exec(x.fields.message)?.[1] : undefined;
467
+ const base = missing === undefined ? undefined : missing.includes(".") ? missing : `public.${missing}`;
468
+ const adding = base === undefined ? null : this.host.added(base);
469
+ // A 42P01 IS THE CATALOG'S IT WAS PLANNED ON (issue 387): a table the sources file adds is waited for until the
470
+ // catalog holds it (`adding`), and also when the catalog moved while this was planned, since the host's answer is
471
+ // the new catalog's, which may hold a table the plan's did not (the demo's start: orders synced and in the catalog
472
+ // while the check's plan, begun before, waited for the catalog Postgres to start without it). `seen` keeps the
473
+ // schema the plan was made on, so the next review checks it again over the one that holds it
474
+ const moved = this.host.schema() !== schema;
475
+ // a parent not built yet: waited for by name; why it is refused or its build failed is read when it is said
476
+ const parent = x.gap === "generated-not-built" ? /the generated table (\S+) is not built yet/.exec(x.fields.message)?.[1] : undefined;
477
+ // A GENERATED BASE IS THERE WHEN THE CATALOG HOLDS IT TOO (issue 474): a run sets its table deployed, so serving,
478
+ // BEFORE its rebuild of the catalog (which reads `served()`, so it must), and that rebuild waits behind any other.
479
+ // A 42P01 for a table this folder defines is the catalog the plan was made on lacking it, whatever its state:
480
+ // waited for by name, and the parent's own trouble read where it is said (`waitingWords`). A name nobody defines
481
+ // (no file, or its file gone) stays Postgres's refusal
482
+ const generated = base !== undefined && adding === null && base !== e.name && this.defines(base);
483
+ if (adding === "adding" || (adding === "copied" && moved)) {
484
+ e.waitingFor = base;
485
+ e.refusals = [];
486
+ }
487
+ else if (parent !== undefined) {
488
+ e.waitingFor = parent;
489
+ e.refusals = [];
490
+ }
491
+ else if (generated) {
492
+ e.waitingFor = base;
493
+ e.refusals = [];
494
+ }
495
+ else {
496
+ e.waitingFor = null;
497
+ e.refusals = [refusalOf(x)];
498
+ }
499
+ }
500
+ this.say(e);
501
+ }
502
+ /** the `target_lag` a table is kept to: its file's, else what it was built from. A table refused in its file but
503
+ * deployed runs at what it was built from, so that is what a child is held to, never the refused file's */
504
+ targetOf(e) {
505
+ const running = e.fileRefused !== null || e.refusals.length > 0 ? e.deployed?.definition.targetLag : undefined;
506
+ return running ?? e.wanted?.targetLag ?? e.deployed?.definition.targetLag ?? null;
507
+ }
508
+ /** A CHILD'S TARGET AGAINST ITS PARENT'S: a table can be no fresher than a generated table it reads, so a `target_lag`
509
+ * shorter than a parent's is refused by name, with the way out; null: none is */
510
+ fresherThan(def, bases, names) {
511
+ for (const n of bases) {
512
+ const p = this.entries.get(n), target = p === undefined || !names.has(n) ? null : this.targetOf(p);
513
+ if (target === null || def.targetLag.seconds >= target.seconds)
514
+ continue;
515
+ return { key: "target_lag", message: `target_lag ${def.targetLag.every} is shorter than that of ${n}, which it reads (${target.every})`,
516
+ why: "A table can be no fresher than a generated table it reads: its rows wait for the parent's run.",
517
+ options: [`set target_lag: ${target.every} or longer`, `or shorten ${n}'s target_lag to ${def.targetLag.every}`] };
518
+ }
519
+ return null;
520
+ }
521
+ /** the generated tables' names, itself among them: a file refused for holding a synced table's name names no generated
522
+ * table */
523
+ names(self) { return new Set([...this.entries.keys(), self].filter((n) => n !== "" && this.host.added(n) === null)); }
524
+ /** why a parent blocks what reads it: refused with nothing deployed, or its last run failed; null: neither. A parent
525
+ * refused in its file but deployed is re-run from what it was built from, and feeds its children from that */
526
+ refusedOrFailed(name) {
527
+ const p = this.entries.get(name);
528
+ if (p === undefined)
529
+ return null;
530
+ if ((p.fileRefused !== null || p.refusals.length > 0) && p.deployed === null)
531
+ return "is refused";
532
+ return p.error === null ? null : "failed its last run";
533
+ }
534
+ /** the generated tables `e` is built from now */
535
+ parentsOf(e) {
536
+ const b = this.buildable(e);
537
+ return b === null ? [] : b.bases.filter((x) => this.entries.has(x) && this.host.added(x) === null);
538
+ }
539
+ /** the table that keeps `e` from being run: a parent, or a parent's parent, refused or failed; null: nothing blocks it */
540
+ blocker(e, seen = new Set()) {
541
+ seen.add(e.name);
542
+ for (const p of this.parentsOf(e)) {
543
+ if (seen.has(p))
544
+ continue;
545
+ if (this.refusedOrFailed(p) !== null)
546
+ return p;
547
+ const up = this.blocker(this.entries.get(p), seen);
548
+ if (up !== null)
549
+ return up;
550
+ }
551
+ return null;
552
+ }
553
+ /** `e` not run because of the table `by`, said once per table */
554
+ block(e, by) {
555
+ if (e.blocked !== by)
556
+ this.host.log(`${e.name}: not run, because ${by} ${this.refusedOrFailed(by) ?? "is not ready"}`);
557
+ e.blocked = by;
558
+ }
559
+ /** a base is there to read: a synced table synced, or a generated table built */
560
+ ready(name) {
561
+ const held = this.host.added(name);
562
+ return held === "copied" || (held === null && this.entries.get(name)?.deployed != null);
563
+ }
564
+ /** whom a run of `e` belongs to: the farthest parent with changes not in it, which builds `e` after it */
565
+ rootOf(e, seen = new Set()) {
566
+ seen.add(e.name);
567
+ for (const n of this.parentsOf(e)) {
568
+ const p = this.entries.get(n);
569
+ if (seen.has(n) || p.deployed === null || this.oldest(p) === null || p.running || Date.now() < p.retryAt)
570
+ continue;
571
+ return this.rootOf(p, seen);
572
+ }
573
+ return e;
574
+ }
575
+ /** what a run of `root` builds, parents before children: `root`, then every table that reads one of them whose other
576
+ * parents are built and blocked by nothing. Not the children of a new definition: theirs is a plan over what the
577
+ * catalog says once it is swapped in. A parent refused in its file runs its deployed definition, and carries them */
578
+ unitOf(root) {
579
+ const now = Date.now(), set = new Map([[root.name, root]]);
580
+ // a table of this run feeds its children unless it is a new definition: refused in its file, it is re-run from what it
581
+ // was built from, which the catalog and its children already hold (its own failure is what this run tries again)
582
+ const feeds = (p) => p.deployed !== null && p.deployed.fingerprint === this.buildable(p)?.fingerprint;
583
+ for (let grew = true; grew;) {
584
+ grew = false;
585
+ for (const c of this.entries.values()) {
586
+ if (set.has(c.name) || c.running || this.buildable(c) === null || now < c.retryAt)
587
+ continue;
588
+ const parents = this.parentsOf(c);
589
+ if (!parents.some((x) => set.has(x)))
590
+ continue;
591
+ if (parents.some((x) => set.has(x) ? !feeds(set.get(x)) : !this.ready(x) || this.refusedOrFailed(x) !== null))
592
+ continue;
593
+ set.set(c.name, c);
594
+ grew = true;
595
+ }
596
+ }
597
+ const order = [], placed = new Set(), members = [...set.values()];
598
+ for (;;) {
599
+ const next = members.find((m) => !placed.has(m.name) && this.parentsOf(m).every((x) => !set.has(x) || placed.has(x)));
600
+ if (next === undefined)
601
+ break;
602
+ order.push(next);
603
+ placed.add(next.name);
604
+ }
605
+ return order;
606
+ }
607
+ /**
608
+ * A NAME A SYNCED TABLE NOW HOLDS (`mason add` of the generated table's name): the synced table wins, so what was deployed
609
+ * is forgotten, its ledger row with it, and the table under that name, now the copy's, is never touched. Not while a run
610
+ * has it: the run's end looks again, and its swap looks on the writer's turn and abandons the run if a synced table
611
+ * holds the name (`swap`).
612
+ */
613
+ async release(e) {
614
+ if (e.deployed === null || e.running || e.queued)
615
+ return;
616
+ e.deployed = null;
617
+ e.dirtySince = null;
618
+ e.taken = null;
619
+ e.error = null;
620
+ await this.host.onWriter(() => this.store.writer.run(`DELETE FROM ${GENERATED_LEDGER} WHERE name = ${literal(e.name)}`));
621
+ this.host.log(`${e.name}: a synced table now holds its name, so the generated table is no longer kept`);
622
+ }
623
+ /** what to build from now: the file's definition where the catalog passed it, else what was deployed; nothing where a
624
+ * synced table holds the name */
625
+ buildable(e) {
626
+ if (this.host.added(e.name) !== null)
627
+ return null;
628
+ if (e.checked !== null && e.refusals.length === 0 && e.fileRefused === null)
629
+ return { definition: e.checked.definition, fingerprint: e.checked.fingerprint, bases: e.checked.bases };
630
+ return e.deployed === null ? null : { definition: e.deployed.definition, fingerprint: e.deployed.fingerprint, bases: e.deployed.bases };
631
+ }
632
+ /** queued when it is due: never built, a new definition, or the oldest change near its target */
633
+ schedule(e, now = Date.now()) {
634
+ const b = this.buildable(e);
635
+ if (b === null || e.queued || e.running || now < e.retryAt || this.stopped)
636
+ return;
637
+ if (e.deployed === null || e.deployed.fingerprint !== b.fingerprint) {
638
+ this.queue(e);
639
+ return;
640
+ }
641
+ if (e.dirtySince === null || e.deployed.bases.some((x) => !this.ready(x)))
642
+ return;
643
+ if (now - e.dirtySince >= (b.definition.targetLag.seconds * 1000) - (e.deployed.seconds * 1000))
644
+ this.queue(e);
645
+ }
646
+ queue(e) { e.queued = true; this.host.enqueue(e.name); }
647
+ // ------------------------------------------------------------------------------------------------------ the run
648
+ /** ONE RUN, in the rebuild queue: never throws but `Stopping`; its failure is its own to say. A run of a table is its
649
+ * chain's: a parent's builds its children after it, and a child's is its dirty parent's (`rootOf`, `unitOf`). A table
650
+ * that fails takes the tables built from it out of the run, which is tried again without them.
651
+ * ONE RUN AT A TIME, whoever asks: a run asked while another is in flight waits for it to end. `rootOf` and `unitOf`
652
+ * leave out a table a run has (`running`), so a run beside another would build a parent and leave its child over the
653
+ * old one; the rebuild queue asks one at a time, and this holds it for any other caller too (issue 195). */
654
+ run(name) {
655
+ const ran = this.runs.then(() => this.runAlone(name));
656
+ // the next waits for this one's end, not its outcome: its Stopping is its caller's
657
+ this.runs = ran.then(() => undefined, () => undefined);
658
+ return ran;
659
+ }
660
+ async runAlone(name) {
661
+ const asked = this.entries.get(name), planner = this.planner;
662
+ if (asked === undefined)
663
+ return;
664
+ asked.queued = false;
665
+ if (this.buildable(asked) === null || planner === null || this.stopped)
666
+ return;
667
+ const why = this.blocker(asked);
668
+ if (why !== null) {
669
+ this.block(asked, why);
670
+ return;
671
+ }
672
+ const root = this.rootOf(asked);
673
+ let members = this.unitOf(root);
674
+ // a child the run leaves out because a parent in it is refused, or is a new definition: said or looked at again
675
+ for (const c of this.entries.values()) {
676
+ if (members.includes(c) || !this.parentsOf(c).some((x) => members.some((m) => m.name === x)))
677
+ continue;
678
+ const blocked = this.blocker(c);
679
+ if (blocked !== null)
680
+ this.block(c, blocked);
681
+ }
682
+ while (members.length > 0) {
683
+ const failed = await this.build(members, planner);
684
+ if (failed === null)
685
+ return;
686
+ const gone = new Set([failed.name]);
687
+ for (const m of members)
688
+ if (this.parentsOf(m).some((x) => gone.has(x))) {
689
+ gone.add(m.name);
690
+ this.block(m, failed.name);
691
+ }
692
+ members = members.filter((m) => !gone.has(m.name));
693
+ }
694
+ }
695
+ /** `members` built, parents first, in ONE transaction over one snapshot, each child over its parents' side tables, and
696
+ * swapped in on ONE writer's turn. The table that failed, its failure said; null when built or abandoned */
697
+ async build(members, planner) {
698
+ const started = Date.now();
699
+ let open = false, at = members[0];
700
+ // what each builds, read now, before anything awaits: a sources file reloaded while it plans changes nothing of this
701
+ // run. One with nothing to build now (its name taken since the run was asked): nothing is built, the review looks again
702
+ const buildables = members.map((m) => this.buildable(m));
703
+ if (buildables.some((b) => b === null))
704
+ return null;
705
+ const c = this.host.connection();
706
+ for (const m of members)
707
+ m.running = true;
708
+ try {
709
+ const sides = new Map();
710
+ const plans = [];
711
+ for (const [i, m] of members.entries()) {
712
+ at = m;
713
+ const b = buildables[i], d = b.definition;
714
+ // planned now, for what it reads as of now (a stored JSON path may have come or gone since it was checked), a
715
+ // parent built in this run read as its side table
716
+ const relations = new Map(b.bases.filter((x) => sides.has(x)).map((x) => [x, sides.get(x)]));
717
+ const planning = planDefinition(planner, d.sql, d.orderBy, relations);
718
+ this.planning = planning;
719
+ const planned = await planning.finally(() => { if (this.planning === planning)
720
+ this.planning = null; });
721
+ // a stop came while it planned: nothing is built, and nothing failed
722
+ if (this.stopped)
723
+ return null;
724
+ if ("refused" in planned)
725
+ throw new Error(planned.refused[0].message);
726
+ sides.set(m.name, `${d.table}${SIDE_SUFFIX}`);
727
+ plans.push({ e: m, b, plan: planned.definition, target: `${ident(d.schema)}.${ident(d.table)}`, side: `${ident(d.schema)}.${ident(`${d.table}${SIDE_SUFFIX}`)}` });
728
+ }
729
+ at = members[0];
730
+ await this.host.settle();
731
+ for (const s of new Set(plans.map((p) => p.b.definition.schema)))
732
+ await c.run(`CREATE SCHEMA IF NOT EXISTS ${ident(s)}`);
733
+ await c.run("BEGIN TRANSACTION");
734
+ open = true;
735
+ const bases = [...new Set(plans.flatMap((p) => p.plan.bases))];
736
+ // THE SNAPSHOT AND THE MARKS, TOGETHER: this read takes the snapshot on the writer's turn, so a commit before it is
737
+ // in the snapshot and a commit after it marks the next run. Every table of the chain reads this one snapshot
738
+ const clock = await this.host.onWriter(async () => {
739
+ await c.runAndReadAll(`SELECT count(*) FROM ${GENERATED_LEDGER}`);
740
+ for (const m of members) {
741
+ m.taken = m.dirtySince;
742
+ m.dirtySince = null;
743
+ }
744
+ // a parent of this run is recorded at this snapshot, one served at its own, read on this turn as its swap is
745
+ const now = Date.now();
746
+ return { at: now, synced: Object.fromEntries(bases.map((x) => [x, members.some((m) => m.name === x) ? now : this.baseSynced(x)])) };
747
+ });
748
+ for (const m of members)
749
+ await this.onStep("snapshot", m.name);
750
+ const written = [];
751
+ for (const p of plans) {
752
+ at = p.e;
753
+ const { plan } = p.plan;
754
+ // a computed numeric's DECIMAL arithmetic, over the engine types of the SQL that runs (a parent's side table now
755
+ // there), never assumed: what the check held, the write holds again
756
+ const stored = !p.plan.columns.some(isNumeric) ? { columns: p.plan.columns, layers: [] } : storedColumns(p.plan, await engineTypes(c, plan.sql.text));
757
+ // refused here, it is a refusal as the check's are, with its options: never a failure retried on a timer
758
+ if ("refused" in stored)
759
+ throw new RefusedAtWrite(p.e, stored.refused);
760
+ const { columns, layers } = stored;
761
+ const params = [];
762
+ if (plan.clock?.exec !== undefined)
763
+ params[plan.clock.exec - 1] = new Date(clock.at).toISOString();
764
+ const aliases = columns.map((_, i) => `__mason_c${i}`);
765
+ // a computed numeric's shared nodes, each written once as a column of its own, a layer over the one before
766
+ // (src/duck/generated-plan.ts `storedProgram`): the columns' expressions read them by name
767
+ const from = layers.reduce((inner, layer) => `(SELECT *, ${layer} FROM ${inner}) AS __mason_output`, `(${plan.sql.text}) AS __mason_output(${aliases.join(", ")})`);
768
+ const sql = `CREATE OR REPLACE TABLE ${p.side} (${columns.map((x) => ident(x.name)).join(", ")}) AS SELECT ${columns.map((x, i) => { const v = x.expr ?? aliases[i]; return x.duck === null ? v : `CAST(${v} AS ${x.duck})`; }).join(", ")} ` +
769
+ `FROM ${from}`;
770
+ const { rowsChanged } = await this.host.interruptible(() => c.run(sql, params.length > 0 ? params : undefined));
771
+ await this.onStep("written", p.e.name);
772
+ written.push({ e: p.e, side: p.side, target: p.target, n: { definition: p.b.definition, fingerprint: p.b.fingerprint, columns, bases: p.plan.bases,
773
+ basesSynced: Object.fromEntries(p.plan.bases.map((x) => [x, clock.synced[x] ?? null])), snapshotAt: clock.at, builtAt: 0, rows: Number(rowsChanged), seconds: 0 } });
774
+ }
775
+ at = members[0];
776
+ await c.run("COMMIT");
777
+ open = false;
778
+ for (const m of members)
779
+ await this.onStep("committed", m.name);
780
+ const seconds = (Date.now() - started) / 1000, builtAt = Date.now();
781
+ for (const w of written) {
782
+ w.n.seconds = seconds;
783
+ w.n.builtAt = builtAt;
784
+ }
785
+ if (!await this.host.onWriter(() => this.swap(written))) {
786
+ // a synced table took a name while it ran: what it built is dropped, and the next review lets the name go; what
787
+ // this run took is still not in the table, so it stays dirty should the name come back (the sources file reverted)
788
+ for (const m of members) {
789
+ m.dirtySince = this.oldest(m);
790
+ m.taken = null;
791
+ }
792
+ this.host.log(`${members.map((m) => m.name).join(", ")}: a synced table now holds ${members.length === 1 ? "its name" : "one of their names"}, so what this run built was dropped`);
793
+ return null;
794
+ }
795
+ for (const m of members)
796
+ await this.onStep("swapped", m.name);
797
+ // what each swap brought, across the run: the oldest change any member at or below it took (parents come first in
798
+ // `written`), so a child left out under a member that took nothing itself is marked at the base change, never at
799
+ // the swap
800
+ const chainTook = new Map();
801
+ for (const w of written) {
802
+ let t = w.e.taken;
803
+ for (const p of w.n.bases) {
804
+ const q = chainTook.get(p);
805
+ if (q !== undefined && q !== null && (t === null || q < t))
806
+ t = q;
807
+ }
808
+ chainTook.set(w.e.name, t);
809
+ }
810
+ for (const w of written) {
811
+ const e = w.e, was = e.error;
812
+ e.deployed = w.n;
813
+ e.taken = null;
814
+ e.error = null;
815
+ e.failures = 0;
816
+ e.retryAt = 0;
817
+ e.blocked = null;
818
+ // what reads it and was not built with it is behind it, whatever its definition: a new one, a child this run
819
+ // left out, or one whose own build failed and was taken out of the run (its marks were the parent's)
820
+ this.changedGenerated(e.name, members, chainTook.get(e.name) ?? null);
821
+ if (was !== null)
822
+ this.host.event({ kind: "recovered", table: e.name, message: `its re-run succeeded (it had failed: ${was})` });
823
+ }
824
+ await this.host.rebuildCatalog();
825
+ for (const w of written)
826
+ this.host.log(`generated ${w.e.name}: ${w.n.rows.toLocaleString("en-US")} row(s) in ${seconds.toFixed(1)} s`);
827
+ return null;
828
+ }
829
+ catch (x) {
830
+ if (open)
831
+ await c.run("ROLLBACK");
832
+ // what this run took is still not in the tables
833
+ for (const m of members) {
834
+ m.dirtySince = this.oldest(m);
835
+ m.taken = null;
836
+ }
837
+ if (this.host.stopping(x))
838
+ throw x;
839
+ if (x instanceof RefusedAtWrite) {
840
+ // what the write's engine types refuse is the table's refusal, with its ways out, as at a check: it keeps serving
841
+ // what it holds and is not run again until its definition or the schema changes (the check's `seen`)
842
+ const e = x.entry;
843
+ e.checked = null;
844
+ e.refusals = x.refused;
845
+ this.say(e);
846
+ this.host.log(`${e.name}: refused at its run: ${x.refused.map((r) => r.message).join("; ")}`);
847
+ return null;
848
+ }
849
+ at.failures++;
850
+ const attempt = Date.now() - started;
851
+ at.retryAt = Date.now() + this.host.retryDelayMs(at.failures, attempt);
852
+ // a numeric past the 38 digits a stored DECIMAL holds is the data's, not the definition's: said with its way out,
853
+ // and run again on the back-off, since a change to the rows can bring it back within them
854
+ at.error = failureWords(x);
855
+ const m = `its re-run failed (it serves what it last held; tried again after ${new Date(at.retryAt).toISOString()}): ${at.error}`;
856
+ this.host.log(`${at.name}: ${m}`);
857
+ this.host.event({ kind: "generated_failed", table: at.name, message: m });
858
+ return at;
859
+ }
860
+ finally {
861
+ for (const m of members)
862
+ m.running = false;
863
+ }
864
+ // a file gone, or changed, while it ran: the next review looks
865
+ }
866
+ /** a generated table swapped in: what reads it and was not built with it is marked, as a base's commit marks, at the
867
+ * oldest base change the swap brought (`took`), so its lag is still measured against the base; now for a new
868
+ * definition, which no base change brought */
869
+ changedGenerated(name, built, took) {
870
+ const at = took ?? Date.now();
871
+ for (const e of this.entries.values()) {
872
+ if (built.includes(e) || !e.deployed?.bases.includes(name))
873
+ continue;
874
+ if (e.dirtySince === null || at < e.dirtySince)
875
+ e.dirtySince = at;
876
+ }
877
+ }
878
+ /** THE SWAP, ON THE WRITER'S TURN, as a copy's is: every side table of the run replaces its table, with its ledger
879
+ * row, in ONE transaction, never beside a write, so a chain swaps in at one moment. The ledger row is what the table
880
+ * is re-run from after a restart. False, and the side tables dropped, when a synced table now holds a name: on this
881
+ * turn, so no copy's swap comes between. */
882
+ async swap(built) {
883
+ const w = this.store.writer;
884
+ if (built.some((b) => this.host.added(b.e.name) !== null)) {
885
+ for (const b of built)
886
+ await w.run(`DROP TABLE IF EXISTS ${b.side}`);
887
+ return false;
888
+ }
889
+ await w.run("BEGIN TRANSACTION");
890
+ try {
891
+ for (const { e, n, side, target } of built) {
892
+ const d = n.definition;
893
+ await w.run(`DROP TABLE IF EXISTS ${target}`);
894
+ await w.run(`ALTER TABLE ${side} RENAME TO ${ident(d.table)}`);
895
+ await w.run(`INSERT OR REPLACE INTO ${GENERATED_LEDGER} VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11::TIMESTAMPTZ, $12::TIMESTAMPTZ, $13, $14, $15)`, [e.name, d.file, d.sql, d.orderBy, d.targetLag.every, d.targetLag.seconds, d.comment, n.fingerprint, JSON.stringify(n.columns), JSON.stringify(n.bases),
896
+ new Date(n.snapshotAt).toISOString(), new Date(n.builtAt).toISOString(), n.rows, n.seconds, JSON.stringify(n.basesSynced)]);
897
+ }
898
+ await w.run("COMMIT");
899
+ }
900
+ catch (x) {
901
+ await w.run("ROLLBACK");
902
+ throw x;
903
+ }
904
+ return true;
905
+ }
906
+ }