@gscdump/engine 1.4.3 → 1.4.4

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/dist/entities.mjs CHANGED
@@ -1,6 +1,7 @@
1
- import { emptyTypesKey, hashSortedUrlList, hashUrl, hashUrlList, indexingMetadataIndexKey, inspectionBaseKey, inspectionEventKey, inspectionEventsPrefix, inspectionHistoryPrefix, inspectionHistoryShardKey, inspectionIndexKey, inspectionParquetKey, queryDimMetaKey, queryDimParquetKey, sitemapHistoryKey, sitemapIndexKey, sitemapUrlsDeltaKey, sitemapUrlsIndexKey, sitemapUrlsIndexPrefix, sitemapUrlsPrefix } from "./entity-keys.mjs";
1
+ import { emptyTypesKey, hashSortedUrlList, hashUrl, hashUrlList, indexingMetadataIndexKey, inspectionBaseKey, inspectionEventKey, inspectionEventsPrefix, inspectionHistoryPrefix, inspectionHistoryShardKey, inspectionIndexKey, inspectionParquetKey, parseSitemapUrlsDeltaKey, queryDimMetaKey, queryDimParquetKey, sitemapHistoryKey, sitemapIndexKey, sitemapUrlsDeltaKey, sitemapUrlsEventKey, sitemapUrlsEventSeedKey, sitemapUrlsEventsPrefix, sitemapUrlsGenerationKey, sitemapUrlsIndexKey, sitemapUrlsIndexPrefix, sitemapUrlsPendingGenerationKey, sitemapUrlsPendingGenerationsPrefix, sitemapUrlsPrefix, sitemapUrlsProjectionManifestKey, sitemapUrlsReconcileGenerationKey } from "./entity-keys.mjs";
2
2
  import { decodeParquetToRows, encodeRowsToParquetFlex } from "./adapters/hyparquet.mjs";
3
3
  import { readOptional } from "./adapters/read-optional.mjs";
4
+ import { SITEMAP_PROJECTION_GRACE_MS, decodeSitemapProjectionManifest, emptySitemapProjectionManifest, parseSitemapProjectionManifest, selectSitemapProjectionFiles, withSitemapProjectionFeed } from "./sitemap-projection.mjs";
4
5
  import { buildQueryDimRecords, createQueryDimStore } from "./query-dim.mjs";
5
6
  import { encodeJsonBigintSafe } from "@gscdump/lakehouse/bigint";
6
7
  const YEAR_MONTH_RE = /^(\d{4})-(\d{2})-/;
@@ -315,7 +316,8 @@ function createInspectionStore(opts) {
315
316
  }
316
317
  };
317
318
  }
