@gscdump/engine 1.4.10 → 1.5.0

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.
Files changed (37) hide show
  1. package/dist/entities/empty-types.d.mts +22 -0
  2. package/dist/entities/empty-types.mjs +58 -0
  3. package/dist/entities/indexing-metadata.d.mts +26 -0
  4. package/dist/entities/indexing-metadata.mjs +31 -0
  5. package/dist/entities/inspection.d.mts +240 -0
  6. package/dist/entities/inspection.mjs +443 -0
  7. package/dist/entities/io.mjs +16 -0
  8. package/dist/{query-dim.d.mts → entities/query-dim.d.mts} +3 -3
  9. package/dist/{query-dim.mjs → entities/query-dim.mjs} +4 -4
  10. package/dist/{sitemap-projection.mjs → entities/sitemap-projection.mjs} +1 -1
  11. package/dist/entities/sitemap-shared.d.mts +199 -0
  12. package/dist/entities/sitemap-shared.mjs +243 -0
  13. package/dist/entities/sitemap-write.d.mts +3 -0
  14. package/dist/entities/sitemap-write.mjs +528 -0
  15. package/dist/entities/sitemap.d.mts +3 -0
  16. package/dist/entities/sitemap.mjs +91 -0
  17. package/dist/entities.d.mts +9 -481
  18. package/dist/entities.mjs +7 -1380
  19. package/dist/rollups/canonical.d.mts +71 -0
  20. package/dist/rollups/canonical.mjs +335 -0
  21. package/dist/rollups/core.d.mts +201 -0
  22. package/dist/rollups/core.mjs +116 -0
  23. package/dist/rollups/dates.mjs +11 -0
  24. package/dist/rollups/defaults.d.mts +11 -0
  25. package/dist/rollups/defaults.mjs +17 -0
  26. package/dist/rollups/hourly.d.mts +38 -0
  27. package/dist/rollups/hourly.mjs +38 -0
  28. package/dist/rollups/indexing.d.mts +46 -0
  29. package/dist/rollups/indexing.mjs +357 -0
  30. package/dist/rollups/traffic.d.mts +38 -0
  31. package/dist/rollups/traffic.mjs +289 -0
  32. package/dist/rollups/windows.d.mts +90 -0
  33. package/dist/rollups/windows.mjs +176 -0
  34. package/dist/rollups.d.mts +8 -471
  35. package/dist/rollups.mjs +7 -1316
  36. package/package.json +4 -4
  37. /package/dist/{sitemap-projection.d.mts → entities/sitemap-projection.d.mts} +0 -0
package/dist/entities.mjs CHANGED
@@ -1,1382 +1,9 @@
1
1
  import { emptyTypesKey, hashSortedUrlList, hashUrl, hashUrlList, indexingMetadataIndexKey, inspectionBaseKey, inspectionEventKey, inspectionEventsPrefix, inspectionHistoryPrefix, inspectionHistoryShardKey, inspectionIndexKey, inspectionParquetKey, inspectionTransitionsMonthKey, inspectionTransitionsPrefix, parseSitemapUrlsDeltaKey, queryDimMetaKey, queryDimParquetKey, sitemapHistoryKey, sitemapIndexKey, sitemapUrlsDeltaKey, sitemapUrlsEventKey, sitemapUrlsEventSeedKey, sitemapUrlsEventsPrefix, sitemapUrlsGenerationKey, sitemapUrlsIndexKey, sitemapUrlsIndexPrefix, sitemapUrlsPendingGenerationKey, sitemapUrlsPendingGenerationsPrefix, sitemapUrlsPrefix, sitemapUrlsProjectionManifestKey, sitemapUrlsReconcileGenerationKey } from "./entity-keys.mjs";
