mcp-scraper 0.35.1 → 0.37.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 (90) hide show
  1. package/README.md +9 -9
  2. package/dist/bin/api-server.cjs +25071 -19082
  3. package/dist/bin/api-server.cjs.map +1 -1
  4. package/dist/bin/api-server.js +3 -3
  5. package/dist/bin/mcp-scraper-cli.cjs +51 -7
  6. package/dist/bin/mcp-scraper-cli.cjs.map +1 -1
  7. package/dist/bin/mcp-scraper-cli.js +48 -5
  8. package/dist/bin/mcp-scraper-cli.js.map +1 -1
  9. package/dist/bin/mcp-scraper-install.cjs +2 -2
  10. package/dist/bin/mcp-scraper-install.cjs.map +1 -1
  11. package/dist/bin/mcp-scraper-install.js +2 -2
  12. package/dist/bin/mcp-stdio-server.cjs +995 -222
  13. package/dist/bin/mcp-stdio-server.cjs.map +1 -1
  14. package/dist/bin/mcp-stdio-server.js +8 -8
  15. package/dist/bin/paa-harvest.cjs +125 -70
  16. package/dist/bin/paa-harvest.cjs.map +1 -1
  17. package/dist/bin/paa-harvest.js +4 -4
  18. package/dist/chunk-345BQXZH.js +712 -0
  19. package/dist/chunk-345BQXZH.js.map +1 -0
  20. package/dist/chunk-3LWYPAU5.js +7 -0
  21. package/dist/chunk-3LWYPAU5.js.map +1 -0
  22. package/dist/{chunk-M2S27J6Z.js → chunk-44HZLHDV.js} +10 -1
  23. package/dist/chunk-44HZLHDV.js.map +1 -0
  24. package/dist/{chunk-62DQAWPF.js → chunk-CSCD2HNS.js} +498 -43
  25. package/dist/chunk-CSCD2HNS.js.map +1 -0
  26. package/dist/{chunk-ZID3WQID.js → chunk-FQI5PFE7.js} +9 -71
  27. package/dist/chunk-FQI5PFE7.js.map +1 -0
  28. package/dist/{chunk-3HBPKR5G.js → chunk-FSAXLDB3.js} +3 -3
  29. package/dist/chunk-G3P3ZDB4.js +69 -0
  30. package/dist/chunk-G3P3ZDB4.js.map +1 -0
  31. package/dist/{chunk-YRGSEY5L.js → chunk-G7KAVJ3F.js} +2 -2
  32. package/dist/{chunk-YRGSEY5L.js.map → chunk-G7KAVJ3F.js.map} +1 -1
  33. package/dist/{chunk-NPMW5HUS.js → chunk-JK2FRDAP.js} +930 -244
  34. package/dist/chunk-JK2FRDAP.js.map +1 -0
  35. package/dist/{chunk-XVVNKASZ.js → chunk-LOPKN3YL.js} +118 -73
  36. package/dist/chunk-LOPKN3YL.js.map +1 -0
  37. package/dist/{chunk-4ZB3X6BQ.js → chunk-MA5JBAUZ.js} +16 -2
  38. package/dist/{chunk-4ZB3X6BQ.js.map → chunk-MA5JBAUZ.js.map} +1 -1
  39. package/dist/chunk-PUJFYJXB.js +684 -0
  40. package/dist/chunk-PUJFYJXB.js.map +1 -0
  41. package/dist/{chunk-BWXLTWF7.js → chunk-PWPUKR5U.js} +9 -5
  42. package/dist/chunk-PWPUKR5U.js.map +1 -0
  43. package/dist/chunk-Q35WZJJK.js +499 -0
  44. package/dist/chunk-Q35WZJJK.js.map +1 -0
  45. package/dist/chunk-QZXKQB7Y.js +414 -0
  46. package/dist/chunk-QZXKQB7Y.js.map +1 -0
  47. package/dist/{db-YAI5AQOI.js → db-N3YECFWR.js} +12 -2
  48. package/dist/{extract-bundle-ONWZVV55.js → extract-bundle-346R6MXD.js} +284 -98
  49. package/dist/extract-bundle-346R6MXD.js.map +1 -0
  50. package/dist/index.cjs +129 -70
  51. package/dist/index.cjs.map +1 -1
  52. package/dist/index.d.cts +11 -0
  53. package/dist/index.d.ts +11 -0
  54. package/dist/index.js +4 -4
  55. package/dist/location-data-repository-O2VII3ON.js +35 -0
  56. package/dist/{server-GKUTC73B.js → server-QKDDEBVQ.js} +7325 -3762
  57. package/dist/server-QKDDEBVQ.js.map +1 -0
  58. package/dist/site-extract-repository-I3VM6WXN.js +62 -0
  59. package/dist/site-extract-repository-I3VM6WXN.js.map +1 -0
  60. package/dist/{worker-645BZPEK.js → worker-TTFXPPDK.js} +7 -7
  61. package/docs/hosted-location-data.md +108 -0
  62. package/docs/mcp-tool-craft-lint.generated.md +6 -3
  63. package/docs/mcp-tool-manifest.generated.json +1447 -240
  64. package/docs/mcp-tool-quality-spec.md +1 -1
  65. package/docs/specs/connected-services-control-plane-decoupling-spec.md +1044 -0
  66. package/docs/specs/kernel-stealth-captcha-test-matrix.md +278 -0
  67. package/docs/specs/multimodal-image-memory-architecture-spec.md +1022 -0
  68. package/docs/specs/unified-credit-and-scheduled-execution-billing-spec.md +36 -27
  69. package/package.json +7 -6
  70. package/dist/chunk-62DQAWPF.js.map +0 -1
  71. package/dist/chunk-BWXLTWF7.js.map +0 -1
  72. package/dist/chunk-M2S27J6Z.js.map +0 -1
  73. package/dist/chunk-NPMW5HUS.js.map +0 -1
  74. package/dist/chunk-R7EETU7Z.js +0 -419
  75. package/dist/chunk-R7EETU7Z.js.map +0 -1
  76. package/dist/chunk-U44TPRST.js +0 -130
  77. package/dist/chunk-U44TPRST.js.map +0 -1
  78. package/dist/chunk-XVVNKASZ.js.map +0 -1
  79. package/dist/chunk-YR4LJ6AQ.js +0 -7
  80. package/dist/chunk-YR4LJ6AQ.js.map +0 -1
  81. package/dist/chunk-YV2FUEBX.js +0 -851
  82. package/dist/chunk-YV2FUEBX.js.map +0 -1
  83. package/dist/chunk-ZID3WQID.js.map +0 -1
  84. package/dist/extract-bundle-ONWZVV55.js.map +0 -1
  85. package/dist/server-GKUTC73B.js.map +0 -1
  86. package/dist/site-extract-repository-L6BHWVDU.js +0 -30
  87. /package/dist/{chunk-3HBPKR5G.js.map → chunk-FSAXLDB3.js.map} +0 -0
  88. /package/dist/{db-YAI5AQOI.js.map → db-N3YECFWR.js.map} +0 -0
  89. /package/dist/{site-extract-repository-L6BHWVDU.js.map → location-data-repository-O2VII3ON.js.map} +0 -0
  90. /package/dist/{worker-645BZPEK.js.map → worker-TTFXPPDK.js.map} +0 -0
