@tikoci/rosetta 0.8.10 → 0.8.11

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tikoci/rosetta",
3
- "version": "0.8.10",
3
+ "version": "0.8.11",
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,44 +61,23 @@ 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 { downloadDb, hasMinimumDbContent, probeDb, cleanupStaleTempArtifacts } = 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 stale .tmp.* artifacts from previous failed runs, regardless of
70
+ // whether a download is needed this time.
71
+ cleanupStaleTempArtifacts(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
83
  log(`Close other rosetta clients and run: bunx @tikoci/rosetta --refresh`);
@@ -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,13 @@ 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("tryUnlinkDbSidecars(tmpPath)");
223
229
  });
224
230
 
225
231
  test("no DB open uses { readonly: true } (WAL-shm init trap on macOS)", () => {
package/src/setup.test.ts CHANGED
@@ -20,7 +20,14 @@ afterAll(() => {
20
20
  } catch {}
21
21
  });
22
22
 
23
- const { dbDownloadUrls, probeDb, releaseDownloadLock, tryAcquireDownloadLock, waitForUsableDb } = await import("./setup.ts");
23
+ const {
24
+ cleanupStaleTempArtifacts,
25
+ dbDownloadUrls,
26
+ probeDb,
27
+ releaseDownloadLock,
28
+ tryAcquireDownloadLock,
29
+ waitForUsableDb,
30
+ } = await import("./setup.ts");
24
31
  const { SCHEMA_VERSION } = await import("./paths.ts");
25
32
 
26
33
  function writeUsableDb(dbFile: string, releaseTag = "v0.0.0-test"): void {
@@ -115,6 +122,20 @@ describe("download lock helpers", () => {
115
122
  expect(probe?.pages).toBe(100);
116
123
  expect(probe?.commands).toBe(1000);
117
124
  });
125
+
126
+ test("waitForUsableDb does not create a missing canonical DB while waiting", async () => {
127
+ const dbFile = path.join(tmp, "wait-no-create.db");
128
+ const lock = tryAcquireDownloadLock(dbFile);
129
+ expect(lock).not.toBeNull();
130
+
131
+ const waiter = waitForUsableDb(dbFile, () => {}, 2_000);
132
+ await Bun.sleep(100);
133
+ expect(existsSync(dbFile)).toBe(false);
134
+
135
+ releaseDownloadLock(lock);
136
+ expect(await waiter).toBe(false);
137
+ expect(existsSync(dbFile)).toBe(false);
138
+ });
118
139
  });
119
140
 
120
141
  // ---------------------------------------------------------------------------
