@mason-data/cli 0.7.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (155) hide show
  1. package/README.md +101 -73
  2. package/dist/src/api/error.js +3 -0
  3. package/dist/src/api/postgres/catalog-worker.js +49 -2
  4. package/dist/src/api/postgres/catalog.js +368 -25
  5. package/dist/src/api/postgres/encode.js +3 -1
  6. package/dist/src/api/postgres/explain.js +3 -2
  7. package/dist/src/api/postgres/listener.js +11 -3
  8. package/dist/src/api/postgres/protocol.js +39 -23
  9. package/dist/src/api/postgres/proxy.js +283 -0
  10. package/dist/src/api/postgres/schema.js +6 -0
  11. package/dist/src/api/postgres/server.js +1617 -245
  12. package/dist/src/api/postgres/translate.js +127 -10
  13. package/dist/src/api/request-log.js +18 -5
  14. package/dist/src/cdc/poll.js +5 -5
  15. package/dist/src/cli/background.js +47 -25
  16. package/dist/src/cli/bridge-stdin.js +101 -0
  17. package/dist/src/cli/cancel.js +75 -0
  18. package/dist/src/cli/command.js +170 -11
  19. package/dist/src/cli/commands.js +804 -209
  20. package/dist/src/cli/control.js +59 -0
  21. package/dist/src/cli/credentials.js +182 -0
  22. package/dist/src/cli/demo.js +20 -11
  23. package/dist/src/cli/deploy.js +574 -0
  24. package/dist/src/cli/dev.js +491 -130
  25. package/dist/src/cli/doctor.js +131 -38
  26. package/dist/src/cli/generated-files.js +143 -0
  27. package/dist/src/cli/guides.js +175 -36
  28. package/dist/src/cli/hosted.js +850 -0
  29. package/dist/src/cli/init.js +162 -65
  30. package/dist/src/cli/login.js +289 -0
  31. package/dist/src/cli/machine.js +13 -12
  32. package/dist/src/cli/main.js +11 -5
  33. package/dist/src/cli/mason.cjs +17 -0
  34. package/dist/src/cli/mcp.js +31 -22
  35. package/dist/src/cli/metrics.js +79 -0
  36. package/dist/src/cli/output-gate.js +176 -0
  37. package/dist/src/cli/page-share.js +96 -0
  38. package/dist/src/cli/pg-tls.js +14 -4
  39. package/dist/src/cli/pg-url.js +4 -0
  40. package/dist/src/cli/pin.js +19 -14
  41. package/dist/src/cli/pool-size.cjs +85 -0
  42. package/dist/src/cli/ports.js +2 -0
  43. package/dist/src/cli/preflight.js +138 -46
  44. package/dist/src/cli/project.js +142 -4
  45. package/dist/src/cli/readiness.js +76 -6
  46. package/dist/src/cli/registry.js +65 -38
  47. package/dist/src/cli/replica-hosted.js +665 -0
  48. package/dist/src/cli/replica.js +76 -0
  49. package/dist/src/cli/replication-checks.js +7 -5
  50. package/dist/src/cli/replication-mode.js +105 -26
  51. package/dist/src/cli/request-filter.js +134 -0
  52. package/dist/src/cli/roles.js +585 -0
  53. package/dist/src/cli/run-server.js +637 -48
  54. package/dist/src/cli/runs.js +1 -1
  55. package/dist/src/cli/security.js +67 -18
  56. package/dist/src/cli/serve.js +204 -46
  57. package/dist/src/cli/share-run.js +329 -0
  58. package/dist/src/cli/share.js +286 -0
  59. package/dist/src/cli/source-edit.js +75 -17
  60. package/dist/src/cli/source-role.js +344 -0
  61. package/dist/src/cli/sources.js +306 -47
  62. package/dist/src/cli/style.js +2 -1
  63. package/dist/src/cli/supervise.js +27 -24
  64. package/dist/src/cli/sync.js +55 -25
  65. package/dist/src/cli/token-hosted.js +337 -0
  66. package/dist/src/cli/tunnel.js +200 -0
  67. package/dist/src/cli/ui-ssh.js +460 -0
  68. package/dist/src/cli/ui.js +124 -17
  69. package/dist/src/cli/usage.js +128 -0
  70. package/dist/src/cli/user-hosted.js +299 -0
  71. package/dist/src/cli/user.js +573 -65
  72. package/dist/src/cli/version.js +9 -7
  73. package/dist/src/demo/seed.js +29 -0
  74. package/dist/src/duck/analyze.js +36 -20
  75. package/dist/src/duck/cancel.js +10 -1
  76. package/dist/src/duck/collation.js +266 -0
  77. package/dist/src/duck/constant-key.js +94 -0
  78. package/dist/src/duck/distinct-key.js +283 -26
  79. package/dist/src/duck/events.js +165 -0
  80. package/dist/src/duck/executor-worker.js +20 -11
  81. package/dist/src/duck/executor.js +47 -33
  82. package/dist/src/duck/explain.js +60 -46
  83. package/dist/src/duck/extensions.js +160 -0
  84. package/dist/src/duck/functions.js +461 -63
  85. package/dist/src/duck/gaps.js +48 -21
  86. package/dist/src/duck/generated-plan.js +1070 -0
  87. package/dist/src/duck/generated.js +906 -0
  88. package/dist/src/duck/grants.js +176 -0
  89. package/dist/src/duck/held-types.js +64 -0
  90. package/dist/src/duck/history.js +127 -0
  91. package/dist/src/duck/jsonb-order.js +9 -12
  92. package/dist/src/duck/kept-route.js +6 -5
  93. package/dist/src/duck/kept.js +15 -13
  94. package/dist/src/duck/late.js +1469 -40
  95. package/dist/src/duck/libc-bound.js +341 -0
  96. package/dist/src/duck/libc-order.js +892 -126
  97. package/dist/src/duck/pg-eval-worker.js +7 -1
  98. package/dist/src/duck/pg-eval.js +6 -4
  99. package/dist/src/duck/pgtree.js +774 -23
  100. package/dist/src/duck/plan.js +369 -47
  101. package/dist/src/duck/publication.js +20 -6
  102. package/dist/src/duck/requests.js +269 -93
  103. package/dist/src/duck/resolved.js +117 -2
  104. package/dist/src/duck/rewrite.js +4584 -580
  105. package/dist/src/duck/row-filter.js +655 -0
  106. package/dist/src/duck/statement.js +19 -0
  107. package/dist/src/duck/store.js +439 -177
  108. package/dist/src/duck/stream.js +19 -10
  109. package/dist/src/duck/sync.js +1411 -368
  110. package/dist/src/duck/token.js +4 -1
  111. package/dist/src/duck/types.js +69 -12
  112. package/dist/src/duck/usage.js +326 -0
  113. package/dist/src/duck/users.js +69 -15
  114. package/dist/src/duck/verified.js +161 -1
  115. package/dist/src/duck/views.js +5 -5
  116. package/dist/src/lib/database-name.js +62 -0
  117. package/dist/src/lib/database-url.js +55 -10
  118. package/dist/src/lib/dotenv.js +17 -4
  119. package/dist/src/lib/instant.js +50 -0
  120. package/dist/src/lib/log-buffer.js +77 -0
  121. package/dist/src/lib/log-read.js +196 -0
  122. package/dist/src/lib/pg-numeric.js +390 -49
  123. package/dist/src/lib/project-version.js +23 -23
  124. package/dist/src/lib/secret-mask.js +142 -0
  125. package/dist/src/lib/table-sync.js +282 -0
  126. package/dist/src/web/app.css +706 -66
  127. package/dist/src/web/app.js +331 -409
  128. package/dist/src/web/charts.js +229 -0
  129. package/dist/src/web/index.html +82 -18
  130. package/dist/src/web/pages/queries.js +192 -0
  131. package/dist/src/web/pages/status.js +611 -0
  132. package/dist/src/web/pages/syncs.js +155 -0
  133. package/dist/src/web/pages/tables.js +439 -0
  134. package/dist/src/web/pages/usage.js +397 -0
  135. package/dist/src/web/pages/users.js +219 -0
  136. package/dist/src/web/shared.js +584 -0
  137. package/dist/src/web/vendor/README.md +25 -0
  138. package/dist/src/web/vendor/geist-1.7.2/Geist-Variable.woff2 +0 -0
  139. package/dist/src/web/vendor/geist-1.7.2/GeistMono-Variable.woff2 +0 -0
  140. package/dist/src/web/vendor/geist-1.7.2/LICENSE.txt +92 -0
  141. package/dist/src/web/vendor/lucide-1.56.0/LICENSE.txt +43 -0
  142. package/dist/src/web/vendor/lucide-1.56.0/chart-column.mjs +29 -0
  143. package/dist/src/web/vendor/lucide-1.56.0/layout-dashboard.mjs +29 -0
  144. package/dist/src/web/vendor/lucide-1.56.0/logs.mjs +34 -0
  145. package/dist/src/web/vendor/lucide-1.56.0/menu.mjs +28 -0
  146. package/dist/src/web/vendor/lucide-1.56.0/square-terminal.mjs +28 -0
  147. package/dist/src/web/vendor/lucide-1.56.0/table.mjs +29 -0
  148. package/dist/src/web/vendor/lucide-1.56.0/users.mjs +29 -0
  149. package/dist/src/web/vendor/lucide-1.56.0/x.mjs +27 -0
  150. package/dist/src/web/vendor/uplot-1.6.32/LICENSE.txt +21 -0
  151. package/dist/src/web/vendor/uplot-1.6.32/uPlot.esm.js +6140 -0
  152. package/dist/src/web/vendor/uplot-1.6.32/uPlot.min.css +1 -0
  153. package/dist/src/web/vendor/zilla-slab-5.3.0/LICENSE.txt +93 -0
  154. package/dist/src/web/vendor/zilla-slab-5.3.0/zilla-slab-latin-700-normal.woff2 +0 -0
  155. package/package.json +12 -4
