@bytebase/dbhub 1.3.1 → 1.5.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.
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  hasMutatingKeyword
3
- } from "./chunk-ABTVMHD6.js";
3
+ } from "./chunk-55X2VY5D.js";
4
4
  import {
5
5
  blankCommentsAndStrings
6
6
  } from "./chunk-IXC4NQGC.js";
@@ -135,20 +135,124 @@ LIMIT ${maxRows}${semicolon}`;
135
135
  value: match[1] !== void 0 ? parseInt(match[1], 10) : null
136
136
  };
137
137
  }
138
- /** The statement's own `SELECT TOP n` clause (SQL Server). */
138
+ /**
139
+ * The statement's own TOP clause (SQL Server), located on its own SELECT
140
+ * (after an optional DISTINCT/ALL). `index`/`length` span the `TOP ...`
141
+ * text only, so it can be spliced without disturbing `SELECT DISTINCT`.
142
+ *
143
+ * `value` is the clause's literal row count (`TOP 3`, `TOP (3)`), or null
144
+ * when that number is not an upper bound on the rows returned: a
145
+ * parenthesised expression or parameter (`TOP (@p1)`), `PERCENT`, or
146
+ * `WITH TIES`. Such a TOP cannot simply be tightened; the statement has to
147
+ * be wrapped and capped from the outside.
148
+ */
139
149
  static findTopLevelTop(sql) {
140
- const match = this.findTopLevelMatch(sql, /\(|\)|\bselect\s+top\s+(\d+)/gi, "first", "sqlserver");
150
+ const match = this.findTopLevelMatch(
151
+ sql,
152
+ /\(|\)|\bselect\s+(?:(?:distinct|all)\s+)?(top)\b/gi,
153
+ "first",
154
+ "sqlserver"
155
+ );
141
156
  if (match === null) {
142
157
  return null;
143
158
  }
144
- return { index: match.index, length: match[0].length, value: parseInt(match[1], 10) };
159
+ const blanked = blankCommentsAndStrings(sql, "sqlserver");
160
+ const index = match.index + match[0].length - match[1].length;
161
+ let end = match.index + match[0].length;
162
+ let literal;
163
+ const operand = /\s*(?:(\()|(\d+)\b)/y;
164
+ operand.lastIndex = end;
165
+ const operandMatch = operand.exec(blanked);
166
+ if (operandMatch === null) {
167
+ return null;
168
+ }
169
+ end = operand.lastIndex;
170
+ if (operandMatch[1] !== void 0) {
171
+ const close = this.findClosingParen(blanked, end - 1);
172
+ if (close === -1) {
173
+ return null;
174
+ }
175
+ const inner = blanked.slice(end, close);
176
+ literal = /^\s*(\d+)\s*$/.exec(inner)?.[1];
177
+ end = close + 1;
178
+ } else {
179
+ literal = operandMatch[2];
180
+ }
181
+ const modifiers = operandMatch[1] !== void 0 ? /(?:\s*percent\b)?(?:\s*with\s+ties\b)?/iy : /(?:\s+percent\b)?(?:\s+with\s+ties\b)?/iy;
182
+ modifiers.lastIndex = end;
183
+ const modifierMatch = modifiers.exec(blanked);
184
+ const unbounded = modifierMatch !== null && modifierMatch[0].length > 0;
185
+ end = modifiers.lastIndex;
186
+ return {
187
+ index,
188
+ length: end - index,
189
+ value: literal !== void 0 && !unbounded ? parseInt(literal, 10) : null
190
+ };
191
+ }
192
+ /** Index of the `)` matching the `(` at `openIndex` in blanked SQL, or -1. */
193
+ static findClosingParen(blankedSQL, openIndex) {
194
+ let depth = 0;
195
+ for (let i = openIndex; i < blankedSQL.length; i++) {
196
+ const ch = blankedSQL[i];
197
+ if (ch === "(") {
198
+ depth++;
199
+ } else if (ch === ")") {
200
+ depth--;
201
+ if (depth === 0) {
202
+ return i;
203
+ }
204
+ }
205
+ }
206
+ return -1;
145
207
  }
146
208
  /**
147
- * The statement's own SELECT keyword (SQL Server). For a CTE this is the
148
- * final SELECT, not the one inside a CTE body.
209
+ * The statement's own SELECT keyword (SQL Server), together with a
210
+ * DISTINCT/ALL that follows it: T-SQL places TOP after those
211
+ * (`SELECT DISTINCT TOP n`), so that is where a TOP gets inserted. For a
212
+ * CTE this is the final SELECT, not the one inside a CTE body.
149
213
  */
150
214
  static findTopLevelSelect(sql) {
151
- return this.findTopLevelMatch(sql, /\(|\)|\bselect\b/gi, "first", "sqlserver");
215
+ return this.findTopLevelMatch(sql, /\(|\)|\bselect\b(?:\s+(?:distinct|all)\b)?/gi, "first", "sqlserver");
216
+ }
217
+ /**
218
+ * The statement's own `OFFSET ... ROWS [FETCH NEXT n ROWS ONLY]` clause
219
+ * (SQL Server). T-SQL forbids TOP in a query that has OFFSET, so such a
220
+ * statement is capped through its FETCH count instead.
221
+ *
222
+ * `fetch` is the FETCH count (null when the clause has no FETCH);
223
+ * `fetchInsertIndex` is where a FETCH can be appended, or -1 when the
224
+ * OFFSET clause has a shape this scanner does not parse (an arithmetic
225
+ * expression, say), in which case the statement is wrapped.
226
+ */
227
+ static findTopLevelOffset(sql) {
228
+ const offset = this.findTopLevelMatch(sql, /\(|\)|\boffset\b/gi, "first", "sqlserver");
229
+ if (offset === null) {
230
+ return null;
231
+ }
232
+ const blanked = blankCommentsAndStrings(sql, "sqlserver");
233
+ const clause = /(offset\s+(?:\d+|@\w+|\?)\s+rows?\b)(?:(\s+fetch\s+(?:first|next)\s+)((\d+)|@\w+|\?)\s+rows?\s+only\b)?/iy;
234
+ clause.lastIndex = offset.index;
235
+ const match = clause.exec(blanked);
236
+ if (match === null) {
237
+ return { fetch: null, fetchInsertIndex: -1 };
238
+ }
239
+ if (match[2] === void 0) {
240
+ const end = match.index + match[0].length;
241
+ const unparsedFetch = /\s*fetch\b/iy;
242
+ unparsedFetch.lastIndex = end;
243
+ if (unparsedFetch.test(blanked)) {
244
+ return { fetch: null, fetchInsertIndex: -1 };
245
+ }
246
+ return { fetch: null, fetchInsertIndex: end };
247
+ }
248
+ return {
249
+ fetch: {
250
+ index: match.index + match[1].length + match[2].length,
251
+ length: match[3].length,
252
+ value: match[4] !== void 0 ? parseInt(match[4], 10) : null
253
+ },
254
+ fetchInsertIndex: -1
255
+ };
152
256
  }
153
257
  /**
154
258
  * Check if a SQL statement combines multiple SELECTs with a set operator
@@ -170,31 +274,72 @@ LIMIT ${maxRows}${semicolon}`;
170
274
  * Add or modify TOP clause in a SQL statement (SQL Server)
171
275
  */
172
276
  static applyTopToQuery(sql, maxRows) {
173
- if (this.hasSetOperator(sql)) {
174
- const { sql: sqlWithoutSemicolon, semicolon } = trimSemicolon(sql);
175
- const cteIndex = this.findTopLevelSelect(sqlWithoutSemicolon)?.index ?? 0;
176
- const ctePrefix = sqlWithoutSemicolon.slice(0, cteIndex);
177
- const body = sqlWithoutSemicolon.slice(cteIndex);
178
- const orderByIndex = this.findTopLevelOrderByIndex(body);
179
- if (orderByIndex !== -1) {
180
- const innerSql = body.slice(0, orderByIndex).trimEnd();
181
- const orderByClause = body.slice(orderByIndex).trim();
182
- return `${ctePrefix}SELECT TOP ${maxRows} * FROM (${innerSql}
183
- ) AS subq ${orderByClause}${semicolon}`;
277
+ const offset = this.findTopLevelOffset(sql);
278
+ if (offset !== null) {
279
+ if (offset.fetch !== null && offset.fetch.value !== null) {
280
+ const effectiveFetch = Math.min(offset.fetch.value, maxRows);
281
+ return `${sql.slice(0, offset.fetch.index)}${effectiveFetch}${sql.slice(offset.fetch.index + offset.fetch.length)}`;
282
+ }
283
+ if (offset.fetch === null && offset.fetchInsertIndex !== -1) {
284
+ return `${sql.slice(0, offset.fetchInsertIndex)} FETCH NEXT ${maxRows} ROWS ONLY${sql.slice(offset.fetchInsertIndex)}`;
184
285
  }
185
- return `${ctePrefix}SELECT TOP ${maxRows} * FROM (${body}
186
- ) AS subq${semicolon}`;
286
+ return this.wrapWithTop(sql, maxRows, false);
287
+ }
288
+ if (this.hasSetOperator(sql)) {
289
+ return this.wrapWithTop(sql, maxRows, true);
187
290
  }
188
291
  const existingTop = this.findTopLevelTop(sql);
189
292
  if (existingTop !== null) {
293
+ if (existingTop.value === null) {
294
+ return this.wrapWithTop(sql, maxRows, false);
295
+ }
190
296
  const effectiveTop = Math.min(existingTop.value, maxRows);
191
- return `${sql.slice(0, existingTop.index)}SELECT TOP ${effectiveTop}${sql.slice(existingTop.index + existingTop.length)}`;
297
+ return `${sql.slice(0, existingTop.index)}TOP ${effectiveTop}${sql.slice(existingTop.index + existingTop.length)}`;
192
298
  }
193
299
  const selectMatch = this.findTopLevelSelect(sql);
194
300
  if (selectMatch === null) {
195
301
  return sql;
196
302
  }
197
- return `${sql.slice(0, selectMatch.index)}SELECT TOP ${maxRows}${sql.slice(selectMatch.index + selectMatch[0].length)}`;
303
+ const insertAt = selectMatch.index + selectMatch[0].length;
304
+ return `${sql.slice(0, insertAt)} TOP ${maxRows}${sql.slice(insertAt)}`;
305
+ }
306
+ /**
307
+ * Cap a statement from the outside: `SELECT TOP n * FROM (<body>) AS subq`.
308
+ *
309
+ * A leading CTE stays outside the derived table: T-SQL has no
310
+ * `SELECT ... FROM (WITH ...) AS subq` form, but a CTE declared before the
311
+ * SELECT is still in scope inside that derived table.
312
+ *
313
+ * T-SQL disallows ORDER BY inside a derived table unless that derived table
314
+ * itself has TOP/OFFSET/FOR XML. With `hoistOrderBy` a top-level trailing
315
+ * ORDER BY moves outside the wrap (the set-operator case, where no TOP
316
+ * belongs to the body as a whole); without it the ORDER BY stays inside,
317
+ * which callers use when the body's own TOP/OFFSET both legalises it and
318
+ * depends on it.
319
+ */
320
+ static wrapWithTop(sql, maxRows, hoistOrderBy) {
321
+ const { sql: sqlWithoutSemicolon, semicolon } = trimSemicolon(sql);
322
+ const cteIndex = this.findTopLevelSelect(sqlWithoutSemicolon)?.index ?? 0;
323
+ const ctePrefix = sqlWithoutSemicolon.slice(0, cteIndex);
324
+ let body = sqlWithoutSemicolon.slice(cteIndex);
325
+ const optionIndex = this.findTopLevelOptionIndex(body);
326
+ const optionClause = optionIndex === -1 ? "" : ` ${body.slice(optionIndex).trim()}`;
327
+ if (optionIndex !== -1) {
328
+ body = body.slice(0, optionIndex).trimEnd();
329
+ }
330
+ const orderByIndex = hoistOrderBy ? this.findTopLevelOrderByIndex(body) : -1;
331
+ if (orderByIndex !== -1) {
332
+ const innerSql = body.slice(0, orderByIndex).trimEnd();
333
+ const orderByClause = body.slice(orderByIndex).trim();
334
+ return `${ctePrefix}SELECT TOP ${maxRows} * FROM (${innerSql}
335
+ ) AS subq ${orderByClause}${optionClause}${semicolon}`;
336
+ }
337
+ return `${ctePrefix}SELECT TOP ${maxRows} * FROM (${body}
338
+ ) AS subq${optionClause}${semicolon}`;
339
+ }
340
+ /** Start index of the statement's own trailing `OPTION (...)` query hint, or -1. */
341
+ static findTopLevelOptionIndex(sql) {
342
+ return this.findTopLevelMatch(sql, /\(|\)|\boption\s*(?=\()/gi, "last", "sqlserver")?.index ?? -1;
198
343
  }
199
344
  /**
200
345
  * Apply maxRows limit to a row-returning query only
@@ -301,11 +446,9 @@ LIMIT ${maxRows}${semicolon}`;
301
446
  if (!maxRows || !this.isSelectQuery(sql, "sqlserver")) {
302
447
  return { sql, probeApplied: false };
303
448
  }
304
- if (!this.hasSetOperator(sql)) {
305
- const existingTop = this.extractTopValue(sql);
306
- if (existingTop !== null && existingTop <= maxRows) {
307
- return { sql, probeApplied: false };
308
- }
449
+ const ownCap = this.findTopLevelOffset(sql)?.fetch?.value ?? (this.hasSetOperator(sql) ? null : this.extractTopValue(sql));
450
+ if (ownCap !== null && ownCap <= maxRows) {
451
+ return { sql, probeApplied: false };
309
452
  }
310
453
  return { sql: this.applyMaxRowsForSQLServer(sql, maxRows + 1), probeApplied: true };
311
454
  }
package/dist/index.js CHANGED
@@ -5,6 +5,7 @@ import {
5
5
  BUILTIN_TOOL_HEALTH_CHECK,
6
6
  BUILTIN_TOOL_SEARCH_OBJECTS,
7
7
  ConnectorManager,
8
+ ToolRegistry,
8
9
  classifyConnectionError,
9
10
  getToolRegistry,
10
11
  initializeToolRegistry,
@@ -18,10 +19,11 @@ import {
18
19
  resolveSourceConfigs,
19
20
  resolveTomlConfigPath,
20
21
  resolveTransport
21
- } from "./chunk-5FL4XMWG.js";
22
+ } from "./chunk-PDKPK25P.js";
22
23
  import {
23
24
  loadConnectors
24
25
  } from "./chunk-WVVMH6FJ.js";
26
+ import "./chunk-5XSBOK3W.js";
25
27
  import {
26
28
  quoteQualifiedIdentifier
27
29
  } from "./chunk-X2EC47W4.js";
@@ -31,7 +33,7 @@ import {
31
33
  hasEscapeHatchFunction,
32
34
  isReadOnlySQL,
33
35
  sqlServerDynamicSqlPattern
34
- } from "./chunk-ABTVMHD6.js";
36
+ } from "./chunk-55X2VY5D.js";
35
37
  import {
36
38
  ConnectorRegistry,
37
39
  getDatabaseTypeFromDSN,
@@ -1633,6 +1635,78 @@ function buildSourceDisplayInfo(sourceConfigs, getToolsForSource2, isDemo) {
1633
1635
  // src/utils/config-watcher.ts
1634
1636
  import fs from "fs";
1635
1637
  var DEBOUNCE_MS = 500;
1638
+ function sourceConfigEquals(a, b) {
1639
+ return stableStringify(a) === stableStringify(b);
1640
+ }
1641
+ function stableStringify(value) {
1642
+ if (Array.isArray(value)) {
1643
+ return `[${value.map(stableStringify).join(",")}]`;
1644
+ }
1645
+ if (value && typeof value === "object") {
1646
+ const entries = Object.entries(value).filter(([, v]) => v !== void 0).sort(([x], [y]) => x < y ? -1 : x > y ? 1 : 0).map(([k, v]) => `${JSON.stringify(k)}:${stableStringify(v)}`);
1647
+ return `{${entries.join(",")}}`;
1648
+ }
1649
+ return JSON.stringify(value);
1650
+ }
1651
+ function selectTools(applied, rolledBack, newTools, oldTools) {
1652
+ if (newTools === void 0 && oldTools === void 0) {
1653
+ return void 0;
1654
+ }
1655
+ const liveIds = new Set(applied.map((s) => s.id));
1656
+ return [
1657
+ ...(newTools ?? []).filter((t) => liveIds.has(t.source) && !rolledBack.has(t.source)),
1658
+ ...(oldTools ?? []).filter((t) => rolledBack.has(t.source))
1659
+ ];
1660
+ }
1661
+ async function applySourceDiff(connectorManager, oldSources, newSources) {
1662
+ const oldById = new Map(oldSources.map((s) => [s.id, s]));
1663
+ const newById = new Map(newSources.map((s) => [s.id, s]));
1664
+ const applied = /* @__PURE__ */ new Map();
1665
+ const rolledBack = /* @__PURE__ */ new Set();
1666
+ for (const oldSource of oldSources) {
1667
+ const newSource = newById.get(oldSource.id);
1668
+ if (!newSource) {
1669
+ console.error(`Config reload: removing source '${oldSource.id}'`);
1670
+ await connectorManager.removeSource(oldSource.id);
1671
+ } else if (sourceConfigEquals(oldSource, newSource)) {
1672
+ applied.set(oldSource.id, oldSource);
1673
+ }
1674
+ }
1675
+ for (const newSource of newSources) {
1676
+ if (applied.has(newSource.id)) {
1677
+ continue;
1678
+ }
1679
+ const oldSource = oldById.get(newSource.id);
1680
+ if (oldSource) {
1681
+ console.error(`Config reload: reconnecting changed source '${newSource.id}'`);
1682
+ await connectorManager.removeSource(newSource.id);
1683
+ } else {
1684
+ console.error(`Config reload: adding source '${newSource.id}'`);
1685
+ }
1686
+ try {
1687
+ await connectorManager.addSource(newSource);
1688
+ applied.set(newSource.id, newSource);
1689
+ } catch (error) {
1690
+ console.error(`Config reload: failed to connect source '${newSource.id}':`, error);
1691
+ if (oldSource) {
1692
+ try {
1693
+ await connectorManager.addSource(oldSource);
1694
+ applied.set(oldSource.id, oldSource);
1695
+ rolledBack.add(oldSource.id);
1696
+ console.error(`Config reload: rolled back source '${oldSource.id}' to its previous config.`);
1697
+ } catch (rollbackError) {
1698
+ console.error(`Config reload: rollback of source '${oldSource.id}' also failed:`, rollbackError);
1699
+ }
1700
+ }
1701
+ }
1702
+ }
1703
+ const order = newSources.map((s) => s.id);
1704
+ connectorManager.reorderSources(order);
1705
+ return {
1706
+ sources: order.filter((id) => applied.has(id)).map((id) => applied.get(id)),
1707
+ rolledBack
1708
+ };
1709
+ }
1636
1710
  function startConfigWatcher(options) {
1637
1711
  const { connectorManager, initialTools } = options;
1638
1712
  const configPath = resolveTomlConfigPath();
@@ -1665,34 +1739,44 @@ Detected change in ${configPath}, reloading configuration...`);
1665
1739
  console.error("Config reload: failed to load TOML config, keeping existing connections.");
1666
1740
  return;
1667
1741
  }
1668
- const oldSources = lastGoodSources;
1669
- const oldTools = lastGoodTools;
1670
- await connectorManager.disconnect();
1742
+ new ToolRegistry({ sources: newConfig.sources, tools: newConfig.tools });
1743
+ const { sources: applied, rolledBack } = await applySourceDiff(
1744
+ connectorManager,
1745
+ lastGoodSources,
1746
+ newConfig.sources
1747
+ );
1748
+ lastGoodSources = applied;
1749
+ let live = applied;
1750
+ let tools = selectTools(applied, rolledBack, newConfig.tools, lastGoodTools);
1671
1751
  try {
1672
- await connectorManager.connectWithSources(newConfig.sources);
1673
- initializeToolRegistry({
1674
- sources: newConfig.sources,
1675
- tools: newConfig.tools
1676
- });
1677
- lastGoodSources = newConfig.sources;
1678
- lastGoodTools = newConfig.tools;
1679
- console.error("Configuration reloaded successfully.");
1680
- } catch (connectError) {
1681
- console.error("Failed to connect with new config, rolling back:", connectError);
1682
- try {
1683
- await connectorManager.disconnect();
1684
- } catch {
1752
+ initializeToolRegistry({ sources: live, tools });
1753
+ } catch (error) {
1754
+ if (rolledBack.size === 0) {
1755
+ throw error;
1685
1756
  }
1686
- try {
1687
- await connectorManager.connectWithSources(oldSources);
1688
- initializeToolRegistry({ sources: oldSources, tools: oldTools });
1689
- console.error("Rolled back to previous configuration.");
1690
- } catch (rollbackError) {
1691
- console.error("Rollback also failed, server has no active connections:", rollbackError);
1757
+ console.error(
1758
+ `Config reload: previous tools of rolled-back source(s) ${[...rolledBack].join(", ")} conflict with the new configuration; taking them offline:`,
1759
+ error
1760
+ );
1761
+ for (const id of rolledBack) {
1762
+ await connectorManager.removeSource(id);
1692
1763
  }
1764
+ live = applied.filter((s) => !rolledBack.has(s.id));
1765
+ lastGoodSources = live;
1766
+ tools = selectTools(live, /* @__PURE__ */ new Set(), newConfig.tools, void 0);
1767
+ initializeToolRegistry({ sources: live, tools });
1768
+ }
1769
+ lastGoodTools = tools;
1770
+ const unavailable = newConfig.sources.length - live.length;
1771
+ if (unavailable === 0 && rolledBack.size === 0) {
1772
+ console.error("Configuration reloaded successfully.");
1773
+ } else {
1774
+ console.error(
1775
+ `Configuration reloaded with ${unavailable} source(s) unavailable and ${rolledBack.size} source(s) rolled back to their previous configuration.`
1776
+ );
1693
1777
  }
1694
1778
  } catch (error) {
1695
- console.error("Config reload failed, keeping existing connections:", error);
1779
+ console.error("Config reload failed:", error);
1696
1780
  } finally {
1697
1781
  isReloading = false;
1698
1782
  if (reloadPending) {
@@ -1913,7 +1997,7 @@ See documentation for more details on configuring database connections.
1913
1997
  const sources = sourceConfigsData.sources;
1914
1998
  console.error(`Configuration source: ${sourceConfigsData.source}`);
1915
1999
  await connectorManager.connectWithSources(sources);
1916
- const { initializeToolRegistry: initializeToolRegistry2 } = await import("./registry-ZTHDPLKP.js");
2000
+ const { initializeToolRegistry: initializeToolRegistry2 } = await import("./registry-ZIVIYCB2.js");
1917
2001
  initializeToolRegistry2({
1918
2002
  sources: sourceConfigsData.sources,
1919
2003
  tools: sourceConfigsData.tools
@@ -2068,12 +2152,12 @@ See documentation for more details on configuring database connections.
2068
2152
 
2069
2153
  // src/index.ts
2070
2154
  var connectorModules = [
2071
- { load: () => import("./postgres-6XJ7V4J4.js"), name: "PostgreSQL", driver: "pg" },
2072
- { load: () => import("./sqlserver-AJUK6L55.js"), name: "SQL Server", driver: "mssql" },
2073
- { load: () => import("./sqlite-AO2BKIC6.js"), name: "SQLite", driver: "node:sqlite" },
2074
- { load: () => import("./mysql-XXIHWQGR.js"), name: "MySQL", driver: "mysql2" },
2075
- { load: () => import("./mariadb-L7Z55ROY.js"), name: "MariaDB", driver: "mariadb" },
2076
- { load: () => import("./oracle-OUKBU52X.js"), name: "Oracle", driver: "oracledb" }
2155
+ { load: () => import("./postgres-KCFNDQLG.js"), name: "PostgreSQL", driver: "pg" },
2156
+ { load: () => import("./sqlserver-25MAE77Q.js"), name: "SQL Server", driver: "mssql" },
2157
+ { load: () => import("./sqlite-WKY6543Q.js"), name: "SQLite", driver: "node:sqlite" },
2158
+ { load: () => import("./mysql-ZXO4TWLG.js"), name: "MySQL", driver: "mysql2" },
2159
+ { load: () => import("./mariadb-RWOMZTA6.js"), name: "MariaDB", driver: "mariadb" },
2160
+ { load: () => import("./oracle-UAVN3F4U.js"), name: "Oracle", driver: "oracledb" }
2077
2161
  ];
2078
2162
  loadConnectors(connectorModules).then(() => main()).catch((error) => {
2079
2163
  console.error("Fatal error:", error);