@andespindola/brainlink 0.1.0-beta.12 → 0.1.0-beta.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -57,7 +57,7 @@ LLMs do not have infinite context. Brainlink gives agents an external memory lay
57
57
  6. Brainlink returns compact, source-backed context.
58
58
 
59
59
  Markdown is the source of truth. `.brainlink/brainlink.db` is only a rebuildable index.
60
- Brainlink now keeps an automatic rollback snapshot at `.brainlink/brainlink.db.backup`. If the main SQLite file is corrupted, Brainlink automatically restores from snapshot (or recreates a clean index when no snapshot exists).
60
+ Brainlink now keeps an automatic rollback snapshot at `.brainlink/brainlink.db.backup` plus rotating snapshots in `.brainlink/brainlink.db.backup.snapshots/`. If the main SQLite file is corrupted, Brainlink automatically restores the newest valid snapshot (or recreates a clean index when no snapshot exists).
61
61
  After each index run, Brainlink also writes private encrypted search packs at `.brainlink/search-packs/*.blpk`. If SQLite is unavailable, search falls back to these packs automatically.
62
62
  Pack decryption uses a Brainlink key from `$BRAINLINK_HOME/keys` or from `BRAINLINK_SEARCH_PACK_KEY` when explicitly configured.
63
63
 
@@ -1,5 +1,5 @@
1
1
  import { stat } from 'node:fs/promises';
2
- import { existsSync } from 'node:fs';
2
+ import { existsSync, readdirSync } from 'node:fs';
3
3
  import { performance } from 'node:perf_hooks';
4
4
  import { join } from 'node:path';
5
5
  import { validateGraph, getBrokenLinks, getOrphanNodes, getVaultStats } from '../domain/graph-analysis.js';
