@gscdump/cli 0.37.2 → 0.37.4

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.
@@ -0,0 +1,400 @@
1
+ import { loadConfig } from "./config.mjs";
2
+ import { resolveCliEnvironment } from "./environment.mjs";
3
+ import { OUTPUT_ARGS, applyOutputMode, displayPath, logger } from "./utils.mjs";
4
+ import { parseEnvFile } from "./env-file.mjs";
5
+ import { loadTokens, resolveAuth, resolveBYOK } from "./auth.mjs";
6
+ import { createCommandContext, createLocalStore } from "./context.mjs";
7
+ import process from "node:process";
8
+ import { defineCommand } from "citty";
9
+ import fs from "node:fs/promises";
10
+ import path from "node:path";
11
+ import { googleSearchConsole, hasGscWriteScope, hasIndexingScope } from "gscdump/api";
12
+ import { ofetch } from "ofetch";
13
+ const REQUIRED_SCOPES = [
14
+ "https://www.googleapis.com/auth/webmasters",
15
+ "https://www.googleapis.com/auth/indexing",
16
+ "https://www.googleapis.com/auth/siteverification"
17
+ ];
18
+ const FETCH_TIMEOUT_MS = 5e3;
19
+ const TIME_SKEW_WARN_MS = 5 * 6e4;
20
+ const WATERMARK_STALE_DAYS_WARN = 7;
21
+ const RELEVANT_ENV_KEYS = [
22
+ "GSC_ACCESS_TOKEN",
23
+ "GSC_CLIENT_ID",
24
+ "GSC_CLIENT_SECRET",
25
+ "GSC_REFRESH_TOKEN",
26
+ "GOOGLE_ACCESS_TOKEN",
27
+ "GOOGLE_CLIENT_ID",
28
+ "GOOGLE_CLIENT_SECRET",
29
+ "GOOGLE_REFRESH_TOKEN",
30
+ "GOOGLE_APPLICATION_CREDENTIALS",
31
+ "GSC_SERVICE_ACCOUNT_JSON"
32
+ ];
33
+ function redact(v) {
34
+ if (!v) return "<missing>";
35
+ if (v.length <= 6) return "***";
36
+ return `***${v.slice(-6)}`;
37
+ }
38
+ function hasGoogleScope(scopes, scope) {
39
+ const suffix = scope.replace("https://www.googleapis.com/auth/", "");
40
+ return scopes.includes(scope) || scopes.includes(suffix);
41
+ }
42
+ function missingRequiredScopes(scopes) {
43
+ return REQUIRED_SCOPES.filter((scope) => {
44
+ if (scope.endsWith("/webmasters")) return !hasGscWriteScope(scopes);
45
+ if (scope.endsWith("/indexing")) return !hasIndexingScope(scopes);
46
+ return !hasGoogleScope(scopes, scope);
47
+ });
48
+ }
49
+ async function checkEnv() {
50
+ const envPath = path.join(process.cwd(), ".env");
51
+ const parsed = parseEnvFile(envPath);
52
+ if (!parsed) return {
53
+ checks: [{
54
+ name: "env",
55
+ status: "info",
56
+ detail: "no .env (using shell env / saved tokens)"
57
+ }],
58
+ envKeys: /* @__PURE__ */ new Set()
59
+ };
60
+ const relevant = RELEVANT_ENV_KEYS.filter((k) => parsed[k] !== void 0);
61
+ const envKeys = new Set(relevant);
62
+ if (relevant.length === 0) return {
63
+ checks: [{
64
+ name: "env",
65
+ status: "info",
66
+ detail: `${displayPath(envPath)} found, no auth vars`
67
+ }],
68
+ envKeys
69
+ };
70
+ const inventory = relevant.map((k) => {
71
+ const v = parsed[k];
72
+ if (k.endsWith("CLIENT_ID")) return `${k}=${v}`;
73
+ return `${k}=${redact(v)}`;
74
+ });
75
+ return {
76
+ checks: [{
77
+ name: "env",
78
+ status: "info",
79
+ detail: `${displayPath(envPath)} → ${inventory.join(", ")} (not validated — see auth)`
80
+ }],
81
+ envKeys
82
+ };
83
+ }
84
+ function describeAuthSource(envKeys, byok) {
85
+ if (!byok) return "saved tokens";
86
+ const isAccessToken = typeof byok === "string";
87
+ const driver = isAccessToken ? "GSC_ACCESS_TOKEN" : "GSC_REFRESH_TOKEN";
88
+ const source = (isAccessToken ? ["GSC_ACCESS_TOKEN", "GOOGLE_ACCESS_TOKEN"] : ["GSC_REFRESH_TOKEN", "GOOGLE_REFRESH_TOKEN"]).some((k) => envKeys.has(k)) ? ".env" : "shell env";
89
+ return `BYOK ${isAccessToken ? "(access-token)" : "(refresh-token)"} from ${source} via ${driver}`;
90
+ }
91
+ async function checkAuth(envKeys) {
92
+ const checks = [];
93
+ const byok = resolveBYOK();
94
+ const tokens = await loadTokens();
95
+ if (!byok && !tokens) {
96
+ checks.push({
97
+ name: "auth",
98
+ status: "fail",
99
+ detail: "no BYOK env vars and no saved tokens; run `gscdump init`"
100
+ });
101
+ return {
102
+ checks,
103
+ liveToken: null
104
+ };
105
+ }
106
+ const clientId = resolveCliEnvironment().clientId ?? (await loadConfig()).clientId ?? null;
107
+ if (clientId) checks.push({
108
+ name: "auth.client_id",
109
+ status: "info",
110
+ detail: clientId
111
+ });
112
+ let liveToken = null;
113
+ let refreshError = null;
114
+ if (typeof byok === "string") liveToken = byok;
115
+ else if (byok && "getAccessToken" in byok) liveToken = await byok.getAccessToken().then((r) => r.token ?? null).catch((e) => {
116
+ refreshError = e?.data?.error_description ?? e?.data?.error ?? e?.message ?? String(e);
117
+ return null;
118
+ });
119
+ else if (tokens?.access_token) liveToken = tokens.access_token;
120
+ if (!liveToken) {
121
+ const source = describeAuthSource(envKeys, byok);
122
+ const detail = refreshError ? `${source} — refresh failed: ${refreshError} (token revoked / invalid — re-run \`gscdump auth login --force\` and update the source above)` : `${source} — no usable access token`;
123
+ checks.push({
124
+ name: "auth",
125
+ status: "fail",
126
+ detail
127
+ });
128
+ return {
129
+ checks,
130
+ liveToken: null
131
+ };
132
+ }
133
+ const info = await ofetch("https://oauth2.googleapis.com/tokeninfo", { query: { access_token: liveToken } }).catch((e) => ({ error: e.message }));
134
+ if ("error" in info) {
135
+ checks.push({
136
+ name: "auth",
137
+ status: "fail",
138
+ detail: `tokeninfo failed: ${info.error}`
139
+ });
140
+ return {
141
+ checks,
142
+ liveToken: null
143
+ };
144
+ }
145
+ checks.push({
146
+ name: "auth",
147
+ status: "pass",
148
+ detail: describeAuthSource(envKeys, byok)
149
+ });
150
+ if (info.email) checks.push({
151
+ name: "auth.account",
152
+ status: "pass",
153
+ detail: info.email
154
+ });
155
+ const scopes = info.scope ? info.scope.split(/\s+/) : [];
156
+ const missing = missingRequiredScopes(scopes);
157
+ if (missing.length > 0) checks.push({
158
+ name: "auth.scopes",
159
+ status: "warn",
160
+ detail: `missing: ${missing.join(", ")} — \`gscdump auth login --force\` to re-consent`
161
+ });
162
+ else checks.push({
163
+ name: "auth.scopes",
164
+ status: "pass",
165
+ detail: `${scopes.length} granted`
166
+ });
167
+ if (tokens?.expiry_date) {
168
+ const expiresInMs = tokens.expiry_date - Date.now();
169
+ if (expiresInMs < 0) checks.push({
170
+ name: "auth.expiry",
171
+ status: "warn",
172
+ detail: `expired ${new Date(tokens.expiry_date).toISOString()} — will refresh on next call`
173
+ });
174
+ else checks.push({
175
+ name: "auth.expiry",
176
+ status: "pass",
177
+ detail: `valid for ${Math.floor(expiresInMs / 6e4)}m`
178
+ });
179
+ }
180
+ return {
181
+ checks,
182
+ liveToken
183
+ };
184
+ }
185
+ async function checkTimeSkew() {
186
+ const dateHeader = await ofetch.raw("https://oauth2.googleapis.com/tokeninfo", {
187
+ method: "GET",
188
+ timeout: FETCH_TIMEOUT_MS
189
+ }).then((r) => r.headers.get("date")).catch((e) => e?.response?.headers?.get("date") ?? null);
190
+ if (!dateHeader) return [{
191
+ name: "time",
192
+ status: "warn",
193
+ detail: "could not probe Google clock (no Date header)"
194
+ }];
195
+ const remoteMs = Date.parse(dateHeader);
196
+ if (!Number.isFinite(remoteMs)) return [{
197
+ name: "time",
198
+ status: "warn",
199
+ detail: `unparseable Date header: ${dateHeader}`
200
+ }];
201
+ const skewMs = Date.now() - remoteMs;
202
+ const human = `${skewMs >= 0 ? "+" : ""}${(skewMs / 1e3).toFixed(1)}s`;
203
+ if (Math.abs(skewMs) > TIME_SKEW_WARN_MS) return [{
204
+ name: "time",
205
+ status: "warn",
206
+ detail: `local clock ${human} off Google — OAuth refresh may reject; sync your clock`
207
+ }];
208
+ return [{
209
+ name: "time",
210
+ status: "pass",
211
+ detail: `in sync (${human})`
212
+ }];
213
+ }
214
+ async function checkDataDir(dataDir) {
215
+ const display = displayPath(dataDir);
216
+ const stat = await fs.stat(dataDir).catch(() => null);
217
+ if (!stat) return [{
218
+ name: "dataDir",
219
+ status: "warn",
220
+ detail: `${display} does not exist (will be created on first sync)`
221
+ }];
222
+ if (!stat.isDirectory()) return [{
223
+ name: "dataDir",
224
+ status: "fail",
225
+ detail: `${display} is not a directory`
226
+ }];
227
+ const probe = `${dataDir}/.gscdump-doctor-probe`;
228
+ return await fs.writeFile(probe, "").then(() => fs.rm(probe)).then(() => true).catch(() => false) ? [{
229
+ name: "dataDir",
230
+ status: "pass",
231
+ detail: display
232
+ }] : [{
233
+ name: "dataDir",
234
+ status: "fail",
235
+ detail: `${display} not writable`
236
+ }];
237
+ }
238
+ async function checkStoreWatermarks(dataDir) {
239
+ if (!(await fs.stat(dataDir).catch(() => null))?.isDirectory()) return [{
240
+ name: "store.watermarks",
241
+ status: "pass",
242
+ detail: "no store yet (run `gscdump sync`)"
243
+ }];
244
+ const store = createLocalStore({ dataDir });
245
+ const watermarks = await store.engine.getWatermarks({ userId: store.userId }).catch(() => null);
246
+ if (!watermarks || watermarks.length === 0) return [{
247
+ name: "store.watermarks",
248
+ status: "pass",
249
+ detail: "no watermarks (run `gscdump sync`)"
250
+ }];
251
+ const bySite = /* @__PURE__ */ new Map();
252
+ for (const w of watermarks) {
253
+ const site = w.siteId ?? "(global)";
254
+ const existing = bySite.get(site);
255
+ if (!existing || w.newestDateSynced > existing) bySite.set(site, w.newestDateSynced);
256
+ }
257
+ const today = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
258
+ const stale = [];
259
+ let freshest = null;
260
+ for (const [site, newest] of bySite) {
261
+ const days = Math.floor((Date.parse(today) - Date.parse(newest)) / 864e5);
262
+ if (days > WATERMARK_STALE_DAYS_WARN) stale.push({
263
+ site,
264
+ days
265
+ });
266
+ if (!freshest || days < freshest.days) freshest = {
267
+ site,
268
+ days
269
+ };
270
+ }
271
+ if (stale.length > 0) {
272
+ const sample = stale.slice(0, 3).map((s) => `${s.site} (${s.days}d)`).join(", ");
273
+ const more = stale.length > 3 ? ` +${stale.length - 3} more` : "";
274
+ return [{
275
+ name: "store.watermarks",
276
+ status: "warn",
277
+ detail: `${stale.length}/${bySite.size} site(s) stale >${WATERMARK_STALE_DAYS_WARN}d: ${sample}${more}`
278
+ }];
279
+ }
280
+ const tail = freshest ? `, freshest ${freshest.days}d ago` : "";
281
+ return [{
282
+ name: "store.watermarks",
283
+ status: "pass",
284
+ detail: `${bySite.size} site(s)${tail}`
285
+ }];
286
+ }
287
+ async function checkApiReachable(name, url) {
288
+ const reachable = await ofetch.raw(url, {
289
+ method: "GET",
290
+ timeout: FETCH_TIMEOUT_MS
291
+ }).then(() => true).catch(() => false);
292
+ return [{
293
+ name,
294
+ status: reachable ? "pass" : "warn",
295
+ detail: reachable ? "reachable" : `${new URL(url).hostname} unreachable (network/firewall?)`
296
+ }];
297
+ }
298
+ async function checkGscSites() {
299
+ const config = await loadConfig();
300
+ const auth = await resolveAuth({
301
+ interactive: false,
302
+ config
303
+ }).catch(() => null);
304
+ if (!auth) return [{
305
+ name: "gsc.sites",
306
+ status: "warn",
307
+ detail: "skipped (no usable auth)"
308
+ }];
309
+ const sites = await googleSearchConsole(auth).sites().catch((e) => e);
310
+ if (sites instanceof Error) return [{
311
+ name: "gsc.sites",
312
+ status: "fail",
313
+ detail: `sites() failed: ${sites.message}`
314
+ }];
315
+ const checks = [];
316
+ const verified = sites.filter((s) => s.permissionLevel !== "siteUnverifiedUser").length;
317
+ checks.push({
318
+ name: "gsc.sites",
319
+ status: "pass",
320
+ detail: `${sites.length} site(s) accessible (${verified} verified)`
321
+ });
322
+ if (config.defaultSite) {
323
+ const match = sites.find((s) => s.siteUrl === config.defaultSite || (s.siteUrl ?? "").includes(String(config.defaultSite)));
324
+ checks.push(match ? {
325
+ name: "config.defaultSite",
326
+ status: "pass",
327
+ detail: `${config.defaultSite} ✓`
328
+ } : {
329
+ name: "config.defaultSite",
330
+ status: "fail",
331
+ detail: `${config.defaultSite} not in verified site list`
332
+ });
333
+ }
334
+ return checks;
335
+ }
336
+ const doctorCommand = defineCommand({
337
+ meta: {
338
+ name: "doctor",
339
+ description: "Run health checks (env, auth, scopes, time, dataDir, store, GSC reachability + ping, defaultSite)"
340
+ },
341
+ args: { ...OUTPUT_ARGS },
342
+ async run({ args }) {
343
+ const { json } = applyOutputMode(args);
344
+ const { dataDir } = await createCommandContext();
345
+ const envResult = await checkEnv();
346
+ const [authResult, timeChecks, dataDirChecks, watermarkChecks, gscApi, indexingApi, siteVerificationApi] = await Promise.all([
347
+ checkAuth(envResult.envKeys),
348
+ checkTimeSkew(),
349
+ checkDataDir(dataDir),
350
+ checkStoreWatermarks(dataDir),
351
+ checkApiReachable("gsc.api", "https://searchconsole.googleapis.com/$discovery/rest?version=v1"),
352
+ checkApiReachable("indexing.api", "https://indexing.googleapis.com/$discovery/rest?version=v3"),
353
+ checkApiReachable("siteverification.api", "https://www.googleapis.com/discovery/v1/apis/siteVerification/v1/rest")
354
+ ]);
355
+ const sitesChecks = authResult.liveToken ? await checkGscSites() : [{
356
+ name: "gsc.sites",
357
+ status: "warn",
358
+ detail: "skipped (auth failed)"
359
+ }];
360
+ const all = [
361
+ ...envResult.checks,
362
+ ...authResult.checks,
363
+ ...timeChecks,
364
+ ...dataDirChecks,
365
+ ...watermarkChecks,
366
+ ...gscApi,
367
+ ...indexingApi,
368
+ ...siteVerificationApi,
369
+ ...sitesChecks
370
+ ];
371
+ if (json) {
372
+ console.log(JSON.stringify({
373
+ checks: all,
374
+ ok: all.every((c) => c.status !== "fail")
375
+ }, null, 2));
376
+ return;
377
+ }
378
+ const ICONS = {
379
+ pass: "\x1B[32m✓\x1B[0m",
380
+ warn: "\x1B[33m!\x1B[0m",
381
+ fail: "\x1B[31m✗\x1B[0m",
382
+ info: "\x1B[34mℹ\x1B[0m"
383
+ };
384
+ console.log();
385
+ for (const c of all) {
386
+ const detail = c.detail ? ` \x1B[90m${c.detail}\x1B[0m` : "";
387
+ console.log(` ${ICONS[c.status]} ${c.name}${detail}`);
388
+ }
389
+ console.log();
390
+ const failed = all.filter((c) => c.status === "fail");
391
+ if (failed.length > 0) {
392
+ logger.error(`${failed.length} check(s) failed`);
393
+ process.exit(1);
394
+ }
395
+ const warned = all.filter((c) => c.status === "warn");
396
+ if (warned.length > 0) logger.warn(`${warned.length} warning(s)`);
397
+ else logger.success("All checks passed");
398
+ }
399
+ });
400
+ export { doctorCommand };
@@ -0,0 +1,193 @@
1
+ import { ALL_SEARCH_TYPES, OUTPUT_ARGS, applyOutputMode, displayPath, logger, parseSearchType, toCSV } from "./utils.mjs";
2
+ import { allTables, createCommandContext } from "./context.mjs";
3
+ import { readParquetRows } from "./native-duckdb.mjs";
4
+ import process from "node:process";
5
+ import { defineCommand } from "citty";
6
+ import fs from "node:fs/promises";
7
+ import path from "node:path";
8
+ import { Buffer } from "node:buffer";
9
+ const DEFAULT_OUT = "./gscdump-export";
10
+ const FORMATS = [
11
+ "parquet",
12
+ "json",
13
+ "ndjson",
14
+ "csv"
15
+ ];
16
+ const dumpCommand = defineCommand({
17
+ meta: {
18
+ name: "dump",
19
+ description: "Export live Parquet files from the local store to a directory"
20
+ },
21
+ args: {
22
+ "site": {
23
+ type: "string",
24
+ alias: "s",
25
+ description: "Site URL (e.g., sc-domain:example.com); ignored with --all-sites"
26
+ },
27
+ "out": {
28
+ type: "string",
29
+ alias: "o",
30
+ default: DEFAULT_OUT,
31
+ description: `Output directory (default: ${DEFAULT_OUT})`
32
+ },
33
+ "format": {
34
+ type: "string",
35
+ alias: "F",
36
+ default: "parquet",
37
+ description: `Output format: ${FORMATS.join(", ")} (default: parquet copies raw files)`
38
+ },
39
+ "tables": {
40
+ type: "string",
41
+ alias: "t",
42
+ description: `Comma-separated table list (default: all). Known: ${allTables().join(", ")}`
43
+ },
44
+ "all-sites": {
45
+ type: "boolean",
46
+ default: false,
47
+ description: "Iterate every site with local data"
48
+ },
49
+ "compact": {
50
+ type: "boolean",
51
+ default: false,
52
+ description: "Compact every closed month into a single file before exporting"
53
+ },
54
+ "search-type": {
55
+ type: "string",
56
+ description: `Restrict dump to a single GSC search-type slice (${ALL_SEARCH_TYPES.join(", ")}). Default: all slices.`
57
+ },
58
+ ...OUTPUT_ARGS
59
+ },
60
+ async run({ args }) {
61
+ const { json, quiet } = applyOutputMode(args);
62
+ const format = String(args.format);
63
+ if (!FORMATS.includes(format)) {
64
+ logger.error(`Invalid --format: ${format}. Allowed: ${FORMATS.join(", ")}`);
65
+ process.exit(1);
66
+ }
67
+ const tablesFilter = args.tables ? new Set(String(args.tables).split(",").map((t) => t.trim()).filter(Boolean)) : null;
68
+ const searchType = parseSearchType(args["search-type"]);
69
+ const ctx = await createCommandContext({
70
+ needsAuth: !args["all-sites"],
71
+ needsStore: true
72
+ });
73
+ const store = ctx.store;
74
+ const outDir = path.resolve(String(args.out));
75
+ const targets = args["all-sites"] ? await listSitesWithData(store) : [await ctx.resolveSite(args.site ? String(args.site) : void 0)];
76
+ if (targets.length === 0) {
77
+ logger.warn("No sites with local data. Run `gscdump sync` first.");
78
+ process.exit(0);
79
+ }
80
+ if (args.compact) for (const siteUrl of targets) await compactClosedMonths(store, siteUrl, quiet);
81
+ const summary = [];
82
+ for (const siteUrl of targets) {
83
+ const entries = (await listLiveEntries(store, siteUrl, searchType)).filter((e) => !tablesFilter || tablesFilter.has(e.table));
84
+ if (entries.length === 0) {
85
+ if (!quiet) logger.warn(`No data for ${siteUrl}; skipping`);
86
+ continue;
87
+ }
88
+ if (format === "parquet") {
89
+ const written = await dumpParquet(store, entries, outDir);
90
+ summary.push({
91
+ site: siteUrl,
92
+ files: written,
93
+ rows: 0,
94
+ format,
95
+ outPath: outDir
96
+ });
97
+ } else {
98
+ const written = await dumpRowFormat(store, entries, outDir, siteUrl, format);
99
+ summary.push({
100
+ site: siteUrl,
101
+ files: written.files,
102
+ rows: written.rows,
103
+ format,
104
+ outPath: outDir
105
+ });
106
+ }
107
+ }
108
+ if (json) {
109
+ console.log(JSON.stringify({
110
+ outDir,
111
+ sites: summary
112
+ }, null, 2));
113
+ return;
114
+ }
115
+ for (const s of summary) {
116
+ const rows = s.rows ? `, ${s.rows.toLocaleString()} rows` : "";
117
+ logger.success(`[${s.site}] ${s.files} ${s.format} file(s)${rows} → ${displayPath(s.outPath)}`);
118
+ }
119
+ }
120
+ });
121
+ async function listSitesWithData(store) {
122
+ const siteIds = /* @__PURE__ */ new Set();
123
+ for (const table of allTables()) {
124
+ const entries = await store.engine.listLive({
125
+ userId: store.userId,
126
+ table
127
+ });
128
+ for (const e of entries) if (e.siteId) siteIds.add(e.siteId);
129
+ }
130
+ return Array.from(siteIds);
131
+ }
132
+ async function listLiveEntries(store, siteUrl, searchType) {
133
+ const siteId = store.siteIdFor(siteUrl);
134
+ return (await Promise.all(allTables().map((table) => store.engine.listLive({
135
+ userId: store.userId,
136
+ siteId,
137
+ table,
138
+ ...searchType !== void 0 ? { searchType } : {}
139
+ })))).flat();
140
+ }
141
+ async function dumpParquet(store, entries, outDir) {
142
+ await fs.mkdir(outDir, { recursive: true });
143
+ let copied = 0;
144
+ for (const entry of entries) {
145
+ const bytes = await store.engine.readObject(entry.objectKey);
146
+ const target = path.join(outDir, entry.objectKey);
147
+ await fs.mkdir(path.dirname(target), { recursive: true });
148
+ await fs.writeFile(target, Buffer.from(bytes));
149
+ copied++;
150
+ }
151
+ return copied;
152
+ }
153
+ async function dumpRowFormat(store, entries, outDir, siteUrl, format) {
154
+ const byTable = /* @__PURE__ */ new Map();
155
+ for (const e of entries) {
156
+ const arr = byTable.get(e.table) ?? [];
157
+ arr.push(e);
158
+ byTable.set(e.table, arr);
159
+ }
160
+ const safeSite = siteUrl.replace(/[^a-z0-9]+/gi, "_");
161
+ const siteDir = path.join(outDir, safeSite);
162
+ await fs.mkdir(siteDir, { recursive: true });
163
+ let files = 0;
164
+ let totalRows = 0;
165
+ for (const [table, tableEntries] of byTable) {
166
+ const rows = await readParquetRows(tableEntries.map((e) => path.join(store.dataDir, e.objectKey)), table);
167
+ const ext = format === "csv" ? "csv" : format === "ndjson" ? "ndjson" : "json";
168
+ const target = path.join(siteDir, `${table}.${ext}`);
169
+ let body;
170
+ if (format === "json") body = JSON.stringify(rows, null, 2);
171
+ else if (format === "ndjson") body = rows.map((r) => JSON.stringify(r)).join("\n");
172
+ else body = rows.length > 0 ? toCSV(rows, Object.keys(rows[0])) : "";
173
+ await fs.writeFile(target, body);
174
+ files++;
175
+ totalRows += rows.length;
176
+ }
177
+ return {
178
+ files,
179
+ rows: totalRows
180
+ };
181
+ }
182
+ async function compactClosedMonths(store, siteUrl, quiet) {
183
+ const siteId = store.siteIdFor(siteUrl);
184
+ for (const table of allTables()) {
185
+ if (!quiet) logger.info(`Compacting ${table} (raw→d7→d30→d90)`);
186
+ await store.engine.compactTiered({
187
+ userId: store.userId,
188
+ siteId,
189
+ table
190
+ });
191
+ }
192
+ }
193
+ export { dumpCommand };