@nitpicker/crawler 0.21.0 → 0.22.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 (113) hide show
  1. package/lib/append-retry-failed-common-setup-phases.d.ts +1 -1
  2. package/lib/append-retry-failed-common-setup-phases.js +5 -1
  3. package/lib/append-setup-phases.d.ts +1 -1
  4. package/lib/archive/archive.d.ts +57 -8
  5. package/lib/archive/archive.js +83 -12
  6. package/lib/archive/create-adjunct-tables.d.ts +3 -2
  7. package/lib/archive/create-adjunct-tables.js +12 -9
  8. package/lib/archive/create-entity-tables.js +3 -0
  9. package/lib/archive/database.d.ts +39 -7
  10. package/lib/archive/database.js +49 -9
  11. package/lib/archive/db-ops/_shared/keyset-paginate-by-id.d.ts +49 -0
  12. package/lib/archive/db-ops/_shared/keyset-paginate-by-id.js +69 -0
  13. package/lib/archive/db-ops/_shared/resolve-content-item-id.d.ts +13 -12
  14. package/lib/archive/db-ops/_shared/resolve-content-item-id.js +63 -31
  15. package/lib/archive/db-ops/_shared/types.d.ts +39 -0
  16. package/lib/archive/db-ops/analysis/replace-analysis-violations.js +7 -4
  17. package/lib/archive/db-ops/config/get-created-cwd.d.ts +11 -0
  18. package/lib/archive/db-ops/config/get-created-cwd.js +13 -0
  19. package/lib/archive/db-ops/config/info-column-allowlist.js +1 -0
  20. package/lib/archive/db-ops/dedupe-cap/list-dedupe-cap-observations.d.ts +57 -0
  21. package/lib/archive/db-ops/dedupe-cap/list-dedupe-cap-observations.js +87 -0
  22. package/lib/archive/db-ops/errors/insert-page-error.js +3 -1
  23. package/lib/archive/db-ops/lifecycle/init.d.ts +13 -6
  24. package/lib/archive/db-ops/lifecycle/init.js +30 -10
  25. package/lib/archive/db-ops/list-reconcile/record-list-reconcile-run.d.ts +22 -0
  26. package/lib/archive/db-ops/{inventory/record-inventory-run.js → list-reconcile/record-list-reconcile-run.js} +11 -10
  27. package/lib/archive/db-ops/pages/read/build-page-query.js +1 -1
  28. package/lib/archive/db-ops/pages/read/get-crawling-state.d.ts +9 -2
  29. package/lib/archive/db-ops/pages/read/get-crawling-state.js +11 -3
  30. package/lib/archive/db-ops/pages/reset/clear-page-derived-rows.d.ts +20 -0
  31. package/lib/archive/db-ops/pages/reset/clear-page-derived-rows.js +37 -0
  32. package/lib/archive/db-ops/pages/reset/repromote-external-pages.d.ts +3 -2
  33. package/lib/archive/db-ops/pages/reset/repromote-external-pages.js +17 -37
  34. package/lib/archive/db-ops/pages/reset/reset-failed-pages.d.ts +22 -1
  35. package/lib/archive/db-ops/pages/reset/reset-failed-pages.js +79 -33
  36. package/lib/archive/db-ops/pages/reset/reset-pages-by-urls.d.ts +78 -0
  37. package/lib/archive/db-ops/pages/reset/reset-pages-by-urls.js +146 -0
  38. package/lib/archive/db-ops/pages/reset/retryable-image-scan-codes.d.ts +24 -0
  39. package/lib/archive/db-ops/pages/reset/retryable-image-scan-codes.js +24 -0
  40. package/lib/archive/db-ops/pages/write/insert-inventory-content-items.js +2 -1
  41. package/lib/archive/db-ops/pages/write/insert-page.js +4 -2
  42. package/lib/archive/db-ops/pages/write/link-redirect-sources.js +3 -1
  43. package/lib/archive/db-ops/pages/write/record-redirect.js +1 -1
  44. package/lib/archive/db-ops/pages/write/set-skipped-page.js +3 -1
  45. package/lib/archive/db-ops/pages/write/update-page.d.ts +9 -1
  46. package/lib/archive/db-ops/pages/write/update-page.js +28 -6
  47. package/lib/archive/db-ops/resources/get-resource-url-list.d.ts +3 -6
  48. package/lib/archive/db-ops/resources/get-resource-url-list.js +10 -35
  49. package/lib/archive/init-schema.d.ts +1 -1
  50. package/lib/archive/init-schema.js +3 -2
  51. package/lib/archive/meta/compute-main-contents-denormalized.d.ts +11 -3
  52. package/lib/archive/meta/compute-main-contents-denormalized.js +14 -2
  53. package/lib/archive/meta/types.d.ts +24 -0
  54. package/lib/archive/migrate-content-items-is-metadata-only.d.ts +44 -0
  55. package/lib/archive/migrate-content-items-is-metadata-only.js +60 -0
  56. package/lib/archive/migrate-info-created-cwd.d.ts +17 -0
  57. package/lib/archive/migrate-info-created-cwd.js +36 -0
  58. package/lib/archive/migrate-inventory-runs-to-list-reconcile-runs.d.ts +34 -0
  59. package/lib/archive/migrate-inventory-runs-to-list-reconcile-runs.js +51 -0
  60. package/lib/archive/{migrate-inventory-runs-exclude-skipped.d.ts → migrate-list-reconcile-runs-exclude-skipped.d.ts} +8 -8
  61. package/lib/archive/{migrate-inventory-runs-exclude-skipped.js → migrate-list-reconcile-runs-exclude-skipped.js} +12 -12
  62. package/lib/archive/migrate-list-reconcile-runs-invalid-skipped.d.ts +16 -0
  63. package/lib/archive/migrate-list-reconcile-runs-invalid-skipped.js +35 -0
  64. package/lib/archive/migrate-page-meta-image-scan.d.ts +27 -0
  65. package/lib/archive/migrate-page-meta-image-scan.js +52 -0
  66. package/lib/archive/page.d.ts +12 -0
  67. package/lib/archive/page.js +16 -0
  68. package/lib/archive/types.d.ts +97 -10
  69. package/lib/compute-auto-retry-backoff-delay.d.ts +30 -0
  70. package/lib/compute-auto-retry-backoff-delay.js +36 -0
  71. package/lib/crawler/apply-crawl-runtime-options-patch.d.ts +32 -0
  72. package/lib/crawler/apply-crawl-runtime-options-patch.js +129 -0
  73. package/lib/crawler/crawler.d.ts +35 -3
  74. package/lib/crawler/crawler.js +486 -338
  75. package/lib/crawler/dedupe/build-dedupe-cap-observation.d.ts +74 -0
  76. package/lib/crawler/dedupe/build-dedupe-cap-observation.js +97 -0
  77. package/lib/crawler/dedupe/compute-meta-signature.d.ts +7 -5
  78. package/lib/crawler/dedupe/compute-meta-signature.js +0 -0
  79. package/lib/crawler/dedupe/compute-shape-key.d.ts +20 -0
  80. package/lib/crawler/dedupe/compute-shape-key.js +73 -1
  81. package/lib/crawler/dedupe/dedupe-cap-tracker.d.ts +6 -1
  82. package/lib/crawler/dedupe/dedupe-cap-tracker.js +6 -1
  83. package/lib/crawler/dedupe/resolve-og-url-mismatch.d.ts +7 -5
  84. package/lib/crawler/dedupe/resolve-og-url-mismatch.js +5 -3
  85. package/lib/crawler/dedupe/types.d.ts +18 -0
  86. package/lib/crawler/fetch-destination.js +1 -0
  87. package/lib/crawler/link-list.d.ts +9 -1
  88. package/lib/crawler/link-list.js +12 -2
  89. package/lib/crawler/link-to-page-data.js +1 -0
  90. package/lib/crawler/resource-to-page-data.js +1 -0
  91. package/lib/crawler/types.d.ts +75 -0
  92. package/lib/crawler-orchestrator.d.ts +171 -8
  93. package/lib/crawler-orchestrator.js +1177 -234
  94. package/lib/crawler.d.ts +3 -0
  95. package/lib/crawler.js +3 -0
  96. package/lib/delay-or-abort.d.ts +20 -0
  97. package/lib/delay-or-abort.js +35 -0
  98. package/lib/pending-urls-remain-error.d.ts +35 -0
  99. package/lib/pending-urls-remain-error.js +43 -0
  100. package/lib/recrawl-setup-phases.d.ts +38 -0
  101. package/lib/recrawl-setup-phases.js +52 -0
  102. package/lib/resume-setup-phases.d.ts +1 -1
  103. package/lib/resume-setup-phases.js +5 -1
  104. package/lib/retry-failed-setup-phases.d.ts +1 -1
  105. package/lib/setup-recovery-phase-labels.d.ts +17 -8
  106. package/lib/setup-recovery-phase-labels.js +17 -8
  107. package/lib/types.d.ts +62 -12
  108. package/lib/utils/compute-file-sha256.d.ts +1 -1
  109. package/lib/utils/compute-file-sha256.js +1 -1
  110. package/package.json +4 -4
  111. package/lib/archive/db-ops/inventory/record-inventory-run.d.ts +0 -21
  112. package/lib/archive/migrate-inventory-runs-invalid-skipped.d.ts +0 -16
  113. package/lib/archive/migrate-inventory-runs-invalid-skipped.js +0 -35
