@gscdump/cli 3.8.0 → 4.0.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 (73) hide show
  1. package/README.md +113 -30
  2. package/bin/gscdump.mjs +21 -1
  3. package/dist/analysis-local.mjs +172 -53
  4. package/dist/auth-state.mjs +21 -6
  5. package/dist/auth.mjs +27 -18
  6. package/dist/bing-data.mjs +6 -2
  7. package/dist/bing-hosted.mjs +1 -1
  8. package/dist/cli-args.mjs +8 -0
  9. package/dist/cli.d.mts +5 -1
  10. package/dist/cli.mjs +41 -10
  11. package/dist/cloud-google.mjs +8 -1
  12. package/dist/command-meta.mjs +4 -4
  13. package/dist/command-registry.mjs +86 -10
  14. package/dist/commands/analyze.mjs +84 -43
  15. package/dist/commands/auth.mjs +46 -37
  16. package/dist/commands/bing.mjs +3 -3
  17. package/dist/commands/compact.mjs +12 -8
  18. package/dist/commands/config.mjs +14 -14
  19. package/dist/commands/doctor.mjs +26 -26
  20. package/dist/commands/dump.mjs +203 -105
  21. package/dist/commands/entities.mjs +10 -113
  22. package/dist/commands/gc.mjs +4 -3
  23. package/dist/commands/indexing-urls.mjs +185 -0
  24. package/dist/commands/indexing.mjs +12 -8
  25. package/dist/commands/init.mjs +77 -18
  26. package/dist/commands/inspect.mjs +97 -110
  27. package/dist/commands/mcp.mjs +37 -57
  28. package/dist/commands/papercut.mjs +1 -1
  29. package/dist/commands/profile.mjs +9 -17
  30. package/dist/commands/query.mjs +219 -210
  31. package/dist/commands/report.mjs +68 -85
  32. package/dist/commands/rollups.mjs +9 -6
  33. package/dist/commands/sitemaps.mjs +51 -104
  34. package/dist/commands/sites.mjs +40 -24
  35. package/dist/commands/stats.mjs +14 -17
  36. package/dist/commands/store-purge.mjs +9 -5
  37. package/dist/commands/store.mjs +0 -12
  38. package/dist/commands/sync.mjs +774 -296
  39. package/dist/config.mjs +7 -4
  40. package/dist/context.mjs +51 -21
  41. package/dist/coverage.mjs +251 -0
  42. package/dist/dump-bing.mjs +71 -0
  43. package/dist/dump-writers.mjs +246 -0
  44. package/dist/error-handler.mjs +108 -38
  45. package/dist/filters.mjs +125 -0
  46. package/dist/hosted-site.mjs +91 -0
  47. package/dist/inspect-urls.mjs +76 -0
  48. package/dist/inspection-record.mjs +93 -0
  49. package/dist/local-entities.mjs +658 -0
  50. package/dist/mcp/errors.mjs +71 -18
  51. package/dist/mcp/handlers/diagnostics.mjs +15 -12
  52. package/dist/mcp/handlers/reports.mjs +33 -42
  53. package/dist/mcp/server/index.mjs +80 -38
  54. package/dist/mcp/types.mjs +7 -7
  55. package/dist/package.mjs +1 -1
  56. package/dist/quota-ledger.mjs +370 -0
  57. package/dist/render/analysis.mjs +8 -1
  58. package/dist/render/report.mjs +8 -1
  59. package/dist/request-pacer.mjs +29 -0
  60. package/dist/route.mjs +295 -0
  61. package/dist/sitemap.mjs +4 -0
  62. package/dist/sql-views.mjs +117 -0
  63. package/dist/store-sites.mjs +134 -0
  64. package/dist/sync-plan.mjs +129 -0
  65. package/dist/sync-run.mjs +96 -0
  66. package/dist/table-sources.mjs +57 -0
  67. package/dist/token-info.mjs +54 -0
  68. package/dist/utils.mjs +28 -19
  69. package/dist/window.mjs +159 -0
  70. package/package.json +17 -14
  71. package/skills/gscdump/SKILL.md +167 -37
  72. package/dist/commands/export.mjs +0 -76
  73. package/dist/native-duckdb.mjs +0 -46
@@ -1,24 +1,45 @@
1
1
  import { syncCommandMeta } from "../command-meta.mjs";
2
- import { OUTPUT_ARGS, applyOutputMode, clearLine, displayPath, formatAge, logger, parseIntegerOption, progressBar, runWithConcurrency } from "../utils.mjs";
2
+ import { QUOTA_CAPS, googleErrorMessage, openQuotaLedger, parseQuotaRefusal } from "../quota-ledger.mjs";
3
+ import { OUTPUT_ARGS, applyOutputMode, clearLine, displayPath, formatAge, logger, parseIntegerOption, parseNameList, progressBar, runWithConcurrency } from "../utils.mjs";
3
4
  import { TABLE_DIMS, allTables, assembleDatesRow, createLocalStore } from "../local-store.mjs";
4
- import { createCommandContext } from "../context.mjs";
5
+ import { formatSiteIdCollision, readSiteMap, recordStoreSite, siteUrlForId } from "../store-sites.mjs";
6
+ import { createCommandContext, formatSiteResolution } from "../context.mjs";
7
+ import { INSPECTION_QPD_PER_PROPERTY } from "../inspection-record.mjs";
8
+ import { inspectionCandidates, syncInspections, syncSitemaps } from "../local-entities.mjs";
9
+ import { minimumCallsPerDate, planJobDates, planSyncJobs, resolveWindow } from "../sync-plan.mjs";
10
+ import { analyticsCoverage, readStoreCoverage, renderCoverage } from "../coverage.mjs";
11
+ import { isProcessAlive, readSyncRun, startSyncRun, syncRunStatus } from "../sync-run.mjs";
12
+ import { loadSitemapUrls } from "../sitemap.mjs";
13
+ import { createRequestPacer } from "../request-pacer.mjs";
5
14
  import process from "node:process";
6
15
  import { defineCommand } from "citty";
16
+ import { randomUUID } from "node:crypto";
7
17
  import { SearchTypes } from "gscdump/query";
18
+ import { getLatestGscDate, getOldestGscDate, getPstDate, groupIntoRanges } from "gscdump/dates";
19
+ import { runGscSearchAppearanceContextSlice, runGscSyncSlice } from "@gscdump/engine-gsc-api";
20
+ import { resolveSiteInput } from "gscdump";
8
21
  import { createRowAccumulator } from "@gscdump/engine/ingest";
9
22
  import { createEmptyTypesStore } from "@gscdump/engine/entities";
10
- import { daysAgoUtc, getDateRange } from "gscdump/dates";
11
23
  import { DEFAULT_ROLLUPS, rebuildRollups } from "@gscdump/engine/rollups";
12
- const DEFAULT_TABLES = [
13
- "pages",
14
- "queries",
15
- "countries",
16
- "dates"
17
- ];
18
- const DEFAULT_TYPES = ["web"];
19
24
  const ALL_SEARCH_TYPES = Object.values(SearchTypes);
20
- const DEFAULT_PENDING_DAYS = 3;
25
+ const DEFAULT_TABLES = allTables();
26
+ const DEFAULT_TYPES = ALL_SEARCH_TYPES;
27
+ const GSC_ROW_LIMIT = 25e3;
28
+ const MAX_SLICE_ROWS = 5e6;
29
+ const SLICE_TABLES = [
30
+ "search_appearance",
31
+ "search_appearance_pages",
32
+ "search_appearance_queries",
33
+ "search_appearance_page_queries",
34
+ "hourly_pages"
35
+ ];
36
+ function isSliceTable(table) {
37
+ return SLICE_TABLES.includes(table);
38
+ }
21
39
  const DEFAULT_CONCURRENCY = 8;
40
+ const DEFAULT_MAX_IN_FLIGHT = 8;
41
+ const DEFAULT_REQUESTS_PER_MINUTE = 600;
42
+ const INSPECT_CONCURRENCY = 4;
22
43
  const EMPTY_TYPE_PROBE_MIN_DAYS = 7;
23
44
  const EMPTY_TYPE_PROTECTED = ["web"];
24
45
  function createProgressTracker(total, quiet) {
@@ -48,25 +69,33 @@ function createProgressTracker(total, quiet) {
48
69
  }
49
70
  };
50
71
  }
