@nitpicker/crawler 0.20.0 → 0.22.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/append-retry-failed-common-setup-phases.d.ts +1 -1
- package/lib/append-retry-failed-common-setup-phases.js +5 -1
- package/lib/append-setup-phases.d.ts +1 -1
- package/lib/archive/archive.d.ts +57 -8
- package/lib/archive/archive.js +83 -12
- package/lib/archive/create-adjunct-tables.d.ts +3 -2
- package/lib/archive/create-adjunct-tables.js +12 -9
- package/lib/archive/create-entity-tables.js +3 -0
- package/lib/archive/database.d.ts +39 -7
- package/lib/archive/database.js +49 -9
- package/lib/archive/db-ops/_shared/keyset-paginate-by-id.d.ts +49 -0
- package/lib/archive/db-ops/_shared/keyset-paginate-by-id.js +69 -0
- package/lib/archive/db-ops/_shared/resolve-content-item-id.d.ts +13 -12
- package/lib/archive/db-ops/_shared/resolve-content-item-id.js +63 -31
- package/lib/archive/db-ops/_shared/types.d.ts +39 -0
- package/lib/archive/db-ops/analysis/replace-analysis-violations.js +7 -4
- package/lib/archive/db-ops/config/get-created-cwd.d.ts +11 -0
- package/lib/archive/db-ops/config/get-created-cwd.js +13 -0
- package/lib/archive/db-ops/config/info-column-allowlist.js +1 -0
- package/lib/archive/db-ops/dedupe-cap/list-dedupe-cap-observations.d.ts +57 -0
- package/lib/archive/db-ops/dedupe-cap/list-dedupe-cap-observations.js +87 -0
- package/lib/archive/db-ops/errors/insert-page-error.js +3 -1
- package/lib/archive/db-ops/lifecycle/init.d.ts +13 -6
- package/lib/archive/db-ops/lifecycle/init.js +30 -10
- package/lib/archive/db-ops/list-reconcile/record-list-reconcile-run.d.ts +22 -0
- package/lib/archive/db-ops/{inventory/record-inventory-run.js → list-reconcile/record-list-reconcile-run.js} +11 -10
- package/lib/archive/db-ops/pages/read/build-page-query.js +1 -1
- package/lib/archive/db-ops/pages/read/get-crawling-state.d.ts +9 -2
- package/lib/archive/db-ops/pages/read/get-crawling-state.js +11 -3
- package/lib/archive/db-ops/pages/reset/clear-page-derived-rows.d.ts +20 -0
- package/lib/archive/db-ops/pages/reset/clear-page-derived-rows.js +37 -0
- package/lib/archive/db-ops/pages/reset/repromote-external-pages.d.ts +3 -2
- package/lib/archive/db-ops/pages/reset/repromote-external-pages.js +17 -37
- package/lib/archive/db-ops/pages/reset/reset-failed-pages.d.ts +22 -1
- package/lib/archive/db-ops/pages/reset/reset-failed-pages.js +79 -33
- package/lib/archive/db-ops/pages/reset/reset-pages-by-urls.d.ts +78 -0
- package/lib/archive/db-ops/pages/reset/reset-pages-by-urls.js +146 -0
- package/lib/archive/db-ops/pages/reset/retryable-image-scan-codes.d.ts +24 -0
- package/lib/archive/db-ops/pages/reset/retryable-image-scan-codes.js +24 -0
- package/lib/archive/db-ops/pages/write/insert-inventory-content-items.js +2 -1
- package/lib/archive/db-ops/pages/write/insert-page.js +4 -2
- package/lib/archive/db-ops/pages/write/link-redirect-sources.js +3 -1
- package/lib/archive/db-ops/pages/write/record-redirect.js +1 -1
- package/lib/archive/db-ops/pages/write/set-skipped-page.js +3 -1
- package/lib/archive/db-ops/pages/write/update-page.d.ts +9 -1
- package/lib/archive/db-ops/pages/write/update-page.js +28 -6
- package/lib/archive/db-ops/resources/get-resource-url-list.d.ts +3 -6
- package/lib/archive/db-ops/resources/get-resource-url-list.js +10 -35
- package/lib/archive/init-schema.d.ts +1 -1
- package/lib/archive/init-schema.js +3 -2
- package/lib/archive/meta/compute-main-contents-denormalized.d.ts +11 -3
- package/lib/archive/meta/compute-main-contents-denormalized.js +14 -2
- package/lib/archive/meta/types.d.ts +24 -0
- package/lib/archive/migrate-content-items-is-metadata-only.d.ts +44 -0
- package/lib/archive/migrate-content-items-is-metadata-only.js +60 -0
- package/lib/archive/migrate-info-created-cwd.d.ts +17 -0
- package/lib/archive/migrate-info-created-cwd.js +36 -0
- package/lib/archive/migrate-inventory-runs-to-list-reconcile-runs.d.ts +34 -0
- package/lib/archive/migrate-inventory-runs-to-list-reconcile-runs.js +51 -0
- package/lib/archive/{migrate-inventory-runs-exclude-skipped.d.ts → migrate-list-reconcile-runs-exclude-skipped.d.ts} +8 -8
- package/lib/archive/{migrate-inventory-runs-exclude-skipped.js → migrate-list-reconcile-runs-exclude-skipped.js} +12 -12
- package/lib/archive/migrate-list-reconcile-runs-invalid-skipped.d.ts +16 -0
- package/lib/archive/migrate-list-reconcile-runs-invalid-skipped.js +35 -0
- package/lib/archive/migrate-page-meta-image-scan.d.ts +27 -0
- package/lib/archive/migrate-page-meta-image-scan.js +52 -0
- package/lib/archive/page.d.ts +12 -0
- package/lib/archive/page.js +16 -0
- package/lib/archive/types.d.ts +97 -10
- package/lib/compute-auto-retry-backoff-delay.d.ts +30 -0
- package/lib/compute-auto-retry-backoff-delay.js +36 -0
- package/lib/crawler/apply-crawl-runtime-options-patch.d.ts +32 -0
- package/lib/crawler/apply-crawl-runtime-options-patch.js +129 -0
- package/lib/crawler/crawler.d.ts +35 -3
- package/lib/crawler/crawler.js +486 -338
- package/lib/crawler/dedupe/build-dedupe-cap-observation.d.ts +74 -0
- package/lib/crawler/dedupe/build-dedupe-cap-observation.js +97 -0
- package/lib/crawler/dedupe/compute-meta-signature.d.ts +7 -5
- package/lib/crawler/dedupe/compute-meta-signature.js +0 -0
- package/lib/crawler/dedupe/compute-shape-key.d.ts +20 -0
- package/lib/crawler/dedupe/compute-shape-key.js +73 -1
- package/lib/crawler/dedupe/dedupe-cap-tracker.d.ts +6 -1
- package/lib/crawler/dedupe/dedupe-cap-tracker.js +6 -1
- package/lib/crawler/dedupe/resolve-og-url-mismatch.d.ts +7 -5
- package/lib/crawler/dedupe/resolve-og-url-mismatch.js +5 -3
- package/lib/crawler/dedupe/types.d.ts +18 -0
- package/lib/crawler/fetch-destination.js +1 -0
- package/lib/crawler/link-list.d.ts +9 -1
- package/lib/crawler/link-list.js +12 -2
- package/lib/crawler/link-to-page-data.js +1 -0
- package/lib/crawler/resource-to-page-data.js +1 -0
- package/lib/crawler/types.d.ts +75 -0
- package/lib/crawler-orchestrator.d.ts +171 -8
- package/lib/crawler-orchestrator.js +1177 -234
- package/lib/crawler.d.ts +3 -0
- package/lib/crawler.js +3 -0
- package/lib/delay-or-abort.d.ts +20 -0
- package/lib/delay-or-abort.js +35 -0
- package/lib/pending-urls-remain-error.d.ts +35 -0
- package/lib/pending-urls-remain-error.js +43 -0
- package/lib/recrawl-setup-phases.d.ts +38 -0
- package/lib/recrawl-setup-phases.js +52 -0
- package/lib/resume-setup-phases.d.ts +1 -1
- package/lib/resume-setup-phases.js +5 -1
- package/lib/retry-failed-setup-phases.d.ts +1 -1
- package/lib/setup-recovery-phase-labels.d.ts +17 -8
- package/lib/setup-recovery-phase-labels.js +17 -8
- package/lib/types.d.ts +62 -12
- package/lib/utils/compute-file-sha256.d.ts +1 -1
- package/lib/utils/compute-file-sha256.js +1 -1
- package/package.json +4 -4
- package/lib/archive/db-ops/inventory/record-inventory-run.d.ts +0 -21
- package/lib/archive/migrate-inventory-runs-invalid-skipped.d.ts +0 -16
- package/lib/archive/migrate-inventory-runs-invalid-skipped.js +0 -35
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import type { Knex } from 'knex';
|
|
2
|
+
/**
|
|
3
|
+
* Reads every row a caller-supplied query matches, in ascending-`id`
|
|
4
|
+
* keyset-paginated chunks rather than one unbounded SELECT (issue #294): on
|
|
5
|
+
* a large archive, a single multi-second-to-minutes scan gives the caller
|
|
6
|
+
* no way to report progress mid-read. The accumulated result is identical
|
|
7
|
+
* to a single un-chunked SELECT — chunking exists purely to make the scan
|
|
8
|
+
* observable, not to bound memory (the mapped output is already fully
|
|
9
|
+
* materialised for the caller either way).
|
|
10
|
+
*
|
|
11
|
+
* Extracted from `getResourceUrlList` / `listDedupeCapObservations`, which
|
|
12
|
+
* had grown byte-for-byte identical pagination boilerplate (the `MAX(id)`
|
|
13
|
+
* probe, the `for (;;)` chunk loop, the `onProgress` shape) around two
|
|
14
|
+
* otherwise-unrelated queries. Callers own everything query-shaped
|
|
15
|
+
* (joins, `WHERE`, `SELECT`, chunk size via `.limit()` inside
|
|
16
|
+
* `buildQuery`) — this helper only owns the keyset-chunking mechanics
|
|
17
|
+
* common to both.
|
|
18
|
+
* @param knex - Knex query builder connected to the archive DB.
|
|
19
|
+
* @param idTable - The table `id` keys off, used for the O(1) `MAX(id)`
|
|
20
|
+
* probe that seeds `onProgress`'s total (only queried when `onProgress`
|
|
21
|
+
* is given).
|
|
22
|
+
* @param buildQuery - Builds one chunk's query given the last-seen `id`
|
|
23
|
+
* (`0` on the first call). Must filter on `id > lastId`, order by `id`
|
|
24
|
+
* ascending, and cap the row count (typically via `.limit()`) — this
|
|
25
|
+
* helper does not add any of those clauses itself. The resolved rows
|
|
26
|
+
* must each carry an `id` field so the next chunk's cursor can advance.
|
|
27
|
+
* @param mapRow - Transforms one raw row into the caller's output shape.
|
|
28
|
+
* @param onProgress - Called after each chunk, with the highest `id`
|
|
29
|
+
* scanned so far and the max `id` in `idTable`. Omit for no reporting.
|
|
30
|
+
* @returns Every matched row, mapped via `mapRow`, in `id` order.
|
|
31
|
+
* @example
|
|
32
|
+
* ```ts
|
|
33
|
+
* const urls = await keysetPaginateById(
|
|
34
|
+
* knex,
|
|
35
|
+
* 'resource_items',
|
|
36
|
+
* (lastId) =>
|
|
37
|
+
* knex('resource_items')
|
|
38
|
+
* .join('url_refs', 'url_refs.id', 'resource_items.url_id')
|
|
39
|
+
* .where('resource_items.id', '>', lastId)
|
|
40
|
+
* .orderBy('resource_items.id', 'asc')
|
|
41
|
+
* .limit(2000)
|
|
42
|
+
* .select('resource_items.id as id', 'url_refs.url as url'),
|
|
43
|
+
* (row) => row.url,
|
|
44
|
+
* );
|
|
45
|
+
* ```
|
|
46
|
+
*/
|
|
47
|
+
export declare function keysetPaginateById<Row extends {
|
|
48
|
+
id: number;
|
|
49
|
+
}, Out>(knex: Knex, idTable: string, buildQuery: (lastId: number) => Knex.QueryBuilder, mapRow: (row: Row) => Out, onProgress?: (scannedUpToId: number, maxId: number) => void): Promise<Out[]>;
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reads every row a caller-supplied query matches, in ascending-`id`
|
|
3
|
+
* keyset-paginated chunks rather than one unbounded SELECT (issue #294): on
|
|
4
|
+
* a large archive, a single multi-second-to-minutes scan gives the caller
|
|
5
|
+
* no way to report progress mid-read. The accumulated result is identical
|
|
6
|
+
* to a single un-chunked SELECT — chunking exists purely to make the scan
|
|
7
|
+
* observable, not to bound memory (the mapped output is already fully
|
|
8
|
+
* materialised for the caller either way).
|
|
9
|
+
*
|
|
10
|
+
* Extracted from `getResourceUrlList` / `listDedupeCapObservations`, which
|
|
11
|
+
* had grown byte-for-byte identical pagination boilerplate (the `MAX(id)`
|
|
12
|
+
* probe, the `for (;;)` chunk loop, the `onProgress` shape) around two
|
|
13
|
+
* otherwise-unrelated queries. Callers own everything query-shaped
|
|
14
|
+
* (joins, `WHERE`, `SELECT`, chunk size via `.limit()` inside
|
|
15
|
+
* `buildQuery`) — this helper only owns the keyset-chunking mechanics
|
|
16
|
+
* common to both.
|
|
17
|
+
* @param knex - Knex query builder connected to the archive DB.
|
|
18
|
+
* @param idTable - The table `id` keys off, used for the O(1) `MAX(id)`
|
|
19
|
+
* probe that seeds `onProgress`'s total (only queried when `onProgress`
|
|
20
|
+
* is given).
|
|
21
|
+
* @param buildQuery - Builds one chunk's query given the last-seen `id`
|
|
22
|
+
* (`0` on the first call). Must filter on `id > lastId`, order by `id`
|
|
23
|
+
* ascending, and cap the row count (typically via `.limit()`) — this
|
|
24
|
+
* helper does not add any of those clauses itself. The resolved rows
|
|
25
|
+
* must each carry an `id` field so the next chunk's cursor can advance.
|
|
26
|
+
* @param mapRow - Transforms one raw row into the caller's output shape.
|
|
27
|
+
* @param onProgress - Called after each chunk, with the highest `id`
|
|
28
|
+
* scanned so far and the max `id` in `idTable`. Omit for no reporting.
|
|
29
|
+
* @returns Every matched row, mapped via `mapRow`, in `id` order.
|
|
30
|
+
* @example
|
|
31
|
+
* ```ts
|
|
32
|
+
* const urls = await keysetPaginateById(
|
|
33
|
+
* knex,
|
|
34
|
+
* 'resource_items',
|
|
35
|
+
* (lastId) =>
|
|
36
|
+
* knex('resource_items')
|
|
37
|
+
* .join('url_refs', 'url_refs.id', 'resource_items.url_id')
|
|
38
|
+
* .where('resource_items.id', '>', lastId)
|
|
39
|
+
* .orderBy('resource_items.id', 'asc')
|
|
40
|
+
* .limit(2000)
|
|
41
|
+
* .select('resource_items.id as id', 'url_refs.url as url'),
|
|
42
|
+
* (row) => row.url,
|
|
43
|
+
* );
|
|
44
|
+
* ```
|
|
45
|
+
*/
|
|
46
|
+
export async function keysetPaginateById(knex, idTable, buildQuery, mapRow, onProgress) {
|
|
47
|
+
// MAX() over the keyset column is an O(1) index-tail read; only fetched
|
|
48
|
+
// when someone is listening.
|
|
49
|
+
let maxId = 0;
|
|
50
|
+
if (onProgress) {
|
|
51
|
+
const [maxRow] = await knex(idTable).max({ max: 'id' });
|
|
52
|
+
maxId = maxRow?.max ?? 0;
|
|
53
|
+
}
|
|
54
|
+
const results = [];
|
|
55
|
+
let lastId = 0;
|
|
56
|
+
for (;;) {
|
|
57
|
+
const rows = (await buildQuery(lastId));
|
|
58
|
+
if (rows.length === 0) {
|
|
59
|
+
onProgress?.(maxId, maxId);
|
|
60
|
+
break;
|
|
61
|
+
}
|
|
62
|
+
lastId = rows.at(-1).id;
|
|
63
|
+
for (const row of rows) {
|
|
64
|
+
results.push(mapRow(row));
|
|
65
|
+
}
|
|
66
|
+
onProgress?.(Math.min(lastId, maxId), maxId);
|
|
67
|
+
}
|
|
68
|
+
return results;
|
|
69
|
+
}
|
|
@@ -1,5 +1,4 @@
|
|
|
1
|
-
import type { WriteRefCaches } from './types.js';
|
|
2
|
-
import type { PageSource } from '../../types.js';
|
|
1
|
+
import type { ResolveContentItemIdOptions, WriteRefCaches } from './types.js';
|
|
3
2
|
import type { Knex } from 'knex';
|
|
4
3
|
/**
|
|
5
4
|
* Returns the `content_items.id` for `url`, inserting a placeholder row
|
|
@@ -23,6 +22,11 @@ import type { Knex } from 'knex';
|
|
|
23
22
|
* inventory label. The cached `source` is updated in the same step so a
|
|
24
23
|
* later hit does not re-issue the UPDATE.
|
|
25
24
|
*
|
|
25
|
+
* **`is_metadata_only` promotion** (issue #369) is evaluated in the same
|
|
26
|
+
* pass as the crawled-wins downgrade, and both diffs are folded into a
|
|
27
|
+
* single `UPDATE` statement (not two) when either column actually
|
|
28
|
+
* changes — see {@link applyExistingRowUpdates}.
|
|
29
|
+
*
|
|
26
30
|
* **Cache poisoning on rollback.** Entries cached inside a transaction
|
|
27
31
|
* that later rolls back would point at ids that no longer exist. Every
|
|
28
32
|
* write op that opens a multi-statement transaction around this function
|
|
@@ -44,18 +48,15 @@ import type { Knex } from 'knex';
|
|
|
44
48
|
* @param caches - The connection's write-side id caches; mutated in place.
|
|
45
49
|
* @param url - The URL to look up or insert (normalised
|
|
46
50
|
* `withoutHashAndAuth` form, matching the legacy identity contract).
|
|
47
|
-
* @param
|
|
48
|
-
* the row as an external URL that will never be scraped as a target.
|
|
49
|
-
* Defaults to `0` (in-scope) on insert, mirroring the legacy column
|
|
50
|
-
* default.
|
|
51
|
-
* @param source - Optional provenance label put on a newly-inserted row.
|
|
52
|
-
* Omit to let the `content_items.source` DEFAULT (`'crawled'`) apply.
|
|
53
|
-
* Pass `'crawled'` to arm the crawled-wins downgrade on existing
|
|
54
|
-
* inventory-labelled rows.
|
|
51
|
+
* @param options - See {@link ResolveContentItemIdOptions}.
|
|
55
52
|
* @returns The `content_items.id` of the existing or newly inserted row.
|
|
56
53
|
* @throws {Error} When the upsert's `RETURNING` yields no row — should
|
|
57
54
|
* not happen, so it surfaces as a hard error.
|
|
58
55
|
* @example
|
|
59
|
-
* const pageId = await resolveContentItemId(trx, caches, anchor.href,
|
|
56
|
+
* const pageId = await resolveContentItemId(trx, caches, anchor.href, {
|
|
57
|
+
* isExternal: 1,
|
|
58
|
+
* source: 'crawled',
|
|
59
|
+
* isMetadataOnly: 1,
|
|
60
|
+
* });
|
|
60
61
|
*/
|
|
61
|
-
export declare function resolveContentItemId(qb: Knex | Knex.Transaction, caches: WriteRefCaches, url: string,
|
|
62
|
+
export declare function resolveContentItemId(qb: Knex | Knex.Transaction, caches: WriteRefCaches, url: string, options?: ResolveContentItemIdOptions): Promise<number>;
|
|
@@ -21,6 +21,11 @@ import { upsertUrlRef } from './upsert-url-ref.js';
|
|
|
21
21
|
* inventory label. The cached `source` is updated in the same step so a
|
|
22
22
|
* later hit does not re-issue the UPDATE.
|
|
23
23
|
*
|
|
24
|
+
* **`is_metadata_only` promotion** (issue #369) is evaluated in the same
|
|
25
|
+
* pass as the crawled-wins downgrade, and both diffs are folded into a
|
|
26
|
+
* single `UPDATE` statement (not two) when either column actually
|
|
27
|
+
* changes — see {@link applyExistingRowUpdates}.
|
|
28
|
+
*
|
|
24
29
|
* **Cache poisoning on rollback.** Entries cached inside a transaction
|
|
25
30
|
* that later rolls back would point at ids that no longer exist. Every
|
|
26
31
|
* write op that opens a multi-statement transaction around this function
|
|
@@ -42,41 +47,45 @@ import { upsertUrlRef } from './upsert-url-ref.js';
|
|
|
42
47
|
* @param caches - The connection's write-side id caches; mutated in place.
|
|
43
48
|
* @param url - The URL to look up or insert (normalised
|
|
44
49
|
* `withoutHashAndAuth` form, matching the legacy identity contract).
|
|
45
|
-
* @param
|
|
46
|
-
* the row as an external URL that will never be scraped as a target.
|
|
47
|
-
* Defaults to `0` (in-scope) on insert, mirroring the legacy column
|
|
48
|
-
* default.
|
|
49
|
-
* @param source - Optional provenance label put on a newly-inserted row.
|
|
50
|
-
* Omit to let the `content_items.source` DEFAULT (`'crawled'`) apply.
|
|
51
|
-
* Pass `'crawled'` to arm the crawled-wins downgrade on existing
|
|
52
|
-
* inventory-labelled rows.
|
|
50
|
+
* @param options - See {@link ResolveContentItemIdOptions}.
|
|
53
51
|
* @returns The `content_items.id` of the existing or newly inserted row.
|
|
54
52
|
* @throws {Error} When the upsert's `RETURNING` yields no row — should
|
|
55
53
|
* not happen, so it surfaces as a hard error.
|
|
56
54
|
* @example
|
|
57
|
-
* const pageId = await resolveContentItemId(trx, caches, anchor.href,
|
|
55
|
+
* const pageId = await resolveContentItemId(trx, caches, anchor.href, {
|
|
56
|
+
* isExternal: 1,
|
|
57
|
+
* source: 'crawled',
|
|
58
|
+
* isMetadataOnly: 1,
|
|
59
|
+
* });
|
|
58
60
|
*/
|
|
59
|
-
export async function resolveContentItemId(qb, caches, url,
|
|
61
|
+
export async function resolveContentItemId(qb, caches, url, options) {
|
|
62
|
+
const { isExternal, source, isMetadataOnly } = options ?? {};
|
|
60
63
|
const cached = caches.contentItems.get(url);
|
|
61
64
|
if (cached !== undefined) {
|
|
62
|
-
await
|
|
65
|
+
await applyExistingRowUpdates(qb, cached, source, isMetadataOnly);
|
|
63
66
|
return cached.id;
|
|
64
67
|
}
|
|
65
68
|
const urlId = await upsertUrlRef(qb, caches, url);
|
|
66
69
|
const [record] = (await qb
|
|
67
|
-
.select('id', 'source')
|
|
70
|
+
.select('id', 'source', 'is_metadata_only')
|
|
68
71
|
.from('content_items')
|
|
69
72
|
.where('url_id', urlId));
|
|
70
73
|
if (record !== undefined) {
|
|
71
|
-
const entry = {
|
|
72
|
-
|
|
74
|
+
const entry = {
|
|
75
|
+
id: record.id,
|
|
76
|
+
source: record.source,
|
|
77
|
+
isMetadataOnly: record.is_metadata_only,
|
|
78
|
+
};
|
|
79
|
+
await applyExistingRowUpdates(qb, entry, source, isMetadataOnly);
|
|
73
80
|
caches.contentItems.set(url, entry);
|
|
74
81
|
return entry.id;
|
|
75
82
|
}
|
|
76
|
-
const insertedRows = await qb.raw(`INSERT INTO content_items (url_id, scraped, is_target, is_external${source === undefined ? '' : ', source'})
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
83
|
+
const insertedRows = await qb.raw(`INSERT INTO content_items (url_id, scraped, is_target, is_external, is_metadata_only${source === undefined ? '' : ', source'})
|
|
84
|
+
VALUES (?, 0, 0, ?, ?${source === undefined ? '' : ', ?'})
|
|
85
|
+
ON CONFLICT(url_id) DO UPDATE SET url_id = url_id
|
|
86
|
+
RETURNING id, source, is_metadata_only`, source === undefined
|
|
87
|
+
? [urlId, isExternal ?? 0, isMetadataOnly ?? 0]
|
|
88
|
+
: [urlId, isExternal ?? 0, isMetadataOnly ?? 0, source]);
|
|
80
89
|
const inserted = insertedRows[0];
|
|
81
90
|
if (inserted === undefined) {
|
|
82
91
|
throw new Error(`Failed to insert a new content item: ${url}`);
|
|
@@ -84,28 +93,51 @@ export async function resolveContentItemId(qb, caches, url, isExternal, source)
|
|
|
84
93
|
const insertedEntry = {
|
|
85
94
|
id: inserted.id,
|
|
86
95
|
source: inserted.source,
|
|
96
|
+
isMetadataOnly: inserted.is_metadata_only,
|
|
87
97
|
};
|
|
88
98
|
// A conflict means a concurrent writer created the row between this
|
|
89
|
-
// function's SELECT miss and the INSERT — the returned `source`
|
|
90
|
-
// row's
|
|
91
|
-
// SELECT-hit path.
|
|
92
|
-
await
|
|
99
|
+
// function's SELECT miss and the INSERT — the returned `source` /
|
|
100
|
+
// `is_metadata_only` are that row's values, so both follow-ups must be
|
|
101
|
+
// evaluated exactly as on the SELECT-hit path.
|
|
102
|
+
await applyExistingRowUpdates(qb, insertedEntry, source, isMetadataOnly);
|
|
93
103
|
caches.contentItems.set(url, insertedEntry);
|
|
94
104
|
return insertedEntry.id;
|
|
95
105
|
}
|
|
96
106
|
/**
|
|
97
|
-
*
|
|
98
|
-
*
|
|
99
|
-
* keeps the cache entry in sync so
|
|
100
|
-
*
|
|
107
|
+
* Applies both the crawled-wins `source` downgrade and the `is_metadata_only`
|
|
108
|
+
* promotion (issue #369) to an already-resolved row in a single `UPDATE`,
|
|
109
|
+
* and keeps the cache entry in sync so a repeat call with the same values
|
|
110
|
+
* does not re-issue any write.
|
|
111
|
+
*
|
|
112
|
+
* Folded into one function (and one statement) rather than two independent
|
|
113
|
+
* ones: `resolveContentItemId` runs once per anchor on every scraped page
|
|
114
|
+
* across a whole crawl, so a page whose anchors trip both conditions would
|
|
115
|
+
* otherwise pay two round trips instead of one.
|
|
101
116
|
* @param qb - Knex instance or transaction.
|
|
102
117
|
* @param entry - The cached identity to check and mutate.
|
|
103
|
-
* @param source - The resolution's lineage label
|
|
118
|
+
* @param source - The resolution's lineage label, if any — see
|
|
119
|
+
* {@link ResolveContentItemIdOptions.source}.
|
|
120
|
+
* @param isMetadataOnly - The resolution's explicit scrape-depth opinion, if
|
|
121
|
+
* any — see {@link ResolveContentItemIdOptions.isMetadataOnly}. Omitted by
|
|
122
|
+
* every caller except `replaceAnchorEdges`, which always has an opinion,
|
|
123
|
+
* so this is a no-op for the rest.
|
|
104
124
|
*/
|
|
105
|
-
async function
|
|
106
|
-
|
|
125
|
+
async function applyExistingRowUpdates(qb, entry, source, isMetadataOnly) {
|
|
126
|
+
const updates = {};
|
|
127
|
+
if (source === 'crawled' && entry.source !== 'crawled') {
|
|
128
|
+
updates.source = 'crawled';
|
|
129
|
+
}
|
|
130
|
+
if (isMetadataOnly !== undefined && entry.isMetadataOnly !== isMetadataOnly) {
|
|
131
|
+
updates.is_metadata_only = isMetadataOnly;
|
|
132
|
+
}
|
|
133
|
+
if (Object.keys(updates).length === 0) {
|
|
107
134
|
return;
|
|
108
135
|
}
|
|
109
|
-
await qb('content_items').where('id', entry.id).update(
|
|
110
|
-
|
|
136
|
+
await qb('content_items').where('id', entry.id).update(updates);
|
|
137
|
+
if (updates.source !== undefined) {
|
|
138
|
+
entry.source = updates.source;
|
|
139
|
+
}
|
|
140
|
+
if (updates.is_metadata_only !== undefined) {
|
|
141
|
+
entry.isMetadataOnly = updates.is_metadata_only;
|
|
142
|
+
}
|
|
111
143
|
}
|
|
@@ -12,6 +12,12 @@ export interface ContentItemCacheEntry {
|
|
|
12
12
|
id: number;
|
|
13
13
|
/** `content_items.source` as last observed / written by this process. */
|
|
14
14
|
source: PageSource;
|
|
15
|
+
/**
|
|
16
|
+
* `content_items.is_metadata_only` as last observed / written by this
|
|
17
|
+
* process. Lets {@link ../_shared/resolve-content-item-id.ts} skip a
|
|
18
|
+
* redundant `UPDATE` when a later resolution recomputes the same value.
|
|
19
|
+
*/
|
|
20
|
+
isMetadataOnly: 0 | 1;
|
|
15
21
|
}
|
|
16
22
|
/**
|
|
17
23
|
* In-process id caches shared across one archive connection's write path.
|
|
@@ -53,3 +59,36 @@ export interface WriteRefCaches {
|
|
|
53
59
|
*/
|
|
54
60
|
headers: HeaderTableCaches | null;
|
|
55
61
|
}
|
|
62
|
+
/**
|
|
63
|
+
* Optional per-call flags for {@link ../_shared/resolve-content-item-id.ts}.
|
|
64
|
+
*/
|
|
65
|
+
export interface ResolveContentItemIdOptions {
|
|
66
|
+
/**
|
|
67
|
+
* `1` marks the row as an external URL that will never be scraped as a
|
|
68
|
+
* target. Recorded on new inserts only. Defaults to `0` (in-scope) on
|
|
69
|
+
* insert, mirroring the legacy column default.
|
|
70
|
+
*/
|
|
71
|
+
isExternal?: 0 | 1;
|
|
72
|
+
/**
|
|
73
|
+
* Provenance label put on a newly-inserted row. Omit to let the
|
|
74
|
+
* `content_items.source` DEFAULT (`'crawled'`) apply. Pass `'crawled'`
|
|
75
|
+
* to arm the crawled-wins downgrade on existing inventory-labelled rows.
|
|
76
|
+
*/
|
|
77
|
+
source?: PageSource;
|
|
78
|
+
/**
|
|
79
|
+
* `1` marks the row as fated for a metadata-only (title-only) scrape
|
|
80
|
+
* rather than a full one, `0` explicitly marks it as a full-scrape
|
|
81
|
+
* target. Omit (the default for every caller except
|
|
82
|
+
* `replaceAnchorEdges`) to leave the column untouched — those callers
|
|
83
|
+
* (redirects, resources, errors, skipped pages, console logs) have no
|
|
84
|
+
* opinion on scrape depth and must not clobber a value an
|
|
85
|
+
* anchor-discovery call already established.
|
|
86
|
+
* `!options.recursive || anchor.isExternal` is a pure function of the
|
|
87
|
+
* URL within one crawl session (`recursive` is session-constant,
|
|
88
|
+
* `isExternal` depends only on scope matching), so every anchor-path
|
|
89
|
+
* call for the same URL always recomputes the same value — there is no
|
|
90
|
+
* real conflict to arbitrate between two *opinionated* calls, only
|
|
91
|
+
* between an opinionated call and the unopinionated majority.
|
|
92
|
+
*/
|
|
93
|
+
isMetadataOnly?: 0 | 1;
|
|
94
|
+
}
|
|
@@ -33,10 +33,13 @@ function parseLegacyCorruptedUrl(url) {
|
|
|
33
33
|
* version-gated migration script (see `scripts/migrate-to-0.13.mjs`). This
|
|
34
34
|
* function relies on the exception documented in ARCHITECTURE.md's
|
|
35
35
|
* invariants list: a nullable, additive column on a table with a single
|
|
36
|
-
*
|
|
37
|
-
* `replaceAnalysisViolations` is that single
|
|
38
|
-
* `analysis_violations
|
|
39
|
-
*
|
|
36
|
+
* INSERT path may self-heal here without a version bump, because
|
|
37
|
+
* `replaceAnalysisViolations` is that single INSERT path for
|
|
38
|
+
* `analysis_violations` — `resetPagesByUrls` (`db-ops/pages/reset/`) also
|
|
39
|
+
* writes to this table, but only ever DELETEs by `page_id`, so it never
|
|
40
|
+
* needs to know about `line`/`col` and cannot violate this self-heal
|
|
41
|
+
* argument. Runs before the transaction below so the DDL is not mixed with
|
|
42
|
+
* the DML rewrite.
|
|
40
43
|
* @param knex - Knex query builder connected to the archive DB.
|
|
41
44
|
*/
|
|
42
45
|
async function ensureLineColColumns(knex) {
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { Knex } from 'knex';
|
|
2
|
+
/**
|
|
3
|
+
* Retrieves the working directory recorded when this stub's crawl session
|
|
4
|
+
* was created, so {@link import('../../archive.js').default.resume} can
|
|
5
|
+
* reconstruct the completed archive's output path independent of the
|
|
6
|
+
* directory `crawl --resume` happens to be invoked from.
|
|
7
|
+
* @param knex - Knex query builder connected to the archive DB.
|
|
8
|
+
* @returns The recorded cwd, or `null` for a stub created before this column
|
|
9
|
+
* existed (or created by a code path that never stamps it, i.e. `resume`).
|
|
10
|
+
*/
|
|
11
|
+
export declare function getCreatedCwd(knex: Knex): Promise<string | null>;
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Retrieves the working directory recorded when this stub's crawl session
|
|
3
|
+
* was created, so {@link import('../../archive.js').default.resume} can
|
|
4
|
+
* reconstruct the completed archive's output path independent of the
|
|
5
|
+
* directory `crawl --resume` happens to be invoked from.
|
|
6
|
+
* @param knex - Knex query builder connected to the archive DB.
|
|
7
|
+
* @returns The recorded cwd, or `null` for a stub created before this column
|
|
8
|
+
* existed (or created by a code path that never stamps it, i.e. `resume`).
|
|
9
|
+
*/
|
|
10
|
+
export async function getCreatedCwd(knex) {
|
|
11
|
+
const [row] = await knex.select('createdCwd').from('info');
|
|
12
|
+
return row?.createdCwd ?? null;
|
|
13
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import type { DedupeCapObservationRow } from '../../types.js';
|
|
2
|
+
import type { Knex } from 'knex';
|
|
3
|
+
/**
|
|
4
|
+
* Reads back every previously-scraped internal page's raw fields in the
|
|
5
|
+
* exact population a live crawl's `DedupeCapTracker#observe` call site
|
|
6
|
+
* would have fed it, so `CrawlerOrchestrator`'s five resuming-session
|
|
7
|
+
* methods (`append`/`inventory`/`recrawl`/`retryFailed`/`resume`) can
|
|
8
|
+
* replay a prior session's Misra-Gries observations into a fresh tracker
|
|
9
|
+
* instance (see `buildDedupeCapObservation`) instead of restarting every
|
|
10
|
+
* not-yet-capped shape's counter at 0.
|
|
11
|
+
*
|
|
12
|
+
* The WHERE clause reproduces the live observation gate
|
|
13
|
+
* (`!isExternal && !isMetadataOnly && html.length > 0`) using columns that
|
|
14
|
+
* survive a process restart:
|
|
15
|
+
*
|
|
16
|
+
* - `is_external = 0` — external pages carry no useful signal (same
|
|
17
|
+
* exclusion `computeMetaSignature`'s design already assumes).
|
|
18
|
+
* - `is_target = 1` — the archived equivalent of "not metadata-only": a
|
|
19
|
+
* metadata-only fetch never invokes the browser and is written with
|
|
20
|
+
* `is_target = 0` (see `Crawler`'s metadata-only branch), matching the
|
|
21
|
+
* live gate's `!isMetadataOnly` exactly.
|
|
22
|
+
* - `redirect_dest_id IS NULL` — a redirect source's own body was never
|
|
23
|
+
* rendered; only the destination page (a separate row) was.
|
|
24
|
+
* - `is_skipped IS NULL OR is_skipped = 0` — a skipped placeholder row
|
|
25
|
+
* (`source = 'inventory-seed'` etc.) has no rendered body either.
|
|
26
|
+
* - `pm.body_hash IS NOT NULL` — the archived proxy for `html.length > 0`:
|
|
27
|
+
* `update-page.ts` only ever writes a non-null `body_hash` on the same
|
|
28
|
+
* `writeHtml && html.length > 0` gate the live site's `precomputedBodyHash`
|
|
29
|
+
* computation uses, so this column faithfully reconstructs that
|
|
30
|
+
* condition. Also excludes pre-`body_hash`-migration legacy archives'
|
|
31
|
+
* un-backfilled rows (`migrate-page-meta-body-hash.ts` adds the column on
|
|
32
|
+
* every writer open but does not compute values for existing rows) —
|
|
33
|
+
* understating the replayed count is safe (never over-counts), so no
|
|
34
|
+
* column-presence guard is needed here.
|
|
35
|
+
*
|
|
36
|
+
* Deliberately does NOT exclude rows already marked
|
|
37
|
+
* `content_items.dedupe_cap_event_id` (the post-hoc marking column) —
|
|
38
|
+
* unlike `body_hash`, that column is only backfilled during a
|
|
39
|
+
* `viewer-build`, not written during a live crawl, so filtering on it here
|
|
40
|
+
* would silently diverge from what the live crawl-time gate actually saw.
|
|
41
|
+
* `DedupeCapTracker#observe` is an O(1) no-op for a shape already in its
|
|
42
|
+
* sticky set regardless, so replaying an already-capped shape's rows costs
|
|
43
|
+
* a discarded observation, never an incorrect one.
|
|
44
|
+
*
|
|
45
|
+
* Rows are read in `ci.id` order (insertion / discovery order) via
|
|
46
|
+
* `keysetPaginateById` — Misra-Gries counting is order-dependent, so
|
|
47
|
+
* replaying in the archive's original discovery order reproduces what the
|
|
48
|
+
* tracker's state would look like had the process never restarted, subject
|
|
49
|
+
* to the same `mapCap` LRU eviction.
|
|
50
|
+
* @param knex - Knex query builder connected to the archive DB.
|
|
51
|
+
* @param onProgress - Called after each chunk, with the highest `ci.id`
|
|
52
|
+
* scanned so far and the max `ci.id` in the table — mirrors
|
|
53
|
+
* `getResourceUrlList`'s progress shape (`SetupProgressCallbacks.onChunkProgress`).
|
|
54
|
+
* Omit for no reporting (tests, and callers that don't need it).
|
|
55
|
+
* @returns Every qualifying page's raw fields, in `ci.id` order.
|
|
56
|
+
*/
|
|
57
|
+
export declare function listDedupeCapObservations(knex: Knex, onProgress?: (scannedUpToId: number, maxId: number) => void): Promise<DedupeCapObservationRow[]>;
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { keysetPaginateById } from '../_shared/keyset-paginate-by-id.js';
|
|
2
|
+
const READ_CHUNK_SIZE = 2000;
|
|
3
|
+
/**
|
|
4
|
+
* Reads back every previously-scraped internal page's raw fields in the
|
|
5
|
+
* exact population a live crawl's `DedupeCapTracker#observe` call site
|
|
6
|
+
* would have fed it, so `CrawlerOrchestrator`'s five resuming-session
|
|
7
|
+
* methods (`append`/`inventory`/`recrawl`/`retryFailed`/`resume`) can
|
|
8
|
+
* replay a prior session's Misra-Gries observations into a fresh tracker
|
|
9
|
+
* instance (see `buildDedupeCapObservation`) instead of restarting every
|
|
10
|
+
* not-yet-capped shape's counter at 0.
|
|
11
|
+
*
|
|
12
|
+
* The WHERE clause reproduces the live observation gate
|
|
13
|
+
* (`!isExternal && !isMetadataOnly && html.length > 0`) using columns that
|
|
14
|
+
* survive a process restart:
|
|
15
|
+
*
|
|
16
|
+
* - `is_external = 0` — external pages carry no useful signal (same
|
|
17
|
+
* exclusion `computeMetaSignature`'s design already assumes).
|
|
18
|
+
* - `is_target = 1` — the archived equivalent of "not metadata-only": a
|
|
19
|
+
* metadata-only fetch never invokes the browser and is written with
|
|
20
|
+
* `is_target = 0` (see `Crawler`'s metadata-only branch), matching the
|
|
21
|
+
* live gate's `!isMetadataOnly` exactly.
|
|
22
|
+
* - `redirect_dest_id IS NULL` — a redirect source's own body was never
|
|
23
|
+
* rendered; only the destination page (a separate row) was.
|
|
24
|
+
* - `is_skipped IS NULL OR is_skipped = 0` — a skipped placeholder row
|
|
25
|
+
* (`source = 'inventory-seed'` etc.) has no rendered body either.
|
|
26
|
+
* - `pm.body_hash IS NOT NULL` — the archived proxy for `html.length > 0`:
|
|
27
|
+
* `update-page.ts` only ever writes a non-null `body_hash` on the same
|
|
28
|
+
* `writeHtml && html.length > 0` gate the live site's `precomputedBodyHash`
|
|
29
|
+
* computation uses, so this column faithfully reconstructs that
|
|
30
|
+
* condition. Also excludes pre-`body_hash`-migration legacy archives'
|
|
31
|
+
* un-backfilled rows (`migrate-page-meta-body-hash.ts` adds the column on
|
|
32
|
+
* every writer open but does not compute values for existing rows) —
|
|
33
|
+
* understating the replayed count is safe (never over-counts), so no
|
|
34
|
+
* column-presence guard is needed here.
|
|
35
|
+
*
|
|
36
|
+
* Deliberately does NOT exclude rows already marked
|
|
37
|
+
* `content_items.dedupe_cap_event_id` (the post-hoc marking column) —
|
|
38
|
+
* unlike `body_hash`, that column is only backfilled during a
|
|
39
|
+
* `viewer-build`, not written during a live crawl, so filtering on it here
|
|
40
|
+
* would silently diverge from what the live crawl-time gate actually saw.
|
|
41
|
+
* `DedupeCapTracker#observe` is an O(1) no-op for a shape already in its
|
|
42
|
+
* sticky set regardless, so replaying an already-capped shape's rows costs
|
|
43
|
+
* a discarded observation, never an incorrect one.
|
|
44
|
+
*
|
|
45
|
+
* Rows are read in `ci.id` order (insertion / discovery order) via
|
|
46
|
+
* `keysetPaginateById` — Misra-Gries counting is order-dependent, so
|
|
47
|
+
* replaying in the archive's original discovery order reproduces what the
|
|
48
|
+
* tracker's state would look like had the process never restarted, subject
|
|
49
|
+
* to the same `mapCap` LRU eviction.
|
|
50
|
+
* @param knex - Knex query builder connected to the archive DB.
|
|
51
|
+
* @param onProgress - Called after each chunk, with the highest `ci.id`
|
|
52
|
+
* scanned so far and the max `ci.id` in the table — mirrors
|
|
53
|
+
* `getResourceUrlList`'s progress shape (`SetupProgressCallbacks.onChunkProgress`).
|
|
54
|
+
* Omit for no reporting (tests, and callers that don't need it).
|
|
55
|
+
* @returns Every qualifying page's raw fields, in `ci.id` order.
|
|
56
|
+
*/
|
|
57
|
+
export async function listDedupeCapObservations(knex, onProgress) {
|
|
58
|
+
return keysetPaginateById(knex, 'content_items', (lastId) => knex('content_items as ci')
|
|
59
|
+
.join('url_refs as ur', 'ur.id', 'ci.url_id')
|
|
60
|
+
.join('page_meta as pm', 'pm.page_id', 'ci.id')
|
|
61
|
+
.leftJoin('text_refs as title_ref', 'title_ref.id', 'pm.title_text_id')
|
|
62
|
+
.leftJoin('text_refs as description_ref', 'description_ref.id', 'pm.description_text_id')
|
|
63
|
+
.leftJoin('text_refs as og_title_ref', 'og_title_ref.id', 'pm.og_title_text_id')
|
|
64
|
+
.leftJoin('url_refs as og_url_ur', 'og_url_ur.id', 'pm.og_url_id')
|
|
65
|
+
.where('ci.id', '>', lastId)
|
|
66
|
+
.andWhere('ci.scraped', 1)
|
|
67
|
+
.andWhere('ci.is_external', 0)
|
|
68
|
+
.andWhere('ci.is_target', 1)
|
|
69
|
+
.whereNull('ci.redirect_dest_id')
|
|
70
|
+
.where((qb) => {
|
|
71
|
+
qb.whereNull('ci.is_skipped').orWhere('ci.is_skipped', 0);
|
|
72
|
+
})
|
|
73
|
+
.whereNotNull('pm.body_hash')
|
|
74
|
+
.orderBy('ci.id', 'asc')
|
|
75
|
+
.limit(READ_CHUNK_SIZE)
|
|
76
|
+
.select('ci.id as id', 'ur.url as url', 'title_ref.text as title', 'description_ref.text as description', 'og_title_ref.text as ogTitle', 'og_url_ur.url as ogUrl', 'pm.body_hash as bodyHash'), (row) => ({
|
|
77
|
+
url: row.url,
|
|
78
|
+
title: row.title,
|
|
79
|
+
description: row.description,
|
|
80
|
+
ogTitle: row.ogTitle,
|
|
81
|
+
ogUrl: row.ogUrl,
|
|
82
|
+
// libsql returns BLOB columns as `Uint8Array`, not `Buffer` —
|
|
83
|
+
// `DedupeCapTracker#observe` compares hashes via `Buffer#equals`,
|
|
84
|
+
// so this must be a real `Buffer`.
|
|
85
|
+
bodyHash: Buffer.from(row.bodyHash),
|
|
86
|
+
}), onProgress);
|
|
87
|
+
}
|
|
@@ -18,7 +18,9 @@ import { resolveContentItemId } from '../_shared/resolve-content-item-id.js';
|
|
|
18
18
|
* @param isExternal - Whether the URL is external. Defaults to `false`.
|
|
19
19
|
*/
|
|
20
20
|
export async function insertPageError(knex, caches, url, phase, message, isExternal = false) {
|
|
21
|
-
const pageId = await resolveContentItemId(knex, caches, url,
|
|
21
|
+
const pageId = await resolveContentItemId(knex, caches, url, {
|
|
22
|
+
isExternal: isExternal ? 1 : 0,
|
|
23
|
+
});
|
|
22
24
|
await knex('page_errors').insert({
|
|
23
25
|
pageId,
|
|
24
26
|
phase,
|
|
@@ -2,8 +2,8 @@ import type { Knex } from 'knex';
|
|
|
2
2
|
/**
|
|
3
3
|
* Initializes the database schema if tables do not exist, then runs the
|
|
4
4
|
* remaining lightweight migrations (`info.roots`, `info.mainContentSelector`,
|
|
5
|
-
* `page_meta.main_content_*`, `
|
|
6
|
-
* `
|
|
5
|
+
* `page_meta.main_content_*`, `list_reconcile_runs.invalid_skipped`,
|
|
6
|
+
* `list_reconcile_runs.exclude_skipped`).
|
|
7
7
|
*
|
|
8
8
|
* There is deliberately no per-table *table-creation* migration chain here:
|
|
9
9
|
* `assertCompatibleVersion` (called below, before any schema work) rejects
|
|
@@ -18,9 +18,10 @@ import type { Knex } from 'knex';
|
|
|
18
18
|
* `CREATE TABLE IF NOT EXISTS` is a no-op once the table is present. Column
|
|
19
19
|
* additions to an existing 0.13 table are therefore the one case that still
|
|
20
20
|
* needs an explicit `hasColumn`-guarded `ALTER TABLE` here (`migrateInfoRoots`,
|
|
21
|
-
* `migrateMainContentsColumns`, `migratePageMetaBodyHash`,
|
|
21
|
+
* `migrateInfoCreatedCwd`, `migrateMainContentsColumns`, `migratePageMetaBodyHash`,
|
|
22
22
|
* `migratePageMetaConsoleErrorCount`, `migratePageMetaCustomElementCount`,
|
|
23
|
-
* `migrateContentItemsAliasOfId`,
|
|
23
|
+
* `migratePageMetaImageScan`, `migrateContentItemsAliasOfId`,
|
|
24
|
+
* `migrateContentItemsIsMetadataOnly`,
|
|
24
25
|
*
|
|
25
26
|
* `migratePageTagsToPageTechnologies` is the one exception to "column adds
|
|
26
27
|
* only": it converts `page_tags` (removed) rows into `technology_signals`/
|
|
@@ -28,8 +29,14 @@ import type { Knex } from 'knex';
|
|
|
28
29
|
* not a column add, but it belongs in this same boot phase for the same
|
|
29
30
|
* reason (self-healing an old archive's schema before any reader runs).
|
|
30
31
|
*
|
|
31
|
-
* `migrateContentItemsDedupeCapEventId`, `
|
|
32
|
-
* `
|
|
32
|
+
* `migrateContentItemsDedupeCapEventId`, `migrateListReconcileRunsInvalidSkipped`,
|
|
33
|
+
* `migrateListReconcileRunsExcludeSkipped`) rather than a DDL-string change alone.
|
|
34
|
+
*
|
|
35
|
+
* `migrateInventoryRunsToListReconcileRuns` runs BEFORE `initSchema`
|
|
36
|
+
* (unlike every other migration here) — see its own JSDoc for why: a
|
|
37
|
+
* plain rename must land before `createAdjunctTables`'s
|
|
38
|
+
* `IF NOT EXISTS` guard would otherwise create an empty `list_reconcile_runs`
|
|
39
|
+
* and strand the old table's rows.
|
|
33
40
|
*
|
|
34
41
|
* `closeStaleOpenNetworkOutages` is not a schema migration (no columns
|
|
35
42
|
* change) but belongs at this same boot phase for the same reason the
|