@mason-data/cli 0.7.0 → 0.8.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 (111) hide show
  1. package/README.md +90 -71
  2. package/dist/src/api/error.js +3 -0
  3. package/dist/src/api/postgres/catalog.js +222 -13
  4. package/dist/src/api/postgres/encode.js +3 -1
  5. package/dist/src/api/postgres/explain.js +3 -2
  6. package/dist/src/api/postgres/protocol.js +39 -23
  7. package/dist/src/api/postgres/server.js +1173 -207
  8. package/dist/src/api/postgres/translate.js +81 -9
  9. package/dist/src/api/request-log.js +18 -5
  10. package/dist/src/cdc/poll.js +5 -5
  11. package/dist/src/cli/background.js +29 -6
  12. package/dist/src/cli/cancel.js +75 -0
  13. package/dist/src/cli/command.js +1 -0
  14. package/dist/src/cli/commands.js +622 -183
  15. package/dist/src/cli/control.js +59 -0
  16. package/dist/src/cli/demo.js +19 -10
  17. package/dist/src/cli/dev.js +351 -111
  18. package/dist/src/cli/doctor.js +20 -10
  19. package/dist/src/cli/guides.js +79 -31
  20. package/dist/src/cli/init.js +119 -58
  21. package/dist/src/cli/machine.js +12 -11
  22. package/dist/src/cli/main.js +3 -3
  23. package/dist/src/cli/mcp.js +29 -20
  24. package/dist/src/cli/metrics.js +79 -0
  25. package/dist/src/cli/pg-tls.js +13 -3
  26. package/dist/src/cli/pg-url.js +4 -0
  27. package/dist/src/cli/pin.js +13 -12
  28. package/dist/src/cli/preflight.js +8 -7
  29. package/dist/src/cli/project.js +43 -4
  30. package/dist/src/cli/readiness.js +38 -4
  31. package/dist/src/cli/registry.js +53 -34
  32. package/dist/src/cli/replication-checks.js +7 -5
  33. package/dist/src/cli/replication-mode.js +105 -26
  34. package/dist/src/cli/request-filter.js +134 -0
  35. package/dist/src/cli/roles.js +584 -0
  36. package/dist/src/cli/run-server.js +483 -41
  37. package/dist/src/cli/runs.js +1 -1
  38. package/dist/src/cli/security.js +10 -8
  39. package/dist/src/cli/serve.js +144 -42
  40. package/dist/src/cli/share-run.js +286 -0
  41. package/dist/src/cli/share.js +286 -0
  42. package/dist/src/cli/source-edit.js +65 -13
  43. package/dist/src/cli/sources.js +287 -38
  44. package/dist/src/cli/supervise.js +12 -1
  45. package/dist/src/cli/sync.js +55 -25
  46. package/dist/src/cli/tunnel.js +200 -0
  47. package/dist/src/cli/ui.js +9 -6
  48. package/dist/src/cli/usage.js +128 -0
  49. package/dist/src/cli/user.js +496 -40
  50. package/dist/src/cli/version.js +9 -7
  51. package/dist/src/demo/seed.js +3 -0
  52. package/dist/src/duck/cancel.js +10 -1
  53. package/dist/src/duck/events.js +134 -0
  54. package/dist/src/duck/executor-worker.js +18 -9
  55. package/dist/src/duck/executor.js +43 -30
  56. package/dist/src/duck/explain.js +56 -46
  57. package/dist/src/duck/extensions.js +160 -0
  58. package/dist/src/duck/functions.js +13 -14
  59. package/dist/src/duck/gaps.js +17 -8
  60. package/dist/src/duck/grants.js +176 -0
  61. package/dist/src/duck/history.js +123 -0
  62. package/dist/src/duck/kept.js +6 -6
  63. package/dist/src/duck/plan.js +102 -25
  64. package/dist/src/duck/publication.js +6 -6
  65. package/dist/src/duck/requests.js +265 -93
  66. package/dist/src/duck/rewrite.js +25 -5
  67. package/dist/src/duck/row-filter.js +649 -0
  68. package/dist/src/duck/store.js +156 -120
  69. package/dist/src/duck/stream.js +10 -5
  70. package/dist/src/duck/sync.js +835 -268
  71. package/dist/src/duck/types.js +6 -5
  72. package/dist/src/duck/usage.js +324 -0
  73. package/dist/src/duck/users.js +64 -15
  74. package/dist/src/duck/verified.js +3 -0
  75. package/dist/src/duck/views.js +2 -3
  76. package/dist/src/lib/database-name.js +62 -0
  77. package/dist/src/lib/dotenv.js +17 -4
  78. package/dist/src/lib/log-buffer.js +68 -0
  79. package/dist/src/lib/pg-numeric.js +5 -2
  80. package/dist/src/lib/project-version.js +23 -23
  81. package/dist/src/lib/table-sync.js +267 -0
  82. package/dist/src/web/app.css +706 -66
  83. package/dist/src/web/app.js +327 -410
  84. package/dist/src/web/charts.js +229 -0
  85. package/dist/src/web/index.html +82 -18
  86. package/dist/src/web/pages/queries.js +192 -0
  87. package/dist/src/web/pages/status.js +446 -0
  88. package/dist/src/web/pages/syncs.js +155 -0
  89. package/dist/src/web/pages/tables.js +434 -0
  90. package/dist/src/web/pages/usage.js +397 -0
  91. package/dist/src/web/pages/users.js +218 -0
  92. package/dist/src/web/shared.js +568 -0
  93. package/dist/src/web/vendor/README.md +25 -0
  94. package/dist/src/web/vendor/geist-1.7.2/Geist-Variable.woff2 +0 -0
  95. package/dist/src/web/vendor/geist-1.7.2/GeistMono-Variable.woff2 +0 -0
  96. package/dist/src/web/vendor/geist-1.7.2/LICENSE.txt +92 -0
  97. package/dist/src/web/vendor/lucide-1.56.0/LICENSE.txt +43 -0
  98. package/dist/src/web/vendor/lucide-1.56.0/chart-column.mjs +29 -0
  99. package/dist/src/web/vendor/lucide-1.56.0/layout-dashboard.mjs +29 -0
  100. package/dist/src/web/vendor/lucide-1.56.0/logs.mjs +34 -0
  101. package/dist/src/web/vendor/lucide-1.56.0/menu.mjs +28 -0
  102. package/dist/src/web/vendor/lucide-1.56.0/square-terminal.mjs +28 -0
  103. package/dist/src/web/vendor/lucide-1.56.0/table.mjs +29 -0
  104. package/dist/src/web/vendor/lucide-1.56.0/users.mjs +29 -0
  105. package/dist/src/web/vendor/lucide-1.56.0/x.mjs +27 -0
  106. package/dist/src/web/vendor/uplot-1.6.32/LICENSE.txt +21 -0
  107. package/dist/src/web/vendor/uplot-1.6.32/uPlot.esm.js +6140 -0
  108. package/dist/src/web/vendor/uplot-1.6.32/uPlot.min.css +1 -0
  109. package/dist/src/web/vendor/zilla-slab-5.3.0/LICENSE.txt +93 -0
  110. package/dist/src/web/vendor/zilla-slab-5.3.0/zilla-slab-latin-700-normal.woff2 +0 -0
  111. package/package.json +10 -3
