@nitpicker/crawler 0.9.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.
- package/lib/archive/archive-accessor.d.ts +87 -14
- package/lib/archive/archive-accessor.js +162 -36
- package/lib/archive/archive.d.ts +262 -24
- package/lib/archive/archive.js +397 -86
- package/lib/archive/cache/compute-archive-cache-key.d.ts +39 -0
- package/lib/archive/cache/compute-archive-cache-key.js +95 -0
- package/lib/archive/cache/extract-archive-to-cache.d.ts +43 -0
- package/lib/archive/cache/extract-archive-to-cache.js +309 -0
- package/lib/archive/cache/get-archive-cache-root.d.ts +20 -0
- package/lib/archive/cache/get-archive-cache-root.js +53 -0
- package/lib/archive/cache/is-archive-cache-disabled.d.ts +24 -0
- package/lib/archive/cache/is-archive-cache-disabled.js +34 -0
- package/lib/archive/cache/resolve-archive-cache-dir.d.ts +26 -0
- package/lib/archive/cache/resolve-archive-cache-dir.js +32 -0
- package/lib/archive/database.d.ts +411 -26
- package/lib/archive/database.js +1881 -540
- package/lib/archive/derive-lineage-from-parent.d.ts +37 -0
- package/lib/archive/derive-lineage-from-parent.js +42 -0
- package/lib/archive/filesystem/peek-tar-top-dir.d.ts +28 -0
- package/lib/archive/filesystem/peek-tar-top-dir.js +65 -0
- package/lib/archive/get-failed-page-messages.d.ts +43 -0
- package/lib/archive/get-failed-page-messages.js +131 -0
- package/lib/archive/init-schema.d.ts +53 -2
- package/lib/archive/init-schema.js +399 -15
- package/lib/archive/is-inventory-source.d.ts +21 -0
- package/lib/archive/is-inventory-source.js +22 -0
- package/lib/archive/meta/assert-compatible-version.d.ts +39 -0
- package/lib/archive/meta/assert-compatible-version.js +72 -0
- package/lib/archive/meta/classify-jsonld-type.d.ts +23 -0
- package/lib/archive/meta/classify-jsonld-type.js +43 -0
- package/lib/archive/meta/compare-semver.d.ts +23 -0
- package/lib/archive/meta/compare-semver.js +51 -0
- package/lib/archive/meta/compute-page-denormalized.d.ts +21 -0
- package/lib/archive/meta/compute-page-denormalized.js +35 -0
- package/lib/archive/meta/derive-flat-from-meta.d.ts +35 -0
- package/lib/archive/meta/derive-flat-from-meta.js +158 -0
- package/lib/archive/meta/derive-meta-extras.d.ts +20 -0
- package/lib/archive/meta/derive-meta-extras.js +23 -0
- package/lib/archive/meta/extract-tags-for-archive.d.ts +18 -0
- package/lib/archive/meta/extract-tags-for-archive.js +36 -0
- package/lib/archive/meta/summarize-jsonld.d.ts +17 -0
- package/lib/archive/meta/summarize-jsonld.js +29 -0
- package/lib/archive/meta/summarize-tags.d.ts +16 -0
- package/lib/archive/meta/summarize-tags.js +33 -0
- package/lib/archive/meta/types.d.ts +207 -0
- package/lib/archive/meta/types.js +33 -0
- package/lib/archive/migrate-crawl-errors.d.ts +20 -0
- package/lib/archive/migrate-crawl-errors.js +38 -0
- package/lib/archive/migrate-html-blob-tables.d.ts +24 -0
- package/lib/archive/migrate-html-blob-tables.js +53 -0
- package/lib/archive/migrate-inventory-runs.d.ts +29 -0
- package/lib/archive/migrate-inventory-runs.js +52 -0
- package/lib/archive/migrate-page-errors.d.ts +16 -0
- package/lib/archive/migrate-page-errors.js +35 -0
- package/lib/archive/migrate-pages-resources-source.d.ts +16 -0
- package/lib/archive/migrate-pages-resources-source.js +46 -0
- package/lib/archive/page.d.ts +187 -49
- package/lib/archive/page.js +258 -63
- package/lib/archive/peek-archive-lock.d.ts +40 -0
- package/lib/archive/peek-archive-lock.js +62 -0
- package/lib/archive/resolve-redirect-chain.d.ts +33 -0
- package/lib/archive/resolve-redirect-chain.js +27 -0
- package/lib/archive/types.d.ts +168 -26
- package/lib/classify-error-kind.d.ts +19 -0
- package/lib/classify-error-kind.js +122 -0
- package/lib/crawler/build-js-redirect-edge.d.ts +68 -0
- package/lib/crawler/build-js-redirect-edge.js +57 -0
- package/lib/crawler/build-redirect-event.d.ts +24 -0
- package/lib/crawler/build-redirect-event.js +28 -0
- package/lib/crawler/clear-dns-burned-host-cache.d.ts +6 -0
- package/lib/crawler/clear-dns-burned-host-cache.js +11 -0
- package/lib/crawler/close-browser-safely.d.ts +64 -0
- package/lib/crawler/close-browser-safely.js +73 -0
- package/lib/crawler/crawler.d.ts +7 -2
- package/lib/crawler/crawler.js +919 -113
- package/lib/crawler/create-change-phase-handler.d.ts +54 -0
- package/lib/crawler/create-change-phase-handler.js +44 -0
- package/lib/crawler/derive-js-redirect-target.d.ts +68 -0
- package/lib/crawler/derive-js-redirect-target.js +129 -0
- package/lib/crawler/derive-page-source.d.ts +23 -0
- package/lib/crawler/derive-page-source.js +28 -0
- package/lib/crawler/derive-resource-source.d.ts +33 -0
- package/lib/crawler/derive-resource-source.js +37 -0
- package/lib/crawler/dns-burned-host-cache.d.ts +26 -0
- package/lib/crawler/dns-burned-host-cache.js +25 -0
- package/lib/crawler/dns-burned-host-short-circuit-counter.d.ts +13 -0
- package/lib/crawler/dns-burned-host-short-circuit-counter.js +11 -0
- package/lib/crawler/drain-phase-errors.d.ts +48 -0
- package/lib/crawler/drain-phase-errors.js +35 -0
- package/lib/crawler/fetch-destination.d.ts +12 -4
- package/lib/crawler/fetch-destination.js +132 -18
- package/lib/crawler/format-crawl-progress.d.ts +12 -3
- package/lib/crawler/format-crawl-progress.js +14 -6
- package/lib/crawler/handle-browser-close.d.ts +29 -0
- package/lib/crawler/handle-browser-close.js +28 -0
- package/lib/crawler/is-html-content-type.d.ts +17 -0
- package/lib/crawler/is-html-content-type.js +19 -0
- package/lib/crawler/is-js-redirect-error-shape.d.ts +40 -0
- package/lib/crawler/is-js-redirect-error-shape.js +53 -0
- package/lib/crawler/is-likely-html-url.d.ts +22 -0
- package/lib/crawler/is-likely-html-url.js +65 -0
- package/lib/crawler/is-puppeteer-fallback-candidate.d.ts +16 -0
- package/lib/crawler/is-puppeteer-fallback-candidate.js +63 -0
- package/lib/crawler/kill-process-tree.d.ts +94 -0
- package/lib/crawler/kill-process-tree.js +178 -0
- package/lib/crawler/link-list.d.ts +21 -1
- package/lib/crawler/link-list.js +25 -4
- package/lib/crawler/link-to-page-data.d.ts +13 -5
- package/lib/crawler/link-to-page-data.js +26 -5
- package/lib/crawler/log-undrained-phase-errors.d.ts +37 -0
- package/lib/crawler/log-undrained-phase-errors.js +34 -0
- package/lib/crawler/normalize-content-type.d.ts +14 -0
- package/lib/crawler/normalize-content-type.js +20 -0
- package/lib/crawler/partition-urls-by-html.d.ts +16 -0
- package/lib/crawler/partition-urls-by-html.js +23 -0
- package/lib/crawler/plan-sub-resource-emits.d.ts +63 -0
- package/lib/crawler/plan-sub-resource-emits.js +44 -0
- package/lib/crawler/preload-short-circuit-error.d.ts +22 -0
- package/lib/crawler/preload-short-circuit-error.js +25 -0
- package/lib/crawler/redirect-dest-key.d.ts +19 -0
- package/lib/crawler/redirect-dest-key.js +27 -0
- package/lib/crawler/resource-to-page-data.d.ts +28 -0
- package/lib/crawler/resource-to-page-data.js +59 -0
- package/lib/crawler/should-burn-host.d.ts +78 -0
- package/lib/crawler/should-burn-host.js +61 -0
- package/lib/crawler/should-get-fallback-on-head-failure.d.ts +38 -0
- package/lib/crawler/should-get-fallback-on-head-failure.js +46 -0
- package/lib/crawler/types.d.ts +229 -1
- package/lib/crawler-orchestrator.d.ts +105 -3
- package/lib/crawler-orchestrator.js +620 -20
- package/lib/crawler.d.ts +8 -2
- package/lib/crawler.js +6 -1
- package/lib/permanent-error-kinds.d.ts +43 -0
- package/lib/permanent-error-kinds.js +48 -0
- package/lib/resource-row-to-lookup-result.d.ts +13 -0
- package/lib/resource-row-to-lookup-result.js +20 -0
- package/lib/types.d.ts +95 -1
- package/lib/utils/compute-file-sha256.d.ts +23 -0
- package/lib/utils/compute-file-sha256.js +55 -0
- package/lib/utils/error/emit-error-with-retry.d.ts +40 -0
- package/lib/utils/error/emit-error-with-retry.js +44 -0
- package/lib/utils/error/emit-error.d.ts +39 -0
- package/lib/utils/error/emit-error.js +41 -0
- package/lib/utils/object/parse-response-headers.d.ts +12 -0
- package/lib/utils/object/parse-response-headers.js +26 -0
- package/package.json +11 -11
- package/lib/utils/error/error-emitter.d.ts +0 -18
- package/lib/utils/error/error-emitter.js +0 -29
|
@@ -1,7 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Applies the connection-level PRAGMAs that govern foreign-key enforcement
|
|
3
|
+
* and BLOB-read performance. These are **per-connection** settings (libsql
|
|
4
|
+
* resets them when a new connection is opened), so they must be reapplied
|
|
5
|
+
* every time `Database.connect` runs — not just on first-time schema
|
|
6
|
+
* initialization. Keeping them separate from `initSchema`'s one-shot path
|
|
7
|
+
* also lets `page_size` (which only takes effect against an empty DB)
|
|
8
|
+
* stay gated behind the existence check.
|
|
9
|
+
* @param instance - The Knex query builder instance connected to the database.
|
|
10
|
+
*/
|
|
11
|
+
export async function applyConnectionPragmas(instance) {
|
|
12
|
+
// Foreign-key enforcement defaults to OFF on every new SQLite
|
|
13
|
+
// connection. Required for ON DELETE CASCADE on `page_html_ref`,
|
|
14
|
+
// `page_tags`, and `page_jsonld` to fire.
|
|
15
|
+
await instance.raw('PRAGMA foreign_keys = ON');
|
|
16
|
+
await instance.raw('PRAGMA wal_autocheckpoint = 1000');
|
|
17
|
+
// Negative value = KiB of memory (64 MiB). Helps large BLOB scans.
|
|
18
|
+
//
|
|
19
|
+
// Empirically validated against larger values on a 10 GB archive:
|
|
20
|
+
// bumping to 512 MiB regressed `getSummary` (1.9s → 5.7s), `pages`
|
|
21
|
+
// (2.3s → 21s), and `images` (3.7s → 12s) — libsql's page eviction
|
|
22
|
+
// policy interacts poorly with a cache sized comparable to the
|
|
23
|
+
// host's page-cache window when the DB itself far exceeds RAM.
|
|
24
|
+
// 64 MiB stays the sweet spot.
|
|
25
|
+
await instance.raw('PRAGMA cache_size = -65536');
|
|
26
|
+
// 256 MiB mmap window. SQLite falls back to read() past this so the
|
|
27
|
+
// limit is a soft ceiling, not a hard one. A 4 GiB window was
|
|
28
|
+
// catastrophic on a 10 GB archive on macOS (summary 1.9s → 43s,
|
|
29
|
+
// pages 2.3s → 21s) — the kernel's read-ahead policy and libsql's
|
|
30
|
+
// mmap path interact badly when the window can cover most of the
|
|
31
|
+
// DB. Keep this conservative.
|
|
32
|
+
await instance.raw('PRAGMA mmap_size = 268435456');
|
|
33
|
+
}
|
|
1
34
|
/**
|
|
2
35
|
* Initializes the archive database schema if tables do not exist.
|
|
3
|
-
*
|
|
4
|
-
*
|
|
36
|
+
*
|
|
37
|
+
* Schema notes:
|
|
38
|
+
*
|
|
39
|
+
* - **Meta columns (v2)**: pages carries ~47 flat columns derived from
|
|
40
|
+
* beholder 3.0.0's nested Meta shape (`canonical`, `og_*`, `twitter_*`,
|
|
41
|
+
* `robots_*`, document basics, editorial fields) plus a `meta_extras`
|
|
42
|
+
* JSON column for everything not flattened. URL-shaped columns are
|
|
43
|
+
* absolutised against the page URL before write (see
|
|
44
|
+
* `archive/meta/derive-flat-from-meta.ts`).
|
|
45
|
+
* - **Denormalised aggregates** (`tag_count`, `jsonld_count`,
|
|
46
|
+
* `tags_providers_csv`): computed at write time from `meta.tags` /
|
|
47
|
+
* `meta.jsonLd` to avoid N+1 GROUP BY at Sheets-render / page-detail time.
|
|
48
|
+
* Plan: "ファイルサイズが多少増えてもいいから取り出しパフォーマンスを優先".
|
|
49
|
+
* - **Per-page timestamps** (`firstCrawledAt`, `lastCrawledAt`): UNIX ms.
|
|
50
|
+
* Written by `#insertPage` on INSERT (`first = last = now`) and UPDATE
|
|
51
|
+
* (`last = now`, `first` preserved). `resetFailedPages` deliberately
|
|
52
|
+
* leaves them alone so failure-reset does not erase the last-success
|
|
53
|
+
* record.
|
|
54
|
+
* - **`page_tags`** (Wappalyzer): per-provider × external-id row shape, plus
|
|
55
|
+
* `categories`/`sources` JSON columns. Compound indexes
|
|
56
|
+
* `(provider, externalId)` / `(provider, pageId)` are pre-built for the
|
|
57
|
+
* Phase 2+ "find duplicate IDs across pages" and "list pages using
|
|
58
|
+
* provider X" hot paths — Phase 1 read perf > storage cost trade-off.
|
|
59
|
+
* - **`page_jsonld`** (JSON-LD / SpeculationRules): one row per
|
|
60
|
+
* `<script type="application/ld+json">` or `<script type="speculationrules">`.
|
|
61
|
+
* `raw` is stored uncompressed (SQLite overflow pages handle large rows);
|
|
62
|
+
* if cross-archive bulk export becomes a use case, add a `codec` column
|
|
63
|
+
* à la `page_html_blobs`. Compound `(type, pageId)` accelerates streaming
|
|
64
|
+
* `list_pages_by_jsonld_type` JOINs.
|
|
65
|
+
* - **HTML snapshots** (`page_html_blobs` + `page_html_ref`): unchanged
|
|
66
|
+
* from v1. zstd-compressed BLOBs keyed by SHA-256 for content-addressable
|
|
67
|
+
* dedup. WITHOUT ROWID via raw SQL because knex's schema builder cannot
|
|
68
|
+
* express it.
|
|
69
|
+
* - **PRAGMA `page_size` and `journal_mode`** are set BEFORE any
|
|
70
|
+
* `CREATE TABLE` because SQLite only honors `page_size` changes against
|
|
71
|
+
* an empty database, and `journal_mode = WAL` is persistent. Other
|
|
72
|
+
* per-connection PRAGMAs live in {@link applyConnectionPragmas}.
|
|
73
|
+
*
|
|
74
|
+
* Pre-0.10 → 0.10 migration is intentionally absent. `assertCompatibleVersion`
|
|
75
|
+
* (called before `initSchema`) rejects pre-0.10 archives with a friendly
|
|
76
|
+
* error pointing the operator at `scripts/migrate-to-0.10.mjs`; `v0.x`
|
|
77
|
+
* policy allows breaking changes.
|
|
5
78
|
* @param instance - The Knex query builder instance connected to the database.
|
|
6
79
|
*/
|
|
7
80
|
export async function initSchema(instance) {
|
|
@@ -9,9 +82,11 @@ export async function initSchema(instance) {
|
|
|
9
82
|
if (isExists) {
|
|
10
83
|
return;
|
|
11
84
|
}
|
|
12
|
-
//
|
|
85
|
+
// Page size must be set on an empty database file; once any data is
|
|
86
|
+
// written, only VACUUM can change it. journal_mode is also one-shot
|
|
87
|
+
// (persistent) and so stays here.
|
|
88
|
+
await instance.raw('PRAGMA page_size = 16384');
|
|
13
89
|
await instance.raw('PRAGMA journal_mode = WAL');
|
|
14
|
-
await instance.raw('PRAGMA foreign_keys = ON');
|
|
15
90
|
await instance.schema
|
|
16
91
|
.createTable('info', (t) => {
|
|
17
92
|
t.increments('id');
|
|
@@ -46,31 +121,92 @@ export async function initSchema(instance) {
|
|
|
46
121
|
t.string('contentType').nullable();
|
|
47
122
|
t.integer('contentLength').unsigned().nullable();
|
|
48
123
|
t.json('responseHeaders').nullable();
|
|
124
|
+
// Document basics
|
|
49
125
|
t.string('lang');
|
|
126
|
+
t.string('dir');
|
|
127
|
+
t.string('charset');
|
|
128
|
+
t.string('baseHref');
|
|
129
|
+
t.text('viewport_raw');
|
|
130
|
+
t.string('themeColor');
|
|
131
|
+
t.string('applicationName');
|
|
132
|
+
t.string('author');
|
|
133
|
+
t.string('generator');
|
|
134
|
+
t.string('publisher');
|
|
135
|
+
// Title / description / keywords (top-level Meta fields)
|
|
50
136
|
t.string('title');
|
|
51
|
-
t.
|
|
52
|
-
t.
|
|
53
|
-
|
|
54
|
-
t.
|
|
55
|
-
t.
|
|
56
|
-
t.
|
|
57
|
-
t.
|
|
137
|
+
t.text('description');
|
|
138
|
+
t.text('keywords');
|
|
139
|
+
// Robots
|
|
140
|
+
t.text('robots_raw');
|
|
141
|
+
t.integer('robots_noindex');
|
|
142
|
+
t.integer('robots_nofollow');
|
|
143
|
+
t.integer('robots_noarchive');
|
|
144
|
+
t.integer('robots_noimageindex');
|
|
145
|
+
t.string('googlebot');
|
|
146
|
+
// Link (1:1 only — array shapes live in meta_extras)
|
|
147
|
+
t.string('canonical', 8190);
|
|
148
|
+
t.string('amphtml', 8190);
|
|
149
|
+
t.string('manifest', 8190);
|
|
150
|
+
t.string('icon_href', 8190);
|
|
151
|
+
t.string('appleTouchIcon_href', 8190);
|
|
152
|
+
// Open Graph
|
|
58
153
|
t.string('og_type');
|
|
59
154
|
t.string('og_title');
|
|
155
|
+
t.string('og_url', 8190);
|
|
60
156
|
t.string('og_site_name');
|
|
61
|
-
t.
|
|
62
|
-
t.string('
|
|
63
|
-
t.string('
|
|
157
|
+
t.text('og_description');
|
|
158
|
+
t.string('og_image', 8190);
|
|
159
|
+
t.string('og_image_alt');
|
|
160
|
+
t.string('og_image_width');
|
|
161
|
+
t.string('og_image_height');
|
|
162
|
+
t.string('og_locale');
|
|
163
|
+
t.string('og_article_published_time');
|
|
164
|
+
t.string('og_article_modified_time');
|
|
165
|
+
// Twitter
|
|
64
166
|
t.string('twitter_card');
|
|
65
|
-
t.string('
|
|
167
|
+
t.string('twitter_site');
|
|
168
|
+
t.string('twitter_creator');
|
|
169
|
+
t.string('twitter_title');
|
|
170
|
+
t.text('twitter_description');
|
|
171
|
+
t.string('twitter_image', 8190);
|
|
172
|
+
// One-offs
|
|
173
|
+
t.string('fb_app_id');
|
|
174
|
+
t.string('verification_google');
|
|
175
|
+
t.integer('formatDetection_telephone');
|
|
176
|
+
// Within-archive observation timestamps (UNIX ms)
|
|
177
|
+
t.integer('firstCrawledAt');
|
|
178
|
+
t.integer('lastCrawledAt');
|
|
179
|
+
// Denormalised aggregates (written at scrape time, see
|
|
180
|
+
// archive/meta/compute-page-denormalized.ts)
|
|
181
|
+
t.integer('tag_count');
|
|
182
|
+
t.integer('jsonld_count');
|
|
183
|
+
t.text('tags_providers_csv');
|
|
184
|
+
// Catch-all JSON for nested Meta sub-objects not flattened above
|
|
185
|
+
t.json('meta_extras');
|
|
186
|
+
// Crawl lifecycle
|
|
66
187
|
t.boolean('isSkipped');
|
|
67
188
|
t.string('skipReason');
|
|
68
189
|
t.integer('order').unsigned().nullable();
|
|
190
|
+
// Provenance: which channel inserted this row. Values:
|
|
191
|
+
// 'crawled' — discovered via the recursive crawl from one of `info.roots`
|
|
192
|
+
// 'inventory-seed' — supplied directly by `crawl --inventory` URL list
|
|
193
|
+
// 'inventory-discovered' — found by following links from an `inventory-seed` page
|
|
194
|
+
// Used by `listIsolatedPages` only for badge display; isolation
|
|
195
|
+
// itself is judged by `anchors.hrefId IS NULL`, not by source.
|
|
196
|
+
t.string('source').notNullable().defaultTo('crawled');
|
|
69
197
|
t.index('isExternal');
|
|
70
198
|
t.index('contentType');
|
|
71
199
|
t.index('scraped');
|
|
72
200
|
t.index('redirectDestId');
|
|
73
201
|
t.index('order');
|
|
202
|
+
// Phase 1: noindex filter (list_pages) and og:type filter
|
|
203
|
+
// (analytics) are the only new flat-column filters with enough
|
|
204
|
+
// selectivity to benefit from an index. `lang` has cardinality 1
|
|
205
|
+
// on mono-language sites (D-Zero's typical customer) so it is
|
|
206
|
+
// skipped.
|
|
207
|
+
t.index('robots_noindex');
|
|
208
|
+
t.index('og_type');
|
|
209
|
+
t.index('source');
|
|
74
210
|
})
|
|
75
211
|
.createTable('anchors', (t) => {
|
|
76
212
|
t.increments('id');
|
|
@@ -107,6 +243,12 @@ export async function initSchema(instance) {
|
|
|
107
243
|
t.string('compress').nullable();
|
|
108
244
|
t.string('cdn').nullable();
|
|
109
245
|
t.json('responseHeaders').nullable();
|
|
246
|
+
// See `pages.source` for the provenance taxonomy. `inventory-seed`
|
|
247
|
+
// rows here come from non-HTML URLs handed in by
|
|
248
|
+
// `crawl --inventory`; `inventory-discovered` rows are sub-resources
|
|
249
|
+
// pulled in while puppeteer rendered an inventory-seed page.
|
|
250
|
+
t.string('source').notNullable().defaultTo('crawled');
|
|
251
|
+
t.index('source');
|
|
110
252
|
})
|
|
111
253
|
.createTable('resources-referrers', (t) => {
|
|
112
254
|
t.increments('id');
|
|
@@ -115,5 +257,247 @@ export async function initSchema(instance) {
|
|
|
115
257
|
t.unique(['resourceId', 'pageId']);
|
|
116
258
|
t.index('resourceId');
|
|
117
259
|
t.index('pageId');
|
|
260
|
+
})
|
|
261
|
+
.createTable('page_errors', (t) => {
|
|
262
|
+
// Records partial scrape failures (e.g. a viewport switch that
|
|
263
|
+
// detaches the frame and trips beholder's @retryable into the
|
|
264
|
+
// `retryExhausted` phase). A page can have zero or more rows here
|
|
265
|
+
// in addition to its normal `pages` entry — the page itself is
|
|
266
|
+
// considered successfully scraped, but image capture or another
|
|
267
|
+
// secondary step failed for at least one device preset.
|
|
268
|
+
t.increments('id');
|
|
269
|
+
t.integer('pageId').notNullable().unsigned().references('pages.id');
|
|
270
|
+
t.string('phase').notNullable();
|
|
271
|
+
t.text('message').notNullable();
|
|
272
|
+
t.integer('createdAt').notNullable();
|
|
273
|
+
t.index('pageId');
|
|
274
|
+
})
|
|
275
|
+
.createTable('crawl_errors', (t) => {
|
|
276
|
+
// Structured form of the crawler-level `error` channel that otherwise
|
|
277
|
+
// only lands in `error.log`. Unlike `page_errors` these are not tied to
|
|
278
|
+
// a scraped page (the URL may be an external link that failed DNS, or
|
|
279
|
+
// null for a process-level error), so there is no `pageId` FK and `url`
|
|
280
|
+
// is nullable. The cause is NOT stored — it is classified on read from
|
|
281
|
+
// `message` so older archives (which only have `error.log`) classify the
|
|
282
|
+
// same way.
|
|
283
|
+
t.increments('id');
|
|
284
|
+
t.string('url', 8190).nullable();
|
|
285
|
+
t.boolean('isExternal');
|
|
286
|
+
t.text('message').notNullable();
|
|
287
|
+
t.integer('createdAt').notNullable();
|
|
288
|
+
})
|
|
289
|
+
.createTable('page_tags', (t) => {
|
|
290
|
+
// Wappalyzer-derived technology detection. One row per
|
|
291
|
+
// (provider × externalId) tuple per page. `category` is the first
|
|
292
|
+
// element of `categories`; the full list lives in the JSON
|
|
293
|
+
// `categories` column. `sources` records where the provider was
|
|
294
|
+
// detected (script-src / inline / iframe-src / window-global / …).
|
|
295
|
+
t.increments('id');
|
|
296
|
+
t.integer('pageId')
|
|
297
|
+
.notNullable()
|
|
298
|
+
.unsigned()
|
|
299
|
+
.references('pages.id')
|
|
300
|
+
.onDelete('CASCADE');
|
|
301
|
+
t.string('provider').notNullable();
|
|
302
|
+
t.string('category');
|
|
303
|
+
t.string('externalId');
|
|
304
|
+
t.string('version');
|
|
305
|
+
t.integer('confidence');
|
|
306
|
+
t.json('categories');
|
|
307
|
+
t.json('sources');
|
|
308
|
+
t.index('pageId');
|
|
309
|
+
t.index('provider');
|
|
310
|
+
t.index('externalId');
|
|
311
|
+
})
|
|
312
|
+
.createTable('page_jsonld', (t) => {
|
|
313
|
+
// JSON-LD and SpeculationRules entries captured from
|
|
314
|
+
// `<script type="application/ld+json">` and
|
|
315
|
+
// `<script type="speculationrules">`. `kind` discriminates; `type`
|
|
316
|
+
// is the top-level `@type` extracted by classify-jsonld-type for
|
|
317
|
+
// indexable filtering. `raw` is stored uncompressed; SQLite
|
|
318
|
+
// overflow pages handle multi-KB JSON bodies transparently.
|
|
319
|
+
t.increments('id');
|
|
320
|
+
t.integer('pageId')
|
|
321
|
+
.notNullable()
|
|
322
|
+
.unsigned()
|
|
323
|
+
.references('pages.id')
|
|
324
|
+
.onDelete('CASCADE');
|
|
325
|
+
t.string('kind').notNullable();
|
|
326
|
+
t.string('type');
|
|
327
|
+
t.text('raw').notNullable();
|
|
328
|
+
t.json('parsed');
|
|
329
|
+
t.text('parseError');
|
|
330
|
+
t.index('pageId');
|
|
331
|
+
t.index('type');
|
|
332
|
+
})
|
|
333
|
+
.createTable('inventory_runs', (t) => {
|
|
334
|
+
// One row per successful `--inventory <list>` invocation. The
|
|
335
|
+
// archive's audit log of "when did we apply which deploy list
|
|
336
|
+
// at what scale". `.bak` is removed on success so this table
|
|
337
|
+
// is the only durable provenance record. Schema rationale +
|
|
338
|
+
// non-goals live in {@link migrateInventoryRuns}.
|
|
339
|
+
t.increments('id');
|
|
340
|
+
t.string('ran_at').notNullable();
|
|
341
|
+
t.string('list_label').nullable();
|
|
342
|
+
t.string('source_file_sha256', 64).nullable();
|
|
343
|
+
t.integer('total_lines').nullable();
|
|
344
|
+
t.integer('new_pages').nullable();
|
|
345
|
+
t.integer('new_resources').nullable();
|
|
346
|
+
t.integer('scope_skipped').nullable();
|
|
347
|
+
t.text('notes').nullable();
|
|
348
|
+
t.index('ran_at');
|
|
118
349
|
});
|
|
350
|
+
// ON DELETE CASCADE and compound indexes for the new tables. Knex's
|
|
351
|
+
// schema builder can't express CASCADE / compound indexes inline in a
|
|
352
|
+
// way that round-trips through libsql consistently, so we use raw SQL
|
|
353
|
+
// to mirror the `page_html_ref` pattern.
|
|
354
|
+
await instance.raw('CREATE INDEX page_tags_provider_extId ON page_tags(provider, externalId)');
|
|
355
|
+
await instance.raw('CREATE INDEX page_tags_provider_pageId ON page_tags(provider, pageId)');
|
|
356
|
+
await instance.raw('CREATE INDEX page_jsonld_type_pageId ON page_jsonld(type, pageId)');
|
|
357
|
+
// Content-addressable HTML blob storage. Knex's schema builder doesn't
|
|
358
|
+
// expose a WITHOUT ROWID toggle, so the BLOB tables are created via raw
|
|
359
|
+
// SQL. WITHOUT ROWID keeps the rows packed inside the b-tree leaves
|
|
360
|
+
// (no hidden rowid + secondary index pair), which matters for the blob
|
|
361
|
+
// table where a 32-byte hash PK + multi-KB body is the dominant row
|
|
362
|
+
// shape.
|
|
363
|
+
await instance.raw(`
|
|
364
|
+
CREATE TABLE page_html_blobs (
|
|
365
|
+
hash BLOB PRIMARY KEY,
|
|
366
|
+
body BLOB NOT NULL,
|
|
367
|
+
codec TEXT NOT NULL CHECK(codec IN ('zstd', 'none')),
|
|
368
|
+
size_raw INTEGER NOT NULL,
|
|
369
|
+
size_stored INTEGER NOT NULL
|
|
370
|
+
) WITHOUT ROWID
|
|
371
|
+
`);
|
|
372
|
+
await instance.raw(`
|
|
373
|
+
CREATE TABLE page_html_ref (
|
|
374
|
+
page_id INTEGER PRIMARY KEY REFERENCES pages(id) ON DELETE CASCADE,
|
|
375
|
+
hash BLOB NOT NULL REFERENCES page_html_blobs(hash)
|
|
376
|
+
) WITHOUT ROWID
|
|
377
|
+
`);
|
|
378
|
+
await instance.raw('CREATE INDEX idx_page_html_ref_hash ON page_html_ref(hash)');
|
|
379
|
+
// Composite covering index for the default Pages-view filter + url-ordered
|
|
380
|
+
// scan. Without it, `listPages` on a 400k-row archive runs ~15s per page
|
|
381
|
+
// click (SCAN pages USING pages_scraped_index + TEMP B-TREE FOR ORDER BY);
|
|
382
|
+
// with it, the same query runs ~45ms (368x speedup, confirmed via
|
|
383
|
+
// `scripts/bench-partial-listfilter.mjs` against a real customer archive).
|
|
384
|
+
// The same index also serves `listIsolatedPages`, `listIsolatedClusters`,
|
|
385
|
+
// and `getSummary`'s HTML-page counts.
|
|
386
|
+
//
|
|
387
|
+
// **Column order: `(isExternal, scraped, redirectDestId, url, contentType)`.**
|
|
388
|
+
// The leading `isExternal` is critical: the Pages view's default
|
|
389
|
+
// "external excluded" filter adds `WHERE isExternal = 0` to both the
|
|
390
|
+
// SELECT and the paginate-query COUNT. A previous version of this index
|
|
391
|
+
// (`(scraped, redirectDestId, url, contentType)`) shipped without
|
|
392
|
+
// `isExternal`, and the SELECT picked it up (`ORDER BY url` forced the
|
|
393
|
+
// match) while the COUNT — having no `ORDER BY` — fell back to the
|
|
394
|
+
// single-column `pages_isexternal_index` + scan + per-row WHERE filter,
|
|
395
|
+
// costing ~8.7s for the COUNT alone on a 165k-internal-page archive.
|
|
396
|
+
// Putting `isExternal` first makes both shapes pick this index as a
|
|
397
|
+
// covering scan (~33ms COUNT, ~1ms SELECT warm).
|
|
398
|
+
//
|
|
399
|
+
// **DO NOT RUN `ANALYZE` ON .nitpicker ARCHIVES.** With ANALYZE statistics
|
|
400
|
+
// available, the planner switches the JOIN paths in `listLinks`,
|
|
401
|
+
// `getLinkGraph`, and `listPageLinks` to use this index for source/dest
|
|
402
|
+
// seeks (SCAN dest → SEARCH anchors → SEARCH source) instead of the
|
|
403
|
+
// existing `SCAN anchors → rowid seek` plan. That regression takes those
|
|
404
|
+
// queries from ~15s to ~500s (33x worse). The unanalyzed-table heuristic
|
|
405
|
+
// happens to pick the right plan for the joins while still picking the new
|
|
406
|
+
// index for `listPages` because the column order exactly matches the
|
|
407
|
+
// WHERE+ORDER predicates. If a future change adds `ANALYZE` anywhere in
|
|
408
|
+
// the crawler / viewer / MCP / migration paths, this index must be
|
|
409
|
+
// re-evaluated first.
|
|
410
|
+
await instance.raw('CREATE INDEX idx_pages_listfilter ON pages(isExternal, scraped, redirectDestId, url, contentType)');
|
|
411
|
+
// Covering index for `listUnusedResources`. Without it the query SCAN s
|
|
412
|
+
// `resources_url_unique` (every resource, including externals) then
|
|
413
|
+
// filters `isExternal = 0` row-by-row — ~66s on the bench archive. With
|
|
414
|
+
// the `(isExternal, url)` leading prefix, the planner serves the WHERE
|
|
415
|
+
// + ORDER BY url from one covering scan — ~7.5s (8.8x). Same
|
|
416
|
+
// no-ANALYZE invariant applies (see `idx_pages_listfilter` above);
|
|
417
|
+
// validated against the 4 regression sentinels in
|
|
418
|
+
// `scripts/bench-unused-images.mjs`.
|
|
419
|
+
await instance.raw('CREATE INDEX idx_resources_internal_url ON resources(isExternal, url)');
|
|
420
|
+
// Covering index for `listImages`. The default query joins `images` to
|
|
421
|
+
// `pages` and orders by `pages.url`. Without this index the planner
|
|
422
|
+
// scans `images` first, seeks `pages` by rowid, and pays a TEMP B-TREE
|
|
423
|
+
// FOR ORDER BY (~32s on the bench archive). With the index the plan
|
|
424
|
+
// flips to SCAN pages (via `pages_url_unique`, url-ordered already)
|
|
425
|
+
// → SEARCH images via the covering pageId index — no temp sort, ~16s
|
|
426
|
+
// (2.0x). The included columns (src, alt, dimensions, isLazy) make
|
|
427
|
+
// `idx_images_covering` covering for every `select` `listImages` does,
|
|
428
|
+
// so the SEARCH does not need to materialise the underlying row.
|
|
429
|
+
// Validated by `scripts/bench-unused-images.mjs`.
|
|
430
|
+
await instance.raw('CREATE INDEX idx_images_covering ON images(pageId, src, alt, width, height, naturalWidth, naturalHeight, isLazy)');
|
|
431
|
+
// Targets `getSummary` Q2 (metadata fulfilment) + Q3 (content-type
|
|
432
|
+
// histogram). With this index Q2 and Q3 both become covering
|
|
433
|
+
// (`SEARCH ... USING COVERING INDEX`) — the SELECT columns are
|
|
434
|
+
// contained inside the index entry, so no rowid lookup is needed.
|
|
435
|
+
// Q1 (status histogram) also picks this index for its seek but
|
|
436
|
+
// keeps `USE TEMP B-TREE FOR GROUP BY` because the index column
|
|
437
|
+
// order leads with `contentType, isExternal` while Q1's GROUP BY
|
|
438
|
+
// is `(isExternal, status)` — the residual ordering inside the
|
|
439
|
+
// `scraped=1 AND redirectDestId IS NULL` slice does not match.
|
|
440
|
+
// Empirically this still gives the largest net win because Q2 +
|
|
441
|
+
// Q3 dominate `getSummary` on archives whose `pages` table dwarfs
|
|
442
|
+
// the SQLite page cache (10 GB bench: 1157 ms → 717 ms, 38 %).
|
|
443
|
+
//
|
|
444
|
+
// **An additional candidate index `(scraped, redirectDestId,
|
|
445
|
+
// isExternal, status)` was empirically rejected**: in isolation it
|
|
446
|
+
// matches Q1's GROUP BY column order and would eliminate the temp
|
|
447
|
+
// B-tree there, but in combination with this one or with the
|
|
448
|
+
// `pages_scraped_index` fallback the planner shifted to plans that
|
|
449
|
+
// regressed `getSummary` to 4.6-10 s (PR #96 教訓 — bulk index
|
|
450
|
+
// addition without ANALYZE confuses the heuristic). The
|
|
451
|
+
// `idx_pages_summary_contenttype` form below is the only summary
|
|
452
|
+
// index that survived the matrix test in
|
|
453
|
+
// `scripts/bench-summary-configs.mjs`.
|
|
454
|
+
//
|
|
455
|
+
// Column order rationale:
|
|
456
|
+
//
|
|
457
|
+
// 1. `scraped` — leading seek key. All summary queries constrain
|
|
458
|
+
// it to `=1`.
|
|
459
|
+
// 2. `redirectDestId` — post-seek filter, IS NULL folded into the
|
|
460
|
+
// seek key by SQLite's index walk without needing ANALYZE
|
|
461
|
+
// (per operator forum; the IS NULL leading column rule only
|
|
462
|
+
// bites when the column is the LEADING one and there is no
|
|
463
|
+
// other equality constraint).
|
|
464
|
+
// 3. `contentType` — the column Q3 groups by.
|
|
465
|
+
// 4. `isExternal` — Q3's second GROUP BY column AND Q2's WHERE
|
|
466
|
+
// constraint (`isExternal=0`).
|
|
467
|
+
// 5. `isSkipped` — Q1/Q3's residual `(isSkipped=0 OR IS NULL)`
|
|
468
|
+
// filter (`excludeSkippedPages`). Having it in the index lets
|
|
469
|
+
// the residual filter use the index entry instead of a per-row
|
|
470
|
+
// rowid lookup.
|
|
471
|
+
//
|
|
472
|
+
// `id` is implicitly included (every SQLite index entry carries
|
|
473
|
+
// the rowid), so the `count(id)` aggregates cover off-index.
|
|
474
|
+
//
|
|
475
|
+
// **No-ANALYZE invariant** identical to `idx_pages_listfilter`
|
|
476
|
+
// (PR #96): the column order matches the WHERE+GROUP BY predicates
|
|
477
|
+
// exactly, so SQLite's heuristic-only planner picks it without
|
|
478
|
+
// needing `sqlite_stat1`. Adding `ANALYZE` would risk planner
|
|
479
|
+
// shifts in this and other queries.
|
|
480
|
+
//
|
|
481
|
+
// **Regression check**: `listPages` / `listPages COUNT` /
|
|
482
|
+
// `listLinks broken` / `listPageLinks` plans were re-verified
|
|
483
|
+
// against this index — `idx_pages_listfilter` continues to win
|
|
484
|
+
// for all of them. See `scripts/bench-summary-configs.mjs`.
|
|
485
|
+
await instance.raw('CREATE INDEX idx_pages_summary_contenttype ON pages(scraped, redirectDestId, contentType, isExternal, isSkipped)');
|
|
486
|
+
// Targets `getSummary` Q4 (`failedPageIdRows`) — selects pages with
|
|
487
|
+
// `scraped=1 AND status=-1 AND redirectDestId IS NULL`. `status=-1`
|
|
488
|
+
// is highly selective (a few hundred rows on archives with
|
|
489
|
+
// ~400 k `scraped=1` pages), but without this index the planner
|
|
490
|
+
// seeks all `scraped=1` rows via `pages_scraped_index` and then
|
|
491
|
+
// row-by-row filters status, costing ~5 s. The 3-column form
|
|
492
|
+
// `(scraped, status, redirectDestId)` is fully covering for
|
|
493
|
+
// `SELECT id` and gives a 5113 ms → 14 ms (~365x) reduction
|
|
494
|
+
// verified by `scripts/prototype-summary-indexes.mjs`.
|
|
495
|
+
//
|
|
496
|
+
// Note the column order: `status` comes second so the `(scraped=1
|
|
497
|
+
// AND status=-1)` 2-column equality seek lands directly in the
|
|
498
|
+
// failed-page slice without scanning the 400 k+ healthy rows.
|
|
499
|
+
// Putting `redirectDestId` last keeps it as a 3rd-level seek
|
|
500
|
+
// constraint that the planner folds into the slice once the
|
|
501
|
+
// (scraped, status) pair is fixed.
|
|
502
|
+
await instance.raw('CREATE INDEX idx_pages_summary_failed ON pages(scraped, status, redirectDestId)');
|
|
119
503
|
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { PageSource } from './types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Predicate that returns `true` when the given page source value belongs to
|
|
4
|
+
* the inventory chain — i.e. it is one of the `'inventory-*'` variants of
|
|
5
|
+
* {@link PageSource}.
|
|
6
|
+
*
|
|
7
|
+
* Centralises the membership check that decides whether lineage
|
|
8
|
+
* propagation should fire. Three call sites used to inline
|
|
9
|
+
* `s === 'inventory-seed' || s === 'inventory-discovered'`, which is both
|
|
10
|
+
* a DRY violation AND a future-proofing trap: when a new inventory-family
|
|
11
|
+
* label gets added (e.g. `'inventory-promoted'`), every inlined check has
|
|
12
|
+
* to be located and updated by hand. Routing through this predicate
|
|
13
|
+
* keeps the membership rule in one place.
|
|
14
|
+
*
|
|
15
|
+
* Returns `false` for `undefined` so callers can pass the raw `source`
|
|
16
|
+
* column value (which is non-NULL in the DB schema but reads as
|
|
17
|
+
* `undefined` from a missing row in JS) without a separate null check.
|
|
18
|
+
* @param source - The source value to test, or `undefined` when no row matched.
|
|
19
|
+
* @returns `true` if the source is in the inventory chain, `false` otherwise.
|
|
20
|
+
*/
|
|
21
|
+
export declare function isInventorySource(source: PageSource | undefined): boolean;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Predicate that returns `true` when the given page source value belongs to
|
|
3
|
+
* the inventory chain — i.e. it is one of the `'inventory-*'` variants of
|
|
4
|
+
* {@link PageSource}.
|
|
5
|
+
*
|
|
6
|
+
* Centralises the membership check that decides whether lineage
|
|
7
|
+
* propagation should fire. Three call sites used to inline
|
|
8
|
+
* `s === 'inventory-seed' || s === 'inventory-discovered'`, which is both
|
|
9
|
+
* a DRY violation AND a future-proofing trap: when a new inventory-family
|
|
10
|
+
* label gets added (e.g. `'inventory-promoted'`), every inlined check has
|
|
11
|
+
* to be located and updated by hand. Routing through this predicate
|
|
12
|
+
* keeps the membership rule in one place.
|
|
13
|
+
*
|
|
14
|
+
* Returns `false` for `undefined` so callers can pass the raw `source`
|
|
15
|
+
* column value (which is non-NULL in the DB schema but reads as
|
|
16
|
+
* `undefined` from a missing row in JS) without a separate null check.
|
|
17
|
+
* @param source - The source value to test, or `undefined` when no row matched.
|
|
18
|
+
* @returns `true` if the source is in the inventory chain, `false` otherwise.
|
|
19
|
+
*/
|
|
20
|
+
export function isInventorySource(source) {
|
|
21
|
+
return source === 'inventory-seed' || source === 'inventory-discovered';
|
|
22
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { Knex } from 'knex';
|
|
2
|
+
/**
|
|
3
|
+
* Minimum `info.version` this build accepts. Archives older than this must
|
|
4
|
+
* be upgraded with `scripts/migrate-to-0.10.mjs` before they can be opened.
|
|
5
|
+
*
|
|
6
|
+
* History:
|
|
7
|
+
*
|
|
8
|
+
* - **pre-0.10**: HTML snapshots in `snapshot-html.zip` (#75), then
|
|
9
|
+
* relocated to `page_html_blobs` (#84); pages table has flat `noindex`,
|
|
10
|
+
* `og:type`-style columns derived from beholder 2.x's flat `Meta`.
|
|
11
|
+
* - **0.10.0**: this build. `page_html_blobs` BLOB storage (#75/#84) +
|
|
12
|
+
* nested-`Meta`-derived flat columns, `meta_extras` JSON, `page_tags` /
|
|
13
|
+
* `page_jsonld` tables, denormalised aggregates (#85).
|
|
14
|
+
*/
|
|
15
|
+
export declare const REQUIRED_FORMAT_VERSION = "0.10.0";
|
|
16
|
+
/**
|
|
17
|
+
* Verifies that the archive's on-disk format is compatible with this build.
|
|
18
|
+
*
|
|
19
|
+
* Compares the archive's `info.version` (a semver string written by
|
|
20
|
+
* `setConfig` at archive-create time, or by the migration script) against
|
|
21
|
+
* {@link REQUIRED_FORMAT_VERSION}. Older archives throw
|
|
22
|
+
* {@link IncompatibleArchiveError} pointing the operator at the migration
|
|
23
|
+
* script.
|
|
24
|
+
*
|
|
25
|
+
* Called from `Database.#init` for both writer and read-only (stub viewer)
|
|
26
|
+
* connections so old `._nitpicker-*` stubs surface the error too. New
|
|
27
|
+
* archives where the `info` table does not yet exist are tolerated —
|
|
28
|
+
* `initSchema` will fill them in next.
|
|
29
|
+
*
|
|
30
|
+
* The check is intentionally version-string-only, not schema-shape-based:
|
|
31
|
+
* `info.version` is the single declared source of truth, and a v0.10
|
|
32
|
+
* `migrate-to-0.10.mjs` run bumps it explicitly so the assertion passes
|
|
33
|
+
* once migration completes.
|
|
34
|
+
* @param instance - The Knex query builder for the archive's libsql connection.
|
|
35
|
+
* @throws {IncompatibleArchiveError} when `info.version` is older than
|
|
36
|
+
* {@link REQUIRED_FORMAT_VERSION}, or missing entirely on a non-empty
|
|
37
|
+
* archive.
|
|
38
|
+
*/
|
|
39
|
+
export declare function assertCompatibleVersion(instance: Knex): Promise<void>;
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { compareSemver } from './compare-semver.js';
|
|
2
|
+
import { IncompatibleArchiveError } from './types.js';
|
|
3
|
+
/**
|
|
4
|
+
* Minimum `info.version` this build accepts. Archives older than this must
|
|
5
|
+
* be upgraded with `scripts/migrate-to-0.10.mjs` before they can be opened.
|
|
6
|
+
*
|
|
7
|
+
* History:
|
|
8
|
+
*
|
|
9
|
+
* - **pre-0.10**: HTML snapshots in `snapshot-html.zip` (#75), then
|
|
10
|
+
* relocated to `page_html_blobs` (#84); pages table has flat `noindex`,
|
|
11
|
+
* `og:type`-style columns derived from beholder 2.x's flat `Meta`.
|
|
12
|
+
* - **0.10.0**: this build. `page_html_blobs` BLOB storage (#75/#84) +
|
|
13
|
+
* nested-`Meta`-derived flat columns, `meta_extras` JSON, `page_tags` /
|
|
14
|
+
* `page_jsonld` tables, denormalised aggregates (#85).
|
|
15
|
+
*/
|
|
16
|
+
export const REQUIRED_FORMAT_VERSION = '0.10.0';
|
|
17
|
+
/**
|
|
18
|
+
* Verifies that the archive's on-disk format is compatible with this build.
|
|
19
|
+
*
|
|
20
|
+
* Compares the archive's `info.version` (a semver string written by
|
|
21
|
+
* `setConfig` at archive-create time, or by the migration script) against
|
|
22
|
+
* {@link REQUIRED_FORMAT_VERSION}. Older archives throw
|
|
23
|
+
* {@link IncompatibleArchiveError} pointing the operator at the migration
|
|
24
|
+
* script.
|
|
25
|
+
*
|
|
26
|
+
* Called from `Database.#init` for both writer and read-only (stub viewer)
|
|
27
|
+
* connections so old `._nitpicker-*` stubs surface the error too. New
|
|
28
|
+
* archives where the `info` table does not yet exist are tolerated —
|
|
29
|
+
* `initSchema` will fill them in next.
|
|
30
|
+
*
|
|
31
|
+
* The check is intentionally version-string-only, not schema-shape-based:
|
|
32
|
+
* `info.version` is the single declared source of truth, and a v0.10
|
|
33
|
+
* `migrate-to-0.10.mjs` run bumps it explicitly so the assertion passes
|
|
34
|
+
* once migration completes.
|
|
35
|
+
* @param instance - The Knex query builder for the archive's libsql connection.
|
|
36
|
+
* @throws {IncompatibleArchiveError} when `info.version` is older than
|
|
37
|
+
* {@link REQUIRED_FORMAT_VERSION}, or missing entirely on a non-empty
|
|
38
|
+
* archive.
|
|
39
|
+
*/
|
|
40
|
+
export async function assertCompatibleVersion(instance) {
|
|
41
|
+
const hasInfo = await instance.schema.hasTable('info');
|
|
42
|
+
if (!hasInfo) {
|
|
43
|
+
// Brand-new archive — `initSchema` will create `info` and fill in
|
|
44
|
+
// the version next.
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
const hasVersionColumn = await instance.schema.hasColumn('info', 'version');
|
|
48
|
+
if (!hasVersionColumn) {
|
|
49
|
+
// Pre-version-tracked archive (very old). The column did not exist
|
|
50
|
+
// before the version was added to the info schema; reject with
|
|
51
|
+
// `'unknown'` so the operator runs the migration script.
|
|
52
|
+
throw new IncompatibleArchiveError('unknown', REQUIRED_FORMAT_VERSION);
|
|
53
|
+
}
|
|
54
|
+
const row = await instance
|
|
55
|
+
.from('info')
|
|
56
|
+
.select('version')
|
|
57
|
+
.first();
|
|
58
|
+
if (row === undefined) {
|
|
59
|
+
// `Archive.create()` calls `Database.connect` (which runs
|
|
60
|
+
// `initSchema` to create the info table) BEFORE `setConfig` writes
|
|
61
|
+
// the initial row. The transient empty-info state is a normal step
|
|
62
|
+
// of archive creation, not a corrupted pre-0.10 archive.
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
const archiveVersion = row.version ?? null;
|
|
66
|
+
if (archiveVersion === null || archiveVersion === '') {
|
|
67
|
+
throw new IncompatibleArchiveError('unknown', REQUIRED_FORMAT_VERSION);
|
|
68
|
+
}
|
|
69
|
+
if (compareSemver(archiveVersion, REQUIRED_FORMAT_VERSION) < 0) {
|
|
70
|
+
throw new IncompatibleArchiveError(archiveVersion, REQUIRED_FORMAT_VERSION);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { JsonLdEntry } from '@d-zero/beholder';
|
|
2
|
+
/**
|
|
3
|
+
* Extracts the top-level `@type` from a parsed {@link JsonLdEntry}, normalised
|
|
4
|
+
* to a single string suitable for an indexed SQL column.
|
|
5
|
+
*
|
|
6
|
+
* JSON-LD `@type` has four shapes in the wild:
|
|
7
|
+
*
|
|
8
|
+
* - **String** — common single-type case (e.g. `"Product"`).
|
|
9
|
+
* - **Array** — multi-type entries (e.g. `["Product", "Offer"]`). The first
|
|
10
|
+
* element is taken; the rest are recoverable from `parsed`.
|
|
11
|
+
* - **Missing** — top-level object without an `@type` (often a `@graph`
|
|
12
|
+
* wrapper). Returns `null` so the column is filterable but does not over-
|
|
13
|
+
* fit a single type.
|
|
14
|
+
* - **Parse error** — `parsed === undefined` because beholder set
|
|
15
|
+
* `parseError`. Returns `null`.
|
|
16
|
+
*
|
|
17
|
+
* `@graph` entries deliberately return `null` rather than `'@graph'` — a
|
|
18
|
+
* graph is a container, not a type, and consumers grouping by `@type` would
|
|
19
|
+
* see noise.
|
|
20
|
+
* @param entry - One JSON-LD entry as captured by beholder.
|
|
21
|
+
* @returns The normalised `@type` string, or `null` when not extractable.
|
|
22
|
+
*/
|
|
23
|
+
export declare function classifyJsonLdType(entry: JsonLdEntry): string | null;
|