@tikoci/rosetta 0.8.11 → 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.11",
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,14 +61,14 @@ 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, hasMinimumDbContent, probeDb, cleanupStaleTempArtifacts } = await import("./setup.ts");
64
+ const { cleanupAbandonedTempArtifacts, downloadDb, hasMinimumDbContent, probeDb } = await import("./setup.ts");
65
65
 
66
66
  const dbPath = resolveDbPath(import.meta.dirname);
67
67
  const runningVersion = resolveVersion(import.meta.dirname);
68
68
 
69
- // Always clean stale .tmp.* artifacts from previous failed runs, regardless of
70
- // whether a download is needed this time.
71
- cleanupStaleTempArtifacts(dbPath);
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);
72
72
 
73
73
  let p = probeDb(dbPath);
74
74
 
@@ -80,7 +80,7 @@ async function ensureDbReady(log: (msg: string) => void): Promise<void> {
80
80
  log("Database downloaded successfully.");
81
81
  } catch (e) {
82
82
  log(`Auto-download failed: ${e instanceof Error ? e.message : e}`);
83
- log(`Close other rosetta clients and run: bunx @tikoci/rosetta --refresh`);
83
+ log(`Close other rosetta clients and run: bunx @tikoci/rosetta@latest --refresh`);
84
84
  throw new Error(`Unable to start rosetta without a usable database at ${dbPath}.`);
85
85
  }
86
86
  }
@@ -225,9 +225,16 @@ describe("setup.ts", () => {
225
225
  test("setup.ts cleans temp DB artifacts instead of accumulating stale .tmp files", () => {
226
226
  const src = readText("src/setup.ts");
227
227
  expect(src).toContain("cleanupStaleTempArtifacts");
228
+ expect(src).toContain("cleanupAbandonedTempArtifacts");
228
229
  expect(src).toContain("tryUnlinkDbSidecars(tmpPath)");
229
230
  });
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");
236
+ });
237
+
231
238
  test("no DB open uses { readonly: true } (WAL-shm init trap on macOS)", () => {
232
239
  // Freshly-renamed WAL-mode DBs fail to open readonly on macOS until a
233
240
  // read-write connection initialises the .shm file. downloadDb explicitly
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
 
@@ -21,6 +21,7 @@ afterAll(() => {
21
21
  });
22
22
 
23
23
  const {
24
+ cleanupAbandonedTempArtifacts,
24
25
  cleanupStaleTempArtifacts,
25
26
  dbDownloadUrls,
26
27
  probeDb,
@@ -178,6 +179,19 @@ describe("probeDb", () => {
178
179
  expect(probe?.releaseTag).toBe("v0.0.0-test");
179
180
  });
180
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
+
181
195
  test("opens a freshly-renamed WAL-mode DB with no .shm sibling", () => {
182
196
  // Reproduces the exact state downloadDb leaves the DB in: journal_mode=WAL
183
197
  // on disk, but the .wal/.shm siblings are deleted just before the rename.
@@ -242,3 +256,39 @@ describe("cleanupStaleTempArtifacts", () => {
242
256
  }
243
257
  });
244
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
@@ -47,6 +47,18 @@ type DbProbe = {
47
47
  releaseTag: string | null;
48
48
  };
49
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
+
50
62
  type DownloadLockHandle = {
51
63
  fd: number;
52
64
  path: string;
@@ -98,20 +110,20 @@ export function probeDb(dbPath: string): {
98
110
  } | null {
99
111
  if (!looksLikeSqliteFile(dbPath)) return null;
100
112
 
113
+ let check: SQLiteDatabase | null = null;
101
114
  try {
102
- const { default: sqlite } = require("bun:sqlite");
103
- const check = new sqlite(dbPath);
104
- const ver = check.prepare("PRAGMA user_version").get() as { user_version: number };
105
- const pages = check.prepare("SELECT COUNT(*) AS c FROM pages").get() as { c: number };
106
- 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");
107
120
  let releaseTag: string | null = null;
108
121
  try {
109
- 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'");
110
123
  releaseTag = meta?.value ?? null;
111
124
  } catch {
112
125
  // db_meta missing — pre-v5 schema, leave releaseTag null
113
126
  }
114
- check.close();
115
127
  return {
116
128
  schemaVersion: ver.user_version,
117
129
  pages: pages.c,
@@ -120,6 +132,23 @@ export function probeDb(dbPath: string): {
120
132
  };
121
133
  } catch {
122
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
+ }
143
+ }
144
+ }
145
+
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();
123
152
  }
124
153
  }
125
154
 
@@ -258,18 +287,45 @@ export function cleanupStaleTempArtifacts(dbPath: string, staleMs = DOWNLOAD_LOC
258
287
  return removed;
259
288
  }
260
289
 
261
- function replaceDbFile(tmpPath: string, dbPath: string): void {
262
- try {
263
- renameSync(tmpPath, dbPath);
264
- return;
265
- } catch (e) {
266
- const code = e instanceof Error && "code" in e ? e.code : undefined;
267
- // Windows can return EBUSY, EPERM, or EEXIST when the destination is open.
268
- if (code !== "EBUSY" && code !== "EEXIST" && code !== "EPERM") throw e;
290
+ export function cleanupAbandonedTempArtifacts(dbPath: string): number {
291
+ if (existsSync(lockPathFor(dbPath))) {
292
+ return cleanupStaleTempArtifacts(dbPath);
269
293
  }
294
+ return cleanupStaleTempArtifacts(dbPath, -1);
295
+ }
270
296
 
271
- tryUnlink(dbPath);
272
- renameSync(tmpPath, dbPath);
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;
273
329
  }
274
330
 
275
331
  /** Build the version-pinned download URL. Falls back to /latest/ when no version.
@@ -418,9 +474,9 @@ export async function downloadDb(
418
474
  if (probe.schemaVersion !== SCHEMA_VERSION) {
419
475
  cleanupDbArtifacts(tmpPath);
420
476
  lastError = new Error(
421
- `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}). ` +
422
478
  `This usually means the cached package version is older than the published DB. ` +
423
- `Run \`bun pm cache rm\` and relaunch to pick up the latest package.`,
479
+ `Run: bunx @tikoci/rosetta@latest --refresh`,
424
480
  );
425
481
  if (isLast) throw lastError;
426
482
  log(` ${lastError.message}`);
@@ -440,7 +496,7 @@ export async function downloadDb(
440
496
  try {
441
497
  tryUnlinkDbSidecars(tmpPath);
442
498
  tryUnlinkDbSidecars(dbPath);
443
- replaceDbFile(tmpPath, dbPath);
499
+ await replaceDbFile(tmpPath, dbPath);
444
500
  } catch (e) {
445
501
  const existingProbe = probeDb(dbPath);
446
502
  if (hasMinimumDbContent(existingProbe) && existingProbe.schemaVersion === probe.schemaVersion && existingProbe.releaseTag === probe.releaseTag) {