@lotargo/memory_plugin 1.3.2 → 1.4.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.
@@ -1,8 +1,11 @@
1
1
  import { DatabaseSync } from "node:sqlite";
2
2
  import { join } from "path";
3
3
  import { existsSync, mkdirSync } from "fs";
4
- import { MEMORY_DIR, ensureDir } from "../memory.js";
4
+ import { MEMORY_DIR } from "../memory.js";
5
5
  import { runMigrations } from "./migrations.js";
6
+ import { getConfig } from "../config/config_manager.js";
7
+ import { loadSecrets } from "../config/auth_store.js";
8
+ import { createClient } from "@libsql/client";
6
9
 
7
10
  let dbInstance = null;
8
11
 
@@ -11,28 +14,192 @@ export const BLOBS_DIR = join(STORAGE_DIR, "blobs");
11
14
  export const MODELS_DIR = join(STORAGE_DIR, "models");
12
15
  export const DB_PATH = join(STORAGE_DIR, "memory.sqlite");
13
16
 
14
- export function getDatabase(customPath = null) {
15
- if (dbInstance && !customPath) {
17
+ class DatabaseWrapper {
18
+ constructor(localDb, cloudClient, mode, failoverClient = null) {
19
+ this.localDb = localDb;
20
+ this.cloudClient = cloudClient;
21
+ this.mode = mode;
22
+ this.failoverClient = failoverClient;
23
+ this.usingFailover = false;
24
+ this.consecutiveFailures = 0;
25
+ }
26
+
27
+ async runWithRetry(fn) {
28
+ let attempts = 0;
29
+ const maxAttempts = 3;
30
+ const timeoutMs = 10000;
31
+
32
+ while (attempts < maxAttempts) {
33
+ attempts++;
34
+ const controller = new AbortController();
35
+ const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
36
+
37
+ try {
38
+ const client = (this.usingFailover && this.failoverClient) ? this.failoverClient : this.cloudClient;
39
+ const result = await Promise.race([
40
+ fn(client),
41
+ new Promise((_, reject) => {
42
+ controller.signal.addEventListener("abort", () => {
43
+ reject(new Error("Database operation timed out after 10 seconds"));
44
+ });
45
+ })
46
+ ]);
47
+ clearTimeout(timeoutId);
48
+ // Successful operation, reset consecutive failures
49
+ this.consecutiveFailures = 0;
50
+ return result;
51
+ } catch (err) {
52
+ clearTimeout(timeoutId);
53
+ if (attempts >= maxAttempts) {
54
+ this.consecutiveFailures++;
55
+ if (this.consecutiveFailures >= 3 && this.failoverClient && !this.usingFailover) {
56
+ console.warn("[WARN] Turso is temporarily unreachable. Switching to LiteFS failover replica...");
57
+ this.usingFailover = true;
58
+ // Retry the operation on the failover client
59
+ return this.runWithRetry(fn);
60
+ }
61
+ throw err;
62
+ }
63
+ // Small delay before retrying (exponential backoff / fixed delay)
64
+ await new Promise((resolve) => setTimeout(resolve, 200));
65
+ }
66
+ }
67
+ }
68
+
69
+ async exec(sql) {
70
+ if (this.mode === "only-cloud" && (this.cloudClient || this.failoverClient)) {
71
+ const trimmed = sql.trim().replace(/;$/, "").toUpperCase();
72
+ if (trimmed === "BEGIN" || trimmed === "BEGIN IMMEDIATE" || trimmed === "COMMIT" || trimmed === "ROLLBACK") {
73
+ return;
74
+ }
75
+ return await this.runWithRetry(async (client) => {
76
+ return await client.executeMultiple(sql);
77
+ });
78
+ } else {
79
+ return this.localDb.exec(sql);
80
+ }
81
+ }
82
+
83
+ prepare(sql) {
84
+ const self = this;
85
+ return {
86
+ async run(...args) {
87
+ if (self.mode === "only-cloud" && (self.cloudClient || self.failoverClient)) {
88
+ const res = await self.runWithRetry(async (client) => {
89
+ return await client.execute({ sql, args });
90
+ });
91
+ return {
92
+ changes: res.rowsAffected || 0,
93
+ lastInsertRowid: res.lastInsertRowid !== undefined ? Number(res.lastInsertRowid) : undefined,
94
+ };
95
+ } else {
96
+ return self.localDb.prepare(sql).run(...args);
97
+ }
98
+ },
99
+ async get(...args) {
100
+ if (self.mode === "only-cloud" && (self.cloudClient || self.failoverClient)) {
101
+ const res = await self.runWithRetry(async (client) => {
102
+ return await client.execute({ sql, args });
103
+ });
104
+ return res.rows[0];
105
+ } else {
106
+ return self.localDb.prepare(sql).get(...args);
107
+ }
108
+ },
109
+ async all(...args) {
110
+ if (self.mode === "only-cloud" && (self.cloudClient || self.failoverClient)) {
111
+ const res = await self.runWithRetry(async (client) => {
112
+ return await client.execute({ sql, args });
113
+ });
114
+ return res.rows;
115
+ } else {
116
+ return self.localDb.prepare(sql).all(...args);
117
+ }
118
+ },
119
+ };
120
+ }
121
+
122
+ close() {
123
+ if (this.localDb) {
124
+ try {
125
+ this.localDb.close();
126
+ } catch (e) {}
127
+ this.localDb = null;
128
+ }
129
+ if (this.cloudClient) {
130
+ try {
131
+ this.cloudClient.close();
132
+ } catch (e) {}
133
+ this.cloudClient = null;
134
+ }
135
+ if (this.failoverClient) {
136
+ try {
137
+ this.failoverClient.close();
138
+ } catch (e) {}
139
+ this.failoverClient = null;
140
+ }
141
+ }
142
+ }
143
+
144
+ export async function getDatabase(customPath = null, forceMode = null) {
145
+ const config = getConfig();
146
+ const mode = forceMode || config.mode || "only-local";
147
+
148
+ if (dbInstance && !customPath && dbInstance.mode === mode) {
16
149
  return dbInstance;
17
150
  }
18
151
 
19
- const dbPath = customPath || DB_PATH;
20
- const parentDir = join(dbPath, "..");
21
- if (!existsSync(parentDir)) {
22
- mkdirSync(parentDir, { recursive: true });
152
+ let localDb = null;
153
+ if (mode !== "only-cloud") {
154
+ const dbPath = customPath || DB_PATH;
155
+ const parentDir = join(dbPath, "..");
156
+ if (!existsSync(parentDir)) {
157
+ mkdirSync(parentDir, { recursive: true });
158
+ }
159
+ localDb = new DatabaseSync(dbPath);
160
+ localDb.exec("PRAGMA foreign_keys = ON;");
161
+ localDb.exec("PRAGMA journal_mode = WAL;");
162
+ }
163
+
164
+ let cloudClient = null;
165
+ let failoverClient = null;
166
+ if (mode === "only-cloud" || mode === "hybrid-sync") {
167
+ const secrets = loadSecrets();
168
+ const tursoUrl = customPath && customPath.startsWith("libsql:") ? customPath : (secrets?.dbUrl || config.tursoUrl);
169
+ const failoverUrl = config.failoverUrl || "";
170
+ const token = secrets?.token;
171
+
172
+ if (tursoUrl) {
173
+ cloudClient = createClient({
174
+ url: tursoUrl,
175
+ authToken: token || undefined,
176
+ });
177
+ if (failoverUrl) {
178
+ failoverClient = createClient({
179
+ url: failoverUrl,
180
+ authToken: token || undefined,
181
+ });
182
+ }
183
+ // In hybrid-sync mode, ensure remote schema is also fully migrated and up to date
184
+ if (mode === "hybrid-sync") {
185
+ const cloudDbWrapper = new DatabaseWrapper(null, cloudClient, "only-cloud", failoverClient);
186
+ await runMigrations(cloudDbWrapper);
187
+ }
188
+ } else if (mode === "only-cloud") {
189
+ throw new Error("Turso URL is required for only-cloud mode. Please login first.");
190
+ }
23
191
  }
24
192
 
25
- const db = new DatabaseSync(dbPath);
26
- db.exec("PRAGMA foreign_keys = ON;");
27
- db.exec("PRAGMA journal_mode = WAL;");
193
+ const wrappedDb = new DatabaseWrapper(localDb, cloudClient, mode, failoverClient);
28
194
 
29
- runMigrations(db);
195
+ // Initialize/run migrations
196
+ await runMigrations(wrappedDb);
30
197
 
31
198
  if (!customPath) {
32
- dbInstance = db;
199
+ dbInstance = wrappedDb;
33
200
  }
34
201
 
35
- return db;
202
+ return wrappedDb;
36
203
  }
37
204
 
38
205
  export function closeDatabase() {
@@ -2,9 +2,9 @@ const MIGRATIONS = [
2
2
  {
3
3
  version: 1,
4
4
  name: "001_initial_rag_schema",
5
- up: (db) => {
5
+ up: async (db) => {
6
6
  // 1. Documents Table
7
- db.exec(`
7
+ await db.exec(`
8
8
  CREATE TABLE IF NOT EXISTS documents (
9
9
  id TEXT PRIMARY KEY,
10
10
  path TEXT UNIQUE NOT NULL,
@@ -19,7 +19,7 @@ const MIGRATIONS = [
19
19
  `);
20
20
 
21
21
  // 2. Sections Table
22
- db.exec(`
22
+ await db.exec(`
23
23
  CREATE TABLE IF NOT EXISTS sections (
24
24
  id TEXT PRIMARY KEY,
25
25
  doc_id TEXT NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
@@ -31,7 +31,7 @@ const MIGRATIONS = [
31
31
  `);
32
32
 
33
33
  // 3. Micro-Chunks Table
34
- db.exec(`
34
+ await db.exec(`
35
35
  CREATE TABLE IF NOT EXISTS micro_chunks (
36
36
  id TEXT PRIMARY KEY,
37
37
  section_id TEXT NOT NULL REFERENCES sections(id) ON DELETE CASCADE,
@@ -43,7 +43,7 @@ const MIGRATIONS = [
43
43
  `);
44
44
 
45
45
  // 4. Full-Text Search (BM25 Index via SQLite FTS5)
46
- db.exec(`
46
+ await db.exec(`
47
47
  CREATE VIRTUAL TABLE IF NOT EXISTS micro_chunks_fts USING fts5(
48
48
  id UNINDEXED,
49
49
  content,
@@ -52,7 +52,7 @@ const MIGRATIONS = [
52
52
  `);
53
53
 
54
54
  // 5. GraphRAG Lite Edges Table
55
- db.exec(`
55
+ await db.exec(`
56
56
  CREATE TABLE IF NOT EXISTS graph_edges (
57
57
  source_id TEXT NOT NULL,
58
58
  target_id TEXT NOT NULL,
@@ -65,15 +65,15 @@ const MIGRATIONS = [
65
65
  {
66
66
  version: 2,
67
67
  name: "002_agent_knowledge_graph",
68
- up: (db) => {
68
+ up: async (db) => {
69
69
  try {
70
- db.exec(`ALTER TABLE graph_edges ADD COLUMN metadata_json TEXT;`);
70
+ await db.exec(`ALTER TABLE graph_edges ADD COLUMN metadata_json TEXT;`);
71
71
  } catch (e) {}
72
72
  try {
73
- db.exec(`ALTER TABLE graph_edges ADD COLUMN created_at INTEGER;`);
73
+ await db.exec(`ALTER TABLE graph_edges ADD COLUMN created_at INTEGER;`);
74
74
  } catch (e) {}
75
75
 
76
- db.exec(`
76
+ await db.exec(`
77
77
  CREATE TABLE IF NOT EXISTS knowledge_links (
78
78
  id TEXT PRIMARY KEY,
79
79
  fact_key TEXT NOT NULL,
@@ -92,8 +92,8 @@ const MIGRATIONS = [
92
92
  {
93
93
  version: 3,
94
94
  name: "003_medium_chunks_hierarchy",
95
- up: (db) => {
96
- db.exec(`
95
+ up: async (db) => {
96
+ await db.exec(`
97
97
  CREATE TABLE IF NOT EXISTS medium_chunks (
98
98
  id TEXT PRIMARY KEY,
99
99
  section_id TEXT NOT NULL REFERENCES sections(id) ON DELETE CASCADE,
@@ -106,43 +106,72 @@ const MIGRATIONS = [
106
106
  `);
107
107
 
108
108
  try {
109
- db.exec(`ALTER TABLE micro_chunks ADD COLUMN medium_id TEXT REFERENCES medium_chunks(id) ON DELETE CASCADE;`);
109
+ await db.exec(`ALTER TABLE micro_chunks ADD COLUMN medium_id TEXT REFERENCES medium_chunks(id) ON DELETE CASCADE;`);
110
110
  } catch (e) {}
111
111
  },
112
112
  },
113
113
  ];
114
114
 
115
- export function runMigrations(db) {
116
- const versionRow = db.prepare("PRAGMA user_version;").get();
117
- const currentVersion = versionRow ? versionRow.user_version : 0;
115
+ export async function runMigrations(db) {
116
+ let currentVersion = 0;
117
+ try {
118
+ const row = await db.prepare("SELECT MAX(version) as v FROM schema_migrations;").get();
119
+ currentVersion = row ? row.v || 0 : 0;
120
+ } catch (e) {
121
+ try {
122
+ const versionRow = await db.prepare("PRAGMA user_version;").get();
123
+ currentVersion = versionRow ? (versionRow.user_version || 0) : 0;
124
+ } catch (e2) {}
125
+ }
126
+
127
+ // Ensure schema_migrations table exists for future
128
+ try {
129
+ await db.exec(`CREATE TABLE IF NOT EXISTS schema_migrations (version INTEGER PRIMARY KEY);`);
130
+ } catch (e) {}
131
+
132
+ // Create notebooks table if not exists
133
+ try {
134
+ await db.exec(`
135
+ CREATE TABLE IF NOT EXISTS notebooks (
136
+ key TEXT PRIMARY KEY,
137
+ content TEXT NOT NULL,
138
+ updated_at INTEGER NOT NULL
139
+ );
140
+ `);
141
+ } catch (e) {}
118
142
 
119
143
  for (const migration of MIGRATIONS) {
120
144
  if (migration.version > currentVersion) {
121
- db.exec("BEGIN IMMEDIATE;");
145
+ await db.exec("BEGIN;");
122
146
  try {
123
- migration.up(db);
124
- db.exec("COMMIT;");
125
- db.exec(`PRAGMA user_version = ${migration.version};`);
147
+ await migration.up(db);
148
+ await db.prepare("INSERT INTO schema_migrations (version) VALUES (?);").run(migration.version);
149
+ await db.exec("COMMIT;");
150
+ try {
151
+ await db.exec(`PRAGMA user_version = ${migration.version};`);
152
+ } catch (e) {}
126
153
  } catch (err) {
127
- db.exec("ROLLBACK;");
154
+ try {
155
+ await db.exec("ROLLBACK;");
156
+ } catch (e) {}
128
157
  throw new Error(`Migration ${migration.name} failed: ${err.message}`);
129
158
  }
130
159
  }
131
160
  }
132
161
 
133
162
  // Defensive table & column check for medium_chunks hierarchy
134
- db.exec(`
135
- CREATE TABLE IF NOT EXISTS medium_chunks (
136
- id TEXT PRIMARY KEY,
137
- section_id TEXT NOT NULL REFERENCES sections(id) ON DELETE CASCADE,
138
- doc_id TEXT NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
139
- content TEXT NOT NULL,
140
- block_type TEXT NOT NULL,
141
- token_count INTEGER NOT NULL,
142
- created_at INTEGER
143
- );
144
- `);
145
163
  try {
146
- db.exec(`ALTER TABLE micro_chunks ADD COLUMN medium_id TEXT REFERENCES medium_chunks(id) ON DELETE CASCADE;`);
164
+ await db.exec(`
165
+ CREATE TABLE IF NOT EXISTS medium_chunks (
166
+ id TEXT PRIMARY KEY,
167
+ section_id TEXT NOT NULL REFERENCES sections(id) ON DELETE CASCADE,
168
+ doc_id TEXT NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
169
+ content TEXT NOT NULL,
170
+ block_type TEXT NOT NULL,
171
+ token_count INTEGER NOT NULL,
172
+ created_at INTEGER
173
+ );
174
+ `);
175
+ await db.exec(`ALTER TABLE micro_chunks ADD COLUMN medium_id TEXT REFERENCES medium_chunks(id) ON DELETE CASCADE;`);
147
176
  } catch (e) {}
148
177
  }
@@ -0,0 +1,211 @@
1
+ let isSyncing = false;
2
+
3
+ async function processSyncTask(db, task) {
4
+ if (task.action === "write_memory") {
5
+ await db.cloudClient.execute({
6
+ sql: `
7
+ INSERT INTO notebooks (key, content, updated_at)
8
+ VALUES (?, ?, ?)
9
+ ON CONFLICT(key) DO UPDATE SET content = excluded.content, updated_at = excluded.updated_at;
10
+ `,
11
+ args: [task.key_or_id, task.payload, task.created_at],
12
+ });
13
+ return;
14
+ }
15
+
16
+ if (task.action === "delete_document") {
17
+ const docId = task.key_or_id;
18
+ const docRow = await db.cloudClient.execute({
19
+ sql: "SELECT id FROM documents WHERE id = ? OR path = ?;",
20
+ args: [docId, docId],
21
+ });
22
+ if (docRow.rows.length > 0) {
23
+ const realDocId = docRow.rows[0].id;
24
+ await db.cloudClient.execute({ sql: "DELETE FROM micro_chunks_fts WHERE id IN (SELECT id FROM micro_chunks WHERE doc_id = ?);", args: [realDocId] });
25
+ await db.cloudClient.execute({ sql: "DELETE FROM micro_chunks WHERE doc_id = ?;", args: [realDocId] });
26
+ await db.cloudClient.execute({ sql: "DELETE FROM medium_chunks WHERE doc_id = ?;", args: [realDocId] });
27
+ await db.cloudClient.execute({ sql: "DELETE FROM sections WHERE doc_id = ?;", args: [realDocId] });
28
+ await db.cloudClient.execute({ sql: "DELETE FROM graph_edges WHERE source_id = ? OR target_id = ?;", args: [realDocId, realDocId] });
29
+ await db.cloudClient.execute({ sql: "DELETE FROM knowledge_links WHERE doc_id = ?;", args: [realDocId] });
30
+ await db.cloudClient.execute({ sql: "DELETE FROM documents WHERE id = ?;", args: [realDocId] });
31
+ }
32
+ return;
33
+ }
34
+
35
+ if (task.action === "ingest_document") {
36
+ const data = JSON.parse(task.payload);
37
+ const doc = data.document;
38
+
39
+ // 1. Delete existing doc from cloud if any
40
+ const existingDocRow = await db.cloudClient.execute({
41
+ sql: "SELECT id FROM documents WHERE id = ? OR path = ?;",
42
+ args: [doc.id, doc.path],
43
+ });
44
+ if (existingDocRow.rows.length > 0) {
45
+ const realDocId = existingDocRow.rows[0].id;
46
+ await db.cloudClient.execute({ sql: "DELETE FROM micro_chunks_fts WHERE id IN (SELECT id FROM micro_chunks WHERE doc_id = ?);", args: [realDocId] });
47
+ await db.cloudClient.execute({ sql: "DELETE FROM micro_chunks WHERE doc_id = ?;", args: [realDocId] });
48
+ await db.cloudClient.execute({ sql: "DELETE FROM medium_chunks WHERE doc_id = ?;", args: [realDocId] });
49
+ await db.cloudClient.execute({ sql: "DELETE FROM sections WHERE doc_id = ?;", args: [realDocId] });
50
+ await db.cloudClient.execute({ sql: "DELETE FROM graph_edges WHERE source_id = ? OR target_id = ?;", args: [realDocId, realDocId] });
51
+ await db.cloudClient.execute({ sql: "DELETE FROM knowledge_links WHERE doc_id = ?;", args: [realDocId] });
52
+ await db.cloudClient.execute({ sql: "DELETE FROM documents WHERE id = ?;", args: [realDocId] });
53
+ }
54
+
55
+ // 2. Insert document
56
+ await db.cloudClient.execute({
57
+ sql: `
58
+ INSERT INTO documents (id, path, blob_hash, title, checksum, toc_json, metadata_json, created_at, updated_at)
59
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?);
60
+ `,
61
+ args: [
62
+ doc.id,
63
+ doc.path,
64
+ doc.blob_hash,
65
+ doc.title,
66
+ doc.checksum,
67
+ doc.toc_json ? (typeof doc.toc_json === "string" ? doc.toc_json : JSON.stringify(doc.toc_json)) : null,
68
+ doc.metadata_json ? (typeof doc.metadata_json === "string" ? doc.metadata_json : JSON.stringify(doc.metadata_json)) : null,
69
+ doc.created_at,
70
+ doc.updated_at,
71
+ ],
72
+ });
73
+
74
+ // 3. Insert sections
75
+ if (Array.isArray(data.sections)) {
76
+ for (const s of data.sections) {
77
+ await db.cloudClient.execute({
78
+ sql: "INSERT INTO sections (id, doc_id, heading, breadcrumbs, content, token_count) VALUES (?, ?, ?, ?, ?, ?);",
79
+ args: [s.id, doc.id, s.heading, s.breadcrumbs, s.content, s.token_count],
80
+ });
81
+ }
82
+ }
83
+
84
+ // 4. Insert medium_chunks
85
+ if (Array.isArray(data.medium_chunks)) {
86
+ for (const m of data.medium_chunks) {
87
+ await db.cloudClient.execute({
88
+ sql: "INSERT INTO medium_chunks (id, section_id, doc_id, content, block_type, token_count, created_at) VALUES (?, ?, ?, ?, ?, ?, ?);",
89
+ args: [m.id, m.section_id, doc.id, m.content, m.block_type, m.token_count, m.created_at || Date.now()],
90
+ });
91
+ }
92
+ }
93
+
94
+ // 5. Insert micro_chunks & FTS
95
+ if (Array.isArray(data.micro_chunks)) {
96
+ for (const mc of data.micro_chunks) {
97
+ let vecBuf = Buffer.alloc(0);
98
+ if (mc.vector) {
99
+ if (Buffer.isBuffer(mc.vector)) {
100
+ vecBuf = mc.vector;
101
+ } else if (typeof mc.vector === "string") {
102
+ vecBuf = Buffer.from(mc.vector, "base64");
103
+ } else if (mc.vector.type === "Buffer" && Array.isArray(mc.vector.data)) {
104
+ vecBuf = Buffer.from(mc.vector.data);
105
+ } else if (Array.isArray(mc.vector)) {
106
+ vecBuf = Buffer.from(mc.vector);
107
+ }
108
+ }
109
+ await db.cloudClient.execute({
110
+ sql: "INSERT INTO micro_chunks (id, section_id, doc_id, content, vector, token_count, medium_id) VALUES (?, ?, ?, ?, ?, ?, ?);",
111
+ args: [mc.id, mc.section_id, doc.id, mc.content, vecBuf, mc.token_count, mc.medium_id || null],
112
+ });
113
+
114
+ // Insert into remote FTS
115
+ try {
116
+ await db.cloudClient.execute({
117
+ sql: "INSERT INTO micro_chunks_fts (id, content, breadcrumbs) VALUES (?, ?, ?);",
118
+ args: [mc.id, mc.content, mc.breadcrumbs || ""],
119
+ });
120
+ } catch (ftsErr) {
121
+ console.warn("FTS insertion failed on cloud:", ftsErr.message);
122
+ }
123
+ }
124
+ }
125
+
126
+ // 6. Insert graph_edges
127
+ if (Array.isArray(data.graph_edges)) {
128
+ for (const e of data.graph_edges) {
129
+ await db.cloudClient.execute({
130
+ sql: "INSERT OR IGNORE INTO graph_edges (source_id, target_id, relation_type, metadata_json, created_at) VALUES (?, ?, ?, ?, ?);",
131
+ args: [e.source_id, e.target_id, e.relation_type, e.metadata_json ? (typeof e.metadata_json === "string" ? e.metadata_json : JSON.stringify(e.metadata_json)) : null, e.created_at || Date.now()],
132
+ });
133
+ }
134
+ }
135
+ }
136
+ }
137
+
138
+ export async function enqueueSyncTask(action, keyOrId, payload = null) {
139
+ const { getDatabase } = await import("./database.js");
140
+ const db = await getDatabase();
141
+ if (db.mode === "only-cloud") return; // No need to queue in only-cloud mode
142
+
143
+ // Ensure queue table exists
144
+ await db.exec(`
145
+ CREATE TABLE IF NOT EXISTS sync_queue (
146
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
147
+ action TEXT NOT NULL,
148
+ key_or_id TEXT NOT NULL,
149
+ payload TEXT,
150
+ created_at INTEGER NOT NULL
151
+ );
152
+ `);
153
+
154
+ await db.prepare(`
155
+ INSERT INTO sync_queue (action, key_or_id, payload, created_at)
156
+ VALUES (?, ?, ?, ?);
157
+ `).run(action, keyOrId, payload ? (typeof payload === "string" ? payload : JSON.stringify(payload)) : null, Date.now());
158
+
159
+ // Trigger background sync worker asynchronously
160
+ triggerBackgroundSync().catch((err) => {
161
+ console.error("Background sync trigger error:", err.message);
162
+ });
163
+ }
164
+
165
+ export async function triggerBackgroundSync() {
166
+ if (isSyncing) return;
167
+ isSyncing = true;
168
+
169
+ try {
170
+ const { getDatabase } = await import("./database.js");
171
+ const db = await getDatabase();
172
+ if (db.mode !== "hybrid-sync" || !db.cloudClient) {
173
+ isSyncing = false;
174
+ return;
175
+ }
176
+
177
+ // Ensure table exists
178
+ await db.exec(`
179
+ CREATE TABLE IF NOT EXISTS sync_queue (
180
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
181
+ action TEXT NOT NULL,
182
+ key_or_id TEXT NOT NULL,
183
+ payload TEXT,
184
+ created_at INTEGER NOT NULL
185
+ );
186
+ `);
187
+
188
+ // Fetch tasks ordered by id
189
+ const tasks = await db.prepare("SELECT * FROM sync_queue ORDER BY id ASC LIMIT 50;").all();
190
+ if (tasks.length === 0) {
191
+ isSyncing = false;
192
+ return;
193
+ }
194
+
195
+ for (const task of tasks) {
196
+ try {
197
+ await processSyncTask(db, task);
198
+ // On success, delete from queue
199
+ await db.prepare("DELETE FROM sync_queue WHERE id = ?;").run(task.id);
200
+ } catch (err) {
201
+ console.error(`Failed to process sync task ${task.id} (${task.action}):`, err.message, err.stack);
202
+ // Stop processing this batch to preserve order on error, retry next time
203
+ break;
204
+ }
205
+ }
206
+ } catch (err) {
207
+ console.error("Error during background sync execution:", err.message);
208
+ } finally {
209
+ isSyncing = false;
210
+ }
211
+ }
@@ -1,20 +1,43 @@
1
+ const ignoredKeywords = new Set([
2
+ "const", "let", "var", "function", "class", "import", "export", "from", "return", "if", "for", "while", "switch", "case", "default",
3
+ "def", "self", "lambda", "pass", "yield", "async", "await", "with", "except", "try", "catch", "finally",
4
+ "func", "type", "struct", "interface", "chan", "map", "go", "defer", "package", "range",
5
+ "fn", "enum", "trait", "impl", "pub", "mut", "ref", "self", "Self", "match", "use", "mod", "crate",
6
+ "namespace", "template", "typename", "public", "private", "protected", "virtual", "override", "using", "inline", "static", "constexpr", "extern", "explicit", "friend", "operator", "throw",
7
+ "record", "synchronized", "final", "void", "throws", "new", "this", "super", "fun", "val", "null", "true", "false",
8
+ "internal", "readonly", "base", "get", "set", "echo", "exit", "die", "require", "include",
9
+ "module", "end", "extend", "attr_accessor", "attr_reader", "attr_writer", "nil", "puts", "raise"
10
+ ]);
11
+
1
12
  export function extractSymbolsFromContent(content) {
13
+ if (!content) return [];
2
14
  const symbols = new Set();
3
15
 
4
- const jsTsRegex = /(?:function|class|interface|type|enum|const|let|var)\s+([a-zA-Z0-9_$]+)/g;
5
- let match;
6
- while ((match = jsTsRegex.exec(content)) !== null) {
7
- const symbol = match[1];
8
- if (symbol.length > 2 && !["const", "let", "var", "function", "class", "import", "export", "from", "return", "if", "for", "while"].includes(symbol)) {
9
- symbols.add(symbol);
10
- }
11
- }
16
+ const patterns = [
17
+ // JS/TS
18
+ /(?:function|class|interface|type|enum|const|let|var)\s+([a-zA-Z0-9_$]+)/g,
19
+ // Python / PHP / Ruby
20
+ /(?:def|class|function|module|trait)\s+([a-zA-Z0-9_!?=]+)/g,
21
+ // Go / Rust / C++ / Java / Kotlin / C# / PHP (Types)
22
+ /\b(?:class|struct|interface|record|enum|trait|type|namespace)\s+([a-zA-Z0-9_]+)/g,
23
+ // Go (Functions)
24
+ /\bfunc\s+(?:\([^)]+\)\s+)?([a-zA-Z0-9_]+)\s*\(/g,
25
+ // Rust (Functions)
26
+ /\bfn\s+([a-zA-Z0-9_]+)/g,
27
+ // Kotlin (Functions)
28
+ /\bfun\s+([a-zA-Z0-9_]+)/g,
29
+ // Java / C# / C++ (Methods/Functions)
30
+ /\b(?:public|protected|private|static|synchronized|final|async|virtual|override|readonly)*\s*[\w<>\[\]]+\s+([a-zA-Z0-9_]+)\s*\([^)]*\)\s*(?:const|override|noexcept|throws\s+[\w,\s]+|\s)*\s*[{;]/g
31
+ ];
12
32
 
13
- const pyRegex = /(?:def|class)\s+([a-zA-Z0-9_]+)/g;
14
- while ((match = pyRegex.exec(content)) !== null) {
15
- const symbol = match[1];
16
- if (symbol.length > 2 && !["def", "class", "self", "return", "import", "from"].includes(symbol)) {
17
- symbols.add(symbol);
33
+ for (const pattern of patterns) {
34
+ let match;
35
+ pattern.lastIndex = 0;
36
+ while ((match = pattern.exec(content)) !== null) {
37
+ const symbol = match[1];
38
+ if (symbol && symbol.length > 2 && !ignoredKeywords.has(symbol)) {
39
+ symbols.add(symbol);
40
+ }
18
41
  }
19
42
  }
20
43
 
@@ -52,21 +75,21 @@ export function buildGraphEdges(docId, hierarchy) {
52
75
  return edges;
53
76
  }
54
77
 
55
- export function saveGraphEdges(db, edges) {
78
+ export async function saveGraphEdges(db, edges) {
56
79
  const stmt = db.prepare(`
57
80
  INSERT OR IGNORE INTO graph_edges (source_id, target_id, relation_type)
58
81
  VALUES (?, ?, ?);
59
82
  `);
60
83
  for (const edge of edges) {
61
- stmt.run(edge.source_id, edge.target_id, edge.relation_type);
84
+ await stmt.run(edge.source_id, edge.target_id, edge.relation_type);
62
85
  }
63
86
  }
64
87
 
65
- export function getRelatedSymbols(db, sectionId) {
88
+ export async function getRelatedSymbols(db, sectionId) {
66
89
  const stmt = db.prepare(`
67
90
  SELECT target_id, relation_type FROM graph_edges
68
91
  WHERE source_id = ? AND relation_type = 'DEFINES_SYMBOL';
69
92
  `);
70
- const rows = stmt.all(sectionId);
93
+ const rows = await stmt.all(sectionId);
71
94
  return rows.map((r) => r.target_id.replace("symbol:", ""));
72
95
  }