@thallylabs/migrate 0.2.28 → 0.2.30

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.
@@ -463,6 +463,73 @@ function isPassiveSvg(content) {
463
463
  return isPassive && sawRoot && depth === 0;
464
464
  }
465
465
 
466
+ // src/url-asset-cache.ts
467
+ function createMigrationAssetCache() {
468
+ const settled = /* @__PURE__ */ new Map();
469
+ const pending = /* @__PURE__ */ new Map();
470
+ const remember = (url, asset) => {
471
+ settled.delete(url);
472
+ settled.set(url, asset);
473
+ if (settled.size > 480) settled.delete(settled.keys().next().value);
474
+ return asset;
475
+ };
476
+ const get = (url) => {
477
+ const asset = settled.get(url);
478
+ if (asset) return Promise.resolve(remember(url, asset));
479
+ return pending.get(url);
480
+ };
481
+ return {
482
+ remember,
483
+ get,
484
+ has(url) {
485
+ return settled.has(url) || pending.has(url);
486
+ },
487
+ get size() {
488
+ return settled.size + pending.size;
489
+ },
490
+ load(url, acquire) {
491
+ const existing = get(url);
492
+ if (existing) return existing;
493
+ const request = Promise.resolve().then(acquire).then((asset) => {
494
+ pending.delete(url);
495
+ return remember(url, asset);
496
+ }, (error) => {
497
+ pending.delete(url);
498
+ throw error;
499
+ });
500
+ pending.set(url, request);
501
+ return request;
502
+ },
503
+ delete(url) {
504
+ settled.delete(url);
505
+ }
506
+ };
507
+ }
508
+
509
+ // src/url-journal-clone.ts
510
+ function cloneUrlMigrationCheckpoint(checkpoint) {
511
+ const ancestors = /* @__PURE__ */ new Set();
512
+ const clone = (value) => {
513
+ if (value === null || typeof value === "string" || typeof value === "boolean") return value;
514
+ if (typeof value === "number") return Number.isFinite(value) ? value === 0 ? 0 : value : null;
515
+ if (typeof value === "undefined" || typeof value === "function" || typeof value === "symbol") return void 0;
516
+ if (typeof value !== "object") throw new TypeError("Checkpoint must contain JSON-safe values.");
517
+ if (ancestors.has(value)) throw new TypeError("Checkpoint must not contain circular references.");
518
+ if (!Array.isArray(value) && Object.getPrototypeOf(value) !== Object.prototype && Object.getPrototypeOf(value) !== null) throw new TypeError("Checkpoint must contain plain JSON containers.");
519
+ ancestors.add(value);
520
+ try {
521
+ if (Array.isArray(value)) return Array.from(value, (entry) => clone(entry) ?? null);
522
+ return Object.fromEntries(Object.entries(value).flatMap(([key, entry]) => {
523
+ const copied = clone(entry);
524
+ return copied === void 0 ? [] : [[key, copied]];
525
+ }));
526
+ } finally {
527
+ ancestors.delete(value);
528
+ }
529
+ };
530
+ return clone(checkpoint);
531
+ }
532
+
466
533
  // src/url-images.ts