@@ -1,12 +1,13 @@
1
1
  /**
2
- * The package's version when installed; else the image's (`MASON_VERSION`, stamped at build by images-publish.yml:
3
- * `v0.6.1` for a release, said `0.6.1` as the package says it, or `main-<sha>`); a checkout runs as "dev" (and
4
- * ClickHouse's `main` image).
2
+ * What this Mason is, and where that was read (what a person running it can act on follows from it: `mason serve`'s pin
3
+ * says the image to run, or the npm line): the package's version when installed; else the image's (`MASON_VERSION`,
4
+ * stamped at build by images-publish.yml: `v0.6.1` for a release, said `0.6.1` as the package says it, or `main-<sha>`);
5
+ * a checkout runs as "dev".
5
6
  */
6
7
  import * as fs from "node:fs";
7
8
  import * as path from "node:path";
8
9
  import { fileURLToPath } from "node:url";
9
- export function version() {
10
+ export function runningVersion() {
10
11
  // src/cli/ in a checkout (the repo's package.json, which is not this one), dist/src/cli/ in the package
11
12
  const here = path.dirname(fileURLToPath(import.meta.url));
12
13
  for (const p of [path.join(here, "..", "..", "package.json"), path.join(here, "..", "..", "..", "package.json")]) {
@@ -14,10 +15,11 @@ export function version() {
14
15
  continue;
15
16
  const pkg = JSON.parse(fs.readFileSync(p, "utf8"));
16
17
  if (pkg.name === "@mason-data/cli" && pkg.version)
17
- return pkg.version;
18
+ return { text: pkg.version, from: "package" };
18
19
  }
19
20
  const image = process.env.MASON_VERSION?.trim();
20
21
  if (image)
21
- return /^v\d+\.\d+\.\d+$/.test(image) ? image.slice(1) : image;
22
- return "dev";
22
+ return { text: /^v\d+\.\d+\.\d+$/.test(image) ? image.slice(1) : image, from: "image" };
23
+ return { text: "dev", from: "checkout" };
23
24
  }
25
+ export const version = () => runningVersion().text;
@@ -45,6 +45,9 @@ export const DEMO_TABLES = ["customers", "products", "orders", "order_items"];
45
45
  export const DEMO_TABLE_TO_ADD = "order_items";
46
46
  /** the tables `mason init demo` writes to the sources file: the database's, but the one left to add */
47
47
  export const DEMO_ADDED_TABLES = DEMO_TABLES.filter((t) => t !== DEMO_TABLE_TO_ADD);
48
+ /** how the demo's tables are kept current (the sources file's top `sync:`, src/lib/table-sync.ts): polled every 5 s,
49
+ * so the demo shows a copy keeping up with its writer — where a project `mason init` makes is `off` */
50
+ export const DEMO_SYNC = { mode: "poll", every: "5s", seconds: 5 };
48
51
  /** an order's status moves along this path (`changeSql`); `cancelled` is a side exit */
49
52
  export const ORDER_STATUSES = ["pending", "paid", "shipped", "delivered"];
50
53
  const ROWS_PER_INSERT = 1_000;
@@ -12,13 +12,22 @@
12
12
  * running`: a cancel of a statement already past never reaches the next one, a cancel that lands before its statement
13
13
  * starts still does, and a statement being drained after its cancel stays cancelled whatever the next one's number.
14
14
  * Dependency-free: the listener's thread loads it without DuckDB.
15
+ *
16
+ * ONE CELL MORE, THE WORKER'S BEAT: a statement that calls a Mason function holds the worker's event loop for a thousand
17
+ * calls at a time (minutes over wide documents), so no message can tell whether its JavaScript runs. Every call, and every
18
+ * row of a call, adds to the cell, and the executor's watch reads it (src/duck/executor.ts SILENT_MS).
15
19
  */
16
20
  /** how many wire sessions may hold a DuckDB connection at once (a slot each) */
17
21
  export const CANCEL_SLOTS = 4096;
18
22
  /** a fresh table (the executor makes one and hands it to its worker) */
19
- export function cancelTable() { return new Int32Array(new SharedArrayBuffer(CANCEL_SLOTS * 2 * 4)); }
23
+ export function cancelTable() { return new Int32Array(new SharedArrayBuffer((CANCEL_SLOTS * 2 + 1) * 4)); }
20
24
  const cancelledAt = (slot) => slot * 2;
21
25
  const runningAt = (slot) => slot * 2 + 1;
26
+ const BEAT_AT = CANCEL_SLOTS * 2;
27
+ /** the worker's JavaScript is running a Mason function (the cell is only ever compared, so its wrap is nothing) */
28
+ export function beat(t) { Atomics.add(t, BEAT_AT, 1); }
29
+ /** the beat as it stands: what the watch compares with its last look */
30
+ export function beats(t) { return Atomics.load(t, BEAT_AT); }
22
31
  /** a slot handed to a new session: nothing cancelled, nothing running */
23
32
  export function resetSlot(t, slot) { Atomics.store(t, cancelledAt(slot), 0); Atomics.store(t, runningAt(slot), 0); }
24
33
  /** statement `seq` of the session in `slot` is cancelled (and every one before it): monotonic, from either thread */
@@ -0,0 +1,134 @@
1
+ import { MASON_SCHEMA } from "./store.js";
2
+ import { Batches, History, Streak } from "./history.js";
3
+ export const EVENTS_TABLE = `${MASON_SCHEMA}.sync_events`;
4
+ export const EVENTS_DDL = `CREATE TABLE IF NOT EXISTS ${EVENTS_TABLE} (happened_at TIMESTAMPTZ, seq BIGINT, kind VARCHAR, table_name VARCHAR, message VARCHAR)`;
5
+ /**
6
+ * WHAT AN EVENT IS, one word each: `copy_failed`, `poll_failed`, `catch_up_failed` (the one poll that brings a new copy
7
+ * current), `stream_failed` (the stream stopped, or refused a table: `table` says which), `source_unreachable` (the
8
+ * database could not be attached), `restart` (the supervisor started the run again after it died) — each a failure;
9
+ * `copied` (a copy finished and was swapped in), `recovered` (a table whose poll, catch-up or stream failed is
10
+ * current again; of no table, the stream applying changes or the database attached again after it failed) and
11
+ * `synced` (a sync asked by hand — mason sync, the page's Sync now — and what it did), and `sync_changed` (a table's
12
+ * `sync:` in the sources file changed while it is copied: from what, to what, and how its copy is caught up). A scheduled poll that succeeds
13
+ * is no event: one every few seconds would bury the failures; its last result is the table's last sync.
14
+ */
15
+ export const EVENT_KINDS = ["copy_failed", "poll_failed", "catch_up_failed", "stream_failed", "source_unreachable", "restart", "copied", "recovered", "synced", "sync_changed"];
16
+ const FAILURES = new Set(["copy_failed", "poll_failed", "catch_up_failed", "stream_failed", "source_unreachable", "restart"]);
17
+ /** whether an event of this kind is a failure: the one place that says it (the reader's filter, `failed`) */
18
+ export const isFailure = (kind) => FAILURES.has(kind);
19
+ const FLUSH_MS = 1000;
20
+ const BATCH_ROWS = 500;
21
+ /** a failure said word for word again within this is the event already written (unless it was over between: `record`) */
22
+ export const REPEAT_MS = 60 * 60 * 1000;
23
+ export class SyncEvents {
24
+ next = 0;
25
+ lost = new Streak();
26
+ closed = false;
27
+ /** each failure's last event, by kind and table: a repeat within REPEAT_MS is not written again */
28
+ lastFailure = new Map();
29
+ history;
30
+ batches;
31
+ log;
32
+ options;
33
+ days;
34
+ constructor(history, log, options) {
35
+ this.history = history;
36
+ this.log = log;
37
+ this.options = options;
38
+ this.days = options.days;
39
+ this.batches = new Batches({ rows: BATCH_ROWS, ms: options.flushMs ?? FLUSH_MS, write: (rows) => this.write(rows) });
40
+ }
41
+ /** The events of a store: their table made, the events past the retention deleted, and a trim every hour. */
42
+ static async open(store, log, options) {
43
+ const history = await History.open(store, [EVENTS_DDL]);
44
+ const events = new SyncEvents(history, log, options);
45
+ events.next = Number((await history.reader.runAndReadAll(`SELECT coalesce(max(seq), -1) + 1 FROM ${EVENTS_TABLE}`)).getRowsJS()[0][0]);
46
+ await history.keepTrimmed(() => events.trim(), (e) => log(`sync events: deleting the events older than ${events.days} days failed: ${e.message}`));
47
+ return events;
48
+ }
49
+ now() { return this.options.now?.() ?? Date.now(); }
50
+ /** One event, written with the next batch. Never throws: the sync that records it goes on whatever happens here. */
51
+ record(e) {
52
+ if (this.closed) {
53
+ this.log(`sync events: an event after they closed, not recorded (${e.kind}${e.table ? ` ${e.table}` : ""}: ${e.message.split("\n")[0]})`);
54
+ return;
55
+ }
56
+ const now = this.now();
57
+ // a failure over (its table current again or copied anew; of no table, the whole sync's) is said again when it recurs
58
+ if (e.kind === "recovered" || e.kind === "copied") {
59
+ for (const k of [...this.lastFailure.keys()])
60
+ if (k.endsWith(`\0${e.table ?? ""}`))
61
+ this.lastFailure.delete(k);
62
+ }
63
+ else if (isFailure(e.kind)) {
64
+ const key = `${e.kind}\0${e.table ?? ""}`;
65
+ const last = this.lastFailure.get(key);
66
+ if (last !== undefined && last.message === e.message && now - last.at < REPEAT_MS)
67
+ return;
68
+ this.lastFailure.set(key, { message: e.message, at: now });
69
+ }
70
+ this.batches.push({ at: new Date(now), seq: this.next++, e });
71
+ }
72
+ /** Writes what is waiting, after any flush in flight. Resolves whether it was written or not (a failure is said). */
73
+ flush() { return this.batches.flush(); }
74
+ async write(rows) {
75
+ const sql = `INSERT INTO ${EVENTS_TABLE} (happened_at, seq, kind, table_name, message) VALUES ` +
76
+ rows.map((_, i) => `($${i * 5 + 1}::TIMESTAMPTZ, $${i * 5 + 2}, $${i * 5 + 3}, $${i * 5 + 4}, $${i * 5 + 5})`).join(", ");
77
+ const values = rows.flatMap((r) => [r.at.toISOString(), r.seq, r.e.kind, r.e.table, r.e.message]);
78
+ try {
79
+ if (this.options.insert !== undefined)
80
+ await this.options.insert(sql, values);
81
+ else
82
+ await this.history.writer.run(sql, values);
83
+ }
84
+ catch (e) {
85
+ // Tolerated, narrowly, as the request log's: the sync's own state and log line hold the event, and a record that
86
+ // cannot be written must not stop the sync. SAID, once per streak with what it cost
87
+ this.lost.failed(rows.length, () => this.log(`sync events: could not record ${rows.length} event(s) in ${EVENTS_TABLE} (${rows[0].e.kind}${rows[0].e.table ? ` ${rows[0].e.table}` : ""}): ` +
88
+ `${e.message}; said once until a write succeeds (the log has each one)`));
89
+ return;
90
+ }
91
+ this.lost.succeeded((n) => this.log(`sync events: recording again; ${n} event(s) were not recorded (the log has them)`));
92
+ }
93
+ /** Deletes the events older than the retention; the count is said when there were any. */
94
+ async trim() {
95
+ const r = await this.history.writer.runAndReadAll(`DELETE FROM ${EVENTS_TABLE} WHERE happened_at < now() - INTERVAL (${this.days}) DAY`);
96
+ const n = Number(r.getRowsJS()[0]?.[0] ?? 0);
97
+ if (n > 0)
98
+ this.log(`sync events: deleted ${n} event(s) older than ${this.days} days (MASON_HISTORY_DAYS)`);
99
+ return n;
100
+ }
101
+ /** The newest `limit` events, newest LAST; `since`: at or after it; `failed`: the failures only. Committed ones only. */
102
+ async recent(o) {
103
+ const where = [];
104
+ const values = [o.limit];
105
+ if (o.failed)
106
+ where.push(`kind IN (${[...FAILURES].map((k) => `'${k}'`).join(", ")})`);
107
+ if (o.since !== undefined) {
108
+ values.push(o.since);
109
+ where.push(`happened_at >= $${values.length}::TIMESTAMPTZ`);
110
+ }
111
+ const rows = (await this.history.reader.runAndReadAll(`SELECT * FROM (SELECT epoch_ms(happened_at) AS ms, seq, kind, table_name, message FROM ${EVENTS_TABLE}` +
112
+ `${where.length ? ` WHERE ${where.join(" AND ")}` : ""} ORDER BY happened_at DESC, seq DESC LIMIT $1) ORDER BY ms, seq`, values)).getRowsJS();
113
+ return rows.map(([ms, , kind, table, message]) => eventOf(new Date(Number(ms)).toISOString(), kind, table, message));
114
+ }
115
+ /** Writes what is waiting and closes the events' connections (mason stop). */
116
+ async close() {
117
+ this.history.stopTrimming();
118
+ await this.flush();
119
+ this.closed = true;
120
+ this.history.closeConnections();
121
+ }
122
+ }
123
+ const eventOf = (at, kind, table, message) => ({ at, kind, failed: isFailure(kind), table, message });
124
+ /**
125
+ * THE EVENTS WITH THE RUN'S RESTARTS (src/cli/supervise.ts `.mason/restarts.json`), as ONE list newest last, asked as
126
+ * `recent` is (the newest `limit`, since, the failures only — a restart is one), within the retention as the events are.
127
+ */
128
+ export function withRestarts(events, restarts, o) {
129
+ const from = Math.max(o.since === undefined ? -Infinity : Date.parse(o.since), (o.now ?? Date.now()) - o.days * 86_400_000);
130
+ const said = restarts.filter((r) => Date.parse(r.at) >= from)
131
+ .map((r) => eventOf(new Date(r.at).toISOString(), "restart", null, `the run stopped (${r.reason}) after ${r.upSeconds} s up, and its supervisor started it again`));
132
+ // a stable sort: an event and a restart of one millisecond keep the events' order, the restart after
133
+ return [...events, ...said].sort((a, b) => Date.parse(a.at) - Date.parse(b.at)).slice(-o.limit);
134
+ }
@@ -6,7 +6,7 @@
6
6
  * — each with its id, and says its log lines as messages.
7
7
  *
8
8
  * THIS thread never waits for the executor (src/duck/store.ts "HOW A STATEMENT IS CANCELLED"): a result or a
9
- * connection is closed only once DuckDB has handed it back. A request is answered asynchronously, so the turns between
9
+ * connection is closed only once DuckDB ended what ran on it. A request is answered asynchronously, so the turns between
10
10
  * a statement's function calls serve the others; a statement's cancel does not need one (the cancel table,
11
11
  * src/duck/cancel.ts).
12
12
  */
@@ -48,7 +48,7 @@ const abortWith = (c, reason) => {
48
48
  /** a connection's call, held in `connCalls` until it settles */
49
49
  function onConn(n, f) {
50
50
  if (stopping)
51
- return Promise.reject(new Error("the copy is closing: no statement starts on it any more"));
51
+ return Promise.reject(new Error("the engine is closing: no statement starts on it any more"));
52
52
  const p = f(need(conns, n, "connection"));
53
53
  const calls = connCalls.get(n) ?? new Set();
54
54
  connCalls.set(n, calls);
@@ -71,7 +71,7 @@ function closeRunner(n) {
71
71
  const need = (m, n, what) => {
72
72
  const v = m.get(n);
73
73
  if (v === undefined)
74
- throw new Error(`no ${what} ${n} on the copy's thread (Mason bug: report it)`);
74
+ throw new Error(`no ${what} ${n} on the engine's thread (Mason bug: report it)`);
75
75
  return v;
76
76
  };
77
77
  async function answer(m) {
@@ -87,7 +87,7 @@ async function answer(m) {
87
87
  }
88
88
  case "connect": {
89
89
  if (instance === null)
90
- throw new Error("the copy is not open (Mason bug: report it)");
90
+ throw new Error("the engine is not open (Mason bug: report it)");
91
91
  const n = nextConn++;
92
92
  conns.set(n, await instance.connect());
93
93
  return { value: n };
@@ -114,13 +114,22 @@ async function answer(m) {
114
114
  conns.get(Number(m.conn))?.interrupt();
115
115
  return undefined;
116
116
  case "conn.close": {
117
- const c = conns.get(Number(m.conn));
118
- conns.delete(Number(m.conn));
117
+ const n = Number(m.conn);
118
+ // NEVER CLOSED UNDER A CALL OF ITS OWN: the close is synchronous here and waits for that call's tasks, and a task
119
+ // inside a Mason function waits for this thread. The close says nobody wants the call's answer, so it is
120
+ // interrupted, and its failure (a call's own promise, handled by whoever asked) waited for off this thread
121
+ const calls = [...(connCalls.get(n) ?? [])];
122
+ if (calls.length > 0) {
123
+ conns.get(n)?.interrupt();
124
+ await Promise.allSettled(calls);
125
+ }
126
+ const c = conns.get(n);
127
+ conns.delete(n);
119
128
  try {
120
129
  c?.closeSync();
121
130
  }
122
131
  catch (e) {
123
- log(`closing a connection of the copy failed: ${e.message}`);
132
+ log(`closing a connection of the engine failed: ${e.message}`);
124
133
  }
125
134
  return undefined;
126
135
  }
@@ -131,7 +140,7 @@ async function answer(m) {
131
140
  case "function rows": return { value: Object.fromEntries(FUNCTION_ROWS) };
132
141
  case "runner.open": {
133
142
  if (instance === null)
134
- throw new Error("the copy is not open (Mason bug: report it)");
143
+ throw new Error("the engine is not open (Mason bug: report it)");
135
144
  const n = nextRunner++;
136
145
  runners.set(n, await SessionRunner.open(instance, log, Number(m.slot), cancels));
137
146
  return { value: n };
@@ -246,7 +255,7 @@ async function answer(m) {
246
255
  return { value: null };
247
256
  }
248
257
  }
249
- throw new Error(`the copy's thread was asked ${JSON.stringify(m.op)}, which it does not know (Mason bug: report it)`);
258
+ throw new Error(`the engine's thread was asked ${JSON.stringify(m.op)}, which it does not know (Mason bug: report it)`);
250
259
  }
251
260
  port.on("message", (m) => {
252
261
  // the executor's watch (src/duck/executor.ts SILENT_MS): answered at once, by a thread whose loop turns
@@ -38,7 +38,7 @@ import { runnable } from "../lib/runnable.js";
38
38
  import { StatementError } from "../api/postgres/server.js";
39
39
  import { EncodeError } from "../api/postgres/encode.js";
40
40
  import { NumericError } from "../lib/pg-numeric.js";
41
- import { CANCEL_SLOTS, cancelStatement, cancelTable, resetSlot } from "./cancel.js";
41
+ import { beats, CANCEL_SLOTS, cancelStatement, cancelTable, resetSlot } from "./cancel.js";
42
42
  // a cycle (store.ts opens the executor): read at run time only
43
43
  import { failureOf } from "./store.js";
44
44
  /** A DuckDB type from its `toJson()` (the worker's), as `@duckdb/node-api`'s own classes. */
@@ -59,7 +59,7 @@ export function typeFromJson(j) {
59
59
  }
60
60
  const simple = SIMPLE_TYPES.get(id);
61
61
  if (simple === undefined)
62
- throw new Error(`a DuckDB type Mason cannot carry between threads: ${JSON.stringify(j)} (Mason bug: report it)`);
62
+ throw new Error(`a type of the engine's that Mason cannot carry between threads: ${JSON.stringify(j)} (Mason bug: report it)`);
63
63
  return simple(alias);
64
64
  }
65
65
  /** every type of no parameters, by its id: each class's `create(alias)` */
@@ -112,11 +112,11 @@ export function valueFromClone(v, t) {
112
112
  }
113
113
  export function wireError(e) {
114
114
  if (e instanceof StatementError)
115
- return { kind: "statement", fields: e.fields, sql: e.sql };
115
+ return { kind: "statement", fields: e.fields, sql: e.sql, gap: e.gap };
116
116
  if (e instanceof EncodeError)
117
- return { kind: "encode", message: e.message, code: e.code };
117
+ return { kind: "encode", message: e.message, code: e.code, gap: e.gap };
118
118
  if (e instanceof NumericError)
119
- return { kind: "numeric", message: e.message, sqlstate: e.sqlstate };
119
+ return { kind: "numeric", message: e.message, sqlstate: e.sqlstate, gap: e.gap };
120
120
  return { kind: "error", message: e instanceof Error ? e.message : String(e), name: e instanceof Error ? e.name : "Error" };
121
121
  }
122
122
  /** the worker is gone: what every request in flight, and every later one, is answered. `stopped`: by the run's own stop
@@ -131,11 +131,11 @@ export const STOPPING = { code: "57P01", message: "terminating connection due to
131
131
  detail: "Mason is stopping (mason stop, or a shutdown)", hint: "connect again once Mason runs again (mason status says whether it does)" };
132
132
  const errorOf = (e) => {
133
133
  if (e.kind === "statement")
134
- return new StatementError(e.fields, e.sql);
134
+ return new StatementError(e.fields, e.sql, e.gap);
135
135
  if (e.kind === "encode")
136
- return new EncodeError(e.message, e.code);
136
+ return new EncodeError(e.message, e.code, e.gap);
137
137
  if (e.kind === "numeric")
138
- return new NumericError(e.message, e.sqlstate);
138
+ return new NumericError(e.message, e.sqlstate, e.gap);
139
139
  const err = new Error(e.message);
140
140
  err.name = e.name;
141
141
  return err;
@@ -148,7 +148,7 @@ function plainValues(values) {
148
148
  if (v === null || typeof v !== "object" || Array.isArray(v) || ArrayBuffer.isView(v) || v instanceof Date)
149
149
  continue;
150
150
  if (Object.getPrototypeOf(v) !== Object.prototype)
151
- throw new Error(`a ${v.constructor.name} cannot be sent to DuckDB's thread: pass its text or a plain value (Mason bug: report it)`);
151
+ throw new Error(`a ${v.constructor.name} cannot be sent to the engine's thread: pass its text or a plain value (Mason bug: report it)`);
152
152
  }
153
153
  return [...values];
154
154
  }
@@ -156,12 +156,17 @@ function plainValues(values) {
156
156
  * EVERY CALL TO THE WORKER SETTLES (under load the worker deadlocked inside DuckDB, src/duck/store.ts
157
157
  * `cleanUp`, and every request to it — the session's statements, the store's close, the test's after-hook — waited for
158
158
  * ever, 0 % CPU). Two bounds:
159
- * - THE WORKER MUST BE HEARD: while a request is pending the executor pings it every `PING_MS`, and a worker that has
160
- * said nothing (no answer, no log line, no pong) for `SILENT_MS` is declared stopped — every request in flight and
161
- * later is told 57P02, and the run is told (`onCrash`), as for a crash. Not shorter: a statement calling a Mason
162
- * function holds the worker's turn for up to a thousand calls at a time (seconds on a loaded machine), and closing a
163
- * call-free result waits for DuckDB to honour an interrupt (~10 s for one join, docs/DESIGN.md "The executor
164
- * worker"); a deadlock is for ever, so a longer bound only says it later.
159
+ * - THE WORKER MUST BE HEARD: while a request is pending the executor pings it every `PING_MS`, and a worker of which
160
+ * nothing was heard (no answer, no log line, no pong, no beat) for `SILENT_MS` is declared stopped — every request in
161
+ * flight and later is told 57P02, and the run is told (`onCrash`), as for a crash. THE TIME IS THIS THREAD'S OWN, its
162
+ * ticks counted and never the clock's: a main thread blocked or a process frozen past the bound (a test's spawnSync, a
163
+ * SIGSTOP, a paused container) counted against a healthy worker, whose answer sat unread in the port when the tick
164
+ * fired (docs/DESIGN.md "The watch"). THE BEAT (src/duck/cancel.ts) is the worker's JavaScript running with no turn of
165
+ * its loop: a statement calling a Mason function holds that loop for a thousand calls at a time, 999 × 322 ms over
166
+ * 20 kB documents, and such a statement ran 1,151 s without a pong and answered. What is left is a thread that neither
167
+ * turned its loop nor ran a row of a Mason function for 60 s: blocked inside the engine, since nothing on it waits for
168
+ * the engine's tasks any more (src/duck/store.ts `cleanUp`), or spinning. A deadlock is for ever, so a longer bound
169
+ * only says it later.
165
170
  * - THE CLOSE IS BOUNDED: `close` gives the worker's shutdown `CLOSE_GRACE_MS` (a calling statement stops at its next
166
171
  * call, milliseconds; a call-free one at DuckDB's interrupt; and the whole stop must fit inside a platform's 10 s
167
172
  * before SIGKILL and `mason stop`'s 15 s), then declares it stopped the same way and resolves; the thread is ended
@@ -198,8 +203,10 @@ export class Executor {
198
203
  /** the cancel table, shared with the worker (src/duck/cancel.ts), and its free slots */
199
204
  cancels = cancelTable();
200
205
  free = Array.from({ length: CANCEL_SLOTS }, (_, i) => CANCEL_SLOTS - 1 - i);
201
- /** when the worker last said anything (performance.now()) */
202
- heard = performance.now();
206
+ /** pings sent since the worker last said anything or beat: the watch's count, of this thread's own ticks */
207
+ unanswered = 0;
208
+ /** the worker's beat at the watch's last look */
209
+ beat = 0;
203
210
  watch;
204
211
  silentMs;
205
212
  /** why the worker was given up on without exiting (`close`'s bound, the silence), once it was: its instance may
@@ -218,7 +225,7 @@ export class Executor {
218
225
  // a request is in flight (`call`)
219
226
  this.worker.unref();
220
227
  this.worker.on("message", (m) => {
221
- this.heard = performance.now();
228
+ this.unanswered = 0;
222
229
  if ("pong" in m)
223
230
  return;
224
231
  if ("log" in m) {
@@ -227,7 +234,7 @@ export class Executor {
227
234
  }
228
235
  if (m.id === this.shutdownId) {
229
236
  if (!m.ok)
230
- this.log(`closing the copy failed: ${errorOf(m.error).message}`);
237
+ this.log(`closing the engine failed: ${errorOf(m.error).message}`);
231
238
  this.shutdownAnswered();
232
239
  return;
233
240
  }
@@ -250,18 +257,24 @@ export class Executor {
250
257
  this.shutdownAnswered();
251
258
  resolve();
252
259
  }));
253
- // the watch (see SILENT_MS): only while a request is pending — an idle worker has nothing to answer
260
+ // the watch (see SILENT_MS): only while a request is pending — an idle worker has nothing to answer — and never
261
+ // sooner than the bound after its first ping, whatever the moment the request came: the count is checked, then raised
262
+ const tick = Math.min(PING_MS, this.silentMs / 4);
263
+ const bound = Math.ceil(this.silentMs / tick);
254
264
  this.watch = setInterval(() => {
255
- if (this.gone !== null || this.pending.size === 0) {
256
- this.heard = performance.now();
265
+ const beat = beats(this.cancels);
266
+ if (this.gone !== null || this.pending.size === 0 || beat !== this.beat) {
267
+ this.beat = beat;
268
+ this.unanswered = 0;
257
269
  return;
258
270
  }
259
- if (performance.now() - this.heard > this.silentMs) {
260
- this.abandon(`it answered nothing for ${Math.round(this.silentMs / 1000)} s with ${this.pending.size} request(s) waiting (stuck inside DuckDB: Mason bug, report it with mason logs)`);
271
+ if (this.unanswered >= bound) {
272
+ this.abandon(`it answered nothing for ${Math.round(this.silentMs / 1000)} s and ran no Mason function, with ${this.pending.size} request(s) waiting (stuck inside the engine: Mason bug, report it with mason logs)`);
261
273
  return;
262
274
  }
275
+ this.unanswered++;
263
276
  this.worker.postMessage({ op: "ping" });
264
- }, Math.min(PING_MS, this.silentMs / 4));
277
+ }, tick);
265
278
  this.watch.unref();
266
279
  }
267
280
  /** the worker given up on without its exit: every request told so (57P02), the run told, and its shutdown asked —
@@ -290,7 +303,7 @@ export class Executor {
290
303
  }
291
304
  void this.shutdown.then(async () => { if (!this.hasExited)
292
305
  await this.worker.terminate(); })
293
- .catch((e) => this.log(`ending the copy's thread failed: ${e.message}`));
306
+ .catch((e) => this.log(`ending the engine's thread failed: ${e.message}`));
294
307
  return this.shutdown;
295
308
  }
296
309
  static async start(o) {
@@ -317,7 +330,7 @@ export class Executor {
317
330
  p.reject(err);
318
331
  }
319
332
  if (!stopped) {
320
- this.log(`the copy's engine stopped: ${reason}`);
333
+ this.log(`the engine stopped: ${reason}`);
321
334
  if (!this.closing)
322
335
  for (const f of this.crashed)
323
336
  f(reason);
@@ -366,7 +379,7 @@ export class Executor {
366
379
  async runner() {
367
380
  const slot = this.free.pop();
368
381
  if (slot === undefined)
369
- throw new StatementError({ code: "53300", message: `too many sessions are reading the copy at once (${CANCEL_SLOTS})`, hint: "close idle connections (a pool's size), then connect again" });
382
+ throw new StatementError({ code: "53300", message: `too many sessions are reading the synced data at once (${CANCEL_SLOTS})`, hint: "close idle connections (a pool's size), then connect again" });
370
383
  resetSlot(this.cancels, slot);
371
384
  try {
372
385
  return new RemoteRunner(this, await this.call({ op: "runner.open", slot }), slot);
@@ -393,7 +406,7 @@ export class Executor {
393
406
  const closed = (async () => { await this.shutDown(); await this.exited; return "closed"; })();
394
407
  try {
395
408
  if (await Promise.race([closed, late]) === "late") {
396
- this.log(`the copy's thread did not close within ${graceMs / 1000} s: given up on (its sessions are told 57P02); it ends once its shutdown finishes, and the process waits for it (a thread stuck inside DuckDB needs a SIGKILL)`);
409
+ this.log(`the engine's thread did not close within ${graceMs / 1000} s: given up on (its sessions are told 57P02); it ends once its shutdown finishes, and the process waits for it (a thread stuck inside the engine needs a SIGKILL)`);
397
410
  this.abandon(`it did not close within ${graceMs / 1000} s`);
398
411
  }
399
412
  }
@@ -437,7 +450,7 @@ const reasonOf = (signal) => !signal.aborted ? null : signal.reason instanceof S
437
450
  function goneError(e, sql) {
438
451
  if (e.stopped)
439
452
  return new StatementError(STOPPING, sql);
440
- return new StatementError({ code: "57P02", message: "terminating connection because the copy's engine stopped", detail: e.message,
453
+ return new StatementError({ code: "57P02", message: "terminating connection because the engine stopped", detail: e.message,
441
454
  hint: "Mason starts again by itself: reconnect in a few seconds and repeat the statement (mason status says when it is back)" }, sql);
442
455
  }
443
456
  /**