@@ -5,9 +5,16 @@
5
5
  *
6
6
  * kind: postgres
7
7
  * url: ${DATABASE_URL} an env var from .env.local, .env or the shell; a literal URL for local dev
8
+ * database: production the database name a tool connects with (none: mason; src/lib/database-name.ts)
9
+ * sync: off how each table is kept current after its first sync (src/lib/table-sync.ts): off
10
+ * (the default: until mason sync <table>), poll <interval> or replication; an entry's
11
+ * own overrides it
12
+ * publication: ${PG_PUBLICATION} the publication `replication` reads, its owner's (never altered); none: Mason's own
13
+ * (the literal `publication: null` says so, past one an earlier start recorded)
8
14
  * tables:
9
15
  * orders: a bare name is in `public`; `billing.invoices` names its schema
10
16
  * columns: [id, customer_id, status]
17
+ * sync: poll 30s this table polled every 30 seconds
11
18
  * customers: {} every column
12
19
  * events:
13
20
  * exclude: [raw_body] every column but these
@@ -26,13 +33,40 @@
26
33
  import * as fs from "node:fs";
27
34
  import * as path from "node:path";
28
35
  import { isMap, isScalar, parseDocument } from "yaml";
36
+ import { databaseNameProblem, DEFAULT_DATABASE } from "../lib/database-name.js";
29
37
  import { appEnvValue, ENV_FILE } from "../lib/dotenv.js";
30
38
  import { closest, didYouMean } from "../lib/did-you-mean.js";
31
39
  import { maskedUrl } from "./preflight.js";
40
+ import { DEFAULT_SYNC, parseSync, syncText } from "../lib/table-sync.js";
32
41
  export const SOURCES_DIR = "sources";
33
- const KEYS = ["kind", "url", "tables"];
34
- const ENTRY_KEYS = ["columns", "exclude", "key"];
42
+ const KEYS = ["kind", "url", "database", "sync", "publication", "tables"];
43
+ const ENTRY_KEYS = ["columns", "exclude", "key", "sync"];
35
44
  const ENV_REF = /^\$\{([A-Za-z_][A-Za-z0-9_]*)\}$/;
45
+ /** The tables a file keeps current by replication, by their effective sync (`effectiveSync`): what streams. */
46
+ export function replicatedEntries(src) {
47
+ return src.tables.filter((e) => effectiveSync(src, e).sync.mode === "replication");
48
+ }
49
+ /** Whether the file says how anything is kept current: a `sync:` at its top or on any entry. A file with none has every
50
+ * table on the default, by no one's choice: what an older Mason's file looks like. */
51
+ export function syncWritten(file) {
52
+ return file.sync !== null || file.tables.some((e) => e.sync !== null);
53
+ }
54
+ /**
55
+ * A FILE THAT HAS GONE SILENT OVER A PROJECT THAT STREAMS: it names tables and no `sync:`, where the config records a
56
+ * slot. Silent is no decision (a file with no table has nothing left to stream). One rule for the start, whose refusal
57
+ * is `recordRefusal` (src/cli/replication-mode.ts), and the run's re-read (`rereadSources`).
58
+ */
59
+ export function silentOverSlot(slotRecorded, file) {
60
+ return slotRecorded && file.tables.length > 0 && !syncWritten(file);
61
+ }
62
+ /** A table's sync: its entry's, else the file's, else the default (`off`) — and which of the three said it. */
63
+ export function effectiveSync(src, e) {
64
+ if (e?.sync)
65
+ return { sync: e.sync, from: "entry" };
66
+ if (src.sync)
67
+ return { sync: src.sync, from: "file" };
68
+ return { sync: DEFAULT_SYNC, from: "default" };
69
+ }
36
70
  /** A file as messages name it: relative to the project's root. */
