@gscdump/engine 1.4.11 → 1.5.1

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 (39) hide show
  1. package/dist/analysis-range.d.mts +7 -0
  2. package/dist/analysis-range.mjs +33 -0
  3. package/dist/entities/empty-types.d.mts +22 -0
  4. package/dist/entities/empty-types.mjs +58 -0
  5. package/dist/entities/indexing-metadata.d.mts +26 -0
  6. package/dist/entities/indexing-metadata.mjs +31 -0
  7. package/dist/entities/inspection.d.mts +240 -0
  8. package/dist/entities/inspection.mjs +443 -0
  9. package/dist/entities/io.mjs +16 -0
  10. package/dist/{query-dim.d.mts → entities/query-dim.d.mts} +3 -3
  11. package/dist/{query-dim.mjs → entities/query-dim.mjs} +4 -4
  12. package/dist/{sitemap-projection.mjs → entities/sitemap-projection.mjs} +1 -1
  13. package/dist/entities/sitemap-shared.d.mts +199 -0
  14. package/dist/entities/sitemap-shared.mjs +243 -0
  15. package/dist/entities/sitemap-write.d.mts +3 -0
  16. package/dist/entities/sitemap-write.mjs +528 -0
  17. package/dist/entities/sitemap.d.mts +3 -0
  18. package/dist/entities/sitemap.mjs +91 -0
  19. package/dist/entities.d.mts +9 -481
  20. package/dist/entities.mjs +7 -1380
  21. package/dist/rollups/canonical.d.mts +71 -0
  22. package/dist/rollups/canonical.mjs +335 -0
  23. package/dist/rollups/core.d.mts +201 -0
  24. package/dist/rollups/core.mjs +116 -0
  25. package/dist/rollups/dates.mjs +11 -0
  26. package/dist/rollups/defaults.d.mts +11 -0
  27. package/dist/rollups/defaults.mjs +17 -0
  28. package/dist/rollups/hourly.d.mts +38 -0
  29. package/dist/rollups/hourly.mjs +38 -0
  30. package/dist/rollups/indexing.d.mts +46 -0
  31. package/dist/rollups/indexing.mjs +357 -0
  32. package/dist/rollups/traffic.d.mts +38 -0
  33. package/dist/rollups/traffic.mjs +289 -0
  34. package/dist/rollups/windows.d.mts +90 -0
  35. package/dist/rollups/windows.mjs +176 -0
  36. package/dist/rollups.d.mts +8 -471
  37. package/dist/rollups.mjs +7 -1316
  38. package/package.json +9 -4
  39. /package/dist/{sitemap-projection.d.mts → entities/sitemap-projection.d.mts} +0 -0
