@gscdump/engine 1.5.3 → 2.0.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.
@@ -0,0 +1,553 @@
1
+ import { hashUrl, parseSitemapFeedIdentity, sitemapImmutableBaseKey, sitemapLegacyImportKey, sitemapPayloadHashV1, sitemapSiteGenerationManifestKey, sitemapSiteManifestKey, sitemapStagedFeedKey, sitemapStagedGenerationPrefix, sitemapUrlsEventKey } from "../entity-keys.mjs";
2
+ import { readOptional } from "../adapters/read-optional.mjs";
3
+ import { decodeParquetToRows, encodeRowsToParquetFlex } from "../adapters/hyparquet.mjs";
4
+ import { URLS_EVENT_COLUMNS, URLS_INDEX_COLUMNS, rowToUrlRecord, urlRecordToRow } from "./sitemap-shared.mjs";
5
+ import { encodeJsonBigintSafe } from "@gscdump/lakehouse/bigint";
6
+ import { sitemapContentHash } from "gscdump/sitemap-identity";
7
+ function validGeneration(generation) {
8
+ if (!generation.id) return {
9
+ _tag: "invalid_generation",
10
+ reason: "empty_id"
11
+ };
12
+ if (!Number.isSafeInteger(generation.observedAt) || generation.observedAt < 0) return {
13
+ _tag: "invalid_generation",
14
+ reason: "invalid_observed_at"
15
+ };
16
+ return { _tag: "ok" };
17
+ }
18
+ function cursorFor(generationId, feedpath, feedIndex, rowOffset) {
19
+ return `v2:${JSON.stringify({
20
+ generationId,
21
+ feedpath: feedpath ?? null,
22
+ feedIndex,
23
+ rowOffset
24
+ })}`;
25
+ }
26
+ function parseCursor(generationId, feedpath, cursor) {
27
+ if (!cursor) return {
28
+ _tag: "ok",
29
+ feedIndex: 0,
30
+ rowOffset: 0
31
+ };
32
+ if (!cursor.startsWith("v2:")) return { _tag: "invalid" };
33
+ let input;
34
+ try {
35
+ input = JSON.parse(cursor.slice(3));
36
+ } catch {
37
+ return { _tag: "invalid" };
38
+ }
39
+ if (!input || typeof input !== "object" || Array.isArray(input)) return { _tag: "invalid" };
40
+ const parsed = input;
41
+ const feedIndex = parsed.feedIndex;
42
+ const rowOffset = parsed.rowOffset;
43
+ return parsed.generationId === generationId && parsed.feedpath === (feedpath ?? null) && typeof feedIndex === "number" && Number.isSafeInteger(feedIndex) && feedIndex >= 0 && typeof rowOffset === "number" && Number.isSafeInteger(rowOffset) && rowOffset >= 0 ? {
44
+ _tag: "ok",
45
+ feedIndex,
46
+ rowOffset
47
+ } : { _tag: "invalid" };
48
+ }
49
+ function generationPosition(generation, current) {
50
+ if (!current) return "newer";
51
+ if (generation.id === current.generationId) return generation.observedAt === current.observedAt ? "same" : "reused";
52
+ return generation.observedAt > current.observedAt ? "newer" : "stale";
53
+ }
54
+ function generationConflict(position) {
55
+ return {
56
+ _tag: "conflict",
57
+ reason: position === "stale" ? "stale_generation" : "generation_reused"
58
+ };
59
+ }
60
+ function invalidStagedFeed(key) {
61
+ throw new Error(`invalid staged sitemap feed descriptor: ${key}`);
62
+ }
63
+ function parseStagedFeed(ctx, generation, key, input) {
64
+ if (!input || typeof input !== "object" || Array.isArray(input)) return invalidStagedFeed(key);
65
+ const staged = input;
66
+ if (!staged.feed || typeof staged.feed !== "object" || Array.isArray(staged.feed)) return invalidStagedFeed(key);
67
+ const feed = staged.feed;
68
+ if (staged.version !== 1 || staged.generationId !== generation.id || staged.observedAt !== generation.observedAt || typeof feed.feedpath !== "string" || typeof feed.feedpathHash !== "string" || typeof feed.baseKey !== "string" || !(feed.eventKey === null || typeof feed.eventKey === "string") || typeof feed.membershipHash !== "string" || typeof feed.payloadHash !== "string" || typeof feed.urlCount !== "number" || !Number.isSafeInteger(feed.urlCount) || feed.urlCount < 0 || feed.observedAt !== generation.observedAt) return invalidStagedFeed(key);
69
+ const identity = parseSitemapFeedIdentity(feed.feedpath);
70
+ if (identity._tag !== "ok" || identity.url !== feed.feedpath) return invalidStagedFeed(key);
71
+ const expectedFeedpathHash = hashUrl(feed.feedpath);
72
+ if (feed.feedpathHash !== expectedFeedpathHash || key !== sitemapStagedFeedKey(ctx, generation, expectedFeedpathHash) || feed.baseKey !== sitemapImmutableBaseKey(ctx, generation, expectedFeedpathHash) || feed.eventKey !== null && feed.eventKey !== sitemapUrlsEventKey(ctx, expectedFeedpathHash, generation)) return invalidStagedFeed(key);
73
+ return input;
74
+ }
75
+ function createSitemapGenerationReadMethods(opts) {
76
+ const ds = opts.dataSource;
77
+ async function readJson(key) {
78
+ const bytes = await readOptional(ds, key);
79
+ return bytes === void 0 ? void 0 : JSON.parse(new TextDecoder().decode(bytes));
80
+ }
81
+ async function findManifest(ctx, generationId) {
82
+ const current = await readJson(sitemapSiteManifestKey(ctx));
83
+ if (!generationId || current?.generationId === generationId) return current;
84
+ let cursor = current;
85
+ const seen = /* @__PURE__ */ new Set();
86
+ while (cursor?.previousManifestKey) {
87
+ if (seen.has(cursor.previousManifestKey)) throw new Error("sitemap generation manifest ancestry cycle");
88
+ seen.add(cursor.previousManifestKey);
89
+ const previous = await readJson(cursor.previousManifestKey);
90
+ if (!previous || previous.generationId !== cursor.previousGenerationId) throw new Error("sitemap generation manifest ancestry is incomplete");
91
+ if (previous.generationId === generationId) return previous;
92
+ cursor = previous;
93
+ }
94
+ }
95
+ async function loadFeedRows(feed) {
96
+ const bytes = await readOptional(ds, feed.baseKey);
97
+ if (!bytes) throw new Error(`published sitemap base missing: ${feed.baseKey}`);
98
+ return (await decodeParquetToRows(bytes)).map((row) => {
99
+ const record = rowToUrlRecord(row);
100
+ return {
101
+ ...record,
102
+ lastSeenAt: Math.max(record.lastSeenAt, feed.observedAt)
103
+ };
104
+ }).sort((left, right) => compareExactText(left.loc, right.loc) || compareExactText(left.urlHash, right.urlHash));
105
+ }
106
+ function compareExactText(left, right) {
107
+ return left < right ? -1 : left > right ? 1 : 0;
108
+ }
109
+ function selectFeeds(manifest, feedpath) {
110
+ return Object.values(manifest.feeds).filter((feed) => !feedpath || feed.feedpath === feedpath).sort((left, right) => compareExactText(left.feedpath, right.feedpath));
111
+ }
112
+ return {
113
+ async getSitemapGeneration(ctx, generationId) {
114
+ const manifest = await findManifest(ctx, generationId);
115
+ if (manifest) return {
116
+ _tag: "available",
117
+ manifest
118
+ };
119
+ return {
120
+ _tag: "unavailable",
121
+ reason: generationId ? "generation_not_found" : "no_generation"
122
+ };
123
+ },
124
+ async iterateSitemapGenerationUrls(ctx, iterateOpts = {}) {
125
+ const selectionInput = iterateOpts.feedpath ? parseSitemapFeedIdentity(iterateOpts.feedpath) : void 0;
126
+ if (selectionInput && selectionInput._tag !== "ok") return {
127
+ _tag: "invalid_request",
128
+ reason: "invalid_feed"
129
+ };
130
+ const manifest = await findManifest(ctx, iterateOpts.generationId);
131
+ if (!manifest) return {
132
+ _tag: "unavailable",
133
+ reason: iterateOpts.generationId ? "generation_not_found" : "no_generation"
134
+ };
135
+ const feeds = selectFeeds(manifest, selectionInput?.url);
136
+ async function* items() {
137
+ for (const feed of feeds) for (const record of await loadFeedRows(feed)) yield record;
138
+ }
139
+ return {
140
+ _tag: "iterator",
141
+ generationId: manifest.generationId,
142
+ observedAt: manifest.observedAt,
143
+ items: items(),
144
+ completeness: manifest.completeness,
145
+ membershipHistoryAvailableFrom: manifest.membershipHistoryAvailableFrom
146
+ };
147
+ },
148
+ async listSitemapGenerationUrls(ctx, listOpts = {}) {
149
+ const limit = listOpts.limit ?? 500;
150
+ if (!Number.isSafeInteger(limit) || limit < 1 || limit > 1e4) return {
151
+ _tag: "invalid_request",
152
+ reason: "invalid_limit"
153
+ };
154
+ const selectionInput = listOpts.feedpath ? parseSitemapFeedIdentity(listOpts.feedpath) : void 0;
155
+ if (selectionInput && selectionInput._tag !== "ok") return {
156
+ _tag: "invalid_request",
157
+ reason: "invalid_feed"
158
+ };
159
+ const manifest = await findManifest(ctx, listOpts.generationId);
160
+ if (!manifest) return {
161
+ _tag: "unavailable",
162
+ reason: listOpts.generationId ? "generation_not_found" : "no_generation"
163
+ };
164
+ const selection = selectionInput?.url;
165
+ const cursor = parseCursor(manifest.generationId, selection, listOpts.cursor);
166
+ if (cursor._tag === "invalid") return {
167
+ _tag: "invalid_request",
168
+ reason: "invalid_cursor"
169
+ };
170
+ const feeds = selectFeeds(manifest, selectionInput?.url);
171
+ if (cursor.feedIndex > feeds.length) return {
172
+ _tag: "invalid_request",
173
+ reason: "invalid_cursor"
174
+ };
175
+ const items = [];
176
+ let feedIndex = cursor.feedIndex;
177
+ let rowOffset = cursor.rowOffset;
178
+ while (feedIndex < feeds.length && items.length < limit) {
179
+ const rows = await loadFeedRows(feeds[feedIndex]);
180
+ if (rowOffset > rows.length) return {
181
+ _tag: "invalid_request",
182
+ reason: "invalid_cursor"
183
+ };
184
+ const take = rows.slice(rowOffset, rowOffset + limit - items.length);
185
+ items.push(...take);
186
+ rowOffset += take.length;
187
+ if (rowOffset >= rows.length) {
188
+ feedIndex++;
189
+ rowOffset = 0;
190
+ }
191
+ }
192
+ return {
193
+ _tag: "page",
194
+ generationId: manifest.generationId,
195
+ observedAt: manifest.observedAt,
196
+ items,
197
+ nextCursor: feedIndex < feeds.length ? cursorFor(manifest.generationId, selection, feedIndex, rowOffset) : null,
198
+ completeness: manifest.completeness,
199
+ membershipHistoryAvailableFrom: manifest.membershipHistoryAvailableFrom
200
+ };
201
+ },
202
+ async querySitemapGenerationMembership(ctx, queryOpts) {
203
+ const manifest = await findManifest(ctx, queryOpts.generationId);
204
+ if (!manifest) return {
205
+ _tag: "unavailable",
206
+ reason: queryOpts.generationId ? "generation_not_found" : "no_generation"
207
+ };
208
+ const requested = new Set(queryOpts.urls);
209
+ const matched = /* @__PURE__ */ new Map();
210
+ const feeds = Object.values(manifest.feeds).sort((a, b) => a.feedpath.localeCompare(b.feedpath));
211
+ for (const feed of feeds) {
212
+ for (const record of await loadFeedRows(feed)) if (requested.has(record.loc) && !matched.has(record.loc)) matched.set(record.loc, record);
213
+ if (matched.size === requested.size) break;
214
+ }
215
+ return {
216
+ _tag: "result",
217
+ generationId: manifest.generationId,
218
+ observedAt: manifest.observedAt,
219
+ evidence: queryOpts.urls.map((url) => {
220
+ const record = matched.get(url);
221
+ return record ? {
222
+ _tag: "present",
223
+ url,
224
+ feedpath: record.feedpath,
225
+ lastmod: record.lastmod ?? null,
226
+ firstSeenAt: record.firstSeenAt,
227
+ lastSeenAt: record.lastSeenAt
228
+ } : {
229
+ _tag: "absent",
230
+ url,
231
+ observedAt: manifest.observedAt
232
+ };
233
+ }),
234
+ completeness: manifest.completeness,
235
+ membershipHistoryAvailableFrom: manifest.membershipHistoryAvailableFrom
236
+ };
237
+ }
238
+ };
239
+ }
240
+ function createSitemapGenerationWriteMethods(opts) {
241
+ const ds = opts.dataSource;
242
+ const now = opts.now ?? (() => Date.now());
243
+ async function readJson(key) {
244
+ const bytes = await readOptional(ds, key);
245
+ return bytes === void 0 ? void 0 : JSON.parse(new TextDecoder().decode(bytes));
246
+ }
247
+ function writeJson(key, value) {
248
+ return ds.write(key, encodeJsonBigintSafe(value));
249
+ }
250
+ function bytesEqual(left, right) {
251
+ return left.length === right.length && left.every((byte, index) => byte === right[index]);
252
+ }
253
+ async function writeImmutable(key, bytes, label) {
254
+ const existing = await readOptional(ds, key);
255
+ if (!existing) {
256
+ await ds.write(key, bytes);
257
+ return;
258
+ }
259
+ if (!bytesEqual(existing, bytes)) throw new Error(`sitemap immutable ${label} conflict: ${key}`);
260
+ }
261
+ async function currentManifest(ctx) {
262
+ return readJson(sitemapSiteManifestKey(ctx));
263
+ }
264
+ async function objectExists(key) {
265
+ if (ds.head) return await ds.head(key) !== void 0;
266
+ return await readOptional(ds, key) !== void 0;
267
+ }
268
+ async function generationIsPublished(ctx, generation) {
269
+ let cursor = await currentManifest(ctx);
270
+ const seen = /* @__PURE__ */ new Set();
271
+ while (cursor) {
272
+ if (cursor.generationId === generation.id && cursor.observedAt === generation.observedAt) return true;
273
+ if (cursor.observedAt < generation.observedAt || !cursor.previousManifestKey) return false;
274
+ if (seen.has(cursor.previousManifestKey)) throw new Error("sitemap generation manifest ancestry cycle");
275
+ seen.add(cursor.previousManifestKey);
276
+ const previous = await readJson(cursor.previousManifestKey);
277
+ if (!previous || previous.generationId !== cursor.previousGenerationId) throw new Error("sitemap generation manifest ancestry is incomplete");
278
+ cursor = previous;
279
+ }
280
+ return false;
281
+ }
282
+ async function readFeedRows(feed) {
283
+ if (!feed) return [];
284
+ const bytes = await readOptional(ds, feed.baseKey);
285
+ if (!bytes) throw new Error(`published sitemap base missing: ${feed.baseKey}`);
286
+ return (await decodeParquetToRows(bytes)).map(rowToUrlRecord);
287
+ }
288
+ function eventRows(generation, feedpath, feedpathHash, previous, next) {
289
+ const previousByLoc = new Map(previous.map((record) => [record.loc, record]));
290
+ const nextByLoc = new Map(next.map((record) => [record.loc, record]));
291
+ const changed = [];
292
+ for (const record of next) {
293
+ const prior = previousByLoc.get(record.loc);
294
+ if (!prior) changed.push({
295
+ op: "added",
296
+ record
297
+ });
298
+ else if ((prior.lastmod ?? null) !== (record.lastmod ?? null)) changed.push({
299
+ op: "updated",
300
+ record,
301
+ previousLastmod: prior.lastmod
302
+ });
303
+ }
304
+ for (const record of previous) if (!nextByLoc.has(record.loc)) changed.push({
305
+ op: "removed",
306
+ record
307
+ });
308
+ return changed.sort((a, b) => a.record.loc.localeCompare(b.record.loc) || a.op.localeCompare(b.op)).map(({ op, record, previousLastmod }, sequence) => ({
309
+ feedpath,
310
+ feedpath_hash: feedpathHash,
311
+ url_hash: record.urlHash,
312
+ op,
313
+ loc: record.loc,
314
+ lastmod: record.lastmod ?? null,
315
+ previous_lastmod: previousLastmod ?? null,
316
+ generation_id: generation.id,
317
+ observed_at: generation.observedAt,
318
+ sequence,
319
+ projects_state: 1
320
+ }));
321
+ }
322
+ async function writeImmutableEvents(ctx, generation, feedpath, feedpathHash, rows) {
323
+ if (rows.length === 0) return null;
324
+ const key = sitemapUrlsEventKey(ctx, feedpathHash, generation);
325
+ await writeImmutable(key, encodeRowsToParquetFlex(rows, {
326
+ columns: URLS_EVENT_COLUMNS,
327
+ sortKey: ["sequence", "url_hash"]
328
+ }), `event for ${feedpath}`);
329
+ return key;
330
+ }
331
+ return {
332
+ stageSitemapGenerationFeed(ctx, generation, inputFeedpath, urls) {
333
+ return opts.withMutation(ctx, async () => {
334
+ const valid = validGeneration(generation);
335
+ if (valid._tag !== "ok") return valid;
336
+ const identity = parseSitemapFeedIdentity(inputFeedpath);
337
+ if (identity._tag !== "ok") return {
338
+ _tag: "invalid_feed",
339
+ reason: identity.reason
340
+ };
341
+ const feedpath = identity.url;
342
+ const feedpathHash = hashUrl(feedpath);
343
+ const current = await currentManifest(ctx);
344
+ const position = generationPosition(generation, current);
345
+ if (position === "stale" || position === "reused") return generationConflict(position);
346
+ const incomingByLoc = /* @__PURE__ */ new Map();
347
+ for (const url of urls) incomingByLoc.set(url.loc, url);
348
+ const canonicalUrls = [...incomingByLoc.values()];
349
+ const stagedKey = sitemapStagedFeedKey(ctx, generation, feedpathHash);
350
+ const existingInput = await readJson(stagedKey);
351
+ const existing = existingInput === void 0 ? void 0 : parseStagedFeed(ctx, generation, stagedKey, existingInput);
352
+ const membershipHash = await sitemapContentHash(canonicalUrls);
353
+ const payloadHash = sitemapPayloadHashV1(canonicalUrls);
354
+ const previousFeed = current?.feeds[feedpathHash];
355
+ const previousRows = await readFeedRows(previousFeed);
356
+ const previousByLoc = new Map(previousRows.map((record) => [record.loc, record]));
357
+ const nextRows = [...incomingByLoc].map(([loc, url]) => {
358
+ const prior = previousByLoc.get(loc);
359
+ return {
360
+ feedpath,
361
+ feedpathHash,
362
+ urlHash: hashUrl(`sitemap-url:v1\u0000${loc}`),
363
+ loc,
364
+ lastmod: url.lastmod,
365
+ firstSeenAt: prior?.firstSeenAt ?? generation.observedAt,
366
+ lastSeenAt: generation.observedAt
367
+ };
368
+ }).sort((a, b) => a.loc.localeCompare(b.loc));
369
+ const changes = eventRows(generation, feedpath, feedpathHash, previousRows, nextRows);
370
+ const baseKey = sitemapImmutableBaseKey(ctx, generation, feedpathHash);
371
+ const eventKey = changes.length === 0 ? null : sitemapUrlsEventKey(ctx, feedpathHash, generation);
372
+ const feed = {
373
+ feedpath,
374
+ feedpathHash,
375
+ baseKey,
376
+ eventKey,
377
+ membershipHash,
378
+ payloadHash,
379
+ urlCount: nextRows.length,
380
+ observedAt: generation.observedAt
381
+ };
382
+ if (existing && (existing.feed.feedpath !== feedpath || existing.feed.membershipHash !== membershipHash || existing.feed.payloadHash !== payloadHash || existing.feed.eventKey !== eventKey || existing.feed.urlCount !== nextRows.length)) return {
383
+ _tag: "conflict",
384
+ reason: "generation_reused"
385
+ };
386
+ if (!existing) await writeJson(stagedKey, {
387
+ version: 1,
388
+ generationId: generation.id,
389
+ observedAt: generation.observedAt,
390
+ feed
391
+ });
392
+ await writeImmutable(baseKey, encodeRowsToParquetFlex(nextRows.map(urlRecordToRow), {
393
+ columns: URLS_INDEX_COLUMNS,
394
+ sortKey: ["feedpath_hash", "url_hash"]
395
+ }), `base for ${feedpath}`);
396
+ await writeImmutableEvents(ctx, generation, feedpath, feedpathHash, changes);
397
+ return {
398
+ _tag: "staged",
399
+ generationId: generation.id,
400
+ feedpath,
401
+ membershipChanged: previousFeed?.membershipHash !== membershipHash,
402
+ payloadChanged: previousFeed?.payloadHash !== payloadHash,
403
+ added: changes.filter((row) => row.op === "added").length,
404
+ removed: changes.filter((row) => row.op === "removed").length,
405
+ updated: changes.filter((row) => row.op === "updated").length
406
+ };
407
+ });
408
+ },
409
+ finalizeSitemapGeneration(ctx, generation, traversal) {
410
+ return opts.withMutation(ctx, async () => {
411
+ const valid = validGeneration(generation);
412
+ if (valid._tag !== "ok") return valid;
413
+ const current = await currentManifest(ctx);
414
+ const position = generationPosition(generation, current);
415
+ if (position === "stale" || position === "reused") return generationConflict(position);
416
+ if (position === "same") return {
417
+ _tag: "unchanged",
418
+ generationId: generation.id,
419
+ observedAt: generation.observedAt,
420
+ feedCount: Object.keys(current?.feeds ?? {}).length
421
+ };
422
+ const expected = /* @__PURE__ */ new Map();
423
+ const invalidFeedpaths = [];
424
+ for (const raw of traversal.expectedFeedpaths) {
425
+ const identity = parseSitemapFeedIdentity(raw);
426
+ if (identity._tag !== "ok") {
427
+ invalidFeedpaths.push(raw);
428
+ continue;
429
+ }
430
+ expected.set(hashUrl(identity.url), identity.url);
431
+ }
432
+ const feeds = {};
433
+ const missingFeedpaths = [];
434
+ for (const [feedpathHash, feedpath] of expected) {
435
+ const stagedKey = sitemapStagedFeedKey(ctx, generation, feedpathHash);
436
+ const stagedInput = await readJson(stagedKey);
437
+ const staged = stagedInput === void 0 ? void 0 : parseStagedFeed(ctx, generation, stagedKey, stagedInput);
438
+ const stagedObjectsExist = staged ? await Promise.all([objectExists(staged.feed.baseKey), ...staged.feed.eventKey ? [objectExists(staged.feed.eventKey)] : []]).then((results) => results.every(Boolean)) : false;
439
+ if (!staged || !stagedObjectsExist || staged.generationId !== generation.id || staged.observedAt !== generation.observedAt || staged.feed.feedpath !== feedpath) {
440
+ missingFeedpaths.push(feedpath);
441
+ continue;
442
+ }
443
+ feeds[feedpathHash] = staged.feed;
444
+ }
445
+ if (missingFeedpaths.length > 0 || invalidFeedpaths.length > 0) return {
446
+ _tag: "incomplete",
447
+ missingFeedpaths,
448
+ invalidFeedpaths
449
+ };
450
+ const eventKeys = Object.values(feeds).map((feed) => feed.eventKey).filter((key) => key !== null);
451
+ for (const [feedpathHash, priorFeed] of Object.entries(current?.feeds ?? {})) {
452
+ if (feeds[feedpathHash]) continue;
453
+ const previousRows = await readFeedRows(priorFeed);
454
+ const eventKey = await writeImmutableEvents(ctx, generation, priorFeed.feedpath, feedpathHash, eventRows(generation, priorFeed.feedpath, feedpathHash, previousRows, []));
455
+ if (eventKey) eventKeys.push(eventKey);
456
+ }
457
+ const legacyImport = await readJson(sitemapLegacyImportKey(ctx)) ?? { _tag: "none" };
458
+ const manifestKey = sitemapSiteGenerationManifestKey(ctx, generation);
459
+ const existingManifest = await readJson(manifestKey);
460
+ const manifest = {
461
+ version: 1,
462
+ generationId: generation.id,
463
+ observedAt: generation.observedAt,
464
+ publishedAt: existingManifest?.publishedAt ?? now(),
465
+ completeness: { _tag: "complete" },
466
+ membershipHistoryAvailableFrom: current?.membershipHistoryAvailableFrom ?? generation.observedAt,
467
+ legacyImport,
468
+ previousGenerationId: current?.generationId ?? null,
469
+ previousManifestKey: current ? sitemapSiteGenerationManifestKey(ctx, {
470
+ id: current.generationId,
471
+ observedAt: current.observedAt
472
+ }) : null,
473
+ eventKeys: eventKeys.sort(),
474
+ feeds
475
+ };
476
+ await writeImmutable(manifestKey, encodeJsonBigintSafe(manifest), `generation manifest for ${generation.id}`);
477
+ const latest = await currentManifest(ctx);
478
+ if (JSON.stringify(latest) !== JSON.stringify(current)) {
479
+ const latestPosition = generationPosition(generation, latest);
480
+ if (latestPosition === "same") return {
481
+ _tag: "unchanged",
482
+ generationId: generation.id,
483
+ observedAt: generation.observedAt,
484
+ feedCount: Object.keys(latest?.feeds ?? {}).length
485
+ };
486
+ if (latestPosition === "stale" || latestPosition === "reused") return generationConflict(latestPosition);
487
+ return {
488
+ _tag: "conflict",
489
+ reason: "publication_changed"
490
+ };
491
+ }
492
+ await writeJson(sitemapSiteManifestKey(ctx), manifest);
493
+ return {
494
+ _tag: "published",
495
+ generationId: generation.id,
496
+ observedAt: generation.observedAt,
497
+ feedCount: Object.keys(feeds).length
498
+ };
499
+ });
500
+ },
501
+ abortSitemapGeneration(ctx, generation) {
502
+ return opts.withMutation(ctx, async () => {
503
+ const valid = validGeneration(generation);
504
+ if (valid._tag !== "ok") throw new Error(`invalid sitemap generation: ${valid.reason}`);
505
+ if (await generationIsPublished(ctx, generation)) throw new Error(`cannot abort published sitemap generation: ${generation.id}`);
506
+ const stagedKeys = await ds.list(`${sitemapStagedGenerationPrefix(ctx, generation)}/`);
507
+ const stagedFeeds = await Promise.all(stagedKeys.map(async (key) => {
508
+ const input = await readJson(key);
509
+ if (input === void 0) return invalidStagedFeed(key);
510
+ return parseStagedFeed(ctx, generation, key, input);
511
+ }));
512
+ const cleanup = /* @__PURE__ */ new Set([...stagedKeys, sitemapSiteGenerationManifestKey(ctx, generation)]);
513
+ for (const staged of stagedFeeds) {
514
+ cleanup.add(staged.feed.baseKey);
515
+ if (staged.feed.eventKey) cleanup.add(staged.feed.eventKey);
516
+ }
517
+ const current = await currentManifest(ctx);
518
+ for (const [feedpathHash] of Object.entries(current?.feeds ?? {})) cleanup.add(sitemapUrlsEventKey(ctx, feedpathHash, generation));
519
+ await ds.delete([...cleanup]);
520
+ });
521
+ },
522
+ recordSitemapLegacyImport(ctx, input) {
523
+ return opts.withMutation(ctx, async () => {
524
+ if (!Number.isSafeInteger(input.importedAt) || input.importedAt < 0) return {
525
+ _tag: "invalid",
526
+ reason: "invalid_imported_at"
527
+ };
528
+ if (!Number.isSafeInteger(input.recordCount) || input.recordCount < 0) return {
529
+ _tag: "invalid",
530
+ reason: "invalid_record_count"
531
+ };
532
+ const key = sitemapLegacyImportKey(ctx);
533
+ const existing = await readJson(key);
534
+ if (existing) return {
535
+ _tag: "existing",
536
+ evidence: existing
537
+ };
538
+ const evidence = {
539
+ _tag: "metadata_only",
540
+ importedAt: input.importedAt,
541
+ recordCount: input.recordCount,
542
+ source: "gsc_sitemaps"
543
+ };
544
+ await writeJson(key, evidence);
545
+ return {
546
+ _tag: "recorded",
547
+ evidence
548
+ };
549
+ });
550
+ }
551
+ };
552
+ }
553
+ export { createSitemapGenerationReadMethods, createSitemapGenerationWriteMethods };