@corgelabs/corge 0.0.1 → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (341) hide show
  1. package/dist/ai/index.d.ts +3 -0
  2. package/dist/ai/index.d.ts.map +1 -0
  3. package/dist/ai/index.js +3 -0
  4. package/dist/ai/index.js.map +1 -0
  5. package/dist/ai/schema.d.ts +83 -0
  6. package/dist/ai/schema.d.ts.map +1 -0
  7. package/dist/ai/schema.js +19 -0
  8. package/dist/ai/schema.js.map +1 -0
  9. package/dist/ai/service.d.ts +14 -0
  10. package/dist/ai/service.d.ts.map +1 -0
  11. package/dist/ai/service.js +266 -0
  12. package/dist/ai/service.js.map +1 -0
  13. package/dist/ai/utils.d.ts +10 -0
  14. package/dist/ai/utils.d.ts.map +1 -0
  15. package/dist/ai/utils.js +27 -0
  16. package/dist/ai/utils.js.map +1 -0
  17. package/dist/attachments/index.d.ts +4 -0
  18. package/dist/attachments/index.d.ts.map +1 -0
  19. package/dist/attachments/index.js +4 -0
  20. package/dist/attachments/index.js.map +1 -0
  21. package/dist/attachments/routes.d.ts +75 -0
  22. package/dist/attachments/routes.d.ts.map +1 -0
  23. package/dist/attachments/routes.js +18 -0
  24. package/dist/attachments/routes.js.map +1 -0
  25. package/dist/attachments/schema.d.ts +61 -0
  26. package/dist/attachments/schema.d.ts.map +1 -0
  27. package/dist/attachments/schema.js +30 -0
  28. package/dist/attachments/schema.js.map +1 -0
  29. package/dist/attachments/service.d.ts +27 -0
  30. package/dist/attachments/service.d.ts.map +1 -0
  31. package/dist/attachments/service.js +224 -0
  32. package/dist/attachments/service.js.map +1 -0
  33. package/dist/attachments/utils.d.ts +9 -0
  34. package/dist/attachments/utils.d.ts.map +1 -0
  35. package/dist/attachments/utils.js +80 -0
  36. package/dist/attachments/utils.js.map +1 -0
  37. package/dist/auth/index.d.ts +4 -0
  38. package/dist/auth/index.d.ts.map +1 -0
  39. package/dist/auth/index.js +4 -0
  40. package/dist/auth/index.js.map +1 -0
  41. package/dist/auth/oidc.d.ts +25 -0
  42. package/dist/auth/oidc.d.ts.map +1 -0
  43. package/dist/auth/oidc.js +298 -0
  44. package/dist/auth/oidc.js.map +1 -0
  45. package/dist/auth/recovery.d.ts +41 -0
  46. package/dist/auth/recovery.d.ts.map +1 -0
  47. package/dist/auth/recovery.js +250 -0
  48. package/dist/auth/recovery.js.map +1 -0
  49. package/dist/auth/routes.d.ts +226 -0
  50. package/dist/auth/routes.d.ts.map +1 -0
  51. package/dist/auth/routes.js +96 -0
  52. package/dist/auth/routes.js.map +1 -0
  53. package/dist/auth/schema.d.ts +158 -0
  54. package/dist/auth/schema.d.ts.map +1 -0
  55. package/dist/auth/schema.js +82 -0
  56. package/dist/auth/schema.js.map +1 -0
  57. package/dist/auth/service.d.ts +96 -0
  58. package/dist/auth/service.d.ts.map +1 -0
  59. package/dist/auth/service.js +129 -0
  60. package/dist/auth/service.js.map +1 -0
  61. package/dist/auth/session.d.ts +51 -0
  62. package/dist/auth/session.d.ts.map +1 -0
  63. package/dist/auth/session.js +344 -0
  64. package/dist/auth/session.js.map +1 -0
  65. package/dist/auth/utils.d.ts +17 -0
  66. package/dist/auth/utils.d.ts.map +1 -0
  67. package/dist/auth/utils.js +96 -0
  68. package/dist/auth/utils.js.map +1 -0
  69. package/dist/bin/corge.js +542 -0
  70. package/dist/chat/context.d.ts +10 -0
  71. package/dist/chat/context.d.ts.map +1 -0
  72. package/dist/chat/context.js +221 -0
  73. package/dist/chat/context.js.map +1 -0
  74. package/dist/chat/index.d.ts +4 -0
  75. package/dist/chat/index.d.ts.map +1 -0
  76. package/dist/chat/index.js +4 -0
  77. package/dist/chat/index.js.map +1 -0
  78. package/dist/chat/routes.d.ts +160 -0
  79. package/dist/chat/routes.d.ts.map +1 -0
  80. package/dist/chat/routes.js +210 -0
  81. package/dist/chat/routes.js.map +1 -0
  82. package/dist/chat/schema.d.ts +285 -0
  83. package/dist/chat/schema.d.ts.map +1 -0
  84. package/dist/chat/schema.js +74 -0
  85. package/dist/chat/schema.js.map +1 -0
  86. package/dist/chat/service.d.ts +64 -0
  87. package/dist/chat/service.d.ts.map +1 -0
  88. package/dist/chat/service.js +279 -0
  89. package/dist/chat/service.js.map +1 -0
  90. package/dist/chat/stream.d.ts +53 -0
  91. package/dist/chat/stream.d.ts.map +1 -0
  92. package/dist/chat/stream.js +339 -0
  93. package/dist/chat/stream.js.map +1 -0
  94. package/dist/chat/utils.d.ts +61 -0
  95. package/dist/chat/utils.d.ts.map +1 -0
  96. package/dist/chat/utils.js +285 -0
  97. package/dist/chat/utils.js.map +1 -0
  98. package/dist/cli/builder.d.ts +6 -0
  99. package/dist/cli/builder.d.ts.map +1 -0
  100. package/dist/cli/builder.js +91 -0
  101. package/dist/cli/builder.js.map +1 -0
  102. package/dist/cli/deployer.d.ts +2 -0
  103. package/dist/cli/deployer.d.ts.map +1 -0
  104. package/dist/cli/deployer.js +152 -0
  105. package/dist/cli/deployer.js.map +1 -0
  106. package/dist/cli/dev-server.d.ts +2 -0
  107. package/dist/cli/dev-server.d.ts.map +1 -0
  108. package/dist/cli/dev-server.js +70 -0
  109. package/dist/cli/dev-server.js.map +1 -0
  110. package/dist/cli/env.d.ts +4 -0
  111. package/dist/cli/env.d.ts.map +1 -0
  112. package/dist/cli/env.js +68 -0
  113. package/dist/cli/env.js.map +1 -0
  114. package/dist/cli/init.d.ts +6 -0
  115. package/dist/cli/init.d.ts.map +1 -0
  116. package/dist/cli/init.js +97 -0
  117. package/dist/cli/init.js.map +1 -0
  118. package/dist/config.d.ts +262 -0
  119. package/dist/config.d.ts.map +1 -0
  120. package/dist/config.js +212 -0
  121. package/dist/config.js.map +1 -0
  122. package/dist/db/index.d.ts +3 -0
  123. package/dist/db/index.d.ts.map +1 -0
  124. package/dist/db/index.js +3 -0
  125. package/dist/db/index.js.map +1 -0
  126. package/dist/db/schema.d.ts +410 -0
  127. package/dist/db/schema.d.ts.map +1 -0
  128. package/dist/db/schema.js +294 -0
  129. package/dist/db/schema.js.map +1 -0
  130. package/dist/db/service.d.ts +42 -0
  131. package/dist/db/service.d.ts.map +1 -0
  132. package/dist/db/service.js +372 -0
  133. package/dist/db/service.js.map +1 -0
  134. package/dist/db/utils.d.ts +25 -0
  135. package/dist/db/utils.d.ts.map +1 -0
  136. package/dist/db/utils.js +188 -0
  137. package/dist/db/utils.js.map +1 -0
  138. package/dist/emails/index.d.ts +4 -0
  139. package/dist/emails/index.d.ts.map +1 -0
  140. package/dist/emails/index.js +4 -0
  141. package/dist/emails/index.js.map +1 -0
  142. package/dist/emails/routes.d.ts +191 -0
  143. package/dist/emails/routes.d.ts.map +1 -0
  144. package/dist/emails/routes.js +39 -0
  145. package/dist/emails/routes.js.map +1 -0
  146. package/dist/emails/schema.d.ts +237 -0
  147. package/dist/emails/schema.d.ts.map +1 -0
  148. package/dist/emails/schema.js +98 -0
  149. package/dist/emails/schema.js.map +1 -0
  150. package/dist/emails/service.d.ts +22 -0
  151. package/dist/emails/service.d.ts.map +1 -0
  152. package/dist/emails/service.js +337 -0
  153. package/dist/emails/service.js.map +1 -0
  154. package/dist/emails/utils.d.ts +49 -0
  155. package/dist/emails/utils.d.ts.map +1 -0
  156. package/dist/emails/utils.js +341 -0
  157. package/dist/emails/utils.js.map +1 -0
  158. package/dist/files/index.d.ts +4 -0
  159. package/dist/files/index.d.ts.map +1 -0
  160. package/dist/files/index.js +4 -0
  161. package/dist/files/index.js.map +1 -0
  162. package/dist/files/routes.d.ts +72 -0
  163. package/dist/files/routes.d.ts.map +1 -0
  164. package/dist/files/routes.js +38 -0
  165. package/dist/files/routes.js.map +1 -0
  166. package/dist/files/schema.d.ts +57 -0
  167. package/dist/files/schema.d.ts.map +1 -0
  168. package/dist/files/schema.js +39 -0
  169. package/dist/files/schema.js.map +1 -0
  170. package/dist/files/service.d.ts +27 -0
  171. package/dist/files/service.d.ts.map +1 -0
  172. package/dist/files/service.js +175 -0
  173. package/dist/files/service.js.map +1 -0
  174. package/dist/files/utils.d.ts +3 -0
  175. package/dist/files/utils.d.ts.map +1 -0
  176. package/dist/files/utils.js +63 -0
  177. package/dist/files/utils.js.map +1 -0
  178. package/dist/index.d.ts +875 -0
  179. package/dist/index.d.ts.map +1 -0
  180. package/dist/index.js +9919 -0
  181. package/dist/index.js.map +7 -0
  182. package/dist/payments/billing.d.ts +29 -0
  183. package/dist/payments/billing.d.ts.map +1 -0
  184. package/dist/payments/billing.js +378 -0
  185. package/dist/payments/billing.js.map +1 -0
  186. package/dist/payments/index.d.ts +4 -0
  187. package/dist/payments/index.d.ts.map +1 -0
  188. package/dist/payments/index.js +4 -0
  189. package/dist/payments/index.js.map +1 -0
  190. package/dist/payments/routes.d.ts +340 -0
  191. package/dist/payments/routes.d.ts.map +1 -0
  192. package/dist/payments/routes.js +56 -0
  193. package/dist/payments/routes.js.map +1 -0
  194. package/dist/payments/schema.d.ts +412 -0
  195. package/dist/payments/schema.d.ts.map +1 -0
  196. package/dist/payments/schema.js +260 -0
  197. package/dist/payments/schema.js.map +1 -0
  198. package/dist/payments/service.d.ts +31 -0
  199. package/dist/payments/service.d.ts.map +1 -0
  200. package/dist/payments/service.js +322 -0
  201. package/dist/payments/service.js.map +1 -0
  202. package/dist/payments/utils.d.ts +61 -0
  203. package/dist/payments/utils.d.ts.map +1 -0
  204. package/dist/payments/utils.js +369 -0
  205. package/dist/payments/utils.js.map +1 -0
  206. package/dist/router/guard.d.ts +19 -0
  207. package/dist/router/guard.d.ts.map +1 -0
  208. package/dist/router/guard.js +243 -0
  209. package/dist/router/guard.js.map +1 -0
  210. package/dist/router/index.d.ts +3 -0
  211. package/dist/router/index.d.ts.map +1 -0
  212. package/dist/router/index.js +3 -0
  213. package/dist/router/index.js.map +1 -0
  214. package/dist/router/schema.d.ts +103 -0
  215. package/dist/router/schema.d.ts.map +1 -0
  216. package/dist/router/schema.js +282 -0
  217. package/dist/router/schema.js.map +1 -0
  218. package/dist/router/service.d.ts +25 -0
  219. package/dist/router/service.d.ts.map +1 -0
  220. package/dist/router/service.js +339 -0
  221. package/dist/router/service.js.map +1 -0
  222. package/dist/tasks/index.d.ts +4 -0
  223. package/dist/tasks/index.d.ts.map +1 -0
  224. package/dist/tasks/index.js +4 -0
  225. package/dist/tasks/index.js.map +1 -0
  226. package/dist/tasks/routes.d.ts +54 -0
  227. package/dist/tasks/routes.d.ts.map +1 -0
  228. package/dist/tasks/routes.js +195 -0
  229. package/dist/tasks/routes.js.map +1 -0
  230. package/dist/tasks/schema.d.ts +261 -0
  231. package/dist/tasks/schema.d.ts.map +1 -0
  232. package/dist/tasks/schema.js +35 -0
  233. package/dist/tasks/schema.js.map +1 -0
  234. package/dist/tasks/service.d.ts +32 -0
  235. package/dist/tasks/service.d.ts.map +1 -0
  236. package/dist/tasks/service.js +201 -0
  237. package/dist/tasks/service.js.map +1 -0
  238. package/dist/tasks/steps.d.ts +18 -0
  239. package/dist/tasks/steps.d.ts.map +1 -0
  240. package/dist/tasks/steps.js +365 -0
  241. package/dist/tasks/steps.js.map +1 -0
  242. package/dist/tasks/utils.d.ts +75 -0
  243. package/dist/tasks/utils.d.ts.map +1 -0
  244. package/dist/tasks/utils.js +311 -0
  245. package/dist/tasks/utils.js.map +1 -0
  246. package/dist/tasks/watcher.d.ts +13 -0
  247. package/dist/tasks/watcher.d.ts.map +1 -0
  248. package/dist/tasks/watcher.js +255 -0
  249. package/dist/tasks/watcher.js.map +1 -0
  250. package/dist/tasks/worker.d.ts +28 -0
  251. package/dist/tasks/worker.d.ts.map +1 -0
  252. package/dist/tasks/worker.js +326 -0
  253. package/dist/tasks/worker.js.map +1 -0
  254. package/dist/utils/index.d.ts +3 -0
  255. package/dist/utils/index.d.ts.map +1 -0
  256. package/dist/utils/index.js +3 -0
  257. package/dist/utils/index.js.map +1 -0
  258. package/dist/utils/schema.d.ts +79 -0
  259. package/dist/utils/schema.d.ts.map +1 -0
  260. package/dist/utils/schema.js +41 -0
  261. package/dist/utils/schema.js.map +1 -0
  262. package/dist/utils/service.d.ts +32 -0
  263. package/dist/utils/service.d.ts.map +1 -0
  264. package/dist/utils/service.js +384 -0
  265. package/dist/utils/service.js.map +1 -0
  266. package/docs/SDK_SPEC.md +1645 -0
  267. package/infra/firestore.base.indexes.json +138 -0
  268. package/infra/firestore.base.rules +152 -0
  269. package/infra/storage.base.rules +8 -0
  270. package/package.json +58 -13
  271. package/src/ai/index.ts +2 -0
  272. package/src/ai/schema.ts +64 -0
  273. package/src/ai/service.ts +317 -0
  274. package/src/ai/utils.ts +38 -0
  275. package/src/attachments/index.ts +3 -0
  276. package/src/attachments/routes.ts +28 -0
  277. package/src/attachments/schema.ts +45 -0
  278. package/src/attachments/service.ts +315 -0
  279. package/src/attachments/utils.ts +115 -0
  280. package/src/auth/index.ts +3 -0
  281. package/src/auth/oidc.ts +419 -0
  282. package/src/auth/recovery.ts +365 -0
  283. package/src/auth/routes.ts +142 -0
  284. package/src/auth/schema.ts +181 -0
  285. package/src/auth/service.ts +183 -0
  286. package/src/auth/session.ts +414 -0
  287. package/src/auth/utils.ts +106 -0
  288. package/src/chat/context.ts +310 -0
  289. package/src/chat/index.ts +3 -0
  290. package/src/chat/routes.ts +264 -0
  291. package/src/chat/schema.ts +295 -0
  292. package/src/chat/service.ts +360 -0
  293. package/src/chat/stream.ts +481 -0
  294. package/src/chat/utils.ts +370 -0
  295. package/src/cli/builder.ts +93 -0
  296. package/src/cli/deployer.ts +184 -0
  297. package/src/cli/dev-server.ts +77 -0
  298. package/src/cli/env.ts +89 -0
  299. package/src/cli/init.ts +121 -0
  300. package/src/config.ts +447 -0
  301. package/src/db/index.ts +2 -0
  302. package/src/db/schema.ts +409 -0
  303. package/src/db/service.ts +477 -0
  304. package/src/db/utils.ts +250 -0
  305. package/src/emails/index.ts +3 -0
  306. package/src/emails/routes.ts +64 -0
  307. package/src/emails/schema.ts +239 -0
  308. package/src/emails/service.ts +488 -0
  309. package/src/emails/utils.ts +449 -0
  310. package/src/files/index.ts +3 -0
  311. package/src/files/routes.ts +44 -0
  312. package/src/files/schema.ts +76 -0
  313. package/src/files/service.ts +238 -0
  314. package/src/files/utils.ts +77 -0
  315. package/src/index.ts +425 -0
  316. package/src/payments/billing.ts +488 -0
  317. package/src/payments/index.ts +3 -0
  318. package/src/payments/routes.ts +92 -0
  319. package/src/payments/schema.ts +401 -0
  320. package/src/payments/service.ts +445 -0
  321. package/src/payments/utils.ts +472 -0
  322. package/src/router/guard.ts +319 -0
  323. package/src/router/index.ts +2 -0
  324. package/src/router/schema.ts +458 -0
  325. package/src/router/service.ts +453 -0
  326. package/src/tasks/index.ts +11 -0
  327. package/src/tasks/routes.ts +230 -0
  328. package/src/tasks/schema.ts +310 -0
  329. package/src/tasks/service.ts +254 -0
  330. package/src/tasks/steps.ts +472 -0
  331. package/src/tasks/utils.ts +445 -0
  332. package/src/tasks/watcher.ts +307 -0
  333. package/src/tasks/worker.ts +461 -0
  334. package/src/utils/index.ts +2 -0
  335. package/src/utils/schema.ts +69 -0
  336. package/src/utils/service.ts +461 -0
  337. package/tests/e2e.test.ts +2531 -0
  338. package/tsconfig.base.json +12 -0
  339. package/README.md +0 -2
  340. package/index.d.ts +0 -1
  341. package/index.js +0 -1
