@lotargo/memory_plugin 1.3.2 → 1.4.5
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 +334 -324
- package/mcp-server/admin/auth.js +385 -0
- package/mcp-server/admin/snapshot.js +34 -32
- package/mcp-server/cli.js +108 -12
- package/mcp-server/config/auth_store.js +137 -0
- package/mcp-server/config/config_manager.js +5 -0
- package/mcp-server/db/database.js +198 -14
- package/mcp-server/db/migrations.js +62 -33
- package/mcp-server/db/sync_queue.js +211 -0
- package/mcp-server/graph/graph_extractor.js +40 -17
- package/mcp-server/graph/knowledge_linker.js +14 -14
- package/mcp-server/index.js +34 -23
- package/mcp-server/ingest/exporter.js +12 -12
- package/mcp-server/ingest/normalizer.js +102 -5
- package/mcp-server/ingest/pipeline.js +53 -29
- package/mcp-server/memory.js +73 -0
- package/mcp-server/retrieval/retriever.js +24 -15
- package/package.json +6 -2
package/mcp-server/cli.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
1
|
+
#!/usr/bin/env node
|
|
2
2
|
import readline from "readline";
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { getConfig, updateConfig, resetConfig } from "./config/config_manager.js";
|
|
@@ -91,9 +91,11 @@ async function getQuickStats() {
|
|
|
91
91
|
let docCount = 0;
|
|
92
92
|
let chunkCount = 0;
|
|
93
93
|
try {
|
|
94
|
-
const db = getDatabase();
|
|
95
|
-
|
|
96
|
-
|
|
94
|
+
const db = await getDatabase();
|
|
95
|
+
const docRow = await db.prepare("SELECT COUNT(*) as cnt FROM documents").get();
|
|
96
|
+
docCount = docRow ? docRow.cnt : 0;
|
|
97
|
+
const chunkRow = await db.prepare("SELECT COUNT(*) as cnt FROM micro_chunks").get();
|
|
98
|
+
chunkCount = chunkRow ? chunkRow.cnt : 0;
|
|
97
99
|
} catch (e) {}
|
|
98
100
|
|
|
99
101
|
let factCount = 0;
|
|
@@ -658,6 +660,31 @@ export async function runCli() {
|
|
|
658
660
|
return;
|
|
659
661
|
}
|
|
660
662
|
|
|
663
|
+
if (cliArgs.includes("login")) {
|
|
664
|
+
console.log("\n [CLOUD] Starting Turso cloud authorization...");
|
|
665
|
+
const { loginToCloud } = await import("./admin/auth.js");
|
|
666
|
+
try {
|
|
667
|
+
const secrets = await loginToCloud();
|
|
668
|
+
console.log(`\n \x1b[32m[OK] Successfully signed in to the cloud! Connected to endpoint: ${secrets.dbUrl}\x1b[0m\n`);
|
|
669
|
+
} catch (e) {
|
|
670
|
+
console.error(`\n \x1b[31m[ERROR] Authorization failed: ${e.message}\x1b[0m\n`);
|
|
671
|
+
process.exit(1);
|
|
672
|
+
}
|
|
673
|
+
return;
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
if (cliArgs.includes("logout")) {
|
|
677
|
+
console.log("\n [CLOUD] Signing out of the cloud...");
|
|
678
|
+
const { logoutFromCloud } = await import("./admin/auth.js");
|
|
679
|
+
const deleted = logoutFromCloud();
|
|
680
|
+
if (deleted) {
|
|
681
|
+
console.log(" \x1b[32m[OK] You have been signed out. Encrypted secrets removed. Mode reverted to only-local.\x1b[0m\n");
|
|
682
|
+
} else {
|
|
683
|
+
console.log(" [*] Mode reverted to only-local. No session tokens were found.\x1b[0m\n");
|
|
684
|
+
}
|
|
685
|
+
return;
|
|
686
|
+
}
|
|
687
|
+
|
|
661
688
|
let running = true;
|
|
662
689
|
let selectedIndex = 0;
|
|
663
690
|
|
|
@@ -760,6 +787,27 @@ export async function runCli() {
|
|
|
760
787
|
},
|
|
761
788
|
],
|
|
762
789
|
},
|
|
790
|
+
{
|
|
791
|
+
title: "Cloud Synchronization & Turso",
|
|
792
|
+
items: [
|
|
793
|
+
{
|
|
794
|
+
label: "[CLOUD] Login to Turso Cloud",
|
|
795
|
+
value: "cloud_login",
|
|
796
|
+
info: "Perform secure OAuth/Device login flow with loopback listener and local AES-256 key encryption",
|
|
797
|
+
},
|
|
798
|
+
{
|
|
799
|
+
label: "[CLOUD] Logout",
|
|
800
|
+
value: "cloud_logout",
|
|
801
|
+
info: "Sign out, purge encrypted secrets, and revert mode to only-local",
|
|
802
|
+
},
|
|
803
|
+
{
|
|
804
|
+
label: "Operational Mode",
|
|
805
|
+
badge: config.mode.toUpperCase(),
|
|
806
|
+
value: "cloud_mode",
|
|
807
|
+
info: "Choose Operational Mode: only-local | only-cloud | hybrid-sync",
|
|
808
|
+
},
|
|
809
|
+
],
|
|
810
|
+
},
|
|
763
811
|
{
|
|
764
812
|
title: "Global Prompt & Integration Management",
|
|
765
813
|
items: [
|
|
@@ -1103,10 +1151,11 @@ export async function runCli() {
|
|
|
1103
1151
|
await writeMemory(key, updated);
|
|
1104
1152
|
let links = 0;
|
|
1105
1153
|
try {
|
|
1106
|
-
const db = getDatabase();
|
|
1107
|
-
|
|
1154
|
+
const db = await getDatabase();
|
|
1155
|
+
const runRes = await db
|
|
1108
1156
|
.prepare("UPDATE knowledge_links SET fact_text = ? WHERE fact_key = ? AND fact_text = ?")
|
|
1109
|
-
.run(newText, key, factText(selectedEntry))
|
|
1157
|
+
.run(newText, key, factText(selectedEntry));
|
|
1158
|
+
links = runRes ? runRes.changes : 0;
|
|
1110
1159
|
} catch (e) {}
|
|
1111
1160
|
console.clear();
|
|
1112
1161
|
console.log(`\n [OK] Fact updated successfully${links ? `, ${links} doc link(s) updated` : ""}.\n`);
|
|
@@ -1234,8 +1283,8 @@ export async function runCli() {
|
|
|
1234
1283
|
case "rag_docs": {
|
|
1235
1284
|
let docRunning = true;
|
|
1236
1285
|
while (docRunning) {
|
|
1237
|
-
const db = getDatabase();
|
|
1238
|
-
const docs = db.prepare("SELECT id, title, path, created_at FROM documents ORDER BY created_at DESC").all();
|
|
1286
|
+
const db = await getDatabase();
|
|
1287
|
+
const docs = await db.prepare("SELECT id, title, path, created_at FROM documents ORDER BY created_at DESC").all();
|
|
1239
1288
|
|
|
1240
1289
|
if (!docs || docs.length === 0) {
|
|
1241
1290
|
console.clear();
|
|
@@ -1295,9 +1344,11 @@ export async function runCli() {
|
|
|
1295
1344
|
});
|
|
1296
1345
|
|
|
1297
1346
|
if (actionRes.action === "select" && actionRes.value === "info") {
|
|
1298
|
-
const
|
|
1299
|
-
const
|
|
1300
|
-
const
|
|
1347
|
+
const secCountRow = await db.prepare("SELECT COUNT(*) as cnt FROM sections WHERE doc_id = ?").get(targetDoc.id);
|
|
1348
|
+
const secCount = secCountRow ? secCountRow.cnt : 0;
|
|
1349
|
+
const chunkCountRow = await db.prepare("SELECT COUNT(*) as cnt FROM micro_chunks WHERE doc_id = ?").get(targetDoc.id);
|
|
1350
|
+
const chunkCount = chunkCountRow ? chunkCountRow.cnt : 0;
|
|
1351
|
+
const sampleSections = await db.prepare("SELECT heading FROM sections WHERE doc_id = ? LIMIT 5").all(targetDoc.id);
|
|
1301
1352
|
|
|
1302
1353
|
console.clear();
|
|
1303
1354
|
const line = "─".repeat(PANEL_WIDTH - 2);
|
|
@@ -1776,6 +1827,51 @@ export async function runCli() {
|
|
|
1776
1827
|
}
|
|
1777
1828
|
break;
|
|
1778
1829
|
}
|
|
1830
|
+
case "cloud_login": {
|
|
1831
|
+
console.clear();
|
|
1832
|
+
console.log("\n [CLOUD] Starting Turso cloud authorization...");
|
|
1833
|
+
const { loginToCloud } = await import("./admin/auth.js");
|
|
1834
|
+
try {
|
|
1835
|
+
const secrets = await loginToCloud();
|
|
1836
|
+
console.log(`\n \x1b[32m[OK] Successfully signed in to the cloud! Connected to endpoint: ${secrets.dbUrl}\x1b[0m\n`);
|
|
1837
|
+
} catch (e) {
|
|
1838
|
+
console.error(`\n \x1b[31m[ERROR] Authorization failed: ${e.message}\x1b[0m\n`);
|
|
1839
|
+
}
|
|
1840
|
+
await waitForEnter();
|
|
1841
|
+
break;
|
|
1842
|
+
}
|
|
1843
|
+
case "cloud_logout": {
|
|
1844
|
+
console.clear();
|
|
1845
|
+
console.log("\n [CLOUD] Signing out of the cloud...");
|
|
1846
|
+
const { logoutFromCloud } = await import("./admin/auth.js");
|
|
1847
|
+
const deleted = logoutFromCloud();
|
|
1848
|
+
if (deleted) {
|
|
1849
|
+
console.log(" \x1b[32m[OK] You have been signed out. Encrypted secrets removed. Mode reverted to only-local.\x1b[0m\n");
|
|
1850
|
+
} else {
|
|
1851
|
+
console.log(" [*] Mode reverted to only-local. No session tokens were found.\x1b[0m\n");
|
|
1852
|
+
}
|
|
1853
|
+
await waitForEnter();
|
|
1854
|
+
break;
|
|
1855
|
+
}
|
|
1856
|
+
case "cloud_mode": {
|
|
1857
|
+
const modeItems = [
|
|
1858
|
+
{ label: "only-local (Local only)", value: "only-local", info: "Fully private, offline-first mode (everything stored on disk)" },
|
|
1859
|
+
{ label: "only-cloud (Cloud only)", value: "only-cloud", info: "Fully serverless cloud mode with no local caching" },
|
|
1860
|
+
{ label: "hybrid-sync (Local with background sync)", value: "hybrid-sync", info: "Instant local operations with a background sync daemon" },
|
|
1861
|
+
];
|
|
1862
|
+
const initialIdx = Math.max(0, modeItems.findIndex((i) => i.value === config.mode));
|
|
1863
|
+
const subRes = await selectSimpleMenu({
|
|
1864
|
+
title: "CHOOSE OPERATIONAL MODE",
|
|
1865
|
+
subtitle: "Configure database storage and cloud sync behavior",
|
|
1866
|
+
items: modeItems,
|
|
1867
|
+
initialIndex: initialIdx,
|
|
1868
|
+
});
|
|
1869
|
+
|
|
1870
|
+
if (subRes.action === "select") {
|
|
1871
|
+
updateConfig({ mode: subRes.value });
|
|
1872
|
+
}
|
|
1873
|
+
break;
|
|
1874
|
+
}
|
|
1779
1875
|
case "enable_prompt": {
|
|
1780
1876
|
const { enableGlobalPrompt } = await import("./prompt_manager.js");
|
|
1781
1877
|
const results = await enableGlobalPrompt();
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import crypto from "node:crypto";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import { execSync } from "node:child_process";
|
|
6
|
+
import { MEMORY_DIR, ensureDirSync } from "../memory.js";
|
|
7
|
+
|
|
8
|
+
const SECRETS_FILE = path.join(MEMORY_DIR, "auth_secrets.enc");
|
|
9
|
+
|
|
10
|
+
// Stable per-machine identifier. Must NOT rely on volatile values (e.g.
|
|
11
|
+
// os.networkInterfaces() — VPN adapters, hotspot IPs and IPv6 privacy
|
|
12
|
+
// addresses rotate constantly and would silently change the AES key).
|
|
13
|
+
function getMachineId() {
|
|
14
|
+
try {
|
|
15
|
+
if (process.platform === "win32") {
|
|
16
|
+
const out = execSync("reg query HKLM\\SOFTWARE\\Microsoft\\Cryptography /v MachineGuid", {
|
|
17
|
+
encoding: "utf8",
|
|
18
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
19
|
+
});
|
|
20
|
+
const m = out.match(/MachineGuid\s+REG_SZ\s+([0-9a-fA-F-]{36})/i);
|
|
21
|
+
if (m) return m[1].toLowerCase();
|
|
22
|
+
} else if (process.platform === "linux") {
|
|
23
|
+
for (const p of ["/etc/machine-id", "/var/lib/dbus/machine-id"]) {
|
|
24
|
+
try {
|
|
25
|
+
const v = fs.readFileSync(p, "utf8").trim();
|
|
26
|
+
if (v) return v;
|
|
27
|
+
} catch {}
|
|
28
|
+
}
|
|
29
|
+
} else if (process.platform === "darwin") {
|
|
30
|
+
const out = execSync("ioreg -rd1 -c IOPlatformExpertDevice", {
|
|
31
|
+
encoding: "utf8",
|
|
32
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
33
|
+
});
|
|
34
|
+
const m = out.match(/"IOPlatformUUID"\s*=\s*"([^"]+)"/);
|
|
35
|
+
if (m) return m[1];
|
|
36
|
+
}
|
|
37
|
+
} catch {}
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Generate a deterministic hardware + system fingerprint (stable across reboots,
|
|
42
|
+
// network changes and user sessions on the same machine).
|
|
43
|
+
function getSystemFingerprint() {
|
|
44
|
+
const parts = [
|
|
45
|
+
getMachineId() || "no-machine-id",
|
|
46
|
+
os.hostname() || "localhost",
|
|
47
|
+
os.userInfo()?.username || "default_user",
|
|
48
|
+
os.platform() || "unknown",
|
|
49
|
+
os.arch() || "unknown",
|
|
50
|
+
];
|
|
51
|
+
return parts.join("|");
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Derive a 256-bit (32 bytes) key using PBKDF2 with salt derived from fingerprint
|
|
55
|
+
function deriveEncryptionKey() {
|
|
56
|
+
const fingerprint = getSystemFingerprint();
|
|
57
|
+
const salt = crypto.createHash("sha256").update(fingerprint).digest();
|
|
58
|
+
// PBKDF2 with 10,000 iterations to derive a secure 32-byte key
|
|
59
|
+
return crypto.pbkdf2Sync(fingerprint, salt, 10000, 32, "sha256");
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Encrypt data using AES-256-GCM
|
|
63
|
+
export function encryptData(plainText) {
|
|
64
|
+
const key = deriveEncryptionKey();
|
|
65
|
+
const iv = crypto.randomBytes(12); // 96-bit IV is standard for GCM
|
|
66
|
+
const cipher = crypto.createCipheriv("aes-256-gcm", key, iv);
|
|
67
|
+
|
|
68
|
+
let encrypted = cipher.update(plainText, "utf8", "hex");
|
|
69
|
+
encrypted += cipher.final("hex");
|
|
70
|
+
|
|
71
|
+
const authTag = cipher.getAuthTag().toString("hex");
|
|
72
|
+
|
|
73
|
+
// Format as: iv_hex:auth_tag_hex:encrypted_hex
|
|
74
|
+
return `${iv.toString("hex")}:${authTag}:${encrypted}`;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Decrypt data using AES-256-GCM
|
|
78
|
+
export function decryptData(encryptedStr) {
|
|
79
|
+
const parts = encryptedStr.split(":");
|
|
80
|
+
if (parts.length !== 3) {
|
|
81
|
+
throw new Error("Invalid encrypted format. Expected iv:tag:ciphertext");
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const [ivHex, authTagHex, encryptedHex] = parts;
|
|
85
|
+
const key = deriveEncryptionKey();
|
|
86
|
+
const iv = Buffer.from(ivHex, "hex");
|
|
87
|
+
const authTag = Buffer.from(authTagHex, "hex");
|
|
88
|
+
|
|
89
|
+
const decipher = crypto.createDecipheriv("aes-256-gcm", key, iv);
|
|
90
|
+
decipher.setAuthTag(authTag);
|
|
91
|
+
|
|
92
|
+
let decrypted = decipher.update(encryptedHex, "hex", "utf8");
|
|
93
|
+
decrypted += decipher.final("utf8");
|
|
94
|
+
|
|
95
|
+
return decrypted;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Save secrets securely
|
|
99
|
+
export function saveSecrets(secrets) {
|
|
100
|
+
ensureDirSync();
|
|
101
|
+
const plainText = JSON.stringify(secrets);
|
|
102
|
+
const encrypted = encryptData(plainText);
|
|
103
|
+
fs.writeFileSync(SECRETS_FILE, encrypted, "utf-8");
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Load secrets securely
|
|
107
|
+
export function loadSecrets() {
|
|
108
|
+
if (!fs.existsSync(SECRETS_FILE)) {
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
try {
|
|
112
|
+
const encrypted = fs.readFileSync(SECRETS_FILE, "utf-8").trim();
|
|
113
|
+
if (!encrypted) return null;
|
|
114
|
+
const decrypted = decryptData(encrypted);
|
|
115
|
+
return JSON.parse(decrypted);
|
|
116
|
+
} catch (err) {
|
|
117
|
+
console.error(
|
|
118
|
+
"Failed to decrypt or load cloud secrets:",
|
|
119
|
+
err.message,
|
|
120
|
+
"— the file was encrypted with a different machine key. Re-run login to recreate it."
|
|
121
|
+
);
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Delete secrets from disk
|
|
127
|
+
export function deleteSecrets() {
|
|
128
|
+
if (fs.existsSync(SECRETS_FILE)) {
|
|
129
|
+
try {
|
|
130
|
+
fs.unlinkSync(SECRETS_FILE);
|
|
131
|
+
return true;
|
|
132
|
+
} catch (err) {
|
|
133
|
+
console.error("Failed to delete secrets file:", err.message);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return false;
|
|
137
|
+
}
|
|
@@ -14,6 +14,11 @@ export const DEFAULT_CONFIG = {
|
|
|
14
14
|
gpuAttentionBudget: 2000000, // GPU micro-batch attention budget [1M - 16M] (default 2.0M ~1.5GB VRAM)
|
|
15
15
|
onnxThreads: 0, // ONNX WASM threads: 0 = auto-detect CPU cores, or 1-16
|
|
16
16
|
executionDevice: "cpu", // "cpu" | "webgpu"
|
|
17
|
+
mode: "only-local", // "only-local" | "only-cloud" | "hybrid-sync"
|
|
18
|
+
tursoUrl: "", // Connection endpoint URL for Turso DB
|
|
19
|
+
failoverUrl: "", // Failover connection endpoint URL (Fly.io + LiteFS)
|
|
20
|
+
authorized: false, // True once the user completed cloud login (token stored encrypted)
|
|
21
|
+
username: "", // Account username from the Turso OAuth profile
|
|
17
22
|
};
|
|
18
23
|
|
|
19
24
|
let cachedConfig = null;
|
|
@@ -1,41 +1,225 @@
|
|
|
1
1
|
import { DatabaseSync } from "node:sqlite";
|
|
2
2
|
import { join } from "path";
|
|
3
3
|
import { existsSync, mkdirSync } from "fs";
|
|
4
|
-
import { MEMORY_DIR
|
|
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;
|
|
11
|
+
let dbInitPromise = null;
|
|
8
12
|
|
|
9
13
|
export const STORAGE_DIR = join(MEMORY_DIR, "storage");
|
|
10
14
|
export const BLOBS_DIR = join(STORAGE_DIR, "blobs");
|
|
11
15
|
export const MODELS_DIR = join(STORAGE_DIR, "models");
|
|
12
16
|
export const DB_PATH = join(STORAGE_DIR, "memory.sqlite");
|
|
13
17
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
18
|
+
class DatabaseWrapper {
|
|
19
|
+
constructor(localDb, cloudClient, mode, failoverClient = null) {
|
|
20
|
+
this.localDb = localDb;
|
|
21
|
+
this.cloudClient = cloudClient;
|
|
22
|
+
this.mode = mode;
|
|
23
|
+
this.failoverClient = failoverClient;
|
|
24
|
+
this.usingFailover = false;
|
|
25
|
+
this.consecutiveFailures = 0;
|
|
17
26
|
}
|
|
18
27
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
28
|
+
async runWithRetry(fn) {
|
|
29
|
+
let attempts = 0;
|
|
30
|
+
const maxAttempts = 3;
|
|
31
|
+
const timeoutMs = 10000;
|
|
32
|
+
|
|
33
|
+
while (attempts < maxAttempts) {
|
|
34
|
+
attempts++;
|
|
35
|
+
const controller = new AbortController();
|
|
36
|
+
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
|
|
37
|
+
|
|
38
|
+
try {
|
|
39
|
+
const client = (this.usingFailover && this.failoverClient) ? this.failoverClient : this.cloudClient;
|
|
40
|
+
const result = await Promise.race([
|
|
41
|
+
fn(client),
|
|
42
|
+
new Promise((_, reject) => {
|
|
43
|
+
controller.signal.addEventListener("abort", () => {
|
|
44
|
+
reject(new Error("Database operation timed out after 10 seconds"));
|
|
45
|
+
});
|
|
46
|
+
})
|
|
47
|
+
]);
|
|
48
|
+
clearTimeout(timeoutId);
|
|
49
|
+
// Successful operation, reset consecutive failures
|
|
50
|
+
this.consecutiveFailures = 0;
|
|
51
|
+
return result;
|
|
52
|
+
} catch (err) {
|
|
53
|
+
clearTimeout(timeoutId);
|
|
54
|
+
if (attempts >= maxAttempts) {
|
|
55
|
+
this.consecutiveFailures++;
|
|
56
|
+
if (this.consecutiveFailures >= 3 && this.failoverClient && !this.usingFailover) {
|
|
57
|
+
console.warn("[WARN] Turso is temporarily unreachable. Switching to LiteFS failover replica...");
|
|
58
|
+
this.usingFailover = true;
|
|
59
|
+
// Retry the operation on the failover client
|
|
60
|
+
return this.runWithRetry(fn);
|
|
61
|
+
}
|
|
62
|
+
throw err;
|
|
63
|
+
}
|
|
64
|
+
// Small delay before retrying (exponential backoff / fixed delay)
|
|
65
|
+
await new Promise((resolve) => setTimeout(resolve, 200));
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async exec(sql) {
|
|
71
|
+
if (this.mode === "only-cloud" && (this.cloudClient || this.failoverClient)) {
|
|
72
|
+
const trimmed = sql.trim().replace(/;$/, "").toUpperCase();
|
|
73
|
+
if (trimmed === "BEGIN" || trimmed === "BEGIN IMMEDIATE" || trimmed === "COMMIT" || trimmed === "ROLLBACK") {
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
return await this.runWithRetry(async (client) => {
|
|
77
|
+
return await client.executeMultiple(sql);
|
|
78
|
+
});
|
|
79
|
+
} else {
|
|
80
|
+
return this.localDb.exec(sql);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
prepare(sql) {
|
|
85
|
+
const self = this;
|
|
86
|
+
return {
|
|
87
|
+
async run(...args) {
|
|
88
|
+
if (self.mode === "only-cloud" && (self.cloudClient || self.failoverClient)) {
|
|
89
|
+
const res = await self.runWithRetry(async (client) => {
|
|
90
|
+
return await client.execute({ sql, args });
|
|
91
|
+
});
|
|
92
|
+
return {
|
|
93
|
+
changes: res.rowsAffected || 0,
|
|
94
|
+
lastInsertRowid: res.lastInsertRowid !== undefined ? Number(res.lastInsertRowid) : undefined,
|
|
95
|
+
};
|
|
96
|
+
} else {
|
|
97
|
+
return self.localDb.prepare(sql).run(...args);
|
|
98
|
+
}
|
|
99
|
+
},
|
|
100
|
+
async get(...args) {
|
|
101
|
+
if (self.mode === "only-cloud" && (self.cloudClient || self.failoverClient)) {
|
|
102
|
+
const res = await self.runWithRetry(async (client) => {
|
|
103
|
+
return await client.execute({ sql, args });
|
|
104
|
+
});
|
|
105
|
+
return res.rows[0];
|
|
106
|
+
} else {
|
|
107
|
+
return self.localDb.prepare(sql).get(...args);
|
|
108
|
+
}
|
|
109
|
+
},
|
|
110
|
+
async all(...args) {
|
|
111
|
+
if (self.mode === "only-cloud" && (self.cloudClient || self.failoverClient)) {
|
|
112
|
+
const res = await self.runWithRetry(async (client) => {
|
|
113
|
+
return await client.execute({ sql, args });
|
|
114
|
+
});
|
|
115
|
+
return res.rows;
|
|
116
|
+
} else {
|
|
117
|
+
return self.localDb.prepare(sql).all(...args);
|
|
118
|
+
}
|
|
119
|
+
},
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
close() {
|
|
124
|
+
if (this.localDb) {
|
|
125
|
+
try {
|
|
126
|
+
this.localDb.close();
|
|
127
|
+
} catch (e) {}
|
|
128
|
+
this.localDb = null;
|
|
129
|
+
}
|
|
130
|
+
if (this.cloudClient) {
|
|
131
|
+
try {
|
|
132
|
+
this.cloudClient.close();
|
|
133
|
+
} catch (e) {}
|
|
134
|
+
this.cloudClient = null;
|
|
135
|
+
}
|
|
136
|
+
if (this.failoverClient) {
|
|
137
|
+
try {
|
|
138
|
+
this.failoverClient.close();
|
|
139
|
+
} catch (e) {}
|
|
140
|
+
this.failoverClient = null;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async function openDatabase(customPath, mode) {
|
|
146
|
+
const config = getConfig();
|
|
147
|
+
let localDb = null;
|
|
148
|
+
if (mode !== "only-cloud") {
|
|
149
|
+
const dbPath = customPath || DB_PATH;
|
|
150
|
+
const parentDir = join(dbPath, "..");
|
|
151
|
+
if (!existsSync(parentDir)) {
|
|
152
|
+
mkdirSync(parentDir, { recursive: true });
|
|
153
|
+
}
|
|
154
|
+
localDb = new DatabaseSync(dbPath);
|
|
155
|
+
localDb.exec("PRAGMA foreign_keys = ON;");
|
|
156
|
+
localDb.exec("PRAGMA journal_mode = WAL;");
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
let cloudClient = null;
|
|
160
|
+
let failoverClient = null;
|
|
161
|
+
if (mode === "only-cloud" || mode === "hybrid-sync") {
|
|
162
|
+
const secrets = loadSecrets();
|
|
163
|
+
const tursoUrl = customPath && customPath.startsWith("libsql:") ? customPath : (secrets?.dbUrl || config.tursoUrl);
|
|
164
|
+
const failoverUrl = config.failoverUrl || "";
|
|
165
|
+
const token = secrets?.token;
|
|
166
|
+
|
|
167
|
+
if (tursoUrl) {
|
|
168
|
+
cloudClient = createClient({
|
|
169
|
+
url: tursoUrl,
|
|
170
|
+
authToken: token || undefined,
|
|
171
|
+
});
|
|
172
|
+
if (failoverUrl) {
|
|
173
|
+
failoverClient = createClient({
|
|
174
|
+
url: failoverUrl,
|
|
175
|
+
authToken: token || undefined,
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
// In hybrid-sync mode, ensure remote schema is also fully migrated and up to date
|
|
179
|
+
if (mode === "hybrid-sync") {
|
|
180
|
+
const cloudDbWrapper = new DatabaseWrapper(null, cloudClient, "only-cloud", failoverClient);
|
|
181
|
+
await runMigrations(cloudDbWrapper);
|
|
182
|
+
}
|
|
183
|
+
} else if (mode === "only-cloud") {
|
|
184
|
+
throw new Error("Turso URL is required for only-cloud mode. Please login first.");
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const wrappedDb = new DatabaseWrapper(localDb, cloudClient, mode, failoverClient);
|
|
189
|
+
|
|
190
|
+
// Initialize/run migrations
|
|
191
|
+
await runMigrations(wrappedDb);
|
|
192
|
+
|
|
193
|
+
if (!customPath) {
|
|
194
|
+
dbInstance = wrappedDb;
|
|
23
195
|
}
|
|
24
196
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
db.exec("PRAGMA journal_mode = WAL;");
|
|
197
|
+
return wrappedDb;
|
|
198
|
+
}
|
|
28
199
|
|
|
29
|
-
|
|
200
|
+
export async function getDatabase(customPath = null, forceMode = null) {
|
|
201
|
+
const config = getConfig();
|
|
202
|
+
const mode = forceMode || config.mode || "only-local";
|
|
30
203
|
|
|
31
204
|
if (!customPath) {
|
|
32
|
-
dbInstance
|
|
205
|
+
if (dbInstance && dbInstance.mode === mode) {
|
|
206
|
+
return dbInstance;
|
|
207
|
+
}
|
|
208
|
+
// Deduplicate concurrent default-DB initialization so migrations never run
|
|
209
|
+
// on multiple connections at once (avoids "database is locked" crashes).
|
|
210
|
+
if (!dbInitPromise) {
|
|
211
|
+
dbInitPromise = openDatabase(null, mode).finally(() => {
|
|
212
|
+
dbInitPromise = null;
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
return await dbInitPromise;
|
|
33
216
|
}
|
|
34
217
|
|
|
35
|
-
return
|
|
218
|
+
return openDatabase(customPath, mode);
|
|
36
219
|
}
|
|
37
220
|
|
|
38
221
|
export function closeDatabase() {
|
|
222
|
+
dbInitPromise = null;
|
|
39
223
|
if (dbInstance) {
|
|
40
224
|
dbInstance.close();
|
|
41
225
|
dbInstance = null;
|