@gscdump/engine 1.4.11 → 1.5.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 (37) hide show
  1. package/dist/entities/empty-types.d.mts +22 -0
  2. package/dist/entities/empty-types.mjs +58 -0
  3. package/dist/entities/indexing-metadata.d.mts +26 -0
  4. package/dist/entities/indexing-metadata.mjs +31 -0
  5. package/dist/entities/inspection.d.mts +240 -0
  6. package/dist/entities/inspection.mjs +443 -0
  7. package/dist/entities/io.mjs +16 -0
  8. package/dist/{query-dim.d.mts → entities/query-dim.d.mts} +3 -3
  9. package/dist/{query-dim.mjs → entities/query-dim.mjs} +4 -4
  10. package/dist/{sitemap-projection.mjs → entities/sitemap-projection.mjs} +1 -1
  11. package/dist/entities/sitemap-shared.d.mts +199 -0
  12. package/dist/entities/sitemap-shared.mjs +243 -0
  13. package/dist/entities/sitemap-write.d.mts +3 -0
  14. package/dist/entities/sitemap-write.mjs +528 -0
  15. package/dist/entities/sitemap.d.mts +3 -0
  16. package/dist/entities/sitemap.mjs +91 -0
  17. package/dist/entities.d.mts +9 -481
  18. package/dist/entities.mjs +7 -1380
  19. package/dist/rollups/canonical.d.mts +71 -0
  20. package/dist/rollups/canonical.mjs +335 -0
  21. package/dist/rollups/core.d.mts +201 -0
  22. package/dist/rollups/core.mjs +116 -0
  23. package/dist/rollups/dates.mjs +11 -0
  24. package/dist/rollups/defaults.d.mts +11 -0
  25. package/dist/rollups/defaults.mjs +17 -0
  26. package/dist/rollups/hourly.d.mts +38 -0
  27. package/dist/rollups/hourly.mjs +38 -0
  28. package/dist/rollups/indexing.d.mts +46 -0
  29. package/dist/rollups/indexing.mjs +357 -0
  30. package/dist/rollups/traffic.d.mts +38 -0
  31. package/dist/rollups/traffic.mjs +289 -0
  32. package/dist/rollups/windows.d.mts +90 -0
  33. package/dist/rollups/windows.mjs +176 -0
  34. package/dist/rollups.d.mts +8 -471
  35. package/dist/rollups.mjs +7 -1316
  36. package/package.json +4 -4
  37. /package/dist/{sitemap-projection.d.mts → entities/sitemap-projection.d.mts} +0 -0
