@nitpicker/crawler 0.14.0 → 0.15.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 (120) hide show
  1. package/lib/archive/archive.d.ts +98 -10
  2. package/lib/archive/archive.js +98 -9
  3. package/lib/archive/body-hash/compute-body-hash.d.ts +22 -0
  4. package/lib/archive/body-hash/compute-body-hash.js +31 -0
  5. package/lib/archive/body-hash/extract-body.d.ts +18 -0
  6. package/lib/archive/body-hash/extract-body.js +29 -0
  7. package/lib/archive/body-hash/mask-dynamic-ids.d.ts +33 -0
  8. package/lib/archive/body-hash/mask-dynamic-ids.js +44 -0
  9. package/lib/archive/body-hash/normalize-url-like-strings.d.ts +16 -0
  10. package/lib/archive/body-hash/normalize-url-like-strings.js +19 -0
  11. package/lib/archive/cache/clear-archive-cache-entry.d.ts +19 -0
  12. package/lib/archive/cache/clear-archive-cache-entry.js +25 -0
  13. package/lib/archive/cache/clear-archive-cache-root.d.ts +26 -0
  14. package/lib/archive/cache/clear-archive-cache-root.js +32 -0
  15. package/lib/archive/cache/list-archive-cache-entries.d.ts +23 -0
  16. package/lib/archive/cache/list-archive-cache-entries.js +125 -0
  17. package/lib/archive/cache/path-exists.d.ts +9 -0
  18. package/lib/archive/cache/path-exists.js +18 -0
  19. package/lib/archive/cache/types.d.ts +33 -0
  20. package/lib/archive/cache/types.js +1 -0
  21. package/lib/archive/create-adjunct-tables.d.ts +11 -0
  22. package/lib/archive/create-adjunct-tables.js +127 -0
  23. package/lib/archive/create-entity-tables.js +21 -1
  24. package/lib/archive/database.d.ts +37 -2
  25. package/lib/archive/database.js +50 -3
  26. package/lib/archive/db-ops/_shared/clear-write-ref-caches.js +1 -0
  27. package/lib/archive/db-ops/_shared/compress-payload.d.ts +26 -0
  28. package/lib/archive/db-ops/_shared/compress-payload.js +30 -0
  29. package/lib/archive/db-ops/_shared/create-write-ref-caches.js +1 -0
  30. package/lib/archive/db-ops/_shared/types.d.ts +2 -0
  31. package/lib/archive/db-ops/_shared/upsert-json-ref.js +3 -3
  32. package/lib/archive/db-ops/analysis/replace-page-templates.d.ts +12 -3
  33. package/lib/archive/db-ops/analysis/replace-page-templates.js +35 -3
  34. package/lib/archive/db-ops/analysis/types.d.ts +61 -0
  35. package/lib/archive/db-ops/analysis/types.js +1 -0
  36. package/lib/archive/db-ops/console-logs/compute-console-log-hash.d.ts +19 -0
  37. package/lib/archive/db-ops/console-logs/compute-console-log-hash.js +30 -0
  38. package/lib/archive/db-ops/console-logs/replace-console-logs.d.ts +34 -0
  39. package/lib/archive/db-ops/console-logs/replace-console-logs.js +123 -0
  40. package/lib/archive/db-ops/console-logs/stringify-console-log-args.d.ts +16 -0
  41. package/lib/archive/db-ops/console-logs/stringify-console-log-args.js +26 -0
  42. package/lib/archive/db-ops/console-logs/types.d.ts +38 -0
  43. package/lib/archive/db-ops/console-logs/types.js +1 -0
  44. package/lib/archive/db-ops/console-logs/upsert-console-log-item.d.ts +29 -0
  45. package/lib/archive/db-ops/console-logs/upsert-console-log-item.js +52 -0
  46. package/lib/archive/db-ops/errors/list-dns-burned-host-candidates.d.ts +11 -0
  47. package/lib/archive/db-ops/errors/list-dns-burned-host-candidates.js +21 -4
  48. package/lib/archive/db-ops/inventory/record-inventory-run.js +1 -0
  49. package/lib/archive/db-ops/lifecycle/init.d.ts +11 -2
  50. package/lib/archive/db-ops/lifecycle/init.js +21 -2
  51. package/lib/archive/db-ops/outages/close-network-outage.d.ts +12 -0
  52. package/lib/archive/db-ops/outages/close-network-outage.js +15 -0
  53. package/lib/archive/db-ops/outages/close-stale-open-network-outages.d.ts +26 -0
  54. package/lib/archive/db-ops/outages/close-stale-open-network-outages.js +42 -0
  55. package/lib/archive/db-ops/outages/compute-outage-clamp-timestamp.d.ts +26 -0
  56. package/lib/archive/db-ops/outages/compute-outage-clamp-timestamp.js +31 -0
  57. package/lib/archive/db-ops/outages/insert-network-outage.d.ts +14 -0
  58. package/lib/archive/db-ops/outages/insert-network-outage.js +29 -0
  59. package/lib/archive/db-ops/outages/list-network-outages.d.ts +28 -0
  60. package/lib/archive/db-ops/outages/list-network-outages.js +42 -0
  61. package/lib/archive/db-ops/pages/reset/reset-failed-pages.d.ts +13 -0
  62. package/lib/archive/db-ops/pages/reset/reset-failed-pages.js +35 -7
  63. package/lib/archive/db-ops/pages/write/update-page.js +12 -0
  64. package/lib/archive/db-ops/pages/write/write-page-html-blob.js +6 -6
  65. package/lib/archive/filesystem/output-binary.d.ts +12 -0
  66. package/lib/archive/filesystem/output-binary.js +17 -0
  67. package/lib/archive/get-failed-page-messages.d.ts +20 -8
  68. package/lib/archive/get-failed-page-messages.js +29 -15
  69. package/lib/archive/migrate-content-items-alias-of-id.d.ts +47 -0
  70. package/lib/archive/migrate-content-items-alias-of-id.js +58 -0
  71. package/lib/archive/migrate-inventory-runs-invalid-skipped.d.ts +11 -0
  72. package/lib/archive/migrate-inventory-runs-invalid-skipped.js +24 -0
  73. package/lib/archive/migrate-page-meta-body-hash.d.ts +39 -0
  74. package/lib/archive/migrate-page-meta-body-hash.js +52 -0
  75. package/lib/archive/migrate-page-meta-console-error-count.d.ts +22 -0
  76. package/lib/archive/migrate-page-meta-console-error-count.js +34 -0
  77. package/lib/archive/page.d.ts +1 -1
  78. package/lib/archive/types.d.ts +47 -2
  79. package/lib/archive/url-alias/compute-tier-a-alias-key.d.ts +31 -0
  80. package/lib/archive/url-alias/compute-tier-a-alias-key.js +36 -0
  81. package/lib/archive/url-alias/compute-tier-b-alias-key.d.ts +19 -0
  82. package/lib/archive/url-alias/compute-tier-b-alias-key.js +30 -0
  83. package/lib/archive/url-alias/format-alias-key.d.ts +13 -0
  84. package/lib/archive/url-alias/format-alias-key.js +14 -0
  85. package/lib/archive/url-alias/parse-alias-key-parts.d.ts +15 -0
  86. package/lib/archive/url-alias/parse-alias-key-parts.js +40 -0
  87. package/lib/archive/url-alias/types.d.ts +7 -0
  88. package/lib/archive/url-alias/types.js +1 -0
  89. package/lib/crawler/choose-probe-host.d.ts +24 -0
  90. package/lib/crawler/choose-probe-host.js +38 -0
  91. package/lib/crawler/clear-dns-burned-host-cache.js +2 -0
  92. package/lib/crawler/crawler.js +257 -0
  93. package/lib/crawler/dns-burned-host-burn-timestamps.d.ts +17 -0
  94. package/lib/crawler/dns-burned-host-burn-timestamps.js +17 -0
  95. package/lib/crawler/evict-network-classified-destination-cache-entries.d.ts +26 -0
  96. package/lib/crawler/evict-network-classified-destination-cache-entries.js +34 -0
  97. package/lib/crawler/evict-outage-tainted-dns-burns.d.ts +23 -0
  98. package/lib/crawler/evict-outage-tainted-dns-burns.js +26 -0
  99. package/lib/crawler/network-gate.d.ts +49 -0
  100. package/lib/crawler/network-gate.js +78 -0
  101. package/lib/crawler/network-outage-detector.d.ts +51 -0
  102. package/lib/crawler/network-outage-detector.js +81 -0
  103. package/lib/crawler/network-outage-summary-counter.d.ts +21 -0
  104. package/lib/crawler/network-outage-summary-counter.js +18 -0
  105. package/lib/crawler/probe-network.d.ts +26 -0
  106. package/lib/crawler/probe-network.js +21 -0
  107. package/lib/crawler/types.d.ts +147 -1
  108. package/lib/crawler-orchestrator.d.ts +62 -18
  109. package/lib/crawler-orchestrator.js +161 -26
  110. package/lib/crawler.d.ts +21 -0
  111. package/lib/crawler.js +17 -0
  112. package/lib/is-within-outage-window.d.ts +49 -0
  113. package/lib/is-within-outage-window.js +33 -0
  114. package/lib/network-related-error-kinds.d.ts +23 -0
  115. package/lib/network-related-error-kinds.js +28 -0
  116. package/lib/types.d.ts +9 -1
  117. package/lib/utils/array/each-splitted.d.ts +1 -1
  118. package/lib/utils/compute-file-sha256.d.ts +17 -17
  119. package/lib/utils/compute-file-sha256.js +18 -47
  120. package/package.json +3 -3