37
71
  export const shownPath = (root, file) => path.relative(root, file) || file;
38
72
  /** A table as a key of the file: its name alone in public, else schema.table. */
@@ -90,6 +124,25 @@ function nameList(v, at, what) {
90
124
  return `${at}: names ${String(twice)} twice`;
91
125
  return v;
92
126
  }
127
+ /** The file's `database:`: the name it writes, null when it has no such line, or the problem with it (`text`, the file's). */
128
+ function databaseKey(doc, text) {
129
+ const node = isMap(doc.contents) ? doc.contents.get("database", true) : undefined;
130
+ if (node === undefined)
131
+ return { database: null };
132
+ const way = `write database: <name>, or take the line out: tools then connect with ${DEFAULT_DATABASE}`;
133
+ if (!isScalar(node))
134
+ return { problem: `database: is one name, not a list or a map — ${way}` };
135
+ if (node.value === null)
136
+ return { problem: `database: is empty — ${way}` };
137
+ if (typeof node.value !== "string") {
138
+ const written = text.slice(node.range[0], node.range[1]);
139
+ return { problem: `database: ${yamlRead(node.value).replace(/^\S+/, written)} — quote it: "${written}"` };
140
+ }
141
+ const bad = databaseNameProblem(node.value);
142
+ return bad === null ? { database: node.value } : { problem: `database: ${bad} — ${way}` };
143
+ }
144
+ /** A file that is YAML and no mapping, said; `v` is what it read as. */
145
+ const notMapping = (v) => `it is ${v === null || v === undefined ? "empty" : "not a mapping"}: a source file is kind: postgres, url: \${DATABASE_URL} and tables: {}`;
93
146
  /**
94
147
  * The file's text read as a source file: its shape, never the database (that is `addedTables`'s). `name` is the
95
148
  * source's (the file's name); the problems are each at their key, in the order they are in the file.
@@ -100,7 +153,7 @@ export function parseSourceText(text, name, file) {
100
153
  return { problems: [`it is not YAML: ${doc.errors[0].message.split("\n")[0]}`] };
101
154
  const v = doc.toJS();
102
155
  if (!isPlain(v))
103
- return { problems: [`it is ${v === null || v === undefined ? "empty" : "not a mapping"}: a source file is kind: postgres, url: \${DATABASE_URL} and tables: {}`] };
156
+ return { problems: [notMapping(v)] };
104
157
  const problems = [];
105
158
  for (const k of Object.keys(v)) {
106
159
  if (!KEYS.includes(k))
@@ -128,10 +181,42 @@ export function parseSourceText(text, name, file) {
128
181
  else if (bare)
129
182
  problems.push(`url: $${bare[1]} — write \${${bare[1]}}: a variable is named in braces`);
130
183
  else if (v.url.includes("${"))
131
- problems.push(`url: ${JSON.stringify(v.url)} — write the URL whole, or \${NAME} alone: the whole URL in one variable, like \${DATABASE_URL}`);
184
+ problems.push("url: holds a ${NAME} inside a longer value (not shown: it may hold a password) — write the URL whole, or ${NAME} alone: the whole URL in one variable, like ${DATABASE_URL}");
132
185
  else
133
186
  url = { literal: v.url.trim() };
134
187
  }
188
+ // the name a tool connects with: written out, the same on every machine (src/lib/database-name.ts)
189
+ const named = databaseKey(doc, text);
190
+ if ("problem" in named)
191
+ problems.push(named.problem);
192
+ // the publication a replicated table is read through: a name, or ${NAME} as url: takes one (resolved by readSource)
193
+ let publication = null;
194
+ // the word `null`, as written: Mason's own, on purpose, past a name a start recorded (src/cli/replication-mode.ts
195
+ // recordRefusal). A key left with no value is a line half-written, never that decision; no line at all says nothing
196
+ const publicationNode = doc.get("publication", true);
197
+ if (v.publication === null && isScalar(publicationNode) && publicationNode.source === "null")
198
+ publication = { own: true };
199
+ else if (v.publication !== undefined) {
200
+ const p = typeof v.publication === "string" ? v.publication.trim() : "";
201
+ const m = ENV_REF.exec(p);
202
+ if (p === "")
203
+ problems.push("publication: write publication: ${PG_PUBLICATION}, or a publication's name (publication: null and Mason makes its own)");
204
+ else if (m)
205
+ publication = { env: m[1] };
206
+ else if (p.includes("$"))
207
+ problems.push(`publication: ${JSON.stringify(p)} — write \${NAME} alone, like \${PG_PUBLICATION}, or the publication's name`);
208
+ else
209
+ publication = { literal: p };
210
+ }
211
+ // every table's sync, unless its entry says its own (src/lib/table-sync.ts: one grammar, `mason add --sync` reads it too)
212
+ let sync = null;
213
+ if (v.sync !== undefined) {
214
+ const s = parseSync(v.sync);
215
+ if ("problem" in s)
216
+ problems.push(`sync: ${s.problem}`);
217
+ else
218
+ sync = s;
219
+ }
135
220
  const tables = [];
136
221
  const tablesNode = isMap(doc.contents) ? doc.contents.items.find((p) => isScalar(p.key) && p.key.value === "tables")?.value : undefined;
137
222
  const odd = new Set();
@@ -167,30 +252,33 @@ export function parseSourceText(text, name, file) {
167
252
  }
168
253
  seen.set(table, key);
169
254
  if (raw !== null && !isPlain(raw)) {
170
- problems.push(`${at}: is {} (every column), { columns: [...] }, { exclude: [...] } or { key: [...] }`);
255
+ problems.push(`${at}: is {} (every column), { columns: [...] }, { exclude: [...] }, { key: [...] } or { sync: poll 5m }`);
171
256
  continue;
172
257
  }
173
258
  const e = raw ?? {};
174
259
  const stray = Object.keys(e).filter((k) => !ENTRY_KEYS.includes(k));
175
260
  for (const k of stray)
176
- problems.push(`${at}: unexpected key ${k}${didYouMean(k, ENTRY_KEYS)} — a table takes columns, exclude and key`);
261
+ problems.push(`${at}: unexpected key ${k}${didYouMean(k, ENTRY_KEYS)} — a table takes columns, exclude, key and sync`);
177
262
  const both = e.columns !== undefined && e.exclude !== undefined;
178
263
  if (both)
179
- problems.push(`${at}: columns and exclude together — columns names what is copied, exclude what is left out of every column; keep one`);
180
- const columns = e.columns === undefined ? null : nameList(e.columns, `${at}.columns`, "the columns copied");
264
+ problems.push(`${at}: columns and exclude together — columns names what is synced, exclude what is left out of every column; keep one`);
265
+ const columns = e.columns === undefined ? null : nameList(e.columns, `${at}.columns`, "the columns synced");
181
266
  const exclude = e.exclude === undefined ? [] : nameList(e.exclude, `${at}.exclude`, "the columns left out");
182
267
  const ownKey = e.key === undefined ? null : nameList(e.key, `${at}.key`, "the columns that tell its rows apart");
183
268
  for (const x of [columns, exclude, ownKey])
184
269
  if (typeof x === "string")
185
270
  problems.push(x);
186
- if (typeof columns === "string" || typeof exclude === "string" || typeof ownKey === "string" || stray.length || both)
271
+ const ownSync = e.sync === undefined ? null : parseSync(e.sync);
272
+ if (ownSync !== null && "problem" in ownSync)
273
+ problems.push(`${at}.sync: ${ownSync.problem}`);
274
+ if (typeof columns === "string" || typeof exclude === "string" || typeof ownKey === "string" || stray.length || both || (ownSync !== null && "problem" in ownSync))
187
275
  continue;
188
- tables.push({ name: key, table, columns, exclude, key: ownKey });
276
+ tables.push({ name: key, table, columns, exclude, key: ownKey, sync: ownSync });
189
277
  }
190
278
  }
191
- if (problems.length || kind === null || url === null)
279
+ if (problems.length || kind === null || url === null || "problem" in named)
192
280
  return { problems };
193
- return { name, file, kind, url, tables };
281
+ return { name, file, kind, url, database: named.database, sync, publication, tables };
194
282
  }
195
283
  /** Where a project's source files are: `<folder>/sources/`. */