@@ -6,12 +6,17 @@ var DB_URL = process.env.TURSO_DATABASE_URL ?? "file:./paa-api.db";
6
6
  var DB_TOKEN = process.env.TURSO_AUTH_TOKEN;
7
7
  var _db = null;
8
8
  var _rateLimitSchemaReady = false;
9
- var CORE_SCHEMA_VERSION = "2026-07-24.2";
9
+ var CORE_SCHEMA_VERSION = "2026-07-28.1";
10
10
  var CORE_SCHEMA_TABLES = [
11
+ "admin_credit_adjustment_events",
12
+ "admin_credit_adjustments",
11
13
  "billing_authorizations",
14
+ "billing_credits",
12
15
  "billing_debits",
16
+ "billing_debit_allocations",
13
17
  "billing_events",
14
18
  "browser_profile_labels",
19
+ "connected_account_billing",
15
20
  "concurrency_locks",
16
21
  "credit_lots",
17
22
  "free_credit_refreshes",
@@ -40,7 +45,10 @@ var CORE_SCHEMA_TABLES = [
40
45
  "workflow_webhook_deliveries"
41
46
  ];
42
47
  var CORE_SCHEMA_INDEXES = [
48
+ "admin_credit_adjustment_events_reference",
49
+ "admin_credit_adjustments_user_created",
43
50
  "billing_authorizations_user_status",
51
+ "billing_credits_user_status",
44
52
  "billing_debits_user_status",
45
53
  "billing_events_authorization",
46
54
  "billing_events_user_created_at",
@@ -65,6 +73,7 @@ var CORE_SCHEMA_INDEXES = [
65
73
  "site_audit_jobs_user_id",
66
74
  "site_audit_phase_log_job_id",
67
75
  "site_extract_jobs_status",
76
+ "site_extract_jobs_idempotency",
68
77
  "site_extract_jobs_user_id",
69
78
  "site_extract_pages_job_id",
70
79
  "workflow_artifacts_run",
@@ -79,6 +88,19 @@ var CORE_SCHEMA_COLUMNS = {
79
88
  credit_lots: ["never_expires"],
80
89
  harvest_attempts: ["debug_json"],
81
90
  oauth_tokens: ["replaced_by", "rotated_at"],
91
+ site_extract_jobs: [
92
+ "attempted_urls",
93
+ "successful_urls",
94
+ "failed_urls",
95
+ "idempotency_key",
96
+ "request_fingerprint",
97
+ "dispatch_attempts",
98
+ "next_dispatch_at",
99
+ "dispatch_error",
100
+ "settlement_attempts",
101
+ "next_settlement_at",
102
+ "settlement_error"
103
+ ],
82
104
  users: [
83
105
  "balance_mc",
84
106
  "concurrency_stripe_sub_id",
@@ -112,6 +134,46 @@ async function hasCurrentCoreSchema(db) {
112
134
  const result = await db.execute(`SELECT ${checks.join(" AND ")} AS ready`);
113
135
  return Number(result.rows[0]?.ready ?? 0) === 1;
114
136
  }
137
+ async function runIdempotentCoreDataBackfills(db) {
138
+ await db.execute(`
139
+ UPDATE site_extract_jobs SET
140
+ attempted_urls = done_urls,
141
+ successful_urls = (
142
+ SELECT COUNT(*) FROM site_extract_pages
143
+ WHERE job_id = site_extract_jobs.id
144
+ AND CASE
145
+ WHEN json_valid(page) = 0 THEN 0
146
+ WHEN json_extract(page, '$.extractionStatus') IS NOT NULL
147
+ THEN json_extract(page, '$.extractionStatus') = 'successful'
148
+ ELSE COALESCE(
149
+ json_extract(page, '$.status') >= 200
150
+ AND json_extract(page, '$.status') < 300
151
+ AND json_extract(page, '$.wordCount') > 0,
152
+ 0
153
+ )
154
+ END
155
+ ),
156
+ failed_urls = MAX(0, done_urls - (
157
+ SELECT COUNT(*) FROM site_extract_pages
158
+ WHERE job_id = site_extract_jobs.id
159
+ AND CASE
160
+ WHEN json_valid(page) = 0 THEN 0
161
+ WHEN json_extract(page, '$.extractionStatus') IS NOT NULL
162
+ THEN json_extract(page, '$.extractionStatus') = 'successful'
163
+ ELSE COALESCE(
164
+ json_extract(page, '$.status') >= 200
165
+ AND json_extract(page, '$.status') < 300
166
+ AND json_extract(page, '$.wordCount') > 0,
167
+ 0
168
+ )
169
+ END
170
+ ))
171
+ WHERE done_urls > 0
172
+ AND attempted_urls = 0
173
+ AND successful_urls = 0
174
+ AND failed_urls = 0
175
+ `);
176
+ }
115
177
  function getDb() {
116
178
  if (!_db) _db = createClient({ url: DB_URL, authToken: DB_TOKEN });
117
179
  return _db;
@@ -138,8 +200,12 @@ async function migrate() {
138
200
  `);
139
201
  applied = { rows: [] };
140
202
  }
141
- if (applied.rows.length > 0 && await hasCurrentCoreSchema(db)) return;
203
+ if (applied.rows.length > 0 && await hasCurrentCoreSchema(db)) {
204
+ await runIdempotentCoreDataBackfills(db);
205
+ return;
206
+ }
142
207
  if (await hasCurrentCoreSchema(db)) {
208
+ await runIdempotentCoreDataBackfills(db);
143
209
  await db.execute({
144
210
  sql: "INSERT OR IGNORE INTO schema_migrations (version) VALUES (?)",
145
211
  args: [CORE_SCHEMA_VERSION]
@@ -397,6 +463,74 @@ async function migrate() {
397
463
  )
398
464
  `);
399
465
  await db.execute(`CREATE INDEX IF NOT EXISTS billing_debits_user_status ON billing_debits(user_id, status)`);
466
+ await db.execute(`
467
+ CREATE TABLE IF NOT EXISTS billing_debit_allocations (
468
+ debit_idempotency_key TEXT NOT NULL REFERENCES billing_debits(idempotency_key),
469
+ lot_id INTEGER NOT NULL REFERENCES credit_lots(id),
470
+ user_id INTEGER NOT NULL REFERENCES users(id),
471
+ amount_mc INTEGER NOT NULL,
472
+ expires_at TEXT NOT NULL,
473
+ PRIMARY KEY (debit_idempotency_key, lot_id)
474
+ )
475
+ `);
476
+ await db.execute(`
477
+ CREATE TABLE IF NOT EXISTS connected_account_billing (
478
+ user_id INTEGER PRIMARY KEY REFERENCES users(id),
479
+ stripe_subscription_id TEXT,
480
+ stripe_subscription_item_id TEXT,
481
+ price_id TEXT,
482
+ quantity INTEGER NOT NULL DEFAULT 0,
483
+ status TEXT NOT NULL DEFAULT 'pending',
484
+ last_error_code TEXT,
485
+ checked_at TEXT NOT NULL DEFAULT (datetime('now')),
486
+ synced_at TEXT,
487
+ updated_at TEXT NOT NULL DEFAULT (datetime('now'))
488
+ )
489
+ `);
490
+ await db.execute(`
491
+ CREATE TABLE IF NOT EXISTS billing_credits (
492
+ idempotency_key TEXT PRIMARY KEY,
493
+ user_id INTEGER NOT NULL REFERENCES users(id),
494
+ amount_mc INTEGER NOT NULL,
495
+ operation TEXT NOT NULL,
496
+ description TEXT,
497
+ status TEXT NOT NULL DEFAULT 'claimed',
498
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
499
+ applied_at TEXT
500
+ )
501
+ `);
502
+ await db.execute(`CREATE INDEX IF NOT EXISTS billing_credits_user_status ON billing_credits(user_id, status)`);
503
+ await db.execute(`
504
+ CREATE TABLE IF NOT EXISTS admin_credit_adjustments (
505
+ reference TEXT PRIMARY KEY,
506
+ user_id INTEGER NOT NULL REFERENCES users(id),
507
+ direction TEXT NOT NULL,
508
+ amount_mc INTEGER NOT NULL,
509
+ reason TEXT NOT NULL,
510
+ actor TEXT NOT NULL,
511
+ status TEXT NOT NULL DEFAULT 'applied',
512
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
513
+ updated_at TEXT
514
+ )
515
+ `);
516
+ await db.execute(`CREATE INDEX IF NOT EXISTS admin_credit_adjustments_user_created ON admin_credit_adjustments(user_id, created_at)`);
517
+ await db.execute(`
518
+ CREATE TABLE IF NOT EXISTS admin_credit_adjustment_events (
519
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
520
+ reference TEXT NOT NULL REFERENCES admin_credit_adjustments(reference),
521
+ seq INTEGER NOT NULL,
522
+ kind TEXT NOT NULL,
523
+ from_amount_mc INTEGER,
524
+ to_amount_mc INTEGER,
525
+ from_reason TEXT,
526
+ to_reason TEXT,
527
+ moved_mc INTEGER NOT NULL DEFAULT 0,
528
+ actor TEXT NOT NULL,
529
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
530
+ UNIQUE (reference, seq)
531
+ )
532
+ `);
533
+ await db.execute(`CREATE INDEX IF NOT EXISTS admin_credit_adjustment_events_reference ON admin_credit_adjustment_events(reference)`);
400
534
  await db.execute(`
401
535
  CREATE TABLE IF NOT EXISTS site_audit_jobs (
402
536
  id TEXT PRIMARY KEY,
@@ -432,6 +566,17 @@ async function migrate() {
432
566
  options TEXT,
433
567
  total_urls INTEGER DEFAULT 0,
434
568
  done_urls INTEGER DEFAULT 0,
569
+ attempted_urls INTEGER DEFAULT 0,
570
+ successful_urls INTEGER DEFAULT 0,
571
+ failed_urls INTEGER DEFAULT 0,
572
+ idempotency_key TEXT,
573
+ request_fingerprint TEXT,
574
+ dispatch_attempts INTEGER DEFAULT 0,
575
+ next_dispatch_at TEXT,
576
+ dispatch_error TEXT,
577
+ settlement_attempts INTEGER DEFAULT 0,
578
+ next_settlement_at TEXT,
579
+ settlement_error TEXT,
435
580
  artifacts TEXT,
436
581
  error TEXT,
437
582
  billed_mc INTEGER,
@@ -450,6 +595,51 @@ async function migrate() {
450
595
  await db.execute(`CREATE INDEX IF NOT EXISTS site_extract_jobs_user_id ON site_extract_jobs(user_id)`);
451
596
  await db.execute(`CREATE INDEX IF NOT EXISTS site_extract_jobs_status ON site_extract_jobs(status)`);
452
597
  await db.execute(`CREATE INDEX IF NOT EXISTS site_extract_pages_job_id ON site_extract_pages(job_id)`);
598
+ try {
599
+ await db.execute(`ALTER TABLE site_extract_jobs ADD COLUMN attempted_urls INTEGER DEFAULT 0`);
600
+ } catch {
601
+ }
602
+ try {
603
+ await db.execute(`ALTER TABLE site_extract_jobs ADD COLUMN successful_urls INTEGER DEFAULT 0`);
604
+ } catch {
605
+ }
606
+ try {
607
+ await db.execute(`ALTER TABLE site_extract_jobs ADD COLUMN failed_urls INTEGER DEFAULT 0`);
608
+ } catch {
609
+ }
610
+ try {
611
+ await db.execute(`ALTER TABLE site_extract_jobs ADD COLUMN idempotency_key TEXT`);
612
+ } catch {
613
+ }
614
+ try {
615
+ await db.execute(`ALTER TABLE site_extract_jobs ADD COLUMN request_fingerprint TEXT`);
616
+ } catch {
617
+ }
618
+ try {
619
+ await db.execute(`ALTER TABLE site_extract_jobs ADD COLUMN dispatch_attempts INTEGER DEFAULT 0`);
620
+ } catch {
621
+ }
622
+ try {
623
+ await db.execute(`ALTER TABLE site_extract_jobs ADD COLUMN next_dispatch_at TEXT`);
624
+ } catch {
625
+ }
626
+ try {
627
+ await db.execute(`ALTER TABLE site_extract_jobs ADD COLUMN dispatch_error TEXT`);
628
+ } catch {
629
+ }
630
+ try {
631
+ await db.execute(`ALTER TABLE site_extract_jobs ADD COLUMN settlement_attempts INTEGER DEFAULT 0`);
632
+ } catch {
633
+ }
634
+ try {
635
+ await db.execute(`ALTER TABLE site_extract_jobs ADD COLUMN next_settlement_at TEXT`);
636
+ } catch {
637
+ }
638
+ try {
639
+ await db.execute(`ALTER TABLE site_extract_jobs ADD COLUMN settlement_error TEXT`);
640
+ } catch {
641
+ }
642
+ await db.execute(`CREATE UNIQUE INDEX IF NOT EXISTS site_extract_jobs_idempotency ON site_extract_jobs(user_id, idempotency_key) WHERE idempotency_key IS NOT NULL`);
453
643
  await db.execute(`
454
644
  CREATE TABLE IF NOT EXISTS password_reset_tokens (
455
645
  token TEXT PRIMARY KEY,
@@ -670,6 +860,7 @@ async function migrate() {
670
860
  if (!await hasCurrentCoreSchema(db)) {
671
861
  throw new Error("core schema migration finished without all required tables, indexes, and columns");
672
862
  }
863
+ await runIdempotentCoreDataBackfills(db);
673
864
  await db.execute({
674
865
  sql: "INSERT OR IGNORE INTO schema_migrations (version) VALUES (?)",
675
866
  args: [CORE_SCHEMA_VERSION]
@@ -1647,6 +1838,75 @@ async function setSubscriptionTier(userId, tier, concurrency, subId) {
1647
1838
  args: [tier, Math.max(0, Math.round(concurrency)), subId, userId]
1648
1839
  });
1649
1840
  }
1841
+ function rowToConnectedAccountBillingState(row) {
1842
+ return {
1843
+ user_id: Number(row.user_id),
1844
+ stripe_subscription_id: row.stripe_subscription_id != null ? String(row.stripe_subscription_id) : null,
1845
+ stripe_subscription_item_id: row.stripe_subscription_item_id != null ? String(row.stripe_subscription_item_id) : null,
1846
+ price_id: row.price_id != null ? String(row.price_id) : null,
1847
+ quantity: Math.max(0, Number(row.quantity ?? 0)),
1848
+ status: ["pending", "synced", "blocked", "error"].includes(String(row.status)) ? String(row.status) : "error",
1849
+ last_error_code: row.last_error_code != null ? String(row.last_error_code) : null,
1850
+ checked_at: String(row.checked_at),
1851
+ synced_at: row.synced_at != null ? String(row.synced_at) : null,
1852
+ updated_at: String(row.updated_at)
1853
+ };
1854
+ }
1855
+ async function getConnectedAccountBillingState(userId) {
1856
+ const res = await getDb().execute({
1857
+ sql: "SELECT * FROM connected_account_billing WHERE user_id = ? LIMIT 1",
1858
+ args: [userId]
1859
+ });
1860
+ const row = res.rows[0];
1861
+ return row ? rowToConnectedAccountBillingState(row) : null;
1862
+ }
1863
+ async function setConnectedAccountBillingState(input) {
1864
+ const quantity = Math.max(0, Math.round(input.quantity));
1865
+ await getDb().execute({
1866
+ sql: `INSERT INTO connected_account_billing
1867
+ (user_id, stripe_subscription_id, stripe_subscription_item_id, price_id, quantity, status, last_error_code, checked_at, synced_at, updated_at)
1868
+ VALUES (?, ?, ?, ?, ?, ?, ?, datetime('now'), CASE WHEN ? THEN datetime('now') ELSE NULL END, datetime('now'))
1869
+ ON CONFLICT(user_id) DO UPDATE SET
1870
+ stripe_subscription_id = excluded.stripe_subscription_id,
1871
+ stripe_subscription_item_id = excluded.stripe_subscription_item_id,
1872
+ price_id = excluded.price_id,
1873
+ quantity = excluded.quantity,
1874
+ status = excluded.status,
1875
+ last_error_code = excluded.last_error_code,
1876
+ checked_at = datetime('now'),
1877
+ synced_at = CASE WHEN ? THEN datetime('now') ELSE connected_account_billing.synced_at END,
1878
+ updated_at = datetime('now')`,
1879
+ args: [
1880
+ input.userId,
1881
+ input.stripeSubscriptionId,
1882
+ input.stripeSubscriptionItemId,
1883
+ input.priceId,
1884
+ quantity,
1885
+ input.status,
1886
+ input.lastErrorCode ?? null,
1887
+ input.synced === true ? 1 : 0,
1888
+ input.synced === true ? 1 : 0
1889
+ ]
1890
+ });
1891
+ const state = await getConnectedAccountBillingState(input.userId);
1892
+ if (!state) throw new Error("connected account billing state was not persisted");
1893
+ return state;
1894
+ }
1895
+ async function listUsersDueForConnectedAccountBillingReconciliation(staleMinutes = 60, limit = 20) {
1896
+ const modifier = `-${Math.max(1, Math.round(staleMinutes))} minutes`;
1897
+ const res = await getDb().execute({
1898
+ sql: `SELECT u.*
1899
+ FROM users u
1900
+ LEFT JOIN connected_account_billing b ON b.user_id = u.id
1901
+ WHERE u.active = 1
1902
+ AND u.subscription_id IS NOT NULL
1903
+ AND (b.checked_at IS NULL OR b.checked_at <= datetime('now', ?))
1904
+ ORDER BY COALESCE(b.checked_at, u.created_at) ASC
1905
+ LIMIT ?`,
1906
+ args: [modifier, Math.max(1, Math.min(100, Math.round(limit)))]
1907
+ });
1908
+ return res.rows.map((row) => rowToUser(row));
1909
+ }
1650
1910
  async function setMemoryPlan(userId, plan, subscriptionId) {
1651
1911
  await getDb().execute({
1652
1912
  sql: "UPDATE users SET memory_plan = ?, memory_subscription_id = ? WHERE id = ?",
@@ -1784,6 +2044,84 @@ async function creditMc(userId, mc, operation, description, stripePaymentIntent,
1784
2044
  const res = await db.execute({ sql: "SELECT balance_mc FROM users WHERE id = ?", args: [userId] });
1785
2045
  return Number(res.rows[0]?.balance_mc ?? 0);
1786
2046
  }
2047
+ async function creditMcIdempotent(userId, mc, operation, description, idempotencyKey, options) {
2048
+ if (!Number.isSafeInteger(mc) || mc <= 0) throw new Error("idempotent credit amount must be a positive integer");
2049
+ if (!idempotencyKey || idempotencyKey.length > 500) throw new Error("idempotent credit key is required and must be bounded");
2050
+ const db = getDb();
2051
+ await ensureMigrated(userId);
2052
+ let neverExpires = options?.neverExpires;
2053
+ if (neverExpires === void 0) {
2054
+ const tier = await db.execute({ sql: "SELECT subscription_tier FROM users WHERE id = ? LIMIT 1", args: [userId] });
2055
+ neverExpires = String(tier.rows[0]?.subscription_tier ?? "").startsWith("sonic");
2056
+ }
2057
+ const expiry = neverExpires ? "9999-12-31 23:59:59" : null;
2058
+ const readBalance = async () => {
2059
+ const res = await db.execute({ sql: "SELECT balance_mc FROM users WHERE id = ?", args: [userId] });
2060
+ return Number(res.rows[0]?.balance_mc ?? 0);
2061
+ };
2062
+ let results;
2063
+ try {
2064
+ results = await db.batch([
2065
+ {
2066
+ sql: "INSERT OR IGNORE INTO billing_credits (idempotency_key, user_id, amount_mc, operation, description, status) VALUES (?, ?, ?, ?, ?, 'claimed')",
2067
+ args: [idempotencyKey, userId, mc, operation, description]
2068
+ },
2069
+ {
2070
+ sql: "UPDATE billing_credits SET status = 'applying' WHERE idempotency_key = ? AND user_id = ? AND amount_mc = ? AND operation = ? AND description = ? AND status = 'claimed'",
2071
+ args: [idempotencyKey, userId, mc, operation, description]
2072
+ },
2073
+ {
2074
+ sql: `INSERT INTO credit_lots (user_id, amount_mc, remaining_mc, source, stripe_pi, expires_at, never_expires)
2075
+ SELECT user_id, amount_mc, amount_mc, operation, NULL, COALESCE(?, datetime('now', ?)), ?
2076
+ FROM billing_credits
2077
+ WHERE idempotency_key = ? AND status = 'applying'`,
2078
+ args: [expiry, CREDIT_LOT_TTL, neverExpires ? 1 : 0, idempotencyKey]
2079
+ },
2080
+ {
2081
+ sql: `UPDATE users
2082
+ SET balance_mc = balance_mc + COALESCE((
2083
+ SELECT amount_mc FROM billing_credits WHERE idempotency_key = ? AND status = 'applying'
2084
+ ), 0)
2085
+ WHERE id = ?`,
2086
+ args: [idempotencyKey, userId]
2087
+ },
2088
+ {
2089
+ sql: "INSERT INTO ledger (user_id, amount_mc, operation, description) SELECT user_id, amount_mc, operation, description FROM billing_credits WHERE idempotency_key = ? AND status = 'applying'",
2090
+ args: [idempotencyKey]
2091
+ },
2092
+ {
2093
+ sql: "UPDATE billing_credits SET status = 'applied', applied_at = datetime('now') WHERE idempotency_key = ? AND status = 'applying'",
2094
+ args: [idempotencyKey]
2095
+ }
2096
+ ], "write");
2097
+ } catch (error) {
2098
+ try {
2099
+ const recovered = await db.execute({
2100
+ sql: "SELECT user_id, amount_mc, operation, description FROM billing_credits WHERE idempotency_key = ? AND status = 'applied' LIMIT 1",
2101
+ args: [idempotencyKey]
2102
+ });
2103
+ const row2 = recovered.rows[0];
2104
+ if (row2 && Number(row2.user_id) === userId && Number(row2.amount_mc) === mc && String(row2.operation) === operation && String(row2.description) === description) {
2105
+ return { ok: true, balance_mc: await readBalance(), duplicate: true };
2106
+ }
2107
+ } catch {
2108
+ }
2109
+ throw error;
2110
+ }
2111
+ const claim = await db.execute({
2112
+ sql: "SELECT user_id, amount_mc, operation, description, status FROM billing_credits WHERE idempotency_key = ? LIMIT 1",
2113
+ args: [idempotencyKey]
2114
+ });
2115
+ const row = claim.rows[0];
2116
+ if (!row || Number(row.user_id) !== userId || Number(row.amount_mc) !== mc || String(row.operation) !== operation || String(row.description) !== description) {
2117
+ throw new Error("idempotent credit key conflicts with another credit");
2118
+ }
2119
+ return {
2120
+ ok: String(row.status) === "applied",
2121
+ balance_mc: await readBalance(),
2122
+ duplicate: results[1]?.rowsAffected === 0 && String(row.status) === "applied"
2123
+ };
2124
+ }
1787
2125
  async function ledgerExistsForOperation(userId, operation) {
1788
2126
  const res = await getDb().execute({
1789
2127
  sql: "SELECT 1 FROM ledger WHERE user_id = ? AND operation = ? LIMIT 1",
@@ -1826,6 +2164,8 @@ async function debitMcIdempotent(userId, mc, operation, description, idempotency
1826
2164
  try {
1827
2165
  results = await db.batch([
1828
2166
  {
2167
+ // An unfunded claim may be retried with a corrected estimate under the
2168
+ // same logical operation fingerprint. Applied claims remain immutable.
1829
2169
  sql: "UPDATE billing_debits SET amount_mc = ? WHERE idempotency_key = ? AND user_id = ? AND operation = ? AND description = ? AND status = 'claimed'",
1830
2170
  args: [mc, idempotencyKey, userId, operation, description]
1831
2171
  },
@@ -1834,17 +2174,29 @@ async function debitMcIdempotent(userId, mc, operation, description, idempotency
1834
2174
  args: [idempotencyKey, userId, mc, operation, description]
1835
2175
  },
1836
2176
  {
1837
- sql: "UPDATE users SET balance_mc = balance_mc - ? WHERE id = ? AND balance_mc >= ? AND EXISTS (SELECT 1 FROM billing_debits WHERE idempotency_key = ? AND user_id = ? AND amount_mc = ? AND operation = ? AND description = ? AND status = 'claimed')",
1838
- args: [mc, userId, mc, idempotencyKey, userId, mc, operation, description]
2177
+ sql: `UPDATE users
2178
+ SET balance_mc = balance_mc - ?
2179
+ WHERE id = ?
2180
+ AND balance_mc >= ?
2181
+ AND (SELECT COALESCE(SUM(remaining_mc), 0)
2182
+ FROM credit_lots
2183
+ WHERE user_id = ?
2184
+ AND remaining_mc > 0
2185
+ AND (never_expires = 1 OR expires_at > datetime('now'))) >= ?
2186
+ AND EXISTS (SELECT 1 FROM billing_debits WHERE idempotency_key = ? AND user_id = ? AND amount_mc = ? AND operation = ? AND description = ? AND status = 'claimed')`,
2187
+ args: [mc, userId, mc, userId, mc, idempotencyKey, userId, mc, operation, description]
1839
2188
  },
1840
2189
  {
1841
2190
  sql: "UPDATE billing_debits SET status = 'applying' WHERE idempotency_key = ? AND user_id = ? AND amount_mc = ? AND operation = ? AND description = ? AND status = 'claimed' AND changes() = 1",
1842
2191
  args: [idempotencyKey, userId, mc, operation, description]
1843
2192
  },
1844
2193
  {
1845
- sql: `WITH ordered AS MATERIALIZED (
2194
+ sql: `INSERT OR IGNORE INTO billing_debit_allocations
2195
+ (debit_idempotency_key, lot_id, user_id, amount_mc, expires_at)
2196
+ WITH ordered AS MATERIALIZED (
1846
2197
  SELECT id,
1847
2198
  remaining_mc,
2199
+ expires_at,
1848
2200
  COALESCE(SUM(remaining_mc) OVER (
1849
2201
  ORDER BY never_expires ASC, expires_at ASC, granted_at ASC, id ASC
1850
2202
  ROWS BETWEEN UNBOUNDED PRECEDING AND 1 PRECEDING
@@ -1852,14 +2204,26 @@ async function debitMcIdempotent(userId, mc, operation, description, idempotency
1852
2204
  FROM credit_lots
1853
2205
  WHERE user_id = ? AND remaining_mc > 0 AND (never_expires = 1 OR expires_at > datetime('now'))
1854
2206
  ), allocation AS (
1855
- SELECT id, MIN(remaining_mc, MAX(0, ? - prior_mc)) AS take_mc
2207
+ SELECT id, expires_at, MIN(remaining_mc, MAX(0, ? - prior_mc)) AS take_mc
1856
2208
  FROM ordered
1857
2209
  )
1858
- UPDATE credit_lots
1859
- SET remaining_mc = remaining_mc - COALESCE((SELECT take_mc FROM allocation WHERE allocation.id = credit_lots.id), 0)
1860
- WHERE id IN (SELECT id FROM allocation WHERE take_mc > 0)
2210
+ SELECT ?, id, ?, take_mc, expires_at
2211
+ FROM allocation
2212
+ WHERE take_mc > 0
1861
2213
  AND EXISTS (SELECT 1 FROM billing_debits WHERE idempotency_key = ? AND status = 'applying')`,
1862
- args: [userId, mc, idempotencyKey]
2214
+ args: [userId, mc, idempotencyKey, userId, idempotencyKey]
2215
+ },
2216
+ {
2217
+ sql: `UPDATE credit_lots
2218
+ SET remaining_mc = remaining_mc - COALESCE((
2219
+ SELECT amount_mc FROM billing_debit_allocations
2220
+ WHERE debit_idempotency_key = ? AND lot_id = credit_lots.id
2221
+ ), 0)
2222
+ WHERE id IN (
2223
+ SELECT lot_id FROM billing_debit_allocations WHERE debit_idempotency_key = ?
2224
+ )
2225
+ AND EXISTS (SELECT 1 FROM billing_debits WHERE idempotency_key = ? AND status = 'applying')`,
2226
+ args: [idempotencyKey, idempotencyKey, idempotencyKey]
1863
2227
  },
1864
2228
  {
1865
2229
  sql: "INSERT INTO ledger (user_id, amount_mc, operation, description) SELECT user_id, -amount_mc, operation, description FROM billing_debits WHERE idempotency_key = ? AND status = 'applying'",
@@ -1923,6 +2287,7 @@ async function settleDebitMcIdempotent(userId, debitIdempotencyKey, finalAmountM
1923
2287
  e.amount_mc AS final_amount_mc,
1924
2288
  e.billing_class,
1925
2289
  e.source_surface,
2290
+ e.settled_at,
1926
2291
  d.amount_mc AS held_amount_mc
1927
2292
  FROM billing_events e
1928
2293
  JOIN billing_debits d ON d.idempotency_key = ?
@@ -1943,11 +2308,35 @@ async function settleDebitMcIdempotent(userId, debitIdempotencyKey, finalAmountM
1943
2308
  if (!Number.isSafeInteger(heldAmountMc) || !Number.isSafeInteger(durableFinalAmountMc) || durableFinalAmountMc < 0 || durableFinalAmountMc > heldAmountMc) {
1944
2309
  throw new Error("idempotent debit settlement receipt is invalid");
1945
2310
  }
2311
+ const nominalRefundMc = heldAmountMc - durableFinalAmountMc;
2312
+ const allocationRefund = await db.execute({
2313
+ sql: `WITH ordered AS (
2314
+ SELECT a.amount_mc,
2315
+ a.expires_at,
2316
+ COALESCE(SUM(a.amount_mc) OVER (
2317
+ ORDER BY a.expires_at ASC, a.lot_id ASC
2318
+ ROWS BETWEEN UNBOUNDED PRECEDING AND 1 PRECEDING
2319
+ ), 0) AS prior_mc
2320
+ FROM billing_debit_allocations a
2321
+ WHERE a.debit_idempotency_key = ?
2322
+ ), refunds AS (
2323
+ SELECT expires_at,
2324
+ amount_mc - MIN(amount_mc, MAX(0, ? - prior_mc)) AS refund_mc
2325
+ FROM ordered
2326
+ )
2327
+ SELECT (SELECT COUNT(*) FROM ordered) AS allocation_count,
2328
+ COALESCE(SUM(CASE WHEN expires_at > ? THEN refund_mc ELSE 0 END), 0) AS credited_refund_mc
2329
+ FROM refunds`,
2330
+ args: [debitIdempotencyKey, durableFinalAmountMc, String(row.settled_at)]
2331
+ });
2332
+ const allocationCount = Number(allocationRefund.rows[0]?.allocation_count ?? 0);
2333
+ const creditedRefundMc = allocationCount > 0 ? Number(allocationRefund.rows[0]?.credited_refund_mc ?? 0) : 0;
1946
2334
  const balance = await db.execute({ sql: "SELECT balance_mc FROM users WHERE id = ?", args: [userId] });
1947
2335
  return {
1948
2336
  balance_mc: Number(balance.rows[0]?.balance_mc ?? 0),
1949
2337
  final_amount_mc: durableFinalAmountMc,
1950
- refund_mc: heldAmountMc - durableFinalAmountMc,
2338
+ refund_mc: creditedRefundMc,
2339
+ expired_refund_mc: nominalRefundMc - creditedRefundMc,
1951
2340
  duplicate: true
1952
2341
  };
1953
2342
  };
@@ -1961,13 +2350,34 @@ async function settleDebitMcIdempotent(userId, debitIdempotencyKey, finalAmountM
1961
2350
  args: [eventId, userId, settlementKey, billingClass, sourceSurface, finalAmountMc, metadata]
1962
2351
  },
1963
2352
  {
1964
- sql: `UPDATE users
1965
- SET balance_mc = balance_mc + (
1966
- SELECT d.amount_mc - e.amount_mc
1967
- FROM billing_events e
1968
- JOIN billing_debits d ON d.idempotency_key = ?
1969
- WHERE e.idempotency_key = ?
2353
+ sql: `WITH ordered AS (
2354
+ SELECT a.lot_id,
2355
+ a.amount_mc,
2356
+ a.expires_at,
2357
+ COALESCE(SUM(a.amount_mc) OVER (
2358
+ ORDER BY a.expires_at ASC, a.lot_id ASC
2359
+ ROWS BETWEEN UNBOUNDED PRECEDING AND 1 PRECEDING
2360
+ ), 0) AS prior_mc
2361
+ FROM billing_debit_allocations a
2362
+ WHERE a.debit_idempotency_key = ?
2363
+ ), refunds AS (
2364
+ SELECT lot_id,
2365
+ expires_at,
2366
+ amount_mc - MIN(amount_mc, MAX(0, ? - prior_mc)) AS refund_mc
2367
+ FROM ordered
1970
2368
  )
2369
+ UPDATE users
2370
+ SET balance_mc = balance_mc + CASE
2371
+ WHEN EXISTS (SELECT 1 FROM ordered) THEN COALESCE(
2372
+ (SELECT SUM(refund_mc) FROM refunds WHERE expires_at > datetime('now')),
2373
+ 0
2374
+ )
2375
+ -- Holds created before allocation provenance cannot be safely
2376
+ -- tied back to a source lot. Treat their unused portion as
2377
+ -- expired instead of minting spendable credits with a guessed
2378
+ -- expiry.
2379
+ ELSE 0
2380
+ END
1971
2381
  WHERE id = ?
1972
2382
  AND EXISTS (
1973
2383
  SELECT 1
@@ -1984,7 +2394,7 @@ async function settleDebitMcIdempotent(userId, debitIdempotencyKey, finalAmountM
1984
2394
  )`,
1985
2395
  args: [
1986
2396
  debitIdempotencyKey,
1987
- settlementKey,
2397
+ finalAmountMc,
1988
2398
  userId,
1989
2399
  debitIdempotencyKey,
1990
2400
  settlementKey,
@@ -2005,34 +2415,74 @@ async function settleDebitMcIdempotent(userId, debitIdempotencyKey, finalAmountM
2005
2415
  args: [settlementKey, userId, billingClass, sourceSurface]
2006
2416
  },
2007
2417
  {
2008
- sql: `INSERT INTO credit_lots
2009
- (user_id, amount_mc, remaining_mc, source, expires_at, never_expires)
2010
- SELECT e.user_id,
2011
- d.amount_mc - e.amount_mc,
2012
- d.amount_mc - e.amount_mc,
2013
- ?,
2014
- CASE WHEN u.subscription_tier LIKE 'sonic%' THEN '9999-12-31 23:59:59' ELSE datetime('now', ?) END,
2015
- CASE WHEN u.subscription_tier LIKE 'sonic%' THEN 1 ELSE 0 END
2016
- FROM billing_events e
2017
- JOIN billing_debits d ON d.idempotency_key = ?
2018
- JOIN users u ON u.id = e.user_id
2019
- WHERE e.idempotency_key = ?
2020
- AND e.status = 'applying'
2021
- AND d.amount_mc > e.amount_mc`,
2022
- args: [refundOperation, CREDIT_LOT_TTL, debitIdempotencyKey, settlementKey]
2418
+ sql: `WITH ordered AS (
2419
+ SELECT a.lot_id,
2420
+ a.amount_mc,
2421
+ a.expires_at,
2422
+ COALESCE(SUM(a.amount_mc) OVER (
2423
+ ORDER BY a.expires_at ASC, a.lot_id ASC
2424
+ ROWS BETWEEN UNBOUNDED PRECEDING AND 1 PRECEDING
2425
+ ), 0) AS prior_mc
2426
+ FROM billing_debit_allocations a
2427
+ WHERE a.debit_idempotency_key = ?
2428
+ ), refunds AS (
2429
+ SELECT lot_id,
2430
+ expires_at,
2431
+ amount_mc - MIN(amount_mc, MAX(0, ? - prior_mc)) AS refund_mc
2432
+ FROM ordered
2433
+ )
2434
+ UPDATE credit_lots
2435
+ SET remaining_mc = remaining_mc + COALESCE((SELECT refund_mc FROM refunds WHERE refunds.lot_id = credit_lots.id), 0)
2436
+ WHERE id IN (
2437
+ SELECT lot_id FROM refunds
2438
+ WHERE refund_mc > 0 AND expires_at > datetime('now')
2439
+ )
2440
+ AND EXISTS (SELECT 1 FROM billing_events WHERE idempotency_key = ? AND status = 'applying')`,
2441
+ args: [debitIdempotencyKey, finalAmountMc, settlementKey]
2023
2442
  },
2024
2443
  {
2025
- sql: `INSERT INTO ledger (user_id, amount_mc, operation, description)
2026
- SELECT e.user_id,
2027
- d.amount_mc - e.amount_mc,
2444
+ sql: `WITH ordered AS (
2445
+ SELECT a.amount_mc,
2446
+ a.expires_at,
2447
+ COALESCE(SUM(a.amount_mc) OVER (
2448
+ ORDER BY a.expires_at ASC, a.lot_id ASC
2449
+ ROWS BETWEEN UNBOUNDED PRECEDING AND 1 PRECEDING
2450
+ ), 0) AS prior_mc
2451
+ FROM billing_debit_allocations a
2452
+ WHERE a.debit_idempotency_key = ?
2453
+ ), refunds AS (
2454
+ SELECT expires_at,
2455
+ amount_mc - MIN(amount_mc, MAX(0, ? - prior_mc)) AS refund_mc
2456
+ FROM ordered
2457
+ ), credited AS (
2458
+ SELECT e.user_id,
2459
+ CASE
2460
+ WHEN EXISTS (SELECT 1 FROM ordered) THEN COALESCE(
2461
+ (SELECT SUM(refund_mc) FROM refunds WHERE expires_at > datetime('now')),
2462
+ 0
2463
+ )
2464
+ ELSE 0
2465
+ END AS refund_mc
2466
+ FROM billing_events e
2467
+ JOIN billing_debits d ON d.idempotency_key = ?
2468
+ WHERE e.idempotency_key = ?
2469
+ AND e.status = 'applying'
2470
+ )
2471
+ INSERT INTO ledger (user_id, amount_mc, operation, description)
2472
+ SELECT user_id,
2473
+ refund_mc,
2028
2474
  ?,
2029
2475
  ?
2030
- FROM billing_events e
2031
- JOIN billing_debits d ON d.idempotency_key = ?
2032
- WHERE e.idempotency_key = ?
2033
- AND e.status = 'applying'
2034
- AND d.amount_mc > e.amount_mc`,
2035
- args: [refundOperation, description, debitIdempotencyKey, settlementKey]
2476
+ FROM credited
2477
+ WHERE refund_mc > 0`,
2478
+ args: [
2479
+ debitIdempotencyKey,
2480
+ finalAmountMc,
2481
+ debitIdempotencyKey,
2482
+ settlementKey,
2483
+ refundOperation,
2484
+ description
2485
+ ]
2036
2486
  },
2037
2487
  {
2038
2488
  sql: `UPDATE billing_events
@@ -2228,6 +2678,7 @@ async function listActiveUserIds() {
2228
2678
  }
2229
2679
 
2230
2680
  export {
2681
+ CORE_SCHEMA_VERSION,
2231
2682
  getDb,
2232
2683
  setDbForTesting,
2233
2684
  migrate,
@@ -2311,6 +2762,9 @@ export {
2311
2762
  setExtraConcurrencySlots,
2312
2763
  setConcurrencySubId,
2313
2764
  setSubscriptionTier,
2765
+ getConnectedAccountBillingState,
2766
+ setConnectedAccountBillingState,
2767
+ listUsersDueForConnectedAccountBillingReconciliation,
2314
2768
  setMemoryPlan,
2315
2769
  getMemoryPlan,
2316
2770
  getMemoryProvisioned,
@@ -2320,6 +2774,7 @@ export {
2320
2774
  expireOldLots,
2321
2775
  migrateExistingBalancesToLots,
2322
2776
  creditMc,
2777
+ creditMcIdempotent,
2323
2778
  ledgerExistsForOperation,
2324
2779
  claimMonthlyFreeRefresh,
2325
2780
  debitMc,
@@ -2338,4 +2793,4 @@ export {
2338
2793
  insertInboxMessage,
2339
2794
  listActiveUserIds
2340
2795
  };
2341
- //# sourceMappingURL=chunk-62DQAWPF.js.map
2796
+ //# sourceMappingURL=chunk-CSCD2HNS.js.map