@nitpicker/crawler 0.11.0 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. package/lib/archive/archive.d.ts +117 -2
  2. package/lib/archive/archive.js +147 -2
  3. package/lib/archive/cache/compute-archive-cache-key.d.ts +39 -0
  4. package/lib/archive/cache/compute-archive-cache-key.js +95 -0
  5. package/lib/archive/cache/extract-archive-to-cache.d.ts +43 -0
  6. package/lib/archive/cache/extract-archive-to-cache.js +309 -0
  7. package/lib/archive/cache/get-archive-cache-root.d.ts +20 -0
  8. package/lib/archive/cache/get-archive-cache-root.js +53 -0
  9. package/lib/archive/cache/is-archive-cache-disabled.d.ts +24 -0
  10. package/lib/archive/cache/is-archive-cache-disabled.js +34 -0
  11. package/lib/archive/cache/resolve-archive-cache-dir.d.ts +26 -0
  12. package/lib/archive/cache/resolve-archive-cache-dir.js +32 -0
  13. package/lib/archive/database.d.ts +216 -15
  14. package/lib/archive/database.js +1459 -938
  15. package/lib/archive/derive-lineage-from-parent.d.ts +37 -0
  16. package/lib/archive/derive-lineage-from-parent.js +42 -0
  17. package/lib/archive/get-failed-page-messages.d.ts +43 -0
  18. package/lib/archive/get-failed-page-messages.js +131 -0
  19. package/lib/archive/init-schema.js +153 -1
  20. package/lib/archive/is-inventory-source.d.ts +21 -0
  21. package/lib/archive/is-inventory-source.js +22 -0
  22. package/lib/archive/migrate-inventory-runs.d.ts +29 -0
  23. package/lib/archive/migrate-inventory-runs.js +52 -0
  24. package/lib/archive/types.d.ts +33 -0
  25. package/lib/classify-error-kind.d.ts +19 -0
  26. package/lib/classify-error-kind.js +122 -0
  27. package/lib/crawler/build-js-redirect-edge.d.ts +68 -0
  28. package/lib/crawler/build-js-redirect-edge.js +57 -0
  29. package/lib/crawler/build-redirect-event.d.ts +24 -0
  30. package/lib/crawler/build-redirect-event.js +28 -0
  31. package/lib/crawler/clear-dns-burned-host-cache.d.ts +6 -0
  32. package/lib/crawler/clear-dns-burned-host-cache.js +11 -0
  33. package/lib/crawler/crawler.d.ts +3 -1
  34. package/lib/crawler/crawler.js +655 -107
  35. package/lib/crawler/derive-js-redirect-target.d.ts +68 -0
  36. package/lib/crawler/derive-js-redirect-target.js +129 -0
  37. package/lib/crawler/derive-resource-source.d.ts +25 -15
  38. package/lib/crawler/derive-resource-source.js +28 -17
  39. package/lib/crawler/dns-burned-host-cache.d.ts +26 -0
  40. package/lib/crawler/dns-burned-host-cache.js +25 -0
  41. package/lib/crawler/dns-burned-host-short-circuit-counter.d.ts +13 -0
  42. package/lib/crawler/dns-burned-host-short-circuit-counter.js +11 -0
  43. package/lib/crawler/fetch-destination.d.ts +12 -4
  44. package/lib/crawler/fetch-destination.js +94 -16
  45. package/lib/crawler/is-js-redirect-error-shape.d.ts +40 -0
  46. package/lib/crawler/is-js-redirect-error-shape.js +53 -0
  47. package/lib/crawler/is-puppeteer-fallback-candidate.d.ts +16 -0
  48. package/lib/crawler/is-puppeteer-fallback-candidate.js +63 -0
  49. package/lib/crawler/link-list.d.ts +21 -1
  50. package/lib/crawler/link-list.js +23 -3
  51. package/lib/crawler/plan-sub-resource-emits.d.ts +63 -0
  52. package/lib/crawler/plan-sub-resource-emits.js +44 -0
  53. package/lib/crawler/preload-short-circuit-error.d.ts +22 -0
  54. package/lib/crawler/preload-short-circuit-error.js +25 -0
  55. package/lib/crawler/should-burn-host.d.ts +78 -0
  56. package/lib/crawler/should-burn-host.js +61 -0
  57. package/lib/crawler/should-get-fallback-on-head-failure.d.ts +38 -0
  58. package/lib/crawler/should-get-fallback-on-head-failure.js +46 -0
  59. package/lib/crawler/types.d.ts +107 -0
  60. package/lib/crawler-orchestrator.d.ts +13 -3
  61. package/lib/crawler-orchestrator.js +292 -69
  62. package/lib/crawler.d.ts +3 -2
  63. package/lib/crawler.js +3 -1
  64. package/lib/permanent-error-kinds.d.ts +43 -0
  65. package/lib/permanent-error-kinds.js +48 -0
  66. package/lib/types.d.ts +84 -0
  67. package/lib/utils/compute-file-sha256.d.ts +23 -0
  68. package/lib/utils/compute-file-sha256.js +55 -0
  69. package/lib/utils/error/emit-error-with-retry.d.ts +40 -0
  70. package/lib/utils/error/emit-error-with-retry.js +44 -0
  71. package/lib/utils/error/emit-error.d.ts +39 -0
  72. package/lib/utils/error/emit-error.js +41 -0
  73. package/package.json +11 -11
  74. package/lib/utils/error/error-emitter.d.ts +0 -18
  75. package/lib/utils/error/error-emitter.js +0 -29
