@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 +26 -0
- package/dist/cli/index.d.ts +2 -0
- package/dist/cli/index.js +2 -0
- package/dist/cli/messenger/doctor-command.d.ts +3 -0
- package/dist/cli/messenger/doctor-command.js +17 -3
- package/dist/cli/messenger/store-maintenance-command.d.ts +28 -3
- package/dist/cli/messenger/store-maintenance-command.js +25 -3
- package/dist/cli/store-info.d.ts +4 -1
- package/dist/cli/store-info.js +7 -3
- package/dist/cli/store-recover.d.ts +56 -0
- package/dist/cli/store-recover.js +153 -0
- package/dist/errors/reasons.js +8 -0
- package/dist/store/damage.d.ts +19 -0
- package/dist/store/damage.js +58 -0
- package/dist/store/health.d.ts +50 -0
- package/dist/store/health.js +170 -0
- package/dist/store/index.d.ts +2 -0
- package/dist/store/index.js +2 -0
- package/dist/store/recover.d.ts +103 -0
- package/dist/store/recover.js +404 -0
- package/dist/store/sqlite/open-store.js +9 -2
- package/package.json +1 -1
- package/parity.json +6 -0
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
|
package/dist/cli/index.d.ts
CHANGED
|
@@ -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
|
-
/**
|
|
5
|
-
|
|
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
|
-
/**
|
|
59
|
-
|
|
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) => {
|
package/dist/cli/store-info.d.ts
CHANGED
|
@@ -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: (
|
|
12
|
+
export declare const sharedStoreCommand: (app?: {
|
|
13
|
+
command: string;
|
|
14
|
+
version?: string;
|
|
15
|
+
}) => Command;
|
package/dist/cli/store-info.js
CHANGED
|
@@ -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 = () =>
|
|
71
|
-
|
|
72
|
-
|
|
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
|
+
};
|
package/dist/errors/reasons.js
CHANGED
|
@@ -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>;
|