@nitpicker/crawler 0.11.0 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. package/lib/archive/archive.d.ts +117 -2
  2. package/lib/archive/archive.js +147 -2
  3. package/lib/archive/cache/compute-archive-cache-key.d.ts +39 -0
  4. package/lib/archive/cache/compute-archive-cache-key.js +95 -0
  5. package/lib/archive/cache/extract-archive-to-cache.d.ts +43 -0
  6. package/lib/archive/cache/extract-archive-to-cache.js +309 -0
  7. package/lib/archive/cache/get-archive-cache-root.d.ts +20 -0
  8. package/lib/archive/cache/get-archive-cache-root.js +53 -0
  9. package/lib/archive/cache/is-archive-cache-disabled.d.ts +24 -0
  10. package/lib/archive/cache/is-archive-cache-disabled.js +34 -0
  11. package/lib/archive/cache/resolve-archive-cache-dir.d.ts +26 -0
  12. package/lib/archive/cache/resolve-archive-cache-dir.js +32 -0
  13. package/lib/archive/database.d.ts +216 -15
  14. package/lib/archive/database.js +1459 -938
  15. package/lib/archive/derive-lineage-from-parent.d.ts +37 -0
  16. package/lib/archive/derive-lineage-from-parent.js +42 -0
  17. package/lib/archive/get-failed-page-messages.d.ts +43 -0
  18. package/lib/archive/get-failed-page-messages.js +131 -0
  19. package/lib/archive/init-schema.js +153 -1
  20. package/lib/archive/is-inventory-source.d.ts +21 -0
  21. package/lib/archive/is-inventory-source.js +22 -0
  22. package/lib/archive/migrate-inventory-runs.d.ts +29 -0
  23. package/lib/archive/migrate-inventory-runs.js +52 -0
  24. package/lib/archive/types.d.ts +33 -0
  25. package/lib/classify-error-kind.d.ts +19 -0
  26. package/lib/classify-error-kind.js +122 -0
  27. package/lib/crawler/build-js-redirect-edge.d.ts +68 -0
  28. package/lib/crawler/build-js-redirect-edge.js +57 -0
  29. package/lib/crawler/build-redirect-event.d.ts +24 -0
  30. package/lib/crawler/build-redirect-event.js +28 -0
  31. package/lib/crawler/clear-dns-burned-host-cache.d.ts +6 -0
  32. package/lib/crawler/clear-dns-burned-host-cache.js +11 -0
  33. package/lib/crawler/crawler.d.ts +3 -1
  34. package/lib/crawler/crawler.js +655 -107
  35. package/lib/crawler/derive-js-redirect-target.d.ts +68 -0
  36. package/lib/crawler/derive-js-redirect-target.js +129 -0
  37. package/lib/crawler/derive-resource-source.d.ts +25 -15
  38. package/lib/crawler/derive-resource-source.js +28 -17
  39. package/lib/crawler/dns-burned-host-cache.d.ts +26 -0
  40. package/lib/crawler/dns-burned-host-cache.js +25 -0
  41. package/lib/crawler/dns-burned-host-short-circuit-counter.d.ts +13 -0
  42. package/lib/crawler/dns-burned-host-short-circuit-counter.js +11 -0
  43. package/lib/crawler/fetch-destination.d.ts +12 -4
  44. package/lib/crawler/fetch-destination.js +94 -16
  45. package/lib/crawler/is-js-redirect-error-shape.d.ts +40 -0
  46. package/lib/crawler/is-js-redirect-error-shape.js +53 -0
  47. package/lib/crawler/is-puppeteer-fallback-candidate.d.ts +16 -0
  48. package/lib/crawler/is-puppeteer-fallback-candidate.js +63 -0
  49. package/lib/crawler/link-list.d.ts +21 -1
  50. package/lib/crawler/link-list.js +23 -3
  51. package/lib/crawler/plan-sub-resource-emits.d.ts +63 -0
  52. package/lib/crawler/plan-sub-resource-emits.js +44 -0
  53. package/lib/crawler/preload-short-circuit-error.d.ts +22 -0
  54. package/lib/crawler/preload-short-circuit-error.js +25 -0
  55. package/lib/crawler/should-burn-host.d.ts +78 -0
  56. package/lib/crawler/should-burn-host.js +61 -0
  57. package/lib/crawler/should-get-fallback-on-head-failure.d.ts +38 -0
  58. package/lib/crawler/should-get-fallback-on-head-failure.js +46 -0
  59. package/lib/crawler/types.d.ts +107 -0
  60. package/lib/crawler-orchestrator.d.ts +13 -3
  61. package/lib/crawler-orchestrator.js +292 -69
  62. package/lib/crawler.d.ts +3 -2
  63. package/lib/crawler.js +3 -1
  64. package/lib/permanent-error-kinds.d.ts +43 -0
  65. package/lib/permanent-error-kinds.js +48 -0
  66. package/lib/types.d.ts +84 -0
  67. package/lib/utils/compute-file-sha256.d.ts +23 -0
  68. package/lib/utils/compute-file-sha256.js +55 -0
  69. package/lib/utils/error/emit-error-with-retry.d.ts +40 -0
  70. package/lib/utils/error/emit-error-with-retry.js +44 -0
  71. package/lib/utils/error/emit-error.d.ts +39 -0
  72. package/lib/utils/error/emit-error.js +41 -0
  73. package/package.json +11 -11
  74. package/lib/utils/error/error-emitter.d.ts +0 -18
  75. package/lib/utils/error/error-emitter.js +0 -29