@@ -0,0 +1,37 @@
1
+ import type { PageSource } from './types.js';
2
+ /**
3
+ * Decide which {@link PageSource} label a child row reached through the
4
+ * crawl graph (anchor placeholder, sub-resource, redirect chain
5
+ * intermediate) should inherit from its parent.
6
+ *
7
+ * Two simple rules, expressed once so anchor / redirect / sub-resource
8
+ * call sites stay in lockstep:
9
+ *
10
+ * 1. If the parent is in the inventory chain
11
+ * ({@link isInventorySource}) → propagate
12
+ * `'inventory-discovered'`. The child is itself a transitively
13
+ * reached node in the inventory chain; it is NOT a new seed (the
14
+ * seed label is reserved for URLs the operator listed in
15
+ * `--inventory ./list.txt`).
16
+ *
17
+ * 2. Otherwise → return `fallback`. The two production fallbacks differ
18
+ * by call site:
19
+ *
20
+ * - Anchor lineage passes `'crawled'` explicitly so the crawled-wins
21
+ * downgrade inside `#getIdByUrl` fires when the anchor reaches an
22
+ * existing `'inventory-*'` row.
23
+ * - Sub-resource emit passes `undefined` so the DB DEFAULT
24
+ * `'crawled'` lands on the freshly INSERTed `resources` row (the
25
+ * `setResources` path is INSERT-only with `onConflict.ignore()`, so
26
+ * no downgrade is needed).
27
+ * - Redirect chain intermediate uses `'crawled'` (same reason as
28
+ * anchor): an existing inventory-* intermediate reached by a
29
+ * crawled redirect chain must be downgraded.
30
+ *
31
+ * Pure function — keeps the lineage decision testable in isolation from
32
+ * the database transaction / event-emitter wiring that consumes it.
33
+ * @param parentSource - The parent page's stored `source` column (or `undefined` when no parent row exists).
34
+ * @param fallback - The label to return when the parent is NOT in the inventory chain. Pass `'crawled'` to enable the crawled-wins downgrade, or `undefined` to let the DB DEFAULT apply.
35
+ * @returns The lineage label to attach to the child row.
36
+ */
37
+ export declare function deriveLineageFromParent(parentSource: PageSource | undefined, fallback: PageSource | undefined): PageSource | undefined;
@@ -0,0 +1,42 @@
1
+ import { isInventorySource } from './is-inventory-source.js';
2
+ /**
3
+ * Decide which {@link PageSource} label a child row reached through the
4
+ * crawl graph (anchor placeholder, sub-resource, redirect chain
5
+ * intermediate) should inherit from its parent.
6
+ *
7
+ * Two simple rules, expressed once so anchor / redirect / sub-resource
8
+ * call sites stay in lockstep:
9
+ *
10
+ * 1. If the parent is in the inventory chain
11
+ * ({@link isInventorySource}) → propagate
12
+ * `'inventory-discovered'`. The child is itself a transitively
13
+ * reached node in the inventory chain; it is NOT a new seed (the
14
+ * seed label is reserved for URLs the operator listed in
15
+ * `--inventory ./list.txt`).
16
+ *
17
+ * 2. Otherwise → return `fallback`. The two production fallbacks differ
18
+ * by call site:
19
+ *
20
+ * - Anchor lineage passes `'crawled'` explicitly so the crawled-wins
21
+ * downgrade inside `#getIdByUrl` fires when the anchor reaches an
22
+ * existing `'inventory-*'` row.
23
+ * - Sub-resource emit passes `undefined` so the DB DEFAULT
24
+ * `'crawled'` lands on the freshly INSERTed `resources` row (the
25
+ * `setResources` path is INSERT-only with `onConflict.ignore()`, so
26
+ * no downgrade is needed).
27
+ * - Redirect chain intermediate uses `'crawled'` (same reason as
28
+ * anchor): an existing inventory-* intermediate reached by a
29
+ * crawled redirect chain must be downgraded.
30
+ *
31
+ * Pure function — keeps the lineage decision testable in isolation from
32
+ * the database transaction / event-emitter wiring that consumes it.
33
+ * @param parentSource - The parent page's stored `source` column (or `undefined` when no parent row exists).
34
+ * @param fallback - The label to return when the parent is NOT in the inventory chain. Pass `'crawled'` to enable the crawled-wins downgrade, or `undefined` to let the DB DEFAULT apply.
35
+ * @returns The lineage label to attach to the child row.
36
+ */
37
+ export function deriveLineageFromParent(parentSource, fallback) {
38
+ if (isInventorySource(parentSource)) {
39
+ return 'inventory-discovered';
40
+ }
41
+ return fallback;
42
+ }
@@ -0,0 +1,43 @@
1
+ import type { Knex } from 'knex';
2
+ /**
3
+ * Bulk-resolve a raw error message for each given page id, using only sources
4
+ * reachable from a {@link Knex} handle. Read order: `page_errors` (keyed by
5
+ * `pageId`, the most direct signal a scrape attempt recorded), then
6
+ * `crawl_errors` (keyed by `url`, the crawler-channel record for failures
7
+ * that happened before a page row was scraped).
8
+ *
9
+ * **Known limitation — pre-`crawl_errors` archives**: This helper does NOT
10
+ * read `error.log`. `migrateCrawlErrors` creates the `crawl_errors` table on
11
+ * writer connect but does NOT back-fill historical lines from `error.log`,
12
+ * so an archive that predates the `crawl_errors` schema and whose failures
13
+ * live only in `error.log` will resolve every id to "no message" here. The
14
+ * downstream `Database.resetFailedPages` treats absence as `unknown` (still
15
+ * retryable), so legacy archives lose the permanent-kind exclusion until a
16
+ * fresh crawl run populates `crawl_errors` / `page_errors`. The trade-off
17
+ * (no error.log parsing in the writer path) keeps the writer dependency
18
+ * surface narrow and avoids re-implementing the parser already living in
19
+ * `@nitpicker/query`'s `resolveFailedPageMessages` — which the crawler
20
+ * package cannot import (reverse-direction dependency). When this matters
21
+ * in practice, run the archive through one fresh `crawl --retry-failed`
22
+ * pass first to populate the structured tables.
23
+ *
24
+ * Pages with no message in any consulted source are simply absent from the
25
+ * returned map; callers treat the absence as "unclassifiable, keep retrying"
26
+ * (i.e. `unknown`).
27
+ * @param instance - The {@link Knex} handle.
28
+ * @param ids - Candidate `pages.id` values.
29
+ * @param urls - The corresponding `pages.url` values, in the same order as
30
+ * `ids`. Length and indexing MUST match `ids` so the page → url join can be
31
+ * reconstructed without a second `pages` round-trip.
32
+ * @returns `Map<pageId, message>` populated only for ids whose message was
33
+ * found in one of the consulted tables.
34
+ * @example
35
+ * ```ts
36
+ * const messages = await getFailedPageMessages(
37
+ * instance,
38
+ * candidates.map(c => c.id),
39
+ * candidates.map(c => c.url),
40
+ * );
41
+ * ```
42
+ */
43
+ export declare function getFailedPageMessages(instance: Knex, ids: readonly number[], urls: readonly string[]): Promise<Map<number, string>>;
@@ -0,0 +1,131 @@
1
+ /** chunk size for `WHERE … IN (?)` SQLite parameter packing. */
2
+ const CHUNK_SIZE = 500;
3
+ /**
4
+ * Bulk-resolve a raw error message for each given page id, using only sources
5
+ * reachable from a {@link Knex} handle. Read order: `page_errors` (keyed by
6
+ * `pageId`, the most direct signal a scrape attempt recorded), then
7
+ * `crawl_errors` (keyed by `url`, the crawler-channel record for failures
8
+ * that happened before a page row was scraped).
9
+ *
10
+ * **Known limitation — pre-`crawl_errors` archives**: This helper does NOT
11
+ * read `error.log`. `migrateCrawlErrors` creates the `crawl_errors` table on
12
+ * writer connect but does NOT back-fill historical lines from `error.log`,
13
+ * so an archive that predates the `crawl_errors` schema and whose failures
14
+ * live only in `error.log` will resolve every id to "no message" here. The
15
+ * downstream `Database.resetFailedPages` treats absence as `unknown` (still
16
+ * retryable), so legacy archives lose the permanent-kind exclusion until a
17
+ * fresh crawl run populates `crawl_errors` / `page_errors`. The trade-off
18
+ * (no error.log parsing in the writer path) keeps the writer dependency
19
+ * surface narrow and avoids re-implementing the parser already living in
20
+ * `@nitpicker/query`'s `resolveFailedPageMessages` — which the crawler
21
+ * package cannot import (reverse-direction dependency). When this matters
22
+ * in practice, run the archive through one fresh `crawl --retry-failed`
23
+ * pass first to populate the structured tables.
24
+ *
25
+ * Pages with no message in any consulted source are simply absent from the
26
+ * returned map; callers treat the absence as "unclassifiable, keep retrying"
27
+ * (i.e. `unknown`).
28
+ * @param instance - The {@link Knex} handle.
29
+ * @param ids - Candidate `pages.id` values.
30
+ * @param urls - The corresponding `pages.url` values, in the same order as
31
+ * `ids`. Length and indexing MUST match `ids` so the page → url join can be
32
+ * reconstructed without a second `pages` round-trip.
33
+ * @returns `Map<pageId, message>` populated only for ids whose message was
34
+ * found in one of the consulted tables.
35
+ * @example
36
+ * ```ts
37
+ * const messages = await getFailedPageMessages(
38
+ * instance,
39
+ * candidates.map(c => c.id),
40
+ * candidates.map(c => c.url),
41
+ * );
42
+ * ```
43
+ */
44
+ export async function getFailedPageMessages(instance, ids, urls) {
45
+ if (ids.length === 0) {
46
+ return new Map();
47
+ }
48
+ if (ids.length !== urls.length) {
49
+ throw new Error(`getFailedPageMessages: ids.length (${ids.length}) !== urls.length (${urls.length}) — must be 1:1`);
50
+ }
51
+ const messageByPageId = new Map();
52
+ if (await instance.schema.hasTable('page_errors')) {
53
+ for (let i = 0; i < ids.length; i += CHUNK_SIZE) {
54
+ const chunk = ids.slice(i, i + CHUNK_SIZE);
55
+ // `orderBy('id', 'asc')` makes the per-pageId "first row seen"
56
+ // behavior deterministic regardless of SQLite's natural ROWID
57
+ // scan order, which is otherwise implementation-defined under
58
+ // concurrent / migrated archives. Without the explicit order, a
59
+ // reset that classified a page as `unknown` once could classify
60
+ // it as `parse-error` on the next run when the rows happen to be
61
+ // returned in a different order.
62
+ const rows = (await instance('page_errors')
63
+ .select('pageId', 'message')
64
+ .whereIn('pageId', chunk)
65
+ .orderBy('id', 'asc'));
66
+ for (const row of rows) {
67
+ // Earliest-id wins. Schema permits multiple rows per pageId
68
+ // (the same scrape can record several phase errors); the
69
+ // first row inserted is usually the trigger cause, later
70
+ // rows are follow-on noise from the same failure cascade.
71
+ //
72
+ // An empty `message` is treated as "no signal" and ignored
73
+ // so the crawl_errors lookup can fill it in. Without this,
74
+ // a page_errors row with `message=''` (recorded by a
75
+ // scraper phase that fired its trigger but had no error
76
+ // text) would short-circuit and we'd lose access to the
77
+ // crawl_errors row that classifies the failure as
78
+ // `dns` / `tls` / `client-blocked` etc. — defeating
79
+ // `--retry-failed`'s permanent-kind exclusion.
80
+ if (row.message !== '' && !messageByPageId.has(row.pageId)) {
81
+ messageByPageId.set(row.pageId, row.message);
82
+ }
83
+ }
84
+ }
85
+ }
86
+ const idsMissing = ids.filter((id) => !messageByPageId.has(id));
87
+ if (idsMissing.length === 0 || !(await instance.schema.hasTable('crawl_errors'))) {
88
+ // Early-exit short-circuits BOTH the idToUrl Map construction and
89
+ // the crawl_errors round-trip. On a 1M-page archive where every
90
+ // failed page already has a `page_errors` row, this avoids walking
91
+ // the candidate list a second time.
92
+ return messageByPageId;
93
+ }
94
+ const idToUrl = new Map();
95
+ for (const [i, id] of ids.entries()) {
96
+ const url = urls[i];
97
+ if (url !== undefined) {
98
+ idToUrl.set(id, url);
99
+ }
100
+ }
101
+ const missingUrls = [];
102
+ for (const id of idsMissing) {
103
+ const url = idToUrl.get(id);
104
+ if (url !== undefined) {
105
+ missingUrls.push(url);
106
+ }
107
+ }
108
+ const urlToMessage = new Map();
109
+ for (let i = 0; i < missingUrls.length; i += CHUNK_SIZE) {
110
+ const chunk = missingUrls.slice(i, i + CHUNK_SIZE);
111
+ const rows = (await instance('crawl_errors')
112
+ .select('url', 'message')
113
+ .whereIn('url', chunk));
114
+ for (const row of rows) {
115
+ if (row.url !== null && !urlToMessage.has(row.url)) {
116
+ urlToMessage.set(row.url, row.message);
117
+ }
118
+ }
119
+ }
120
+ for (const id of idsMissing) {
121
+ const url = idToUrl.get(id);
122
+ if (url === undefined) {
123
+ continue;
124
+ }
125
+ const message = urlToMessage.get(url);
126
+ if (message !== undefined) {
127
+ messageByPageId.set(id, message);
128
+ }
129
+ }
130
+ return messageByPageId;
131
+ }
@@ -15,9 +15,20 @@ export async function applyConnectionPragmas(instance) {
15
15
  await instance.raw('PRAGMA foreign_keys = ON');
16
16
  await instance.raw('PRAGMA wal_autocheckpoint = 1000');
17
17
  // Negative value = KiB of memory (64 MiB). Helps large BLOB scans.
18
+ //
19
+ // Empirically validated against larger values on a 10 GB archive:
20
+ // bumping to 512 MiB regressed `getSummary` (1.9s → 5.7s), `pages`
21
+ // (2.3s → 21s), and `images` (3.7s → 12s) — libsql's page eviction
22
+ // policy interacts poorly with a cache sized comparable to the
23
+ // host's page-cache window when the DB itself far exceeds RAM.
24
+ // 64 MiB stays the sweet spot.
18
25
  await instance.raw('PRAGMA cache_size = -65536');
19
26
  // 256 MiB mmap window. SQLite falls back to read() past this so the
20
- // limit is a soft ceiling, not a hard one.
27
+ // limit is a soft ceiling, not a hard one. A 4 GiB window was
28
+ // catastrophic on a 10 GB archive on macOS (summary 1.9s → 43s,
29
+ // pages 2.3s → 21s) — the kernel's read-ahead policy and libsql's
30
+ // mmap path interact badly when the window can cover most of the
31
+ // DB. Keep this conservative.
21
32
  await instance.raw('PRAGMA mmap_size = 268435456');
22
33
  }
