mcp-scraper 0.38.0 → 0.38.2

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 (58) hide show
  1. package/README.md +1 -1
  2. package/dist/bin/api-server.cjs +813 -338
  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 +1 -1
  6. package/dist/bin/mcp-scraper-cli.cjs.map +1 -1
  7. package/dist/bin/mcp-scraper-cli.js +1 -1
  8. package/dist/bin/mcp-scraper-install.cjs +1 -1
  9. package/dist/bin/mcp-scraper-install.cjs.map +1 -1
  10. package/dist/bin/mcp-scraper-install.js +1 -1
  11. package/dist/bin/mcp-stdio-server.cjs +25 -7
  12. package/dist/bin/mcp-stdio-server.cjs.map +1 -1
  13. package/dist/bin/mcp-stdio-server.js +5 -5
  14. package/dist/bin/paa-harvest.cjs +65 -4
  15. package/dist/bin/paa-harvest.cjs.map +1 -1
  16. package/dist/bin/paa-harvest.js +3 -3
  17. package/dist/{chunk-PWPUKR5U.js → chunk-AZRPG43B.js} +12 -3
  18. package/dist/chunk-AZRPG43B.js.map +1 -0
  19. package/dist/{chunk-MA5JBAUZ.js → chunk-EGJKUB4Q.js} +3 -3
  20. package/dist/{chunk-MA5JBAUZ.js.map → chunk-EGJKUB4Q.js.map} +1 -1
  21. package/dist/{chunk-PUJFYJXB.js → chunk-FRYT3ID4.js} +2 -2
  22. package/dist/{chunk-CSCD2HNS.js → chunk-N7KUTTCC.js} +312 -101
  23. package/dist/chunk-N7KUTTCC.js.map +1 -0
  24. package/dist/{chunk-LOPKN3YL.js → chunk-NGM237OO.js} +66 -5
  25. package/dist/chunk-NGM237OO.js.map +1 -0
  26. package/dist/{chunk-P64PZUCA.js → chunk-NKCCGADE.js} +23 -8
  27. package/dist/chunk-NKCCGADE.js.map +1 -0
  28. package/dist/{chunk-FSAXLDB3.js → chunk-SFRMFGQ6.js} +2 -2
  29. package/dist/{chunk-Q35WZJJK.js → chunk-YCI2PNCS.js} +3 -3
  30. package/dist/chunk-YODBNTTN.js +7 -0
  31. package/dist/chunk-YODBNTTN.js.map +1 -0
  32. package/dist/{db-N3YECFWR.js → db-C5KVCOYT.js} +10 -4
  33. package/dist/{extract-bundle-346R6MXD.js → extract-bundle-KUBX6N6Z.js} +4 -4
  34. package/dist/index.cjs +65 -4
  35. package/dist/index.cjs.map +1 -1
  36. package/dist/index.js +3 -3
  37. package/dist/{location-data-repository-O2VII3ON.js → location-data-repository-TTWF3OTM.js} +3 -3
  38. package/dist/{server-3Q6XIJPF.js → server-5EX6XBIA.js} +320 -152
  39. package/dist/server-5EX6XBIA.js.map +1 -0
  40. package/dist/{site-extract-repository-I3VM6WXN.js → site-extract-repository-XSPJTCIL.js} +4 -4
  41. package/dist/{worker-TTFXPPDK.js → worker-XCPU4YSN.js} +6 -6
  42. package/docs/mcp-tool-manifest.generated.json +66 -9
  43. package/package.json +4 -2
  44. package/dist/chunk-CSCD2HNS.js.map +0 -1
  45. package/dist/chunk-H3M3O457.js +0 -7
  46. package/dist/chunk-H3M3O457.js.map +0 -1
  47. package/dist/chunk-LOPKN3YL.js.map +0 -1
  48. package/dist/chunk-P64PZUCA.js.map +0 -1
  49. package/dist/chunk-PWPUKR5U.js.map +0 -1
  50. package/dist/server-3Q6XIJPF.js.map +0 -1
  51. /package/dist/{chunk-PUJFYJXB.js.map → chunk-FRYT3ID4.js.map} +0 -0
  52. /package/dist/{chunk-FSAXLDB3.js.map → chunk-SFRMFGQ6.js.map} +0 -0
  53. /package/dist/{chunk-Q35WZJJK.js.map → chunk-YCI2PNCS.js.map} +0 -0
  54. /package/dist/{db-N3YECFWR.js.map → db-C5KVCOYT.js.map} +0 -0
  55. /package/dist/{extract-bundle-346R6MXD.js.map → extract-bundle-KUBX6N6Z.js.map} +0 -0
  56. /package/dist/{location-data-repository-O2VII3ON.js.map → location-data-repository-TTWF3OTM.js.map} +0 -0
  57. /package/dist/{site-extract-repository-I3VM6WXN.js.map → site-extract-repository-XSPJTCIL.js.map} +0 -0
  58. /package/dist/{worker-TTFXPPDK.js.map → worker-XCPU4YSN.js.map} +0 -0