@@ -0,0 +1,528 @@
1
+ import { hashSortedUrlList, hashUrl, hashUrlList, parseSitemapUrlsDeltaKey, sitemapHistoryKey, sitemapIndexKey, sitemapUrlsDeltaKey, sitemapUrlsEventKey, sitemapUrlsEventSeedKey, sitemapUrlsGenerationKey, sitemapUrlsIndexKey, sitemapUrlsIndexPrefix, sitemapUrlsPendingGenerationKey, sitemapUrlsPendingGenerationsPrefix, sitemapUrlsPrefix, sitemapUrlsProjectionManifestKey, sitemapUrlsReconcileGenerationKey } 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 { decodeSitemapProjectionManifest, emptySitemapProjectionManifest, selectSitemapProjectionFiles, withSitemapProjectionFeed } from "./sitemap-projection.mjs";
6
+ import { SITEMAP_URLS_PENDING_GENERATION_RE, URLS_DELTA_COLUMNS, URLS_EVENT_COLUMNS, URLS_INDEX_COLUMNS, applySitemapDeltaFiles, createSitemapUrlState, readSitemapDeltaFiles, urlRecordToRow } from "./sitemap-shared.mjs";
7
+ import { createSitemapReadStore } from "./sitemap.mjs";
8
+ import { encodeJsonBigintSafe } from "@gscdump/lakehouse/bigint";
9
+ function createSitemapStore(opts) {
10
+ const ds = opts.dataSource;
11
+ const hash = opts.hash ?? hashUrl;
12
+ const now = opts.now ?? (() => Date.now());
13
+ const withMutation = opts.withMutation;
14
+ const readStore = createSitemapReadStore({
15
+ dataSource: ds,
16
+ hash
17
+ });
18
+ async function readJson(key) {
19
+ const bytes = await readOptional(ds, key);
20
+ return bytes === void 0 ? void 0 : JSON.parse(new TextDecoder().decode(bytes));
21
+ }
22
+ function writeJson(key, value) {
23
+ return ds.write(key, encodeJsonBigintSafe(value));
24
+ }
25
+ async function readProjectionManifest(ctx) {
26
+ const bytes = await readOptional(ds, sitemapUrlsProjectionManifestKey(ctx));
27
+ return bytes ? decodeSitemapProjectionManifest(new TextDecoder().decode(bytes)) : emptySitemapProjectionManifest();
28
+ }
29
+ async function deleteExpiredProjectionDeltas(manifest, deltaKeys, currentTime) {
30
+ const expired = deltaKeys.filter((key) => {
31
+ const parsed = parseSitemapUrlsDeltaKey(key);
32
+ const feed = parsed ? manifest.feeds[parsed.feedpathHash] : void 0;
33
+ return Boolean(feed && key <= feed.compactedThrough && currentTime - feed.publishedAt >= 9e5);
34
+ });
35
+ if (expired.length > 0) await ds.delete(expired);
36
+ }
37
+ async function publishCurrentProjection(ctx, feedpathHash, rows, consumedDeltas, manifest, publishedAt) {
38
+ const bytes = encodeRowsToParquetFlex(rows.map(urlRecordToRow), {
39
+ columns: URLS_INDEX_COLUMNS,
40
+ sortKey: ["feedpath_hash", "url_hash"]
41
+ });
42
+ await ds.write(sitemapUrlsIndexKey(ctx, feedpathHash), bytes);
43
+ const compactedThrough = consumedDeltas.at(-1)?.key;
44
+ if (!compactedThrough) return manifest;
45
+ const next = withSitemapProjectionFeed(manifest, feedpathHash, {
46
+ compactedThrough,
47
+ publishedAt
48
+ });
49
+ await writeJson(sitemapUrlsProjectionManifestKey(ctx), next);
50
+ return next;
51
+ }
52
+ function normalizedEventRows(rows) {
53
+ return rows.map((row) => ({
54
+ feedpath: String(row.feedpath),
55
+ feedpathHash: String(row.feedpath_hash),
56
+ urlHash: String(row.url_hash),
57
+ op: String(row.op),
58
+ loc: String(row.loc),
59
+ lastmod: row.lastmod == null ? null : String(row.lastmod),
60
+ generationId: String(row.generation_id),
61
+ observedAt: Number(row.observed_at),
62
+ sequence: Number(row.sequence),
63
+ projectsState: Boolean(row.projects_state),
64
+ seedGeneration: Boolean(row.seed_generation),
65
+ generationKind: String(row.generation_kind),
66
+ contentHash: row.content_hash == null ? null : String(row.content_hash),
67
+ inputCount: row.input_count == null ? null : Number(row.input_count)
68
+ })).sort((a, b) => a.observedAt - b.observedAt || a.sequence - b.sequence || a.urlHash.localeCompare(b.urlHash) || a.op.localeCompare(b.op));
69
+ }
70
+ function eventDigest(rows) {
71
+ return hashUrl(JSON.stringify(normalizedEventRows(rows)));
72
+ }
73
+ function assertGenerationAccepted(generation, checkpoint, scope) {
74
+ if (!checkpoint) return "newer";
75
+ if (generation.id === checkpoint.generationId) {
76
+ if (generation.observedAt === checkpoint.observedAt) return "same";
77
+ throw new Error(`sitemap generation conflict for ${scope}: generation ${generation.id} changed observedAt`);
78
+ }
79
+ if (generation.observedAt > checkpoint.observedAt) return "newer";
80
+ const reason = generation.observedAt === checkpoint.observedAt ? "ambiguous timestamp" : "stale generation";
81
+ throw new Error(`sitemap generation conflict for ${scope}: ${reason}`);
82
+ }
83
+ async function ensureEvents(ctx, feedpathHash, generation, rows) {
84
+ const expectedDigest = eventDigest(rows);
85
+ if (rows.length === 0) return {
86
+ rows: [],
87
+ digest: expectedDigest
88
+ };
89
+ const key = sitemapUrlsEventKey(ctx, feedpathHash, generation);
90
+ await writeJson(sitemapUrlsPendingGenerationKey(ctx, feedpathHash), {
91
+ version: 1,
92
+ generationId: generation.id,
93
+ observedAt: generation.observedAt,
94
+ eventKey: key,
95
+ eventDigest: expectedDigest
96
+ });
97
+ const existing = await readOptional(ds, key);
98
+ if (existing) {
99
+ const existingRows = await decodeParquetToRows(existing);
100
+ const existingDigest = eventDigest(existingRows);
101
+ if (existingDigest !== expectedDigest) throw new Error(`sitemap generation conflict for ${feedpathHash}: immutable event digest changed`);
102
+ return {
103
+ rows: existingRows,
104
+ digest: existingDigest
105
+ };
106
+ }
107
+ const bytes = encodeRowsToParquetFlex(rows, {
108
+ columns: URLS_EVENT_COLUMNS,
109
+ sortKey: ["sequence", "url_hash"]
110
+ });
111
+ await ds.write(key, bytes);
112
+ return {
113
+ rows: [...rows],
114
+ digest: expectedDigest
115
+ };
116
+ }
117
+ function eventRow(generation, row, metadata) {
118
+ return {
119
+ feedpath: row.feedpath,
120
+ feedpath_hash: row.feedpath_hash,
121
+ url_hash: row.url_hash,
122
+ op: row.op,
123
+ loc: row.loc,
124
+ lastmod: row.lastmod,
125
+ generation_id: generation.id,
126
+ observed_at: generation.observedAt,
127
+ sequence: metadata.sequence,
128
+ projects_state: metadata.projectsState ? 1 : 0,
129
+ seed_generation: metadata.seedGeneration ? 1 : 0,
130
+ generation_kind: metadata.generationKind,
131
+ content_hash: metadata.contentHash ?? null,
132
+ input_count: metadata.inputCount ?? null
133
+ };
134
+ }
135
+ function stateRowsFromEvents(rows) {
136
+ return rows.filter((row) => Boolean(row.projects_state)).map((row) => ({
137
+ feedpath: row.feedpath,
138
+ feedpath_hash: row.feedpath_hash,
139
+ url_hash: row.url_hash,
140
+ op: row.op,
141
+ loc: row.loc,
142
+ lastmod: row.lastmod,
143
+ at: row.observed_at,
144
+ generation_id: row.generation_id
145
+ }));
146
+ }
147
+ function checkpointFromEvents(rows, digest) {
148
+ const first = rows[0];
149
+ if (!first) throw new Error("cannot checkpoint an empty sitemap event file");
150
+ const generationId = String(first.generation_id);
151
+ const observedAt = Number(first.observed_at);
152
+ 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");
153
+ if (String(first.generation_kind) === "snapshot") {
154
+ const contentHash = String(first.content_hash);
155
+ const inputCount = Number(first.input_count);
156
+ const stateRows = stateRowsFromEvents(rows);
157
+ const added = stateRows.filter((row) => String(row.op) === "added").length;
158
+ return {
159
+ _tag: "snapshot",
160
+ version: 1,
161
+ generationId,
162
+ observedAt,
163
+ eventDigest: digest,
164
+ result: {
165
+ added,
166
+ removed: stateRows.filter((row) => String(row.op) === "removed").length,
167
+ kept: Math.max(0, inputCount - added),
168
+ contentHash,
169
+ unchanged: stateRows.length === 0
170
+ }
171
+ };
172
+ }
173
+ return {
174
+ _tag: "reconcile",
175
+ version: 1,
176
+ generationId,
177
+ observedAt,
178
+ eventDigest: digest
179
+ };
180
+ }
181
+ async function projectEvents(ctx, feedpathHash, rows, digest) {
182
+ const checkpoint = checkpointFromEvents(rows, digest);
183
+ const generation = {
184
+ id: checkpoint.generationId,
185
+ observedAt: checkpoint.observedAt
186
+ };
187
+ const stateRows = stateRowsFromEvents(rows);
188
+ if (stateRows.length > 0) {
189
+ const bytes = encodeRowsToParquetFlex(stateRows, {
190
+ columns: URLS_DELTA_COLUMNS,
191
+ sortKey: ["url_hash"]
192
+ });
193
+ await ds.write(sitemapUrlsDeltaKey(ctx, feedpathHash, generation), bytes);
194
+ }
195
+ if (rows.some((row) => Boolean(row.seed_generation))) await writeJson(sitemapUrlsEventSeedKey(ctx, feedpathHash), {
196
+ version: 1,
197
+ generationId: checkpoint.generationId,
198
+ observedAt: checkpoint.observedAt
199
+ });
200
+ await writeJson(sitemapUrlsGenerationKey(ctx, feedpathHash), checkpoint);
201
+ return checkpoint;
202
+ }
203
+ async function repairFeedProjection(ctx, feedpathHash) {
204
+ const pendingKey = sitemapUrlsPendingGenerationKey(ctx, feedpathHash);
205
+ const [checkpoint, pending] = await Promise.all([readJson(sitemapUrlsGenerationKey(ctx, feedpathHash)), readJson(pendingKey)]);
206
+ if (!pending) return checkpoint;
207
+ const pendingGeneration = {
208
+ _tag: "complete",
209
+ id: pending.generationId,
210
+ observedAt: pending.observedAt
211
+ };
212
+ if (checkpoint?.generationId === pending.generationId && checkpoint.observedAt !== pending.observedAt) throw new Error(`sitemap generation conflict for ${feedpathHash}: generation ${pending.generationId} changed observedAt`);
213
+ if (checkpoint && pending.observedAt < checkpoint.observedAt) {
214
+ await ds.delete([pendingKey]);
215
+ return checkpoint;
216
+ }
217
+ const position = assertGenerationAccepted(pendingGeneration, checkpoint, feedpathHash);
218
+ const eventBytes = await readOptional(ds, pending.eventKey);
219
+ if (!eventBytes) {
220
+ await ds.delete([pendingKey]);
221
+ return checkpoint;
222
+ }
223
+ const rows = await decodeParquetToRows(eventBytes);
224
+ const digest = eventDigest(rows);
225
+ if (digest !== pending.eventDigest) throw new Error(`sitemap generation conflict for ${feedpathHash}: pending event digest changed`);
226
+ if (position === "same") {
227
+ if (checkpoint?.eventDigest !== digest) throw new Error(`sitemap generation conflict for ${feedpathHash}: checkpoint digest changed`);
228
+ await ds.delete([pendingKey]);
229
+ return checkpoint;
230
+ }
231
+ const repaired = await projectEvents(ctx, feedpathHash, rows, digest);
232
+ await ds.delete([pendingKey]);
233
+ return repaired;
234
+ }
235
+ return {
236
+ ...readStore,
237
+ writeSnapshot(ctx, records) {
238
+ return withMutation(ctx, async () => {
239
+ if (records.length === 0) return;
240
+ const indexKey = sitemapIndexKey(ctx);
241
+ const index = await readJson(indexKey) ?? {
242
+ version: 1,
243
+ records: {}
244
+ };
245
+ const stamp = now();
246
+ const historyDocs = /* @__PURE__ */ new Map();
247
+ for (const record of records) {
248
+ const feedpathHash = hash(record.path);
249
+ index.records[feedpathHash] = record;
250
+ historyDocs.set(sitemapHistoryKey(ctx, feedpathHash, stamp), {
251
+ version: 1,
252
+ path: record.path,
253
+ capturedAt: record.capturedAt,
254
+ record
255
+ });
256
+ }
257
+ await mapEntityIo([...historyDocs], ([key, doc]) => writeJson(key, doc));
258
+ await writeJson(indexKey, index);
259
+ });
260
+ },
261
+ snapshotUrls(ctx, generation, feedpath, urls) {
262
+ return withMutation(ctx, async () => {
263
+ const feedpathHash = hash(feedpath);
264
+ const contentHash = hashUrlList(urls);
265
+ const checkpoint = await repairFeedProjection(ctx, feedpathHash);
266
+ if (assertGenerationAccepted(generation, checkpoint, feedpathHash) === "same") {
267
+ if (checkpoint?._tag !== "snapshot" || checkpoint.result.contentHash !== contentHash) throw new Error(`sitemap generation conflict for ${feedpathHash}: snapshot input changed`);
268
+ return checkpoint.result;
269
+ }
270
+ if (assertGenerationAccepted(generation, await readJson(sitemapUrlsReconcileGenerationKey(ctx)), "site reconciliation") === "same") throw new Error("sitemap generation conflict: site generation was already reconciled");
271
+ const priorByHash = /* @__PURE__ */ new Map();
272
+ for await (const record of readStore.loadUrls(ctx, feedpath, { includeRemoved: true })) priorByHash.set(record.urlHash, record);
273
+ const livePrior = [...priorByHash.values()].filter((record) => record.removedAt == null);
274
+ const incomingByHash = /* @__PURE__ */ new Map();
275
+ for (const url of urls) incomingByHash.set(hash(url.loc), url);
276
+ const deltaRows = [];
277
+ let added = 0;
278
+ let removed = 0;
279
+ let kept = 0;
280
+ for (const [urlHash, url] of incomingByHash) {
281
+ const previous = priorByHash.get(urlHash);
282
+ if (!previous || previous.removedAt != null) {
283
+ added++;
284
+ deltaRows.push({
285
+ feedpath,
286
+ feedpath_hash: feedpathHash,
287
+ url_hash: urlHash,
288
+ op: "added",
289
+ loc: url.loc,
290
+ lastmod: url.lastmod ?? null,
291
+ at: generation.observedAt,
292
+ generation_id: generation.id
293
+ });
294
+ } else kept++;
295
+ }
296
+ for (const [urlHash, previous] of priorByHash) if (previous.removedAt == null && !incomingByHash.has(urlHash)) {
297
+ removed++;
298
+ deltaRows.push({
299
+ feedpath,
300
+ feedpath_hash: feedpathHash,
301
+ url_hash: urlHash,
302
+ op: "removed",
303
+ loc: previous.loc,
304
+ lastmod: previous.lastmod ?? null,
305
+ at: generation.observedAt,
306
+ generation_id: generation.id
307
+ });
308
+ }
309
+ const seedKey = sitemapUrlsEventSeedKey(ctx, feedpathHash);
310
+ const seed = await readJson(seedKey);
311
+ const isSeedGeneration = seed === void 0 || seed.generationId === generation.id;
312
+ const seedRows = isSeedGeneration ? livePrior.map((record) => ({
313
+ feedpath,
314
+ feedpath_hash: feedpathHash,
315
+ url_hash: record.urlHash,
316
+ op: "added",
317
+ loc: record.loc,
318
+ lastmod: record.lastmod ?? null
319
+ })) : [];
320
+ const actualSequence = seedRows.length > 0 ? 1 : 0;
321
+ const inputCount = incomingByHash.size;
322
+ const persistedEvents = await ensureEvents(ctx, feedpathHash, generation, [...seedRows.map((row) => eventRow(generation, row, {
323
+ sequence: 0,
324
+ projectsState: false,
325
+ seedGeneration: isSeedGeneration,
326
+ generationKind: "snapshot",
327
+ contentHash,
328
+ inputCount
329
+ })), ...deltaRows.map((row) => eventRow(generation, row, {
330
+ sequence: actualSequence,
331
+ projectsState: true,
332
+ seedGeneration: isSeedGeneration,
333
+ generationKind: "snapshot",
334
+ contentHash,
335
+ inputCount
336
+ }))]);
337
+ const result = {
338
+ added,
339
+ removed,
340
+ kept,
341
+ contentHash,
342
+ unchanged: deltaRows.length === 0
343
+ };
344
+ if (persistedEvents.rows.length > 0) {
345
+ if ((await projectEvents(ctx, feedpathHash, persistedEvents.rows, persistedEvents.digest))._tag !== "snapshot") throw new Error(`sitemap generation conflict for ${feedpathHash}: expected snapshot event`);
346
+ await ds.delete([sitemapUrlsPendingGenerationKey(ctx, feedpathHash)]);
347
+ } else {
348
+ if (isSeedGeneration) await writeJson(seedKey, {
349
+ version: 1,
350
+ generationId: generation.id,
351
+ observedAt: generation.observedAt
352
+ });
353
+ await writeJson(sitemapUrlsGenerationKey(ctx, feedpathHash), {
354
+ _tag: "snapshot",
355
+ version: 1,
356
+ generationId: generation.id,
357
+ observedAt: generation.observedAt,
358
+ eventDigest: persistedEvents.digest,
359
+ result
360
+ });
361
+ }
362
+ return result;
363
+ });
364
+ },
365
+ compactUrls(ctx, compactOpts = {}) {
366
+ return withMutation(ctx, async () => {
367
+ const startedAt = now();
368
+ const deadlineMs = compactOpts.deadlineMs ?? Number.POSITIVE_INFINITY;
369
+ const maxFeedpaths = compactOpts.maxFeedpaths ?? Number.POSITIVE_INFINITY;
370
+ for (const key of await ds.list(`${sitemapUrlsPendingGenerationsPrefix(ctx)}/`)) {
371
+ const feedpathHash = SITEMAP_URLS_PENDING_GENERATION_RE.exec(key)?.[1];
372
+ if (feedpathHash) await repairFeedProjection(ctx, feedpathHash);
373
+ }
374
+ const deltaKeys = await ds.list(`${sitemapUrlsPrefix(ctx)}/deltas/`);
375
+ let projectionManifest = await readProjectionManifest(ctx);
376
+ await deleteExpiredProjectionDeltas(projectionManifest, deltaKeys, startedAt);
377
+ const activeDeltaKeys = selectSitemapProjectionFiles([], deltaKeys, projectionManifest).deltaKeys;
378
+ const deltasByFeed = /* @__PURE__ */ new Map();
379
+ for (const key of activeDeltaKeys) {
380
+ const feedpathHash = parseSitemapUrlsDeltaKey(key)?.feedpathHash;
381
+ if (!feedpathHash) continue;
382
+ const keys = deltasByFeed.get(feedpathHash) ?? [];
383
+ keys.push(key);
384
+ deltasByFeed.set(feedpathHash, keys);
385
+ }
386
+ const totalFeedpaths = deltasByFeed.size;
387
+ let compactedFeedpaths = 0;
388
+ for (const [feedpathHash, feedDeltaKeys] of deltasByFeed) {
389
+ if (compactedFeedpaths > 0 && (compactedFeedpaths >= maxFeedpaths || now() - startedAt >= deadlineMs)) break;
390
+ const [indexRows, deltaFiles] = await Promise.all([readOptional(ds, sitemapUrlsIndexKey(ctx, feedpathHash)).then((bytes) => bytes ? decodeParquetToRows(bytes) : []), readSitemapDeltaFiles(ds, feedDeltaKeys)]);
391
+ const state = createSitemapUrlState(indexRows);
392
+ applySitemapDeltaFiles(state, deltaFiles);
393
+ projectionManifest = await publishCurrentProjection(ctx, feedpathHash, [...state.live.values(), ...state.removed.values()].sort((a, b) => a.urlHash.localeCompare(b.urlHash)), deltaFiles, projectionManifest, now());
394
+ compactedFeedpaths++;
395
+ }
396
+ return {
397
+ compactedFeedpaths,
398
+ remainingFeedpaths: totalFeedpaths - compactedFeedpaths
399
+ };
400
+ });
401
+ },
402
+ reconcile(ctx, generation, { liveFeedpaths }) {
403
+ return withMutation(ctx, async () => {
404
+ const liveHashes = new Set(liveFeedpaths.map((feedpath) => hash(feedpath)));
405
+ const inputDigest = hashSortedUrlList([...liveHashes]);
406
+ const siteCheckpoint = await readJson(sitemapUrlsReconcileGenerationKey(ctx));
407
+ if (assertGenerationAccepted(generation, siteCheckpoint, "site reconciliation") === "same") {
408
+ if (siteCheckpoint?.inputDigest !== inputDigest) throw new Error("sitemap generation conflict: reconcile input changed");
409
+ return {
410
+ feedpathsPruned: 0,
411
+ urlsRemoved: 0
412
+ };
413
+ }
414
+ const pendingFeedHashes = /* @__PURE__ */ new Set();
415
+ for (const key of await ds.list(`${sitemapUrlsPendingGenerationsPrefix(ctx)}/`)) {
416
+ const feedpathHash = SITEMAP_URLS_PENDING_GENERATION_RE.exec(key)?.[1];
417
+ if (feedpathHash) pendingFeedHashes.add(feedpathHash);
418
+ }
419
+ const repairedCheckpoints = /* @__PURE__ */ new Map();
420
+ for (const feedpathHash of pendingFeedHashes) repairedCheckpoints.set(feedpathHash, await repairFeedProjection(ctx, feedpathHash));
421
+ const present = /* @__PURE__ */ new Set();
422
+ const [indexKeys, deltaKeys] = await Promise.all([ds.list(`${sitemapUrlsIndexPrefix(ctx)}/`), ds.list(`${sitemapUrlsPrefix(ctx)}/deltas/`)]);
423
+ let projectionManifest = await readProjectionManifest(ctx);
424
+ await deleteExpiredProjectionDeltas(projectionManifest, deltaKeys, now());
425
+ for (const feedpathHash of Object.keys(projectionManifest.feeds)) present.add(feedpathHash);
426
+ for (const key of indexKeys) {
427
+ const match = /\/by-feed\/([0-9a-f]+)\/index\.parquet$/.exec(key);
428
+ if (match?.[1]) present.add(match[1]);
429
+ }
430
+ const deltasByFeed = /* @__PURE__ */ new Map();
431
+ const activeDeltaKeys = selectSitemapProjectionFiles([], deltaKeys, projectionManifest).deltaKeys;
432
+ for (const key of activeDeltaKeys) {
433
+ const feedpathHash = parseSitemapUrlsDeltaKey(key)?.feedpathHash;
434
+ if (!feedpathHash) continue;
435
+ present.add(feedpathHash);
436
+ const keys = deltasByFeed.get(feedpathHash) ?? [];
437
+ keys.push(key);
438
+ deltasByFeed.set(feedpathHash, keys);
439
+ }
440
+ let feedpathsPruned = 0;
441
+ let urlsRemoved = 0;
442
+ for (const feedpathHash of present) {
443
+ if (liveHashes.has(feedpathHash)) continue;
444
+ const checkpoint = repairedCheckpoints.has(feedpathHash) ? repairedCheckpoints.get(feedpathHash) : await repairFeedProjection(ctx, feedpathHash);
445
+ const feedPosition = assertGenerationAccepted(generation, checkpoint, feedpathHash);
446
+ if (feedPosition === "same" && checkpoint?._tag !== "reconcile") throw new Error(`sitemap generation conflict for ${feedpathHash}: expected reconcile event`);
447
+ const [indexRows, deltaFiles] = await Promise.all([readOptional(ds, sitemapUrlsIndexKey(ctx, feedpathHash)).then((bytes) => bytes ? decodeParquetToRows(bytes) : []), readSitemapDeltaFiles(ds, deltasByFeed.get(feedpathHash) ?? [])]);
448
+ const state = createSitemapUrlState(indexRows);
449
+ applySitemapDeltaFiles(state, deltaFiles);
450
+ const live = [...state.live.values()];
451
+ const seedKey = sitemapUrlsEventSeedKey(ctx, feedpathHash);
452
+ const seed = await readJson(seedKey);
453
+ const isSeedGeneration = seed === void 0 || seed.generationId === generation.id;
454
+ const seedRows = isSeedGeneration ? live.map((record) => ({
455
+ feedpath: record.feedpath,
456
+ feedpath_hash: feedpathHash,
457
+ url_hash: record.urlHash,
458
+ op: "added",
459
+ loc: record.loc,
460
+ lastmod: record.lastmod ?? null
461
+ })) : [];
462
+ const removalRows = live.map((record) => ({
463
+ feedpath: record.feedpath,
464
+ feedpath_hash: feedpathHash,
465
+ url_hash: record.urlHash,
466
+ op: "removed",
467
+ loc: record.loc,
468
+ lastmod: record.lastmod ?? null
469
+ }));
470
+ const proposedEvents = feedPosition === "newer" ? [...seedRows.map((row) => eventRow(generation, row, {
471
+ sequence: 0,
472
+ projectsState: false,
473
+ seedGeneration: isSeedGeneration,
474
+ generationKind: "reconcile"
475
+ })), ...removalRows.map((row) => eventRow(generation, row, {
476
+ sequence: seedRows.length > 0 ? 1 : 0,
477
+ projectsState: true,
478
+ seedGeneration: isSeedGeneration,
479
+ generationKind: "reconcile"
480
+ }))] : [];
481
+ const persistedEvents = feedPosition === "newer" ? await ensureEvents(ctx, feedpathHash, generation, proposedEvents) : {
482
+ rows: [],
483
+ digest: checkpoint.eventDigest
484
+ };
485
+ if (isSeedGeneration && feedPosition === "newer") await writeJson(seedKey, {
486
+ version: 1,
487
+ generationId: generation.id,
488
+ observedAt: generation.observedAt
489
+ });
490
+ const removedHashes = new Set(persistedEvents.rows.filter((row) => Boolean(row.projects_state) && String(row.op) === "removed").map((row) => String(row.url_hash)));
491
+ const removedBeforeFeed = urlsRemoved;
492
+ for (const record of live.filter((record) => removedHashes.has(record.urlHash))) {
493
+ state.live.delete(record.urlHash);
494
+ state.removed.set(record.urlHash, {
495
+ ...record,
496
+ removedAt: generation.observedAt
497
+ });
498
+ urlsRemoved++;
499
+ }
500
+ 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());
501
+ if (feedPosition === "newer") {
502
+ const feedCheckpoint = persistedEvents.rows.length > 0 ? checkpointFromEvents(persistedEvents.rows, persistedEvents.digest) : {
503
+ _tag: "reconcile",
504
+ version: 1,
505
+ generationId: generation.id,
506
+ observedAt: generation.observedAt,
507
+ eventDigest: persistedEvents.digest
508
+ };
509
+ await writeJson(sitemapUrlsGenerationKey(ctx, feedpathHash), feedCheckpoint);
510
+ if (persistedEvents.rows.length > 0) await ds.delete([sitemapUrlsPendingGenerationKey(ctx, feedpathHash)]);
511
+ }
512
+ if (urlsRemoved > removedBeforeFeed) feedpathsPruned++;
513
+ }
514
+ await writeJson(sitemapUrlsReconcileGenerationKey(ctx), {
515
+ version: 1,
516
+ generationId: generation.id,
517
+ observedAt: generation.observedAt,
518
+ inputDigest
519
+ });
520
+ return {
521
+ feedpathsPruned,
522
+ urlsRemoved
523
+ };
524
+ });
525
+ }
526
+ };
527
+ }
528
+ export { createSitemapStore };
@@ -0,0 +1,3 @@
1
+ import { CreateSitemapReadStoreOptions, SitemapReadStore } from "./sitemap-shared.mjs";
2
+ declare function createSitemapReadStore(opts: CreateSitemapReadStoreOptions): SitemapReadStore;
3
+ export { createSitemapReadStore };
@@ -0,0 +1,91 @@
1
+ import { hashUrl, parseSitemapUrlsDeltaKey, sitemapIndexKey, sitemapUrlsEventsPrefix, sitemapUrlsIndexKey, sitemapUrlsPendingGenerationsPrefix, sitemapUrlsPrefix, sitemapUrlsProjectionManifestKey } from "../entity-keys.mjs";
2
+ import { readOptional } from "../adapters/read-optional.mjs";
3
+ import { decodeParquetToRows } from "../adapters/hyparquet.mjs";
4
+ import { mapEntityIo } from "./io.mjs";
5
+ import { decodeSitemapProjectionManifest, selectSitemapProjectionFiles } from "./sitemap-projection.mjs";
6
+ import { SITEMAP_URLS_EVENT_PREFIX_RE, applySitemapDeltaFiles, createSitemapUrlState, dateInRange, readSitemapDeltaFiles } from "./sitemap-shared.mjs";
7
+ function createSitemapReadStore(opts) {
8
+ const ds = opts.dataSource;
9
+ const hash = opts.hash ?? hashUrl;
10
+ async function readJson(key) {
11
+ const bytes = await readOptional(ds, key);
12
+ return bytes === void 0 ? void 0 : JSON.parse(new TextDecoder().decode(bytes));
13
+ }
14
+ return {
15
+ async loadIndex(ctx) {
16
+ return await readJson(sitemapIndexKey(ctx)) ?? {
17
+ version: 1,
18
+ records: {}
19
+ };
20
+ },
21
+ async getLatest(ctx, path) {
22
+ return (await readJson(sitemapIndexKey(ctx)))?.records[hash(path)];
23
+ },
24
+ async *loadUrls(ctx, feedpath, loadOpts) {
25
+ const feedpathHash = hash(feedpath);
26
+ const listedDeltaKeys = (await ds.list(`${sitemapUrlsPrefix(ctx)}/deltas/`)).filter((key) => parseSitemapUrlsDeltaKey(key)?.feedpathHash === feedpathHash);
27
+ const listedDeltaFiles = await readSitemapDeltaFiles(ds, listedDeltaKeys);
28
+ const manifestBytes = await readOptional(ds, sitemapUrlsProjectionManifestKey(ctx));
29
+ const manifest = manifestBytes ? decodeSitemapProjectionManifest(new TextDecoder().decode(manifestBytes)) : void 0;
30
+ const indexRows = await readOptional(ds, sitemapUrlsIndexKey(ctx, feedpathHash)).then((bytes) => bytes ? decodeParquetToRows(bytes) : []);
31
+ const currentDeltaKeys = new Set(selectSitemapProjectionFiles([], listedDeltaKeys, manifest).deltaKeys);
32
+ const state = createSitemapUrlState(indexRows);
33
+ applySitemapDeltaFiles(state, listedDeltaFiles.filter((file) => currentDeltaKeys.has(file.key)));
34
+ for (const record of state.live.values()) yield record;
35
+ if (loadOpts?.includeRemoved) for (const record of state.removed.values()) yield record;
36
+ },
37
+ async *loadDeltas(ctx, dateRange) {
38
+ const [listedKeys, manifestBytes] = await Promise.all([ds.list(`${sitemapUrlsPrefix(ctx)}/deltas/`), readOptional(ds, sitemapUrlsProjectionManifestKey(ctx))]);
39
+ const keys = selectSitemapProjectionFiles([], listedKeys, manifestBytes ? decodeSitemapProjectionManifest(new TextDecoder().decode(manifestBytes)) : void 0).deltaKeys.filter((key) => {
40
+ const parsed = parseSitemapUrlsDeltaKey(key);
41
+ return Boolean(parsed && dateInRange(parsed.date, dateRange));
42
+ });
43
+ const files = await readSitemapDeltaFiles(ds, keys);
44
+ for (const file of files) for (const row of file.rows) {
45
+ const op = String(row.op);
46
+ if (op !== "added" && op !== "removed") continue;
47
+ yield {
48
+ feedpath: String(row.feedpath),
49
+ feedpathHash: String(row.feedpath_hash),
50
+ urlHash: String(row.url_hash),
51
+ op,
52
+ loc: String(row.loc),
53
+ lastmod: row.lastmod == null ? void 0 : String(row.lastmod),
54
+ at: Number(row.at)
55
+ };
56
+ }
57
+ },
58
+ async *loadEvents(ctx, dateRange) {
59
+ const listedEventKeys = await ds.list(`${sitemapUrlsEventsPrefix(ctx)}/`);
60
+ const pendingKeys = await ds.list(`${sitemapUrlsPendingGenerationsPrefix(ctx)}/`);
61
+ const pendingEvents = new Set((await mapEntityIo(pendingKeys, (key) => readJson(key))).filter((pending) => pending !== void 0).map((pending) => pending.eventKey));
62
+ const keys = listedEventKeys.filter((key) => {
63
+ const match = SITEMAP_URLS_EVENT_PREFIX_RE.exec(key);
64
+ return !pendingEvents.has(key) && Boolean(match?.[1] && dateInRange(match[1], dateRange));
65
+ }).sort();
66
+ for (const key of keys) {
67
+ const bytes = await readOptional(ds, key);
68
+ if (!bytes) continue;
69
+ const rows = await decodeParquetToRows(bytes);
70
+ 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)));
71
+ for (const row of rows) {
72
+ const op = String(row.op);
73
+ if (op !== "added" && op !== "removed") continue;
74
+ yield {
75
+ feedpath: String(row.feedpath),
76
+ feedpathHash: String(row.feedpath_hash),
77
+ urlHash: String(row.url_hash),
78
+ op,
79
+ loc: String(row.loc),
80
+ lastmod: row.lastmod == null ? void 0 : String(row.lastmod),
81
+ generationId: String(row.generation_id),
82
+ observedAt: Number(row.observed_at),
83
+ sequence: Number(row.sequence),
84
+ projectsState: Boolean(row.projects_state)
85
+ };
86
+ }
87
+ }
88
+ }
89
+ };
90
+ }
91
+ export { createSitemapReadStore };