@@ -0,0 +1,49 @@
1
+ import type { Knex } from 'knex';
2
+ /**
3
+ * Reads every row a caller-supplied query matches, in ascending-`id`
4
+ * keyset-paginated chunks rather than one unbounded SELECT (issue #294): on
5
+ * a large archive, a single multi-second-to-minutes scan gives the caller
6
+ * no way to report progress mid-read. The accumulated result is identical
7
+ * to a single un-chunked SELECT — chunking exists purely to make the scan
8
+ * observable, not to bound memory (the mapped output is already fully
9
+ * materialised for the caller either way).
10
+ *
11
+ * Extracted from `getResourceUrlList` / `listDedupeCapObservations`, which
12
+ * had grown byte-for-byte identical pagination boilerplate (the `MAX(id)`
13
+ * probe, the `for (;;)` chunk loop, the `onProgress` shape) around two
14
+ * otherwise-unrelated queries. Callers own everything query-shaped
15
+ * (joins, `WHERE`, `SELECT`, chunk size via `.limit()` inside
16
+ * `buildQuery`) — this helper only owns the keyset-chunking mechanics
17
+ * common to both.
18
+ * @param knex - Knex query builder connected to the archive DB.
19
+ * @param idTable - The table `id` keys off, used for the O(1) `MAX(id)`
20
+ * probe that seeds `onProgress`'s total (only queried when `onProgress`
21
+ * is given).
22
+ * @param buildQuery - Builds one chunk's query given the last-seen `id`
23
+ * (`0` on the first call). Must filter on `id > lastId`, order by `id`
24
+ * ascending, and cap the row count (typically via `.limit()`) — this
25
+ * helper does not add any of those clauses itself. The resolved rows
26
+ * must each carry an `id` field so the next chunk's cursor can advance.
27
+ * @param mapRow - Transforms one raw row into the caller's output shape.
28
+ * @param onProgress - Called after each chunk, with the highest `id`
29
+ * scanned so far and the max `id` in `idTable`. Omit for no reporting.
30
+ * @returns Every matched row, mapped via `mapRow`, in `id` order.
31
+ * @example
32
+ * ```ts
33
+ * const urls = await keysetPaginateById(
34
+ * knex,
35
+ * 'resource_items',
36
+ * (lastId) =>
37
+ * knex('resource_items')
38
+ * .join('url_refs', 'url_refs.id', 'resource_items.url_id')
39
+ * .where('resource_items.id', '>', lastId)
40
+ * .orderBy('resource_items.id', 'asc')
41
+ * .limit(2000)
42
+ * .select('resource_items.id as id', 'url_refs.url as url'),
43
+ * (row) => row.url,
44
+ * );
45
+ * ```
46
+ */
47
+ export declare function keysetPaginateById<Row extends {
48
+ id: number;
49
+ }, Out>(knex: Knex, idTable: string, buildQuery: (lastId: number) => Knex.QueryBuilder, mapRow: (row: Row) => Out, onProgress?: (scannedUpToId: number, maxId: number) => void): Promise<Out[]>;
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Reads every row a caller-supplied query matches, in ascending-`id`
3
+ * keyset-paginated chunks rather than one unbounded SELECT (issue #294): on
4
+ * a large archive, a single multi-second-to-minutes scan gives the caller
5
+ * no way to report progress mid-read. The accumulated result is identical
6
+ * to a single un-chunked SELECT — chunking exists purely to make the scan
7
+ * observable, not to bound memory (the mapped output is already fully
8
+ * materialised for the caller either way).
9
+ *
10
+ * Extracted from `getResourceUrlList` / `listDedupeCapObservations`, which
11
+ * had grown byte-for-byte identical pagination boilerplate (the `MAX(id)`
12
+ * probe, the `for (;;)` chunk loop, the `onProgress` shape) around two
13
+ * otherwise-unrelated queries. Callers own everything query-shaped
14
+ * (joins, `WHERE`, `SELECT`, chunk size via `.limit()` inside
15
+ * `buildQuery`) — this helper only owns the keyset-chunking mechanics
16
+ * common to both.
17
+ * @param knex - Knex query builder connected to the archive DB.
18
+ * @param idTable - The table `id` keys off, used for the O(1) `MAX(id)`
19
+ * probe that seeds `onProgress`'s total (only queried when `onProgress`
20
+ * is given).
21
+ * @param buildQuery - Builds one chunk's query given the last-seen `id`
22
+ * (`0` on the first call). Must filter on `id > lastId`, order by `id`
23
+ * ascending, and cap the row count (typically via `.limit()`) — this
24
+ * helper does not add any of those clauses itself. The resolved rows
25
+ * must each carry an `id` field so the next chunk's cursor can advance.
26
+ * @param mapRow - Transforms one raw row into the caller's output shape.
27
+ * @param onProgress - Called after each chunk, with the highest `id`
28
+ * scanned so far and the max `id` in `idTable`. Omit for no reporting.
29
+ * @returns Every matched row, mapped via `mapRow`, in `id` order.
30
+ * @example
31
+ * ```ts
32
+ * const urls = await keysetPaginateById(
33
+ * knex,
34
+ * 'resource_items',
35
+ * (lastId) =>
36
+ * knex('resource_items')
37
+ * .join('url_refs', 'url_refs.id', 'resource_items.url_id')
38
+ * .where('resource_items.id', '>', lastId)
39
+ * .orderBy('resource_items.id', 'asc')
40
+ * .limit(2000)
41
+ * .select('resource_items.id as id', 'url_refs.url as url'),
42
+ * (row) => row.url,
43
+ * );
44
+ * ```
45
+ */
46
+ export async function keysetPaginateById(knex, idTable, buildQuery, mapRow, onProgress) {
47
+ // MAX() over the keyset column is an O(1) index-tail read; only fetched
48
+ // when someone is listening.
49
+ let maxId = 0;
50
+ if (onProgress) {
51
+ const [maxRow] = await knex(idTable).max({ max: 'id' });
52
+ maxId = maxRow?.max ?? 0;
53
+ }
54
+ const results = [];
55
+ let lastId = 0;
56
+ for (;;) {
57
+ const rows = (await buildQuery(lastId));
58
+ if (rows.length === 0) {
59
+ onProgress?.(maxId, maxId);
60
+ break;
61
+ }
62
+ lastId = rows.at(-1).id;
63
+ for (const row of rows) {
64
+ results.push(mapRow(row));
65
+ }
66
+ onProgress?.(Math.min(lastId, maxId), maxId);
67
+ }
68
+ return results;
69
+ }
@@ -1,5 +1,4 @@
1
- import type { WriteRefCaches } from './types.js';
2
- import type { PageSource } from '../../types.js';
1
+ import type { ResolveContentItemIdOptions, WriteRefCaches } from './types.js';
3
2
  import type { Knex } from 'knex';