@@ -2,11 +2,16 @@
2
2
  import { createClient } from "@libsql/client/http";
3
3
  import { createHash, randomBytes, randomUUID, scryptSync, timingSafeEqual } from "crypto";
4
4
  import { z } from "zod";
5
+
6
+ // src/api/credit-lot-integrity.ts
7
+ var CREDIT_LOTS_EPOCH = "2026-06-26 21:58:03";
8
+
9
+ // src/api/db.ts
5
10
  var DB_URL = process.env.TURSO_DATABASE_URL ?? "file:./paa-api.db";
6
11
  var DB_TOKEN = process.env.TURSO_AUTH_TOKEN;
7
12
  var _db = null;
8
13
  var _rateLimitSchemaReady = false;
9
- var CORE_SCHEMA_VERSION = "2026-07-28.1";
14
+ var CORE_SCHEMA_VERSION = "2026-07-28.3";
10
15
  var CORE_SCHEMA_TABLES = [
11
16
  "admin_credit_adjustment_events",
12
17
  "admin_credit_adjustments",
@@ -18,7 +23,9 @@ var CORE_SCHEMA_TABLES = [
18
23
  "browser_profile_labels",
19
24
  "connected_account_billing",
20
25
  "concurrency_locks",
26
+ "credit_lot_expirations",
21
27
  "credit_lots",
28
+ "credit_lot_repairs",
22
29
  "free_credit_refreshes",
23
30
  "harvest_attempts",
24
31
  "inbox_messages",
@@ -55,6 +62,7 @@ var CORE_SCHEMA_INDEXES = [
55
62
  "concurrency_locks_operation",
56
63
  "concurrency_locks_user_status",
57
64
  "credit_lots_user_active",
65
+ "credit_lot_repairs_user_applied",
58
66
  "harvest_attempts_job_id",
59
67
  "harvest_attempts_user_created_at",
60
68
  "inbox_messages_user_sent_at",
@@ -84,6 +92,12 @@ var CORE_SCHEMA_INDEXES = [
84
92
  "workflow_schedules_user_status",
85
93
  "workflow_webhook_deliveries_run"
86
94
  ];
95
+ var CORE_SCHEMA_TRIGGERS = [
96
+ "credit_lots_bounds_insert",
97
+ "credit_lots_bounds_update",
98
+ "credit_lots_one_migration_insert",
99
+ "credit_lots_one_migration_update"
100
+ ];
87
101
  var CORE_SCHEMA_COLUMNS = {
88
102
  credit_lots: ["never_expires"],
89
103
  harvest_attempts: ["debug_json"],
@@ -101,6 +115,7 @@ var CORE_SCHEMA_COLUMNS = {
101
115
  "next_settlement_at",
102
116
  "settlement_error"
103
117
  ],
118
+ stripe_events: ["attempt_count", "claim_token", "last_error", "status", "updated_at"],
104
119
  users: [
105
120
  "balance_mc",
106
121
  "concurrency_stripe_sub_id",
@@ -127,6 +142,7 @@ async function hasCurrentCoreSchema(db) {
127
142
  const checks = [
128
143
  `(SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name IN (${sqlStringList(CORE_SCHEMA_TABLES)})) = ${CORE_SCHEMA_TABLES.length}`,
129
144
  `(SELECT COUNT(*) FROM sqlite_master WHERE type = 'index' AND name IN (${sqlStringList(CORE_SCHEMA_INDEXES)})) = ${CORE_SCHEMA_INDEXES.length}`,
145
+ `(SELECT COUNT(*) FROM sqlite_master WHERE type = 'trigger' AND name IN (${sqlStringList(CORE_SCHEMA_TRIGGERS)})) = ${CORE_SCHEMA_TRIGGERS.length}`,
130
146
  ...Object.entries(CORE_SCHEMA_COLUMNS).map(
131
147
  ([table, columns]) => `(SELECT COUNT(*) FROM pragma_table_info('${table}') WHERE name IN (${sqlStringList(columns)})) = ${columns.length}`
132
148
  )
@@ -402,6 +418,76 @@ async function migrate() {
402
418
  } catch {
403
419
  }
404
420
  await db.execute(`CREATE INDEX IF NOT EXISTS credit_lots_user_active ON credit_lots(user_id, expires_at, remaining_mc)`);
421
+ await db.execute(`
422
+ CREATE TABLE IF NOT EXISTS credit_lot_expirations (
423
+ lot_id INTEGER PRIMARY KEY REFERENCES credit_lots(id),
424
+ user_id INTEGER NOT NULL REFERENCES users(id),
425
+ amount_mc INTEGER NOT NULL,
426
+ claim_token TEXT NOT NULL UNIQUE,
427
+ expired_at TEXT NOT NULL DEFAULT (datetime('now'))
428
+ )
429
+ `);
430
+ await db.execute(`
431
+ CREATE TRIGGER IF NOT EXISTS credit_lots_bounds_insert
432
+ BEFORE INSERT ON credit_lots
433
+ WHEN NEW.amount_mc < 0
434
+ OR NEW.remaining_mc < 0
435
+ OR NEW.remaining_mc > NEW.amount_mc
436
+ BEGIN
437
+ SELECT RAISE(ABORT, 'invalid credit lot bounds');
438
+ END
439
+ `);
440
+ await db.execute(`
441
+ CREATE TRIGGER IF NOT EXISTS credit_lots_bounds_update
442
+ BEFORE UPDATE OF amount_mc, remaining_mc ON credit_lots
443
+ WHEN NEW.amount_mc < 0
444
+ OR NEW.remaining_mc < 0
445
+ OR NEW.remaining_mc > NEW.amount_mc
446
+ BEGIN
447
+ SELECT RAISE(ABORT, 'invalid credit lot bounds');
448
+ END
449
+ `);
450
+ await db.execute(`
451
+ CREATE TRIGGER IF NOT EXISTS credit_lots_one_migration_insert
452
+ BEFORE INSERT ON credit_lots
453
+ WHEN NEW.source = 'migration'
454
+ AND EXISTS (
455
+ SELECT 1 FROM credit_lots
456
+ WHERE user_id = NEW.user_id AND source = 'migration'
457
+ )
458
+ BEGIN
459
+ SELECT RAISE(ABORT, 'duplicate migration lot');
460
+ END
461
+ `);
462
+ await db.execute(`
463
+ CREATE TRIGGER IF NOT EXISTS credit_lots_one_migration_update
464
+ BEFORE UPDATE OF source, user_id ON credit_lots
465
+ WHEN NEW.source = 'migration'
466
+ AND EXISTS (
467
+ SELECT 1 FROM credit_lots
468
+ WHERE user_id = NEW.user_id
469
+ AND source = 'migration'
470
+ AND id != OLD.id
471
+ )
472
+ BEGIN
473
+ SELECT RAISE(ABORT, 'duplicate migration lot');
474
+ END
475
+ `);
476
+ await db.execute(`
477
+ CREATE TABLE IF NOT EXISTS credit_lot_repairs (
478
+ repair_id TEXT PRIMARY KEY,
479
+ user_id INTEGER NOT NULL REFERENCES users(id),
480
+ report_sha256 TEXT NOT NULL,
481
+ classification TEXT NOT NULL,
482
+ before_json TEXT NOT NULL,
483
+ plan_json TEXT NOT NULL,
484
+ after_json TEXT,
485
+ status TEXT NOT NULL DEFAULT 'claimed',
486
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
487
+ applied_at TEXT
488
+ )
489
+ `);
490
+ await db.execute(`CREATE INDEX IF NOT EXISTS credit_lot_repairs_user_applied ON credit_lot_repairs(user_id, applied_at)`);
405
491
  await db.execute(`
406
492
  CREATE TABLE IF NOT EXISTS free_credit_refreshes (
407
493
  user_id INTEGER NOT NULL REFERENCES users(id),
@@ -776,10 +862,37 @@ async function migrate() {
776
862
  await db.execute(`CREATE INDEX IF NOT EXISTS workflow_webhook_deliveries_run ON workflow_webhook_deliveries(run_id)`);
777
863
  await db.execute(`
778
864
  CREATE TABLE IF NOT EXISTS stripe_events (
779
- event_id TEXT PRIMARY KEY,
780
- processed_at TEXT NOT NULL DEFAULT (datetime('now'))
865
+ event_id TEXT PRIMARY KEY,
866
+ status TEXT NOT NULL DEFAULT 'processed',
867
+ attempt_count INTEGER NOT NULL DEFAULT 1,
868
+ claim_token TEXT,
869
+ last_error TEXT,
870
+ processed_at TEXT NOT NULL DEFAULT (datetime('now')),
871
+ updated_at TEXT NOT NULL DEFAULT (datetime('now'))
781
872
  )
782
873
  `);
874
+ for (const col of [
875
+ `status TEXT NOT NULL DEFAULT 'processed'`,
876
+ "attempt_count INTEGER NOT NULL DEFAULT 1",
877
+ "claim_token TEXT",
878
+ "last_error TEXT",
879
+ "updated_at TEXT"
880
+ ]) {
881
+ try {
882
+ await db.execute(`ALTER TABLE stripe_events ADD COLUMN ${col}`);
883
+ } catch {
884
+ }
885
+ }
886
+ await db.execute(`
887
+ UPDATE stripe_events
888
+ SET updated_at = COALESCE(updated_at, processed_at),
889
+ status = COALESCE(status, 'processed'),
890
+ attempt_count = MAX(1, COALESCE(attempt_count, 1))
891
+ WHERE updated_at IS NULL
892
+ OR status IS NULL
893
+ OR attempt_count IS NULL
894
+ OR attempt_count < 1
895
+ `);
783
896
  await db.execute(`
784
897
  CREATE TABLE IF NOT EXISTS oauth_clients (
785
898
  client_id TEXT PRIMARY KEY,
@@ -945,16 +1058,60 @@ async function rotateRefresh(oldToken, next) {
945
1058
  args: [next.refresh_token, (/* @__PURE__ */ new Date()).toISOString(), oldToken]
946
1059
  });
947
1060
  }
948
- async function recordStripeEvent(eventId) {
949
- const res = await getDb().execute({
950
- sql: "INSERT OR IGNORE INTO stripe_events (event_id) VALUES (?)",
1061
+ async function claimStripeEvent(eventId) {
1062
+ const token = randomUUID();
1063
+ const claimed = await getDb().execute({
1064
+ sql: `INSERT INTO stripe_events
1065
+ (event_id, status, attempt_count, claim_token, last_error, processed_at, updated_at)
1066
+ VALUES (?, 'processing', 1, ?, NULL, datetime('now'), datetime('now'))
1067
+ ON CONFLICT(event_id) DO UPDATE SET
1068
+ status = 'processing',
1069
+ attempt_count = stripe_events.attempt_count + 1,
1070
+ claim_token = excluded.claim_token,
1071
+ last_error = NULL,
1072
+ updated_at = datetime('now')
1073
+ WHERE stripe_events.status = 'failed'
1074
+ OR (
1075
+ stripe_events.status = 'processing'
1076
+ AND stripe_events.updated_at <= datetime('now', '-10 minutes')
1077
+ )`,
1078
+ args: [eventId, token]
1079
+ });
1080
+ if (claimed.rowsAffected > 0) return { state: "claimed", token };
1081
+ const current = await getDb().execute({
1082
+ sql: "SELECT status FROM stripe_events WHERE event_id = ? LIMIT 1",
951
1083
  args: [eventId]
952
1084
  });
953
- return res.rowsAffected > 0;
1085
+ return { state: String(current.rows[0]?.status) === "processed" ? "processed" : "processing" };
1086
+ }
1087
+ async function completeStripeEvent(eventId, token) {
1088
+ const result = await getDb().execute({
1089
+ sql: `UPDATE stripe_events
1090
+ SET status = 'processed',
1091
+ claim_token = NULL,
1092
+ last_error = NULL,
1093
+ processed_at = datetime('now'),
1094
+ updated_at = datetime('now')
1095
+ WHERE event_id = ? AND status = 'processing' AND claim_token = ?`,
1096
+ args: [eventId, token]
1097
+ });
1098
+ return result.rowsAffected === 1;
1099
+ }
1100
+ async function failStripeEvent(eventId, token, error) {
1101
+ const result = await getDb().execute({
1102
+ sql: `UPDATE stripe_events
1103
+ SET status = 'failed',
1104
+ claim_token = NULL,
1105
+ last_error = ?,
1106
+ updated_at = datetime('now')
1107
+ WHERE event_id = ? AND status = 'processing' AND claim_token = ?`,
1108
+ args: [error.slice(0, 500), eventId, token]
1109
+ });
1110
+ return result.rowsAffected === 1;
954
1111
  }
955
1112
  async function stripeEventAlreadyProcessed(eventId) {
956
1113
  const res = await getDb().execute({
957
- sql: "SELECT 1 FROM stripe_events WHERE event_id = ? LIMIT 1",
1114
+ sql: `SELECT 1 FROM stripe_events WHERE event_id = ? AND status = 'processed' LIMIT 1`,
958
1115
  args: [eventId]
959
1116
  });
960
1117
  return res.rows.length > 0;
@@ -1936,74 +2093,114 @@ async function resolveUserByMemorySubscription(subscriptionId) {
1936
2093
  var CREDIT_LOT_TTL = "+3 months";
1937
2094
  async function ensureMigrated(userId) {
1938
2095
  const db = getDb();
1939
- const hasLots = await db.execute({ sql: "SELECT 1 FROM credit_lots WHERE user_id = ? LIMIT 1", args: [userId] });
1940
- if (hasLots.rows.length) return;
1941
- const legacy = await db.execute({ sql: "SELECT COALESCE(SUM(amount_mc), 0) AS bal FROM ledger WHERE user_id = ?", args: [userId] });
1942
- const bal = Number(legacy.rows[0]?.bal ?? 0);
1943
- if (bal > 0) {
1944
- await db.execute({
1945
- sql: `INSERT INTO credit_lots (user_id, amount_mc, remaining_mc, source, expires_at) VALUES (?, ?, ?, 'migration', datetime('now', ?))`,
1946
- args: [userId, bal, bal, CREDIT_LOT_TTL]
1947
- });
1948
- }
1949
- }
1950
- async function drainLots(userId, mc) {
1951
- const db = getDb();
1952
- const lots = await db.execute({
1953
- sql: `SELECT id, remaining_mc FROM credit_lots WHERE user_id = ? AND remaining_mc > 0 AND (never_expires = 1 OR expires_at > datetime('now')) ORDER BY never_expires ASC, expires_at ASC, granted_at ASC, id ASC`,
1954
- args: [userId]
2096
+ const inserted = await db.execute({
2097
+ sql: `INSERT INTO credit_lots (user_id, amount_mc, remaining_mc, source, expires_at)
2098
+ SELECT ?, legacy.balance_mc, legacy.balance_mc, 'migration', datetime('now', ?)
2099
+ FROM (
2100
+ SELECT COALESCE(SUM(amount_mc), 0) AS balance_mc
2101
+ FROM ledger
2102
+ WHERE user_id = ?
2103
+ ) AS legacy
2104
+ WHERE legacy.balance_mc > 0
2105
+ AND EXISTS (
2106
+ SELECT 1 FROM ledger
2107
+ WHERE user_id = ? AND created_at < ?
2108
+ )
2109
+ AND NOT EXISTS (
2110
+ SELECT 1 FROM credit_lots WHERE user_id = ?
2111
+ )`,
2112
+ args: [userId, CREDIT_LOT_TTL, userId, userId, CREDIT_LOTS_EPOCH, userId]
1955
2113
  });
1956
- let need = mc;
1957
- const updates = [];
1958
- for (const lot of lots.rows) {
1959
- if (need <= 0) break;
1960
- const take = Math.min(Number(lot.remaining_mc), need);
1961
- updates.push({ sql: "UPDATE credit_lots SET remaining_mc = remaining_mc - ? WHERE id = ?", args: [take, Number(lot.id)] });
1962
- need -= take;
1963
- }
1964
- if (updates.length) await db.batch(updates, "write");
2114
+ return inserted.rowsAffected;
1965
2115
  }
1966
2116
  async function expireOldLots() {
1967
2117
  const db = getDb();
1968
2118
  const lots = await db.execute({
1969
- sql: `SELECT id, user_id, remaining_mc FROM credit_lots WHERE remaining_mc > 0 AND never_expires = 0 AND expires_at <= datetime('now') LIMIT 500`
2119
+ sql: `SELECT id
2120
+ FROM credit_lots
2121
+ WHERE remaining_mc > 0
2122
+ AND never_expires = 0
2123
+ AND expires_at <= datetime('now')
2124
+ LIMIT 500`
1970
2125
  });
1971
2126
  if (lots.rows.length === 0) return { expired_lots: 0, expired_mc: 0 };
1972
- let totalMc = 0;
1973
- const users = /* @__PURE__ */ new Set();
2127
+ const claims = [];
1974
2128
  const stmts = [];
1975
2129
  for (const lot of lots.rows) {
1976
- const rem = Number(lot.remaining_mc);
1977
- const uid = Number(lot.user_id);
1978
- totalMc += rem;
1979
- users.add(uid);
1980
- stmts.push({ sql: "UPDATE credit_lots SET remaining_mc = 0 WHERE id = ?", args: [Number(lot.id)] });
1981
- stmts.push({ sql: `INSERT INTO ledger (user_id, amount_mc, operation, description) VALUES (?, ?, 'credit_expired', 'credits expired')`, args: [uid, -rem] });
2130
+ const lotId = Number(lot.id);
2131
+ const token = randomUUID();
2132
+ claims.push({ token, insertResultIndex: stmts.length });
2133
+ stmts.push({
2134
+ sql: `INSERT OR IGNORE INTO credit_lot_expirations (lot_id, user_id, amount_mc, claim_token)
2135
+ SELECT id, user_id, remaining_mc, ?
2136
+ FROM credit_lots
2137
+ WHERE id = ?
2138
+ AND remaining_mc > 0
2139
+ AND never_expires = 0
2140
+ AND expires_at <= datetime('now')`,
2141
+ args: [token, lotId]
2142
+ });
2143
+ stmts.push({
2144
+ sql: `UPDATE credit_lots
2145
+ SET remaining_mc = 0
2146
+ WHERE id = ?
2147
+ AND EXISTS (
2148
+ SELECT 1 FROM credit_lot_expirations
2149
+ WHERE lot_id = ? AND claim_token = ?
2150
+ )`,
2151
+ args: [lotId, lotId, token]
2152
+ });
2153
+ stmts.push({
2154
+ sql: `INSERT INTO ledger (user_id, amount_mc, operation, description)
2155
+ SELECT user_id, -amount_mc, 'credit_expired', 'credits expired'
2156
+ FROM credit_lot_expirations
2157
+ WHERE lot_id = ? AND claim_token = ?`,
2158
+ args: [lotId, token]
2159
+ });
1982
2160
  }
1983
- await db.batch(stmts, "write");
2161
+ const results = await db.batch(stmts, "write");
2162
+ const claimedTokens = claims.filter((claim) => results[claim.insertResultIndex]?.rowsAffected === 1).map((claim) => claim.token);
2163
+ if (claimedTokens.length === 0) return { expired_lots: 0, expired_mc: 0 };
2164
+ const placeholders = claimedTokens.map(() => "?").join(", ");
2165
+ const expired = await db.execute({
2166
+ sql: `SELECT user_id, amount_mc
2167
+ FROM credit_lot_expirations
2168
+ WHERE claim_token IN (${placeholders})`,
2169
+ args: claimedTokens
2170
+ });
2171
+ const users = new Set(expired.rows.map((row) => Number(row.user_id)));
1984
2172
  for (const uid of users) await reconcileBalanceMc(uid);
1985
- return { expired_lots: lots.rows.length, expired_mc: totalMc };
2173
+ return {
2174
+ expired_lots: expired.rows.length,
2175
+ expired_mc: expired.rows.reduce((sum, row) => sum + Number(row.amount_mc), 0)
2176
+ };
1986
2177
  }
1987
2178
  async function migrateExistingBalancesToLots() {
1988
2179
  const db = getDb();
1989
2180
  const res = await db.execute({
1990
- sql: `SELECT u.id AS user_id, COALESCE(SUM(l.amount_mc), 0) AS bal
1991
- FROM users u LEFT JOIN ledger l ON l.user_id = u.id
1992
- WHERE u.id NOT IN (SELECT DISTINCT user_id FROM credit_lots)
1993
- GROUP BY u.id HAVING bal > 0`
2181
+ sql: `SELECT DISTINCT u.id AS user_id
2182
+ FROM users u
2183
+ JOIN ledger l ON l.user_id = u.id
2184
+ WHERE l.created_at < ?
2185
+ AND NOT EXISTS (SELECT 1 FROM credit_lots WHERE user_id = u.id)`,
2186
+ args: [CREDIT_LOTS_EPOCH]
1994
2187
  });
1995
2188
  let migrated = 0;
1996
2189
  let totalMc = 0;
1997
2190
  for (const row of res.rows) {
1998
2191
  const uid = Number(row.user_id);
1999
- const bal = Number(row.bal);
2000
- await db.execute({
2001
- sql: `INSERT INTO credit_lots (user_id, amount_mc, remaining_mc, source, expires_at) VALUES (?, ?, ?, 'migration', datetime('now', ?))`,
2002
- args: [uid, bal, bal, CREDIT_LOT_TTL]
2192
+ const inserted = await ensureMigrated(uid);
2193
+ if (inserted === 0) continue;
2194
+ const lot = await db.execute({
2195
+ sql: `SELECT amount_mc FROM credit_lots
2196
+ WHERE user_id = ? AND source = 'migration'
2197
+ ORDER BY id DESC LIMIT 1`,
2198
+ args: [uid]
2003
2199
  });
2200
+ const amountMc = Number(lot.rows[0]?.amount_mc ?? 0);
2004
2201
  await reconcileBalanceMc(uid);
2005
- migrated++;
2006
- totalMc += bal;
2202
+ migrated += 1;
2203
+ totalMc += amountMc;
2007
2204
  }
2008
2205
  return { migrated, total_mc: totalMc };
2009
2206
  }
@@ -2017,19 +2214,22 @@ async function creditMc(userId, mc, operation, description, stripePaymentIntent,
2017
2214
  }
2018
2215
  const expiry = neverExpires ? "9999-12-31 23:59:59" : null;
2019
2216
  if (stripePaymentIntent) {
2020
- const ledgerInsert = await db.execute({
2217
+ const stmts2 = [{
2021
2218
  sql: "INSERT OR IGNORE INTO ledger (user_id, amount_mc, operation, description, stripe_pi) VALUES (?, ?, ?, ?, ?)",
2022
2219
  args: [userId, mc, operation, description ?? null, stripePaymentIntent]
2023
- });
2024
- if (ledgerInsert.rowsAffected === 0) {
2025
- const res3 = await db.execute({ sql: "SELECT balance_mc FROM users WHERE id = ?", args: [userId] });
2026
- return Number(res3.rows[0]?.balance_mc ?? 0);
2027
- }
2028
- const stmts2 = [];
2220
+ }];
2029
2221
  if (mc > 0) {
2030
- stmts2.push({ sql: `INSERT INTO credit_lots (user_id, amount_mc, remaining_mc, source, stripe_pi, expires_at, never_expires) VALUES (?, ?, ?, ?, ?, COALESCE(?, datetime('now', ?)), ?)`, args: [userId, mc, mc, operation, stripePaymentIntent, expiry, CREDIT_LOT_TTL, neverExpires ? 1 : 0] });
2222
+ stmts2.push({
2223
+ sql: `INSERT INTO credit_lots (user_id, amount_mc, remaining_mc, source, stripe_pi, expires_at, never_expires)
2224
+ SELECT ?, ?, ?, ?, ?, COALESCE(?, datetime('now', ?)), ?
2225
+ WHERE changes() = 1`,
2226
+ args: [userId, mc, mc, operation, stripePaymentIntent, expiry, CREDIT_LOT_TTL, neverExpires ? 1 : 0]
2227
+ });
2031
2228
  }
2032
- stmts2.push({ sql: "UPDATE users SET balance_mc = balance_mc + ? WHERE id = ?", args: [mc, userId] });
2229
+ stmts2.push({
2230
+ sql: "UPDATE users SET balance_mc = balance_mc + ? WHERE id = ? AND changes() = 1",
2231
+ args: [mc, userId]
2232
+ });
2033
2233
  await db.batch(stmts2, "write");
2034
2234
  const res2 = await db.execute({ sql: "SELECT balance_mc FROM users WHERE id = ?", args: [userId] });
2035
2235
  return Number(res2.rows[0]?.balance_mc ?? 0);
@@ -2137,23 +2337,14 @@ async function claimMonthlyFreeRefresh(userId, month) {
2137
2337
  return res.rowsAffected > 0;
2138
2338
  }
2139
2339
  async function debitMc(userId, mc, operation, description) {
2140
- const db = getDb();
2141
- await ensureMigrated(userId);
2142
- const upd = await db.execute({
2143
- sql: "UPDATE users SET balance_mc = balance_mc - ? WHERE id = ? AND balance_mc >= ?",
2144
- args: [mc, userId, mc]
2145
- });
2146
- if (upd.rowsAffected === 0) {
2147
- const res2 = await db.execute({ sql: "SELECT balance_mc FROM users WHERE id = ?", args: [userId] });
2148
- return { ok: false, balance_mc: Number(res2.rows[0]?.balance_mc ?? 0) };
2149
- }
2150
- await drainLots(userId, mc);
2151
- await db.execute({
2152
- sql: "INSERT INTO ledger (user_id, amount_mc, operation, description) VALUES (?, ?, ?, ?)",
2153
- args: [userId, -mc, operation, description ?? null]
2154
- });
2155
- const res = await db.execute({ sql: "SELECT balance_mc FROM users WHERE id = ?", args: [userId] });
2156
- return { ok: true, balance_mc: Number(res.rows[0]?.balance_mc ?? 0) };
2340
+ const debit = await debitMcIdempotent(
2341
+ userId,
2342
+ mc,
2343
+ operation,
2344
+ description ?? "",
2345
+ `debit:${randomUUID()}`
2346
+ );
2347
+ return { ok: debit.ok, balance_mc: debit.balance_mc };
2157
2348
  }
2158
2349
  async function debitMcIdempotent(userId, mc, operation, description, idempotencyKey) {
2159
2350
  if (!Number.isSafeInteger(mc) || mc <= 0) throw new Error("idempotent debit amount must be a positive integer");
@@ -2572,29 +2763,46 @@ async function ledgerExistsForStripePI(stripePI) {
2572
2763
  }
2573
2764
  async function reconcileBalanceMc(userId, knownBalanceMc) {
2574
2765
  const db = getDb();
2575
- const res = await db.execute({
2576
- sql: `SELECT
2577
- COUNT(*) AS lot_count,
2578
- COALESCE(SUM(CASE WHEN remaining_mc > 0 AND (never_expires = 1 OR expires_at > datetime('now')) THEN remaining_mc ELSE 0 END), 0) AS balance_mc
2579
- FROM credit_lots WHERE user_id = ?`,
2580
- args: [userId]
2581
- });
2582
- let balanceMc = Number(res.rows[0]?.balance_mc ?? 0);
2583
- if (Number(res.rows[0]?.lot_count ?? 0) === 0) {
2584
- await ensureMigrated(Number(userId));
2585
- const migrated = await db.execute({
2586
- sql: `SELECT COALESCE(SUM(remaining_mc), 0) AS balance_mc FROM credit_lots WHERE user_id = ? AND remaining_mc > 0 AND (never_expires = 1 OR expires_at > datetime('now'))`,
2766
+ void knownBalanceMc;
2767
+ for (let attempt = 0; attempt < 8; attempt += 1) {
2768
+ const snapshot = await db.execute({
2769
+ sql: `SELECT
2770
+ u.balance_mc AS cached_balance_mc,
2771
+ COUNT(l.id) AS lot_count,
2772
+ COALESCE(SUM(CASE
2773
+ WHEN l.remaining_mc > 0
2774
+ AND (l.never_expires = 1 OR l.expires_at > datetime('now'))
2775
+ THEN l.remaining_mc ELSE 0 END), 0) AS projected_balance_mc
2776
+ FROM users u
2777
+ LEFT JOIN credit_lots l ON l.user_id = u.id
2778
+ WHERE u.id = ?
2779
+ GROUP BY u.id, u.balance_mc`,
2587
2780
  args: [userId]
2588
2781
  });
2589
- balanceMc = Number(migrated.rows[0]?.balance_mc ?? 0);
2590
- }
2591
- if (knownBalanceMc !== balanceMc) {
2592
- await db.execute({
2593
- sql: "UPDATE users SET balance_mc = ? WHERE id = ? AND balance_mc != ?",
2594
- args: [balanceMc, userId, balanceMc]
2782
+ const row = snapshot.rows[0];
2783
+ if (!row) return 0;
2784
+ if (Number(row.lot_count ?? 0) === 0) {
2785
+ await ensureMigrated(Number(userId));
2786
+ const nowHasLots = await db.execute({
2787
+ sql: "SELECT 1 FROM credit_lots WHERE user_id = ? LIMIT 1",
2788
+ args: [userId]
2789
+ });
2790
+ if (nowHasLots.rows.length > 0) continue;
2791
+ }
2792
+ const cachedBalanceMc = Number(row.cached_balance_mc ?? 0);
2793
+ const projectedBalanceMc = Number(row.projected_balance_mc ?? 0);
2794
+ if (cachedBalanceMc === projectedBalanceMc) return projectedBalanceMc;
2795
+ const updated = await db.execute({
2796
+ sql: "UPDATE users SET balance_mc = ? WHERE id = ? AND balance_mc = ?",
2797
+ args: [projectedBalanceMc, userId, cachedBalanceMc]
2595
2798
  });
2799
+ if (updated.rowsAffected === 1) return projectedBalanceMc;
2596
2800
  }
2597
- return balanceMc;
2801
+ const current = await db.execute({
2802
+ sql: "SELECT balance_mc FROM users WHERE id = ? LIMIT 1",
2803
+ args: [userId]
2804
+ });
2805
+ return Number(current.rows[0]?.balance_mc ?? 0);
2598
2806
  }
2599
2807
  var SiteAuditJobRowSchema = z.object({
2600
2808
  id: z.string(),
@@ -2678,6 +2886,7 @@ async function listActiveUserIds() {
2678
2886
  }
2679
2887
 
2680
2888
  export {
2889
+ CREDIT_LOTS_EPOCH,
2681
2890
  CORE_SCHEMA_VERSION,
2682
2891
  getDb,
2683
2892
  setDbForTesting,
@@ -2690,7 +2899,9 @@ export {
2690
2899
  getRefresh,
2691
2900
  revokeRefresh,
2692
2901
  rotateRefresh,
2693
- recordStripeEvent,
2902
+ claimStripeEvent,
2903
+ completeStripeEvent,
2904
+ failStripeEvent,
2694
2905
  stripeEventAlreadyProcessed,
2695
2906
  generateApiKey,
2696
2907
  hashPassword,
@@ -2793,4 +3004,4 @@ export {
2793
3004
  insertInboxMessage,
2794
3005
  listActiveUserIds
2795
3006
  };
2796
- //# sourceMappingURL=chunk-CSCD2HNS.js.map
3007
+ //# sourceMappingURL=chunk-N7KUTTCC.js.map