@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
|
@@ -1,528 +1,9 @@
|
|
|
1
|
-
import {
|
|
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";
|
|
1
|
+
import { createSitemapGenerationWriteMethods } from "./sitemap-generation.mjs";
|
|
7
2
|
import { createSitemapReadStore } from "./sitemap.mjs";
|
|
8
|
-
import { encodeJsonBigintSafe } from "@gscdump/lakehouse/bigint";
|
|
9
3
|
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
4
|
return {
|
|
236
|
-
...
|
|
237
|
-
|
|
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
|
-
}
|
|
5
|
+
...createSitemapReadStore(opts),
|
|
6
|
+
...createSitemapGenerationWriteMethods(opts)
|
|
526
7
|
};
|
|
527
8
|
}
|
|
528
9
|
export { createSitemapStore };
|
|
@@ -1,88 +1,64 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { sitemapSiteManifestKey } from "../entity-keys.mjs";
|
|
2
2
|
import { readOptional } from "../adapters/read-optional.mjs";
|
|
3
3
|
import { decodeParquetToRows } from "../adapters/hyparquet.mjs";
|
|
4
|
-
import {
|
|
5
|
-
|
|
6
|
-
|
|
4
|
+
import { createSitemapGenerationReadMethods } from "./sitemap-generation.mjs";
|
|
5
|
+
function dateInRange(date, range) {
|
|
6
|
+
return (!range?.from || date >= range.from) && (!range?.to || date <= range.to);
|
|
7
|
+
}
|
|
8
|
+
function rowToMembershipEvent(row) {
|
|
9
|
+
const op = String(row.op);
|
|
10
|
+
if (op !== "added" && op !== "removed" && op !== "updated") return void 0;
|
|
11
|
+
return {
|
|
12
|
+
feedpath: String(row.feedpath),
|
|
13
|
+
feedpathHash: String(row.feedpath_hash),
|
|
14
|
+
urlHash: String(row.url_hash),
|
|
15
|
+
op,
|
|
16
|
+
loc: String(row.loc),
|
|
17
|
+
lastmod: row.lastmod == null ? void 0 : String(row.lastmod),
|
|
18
|
+
previousLastmod: row.previous_lastmod == null ? void 0 : String(row.previous_lastmod),
|
|
19
|
+
generationId: String(row.generation_id),
|
|
20
|
+
observedAt: Number(row.observed_at),
|
|
21
|
+
sequence: Number(row.sequence),
|
|
22
|
+
projectsState: Boolean(row.projects_state)
|
|
23
|
+
};
|
|
24
|
+
}
|
|
7
25
|
function createSitemapReadStore(opts) {
|
|
8
26
|
const ds = opts.dataSource;
|
|
9
|
-
const
|
|
10
|
-
async function
|
|
27
|
+
const generationReads = createSitemapGenerationReadMethods(opts);
|
|
28
|
+
async function readManifest(key) {
|
|
11
29
|
const bytes = await readOptional(ds, key);
|
|
12
30
|
return bytes === void 0 ? void 0 : JSON.parse(new TextDecoder().decode(bytes));
|
|
13
31
|
}
|
|
14
32
|
return {
|
|
15
|
-
|
|
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
|
-
},
|
|
33
|
+
...generationReads,
|
|
58
34
|
async *loadEvents(ctx, dateRange) {
|
|
59
|
-
const
|
|
60
|
-
|
|
61
|
-
const
|
|
62
|
-
const
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
if (!
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
}
|
|
35
|
+
const current = await readManifest(sitemapSiteManifestKey(ctx));
|
|
36
|
+
if (!current) return;
|
|
37
|
+
const ancestry = [];
|
|
38
|
+
const seen = /* @__PURE__ */ new Set();
|
|
39
|
+
let cursor = current;
|
|
40
|
+
while (cursor) {
|
|
41
|
+
const observedDate = new Date(cursor.observedAt).toISOString().slice(0, 10);
|
|
42
|
+
if (dateRange?.from && observedDate < dateRange.from) break;
|
|
43
|
+
ancestry.push(cursor);
|
|
44
|
+
if (!cursor.previousManifestKey) break;
|
|
45
|
+
if (seen.has(cursor.previousManifestKey)) throw new Error("sitemap generation manifest ancestry cycle");
|
|
46
|
+
seen.add(cursor.previousManifestKey);
|
|
47
|
+
const previous = await readManifest(cursor.previousManifestKey);
|
|
48
|
+
if (!previous || previous.generationId !== cursor.previousGenerationId) throw new Error("sitemap generation manifest ancestry is incomplete");
|
|
49
|
+
cursor = previous;
|
|
50
|
+
}
|
|
51
|
+
for (const manifest of ancestry.reverse()) {
|
|
52
|
+
if (!dateInRange(new Date(manifest.observedAt).toISOString().slice(0, 10), dateRange)) continue;
|
|
53
|
+
for (const key of manifest.eventKeys) {
|
|
54
|
+
const bytes = await readOptional(ds, key);
|
|
55
|
+
if (!bytes) throw new Error(`published sitemap event missing: ${key}`);
|
|
56
|
+
const rows = await decodeParquetToRows(bytes);
|
|
57
|
+
rows.sort((left, right) => Number(left.sequence) - Number(right.sequence) || String(left.url_hash).localeCompare(String(right.url_hash)));
|
|
58
|
+
for (const row of rows) {
|
|
59
|
+
const event = rowToMembershipEvent(row);
|
|
60
|
+
if (event) yield event;
|
|
61
|
+
}
|
|
86
62
|
}
|
|
87
63
|
}
|
|
88
64
|
}
|