@wirecat/cli-core 0.598.0 → 0.599.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.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,32 @@ shape of this file.
7
7
 
8
8
  ## Unreleased
9
9
 
10
+ ## 0.599.0 — 07.11.2026
11
+
12
+ ### Added
13
+
14
+ - **`store recover`** tells whether the store file is damaged: it reads every page and every search index, names the
15
+ damaged tables, and answers in plain words what happened, likely causes, how to look further, what is affected, that
16
+ the messengers', mail servers' and Drive's own data is not, and the commands to run. It changes nothing.
17
+ `storeRecoveryCheck` gives the same to a CLI with its own `store` command.
18
+ - **`store recover run`** rebuilds a damaged store: it copies every readable row into a new file with the `sqlite3`
19
+ program (3.43 or newer; node:sqlite has no recovery API), rebuilds every search index, the mentions and the vector
20
+ index from the data, checks the result, and puts it in place of the store, keeping the damaged file beside it. It
21
+ refuses while a WireCat timer is scheduled or a process has the store open, and names them. The answer lists the
22
+ tables that lost rows, the rows that point at lost ones, and the mail sync that fetches lost mail again.
23
+ `storeRecoveryRun` and `storeRecoverCommand` give it to other CLIs.
24
+ - **`doctor` checks the store's health**: the file header, its size and the schema's own pages, in milliseconds. A
25
+ damaged store is explained in the answer's `store.damage` (`explainDamage`: what, causes, investigate, impact,
26
+ external_unaffected, steps) and points at `store recover`, the whole-file check. `storeSummary` takes
27
+ `{ deep, command }`, and `DOCTOR_DEEP_OPTION` words a CLI's own `--deep`.
28
+
29
+ ### Fixed
30
+
31
+ - **Opening the store a second time in one process no longer drops the locks of the connection already open.** The
32
+ open touched the existing file through its own descriptor, and on Linux closing any descriptor of a file releases
33
+ every lock the process holds on it, so another process could checkpoint and delete the write-ahead log under a
34
+ writer that went on writing into it. That can damage the store. The file is now created only when it is missing.
35
+
10
36
  ## 0.598.0 — 07.11.2026
11
37
 
12
38
  ### Added
@@ -99,6 +99,7 @@ export { accountFileFor, profilesHere, rememberAccount } from "./messenger/accou
99
99
  export { terminalAsker } from "./messenger/ask.js";
100
100
  export type { CompletionOptions } from "./messenger/complete-command.js";
101
101
  export { type ConnectOptions, connectFor, type Messenger, type MessengerContext, messengerContext, type ProfileState, type ReadConnection, type StoredMessages, } from "./messenger/context.js";
102
+ export { DOCTOR_DEEP_OPTION, damageNotes } from "./messenger/doctor-command.js";
102
103
  export { COMMAND_NEEDS } from "./messenger/lacks.js";
103
104
  export { httpTokenFile, type McpEnvironment, type McpHost, serverEntry } from "./messenger/mcp-command.js";
104
105
  export { type MessageItem, messageItem, type ReadMessage } from "./messenger/message-items.js";