4
3
  /**
5
4
  * Returns the `content_items.id` for `url`, inserting a placeholder row
@@ -23,6 +22,11 @@ import type { Knex } from 'knex';
23
22
  * inventory label. The cached `source` is updated in the same step so a
24
23
  * later hit does not re-issue the UPDATE.
25
24
  *
25
+ * **`is_metadata_only` promotion** (issue #369) is evaluated in the same
26
+ * pass as the crawled-wins downgrade, and both diffs are folded into a
27
+ * single `UPDATE` statement (not two) when either column actually
28
+ * changes — see {@link applyExistingRowUpdates}.
29
+ *
26
30
  * **Cache poisoning on rollback.** Entries cached inside a transaction
27
31
  * that later rolls back would point at ids that no longer exist. Every
28
32
  * write op that opens a multi-statement transaction around this function
@@ -44,18 +48,15 @@ import type { Knex } from 'knex';
44
48
  * @param caches - The connection's write-side id caches; mutated in place.
45
49
  * @param url - The URL to look up or insert (normalised
46
50
  * `withoutHashAndAuth` form, matching the legacy identity contract).
47
- * @param isExternal - Optional; recorded on new inserts only. `1` marks
48
- * the row as an external URL that will never be scraped as a target.
49
- * Defaults to `0` (in-scope) on insert, mirroring the legacy column
50
- * default.
51
- * @param source - Optional provenance label put on a newly-inserted row.
52
- * Omit to let the `content_items.source` DEFAULT (`'crawled'`) apply.
53
- * Pass `'crawled'` to arm the crawled-wins downgrade on existing
54
- * inventory-labelled rows.
51
+ * @param options - See {@link ResolveContentItemIdOptions}.
55
52
  * @returns The `content_items.id` of the existing or newly inserted row.
56
53
  * @throws {Error} When the upsert's `RETURNING` yields no row — should
57
54
  * not happen, so it surfaces as a hard error.
58
55
  * @example
59
- * const pageId = await resolveContentItemId(trx, caches, anchor.href, 1, 'crawled');
56
+ * const pageId = await resolveContentItemId(trx, caches, anchor.href, {
57
+ * isExternal: 1,
58
+ * source: 'crawled',
59
+ * isMetadataOnly: 1,
60
+ * });
60
61
  */
