@tikoci/rosetta 0.8.9 → 0.8.10
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 +80 -15
- package/src/query.test.ts +146 -3
- package/src/query.ts +252 -14
- package/src/release.test.ts +121 -1
- package/src/setup.test.ts +65 -1
- package/src/setup.ts +277 -106
package/src/setup.ts
CHANGED
|
@@ -8,7 +8,15 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import { execSync } from "node:child_process";
|
|
11
|
-
import {
|
|
11
|
+
import {
|
|
12
|
+
closeSync,
|
|
13
|
+
existsSync,
|
|
14
|
+
openSync,
|
|
15
|
+
renameSync,
|
|
16
|
+
statSync,
|
|
17
|
+
unlinkSync,
|
|
18
|
+
writeFileSync,
|
|
19
|
+
} from "node:fs";
|
|
12
20
|
import { gunzipSync } from "bun";
|
|
13
21
|
import { detectMode, resolveBaseDir, resolveDbPath, resolveVersion, SCHEMA_VERSION } from "./paths.ts";
|
|
14
22
|
|
|
@@ -25,6 +33,21 @@ const MIN_PAGES = 100;
|
|
|
25
33
|
const MIN_COMMANDS = 1000;
|
|
26
34
|
const MIN_DECOMPRESSED_BYTES = 50 * 1024 * 1024; // 50 MB
|
|
27
35
|
const SQLITE_MAGIC = "SQLite format 3\0";
|
|
36
|
+
const DOWNLOAD_LOCK_WAIT_MS = 90_000;
|
|
37
|
+
const DOWNLOAD_LOCK_POLL_MS = 250;
|
|
38
|
+
const DOWNLOAD_LOCK_STALE_MS = 15 * 60 * 1000;
|
|
39
|
+
|
|
40
|
+
type DbProbe = {
|
|
41
|
+
schemaVersion: number;
|
|
42
|
+
pages: number;
|
|
43
|
+
commands: number;
|
|
44
|
+
releaseTag: string | null;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
type DownloadLockHandle = {
|
|
48
|
+
fd: number;
|
|
49
|
+
path: string;
|
|
50
|
+
};
|
|
28
51
|
|
|
29
52
|
/** Check if a DB file exists and has actual page data.
|
|
30
53
|
* Opens read-write — see probeDb's note: freshly written WAL-mode files can
|
|
@@ -79,6 +102,106 @@ export function probeDb(dbPath: string): {
|
|
|
79
102
|
}
|
|
80
103
|
}
|
|
81
104
|
|
|
105
|
+
function isUsableDbProbe(probe: DbProbe | null): probe is DbProbe {
|
|
106
|
+
return !!probe && probe.schemaVersion === SCHEMA_VERSION && probe.pages >= MIN_PAGES && probe.commands >= MIN_COMMANDS;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function lockPathFor(dbPath: string): string {
|
|
110
|
+
return `${dbPath}.lock`;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function formatProbeSummary(probe: DbProbe): string {
|
|
114
|
+
const tagInfo = probe.releaseTag ? ` (release ${probe.releaseTag})` : "";
|
|
115
|
+
return `schema v${probe.schemaVersion}, ${probe.pages} pages, ${probe.commands} commands${tagInfo}`;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function tryAcquireDownloadLock(dbPath: string): DownloadLockHandle | null {
|
|
119
|
+
const lockPath = lockPathFor(dbPath);
|
|
120
|
+
|
|
121
|
+
while (true) {
|
|
122
|
+
try {
|
|
123
|
+
const fd = openSync(lockPath, "wx");
|
|
124
|
+
writeFileSync(
|
|
125
|
+
fd,
|
|
126
|
+
JSON.stringify({
|
|
127
|
+
pid: process.pid,
|
|
128
|
+
created_at: new Date().toISOString(),
|
|
129
|
+
db_path: dbPath,
|
|
130
|
+
}) + "\n",
|
|
131
|
+
);
|
|
132
|
+
return { fd, path: lockPath };
|
|
133
|
+
} catch (e) {
|
|
134
|
+
const code = e instanceof Error && "code" in e ? e.code : undefined;
|
|
135
|
+
if (code !== "EEXIST") throw e;
|
|
136
|
+
|
|
137
|
+
let ageMs: number | null = null;
|
|
138
|
+
try {
|
|
139
|
+
ageMs = Date.now() - statSync(lockPath).mtimeMs;
|
|
140
|
+
} catch {
|
|
141
|
+
ageMs = null;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (ageMs !== null && ageMs > DOWNLOAD_LOCK_STALE_MS) {
|
|
145
|
+
tryUnlink(lockPath);
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return null;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export function releaseDownloadLock(lock: DownloadLockHandle | null): void {
|
|
155
|
+
if (!lock) return;
|
|
156
|
+
try {
|
|
157
|
+
closeSync(lock.fd);
|
|
158
|
+
} catch {
|
|
159
|
+
// best-effort cleanup
|
|
160
|
+
}
|
|
161
|
+
tryUnlink(lock.path);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export async function waitForUsableDb(
|
|
165
|
+
dbPath: string,
|
|
166
|
+
log: (msg: string) => void = console.log,
|
|
167
|
+
timeoutMs = DOWNLOAD_LOCK_WAIT_MS,
|
|
168
|
+
): Promise<boolean> {
|
|
169
|
+
const lockPath = lockPathFor(dbPath);
|
|
170
|
+
const deadline = Date.now() + timeoutMs;
|
|
171
|
+
let announced = false;
|
|
172
|
+
|
|
173
|
+
while (Date.now() < deadline) {
|
|
174
|
+
const probe = probeDb(dbPath);
|
|
175
|
+
if (isUsableDbProbe(probe)) return true;
|
|
176
|
+
|
|
177
|
+
if (!existsSync(lockPath)) return false;
|
|
178
|
+
|
|
179
|
+
if (!announced) {
|
|
180
|
+
log(` Another rosetta process is preparing ${dbPath}; waiting...`);
|
|
181
|
+
announced = true;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const remaining = deadline - Date.now();
|
|
185
|
+
if (remaining <= 0) break;
|
|
186
|
+
await Bun.sleep(Math.min(remaining, DOWNLOAD_LOCK_POLL_MS));
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
return false;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function replaceDbFile(tmpPath: string, dbPath: string): void {
|
|
193
|
+
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;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
tryUnlink(dbPath);
|
|
202
|
+
renameSync(tmpPath, dbPath);
|
|
203
|
+
}
|
|
204
|
+
|
|
82
205
|
/** Build the version-pinned download URL. Falls back to /latest/ when no version.
|
|
83
206
|
* Exported for test coverage. */
|
|
84
207
|
export function dbDownloadUrls(version: string): string[] {
|
|
@@ -96,7 +219,7 @@ export function dbDownloadUrls(version: string): string[] {
|
|
|
96
219
|
* Download ros-help.db.gz from GitHub Releases atomically:
|
|
97
220
|
* 1. Try version-pinned URL first, fall back to /latest/ on 404.
|
|
98
221
|
* 2. Decompress in memory, verify SQLite magic bytes + minimum size.
|
|
99
|
-
* 3. Write to <dbPath>.tmp.<pid>,
|
|
222
|
+
* 3. Write to <dbPath>.tmp.<pid>, probe it with SQLite, verify schema_version
|
|
100
223
|
* matches the running code and pages/commands counts look healthy.
|
|
101
224
|
* 4. Atomically rename .tmp → dbPath, then delete stale .db-wal / .db-shm.
|
|
102
225
|
*
|
|
@@ -108,121 +231,169 @@ export async function downloadDb(
|
|
|
108
231
|
dbPath: string,
|
|
109
232
|
log: (msg: string) => void = console.log,
|
|
110
233
|
) {
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
log(` ${url}`);
|
|
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;
|
|
234
|
+
let lock = tryAcquireDownloadLock(dbPath);
|
|
235
|
+
if (!lock) {
|
|
236
|
+
const reused = await waitForUsableDb(dbPath, log);
|
|
237
|
+
if (reused) {
|
|
238
|
+
const probe = probeDb(dbPath);
|
|
239
|
+
if (probe) log(` Reused existing database: ${formatProbeSummary(probe)}`);
|
|
240
|
+
return;
|
|
128
241
|
}
|
|
129
242
|
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
lastError = new Error(`Download failed: ${response.status} ${response.statusText}`);
|
|
136
|
-
if (isLast) throw lastError;
|
|
137
|
-
log(` ${lastError.message} — trying fallback...`);
|
|
138
|
-
continue;
|
|
243
|
+
// Re-probe once — the lock may have been released with a healthy DB
|
|
244
|
+
const fallbackProbe = probeDb(dbPath);
|
|
245
|
+
if (isUsableDbProbe(fallbackProbe)) {
|
|
246
|
+
log(` Reused existing database: ${formatProbeSummary(fallbackProbe)}`);
|
|
247
|
+
return;
|
|
139
248
|
}
|
|
140
249
|
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
log(` Decompressing...`);
|
|
147
|
-
|
|
148
|
-
let decompressed: Uint8Array;
|
|
149
|
-
try {
|
|
150
|
-
decompressed = gunzipSync(compressed);
|
|
151
|
-
} catch (e) {
|
|
152
|
-
lastError = new Error(`Gunzip failed (corrupt download or HTML error page): ${e}`);
|
|
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})`,
|
|
250
|
+
lock = tryAcquireDownloadLock(dbPath);
|
|
251
|
+
if (!lock) {
|
|
252
|
+
throw new Error(
|
|
253
|
+
`Timed out waiting for another rosetta process to finish preparing ${dbPath}. ` +
|
|
254
|
+
`Close other rosetta clients and retry.`,
|
|
162
255
|
);
|
|
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
256
|
}
|
|
257
|
+
}
|
|
174
258
|
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
try {
|
|
178
|
-
writeFileSync(tmpPath, decompressed);
|
|
179
|
-
} catch (e) {
|
|
180
|
-
lastError = new Error(`Write to ${tmpPath} failed: ${e}`);
|
|
181
|
-
throw lastError;
|
|
182
|
-
}
|
|
259
|
+
const urls = dbDownloadUrls(RELEASE_VERSION);
|
|
260
|
+
let lastError: Error | null = null;
|
|
183
261
|
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
log(` ${
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
262
|
+
try {
|
|
263
|
+
for (let i = 0; i < urls.length; i++) {
|
|
264
|
+
const url = urls[i];
|
|
265
|
+
const isLast = i === urls.length - 1;
|
|
266
|
+
log(`Downloading database from GitHub Releases...`);
|
|
267
|
+
log(` ${url}`);
|
|
268
|
+
|
|
269
|
+
let response: Response;
|
|
270
|
+
try {
|
|
271
|
+
response = await fetch(url, { redirect: "follow" });
|
|
272
|
+
} catch (e) {
|
|
273
|
+
lastError = e as Error;
|
|
274
|
+
log(` Network error: ${e}`);
|
|
275
|
+
if (isLast) throw lastError;
|
|
276
|
+
continue;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
if (response.status === 404 && !isLast) {
|
|
280
|
+
log(` Not found at this URL, trying fallback...`);
|
|
281
|
+
continue;
|
|
282
|
+
}
|
|
283
|
+
if (!response.ok) {
|
|
284
|
+
lastError = new Error(`Download failed: ${response.status} ${response.statusText}`);
|
|
285
|
+
if (isLast) throw lastError;
|
|
286
|
+
log(` ${lastError.message} — trying fallback...`);
|
|
287
|
+
continue;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
const contentLength = response.headers.get("content-length");
|
|
291
|
+
const totalMB = contentLength ? (Number(contentLength) / 1024 / 1024).toFixed(1) : "?";
|
|
292
|
+
log(` Downloading ${totalMB} MB (compressed)...`);
|
|
293
|
+
|
|
294
|
+
const compressed = new Uint8Array(await response.arrayBuffer());
|
|
295
|
+
log(` Decompressing...`);
|
|
296
|
+
|
|
297
|
+
let decompressed: Uint8Array;
|
|
298
|
+
try {
|
|
299
|
+
decompressed = gunzipSync(compressed);
|
|
300
|
+
} catch (e) {
|
|
301
|
+
lastError = new Error(`Gunzip failed (corrupt download or HTML error page): ${e}`);
|
|
302
|
+
if (isLast) throw lastError;
|
|
303
|
+
log(` ${lastError.message}`);
|
|
304
|
+
continue;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// Validate magic bytes and minimum size before touching the filesystem.
|
|
308
|
+
if (decompressed.byteLength < MIN_DECOMPRESSED_BYTES) {
|
|
309
|
+
lastError = new Error(
|
|
310
|
+
`Decompressed DB too small: ${decompressed.byteLength} bytes (expected ≥ ${MIN_DECOMPRESSED_BYTES})`,
|
|
311
|
+
);
|
|
312
|
+
if (isLast) throw lastError;
|
|
313
|
+
log(` ${lastError.message}`);
|
|
314
|
+
continue;
|
|
315
|
+
}
|
|
316
|
+
const header = new TextDecoder().decode(decompressed.subarray(0, SQLITE_MAGIC.length));
|
|
317
|
+
if (header !== SQLITE_MAGIC) {
|
|
318
|
+
lastError = new Error("Downloaded payload is not a SQLite database (magic bytes mismatch)");
|
|
319
|
+
if (isLast) throw lastError;
|
|
320
|
+
log(` ${lastError.message}`);
|
|
321
|
+
continue;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// Write to a temp file next to the canonical DB path, validate, then rename.
|
|
325
|
+
const tmpPath = `${dbPath}.tmp.${process.pid}`;
|
|
326
|
+
try {
|
|
327
|
+
writeFileSync(tmpPath, decompressed);
|
|
328
|
+
} catch (e) {
|
|
329
|
+
lastError = new Error(`Write to ${tmpPath} failed: ${e}`);
|
|
330
|
+
throw lastError;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
const probe = probeDb(tmpPath);
|
|
334
|
+
if (!probe) {
|
|
335
|
+
tryUnlink(tmpPath);
|
|
336
|
+
lastError = new Error("Downloaded DB failed to open with SQLite");
|
|
337
|
+
if (isLast) throw lastError;
|
|
338
|
+
log(` ${lastError.message} — trying fallback...`);
|
|
339
|
+
continue;
|
|
340
|
+
}
|
|
341
|
+
if (probe.schemaVersion !== SCHEMA_VERSION) {
|
|
342
|
+
tryUnlink(tmpPath);
|
|
343
|
+
lastError = new Error(
|
|
344
|
+
`Downloaded DB schema=${probe.schemaVersion} does not match this rosetta build (expected ${SCHEMA_VERSION}). ` +
|
|
345
|
+
`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.`,
|
|
347
|
+
);
|
|
348
|
+
if (isLast) throw lastError;
|
|
349
|
+
log(` ${lastError.message}`);
|
|
350
|
+
continue;
|
|
351
|
+
}
|
|
352
|
+
if (probe.pages < MIN_PAGES || probe.commands < MIN_COMMANDS) {
|
|
353
|
+
tryUnlink(tmpPath);
|
|
354
|
+
lastError = new Error(
|
|
355
|
+
`Downloaded DB content looks incomplete (pages=${probe.pages}, commands=${probe.commands})`,
|
|
356
|
+
);
|
|
357
|
+
if (isLast) throw lastError;
|
|
358
|
+
log(` ${lastError.message} — trying fallback...`);
|
|
359
|
+
continue;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// Validation passed — drop stale WAL/SHM and atomically swap.
|
|
363
|
+
try {
|
|
364
|
+
tryUnlink(`${dbPath}-wal`);
|
|
365
|
+
tryUnlink(`${dbPath}-shm`);
|
|
366
|
+
replaceDbFile(tmpPath, dbPath);
|
|
367
|
+
} catch (e) {
|
|
368
|
+
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);
|
|
377
|
+
log(` Another rosetta process already installed the same database.`);
|
|
378
|
+
log(` Reused existing database: ${formatProbeSummary(existingProbe)}`);
|
|
379
|
+
return;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
tryUnlink(tmpPath);
|
|
383
|
+
throw e;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
const sizeMB = (decompressed.byteLength / 1024 / 1024).toFixed(1);
|
|
387
|
+
const tagInfo = probe.releaseTag ? ` (release ${probe.releaseTag})` : "";
|
|
388
|
+
log(` Wrote ${sizeMB} MB to ${dbPath}${tagInfo}`);
|
|
389
|
+
log(` Validated: schema v${probe.schemaVersion}, ${probe.pages} pages, ${probe.commands} commands.`);
|
|
390
|
+
return;
|
|
211
391
|
}
|
|
212
392
|
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
renameSync(tmpPath, dbPath);
|
|
217
|
-
|
|
218
|
-
const sizeMB = (decompressed.byteLength / 1024 / 1024).toFixed(1);
|
|
219
|
-
const tagInfo = probe.releaseTag ? ` (release ${probe.releaseTag})` : "";
|
|
220
|
-
log(` Wrote ${sizeMB} MB to ${dbPath}${tagInfo}`);
|
|
221
|
-
log(` Validated: schema v${probe.schemaVersion}, ${probe.pages} pages, ${probe.commands} commands.`);
|
|
222
|
-
return;
|
|
393
|
+
throw lastError ?? new Error("Database download failed for unknown reasons");
|
|
394
|
+
} finally {
|
|
395
|
+
releaseDownloadLock(lock);
|
|
223
396
|
}
|
|
224
|
-
|
|
225
|
-
throw lastError ?? new Error("Database download failed for unknown reasons");
|
|
226
397
|
}
|
|
227
398
|
|
|
228
399
|
/** Remove a file if it exists, swallowing all errors. */
|