@@ -1,4 +1,4 @@
1
- import type { Config, PageSource } from './types.js';
1
+ import type { Config, InventoryRunMeta, PageSource } from './types.js';
2
2
  import type { PageData, CrawlerError, Resource } from '../utils/types/types.js';
3
3
  import type { ExURL, ParseURLOptions } from '@d-zero/shared/parse-url';
4
4
  import { ArchiveAccessor } from './archive-accessor.js';
@@ -84,6 +84,16 @@ export default class Archive extends ArchiveAccessor {
84
84
  * @returns URLs already present in `resources`.
85
85
  */
86
86
  getExistingResourceUrls(urls: readonly string[]): Promise<string[]>;
87
+ /**
88
+ * Look up the `source` column of a single page row by its URL key. Thin
89
+ * facade over {@link Database.getPageSourceByUrl} — exposes the lookup
90
+ * to the orchestrator so it can inject a `PageSourceLookup` into the
91
+ * Crawler for sub-resource lineage propagation on `--resume` /
92
+ * `--retry-failed` sessions.
93
+ * @param url - URL key in `url.withoutHashAndAuth` form.
94
+ * @returns The recorded `source`, or `undefined` when no row exists.
95
+ */
96
+ getPageSourceByUrl(url: string): Promise<PageSource | undefined>;
87
97
  /**
88
98
  * Retrieves a single recorded sub-resource by its URL.
89
99
  * @param urls - URL candidates to match against the stored resource URL.
@@ -103,6 +113,61 @@ export default class Archive extends ArchiveAccessor {
103
113
  * @returns The base URL string.
104
114
  */
105
115
  getUrl(): Promise<any>;
116
+ /**
117
+ * Pre-insert inventory non-HTML URLs as `source='inventory-seed'`
118
+ * placeholders in the `resources` table — the non-HTML counterpart of
119
+ * {@link Archive.insertInventorySeeds}. Replaces the previous per-URL
120
+ * `setResources` loop in `CrawlerOrchestrator.inventory` so the
121
+ * ingestion phase commits all non-HTML rows in one chunked round-trip
122
+ * per 500 (a 50k-URL inventory list dropped from minutes-inside-`.bak`
123
+ * to seconds).
124
+ *
125
+ * Thin facade over {@link Database.insertInventoryResources}.
126
+ * `ExURL.href` is the storage key for `resources.url` (matches what
127
+ * `insertResource` writes for the per-URL path); we normalise here so
128
+ * the orchestrator stays decoupled from the storage form.
129
+ * @param urls - Non-HTML inventory URLs to record. No-op when empty.
130
+ */
131
+ insertInventoryResources(urls: readonly ExURL[]): Promise<void>;
132
+ /**
133
+ * Pre-insert inventory HTML seeds as `scraped=0`, `source='inventory-seed'`
134
+ * placeholder pages so the URL is durably tracked in the archive **before**
135
+ * the scrape phase starts. Thin facade over
136
+ * {@link Database.insertInventorySeeds} — see that method's JSDoc for the
137
+ * Ctrl+C-tolerance rationale and the `getCrawlingState` interaction.
138
+ *
139
+ * `ExURL` inputs are normalised to `withoutHashAndAuth` here so the storage
140
+ * key matches what `#getIdByUrl` writes for crawled rows, keeping the
141
+ * crawled-wins downgrade and the existing-URL filter (`getExistingPageUrls`)
142
+ * lookups consistent.
143
+ * @param urls - HTML seed URLs to pre-insert. No-op when empty.
144
+ */
145
+ insertInventorySeeds(urls: readonly ExURL[]): Promise<void>;
146
+ /**
147
+ * Hostnames whose `crawl_errors` history is consistently DNS failures and
148
+ * for which no recent 2xx/3xx page or resource is recorded. Consumed by
149
+ * `CrawlerOrchestrator.#preloadDnsBurnedHostCache` to seed the DNS-burned
150
+ * host cache at re-open (append / inventory / retryFailed / resume), so
151
+ * the next crawl skips HEAD pre-flight on hosts the previous crawl
152
+ * already proved unreachable.
153
+ *
154
+ * Deliberately exposed only on `Archive` (writer-side) — read-only
155
+ * `ArchiveAccessor` (stub viewer) does not see this method so the
156
+ * stub's no-migration contract is preserved.
157
+ * @returns Lower-cased hostnames safe to short-circuit.
158
+ */
159
+ listDnsBurnedHostCandidates(): Promise<string[]>;
160
+ /**
161
+ * Appends one row to the `inventory_runs` audit log.
162
+ *
163
+ * Thin facade over {@link Database.recordInventoryRun} — keeps the
164
+ * orchestrator decoupled from the knex layer and gives a single
165
+ * write entry point that future Archive-level concerns (locking,
166
+ * mirror sync, etc.) can hook into without touching every caller.
167
+ * @param meta - The run metadata. Only `ran_at` is required.
168
+ * @returns The autoincremented `id` of the inserted row.
169
+ */
170
+ recordInventoryRun(meta: InventoryRunMeta): Promise<number>;
106
171
  /**
107
172
  * Releases the SQLite handle and the advisory lock **without** writing
108
173
  * the archive or removing `tmpDir`.
@@ -163,8 +228,13 @@ export default class Archive extends ArchiveAccessor {
163
228
  * destination edge is written, leaving the destination's stored title / meta /
164
229
  * anchors / images untouched.
165
230
  * @param pageInfo - The HEAD-resolved page data carrying the redirect chain.
231
+ * @param source - Inventory provenance for a brand-new destination row.
232
+ * Forwarded to `recordRedirect` so the destination's `source` (and the
233
+ * chain-intermediate `source` derived from it) lands on the inventory
234
+ * label instead of the DB DEFAULT `'crawled'` when the orchestrator is
235
+ * running an inventory pass. `undefined` keeps the DB DEFAULT.
166
236
  */
167
- setRedirect(pageInfo: PageData): Promise<void>;
237
+ setRedirect(pageInfo: PageData, source?: PageSource): Promise<void>;
168
238
  /**
169
239
  * Stores a sub-resource (CSS, JS, image, etc.) in the archive database.
170
240
  * @param resource - The resource data to store.
@@ -234,6 +304,51 @@ export default class Archive extends ArchiveAccessor {
234
304
  * @returns An ArchiveAccessor instance for querying the archive data.
235
305
  */
236
306
  static connect(tmpDir: string, namespace?: string | null): Promise<ArchiveAccessor>;
307
+ /**
308
+ * Open a `.nitpicker` archive through the read-only tar cache.
309
+ *
310
+ * This is the fast path for read-only consumers (viewer, MCP, query
311
+ * CLI). It diverges from {@link Archive.open} in two important ways:
312
+ *
313
+ * 1. The extracted contents land in an OS-temp-scoped cache directory
314
+ * keyed by the archive's `size + mtime_ns + ctime_ns` (see
315
+ * {@link computeArchiveCacheKey}). Subsequent opens of the same
316
+ * unchanged archive skip the untar entirely. A fresh 10 GB archive
317
+ * pays the ~10 s untar cost once; reopens are instant.
318
+ * 2. The returned value is an {@link ArchiveAccessor} (read-only), not
319
+ * an `Archive` (writer). Closing it tears down the DB handle but
320
+ * leaves the cache directory in place for the next reader. The
321
+ * OS's own temp-directory cleanup (macOS reboot, Linux
322
+ * `systemd-tmpfiles`, Windows Disk Cleanup) reclaims stale
323
+ * entries — we do not own eviction.
324
+ *
325
+ * Migrations: the writer-side migration stack
326
+ * (`initSchema` / `migrate*`) runs once at cache-miss extraction, so
327
+ * the cache directory always lands on the current schema before the
328
+ * read-only re-open. Cache hits then skip migrations entirely.
329
+ *
330
+ * Override the cache location with `NITPICKER_TAR_CACHE_DIR`. The
331
+ * disable switch (`NITPICKER_DISABLE_TAR_CACHE=1`) is honoured by
332
+ * the caller (`ArchiveManager.open` falls back to {@link Archive.open}
333
+ * in that case); this function itself always goes through the cache.
334
+ *
335
+ * Writer entry points (`crawl --append`, `crawl --retry-failed`) must
336
+ * NOT use this path — they need the lock + write-back semantics of
337
+ * {@link Archive.open}.
338
+ * @param filePath - Absolute path to the `.nitpicker` file.
339
+ * @param namespace - Optional namespace forwarded to {@link ArchiveAccessor}.
340
+ * @returns A read-only {@link ArchiveAccessor} backed by the cache directory.
341
+ * @example
342
+ * ```ts
343
+ * const accessor = await Archive.openCached('/path/to/site.nitpicker');
344
+ * try {
345
+ * const summary = await getSummary(accessor);
346
+ * } finally {
347
+ * await accessor.close(); // tears down DB handle, cacheDir persists.
348
+ * }
349
+ * ```
350
+ */
351
+ static openCached(filePath: string, namespace?: string | null): Promise<ArchiveAccessor>;
237
352
  /**
238
353
  * Creates a new archive at the specified file path.
239
354
  * Initializes a temporary working directory and a fresh SQLite database.
@@ -1,6 +1,10 @@
1
1
  import path from 'node:path';
2
2
  import { ArchiveAccessor } from './archive-accessor.js';
3
3
  import { acquireArchiveLock } from './archive-lock.js';
4
+ import { computeArchiveCacheKey } from './cache/compute-archive-cache-key.js';
5
+ import { extractArchiveToCache } from './cache/extract-archive-to-cache.js';
6
+ import { getArchiveCacheRoot } from './cache/get-archive-cache-root.js';
7
+ import { resolveArchiveCacheDir } from './cache/resolve-archive-cache-dir.js';
4
8
  import { Database } from './database.js';
5
9
  import { dbLog, log, saveLog } from './debug.js';
6
10
  import { appendText } from './filesystem/append-text.js';
@@ -136,6 +140,18 @@ export default class Archive extends ArchiveAccessor {
136
140
  async getExistingResourceUrls(urls) {
137
141
  return this.#db.getExistingResourceUrls(urls);
138
142
  }
143
+ /**
144
+ * Look up the `source` column of a single page row by its URL key. Thin
145
+ * facade over {@link Database.getPageSourceByUrl} — exposes the lookup
146
+ * to the orchestrator so it can inject a `PageSourceLookup` into the
147
+ * Crawler for sub-resource lineage propagation on `--resume` /
148
+ * `--retry-failed` sessions.
149
+ * @param url - URL key in `url.withoutHashAndAuth` form.
150
+ * @returns The recorded `source`, or `undefined` when no row exists.
151
+ */
152
+ async getPageSourceByUrl(url) {
153
+ return this.#db.getPageSourceByUrl(url);
154
+ }
139
155
  /**
140
156
  * Retrieves a single recorded sub-resource by its URL.
141
157
  * @param urls - URL candidates to match against the stored resource URL.
@@ -161,6 +177,78 @@ export default class Archive extends ArchiveAccessor {
161
177
  async getUrl() {
162
178
  return this.#db.getBaseUrl();
163
179
  }
180
+ /**
181
+ * Pre-insert inventory non-HTML URLs as `source='inventory-seed'`
182
+ * placeholders in the `resources` table — the non-HTML counterpart of
183
+ * {@link Archive.insertInventorySeeds}. Replaces the previous per-URL
184
+ * `setResources` loop in `CrawlerOrchestrator.inventory` so the
185
+ * ingestion phase commits all non-HTML rows in one chunked round-trip
186
+ * per 500 (a 50k-URL inventory list dropped from minutes-inside-`.bak`
187
+ * to seconds).
188
+ *
189
+ * Thin facade over {@link Database.insertInventoryResources}.
190
+ * `ExURL.href` is the storage key for `resources.url` (matches what
191
+ * `insertResource` writes for the per-URL path); we normalise here so
192
+ * the orchestrator stays decoupled from the storage form.
193
+ * @param urls - Non-HTML inventory URLs to record. No-op when empty.
194
+ */
195
+ async insertInventoryResources(urls) {
196
+ if (urls.length === 0) {
197
+ return;
198
+ }
199
+ dbLog('Insert inventory resources: %d URL(s)', urls.length);
200
+ await this.#db.insertInventoryResources(urls.map((u) => u.href));
201
+ }
202
+ /**
203
+ * Pre-insert inventory HTML seeds as `scraped=0`, `source='inventory-seed'`
204
+ * placeholder pages so the URL is durably tracked in the archive **before**
205
+ * the scrape phase starts. Thin facade over
206
+ * {@link Database.insertInventorySeeds} — see that method's JSDoc for the
207
+ * Ctrl+C-tolerance rationale and the `getCrawlingState` interaction.
208
+ *
209
+ * `ExURL` inputs are normalised to `withoutHashAndAuth` here so the storage
210
+ * key matches what `#getIdByUrl` writes for crawled rows, keeping the
211
+ * crawled-wins downgrade and the existing-URL filter (`getExistingPageUrls`)
212
+ * lookups consistent.
213
+ * @param urls - HTML seed URLs to pre-insert. No-op when empty.
214
+ */
215
+ async insertInventorySeeds(urls) {
216
+ if (urls.length === 0) {
217
+ return;
218
+ }
219
+ dbLog('Insert inventory seeds: %d URL(s)', urls.length);
220
+ await this.#db.insertInventorySeeds(urls.map((u) => u.withoutHashAndAuth));
221
+ }
222
+ /**
223
+ * Hostnames whose `crawl_errors` history is consistently DNS failures and
224
+ * for which no recent 2xx/3xx page or resource is recorded. Consumed by
225
+ * `CrawlerOrchestrator.#preloadDnsBurnedHostCache` to seed the DNS-burned
226
+ * host cache at re-open (append / inventory / retryFailed / resume), so
227
+ * the next crawl skips HEAD pre-flight on hosts the previous crawl
228
+ * already proved unreachable.
229
+ *
230
+ * Deliberately exposed only on `Archive` (writer-side) — read-only
231
+ * `ArchiveAccessor` (stub viewer) does not see this method so the
232
+ * stub's no-migration contract is preserved.
233
+ * @returns Lower-cased hostnames safe to short-circuit.
234
+ */
235
+ async listDnsBurnedHostCandidates() {
236
+ return this.#db.listDnsBurnedHostCandidates();
237
+ }
238
+ /**
239
+ * Appends one row to the `inventory_runs` audit log.
240
+ *
241
+ * Thin facade over {@link Database.recordInventoryRun} — keeps the
242
+ * orchestrator decoupled from the knex layer and gives a single
243
+ * write entry point that future Archive-level concerns (locking,
244
+ * mirror sync, etc.) can hook into without touching every caller.
245
+ * @param meta - The run metadata. Only `ran_at` is required.
246
+ * @returns The autoincremented `id` of the inserted row.
247
+ */
248
+ async recordInventoryRun(meta) {
249
+ dbLog('Record inventory run: %s', meta.list_label ?? meta.ran_at);
250
+ return await this.#db.recordInventoryRun(meta);
251
+ }
164
252
  /**
165
253
  * Releases the SQLite handle and the advisory lock **without** writing
166
254
  * the archive or removing `tmpDir`.
@@ -242,10 +330,15 @@ export default class Archive extends ArchiveAccessor {
242
330
  * destination edge is written, leaving the destination's stored title / meta /
243
331
  * anchors / images untouched.
244
332
  * @param pageInfo - The HEAD-resolved page data carrying the redirect chain.
333
+ * @param source - Inventory provenance for a brand-new destination row.
334
+ * Forwarded to `recordRedirect` so the destination's `source` (and the
335
+ * chain-intermediate `source` derived from it) lands on the inventory
336
+ * label instead of the DB DEFAULT `'crawled'` when the orchestrator is
337
+ * running an inventory pass. `undefined` keeps the DB DEFAULT.
245
338
  */
246
- async setRedirect(pageInfo) {
339
+ async setRedirect(pageInfo, source) {
247
340
  dbLog('Set redirect: %s', pageInfo.url.href);
248
- await this.#db.recordRedirect(pageInfo);
341
+ await this.#db.recordRedirect(pageInfo, source);
249
342
  }
250
343
  /**
251
344
  * Stores a sub-resource (CSS, JS, image, etc.) in the archive database.
@@ -380,6 +473,58 @@ export default class Archive extends ArchiveAccessor {
380
473
  const archive = new ArchiveAccessor(tmpDir, db, namespace, { readOnly: true });
381
474
  return archive;
382
475
  }
476
+ /**
477
+ * Open a `.nitpicker` archive through the read-only tar cache.
478
+ *
479
+ * This is the fast path for read-only consumers (viewer, MCP, query
480
+ * CLI). It diverges from {@link Archive.open} in two important ways:
481
+ *
482
+ * 1. The extracted contents land in an OS-temp-scoped cache directory
483
+ * keyed by the archive's `size + mtime_ns + ctime_ns` (see
484
+ * {@link computeArchiveCacheKey}). Subsequent opens of the same
485
+ * unchanged archive skip the untar entirely. A fresh 10 GB archive
486
+ * pays the ~10 s untar cost once; reopens are instant.
487
+ * 2. The returned value is an {@link ArchiveAccessor} (read-only), not
488
+ * an `Archive` (writer). Closing it tears down the DB handle but
489
+ * leaves the cache directory in place for the next reader. The
490
+ * OS's own temp-directory cleanup (macOS reboot, Linux
491
+ * `systemd-tmpfiles`, Windows Disk Cleanup) reclaims stale
492
+ * entries — we do not own eviction.
493
+ *
494
+ * Migrations: the writer-side migration stack
495
+ * (`initSchema` / `migrate*`) runs once at cache-miss extraction, so
496
+ * the cache directory always lands on the current schema before the
497
+ * read-only re-open. Cache hits then skip migrations entirely.
498
+ *
499
+ * Override the cache location with `NITPICKER_TAR_CACHE_DIR`. The
500
+ * disable switch (`NITPICKER_DISABLE_TAR_CACHE=1`) is honoured by
501
+ * the caller (`ArchiveManager.open` falls back to {@link Archive.open}
502
+ * in that case); this function itself always goes through the cache.
503
+ *
504
+ * Writer entry points (`crawl --append`, `crawl --retry-failed`) must
505
+ * NOT use this path — they need the lock + write-back semantics of
506
+ * {@link Archive.open}.
507
+ * @param filePath - Absolute path to the `.nitpicker` file.
508
+ * @param namespace - Optional namespace forwarded to {@link ArchiveAccessor}.
509
+ * @returns A read-only {@link ArchiveAccessor} backed by the cache directory.
510
+ * @example
511
+ * ```ts
512
+ * const accessor = await Archive.openCached('/path/to/site.nitpicker');
513
+ * try {
514
+ * const summary = await getSummary(accessor);
515
+ * } finally {
516
+ * await accessor.close(); // tears down DB handle, cacheDir persists.
517
+ * }
518
+ * ```
519
+ */
520
+ static async openCached(filePath, namespace = null) {
521
+ const cacheRoot = getArchiveCacheRoot();
522
+ const cacheKey = await computeArchiveCacheKey(filePath);
523
+ const cacheDir = resolveArchiveCacheDir(cacheRoot, cacheKey, filePath);
524
+ log('Open cached: %s (cacheDir=%s)', filePath, cacheDir);
525
+ await extractArchiveToCache(filePath, cacheRoot, cacheDir, cacheKey);
526
+ return await Archive.connect(cacheDir, namespace);
527
+ }
383
528
  /**
384
529
  * Creates a new archive at the specified file path.
385
530
  * Initializes a temporary working directory and a fresh SQLite database.
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Derive a stable cache key from a `.nitpicker` file's inode metadata
3
+ * plus a head+tail content sample.
4
+ *
5
+ * Metadata fields:
6
+ *
7
+ * - `size` covers the bulk of accidental cache collision risk in O(1).
8
+ * - `mtime` (mod time) changes whenever the file's content is rewritten,
9
+ * which is the common case (`crawl --append`, `crawl --retry-failed`,
10
+ * `cp -f`, rsync).
11
+ * - `ctime` (inode change time) closes the `touch -m -t <past>` loophole:
12
+ * even if a user resets `mtime` to fake "unchanged", the act of touching
13
+ * bumps `ctime` on POSIX so the key still rolls.
14
+ *
15
+ * Head + tail digest:
16
+ *
17
+ * - On filesystems with low-resolution timestamps (FAT / exFAT / NFSv3 /
18
+ * some Docker volume mounts where mtime/ctime are second-granular and
19
+ * sometimes don't move on small appends), `size + mtime + ctime`
20
+ * alone can stay identical across an in-place rewrite.
21
+ * - We hash the first {@link HEAD_TAIL_HASH_BYTES} bytes and the last
22
+ * {@link HEAD_TAIL_HASH_BYTES} bytes to detect this. For tar archives
23
+ * the head holds the first entry's header (which moves when the inner
24
+ * directory's name changes) and the tail holds the last entry's data
25
+ * (which always moves on `crawl --append` because the appended pages
26
+ * land near the end of the tar stream).
27
+ * - Full-content sha256 was rejected: ~20-30 s on a 10 GB archive,
28
+ * which is slower than the untar this cache is meant to avoid.
29
+ *
30
+ * The key is stable across symlinks (the caller resolves via
31
+ * `fs.realpath` upstream) but **not** across hardlinks pointing at a
32
+ * mutated inode — that is intentional, hardlinking is a power-user
33
+ * move and the user is expected to know the cache will share an entry.
34
+ * @param archivePath - Absolute path to the `.nitpicker` file.
35
+ * @returns A string of the form
36
+ * `<size>-<mtime_ns>-<ctime_ns>-<headHex>-<tailHex>` suitable for use
37
+ * as a directory-name component.
38
+ */
39
+ export declare function computeArchiveCacheKey(archivePath: string): Promise<string>;
@@ -0,0 +1,95 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { createReadStream } from 'node:fs';
3
+ import fs from 'node:fs/promises';
4
+ /**
5
+ * Number of bytes hashed from each end of the file to corner the case
6
+ * where two different archives share `size + mtime + ctime` by accident.
7
+ *
8
+ * 64 KiB is a sweet spot: on a 10 GB archive it reads 0.0006 % of the
9
+ * file (~1 ms on an SSD, ~5 ms on a spinning disk) while reliably
10
+ * sampling enough of both the tar leading header AND the tail (which
11
+ * for tar archives contains the last-written entry's data, so any real
12
+ * mutation moves at least one of the two windows).
13
+ */
14
+ const HEAD_TAIL_HASH_BYTES = 64 * 1024;
15
+ /**
16
+ * Cache-key segment substituted when a file is smaller than the
17
+ * head+tail sample window. In that case the head segment alone already
18
+ * spans the whole file (size column also rolls), so a separate tail
19
+ * digest is meaningless and we encode that explicitly rather than
20
+ * silently overlapping reads.
21
+ */
22
+ const TAIL_NOT_NEEDED = 'short';
23
+ /**
24
+ * Derive a stable cache key from a `.nitpicker` file's inode metadata
25
+ * plus a head+tail content sample.
26
+ *
27
+ * Metadata fields:
28
+ *
29
+ * - `size` covers the bulk of accidental cache collision risk in O(1).
30
+ * - `mtime` (mod time) changes whenever the file's content is rewritten,
31
+ * which is the common case (`crawl --append`, `crawl --retry-failed`,
32
+ * `cp -f`, rsync).
33
+ * - `ctime` (inode change time) closes the `touch -m -t <past>` loophole:
34
+ * even if a user resets `mtime` to fake "unchanged", the act of touching
35
+ * bumps `ctime` on POSIX so the key still rolls.
36
+ *
37
+ * Head + tail digest:
38
+ *
39
+ * - On filesystems with low-resolution timestamps (FAT / exFAT / NFSv3 /
40
+ * some Docker volume mounts where mtime/ctime are second-granular and
41
+ * sometimes don't move on small appends), `size + mtime + ctime`
42
+ * alone can stay identical across an in-place rewrite.
43
+ * - We hash the first {@link HEAD_TAIL_HASH_BYTES} bytes and the last
44
+ * {@link HEAD_TAIL_HASH_BYTES} bytes to detect this. For tar archives
45
+ * the head holds the first entry's header (which moves when the inner
46
+ * directory's name changes) and the tail holds the last entry's data
47
+ * (which always moves on `crawl --append` because the appended pages
48
+ * land near the end of the tar stream).
49
+ * - Full-content sha256 was rejected: ~20-30 s on a 10 GB archive,
50
+ * which is slower than the untar this cache is meant to avoid.
51
+ *
52
+ * The key is stable across symlinks (the caller resolves via
53
+ * `fs.realpath` upstream) but **not** across hardlinks pointing at a
54
+ * mutated inode — that is intentional, hardlinking is a power-user
55
+ * move and the user is expected to know the cache will share an entry.
56
+ * @param archivePath - Absolute path to the `.nitpicker` file.
57
+ * @returns A string of the form
58
+ * `<size>-<mtime_ns>-<ctime_ns>-<headHex>-<tailHex>` suitable for use
59
+ * as a directory-name component.
60
+ */
61
+ export async function computeArchiveCacheKey(archivePath) {
62
+ const stats = await fs.stat(archivePath, { bigint: true });
63
+ const fileSize = Number(stats.size);
64
+ const headHex = await sha256OfRange(archivePath, 0, Math.min(HEAD_TAIL_HASH_BYTES, fileSize));
65
+ const tailHex = fileSize > HEAD_TAIL_HASH_BYTES
66
+ ? await sha256OfRange(archivePath, fileSize - HEAD_TAIL_HASH_BYTES, HEAD_TAIL_HASH_BYTES)
67
+ : TAIL_NOT_NEEDED;
68
+ return `${stats.size}-${stats.mtimeNs}-${stats.ctimeNs}-${headHex}-${tailHex}`;
69
+ }
70
+ /**
71
+ * SHA-256 the requested byte range of a file via a single read stream.
72
+ * Truncated to 16 hex chars (64 bits) because the digest only needs to
73
+ * disambiguate within a `(size, mtime, ctime)` bucket — full 256-bit
74
+ * fingerprints would only waste filesystem path budget.
75
+ * @param filePath - Absolute path to the file.
76
+ * @param start - Inclusive byte offset to begin reading at.
77
+ * @param length - Number of bytes to read; 0 returns the empty-input digest.
78
+ * @returns A 16-character hex string.
79
+ */
80
+ async function sha256OfRange(filePath, start, length) {
81
+ const hash = createHash('sha256');
82
+ if (length === 0) {
83
+ return hash.digest('hex').slice(0, 16);
84
+ }
85
+ await new Promise((resolve, reject) => {
86
+ const stream = createReadStream(filePath, {
87
+ start,
88
+ end: start + length - 1,
89
+ });
90
+ stream.on('data', (chunk) => hash.update(chunk));
91
+ stream.on('end', () => resolve());
92
+ stream.on('error', (error) => reject(error));
93
+ });
94
+ return hash.digest('hex').slice(0, 16);
95
+ }
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Untar a `.nitpicker` archive into the given cache directory.
3
+ *
4
+ * Concurrency / re-entry contract:
5
+ *
6
+ * - Two viewers opening the same archive race for the cache lock; the
7
+ * loser waits and recheck-loops until the winner writes the ready
8
+ * marker, then short-circuits.
9
+ * - Same-process concurrent callers dedupe through
10
+ * `inFlightByCacheDir` so they share one extraction promise.
11
+ *
12
+ * Per-archive staging:
13
+ *
14
+ * - The tar's inner directory is extracted into `${cacheDir}.staging/`
15
+ * first, then atomically renamed into `cacheDir`. Crucially, the
16
+ * staging path is keyed off `cacheDir` (not the tar's inner directory
17
+ * name) so two archives that happen to share an inner-dir name never
18
+ * collide on the same staging slot.
19
+ *
20
+ * Integrity guard:
21
+ *
22
+ * - After rename, we run the writer-side `Database.connect` so all
23
+ * migrations apply (column adds, new tables) and the version check
24
+ * fires before the ready marker is written. A corrupt or incompatible
25
+ * archive therefore poisons no cache entry — the cacheDir is removed
26
+ * and the next caller retries from scratch.
27
+ *
28
+ * TOCTOU guard:
29
+ *
30
+ * - The cache key is recomputed from the file's stat AFTER the untar
31
+ * completes. If the file changed mid-flight (a concurrent
32
+ * `crawl --append` rewrote it), the freshly-landed cache contents do
33
+ * not correspond to the original key, so we abort and remove the
34
+ * cacheDir so the caller upstream sees the inconsistency rather than
35
+ * serving misattributed data.
36
+ * @param archivePath - Absolute path to the source `.nitpicker` file.
37
+ * @param cacheRoot - Absolute path to the cache root directory.
38
+ * @param cacheDir - Absolute path the extracted contents should end up at.
39
+ * @param cacheKey - The cache key used to derive `cacheDir`. Recomputed
40
+ * after extraction to detect concurrent writers; must match.
41
+ * @returns Resolves once `cacheDir` is ready to be opened read-only.
42
+ */
43
+ export declare function extractArchiveToCache(archivePath: string, cacheRoot: string, cacheDir: string, cacheKey: string): Promise<void>;