@@ -1,5 +1,5 @@
1
1
  import { createHash } from 'node:crypto';
2
- import { zstdCompressSync } from 'node:zlib';
2
+ import { compressPayload } from '../../_shared/compress-payload.js';
3
3
  /**
4
4
  * Encodes, dedups, and persists a page's HTML snapshot.
5
5
  *
@@ -20,14 +20,14 @@ import { zstdCompressSync } from 'node:zlib';
20
20
  export async function writePageHtmlBlob(pageId, html, trx) {
21
21
  const rawBytes = Buffer.from(html, 'utf8');
22
22
  const hash = createHash('sha256').update(rawBytes).digest();
23
- const compressed = zstdCompressSync(rawBytes);
23
+ const { body, codec, sizeRaw, sizeStored } = compressPayload(rawBytes);
24
24
  await trx('page_html_blobs')
25
25
  .insert({
26
26
  hash,
27
- body: compressed,
28
- codec: 'zstd',
29
- size_raw: rawBytes.byteLength,
30
- size_stored: compressed.byteLength,
27
+ body,
28
+ codec,
29
+ size_raw: sizeRaw,
30
+ size_stored: sizeStored,
31
31
  })
32
32
  .onConflict('hash')
33
33
  .ignore();
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Writes raw bytes to a file at the specified path, creating parent
3
+ * directories if needed.
4
+ *
5
+ * Unlike {@link outputText}, the buffer is written verbatim with no UTF-8
6
+ * re-encoding, so callers that need byte-for-byte fidelity (e.g. archiving
7
+ * a source file of unknown or mixed encoding for audit purposes) are not
8
+ * exposed to lossy round-tripping through a JS string.
9
+ * @param filePath - The absolute or relative path to the file to write.
10
+ * @param data - The raw bytes to write.
11
+ */
12
+ export declare function outputBinary(filePath: string, data: Buffer): Promise<void>;
@@ -0,0 +1,17 @@
1
+ import { promises as fs } from 'node:fs';
2
+ import { mkdir } from './mkdir.js';
3
+ /**
4
+ * Writes raw bytes to a file at the specified path, creating parent
5
+ * directories if needed.
6
+ *
7
+ * Unlike {@link outputText}, the buffer is written verbatim with no UTF-8
8
+ * re-encoding, so callers that need byte-for-byte fidelity (e.g. archiving
9
+ * a source file of unknown or mixed encoding for audit purposes) are not
10
+ * exposed to lossy round-tripping through a JS string.
11
+ * @param filePath - The absolute or relative path to the file to write.
12
+ * @param data - The raw bytes to write.
13
+ */
14
+ export async function outputBinary(filePath, data) {
15
+ mkdir(filePath);
16
+ await fs.writeFile(filePath, data);
17
+ }
@@ -1,10 +1,22 @@
1
1
  import type { Knex } from 'knex';
