@nitpicker/crawler 0.8.0 → 0.11.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 (92) hide show
  1. package/lib/archive/archive-accessor.d.ts +87 -14
  2. package/lib/archive/archive-accessor.js +162 -36
  3. package/lib/archive/archive.d.ts +147 -24
  4. package/lib/archive/archive.js +252 -86
  5. package/lib/archive/database.d.ts +209 -25
  6. package/lib/archive/database.js +928 -108
  7. package/lib/archive/filesystem/peek-tar-top-dir.d.ts +28 -0
  8. package/lib/archive/filesystem/peek-tar-top-dir.js +65 -0
  9. package/lib/archive/init-schema.d.ts +53 -2
  10. package/lib/archive/init-schema.js +247 -15
  11. package/lib/archive/meta/assert-compatible-version.d.ts +39 -0
  12. package/lib/archive/meta/assert-compatible-version.js +72 -0
  13. package/lib/archive/meta/classify-jsonld-type.d.ts +23 -0
  14. package/lib/archive/meta/classify-jsonld-type.js +43 -0
  15. package/lib/archive/meta/compare-semver.d.ts +23 -0
  16. package/lib/archive/meta/compare-semver.js +51 -0
  17. package/lib/archive/meta/compute-page-denormalized.d.ts +21 -0
  18. package/lib/archive/meta/compute-page-denormalized.js +35 -0
  19. package/lib/archive/meta/derive-flat-from-meta.d.ts +35 -0
  20. package/lib/archive/meta/derive-flat-from-meta.js +158 -0
  21. package/lib/archive/meta/derive-meta-extras.d.ts +20 -0
  22. package/lib/archive/meta/derive-meta-extras.js +23 -0
  23. package/lib/archive/meta/extract-tags-for-archive.d.ts +18 -0
  24. package/lib/archive/meta/extract-tags-for-archive.js +36 -0
  25. package/lib/archive/meta/summarize-jsonld.d.ts +17 -0
  26. package/lib/archive/meta/summarize-jsonld.js +29 -0
  27. package/lib/archive/meta/summarize-tags.d.ts +16 -0
  28. package/lib/archive/meta/summarize-tags.js +33 -0
  29. package/lib/archive/meta/types.d.ts +207 -0
  30. package/lib/archive/meta/types.js +33 -0
  31. package/lib/archive/migrate-crawl-errors.d.ts +20 -0
  32. package/lib/archive/migrate-crawl-errors.js +38 -0
  33. package/lib/archive/migrate-html-blob-tables.d.ts +24 -0
  34. package/lib/archive/migrate-html-blob-tables.js +53 -0
  35. package/lib/archive/migrate-page-errors.d.ts +16 -0
  36. package/lib/archive/migrate-page-errors.js +35 -0
  37. package/lib/archive/migrate-pages-resources-source.d.ts +16 -0
  38. package/lib/archive/migrate-pages-resources-source.js +46 -0
  39. package/lib/archive/page.d.ts +187 -49
  40. package/lib/archive/page.js +258 -63
  41. package/lib/archive/peek-archive-lock.d.ts +40 -0
  42. package/lib/archive/peek-archive-lock.js +62 -0
  43. package/lib/archive/resolve-redirect-chain.d.ts +33 -0
  44. package/lib/archive/resolve-redirect-chain.js +27 -0
  45. package/lib/archive/types.d.ts +135 -26
  46. package/lib/crawler/close-browser-safely.d.ts +64 -0
  47. package/lib/crawler/close-browser-safely.js +73 -0
  48. package/lib/crawler/crawler.d.ts +4 -1
  49. package/lib/crawler/crawler.js +290 -32
  50. package/lib/crawler/create-change-phase-handler.d.ts +54 -0
  51. package/lib/crawler/create-change-phase-handler.js +44 -0
  52. package/lib/crawler/derive-page-source.d.ts +23 -0
  53. package/lib/crawler/derive-page-source.js +28 -0
  54. package/lib/crawler/derive-resource-source.d.ts +23 -0
  55. package/lib/crawler/derive-resource-source.js +26 -0
  56. package/lib/crawler/drain-phase-errors.d.ts +48 -0
  57. package/lib/crawler/drain-phase-errors.js +35 -0
  58. package/lib/crawler/fetch-destination.js +38 -2
  59. package/lib/crawler/format-crawl-progress.d.ts +12 -3
  60. package/lib/crawler/format-crawl-progress.js +14 -6
  61. package/lib/crawler/handle-browser-close.d.ts +29 -0
  62. package/lib/crawler/handle-browser-close.js +28 -0
  63. package/lib/crawler/is-html-content-type.d.ts +17 -0
  64. package/lib/crawler/is-html-content-type.js +19 -0
  65. package/lib/crawler/is-likely-html-url.d.ts +22 -0
  66. package/lib/crawler/is-likely-html-url.js +65 -0
  67. package/lib/crawler/kill-process-tree.d.ts +94 -0
  68. package/lib/crawler/kill-process-tree.js +178 -0
  69. package/lib/crawler/link-list.js +2 -1
  70. package/lib/crawler/link-to-page-data.d.ts +13 -5
  71. package/lib/crawler/link-to-page-data.js +26 -5
  72. package/lib/crawler/log-undrained-phase-errors.d.ts +37 -0
  73. package/lib/crawler/log-undrained-phase-errors.js +34 -0
  74. package/lib/crawler/normalize-content-type.d.ts +14 -0
  75. package/lib/crawler/normalize-content-type.js +20 -0
  76. package/lib/crawler/partition-urls-by-html.d.ts +16 -0
  77. package/lib/crawler/partition-urls-by-html.js +23 -0
  78. package/lib/crawler/redirect-dest-key.d.ts +19 -0
  79. package/lib/crawler/redirect-dest-key.js +27 -0
  80. package/lib/crawler/resource-to-page-data.d.ts +28 -0
  81. package/lib/crawler/resource-to-page-data.js +59 -0
  82. package/lib/crawler/types.d.ts +122 -1
  83. package/lib/crawler-orchestrator.d.ts +93 -1
  84. package/lib/crawler-orchestrator.js +389 -12
  85. package/lib/crawler.d.ts +5 -0
  86. package/lib/crawler.js +3 -0
  87. package/lib/resource-row-to-lookup-result.d.ts +13 -0
  88. package/lib/resource-row-to-lookup-result.js +20 -0
  89. package/lib/types.d.ts +11 -1
  90. package/lib/utils/object/parse-response-headers.d.ts +12 -0
  91. package/lib/utils/object/parse-response-headers.js +26 -0
  92. package/package.json +4 -4
