@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.
@@ -0,0 +1,404 @@
1
+ import { execFileSync, spawn } from "node:child_process";
2
+ import { existsSync, readFileSync, realpathSync, renameSync, rmSync, statfsSync, statSync, writeFileSync, } from "node:fs";
3
+ import { basename, dirname, join } from "node:path";
4
+ import { Transform } from "node:stream";
5
+ import { CliError } from "@wirecat/cli-base";
6
+ import { holdersOf } from "../background/processes.js";
7
+ import { fts5Tables, storeHealth } from "./health.js";
8
+ import { migrate } from "./migrations.js";
9
+ import { resetAttachmentWords } from "./sqlite/attachment-texts.js";
10
+ import { involvementStoreOver } from "./sqlite/involvements.js";
11
+ import { drainMeetingIndex } from "./sqlite/meetings.js";
12
+ import { rebuildMentions } from "./sqlite/mentions.js";
13
+ import { CORPORA, drainCorpus, fillTrigrams, resetNoteIndex } from "./sqlite/note-index.js";
14
+ import { openSqlite } from "./sqlite/open.js";
15
+ import { fillSearchIndex, resetSearchIndex } from "./sqlite/search-index.js";
16
+ import { stemmerCache } from "./sqlite/stems.js";
17
+ import { fillStems, resetStems } from "./sqlite/stems-fill.js";
18
+ import { fillVectorIndexes } from "./sqlite/vector-index.js";
19
+ /** `contentless_delete`, which every word index uses, came in SQLite 3.43. */
20
+ export const SQLITE3_MINIMUM = [3, 43, 0];
21
+ const STORE_PARTS = ["", "-wal", "-shm"];
22
+ /** The start of a row `.recover` writes; a value's own lines cannot start a line with it, its quotes are doubled. */
23
+ const INSERTED = /^INSERT OR IGNORE INTO '([^']+)'/;
24
+ const LOST_AND_FOUND = "recover_lost_and_found";
25
+ /** The `sqlite3` program's version, or why it cannot be used: the recovery reads the damaged file with it. */
26
+ /** Some builds leave out the page table `.recover` reads, and fail only when it runs. */
27
+ const recovers = (program) => {
28
+ try {
29
+ execFileSync(program, [":memory:", ".recover"], { stdio: ["ignore", "ignore", "pipe"] });
30
+ return true;
31
+ }
32
+ catch {
33
+ return false;
34
+ }
35
+ };
36
+ export const sqlite3Program = (program = "sqlite3") => {
37
+ let version = null;
38
+ try {
39
+ version =
40
+ execFileSync(program, ["--version"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).split(" ")[0] ??
41
+ null;
42
+ }
43
+ catch {
44
+ return { program, version: null, usable: false };
45
+ }
46
+ const parts = (version ?? "").split(".").map(Number);
47
+ const at = SQLITE3_MINIMUM.findIndex((wanted, i) => (parts[i] ?? 0) !== wanted);
48
+ const recent = at === -1 || (parts[at] ?? 0) > (SQLITE3_MINIMUM[at] ?? 0);
49
+ return { program, version, usable: recent && recovers(program) };
50
+ };
51
+ /** The WireCat timers still scheduled: each may open the store while it is being recovered. Linux only. */
52
+ export const activeTimers = () => {
53
+ if (process.platform !== "linux")
54
+ return undefined;
55
+ try {
56
+ return execFileSync("systemctl", ["--user", "list-timers", "--plain", "--no-legend"], {
57
+ encoding: "utf8",
58
+ stdio: ["ignore", "pipe", "ignore"],
59
+ })
60
+ .split("\n")
61
+ .map((line) => line.trim().split(/\s+/).at(-2) ?? "")
62
+ .filter((unit) => /^(memo|mail|tg|max|whatsapp|zoom|drive|gcal|wirecat)-.*\.timer$/.test(unit));
63
+ }
64
+ catch {
65
+ return undefined;
66
+ }
67
+ };
68
+ /** Processes that have any of the store's files open, by PID and program name, never their arguments. */
69
+ export const storeHolders = (path) => {
70
+ const folder = realpathSync(dirname(path));
71
+ const files = STORE_PARTS.map((suffix) => join(folder, `${basename(path)}${suffix}`));
72
+ // A process that kept writing after its log was deleted holds it as "<path> (deleted)" (BUG-557).
73
+ const wanted = process.platform === "linux" ? files.flatMap((file) => [file, `${file} (deleted)`]) : files.filter(existsSync);
74
+ return holdersOf(wanted)?.map((pid) => {
75
+ let name = "?";
76
+ try {
77
+ name = readFileSync(`/proc/${pid}/comm`, "utf8").trim();
78
+ }
79
+ catch { }
80
+ return { pid, name };
81
+ });
82
+ };
83
+ /** Rows per table that holds data; `null` where the table cannot be counted. Search shadow tables are left out. */
84
+ export const tableRows = (database) => {
85
+ const indexes = fts5Tables(database).map((index) => index.name);
86
+ const tables = database
87
+ .prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND rootpage > 0 AND name NOT LIKE 'sqlite_%' " +
88
+ "AND name NOT LIKE 'vector_index_%' ORDER BY name")
89
+ .all()
90
+ .map((row) => String(row.name))
91
+ .filter((name) => !indexes.some((index) => name.startsWith(`${index}_`)));
92
+ return Object.fromEntries(tables.map((table) => {
93
+ try {
94
+ return [table, Number(database.prepare(`SELECT count(*) AS n FROM "${table}"`).get()?.n)];
95
+ }
96
+ catch {
97
+ return [table, null];
98
+ }
99
+ }));
100
+ };
101
+ /**
102
+ * Copies every row SQLite can still read into a new file with the `sqlite3` program's `.recover`: node:sqlite has
103
+ * no recovery API. The damaged file is opened read-only. Rows that fit no table go to a table the rebuild counts
104
+ * and drops; a row that breaks its table's constraints (a NULL where none may be) is skipped. Free pages are not
105
+ * read: what they hold was deleted, and must not come back.
106
+ */
107
+ export const copyReadableRows = (path, target, program = "sqlite3") => new Promise((resolve, reject) => {
108
+ const reader = spawn(program, ["-readonly", path, `.recover --ignore-freelist --lost-and-found ${LOST_AND_FOUND}`], {
109
+ stdio: ["ignore", "pipe", "pipe"],
110
+ });
111
+ // `.recover` writes a virtual table's row into the schema itself, which the shell refuses while defensive.
112
+ const writer = spawn(program, ["-cmd", ".dbconfig defensive off", target], { stdio: ["pipe", "ignore", "pipe"] });
113
+ const errors = [];
114
+ const collect = (chunk) => {
115
+ for (const line of chunk.toString("utf8").split("\n"))
116
+ if (line && errors.length < 50)
117
+ errors.push(line.slice(0, 300));
118
+ };
119
+ reader.stderr.on("data", collect);
120
+ writer.stderr.on("data", collect);
121
+ const read = {};
122
+ let rest = "";
123
+ const counter = new Transform({
124
+ transform(chunk, _encoding, done) {
125
+ const lines = (rest + chunk.toString("latin1")).split("\n");
126
+ rest = lines.pop() ?? "";
127
+ for (const line of lines) {
128
+ const table = INSERTED.exec(line)?.[1];
129
+ if (table)
130
+ read[table] = (read[table] ?? 0) + 1;
131
+ }
132
+ done(null, chunk);
133
+ },
134
+ });
135
+ reader.stdout.pipe(counter).pipe(writer.stdin);
136
+ let exits = 0;
137
+ let readerCode = null;
138
+ const exited = () => {
139
+ exits += 1;
140
+ if (exits < 2)
141
+ return;
142
+ if (readerCode !== 0)
143
+ reject(new CliError("validation_error", `sqlite3 could not read the store: ${errors.join("; ")}`));
144
+ else
145
+ resolve({ errors, read });
146
+ };
147
+ reader.on("error", reject);
148
+ writer.on("error", reject);
149
+ reader.on("close", (code) => {
150
+ readerCode = code;
151
+ exited();
152
+ });
153
+ writer.on("close", exited);
154
+ });
155
+ /** Each index recreated empty, and how it is filled again from the data: the same code the tools index with. */
156
+ const FILLERS = {
157
+ message_words: (database, now) => {
158
+ resetSearchIndex(database);
159
+ fillSearchIndex(database, { now });
160
+ },
161
+ message_stems: (database, now) => {
162
+ resetStems(database, { force: true, now });
163
+ fillStems(database, { now });
164
+ },
165
+ attachment_words: (database) => void resetAttachmentWords(database),
166
+ attachment_name_words: (database) =>
167
+ // As migration 20 filled it: й and ё fold as the triggers fold them.
168
+ database.exec("INSERT INTO attachment_name_words (rowid, name) SELECT id, " +
169
+ "replace(replace(replace(replace(name, 'й', 'и'), 'Й', 'И'), 'ё', 'е'), 'Ё', 'Е') FROM attachments WHERE name IS NOT NULL"),
170
+ meeting_words: (database) => {
171
+ database.exec("INSERT OR IGNORE INTO meeting_index_pending (indexable_type, id) " +
172
+ "SELECT 'meeting_transcript_row', id FROM meeting_transcript_rows " +
173
+ "UNION ALL SELECT 'meeting_chat_message', id FROM meeting_chat_messages " +
174
+ "UNION ALL SELECT 'meeting_summary', id FROM meeting_summaries");
175
+ drainMeetingIndex(database);
176
+ },
177
+ // Built per account by the first search that needs them.
178
+ meeting_stems: (database) => database.exec("UPDATE search_index_state SET filled_through = 0, built_at = NULL WHERE name LIKE 'meeting_stems:%'"),
179
+ };
180
+ const CORPUS_TABLES = new Set(Object.values(CORPORA).flatMap((corpus) => [`${corpus.type}_words`, `${corpus.type}_stems`]));
181
+ const TRIGRAMS = ["note_trigrams", "document_trigrams"];
182
+ /**
183
+ * Drops every search index and vector index and makes it again empty, then fills it from the data. A recovered
184
+ * index holds whatever pages survived, which no check can tell from a whole one; the data they come from can.
185
+ */
186
+ export const rebuildDerived = (database, { now = Date.now, note = () => { } } = {}) => {
187
+ const lost = database.prepare("SELECT 1 FROM sqlite_master WHERE name = ?").get(LOST_AND_FOUND);
188
+ const lostAndFound = lost ? Number(database.prepare(`SELECT count(*) AS n FROM ${LOST_AND_FOUND}`).get()?.n) : 0;
189
+ if (lost)
190
+ database.exec(`DROP TABLE ${LOST_AND_FOUND}`);
191
+ const indexes = fts5Tables(database);
192
+ for (const { name, sql } of indexes) {
193
+ database.exec(`DROP TABLE "${name}"`);
194
+ database.exec(sql);
195
+ }
196
+ const vectors = database
197
+ .prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name GLOB 'vector_index_[0-9]*' AND sql LIKE 'CREATE VIRTUAL%'")
198
+ .all()
199
+ .map((row) => String(row.name));
200
+ for (const table of vectors) {
201
+ database.exec(`DROP TRIGGER IF EXISTS "${table}_redo"`);
202
+ database.exec(`DROP TABLE "${table}"`);
203
+ database.prepare("DELETE FROM vector_indexes WHERE id = ?").run(Number(table.replace("vector_index_", "")));
204
+ }
205
+ const unfilled = [];
206
+ const stemmerFor = stemmerCache();
207
+ for (const { name, external } of indexes) {
208
+ note(`rebuilding ${name}`);
209
+ if (external)
210
+ database.exec(`INSERT INTO "${name}" ("${name}") VALUES ('rebuild')`);
211
+ else if (FILLERS[name])
212
+ FILLERS[name](database, now);
213
+ else if (!CORPUS_TABLES.has(name) && !TRIGRAMS.includes(name))
214
+ unfilled.push(name);
215
+ }
216
+ note("rebuilding the words of notes, documents, memories, mail, tasks, transcripts and events");
217
+ resetNoteIndex(database);
218
+ for (const corpus of Object.values(CORPORA))
219
+ drainCorpus(database, corpus, stemmerFor);
220
+ for (const name of TRIGRAMS) {
221
+ const type = name.replace("_trigrams", "");
222
+ database
223
+ .prepare(`UPDATE search_index_state SET filled_through = 0, watermark = (SELECT coalesce(max(id), 0) FROM ${type}s) WHERE name = ?`)
224
+ .run(name);
225
+ }
226
+ fillTrigrams(database);
227
+ note("finding mentions again");
228
+ const mentions = rebuildMentions(database, { now: now() }).mentions;
229
+ involvementStoreOver({ database, now }).rebuild();
230
+ note("indexing the stored vectors");
231
+ const vectorModels = fillVectorIndexes(database);
232
+ // Per model, so each lookup is one probe of the embeddings' primary key.
233
+ const without = database.prepare("SELECT count(*) AS n FROM chunks k WHERE NOT EXISTS (SELECT 1 FROM embeddings v WHERE v.model = ? AND v.content_hash = k.content_hash)");
234
+ const chunksWithoutVector = Object.fromEntries(database
235
+ .prepare("SELECT model FROM vector_indexes ORDER BY model")
236
+ .all()
237
+ .map((row) => [String(row.model), Number(without.get(String(row.model))?.n)]));
238
+ return {
239
+ lostAndFound,
240
+ searchIndexes: indexes.map((index) => index.name),
241
+ unfilled,
242
+ mentions,
243
+ vectorModels,
244
+ chunksWithoutVector,
245
+ };
246
+ };
247
+ /** Rows that point at a row the store does not hold: after a recovery, mostly rows that could not be read. */
248
+ export const danglingRows = (database) => database
249
+ .prepare(`SELECT "table" AS child, parent, fkid, count(*) AS n FROM pragma_foreign_key_check GROUP BY 1, 2, 3 ORDER BY 4 DESC`)
250
+ .all()
251
+ .map((group) => {
252
+ const child = String(group.child);
253
+ const column = database
254
+ .prepare(`SELECT "from" AS name FROM pragma_foreign_key_list(?) WHERE id = ? ORDER BY seq LIMIT 1`)
255
+ .get(child, Number(group.fkid))?.name;
256
+ let missing = [];
257
+ try {
258
+ missing = database
259
+ .prepare(`SELECT DISTINCT "${String(column)}" AS id FROM "${child}" WHERE rowid IN ` +
260
+ `(SELECT rowid FROM pragma_foreign_key_check(?) WHERE fkid = ?) ORDER BY 1 LIMIT 20`)
261
+ .all(child, Number(group.fkid))
262
+ .map((row) => (typeof row.id === "number" ? row.id : String(row.id)));
263
+ }
264
+ catch { }
265
+ return { table: child, parent: String(group.parent), rows: Number(group.n), missing };
266
+ });
267
+ /** Mail accounts whose emails other rows still point at: the ones a mail sync should list again. */
268
+ const mailToFetch = (database) => {
269
+ try {
270
+ return database
271
+ .prepare(`SELECT a.external_id AS account, count(DISTINCT l.email_id) AS n FROM email_locations l
272
+ JOIN mailboxes b ON b.id = l.mailbox_id JOIN accounts a ON a.id = b.account_id
273
+ WHERE NOT EXISTS (SELECT 1 FROM emails e WHERE e.id = l.email_id) GROUP BY a.id ORDER BY a.external_id`)
274
+ .all()
275
+ .map((row) => ({ account: String(row.account), emails: Number(row.n) }));
276
+ }
277
+ catch {
278
+ return [];
279
+ }
280
+ };
281
+ /** The file and its log; the shared-memory file changes on every read, so it says nothing. */
282
+ const fingerprint = (path) => ["", "-wal"]
283
+ .map((suffix) => {
284
+ const stat = statSync(`${path}${suffix}`, { throwIfNoEntry: false });
285
+ return stat ? `${stat.size}:${stat.mtimeMs}` : "-";
286
+ })
287
+ .join("|");
288
+ const refuseInUse = (path) => {
289
+ const holders = storeHolders(path);
290
+ if (holders && holders.length > 0) {
291
+ const timers = activeTimers() ?? [];
292
+ throw new CliError("validation_error", `the store is open in ${holders.map(({ pid, name }) => `${name} (process ${pid})`).join(", ")} — stop it first` +
293
+ (timers.length ? `, and the timers: systemctl --user stop ${timers.join(" ")}` : ""), { reason: "store.in-use" });
294
+ }
295
+ };
296
+ /**
297
+ * Copies every readable row of a damaged store into a new file, rebuilds what is derived from them, checks the
298
+ * result, and puts it in place of the store. The damaged file is kept beside it and never written to.
299
+ */
300
+ export const recoverStore = async ({ path, command, version, sqlite3 = "sqlite3", now = Date.now, note = () => { }, copy, }) => {
301
+ const started = Date.now();
302
+ if (!existsSync(path))
303
+ throw new CliError("not_found", `no store at ${path}`);
304
+ const program = copy ? { program: sqlite3, version: "given", usable: true } : sqlite3Program(sqlite3);
305
+ if (!program.usable) {
306
+ throw new CliError("configuration_error", program.version
307
+ ? `sqlite3 ${program.version} cannot recover: the recovery needs ${SQLITE3_MINIMUM.join(".")} or newer, built with .recover`
308
+ : "the recovery needs the sqlite3 program with its .recover command: install it (apt install sqlite3, brew install sqlite)", { reason: "store.sqlite3-missing" });
309
+ }
310
+ refuseInUse(path);
311
+ const bytes = STORE_PARTS.reduce((sum, suffix) => sum + (statSync(`${path}${suffix}`, { throwIfNoEntry: false })?.size ?? 0), 0);
312
+ const { bavail, bsize } = statfsSync(dirname(path));
313
+ if (Number(bavail) * Number(bsize) < bytes * 1.2)
314
+ throw new CliError("validation_error", `the recovery needs about ${Math.ceil((bytes * 1.2) / 1e9)} GB free beside the store`);
315
+ const old = await openSqlite(path, { readOnly: true });
316
+ let rowsBefore;
317
+ try {
318
+ rowsBefore = tableRows(old.database);
319
+ }
320
+ finally {
321
+ old.database.close();
322
+ }
323
+ const before = fingerprint(path);
324
+ const stamp = new Date(now()).toISOString().replace(/[:.]/g, "-");
325
+ const target = `${path}.recovering-${stamp}`;
326
+ writeFileSync(target, "", { flag: "wx", mode: 0o600 });
327
+ const dropTarget = () => {
328
+ for (const suffix of [...STORE_PARTS, "-journal"])
329
+ rmSync(`${target}${suffix}`, { force: true });
330
+ };
331
+ note(`copying the readable rows into ${target}`);
332
+ let copied;
333
+ let copyErrors;
334
+ let read;
335
+ let opened;
336
+ try {
337
+ ;
338
+ ({ errors: copyErrors, read } = await (copy ?? ((from, to) => copyReadableRows(from, to, sqlite3)))(path, target));
339
+ copied = Date.now();
340
+ opened = await openSqlite(target);
341
+ }
342
+ catch (error) {
343
+ dropTarget();
344
+ throw error;
345
+ }
346
+ let rebuilt;
347
+ let kept;
348
+ let dangling;
349
+ let fetch;
350
+ try {
351
+ kept = tableRows(opened.database);
352
+ migrate(opened.database, { command, ...(version ? { version } : {}) });
353
+ rebuilt = rebuildDerived(opened.database, { now, note });
354
+ note("looking for rows that point at rows that were lost");
355
+ dangling = danglingRows(opened.database);
356
+ fetch = mailToFetch(opened.database);
357
+ opened.database.exec("PRAGMA wal_checkpoint(TRUNCATE)");
358
+ }
359
+ catch (error) {
360
+ opened.database.close();
361
+ dropTarget();
362
+ throw error;
363
+ }
364
+ opened.database.close();
365
+ const rebuiltAt = Date.now();
366
+ note("checking the recovered file");
367
+ const health = await storeHealth(target, { deep: true });
368
+ const checkedAt = Date.now();
369
+ if (!health.ok) {
370
+ throw new CliError("validation_error", `the recovered file did not pass its check, so the store was left as it was; the file is kept at ${target}`, { reason: "store.recovery-failed" });
371
+ }
372
+ refuseInUse(path);
373
+ if (fingerprint(path) !== before) {
374
+ throw new CliError("validation_error", `the store was written to during the recovery, so it was left as it was — stop every timer and run it again; ${target} can be deleted`, { reason: "store.changed-during-recovery" });
375
+ }
376
+ const keptAt = `${path}.before-recover-${stamp}`;
377
+ for (const suffix of STORE_PARTS)
378
+ if (existsSync(`${path}${suffix}`))
379
+ renameSync(`${path}${suffix}`, `${keptAt}${suffix}`);
380
+ for (const suffix of STORE_PARTS)
381
+ if (existsSync(`${target}${suffix}`))
382
+ renameSync(`${target}${suffix}`, `${path}${suffix}`);
383
+ const lost = Object.entries(rowsBefore)
384
+ .map(([table, before]) => ({ table, before, read: read[table] ?? 0, kept: kept[table] ?? 0 }))
385
+ .filter(({ before, read, kept }) => before === null || before > kept || read > kept);
386
+ const seconds = (from, to) => Math.round((to - from) / 100) / 10;
387
+ return {
388
+ path,
389
+ keptAt,
390
+ sqlite3: program.version,
391
+ lost,
392
+ dangling,
393
+ mailToFetch: fetch,
394
+ rebuilt,
395
+ copyErrors,
396
+ health,
397
+ seconds: {
398
+ copy: seconds(started, copied),
399
+ rebuild: seconds(copied, rebuiltAt),
400
+ check: seconds(rebuiltAt, checkedAt),
401
+ total: seconds(started, Date.now()),
402
+ },
403
+ };
404
+ };
@@ -1,4 +1,4 @@
1
- import { mkdirSync, statSync, writeFileSync } from "node:fs";
1
+ import { accessSync, constants, existsSync, mkdirSync, statSync, writeFileSync } from "node:fs";
2
2
  import { dirname } from "node:path";