2
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).
3
+ * One resolved failure message, with the timestamp it was recorded at.
4
+ * `createdAt` is what lets a caller (`resetFailedPages`) decide whether the
5
+ * failure falls inside a `network_outages` window and should be treated as
6
+ * retryable regardless of its classified `ErrorKind` see
7
+ * `is-within-outage-window.ts`.
8
+ */
9
+ export interface FailedPageMessage {
10
+ message: string;
11
+ /** Epoch ms the message was recorded (`page_errors.createdAt` or `crawl_errors.createdAt`). */
12
+ createdAt: number;
13
+ }
14
+ /**
15
+ * Bulk-resolve a raw error message (plus its timestamp) for each given page
16
+ * id, using only sources reachable from a {@link Knex} handle. Read order:
17
+ * `page_errors` (keyed by `pageId`, the most direct signal a scrape attempt
18
+ * recorded), then `crawl_errors` (keyed by `url`, the crawler-channel
19
+ * record for failures that happened before a page row was scraped).
8
20
  *
9
21
  * **Known limitation — pre-`crawl_errors` archives**: This helper does NOT
10
22
  * read `error.log`. The `crawl_errors` table is created empty (by
@@ -30,8 +42,8 @@ import type { Knex } from 'knex';
30
42
  * @param urls - The corresponding `pages.url` values, in the same order as
31
43
  * `ids`. Length and indexing MUST match `ids` so the page → url join can be
32
44
  * 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.
45
+ * @returns `Map<pageId, FailedPageMessage>` populated only for ids whose
46
+ * message was found in one of the consulted tables.
35
47
  * @example
36
48
  * ```ts
37
49
  * const messages = await getFailedPageMessages(
@@ -41,4 +53,4 @@ import type { Knex } from 'knex';
41
53
  * );
42
54
  * ```
43
55
  */
44
- export declare function getFailedPageMessages(instance: Knex, ids: readonly number[], urls: readonly string[]): Promise<Map<number, string>>;
56
+ export declare function getFailedPageMessages(instance: Knex, ids: readonly number[], urls: readonly string[]): Promise<Map<number, FailedPageMessage>>;
@@ -1,11 +1,11 @@
1
1
  /** chunk size for `WHERE … IN (?)` SQLite parameter packing. */
2
2
  const CHUNK_SIZE = 500;
3
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).
4
+ * Bulk-resolve a raw error message (plus its timestamp) for each given page
5
+ * id, using only sources reachable from a {@link Knex} handle. Read order:
6
+ * `page_errors` (keyed by `pageId`, the most direct signal a scrape attempt
7
+ * recorded), then `crawl_errors` (keyed by `url`, the crawler-channel
8
+ * record for failures that happened before a page row was scraped).
9
9
  *
10
10
  * **Known limitation — pre-`crawl_errors` archives**: This helper does NOT
