@reddoorla/maintenance 0.50.0 → 0.52.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli/bin.js CHANGED
@@ -87,6 +87,38 @@ var init_url = __esm({
87
87
  }
88
88
  });
89
89
 
90
+ // src/reports/airtable/throttle.ts
91
+ function createMinIntervalThrottle(opts) {
92
+ const { minIntervalMs, now, delay } = opts;
93
+ return function wrap(fn) {
94
+ let chain = Promise.resolve();
95
+ let last = Number.NEGATIVE_INFINITY;
96
+ return (...args) => {
97
+ chain = chain.then(async () => {
98
+ const wait = minIntervalMs - (now() - last);
99
+ if (wait > 0) await delay(wait);
100
+ last = now();
101
+ fn(...args);
102
+ }).catch(() => {
103
+ });
104
+ };
105
+ };
106
+ }
107
+ function applyThrottle(base, opts) {
108
+ const real = base._base?.runAction;
109
+ if (typeof real !== "function") return base;
110
+ const wrap = createMinIntervalThrottle(opts);
111
+ const throttled = wrap(real.bind(base._base));
112
+ base._base.runAction = throttled;
113
+ base.runAction = throttled;
114
+ return base;
115
+ }
116
+ var init_throttle = __esm({
117
+ "src/reports/airtable/throttle.ts"() {
118
+ "use strict";
119
+ }
120
+ });
121
+
90
122
  // src/reports/airtable/client.ts
91
123
  var client_exports = {};