61
- export declare function resolveContentItemId(qb: Knex | Knex.Transaction, caches: WriteRefCaches, url: string, isExternal?: 0 | 1, source?: PageSource): Promise<number>;
62
+ export declare function resolveContentItemId(qb: Knex | Knex.Transaction, caches: WriteRefCaches, url: string, options?: ResolveContentItemIdOptions): Promise<number>;
@@ -21,6 +21,11 @@ import { upsertUrlRef } from './upsert-url-ref.js';
21
21
  * inventory label. The cached `source` is updated in the same step so a
22
22
  * later hit does not re-issue the UPDATE.
23
23
  *
24
+ * **`is_metadata_only` promotion** (issue #369) is evaluated in the same
25
+ * pass as the crawled-wins downgrade, and both diffs are folded into a
26
+ * single `UPDATE` statement (not two) when either column actually
27
+ * changes — see {@link applyExistingRowUpdates}.
28
+ *
24
29
  * **Cache poisoning on rollback.** Entries cached inside a transaction
25
30
  * that later rolls back would point at ids that no longer exist. Every
26
31
  * write op that opens a multi-statement transaction around this function
@@ -42,41 +47,45 @@ import { upsertUrlRef } from './upsert-url-ref.js';
42
47
  * @param caches - The connection's write-side id caches; mutated in place.
43
48
  * @param url - The URL to look up or insert (normalised
44
49
  * `withoutHashAndAuth` form, matching the legacy identity contract).
45
- * @param isExternal - Optional; recorded on new inserts only. `1` marks
46
- * the row as an external URL that will never be scraped as a target.
47
- * Defaults to `0` (in-scope) on insert, mirroring the legacy column
48
- * default.
49
- * @param source - Optional provenance label put on a newly-inserted row.
50
- * Omit to let the `content_items.source` DEFAULT (`'crawled'`) apply.
51
- * Pass `'crawled'` to arm the crawled-wins downgrade on existing
52
- * inventory-labelled rows.
50
+ * @param options - See {@link ResolveContentItemIdOptions}.
53
51
  * @returns The `content_items.id` of the existing or newly inserted row.
54
52
  * @throws {Error} When the upsert's `RETURNING` yields no row — should
55
53
  * not happen, so it surfaces as a hard error.
56
54
  * @example
57
- * const pageId = await resolveContentItemId(trx, caches, anchor.href, 1, 'crawled');
55
+ * const pageId = await resolveContentItemId(trx, caches, anchor.href, {
56
+ * isExternal: 1,
57
+ * source: 'crawled',
58
+ * isMetadataOnly: 1,
59
+ * });
58
60
  */
59
- export async function resolveContentItemId(qb, caches, url, isExternal, source) {
61
+ export async function resolveContentItemId(qb, caches, url, options) {
62
+ const { isExternal, source, isMetadataOnly } = options ?? {};
60
63
  const cached = caches.contentItems.get(url);
61
64
  if (cached !== undefined) {
62
- await applyCrawledWinsDowngrade(qb, cached, source);
65
+ await applyExistingRowUpdates(qb, cached, source, isMetadataOnly);
63
66
  return cached.id;
64
67
  }
65
68
  const urlId = await upsertUrlRef(qb, caches, url);
66
69
  const [record] = (await qb
67
- .select('id', 'source')
70
+ .select('id', 'source', 'is_metadata_only')
68
71
  .from('content_items')
69
72
  .where('url_id', urlId));
70
73
  if (record !== undefined) {
71
- const entry = { id: record.id, source: record.source };
72
- await applyCrawledWinsDowngrade(qb, entry, source);
74
+ const entry = {
75
+ id: record.id,
76
+ source: record.source,
77
+ isMetadataOnly: record.is_metadata_only,
78
+ };
79
+ await applyExistingRowUpdates(qb, entry, source, isMetadataOnly);
73
80
  caches.contentItems.set(url, entry);
74
81
  return entry.id;
75
82
  }
76
- const insertedRows = await qb.raw(`INSERT INTO content_items (url_id, scraped, is_target, is_external${source === undefined ? '' : ', source'})
77
- VALUES (?, 0, 0, ?${source === undefined ? '' : ', ?'})
78
- ON CONFLICT(url_id) DO UPDATE SET url_id = url_id
79
- RETURNING id, source`, source === undefined ? [urlId, isExternal ?? 0] : [urlId, isExternal ?? 0, source]);
83
+ const insertedRows = await qb.raw(`INSERT INTO content_items (url_id, scraped, is_target, is_external, is_metadata_only${source === undefined ? '' : ', source'})
84
+ VALUES (?, 0, 0, ?, ?${source === undefined ? '' : ', ?'})
85
+ ON CONFLICT(url_id) DO UPDATE SET url_id = url_id
86
+ RETURNING id, source, is_metadata_only`, source === undefined
87
+ ? [urlId, isExternal ?? 0, isMetadataOnly ?? 0]
88
+ : [urlId, isExternal ?? 0, isMetadataOnly ?? 0, source]);
80
89
  const inserted = insertedRows[0];
81
90
  if (inserted === undefined) {
82
91
  throw new Error(`Failed to insert a new content item: ${url}`);
@@ -84,28 +93,51 @@ export async function resolveContentItemId(qb, caches, url, isExternal, source)
84
93
  const insertedEntry = {
85
94
  id: inserted.id,
86
95
  source: inserted.source,
96
+ isMetadataOnly: inserted.is_metadata_only,
87
97
  };
88
98
  // A conflict means a concurrent writer created the row between this
89
- // function's SELECT miss and the INSERT — the returned `source` is that
90
- // row's value, so the downgrade must be evaluated exactly as on the
91
- // SELECT-hit path.
92
- await applyCrawledWinsDowngrade(qb, insertedEntry, source);
99
+ // function's SELECT miss and the INSERT — the returned `source` /
100
+ // `is_metadata_only` are that row's values, so both follow-ups must be
101
+ // evaluated exactly as on the SELECT-hit path.
102
+ await applyExistingRowUpdates(qb, insertedEntry, source, isMetadataOnly);
93
103
  caches.contentItems.set(url, insertedEntry);
94
104
  return insertedEntry.id;
95
105
  }
96
106
  /**
97
- * Fires the crawled-wins downgrade when a `'crawled'`-lineage resolution
98
- * lands on a row whose last-known `source` is an inventory label, and
99
- * keeps the cache entry in sync so the UPDATE runs at most once per
100
- * (connection, row).
107
+ * Applies both the crawled-wins `source` downgrade and the `is_metadata_only`
108
+ * promotion (issue #369) to an already-resolved row in a single `UPDATE`,
109
+ * and keeps the cache entry in sync so a repeat call with the same values
110
+ * does not re-issue any write.
111
+ *
112
+ * Folded into one function (and one statement) rather than two independent
113
+ * ones: `resolveContentItemId` runs once per anchor on every scraped page
114
+ * across a whole crawl, so a page whose anchors trip both conditions would
115
+ * otherwise pay two round trips instead of one.
101
116
  * @param qb - Knex instance or transaction.
102
117
  * @param entry - The cached identity to check and mutate.
103
- * @param source - The resolution's lineage label.
118
+ * @param source - The resolution's lineage label, if any — see
119
+ * {@link ResolveContentItemIdOptions.source}.
120
+ * @param isMetadataOnly - The resolution's explicit scrape-depth opinion, if
121
+ * any — see {@link ResolveContentItemIdOptions.isMetadataOnly}. Omitted by
122
+ * every caller except `replaceAnchorEdges`, which always has an opinion,
123
+ * so this is a no-op for the rest.
104
124
  */
105
- async function applyCrawledWinsDowngrade(qb, entry, source) {
106
- if (source !== 'crawled' || entry.source === 'crawled') {
125
+ async function applyExistingRowUpdates(qb, entry, source, isMetadataOnly) {
126
+ const updates = {};
127
+ if (source === 'crawled' && entry.source !== 'crawled') {
128
+ updates.source = 'crawled';
129
+ }
130
+ if (isMetadataOnly !== undefined && entry.isMetadataOnly !== isMetadataOnly) {
131
+ updates.is_metadata_only = isMetadataOnly;
132
+ }
133
+ if (Object.keys(updates).length === 0) {
107
134
  return;
108
135
  }
109
- await qb('content_items').where('id', entry.id).update({ source: 'crawled' });
110
- entry.source = 'crawled';
136
+ await qb('content_items').where('id', entry.id).update(updates);
137
+ if (updates.source !== undefined) {
138
+ entry.source = updates.source;
139
+ }
140
+ if (updates.is_metadata_only !== undefined) {
141
+ entry.isMetadataOnly = updates.is_metadata_only;
142
+ }
111
143
  }
@@ -12,6 +12,12 @@ export interface ContentItemCacheEntry {
12
12
  id: number;
13
13
  /** `content_items.source` as last observed / written by this process. */
14
14
  source: PageSource;
15
+ /**
16
+ * `content_items.is_metadata_only` as last observed / written by this
17
+ * process. Lets {@link ../_shared/resolve-content-item-id.ts} skip a
18
+ * redundant `UPDATE` when a later resolution recomputes the same value.
19
+ */
20
+ isMetadataOnly: 0 | 1;
15
21
  }
16
22
  /**
17
23
  * In-process id caches shared across one archive connection's write path.
@@ -53,3 +59,36 @@ export interface WriteRefCaches {
53
59
  */
54
60
  headers: HeaderTableCaches | null;
55
61
  }
62
+ /**
63
+ * Optional per-call flags for {@link ../_shared/resolve-content-item-id.ts}.
64
+ */
65
+ export interface ResolveContentItemIdOptions {
66
+ /**
67
+ * `1` marks the row as an external URL that will never be scraped as a
68
+ * target. Recorded on new inserts only. Defaults to `0` (in-scope) on
69
+ * insert, mirroring the legacy column default.
70
+ */
71
+ isExternal?: 0 | 1;
72
+ /**
73
+ * Provenance label put on a newly-inserted row. Omit to let the
74
+ * `content_items.source` DEFAULT (`'crawled'`) apply. Pass `'crawled'`
75
+ * to arm the crawled-wins downgrade on existing inventory-labelled rows.
76
+ */
77
+ source?: PageSource;
78
+ /**
79
+ * `1` marks the row as fated for a metadata-only (title-only) scrape
80
+ * rather than a full one, `0` explicitly marks it as a full-scrape
81
+ * target. Omit (the default for every caller except
82
+ * `replaceAnchorEdges`) to leave the column untouched — those callers
83
+ * (redirects, resources, errors, skipped pages, console logs) have no
84
+ * opinion on scrape depth and must not clobber a value an
85
+ * anchor-discovery call already established.
86
+ * `!options.recursive || anchor.isExternal` is a pure function of the
87
+ * URL within one crawl session (`recursive` is session-constant,
88
+ * `isExternal` depends only on scope matching), so every anchor-path
89
+ * call for the same URL always recomputes the same value — there is no
90
+ * real conflict to arbitrate between two *opinionated* calls, only
91
+ * between an opinionated call and the unopinionated majority.
92
+ */
93
+ isMetadataOnly?: 0 | 1;
94
+ }
@@ -33,10 +33,13 @@ function parseLegacyCorruptedUrl(url) {
33
33
  * version-gated migration script (see `scripts/migrate-to-0.13.mjs`). This
34
34
  * function relies on the exception documented in ARCHITECTURE.md's
35
35
  * invariants list: a nullable, additive column on a table with a single
36
- * write path may self-heal here without a version bump, because
37
- * `replaceAnalysisViolations` is that single write path for
38
- * `analysis_violations`. Runs before the transaction below so the DDL is
39
- * not mixed with the DML rewrite.
36
+ * INSERT path may self-heal here without a version bump, because
37
+ * `replaceAnalysisViolations` is that single INSERT path for
38
+ * `analysis_violations` `resetPagesByUrls` (`db-ops/pages/reset/`) also
39
+ * writes to this table, but only ever DELETEs by `page_id`, so it never
40
+ * needs to know about `line`/`col` and cannot violate this self-heal
41
+ * argument. Runs before the transaction below so the DDL is not mixed with
42
+ * the DML rewrite.
40
43
  * @param knex - Knex query builder connected to the archive DB.
41
44
  */
42
45
  async function ensureLineColColumns(knex) {
@@ -0,0 +1,11 @@
1
+ import type { Knex } from 'knex';
2
+ /**
3
+ * Retrieves the working directory recorded when this stub's crawl session
4
+ * was created, so {@link import('../../archive.js').default.resume} can
5
+ * reconstruct the completed archive's output path independent of the
6
+ * directory `crawl --resume` happens to be invoked from.
7
+ * @param knex - Knex query builder connected to the archive DB.
8
+ * @returns The recorded cwd, or `null` for a stub created before this column
9
+ * existed (or created by a code path that never stamps it, i.e. `resume`).
10
+ */
11
+ export declare function getCreatedCwd(knex: Knex): Promise<string | null>;
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Retrieves the working directory recorded when this stub's crawl session
3
+ * was created, so {@link import('../../archive.js').default.resume} can
4
+ * reconstruct the completed archive's output path independent of the
5
+ * directory `crawl --resume` happens to be invoked from.
6
+ * @param knex - Knex query builder connected to the archive DB.
7
+ * @returns The recorded cwd, or `null` for a stub created before this column
8
+ * existed (or created by a code path that never stamps it, i.e. `resume`).
9
+ */
10
+ export async function getCreatedCwd(knex) {
11
+ const [row] = await knex.select('createdCwd').from('info');
12
+ return row?.createdCwd ?? null;
13
+ }
@@ -24,4 +24,5 @@ export const INFO_COLUMN_ALLOWLIST = new Set([
24
24
  'userAgent',
25
25
  'ignoreRobots',
26
26
  'mainContentSelector',
27
+ 'createdCwd',
27
28
  ]);
@@ -0,0 +1,57 @@
1
+ import type { DedupeCapObservationRow } from '../../types.js';
2
+ import type { Knex } from 'knex';
3
+ /**
4
+ * Reads back every previously-scraped internal page's raw fields in the
5
+ * exact population a live crawl's `DedupeCapTracker#observe` call site
6
+ * would have fed it, so `CrawlerOrchestrator`'s five resuming-session
7
+ * methods (`append`/`inventory`/`recrawl`/`retryFailed`/`resume`) can
8
+ * replay a prior session's Misra-Gries observations into a fresh tracker
9
+ * instance (see `buildDedupeCapObservation`) instead of restarting every
10
+ * not-yet-capped shape's counter at 0.
11
+ *
12
+ * The WHERE clause reproduces the live observation gate
13
+ * (`!isExternal && !isMetadataOnly && html.length > 0`) using columns that
14
+ * survive a process restart:
15
+ *
16
+ * - `is_external = 0` — external pages carry no useful signal (same
17
+ * exclusion `computeMetaSignature`'s design already assumes).
18
+ * - `is_target = 1` — the archived equivalent of "not metadata-only": a
19
+ * metadata-only fetch never invokes the browser and is written with
20
+ * `is_target = 0` (see `Crawler`'s metadata-only branch), matching the
21
+ * live gate's `!isMetadataOnly` exactly.
22
+ * - `redirect_dest_id IS NULL` — a redirect source's own body was never
23
+ * rendered; only the destination page (a separate row) was.
24
+ * - `is_skipped IS NULL OR is_skipped = 0` — a skipped placeholder row
25
+ * (`source = 'inventory-seed'` etc.) has no rendered body either.
26
+ * - `pm.body_hash IS NOT NULL` — the archived proxy for `html.length > 0`:
27
+ * `update-page.ts` only ever writes a non-null `body_hash` on the same
28
+ * `writeHtml && html.length > 0` gate the live site's `precomputedBodyHash`
29
+ * computation uses, so this column faithfully reconstructs that
30
+ * condition. Also excludes pre-`body_hash`-migration legacy archives'
31
+ * un-backfilled rows (`migrate-page-meta-body-hash.ts` adds the column on
32
+ * every writer open but does not compute values for existing rows) —
33
+ * understating the replayed count is safe (never over-counts), so no
34
+ * column-presence guard is needed here.
35
+ *
36
+ * Deliberately does NOT exclude rows already marked
37
+ * `content_items.dedupe_cap_event_id` (the post-hoc marking column) —
38
+ * unlike `body_hash`, that column is only backfilled during a
39
+ * `viewer-build`, not written during a live crawl, so filtering on it here
40
+ * would silently diverge from what the live crawl-time gate actually saw.
41
+ * `DedupeCapTracker#observe` is an O(1) no-op for a shape already in its
42
+ * sticky set regardless, so replaying an already-capped shape's rows costs
43
+ * a discarded observation, never an incorrect one.
44
+ *
45
+ * Rows are read in `ci.id` order (insertion / discovery order) via
46
+ * `keysetPaginateById` — Misra-Gries counting is order-dependent, so
47
+ * replaying in the archive's original discovery order reproduces what the
48
+ * tracker's state would look like had the process never restarted, subject
49
+ * to the same `mapCap` LRU eviction.
50
+ * @param knex - Knex query builder connected to the archive DB.
51
+ * @param onProgress - Called after each chunk, with the highest `ci.id`
52
+ * scanned so far and the max `ci.id` in the table — mirrors
53
+ * `getResourceUrlList`'s progress shape (`SetupProgressCallbacks.onChunkProgress`).
54
+ * Omit for no reporting (tests, and callers that don't need it).
55
+ * @returns Every qualifying page's raw fields, in `ci.id` order.
56
+ */
57
+ export declare function listDedupeCapObservations(knex: Knex, onProgress?: (scannedUpToId: number, maxId: number) => void): Promise<DedupeCapObservationRow[]>;
@@ -0,0 +1,87 @@
1
+ import { keysetPaginateById } from '../_shared/keyset-paginate-by-id.js';
2
+ const READ_CHUNK_SIZE = 2000;
3
+ /**
4
+ * Reads back every previously-scraped internal page's raw fields in the
5
+ * exact population a live crawl's `DedupeCapTracker#observe` call site
6
+ * would have fed it, so `CrawlerOrchestrator`'s five resuming-session
7
+ * methods (`append`/`inventory`/`recrawl`/`retryFailed`/`resume`) can
8
+ * replay a prior session's Misra-Gries observations into a fresh tracker
9
+ * instance (see `buildDedupeCapObservation`) instead of restarting every
10
+ * not-yet-capped shape's counter at 0.
11
+ *
12
+ * The WHERE clause reproduces the live observation gate
13
+ * (`!isExternal && !isMetadataOnly && html.length > 0`) using columns that
14
+ * survive a process restart:
15
+ *
16
+ * - `is_external = 0` — external pages carry no useful signal (same
17
+ * exclusion `computeMetaSignature`'s design already assumes).
18
+ * - `is_target = 1` — the archived equivalent of "not metadata-only": a
19
+ * metadata-only fetch never invokes the browser and is written with
20
+ * `is_target = 0` (see `Crawler`'s metadata-only branch), matching the
21
+ * live gate's `!isMetadataOnly` exactly.
22
+ * - `redirect_dest_id IS NULL` — a redirect source's own body was never
23
+ * rendered; only the destination page (a separate row) was.
24
+ * - `is_skipped IS NULL OR is_skipped = 0` — a skipped placeholder row
25
+ * (`source = 'inventory-seed'` etc.) has no rendered body either.
26
+ * - `pm.body_hash IS NOT NULL` — the archived proxy for `html.length > 0`:
27
+ * `update-page.ts` only ever writes a non-null `body_hash` on the same
28
+ * `writeHtml && html.length > 0` gate the live site's `precomputedBodyHash`
29
+ * computation uses, so this column faithfully reconstructs that
30
+ * condition. Also excludes pre-`body_hash`-migration legacy archives'
31
+ * un-backfilled rows (`migrate-page-meta-body-hash.ts` adds the column on
32
+ * every writer open but does not compute values for existing rows) —
33
+ * understating the replayed count is safe (never over-counts), so no
34
+ * column-presence guard is needed here.
35
+ *
36
+ * Deliberately does NOT exclude rows already marked
37
+ * `content_items.dedupe_cap_event_id` (the post-hoc marking column) —
38
+ * unlike `body_hash`, that column is only backfilled during a
39
+ * `viewer-build`, not written during a live crawl, so filtering on it here
40
+ * would silently diverge from what the live crawl-time gate actually saw.
41
+ * `DedupeCapTracker#observe` is an O(1) no-op for a shape already in its
42
+ * sticky set regardless, so replaying an already-capped shape's rows costs
43
+ * a discarded observation, never an incorrect one.
44
+ *
45
+ * Rows are read in `ci.id` order (insertion / discovery order) via
46
+ * `keysetPaginateById` — Misra-Gries counting is order-dependent, so
47
+ * replaying in the archive's original discovery order reproduces what the
48
+ * tracker's state would look like had the process never restarted, subject
49
+ * to the same `mapCap` LRU eviction.
50
+ * @param knex - Knex query builder connected to the archive DB.
51
+ * @param onProgress - Called after each chunk, with the highest `ci.id`
52
+ * scanned so far and the max `ci.id` in the table — mirrors
53
+ * `getResourceUrlList`'s progress shape (`SetupProgressCallbacks.onChunkProgress`).
54
+ * Omit for no reporting (tests, and callers that don't need it).
55
+ * @returns Every qualifying page's raw fields, in `ci.id` order.
56
+ */
57
+ export async function listDedupeCapObservations(knex, onProgress) {
58
+ return keysetPaginateById(knex, 'content_items', (lastId) => knex('content_items as ci')
59
+ .join('url_refs as ur', 'ur.id', 'ci.url_id')
60
+ .join('page_meta as pm', 'pm.page_id', 'ci.id')
61
+ .leftJoin('text_refs as title_ref', 'title_ref.id', 'pm.title_text_id')
62
+ .leftJoin('text_refs as description_ref', 'description_ref.id', 'pm.description_text_id')
63
+ .leftJoin('text_refs as og_title_ref', 'og_title_ref.id', 'pm.og_title_text_id')
64
+ .leftJoin('url_refs as og_url_ur', 'og_url_ur.id', 'pm.og_url_id')
65
+ .where('ci.id', '>', lastId)
66
+ .andWhere('ci.scraped', 1)
67
+ .andWhere('ci.is_external', 0)
68
+ .andWhere('ci.is_target', 1)
69
+ .whereNull('ci.redirect_dest_id')
70
+ .where((qb) => {
71
+ qb.whereNull('ci.is_skipped').orWhere('ci.is_skipped', 0);
72
+ })
73
+ .whereNotNull('pm.body_hash')
74
+ .orderBy('ci.id', 'asc')
75
+ .limit(READ_CHUNK_SIZE)
76
+ .select('ci.id as id', 'ur.url as url', 'title_ref.text as title', 'description_ref.text as description', 'og_title_ref.text as ogTitle', 'og_url_ur.url as ogUrl', 'pm.body_hash as bodyHash'), (row) => ({
77
+ url: row.url,
78
+ title: row.title,
79
+ description: row.description,
80
+ ogTitle: row.ogTitle,
81
+ ogUrl: row.ogUrl,
82
+ // libsql returns BLOB columns as `Uint8Array`, not `Buffer` —
83
+ // `DedupeCapTracker#observe` compares hashes via `Buffer#equals`,
84
+ // so this must be a real `Buffer`.
85
+ bodyHash: Buffer.from(row.bodyHash),
86
+ }), onProgress);
87
+ }
@@ -18,7 +18,9 @@ import { resolveContentItemId } from '../_shared/resolve-content-item-id.js';
18
18
  * @param isExternal - Whether the URL is external. Defaults to `false`.
19
19
  */
20
20
  export async function insertPageError(knex, caches, url, phase, message, isExternal = false) {
21
- const pageId = await resolveContentItemId(knex, caches, url, isExternal ? 1 : 0);
21
+ const pageId = await resolveContentItemId(knex, caches, url, {
22
+ isExternal: isExternal ? 1 : 0,
23
+ });
22
24
  await knex('page_errors').insert({
23
25
  pageId,
24
26
  phase,
@@ -2,8 +2,8 @@ import type { Knex } from 'knex';
2
2
  /**
3
3
  * Initializes the database schema if tables do not exist, then runs the
4
4
  * remaining lightweight migrations (`info.roots`, `info.mainContentSelector`,
5
- * `page_meta.main_content_*`, `inventory_runs.invalid_skipped`,
6
- * `inventory_runs.exclude_skipped`).
5
+ * `page_meta.main_content_*`, `list_reconcile_runs.invalid_skipped`,
6
+ * `list_reconcile_runs.exclude_skipped`).
7
7
  *
8
8
  * There is deliberately no per-table *table-creation* migration chain here:
9
9
  * `assertCompatibleVersion` (called below, before any schema work) rejects
@@ -18,9 +18,10 @@ import type { Knex } from 'knex';
18
18
  * `CREATE TABLE IF NOT EXISTS` is a no-op once the table is present. Column
19
19
  * additions to an existing 0.13 table are therefore the one case that still
20
20
  * needs an explicit `hasColumn`-guarded `ALTER TABLE` here (`migrateInfoRoots`,
21
- * `migrateMainContentsColumns`, `migratePageMetaBodyHash`,
21
+ * `migrateInfoCreatedCwd`, `migrateMainContentsColumns`, `migratePageMetaBodyHash`,
22
22
  * `migratePageMetaConsoleErrorCount`, `migratePageMetaCustomElementCount`,
23
- * `migrateContentItemsAliasOfId`,
23
+ * `migratePageMetaImageScan`, `migrateContentItemsAliasOfId`,
24
+ * `migrateContentItemsIsMetadataOnly`,
24
25
  *
25
26
  * `migratePageTagsToPageTechnologies` is the one exception to "column adds
26
27
  * only": it converts `page_tags` (removed) rows into `technology_signals`/
@@ -28,8 +29,14 @@ import type { Knex } from 'knex';
28
29
  * not a column add, but it belongs in this same boot phase for the same
29
30
  * reason (self-healing an old archive's schema before any reader runs).
30
31
  *
31
- * `migrateContentItemsDedupeCapEventId`, `migrateInventoryRunsInvalidSkipped`,
32
- * `migrateInventoryRunsExcludeSkipped`) rather than a DDL-string change alone.
32
+ * `migrateContentItemsDedupeCapEventId`, `migrateListReconcileRunsInvalidSkipped`,
33
+ * `migrateListReconcileRunsExcludeSkipped`) rather than a DDL-string change alone.
34
+ *
35
+ * `migrateInventoryRunsToListReconcileRuns` runs BEFORE `initSchema`
36
+ * (unlike every other migration here) — see its own JSDoc for why: a
37
+ * plain rename must land before `createAdjunctTables`'s
38
+ * `IF NOT EXISTS` guard would otherwise create an empty `list_reconcile_runs`
39
+ * and strand the old table's rows.
33
40
  *
34
41
  * `closeStaleOpenNetworkOutages` is not a schema migration (no columns
35
42
  * change) but belongs at this same boot phase for the same reason the