3
3
  import { CliError } from "@wirecat/cli-base";
4
4
  import { DEFAULT_DECISION_POLICY } from "../decisions.js";
@@ -26,10 +26,17 @@ const NOT_WRITABLE = new Set(["EACCES", "EPERM", "EROFS"]);
26
26
  const createWritable = (file) => {
27
27
  try {
28
28
  mkdirSync(dirname(file), { recursive: true, mode: 0o700 });
29
+ // Only a missing file: on Linux, closing any descriptor of a file drops every POSIX lock this process holds on it,
30
+ // so opening the store a second time would take SQLite's locks from the connection already open (BUG-557).
29
31
  // SQLite gives its sidecar files the database's permissions.
30
- writeFileSync(file, "", { flag: "a", mode: 0o600 });
32
+ if (existsSync(file))
33
+ accessSync(file, constants.W_OK);
34
+ else
35
+ writeFileSync(file, "", { flag: "wx", mode: 0o600 });
31
36
  }
32
37
  catch (error) {
38
+ if (error.code === "EEXIST")
39
+ return;
33
40
  if (!NOT_WRITABLE.has(error.code ?? ""))
34
41
  throw error;
35
42
  // The errno's own sentence names the path, which holds the owner's home folder.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wirecat/cli-core",
3
- "version": "0.598.0",
3
+ "version": "0.599.0",
4
4
  "description": "The messenger-neutral half of a messaging CLI: domain model, message locators, rendering, name resolution, the SQLite seam, the command skeleton and shared read commands, the send guard, run records and the message store",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Viacheslav Ptsarev",
package/parity.json CHANGED
@@ -4825,6 +4825,12 @@
4825
4825
  "store rebuild": {
4826
4826
  "in": "all"
4827
4827
  },
4828
+ "store recover": {
4829
+ "in": "all"
4830
+ },
4831
+ "store recover run": {
4832
+ "in": "all"
4833
+ },
4828
4834
  "store repair": {
4829
4835
  "in": "all",
4830
4836
  "options": {