@nitpicker/crawler 0.15.0 → 0.17.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 (69) hide show
  1. package/lib/archive/archive.d.ts +52 -1
  2. package/lib/archive/archive.js +68 -0
  3. package/lib/archive/create-adjunct-tables.d.ts +3 -0
  4. package/lib/archive/create-adjunct-tables.js +43 -0
  5. package/lib/archive/create-entity-tables.js +10 -0
  6. package/lib/archive/database.d.ts +40 -1
  7. package/lib/archive/database.js +54 -0
  8. package/lib/archive/db-ops/config/get-config.js +1 -0
  9. package/lib/archive/db-ops/dedupe-cap/accumulate-dedupe-cap-rejected-count.d.ts +18 -0
  10. package/lib/archive/db-ops/dedupe-cap/accumulate-dedupe-cap-rejected-count.js +23 -0
  11. package/lib/archive/db-ops/dedupe-cap/finalize-dedupe-cap-event.d.ts +12 -0
  12. package/lib/archive/db-ops/dedupe-cap/finalize-dedupe-cap-event.js +15 -0
  13. package/lib/archive/db-ops/dedupe-cap/insert-dedupe-cap-event.d.ts +14 -0
  14. package/lib/archive/db-ops/dedupe-cap/insert-dedupe-cap-event.js +30 -0
  15. package/lib/archive/db-ops/dedupe-cap/list-dedupe-cap-shape-keys.d.ts +21 -0
  16. package/lib/archive/db-ops/dedupe-cap/list-dedupe-cap-shape-keys.js +27 -0
  17. package/lib/archive/db-ops/inventory/record-inventory-run.js +1 -0
  18. package/lib/archive/db-ops/lifecycle/init.d.ts +4 -2
  19. package/lib/archive/db-ops/lifecycle/init.js +12 -2
  20. package/lib/archive/db-ops/pages/write/insert-inventory-content-items.d.ts +38 -0
  21. package/lib/archive/db-ops/pages/write/insert-inventory-content-items.js +59 -0
  22. package/lib/archive/db-ops/pages/write/insert-inventory-seeds.d.ts +5 -6
  23. package/lib/archive/db-ops/pages/write/insert-inventory-seeds.js +17 -41
  24. package/lib/archive/db-ops/pages/write/insert-inventory-skipped-pages.d.ts +42 -0
  25. package/lib/archive/db-ops/pages/write/insert-inventory-skipped-pages.js +56 -0
  26. package/lib/archive/migrate-content-items-dedupe-cap-event-id.d.ts +41 -0
  27. package/lib/archive/migrate-content-items-dedupe-cap-event-id.js +51 -0
  28. package/lib/archive/migrate-entity-tables.d.ts +10 -0
  29. package/lib/archive/migrate-entity-tables.js +10 -0
  30. package/lib/archive/migrate-inventory-runs-exclude-skipped.d.ts +20 -0
  31. package/lib/archive/migrate-inventory-runs-exclude-skipped.js +33 -0
  32. package/lib/archive/populate-entity-tables/test-utils/setup-entities-db.d.ts +7 -0
  33. package/lib/archive/populate-entity-tables/test-utils/setup-entities-db.js +9 -0
  34. package/lib/archive/types.d.ts +16 -0
  35. package/lib/classify-error-kind.d.ts +1 -0
  36. package/lib/classify-error-kind.js +14 -0
  37. package/lib/crawler/assert-chrome-installed.d.ts +24 -0
  38. package/lib/crawler/assert-chrome-installed.js +43 -0
  39. package/lib/crawler/crawler.d.ts +12 -0
  40. package/lib/crawler/crawler.js +239 -29
  41. package/lib/crawler/decode-auth-credential.d.ts +29 -0
  42. package/lib/crawler/decode-auth-credential.js +39 -0
  43. package/lib/crawler/dedupe/compute-meta-signature.d.ts +30 -0
  44. package/lib/crawler/dedupe/compute-meta-signature.js +0 -0
  45. package/lib/crawler/dedupe/compute-shape-key.d.ts +37 -0
  46. package/lib/crawler/dedupe/compute-shape-key.js +56 -0
  47. package/lib/crawler/dedupe/dedupe-cap-tracker.d.ts +84 -0
  48. package/lib/crawler/dedupe/dedupe-cap-tracker.js +185 -0
  49. package/lib/crawler/dedupe/is-predicted-content-duplicate.d.ts +24 -0
  50. package/lib/crawler/dedupe/is-predicted-content-duplicate.js +26 -0
  51. package/lib/crawler/dedupe/is-shape-capped.d.ts +10 -0
  52. package/lib/crawler/dedupe/is-shape-capped.js +12 -0
  53. package/lib/crawler/dedupe/resolve-og-url-mismatch.d.ts +31 -0
  54. package/lib/crawler/dedupe/resolve-og-url-mismatch.js +40 -0
  55. package/lib/crawler/dedupe/types.d.ts +42 -0
  56. package/lib/crawler/dedupe/types.js +1 -0
  57. package/lib/crawler/fetch-destination.js +14 -2
  58. package/lib/crawler/generate-predicted-urls.d.ts +12 -0
  59. package/lib/crawler/generate-predicted-urls.js +33 -2
  60. package/lib/crawler/is-puppeteer-fallback-candidate.js +3 -0
  61. package/lib/crawler/types.d.ts +38 -0
  62. package/lib/crawler-orchestrator.d.ts +39 -10
  63. package/lib/crawler-orchestrator.js +187 -23
  64. package/lib/crawler.d.ts +2 -0
  65. package/lib/crawler.js +2 -0
  66. package/lib/permanent-error-kinds.d.ts +9 -4
  67. package/lib/permanent-error-kinds.js +10 -4
  68. package/lib/types.d.ts +4 -1
  69. package/package.json +3 -3
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Every distinct `dedupe_cap_events.shape_key` recorded in this archive —
3
+ * used by `CrawlerOrchestrator` to preload `DedupeCapTracker`'s sticky set
4
+ * on `--resume` / `--append` / `--retry-failed` / `--inventory`, so a trap
5
+ * this crawl already paid the cost of discovering once is not re-admitted
6
+ * in a later session. Fresh (non-resuming) crawls do not call this — there
7
+ * is no archive history to seed from.
8
+ *
9
+ * Unlike `listDnsBurnedHostCandidates`, no additional exclusion logic is
10
+ * needed: once `DedupeCapTracker` confirms a shape as a trap, it stays
11
+ * confirmed — there is no equivalent of "the host might have recovered
12
+ * since".
13
+ *
14
+ * Returns `[]` on legacy archives that pre-date the `dedupe_cap_events`
15
+ * table (self-healed on next writer open, so this is never a permanent
16
+ * state) or that have recorded no capped shapes.
17
+ * @param knex - Knex query builder connected to the archive DB.
18
+ * @returns Distinct shape keys already confirmed capped.
19
+ */
20
+ export async function listDedupeCapShapeKeys(knex) {
21
+ const hasTable = await knex.schema.hasTable('dedupe_cap_events');
22
+ if (!hasTable) {
23
+ return [];
24
+ }
25
+ const rows = (await knex('dedupe_cap_events').distinct('shape_key'));
26
+ return rows.map((row) => row.shape_key);
27
+ }
@@ -27,6 +27,7 @@ export async function recordInventoryRun(knex, meta) {
27
27
  new_pages: meta.new_pages ?? null,
28
28
  new_resources: meta.new_resources ?? null,
29
29
  scope_skipped: meta.scope_skipped ?? null,
30
+ exclude_skipped: meta.exclude_skipped ?? null,
30
31
  invalid_skipped: meta.invalid_skipped ?? null,
31
32
  notes: meta.notes ?? null,
32
33
  })