318
- const SITEMAP_URLS_DELTA_PREFIX_RE = /\/urls\/deltas\/(\d{4}-\d{2}-\d{2})__([0-9a-f]+)\.parquet$/;
319
+ const SITEMAP_URLS_EVENT_PREFIX_RE = /\/urls\/events\/(\d{4}-\d{2}-\d{2})__[0-9a-f]+__\d+__[0-9a-f]+\.parquet$/;
320
+ const SITEMAP_URLS_PENDING_GENERATION_RE = /\/urls\/generations\/pending\/([0-9a-f]+)\.json$/;
319
321
  const URLS_INDEX_COLUMNS = [
320
322
  {
321
323
  name: "feedpath",
@@ -393,6 +395,83 @@ const URLS_DELTA_COLUMNS = [
393
395
  name: "at",
394
396
  type: "BIGINT",
395
397
  nullable: false
398
+ },
399
+ {
400
+ name: "generation_id",
401
+ type: "VARCHAR",
402
+ nullable: true
403
+ }
404
+ ];
405
+ const URLS_EVENT_COLUMNS = [
406
+ {
407
+ name: "feedpath",
408
+ type: "VARCHAR",
409
+ nullable: false
410
+ },
411
+ {
412
+ name: "feedpath_hash",
413
+ type: "VARCHAR",
414
+ nullable: false
415
+ },
416
+ {
417
+ name: "url_hash",
418
+ type: "VARCHAR",
419
+ nullable: false
420
+ },
421
+ {
422
+ name: "op",
423
+ type: "VARCHAR",
424
+ nullable: false
425
+ },
426
+ {
427
+ name: "loc",
428
+ type: "VARCHAR",
429
+ nullable: false
430
+ },
431
+ {
432
+ name: "lastmod",
433
+ type: "VARCHAR",
434
+ nullable: true
435
+ },
436
+ {
437
+ name: "generation_id",
438
+ type: "VARCHAR",
439
+ nullable: false
440
+ },
441
+ {
442
+ name: "observed_at",
443
+ type: "BIGINT",
444
+ nullable: false
445
+ },
446
+ {
447
+ name: "sequence",
448
+ type: "INTEGER",
449
+ nullable: false
450
+ },
451
+ {
452
+ name: "projects_state",
453
+ type: "INTEGER",
454
+ nullable: false
455
+ },
456
+ {
457
+ name: "seed_generation",
458
+ type: "INTEGER",
459
+ nullable: false
460
+ },
461
+ {
462
+ name: "generation_kind",
463
+ type: "VARCHAR",
464
+ nullable: false
465
+ },
466
+ {
467
+ name: "content_hash",
468
+ type: "VARCHAR",
469
+ nullable: true
470
+ },
471
+ {
472
+ name: "input_count",
473
+ type: "INTEGER",
474
+ nullable: true
396
475
  }
397
476
  ];
398
477
  function rowToUrlRecord(row) {
@@ -419,44 +498,71 @@ function urlRecordToRow(r) {
419
498
  removed_at: r.removedAt ?? null
420
499
  };
421
500
  }
422
- function isoDate(ms) {
423
- return new Date(ms).toISOString().slice(0, 10);
501
+ function createSitemapUrlState(indexRows) {
502
+ const state = {
503
+ live: /* @__PURE__ */ new Map(),
504
+ removed: /* @__PURE__ */ new Map()
505
+ };
506
+ for (const row of indexRows) {
507
+ const record = rowToUrlRecord(row);
508
+ if (record.removedAt == null) state.live.set(record.urlHash, record);
509
+ else state.removed.set(record.urlHash, record);
510
+ }
511
+ return state;
424
512
  }
425
- function createSitemapStore(opts) {
513
+ function applySitemapDeltaRows(state, rows) {
514
+ for (const row of rows) {
515
+ const urlHash = String(row.url_hash);
516
+ const observedAt = Number(row.at);
517
+ const op = String(row.op);
518
+ if (op === "added") {
519
+ const previous = state.live.get(urlHash) ?? state.removed.get(urlHash);
520
+ state.removed.delete(urlHash);
521
+ state.live.set(urlHash, {
522
+ feedpath: String(row.feedpath),
523
+ feedpathHash: String(row.feedpath_hash),
524
+ urlHash,
525
+ loc: String(row.loc),
526
+ lastmod: row.lastmod == null ? void 0 : String(row.lastmod),
527
+ firstSeenAt: previous?.firstSeenAt ?? observedAt,
528
+ lastSeenAt: observedAt
529
+ });
530
+ } else if (op === "removed") {
531
+ const previous = state.live.get(urlHash);
532
+ state.live.delete(urlHash);
533
+ if (previous) state.removed.set(urlHash, {
534
+ ...previous,
535
+ removedAt: observedAt
536
+ });
537
+ }
538
+ }
539
+ }
540
+ function applySitemapDeltaFiles(state, files) {
541
+ for (const file of files) applySitemapDeltaRows(state, file.rows);
542
+ }
543
+ function sortSitemapDeltaFiles(files) {
544
+ return files.sort((a, b) => a.key.localeCompare(b.key));
545
+ }
546
+ async function readSitemapDeltaFiles(ds, keys) {
547
+ return sortSitemapDeltaFiles((await mapEntityIo(keys, async (key) => {
548
+ const bytes = await readOptional(ds, key);
549
+ return bytes ? {
550
+ key,
551
+ rows: await decodeParquetToRows(bytes)
552
+ } : void 0;
553
+ })).filter((file) => file !== void 0));
554
+ }
555
+ function dateInRange(date, range) {
556
+ return (!range?.from || date >= range.from) && (!range?.to || date <= range.to);
557
+ }
558
+ function createSitemapReadStore(opts) {
426
559
  const ds = opts.dataSource;
427
560
  const hash = opts.hash ?? hashUrl;
428
- const now = opts.now ?? (() => Date.now());
429
561
  async function readJson(key) {
430
562
  const bytes = await readOptional(ds, key);
431
- if (bytes === void 0) return void 0;
432
- return JSON.parse(new TextDecoder().decode(bytes));
433
- }
434
- async function writeJson(key, value) {
435
- await ds.write(key, encodeJsonBigintSafe(value));
563
+ return bytes === void 0 ? void 0 : JSON.parse(new TextDecoder().decode(bytes));
436
564
  }
437
565
  return {
438
- async writeSnapshot(ctx, records) {
439
- if (records.length === 0) return;
440
- const indexKey = sitemapIndexKey(ctx);
441
- const index = await readJson(indexKey) ?? {
442
- version: 1,
443
- records: {}
444
- };
445
- const stamp = now();
446
- const historyDocs = /* @__PURE__ */ new Map();
447
- for (const r of records) {
448
- const h = hash(r.path);
449
- index.records[h] = r;
450
- historyDocs.set(sitemapHistoryKey(ctx, h, stamp), {
451
- version: 1,
452
- path: r.path,
453
- capturedAt: r.capturedAt,
454
- record: r
455
- });
456
- }
457
- await mapEntityIo([...historyDocs], ([key, doc]) => writeJson(key, doc));
458
- await writeJson(indexKey, index);
459
- },
460
566
  async loadIndex(ctx) {
461
567
  return await readJson(sitemapIndexKey(ctx)) ?? {
462
568
  version: 1,
@@ -466,322 +572,589 @@ function createSitemapStore(opts) {
466
572
  async getLatest(ctx, path) {
467
573
  return (await readJson(sitemapIndexKey(ctx)))?.records[hash(path)];
468
574
  },
469
- async snapshotUrls(ctx, feedpath, urls) {
470
- const fpHash = hash(feedpath);
471
- const contentHash = hashUrlList(urls);
472
- const at = now();
473
- const priorByHash = /* @__PURE__ */ new Map();
474
- for await (const rec of this.loadUrls(ctx, feedpath, { includeRemoved: true })) priorByHash.set(rec.urlHash, rec);
475
- const livePrior = Array.from(priorByHash.values()).filter((r) => r.removedAt == null);
476
- if (livePrior.length > 0) {
477
- if (hashSortedUrlList(livePrior.map((r) => String(r.loc)).sort()) === contentHash) return {
478
- added: 0,
479
- removed: 0,
480
- kept: livePrior.length,
481
- contentHash,
482
- unchanged: true
483
- };
484
- }
485
- const incomingByHash = /* @__PURE__ */ new Map();
486
- for (const u of urls) incomingByHash.set(hash(u.loc), u);
487
- const deltaRows = [];
488
- let added = 0;
489
- let removed = 0;
490
- let kept = 0;
491
- const date = isoDate(at);
492
- for (const [urlHash, u] of incomingByHash) {
493
- const prev = priorByHash.get(urlHash);
494
- if (!prev || prev.removedAt != null) {
495
- added++;
496
- deltaRows.push({
497
- feedpath,
498
- feedpath_hash: fpHash,
499
- url_hash: urlHash,
500
- op: "added",
501
- loc: u.loc,
502
- lastmod: u.lastmod ?? null,
503
- at
504
- });
505
- } else kept++;
506
- }
507
- for (const [urlHash, prev] of priorByHash) {
508
- if (prev.removedAt != null) continue;
509
- if (!incomingByHash.has(urlHash)) {
510
- removed++;
511
- deltaRows.push({
512
- feedpath,
513
- feedpath_hash: fpHash,
514
- url_hash: urlHash,
515
- op: "removed",
516
- loc: prev.loc,
517
- lastmod: prev.lastmod ?? null,
518
- at
519
- });
520
- }
521
- }
522
- if (deltaRows.length > 0) {
523
- const bytes = encodeRowsToParquetFlex(deltaRows, {
524
- columns: URLS_DELTA_COLUMNS,
525
- sortKey: ["url_hash"]
526
- });
527
- await ds.write(sitemapUrlsDeltaKey(ctx, fpHash, date), bytes);
528
- }
529
- return {
530
- added,
531
- removed,
532
- kept,
533
- contentHash,
534
- unchanged: false
535
- };
575
+ async *loadUrls(ctx, feedpath, loadOpts) {
576
+ const feedpathHash = hash(feedpath);
577
+ const listedDeltaKeys = (await ds.list(`${sitemapUrlsPrefix(ctx)}/deltas/`)).filter((key) => parseSitemapUrlsDeltaKey(key)?.feedpathHash === feedpathHash);
578
+ const listedDeltaFiles = await readSitemapDeltaFiles(ds, listedDeltaKeys);
579
+ const manifestBytes = await readOptional(ds, sitemapUrlsProjectionManifestKey(ctx));
580
+ const manifest = manifestBytes ? decodeSitemapProjectionManifest(new TextDecoder().decode(manifestBytes)) : void 0;
581
+ const indexRows = await readOptional(ds, sitemapUrlsIndexKey(ctx, feedpathHash)).then((bytes) => bytes ? decodeParquetToRows(bytes) : []);
582
+ const currentDeltaKeys = new Set(selectSitemapProjectionFiles([], listedDeltaKeys, manifest).deltaKeys);
583
+ const state = createSitemapUrlState(indexRows);
584
+ applySitemapDeltaFiles(state, listedDeltaFiles.filter((file) => currentDeltaKeys.has(file.key)));
585
+ for (const record of state.live.values()) yield record;
586
+ if (loadOpts?.includeRemoved) for (const record of state.removed.values()) yield record;
536
587
  },
537
- async *loadUrls(ctx, feedpath, opts) {
538
- const fpHash = hash(feedpath);
539
- const includeRemoved = opts?.includeRemoved ?? false;
540
- const [indexRows, listedDeltaKeys] = await Promise.all([readOptional(ds, sitemapUrlsIndexKey(ctx, fpHash)).then((bytes) => bytes ? decodeParquetToRows(bytes) : []), ds.list(`${sitemapUrlsPrefix(ctx)}/deltas/`)]);
541
- const deltaKeys = listedDeltaKeys.filter((key) => {
542
- return SITEMAP_URLS_DELTA_PREFIX_RE.exec(key)?.[2] === fpHash;
543
- }).sort();
544
- const live = /* @__PURE__ */ new Map();
545
- const removedMap = /* @__PURE__ */ new Map();
546
- for (const row of indexRows) {
547
- const rec = rowToUrlRecord(row);
548
- if (rec.removedAt != null) removedMap.set(rec.urlHash, rec);
549
- else live.set(rec.urlHash, rec);
550
- }
551
- const deltas = await mapEntityIo(deltaKeys, async (key) => {
552
- const dBytes = await readOptional(ds, key);
553
- if (!dBytes) return [];
554
- return decodeParquetToRows(dBytes);
588
+ async *loadDeltas(ctx, dateRange) {
589
+ const [listedKeys, manifestBytes] = await Promise.all([ds.list(`${sitemapUrlsPrefix(ctx)}/deltas/`), readOptional(ds, sitemapUrlsProjectionManifestKey(ctx))]);
590
+ const keys = selectSitemapProjectionFiles([], listedKeys, manifestBytes ? decodeSitemapProjectionManifest(new TextDecoder().decode(manifestBytes)) : void 0).deltaKeys.filter((key) => {
591
+ const parsed = parseSitemapUrlsDeltaKey(key);
592
+ return Boolean(parsed && dateInRange(parsed.date, dateRange));
555
593
  });
556
- for (const dRows of deltas) for (const r of dRows) {
557
- const op = String(r.op);
558
- const urlHash = String(r.url_hash);
559
- const at = Number(r.at);
560
- if (op === "added") {
561
- const prev = live.get(urlHash) ?? removedMap.get(urlHash);
562
- removedMap.delete(urlHash);
563
- live.set(urlHash, {
564
- feedpath,
565
- feedpathHash: fpHash,
566
- urlHash,
567
- loc: String(r.loc),
568
- lastmod: r.lastmod == null ? void 0 : String(r.lastmod),
569
- firstSeenAt: prev?.firstSeenAt ?? at,
570
- lastSeenAt: at
571
- });
572
- } else if (op === "removed") {
573
- const prev = live.get(urlHash);
574
- live.delete(urlHash);
575
- if (prev) removedMap.set(urlHash, {
576
- ...prev,
577
- removedAt: at
578
- });
579
- }
594
+ const files = await readSitemapDeltaFiles(ds, keys);
595
+ for (const file of files) for (const row of file.rows) {
596
+ const op = String(row.op);
597
+ if (op !== "added" && op !== "removed") continue;
598
+ yield {
599
+ feedpath: String(row.feedpath),
600
+ feedpathHash: String(row.feedpath_hash),
601
+ urlHash: String(row.url_hash),
602
+ op,
603
+ loc: String(row.loc),
604
+ lastmod: row.lastmod == null ? void 0 : String(row.lastmod),
605
+ at: Number(row.at)
606
+ };
580
607
  }
581
- for (const rec of live.values()) yield rec;
582
- if (includeRemoved) for (const rec of removedMap.values()) yield rec;
583
608
  },
584
- async *loadDeltas(ctx, dateRange) {
585
- const from = dateRange?.from;
586
- const to = dateRange?.to;
587
- const keys = (await ds.list(`${sitemapUrlsPrefix(ctx)}/deltas/`)).sort();
609
+ async *loadEvents(ctx, dateRange) {
610
+ const listedEventKeys = await ds.list(`${sitemapUrlsEventsPrefix(ctx)}/`);
611
+ const pendingKeys = await ds.list(`${sitemapUrlsPendingGenerationsPrefix(ctx)}/`);
612
+ const pendingEvents = new Set((await mapEntityIo(pendingKeys, (key) => readJson(key))).filter((pending) => pending !== void 0).map((pending) => pending.eventKey));
613
+ const keys = listedEventKeys.filter((key) => {
614
+ const match = SITEMAP_URLS_EVENT_PREFIX_RE.exec(key);
615
+ return !pendingEvents.has(key) && Boolean(match?.[1] && dateInRange(match[1], dateRange));
616
+ }).sort();
588
617
  for (const key of keys) {
589
- const m = SITEMAP_URLS_DELTA_PREFIX_RE.exec(key);
590
- if (!m) continue;
591
- const date = m[1];
592
- if (!date) continue;
593
- if (from && date < from) continue;
594
- if (to && date > to) continue;
595
618
  const bytes = await readOptional(ds, key);
596
619
  if (!bytes) continue;
597
620
  const rows = await decodeParquetToRows(bytes);
598
- for (const r of rows) {
599
- const op = String(r.op);
621
+ rows.sort((a, b) => Number(a.observed_at) - Number(b.observed_at) || Number(a.sequence) - Number(b.sequence) || String(a.url_hash).localeCompare(String(b.url_hash)));
622
+ for (const row of rows) {
623
+ const op = String(row.op);
600
624
  if (op !== "added" && op !== "removed") continue;
601
625
  yield {
602
- feedpath: String(r.feedpath),
603
- feedpathHash: String(r.feedpath_hash),
604
- urlHash: String(r.url_hash),
626
+ feedpath: String(row.feedpath),
627
+ feedpathHash: String(row.feedpath_hash),
628
+ urlHash: String(row.url_hash),
605
629
  op,
606
- loc: String(r.loc),
607
- lastmod: r.lastmod == null ? void 0 : String(r.lastmod),
608
- at: Number(r.at)
630
+ loc: String(row.loc),
631
+ lastmod: row.lastmod == null ? void 0 : String(row.lastmod),
632
+ generationId: String(row.generation_id),
633
+ observedAt: Number(row.observed_at),
634
+ sequence: Number(row.sequence),
635
+ projectsState: Boolean(row.projects_state)
609
636
  };
610
637
  }
611
638
  }
639
+ }
640
+ };
641
+ }
642
+ function createSitemapStore(opts) {
643
+ const ds = opts.dataSource;
644
+ const hash = opts.hash ?? hashUrl;
645
+ const now = opts.now ?? (() => Date.now());
646
+ const withMutation = opts.withMutation;
647
+ const readStore = createSitemapReadStore({
648
+ dataSource: ds,
649
+ hash
650
+ });
651
+ async function readJson(key) {
652
+ const bytes = await readOptional(ds, key);
653
+ return bytes === void 0 ? void 0 : JSON.parse(new TextDecoder().decode(bytes));
654
+ }
655
+ function writeJson(key, value) {
656
+ return ds.write(key, encodeJsonBigintSafe(value));
657
+ }
658
+ async function readProjectionManifest(ctx) {
659
+ const bytes = await readOptional(ds, sitemapUrlsProjectionManifestKey(ctx));
660
+ return bytes ? decodeSitemapProjectionManifest(new TextDecoder().decode(bytes)) : emptySitemapProjectionManifest();
661
+ }
662
+ async function deleteExpiredProjectionDeltas(manifest, deltaKeys, currentTime) {
663
+ const expired = deltaKeys.filter((key) => {
664
+ const parsed = parseSitemapUrlsDeltaKey(key);
665
+ const feed = parsed ? manifest.feeds[parsed.feedpathHash] : void 0;
666
+ return Boolean(feed && key <= feed.compactedThrough && currentTime - feed.publishedAt >= 9e5);
667
+ });
668
+ if (expired.length > 0) await ds.delete(expired);
669
+ }
670
+ async function publishCurrentProjection(ctx, feedpathHash, rows, consumedDeltas, manifest, publishedAt) {
671
+ const bytes = encodeRowsToParquetFlex(rows.map(urlRecordToRow), {
672
+ columns: URLS_INDEX_COLUMNS,
673
+ sortKey: ["feedpath_hash", "url_hash"]
674
+ });
675
+ await ds.write(sitemapUrlsIndexKey(ctx, feedpathHash), bytes);
676
+ const compactedThrough = consumedDeltas.at(-1)?.key;
677
+ if (!compactedThrough) return manifest;
678
+ const next = withSitemapProjectionFeed(manifest, feedpathHash, {
679
+ compactedThrough,
680
+ publishedAt
681
+ });
682
+ await writeJson(sitemapUrlsProjectionManifestKey(ctx), next);
683
+ return next;
684
+ }
685
+ function normalizedEventRows(rows) {
686
+ return rows.map((row) => ({
687
+ feedpath: String(row.feedpath),
688
+ feedpathHash: String(row.feedpath_hash),
689
+ urlHash: String(row.url_hash),
690
+ op: String(row.op),
691
+ loc: String(row.loc),
692
+ lastmod: row.lastmod == null ? null : String(row.lastmod),
693
+ generationId: String(row.generation_id),
694
+ observedAt: Number(row.observed_at),
695
+ sequence: Number(row.sequence),
696
+ projectsState: Boolean(row.projects_state),
697
+ seedGeneration: Boolean(row.seed_generation),
698
+ generationKind: String(row.generation_kind),
699
+ contentHash: row.content_hash == null ? null : String(row.content_hash),
700
+ inputCount: row.input_count == null ? null : Number(row.input_count)
701
+ })).sort((a, b) => a.observedAt - b.observedAt || a.sequence - b.sequence || a.urlHash.localeCompare(b.urlHash) || a.op.localeCompare(b.op));
702
+ }
703
+ function eventDigest(rows) {
704
+ return hashUrl(JSON.stringify(normalizedEventRows(rows)));
705
+ }
706
+ function assertGenerationAccepted(generation, checkpoint, scope) {
707
+ if (!checkpoint) return "newer";
708
+ if (generation.id === checkpoint.generationId) {
709
+ if (generation.observedAt === checkpoint.observedAt) return "same";
710
+ throw new Error(`sitemap generation conflict for ${scope}: generation ${generation.id} changed observedAt`);
711
+ }
712
+ if (generation.observedAt > checkpoint.observedAt) return "newer";
713
+ const reason = generation.observedAt === checkpoint.observedAt ? "ambiguous timestamp" : "stale generation";
714
+ throw new Error(`sitemap generation conflict for ${scope}: ${reason}`);
715
+ }
716
+ async function ensureEvents(ctx, feedpathHash, generation, rows) {
717
+ const expectedDigest = eventDigest(rows);
718
+ if (rows.length === 0) return {
719
+ rows: [],
720
+ digest: expectedDigest
721
+ };
722
+ const key = sitemapUrlsEventKey(ctx, feedpathHash, generation);
723
+ await writeJson(sitemapUrlsPendingGenerationKey(ctx, feedpathHash), {
724
+ version: 1,
725
+ generationId: generation.id,
726
+ observedAt: generation.observedAt,
727
+ eventKey: key,
728
+ eventDigest: expectedDigest
729
+ });
730
+ const existing = await readOptional(ds, key);
731
+ if (existing) {
732
+ const existingRows = await decodeParquetToRows(existing);
733
+ const existingDigest = eventDigest(existingRows);
734
+ if (existingDigest !== expectedDigest) throw new Error(`sitemap generation conflict for ${feedpathHash}: immutable event digest changed`);
735
+ return {
736
+ rows: existingRows,
737
+ digest: existingDigest
738
+ };
739
+ }
740
+ const bytes = encodeRowsToParquetFlex(rows, {
741
+ columns: URLS_EVENT_COLUMNS,
742
+ sortKey: ["sequence", "url_hash"]
743
+ });
744
+ await ds.write(key, bytes);
745
+ return {
746
+ rows: [...rows],
747
+ digest: expectedDigest
748
+ };
749
+ }
750
+ function eventRow(generation, row, metadata) {
751
+ return {
752
+ feedpath: row.feedpath,
753
+ feedpath_hash: row.feedpath_hash,
754
+ url_hash: row.url_hash,
755
+ op: row.op,
756
+ loc: row.loc,
757
+ lastmod: row.lastmod,
758
+ generation_id: generation.id,
759
+ observed_at: generation.observedAt,
760
+ sequence: metadata.sequence,
761
+ projects_state: metadata.projectsState ? 1 : 0,
762
+ seed_generation: metadata.seedGeneration ? 1 : 0,
763
+ generation_kind: metadata.generationKind,
764
+ content_hash: metadata.contentHash ?? null,
765
+ input_count: metadata.inputCount ?? null
766
+ };
767
+ }
768
+ function stateRowsFromEvents(rows) {
769
+ return rows.filter((row) => Boolean(row.projects_state)).map((row) => ({
770
+ feedpath: row.feedpath,
771
+ feedpath_hash: row.feedpath_hash,
772
+ url_hash: row.url_hash,
773
+ op: row.op,
774
+ loc: row.loc,
775
+ lastmod: row.lastmod,
776
+ at: row.observed_at,
777
+ generation_id: row.generation_id
778
+ }));
779
+ }
780
+ function checkpointFromEvents(rows, digest) {
781
+ const first = rows[0];
782
+ if (!first) throw new Error("cannot checkpoint an empty sitemap event file");
783
+ const generationId = String(first.generation_id);
784
+ const observedAt = Number(first.observed_at);
785
+ for (const row of rows) if (String(row.generation_id) !== generationId || Number(row.observed_at) !== observedAt) throw new Error("sitemap event file contains multiple generations");
786
+ if (String(first.generation_kind) === "snapshot") {
787
+ const contentHash = String(first.content_hash);
788
+ const inputCount = Number(first.input_count);
789
+ const stateRows = stateRowsFromEvents(rows);
790
+ const added = stateRows.filter((row) => String(row.op) === "added").length;
791
+ return {
792
+ _tag: "snapshot",
793
+ version: 1,
794
+ generationId,
795
+ observedAt,
796
+ eventDigest: digest,
797
+ result: {
798
+ added,
799
+ removed: stateRows.filter((row) => String(row.op) === "removed").length,
800
+ kept: Math.max(0, inputCount - added),
801
+ contentHash,
802
+ unchanged: stateRows.length === 0
803
+ }
804
+ };
805
+ }
806
+ return {
807
+ _tag: "reconcile",
808
+ version: 1,
809
+ generationId,
810
+ observedAt,
811
+ eventDigest: digest
812
+ };
813
+ }
814
+ async function projectEvents(ctx, feedpathHash, rows, digest) {
815
+ const checkpoint = checkpointFromEvents(rows, digest);
816
+ const generation = {
817
+ id: checkpoint.generationId,
818
+ observedAt: checkpoint.observedAt
819
+ };
820
+ const stateRows = stateRowsFromEvents(rows);
821
+ if (stateRows.length > 0) {
822
+ const bytes = encodeRowsToParquetFlex(stateRows, {
823
+ columns: URLS_DELTA_COLUMNS,
824
+ sortKey: ["url_hash"]
825
+ });
826
+ await ds.write(sitemapUrlsDeltaKey(ctx, feedpathHash, generation), bytes);
827
+ }
828
+ if (rows.some((row) => Boolean(row.seed_generation))) await writeJson(sitemapUrlsEventSeedKey(ctx, feedpathHash), {
829
+ version: 1,
830
+ generationId: checkpoint.generationId,
831
+ observedAt: checkpoint.observedAt
832
+ });
833
+ await writeJson(sitemapUrlsGenerationKey(ctx, feedpathHash), checkpoint);
834
+ return checkpoint;
835
+ }
836
+ async function repairFeedProjection(ctx, feedpathHash) {
837
+ const pendingKey = sitemapUrlsPendingGenerationKey(ctx, feedpathHash);
838
+ const [checkpoint, pending] = await Promise.all([readJson(sitemapUrlsGenerationKey(ctx, feedpathHash)), readJson(pendingKey)]);
839
+ if (!pending) return checkpoint;
840
+ const pendingGeneration = {
841
+ _tag: "complete",
842
+ id: pending.generationId,
843
+ observedAt: pending.observedAt
844
+ };
845
+ if (checkpoint?.generationId === pending.generationId && checkpoint.observedAt !== pending.observedAt) throw new Error(`sitemap generation conflict for ${feedpathHash}: generation ${pending.generationId} changed observedAt`);
846
+ if (checkpoint && pending.observedAt < checkpoint.observedAt) {
847
+ await ds.delete([pendingKey]);
848
+ return checkpoint;
849
+ }
850
+ const position = assertGenerationAccepted(pendingGeneration, checkpoint, feedpathHash);
851
+ const eventBytes = await readOptional(ds, pending.eventKey);
852
+ if (!eventBytes) {
853
+ await ds.delete([pendingKey]);
854
+ return checkpoint;
855
+ }
856
+ const rows = await decodeParquetToRows(eventBytes);
857
+ const digest = eventDigest(rows);
858
+ if (digest !== pending.eventDigest) throw new Error(`sitemap generation conflict for ${feedpathHash}: pending event digest changed`);
859
+ if (position === "same") {
860
+ if (checkpoint?.eventDigest !== digest) throw new Error(`sitemap generation conflict for ${feedpathHash}: checkpoint digest changed`);
861
+ await ds.delete([pendingKey]);
862
+ return checkpoint;
863
+ }
864
+ const repaired = await projectEvents(ctx, feedpathHash, rows, digest);
865
+ await ds.delete([pendingKey]);
866
+ return repaired;
867
+ }
868
+ return {
869
+ ...readStore,
870
+ writeSnapshot(ctx, records) {
871
+ return withMutation(ctx, async () => {
872
+ if (records.length === 0) return;
873
+ const indexKey = sitemapIndexKey(ctx);
874
+ const index = await readJson(indexKey) ?? {
875
+ version: 1,
876
+ records: {}
877
+ };
878
+ const stamp = now();
879
+ const historyDocs = /* @__PURE__ */ new Map();
880
+ for (const record of records) {
881
+ const feedpathHash = hash(record.path);
882
+ index.records[feedpathHash] = record;
883
+ historyDocs.set(sitemapHistoryKey(ctx, feedpathHash, stamp), {
884
+ version: 1,
885
+ path: record.path,
886
+ capturedAt: record.capturedAt,
887
+ record
888
+ });
889
+ }
890
+ await mapEntityIo([...historyDocs], ([key, doc]) => writeJson(key, doc));
891
+ await writeJson(indexKey, index);
892
+ });
612
893
  },
613
- async compactUrls(ctx, opts = {}) {
614
- const startedAt = now();
615
- const deadlineMs = opts.deadlineMs ?? Number.POSITIVE_INFINITY;
616
- const maxFeedpaths = opts.maxFeedpaths ?? Number.POSITIVE_INFINITY;
617
- const deltaKeys = await ds.list(`${sitemapUrlsPrefix(ctx)}/deltas/`);
618
- const deltasByFeed = /* @__PURE__ */ new Map();
619
- for (const key of deltaKeys) {
620
- const m = SITEMAP_URLS_DELTA_PREFIX_RE.exec(key);
621
- if (!m) continue;
622
- const feedpathHash = m[2];
623
- if (!feedpathHash) continue;
624
- const list = deltasByFeed.get(feedpathHash) ?? [];
625
- list.push(key);
626
- deltasByFeed.set(feedpathHash, list);
627
- }
628
- const totalFeedpaths = deltasByFeed.size;
629
- let compactedFeedpaths = 0;
630
- for (const [fpHash, feedDeltaKeys] of deltasByFeed) {
631
- if (compactedFeedpaths > 0 && (compactedFeedpaths >= maxFeedpaths || now() - startedAt >= deadlineMs)) break;
632
- const indexKey = sitemapUrlsIndexKey(ctx, fpHash);
633
- const [indexRows, deltaFiles] = await Promise.all([readOptional(ds, indexKey).then((bytes) => bytes ? decodeParquetToRows(bytes) : []), mapEntityIo(feedDeltaKeys.sort(), async (key) => {
634
- const bytes = await readOptional(ds, key);
635
- if (!bytes) return void 0;
636
- return {
637
- key,
638
- rows: await decodeParquetToRows(bytes)
639
- };
640
- })]);
641
- const live = /* @__PURE__ */ new Map();
642
- const removed = /* @__PURE__ */ new Map();
643
- for (const row of indexRows) {
644
- const rec = rowToUrlRecord(row);
645
- if (rec.removedAt != null) removed.set(rec.urlHash, rec);
646
- else live.set(rec.urlHash, rec);
894
+ snapshotUrls(ctx, generation, feedpath, urls) {
895
+ return withMutation(ctx, async () => {
896
+ const feedpathHash = hash(feedpath);
897
+ const contentHash = hashUrlList(urls);
898
+ const checkpoint = await repairFeedProjection(ctx, feedpathHash);
899
+ if (assertGenerationAccepted(generation, checkpoint, feedpathHash) === "same") {
900
+ if (checkpoint?._tag !== "snapshot" || checkpoint.result.contentHash !== contentHash) throw new Error(`sitemap generation conflict for ${feedpathHash}: snapshot input changed`);
901
+ return checkpoint.result;
647
902
  }
648
- const consumed = [];
649
- for (const file of deltaFiles) {
650
- if (!file) continue;
651
- const { key, rows } = file;
652
- consumed.push(key);
653
- for (const r of rows) {
654
- const urlHash = String(r.url_hash);
655
- const at = Number(r.at);
656
- const op = String(r.op);
657
- if (op === "added") {
658
- const prev = live.get(urlHash) ?? removed.get(urlHash);
659
- removed.delete(urlHash);
660
- live.set(urlHash, {
661
- feedpath: String(r.feedpath),
662
- feedpathHash: fpHash,
663
- urlHash,
664
- loc: String(r.loc),
665
- lastmod: r.lastmod == null ? void 0 : String(r.lastmod),
666
- firstSeenAt: prev?.firstSeenAt ?? at,
667
- lastSeenAt: at
668
- });
669
- } else if (op === "removed") {
670
- const prev = live.get(urlHash);
671
- live.delete(urlHash);
672
- if (prev) removed.set(urlHash, {
673
- ...prev,
674
- removedAt: at
675
- });
676
- }
677
- }
903
+ if (assertGenerationAccepted(generation, await readJson(sitemapUrlsReconcileGenerationKey(ctx)), "site reconciliation") === "same") throw new Error("sitemap generation conflict: site generation was already reconciled");
904
+ const priorByHash = /* @__PURE__ */ new Map();
905
+ for await (const record of readStore.loadUrls(ctx, feedpath, { includeRemoved: true })) priorByHash.set(record.urlHash, record);
906
+ const livePrior = [...priorByHash.values()].filter((record) => record.removedAt == null);
907
+ const incomingByHash = /* @__PURE__ */ new Map();
908
+ for (const url of urls) incomingByHash.set(hash(url.loc), url);
909
+ const deltaRows = [];
910
+ let added = 0;
911
+ let removed = 0;
912
+ let kept = 0;
913
+ for (const [urlHash, url] of incomingByHash) {
914
+ const previous = priorByHash.get(urlHash);
915
+ if (!previous || previous.removedAt != null) {
916
+ added++;
917
+ deltaRows.push({
918
+ feedpath,
919
+ feedpath_hash: feedpathHash,
920
+ url_hash: urlHash,
921
+ op: "added",
922
+ loc: url.loc,
923
+ lastmod: url.lastmod ?? null,
924
+ at: generation.observedAt,
925
+ generation_id: generation.id
926
+ });
927
+ } else kept++;
678
928
  }
679
- const merged = [...live.values(), ...removed.values()];
680
- merged.sort((a, b) => a.urlHash < b.urlHash ? -1 : a.urlHash > b.urlHash ? 1 : 0);
681
- const bytes = encodeRowsToParquetFlex(merged.map(urlRecordToRow), {
682
- columns: URLS_INDEX_COLUMNS,
683
- sortKey: ["feedpath_hash", "url_hash"]
684
- });
685
- await ds.write(indexKey, bytes);
686
- if (consumed.length > 0) await ds.delete(consumed);
687
- compactedFeedpaths++;
688
- }
689
- return {
690
- compactedFeedpaths,
691
- remainingFeedpaths: totalFeedpaths - compactedFeedpaths
692
- };
929
+ for (const [urlHash, previous] of priorByHash) if (previous.removedAt == null && !incomingByHash.has(urlHash)) {
930
+ removed++;
931
+ deltaRows.push({
932
+ feedpath,
933
+ feedpath_hash: feedpathHash,
934
+ url_hash: urlHash,
935
+ op: "removed",
936
+ loc: previous.loc,
937
+ lastmod: previous.lastmod ?? null,
938
+ at: generation.observedAt,
939
+ generation_id: generation.id
940
+ });
941
+ }
942
+ const seedKey = sitemapUrlsEventSeedKey(ctx, feedpathHash);
943
+ const seed = await readJson(seedKey);
944
+ const isSeedGeneration = seed === void 0 || seed.generationId === generation.id;
945
+ const seedRows = isSeedGeneration ? livePrior.map((record) => ({
946
+ feedpath,
947
+ feedpath_hash: feedpathHash,
948
+ url_hash: record.urlHash,
949
+ op: "added",
950
+ loc: record.loc,
951
+ lastmod: record.lastmod ?? null
952
+ })) : [];
953
+ const actualSequence = seedRows.length > 0 ? 1 : 0;
954
+ const inputCount = incomingByHash.size;
955
+ const persistedEvents = await ensureEvents(ctx, feedpathHash, generation, [...seedRows.map((row) => eventRow(generation, row, {
956
+ sequence: 0,
957
+ projectsState: false,
958
+ seedGeneration: isSeedGeneration,
959
+ generationKind: "snapshot",
960
+ contentHash,
961
+ inputCount
962
+ })), ...deltaRows.map((row) => eventRow(generation, row, {
963
+ sequence: actualSequence,
964
+ projectsState: true,
965
+ seedGeneration: isSeedGeneration,
966
+ generationKind: "snapshot",
967
+ contentHash,
968
+ inputCount
969
+ }))]);
970
+ const result = {
971
+ added,
972
+ removed,
973
+ kept,
974
+ contentHash,
975
+ unchanged: deltaRows.length === 0
976
+ };
977
+ if (persistedEvents.rows.length > 0) {
978
+ if ((await projectEvents(ctx, feedpathHash, persistedEvents.rows, persistedEvents.digest))._tag !== "snapshot") throw new Error(`sitemap generation conflict for ${feedpathHash}: expected snapshot event`);
979
+ await ds.delete([sitemapUrlsPendingGenerationKey(ctx, feedpathHash)]);
980
+ } else {
981
+ if (isSeedGeneration) await writeJson(seedKey, {
982
+ version: 1,
983
+ generationId: generation.id,
984
+ observedAt: generation.observedAt
985
+ });
986
+ await writeJson(sitemapUrlsGenerationKey(ctx, feedpathHash), {
987
+ _tag: "snapshot",
988
+ version: 1,
989
+ generationId: generation.id,
990
+ observedAt: generation.observedAt,
991
+ eventDigest: persistedEvents.digest,
992
+ result
993
+ });
994
+ }
995
+ return result;
996
+ });
693
997
  },
694
- async reconcile(ctx, { liveFeedpaths, at: atOpt }) {
695
- const at = atOpt ?? now();
696
- const liveHashes = new Set(liveFeedpaths.map((fp) => hash(fp)));
697
- const present = /* @__PURE__ */ new Set();
698
- const [indexKeys, deltaKeys] = await Promise.all([ds.list(`${sitemapUrlsIndexPrefix(ctx)}/`), ds.list(`${sitemapUrlsPrefix(ctx)}/deltas/`)]);
699
- for (const key of indexKeys) {
700
- const m = /\/by-feed\/([0-9a-f]+)\/index\.parquet$/.exec(key);
701
- if (m) present.add(m[1]);
702
- }
703
- const deltasByFeed = /* @__PURE__ */ new Map();
704
- for (const key of deltaKeys) {
705
- const m = SITEMAP_URLS_DELTA_PREFIX_RE.exec(key);
706
- if (!m) continue;
707
- present.add(m[2]);
708
- const list = deltasByFeed.get(m[2]) ?? [];
709
- list.push(key);
710
- deltasByFeed.set(m[2], list);
711
- }
712
- let feedpathsPruned = 0;
713
- let urlsRemoved = 0;
714
- for (const fpHash of present) {
715
- if (liveHashes.has(fpHash)) continue;
716
- const indexKey = sitemapUrlsIndexKey(ctx, fpHash);
717
- const [indexRows, deltaFiles] = await Promise.all([readOptional(ds, indexKey).then((bytes) => bytes ? decodeParquetToRows(bytes) : []), mapEntityIo((deltasByFeed.get(fpHash) ?? []).sort(), async (key) => {
718
- const bytes = await readOptional(ds, key);
719
- if (!bytes) return void 0;
998
+ compactUrls(ctx, compactOpts = {}) {
999
+ return withMutation(ctx, async () => {
1000
+ const startedAt = now();
1001
+ const deadlineMs = compactOpts.deadlineMs ?? Number.POSITIVE_INFINITY;
1002
+ const maxFeedpaths = compactOpts.maxFeedpaths ?? Number.POSITIVE_INFINITY;
1003
+ for (const key of await ds.list(`${sitemapUrlsPendingGenerationsPrefix(ctx)}/`)) {
1004
+ const feedpathHash = SITEMAP_URLS_PENDING_GENERATION_RE.exec(key)?.[1];
1005
+ if (feedpathHash) await repairFeedProjection(ctx, feedpathHash);
1006
+ }
1007
+ const deltaKeys = await ds.list(`${sitemapUrlsPrefix(ctx)}/deltas/`);
1008
+ let projectionManifest = await readProjectionManifest(ctx);
1009
+ await deleteExpiredProjectionDeltas(projectionManifest, deltaKeys, startedAt);
1010
+ const activeDeltaKeys = selectSitemapProjectionFiles([], deltaKeys, projectionManifest).deltaKeys;
1011
+ const deltasByFeed = /* @__PURE__ */ new Map();
1012
+ for (const key of activeDeltaKeys) {
1013
+ const feedpathHash = parseSitemapUrlsDeltaKey(key)?.feedpathHash;
1014
+ if (!feedpathHash) continue;
1015
+ const keys = deltasByFeed.get(feedpathHash) ?? [];
1016
+ keys.push(key);
1017
+ deltasByFeed.set(feedpathHash, keys);
1018
+ }
1019
+ const totalFeedpaths = deltasByFeed.size;
1020
+ let compactedFeedpaths = 0;
1021
+ for (const [feedpathHash, feedDeltaKeys] of deltasByFeed) {
1022
+ if (compactedFeedpaths > 0 && (compactedFeedpaths >= maxFeedpaths || now() - startedAt >= deadlineMs)) break;
1023
+ const [indexRows, deltaFiles] = await Promise.all([readOptional(ds, sitemapUrlsIndexKey(ctx, feedpathHash)).then((bytes) => bytes ? decodeParquetToRows(bytes) : []), readSitemapDeltaFiles(ds, feedDeltaKeys)]);
1024
+ const state = createSitemapUrlState(indexRows);
1025
+ applySitemapDeltaFiles(state, deltaFiles);
1026
+ projectionManifest = await publishCurrentProjection(ctx, feedpathHash, [...state.live.values(), ...state.removed.values()].sort((a, b) => a.urlHash.localeCompare(b.urlHash)), deltaFiles, projectionManifest, now());
1027
+ compactedFeedpaths++;
1028
+ }
1029
+ return {
1030
+ compactedFeedpaths,
1031
+ remainingFeedpaths: totalFeedpaths - compactedFeedpaths
1032
+ };
1033
+ });
1034
+ },
1035
+ reconcile(ctx, generation, { liveFeedpaths }) {
1036
+ return withMutation(ctx, async () => {
1037
+ const liveHashes = new Set(liveFeedpaths.map((feedpath) => hash(feedpath)));
1038
+ const inputDigest = hashSortedUrlList([...liveHashes]);
1039
+ const siteCheckpoint = await readJson(sitemapUrlsReconcileGenerationKey(ctx));
1040
+ if (assertGenerationAccepted(generation, siteCheckpoint, "site reconciliation") === "same") {
1041
+ if (siteCheckpoint?.inputDigest !== inputDigest) throw new Error("sitemap generation conflict: reconcile input changed");
720
1042
  return {
721
- key,
722
- rows: await decodeParquetToRows(bytes)
1043
+ feedpathsPruned: 0,
1044
+ urlsRemoved: 0
723
1045
  };
724
- })]);
725
- const live = /* @__PURE__ */ new Map();
726
- const removed = /* @__PURE__ */ new Map();
727
- for (const row of indexRows) {
728
- const r = rowToUrlRecord(row);
729
- if (r.removedAt != null) removed.set(r.urlHash, r);
730
- else live.set(r.urlHash, r);
731
1046
  }
732
- const consumed = [];
733
- for (const file of deltaFiles) {
734
- if (!file) continue;
735
- const { key, rows } = file;
736
- consumed.push(key);
737
- for (const r of rows) {
738
- const urlHash = String(r.url_hash);
739
- const dat = Number(r.at);
740
- if (String(r.op) === "added") {
741
- const prev = live.get(urlHash) ?? removed.get(urlHash);
742
- removed.delete(urlHash);
743
- live.set(urlHash, {
744
- feedpath: String(r.feedpath),
745
- feedpathHash: fpHash,
746
- urlHash,
747
- loc: String(r.loc),
748
- lastmod: r.lastmod == null ? void 0 : String(r.lastmod),
749
- firstSeenAt: prev?.firstSeenAt ?? dat,
750
- lastSeenAt: dat
751
- });
752
- } else if (String(r.op) === "removed") {
753
- const prev = live.get(urlHash);
754
- live.delete(urlHash);
755
- if (prev) removed.set(urlHash, {
756
- ...prev,
757
- removedAt: dat
758
- });
759
- }
760
- }
1047
+ const pendingFeedHashes = /* @__PURE__ */ new Set();
1048
+ for (const key of await ds.list(`${sitemapUrlsPendingGenerationsPrefix(ctx)}/`)) {
1049
+ const feedpathHash = SITEMAP_URLS_PENDING_GENERATION_RE.exec(key)?.[1];
1050
+ if (feedpathHash) pendingFeedHashes.add(feedpathHash);
1051
+ }
1052
+ const repairedCheckpoints = /* @__PURE__ */ new Map();
1053
+ for (const feedpathHash of pendingFeedHashes) repairedCheckpoints.set(feedpathHash, await repairFeedProjection(ctx, feedpathHash));
1054
+ const present = /* @__PURE__ */ new Set();
1055
+ const [indexKeys, deltaKeys] = await Promise.all([ds.list(`${sitemapUrlsIndexPrefix(ctx)}/`), ds.list(`${sitemapUrlsPrefix(ctx)}/deltas/`)]);
1056
+ let projectionManifest = await readProjectionManifest(ctx);
1057
+ await deleteExpiredProjectionDeltas(projectionManifest, deltaKeys, now());
1058
+ for (const feedpathHash of Object.keys(projectionManifest.feeds)) present.add(feedpathHash);
1059
+ for (const key of indexKeys) {
1060
+ const match = /\/by-feed\/([0-9a-f]+)\/index\.parquet$/.exec(key);
1061
+ if (match?.[1]) present.add(match[1]);
761
1062
  }
762
- const hadLive = live.size > 0;
763
- if (!hadLive && consumed.length === 0) continue;
764
- for (const [urlHash, r] of live) {
765
- removed.set(urlHash, {
766
- ...r,
767
- removedAt: at
1063
+ const deltasByFeed = /* @__PURE__ */ new Map();
1064
+ const activeDeltaKeys = selectSitemapProjectionFiles([], deltaKeys, projectionManifest).deltaKeys;
1065
+ for (const key of activeDeltaKeys) {
1066
+ const feedpathHash = parseSitemapUrlsDeltaKey(key)?.feedpathHash;
1067
+ if (!feedpathHash) continue;
1068
+ present.add(feedpathHash);
1069
+ const keys = deltasByFeed.get(feedpathHash) ?? [];
1070
+ keys.push(key);
1071
+ deltasByFeed.set(feedpathHash, keys);
1072
+ }
1073
+ let feedpathsPruned = 0;
1074
+ let urlsRemoved = 0;
1075
+ for (const feedpathHash of present) {
1076
+ if (liveHashes.has(feedpathHash)) continue;
1077
+ const checkpoint = repairedCheckpoints.has(feedpathHash) ? repairedCheckpoints.get(feedpathHash) : await repairFeedProjection(ctx, feedpathHash);
1078
+ const feedPosition = assertGenerationAccepted(generation, checkpoint, feedpathHash);
1079
+ if (feedPosition === "same" && checkpoint?._tag !== "reconcile") throw new Error(`sitemap generation conflict for ${feedpathHash}: expected reconcile event`);
1080
+ const [indexRows, deltaFiles] = await Promise.all([readOptional(ds, sitemapUrlsIndexKey(ctx, feedpathHash)).then((bytes) => bytes ? decodeParquetToRows(bytes) : []), readSitemapDeltaFiles(ds, deltasByFeed.get(feedpathHash) ?? [])]);
1081
+ const state = createSitemapUrlState(indexRows);
1082
+ applySitemapDeltaFiles(state, deltaFiles);
1083
+ const live = [...state.live.values()];
1084
+ const seedKey = sitemapUrlsEventSeedKey(ctx, feedpathHash);
1085
+ const seed = await readJson(seedKey);
1086
+ const isSeedGeneration = seed === void 0 || seed.generationId === generation.id;
1087
+ const seedRows = isSeedGeneration ? live.map((record) => ({
1088
+ feedpath: record.feedpath,
1089
+ feedpath_hash: feedpathHash,
1090
+ url_hash: record.urlHash,
1091
+ op: "added",
1092
+ loc: record.loc,
1093
+ lastmod: record.lastmod ?? null
1094
+ })) : [];
1095
+ const removalRows = live.map((record) => ({
1096
+ feedpath: record.feedpath,
1097
+ feedpath_hash: feedpathHash,
1098
+ url_hash: record.urlHash,
1099
+ op: "removed",
1100
+ loc: record.loc,
1101
+ lastmod: record.lastmod ?? null
1102
+ }));
1103
+ const proposedEvents = feedPosition === "newer" ? [...seedRows.map((row) => eventRow(generation, row, {
1104
+ sequence: 0,
1105
+ projectsState: false,
1106
+ seedGeneration: isSeedGeneration,
1107
+ generationKind: "reconcile"
1108
+ })), ...removalRows.map((row) => eventRow(generation, row, {
1109
+ sequence: seedRows.length > 0 ? 1 : 0,
1110
+ projectsState: true,
1111
+ seedGeneration: isSeedGeneration,
1112
+ generationKind: "reconcile"
1113
+ }))] : [];
1114
+ const persistedEvents = feedPosition === "newer" ? await ensureEvents(ctx, feedpathHash, generation, proposedEvents) : {
1115
+ rows: [],
1116
+ digest: checkpoint.eventDigest
1117
+ };
1118
+ if (isSeedGeneration && feedPosition === "newer") await writeJson(seedKey, {
1119
+ version: 1,
1120
+ generationId: generation.id,
1121
+ observedAt: generation.observedAt
768
1122
  });
769
- urlsRemoved++;
1123
+ const removedHashes = new Set(persistedEvents.rows.filter((row) => Boolean(row.projects_state) && String(row.op) === "removed").map((row) => String(row.url_hash)));
1124
+ const removedBeforeFeed = urlsRemoved;
1125
+ for (const record of live.filter((record) => removedHashes.has(record.urlHash))) {
1126
+ state.live.delete(record.urlHash);
1127
+ state.removed.set(record.urlHash, {
1128
+ ...record,
1129
+ removedAt: generation.observedAt
1130
+ });
1131
+ urlsRemoved++;
1132
+ }
1133
+ if (live.length > 0 || deltaFiles.length > 0) projectionManifest = await publishCurrentProjection(ctx, feedpathHash, [...state.live.values(), ...state.removed.values()].sort((a, b) => a.urlHash.localeCompare(b.urlHash)), deltaFiles, projectionManifest, now());
1134
+ if (feedPosition === "newer") {
1135
+ const feedCheckpoint = persistedEvents.rows.length > 0 ? checkpointFromEvents(persistedEvents.rows, persistedEvents.digest) : {
1136
+ _tag: "reconcile",
1137
+ version: 1,
1138
+ generationId: generation.id,
1139
+ observedAt: generation.observedAt,
1140
+ eventDigest: persistedEvents.digest
1141
+ };
1142
+ await writeJson(sitemapUrlsGenerationKey(ctx, feedpathHash), feedCheckpoint);
1143
+ if (persistedEvents.rows.length > 0) await ds.delete([sitemapUrlsPendingGenerationKey(ctx, feedpathHash)]);
1144
+ }
1145
+ if (urlsRemoved > removedBeforeFeed) feedpathsPruned++;
770
1146
  }
771
- const merged = [...removed.values()];
772
- merged.sort((a, b) => a.urlHash < b.urlHash ? -1 : a.urlHash > b.urlHash ? 1 : 0);
773
- const bytes = encodeRowsToParquetFlex(merged.map(urlRecordToRow), {
774
- columns: URLS_INDEX_COLUMNS,
775
- sortKey: ["feedpath_hash", "url_hash"]
1147
+ await writeJson(sitemapUrlsReconcileGenerationKey(ctx), {
1148
+ version: 1,
1149
+ generationId: generation.id,
1150
+ observedAt: generation.observedAt,
1151
+ inputDigest
776
1152
  });
777
- await ds.write(indexKey, bytes);
778
- if (consumed.length > 0) await ds.delete(consumed);
779
- if (hadLive) feedpathsPruned++;
780
- }
781
- return {
782
- feedpathsPruned,
783
- urlsRemoved
784
- };
1153
+ return {
1154
+ feedpathsPruned,
1155
+ urlsRemoved
1156
+ };
1157
+ });
785
1158
  }
786
1159
  };
787
1160
  }
@@ -866,4 +1239,4 @@ function createEmptyTypesStore(opts) {
866
1239
  }
867
1240
  };
868
1241
  }
869
- export { INSPECTION_HISTORY_MAX_BYTES, buildQueryDimRecords, createEmptyTypesStore, createIndexingMetadataStore, createInspectionStore, createQueryDimStore, createSitemapStore, emptyTypesKey, hashSortedUrlList, hashUrl, hashUrlList, indexingMetadataIndexKey, inspectionBaseKey, inspectionEventKey, inspectionEventsPrefix, inspectionHistoryPrefix, inspectionHistoryShardKey, inspectionIndexKey, inspectionParquetKey, queryDimMetaKey, queryDimParquetKey, sitemapHistoryKey, sitemapIndexKey, sitemapUrlsDeltaKey, sitemapUrlsIndexKey, sitemapUrlsIndexPrefix, sitemapUrlsPrefix };
1242
+ export { INSPECTION_HISTORY_MAX_BYTES, SITEMAP_PROJECTION_GRACE_MS, buildQueryDimRecords, createEmptyTypesStore, createIndexingMetadataStore, createInspectionStore, createQueryDimStore, createSitemapReadStore, createSitemapStore, decodeSitemapProjectionManifest, emptySitemapProjectionManifest, emptyTypesKey, hashSortedUrlList, hashUrl, hashUrlList, indexingMetadataIndexKey, inspectionBaseKey, inspectionEventKey, inspectionEventsPrefix, inspectionHistoryPrefix, inspectionHistoryShardKey, inspectionIndexKey, inspectionParquetKey, parseSitemapProjectionManifest, parseSitemapUrlsDeltaKey, queryDimMetaKey, queryDimParquetKey, selectSitemapProjectionFiles, sitemapHistoryKey, sitemapIndexKey, sitemapUrlsDeltaKey, sitemapUrlsEventKey, sitemapUrlsEventSeedKey, sitemapUrlsEventsPrefix, sitemapUrlsGenerationKey, sitemapUrlsIndexKey, sitemapUrlsIndexPrefix, sitemapUrlsPendingGenerationKey, sitemapUrlsPendingGenerationsPrefix, sitemapUrlsPrefix, sitemapUrlsProjectionManifestKey, sitemapUrlsReconcileGenerationKey, withSitemapProjectionFeed };