@@ -131,5 +132,6 @@ export { accountCommand, addMeetingBatchCommand, addMeetingPreparationCommand, a
131
132
  export { assertSharedContracts, customizeSharedCommand, hasSharedContract, overrideSharedContract, type SharedContractOverride, sharedContractOverrides, sharedContracts, } from "./shared-contracts.js";
132
133
  export { runnable, withSafety } from "./steps.js";
133
134
  export { type StoreInfo, sharedStoreCommand, storeInfo, storeInfoCommand } from "./store-info.js";
135
+ export { type RecoveryCheck, type RecoveryRun, recoveryNotes, storeRecoverCommand, storeRecoveryCheck, storeRecoveryRun, } from "./store-recover.js";
134
136
  export { changeStoreSetting, isStoreSetting, STORE_SETTINGS, storeSettings } from "./store-settings.js";
135
137
  export { restartServers, type UpgradeContext } from "./upgrade-command.js";
package/dist/cli/index.js CHANGED
@@ -89,6 +89,7 @@ export { readMeetingSeriesInventory } from "./meeting-series-inventory-read.js";
89
89
  export { accountFileFor, profilesHere, rememberAccount } from "./messenger/accounts.js";
90
90
  export { terminalAsker } from "./messenger/ask.js";
91
91
  export { connectFor, messengerContext, } from "./messenger/context.js";
92
+ export { DOCTOR_DEEP_OPTION, damageNotes } from "./messenger/doctor-command.js";
92
93
  export { COMMAND_NEEDS } from "./messenger/lacks.js";
93
94
  export { httpTokenFile, serverEntry } from "./messenger/mcp-command.js";
94
95
  export { messageItem } from "./messenger/message-items.js";
@@ -120,5 +121,6 @@ export { accountCommand, addMeetingBatchCommand, addMeetingPreparationCommand, a
120
121
  export { assertSharedContracts, customizeSharedCommand, hasSharedContract, overrideSharedContract, sharedContractOverrides, sharedContracts, } from "./shared-contracts.js";
121
122
  export { runnable, withSafety } from "./steps.js";
122
123
  export { sharedStoreCommand, storeInfo, storeInfoCommand } from "./store-info.js";
124
+ export { recoveryNotes, storeRecoverCommand, storeRecoveryCheck, storeRecoveryRun, } from "./store-recover.js";
123
125
  export { changeStoreSetting, isStoreSetting, STORE_SETTINGS, storeSettings } from "./store-settings.js";
124
126
  export { restartServers } from "./upgrade-command.js";
@@ -8,6 +8,9 @@ import { type Messenger } from "./context.js";
8
8
  * open — each is a field in the answer, never an exception.
9
9
  */
10
10
  export declare const doctorCommand: (messenger: Messenger) => Command;
11
+ export declare const DOCTOR_DEEP_OPTION = "also read every page of the store and check every search index (about 30 s for 6 GB); without it, only the file header and schema";
12
+ /** The plain-words account of a damaged store, under the answer. */
13
+ export declare const damageNotes: (store: unknown) => string[];
11
14
  /** What the messenger asked this profile to wait for, still in force — what the next command would be refused. */
12
15
  export declare const floodState: (memory: FloodMemory) => {
13
16
  path: string;
@@ -49,10 +49,24 @@ export const doctorCommand = (messenger) => {
49
49
  renderAnswer(this, { ...output, ...(renderer ? { renderer } : {}) }, itemOf(report, { next: unchecked ? [nextStep("doctor.online", { args: { online: true } })] : [] }));
50
50
  for (const fix of fixes)
51
51
  (renderer ?? output.renderer).note(`to make it private: ${fix}`);
52
+ for (const line of damageNotes(report.store))
53
+ (renderer ?? output.renderer).note(line);
52
54
  });
53
55
  command.addCommand(reportCommand(messenger));
54
56
  return command;
55
57
  };
58
+ export const DOCTOR_DEEP_OPTION = "also read every page of the store and check every search index (about 30 s for 6 GB); without it, only the file header and schema";
59
+ /** The shared doctor stays quick; `store recover` is its deep check, and a CLI may add `--deep` to its own doctor. */
60
+ const summaryOptions = (command, app) => ({
61
+ deep: command.opts().deep === true,
62
+ command: app,
63
+ });
64
+ /** The plain-words account of a damaged store, under the answer. */
65
+ export const damageNotes = (store) => {
66
+ const damage = store
67
+ ?.damage;
68
+ return damage ? [damage.what, damage.external_unaffected, ...damage.steps.map((step) => `next: ${step}`)] : [];
69
+ };
56
70
  const INCLUDES = [
57
71
  "the version, the runtime and the operating system",
58
72
  "what `doctor` answers: paths with the home folder shown as ~, the store's schema and counts, the messenger's own checks",
@@ -149,7 +163,7 @@ const diagnose = async (command, messenger) => {
149
163
  cli,
150
164
  config: { path, found: existsSync(path), issues: [] },
151
165
  settings: { error: messageOf(error) },
152
- store: snakeKeys(await storeSummary(env)),
166
+ store: snakeKeys(await storeSummary(env, summaryOptions(command, app.command))),
153
167
  runs: { directory: runsDirFor(app, env), kept: listRuns(runsDirFor(app, env)).length },
154
168
  files: filesReport(files),
155
169
  },
@@ -174,7 +188,7 @@ const diagnose = async (command, messenger) => {
174
188
  cli,
175
189
  profile: null,
176
190
  config,
177
- store: snakeKeys(await storeSummary(env)),
191
+ store: snakeKeys(await storeSummary(env, summaryOptions(command, app.command))),
178
192
  readers: engineStates(),
179
193
  runs: { directory: runsDirFor(app, env), kept: listRuns(runsDirFor(app, env)).length },
180
194
  files: filesReport(files),
@@ -210,7 +224,7 @@ const diagnose = async (command, messenger) => {
210
224
  account: { remembered: account?.account ?? null },
211
225
  // A session file on disk says nothing about whether the messenger still accepts it.
212
226
  login: { state: "not checked" },
213
- store: snakeKeys(await storeSummary(env)),
227
+ store: snakeKeys(await storeSummary(env, summaryOptions(command, app.command))),
214
228
  readers: engineStates(),
215
229
  sends: sendsState(new SendJournal(sendsPathFor(app, profile, env))),
216
230
  flood: floodState(new FloodMemory(flood)),
@@ -1,12 +1,27 @@
1
1
  import { Command } from "commander";
2
2
  import type { CacheDatabase } from "../../store/driver.js";
3
3
  import type { Messenger } from "./context.js";
4
- /** `doctor`'s short summary of the store. Must answer for a broken file, so an error is a field. */
5
- export declare const storeSummary: (env: NodeJS.ProcessEnv) => Promise<{
4
+ /**
5
+ * `doctor`'s short summary of the store. Must answer for a broken file, so an error is a field. `health` is the quick
6
+ * check unless `deep`; `damage` says in plain words what a failed one means and what to run.
7
+ */
8
+ export declare const storeSummary: (env: NodeJS.ProcessEnv, { deep, command }?: {
9
+ deep?: boolean;
10
+ command?: string;
11
+ }) => Promise<{
6
12
  path: string;
7
13
  exists: boolean;
8
- error?: undefined;
9
14
  } | {
15
+ health: {
16
+ checked: "deep" | "quick";
17
+ covers: string;
18
+ ok: boolean;
19
+ ms: number;
20
+ problems?: number | undefined;
21
+ damagedTables?: import("../../store/health.js").DamagedTable[] | undefined;
22
+ damagedSearchIndexes?: string[] | undefined;
23
+ };
24
+ damage?: import("../../store/damage.js").DamageExplanation | undefined;
10
25
  path: string;
11
26
  exists: boolean;
12
27
  schema: number;
@@ -15,6 +30,16 @@ export declare const storeSummary: (env: NodeJS.ProcessEnv) => Promise<{
15
30
  chats?: number | undefined;
16
31
  messages?: number | undefined;
17
32
  } | {
33
+ health: {
34
+ checked: "deep" | "quick";
35
+ covers: string;
36
+ ok: boolean;
37
+ ms: number;
38
+ problems?: number | undefined;
39
+ damagedTables?: import("../../store/health.js").DamagedTable[] | undefined;
40
+ damagedSearchIndexes?: string[] | undefined;
41
+ };
42
+ damage?: import("../../store/damage.js").DamageExplanation | undefined;
18
43
  path: string;
19
44
  exists: boolean;
20
45
  error: string;
@@ -8,6 +8,8 @@ import { RULES_VERSION } from "../../conversations/link.js";
8
8
  import { syncsOneChat } from "../../ports/traits.js";
9
9
  import { isSealed, sealFile, unsealFile } from "../../sealed.js";
10
10
  import { fileSchemaOf, SPEAKS, writableBy } from "../../store/compatibility.js";
11
+ import { explainDamage } from "../../store/damage.js";
12
+ import { QUICK_HEALTH_CHECKS, storeHealth } from "../../store/health.js";
11
13
  import { migrate } from "../../store/migrations.js";
12
14
  import { openCache } from "../../store/open.js";
13
15
  import { storePath } from "../../store/path.js";
@@ -18,6 +20,7 @@ import { destroysNothing, LOCAL_READ, LOCAL_WRITE } from "../effects.js";
18
20
  import { itemOf, renderAnswer, snakeKeys } from "../envelope.js";
19
21
  import { lineOf, nextStep } from "../steps.js";
20
22
  import { storeInfoCommand } from "../store-info.js";
23
+ import { storeRecoverCommand } from "../store-recover.js";
21
24
  import { answerOf } from "./ask.js";
22
25
  import { ENCRYPT_OPTION, passwordOf } from "./password.js";
23
26
  import { servingProfiles } from "./serve-command.js";
@@ -55,11 +58,28 @@ const readingOnly = async (path, read) => {
55
58
  database.close();
56
59
  }
57
60
  };
58
- /** `doctor`'s short summary of the store. Must answer for a broken file, so an error is a field. */
59
- export const storeSummary = async (env) => {
61
+ /**
62
+ * `doctor`'s short summary of the store. Must answer for a broken file, so an error is a field. `health` is the quick
63
+ * check unless `deep`; `damage` says in plain words what a failed one means and what to run.
64
+ */
65
+ export const storeSummary = async (env, { deep = false, command } = {}) => {
60
66
  const path = storePath(env);
61
67
  if (!existsSync(path))
62
68
  return { path, exists: false };
69
+ const checked = await storeHealth(path, { deep });
70
+ const health = {
71
+ checked: checked.checked,
72
+ covers: deep ? "every page and every search index" : QUICK_HEALTH_CHECKS,
73
+ ok: checked.ok,
74
+ ms: checked.ms,
75
+ ...(checked.problems ? { problems: checked.problems } : {}),
76
+ ...(checked.damagedTables?.length ? { damagedTables: checked.damagedTables } : {}),
77
+ ...(checked.searchIndexes?.some((index) => !index.ok)
78
+ ? { damagedSearchIndexes: checked.searchIndexes.filter((index) => !index.ok).map((index) => index.name) }
79
+ : {}),
80
+ };
81
+ const damage = command ? explainDamage(checked, command) : null;
82
+ const healthOf = { health, ...(damage ? { damage } : {}) };
63
83
  try {
64
84
  return await reading(path, (database) => {
65
85
  const { version, writable } = schemaOf(database);
@@ -70,11 +90,12 @@ export const storeSummary = async (env) => {
70
90
  speaks: SPEAKS,
71
91
  writable,
72
92
  ...(version > 0 ? { chats: count(database, "chats"), messages: count(database, "messages") } : {}),
93
+ ...healthOf,
73
94
  };
74
95
  });
75
96
  }
76
97
  catch (error) {
77
- return { path, exists: true, error: messageOf(error) };
98
+ return { path, exists: true, error: messageOf(error), ...healthOf };
78
99
  }
79
100
  };
80
101
  /** A maintenance answer: one item, snake_case, its hints as steps where a registered one fits. */
@@ -91,6 +112,7 @@ export const storeMaintenanceCommands = (messenger) => [
91
112
  resetCommand(messenger),
92
113
  copiesCommand(),
93
114
  storeTablesCommand(messenger),
115
+ storeRecoverCommand({ command: messenger.app.command, version: messenger.app.version }),
94
116
  ];
95
117
  /** Renaming tables under a running `serve` would pull them from under its statements. */
96
118
  const refuseWhileServing = (command, messenger) => {
@@ -9,4 +9,7 @@ export type StoreInfo = v.InferOutput<typeof storeInfoEntity>;
9
9
  export declare const storeInfo: (env: NodeJS.ProcessEnv) => Promise<StoreInfo>;
10
10
  export declare const storeInfoCommand: () => Command;
11
11
  /** `store info` for a tool with no `store` command of its own. */
12
- export declare const sharedStoreCommand: () => Command;
12
+ export declare const sharedStoreCommand: (app?: {
13
+ command: string;
14
+ version?: string;
15
+ }) => Command;
@@ -7,6 +7,7 @@ import { fileFacts, openSqlite, pendingNormalization, rowCounts, searchIndexStat
7
7
  import { environmentOf, outputFor } from "./context.js";
8
8
  import { LOCAL_READ } from "./effects.js";
9
9
  import { itemOf, renderAnswer, snakeKeys } from "./envelope.js";
10
+ import { storeRecoverCommand } from "./store-recover.js";
10
11
  /** Before version 6 the column does not exist, and a file behind this build is not migrated here. */
11
12
  const pendingIfKnown = (database) => database.prepare("SELECT 1 FROM pragma_table_info('messages') WHERE name = 'normalized_text'").get()
12
13
  ? pendingNormalization(database)
@@ -67,6 +68,9 @@ export const storeInfoCommand = () => annotate(new Command("info"), { output: {
67
68
  renderAnswer(this, outputFor(this), itemOf(answer));
68
69
  });
69
70
  /** `store info` for a tool with no `store` command of its own. */
70
- export const sharedStoreCommand = () => annotate(new Command("store"), { effects: LOCAL_READ })
71
- .description("the shared local store every WireCat tool reads")
72
- .addCommand(storeInfoCommand());
71
+ export const sharedStoreCommand = (app) => {
72
+ const store = annotate(new Command("store"), { effects: LOCAL_READ })
73
+ .description("the shared local store every WireCat tool reads")
74
+ .addCommand(storeInfoCommand());
75
+ return app ? store.addCommand(storeRecoverCommand(app)) : store;
76
+ };
@@ -0,0 +1,56 @@
1
+ import { Command } from "commander";
2
+ import { type DamageExplanation } from "../store/damage.js";
3
+ import { type RecoverOptions, type Recovery } from "../store/recover.js";
4
+ export interface RecoveryCheck {
5
+ path: string;
6
+ exists: boolean;
7
+ ok: boolean;
8
+ seconds?: number;
9
+ problems?: number;
10
+ damagedTables?: {
11
+ table: string | null;
12
+ problems: number;
13
+ sample: string;
14
+ }[];
15
+ damagedSearchIndexes?: {
16
+ name: string;
17
+ error?: string;
18
+ }[];
19
+ rows?: Record<string, number | null>;
20
+ explanation?: DamageExplanation | null;
21
+ inUse?: {
22
+ pid: number;
23
+ name: string;
24
+ }[] | null;
25
+ timers?: string[] | null;
26
+ sqlite3?: {
27
+ version: string | null;
28
+ usable: boolean;
29
+ };
30
+ /** The commands to run, in order. */
31
+ next: string[];
32
+ }
33
+ /** `store recover`: what is wrong with the store and what to run, reading every page; changes nothing. */
34
+ export declare const storeRecoveryCheck: (env: NodeJS.ProcessEnv, command: string) => Promise<RecoveryCheck>;
35
+ export type RecoveryRun = Omit<Recovery, "health" | "copyErrors"> & {
36
+ /** `sqlite3` reported these while copying; most are rows that broke a constraint and were skipped. */
37
+ copyErrors: number;
38
+ checkedOk: boolean;
39
+ /** The commands that fetch again what was lost, then the timers to start. */
40
+ next: string[];
41
+ };
42
+ /** `store recover run`: refuses while a WireCat timer is scheduled, so none writes to the file being replaced. */
43
+ export declare const storeRecoveryRun: (env: NodeJS.ProcessEnv, { command, version, note, timers: scheduled, copy, }: {
44
+ command: string;
45
+ version?: string;
46
+ note?: (text: string) => void;
47
+ timers?: () => string[] | undefined;
48
+ copy?: RecoverOptions["copy"];
49
+ }) => Promise<RecoveryRun>;
50
+ /** The lines a person reads under the answer; the JSON carries the same in its fields. */
51
+ export declare const recoveryNotes: (answer: RecoveryCheck | RecoveryRun) => string[];
52
+ /** `store recover` and `store recover run`, for a CLI whose commands run on cli-core's context. */
53
+ export declare const storeRecoverCommand: ({ command, version }: {
54
+ command: string;
55
+ version?: string;
56
+ }) => Command;
@@ -0,0 +1,153 @@
1
+ import { existsSync } from "node:fs";
2
+ import { CliError } from "@wirecat/cli-base";
3
+ import { annotate } from "@wirecat/cli-base/commands";
4
+ import { Command } from "commander";
5
+ import { explainDamage } from "../store/damage.js";
6
+ import { storeHealth } from "../store/health.js";
7
+ import { storePath } from "../store/path.js";
8
+ import { activeTimers, recoverStore, sqlite3Program, storeHolders, tableRows, } from "../store/recover.js";
9
+ import { openSqlite } from "../store/store.js";
10
+ import { environmentOf, outputFor } from "./context.js";
11
+ import { LOCAL_READ, LOCAL_WRITE } from "./effects.js";
12
+ import { itemOf, renderAnswer, snakeKeys } from "./envelope.js";
13
+ /** The tables whose counts say what the store holds; every other table is in `store info`. */
14
+ const COUNTED = [
15
+ "accounts",
16
+ "chats",
17
+ "messages",
18
+ "attachments",
19
+ "emails",
20
+ "documents",
21
+ "notes",
22
+ "meetings",
23
+ "chunks",
24
+ "embeddings",
25
+ "mentions",
26
+ ];
27
+ const stopTimers = (timers) => `systemctl --user stop ${timers.join(" ")}`;
28
+ /** `store recover`: what is wrong with the store and what to run, reading every page; changes nothing. */
29
+ export const storeRecoveryCheck = async (env, command) => {
30
+ const path = storePath(env);
31
+ if (!existsSync(path))
32
+ return { path, exists: false, ok: true, next: [] };
33
+ const health = await storeHealth(path, { deep: true });
34
+ let rows = {};
35
+ try {
36
+ const { database } = await openSqlite(path, { readOnly: true });
37
+ try {
38
+ const all = tableRows(database);
39
+ rows = Object.fromEntries(COUNTED.filter((table) => table in all).map((table) => [table, all[table] ?? null]));
40
+ }
41
+ finally {
42
+ database.close();
43
+ }
44
+ }
45
+ catch { }
46
+ const explanation = explainDamage(health, command);
47
+ const inUse = storeHolders(path) ?? null;
48
+ const timers = activeTimers() ?? null;
49
+ const sqlite3 = sqlite3Program();
50
+ const next = health.ok
51
+ ? []
52
+ : [
53
+ ...(timers?.length ? [stopTimers(timers)] : []),
54
+ ...(inUse ?? []).map(({ pid, name }) => `stop ${name} (process ${pid}): it has the store open`),
55
+ ...(sqlite3.usable ? [] : ["install sqlite3 3.43 or newer (apt install sqlite3, brew install sqlite)"]),
56
+ `${command} store recover run`,
57
+ ];
58
+ return {
59
+ path,
60
+ exists: true,
61
+ ok: health.ok,
62
+ seconds: Math.round(health.ms / 100) / 10,
63
+ problems: health.problems ?? 0,
64
+ damagedTables: health.damagedTables ?? [],
65
+ damagedSearchIndexes: (health.searchIndexes ?? [])
66
+ .filter((index) => !index.ok)
67
+ .map(({ name, error }) => ({ name, ...(error ? { error } : {}) })),
68
+ rows,
69
+ explanation,
70
+ inUse,
71
+ timers,
72
+ sqlite3: { version: sqlite3.version, usable: sqlite3.usable },
73
+ next,
74
+ };
75
+ };
76
+ /** `store recover run`: refuses while a WireCat timer is scheduled, so none writes to the file being replaced. */
77
+ export const storeRecoveryRun = async (env, { command, version, note, timers: scheduled = activeTimers, copy, }) => {
78
+ const timers = scheduled() ?? [];
79
+ if (timers.length > 0)
80
+ throw new CliError("validation_error", `stop the timers first, they open the store: ${stopTimers(timers)}`, {
81
+ reason: "store.in-use",
82
+ });
83
+ const recovery = await recoverStore({
84
+ path: storePath(env),
85
+ command,
86
+ ...(version ? { version } : {}),
87
+ ...(note ? { note } : {}),
88
+ ...(copy ? { copy } : {}),
89
+ });
90
+ const mail = recovery.mailToFetch.map(({ account, emails }) => `mail <profile of ${account}> sync --rescan # ${emails} emails`);
91
+ const lostMail = recovery.lost.some(({ table }) => table === "emails") && mail.length === 0
92
+ ? ["mail <profile> sync --rescan # each mail profile"]
93
+ : [];
94
+ const { health, copyErrors, ...kept } = recovery;
95
+ return {
96
+ ...kept,
97
+ copyErrors: copyErrors.length,
98
+ checkedOk: health.ok,
99
+ next: [...mail, ...lostMail, "systemctl --user start <the timers stopped before>"],
100
+ };
101
+ };
102
+ /** The lines a person reads under the answer; the JSON carries the same in its fields. */
103
+ export const recoveryNotes = (answer) => {
104
+ if ("keptAt" in answer) {
105
+ const lost = answer.lost.map(({ table, before, read, kept }) => `${table}: ${kept} kept of ${before ?? "an uncountable number"}${read > kept ? `, ${read - kept} read but unusable` : ""}`);
106
+ return [
107
+ `recovered in ${answer.seconds.total} s; the damaged file is kept at ${answer.keptAt}`,
108
+ ...(lost.length ? lost : ["no table lost a row"]),
109
+ ...answer.dangling.map(({ table, parent, rows }) => `${rows} rows of ${table} point at ${parent} rows the store does not hold`),
110
+ ...answer.next.map((line) => `next: ${line}`),
111
+ ];
112
+ }
113
+ if (!answer.exists)
114
+ return ["no store yet"];
115
+ if (answer.ok)
116
+ return [`the store is healthy (checked every page and search index in ${answer.seconds} s)`];
117
+ const why = answer.explanation;
118
+ return [
119
+ ...(why ? [why.what, ...why.impact, why.externalUnaffected] : []),
120
+ ...answer.next.map((line) => `next: ${line}`),
121
+ ];
122
+ };
123
+ /** `store recover` and `store recover run`, for a CLI whose commands run on cli-core's context. */
124
+ export const storeRecoverCommand = ({ command, version }) => {
125
+ const recover = annotate(new Command("recover"), { effects: LOCAL_READ, examples: [`${command} store recover`] })
126
+ .summary("what is damaged in the store and how to recover it")
127
+ .description("check every page and search index of the store, say what is damaged, what it means and the commands that recover it; changes nothing")
128
+ .action(async function () {
129
+ const output = outputFor(this);
130
+ const answer = await storeRecoveryCheck(environmentOf(this).env ?? process.env, command);
131
+ renderAnswer(this, output, itemOf(snakeKeys(answer)));
132
+ if (output.format === "pretty")
133
+ for (const line of recoveryNotes(answer))
134
+ output.renderer.note(line);
135
+ });
136
+ recover.addCommand(annotate(new Command("run"), { effects: LOCAL_WRITE, examples: [`${command} store recover run`] })
137
+ .summary("rebuild a damaged store from every row it can read")
138
+ .description("copy every readable row into a new file with the sqlite3 program, rebuild the search indexes, mentions and vector index, " +
139
+ "check it, and put it in place of the store; the damaged file is kept beside it. Refuses while a WireCat timer or process has the store")
140
+ .action(async function () {
141
+ const output = outputFor(this);
142
+ const answer = await storeRecoveryRun(environmentOf(this).env ?? process.env, {
143
+ command,
144
+ ...(version ? { version } : {}),
145
+ note: (text) => output.renderer.note(text),
146
+ });
147
+ renderAnswer(this, output, itemOf(snakeKeys(answer)));
148
+ if (output.format === "pretty")
149
+ for (const line of recoveryNotes(answer))
150
+ output.renderer.note(line);
151
+ }));
152
+ return recover;
153
+ };
@@ -156,6 +156,14 @@ const OTHERS = [
156
156
  ],
157
157
  validation("store.refused", "the local store refused the document text"),
158
158
  ["store.not-writable", "permission_error", "the message store cannot be written from this process"],
159
+ validation("store.in-use", "a process or a scheduled timer has the store open; stop it, then recover the store"),
160
+ [
161
+ "store.sqlite3-missing",
162
+ "configuration_error",
163
+ "recovering the store needs the sqlite3 program, 3.43 or newer, with its .recover command",
164
+ ],
165
+ validation("store.recovery-failed", "the recovered store did not pass its check, so the store was left as it was"),
166
+ validation("store.changed-during-recovery", "the store was written to during the recovery, so it was left as it was"),
159
167
  [
160
168
  "store.vector-extension-missing",
161
169
  "configuration_error",
@@ -0,0 +1,19 @@
1
+ import type { StoreHealth } from "./health.js";
2
+ /**
3
+ * What a damaged store means, in the owner's words: one structure the CLIs render and the desktop app can show as it
4
+ * is. Built from a health check only, so it never reads the damaged file again.
5
+ */
6
+ export interface DamageExplanation {
7
+ what: string;
8
+ causes: string[];
9
+ investigate: string[];
10
+ impact: string[];
11
+ externalUnaffected: string;
12
+ steps: string[];
13
+ }
14
+ /**
15
+ * Tables a recovery rebuilds from the others: search indexes, the vector index, what the indexing finds, and the
16
+ * vectors, which a search by meaning computes again for any text that has none.
17
+ */
18
+ export declare const isDerivedTable: (table: string, searchIndexes: readonly string[]) => boolean;
19
+ export declare const explainDamage: (health: StoreHealth, command: string) => DamageExplanation | null;
@@ -0,0 +1,58 @@
1
+ const DERIVED = new Set(["mentions", "involvements", "embeddings"]);
2
+ const named = (tables) => tables.length <= 5 ? tables.join(", ") : `${tables.slice(0, 5).join(", ")} and ${tables.length - 5} more`;
3
+ /**
4
+ * Tables a recovery rebuilds from the others: search indexes, the vector index, what the indexing finds, and the
5
+ * vectors, which a search by meaning computes again for any text that has none.
6
+ */
7
+ export const isDerivedTable = (table, searchIndexes) => DERIVED.has(table) ||
8
+ table.startsWith("vector_index_") ||
9
+ searchIndexes.some((index) => table === index || table.startsWith(`${index}_`));
10
+ const WIRECAT_UNITS = ["memo-*", "mail-*", "tg-*", "max-*", "wirecat-*"];
11
+ export const explainDamage = (health, command) => {
12
+ if (health.ok)
13
+ return null;
14
+ const indexNames = (health.searchIndexes ?? []).map((index) => index.name);
15
+ const badIndexes = (health.searchIndexes ?? []).filter((index) => !index.ok).map((index) => index.name);
16
+ const tables = (health.damagedTables ?? []).map((one) => one.table).filter((table) => table !== null);
17
+ const sources = tables.filter((table) => !isDerivedTable(table, indexNames));
18
+ const derived = [...new Set([...tables.filter((table) => isDerivedTable(table, indexNames)), ...badIndexes])];
19
+ const unopened = health.error !== undefined;
20
+ const what = unopened
21
+ ? `the store file cannot be opened: ${health.error}`
22
+ : health.checked === "quick"
23
+ ? "the store file's header or schema is damaged"
24
+ : `the store file is damaged: ${health.problems ?? 0} problems` +
25
+ (sources.length ? ` in the data of ${named(sources)}` : "") +
26
+ (derived.length ? `${sources.length ? ", and" : " in"} the search data of ${named(derived)}` : "");
27
+ const impact = [
28
+ ...(sources.length
29
+ ? [`some rows of ${named(sources)} cannot be read; a recovery keeps every row it can read and names the rest`]
30
+ : []),
31
+ ...(derived.length
32
+ ? [`searches may miss results or fail; ${named(derived)} hold nothing that is not elsewhere, and are rebuilt`]
33
+ : []),
34
+ "reads that do not touch a damaged page work; a write that touches one fails until the store is recovered",
35
+ ];
36
+ return {
37
+ what,
38
+ causes: [
39
+ "the computer lost power or crashed, or the disk filled up, while a tool was writing",
40
+ "the file was copied, restored or synced while a tool had it open, or it sits in a synced or network folder",
41
+ "a fault of the disk itself",
42
+ ],
43
+ investigate: [
44
+ `journalctl --user ${WIRECAT_UNITS.map((unit) => `-u '${unit}'`).join(" ")} --since -14d | grep -i -e malformed -e corrupt -e 'disk full'`,
45
+ "journalctl -k --since -14d | grep -i -e 'I/O error' -e nvme -e ata",
46
+ `df -h ${health.path.replace(/\/[^/]*$/, "")}`,
47
+ ...(health.checked === "quick" ? [`${command} store recover`] : []),
48
+ ],
49
+ impact,
50
+ externalUnaffected: "only this computer's copy is damaged: Telegram, MAX, the mail servers and Google Drive keep their own data, " +
51
+ "so whatever is lost here can be fetched again by a sync",
52
+ steps: [
53
+ "stop every timer and server of the WireCat tools; `store recover run` lists the ones still running",
54
+ `${command} store recover run`,
55
+ "run the fetch commands it prints, then start the timers again",
56
+ ],
57
+ };
58
+ };
@@ -0,0 +1,50 @@
1
+ import type { CacheDatabase } from "./driver.js";
2
+ /** One table's share of what `PRAGMA quick_check` found; `table` is null for pages no table claims. */
3
+ export interface DamagedTable {
4
+ table: string | null;
5
+ problems: number;
6
+ /** The first line SQLite gave for it: page and cell numbers, never row content. */
7
+ sample: string;
8
+ }
9
+ export interface SearchIndexHealth {
10
+ name: string;
11
+ ok: boolean;
12
+ error?: string;
13
+ }
14
+ export interface StoreHealth {
15
+ path: string;
16
+ /** `quick`: the file header and the schema only, in milliseconds. `deep`: every page and every search index. */
17
+ checked: "quick" | "deep";
18
+ ok: boolean;
19
+ error?: string;
20
+ file?: {
21
+ bytes: number;
22
+ pages: number;
23
+ pageSize: number;
24
+ freePages: number;
25
+ };
26
+ damagedTables?: DamagedTable[];
27
+ /** Lines `quick_check` gave in all, capped at `QUICK_CHECK_LIMIT`. */
28
+ problems?: number;
29
+ searchIndexes?: SearchIndexHealth[];
30
+ ms: number;
31
+ }
32
+ export declare const QUICK_CHECK_LIMIT = 10000;
33
+ /** What `doctor` checks by default, said where it answers. */
34
+ export declare const QUICK_HEALTH_CHECKS = "the file header, its size against its page count, and the schema's own pages";
35
+ export declare const fts5Tables: (database: CacheDatabase) => {
36
+ name: string;
37
+ sql: string;
38
+ external: boolean;
39
+ }[];
40
+ /** Groups the lines by the table whose tree they name: `Tree <root page> …` or `NULL value in <table>.<column>`. */
41
+ export declare const damagedTables: (database: CacheDatabase, lines: string[]) => DamagedTable[];
42
+ /** `rank = 0` checks the index's own structure, the only check a contentless index has; it writes nothing. */
43
+ export declare const searchIndexHealth: (database: CacheDatabase) => SearchIndexHealth[];
44
+ /**
45
+ * The store's health without changing it. `quick` takes milliseconds at any size; `deep` reads every page
46
+ * (about 30 s for 6 GB) and checks every search index, under a transaction it rolls back.
47
+ */
48
+ export declare const storeHealth: (path: string, { deep }?: {
49
+ deep?: boolean;
50
+ }) => Promise<StoreHealth>;