@coywolf/emdash 0.0.0-stage → 0.35.13
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/LICENSE +21 -0
- package/README.md +201 -2
- package/package.json +70 -5
- package/src/admin/admin-css.ts +57 -0
- package/src/admin/ai.tsx +915 -0
- package/src/admin/backups.tsx +613 -0
- package/src/admin/breadcrumbs.tsx +542 -0
- package/src/admin/codeBlocks.tsx +204 -0
- package/src/admin/customBlocks.tsx +321 -0
- package/src/admin/dirty.ts +20 -0
- package/src/admin/discovery.tsx +463 -0
- package/src/admin/download.ts +28 -0
- package/src/admin/feature-switches.tsx +124 -0
- package/src/admin/features.tsx +142 -0
- package/src/admin/files.tsx +791 -0
- package/src/admin/formUploads.tsx +445 -0
- package/src/admin/guides.tsx +18 -0
- package/src/admin/headings.tsx +262 -0
- package/src/admin/images.tsx +825 -0
- package/src/admin/index.tsx +62 -0
- package/src/admin/links.tsx +980 -0
- package/src/admin/performance.tsx +330 -0
- package/src/admin/preview.tsx +63 -0
- package/src/admin/redirects-removed.tsx +266 -0
- package/src/admin/redirects.tsx +470 -0
- package/src/admin/reviews.tsx +232 -0
- package/src/admin/robots-bots.tsx +647 -0
- package/src/admin/robots-shared.tsx +109 -0
- package/src/admin/robots-wizard.tsx +748 -0
- package/src/admin/robots.tsx +911 -0
- package/src/admin/save-bar.tsx +134 -0
- package/src/admin/schema.tsx +1187 -0
- package/src/admin/search.tsx +355 -0
- package/src/admin/settings-ui.tsx +87 -0
- package/src/admin/trash-prompt-core.ts +135 -0
- package/src/admin/trash-prompt.tsx +156 -0
- package/src/admin/usage.tsx +301 -0
- package/src/admin/videos-display.tsx +248 -0
- package/src/admin/videos.tsx +1202 -0
- package/src/admin/wpImport.tsx +1516 -0
- package/src/ai/entities.ts +59 -0
- package/src/ai/export.ts +61 -0
- package/src/ai/logic.test.ts +187 -0
- package/src/ai/logic.ts +606 -0
- package/src/ai/pack.ts +121 -0
- package/src/ai/prompts.ts +87 -0
- package/src/ai/providers.ts +276 -0
- package/src/ai/queue.test.ts +97 -0
- package/src/ai/routes.ts +359 -0
- package/src/ai/store.ts +301 -0
- package/src/ai/wikidata.test.ts +184 -0
- package/src/ai/wikidata.ts +87 -0
- package/src/ai/worker.ts +530 -0
- package/src/astro/astro-modules.d.ts +6 -0
- package/src/astro/breadcrumbs/Breadcrumbs.astro +129 -0
- package/src/astro/breadcrumbs/BreadcrumbsBlock.astro +12 -0
- package/src/astro/codeBlocks/CodeBlock.astro +62 -0
- package/src/astro/codeBlocks/EmDashCode.astro +61 -0
- package/src/astro/customBlocks/CustomBlock.astro +64 -0
- package/src/astro/customBlocks/Details.astro +8 -0
- package/src/astro/customBlocks/Disclosure.astro +8 -0
- package/src/astro/customBlocks/Note.astro +8 -0
- package/src/astro/customBlocks/Podcast.astro +8 -0
- package/src/astro/customBlocks/Quote.astro +8 -0
- package/src/astro/customBlocks/Testimonial.astro +8 -0
- package/src/astro/files/FileCard.astro +103 -0
- package/src/astro/headings/HeadingBlock.astro +129 -0
- package/src/astro/headings/TableOfContents.astro +45 -0
- package/src/astro/headings/request.ts +19 -0
- package/src/astro/images/ResponsiveImage.astro +60 -0
- package/src/astro/index.ts +84 -0
- package/src/astro/reviews/CoywolfReview.astro +27 -0
- package/src/astro/reviews/Review.astro +49 -0
- package/src/astro/search/SearchBox.astro +61 -0
- package/src/astro/search/SearchBoxForm.astro +509 -0
- package/src/astro/tables/CaptionedTable.astro +72 -0
- package/src/astro/videos/CoywolfVideo.astro +448 -0
- package/src/backups/download.ts +112 -0
- package/src/backups/dump.test.mjs +287 -0
- package/src/backups/dump.ts +288 -0
- package/src/backups/module.ts +276 -0
- package/src/backups/pack.ts +33 -0
- package/src/backups/restore.test.mjs +103 -0
- package/src/backups/restore.ts +209 -0
- package/src/backups/store.ts +409 -0
- package/src/breadcrumbs/pack.ts +113 -0
- package/src/breadcrumbs/settings.ts +102 -0
- package/src/breadcrumbs/stamp.ts +40 -0
- package/src/breadcrumbs/trail.ts +123 -0
- package/src/codeBlocks/contrast.ts +179 -0
- package/src/codeBlocks/hljs.d.ts +5 -0
- package/src/codeBlocks/pack.ts +110 -0
- package/src/codeBlocks/render.test.ts +152 -0
- package/src/codeBlocks/render.ts +304 -0
- package/src/codeBlocks/settings.ts +18 -0
- package/src/codeBlocks/themes.generated.ts +304 -0
- package/src/codeBlocks/themes.ts +114 -0
- package/src/core/compose.ts +244 -0
- package/src/core/content-url.ts +888 -0
- package/src/core/csv.ts +119 -0
- package/src/core/d1-batch.ts +65 -0
- package/src/core/entry-docs.ts +175 -0
- package/src/core/features-module.ts +48 -0
- package/src/core/features.ts +392 -0
- package/src/core/generation.ts +88 -0
- package/src/core/guide-content.ts +166 -0
- package/src/core/module.ts +104 -0
- package/src/core/read-failures.ts +57 -0
- package/src/core/request-memo.ts +71 -0
- package/src/core/secrets.ts +83 -0
- package/src/core/settings-enhance.ts +74 -0
- package/src/core/site.ts +36 -0
- package/src/core/storage.ts +27 -0
- package/src/customBlocks/pack.ts +260 -0
- package/src/customBlocks/render.ts +424 -0
- package/src/customBlocks/rich.ts +313 -0
- package/src/customBlocks/settings.ts +46 -0
- package/src/discovery/build.ts +252 -0
- package/src/discovery/feed-build.ts +217 -0
- package/src/discovery/feed.ts +172 -0
- package/src/discovery/indexnow.ts +114 -0
- package/src/discovery/llms.ts +79 -0
- package/src/discovery/markdown.ts +294 -0
- package/src/discovery/memory-cache.ts +97 -0
- package/src/discovery/middleware.ts +186 -0
- package/src/discovery/module.ts +353 -0
- package/src/discovery/news.ts +99 -0
- package/src/discovery/pack.ts +58 -0
- package/src/discovery/settings.ts +91 -0
- package/src/discovery/urls.ts +35 -0
- package/src/env.d.ts +10 -0
- package/src/files/card.ts +231 -0
- package/src/files/counts.test.ts +114 -0
- package/src/files/counts.ts +60 -0
- package/src/files/export.ts +44 -0
- package/src/files/format.test.ts +93 -0
- package/src/files/format.ts +163 -0
- package/src/files/module.ts +596 -0
- package/src/files/pack.ts +92 -0
- package/src/files/r2-rules.test.ts +30 -0
- package/src/files/r2-rules.ts +41 -0
- package/src/files/range.ts +50 -0
- package/src/files/s3.ts +114 -0
- package/src/files/serve.ts +141 -0
- package/src/files/sigv4.test.ts +64 -0
- package/src/files/sigv4.ts +160 -0
- package/src/files/site.ts +137 -0
- package/src/files/walker.ts +58 -0
- package/src/formUploads/lib.ts +266 -0
- package/src/formUploads/pack.ts +55 -0
- package/src/formUploads/wrap.ts +592 -0
- package/src/forms.ts +16 -0
- package/src/headings/headings.test.ts +159 -0
- package/src/headings/pack.ts +154 -0
- package/src/headings/render.ts +139 -0
- package/src/headings/settings.ts +117 -0
- package/src/headings/slug.ts +68 -0
- package/src/headings/stamp.ts +142 -0
- package/src/headings/test-hooks.mjs +14 -0
- package/src/headings/toc.ts +48 -0
- package/src/images/cloudflare.ts +329 -0
- package/src/images/lib.ts +221 -0
- package/src/images/module.ts +372 -0
- package/src/images/pack.ts +804 -0
- package/src/images/pending.ts +37 -0
- package/src/images/settings.ts +48 -0
- package/src/images/setup.ts +192 -0
- package/src/images/variants-estimate.ts +162 -0
- package/src/images/variants-store.ts +475 -0
- package/src/images/variants.ts +311 -0
- package/src/index.ts +143 -0
- package/src/links/check.ts +101 -0
- package/src/links/classify.ts +270 -0
- package/src/links/export.ts +59 -0
- package/src/links/module.ts +652 -0
- package/src/links/pack.ts +43 -0
- package/src/links/pt.ts +325 -0
- package/src/links/store.ts +704 -0
- package/src/middleware.ts +287 -0
- package/src/modules.ts +53 -0
- package/src/options.ts +66 -0
- package/src/pageCache/lib.test.mjs +224 -0
- package/src/pageCache/lib.ts +274 -0
- package/src/pageCache/pack.ts +240 -0
- package/src/pageCache/usage-core.ts +152 -0
- package/src/pageCache/usage.ts +262 -0
- package/src/pageCache/warm.test.mjs +670 -0
- package/src/pageCache/warm.ts +722 -0
- package/src/redirects/middleware.ts +169 -0
- package/src/redirects/module.ts +124 -0
- package/src/redirects/pack.ts +89 -0
- package/src/redirects/removed-core.ts +151 -0
- package/src/redirects/removed.ts +293 -0
- package/src/redirects/rules.ts +355 -0
- package/src/redirects/slug-changes.ts +207 -0
- package/src/redirects/slug-core.test.ts +50 -0
- package/src/redirects/slug-core.ts +49 -0
- package/src/redirects/transfer.ts +92 -0
- package/src/reviews/lib.ts +624 -0
- package/src/reviews/pack.ts +135 -0
- package/src/reviews/schema.ts +128 -0
- package/src/reviews/settings.ts +26 -0
- package/src/robots/bots.ts +321 -0
- package/src/robots/data/bots.json +696 -0
- package/src/robots/data/verified.json +582 -0
- package/src/robots/directory.ts +26 -0
- package/src/robots/explain.ts +191 -0
- package/src/robots/guided.ts +300 -0
- package/src/robots/importer.ts +263 -0
- package/src/robots/middleware.ts +151 -0
- package/src/robots/module.ts +446 -0
- package/src/robots/pack.ts +69 -0
- package/src/robots/radar.ts +129 -0
- package/src/robots/rep.test.ts +335 -0
- package/src/robots/rep.ts +362 -0
- package/src/robots/rules.test.ts +251 -0
- package/src/robots/rules.ts +883 -0
- package/src/robots/transfer.ts +45 -0
- package/src/robots/validate.ts +524 -0
- package/src/schema/catalog.ts +146 -0
- package/src/schema/graph.test.mjs +522 -0
- package/src/schema/graph.ts +791 -0
- package/src/schema/module.ts +1138 -0
- package/src/schema/og-image.ts +100 -0
- package/src/schema/pack.ts +58 -0
- package/src/search/engine.ts +578 -0
- package/src/search/fallback.ts +80 -0
- package/src/search/live-cache.ts +116 -0
- package/src/search/live-client.ts +770 -0
- package/src/search/live-serve.ts +282 -0
- package/src/search/module.ts +261 -0
- package/src/search/pack.ts +57 -0
- package/src/search/query.ts +132 -0
- package/src/search/ratelimit-core.ts +118 -0
- package/src/search/ratelimit.ts +79 -0
- package/src/search/snippet.ts +135 -0
- package/src/search/title-index.ts +139 -0
- package/src/search/title-match.ts +192 -0
- package/src/shared.ts +71 -0
- package/src/version.ts +2 -0
- package/src/videos/date.ts +40 -0
- package/src/videos/engagement.ts +75 -0
- package/src/videos/lib.ts +679 -0
- package/src/videos/module.ts +1160 -0
- package/src/videos/pack.ts +187 -0
- package/src/videos/pause.ts +42 -0
- package/src/videos/poster.ts +324 -0
- package/src/videos/render.ts +336 -0
- package/src/videos/store.ts +551 -0
- package/src/videos/stream.ts +134 -0
- package/src/wpImport/convert.ts +702 -0
- package/src/wpImport/guests.ts +329 -0
- package/src/wpImport/gutenberg.ts +157 -0
- package/src/wpImport/markers.ts +66 -0
- package/src/wpImport/pack.ts +451 -0
- package/src/wpImport/parents.ts +202 -0
- package/src/wpImport/prepare.ts +592 -0
- package/src/wpImport/redirects.ts +358 -0
- package/src/wpImport/sources.ts +67 -0
- package/src/wpImport/stream.ts +160 -0
- package/src/wpImport/urls.ts +354 -0
|
@@ -0,0 +1,670 @@
|
|
|
1
|
+
// Unit tests for cache warming and scoped purges.
|
|
2
|
+
// Run: node --test src/pageCache/warm.test.mjs (Node 22.6+ strips the types from the .ts files)
|
|
3
|
+
import assert from "node:assert/strict";
|
|
4
|
+
import { test } from "node:test";
|
|
5
|
+
|
|
6
|
+
const { sitemapLocs, warmOrder, collectUrls, homeLinks, newWarmState, claimWork, warmStep, writeWarmState, writeWarmQueue, readWarmState, startWarm, finishCollect, recordBatch, recordRevisit, remainingUrls, pendingRevisits, WARM_STATE_OPTION, WARM_QUEUE_PREFIX, QUEUE_CHUNK } =
|
|
7
|
+
await import("./warm.ts");
|
|
8
|
+
const { STOPGAP_HEADER, MAX_REVISIT, REVISIT_DELAY_MS, MAX_REVISIT_ROUNDS } = await import("./warm.ts");
|
|
9
|
+
const { scheduleRewarm, warmMayHaveWork, REWARM_DELAY_MS, REWARM_MAX_DELAY_MS, BATCH_STALE_MS, DAILY_REFRESH_MS, nextDailyAt } = await import("./warm.ts");
|
|
10
|
+
const { purgeScope } = await import("./lib.ts");
|
|
11
|
+
|
|
12
|
+
const API = "/_emdash/api/plugins/coywolf-pack/";
|
|
13
|
+
|
|
14
|
+
test("settings that don't change pages don't clear them; robots clears only robots.txt", () => {
|
|
15
|
+
assert.deepEqual(purgeScope(API + "redirects/import"), { tags: ["coywolf-redirects"] });
|
|
16
|
+
assert.equal(purgeScope(API + "backups/run"), null);
|
|
17
|
+
assert.equal(purgeScope(API + "links/recheck"), null);
|
|
18
|
+
assert.equal(purgeScope(API + "cache/warm/start"), null);
|
|
19
|
+
assert.equal(purgeScope(API + "cache/media/apply"), null);
|
|
20
|
+
assert.deepEqual(purgeScope(API + "robots/save"), { pathPrefixes: ["/robots.txt"] });
|
|
21
|
+
assert.deepEqual(purgeScope(API + "schema/site/save"), { purgeEverything: true });
|
|
22
|
+
assert.deepEqual(purgeScope(API + "cache/settings/save"), { purgeEverything: true });
|
|
23
|
+
assert.deepEqual(purgeScope(API + "features/save"), { purgeEverything: true });
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test("reads sitemaps and sitemap indexes", () => {
|
|
27
|
+
assert.deepEqual(sitemapLocs('<urlset><url><loc>https://x.com/a/?p=1&q=2</loc></url><url><loc> https://x.com/b/ </loc></url></urlset>'), {
|
|
28
|
+
isIndex: false,
|
|
29
|
+
locs: ["https://x.com/a/?p=1&q=2", "https://x.com/b/"],
|
|
30
|
+
});
|
|
31
|
+
assert.equal(sitemapLocs("<sitemapindex><sitemap><loc>https://x.com/s1.xml</loc></sitemap></sitemapindex>").isIndex, true);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("warm order: home first, same origin only, no duplicates, capped", () => {
|
|
35
|
+
const order = warmOrder("https://x.com", ["https://x.com/new/", "https://other.com/x/", "https://x.com/", "https://x.com/new/#top", "/old/"], 3);
|
|
36
|
+
assert.deepEqual(order, ["https://x.com/", "https://x.com/new/", "https://x.com/old/"]);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
function fakeSite(pages) {
|
|
40
|
+
const hits = [];
|
|
41
|
+
return {
|
|
42
|
+
hits,
|
|
43
|
+
async fetch(req) {
|
|
44
|
+
hits.push(req.url);
|
|
45
|
+
const body = pages[req.url];
|
|
46
|
+
return body === undefined ? new Response("nope", { status: 404 }) : new Response(body, { status: 200 });
|
|
47
|
+
},
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
test("collects URLs through a sitemap index", async () => {
|
|
52
|
+
const site = fakeSite({
|
|
53
|
+
"https://x.com/sitemap.xml": "<sitemapindex><sitemap><loc>https://x.com/sitemap-posts-1.xml</loc></sitemap><sitemap><loc>https://evil.com/s.xml</loc></sitemap></sitemapindex>",
|
|
54
|
+
"https://x.com/sitemap-posts-1.xml": "<urlset><url><loc>https://x.com/p2/</loc></url><url><loc>https://x.com/p1/</loc></url></urlset>",
|
|
55
|
+
});
|
|
56
|
+
assert.deepEqual(await collectUrls(site, "https://x.com"), ["https://x.com/", "https://x.com/p2/", "https://x.com/p1/"]);
|
|
57
|
+
assert.ok(!site.hits.some((u) => u.includes("evil.com")), "other hosts are never fetched");
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("section pages linked from the home page come right after it", async () => {
|
|
61
|
+
const site = fakeSite({
|
|
62
|
+
"https://x.com/": `<nav><a href="/notes/">Notes</a> <a class="x" href='https://x.com/news/#top'>News</a> <a href="https://other.com/a/">x</a>
|
|
63
|
+
<a href="/p1/">p1</a> <a href="/_emdash/admin">admin</a> <a href="/feed/">rss</a> <a href="/file.pdf">pdf</a> <a href="/search/?q=a">s</a> <a href="#main">skip</a></nav>`,
|
|
64
|
+
"https://x.com/sitemap.xml": "<urlset><url><loc>https://x.com/p2/</loc></url><url><loc>https://x.com/p1/</loc></url></urlset>",
|
|
65
|
+
});
|
|
66
|
+
assert.deepEqual(await collectUrls(site, "https://x.com"), ["https://x.com/", "https://x.com/notes/", "https://x.com/news/", "https://x.com/p1/", "https://x.com/p2/"]);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("home links are capped", () => {
|
|
70
|
+
const html = Array.from({ length: 300 }, (_, i) => `<a href="/c${i}/">c</a>`).join("");
|
|
71
|
+
assert.equal(homeLinks(html, "https://x.com").length, 200);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
/** The slice of D1 the warm queue uses: one options table. Counts writes to the progress row and their size. */
|
|
75
|
+
function fakeDb() {
|
|
76
|
+
const rows = new Map();
|
|
77
|
+
const db = {
|
|
78
|
+
rows,
|
|
79
|
+
stateWrites: [],
|
|
80
|
+
prepare(sql) {
|
|
81
|
+
let args = [];
|
|
82
|
+
// D1 refuses LIKE/GLOB patterns over 50 bytes (SQLITE_MAX_LIKE_PATTERN_LENGTH); queue row names are longer.
|
|
83
|
+
const checkLike = () => {
|
|
84
|
+
if (/\bLIKE\b/.test(sql) && args.some((a) => typeof a === "string" && Buffer.byteLength(a) > 50)) {
|
|
85
|
+
throw new Error("LIKE or GLOB pattern too complex: SQLITE_ERROR [code: 7500]");
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
const stmt = {
|
|
89
|
+
bind(...a) {
|
|
90
|
+
args = a;
|
|
91
|
+
return stmt;
|
|
92
|
+
},
|
|
93
|
+
async first() {
|
|
94
|
+
return rows.has(args[0]) ? { value: rows.get(args[0]) } : null;
|
|
95
|
+
},
|
|
96
|
+
async all() {
|
|
97
|
+
// SELECT name, value FROM options WHERE name IN (…)
|
|
98
|
+
return { results: args.filter((n) => rows.has(n)).map((name) => ({ name, value: rows.get(name) })) };
|
|
99
|
+
},
|
|
100
|
+
async run() {
|
|
101
|
+
checkLike();
|
|
102
|
+
if (sql.startsWith("INSERT")) {
|
|
103
|
+
if (sql.includes("DO NOTHING") && rows.has(args[0])) return { meta: { changes: 0 } };
|
|
104
|
+
rows.set(args[0], args[1]);
|
|
105
|
+
if (args[0] === WARM_STATE_OPTION) db.stateWrites.push(args[1].length);
|
|
106
|
+
return { meta: { changes: 1 } };
|
|
107
|
+
}
|
|
108
|
+
if (sql.startsWith("DELETE")) {
|
|
109
|
+
// name >= ? AND name < ? [AND NOT (name >= ? AND name < ?)]
|
|
110
|
+
assert.ok(!sql.includes("LIKE"), "queue rows are deleted by name range");
|
|
111
|
+
const [from, to, keepFrom, keepTo] = args;
|
|
112
|
+
let changes = 0;
|
|
113
|
+
for (const name of [...rows.keys()]) {
|
|
114
|
+
if (name >= from && name < to && !(keepFrom !== undefined && name >= keepFrom && name < keepTo)) {
|
|
115
|
+
rows.delete(name);
|
|
116
|
+
changes++;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return { meta: { changes } };
|
|
120
|
+
}
|
|
121
|
+
// UPDATE options SET value = ? WHERE name = ? AND value = ?
|
|
122
|
+
const [value, name, before] = args;
|
|
123
|
+
if (rows.get(name) !== before) return { meta: { changes: 0 } };
|
|
124
|
+
rows.set(name, value);
|
|
125
|
+
db.stateWrites.push(value.length);
|
|
126
|
+
return { meta: { changes: 1 } };
|
|
127
|
+
},
|
|
128
|
+
};
|
|
129
|
+
return stmt;
|
|
130
|
+
},
|
|
131
|
+
async batch(statements) {
|
|
132
|
+
return Promise.all(statements.map((s) => s.run()));
|
|
133
|
+
},
|
|
134
|
+
};
|
|
135
|
+
return db;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const queueRows = (db) => [...db.rows.keys()].filter((name) => name.startsWith(WARM_QUEUE_PREFIX));
|
|
139
|
+
|
|
140
|
+
/** A run in its warm phase with these URLs queued. */
|
|
141
|
+
async function warmRun(db, urls) {
|
|
142
|
+
const state = { ...newWarmState("x"), phase: "warm", next: 0, total: urls.length };
|
|
143
|
+
await writeWarmState(db, state);
|
|
144
|
+
await writeWarmQueue(db, state.generation, urls);
|
|
145
|
+
return state;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const site = (paths) =>
|
|
149
|
+
fakeSite({
|
|
150
|
+
"https://x.com/sitemap.xml": `<urlset>${paths.map((p) => `<url><loc>https://x.com${p}</loc></url>`).join("")}</urlset>`,
|
|
151
|
+
"https://x.com/": "home",
|
|
152
|
+
...Object.fromEntries(paths.map((p) => [`https://x.com${p}`, p])),
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test("a warm step reads the sitemap, then warms every page and finishes", async () => {
|
|
156
|
+
const db = fakeDb();
|
|
157
|
+
const s = site(["/a/", "/b/", "/c/", "/gone/"]);
|
|
158
|
+
await startWarm(db, "deploy");
|
|
159
|
+
await warmStep(db, { fetch: (req) => (req.url.endsWith("/gone/") ? Promise.resolve(new Response("", { status: 404 })) : s.fetch(req)) }, "https://x.com", { budgetMs: 10_000, batchSize: 2 });
|
|
160
|
+
const state = await readWarmState(db);
|
|
161
|
+
assert.equal(state.phase, "done");
|
|
162
|
+
assert.equal(state.total, 5, "home page plus the sitemap's four");
|
|
163
|
+
assert.equal(state.warmed, 4);
|
|
164
|
+
assert.equal(state.failed, 1);
|
|
165
|
+
assert.deepEqual(queueRows(db), [], "a finished run's URL rows are removed");
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test("claims only rewrite the small progress row, however long the queue", async () => {
|
|
169
|
+
const db = fakeDb();
|
|
170
|
+
const urls = Array.from({ length: 5000 }, (_, i) => `https://x.com/a-fairly-long-post-slug-number-${i}/`);
|
|
171
|
+
const state = await warmRun(db, urls);
|
|
172
|
+
assert.equal(queueRows(db).length, Math.ceil(5000 / QUEUE_CHUNK));
|
|
173
|
+
db.stateWrites.length = 0;
|
|
174
|
+
const claimed = [];
|
|
175
|
+
for (let i = 0; i < 300; i++) {
|
|
176
|
+
const claim = await claimWork(db, 4);
|
|
177
|
+
claimed.push(...claim.urls);
|
|
178
|
+
await recordBatch(db, state.generation, claim.urls.length, 0);
|
|
179
|
+
}
|
|
180
|
+
assert.deepEqual(claimed, urls.slice(0, 1200), "batches follow the queue order, across chunk rows");
|
|
181
|
+
assert.ok(Math.max(...db.stateWrites) < 1000, `progress writes stay small (largest ${Math.max(...db.stateWrites)} bytes)`);
|
|
182
|
+
const progress = await readWarmState(db);
|
|
183
|
+
assert.equal(progress.next, 1200);
|
|
184
|
+
assert.equal(remainingUrls(progress), 3800);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
test("a batch spanning two queue rows gets URLs from both", async () => {
|
|
188
|
+
const db = fakeDb();
|
|
189
|
+
const urls = Array.from({ length: QUEUE_CHUNK + 3 }, (_, i) => `https://x.com/${i}/`);
|
|
190
|
+
const state = await warmRun(db, urls);
|
|
191
|
+
await writeWarmState(db, { ...state, next: QUEUE_CHUNK - 2 });
|
|
192
|
+
const claim = await claimWork(db, 4);
|
|
193
|
+
assert.deepEqual(claim.urls, urls.slice(QUEUE_CHUNK - 2, QUEUE_CHUNK + 2));
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
test("a new run clears older runs' URL rows; a superseded collect doesn't leave its URLs", async () => {
|
|
197
|
+
const db = fakeDb();
|
|
198
|
+
const old = await warmRun(db, ["https://x.com/1/", "https://x.com/2/"]);
|
|
199
|
+
assert.equal(queueRows(db).length, 1);
|
|
200
|
+
const fresh = await startWarm(db, "deploy");
|
|
201
|
+
assert.deepEqual(queueRows(db), []);
|
|
202
|
+
// The old run's collect finishing late (after the restart) stores nothing.
|
|
203
|
+
await finishCollect(db, old.generation, ["https://x.com/1/"], "https://x.com");
|
|
204
|
+
assert.deepEqual(queueRows(db), []);
|
|
205
|
+
assert.equal((await readWarmState(db)).generation, fresh.generation);
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
test("progress rows from before the queue rows are reported, then moved to URL rows and worked on", async () => {
|
|
209
|
+
const db = fakeDb();
|
|
210
|
+
const legacy = { ...newWarmState("x"), next: undefined, phase: "warm", queue: ["https://x.com/3/"], total: 3, warmed: 2 };
|
|
211
|
+
db.rows.set(WARM_STATE_OPTION, JSON.stringify(legacy));
|
|
212
|
+
const state = await readWarmState(db);
|
|
213
|
+
assert.equal(state.legacy, true);
|
|
214
|
+
assert.equal(remainingUrls(state), 1);
|
|
215
|
+
assert.equal(state.queue, undefined);
|
|
216
|
+
const claim = await claimWork(db, 4);
|
|
217
|
+
assert.equal(claim.kind, "warm");
|
|
218
|
+
assert.deepEqual(claim.urls, ["https://x.com/3/"]);
|
|
219
|
+
assert.notEqual(claim.state.generation, legacy.generation, "a new generation, so a stale cleanup of the old one can't remove its rows");
|
|
220
|
+
const migrated = JSON.parse(db.rows.get(WARM_STATE_OPTION));
|
|
221
|
+
assert.equal(migrated.queue, undefined);
|
|
222
|
+
assert.equal(migrated.total, 1);
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
test("deploy race: an old isolate finishing a new run's collect (queue + next, no URL rows) doesn't fail every page", async () => {
|
|
226
|
+
const db = fakeDb();
|
|
227
|
+
const s = site(["/a/", "/b/", "/c/"]);
|
|
228
|
+
const fresh = await startWarm(db, "deploy");
|
|
229
|
+
// What the older version's finishCollect writes: the new state spread, plus `queue`.
|
|
230
|
+
const urls = ["https://x.com/", "https://x.com/a/", "https://x.com/b/", "https://x.com/c/"];
|
|
231
|
+
db.rows.set(WARM_STATE_OPTION, JSON.stringify({ ...fresh, phase: "warm", queue: urls, total: urls.length }));
|
|
232
|
+
assert.deepEqual(queueRows(db), []);
|
|
233
|
+
await warmStep(db, s, "https://x.com", { budgetMs: 10_000, batchSize: 2 });
|
|
234
|
+
const state = await readWarmState(db);
|
|
235
|
+
assert.equal(state.phase, "done");
|
|
236
|
+
assert.equal(state.warmed, 4);
|
|
237
|
+
assert.equal(state.failed, 0);
|
|
238
|
+
assert.equal(state.legacy, undefined);
|
|
239
|
+
assert.deepEqual(queueRows(db), []);
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
test("an older-format collect step (queue: [], no next) is still collected", async () => {
|
|
243
|
+
const db = fakeDb();
|
|
244
|
+
const { next: _next, ...rest } = newWarmState("deploy");
|
|
245
|
+
db.rows.set(WARM_STATE_OPTION, JSON.stringify({ ...rest, queue: [] }));
|
|
246
|
+
const claim = await claimWork(db, 4);
|
|
247
|
+
assert.equal(claim.kind, "collect");
|
|
248
|
+
assert.equal(claim.state.next, 0);
|
|
249
|
+
assert.equal(JSON.parse(db.rows.get(WARM_STATE_OPTION)).queue, undefined);
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
test("an older-format finished run is only reported", async () => {
|
|
253
|
+
const db = fakeDb();
|
|
254
|
+
db.rows.set(WARM_STATE_OPTION, JSON.stringify({ ...newWarmState("x"), next: undefined, phase: "done", queue: [], total: 2, warmed: 2 }));
|
|
255
|
+
const before = db.rows.get(WARM_STATE_OPTION);
|
|
256
|
+
assert.equal(await claimWork(db, 4), null);
|
|
257
|
+
assert.equal(db.rows.get(WARM_STATE_OPTION), before);
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
test("parallel claims never take the same pages", async () => {
|
|
261
|
+
const db = fakeDb();
|
|
262
|
+
await warmRun(db, ["https://x.com/1/", "https://x.com/2/", "https://x.com/3/", "https://x.com/4/"]);
|
|
263
|
+
const [a, b] = await Promise.all([claimWork(db, 2), claimWork(db, 2)]);
|
|
264
|
+
const taken = [a, b].filter(Boolean).flatMap((c) => c.urls);
|
|
265
|
+
assert.equal(new Set(taken).size, taken.length, "no URL claimed twice");
|
|
266
|
+
const c = await claimWork(db, 2);
|
|
267
|
+
const all = [...taken, ...(c?.urls ?? [])];
|
|
268
|
+
assert.ok(all.length <= 4);
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
test("a restarted run makes old batches stop counting", async () => {
|
|
272
|
+
const db = fakeDb();
|
|
273
|
+
await warmRun(db, ["https://x.com/1/"]);
|
|
274
|
+
const claim = await claimWork(db, 5);
|
|
275
|
+
await startWarm(db, "settings");
|
|
276
|
+
await recordBatch(db, claim.state.generation, 1, 0);
|
|
277
|
+
const state = await readWarmState(db);
|
|
278
|
+
assert.equal(state.phase, "collect");
|
|
279
|
+
assert.equal(state.warmed, 0, "the old run's batch isn't added to the new one");
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
test("nothing to do when the collect step is already taken", async () => {
|
|
283
|
+
const db = fakeDb();
|
|
284
|
+
await startWarm(db, "deploy");
|
|
285
|
+
assert.equal((await claimWork(db, 4)).kind, "collect");
|
|
286
|
+
assert.equal(await claimWork(db, 4), null, "another isolate is reading the sitemap");
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
// ── Revisits: pages first rendered with a stopgap poster ──
|
|
290
|
+
|
|
291
|
+
/** A site whose pages in `stopgapFor` come back marked (STOPGAP_HEADER) for their first n visits (Infinity: always). */
|
|
292
|
+
function stopgapSite(paths, stopgapFor) {
|
|
293
|
+
const hits = [];
|
|
294
|
+
return {
|
|
295
|
+
hits,
|
|
296
|
+
async fetch(req) {
|
|
297
|
+
hits.push(req.url);
|
|
298
|
+
const path = new URL(req.url).pathname;
|
|
299
|
+
if (path === "/sitemap.xml") return new Response(`<urlset>${paths.map((p) => `<url><loc>https://x.com${p}</loc></url>`).join("")}</urlset>`);
|
|
300
|
+
const seen = hits.filter((u) => u === req.url).length;
|
|
301
|
+
const marked = seen <= (stopgapFor[path] ?? 0);
|
|
302
|
+
return new Response(path, { status: 200, headers: marked ? { [STOPGAP_HEADER]: "1" } : {} });
|
|
303
|
+
},
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
const clock = (start = 1_000_000) => {
|
|
308
|
+
const c = { t: start, now: () => c.t };
|
|
309
|
+
return c;
|
|
310
|
+
};
|
|
311
|
+
|
|
312
|
+
test("a page warmed with a stopgap is visited again once its posters have had time to copy, then the run finishes", async () => {
|
|
313
|
+
const db = fakeDb();
|
|
314
|
+
const c = clock();
|
|
315
|
+
const s = stopgapSite(["/a/", "/v/"], { "/v/": 1 });
|
|
316
|
+
await startWarm(db, "deploy");
|
|
317
|
+
const opts = { budgetMs: 10_000, batchSize: 2, now: c.now };
|
|
318
|
+
// The step's clock doesn't move, so it ends on the first "wait" rather than its budget.
|
|
319
|
+
assert.equal(await warmStep(db, s, "https://x.com", opts), "warmed");
|
|
320
|
+
let state = await readWarmState(db);
|
|
321
|
+
assert.equal(state.phase, "warm", "not done while a revisit waits");
|
|
322
|
+
assert.equal(state.warmed, 3);
|
|
323
|
+
assert.deepEqual(state.revisit, [["https://x.com/v/", 0]]);
|
|
324
|
+
assert.equal(pendingRevisits(state), 1);
|
|
325
|
+
assert.equal(remainingUrls(state), 0);
|
|
326
|
+
|
|
327
|
+
c.t += REVISIT_DELAY_MS - 1;
|
|
328
|
+
assert.equal(await warmStep(db, s, "https://x.com", opts), "waiting", "too early: nothing visited");
|
|
329
|
+
assert.equal(s.hits.filter((u) => u === "https://x.com/v/").length, 1);
|
|
330
|
+
|
|
331
|
+
c.t += 1;
|
|
332
|
+
await warmStep(db, s, "https://x.com", opts);
|
|
333
|
+
state = await readWarmState(db);
|
|
334
|
+
assert.equal(state.phase, "done");
|
|
335
|
+
assert.equal(state.rewarmed, 1);
|
|
336
|
+
assert.equal(state.revisitGaveUp, undefined);
|
|
337
|
+
assert.equal(state.revisit, undefined);
|
|
338
|
+
assert.equal(state.warmed, 3, "revisits don't count twice");
|
|
339
|
+
assert.equal(s.hits.filter((u) => u === "https://x.com/v/").length, 2);
|
|
340
|
+
assert.deepEqual(queueRows(db), []);
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
test("a page still on a stopgap after its last revisit is given up on without failing the run", async () => {
|
|
344
|
+
const db = fakeDb();
|
|
345
|
+
const c = clock();
|
|
346
|
+
const s = stopgapSite(["/v/"], { "/v/": Infinity });
|
|
347
|
+
await startWarm(db, "deploy");
|
|
348
|
+
const opts = { budgetMs: 10_000, batchSize: 4, now: c.now };
|
|
349
|
+
for (let i = 0; i < 6; i++) {
|
|
350
|
+
await warmStep(db, s, "https://x.com", opts);
|
|
351
|
+
c.t += REVISIT_DELAY_MS;
|
|
352
|
+
}
|
|
353
|
+
const state = await readWarmState(db);
|
|
354
|
+
assert.equal(state.phase, "done");
|
|
355
|
+
assert.equal(state.failed, 0);
|
|
356
|
+
assert.equal(state.revisitGaveUp, 1);
|
|
357
|
+
assert.equal(state.rewarmed, undefined);
|
|
358
|
+
assert.equal(s.hits.filter((u) => u === "https://x.com/v/").length, 1 + MAX_REVISIT_ROUNDS);
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
test("revisits are deduplicated, capped and same-origin, and keep the progress row small", async () => {
|
|
362
|
+
const db = fakeDb();
|
|
363
|
+
const state = await warmRun(db, ["https://x.com/1/"]);
|
|
364
|
+
db.stateWrites.length = 0;
|
|
365
|
+
const many = Array.from({ length: MAX_REVISIT + 20 }, (_, i) => `https://x.com/videos/a-fairly-long-video-post-slug-${i}/`);
|
|
366
|
+
await recordBatch(db, state.generation, 0, 0, 1000, many.slice(0, 50));
|
|
367
|
+
await recordBatch(db, state.generation, 0, 0, 1000, many);
|
|
368
|
+
const after = await readWarmState(db);
|
|
369
|
+
assert.equal(after.revisit.length, MAX_REVISIT);
|
|
370
|
+
assert.equal(new Set(after.revisit.map(([u]) => u)).size, MAX_REVISIT, "no duplicates");
|
|
371
|
+
assert.equal(after.revisitGaveUp, 20, "pages past the cap are counted, not kept");
|
|
372
|
+
assert.ok(Math.max(...db.stateWrites) < 20_000, `progress row stays small (largest ${Math.max(...db.stateWrites)} bytes)`);
|
|
373
|
+
|
|
374
|
+
// warmStep only lists same-origin pages.
|
|
375
|
+
const db2 = fakeDb();
|
|
376
|
+
const s2 = { async fetch(req) { return new Response("x", { headers: { [STOPGAP_HEADER]: "1" } }); } };
|
|
377
|
+
await warmRun(db2, ["https://other.com/v/", "https://x.com/v/"]);
|
|
378
|
+
await warmStep(db2, s2, "https://x.com", { budgetMs: 10_000, batchSize: 4, now: () => 0 });
|
|
379
|
+
assert.deepEqual((await readWarmState(db2)).revisit, [["https://x.com/v/", 0]]);
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
test("revisits belong to their run: a new run drops them and an old run's revisit batch doesn't count", async () => {
|
|
383
|
+
const db = fakeDb();
|
|
384
|
+
const state = await warmRun(db, ["https://x.com/v/"]);
|
|
385
|
+
await claimWork(db, 4, 0);
|
|
386
|
+
await recordBatch(db, state.generation, 1, 0, 0, ["https://x.com/v/"]);
|
|
387
|
+
const claim = await claimWork(db, 4, REVISIT_DELAY_MS);
|
|
388
|
+
assert.equal(claim.kind, "revisit");
|
|
389
|
+
assert.deepEqual(claim.pages, [["https://x.com/v/", 0]]);
|
|
390
|
+
const fresh = await startWarm(db, "settings");
|
|
391
|
+
await recordRevisit(db, state.generation, [{ url: "https://x.com/v/", ok: true, stopgap: true, round: 0 }], REVISIT_DELAY_MS);
|
|
392
|
+
const now = await readWarmState(db);
|
|
393
|
+
assert.equal(now.generation, fresh.generation);
|
|
394
|
+
assert.equal(now.revisit, undefined);
|
|
395
|
+
assert.equal(now.revisitBusy, undefined);
|
|
396
|
+
assert.equal(pendingRevisits(now), 0);
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
test("parallel revisit claims never take the same page; parallel batches all list their pages", async () => {
|
|
400
|
+
const db = fakeDb();
|
|
401
|
+
const urls = ["https://x.com/1/", "https://x.com/2/", "https://x.com/3/", "https://x.com/4/"];
|
|
402
|
+
const state = await warmRun(db, urls);
|
|
403
|
+
await claimWork(db, 4, 0);
|
|
404
|
+
await Promise.all(urls.map((u) => recordBatch(db, state.generation, 1, 0, 0, [u])));
|
|
405
|
+
let progress = await readWarmState(db);
|
|
406
|
+
assert.equal(progress.warmed, 4);
|
|
407
|
+
assert.deepEqual(progress.revisit.map(([u]) => u).sort(), urls);
|
|
408
|
+
const claims = await Promise.all([claimWork(db, 2, REVISIT_DELAY_MS), claimWork(db, 2, REVISIT_DELAY_MS), claimWork(db, 2, REVISIT_DELAY_MS)]);
|
|
409
|
+
const taken = claims.filter(Boolean).flatMap((c) => c.pages.map(([u]) => u));
|
|
410
|
+
assert.equal(new Set(taken).size, taken.length, "no page claimed twice");
|
|
411
|
+
progress = await readWarmState(db);
|
|
412
|
+
assert.equal(progress.revisitBusy, taken.length);
|
|
413
|
+
assert.equal((progress.revisit?.length ?? 0) + taken.length, 4);
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
test("revisits claimed by an isolate that stopped don't keep the run open forever", async () => {
|
|
417
|
+
const db = fakeDb();
|
|
418
|
+
const state = await warmRun(db, ["https://x.com/v/"]);
|
|
419
|
+
await claimWork(db, 4, 0);
|
|
420
|
+
await recordBatch(db, state.generation, 1, 0, 0, ["https://x.com/v/"]);
|
|
421
|
+
assert.equal((await claimWork(db, 4, REVISIT_DELAY_MS)).kind, "revisit");
|
|
422
|
+
// …and that batch is never recorded.
|
|
423
|
+
assert.equal(await claimWork(db, 4, REVISIT_DELAY_MS + 1000), null);
|
|
424
|
+
assert.equal((await readWarmState(db)).phase, "warm");
|
|
425
|
+
assert.equal(await claimWork(db, 4, REVISIT_DELAY_MS + 3 * 60_000), null);
|
|
426
|
+
const done = await readWarmState(db);
|
|
427
|
+
assert.equal(done.phase, "done");
|
|
428
|
+
assert.equal(done.revisitGaveUp, 1);
|
|
429
|
+
assert.deepEqual(queueRows(db), []);
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
test("queue rows of every older run are cleared, even ones a failed cleanup left behind; a newer run's stay", async () => {
|
|
433
|
+
const db = fakeDb();
|
|
434
|
+
// Thirteen leftover runs (the LIKE-based cleanup never deleted anything on D1), then the current one.
|
|
435
|
+
for (let i = 0; i < 13; i++) await writeWarmQueue(db, `17912${String(i).padStart(8, "0")}-old${i}`, ["https://x.com/"]);
|
|
436
|
+
db.rows.set("plugin:coywolf-pack:pageCache:warmQueueish", "not ours");
|
|
437
|
+
db.rows.set("plugin:coywolf-pack:pageCache:warmState2", "not ours");
|
|
438
|
+
const fresh = await startWarm(db, "deploy");
|
|
439
|
+
assert.deepEqual(queueRows(db).filter((n) => !n.endsWith("ish")), [], "starting a run clears every other run's rows");
|
|
440
|
+
assert.ok(db.rows.has("plugin:coywolf-pack:pageCache:warmQueueish"), "rows outside the queue prefix stay");
|
|
441
|
+
|
|
442
|
+
// Finishing a run clears its rows and older ones, never a newer run's.
|
|
443
|
+
const s = site(["/a/"]);
|
|
444
|
+
await warmStep(db, s, "https://x.com", { budgetMs: 10_000, batchSize: 1, now: () => Date.now() });
|
|
445
|
+
assert.equal((await readWarmState(db)).generation, fresh.generation);
|
|
446
|
+
const newer = `${Date.now() + 60_000}-newer`;
|
|
447
|
+
const older = `${Date.now() - 60_000}-older`;
|
|
448
|
+
const current = await warmRun(db, ["https://x.com/a/"]);
|
|
449
|
+
await writeWarmQueue(db, newer, ["https://x.com/n/"]);
|
|
450
|
+
await writeWarmQueue(db, older, ["https://x.com/o/"]);
|
|
451
|
+
const claim = await claimWork(db, 4);
|
|
452
|
+
await recordBatch(db, current.generation, claim.urls.length, 0);
|
|
453
|
+
assert.equal((await readWarmState(db)).phase, "done");
|
|
454
|
+
assert.deepEqual(queueRows(db).filter((n) => !n.endsWith("ish")), [`${WARM_QUEUE_PREFIX}${newer}:0`]);
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
test("content edits schedule one run a minute after the last edit (debounced, capped)", async () => {
|
|
458
|
+
const db = fakeDb();
|
|
459
|
+
const t0 = Date.parse("2026-10-06T12:00:00Z");
|
|
460
|
+
const urls = ["https://x.com/a/"];
|
|
461
|
+
const done = { ...(await warmRun(db, urls)), phase: "done", next: 1, warmed: 1, finishedAt: new Date(t0 - 1000).toISOString() };
|
|
462
|
+
await writeWarmState(db, done);
|
|
463
|
+
const raw0 = JSON.stringify(done);
|
|
464
|
+
assert.equal(warmMayHaveWork(raw0, t0), false, "a finished run has nothing to do");
|
|
465
|
+
|
|
466
|
+
await scheduleRewarm(db, t0);
|
|
467
|
+
await scheduleRewarm(db, t0 + 30_000); // an autosave or another edit pushes it back
|
|
468
|
+
let state = await readWarmState(db);
|
|
469
|
+
assert.equal(state.rewarmAfter, new Date(t0 + 30_000 + REWARM_DELAY_MS).toISOString());
|
|
470
|
+
assert.equal(state.rewarmBy, new Date(t0 + REWARM_MAX_DELAY_MS).toISOString());
|
|
471
|
+
assert.equal(state.phase, "done", "the finished run is still reported meanwhile");
|
|
472
|
+
const raw = db.rows.get(WARM_STATE_OPTION);
|
|
473
|
+
assert.equal(warmMayHaveWork(raw, t0 + 60_000), false, "not due yet: requests skip the warm step");
|
|
474
|
+
assert.equal(warmMayHaveWork(raw, t0 + 90_000), true);
|
|
475
|
+
assert.equal(await claimWork(db, 4, t0 + 60_000), null, "nothing before it's due");
|
|
476
|
+
|
|
477
|
+
const claim = await claimWork(db, 4, t0 + 90_000);
|
|
478
|
+
assert.equal(claim.kind, "collect");
|
|
479
|
+
assert.equal(claim.state.reason, "edit");
|
|
480
|
+
assert.notEqual(claim.state.generation, done.generation);
|
|
481
|
+
assert.equal(claim.state.rewarmAfter, undefined, "the new run clears the schedule");
|
|
482
|
+
assert.deepEqual(queueRows(db), [], "the old run's rows go");
|
|
483
|
+
assert.equal(await claimWork(db, 4, t0 + 91_000), null, "the collect step is taken once");
|
|
484
|
+
});
|
|
485
|
+
|
|
486
|
+
test("edits that keep coming still get a run within the cap", async () => {
|
|
487
|
+
const db = fakeDb();
|
|
488
|
+
const t0 = Date.parse("2026-10-06T12:00:00Z");
|
|
489
|
+
for (let t = t0; t <= t0 + 10 * 60_000; t += 20_000) {
|
|
490
|
+
await scheduleRewarm(db, t);
|
|
491
|
+
assert.ok(Date.parse((await readWarmState(db)).rewarmAfter) <= t0 + REWARM_MAX_DELAY_MS);
|
|
492
|
+
// Steps run with the traffic; once due, the run starts and the next edit schedules afresh.
|
|
493
|
+
const claim = await claimWork(db, 4, t);
|
|
494
|
+
if (claim?.kind === "collect") {
|
|
495
|
+
assert.equal(t, t0 + REWARM_MAX_DELAY_MS, "the first run starts at the cap");
|
|
496
|
+
await finishCollect(db, claim.state.generation, [], "https://x.com");
|
|
497
|
+
return;
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
assert.fail("no run started");
|
|
501
|
+
});
|
|
502
|
+
|
|
503
|
+
test("an edit with no run yet stores a finished, empty row carrying the schedule", async () => {
|
|
504
|
+
const db = fakeDb();
|
|
505
|
+
const t0 = Date.now();
|
|
506
|
+
const state = await scheduleRewarm(db, t0);
|
|
507
|
+
assert.equal(state.phase, "done");
|
|
508
|
+
assert.equal(state.total, 0);
|
|
509
|
+
assert.equal(warmMayHaveWork(db.rows.get(WARM_STATE_OPTION), t0), false);
|
|
510
|
+
assert.equal((await claimWork(db, 4, t0 + REWARM_DELAY_MS)).kind, "collect");
|
|
511
|
+
});
|
|
512
|
+
|
|
513
|
+
test("an edit during a run lets it go on, then replaces it when due; the old run's batches stop counting", async () => {
|
|
514
|
+
const db = fakeDb();
|
|
515
|
+
const t0 = Date.now();
|
|
516
|
+
const urls = Array.from({ length: 10 }, (_, i) => `https://x.com/${i}/`);
|
|
517
|
+
const run = await warmRun(db, urls);
|
|
518
|
+
await scheduleRewarm(db, t0);
|
|
519
|
+
const first = await claimWork(db, 4, t0 + 1000);
|
|
520
|
+
assert.equal(first.kind, "warm", "the run goes on while the rewarm waits");
|
|
521
|
+
assert.equal(first.state.generation, run.generation);
|
|
522
|
+
assert.equal(warmMayHaveWork(db.rows.get(WARM_STATE_OPTION), t0 + 1000), true);
|
|
523
|
+
const replaced = await claimWork(db, 4, t0 + REWARM_DELAY_MS);
|
|
524
|
+
assert.equal(replaced.kind, "collect");
|
|
525
|
+
await recordBatch(db, run.generation, 4, 0, t0 + REWARM_DELAY_MS);
|
|
526
|
+
const state = await readWarmState(db);
|
|
527
|
+
assert.equal(state.generation, replaced.state.generation);
|
|
528
|
+
assert.equal(state.warmed, 0);
|
|
529
|
+
assert.deepEqual(queueRows(db), []);
|
|
530
|
+
});
|
|
531
|
+
|
|
532
|
+
test("a batch claimed by an isolate that stopped doesn't keep the run open forever", async () => {
|
|
533
|
+
const db = fakeDb();
|
|
534
|
+
const t0 = Date.now();
|
|
535
|
+
const urls = Array.from({ length: 6 }, (_, i) => `https://x.com/${i}/`);
|
|
536
|
+
const run = await warmRun(db, urls);
|
|
537
|
+
const a = await claimWork(db, 4, t0);
|
|
538
|
+
const b = await claimWork(db, 4, t0 + 1000); // never recorded
|
|
539
|
+
assert.equal(b.urls.length, 2);
|
|
540
|
+
await recordBatch(db, run.generation, a.urls.length, 0, t0 + 2000);
|
|
541
|
+
assert.equal(await claimWork(db, 4, t0 + 60_000), null);
|
|
542
|
+
assert.equal((await readWarmState(db)).phase, "warm", "a recent batch may still be under way");
|
|
543
|
+
assert.equal(warmMayHaveWork(db.rows.get(WARM_STATE_OPTION)), true);
|
|
544
|
+
await claimWork(db, 4, t0 + 1000 + BATCH_STALE_MS);
|
|
545
|
+
const state = await readWarmState(db);
|
|
546
|
+
assert.equal(state.phase, "done");
|
|
547
|
+
assert.equal(state.warmed, 4);
|
|
548
|
+
assert.equal(state.failed, 2, "the lost batch counts as failed");
|
|
549
|
+
assert.deepEqual(queueRows(db), []);
|
|
550
|
+
// Its isolate recording late changes nothing.
|
|
551
|
+
await recordBatch(db, run.generation, 2, 0, t0 + 1000 + BATCH_STALE_MS + 1);
|
|
552
|
+
assert.deepEqual([(await readWarmState(db)).warmed, (await readWarmState(db)).failed], [4, 2]);
|
|
553
|
+
});
|
|
554
|
+
|
|
555
|
+
// ── Daily refresh ──
|
|
556
|
+
|
|
557
|
+
/** A finished run whose last batch was recorded at `finishedAt`. */
|
|
558
|
+
async function finishedRun(db, finishedAt) {
|
|
559
|
+
const state = { ...newWarmState("deploy", new Date(finishedAt - 60_000)), phase: "done", total: 3, next: 3, warmed: 3, finishedAt: new Date(finishedAt).toISOString() };
|
|
560
|
+
await writeWarmState(db, state);
|
|
561
|
+
return state;
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
test("daily refresh: a new run starts a day after the last one finished, not before", async () => {
|
|
565
|
+
const db = fakeDb();
|
|
566
|
+
const t0 = Date.now();
|
|
567
|
+
const done = await finishedRun(db, t0);
|
|
568
|
+
assert.equal(nextDailyAt(done), t0 + DAILY_REFRESH_MS);
|
|
569
|
+
const raw = db.rows.get(WARM_STATE_OPTION);
|
|
570
|
+
assert.equal(warmMayHaveWork(raw, t0 + DAILY_REFRESH_MS - 1), false, "requests skip the warm step (no query) until it's due");
|
|
571
|
+
assert.equal(await claimWork(db, 4, t0 + DAILY_REFRESH_MS - 1), null);
|
|
572
|
+
assert.equal(db.rows.get(WARM_STATE_OPTION), raw, "nothing written before it's due");
|
|
573
|
+
assert.equal(warmMayHaveWork(raw, t0 + DAILY_REFRESH_MS), true);
|
|
574
|
+
const claim = await claimWork(db, 4, t0 + DAILY_REFRESH_MS);
|
|
575
|
+
assert.equal(claim.kind, "collect");
|
|
576
|
+
assert.equal(claim.state.reason, "daily");
|
|
577
|
+
assert.notEqual(claim.state.generation, done.generation);
|
|
578
|
+
assert.equal(nextDailyAt(claim.state), null, "no refresh due while the run goes on");
|
|
579
|
+
});
|
|
580
|
+
|
|
581
|
+
test("daily refresh: same pages, same order; it finishes and pushes the next one a day later", async () => {
|
|
582
|
+
const db = fakeDb();
|
|
583
|
+
const c = clock();
|
|
584
|
+
await finishedRun(db, c.t);
|
|
585
|
+
c.t += DAILY_REFRESH_MS;
|
|
586
|
+
const s = site(["/new/", "/old/"]);
|
|
587
|
+
assert.equal(await warmStep(db, s, "https://x.com", { budgetMs: 1000, batchSize: 4, now: c.now }), "warmed");
|
|
588
|
+
assert.deepEqual(s.hits, ["https://x.com/", "https://x.com/sitemap.xml", "https://x.com/", "https://x.com/new/", "https://x.com/old/"]);
|
|
589
|
+
const state = await readWarmState(db);
|
|
590
|
+
assert.equal(state.phase, "done");
|
|
591
|
+
assert.equal(state.reason, "daily");
|
|
592
|
+
assert.equal(nextDailyAt(state), c.t + DAILY_REFRESH_MS);
|
|
593
|
+
assert.equal(await claimWork(db, 4, c.t + 1000), null, "it doesn't loop");
|
|
594
|
+
});
|
|
595
|
+
|
|
596
|
+
test("daily refresh: parallel claims start it once", async () => {
|
|
597
|
+
const db = fakeDb();
|
|
598
|
+
const t0 = Date.now();
|
|
599
|
+
await finishedRun(db, t0);
|
|
600
|
+
const claims = await Promise.all(Array.from({ length: 8 }, () => claimWork(db, 4, t0 + DAILY_REFRESH_MS)));
|
|
601
|
+
const started = claims.filter(Boolean);
|
|
602
|
+
assert.equal(started.length, 1);
|
|
603
|
+
assert.equal(started[0].kind, "collect");
|
|
604
|
+
assert.equal((await claimWork(db, 4, t0 + DAILY_REFRESH_MS + 1)), null, "the collect step is taken once");
|
|
605
|
+
});
|
|
606
|
+
|
|
607
|
+
test("daily refresh: a run started for another reason resets the clock", async () => {
|
|
608
|
+
const db = fakeDb();
|
|
609
|
+
const t0 = Date.now();
|
|
610
|
+
await finishedRun(db, t0);
|
|
611
|
+
const later = t0 + 20 * 60 * 60_000;
|
|
612
|
+
const deploy = await startWarm(db, "deploy");
|
|
613
|
+
assert.equal(warmMayHaveWork(db.rows.get(WARM_STATE_OPTION), t0 + DAILY_REFRESH_MS), true, "a run under way has work");
|
|
614
|
+
await writeWarmState(db, { ...deploy, phase: "done", finishedAt: new Date(later).toISOString() });
|
|
615
|
+
const raw = db.rows.get(WARM_STATE_OPTION);
|
|
616
|
+
assert.equal(warmMayHaveWork(raw, t0 + DAILY_REFRESH_MS), false);
|
|
617
|
+
assert.equal(await claimWork(db, 4, t0 + DAILY_REFRESH_MS), null, "not due a day after the older run");
|
|
618
|
+
assert.equal((await claimWork(db, 4, later + DAILY_REFRESH_MS)).state.reason, "daily");
|
|
619
|
+
});
|
|
620
|
+
|
|
621
|
+
test("daily refresh: content edits due at the same time win (reason edit)", async () => {
|
|
622
|
+
const db = fakeDb();
|
|
623
|
+
const t0 = Date.now();
|
|
624
|
+
await finishedRun(db, t0);
|
|
625
|
+
await scheduleRewarm(db, t0 + DAILY_REFRESH_MS - REWARM_DELAY_MS);
|
|
626
|
+
assert.equal((await claimWork(db, 4, t0 + DAILY_REFRESH_MS)).state.reason, "edit");
|
|
627
|
+
});
|
|
628
|
+
|
|
629
|
+
test("daily refresh: with no run yet, the first warm step starts one (once)", async () => {
|
|
630
|
+
const db = fakeDb();
|
|
631
|
+
assert.equal(warmMayHaveWork(null), true, "no row: the warm step runs");
|
|
632
|
+
const claims = await Promise.all([claimWork(db, 4), claimWork(db, 4), claimWork(db, 4)]);
|
|
633
|
+
const started = claims.filter(Boolean);
|
|
634
|
+
assert.equal(started.length, 1);
|
|
635
|
+
assert.equal(started[0].kind, "collect");
|
|
636
|
+
assert.equal(started[0].state.reason, "daily");
|
|
637
|
+
assert.equal((await readWarmState(db)).generation, started[0].state.generation);
|
|
638
|
+
});
|
|
639
|
+
|
|
640
|
+
test("daily refresh off: no daily run (none with no row either), and requests don't wake for it; on by default", async () => {
|
|
641
|
+
const db = fakeDb();
|
|
642
|
+
assert.equal(warmMayHaveWork(null, Date.now(), false), false);
|
|
643
|
+
assert.equal(await claimWork(db, 4, Date.now(), false), null);
|
|
644
|
+
assert.equal(db.rows.has(WARM_STATE_OPTION), false, "no row written");
|
|
645
|
+
const t0 = Date.now();
|
|
646
|
+
await finishedRun(db, t0);
|
|
647
|
+
const raw = db.rows.get(WARM_STATE_OPTION);
|
|
648
|
+
const later = t0 + 3 * DAILY_REFRESH_MS;
|
|
649
|
+
assert.equal(warmMayHaveWork(raw, later, false), false);
|
|
650
|
+
assert.equal(await claimWork(db, 4, later, false), null);
|
|
651
|
+
assert.equal(db.rows.get(WARM_STATE_OPTION), raw, "nothing started");
|
|
652
|
+
const s = site(["/a/"]);
|
|
653
|
+
assert.equal(await warmStep(db, s, "https://x.com", { budgetMs: 1000, batchSize: 4, now: () => later, daily: false }), "idle");
|
|
654
|
+
assert.deepEqual(s.hits, []);
|
|
655
|
+
// Other triggers still work with it off.
|
|
656
|
+
await scheduleRewarm(db, later);
|
|
657
|
+
assert.equal((await claimWork(db, 4, later + REWARM_DELAY_MS, false)).state.reason, "edit");
|
|
658
|
+
// Default (no option): on.
|
|
659
|
+
const db2 = fakeDb();
|
|
660
|
+
await finishedRun(db2, t0);
|
|
661
|
+
assert.equal(warmMayHaveWork(db2.rows.get(WARM_STATE_OPTION), later), true);
|
|
662
|
+
assert.equal((await warmStep(db2, site(["/a/"]), "https://x.com", { budgetMs: 1000, batchSize: 4, now: () => later })), "warmed");
|
|
663
|
+
assert.equal((await readWarmState(db2)).reason, "daily");
|
|
664
|
+
});
|
|
665
|
+
|
|
666
|
+
test("a failed run is retried by the daily refresh a day after it started", () => {
|
|
667
|
+
const startedAt = "2026-10-01T00:00:00.000Z";
|
|
668
|
+
assert.equal(nextDailyAt({ phase: "failed", startedAt }), Date.parse(startedAt) + DAILY_REFRESH_MS);
|
|
669
|
+
assert.equal(nextDailyAt({ phase: "warm", startedAt }), null);
|
|
670
|
+
});
|