23
34
  /**
@@ -318,6 +329,23 @@ export async function initSchema(instance) {
318
329
  t.text('parseError');
319
330
  t.index('pageId');
320
331
  t.index('type');
332
+ })
333
+ .createTable('inventory_runs', (t) => {
334
+ // One row per successful `--inventory <list>` invocation. The
335
+ // archive's audit log of "when did we apply which deploy list
336
+ // at what scale". `.bak` is removed on success so this table
337
+ // is the only durable provenance record. Schema rationale +
338
+ // non-goals live in {@link migrateInventoryRuns}.
339
+ t.increments('id');
340
+ t.string('ran_at').notNullable();
341
+ t.string('list_label').nullable();
342
+ t.string('source_file_sha256', 64).nullable();
343
+ t.integer('total_lines').nullable();
344
+ t.integer('new_pages').nullable();
345
+ t.integer('new_resources').nullable();
346
+ t.integer('scope_skipped').nullable();
347
+ t.text('notes').nullable();
348
+ t.index('ran_at');
321
349
  });
322
350
  // ON DELETE CASCADE and compound indexes for the new tables. Knex's
323
351
  // schema builder can't express CASCADE / compound indexes inline in a
@@ -348,4 +376,128 @@ export async function initSchema(instance) {
348
376
  ) WITHOUT ROWID
349
377
  `);
350
378
  await instance.raw('CREATE INDEX idx_page_html_ref_hash ON page_html_ref(hash)');
379
+ // Composite covering index for the default Pages-view filter + url-ordered
380
+ // scan. Without it, `listPages` on a 400k-row archive runs ~15s per page
381
+ // click (SCAN pages USING pages_scraped_index + TEMP B-TREE FOR ORDER BY);
382
+ // with it, the same query runs ~45ms (368x speedup, confirmed via
383
+ // `scripts/bench-partial-listfilter.mjs` against a real customer archive).
384
+ // The same index also serves `listIsolatedPages`, `listIsolatedClusters`,
385
+ // and `getSummary`'s HTML-page counts.
386
+ //
387
+ // **Column order: `(isExternal, scraped, redirectDestId, url, contentType)`.**
388
+ // The leading `isExternal` is critical: the Pages view's default
389
+ // "external excluded" filter adds `WHERE isExternal = 0` to both the
390
+ // SELECT and the paginate-query COUNT. A previous version of this index
391
+ // (`(scraped, redirectDestId, url, contentType)`) shipped without
392
+ // `isExternal`, and the SELECT picked it up (`ORDER BY url` forced the
393
+ // match) while the COUNT — having no `ORDER BY` — fell back to the
394
+ // single-column `pages_isexternal_index` + scan + per-row WHERE filter,
395
+ // costing ~8.7s for the COUNT alone on a 165k-internal-page archive.
396
+ // Putting `isExternal` first makes both shapes pick this index as a
397
+ // covering scan (~33ms COUNT, ~1ms SELECT warm).
398
+ //
399
+ // **DO NOT RUN `ANALYZE` ON .nitpicker ARCHIVES.** With ANALYZE statistics
400
+ // available, the planner switches the JOIN paths in `listLinks`,
401
+ // `getLinkGraph`, and `listPageLinks` to use this index for source/dest
402
+ // seeks (SCAN dest → SEARCH anchors → SEARCH source) instead of the
403
+ // existing `SCAN anchors → rowid seek` plan. That regression takes those
404
+ // queries from ~15s to ~500s (33x worse). The unanalyzed-table heuristic
405
+ // happens to pick the right plan for the joins while still picking the new
406
+ // index for `listPages` because the column order exactly matches the
407
+ // WHERE+ORDER predicates. If a future change adds `ANALYZE` anywhere in
408
+ // the crawler / viewer / MCP / migration paths, this index must be
409
+ // re-evaluated first.
410
+ await instance.raw('CREATE INDEX idx_pages_listfilter ON pages(isExternal, scraped, redirectDestId, url, contentType)');
411
+ // Covering index for `listUnusedResources`. Without it the query SCAN s
412
+ // `resources_url_unique` (every resource, including externals) then
413
+ // filters `isExternal = 0` row-by-row — ~66s on the bench archive. With
414
+ // the `(isExternal, url)` leading prefix, the planner serves the WHERE
415
+ // + ORDER BY url from one covering scan — ~7.5s (8.8x). Same
416
+ // no-ANALYZE invariant applies (see `idx_pages_listfilter` above);
417
+ // validated against the 4 regression sentinels in
418
+ // `scripts/bench-unused-images.mjs`.
419
+ await instance.raw('CREATE INDEX idx_resources_internal_url ON resources(isExternal, url)');
420
+ // Covering index for `listImages`. The default query joins `images` to
421
+ // `pages` and orders by `pages.url`. Without this index the planner
422
+ // scans `images` first, seeks `pages` by rowid, and pays a TEMP B-TREE
423
+ // FOR ORDER BY (~32s on the bench archive). With the index the plan
424
+ // flips to SCAN pages (via `pages_url_unique`, url-ordered already)
425
+ // → SEARCH images via the covering pageId index — no temp sort, ~16s
426
+ // (2.0x). The included columns (src, alt, dimensions, isLazy) make
427
+ // `idx_images_covering` covering for every `select` `listImages` does,
428
+ // so the SEARCH does not need to materialise the underlying row.
429
+ // Validated by `scripts/bench-unused-images.mjs`.
430
+ await instance.raw('CREATE INDEX idx_images_covering ON images(pageId, src, alt, width, height, naturalWidth, naturalHeight, isLazy)');
431
+ // Targets `getSummary` Q2 (metadata fulfilment) + Q3 (content-type
432
+ // histogram). With this index Q2 and Q3 both become covering
433
+ // (`SEARCH ... USING COVERING INDEX`) — the SELECT columns are
434
+ // contained inside the index entry, so no rowid lookup is needed.
435
+ // Q1 (status histogram) also picks this index for its seek but
436
+ // keeps `USE TEMP B-TREE FOR GROUP BY` because the index column
437
+ // order leads with `contentType, isExternal` while Q1's GROUP BY
438
+ // is `(isExternal, status)` — the residual ordering inside the
439
+ // `scraped=1 AND redirectDestId IS NULL` slice does not match.
440
+ // Empirically this still gives the largest net win because Q2 +
441
+ // Q3 dominate `getSummary` on archives whose `pages` table dwarfs
442
+ // the SQLite page cache (10 GB bench: 1157 ms → 717 ms, 38 %).
443
+ //
444
+ // **An additional candidate index `(scraped, redirectDestId,
445
+ // isExternal, status)` was empirically rejected**: in isolation it
446
+ // matches Q1's GROUP BY column order and would eliminate the temp
447
+ // B-tree there, but in combination with this one or with the
448
+ // `pages_scraped_index` fallback the planner shifted to plans that
449
+ // regressed `getSummary` to 4.6-10 s (PR #96 教訓 — bulk index
450
+ // addition without ANALYZE confuses the heuristic). The
451
+ // `idx_pages_summary_contenttype` form below is the only summary
452
+ // index that survived the matrix test in
453
+ // `scripts/bench-summary-configs.mjs`.
454
+ //
455
+ // Column order rationale:
456
+ //
457
+ // 1. `scraped` — leading seek key. All summary queries constrain
458
+ // it to `=1`.
459
+ // 2. `redirectDestId` — post-seek filter, IS NULL folded into the
460
+ // seek key by SQLite's index walk without needing ANALYZE
461
+ // (per operator forum; the IS NULL leading column rule only
462
+ // bites when the column is the LEADING one and there is no
463
+ // other equality constraint).
464
+ // 3. `contentType` — the column Q3 groups by.
465
+ // 4. `isExternal` — Q3's second GROUP BY column AND Q2's WHERE
466
+ // constraint (`isExternal=0`).
467
+ // 5. `isSkipped` — Q1/Q3's residual `(isSkipped=0 OR IS NULL)`
468
+ // filter (`excludeSkippedPages`). Having it in the index lets
469
+ // the residual filter use the index entry instead of a per-row
470
+ // rowid lookup.
471
+ //
472
+ // `id` is implicitly included (every SQLite index entry carries
473
+ // the rowid), so the `count(id)` aggregates cover off-index.
474
+ //
475
+ // **No-ANALYZE invariant** identical to `idx_pages_listfilter`
476
+ // (PR #96): the column order matches the WHERE+GROUP BY predicates
477
+ // exactly, so SQLite's heuristic-only planner picks it without
478
+ // needing `sqlite_stat1`. Adding `ANALYZE` would risk planner
479
+ // shifts in this and other queries.
480
+ //
481
+ // **Regression check**: `listPages` / `listPages COUNT` /
482
+ // `listLinks broken` / `listPageLinks` plans were re-verified
483
+ // against this index — `idx_pages_listfilter` continues to win
484
+ // for all of them. See `scripts/bench-summary-configs.mjs`.
485
+ await instance.raw('CREATE INDEX idx_pages_summary_contenttype ON pages(scraped, redirectDestId, contentType, isExternal, isSkipped)');
486
+ // Targets `getSummary` Q4 (`failedPageIdRows`) — selects pages with
487
+ // `scraped=1 AND status=-1 AND redirectDestId IS NULL`. `status=-1`
488
+ // is highly selective (a few hundred rows on archives with
489
+ // ~400 k `scraped=1` pages), but without this index the planner
490
+ // seeks all `scraped=1` rows via `pages_scraped_index` and then
491
+ // row-by-row filters status, costing ~5 s. The 3-column form
492
+ // `(scraped, status, redirectDestId)` is fully covering for
493
+ // `SELECT id` and gives a 5113 ms → 14 ms (~365x) reduction
494
+ // verified by `scripts/prototype-summary-indexes.mjs`.
495
+ //
496
+ // Note the column order: `status` comes second so the `(scraped=1
497
+ // AND status=-1)` 2-column equality seek lands directly in the
498
+ // failed-page slice without scanning the 400 k+ healthy rows.
499
+ // Putting `redirectDestId` last keeps it as a 3rd-level seek
500
+ // constraint that the planner folds into the slice once the
501
+ // (scraped, status) pair is fixed.
502
+ await instance.raw('CREATE INDEX idx_pages_summary_failed ON pages(scraped, status, redirectDestId)');
351
503
  }
@@ -0,0 +1,21 @@
1
+ import type { PageSource } from './types.js';
2
+ /**
3
+ * Predicate that returns `true` when the given page source value belongs to
4
+ * the inventory chain — i.e. it is one of the `'inventory-*'` variants of
5
+ * {@link PageSource}.
6
+ *
7
+ * Centralises the membership check that decides whether lineage
8
+ * propagation should fire. Three call sites used to inline
9
+ * `s === 'inventory-seed' || s === 'inventory-discovered'`, which is both
10
+ * a DRY violation AND a future-proofing trap: when a new inventory-family
11
+ * label gets added (e.g. `'inventory-promoted'`), every inlined check has
12
+ * to be located and updated by hand. Routing through this predicate
13
+ * keeps the membership rule in one place.
14
+ *
15
+ * Returns `false` for `undefined` so callers can pass the raw `source`
16
+ * column value (which is non-NULL in the DB schema but reads as
17
+ * `undefined` from a missing row in JS) without a separate null check.
18
+ * @param source - The source value to test, or `undefined` when no row matched.
19
+ * @returns `true` if the source is in the inventory chain, `false` otherwise.
20
+ */
21
+ export declare function isInventorySource(source: PageSource | undefined): boolean;
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Predicate that returns `true` when the given page source value belongs to
3
+ * the inventory chain — i.e. it is one of the `'inventory-*'` variants of
4
+ * {@link PageSource}.
5
+ *
6
+ * Centralises the membership check that decides whether lineage
7
+ * propagation should fire. Three call sites used to inline
8
+ * `s === 'inventory-seed' || s === 'inventory-discovered'`, which is both
9
+ * a DRY violation AND a future-proofing trap: when a new inventory-family
10
+ * label gets added (e.g. `'inventory-promoted'`), every inlined check has
11
+ * to be located and updated by hand. Routing through this predicate
12
+ * keeps the membership rule in one place.
13
+ *
14
+ * Returns `false` for `undefined` so callers can pass the raw `source`
15
+ * column value (which is non-NULL in the DB schema but reads as
16
+ * `undefined` from a missing row in JS) without a separate null check.
17
+ * @param source - The source value to test, or `undefined` when no row matched.
18
+ * @returns `true` if the source is in the inventory chain, `false` otherwise.
19
+ */
20
+ export function isInventorySource(source) {
21
+ return source === 'inventory-seed' || source === 'inventory-discovered';
22
+ }
@@ -0,0 +1,29 @@
1
+ import type { Knex } from 'knex';
2
+ /**
3
+ * Adds the `inventory_runs` audit-log table to archives created before
4
+ * Phase 1 of inventory run tracking shipped.
5
+ *
6
+ * `inventory_runs` records one row per successful `--inventory <list>`
7
+ * invocation, capturing which deploy list was applied when and what
8
+ * scale it operated at. The motivation is operational: client /
9
+ * director conversations repeatedly ask "did you apply last month's
10
+ * list" / "we didn't double-apply, right" — the archive itself had no
11
+ * trace of inventory passes (`.bak` is unlinked on success), so this
12
+ * table is the durable provenance record.
13
+ *
14
+ * Schema details (column semantics, NULL policy, index) live in
15
+ * {@link initSchema} — this migration only re-creates the table shape
16
+ * on legacy archives so the rest of the codebase can treat the table
17
+ * as always-present once a writer connection has opened the file.
18
+ *
19
+ * Idempotent: when the table already exists, the function exits
20
+ * silently — the `[migrate] inventory_runs table created` stderr line
21
+ * fires **only** on the first run against a legacy archive, matching
22
+ * the established pattern of `migrate-page-errors.ts` /
23
+ * `migrate-crawl-errors.ts`. Operators can rely on the log line as a
24
+ * stable "first time this archive saw Phase 1 schema" event marker.
25
+ * Empty archives (no `pages` table) are skipped entirely; the regular
26
+ * `initSchema` path provisions them at first crawl.
27
+ * @param instance - The Knex query builder instance connected to the database.
28
+ */
29
+ export declare function migrateInventoryRuns(instance: Knex): Promise<void>;
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Adds the `inventory_runs` audit-log table to archives created before
3
+ * Phase 1 of inventory run tracking shipped.
4
+ *
5
+ * `inventory_runs` records one row per successful `--inventory <list>`
6
+ * invocation, capturing which deploy list was applied when and what
7
+ * scale it operated at. The motivation is operational: client /
8
+ * director conversations repeatedly ask "did you apply last month's
9
+ * list" / "we didn't double-apply, right" — the archive itself had no
10
+ * trace of inventory passes (`.bak` is unlinked on success), so this
11
+ * table is the durable provenance record.
12
+ *
13
+ * Schema details (column semantics, NULL policy, index) live in
14
+ * {@link initSchema} — this migration only re-creates the table shape
15
+ * on legacy archives so the rest of the codebase can treat the table
16
+ * as always-present once a writer connection has opened the file.
17
+ *
18
+ * Idempotent: when the table already exists, the function exits
19
+ * silently — the `[migrate] inventory_runs table created` stderr line
20
+ * fires **only** on the first run against a legacy archive, matching
21
+ * the established pattern of `migrate-page-errors.ts` /
22
+ * `migrate-crawl-errors.ts`. Operators can rely on the log line as a
23
+ * stable "first time this archive saw Phase 1 schema" event marker.
24
+ * Empty archives (no `pages` table) are skipped entirely; the regular
25
+ * `initSchema` path provisions them at first crawl.
26
+ * @param instance - The Knex query builder instance connected to the database.
27
+ */
28
+ export async function migrateInventoryRuns(instance) {
29
+ const hasTable = await instance.schema.hasTable('inventory_runs');
30
+ if (hasTable) {
31
+ return;
32
+ }
33
+ const hasPages = await instance.schema.hasTable('pages');
34
+ if (!hasPages) {
35
+ // Empty archive; the regular initSchema path will create the table.
36
+ return;
37
+ }
38
+ await instance.schema.createTable('inventory_runs', (t) => {
39
+ t.increments('id');
40
+ t.string('ran_at').notNullable();
41
+ t.string('list_label').nullable();
42
+ t.string('source_file_sha256', 64).nullable();
43
+ t.integer('total_lines').nullable();
44
+ t.integer('new_pages').nullable();
45
+ t.integer('new_resources').nullable();
46
+ t.integer('scope_skipped').nullable();
47
+ t.text('notes').nullable();
48
+ t.index('ran_at');
49
+ });
50
+ // eslint-disable-next-line no-console
51
+ console.error('[migrate] inventory_runs table created');
52
+ }
@@ -68,6 +68,39 @@ export interface Config extends Required<Pick<ParseURLOptions, 'disableQueries'>
68
68
  * the row.