196
284
  export const sourcesDir = (folder) => path.join(folder, SOURCES_DIR);
@@ -201,16 +289,80 @@ export function sourceFileNames(folder) {
201
289
  return [];
202
290
  return fs.readdirSync(dir).filter((f) => /\.ya?ml$/.test(f) && !f.startsWith(".")).sort();
203
291
  }
204
- /** The file `mason init` writes: the database, then its tables — none yet, but for `mason init demo`'s, which names the
205
- * demo database's tables (`tables`, each copied whole: `{}`), so its first start copies them and shows something. */
206
- export function newSourceText(url = "${DATABASE_URL}", tables = []) {
207
- return "# The database this project copies from, and which of its tables. Yours to commit; the URL stays out of it.\n" +
292
+ /**
293
+ * THE TOP OF A PROJECT'S SOURCES FILE, for a retired setting's way out (src/lib/table-sync.ts `topEdit`): its one file
294
+ * as a person names it from `root`, and the values its top-level `sync:` and `publication:` lines hold as written, so
295
+ * the way out changes the line the file has rather than writing a second. No file (or several): the place every
296
+ * project's file has, and no line. A file that cannot be read or parsed is said by the run in its own words
297
+ * (`readSource`); here it only words the edit, as for a file with no such line.
298
+ */
299
+ export function sourcesTop(root, folder) {
300
+ const names = sourceFileNames(folder);
301
+ if (names.length !== 1)
302
+ return { shown: `${shownPath(root, sourcesDir(folder))}/<name>.yml`, sync: null, publication: null };
303
+ const file = path.join(sourcesDir(folder), names[0]);
304
+ const shown = shownPath(root, file);
305
+ let text;
306
+ try {
307
+ text = fs.readFileSync(file, "utf8");
308
+ }
309
+ catch (e) {
310
+ // the run's own read says it, with the file and the error (readSource); the edit is worded as for no line
311
+ if (e.code === undefined)
312
+ throw e;
313
+ return { shown, sync: null, publication: null };
314
+ }
315
+ const doc = parseDocument(text);
316
+ const at = (key) => {
317
+ if (!isMap(doc.contents))
318
+ return null;
319
+ const node = doc.contents.get(key, true);
320
+ if (node === undefined || node === null)
321
+ return null;
322
+ return isScalar(node) ? String(node.value) : String(node);
323
+ };
324
+ return { shown, sync: at("sync"), publication: at("publication") };
325
+ }
326
+ /**
327
+ * THE `database:` A PROJECT WRITES, from the file alone and only its one key (src/lib/database-name.ts): what `mason user
328
+ * add` says with Mason stopped, which needs neither the database's URL nor the rest of the file right. No file writes
329
+ * none. A file it cannot read this key from is refused, in the failure shape: guessing `mason` would hand a tool a name
330
+ * the run may not answer to.
331
+ */
332
+ export function writtenDatabase(folder, root) {
333
+ const names = sourceFileNames(folder);
334
+ if (names.length === 0)
335
+ return { database: null };
336
+ if (names.length > 1)
337
+ return tooManySources(root, sourcesDir(folder), names);
338
+ const file = path.join(sourcesDir(folder), names[0]);
339
+ const text = fs.readFileSync(file, "utf8");
340
+ const doc = parseDocument(text, { uniqueKeys: true });
341
+ if (doc.errors.length)
342
+ return sourceFailure(file, root, [`it is not YAML: ${doc.errors[0].message.split("\n")[0]}`]);
343
+ if (!isMap(doc.contents))
344
+ return sourceFailure(file, root, [notMapping(doc.toJS())]);
345
+ const named = databaseKey(doc, text);
346
+ return "problem" in named ? sourceFailure(file, root, [named.problem]) : named;
347
+ }
348
+ /** The file `mason init` writes: the database, how its tables are kept current (`sync`: `off`, the default; the demo's
349
+ * `poll 5s`, so it shows a copy keeping up), then its tables — none yet, but for `mason init demo`'s, which names the
350
+ * demo database's tables (`tables`, each copied whole: `{}`), so its first start copies them and shows something.
351
+ * `database:` only when one is asked for (`--database`): none written, the default stays a default. */
352
+ export function newSourceText(url = "${DATABASE_URL}", tables = [], sync = DEFAULT_SYNC, database = null) {
353
+ return "# The database this project syncs from, and which of its tables. Yours to commit; the URL stays out of it.\n" +
354
+ (database === null ? `# Tools connect with the database name ${DEFAULT_DATABASE}; database: <name> here keeps the name a tool already has.\n` : "") +
208
355
  "kind: postgres\n" +
209
356
  `url: ${url}${ENV_REF.test(url) ? " # from .env.local, .env or the shell; a literal postgres:// URL works too" : ""}\n` +
357
+ (database === null ? "" : `database: ${yamlName(database)} # the database name a tool connects with\n`) +
358
+ `sync: ${syncText(sync)}${sync.mode === "off" ? " # after its first sync, a table is read again only by mason sync <table> (poll 5m: every 5 minutes)"
359
+ : sync.mode === "replication" ? " # each table kept current from the database's change log after its first sync (poll 5m, or off, on an entry)"
360
+ : ` # each table polled every ${sync.every} after its first sync (off: read again only by mason sync <table>)`}\n` +
210
361
  "\n" +
211
- "# One entry per table, a bare name in public, else schema.table: {} copies every column, columns: [id, status]\n" +
362
+ "# One entry per table, a bare name in public, else schema.table: {} syncs every column, columns: [id, status]\n" +
212
363
  "# those, exclude: [payload] every column but those; key: [a, b] names the columns that tell its rows apart, for a\n" +
213
- "# table with no primary key. mason add and mason remove edit this list; so can you.\n" +
364
+ "# table with no primary key; sync: poll 30s (or off) keeps that table current its own way. mason add and mason remove\n" +
365
+ "# edit this list; so can you.\n" +
214
366
  (tables.length ? `tables:\n${tables.map((t) => ` ${t}: {}\n`).join("")}` : "tables: {}\n");
215
367
  }
216
368
  /** A source's name from its database's: the file is named for it, else `app`. */
@@ -220,14 +372,53 @@ export function sourceNameFor(database) {
220
372
  }
221
373
  /** The folder an older Mason listed a project's tables in, one file per table: read by nothing, said by `readSource`. */
222
374
  export const OLD_TABLES_DIR = "models";
375
+ /** A second source file, refused: one source per project for now. */
376
+ const tooManySources = (root, dir, names) => ({
377
+ message: `${shownPath(root, dir)}/ has ${names.length} source files (${names.join(", ")}): one source per project for now`, code: "bad_source",
378
+ fix: `keep the one this project syncs from, and move the others out of ${shownPath(root, dir)}/`
379
+ });
223
380
  /**
224
381
  * THE PROJECT'S SOURCE FILE, read: the one file in `sources/` (a second is refused by name: one source per project for
225
382
  * now), parsed, its URL resolved — `${NAME}` from the project's .env.local or .env, else the shell (src/lib/dotenv.ts
226
- * `appEnvValue`, the order `mason init` reads DATABASE_URL in, so the two cannot disagree); `shellFirst` for a server
383
+ * `appEnvValue`, the order `mason init` reads MASON_DATABASE_URL and DATABASE_URL in, so the two cannot disagree); `shellFirst` for a server
227
384
  * (`mason serve`), configured by its environment alone. A project with no file is told what to write — one an older
228
385
  * Mason made (its tables listed in another folder, which nothing reads) that mason init writes it.
229
386
  */
230
387
  export function readSource(folder, root, env = process.env, o = {}) {
388
+ const parsed = readSourceFile(folder, root);
389
+ if ("message" in parsed)
390
+ return parsed;
391
+ const file = parsed.file;
392
+ const where = o.shellFirst ? "the environment, .env.local or .env" : ".env.local, .env or the shell";
393
+ // the publication's name, read as url: is: ${NAME} from the same places, in the same order
394
+ let publicationName = null;
395
+ if (parsed.publication !== null && "literal" in parsed.publication)
396
+ publicationName = parsed.publication.literal;
397
+ else if (parsed.publication !== null && "env" in parsed.publication) {
398
+ const v = parsed.publication.env;
399
+ const p = appEnvValue(root, v, env, { shellFirst: o.shellFirst === true });
400
+ if (p === null || p.value.trim() === "") {
401
+ return { message: `${shownPath(root, file)}: publication: \${${v}} is not set — not in ${where}`, code: "bad_source",
402
+ fix: `put ${v}=<the publication's name> in ${shownPath(root, path.join(root, ENV_FILE))} (or export it), or write publication: null and Mason makes its own` };
403
+ }
404
+ publicationName = p.value.trim();
405
+ }
406
+ if ("literal" in parsed.url)
407
+ return { ...parsed, resolved: parsed.url.literal, from: shownPath(root, file), publicationName };
408
+ const found = appEnvValue(root, parsed.url.env, env, { shellFirst: o.shellFirst === true });
409
+ if (found === null) {
410
+ const v = parsed.url.env;
411
+ return { message: `${shownPath(root, file)}: url: \${${v}} is not set — not in ${where}`, code: "bad_source",
412
+ fix: `put ${v}=postgres://user:password@host:5432/your_app in ${shownPath(root, path.join(root, ENV_FILE))} (or export it), or write the URL in url:` };
413
+ }
414
+ return { ...parsed, resolved: found.value, from: `${parsed.url.env} in ${found.from}`, publicationName };
415
+ }
416
+ /**
417
+ * THE PROJECT'S SOURCES FILE, ITS TEXT ALONE (the first step of `readSource`, which resolves its URL after): the one file
418
+ * in `sources/`, parsed, or what is wrong with it. What `mason deploy` checks before it sends a project (src/cli/deploy.ts):
419
+ * there the URL is the replica's environment's, never this machine's .env.
420
+ */
421
+ export function readSourceFile(folder, root) {
231
422
  const names = sourceFileNames(folder);
232
423
  const dir = sourcesDir(folder);
233
424
  if (names.length === 0) {
@@ -241,23 +432,11 @@ export function readSource(folder, root, env = process.env, o = {}) {
241
432
  return { message: `this project names no database: ${shownPath(root, dir)}/ has no source file`, code: "bad_source",
242
433
  fix: `write ${shownPath(root, file)}, with the database's URL in .env as DATABASE_URL:\n${newSourceText().trimEnd()}` };
243
434
  }
244
- if (names.length > 1) {
245
- return { message: `${shownPath(root, dir)}/ has ${names.length} source files (${names.join(", ")}): one source per project for now`, code: "bad_source",
246
- fix: `keep the one this project copies from, and move the others out of ${shownPath(root, dir)}/` };
247
- }
435
+ if (names.length > 1)
436
+ return tooManySources(root, dir, names);
248
437
  const file = path.join(dir, names[0]);
249
438
  const parsed = parseSourceText(fs.readFileSync(file, "utf8"), names[0].replace(/\.ya?ml$/, ""), file);
250
- if ("problems" in parsed)
251
- return sourceFailure(file, root, parsed.problems);
252
- if ("literal" in parsed.url)
253
- return { ...parsed, resolved: parsed.url.literal, from: shownPath(root, file) };
254
- const found = appEnvValue(root, parsed.url.env, env, { shellFirst: o.shellFirst === true });
255
- if (found === null) {
256
- const v = parsed.url.env;
257
- return { message: `${shownPath(root, file)}: url: \${${v}} is not set — not in ${o.shellFirst ? "the environment, .env.local or .env" : ".env.local, .env or the shell"}`, code: "bad_source",
258
- fix: `put ${v}=postgres://user:password@host:5432/your_app in ${shownPath(root, path.join(root, ENV_FILE))} (or export it), or write the URL in url:` };
259
- }
260
- return { ...parsed, resolved: found.value, from: `${parsed.url.env} in ${found.from}` };
439
+ return "problems" in parsed ? sourceFailure(file, root, parsed.problems) : parsed;
261
440
  }
262
441
  /** The columns a table's copy keeps, in the source's order: the entry's, its key always among them. */
263
442
  export function copiedColumns(t, e) {
@@ -267,7 +446,7 @@ export function copiedColumns(t, e) {
267
446
  return t.columns.map((c) => c.name).filter((c) => keep.has(c));
268
447
  }
269
448
  /** Why a table cannot be copied as its entry says, each at its key, against the database's catalog; none when it can. */
270
- export function entryProblems(t, e, all, database, o) {
449
+ export function entryProblems(t, e, all, database, sync) {
271
450
  const at = `tables.${e.name}`;
272
451
  if (t === undefined) {
273
452
  const names = all.map((x) => entryName(x.source));
@@ -278,13 +457,13 @@ export function entryProblems(t, e, all, database, o) {
278
457
  const out = [...missing(e.columns ?? [], "columns"), ...missing(e.exclude, "exclude")];
279
458
  if (e.key !== null) {
280
459
  if (t.key.length)
281
- out.push(`${at}.key: ${t.source} has a primary key (${t.key.join(", ")}), which Mason copies it by — remove key`);
460
+ out.push(`${at}.key: ${t.source} has a primary key (${t.key.join(", ")}), which Mason keys it by — remove key`);
282
461
  else {
283
462
  out.push(...missing(e.key, "key"));
284
463
  out.push(...keyProblems(t, e.key, at));
285
464
  // its rows have no identity on the database, so logical replication carries no key to apply a change by
286
- if (o.replication)
287
- out.push(`${at}: ${t.source} has no primary key, and logical replication cannot apply its changes by key: — copy it by polling (mason start --poll), or remove it`);
465
+ if (sync.mode === "replication")
466
+ out.push(`${at}: ${t.source} has no primary key, so logical replication cannot apply its changes by its key: (sync: replication) — give its entry sync: poll <interval> (poll 5m), or sync: off`);
288
467
  }
289
468
  }
290
469
  else if (!t.key.length) {
@@ -293,7 +472,13 @@ export function entryProblems(t, e, all, database, o) {
293
472
  `${unique ? ` (its unique index ${unique.name})` : ""}`);
294
473
  }
295
474
  for (const k of (e.key ?? t.key).filter((k) => e.exclude.includes(k)))
296
- out.push(`${at}.exclude: ${k} is ${t.source}'s key, which is always copied — take it out of exclude`);
475
+ out.push(`${at}.exclude: ${k} is ${t.source}'s key, which is always synced — take it out of exclude`);
476
+ // a table that identifies no changed row: a publication carrying its updates would make the app's own UPDATEs and
477
+ // DELETEs on it fail (the catalog says so; the file cannot), so it is never replicated
478
+ if (sync.mode === "replication" && t.replicaIdentity === "n" && e.key === null) {
479
+ out.push(`${at}: ${t.source} has REPLICA IDENTITY NOTHING, and a publication carrying it would make your app's own UPDATEs and DELETEs on it fail (sync: replication) — ` +
480
+ `give its entry sync: poll <interval> (poll 5m), or sync: off; or ALTER TABLE ${t.source} REPLICA IDENTITY DEFAULT on the database`);
481
+ }
297
482
  return out;
298
483
  }
299
484
  /**
@@ -315,7 +500,7 @@ function keyProblems(t, key, at) {
315
500
  if (!(t.uniqueKeys ?? []).some((u) => u.columns.every((c) => key.includes(c)))) {
316
501
  out.push(`${at}.key: no unique index of ${t.source} is over (${key.join(", ")}), so two of its rows may share them and Mason could not tell them apart — ` +
317
502
  (usable.length ? `name the columns of one: ${usable.map((u) => `key: [${u.columns.join(", ")}] (its unique index ${u.name})`).join(", or ")}`
318
- : `it has no unique index over NOT NULL columns, so Mason cannot copy it until its owner adds one (or a primary key)`));
503
+ : `it has no unique index over NOT NULL columns, so Mason cannot sync it until its owner adds one (or a primary key)`));
319
504
  }
320
505
  return out;
321
506
  }
@@ -325,19 +510,20 @@ function keyProblems(t, key, at) {
325
510
  * `primaryKey` keeps the source's own (none): the catalog declares only what the source does. Every entry's problems on
326
511
  * one report (`sourceFailure`).
327
512
  */
328
- export function addedTables(src, tables, database, root, o) {
513
+ export function addedTables(src, tables, database, root) {
329
514
  const problems = [];
330
515
  const out = [];
331
516
  for (const e of src.tables) {
332
517
  const t = tables.find((x) => x.source === e.table);
333
- const p = entryProblems(t, e, tables, database, o);
518
+ const sync = effectiveSync(src, e).sync;
519
+ const p = entryProblems(t, e, tables, database, sync);
334
520
  if (p.length || t === undefined) {
335
521
  problems.push(...p);
336
522
  continue;
337
523
  }
338
524
  const keep = new Set(copiedColumns(t, e));
339
525
  out.push({ ...t, columns: t.columns.filter((c) => keep.has(c.name)), foreignKeys: t.foreignKeys.filter((f) => keep.has(f.column)),
340
- ...(e.key !== null ? { key: e.key, primaryKey: t.key } : {}) });
526
+ ...(e.key !== null ? { key: e.key, primaryKey: t.key } : {}), sync });
341
527
  }
342
528
  return problems.length ? sourceFailure(src.file, root, problems, [["mason tables", "the database's tables and their columns"]]) : out;
343
529
  }
@@ -345,18 +531,30 @@ export function addedTables(src, tables, database, root, o) {
345
531
  * THE SOURCES FILE READ AGAIN WHILE THE RUN IS UP (mason add, mason remove): its tables now — or, when the file cannot be
346
532
  * taken as it is, the tables read before, kept (a typo never drops a copy), with what is wrong; `said` is the log line,
347
533
  * once per new problem. A `url:` that resolves to another database than the one the run copies (a .env edited) is such a
348
- * problem: the run's catalog is the old database's, so it is said, and mason stop && mason start reads the new one.
534
+ * problem: the run's catalog is the old database's, so it is said, and mason stop && mason start reads the new one. So
535
+ * is a file gone silent on sync: while `slot` is recorded: taken, it would retire the slot.
349
536
  */
350
- export function rereadSources(prev, read, runUrl, catalog, database, root, o) {
537
+ export function rereadSources(prev, read, runUrl, catalog, database, root, o = {}) {
351
538
  let problem = null;
352
539
  let next = null;
353
540
  if ("message" in read)
354
541
  problem = `${read.message}${read.why ? `: ${read.why.replace(/\n/g, "; ")}` : ""}`;
355
542
  else if (read.resolved !== runUrl) {
356
- problem = `${shownPath(root, read.file)}: url: now names ${maskedUrl(read.resolved)} (${read.from}), and this run copies ${maskedUrl(runUrl)}: mason stop && mason start copies from the new one`;
543
+ problem = `${shownPath(root, read.file)}: url: now names ${maskedUrl(read.resolved)} (${read.from}), and this run syncs ${maskedUrl(runUrl)}: mason stop && mason start syncs from the new one`;
544
+ }
545
+ else if (silentOverSlot((o.slot ?? null) !== null, read)) {
546
+ problem = `${shownPath(root, read.file)}: no sync: at its top or on an entry, and this project streams through replication slot ${o.slot}: ` +
547
+ "write sync: replication at its top to keep streaming, or sync: off or sync: poll <interval> to drop the slot";
548
+ }
549
+ else if (o.replicationChecked === false && replicatedEntries(read).length > 0) {
550
+ // a table set to replication in a run that never checked the database for it (the commands' edits are checked, the
551
+ // run server's checkReplication): kept as it was, never frozen by a slot the database cannot make
552
+ const names = replicatedEntries(read).map((e) => entryName(e.table));
553
+ problem = `${shownPath(root, read.file)}: ${names.join(", ")} ${names.length === 1 ? "is" : "are"} sync: replication, and this run has not checked ${database} for logical replication ` +
554
+ "(no table was replicated when it started): mason stop && mason start checks it and streams them";
357
555
  }
358
556
  else {
359
- const t = addedTables(read, catalog, database, root, o);
557
+ const t = addedTables(read, catalog, database, root);
360
558
  if (Array.isArray(t))
361
559
  next = t;
362
560
  else
@@ -414,6 +612,8 @@ export function entryText(e, indent) {
414
612
  body.push(`exclude: ${flowList(e.exclude)}`);
415
613
  if (e.key !== null)
416
614
  body.push(`key: ${flowList(e.key)}`);
615
+ if (e.sync)
616
+ body.push(`sync: ${syncText(e.sync)}`);
417
617
  if (!body.length)
418
618
  return `${indent}${yamlName(e.name)}: {}\n`;
419
619
  return `${indent}${yamlName(e.name)}:\n${body.map((b) => `${indent} ${b}\n`).join("")}`;
@@ -498,6 +698,65 @@ function withTableLf(text, e) {
498
698
  const at = lineEnd(lv?.range ? lv.range[1] : last.key.range[1]);
499
699
  return { text: `${text.slice(0, at)}${at > 0 && text[at - 1] !== "\n" ? "\n" : ""}${entryText(e, indent)}${text.slice(at)}` };
500
700
  }
701
+ /**
702
+ * The file with ONE entry's own `sync:` set (`sync`), or taken out (null: it follows the file's again), and every other
703
+ * byte as it was — the entry's columns, key and comments too (`mason add orders --sync "poll 30s"` on a table already
704
+ * added): the value spliced at its node's range, a line of its own under the entry's last key at its keys' indent, or —
705
+ * an entry written as a flow map (`{}`, `{ columns: [...] }`) — the entry written again a key a line, a comment after it
706
+ * on the key's line kept. `e` is the entry as the file reads (its columns and key, for a flow map written again).
707
+ */
708
+ export function withEntrySync(text, e, sync) {
709
+ return inOwnLineEndings(text, (t) => withEntrySyncLf(t, e, sync));
710
+ }
711
+ function withEntrySyncLf(text, e, sync) {
712
+ const r = forEdit(text);
713
+ if ("problem" in r)
714
+ return r;
715
+ const { pair, lineStart, lineEnd } = r;
716
+ const map = pair?.value;
717
+ if (!isMap(map))
718
+ return withTableLf(text, { ...e, sync });
719
+ if (map.flow)
720
+ return { problem: FLOW_MAP };
721
+ const same = entryPairs(map).find((x) => x.table === e.table);
722
+ if (same === undefined)
723
+ return withTableLf(text, { ...e, sync });
724
+ const value = same.pair.value;
725
+ if (isMap(value) && !value.flow && value.items.length > 0) {
726
+ const own = value.items.find((p) => isScalar(p.key) && p.key.value === "sync");
727
+ if (own !== undefined) {
728
+ const k = own.key, v = own.value;
729
+ // its line cut, unless it is the entry's only key: then the entry is `{}` again
730
+ if (sync === null && value.items.length === 1)
731
+ return withTableLf(text, { ...e, name: same.name, sync: null });
732
+ if (sync === null)
733
+ return { text: `${text.slice(0, lineStart(k.range[0]))}${text.slice(lineEnd(v?.range ? v.range[1] : k.range[1]))}` };
734
+ if (!v?.range)
735
+ return { problem: `its ${e.name}.sync: has no value to replace` };
736
+ return { text: `${text.slice(0, v.range[0])}${syncText(sync)}${text.slice(v.range[1])}` };
737
+ }
738
+ if (sync === null)
739
+ return { text };
740
+ const first = value.items[0].key;
741
+ const indent = text.slice(lineStart(first.range[0]), first.range[0]);
742
+ const last = value.items[value.items.length - 1];
743
+ const lv = last.value;
744
+ const at = lineEnd(lv?.range ? lv.range[1] : last.key.range[1]);
745
+ return { text: `${text.slice(0, at)}${at > 0 && text[at - 1] !== "\n" ? "\n" : ""}${indent}sync: ${syncText(sync)}\n${text.slice(at)}` };
746
+ }
747
+ if (sync === null)
748
+ return { text };
749
+ // a flow map (`orders: {}`, `orders: { columns: [id] }`) or nothing: the entry written again, a comment on its line kept
750
+ const k = same.pair.key, v = value;
751
+ // an entry with no value (`orders: # c`) has an empty scalar placed AT its comment: the comment is read from the key
752
+ const after = v?.range && v.range[1] > v.range[0] ? v.range[1] : k.range[1];
753
+ const end = lineEnd(after);
754
+ const comment = /(\s+#.*)$/.exec(text.slice(after, end).replace(/\n$/, ""))?.[1] ?? "";
755
+ const indent = text.slice(lineStart(k.range[0]), k.range[0]);
756
+ const lines = entryText({ ...e, name: same.name, sync }, indent).split("\n");
757
+ lines[0] = `${lines[0]}${comment}`;
758
+ return { text: `${text.slice(0, lineStart(k.range[0]))}${lines.join("\n")}${text.slice(end)}` };
759
+ }
501
760
  /**
502
761
  * The file without its entry for `table`: its lines cut, every other byte as it was; the last entry gone leaves
503
762
  * `tables: {}`. Null when no entry names that table.
@@ -34,7 +34,8 @@ export function colourOn(stream, env = process.env) {
34
34
  /** The style for a stream: colour when colourOn, and its width when it is a terminal that says one. */
35
35
  export function styleFor(stream, env = process.env) {
36
36
  const width = stream.isTTY === true && stream.columns !== undefined && stream.columns >= 40 ? stream.columns : null;
37
- return colourOn(stream, env) ? { ...COLOUR, width } : { ...PLAIN, width };
37
+ const columns = stream.isTTY === true && stream.columns !== undefined && stream.columns >= 1 ? { columns: stream.columns } : {};
38
+ return colourOn(stream, env) ? { ...COLOUR, width, ...columns } : { ...PLAIN, width, ...columns };
38
39
  }
39
40
  /** stdout's style (what a command prints) and stderr's (its progress), read when first asked. */
40
41
  export const out = () => styleFor(process.stdout);
@@ -27,6 +27,7 @@ import * as path from "node:path";
27
27
  import { spawn, spawnSync } from "node:child_process";
28
28
  import { fileURLToPath } from "node:url";
29
29
  import { exitWhenWritten } from "../lib/exit-when-written.js";
30
+ import { LogReader } from "../lib/log-read.js";
30
31
  export const READY_LINE = "mason start: ready";
31
32
  export const RESTARTS_FILE = "restarts.json";
32
33
  export const SUPERVISOR_FILE = "supervisor.json";
@@ -42,9 +43,20 @@ export function readRestarts(home) {
42
43
  const v = JSON.parse(fs.readFileSync(file, "utf8"));
43
44
  return Array.isArray(v.restarts) ? v.restarts : [];
44
45
  }
46
+ export function restartsFact(home, read = () => readRestarts(home)) {
47
+ let r;
48
+ try {
49
+ r = read();
50
+ }
51
+ catch (e) {
52
+ return { error: `${path.join(home, RESTARTS_FILE)} could not be read: ${e.message}` };
53
+ }
54
+ const last = r[r.length - 1];
55
+ return { count: r.length, last: last === undefined ? null : { at: last.at, why: last.reason, upSeconds: last.upSeconds } };
56
+ }
45
57
  /** Why a run died, in the words a person reads: V8's own fatal line when it printed one */
46
58
  export function crashReason(code, signal, fatal) {
47
- // Mason's own fatal line (src/cli/dev.ts: the copy's thread stopped) is said as Mason said it
59
+ // Mason's own fatal line (src/cli/dev.ts: the engine's thread stopped) is said as Mason said it
48
60
  if (fatal !== null)
49
61
  return /heap out of memory/i.test(fatal) && !fatal.startsWith("Mason: ") ? "it ran out of JavaScript heap (V8: heap out of memory)"
50
62
  : fatal.startsWith("Mason: ") ? fatal.slice("Mason: ".length) : `V8: ${fatal}`;
@@ -116,33 +128,24 @@ function stillRunning(home, note) {
116
128
  }
117
129
  }
118
130
  /** What the run wrote to the log from `from` on: whether it said it was ready, and V8's fatal line if it printed one.
119
- * Read after it exits, in chunks: a run's log with every statement traced (MASON_PG_TRACE) runs to hundreds of MB. */
131
+ * Read after it exits, in chunks, through the one reader (src/lib/log-read.ts: each line whole, masked): a run's log
132
+ * with every statement traced (MASON_PG_TRACE) runs to hundreds of MB. */
120
133
  export function scanLog(file, from) {
121
134
  let ready = false;
122
135
  let fatal = null;
123
- const fd = fs.openSync(file, "r");
124
- try {
125
- const buf = Buffer.alloc(1 << 20);
126
- let partial = "";
127
- for (let at = from;;) {
128
- const n = fs.readSync(fd, buf, 0, buf.length, at);
129
- const lines = (partial + buf.toString("utf8", 0, n)).split("\n");
130
- partial = n === 0 ? "" : lines.pop() ?? "";
131
- for (const l of lines) {
132
- if (l === READY_LINE)
133
- ready = true;
134
- const f = /^FATAL ERROR: (.*)$/.exec(l.trim());
135
- if (f)
136
- fatal = f[1];
137
- }
138
- if (n === 0)
139
- break;
140
- at += n;
136
+ const reader = new LogReader(file, { from });
137
+ const scan = (text) => {
138
+ for (const l of text.split("\n")) {
139
+ if (l === READY_LINE)
140
+ ready = true;
141
+ const f = /^FATAL ERROR: (.*)$/.exec(l.trim());
142
+ if (f)
143
+ fatal = f[1];
141
144
  }
142
- }
143
- finally {
144
- fs.closeSync(fd);
145
- }
145
+ };
146
+ for (let text = reader.read(); text !== null; text = reader.read())
147
+ scan(text);
148
+ scan(reader.rest());
146
149
  return { ready, fatal };
147
150
  }
148
151
  /** Runs the run until it stops for good; the exit code is the last run's (a start that failed: its own). */