@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,170 @@
1
+ import { existsSync, statSync } from "node:fs";
2
+ import { openSqlite } from "./sqlite/open.js";
3
+ export const QUICK_CHECK_LIMIT = 10_000;
4
+ /** What `doctor` checks by default, said where it answers. */
5
+ export const QUICK_HEALTH_CHECKS = "the file header, its size against its page count, and the schema's own pages";
6
+ const FTS5 = /^\s*CREATE\s+VIRTUAL\s+TABLE\s+\S+\s+USING\s+fts5\s*\(/i;
7
+ export const fts5Tables = (database) => database
8
+ .prepare("SELECT name, sql FROM sqlite_master WHERE type = 'table' AND sql LIKE 'CREATE VIRTUAL TABLE%' ORDER BY name")
9
+ .all()
10
+ .filter((row) => FTS5.test(String(row.sql)))
11
+ .map((row) => {
12
+ const sql = String(row.sql);
13
+ return { name: String(row.name), sql, external: /content\s*=\s*'[^']+'/i.test(sql) };
14
+ });
15
+ const messageOf = (error) => (error instanceof Error ? error.message : String(error));
16
+ const bytesOf = (path) => statSync(path, { throwIfNoEntry: false })?.size ?? 0;
17
+ /** Groups the lines by the table whose tree they name: `Tree <root page> …` or `NULL value in <table>.<column>`. */
18
+ export const damagedTables = (database, lines) => {
19
+ const roots = new Map(database
20
+ .prepare("SELECT rootpage, tbl_name FROM sqlite_master WHERE rootpage > 0")
21
+ .all()
22
+ .map((row) => [Number(row.rootpage), String(row.tbl_name)]));
23
+ const owners = new Map(database
24
+ .prepare("SELECT name, tbl_name FROM sqlite_master WHERE type = 'index'")
25
+ .all()
26
+ .map((row) => [String(row.name), String(row.tbl_name)]));
27
+ const virtual = database
28
+ .prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND sql LIKE 'CREATE VIRTUAL TABLE%'")
29
+ .all()
30
+ .map((row) => String(row.name));
31
+ // A search index's rows live in its shadow tables: `email_words_data` is damage to `email_words`.
32
+ const ownerOf = (table) => table === null ? null : (virtual.find((name) => table.startsWith(`${name}_`)) ?? table);
33
+ const tables = new Map();
34
+ for (const raw of lines) {
35
+ const line = raw.replace(/^\*\*\* in database \w+ \*\*\*\s*/, "");
36
+ const tree = /^Tree (\d+) /.exec(line);
37
+ const column = /value in (\w+)\.\w+/.exec(line) ?? /^table (\w+): /.exec(line);
38
+ const index = /index (\w+)/.exec(line)?.[1];
39
+ const table = ownerOf(tree
40
+ ? (roots.get(Number(tree[1])) ?? null)
41
+ : column
42
+ ? (column[1] ?? null)
43
+ : index
44
+ ? (owners.get(index) ?? null)
45
+ : null);
46
+ const known = tables.get(table);
47
+ if (known)
48
+ known.problems += 1;
49
+ else
50
+ tables.set(table, { table, problems: 1, sample: line.slice(0, 200) });
51
+ }
52
+ return [...tables.values()].sort((a, b) => b.problems - a.problems);
53
+ };
54
+ /** `rank = 0` checks the index's own structure, the only check a contentless index has; it writes nothing. */
55
+ export const searchIndexHealth = (database) => fts5Tables(database).map(({ name }) => {
56
+ try {
57
+ database.exec("BEGIN");
58
+ try {
59
+ database.exec(`INSERT INTO "${name}" ("${name}", rank) VALUES ('integrity-check', 0)`);
60
+ }
61
+ finally {
62
+ database.exec("ROLLBACK");
63
+ }
64
+ return { name, ok: true };
65
+ }
66
+ catch (error) {
67
+ return { name, ok: false, error: messageOf(error) };
68
+ }
69
+ });
70
+ const pragmaLines = (database, sql) => database
71
+ .prepare(sql)
72
+ .all()
73
+ .flatMap((row) => String(Object.values(row)[0]).split("\n"));
74
+ /**
75
+ * SQLite stops the whole check at a page it cannot read and returns none of what it found. Then each table is
76
+ * checked alone, so the answer still names every damaged one.
77
+ */
78
+ const checkLines = (database) => {
79
+ try {
80
+ return pragmaLines(database, `PRAGMA quick_check(${QUICK_CHECK_LIMIT})`);
81
+ }
82
+ catch {
83
+ const tables = database
84
+ .prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND rootpage > 0 ORDER BY name")
85
+ .all()
86
+ .map((row) => String(row.name));
87
+ return tables.flatMap((table) => {
88
+ try {
89
+ return pragmaLines(database, `PRAGMA quick_check("${table}")`).filter((line) => line !== "ok");
90
+ }
91
+ catch (error) {
92
+ return [`table ${table}: ${messageOf(error)}`];
93
+ }
94
+ });
95
+ }
96
+ };
97
+ const quickHealth = (database, path) => {
98
+ const pragma = (name) => Number(Object.values(database.prepare(`PRAGMA ${name}`).get() ?? {})[0] ?? 0);
99
+ const file = {
100
+ bytes: bytesOf(path),
101
+ pages: pragma("page_count"),
102
+ pageSize: pragma("page_size"),
103
+ freePages: pragma("freelist_count"),
104
+ };
105
+ const schema = database
106
+ .prepare("PRAGMA quick_check('sqlite_schema')")
107
+ .all()
108
+ .map((row) => String(Object.values(row)[0]));
109
+ // Pages still in the write-ahead log are not in the file yet, so only a file with none of them must match.
110
+ const walFrames = existsSync(`${path}-wal`) && bytesOf(`${path}-wal`) > 0;
111
+ const sized = walFrames || file.bytes === file.pages * file.pageSize;
112
+ const problems = [
113
+ ...(schema.length === 1 && schema[0] === "ok" ? [] : schema),
114
+ ...(sized ? [] : [`the file holds ${file.bytes} bytes, its header says ${file.pages * file.pageSize}`]),
115
+ ];
116
+ return { file, problems };
117
+ };
118
+ /**
119
+ * The store's health without changing it. `quick` takes milliseconds at any size; `deep` reads every page
120
+ * (about 30 s for 6 GB) and checks every search index, under a transaction it rolls back.
121
+ */
122
+ export const storeHealth = async (path, { deep = false } = {}) => {
123
+ const started = Date.now();
124
+ const checked = deep ? "deep" : "quick";
125
+ const done = (health) => ({
126
+ path,
127
+ checked,
128
+ ...health,
129
+ ms: Date.now() - started,
130
+ });
131
+ let database;
132
+ try {
133
+ database = (await openSqlite(path, { readOnly: !deep })).database;
134
+ }
135
+ catch (error) {
136
+ return done({ ok: false, error: messageOf(error) });
137
+ }
138
+ try {
139
+ const { file, problems } = quickHealth(database, path);
140
+ if (!deep) {
141
+ return done({
142
+ ok: problems.length === 0,
143
+ file,
144
+ ...(problems.length
145
+ ? {
146
+ problems: problems.length,
147
+ damagedTables: [{ table: null, problems: problems.length, sample: problems[0] }],
148
+ }
149
+ : {}),
150
+ });
151
+ }
152
+ database.exec("PRAGMA busy_timeout = 30000");
153
+ const lines = checkLines(database);
154
+ const pagesOk = lines.length === 1 && lines[0] === "ok";
155
+ const indexes = searchIndexHealth(database);
156
+ return done({
157
+ ok: pagesOk && problems.length === 0 && indexes.every((index) => index.ok),
158
+ file,
159
+ problems: pagesOk ? 0 : lines.length,
160
+ damagedTables: pagesOk ? [] : damagedTables(database, lines),
161
+ searchIndexes: indexes,
162
+ });
163
+ }
164
+ catch (error) {
165
+ return done({ ok: false, error: messageOf(error) });
166
+ }
167
+ finally {
168
+ database.close();
169
+ }
170
+ };
@@ -9,12 +9,14 @@ export type { FindingEvidence, FindingOutcome, MeetingFactFinding, MeetingFindin
9
9
  export { PAYMENT_DIRECTIONS, PAYMENT_STATES, type Payment, paymentTitle, validatePayment } from "../meetings/index.js";
10
10
  export { CLOSED_STATES, isClosedState, PRIORITY_LABELS, type PriorityLabel, priorityLabel, priorityOf, priorityOption, TASK_STATES, type TaskState, } from "../tasks/index.js";
11
11
  export type { AccountMail, AccountMeetings, AccountStore } from "./account-store.js";
12
+ export { type DamageExplanation, explainDamage } from "./damage.js";
12
13
  export { assertMayDecide, DEFAULT_DECISION_POLICY, type Decider, type DecisionPolicy, isOwnerOnlyKind, mayDecide, type Proposer, type Refusal, } from "./decisions.js";
13
14
  export type { DocumentTransferOptions, DocumentTransferPreview, DocumentTransferReceipt, } from "./document-transfer/index.js";
14
15
  export { applyDocumentTransfer, previewDocumentTransfer } from "./document-transfer/index.js";
15
16
  export type { CacheDatabase, CacheStatement, OpenDatabase, SqlValue } from "./driver.js";
16
17
  export { PRAGMAS } from "./driver.js";
17
18
  export { EVIDENCE_FINDINGS_KIND, type EvidenceFindingsBatch, type EvidenceFindingsDecider, type EvidenceFindingsReceipt, type EvidenceFindingsStore, validateEvidenceFindings, } from "./evidence-findings.js";
19
+ export { type DamagedTable, QUICK_HEALTH_CHECKS, type SearchIndexHealth, type StoreHealth, storeHealth, } from "./health.js";
18
20
  export { ORGANIZATIONS_QUERY, organization, PROJECTS_QUERY, project, REMINDERS_QUERY, reminder, } from "./knowledge-query.js";
19
21
  export { MAIL_FINDINGS_KIND, type MailFindingsBatch, type MailFindingsCheck, type MailFindingsStore, } from "./mail-findings.js";
20
22
  export type { MeetingFindingsBatch, MeetingFindingsStore } from "./meeting-findings.js";
@@ -3,10 +3,12 @@ export { createTagRedirectIndex } from "../domain/tag-redirects.js";
3
3
  export { note } from "../entities/note.js";
4
4
  export { PAYMENT_DIRECTIONS, PAYMENT_STATES, paymentTitle, validatePayment } from "../meetings/index.js";
5
5
  export { CLOSED_STATES, isClosedState, PRIORITY_LABELS, priorityLabel, priorityOf, priorityOption, TASK_STATES, } from "../tasks/index.js";
6
+ export { explainDamage } from "./damage.js";
6
7
  export { assertMayDecide, DEFAULT_DECISION_POLICY, isOwnerOnlyKind, mayDecide, } from "./decisions.js";
7
8
  export { applyDocumentTransfer, previewDocumentTransfer } from "./document-transfer/index.js";
8
9
  export { PRAGMAS } from "./driver.js";
9
10
  export { EVIDENCE_FINDINGS_KIND, validateEvidenceFindings, } from "./evidence-findings.js";
11
+ export { QUICK_HEALTH_CHECKS, storeHealth, } from "./health.js";
10
12
  export { ORGANIZATIONS_QUERY, organization, PROJECTS_QUERY, project, REMINDERS_QUERY, reminder, } from "./knowledge-query.js";
11
13
  export { MAIL_FINDINGS_KIND, } from "./mail-findings.js";
12
14
  export { MIGRATIONS, migrate } from "./migrations.js";
@@ -0,0 +1,103 @@
1
+ import type { CacheDatabase } from "./driver.js";
2
+ import { type StoreHealth } from "./health.js";
3
+ /** `contentless_delete`, which every word index uses, came in SQLite 3.43. */
4
+ export declare const SQLITE3_MINIMUM: readonly [3, 43, 0];
5
+ export declare const sqlite3Program: (program?: string) => {
6
+ program: string;
7
+ version: string | null;
8
+ usable: boolean;
9
+ };
10
+ /** The WireCat timers still scheduled: each may open the store while it is being recovered. Linux only. */
11
+ export declare const activeTimers: () => string[] | undefined;
12
+ /** Processes that have any of the store's files open, by PID and program name, never their arguments. */
13
+ export declare const storeHolders: (path: string) => {
14
+ pid: number;
15
+ name: string;
16
+ }[] | undefined;
17
+ /** Rows per table that holds data; `null` where the table cannot be counted. Search shadow tables are left out. */
18
+ export declare const tableRows: (database: CacheDatabase) => Record<string, number | null>;
19
+ /**
20
+ * Copies every row SQLite can still read into a new file with the `sqlite3` program's `.recover`: node:sqlite has
21
+ * no recovery API. The damaged file is opened read-only. Rows that fit no table go to a table the rebuild counts
22
+ * and drops; a row that breaks its table's constraints (a NULL where none may be) is skipped. Free pages are not
23
+ * read: what they hold was deleted, and must not come back.
24
+ */
25
+ export declare const copyReadableRows: (path: string, target: string, program?: string) => Promise<{
26
+ errors: string[];
27
+ read: Record<string, number>;
28
+ }>;
29
+ export interface DerivedRebuild {
30
+ lostAndFound: number;
31
+ searchIndexes: string[];
32
+ /** Indexes recreated empty that this build has no recipe for: they fill as the tools that own them write. */
33
+ unfilled: string[];
34
+ mentions: number;
35
+ vectorModels: string[];
36
+ /** Per model: pieces of text a search by meaning computes a vector for when it first needs one. */
37
+ chunksWithoutVector: Record<string, number>;
38
+ }
39
+ /**
40
+ * Drops every search index and vector index and makes it again empty, then fills it from the data. A recovered
41
+ * index holds whatever pages survived, which no check can tell from a whole one; the data they come from can.
42
+ */
43
+ export declare const rebuildDerived: (database: CacheDatabase, { now, note }?: {
44
+ now?: () => number;
45
+ note?: (text: string) => void;
46
+ }) => DerivedRebuild;
47
+ export interface DanglingRows {
48
+ table: string;
49
+ parent: string;
50
+ rows: number;
51
+ /** Up to 20 keys of the parent rows they point at, which the store no longer holds. */
52
+ missing: (number | string)[];
53
+ }
54
+ /** Rows that point at a row the store does not hold: after a recovery, mostly rows that could not be read. */
55
+ export declare const danglingRows: (database: CacheDatabase) => DanglingRows[];
56
+ export interface LostRows {
57
+ table: string;
58
+ before: number | null;
59
+ read: number;
60
+ kept: number;
61
+ }
62
+ export interface Recovery {
63
+ path: string;
64
+ keptAt: string;
65
+ sqlite3: string;
66
+ /**
67
+ * Tables that lost rows or may have: `before` is null where the damaged file could not count them, `read` is what
68
+ * `.recover` found, `kept` what fit the table; rows on pages it could not read at all are in none of them.
69
+ */
70
+ lost: LostRows[];
71
+ dangling: DanglingRows[];
72
+ mailToFetch: {
73
+ account: string;
74
+ emails: number;
75
+ }[];
76
+ rebuilt: DerivedRebuild;
77
+ copyErrors: string[];
78
+ health: StoreHealth;
79
+ seconds: {
80
+ copy: number;
81
+ rebuild: number;
82
+ check: number;
83
+ total: number;
84
+ };
85
+ }
86
+ export interface RecoverOptions {
87
+ path: string;
88
+ command: string;
89
+ version?: string;
90
+ sqlite3?: string;
91
+ now?: () => number;
92
+ note?: (text: string) => void;
93
+ /** Copies the readable rows into the new file; `copyReadableRows` unless a test gives its own. */
94
+ copy?: (path: string, target: string) => Promise<{
95
+ errors: string[];
96
+ read: Record<string, number>;
97
+ }>;
98
+ }
99
+ /**
100
+ * Copies every readable row of a damaged store into a new file, rebuilds what is derived from them, checks the
101
+ * result, and puts it in place of the store. The damaged file is kept beside it and never written to.
102
+ */
103
+ export declare const recoverStore: ({ path, command, version, sqlite3, now, note, copy, }: RecoverOptions) => Promise<Recovery>;