@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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tikoci/rosetta",
3
- "version": "0.8.10",
3
+ "version": "0.8.12",
4
4
  "description": "RouterOS documentation as SQLite FTS5 — RAG search + command glossary via MCP",
5
5
  "type": "module",
6
6
  "license": "MIT",
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
- /** Probe an existing DB. Returns null if the file is missing or unreadable.
71
- * Do NOT pass { readonly: true } freshly written SQLite WAL-mode files fail
72
- * to open readonly on macOS until a read-write connection initialises the WAL
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 = probe();
73
+ let p = probeDb(dbPath);
94
74
 
95
75
  // Case 1: DB missing or empty → first-time download.
96
- if (!p || p.pages === 0) {
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.pages === 0) {
114
- throw new Error(`Database remained empty after recovery: ${dbPath}`);
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, then run \`bun pm cache rm && bunx @tikoci/rosetta --refresh\`.`,
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
- const p2 = probe();
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=${p2?.schemaVersion ?? "unreadable"}, expected=${SCHEMA_VERSION}).`,
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 \`bun pm cache rm && bunx @tikoci/rosetta --refresh\` to update both the package and the DB.`,
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.
@@ -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 empty after recovery");
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 { dbDownloadUrls, probeDb, releaseDownloadLock, tryAcquireDownloadLock, waitForUsableDb } = await import("./setup.ts");
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
- expect(probeDb(path.join(tmp, "does-not-exist.db"))).toBeNull();
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 { default: sqlite } = require("bun:sqlite");
59
- const check = new sqlite(dbPath);
60
- const row = check.prepare("SELECT COUNT(*) AS c FROM pages").get() as { c: number };
61
- check.close();
62
- return row.c > 0;
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
- const check = new sqlite(dbPath);
83
- const ver = check.prepare("PRAGMA user_version").get() as { user_version: number };
84
- const pages = check.prepare("SELECT COUNT(*) AS c FROM pages").get() as { c: number };
85
- const cmds = check.prepare("SELECT COUNT(*) AS c FROM commands").get() as { c: number };
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.prepare("SELECT value FROM db_meta WHERE key = 'release_tag'").get() as { value: string } | null;
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 isUsableDbProbe(probe: DbProbe | null): probe is DbProbe {
106
- return !!probe && probe.schemaVersion === SCHEMA_VERSION && probe.pages >= MIN_PAGES && probe.commands >= MIN_COMMANDS;
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
- }) + "\n",
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
- const probe = probeDb(dbPath);
175
- if (isUsableDbProbe(probe)) return true;
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 replaceDbFile(tmpPath: string, dbPath: string): void {
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
- renameSync(tmpPath, dbPath);
195
- return;
196
- } catch (e) {
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
- tryUnlink(dbPath);
202
- renameSync(tmpPath, dbPath);
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) log(` Reused existing database: ${formatProbeSummary(probe)}`);
240
- return;
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
- tryUnlink(tmpPath);
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
- tryUnlink(tmpPath);
475
+ cleanupDbArtifacts(tmpPath);
343
476
  lastError = new Error(
344
- `Downloaded DB schema=${probe.schemaVersion} does not match this rosetta build (expected ${SCHEMA_VERSION}). ` +
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 \`bun pm cache rm\` and relaunch to pick up the latest package.`,
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
- tryUnlink(tmpPath);
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
- tryUnlink(`${dbPath}-wal`);
365
- tryUnlink(`${dbPath}-shm`);
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
- existingProbe &&
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
- tryUnlink(tmpPath);
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
- ` Cached package may be out of date. Run \`bun pm cache rm\` and relaunch.`,
591
+ ` Package may be out of date. Run: bunx @tikoci/rosetta@latest --refresh`,
468
592
  );
469
593
  process.exit(1);
470
594
  }