@gscdump/engine 1.5.4 → 2.0.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.
- package/dist/entities/sitemap-generation.mjs +553 -0
- package/dist/entities/sitemap-shared.d.mts +191 -148
- package/dist/entities/sitemap-shared.mjs +12 -131
- package/dist/entities/sitemap-write.mjs +3 -522
- package/dist/entities/sitemap.mjs +52 -76
- package/dist/entities.d.mts +3 -4
- package/dist/entities.mjs +2 -3
- package/dist/entity-keys.d.mts +20 -20
- package/dist/entity-keys.mjs +49 -39
- package/dist/rollups/indexing.d.mts +5 -9
- package/dist/rollups/indexing.mjs +59 -88
- package/package.json +4 -4
- package/dist/entities/sitemap-projection.d.mts +0 -20
- package/dist/entities/sitemap-projection.mjs +0 -57
|
@@ -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 };
|