92
124
  __export(client_exports, {
@@ -110,12 +142,20 @@ function readAirtableConfig() {
110
142
  return { apiKey, baseId };
111
143
  }
112
144
  function openBase(cfg) {
113
- return new Airtable({ apiKey: cfg.apiKey }).base(cfg.baseId);
145
+ const base = new Airtable({ apiKey: cfg.apiKey }).base(cfg.baseId);
146
+ return applyThrottle(base, {
147
+ minIntervalMs: MIN_REQUEST_INTERVAL_MS,
148
+ now: () => Date.now(),
149
+ delay: (ms) => new Promise((resolve12) => setTimeout(resolve12, ms))
150
+ });
114
151
  }
152
+ var MIN_REQUEST_INTERVAL_MS;
115
153
  var init_client = __esm({
116
154
  "src/reports/airtable/client.ts"() {
117
155
  "use strict";
118
156
  init_credentials();
157
+ init_throttle();
158
+ MIN_REQUEST_INTERVAL_MS = 220;
119
159
  }
120
160
  });
121
161
 
@@ -123,12 +163,15 @@ var init_client = __esm({
123
163
  var websites_exports = {};
124
164
  __export(websites_exports, {
125
165
  ACTIVE_STATUSES: () => ACTIVE_STATUSES,
166
+ SEVERITY_RANK: () => SEVERITY_RANK,
126
167
  WEBSITES_TABLE: () => WEBSITES_TABLE,
127
168
  getWebsiteBySlug: () => getWebsiteBySlug,
128
169
  isDashboardVisible: () => isDashboardVisible,
129
170
  listWebsites: () => listWebsites,
130
171
  mapRow: () => mapRow,
172
+ normalizeSecurityAdvisory: () => normalizeSecurityAdvisory,
131
173
  parseNotifyRouting: () => parseNotifyRouting,
174
+ parseSecurityAdvisories: () => parseSecurityAdvisories,
132
175
  siteSlug: () => siteSlug,
133
176
  updateA11yCounts: () => updateA11yCounts,
134
177
  updateAuditFields: () => updateAuditFields,
@@ -209,6 +252,7 @@ function mapRow(rec) {
209
252
  securityVulnsModerate: f["Security Vulns Moderate"] ?? null,
210
253
  securityVulnsLow: f["Security Vulns Low"] ?? null,
211
254
  lastSecurityAuditAt: f["Last security audit at"] ?? null,
255
+ securityAdvisories: parseSecurityAdvisories(f["Security advisories"]),
212
256
  certDaysRemaining: f["Cert days remaining"] ?? null,
213
257
  domainCheckedAt: f["Domain checked at"] ?? null,
214
258
  crossbrowserOk: typeof f["Crossbrowser OK"] === "boolean" ? f["Crossbrowser OK"] : null,
@@ -272,6 +316,38 @@ function depsFields(counts) {
272
316
  }
273
317
  return fields;
274
318
  }
319
+ function normalizeSecurityAdvisory(raw) {
320
+ if (!raw || typeof raw !== "object") return null;
321
+ const e = raw;
322
+ const module = typeof e["module"] === "string" ? e["module"] : null;
323
+ const severity = e["severity"];
324
+ if (module === null) return null;
325
+ if (severity !== "low" && severity !== "moderate" && severity !== "high" && severity !== "critical")
326
+ return null;
327
+ const cves = Array.isArray(e["cves"]) ? e["cves"].filter((c) => typeof c === "string") : [];
328
+ return {
329
+ module,
330
+ severity,
331
+ title: typeof e["title"] === "string" ? e["title"] : "",
332
+ cves,
333
+ url: typeof e["url"] === "string" ? e["url"] : null
334
+ };
335
+ }
336
+ function parseSecurityAdvisories(raw) {
337
+ if (typeof raw !== "string" || raw.trim() === "") return null;
338
+ let parsed;
339
+ try {
340
+ parsed = JSON.parse(raw);
341
+ } catch {
342
+ return null;
343
+ }
344
+ if (!Array.isArray(parsed)) return null;
345
+ return parsed.map(normalizeSecurityAdvisory).filter((a) => a !== null);
346
+ }
347
+ function securityAdvisoryFields(advisories) {
348
+ const capped = [...advisories].sort((a, b) => SEVERITY_RANK[a.severity] - SEVERITY_RANK[b.severity]).slice(0, MAX_PERSISTED_ADVISORIES);
349
+ return { "Security advisories": JSON.stringify(capped) };
350
+ }
275
351
  function securityFields(counts) {
276
352
  return {
277
353
  "Security Vulns Critical": counts.critical,
@@ -315,6 +391,8 @@ async function updateAuditFields(base, recordId, audits) {
315
391
  if (audits.a11y) Object.assign(fields, a11yFields(audits.a11y));
316
392
  if (audits.deps) Object.assign(fields, depsFields(audits.deps));
317
393
  if (audits.security) Object.assign(fields, securityFields(audits.security));
394
+ if (audits.securityAdvisories)
395
+ Object.assign(fields, securityAdvisoryFields(audits.securityAdvisories));
318
396
  if (audits.domain) Object.assign(fields, domainFields(audits.domain));
319
397
  if (audits.browser) Object.assign(fields, browserFields(audits.browser));
320
398
  await base(WEBSITES_TABLE).update([{ id: recordId, fields }]);
@@ -335,7 +413,7 @@ async function updateLaunched(base, recordId, at) {
335
413
  const fields = { Status: "maintenance", "Launched at": at };
336
414
  await base(WEBSITES_TABLE).update([{ id: recordId, fields }]);
337
415
  }
338
- var WEBSITES_TABLE, ACTIVE_STATUSES, FREQUENCIES;
416
+ var WEBSITES_TABLE, ACTIVE_STATUSES, FREQUENCIES, SEVERITY_RANK, MAX_PERSISTED_ADVISORIES;
339
417
  var init_websites = __esm({
340
418
  "src/reports/airtable/websites.ts"() {
341
419
  "use strict";
@@ -345,6 +423,13 @@ var init_websites = __esm({
345
423
  "launch period"
346
424
  ]);
347
425
  FREQUENCIES = ["None", "Monthly", "Quarterly", "Yearly"];
426
+ SEVERITY_RANK = {
427
+ critical: 0,
428
+ high: 1,
429
+ moderate: 2,
430
+ low: 3
431
+ };
432
+ MAX_PERSISTED_ADVISORIES = 25;
348
433
  }
349
434
  });
350
435
 
@@ -505,9 +590,19 @@ function securityCountsFromResult(result) {
505
590
  const c = details?.counts ?? { low: 0, moderate: 0, high: 0, critical: 0 };
506
591
  return { critical: c.critical, high: c.high, moderate: c.moderate, low: c.low };
507
592
  }
593
+ function advisoriesFromResult(result) {
594
+ if (result.audit !== "security") {
595
+ throw new Error(`Expected a 'security' AuditResult, got '${result.audit}'`);
596
+ }
597
+ const details = result.details;
598
+ const raw = details?.advisories;
599
+ if (!Array.isArray(raw)) return [];
600
+ return raw.map(normalizeSecurityAdvisory).filter((a) => a !== null);
601
+ }
508
602
  var init_security_airtable = __esm({
509
603
  "src/audits/security-airtable.ts"() {
510
604
  "use strict";
605
+ init_websites();
511
606
  }
512
607
  });
513
608
 
@@ -596,6 +691,7 @@ async function writeAuditsToAirtable(args) {
596
691
  if (sec && hasSecurityCounts(sec)) {
597
692
  const counts = securityCountsFromResult(sec);
598
693
  audits.security = counts;
694
+ audits.securityAdvisories = advisoriesFromResult(sec);
599
695
  writes.push({ audit: "security", counts });
600
696
  }
601
697
  const dom = results.find((r) => r.audit === "domain");
@@ -2237,6 +2333,366 @@ var init_orchestrate = __esm({
2237
2333
  }
2238
2334
  });
2239
2335
 
2336
+ // src/db/migrations.ts
2337
+ var MIGRATIONS;
2338
+ var init_migrations = __esm({
2339
+ "src/db/migrations.ts"() {
2340
+ "use strict";
2341
+ MIGRATIONS = [
2342
+ {
2343
+ id: "0001_init",
2344
+ sql: `
2345
+ CREATE TABLE IF NOT EXISTS submissions (
2346
+ id TEXT PRIMARY KEY,
2347
+ submission_id INTEGER,
2348
+ site_id TEXT NOT NULL,
2349
+ form_type TEXT NOT NULL,
2350
+ name TEXT NOT NULL,
2351
+ email TEXT NOT NULL,
2352
+ phone TEXT,
2353
+ message TEXT,
2354
+ extra_fields TEXT,
2355
+ source_url TEXT,
2356
+ utm TEXT,
2357
+ submitted_at TEXT,
2358
+ status TEXT NOT NULL DEFAULT 'new',
2359
+ notify_status TEXT NOT NULL DEFAULT 'skipped',
2360
+ resend_message_id TEXT
2361
+ );
2362
+ CREATE INDEX IF NOT EXISTS idx_submissions_site_submitted
2363
+ ON submissions (site_id, submitted_at DESC);
2364
+ CREATE INDEX IF NOT EXISTS idx_submissions_status
2365
+ ON submissions (status);
2366
+ CREATE TABLE IF NOT EXISTS spam_screenouts (
2367
+ site_id TEXT NOT NULL,
2368
+ date TEXT NOT NULL,
2369
+ honeypot INTEGER NOT NULL DEFAULT 0,
2370
+ too_fast INTEGER NOT NULL DEFAULT 0,
2371
+ marked_spam INTEGER NOT NULL DEFAULT 0,
2372
+ PRIMARY KEY (site_id, date)
2373
+ );
2374
+ `
2375
+ }
2376
+ ];
2377
+ }
2378
+ });
2379
+
2380
+ // src/db/migrate.ts
2381
+ var migrate_exports = {};
2382
+ __export(migrate_exports, {
2383
+ runMigrations: () => runMigrations
2384
+ });
2385
+ async function runMigrations(client) {
2386
+ await client.execute(
2387
+ "CREATE TABLE IF NOT EXISTS _migrations (id TEXT PRIMARY KEY, applied_at TEXT NOT NULL)"
2388
+ );
2389
+ const existing = await client.execute("SELECT id FROM _migrations");
2390
+ const applied = new Set(existing.rows.map((r) => String(r.id)));
2391
+ const ran = [];
2392
+ for (const m of MIGRATIONS) {
2393
+ if (applied.has(m.id)) continue;
2394
+ await client.executeMultiple(m.sql);
2395
+ await client.execute({
2396
+ sql: "INSERT INTO _migrations (id, applied_at) VALUES (?, ?)",
2397
+ args: [m.id, (/* @__PURE__ */ new Date()).toISOString()]
2398
+ });
2399
+ ran.push(m.id);
2400
+ }
2401
+ return ran;
2402
+ }
2403
+ var init_migrate = __esm({
2404
+ "src/db/migrate.ts"() {
2405
+ "use strict";
2406
+ init_migrations();
2407
+ }
2408
+ });
2409
+
2410
+ // src/db/client.ts
2411
+ var client_exports2 = {};
2412
+ __export(client_exports2, {
2413
+ openDb: () => openDb,
2414
+ readDbConfig: () => readDbConfig
2415
+ });
2416
+ import { createClient } from "@libsql/client";
2417
+ import { Kysely } from "kysely";
2418
+ import { LibsqlDialect } from "@libsql/kysely-libsql";
2419
+ function missing2(name) {
2420
+ return Object.assign(
2421
+ new Error(
2422
+ `${name} not set. Export it in your shell or put it in ${defaultCredentialsPath()} as ${name}=...`
2423
+ ),
2424
+ { exitCode: 2 }
2425
+ );
2426
+ }
2427
+ function readDbConfig() {
2428
+ const url = process.env.TURSO_DATABASE_URL;
2429
+ if (!url) throw missing2("TURSO_DATABASE_URL");
2430
+ const authToken = process.env.TURSO_AUTH_TOKEN;
2431
+ return authToken ? { url, authToken } : { url };
2432
+ }
2433
+ async function openDb(cfg) {
2434
+ const clientConfig = cfg.authToken ? { url: cfg.url, authToken: cfg.authToken } : { url: cfg.url };
2435
+ const client = createClient(clientConfig);
2436
+ await runMigrations(client);
2437
+ return new Kysely({ dialect: new LibsqlDialect({ client }) });
2438
+ }
2439
+ var init_client2 = __esm({
2440
+ "src/db/client.ts"() {
2441
+ "use strict";
2442
+ init_credentials();
2443
+ init_migrate();
2444
+ }
2445
+ });
2446
+
2447
+ // src/forms/types.ts
2448
+ var SUBMISSION_FORM_TYPES;
2449
+ var init_types = __esm({
2450
+ "src/forms/types.ts"() {
2451
+ "use strict";
2452
+ SUBMISSION_FORM_TYPES = [
2453
+ "contact",
2454
+ "inquiry",
2455
+ "newsletter",
2456
+ "rsvp",
2457
+ "reserve"
2458
+ ];
2459
+ }
2460
+ });
2461
+
2462
+ // src/reports/submission-row.ts
2463
+ function toFormType(raw) {
2464
+ if (raw && SUBMISSION_FORM_TYPES.includes(raw)) return raw;
2465
+ if (raw)
2466
+ console.warn(`[submissions] unknown Form type ${JSON.stringify(raw)} \u2014 treating as contact`);
2467
+ return "contact";
2468
+ }
2469
+ function toStatus(raw) {
2470
+ if (raw && SUBMISSION_STATUSES.includes(raw))
2471
+ return raw;
2472
+ return "new";
2473
+ }
2474
+ function toNotifyStatus(raw) {
2475
+ if (raw && NOTIFY_STATUSES.includes(raw)) return raw;
2476
+ return "skipped";
2477
+ }
2478
+ var SUBMISSION_STATUSES, NOTIFY_STATUSES;
2479
+ var init_submission_row = __esm({
2480
+ "src/reports/submission-row.ts"() {
2481
+ "use strict";
2482
+ init_types();
2483
+ SUBMISSION_STATUSES = ["new", "read", "archived", "spam"];
2484
+ NOTIFY_STATUSES = ["sent", "failed", "skipped"];
2485
+ }
2486
+ });
2487
+
2488
+ // src/reports/airtable/submissions.ts
2489
+ function mapRow3(rec) {
2490
+ const f = rec.fields;
2491
+ const linkSites = f["Site"] ?? [];
2492
+ return {
2493
+ id: rec.id,
2494
+ submissionId: typeof f["Submission ID"] === "number" ? f["Submission ID"] : null,
2495
+ siteId: linkSites[0] ?? "",
2496
+ formType: toFormType(f["Form type"]),
2497
+ name: String(f["Name"] ?? ""),
2498
+ email: String(f["Email"] ?? ""),
2499
+ phone: f["Phone"] ?? null,
2500
+ message: f["Message"] ?? null,
2501
+ extraFields: f["Extra fields"] ?? null,
2502
+ sourceUrl: f["Source URL"] ?? null,
2503
+ utm: f["UTM"] ?? null,
2504
+ submittedAt: f["Submitted at"] ?? null,
2505
+ status: toStatus(f["Status"]),
2506
+ notifyStatus: toNotifyStatus(f["Notify status"]),
2507
+ resendMessageId: f["Resend message ID"] ?? null
2508
+ };
2509
+ }
2510
+ var SUBMISSIONS_TABLE;
2511
+ var init_submissions = __esm({
2512
+ "src/reports/airtable/submissions.ts"() {
2513
+ "use strict";
2514
+ init_reports();
2515
+ init_submission_row();
2516
+ SUBMISSIONS_TABLE = "Submissions";
2517
+ }
2518
+ });
2519
+
2520
+ // src/reports/airtable/screenouts.ts
2521
+ function num(v) {
2522
+ return typeof v === "number" && Number.isFinite(v) ? v : 0;
2523
+ }
2524
+ function siteIdOf(fields) {
2525
+ const link = fields["Site"];
2526
+ return link?.[0] ?? "";
2527
+ }
2528
+ async function listScreenOutsSince(base, sinceDate) {
2529
+ const out = /* @__PURE__ */ new Map();
2530
+ await base(SCREENOUTS_TABLE).select({ filterByFormula: `{Date} >= ${JSON.stringify(sinceDate)}`, pageSize: 100 }).eachPage((records, fetchNextPage) => {
2531
+ for (const rec of records) {
2532
+ const f = rec.fields;
2533
+ const date = typeof f["Date"] === "string" ? f["Date"] : "";
2534
+ if (date < sinceDate) continue;
2535
+ const siteId = siteIdOf(f);
2536
+ if (!siteId) continue;
2537
+ const cur = out.get(siteId) ?? { honeypot: 0, tooFast: 0, markedSpam: 0 };
2538
+ cur.honeypot += num(f["Honeypot"]);
2539
+ cur.tooFast += num(f["Too-fast"]);
2540
+ cur.markedSpam += num(f["Marked spam"]);
2541
+ out.set(siteId, cur);
2542
+ }
2543
+ fetchNextPage();
2544
+ });
2545
+ return out;
2546
+ }
2547
+ var SCREENOUTS_TABLE;
2548
+ var init_screenouts = __esm({
2549
+ "src/reports/airtable/screenouts.ts"() {
2550
+ "use strict";
2551
+ SCREENOUTS_TABLE = "Spam Screenouts";
2552
+ }
2553
+ });
2554
+
2555
+ // src/db/submissions.ts
2556
+ import { sql } from "kysely";
2557
+ async function backfillSubmission(db, row) {
2558
+ await db.insertInto("submissions").values({
2559
+ id: row.id,
2560
+ submission_id: row.submissionId,
2561
+ site_id: row.siteId,
2562
+ form_type: row.formType,
2563
+ name: row.name,
2564
+ email: row.email,
2565
+ phone: row.phone,
2566
+ message: row.message,
2567
+ extra_fields: row.extraFields,
2568
+ source_url: row.sourceUrl,
2569
+ utm: row.utm,
2570
+ submitted_at: row.submittedAt,
2571
+ status: row.status,
2572
+ notify_status: row.notifyStatus,
2573
+ resend_message_id: row.resendMessageId
2574
+ }).onConflict((oc) => oc.column("id").doNothing()).execute();
2575
+ }
2576
+ var init_submissions2 = __esm({
2577
+ "src/db/submissions.ts"() {
2578
+ "use strict";
2579
+ init_submission_row();
2580
+ }
2581
+ });
2582
+
2583
+ // src/db/screenouts.ts
2584
+ import { sql as sql2 } from "kysely";
2585
+ async function listScreenOutsSince2(db, sinceDate) {
2586
+ const rows = await db.selectFrom("spam_screenouts").select((eb) => [
2587
+ "site_id",
2588
+ eb.fn.sum("honeypot").as("honeypot"),
2589
+ eb.fn.sum("too_fast").as("too_fast"),
2590
+ eb.fn.sum("marked_spam").as("marked_spam")
2591
+ ]).where("date", ">=", sinceDate).groupBy("site_id").execute();
2592
+ const out = /* @__PURE__ */ new Map();
2593
+ for (const r of rows) {
2594
+ out.set(r.site_id, {
2595
+ honeypot: Number(r.honeypot) || 0,
2596
+ tooFast: Number(r.too_fast) || 0,
2597
+ markedSpam: Number(r.marked_spam) || 0
2598
+ });
2599
+ }
2600
+ return out;
2601
+ }
2602
+ async function backfillScreenoutBucket(db, b) {
2603
+ await sql2`
2604
+ INSERT INTO spam_screenouts (site_id, date, honeypot, too_fast, marked_spam)
2605
+ VALUES (${b.siteId}, ${b.date}, ${b.honeypot}, ${b.tooFast}, ${b.markedSpam})
2606
+ ON CONFLICT (site_id, date) DO UPDATE SET
2607
+ honeypot = excluded.honeypot,
2608
+ too_fast = excluded.too_fast,
2609
+ marked_spam = excluded.marked_spam
2610
+ `.execute(db);
2611
+ }
2612
+ var init_screenouts2 = __esm({
2613
+ "src/db/screenouts.ts"() {
2614
+ "use strict";
2615
+ }
2616
+ });
2617
+
2618
+ // src/db/backfill.ts
2619
+ var backfill_exports = {};
2620
+ __export(backfill_exports, {
2621
+ backfillScreenouts: () => backfillScreenouts,
2622
+ backfillSubmissions: () => backfillSubmissions,
2623
+ reconcile: () => reconcile
2624
+ });
2625
+ import { sql as sql3 } from "kysely";
2626
+ async function backfillSubmissions(base, db) {
2627
+ const rows = [];
2628
+ await base(SUBMISSIONS_TABLE).select({ pageSize: 100 }).eachPage((records, fetchNextPage) => {
2629
+ for (const rec of records) rows.push(mapRow3({ id: rec.id, fields: rec.fields }));
2630
+ fetchNextPage();
2631
+ });
2632
+ for (const row of rows) await backfillSubmission(db, row);
2633
+ return rows.length;
2634
+ }
2635
+ function num2(v) {
2636
+ return typeof v === "number" && Number.isFinite(v) ? v : 0;
2637
+ }
2638
+ async function backfillScreenouts(base, db) {
2639
+ const agg = /* @__PURE__ */ new Map();
2640
+ await base(SCREENOUTS_TABLE).select({ pageSize: 100 }).eachPage((records, fetchNextPage) => {
2641
+ for (const rec of records) {
2642
+ const f = rec.fields;
2643
+ const siteId = f["Site"]?.[0] ?? "";
2644
+ const date = typeof f["Date"] === "string" ? f["Date"] : "";
2645
+ if (!siteId || !date) continue;
2646
+ const key = `${siteId} ${date}`;
2647
+ const cur = agg.get(key) ?? { siteId, date, honeypot: 0, tooFast: 0, markedSpam: 0 };
2648
+ cur.honeypot += num2(f["Honeypot"]);
2649
+ cur.tooFast += num2(f["Too-fast"]);
2650
+ cur.markedSpam += num2(f["Marked spam"]);
2651
+ agg.set(key, cur);
2652
+ }
2653
+ fetchNextPage();
2654
+ });
2655
+ for (const bucket of agg.values()) await backfillScreenoutBucket(db, bucket);
2656
+ return agg.size;
2657
+ }
2658
+ async function reconcile(base, db) {
2659
+ let airtableSubs = 0;
2660
+ await base(SUBMISSIONS_TABLE).select({ pageSize: 100, fields: [] }).eachPage((records, fetchNextPage) => {
2661
+ airtableSubs += records.length;
2662
+ fetchNextPage();
2663
+ });
2664
+ const libCountRow = await sql3`SELECT COUNT(*) AS n FROM submissions`.execute(db);
2665
+ const libsqlSubs = Number(libCountRow.rows[0]?.n ?? 0);
2666
+ const aMap = await listScreenOutsSince(base, "0001-01-01");
2667
+ const lMap = await listScreenOutsSince2(db, "0001-01-01");
2668
+ const sumOf = (m) => {
2669
+ const t = { honeypot: 0, tooFast: 0, markedSpam: 0 };
2670
+ for (const v of m.values()) {
2671
+ t.honeypot += v.honeypot;
2672
+ t.tooFast += v.tooFast;
2673
+ t.markedSpam += v.markedSpam;
2674
+ }
2675
+ return t;
2676
+ };
2677
+ const aScreen = sumOf(aMap);
2678
+ const lScreen = sumOf(lMap);
2679
+ const ok = airtableSubs === libsqlSubs && aScreen.honeypot === lScreen.honeypot && aScreen.tooFast === lScreen.tooFast && aScreen.markedSpam === lScreen.markedSpam;
2680
+ return {
2681
+ ok,
2682
+ submissions: { airtable: airtableSubs, libsql: libsqlSubs },
2683
+ screenouts: { airtable: aScreen, libsql: lScreen }
2684
+ };
2685
+ }
2686
+ var init_backfill = __esm({
2687
+ "src/db/backfill.ts"() {
2688
+ "use strict";
2689
+ init_submissions();
2690
+ init_screenouts();
2691
+ init_submissions2();
2692
+ init_screenouts2();
2693
+ }
2694
+ });
2695
+
2240
2696
  // src/cli/bin.ts
2241
2697
  init_credentials();
2242
2698
  import { dirname as dirname9 } from "path";
@@ -7400,6 +7856,52 @@ async function runGitHubSignalsCommand(opts) {
7400
7856
  };
7401
7857
  }
7402
7858
 
7859
+ // src/cli/commands/db.ts
7860
+ async function runDbCommand(action, opts) {
7861
+ if (action === "migrate") {
7862
+ const { readDbConfig: readDbConfig2 } = await Promise.resolve().then(() => (init_client2(), client_exports2));
7863
+ const cfg = opts.url ? { url: opts.url } : readDbConfig2();
7864
+ const { runMigrations: runMigrations2 } = await Promise.resolve().then(() => (init_migrate(), migrate_exports));
7865
+ const { createClient: createClient2 } = await import("@libsql/client");
7866
+ const client = createClient2(cfg.url === ":memory:" ? { url: ":memory:" } : cfg);
7867
+ const ran = await runMigrations2(client);
7868
+ return {
7869
+ output: ran.length ? `Applied migrations: ${ran.join(", ")}` : "Already up to date.",
7870
+ code: 0
7871
+ };
7872
+ }
7873
+ if (action === "backfill") {
7874
+ const { openDb: openDb2, readDbConfig: readDbConfig2 } = await Promise.resolve().then(() => (init_client2(), client_exports2));
7875
+ const cfg = opts.url ? { url: opts.url } : readDbConfig2();
7876
+ const { openBase: openBase2, readAirtableConfig: readAirtableConfig2 } = await Promise.resolve().then(() => (init_client(), client_exports));
7877
+ const { backfillSubmissions: backfillSubmissions2, backfillScreenouts: backfillScreenouts2 } = await Promise.resolve().then(() => (init_backfill(), backfill_exports));
7878
+ const base = openBase2(readAirtableConfig2());
7879
+ const db = await openDb2(cfg);
7880
+ const subs = await backfillSubmissions2(base, db);
7881
+ const buckets = await backfillScreenouts2(base, db);
7882
+ return { output: `Backfilled ${subs} submissions, ${buckets} screen-out buckets.`, code: 0 };
7883
+ }
7884
+ if (action === "reconcile") {
7885
+ const { openDb: openDb2, readDbConfig: readDbConfig2 } = await Promise.resolve().then(() => (init_client2(), client_exports2));
7886
+ const cfg = opts.url ? { url: opts.url } : readDbConfig2();
7887
+ const { openBase: openBase2, readAirtableConfig: readAirtableConfig2 } = await Promise.resolve().then(() => (init_client(), client_exports));
7888
+ const { reconcile: reconcile2 } = await Promise.resolve().then(() => (init_backfill(), backfill_exports));
7889
+ const base = openBase2(readAirtableConfig2());
7890
+ const db = await openDb2(cfg);
7891
+ const r = await reconcile2(base, db);
7892
+ const lines = [
7893
+ `submissions: airtable=${r.submissions.airtable} libsql=${r.submissions.libsql}`,
7894
+ `screenouts: airtable=${JSON.stringify(r.screenouts.airtable)} libsql=${JSON.stringify(r.screenouts.libsql)}`,
7895
+ r.ok ? "OK \u2014 parity confirmed." : "MISMATCH \u2014 do not cut over."
7896
+ ];
7897
+ return { output: lines.join("\n"), code: r.ok ? 0 : 1 };
7898
+ }
7899
+ return {
7900
+ output: `unknown db action '${action}'. Use: migrate | backfill | reconcile.`,
7901
+ code: 1
7902
+ };
7903
+ }
7904
+
7403
7905
  // src/cli/version.ts
7404
7906
  import { readFileSync as readFileSync5, existsSync as existsSync4 } from "fs";
7405
7907
  import { dirname as dirname8, join as join28 } from "path";
@@ -7578,6 +8080,12 @@ cli.command(
7578
8080
  opts
7579
8081
  )
7580
8082
  );
8083
+ cli.command(
8084
+ "db <action>",
8085
+ "Migrate / backfill / reconcile the libSQL store (migrate | backfill | reconcile)."
8086
+ ).action(
8087
+ async (action, opts) => runOrExit(() => runDbCommand(action, opts), opts)
8088
+ );
7581
8089
  cli.help();
7582
8090
  cli.version(version);
7583
8091
  cli.on("command:*", () => {