@@ -1,5 +1,4 @@
1
1
  import path from 'node:path';
2
- import { zip } from '@d-zero/fs/zip';
3
2
  import { ArchiveAccessor } from './archive-accessor.js';
4
3
  import { acquireArchiveLock } from './archive-lock.js';
5
4
  import { Database } from './database.js';
@@ -7,31 +6,40 @@ import { dbLog, log, saveLog } from './debug.js';
7
6
  import { appendText } from './filesystem/append-text.js';
8
7
  import { exists } from './filesystem/exists.js';
9
8
  import { isDir } from './filesystem/is-dir.js';
10
- import { outputText } from './filesystem/output-text.js';
9
+ import { peekTarTopDir } from './filesystem/peek-tar-top-dir.js';
11
10
  import { remove } from './filesystem/remove.js';
12
11
  import { rename } from './filesystem/rename.js';
13
12
  import { tar } from './filesystem/tar.js';
14
13
  import { untar } from './filesystem/untar.js';
15
14
  /**
16
- * Main archive class for creating, opening, resuming, and writing Nitpicker archive files (`.nitpicker`).
15
+ * Main archive class for creating, opening, resuming, and writing Nitpicker
16
+ * archive files (`.nitpicker`).
17
17
  *
18
- * An Archive wraps a SQLite database and optional HTML snapshots into a compressed
19
- * tar archive. It extends {@link ArchiveAccessor} to provide read access to stored data.
18
+ * An Archive wraps a single SQLite database into a tar archive. HTML
19
+ * bodies live inside the same DB as zstd-compressed BLOBs (see #75) the
20
+ * tar payload is effectively just `db.sqlite`. It extends
21
+ * {@link ArchiveAccessor} to provide read access to stored data.
20
22
  *
21
23
  * Use the static factory methods ({@link Archive.create}, {@link Archive.open},
22
24
  * {@link Archive.resume}, {@link Archive.connect}) to obtain instances.
23
25
  * The constructor is private.
24
26
  */