@@ -0,0 +1,461 @@
1
+ import { HTTPException } from 'hono/http-exception'
2
+ import type { ContentfulStatusCode } from 'hono/utils/http-status'
3
+ import { ZodError } from 'zod'
4
+ import { CORE_CONFIG } from '../config'
5
+ import type { Core } from '../index'
6
+ import {
7
+ CircuitBreakerOpenError,
8
+ errorBodySchema,
9
+ NonRetryableError,
10
+ StaleWorkerEpochError,
11
+ type Bucket,
12
+ type CircuitBreakerKey,
13
+ type CircuitBreakerRecord,
14
+ type CircuitBreakerStateName,
15
+ type Duration,
16
+ type ErrorBody,
17
+ type SingleflightOptions
18
+ } from './schema'
19
+
20
+ const DURATION_UNITS: Record<string, number> = {
21
+ ms: 1,
22
+ s: 1_000,
23
+ m: 60_000,
24
+ h: 3_600_000,
25
+ d: 86_400_000
26
+ }
27
+
28
+ export class ErrorsService {
29
+ readonly NonRetryableError = NonRetryableError
30
+ readonly StaleWorkerEpochError = StaleWorkerEpochError
31
+ readonly CircuitBreakerOpenError = CircuitBreakerOpenError
32
+
33
+ parseDuration = (d?: Duration, fallbackMs = 0): number => {
34
+ if (d === undefined || d === null) return fallbackMs
35
+ if (typeof d === 'number') return Math.max(0, d)
36
+ const m = /^(\d+(?:\.\d+)?)(ms|s|m|h|d)$/.exec(d.trim())
37
+ if (!m) throw new Error(`Invalid duration: ${d}`)
38
+ return Math.round(Number(m[1]) * (DURATION_UNITS[m[2]!] ?? 1))
39
+ }
40
+
41
+ buildErrorBody = (error: string, status?: number, code?: string, retryAfterMs?: number): ErrorBody =>
42
+ errorBodySchema.parse({
43
+ error,
44
+ ...(code ? { code } : {}),
45
+ ...(status ? { status } : {}),
46
+ ...(retryAfterMs !== undefined ? { retryAfterMs: Math.ceil(retryAfterMs) } : {})
47
+ })
48
+
49
+ isStaleWorkerEpochError = (err: unknown): err is StaleWorkerEpochError =>
50
+ err instanceof StaleWorkerEpochError ||
51
+ Boolean(
52
+ err &&
53
+ typeof err === 'object' &&
54
+ ((err as { isStaleWorkerEpoch?: unknown }).isStaleWorkerEpoch === true ||
55
+ (err as { name?: unknown }).name === 'StaleWorkerEpochError')
56
+ )
57
+
58
+ nonRetryableError = (message: string, code?: string): never => {
59
+ throw new NonRetryableError(message, code)
60
+ }
61
+
62
+ #jsonErr = (status: ContentfulStatusCode, error: string, code?: string, retryAfterMs?: number): HTTPException => {
63
+ const headers: Record<string, string> = { 'content-type': 'application/json' }
64
+ if (retryAfterMs !== undefined && retryAfterMs > 0) {
65
+ headers['retry-after'] = String(Math.max(1, Math.ceil(retryAfterMs / 1000)))
66
+ }
67
+ return new HTTPException(status, {
68
+ res: new Response(JSON.stringify(this.buildErrorBody(error, status, code, retryAfterMs)), { status, headers })
69
+ })
70
+ }
71
+
72
+ httpError = (status: ContentfulStatusCode, message: string, code?: string, retryAfterMs?: number): never => {
73
+ throw this.#jsonErr(status, message, code, retryAfterMs)
74
+ }
75
+
76
+ isNonRetryableError = (err: unknown): boolean => {
77
+ if (err instanceof NonRetryableError || err instanceof ZodError) return true
78
+ if (err instanceof HTTPException && err.status < 500) return true
79
+ if (
80
+ err &&
81
+ typeof err === 'object' &&
82
+ ('nonRetryable' in err || ('code' in err && (err.code === 'NOT_FOUND' || err.code === 5)))
83
+ ) {
84
+ return true
85
+ }
86
+ const msg = err instanceof Error ? err.message : String(err)
87
+ return (
88
+ msg.startsWith('File not found:') ||
89
+ msg.startsWith('Not found:') ||
90
+ msg.includes('NOT_FOUND') ||
91
+ msg.includes('No document to update')
92
+ )
93
+ }
94
+
95
+ toHttpError = (err: unknown): HTTPException => {
96
+ if (err instanceof HTTPException) return err
97
+ if (err instanceof ZodError) {
98
+ return this.#jsonErr(
99
+ 400,
100
+ err.issues.map((i) => `${i.path.join('.') || 'input'}: ${i.message}`).join('; '),
101
+ 'VALIDATION_ERROR'
102
+ )
103
+ }
104
+ if (err instanceof NonRetryableError) {
105
+ return this.#jsonErr(400, err.message, err.code ?? 'NON_RETRYABLE_ERROR')
106
+ }
107
+ const msg = err instanceof Error ? err.message : ''
108
+ if (msg.startsWith('File not found:') || msg.startsWith('Not found:')) {
109
+ return this.#jsonErr(404, msg, 'NOT_FOUND')
110
+ }
111
+ console.error('[http] unhandled error:', err)
112
+ return this.#jsonErr(500, 'Internal server error', 'INTERNAL_SERVER_ERROR')
113
+ }
114
+ }
115
+
116
+ class RateLimiter {
117
+ #slots: number[] = []
118
+ #maxOverride?: number
119
+ #remoteRemaining?: number
120
+ #remoteResetAt = 0
121
+ readonly #bucket: Bucket
122
+ readonly #getMaxWaitMs: () => number
123
+
124
+ constructor(bucket: Bucket, getMaxWaitMs: () => number) {
125
+ this.#bucket = bucket
126
+ this.#getMaxWaitMs = getMaxWaitMs
127
+ }
128
+
129
+ #cfg(now = Date.now()) {
130
+ const base = CORE_CONFIG.limits.buckets[this.#bucket]
131
+ const windowMs = this.#bucket === 'mistral' ? base.windowMs + 3000 : base.windowMs
132
+ const max = this.#maxOverride ?? base.max
133
+ const safeMax = this.#bucket === 'mistral' ? Math.max(1, max - 5) : max
134
+ this.#slots = this.#slots.filter((at) => at > now - windowMs)
135
+ if (now >= this.#remoteResetAt) this.#remoteRemaining = undefined
136
+ return { now, windowMs, max, safeMax }
137
+ }
138
+
139
+ acquire(units = 1): number {
140
+ const { now, windowMs, safeMax } = this.#cfg()
141
+ const needed = Math.max(1, units)
142
+ const next = [...this.#slots]
143
+ let lastAt = now
144
+ if (this.#remoteRemaining !== undefined && this.#remoteRemaining < needed) {
145
+ lastAt = Math.max(lastAt, this.#remoteResetAt)
146
+ }
147
+ for (let i = 0; i < needed; i++) {
148
+ const windowReady = next.length >= safeMax ? next[next.length - safeMax]! + windowMs : now
149
+ lastAt = Math.max(lastAt, windowReady)
150
+ next.push(lastAt)
151
+ }
152
+ const waitMs = Math.max(0, lastAt - now)
153
+ if (waitMs <= this.#getMaxWaitMs()) {
154
+ this.#slots = next
155
+ if (this.#remoteRemaining !== undefined) {
156
+ this.#remoteRemaining = Math.max(0, this.#remoteRemaining - needed)
157
+ }
158
+ }
159
+ return waitMs
160
+ }
161
+
162
+ charge(units: number) {
163
+ const now = Date.now()
164
+ for (let i = 0; i < units; i++) this.#slots.push(now)
165
+ if (this.#remoteRemaining !== undefined) {
166
+ this.#remoteRemaining = Math.max(0, this.#remoteRemaining - units)
167
+ }
168
+ }
169
+
170
+ sync(remaining?: number, limit?: number) {
171
+ if (limit && limit > 0) this.#maxOverride = limit
172
+ if (remaining === undefined || Number.isNaN(remaining)) return
173
+ const now = Date.now()
174
+ this.#remoteRemaining = Math.max(0, remaining)
175
+ this.#remoteResetAt = now + (remaining === 0 ? 5_000 : 10_000)
176
+ }
177
+ }
178
+
179
+ const inflight = new Map<string, Promise<unknown>>()
180
+ const limiters = new Map<string, RateLimiter>()
181
+ const httpCounters = new Map<string, { count: number; resetAt: number }>()
182
+ const breakerCache = new Map<string, { record: CircuitBreakerRecord; cachedAt: number }>()
183
+
184
+ const BREAKER_CACHE_TTL_MS = 3_000
185
+ const BREAKER_FAILURE_WINDOW_MS = 30_000
186
+ const BREAKER_TRIP_THRESHOLD = 3
187
+ const BREAKER_OPEN_DURATION_MS = 45_000
188
+
189
+ const breakerCacheKey = (key: CircuitBreakerKey, isDev: boolean): string => `${isDev ? 'dev' : 'prod'}:${key}`
190
+ const breakerDocPath = (key: CircuitBreakerKey): string => `_system/breaker_${key}`
191
+
192
+ export class ResilienceService {
193
+ readonly #core: Core
194
+
195
+ constructor(coreClient: Core) {
196
+ this.#core = coreClient
197
+ }
198
+
199
+ #getLimiter = (b: Bucket): RateLimiter => {
200
+ const key = `${this.#core.env.isDev ? 'dev' : 'prod'}:${b}`
201
+ return (
202
+ limiters.get(key) ??
203
+ (limiters.set(key, new RateLimiter(b, () => this.#core.config.limits.maxWaitMs)), limiters.get(key)!)
204
+ )
205
+ }
206
+
207
+ singleflight = <T>(key: string, fn: () => Promise<T>, opts?: SingleflightOptions): Promise<T> => {
208
+ const scope = opts?.scope ?? 'tenant'
209
+ const active = this.#core.internals.context.getState()
210
+ const activeUserId = scope === 'tenant' ? active?.userId : undefined
211
+ const scopedKey = activeUserId ? `tenant:${activeUserId}:${key}` : `global:${key}`
212
+
213
+ const existing = inflight.get(scopedKey) as Promise<T> | undefined
214
+ if (existing) return existing
215
+
216
+ const exec =
217
+ scope === 'global' && active
218
+ ? () =>
219
+ this.#core.internals.context.run(
220
+ { env: active.env, userId: this.#core.config.auth.adminUserId, role: 'admin' },
221
+ fn
222
+ )
223
+ : fn
224
+ const promise = exec().finally(() => {
225
+ inflight.delete(scopedKey)
226
+ })
227
+ inflight.set(scopedKey, promise)
228
+ return promise
229
+ }
230
+
231
+ acquireLimit = async (bucket: Bucket, cost = 1): Promise<void> => {
232
+ const waitMs = this.#getLimiter(bucket).acquire(cost)
233
+ if (waitMs > this.#core.config.limits.maxWaitMs) {
234
+ this.#core.errors.httpError(429, `Rate limit exceeded for ${bucket}`, 'RATE_LIMITED', waitMs)
235
+ }
236
+ if (waitMs > 0) await new Promise((r) => setTimeout(r, waitMs))
237
+ }
238
+
239
+ chargeLimit = async (bucket: Bucket, extra: number): Promise<void> => {
240
+ this.#getLimiter(bucket).charge(extra)
241
+ }
242
+
243
+ syncLimit = async (bucket: Bucket, remaining?: number, max?: number): Promise<void> => {
244
+ this.#getLimiter(bucket).sync(remaining, max)
245
+ }
246
+
247
+ acquireHttpLimit = (key: string, max = 300, windowMs = 60_000): void => {
248
+ const now = Date.now()
249
+ const existing = httpCounters.get(key)
250
+ if (!existing || now >= existing.resetAt) {
251
+ if (!existing && httpCounters.size >= 5000) {
252
+ httpCounters.delete(httpCounters.keys().next().value!)
253
+ }
254
+ httpCounters.set(key, { count: 1, resetAt: now + windowMs })
255
+ return
256
+ }
257
+ if (existing.count >= max) {
258
+ const retryAfterMs = Math.max(1000, existing.resetAt - now)
259
+ this.#core.errors.httpError(429, 'Rate limit exceeded', 'RATE_LIMITED', retryAfterMs)
260
+ }
261
+ existing.count += 1
262
+ }
263
+
264
+ #isUpstreamProviderError = (err: unknown): boolean => {
265
+ if (!err) return false
266
+ const rec = typeof err === 'object' ? (err as Record<string, unknown>) : {}
267
+ const rawStatus =
268
+ typeof rec.statusCode === 'number'
269
+ ? rec.statusCode
270
+ : typeof rec.status === 'number'
271
+ ? rec.status
272
+ : undefined
273
+ const code = typeof rec.code === 'string' ? rec.code.toUpperCase() : ''
274
+ const msg = err instanceof Error ? err.message : String(err)
275
+ const lower = msg.toLowerCase()
276
+
277
+ const parsedStatusMatch = /\b(?:status(?:\s+code)?|http)\s*[:=]?\s*(\d{3})\b/i.exec(msg)
278
+ const status = rawStatus ?? (parsedStatusMatch?.[1] ? Number(parsedStatusMatch[1]) : undefined)
279
+
280
+ if (status !== undefined) {
281
+ if (status === 400 || status === 401 || status === 403 || status === 404 || status === 415 || status === 422) {
282
+ return false
283
+ }
284
+ if (status === 502 || status === 503 || status === 504 || status === 408) {
285
+ return true
286
+ }
287
+ }
288
+
289
+ if (
290
+ code === 'ETIMEDOUT' ||
291
+ code === 'ECONNRESET' ||
292
+ code === 'ECONNREFUSED' ||
293
+ code === 'UND_ERR_CONNECT_TIMEOUT' ||
294
+ lower.includes('etimedout') ||
295
+ lower.includes('econnreset') ||
296
+ lower.includes('econnrefused') ||
297
+ lower.includes('socket hang up') ||
298
+ lower.includes('bad gateway') ||
299
+ lower.includes('service unavailable') ||
300
+ lower.includes('gateway timeout') ||
301
+ lower.includes('502') ||
302
+ lower.includes('503') ||
303
+ lower.includes('504')
304
+ ) {
305
+ return true
306
+ }
307
+
308
+ return false
309
+ }
310
+
311
+ getCircuitBreakerState = async (
312
+ breakerKey: CircuitBreakerKey,
313
+ isDev: boolean = this.#core.env.isDev,
314
+ forceRefresh = false
315
+ ): Promise<CircuitBreakerRecord> => {
316
+ const cKey = breakerCacheKey(breakerKey, isDev)
317
+ const now = Date.now()
318
+ const hit = breakerCache.get(cKey)
319
+ if (!forceRefresh && hit && now - hit.cachedAt < BREAKER_CACHE_TTL_MS) {
320
+ return hit.record
321
+ }
322
+
323
+ return this.singleflight(
324
+ `breaker:read:${cKey}`,
325
+ async () => {
326
+ try {
327
+ const snap = await this.#core.db.firestore(isDev).doc(breakerDocPath(breakerKey)).get()
328
+ const d = snap.data() as Partial<CircuitBreakerRecord> | undefined
329
+ const record: CircuitBreakerRecord = {
330
+ state: d?.state === 'OPEN' || d?.state === 'HALF_OPEN' ? d.state : 'CLOSED',
331
+ failureCount: typeof d?.failureCount === 'number' ? d.failureCount : 0,
332
+ firstFailureAt: typeof d?.firstFailureAt === 'number' ? d.firstFailureAt : 0,
333
+ openedAt: typeof d?.openedAt === 'number' ? d.openedAt : 0
334
+ }
335
+ breakerCache.set(cKey, { record, cachedAt: Date.now() })
336
+ return record
337
+ } catch {
338
+ const fallback: CircuitBreakerRecord = {
339
+ state: 'CLOSED',
340
+ failureCount: 0,
341
+ firstFailureAt: 0,
342
+ openedAt: 0
343
+ }
344
+ breakerCache.set(cKey, { record: fallback, cachedAt: Date.now() })
345
+ return fallback
346
+ }
347
+ },
348
+ { scope: 'global' }
349
+ )
350
+ }
351
+
352
+ resetCircuitBreaker = async (breakerKey: CircuitBreakerKey, isDev: boolean = this.#core.env.isDev): Promise<void> => {
353
+ const cKey = breakerCacheKey(breakerKey, isDev)
354
+ const record: CircuitBreakerRecord = {
355
+ state: 'CLOSED',
356
+ failureCount: 0,
357
+ firstFailureAt: 0,
358
+ openedAt: 0,
359
+ updatedAt: Date.now()
360
+ }
361
+ breakerCache.set(cKey, { record, cachedAt: Date.now() })
362
+ await this.#core.db.firestore(isDev).doc(breakerDocPath(breakerKey)).set(record).catch(() => {})
363
+ }
364
+
365
+ #computeWindowFailureState = (prevFirst: number, prevCount: number, now: number): CircuitBreakerRecord => {
366
+ const inWindow = prevFirst > 0 && now - prevFirst <= BREAKER_FAILURE_WINDOW_MS
367
+ const failureCount = inWindow ? prevCount + 1 : 1
368
+ const firstFailureAt = inWindow ? prevFirst : now
369
+ const tripped = failureCount >= BREAKER_TRIP_THRESHOLD
370
+ return {
371
+ state: tripped ? 'OPEN' : 'CLOSED',
372
+ failureCount,
373
+ firstFailureAt,
374
+ openedAt: tripped ? now : 0,
375
+ updatedAt: now
376
+ }
377
+ }
378
+
379
+ #recordBreakerFailure = async (breakerKey: CircuitBreakerKey, isDev: boolean): Promise<CircuitBreakerRecord> => {
380
+ const cKey = breakerCacheKey(breakerKey, isDev)
381
+ const db = this.#core.db.firestore(isDev)
382
+ const docRef = db.doc(breakerDocPath(breakerKey))
383
+ const now = Date.now()
384
+
385
+ try {
386
+ const updated = await db.runTransaction(async (tx) => {
387
+ const snap = await tx.get(docRef)
388
+ const d = snap.data() as Partial<CircuitBreakerRecord> | undefined
389
+ const prevState: CircuitBreakerStateName =
390
+ d?.state === 'OPEN' || d?.state === 'HALF_OPEN' ? d.state : 'CLOSED'
391
+ const prevFirst = typeof d?.firstFailureAt === 'number' ? d.firstFailureAt : 0
392
+ const prevCount = typeof d?.failureCount === 'number' ? d.failureCount : 0
393
+
394
+ const next: CircuitBreakerRecord =
395
+ prevState === 'HALF_OPEN'
396
+ ? {
397
+ state: 'OPEN',
398
+ failureCount: Math.max(BREAKER_TRIP_THRESHOLD, prevCount + 1),
399
+ firstFailureAt: prevFirst || now,
400
+ openedAt: now,
401
+ updatedAt: now
402
+ }
403
+ : this.#computeWindowFailureState(prevFirst, prevCount, now)
404
+ tx.set(docRef, next)
405
+ return next
406
+ })
407
+ breakerCache.set(cKey, { record: updated, cachedAt: now })
408
+ return updated
409
+ } catch {
410
+ const localPrev = breakerCache.get(cKey)?.record
411
+ const next = this.#computeWindowFailureState(localPrev?.firstFailureAt ?? 0, localPrev?.failureCount ?? 0, now)
412
+ breakerCache.set(cKey, { record: next, cachedAt: now })
413
+ return next
414
+ }
415
+ }
416
+
417
+ circuitBreaker = async <T>(
418
+ breakerKey: CircuitBreakerKey,
419
+ fn: () => Promise<T>,
420
+ fallbackFn?: () => Promise<T>,
421
+ opts?: { isDev?: boolean }
422
+ ): Promise<T> => {
423
+ const targetIsDev = opts?.isDev ?? this.#core.env.isDev
424
+ const cKey = breakerCacheKey(breakerKey, targetIsDev)
425
+ let current = await this.getCircuitBreakerState(breakerKey, targetIsDev)
426
+ const now = Date.now()
427
+
428
+ if (current.state === 'OPEN') {
429
+ if (now - current.openedAt >= BREAKER_OPEN_DURATION_MS) {
430
+ current = { ...current, state: 'HALF_OPEN' }
431
+ breakerCache.set(cKey, { record: current, cachedAt: now })
432
+ void this.#core.db
433
+ .firestore(targetIsDev)
434
+ .doc(breakerDocPath(breakerKey))
435
+ .set({ state: 'HALF_OPEN', updatedAt: now }, { merge: true })
436
+ .catch(() => {})
437
+ } else {
438
+ if (fallbackFn) {
439
+ return fallbackFn()
440
+ }
441
+ throw new CircuitBreakerOpenError(breakerKey)
442
+ }
443
+ }
444
+
445
+ try {
446
+ const result = await fn()
447
+ if (current.state === 'HALF_OPEN' || current.failureCount > 0) {
448
+ await this.resetCircuitBreaker(breakerKey, targetIsDev)
449
+ }
450
+ return result
451
+ } catch (err) {
452
+ if (this.#isUpstreamProviderError(err)) {
453
+ const updated = await this.#recordBreakerFailure(breakerKey, targetIsDev)
454
+ if (updated.state === 'OPEN' && fallbackFn) {
455
+ return fallbackFn()
456
+ }
457
+ }
458
+ throw err
459
+ }
460
+ }
461
+ }