@@ -97,7 +97,11 @@ export const doctorVault = async (vaultPath) => {
97
97
  const graph = await getGraphSummary(absoluteVaultPath);
98
98
  const validation = validateGraph(graph);
99
99
  const backupPath = join(absoluteVaultPath, '.brainlink', 'brainlink.db.backup');
100
+ const snapshotDirectory = join(absoluteVaultPath, '.brainlink', 'brainlink.db.backup.snapshots');
100
101
  const hasBackup = existsSync(backupPath);
102
+ const snapshotCount = existsSync(snapshotDirectory)
103
+ ? readdirSync(snapshotDirectory).filter((name) => name.endsWith('.db')).length
104
+ : 0;
101
105
  const backupReady = graph.nodes.length === 0 || hasBackup;
102
106
  const checks = [
103
107
  createCheck('vault', true, `Vault ready at ${absoluteVaultPath}`),
@@ -105,7 +109,9 @@ export const doctorVault = async (vaultPath) => {
105
109
  createCheck('index', graph.nodes.length > 0, `${graph.nodes.length} indexed documents found`),
106
110
  createCheck('broken-links', validation.brokenLinks.length === 0, `${validation.brokenLinks.length} broken links found`),
107
111
  createCheck('index-backup', backupReady, backupReady
108
- ? (hasBackup ? 'SQLite recovery snapshot is available' : 'No index yet. Snapshot will be created after first indexing run')
112
+ ? (hasBackup
113
+ ? `SQLite recovery snapshot is available (${snapshotCount} rotating snapshots)`
114
+ : 'No index yet. Snapshot will be created after first indexing run')
109
115
  : 'Recovery snapshot missing. Run blink index to create a rollback snapshot')
110
116
  ];
111
117
  const recommendations = files.length === 0 && graph.nodes.length === 0
@@ -1,6 +1,6 @@
1
1
  import Database from 'better-sqlite3';
2
- import { copyFileSync, existsSync, mkdirSync, renameSync, rmSync, unlinkSync } from 'node:fs';
3
- import { dirname } from 'node:path';
2
+ import { copyFileSync, existsSync, mkdirSync, readdirSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
3
+ import { basename, dirname, join } from 'node:path';
4
4
  const sqliteCorruptionHints = [
5
5
  'database disk image is malformed',
6
6
  'file is not a database',
@@ -8,6 +8,7 @@ const sqliteCorruptionHints = [
8
8
  'malformed database schema',
9
9
  'sqlite quick_check failed'
10
10
  ];
11
+ const maxSnapshotFiles = 24;
11
12
  const normalizeMessage = (error) => error instanceof Error ? error.message.toLowerCase() : String(error).toLowerCase();
12
13
  const isSqliteCorruptionError = (error) => sqliteCorruptionHints.some((hint) => normalizeMessage(error).includes(hint));
13
14
  const safeUnlink = (path) => {
@@ -39,15 +40,19 @@ const archiveCorruptedDatabase = (databasePath) => {
39
40
  const archivedPath = `${databasePath}.corrupt-${Date.now()}`;
40
41
  renameSync(databasePath, archivedPath);
41
42
  };
42
- const restoreFromBackupOrReset = (databasePath, backupPath) => {
43
- clearSidecars(databasePath);
44
- archiveCorruptedDatabase(databasePath);
45
- if (existsSync(backupPath)) {
46
- copyFileSync(backupPath, databasePath);
47
- clearSidecars(databasePath);
43
+ const snapshotDirectoryPath = (backupPath) => join(dirname(backupPath), `${basename(backupPath)}.snapshots`);
44
+ const snapshotFileName = () => `snapshot-${new Date().toISOString().replace(/[:.]/g, '-')}.db`;
45
+ const cleanupSnapshotOverflow = (backupPath) => {
46
+ const directory = snapshotDirectoryPath(backupPath);
47
+ if (!existsSync(directory)) {
48
48
  return;
49
49
  }
50
- rmSync(databasePath, { force: true });
50
+ const snapshots = readdirSync(directory)
51
+ .filter((name) => name.endsWith('.db'))
52
+ .sort((left, right) => right.localeCompare(left));
53
+ snapshots.slice(maxSnapshotFiles).forEach((name) => {
54
+ rmSync(join(directory, name), { force: true });
55
+ });
51
56
  };
52
57
  const openCheckedDatabase = (databasePath) => {
53
58
  const database = new Database(databasePath);
@@ -60,13 +65,88 @@ const openCheckedDatabase = (databasePath) => {
60
65
  }
61
66
  return database;
62
67
  };
68
+ const isValidDatabaseSnapshot = (path) => {
69
+ if (!existsSync(path)) {
70
+ return false;
71
+ }
72
+ try {
73
+ const size = statSync(path).size;
74
+ if (size <= 0) {
75
+ return false;
76
+ }
77
+ }
78
+ catch {
79
+ return false;
80
+ }
81
+ try {
82
+ const database = new Database(path);
83
+ try {
84
+ assertQuickCheck(database);
85
+ return true;
86
+ }
87
+ finally {
88
+ database.close();
89
+ }
90
+ }
91
+ catch {
92
+ return false;
93
+ }
94
+ };
95
+ const candidateBackupFiles = (backupPath) => {
96
+ const directory = snapshotDirectoryPath(backupPath);
97
+ const snapshots = existsSync(directory)
98
+ ? readdirSync(directory)
99
+ .filter((name) => name.endsWith('.db'))
100
+ .sort((left, right) => right.localeCompare(left))
101
+ .map((name) => join(directory, name))
102
+ : [];
103
+ return [backupPath, ...snapshots];
104
+ };
105
+ const ensureSnapshotDirectory = (backupPath) => {
106
+ mkdirSync(snapshotDirectoryPath(backupPath), { recursive: true, mode: 0o700 });
107
+ };
108
+ const writeRecoveryMarker = (backupPath, restoredFrom) => {
109
+ const markerPath = join(dirname(backupPath), 'recovery-last-restore.json');
110
+ const payload = {
111
+ restoredAt: new Date().toISOString(),
112
+ restoredFrom
113
+ };
114
+ writeFileSync(markerPath, `${JSON.stringify(payload, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 });
115
+ };
116
+ const restoreFromBackupOrReset = (databasePath, backupPath) => {
117
+ clearSidecars(databasePath);
118
+ archiveCorruptedDatabase(databasePath);
119
+ for (const candidate of candidateBackupFiles(backupPath)) {
120
+ if (!isValidDatabaseSnapshot(candidate)) {
121
+ continue;
122
+ }
123
+ copyFileSync(candidate, databasePath);
124
+ clearSidecars(databasePath);
125
+ if (isValidDatabaseSnapshot(databasePath)) {
126
+ writeRecoveryMarker(backupPath, candidate);
127
+ return;
128
+ }
129
+ }
130
+ rmSync(databasePath, { force: true });
131
+ };
63
132
  export const createRecoverySnapshot = (database, backupPath) => {
64
133
  const backupDirectory = dirname(backupPath);
65
134
  const tempBackupPath = `${backupPath}.tmp`;
135
+ const snapshotDirectory = snapshotDirectoryPath(backupPath);
136
+ const snapshotPath = join(snapshotDirectory, snapshotFileName());
66
137
  mkdirSync(backupDirectory, { recursive: true });
138
+ ensureSnapshotDirectory(backupPath);
67
139
  rmSync(tempBackupPath, { force: true });
140
+ try {
141
+ database.pragma('wal_checkpoint(PASSIVE)');
142
+ }
143
+ catch {
144
+ // Checkpoint is best-effort.
145
+ }
68
146
  database.prepare('VACUUM INTO ?').run(tempBackupPath);
69
147
  renameSync(tempBackupPath, backupPath);
148
+ copyFileSync(backupPath, snapshotPath);
149
+ cleanupSnapshotOverflow(backupPath);
70
150
  };
71
151
  export const openDatabaseWithRecovery = (databasePath, backupPath) => {
72
152
  mkdirSync(dirname(databasePath), { recursive: true });
@@ -634,7 +634,7 @@ GET /api/validate
634
634
 
635
635
  The HTTP API is read-only. Use the CLI for writes and indexing.
636
636
 
637
- Brainlink maintains an automatic SQLite rollback snapshot at `.brainlink/brainlink.db.backup`. When `.brainlink/brainlink.db` is corrupted, Brainlink restores from snapshot automatically or recreates a clean index if no snapshot exists yet.
637
+ Brainlink maintains an automatic SQLite rollback snapshot at `.brainlink/brainlink.db.backup` and rotating snapshots in `.brainlink/brainlink.db.backup.snapshots/`. When `.brainlink/brainlink.db` is corrupted, Brainlink restores the newest valid snapshot automatically or recreates a clean index if no snapshot exists yet.
638
638
  Indexing also writes private encrypted search packs at `.brainlink/search-packs/*.blpk`; when SQLite cannot be opened, Brainlink falls back to pack-based search automatically.
639
639
  Pack decryption keys are resolved from `$BRAINLINK_HOME/keys` (or `BRAINLINK_SEARCH_PACK_KEY` when explicitly set).
640
640
 
@@ -300,7 +300,7 @@ Markdown keeps the system portable, inspectable, Git-friendly, and compatible wi
300
300
 
301
301
  SQLite gives fast local search, local vector storage and rebuildable retrieval without forcing users to run external infrastructure.
302
302
  Hybrid retrieval also uses a short-lived in-memory cache keyed by vault/query/agent and invalidated by index file mtime to reduce repeated query latency.
303
- Brainlink also writes a local rollback snapshot (`.brainlink/brainlink.db.backup`) after successful indexing. On corruption detection (`quick_check`/SQLite malformed errors), Brainlink restores from snapshot automatically before reopening the index.
303
+ Brainlink also writes a local rollback snapshot (`.brainlink/brainlink.db.backup`) plus rotating point-in-time snapshots (`.brainlink/brainlink.db.backup.snapshots/`) after successful indexing. On corruption detection (`quick_check`/SQLite malformed errors), Brainlink restores the newest valid snapshot automatically before reopening the index.
304
304
  Indexing additionally exports private encrypted pack files (`.brainlink/search-packs/*.blpk`) from indexed chunks. Search falls back to these packs when SQLite is unavailable, preserving retrieval continuity in degraded mode.
305
305
  Pack encryption keys are resolved from `$BRAINLINK_HOME/keys` or from `BRAINLINK_SEARCH_PACK_KEY` when configured.
306
306
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@andespindola/brainlink",
3
- "version": "0.1.0-beta.12",
3
+ "version": "0.1.0-beta.13",
4
4
  "description": "Local-first knowledge memory for agents with Markdown, backlinks, indexing and context retrieval.",
5
5
  "type": "module",
6
6
  "license": "MIT",