@@ -0,0 +1,22 @@
1
+ import { DataSource } from "../storage.mjs";
2
+ import { TenantCtx } from "@gscdump/contracts";
3
+ interface EmptyTypesDoc {
4
+ version: 1;
5
+ /** SearchType strings detected as empty for this (user, site). */
6
+ emptyTypes: string[];
7
+ /** When each type was last marked empty (unix ms). Helps debug stale skips. */
8
+ markedAt: Record<string, number>;
9
+ }
10
+ interface EmptyTypesStore {
11
+ load: (ctx: TenantCtx) => Promise<EmptyTypesDoc>;
12
+ /** Add types to the empty set, preserving existing markers. No-op if all already present. */
13
+ mark: (ctx: TenantCtx, types: readonly string[], now?: number) => Promise<EmptyTypesDoc>;
14
+ /** Remove types from the empty set. Returns the updated doc. */
15
+ clear: (ctx: TenantCtx, types: readonly string[]) => Promise<EmptyTypesDoc>;
16
+ }
17
+ interface CreateEmptyTypesStoreOptions {
18
+ dataSource: DataSource;
19
+ now?: () => number;
20
+ }
21
+ declare function createEmptyTypesStore(opts: CreateEmptyTypesStoreOptions): EmptyTypesStore;
22
+ export { CreateEmptyTypesStoreOptions, EmptyTypesDoc, EmptyTypesStore, createEmptyTypesStore };
@@ -0,0 +1,58 @@
1
+ import { emptyTypesKey } from "../entity-keys.mjs";
2
+ import { readOptional } from "../adapters/read-optional.mjs";
3
+ import { encodeJsonBigintSafe } from "@gscdump/lakehouse/bigint";
4
+ function createEmptyTypesStore(opts) {
5
+ const ds = opts.dataSource;
6
+ const now = opts.now ?? (() => Date.now());
7
+ async function readDoc(key) {
8
+ const bytes = await readOptional(ds, key);
9
+ if (bytes === void 0) return {
10
+ version: 1,
11
+ emptyTypes: [],
12
+ markedAt: {}
13
+ };
14
+ return JSON.parse(new TextDecoder().decode(bytes));
15
+ }
16
+ async function writeDoc(key, doc) {
17
+ await ds.write(key, encodeJsonBigintSafe(doc));
18
+ }
19
+ return {
20
+ async load(ctx) {
21
+ return readDoc(emptyTypesKey(ctx));
22
+ },
23
+ async mark(ctx, types, at) {
24
+ if (types.length === 0) return readDoc(emptyTypesKey(ctx));
25
+ const key = emptyTypesKey(ctx);
26
+ const doc = await readDoc(key);
27
+ const stamp = at ?? now();
28
+ let changed = false;
29
+ for (const t of types) {
30
+ if (!doc.emptyTypes.includes(t)) {
31
+ doc.emptyTypes.push(t);
32
+ changed = true;
33
+ }
34
+ if (doc.markedAt[t] === void 0) {
35
+ doc.markedAt[t] = stamp;
36
+ changed = true;
37
+ }
38
+ }
39
+ if (changed) {
40
+ doc.emptyTypes.sort();
41
+ await writeDoc(key, doc);
42
+ }
43
+ return doc;
44
+ },
45
+ async clear(ctx, types) {
46
+ if (types.length === 0) return readDoc(emptyTypesKey(ctx));
47
+ const key = emptyTypesKey(ctx);
48
+ const doc = await readDoc(key);
49
+ const drop = new Set(types);
50
+ const before = doc.emptyTypes.length;
51
+ doc.emptyTypes = doc.emptyTypes.filter((t) => !drop.has(t));
52
+ for (const t of drop) delete doc.markedAt[t];
53
+ if (doc.emptyTypes.length !== before) await writeDoc(key, doc);
54
+ return doc;
55
+ }
56
+ };
57
+ }
58
+ export { createEmptyTypesStore };
@@ -0,0 +1,26 @@
1
+ import { DataSource } from "../storage.mjs";
2
+ import { TenantCtx } from "@gscdump/contracts";
3
+ interface IndexingMetadataRecord {
4
+ url: string;
5
+ capturedAt: string;
6
+ /** ISO-8601 notifyTime of the latest `URL_UPDATED` notification we've seen. */
7
+ latestUpdateAt?: string;
8
+ /** ISO-8601 notifyTime of the latest `URL_REMOVED` notification we've seen. */
9
+ latestRemoveAt?: string;
10
+ raw?: unknown;
11
+ }
12
+ interface IndexingMetadataIndex {
13
+ version: 1;
14
+ records: Record<string, IndexingMetadataRecord>;
15
+ }
16
+ interface IndexingMetadataStore {
17
+ writeBatch: (ctx: TenantCtx, records: readonly IndexingMetadataRecord[]) => Promise<void>;
18
+ loadIndex: (ctx: TenantCtx) => Promise<IndexingMetadataIndex>;
19
+ getLatest: (ctx: TenantCtx, url: string) => Promise<IndexingMetadataRecord | undefined>;
20
+ }
21
+ interface CreateIndexingMetadataStoreOptions {
22
+ dataSource: DataSource;
23
+ hash?: (url: string) => string;
24
+ }
25
+ declare function createIndexingMetadataStore(opts: CreateIndexingMetadataStoreOptions): IndexingMetadataStore;
26
+ export { CreateIndexingMetadataStoreOptions, IndexingMetadataIndex, IndexingMetadataRecord, IndexingMetadataStore, createIndexingMetadataStore };
@@ -0,0 +1,31 @@
1
+ import { hashUrl, indexingMetadataIndexKey } from "../entity-keys.mjs";
2
+ import { readOptional } from "../adapters/read-optional.mjs";
3
+ import { encodeJsonBigintSafe } from "@gscdump/lakehouse/bigint";
4
+ function createIndexingMetadataStore(opts) {
5
+ const ds = opts.dataSource;
6
+ const hash = opts.hash ?? hashUrl;
7
+ async function readIndex(key) {
8
+ const bytes = await readOptional(ds, key);
9
+ if (bytes === void 0) return {
10
+ version: 1,
11
+ records: {}
12
+ };
13
+ return JSON.parse(new TextDecoder().decode(bytes));
14
+ }
15
+ return {
16
+ async writeBatch(ctx, records) {
17
+ if (records.length === 0) return;
18
+ const key = indexingMetadataIndexKey(ctx);
19
+ const index = await readIndex(key);
20
+ for (const r of records) index.records[hash(r.url)] = r;
21
+ await ds.write(key, encodeJsonBigintSafe(index));
22
+ },
23
+ async loadIndex(ctx) {
24
+ return readIndex(indexingMetadataIndexKey(ctx));
25
+ },
26
+ async getLatest(ctx, url) {
27
+ return (await readIndex(indexingMetadataIndexKey(ctx))).records[hash(url)];
28
+ }
29
+ };
30
+ }
31
+ export { createIndexingMetadataStore };
@@ -0,0 +1,240 @@
1
+ import { DataSource } from "../storage.mjs";
2
+ import { ScheduleState } from "../schedule.mjs";
3
+ import { TenantCtx } from "@gscdump/contracts";
4
+ import { CanonicalDifferenceKind } from "gscdump";
5
+ /**
6
+ * GSC URL inspection result fields we persist. Mirrors the
7
+ * `searchconsole_v1.Schema$UrlInspectionResult` shape but as plain JSON
8
+ * so storage doesn't depend on the googleapis type tree.
9
+ */
10
+ interface InspectionRecord {
11
+ url: string;
12
+ /** ISO-8601 timestamp of when we ran the inspection. */
13
+ inspectedAt: string;
14
+ /** PASS / NEUTRAL / FAIL — the headline verdict from indexStatusResult. */
15
+ indexStatus?: string;
16
+ /** Last-crawl timestamp the API reports (ISO-8601). */
17
+ lastCrawlTime?: string;
18
+ /** Canonical URL Google selected. */
19
+ googleCanonical?: string;
20
+ /** Canonical URL the page declares. */
21
+ userCanonical?: string;
22
+ /** Crawl/index/serving disposition strings as the API returns them. */
23
+ coverageState?: string;
24
+ robotsTxtState?: string;
25
+ indexingState?: string;
26
+ pageFetchState?: string;
27
+ mobileUsabilityVerdict?: string;
28
+ richResultsVerdict?: string;
29
+ /**
30
+ * Free-form payload for fields we don't promote to first-class columns
31
+ * (e.g. `referringUrls`, `crawledAs`). Keeps the wire format forward-compat
32
+ * without bumping the schema for every API addition.
33
+ *
34
+ * Recognised keys:
35
+ * - `schedule`: optional `ScheduleState` from {@link inspectionPolicy}
36
+ * governing when this URL is next due for re-inspection. Undefined on
37
+ * pre-§0 records — readers must tolerate the missing field and fall
38
+ * back to default policy on first observe.
39
+ */
40
+ raw?: {
41
+ schedule?: ScheduleState;
42
+ [key: string]: unknown;
43
+ };
44
+ }
45
+ /** Wire shape persisted to disk/R2. */
46
+ interface InspectionIndex {
47
+ version: 1;
48
+ /** Map of urlHash → InspectionRecord (latest only). */
49
+ records: Record<string, InspectionRecord>;
50
+ }
51
+ /**
52
+ * Append-only history shard, one blob per `appendHistory` call.
53
+ * Keyed by UUID under the month directory — retries write a new blob,
54
+ * never RMW an existing one. Idempotent under job retries.
55
+ */
56
+ interface InspectionHistoryShard {
57
+ version: 1;
58
+ /** Records persisted in this batch. */
59
+ records: InspectionRecord[];
60
+ }
61
+ /**
62
+ * Directory prefix for a month's history shards. Each shard is a UUID-keyed
63
+ * blob under this prefix; `appendHistory` writes one per call, `loadHistory`
64
+ * lists + concatenates.
65
+ */
66
+ /**
67
+ * Row shape for the inspections parquet sidecar. Caller-side schema for
68
+ * `materialize` — D1 is the source of truth in the 2026-05-19 redesign, so
69
+ * consumers stream rows from `url_indexing_status` and pass them in. The
70
+ * parquet sidecar exists for DuckDB JOIN seams; readers go through
71
+ * `parquetUri`.
72
+ */
73
+ interface InspectionParquetRow {
74
+ [column: string]: string | number | null;
75
+ urlHash: string;
76
+ url: string;
77
+ inspectedAt: string;
78
+ indexStatus: string | null;
79
+ lastCrawlTime: string | null;
80
+ googleCanonical: string | null;
81
+ userCanonical: string | null;
82
+ coverageState: string | null;
83
+ robotsTxtState: string | null;
84
+ indexingState: string | null;
85
+ pageFetchState: string | null;
86
+ mobileUsabilityVerdict: string | null;
87
+ richResultsVerdict: string | null;
88
+ scheduleNextAt: number | null;
89
+ scheduleConsecutiveUnchanged: number | null;
90
+ schedulePolicyVersion: number | null;
91
+ }
92
+ /**
93
+ * Row shape for the append-only inspection-event store. Superset of
94
+ * {@link InspectionParquetRow}: carries the full-fidelity columns the lossy
95
+ * `materialize` parquet dropped (`crawlingUserAgent`, `richResultsItems`,
96
+ * `sitemaps`, `referringUrls`, `mobileIssues`, `inspectionResultLink`,
97
+ * `firstCheckedAt`, `checkCount`). Object/array fields are persisted as JSON
98
+ * strings — read paths unpack them with DuckDB's JSON functions.
99
+ *
100
+ * `firstCheckedAt` / `checkCount` are caller-managed: the writer carries the
101
+ * earliest-seen timestamp + running observation count forward. Compaction
102
+ * preserves the EARLIEST `firstCheckedAt` per url (mirrors the sitemap store's
103
+ * `firstSeenAt` preservation); every other column is taken from the
104
+ * newest-by-`inspectedAt` event.
105
+ */
106
+ interface InspectionEventRow extends InspectionParquetRow {
107
+ /** Declared-vs-selected canonical classification, computed at inspection ingest. */
108
+ canonicalMismatchKind: CanonicalDifferenceKind;
109
+ crawlingUserAgent: string | null;
110
+ /** JSON-encoded `RichResultsItem[]`. */
111
+ richResultsItems: string | null;
112
+ /** JSON-encoded list of sitemap URLs referencing this page. */
113
+ sitemaps: string | null;
114
+ /** JSON-encoded list of referring URLs. */
115
+ referringUrls: string | null;
116
+ /** JSON-encoded mobile-usability issues. */
117
+ mobileIssues: string | null;
118
+ inspectionResultLink: string | null;
119
+ /** ISO-8601 timestamp of the first inspection we ever recorded for this url. */
120
+ firstCheckedAt: string | null;
121
+ /** Total number of inspections recorded for this url. */
122
+ checkCount: number | null;
123
+ /**
124
+ * Stored next-recheck unix-seconds + priority as computed AT INSPECT TIME.
125
+ * Carried verbatim (NOT recomputed at read) because the scheduling policy can
126
+ * change over time — `__gsc/inspections` must replay the historical value to
127
+ * keep its frozen wire shape byte-stable.
128
+ */
129
+ nextCheckAfter: number | null;
130
+ nextCheckPriority: string | null;
131
+ }
132
+ /**
133
+ * Hard cap on a single `appendHistory` shard payload. Encoded bytes >
134
+ * this threshold throws — the caller logs and moves on (D1 is
135
+ * authoritative, R2 history is a sidecar). At `URLS_PER_JOB=3` a real
136
+ * batch encodes to ~10 KB so the cap is purely defensive against future
137
+ * batch-size bumps.
138
+ */
139
+ declare const INSPECTION_HISTORY_MAX_BYTES: number;
140
+ interface InspectionStore {
141
+ /**
142
+ * Append a batch of fresh inspection results as an immutable per-batch
143
+ * shard under `history/<YYYY-MM>/<batchId>.json`. Idempotent under job
144
+ * retry (caller-supplied UUID per logical batch), no read-before-write,
145
+ * one PUT per month-group within the batch.
146
+ *
147
+ * Throws if the encoded payload exceeds {@link INSPECTION_HISTORY_MAX_BYTES}.
148
+ */
149
+ appendHistory: (ctx: TenantCtx, records: readonly InspectionRecord[], opts?: {
150
+ batchId?: string;
151
+ }) => Promise<void>;
152
+ /**
153
+ * Read every shard in a month directory and concatenate. Best-effort:
154
+ * shards that fail to decode are skipped (logged via console). Returns
155
+ * `undefined` if the month has no shards.
156
+ */
157
+ loadHistory: (ctx: TenantCtx, yearMonth: string) => Promise<InspectionHistoryShard | undefined>;
158
+ /**
159
+ * Encode caller-provided rows into the inspections parquet sidecar at
160
+ * `entities/inspections/index.parquet`. Sorted by `urlHash` so DuckDB
161
+ * row-group stats can prune URL-keyed JOINs efficiently. One PUT.
162
+ *
163
+ * D1 is the source of truth in the 2026-05-19 redesign; this rebuilds
164
+ * the parquet from D1 rows the caller streams in (engine has no D1
165
+ * access). Triggered by `indexing/complete` post-hook.
166
+ *
167
+ * Returns the parquet object key (matches {@link parquetUri} after write).
168
+ */
169
+ materialize: (ctx: TenantCtx, rows: Iterable<InspectionParquetRow>) => Promise<{
170
+ key: string;
171
+ rowCount: number;
172
+ bytes: number;
173
+ }>;
174
+ /**
175
+ * Append a batch of inspection results as an immutable per-batch parquet
176
+ * under `events/<YYYY-MM>/<batchId>.parquet`, partitioned by the `YYYY-MM`
177
+ * of each row's `inspectedAt` (a batch spanning a month boundary writes one
178
+ * file per month). No read-before-write; idempotent under job retry (same
179
+ * `batchId` → same key → whole-file overwrite). Rows carry the FULL column
180
+ * set ({@link INSPECTION_EVENT_COLUMNS}); this is the append-only
181
+ * source-of-truth that supersedes {@link InspectionStore.materialize}.
182
+ *
183
+ * Returns the keys written + total row count. Empty input is a no-op.
184
+ */
185
+ appendInspectionEvents: (ctx: TenantCtx, rows: readonly InspectionEventRow[], opts?: {
186
+ batchId?: string;
187
+ }) => Promise<{
188
+ keys: string[];
189
+ rowCount: number;
190
+ }>;
191
+ /**
192
+ * Fold every outstanding event file into the `base.parquet`: latest-per-url
193
+ * by max `inspectedAt` (newest-wins), preserving the earliest non-null
194
+ * `firstCheckedAt` per url. Writes the new base then deletes the consumed
195
+ * event files — file-level only, never row-level (ADR-0002). Idempotent +
196
+ * re-runnable: a crash after the base write but before the delete just
197
+ * re-folds the same events (newest-wins makes that a no-op). A real read
198
+ * failure on the existing base propagates rather than rebuilding from events
199
+ * alone (which would drop URLs only the base held).
200
+ *
201
+ * No-op (no base rewrite) when there are zero outstanding events.
202
+ */
203
+ compactInspections: (ctx: TenantCtx, opts?: {
204
+ /**
205
+ * Also record state changes into the durable transitions sidecar.
206
+ * Default OFF so publishing this is inert until a canary opts in.
207
+ */
208
+ transitions?: boolean;
209
+ }) => Promise<{
210
+ baseRowCount: number;
211
+ eventsFolded: number;
212
+ eventFilesDeleted: number;
213
+ transitionsWritten: number;
214
+ }>;
215
+ /**
216
+ * Rewrite a legacy latest-only base with the canonical kind derived from the
217
+ * canonical pair it already retains. Outstanding events must be compacted first.
218
+ */
219
+ backfillCanonicalMismatchKinds: (ctx: TenantCtx) => Promise<{
220
+ baseRowCount: number;
221
+ rowsBackfilled: number;
222
+ rewritten: boolean;
223
+ }>;
224
+ /**
225
+ * DuckDB-resolvable URI for the materialised parquet sidecar, or
226
+ * `undefined` if the underlying `DataSource` has no native URI shape
227
+ * (in-memory tests). When defined, read paths can `read_parquet(<uri>)`
228
+ * directly without staging bytes through JS.
229
+ *
230
+ * Does not check existence — caller is responsible for ensuring
231
+ * `materialize` has run at least once. Returning a URI for a missing key
232
+ * is safe; DuckDB will surface a 404 / not-found at query time.
233
+ */
234
+ parquetUri: (ctx: TenantCtx) => string | undefined;
235
+ }
236
+ interface CreateInspectionStoreOptions {
237
+ dataSource: DataSource;
238
+ }
239
+ declare function createInspectionStore(opts: CreateInspectionStoreOptions): InspectionStore;
240
+ export { CreateInspectionStoreOptions, INSPECTION_HISTORY_MAX_BYTES, InspectionEventRow, InspectionHistoryShard, InspectionIndex, InspectionParquetRow, InspectionRecord, InspectionStore, createInspectionStore };