467
534
  function migrationImageUrl(value, base) {
468
535
  const url = new URL(value, base);
@@ -3191,7 +3258,8 @@ async function runUrlMigration(options) {
3191
3258
  const batchSize = Math.max(1, Math.min(500, Math.floor(options.batchSize ?? 50)));
3192
3259
  if (!Number.isFinite(concurrency) || !Number.isFinite(batchSize) || options.maxCaptures !== void 0 && (!Number.isInteger(options.maxCaptures) || options.maxCaptures < 1) || options.maxTotalBytes !== void 0 && (!Number.isFinite(options.maxTotalBytes) || options.maxTotalBytes < 1)) throw new Error("Migration batch, concurrency and byte limits must be positive finite numbers.");
3193
3260
  const captureVersion = options.captureVersion ?? "fetch-v1";
3194
- const checkpoint = options.checkpoint ? JSON.parse(JSON.stringify(options.checkpoint)) : { schemaVersion: 1, converterVersion: CONVERTER_VERSION, sourceUrl, scopePath, captureVersion, records: [], navigation: [] };
3261
+ if (options.checkpoint && options.checkpoint.records.length > MAX_RECORDS) throw new Error("Checkpoint inventory exceeds its bound.");
3262
+ const checkpoint = options.checkpoint ? cloneUrlMigrationCheckpoint(options.checkpoint) : { schemaVersion: 1, converterVersion: CONVERTER_VERSION, sourceUrl, scopePath, captureVersion, records: [], navigation: [] };
3195
3263
  if (checkpoint.schemaVersion !== 1 || checkpoint.converterVersion !== CONVERTER_VERSION || checkpoint.sourceUrl !== sourceUrl || checkpoint.scopePath !== scopePath || checkpoint.captureVersion !== captureVersion) throw new Error("Checkpoint does not match this source, scope or capture/converter version.");
3196
3264
  if (checkpoint.records.length > MAX_RECORDS) throw new Error("Checkpoint inventory exceeds its bound.");
3197
3265
  const records = /* @__PURE__ */ new Map();
@@ -3232,6 +3300,33 @@ async function runUrlMigration(options) {
3232
3300
  enqueue(sourceUrl, sourceUrl, "submitted");
3233
3301
  for (const path of ["llms.txt", "sitemap.xml", "robots.txt"]) enqueue(`${scopePath === "/" ? "" : scopePath}/${path}`, sourceUrl, "discovery-index");
3234
3302
  if (scopePath !== "/") for (const path of ["llms.txt", "sitemap.xml", "robots.txt"]) enqueue(`/${path}`, sourceUrl, "discovery-index");
3303
+ const enqueueArticleLinks = (links, base, discoveredBy) => {
3304
+ for (const link of links) {
3305
+ try {
3306
+ const url = new URL(link, base);
3307
+ if (!/\.(?:png|jpe?g|gif|svg|webp|avif|pdf|zip|mp4|webm|css|js)$/i.test(url.pathname)) enqueue(link, base, discoveredBy);
3308
+ } catch {
3309
+ }
3310
+ }
3311
+ };
3312
+ const enqueueNavigation = (nodes, base) => {
3313
+ for (const node of nodes) {
3314
+ if (node.url) enqueue(node.url, base, "source-navigation");
3315
+ enqueueNavigation(node.children ?? [], base);
3316
+ }
3317
+ };
3318
+ for (const record of [...records.values()]) {
3319
+ if (!record.evidence) continue;
3320
+ const final = canonical(record.evidence.finalUrl, sourceUrl);
3321
+ if (!final) continue;
3322
+ if (record.outcome === "imported") enqueueArticleLinks(record.evidence.links, final, record.url);
3323
+ else if (record.outcome === "excluded") {
3324
+ if (classifyMigrationResponse({ finalUrl: new URL(final), contentType: record.evidence.contentType, body: "" }) === "index") {
3325
+ for (const link of record.evidence.links) enqueue(link, final, record.url);
3326
+ }
3327
+ }
3328
+ }
3329
+ enqueueNavigation(checkpoint.navigation, sourceUrl);
3235
3330
  const fetcher = options.fetcher ?? defaultMigrationFetcher;
3236
3331
  const capture = options.captureProvider?.capture.bind(options.captureProvider) ?? (async ({ url }) => fetcher(url, { accept: ACCEPT }));
3237
3332
  const assetFetcher = options.assetFetcher ?? (!options.fetcher && !options.captureProvider ? defaultAssetFetcher : void 0);
@@ -3239,9 +3334,21 @@ async function runUrlMigration(options) {
3239
3334
  const finalUrls = new Map([...records.values()].filter((record) => record.evidence && record.page).map((record) => [canonical(record.evidence.finalUrl, sourceUrl), record.url]));
3240
3335
  let imported = usedIds.size;
3241
3336
  const assetPayloads = new Map(checkpoint.records.flatMap((record) => (record.assets ?? []).map((asset) => [asset.sha256, asset])));
3242
- const assetRequests = /* @__PURE__ */ new Map();
3337
+ const assetBytes = /* @__PURE__ */ new Map();
3338
+ const retainAsset = (asset) => {
3339
+ if (!acceptedAsset(asset)) throw new Error("Asset MIME/signature is unsupported or unsafe to serve.");
3340
+ const sha256 = hash(asset.content);
3341
+ const content = assetBytes.get(sha256) ?? Buffer.from(asset.content);
3342
+ assetBytes.set(sha256, content);
3343
+ return { ...asset, content };
3344
+ };
3345
+ const assetRequests = createMigrationAssetCache();
3243
3346
  for (const record of checkpoint.records) for (const asset of record.assets ?? []) {
3244
- if (!assetRequests.has(asset.url)) assetRequests.set(asset.url, Promise.resolve({ url: asset.url, contentType: asset.contentType, content: Buffer.from(asset.base64, "base64") }));
3347
+ if (!assetRequests.has(asset.url)) {
3348
+ const content = assetBytes.get(asset.sha256) ?? Buffer.from(asset.base64, "base64");
3349
+ assetBytes.set(asset.sha256, content);
3350
+ assetRequests.remember(asset.url, { url: asset.url, contentType: asset.contentType, content });
3351
+ }
3245
3352
  }
3246
3353
  let totalBytes = checkpoint.records.reduce((sum, record) => sum + (record.evidence?.byteLength ?? 0), 0) + [...assetPayloads.values()].reduce((sum, asset) => sum + Buffer.byteLength(asset.base64, "base64"), 0);
3247
3354
  let budgetExhausted = false;
@@ -3353,10 +3460,10 @@ ${difference}`;
3353
3460
  try {
3354
3461
  let assetRequest = assetRequests.get(url);
3355
3462
  if (!suppliedAssets.has(url) && !assetRequest && assetFetcher) {
3356
- assetRequest = assetFetcher(new URL(url), options.signal);
3357
- assetRequests.set(url, assetRequest);
3463
+ assetRequest = assetRequests.load(url, () => assetFetcher(new URL(url), options.signal).then(retainAsset));
3358
3464
  }
3359
- const asset = suppliedAssets.get(url) ?? (assetRequest ? await assetRequest : null);
3465
+ const supplied = suppliedAssets.get(url);
3466
+ const asset = supplied ? retainAsset(supplied) : assetRequest ? await assetRequest : null;
3360
3467
  if (!asset) throw new Error("Capture host did not supply asset bytes or a binary fetch boundary.");
3361
3468
  const extension = acceptedAsset(asset);
3362
3469
  if (!extension) throw new Error("Asset MIME/signature is unsupported or unsafe to serve.");
@@ -3373,7 +3480,7 @@ ${difference}`;
3373
3480
  const receipt = { url, path, sha256, contentType: asset.contentType, base64: existing?.base64 ?? Buffer.from(asset.content).toString("base64") };
3374
3481
  record.assets.push(receipt);
3375
3482
  if (!existing) assetPayloads.set(sha256, receipt);
3376
- if (!assetRequests.has(url)) assetRequests.set(url, Promise.resolve(asset));
3483
+ if (!assetRequests.has(url)) assetRequests.remember(url, asset);
3377
3484
  page.body = rewriteMarkdownDestinations(page.body, (destination) => canonical(destination, final) === url ? `/${path}${new URL(destination, final).hash}` : destination);
3378
3485
  } catch (error) {
3379
3486
  assetRequests.delete(url);
@@ -3406,20 +3513,8 @@ ${difference}`;
3406
3513
  checkpoint.navigation = navigation;
3407
3514
  navigationChanged = true;
3408
3515
  }
3409
- for (const link of evidence.links) {
3410
- try {
3411
- const url = new URL(link, final);
3412
- if (!/\.(?:png|jpe?g|gif|svg|webp|avif|pdf|zip|mp4|webm|css|js)$/i.test(url.pathname)) enqueue(link, final, record.url);
3413
- } catch {
3414
- }
3415
- }
3416
- const visit = (nodes) => {
3417
- for (const node of nodes) {
3418
- if (node.url) enqueue(node.url, final, "source-navigation");
3419
- visit(node.children ?? []);
3420
- }
3421
- };
3422
- visit(navigation);
3516
+ enqueueArticleLinks(evidence.links, final, record.url);
3517
+ enqueueNavigation(navigation, final);
3423
3518
  } catch (error) {
3424
3519
  if (options.signal?.aborted) return;
3425
3520
  const wasImported = record.outcome === "imported";
@@ -3489,7 +3584,13 @@ ${difference}`;
3489
3584
  const validation = validateUrlMigration(checkpoint, Boolean(options.signal?.aborted));
3490
3585
  validation.issues.push(...navigationIssues);
3491
3586
  if (navigationIssues.length && validation.status === "passed") validation.status = "partial";
3492
- const assets = new Map(checkpoint.records.flatMap((record) => (record.assets ?? []).map((asset) => [asset.path, { path: asset.path, content: Buffer.from(asset.base64, "base64") }])));
3587
+ const assets = /* @__PURE__ */ new Map();
3588
+ for (const record of checkpoint.records) for (const asset of record.assets ?? []) {
3589
+ if (!assets.has(asset.path)) {
3590
+ const content = assetBytes.get(asset.sha256) ?? Buffer.from(asset.base64, "base64");
3591
+ assets.set(asset.path, { path: asset.path, content: Buffer.from(content.buffer, content.byteOffset, content.byteLength) });
3592
+ }
3593
+ }
3493
3594
  const bundle = {
3494
3595
  sourceUrl,
3495
3596
  sourceKind: "url",