69
69
  */
70
70
  export type PageSource = 'crawled' | 'inventory-seed' | 'inventory-discovered';
71
+ /**
72
+ * One row written to the `inventory_runs` audit table on each successful
73
+ * `--inventory <list>` invocation.
74
+ *
75
+ * Schema-mirror interface: every column on `inventory_runs` is represented
76
+ * here. Only `ran_at` is required — every other field is nullable so the
77
+ * post-merge raw-SQL backfill path (a one-off `sqlite3 INSERT` for the
78
+ * initial inventory pass that predated this table) can omit summary
79
+ * stats it cannot reconstruct.
80
+ *
81
+ * The audit log is append-only at Phase 1: there is no UPDATE path, no
82
+ * UNIQUE constraint on `source_file_sha256`, and no FK to pages /
83
+ * resources. Phase 2 (`inventory_memberships`) introduces the M:N link
84
+ * to URLs; Phase 3 (`--refresh`) uses `source_file_sha256` for dedupe.
85
+ */
86
+ export interface InventoryRunMeta {
87
+ /** ISO 8601 timestamp at which the run completed (e.g. `'2026-06-21T11:30:00+09:00'`). */
88
+ ran_at: string;
89
+ /** Human-readable identifier (e.g. `'prod-2026-06-21'`). `null` when the caller did not supply one. */
90
+ list_label?: string | null;
91
+ /** SHA-256 hex digest of the source file. `null` if hashing failed (e.g. file vanished mid-run). */
92
+ source_file_sha256?: string | null;
93
+ /** Number of non-empty lines in the input list (= URL count before scope filtering). */
94
+ total_lines?: number | null;
95
+ /** Number of new HTML seeds inserted as `pages` rows by this run. */
96
+ new_pages?: number | null;
97
+ /** Number of new non-HTML URLs inserted as `resources` rows by this run. */
98
+ new_resources?: number | null;
99
+ /** Number of input URLs dropped because they fell outside the archived scope. */
100
+ scope_skipped?: number | null;
101
+ /** Free-form text for backfill annotations or operator notes. */
102
+ notes?: string | null;
103
+ }
71
104
  /**
72
105
  * Filter type for querying pages from the database.
73
106
  *
@@ -0,0 +1,19 @@
1
+ import type { ErrorKind } from './types.js';
2
+ /**
3
+ * Classify a raw crawler/scraper error message into a coarse {@link ErrorKind}.
4
+ *
5
+ * Pure and deterministic: the same message always yields the same kind, which
6
+ * is why the kind is derived on read rather than persisted — it can be applied
7
+ * uniformly to freshly captured `crawl_errors`, legacy `error.log` lines, and
8
+ * `page_errors` alike.
9
+ * @param message - The raw error message (a single line is sufficient; the
10
+ * cause token such as `ENOTFOUND` or `Navigation timeout` lives there).
11
+ * @returns The matched kind, or `unknown` when no matcher applies.
12
+ * @example
13
+ * ```ts
14
+ * classifyErrorKind('getaddrinfo ENOTFOUND www.example.com'); // 'dns'
15
+ * classifyErrorKind('gave up after 3 retries — Race 180,000ms'); // 'timeout'
16
+ * classifyErrorKind('Protocol error (Page.reload): Target closed'); // 'protocol'
17
+ * ```
18
+ */
19
+ export declare function classifyErrorKind(message: string): ErrorKind;