@@ -123,7 +144,9 @@ describe("download lock helpers", () => {
123
144
 
124
145
  describe("probeDb", () => {
125
146
  test("returns null for a missing file", () => {
126
- expect(probeDb(path.join(tmp, "does-not-exist.db"))).toBeNull();
147
+ const missing = path.join(tmp, "does-not-exist.db");
148
+ expect(probeDb(missing)).toBeNull();
149
+ expect(existsSync(missing)).toBe(false);
127
150
  });
128
151
 
129
152
  test("returns null for a non-SQLite file", () => {
@@ -199,3 +222,23 @@ describe("probeDb", () => {
199
222
  expect(probe?.releaseTag).toBeNull();
200
223
  });
201
224
  });
225
+
226
+ describe("cleanupStaleTempArtifacts", () => {
227
+ test("removes stale temp DB files and sidecars", () => {
228
+ const dbFile = path.join(tmp, "cleanup.db");
229
+ const artifacts = [
230
+ `${dbFile}.tmp.111`,
231
+ `${dbFile}.tmp.111-wal`,
232
+ `${dbFile}.tmp.111-shm`,
233
+ ];
234
+
235
+ for (const artifact of artifacts) {
236
+ writeFileSync(artifact, "x");
237
+ }
238
+
239
+ expect(cleanupStaleTempArtifacts(dbFile, -1)).toBe(3);
240
+ for (const artifact of artifacts) {
241
+ expect(existsSync(artifact)).toBe(false);
242
+ }
243
+ });
244
+ });
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
 
@@ -53,15 +56,31 @@ type DownloadLockHandle = {
53
56
  * Opens read-write — see probeDb's note: freshly written WAL-mode files can
54
57
  * fail to open readonly on macOS when the .shm file is missing. */
55
58
  function dbHasData(dbPath: string): boolean {
59
+ return hasMinimumDbContent(probeDb(dbPath));
60
+ }
61
+
62
+ function looksLikeSqliteFile(dbPath: string): boolean {
56
63
  if (!existsSync(dbPath)) return false;
64
+
65
+ let fd: number | null = null;
57
66
  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;
67
+ const stats = statSync(dbPath);
68
+ if (!stats.isFile() || stats.size < SQLITE_MAGIC.length) return false;
69
+
70
+ fd = openSync(dbPath, "r");
71
+ const header = Buffer.alloc(SQLITE_MAGIC.length);
72
+ const bytesRead = readSync(fd, header, 0, header.byteLength, 0);
73
+ return bytesRead === header.byteLength && header.toString("utf8") === SQLITE_MAGIC;
63
74
  } catch {
64
75
  return false;
76
+ } finally {
77
+ if (fd !== null) {
78
+ try {
79
+ closeSync(fd);
80
+ } catch {
81
+ // best-effort cleanup
82
+ }
83
+ }
65
84
  }
66
85
  }
67
86
 
@@ -77,6 +96,8 @@ export function probeDb(dbPath: string): {
77
96
  commands: number;
78
97
  releaseTag: string | null;
79
98
  } | null {
99
+ if (!looksLikeSqliteFile(dbPath)) return null;
100
+
80
101
  try {
81
102
  const { default: sqlite } = require("bun:sqlite");
82
103
  const check = new sqlite(dbPath);
@@ -102,8 +123,12 @@ export function probeDb(dbPath: string): {
102
123
  }
103
124
  }
104
125
 
105
- function isUsableDbProbe(probe: DbProbe | null): probe is DbProbe {
106
- return !!probe && probe.schemaVersion === SCHEMA_VERSION && probe.pages >= MIN_PAGES && probe.commands >= MIN_COMMANDS;
126
+ export function hasMinimumDbContent(probe: DbProbe | null): probe is DbProbe {
127
+ return !!probe && probe.pages >= MIN_PAGES && probe.commands >= MIN_COMMANDS;
128
+ }
129
+
130
+ export function isUsableDbProbe(probe: DbProbe | null): probe is DbProbe {
131
+ return hasMinimumDbContent(probe) && probe.schemaVersion === SCHEMA_VERSION;
107
132
  }
108
133
 
109
134
  function lockPathFor(dbPath: string): string {
@@ -123,11 +148,11 @@ export function tryAcquireDownloadLock(dbPath: string): DownloadLockHandle | nul
123
148
  const fd = openSync(lockPath, "wx");
124
149
  writeFileSync(
125
150
  fd,
126
- JSON.stringify({
151
+ `${JSON.stringify({
127
152
  pid: process.pid,
128
153
  created_at: new Date().toISOString(),
129
154
  db_path: dbPath,
130
- }) + "\n",
155
+ })}\n`,
131
156
  );
132
157
  return { fd, path: lockPath };
133
158
  } catch (e) {
@@ -171,10 +196,9 @@ export async function waitForUsableDb(
171
196
  let announced = false;
172
197
 
173
198
  while (Date.now() < deadline) {
174
- const probe = probeDb(dbPath);
175
- if (isUsableDbProbe(probe)) return true;
176
-
177
- if (!existsSync(lockPath)) return false;
199
+ if (!existsSync(lockPath)) {
200
+ return isUsableDbProbe(probeDb(dbPath));
201
+ }
178
202
 
179
203
  if (!announced) {
180
204
  log(` Another rosetta process is preparing ${dbPath}; waiting...`);
@@ -189,13 +213,59 @@ export async function waitForUsableDb(
189
213
  return false;
190
214
  }
191
215
 
216
+ function tryUnlinkDbSidecars(dbPath: string): void {
217
+ tryUnlink(`${dbPath}-wal`);
218
+ tryUnlink(`${dbPath}-shm`);
219
+ }
220
+
221
+ function cleanupDbArtifacts(dbPath: string): void {
222
+ tryUnlinkDbSidecars(dbPath);
223
+ tryUnlink(dbPath);
224
+ }
225
+
226
+ export function cleanupStaleTempArtifacts(dbPath: string, staleMs = DOWNLOAD_LOCK_STALE_MS): number {
227
+ const dir = path.dirname(dbPath);
228
+ const prefix = `${path.basename(dbPath)}.tmp.`;
229
+ const now = Date.now();
230
+ let removed = 0;
231
+
232
+ let entries: string[];
233
+ try {
234
+ entries = readdirSync(dir);
235
+ } catch {
236
+ return 0;
237
+ }
238
+
239
+ for (const entry of entries) {
240
+ if (!entry.startsWith(prefix)) continue;
241
+
242
+ const fullPath = path.join(dir, entry);
243
+ try {
244
+ const ageMs = now - statSync(fullPath).mtimeMs;
245
+ if (ageMs <= staleMs) continue;
246
+ } catch {
247
+ continue;
248
+ }
249
+
250
+ try {
251
+ unlinkSync(fullPath);
252
+ removed++;
253
+ } catch {
254
+ // best-effort cleanup
255
+ }
256
+ }
257
+
258
+ return removed;
259
+ }
260
+
192
261
  function replaceDbFile(tmpPath: string, dbPath: string): void {
193
262
  try {
194
263
  renameSync(tmpPath, dbPath);
195
264
  return;
196
265
  } catch (e) {
197
266
  const code = e instanceof Error && "code" in e ? e.code : undefined;
198
- if (code !== "EEXIST" && code !== "EPERM") throw e;
267
+ // Windows can return EBUSY, EPERM, or EEXIST when the destination is open.
268
+ if (code !== "EBUSY" && code !== "EEXIST" && code !== "EPERM") throw e;
199
269
  }
200
270
 
201
271
  tryUnlink(dbPath);
@@ -230,21 +300,23 @@ export function dbDownloadUrls(version: string): string[] {
230
300
  export async function downloadDb(
231
301
  dbPath: string,
232
302
  log: (msg: string) => void = console.log,
233
- ) {
303
+ ): Promise<DbProbe> {
234
304
  let lock = tryAcquireDownloadLock(dbPath);
235
305
  if (!lock) {
236
306
  const reused = await waitForUsableDb(dbPath, log);
237
307
  if (reused) {
238
308
  const probe = probeDb(dbPath);
239
- if (probe) log(` Reused existing database: ${formatProbeSummary(probe)}`);
240
- return;
309
+ if (probe) {
310
+ log(` Reused existing database: ${formatProbeSummary(probe)}`);
311
+ return probe;
312
+ }
241
313
  }
242
314
 
243
315
  // Re-probe once — the lock may have been released with a healthy DB
244
316
  const fallbackProbe = probeDb(dbPath);
245
317
  if (isUsableDbProbe(fallbackProbe)) {
246
318
  log(` Reused existing database: ${formatProbeSummary(fallbackProbe)}`);
247
- return;
319
+ return fallbackProbe;
248
320
  }
249
321
 
250
322
  lock = tryAcquireDownloadLock(dbPath);
@@ -260,6 +332,11 @@ export async function downloadDb(
260
332
  let lastError: Error | null = null;
261
333
 
262
334
  try {
335
+ const cleaned = cleanupStaleTempArtifacts(dbPath);
336
+ if (cleaned > 0) {
337
+ log(` Removed ${cleaned} stale temp DB artifact${cleaned === 1 ? "" : "s"}.`);
338
+ }
339
+
263
340
  for (let i = 0; i < urls.length; i++) {
264
341
  const url = urls[i];
265
342
  const isLast = i === urls.length - 1;
@@ -332,14 +409,14 @@ export async function downloadDb(
332
409
 
333
410
  const probe = probeDb(tmpPath);
334
411
  if (!probe) {
335
- tryUnlink(tmpPath);
412
+ cleanupDbArtifacts(tmpPath);
336
413
  lastError = new Error("Downloaded DB failed to open with SQLite");
337
414
  if (isLast) throw lastError;
338
415
  log(` ${lastError.message} — trying fallback...`);
339
416
  continue;
340
417
  }
341
418
  if (probe.schemaVersion !== SCHEMA_VERSION) {
342
- tryUnlink(tmpPath);
419
+ cleanupDbArtifacts(tmpPath);
343
420
  lastError = new Error(
344
421
  `Downloaded DB schema=${probe.schemaVersion} does not match this rosetta build (expected ${SCHEMA_VERSION}). ` +
345
422
  `This usually means the cached package version is older than the published DB. ` +
@@ -350,7 +427,7 @@ export async function downloadDb(
350
427
  continue;
351
428
  }
352
429
  if (probe.pages < MIN_PAGES || probe.commands < MIN_COMMANDS) {
353
- tryUnlink(tmpPath);
430
+ cleanupDbArtifacts(tmpPath);
354
431
  lastError = new Error(
355
432
  `Downloaded DB content looks incomplete (pages=${probe.pages}, commands=${probe.commands})`,
356
433
  );
@@ -361,25 +438,19 @@ export async function downloadDb(
361
438
 
362
439
  // Validation passed — drop stale WAL/SHM and atomically swap.
363
440
  try {
364
- tryUnlink(`${dbPath}-wal`);
365
- tryUnlink(`${dbPath}-shm`);
441
+ tryUnlinkDbSidecars(tmpPath);
442
+ tryUnlinkDbSidecars(dbPath);
366
443
  replaceDbFile(tmpPath, dbPath);
367
444
  } catch (e) {
368
445
  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);
446
+ if (hasMinimumDbContent(existingProbe) && existingProbe.schemaVersion === probe.schemaVersion && existingProbe.releaseTag === probe.releaseTag) {
447
+ cleanupDbArtifacts(tmpPath);
377
448
  log(` Another rosetta process already installed the same database.`);
378
449
  log(` Reused existing database: ${formatProbeSummary(existingProbe)}`);
379
- return;
450
+ return existingProbe;
380
451
  }
381
452
 
382
- tryUnlink(tmpPath);
453
+ cleanupDbArtifacts(tmpPath);
383
454
  throw e;
384
455
  }
385
456
 
@@ -387,7 +458,7 @@ export async function downloadDb(
387
458
  const tagInfo = probe.releaseTag ? ` (release ${probe.releaseTag})` : "";
388
459
  log(` Wrote ${sizeMB} MB to ${dbPath}${tagInfo}`);
389
460
  log(` Validated: schema v${probe.schemaVersion}, ${probe.pages} pages, ${probe.commands} commands.`);
390
- return;
461
+ return probe;
391
462
  }
392
463
 
393
464
  throw lastError ?? new Error("Database download failed for unknown reasons");
@@ -412,17 +483,13 @@ function tryUnlink(p: string): void {
412
483
  */
413
484
  export async function refreshDb(log: (msg: string) => void = console.log): Promise<boolean> {
414
485
  const dbPath = resolveDbPath(import.meta.dirname);
486
+ let probe: DbProbe;
415
487
  try {
416
- await downloadDb(dbPath, log);
488
+ probe = await downloadDb(dbPath, log);
417
489
  } catch (e) {
418
490
  log(`✗ Refresh failed: ${e instanceof Error ? e.message : e}`);
419
491
  return false;
420
492
  }
421
- const probe = probeDb(dbPath);
422
- if (!probe) {
423
- log(`✗ Post-download probe failed`);
424
- return false;
425
- }
426
493
  const tagInfo = probe.releaseTag ? ` (release ${probe.releaseTag})` : "";
427
494
  log(`✓ Database ready${tagInfo}: ${probe.pages} pages, ${probe.commands} commands, schema v${probe.schemaVersion}`);
428
495
  return true;
@@ -431,6 +498,7 @@ export async function refreshDb(log: (msg: string) => void = console.log): Promi
431
498
  export async function runSetup(force = false) {
432
499
  const mode = detectMode(import.meta.dirname);
433
500
  const dbPath = resolveDbPath(import.meta.dirname);
501
+ let downloadedProbe: DbProbe | null = null;
434
502
 
435
503
  console.log(`rosetta ${RELEASE_VERSION}`);
436
504
  console.log(` ${link("https://github.com/tikoci/rosetta")}`);
@@ -443,7 +511,7 @@ export async function runSetup(force = false) {
443
511
  console.log(` (use --refresh or --setup --force to re-download)`);
444
512
  } else {
445
513
  try {
446
- await downloadDb(dbPath);
514
+ downloadedProbe = await downloadDb(dbPath);
447
515
  } catch (e) {
448
516
  console.error(`✗ Database download failed: ${e instanceof Error ? e.message : e}`);
449
517
  process.exit(1);
@@ -452,7 +520,7 @@ export async function runSetup(force = false) {
452
520
 
453
521
  // ── Validate DB ──
454
522
  console.log();
455
- const probe = probeDb(dbPath);
523
+ const probe = downloadedProbe ?? probeDb(dbPath);
456
524
  if (!probe) {
457
525
  console.error(`✗ Database validation failed: cannot open ${dbPath}`);
458
526
  const retryCmd = mode === "compiled" ? "rosetta" : mode === "package" ? "bunx @tikoci/rosetta" : "bun run src/setup.ts";
@@ -464,7 +532,7 @@ export async function runSetup(force = false) {
464
532
  `✗ DB schema version is ${probe.schemaVersion}, expected ${SCHEMA_VERSION}.`,
465
533
  );
466
534
  console.error(
467
- ` Cached package may be out of date. Run \`bun pm cache rm\` and relaunch.`,
535
+ ` Package may be out of date. Run: bunx @tikoci/rosetta@latest --refresh`,
468
536
  );
469
537
  process.exit(1);
470
538
  }