2
- import { decodeParquetToRows, encodeRowsToParquetFlex } from "./adapters/hyparquet.mjs";
3
- import { readOptional } from "./adapters/read-optional.mjs";
4
- import { SITEMAP_PROJECTION_GRACE_MS, decodeSitemapProjectionManifest, emptySitemapProjectionManifest, parseSitemapProjectionManifest, selectSitemapProjectionFiles, withSitemapProjectionFeed } from "./sitemap-projection.mjs";
5
- import { buildQueryDimRecords, createQueryDimStore } from "./query-dim.mjs";
6
- import { encodeJsonBigintSafe } from "@gscdump/lakehouse/bigint";
7
- import { GSCDUMP_INDEXING_TRANSITION_FIELDS } from "@gscdump/contracts";
8
- import { classifyCanonicalDifference } from "gscdump";
9
- const YEAR_MONTH_RE = /^(\d{4})-(\d{2})-/;
10
- const ENTITY_IO_CONCURRENCY = 8;
11
- async function mapEntityIo(items, fn) {
12
- if (items.length === 0) return [];
13
- const results = Array.from({ length: items.length }, () => void 0);
14
- let next = 0;
15
- async function worker() {
16
- while (true) {
17
- const index = next++;
18
- if (index >= items.length) return;
19
- results[index] = await fn(items[index], index);
20
- }
21
- }
22
- await Promise.all(Array.from({ length: Math.min(ENTITY_IO_CONCURRENCY, items.length) }, worker));
23
- return results;
24
- }
25
- const INSPECTION_EVENT_KEY_RE = /\/inspections\/events\/\d{4}-\d{2}\/[^/]+\.parquet$/;
26
- const INSPECTION_HISTORY_MAX_BYTES = 5 * 1024 * 1024;
27
- const INSPECTION_PARQUET_COLUMNS = [
28
- {
29
- name: "urlHash",
30
- type: "VARCHAR",
31
- nullable: false
32
- },
33
- {
34
- name: "url",
35
- type: "VARCHAR",
36
- nullable: false
37
- },
38
- {
39
- name: "inspectedAt",
40
- type: "VARCHAR",
41
- nullable: false
42
- },
43
- {
44
- name: "indexStatus",
45
- type: "VARCHAR",
46
- nullable: true
47
- },
48
- {
49
- name: "lastCrawlTime",
50
- type: "VARCHAR",
51
- nullable: true
52
- },
53
- {
54
- name: "googleCanonical",
55
- type: "VARCHAR",
56
- nullable: true
57
- },
58
- {
59
- name: "userCanonical",
60
- type: "VARCHAR",
61
- nullable: true
62
- },
63
- {
64
- name: "coverageState",
65
- type: "VARCHAR",
66
- nullable: true
67
- },
68
- {
69
- name: "robotsTxtState",
70
- type: "VARCHAR",
71
- nullable: true
72
- },
73
- {
74
- name: "indexingState",
75
- type: "VARCHAR",
76
- nullable: true
77
- },
78
- {
79
- name: "pageFetchState",
80
- type: "VARCHAR",
81
- nullable: true
82
- },
83
- {
84
- name: "mobileUsabilityVerdict",
85
- type: "VARCHAR",
86
- nullable: true
87
- },
88
- {
89
- name: "richResultsVerdict",
90
- type: "VARCHAR",
91
- nullable: true
92
- },
93
- {
94
- name: "scheduleNextAt",
95
- type: "BIGINT",
96
- nullable: true
97
- },
98
- {
99
- name: "scheduleConsecutiveUnchanged",
100
- type: "INTEGER",
101
- nullable: true
102
- },
103
- {
104
- name: "schedulePolicyVersion",
105
- type: "INTEGER",
106
- nullable: true
107
- }
108
- ];
109
- const INSPECTION_EVENT_COLUMNS = [
110
- ...INSPECTION_PARQUET_COLUMNS,
111
- {
112
- name: "canonicalMismatchKind",
113
- type: "VARCHAR",
114
- nullable: false
115
- },
116
- {
117
- name: "crawlingUserAgent",
118
- type: "VARCHAR",
119
- nullable: true
120
- },
121
- {
122
- name: "richResultsItems",
123
- type: "VARCHAR",
124
- nullable: true
125
- },
126
- {
127
- name: "sitemaps",
128
- type: "VARCHAR",
129
- nullable: true
130
- },
131
- {
132
- name: "referringUrls",
133
- type: "VARCHAR",
134
- nullable: true
135
- },
136
- {
137
- name: "mobileIssues",
138
- type: "VARCHAR",
139
- nullable: true
140
- },
141
- {
142
- name: "inspectionResultLink",
143
- type: "VARCHAR",
144
- nullable: true
145
- },
146
- {
147
- name: "firstCheckedAt",
148
- type: "VARCHAR",
149
- nullable: true
150
- },
151
- {
152
- name: "checkCount",
153
- type: "INTEGER",
154
- nullable: true
155
- },
156
- {
157
- name: "nextCheckAfter",
158
- type: "BIGINT",
159
- nullable: true
160
- },
161
- {
162
- name: "nextCheckPriority",
163
- type: "VARCHAR",
164
- nullable: true
165
- }
166
- ];
167
- const CANONICAL_DIFFERENCE_KINDS = /* @__PURE__ */ new Set([
168
- "none",
169
- "formatting",
170
- "path",
171
- "cross_domain"
172
- ]);
173
- function populateCanonicalMismatchKind(row) {
174
- const current = row.canonicalMismatchKind;
175
- const expected = classifyCanonicalDifference(typeof row.userCanonical === "string" ? row.userCanonical : null, typeof row.googleCanonical === "string" ? row.googleCanonical : null);
176
- const missingOrInvalid = typeof current !== "string" || !CANONICAL_DIFFERENCE_KINDS.has(current);
177
- row.canonicalMismatchKind = expected;
178
- return missingOrInvalid || current !== expected;
179
- }
180
- const INSPECTION_TRANSITION_COLUMNS = [
181
- {
182
- name: "urlHash",
183
- type: "VARCHAR",
184
- nullable: false
185
- },
186
- {
187
- name: "url",
188
- type: "VARCHAR",
189
- nullable: false
190
- },
191
- {
192
- name: "changedAfter",
193
- type: "VARCHAR",
194
- nullable: false
195
- },
196
- {
197
- name: "changedBefore",
198
- type: "VARCHAR",
199
- nullable: false
200
- },
201
- ...GSCDUMP_INDEXING_TRANSITION_FIELDS.flatMap((field) => {
202
- const capped = field[0].toUpperCase() + field.slice(1);
203
- return [{
204
- name: `from${capped}`,
205
- type: "VARCHAR",
206
- nullable: true
207
- }, {
208
- name: `to${capped}`,
209
- type: "VARCHAR",
210
- nullable: true
211
- }];
212
- })
213
- ];
214
- function isStateTransition(before, after) {
215
- return GSCDUMP_INDEXING_TRANSITION_FIELDS.some((field) => (before[field] ?? null) !== (after[field] ?? null));
216
- }
217
- function buildTransitionRow(before, after) {
218
- const row = {
219
- urlHash: String(after.urlHash),
220
- url: String(after.url ?? before.url ?? ""),
221
- changedAfter: String(before.inspectedAt ?? ""),
222
- changedBefore: String(after.inspectedAt ?? "")
223
- };
224
- for (const field of GSCDUMP_INDEXING_TRANSITION_FIELDS) {
225
- const capped = field[0].toUpperCase() + field.slice(1);
226
- row[`from${capped}`] = before[field] ?? null;
227
- row[`to${capped}`] = after[field] ?? null;
228
- }
229
- return row;
230
- }
231
- async function appendTransitions(ds, ctx, rows) {
232
- if (rows.length === 0) return 0;
233
- const byMonth = /* @__PURE__ */ new Map();
234
- for (const row of rows) {
235
- const month = transitionMonth(row);
236
- const bucket = byMonth.get(month);
237
- if (bucket) bucket.push(row);
238
- else byMonth.set(month, [row]);
239
- }
240
- let written = 0;
241
- for (const [month, monthRows] of byMonth) {
242
- const key = inspectionTransitionsMonthKey(ctx, month);
243
- const existingBytes = await readOptional(ds, key);
244
- const existing = existingBytes ? await decodeParquetToRows(existingBytes) : [];
245
- const seen = new Set(existing.map((r) => `${String(r.urlHash)}\u0000${String(r.changedBefore)}`));
246
- const fresh = monthRows.filter((r) => {
247
- const id = `${String(r.urlHash)}\u0000${String(r.changedBefore)}`;
248
- if (seen.has(id)) return false;
249
- seen.add(id);
250
- return true;
251
- });
252
- if (fresh.length === 0) continue;
253
- const merged = [...existing, ...fresh];
254
- await ds.write(key, encodeRowsToParquetFlex(merged, {
255
- columns: INSPECTION_TRANSITION_COLUMNS,
256
- sortKey: ["urlHash"]
257
- }));
258
- written += fresh.length;
259
- }
260
- return written;
261
- }
262
- function transitionMonth(row) {
263
- return String(row.changedBefore ?? "").slice(0, 7) || "unknown";
264
- }
265
- function createInspectionStore(opts) {
266
- const ds = opts.dataSource;
267
- function shardFor(record) {
268
- const m = YEAR_MONTH_RE.exec(record.inspectedAt);
269
- return m ? `${m[1]}-${m[2]}` : "unknown";
270
- }
271
- function randomBatchId() {
272
- return typeof crypto !== "undefined" && "randomUUID" in crypto ? crypto.randomUUID() : `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
273
- }
274
- return {
275
- async appendHistory(ctx, records, options) {
276
- if (records.length === 0) return;
277
- const batchId = options?.batchId ?? randomBatchId();
278
- const byMonth = /* @__PURE__ */ new Map();
279
- for (const r of records) {
280
- const month = shardFor(r);
281
- if (!byMonth.has(month)) byMonth.set(month, []);
282
- byMonth.get(month).push(r);
283
- }
284
- await mapEntityIo([...byMonth].map(([yearMonth, batch]) => {
285
- const bytes = encodeJsonBigintSafe({
286
- version: 1,
287
- records: batch
288
- });
289
- if (bytes.byteLength > 5242880) throw new Error(`inspection history shard exceeds ${INSPECTION_HISTORY_MAX_BYTES} bytes (got ${bytes.byteLength}); split the batch`);
290
- return {
291
- key: inspectionHistoryShardKey(ctx, yearMonth, batchId),
292
- bytes
293
- };
294
- }), (shard) => ds.write(shard.key, shard.bytes));
295
- },
296
- async loadHistory(ctx, yearMonth) {
297
- const keys = await ds.list(inspectionHistoryPrefix(ctx, yearMonth));
298
- if (keys.length === 0) return void 0;
299
- return {
300
- version: 1,
301
- records: (await mapEntityIo(keys, async (key) => {
302
- const bytes = await readOptional(ds, key);
303
- if (!bytes) return [];
304
- return (await Promise.resolve().then(() => JSON.parse(new TextDecoder().decode(bytes))).catch((err) => {
305
- console.warn("[inspection.loadHistory] failed to decode shard", {
306
- key,
307
- error: err.message
308
- });
309
- }))?.records ?? [];
310
- })).flat()
311
- };
312
- },
313
- async materialize(ctx, rowIter) {
314
- const rows = Array.from(rowIter);
315
- rows.sort((a, b) => a.urlHash < b.urlHash ? -1 : a.urlHash > b.urlHash ? 1 : 0);
316
- const bytes = encodeRowsToParquetFlex(rows, {
317
- columns: INSPECTION_PARQUET_COLUMNS,
318
- sortKey: ["urlHash"]
319
- });
320
- const key = inspectionParquetKey(ctx);
321
- await ds.write(key, bytes);
322
- return {
323
- key,
324
- rowCount: rows.length,
325
- bytes: bytes.byteLength
326
- };
327
- },
328
- async appendInspectionEvents(ctx, rows, options) {
329
- if (rows.length === 0) return {
330
- keys: [],
331
- rowCount: 0
332
- };
333
- const batchId = options?.batchId ?? randomBatchId();
334
- const byMonth = /* @__PURE__ */ new Map();
335
- for (const r of rows) {
336
- const m = YEAR_MONTH_RE.exec(r.inspectedAt);
337
- const month = m ? `${m[1]}-${m[2]}` : "unknown";
338
- const bucket = byMonth.get(month) ?? [];
339
- bucket.push(r);
340
- byMonth.set(month, bucket);
341
- }
342
- const files = [...byMonth].map(([month, batch]) => {
343
- const bytes = encodeRowsToParquetFlex(batch, {
344
- columns: INSPECTION_EVENT_COLUMNS,
345
- sortKey: ["urlHash"]
346
- });
347
- return {
348
- key: inspectionEventKey(ctx, month, batchId),
349
- bytes
350
- };
351
- });
352
- await mapEntityIo(files, (file) => ds.write(file.key, file.bytes));
353
- return {
354
- keys: files.map((file) => file.key),
355
- rowCount: rows.length
356
- };
357
- },
358
- async compactInspections(ctx, opts) {
359
- const eventKeys = (await ds.list(`${inspectionEventsPrefix(ctx)}/`)).filter((k) => INSPECTION_EVENT_KEY_RE.test(k));
360
- if (eventKeys.length === 0) return {
361
- baseRowCount: 0,
362
- eventsFolded: 0,
363
- eventFilesDeleted: 0,
364
- transitionsWritten: 0
365
- };
366
- const baseKey = inspectionBaseKey(ctx);
367
- const baseBytes = await readOptional(ds, baseKey);
368
- const baseRows = baseBytes ? await decodeParquetToRows(baseBytes) : [];
369
- const latest = /* @__PURE__ */ new Map();
370
- const earliestChecked = /* @__PURE__ */ new Map();
371
- const transitions = [];
372
- const consider = (row) => {
373
- const h = String(row.urlHash);
374
- const prev = latest.get(h);
375
- if (!prev || String(row.inspectedAt ?? "") > String(prev.inspectedAt ?? "")) {
376
- if (opts?.transitions && prev && isStateTransition(prev, row)) transitions.push(buildTransitionRow(prev, row));
377
- latest.set(h, row);
378
- }
379
- const fc = row.firstCheckedAt;
380
- if (fc != null) {
381
- const fcStr = String(fc);
382
- const cur = earliestChecked.get(h);
383
- if (cur === void 0 || fcStr < cur) earliestChecked.set(h, fcStr);
384
- }
385
- };
386
- for (const row of baseRows) consider(row);
387
- let eventsFolded = 0;
388
- const consumed = [];
389
- const eventRows = [];
390
- const eventFiles = await mapEntityIo(eventKeys.sort(), async (key) => {
391
- const bytes = await readOptional(ds, key);
392
- if (!bytes) return void 0;
393
- return {
394
- key,
395
- rows: await decodeParquetToRows(bytes)
396
- };
397
- });
398
- for (const file of eventFiles) {
399
- if (!file) continue;
400
- const { key, rows } = file;
401
- consumed.push(key);
402
- eventRows.push(...rows);
403
- eventsFolded += rows.length;
404
- }
405
- eventRows.sort((a, b) => String(a.urlHash).localeCompare(String(b.urlHash)) || String(a.inspectedAt ?? "").localeCompare(String(b.inspectedAt ?? "")));
406
- for (const row of eventRows) consider(row);
407
- const merged = [];
408
- for (const [h, row] of latest) {
409
- const fc = earliestChecked.get(h);
410
- if (fc !== void 0) row.firstCheckedAt = fc;
411
- populateCanonicalMismatchKind(row);
412
- merged.push(row);
413
- }
414
- const bytes = encodeRowsToParquetFlex(merged, {
415
- columns: INSPECTION_EVENT_COLUMNS,
416
- sortKey: ["urlHash"]
417
- });
418
- const transitionsWritten = opts?.transitions ? await appendTransitions(ds, ctx, transitions) : 0;
419
- await ds.write(baseKey, bytes);
420
- if (consumed.length > 0) await ds.delete(consumed);
421
- return {
422
- baseRowCount: merged.length,
423
- eventsFolded,
424
- eventFilesDeleted: consumed.length,
425
- transitionsWritten
426
- };
427
- },
428
- async backfillCanonicalMismatchKinds(ctx) {
429
- const baseKey = inspectionBaseKey(ctx);
430
- const baseBytes = await readOptional(ds, baseKey);
431
- if (!baseBytes) return {
432
- baseRowCount: 0,
433
- rowsBackfilled: 0,
434
- rewritten: false
435
- };
436
- const rows = await decodeParquetToRows(baseBytes);
437
- let rowsBackfilled = 0;
438
- for (const row of rows) if (populateCanonicalMismatchKind(row)) rowsBackfilled++;
439
- if (rowsBackfilled === 0) return {
440
- baseRowCount: rows.length,
441
- rowsBackfilled: 0,
442
- rewritten: false
443
- };
444
- await ds.write(baseKey, encodeRowsToParquetFlex(rows, {
445
- columns: INSPECTION_EVENT_COLUMNS,
446
- sortKey: ["urlHash"]
447
- }));
448
- return {
449
- baseRowCount: rows.length,
450
- rowsBackfilled,
451
- rewritten: true
452
- };
453
- },
454
- parquetUri(ctx) {
455
- return ds.uri?.(inspectionParquetKey(ctx));
456
- }
457
- };
458
- }
459
- const SITEMAP_URLS_EVENT_PREFIX_RE = /\/urls\/events\/(\d{4}-\d{2}-\d{2})__[0-9a-f]+__\d+__[0-9a-f]+\.parquet$/;
460
- const SITEMAP_URLS_PENDING_GENERATION_RE = /\/urls\/generations\/pending\/([0-9a-f]+)\.json$/;
461
- const URLS_INDEX_COLUMNS = [
462
- {
463
- name: "feedpath",
464
- type: "VARCHAR",
465
- nullable: false
466
- },
467
- {
468
- name: "feedpath_hash",
469
- type: "VARCHAR",
470
- nullable: false
471
- },
472
- {
473
- name: "url_hash",
474
- type: "VARCHAR",
475
- nullable: false
476
- },
477
- {
478
- name: "loc",
479
- type: "VARCHAR",
480
- nullable: false
481
- },
482
- {
483
- name: "lastmod",
484
- type: "VARCHAR",
485
- nullable: true
486
- },
487
- {
488
- name: "first_seen_at",
489
- type: "BIGINT",
490
- nullable: false
491
- },
492
- {
493
- name: "last_seen_at",
494
- type: "BIGINT",
495
- nullable: false
496
- },
497
- {
498
- name: "removed_at",
499
- type: "BIGINT",
500
- nullable: true
501
- }
502
- ];
503
- const URLS_DELTA_COLUMNS = [
504
- {
505
- name: "feedpath",
506
- type: "VARCHAR",
507
- nullable: false
508
- },
509
- {
510
- name: "feedpath_hash",
511
- type: "VARCHAR",
512
- nullable: false
513
- },
514
- {
515
- name: "url_hash",
516
- type: "VARCHAR",
517
- nullable: false
518
- },
519
- {
520
- name: "op",
521
- type: "VARCHAR",
522
- nullable: false
523
- },
524
- {
525
- name: "loc",
526
- type: "VARCHAR",
527
- nullable: false
528
- },
529
- {
530
- name: "lastmod",
531
- type: "VARCHAR",
532
- nullable: true
533
- },
534
- {
535
- name: "at",
536
- type: "BIGINT",
537
- nullable: false
538
- },
539
- {
540
- name: "generation_id",
541
- type: "VARCHAR",
542
- nullable: true
543
- }
544
- ];
545
- const URLS_EVENT_COLUMNS = [
546
- {
547
- name: "feedpath",
548
- type: "VARCHAR",
549
- nullable: false
550
- },
551
- {
552
- name: "feedpath_hash",
553
- type: "VARCHAR",
554
- nullable: false
555
- },
556
- {
557
- name: "url_hash",
558
- type: "VARCHAR",
559
- nullable: false
560
- },
561
- {
562
- name: "op",
563
- type: "VARCHAR",
564
- nullable: false
565
- },
566
- {
567
- name: "loc",
568
- type: "VARCHAR",
569
- nullable: false
570
- },
571
- {
572
- name: "lastmod",
573
- type: "VARCHAR",
574
- nullable: true
575
- },
576
- {
577
- name: "generation_id",
578
- type: "VARCHAR",
579
- nullable: false
580
- },
581
- {
582
- name: "observed_at",
583
- type: "BIGINT",
584
- nullable: false
585
- },
586
- {
587
- name: "sequence",
588
- type: "INTEGER",
589
- nullable: false
590
- },
591
- {
592
- name: "projects_state",
593
- type: "INTEGER",
594
- nullable: false
595
- },
596
- {
597
- name: "seed_generation",
598
- type: "INTEGER",
599
- nullable: false
600
- },
601
- {
602
- name: "generation_kind",
603
- type: "VARCHAR",
604
- nullable: false
605
- },
606
- {
607
- name: "content_hash",
608
- type: "VARCHAR",
609
- nullable: true
610
- },
611
- {
612
- name: "input_count",
613
- type: "INTEGER",
614
- nullable: true
615
- }
616
- ];
617
- function rowToUrlRecord(row) {
618
- return {
619
- feedpath: String(row.feedpath),
620
- feedpathHash: String(row.feedpath_hash),
621
- urlHash: String(row.url_hash),
622
- loc: String(row.loc),
623
- lastmod: row.lastmod == null ? void 0 : String(row.lastmod),
624
- firstSeenAt: Number(row.first_seen_at),
625
- lastSeenAt: Number(row.last_seen_at),
626
- removedAt: row.removed_at == null ? void 0 : Number(row.removed_at)
627
- };
628
- }
629
- function urlRecordToRow(r) {
630
- return {
631
- feedpath: r.feedpath,
632
- feedpath_hash: r.feedpathHash,
633
- url_hash: r.urlHash,
634
- loc: r.loc,
635
- lastmod: r.lastmod ?? null,
636
- first_seen_at: r.firstSeenAt,
637
- last_seen_at: r.lastSeenAt,
638
- removed_at: r.removedAt ?? null
639
- };
640
- }
641
- function createSitemapUrlState(indexRows) {
642
- const state = {
643
- live: /* @__PURE__ */ new Map(),
644
- removed: /* @__PURE__ */ new Map()
645
- };
646
- for (const row of indexRows) {
647
- const record = rowToUrlRecord(row);
648
- if (record.removedAt == null) state.live.set(record.urlHash, record);
649
- else state.removed.set(record.urlHash, record);
650
- }
651
- return state;
652
- }
653
- function applySitemapDeltaRows(state, rows) {
654
- for (const row of rows) {
655
- const urlHash = String(row.url_hash);
656
- const observedAt = Number(row.at);
657
- const op = String(row.op);
658
- if (op === "added") {
659
- const previous = state.live.get(urlHash) ?? state.removed.get(urlHash);
660
- state.removed.delete(urlHash);
661
- state.live.set(urlHash, {
662
- feedpath: String(row.feedpath),
663
- feedpathHash: String(row.feedpath_hash),
664
- urlHash,
665
- loc: String(row.loc),
666
- lastmod: row.lastmod == null ? void 0 : String(row.lastmod),
667
- firstSeenAt: previous?.firstSeenAt ?? observedAt,
668
- lastSeenAt: observedAt
669
- });
670
- } else if (op === "removed") {
671
- const previous = state.live.get(urlHash);
672
- state.live.delete(urlHash);
673
- if (previous) state.removed.set(urlHash, {
674
- ...previous,
675
- removedAt: observedAt
676
- });
677
- }
678
- }
679
- }
680
- function applySitemapDeltaFiles(state, files) {
681
- for (const file of files) applySitemapDeltaRows(state, file.rows);
682
- }
683
- function sortSitemapDeltaFiles(files) {
684
- return files.sort((a, b) => a.key.localeCompare(b.key));
685
- }
686
- async function readSitemapDeltaFiles(ds, keys) {
687
- return sortSitemapDeltaFiles((await mapEntityIo(keys, async (key) => {
688
- const bytes = await readOptional(ds, key);
689
- return bytes ? {
690
- key,
691
- rows: await decodeParquetToRows(bytes)
692
- } : void 0;
693
- })).filter((file) => file !== void 0));
694
- }
695
- function dateInRange(date, range) {
696
- return (!range?.from || date >= range.from) && (!range?.to || date <= range.to);
697
- }
698
- function createSitemapReadStore(opts) {
699
- const ds = opts.dataSource;
700
- const hash = opts.hash ?? hashUrl;
701
- async function readJson(key) {
702
- const bytes = await readOptional(ds, key);
703
- return bytes === void 0 ? void 0 : JSON.parse(new TextDecoder().decode(bytes));
704
- }
705
- return {
706
- async loadIndex(ctx) {
707
- return await readJson(sitemapIndexKey(ctx)) ?? {
708
- version: 1,
709
- records: {}
710
- };
711
- },
712
- async getLatest(ctx, path) {
713
- return (await readJson(sitemapIndexKey(ctx)))?.records[hash(path)];
714
- },
715
- async *loadUrls(ctx, feedpath, loadOpts) {
716
- const feedpathHash = hash(feedpath);
717
- const listedDeltaKeys = (await ds.list(`${sitemapUrlsPrefix(ctx)}/deltas/`)).filter((key) => parseSitemapUrlsDeltaKey(key)?.feedpathHash === feedpathHash);
718
- const listedDeltaFiles = await readSitemapDeltaFiles(ds, listedDeltaKeys);
719
- const manifestBytes = await readOptional(ds, sitemapUrlsProjectionManifestKey(ctx));
720
- const manifest = manifestBytes ? decodeSitemapProjectionManifest(new TextDecoder().decode(manifestBytes)) : void 0;
721
- const indexRows = await readOptional(ds, sitemapUrlsIndexKey(ctx, feedpathHash)).then((bytes) => bytes ? decodeParquetToRows(bytes) : []);
722
- const currentDeltaKeys = new Set(selectSitemapProjectionFiles([], listedDeltaKeys, manifest).deltaKeys);
723
- const state = createSitemapUrlState(indexRows);
724
- applySitemapDeltaFiles(state, listedDeltaFiles.filter((file) => currentDeltaKeys.has(file.key)));
725
- for (const record of state.live.values()) yield record;
726
- if (loadOpts?.includeRemoved) for (const record of state.removed.values()) yield record;
727
- },
728
- async *loadDeltas(ctx, dateRange) {
729
- const [listedKeys, manifestBytes] = await Promise.all([ds.list(`${sitemapUrlsPrefix(ctx)}/deltas/`), readOptional(ds, sitemapUrlsProjectionManifestKey(ctx))]);
730
- const keys = selectSitemapProjectionFiles([], listedKeys, manifestBytes ? decodeSitemapProjectionManifest(new TextDecoder().decode(manifestBytes)) : void 0).deltaKeys.filter((key) => {
731
- const parsed = parseSitemapUrlsDeltaKey(key);
732
- return Boolean(parsed && dateInRange(parsed.date, dateRange));
733
- });
734
- const files = await readSitemapDeltaFiles(ds, keys);
735
- for (const file of files) for (const row of file.rows) {
736
- const op = String(row.op);
737
- if (op !== "added" && op !== "removed") continue;
738
- yield {
739
- feedpath: String(row.feedpath),
740
- feedpathHash: String(row.feedpath_hash),
741
- urlHash: String(row.url_hash),
742
- op,
743
- loc: String(row.loc),
744
- lastmod: row.lastmod == null ? void 0 : String(row.lastmod),
745
- at: Number(row.at)
746
- };
747
- }
748
- },
749
- async *loadEvents(ctx, dateRange) {
750
- const listedEventKeys = await ds.list(`${sitemapUrlsEventsPrefix(ctx)}/`);
751
- const pendingKeys = await ds.list(`${sitemapUrlsPendingGenerationsPrefix(ctx)}/`);
752
- const pendingEvents = new Set((await mapEntityIo(pendingKeys, (key) => readJson(key))).filter((pending) => pending !== void 0).map((pending) => pending.eventKey));
753
- const keys = listedEventKeys.filter((key) => {
754
- const match = SITEMAP_URLS_EVENT_PREFIX_RE.exec(key);
755
- return !pendingEvents.has(key) && Boolean(match?.[1] && dateInRange(match[1], dateRange));
756
- }).sort();
757
- for (const key of keys) {
758
- const bytes = await readOptional(ds, key);
759
- if (!bytes) continue;
760
- const rows = await decodeParquetToRows(bytes);
761
- 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)));
762
- for (const row of rows) {
763
- const op = String(row.op);
764
- if (op !== "added" && op !== "removed") continue;
765
- yield {
766
- feedpath: String(row.feedpath),
767
- feedpathHash: String(row.feedpath_hash),
768
- urlHash: String(row.url_hash),
769
- op,
770
- loc: String(row.loc),
771
- lastmod: row.lastmod == null ? void 0 : String(row.lastmod),
772
- generationId: String(row.generation_id),
773
- observedAt: Number(row.observed_at),
774
- sequence: Number(row.sequence),
775
- projectsState: Boolean(row.projects_state)
776
- };
777
- }
778
- }
779
- }
780
- };
781
- }
782
- function createSitemapStore(opts) {
783
- const ds = opts.dataSource;
784
- const hash = opts.hash ?? hashUrl;
785
- const now = opts.now ?? (() => Date.now());
786
- const withMutation = opts.withMutation;
787
- const readStore = createSitemapReadStore({
788
- dataSource: ds,
789
- hash
790
- });
791
- async function readJson(key) {
792
- const bytes = await readOptional(ds, key);
793
- return bytes === void 0 ? void 0 : JSON.parse(new TextDecoder().decode(bytes));
794
- }
795
- function writeJson(key, value) {
796
- return ds.write(key, encodeJsonBigintSafe(value));
797
- }
798
- async function readProjectionManifest(ctx) {
799
- const bytes = await readOptional(ds, sitemapUrlsProjectionManifestKey(ctx));
800
- return bytes ? decodeSitemapProjectionManifest(new TextDecoder().decode(bytes)) : emptySitemapProjectionManifest();
801
- }
802
- async function deleteExpiredProjectionDeltas(manifest, deltaKeys, currentTime) {
803
- const expired = deltaKeys.filter((key) => {
804
- const parsed = parseSitemapUrlsDeltaKey(key);
805
- const feed = parsed ? manifest.feeds[parsed.feedpathHash] : void 0;
806
- return Boolean(feed && key <= feed.compactedThrough && currentTime - feed.publishedAt >= 9e5);
807
- });
808
- if (expired.length > 0) await ds.delete(expired);
809
- }
810
- async function publishCurrentProjection(ctx, feedpathHash, rows, consumedDeltas, manifest, publishedAt) {
811
- const bytes = encodeRowsToParquetFlex(rows.map(urlRecordToRow), {
812
- columns: URLS_INDEX_COLUMNS,
813
- sortKey: ["feedpath_hash", "url_hash"]
814
- });
815
- await ds.write(sitemapUrlsIndexKey(ctx, feedpathHash), bytes);
816
- const compactedThrough = consumedDeltas.at(-1)?.key;
817
- if (!compactedThrough) return manifest;
818
- const next = withSitemapProjectionFeed(manifest, feedpathHash, {
819
- compactedThrough,
820
- publishedAt
821
- });
822
- await writeJson(sitemapUrlsProjectionManifestKey(ctx), next);
823
- return next;
824
- }
825
- function normalizedEventRows(rows) {
826
- return rows.map((row) => ({
827
- feedpath: String(row.feedpath),
828
- feedpathHash: String(row.feedpath_hash),
829
- urlHash: String(row.url_hash),
830
- op: String(row.op),
831
- loc: String(row.loc),
832
- lastmod: row.lastmod == null ? null : String(row.lastmod),
833
- generationId: String(row.generation_id),
834
- observedAt: Number(row.observed_at),
835
- sequence: Number(row.sequence),
836
- projectsState: Boolean(row.projects_state),
837
- seedGeneration: Boolean(row.seed_generation),
838
- generationKind: String(row.generation_kind),
839
- contentHash: row.content_hash == null ? null : String(row.content_hash),
840
- inputCount: row.input_count == null ? null : Number(row.input_count)
841
- })).sort((a, b) => a.observedAt - b.observedAt || a.sequence - b.sequence || a.urlHash.localeCompare(b.urlHash) || a.op.localeCompare(b.op));
842
- }
843
- function eventDigest(rows) {
844
- return hashUrl(JSON.stringify(normalizedEventRows(rows)));
845
- }
846
- function assertGenerationAccepted(generation, checkpoint, scope) {
847
- if (!checkpoint) return "newer";
848
- if (generation.id === checkpoint.generationId) {
849
- if (generation.observedAt === checkpoint.observedAt) return "same";
850
- throw new Error(`sitemap generation conflict for ${scope}: generation ${generation.id} changed observedAt`);
851
- }
852
- if (generation.observedAt > checkpoint.observedAt) return "newer";
853
- const reason = generation.observedAt === checkpoint.observedAt ? "ambiguous timestamp" : "stale generation";
854
- throw new Error(`sitemap generation conflict for ${scope}: ${reason}`);
855
- }
856
- async function ensureEvents(ctx, feedpathHash, generation, rows) {
857
- const expectedDigest = eventDigest(rows);
858
- if (rows.length === 0) return {
859
- rows: [],
860
- digest: expectedDigest
861
- };
862
- const key = sitemapUrlsEventKey(ctx, feedpathHash, generation);
863
- await writeJson(sitemapUrlsPendingGenerationKey(ctx, feedpathHash), {
864
- version: 1,
865
- generationId: generation.id,
866
- observedAt: generation.observedAt,
867
- eventKey: key,
868
- eventDigest: expectedDigest
869
- });
870
- const existing = await readOptional(ds, key);
871
- if (existing) {
872
- const existingRows = await decodeParquetToRows(existing);
873
- const existingDigest = eventDigest(existingRows);
874
- if (existingDigest !== expectedDigest) throw new Error(`sitemap generation conflict for ${feedpathHash}: immutable event digest changed`);
875
- return {
876
- rows: existingRows,
877
- digest: existingDigest
878
- };
879
- }
880
- const bytes = encodeRowsToParquetFlex(rows, {
881
- columns: URLS_EVENT_COLUMNS,
882
- sortKey: ["sequence", "url_hash"]
883
- });
884
- await ds.write(key, bytes);
885
- return {
886
- rows: [...rows],
887
- digest: expectedDigest
888
- };
889
- }
890
- function eventRow(generation, row, metadata) {
891
- return {
892
- feedpath: row.feedpath,
893
- feedpath_hash: row.feedpath_hash,
894
- url_hash: row.url_hash,
895
- op: row.op,
896
- loc: row.loc,
897
- lastmod: row.lastmod,
898
- generation_id: generation.id,
899
- observed_at: generation.observedAt,
900
- sequence: metadata.sequence,
901
- projects_state: metadata.projectsState ? 1 : 0,
902
- seed_generation: metadata.seedGeneration ? 1 : 0,
903
- generation_kind: metadata.generationKind,
904
- content_hash: metadata.contentHash ?? null,
905
- input_count: metadata.inputCount ?? null
906
- };
907
- }
908
- function stateRowsFromEvents(rows) {
909
- return rows.filter((row) => Boolean(row.projects_state)).map((row) => ({
910
- feedpath: row.feedpath,
911
- feedpath_hash: row.feedpath_hash,
912
- url_hash: row.url_hash,
913
- op: row.op,
914
- loc: row.loc,
915
- lastmod: row.lastmod,
916
- at: row.observed_at,
917
- generation_id: row.generation_id
918
- }));
919
- }
920
- function checkpointFromEvents(rows, digest) {
921
- const first = rows[0];
922
- if (!first) throw new Error("cannot checkpoint an empty sitemap event file");
923
- const generationId = String(first.generation_id);
924
- const observedAt = Number(first.observed_at);
925
- 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");
926
- if (String(first.generation_kind) === "snapshot") {
927
- const contentHash = String(first.content_hash);
928
- const inputCount = Number(first.input_count);
929
- const stateRows = stateRowsFromEvents(rows);
930
- const added = stateRows.filter((row) => String(row.op) === "added").length;
931
- return {
932
- _tag: "snapshot",
933
- version: 1,
934
- generationId,
935
- observedAt,
936
- eventDigest: digest,
937
- result: {
938
- added,
939
- removed: stateRows.filter((row) => String(row.op) === "removed").length,
940
- kept: Math.max(0, inputCount - added),
941
- contentHash,
942
- unchanged: stateRows.length === 0
943
- }
944
- };
945
- }
946
- return {
947
- _tag: "reconcile",
948
- version: 1,
949
- generationId,
950
- observedAt,
951
- eventDigest: digest
952
- };
953
- }
954
- async function projectEvents(ctx, feedpathHash, rows, digest) {
955
- const checkpoint = checkpointFromEvents(rows, digest);
956
- const generation = {
957
- id: checkpoint.generationId,
958
- observedAt: checkpoint.observedAt
959
- };
960
- const stateRows = stateRowsFromEvents(rows);
961
- if (stateRows.length > 0) {
962
- const bytes = encodeRowsToParquetFlex(stateRows, {
963
- columns: URLS_DELTA_COLUMNS,
964
- sortKey: ["url_hash"]
965
- });
966
- await ds.write(sitemapUrlsDeltaKey(ctx, feedpathHash, generation), bytes);
967
- }
968
- if (rows.some((row) => Boolean(row.seed_generation))) await writeJson(sitemapUrlsEventSeedKey(ctx, feedpathHash), {
969
- version: 1,
970
- generationId: checkpoint.generationId,
971
- observedAt: checkpoint.observedAt
972
- });
973
- await writeJson(sitemapUrlsGenerationKey(ctx, feedpathHash), checkpoint);
974
- return checkpoint;
975
- }
976
- async function repairFeedProjection(ctx, feedpathHash) {
977
- const pendingKey = sitemapUrlsPendingGenerationKey(ctx, feedpathHash);
978
- const [checkpoint, pending] = await Promise.all([readJson(sitemapUrlsGenerationKey(ctx, feedpathHash)), readJson(pendingKey)]);
979
- if (!pending) return checkpoint;
980
- const pendingGeneration = {
981
- _tag: "complete",
982
- id: pending.generationId,
983
- observedAt: pending.observedAt
984
- };
985
- if (checkpoint?.generationId === pending.generationId && checkpoint.observedAt !== pending.observedAt) throw new Error(`sitemap generation conflict for ${feedpathHash}: generation ${pending.generationId} changed observedAt`);
986
- if (checkpoint && pending.observedAt < checkpoint.observedAt) {
987
- await ds.delete([pendingKey]);
988
- return checkpoint;
989
- }
990
- const position = assertGenerationAccepted(pendingGeneration, checkpoint, feedpathHash);
991
- const eventBytes = await readOptional(ds, pending.eventKey);
992
- if (!eventBytes) {
993
- await ds.delete([pendingKey]);
994
- return checkpoint;
995
- }
996
- const rows = await decodeParquetToRows(eventBytes);
997
- const digest = eventDigest(rows);
998
- if (digest !== pending.eventDigest) throw new Error(`sitemap generation conflict for ${feedpathHash}: pending event digest changed`);
999
- if (position === "same") {
1000
- if (checkpoint?.eventDigest !== digest) throw new Error(`sitemap generation conflict for ${feedpathHash}: checkpoint digest changed`);
1001
- await ds.delete([pendingKey]);
1002
- return checkpoint;
1003
- }
1004
- const repaired = await projectEvents(ctx, feedpathHash, rows, digest);
1005
- await ds.delete([pendingKey]);
1006
- return repaired;
1007
- }
1008
- return {
1009
- ...readStore,
1010
- writeSnapshot(ctx, records) {
1011
- return withMutation(ctx, async () => {
1012
- if (records.length === 0) return;
1013
- const indexKey = sitemapIndexKey(ctx);
1014
- const index = await readJson(indexKey) ?? {
1015
- version: 1,
1016
- records: {}
1017
- };
1018
- const stamp = now();
1019
- const historyDocs = /* @__PURE__ */ new Map();
1020
- for (const record of records) {
1021
- const feedpathHash = hash(record.path);
1022
- index.records[feedpathHash] = record;
1023
- historyDocs.set(sitemapHistoryKey(ctx, feedpathHash, stamp), {
1024
- version: 1,
1025
- path: record.path,
1026
- capturedAt: record.capturedAt,
1027
- record
1028
- });
1029
- }
1030
- await mapEntityIo([...historyDocs], ([key, doc]) => writeJson(key, doc));
1031
- await writeJson(indexKey, index);
1032
- });
1033
- },
1034
- snapshotUrls(ctx, generation, feedpath, urls) {
1035
- return withMutation(ctx, async () => {
1036
- const feedpathHash = hash(feedpath);
1037
- const contentHash = hashUrlList(urls);
1038
- const checkpoint = await repairFeedProjection(ctx, feedpathHash);
1039
- if (assertGenerationAccepted(generation, checkpoint, feedpathHash) === "same") {
1040
- if (checkpoint?._tag !== "snapshot" || checkpoint.result.contentHash !== contentHash) throw new Error(`sitemap generation conflict for ${feedpathHash}: snapshot input changed`);
1041
- return checkpoint.result;
1042
- }
1043
- if (assertGenerationAccepted(generation, await readJson(sitemapUrlsReconcileGenerationKey(ctx)), "site reconciliation") === "same") throw new Error("sitemap generation conflict: site generation was already reconciled");
1044
- const priorByHash = /* @__PURE__ */ new Map();
1045
- for await (const record of readStore.loadUrls(ctx, feedpath, { includeRemoved: true })) priorByHash.set(record.urlHash, record);
1046
- const livePrior = [...priorByHash.values()].filter((record) => record.removedAt == null);
1047
- const incomingByHash = /* @__PURE__ */ new Map();
1048
- for (const url of urls) incomingByHash.set(hash(url.loc), url);
1049
- const deltaRows = [];
1050
- let added = 0;
1051
- let removed = 0;
1052
- let kept = 0;
1053
- for (const [urlHash, url] of incomingByHash) {
1054
- const previous = priorByHash.get(urlHash);
1055
- if (!previous || previous.removedAt != null) {
1056
- added++;
1057
- deltaRows.push({
1058
- feedpath,
1059
- feedpath_hash: feedpathHash,
1060
- url_hash: urlHash,
1061
- op: "added",
1062
- loc: url.loc,
1063
- lastmod: url.lastmod ?? null,
1064
- at: generation.observedAt,
1065
- generation_id: generation.id
1066
- });
1067
- } else kept++;
1068
- }
1069
- for (const [urlHash, previous] of priorByHash) if (previous.removedAt == null && !incomingByHash.has(urlHash)) {
1070
- removed++;
1071
- deltaRows.push({
1072
- feedpath,
1073
- feedpath_hash: feedpathHash,
1074
- url_hash: urlHash,
1075
- op: "removed",
1076
- loc: previous.loc,
1077
- lastmod: previous.lastmod ?? null,
1078
- at: generation.observedAt,
1079
- generation_id: generation.id
1080
- });
1081
- }
1082
- const seedKey = sitemapUrlsEventSeedKey(ctx, feedpathHash);
1083
- const seed = await readJson(seedKey);
1084
- const isSeedGeneration = seed === void 0 || seed.generationId === generation.id;
1085
- const seedRows = isSeedGeneration ? livePrior.map((record) => ({
1086
- feedpath,
1087
- feedpath_hash: feedpathHash,
1088
- url_hash: record.urlHash,
1089
- op: "added",
1090
- loc: record.loc,
1091
- lastmod: record.lastmod ?? null
1092
- })) : [];
1093
- const actualSequence = seedRows.length > 0 ? 1 : 0;
1094
- const inputCount = incomingByHash.size;
1095
- const persistedEvents = await ensureEvents(ctx, feedpathHash, generation, [...seedRows.map((row) => eventRow(generation, row, {
1096
- sequence: 0,
1097
- projectsState: false,
1098
- seedGeneration: isSeedGeneration,
1099
- generationKind: "snapshot",
1100
- contentHash,
1101
- inputCount
1102
- })), ...deltaRows.map((row) => eventRow(generation, row, {
1103
- sequence: actualSequence,
1104
- projectsState: true,
1105
- seedGeneration: isSeedGeneration,
1106
- generationKind: "snapshot",
1107
- contentHash,
1108
- inputCount
1109
- }))]);
1110
- const result = {
1111
- added,
1112
- removed,
1113
- kept,
1114
- contentHash,
1115
- unchanged: deltaRows.length === 0
1116
- };
1117
- if (persistedEvents.rows.length > 0) {
1118
- if ((await projectEvents(ctx, feedpathHash, persistedEvents.rows, persistedEvents.digest))._tag !== "snapshot") throw new Error(`sitemap generation conflict for ${feedpathHash}: expected snapshot event`);
1119
- await ds.delete([sitemapUrlsPendingGenerationKey(ctx, feedpathHash)]);
1120
- } else {
1121
- if (isSeedGeneration) await writeJson(seedKey, {
1122
- version: 1,
1123
- generationId: generation.id,
1124
- observedAt: generation.observedAt
1125
- });
1126
- await writeJson(sitemapUrlsGenerationKey(ctx, feedpathHash), {
1127
- _tag: "snapshot",
1128
- version: 1,
1129
- generationId: generation.id,
1130
- observedAt: generation.observedAt,
1131
- eventDigest: persistedEvents.digest,
1132
- result
1133
- });
1134
- }
1135
- return result;
1136
- });
1137
- },
1138
- compactUrls(ctx, compactOpts = {}) {
1139
- return withMutation(ctx, async () => {
1140
- const startedAt = now();
1141
- const deadlineMs = compactOpts.deadlineMs ?? Number.POSITIVE_INFINITY;
1142
- const maxFeedpaths = compactOpts.maxFeedpaths ?? Number.POSITIVE_INFINITY;
1143
- for (const key of await ds.list(`${sitemapUrlsPendingGenerationsPrefix(ctx)}/`)) {
1144
- const feedpathHash = SITEMAP_URLS_PENDING_GENERATION_RE.exec(key)?.[1];
1145
- if (feedpathHash) await repairFeedProjection(ctx, feedpathHash);
1146
- }
1147
- const deltaKeys = await ds.list(`${sitemapUrlsPrefix(ctx)}/deltas/`);
1148
- let projectionManifest = await readProjectionManifest(ctx);
1149
- await deleteExpiredProjectionDeltas(projectionManifest, deltaKeys, startedAt);
1150
- const activeDeltaKeys = selectSitemapProjectionFiles([], deltaKeys, projectionManifest).deltaKeys;
1151
- const deltasByFeed = /* @__PURE__ */ new Map();
1152
- for (const key of activeDeltaKeys) {
1153
- const feedpathHash = parseSitemapUrlsDeltaKey(key)?.feedpathHash;
1154
- if (!feedpathHash) continue;
1155
- const keys = deltasByFeed.get(feedpathHash) ?? [];
1156
- keys.push(key);
1157
- deltasByFeed.set(feedpathHash, keys);
1158
- }
1159
- const totalFeedpaths = deltasByFeed.size;
1160
- let compactedFeedpaths = 0;
1161
- for (const [feedpathHash, feedDeltaKeys] of deltasByFeed) {
1162
- if (compactedFeedpaths > 0 && (compactedFeedpaths >= maxFeedpaths || now() - startedAt >= deadlineMs)) break;
1163
- const [indexRows, deltaFiles] = await Promise.all([readOptional(ds, sitemapUrlsIndexKey(ctx, feedpathHash)).then((bytes) => bytes ? decodeParquetToRows(bytes) : []), readSitemapDeltaFiles(ds, feedDeltaKeys)]);
1164
- const state = createSitemapUrlState(indexRows);
1165
- applySitemapDeltaFiles(state, deltaFiles);
1166
- projectionManifest = await publishCurrentProjection(ctx, feedpathHash, [...state.live.values(), ...state.removed.values()].sort((a, b) => a.urlHash.localeCompare(b.urlHash)), deltaFiles, projectionManifest, now());
1167
- compactedFeedpaths++;
1168
- }
1169
- return {
1170
- compactedFeedpaths,
1171
- remainingFeedpaths: totalFeedpaths - compactedFeedpaths
1172
- };
1173
- });
1174
- },
1175
- reconcile(ctx, generation, { liveFeedpaths }) {
1176
- return withMutation(ctx, async () => {
1177
- const liveHashes = new Set(liveFeedpaths.map((feedpath) => hash(feedpath)));
1178
- const inputDigest = hashSortedUrlList([...liveHashes]);
1179
- const siteCheckpoint = await readJson(sitemapUrlsReconcileGenerationKey(ctx));
1180
- if (assertGenerationAccepted(generation, siteCheckpoint, "site reconciliation") === "same") {
1181
- if (siteCheckpoint?.inputDigest !== inputDigest) throw new Error("sitemap generation conflict: reconcile input changed");
1182
- return {
1183
- feedpathsPruned: 0,
1184
- urlsRemoved: 0
1185
- };
1186
- }
1187
- const pendingFeedHashes = /* @__PURE__ */ new Set();
1188
- for (const key of await ds.list(`${sitemapUrlsPendingGenerationsPrefix(ctx)}/`)) {
1189
- const feedpathHash = SITEMAP_URLS_PENDING_GENERATION_RE.exec(key)?.[1];
1190
- if (feedpathHash) pendingFeedHashes.add(feedpathHash);
1191
- }
1192
- const repairedCheckpoints = /* @__PURE__ */ new Map();
1193
- for (const feedpathHash of pendingFeedHashes) repairedCheckpoints.set(feedpathHash, await repairFeedProjection(ctx, feedpathHash));
1194
- const present = /* @__PURE__ */ new Set();
1195
- const [indexKeys, deltaKeys] = await Promise.all([ds.list(`${sitemapUrlsIndexPrefix(ctx)}/`), ds.list(`${sitemapUrlsPrefix(ctx)}/deltas/`)]);
1196
- let projectionManifest = await readProjectionManifest(ctx);
1197
- await deleteExpiredProjectionDeltas(projectionManifest, deltaKeys, now());
1198
- for (const feedpathHash of Object.keys(projectionManifest.feeds)) present.add(feedpathHash);
1199
- for (const key of indexKeys) {
1200
- const match = /\/by-feed\/([0-9a-f]+)\/index\.parquet$/.exec(key);
1201
- if (match?.[1]) present.add(match[1]);
1202
- }
1203
- const deltasByFeed = /* @__PURE__ */ new Map();
1204
- const activeDeltaKeys = selectSitemapProjectionFiles([], deltaKeys, projectionManifest).deltaKeys;
1205
- for (const key of activeDeltaKeys) {
1206
- const feedpathHash = parseSitemapUrlsDeltaKey(key)?.feedpathHash;
1207
- if (!feedpathHash) continue;
1208
- present.add(feedpathHash);
1209
- const keys = deltasByFeed.get(feedpathHash) ?? [];
1210
- keys.push(key);
1211
- deltasByFeed.set(feedpathHash, keys);
1212
- }
1213
- let feedpathsPruned = 0;
1214
- let urlsRemoved = 0;
1215
- for (const feedpathHash of present) {
1216
- if (liveHashes.has(feedpathHash)) continue;
1217
- const checkpoint = repairedCheckpoints.has(feedpathHash) ? repairedCheckpoints.get(feedpathHash) : await repairFeedProjection(ctx, feedpathHash);
1218
- const feedPosition = assertGenerationAccepted(generation, checkpoint, feedpathHash);
1219
- if (feedPosition === "same" && checkpoint?._tag !== "reconcile") throw new Error(`sitemap generation conflict for ${feedpathHash}: expected reconcile event`);
1220
- const [indexRows, deltaFiles] = await Promise.all([readOptional(ds, sitemapUrlsIndexKey(ctx, feedpathHash)).then((bytes) => bytes ? decodeParquetToRows(bytes) : []), readSitemapDeltaFiles(ds, deltasByFeed.get(feedpathHash) ?? [])]);
1221
- const state = createSitemapUrlState(indexRows);
1222
- applySitemapDeltaFiles(state, deltaFiles);
1223
- const live = [...state.live.values()];
1224
- const seedKey = sitemapUrlsEventSeedKey(ctx, feedpathHash);
1225
- const seed = await readJson(seedKey);
1226
- const isSeedGeneration = seed === void 0 || seed.generationId === generation.id;
1227
- const seedRows = isSeedGeneration ? live.map((record) => ({
1228
- feedpath: record.feedpath,
1229
- feedpath_hash: feedpathHash,
1230
- url_hash: record.urlHash,
1231
- op: "added",
1232
- loc: record.loc,
1233
- lastmod: record.lastmod ?? null
1234
- })) : [];
1235
- const removalRows = live.map((record) => ({
1236
- feedpath: record.feedpath,
1237
- feedpath_hash: feedpathHash,
1238
- url_hash: record.urlHash,
1239
- op: "removed",
1240
- loc: record.loc,
1241
- lastmod: record.lastmod ?? null
1242
- }));
1243
- const proposedEvents = feedPosition === "newer" ? [...seedRows.map((row) => eventRow(generation, row, {
1244
- sequence: 0,
1245
- projectsState: false,
1246
- seedGeneration: isSeedGeneration,
1247
- generationKind: "reconcile"
1248
- })), ...removalRows.map((row) => eventRow(generation, row, {
1249
- sequence: seedRows.length > 0 ? 1 : 0,
1250
- projectsState: true,
1251
- seedGeneration: isSeedGeneration,
1252
- generationKind: "reconcile"
1253
- }))] : [];
1254
- const persistedEvents = feedPosition === "newer" ? await ensureEvents(ctx, feedpathHash, generation, proposedEvents) : {
1255
- rows: [],
1256
- digest: checkpoint.eventDigest
1257
- };
1258
- if (isSeedGeneration && feedPosition === "newer") await writeJson(seedKey, {
1259
- version: 1,
1260
- generationId: generation.id,
1261
- observedAt: generation.observedAt
1262
- });
1263
- const removedHashes = new Set(persistedEvents.rows.filter((row) => Boolean(row.projects_state) && String(row.op) === "removed").map((row) => String(row.url_hash)));
1264
- const removedBeforeFeed = urlsRemoved;
1265
- for (const record of live.filter((record) => removedHashes.has(record.urlHash))) {
1266
- state.live.delete(record.urlHash);
1267
- state.removed.set(record.urlHash, {
1268
- ...record,
1269
- removedAt: generation.observedAt
1270
- });
1271
- urlsRemoved++;
1272
- }
1273
- 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());
1274
- if (feedPosition === "newer") {
1275
- const feedCheckpoint = persistedEvents.rows.length > 0 ? checkpointFromEvents(persistedEvents.rows, persistedEvents.digest) : {
1276
- _tag: "reconcile",
1277
- version: 1,
1278
- generationId: generation.id,
1279
- observedAt: generation.observedAt,
1280
- eventDigest: persistedEvents.digest
1281
- };
1282
- await writeJson(sitemapUrlsGenerationKey(ctx, feedpathHash), feedCheckpoint);
1283
- if (persistedEvents.rows.length > 0) await ds.delete([sitemapUrlsPendingGenerationKey(ctx, feedpathHash)]);
1284
- }
1285
- if (urlsRemoved > removedBeforeFeed) feedpathsPruned++;
1286
- }
1287
- await writeJson(sitemapUrlsReconcileGenerationKey(ctx), {
1288
- version: 1,
1289
- generationId: generation.id,
1290
- observedAt: generation.observedAt,
1291
- inputDigest
1292
- });
1293
- return {
1294
- feedpathsPruned,
1295
- urlsRemoved
1296
- };
1297
- });
1298
- }
1299
- };
1300
- }
1301
- function createIndexingMetadataStore(opts) {
1302
- const ds = opts.dataSource;
1303
- const hash = opts.hash ?? hashUrl;
1304
- async function readIndex(key) {
1305
- const bytes = await readOptional(ds, key);
1306
- if (bytes === void 0) return {
1307
- version: 1,
1308
- records: {}
1309
- };
1310
- return JSON.parse(new TextDecoder().decode(bytes));
1311
- }
1312
- return {
1313
- async writeBatch(ctx, records) {
1314
- if (records.length === 0) return;
1315
- const key = indexingMetadataIndexKey(ctx);
1316
- const index = await readIndex(key);
1317
- for (const r of records) index.records[hash(r.url)] = r;
1318
- await ds.write(key, encodeJsonBigintSafe(index));
1319
- },
1320
- async loadIndex(ctx) {
1321
- return readIndex(indexingMetadataIndexKey(ctx));
1322
- },
1323
- async getLatest(ctx, url) {
1324
- return (await readIndex(indexingMetadataIndexKey(ctx))).records[hash(url)];
1325
- }
1326
- };
1327
- }
1328
- function createEmptyTypesStore(opts) {
1329
- const ds = opts.dataSource;
1330
- const now = opts.now ?? (() => Date.now());
1331
- async function readDoc(key) {
1332
- const bytes = await readOptional(ds, key);
1333
- if (bytes === void 0) return {
1334
- version: 1,
1335
- emptyTypes: [],
1336
- markedAt: {}
1337
- };
1338
- return JSON.parse(new TextDecoder().decode(bytes));
1339
- }
1340
- async function writeDoc(key, doc) {
1341
- await ds.write(key, encodeJsonBigintSafe(doc));
1342
- }
1343
- return {
1344
- async load(ctx) {
1345
- return readDoc(emptyTypesKey(ctx));
1346
- },
1347
- async mark(ctx, types, at) {
1348
- if (types.length === 0) return readDoc(emptyTypesKey(ctx));
1349
- const key = emptyTypesKey(ctx);
1350
- const doc = await readDoc(key);
1351
- const stamp = at ?? now();
1352
- let changed = false;
1353
- for (const t of types) {
1354
- if (!doc.emptyTypes.includes(t)) {
1355
- doc.emptyTypes.push(t);
1356
- changed = true;
1357
- }
1358
- if (doc.markedAt[t] === void 0) {
1359
- doc.markedAt[t] = stamp;
1360
- changed = true;
1361
- }
1362
- }
1363
- if (changed) {
1364
- doc.emptyTypes.sort();
1365
- await writeDoc(key, doc);
1366
- }
1367
- return doc;
1368
- },
1369
- async clear(ctx, types) {
1370
- if (types.length === 0) return readDoc(emptyTypesKey(ctx));
1371
- const key = emptyTypesKey(ctx);
1372
- const doc = await readDoc(key);
1373
- const drop = new Set(types);
1374
- const before = doc.emptyTypes.length;
1375
- doc.emptyTypes = doc.emptyTypes.filter((t) => !drop.has(t));
1376
- for (const t of drop) delete doc.markedAt[t];
1377
- if (doc.emptyTypes.length !== before) await writeDoc(key, doc);
1378
- return doc;
1379
- }
1380
- };
1381
- }
2
+ import { createEmptyTypesStore } from "./entities/empty-types.mjs";
3
+ import { createIndexingMetadataStore } from "./entities/indexing-metadata.mjs";
4
+ import { INSPECTION_HISTORY_MAX_BYTES, createInspectionStore } from "./entities/inspection.mjs";
5
+ import { buildQueryDimRecords, createQueryDimStore } from "./entities/query-dim.mjs";
6
+ import { SITEMAP_PROJECTION_GRACE_MS, decodeSitemapProjectionManifest, emptySitemapProjectionManifest, parseSitemapProjectionManifest, selectSitemapProjectionFiles, withSitemapProjectionFeed } from "./entities/sitemap-projection.mjs";
7
+ import { createSitemapReadStore } from "./entities/sitemap.mjs";
8
+ import { createSitemapStore } from "./entities/sitemap-write.mjs";
1382
9
  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, inspectionTransitionsMonthKey, inspectionTransitionsPrefix, parseSitemapProjectionManifest, parseSitemapUrlsDeltaKey, queryDimMetaKey, queryDimParquetKey, selectSitemapProjectionFiles, sitemapHistoryKey, sitemapIndexKey, sitemapUrlsDeltaKey, sitemapUrlsEventKey, sitemapUrlsEventSeedKey, sitemapUrlsEventsPrefix, sitemapUrlsGenerationKey, sitemapUrlsIndexKey, sitemapUrlsIndexPrefix, sitemapUrlsPendingGenerationKey, sitemapUrlsPendingGenerationsPrefix, sitemapUrlsPrefix, sitemapUrlsProjectionManifestKey, sitemapUrlsReconcileGenerationKey, withSitemapProjectionFeed };