@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
@@ -0,0 +1,443 @@
1
+ import { inspectionBaseKey, inspectionEventKey, inspectionEventsPrefix, inspectionHistoryPrefix, inspectionHistoryShardKey, inspectionParquetKey, inspectionTransitionsMonthKey } from "../entity-keys.mjs";
2
+ import { readOptional } from "../adapters/read-optional.mjs";
3
+ import { decodeParquetToRows, encodeRowsToParquetFlex } from "../adapters/hyparquet.mjs";
4
+ import { mapEntityIo } from "./io.mjs";
5
+ import { encodeJsonBigintSafe } from "@gscdump/lakehouse/bigint";
6
+ import { GSCDUMP_INDEXING_TRANSITION_FIELDS } from "@gscdump/contracts";
7
+ import { classifyCanonicalDifference } from "gscdump";
8
+ const YEAR_MONTH_RE = /^(\d{4})-(\d{2})-/;
9
+ const INSPECTION_EVENT_KEY_RE = /\/inspections\/events\/\d{4}-\d{2}\/[^/]+\.parquet$/;
10
+ const INSPECTION_HISTORY_MAX_BYTES = 5 * 1024 * 1024;
11
+ const INSPECTION_PARQUET_COLUMNS = [
12
+ {
13
+ name: "urlHash",
14
+ type: "VARCHAR",
15
+ nullable: false
16
+ },
17
+ {
18
+ name: "url",
19
+ type: "VARCHAR",
20
+ nullable: false
21
+ },
22
+ {
23
+ name: "inspectedAt",
24
+ type: "VARCHAR",
25
+ nullable: false
26
+ },
27
+ {
28
+ name: "indexStatus",
29
+ type: "VARCHAR",
30
+ nullable: true
31
+ },
32
+ {
33
+ name: "lastCrawlTime",
34
+ type: "VARCHAR",
35
+ nullable: true
36
+ },
37
+ {
38
+ name: "googleCanonical",
39
+ type: "VARCHAR",
40
+ nullable: true
41
+ },
42
+ {
43
+ name: "userCanonical",
44
+ type: "VARCHAR",
45
+ nullable: true
46
+ },
47
+ {
48
+ name: "coverageState",
49
+ type: "VARCHAR",
50
+ nullable: true
51
+ },
52
+ {
53
+ name: "robotsTxtState",
54
+ type: "VARCHAR",
55
+ nullable: true
56
+ },
57
+ {
58
+ name: "indexingState",
59
+ type: "VARCHAR",
60
+ nullable: true
61
+ },
62
+ {
63
+ name: "pageFetchState",
64
+ type: "VARCHAR",
65
+ nullable: true
66
+ },
67
+ {
68
+ name: "mobileUsabilityVerdict",
69
+ type: "VARCHAR",
70
+ nullable: true
71
+ },
72
+ {
73
+ name: "richResultsVerdict",
74
+ type: "VARCHAR",
75
+ nullable: true
76
+ },
77
+ {
78
+ name: "scheduleNextAt",
79
+ type: "BIGINT",
80
+ nullable: true
81
+ },
82
+ {
83
+ name: "scheduleConsecutiveUnchanged",
84
+ type: "INTEGER",
85
+ nullable: true
86
+ },
87
+ {
88
+ name: "schedulePolicyVersion",
89
+ type: "INTEGER",
90
+ nullable: true
91
+ }
92
+ ];
93
+ const INSPECTION_EVENT_COLUMNS = [
94
+ ...INSPECTION_PARQUET_COLUMNS,
95
+ {
96
+ name: "canonicalMismatchKind",
97
+ type: "VARCHAR",
98
+ nullable: false
99
+ },
100
+ {
101
+ name: "crawlingUserAgent",
102
+ type: "VARCHAR",
103
+ nullable: true
104
+ },
105
+ {
106
+ name: "richResultsItems",
107
+ type: "VARCHAR",
108
+ nullable: true
109
+ },
110
+ {
111
+ name: "sitemaps",
112
+ type: "VARCHAR",
113
+ nullable: true
114
+ },
115
+ {
116
+ name: "referringUrls",
117
+ type: "VARCHAR",
118
+ nullable: true
119
+ },
120
+ {
121
+ name: "mobileIssues",
122
+ type: "VARCHAR",
123
+ nullable: true
124
+ },
125
+ {
126
+ name: "inspectionResultLink",
127
+ type: "VARCHAR",
128
+ nullable: true
129
+ },
130
+ {
131
+ name: "firstCheckedAt",
132
+ type: "VARCHAR",
133
+ nullable: true
134
+ },
135
+ {
136
+ name: "checkCount",
137
+ type: "INTEGER",
138
+ nullable: true
139
+ },
140
+ {
141
+ name: "nextCheckAfter",
142
+ type: "BIGINT",
143
+ nullable: true
144
+ },
145
+ {
146
+ name: "nextCheckPriority",
147
+ type: "VARCHAR",
148
+ nullable: true
149
+ }
150
+ ];
151
+ const CANONICAL_DIFFERENCE_KINDS = /* @__PURE__ */ new Set([
152
+ "none",
153
+ "formatting",
154
+ "path",
155
+ "cross_domain"
156
+ ]);
157
+ function populateCanonicalMismatchKind(row) {
158
+ const current = row.canonicalMismatchKind;
159
+ const expected = classifyCanonicalDifference(typeof row.userCanonical === "string" ? row.userCanonical : null, typeof row.googleCanonical === "string" ? row.googleCanonical : null);
160
+ const missingOrInvalid = typeof current !== "string" || !CANONICAL_DIFFERENCE_KINDS.has(current);
161
+ row.canonicalMismatchKind = expected;
162
+ return missingOrInvalid || current !== expected;
163
+ }
164
+ const INSPECTION_TRANSITION_COLUMNS = [
165
+ {
166
+ name: "urlHash",
167
+ type: "VARCHAR",
168
+ nullable: false
169
+ },
170
+ {
171
+ name: "url",
172
+ type: "VARCHAR",
173
+ nullable: false
174
+ },
175
+ {
176
+ name: "changedAfter",
177
+ type: "VARCHAR",
178
+ nullable: false
179
+ },
180
+ {
181
+ name: "changedBefore",
182
+ type: "VARCHAR",
183
+ nullable: false
184
+ },
185
+ ...GSCDUMP_INDEXING_TRANSITION_FIELDS.flatMap((field) => {
186
+ const capped = field[0].toUpperCase() + field.slice(1);
187
+ return [{
188
+ name: `from${capped}`,
189
+ type: "VARCHAR",
190
+ nullable: true
191
+ }, {
192
+ name: `to${capped}`,
193
+ type: "VARCHAR",
194
+ nullable: true
195
+ }];
196
+ })
197
+ ];
198
+ function isStateTransition(before, after) {
199
+ return GSCDUMP_INDEXING_TRANSITION_FIELDS.some((field) => (before[field] ?? null) !== (after[field] ?? null));
200
+ }
201
+ function buildTransitionRow(before, after) {
202
+ const row = {
203
+ urlHash: String(after.urlHash),
204
+ url: String(after.url ?? before.url ?? ""),
205
+ changedAfter: String(before.inspectedAt ?? ""),
206
+ changedBefore: String(after.inspectedAt ?? "")
207
+ };
208
+ for (const field of GSCDUMP_INDEXING_TRANSITION_FIELDS) {
209
+ const capped = field[0].toUpperCase() + field.slice(1);
210
+ row[`from${capped}`] = before[field] ?? null;
211
+ row[`to${capped}`] = after[field] ?? null;
212
+ }
213
+ return row;
214
+ }
215
+ async function appendTransitions(ds, ctx, rows) {
216
+ if (rows.length === 0) return 0;
217
+ const byMonth = /* @__PURE__ */ new Map();
218
+ for (const row of rows) {
219
+ const month = transitionMonth(row);
220
+ const bucket = byMonth.get(month);
221
+ if (bucket) bucket.push(row);
222
+ else byMonth.set(month, [row]);
223
+ }
224
+ let written = 0;
225
+ for (const [month, monthRows] of byMonth) {
226
+ const key = inspectionTransitionsMonthKey(ctx, month);
227
+ const existingBytes = await readOptional(ds, key);
228
+ const existing = existingBytes ? await decodeParquetToRows(existingBytes) : [];
229
+ const seen = new Set(existing.map((r) => `${String(r.urlHash)}\u0000${String(r.changedBefore)}`));
230
+ const fresh = monthRows.filter((r) => {
231
+ const id = `${String(r.urlHash)}\u0000${String(r.changedBefore)}`;
232
+ if (seen.has(id)) return false;
233
+ seen.add(id);
234
+ return true;
235
+ });
236
+ if (fresh.length === 0) continue;
237
+ const merged = [...existing, ...fresh];
238
+ await ds.write(key, encodeRowsToParquetFlex(merged, {
239
+ columns: INSPECTION_TRANSITION_COLUMNS,
240
+ sortKey: ["urlHash"]
241
+ }));
242
+ written += fresh.length;
243
+ }
244
+ return written;
245
+ }
246
+ function transitionMonth(row) {
247
+ return String(row.changedBefore ?? "").slice(0, 7) || "unknown";
248
+ }
249
+ function createInspectionStore(opts) {
250
+ const ds = opts.dataSource;
251
+ function shardFor(record) {
252
+ const m = YEAR_MONTH_RE.exec(record.inspectedAt);
253
+ return m ? `${m[1]}-${m[2]}` : "unknown";
254
+ }
255
+ function randomBatchId() {
256
+ return typeof crypto !== "undefined" && "randomUUID" in crypto ? crypto.randomUUID() : `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
257
+ }
258
+ return {
259
+ async appendHistory(ctx, records, options) {
260
+ if (records.length === 0) return;
261
+ const batchId = options?.batchId ?? randomBatchId();
262
+ const byMonth = /* @__PURE__ */ new Map();
263
+ for (const r of records) {
264
+ const month = shardFor(r);
265
+ if (!byMonth.has(month)) byMonth.set(month, []);
266
+ byMonth.get(month).push(r);
267
+ }
268
+ await mapEntityIo([...byMonth].map(([yearMonth, batch]) => {
269
+ const bytes = encodeJsonBigintSafe({
270
+ version: 1,
271
+ records: batch
272
+ });
273
+ if (bytes.byteLength > 5242880) throw new Error(`inspection history shard exceeds ${INSPECTION_HISTORY_MAX_BYTES} bytes (got ${bytes.byteLength}); split the batch`);
274
+ return {
275
+ key: inspectionHistoryShardKey(ctx, yearMonth, batchId),
276
+ bytes
277
+ };
278
+ }), (shard) => ds.write(shard.key, shard.bytes));
279
+ },
280
+ async loadHistory(ctx, yearMonth) {
281
+ const keys = await ds.list(inspectionHistoryPrefix(ctx, yearMonth));
282
+ if (keys.length === 0) return void 0;
283
+ return {
284
+ version: 1,
285
+ records: (await mapEntityIo(keys, async (key) => {
286
+ const bytes = await readOptional(ds, key);
287
+ if (!bytes) return [];
288
+ return (await Promise.resolve().then(() => JSON.parse(new TextDecoder().decode(bytes))).catch((err) => {
289
+ console.warn("[inspection.loadHistory] failed to decode shard", {
290
+ key,
291
+ error: err.message
292
+ });
293
+ }))?.records ?? [];
294
+ })).flat()
295
+ };
296
+ },
297
+ async materialize(ctx, rowIter) {
298
+ const rows = Array.from(rowIter);
299
+ rows.sort((a, b) => a.urlHash < b.urlHash ? -1 : a.urlHash > b.urlHash ? 1 : 0);
300
+ const bytes = encodeRowsToParquetFlex(rows, {
301
+ columns: INSPECTION_PARQUET_COLUMNS,
302
+ sortKey: ["urlHash"]
303
+ });
304
+ const key = inspectionParquetKey(ctx);
305
+ await ds.write(key, bytes);
306
+ return {
307
+ key,
308
+ rowCount: rows.length,
309
+ bytes: bytes.byteLength
310
+ };
311
+ },
312
+ async appendInspectionEvents(ctx, rows, options) {
313
+ if (rows.length === 0) return {
314
+ keys: [],
315
+ rowCount: 0
316
+ };
317
+ const batchId = options?.batchId ?? randomBatchId();
318
+ const byMonth = /* @__PURE__ */ new Map();
319
+ for (const r of rows) {
320
+ const m = YEAR_MONTH_RE.exec(r.inspectedAt);
321
+ const month = m ? `${m[1]}-${m[2]}` : "unknown";
322
+ const bucket = byMonth.get(month) ?? [];
323
+ bucket.push(r);
324
+ byMonth.set(month, bucket);
325
+ }
326
+ const files = [...byMonth].map(([month, batch]) => {
327
+ const bytes = encodeRowsToParquetFlex(batch, {
328
+ columns: INSPECTION_EVENT_COLUMNS,
329
+ sortKey: ["urlHash"]
330
+ });
331
+ return {
332
+ key: inspectionEventKey(ctx, month, batchId),
333
+ bytes
334
+ };
335
+ });
336
+ await mapEntityIo(files, (file) => ds.write(file.key, file.bytes));
337
+ return {
338
+ keys: files.map((file) => file.key),
339
+ rowCount: rows.length
340
+ };
341
+ },
342
+ async compactInspections(ctx, opts) {
343
+ const eventKeys = (await ds.list(`${inspectionEventsPrefix(ctx)}/`)).filter((k) => INSPECTION_EVENT_KEY_RE.test(k));
344
+ if (eventKeys.length === 0) return {
345
+ baseRowCount: 0,
346
+ eventsFolded: 0,
347
+ eventFilesDeleted: 0,
348
+ transitionsWritten: 0
349
+ };
350
+ const baseKey = inspectionBaseKey(ctx);
351
+ const baseBytes = await readOptional(ds, baseKey);
352
+ const baseRows = baseBytes ? await decodeParquetToRows(baseBytes) : [];
353
+ const latest = /* @__PURE__ */ new Map();
354
+ const earliestChecked = /* @__PURE__ */ new Map();
355
+ const transitions = [];
356
+ const consider = (row) => {
357
+ const h = String(row.urlHash);
358
+ const prev = latest.get(h);
359
+ if (!prev || String(row.inspectedAt ?? "") > String(prev.inspectedAt ?? "")) {
360
+ if (opts?.transitions && prev && isStateTransition(prev, row)) transitions.push(buildTransitionRow(prev, row));
361
+ latest.set(h, row);
362
+ }
363
+ const fc = row.firstCheckedAt;
364
+ if (fc != null) {
365
+ const fcStr = String(fc);
366
+ const cur = earliestChecked.get(h);
367
+ if (cur === void 0 || fcStr < cur) earliestChecked.set(h, fcStr);
368
+ }
369
+ };
370
+ for (const row of baseRows) consider(row);
371
+ let eventsFolded = 0;
372
+ const consumed = [];
373
+ const eventRows = [];
374
+ const eventFiles = await mapEntityIo(eventKeys.sort(), async (key) => {
375
+ const bytes = await readOptional(ds, key);
376
+ if (!bytes) return void 0;
377
+ return {
378
+ key,
379
+ rows: await decodeParquetToRows(bytes)
380
+ };
381
+ });
382
+ for (const file of eventFiles) {
383
+ if (!file) continue;
384
+ const { key, rows } = file;
385
+ consumed.push(key);
386
+ eventRows.push(...rows);
387
+ eventsFolded += rows.length;
388
+ }
389
+ eventRows.sort((a, b) => String(a.urlHash).localeCompare(String(b.urlHash)) || String(a.inspectedAt ?? "").localeCompare(String(b.inspectedAt ?? "")));
390
+ for (const row of eventRows) consider(row);
391
+ const merged = [];
392
+ for (const [h, row] of latest) {
393
+ const fc = earliestChecked.get(h);
394
+ if (fc !== void 0) row.firstCheckedAt = fc;
395
+ populateCanonicalMismatchKind(row);
396
+ merged.push(row);
397
+ }
398
+ const bytes = encodeRowsToParquetFlex(merged, {
399
+ columns: INSPECTION_EVENT_COLUMNS,
400
+ sortKey: ["urlHash"]
401
+ });
402
+ const transitionsWritten = opts?.transitions ? await appendTransitions(ds, ctx, transitions) : 0;
403
+ await ds.write(baseKey, bytes);
404
+ if (consumed.length > 0) await ds.delete(consumed);
405
+ return {
406
+ baseRowCount: merged.length,
407
+ eventsFolded,
408
+ eventFilesDeleted: consumed.length,
409
+ transitionsWritten
410
+ };
411
+ },
412
+ async backfillCanonicalMismatchKinds(ctx) {
413
+ const baseKey = inspectionBaseKey(ctx);
414
+ const baseBytes = await readOptional(ds, baseKey);
415
+ if (!baseBytes) return {
416
+ baseRowCount: 0,
417
+ rowsBackfilled: 0,
418
+ rewritten: false
419
+ };
420
+ const rows = await decodeParquetToRows(baseBytes);
421
+ let rowsBackfilled = 0;
422
+ for (const row of rows) if (populateCanonicalMismatchKind(row)) rowsBackfilled++;
423
+ if (rowsBackfilled === 0) return {
424
+ baseRowCount: rows.length,
425
+ rowsBackfilled: 0,
426
+ rewritten: false
427
+ };
428
+ await ds.write(baseKey, encodeRowsToParquetFlex(rows, {
429
+ columns: INSPECTION_EVENT_COLUMNS,
430
+ sortKey: ["urlHash"]
431
+ }));
432
+ return {
433
+ baseRowCount: rows.length,
434
+ rowsBackfilled,
435
+ rewritten: true
436
+ };
437
+ },
438
+ parquetUri(ctx) {
439
+ return ds.uri?.(inspectionParquetKey(ctx));
440
+ }
441
+ };
442
+ }
443
+ export { INSPECTION_HISTORY_MAX_BYTES, createInspectionStore };
@@ -0,0 +1,16 @@
1
+ const ENTITY_IO_CONCURRENCY = 8;
2
+ async function mapEntityIo(items, fn) {
3
+ if (items.length === 0) return [];
4
+ const results = Array.from({ length: items.length }, () => void 0);
5
+ let next = 0;
6
+ async function worker() {
7
+ while (true) {
8
+ const index = next++;
9
+ if (index >= items.length) return;
10
+ results[index] = await fn(items[index], index);
11
+ }
12
+ }
13
+ await Promise.all(Array.from({ length: Math.min(ENTITY_IO_CONCURRENCY, items.length) }, worker));
14
+ return results;
15
+ }
16
+ export { mapEntityIo };
@@ -1,5 +1,5 @@
1
- import { DataSource } from "./storage.mjs";
2
- import { queryDimMetaKey, queryDimParquetKey } from "./entity-keys.mjs";
1
+ import { DataSource } from "../storage.mjs";
2
+ import { queryDimMetaKey, queryDimParquetKey } from "../entity-keys.mjs";
3
3
  import { TenantCtx } from "@gscdump/contracts";
4
4
  interface QueryDimRecord {
5
5
  query: string;
@@ -51,4 +51,4 @@ interface QueryDimStore {
51
51
  declare function createQueryDimStore({ dataSource }: {
52
52
  dataSource: DataSource;
53
53
  }): QueryDimStore;
54
- export { QueryDimDeps, QueryDimMeta, QueryDimRecord, QueryDimStore, buildQueryDimRecords, createQueryDimStore };
54
+ export { QueryDimDeps, QueryDimMeta, QueryDimRecord, QueryDimStore, buildQueryDimRecords, createQueryDimStore, queryDimMetaKey, queryDimParquetKey };
@@ -1,6 +1,6 @@
1
- import { queryDimMetaKey, queryDimParquetKey } from "./entity-keys.mjs";
2
- import { decodeParquetToRows, encodeRowsToParquetFlex } from "./adapters/hyparquet.mjs";
3
- import { readOptional } from "./adapters/read-optional.mjs";
1
+ import { queryDimMetaKey, queryDimParquetKey } from "../entity-keys.mjs";
2
+ import { readOptional } from "../adapters/read-optional.mjs";
3
+ import { decodeParquetToRows, encodeRowsToParquetFlex } from "../adapters/hyparquet.mjs";
4
4
  import { encodeJsonBigintSafe } from "@gscdump/lakehouse/bigint";
5
5
  const QUERY_DIM_COLUMNS = [
6
6
  {
@@ -88,4 +88,4 @@ function createQueryDimStore({ dataSource }) {
88
88
  }
89
89
  };
90
90
  }
91
- export { buildQueryDimRecords, createQueryDimStore };
91
+ export { buildQueryDimRecords, createQueryDimStore, queryDimMetaKey, queryDimParquetKey };
@@ -1,4 +1,4 @@
1
- import { parseSitemapUrlsDeltaKey } from "./entity-keys.mjs";
1
+ import { parseSitemapUrlsDeltaKey } from "../entity-keys.mjs";
2
2
  const SITEMAP_PROJECTION_GRACE_MS = 900 * 1e3;
3
3
  function emptySitemapProjectionManifest() {
4
4
  return {
@@ -0,0 +1,199 @@
1
+ import { DataSource } from "../storage.mjs";
2
+ import { ScheduleState } from "../schedule.mjs";
3
+ import { Row, TenantCtx } from "@gscdump/contracts";
4
+ /** GSC sitemap record we persist. Matches `Schema$WmxSitemap` but as plain JSON. */
5
+ interface SitemapRecord {
6
+ /** The sitemap URL (feedpath) as returned by GSC. */
7
+ path: string;
8
+ /** ISO-8601 timestamp of the snapshot run that captured this record. */
9
+ capturedAt: string;
10
+ /** Last time Google downloaded this sitemap (RFC 3339, from the API). */
11
+ lastDownloaded?: string;
12
+ /** Last time the sitemap was submitted. */
13
+ lastSubmitted?: string;
14
+ type?: string;
15
+ isPending?: boolean;
16
+ isSitemapsIndex?: boolean;
17
+ errors?: string;
18
+ warnings?: string;
19
+ /** Per-content-type counts (web, image, video, news). */
20
+ contents?: Array<{
21
+ type?: string;
22
+ submitted?: string;
23
+ indexed?: string;
24
+ }>;
25
+ /** Raw payload for fields we don't promote to first-class columns. */
26
+ raw?: unknown;
27
+ /** Number of URLs observed in this feedpath at last snapshot. */
28
+ urlCount?: number;
29
+ /** Stable hash of the sorted normalized loc list at last snapshot. */
30
+ contentHash?: string;
31
+ /** Adaptive cadence state owned by `sitemapPolicy`. */
32
+ schedule?: ScheduleState;
33
+ }
34
+ interface SitemapIndex {
35
+ version: 1;
36
+ /** Map of feedpathHash → latest SitemapRecord. */
37
+ records: Record<string, SitemapRecord>;
38
+ }
39
+ interface SitemapHistoryDoc {
40
+ version: 1;
41
+ path: string;
42
+ capturedAt: string;
43
+ record: SitemapRecord;
44
+ }
45
+ /** Parsed URL entry from a sitemap XML. */
46
+ interface ParsedUrl {
47
+ loc: string;
48
+ /** ISO-8601 lastmod from the sitemap, if present. */
49
+ lastmod?: string;
50
+ }
51
+ /** A single URL row in the urls/index.parquet partition. */
52
+ interface SitemapUrlRecord {
53
+ feedpath: string;
54
+ feedpathHash: string;
55
+ urlHash: string;
56
+ loc: string;
57
+ lastmod?: string;
58
+ firstSeenAt: number;
59
+ lastSeenAt: number;
60
+ /** Set when the URL has been removed. Null/undefined = currently live. */
61
+ removedAt?: number;
62
+ }
63
+ interface SnapshotUrlsResult {
64
+ added: number;
65
+ removed: number;
66
+ kept: number;
67
+ contentHash: string;
68
+ /** True when current membership did not change. Initial history seeding may write. */
69
+ unchanged: boolean;
70
+ }
71
+ interface CompleteSitemapGeneration {
72
+ _tag: 'complete';
73
+ id: string;
74
+ /** Unix epoch milliseconds. */
75
+ observedAt: number;
76
+ }
77
+ interface ReconcileResult {
78
+ /** Feedpaths that were absent from the live set and had their live URLs pruned. */
79
+ feedpathsPruned: number;
80
+ /** Total URL rows transitioned live → removed across pruned feedpaths. */
81
+ urlsRemoved: number;
82
+ }
83
+ /**
84
+ * Bounds on a single `compactUrls` call. `compactUrls` is memory-bounded (one
85
+ * feedpath at a time) but was previously unbounded in TIME: a tenant with a
86
+ * large accumulated delta backlog could run past a caller's execution budget.
87
+ * Both bounds are checked BETWEEN feedpaths and only after at least one has
88
+ * been compacted, so a call always makes forward progress.
89
+ */
90
+ interface CompactUrlsOptions {
91
+ /** Stop before starting another feedpath once this many ms have elapsed. */
92
+ deadlineMs?: number;
93
+ /** Stop after compacting this many feedpaths. */
94
+ maxFeedpaths?: number;
95
+ }
96
+ interface CompactUrlsResult {
97
+ /** Feedpaths whose active deltas were folded and retired by this call. */
98
+ compactedFeedpaths: number;
99
+ /**
100
+ * Feedpaths that still hold outstanding deltas. Call `compactUrls` again to
101
+ * continue. No cursor is needed because the projection watermark excludes a
102
+ * compacted feedpath's grace-retained deltas from the next call.
103
+ */
104
+ remainingFeedpaths: number;
105
+ }
106
+ interface DeltaEntry {
107
+ feedpath: string;
108
+ feedpathHash: string;
109
+ urlHash: string;
110
+ op: 'added' | 'removed';
111
+ loc: string;
112
+ lastmod?: string;
113
+ at: number;
114
+ }
115
+ interface SitemapMembershipEvent {
116
+ feedpath: string;
117
+ feedpathHash: string;
118
+ urlHash: string;
119
+ op: 'added' | 'removed';
120
+ loc: string;
121
+ lastmod?: string;
122
+ generationId: string;
123
+ /** Unix epoch milliseconds. */
124
+ observedAt: number;
125
+ /** Stable ordering within one generation. */
126
+ sequence: number;
127
+ /** Whether this row also mutates the disposable current-state projection. */
128
+ projectsState: boolean;
129
+ }
130
+ interface DateRange {
131
+ /** YYYY-MM-DD inclusive. */
132
+ from?: string;
133
+ /** YYYY-MM-DD inclusive. */
134
+ to?: string;
135
+ }
136
+ interface LoadUrlsOptions {
137
+ includeRemoved?: boolean;
138
+ }
139
+ interface SitemapReadStore {
140
+ /** Load the full site index (latest record per feedpath). */
141
+ loadIndex: (ctx: TenantCtx) => Promise<SitemapIndex>;
142
+ /** Fetch the latest snapshot for a feedpath, or undefined. */
143
+ getLatest: (ctx: TenantCtx, path: string) => Promise<SitemapRecord | undefined>;
144
+ /** Stream live (and optionally removed) URL rows for a feedpath. */
145
+ loadUrls: (ctx: TenantCtx, feedpath: string, opts?: LoadUrlsOptions) => AsyncIterable<SitemapUrlRecord>;
146
+ /** Stream all delta entries within `[from, to]` (YYYY-MM-DD inclusive). */
147
+ loadDeltas: (ctx: TenantCtx, dateRange?: DateRange) => AsyncIterable<DeltaEntry>;
148
+ /** Stream immutable membership events within `[from, to]`. */
149
+ loadEvents: (ctx: TenantCtx, dateRange?: DateRange) => AsyncIterable<SitemapMembershipEvent>;
150
+ }
151
+ interface SitemapStore extends SitemapReadStore {
152
+ /**
153
+ * Persist a snapshot run. Updates the index + writes one immutable
154
+ * history doc per record under `history/<feedpathHash>__<capturedAtMs>.json`.
155
+ */
156
+ writeSnapshot: (ctx: TenantCtx, records: readonly SitemapRecord[]) => Promise<void>;
157
+ /**
158
+ * Diff a complete sitemap generation against current state. The immutable
159
+ * membership event lands before its disposable state delta.
160
+ */
161
+ snapshotUrls: (ctx: TenantCtx, generation: CompleteSitemapGeneration, feedpath: string, urls: readonly ParsedUrl[]) => Promise<SnapshotUrlsResult>;
162
+ /**
163
+ * Fold accumulated deltas into the prior index, one feedpath at a time:
164
+ * rewrites each touched feedpath's `by-feed/<hash>/index.parquet` and deletes
165
+ * the consumed delta files. Bounded per feedpath, so it stays within memory
166
+ * regardless of total site URL count.
167
+ *
168
+ * Optionally bounded in TIME too (`opts.deadlineMs` / `opts.maxFeedpaths`).
169
+ * A bounded call is safe to stop mid-way: each rewritten feedpath advances
170
+ * the projection watermark, so the remainder is simply what the next call
171
+ * finds. Callers drive this from `remainingFeedpaths`, not a cursor.
172
+ */
173
+ compactUrls: (ctx: TenantCtx, opts?: CompactUrlsOptions) => Promise<CompactUrlsResult>;
174
+ /**
175
+ * Site-wide convergence: mark every still-live URL whose owning feedpath is
176
+ * absent from `liveFeedpaths` as removed. `compactUrls`/`snapshotUrls` only
177
+ * prune URLs *inside* a feedpath that was re-observed; a whole feed dropped
178
+ * from the sitemap list (no `snapshotUrls` call) leaves its URLs frozen-live
179
+ * forever. This is the sidecar mirror of the D1 generation sweep: it rewrites
180
+ * each dropped feedpath's `by-feed/<hash>/index.parquet` with `removedAt` set,
181
+ * advances its projection watermark, and grace-retires outstanding deltas.
182
+ * Bounded per feedpath, so memory stays flat regardless of site size. Live
183
+ * feedpaths are never touched.
184
+ */
185
+ reconcile: (ctx: TenantCtx, generation: CompleteSitemapGeneration, opts: {
186
+ liveFeedpaths: readonly string[];
187
+ }) => Promise<ReconcileResult>;
188
+ }
189
+ interface CreateSitemapReadStoreOptions {
190
+ dataSource: DataSource;
191
+ /** Override the feedpath hash (test seam). */
192
+ hash?: (path: string) => string;
193
+ }
194
+ type SitemapMutation = <T>(ctx: TenantCtx, fn: () => Promise<T>) => Promise<T>;
195
+ interface CreateSitemapStoreOptions extends CreateSitemapReadStoreOptions {
196
+ withMutation: SitemapMutation;
197
+ now?: () => number;
198
+ }
199
+ export { CompactUrlsOptions, CompactUrlsResult, CompleteSitemapGeneration, CreateSitemapReadStoreOptions, CreateSitemapStoreOptions, DateRange, DeltaEntry, LoadUrlsOptions, ParsedUrl, ReconcileResult, SitemapHistoryDoc, SitemapIndex, SitemapMembershipEvent, SitemapMutation, SitemapReadStore, SitemapRecord, SitemapStore, SitemapUrlRecord, SnapshotUrlsResult };