botholomew 0.9.10 → 0.9.11
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.
- package/README.md +16 -13
- package/package.json +1 -1
- package/src/cli.ts +2 -0
- package/src/commands/db.ts +112 -0
- package/src/db/doctor.ts +214 -0
package/README.md
CHANGED
|
@@ -31,8 +31,9 @@ through MCP servers wired up via [MCPX](https://github.com/evantahler/mcpx).
|
|
|
31
31
|
DuckDB. Copy it, share it, check it in (or `.gitignore` it).
|
|
32
32
|
- **Your data, your disk.** Project state — tasks, threads, ingested
|
|
33
33
|
context, embeddings — lives in `.botholomew/`, indexed in DuckDB with
|
|
34
|
-
|
|
35
|
-
any further reach is scoped to
|
|
34
|
+
BM25 keyword search and `array_cosine_distance` vector search. Model
|
|
35
|
+
calls go direct to Anthropic and OpenAI; any further reach is scoped to
|
|
36
|
+
the MCP servers you add.
|
|
36
37
|
- **Extensible.** External tools come from MCP servers via
|
|
37
38
|
[MCPX](https://github.com/evantahler/mcpx) — run them locally (Gmail,
|
|
38
39
|
Slack, GitHub) or connect through an MCP gateway like
|
|
@@ -115,12 +116,14 @@ my-project/
|
|
|
115
116
|
soul.md # always-loaded identity (not agent-editable)
|
|
116
117
|
beliefs.md # always-loaded, agent-editable priors
|
|
117
118
|
goals.md # always-loaded, agent-editable goals
|
|
119
|
+
capabilities.md # always-loaded, agent-editable tool inventory
|
|
118
120
|
config.json # models, tick interval, API keys
|
|
119
121
|
data.duckdb # tasks, schedules, context, embeddings, logs
|
|
120
122
|
mcpx/servers.json # external MCP servers (Gmail, Slack, …)
|
|
121
|
-
skills/ # user-defined
|
|
123
|
+
skills/ # slash commands (built-ins + user-defined)
|
|
122
124
|
summarize.md
|
|
123
125
|
standup.md
|
|
126
|
+
capabilities.md
|
|
124
127
|
logs/ # per-worker log files (one file per spawned worker)
|
|
125
128
|
<worker-id>.log
|
|
126
129
|
```
|
|
@@ -140,14 +143,14 @@ Everything the agent can touch is here. No surprises.
|
|
|
140
143
|
| `botholomew worker list\|status\|stop\|kill\|reap` | Inspect and manage running workers |
|
|
141
144
|
| `botholomew chat` | Interactive Ink/React TUI |
|
|
142
145
|
| `botholomew task list\|add\|view\|update\|reset\|delete` | Manage the task queue |
|
|
143
|
-
| `botholomew schedule list\|add\|enable\|trigger\|delete` | Recurring work |
|
|
144
|
-
| `botholomew context add\|list\|
|
|
146
|
+
| `botholomew schedule list\|add\|view\|enable\|disable\|trigger\|delete` | Recurring work |
|
|
147
|
+
| `botholomew context add\|list\|search\|chunks\|refresh\|delete` | Ingest & browse knowledge (files, folders, URLs); also exposes the agent's `read`/`write`/`tree`/`edit`/… tools as subcommands |
|
|
145
148
|
| `botholomew capabilities` | Rescan built-in + MCPX tools and rewrite `.botholomew/capabilities.md` |
|
|
146
|
-
| `botholomew mcpx servers\|add\|remove\|info\|search\|exec\|ping\|auth\|import-global
|
|
149
|
+
| `botholomew mcpx servers\|list\|add\|remove\|info\|search\|exec\|ping\|auth\|deauth\|import-global\|…` | Configure external MCP servers (passthrough to `mcpx`) |
|
|
147
150
|
| `botholomew skill list\|show\|create\|validate` | Manage slash-command skills |
|
|
148
|
-
| `botholomew context ... \| search ...` | Direct access to the agent's virtual filesystem |
|
|
149
151
|
| `botholomew thread list\|view` | Browse the agent's interaction history |
|
|
150
152
|
| `botholomew nuke context\|tasks\|schedules\|threads\|all` | Bulk-erase sections of the database |
|
|
153
|
+
| `botholomew db doctor [--repair]` | Probe each table for primary-key index corruption; rebuild via EXPORT/IMPORT |
|
|
151
154
|
| `botholomew upgrade` | Self-update |
|
|
152
155
|
|
|
153
156
|
All `list` subcommands support `-l, --limit <n>` and `-o, --offset <n>` for pagination.
|
|
@@ -175,7 +178,7 @@ All `list` subcommands support `-l, --limit <n>` and `-o, --offset <n>` for pagi
|
|
|
175
178
|
│ ┌───────────┐ ┌──────────────┐ │
|
|
176
179
|
│ │ tasks │ │ context_items│ │
|
|
177
180
|
│ │ schedules │ │ embeddings │ │
|
|
178
|
-
│ │ workers │ │
|
|
181
|
+
│ │ workers │ │ (FTS+vector)│ │
|
|
179
182
|
│ │ threads │ │ │ │
|
|
180
183
|
│ └───────────┘ └──────────────┘ │
|
|
181
184
|
└─────┬───────────────────────────────┘
|
|
@@ -203,8 +206,8 @@ Topics worth understanding in detail:
|
|
|
203
206
|
- **[The virtual filesystem](docs/virtual-filesystem.md)** — why the agent's
|
|
204
207
|
"files" are actually DuckDB rows, and how `context_read`/`context_write` work.
|
|
205
208
|
- **[Context & hybrid search](docs/context-and-search.md)** — LLM-driven
|
|
206
|
-
chunking, OpenAI embeddings, and DuckDB
|
|
207
|
-
|
|
209
|
+
chunking, OpenAI embeddings, and DuckDB BM25 + linear-scan vector
|
|
210
|
+
search merged with reciprocal rank fusion.
|
|
208
211
|
- **[Tasks & schedules](docs/tasks-and-schedules.md)** — the claim loop, DAG
|
|
209
212
|
validation, stale-task recovery, and natural-language recurring schedules.
|
|
210
213
|
- **[The Tool class](docs/tools.md)** — one Zod definition, three consumers
|
|
@@ -226,9 +229,9 @@ Topics worth understanding in detail:
|
|
|
226
229
|
## Tech stack
|
|
227
230
|
|
|
228
231
|
- **[Bun](https://bun.sh)** + TypeScript
|
|
229
|
-
- **[DuckDB](https://duckdb.org)** via `@duckdb/node-api
|
|
230
|
-
|
|
231
|
-
|
|
232
|
+
- **[DuckDB](https://duckdb.org)** via `@duckdb/node-api` —
|
|
233
|
+
`array_cosine_distance()` (core DuckDB) for vector search, plus the
|
|
234
|
+
built-in FTS extension for BM25 keyword search
|
|
232
235
|
- **[Anthropic SDK](https://docs.anthropic.com/en/api/client-sdks)** for
|
|
233
236
|
Claude — the reasoning model
|
|
234
237
|
- **OpenAI embeddings API** (`text-embedding-3-small`, 1536-dim) for
|
package/package.json
CHANGED
package/src/cli.ts
CHANGED
|
@@ -6,6 +6,7 @@ import { registerCapabilitiesCommand } from "./commands/capabilities.ts";
|
|
|
6
6
|
import { registerChatCommand } from "./commands/chat.ts";
|
|
7
7
|
import { registerCheckUpdateCommand } from "./commands/check-update.ts";
|
|
8
8
|
import { registerContextCommand } from "./commands/context.ts";
|
|
9
|
+
import { registerDbCommand } from "./commands/db.ts";
|
|
9
10
|
import { registerInitCommand } from "./commands/init.ts";
|
|
10
11
|
import { registerMcpxCommand } from "./commands/mcpx.ts";
|
|
11
12
|
import { registerNukeCommand } from "./commands/nuke.ts";
|
|
@@ -40,6 +41,7 @@ registerThreadCommand(program);
|
|
|
40
41
|
registerScheduleCommand(program);
|
|
41
42
|
registerChatCommand(program);
|
|
42
43
|
registerContextCommand(program);
|
|
44
|
+
registerDbCommand(program);
|
|
43
45
|
registerCapabilitiesCommand(program);
|
|
44
46
|
registerMcpxCommand(program);
|
|
45
47
|
registerSkillCommand(program);
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import ansis from "ansis";
|
|
2
|
+
import type { Command } from "commander";
|
|
3
|
+
import { getDbPath } from "../constants.ts";
|
|
4
|
+
import { withDb as coreWithDb } from "../db/connection.ts";
|
|
5
|
+
import {
|
|
6
|
+
type ProbeResult,
|
|
7
|
+
probeAllTables,
|
|
8
|
+
repairDatabase,
|
|
9
|
+
} from "../db/doctor.ts";
|
|
10
|
+
import { listWorkers } from "../db/workers.ts";
|
|
11
|
+
import { logger } from "../utils/logger.ts";
|
|
12
|
+
|
|
13
|
+
function statusBadge(status: ProbeResult["status"]): string {
|
|
14
|
+
switch (status) {
|
|
15
|
+
case "ok":
|
|
16
|
+
return ansis.green("ok");
|
|
17
|
+
case "empty":
|
|
18
|
+
return ansis.dim("empty");
|
|
19
|
+
case "missing":
|
|
20
|
+
return ansis.dim("missing");
|
|
21
|
+
case "corrupt":
|
|
22
|
+
return ansis.red.bold("corrupt");
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function printResults(results: ProbeResult[]) {
|
|
27
|
+
const nameWidth = Math.max(...results.map((r) => r.table.length));
|
|
28
|
+
for (const r of results) {
|
|
29
|
+
const name = r.table.padEnd(nameWidth + 2);
|
|
30
|
+
const detail = r.message ? ansis.dim(` ${r.message.slice(0, 200)}`) : "";
|
|
31
|
+
console.log(` ${name}${statusBadge(r.status)}${detail}`);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function registerDbCommand(program: Command) {
|
|
36
|
+
const db = program
|
|
37
|
+
.command("db")
|
|
38
|
+
.description("Inspect and repair the project database");
|
|
39
|
+
|
|
40
|
+
db.command("doctor")
|
|
41
|
+
.description(
|
|
42
|
+
"Probe every table for primary-key index corruption and optionally repair via EXPORT/IMPORT",
|
|
43
|
+
)
|
|
44
|
+
.option(
|
|
45
|
+
"-r, --repair",
|
|
46
|
+
"Rebuild the database file from an export when corruption is detected",
|
|
47
|
+
)
|
|
48
|
+
.action((opts) => doctor(program, opts.repair === true));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async function doctor(program: Command, repair: boolean): Promise<void> {
|
|
52
|
+
const dir = program.opts().dir as string;
|
|
53
|
+
const dbPath = getDbPath(dir);
|
|
54
|
+
|
|
55
|
+
logger.info(`Probing tables in ${dbPath}`);
|
|
56
|
+
const results = await probeAllTables(dbPath);
|
|
57
|
+
printResults(results);
|
|
58
|
+
|
|
59
|
+
const corrupt = results.filter((r) => r.status === "corrupt");
|
|
60
|
+
if (corrupt.length === 0) {
|
|
61
|
+
logger.success("No corruption detected.");
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
logger.error(
|
|
66
|
+
`${corrupt.length} table(s) have corrupted indexes: ${corrupt
|
|
67
|
+
.map((r) => r.table)
|
|
68
|
+
.join(", ")}`,
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
if (!repair) {
|
|
72
|
+
console.log("");
|
|
73
|
+
console.log(
|
|
74
|
+
ansis.yellow(
|
|
75
|
+
"Re-run with --repair to rebuild the database file (creates a timestamped backup).",
|
|
76
|
+
),
|
|
77
|
+
);
|
|
78
|
+
process.exit(1);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Repair requires exclusive access — refuse if any worker is registered
|
|
82
|
+
// as running, otherwise the EXPORT would race with the worker's writes.
|
|
83
|
+
const running = await coreWithDb(dbPath, async (conn) => {
|
|
84
|
+
try {
|
|
85
|
+
return await listWorkers(conn, { status: "running" });
|
|
86
|
+
} catch {
|
|
87
|
+
// If listWorkers itself trips the corruption we're about to fix,
|
|
88
|
+
// fall through and let repair proceed; the user is on their own
|
|
89
|
+
// for confirming no live workers, which `worker reap` would also
|
|
90
|
+
// be unable to do anyway.
|
|
91
|
+
return [];
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
if (running.length > 0) {
|
|
95
|
+
logger.error(
|
|
96
|
+
`${running.length} worker(s) registered as running. Stop them first: botholomew worker stop <id>`,
|
|
97
|
+
);
|
|
98
|
+
for (const w of running) {
|
|
99
|
+
logger.dim(` ${w.id} (pid ${w.pid}, mode=${w.mode})`);
|
|
100
|
+
}
|
|
101
|
+
process.exit(1);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
logger.phase("repair", "EXPORT DATABASE → swap files → IMPORT DATABASE");
|
|
105
|
+
const result = await repairDatabase(dbPath);
|
|
106
|
+
logger.success(
|
|
107
|
+
`Repaired in ${result.durationMs}ms. Backup: ${result.backupDbPath}`,
|
|
108
|
+
);
|
|
109
|
+
logger.dim(
|
|
110
|
+
" Re-run `botholomew db doctor` to confirm. Delete the backup once you're sure.",
|
|
111
|
+
);
|
|
112
|
+
}
|
package/src/db/doctor.ts
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import { mkdir, rename, rm, stat } from "node:fs/promises";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import { withDb } from "./connection.ts";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Tables we probe for primary-key index integrity. Every user table has a
|
|
7
|
+
* single-column PK that we exercise with a self-update (SET pk = pk WHERE
|
|
8
|
+
* pk = ...). DuckDB still walks the index for the SET, which surfaces
|
|
9
|
+
* "Failed to delete all rows from index" FATAL errors when the index is
|
|
10
|
+
* out of sync with the row data. `_migrations` is excluded — it is small,
|
|
11
|
+
* append-only, and rebuilding it would defeat its purpose.
|
|
12
|
+
*/
|
|
13
|
+
export const PROBE_TABLES: ReadonlyArray<{ name: string; pk: string }> = [
|
|
14
|
+
{ name: "workers", pk: "id" },
|
|
15
|
+
{ name: "tasks", pk: "id" },
|
|
16
|
+
{ name: "schedules", pk: "id" },
|
|
17
|
+
{ name: "threads", pk: "id" },
|
|
18
|
+
{ name: "interactions", pk: "id" },
|
|
19
|
+
{ name: "context_items", pk: "id" },
|
|
20
|
+
{ name: "embeddings", pk: "id" },
|
|
21
|
+
{ name: "daemon_state", pk: "key" },
|
|
22
|
+
];
|
|
23
|
+
|
|
24
|
+
export type ProbeStatus = "ok" | "empty" | "missing" | "corrupt";
|
|
25
|
+
|
|
26
|
+
export interface ProbeResult {
|
|
27
|
+
table: string;
|
|
28
|
+
status: ProbeStatus;
|
|
29
|
+
/** Detail message when status is corrupt or missing. */
|
|
30
|
+
message?: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Probe a single table for index corruption by spawning a child Bun
|
|
35
|
+
* process. We use a child process because a corrupt PK index in DuckDB
|
|
36
|
+
* surfaces as a Bun panic (a C++ exception that unwinds past the NAPI
|
|
37
|
+
* boundary), which would kill the doctor itself. The child reports its
|
|
38
|
+
* verdict on stdout and exits.
|
|
39
|
+
*
|
|
40
|
+
* Uses absolute import path resolved against this file so the spawned
|
|
41
|
+
* Bun process picks up the same `@duckdb/node-api` install.
|
|
42
|
+
*/
|
|
43
|
+
export async function probeTable(
|
|
44
|
+
dbPath: string,
|
|
45
|
+
table: string,
|
|
46
|
+
pk: string,
|
|
47
|
+
): Promise<ProbeResult> {
|
|
48
|
+
const script = `
|
|
49
|
+
const { DuckDBInstance } = await import("@duckdb/node-api");
|
|
50
|
+
const dbPath = ${JSON.stringify(dbPath)};
|
|
51
|
+
const table = ${JSON.stringify(table)};
|
|
52
|
+
const pk = ${JSON.stringify(pk)};
|
|
53
|
+
let inst;
|
|
54
|
+
try {
|
|
55
|
+
inst = await DuckDBInstance.create(dbPath);
|
|
56
|
+
} catch (e) {
|
|
57
|
+
process.stdout.write("MISSING:" + (e?.message ?? String(e)));
|
|
58
|
+
process.exit(0);
|
|
59
|
+
}
|
|
60
|
+
const c = await inst.connect();
|
|
61
|
+
try {
|
|
62
|
+
const r = await c.runAndReadAll(\`SELECT \${pk} FROM \${table} LIMIT 1\`);
|
|
63
|
+
if (r.getRows().length === 0) {
|
|
64
|
+
process.stdout.write("EMPTY");
|
|
65
|
+
process.exit(0);
|
|
66
|
+
}
|
|
67
|
+
} catch (e) {
|
|
68
|
+
const msg = String(e?.message ?? e);
|
|
69
|
+
// Table doesn't exist yet (e.g., schema older than this doctor) — not
|
|
70
|
+
// a corruption signal, just skip it.
|
|
71
|
+
if (msg.includes("does not exist") || msg.includes("Catalog Error")) {
|
|
72
|
+
process.stdout.write("MISSING:" + msg);
|
|
73
|
+
process.exit(0);
|
|
74
|
+
}
|
|
75
|
+
process.stdout.write("CORRUPT:" + msg);
|
|
76
|
+
process.exit(2);
|
|
77
|
+
}
|
|
78
|
+
try {
|
|
79
|
+
await c.run(\`UPDATE \${table} SET \${pk} = \${pk} WHERE \${pk} = (SELECT \${pk} FROM \${table} LIMIT 1)\`);
|
|
80
|
+
process.stdout.write("OK");
|
|
81
|
+
process.exit(0);
|
|
82
|
+
} catch (e) {
|
|
83
|
+
process.stdout.write("CORRUPT:" + (e?.message ?? String(e)));
|
|
84
|
+
process.exit(2);
|
|
85
|
+
}
|
|
86
|
+
`;
|
|
87
|
+
|
|
88
|
+
const proc = Bun.spawn(["bun", "-e", script], {
|
|
89
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
90
|
+
});
|
|
91
|
+
const [stdout, stderr, exitCode] = await Promise.all([
|
|
92
|
+
new Response(proc.stdout).text(),
|
|
93
|
+
new Response(proc.stderr).text(),
|
|
94
|
+
proc.exited,
|
|
95
|
+
]);
|
|
96
|
+
|
|
97
|
+
// Bun panic: process killed by SIGTRAP / non-zero exit with no stdout
|
|
98
|
+
// verdict. Treat any unrecognized exit as corruption — better to flag
|
|
99
|
+
// for repair than to silently miss a problem.
|
|
100
|
+
if (stdout.startsWith("OK")) return { table, status: "ok" };
|
|
101
|
+
if (stdout.startsWith("EMPTY")) return { table, status: "empty" };
|
|
102
|
+
if (stdout.startsWith("MISSING:")) {
|
|
103
|
+
return {
|
|
104
|
+
table,
|
|
105
|
+
status: "missing",
|
|
106
|
+
message: stdout.slice("MISSING:".length),
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
if (stdout.startsWith("CORRUPT:")) {
|
|
110
|
+
return {
|
|
111
|
+
table,
|
|
112
|
+
status: "corrupt",
|
|
113
|
+
message: stdout.slice("CORRUPT:".length),
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
const reason =
|
|
117
|
+
stderr.trim() ||
|
|
118
|
+
`child exited with code ${exitCode} and no verdict (likely native panic)`;
|
|
119
|
+
return { table, status: "corrupt", message: reason };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Run probes for every known table. Sequential rather than parallel so we
|
|
124
|
+
* cooperate with DuckDB's per-process file lock and don't multiply the
|
|
125
|
+
* blast radius of a panic.
|
|
126
|
+
*/
|
|
127
|
+
export async function probeAllTables(dbPath: string): Promise<ProbeResult[]> {
|
|
128
|
+
const results: ProbeResult[] = [];
|
|
129
|
+
for (const { name, pk } of PROBE_TABLES) {
|
|
130
|
+
results.push(await probeTable(dbPath, name, pk));
|
|
131
|
+
}
|
|
132
|
+
return results;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export interface RepairResult {
|
|
136
|
+
backupDbPath: string;
|
|
137
|
+
exportDir: string;
|
|
138
|
+
durationMs: number;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Repair `dbPath` by exporting its contents and importing into a fresh
|
|
143
|
+
* file. EXPORT DATABASE reads via sequential scans, not via PK indexes,
|
|
144
|
+
* so it survives the kind of index corruption that breaks UPDATE/DELETE.
|
|
145
|
+
* IMPORT DATABASE rebuilds every index from the data, which restores
|
|
146
|
+
* write integrity.
|
|
147
|
+
*
|
|
148
|
+
* Steps:
|
|
149
|
+
* 1. CHECKPOINT (best-effort) to flush WAL.
|
|
150
|
+
* 2. EXPORT DATABASE to `<dotDir>/.export-<timestamp>`.
|
|
151
|
+
* 3. Move `data.duckdb` (and `.wal`) to `data.duckdb.bak-<timestamp>`.
|
|
152
|
+
* 4. Open a fresh DB at the original path and IMPORT DATABASE.
|
|
153
|
+
* 5. Leave the export dir on disk — cheap insurance if step 4 ever fails
|
|
154
|
+
* mid-way; cleanup on the next successful run.
|
|
155
|
+
*
|
|
156
|
+
* The caller is responsible for ensuring no other process holds the DB
|
|
157
|
+
* (no running workers, no chat session, no TUI).
|
|
158
|
+
*/
|
|
159
|
+
export async function repairDatabase(dbPath: string): Promise<RepairResult> {
|
|
160
|
+
const start = Date.now();
|
|
161
|
+
const dotDir = dirname(dbPath);
|
|
162
|
+
await mkdir(dotDir, { recursive: true });
|
|
163
|
+
|
|
164
|
+
const stamp = new Date()
|
|
165
|
+
.toISOString()
|
|
166
|
+
.replace(/[:.]/g, "-")
|
|
167
|
+
.replace(/Z$/, "");
|
|
168
|
+
const exportDir = join(dotDir, `.export-${stamp}`);
|
|
169
|
+
const backupDbPath = `${dbPath}.bak-${stamp}`;
|
|
170
|
+
const walPath = `${dbPath}.wal`;
|
|
171
|
+
const backupWalPath = `${backupDbPath}.wal`;
|
|
172
|
+
|
|
173
|
+
await withDb(dbPath, async (conn) => {
|
|
174
|
+
try {
|
|
175
|
+
await conn.exec("CHECKPOINT");
|
|
176
|
+
} catch {
|
|
177
|
+
// CHECKPOINT can fail on an already-invalidated DB; the EXPORT
|
|
178
|
+
// below is what actually matters.
|
|
179
|
+
}
|
|
180
|
+
await conn.exec(`EXPORT DATABASE '${exportDir.replace(/'/g, "''")}'`);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
await rename(dbPath, backupDbPath);
|
|
184
|
+
if (await pathExists(walPath)) {
|
|
185
|
+
await rename(walPath, backupWalPath);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
await withDb(dbPath, async (conn) => {
|
|
189
|
+
await conn.exec(`IMPORT DATABASE '${exportDir.replace(/'/g, "''")}'`);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
// Best-effort cleanup of the export dir. Leave it on failure — the user
|
|
193
|
+
// still has data.duckdb (rebuilt) and the backup.
|
|
194
|
+
try {
|
|
195
|
+
await rm(exportDir, { recursive: true, force: true });
|
|
196
|
+
} catch {
|
|
197
|
+
// ignore
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
return {
|
|
201
|
+
backupDbPath,
|
|
202
|
+
exportDir,
|
|
203
|
+
durationMs: Date.now() - start,
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
async function pathExists(p: string): Promise<boolean> {
|
|
208
|
+
try {
|
|
209
|
+
await stat(p);
|
|
210
|
+
return true;
|
|
211
|
+
} catch {
|
|
212
|
+
return false;
|
|
213
|
+
}
|
|
214
|
+
}
|