mcp-scraper 0.88.2 → 0.89.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/CHANGELOG.md +36 -15
- package/README.md +6 -5
- package/THIRD_PARTY_NOTICES.html +203 -0
- package/dist/analytics-repository-2BMT5JNE.js +1 -0
- package/dist/bin/api-server.js +2 -41
- package/dist/bin/mcp-scraper-cli.js +39 -756
- package/dist/bin/mcp-scraper-core.js +1 -60
- package/dist/bin/mcp-scraper-install.js +2 -25
- package/dist/bin/mcp-stdio-server.js +1 -19
- package/dist/bin/paa-harvest.js +1 -41
- package/dist/chunk-3GP5CYZX.js +1 -0
- package/dist/chunk-4AI7DOS7.js +59 -0
- package/dist/chunk-4FROKQJN.js +1 -0
- package/dist/chunk-7YGVI5J4.js +21710 -0
- package/dist/chunk-CCYWSJNG.js +16 -0
- package/dist/chunk-CFI6CXIV.js +182 -0
- package/dist/chunk-E2WRWV3A.js +1 -0
- package/dist/chunk-GMWKPIYX.js +1172 -0
- package/dist/chunk-HDPYG3XV.js +102 -0
- package/dist/chunk-HE45FFBU.js +1 -0
- package/dist/chunk-HUV2WTRW.js +1 -0
- package/dist/chunk-KJQXUZ4Y.js +4 -0
- package/dist/chunk-L4CGLFPU.js +4 -0
- package/dist/chunk-M22MM4N4.js +84 -0
- package/dist/chunk-MASR22K4.js +73 -0
- package/dist/chunk-MZN4U5BL.js +1 -0
- package/dist/chunk-PUHFVA7P.js +1280 -0
- package/dist/chunk-QPWPR5XG.js +10 -0
- package/dist/chunk-TMB56NCA.js +1 -0
- package/dist/chunk-TXENITMS.js +20 -0
- package/dist/chunk-W2BVJ7S2.js +13 -0
- package/dist/chunk-WO3N5FH2.js +5 -0
- package/dist/chunk-WSCGYRWA.js +2595 -0
- package/dist/chunk-X54CQLK2.js +1 -0
- package/dist/chunk-XLWNEVUZ.js +27 -0
- package/dist/chunk-XPZVJIZ2.js +100 -0
- package/dist/chunk-YQZGZBB4.js +1 -0
- package/dist/chunk-Z2QGQJS2.js +1 -0
- package/dist/db-F2MX63GI.js +1 -0
- package/dist/extract-bundle-SNUIHM3J.js +26 -0
- package/dist/gmail-service-BZ3H75XC.js +1 -0
- package/dist/index.cjs +21750 -6045
- package/dist/index.d.cts +14 -14
- package/dist/index.d.ts +14 -14
- package/dist/index.js +18 -315
- package/dist/lead-list-enrichment-repository-S2H3U7T7.js +1 -0
- package/dist/location-data-repository-OTWHWMV6.js +1 -0
- package/dist/server-RFR2A5UJ.js +7303 -0
- package/dist/site-extract-repository-SE776XDC.js +1 -0
- package/dist/worker-XUDSM3AL.js +1 -0
- package/package.json +17 -124
- package/dist/analytics-repository-GGJJCVVP.js +0 -194
- package/dist/chunk-4QMUF6XM.js +0 -1013
- package/dist/chunk-6HAV7LCE.js +0 -265
- package/dist/chunk-ABF2CGOZ.js +0 -113
- package/dist/chunk-C5Z4OFKW.js +0 -404
- package/dist/chunk-DNM65UCK.js +0 -299
- package/dist/chunk-EQGTEHLZ.js +0 -592
- package/dist/chunk-F5GQJWZU.js +0 -732
- package/dist/chunk-GGZEC22A.js +0 -215
- package/dist/chunk-GXBZXWXB.js +0 -184
- package/dist/chunk-IHXAXYIS.js +0 -843
- package/dist/chunk-K3Z5AQYE.js +0 -683
- package/dist/chunk-K45K75OF.js +0 -6
- package/dist/chunk-LFW2FRPJ.js +0 -224
- package/dist/chunk-MZDNZQWT.js +0 -2078
- package/dist/chunk-NVUKO5NN.js +0 -256
- package/dist/chunk-OM7HVEJ3.js +0 -26
- package/dist/chunk-OPQIGAFB.js +0 -286
- package/dist/chunk-OZJMVCDK.js +0 -16
- package/dist/chunk-P7FWOMU7.js +0 -505
- package/dist/chunk-PGJQDMC2.js +0 -383
- package/dist/chunk-PKZS6SHW.js +0 -33139
- package/dist/chunk-RJ7JVYKU.js +0 -68
- package/dist/chunk-S24LFPL7.js +0 -5262
- package/dist/chunk-T3MZISOF.js +0 -240
- package/dist/chunk-UZPTGUDV.js +0 -1915
- package/dist/chunk-X623GTBV.js +0 -8290
- package/dist/chunk-YXNDOQXN.js +0 -4018
- package/dist/db-Z34LPZNR.js +0 -284
- package/dist/extract-bundle-565SBZCR.js +0 -1003
- package/dist/gmail-service-E6ALS7JG.js +0 -25
- package/dist/lead-list-enrichment-repository-36RPVV6N.js +0 -67
- package/dist/location-data-repository-WPRG62GE.js +0 -34
- package/dist/server-SQZ3A7SY.js +0 -86606
- package/dist/site-extract-repository-VYFZASPU.js +0 -69
- package/dist/worker-LDCAULWL.js +0 -146
package/dist/chunk-YXNDOQXN.js
DELETED
|
@@ -1,4018 +0,0 @@
|
|
|
1
|
-
// src/api/db.ts
|
|
2
|
-
import { createClient } from "@libsql/client/http";
|
|
3
|
-
import { createHash, randomBytes, randomUUID, scryptSync, timingSafeEqual } from "crypto";
|
|
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/errors.ts
|
|
10
|
-
var RECAPTCHA_INSTRUCTIONS = "Google returned a CAPTCHA. Run with --headless=false to re-warm the browser profile, then retry.";
|
|
11
|
-
var PUBLIC_SERVICE_UNAVAILABLE_MESSAGE = "This operation is temporarily unavailable. Please retry later.";
|
|
12
|
-
var PUBLIC_ERROR_CODES = [
|
|
13
|
-
"request_aborted",
|
|
14
|
-
"captcha_exhausted",
|
|
15
|
-
"captcha_or_blocked",
|
|
16
|
-
"location_mismatch",
|
|
17
|
-
"proxy_tunnel_failed",
|
|
18
|
-
"proxy_unavailable",
|
|
19
|
-
"harvest_timeout",
|
|
20
|
-
"mcp_request_timeout",
|
|
21
|
-
"mcp_http_error",
|
|
22
|
-
"extraction_failed",
|
|
23
|
-
"concurrency_limit_exceeded",
|
|
24
|
-
"unauthorized",
|
|
25
|
-
"forbidden",
|
|
26
|
-
"invalid_request",
|
|
27
|
-
"rate_limited",
|
|
28
|
-
"upstream_rate_limited",
|
|
29
|
-
"insufficient_balance",
|
|
30
|
-
"service_unavailable",
|
|
31
|
-
"response_lost",
|
|
32
|
-
"vendor_unavailable",
|
|
33
|
-
"page_not_found",
|
|
34
|
-
"page_forbidden",
|
|
35
|
-
"page_rate_limited",
|
|
36
|
-
"page_server_error",
|
|
37
|
-
"page_http_error",
|
|
38
|
-
"bot_check_unresolved",
|
|
39
|
-
"page_too_large",
|
|
40
|
-
"page_unreachable",
|
|
41
|
-
"browser_session_interrupted",
|
|
42
|
-
"site_export_not_found",
|
|
43
|
-
"site_export_format_unavailable",
|
|
44
|
-
"site_export_read_failed",
|
|
45
|
-
"site_export_image_not_found",
|
|
46
|
-
"image_url_invalid",
|
|
47
|
-
"image_url_not_https",
|
|
48
|
-
"image_host_unresolvable",
|
|
49
|
-
"image_host_blocked",
|
|
50
|
-
"image_redirect_invalid",
|
|
51
|
-
"image_source_unavailable",
|
|
52
|
-
"image_too_large",
|
|
53
|
-
"image_redirect_limit",
|
|
54
|
-
"image_base64_invalid",
|
|
55
|
-
"image_source_missing",
|
|
56
|
-
"image_source_ambiguous",
|
|
57
|
-
"image_source_type_invalid",
|
|
58
|
-
"image_artifact_not_found",
|
|
59
|
-
"image_page_too_large",
|
|
60
|
-
"page_image_not_found",
|
|
61
|
-
"page_image_candidates_unusable",
|
|
62
|
-
"image_format_unsupported",
|
|
63
|
-
"image_storage_unconfigured",
|
|
64
|
-
"featured_image_unhostable"
|
|
65
|
-
];
|
|
66
|
-
var PUBLIC_ERROR_TYPES = [
|
|
67
|
-
"request_aborted",
|
|
68
|
-
"captcha",
|
|
69
|
-
"location_mismatch",
|
|
70
|
-
"connection",
|
|
71
|
-
"timeout",
|
|
72
|
-
"extraction",
|
|
73
|
-
"concurrency_limit",
|
|
74
|
-
"unauthorized",
|
|
75
|
-
"forbidden",
|
|
76
|
-
"invalid_request",
|
|
77
|
-
"rate_limit",
|
|
78
|
-
"billing",
|
|
79
|
-
"service_unavailable"
|
|
80
|
-
];
|
|
81
|
-
function serializePublicErrorEnvelope(envelope) {
|
|
82
|
-
return envelope ? JSON.stringify(envelope) : null;
|
|
83
|
-
}
|
|
84
|
-
function parsePublicErrorEnvelope(value) {
|
|
85
|
-
if (value == null) return null;
|
|
86
|
-
try {
|
|
87
|
-
const parsed = typeof value === "string" ? JSON.parse(value) : value;
|
|
88
|
-
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null;
|
|
89
|
-
const row = parsed;
|
|
90
|
-
if (typeof row.error_code !== "string" || !PUBLIC_ERROR_CODE_SET.has(row.error_code)) return null;
|
|
91
|
-
const envelope = buildPublicErrorEnvelope({
|
|
92
|
-
errorCode: row.error_code,
|
|
93
|
-
errorType: row.error_type,
|
|
94
|
-
retryable: row.retryable,
|
|
95
|
-
retryAfterSeconds: row.retry_after_seconds,
|
|
96
|
-
chargeStatus: row.charge_status,
|
|
97
|
-
details: row.details
|
|
98
|
-
});
|
|
99
|
-
if (typeof row.message === "string") {
|
|
100
|
-
const message = sanitizeVendorName(row.message).trim().slice(0, 1e3);
|
|
101
|
-
if (message && !/https?:\/\//i.test(message)) envelope.message = message;
|
|
102
|
-
}
|
|
103
|
-
return envelope;
|
|
104
|
-
} catch {
|
|
105
|
-
return null;
|
|
106
|
-
}
|
|
107
|
-
}
|
|
108
|
-
var PUBLIC_ERROR_CODE_SET = new Set(PUBLIC_ERROR_CODES);
|
|
109
|
-
var PUBLIC_ERROR_TYPE_SET = new Set(PUBLIC_ERROR_TYPES);
|
|
110
|
-
var PUBLIC_DETAIL_KEYS = /* @__PURE__ */ new Set([
|
|
111
|
-
"active",
|
|
112
|
-
"limit",
|
|
113
|
-
"operation",
|
|
114
|
-
"upgrade_url",
|
|
115
|
-
"topup_url",
|
|
116
|
-
"upgrade_command",
|
|
117
|
-
"slots_per_pack",
|
|
118
|
-
"pack_price_usd",
|
|
119
|
-
"balance_credits",
|
|
120
|
-
"required_credits",
|
|
121
|
-
"retry_guidance",
|
|
122
|
-
"http_status"
|
|
123
|
-
]);
|
|
124
|
-
var MAX_PUBLIC_DETAILS = 12;
|
|
125
|
-
var MAX_PUBLIC_DETAIL_TEXT = 500;
|
|
126
|
-
function normalizePublicErrorCode(code) {
|
|
127
|
-
return typeof code === "string" && PUBLIC_ERROR_CODE_SET.has(code) ? code : "service_unavailable";
|
|
128
|
-
}
|
|
129
|
-
function sanitizePublicErrorDetails(details) {
|
|
130
|
-
if (!details || typeof details !== "object" || Array.isArray(details)) return void 0;
|
|
131
|
-
const safe = {};
|
|
132
|
-
for (const [key, value] of Object.entries(details)) {
|
|
133
|
-
if (Object.keys(safe).length >= MAX_PUBLIC_DETAILS) break;
|
|
134
|
-
if (!PUBLIC_DETAIL_KEYS.has(key)) continue;
|
|
135
|
-
if (typeof value === "string") {
|
|
136
|
-
if ((key === "upgrade_url" || key === "topup_url") && !isSafePublicAccountUrl(value)) continue;
|
|
137
|
-
const sanitized = sanitizeVendorName(value).slice(0, MAX_PUBLIC_DETAIL_TEXT);
|
|
138
|
-
if (sanitized) safe[key] = sanitized;
|
|
139
|
-
} else if (typeof value === "number" && Number.isFinite(value)) safe[key] = value;
|
|
140
|
-
else if (typeof value === "boolean" || value === null) safe[key] = value;
|
|
141
|
-
}
|
|
142
|
-
return Object.keys(safe).length > 0 ? safe : void 0;
|
|
143
|
-
}
|
|
144
|
-
function isSafePublicAccountUrl(value) {
|
|
145
|
-
if (value.startsWith("/") && !value.startsWith("//")) return true;
|
|
146
|
-
try {
|
|
147
|
-
const url = new URL(value);
|
|
148
|
-
return url.protocol === "https:" && (url.hostname === "mcpscraper.dev" || url.hostname.endsWith(".mcpscraper.dev"));
|
|
149
|
-
} catch {
|
|
150
|
-
return false;
|
|
151
|
-
}
|
|
152
|
-
}
|
|
153
|
-
function publicErrorType(code) {
|
|
154
|
-
const publicCode = normalizePublicErrorCode(code);
|
|
155
|
-
switch (publicCode) {
|
|
156
|
-
case "captcha_exhausted":
|
|
157
|
-
case "captcha_or_blocked":
|
|
158
|
-
return "captcha";
|
|
159
|
-
case "proxy_tunnel_failed":
|
|
160
|
-
case "proxy_unavailable":
|
|
161
|
-
case "response_lost":
|
|
162
|
-
return "connection";
|
|
163
|
-
case "harvest_timeout":
|
|
164
|
-
case "mcp_request_timeout":
|
|
165
|
-
return "timeout";
|
|
166
|
-
case "concurrency_limit_exceeded":
|
|
167
|
-
return "concurrency_limit";
|
|
168
|
-
case "rate_limited":
|
|
169
|
-
case "upstream_rate_limited":
|
|
170
|
-
return "rate_limit";
|
|
171
|
-
case "extraction_failed":
|
|
172
|
-
case "page_not_found":
|
|
173
|
-
case "page_forbidden":
|
|
174
|
-
case "page_rate_limited":
|
|
175
|
-
case "page_server_error":
|
|
176
|
-
case "page_http_error":
|
|
177
|
-
case "bot_check_unresolved":
|
|
178
|
-
case "page_too_large":
|
|
179
|
-
case "page_unreachable":
|
|
180
|
-
case "browser_session_interrupted":
|
|
181
|
-
case "site_export_not_found":
|
|
182
|
-
case "site_export_format_unavailable":
|
|
183
|
-
case "site_export_read_failed":
|
|
184
|
-
case "site_export_image_not_found":
|
|
185
|
-
return "extraction";
|
|
186
|
-
case "image_url_invalid":
|
|
187
|
-
case "image_url_not_https":
|
|
188
|
-
case "image_host_unresolvable":
|
|
189
|
-
case "image_host_blocked":
|
|
190
|
-
case "image_redirect_invalid":
|
|
191
|
-
case "image_source_unavailable":
|
|
192
|
-
case "image_too_large":
|
|
193
|
-
case "image_redirect_limit":
|
|
194
|
-
case "image_base64_invalid":
|
|
195
|
-
case "image_source_missing":
|
|
196
|
-
case "image_source_ambiguous":
|
|
197
|
-
case "image_source_type_invalid":
|
|
198
|
-
case "image_artifact_not_found":
|
|
199
|
-
case "image_page_too_large":
|
|
200
|
-
case "page_image_not_found":
|
|
201
|
-
case "page_image_candidates_unusable":
|
|
202
|
-
case "image_format_unsupported":
|
|
203
|
-
case "image_storage_unconfigured":
|
|
204
|
-
case "featured_image_unhostable":
|
|
205
|
-
return "invalid_request";
|
|
206
|
-
case "insufficient_balance":
|
|
207
|
-
return "billing";
|
|
208
|
-
case "vendor_unavailable":
|
|
209
|
-
case "mcp_http_error":
|
|
210
|
-
return "service_unavailable";
|
|
211
|
-
default:
|
|
212
|
-
return publicCode;
|
|
213
|
-
}
|
|
214
|
-
}
|
|
215
|
-
function publicErrorMessage(code) {
|
|
216
|
-
switch (normalizePublicErrorCode(code)) {
|
|
217
|
-
case "request_aborted":
|
|
218
|
-
return "The request was cancelled before it completed.";
|
|
219
|
-
case "captcha_exhausted":
|
|
220
|
-
case "captcha_or_blocked":
|
|
221
|
-
return "The target site returned a verification challenge. Heavy traffic can temporarily increase these challenges; retry in a few minutes.";
|
|
222
|
-
case "location_mismatch":
|
|
223
|
-
return "The target site returned results for a different location. Please retry.";
|
|
224
|
-
case "proxy_tunnel_failed":
|
|
225
|
-
case "proxy_unavailable":
|
|
226
|
-
return "The web request could not establish a working connection. Please retry later.";
|
|
227
|
-
case "harvest_timeout":
|
|
228
|
-
return "The request timed out before it completed. Please retry with a smaller request.";
|
|
229
|
-
case "mcp_request_timeout":
|
|
230
|
-
return "The MCP caller stopped waiting before a final response arrived. Durable work may still be running; continue with the returned Task or job handle instead of restarting it.";
|
|
231
|
-
case "concurrency_limit_exceeded":
|
|
232
|
-
return "The service is available, but this account has reached its parallel-operation limit. Wait for an active operation to finish or add capacity.";
|
|
233
|
-
case "unauthorized":
|
|
234
|
-
return "Authentication is required to perform this operation.";
|
|
235
|
-
case "forbidden":
|
|
236
|
-
return "This account does not have permission to perform this operation.";
|
|
237
|
-
case "invalid_request":
|
|
238
|
-
return "The request is invalid. Check the supplied inputs and try again.";
|
|
239
|
-
case "rate_limited":
|
|
240
|
-
return "This account is sending requests too quickly. Wait briefly and retry.";
|
|
241
|
-
case "upstream_rate_limited":
|
|
242
|
-
return "The connected provider is temporarily rate-limiting requests. Wait briefly and retry.";
|
|
243
|
-
case "mcp_http_error":
|
|
244
|
-
return "The API returned an unexpected HTTP response.";
|
|
245
|
-
case "insufficient_balance":
|
|
246
|
-
return "This account does not have enough Credits for the operation.";
|
|
247
|
-
case "extraction_failed":
|
|
248
|
-
return "The target could not be extracted. Please retry later.";
|
|
249
|
-
case "response_lost":
|
|
250
|
-
return "The connection was lost before the response arrived. The operation may have already completed and been billed on our side \u2014 check credits_info before retrying blind.";
|
|
251
|
-
case "vendor_unavailable":
|
|
252
|
-
return "MCP Scraper's internal browser services require MCP Scraper team attention. Servers or IPs are down until this is fixed \u2014 this is not caused by your request. Please retry in a few minutes.";
|
|
253
|
-
case "page_not_found":
|
|
254
|
-
return "The page could not be found. It may have been moved or deleted.";
|
|
255
|
-
case "page_forbidden":
|
|
256
|
-
return "The site refused the request.";
|
|
257
|
-
case "page_rate_limited":
|
|
258
|
-
return "The site is rate-limiting requests right now. Retrying after a short wait usually works.";
|
|
259
|
-
case "page_server_error":
|
|
260
|
-
return "The page's own server returned an error. This is on the target site's side, not something a retry here can fix.";
|
|
261
|
-
case "page_http_error":
|
|
262
|
-
return "The page returned an unexpected HTTP response.";
|
|
263
|
-
case "bot_check_unresolved":
|
|
264
|
-
return "This site has automated-traffic protection (a bot/CAPTCHA check) that could not be resolved in time. Some sites are simply not extractable this way.";
|
|
265
|
-
case "page_too_large":
|
|
266
|
-
return "The page is larger than we can safely process.";
|
|
267
|
-
case "page_unreachable":
|
|
268
|
-
return "The page did not respond in time, or the connection could not be completed.";
|
|
269
|
-
case "browser_session_interrupted":
|
|
270
|
-
return "The browser session closed before extraction completed. MCP Scraper will retry once with a fresh session; retry the request if this persists.";
|
|
271
|
-
case "site_export_not_found":
|
|
272
|
-
return "The site export or requested page was not found, expired, or does not belong to this account.";
|
|
273
|
-
case "site_export_format_unavailable":
|
|
274
|
-
return "That representation was not retained for this export page. Read the export manifest and request one of the formats marked available.";
|
|
275
|
-
case "site_export_read_failed":
|
|
276
|
-
return "The stored site export could not be read or did not pass its integrity check.";
|
|
277
|
-
case "site_export_image_not_found":
|
|
278
|
-
return "The requested site-export image was not found, expired, or does not belong to this account.";
|
|
279
|
-
case "image_url_invalid":
|
|
280
|
-
return "The image source is not a valid URL. Use a stable public HTTPS URL or inline image bytes.";
|
|
281
|
-
case "image_url_not_https":
|
|
282
|
-
return "Commons image sources must use a public HTTPS URL or inline image bytes.";
|
|
283
|
-
case "image_host_unresolvable":
|
|
284
|
-
return "The image host could not be resolved. Use a different stable public URL or inline image bytes.";
|
|
285
|
-
case "image_host_blocked":
|
|
286
|
-
return "The image source resolves to a network address Commons cannot fetch safely.";
|
|
287
|
-
case "image_redirect_invalid":
|
|
288
|
-
return "The image source redirected to an invalid or unsafe destination.";
|
|
289
|
-
case "image_source_unavailable":
|
|
290
|
-
return "The image source could not be fetched. Host a different stable public URL or provide inline image bytes.";
|
|
291
|
-
case "image_too_large":
|
|
292
|
-
return "The image exceeds the Commons image size limit.";
|
|
293
|
-
case "image_redirect_limit":
|
|
294
|
-
return "The image source exceeded the Commons redirect limit.";
|
|
295
|
-
case "image_base64_invalid":
|
|
296
|
-
return "The supplied inline image bytes are invalid.";
|
|
297
|
-
case "image_source_missing":
|
|
298
|
-
return "Provide exactly one of a public image or webpage URL, inline image bytes, or an owner-scoped image artifact.";
|
|
299
|
-
case "image_source_ambiguous":
|
|
300
|
-
return "Provide only one image source: sourceUrl, imageBase64, or artifactId.";
|
|
301
|
-
case "image_source_type_invalid":
|
|
302
|
-
return "Use sourceType only with inline image bytes or an owner-scoped image artifact.";
|
|
303
|
-
case "image_artifact_not_found":
|
|
304
|
-
return "The image artifact was not found, expired, or does not belong to this account.";
|
|
305
|
-
case "image_page_too_large":
|
|
306
|
-
return "The webpage is too large for bounded Commons image discovery.";
|
|
307
|
-
case "page_image_not_found":
|
|
308
|
-
return "The webpage did not expose a usable featured or content image.";
|
|
309
|
-
case "page_image_candidates_unusable":
|
|
310
|
-
return "The webpage exposed image candidates, but none returned supported public image bytes.";
|
|
311
|
-
case "image_format_unsupported":
|
|
312
|
-
return "Commons accepts JPEG, PNG, GIF, or WebP image bytes.";
|
|
313
|
-
case "image_storage_unconfigured":
|
|
314
|
-
return "Commons image storage is temporarily unavailable.";
|
|
315
|
-
case "featured_image_unhostable":
|
|
316
|
-
return "The required featured image could not be hosted for Commons publication.";
|
|
317
|
-
default:
|
|
318
|
-
return PUBLIC_SERVICE_UNAVAILABLE_MESSAGE;
|
|
319
|
-
}
|
|
320
|
-
}
|
|
321
|
-
function buildPublicErrorEnvelope(input) {
|
|
322
|
-
const errorCode = normalizePublicErrorCode(input.errorCode);
|
|
323
|
-
const retryAfter = typeof input.retryAfterSeconds === "number" && Number.isFinite(input.retryAfterSeconds) ? Math.max(1, Math.min(86400, Math.trunc(input.retryAfterSeconds))) : void 0;
|
|
324
|
-
const chargeStatus = input.chargeStatus === "not_charged" || input.chargeStatus === "refund_pending" || input.chargeStatus === "refunded" || input.chargeStatus === "charged" ? input.chargeStatus : void 0;
|
|
325
|
-
const requestedType = typeof input.errorType === "string" && PUBLIC_ERROR_TYPE_SET.has(input.errorType) ? input.errorType : void 0;
|
|
326
|
-
const details = sanitizePublicErrorDetails(input.details);
|
|
327
|
-
return {
|
|
328
|
-
error_code: errorCode,
|
|
329
|
-
error_type: requestedType ?? publicErrorType(errorCode),
|
|
330
|
-
message: publicErrorMessage(errorCode),
|
|
331
|
-
retryable: input.retryable === true,
|
|
332
|
-
...retryAfter ? { retry_after_seconds: retryAfter } : {},
|
|
333
|
-
...chargeStatus ? { charge_status: chargeStatus } : {},
|
|
334
|
-
...details ? { details } : {}
|
|
335
|
-
};
|
|
336
|
-
}
|
|
337
|
-
function sanitizeVendorName(message) {
|
|
338
|
-
return message.replace(/https?:\/\/[^\s]*(?:brightdata|onkernel|kernel\.sh)[^\s]*/gi, "the service").replace(/bright\s*data/gi, "the service").replace(/brightdata/gi, "the service").replace(/kernel\.sh\s+sessions?/gi, "sessions").replace(/kernel\.sh\s+session/gi, "this session").replace(/kernel\.sh/gi, "the service").replace(/kernel\s+sessions?/gi, "sessions").replace(/kernel\s+session/gi, "this session").replace(/\b(?:sub|cus|sess|proxy)_[A-Za-z0-9_-]+\b/g, "[redacted]").replace(/\b(?:KERNEL|BRIGHTDATA|BRIGHT_DATA)_[A-Z0-9_]*API_KEY\b/g, "BROWSER_SERVICE_API_KEY").replace(/\bkernel\b/gi, "the service").replace(/ +/g, " ").trim();
|
|
339
|
-
}
|
|
340
|
-
var CaptchaError = class extends Error {
|
|
341
|
-
constructor(instructions) {
|
|
342
|
-
super(`CAPTCHA detected. ${instructions}`);
|
|
343
|
-
this.instructions = instructions;
|
|
344
|
-
}
|
|
345
|
-
instructions;
|
|
346
|
-
name = "CaptchaError";
|
|
347
|
-
};
|
|
348
|
-
var ExtractionError = class extends Error {
|
|
349
|
-
constructor(message, cause) {
|
|
350
|
-
super(message);
|
|
351
|
-
this.cause = cause;
|
|
352
|
-
}
|
|
353
|
-
cause;
|
|
354
|
-
name = "ExtractionError";
|
|
355
|
-
};
|
|
356
|
-
var MapsSearchIncompletePageError = class extends Error {
|
|
357
|
-
constructor(pageState) {
|
|
358
|
-
super(`Google Maps search did not reach a complete result state (${pageState})`);
|
|
359
|
-
this.pageState = pageState;
|
|
360
|
-
}
|
|
361
|
-
pageState;
|
|
362
|
-
name = "MapsSearchIncompletePageError";
|
|
363
|
-
};
|
|
364
|
-
var RequestAbortedError = class extends Error {
|
|
365
|
-
name = "RequestAbortedError";
|
|
366
|
-
constructor(message = "Request aborted before harvest completed") {
|
|
367
|
-
super(message);
|
|
368
|
-
}
|
|
369
|
-
};
|
|
370
|
-
var PaaInteractionInterruptedError = class extends Error {
|
|
371
|
-
name = "PaaInteractionInterruptedError";
|
|
372
|
-
};
|
|
373
|
-
var LocationMismatchError = class extends Error {
|
|
374
|
-
name = "LocationMismatchError";
|
|
375
|
-
constructor(message = "Google returned results for a different location than requested") {
|
|
376
|
-
super(message);
|
|
377
|
-
}
|
|
378
|
-
};
|
|
379
|
-
|
|
380
|
-
// src/api/db.ts
|
|
381
|
-
var DB_URL = process.env.TURSO_DATABASE_URL ?? "file:./paa-api.db";
|
|
382
|
-
var DB_TOKEN = process.env.TURSO_AUTH_TOKEN;
|
|
383
|
-
var _db = null;
|
|
384
|
-
var _rateLimitSchemaReady = false;
|
|
385
|
-
var CORE_SCHEMA_VERSION = "2026-08-27.1";
|
|
386
|
-
var SITE_EXTRACT_COUNTER_REPAIR_VERSION = "data-2026-07-28-site-extract-counters.1";
|
|
387
|
-
var CORE_SCHEMA_TABLES = [
|
|
388
|
-
"admin_credit_adjustment_events",
|
|
389
|
-
"admin_credit_adjustments",
|
|
390
|
-
"billing_authorizations",
|
|
391
|
-
"billing_credits",
|
|
392
|
-
"billing_debits",
|
|
393
|
-
"billing_debit_allocations",
|
|
394
|
-
"billing_events",
|
|
395
|
-
"browser_profile_labels",
|
|
396
|
-
"connected_account_billing",
|
|
397
|
-
"concurrency_locks",
|
|
398
|
-
"credit_lot_expirations",
|
|
399
|
-
"credit_lots",
|
|
400
|
-
"credit_lot_repairs",
|
|
401
|
-
"free_credit_refreshes",
|
|
402
|
-
"harvest_attempts",
|
|
403
|
-
"inbox_messages",
|
|
404
|
-
"jobs",
|
|
405
|
-
"kpo_jobs",
|
|
406
|
-
"kpo_phase_log",
|
|
407
|
-
"ledger",
|
|
408
|
-
"oauth_clients",
|
|
409
|
-
"oauth_codes",
|
|
410
|
-
"oauth_tokens",
|
|
411
|
-
"page_snapshots",
|
|
412
|
-
"password_reset_tokens",
|
|
413
|
-
"rate_limits",
|
|
414
|
-
"request_events",
|
|
415
|
-
"scheduled_run_view_links",
|
|
416
|
-
"serp_identities",
|
|
417
|
-
"site_audit_jobs",
|
|
418
|
-
"site_audit_phase_log",
|
|
419
|
-
"site_extract_jobs",
|
|
420
|
-
"site_extract_pages",
|
|
421
|
-
"stripe_events",
|
|
422
|
-
"users",
|
|
423
|
-
"workflow_artifacts",
|
|
424
|
-
"workflow_runs",
|
|
425
|
-
"workflow_schedules",
|
|
426
|
-
"workflow_webhook_deliveries",
|
|
427
|
-
"workshop_registrations"
|
|
428
|
-
];
|
|
429
|
-
var CORE_SCHEMA_INDEXES = [
|
|
430
|
-
"admin_credit_adjustment_events_reference",
|
|
431
|
-
"admin_credit_adjustments_user_created",
|
|
432
|
-
"billing_authorizations_user_status",
|
|
433
|
-
"billing_credits_user_status",
|
|
434
|
-
"billing_debits_user_status",
|
|
435
|
-
"billing_events_authorization",
|
|
436
|
-
"billing_events_user_created_at",
|
|
437
|
-
"concurrency_locks_active_user",
|
|
438
|
-
"concurrency_locks_operation",
|
|
439
|
-
"concurrency_locks_user_status",
|
|
440
|
-
"credit_lots_user_active",
|
|
441
|
-
"credit_lot_repairs_user_applied",
|
|
442
|
-
"harvest_attempts_job_id",
|
|
443
|
-
"harvest_attempts_provider_lookup",
|
|
444
|
-
"harvest_attempts_user_created_at",
|
|
445
|
-
"inbox_messages_user_sent_at",
|
|
446
|
-
"jobs_created_at_status_user",
|
|
447
|
-
"jobs_status",
|
|
448
|
-
"jobs_user_created_at_status",
|
|
449
|
-
"jobs_user_id",
|
|
450
|
-
"kpo_jobs_status",
|
|
451
|
-
"kpo_jobs_user_id",
|
|
452
|
-
"kpo_phase_log_job_id",
|
|
453
|
-
"ledger_stripe_pi_unique",
|
|
454
|
-
"ledger_user_id",
|
|
455
|
-
"oauth_tokens_identity",
|
|
456
|
-
"page_snapshots_user_checked_at",
|
|
457
|
-
"prt_user_id",
|
|
458
|
-
"request_events_created_at",
|
|
459
|
-
"request_events_user_created_at",
|
|
460
|
-
"scheduled_run_view_links_expiry",
|
|
461
|
-
"scheduled_run_view_links_owner_run",
|
|
462
|
-
"serp_identities_user_name",
|
|
463
|
-
"site_audit_jobs_status",
|
|
464
|
-
"site_audit_jobs_user_id",
|
|
465
|
-
"site_audit_phase_log_job_id",
|
|
466
|
-
"site_extract_jobs_status",
|
|
467
|
-
"site_extract_jobs_idempotency",
|
|
468
|
-
"site_extract_jobs_user_id",
|
|
469
|
-
"site_extract_pages_job_id",
|
|
470
|
-
"workflow_artifacts_run",
|
|
471
|
-
"workflow_runs_idempotency",
|
|
472
|
-
"workflow_runs_schedule",
|
|
473
|
-
"workflow_runs_user_created",
|
|
474
|
-
"workflow_schedules_due",
|
|
475
|
-
"workflow_schedules_user_status",
|
|
476
|
-
"workflow_webhook_deliveries_run",
|
|
477
|
-
"workshop_registrations_event_status"
|
|
478
|
-
];
|
|
479
|
-
var CORE_SCHEMA_TRIGGERS = [
|
|
480
|
-
"credit_lots_bounds_insert",
|
|
481
|
-
"credit_lots_bounds_update",
|
|
482
|
-
"credit_lots_one_migration_insert",
|
|
483
|
-
"credit_lots_one_migration_update"
|
|
484
|
-
];
|
|
485
|
-
var CORE_SCHEMA_COLUMNS = {
|
|
486
|
-
credit_lots: ["never_expires"],
|
|
487
|
-
harvest_attempts: [
|
|
488
|
-
"debug_json",
|
|
489
|
-
"browser_provider",
|
|
490
|
-
"provider_session_id",
|
|
491
|
-
"provider_session_observed_at",
|
|
492
|
-
"provider_lookup_status",
|
|
493
|
-
"provider_status",
|
|
494
|
-
"provider_error_code",
|
|
495
|
-
"provider_error_message",
|
|
496
|
-
"provider_reason_source",
|
|
497
|
-
"provider_duration_ms",
|
|
498
|
-
"provider_bandwidth_bytes",
|
|
499
|
-
"provider_captcha",
|
|
500
|
-
"provider_checked_at",
|
|
501
|
-
"provider_detail_json",
|
|
502
|
-
"provider_disconnect_observed",
|
|
503
|
-
"provider_disconnect_message"
|
|
504
|
-
],
|
|
505
|
-
jobs: ["public_error_json"],
|
|
506
|
-
oauth_tokens: ["replaced_by", "rotated_at"],
|
|
507
|
-
site_extract_jobs: [
|
|
508
|
-
"attempted_urls",
|
|
509
|
-
"successful_urls",
|
|
510
|
-
"failed_urls",
|
|
511
|
-
"idempotency_key",
|
|
512
|
-
"request_fingerprint",
|
|
513
|
-
"dispatch_attempts",
|
|
514
|
-
"next_dispatch_at",
|
|
515
|
-
"dispatch_error",
|
|
516
|
-
"settlement_attempts",
|
|
517
|
-
"next_settlement_at",
|
|
518
|
-
"settlement_error",
|
|
519
|
-
"public_error_json"
|
|
520
|
-
],
|
|
521
|
-
stripe_events: ["attempt_count", "claim_token", "last_error", "status", "updated_at"],
|
|
522
|
-
users: [
|
|
523
|
-
"balance_mc",
|
|
524
|
-
"concurrency_stripe_sub_id",
|
|
525
|
-
"concurrency_pack_quantity",
|
|
526
|
-
"extra_concurrency_slots",
|
|
527
|
-
"key_active",
|
|
528
|
-
"memory_identity",
|
|
529
|
-
"memory_key",
|
|
530
|
-
"memory_plan",
|
|
531
|
-
"memory_provisioned",
|
|
532
|
-
"memory_subscription_id",
|
|
533
|
-
"password_hash",
|
|
534
|
-
"stripe_customer_id",
|
|
535
|
-
"subscription_concurrency",
|
|
536
|
-
"subscription_id",
|
|
537
|
-
"subscription_tier"
|
|
538
|
-
],
|
|
539
|
-
workshop_registrations: ["field_notes_email_id", "reminder_48h_email_id", "reminder_24h_email_id", "reminder_1h_email_id"],
|
|
540
|
-
workflow_artifacts: ["content"],
|
|
541
|
-
workflow_runs: ["step_index", "step_log_json", "step_state_json", "total_steps", "webhook_url"]
|
|
542
|
-
};
|
|
543
|
-
function sqlStringList(values) {
|
|
544
|
-
return values.map((value) => `'${value.replaceAll("'", "''")}'`).join(", ");
|
|
545
|
-
}
|
|
546
|
-
async function hasCurrentCoreSchema(db) {
|
|
547
|
-
const checks = [
|
|
548
|
-
`(SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name IN (${sqlStringList(CORE_SCHEMA_TABLES)})) = ${CORE_SCHEMA_TABLES.length}`,
|
|
549
|
-
`(SELECT COUNT(*) FROM sqlite_master WHERE type = 'index' AND name IN (${sqlStringList(CORE_SCHEMA_INDEXES)})) = ${CORE_SCHEMA_INDEXES.length}`,
|
|
550
|
-
`(SELECT COUNT(*) FROM sqlite_master WHERE type = 'trigger' AND name IN (${sqlStringList(CORE_SCHEMA_TRIGGERS)})) = ${CORE_SCHEMA_TRIGGERS.length}`,
|
|
551
|
-
...Object.entries(CORE_SCHEMA_COLUMNS).map(
|
|
552
|
-
([table, columns]) => `(SELECT COUNT(*) FROM pragma_table_info('${table}') WHERE name IN (${sqlStringList(columns)})) = ${columns.length}`
|
|
553
|
-
)
|
|
554
|
-
];
|
|
555
|
-
const result = await db.execute(`SELECT ${checks.join(" AND ")} AS ready`);
|
|
556
|
-
return Number(result.rows[0]?.ready ?? 0) === 1;
|
|
557
|
-
}
|
|
558
|
-
async function runSiteExtractCounterRepair(db) {
|
|
559
|
-
await db.execute(`
|
|
560
|
-
UPDATE site_extract_jobs SET
|
|
561
|
-
attempted_urls = done_urls,
|
|
562
|
-
successful_urls = (
|
|
563
|
-
SELECT COUNT(*) FROM site_extract_pages
|
|
564
|
-
WHERE job_id = site_extract_jobs.id
|
|
565
|
-
AND CASE
|
|
566
|
-
WHEN json_valid(page) = 0 THEN 0
|
|
567
|
-
WHEN json_extract(page, '$.extractionStatus') IS NOT NULL
|
|
568
|
-
THEN json_extract(page, '$.extractionStatus') = 'successful'
|
|
569
|
-
ELSE COALESCE(
|
|
570
|
-
json_extract(page, '$.status') >= 200
|
|
571
|
-
AND json_extract(page, '$.status') < 300
|
|
572
|
-
AND json_extract(page, '$.wordCount') > 0,
|
|
573
|
-
0
|
|
574
|
-
)
|
|
575
|
-
END
|
|
576
|
-
),
|
|
577
|
-
failed_urls = MAX(0, done_urls - (
|
|
578
|
-
SELECT COUNT(*) FROM site_extract_pages
|
|
579
|
-
WHERE job_id = site_extract_jobs.id
|
|
580
|
-
AND CASE
|
|
581
|
-
WHEN json_valid(page) = 0 THEN 0
|
|
582
|
-
WHEN json_extract(page, '$.extractionStatus') IS NOT NULL
|
|
583
|
-
THEN json_extract(page, '$.extractionStatus') = 'successful'
|
|
584
|
-
ELSE COALESCE(
|
|
585
|
-
json_extract(page, '$.status') >= 200
|
|
586
|
-
AND json_extract(page, '$.status') < 300
|
|
587
|
-
AND json_extract(page, '$.wordCount') > 0,
|
|
588
|
-
0
|
|
589
|
-
)
|
|
590
|
-
END
|
|
591
|
-
))
|
|
592
|
-
WHERE done_urls > 0
|
|
593
|
-
AND attempted_urls = 0
|
|
594
|
-
AND successful_urls = 0
|
|
595
|
-
AND failed_urls = 0
|
|
596
|
-
`);
|
|
597
|
-
}
|
|
598
|
-
async function runPendingDataRepairs(db) {
|
|
599
|
-
const applied = await db.execute({
|
|
600
|
-
sql: "SELECT 1 AS applied FROM schema_migrations WHERE version = ? LIMIT 1",
|
|
601
|
-
args: [SITE_EXTRACT_COUNTER_REPAIR_VERSION]
|
|
602
|
-
});
|
|
603
|
-
if (applied.rows.length > 0) return;
|
|
604
|
-
await runSiteExtractCounterRepair(db);
|
|
605
|
-
await db.execute({
|
|
606
|
-
sql: "INSERT OR IGNORE INTO schema_migrations (version) VALUES (?)",
|
|
607
|
-
args: [SITE_EXTRACT_COUNTER_REPAIR_VERSION]
|
|
608
|
-
});
|
|
609
|
-
}
|
|
610
|
-
function getDb() {
|
|
611
|
-
if (!_db) _db = createClient({ url: DB_URL, authToken: DB_TOKEN });
|
|
612
|
-
return _db;
|
|
613
|
-
}
|
|
614
|
-
function setDbForTesting(client) {
|
|
615
|
-
_db = client;
|
|
616
|
-
}
|
|
617
|
-
async function migrate(options = {}) {
|
|
618
|
-
const db = getDb();
|
|
619
|
-
let applied;
|
|
620
|
-
try {
|
|
621
|
-
applied = await db.execute({
|
|
622
|
-
sql: "SELECT 1 AS applied FROM schema_migrations WHERE version = ? LIMIT 1",
|
|
623
|
-
args: [CORE_SCHEMA_VERSION]
|
|
624
|
-
});
|
|
625
|
-
} catch (error) {
|
|
626
|
-
const message = error instanceof Error ? error.message.toLowerCase() : String(error).toLowerCase();
|
|
627
|
-
if (!message.includes("no such table") || !message.includes("schema_migrations")) throw error;
|
|
628
|
-
await db.execute(`
|
|
629
|
-
CREATE TABLE IF NOT EXISTS schema_migrations (
|
|
630
|
-
version TEXT PRIMARY KEY,
|
|
631
|
-
applied_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
632
|
-
)
|
|
633
|
-
`);
|
|
634
|
-
applied = { rows: [] };
|
|
635
|
-
}
|
|
636
|
-
if (applied.rows.length > 0) {
|
|
637
|
-
if (!options.verifyCurrentSchema) return;
|
|
638
|
-
if (await hasCurrentCoreSchema(db)) {
|
|
639
|
-
if (options.runDataRepairs) await runPendingDataRepairs(db);
|
|
640
|
-
return;
|
|
641
|
-
}
|
|
642
|
-
}
|
|
643
|
-
if (await hasCurrentCoreSchema(db)) {
|
|
644
|
-
await runPendingDataRepairs(db);
|
|
645
|
-
await db.execute({
|
|
646
|
-
sql: "INSERT OR IGNORE INTO schema_migrations (version) VALUES (?)",
|
|
647
|
-
args: [CORE_SCHEMA_VERSION]
|
|
648
|
-
});
|
|
649
|
-
return;
|
|
650
|
-
}
|
|
651
|
-
await db.execute(`
|
|
652
|
-
CREATE TABLE IF NOT EXISTS users (
|
|
653
|
-
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
654
|
-
email TEXT UNIQUE NOT NULL,
|
|
655
|
-
name TEXT,
|
|
656
|
-
api_key TEXT UNIQUE NOT NULL,
|
|
657
|
-
password_hash TEXT,
|
|
658
|
-
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
659
|
-
active INTEGER NOT NULL DEFAULT 1
|
|
660
|
-
)
|
|
661
|
-
`);
|
|
662
|
-
await db.execute(`
|
|
663
|
-
CREATE TABLE IF NOT EXISTS jobs (
|
|
664
|
-
id TEXT PRIMARY KEY,
|
|
665
|
-
user_id INTEGER NOT NULL REFERENCES users(id),
|
|
666
|
-
status TEXT NOT NULL DEFAULT 'pending',
|
|
667
|
-
query TEXT NOT NULL,
|
|
668
|
-
options TEXT NOT NULL,
|
|
669
|
-
callback_url TEXT,
|
|
670
|
-
result TEXT,
|
|
671
|
-
error TEXT,
|
|
672
|
-
public_error_json TEXT,
|
|
673
|
-
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
674
|
-
started_at TEXT,
|
|
675
|
-
completed_at TEXT
|
|
676
|
-
)
|
|
677
|
-
`);
|
|
678
|
-
await db.execute(`CREATE INDEX IF NOT EXISTS jobs_user_id ON jobs(user_id)`);
|
|
679
|
-
await db.execute(`CREATE INDEX IF NOT EXISTS jobs_status ON jobs(status)`);
|
|
680
|
-
try {
|
|
681
|
-
await db.execute(`ALTER TABLE jobs ADD COLUMN public_error_json TEXT`);
|
|
682
|
-
} catch {
|
|
683
|
-
}
|
|
684
|
-
await db.execute(
|
|
685
|
-
`CREATE INDEX IF NOT EXISTS jobs_created_at_status_user ON jobs(created_at, status, user_id)`
|
|
686
|
-
);
|
|
687
|
-
await db.execute(
|
|
688
|
-
`CREATE INDEX IF NOT EXISTS jobs_user_created_at_status ON jobs(user_id, created_at, status)`
|
|
689
|
-
);
|
|
690
|
-
await db.execute(`
|
|
691
|
-
CREATE TABLE IF NOT EXISTS harvest_attempts (
|
|
692
|
-
id TEXT PRIMARY KEY,
|
|
693
|
-
job_id TEXT NOT NULL REFERENCES jobs(id),
|
|
694
|
-
user_id INTEGER NOT NULL REFERENCES users(id),
|
|
695
|
-
attempt_number INTEGER NOT NULL,
|
|
696
|
-
max_attempts INTEGER NOT NULL,
|
|
697
|
-
query TEXT NOT NULL,
|
|
698
|
-
location TEXT,
|
|
699
|
-
max_questions INTEGER NOT NULL,
|
|
700
|
-
status TEXT NOT NULL,
|
|
701
|
-
outcome TEXT,
|
|
702
|
-
kernel_session_id TEXT,
|
|
703
|
-
question_count INTEGER,
|
|
704
|
-
duration_ms INTEGER,
|
|
705
|
-
error TEXT,
|
|
706
|
-
will_retry INTEGER,
|
|
707
|
-
kernel_delete_started INTEGER NOT NULL DEFAULT 0,
|
|
708
|
-
kernel_delete_succeeded INTEGER,
|
|
709
|
-
kernel_delete_error TEXT,
|
|
710
|
-
browser_close_succeeded INTEGER,
|
|
711
|
-
browser_close_error TEXT,
|
|
712
|
-
debug_json TEXT,
|
|
713
|
-
browser_provider TEXT,
|
|
714
|
-
provider_session_id TEXT,
|
|
715
|
-
provider_session_observed_at TEXT,
|
|
716
|
-
provider_lookup_status TEXT,
|
|
717
|
-
provider_status TEXT,
|
|
718
|
-
provider_error_code TEXT,
|
|
719
|
-
provider_error_message TEXT,
|
|
720
|
-
provider_reason_source TEXT,
|
|
721
|
-
provider_duration_ms INTEGER,
|
|
722
|
-
provider_bandwidth_bytes INTEGER,
|
|
723
|
-
provider_captcha INTEGER,
|
|
724
|
-
provider_checked_at TEXT,
|
|
725
|
-
provider_detail_json TEXT,
|
|
726
|
-
provider_disconnect_observed INTEGER,
|
|
727
|
-
provider_disconnect_message TEXT,
|
|
728
|
-
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
729
|
-
completed_at TEXT,
|
|
730
|
-
UNIQUE(job_id, attempt_number)
|
|
731
|
-
)
|
|
732
|
-
`);
|
|
733
|
-
await db.execute(`CREATE INDEX IF NOT EXISTS harvest_attempts_job_id ON harvest_attempts(job_id)`);
|
|
734
|
-
await db.execute(`CREATE INDEX IF NOT EXISTS harvest_attempts_user_created_at ON harvest_attempts(user_id, created_at DESC)`);
|
|
735
|
-
try {
|
|
736
|
-
await db.execute(`ALTER TABLE harvest_attempts ADD COLUMN debug_json TEXT`);
|
|
737
|
-
} catch {
|
|
738
|
-
}
|
|
739
|
-
for (const column of [
|
|
740
|
-
"browser_provider TEXT",
|
|
741
|
-
"provider_session_id TEXT",
|
|
742
|
-
"provider_session_observed_at TEXT",
|
|
743
|
-
"provider_lookup_status TEXT",
|
|
744
|
-
"provider_status TEXT",
|
|
745
|
-
"provider_error_code TEXT",
|
|
746
|
-
"provider_error_message TEXT",
|
|
747
|
-
"provider_reason_source TEXT",
|
|
748
|
-
"provider_duration_ms INTEGER",
|
|
749
|
-
"provider_bandwidth_bytes INTEGER",
|
|
750
|
-
"provider_captcha INTEGER",
|
|
751
|
-
"provider_checked_at TEXT",
|
|
752
|
-
"provider_detail_json TEXT",
|
|
753
|
-
"provider_disconnect_observed INTEGER",
|
|
754
|
-
"provider_disconnect_message TEXT"
|
|
755
|
-
]) {
|
|
756
|
-
try {
|
|
757
|
-
await db.execute(`ALTER TABLE harvest_attempts ADD COLUMN ${column}`);
|
|
758
|
-
} catch {
|
|
759
|
-
}
|
|
760
|
-
}
|
|
761
|
-
await db.execute(`CREATE INDEX IF NOT EXISTS harvest_attempts_provider_lookup ON harvest_attempts(browser_provider, provider_lookup_status, provider_checked_at)`);
|
|
762
|
-
try {
|
|
763
|
-
await db.execute(`ALTER TABLE users ADD COLUMN password_hash TEXT`);
|
|
764
|
-
} catch {
|
|
765
|
-
}
|
|
766
|
-
try {
|
|
767
|
-
await db.execute(`ALTER TABLE users ADD COLUMN key_active INTEGER NOT NULL DEFAULT 1`);
|
|
768
|
-
} catch {
|
|
769
|
-
}
|
|
770
|
-
await db.execute(`
|
|
771
|
-
CREATE TABLE IF NOT EXISTS kpo_jobs (
|
|
772
|
-
id TEXT PRIMARY KEY,
|
|
773
|
-
user_id INTEGER NOT NULL REFERENCES users(id),
|
|
774
|
-
status TEXT NOT NULL,
|
|
775
|
-
entity_name TEXT NOT NULL,
|
|
776
|
-
entity_type TEXT NOT NULL,
|
|
777
|
-
url TEXT,
|
|
778
|
-
request TEXT NOT NULL,
|
|
779
|
-
result TEXT,
|
|
780
|
-
error TEXT,
|
|
781
|
-
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
782
|
-
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
783
|
-
)
|
|
784
|
-
`);
|
|
785
|
-
await db.execute(`
|
|
786
|
-
CREATE TABLE IF NOT EXISTS kpo_phase_log (
|
|
787
|
-
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
788
|
-
job_id TEXT NOT NULL REFERENCES kpo_jobs(id),
|
|
789
|
-
phase TEXT NOT NULL,
|
|
790
|
-
output TEXT NOT NULL,
|
|
791
|
-
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
792
|
-
)
|
|
793
|
-
`);
|
|
794
|
-
await db.execute(`CREATE INDEX IF NOT EXISTS kpo_jobs_user_id ON kpo_jobs(user_id)`);
|
|
795
|
-
await db.execute(`CREATE INDEX IF NOT EXISTS kpo_jobs_status ON kpo_jobs(status)`);
|
|
796
|
-
await db.execute(`CREATE INDEX IF NOT EXISTS kpo_phase_log_job_id ON kpo_phase_log(job_id)`);
|
|
797
|
-
try {
|
|
798
|
-
await db.execute(`ALTER TABLE users ADD COLUMN stripe_customer_id TEXT`);
|
|
799
|
-
} catch {
|
|
800
|
-
}
|
|
801
|
-
try {
|
|
802
|
-
await db.execute(`ALTER TABLE users ADD COLUMN balance_mc INTEGER NOT NULL DEFAULT 0`);
|
|
803
|
-
} catch {
|
|
804
|
-
}
|
|
805
|
-
try {
|
|
806
|
-
await db.execute(`ALTER TABLE users ADD COLUMN extra_concurrency_slots INTEGER NOT NULL DEFAULT 0`);
|
|
807
|
-
} catch {
|
|
808
|
-
}
|
|
809
|
-
try {
|
|
810
|
-
await db.execute(`ALTER TABLE users ADD COLUMN concurrency_stripe_sub_id TEXT`);
|
|
811
|
-
} catch {
|
|
812
|
-
}
|
|
813
|
-
try {
|
|
814
|
-
await db.execute(`ALTER TABLE users ADD COLUMN concurrency_pack_quantity INTEGER NOT NULL DEFAULT 0`);
|
|
815
|
-
} catch {
|
|
816
|
-
}
|
|
817
|
-
try {
|
|
818
|
-
await db.execute(`ALTER TABLE users ADD COLUMN subscription_concurrency INTEGER NOT NULL DEFAULT 0`);
|
|
819
|
-
} catch {
|
|
820
|
-
}
|
|
821
|
-
try {
|
|
822
|
-
await db.execute(`ALTER TABLE users ADD COLUMN subscription_tier TEXT`);
|
|
823
|
-
} catch {
|
|
824
|
-
}
|
|
825
|
-
try {
|
|
826
|
-
await db.execute(`ALTER TABLE users ADD COLUMN subscription_id TEXT`);
|
|
827
|
-
} catch {
|
|
828
|
-
}
|
|
829
|
-
try {
|
|
830
|
-
await db.execute(`ALTER TABLE users ADD COLUMN memory_key TEXT`);
|
|
831
|
-
} catch {
|
|
832
|
-
}
|
|
833
|
-
try {
|
|
834
|
-
await db.execute(`ALTER TABLE users ADD COLUMN memory_identity TEXT`);
|
|
835
|
-
} catch {
|
|
836
|
-
}
|
|
837
|
-
try {
|
|
838
|
-
await db.execute(`ALTER TABLE users ADD COLUMN memory_plan TEXT NOT NULL DEFAULT 'free'`);
|
|
839
|
-
} catch {
|
|
840
|
-
}
|
|
841
|
-
try {
|
|
842
|
-
await db.execute(`ALTER TABLE users ADD COLUMN memory_subscription_id TEXT`);
|
|
843
|
-
} catch {
|
|
844
|
-
}
|
|
845
|
-
try {
|
|
846
|
-
await db.execute(`ALTER TABLE users ADD COLUMN memory_provisioned INTEGER NOT NULL DEFAULT 0`);
|
|
847
|
-
} catch {
|
|
848
|
-
}
|
|
849
|
-
await db.execute(`
|
|
850
|
-
CREATE TABLE IF NOT EXISTS concurrency_locks (
|
|
851
|
-
id TEXT PRIMARY KEY,
|
|
852
|
-
user_id INTEGER NOT NULL REFERENCES users(id),
|
|
853
|
-
operation TEXT NOT NULL,
|
|
854
|
-
status TEXT NOT NULL DEFAULT 'active',
|
|
855
|
-
acquired_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
856
|
-
expires_at TEXT NOT NULL,
|
|
857
|
-
released_at TEXT,
|
|
858
|
-
metadata TEXT
|
|
859
|
-
)
|
|
860
|
-
`);
|
|
861
|
-
await db.execute(`CREATE INDEX IF NOT EXISTS concurrency_locks_user_status ON concurrency_locks(user_id, status, expires_at)`);
|
|
862
|
-
await db.execute(`CREATE INDEX IF NOT EXISTS concurrency_locks_operation ON concurrency_locks(operation, status, expires_at)`);
|
|
863
|
-
await db.execute(`CREATE INDEX IF NOT EXISTS concurrency_locks_active_user ON concurrency_locks(user_id, expires_at) WHERE status = 'active'`);
|
|
864
|
-
await db.execute(`
|
|
865
|
-
CREATE TABLE IF NOT EXISTS ledger (
|
|
866
|
-
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
867
|
-
user_id INTEGER NOT NULL REFERENCES users(id),
|
|
868
|
-
amount_mc INTEGER NOT NULL,
|
|
869
|
-
operation TEXT NOT NULL,
|
|
870
|
-
description TEXT,
|
|
871
|
-
stripe_pi TEXT,
|
|
872
|
-
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
873
|
-
)
|
|
874
|
-
`);
|
|
875
|
-
await db.execute(`CREATE INDEX IF NOT EXISTS ledger_user_id ON ledger(user_id)`);
|
|
876
|
-
await db.execute(`CREATE UNIQUE INDEX IF NOT EXISTS ledger_stripe_pi_unique ON ledger(stripe_pi) WHERE stripe_pi IS NOT NULL`);
|
|
877
|
-
await db.execute(`
|
|
878
|
-
CREATE TABLE IF NOT EXISTS credit_lots (
|
|
879
|
-
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
880
|
-
user_id INTEGER NOT NULL REFERENCES users(id),
|
|
881
|
-
amount_mc INTEGER NOT NULL,
|
|
882
|
-
remaining_mc INTEGER NOT NULL,
|
|
883
|
-
source TEXT NOT NULL,
|
|
884
|
-
stripe_pi TEXT,
|
|
885
|
-
granted_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
886
|
-
expires_at TEXT NOT NULL,
|
|
887
|
-
never_expires INTEGER NOT NULL DEFAULT 0
|
|
888
|
-
)
|
|
889
|
-
`);
|
|
890
|
-
try {
|
|
891
|
-
await db.execute(`ALTER TABLE credit_lots ADD COLUMN never_expires INTEGER NOT NULL DEFAULT 0`);
|
|
892
|
-
} catch {
|
|
893
|
-
}
|
|
894
|
-
await db.execute(`CREATE INDEX IF NOT EXISTS credit_lots_user_active ON credit_lots(user_id, expires_at, remaining_mc)`);
|
|
895
|
-
await db.execute(`
|
|
896
|
-
CREATE TABLE IF NOT EXISTS credit_lot_expirations (
|
|
897
|
-
lot_id INTEGER PRIMARY KEY REFERENCES credit_lots(id),
|
|
898
|
-
user_id INTEGER NOT NULL REFERENCES users(id),
|
|
899
|
-
amount_mc INTEGER NOT NULL,
|
|
900
|
-
claim_token TEXT NOT NULL UNIQUE,
|
|
901
|
-
expired_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
902
|
-
)
|
|
903
|
-
`);
|
|
904
|
-
await db.execute(`
|
|
905
|
-
CREATE TRIGGER IF NOT EXISTS credit_lots_bounds_insert
|
|
906
|
-
BEFORE INSERT ON credit_lots
|
|
907
|
-
WHEN NEW.amount_mc < 0
|
|
908
|
-
OR NEW.remaining_mc < 0
|
|
909
|
-
OR NEW.remaining_mc > NEW.amount_mc
|
|
910
|
-
BEGIN
|
|
911
|
-
SELECT RAISE(ABORT, 'invalid credit lot bounds');
|
|
912
|
-
END
|
|
913
|
-
`);
|
|
914
|
-
await db.execute(`
|
|
915
|
-
CREATE TRIGGER IF NOT EXISTS credit_lots_bounds_update
|
|
916
|
-
BEFORE UPDATE OF amount_mc, remaining_mc ON credit_lots
|
|
917
|
-
WHEN NEW.amount_mc < 0
|
|
918
|
-
OR NEW.remaining_mc < 0
|
|
919
|
-
OR NEW.remaining_mc > NEW.amount_mc
|
|
920
|
-
BEGIN
|
|
921
|
-
SELECT RAISE(ABORT, 'invalid credit lot bounds');
|
|
922
|
-
END
|
|
923
|
-
`);
|
|
924
|
-
await db.execute(`
|
|
925
|
-
CREATE TRIGGER IF NOT EXISTS credit_lots_one_migration_insert
|
|
926
|
-
BEFORE INSERT ON credit_lots
|
|
927
|
-
WHEN NEW.source = 'migration'
|
|
928
|
-
AND EXISTS (
|
|
929
|
-
SELECT 1 FROM credit_lots
|
|
930
|
-
WHERE user_id = NEW.user_id AND source = 'migration'
|
|
931
|
-
)
|
|
932
|
-
BEGIN
|
|
933
|
-
SELECT RAISE(ABORT, 'duplicate migration lot');
|
|
934
|
-
END
|
|
935
|
-
`);
|
|
936
|
-
await db.execute(`
|
|
937
|
-
CREATE TRIGGER IF NOT EXISTS credit_lots_one_migration_update
|
|
938
|
-
BEFORE UPDATE OF source, user_id ON credit_lots
|
|
939
|
-
WHEN NEW.source = 'migration'
|
|
940
|
-
AND EXISTS (
|
|
941
|
-
SELECT 1 FROM credit_lots
|
|
942
|
-
WHERE user_id = NEW.user_id
|
|
943
|
-
AND source = 'migration'
|
|
944
|
-
AND id != OLD.id
|
|
945
|
-
)
|
|
946
|
-
BEGIN
|
|
947
|
-
SELECT RAISE(ABORT, 'duplicate migration lot');
|
|
948
|
-
END
|
|
949
|
-
`);
|
|
950
|
-
await db.execute(`
|
|
951
|
-
CREATE TABLE IF NOT EXISTS credit_lot_repairs (
|
|
952
|
-
repair_id TEXT PRIMARY KEY,
|
|
953
|
-
user_id INTEGER NOT NULL REFERENCES users(id),
|
|
954
|
-
report_sha256 TEXT NOT NULL,
|
|
955
|
-
classification TEXT NOT NULL,
|
|
956
|
-
before_json TEXT NOT NULL,
|
|
957
|
-
plan_json TEXT NOT NULL,
|
|
958
|
-
after_json TEXT,
|
|
959
|
-
status TEXT NOT NULL DEFAULT 'claimed',
|
|
960
|
-
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
961
|
-
applied_at TEXT
|
|
962
|
-
)
|
|
963
|
-
`);
|
|
964
|
-
await db.execute(`CREATE INDEX IF NOT EXISTS credit_lot_repairs_user_applied ON credit_lot_repairs(user_id, applied_at)`);
|
|
965
|
-
await db.execute(`
|
|
966
|
-
CREATE TABLE IF NOT EXISTS free_credit_refreshes (
|
|
967
|
-
user_id INTEGER NOT NULL REFERENCES users(id),
|
|
968
|
-
month TEXT NOT NULL,
|
|
969
|
-
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
970
|
-
PRIMARY KEY (user_id, month)
|
|
971
|
-
)
|
|
972
|
-
`);
|
|
973
|
-
await db.execute(`
|
|
974
|
-
CREATE TABLE IF NOT EXISTS billing_authorizations (
|
|
975
|
-
id TEXT PRIMARY KEY,
|
|
976
|
-
user_id INTEGER NOT NULL REFERENCES users(id),
|
|
977
|
-
idempotency_key TEXT NOT NULL UNIQUE,
|
|
978
|
-
billing_class TEXT NOT NULL,
|
|
979
|
-
status TEXT NOT NULL,
|
|
980
|
-
minimum_amount_mc INTEGER NOT NULL,
|
|
981
|
-
maximum_amount_mc INTEGER NOT NULL,
|
|
982
|
-
token_hash TEXT NOT NULL,
|
|
983
|
-
metadata TEXT,
|
|
984
|
-
expires_at TEXT NOT NULL,
|
|
985
|
-
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
986
|
-
settled_at TEXT
|
|
987
|
-
)
|
|
988
|
-
`);
|
|
989
|
-
await db.execute(`CREATE INDEX IF NOT EXISTS billing_authorizations_user_status ON billing_authorizations(user_id, status, expires_at)`);
|
|
990
|
-
await db.execute(`
|
|
991
|
-
CREATE TABLE IF NOT EXISTS billing_events (
|
|
992
|
-
id TEXT PRIMARY KEY,
|
|
993
|
-
user_id INTEGER NOT NULL REFERENCES users(id),
|
|
994
|
-
authorization_id TEXT REFERENCES billing_authorizations(id),
|
|
995
|
-
idempotency_key TEXT NOT NULL UNIQUE,
|
|
996
|
-
billing_class TEXT NOT NULL,
|
|
997
|
-
source_surface TEXT NOT NULL,
|
|
998
|
-
status TEXT NOT NULL,
|
|
999
|
-
amount_mc INTEGER NOT NULL DEFAULT 0,
|
|
1000
|
-
raw_cost_usd_nanos INTEGER,
|
|
1001
|
-
multiplier_bps INTEGER,
|
|
1002
|
-
input_tokens INTEGER,
|
|
1003
|
-
output_tokens INTEGER,
|
|
1004
|
-
cached_tokens INTEGER,
|
|
1005
|
-
model_steps INTEGER,
|
|
1006
|
-
metadata TEXT,
|
|
1007
|
-
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
1008
|
-
settled_at TEXT
|
|
1009
|
-
)
|
|
1010
|
-
`);
|
|
1011
|
-
await db.execute(`CREATE INDEX IF NOT EXISTS billing_events_user_created_at ON billing_events(user_id, created_at DESC)`);
|
|
1012
|
-
await db.execute(`CREATE INDEX IF NOT EXISTS billing_events_authorization ON billing_events(authorization_id)`);
|
|
1013
|
-
await db.execute(`
|
|
1014
|
-
CREATE TABLE IF NOT EXISTS billing_debits (
|
|
1015
|
-
idempotency_key TEXT PRIMARY KEY,
|
|
1016
|
-
user_id INTEGER NOT NULL REFERENCES users(id),
|
|
1017
|
-
amount_mc INTEGER NOT NULL,
|
|
1018
|
-
operation TEXT NOT NULL,
|
|
1019
|
-
description TEXT,
|
|
1020
|
-
status TEXT NOT NULL DEFAULT 'claimed',
|
|
1021
|
-
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
1022
|
-
applied_at TEXT
|
|
1023
|
-
)
|
|
1024
|
-
`);
|
|
1025
|
-
await db.execute(`CREATE INDEX IF NOT EXISTS billing_debits_user_status ON billing_debits(user_id, status)`);
|
|
1026
|
-
await db.execute(`
|
|
1027
|
-
CREATE TABLE IF NOT EXISTS billing_debit_allocations (
|
|
1028
|
-
debit_idempotency_key TEXT NOT NULL REFERENCES billing_debits(idempotency_key),
|
|
1029
|
-
lot_id INTEGER NOT NULL REFERENCES credit_lots(id),
|
|
1030
|
-
user_id INTEGER NOT NULL REFERENCES users(id),
|
|
1031
|
-
amount_mc INTEGER NOT NULL,
|
|
1032
|
-
expires_at TEXT NOT NULL,
|
|
1033
|
-
PRIMARY KEY (debit_idempotency_key, lot_id)
|
|
1034
|
-
)
|
|
1035
|
-
`);
|
|
1036
|
-
await db.execute(`
|
|
1037
|
-
CREATE TABLE IF NOT EXISTS connected_account_billing (
|
|
1038
|
-
user_id INTEGER PRIMARY KEY REFERENCES users(id),
|
|
1039
|
-
stripe_subscription_id TEXT,
|
|
1040
|
-
stripe_subscription_item_id TEXT,
|
|
1041
|
-
price_id TEXT,
|
|
1042
|
-
quantity INTEGER NOT NULL DEFAULT 0,
|
|
1043
|
-
status TEXT NOT NULL DEFAULT 'pending',
|
|
1044
|
-
last_error_code TEXT,
|
|
1045
|
-
checked_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
1046
|
-
synced_at TEXT,
|
|
1047
|
-
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
1048
|
-
)
|
|
1049
|
-
`);
|
|
1050
|
-
await db.execute(`
|
|
1051
|
-
CREATE TABLE IF NOT EXISTS connected_account_charges (
|
|
1052
|
-
user_id INTEGER NOT NULL,
|
|
1053
|
-
connection_id TEXT NOT NULL,
|
|
1054
|
-
period_start TEXT NOT NULL,
|
|
1055
|
-
amount_mc INTEGER NOT NULL,
|
|
1056
|
-
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
1057
|
-
PRIMARY KEY (user_id, connection_id, period_start)
|
|
1058
|
-
)
|
|
1059
|
-
`);
|
|
1060
|
-
await db.execute(`
|
|
1061
|
-
CREATE TABLE IF NOT EXISTS connected_account_charge_transfers (
|
|
1062
|
-
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
1063
|
-
user_id INTEGER NOT NULL,
|
|
1064
|
-
period_start TEXT NOT NULL,
|
|
1065
|
-
from_connection_id TEXT NOT NULL,
|
|
1066
|
-
to_connection_id TEXT NOT NULL,
|
|
1067
|
-
provider_config_key TEXT NOT NULL,
|
|
1068
|
-
transferred_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
1069
|
-
UNIQUE (user_id, period_start, from_connection_id),
|
|
1070
|
-
CHECK (from_connection_id <> to_connection_id)
|
|
1071
|
-
)
|
|
1072
|
-
`);
|
|
1073
|
-
await db.execute(`CREATE INDEX IF NOT EXISTS connected_account_charge_transfers_destination ON connected_account_charge_transfers(user_id, period_start, to_connection_id, transferred_at DESC)`);
|
|
1074
|
-
await db.execute(`
|
|
1075
|
-
CREATE TABLE IF NOT EXISTS billing_credits (
|
|
1076
|
-
idempotency_key TEXT PRIMARY KEY,
|
|
1077
|
-
user_id INTEGER NOT NULL REFERENCES users(id),
|
|
1078
|
-
amount_mc INTEGER NOT NULL,
|
|
1079
|
-
operation TEXT NOT NULL,
|
|
1080
|
-
description TEXT,
|
|
1081
|
-
status TEXT NOT NULL DEFAULT 'claimed',
|
|
1082
|
-
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
1083
|
-
applied_at TEXT
|
|
1084
|
-
)
|
|
1085
|
-
`);
|
|
1086
|
-
await db.execute(`CREATE INDEX IF NOT EXISTS billing_credits_user_status ON billing_credits(user_id, status)`);
|
|
1087
|
-
await db.execute(`
|
|
1088
|
-
CREATE TABLE IF NOT EXISTS admin_credit_adjustments (
|
|
1089
|
-
reference TEXT PRIMARY KEY,
|
|
1090
|
-
user_id INTEGER NOT NULL REFERENCES users(id),
|
|
1091
|
-
direction TEXT NOT NULL,
|
|
1092
|
-
amount_mc INTEGER NOT NULL,
|
|
1093
|
-
reason TEXT NOT NULL,
|
|
1094
|
-
actor TEXT NOT NULL,
|
|
1095
|
-
status TEXT NOT NULL DEFAULT 'applied',
|
|
1096
|
-
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
1097
|
-
updated_at TEXT
|
|
1098
|
-
)
|
|
1099
|
-
`);
|
|
1100
|
-
await db.execute(`CREATE INDEX IF NOT EXISTS admin_credit_adjustments_user_created ON admin_credit_adjustments(user_id, created_at)`);
|
|
1101
|
-
await db.execute(`
|
|
1102
|
-
CREATE TABLE IF NOT EXISTS admin_credit_adjustment_events (
|
|
1103
|
-
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
1104
|
-
reference TEXT NOT NULL REFERENCES admin_credit_adjustments(reference),
|
|
1105
|
-
seq INTEGER NOT NULL,
|
|
1106
|
-
kind TEXT NOT NULL,
|
|
1107
|
-
from_amount_mc INTEGER,
|
|
1108
|
-
to_amount_mc INTEGER,
|
|
1109
|
-
from_reason TEXT,
|
|
1110
|
-
to_reason TEXT,
|
|
1111
|
-
moved_mc INTEGER NOT NULL DEFAULT 0,
|
|
1112
|
-
actor TEXT NOT NULL,
|
|
1113
|
-
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
1114
|
-
UNIQUE (reference, seq)
|
|
1115
|
-
)
|
|
1116
|
-
`);
|
|
1117
|
-
await db.execute(`CREATE INDEX IF NOT EXISTS admin_credit_adjustment_events_reference ON admin_credit_adjustment_events(reference)`);
|
|
1118
|
-
await db.execute(`
|
|
1119
|
-
CREATE TABLE IF NOT EXISTS site_audit_jobs (
|
|
1120
|
-
id TEXT PRIMARY KEY,
|
|
1121
|
-
user_id INTEGER,
|
|
1122
|
-
status TEXT DEFAULT 'pending',
|
|
1123
|
-
client_domain TEXT,
|
|
1124
|
-
session_path TEXT,
|
|
1125
|
-
request TEXT,
|
|
1126
|
-
result TEXT,
|
|
1127
|
-
error TEXT,
|
|
1128
|
-
created_at TEXT DEFAULT (datetime('now')),
|
|
1129
|
-
updated_at TEXT DEFAULT (datetime('now'))
|
|
1130
|
-
)
|
|
1131
|
-
`);
|
|
1132
|
-
await db.execute(`
|
|
1133
|
-
CREATE TABLE IF NOT EXISTS site_audit_phase_log (
|
|
1134
|
-
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
1135
|
-
job_id TEXT REFERENCES site_audit_jobs(id),
|
|
1136
|
-
phase TEXT,
|
|
1137
|
-
completed_at TEXT DEFAULT (datetime('now')),
|
|
1138
|
-
output_summary TEXT
|
|
1139
|
-
)
|
|
1140
|
-
`);
|
|
1141
|
-
await db.execute(`CREATE INDEX IF NOT EXISTS site_audit_jobs_user_id ON site_audit_jobs(user_id)`);
|
|
1142
|
-
await db.execute(`CREATE INDEX IF NOT EXISTS site_audit_jobs_status ON site_audit_jobs(status)`);
|
|
1143
|
-
await db.execute(`CREATE INDEX IF NOT EXISTS site_audit_phase_log_job_id ON site_audit_phase_log(job_id)`);
|
|
1144
|
-
await db.execute(`
|
|
1145
|
-
CREATE TABLE IF NOT EXISTS site_extract_jobs (
|
|
1146
|
-
id TEXT PRIMARY KEY,
|
|
1147
|
-
user_id INTEGER,
|
|
1148
|
-
status TEXT DEFAULT 'pending',
|
|
1149
|
-
start_url TEXT,
|
|
1150
|
-
options TEXT,
|
|
1151
|
-
total_urls INTEGER DEFAULT 0,
|
|
1152
|
-
done_urls INTEGER DEFAULT 0,
|
|
1153
|
-
attempted_urls INTEGER DEFAULT 0,
|
|
1154
|
-
successful_urls INTEGER DEFAULT 0,
|
|
1155
|
-
failed_urls INTEGER DEFAULT 0,
|
|
1156
|
-
idempotency_key TEXT,
|
|
1157
|
-
request_fingerprint TEXT,
|
|
1158
|
-
dispatch_attempts INTEGER DEFAULT 0,
|
|
1159
|
-
next_dispatch_at TEXT,
|
|
1160
|
-
dispatch_error TEXT,
|
|
1161
|
-
settlement_attempts INTEGER DEFAULT 0,
|
|
1162
|
-
next_settlement_at TEXT,
|
|
1163
|
-
settlement_error TEXT,
|
|
1164
|
-
artifacts TEXT,
|
|
1165
|
-
error TEXT,
|
|
1166
|
-
public_error_json TEXT,
|
|
1167
|
-
billed_mc INTEGER,
|
|
1168
|
-
created_at TEXT DEFAULT (datetime('now')),
|
|
1169
|
-
updated_at TEXT DEFAULT (datetime('now'))
|
|
1170
|
-
)
|
|
1171
|
-
`);
|
|
1172
|
-
await db.execute(`
|
|
1173
|
-
CREATE TABLE IF NOT EXISTS site_extract_pages (
|
|
1174
|
-
job_id TEXT,
|
|
1175
|
-
url TEXT,
|
|
1176
|
-
page TEXT,
|
|
1177
|
-
PRIMARY KEY (job_id, url)
|
|
1178
|
-
)
|
|
1179
|
-
`);
|
|
1180
|
-
await db.execute(`CREATE INDEX IF NOT EXISTS site_extract_jobs_user_id ON site_extract_jobs(user_id)`);
|
|
1181
|
-
await db.execute(`CREATE INDEX IF NOT EXISTS site_extract_jobs_status ON site_extract_jobs(status)`);
|
|
1182
|
-
await db.execute(`CREATE INDEX IF NOT EXISTS site_extract_pages_job_id ON site_extract_pages(job_id)`);
|
|
1183
|
-
try {
|
|
1184
|
-
await db.execute(`ALTER TABLE site_extract_jobs ADD COLUMN attempted_urls INTEGER DEFAULT 0`);
|
|
1185
|
-
} catch {
|
|
1186
|
-
}
|
|
1187
|
-
try {
|
|
1188
|
-
await db.execute(`ALTER TABLE site_extract_jobs ADD COLUMN successful_urls INTEGER DEFAULT 0`);
|
|
1189
|
-
} catch {
|
|
1190
|
-
}
|
|
1191
|
-
try {
|
|
1192
|
-
await db.execute(`ALTER TABLE site_extract_jobs ADD COLUMN failed_urls INTEGER DEFAULT 0`);
|
|
1193
|
-
} catch {
|
|
1194
|
-
}
|
|
1195
|
-
try {
|
|
1196
|
-
await db.execute(`ALTER TABLE site_extract_jobs ADD COLUMN idempotency_key TEXT`);
|
|
1197
|
-
} catch {
|
|
1198
|
-
}
|
|
1199
|
-
try {
|
|
1200
|
-
await db.execute(`ALTER TABLE site_extract_jobs ADD COLUMN request_fingerprint TEXT`);
|
|
1201
|
-
} catch {
|
|
1202
|
-
}
|
|
1203
|
-
try {
|
|
1204
|
-
await db.execute(`ALTER TABLE site_extract_jobs ADD COLUMN dispatch_attempts INTEGER DEFAULT 0`);
|
|
1205
|
-
} catch {
|
|
1206
|
-
}
|
|
1207
|
-
try {
|
|
1208
|
-
await db.execute(`ALTER TABLE site_extract_jobs ADD COLUMN next_dispatch_at TEXT`);
|
|
1209
|
-
} catch {
|
|
1210
|
-
}
|
|
1211
|
-
try {
|
|
1212
|
-
await db.execute(`ALTER TABLE site_extract_jobs ADD COLUMN dispatch_error TEXT`);
|
|
1213
|
-
} catch {
|
|
1214
|
-
}
|
|
1215
|
-
try {
|
|
1216
|
-
await db.execute(`ALTER TABLE site_extract_jobs ADD COLUMN settlement_attempts INTEGER DEFAULT 0`);
|
|
1217
|
-
} catch {
|
|
1218
|
-
}
|
|
1219
|
-
try {
|
|
1220
|
-
await db.execute(`ALTER TABLE site_extract_jobs ADD COLUMN next_settlement_at TEXT`);
|
|
1221
|
-
} catch {
|
|
1222
|
-
}
|
|
1223
|
-
try {
|
|
1224
|
-
await db.execute(`ALTER TABLE site_extract_jobs ADD COLUMN settlement_error TEXT`);
|
|
1225
|
-
} catch {
|
|
1226
|
-
}
|
|
1227
|
-
try {
|
|
1228
|
-
await db.execute(`ALTER TABLE site_extract_jobs ADD COLUMN public_error_json TEXT`);
|
|
1229
|
-
} catch {
|
|
1230
|
-
}
|
|
1231
|
-
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`);
|
|
1232
|
-
await db.execute(`
|
|
1233
|
-
CREATE TABLE IF NOT EXISTS password_reset_tokens (
|
|
1234
|
-
token TEXT PRIMARY KEY,
|
|
1235
|
-
user_id INTEGER NOT NULL REFERENCES users(id),
|
|
1236
|
-
expires_at TEXT NOT NULL,
|
|
1237
|
-
used INTEGER NOT NULL DEFAULT 0,
|
|
1238
|
-
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
1239
|
-
)
|
|
1240
|
-
`);
|
|
1241
|
-
await db.execute(`CREATE INDEX IF NOT EXISTS prt_user_id ON password_reset_tokens(user_id)`);
|
|
1242
|
-
await db.execute(`
|
|
1243
|
-
CREATE TABLE IF NOT EXISTS rate_limits (
|
|
1244
|
-
scope TEXT NOT NULL,
|
|
1245
|
-
key_hash TEXT NOT NULL,
|
|
1246
|
-
window_start INTEGER NOT NULL,
|
|
1247
|
-
count INTEGER NOT NULL DEFAULT 0,
|
|
1248
|
-
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
1249
|
-
PRIMARY KEY (scope, key_hash, window_start)
|
|
1250
|
-
)
|
|
1251
|
-
`);
|
|
1252
|
-
await db.execute(`
|
|
1253
|
-
CREATE TABLE IF NOT EXISTS request_events (
|
|
1254
|
-
id TEXT PRIMARY KEY,
|
|
1255
|
-
user_id INTEGER NOT NULL REFERENCES users(id),
|
|
1256
|
-
source TEXT NOT NULL,
|
|
1257
|
-
status TEXT NOT NULL,
|
|
1258
|
-
query TEXT NOT NULL,
|
|
1259
|
-
location TEXT,
|
|
1260
|
-
result_count INTEGER,
|
|
1261
|
-
result TEXT,
|
|
1262
|
-
error TEXT,
|
|
1263
|
-
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
1264
|
-
)
|
|
1265
|
-
`);
|
|
1266
|
-
await db.execute(`CREATE INDEX IF NOT EXISTS request_events_user_created_at ON request_events(user_id, created_at DESC)`);
|
|
1267
|
-
await db.execute(`CREATE INDEX IF NOT EXISTS request_events_created_at ON request_events(created_at)`);
|
|
1268
|
-
await db.execute(`
|
|
1269
|
-
CREATE TABLE IF NOT EXISTS workflow_schedules (
|
|
1270
|
-
id TEXT PRIMARY KEY,
|
|
1271
|
-
user_id INTEGER NOT NULL REFERENCES users(id),
|
|
1272
|
-
workflow_id TEXT NOT NULL,
|
|
1273
|
-
name TEXT NOT NULL,
|
|
1274
|
-
input_json TEXT NOT NULL,
|
|
1275
|
-
cadence TEXT NOT NULL,
|
|
1276
|
-
cron_expr TEXT,
|
|
1277
|
-
timezone TEXT NOT NULL DEFAULT 'UTC',
|
|
1278
|
-
webhook_url TEXT,
|
|
1279
|
-
status TEXT NOT NULL DEFAULT 'active',
|
|
1280
|
-
next_run_at TEXT,
|
|
1281
|
-
last_run_at TEXT,
|
|
1282
|
-
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
1283
|
-
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
1284
|
-
)
|
|
1285
|
-
`);
|
|
1286
|
-
await db.execute(`CREATE INDEX IF NOT EXISTS workflow_schedules_user_status ON workflow_schedules(user_id, status, created_at DESC)`);
|
|
1287
|
-
await db.execute(`CREATE INDEX IF NOT EXISTS workflow_schedules_due ON workflow_schedules(status, next_run_at)`);
|
|
1288
|
-
await db.execute(`
|
|
1289
|
-
CREATE TABLE IF NOT EXISTS workflow_runs (
|
|
1290
|
-
id TEXT PRIMARY KEY,
|
|
1291
|
-
user_id INTEGER NOT NULL REFERENCES users(id),
|
|
1292
|
-
schedule_id TEXT,
|
|
1293
|
-
workflow_id TEXT NOT NULL,
|
|
1294
|
-
input_json TEXT NOT NULL,
|
|
1295
|
-
status TEXT NOT NULL DEFAULT 'queued',
|
|
1296
|
-
manifest_json TEXT,
|
|
1297
|
-
error_message TEXT,
|
|
1298
|
-
queued_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
1299
|
-
started_at TEXT,
|
|
1300
|
-
completed_at TEXT,
|
|
1301
|
-
idempotency_key TEXT,
|
|
1302
|
-
step_index INTEGER NOT NULL DEFAULT 0,
|
|
1303
|
-
total_steps INTEGER,
|
|
1304
|
-
step_state_json TEXT,
|
|
1305
|
-
step_log_json TEXT,
|
|
1306
|
-
webhook_url TEXT
|
|
1307
|
-
)
|
|
1308
|
-
`);
|
|
1309
|
-
try {
|
|
1310
|
-
await db.execute(`ALTER TABLE workflow_runs ADD COLUMN step_index INTEGER NOT NULL DEFAULT 0`);
|
|
1311
|
-
} catch {
|
|
1312
|
-
}
|
|
1313
|
-
try {
|
|
1314
|
-
await db.execute(`ALTER TABLE workflow_runs ADD COLUMN total_steps INTEGER`);
|
|
1315
|
-
} catch {
|
|
1316
|
-
}
|
|
1317
|
-
try {
|
|
1318
|
-
await db.execute(`ALTER TABLE workflow_runs ADD COLUMN step_state_json TEXT`);
|
|
1319
|
-
} catch {
|
|
1320
|
-
}
|
|
1321
|
-
try {
|
|
1322
|
-
await db.execute(`ALTER TABLE workflow_runs ADD COLUMN step_log_json TEXT`);
|
|
1323
|
-
} catch {
|
|
1324
|
-
}
|
|
1325
|
-
try {
|
|
1326
|
-
await db.execute(`ALTER TABLE workflow_runs ADD COLUMN webhook_url TEXT`);
|
|
1327
|
-
} catch {
|
|
1328
|
-
}
|
|
1329
|
-
await db.execute(`CREATE INDEX IF NOT EXISTS workflow_runs_user_created ON workflow_runs(user_id, queued_at DESC)`);
|
|
1330
|
-
await db.execute(`CREATE INDEX IF NOT EXISTS workflow_runs_schedule ON workflow_runs(schedule_id, queued_at DESC)`);
|
|
1331
|
-
await db.execute(`CREATE UNIQUE INDEX IF NOT EXISTS workflow_runs_idempotency ON workflow_runs(idempotency_key) WHERE idempotency_key IS NOT NULL`);
|
|
1332
|
-
await db.execute(`
|
|
1333
|
-
CREATE TABLE IF NOT EXISTS workflow_artifacts (
|
|
1334
|
-
id TEXT PRIMARY KEY,
|
|
1335
|
-
run_id TEXT NOT NULL REFERENCES workflow_runs(id),
|
|
1336
|
-
user_id INTEGER NOT NULL REFERENCES users(id),
|
|
1337
|
-
kind TEXT NOT NULL,
|
|
1338
|
-
label TEXT NOT NULL,
|
|
1339
|
-
path TEXT NOT NULL,
|
|
1340
|
-
content_type TEXT NOT NULL,
|
|
1341
|
-
bytes INTEGER,
|
|
1342
|
-
rows_count INTEGER,
|
|
1343
|
-
content TEXT,
|
|
1344
|
-
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
1345
|
-
)
|
|
1346
|
-
`);
|
|
1347
|
-
try {
|
|
1348
|
-
await db.execute(`ALTER TABLE workflow_artifacts ADD COLUMN content TEXT`);
|
|
1349
|
-
} catch {
|
|
1350
|
-
}
|
|
1351
|
-
await db.execute(`CREATE INDEX IF NOT EXISTS workflow_artifacts_run ON workflow_artifacts(run_id)`);
|
|
1352
|
-
await db.execute(`
|
|
1353
|
-
CREATE TABLE IF NOT EXISTS workflow_webhook_deliveries (
|
|
1354
|
-
id TEXT PRIMARY KEY,
|
|
1355
|
-
run_id TEXT NOT NULL REFERENCES workflow_runs(id),
|
|
1356
|
-
schedule_id TEXT,
|
|
1357
|
-
user_id INTEGER NOT NULL REFERENCES users(id),
|
|
1358
|
-
webhook_url TEXT NOT NULL,
|
|
1359
|
-
status TEXT NOT NULL,
|
|
1360
|
-
attempts INTEGER NOT NULL DEFAULT 0,
|
|
1361
|
-
last_error TEXT,
|
|
1362
|
-
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
1363
|
-
delivered_at TEXT
|
|
1364
|
-
)
|
|
1365
|
-
`);
|
|
1366
|
-
await db.execute(`CREATE INDEX IF NOT EXISTS workflow_webhook_deliveries_run ON workflow_webhook_deliveries(run_id)`);
|
|
1367
|
-
await db.execute(`
|
|
1368
|
-
CREATE TABLE IF NOT EXISTS scheduled_run_view_links (
|
|
1369
|
-
share_id TEXT PRIMARY KEY,
|
|
1370
|
-
owner_id TEXT NOT NULL,
|
|
1371
|
-
run_id TEXT NOT NULL,
|
|
1372
|
-
artifact_id TEXT NOT NULL,
|
|
1373
|
-
title TEXT NOT NULL,
|
|
1374
|
-
summary TEXT NOT NULL,
|
|
1375
|
-
renderer_key TEXT NOT NULL CHECK (renderer_key IN ('editorial_reading_room_v1','personal_authority_v1','personal_authority_v2','newsroom_publisher_v1','blog_article_v1')),
|
|
1376
|
-
token_hash TEXT NOT NULL UNIQUE,
|
|
1377
|
-
expires_at TEXT NOT NULL,
|
|
1378
|
-
revoked_at TEXT,
|
|
1379
|
-
created_at TEXT NOT NULL
|
|
1380
|
-
)
|
|
1381
|
-
`);
|
|
1382
|
-
await db.execute(`
|
|
1383
|
-
CREATE INDEX IF NOT EXISTS scheduled_run_view_links_owner_run
|
|
1384
|
-
ON scheduled_run_view_links(owner_id, run_id, created_at DESC)
|
|
1385
|
-
`);
|
|
1386
|
-
await db.execute(`
|
|
1387
|
-
CREATE INDEX IF NOT EXISTS scheduled_run_view_links_expiry
|
|
1388
|
-
ON scheduled_run_view_links(expires_at, revoked_at)
|
|
1389
|
-
`);
|
|
1390
|
-
await db.execute(`
|
|
1391
|
-
CREATE TABLE IF NOT EXISTS stripe_events (
|
|
1392
|
-
event_id TEXT PRIMARY KEY,
|
|
1393
|
-
status TEXT NOT NULL DEFAULT 'processed',
|
|
1394
|
-
attempt_count INTEGER NOT NULL DEFAULT 1,
|
|
1395
|
-
claim_token TEXT,
|
|
1396
|
-
last_error TEXT,
|
|
1397
|
-
processed_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
1398
|
-
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
1399
|
-
)
|
|
1400
|
-
`);
|
|
1401
|
-
for (const col of [
|
|
1402
|
-
`status TEXT NOT NULL DEFAULT 'processed'`,
|
|
1403
|
-
"attempt_count INTEGER NOT NULL DEFAULT 1",
|
|
1404
|
-
"claim_token TEXT",
|
|
1405
|
-
"last_error TEXT",
|
|
1406
|
-
"updated_at TEXT"
|
|
1407
|
-
]) {
|
|
1408
|
-
try {
|
|
1409
|
-
await db.execute(`ALTER TABLE stripe_events ADD COLUMN ${col}`);
|
|
1410
|
-
} catch {
|
|
1411
|
-
}
|
|
1412
|
-
}
|
|
1413
|
-
await db.execute(`
|
|
1414
|
-
UPDATE stripe_events
|
|
1415
|
-
SET updated_at = COALESCE(updated_at, processed_at),
|
|
1416
|
-
status = COALESCE(status, 'processed'),
|
|
1417
|
-
attempt_count = MAX(1, COALESCE(attempt_count, 1))
|
|
1418
|
-
WHERE updated_at IS NULL
|
|
1419
|
-
OR status IS NULL
|
|
1420
|
-
OR attempt_count IS NULL
|
|
1421
|
-
OR attempt_count < 1
|
|
1422
|
-
`);
|
|
1423
|
-
await db.execute(`
|
|
1424
|
-
CREATE TABLE IF NOT EXISTS oauth_clients (
|
|
1425
|
-
client_id TEXT PRIMARY KEY,
|
|
1426
|
-
redirect_uris TEXT NOT NULL,
|
|
1427
|
-
name TEXT,
|
|
1428
|
-
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
1429
|
-
)
|
|
1430
|
-
`);
|
|
1431
|
-
await db.execute(`
|
|
1432
|
-
CREATE TABLE IF NOT EXISTS oauth_codes (
|
|
1433
|
-
code TEXT PRIMARY KEY,
|
|
1434
|
-
client_id TEXT NOT NULL,
|
|
1435
|
-
redirect_uri TEXT NOT NULL,
|
|
1436
|
-
code_challenge TEXT NOT NULL,
|
|
1437
|
-
identity TEXT NOT NULL,
|
|
1438
|
-
scope TEXT NOT NULL,
|
|
1439
|
-
resource TEXT,
|
|
1440
|
-
expires_at TEXT NOT NULL,
|
|
1441
|
-
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
1442
|
-
)
|
|
1443
|
-
`);
|
|
1444
|
-
await db.execute(`
|
|
1445
|
-
CREATE TABLE IF NOT EXISTS oauth_tokens (
|
|
1446
|
-
refresh_token TEXT PRIMARY KEY,
|
|
1447
|
-
client_id TEXT NOT NULL,
|
|
1448
|
-
identity TEXT NOT NULL,
|
|
1449
|
-
scope TEXT NOT NULL,
|
|
1450
|
-
resource TEXT,
|
|
1451
|
-
expires_at TEXT NOT NULL,
|
|
1452
|
-
revoked INTEGER NOT NULL DEFAULT 0,
|
|
1453
|
-
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
1454
|
-
)
|
|
1455
|
-
`);
|
|
1456
|
-
await db.execute(`CREATE INDEX IF NOT EXISTS oauth_tokens_identity ON oauth_tokens(identity)`);
|
|
1457
|
-
for (const col of ["replaced_by TEXT", "rotated_at TEXT"]) {
|
|
1458
|
-
try {
|
|
1459
|
-
await db.execute(`ALTER TABLE oauth_tokens ADD COLUMN ${col}`);
|
|
1460
|
-
} catch {
|
|
1461
|
-
}
|
|
1462
|
-
}
|
|
1463
|
-
await db.execute(`
|
|
1464
|
-
CREATE TABLE IF NOT EXISTS page_snapshots (
|
|
1465
|
-
user_id INTEGER NOT NULL REFERENCES users(id),
|
|
1466
|
-
url TEXT NOT NULL,
|
|
1467
|
-
content_hash TEXT NOT NULL,
|
|
1468
|
-
content TEXT NOT NULL,
|
|
1469
|
-
title TEXT,
|
|
1470
|
-
content_bytes INTEGER NOT NULL,
|
|
1471
|
-
truncated INTEGER NOT NULL DEFAULT 0,
|
|
1472
|
-
checked_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
1473
|
-
PRIMARY KEY (user_id, url)
|
|
1474
|
-
)
|
|
1475
|
-
`);
|
|
1476
|
-
await db.execute(`CREATE INDEX IF NOT EXISTS page_snapshots_user_checked_at ON page_snapshots(user_id, checked_at DESC)`);
|
|
1477
|
-
await db.execute(`
|
|
1478
|
-
CREATE TABLE IF NOT EXISTS inbox_messages (
|
|
1479
|
-
id TEXT PRIMARY KEY,
|
|
1480
|
-
user_id INTEGER NOT NULL REFERENCES users(id),
|
|
1481
|
-
subject TEXT NOT NULL,
|
|
1482
|
-
body_md TEXT NOT NULL,
|
|
1483
|
-
source TEXT NOT NULL DEFAULT 'operator',
|
|
1484
|
-
sent_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
1485
|
-
opened_at TEXT,
|
|
1486
|
-
deleted_at TEXT
|
|
1487
|
-
)
|
|
1488
|
-
`);
|
|
1489
|
-
await db.execute(`CREATE INDEX IF NOT EXISTS inbox_messages_user_sent_at ON inbox_messages(user_id, sent_at DESC)`);
|
|
1490
|
-
await db.execute(`
|
|
1491
|
-
CREATE TABLE IF NOT EXISTS workshop_registrations (
|
|
1492
|
-
id TEXT PRIMARY KEY,
|
|
1493
|
-
event_slug TEXT NOT NULL,
|
|
1494
|
-
email TEXT NOT NULL,
|
|
1495
|
-
first_name TEXT NOT NULL,
|
|
1496
|
-
last_name TEXT,
|
|
1497
|
-
status TEXT NOT NULL,
|
|
1498
|
-
zoom_registrant_id TEXT,
|
|
1499
|
-
zoom_join_url TEXT,
|
|
1500
|
-
resend_contact_id TEXT,
|
|
1501
|
-
confirmation_email_id TEXT,
|
|
1502
|
-
field_notes_email_id TEXT,
|
|
1503
|
-
reminder_48h_email_id TEXT,
|
|
1504
|
-
reminder_24h_email_id TEXT,
|
|
1505
|
-
reminder_1h_email_id TEXT,
|
|
1506
|
-
last_error TEXT,
|
|
1507
|
-
registered_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
1508
|
-
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
1509
|
-
UNIQUE(event_slug, email)
|
|
1510
|
-
)
|
|
1511
|
-
`);
|
|
1512
|
-
for (const col of ["field_notes_email_id TEXT", "reminder_48h_email_id TEXT", "reminder_24h_email_id TEXT", "reminder_1h_email_id TEXT"]) {
|
|
1513
|
-
try {
|
|
1514
|
-
await db.execute(`ALTER TABLE workshop_registrations ADD COLUMN ${col}`);
|
|
1515
|
-
} catch {
|
|
1516
|
-
}
|
|
1517
|
-
}
|
|
1518
|
-
await db.execute(`
|
|
1519
|
-
CREATE INDEX IF NOT EXISTS workshop_registrations_event_status
|
|
1520
|
-
ON workshop_registrations(event_slug, status, registered_at)
|
|
1521
|
-
`);
|
|
1522
|
-
await db.execute(`
|
|
1523
|
-
CREATE TABLE IF NOT EXISTS browser_profile_labels (
|
|
1524
|
-
user_id INTEGER NOT NULL REFERENCES users(id),
|
|
1525
|
-
profile TEXT NOT NULL,
|
|
1526
|
-
label TEXT NOT NULL,
|
|
1527
|
-
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
1528
|
-
updated_at TEXT,
|
|
1529
|
-
PRIMARY KEY (user_id, profile)
|
|
1530
|
-
)
|
|
1531
|
-
`);
|
|
1532
|
-
await db.execute(`
|
|
1533
|
-
CREATE TABLE IF NOT EXISTS serp_identities (
|
|
1534
|
-
id TEXT PRIMARY KEY,
|
|
1535
|
-
user_id INTEGER NOT NULL REFERENCES users(id),
|
|
1536
|
-
name TEXT NOT NULL,
|
|
1537
|
-
kernel_profile_name TEXT NOT NULL,
|
|
1538
|
-
kernel_proxy_id TEXT NOT NULL,
|
|
1539
|
-
proxy_type TEXT NOT NULL DEFAULT 'isp',
|
|
1540
|
-
country TEXT NOT NULL DEFAULT 'US',
|
|
1541
|
-
status TEXT NOT NULL DEFAULT 'ready',
|
|
1542
|
-
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
1543
|
-
updated_at TEXT,
|
|
1544
|
-
last_used_at TEXT,
|
|
1545
|
-
UNIQUE(user_id, name)
|
|
1546
|
-
)
|
|
1547
|
-
`);
|
|
1548
|
-
await db.execute(`
|
|
1549
|
-
CREATE UNIQUE INDEX IF NOT EXISTS serp_identities_user_name
|
|
1550
|
-
ON serp_identities(user_id, name)
|
|
1551
|
-
`);
|
|
1552
|
-
if (!await hasCurrentCoreSchema(db)) {
|
|
1553
|
-
throw new Error("core schema migration finished without all required tables, indexes, and columns");
|
|
1554
|
-
}
|
|
1555
|
-
await runPendingDataRepairs(db);
|
|
1556
|
-
await db.execute({
|
|
1557
|
-
sql: "INSERT OR IGNORE INTO schema_migrations (version) VALUES (?)",
|
|
1558
|
-
args: [CORE_SCHEMA_VERSION]
|
|
1559
|
-
});
|
|
1560
|
-
}
|
|
1561
|
-
async function registerClient(clientId, redirectUris, name) {
|
|
1562
|
-
await getDb().execute({
|
|
1563
|
-
sql: "INSERT INTO oauth_clients (client_id, redirect_uris, name) VALUES (?, ?, ?)",
|
|
1564
|
-
args: [clientId, JSON.stringify(redirectUris), name]
|
|
1565
|
-
});
|
|
1566
|
-
}
|
|
1567
|
-
async function getClient(clientId) {
|
|
1568
|
-
const res = await getDb().execute({ sql: "SELECT * FROM oauth_clients WHERE client_id = ?", args: [clientId] });
|
|
1569
|
-
const row = res.rows[0];
|
|
1570
|
-
if (!row) return void 0;
|
|
1571
|
-
let uris = [];
|
|
1572
|
-
try {
|
|
1573
|
-
const parsed = JSON.parse(String(row.redirect_uris));
|
|
1574
|
-
if (Array.isArray(parsed)) uris = parsed.filter((u) => typeof u === "string");
|
|
1575
|
-
} catch {
|
|
1576
|
-
uris = [];
|
|
1577
|
-
}
|
|
1578
|
-
return {
|
|
1579
|
-
client_id: String(row.client_id),
|
|
1580
|
-
redirect_uris: uris,
|
|
1581
|
-
name: row.name != null ? String(row.name) : null,
|
|
1582
|
-
created_at: String(row.created_at)
|
|
1583
|
-
};
|
|
1584
|
-
}
|
|
1585
|
-
async function putCode(code) {
|
|
1586
|
-
await getDb().execute({
|
|
1587
|
-
sql: "INSERT INTO oauth_codes (code, client_id, redirect_uri, code_challenge, identity, scope, resource, expires_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
|
1588
|
-
args: [code.code, code.client_id, code.redirect_uri, code.code_challenge, code.identity, code.scope, code.resource, code.expires_at]
|
|
1589
|
-
});
|
|
1590
|
-
}
|
|
1591
|
-
async function consumeCode(code) {
|
|
1592
|
-
const db = getDb();
|
|
1593
|
-
const res = await db.execute({ sql: "SELECT * FROM oauth_codes WHERE code = ?", args: [code] });
|
|
1594
|
-
const row = res.rows[0];
|
|
1595
|
-
await db.execute({ sql: "DELETE FROM oauth_codes WHERE code = ?", args: [code] });
|
|
1596
|
-
if (!row) return void 0;
|
|
1597
|
-
return {
|
|
1598
|
-
code: String(row.code),
|
|
1599
|
-
client_id: String(row.client_id),
|
|
1600
|
-
redirect_uri: String(row.redirect_uri),
|
|
1601
|
-
code_challenge: String(row.code_challenge),
|
|
1602
|
-
identity: String(row.identity),
|
|
1603
|
-
scope: String(row.scope),
|
|
1604
|
-
resource: row.resource != null ? String(row.resource) : null,
|
|
1605
|
-
expires_at: String(row.expires_at)
|
|
1606
|
-
};
|
|
1607
|
-
}
|
|
1608
|
-
async function putRefresh(token) {
|
|
1609
|
-
await getDb().execute({
|
|
1610
|
-
sql: "INSERT INTO oauth_tokens (refresh_token, client_id, identity, scope, resource, expires_at, revoked) VALUES (?, ?, ?, ?, ?, ?, 0)",
|
|
1611
|
-
args: [token.refresh_token, token.client_id, token.identity, token.scope, token.resource, token.expires_at]
|
|
1612
|
-
});
|
|
1613
|
-
}
|
|
1614
|
-
async function getRefresh(refreshToken) {
|
|
1615
|
-
const res = await getDb().execute({ sql: "SELECT * FROM oauth_tokens WHERE refresh_token = ?", args: [refreshToken] });
|
|
1616
|
-
const row = res.rows[0];
|
|
1617
|
-
if (!row) return void 0;
|
|
1618
|
-
return {
|
|
1619
|
-
refresh_token: String(row.refresh_token),
|
|
1620
|
-
client_id: String(row.client_id),
|
|
1621
|
-
identity: String(row.identity),
|
|
1622
|
-
scope: String(row.scope),
|
|
1623
|
-
resource: row.resource != null ? String(row.resource) : null,
|
|
1624
|
-
expires_at: String(row.expires_at),
|
|
1625
|
-
revoked: Number(row.revoked ?? 0),
|
|
1626
|
-
replaced_by: row.replaced_by != null ? String(row.replaced_by) : null,
|
|
1627
|
-
rotated_at: row.rotated_at != null ? String(row.rotated_at) : null
|
|
1628
|
-
};
|
|
1629
|
-
}
|
|
1630
|
-
async function revokeRefresh(refreshToken) {
|
|
1631
|
-
await getDb().execute({ sql: "UPDATE oauth_tokens SET revoked = 1 WHERE refresh_token = ?", args: [refreshToken] });
|
|
1632
|
-
}
|
|
1633
|
-
async function rotateRefresh(oldToken, next) {
|
|
1634
|
-
await putRefresh(next);
|
|
1635
|
-
await getDb().execute({
|
|
1636
|
-
sql: "UPDATE oauth_tokens SET revoked = 1, replaced_by = ?, rotated_at = ? WHERE refresh_token = ?",
|
|
1637
|
-
args: [next.refresh_token, (/* @__PURE__ */ new Date()).toISOString(), oldToken]
|
|
1638
|
-
});
|
|
1639
|
-
}
|
|
1640
|
-
async function claimStripeEvent(eventId) {
|
|
1641
|
-
const token = randomUUID();
|
|
1642
|
-
const claimed = await getDb().execute({
|
|
1643
|
-
sql: `INSERT INTO stripe_events
|
|
1644
|
-
(event_id, status, attempt_count, claim_token, last_error, processed_at, updated_at)
|
|
1645
|
-
VALUES (?, 'processing', 1, ?, NULL, datetime('now'), datetime('now'))
|
|
1646
|
-
ON CONFLICT(event_id) DO UPDATE SET
|
|
1647
|
-
status = 'processing',
|
|
1648
|
-
attempt_count = stripe_events.attempt_count + 1,
|
|
1649
|
-
claim_token = excluded.claim_token,
|
|
1650
|
-
last_error = NULL,
|
|
1651
|
-
updated_at = datetime('now')
|
|
1652
|
-
WHERE stripe_events.status = 'failed'
|
|
1653
|
-
OR (
|
|
1654
|
-
stripe_events.status = 'processing'
|
|
1655
|
-
AND stripe_events.updated_at <= datetime('now', '-10 minutes')
|
|
1656
|
-
)`,
|
|
1657
|
-
args: [eventId, token]
|
|
1658
|
-
});
|
|
1659
|
-
if (claimed.rowsAffected > 0) return { state: "claimed", token };
|
|
1660
|
-
const current = await getDb().execute({
|
|
1661
|
-
sql: "SELECT status FROM stripe_events WHERE event_id = ? LIMIT 1",
|
|
1662
|
-
args: [eventId]
|
|
1663
|
-
});
|
|
1664
|
-
return { state: String(current.rows[0]?.status) === "processed" ? "processed" : "processing" };
|
|
1665
|
-
}
|
|
1666
|
-
async function completeStripeEvent(eventId, token) {
|
|
1667
|
-
const result = await getDb().execute({
|
|
1668
|
-
sql: `UPDATE stripe_events
|
|
1669
|
-
SET status = 'processed',
|
|
1670
|
-
claim_token = NULL,
|
|
1671
|
-
last_error = NULL,
|
|
1672
|
-
processed_at = datetime('now'),
|
|
1673
|
-
updated_at = datetime('now')
|
|
1674
|
-
WHERE event_id = ? AND status = 'processing' AND claim_token = ?`,
|
|
1675
|
-
args: [eventId, token]
|
|
1676
|
-
});
|
|
1677
|
-
return result.rowsAffected === 1;
|
|
1678
|
-
}
|
|
1679
|
-
async function failStripeEvent(eventId, token, error) {
|
|
1680
|
-
const result = await getDb().execute({
|
|
1681
|
-
sql: `UPDATE stripe_events
|
|
1682
|
-
SET status = 'failed',
|
|
1683
|
-
claim_token = NULL,
|
|
1684
|
-
last_error = ?,
|
|
1685
|
-
updated_at = datetime('now')
|
|
1686
|
-
WHERE event_id = ? AND status = 'processing' AND claim_token = ?`,
|
|
1687
|
-
args: [error.slice(0, 500), eventId, token]
|
|
1688
|
-
});
|
|
1689
|
-
return result.rowsAffected === 1;
|
|
1690
|
-
}
|
|
1691
|
-
async function stripeEventAlreadyProcessed(eventId) {
|
|
1692
|
-
const res = await getDb().execute({
|
|
1693
|
-
sql: `SELECT 1 FROM stripe_events WHERE event_id = ? AND status = 'processed' LIMIT 1`,
|
|
1694
|
-
args: [eventId]
|
|
1695
|
-
});
|
|
1696
|
-
return res.rows.length > 0;
|
|
1697
|
-
}
|
|
1698
|
-
function generateApiKey() {
|
|
1699
|
-
return "sk_" + randomBytes(24).toString("hex");
|
|
1700
|
-
}
|
|
1701
|
-
function hashPassword(password) {
|
|
1702
|
-
const salt = randomBytes(16).toString("hex");
|
|
1703
|
-
const hash = scryptSync(password, salt, 64);
|
|
1704
|
-
return `${salt}:${hash.toString("hex")}`;
|
|
1705
|
-
}
|
|
1706
|
-
function verifyPassword(password, stored) {
|
|
1707
|
-
const [salt, hash] = stored.split(":");
|
|
1708
|
-
const hashBuf = Buffer.from(hash, "hex");
|
|
1709
|
-
const derived = scryptSync(password, salt, 64);
|
|
1710
|
-
return timingSafeEqual(hashBuf, derived);
|
|
1711
|
-
}
|
|
1712
|
-
function rowToUser(row) {
|
|
1713
|
-
return {
|
|
1714
|
-
id: Number(row.id),
|
|
1715
|
-
email: String(row.email),
|
|
1716
|
-
name: row.name != null ? String(row.name) : null,
|
|
1717
|
-
api_key: String(row.api_key),
|
|
1718
|
-
key_active: Number(row.key_active ?? 1),
|
|
1719
|
-
password_hash: row.password_hash != null ? String(row.password_hash) : null,
|
|
1720
|
-
created_at: String(row.created_at),
|
|
1721
|
-
active: Number(row.active),
|
|
1722
|
-
stripe_customer_id: row.stripe_customer_id != null ? String(row.stripe_customer_id) : null,
|
|
1723
|
-
balance_mc: Number(row.balance_mc ?? 0),
|
|
1724
|
-
extra_concurrency_slots: Number(row.extra_concurrency_slots ?? 0),
|
|
1725
|
-
concurrency_stripe_sub_id: row.concurrency_stripe_sub_id != null ? String(row.concurrency_stripe_sub_id) : null,
|
|
1726
|
-
concurrency_pack_quantity: Number(row.concurrency_pack_quantity ?? 0),
|
|
1727
|
-
subscription_concurrency: Number(row.subscription_concurrency ?? 0),
|
|
1728
|
-
subscription_tier: row.subscription_tier != null ? String(row.subscription_tier) : null,
|
|
1729
|
-
subscription_id: row.subscription_id != null ? String(row.subscription_id) : null,
|
|
1730
|
-
memory_plan: row.memory_plan != null ? String(row.memory_plan) : "free",
|
|
1731
|
-
memory_subscription_id: row.memory_subscription_id != null ? String(row.memory_subscription_id) : null,
|
|
1732
|
-
memory_provisioned: Number(row.memory_provisioned ?? 0)
|
|
1733
|
-
};
|
|
1734
|
-
}
|
|
1735
|
-
async function getUserByEmail(email) {
|
|
1736
|
-
const res = await getDb().execute({ sql: "SELECT * FROM users WHERE email = ? AND active = 1", args: [email] });
|
|
1737
|
-
return res.rows[0] ? rowToUser(res.rows[0]) : void 0;
|
|
1738
|
-
}
|
|
1739
|
-
async function getUserByApiKey(api_key) {
|
|
1740
|
-
const res = await getDb().execute({ sql: "SELECT * FROM users WHERE api_key = ? AND active = 1 AND key_active = 1", args: [api_key] });
|
|
1741
|
-
return res.rows[0] ? rowToUser(res.rows[0]) : void 0;
|
|
1742
|
-
}
|
|
1743
|
-
async function getUserById(id) {
|
|
1744
|
-
const res = await getDb().execute({ sql: "SELECT * FROM users WHERE id = ? AND active = 1", args: [id] });
|
|
1745
|
-
return res.rows[0] ? rowToUser(res.rows[0]) : void 0;
|
|
1746
|
-
}
|
|
1747
|
-
async function getUserMemoryCreds(id) {
|
|
1748
|
-
const res = await getDb().execute({ sql: "SELECT memory_key, memory_identity FROM users WHERE id = ?", args: [id] });
|
|
1749
|
-
const row = res.rows[0];
|
|
1750
|
-
return {
|
|
1751
|
-
memory_key: row?.memory_key != null ? String(row.memory_key) : null,
|
|
1752
|
-
memory_identity: row?.memory_identity != null ? String(row.memory_identity) : null
|
|
1753
|
-
};
|
|
1754
|
-
}
|
|
1755
|
-
async function setUserMemoryCreds(id, memoryKeyEncrypted, memoryIdentity) {
|
|
1756
|
-
await getDb().execute({ sql: "UPDATE users SET memory_key = ?, memory_identity = ? WHERE id = ?", args: [memoryKeyEncrypted, memoryIdentity, id] });
|
|
1757
|
-
}
|
|
1758
|
-
async function setPassword(id, password) {
|
|
1759
|
-
await getDb().execute({ sql: "UPDATE users SET password_hash = ? WHERE id = ?", args: [hashPassword(password), id] });
|
|
1760
|
-
}
|
|
1761
|
-
async function getUserStats(userId) {
|
|
1762
|
-
const db = getDb();
|
|
1763
|
-
const [totR, doneR, failR, tqR, luR] = await Promise.all([
|
|
1764
|
-
db.execute({ sql: "SELECT COUNT(*) as n FROM jobs WHERE user_id = ?", args: [userId] }),
|
|
1765
|
-
db.execute({ sql: "SELECT COUNT(*) as n FROM jobs WHERE user_id = ? AND status = 'done'", args: [userId] }),
|
|
1766
|
-
db.execute({ sql: "SELECT COUNT(*) as n FROM jobs WHERE user_id = ? AND status = 'failed'", args: [userId] }),
|
|
1767
|
-
db.execute({ sql: "SELECT COALESCE(SUM(json_extract(result, '$.totalQuestions')), 0) as n FROM jobs WHERE user_id = ? AND status = 'done'", args: [userId] }),
|
|
1768
|
-
db.execute({ sql: "SELECT MAX(created_at) as t FROM jobs WHERE user_id = ?", args: [userId] })
|
|
1769
|
-
]);
|
|
1770
|
-
return {
|
|
1771
|
-
total: Number(totR.rows[0]?.n ?? 0),
|
|
1772
|
-
done: Number(doneR.rows[0]?.n ?? 0),
|
|
1773
|
-
failed: Number(failR.rows[0]?.n ?? 0),
|
|
1774
|
-
totalQuestions: Number(tqR.rows[0]?.n ?? 0),
|
|
1775
|
-
lastUsed: luR.rows[0]?.t != null ? String(luR.rows[0].t) : null
|
|
1776
|
-
};
|
|
1777
|
-
}
|
|
1778
|
-
async function createUser(email, name, password, stripeCustomerId) {
|
|
1779
|
-
const db = getDb();
|
|
1780
|
-
const api_key = generateApiKey();
|
|
1781
|
-
const plainPassword = password ?? randomBytes(6).toString("hex");
|
|
1782
|
-
const password_hash = hashPassword(plainPassword);
|
|
1783
|
-
const result = await db.execute({
|
|
1784
|
-
sql: "INSERT INTO users (email, name, api_key, password_hash, stripe_customer_id) VALUES (?, ?, ?, ?, ?)",
|
|
1785
|
-
args: [email, name ?? null, api_key, password_hash, stripeCustomerId ?? null]
|
|
1786
|
-
});
|
|
1787
|
-
return { id: result.lastInsertRowid, email, name, api_key, password: plainPassword };
|
|
1788
|
-
}
|
|
1789
|
-
async function checkRateLimit(scope, key, limit, windowSeconds) {
|
|
1790
|
-
const nowSeconds = Math.floor(Date.now() / 1e3);
|
|
1791
|
-
const windowStart = Math.floor(nowSeconds / windowSeconds) * windowSeconds;
|
|
1792
|
-
const keyHash = createHash("sha256").update(key).digest("hex");
|
|
1793
|
-
const db = getDb();
|
|
1794
|
-
if (!_rateLimitSchemaReady) {
|
|
1795
|
-
await db.execute(`
|
|
1796
|
-
CREATE TABLE IF NOT EXISTS rate_limits (
|
|
1797
|
-
scope TEXT NOT NULL,
|
|
1798
|
-
key_hash TEXT NOT NULL,
|
|
1799
|
-
window_start INTEGER NOT NULL,
|
|
1800
|
-
count INTEGER NOT NULL DEFAULT 0,
|
|
1801
|
-
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
1802
|
-
PRIMARY KEY (scope, key_hash, window_start)
|
|
1803
|
-
)
|
|
1804
|
-
`);
|
|
1805
|
-
_rateLimitSchemaReady = true;
|
|
1806
|
-
}
|
|
1807
|
-
await db.execute({
|
|
1808
|
-
sql: `
|
|
1809
|
-
INSERT INTO rate_limits (scope, key_hash, window_start, count)
|
|
1810
|
-
VALUES (?, ?, ?, 1)
|
|
1811
|
-
ON CONFLICT(scope, key_hash, window_start)
|
|
1812
|
-
DO UPDATE SET count = count + 1, updated_at = datetime('now')
|
|
1813
|
-
`,
|
|
1814
|
-
args: [scope, keyHash, windowStart]
|
|
1815
|
-
});
|
|
1816
|
-
const res = await db.execute({
|
|
1817
|
-
sql: "SELECT count FROM rate_limits WHERE scope = ? AND key_hash = ? AND window_start = ?",
|
|
1818
|
-
args: [scope, keyHash, windowStart]
|
|
1819
|
-
});
|
|
1820
|
-
const count = Number(res.rows[0]?.count ?? 1);
|
|
1821
|
-
return {
|
|
1822
|
-
allowed: count <= limit,
|
|
1823
|
-
count,
|
|
1824
|
-
remaining: Math.max(0, limit - count),
|
|
1825
|
-
resetSeconds: Math.max(1, windowStart + windowSeconds - nowSeconds)
|
|
1826
|
-
};
|
|
1827
|
-
}
|
|
1828
|
-
async function createPasswordResetToken(userId) {
|
|
1829
|
-
const token = randomBytes(32).toString("hex");
|
|
1830
|
-
const tokenHash = createHash("sha256").update(token).digest("hex");
|
|
1831
|
-
const expiresAt = new Date(Date.now() + 60 * 60 * 1e3).toISOString();
|
|
1832
|
-
await getDb().execute({
|
|
1833
|
-
sql: "INSERT INTO password_reset_tokens (token, user_id, expires_at) VALUES (?, ?, ?)",
|
|
1834
|
-
args: [tokenHash, userId, expiresAt]
|
|
1835
|
-
});
|
|
1836
|
-
return token;
|
|
1837
|
-
}
|
|
1838
|
-
async function consumePasswordResetToken(token) {
|
|
1839
|
-
const db = getDb();
|
|
1840
|
-
const tokenHash = createHash("sha256").update(token).digest("hex");
|
|
1841
|
-
const res = await db.execute({
|
|
1842
|
-
sql: "SELECT user_id, expires_at, used FROM password_reset_tokens WHERE token = ?",
|
|
1843
|
-
args: [tokenHash]
|
|
1844
|
-
});
|
|
1845
|
-
const row = res.rows[0];
|
|
1846
|
-
if (!row) return null;
|
|
1847
|
-
if (Number(row.used)) return null;
|
|
1848
|
-
if (new Date(String(row.expires_at)) < /* @__PURE__ */ new Date()) return null;
|
|
1849
|
-
await db.execute({ sql: "UPDATE password_reset_tokens SET used = 1 WHERE token = ?", args: [tokenHash] });
|
|
1850
|
-
return Number(row.user_id);
|
|
1851
|
-
}
|
|
1852
|
-
async function rotateApiKey(userId) {
|
|
1853
|
-
const newKey = generateApiKey();
|
|
1854
|
-
await getDb().execute({ sql: "UPDATE users SET api_key = ?, key_active = 1 WHERE id = ?", args: [newKey, userId] });
|
|
1855
|
-
return newKey;
|
|
1856
|
-
}
|
|
1857
|
-
async function revokeApiKey(userId) {
|
|
1858
|
-
await getDb().execute({ sql: "UPDATE users SET key_active = 0 WHERE id = ?", args: [userId] });
|
|
1859
|
-
}
|
|
1860
|
-
async function listUsers() {
|
|
1861
|
-
const res = await getDb().execute("SELECT id, email, name, created_at, active FROM users ORDER BY created_at DESC");
|
|
1862
|
-
return res.rows.map((r) => rowToUser({ ...r, api_key: "", key_active: 1 }));
|
|
1863
|
-
}
|
|
1864
|
-
async function deactivateUser(id) {
|
|
1865
|
-
await getDb().execute({ sql: "UPDATE users SET active = 0 WHERE id = ?", args: [id] });
|
|
1866
|
-
}
|
|
1867
|
-
function rowToRawJob(row) {
|
|
1868
|
-
return {
|
|
1869
|
-
id: String(row.id),
|
|
1870
|
-
user_id: Number(row.user_id),
|
|
1871
|
-
status: String(row.status),
|
|
1872
|
-
query: String(row.query),
|
|
1873
|
-
options: String(row.options),
|
|
1874
|
-
callback_url: row.callback_url != null ? String(row.callback_url) : null,
|
|
1875
|
-
result: row.result != null ? String(row.result) : null,
|
|
1876
|
-
error: row.error != null ? String(row.error) : null,
|
|
1877
|
-
public_error_json: row.public_error_json != null ? String(row.public_error_json) : null,
|
|
1878
|
-
created_at: String(row.created_at),
|
|
1879
|
-
started_at: row.started_at != null ? String(row.started_at) : null,
|
|
1880
|
-
completed_at: row.completed_at != null ? String(row.completed_at) : null
|
|
1881
|
-
};
|
|
1882
|
-
}
|
|
1883
|
-
function nullableBoolean(value) {
|
|
1884
|
-
return value == null ? null : Number(value) === 1;
|
|
1885
|
-
}
|
|
1886
|
-
function parseNullableJson(value) {
|
|
1887
|
-
if (value == null) return null;
|
|
1888
|
-
try {
|
|
1889
|
-
return JSON.parse(String(value));
|
|
1890
|
-
} catch {
|
|
1891
|
-
return null;
|
|
1892
|
-
}
|
|
1893
|
-
}
|
|
1894
|
-
function rowToHarvestAttemptLog(row) {
|
|
1895
|
-
return {
|
|
1896
|
-
id: String(row.id),
|
|
1897
|
-
job_id: String(row.job_id),
|
|
1898
|
-
user_id: Number(row.user_id),
|
|
1899
|
-
attempt_number: Number(row.attempt_number),
|
|
1900
|
-
max_attempts: Number(row.max_attempts),
|
|
1901
|
-
query: String(row.query),
|
|
1902
|
-
location: row.location != null ? String(row.location) : null,
|
|
1903
|
-
max_questions: Number(row.max_questions),
|
|
1904
|
-
status: String(row.status),
|
|
1905
|
-
outcome: row.outcome != null ? String(row.outcome) : null,
|
|
1906
|
-
kernel_session_id: row.kernel_session_id != null ? String(row.kernel_session_id) : null,
|
|
1907
|
-
question_count: row.question_count != null ? Number(row.question_count) : null,
|
|
1908
|
-
duration_ms: row.duration_ms != null ? Number(row.duration_ms) : null,
|
|
1909
|
-
error: row.error != null ? String(row.error) : null,
|
|
1910
|
-
will_retry: nullableBoolean(row.will_retry),
|
|
1911
|
-
kernel_delete_started: Number(row.kernel_delete_started ?? 0) === 1,
|
|
1912
|
-
kernel_delete_succeeded: nullableBoolean(row.kernel_delete_succeeded),
|
|
1913
|
-
kernel_delete_error: row.kernel_delete_error != null ? String(row.kernel_delete_error) : null,
|
|
1914
|
-
browser_close_succeeded: nullableBoolean(row.browser_close_succeeded),
|
|
1915
|
-
browser_close_error: row.browser_close_error != null ? String(row.browser_close_error) : null,
|
|
1916
|
-
browser_provider: row.browser_provider != null ? String(row.browser_provider) : null,
|
|
1917
|
-
provider_session_id: row.provider_session_id != null ? String(row.provider_session_id) : null,
|
|
1918
|
-
provider_session_observed_at: row.provider_session_observed_at != null ? String(row.provider_session_observed_at) : null,
|
|
1919
|
-
provider_lookup_status: row.provider_lookup_status != null ? String(row.provider_lookup_status) : null,
|
|
1920
|
-
provider_status: row.provider_status != null ? String(row.provider_status) : null,
|
|
1921
|
-
provider_error_code: row.provider_error_code != null ? String(row.provider_error_code) : null,
|
|
1922
|
-
provider_error_message: row.provider_error_message != null ? String(row.provider_error_message) : null,
|
|
1923
|
-
provider_reason_source: row.provider_reason_source != null ? String(row.provider_reason_source) : null,
|
|
1924
|
-
provider_duration_ms: row.provider_duration_ms != null ? Number(row.provider_duration_ms) : null,
|
|
1925
|
-
provider_bandwidth_bytes: row.provider_bandwidth_bytes != null ? Number(row.provider_bandwidth_bytes) : null,
|
|
1926
|
-
provider_captcha: nullableBoolean(row.provider_captcha),
|
|
1927
|
-
provider_checked_at: row.provider_checked_at != null ? String(row.provider_checked_at) : null,
|
|
1928
|
-
provider_detail: parseNullableJson(row.provider_detail_json),
|
|
1929
|
-
provider_disconnect_observed: nullableBoolean(row.provider_disconnect_observed),
|
|
1930
|
-
provider_disconnect_message: row.provider_disconnect_message != null ? String(row.provider_disconnect_message) : null,
|
|
1931
|
-
debug: parseNullableJson(row.debug_json),
|
|
1932
|
-
created_at: String(row.created_at),
|
|
1933
|
-
completed_at: row.completed_at != null ? String(row.completed_at) : null
|
|
1934
|
-
};
|
|
1935
|
-
}
|
|
1936
|
-
function deserialize(raw) {
|
|
1937
|
-
const { public_error_json: publicErrorJson, ...rest } = raw;
|
|
1938
|
-
return {
|
|
1939
|
-
...rest,
|
|
1940
|
-
options: JSON.parse(raw.options),
|
|
1941
|
-
result: raw.result ? JSON.parse(raw.result) : null,
|
|
1942
|
-
publicError: parsePublicErrorEnvelope(publicErrorJson)
|
|
1943
|
-
};
|
|
1944
|
-
}
|
|
1945
|
-
async function createJob(userId, query, options, callbackUrl) {
|
|
1946
|
-
const id = randomUUID();
|
|
1947
|
-
await createJobWithId(id, userId, query, options, callbackUrl);
|
|
1948
|
-
return id;
|
|
1949
|
-
}
|
|
1950
|
-
async function createJobWithId(id, userId, query, options, callbackUrl) {
|
|
1951
|
-
if (!/^[a-zA-Z0-9-]{1,100}$/.test(id)) throw new Error("job id is invalid");
|
|
1952
|
-
await getDb().execute({
|
|
1953
|
-
sql: "INSERT INTO jobs (id, user_id, query, options, callback_url) VALUES (?, ?, ?, ?, ?)",
|
|
1954
|
-
args: [id, userId, query, JSON.stringify(options), callbackUrl ?? null]
|
|
1955
|
-
});
|
|
1956
|
-
}
|
|
1957
|
-
async function createRunningJob(userId, query, options) {
|
|
1958
|
-
const id = randomUUID();
|
|
1959
|
-
await getDb().execute({
|
|
1960
|
-
sql: `INSERT INTO jobs (id, user_id, query, options, status, started_at) VALUES (?, ?, ?, ?, 'running', datetime('now'))`,
|
|
1961
|
-
args: [id, userId, query, JSON.stringify(options)]
|
|
1962
|
-
});
|
|
1963
|
-
return id;
|
|
1964
|
-
}
|
|
1965
|
-
async function getOrCreateRunningJob(id, userId, query, options) {
|
|
1966
|
-
const inserted = await getDb().execute({
|
|
1967
|
-
sql: `INSERT INTO jobs (id, user_id, query, options, status, started_at)
|
|
1968
|
-
VALUES (?, ?, ?, ?, 'running', datetime('now'))
|
|
1969
|
-
ON CONFLICT(id) DO NOTHING`,
|
|
1970
|
-
args: [id, userId, query, JSON.stringify(options)]
|
|
1971
|
-
});
|
|
1972
|
-
const job = await getJob(id, userId);
|
|
1973
|
-
if (!job) throw new Error(`getOrCreateRunningJob: row missing immediately after insert/lookup for ${id}`);
|
|
1974
|
-
return { job, created: inserted.rowsAffected === 1 };
|
|
1975
|
-
}
|
|
1976
|
-
async function startHarvestAttempt(input) {
|
|
1977
|
-
await getDb().execute({
|
|
1978
|
-
sql: `
|
|
1979
|
-
INSERT INTO harvest_attempts (
|
|
1980
|
-
id, job_id, user_id, attempt_number, max_attempts, query, location, max_questions, status, created_at
|
|
1981
|
-
)
|
|
1982
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'running', ?)
|
|
1983
|
-
ON CONFLICT(job_id, attempt_number) DO NOTHING
|
|
1984
|
-
`,
|
|
1985
|
-
args: [
|
|
1986
|
-
randomUUID(),
|
|
1987
|
-
input.jobId,
|
|
1988
|
-
input.userId,
|
|
1989
|
-
input.attemptNumber,
|
|
1990
|
-
input.maxAttempts,
|
|
1991
|
-
input.query,
|
|
1992
|
-
input.location,
|
|
1993
|
-
input.maxQuestions,
|
|
1994
|
-
input.startedAt
|
|
1995
|
-
]
|
|
1996
|
-
});
|
|
1997
|
-
}
|
|
1998
|
-
async function attachHarvestAttemptProviderSession(input) {
|
|
1999
|
-
const result = await getDb().execute({
|
|
2000
|
-
sql: `
|
|
2001
|
-
UPDATE harvest_attempts SET
|
|
2002
|
-
browser_provider = ?,
|
|
2003
|
-
provider_session_id = ?,
|
|
2004
|
-
provider_session_observed_at = COALESCE(provider_session_observed_at, ?),
|
|
2005
|
-
provider_lookup_status = CASE
|
|
2006
|
-
WHEN provider_lookup_status = 'complete' THEN provider_lookup_status
|
|
2007
|
-
ELSE 'pending'
|
|
2008
|
-
END
|
|
2009
|
-
WHERE job_id = ? AND attempt_number = ? AND status = 'running'
|
|
2010
|
-
`,
|
|
2011
|
-
args: [
|
|
2012
|
-
input.browserProvider,
|
|
2013
|
-
input.providerSessionId,
|
|
2014
|
-
input.observedAt,
|
|
2015
|
-
input.jobId,
|
|
2016
|
-
input.attemptNumber
|
|
2017
|
-
]
|
|
2018
|
-
});
|
|
2019
|
-
return result.rowsAffected === 1;
|
|
2020
|
-
}
|
|
2021
|
-
async function finalizeExpiredHarvestAttempts(jobId, willRetry, completedAt = (/* @__PURE__ */ new Date()).toISOString()) {
|
|
2022
|
-
const result = await getDb().execute({
|
|
2023
|
-
sql: `
|
|
2024
|
-
UPDATE harvest_attempts SET
|
|
2025
|
-
status = 'finished',
|
|
2026
|
-
outcome = COALESCE(outcome, 'timeout'),
|
|
2027
|
-
question_count = COALESCE(question_count, 0),
|
|
2028
|
-
duration_ms = COALESCE(duration_ms, MAX(0, CAST((julianday(?) - julianday(created_at)) * 86400000 AS INTEGER))),
|
|
2029
|
-
error = COALESCE(error, 'Durable worker lease expired before attempt completion'),
|
|
2030
|
-
will_retry = ?,
|
|
2031
|
-
provider_error_code = COALESCE(provider_error_code, 'timeout'),
|
|
2032
|
-
provider_error_message = COALESCE(provider_error_message, 'Durable worker lease expired before attempt completion'),
|
|
2033
|
-
provider_reason_source = COALESCE(provider_reason_source, 'client_runtime'),
|
|
2034
|
-
completed_at = COALESCE(completed_at, ?)
|
|
2035
|
-
WHERE job_id = ? AND status = 'running'
|
|
2036
|
-
`,
|
|
2037
|
-
args: [completedAt, willRetry ? 1 : 0, completedAt, jobId]
|
|
2038
|
-
});
|
|
2039
|
-
return result.rowsAffected;
|
|
2040
|
-
}
|
|
2041
|
-
async function finishHarvestAttempt(input) {
|
|
2042
|
-
await getDb().execute({
|
|
2043
|
-
sql: `
|
|
2044
|
-
UPDATE harvest_attempts SET
|
|
2045
|
-
status = 'finished',
|
|
2046
|
-
outcome = ?,
|
|
2047
|
-
kernel_session_id = ?,
|
|
2048
|
-
question_count = ?,
|
|
2049
|
-
duration_ms = ?,
|
|
2050
|
-
error = ?,
|
|
2051
|
-
will_retry = ?,
|
|
2052
|
-
kernel_delete_started = ?,
|
|
2053
|
-
kernel_delete_succeeded = ?,
|
|
2054
|
-
kernel_delete_error = ?,
|
|
2055
|
-
browser_close_succeeded = ?,
|
|
2056
|
-
browser_close_error = ?,
|
|
2057
|
-
browser_provider = COALESCE(browser_provider, ?),
|
|
2058
|
-
provider_session_id = COALESCE(provider_session_id, ?),
|
|
2059
|
-
provider_lookup_status = CASE
|
|
2060
|
-
WHEN provider_lookup_status = 'complete' THEN provider_lookup_status
|
|
2061
|
-
ELSE COALESCE(?, provider_lookup_status)
|
|
2062
|
-
END,
|
|
2063
|
-
provider_status = COALESCE(provider_status, ?),
|
|
2064
|
-
provider_error_code = CASE
|
|
2065
|
-
WHEN provider_reason_source = 'provider_session_api' THEN provider_error_code
|
|
2066
|
-
ELSE COALESCE(?, provider_error_code)
|
|
2067
|
-
END,
|
|
2068
|
-
provider_error_message = CASE
|
|
2069
|
-
WHEN provider_reason_source = 'provider_session_api' THEN provider_error_message
|
|
2070
|
-
ELSE COALESCE(?, provider_error_message)
|
|
2071
|
-
END,
|
|
2072
|
-
provider_reason_source = CASE
|
|
2073
|
-
WHEN provider_reason_source = 'provider_session_api' THEN provider_reason_source
|
|
2074
|
-
ELSE COALESCE(?, provider_reason_source)
|
|
2075
|
-
END,
|
|
2076
|
-
provider_duration_ms = COALESCE(provider_duration_ms, ?),
|
|
2077
|
-
provider_bandwidth_bytes = COALESCE(provider_bandwidth_bytes, ?),
|
|
2078
|
-
provider_captcha = COALESCE(provider_captcha, ?),
|
|
2079
|
-
provider_checked_at = COALESCE(provider_checked_at, ?),
|
|
2080
|
-
provider_detail_json = COALESCE(provider_detail_json, ?),
|
|
2081
|
-
provider_disconnect_observed = COALESCE(provider_disconnect_observed, ?),
|
|
2082
|
-
provider_disconnect_message = COALESCE(provider_disconnect_message, ?),
|
|
2083
|
-
debug_json = ?,
|
|
2084
|
-
completed_at = ?
|
|
2085
|
-
WHERE job_id = ? AND attempt_number = ?
|
|
2086
|
-
`,
|
|
2087
|
-
args: [
|
|
2088
|
-
input.outcome,
|
|
2089
|
-
input.kernelSessionId,
|
|
2090
|
-
input.questionCount,
|
|
2091
|
-
input.durationMs,
|
|
2092
|
-
input.error,
|
|
2093
|
-
input.willRetry ? 1 : 0,
|
|
2094
|
-
input.kernelDeleteStarted ? 1 : 0,
|
|
2095
|
-
input.kernelDeleteSucceeded == null ? null : input.kernelDeleteSucceeded ? 1 : 0,
|
|
2096
|
-
input.kernelDeleteError,
|
|
2097
|
-
input.browserCloseSucceeded == null ? null : input.browserCloseSucceeded ? 1 : 0,
|
|
2098
|
-
input.browserCloseError,
|
|
2099
|
-
input.browserProvider ?? null,
|
|
2100
|
-
input.providerSessionId ?? null,
|
|
2101
|
-
input.providerLookupStatus ?? null,
|
|
2102
|
-
input.providerStatus ?? null,
|
|
2103
|
-
input.providerErrorCode ?? null,
|
|
2104
|
-
input.providerErrorMessage ?? null,
|
|
2105
|
-
input.providerReasonSource ?? null,
|
|
2106
|
-
input.providerDurationMs ?? null,
|
|
2107
|
-
input.providerBandwidthBytes ?? null,
|
|
2108
|
-
input.providerCaptcha == null ? null : input.providerCaptcha ? 1 : 0,
|
|
2109
|
-
input.providerCheckedAt ?? null,
|
|
2110
|
-
input.providerDetail == null ? null : boundedJson(input.providerDetail),
|
|
2111
|
-
input.providerDisconnectObserved == null ? null : input.providerDisconnectObserved ? 1 : 0,
|
|
2112
|
-
input.providerDisconnectMessage ?? null,
|
|
2113
|
-
input.debug == null ? null : JSON.stringify(input.debug),
|
|
2114
|
-
input.completedAt,
|
|
2115
|
-
input.jobId,
|
|
2116
|
-
input.attemptNumber
|
|
2117
|
-
]
|
|
2118
|
-
});
|
|
2119
|
-
}
|
|
2120
|
-
function boundedJson(value, maxBytes = 4096) {
|
|
2121
|
-
const serialized = JSON.stringify(value);
|
|
2122
|
-
return serialized.length <= maxBytes ? serialized : JSON.stringify({ truncated: true });
|
|
2123
|
-
}
|
|
2124
|
-
async function updateHarvestAttemptProviderTelemetry(input) {
|
|
2125
|
-
const result = await getDb().execute({
|
|
2126
|
-
sql: `
|
|
2127
|
-
UPDATE harvest_attempts SET
|
|
2128
|
-
provider_lookup_status = ?,
|
|
2129
|
-
provider_status = ?,
|
|
2130
|
-
provider_error_code = ?,
|
|
2131
|
-
provider_error_message = ?,
|
|
2132
|
-
provider_reason_source = ?,
|
|
2133
|
-
provider_duration_ms = ?,
|
|
2134
|
-
provider_bandwidth_bytes = ?,
|
|
2135
|
-
provider_captcha = ?,
|
|
2136
|
-
provider_checked_at = ?,
|
|
2137
|
-
provider_detail_json = ?
|
|
2138
|
-
WHERE id = ?
|
|
2139
|
-
AND job_id = ?
|
|
2140
|
-
AND user_id = ?
|
|
2141
|
-
AND browser_provider = 'bright_data_browser_api'
|
|
2142
|
-
AND provider_session_id = ?
|
|
2143
|
-
AND COALESCE(provider_lookup_status, '') <> 'complete'
|
|
2144
|
-
`,
|
|
2145
|
-
args: [
|
|
2146
|
-
input.lookupStatus,
|
|
2147
|
-
input.providerStatus ?? null,
|
|
2148
|
-
input.errorCode ?? null,
|
|
2149
|
-
input.errorMessage ?? null,
|
|
2150
|
-
input.reasonSource ?? null,
|
|
2151
|
-
input.durationMs ?? null,
|
|
2152
|
-
input.bandwidthBytes ?? null,
|
|
2153
|
-
input.captcha == null ? null : input.captcha ? 1 : 0,
|
|
2154
|
-
input.checkedAt,
|
|
2155
|
-
input.detail == null ? null : boundedJson(input.detail),
|
|
2156
|
-
input.attemptId,
|
|
2157
|
-
input.jobId,
|
|
2158
|
-
input.userId,
|
|
2159
|
-
input.providerSessionId
|
|
2160
|
-
]
|
|
2161
|
-
});
|
|
2162
|
-
return result.rowsAffected === 1;
|
|
2163
|
-
}
|
|
2164
|
-
async function listHarvestAttemptsPendingProviderTelemetry(limit = 25) {
|
|
2165
|
-
const boundedLimit = Math.max(1, Math.min(100, Math.trunc(limit)));
|
|
2166
|
-
const result = await getDb().execute({
|
|
2167
|
-
sql: `
|
|
2168
|
-
SELECT * FROM harvest_attempts
|
|
2169
|
-
WHERE browser_provider = 'bright_data_browser_api'
|
|
2170
|
-
AND provider_session_id IS NOT NULL
|
|
2171
|
-
AND provider_lookup_status IN ('pending', 'temporary_error')
|
|
2172
|
-
ORDER BY COALESCE(provider_checked_at, created_at) ASC
|
|
2173
|
-
LIMIT ?
|
|
2174
|
-
`,
|
|
2175
|
-
args: [boundedLimit]
|
|
2176
|
-
});
|
|
2177
|
-
return result.rows.map((row) => rowToHarvestAttemptLog(row));
|
|
2178
|
-
}
|
|
2179
|
-
async function listHarvestAttempts(jobId, userId) {
|
|
2180
|
-
const res = userId == null ? await getDb().execute({
|
|
2181
|
-
sql: `SELECT * FROM harvest_attempts WHERE job_id = ? ORDER BY attempt_number ASC`,
|
|
2182
|
-
args: [jobId]
|
|
2183
|
-
}) : await getDb().execute({
|
|
2184
|
-
sql: `
|
|
2185
|
-
SELECT ha.*
|
|
2186
|
-
FROM harvest_attempts ha
|
|
2187
|
-
INNER JOIN jobs j ON j.id = ha.job_id
|
|
2188
|
-
WHERE ha.job_id = ? AND j.user_id = ?
|
|
2189
|
-
ORDER BY ha.attempt_number ASC
|
|
2190
|
-
`,
|
|
2191
|
-
args: [jobId, userId]
|
|
2192
|
-
});
|
|
2193
|
-
return res.rows.map((r) => rowToHarvestAttemptLog(r));
|
|
2194
|
-
}
|
|
2195
|
-
async function getJob(id, userId) {
|
|
2196
|
-
const db = getDb();
|
|
2197
|
-
const res = userId ? await db.execute({ sql: "SELECT * FROM jobs WHERE id = ? AND user_id = ?", args: [id, userId] }) : await db.execute({ sql: "SELECT * FROM jobs WHERE id = ?", args: [id] });
|
|
2198
|
-
return res.rows[0] ? deserialize(rowToRawJob(res.rows[0])) : void 0;
|
|
2199
|
-
}
|
|
2200
|
-
async function listJobs(userId) {
|
|
2201
|
-
const res = await getDb().execute({ sql: "SELECT * FROM jobs WHERE user_id = ? ORDER BY created_at DESC LIMIT 50", args: [userId] });
|
|
2202
|
-
return res.rows.map((r) => deserialize(rowToRawJob(r)));
|
|
2203
|
-
}
|
|
2204
|
-
async function getPageSnapshot(userId, url) {
|
|
2205
|
-
const res = await getDb().execute({
|
|
2206
|
-
sql: "SELECT content_hash, content, title, content_bytes, truncated, checked_at FROM page_snapshots WHERE user_id = ? AND url = ?",
|
|
2207
|
-
args: [userId, url]
|
|
2208
|
-
});
|
|
2209
|
-
const row = res.rows[0];
|
|
2210
|
-
if (!row) return null;
|
|
2211
|
-
return {
|
|
2212
|
-
contentHash: String(row.content_hash),
|
|
2213
|
-
content: String(row.content),
|
|
2214
|
-
title: row.title == null ? null : String(row.title),
|
|
2215
|
-
contentBytes: Number(row.content_bytes),
|
|
2216
|
-
truncated: Number(row.truncated) === 1,
|
|
2217
|
-
checkedAt: String(row.checked_at)
|
|
2218
|
-
};
|
|
2219
|
-
}
|
|
2220
|
-
async function upsertPageSnapshot(userId, url, snapshot) {
|
|
2221
|
-
await getDb().execute({
|
|
2222
|
-
sql: `
|
|
2223
|
-
INSERT INTO page_snapshots (user_id, url, content_hash, content, title, content_bytes, truncated, checked_at)
|
|
2224
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, datetime('now'))
|
|
2225
|
-
ON CONFLICT(user_id, url) DO UPDATE SET
|
|
2226
|
-
content_hash = excluded.content_hash,
|
|
2227
|
-
content = excluded.content,
|
|
2228
|
-
title = excluded.title,
|
|
2229
|
-
content_bytes = excluded.content_bytes,
|
|
2230
|
-
truncated = excluded.truncated,
|
|
2231
|
-
checked_at = excluded.checked_at
|
|
2232
|
-
`,
|
|
2233
|
-
args: [userId, url, snapshot.contentHash, snapshot.content, snapshot.title, snapshot.contentBytes, snapshot.truncated ? 1 : 0]
|
|
2234
|
-
});
|
|
2235
|
-
}
|
|
2236
|
-
var REQUEST_EVENT_RESULT_MAX_BYTES = 32 * 1024;
|
|
2237
|
-
function serializeRequestEventResult(result) {
|
|
2238
|
-
if (result == null) return null;
|
|
2239
|
-
const serialized = JSON.stringify(result);
|
|
2240
|
-
const bytes = Buffer.byteLength(serialized, "utf8");
|
|
2241
|
-
if (bytes <= REQUEST_EVENT_RESULT_MAX_BYTES) return serialized;
|
|
2242
|
-
return JSON.stringify({
|
|
2243
|
-
truncated: true,
|
|
2244
|
-
originalBytes: bytes,
|
|
2245
|
-
sha256: createHash("sha256").update(serialized).digest("hex")
|
|
2246
|
-
});
|
|
2247
|
-
}
|
|
2248
|
-
function rowToRequestEvent(row) {
|
|
2249
|
-
const rawResult = row.result != null ? String(row.result) : null;
|
|
2250
|
-
return {
|
|
2251
|
-
id: String(row.id),
|
|
2252
|
-
user_id: Number(row.user_id),
|
|
2253
|
-
source: String(row.source),
|
|
2254
|
-
status: String(row.status),
|
|
2255
|
-
query: String(row.query),
|
|
2256
|
-
location: row.location != null ? String(row.location) : null,
|
|
2257
|
-
result_count: row.result_count != null ? Number(row.result_count) : null,
|
|
2258
|
-
result: rawResult ? JSON.parse(rawResult) : null,
|
|
2259
|
-
error: row.error != null ? String(row.error) : null,
|
|
2260
|
-
created_at: String(row.created_at)
|
|
2261
|
-
};
|
|
2262
|
-
}
|
|
2263
|
-
async function logRequestEvent(input) {
|
|
2264
|
-
const id = randomUUID();
|
|
2265
|
-
await getDb().execute({
|
|
2266
|
-
sql: `
|
|
2267
|
-
INSERT INTO request_events (id, user_id, source, status, query, location, result_count, result, error)
|
|
2268
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
2269
|
-
`,
|
|
2270
|
-
args: [
|
|
2271
|
-
id,
|
|
2272
|
-
input.userId,
|
|
2273
|
-
input.source,
|
|
2274
|
-
input.status,
|
|
2275
|
-
input.query,
|
|
2276
|
-
input.location ?? null,
|
|
2277
|
-
input.resultCount ?? null,
|
|
2278
|
-
serializeRequestEventResult(input.result),
|
|
2279
|
-
input.error ?? null
|
|
2280
|
-
]
|
|
2281
|
-
});
|
|
2282
|
-
return id;
|
|
2283
|
-
}
|
|
2284
|
-
async function listRequestEvents(userId, limit = 100) {
|
|
2285
|
-
const res = await getDb().execute({
|
|
2286
|
-
sql: `
|
|
2287
|
-
SELECT id, user_id, source, status, query, location, result_count, NULL AS result, error, created_at
|
|
2288
|
-
FROM request_events
|
|
2289
|
-
WHERE user_id = ?
|
|
2290
|
-
ORDER BY created_at DESC
|
|
2291
|
-
LIMIT ?
|
|
2292
|
-
`,
|
|
2293
|
-
args: [userId, limit]
|
|
2294
|
-
});
|
|
2295
|
-
return res.rows.map((r) => rowToRequestEvent(r));
|
|
2296
|
-
}
|
|
2297
|
-
function parseJsonRecordValue(value) {
|
|
2298
|
-
if (value == null) return {};
|
|
2299
|
-
try {
|
|
2300
|
-
const parsed = JSON.parse(String(value));
|
|
2301
|
-
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
|
|
2302
|
-
} catch {
|
|
2303
|
-
return {};
|
|
2304
|
-
}
|
|
2305
|
-
}
|
|
2306
|
-
function rowToWorkflowSchedule(row) {
|
|
2307
|
-
return {
|
|
2308
|
-
id: String(row.id),
|
|
2309
|
-
user_id: Number(row.user_id),
|
|
2310
|
-
workflow_id: String(row.workflow_id),
|
|
2311
|
-
name: String(row.name),
|
|
2312
|
-
input: parseJsonRecordValue(row.input_json),
|
|
2313
|
-
cadence: String(row.cadence),
|
|
2314
|
-
cron_expr: row.cron_expr != null ? String(row.cron_expr) : null,
|
|
2315
|
-
timezone: String(row.timezone ?? "UTC"),
|
|
2316
|
-
webhook_url: row.webhook_url != null ? String(row.webhook_url) : null,
|
|
2317
|
-
status: String(row.status),
|
|
2318
|
-
next_run_at: row.next_run_at != null ? String(row.next_run_at) : null,
|
|
2319
|
-
last_run_at: row.last_run_at != null ? String(row.last_run_at) : null,
|
|
2320
|
-
created_at: String(row.created_at),
|
|
2321
|
-
updated_at: String(row.updated_at)
|
|
2322
|
-
};
|
|
2323
|
-
}
|
|
2324
|
-
function rowToWorkflowRun(row) {
|
|
2325
|
-
return {
|
|
2326
|
-
id: String(row.id),
|
|
2327
|
-
user_id: Number(row.user_id),
|
|
2328
|
-
schedule_id: row.schedule_id != null ? String(row.schedule_id) : null,
|
|
2329
|
-
workflow_id: String(row.workflow_id),
|
|
2330
|
-
input: parseJsonRecordValue(row.input_json),
|
|
2331
|
-
status: String(row.status),
|
|
2332
|
-
manifest: row.manifest_json != null ? parseNullableJson(row.manifest_json) : null,
|
|
2333
|
-
error_message: row.error_message != null ? String(row.error_message) : null,
|
|
2334
|
-
queued_at: String(row.queued_at),
|
|
2335
|
-
started_at: row.started_at != null ? String(row.started_at) : null,
|
|
2336
|
-
completed_at: row.completed_at != null ? String(row.completed_at) : null,
|
|
2337
|
-
idempotency_key: row.idempotency_key != null ? String(row.idempotency_key) : null,
|
|
2338
|
-
step_index: row.step_index != null ? Number(row.step_index) : 0,
|
|
2339
|
-
total_steps: row.total_steps != null ? Number(row.total_steps) : null,
|
|
2340
|
-
step_log: parseWorkflowStepLog(row.step_log_json),
|
|
2341
|
-
webhook_url: row.webhook_url != null ? String(row.webhook_url) : null
|
|
2342
|
-
};
|
|
2343
|
-
}
|
|
2344
|
-
function parseWorkflowStepLog(value) {
|
|
2345
|
-
if (value == null) return [];
|
|
2346
|
-
try {
|
|
2347
|
-
const parsed = JSON.parse(String(value));
|
|
2348
|
-
return Array.isArray(parsed) ? parsed : [];
|
|
2349
|
-
} catch {
|
|
2350
|
-
return [];
|
|
2351
|
-
}
|
|
2352
|
-
}
|
|
2353
|
-
function rowToWorkflowArtifact(row) {
|
|
2354
|
-
return {
|
|
2355
|
-
id: String(row.id),
|
|
2356
|
-
run_id: String(row.run_id),
|
|
2357
|
-
user_id: Number(row.user_id),
|
|
2358
|
-
kind: String(row.kind),
|
|
2359
|
-
label: String(row.label),
|
|
2360
|
-
path: String(row.path),
|
|
2361
|
-
content_type: String(row.content_type),
|
|
2362
|
-
bytes: row.bytes != null ? Number(row.bytes) : null,
|
|
2363
|
-
rows_count: row.rows_count != null ? Number(row.rows_count) : null,
|
|
2364
|
-
content: row.content != null ? String(row.content) : null,
|
|
2365
|
-
created_at: String(row.created_at)
|
|
2366
|
-
};
|
|
2367
|
-
}
|
|
2368
|
-
async function createWorkflowSchedule(input) {
|
|
2369
|
-
const id = randomUUID();
|
|
2370
|
-
await getDb().execute({
|
|
2371
|
-
sql: `
|
|
2372
|
-
INSERT INTO workflow_schedules (
|
|
2373
|
-
id, user_id, workflow_id, name, input_json, cadence, timezone, webhook_url, next_run_at
|
|
2374
|
-
)
|
|
2375
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
2376
|
-
`,
|
|
2377
|
-
args: [
|
|
2378
|
-
id,
|
|
2379
|
-
input.userId,
|
|
2380
|
-
input.workflowId,
|
|
2381
|
-
input.name,
|
|
2382
|
-
JSON.stringify(input.workflowInput),
|
|
2383
|
-
input.cadence,
|
|
2384
|
-
input.timezone ?? "UTC",
|
|
2385
|
-
input.webhookUrl ?? null,
|
|
2386
|
-
input.nextRunAt ?? null
|
|
2387
|
-
]
|
|
2388
|
-
});
|
|
2389
|
-
const schedule = await getWorkflowSchedule(id, input.userId);
|
|
2390
|
-
if (!schedule) throw new Error("Failed to create workflow schedule");
|
|
2391
|
-
return schedule;
|
|
2392
|
-
}
|
|
2393
|
-
async function getWorkflowSchedule(id, userId) {
|
|
2394
|
-
const res = userId == null ? await getDb().execute({ sql: "SELECT * FROM workflow_schedules WHERE id = ? AND status != ? LIMIT 1", args: [id, "deleted"] }) : await getDb().execute({ sql: "SELECT * FROM workflow_schedules WHERE id = ? AND user_id = ? AND status != ? LIMIT 1", args: [id, userId, "deleted"] });
|
|
2395
|
-
return res.rows[0] ? rowToWorkflowSchedule(res.rows[0]) : null;
|
|
2396
|
-
}
|
|
2397
|
-
async function listWorkflowSchedules(userId) {
|
|
2398
|
-
const res = await getDb().execute({
|
|
2399
|
-
sql: `SELECT * FROM workflow_schedules WHERE user_id = ? AND status != 'deleted' ORDER BY created_at DESC LIMIT 100`,
|
|
2400
|
-
args: [userId]
|
|
2401
|
-
});
|
|
2402
|
-
return res.rows.map((r) => rowToWorkflowSchedule(r));
|
|
2403
|
-
}
|
|
2404
|
-
async function patchWorkflowSchedule(input) {
|
|
2405
|
-
const sets = [];
|
|
2406
|
-
const args = [];
|
|
2407
|
-
if (input.name !== void 0) {
|
|
2408
|
-
sets.push("name = ?");
|
|
2409
|
-
args.push(input.name);
|
|
2410
|
-
}
|
|
2411
|
-
if (input.status !== void 0) {
|
|
2412
|
-
sets.push("status = ?");
|
|
2413
|
-
args.push(input.status);
|
|
2414
|
-
}
|
|
2415
|
-
if (input.workflowInput !== void 0) {
|
|
2416
|
-
sets.push("input_json = ?");
|
|
2417
|
-
args.push(JSON.stringify(input.workflowInput));
|
|
2418
|
-
}
|
|
2419
|
-
if (input.cadence !== void 0) {
|
|
2420
|
-
sets.push("cadence = ?");
|
|
2421
|
-
args.push(input.cadence);
|
|
2422
|
-
}
|
|
2423
|
-
if (input.timezone !== void 0) {
|
|
2424
|
-
sets.push("timezone = ?");
|
|
2425
|
-
args.push(input.timezone);
|
|
2426
|
-
}
|
|
2427
|
-
if (input.webhookUrl !== void 0) {
|
|
2428
|
-
sets.push("webhook_url = ?");
|
|
2429
|
-
args.push(input.webhookUrl);
|
|
2430
|
-
}
|
|
2431
|
-
if (input.nextRunAt !== void 0) {
|
|
2432
|
-
sets.push("next_run_at = ?");
|
|
2433
|
-
args.push(input.nextRunAt);
|
|
2434
|
-
}
|
|
2435
|
-
if (sets.length === 0) return getWorkflowSchedule(input.id, input.userId);
|
|
2436
|
-
sets.push(`updated_at = datetime('now')`);
|
|
2437
|
-
await getDb().execute({
|
|
2438
|
-
sql: `UPDATE workflow_schedules SET ${sets.join(", ")} WHERE id = ? AND user_id = ? AND status != 'deleted'`,
|
|
2439
|
-
args: [...args, input.id, input.userId]
|
|
2440
|
-
});
|
|
2441
|
-
return getWorkflowSchedule(input.id, input.userId);
|
|
2442
|
-
}
|
|
2443
|
-
async function deleteWorkflowSchedule(id, userId) {
|
|
2444
|
-
const res = await getDb().execute({
|
|
2445
|
-
sql: `UPDATE workflow_schedules SET status = 'deleted', updated_at = datetime('now') WHERE id = ? AND user_id = ? AND status != 'deleted'`,
|
|
2446
|
-
args: [id, userId]
|
|
2447
|
-
});
|
|
2448
|
-
return res.rowsAffected > 0;
|
|
2449
|
-
}
|
|
2450
|
-
async function listDueWorkflowSchedules(nowIso, limit = 10) {
|
|
2451
|
-
const res = await getDb().execute({
|
|
2452
|
-
sql: `
|
|
2453
|
-
SELECT * FROM workflow_schedules
|
|
2454
|
-
WHERE status = 'active' AND next_run_at IS NOT NULL AND next_run_at <= ?
|
|
2455
|
-
ORDER BY next_run_at ASC
|
|
2456
|
-
LIMIT ?
|
|
2457
|
-
`,
|
|
2458
|
-
args: [nowIso, limit]
|
|
2459
|
-
});
|
|
2460
|
-
return res.rows.map((r) => rowToWorkflowSchedule(r));
|
|
2461
|
-
}
|
|
2462
|
-
async function markWorkflowScheduleRan(id, lastRunAt, nextRunAt) {
|
|
2463
|
-
await getDb().execute({
|
|
2464
|
-
sql: `UPDATE workflow_schedules SET last_run_at = ?, next_run_at = ?, updated_at = datetime('now') WHERE id = ?`,
|
|
2465
|
-
args: [lastRunAt, nextRunAt, id]
|
|
2466
|
-
});
|
|
2467
|
-
}
|
|
2468
|
-
async function createWorkflowRun(input) {
|
|
2469
|
-
const id = input.id ?? randomUUID();
|
|
2470
|
-
const args = [
|
|
2471
|
-
id,
|
|
2472
|
-
input.userId,
|
|
2473
|
-
input.scheduleId ?? null,
|
|
2474
|
-
input.workflowId,
|
|
2475
|
-
JSON.stringify(input.workflowInput),
|
|
2476
|
-
input.idempotencyKey ?? null,
|
|
2477
|
-
input.webhookUrl ?? null,
|
|
2478
|
-
input.totalSteps ?? null
|
|
2479
|
-
];
|
|
2480
|
-
try {
|
|
2481
|
-
await getDb().execute({
|
|
2482
|
-
sql: `
|
|
2483
|
-
INSERT INTO workflow_runs (id, user_id, schedule_id, workflow_id, input_json, idempotency_key, webhook_url, total_steps)
|
|
2484
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
2485
|
-
`,
|
|
2486
|
-
args
|
|
2487
|
-
});
|
|
2488
|
-
} catch (err) {
|
|
2489
|
-
if (input.idempotencyKey) {
|
|
2490
|
-
const existing = await getWorkflowRunByIdempotencyKey(input.idempotencyKey);
|
|
2491
|
-
if (existing) return existing;
|
|
2492
|
-
}
|
|
2493
|
-
throw err;
|
|
2494
|
-
}
|
|
2495
|
-
const run = await getWorkflowRun(id, input.userId);
|
|
2496
|
-
if (!run) throw new Error("Failed to create workflow run");
|
|
2497
|
-
return run;
|
|
2498
|
-
}
|
|
2499
|
-
async function getWorkflowRun(id, userId) {
|
|
2500
|
-
const res = userId == null ? await getDb().execute({ sql: "SELECT * FROM workflow_runs WHERE id = ? LIMIT 1", args: [id] }) : await getDb().execute({ sql: "SELECT * FROM workflow_runs WHERE id = ? AND user_id = ? LIMIT 1", args: [id, userId] });
|
|
2501
|
-
return res.rows[0] ? rowToWorkflowRun(res.rows[0]) : null;
|
|
2502
|
-
}
|
|
2503
|
-
async function getWorkflowRunByIdempotencyKey(idempotencyKey) {
|
|
2504
|
-
const res = await getDb().execute({ sql: "SELECT * FROM workflow_runs WHERE idempotency_key = ? LIMIT 1", args: [idempotencyKey] });
|
|
2505
|
-
return res.rows[0] ? rowToWorkflowRun(res.rows[0]) : null;
|
|
2506
|
-
}
|
|
2507
|
-
async function listWorkflowRuns(userId, limit = 50) {
|
|
2508
|
-
const res = await getDb().execute({
|
|
2509
|
-
sql: "SELECT * FROM workflow_runs WHERE user_id = ? ORDER BY queued_at DESC LIMIT ?",
|
|
2510
|
-
args: [userId, limit]
|
|
2511
|
-
});
|
|
2512
|
-
return res.rows.map((r) => rowToWorkflowRun(r));
|
|
2513
|
-
}
|
|
2514
|
-
async function markWorkflowRunRunning(id) {
|
|
2515
|
-
await getDb().execute({
|
|
2516
|
-
sql: `UPDATE workflow_runs SET status = 'running', started_at = COALESCE(started_at, datetime('now')) WHERE id = ?`,
|
|
2517
|
-
args: [id]
|
|
2518
|
-
});
|
|
2519
|
-
}
|
|
2520
|
-
async function completeWorkflowRunRecord(id, status, manifest) {
|
|
2521
|
-
await getDb().execute({
|
|
2522
|
-
sql: `UPDATE workflow_runs SET status = ?, manifest_json = ?, completed_at = datetime('now') WHERE id = ?`,
|
|
2523
|
-
args: [status, JSON.stringify(manifest), id]
|
|
2524
|
-
});
|
|
2525
|
-
}
|
|
2526
|
-
async function failWorkflowRunRecord(id, errorMessage, manifest) {
|
|
2527
|
-
await getDb().execute({
|
|
2528
|
-
sql: `UPDATE workflow_runs SET status = 'failed', error_message = ?, manifest_json = ?, completed_at = datetime('now') WHERE id = ?`,
|
|
2529
|
-
args: [errorMessage, manifest == null ? null : JSON.stringify(manifest), id]
|
|
2530
|
-
});
|
|
2531
|
-
}
|
|
2532
|
-
function workflowArtifactContentType(kind) {
|
|
2533
|
-
if (kind === "html_report") return "text/html; charset=utf-8";
|
|
2534
|
-
if (kind === "csv") return "text/csv; charset=utf-8";
|
|
2535
|
-
if (kind === "json") return "application/json; charset=utf-8";
|
|
2536
|
-
return "text/plain; charset=utf-8";
|
|
2537
|
-
}
|
|
2538
|
-
async function insertWorkflowArtifact(runId, userId, artifact) {
|
|
2539
|
-
await getDb().execute({
|
|
2540
|
-
sql: `
|
|
2541
|
-
INSERT INTO workflow_artifacts (id, run_id, user_id, kind, label, path, content_type, bytes, rows_count, content)
|
|
2542
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
2543
|
-
`,
|
|
2544
|
-
args: [
|
|
2545
|
-
randomUUID(),
|
|
2546
|
-
runId,
|
|
2547
|
-
userId,
|
|
2548
|
-
artifact.kind,
|
|
2549
|
-
artifact.label,
|
|
2550
|
-
artifact.path,
|
|
2551
|
-
workflowArtifactContentType(artifact.kind),
|
|
2552
|
-
artifact.bytes ?? null,
|
|
2553
|
-
artifact.rows ?? null,
|
|
2554
|
-
artifact.content ?? null
|
|
2555
|
-
]
|
|
2556
|
-
});
|
|
2557
|
-
}
|
|
2558
|
-
async function replaceWorkflowArtifacts(runId, userId, artifacts) {
|
|
2559
|
-
await getDb().execute({ sql: "DELETE FROM workflow_artifacts WHERE run_id = ? AND user_id = ?", args: [runId, userId] });
|
|
2560
|
-
for (const artifact of artifacts) await insertWorkflowArtifact(runId, userId, artifact);
|
|
2561
|
-
return listWorkflowArtifacts(runId, userId);
|
|
2562
|
-
}
|
|
2563
|
-
async function appendWorkflowArtifacts(runId, userId, artifacts) {
|
|
2564
|
-
for (const artifact of artifacts) await insertWorkflowArtifact(runId, userId, artifact);
|
|
2565
|
-
return listWorkflowArtifacts(runId, userId);
|
|
2566
|
-
}
|
|
2567
|
-
async function getWorkflowRunStepState(id, userId) {
|
|
2568
|
-
const res = userId == null ? await getDb().execute({ sql: "SELECT step_index, total_steps, step_state_json, step_log_json FROM workflow_runs WHERE id = ? LIMIT 1", args: [id] }) : await getDb().execute({ sql: "SELECT step_index, total_steps, step_state_json, step_log_json FROM workflow_runs WHERE id = ? AND user_id = ? LIMIT 1", args: [id, userId] });
|
|
2569
|
-
const row = res.rows[0];
|
|
2570
|
-
if (!row) return null;
|
|
2571
|
-
return {
|
|
2572
|
-
stepIndex: row.step_index != null ? Number(row.step_index) : 0,
|
|
2573
|
-
totalSteps: row.total_steps != null ? Number(row.total_steps) : null,
|
|
2574
|
-
state: row.step_state_json != null ? parseNullableJson(row.step_state_json) : null,
|
|
2575
|
-
stepLog: parseWorkflowStepLog(row.step_log_json)
|
|
2576
|
-
};
|
|
2577
|
-
}
|
|
2578
|
-
async function advanceWorkflowRunStep(id, input) {
|
|
2579
|
-
const stepLog = [...input.appendLog, input.logEntry];
|
|
2580
|
-
await getDb().execute({
|
|
2581
|
-
sql: `UPDATE workflow_runs SET status = ?, step_index = ?, total_steps = ?, step_state_json = ?, step_log_json = ?, started_at = COALESCE(started_at, datetime('now')) WHERE id = ?`,
|
|
2582
|
-
args: [input.status, input.stepIndex, input.totalSteps, JSON.stringify(input.state ?? null), JSON.stringify(stepLog), id]
|
|
2583
|
-
});
|
|
2584
|
-
}
|
|
2585
|
-
async function listWorkflowArtifacts(runId, userId) {
|
|
2586
|
-
const res = await getDb().execute({
|
|
2587
|
-
sql: "SELECT * FROM workflow_artifacts WHERE run_id = ? AND user_id = ? ORDER BY created_at ASC",
|
|
2588
|
-
args: [runId, userId]
|
|
2589
|
-
});
|
|
2590
|
-
return res.rows.map((r) => rowToWorkflowArtifact(r));
|
|
2591
|
-
}
|
|
2592
|
-
async function getWorkflowArtifact(id, userId) {
|
|
2593
|
-
const res = await getDb().execute({
|
|
2594
|
-
sql: "SELECT * FROM workflow_artifacts WHERE id = ? AND user_id = ? LIMIT 1",
|
|
2595
|
-
args: [id, userId]
|
|
2596
|
-
});
|
|
2597
|
-
return res.rows[0] ? rowToWorkflowArtifact(res.rows[0]) : null;
|
|
2598
|
-
}
|
|
2599
|
-
async function recordWorkflowWebhookDelivery(input) {
|
|
2600
|
-
await getDb().execute({
|
|
2601
|
-
sql: `
|
|
2602
|
-
INSERT INTO workflow_webhook_deliveries (
|
|
2603
|
-
id, run_id, schedule_id, user_id, webhook_url, status, attempts, last_error, delivered_at
|
|
2604
|
-
)
|
|
2605
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
2606
|
-
`,
|
|
2607
|
-
args: [
|
|
2608
|
-
randomUUID(),
|
|
2609
|
-
input.runId,
|
|
2610
|
-
input.scheduleId ?? null,
|
|
2611
|
-
input.userId,
|
|
2612
|
-
input.webhookUrl,
|
|
2613
|
-
input.status,
|
|
2614
|
-
input.attempts,
|
|
2615
|
-
input.lastError ?? null,
|
|
2616
|
-
input.status === "delivered" ? (/* @__PURE__ */ new Date()).toISOString() : null
|
|
2617
|
-
]
|
|
2618
|
-
});
|
|
2619
|
-
}
|
|
2620
|
-
async function countActiveUsers() {
|
|
2621
|
-
const res = await getDb().execute(`SELECT COUNT(*) as n FROM users WHERE active = 1`);
|
|
2622
|
-
return Number(res.rows[0]?.n ?? 0);
|
|
2623
|
-
}
|
|
2624
|
-
async function countActiveJobsForUser(userId) {
|
|
2625
|
-
const res = await getDb().execute({
|
|
2626
|
-
sql: `SELECT COUNT(*) as n FROM jobs WHERE user_id = ? AND (status = 'pending' OR (status = 'running' AND started_at > datetime('now', '-10 minutes')))`,
|
|
2627
|
-
args: [userId]
|
|
2628
|
-
});
|
|
2629
|
-
return Number(res.rows[0]?.n ?? 0);
|
|
2630
|
-
}
|
|
2631
|
-
async function claimPendingJob() {
|
|
2632
|
-
const db = getDb();
|
|
2633
|
-
const res = await db.execute(`
|
|
2634
|
-
SELECT * FROM jobs
|
|
2635
|
-
WHERE status = 'pending'
|
|
2636
|
-
AND COALESCE(json_extract(options, '$.executionOwner'), 'cron') NOT IN ('direct', 'inngest')
|
|
2637
|
-
ORDER BY created_at
|
|
2638
|
-
LIMIT 1
|
|
2639
|
-
`);
|
|
2640
|
-
const job = res.rows[0] ? rowToRawJob(res.rows[0]) : void 0;
|
|
2641
|
-
if (!job) return void 0;
|
|
2642
|
-
const upd = await db.execute({
|
|
2643
|
-
sql: `UPDATE jobs SET status = 'running', started_at = datetime('now') WHERE id = ? AND status = 'pending'`,
|
|
2644
|
-
args: [job.id]
|
|
2645
|
-
});
|
|
2646
|
-
return upd.rowsAffected === 0 ? void 0 : { ...job, status: "running" };
|
|
2647
|
-
}
|
|
2648
|
-
var DURABLE_PAA_LEASE_MS = 45e3;
|
|
2649
|
-
async function claimDurablePaaJob(id) {
|
|
2650
|
-
const db = getDb();
|
|
2651
|
-
const current = await getJob(id);
|
|
2652
|
-
if (!current || current.options.executionOwner !== "direct" || current.options.serpOnly === true) return void 0;
|
|
2653
|
-
const recoveryCount = Math.max(0, Number(current.options.paaRecoveryCount ?? 0));
|
|
2654
|
-
if (current.status === "running") return void 0;
|
|
2655
|
-
if (current.status === "pending") {
|
|
2656
|
-
const leaseToken = randomUUID();
|
|
2657
|
-
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
2658
|
-
const leaseExpiresAt = new Date(Date.now() + DURABLE_PAA_LEASE_MS).toISOString();
|
|
2659
|
-
const updated = await db.execute({
|
|
2660
|
-
sql: `UPDATE jobs
|
|
2661
|
-
SET status = 'running', started_at = datetime('now'), error = NULL,
|
|
2662
|
-
options = json_set(options,
|
|
2663
|
-
'$.paaLeaseToken', ?,
|
|
2664
|
-
'$.paaLeaseExpiresAt', ?,
|
|
2665
|
-
'$.paaLastDispatchAt', ?)
|
|
2666
|
-
WHERE id = ? AND status = 'pending'`,
|
|
2667
|
-
args: [leaseToken, leaseExpiresAt, now, id]
|
|
2668
|
-
});
|
|
2669
|
-
if (updated.rowsAffected === 0) return void 0;
|
|
2670
|
-
return {
|
|
2671
|
-
job: {
|
|
2672
|
-
...current,
|
|
2673
|
-
status: "running",
|
|
2674
|
-
started_at: now,
|
|
2675
|
-
options: { ...current.options, paaLeaseToken: leaseToken, paaLeaseExpiresAt: leaseExpiresAt, paaLastDispatchAt: now }
|
|
2676
|
-
},
|
|
2677
|
-
leaseToken,
|
|
2678
|
-
recoveryCount
|
|
2679
|
-
};
|
|
2680
|
-
}
|
|
2681
|
-
return { job: current, leaseToken: null, recoveryCount };
|
|
2682
|
-
}
|
|
2683
|
-
async function checkpointJob(id, result) {
|
|
2684
|
-
const updated = await getDb().execute({
|
|
2685
|
-
sql: `UPDATE jobs SET result = ? WHERE id = ? AND status = 'running'`,
|
|
2686
|
-
args: [JSON.stringify(result), id]
|
|
2687
|
-
});
|
|
2688
|
-
return updated.rowsAffected === 1;
|
|
2689
|
-
}
|
|
2690
|
-
async function checkpointDurablePaaJob(id, leaseToken, result) {
|
|
2691
|
-
const leaseExpiresAt = new Date(Date.now() + DURABLE_PAA_LEASE_MS).toISOString();
|
|
2692
|
-
const updated = await getDb().execute({
|
|
2693
|
-
sql: `UPDATE jobs SET result = ?, options = json_set(options, '$.paaLeaseExpiresAt', ?)
|
|
2694
|
-
WHERE id = ? AND status = 'running' AND json_extract(options, '$.paaLeaseToken') = ?`,
|
|
2695
|
-
args: [JSON.stringify(result), leaseExpiresAt, id, leaseToken]
|
|
2696
|
-
});
|
|
2697
|
-
return updated.rowsAffected === 1;
|
|
2698
|
-
}
|
|
2699
|
-
async function renewDurablePaaLease(id, leaseToken) {
|
|
2700
|
-
const leaseExpiresAt = new Date(Date.now() + DURABLE_PAA_LEASE_MS).toISOString();
|
|
2701
|
-
const updated = await getDb().execute({
|
|
2702
|
-
sql: `UPDATE jobs SET options = json_set(options, '$.paaLeaseExpiresAt', ?)
|
|
2703
|
-
WHERE id = ? AND status = 'running' AND json_extract(options, '$.paaLeaseToken') = ?`,
|
|
2704
|
-
args: [leaseExpiresAt, id, leaseToken]
|
|
2705
|
-
});
|
|
2706
|
-
return updated.rowsAffected === 1;
|
|
2707
|
-
}
|
|
2708
|
-
async function completeDurablePaaJob(id, leaseToken, result) {
|
|
2709
|
-
const updated = await getDb().execute({
|
|
2710
|
-
sql: `UPDATE jobs SET status = 'done', result = ?, error = NULL, public_error_json = NULL,
|
|
2711
|
-
completed_at = datetime('now'),
|
|
2712
|
-
options = json_set(options, '$.paaLeaseExpiresAt', NULL)
|
|
2713
|
-
WHERE id = ? AND status = 'running' AND json_extract(options, '$.paaLeaseToken') = ?`,
|
|
2714
|
-
args: [JSON.stringify(result), id, leaseToken]
|
|
2715
|
-
});
|
|
2716
|
-
return updated.rowsAffected === 1;
|
|
2717
|
-
}
|
|
2718
|
-
async function failDurablePaaJob(id, leaseToken, error, publicError) {
|
|
2719
|
-
const updated = await getDb().execute({
|
|
2720
|
-
sql: `UPDATE jobs SET status = 'failed', error = ?, public_error_json = ?, completed_at = datetime('now'),
|
|
2721
|
-
options = json_set(options, '$.paaLeaseExpiresAt', NULL)
|
|
2722
|
-
WHERE id = ? AND status = 'running' AND json_extract(options, '$.paaLeaseToken') = ?`,
|
|
2723
|
-
args: [error, serializePublicErrorEnvelope(publicError), id, leaseToken]
|
|
2724
|
-
});
|
|
2725
|
-
return updated.rowsAffected === 1;
|
|
2726
|
-
}
|
|
2727
|
-
async function requeueExpiredDurablePaaJob(id, maxRecoveries) {
|
|
2728
|
-
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
2729
|
-
const updated = await getDb().execute({
|
|
2730
|
-
sql: `UPDATE jobs SET status = 'pending', started_at = NULL,
|
|
2731
|
-
result = CASE WHEN json_valid(result) THEN json_set(result,
|
|
2732
|
-
'$.progress.lifecycle.discoveryStatus', 'interrupted',
|
|
2733
|
-
'$.progress.lifecycle.completionReason', 'The previous worker lease expired; the same job is recovering automatically.',
|
|
2734
|
-
'$.progress.lifecycle.automaticRetries', COALESCE(json_extract(options, '$.paaRecoveryCount'), 0) + 1)
|
|
2735
|
-
ELSE result END,
|
|
2736
|
-
options = json_set(options,
|
|
2737
|
-
'$.paaRecoveryCount', COALESCE(json_extract(options, '$.paaRecoveryCount'), 0) + 1,
|
|
2738
|
-
'$.paaLeaseToken', NULL,
|
|
2739
|
-
'$.paaLeaseExpiresAt', NULL,
|
|
2740
|
-
'$.paaRecoveryQueuedAt', ?)
|
|
2741
|
-
WHERE id = ? AND status = 'running'
|
|
2742
|
-
AND (
|
|
2743
|
-
datetime(json_extract(options, '$.paaLeaseExpiresAt')) <= datetime('now')
|
|
2744
|
-
OR (json_extract(options, '$.paaLeaseExpiresAt') IS NULL AND started_at <= datetime('now', '-15 minutes'))
|
|
2745
|
-
)
|
|
2746
|
-
AND COALESCE(json_extract(options, '$.paaRecoveryCount'), 0) < ?`,
|
|
2747
|
-
args: [now, id, Math.max(0, Math.trunc(maxRecoveries))]
|
|
2748
|
-
});
|
|
2749
|
-
return updated.rowsAffected === 1;
|
|
2750
|
-
}
|
|
2751
|
-
async function completeExpiredDurablePaaJob(id, result) {
|
|
2752
|
-
const updated = await getDb().execute({
|
|
2753
|
-
sql: `UPDATE jobs SET status = 'done', result = ?, error = NULL, public_error_json = NULL, completed_at = datetime('now')
|
|
2754
|
-
WHERE id = ? AND status = 'running'
|
|
2755
|
-
AND (
|
|
2756
|
-
datetime(json_extract(options, '$.paaLeaseExpiresAt')) <= datetime('now')
|
|
2757
|
-
OR (json_extract(options, '$.paaLeaseExpiresAt') IS NULL AND started_at <= datetime('now', '-15 minutes'))
|
|
2758
|
-
)`,
|
|
2759
|
-
args: [JSON.stringify(result), id]
|
|
2760
|
-
});
|
|
2761
|
-
return updated.rowsAffected === 1;
|
|
2762
|
-
}
|
|
2763
|
-
async function failExpiredDurablePaaJob(id, error, publicError) {
|
|
2764
|
-
const updated = await getDb().execute({
|
|
2765
|
-
sql: `UPDATE jobs SET status = 'failed', error = ?, public_error_json = ?, completed_at = datetime('now')
|
|
2766
|
-
WHERE id = ? AND status = 'running'
|
|
2767
|
-
AND (
|
|
2768
|
-
datetime(json_extract(options, '$.paaLeaseExpiresAt')) <= datetime('now')
|
|
2769
|
-
OR (json_extract(options, '$.paaLeaseExpiresAt') IS NULL AND started_at <= datetime('now', '-15 minutes'))
|
|
2770
|
-
)`,
|
|
2771
|
-
args: [error, serializePublicErrorEnvelope(publicError), id]
|
|
2772
|
-
});
|
|
2773
|
-
return updated.rowsAffected === 1;
|
|
2774
|
-
}
|
|
2775
|
-
async function listRecentTerminalDurablePaaJobs(limit = 25) {
|
|
2776
|
-
const safeLimit = Math.max(1, Math.min(100, Math.floor(limit)));
|
|
2777
|
-
const rows = await getDb().execute({
|
|
2778
|
-
sql: `SELECT * FROM jobs
|
|
2779
|
-
WHERE status IN ('done', 'failed', 'cancelled')
|
|
2780
|
-
AND json_extract(options, '$.executionOwner') IN ('direct', 'inngest')
|
|
2781
|
-
AND json_extract(options, '$.billingDebitKey') IS NOT NULL
|
|
2782
|
-
AND json_extract(options, '$.billingSettledAt') IS NULL
|
|
2783
|
-
ORDER BY completed_at DESC
|
|
2784
|
-
LIMIT ?`,
|
|
2785
|
-
args: [safeLimit]
|
|
2786
|
-
});
|
|
2787
|
-
return rows.rows.map((row) => deserialize(rowToRawJob(row)));
|
|
2788
|
-
}
|
|
2789
|
-
async function listStaleRunningDurablePaaJobs(limit = 25) {
|
|
2790
|
-
const safeLimit = Math.max(1, Math.min(100, Math.floor(limit)));
|
|
2791
|
-
const rows = await getDb().execute({
|
|
2792
|
-
sql: `SELECT * FROM jobs
|
|
2793
|
-
WHERE status = 'running'
|
|
2794
|
-
AND started_at <= datetime('now', '-15 minutes')
|
|
2795
|
-
AND json_extract(options, '$.executionOwner') IN ('direct', 'inngest')
|
|
2796
|
-
ORDER BY started_at
|
|
2797
|
-
LIMIT ?`,
|
|
2798
|
-
args: [safeLimit]
|
|
2799
|
-
});
|
|
2800
|
-
return rows.rows.map((row) => deserialize(rowToRawJob(row)));
|
|
2801
|
-
}
|
|
2802
|
-
async function listRecoverableDurablePaaJobs(limit = 25) {
|
|
2803
|
-
const safeLimit = Math.max(1, Math.min(100, Math.floor(limit)));
|
|
2804
|
-
const rows = await getDb().execute({
|
|
2805
|
-
sql: `SELECT * FROM jobs
|
|
2806
|
-
WHERE json_extract(options, '$.executionOwner') = 'direct'
|
|
2807
|
-
AND json_extract(options, '$.serpOnly') IS NOT 1
|
|
2808
|
-
AND (
|
|
2809
|
-
(status = 'running' AND (
|
|
2810
|
-
datetime(json_extract(options, '$.paaLeaseExpiresAt')) <= datetime('now')
|
|
2811
|
-
OR (json_extract(options, '$.paaLeaseExpiresAt') IS NULL AND started_at <= datetime('now', '-15 minutes'))
|
|
2812
|
-
))
|
|
2813
|
-
OR
|
|
2814
|
-
(status = 'pending' AND COALESCE(json_extract(options, '$.paaRecoveryCount'), 0) > 0
|
|
2815
|
-
AND datetime(COALESCE(json_extract(options, '$.paaLastDispatchAt'), '1970-01-01')) <= datetime('now', '-30 seconds'))
|
|
2816
|
-
)
|
|
2817
|
-
ORDER BY COALESCE(started_at, created_at)
|
|
2818
|
-
LIMIT ?`,
|
|
2819
|
-
args: [safeLimit]
|
|
2820
|
-
});
|
|
2821
|
-
return rows.rows.map((row) => deserialize(rowToRawJob(row)));
|
|
2822
|
-
}
|
|
2823
|
-
async function markJobBillingSettled(id, billedMc, settledAt = (/* @__PURE__ */ new Date()).toISOString()) {
|
|
2824
|
-
if (!Number.isSafeInteger(billedMc) || billedMc < 0) throw new Error("job billed amount must be a non-negative integer");
|
|
2825
|
-
const updated = await getDb().execute({
|
|
2826
|
-
sql: `UPDATE jobs
|
|
2827
|
-
SET options = json_set(options, '$.billingSettledAt', ?, '$.billedMc', ?)
|
|
2828
|
-
WHERE id = ?`,
|
|
2829
|
-
args: [settledAt, billedMc, id]
|
|
2830
|
-
});
|
|
2831
|
-
return updated.rowsAffected === 1;
|
|
2832
|
-
}
|
|
2833
|
-
async function completeJob(id, result) {
|
|
2834
|
-
await getDb().execute({
|
|
2835
|
-
sql: `UPDATE jobs SET status = 'done', result = ?, error = NULL, public_error_json = NULL, completed_at = datetime('now') WHERE id = ?`,
|
|
2836
|
-
args: [JSON.stringify(result), id]
|
|
2837
|
-
});
|
|
2838
|
-
}
|
|
2839
|
-
async function completeRunningJob(id, result) {
|
|
2840
|
-
const updated = await getDb().execute({
|
|
2841
|
-
sql: `UPDATE jobs SET status = 'done', result = ?, error = NULL, public_error_json = NULL, completed_at = datetime('now')
|
|
2842
|
-
WHERE id = ? AND status = 'running'`,
|
|
2843
|
-
args: [JSON.stringify(result), id]
|
|
2844
|
-
});
|
|
2845
|
-
return updated.rowsAffected === 1;
|
|
2846
|
-
}
|
|
2847
|
-
async function failJob(id, error, publicError) {
|
|
2848
|
-
await getDb().execute({
|
|
2849
|
-
sql: `UPDATE jobs SET status = 'failed', error = ?, public_error_json = ?, completed_at = datetime('now') WHERE id = ?`,
|
|
2850
|
-
args: [error, serializePublicErrorEnvelope(publicError), id]
|
|
2851
|
-
});
|
|
2852
|
-
}
|
|
2853
|
-
async function failRunningJob(id, error, publicError) {
|
|
2854
|
-
const updated = await getDb().execute({
|
|
2855
|
-
sql: `UPDATE jobs SET status = 'failed', error = ?, public_error_json = ?, completed_at = datetime('now')
|
|
2856
|
-
WHERE id = ? AND status = 'running'`,
|
|
2857
|
-
args: [error, serializePublicErrorEnvelope(publicError), id]
|
|
2858
|
-
});
|
|
2859
|
-
return updated.rowsAffected === 1;
|
|
2860
|
-
}
|
|
2861
|
-
async function cancelJob(id, reason, publicError) {
|
|
2862
|
-
await getDb().execute({
|
|
2863
|
-
sql: `UPDATE jobs SET status = 'cancelled', error = ?, public_error_json = ?, completed_at = datetime('now') WHERE id = ?`,
|
|
2864
|
-
args: [reason, serializePublicErrorEnvelope(publicError), id]
|
|
2865
|
-
});
|
|
2866
|
-
}
|
|
2867
|
-
function rowToKpoJob(row) {
|
|
2868
|
-
return {
|
|
2869
|
-
id: String(row.id),
|
|
2870
|
-
user_id: Number(row.user_id),
|
|
2871
|
-
status: String(row.status),
|
|
2872
|
-
entity_name: String(row.entity_name),
|
|
2873
|
-
entity_type: String(row.entity_type),
|
|
2874
|
-
url: row.url != null ? String(row.url) : null,
|
|
2875
|
-
request: String(row.request),
|
|
2876
|
-
result: row.result != null ? String(row.result) : null,
|
|
2877
|
-
error: row.error != null ? String(row.error) : null,
|
|
2878
|
-
created_at: String(row.created_at),
|
|
2879
|
-
updated_at: String(row.updated_at)
|
|
2880
|
-
};
|
|
2881
|
-
}
|
|
2882
|
-
function rowToKpoPhaseLog(row) {
|
|
2883
|
-
return {
|
|
2884
|
-
id: Number(row.id),
|
|
2885
|
-
job_id: String(row.job_id),
|
|
2886
|
-
phase: String(row.phase),
|
|
2887
|
-
output: String(row.output),
|
|
2888
|
-
created_at: String(row.created_at)
|
|
2889
|
-
};
|
|
2890
|
-
}
|
|
2891
|
-
async function createKpoJob(jobId, userId, entityName, entityType, request, url) {
|
|
2892
|
-
await getDb().execute({
|
|
2893
|
-
sql: `INSERT INTO kpo_jobs (id, user_id, status, entity_name, entity_type, url, request) VALUES (?, ?, 'pending', ?, ?, ?, ?)`,
|
|
2894
|
-
args: [jobId, userId, entityName, entityType, url ?? null, JSON.stringify(request)]
|
|
2895
|
-
});
|
|
2896
|
-
}
|
|
2897
|
-
async function claimPendingKpoJob() {
|
|
2898
|
-
const db = getDb();
|
|
2899
|
-
const upd = await db.execute(
|
|
2900
|
-
`UPDATE kpo_jobs SET status='running', updated_at=datetime('now') WHERE id = (SELECT id FROM kpo_jobs WHERE status='pending' ORDER BY created_at ASC LIMIT 1) RETURNING *`
|
|
2901
|
-
);
|
|
2902
|
-
if (upd.rowsAffected === 0) return null;
|
|
2903
|
-
return upd.rows[0] ? rowToKpoJob(upd.rows[0]) : null;
|
|
2904
|
-
}
|
|
2905
|
-
async function getKpoJob(jobId) {
|
|
2906
|
-
const res = await getDb().execute({ sql: `SELECT * FROM kpo_jobs WHERE id = ?`, args: [jobId] });
|
|
2907
|
-
return res.rows[0] ? rowToKpoJob(res.rows[0]) : null;
|
|
2908
|
-
}
|
|
2909
|
-
async function updateKpoJobState(jobId, status) {
|
|
2910
|
-
await getDb().execute({ sql: `UPDATE kpo_jobs SET status = ?, updated_at = datetime('now') WHERE id = ?`, args: [status, jobId] });
|
|
2911
|
-
}
|
|
2912
|
-
async function completeKpoJob(jobId, result) {
|
|
2913
|
-
await getDb().execute({ sql: `UPDATE kpo_jobs SET status = 'done', result = ?, updated_at = datetime('now') WHERE id = ?`, args: [JSON.stringify(result), jobId] });
|
|
2914
|
-
}
|
|
2915
|
-
async function failKpoJob(jobId, error) {
|
|
2916
|
-
await getDb().execute({ sql: `UPDATE kpo_jobs SET status = 'failed', error = ?, updated_at = datetime('now') WHERE id = ?`, args: [error, jobId] });
|
|
2917
|
-
}
|
|
2918
|
-
async function logKpoPhaseComplete(jobId, phase, output) {
|
|
2919
|
-
await getDb().execute({
|
|
2920
|
-
sql: `INSERT INTO kpo_phase_log (job_id, phase, output) VALUES (?, ?, ?)`,
|
|
2921
|
-
args: [jobId, phase, JSON.stringify(output)]
|
|
2922
|
-
});
|
|
2923
|
-
}
|
|
2924
|
-
async function getKpoPhaseLog(jobId) {
|
|
2925
|
-
const res = await getDb().execute({ sql: `SELECT * FROM kpo_phase_log WHERE job_id = ? ORDER BY created_at ASC`, args: [jobId] });
|
|
2926
|
-
return res.rows.map((r) => rowToKpoPhaseLog(r));
|
|
2927
|
-
}
|
|
2928
|
-
async function listKpoJobs(userId) {
|
|
2929
|
-
const res = userId != null ? await getDb().execute({ sql: `SELECT * FROM kpo_jobs WHERE user_id = ? ORDER BY created_at DESC LIMIT 50`, args: [userId] }) : await getDb().execute(`SELECT * FROM kpo_jobs ORDER BY created_at DESC LIMIT 50`);
|
|
2930
|
-
return res.rows.map((r) => rowToKpoJob(r));
|
|
2931
|
-
}
|
|
2932
|
-
async function getUserByStripeCustomerId(customerId) {
|
|
2933
|
-
const res = await getDb().execute({
|
|
2934
|
-
sql: "SELECT * FROM users WHERE stripe_customer_id = ? AND active = 1",
|
|
2935
|
-
args: [customerId]
|
|
2936
|
-
});
|
|
2937
|
-
return res.rows[0] ? rowToUser(res.rows[0]) : void 0;
|
|
2938
|
-
}
|
|
2939
|
-
async function setStripeCustomerId(userId, customerId) {
|
|
2940
|
-
await getDb().execute({
|
|
2941
|
-
sql: "UPDATE users SET stripe_customer_id = ? WHERE id = ?",
|
|
2942
|
-
args: [customerId, userId]
|
|
2943
|
-
});
|
|
2944
|
-
}
|
|
2945
|
-
async function setExtraConcurrencySlots(userId, slots) {
|
|
2946
|
-
if (!Number.isSafeInteger(slots) || slots < 0) {
|
|
2947
|
-
throw new Error("extra concurrency slots must be a non-negative safe integer");
|
|
2948
|
-
}
|
|
2949
|
-
await setConcurrencyPackQuantity(userId, Math.ceil(slots / 2));
|
|
2950
|
-
}
|
|
2951
|
-
async function setConcurrencyPackQuantity(userId, quantity) {
|
|
2952
|
-
if (!Number.isSafeInteger(quantity) || quantity < 0) {
|
|
2953
|
-
throw new Error("concurrency pack quantity must be a non-negative safe integer");
|
|
2954
|
-
}
|
|
2955
|
-
await getDb().execute({
|
|
2956
|
-
sql: "UPDATE users SET concurrency_pack_quantity = ?, extra_concurrency_slots = ? WHERE id = ?",
|
|
2957
|
-
args: [quantity, quantity * 2, userId]
|
|
2958
|
-
});
|
|
2959
|
-
}
|
|
2960
|
-
async function setConcurrencySubId(userId, subId) {
|
|
2961
|
-
await getDb().execute({
|
|
2962
|
-
sql: "UPDATE users SET concurrency_stripe_sub_id = ? WHERE id = ?",
|
|
2963
|
-
args: [subId, userId]
|
|
2964
|
-
});
|
|
2965
|
-
}
|
|
2966
|
-
async function setSubscriptionTier(userId, tier, concurrency, subId) {
|
|
2967
|
-
await getDb().execute({
|
|
2968
|
-
sql: "UPDATE users SET subscription_tier = ?, subscription_concurrency = ?, subscription_id = ? WHERE id = ?",
|
|
2969
|
-
args: [tier, Math.max(0, Math.round(concurrency)), subId, userId]
|
|
2970
|
-
});
|
|
2971
|
-
}
|
|
2972
|
-
function rowToConnectedAccountBillingState(row) {
|
|
2973
|
-
return {
|
|
2974
|
-
user_id: Number(row.user_id),
|
|
2975
|
-
stripe_subscription_id: row.stripe_subscription_id != null ? String(row.stripe_subscription_id) : null,
|
|
2976
|
-
stripe_subscription_item_id: row.stripe_subscription_item_id != null ? String(row.stripe_subscription_item_id) : null,
|
|
2977
|
-
price_id: row.price_id != null ? String(row.price_id) : null,
|
|
2978
|
-
quantity: Math.max(0, Number(row.quantity ?? 0)),
|
|
2979
|
-
status: ["pending", "synced", "blocked", "error"].includes(String(row.status)) ? String(row.status) : "error",
|
|
2980
|
-
last_error_code: row.last_error_code != null ? String(row.last_error_code) : null,
|
|
2981
|
-
checked_at: String(row.checked_at),
|
|
2982
|
-
synced_at: row.synced_at != null ? String(row.synced_at) : null,
|
|
2983
|
-
updated_at: String(row.updated_at)
|
|
2984
|
-
};
|
|
2985
|
-
}
|
|
2986
|
-
async function getConnectedAccountBillingState(userId) {
|
|
2987
|
-
const res = await getDb().execute({
|
|
2988
|
-
sql: "SELECT * FROM connected_account_billing WHERE user_id = ? LIMIT 1",
|
|
2989
|
-
args: [userId]
|
|
2990
|
-
});
|
|
2991
|
-
const row = res.rows[0];
|
|
2992
|
-
return row ? rowToConnectedAccountBillingState(row) : null;
|
|
2993
|
-
}
|
|
2994
|
-
async function setConnectedAccountBillingState(input) {
|
|
2995
|
-
const quantity = Math.max(0, Math.round(input.quantity));
|
|
2996
|
-
await getDb().execute({
|
|
2997
|
-
sql: `INSERT INTO connected_account_billing
|
|
2998
|
-
(user_id, stripe_subscription_id, stripe_subscription_item_id, price_id, quantity, status, last_error_code, checked_at, synced_at, updated_at)
|
|
2999
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, datetime('now'), CASE WHEN ? THEN datetime('now') ELSE NULL END, datetime('now'))
|
|
3000
|
-
ON CONFLICT(user_id) DO UPDATE SET
|
|
3001
|
-
stripe_subscription_id = excluded.stripe_subscription_id,
|
|
3002
|
-
stripe_subscription_item_id = excluded.stripe_subscription_item_id,
|
|
3003
|
-
price_id = excluded.price_id,
|
|
3004
|
-
quantity = excluded.quantity,
|
|
3005
|
-
status = excluded.status,
|
|
3006
|
-
last_error_code = excluded.last_error_code,
|
|
3007
|
-
checked_at = datetime('now'),
|
|
3008
|
-
synced_at = CASE WHEN ? THEN datetime('now') ELSE connected_account_billing.synced_at END,
|
|
3009
|
-
updated_at = datetime('now')`,
|
|
3010
|
-
args: [
|
|
3011
|
-
input.userId,
|
|
3012
|
-
input.stripeSubscriptionId,
|
|
3013
|
-
input.stripeSubscriptionItemId,
|
|
3014
|
-
input.priceId,
|
|
3015
|
-
quantity,
|
|
3016
|
-
input.status,
|
|
3017
|
-
input.lastErrorCode ?? null,
|
|
3018
|
-
input.synced === true ? 1 : 0,
|
|
3019
|
-
input.synced === true ? 1 : 0
|
|
3020
|
-
]
|
|
3021
|
-
});
|
|
3022
|
-
const state = await getConnectedAccountBillingState(input.userId);
|
|
3023
|
-
if (!state) throw new Error("connected account billing state was not persisted");
|
|
3024
|
-
return state;
|
|
3025
|
-
}
|
|
3026
|
-
async function listUsersDueForConnectedAccountBillingReconciliation(staleMinutes = 60, limit = 20) {
|
|
3027
|
-
const modifier = `-${Math.max(1, Math.round(staleMinutes))} minutes`;
|
|
3028
|
-
const res = await getDb().execute({
|
|
3029
|
-
sql: `SELECT u.*
|
|
3030
|
-
FROM users u
|
|
3031
|
-
LEFT JOIN connected_account_billing b ON b.user_id = u.id
|
|
3032
|
-
WHERE u.active = 1
|
|
3033
|
-
AND u.subscription_id IS NOT NULL
|
|
3034
|
-
AND (b.checked_at IS NULL OR b.checked_at <= datetime('now', ?))
|
|
3035
|
-
ORDER BY COALESCE(b.checked_at, u.created_at) ASC
|
|
3036
|
-
LIMIT ?`,
|
|
3037
|
-
args: [modifier, Math.max(1, Math.min(100, Math.round(limit)))]
|
|
3038
|
-
});
|
|
3039
|
-
return res.rows.map((row) => rowToUser(row));
|
|
3040
|
-
}
|
|
3041
|
-
async function setMemoryPlan(userId, plan, subscriptionId) {
|
|
3042
|
-
await getDb().execute({
|
|
3043
|
-
sql: "UPDATE users SET memory_plan = ?, memory_subscription_id = ? WHERE id = ?",
|
|
3044
|
-
args: [plan, subscriptionId, userId]
|
|
3045
|
-
});
|
|
3046
|
-
}
|
|
3047
|
-
async function getMemoryPlan(userId) {
|
|
3048
|
-
const res = await getDb().execute({ sql: "SELECT memory_plan, memory_subscription_id FROM users WHERE id = ?", args: [userId] });
|
|
3049
|
-
const row = res.rows[0];
|
|
3050
|
-
return {
|
|
3051
|
-
plan: row?.memory_plan != null ? String(row.memory_plan) : "free",
|
|
3052
|
-
memory_subscription_id: row?.memory_subscription_id != null ? String(row.memory_subscription_id) : null
|
|
3053
|
-
};
|
|
3054
|
-
}
|
|
3055
|
-
async function getMemoryProvisioned(userId) {
|
|
3056
|
-
const res = await getDb().execute({ sql: "SELECT memory_provisioned FROM users WHERE id = ?", args: [userId] });
|
|
3057
|
-
const row = res.rows[0];
|
|
3058
|
-
return Number(row?.memory_provisioned ?? 0) === 1;
|
|
3059
|
-
}
|
|
3060
|
-
async function setMemoryProvisioned(userId, provisioned) {
|
|
3061
|
-
await getDb().execute({ sql: "UPDATE users SET memory_provisioned = ? WHERE id = ?", args: [provisioned ? 1 : 0, userId] });
|
|
3062
|
-
}
|
|
3063
|
-
async function resolveUserByMemorySubscription(subscriptionId) {
|
|
3064
|
-
const res = await getDb().execute({ sql: "SELECT * FROM users WHERE memory_subscription_id = ? AND active = 1", args: [subscriptionId] });
|
|
3065
|
-
return res.rows[0] ? rowToUser(res.rows[0]) : void 0;
|
|
3066
|
-
}
|
|
3067
|
-
var CREDIT_LOT_TTL = "+3 months";
|
|
3068
|
-
async function ensureMigrated(userId) {
|
|
3069
|
-
const db = getDb();
|
|
3070
|
-
const inserted = await db.execute({
|
|
3071
|
-
sql: `INSERT INTO credit_lots (user_id, amount_mc, remaining_mc, source, expires_at)
|
|
3072
|
-
SELECT ?, legacy.balance_mc, legacy.balance_mc, 'migration', datetime('now', ?)
|
|
3073
|
-
FROM (
|
|
3074
|
-
SELECT COALESCE(SUM(amount_mc), 0) AS balance_mc
|
|
3075
|
-
FROM ledger
|
|
3076
|
-
WHERE user_id = ?
|
|
3077
|
-
) AS legacy
|
|
3078
|
-
WHERE legacy.balance_mc > 0
|
|
3079
|
-
AND EXISTS (
|
|
3080
|
-
SELECT 1 FROM ledger
|
|
3081
|
-
WHERE user_id = ? AND created_at < ?
|
|
3082
|
-
)
|
|
3083
|
-
AND NOT EXISTS (
|
|
3084
|
-
SELECT 1 FROM credit_lots WHERE user_id = ?
|
|
3085
|
-
)`,
|
|
3086
|
-
args: [userId, CREDIT_LOT_TTL, userId, userId, CREDIT_LOTS_EPOCH, userId]
|
|
3087
|
-
});
|
|
3088
|
-
return inserted.rowsAffected;
|
|
3089
|
-
}
|
|
3090
|
-
async function expireOldLots() {
|
|
3091
|
-
const db = getDb();
|
|
3092
|
-
const lots = await db.execute({
|
|
3093
|
-
sql: `SELECT id
|
|
3094
|
-
FROM credit_lots
|
|
3095
|
-
WHERE remaining_mc > 0
|
|
3096
|
-
AND never_expires = 0
|
|
3097
|
-
AND expires_at <= datetime('now')
|
|
3098
|
-
LIMIT 500`
|
|
3099
|
-
});
|
|
3100
|
-
if (lots.rows.length === 0) return { expired_lots: 0, expired_mc: 0 };
|
|
3101
|
-
const claims = [];
|
|
3102
|
-
const stmts = [];
|
|
3103
|
-
for (const lot of lots.rows) {
|
|
3104
|
-
const lotId = Number(lot.id);
|
|
3105
|
-
const token = randomUUID();
|
|
3106
|
-
claims.push({ token, insertResultIndex: stmts.length });
|
|
3107
|
-
stmts.push({
|
|
3108
|
-
sql: `INSERT OR IGNORE INTO credit_lot_expirations (lot_id, user_id, amount_mc, claim_token)
|
|
3109
|
-
SELECT id, user_id, remaining_mc, ?
|
|
3110
|
-
FROM credit_lots
|
|
3111
|
-
WHERE id = ?
|
|
3112
|
-
AND remaining_mc > 0
|
|
3113
|
-
AND never_expires = 0
|
|
3114
|
-
AND expires_at <= datetime('now')`,
|
|
3115
|
-
args: [token, lotId]
|
|
3116
|
-
});
|
|
3117
|
-
stmts.push({
|
|
3118
|
-
sql: `UPDATE credit_lots
|
|
3119
|
-
SET remaining_mc = 0
|
|
3120
|
-
WHERE id = ?
|
|
3121
|
-
AND EXISTS (
|
|
3122
|
-
SELECT 1 FROM credit_lot_expirations
|
|
3123
|
-
WHERE lot_id = ? AND claim_token = ?
|
|
3124
|
-
)`,
|
|
3125
|
-
args: [lotId, lotId, token]
|
|
3126
|
-
});
|
|
3127
|
-
stmts.push({
|
|
3128
|
-
sql: `INSERT INTO ledger (user_id, amount_mc, operation, description)
|
|
3129
|
-
SELECT user_id, -amount_mc, 'credit_expired', 'credits expired'
|
|
3130
|
-
FROM credit_lot_expirations
|
|
3131
|
-
WHERE lot_id = ? AND claim_token = ?`,
|
|
3132
|
-
args: [lotId, token]
|
|
3133
|
-
});
|
|
3134
|
-
}
|
|
3135
|
-
const results = await db.batch(stmts, "write");
|
|
3136
|
-
const claimedTokens = claims.filter((claim) => results[claim.insertResultIndex]?.rowsAffected === 1).map((claim) => claim.token);
|
|
3137
|
-
if (claimedTokens.length === 0) return { expired_lots: 0, expired_mc: 0 };
|
|
3138
|
-
const placeholders = claimedTokens.map(() => "?").join(", ");
|
|
3139
|
-
const expired = await db.execute({
|
|
3140
|
-
sql: `SELECT user_id, amount_mc
|
|
3141
|
-
FROM credit_lot_expirations
|
|
3142
|
-
WHERE claim_token IN (${placeholders})`,
|
|
3143
|
-
args: claimedTokens
|
|
3144
|
-
});
|
|
3145
|
-
const users = new Set(expired.rows.map((row) => Number(row.user_id)));
|
|
3146
|
-
for (const uid of users) await reconcileBalanceMc(uid);
|
|
3147
|
-
return {
|
|
3148
|
-
expired_lots: expired.rows.length,
|
|
3149
|
-
expired_mc: expired.rows.reduce((sum, row) => sum + Number(row.amount_mc), 0)
|
|
3150
|
-
};
|
|
3151
|
-
}
|
|
3152
|
-
async function migrateExistingBalancesToLots() {
|
|
3153
|
-
const db = getDb();
|
|
3154
|
-
const res = await db.execute({
|
|
3155
|
-
sql: `SELECT DISTINCT u.id AS user_id
|
|
3156
|
-
FROM users u
|
|
3157
|
-
JOIN ledger l ON l.user_id = u.id
|
|
3158
|
-
WHERE l.created_at < ?
|
|
3159
|
-
AND NOT EXISTS (SELECT 1 FROM credit_lots WHERE user_id = u.id)`,
|
|
3160
|
-
args: [CREDIT_LOTS_EPOCH]
|
|
3161
|
-
});
|
|
3162
|
-
let migrated = 0;
|
|
3163
|
-
let totalMc = 0;
|
|
3164
|
-
for (const row of res.rows) {
|
|
3165
|
-
const uid = Number(row.user_id);
|
|
3166
|
-
const inserted = await ensureMigrated(uid);
|
|
3167
|
-
if (inserted === 0) continue;
|
|
3168
|
-
const lot = await db.execute({
|
|
3169
|
-
sql: `SELECT amount_mc FROM credit_lots
|
|
3170
|
-
WHERE user_id = ? AND source = 'migration'
|
|
3171
|
-
ORDER BY id DESC LIMIT 1`,
|
|
3172
|
-
args: [uid]
|
|
3173
|
-
});
|
|
3174
|
-
const amountMc = Number(lot.rows[0]?.amount_mc ?? 0);
|
|
3175
|
-
await reconcileBalanceMc(uid);
|
|
3176
|
-
migrated += 1;
|
|
3177
|
-
totalMc += amountMc;
|
|
3178
|
-
}
|
|
3179
|
-
return { migrated, total_mc: totalMc };
|
|
3180
|
-
}
|
|
3181
|
-
async function creditMc(userId, mc, operation, description, stripePaymentIntent, options) {
|
|
3182
|
-
const db = getDb();
|
|
3183
|
-
await ensureMigrated(userId);
|
|
3184
|
-
let neverExpires = options?.neverExpires;
|
|
3185
|
-
if (neverExpires === void 0 && mc > 0) {
|
|
3186
|
-
const tier = await db.execute({ sql: "SELECT subscription_tier FROM users WHERE id = ? LIMIT 1", args: [userId] });
|
|
3187
|
-
neverExpires = String(tier.rows[0]?.subscription_tier ?? "").startsWith("sonic");
|
|
3188
|
-
}
|
|
3189
|
-
const expiry = neverExpires ? "9999-12-31 23:59:59" : null;
|
|
3190
|
-
if (stripePaymentIntent) {
|
|
3191
|
-
const stmts2 = [{
|
|
3192
|
-
sql: "INSERT OR IGNORE INTO ledger (user_id, amount_mc, operation, description, stripe_pi) VALUES (?, ?, ?, ?, ?)",
|
|
3193
|
-
args: [userId, mc, operation, description ?? null, stripePaymentIntent]
|
|
3194
|
-
}];
|
|
3195
|
-
if (mc > 0) {
|
|
3196
|
-
stmts2.push({
|
|
3197
|
-
sql: `INSERT INTO credit_lots (user_id, amount_mc, remaining_mc, source, stripe_pi, expires_at, never_expires)
|
|
3198
|
-
SELECT ?, ?, ?, ?, ?, COALESCE(?, datetime('now', ?)), ?
|
|
3199
|
-
WHERE changes() = 1`,
|
|
3200
|
-
args: [userId, mc, mc, operation, stripePaymentIntent, expiry, CREDIT_LOT_TTL, neverExpires ? 1 : 0]
|
|
3201
|
-
});
|
|
3202
|
-
}
|
|
3203
|
-
stmts2.push({
|
|
3204
|
-
sql: "UPDATE users SET balance_mc = balance_mc + ? WHERE id = ? AND changes() = 1",
|
|
3205
|
-
args: [mc, userId]
|
|
3206
|
-
});
|
|
3207
|
-
await db.batch(stmts2, "write");
|
|
3208
|
-
const res2 = await db.execute({ sql: "SELECT balance_mc FROM users WHERE id = ?", args: [userId] });
|
|
3209
|
-
return Number(res2.rows[0]?.balance_mc ?? 0);
|
|
3210
|
-
}
|
|
3211
|
-
const stmts = [];
|
|
3212
|
-
if (mc > 0) {
|
|
3213
|
-
stmts.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, null, expiry, CREDIT_LOT_TTL, neverExpires ? 1 : 0] });
|
|
3214
|
-
}
|
|
3215
|
-
stmts.push({ sql: "UPDATE users SET balance_mc = balance_mc + ? WHERE id = ?", args: [mc, userId] });
|
|
3216
|
-
stmts.push({ sql: "INSERT INTO ledger (user_id, amount_mc, operation, description, stripe_pi) VALUES (?, ?, ?, ?, ?)", args: [userId, mc, operation, description ?? null, null] });
|
|
3217
|
-
await db.batch(stmts, "write");
|
|
3218
|
-
const res = await db.execute({ sql: "SELECT balance_mc FROM users WHERE id = ?", args: [userId] });
|
|
3219
|
-
return Number(res.rows[0]?.balance_mc ?? 0);
|
|
3220
|
-
}
|
|
3221
|
-
async function creditMcIdempotent(userId, mc, operation, description, idempotencyKey, options) {
|
|
3222
|
-
if (!Number.isSafeInteger(mc) || mc <= 0) throw new Error("idempotent credit amount must be a positive integer");
|
|
3223
|
-
if (!idempotencyKey || idempotencyKey.length > 500) throw new Error("idempotent credit key is required and must be bounded");
|
|
3224
|
-
const db = getDb();
|
|
3225
|
-
await ensureMigrated(userId);
|
|
3226
|
-
let neverExpires = options?.neverExpires;
|
|
3227
|
-
if (neverExpires === void 0) {
|
|
3228
|
-
const tier = await db.execute({ sql: "SELECT subscription_tier FROM users WHERE id = ? LIMIT 1", args: [userId] });
|
|
3229
|
-
neverExpires = String(tier.rows[0]?.subscription_tier ?? "").startsWith("sonic");
|
|
3230
|
-
}
|
|
3231
|
-
const expiry = neverExpires ? "9999-12-31 23:59:59" : null;
|
|
3232
|
-
const readBalance = async () => {
|
|
3233
|
-
const res = await db.execute({ sql: "SELECT balance_mc FROM users WHERE id = ?", args: [userId] });
|
|
3234
|
-
return Number(res.rows[0]?.balance_mc ?? 0);
|
|
3235
|
-
};
|
|
3236
|
-
let results;
|
|
3237
|
-
try {
|
|
3238
|
-
results = await db.batch([
|
|
3239
|
-
{
|
|
3240
|
-
sql: "INSERT OR IGNORE INTO billing_credits (idempotency_key, user_id, amount_mc, operation, description, status) VALUES (?, ?, ?, ?, ?, 'claimed')",
|
|
3241
|
-
args: [idempotencyKey, userId, mc, operation, description]
|
|
3242
|
-
},
|
|
3243
|
-
{
|
|
3244
|
-
sql: "UPDATE billing_credits SET status = 'applying' WHERE idempotency_key = ? AND user_id = ? AND amount_mc = ? AND operation = ? AND description = ? AND status = 'claimed'",
|
|
3245
|
-
args: [idempotencyKey, userId, mc, operation, description]
|
|
3246
|
-
},
|
|
3247
|
-
{
|
|
3248
|
-
sql: `INSERT INTO credit_lots (user_id, amount_mc, remaining_mc, source, stripe_pi, expires_at, never_expires)
|
|
3249
|
-
SELECT user_id, amount_mc, amount_mc, operation, NULL, COALESCE(?, datetime('now', ?)), ?
|
|
3250
|
-
FROM billing_credits
|
|
3251
|
-
WHERE idempotency_key = ? AND status = 'applying'`,
|
|
3252
|
-
args: [expiry, CREDIT_LOT_TTL, neverExpires ? 1 : 0, idempotencyKey]
|
|
3253
|
-
},
|
|
3254
|
-
{
|
|
3255
|
-
sql: `UPDATE users
|
|
3256
|
-
SET balance_mc = balance_mc + COALESCE((
|
|
3257
|
-
SELECT amount_mc FROM billing_credits WHERE idempotency_key = ? AND status = 'applying'
|
|
3258
|
-
), 0)
|
|
3259
|
-
WHERE id = ?`,
|
|
3260
|
-
args: [idempotencyKey, userId]
|
|
3261
|
-
},
|
|
3262
|
-
{
|
|
3263
|
-
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'",
|
|
3264
|
-
args: [idempotencyKey]
|
|
3265
|
-
},
|
|
3266
|
-
{
|
|
3267
|
-
sql: "UPDATE billing_credits SET status = 'applied', applied_at = datetime('now') WHERE idempotency_key = ? AND status = 'applying'",
|
|
3268
|
-
args: [idempotencyKey]
|
|
3269
|
-
}
|
|
3270
|
-
], "write");
|
|
3271
|
-
} catch (error) {
|
|
3272
|
-
try {
|
|
3273
|
-
const recovered = await db.execute({
|
|
3274
|
-
sql: "SELECT user_id, amount_mc, operation, description FROM billing_credits WHERE idempotency_key = ? AND status = 'applied' LIMIT 1",
|
|
3275
|
-
args: [idempotencyKey]
|
|
3276
|
-
});
|
|
3277
|
-
const row2 = recovered.rows[0];
|
|
3278
|
-
if (row2 && Number(row2.user_id) === userId && Number(row2.amount_mc) === mc && String(row2.operation) === operation && String(row2.description) === description) {
|
|
3279
|
-
return { ok: true, balance_mc: await readBalance(), duplicate: true };
|
|
3280
|
-
}
|
|
3281
|
-
} catch {
|
|
3282
|
-
}
|
|
3283
|
-
throw error;
|
|
3284
|
-
}
|
|
3285
|
-
const claim = await db.execute({
|
|
3286
|
-
sql: "SELECT user_id, amount_mc, operation, description, status FROM billing_credits WHERE idempotency_key = ? LIMIT 1",
|
|
3287
|
-
args: [idempotencyKey]
|
|
3288
|
-
});
|
|
3289
|
-
const row = claim.rows[0];
|
|
3290
|
-
if (!row || Number(row.user_id) !== userId || Number(row.amount_mc) !== mc || String(row.operation) !== operation || String(row.description) !== description) {
|
|
3291
|
-
throw new Error("idempotent credit key conflicts with another credit");
|
|
3292
|
-
}
|
|
3293
|
-
return {
|
|
3294
|
-
ok: String(row.status) === "applied",
|
|
3295
|
-
balance_mc: await readBalance(),
|
|
3296
|
-
duplicate: results[1]?.rowsAffected === 0 && String(row.status) === "applied"
|
|
3297
|
-
};
|
|
3298
|
-
}
|
|
3299
|
-
async function ledgerExistsForOperation(userId, operation) {
|
|
3300
|
-
const res = await getDb().execute({
|
|
3301
|
-
sql: "SELECT 1 FROM ledger WHERE user_id = ? AND operation = ? LIMIT 1",
|
|
3302
|
-
args: [userId, operation]
|
|
3303
|
-
});
|
|
3304
|
-
return res.rows.length > 0;
|
|
3305
|
-
}
|
|
3306
|
-
async function claimMonthlyFreeRefresh(userId, month) {
|
|
3307
|
-
const res = await getDb().execute({
|
|
3308
|
-
sql: "INSERT OR IGNORE INTO free_credit_refreshes (user_id, month) VALUES (?, ?)",
|
|
3309
|
-
args: [userId, month]
|
|
3310
|
-
});
|
|
3311
|
-
return res.rowsAffected > 0;
|
|
3312
|
-
}
|
|
3313
|
-
async function debitMc(userId, mc, operation, description) {
|
|
3314
|
-
const debit = await debitMcIdempotent(
|
|
3315
|
-
userId,
|
|
3316
|
-
mc,
|
|
3317
|
-
operation,
|
|
3318
|
-
description ?? "",
|
|
3319
|
-
`debit:${randomUUID()}`
|
|
3320
|
-
);
|
|
3321
|
-
return { ok: debit.ok, balance_mc: debit.balance_mc };
|
|
3322
|
-
}
|
|
3323
|
-
async function debitMcIdempotent(userId, mc, operation, description, idempotencyKey) {
|
|
3324
|
-
if (!Number.isSafeInteger(mc) || mc <= 0) throw new Error("idempotent debit amount must be a positive integer");
|
|
3325
|
-
if (!idempotencyKey || idempotencyKey.length > 500) throw new Error("idempotent debit key is required and must be bounded");
|
|
3326
|
-
const db = getDb();
|
|
3327
|
-
await ensureMigrated(userId);
|
|
3328
|
-
let results;
|
|
3329
|
-
try {
|
|
3330
|
-
results = await db.batch([
|
|
3331
|
-
{
|
|
3332
|
-
// An unfunded claim may be retried with a corrected estimate under the
|
|
3333
|
-
// same logical operation fingerprint. Applied claims remain immutable.
|
|
3334
|
-
sql: "UPDATE billing_debits SET amount_mc = ? WHERE idempotency_key = ? AND user_id = ? AND operation = ? AND description = ? AND status = 'claimed'",
|
|
3335
|
-
args: [mc, idempotencyKey, userId, operation, description]
|
|
3336
|
-
},
|
|
3337
|
-
{
|
|
3338
|
-
sql: "INSERT OR IGNORE INTO billing_debits (idempotency_key, user_id, amount_mc, operation, description, status) VALUES (?, ?, ?, ?, ?, 'claimed')",
|
|
3339
|
-
args: [idempotencyKey, userId, mc, operation, description]
|
|
3340
|
-
},
|
|
3341
|
-
{
|
|
3342
|
-
sql: `UPDATE users
|
|
3343
|
-
SET balance_mc = balance_mc - ?
|
|
3344
|
-
WHERE id = ?
|
|
3345
|
-
AND balance_mc >= ?
|
|
3346
|
-
AND (SELECT COALESCE(SUM(remaining_mc), 0)
|
|
3347
|
-
FROM credit_lots
|
|
3348
|
-
WHERE user_id = ?
|
|
3349
|
-
AND remaining_mc > 0
|
|
3350
|
-
AND (never_expires = 1 OR expires_at > datetime('now'))) >= ?
|
|
3351
|
-
AND EXISTS (SELECT 1 FROM billing_debits WHERE idempotency_key = ? AND user_id = ? AND amount_mc = ? AND operation = ? AND description = ? AND status = 'claimed')`,
|
|
3352
|
-
args: [mc, userId, mc, userId, mc, idempotencyKey, userId, mc, operation, description]
|
|
3353
|
-
},
|
|
3354
|
-
{
|
|
3355
|
-
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",
|
|
3356
|
-
args: [idempotencyKey, userId, mc, operation, description]
|
|
3357
|
-
},
|
|
3358
|
-
{
|
|
3359
|
-
sql: `INSERT OR IGNORE INTO billing_debit_allocations
|
|
3360
|
-
(debit_idempotency_key, lot_id, user_id, amount_mc, expires_at)
|
|
3361
|
-
WITH ordered AS MATERIALIZED (
|
|
3362
|
-
SELECT id,
|
|
3363
|
-
remaining_mc,
|
|
3364
|
-
expires_at,
|
|
3365
|
-
COALESCE(SUM(remaining_mc) OVER (
|
|
3366
|
-
ORDER BY never_expires ASC, expires_at ASC, granted_at ASC, id ASC
|
|
3367
|
-
ROWS BETWEEN UNBOUNDED PRECEDING AND 1 PRECEDING
|
|
3368
|
-
), 0) AS prior_mc
|
|
3369
|
-
FROM credit_lots
|
|
3370
|
-
WHERE user_id = ? AND remaining_mc > 0 AND (never_expires = 1 OR expires_at > datetime('now'))
|
|
3371
|
-
), allocation AS (
|
|
3372
|
-
SELECT id, expires_at, MIN(remaining_mc, MAX(0, ? - prior_mc)) AS take_mc
|
|
3373
|
-
FROM ordered
|
|
3374
|
-
)
|
|
3375
|
-
SELECT ?, id, ?, take_mc, expires_at
|
|
3376
|
-
FROM allocation
|
|
3377
|
-
WHERE take_mc > 0
|
|
3378
|
-
AND EXISTS (SELECT 1 FROM billing_debits WHERE idempotency_key = ? AND status = 'applying')`,
|
|
3379
|
-
args: [userId, mc, idempotencyKey, userId, idempotencyKey]
|
|
3380
|
-
},
|
|
3381
|
-
{
|
|
3382
|
-
sql: `UPDATE credit_lots
|
|
3383
|
-
SET remaining_mc = remaining_mc - COALESCE((
|
|
3384
|
-
SELECT amount_mc FROM billing_debit_allocations
|
|
3385
|
-
WHERE debit_idempotency_key = ? AND lot_id = credit_lots.id
|
|
3386
|
-
), 0)
|
|
3387
|
-
WHERE id IN (
|
|
3388
|
-
SELECT lot_id FROM billing_debit_allocations WHERE debit_idempotency_key = ?
|
|
3389
|
-
)
|
|
3390
|
-
AND EXISTS (SELECT 1 FROM billing_debits WHERE idempotency_key = ? AND status = 'applying')`,
|
|
3391
|
-
args: [idempotencyKey, idempotencyKey, idempotencyKey]
|
|
3392
|
-
},
|
|
3393
|
-
{
|
|
3394
|
-
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'",
|
|
3395
|
-
args: [idempotencyKey]
|
|
3396
|
-
},
|
|
3397
|
-
{
|
|
3398
|
-
sql: "UPDATE billing_debits SET status = 'applied', applied_at = datetime('now') WHERE idempotency_key = ? AND status = 'applying'",
|
|
3399
|
-
args: [idempotencyKey]
|
|
3400
|
-
}
|
|
3401
|
-
], "write");
|
|
3402
|
-
} catch (error) {
|
|
3403
|
-
try {
|
|
3404
|
-
const recovered = await db.execute({
|
|
3405
|
-
sql: "SELECT user_id, amount_mc, operation, description, status FROM billing_debits WHERE idempotency_key = ? AND status = 'applied' LIMIT 1",
|
|
3406
|
-
args: [idempotencyKey]
|
|
3407
|
-
});
|
|
3408
|
-
const row2 = recovered.rows[0];
|
|
3409
|
-
if (row2 && Number(row2.user_id) === userId && Number(row2.amount_mc) === mc && String(row2.operation) === operation && String(row2.description) === description) {
|
|
3410
|
-
const balance2 = await db.execute({ sql: "SELECT balance_mc FROM users WHERE id = ?", args: [userId] });
|
|
3411
|
-
return { ok: true, balance_mc: Number(balance2.rows[0]?.balance_mc ?? 0), duplicate: true };
|
|
3412
|
-
}
|
|
3413
|
-
} catch {
|
|
3414
|
-
}
|
|
3415
|
-
throw error;
|
|
3416
|
-
}
|
|
3417
|
-
const claim = await db.execute({
|
|
3418
|
-
sql: "SELECT user_id, amount_mc, operation, description, status FROM billing_debits WHERE idempotency_key = ? LIMIT 1",
|
|
3419
|
-
args: [idempotencyKey]
|
|
3420
|
-
});
|
|
3421
|
-
const row = claim.rows[0];
|
|
3422
|
-
if (!row || Number(row.user_id) !== userId || Number(row.amount_mc) !== mc || String(row.operation) !== operation || String(row.description) !== description) {
|
|
3423
|
-
throw new Error("idempotent debit key conflicts with another debit");
|
|
3424
|
-
}
|
|
3425
|
-
const balance = await db.execute({ sql: "SELECT balance_mc FROM users WHERE id = ?", args: [userId] });
|
|
3426
|
-
return {
|
|
3427
|
-
ok: String(row.status) === "applied",
|
|
3428
|
-
balance_mc: Number(balance.rows[0]?.balance_mc ?? 0),
|
|
3429
|
-
duplicate: results[2]?.rowsAffected === 0 && String(row.status) === "applied"
|
|
3430
|
-
};
|
|
3431
|
-
}
|
|
3432
|
-
async function settleDebitMcIdempotent(userId, debitIdempotencyKey, finalAmountMc, refundOperation, description, sourceSurface) {
|
|
3433
|
-
if (!Number.isSafeInteger(finalAmountMc) || finalAmountMc < 0) {
|
|
3434
|
-
throw new Error("idempotent debit settlement amount must be a non-negative integer");
|
|
3435
|
-
}
|
|
3436
|
-
if (!debitIdempotencyKey || debitIdempotencyKey.length > 500) {
|
|
3437
|
-
throw new Error("idempotent debit settlement key is required and must be bounded");
|
|
3438
|
-
}
|
|
3439
|
-
if (!refundOperation || !sourceSurface) {
|
|
3440
|
-
throw new Error("idempotent debit settlement operation and source are required");
|
|
3441
|
-
}
|
|
3442
|
-
const db = getDb();
|
|
3443
|
-
await ensureMigrated(userId);
|
|
3444
|
-
const settlementKey = `debit-settlement:${createHash("sha256").update(debitIdempotencyKey).digest("hex")}`;
|
|
3445
|
-
const billingClass = "wallet_hold_settlement";
|
|
3446
|
-
const eventId = randomUUID();
|
|
3447
|
-
const metadata = JSON.stringify({ debitIdempotencyKey, refundOperation, description });
|
|
3448
|
-
const readReceipt = async () => {
|
|
3449
|
-
const receipt2 = await db.execute({
|
|
3450
|
-
sql: `SELECT e.user_id,
|
|
3451
|
-
e.status,
|
|
3452
|
-
e.amount_mc AS final_amount_mc,
|
|
3453
|
-
e.billing_class,
|
|
3454
|
-
e.source_surface,
|
|
3455
|
-
e.settled_at,
|
|
3456
|
-
d.amount_mc AS held_amount_mc
|
|
3457
|
-
FROM billing_events e
|
|
3458
|
-
JOIN billing_debits d ON d.idempotency_key = ?
|
|
3459
|
-
WHERE e.idempotency_key = ?
|
|
3460
|
-
AND d.user_id = e.user_id
|
|
3461
|
-
AND d.status = 'applied'
|
|
3462
|
-
LIMIT 1`,
|
|
3463
|
-
args: [debitIdempotencyKey, settlementKey]
|
|
3464
|
-
});
|
|
3465
|
-
const row = receipt2.rows[0];
|
|
3466
|
-
if (!row) return null;
|
|
3467
|
-
if (Number(row.user_id) !== userId || String(row.billing_class) !== billingClass || String(row.source_surface) !== sourceSurface) {
|
|
3468
|
-
throw new Error("idempotent debit settlement key conflicts with another settlement");
|
|
3469
|
-
}
|
|
3470
|
-
if (String(row.status) !== "settled") return null;
|
|
3471
|
-
const heldAmountMc = Number(row.held_amount_mc);
|
|
3472
|
-
const durableFinalAmountMc = Number(row.final_amount_mc);
|
|
3473
|
-
if (!Number.isSafeInteger(heldAmountMc) || !Number.isSafeInteger(durableFinalAmountMc) || durableFinalAmountMc < 0 || durableFinalAmountMc > heldAmountMc) {
|
|
3474
|
-
throw new Error("idempotent debit settlement receipt is invalid");
|
|
3475
|
-
}
|
|
3476
|
-
const nominalRefundMc = heldAmountMc - durableFinalAmountMc;
|
|
3477
|
-
const allocationRefund = await db.execute({
|
|
3478
|
-
sql: `WITH ordered AS (
|
|
3479
|
-
SELECT a.amount_mc,
|
|
3480
|
-
a.expires_at,
|
|
3481
|
-
COALESCE(SUM(a.amount_mc) OVER (
|
|
3482
|
-
ORDER BY a.expires_at ASC, a.lot_id ASC
|
|
3483
|
-
ROWS BETWEEN UNBOUNDED PRECEDING AND 1 PRECEDING
|
|
3484
|
-
), 0) AS prior_mc
|
|
3485
|
-
FROM billing_debit_allocations a
|
|
3486
|
-
WHERE a.debit_idempotency_key = ?
|
|
3487
|
-
), refunds AS (
|
|
3488
|
-
SELECT expires_at,
|
|
3489
|
-
amount_mc - MIN(amount_mc, MAX(0, ? - prior_mc)) AS refund_mc
|
|
3490
|
-
FROM ordered
|
|
3491
|
-
)
|
|
3492
|
-
SELECT (SELECT COUNT(*) FROM ordered) AS allocation_count,
|
|
3493
|
-
COALESCE(SUM(CASE WHEN expires_at > ? THEN refund_mc ELSE 0 END), 0) AS credited_refund_mc
|
|
3494
|
-
FROM refunds`,
|
|
3495
|
-
args: [debitIdempotencyKey, durableFinalAmountMc, String(row.settled_at)]
|
|
3496
|
-
});
|
|
3497
|
-
const allocationCount = Number(allocationRefund.rows[0]?.allocation_count ?? 0);
|
|
3498
|
-
const creditedRefundMc = allocationCount > 0 ? Number(allocationRefund.rows[0]?.credited_refund_mc ?? 0) : 0;
|
|
3499
|
-
const balance = await db.execute({ sql: "SELECT balance_mc FROM users WHERE id = ?", args: [userId] });
|
|
3500
|
-
return {
|
|
3501
|
-
balance_mc: Number(balance.rows[0]?.balance_mc ?? 0),
|
|
3502
|
-
final_amount_mc: durableFinalAmountMc,
|
|
3503
|
-
refund_mc: creditedRefundMc,
|
|
3504
|
-
expired_refund_mc: nominalRefundMc - creditedRefundMc,
|
|
3505
|
-
duplicate: true
|
|
3506
|
-
};
|
|
3507
|
-
};
|
|
3508
|
-
let results;
|
|
3509
|
-
try {
|
|
3510
|
-
results = await db.batch([
|
|
3511
|
-
{
|
|
3512
|
-
sql: `INSERT OR IGNORE INTO billing_events
|
|
3513
|
-
(id, user_id, idempotency_key, billing_class, source_surface, status, amount_mc, metadata)
|
|
3514
|
-
VALUES (?, ?, ?, ?, ?, 'settling', ?, ?)`,
|
|
3515
|
-
args: [eventId, userId, settlementKey, billingClass, sourceSurface, finalAmountMc, metadata]
|
|
3516
|
-
},
|
|
3517
|
-
{
|
|
3518
|
-
sql: `WITH ordered AS (
|
|
3519
|
-
SELECT a.lot_id,
|
|
3520
|
-
a.amount_mc,
|
|
3521
|
-
a.expires_at,
|
|
3522
|
-
COALESCE(SUM(a.amount_mc) OVER (
|
|
3523
|
-
ORDER BY a.expires_at ASC, a.lot_id ASC
|
|
3524
|
-
ROWS BETWEEN UNBOUNDED PRECEDING AND 1 PRECEDING
|
|
3525
|
-
), 0) AS prior_mc
|
|
3526
|
-
FROM billing_debit_allocations a
|
|
3527
|
-
WHERE a.debit_idempotency_key = ?
|
|
3528
|
-
), refunds AS (
|
|
3529
|
-
SELECT lot_id,
|
|
3530
|
-
expires_at,
|
|
3531
|
-
amount_mc - MIN(amount_mc, MAX(0, ? - prior_mc)) AS refund_mc
|
|
3532
|
-
FROM ordered
|
|
3533
|
-
)
|
|
3534
|
-
UPDATE users
|
|
3535
|
-
SET balance_mc = balance_mc + CASE
|
|
3536
|
-
WHEN EXISTS (SELECT 1 FROM ordered) THEN COALESCE(
|
|
3537
|
-
(SELECT SUM(refund_mc) FROM refunds WHERE expires_at > datetime('now')),
|
|
3538
|
-
0
|
|
3539
|
-
)
|
|
3540
|
-
-- Holds created before allocation provenance cannot be safely
|
|
3541
|
-
-- tied back to a source lot. Treat their unused portion as
|
|
3542
|
-
-- expired instead of minting spendable credits with a guessed
|
|
3543
|
-
-- expiry.
|
|
3544
|
-
ELSE 0
|
|
3545
|
-
END
|
|
3546
|
-
WHERE id = ?
|
|
3547
|
-
AND EXISTS (
|
|
3548
|
-
SELECT 1
|
|
3549
|
-
FROM billing_events e
|
|
3550
|
-
JOIN billing_debits d ON d.idempotency_key = ?
|
|
3551
|
-
WHERE e.idempotency_key = ?
|
|
3552
|
-
AND e.user_id = ?
|
|
3553
|
-
AND e.billing_class = ?
|
|
3554
|
-
AND e.source_surface = ?
|
|
3555
|
-
AND e.status = 'settling'
|
|
3556
|
-
AND d.user_id = e.user_id
|
|
3557
|
-
AND d.status = 'applied'
|
|
3558
|
-
AND e.amount_mc BETWEEN 0 AND d.amount_mc
|
|
3559
|
-
)`,
|
|
3560
|
-
args: [
|
|
3561
|
-
debitIdempotencyKey,
|
|
3562
|
-
finalAmountMc,
|
|
3563
|
-
userId,
|
|
3564
|
-
debitIdempotencyKey,
|
|
3565
|
-
settlementKey,
|
|
3566
|
-
userId,
|
|
3567
|
-
billingClass,
|
|
3568
|
-
sourceSurface
|
|
3569
|
-
]
|
|
3570
|
-
},
|
|
3571
|
-
{
|
|
3572
|
-
sql: `UPDATE billing_events
|
|
3573
|
-
SET status = 'applying'
|
|
3574
|
-
WHERE idempotency_key = ?
|
|
3575
|
-
AND user_id = ?
|
|
3576
|
-
AND billing_class = ?
|
|
3577
|
-
AND source_surface = ?
|
|
3578
|
-
AND status = 'settling'
|
|
3579
|
-
AND changes() = 1`,
|
|
3580
|
-
args: [settlementKey, userId, billingClass, sourceSurface]
|
|
3581
|
-
},
|
|
3582
|
-
{
|
|
3583
|
-
sql: `WITH ordered AS (
|
|
3584
|
-
SELECT a.lot_id,
|
|
3585
|
-
a.amount_mc,
|
|
3586
|
-
a.expires_at,
|
|
3587
|
-
COALESCE(SUM(a.amount_mc) OVER (
|
|
3588
|
-
ORDER BY a.expires_at ASC, a.lot_id ASC
|
|
3589
|
-
ROWS BETWEEN UNBOUNDED PRECEDING AND 1 PRECEDING
|
|
3590
|
-
), 0) AS prior_mc
|
|
3591
|
-
FROM billing_debit_allocations a
|
|
3592
|
-
WHERE a.debit_idempotency_key = ?
|
|
3593
|
-
), refunds AS (
|
|
3594
|
-
SELECT lot_id,
|
|
3595
|
-
expires_at,
|
|
3596
|
-
amount_mc - MIN(amount_mc, MAX(0, ? - prior_mc)) AS refund_mc
|
|
3597
|
-
FROM ordered
|
|
3598
|
-
)
|
|
3599
|
-
UPDATE credit_lots
|
|
3600
|
-
SET remaining_mc = remaining_mc + COALESCE((SELECT refund_mc FROM refunds WHERE refunds.lot_id = credit_lots.id), 0)
|
|
3601
|
-
WHERE id IN (
|
|
3602
|
-
SELECT lot_id FROM refunds
|
|
3603
|
-
WHERE refund_mc > 0 AND expires_at > datetime('now')
|
|
3604
|
-
)
|
|
3605
|
-
AND EXISTS (SELECT 1 FROM billing_events WHERE idempotency_key = ? AND status = 'applying')`,
|
|
3606
|
-
args: [debitIdempotencyKey, finalAmountMc, settlementKey]
|
|
3607
|
-
},
|
|
3608
|
-
{
|
|
3609
|
-
sql: `WITH ordered AS (
|
|
3610
|
-
SELECT a.amount_mc,
|
|
3611
|
-
a.expires_at,
|
|
3612
|
-
COALESCE(SUM(a.amount_mc) OVER (
|
|
3613
|
-
ORDER BY a.expires_at ASC, a.lot_id ASC
|
|
3614
|
-
ROWS BETWEEN UNBOUNDED PRECEDING AND 1 PRECEDING
|
|
3615
|
-
), 0) AS prior_mc
|
|
3616
|
-
FROM billing_debit_allocations a
|
|
3617
|
-
WHERE a.debit_idempotency_key = ?
|
|
3618
|
-
), refunds AS (
|
|
3619
|
-
SELECT expires_at,
|
|
3620
|
-
amount_mc - MIN(amount_mc, MAX(0, ? - prior_mc)) AS refund_mc
|
|
3621
|
-
FROM ordered
|
|
3622
|
-
), credited AS (
|
|
3623
|
-
SELECT e.user_id,
|
|
3624
|
-
CASE
|
|
3625
|
-
WHEN EXISTS (SELECT 1 FROM ordered) THEN COALESCE(
|
|
3626
|
-
(SELECT SUM(refund_mc) FROM refunds WHERE expires_at > datetime('now')),
|
|
3627
|
-
0
|
|
3628
|
-
)
|
|
3629
|
-
ELSE 0
|
|
3630
|
-
END AS refund_mc
|
|
3631
|
-
FROM billing_events e
|
|
3632
|
-
JOIN billing_debits d ON d.idempotency_key = ?
|
|
3633
|
-
WHERE e.idempotency_key = ?
|
|
3634
|
-
AND e.status = 'applying'
|
|
3635
|
-
)
|
|
3636
|
-
INSERT INTO ledger (user_id, amount_mc, operation, description)
|
|
3637
|
-
SELECT user_id,
|
|
3638
|
-
refund_mc,
|
|
3639
|
-
?,
|
|
3640
|
-
?
|
|
3641
|
-
FROM credited
|
|
3642
|
-
WHERE refund_mc > 0`,
|
|
3643
|
-
args: [
|
|
3644
|
-
debitIdempotencyKey,
|
|
3645
|
-
finalAmountMc,
|
|
3646
|
-
debitIdempotencyKey,
|
|
3647
|
-
settlementKey,
|
|
3648
|
-
refundOperation,
|
|
3649
|
-
description
|
|
3650
|
-
]
|
|
3651
|
-
},
|
|
3652
|
-
{
|
|
3653
|
-
sql: `UPDATE billing_events
|
|
3654
|
-
SET status = 'settled', settled_at = datetime('now')
|
|
3655
|
-
WHERE idempotency_key = ?
|
|
3656
|
-
AND status = 'applying'`,
|
|
3657
|
-
args: [settlementKey]
|
|
3658
|
-
}
|
|
3659
|
-
], "write");
|
|
3660
|
-
} catch (error) {
|
|
3661
|
-
try {
|
|
3662
|
-
const recovered = await readReceipt();
|
|
3663
|
-
if (recovered) return recovered;
|
|
3664
|
-
} catch {
|
|
3665
|
-
}
|
|
3666
|
-
throw error;
|
|
3667
|
-
}
|
|
3668
|
-
const receipt = await readReceipt();
|
|
3669
|
-
if (!receipt) throw new Error("idempotent debit settlement did not reach a durable settled state");
|
|
3670
|
-
return { ...receipt, duplicate: results[0]?.rowsAffected === 0 };
|
|
3671
|
-
}
|
|
3672
|
-
async function getLedger(userId, limit = 50) {
|
|
3673
|
-
const res = await getDb().execute({
|
|
3674
|
-
sql: "SELECT * FROM ledger WHERE user_id = ? ORDER BY created_at DESC LIMIT ?",
|
|
3675
|
-
args: [userId, limit]
|
|
3676
|
-
});
|
|
3677
|
-
return res.rows.map((r) => ({
|
|
3678
|
-
id: Number(r.id),
|
|
3679
|
-
user_id: Number(r.user_id),
|
|
3680
|
-
amount_mc: Number(r.amount_mc),
|
|
3681
|
-
operation: String(r.operation),
|
|
3682
|
-
description: r.description != null ? String(r.description) : null,
|
|
3683
|
-
stripe_pi: r.stripe_pi != null ? String(r.stripe_pi) : null,
|
|
3684
|
-
created_at: String(r.created_at)
|
|
3685
|
-
}));
|
|
3686
|
-
}
|
|
3687
|
-
async function getBillingLedgerSummary(userId, since, classification) {
|
|
3688
|
-
const db = getDb();
|
|
3689
|
-
const refundPlaceholders = classification.refund.map(() => "?").join(", ");
|
|
3690
|
-
const grantPlaceholders = classification.grant.map(() => "?").join(", ");
|
|
3691
|
-
const purchasePlaceholders = classification.purchase.map(() => "?").join(", ");
|
|
3692
|
-
const totalsRes = await db.execute({
|
|
3693
|
-
sql: `SELECT
|
|
3694
|
-
COALESCE(SUM(CASE WHEN amount_mc < 0 AND operation NOT IN (${refundPlaceholders}) THEN -amount_mc ELSE 0 END), 0) AS spent_mc,
|
|
3695
|
-
COALESCE(SUM(CASE WHEN amount_mc > 0 AND operation IN (${refundPlaceholders}) THEN amount_mc ELSE 0 END), 0) AS refunded_mc,
|
|
3696
|
-
COALESCE(SUM(CASE WHEN amount_mc > 0 AND operation IN (${grantPlaceholders}) THEN amount_mc ELSE 0 END), 0) AS granted_mc,
|
|
3697
|
-
COALESCE(SUM(CASE WHEN amount_mc > 0 AND operation IN (${purchasePlaceholders}) THEN amount_mc ELSE 0 END), 0) AS purchased_mc,
|
|
3698
|
-
COALESCE(SUM(amount_mc), 0) AS net_mc
|
|
3699
|
-
FROM ledger WHERE user_id = ? AND created_at >= ?`,
|
|
3700
|
-
args: [
|
|
3701
|
-
...classification.refund,
|
|
3702
|
-
...classification.refund,
|
|
3703
|
-
...classification.grant,
|
|
3704
|
-
...classification.purchase,
|
|
3705
|
-
userId,
|
|
3706
|
-
since
|
|
3707
|
-
]
|
|
3708
|
-
});
|
|
3709
|
-
const byOpRes = await db.execute({
|
|
3710
|
-
sql: `SELECT operation, -SUM(amount_mc) AS mc, COUNT(*) AS count
|
|
3711
|
-
FROM ledger
|
|
3712
|
-
WHERE user_id = ? AND created_at >= ? AND amount_mc < 0 AND operation NOT IN (${refundPlaceholders})
|
|
3713
|
-
GROUP BY operation
|
|
3714
|
-
ORDER BY mc DESC, operation ASC`,
|
|
3715
|
-
args: [userId, since, ...classification.refund]
|
|
3716
|
-
});
|
|
3717
|
-
const totals = totalsRes.rows[0];
|
|
3718
|
-
return {
|
|
3719
|
-
spent_mc: Number(totals?.spent_mc ?? 0),
|
|
3720
|
-
refunded_mc: Number(totals?.refunded_mc ?? 0),
|
|
3721
|
-
granted_mc: Number(totals?.granted_mc ?? 0),
|
|
3722
|
-
purchased_mc: Number(totals?.purchased_mc ?? 0),
|
|
3723
|
-
net_mc: Number(totals?.net_mc ?? 0),
|
|
3724
|
-
by_operation: byOpRes.rows.map((r) => ({
|
|
3725
|
-
operation: String(r.operation),
|
|
3726
|
-
mc: Number(r.mc),
|
|
3727
|
-
count: Number(r.count)
|
|
3728
|
-
}))
|
|
3729
|
-
};
|
|
3730
|
-
}
|
|
3731
|
-
async function ledgerExistsForStripePI(stripePI) {
|
|
3732
|
-
const res = await getDb().execute({
|
|
3733
|
-
sql: "SELECT 1 FROM ledger WHERE stripe_pi = ? LIMIT 1",
|
|
3734
|
-
args: [stripePI]
|
|
3735
|
-
});
|
|
3736
|
-
return res.rows.length > 0;
|
|
3737
|
-
}
|
|
3738
|
-
async function reconcileBalanceMc(userId, knownBalanceMc) {
|
|
3739
|
-
const db = getDb();
|
|
3740
|
-
void knownBalanceMc;
|
|
3741
|
-
for (let attempt = 0; attempt < 8; attempt += 1) {
|
|
3742
|
-
const snapshot = await db.execute({
|
|
3743
|
-
sql: `SELECT
|
|
3744
|
-
u.balance_mc AS cached_balance_mc,
|
|
3745
|
-
COUNT(l.id) AS lot_count,
|
|
3746
|
-
COALESCE(SUM(CASE
|
|
3747
|
-
WHEN l.remaining_mc > 0
|
|
3748
|
-
AND (l.never_expires = 1 OR l.expires_at > datetime('now'))
|
|
3749
|
-
THEN l.remaining_mc ELSE 0 END), 0) AS projected_balance_mc
|
|
3750
|
-
FROM users u
|
|
3751
|
-
LEFT JOIN credit_lots l ON l.user_id = u.id
|
|
3752
|
-
WHERE u.id = ?
|
|
3753
|
-
GROUP BY u.id, u.balance_mc`,
|
|
3754
|
-
args: [userId]
|
|
3755
|
-
});
|
|
3756
|
-
const row = snapshot.rows[0];
|
|
3757
|
-
if (!row) return 0;
|
|
3758
|
-
if (Number(row.lot_count ?? 0) === 0) {
|
|
3759
|
-
await ensureMigrated(Number(userId));
|
|
3760
|
-
const nowHasLots = await db.execute({
|
|
3761
|
-
sql: "SELECT 1 FROM credit_lots WHERE user_id = ? LIMIT 1",
|
|
3762
|
-
args: [userId]
|
|
3763
|
-
});
|
|
3764
|
-
if (nowHasLots.rows.length > 0) continue;
|
|
3765
|
-
}
|
|
3766
|
-
const cachedBalanceMc = Number(row.cached_balance_mc ?? 0);
|
|
3767
|
-
const projectedBalanceMc = Number(row.projected_balance_mc ?? 0);
|
|
3768
|
-
if (cachedBalanceMc === projectedBalanceMc) return projectedBalanceMc;
|
|
3769
|
-
const updated = await db.execute({
|
|
3770
|
-
sql: "UPDATE users SET balance_mc = ? WHERE id = ? AND balance_mc = ?",
|
|
3771
|
-
args: [projectedBalanceMc, userId, cachedBalanceMc]
|
|
3772
|
-
});
|
|
3773
|
-
if (updated.rowsAffected === 1) return projectedBalanceMc;
|
|
3774
|
-
}
|
|
3775
|
-
const current = await db.execute({
|
|
3776
|
-
sql: "SELECT balance_mc FROM users WHERE id = ? LIMIT 1",
|
|
3777
|
-
args: [userId]
|
|
3778
|
-
});
|
|
3779
|
-
return Number(current.rows[0]?.balance_mc ?? 0);
|
|
3780
|
-
}
|
|
3781
|
-
var SiteAuditJobRowSchema = z.object({
|
|
3782
|
-
id: z.string(),
|
|
3783
|
-
user_id: z.number(),
|
|
3784
|
-
status: z.string().default("pending"),
|
|
3785
|
-
client_domain: z.string(),
|
|
3786
|
-
session_path: z.string(),
|
|
3787
|
-
request: z.string(),
|
|
3788
|
-
result: z.string().nullable(),
|
|
3789
|
-
error: z.string().nullable(),
|
|
3790
|
-
created_at: z.string(),
|
|
3791
|
-
updated_at: z.string()
|
|
3792
|
-
});
|
|
3793
|
-
var SiteAuditPhaseLogRowSchema = z.object({
|
|
3794
|
-
id: z.number(),
|
|
3795
|
-
job_id: z.string(),
|
|
3796
|
-
phase: z.string(),
|
|
3797
|
-
completed_at: z.string(),
|
|
3798
|
-
output_summary: z.string()
|
|
3799
|
-
});
|
|
3800
|
-
async function listInboxMessages(userId) {
|
|
3801
|
-
const res = await getDb().execute({
|
|
3802
|
-
sql: "SELECT id, subject, sent_at, opened_at FROM inbox_messages WHERE user_id = ? AND deleted_at IS NULL ORDER BY sent_at DESC, id DESC",
|
|
3803
|
-
args: [userId]
|
|
3804
|
-
});
|
|
3805
|
-
return res.rows.map((r) => ({
|
|
3806
|
-
id: String(r.id),
|
|
3807
|
-
subject: String(r.subject),
|
|
3808
|
-
sent_at: String(r.sent_at),
|
|
3809
|
-
opened_at: r.opened_at != null ? String(r.opened_at) : null
|
|
3810
|
-
}));
|
|
3811
|
-
}
|
|
3812
|
-
async function getInboxMessage(userId, id) {
|
|
3813
|
-
const res = await getDb().execute({
|
|
3814
|
-
sql: "SELECT id, subject, body_md, source, sent_at, opened_at FROM inbox_messages WHERE id = ? AND user_id = ? AND deleted_at IS NULL",
|
|
3815
|
-
args: [id, userId]
|
|
3816
|
-
});
|
|
3817
|
-
const row = res.rows[0];
|
|
3818
|
-
if (!row) return void 0;
|
|
3819
|
-
return {
|
|
3820
|
-
id: String(row.id),
|
|
3821
|
-
subject: String(row.subject),
|
|
3822
|
-
body_md: String(row.body_md),
|
|
3823
|
-
source: String(row.source),
|
|
3824
|
-
sent_at: String(row.sent_at),
|
|
3825
|
-
opened_at: row.opened_at != null ? String(row.opened_at) : null
|
|
3826
|
-
};
|
|
3827
|
-
}
|
|
3828
|
-
async function markInboxMessageOpened(userId, id) {
|
|
3829
|
-
const db = getDb();
|
|
3830
|
-
await db.execute({
|
|
3831
|
-
sql: "UPDATE inbox_messages SET opened_at = datetime('now') WHERE id = ? AND user_id = ? AND deleted_at IS NULL AND opened_at IS NULL",
|
|
3832
|
-
args: [id, userId]
|
|
3833
|
-
});
|
|
3834
|
-
const res = await db.execute({
|
|
3835
|
-
sql: "SELECT opened_at FROM inbox_messages WHERE id = ? AND user_id = ? AND deleted_at IS NULL",
|
|
3836
|
-
args: [id, userId]
|
|
3837
|
-
});
|
|
3838
|
-
const row = res.rows[0];
|
|
3839
|
-
if (!row) return null;
|
|
3840
|
-
return row.opened_at != null ? String(row.opened_at) : null;
|
|
3841
|
-
}
|
|
3842
|
-
async function softDeleteInboxMessage(userId, id) {
|
|
3843
|
-
const res = await getDb().execute({
|
|
3844
|
-
sql: "UPDATE inbox_messages SET deleted_at = datetime('now') WHERE id = ? AND user_id = ? AND deleted_at IS NULL",
|
|
3845
|
-
args: [id, userId]
|
|
3846
|
-
});
|
|
3847
|
-
return res.rowsAffected > 0;
|
|
3848
|
-
}
|
|
3849
|
-
async function insertInboxMessage(userId, subject, bodyMd, source = "operator") {
|
|
3850
|
-
const id = randomUUID();
|
|
3851
|
-
await getDb().execute({
|
|
3852
|
-
sql: "INSERT INTO inbox_messages (id, user_id, subject, body_md, source) VALUES (?, ?, ?, ?, ?)",
|
|
3853
|
-
args: [id, userId, subject, bodyMd, source]
|
|
3854
|
-
});
|
|
3855
|
-
return id;
|
|
3856
|
-
}
|
|
3857
|
-
async function listActiveUserIds() {
|
|
3858
|
-
const res = await getDb().execute("SELECT id FROM users WHERE active = 1");
|
|
3859
|
-
return res.rows.map((r) => Number(r.id));
|
|
3860
|
-
}
|
|
3861
|
-
|
|
3862
|
-
export {
|
|
3863
|
-
RECAPTCHA_INSTRUCTIONS,
|
|
3864
|
-
PUBLIC_SERVICE_UNAVAILABLE_MESSAGE,
|
|
3865
|
-
PUBLIC_ERROR_CODES,
|
|
3866
|
-
serializePublicErrorEnvelope,
|
|
3867
|
-
parsePublicErrorEnvelope,
|
|
3868
|
-
normalizePublicErrorCode,
|
|
3869
|
-
publicErrorMessage,
|
|
3870
|
-
buildPublicErrorEnvelope,
|
|
3871
|
-
sanitizeVendorName,
|
|
3872
|
-
CaptchaError,
|
|
3873
|
-
ExtractionError,
|
|
3874
|
-
MapsSearchIncompletePageError,
|
|
3875
|
-
RequestAbortedError,
|
|
3876
|
-
PaaInteractionInterruptedError,
|
|
3877
|
-
LocationMismatchError,
|
|
3878
|
-
CREDIT_LOTS_EPOCH,
|
|
3879
|
-
CORE_SCHEMA_VERSION,
|
|
3880
|
-
SITE_EXTRACT_COUNTER_REPAIR_VERSION,
|
|
3881
|
-
getDb,
|
|
3882
|
-
setDbForTesting,
|
|
3883
|
-
migrate,
|
|
3884
|
-
registerClient,
|
|
3885
|
-
getClient,
|
|
3886
|
-
putCode,
|
|
3887
|
-
consumeCode,
|
|
3888
|
-
putRefresh,
|
|
3889
|
-
getRefresh,
|
|
3890
|
-
revokeRefresh,
|
|
3891
|
-
rotateRefresh,
|
|
3892
|
-
claimStripeEvent,
|
|
3893
|
-
completeStripeEvent,
|
|
3894
|
-
failStripeEvent,
|
|
3895
|
-
stripeEventAlreadyProcessed,
|
|
3896
|
-
generateApiKey,
|
|
3897
|
-
hashPassword,
|
|
3898
|
-
verifyPassword,
|
|
3899
|
-
getUserByEmail,
|
|
3900
|
-
getUserByApiKey,
|
|
3901
|
-
getUserById,
|
|
3902
|
-
getUserMemoryCreds,
|
|
3903
|
-
setUserMemoryCreds,
|
|
3904
|
-
setPassword,
|
|
3905
|
-
getUserStats,
|
|
3906
|
-
createUser,
|
|
3907
|
-
checkRateLimit,
|
|
3908
|
-
createPasswordResetToken,
|
|
3909
|
-
consumePasswordResetToken,
|
|
3910
|
-
rotateApiKey,
|
|
3911
|
-
revokeApiKey,
|
|
3912
|
-
listUsers,
|
|
3913
|
-
deactivateUser,
|
|
3914
|
-
createJob,
|
|
3915
|
-
createJobWithId,
|
|
3916
|
-
createRunningJob,
|
|
3917
|
-
getOrCreateRunningJob,
|
|
3918
|
-
startHarvestAttempt,
|
|
3919
|
-
attachHarvestAttemptProviderSession,
|
|
3920
|
-
finalizeExpiredHarvestAttempts,
|
|
3921
|
-
finishHarvestAttempt,
|
|
3922
|
-
updateHarvestAttemptProviderTelemetry,
|
|
3923
|
-
listHarvestAttemptsPendingProviderTelemetry,
|
|
3924
|
-
listHarvestAttempts,
|
|
3925
|
-
getJob,
|
|
3926
|
-
listJobs,
|
|
3927
|
-
getPageSnapshot,
|
|
3928
|
-
upsertPageSnapshot,
|
|
3929
|
-
logRequestEvent,
|
|
3930
|
-
listRequestEvents,
|
|
3931
|
-
createWorkflowSchedule,
|
|
3932
|
-
getWorkflowSchedule,
|
|
3933
|
-
listWorkflowSchedules,
|
|
3934
|
-
patchWorkflowSchedule,
|
|
3935
|
-
deleteWorkflowSchedule,
|
|
3936
|
-
listDueWorkflowSchedules,
|
|
3937
|
-
markWorkflowScheduleRan,
|
|
3938
|
-
createWorkflowRun,
|
|
3939
|
-
getWorkflowRun,
|
|
3940
|
-
getWorkflowRunByIdempotencyKey,
|
|
3941
|
-
listWorkflowRuns,
|
|
3942
|
-
markWorkflowRunRunning,
|
|
3943
|
-
completeWorkflowRunRecord,
|
|
3944
|
-
failWorkflowRunRecord,
|
|
3945
|
-
replaceWorkflowArtifacts,
|
|
3946
|
-
appendWorkflowArtifacts,
|
|
3947
|
-
getWorkflowRunStepState,
|
|
3948
|
-
advanceWorkflowRunStep,
|
|
3949
|
-
listWorkflowArtifacts,
|
|
3950
|
-
getWorkflowArtifact,
|
|
3951
|
-
recordWorkflowWebhookDelivery,
|
|
3952
|
-
countActiveUsers,
|
|
3953
|
-
countActiveJobsForUser,
|
|
3954
|
-
claimPendingJob,
|
|
3955
|
-
claimDurablePaaJob,
|
|
3956
|
-
checkpointJob,
|
|
3957
|
-
checkpointDurablePaaJob,
|
|
3958
|
-
renewDurablePaaLease,
|
|
3959
|
-
completeDurablePaaJob,
|
|
3960
|
-
failDurablePaaJob,
|
|
3961
|
-
requeueExpiredDurablePaaJob,
|
|
3962
|
-
completeExpiredDurablePaaJob,
|
|
3963
|
-
failExpiredDurablePaaJob,
|
|
3964
|
-
listRecentTerminalDurablePaaJobs,
|
|
3965
|
-
listStaleRunningDurablePaaJobs,
|
|
3966
|
-
listRecoverableDurablePaaJobs,
|
|
3967
|
-
markJobBillingSettled,
|
|
3968
|
-
completeJob,
|
|
3969
|
-
completeRunningJob,
|
|
3970
|
-
failJob,
|
|
3971
|
-
failRunningJob,
|
|
3972
|
-
cancelJob,
|
|
3973
|
-
createKpoJob,
|
|
3974
|
-
claimPendingKpoJob,
|
|
3975
|
-
getKpoJob,
|
|
3976
|
-
updateKpoJobState,
|
|
3977
|
-
completeKpoJob,
|
|
3978
|
-
failKpoJob,
|
|
3979
|
-
logKpoPhaseComplete,
|
|
3980
|
-
getKpoPhaseLog,
|
|
3981
|
-
listKpoJobs,
|
|
3982
|
-
getUserByStripeCustomerId,
|
|
3983
|
-
setStripeCustomerId,
|
|
3984
|
-
setExtraConcurrencySlots,
|
|
3985
|
-
setConcurrencyPackQuantity,
|
|
3986
|
-
setConcurrencySubId,
|
|
3987
|
-
setSubscriptionTier,
|
|
3988
|
-
getConnectedAccountBillingState,
|
|
3989
|
-
setConnectedAccountBillingState,
|
|
3990
|
-
listUsersDueForConnectedAccountBillingReconciliation,
|
|
3991
|
-
setMemoryPlan,
|
|
3992
|
-
getMemoryPlan,
|
|
3993
|
-
getMemoryProvisioned,
|
|
3994
|
-
setMemoryProvisioned,
|
|
3995
|
-
resolveUserByMemorySubscription,
|
|
3996
|
-
CREDIT_LOT_TTL,
|
|
3997
|
-
expireOldLots,
|
|
3998
|
-
migrateExistingBalancesToLots,
|
|
3999
|
-
creditMc,
|
|
4000
|
-
creditMcIdempotent,
|
|
4001
|
-
ledgerExistsForOperation,
|
|
4002
|
-
claimMonthlyFreeRefresh,
|
|
4003
|
-
debitMc,
|
|
4004
|
-
debitMcIdempotent,
|
|
4005
|
-
settleDebitMcIdempotent,
|
|
4006
|
-
getLedger,
|
|
4007
|
-
getBillingLedgerSummary,
|
|
4008
|
-
ledgerExistsForStripePI,
|
|
4009
|
-
reconcileBalanceMc,
|
|
4010
|
-
SiteAuditJobRowSchema,
|
|
4011
|
-
SiteAuditPhaseLogRowSchema,
|
|
4012
|
-
listInboxMessages,
|
|
4013
|
-
getInboxMessage,
|
|
4014
|
-
markInboxMessageOpened,
|
|
4015
|
-
softDeleteInboxMessage,
|
|
4016
|
-
insertInboxMessage,
|
|
4017
|
-
listActiveUserIds
|
|
4018
|
-
};
|