25
27
  export default class Archive extends ArchiveAccessor {
28
+ /**
29
+ * Promise tracking an in-progress {@link Archive.close} (or
30
+ * {@link Archive.releaseHandle}). Acts as the override's idempotency
31
+ * guard so a second call — e.g. from a signal handler racing the
32
+ * primary teardown — does not re-enter the destructive prologue
33
+ * (write/remove) on a half-mutated state.
34
+ */
35
+ #closeOnce = null;
26
36
  /** The SQLite database instance for reading and writing crawl data. */
27
37
  #db;
28
38
  /** Absolute path to the output `.nitpicker` archive file. */
29
39
  #filePath;
30
40
  /** Lock release function held while the writer owns the archive. */
31
41
  #releaseLock;
32
- /** Absolute path to the HTML snapshot directory within the temporary working directory. */
33
- #snapshotDir;
34
- /** Absolute path to the temporary working directory containing the SQLite DB and snapshots. */
42
+ /** Absolute path to the temporary working directory containing the SQLite DB. */
35
43
  #tmpDir;
36
44
  /**
37
45
  * The absolute file path of the archive (`.nitpicker` file).
@@ -39,18 +47,26 @@ export default class Archive extends ArchiveAccessor {
39
47
  get filePath() {
40
48
  return this.#filePath;
41
49
  }
50
+ /**
51
+ * The intermediate directory `Archive.write()` produces by renaming
52
+ * `tmpDir` before tarring (`{cwd}/{archiveName}`). Exposed so the
53
+ * manager can include it in its cleanup-on-failure path: if `tar()`
54
+ * fails after the rename, this directory is orphaned and would
55
+ * otherwise be invisible to a `rmSync(tmpDir)` recovery.
56
+ */
57
+ get renamedDir() {
58
+ return path.resolve(path.dirname(this.#filePath), path.basename(this.#filePath, path.extname(this.#filePath)));
59
+ }
42
60
  // eslint-disable-next-line no-restricted-syntax
43
61
  constructor(filePath, tmpDir, db, releaseLock) {
44
62
  super(tmpDir, db, '');
45
63
  this.#filePath = filePath;
46
64
  this.#tmpDir = tmpDir;
47
- this.#snapshotDir = path.resolve(this.#tmpDir, Archive.SNAPSHOT_HTML_DIR);
48
65
  this.#db = db;
49
66
  this.#releaseLock = releaseLock;
50
67
  log('create instance: %O', {
51
68
  filePath,
52
69
  tmpDir,
53
- snapshotDir: this.#snapshotDir,
54
70
  });
55
71
  this.#db.on('error', (e) => {
56
72
  void this.emit('error', e);
@@ -61,35 +77,34 @@ export default class Archive extends ArchiveAccessor {
61
77
  */
62
78
  abort() { }
63
79
  /**
64
- * Appends an error entry to the archive's error log file.
80
+ * Records a crawler-level error to both the human-readable `error.log` (full
81
+ * stack, for debugging) and the structured `crawl_errors` table (queryable,
82
+ * for the `error-kinds` analysis). The cause is not classified here — it is
83
+ * derived on read. `error.log` keeps the full stack while `crawl_errors`
84
+ * stores `error.message`; both normally carry the same cause token (e.g.
85
+ * `ENOTFOUND`), so classification agrees across the two — only an error whose
86
+ * cause lives solely in deeper stack frames could differ.
65
87
  * @param error - The crawler error object containing process and URL information.
66
88
  */
67
89
  async addError(error) {
68
90
  const logFile = path.resolve(this.#tmpDir, 'error.log');
69
91
  await appendText(logFile, `[${error.pid}(${error.isMainProcess ? 'main' : 'sub'})] ${error.url} ${error.error.stack ?? error.error}`);
92
+ await this.#db.insertCrawlError(error.url, error.error.message, error.isExternal);
70
93
  }
71
94
  /**
72
- * Closes the archive. If the archive file does not yet exist on disk,
73
- * it writes the archive first. If the temporary directory still exists,
74
- * it is removed.
95
+ * Records a partial scrape failure against the page identified by `url`.
96
+ *
97
+ * The corresponding `pages` row is created on demand (or matched if it
98
+ * already exists), so the call works even if the page's normal data has
99
+ * not been written yet.
100
+ * @param url - URL of the affected page.
101
+ * @param phase - Scrape phase name (typically `'retryExhausted'`).
102
+ * @param message - Human-readable failure message.
103
+ * @param isExternal - Whether the URL is external. Defaults to `false`.
75
104
  */
76
- async close() {
77
- log('Closing');
78
- try {
79
- if (!exists(this.#filePath)) {
80
- log("Save the file because it doesn't exist");
81
- await this.write();
82
- }
83
- else if (exists(this.#tmpDir)) {
84
- log('Remove temporary dir');
85
- await remove(this.#tmpDir);
86
- }
87
- await this.#db.destroy();
88
- }
89
- finally {
90
- await this.#releaseLock();
91
- }
92
- log('Closing done');
105
+ async addPageError(url, phase, message, isExternal = false) {
106
+ dbLog('Add page error: %s [%s]', url, phase);
107
+ await this.#db.insertPageError(url, phase, message, isExternal);
93
108
  }
94
109
  /**
95
110
  * Retrieves the current crawling state, including lists of scraped and pending URLs.
@@ -98,6 +113,47 @@ export default class Archive extends ArchiveAccessor {
98
113
  async getCrawlingState() {
99
114
  return this.#db.getCrawlingState();
100
115
  }
116
+ /**
117
+ * Return the subset of `urls` that already exist as `pages.url`. Used by
118
+ * `CrawlerOrchestrator.inventory` to filter the user-supplied URL list
119
+ * down to "URLs that are NOT yet in the archive" — only those reach the
120
+ * HEAD / scrape pipeline. Existing URLs are skipped to keep the second
121
+ * (and N-th) `--inventory` pass non-destructive.
122
+ * @param urls - Candidate URLs in `withoutHashAndAuth` form.
123
+ * @returns URLs already present in `pages`.
124
+ */
125
+ async getExistingPageUrls(urls) {
126
+ return this.#db.getExistingPageUrls(urls);
127
+ }
128
+ /**
129
+ * Return the subset of `urls` that already exist as `resources.url`. See
130
+ * {@link Archive.getExistingPageUrls} — the resource-side counterpart used
131
+ * by inventory mode to skip URLs that are already tracked as
132
+ * sub-resources.
133
+ * @param urls - Candidate URLs.
134
+ * @returns URLs already present in `resources`.
135
+ */
136
+ async getExistingResourceUrls(urls) {
137
+ return this.#db.getExistingResourceUrls(urls);
138
+ }
139
+ /**
140
+ * Retrieves a single recorded sub-resource by its URL.
141
+ * @param urls - URL candidates to match against the stored resource URL.
142
+ * @returns The raw resource row, or `null` if none match.
143
+ */
144
+ async getResourceByUrl(urls) {
145
+ return this.#db.getResourceByUrl(urls);
146
+ }
147
+ /**
148
+ * Counts the number of pages already scraped as crawl targets in the archive.
149
+ *
150
+ * Lets the crawler initialize its session-progress counter on resume so the
151
+ * displayed HTML-page count accounts for previously-rendered pages.
152
+ * @returns The count of pages with `isTarget = 1` and `scraped = 1`.
153
+ */
154
+ async getScrapedHtmlPageCount() {
155
+ return this.#db.getScrapedHtmlPageCount();
156
+ }
101
157
  /**
102
158
  * Retrieves the base URL of the crawl session from the archive database.
103
159
  * @returns The base URL string.
@@ -105,6 +161,24 @@ export default class Archive extends ArchiveAccessor {
105
161
  async getUrl() {
106
162
  return this.#db.getBaseUrl();
107
163
  }
164
+ /**
165
+ * Releases the SQLite handle and the advisory lock **without** writing
166
+ * the archive or removing `tmpDir`.
167
+ *
168
+ * Use this when you need to detach from a freshly-created `Archive`
169
+ * without finalising it — fixtures producing a stub state for tests,
170
+ * tooling that wants to leave the tmpDir alive for `crawl --resume`,
171
+ * or any non-orchestrator caller that owns the lifecycle externally.
172
+ * Shares the same idempotency guard as {@link close}, so the two paths
173
+ * are mutually exclusive (the first one called wins).
174
+ */
175
+ async releaseHandle() {
176
+ if (this.#closeOnce) {
177
+ return this.#closeOnce;
178
+ }
179
+ this.#closeOnce = this.#runReleaseHandle();
180
+ return this.#closeOnce;
181
+ }
108
182
  /**
109
183
  * Promote previously-external pages that now fall under the (possibly extended)
110
184
  * scope back to a pending state so that the crawler re-scrapes them as fully
@@ -117,6 +191,17 @@ export default class Archive extends ArchiveAccessor {
117
191
  dbLog('Repromote external pages with %d hostnames in scope', scopes.size);
118
192
  return this.#db.repromoteExternalPages(scopes, options);
119
193
  }
194
+ /**
195
+ * Reset previously-failed pages back to pending so a follow-up crawl re-fetches them.
196
+ *
197
+ * Delegates to {@link Database.resetFailedPages}. See that method for the
198
+ * exact failure criteria (missing status / content type, or a 5xx status).
199
+ * @returns The URLs of the pages that were reset to pending.
200
+ */
201
+ async resetFailedPages() {
202
+ dbLog('Reset failed pages back to pending');
203
+ return this.#db.resetFailedPages();
204
+ }
120
205
  /**
121
206
  * Stores the crawl configuration into the archive database.
122
207
  * @param config - The configuration object to store.
@@ -126,48 +211,50 @@ export default class Archive extends ArchiveAccessor {
126
211
  return this.#db.setConfig(config);
127
212
  }
128
213
  /**
129
- * Stores an external page's data in the archive database without saving a snapshot.
214
+ * Stores an external page's data in the archive database without storing
215
+ * an HTML snapshot. External-page rows carry only metadata (status, title,
216
+ * content-type), never a rendered body.
130
217
  * @param pageInfo - The page data to store.
218
+ * @param source - Provenance label for new rows. `undefined` leaves the DB DEFAULT (`'crawled'`).
131
219
  */
132
- async setExternalPage(pageInfo) {
220
+ async setExternalPage(pageInfo, source) {
133
221
  dbLog('Set external page: %s', pageInfo.url.href);
134
- await this.#db.updatePage(pageInfo, null, false);
222
+ await this.#db.updatePage(pageInfo, false, false, source);
135
223
  }
136
224
  /**
137
- * Stores a crawled page's data in the archive database and optionally saves an HTML snapshot.
138
- * If the snapshot file write fails, the HTML path in the database is cleared to prevent
139
- * referencing a non-existent file, and the error is re-thrown.
225
+ * Stores a crawled page's data in the archive database, persisting the
226
+ * rendered HTML body as a zstd-compressed BLOB inside the same SQLite
227
+ * transaction. Storage is content-addressable: identical bodies across
228
+ * pages share a single `page_html_blobs` row.
140
229
  * @param pageInfo - The page data to store.
230
+ * @param source - Provenance label for new rows. `undefined` leaves the DB DEFAULT (`'crawled'`).
141
231
  * @returns The database ID of the stored page.
142
- * @throws {Error} Re-throws any error from the snapshot file write after clearing the HTML path.
143
232
  */
144
- async setPage(pageInfo) {
233
+ async setPage(pageInfo, source) {
145
234
  dbLog('Set page: %s', pageInfo.url.href);
146
- const { html, pageId } = await this.#db.updatePage(pageInfo, this.#snapshotDir, pageInfo.isTarget);
147
- if (html) {
148
- try {
149
- await outputText(html, pageInfo.html);
150
- }
151
- catch (error) {
152
- dbLog('Snapshot write failed for page %d, clearing html path: %s', pageId, html);
153
- try {
154
- await this.#db.clearHtmlPath(pageId);
155
- }
156
- catch (clearError) {
157
- dbLog('Failed to clear html path for page %d: %s', pageId, clearError);
158
- }
159
- throw error;
160
- }
161
- }
162
- return pageId;
235
+ return await this.#db.updatePage(pageInfo, true, pageInfo.isTarget, source);
236
+ }
237
+ /**
238
+ * Records a redirect edge without re-storing the destination's content.
239
+ *
240
+ * The crawler calls this (instead of {@link setPage}) when a URL redirects to
241
+ * a destination that has already been rendered (#73): only the source →
242
+ * destination edge is written, leaving the destination's stored title / meta /
243
+ * anchors / images untouched.
244
+ * @param pageInfo - The HEAD-resolved page data carrying the redirect chain.
245
+ */
246
+ async setRedirect(pageInfo) {
247
+ dbLog('Set redirect: %s', pageInfo.url.href);
248
+ await this.#db.recordRedirect(pageInfo);
163
249
  }
164
250
  /**
165
251
  * Stores a sub-resource (CSS, JS, image, etc.) in the archive database.
166
252
  * @param resource - The resource data to store.
253
+ * @param source - Provenance label for new rows. `undefined` leaves the DB DEFAULT (`'crawled'`).
167
254
  */
168
- async setResources(resource) {
255
+ async setResources(resource, source) {
169
256
  dbLog('Set resource: %s', resource.url.href);
170
- await this.#db.insertResource(resource);
257
+ await this.#db.insertResource(resource, source);
171
258
  }
172
259
  /**
173
260
  * Stores the referrer relationship between a resource and the page that references it.
@@ -207,52 +294,90 @@ export default class Archive extends ArchiveAccessor {
207
294
  await this.#db.updateConfig(patch);
208
295
  }
209
296
  /**
210
- * Writes the archive to disk as a compressed `.nitpicker` file.
297
+ * Writes the archive to disk as a `.nitpicker` tar file.
211
298
  *
212
- * This method compresses the HTML snapshot directory into a zip file,
213
- * renames the temporary working directory, and creates the final tar archive.
214
- * The temporary directory is removed after writing.
299
+ * Checkpoints the SQLite WAL so the database is self-contained inside
300
+ * `db.sqlite`, renames the temporary working directory to the archive's
301
+ * basename, and tars it into the final `.nitpicker`. The tar container
302
+ * holds a single `db.sqlite` file (the legacy `snapshot-html.zip` is gone
303
+ * — HTML lives as BLOBs in the DB), so finalisation is effectively a
304
+ * single-file copy with no per-snapshot syscalls.
215
305
  */
216
306
  async write() {
217
307
  saveLog('Starts: %s', this.#filePath);
218
- const snapshotZip = `${this.#snapshotDir}.zip`;
219
- if (exists(this.#snapshotDir)) {
220
- if (!exists(snapshotZip)) {
221
- saveLog('Zips snapshot dir: %s', this.#snapshotDir);
222
- await zip(snapshotZip, this.#snapshotDir);
223
- }
224
- saveLog('Remove snapshot dir: %s', this.#snapshotDir);
225
- await remove(this.#snapshotDir);
226
- }
227
308
  await this.#db.checkpoint();
228
309
  const filePathWithoutExt = path.resolve(path.dirname(this.#filePath), path.basename(this.#filePath, path.extname(this.#filePath)));
229
310
  saveLog('Rename temporary dir: %s to %s', this.#tmpDir, filePathWithoutExt);
230
311
  await rename(this.#tmpDir, filePathWithoutExt, true);
231
- saveLog('Zip temporary dir to file: %s to %s', filePathWithoutExt, this.#filePath);
312
+ saveLog('Tar temporary dir to file: %s to %s', filePathWithoutExt, this.#filePath);
232
313
  await tar(filePathWithoutExt, this.#filePath);
233
314
  saveLog('Remove temporary dir: %s', filePathWithoutExt);
234
315
  await remove(filePathWithoutExt);
235
316
  saveLog('Done: %s', this.#filePath);
236
317
  }
318
+ /**
319
+ * Worker for {@link close}. Performs the destructive prologue
320
+ * (write or remove), drops the DB handle via the base class, then
321
+ * releases the lock in a `finally` so the lock never leaks even on
322
+ * partial failure.
323
+ */
324
+ async #runFullClose() {
325
+ log('Closing');
326
+ try {
327
+ if (!exists(this.#filePath)) {
328
+ log("Save the file because it doesn't exist");
329
+ await this.write();
330
+ }
331
+ else if (exists(this.#tmpDir)) {
332
+ log('Remove temporary dir');
333
+ await remove(this.#tmpDir);
334
+ }
335
+ await super.close();
336
+ }
337
+ finally {
338
+ await this.#releaseLock();
339
+ }
340
+ log('Closing done');
341
+ }
342
+ /**
343
+ * Worker for {@link releaseHandle}. Drops the SQLite handle and the
344
+ * advisory lock with no filesystem mutation.
345
+ */
346
+ async #runReleaseHandle() {
347
+ log('Releasing handle (no write, no remove)');
348
+ try {
349
+ await super.close();
350
+ }
351
+ finally {
352
+ await this.#releaseLock();
353
+ }
354
+ }
237
355
  /** The file extension for Nitpicker archive files (without the leading dot). */
238
356
  static FILE_EXTENSION = 'nitpicker';
239
- /** The directory name used for storing HTML snapshots within the archive. */
240
- static SNAPSHOT_HTML_DIR = 'snapshot-html';
241
357
  /** The filename of the SQLite database within the archive. */
242
358
  static SQLITE_DB_FILE_NAME = 'db.sqlite';
243
359
  /** The prefix used for temporary working directories during archive operations. */
244
360
  static TMP_DIR_PREFIX = '._nitpicker-';
245
361
  /**
246
362
  * Opens a read-only connection to an existing archive's database.
363
+ *
247
364
  * Returns an {@link ArchiveAccessor} that provides query methods
248
- * without the ability to modify or write the archive.
365
+ * without the ability to modify or write the archive. The DB is opened
366
+ * in **read-only mode**: no schema migrations run, and the connection
367
+ * refuses to resurrect a missing parent directory or db file (so a
368
+ * TOCTOU window between source classification and this call cannot
369
+ * silently produce an empty phantom tmpDir).
370
+ *
371
+ * The returned accessor is also marked read-only so consumer-facing
372
+ * helpers (e.g. {@link ArchiveAccessor.getHtmlOfPage}) avoid any
373
+ * filesystem mutation on the user's tmpDir.
249
374
  * @param tmpDir - The path to the temporary directory containing the database.
250
375
  * @param namespace - An optional namespace for scoping data access within the archive.
251
376
  * @returns An ArchiveAccessor instance for querying the archive data.
252
377
  */
253
378
  static async connect(tmpDir, namespace = null) {
254
- const db = await Archive.#connectDB(tmpDir);
255
- const archive = new ArchiveAccessor(tmpDir, db, namespace);
379
+ const db = await Archive.#connectDB(tmpDir, { readOnly: true });
380
+ const archive = new ArchiveAccessor(tmpDir, db, namespace, { readOnly: true });
256
381
  return archive;
257
382
  }
258
383
  /**
@@ -301,22 +426,30 @@ export default class Archive extends ArchiveAccessor {
301
426
  cwd,
302
427
  openPluginData,
303
428
  });
304
- const fileName = path.basename(filePath, path.extname(filePath));
305
- const tmpDir = path.resolve(cwd, Archive.TMP_DIR_PREFIX + fileName);
429
+ // Read the tar's actual top-level directory name instead of deriving
430
+ // it from the outer file's basename. `.nitpicker` files are plain
431
+ // tar archives and users routinely rename them (`mv X.nitpicker
432
+ // Y.nitpicker`) — that operation must not break `open`. The inner
433
+ // directory keeps whatever name `Archive.write()` baked in at write
434
+ // time, and `tmpDir` mirrors the OUTER basename (so concurrent
435
+ // crawls on differently-named copies of the same archive don't
436
+ // collide on the lockfile).
437
+ const outerBasename = path.basename(filePath, path.extname(filePath));
438
+ const innerDirName = await peekTarTopDir(filePath);
439
+ const tmpDir = path.resolve(cwd, Archive.TMP_DIR_PREFIX + outerBasename);
306
440
  const releaseLock = await acquireArchiveLock(tmpDir);
307
441
  try {
308
442
  const openFiles = [];
309
443
  if (!openPluginData) {
310
- const relDdPath = path.join(fileName, Archive.SQLITE_DB_FILE_NAME);
311
- const relSnapshotPath = path.join(fileName, Archive.SNAPSHOT_HTML_DIR + '.zip');
312
- openFiles.push(relDdPath, relSnapshotPath);
444
+ const relDdPath = path.join(innerDirName, Archive.SQLITE_DB_FILE_NAME);
445
+ openFiles.push(relDdPath);
313
446
  }
314
447
  log('Unzip file: %s (%O)', filePath, openFiles);
315
448
  await untar(filePath, {
316
449
  cwd,
317
450
  fileList: openFiles.length > 0 ? openFiles : undefined,
318
451
  });
319
- const extractedDir = path.resolve(cwd, fileName);
452
+ const extractedDir = path.resolve(cwd, innerDirName);
320
453
  log('Move directory: %s to %s', extractedDir, tmpDir);
321
454
  await rename(extractedDir, tmpDir, true);
322
455
  return await Archive.#init(filePath, tmpDir, releaseLock);
@@ -373,13 +506,18 @@ export default class Archive extends ArchiveAccessor {
373
506
  /**
374
507
  * Connects to (or creates) the SQLite database in the given directory.
375
508
  * @param tmpDir - Directory containing `db.sqlite`
509
+ * @param options - Optional connection flags forwarded to
510
+ * {@link Database.connect}. Used by {@link Archive.connect} to pass
511
+ * `readOnly: true` so no migrations run and a missing tmpDir is not
512
+ * resurrected.
513
+ * @param options.readOnly
376
514
  */
377
- static async #connectDB(tmpDir) {
515
+ static async #connectDB(tmpDir, options) {
378
516
  const dbPath = path.resolve(tmpDir, Archive.SQLITE_DB_FILE_NAME);
379
- dbLog('connects database: %s', dbPath);
517
+ dbLog('connects database: %s (readOnly=%s)', dbPath, options?.readOnly ?? false);
380
518
  return await Database.connect({
381
- workingDir: tmpDir,
382
519
  filename: dbPath,
520
+ readOnly: options?.readOnly,
383
521
  });
384
522
  }
385
523
  /**
@@ -397,6 +535,34 @@ export default class Archive extends ArchiveAccessor {
397
535
  const archive = new Archive(filePath, tmpDir, db, releaseLock);
398
536
  return archive;
399
537
  }
538
+ /**
539
+ * Closes the archive. If the archive file does not yet exist on disk,
540
+ * it writes the archive first. If the temporary directory still exists,
541
+ * it is removed. The database connection is then closed via
542
+ * {@link ArchiveAccessor.close} (the base class owns the SQLite handle),
543
+ * and finally the archive's advisory lock is released.
544
+ *
545
+ * **Idempotent**: the first invocation captures the close promise;
546
+ * subsequent invocations (signal handlers, parallel teardowns, retried
547
+ * orchestrator paths) await the same promise instead of re-entering
548
+ * the destructive prologue on a half-mutated state. If the first
549
+ * close fails (e.g. ENOSPC during tar), the rejection propagates to
550
+ * all awaiters and the archive stays latched closed — there is no
551
+ * safe way to retry `write()` once `tmpDir` has been renamed.
552
+ *
553
+ * **Read-only consumers must not reach this override.** Anything that
554
+ * obtains an archive view via {@link Archive.connect} receives an
555
+ * {@link ArchiveAccessor} (not an `Archive`), so `close()` resolves to
556
+ * the safe base implementation — no `write()`, no `remove()`, no lock
557
+ * release — leaving the tmpDir intact for the live crawler.
558
+ */
559
+ async close() {
560
+ if (this.#closeOnce) {
561
+ return this.#closeOnce;
562
+ }
563
+ this.#closeOnce = this.#runFullClose();
564
+ return this.#closeOnce;
565
+ }
400
566
  /**
401
567
  * Retrieves the crawl configuration stored in the archive database.
402
568
  * @returns The configuration object.