11
11
  * read `error.log`. The `crawl_errors` table is created empty (by
@@ -31,8 +31,8 @@ const CHUNK_SIZE = 500;
31
31
  * @param urls - The corresponding `pages.url` values, in the same order as
32
32
  * `ids`. Length and indexing MUST match `ids` so the page → url join can be
33
33
  * reconstructed without a second `pages` round-trip.
34
- * @returns `Map<pageId, message>` populated only for ids whose message was
35
- * found in one of the consulted tables.
34
+ * @returns `Map<pageId, FailedPageMessage>` populated only for ids whose
35
+ * message was found in one of the consulted tables.
36
36
  * @example
37
37
  * ```ts
38
38
  * const messages = await getFailedPageMessages(
@@ -61,7 +61,7 @@ export async function getFailedPageMessages(instance, ids, urls) {
61
61
  // it as `parse-error` on the next run when the rows happen to be
62
62
  // returned in a different order.
63
63
  const rows = (await instance('page_errors')
64
- .select('pageId', 'message')
64
+ .select('pageId', 'message', 'createdAt')
65
65
  .whereIn('pageId', chunk)
66
66
  .orderBy('id', 'asc'));
67
67
  for (const row of rows) {
@@ -79,7 +79,10 @@ export async function getFailedPageMessages(instance, ids, urls) {
79
79
  // `dns` / `tls` / `client-blocked` etc. — defeating
80
80
  // `--retry-failed`'s permanent-kind exclusion.
81
81
  if (row.message !== '' && !messageByPageId.has(row.pageId)) {
82
- messageByPageId.set(row.pageId, row.message);
82
+ messageByPageId.set(row.pageId, {
83
+ message: row.message,
84
+ createdAt: row.createdAt,
85
+ });
83
86
  }
84
87
  }
85
88
  }
@@ -110,11 +113,22 @@ export async function getFailedPageMessages(instance, ids, urls) {
110
113
  for (let i = 0; i < missingUrls.length; i += CHUNK_SIZE) {
111
114
  const chunk = missingUrls.slice(i, i + CHUNK_SIZE);
112
115
  const rows = (await instance('crawl_errors')
113
- .select('url', 'message')
116
+ .select('url', 'message', 'createdAt')
114
117
  .whereIn('url', chunk));
115
118
  for (const row of rows) {
116
- if (row.url !== null && !urlToMessage.has(row.url)) {
117
- urlToMessage.set(row.url, row.message);
119
+ if (row.url === null) {
120
+ continue;
121
+ }
122
+ // Latest-createdAt wins (fixes a previously-undefined
123
+ // selection among duplicate URLs — SQLite's natural scan
124
+ // order is implementation-defined). The most recent message
125
+ // is the most relevant one for both classification and outage
126
+ // attribution: an old NXDOMAIN followed by a network-outage
127
+ // blip should resolve to the outage-era message, not whichever
128
+ // happened to be inserted first.
129
+ const existing = urlToMessage.get(row.url);
130
+ if (existing === undefined || row.createdAt > existing.createdAt) {
131
+ urlToMessage.set(row.url, { message: row.message, createdAt: row.createdAt });
118
132
  }
119
133
  }
120
134
  }
@@ -123,9 +137,9 @@ export async function getFailedPageMessages(instance, ids, urls) {
123
137
  if (url === undefined) {
124
138
  continue;
125
139
  }
126
- const message = urlToMessage.get(url);
127
- if (message !== undefined) {
128
- messageByPageId.set(id, message);
140
+ const resolved = urlToMessage.get(url);
141
+ if (resolved !== undefined) {
142
+ messageByPageId.set(id, resolved);
129
143
  }
130
144
  }
131
145
  return messageByPageId;
@@ -0,0 +1,47 @@
1
+ import type { Knex } from 'knex';
2
+ /**
3
+ * Adds the `content_items.alias_of_id` column to archives created before
4
+ * this feature, then ensures its index exists.
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-page-meta-body-hash.js').migratePageMetaBodyHash}'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 content_items(id) DEFERRABLE INITIALLY DEFERRED`
17
+ * constraint matches the fresh-archive DDL bit-for-bit — the same
18
+ * self-referencing shape as `redirect_dest_id` (see
19
+ * `create-entity-tables.ts`'s JSDoc on why that FK must be deferred: a
20
+ * lower-id row can reference a higher-id row within the same write).
21
+ *
22
+ * The index is created here — unconditionally, after the column-add guard,
23
+ * not inside `createEntityTables`'s DDL — for both a fresh archive (where
24
+ * `alias_of_id` already exists from the DDL, so only the index still needs
25
+ * creating) and a legacy archive (where the column is added just above,
26
+ * then the index follows in the same call). `createEntityTables` runs
27
+ * unconditionally on every archive open, including legacy archives that
28
+ * still lack `alias_of_id` at that point; an unconditional
29
+ * `CREATE INDEX ... alias_of_id` there would fail with `no such column`
30
+ * before this migration ever runs. This function is the one place
31
+ * guaranteed to run only after the column is confirmed present, for both
32
+ * archive kinds.
33
+ *
34
+ * Only adds the column and its index — it does not compute values for
35
+ * existing rows (they stay `NULL`). That computation runs separately, from
36
+ * `backfillAliasOfId` during a viewer-read-model build, since it requires
37
+ * comparing every page's title and (for the trailing-slash tier) its
38
+ * `body_hash` against every other page.
39
+ *
40
+ * Idempotent: adding the column is a no-op once it exists (the index
41
+ * creation always runs, but `IF NOT EXISTS` makes repeat runs a no-op too).
42
+ * Guards on `content_items`'s existence defensively, though by the time this
43
+ * runs (after `initSchema`, itself after `assertCompatibleVersion` rejects
44
+ * pre-0.13 archives) the table is always present.
45
+ * @param instance - The Knex query builder instance connected to the database.
46
+ */
47
+ export declare function migrateContentItemsAliasOfId(instance: Knex): Promise<void>;
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Adds the `content_items.alias_of_id` column to archives created before
3
+ * this feature, then ensures its index exists.
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-page-meta-body-hash.js').migratePageMetaBodyHash}'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 content_items(id) DEFERRABLE INITIALLY DEFERRED`
16
+ * constraint matches the fresh-archive DDL bit-for-bit — the same
17
+ * self-referencing shape as `redirect_dest_id` (see
18
+ * `create-entity-tables.ts`'s JSDoc on why that FK must be deferred: a
19
+ * lower-id row can reference a higher-id row within the same write).
20
+ *
21
+ * The index is created here — unconditionally, after the column-add guard,
22
+ * not inside `createEntityTables`'s DDL — for both a fresh archive (where
23
+ * `alias_of_id` already exists from the DDL, so only the index still needs
24
+ * creating) and a legacy archive (where the column is added just above,
25
+ * then the index follows in the same call). `createEntityTables` runs
26
+ * unconditionally on every archive open, including legacy archives that
27
+ * still lack `alias_of_id` at that point; an unconditional
28
+ * `CREATE INDEX ... alias_of_id` there would fail with `no such column`
29
+ * before this migration ever runs. This function is the one place
30
+ * guaranteed to run only after the column is confirmed present, for both
31
+ * archive kinds.
32
+ *
33
+ * Only adds the column and its index — it does not compute values for
34
+ * existing rows (they stay `NULL`). That computation runs separately, from
35
+ * `backfillAliasOfId` during a viewer-read-model build, since it requires
36
+ * comparing every page's title and (for the trailing-slash tier) its
37
+ * `body_hash` against every other page.
38
+ *
39
+ * Idempotent: adding the column is a no-op once it exists (the index
40
+ * creation always runs, but `IF NOT EXISTS` makes repeat runs a no-op too).
41
+ * Guards on `content_items`'s existence defensively, though by the time this
42
+ * runs (after `initSchema`, itself after `assertCompatibleVersion` rejects
43
+ * pre-0.13 archives) the table is always present.
44
+ * @param instance - The Knex query builder instance connected to the database.
45
+ */
46
+ export async function migrateContentItemsAliasOfId(instance) {
47
+ const hasContentItems = await instance.schema.hasTable('content_items');
48
+ if (!hasContentItems) {
49
+ return;
50
+ }
51
+ const hasColumn = await instance.schema.hasColumn('content_items', 'alias_of_id');
52
+ if (!hasColumn) {
53
+ await instance.raw('ALTER TABLE content_items ADD COLUMN alias_of_id INTEGER REFERENCES content_items(id) DEFERRABLE INITIALLY DEFERRED');
54
+ // eslint-disable-next-line no-console
55
+ console.error('[migrate] content_items.alias_of_id column added');
56
+ }
57
+ await instance.raw('CREATE INDEX IF NOT EXISTS idx_content_items_alias_of_id ON content_items(alias_of_id)');
58
+ }
@@ -0,0 +1,11 @@
1
+ import type { Knex } from 'knex';
2
+ /**
3
+ * Adds the `inventory_runs.invalid_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
+ * @param instance - The Knex query builder instance connected to the database.
10
+ */
11
+ export declare function migrateInventoryRunsInvalidSkipped(instance: Knex): Promise<void>;
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Adds the `inventory_runs.invalid_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
+ * @param instance - The Knex query builder instance connected to the database.
9
+ */
10
+ export async function migrateInventoryRunsInvalidSkipped(instance) {
11
+ const hasTable = await instance.schema.hasTable('inventory_runs');
12
+ if (!hasTable) {
13
+ return;
14
+ }
15
+ const hasColumn = await instance.schema.hasColumn('inventory_runs', 'invalid_skipped');
16
+ if (hasColumn) {
17
+ return;
18
+ }
19
+ await instance.schema.table('inventory_runs', (t) => {
20
+ t.integer('invalid_skipped');
21
+ });
22
+ // eslint-disable-next-line no-console
23
+ console.error('[migrate] inventory_runs.invalid_skipped column added');
24
+ }
@@ -0,0 +1,39 @@
1
+ import type { Knex } from 'knex';
2
+ /**
3
+ * Adds the `page_meta.body_hash` column to archives created before this
4
+ * feature, then ensures its index exists.
5
+ *
6
+ * `page_meta` 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 `page_meta` predates this change. This mirrors
11
+ * {@link import('./migrate-main-contents-columns.js').migrateMainContentsColumns}'s
12
+ * catch-up: a `hasColumn`-guarded `ALTER TABLE` for the one column
13
+ * `CREATE TABLE IF NOT EXISTS` cannot retrofit.
14
+ *
15
+ * The index is created here — unconditionally, after the column-add guard,
16
+ * not inside `createEntityTables`'s DDL — for both a fresh archive (where
17
+ * `body_hash` already exists from the DDL, so only the index still needs
18
+ * creating) and a legacy archive (where the column is added just above,
19
+ * then the index follows in the same call). `createEntityTables` runs
20
+ * unconditionally on every archive open, including legacy archives that
21
+ * still lack `body_hash` at that point; an unconditional
22
+ * `CREATE INDEX ... body_hash` there would fail with `no such column` before
23
+ * this migration ever runs. This function is the one place guaranteed to
24
+ * run only after the column is confirmed present, for both archive kinds.
25
+ *
26
+ * Only adds the column and its index — it does not backfill values for
27
+ * existing rows (they stay `NULL`). That backfill runs separately, from
28
+ * `backfillBodyHashFromHtmlBlobs` during a viewer-read-model build, since it
29
+ * requires decompressing every page's stored HTML and is too heavy to run on
30
+ * every archive open.
31
+ *
32
+ * Idempotent: adding the column is a no-op once it exists (the index
33
+ * creation always runs, but `IF NOT EXISTS` makes repeat runs a no-op too).
34
+ * Guards on `page_meta`'s existence defensively, though by the time this
35
+ * runs (after `initSchema`, itself after `assertCompatibleVersion` rejects
36
+ * pre-0.13 archives) the table is always present.
37
+ * @param instance - The Knex query builder instance connected to the database.
38
+ */
39
+ export declare function migratePageMetaBodyHash(instance: Knex): Promise<void>;
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Adds the `page_meta.body_hash` column to archives created before this
3
+ * feature, then ensures its index exists.
4
+ *
5
+ * `page_meta` 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 `page_meta` predates this change. This mirrors
10
+ * {@link import('./migrate-main-contents-columns.js').migrateMainContentsColumns}'s
11
+ * catch-up: a `hasColumn`-guarded `ALTER TABLE` for the one column
12
+ * `CREATE TABLE IF NOT EXISTS` cannot retrofit.
13
+ *
14
+ * The index is created here — unconditionally, after the column-add guard,
15
+ * not inside `createEntityTables`'s DDL — for both a fresh archive (where
16
+ * `body_hash` already exists from the DDL, so only the index still needs
17
+ * creating) and a legacy archive (where the column is added just above,
18
+ * then the index follows in the same call). `createEntityTables` runs
19
+ * unconditionally on every archive open, including legacy archives that
20
+ * still lack `body_hash` at that point; an unconditional
21
+ * `CREATE INDEX ... body_hash` there would fail with `no such column` before
22
+ * this migration ever runs. This function is the one place guaranteed to
23
+ * run only after the column is confirmed present, for both archive kinds.
24
+ *
25
+ * Only adds the column and its index — it does not backfill values for
26
+ * existing rows (they stay `NULL`). That backfill runs separately, from
27
+ * `backfillBodyHashFromHtmlBlobs` during a viewer-read-model build, since it
28
+ * requires decompressing every page's stored HTML and is too heavy to run on
29
+ * every archive open.
30
+ *
31
+ * Idempotent: adding the column is a no-op once it exists (the index
32
+ * creation always runs, but `IF NOT EXISTS` makes repeat runs a no-op too).
33
+ * Guards on `page_meta`'s existence defensively, though by the time this
34
+ * runs (after `initSchema`, itself after `assertCompatibleVersion` rejects
35
+ * pre-0.13 archives) the table is always present.
36
+ * @param instance - The Knex query builder instance connected to the database.
37
+ */
38
+ export async function migratePageMetaBodyHash(instance) {
39
+ const hasPageMeta = await instance.schema.hasTable('page_meta');
40
+ if (!hasPageMeta) {
41
+ return;
42
+ }
43
+ const hasColumn = await instance.schema.hasColumn('page_meta', 'body_hash');
44
+ if (!hasColumn) {
45
+ await instance.schema.table('page_meta', (t) => {
46
+ t.binary('body_hash');
47
+ });
48
+ // eslint-disable-next-line no-console
49
+ console.error('[migrate] page_meta.body_hash column added');
50
+ }
51
+ await instance.raw('CREATE INDEX IF NOT EXISTS idx_page_meta_body_hash ON page_meta(body_hash)');
52
+ }
@@ -0,0 +1,22 @@
1
+ import type { Knex } from 'knex';
2
+ /**
3
+ * Adds the `page_meta.console_error_count` column to archives created
4
+ * before this feature (issue #228).
5
+ *
6
+ * `page_meta` 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 `page_meta` predates this change. Same
11
+ * catch-up shape as {@link import('./migrate-page-meta-body-hash.js').migratePageMetaBodyHash}.
12
+ *
13
+ * Unlike `body_hash`, there is no separate backfill step: a page that has
14
+ * never been re-scraped since this feature shipped has no
15
+ * `page_console_logs` rows either, so `0` (the value `replaceConsoleLogs`
16
+ * writes for a page with no error/pageerror entries) is already the
17
+ * correct value, not a placeholder pending backfill.
18
+ *
19
+ * Idempotent: adding the column is a no-op once it exists.
20
+ * @param instance - The Knex query builder instance connected to the database.
21
+ */
22
+ export declare function migratePageMetaConsoleErrorCount(instance: Knex): Promise<void>;
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Adds the `page_meta.console_error_count` column to archives created
3
+ * before this feature (issue #228).
4
+ *
5
+ * `page_meta` 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 `page_meta` predates this change. Same
10
+ * catch-up shape as {@link import('./migrate-page-meta-body-hash.js').migratePageMetaBodyHash}.
11
+ *
12
+ * Unlike `body_hash`, there is no separate backfill step: a page that has
13
+ * never been re-scraped since this feature shipped has no
14
+ * `page_console_logs` rows either, so `0` (the value `replaceConsoleLogs`
15
+ * writes for a page with no error/pageerror entries) is already the
16
+ * correct value, not a placeholder pending backfill.
17
+ *
18
+ * Idempotent: adding the column is a no-op once it exists.
19
+ * @param instance - The Knex query builder instance connected to the database.
20
+ */
21
+ export async function migratePageMetaConsoleErrorCount(instance) {
22
+ const hasPageMeta = await instance.schema.hasTable('page_meta');
23
+ if (!hasPageMeta) {
24
+ return;
25
+ }
26
+ const hasColumn = await instance.schema.hasColumn('page_meta', 'console_error_count');
27
+ if (!hasColumn) {
28
+ await instance.schema.table('page_meta', (t) => {
29
+ t.integer('console_error_count');
30
+ });
31
+ // eslint-disable-next-line no-console
32
+ console.error('[migrate] page_meta.console_error_count column added');
33
+ }
34
+ }
@@ -406,6 +406,7 @@ export default class Page {
406
406
  isInternalPage: boolean;
407
407
  getAnchors: Anchor[];
408
408
  getReferrers: Referrer[];
409
+ dir: string | number | null;
409
410
  title: string | number | null;
410
411
  main_content_word_count: string | number | null;
411
412
  main_content_node_name: string | number | null;
@@ -425,7 +426,6 @@ export default class Page {
425
426
  scroll_height_desktop: string | number | null;
426
427
  scroll_height_mobile: string | number | null;
427
428
  lang: string | number | null;
428
- dir: string | number | null;
429
429
  charset: string | number | null;
430
430
  baseHref: string | number | null;
431
431
  viewport_raw: string | number | null;
@@ -128,9 +128,9 @@ export interface InventoryRunMeta {
128
128
  ran_at: string;
129
129
  /** Human-readable identifier (e.g. `'prod-2026-06-21'`). `null` when the caller did not supply one. */
130
130
  list_label?: string | null;
131
- /** SHA-256 hex digest of the source file. `null` if hashing failed (e.g. file vanished mid-run). */
131
+ /** SHA-256 hex digest of the source file. `null` for programmatic callers that built the URL list in-memory (no source file to hash). */
132
132
  source_file_sha256?: string | null;
133
- /** Number of non-empty lines in the input list (= URL count before scope filtering). */
133
+ /** Number of valid URLs in the input list, after the CLI has warned-and-dropped unparseable-URL lines but before scope filtering. */
134
134
  total_lines?: number | null;
135
135
  /** Number of new HTML seeds inserted as `content_items` rows by this run. */
136
136
  new_pages?: number | null;
@@ -138,9 +138,54 @@ export interface InventoryRunMeta {
138
138
  new_resources?: number | null;
139
139
  /** Number of input URLs dropped because they fell outside the archived scope. */
140
140
  scope_skipped?: number | null;
141
+ /** Number of source-file lines dropped by the CLI for failing URL validation, before this row's `total_lines` was counted. `null` for programmatic callers that built the URL list in-memory (no source file to have invalid lines). */
142
+ invalid_skipped?: number | null;
141
143
  /** Free-form text for backfill annotations or operator notes. */
142
144
  notes?: string | null;
143
145
  }
146
+ /**
147
+ * A row in `network_outages` — one detected operator-network outage.
148
+ *
149
+ * Append-only except `ended_at`: it is written once, `NULL`, when the
150
+ * outage is first detected, and updated exactly once when a recovery probe
151
+ * succeeds. A row can also be left `ended_at = NULL` forever if the crawl
152
+ * process is killed mid-outage — readers must resolve this via a clamp
153
+ * (see `is-within-outage-window.ts` and the `db-ops/outages/` writer that
154
+ * closes stale-open rows on the next writer session), never by treating
155
+ * `NULL` as an unbounded window.
156
+ */
157
+ export interface NetworkOutageRow {
158
+ id: number;
159
+ /** Epoch ms, backdated to the earliest error still inside the detector's sliding window at trigger time. */
160
+ started_at: number;
161
+ /** Epoch ms the sliding window actually crossed both thresholds. */
162
+ detected_at: number;
163
+ /** Epoch ms the recovery probe first succeeded, or `null` while still open / if the session crashed before recovery. */
164
+ ended_at: number | null;
165
+ /** Hostname the recovery probe targeted, or `null` if none was available (see `choose-probe-host.ts`). */
166
+ probe_host: string | null;
167
+ /** Error count in the detector's window at trigger time. */
168
+ trigger_error_count: number;
169
+ /** Distinct host count in the detector's window at trigger time. */
170
+ trigger_host_count: number;
171
+ }
172
+ /**
173
+ * Fields required to record a newly-detected outage via
174
+ * `Database.insertNetworkOutage`. camelCase (unlike {@link NetworkOutageRow}
175
+ * / {@link InventoryRunMeta}) because callers build this directly from
176
+ * `NetworkOutageDetector`'s camelCase `OutageSuspect` plus a probe host —
177
+ * the db-op does the camelCase → snake_case column mapping on write.
178
+ */
179
+ export interface InsertNetworkOutageParams {
180
+ /** Backdated to the earliest error still inside the detector's window at trigger time. */
181
+ startedAt: number;
182
+ /** When the sliding window actually crossed both thresholds. */
183
+ detectedAt: number;
184
+ /** Hostname the recovery probe will target, or `null` if none was available. */
185
+ probeHost: string | null;
186
+ triggerErrorCount: number;
187
+ triggerHostCount: number;
188
+ }
144
189
  /**
145
190
  * Filter type for querying pages from the database.
146
191
  *
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Computes the Tier A alias key for `url`: two URLs share a Tier A key when
3
+ * they differ only by scheme (`http` vs `https` are folded together),
4
+ * host letter-casing, or a `/index.{ext}` path suffix — differences that
5
+ * are safe to treat as "the same resource" from the URL string alone, with
6
+ * no need to compare rendered content.
7
+ *
8
+ * This is a canonical-key function, not a pairwise comparator: grouping
9
+ * rows by exact string equality of this function's output is definitionally
10
+ * an equivalence relation (reflexive, symmetric, transitive), unlike
11
+ * `compareUrlSortKeys`'s pairwise "roughly equal" comparison (see
12
+ * ARCHITECTURE.md's "URL natural-sort comparator は推移律を保証しない" —
13
+ * that non-transitivity problem is inherent to pairwise comparators, and
14
+ * does not apply to a canonical-key partition like this one).
15
+ *
16
+ * Deliberately does not touch the query string or fragment: a query-string
17
+ * difference is not addressed by this feature (out of scope — see the
18
+ * `body_hash` masking, a separate content-hash-based mechanism, for
19
+ * detecting meaningless-parameter duplicates), and fragments are
20
+ * client-side only and never affect server-rendered content.
21
+ * @param url - The URL string to compute a key for.
22
+ * @returns The Tier A key, or `null` if `url` is not a parseable http(s) URL.
23
+ * @example
24
+ * ```ts
25
+ * computeTierAAliasKey('http://Example.com/about/index.html');
26
+ * // 'example.com/about/'
27
+ * computeTierAAliasKey('https://example.com/about/');
28
+ * // 'example.com/about/' -- same key: scheme and /index.html are folded
29
+ * ```
30
+ */
31
+ export declare function computeTierAAliasKey(url: string): string | null;
@@ -0,0 +1,36 @@
1
+ import { formatAliasKey } from './format-alias-key.js';
2
+ import { parseAliasKeyParts } from './parse-alias-key-parts.js';
3
+ /**
4
+ * Computes the Tier A alias key for `url`: two URLs share a Tier A key when
5
+ * they differ only by scheme (`http` vs `https` are folded together),
6
+ * host letter-casing, or a `/index.{ext}` path suffix — differences that
7
+ * are safe to treat as "the same resource" from the URL string alone, with
8
+ * no need to compare rendered content.
9
+ *
10
+ * This is a canonical-key function, not a pairwise comparator: grouping
11
+ * rows by exact string equality of this function's output is definitionally
12
+ * an equivalence relation (reflexive, symmetric, transitive), unlike
13
+ * `compareUrlSortKeys`'s pairwise "roughly equal" comparison (see
14
+ * ARCHITECTURE.md's "URL natural-sort comparator は推移律を保証しない" —
15
+ * that non-transitivity problem is inherent to pairwise comparators, and
16
+ * does not apply to a canonical-key partition like this one).
17
+ *
18
+ * Deliberately does not touch the query string or fragment: a query-string
19
+ * difference is not addressed by this feature (out of scope — see the
20
+ * `body_hash` masking, a separate content-hash-based mechanism, for
21
+ * detecting meaningless-parameter duplicates), and fragments are
22
+ * client-side only and never affect server-rendered content.
23
+ * @param url - The URL string to compute a key for.
24
+ * @returns The Tier A key, or `null` if `url` is not a parseable http(s) URL.
25
+ * @example
26
+ * ```ts
27
+ * computeTierAAliasKey('http://Example.com/about/index.html');
28
+ * // 'example.com/about/'
29
+ * computeTierAAliasKey('https://example.com/about/');
30
+ * // 'example.com/about/' -- same key: scheme and /index.html are folded
31
+ * ```
32
+ */
33
+ export function computeTierAAliasKey(url) {
34
+ const parts = parseAliasKeyParts(url);
35
+ return parts ? formatAliasKey(parts) : null;
36
+ }