@tikoci/rosetta 0.8.10 → 0.8.12
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 +1 -1
- package/package.json +1 -1
- package/src/mcp.ts +15 -38
- package/src/release.test.ts +14 -1
- package/src/setup.test.ts +96 -3
- package/src/setup.ts +184 -60
package/README.md
CHANGED
|
@@ -118,7 +118,7 @@ This downloads the database and prints config snippets for all supported MCP cli
|
|
|
118
118
|
Need to force a database reload later? Use:
|
|
119
119
|
|
|
120
120
|
```sh
|
|
121
|
-
bunx @tikoci/rosetta --refresh
|
|
121
|
+
bunx @tikoci/rosetta@latest --refresh
|
|
122
122
|
```
|
|
123
123
|
|
|
124
124
|
### Configure your MCP client
|
package/package.json
CHANGED
package/src/mcp.ts
CHANGED
|
@@ -61,47 +61,26 @@ function link(url: string, display?: string): string {
|
|
|
61
61
|
*/
|
|
62
62
|
async function ensureDbReady(log: (msg: string) => void): Promise<void> {
|
|
63
63
|
const { resolveDbPath, SCHEMA_VERSION, resolveVersion } = await import("./paths.ts");
|
|
64
|
-
const { downloadDb } = await import("./setup.ts");
|
|
65
|
-
const { default: sqlite } = await import("bun:sqlite");
|
|
64
|
+
const { cleanupAbandonedTempArtifacts, downloadDb, hasMinimumDbContent, probeDb } = await import("./setup.ts");
|
|
66
65
|
|
|
67
66
|
const dbPath = resolveDbPath(import.meta.dirname);
|
|
68
67
|
const runningVersion = resolveVersion(import.meta.dirname);
|
|
69
68
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
* shared-memory file. Same gotcha as setup.ts::probeDb. */
|
|
74
|
-
function probe(): { pages: number; schemaVersion: number; releaseTag: string | null } | null {
|
|
75
|
-
try {
|
|
76
|
-
const check = new sqlite(dbPath);
|
|
77
|
-
const pages = (check.prepare("SELECT COUNT(*) AS c FROM pages").get() as { c: number }).c;
|
|
78
|
-
const ver = (check.prepare("PRAGMA user_version").get() as { user_version: number }).user_version;
|
|
79
|
-
let releaseTag: string | null = null;
|
|
80
|
-
try {
|
|
81
|
-
const meta = check.prepare("SELECT value FROM db_meta WHERE key = 'release_tag'").get() as { value: string } | null;
|
|
82
|
-
releaseTag = meta?.value ?? null;
|
|
83
|
-
} catch {
|
|
84
|
-
// db_meta missing on pre-v5 DBs — leave null
|
|
85
|
-
}
|
|
86
|
-
check.close();
|
|
87
|
-
return { pages, schemaVersion: ver, releaseTag };
|
|
88
|
-
} catch {
|
|
89
|
-
return null;
|
|
90
|
-
}
|
|
91
|
-
}
|
|
69
|
+
// Always clean abandoned .tmp.* artifacts from previous failed runs when no
|
|
70
|
+
// active download lock exists, regardless of whether a download is needed now.
|
|
71
|
+
cleanupAbandonedTempArtifacts(dbPath);
|
|
92
72
|
|
|
93
|
-
let p =
|
|
73
|
+
let p = probeDb(dbPath);
|
|
94
74
|
|
|
95
75
|
// Case 1: DB missing or empty → first-time download.
|
|
96
|
-
if (!p
|
|
76
|
+
if (!hasMinimumDbContent(p)) {
|
|
97
77
|
log(`No usable database at ${dbPath} — downloading...`);
|
|
98
78
|
try {
|
|
99
|
-
await downloadDb(dbPath, log);
|
|
79
|
+
p = await downloadDb(dbPath, log);
|
|
100
80
|
log("Database downloaded successfully.");
|
|
101
|
-
p = probe();
|
|
102
81
|
} catch (e) {
|
|
103
82
|
log(`Auto-download failed: ${e instanceof Error ? e.message : e}`);
|
|
104
|
-
log(`Close other rosetta clients and run: bunx @tikoci/rosetta --refresh`);
|
|
83
|
+
log(`Close other rosetta clients and run: bunx @tikoci/rosetta@latest --refresh`);
|
|
105
84
|
throw new Error(`Unable to start rosetta without a usable database at ${dbPath}.`);
|
|
106
85
|
}
|
|
107
86
|
}
|
|
@@ -110,8 +89,8 @@ async function ensureDbReady(log: (msg: string) => void): Promise<void> {
|
|
|
110
89
|
throw new Error(`Database probe failed after download: ${dbPath}`);
|
|
111
90
|
}
|
|
112
91
|
|
|
113
|
-
if (p
|
|
114
|
-
throw new Error(`Database remained
|
|
92
|
+
if (!hasMinimumDbContent(p)) {
|
|
93
|
+
throw new Error(`Database remained incomplete after recovery: ${dbPath}`);
|
|
115
94
|
}
|
|
116
95
|
|
|
117
96
|
// Case 2: Schema mismatch → re-download, then re-probe and fail hard if
|
|
@@ -122,27 +101,25 @@ async function ensureDbReady(log: (msg: string) => void): Promise<void> {
|
|
|
122
101
|
`Re-downloading database...`,
|
|
123
102
|
);
|
|
124
103
|
try {
|
|
125
|
-
await downloadDb(dbPath, log);
|
|
104
|
+
p = await downloadDb(dbPath, log);
|
|
126
105
|
} catch (e) {
|
|
127
106
|
log(`✗ Auto-recovery download failed: ${e instanceof Error ? e.message : e}`);
|
|
128
107
|
log(
|
|
129
108
|
` This rosetta build (v${runningVersion}) cannot use the existing DB. ` +
|
|
130
|
-
`Close other rosetta clients
|
|
109
|
+
`Close other rosetta clients and run: bunx @tikoci/rosetta@latest --refresh`,
|
|
131
110
|
);
|
|
132
111
|
throw new Error(`Unable to recover an incompatible database at ${dbPath}.`);
|
|
133
112
|
}
|
|
134
|
-
|
|
135
|
-
if (!p2 || p2.schemaVersion !== SCHEMA_VERSION) {
|
|
113
|
+
if (!p || !hasMinimumDbContent(p) || p.schemaVersion !== SCHEMA_VERSION) {
|
|
136
114
|
log(
|
|
137
|
-
`✗ Still incompatible after re-download (DB=${
|
|
115
|
+
`✗ Still incompatible after re-download (DB=${p?.schemaVersion ?? "unreadable"}, expected=${SCHEMA_VERSION}).`,
|
|
138
116
|
);
|
|
139
117
|
log(
|
|
140
118
|
` The published database does not match this rosetta build (v${runningVersion}). ` +
|
|
141
|
-
`Run
|
|
119
|
+
`Run: bunx @tikoci/rosetta@latest --refresh`,
|
|
142
120
|
);
|
|
143
121
|
throw new Error(`Database remained incompatible after recovery: ${dbPath}`);
|
|
144
122
|
}
|
|
145
|
-
p = p2;
|
|
146
123
|
}
|
|
147
124
|
|
|
148
125
|
// Quietly emit a one-line provenance banner so MCP-client logs show what's loaded.
|
package/src/release.test.ts
CHANGED
|
@@ -219,7 +219,20 @@ describe("setup.ts", () => {
|
|
|
219
219
|
test("mcp.ts aborts startup instead of continuing with an empty DB", () => {
|
|
220
220
|
const src = readText("src/mcp.ts");
|
|
221
221
|
expect(src).toContain("Unable to start rosetta without a usable database");
|
|
222
|
-
expect(src).toContain("Database remained
|
|
222
|
+
expect(src).toContain("Database remained incomplete after recovery");
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
test("setup.ts cleans temp DB artifacts instead of accumulating stale .tmp files", () => {
|
|
226
|
+
const src = readText("src/setup.ts");
|
|
227
|
+
expect(src).toContain("cleanupStaleTempArtifacts");
|
|
228
|
+
expect(src).toContain("cleanupAbandonedTempArtifacts");
|
|
229
|
+
expect(src).toContain("tryUnlinkDbSidecars(tmpPath)");
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
test("setup.ts finalizes probe statements before renaming temp DBs", () => {
|
|
233
|
+
const src = readText("src/setup.ts");
|
|
234
|
+
expect(src).toContain("stmt.finalize()");
|
|
235
|
+
expect(src).toContain("sqliteGet");
|
|
223
236
|
});
|
|
224
237
|
|
|
225
238
|
test("no DB open uses { readonly: true } (WAL-shm init trap on macOS)", () => {
|
package/src/setup.test.ts
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
|
|
9
9
|
import sqlite from "bun:sqlite";
|
|
10
10
|
import { afterAll, describe, expect, test } from "bun:test";
|
|
11
|
-
import { existsSync, mkdtempSync, rmSync, unlinkSync, writeFileSync } from "node:fs";
|
|
11
|
+
import { existsSync, mkdtempSync, renameSync, rmSync, unlinkSync, writeFileSync } from "node:fs";
|
|
12
12
|
import { tmpdir } from "node:os";
|
|
13
13
|
import path from "node:path";
|
|
14
14
|
|
|
@@ -20,7 +20,15 @@ afterAll(() => {
|
|
|
20
20
|
} catch {}
|
|
21
21
|
});
|
|
22
22
|
|
|
23
|
-
const {
|
|
23
|
+
const {
|
|
24
|
+
cleanupAbandonedTempArtifacts,
|
|
25
|
+
cleanupStaleTempArtifacts,
|
|
26
|
+
dbDownloadUrls,
|
|
27
|
+
probeDb,
|
|
28
|
+
releaseDownloadLock,
|
|
29
|
+
tryAcquireDownloadLock,
|
|
30
|
+
waitForUsableDb,
|
|
31
|
+
} = await import("./setup.ts");
|
|
24
32
|
const { SCHEMA_VERSION } = await import("./paths.ts");
|
|
25
33
|
|
|
26
34
|
function writeUsableDb(dbFile: string, releaseTag = "v0.0.0-test"): void {
|
|
@@ -115,6 +123,20 @@ describe("download lock helpers", () => {
|
|
|
115
123
|
expect(probe?.pages).toBe(100);
|
|
116
124
|
expect(probe?.commands).toBe(1000);
|
|
117
125
|
});
|
|
126
|
+
|
|
127
|
+
test("waitForUsableDb does not create a missing canonical DB while waiting", async () => {
|
|
128
|
+
const dbFile = path.join(tmp, "wait-no-create.db");
|
|
129
|
+
const lock = tryAcquireDownloadLock(dbFile);
|
|
130
|
+
expect(lock).not.toBeNull();
|
|
131
|
+
|
|
132
|
+
const waiter = waitForUsableDb(dbFile, () => {}, 2_000);
|
|
133
|
+
await Bun.sleep(100);
|
|
134
|
+
expect(existsSync(dbFile)).toBe(false);
|
|
135
|
+
|
|
136
|
+
releaseDownloadLock(lock);
|
|
137
|
+
expect(await waiter).toBe(false);
|
|
138
|
+
expect(existsSync(dbFile)).toBe(false);
|
|
139
|
+
});
|
|
118
140
|
});
|
|
119
141
|
|
|
120
142
|
// ---------------------------------------------------------------------------
|
|
@@ -123,7 +145,9 @@ describe("download lock helpers", () => {
|
|
|
123
145
|
|
|
124
146
|
describe("probeDb", () => {
|
|
125
147
|
test("returns null for a missing file", () => {
|
|
126
|
-
|
|
148
|
+
const missing = path.join(tmp, "does-not-exist.db");
|
|
149
|
+
expect(probeDb(missing)).toBeNull();
|
|
150
|
+
expect(existsSync(missing)).toBe(false);
|
|
127
151
|
});
|
|
128
152
|
|
|
129
153
|
test("returns null for a non-SQLite file", () => {
|
|
@@ -155,6 +179,19 @@ describe("probeDb", () => {
|
|
|
155
179
|
expect(probe?.releaseTag).toBe("v0.0.0-test");
|
|
156
180
|
});
|
|
157
181
|
|
|
182
|
+
test("closes statements so a probed temp DB can be renamed immediately", () => {
|
|
183
|
+
const dbFile = path.join(tmp, "probe-rename-source.db");
|
|
184
|
+
const renamed = path.join(tmp, "probe-rename-dest.db");
|
|
185
|
+
writeUsableDb(dbFile, "v0.0.0-rename");
|
|
186
|
+
|
|
187
|
+
const probe = probeDb(dbFile);
|
|
188
|
+
expect(probe?.releaseTag).toBe("v0.0.0-rename");
|
|
189
|
+
|
|
190
|
+
renameSync(dbFile, renamed);
|
|
191
|
+
expect(existsSync(dbFile)).toBe(false);
|
|
192
|
+
expect(probeDb(renamed)?.releaseTag).toBe("v0.0.0-rename");
|
|
193
|
+
});
|
|
194
|
+
|
|
158
195
|
test("opens a freshly-renamed WAL-mode DB with no .shm sibling", () => {
|
|
159
196
|
// Reproduces the exact state downloadDb leaves the DB in: journal_mode=WAL
|
|
160
197
|
// on disk, but the .wal/.shm siblings are deleted just before the rename.
|
|
@@ -199,3 +236,59 @@ describe("probeDb", () => {
|
|
|
199
236
|
expect(probe?.releaseTag).toBeNull();
|
|
200
237
|
});
|
|
201
238
|
});
|
|
239
|
+
|
|
240
|
+
describe("cleanupStaleTempArtifacts", () => {
|
|
241
|
+
test("removes stale temp DB files and sidecars", () => {
|
|
242
|
+
const dbFile = path.join(tmp, "cleanup.db");
|
|
243
|
+
const artifacts = [
|
|
244
|
+
`${dbFile}.tmp.111`,
|
|
245
|
+
`${dbFile}.tmp.111-wal`,
|
|
246
|
+
`${dbFile}.tmp.111-shm`,
|
|
247
|
+
];
|
|
248
|
+
|
|
249
|
+
for (const artifact of artifacts) {
|
|
250
|
+
writeFileSync(artifact, "x");
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
expect(cleanupStaleTempArtifacts(dbFile, -1)).toBe(3);
|
|
254
|
+
for (const artifact of artifacts) {
|
|
255
|
+
expect(existsSync(artifact)).toBe(false);
|
|
256
|
+
}
|
|
257
|
+
});
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
describe("cleanupAbandonedTempArtifacts", () => {
|
|
261
|
+
test("removes fresh temp artifacts when no download lock exists", () => {
|
|
262
|
+
const dbFile = path.join(tmp, "cleanup-abandoned.db");
|
|
263
|
+
const artifacts = [
|
|
264
|
+
`${dbFile}.tmp.222`,
|
|
265
|
+
`${dbFile}.tmp.222-wal`,
|
|
266
|
+
`${dbFile}.tmp.222-shm`,
|
|
267
|
+
];
|
|
268
|
+
|
|
269
|
+
for (const artifact of artifacts) {
|
|
270
|
+
writeFileSync(artifact, "x");
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
expect(cleanupAbandonedTempArtifacts(dbFile)).toBe(3);
|
|
274
|
+
for (const artifact of artifacts) {
|
|
275
|
+
expect(existsSync(artifact)).toBe(false);
|
|
276
|
+
}
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
test("preserves fresh temp artifacts while a download lock exists", () => {
|
|
280
|
+
const dbFile = path.join(tmp, "cleanup-active.db");
|
|
281
|
+
const artifact = `${dbFile}.tmp.333`;
|
|
282
|
+
writeFileSync(artifact, "x");
|
|
283
|
+
const lock = tryAcquireDownloadLock(dbFile);
|
|
284
|
+
expect(lock).not.toBeNull();
|
|
285
|
+
|
|
286
|
+
try {
|
|
287
|
+
expect(cleanupAbandonedTempArtifacts(dbFile)).toBe(0);
|
|
288
|
+
expect(existsSync(artifact)).toBe(true);
|
|
289
|
+
} finally {
|
|
290
|
+
releaseDownloadLock(lock);
|
|
291
|
+
unlinkSync(artifact);
|
|
292
|
+
}
|
|
293
|
+
});
|
|
294
|
+
});
|
package/src/setup.ts
CHANGED
|
@@ -12,11 +12,14 @@ import {
|
|
|
12
12
|
closeSync,
|
|
13
13
|
existsSync,
|
|
14
14
|
openSync,
|
|
15
|
+
readdirSync,
|
|
16
|
+
readSync,
|
|
15
17
|
renameSync,
|
|
16
18
|
statSync,
|
|
17
19
|
unlinkSync,
|
|
18
20
|
writeFileSync,
|
|
19
21
|
} from "node:fs";
|
|
22
|
+
import path from "node:path";
|
|
20
23
|
import { gunzipSync } from "bun";
|
|
21
24
|
import { detectMode, resolveBaseDir, resolveDbPath, resolveVersion, SCHEMA_VERSION } from "./paths.ts";
|
|
22
25
|
|
|
@@ -44,6 +47,18 @@ type DbProbe = {
|
|
|
44
47
|
releaseTag: string | null;
|
|
45
48
|
};
|
|
46
49
|
|
|
50
|
+
type SQLiteStatement = {
|
|
51
|
+
get: (...params: unknown[]) => unknown;
|
|
52
|
+
finalize: () => void;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
type SQLiteDatabase = {
|
|
56
|
+
prepare: (sql: string) => SQLiteStatement;
|
|
57
|
+
close: () => void;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
type SQLiteConstructor = new (path: string) => SQLiteDatabase;
|
|
61
|
+
|
|
47
62
|
type DownloadLockHandle = {
|
|
48
63
|
fd: number;
|
|
49
64
|
path: string;
|
|
@@ -53,15 +68,31 @@ type DownloadLockHandle = {
|
|
|
53
68
|
* Opens read-write — see probeDb's note: freshly written WAL-mode files can
|
|
54
69
|
* fail to open readonly on macOS when the .shm file is missing. */
|
|
55
70
|
function dbHasData(dbPath: string): boolean {
|
|
71
|
+
return hasMinimumDbContent(probeDb(dbPath));
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function looksLikeSqliteFile(dbPath: string): boolean {
|
|
56
75
|
if (!existsSync(dbPath)) return false;
|
|
76
|
+
|
|
77
|
+
let fd: number | null = null;
|
|
57
78
|
try {
|
|
58
|
-
const
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
79
|
+
const stats = statSync(dbPath);
|
|
80
|
+
if (!stats.isFile() || stats.size < SQLITE_MAGIC.length) return false;
|
|
81
|
+
|
|
82
|
+
fd = openSync(dbPath, "r");
|
|
83
|
+
const header = Buffer.alloc(SQLITE_MAGIC.length);
|
|
84
|
+
const bytesRead = readSync(fd, header, 0, header.byteLength, 0);
|
|
85
|
+
return bytesRead === header.byteLength && header.toString("utf8") === SQLITE_MAGIC;
|
|
63
86
|
} catch {
|
|
64
87
|
return false;
|
|
88
|
+
} finally {
|
|
89
|
+
if (fd !== null) {
|
|
90
|
+
try {
|
|
91
|
+
closeSync(fd);
|
|
92
|
+
} catch {
|
|
93
|
+
// best-effort cleanup
|
|
94
|
+
}
|
|
95
|
+
}
|
|
65
96
|
}
|
|
66
97
|
}
|
|
67
98
|
|
|
@@ -77,20 +108,22 @@ export function probeDb(dbPath: string): {
|
|
|
77
108
|
commands: number;
|
|
78
109
|
releaseTag: string | null;
|
|
79
110
|
} | null {
|
|
111
|
+
if (!looksLikeSqliteFile(dbPath)) return null;
|
|
112
|
+
|
|
113
|
+
let check: SQLiteDatabase | null = null;
|
|
80
114
|
try {
|
|
81
|
-
const { default: sqlite } = require("bun:sqlite");
|
|
82
|
-
|
|
83
|
-
const ver =
|
|
84
|
-
const pages = check
|
|
85
|
-
const cmds = check
|
|
115
|
+
const { default: sqlite } = require("bun:sqlite") as { default: SQLiteConstructor };
|
|
116
|
+
check = new sqlite(dbPath);
|
|
117
|
+
const ver = sqliteGet<{ user_version: number }>(check, "PRAGMA user_version");
|
|
118
|
+
const pages = sqliteGet<{ c: number }>(check, "SELECT COUNT(*) AS c FROM pages");
|
|
119
|
+
const cmds = sqliteGet<{ c: number }>(check, "SELECT COUNT(*) AS c FROM commands");
|
|
86
120
|
let releaseTag: string | null = null;
|
|
87
121
|
try {
|
|
88
|
-
const meta = check
|
|
122
|
+
const meta = sqliteGet<{ value: string } | null>(check, "SELECT value FROM db_meta WHERE key = 'release_tag'");
|
|
89
123
|
releaseTag = meta?.value ?? null;
|
|
90
124
|
} catch {
|
|
91
125
|
// db_meta missing — pre-v5 schema, leave releaseTag null
|
|
92
126
|
}
|
|
93
|
-
check.close();
|
|
94
127
|
return {
|
|
95
128
|
schemaVersion: ver.user_version,
|
|
96
129
|
pages: pages.c,
|
|
@@ -99,11 +132,32 @@ export function probeDb(dbPath: string): {
|
|
|
99
132
|
};
|
|
100
133
|
} catch {
|
|
101
134
|
return null;
|
|
135
|
+
} finally {
|
|
136
|
+
if (check) {
|
|
137
|
+
try {
|
|
138
|
+
check.close();
|
|
139
|
+
} catch {
|
|
140
|
+
// best-effort cleanup; a failed probe should never leave a DB handle open
|
|
141
|
+
}
|
|
142
|
+
}
|
|
102
143
|
}
|
|
103
144
|
}
|
|
104
145
|
|
|
105
|
-
function
|
|
106
|
-
|
|
146
|
+
function sqliteGet<T>(db: SQLiteDatabase, sql: string): T {
|
|
147
|
+
const stmt = db.prepare(sql);
|
|
148
|
+
try {
|
|
149
|
+
return stmt.get() as T;
|
|
150
|
+
} finally {
|
|
151
|
+
stmt.finalize();
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export function hasMinimumDbContent(probe: DbProbe | null): probe is DbProbe {
|
|
156
|
+
return !!probe && probe.pages >= MIN_PAGES && probe.commands >= MIN_COMMANDS;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export function isUsableDbProbe(probe: DbProbe | null): probe is DbProbe {
|
|
160
|
+
return hasMinimumDbContent(probe) && probe.schemaVersion === SCHEMA_VERSION;
|
|
107
161
|
}
|
|
108
162
|
|
|
109
163
|
function lockPathFor(dbPath: string): string {
|
|
@@ -123,11 +177,11 @@ export function tryAcquireDownloadLock(dbPath: string): DownloadLockHandle | nul
|
|
|
123
177
|
const fd = openSync(lockPath, "wx");
|
|
124
178
|
writeFileSync(
|
|
125
179
|
fd,
|
|
126
|
-
JSON.stringify({
|
|
180
|
+
`${JSON.stringify({
|
|
127
181
|
pid: process.pid,
|
|
128
182
|
created_at: new Date().toISOString(),
|
|
129
183
|
db_path: dbPath,
|
|
130
|
-
})
|
|
184
|
+
})}\n`,
|
|
131
185
|
);
|
|
132
186
|
return { fd, path: lockPath };
|
|
133
187
|
} catch (e) {
|
|
@@ -171,10 +225,9 @@ export async function waitForUsableDb(
|
|
|
171
225
|
let announced = false;
|
|
172
226
|
|
|
173
227
|
while (Date.now() < deadline) {
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
if (!existsSync(lockPath)) return false;
|
|
228
|
+
if (!existsSync(lockPath)) {
|
|
229
|
+
return isUsableDbProbe(probeDb(dbPath));
|
|
230
|
+
}
|
|
178
231
|
|
|
179
232
|
if (!announced) {
|
|
180
233
|
log(` Another rosetta process is preparing ${dbPath}; waiting...`);
|
|
@@ -189,17 +242,90 @@ export async function waitForUsableDb(
|
|
|
189
242
|
return false;
|
|
190
243
|
}
|
|
191
244
|
|
|
192
|
-
function
|
|
245
|
+
function tryUnlinkDbSidecars(dbPath: string): void {
|
|
246
|
+
tryUnlink(`${dbPath}-wal`);
|
|
247
|
+
tryUnlink(`${dbPath}-shm`);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function cleanupDbArtifacts(dbPath: string): void {
|
|
251
|
+
tryUnlinkDbSidecars(dbPath);
|
|
252
|
+
tryUnlink(dbPath);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
export function cleanupStaleTempArtifacts(dbPath: string, staleMs = DOWNLOAD_LOCK_STALE_MS): number {
|
|
256
|
+
const dir = path.dirname(dbPath);
|
|
257
|
+
const prefix = `${path.basename(dbPath)}.tmp.`;
|
|
258
|
+
const now = Date.now();
|
|
259
|
+
let removed = 0;
|
|
260
|
+
|
|
261
|
+
let entries: string[];
|
|
193
262
|
try {
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
const code = e instanceof Error && "code" in e ? e.code : undefined;
|
|
198
|
-
if (code !== "EEXIST" && code !== "EPERM") throw e;
|
|
263
|
+
entries = readdirSync(dir);
|
|
264
|
+
} catch {
|
|
265
|
+
return 0;
|
|
199
266
|
}
|
|
200
267
|
|
|
201
|
-
|
|
202
|
-
|
|
268
|
+
for (const entry of entries) {
|
|
269
|
+
if (!entry.startsWith(prefix)) continue;
|
|
270
|
+
|
|
271
|
+
const fullPath = path.join(dir, entry);
|
|
272
|
+
try {
|
|
273
|
+
const ageMs = now - statSync(fullPath).mtimeMs;
|
|
274
|
+
if (ageMs <= staleMs) continue;
|
|
275
|
+
} catch {
|
|
276
|
+
continue;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
try {
|
|
280
|
+
unlinkSync(fullPath);
|
|
281
|
+
removed++;
|
|
282
|
+
} catch {
|
|
283
|
+
// best-effort cleanup
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
return removed;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
export function cleanupAbandonedTempArtifacts(dbPath: string): number {
|
|
291
|
+
if (existsSync(lockPathFor(dbPath))) {
|
|
292
|
+
return cleanupStaleTempArtifacts(dbPath);
|
|
293
|
+
}
|
|
294
|
+
return cleanupStaleTempArtifacts(dbPath, -1);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function isReplaceRaceError(e: unknown): boolean {
|
|
298
|
+
const code = e instanceof Error && "code" in e ? e.code : undefined;
|
|
299
|
+
// Windows can return EBUSY, EPERM, or EEXIST when the source or destination is open.
|
|
300
|
+
return code === "EBUSY" || code === "EEXIST" || code === "EPERM";
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
async function replaceDbFile(tmpPath: string, dbPath: string): Promise<void> {
|
|
304
|
+
const deadline = Date.now() + 30_000;
|
|
305
|
+
let lastError: unknown = null;
|
|
306
|
+
|
|
307
|
+
while (Date.now() <= deadline) {
|
|
308
|
+
try {
|
|
309
|
+
renameSync(tmpPath, dbPath);
|
|
310
|
+
return;
|
|
311
|
+
} catch (e) {
|
|
312
|
+
if (!isReplaceRaceError(e)) throw e;
|
|
313
|
+
lastError = e;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
tryUnlink(dbPath);
|
|
317
|
+
try {
|
|
318
|
+
renameSync(tmpPath, dbPath);
|
|
319
|
+
return;
|
|
320
|
+
} catch (e) {
|
|
321
|
+
if (!isReplaceRaceError(e)) throw e;
|
|
322
|
+
lastError = e;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
await Bun.sleep(250);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
throw lastError;
|
|
203
329
|
}
|
|
204
330
|
|
|
205
331
|
/** Build the version-pinned download URL. Falls back to /latest/ when no version.
|
|
@@ -230,21 +356,23 @@ export function dbDownloadUrls(version: string): string[] {
|
|
|
230
356
|
export async function downloadDb(
|
|
231
357
|
dbPath: string,
|
|
232
358
|
log: (msg: string) => void = console.log,
|
|
233
|
-
) {
|
|
359
|
+
): Promise<DbProbe> {
|
|
234
360
|
let lock = tryAcquireDownloadLock(dbPath);
|
|
235
361
|
if (!lock) {
|
|
236
362
|
const reused = await waitForUsableDb(dbPath, log);
|
|
237
363
|
if (reused) {
|
|
238
364
|
const probe = probeDb(dbPath);
|
|
239
|
-
if (probe)
|
|
240
|
-
|
|
365
|
+
if (probe) {
|
|
366
|
+
log(` Reused existing database: ${formatProbeSummary(probe)}`);
|
|
367
|
+
return probe;
|
|
368
|
+
}
|
|
241
369
|
}
|
|
242
370
|
|
|
243
371
|
// Re-probe once — the lock may have been released with a healthy DB
|
|
244
372
|
const fallbackProbe = probeDb(dbPath);
|
|
245
373
|
if (isUsableDbProbe(fallbackProbe)) {
|
|
246
374
|
log(` Reused existing database: ${formatProbeSummary(fallbackProbe)}`);
|
|
247
|
-
return;
|
|
375
|
+
return fallbackProbe;
|
|
248
376
|
}
|
|
249
377
|
|
|
250
378
|
lock = tryAcquireDownloadLock(dbPath);
|
|
@@ -260,6 +388,11 @@ export async function downloadDb(
|
|
|
260
388
|
let lastError: Error | null = null;
|
|
261
389
|
|
|
262
390
|
try {
|
|
391
|
+
const cleaned = cleanupStaleTempArtifacts(dbPath);
|
|
392
|
+
if (cleaned > 0) {
|
|
393
|
+
log(` Removed ${cleaned} stale temp DB artifact${cleaned === 1 ? "" : "s"}.`);
|
|
394
|
+
}
|
|
395
|
+
|
|
263
396
|
for (let i = 0; i < urls.length; i++) {
|
|
264
397
|
const url = urls[i];
|
|
265
398
|
const isLast = i === urls.length - 1;
|
|
@@ -332,25 +465,25 @@ export async function downloadDb(
|
|
|
332
465
|
|
|
333
466
|
const probe = probeDb(tmpPath);
|
|
334
467
|
if (!probe) {
|
|
335
|
-
|
|
468
|
+
cleanupDbArtifacts(tmpPath);
|
|
336
469
|
lastError = new Error("Downloaded DB failed to open with SQLite");
|
|
337
470
|
if (isLast) throw lastError;
|
|
338
471
|
log(` ${lastError.message} — trying fallback...`);
|
|
339
472
|
continue;
|
|
340
473
|
}
|
|
341
474
|
if (probe.schemaVersion !== SCHEMA_VERSION) {
|
|
342
|
-
|
|
475
|
+
cleanupDbArtifacts(tmpPath);
|
|
343
476
|
lastError = new Error(
|
|
344
|
-
|
|
477
|
+
`Downloaded DB schema=${probe.schemaVersion} does not match this rosetta build (expected ${SCHEMA_VERSION}). ` +
|
|
345
478
|
`This usually means the cached package version is older than the published DB. ` +
|
|
346
|
-
`Run
|
|
479
|
+
`Run: bunx @tikoci/rosetta@latest --refresh`,
|
|
347
480
|
);
|
|
348
481
|
if (isLast) throw lastError;
|
|
349
482
|
log(` ${lastError.message}`);
|
|
350
483
|
continue;
|
|
351
484
|
}
|
|
352
485
|
if (probe.pages < MIN_PAGES || probe.commands < MIN_COMMANDS) {
|
|
353
|
-
|
|
486
|
+
cleanupDbArtifacts(tmpPath);
|
|
354
487
|
lastError = new Error(
|
|
355
488
|
`Downloaded DB content looks incomplete (pages=${probe.pages}, commands=${probe.commands})`,
|
|
356
489
|
);
|
|
@@ -361,25 +494,19 @@ export async function downloadDb(
|
|
|
361
494
|
|
|
362
495
|
// Validation passed — drop stale WAL/SHM and atomically swap.
|
|
363
496
|
try {
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
replaceDbFile(tmpPath, dbPath);
|
|
497
|
+
tryUnlinkDbSidecars(tmpPath);
|
|
498
|
+
tryUnlinkDbSidecars(dbPath);
|
|
499
|
+
await replaceDbFile(tmpPath, dbPath);
|
|
367
500
|
} catch (e) {
|
|
368
501
|
const existingProbe = probeDb(dbPath);
|
|
369
|
-
if (
|
|
370
|
-
|
|
371
|
-
existingProbe.schemaVersion === probe.schemaVersion &&
|
|
372
|
-
existingProbe.releaseTag === probe.releaseTag &&
|
|
373
|
-
existingProbe.pages >= MIN_PAGES &&
|
|
374
|
-
existingProbe.commands >= MIN_COMMANDS
|
|
375
|
-
) {
|
|
376
|
-
tryUnlink(tmpPath);
|
|
502
|
+
if (hasMinimumDbContent(existingProbe) && existingProbe.schemaVersion === probe.schemaVersion && existingProbe.releaseTag === probe.releaseTag) {
|
|
503
|
+
cleanupDbArtifacts(tmpPath);
|
|
377
504
|
log(` Another rosetta process already installed the same database.`);
|
|
378
505
|
log(` Reused existing database: ${formatProbeSummary(existingProbe)}`);
|
|
379
|
-
return;
|
|
506
|
+
return existingProbe;
|
|
380
507
|
}
|
|
381
508
|
|
|
382
|
-
|
|
509
|
+
cleanupDbArtifacts(tmpPath);
|
|
383
510
|
throw e;
|
|
384
511
|
}
|
|
385
512
|
|
|
@@ -387,7 +514,7 @@ export async function downloadDb(
|
|
|
387
514
|
const tagInfo = probe.releaseTag ? ` (release ${probe.releaseTag})` : "";
|
|
388
515
|
log(` Wrote ${sizeMB} MB to ${dbPath}${tagInfo}`);
|
|
389
516
|
log(` Validated: schema v${probe.schemaVersion}, ${probe.pages} pages, ${probe.commands} commands.`);
|
|
390
|
-
return;
|
|
517
|
+
return probe;
|
|
391
518
|
}
|
|
392
519
|
|
|
393
520
|
throw lastError ?? new Error("Database download failed for unknown reasons");
|
|
@@ -412,17 +539,13 @@ function tryUnlink(p: string): void {
|
|
|
412
539
|
*/
|
|
413
540
|
export async function refreshDb(log: (msg: string) => void = console.log): Promise<boolean> {
|
|
414
541
|
const dbPath = resolveDbPath(import.meta.dirname);
|
|
542
|
+
let probe: DbProbe;
|
|
415
543
|
try {
|
|
416
|
-
await downloadDb(dbPath, log);
|
|
544
|
+
probe = await downloadDb(dbPath, log);
|
|
417
545
|
} catch (e) {
|
|
418
546
|
log(`✗ Refresh failed: ${e instanceof Error ? e.message : e}`);
|
|
419
547
|
return false;
|
|
420
548
|
}
|
|
421
|
-
const probe = probeDb(dbPath);
|
|
422
|
-
if (!probe) {
|
|
423
|
-
log(`✗ Post-download probe failed`);
|
|
424
|
-
return false;
|
|
425
|
-
}
|
|
426
549
|
const tagInfo = probe.releaseTag ? ` (release ${probe.releaseTag})` : "";
|
|
427
550
|
log(`✓ Database ready${tagInfo}: ${probe.pages} pages, ${probe.commands} commands, schema v${probe.schemaVersion}`);
|
|
428
551
|
return true;
|
|
@@ -431,6 +554,7 @@ export async function refreshDb(log: (msg: string) => void = console.log): Promi
|
|
|
431
554
|
export async function runSetup(force = false) {
|
|
432
555
|
const mode = detectMode(import.meta.dirname);
|
|
433
556
|
const dbPath = resolveDbPath(import.meta.dirname);
|
|
557
|
+
let downloadedProbe: DbProbe | null = null;
|
|
434
558
|
|
|
435
559
|
console.log(`rosetta ${RELEASE_VERSION}`);
|
|
436
560
|
console.log(` ${link("https://github.com/tikoci/rosetta")}`);
|
|
@@ -443,7 +567,7 @@ export async function runSetup(force = false) {
|
|
|
443
567
|
console.log(` (use --refresh or --setup --force to re-download)`);
|
|
444
568
|
} else {
|
|
445
569
|
try {
|
|
446
|
-
await downloadDb(dbPath);
|
|
570
|
+
downloadedProbe = await downloadDb(dbPath);
|
|
447
571
|
} catch (e) {
|
|
448
572
|
console.error(`✗ Database download failed: ${e instanceof Error ? e.message : e}`);
|
|
449
573
|
process.exit(1);
|
|
@@ -452,7 +576,7 @@ export async function runSetup(force = false) {
|
|
|
452
576
|
|
|
453
577
|
// ── Validate DB ──
|
|
454
578
|
console.log();
|
|
455
|
-
const probe = probeDb(dbPath);
|
|
579
|
+
const probe = downloadedProbe ?? probeDb(dbPath);
|
|
456
580
|
if (!probe) {
|
|
457
581
|
console.error(`✗ Database validation failed: cannot open ${dbPath}`);
|
|
458
582
|
const retryCmd = mode === "compiled" ? "rosetta" : mode === "package" ? "bunx @tikoci/rosetta" : "bun run src/setup.ts";
|
|
@@ -464,7 +588,7 @@ export async function runSetup(force = false) {
|
|
|
464
588
|
`✗ DB schema version is ${probe.schemaVersion}, expected ${SCHEMA_VERSION}.`,
|
|
465
589
|
);
|
|
466
590
|
console.error(
|
|
467
|
-
`
|
|
591
|
+
` Package may be out of date. Run: bunx @tikoci/rosetta@latest --refresh`,
|
|
468
592
|
);
|
|
469
593
|
process.exit(1);
|
|
470
594
|
}
|