@@ -2,7 +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`).
5
+ * `page_meta.main_content_*`, `inventory_runs.invalid_skipped`,
6
+ * `inventory_runs.exclude_skipped`).
6
7
  *
7
8
  * There is deliberately no per-table *table-creation* migration chain here:
8
9
  * `assertCompatibleVersion` (called below, before any schema work) rejects
@@ -19,7 +20,8 @@ import type { Knex } from 'knex';
19
20
  * needs an explicit `hasColumn`-guarded `ALTER TABLE` here (`migrateInfoRoots`,
20
21
  * `migrateMainContentsColumns`, `migratePageMetaBodyHash`,
21
22
  * `migratePageMetaConsoleErrorCount`, `migrateContentItemsAliasOfId`,
22
- * `migrateInventoryRunsInvalidSkipped`) rather than a DDL-string change alone.
23
+ * `migrateContentItemsDedupeCapEventId`, `migrateInventoryRunsInvalidSkipped`,
24
+ * `migrateInventoryRunsExcludeSkipped`) rather than a DDL-string change alone.
23
25
  *
24
26
  * `closeStaleOpenNetworkOutages` is not a schema migration (no columns
25
27
  * change) but belongs at this same boot phase for the same reason the
@@ -1,8 +1,10 @@
1
1
  import { applyConnectionPragmas, initSchema } from '../../init-schema.js';
2
2
  import { assertCompatibleVersion } from '../../meta/assert-compatible-version.js';
3
3
  import { migrateContentItemsAliasOfId } from '../../migrate-content-items-alias-of-id.js';
4
+ import { migrateContentItemsDedupeCapEventId } from '../../migrate-content-items-dedupe-cap-event-id.js';
4
5
  import { migrateInfoMainContentSelector } from '../../migrate-info-main-content-selector.js';
5
6
  import { migrateInfoRoots } from '../../migrate-info-roots.js';
7
+ import { migrateInventoryRunsExcludeSkipped } from '../../migrate-inventory-runs-exclude-skipped.js';
6
8
  import { migrateInventoryRunsInvalidSkipped } from '../../migrate-inventory-runs-invalid-skipped.js';
7
9
  import { migrateMainContentsColumns } from '../../migrate-main-contents-columns.js';
8
10
  import { migratePageMetaBodyHash } from '../../migrate-page-meta-body-hash.js';
@@ -11,7 +13,8 @@ import { closeStaleOpenNetworkOutages } from '../outages/close-stale-open-networ
11
13
  /**
12
14
  * Initializes the database schema if tables do not exist, then runs the
13
15
  * remaining lightweight migrations (`info.roots`, `info.mainContentSelector`,
14
- * `page_meta.main_content_*`, `inventory_runs.invalid_skipped`).
16
+ * `page_meta.main_content_*`, `inventory_runs.invalid_skipped`,
17
+ * `inventory_runs.exclude_skipped`).
15
18
  *
16
19
  * There is deliberately no per-table *table-creation* migration chain here:
17
20
  * `assertCompatibleVersion` (called below, before any schema work) rejects
@@ -28,7 +31,8 @@ import { closeStaleOpenNetworkOutages } from '../outages/close-stale-open-networ
28
31
  * needs an explicit `hasColumn`-guarded `ALTER TABLE` here (`migrateInfoRoots`,
29
32
  * `migrateMainContentsColumns`, `migratePageMetaBodyHash`,
30
33
  * `migratePageMetaConsoleErrorCount`, `migrateContentItemsAliasOfId`,
31
- * `migrateInventoryRunsInvalidSkipped`) rather than a DDL-string change alone.
34
+ * `migrateContentItemsDedupeCapEventId`, `migrateInventoryRunsInvalidSkipped`,
35
+ * `migrateInventoryRunsExcludeSkipped`) rather than a DDL-string change alone.
32
36
  *
33
37
  * `closeStaleOpenNetworkOutages` is not a schema migration (no columns
34
38
  * change) but belongs at this same boot phase for the same reason the
@@ -66,6 +70,12 @@ export async function init(knex, readOnly) {
66
70
  await migratePageMetaBodyHash(knex);
67
71
  await migratePageMetaConsoleErrorCount(knex);
68
72
  await migrateContentItemsAliasOfId(knex);
73
+ // Runs after `initSchema` above, which already created
74
+ // `dedupe_cap_events` (an adjunct table) unconditionally — so the new
75
+ // column's `REFERENCES dedupe_cap_events(id)` target always exists by
76
+ // this point, for both fresh and legacy archives.
77
+ await migrateContentItemsDedupeCapEventId(knex);
69
78
  await migrateInventoryRunsInvalidSkipped(knex);
79
+ await migrateInventoryRunsExcludeSkipped(knex);
70
80
  await closeStaleOpenNetworkOutages(knex);
71
81
  }
@@ -0,0 +1,38 @@
1
+ import type { WriteRefCaches } from '../../_shared/types.js';
2
+ import type { Knex } from 'knex';
3
+ /**
4
+ * Parameters for {@link insertInventoryContentItems}.
5
+ */
6
+ export interface InsertInventoryContentItemsParams {
7
+ /** Knex query builder connected to the archive DB. */
8
+ readonly knex: Knex;
9
+ /** The connection's write-side id caches. */
10
+ readonly caches: WriteRefCaches;
11
+ /** URL strings already in `withoutHashAndAuth` form. */
12
+ readonly urls: readonly string[];
13
+ /** `content_items` column values shared by every inserted row (everything except `url_id`). */
14
+ readonly row: Readonly<Record<string, number | string>>;
15
+ /** Calling op's name, used to prefix the unresolved-url_ref error message. */
16
+ readonly opName: string;
17
+ }
18
+ /**
19
+ * Shared body of the inventory `content_items` bulk-insert ops
20
+ * (`insertInventorySeeds` / `insertInventorySkippedPages`): chunked
21
+ * `url_refs` upsert → id resolution → `content_items` insert-ignore →
22
+ * write-cache population. The two callers differ only in the row
23
+ * constants they stamp on every row, so the invariant-heavy plumbing
24
+ * lives here exactly once:
25
+ *
26
+ * - Chunked into 500-URL batches so SQLite's bound-parameter limit
27
+ * (`SQLITE_MAX_VARIABLE_NUMBER`) cannot be hit even on a
28
+ * tens-of-thousands inventory list.
29
+ * - Both inserts are `ON CONFLICT ... IGNORE`, so existing rows — in
30
+ * particular previously crawled pages — are never overwritten
31
+ * (crawled-wins), and within-list duplicates collapse to one row.
32
+ * - The `urlIds` / `contentItems` write caches are populated from what
33
+ * the DB actually holds after the insert (not from the attempted row
34
+ * values), keeping later cache-hits consistent with conflict-ignored
35
+ * rows.
36
+ * @param params - See {@link InsertInventoryContentItemsParams}.
37
+ */
38
+ export declare function insertInventoryContentItems(params: InsertInventoryContentItemsParams): Promise<void>;
@@ -0,0 +1,59 @@
1
+ import { eachSplitted } from '../../../../utils/array/each-splitted.js';
2
+ import { resolveUrlRefs } from '../../../populate-entity-tables/resolve-url-refs.js';
3
+ import { decomposeUrl } from '../../../populate-ref-tables/decompose-url.js';
4
+ /**
5
+ * Shared body of the inventory `content_items` bulk-insert ops
6
+ * (`insertInventorySeeds` / `insertInventorySkippedPages`): chunked
7
+ * `url_refs` upsert → id resolution → `content_items` insert-ignore →
8
+ * write-cache population. The two callers differ only in the row
9
+ * constants they stamp on every row, so the invariant-heavy plumbing
10
+ * lives here exactly once:
11
+ *
12
+ * - Chunked into 500-URL batches so SQLite's bound-parameter limit
13
+ * (`SQLITE_MAX_VARIABLE_NUMBER`) cannot be hit even on a
14
+ * tens-of-thousands inventory list.
15
+ * - Both inserts are `ON CONFLICT ... IGNORE`, so existing rows — in
16
+ * particular previously crawled pages — are never overwritten
17
+ * (crawled-wins), and within-list duplicates collapse to one row.
18
+ * - The `urlIds` / `contentItems` write caches are populated from what
19
+ * the DB actually holds after the insert (not from the attempted row
20
+ * values), keeping later cache-hits consistent with conflict-ignored
21
+ * rows.
22
+ * @param params - See {@link InsertInventoryContentItemsParams}.
23
+ */
24
+ export async function insertInventoryContentItems(params) {
25
+ const { knex, caches, urls, row, opName } = params;
26
+ if (urls.length === 0) {
27
+ return;
28
+ }
29
+ await eachSplitted([...urls], 500, async (chunk) => {
30
+ await knex('url_refs')
31
+ .insert(chunk.map((url) => ({ url, ...decomposeUrl(url) })))
32
+ .onConflict('url')
33
+ .ignore();
34
+ const urlIds = await resolveUrlRefs(knex, chunk);
35
+ const rows = chunk.map((url) => {
36
+ const urlId = urlIds.get(url);
37
+ if (urlId === undefined) {
38
+ throw new Error(`${opName}: url_refs.id not resolved for ${url}`);
39
+ }
40
+ caches.urlIds.set(url, urlId);
41
+ return {
42
+ url_id: urlId,
43
+ ...row,
44
+ };
45
+ });
46
+ await knex('content_items').insert(rows).onConflict('url_id').ignore();
47
+ const inserted = (await knex
48
+ .select('ci.id', 'ci.source', 'ur.url')
49
+ .from('content_items as ci')
50
+ .join('url_refs as ur', 'ur.id', 'ci.url_id')
51
+ .whereIn('ur.url', chunk));
52
+ for (const insertedRow of inserted) {
53
+ caches.contentItems.set(insertedRow.url, {
54
+ id: insertedRow.id,
55
+ source: insertedRow.source,
56
+ });
57
+ }
58
+ });
59
+ }
@@ -23,15 +23,14 @@ import type { Knex } from 'knex';
23
23
  * behaviour (a seed that turned out to be reachable is not an orphan
24
24
  * and should not retain the inventory label).
25
25
  *
26
- * Chunked into 500-URL batches so SQLite's bound-parameter limit
27
- * (`SQLITE_MAX_VARIABLE_NUMBER`) cannot be hit even on a
28
- * tens-of-thousands inventory list.
29
- *
30
26
  * Called by `CrawlerOrchestrator.inventory` during the
31
27
  * `.bak`-protected ingestion phase, so any failure here aborts the run
32
- * and restores from backup — the operator reruns from scratch.
28
+ * and restores from backup — the operator reruns from scratch. The
29
+ * chunking / conflict-ignore / cache-population plumbing lives in
30
+ * {@link insertInventoryContentItems}, shared with
31
+ * `insertInventorySkippedPages`.
33
32
  * @param knex - Knex query builder connected to the archive DB.
34
- * @param caches
33
+ * @param caches - The connection's write-side id caches.
35
34
  * @param urls - URL strings already in `withoutHashAndAuth` form.
36
35
  */
37
36
  export declare function insertInventorySeeds(knex: Knex, caches: WriteRefCaches, urls: readonly string[]): Promise<void>;
@@ -1,6 +1,4 @@
1
- import { eachSplitted } from '../../../../utils/array/each-splitted.js';
2
- import { resolveUrlRefs } from '../../../populate-entity-tables/resolve-url-refs.js';
3
- import { decomposeUrl } from '../../../populate-ref-tables/decompose-url.js';
1
+ import { insertInventoryContentItems } from './insert-inventory-content-items.js';
4
2
  /**
5
3
  * Pre-insert inventory HTML seeds into `content_items` as `scraped = 0`,
6
4
  * `source = 'inventory-seed'` placeholders so the URL's existence in the
@@ -24,49 +22,27 @@ import { decomposeUrl } from '../../../populate-ref-tables/decompose-url.js';
24
22
  * behaviour (a seed that turned out to be reachable is not an orphan
25
23
  * and should not retain the inventory label).
26
24
  *
27
- * Chunked into 500-URL batches so SQLite's bound-parameter limit
28
- * (`SQLITE_MAX_VARIABLE_NUMBER`) cannot be hit even on a
29
- * tens-of-thousands inventory list.
30
- *
31
25
  * Called by `CrawlerOrchestrator.inventory` during the
32
26
  * `.bak`-protected ingestion phase, so any failure here aborts the run
33
- * and restores from backup — the operator reruns from scratch.
27
+ * and restores from backup — the operator reruns from scratch. The
28
+ * chunking / conflict-ignore / cache-population plumbing lives in
29
+ * {@link insertInventoryContentItems}, shared with
30
+ * `insertInventorySkippedPages`.
34
31
  * @param knex - Knex query builder connected to the archive DB.
35
- * @param caches
32
+ * @param caches - The connection's write-side id caches.
36
33
  * @param urls - URL strings already in `withoutHashAndAuth` form.
37
34
  */
38
35
  export async function insertInventorySeeds(knex, caches, urls) {
39
- if (urls.length === 0) {
40
- return;
41
- }
42
- await eachSplitted([...urls], 500, async (chunk) => {
43
- await knex('url_refs')
44
- .insert(chunk.map((url) => ({ url, ...decomposeUrl(url) })))
45
- .onConflict('url')
46
- .ignore();
47
- const urlIds = await resolveUrlRefs(knex, chunk);
48
- const rows = chunk.map((url) => {
49
- const urlId = urlIds.get(url);
50
- if (urlId === undefined) {
51
- throw new Error(`insertInventorySeeds: url_refs.id not resolved for ${url}`);
52
- }
53
- caches.urlIds.set(url, urlId);
54
- return {
55
- url_id: urlId,
56
- scraped: 0,
57
- is_external: 0,
58
- is_target: 0,
59
- source: 'inventory-seed',
60
- };
61
- });
62
- await knex('content_items').insert(rows).onConflict('url_id').ignore();
63
- const inserted = (await knex
64
- .select('ci.id', 'ci.source', 'ur.url')
65
- .from('content_items as ci')
66
- .join('url_refs as ur', 'ur.id', 'ci.url_id')
67
- .whereIn('ur.url', chunk));
68
- for (const row of inserted) {
69
- caches.contentItems.set(row.url, { id: row.id, source: row.source });
70
- }
36
+ await insertInventoryContentItems({
37
+ knex,
38
+ caches,
39
+ urls,
40
+ row: {
41
+ scraped: 0,
42
+ is_external: 0,
43
+ is_target: 0,
44
+ source: 'inventory-seed',
45
+ },
46
+ opName: 'insertInventorySeeds',
71
47
  });
72
48
  }
@@ -0,0 +1,42 @@
1
+ import type { WriteRefCaches } from '../../_shared/types.js';
2
+ import type { Knex } from 'knex';
3
+ /**
4
+ * Record exclude-matched inventory URLs into `content_items` as
5
+ * `scraped = 1`, `is_skipped = 1`, `skip_reason = 'excluded'`,
6
+ * `source = 'inventory-seed'` rows — the same terminal state the normal
7
+ * crawl's fetch-time `shouldSkipUrl` gate produces via `setSkippedPage`
8
+ * for link-discovered excluded URLs.
9
+ *
10
+ * Why a dedicated write path instead of routing these URLs through the
11
+ * crawler's gate: non-HTML inventory URLs never enter the crawler at all
12
+ * (they are recorded straight into `resources`), so the gate cannot see
13
+ * them, and pre-inserting HTML seeds only to have the dealer skip them
14
+ * wastes dealer slots for a verdict already known at ingestion time.
15
+ * Writing the terminal skipped state directly keeps the invariant "the
16
+ * same URL lands in the same archive state regardless of how it was
17
+ * discovered (anchor vs inventory list)" for both classifications
18
+ * (issue #260).
19
+ *
20
+ * `scraped = 1` is load-bearing: it keeps these rows out of
21
+ * `getCrawlingState`'s strict pending set, so `--resume` after an
22
+ * interrupted inventory pass does not try to fetch operator-excluded
23
+ * URLs.
24
+ *
25
+ * Idempotent: both the `url_refs` and `content_items` inserts are
26
+ * `ON CONFLICT ... IGNORE`, so an existing row — in particular a
27
+ * previously crawled page that now matches the exclusion config — is
28
+ * never downgraded to skipped by this path (crawled-wins). The
29
+ * orchestrator additionally filters known URLs out before calling this,
30
+ * so conflicts here are limited to within-list duplicates.
31
+ *
32
+ * Called by `CrawlerOrchestrator.inventory` during the `.bak`-protected
33
+ * ingestion phase, so any failure here aborts the run and restores from
34
+ * backup — the operator reruns from scratch. The chunking /
35
+ * conflict-ignore / cache-population plumbing lives in
36
+ * {@link insertInventoryContentItems}, shared with
37
+ * `insertInventorySeeds`.
38
+ * @param knex - Knex query builder connected to the archive DB.
39
+ * @param caches - The connection's write-side id caches.
40
+ * @param urls - URL strings already in `withoutHashAndAuth` form.
41
+ */
42
+ export declare function insertInventorySkippedPages(knex: Knex, caches: WriteRefCaches, urls: readonly string[]): Promise<void>;
@@ -0,0 +1,56 @@
1
+ import { insertInventoryContentItems } from './insert-inventory-content-items.js';
2
+ /**
3
+ * Record exclude-matched inventory URLs into `content_items` as
4
+ * `scraped = 1`, `is_skipped = 1`, `skip_reason = 'excluded'`,
5
+ * `source = 'inventory-seed'` rows — the same terminal state the normal
6
+ * crawl's fetch-time `shouldSkipUrl` gate produces via `setSkippedPage`
7
+ * for link-discovered excluded URLs.
8
+ *
9
+ * Why a dedicated write path instead of routing these URLs through the
10
+ * crawler's gate: non-HTML inventory URLs never enter the crawler at all
11
+ * (they are recorded straight into `resources`), so the gate cannot see
12
+ * them, and pre-inserting HTML seeds only to have the dealer skip them
13
+ * wastes dealer slots for a verdict already known at ingestion time.
14
+ * Writing the terminal skipped state directly keeps the invariant "the
15
+ * same URL lands in the same archive state regardless of how it was
16
+ * discovered (anchor vs inventory list)" for both classifications
17
+ * (issue #260).
18
+ *
19
+ * `scraped = 1` is load-bearing: it keeps these rows out of
20
+ * `getCrawlingState`'s strict pending set, so `--resume` after an
21
+ * interrupted inventory pass does not try to fetch operator-excluded
22
+ * URLs.
23
+ *
24
+ * Idempotent: both the `url_refs` and `content_items` inserts are
25
+ * `ON CONFLICT ... IGNORE`, so an existing row — in particular a
26
+ * previously crawled page that now matches the exclusion config — is
27
+ * never downgraded to skipped by this path (crawled-wins). The
28
+ * orchestrator additionally filters known URLs out before calling this,
29
+ * so conflicts here are limited to within-list duplicates.
30
+ *
31
+ * Called by `CrawlerOrchestrator.inventory` during the `.bak`-protected
32
+ * ingestion phase, so any failure here aborts the run and restores from
33
+ * backup — the operator reruns from scratch. The chunking /
34
+ * conflict-ignore / cache-population plumbing lives in
35
+ * {@link insertInventoryContentItems}, shared with
36
+ * `insertInventorySeeds`.
37
+ * @param knex - Knex query builder connected to the archive DB.
38
+ * @param caches - The connection's write-side id caches.
39
+ * @param urls - URL strings already in `withoutHashAndAuth` form.
40
+ */
41
+ export async function insertInventorySkippedPages(knex, caches, urls) {
42
+ await insertInventoryContentItems({
43
+ knex,
44
+ caches,
45
+ urls,
46
+ row: {
47
+ scraped: 1,
48
+ is_external: 0,
49
+ is_target: 0,
50
+ is_skipped: 1,
51
+ skip_reason: 'excluded',
52
+ source: 'inventory-seed',
53
+ },
54
+ opName: 'insertInventorySkippedPages',
55
+ });
56
+ }
@@ -0,0 +1,41 @@
1
+ import type { Knex } from 'knex';
2
+ /**
3
+ * Adds the `content_items.dedupe_cap_event_id` column to archives created
4
+ * before this feature.
5
+ *
6
+ * `content_items` is provisioned via a bare `CREATE TABLE IF NOT EXISTS` in
7
+ * {@link import('./create-entity-tables.js').createEntityTables}, which
8
+ * self-heals a *missing table* on every `initSchema` call but is a no-op
9
+ * against an *existing* table — adding a column to the DDL string never
10
+ * reaches an archive whose `content_items` predates this change. This
11
+ * mirrors {@link import('./migrate-content-items-alias-of-id.js').migrateContentItemsAliasOfId}'s
12
+ * catch-up: a `hasColumn`-guarded `ALTER TABLE` for the one column
13
+ * `CREATE TABLE IF NOT EXISTS` cannot retrofit.
14
+ *
15
+ * Uses a raw `ALTER TABLE` (not the knex schema builder) so the retrofitted
16
+ * column's `REFERENCES dedupe_cap_events(id) DEFERRABLE INITIALLY DEFERRED`
17
+ * constraint matches the fresh-archive DDL bit-for-bit.
18
+ *
19
+ * Unlike `migrateContentItemsAliasOfId`, this migration never creates an
20
+ * index for the column — `--dedupe-cap` is opt-in and the number of rows a
21
+ * cap event ever marks is small (capped shapes × matching URLs), so there is
22
+ * no measured hot path to justify one. See `createEntityTables`'s DDL
23
+ * comment for the same reasoning.
24
+ *
25
+ * Only adds the column — it does not compute values for existing rows (they
26
+ * stay `NULL`). That computation runs separately, from
27
+ * `backfillDedupeCapEventId` during a viewer-read-model build, since it
28
+ * requires recomputing `computeShapeKey` against every internal page's URL
29
+ * and matching it against `dedupe_cap_events.shape_key`.
30
+ *
31
+ * Idempotent: adding the column is a no-op once it exists. Guards on
32
+ * `content_items`'s existence defensively, though by the time this runs
33
+ * (after `initSchema`, itself after `assertCompatibleVersion` rejects
34
+ * pre-0.13 archives) the table is always present.
35
+ * @param instance - The Knex query builder instance connected to the database.
36
+ * @example
37
+ * ```ts
38
+ * await migrateContentItemsDedupeCapEventId(knex);
39
+ * ```
40
+ */
41
+ export declare function migrateContentItemsDedupeCapEventId(instance: Knex): Promise<void>;
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Adds the `content_items.dedupe_cap_event_id` column to archives created
3
+ * before this feature.
4
+ *
5
+ * `content_items` is provisioned via a bare `CREATE TABLE IF NOT EXISTS` in
6
+ * {@link import('./create-entity-tables.js').createEntityTables}, which
7
+ * self-heals a *missing table* on every `initSchema` call but is a no-op
8
+ * against an *existing* table — adding a column to the DDL string never
9
+ * reaches an archive whose `content_items` predates this change. This
10
+ * mirrors {@link import('./migrate-content-items-alias-of-id.js').migrateContentItemsAliasOfId}'s
11
+ * catch-up: a `hasColumn`-guarded `ALTER TABLE` for the one column
12
+ * `CREATE TABLE IF NOT EXISTS` cannot retrofit.
13
+ *
14
+ * Uses a raw `ALTER TABLE` (not the knex schema builder) so the retrofitted
15
+ * column's `REFERENCES dedupe_cap_events(id) DEFERRABLE INITIALLY DEFERRED`
16
+ * constraint matches the fresh-archive DDL bit-for-bit.
17
+ *
18
+ * Unlike `migrateContentItemsAliasOfId`, this migration never creates an
19
+ * index for the column — `--dedupe-cap` is opt-in and the number of rows a
20
+ * cap event ever marks is small (capped shapes × matching URLs), so there is
21
+ * no measured hot path to justify one. See `createEntityTables`'s DDL
22
+ * comment for the same reasoning.
23
+ *
24
+ * Only adds the column — it does not compute values for existing rows (they
25
+ * stay `NULL`). That computation runs separately, from
26
+ * `backfillDedupeCapEventId` during a viewer-read-model build, since it
27
+ * requires recomputing `computeShapeKey` against every internal page's URL
28
+ * and matching it against `dedupe_cap_events.shape_key`.
29
+ *
30
+ * Idempotent: adding the column is a no-op once it exists. Guards on
31
+ * `content_items`'s existence defensively, though by the time this runs
32
+ * (after `initSchema`, itself after `assertCompatibleVersion` rejects
33
+ * pre-0.13 archives) the table is always present.
34
+ * @param instance - The Knex query builder instance connected to the database.
35
+ * @example
36
+ * ```ts
37
+ * await migrateContentItemsDedupeCapEventId(knex);
38
+ * ```
39
+ */
40
+ export async function migrateContentItemsDedupeCapEventId(instance) {
41
+ const hasContentItems = await instance.schema.hasTable('content_items');
42
+ if (!hasContentItems) {
43
+ return;
44
+ }
45
+ const hasColumn = await instance.schema.hasColumn('content_items', 'dedupe_cap_event_id');
46
+ if (!hasColumn) {
47
+ await instance.raw('ALTER TABLE content_items ADD COLUMN dedupe_cap_event_id INTEGER REFERENCES dedupe_cap_events(id) DEFERRABLE INITIALLY DEFERRED');
48
+ // eslint-disable-next-line no-console
49
+ console.error('[migrate] content_items.dedupe_cap_event_id column added');
50
+ }
51
+ }
@@ -29,6 +29,16 @@ import type { Knex } from 'knex';
29
29
  * `scripts/migrate-to-0.13.mjs` orders the two calls statically so this
30
30
  * ordering is enforced there, not here.
31
31
  *
32
+ * `content_items` also declares `dedupe_cap_event_id REFERENCES
33
+ * dedupe_cap_events(id)` — an adjunct table (`createAdjunctTables`), not a
34
+ * ref table. This function only creates the empty tables (schema-only, see
35
+ * above), so it does not itself need `dedupe_cap_events` to exist. Callers
36
+ * that write data into `content_items` afterward do: under `PRAGMA
37
+ * foreign_keys = ON`, SQLite refuses to even prepare an INSERT/UPDATE
38
+ * against a table with an unresolvable `REFERENCES` target, so
39
+ * `scripts/migrate-to-0.13.mjs` creates adjunct tables immediately after
40
+ * this migration and before any entity-table data write.
41
+ *
32
42
  * **Idempotency**: `createEntityTables` itself uses
33
43
  * `CREATE TABLE IF NOT EXISTS` / `CREATE INDEX IF NOT EXISTS` for every
34
44
  * statement, so calling it multiple times against any DB state is safe.
@@ -29,6 +29,16 @@ import { createEntityTables } from './create-entity-tables.js';
29
29
  * `scripts/migrate-to-0.13.mjs` orders the two calls statically so this
30
30
  * ordering is enforced there, not here.
31
31
  *
32
+ * `content_items` also declares `dedupe_cap_event_id REFERENCES
33
+ * dedupe_cap_events(id)` — an adjunct table (`createAdjunctTables`), not a
34
+ * ref table. This function only creates the empty tables (schema-only, see
35
+ * above), so it does not itself need `dedupe_cap_events` to exist. Callers
36
+ * that write data into `content_items` afterward do: under `PRAGMA
37
+ * foreign_keys = ON`, SQLite refuses to even prepare an INSERT/UPDATE
38
+ * against a table with an unresolvable `REFERENCES` target, so
39
+ * `scripts/migrate-to-0.13.mjs` creates adjunct tables immediately after
40
+ * this migration and before any entity-table data write.
41
+ *
32
42
  * **Idempotency**: `createEntityTables` itself uses
33
43
  * `CREATE TABLE IF NOT EXISTS` / `CREATE INDEX IF NOT EXISTS` for every
34
44
  * statement, so calling it multiple times against any DB state is safe.
@@ -0,0 +1,20 @@
1
+ import type { Knex } from 'knex';
2
+ /**
3
+ * Adds the `inventory_runs.exclude_skipped` column to archives created
4
+ * before it existed. `CREATE TABLE IF NOT EXISTS` (used for `inventory_runs`
5
+ * itself) cannot retrofit a new column onto an already-existing table, so
6
+ * this lightweight, `hasColumn`-guarded `ALTER TABLE` runs on every
7
+ * `initSchema` call — idempotent, and self-healing for archives whose
8
+ * provisioning crashed partway through.
9
+ *
10
+ * Pre-migration rows stay `NULL`: those runs predate ingestion-side
11
+ * exclusion (issue #260), so their excluded URLs were imported as real
12
+ * pages/resources rather than recorded as skipped — `NULL` means "not
13
+ * measured", not `0`.
14
+ *
15
+ * The column is pure audit output: written once per run and read back
16
+ * only by `listInventoryRuns` display surfaces, never consumed by any
17
+ * runtime decision — matching `scope_skipped` / `invalid_skipped`.
18
+ * @param instance - The Knex query builder instance connected to the database.
19
+ */
20
+ export declare function migrateInventoryRunsExcludeSkipped(instance: Knex): Promise<void>;
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Adds the `inventory_runs.exclude_skipped` column to archives created
3
+ * before it existed. `CREATE TABLE IF NOT EXISTS` (used for `inventory_runs`
4
+ * itself) cannot retrofit a new column onto an already-existing table, so
5
+ * this lightweight, `hasColumn`-guarded `ALTER TABLE` runs on every
6
+ * `initSchema` call — idempotent, and self-healing for archives whose
7
+ * provisioning crashed partway through.
8
+ *
9
+ * Pre-migration rows stay `NULL`: those runs predate ingestion-side
10
+ * exclusion (issue #260), so their excluded URLs were imported as real
11
+ * pages/resources rather than recorded as skipped — `NULL` means "not
12
+ * measured", not `0`.
13
+ *
14
+ * The column is pure audit output: written once per run and read back
15
+ * only by `listInventoryRuns` display surfaces, never consumed by any
16
+ * runtime decision — matching `scope_skipped` / `invalid_skipped`.
17
+ * @param instance - The Knex query builder instance connected to the database.
18
+ */
19
+ export async function migrateInventoryRunsExcludeSkipped(instance) {
20
+ const hasTable = await instance.schema.hasTable('inventory_runs');
21
+ if (!hasTable) {
22
+ return;
23
+ }
24
+ const hasColumn = await instance.schema.hasColumn('inventory_runs', 'exclude_skipped');
25
+ if (hasColumn) {
26
+ return;
27
+ }
28
+ await instance.schema.table('inventory_runs', (t) => {
29
+ t.integer('exclude_skipped');
30
+ });
31
+ // eslint-disable-next-line no-console
32
+ console.error('[migrate] inventory_runs.exclude_skipped column added');
33
+ }
@@ -8,6 +8,13 @@ import knex from 'knex';
8
8
  * actually read.
9
9
  * - The 0.13 ref / header tables (via {@link createRefTables}).
10
10
  * - The 0.13 entity tables (via {@link createEntityTables}).
11
+ * - The 0.13 adjunct tables (via {@link createAdjunctTables}) — required
12
+ * because `content_items.dedupe_cap_event_id REFERENCES
13
+ * dedupe_cap_events(id)`; under `PRAGMA foreign_keys = ON` (enabled
14
+ * below), inserting into `content_items` fails with `no such table:
15
+ * dedupe_cap_events` if the adjunct tables were skipped. `initSchema`
16
+ * always calls both create functions together, so this mirrors a real
17
+ * archive's actual schema rather than an artificially incomplete one.
11
18
  *
12
19
  * Every 0.13 populate spec calls this to obtain a fresh DB. The
13
20
  * caller is responsible for `db.destroy()` (spec `afterEach`).
@@ -1,4 +1,5 @@
1
1
  import knex from 'knex';
2
+ import { createAdjunctTables } from '../../create-adjunct-tables.js';
2
3
  import { createEntityTables } from '../../create-entity-tables.js';
3
4
  import { createRefTables } from '../../create-ref-tables.js';
4
5
  import { LibsqlDialect } from '../../libsql-dialect.js';
@@ -11,6 +12,13 @@ import { LibsqlDialect } from '../../libsql-dialect.js';
11
12
  * actually read.
12
13
  * - The 0.13 ref / header tables (via {@link createRefTables}).
13
14
  * - The 0.13 entity tables (via {@link createEntityTables}).
15
+ * - The 0.13 adjunct tables (via {@link createAdjunctTables}) — required
16
+ * because `content_items.dedupe_cap_event_id REFERENCES
17
+ * dedupe_cap_events(id)`; under `PRAGMA foreign_keys = ON` (enabled
18
+ * below), inserting into `content_items` fails with `no such table:
19
+ * dedupe_cap_events` if the adjunct tables were skipped. `initSchema`
20
+ * always calls both create functions together, so this mirrors a real
21
+ * archive's actual schema rather than an artificially incomplete one.
14
22
  *
15
23
  * Every 0.13 populate spec calls this to obtain a fresh DB. The
16
24
  * caller is responsible for `db.destroy()` (spec `afterEach`).
@@ -174,5 +182,6 @@ export async function setupMigrationDb() {
174
182
  `);
175
183
  await createRefTables(db);
176
184
  await createEntityTables(db);
185
+ await createAdjunctTables(db);
177
186
  return db;
178
187
  }