51
- async function syncTable(store, siteUrl, table, searchType, dates, client, concurrency, force, progress) {
72
+ const SYNC_STOP = Symbol("syncStop");
73
+ function stopError(stop) {
74
+ return Object.assign(new Error(describeStop(stop)), { [SYNC_STOP]: stop });
75
+ }
76
+ function stopOf(error) {
77
+ return error?.[SYNC_STOP];
78
+ }
79
+ function describeStop(stop) {
80
+ if (stop.kind === "budget") return `Reached --max-calls ${stop.maxCalls}.`;
81
+ return `Google quota: ${stop.reason}`;
82
+ }
83
+ async function syncTable(opts) {
84
+ const { store, siteUrl, job, control, progress } = opts;
85
+ const { table, type: searchType } = job;
52
86
  const dims = TABLE_DIMS[table];
53
87
  const siteId = store.siteIdFor(siteUrl);
54
- let totalRows = 0;
55
- let skipped = 0;
56
- let failed = 0;
57
- const priorStates = await store.engine.getSyncStates({
58
- userId: store.userId,
59
- siteId,
60
- table,
61
- searchType
62
- });
63
- const stateByDate = new Map(priorStates.map((s) => [s.date, s]));
64
- const label = searchType === "web" ? table : `${table}/${searchType}`;
65
- await runWithConcurrency(dates, concurrency, async (date) => {
66
- const prior = stateByDate.get(date);
67
- if (!force && prior?.state === "done") {
68
- skipped++;
69
- progress.tick(`${label} ${date} (skip)`);
88
+ const totals = {
89
+ rows: 0,
90
+ skipped: opts.skipped,
91
+ failed: 0,
92
+ pending: 0,
93
+ fetched: 0
94
+ };
95
+ await runWithConcurrency(opts.dates, opts.concurrency, async (date) => {
96
+ if (control.stop) {
97
+ totals.pending++;
98
+ progress.tick(`${job.label} ${date} (later)`);
70
99
  return;
71
100
  }
72
101
  const scope = {
@@ -77,28 +106,42 @@ async function syncTable(store, siteUrl, table, searchType, dates, client, concu
77
106
  searchType
78
107
  };
79
108
  await store.engine.setSyncState(scope, "inflight");
80
- const result = await runOneDate(store, client, siteUrl, table, searchType, dims, date).catch((err) => ({
109
+ const result = await runOneDate(store, opts.client, siteUrl, table, searchType, dims, date, opts.latest).catch((err) => ({
81
110
  kind: "error",
82
111
  error: err
83
112
  }));
84
113
  if (result.kind === "error") {
85
- await store.engine.setSyncState(scope, "failed", { error: result.error.message });
86
- failed++;
87
- progress.tick(`${label} ${date} (fail)`);
114
+ const stop = stopOf(result.error);
115
+ if (stop) {
116
+ control.stop ??= stop;
117
+ await store.engine.setSyncState(scope, "pending", { error: describeStop(stop) });
118
+ totals.pending++;
119
+ progress.tick(`${job.label} ${date} (later)`);
120
+ return;
121
+ }
122
+ await store.engine.setSyncState(scope, "failed", { error: googleErrorMessage(result.error) });
123
+ totals.failed++;
124
+ progress.tick(`${job.label} ${date} (fail)`);
125
+ opts.run.tick();
88
126
  return;
89
127
  }
90
- await store.engine.setSyncState(scope, "done");
91
- totalRows += result.rows;
92
- progress.tick(`${label} ${date}`);
128
+ await store.engine.setSyncState(scope, result.final ? "done" : "pending");
129
+ totals.rows += result.rows;
130
+ totals.fetched++;
131
+ if (!result.final) totals.pending++;
132
+ progress.tick(`${job.label} ${date}`);
133
+ opts.run.tick();
93
134
  });
94
- return {
95
- rows: totalRows,
96
- skipped,
97
- failed
98
- };
135
+ return totals;
136
+ }
137
+ function isFinalDate(date, latest, metadata) {
138
+ if (date > latest) return false;
139
+ const incompleteDate = metadata?.first_incomplete_date;
140
+ if (incompleteDate && incompleteDate <= date) return false;
141
+ const incompleteHour = metadata?.first_incomplete_hour;
142
+ return !(incompleteHour && incompleteHour.slice(0, 10) <= date);
99
143
  }
100
144
  async function fetchDateRows(client, siteUrl, searchType, dims, date) {
101
- const rowLimit = 25e3;
102
145
  const rows = [];
103
146
  let startRow = 0;
104
147
  while (true) {
@@ -107,7 +150,7 @@ async function fetchDateRows(client, siteUrl, searchType, dims, date) {
107
150
  endDate: date,
108
151
  dimensions: dims,
109
152
  searchType,
110
- rowLimit,
153
+ rowLimit: GSC_ROW_LIMIT,
111
154
  startRow
112
155
  })).rows || [];
113
156
  for (const apiRow of batch) rows.push({
@@ -117,12 +160,13 @@ async function fetchDateRows(client, siteUrl, searchType, dims, date) {
117
160
  ctr: apiRow.ctr ?? 0,
118
161
  position: apiRow.position ?? 0
119
162
  });
120
- if (batch.length === 0) break;
163
+ if (batch.length < GSC_ROW_LIMIT) break;
121
164
  startRow += batch.length;
122
165
  }
123
166
  return rows;
124
167
  }
125
- async function runOneDate(store, client, siteUrl, table, searchType, dims, date) {
168
+ async function runOneDate(store, client, siteUrl, table, searchType, dims, date, latest) {
169
+ if (isSliceTable(table)) return runSliceDate(store, client, siteUrl, table, searchType, date, latest);
126
170
  const apiRows = await fetchDateRows(client, siteUrl, searchType, dims, date);
127
171
  let rows = [];
128
172
  if (table === "dates") {
@@ -137,6 +181,63 @@ async function runOneDate(store, client, siteUrl, table, searchType, dims, date)
137
181
  accumulator.push(table, apiRows);
138
182
  rows = accumulator.drain().get(table)?.get(date) ?? [];
139
183
  }
184
+ return writeDayRows(store, siteUrl, table, searchType, date, rows, isFinalDate(date, latest, void 0));
185
+ }
186
+ async function runSliceDate(store, client, siteUrl, table, searchType, date, latest) {
187
+ const base = {
188
+ client,
189
+ siteUrl,
190
+ startDate: date,
191
+ endDate: date,
192
+ searchType,
193
+ rowLimit: GSC_ROW_LIMIT,
194
+ cpuBudgetMs: Infinity
195
+ };
196
+ const drainDay = (accumulator) => {
197
+ if (accumulator.overflowed) throw new Error(`${table} ${date}: more rows than one sync can hold`);
198
+ return accumulator.drain().get(table)?.get(date) ?? [];
199
+ };
200
+ let rows;
201
+ let metadata;
202
+ if (table === "search_appearance" || table === "hourly_pages") {
203
+ const accumulator = createRowAccumulator({
204
+ maxRows: MAX_SLICE_ROWS,
205
+ date
206
+ });
207
+ const result = await runGscSyncSlice({
208
+ ...base,
209
+ table,
210
+ onBatch: async (batch) => {
211
+ accumulator.push(table, batch);
212
+ }
213
+ });
214
+ if (result.hasMore) throw new Error(`${table} ${date}: Google stopped before the last page`);
215
+ metadata = result.metadata;
216
+ rows = drainDay(accumulator);
217
+ } else {
218
+ const byAppearance = /* @__PURE__ */ new Map();
219
+ const result = await runGscSearchAppearanceContextSlice({
220
+ ...base,
221
+ table,
222
+ onContextBatch: async ({ searchAppearance, rows: batch }) => {
223
+ let accumulator = byAppearance.get(searchAppearance);
224
+ if (!accumulator) {
225
+ accumulator = createRowAccumulator({
226
+ maxRows: MAX_SLICE_ROWS,
227
+ searchAppearance
228
+ });
229
+ byAppearance.set(searchAppearance, accumulator);
230
+ }
231
+ accumulator.push(table, batch);
232
+ }
233
+ });
234
+ if (result.hasMore) throw new Error(`${table} ${date}: Google stopped before the last page`);
235
+ metadata = result.metadata;
236
+ rows = [...byAppearance.values()].flatMap(drainDay);
237
+ }
238
+ return writeDayRows(store, siteUrl, table, searchType, date, rows, isFinalDate(date, latest, metadata));
239
+ }
240
+ async function writeDayRows(store, siteUrl, table, searchType, date, rows, final) {
140
241
  const writeCtx = {
141
242
  userId: store.userId,
142
243
  siteId: store.siteIdFor(siteUrl),
@@ -147,16 +248,25 @@ async function runOneDate(store, client, siteUrl, table, searchType, dims, date)
147
248
  await store.engine.writeDay(writeCtx, rows);
148
249
  return {
149
250
  kind: "ok",
150
- rows: rows.length
251
+ rows: rows.length,
252
+ final
151
253
  };
152
254
  }
255
+ const LEDGER_FETCH_OPTIONS = { onResponseError(ctx) {
256
+ if (ctx.options.retryStatusCodes) ctx.options.retryStatusCodes = ctx.options.retryStatusCodes.filter((status) => status !== 403);
257
+ } };
153
258
  const syncCommand = defineCommand({
154
259
  meta: syncCommandMeta,
155
260
  args: {
156
261
  "site": {
157
262
  type: "string",
158
263
  alias: "s",
159
- description: "Site URL"
264
+ description: "Site, for example example.com"
265
+ },
266
+ "all-sites": {
267
+ type: "boolean",
268
+ default: false,
269
+ description: "Sync every verified Site, one after another (they share one Google quota)"
160
270
  },
161
271
  "start": {
162
272
  type: "string",
@@ -164,11 +274,11 @@ const syncCommand = defineCommand({
164
274
  },
165
275
  "end": {
166
276
  type: "string",
167
- description: "End date (YYYY-MM-DD); defaults to 3 days ago"
277
+ description: "End date (YYYY-MM-DD); defaults to the latest date Google has finalized"
168
278
  },
169
279
  "days": {
170
280
  type: "string",
171
- description: `Number of days back to sync (default: ${DEFAULT_PENDING_DAYS})`
281
+ description: "Sync the last N final days instead of catching up"
172
282
  },
173
283
  "tables": {
174
284
  type: "string",
@@ -184,14 +294,35 @@ const syncCommand = defineCommand({
184
294
  default: false,
185
295
  description: "Ignore stored empty-type markers and re-probe every requested type"
186
296
  },
187
- "no-rollups": {
297
+ "rollups": {
188
298
  type: "boolean",
189
- default: false,
190
- description: "Skip the post-sync rollup rebuild (daily/weekly totals, top-N tables)"
299
+ default: true,
300
+ description: "Rebuild rollups after sync (daily and weekly totals, top-N tables)",
301
+ negativeDescription: "Skip the post-sync rollup rebuild"
302
+ },
303
+ "sitemaps": {
304
+ type: "boolean",
305
+ default: true,
306
+ description: "Save the Search Console sitemap list and the URLs in each sitemap",
307
+ negativeDescription: "Skip saving sitemaps"
308
+ },
309
+ "inspections": {
310
+ type: "boolean",
311
+ default: true,
312
+ description: "Run URL Inspection on URLs that are due and save the results",
313
+ negativeDescription: "Skip URL Inspection"
314
+ },
315
+ "inspect-limit": {
316
+ type: "string",
317
+ description: `Most URLs to inspect in this run (default: 50; Google allows ${INSPECTION_QPD_PER_PROPERTY} per Site per day)`
191
318
  },
192
319
  "full": {
193
320
  type: "boolean",
194
- description: "Backfill up to 450 days of available Search Console data"
321
+ description: `Backfill all the Search Console data Google keeps (16 months, plus 14 days Google often still serves)`
322
+ },
323
+ "max-calls": {
324
+ type: "string",
325
+ description: "Most Search Analytics calls this run makes. The next run continues where it stopped."
195
326
  },
196
327
  ...OUTPUT_ARGS,
197
328
  "force": {
@@ -202,12 +333,16 @@ const syncCommand = defineCommand({
202
333
  "status": {
203
334
  type: "boolean",
204
335
  default: false,
205
- description: "Print watermarks + sync-state summary instead of syncing"
336
+ description: "Print coverage, gaps, and the running sync instead of syncing"
206
337
  },
207
338
  "concurrency": {
208
339
  type: "string",
209
340
  alias: "c",
210
- description: `Concurrent in-flight day fetches per table (default: ${DEFAULT_CONCURRENCY})`
341
+ description: `Concurrent day fetches per table (default: ${DEFAULT_CONCURRENCY}). All tables share ${DEFAULT_MAX_IN_FLIGHT} requests in flight.`
342
+ },
343
+ "requests-per-minute": {
344
+ type: "string",
345
+ description: `Most Search Analytics requests to start per minute, across all tables (default: ${DEFAULT_REQUESTS_PER_MINUTE}; Google allows 1,200)`
211
346
  },
212
347
  "serial-tables": {
213
348
  type: "boolean",
@@ -217,7 +352,7 @@ const syncCommand = defineCommand({
217
352
  "retry-failed": {
218
353
  type: "boolean",
219
354
  default: false,
220
- description: "Only re-run dates currently in `failed` state (cheaper than --force)"
355
+ description: "Only re-run dates currently in `failed` state (a plain sync also retries them)"
221
356
  },
222
357
  "dry-run": {
223
358
  type: "boolean",
@@ -229,283 +364,618 @@ const syncCommand = defineCommand({
229
364
  const { json, quiet } = applyOutputMode(args);
230
365
  const days = parseIntegerOption(args.days, "--days");
231
366
  const concurrency = parseIntegerOption(args.concurrency, "--concurrency") ?? DEFAULT_CONCURRENCY;
367
+ const maxCalls = parseIntegerOption(args["max-calls"], "--max-calls");
368
+ const inspectLimit = Math.min(parseIntegerOption(args["inspect-limit"], "--inspect-limit", 0) ?? 50, INSPECTION_QPD_PER_PROPERTY);
369
+ const tables = args.tables ? parseNameList(args.tables, allTables(), "--tables") : DEFAULT_TABLES;
370
+ const requestedTypes = args.types ? parseNameList(args.types, ALL_SEARCH_TYPES, "--types") : DEFAULT_TYPES;
232
371
  if (args.status) {
233
372
  const ctx = await createCommandContext();
373
+ const siteUrl = args.site ? await resolveStatusSite(ctx, String(args.site)) : void 0;
234
374
  await printSyncStatus({
235
375
  config: ctx.config,
236
376
  dataDir: ctx.dataDir
237
- }, args.site ? String(args.site) : void 0, json);
377
+ }, siteUrl, json, inspectLimit);
238
378
  return;
239
379
  }
240
380
  const ctx = await createCommandContext({
241
381
  needsAuth: true,
242
- needsStore: true
382
+ needsStore: true,
383
+ fetchOptions: LEDGER_FETCH_OPTIONS,
384
+ quota: false
243
385
  });
244
- const client = ctx.client;
245
- const siteUrl = await ctx.resolveSite(args.site ? String(args.site) : void 0);
246
- const tables = args.tables ? String(args.tables).split(",").map((t) => t.trim()).filter(isKnownTable) : DEFAULT_TABLES;
247
- const requestedTypes = args.types ? String(args.types).split(",").map((t) => t.trim()).filter(isKnownSearchType) : DEFAULT_TYPES;
248
- if (requestedTypes.length === 0) {
249
- logger.error(`No valid search types specified. Allowed: ${ALL_SEARCH_TYPES.join(",")}`);
250
- process.exit(1);
251
- }
252
386
  const store = ctx.store;
253
- const siteId = store.siteIdFor(siteUrl);
254
- const scope = {
255
- userId: store.userId,
256
- siteId
257
- };
258
- const emptyTypesStore = createEmptyTypesStore({ dataSource: store.dataSource });
259
- let emptyTypesDoc = await emptyTypesStore.load(scope);
260
- if (emptyTypesDoc.emptyTypes.length > 0) {
261
- const entries = await store.engine.listLive(scope);
262
- const populated = new Set(entries.filter((entry) => entry.rowCount > 0).map((entry) => entry.searchType ?? "web"));
263
- const toClear = requestedTypes.filter((type) => emptyTypesDoc.emptyTypes.includes(type) && populated.has(type));
264
- if (toClear.length > 0) {
265
- if (!args["dry-run"]) emptyTypesDoc = await emptyTypesStore.clear(scope, toClear);
266
- else emptyTypesDoc.emptyTypes = emptyTypesDoc.emptyTypes.filter((type) => !toClear.includes(type));
267
- }
268
- }
269
- const forceTypes = Boolean(args["force-types"]);
270
- const skippedTypes = [];
271
- const types = [];
272
- for (const t of requestedTypes) {
273
- if (!forceTypes && emptyTypesDoc.emptyTypes.includes(t) && !EMPTY_TYPE_PROTECTED.includes(t)) {
274
- skippedTypes.push(t);
387
+ const requestedSites = args["all-sites"] ? (await ctx.loadSites()).map((site) => site.siteUrl) : [await ctx.resolveSite(args.site ? String(args.site) : void 0)];
388
+ const siteUrls = [];
389
+ for (const siteUrl of requestedSites) {
390
+ const claim = await recordStoreSite(store.dataDir, siteUrl, {
391
+ userId: store.userId,
392
+ write: !args["dry-run"]
393
+ });
394
+ if (claim.ok) {
395
+ siteUrls.push(siteUrl);
275
396
  continue;
276
397
  }
277
- types.push(t);
398
+ if (!args["all-sites"]) {
399
+ logger.error(formatSiteIdCollision(claim.error));
400
+ process.exit(1);
401
+ }
402
+ logger.warn(`Skipped ${siteUrl}. ${formatSiteIdCollision(claim.error)}`);
278
403
  }
279
- if (skippedTypes.length > 0 && !quiet) logger.info(`Skipping ${skippedTypes.join(", ")} (marked empty for this site; pass --force-types to re-probe).`);
280
- const endDate = args.end ? String(args.end) : daysAgoUtc(DEFAULT_PENDING_DAYS);
281
- let startDate;
282
- if (args.start) startDate = String(args.start);
283
- else if (args.full) startDate = daysAgoUtc(450);
284
- else if (days !== void 0) startDate = daysAgoUtc(days + DEFAULT_PENDING_DAYS - 1);
285
- else startDate = daysAgoUtc(5);
286
- let dates = getDateRange(startDate, endDate);
287
- if (dates.length === 0) {
288
- logger.error(`No dates to sync (start=${startDate}, end=${endDate})`);
289
- process.exit(1);
404
+ const latest = getLatestGscDate();
405
+ const window = resolveWindow({
406
+ start: args.start ? String(args.start) : void 0,
407
+ end: args.end ? String(args.end) : void 0,
408
+ days,
409
+ full: Boolean(args.full),
410
+ latest,
411
+ floor: getOldestGscDate()
412
+ });
413
+ const mode = args["retry-failed"] ? "retry-failed" : args.force ? "force" : "resume";
414
+ if (!args["dry-run"]) {
415
+ const existing = syncRunStatus(await readSyncRun(store.dataDir), {
416
+ now: Date.now(),
417
+ isAlive: isProcessAlive
418
+ });
419
+ if (existing.kind === "running") {
420
+ logger.error(`Another sync is running (pid ${existing.record.pid}, ${existing.record.done}/${existing.record.planned} days). Wait for it to finish, or run \`gscdump sync --status\`.`);
421
+ process.exit(1);
422
+ }
290
423
  }
291
- const printCompletion = async (status, totals, reason, rollupError) => {
292
- if (!json) return;
293
- console.log(JSON.stringify({
294
- status,
295
- ...reason ? { reason } : {},
296
- siteUrl,
297
- range: {
298
- start: startDate,
299
- end: endDate
300
- },
301
- tables,
302
- types,
303
- skippedTypes,
304
- totals,
305
- watermarks: await store.engine.getWatermarks({
306
- userId: store.userId,
307
- siteId
308
- }),
309
- ...rollupError ? { rollupError } : {}
310
- }, null, 2));
424
+ const ledger = await openQuotaLedger({ dataDir: store.dataDir });
425
+ const pacer = createRequestPacer({
426
+ maxInFlight: DEFAULT_MAX_IN_FLIGHT,
427
+ perMinute: Math.min(parseIntegerOption(args["requests-per-minute"], "--requests-per-minute") ?? DEFAULT_REQUESTS_PER_MINUTE, QUOTA_CAPS.searchAnalytics.perMinute)
428
+ });
429
+ const common = {
430
+ store,
431
+ client: gatedClient(ctx.client, {
432
+ pacer,
433
+ ledger,
434
+ calls: { made: 0 },
435
+ maxCalls
436
+ }),
437
+ ledger,
438
+ tables,
439
+ requestedTypes,
440
+ window,
441
+ mode,
442
+ latest,
443
+ concurrency,
444
+ inspectLimit,
445
+ json,
446
+ quiet,
447
+ args
311
448
  };
312
- if (types.length === 0) {
313
- if (!quiet) logger.warn(`All requested types are marked empty. Pass --force-types to check them again.`);
314
- await printCompletion("skipped", {}, "empty-types");
449
+ if (args["dry-run"]) {
450
+ const plans = [];
451
+ for (const siteUrl of siteUrls) plans.push(await planSite({
452
+ ...common,
453
+ siteUrl
454
+ }));
455
+ if (json) console.log(JSON.stringify(plans.length === 1 ? plans[0] : { sites: plans }, null, 2));
315
456
  return;
316
457
  }
317
- if (args["retry-failed"]) {
318
- const failedSet = /* @__PURE__ */ new Set();
319
- const selectedTables = new Set(tables);
320
- const selectedTypes = new Set(types);
321
- const states = await store.engine.getSyncStates({
322
- userId: store.userId,
323
- siteId
458
+ const run = await startSyncRun({
459
+ dataDir: store.dataDir,
460
+ sites: siteUrls,
461
+ onHeartbeat: () => ledger.flush()
462
+ });
463
+ const onInterrupt = () => {
464
+ clearLine();
465
+ logger.warn("Sync interrupted. Run the same command to resume.");
466
+ Promise.all([run.finish("interrupted"), ledger.flush()]).catch((error) => logger.warn(`Could not save sync progress: ${error.message}`)).finally(() => process.exit(130));
467
+ };
468
+ process.once("SIGINT", onInterrupt);
469
+ const results = [];
470
+ for (const siteUrl of siteUrls) {
471
+ run.setSite(siteUrl);
472
+ const result = await syncSite({
473
+ ...common,
474
+ siteUrl,
475
+ run
324
476
  });
325
- for (const s of states) if (selectedTables.has(s.table) && selectedTypes.has(s.searchType ?? "web") && s.state === "failed" && s.date >= startDate && s.date <= endDate) failedSet.add(s.date);
326
- dates = dates.filter((d) => failedSet.has(d));
327
- if (dates.length === 0) {
328
- if (!quiet) logger.success("No failed dates in range. Nothing to retry.");
329
- await printCompletion("skipped", {}, "no-failed-dates");
330
- return;
331
- }
332
- args.force = true;
333
- if (!quiet) logger.info(`--retry-failed: ${dates.length} date(s) to retry`);
477
+ results.push(result);
478
+ if (result.stop?.kind === "budget") break;
334
479
  }
335
- if (args["dry-run"]) {
336
- const plan = [];
337
- for (const table of tables) for (const type of types) for (const date of dates) plan.push({
338
- table,
339
- searchType: type,
340
- date
341
- });
342
- if (json) {
343
- console.log(JSON.stringify({
344
- siteUrl,
345
- range: {
346
- start: startDate,
347
- end: endDate
348
- },
349
- tables,
350
- types,
351
- totalCalls: plan.length,
352
- plan
353
- }, null, 2));
354
- return;
355
- }
356
- console.log();
357
- logger.info(`Plan: ${plan.length} API call(s) for ${siteUrl}`);
358
- console.log(` Tables: ${tables.join(", ")}`);
359
- console.log(` Types: ${types.join(", ")}`);
360
- console.log(` Range: ${startDate} → ${endDate} (${dates.length} days)`);
361
- console.log();
362
- logger.info("Pass without --dry-run to execute.");
363
- return;
480
+ process.off("SIGINT", onInterrupt);
481
+ const failed = results.some((result) => result.status === "failed");
482
+ const stopped = results.some((result) => result.status === "partial");
483
+ await ledger.flush();
484
+ await run.finish(failed ? "failed" : stopped ? "stopped" : "completed");
485
+ if (json) {
486
+ const skippedSites = siteUrls.slice(results.length);
487
+ const reports = results.map((result) => result.report);
488
+ console.log(JSON.stringify(siteUrls.length === 1 ? reports[0] : {
489
+ sites: reports,
490
+ notStarted: skippedSites
491
+ }, null, 2));
492
+ } else if (siteUrls.length > results.length && !quiet) logger.info(`Not started: ${siteUrls.slice(results.length).join(", ")}. The next sync covers them.`);
493
+ if (failed) process.exit(1);
494
+ }
495
+ });
496
+ async function resolveTypes(opts, dryRun) {
497
+ const { store, siteUrl, requestedTypes, quiet } = opts;
498
+ const scope = {
499
+ userId: store.userId,
500
+ siteId: store.siteIdFor(siteUrl)
501
+ };
502
+ const emptyTypesStore = createEmptyTypesStore({ dataSource: store.dataSource });
503
+ let emptyTypesDoc = await emptyTypesStore.load(scope);
504
+ if (emptyTypesDoc.emptyTypes.length > 0) {
505
+ const entries = await store.engine.listLive(scope);
506
+ const populated = new Set(entries.filter((entry) => entry.rowCount > 0).map((entry) => entry.searchType ?? "web"));
507
+ const toClear = requestedTypes.filter((type) => emptyTypesDoc.emptyTypes.includes(type) && populated.has(type));
508
+ if (toClear.length > 0) {
509
+ if (!dryRun) emptyTypesDoc = await emptyTypesStore.clear(scope, toClear);
510
+ else emptyTypesDoc.emptyTypes = emptyTypesDoc.emptyTypes.filter((type) => !toClear.includes(type));
364
511
  }
365
- if (!quiet) {
366
- logger.info(`Syncing ${siteUrl} (${tables.join(", ")}) [${types.join(", ")}] → ${displayPath(store.dataDir)}`);
367
- logger.info(`Range: ${startDate} → ${endDate} (${dates.length} days)`);
512
+ }
513
+ const forceTypes = Boolean(opts.args["force-types"]);
514
+ const skippedTypes = [];
515
+ const types = [];
516
+ for (const t of requestedTypes) {
517
+ if (!forceTypes && emptyTypesDoc.emptyTypes.includes(t) && !EMPTY_TYPE_PROTECTED.includes(t)) {
518
+ skippedTypes.push(t);
519
+ continue;
368
520
  }
369
- const serialTables = Boolean(args["serial-tables"]);
370
- const start = Date.now();
371
- const totals = {};
372
- const jobs = [];
373
- for (const table of tables) for (const type of types) {
374
- const label = type === "web" ? table : `${table}/${type}`;
375
- jobs.push({
376
- table,
377
- type,
378
- label
521
+ types.push(t);
522
+ }
523
+ if (skippedTypes.length > 0 && !quiet) logger.info(`Skipping ${skippedTypes.join(", ")} (marked empty for this site; pass --force-types to re-probe).`);
524
+ return {
525
+ types,
526
+ skippedTypes,
527
+ emptyTypes: emptyTypesDoc.emptyTypes
528
+ };
529
+ }
530
+ async function buildSitePlan(opts, types, skippedTypes, emptyTypes) {
531
+ const { store, siteUrl, tables, quiet, args } = opts;
532
+ const { jobs, unsupported } = planSyncJobs(tables, types);
533
+ if (unsupported.length > 0 && !quiet && (args.tables || args.types)) logger.info(`Skipping ${unsupported.map((job) => job.label).join(", ")}: Google has no such breakdown for that search type.`);
534
+ const states = await store.engine.getSyncStates({
535
+ userId: store.userId,
536
+ siteId: store.siteIdFor(siteUrl)
537
+ });
538
+ const today = getPstDate();
539
+ const forceTypes = Boolean(args["force-types"]);
540
+ return {
541
+ types,
542
+ skippedTypes,
543
+ jobs: jobs.map((job) => {
544
+ const jobStates = states.filter((state) => state.table === job.table && (state.searchType ?? "web") === job.type);
545
+ const mode = forceTypes && opts.mode === "resume" && emptyTypes.includes(job.type) ? "force" : opts.mode;
546
+ const planned = planJobDates({
547
+ table: job.table,
548
+ window: opts.window,
549
+ states: jobStates,
550
+ today,
551
+ mode
379
552
  });
553
+ return {
554
+ job,
555
+ dates: planned.dates,
556
+ skipped: planned.skippedDone
557
+ };
558
+ })
559
+ };
560
+ }
561
+ function windowLabel(window) {
562
+ return window.kind === "range" ? `${window.start} → ${window.end}` : `catch up to ${window.end} (new tables start ${window.firstStart})`;
563
+ }
564
+ async function planSite(opts) {
565
+ const { types, skippedTypes, emptyTypes } = await resolveTypes(opts, true);
566
+ const items = (await buildSitePlan(opts, types, skippedTypes, emptyTypes)).jobs.flatMap(({ job, dates }) => dates.map((date) => ({
567
+ table: job.table,
568
+ searchType: job.type,
569
+ date,
570
+ calls: minimumCallsPerDate(job.table)
571
+ })));
572
+ const minimumCalls = items.reduce((sum, item) => sum + item.calls, 0);
573
+ const ranges = groupIntoRanges([...new Set(items.map((item) => item.date))]);
574
+ if (!opts.json) {
575
+ console.log();
576
+ logger.info(`Plan: at least ${minimumCalls.toLocaleString("en-US")} Search Analytics call(s) for ${opts.siteUrl}`);
577
+ console.log(` Tables: ${opts.tables.join(", ")}`);
578
+ console.log(` Types: ${types.join(", ")}`);
579
+ console.log(` Window: ${windowLabel(opts.window)}`);
580
+ console.log(` Dates: ${ranges.length === 0 ? "none; every date is done" : ranges.map((range) => range.startDate === range.endDate ? range.startDate : `${range.startDate}..${range.endDate}`).join(", ")}`);
581
+ console.log();
582
+ logger.info("Pass without --dry-run to execute. Google may need more calls for large days and search appearances.");
583
+ }
584
+ return {
585
+ siteUrl: opts.siteUrl,
586
+ window: opts.window,
587
+ tables: opts.tables,
588
+ types,
589
+ skippedTypes,
590
+ ranges,
591
+ minimumCalls,
592
+ totalCalls: minimumCalls,
593
+ plan: items
594
+ };
595
+ }
596
+ async function syncSite(opts) {
597
+ const { store, siteUrl, client, quiet, run } = opts;
598
+ const siteId = store.siteIdFor(siteUrl);
599
+ const scope = {
600
+ userId: store.userId,
601
+ siteId
602
+ };
603
+ const { types, skippedTypes, emptyTypes } = await resolveTypes(opts, false);
604
+ const forceTypes = Boolean(opts.args["force-types"]);
605
+ const report = async (status, totals, extra = {}) => ({
606
+ status,
607
+ siteUrl,
608
+ window: opts.window,
609
+ tables: opts.tables,
610
+ types,
611
+ skippedTypes,
612
+ totals,
613
+ watermarks: await store.engine.getWatermarks(scope),
614
+ ...extra
615
+ });
616
+ if (types.length === 0) {
617
+ if (!quiet) logger.warn(`All requested types are marked empty. Pass --force-types to check them again.`);
618
+ return {
619
+ status: "skipped",
620
+ report: await report("skipped", {}, { reason: "empty-types" })
621
+ };
622
+ }
623
+ const plan = await buildSitePlan(opts, types, skippedTypes, emptyTypes);
624
+ const plannedDates = plan.jobs.reduce((sum, entry) => sum + entry.dates.length, 0);
625
+ if (opts.mode === "retry-failed" && plannedDates === 0) {
626
+ if (!quiet) logger.success("No failed dates in range. Nothing to retry.");
627
+ return {
628
+ status: "skipped",
629
+ report: await report("skipped", {}, { reason: "no-failed-dates" })
630
+ };
631
+ }
632
+ await store.engine.setSyncStates(plan.jobs.flatMap(({ job, dates }) => dates.map((date) => ({
633
+ userId: store.userId,
634
+ siteId,
635
+ table: job.table,
636
+ date,
637
+ searchType: job.type
638
+ }))), "pending");
639
+ run.addPlanned(plannedDates);
640
+ if (!quiet) {
641
+ const ranges = groupIntoRanges([...new Set(plan.jobs.flatMap((entry) => entry.dates))]);
642
+ logger.info(`Syncing ${siteUrl} (${opts.tables.join(", ")}) [${types.join(", ")}] → ${displayPath(store.dataDir)}`);
643
+ logger.info(ranges.length === 0 ? `Every date is done up to ${opts.latest}.` : `${plannedDates.toLocaleString("en-US")} table days, newest first: ${ranges.slice(-3).reverse().map((range) => range.startDate === range.endDate ? range.startDate : `${range.startDate}..${range.endDate}`).join(", ")}${ranges.length > 3 ? `, and ${ranges.length - 3} more range(s)` : ""}`);
644
+ }
645
+ const control = {};
646
+ const start = Date.now();
647
+ const totals = {};
648
+ const progress = createProgressTracker(plannedDates, quiet);
649
+ const runJob = (entry) => syncTable({
650
+ store,
651
+ siteUrl,
652
+ job: entry.job,
653
+ dates: entry.dates,
654
+ skipped: entry.skipped,
655
+ client,
656
+ concurrency: opts.concurrency,
657
+ latest: opts.latest,
658
+ control,
659
+ progress,
660
+ run
661
+ });
662
+ if (opts.args["serial-tables"]) for (const entry of plan.jobs) totals[entry.job.label] = await runJob(entry);
663
+ else {
664
+ const results = await Promise.all(plan.jobs.map(runJob));
665
+ plan.jobs.forEach((entry, i) => {
666
+ totals[entry.job.label] = results[i];
667
+ });
668
+ }
669
+ progress.done();
670
+ await run.save();
671
+ const seconds = ((Date.now() - start) / 1e3).toFixed(1);
672
+ if (!quiet) {
673
+ logger.success(`Synced ${siteUrl} in ${seconds}s`);
674
+ for (const [t, n] of Object.entries(totals)) {
675
+ const suffix = [
676
+ n.skipped > 0 ? `${n.skipped} already done` : null,
677
+ n.pending > 0 ? `${n.pending} for the next sync` : null,
678
+ n.failed > 0 ? `\x1B[31m${n.failed} failed\x1B[0m` : null
679
+ ].filter(Boolean).join(", ");
680
+ const tail = suffix ? ` (${suffix})` : "";
681
+ console.log(` ${t}: ${n.rows.toLocaleString()} rows${tail}`);
380
682
  }
381
- const progress = createProgressTracker(dates.length * jobs.length, quiet);
382
- if (serialTables) for (const job of jobs) totals[job.label] = await syncTable(store, siteUrl, job.table, job.type, dates, client, concurrency, args.force || forceTypes, progress);
383
- else {
384
- const results = await Promise.all(jobs.map((job) => syncTable(store, siteUrl, job.table, job.type, dates, client, concurrency, args.force || forceTypes, progress)));
385
- jobs.forEach((job, i) => {
386
- totals[job.label] = results[i];
387
- });
683
+ console.log();
684
+ }
685
+ if (control.stop?.kind === "budget" && !quiet) logger.info(`${describeStop(control.stop)} The next sync continues from here.`);
686
+ else if (control.stop?.kind === "quota" && !opts.json) logger.warn(`${describeStop(control.stop)} The next sync continues after ${clockTime(control.stop.resetsAt)}.`);
687
+ const anyFailed = Object.values(totals).some((t) => t.failed > 0);
688
+ if (!forceTypes && opts.tables.length > 0 && !control.stop) {
689
+ const toMark = [];
690
+ for (const type of types) {
691
+ if (EMPTY_TYPE_PROTECTED.includes(type)) continue;
692
+ const typeTotals = plan.jobs.filter((entry) => entry.job.type === type).map((entry) => totals[entry.job.label]);
693
+ if (!(typeTotals.length > 0 && typeTotals.every((t) => t.fetched >= EMPTY_TYPE_PROBE_MIN_DAYS && t.skipped === 0 && t.failed === 0 && t.rows === 0))) continue;
694
+ if ((await store.engine.listLive({
695
+ ...scope,
696
+ searchType: type
697
+ })).some((entry) => entry.rowCount > 0)) continue;
698
+ toMark.push(type);
388
699
  }
389
- progress.done();
390
- const seconds = ((Date.now() - start) / 1e3).toFixed(1);
391
- if (!quiet) {
392
- logger.success(`Synced ${siteUrl} in ${seconds}s`);
393
- for (const [t, n] of Object.entries(totals)) {
394
- const suffix = [n.skipped > 0 ? `${n.skipped} skipped` : null, n.failed > 0 ? `\x1B[31m${n.failed} failed\x1B[0m` : null].filter(Boolean).join(", ");
395
- const tail = suffix ? ` (${suffix})` : "";
396
- console.log(` ${t}: ${n.rows.toLocaleString()} rows${tail}`);
397
- }
398
- console.log();
700
+ if (toMark.length > 0) {
701
+ await createEmptyTypesStore({ dataSource: store.dataSource }).mark(scope, toMark);
702
+ if (!quiet) logger.info(`Marked empty for future syncs: ${toMark.join(", ")} (0 rows across ${EMPTY_TYPE_PROBE_MIN_DAYS}+ days; pass --force-types to re-probe).`);
399
703
  }
400
- const anyFailed = Object.values(totals).some((t) => t.failed > 0);
401
- const rowsByType = /* @__PURE__ */ new Map();
402
- const failedByType = /* @__PURE__ */ new Map();
403
- const skippedByType = /* @__PURE__ */ new Map();
404
- for (const job of jobs) {
405
- const t = totals[job.label];
406
- rowsByType.set(job.type, (rowsByType.get(job.type) ?? 0) + t.rows);
407
- failedByType.set(job.type, (failedByType.get(job.type) ?? 0) + t.failed);
408
- skippedByType.set(job.type, (skippedByType.get(job.type) ?? 0) + t.skipped);
704
+ }
705
+ if (forceTypes && emptyTypes.length > 0) {
706
+ const toClear = types.filter((type) => emptyTypes.includes(type) && plan.jobs.some((entry) => entry.job.type === type && totals[entry.job.label].rows > 0));
707
+ if (toClear.length > 0) {
708
+ await createEmptyTypesStore({ dataSource: store.dataSource }).clear(scope, toClear);
709
+ if (!quiet) logger.info(`Cleared empty markers for: ${toClear.join(", ")} (re-probe found data).`);
409
710
  }
410
- if (!forceTypes && tables.length > 0 && dates.length >= EMPTY_TYPE_PROBE_MIN_DAYS) {
411
- const toMark = [];
412
- for (const type of types) {
413
- if (EMPTY_TYPE_PROTECTED.includes(type)) continue;
414
- if ((failedByType.get(type) ?? 0) > 0) continue;
415
- if ((skippedByType.get(type) ?? 0) > 0 || (rowsByType.get(type) ?? 0) > 0) continue;
416
- if ((await store.engine.listLive({
417
- ...scope,
418
- searchType: type
419
- })).some((entry) => entry.rowCount > 0)) continue;
420
- toMark.push(type);
421
- }
422
- if (toMark.length > 0) {
423
- await emptyTypesStore.mark({
424
- userId: store.userId,
425
- siteId
426
- }, toMark);
427
- if (!quiet) logger.info(`Marked empty for future syncs: ${toMark.join(", ")} (0 rows across ${dates.length} days; pass --force-types to re-probe).`);
428
- }
711
+ }
712
+ const noRollups = opts.args.rollups === false;
713
+ let rollupError;
714
+ const anyRowsSynced = Object.values(totals).some((t) => t.rows > 0);
715
+ if (!noRollups && anyRowsSynced) {
716
+ if (!quiet) logger.info(`Rebuilding rollups for [${siteId}] (${DEFAULT_ROLLUPS.length} rollups)…`);
717
+ const rollupStart = Date.now();
718
+ const results = await rebuildRollups({
719
+ engine: {
720
+ runSQL: (sqlOpts) => store.engine.runSQL(sqlOpts),
721
+ listPartitions: async ({ ctx, table, searchType }) => {
722
+ return (await store.engine.listLive({
723
+ userId: ctx.userId,
724
+ ...ctx.siteId !== void 0 ? { siteId: ctx.siteId } : {},
725
+ table,
726
+ ...searchType !== void 0 ? { searchType } : {}
727
+ })).map((e) => ({
728
+ partition: e.partition,
729
+ bytes: e.bytes
730
+ }));
731
+ }
732
+ },
733
+ dataSource: store.dataSource,
734
+ ctx: scope,
735
+ defs: DEFAULT_ROLLUPS
736
+ }).catch((err) => {
737
+ rollupError = err.message;
738
+ logger.warn(`Rollup rebuild failed: ${err.message}`);
739
+ return [];
740
+ });
741
+ if (!quiet && results.length > 0) {
742
+ const kb = results.reduce((a, r) => a + r.bytes, 0) / 1024;
743
+ const ms = Date.now() - rollupStart;
744
+ logger.success(`Rebuilt ${results.length} rollup(s) in ${ms}ms — ${kb.toFixed(1)} KB`);
429
745
  }
430
- if (forceTypes && emptyTypesDoc.emptyTypes.length > 0) {
431
- const toClear = [];
432
- for (const type of types) if (emptyTypesDoc.emptyTypes.includes(type) && (rowsByType.get(type) ?? 0) > 0) toClear.push(type);
433
- if (toClear.length > 0) {
434
- await emptyTypesStore.clear({
435
- userId: store.userId,
436
- siteId
437
- }, toClear);
438
- if (!quiet) logger.info(`Cleared empty markers for: ${toClear.join(", ")} (re-probe found data).`);
746
+ }
747
+ const entities = await syncEntities({
748
+ store,
749
+ client,
750
+ siteUrl,
751
+ ledger: opts.ledger,
752
+ sitemaps: opts.args.sitemaps !== false,
753
+ inspections: opts.args.inspections !== false,
754
+ inspectLimit: opts.inspectLimit,
755
+ quiet
756
+ });
757
+ await opts.ledger.flush();
758
+ const coverage = await readStoreCoverage({
759
+ store,
760
+ site: siteUrl,
761
+ inspectLimit: opts.inspectLimit,
762
+ run: { kind: "none" }
763
+ });
764
+ if (!quiet) {
765
+ for (const line of renderCoverage(coverage)) console.log(` ${line}`);
766
+ console.log();
767
+ }
768
+ const status = anyFailed || rollupError ? "failed" : control.stop ? "partial" : "completed";
769
+ return {
770
+ status,
771
+ ...control.stop ? { stop: control.stop } : {},
772
+ report: await report(status, totals, {
773
+ ...control.stop ? { stopped: control.stop } : {},
774
+ ...rollupError ? { rollupError } : {},
775
+ ...entities,
776
+ coverage
777
+ })
778
+ };
779
+ }
780
+ function failureReason(error) {
781
+ return error instanceof Error ? error.message : String(error);
782
+ }
783
+ function clockTime(at) {
784
+ return new Date(at).toLocaleString("en-US", {
785
+ hour: "numeric",
786
+ minute: "2-digit",
787
+ month: "short",
788
+ day: "numeric"
789
+ });
790
+ }
791
+ async function syncEntities(opts) {
792
+ const { store, client, siteUrl, quiet } = opts;
793
+ const ctx = {
794
+ userId: store.userId,
795
+ siteId: store.siteIdFor(siteUrl)
796
+ };
797
+ const now = () => /* @__PURE__ */ new Date();
798
+ let sitemaps = { _tag: "disabled" };
799
+ if (opts.sitemaps) {
800
+ if (!quiet) logger.info("Saving sitemaps…");
801
+ sitemaps = await syncSitemaps({
802
+ client,
803
+ dataSource: store.dataSource,
804
+ ctx,
805
+ siteUrl,
806
+ now,
807
+ generationId: randomUUID,
808
+ loadFeed: async (url) => {
809
+ const loaded = await loadSitemapUrls(url, {
810
+ maxUrls: 5e5,
811
+ maxDocuments: 1e3
812
+ });
813
+ return loaded._tag === "ok" ? {
814
+ _tag: "ok",
815
+ entries: loaded.value.entries,
816
+ complete: loaded.value.complete
817
+ } : loaded;
439
818
  }
819
+ }).catch((error) => ({
820
+ _tag: "failed",
821
+ reason: failureReason(error)
822
+ }));
823
+ if (sitemaps._tag === "failed") logger.warn(`Sitemaps not saved: ${sitemaps.reason}`);
824
+ else if (sitemaps._tag === "list_only") logger.warn(`Saved ${sitemaps.sitemaps} sitemap(s). Kept the stored sitemap URLs: ${sitemaps.reason}`);
825
+ else if (!quiet) logger.success(`Saved ${sitemaps.sitemaps} sitemap(s) with ${sitemaps.urls.toLocaleString()} URL(s)`);
826
+ if (sitemaps._tag === "saved" || sitemaps._tag === "list_only") {
827
+ for (const feed of sitemaps.feeds) if (feed._tag === "unreachable") logger.warn(` ${feed.path}: ${feed.message}`);
828
+ else if (!feed.complete) logger.warn(` ${feed.path}: read stopped early; saved ${feed.urls.toLocaleString()} URL(s)`);
440
829
  }
441
- const noRollups = Boolean(args["no-rollups"]);
442
- let rollupError;
443
- const anyRowsSynced = Object.values(totals).some((t) => t.rows > 0);
444
- if (!noRollups && anyRowsSynced) {
445
- if (!quiet) logger.info(`Rebuilding rollups for [${siteId}] (${DEFAULT_ROLLUPS.length} rollups)…`);
446
- const rollupStart = Date.now();
447
- const results = await rebuildRollups({
448
- engine: {
449
- runSQL: (opts) => store.engine.runSQL(opts),
450
- listPartitions: async ({ ctx, table, searchType }) => {
451
- return (await store.engine.listLive({
452
- userId: ctx.userId,
453
- ...ctx.siteId !== void 0 ? { siteId: ctx.siteId } : {},
454
- table,
455
- ...searchType !== void 0 ? { searchType } : {}
456
- })).map((e) => ({
457
- partition: e.partition,
458
- bytes: e.bytes
459
- }));
460
- }
461
- },
830
+ }
831
+ let inspections = { _tag: "disabled" };
832
+ if (opts.inspections && opts.inspectLimit > 0) {
833
+ inspections = await (async () => {
834
+ const candidates = await inspectionCandidates(store, siteUrl);
835
+ const progress = createProgressTracker(Math.min(opts.inspectLimit, candidates.length), quiet);
836
+ return await syncInspections({
837
+ client,
462
838
  dataSource: store.dataSource,
463
- ctx: {
464
- userId: store.userId,
465
- siteId
466
- },
467
- defs: DEFAULT_ROLLUPS
468
- }).catch((err) => {
469
- rollupError = err.message;
470
- logger.warn(`Rollup rebuild failed: ${err.message}`);
471
- return [];
472
- });
473
- if (!quiet && results.length > 0) {
474
- const kb = results.reduce((a, r) => a + r.bytes, 0) / 1024;
475
- const ms = Date.now() - rollupStart;
476
- logger.success(`Rebuilt ${results.length} rollup(s) in ${ms}ms — ${kb.toFixed(1)} KB`);
839
+ ctx,
840
+ siteUrl,
841
+ candidates,
842
+ limit: opts.inspectLimit,
843
+ concurrency: INSPECT_CONCURRENCY,
844
+ ledger: opts.ledger,
845
+ now,
846
+ onProgress: () => progress.tick("inspect")
847
+ }).finally(() => progress.done());
848
+ })().catch((error) => ({
849
+ _tag: "failed",
850
+ reason: failureReason(error)
851
+ }));
852
+ if (inspections._tag === "failed") logger.warn(`URL Inspection not saved: ${inspections.reason}`);
853
+ else if (inspections._tag === "quota_exhausted") logger.warn(`URL Inspection paused until ${clockTime(inspections.resetsAt)}: ${inspections.reason}`);
854
+ else if (inspections._tag === "inspected") {
855
+ if (!quiet) logger.success(`Inspected ${inspections.inspected} URL(s); ${inspections.deferred} due URL(s) left for later runs`);
856
+ if (inspections.stopped) logger.warn(`URL Inspection paused until ${clockTime(inspections.stopped.resetsAt)}: ${inspections.stopped.reason}`);
857
+ if (inspections.failed > 0) {
858
+ logger.warn(`${inspections.failed} URL Inspection call(s) failed:`);
859
+ for (const failure of inspections.failures.slice(0, 5)) logger.warn(` ${failure.url}: ${failure.error}`);
477
860
  }
478
- }
479
- await printCompletion(anyFailed || rollupError ? "failed" : "completed", totals, void 0, rollupError);
480
- if (anyFailed || rollupError) process.exit(1);
861
+ } else if (!quiet) logger.info(inspections.quotaLeft === 0 ? "URL Inspection quota is used up for today" : "No URLs are due for URL Inspection");
481
862
  }
482
- });
483
- function isKnownTable(name) {
484
- return allTables().includes(name);
863
+ return {
864
+ sitemaps,
865
+ inspections
866
+ };
485
867
  }
486
- function isKnownSearchType(name) {
487
- return ALL_SEARCH_TYPES.includes(name);
868
+ function gatedClient(client, gate) {
869
+ const query = client.searchAnalytics.query;
870
+ return {
871
+ ...client,
872
+ searchAnalytics: {
873
+ ...client.searchAnalytics,
874
+ query: async (...args) => {
875
+ const [siteUrl] = args;
876
+ if (gate.maxCalls !== void 0 && gate.calls.made >= gate.maxCalls) throw stopError({
877
+ kind: "budget",
878
+ maxCalls: gate.maxCalls
879
+ });
880
+ const decision = gate.ledger.reserve("searchAnalytics", siteUrl, 1);
881
+ if (decision.kind === "exhausted") throw stopError({
882
+ kind: "quota",
883
+ reason: decision.reason,
884
+ resetsAt: decision.resetsAt
885
+ });
886
+ gate.calls.made++;
887
+ return pacer(gate.pacer, () => query(...args)).catch((error) => {
888
+ const refusal = parseQuotaRefusal(error);
889
+ if (!refusal) throw error;
890
+ gate.ledger.record("searchAnalytics", siteUrl, {
891
+ kind: "refused",
892
+ reason: refusal
893
+ });
894
+ throw stopError({
895
+ kind: "quota",
896
+ reason: refusal,
897
+ resetsAt: gate.ledger.status("searchAnalytics", siteUrl).blocked?.until ?? Date.now()
898
+ });
899
+ });
900
+ }
901
+ }
902
+ };
488
903
  }
489
- async function printSyncStatus(resolved, siteFilter, asJson) {
904
+ function pacer(requestPacer, task) {
905
+ return requestPacer.run(task);
906
+ }
907
+ async function resolveStatusSite(ctx, input) {
908
+ const inStore = await ctx.matchSite(input, { scope: "store" });
909
+ if (inStore.kind === "resolved") return inStore.siteUrl;
910
+ const record = await readSyncRun(ctx.dataDir);
911
+ const inRun = record ? resolveSiteInput(input, record.sites.map((siteUrl) => ({ siteUrl }))) : void 0;
912
+ if (inRun?.kind === "resolved") return inRun.siteUrl;
913
+ throw new Error(formatSiteResolution(inStore, "store"));
914
+ }
915
+ async function printSyncStatus(resolved, siteFilter, asJson, inspectLimit) {
490
916
  const store = createLocalStore({ dataDir: resolved.dataDir });
491
917
  const siteId = siteFilter ? store.siteIdFor(siteFilter) : void 0;
492
- const watermarks = await store.engine.getWatermarks({
918
+ const run = syncRunStatus(await readSyncRun(store.dataDir), {
919
+ now: Date.now(),
920
+ isAlive: isProcessAlive
921
+ });
922
+ const siteMap = await readSiteMap(store.dataDir, store.userId);
923
+ const siteLabel = (id) => id ? `@${siteUrlForId(siteMap, id)}` : "";
924
+ const watermarks = (await store.engine.getWatermarks({
493
925
  userId: store.userId,
494
926
  siteId
495
- });
927
+ })).map((w) => ({
928
+ ...w,
929
+ siteUrl: w.siteId ? siteUrlForId(siteMap, w.siteId) : null
930
+ }));
496
931
  const states = await store.engine.getSyncStates({
497
932
  userId: store.userId,
498
933
  siteId
499
934
  });
500
935
  const failed = states.filter((s) => s.state === "failed");
501
936
  const inflight = states.filter((s) => s.state === "inflight");
937
+ const staleInflight = run.kind === "running" ? [] : inflight;
938
+ const bySite = /* @__PURE__ */ new Map();
939
+ for (const state of states) {
940
+ const key = state.siteId ?? "";
941
+ bySite.set(key, [...bySite.get(key) ?? [], state]);
942
+ }
943
+ const latest = getLatestGscDate();
944
+ const gaps = [...bySite].flatMap(([id, siteStates]) => analyticsCoverage({
945
+ states: siteStates,
946
+ latest,
947
+ floor: getOldestGscDate(),
948
+ today: getPstDate()
949
+ }).jobs.map((job) => ({
950
+ siteId: id,
951
+ siteUrl: id ? siteUrlForId(siteMap, id) : null,
952
+ table: job.table,
953
+ searchType: job.searchType,
954
+ from: job.from,
955
+ to: job.to,
956
+ done: job.coverage.kind === "complete" ? job.coverage.done : job.coverage.kind === "partial" ? job.coverage.done : 0,
957
+ missing: job.coverage.kind === "partial" ? job.coverage.pending : 0,
958
+ failed: job.coverage.kind === "partial" ? job.coverage.failed : 0
959
+ })));
960
+ const coverageSites = siteFilter ? [siteFilter] : [.../* @__PURE__ */ new Set([...run.kind === "none" ? [] : run.record.sites, ...resolved.config.defaultSite ? [resolved.config.defaultSite] : []])];
961
+ const coverages = [];
962
+ for (const site of coverageSites) coverages.push(await readStoreCoverage({
963
+ store,
964
+ site,
965
+ inspectLimit,
966
+ run
967
+ }));
502
968
  if (asJson) {
503
969
  console.log(JSON.stringify({
504
970
  dataDir: store.dataDir,
505
971
  siteFilter: siteFilter ?? null,
972
+ run,
973
+ gaps,
974
+ coverage: coverages,
506
975
  watermarks,
507
976
  failed,
508
- inflight
977
+ inflight,
978
+ staleInflight: staleInflight.length
509
979
  }, null, 2));
510
980
  return;
511
981
  }
@@ -513,31 +983,39 @@ async function printSyncStatus(resolved, siteFilter, asJson) {
513
983
  console.log(` \x1B[1m${displayPath(store.dataDir)}\x1B[0m`);
514
984
  if (siteFilter) console.log(` \x1B[90mSite: ${siteFilter}\x1B[0m`);
515
985
  console.log();
516
- if (watermarks.length === 0) {
517
- console.log(` No sync watermarks. Run \`gscdump sync\` to ingest data.`);
986
+ if (run.kind === "running") console.log(` Sync running: ${run.record.done}/${run.record.planned} days${run.record.site ? ` for ${run.record.site}` : ""}, pid ${run.record.pid}, started ${formatAge(run.record.startedAt)}.`);
987
+ else if (run.kind === "stale") console.log(` \x1B[33mThe last sync stopped without finishing (pid ${run.record.pid}, ${run.reason === "process-gone" ? "process gone" : "no heartbeat"}). The next sync retries its dates.\x1B[0m`);
988
+ if (gaps.length === 0 && watermarks.length === 0) {
989
+ console.log(` No synced data. Run \`gscdump sync\` to fill the Store.`);
518
990
  console.log();
519
991
  return;
520
992
  }
521
- console.log(` \x1B[1mWatermarks:\x1B[0m`);
522
- const sorted = [...watermarks].sort((a, b) => {
523
- if (a.table !== b.table) return a.table.localeCompare(b.table);
524
- return (a.siteId ?? "").localeCompare(b.siteId ?? "");
525
- });
526
- for (const w of sorted) {
527
- const scope = w.siteId ? `${w.table}@${w.siteId}` : w.table;
528
- console.log(` ${scope.padEnd(28)} \x1B[36m${w.oldestDateSynced}\x1B[0m → \x1B[36m${w.newestDateSynced}\x1B[0m \x1B[90m(last ${formatAge(w.lastSyncAt)})\x1B[0m`);
993
+ console.log(` \x1B[1mTables:\x1B[0m`);
994
+ for (const gap of gaps) {
995
+ const label = `${gap.searchType === "web" ? gap.table : `${gap.table}/${gap.searchType}`}${siteFilter ? "" : siteLabel(gap.siteId)}`;
996
+ const parts = [`${gap.done} done`];
997
+ if (gap.missing > 0) parts.push(`${gap.missing} missing`);
998
+ if (gap.failed > 0) parts.push(`\x1B[31m${gap.failed} failed\x1B[0m`);
999
+ console.log(` ${label.padEnd(34)} \x1B[36m${gap.from}\x1B[0m → \x1B[36m${gap.to}\x1B[0m ${parts.join(", ")}`);
529
1000
  }
530
- if (inflight.length > 0) {
1001
+ if (staleInflight.length > 0) {
531
1002
  console.log();
532
- console.log(` \x1B[33m${inflight.length} inflight:\x1B[0m`);
533
- for (const s of inflight) console.log(` ${s.table}${s.siteId ? `@${s.siteId}` : ""} ${s.date} (attempt ${s.attempts}, started ${formatAge(s.updatedAt)})`);
1003
+ console.log(` \x1B[33m${staleInflight.length} date(s) stale from a stopped sync. The next sync retries them.\x1B[0m`);
534
1004
  }
535
1005
  if (failed.length > 0) {
536
1006
  console.log();
537
1007
  console.log(` \x1B[31m${failed.length} failed:\x1B[0m`);
538
- for (const s of failed) console.log(` ${s.table}${s.siteId ? `@${s.siteId}` : ""} ${s.date}: ${s.error ?? "unknown"}`);
1008
+ for (const s of failed.slice(0, 20)) console.log(` ${s.table}${siteLabel(s.siteId)} ${s.date}: ${s.error ?? "unknown"}`);
1009
+ if (failed.length > 20) console.log(` and ${failed.length - 20} more`);
1010
+ }
1011
+ for (const coverage of coverages) {
1012
+ console.log();
1013
+ console.log(` \x1B[1m${coverage.site}\x1B[0m`);
1014
+ for (const line of renderCoverage(coverage)) console.log(` ${line}`);
1015
+ }
1016
+ if (coverages.length === 0) {
539
1017
  console.log();
540
- console.log(` Re-run \`gscdump sync --force\` to retry failed dates.`);
1018
+ console.log(` Pass --site to see inspection and sitemap coverage.`);
541
1019
  }
542
1020
  console.log();
543
1021
  }