@tikoci/rosetta 0.8.9 → 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/README.md +3 -2
- package/package.json +1 -1
- package/src/browse.ts +12 -2
- package/src/canonicalize-resolver.ts +59 -0
- package/src/canonicalize.fuzz.test.ts +371 -0
- package/src/canonicalize.test.ts +68 -0
- package/src/canonicalize.ts +240 -20
- package/src/classify.test.ts +16 -3
- package/src/classify.ts +36 -8
- package/src/extract-videos.ts +46 -4
- package/src/gc-versions.test.ts +201 -0
- package/src/gc-versions.ts +230 -0
- package/src/mcp-contract.test.ts +7 -10
- package/src/mcp-http.test.ts +6 -5
- package/src/mcp.ts +91 -49
- package/src/query.test.ts +146 -3
- package/src/query.ts +252 -14
- package/src/release.test.ts +127 -1
- package/src/setup.test.ts +109 -2
- package/src/setup.ts +359 -120
package/src/setup.ts
CHANGED
|
@@ -8,7 +8,18 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import { execSync } from "node:child_process";
|
|
11
|
-
import {
|
|
11
|
+
import {
|
|
12
|
+
closeSync,
|
|
13
|
+
existsSync,
|
|
14
|
+
openSync,
|
|
15
|
+
readdirSync,
|
|
16
|
+
readSync,
|
|
17
|
+
renameSync,
|
|
18
|
+
statSync,
|
|
19
|
+
unlinkSync,
|
|
20
|
+
writeFileSync,
|
|
21
|
+
} from "node:fs";
|
|
22
|
+
import path from "node:path";
|
|
12
23
|
import { gunzipSync } from "bun";
|
|
13
24
|
import { detectMode, resolveBaseDir, resolveDbPath, resolveVersion, SCHEMA_VERSION } from "./paths.ts";
|
|
14
25
|
|
|
@@ -25,20 +36,51 @@ const MIN_PAGES = 100;
|
|
|
25
36
|
const MIN_COMMANDS = 1000;
|
|
26
37
|
const MIN_DECOMPRESSED_BYTES = 50 * 1024 * 1024; // 50 MB
|
|
27
38
|
const SQLITE_MAGIC = "SQLite format 3\0";
|
|
39
|
+
const DOWNLOAD_LOCK_WAIT_MS = 90_000;
|
|
40
|
+
const DOWNLOAD_LOCK_POLL_MS = 250;
|
|
41
|
+
const DOWNLOAD_LOCK_STALE_MS = 15 * 60 * 1000;
|
|
42
|
+
|
|
43
|
+
type DbProbe = {
|
|
44
|
+
schemaVersion: number;
|
|
45
|
+
pages: number;
|
|
46
|
+
commands: number;
|
|
47
|
+
releaseTag: string | null;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
type DownloadLockHandle = {
|
|
51
|
+
fd: number;
|
|
52
|
+
path: string;
|
|
53
|
+
};
|
|
28
54
|
|
|
29
55
|
/** Check if a DB file exists and has actual page data.
|
|
30
56
|
* Opens read-write — see probeDb's note: freshly written WAL-mode files can
|
|
31
57
|
* fail to open readonly on macOS when the .shm file is missing. */
|
|
32
58
|
function dbHasData(dbPath: string): boolean {
|
|
59
|
+
return hasMinimumDbContent(probeDb(dbPath));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function looksLikeSqliteFile(dbPath: string): boolean {
|
|
33
63
|
if (!existsSync(dbPath)) return false;
|
|
64
|
+
|
|
65
|
+
let fd: number | null = null;
|
|
34
66
|
try {
|
|
35
|
-
const
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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;
|
|
40
74
|
} catch {
|
|
41
75
|
return false;
|
|
76
|
+
} finally {
|
|
77
|
+
if (fd !== null) {
|
|
78
|
+
try {
|
|
79
|
+
closeSync(fd);
|
|
80
|
+
} catch {
|
|
81
|
+
// best-effort cleanup
|
|
82
|
+
}
|
|
83
|
+
}
|
|
42
84
|
}
|
|
43
85
|
}
|
|
44
86
|
|
|
@@ -54,6 +96,8 @@ export function probeDb(dbPath: string): {
|
|
|
54
96
|
commands: number;
|
|
55
97
|
releaseTag: string | null;
|
|
56
98
|
} | null {
|
|
99
|
+
if (!looksLikeSqliteFile(dbPath)) return null;
|
|
100
|
+
|
|
57
101
|
try {
|
|
58
102
|
const { default: sqlite } = require("bun:sqlite");
|
|
59
103
|
const check = new sqlite(dbPath);
|
|
@@ -79,6 +123,155 @@ export function probeDb(dbPath: string): {
|
|
|
79
123
|
}
|
|
80
124
|
}
|
|
81
125
|
|
|
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;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function lockPathFor(dbPath: string): string {
|
|
135
|
+
return `${dbPath}.lock`;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function formatProbeSummary(probe: DbProbe): string {
|
|
139
|
+
const tagInfo = probe.releaseTag ? ` (release ${probe.releaseTag})` : "";
|
|
140
|
+
return `schema v${probe.schemaVersion}, ${probe.pages} pages, ${probe.commands} commands${tagInfo}`;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export function tryAcquireDownloadLock(dbPath: string): DownloadLockHandle | null {
|
|
144
|
+
const lockPath = lockPathFor(dbPath);
|
|
145
|
+
|
|
146
|
+
while (true) {
|
|
147
|
+
try {
|
|
148
|
+
const fd = openSync(lockPath, "wx");
|
|
149
|
+
writeFileSync(
|
|
150
|
+
fd,
|
|
151
|
+
`${JSON.stringify({
|
|
152
|
+
pid: process.pid,
|
|
153
|
+
created_at: new Date().toISOString(),
|
|
154
|
+
db_path: dbPath,
|
|
155
|
+
})}\n`,
|
|
156
|
+
);
|
|
157
|
+
return { fd, path: lockPath };
|
|
158
|
+
} catch (e) {
|
|
159
|
+
const code = e instanceof Error && "code" in e ? e.code : undefined;
|
|
160
|
+
if (code !== "EEXIST") throw e;
|
|
161
|
+
|
|
162
|
+
let ageMs: number | null = null;
|
|
163
|
+
try {
|
|
164
|
+
ageMs = Date.now() - statSync(lockPath).mtimeMs;
|
|
165
|
+
} catch {
|
|
166
|
+
ageMs = null;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
if (ageMs !== null && ageMs > DOWNLOAD_LOCK_STALE_MS) {
|
|
170
|
+
tryUnlink(lockPath);
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
return null;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export function releaseDownloadLock(lock: DownloadLockHandle | null): void {
|
|
180
|
+
if (!lock) return;
|
|
181
|
+
try {
|
|
182
|
+
closeSync(lock.fd);
|
|
183
|
+
} catch {
|
|
184
|
+
// best-effort cleanup
|
|
185
|
+
}
|
|
186
|
+
tryUnlink(lock.path);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export async function waitForUsableDb(
|
|
190
|
+
dbPath: string,
|
|
191
|
+
log: (msg: string) => void = console.log,
|
|
192
|
+
timeoutMs = DOWNLOAD_LOCK_WAIT_MS,
|
|
193
|
+
): Promise<boolean> {
|
|
194
|
+
const lockPath = lockPathFor(dbPath);
|
|
195
|
+
const deadline = Date.now() + timeoutMs;
|
|
196
|
+
let announced = false;
|
|
197
|
+
|
|
198
|
+
while (Date.now() < deadline) {
|
|
199
|
+
if (!existsSync(lockPath)) {
|
|
200
|
+
return isUsableDbProbe(probeDb(dbPath));
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
if (!announced) {
|
|
204
|
+
log(` Another rosetta process is preparing ${dbPath}; waiting...`);
|
|
205
|
+
announced = true;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const remaining = deadline - Date.now();
|
|
209
|
+
if (remaining <= 0) break;
|
|
210
|
+
await Bun.sleep(Math.min(remaining, DOWNLOAD_LOCK_POLL_MS));
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
return false;
|
|
214
|
+
}
|
|
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
|
+
|
|
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;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
tryUnlink(dbPath);
|
|
272
|
+
renameSync(tmpPath, dbPath);
|
|
273
|
+
}
|
|
274
|
+
|
|
82
275
|
/** Build the version-pinned download URL. Falls back to /latest/ when no version.
|
|
83
276
|
* Exported for test coverage. */
|
|
84
277
|
export function dbDownloadUrls(version: string): string[] {
|
|
@@ -96,7 +289,7 @@ export function dbDownloadUrls(version: string): string[] {
|
|
|
96
289
|
* Download ros-help.db.gz from GitHub Releases atomically:
|
|
97
290
|
* 1. Try version-pinned URL first, fall back to /latest/ on 404.
|
|
98
291
|
* 2. Decompress in memory, verify SQLite magic bytes + minimum size.
|
|
99
|
-
* 3. Write to <dbPath>.tmp.<pid>,
|
|
292
|
+
* 3. Write to <dbPath>.tmp.<pid>, probe it with SQLite, verify schema_version
|
|
100
293
|
* matches the running code and pages/commands counts look healthy.
|
|
101
294
|
* 4. Atomically rename .tmp → dbPath, then delete stale .db-wal / .db-shm.
|
|
102
295
|
*
|
|
@@ -107,122 +300,171 @@ export function dbDownloadUrls(version: string): string[] {
|
|
|
107
300
|
export async function downloadDb(
|
|
108
301
|
dbPath: string,
|
|
109
302
|
log: (msg: string) => void = console.log,
|
|
110
|
-
) {
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
let response: Response;
|
|
121
|
-
try {
|
|
122
|
-
response = await fetch(url, { redirect: "follow" });
|
|
123
|
-
} catch (e) {
|
|
124
|
-
lastError = e as Error;
|
|
125
|
-
log(` Network error: ${e}`);
|
|
126
|
-
if (isLast) throw lastError;
|
|
127
|
-
continue;
|
|
303
|
+
): Promise<DbProbe> {
|
|
304
|
+
let lock = tryAcquireDownloadLock(dbPath);
|
|
305
|
+
if (!lock) {
|
|
306
|
+
const reused = await waitForUsableDb(dbPath, log);
|
|
307
|
+
if (reused) {
|
|
308
|
+
const probe = probeDb(dbPath);
|
|
309
|
+
if (probe) {
|
|
310
|
+
log(` Reused existing database: ${formatProbeSummary(probe)}`);
|
|
311
|
+
return probe;
|
|
312
|
+
}
|
|
128
313
|
}
|
|
129
314
|
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
315
|
+
// Re-probe once — the lock may have been released with a healthy DB
|
|
316
|
+
const fallbackProbe = probeDb(dbPath);
|
|
317
|
+
if (isUsableDbProbe(fallbackProbe)) {
|
|
318
|
+
log(` Reused existing database: ${formatProbeSummary(fallbackProbe)}`);
|
|
319
|
+
return fallbackProbe;
|
|
133
320
|
}
|
|
134
|
-
if (!response.ok) {
|
|
135
|
-
lastError = new Error(`Download failed: ${response.status} ${response.statusText}`);
|
|
136
|
-
if (isLast) throw lastError;
|
|
137
|
-
log(` ${lastError.message} — trying fallback...`);
|
|
138
|
-
continue;
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
const contentLength = response.headers.get("content-length");
|
|
142
|
-
const totalMB = contentLength ? (Number(contentLength) / 1024 / 1024).toFixed(1) : "?";
|
|
143
|
-
log(` Downloading ${totalMB} MB (compressed)...`);
|
|
144
|
-
|
|
145
|
-
const compressed = new Uint8Array(await response.arrayBuffer());
|
|
146
|
-
log(` Decompressing...`);
|
|
147
321
|
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
if (isLast) throw lastError;
|
|
154
|
-
log(` ${lastError.message}`);
|
|
155
|
-
continue;
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
// Validate magic bytes and minimum size before touching the filesystem.
|
|
159
|
-
if (decompressed.byteLength < MIN_DECOMPRESSED_BYTES) {
|
|
160
|
-
lastError = new Error(
|
|
161
|
-
`Decompressed DB too small: ${decompressed.byteLength} bytes (expected ≥ ${MIN_DECOMPRESSED_BYTES})`,
|
|
322
|
+
lock = tryAcquireDownloadLock(dbPath);
|
|
323
|
+
if (!lock) {
|
|
324
|
+
throw new Error(
|
|
325
|
+
`Timed out waiting for another rosetta process to finish preparing ${dbPath}. ` +
|
|
326
|
+
`Close other rosetta clients and retry.`,
|
|
162
327
|
);
|
|
163
|
-
if (isLast) throw lastError;
|
|
164
|
-
log(` ${lastError.message}`);
|
|
165
|
-
continue;
|
|
166
|
-
}
|
|
167
|
-
const header = new TextDecoder().decode(decompressed.subarray(0, SQLITE_MAGIC.length));
|
|
168
|
-
if (header !== SQLITE_MAGIC) {
|
|
169
|
-
lastError = new Error("Downloaded payload is not a SQLite database (magic bytes mismatch)");
|
|
170
|
-
if (isLast) throw lastError;
|
|
171
|
-
log(` ${lastError.message}`);
|
|
172
|
-
continue;
|
|
173
328
|
}
|
|
329
|
+
}
|
|
174
330
|
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
try {
|
|
178
|
-
writeFileSync(tmpPath, decompressed);
|
|
179
|
-
} catch (e) {
|
|
180
|
-
lastError = new Error(`Write to ${tmpPath} failed: ${e}`);
|
|
181
|
-
throw lastError;
|
|
182
|
-
}
|
|
331
|
+
const urls = dbDownloadUrls(RELEASE_VERSION);
|
|
332
|
+
let lastError: Error | null = null;
|
|
183
333
|
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
if (isLast) throw lastError;
|
|
189
|
-
log(` ${lastError.message} — trying fallback...`);
|
|
190
|
-
continue;
|
|
191
|
-
}
|
|
192
|
-
if (probe.schemaVersion !== SCHEMA_VERSION) {
|
|
193
|
-
tryUnlink(tmpPath);
|
|
194
|
-
lastError = new Error(
|
|
195
|
-
`Downloaded DB schema=${probe.schemaVersion} does not match this rosetta build (expected ${SCHEMA_VERSION}). ` +
|
|
196
|
-
`This usually means the cached package version is older than the published DB. ` +
|
|
197
|
-
`Run \`bun pm cache rm\` and relaunch to pick up the latest package.`,
|
|
198
|
-
);
|
|
199
|
-
if (isLast) throw lastError;
|
|
200
|
-
log(` ${lastError.message}`);
|
|
201
|
-
continue;
|
|
202
|
-
}
|
|
203
|
-
if (probe.pages < MIN_PAGES || probe.commands < MIN_COMMANDS) {
|
|
204
|
-
tryUnlink(tmpPath);
|
|
205
|
-
lastError = new Error(
|
|
206
|
-
`Downloaded DB content looks incomplete (pages=${probe.pages}, commands=${probe.commands})`,
|
|
207
|
-
);
|
|
208
|
-
if (isLast) throw lastError;
|
|
209
|
-
log(` ${lastError.message} — trying fallback...`);
|
|
210
|
-
continue;
|
|
334
|
+
try {
|
|
335
|
+
const cleaned = cleanupStaleTempArtifacts(dbPath);
|
|
336
|
+
if (cleaned > 0) {
|
|
337
|
+
log(` Removed ${cleaned} stale temp DB artifact${cleaned === 1 ? "" : "s"}.`);
|
|
211
338
|
}
|
|
212
339
|
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
340
|
+
for (let i = 0; i < urls.length; i++) {
|
|
341
|
+
const url = urls[i];
|
|
342
|
+
const isLast = i === urls.length - 1;
|
|
343
|
+
log(`Downloading database from GitHub Releases...`);
|
|
344
|
+
log(` ${url}`);
|
|
345
|
+
|
|
346
|
+
let response: Response;
|
|
347
|
+
try {
|
|
348
|
+
response = await fetch(url, { redirect: "follow" });
|
|
349
|
+
} catch (e) {
|
|
350
|
+
lastError = e as Error;
|
|
351
|
+
log(` Network error: ${e}`);
|
|
352
|
+
if (isLast) throw lastError;
|
|
353
|
+
continue;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
if (response.status === 404 && !isLast) {
|
|
357
|
+
log(` Not found at this URL, trying fallback...`);
|
|
358
|
+
continue;
|
|
359
|
+
}
|
|
360
|
+
if (!response.ok) {
|
|
361
|
+
lastError = new Error(`Download failed: ${response.status} ${response.statusText}`);
|
|
362
|
+
if (isLast) throw lastError;
|
|
363
|
+
log(` ${lastError.message} — trying fallback...`);
|
|
364
|
+
continue;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
const contentLength = response.headers.get("content-length");
|
|
368
|
+
const totalMB = contentLength ? (Number(contentLength) / 1024 / 1024).toFixed(1) : "?";
|
|
369
|
+
log(` Downloading ${totalMB} MB (compressed)...`);
|
|
370
|
+
|
|
371
|
+
const compressed = new Uint8Array(await response.arrayBuffer());
|
|
372
|
+
log(` Decompressing...`);
|
|
373
|
+
|
|
374
|
+
let decompressed: Uint8Array;
|
|
375
|
+
try {
|
|
376
|
+
decompressed = gunzipSync(compressed);
|
|
377
|
+
} catch (e) {
|
|
378
|
+
lastError = new Error(`Gunzip failed (corrupt download or HTML error page): ${e}`);
|
|
379
|
+
if (isLast) throw lastError;
|
|
380
|
+
log(` ${lastError.message}`);
|
|
381
|
+
continue;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// Validate magic bytes and minimum size before touching the filesystem.
|
|
385
|
+
if (decompressed.byteLength < MIN_DECOMPRESSED_BYTES) {
|
|
386
|
+
lastError = new Error(
|
|
387
|
+
`Decompressed DB too small: ${decompressed.byteLength} bytes (expected ≥ ${MIN_DECOMPRESSED_BYTES})`,
|
|
388
|
+
);
|
|
389
|
+
if (isLast) throw lastError;
|
|
390
|
+
log(` ${lastError.message}`);
|
|
391
|
+
continue;
|
|
392
|
+
}
|
|
393
|
+
const header = new TextDecoder().decode(decompressed.subarray(0, SQLITE_MAGIC.length));
|
|
394
|
+
if (header !== SQLITE_MAGIC) {
|
|
395
|
+
lastError = new Error("Downloaded payload is not a SQLite database (magic bytes mismatch)");
|
|
396
|
+
if (isLast) throw lastError;
|
|
397
|
+
log(` ${lastError.message}`);
|
|
398
|
+
continue;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// Write to a temp file next to the canonical DB path, validate, then rename.
|
|
402
|
+
const tmpPath = `${dbPath}.tmp.${process.pid}`;
|
|
403
|
+
try {
|
|
404
|
+
writeFileSync(tmpPath, decompressed);
|
|
405
|
+
} catch (e) {
|
|
406
|
+
lastError = new Error(`Write to ${tmpPath} failed: ${e}`);
|
|
407
|
+
throw lastError;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
const probe = probeDb(tmpPath);
|
|
411
|
+
if (!probe) {
|
|
412
|
+
cleanupDbArtifacts(tmpPath);
|
|
413
|
+
lastError = new Error("Downloaded DB failed to open with SQLite");
|
|
414
|
+
if (isLast) throw lastError;
|
|
415
|
+
log(` ${lastError.message} — trying fallback...`);
|
|
416
|
+
continue;
|
|
417
|
+
}
|
|
418
|
+
if (probe.schemaVersion !== SCHEMA_VERSION) {
|
|
419
|
+
cleanupDbArtifacts(tmpPath);
|
|
420
|
+
lastError = new Error(
|
|
421
|
+
`Downloaded DB schema=${probe.schemaVersion} does not match this rosetta build (expected ${SCHEMA_VERSION}). ` +
|
|
422
|
+
`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.`,
|
|
424
|
+
);
|
|
425
|
+
if (isLast) throw lastError;
|
|
426
|
+
log(` ${lastError.message}`);
|
|
427
|
+
continue;
|
|
428
|
+
}
|
|
429
|
+
if (probe.pages < MIN_PAGES || probe.commands < MIN_COMMANDS) {
|
|
430
|
+
cleanupDbArtifacts(tmpPath);
|
|
431
|
+
lastError = new Error(
|
|
432
|
+
`Downloaded DB content looks incomplete (pages=${probe.pages}, commands=${probe.commands})`,
|
|
433
|
+
);
|
|
434
|
+
if (isLast) throw lastError;
|
|
435
|
+
log(` ${lastError.message} — trying fallback...`);
|
|
436
|
+
continue;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
// Validation passed — drop stale WAL/SHM and atomically swap.
|
|
440
|
+
try {
|
|
441
|
+
tryUnlinkDbSidecars(tmpPath);
|
|
442
|
+
tryUnlinkDbSidecars(dbPath);
|
|
443
|
+
replaceDbFile(tmpPath, dbPath);
|
|
444
|
+
} catch (e) {
|
|
445
|
+
const existingProbe = probeDb(dbPath);
|
|
446
|
+
if (hasMinimumDbContent(existingProbe) && existingProbe.schemaVersion === probe.schemaVersion && existingProbe.releaseTag === probe.releaseTag) {
|
|
447
|
+
cleanupDbArtifacts(tmpPath);
|
|
448
|
+
log(` Another rosetta process already installed the same database.`);
|
|
449
|
+
log(` Reused existing database: ${formatProbeSummary(existingProbe)}`);
|
|
450
|
+
return existingProbe;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
cleanupDbArtifacts(tmpPath);
|
|
454
|
+
throw e;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
const sizeMB = (decompressed.byteLength / 1024 / 1024).toFixed(1);
|
|
458
|
+
const tagInfo = probe.releaseTag ? ` (release ${probe.releaseTag})` : "";
|
|
459
|
+
log(` Wrote ${sizeMB} MB to ${dbPath}${tagInfo}`);
|
|
460
|
+
log(` Validated: schema v${probe.schemaVersion}, ${probe.pages} pages, ${probe.commands} commands.`);
|
|
461
|
+
return probe;
|
|
462
|
+
}
|
|
217
463
|
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
log(` Validated: schema v${probe.schemaVersion}, ${probe.pages} pages, ${probe.commands} commands.`);
|
|
222
|
-
return;
|
|
464
|
+
throw lastError ?? new Error("Database download failed for unknown reasons");
|
|
465
|
+
} finally {
|
|
466
|
+
releaseDownloadLock(lock);
|
|
223
467
|
}
|
|
224
|
-
|
|
225
|
-
throw lastError ?? new Error("Database download failed for unknown reasons");
|
|
226
468
|
}
|
|
227
469
|
|
|
228
470
|
/** Remove a file if it exists, swallowing all errors. */
|
|
@@ -241,17 +483,13 @@ function tryUnlink(p: string): void {
|
|
|
241
483
|
*/
|
|
242
484
|
export async function refreshDb(log: (msg: string) => void = console.log): Promise<boolean> {
|
|
243
485
|
const dbPath = resolveDbPath(import.meta.dirname);
|
|
486
|
+
let probe: DbProbe;
|
|
244
487
|
try {
|
|
245
|
-
await downloadDb(dbPath, log);
|
|
488
|
+
probe = await downloadDb(dbPath, log);
|
|
246
489
|
} catch (e) {
|
|
247
490
|
log(`✗ Refresh failed: ${e instanceof Error ? e.message : e}`);
|
|
248
491
|
return false;
|
|
249
492
|
}
|
|
250
|
-
const probe = probeDb(dbPath);
|
|
251
|
-
if (!probe) {
|
|
252
|
-
log(`✗ Post-download probe failed`);
|
|
253
|
-
return false;
|
|
254
|
-
}
|
|
255
493
|
const tagInfo = probe.releaseTag ? ` (release ${probe.releaseTag})` : "";
|
|
256
494
|
log(`✓ Database ready${tagInfo}: ${probe.pages} pages, ${probe.commands} commands, schema v${probe.schemaVersion}`);
|
|
257
495
|
return true;
|
|
@@ -260,6 +498,7 @@ export async function refreshDb(log: (msg: string) => void = console.log): Promi
|
|
|
260
498
|
export async function runSetup(force = false) {
|
|
261
499
|
const mode = detectMode(import.meta.dirname);
|
|
262
500
|
const dbPath = resolveDbPath(import.meta.dirname);
|
|
501
|
+
let downloadedProbe: DbProbe | null = null;
|
|
263
502
|
|
|
264
503
|
console.log(`rosetta ${RELEASE_VERSION}`);
|
|
265
504
|
console.log(` ${link("https://github.com/tikoci/rosetta")}`);
|
|
@@ -272,7 +511,7 @@ export async function runSetup(force = false) {
|
|
|
272
511
|
console.log(` (use --refresh or --setup --force to re-download)`);
|
|
273
512
|
} else {
|
|
274
513
|
try {
|
|
275
|
-
await downloadDb(dbPath);
|
|
514
|
+
downloadedProbe = await downloadDb(dbPath);
|
|
276
515
|
} catch (e) {
|
|
277
516
|
console.error(`✗ Database download failed: ${e instanceof Error ? e.message : e}`);
|
|
278
517
|
process.exit(1);
|
|
@@ -281,7 +520,7 @@ export async function runSetup(force = false) {
|
|
|
281
520
|
|
|
282
521
|
// ── Validate DB ──
|
|
283
522
|
console.log();
|
|
284
|
-
const probe = probeDb(dbPath);
|
|
523
|
+
const probe = downloadedProbe ?? probeDb(dbPath);
|
|
285
524
|
if (!probe) {
|
|
286
525
|
console.error(`✗ Database validation failed: cannot open ${dbPath}`);
|
|
287
526
|
const retryCmd = mode === "compiled" ? "rosetta" : mode === "package" ? "bunx @tikoci/rosetta" : "bun run src/setup.ts";
|
|
@@ -293,7 +532,7 @@ export async function runSetup(force = false) {
|
|
|
293
532
|
`✗ DB schema version is ${probe.schemaVersion}, expected ${SCHEMA_VERSION}.`,
|
|
294
533
|
);
|
|
295
534
|
console.error(
|
|
296
|
-
`
|
|
535
|
+
` Package may be out of date. Run: bunx @tikoci/rosetta@latest --refresh`,
|
|
297
536
|
);
|
|
298
537
|
process.exit(1);
|
|
299
538
|
}
|