@wopr-network/platform-core 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (694) hide show
  1. package/biome.json +61 -0
  2. package/dist/admin/admin-audit-log-repository.d.ts +33 -0
  3. package/dist/admin/admin-audit-log-repository.js +102 -0
  4. package/dist/admin/audit-log.d.ts +49 -0
  5. package/dist/admin/audit-log.js +63 -0
  6. package/dist/admin/index.d.ts +6 -0
  7. package/dist/admin/index.js +3 -0
  8. package/dist/admin/role-store.d.ts +37 -0
  9. package/dist/admin/role-store.js +106 -0
  10. package/dist/auth/api-key-repository.d.ts +11 -0
  11. package/dist/auth/api-key-repository.js +33 -0
  12. package/dist/auth/api-key-repository.test.d.ts +1 -0
  13. package/dist/auth/api-key-repository.test.js +46 -0
  14. package/dist/auth/auth.test.d.ts +1 -0
  15. package/dist/auth/auth.test.js +140 -0
  16. package/dist/auth/better-auth.d.ts +42 -0
  17. package/dist/auth/better-auth.js +196 -0
  18. package/dist/auth/index.d.ts +186 -0
  19. package/dist/auth/index.js +422 -0
  20. package/dist/auth/login-history-repository.d.ts +14 -0
  21. package/dist/auth/login-history-repository.js +15 -0
  22. package/dist/auth/login-history-repository.test.d.ts +1 -0
  23. package/dist/auth/login-history-repository.test.js +47 -0
  24. package/dist/auth/middleware.d.ts +55 -0
  25. package/dist/auth/middleware.js +101 -0
  26. package/dist/auth/middleware.test.d.ts +1 -0
  27. package/dist/auth/middleware.test.js +213 -0
  28. package/dist/auth/scoped-tokens.test.d.ts +1 -0
  29. package/dist/auth/scoped-tokens.test.js +306 -0
  30. package/dist/auth/tenant-access.test.d.ts +1 -0
  31. package/dist/auth/tenant-access.test.js +62 -0
  32. package/dist/auth/user-creator.d.ts +9 -0
  33. package/dist/auth/user-creator.js +47 -0
  34. package/dist/auth/user-creator.test.d.ts +1 -0
  35. package/dist/auth/user-creator.test.js +78 -0
  36. package/dist/auth/user-role-repository.d.ts +31 -0
  37. package/dist/auth/user-role-repository.js +53 -0
  38. package/dist/auth/user-role-repository.test.d.ts +1 -0
  39. package/dist/auth/user-role-repository.test.js +122 -0
  40. package/dist/billing/drizzle-webhook-seen-repository.d.ts +10 -0
  41. package/dist/billing/drizzle-webhook-seen-repository.js +28 -0
  42. package/dist/billing/index.d.ts +7 -0
  43. package/dist/billing/index.js +7 -0
  44. package/dist/billing/payment-processor.d.ts +127 -0
  45. package/dist/billing/payment-processor.js +8 -0
  46. package/dist/billing/payment-processor.test.d.ts +1 -0
  47. package/dist/billing/payment-processor.test.js +71 -0
  48. package/dist/billing/payram/cents-credits-boundary.test.d.ts +1 -0
  49. package/dist/billing/payram/cents-credits-boundary.test.js +75 -0
  50. package/dist/billing/payram/charge-store.d.ts +41 -0
  51. package/dist/billing/payram/charge-store.js +72 -0
  52. package/dist/billing/payram/charge-store.test.d.ts +1 -0
  53. package/dist/billing/payram/charge-store.test.js +64 -0
  54. package/dist/billing/payram/checkout.d.ts +15 -0
  55. package/dist/billing/payram/checkout.js +24 -0
  56. package/dist/billing/payram/checkout.test.d.ts +1 -0
  57. package/dist/billing/payram/checkout.test.js +74 -0
  58. package/dist/billing/payram/client.d.ts +7 -0
  59. package/dist/billing/payram/client.js +15 -0
  60. package/dist/billing/payram/client.test.d.ts +1 -0
  61. package/dist/billing/payram/client.test.js +52 -0
  62. package/dist/billing/payram/index.d.ts +8 -0
  63. package/dist/billing/payram/index.js +4 -0
  64. package/dist/billing/payram/types.d.ts +40 -0
  65. package/dist/billing/payram/types.js +1 -0
  66. package/dist/billing/payram/webhook.d.ts +19 -0
  67. package/dist/billing/payram/webhook.js +67 -0
  68. package/dist/billing/payram/webhook.test.d.ts +7 -0
  69. package/dist/billing/payram/webhook.test.js +248 -0
  70. package/dist/billing/stripe/cents-credits-boundary.test.d.ts +1 -0
  71. package/dist/billing/stripe/cents-credits-boundary.test.js +62 -0
  72. package/dist/billing/stripe/checkout.d.ts +20 -0
  73. package/dist/billing/stripe/checkout.js +63 -0
  74. package/dist/billing/stripe/checkout.test.d.ts +1 -0
  75. package/dist/billing/stripe/checkout.test.js +148 -0
  76. package/dist/billing/stripe/client.d.ts +14 -0
  77. package/dist/billing/stripe/client.js +33 -0
  78. package/dist/billing/stripe/client.test.d.ts +1 -0
  79. package/dist/billing/stripe/client.test.js +58 -0
  80. package/dist/billing/stripe/credit-prices.d.ts +63 -0
  81. package/dist/billing/stripe/credit-prices.js +81 -0
  82. package/dist/billing/stripe/credit-prices.test.d.ts +1 -0
  83. package/dist/billing/stripe/credit-prices.test.js +87 -0
  84. package/dist/billing/stripe/index.d.ts +14 -0
  85. package/dist/billing/stripe/index.js +8 -0
  86. package/dist/billing/stripe/payment-methods-detach-all.test.d.ts +1 -0
  87. package/dist/billing/stripe/payment-methods-detach-all.test.js +40 -0
  88. package/dist/billing/stripe/payment-methods.d.ts +25 -0
  89. package/dist/billing/stripe/payment-methods.js +53 -0
  90. package/dist/billing/stripe/payment-methods.test.d.ts +1 -0
  91. package/dist/billing/stripe/payment-methods.test.js +122 -0
  92. package/dist/billing/stripe/portal.d.ts +10 -0
  93. package/dist/billing/stripe/portal.js +16 -0
  94. package/dist/billing/stripe/portal.test.d.ts +1 -0
  95. package/dist/billing/stripe/portal.test.js +48 -0
  96. package/dist/billing/stripe/setup-intent.d.ts +16 -0
  97. package/dist/billing/stripe/setup-intent.js +22 -0
  98. package/dist/billing/stripe/setup-intent.test.d.ts +1 -0
  99. package/dist/billing/stripe/setup-intent.test.js +58 -0
  100. package/dist/billing/stripe/stripe-payment-processor.d.ts +49 -0
  101. package/dist/billing/stripe/stripe-payment-processor.js +166 -0
  102. package/dist/billing/stripe/stripe-payment-processor.test.d.ts +1 -0
  103. package/dist/billing/stripe/stripe-payment-processor.test.js +413 -0
  104. package/dist/billing/stripe/tenant-store.d.ts +56 -0
  105. package/dist/billing/stripe/tenant-store.js +119 -0
  106. package/dist/billing/stripe/tenant-store.test.d.ts +1 -0
  107. package/dist/billing/stripe/tenant-store.test.js +97 -0
  108. package/dist/billing/stripe/types.d.ts +49 -0
  109. package/dist/billing/stripe/types.js +1 -0
  110. package/dist/billing/webhook-seen-repository.d.ts +14 -0
  111. package/dist/billing/webhook-seen-repository.js +13 -0
  112. package/dist/config/billing-env.test.d.ts +1 -0
  113. package/dist/config/billing-env.test.js +48 -0
  114. package/dist/config/index.d.ts +46 -0
  115. package/dist/config/index.js +38 -0
  116. package/dist/config/logger.d.ts +2 -0
  117. package/dist/config/logger.js +11 -0
  118. package/dist/config/provider-endpoints.d.ts +6 -0
  119. package/dist/config/provider-endpoints.js +12 -0
  120. package/dist/credits/auto-topup-charge.d.ts +27 -0
  121. package/dist/credits/auto-topup-charge.js +139 -0
  122. package/dist/credits/auto-topup-charge.test.d.ts +1 -0
  123. package/dist/credits/auto-topup-charge.test.js +242 -0
  124. package/dist/credits/auto-topup-event-log-repository.d.ts +16 -0
  125. package/dist/credits/auto-topup-event-log-repository.js +18 -0
  126. package/dist/credits/auto-topup-event-log-repository.test.d.ts +1 -0
  127. package/dist/credits/auto-topup-event-log-repository.test.js +83 -0
  128. package/dist/credits/auto-topup-schedule.d.ts +27 -0
  129. package/dist/credits/auto-topup-schedule.js +66 -0
  130. package/dist/credits/auto-topup-schedule.test.d.ts +1 -0
  131. package/dist/credits/auto-topup-schedule.test.js +145 -0
  132. package/dist/credits/auto-topup-settings-repository.d.ts +54 -0
  133. package/dist/credits/auto-topup-settings-repository.js +184 -0
  134. package/dist/credits/auto-topup-settings-repository.test.d.ts +1 -0
  135. package/dist/credits/auto-topup-settings-repository.test.js +104 -0
  136. package/dist/credits/auto-topup-usage.d.ts +22 -0
  137. package/dist/credits/auto-topup-usage.js +56 -0
  138. package/dist/credits/auto-topup-usage.test.d.ts +1 -0
  139. package/dist/credits/auto-topup-usage.test.js +181 -0
  140. package/dist/credits/credit-expiry-cron.d.ts +19 -0
  141. package/dist/credits/credit-expiry-cron.js +50 -0
  142. package/dist/credits/credit-expiry-cron.test.d.ts +1 -0
  143. package/dist/credits/credit-expiry-cron.test.js +67 -0
  144. package/dist/credits/credit-ledger-extra.test.d.ts +1 -0
  145. package/dist/credits/credit-ledger-extra.test.js +40 -0
  146. package/dist/credits/credit-ledger.bench.d.ts +1 -0
  147. package/dist/credits/credit-ledger.bench.js +33 -0
  148. package/dist/credits/credit-ledger.d.ts +130 -0
  149. package/dist/credits/credit-ledger.js +293 -0
  150. package/dist/credits/credit-ledger.test.d.ts +4 -0
  151. package/dist/credits/credit-ledger.test.js +203 -0
  152. package/dist/credits/credit-transaction-repository.d.ts +17 -0
  153. package/dist/credits/credit-transaction-repository.js +35 -0
  154. package/dist/credits/credit-transaction-repository.test.d.ts +1 -0
  155. package/dist/credits/credit-transaction-repository.test.js +232 -0
  156. package/dist/credits/credit.d.ts +75 -0
  157. package/dist/credits/credit.js +139 -0
  158. package/dist/credits/credit.test.d.ts +1 -0
  159. package/dist/credits/credit.test.js +196 -0
  160. package/dist/credits/dividend-cron.d.ts +29 -0
  161. package/dist/credits/dividend-cron.js +88 -0
  162. package/dist/credits/dividend-cron.test.d.ts +1 -0
  163. package/dist/credits/dividend-cron.test.js +128 -0
  164. package/dist/credits/dividend-repository.d.ts +29 -0
  165. package/dist/credits/dividend-repository.js +126 -0
  166. package/dist/credits/dividend-repository.test.d.ts +1 -0
  167. package/dist/credits/dividend-repository.test.js +176 -0
  168. package/dist/credits/index.d.ts +9 -0
  169. package/dist/credits/index.js +5 -0
  170. package/dist/credits/repository-types.d.ts +29 -0
  171. package/dist/credits/repository-types.js +1 -0
  172. package/dist/credits/signup-grant.d.ts +12 -0
  173. package/dist/credits/signup-grant.js +35 -0
  174. package/dist/credits/signup-grant.test.d.ts +1 -0
  175. package/dist/credits/signup-grant.test.js +51 -0
  176. package/dist/credits/tenant-customer-repository.d.ts +30 -0
  177. package/dist/credits/tenant-customer-repository.js +5 -0
  178. package/dist/db/auth-user-repository.d.ts +46 -0
  179. package/dist/db/auth-user-repository.js +90 -0
  180. package/dist/db/credit-column.d.ts +27 -0
  181. package/dist/db/credit-column.js +13 -0
  182. package/dist/db/index.d.ts +14 -0
  183. package/dist/db/index.js +8 -0
  184. package/dist/db/schema/account-deletion-requests.d.ts +203 -0
  185. package/dist/db/schema/account-deletion-requests.js +36 -0
  186. package/dist/db/schema/account-export-requests.d.ts +148 -0
  187. package/dist/db/schema/account-export-requests.js +19 -0
  188. package/dist/db/schema/admin-audit.d.ts +194 -0
  189. package/dist/db/schema/admin-audit.js +21 -0
  190. package/dist/db/schema/admin-users.d.ts +177 -0
  191. package/dist/db/schema/admin-users.js +23 -0
  192. package/dist/db/schema/affiliate-fraud.d.ts +160 -0
  193. package/dist/db/schema/affiliate-fraud.js +18 -0
  194. package/dist/db/schema/affiliate.d.ts +277 -0
  195. package/dist/db/schema/affiliate.js +32 -0
  196. package/dist/db/schema/coupon-codes.d.ts +143 -0
  197. package/dist/db/schema/coupon-codes.js +17 -0
  198. package/dist/db/schema/credit-auto-topup-settings.d.ts +232 -0
  199. package/dist/db/schema/credit-auto-topup-settings.js +27 -0
  200. package/dist/db/schema/credit-auto-topup.d.ts +130 -0
  201. package/dist/db/schema/credit-auto-topup.js +21 -0
  202. package/dist/db/schema/credits.d.ts +283 -0
  203. package/dist/db/schema/credits.js +38 -0
  204. package/dist/db/schema/dividend-distributions.d.ts +130 -0
  205. package/dist/db/schema/dividend-distributions.js +19 -0
  206. package/dist/db/schema/email-notifications.d.ts +99 -0
  207. package/dist/db/schema/email-notifications.js +21 -0
  208. package/dist/db/schema/index.d.ts +33 -0
  209. package/dist/db/schema/index.js +33 -0
  210. package/dist/db/schema/meter-events.d.ts +599 -0
  211. package/dist/db/schema/meter-events.js +55 -0
  212. package/dist/db/schema/notification-preferences.d.ts +165 -0
  213. package/dist/db/schema/notification-preferences.js +18 -0
  214. package/dist/db/schema/notification-queue.d.ts +236 -0
  215. package/dist/db/schema/notification-queue.js +40 -0
  216. package/dist/db/schema/org-memberships.d.ts +63 -0
  217. package/dist/db/schema/org-memberships.js +15 -0
  218. package/dist/db/schema/organization-members.d.ts +235 -0
  219. package/dist/db/schema/organization-members.js +27 -0
  220. package/dist/db/schema/payram.d.ts +164 -0
  221. package/dist/db/schema/payram.js +21 -0
  222. package/dist/db/schema/platform-api-keys.d.ts +143 -0
  223. package/dist/db/schema/platform-api-keys.js +20 -0
  224. package/dist/db/schema/promotion-redemptions.d.ts +143 -0
  225. package/dist/db/schema/promotion-redemptions.js +18 -0
  226. package/dist/db/schema/promotions.d.ts +445 -0
  227. package/dist/db/schema/promotions.js +48 -0
  228. package/dist/db/schema/provider-credentials.d.ts +201 -0
  229. package/dist/db/schema/provider-credentials.js +36 -0
  230. package/dist/db/schema/rate-limit-entries.d.ts +75 -0
  231. package/dist/db/schema/rate-limit-entries.js +7 -0
  232. package/dist/db/schema/secret-audit-log.d.ts +109 -0
  233. package/dist/db/schema/secret-audit-log.js +15 -0
  234. package/dist/db/schema/session-usage.d.ts +194 -0
  235. package/dist/db/schema/session-usage.js +19 -0
  236. package/dist/db/schema/spending-limits.d.ts +92 -0
  237. package/dist/db/schema/spending-limits.js +8 -0
  238. package/dist/db/schema/tenant-addons.d.ts +58 -0
  239. package/dist/db/schema/tenant-addons.js +9 -0
  240. package/dist/db/schema/tenant-api-keys.d.ts +131 -0
  241. package/dist/db/schema/tenant-api-keys.js +21 -0
  242. package/dist/db/schema/tenant-capability-settings.d.ts +79 -0
  243. package/dist/db/schema/tenant-capability-settings.js +12 -0
  244. package/dist/db/schema/tenant-customers.d.ts +303 -0
  245. package/dist/db/schema/tenant-customers.js +25 -0
  246. package/dist/db/schema/tenants.d.ts +126 -0
  247. package/dist/db/schema/tenants.js +18 -0
  248. package/dist/db/schema/user-roles.d.ts +98 -0
  249. package/dist/db/schema/user-roles.js +18 -0
  250. package/dist/db/schema/webhook-seen-events.d.ts +58 -0
  251. package/dist/db/schema/webhook-seen-events.js +9 -0
  252. package/dist/email/billing-emails.d.ts +51 -0
  253. package/dist/email/billing-emails.js +163 -0
  254. package/dist/email/billing-emails.test.d.ts +1 -0
  255. package/dist/email/billing-emails.test.js +162 -0
  256. package/dist/email/client.d.ts +51 -0
  257. package/dist/email/client.js +102 -0
  258. package/dist/email/client.test.d.ts +1 -0
  259. package/dist/email/client.test.js +120 -0
  260. package/dist/email/drizzle-billing-email-repository.d.ts +21 -0
  261. package/dist/email/drizzle-billing-email-repository.js +36 -0
  262. package/dist/email/drizzle-billing-email-repository.test.d.ts +1 -0
  263. package/dist/email/drizzle-billing-email-repository.test.js +42 -0
  264. package/dist/email/index.d.ts +33 -0
  265. package/dist/email/index.js +22 -0
  266. package/dist/email/notification-preferences-store.d.ts +12 -0
  267. package/dist/email/notification-preferences-store.js +82 -0
  268. package/dist/email/notification-preferences-store.test.d.ts +1 -0
  269. package/dist/email/notification-preferences-store.test.js +86 -0
  270. package/dist/email/notification-queue-store.d.ts +25 -0
  271. package/dist/email/notification-queue-store.js +97 -0
  272. package/dist/email/notification-queue-store.test.d.ts +1 -0
  273. package/dist/email/notification-queue-store.test.js +177 -0
  274. package/dist/email/notification-repository-types.d.ts +70 -0
  275. package/dist/email/notification-repository-types.js +6 -0
  276. package/dist/email/notification-service.d.ts +41 -0
  277. package/dist/email/notification-service.js +196 -0
  278. package/dist/email/notification-service.test.d.ts +1 -0
  279. package/dist/email/notification-service.test.js +160 -0
  280. package/dist/email/notification-templates.d.ts +18 -0
  281. package/dist/email/notification-templates.js +574 -0
  282. package/dist/email/notification-templates.test.d.ts +1 -0
  283. package/dist/email/notification-templates.test.js +238 -0
  284. package/dist/email/notification-worker.d.ts +24 -0
  285. package/dist/email/notification-worker.js +109 -0
  286. package/dist/email/notification-worker.test.d.ts +1 -0
  287. package/dist/email/notification-worker.test.js +153 -0
  288. package/dist/email/require-verified.d.ts +25 -0
  289. package/dist/email/require-verified.js +52 -0
  290. package/dist/email/require-verified.test.d.ts +1 -0
  291. package/dist/email/require-verified.test.js +62 -0
  292. package/dist/email/resend-adapter.d.ts +47 -0
  293. package/dist/email/resend-adapter.js +137 -0
  294. package/dist/email/resend-adapter.test.d.ts +1 -0
  295. package/dist/email/resend-adapter.test.js +190 -0
  296. package/dist/email/templates.d.ts +22 -0
  297. package/dist/email/templates.js +359 -0
  298. package/dist/email/templates.test.d.ts +1 -0
  299. package/dist/email/templates.test.js +170 -0
  300. package/dist/email/verification.d.ts +42 -0
  301. package/dist/email/verification.js +83 -0
  302. package/dist/email/verification.test.d.ts +1 -0
  303. package/dist/email/verification.test.js +141 -0
  304. package/dist/index.d.ts +13 -0
  305. package/dist/index.js +23 -0
  306. package/dist/metering/aggregator.d.ts +54 -0
  307. package/dist/metering/aggregator.js +123 -0
  308. package/dist/metering/aggregator.test.d.ts +1 -0
  309. package/dist/metering/aggregator.test.js +179 -0
  310. package/dist/metering/dlq.d.ts +31 -0
  311. package/dist/metering/dlq.js +82 -0
  312. package/dist/metering/dlq.test.d.ts +1 -0
  313. package/dist/metering/dlq.test.js +117 -0
  314. package/dist/metering/drizzle-usage-summary-repository.d.ts +67 -0
  315. package/dist/metering/drizzle-usage-summary-repository.js +98 -0
  316. package/dist/metering/emitter.d.ts +66 -0
  317. package/dist/metering/emitter.js +185 -0
  318. package/dist/metering/emitter.test.d.ts +1 -0
  319. package/dist/metering/emitter.test.js +171 -0
  320. package/dist/metering/index.d.ts +11 -0
  321. package/dist/metering/index.js +5 -0
  322. package/dist/metering/load-test.bench.d.ts +1 -0
  323. package/dist/metering/load-test.bench.js +103 -0
  324. package/dist/metering/meter-event-repository.d.ts +33 -0
  325. package/dist/metering/meter-event-repository.js +58 -0
  326. package/dist/metering/meter-repositories.test.d.ts +1 -0
  327. package/dist/metering/meter-repositories.test.js +419 -0
  328. package/dist/metering/metering.test.d.ts +1 -0
  329. package/dist/metering/metering.test.js +1046 -0
  330. package/dist/metering/reconciliation-cron.d.ts +37 -0
  331. package/dist/metering/reconciliation-cron.js +85 -0
  332. package/dist/metering/reconciliation-cron.test.d.ts +1 -0
  333. package/dist/metering/reconciliation-cron.test.js +162 -0
  334. package/dist/metering/reconciliation-repository.d.ts +27 -0
  335. package/dist/metering/reconciliation-repository.js +43 -0
  336. package/dist/metering/reconciliation-repository.test.d.ts +1 -0
  337. package/dist/metering/reconciliation-repository.test.js +160 -0
  338. package/dist/metering/types.d.ts +88 -0
  339. package/dist/metering/types.js +1 -0
  340. package/dist/metering/wal.d.ts +49 -0
  341. package/dist/metering/wal.js +124 -0
  342. package/dist/metering/wal.test.d.ts +1 -0
  343. package/dist/metering/wal.test.js +175 -0
  344. package/dist/middleware/csrf.d.ts +24 -0
  345. package/dist/middleware/csrf.js +80 -0
  346. package/dist/middleware/csrf.test.d.ts +1 -0
  347. package/dist/middleware/csrf.test.js +152 -0
  348. package/dist/middleware/drizzle-rate-limit-repository.d.ts +9 -0
  349. package/dist/middleware/drizzle-rate-limit-repository.js +52 -0
  350. package/dist/middleware/drizzle-rate-limit-repository.test.d.ts +1 -0
  351. package/dist/middleware/drizzle-rate-limit-repository.test.js +74 -0
  352. package/dist/middleware/get-client-ip.d.ts +22 -0
  353. package/dist/middleware/get-client-ip.js +51 -0
  354. package/dist/middleware/get-client-ip.test.d.ts +1 -0
  355. package/dist/middleware/get-client-ip.test.js +40 -0
  356. package/dist/middleware/index.d.ts +5 -0
  357. package/dist/middleware/index.js +4 -0
  358. package/dist/middleware/rate-limit-repository.d.ts +19 -0
  359. package/dist/middleware/rate-limit-repository.js +1 -0
  360. package/dist/middleware/rate-limit.d.ts +57 -0
  361. package/dist/middleware/rate-limit.js +109 -0
  362. package/dist/middleware/rate-limit.test.d.ts +1 -0
  363. package/dist/middleware/rate-limit.test.js +247 -0
  364. package/dist/security/credential-vault/audit-repository.d.ts +27 -0
  365. package/dist/security/credential-vault/audit-repository.js +42 -0
  366. package/dist/security/credential-vault/audit-repository.test.d.ts +1 -0
  367. package/dist/security/credential-vault/audit-repository.test.js +78 -0
  368. package/dist/security/credential-vault/credential-repository.d.ts +94 -0
  369. package/dist/security/credential-vault/credential-repository.js +145 -0
  370. package/dist/security/credential-vault/credential-repository.test.d.ts +1 -0
  371. package/dist/security/credential-vault/credential-repository.test.js +206 -0
  372. package/dist/security/credential-vault/index.d.ts +12 -0
  373. package/dist/security/credential-vault/index.js +6 -0
  374. package/dist/security/credential-vault/key-rotation.d.ts +18 -0
  375. package/dist/security/credential-vault/key-rotation.js +52 -0
  376. package/dist/security/credential-vault/key-rotation.test.d.ts +1 -0
  377. package/dist/security/credential-vault/key-rotation.test.js +95 -0
  378. package/dist/security/credential-vault/migrate-plaintext.d.ts +15 -0
  379. package/dist/security/credential-vault/migrate-plaintext.js +80 -0
  380. package/dist/security/credential-vault/migrate-plaintext.test.d.ts +1 -0
  381. package/dist/security/credential-vault/migrate-plaintext.test.js +111 -0
  382. package/dist/security/credential-vault/migration-check.d.ts +15 -0
  383. package/dist/security/credential-vault/migration-check.js +71 -0
  384. package/dist/security/credential-vault/migration-check.test.d.ts +1 -0
  385. package/dist/security/credential-vault/migration-check.test.js +457 -0
  386. package/dist/security/credential-vault/store.d.ts +106 -0
  387. package/dist/security/credential-vault/store.js +181 -0
  388. package/dist/security/credential-vault/store.test.d.ts +1 -0
  389. package/dist/security/credential-vault/store.test.js +482 -0
  390. package/dist/security/encryption.d.ts +22 -0
  391. package/dist/security/encryption.js +53 -0
  392. package/dist/security/encryption.test.d.ts +1 -0
  393. package/dist/security/encryption.test.js +95 -0
  394. package/dist/security/host-validation.d.ts +11 -0
  395. package/dist/security/host-validation.js +108 -0
  396. package/dist/security/host-validation.test.d.ts +1 -0
  397. package/dist/security/host-validation.test.js +106 -0
  398. package/dist/security/index.d.ts +11 -0
  399. package/dist/security/index.js +11 -0
  400. package/dist/security/key-audit.d.ts +16 -0
  401. package/dist/security/key-audit.js +35 -0
  402. package/dist/security/key-audit.test.d.ts +1 -0
  403. package/dist/security/key-audit.test.js +50 -0
  404. package/dist/security/key-injection.d.ts +28 -0
  405. package/dist/security/key-injection.js +57 -0
  406. package/dist/security/key-injection.test.d.ts +1 -0
  407. package/dist/security/key-injection.test.js +97 -0
  408. package/dist/security/key-validation.d.ts +16 -0
  409. package/dist/security/key-validation.js +78 -0
  410. package/dist/security/key-validation.test.d.ts +1 -0
  411. package/dist/security/key-validation.test.js +87 -0
  412. package/dist/security/redirect-allowlist.d.ts +6 -0
  413. package/dist/security/redirect-allowlist.js +36 -0
  414. package/dist/security/redirect-allowlist.test.d.ts +1 -0
  415. package/dist/security/redirect-allowlist.test.js +55 -0
  416. package/dist/security/tenant-keys/capability-settings-store.d.ts +22 -0
  417. package/dist/security/tenant-keys/capability-settings-store.js +33 -0
  418. package/dist/security/tenant-keys/capability-settings-store.test.d.ts +1 -0
  419. package/dist/security/tenant-keys/capability-settings-store.test.js +77 -0
  420. package/dist/security/tenant-keys/index.d.ts +10 -0
  421. package/dist/security/tenant-keys/index.js +5 -0
  422. package/dist/security/tenant-keys/key-resolution-repository.d.ts +15 -0
  423. package/dist/security/tenant-keys/key-resolution-repository.js +18 -0
  424. package/dist/security/tenant-keys/key-resolution-repository.test.d.ts +1 -0
  425. package/dist/security/tenant-keys/key-resolution-repository.test.js +72 -0
  426. package/dist/security/tenant-keys/key-resolution.d.ts +39 -0
  427. package/dist/security/tenant-keys/key-resolution.js +59 -0
  428. package/dist/security/tenant-keys/key-resolution.test.d.ts +1 -0
  429. package/dist/security/tenant-keys/key-resolution.test.js +97 -0
  430. package/dist/security/tenant-keys/org-key-resolution.d.ts +30 -0
  431. package/dist/security/tenant-keys/org-key-resolution.js +50 -0
  432. package/dist/security/tenant-keys/org-key-resolution.test.d.ts +1 -0
  433. package/dist/security/tenant-keys/org-key-resolution.test.js +103 -0
  434. package/dist/security/tenant-keys/tenant-key-repository.d.ts +36 -0
  435. package/dist/security/tenant-keys/tenant-key-repository.js +96 -0
  436. package/dist/security/tenant-keys/tenant-key-repository.test.d.ts +1 -0
  437. package/dist/security/tenant-keys/tenant-key-repository.test.js +114 -0
  438. package/dist/security/types.d.ts +35 -0
  439. package/dist/security/types.js +15 -0
  440. package/dist/tenancy/drizzle-org-repository.d.ts +40 -0
  441. package/dist/tenancy/drizzle-org-repository.js +126 -0
  442. package/dist/tenancy/index.d.ts +6 -0
  443. package/dist/tenancy/index.js +3 -0
  444. package/dist/tenancy/org-member-repository.d.ts +57 -0
  445. package/dist/tenancy/org-member-repository.js +99 -0
  446. package/dist/tenancy/org-repository.test.d.ts +1 -0
  447. package/dist/tenancy/org-repository.test.js +143 -0
  448. package/dist/tenancy/org-service.d.ts +70 -0
  449. package/dist/tenancy/org-service.js +223 -0
  450. package/dist/tenancy/org-service.test.d.ts +1 -0
  451. package/dist/tenancy/org-service.test.js +550 -0
  452. package/dist/test/db.d.ts +33 -0
  453. package/dist/test/db.js +65 -0
  454. package/dist/trpc/index.d.ts +1 -0
  455. package/dist/trpc/index.js +1 -0
  456. package/dist/trpc/init.d.ts +49 -0
  457. package/dist/trpc/init.js +108 -0
  458. package/dist/trpc/init.test.d.ts +1 -0
  459. package/dist/trpc/init.test.js +154 -0
  460. package/drizzle/migrations/0000_slippery_mandrill.sql +559 -0
  461. package/drizzle/migrations/meta/0000_snapshot.json +4374 -0
  462. package/drizzle/migrations/meta/_journal.json +13 -0
  463. package/drizzle.config.ts +41 -0
  464. package/package.json +64 -0
  465. package/src/admin/admin-audit-log-repository.ts +135 -0
  466. package/src/admin/audit-log.ts +111 -0
  467. package/src/admin/index.ts +6 -0
  468. package/src/admin/role-store.ts +134 -0
  469. package/src/auth/api-key-repository.test.ts +63 -0
  470. package/src/auth/api-key-repository.ts +46 -0
  471. package/src/auth/auth.test.ts +166 -0
  472. package/src/auth/better-auth.ts +216 -0
  473. package/src/auth/index.ts +520 -0
  474. package/src/auth/login-history-repository.test.ts +54 -0
  475. package/src/auth/login-history-repository.ts +28 -0
  476. package/src/auth/middleware.test.ts +264 -0
  477. package/src/auth/middleware.ts +117 -0
  478. package/src/auth/scoped-tokens.test.ts +362 -0
  479. package/src/auth/tenant-access.test.ts +69 -0
  480. package/src/auth/user-creator.test.ts +98 -0
  481. package/src/auth/user-creator.ts +54 -0
  482. package/src/auth/user-role-repository.test.ts +149 -0
  483. package/src/auth/user-role-repository.ts +67 -0
  484. package/src/billing/drizzle-webhook-seen-repository.ts +34 -0
  485. package/src/billing/index.ts +22 -0
  486. package/src/billing/payment-processor.test.ts +93 -0
  487. package/src/billing/payment-processor.ts +150 -0
  488. package/src/billing/payram/cents-credits-boundary.test.ts +84 -0
  489. package/src/billing/payram/charge-store.test.ts +84 -0
  490. package/src/billing/payram/charge-store.ts +109 -0
  491. package/src/billing/payram/checkout.test.ts +99 -0
  492. package/src/billing/payram/checkout.ts +40 -0
  493. package/src/billing/payram/client.test.ts +62 -0
  494. package/src/billing/payram/client.ts +21 -0
  495. package/src/billing/payram/index.ts +14 -0
  496. package/src/billing/payram/types.ts +44 -0
  497. package/src/billing/payram/webhook.test.ts +318 -0
  498. package/src/billing/payram/webhook.ts +97 -0
  499. package/src/billing/stripe/cents-credits-boundary.test.ts +70 -0
  500. package/src/billing/stripe/checkout.test.ts +186 -0
  501. package/src/billing/stripe/checkout.ts +82 -0
  502. package/src/billing/stripe/client.test.ts +64 -0
  503. package/src/billing/stripe/client.ts +39 -0
  504. package/src/billing/stripe/credit-prices.test.ts +114 -0
  505. package/src/billing/stripe/credit-prices.ts +113 -0
  506. package/src/billing/stripe/index.ts +14 -0
  507. package/src/billing/stripe/payment-methods-detach-all.test.ts +53 -0
  508. package/src/billing/stripe/payment-methods.test.ts +157 -0
  509. package/src/billing/stripe/payment-methods.ts +76 -0
  510. package/src/billing/stripe/portal.test.ts +63 -0
  511. package/src/billing/stripe/portal.ts +25 -0
  512. package/src/billing/stripe/setup-intent.test.ts +78 -0
  513. package/src/billing/stripe/setup-intent.ts +34 -0
  514. package/src/billing/stripe/stripe-payment-processor.test.ts +517 -0
  515. package/src/billing/stripe/stripe-payment-processor.ts +255 -0
  516. package/src/billing/stripe/tenant-store.test.ts +124 -0
  517. package/src/billing/stripe/tenant-store.ts +151 -0
  518. package/src/billing/stripe/types.ts +53 -0
  519. package/src/billing/webhook-seen-repository.ts +24 -0
  520. package/src/config/billing-env.test.ts +54 -0
  521. package/src/config/index.ts +44 -0
  522. package/src/config/logger.ts +12 -0
  523. package/src/config/provider-endpoints.ts +14 -0
  524. package/src/credits/auto-topup-charge.test.ts +292 -0
  525. package/src/credits/auto-topup-charge.ts +171 -0
  526. package/src/credits/auto-topup-event-log-repository.test.ts +99 -0
  527. package/src/credits/auto-topup-event-log-repository.ts +30 -0
  528. package/src/credits/auto-topup-schedule.test.ts +179 -0
  529. package/src/credits/auto-topup-schedule.ts +93 -0
  530. package/src/credits/auto-topup-settings-repository.test.ts +123 -0
  531. package/src/credits/auto-topup-settings-repository.ts +245 -0
  532. package/src/credits/auto-topup-usage.test.ts +220 -0
  533. package/src/credits/auto-topup-usage.ts +68 -0
  534. package/src/credits/credit-expiry-cron.test.ts +125 -0
  535. package/src/credits/credit-expiry-cron.ts +76 -0
  536. package/src/credits/credit-ledger-extra.test.ts +57 -0
  537. package/src/credits/credit-ledger.bench.ts +56 -0
  538. package/src/credits/credit-ledger.test.ts +276 -0
  539. package/src/credits/credit-ledger.ts +450 -0
  540. package/src/credits/credit-transaction-repository.test.ts +274 -0
  541. package/src/credits/credit-transaction-repository.ts +62 -0
  542. package/src/credits/credit.test.ts +234 -0
  543. package/src/credits/credit.ts +160 -0
  544. package/src/credits/dividend-cron.test.ts +158 -0
  545. package/src/credits/dividend-cron.ts +127 -0
  546. package/src/credits/dividend-repository.test.ts +223 -0
  547. package/src/credits/dividend-repository.ts +182 -0
  548. package/src/credits/index.ts +25 -0
  549. package/src/credits/repository-types.ts +33 -0
  550. package/src/credits/signup-grant.test.ts +63 -0
  551. package/src/credits/signup-grant.ts +44 -0
  552. package/src/credits/tenant-customer-repository.ts +28 -0
  553. package/src/db/auth-user-repository.ts +124 -0
  554. package/src/db/credit-column.ts +17 -0
  555. package/src/db/index.ts +21 -0
  556. package/src/db/schema/account-deletion-requests.ts +41 -0
  557. package/src/db/schema/account-export-requests.ts +24 -0
  558. package/src/db/schema/admin-audit.ts +26 -0
  559. package/src/db/schema/admin-users.ts +31 -0
  560. package/src/db/schema/affiliate-fraud.ts +23 -0
  561. package/src/db/schema/affiliate.ts +38 -0
  562. package/src/db/schema/coupon-codes.ts +22 -0
  563. package/src/db/schema/credit-auto-topup-settings.ts +32 -0
  564. package/src/db/schema/credit-auto-topup.ts +26 -0
  565. package/src/db/schema/credits.ts +44 -0
  566. package/src/db/schema/dividend-distributions.ts +24 -0
  567. package/src/db/schema/email-notifications.ts +26 -0
  568. package/src/db/schema/index.ts +33 -0
  569. package/src/db/schema/meter-events.ts +70 -0
  570. package/src/db/schema/notification-preferences.ts +19 -0
  571. package/src/db/schema/notification-queue.ts +45 -0
  572. package/src/db/schema/org-memberships.ts +20 -0
  573. package/src/db/schema/organization-members.ts +37 -0
  574. package/src/db/schema/payram.ts +26 -0
  575. package/src/db/schema/platform-api-keys.ts +25 -0
  576. package/src/db/schema/promotion-redemptions.ts +23 -0
  577. package/src/db/schema/promotions.ts +57 -0
  578. package/src/db/schema/provider-credentials.ts +41 -0
  579. package/src/db/schema/rate-limit-entries.ts +12 -0
  580. package/src/db/schema/secret-audit-log.ts +20 -0
  581. package/src/db/schema/session-usage.ts +24 -0
  582. package/src/db/schema/spending-limits.ts +9 -0
  583. package/src/db/schema/tenant-addons.ts +14 -0
  584. package/src/db/schema/tenant-api-keys.ts +26 -0
  585. package/src/db/schema/tenant-capability-settings.ts +17 -0
  586. package/src/db/schema/tenant-customers.ts +35 -0
  587. package/src/db/schema/tenants.ts +23 -0
  588. package/src/db/schema/user-roles.ts +23 -0
  589. package/src/db/schema/webhook-seen-events.ts +14 -0
  590. package/src/email/billing-emails.test.ts +198 -0
  591. package/src/email/billing-emails.ts +211 -0
  592. package/src/email/client.test.ts +149 -0
  593. package/src/email/client.ts +137 -0
  594. package/src/email/drizzle-billing-email-repository.test.ts +52 -0
  595. package/src/email/drizzle-billing-email-repository.ts +59 -0
  596. package/src/email/index.ts +57 -0
  597. package/src/email/notification-preferences-store.test.ts +102 -0
  598. package/src/email/notification-preferences-store.ts +90 -0
  599. package/src/email/notification-queue-store.test.ts +215 -0
  600. package/src/email/notification-queue-store.ts +127 -0
  601. package/src/email/notification-repository-types.ts +101 -0
  602. package/src/email/notification-service.test.ts +178 -0
  603. package/src/email/notification-service.ts +265 -0
  604. package/src/email/notification-templates.test.ts +261 -0
  605. package/src/email/notification-templates.ts +727 -0
  606. package/src/email/notification-worker.test.ts +189 -0
  607. package/src/email/notification-worker.ts +133 -0
  608. package/src/email/require-verified.ts +65 -0
  609. package/src/email/resend-adapter.test.ts +253 -0
  610. package/src/email/resend-adapter.ts +157 -0
  611. package/src/email/templates.test.ts +217 -0
  612. package/src/email/templates.ts +469 -0
  613. package/src/email/verification.test.ts +185 -0
  614. package/src/email/verification.ts +110 -0
  615. package/src/index.ts +51 -0
  616. package/src/metering/aggregator.test.ts +239 -0
  617. package/src/metering/aggregator.ts +160 -0
  618. package/src/metering/dlq.test.ts +134 -0
  619. package/src/metering/dlq.ts +102 -0
  620. package/src/metering/drizzle-usage-summary-repository.ts +167 -0
  621. package/src/metering/emitter.test.ts +202 -0
  622. package/src/metering/emitter.ts +227 -0
  623. package/src/metering/index.ts +21 -0
  624. package/src/metering/load-test.bench.ts +130 -0
  625. package/src/metering/meter-event-repository.ts +87 -0
  626. package/src/metering/meter-repositories.test.ts +491 -0
  627. package/src/metering/metering.test.ts +1317 -0
  628. package/src/metering/reconciliation-cron.test.ts +202 -0
  629. package/src/metering/reconciliation-cron.ts +134 -0
  630. package/src/metering/reconciliation-repository.test.ts +196 -0
  631. package/src/metering/reconciliation-repository.ts +83 -0
  632. package/src/metering/types.ts +93 -0
  633. package/src/metering/wal.test.ts +222 -0
  634. package/src/metering/wal.ts +139 -0
  635. package/src/middleware/csrf.test.ts +178 -0
  636. package/src/middleware/csrf.ts +101 -0
  637. package/src/middleware/drizzle-rate-limit-repository.test.ts +97 -0
  638. package/src/middleware/drizzle-rate-limit-repository.ts +57 -0
  639. package/src/middleware/get-client-ip.test.ts +49 -0
  640. package/src/middleware/get-client-ip.ts +62 -0
  641. package/src/middleware/index.ts +12 -0
  642. package/src/middleware/rate-limit-repository.ts +22 -0
  643. package/src/middleware/rate-limit.test.ts +338 -0
  644. package/src/middleware/rate-limit.ts +169 -0
  645. package/src/security/credential-vault/audit-repository.test.ts +91 -0
  646. package/src/security/credential-vault/audit-repository.ts +64 -0
  647. package/src/security/credential-vault/credential-repository.test.ts +264 -0
  648. package/src/security/credential-vault/credential-repository.ts +233 -0
  649. package/src/security/credential-vault/index.ts +26 -0
  650. package/src/security/credential-vault/key-rotation.test.ts +139 -0
  651. package/src/security/credential-vault/key-rotation.ts +70 -0
  652. package/src/security/credential-vault/migrate-plaintext.test.ts +138 -0
  653. package/src/security/credential-vault/migrate-plaintext.ts +101 -0
  654. package/src/security/credential-vault/migration-check.test.ts +533 -0
  655. package/src/security/credential-vault/migration-check.ts +88 -0
  656. package/src/security/credential-vault/store.test.ts +569 -0
  657. package/src/security/credential-vault/store.ts +284 -0
  658. package/src/security/encryption.test.ts +114 -0
  659. package/src/security/encryption.ts +65 -0
  660. package/src/security/host-validation.test.ts +136 -0
  661. package/src/security/host-validation.ts +116 -0
  662. package/src/security/index.ts +59 -0
  663. package/src/security/key-audit.test.ts +57 -0
  664. package/src/security/key-audit.ts +45 -0
  665. package/src/security/key-injection.test.ts +131 -0
  666. package/src/security/key-injection.ts +71 -0
  667. package/src/security/key-validation.test.ts +111 -0
  668. package/src/security/key-validation.ts +84 -0
  669. package/src/security/redirect-allowlist.test.ts +70 -0
  670. package/src/security/redirect-allowlist.ts +35 -0
  671. package/src/security/tenant-keys/capability-settings-store.test.ts +98 -0
  672. package/src/security/tenant-keys/capability-settings-store.ts +53 -0
  673. package/src/security/tenant-keys/index.ts +10 -0
  674. package/src/security/tenant-keys/key-resolution-repository.test.ts +95 -0
  675. package/src/security/tenant-keys/key-resolution-repository.ts +31 -0
  676. package/src/security/tenant-keys/key-resolution.test.ts +173 -0
  677. package/src/security/tenant-keys/key-resolution.ts +87 -0
  678. package/src/security/tenant-keys/org-key-resolution.test.ts +217 -0
  679. package/src/security/tenant-keys/org-key-resolution.ts +76 -0
  680. package/src/security/tenant-keys/tenant-key-repository.test.ts +143 -0
  681. package/src/security/tenant-keys/tenant-key-repository.ts +130 -0
  682. package/src/security/types.ts +43 -0
  683. package/src/tenancy/drizzle-org-repository.ts +169 -0
  684. package/src/tenancy/index.ts +6 -0
  685. package/src/tenancy/org-member-repository.ts +159 -0
  686. package/src/tenancy/org-repository.test.ts +172 -0
  687. package/src/tenancy/org-service.test.ts +634 -0
  688. package/src/tenancy/org-service.ts +290 -0
  689. package/src/test/db.ts +97 -0
  690. package/src/trpc/index.ts +11 -0
  691. package/src/trpc/init.test.ts +196 -0
  692. package/src/trpc/init.ts +138 -0
  693. package/tsconfig.json +20 -0
  694. package/vitest.config.ts +8 -0
@@ -0,0 +1,1046 @@
1
+ import { existsSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
2
+ import { eq, sql } from "drizzle-orm";
3
+ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
4
+ import { meterEvents } from "../db/schema/meter-events.js";
5
+ import { createTestDb, truncateAllTables } from "../test/db.js";
6
+ import { Credit } from "../credits/credit.js";
7
+ import { MeterAggregator } from "./aggregator.js";
8
+ import { DrizzleUsageSummaryRepository } from "./drizzle-usage-summary-repository.js";
9
+ import { MeterEmitter } from "./emitter.js";
10
+ import { DrizzleMeterEventRepository } from "./meter-event-repository.js";
11
+ function makeEmitter(db, opts = {}) {
12
+ return new MeterEmitter(new DrizzleMeterEventRepository(db), opts);
13
+ }
14
+ // Clean up the default WAL/DLQ files before any test runs to prevent
15
+ // stale events from being replayed into fresh in-memory databases.
16
+ // This is the root cause of the flaky "preserves event ordering" test —
17
+ // the MeterEmitter constructor calls replayWAL() which reads from these
18
+ // files if they exist from a prior test run.
19
+ const DEFAULT_WAL_PATH = "./data/meter-wal.jsonl";
20
+ const DEFAULT_DLQ_PATH = "./data/meter-dlq.jsonl";
21
+ function cleanDefaultWalFiles() {
22
+ try {
23
+ unlinkSync(DEFAULT_WAL_PATH);
24
+ }
25
+ catch {
26
+ /* ignore */
27
+ }
28
+ try {
29
+ unlinkSync(DEFAULT_DLQ_PATH);
30
+ }
31
+ catch {
32
+ /* ignore */
33
+ }
34
+ }
35
+ beforeAll(() => {
36
+ cleanDefaultWalFiles();
37
+ });
38
+ function makeEvent(overrides = {}) {
39
+ return {
40
+ tenant: "tenant-1",
41
+ cost: Credit.fromDollars(0.001),
42
+ charge: Credit.fromDollars(0.002),
43
+ capability: "embeddings",
44
+ provider: "openai",
45
+ timestamp: Date.now(),
46
+ ...overrides,
47
+ };
48
+ }
49
+ // -- Schema -----------------------------------------------------------------
50
+ // Tests 1-3 share one pool (read-only checks). "is idempotent" uses local pools inline.
51
+ describe("Drizzle schema", () => {
52
+ let schemaPool;
53
+ beforeAll(async () => {
54
+ ({ pool: schemaPool } = await createTestDb());
55
+ });
56
+ afterAll(async () => {
57
+ await schemaPool.close();
58
+ });
59
+ it("creates meter_events table", async () => {
60
+ const result = await schemaPool.query("SELECT table_name FROM information_schema.tables WHERE table_schema = 'public' AND table_name = 'meter_events'");
61
+ expect(result.rows).toHaveLength(1);
62
+ });
63
+ it("creates usage_summaries table", async () => {
64
+ const result = await schemaPool.query("SELECT table_name FROM information_schema.tables WHERE table_schema = 'public' AND table_name = 'usage_summaries'");
65
+ expect(result.rows).toHaveLength(1);
66
+ });
67
+ it("creates indexes", async () => {
68
+ const result = await schemaPool.query("SELECT indexname FROM pg_indexes WHERE schemaname = 'public' AND indexname LIKE 'idx_meter_%'");
69
+ expect(result.rows.length).toBeGreaterThanOrEqual(1);
70
+ });
71
+ it("is idempotent", async () => {
72
+ const { pool: pool1 } = await createTestDb();
73
+ await pool1.close();
74
+ const { pool: pool2 } = await createTestDb();
75
+ await pool2.close();
76
+ });
77
+ });
78
+ // -- Emitter ----------------------------------------------------------------
79
+ // MeterEmitter uses db.transaction() internally so we use truncateAllTables (not savepoints).
80
+ // The flush-failure test uses its own local db/pool to avoid poisoning shared state.
81
+ describe("MeterEmitter", () => {
82
+ let db;
83
+ let pool;
84
+ let emitter;
85
+ const TEST_WAL_PATH = `/tmp/wopr-test-wal-${Date.now()}.jsonl`;
86
+ const TEST_DLQ_PATH = `/tmp/wopr-test-dlq-${Date.now()}.jsonl`;
87
+ beforeAll(async () => {
88
+ ({ db, pool } = await createTestDb());
89
+ });
90
+ afterAll(async () => {
91
+ await pool.close();
92
+ });
93
+ beforeEach(async () => {
94
+ await truncateAllTables(pool);
95
+ // Disable auto-flush timer in tests; we flush manually.
96
+ emitter = makeEmitter(db, {
97
+ flushIntervalMs: 60_000,
98
+ batchSize: 100,
99
+ walPath: TEST_WAL_PATH,
100
+ dlqPath: TEST_DLQ_PATH,
101
+ });
102
+ });
103
+ afterEach(async () => {
104
+ await emitter.close();
105
+ // Clean up test files.
106
+ try {
107
+ unlinkSync(TEST_WAL_PATH);
108
+ }
109
+ catch {
110
+ // Ignore if file doesn't exist.
111
+ }
112
+ try {
113
+ unlinkSync(TEST_DLQ_PATH);
114
+ }
115
+ catch {
116
+ // Ignore if file doesn't exist.
117
+ }
118
+ });
119
+ it("buffers events without writing until flush", async () => {
120
+ emitter.emit(makeEvent());
121
+ expect(emitter.pending).toBe(1);
122
+ const rows = (await db.select({ cnt: sql `COUNT(*)` }).from(meterEvents))[0];
123
+ expect(rows?.cnt).toBe(0);
124
+ });
125
+ it("flush writes buffered events to the database", async () => {
126
+ emitter.emit(makeEvent());
127
+ emitter.emit(makeEvent({ tenant: "tenant-2" }));
128
+ const flushed = await emitter.flush();
129
+ expect(flushed).toBe(2);
130
+ expect(emitter.pending).toBe(0);
131
+ const rows = (await db.select({ cnt: sql `COUNT(*)` }).from(meterEvents))[0];
132
+ expect(rows?.cnt).toBe(2);
133
+ });
134
+ it("persists all MeterEvent fields", async () => {
135
+ const event = makeEvent({
136
+ tenant: "t-abc",
137
+ cost: Credit.fromDollars(0.05),
138
+ charge: Credit.fromDollars(0.1),
139
+ capability: "voice",
140
+ provider: "deepgram",
141
+ timestamp: 1700000000000,
142
+ sessionId: "sess-123",
143
+ duration: 5000,
144
+ });
145
+ emitter.emit(event);
146
+ await emitter.flush();
147
+ const rows = await emitter.queryEvents("t-abc");
148
+ expect(rows).toHaveLength(1);
149
+ expect(rows[0].tenant).toBe("t-abc");
150
+ expect(rows[0].cost).toBe(Credit.fromDollars(0.05).toRaw());
151
+ expect(rows[0].charge).toBe(Credit.fromDollars(0.1).toRaw());
152
+ expect(rows[0].capability).toBe("voice");
153
+ expect(rows[0].provider).toBe("deepgram");
154
+ expect(rows[0].timestamp).toBe(1700000000000);
155
+ expect(rows[0].session_id).toBe("sess-123");
156
+ expect(rows[0].duration).toBe(5000);
157
+ });
158
+ it("handles null optional fields", async () => {
159
+ emitter.emit(makeEvent({ sessionId: undefined, duration: undefined }));
160
+ await emitter.flush();
161
+ const rows = await emitter.queryEvents("tenant-1");
162
+ expect(rows[0].session_id).toBeNull();
163
+ expect(rows[0].duration).toBeNull();
164
+ });
165
+ it("generates unique IDs for each event", async () => {
166
+ emitter.emit(makeEvent());
167
+ emitter.emit(makeEvent());
168
+ await emitter.flush();
169
+ const rows = await emitter.queryEvents("tenant-1");
170
+ expect(rows).toHaveLength(2);
171
+ expect(rows[0].id).not.toBe(rows[1].id);
172
+ });
173
+ it("auto-flushes when batch size is reached", async () => {
174
+ const smallBatch = makeEmitter(db, { flushIntervalMs: 60_000, batchSize: 3 });
175
+ smallBatch.emit(makeEvent());
176
+ smallBatch.emit(makeEvent());
177
+ // Third event triggers auto-flush.
178
+ smallBatch.emit(makeEvent());
179
+ const rows = (await db.select({ cnt: sql `COUNT(*)` }).from(meterEvents))[0];
180
+ expect(rows?.cnt).toBe(3);
181
+ expect(smallBatch.pending).toBe(0);
182
+ smallBatch.close();
183
+ });
184
+ it("close flushes remaining events", async () => {
185
+ emitter.emit(makeEvent());
186
+ emitter.emit(makeEvent());
187
+ emitter.close();
188
+ const rows = (await db.select({ cnt: sql `COUNT(*)` }).from(meterEvents))[0];
189
+ expect(rows?.cnt).toBe(2);
190
+ });
191
+ it("ignores events after close", async () => {
192
+ emitter.close();
193
+ emitter.emit(makeEvent());
194
+ expect(emitter.pending).toBe(0);
195
+ });
196
+ it("re-adds events to buffer on flush failure", async () => {
197
+ // Use local db/pool so closing it doesn't poison the shared beforeAll pool.
198
+ const { db: localDb, pool: localPool } = await createTestDb();
199
+ const localEmitter = makeEmitter(localDb, {
200
+ flushIntervalMs: 60_000,
201
+ batchSize: 100,
202
+ walPath: TEST_WAL_PATH,
203
+ dlqPath: TEST_DLQ_PATH,
204
+ });
205
+ localEmitter.emit(makeEvent());
206
+ localEmitter.emit(makeEvent());
207
+ expect(localEmitter.pending).toBe(2);
208
+ await localPool.close();
209
+ // Should not throw even though db is closed.
210
+ const flushed = await localEmitter.flush();
211
+ expect(flushed).toBe(0);
212
+ // Events should be back in the buffer for retry.
213
+ expect(localEmitter.pending).toBe(2);
214
+ localEmitter.close();
215
+ });
216
+ it("queryEvents returns events for a specific tenant", async () => {
217
+ emitter.emit(makeEvent({ tenant: "t-1" }));
218
+ emitter.emit(makeEvent({ tenant: "t-2" }));
219
+ emitter.emit(makeEvent({ tenant: "t-1" }));
220
+ await emitter.flush();
221
+ const t1Events = await emitter.queryEvents("t-1");
222
+ expect(t1Events).toHaveLength(2);
223
+ const t2Events = await emitter.queryEvents("t-2");
224
+ expect(t2Events).toHaveLength(1);
225
+ });
226
+ it("flush returns 0 when buffer is empty", async () => {
227
+ expect(await emitter.flush()).toBe(0);
228
+ });
229
+ });
230
+ // -- Concurrent sessions (STT + LLM + TTS) ---------------------------------
231
+ // Shares ONE pool across all tests via truncateAllTables
232
+ describe("MeterEmitter - concurrent multi-provider sessions", () => {
233
+ let db;
234
+ let pool;
235
+ let emitter;
236
+ beforeAll(async () => {
237
+ ({ db, pool } = await createTestDb());
238
+ });
239
+ afterAll(async () => {
240
+ await pool.close();
241
+ });
242
+ beforeEach(async () => {
243
+ await truncateAllTables(pool);
244
+ emitter = makeEmitter(db, { flushIntervalMs: 60_000 });
245
+ });
246
+ afterEach(async () => {
247
+ await emitter.close();
248
+ });
249
+ it("groups multiple providers under one sessionId", async () => {
250
+ const sessionId = "voice-session-1";
251
+ emitter.emit(makeEvent({ capability: "stt", provider: "deepgram", sessionId }));
252
+ emitter.emit(makeEvent({ capability: "chat", provider: "openai", sessionId }));
253
+ emitter.emit(makeEvent({ capability: "tts", provider: "elevenlabs", sessionId }));
254
+ await emitter.flush();
255
+ const rows = await db.select().from(meterEvents).where(eq(meterEvents.sessionId, sessionId));
256
+ expect(rows).toHaveLength(3);
257
+ const caps = rows.map((r) => r.capability).sort();
258
+ expect(caps).toEqual(["chat", "stt", "tts"]);
259
+ const providers = rows.map((r) => r.provider).sort();
260
+ expect(providers).toEqual(["deepgram", "elevenlabs", "openai"]);
261
+ });
262
+ it("handles events from different sessions simultaneously", async () => {
263
+ emitter.emit(makeEvent({ sessionId: "sess-a", capability: "stt" }));
264
+ emitter.emit(makeEvent({ sessionId: "sess-b", capability: "stt" }));
265
+ emitter.emit(makeEvent({ sessionId: "sess-a", capability: "tts" }));
266
+ await emitter.flush();
267
+ const sessA = (await db.select({ cnt: sql `COUNT(*)` }).from(meterEvents).where(eq(meterEvents.sessionId, "sess-a")))[0];
268
+ const sessB = (await db.select({ cnt: sql `COUNT(*)` }).from(meterEvents).where(eq(meterEvents.sessionId, "sess-b")))[0];
269
+ expect(sessA?.cnt).toBe(2);
270
+ expect(sessB?.cnt).toBe(1);
271
+ });
272
+ });
273
+ // -- Aggregator -------------------------------------------------------------
274
+ // Shares ONE pool across all tests via truncateAllTables
275
+ describe("MeterAggregator", () => {
276
+ let db;
277
+ let pool;
278
+ let emitter;
279
+ let aggregator;
280
+ const WINDOW = 60_000; // 1 minute
281
+ beforeAll(async () => {
282
+ ({ db, pool } = await createTestDb());
283
+ });
284
+ afterAll(async () => {
285
+ await pool.close();
286
+ });
287
+ beforeEach(async () => {
288
+ await truncateAllTables(pool);
289
+ emitter = makeEmitter(db, { flushIntervalMs: 60_000 });
290
+ aggregator = new MeterAggregator(new DrizzleUsageSummaryRepository(db), { windowMs: WINDOW });
291
+ });
292
+ afterEach(async () => {
293
+ aggregator.stop();
294
+ await emitter.close();
295
+ });
296
+ it("aggregates events from completed windows", async () => {
297
+ // Insert events in a past window.
298
+ const pastWindow = Math.floor(Date.now() / WINDOW) * WINDOW - WINDOW;
299
+ emitter.emit(makeEvent({
300
+ tenant: "t-1",
301
+ cost: Credit.fromDollars(0.01),
302
+ charge: Credit.fromDollars(0.02),
303
+ timestamp: pastWindow + 100,
304
+ }));
305
+ emitter.emit(makeEvent({
306
+ tenant: "t-1",
307
+ cost: Credit.fromDollars(0.03),
308
+ charge: Credit.fromDollars(0.06),
309
+ timestamp: pastWindow + 200,
310
+ }));
311
+ await emitter.flush();
312
+ const count = await aggregator.aggregate();
313
+ expect(count).toBe(1); // One group: t-1 + embeddings + openai.
314
+ const summaries = await aggregator.querySummaries("t-1");
315
+ expect(summaries).toHaveLength(1);
316
+ expect(summaries[0].event_count).toBe(2);
317
+ expect(summaries[0].total_cost).toBe(Credit.fromDollars(0.04).toRaw());
318
+ expect(summaries[0].total_charge).toBe(Credit.fromDollars(0.08).toRaw());
319
+ });
320
+ it("groups by tenant, capability, and provider", async () => {
321
+ const pastWindow = Math.floor(Date.now() / WINDOW) * WINDOW - WINDOW;
322
+ emitter.emit(makeEvent({ tenant: "t-1", capability: "embeddings", provider: "openai", timestamp: pastWindow + 10 }));
323
+ emitter.emit(makeEvent({ tenant: "t-1", capability: "voice", provider: "deepgram", timestamp: pastWindow + 20 }));
324
+ emitter.emit(makeEvent({ tenant: "t-2", capability: "embeddings", provider: "openai", timestamp: pastWindow + 30 }));
325
+ await emitter.flush();
326
+ const count = await aggregator.aggregate();
327
+ expect(count).toBe(3); // Three distinct groups.
328
+ const t1Summaries = await aggregator.querySummaries("t-1");
329
+ expect(t1Summaries).toHaveLength(2);
330
+ const t2Summaries = await aggregator.querySummaries("t-2");
331
+ expect(t2Summaries).toHaveLength(1);
332
+ });
333
+ it("does not aggregate the current (incomplete) window", async () => {
334
+ // Insert an event in the *current* window.
335
+ emitter.emit(makeEvent({ timestamp: Date.now() }));
336
+ await emitter.flush();
337
+ const count = await aggregator.aggregate();
338
+ // If there are no events in prior windows, nothing to aggregate.
339
+ const summaries = await aggregator.querySummaries("tenant-1");
340
+ // Should either be 0 summaries, or count should be 0 for just-current events.
341
+ expect(count).toBe(0);
342
+ expect(summaries).toHaveLength(0);
343
+ });
344
+ it("is idempotent - does not double-aggregate", async () => {
345
+ const pastWindow = Math.floor(Date.now() / WINDOW) * WINDOW - WINDOW;
346
+ emitter.emit(makeEvent({
347
+ tenant: "t-1",
348
+ cost: Credit.fromDollars(0.01),
349
+ charge: Credit.fromDollars(0.02),
350
+ timestamp: pastWindow + 10,
351
+ }));
352
+ await emitter.flush();
353
+ await aggregator.aggregate();
354
+ await aggregator.aggregate(); // Second call should be no-op.
355
+ const summaries = await aggregator.querySummaries("t-1");
356
+ expect(summaries).toHaveLength(1);
357
+ expect(summaries[0].event_count).toBe(1);
358
+ });
359
+ it("aggregates duration for session-based capabilities", async () => {
360
+ const pastWindow = Math.floor(Date.now() / WINDOW) * WINDOW - WINDOW;
361
+ emitter.emit(makeEvent({
362
+ tenant: "t-1",
363
+ capability: "voice",
364
+ duration: 3000,
365
+ timestamp: pastWindow + 10,
366
+ }));
367
+ emitter.emit(makeEvent({
368
+ tenant: "t-1",
369
+ capability: "voice",
370
+ duration: 5000,
371
+ timestamp: pastWindow + 20,
372
+ }));
373
+ await emitter.flush();
374
+ await aggregator.aggregate();
375
+ const summaries = await aggregator.querySummaries("t-1");
376
+ const voiceSummary = summaries.find((s) => s.capability === "voice");
377
+ expect(voiceSummary).toEqual(expect.objectContaining({
378
+ capability: "voice",
379
+ tenant: "t-1",
380
+ total_duration: 8000,
381
+ }));
382
+ });
383
+ it("getTenantTotal returns aggregate totals", async () => {
384
+ const pastWindow = Math.floor(Date.now() / WINDOW) * WINDOW - WINDOW;
385
+ emitter.emit(makeEvent({
386
+ tenant: "t-1",
387
+ cost: Credit.fromDollars(0.01),
388
+ charge: Credit.fromDollars(0.02),
389
+ timestamp: pastWindow + 10,
390
+ }));
391
+ emitter.emit(makeEvent({
392
+ tenant: "t-1",
393
+ cost: Credit.fromDollars(0.05),
394
+ charge: Credit.fromDollars(0.1),
395
+ capability: "voice",
396
+ timestamp: pastWindow + 20,
397
+ }));
398
+ await emitter.flush();
399
+ await aggregator.aggregate();
400
+ const total = await aggregator.getTenantTotal("t-1", 0);
401
+ expect(total.totalCost).toBe(Credit.fromDollars(0.06).toRaw());
402
+ expect(total.totalCharge).toBe(Credit.fromDollars(0.12).toRaw());
403
+ expect(total.eventCount).toBe(2);
404
+ });
405
+ it("getTenantTotal returns zeros for unknown tenant", async () => {
406
+ const total = await aggregator.getTenantTotal("nonexistent", 0);
407
+ expect(total.totalCost).toBe(0);
408
+ expect(total.totalCharge).toBe(0);
409
+ expect(total.eventCount).toBe(0);
410
+ });
411
+ it("querySummaries respects since/until filters", async () => {
412
+ const now = Date.now();
413
+ const twoWindowsAgo = Math.floor(now / WINDOW) * WINDOW - 2 * WINDOW;
414
+ const oneWindowAgo = Math.floor(now / WINDOW) * WINDOW - WINDOW;
415
+ emitter.emit(makeEvent({ tenant: "t-1", timestamp: twoWindowsAgo + 10 }));
416
+ emitter.emit(makeEvent({ tenant: "t-1", timestamp: oneWindowAgo + 10 }));
417
+ await emitter.flush();
418
+ // Aggregate both windows.
419
+ await aggregator.aggregate(twoWindowsAgo + WINDOW + 1);
420
+ await aggregator.aggregate(now);
421
+ const all = await aggregator.querySummaries("t-1");
422
+ // Filter to only recent window.
423
+ const recent = await aggregator.querySummaries("t-1", { since: oneWindowAgo });
424
+ expect(recent.length).toBeLessThanOrEqual(all.length);
425
+ });
426
+ it("returns 0 when no events exist", async () => {
427
+ const count = await aggregator.aggregate();
428
+ expect(count).toBe(0);
429
+ });
430
+ });
431
+ // -- Aggregator edge cases --------------------------------------------------
432
+ describe("MeterAggregator - edge cases", () => {
433
+ let db;
434
+ let pool;
435
+ let emitter;
436
+ let aggregator;
437
+ const WINDOW = 60_000;
438
+ beforeAll(async () => {
439
+ ({ db, pool } = await createTestDb());
440
+ });
441
+ afterAll(async () => {
442
+ await pool.close();
443
+ });
444
+ beforeEach(async () => {
445
+ await truncateAllTables(pool);
446
+ emitter = makeEmitter(db, { flushIntervalMs: 60_000 });
447
+ aggregator = new MeterAggregator(new DrizzleUsageSummaryRepository(db), { windowMs: WINDOW });
448
+ });
449
+ afterEach(async () => {
450
+ aggregator.stop();
451
+ await emitter.close();
452
+ });
453
+ it("inserts sentinel for empty windows between events", async () => {
454
+ const now = Date.now();
455
+ const threeWindowsAgo = Math.floor(now / WINDOW) * WINDOW - 3 * WINDOW;
456
+ // Place one event 3 windows ago; windows 2-ago and 1-ago are empty.
457
+ emitter.emit(makeEvent({ tenant: "t-1", timestamp: threeWindowsAgo + 10 }));
458
+ await emitter.flush();
459
+ await aggregator.aggregate(now);
460
+ // The event window should produce a real summary.
461
+ const summaries = await aggregator.querySummaries("t-1");
462
+ expect(summaries).toHaveLength(1);
463
+ expect(summaries[0].event_count).toBe(1);
464
+ // Sentinel rows fill the empty windows; verify they exist via Drizzle.
465
+ const sentinels = (await db
466
+ .select({ cnt: sql `COUNT(*)` })
467
+ .from(
468
+ // Use usageSummaries table reference
469
+ sql `usage_summaries`)
470
+ .where(sql `tenant = '__sentinel__'`))[0];
471
+ expect(sentinels?.cnt).toBe(2);
472
+ });
473
+ it("handles single-event windows correctly", async () => {
474
+ const now = Date.now();
475
+ const pastWindow = Math.floor(now / WINDOW) * WINDOW - WINDOW;
476
+ emitter.emit(makeEvent({
477
+ tenant: "t-1",
478
+ cost: Credit.fromDollars(0.123),
479
+ charge: Credit.fromDollars(0.456),
480
+ timestamp: pastWindow + 500,
481
+ }));
482
+ await emitter.flush();
483
+ const count = await aggregator.aggregate(now);
484
+ expect(count).toBe(1);
485
+ const summaries = await aggregator.querySummaries("t-1");
486
+ expect(summaries).toHaveLength(1);
487
+ expect(summaries[0].event_count).toBe(1);
488
+ expect(summaries[0].total_cost).toBe(Credit.fromDollars(0.123).toRaw());
489
+ expect(summaries[0].total_charge).toBe(Credit.fromDollars(0.456).toRaw());
490
+ });
491
+ it("places event at exact window start into that window", async () => {
492
+ const now = Date.now();
493
+ const pastWindow = Math.floor(now / WINDOW) * WINDOW - WINDOW;
494
+ // Event at the exact start of the window (timestamp === windowStart).
495
+ emitter.emit(makeEvent({
496
+ tenant: "t-1",
497
+ cost: Credit.fromDollars(0.01),
498
+ charge: Credit.fromDollars(0.02),
499
+ timestamp: pastWindow,
500
+ }));
501
+ await emitter.flush();
502
+ await aggregator.aggregate(now);
503
+ const summaries = await aggregator.querySummaries("t-1");
504
+ expect(summaries).toHaveLength(1);
505
+ expect(summaries[0].event_count).toBe(1);
506
+ expect(summaries[0].window_start).toBe(pastWindow);
507
+ expect(summaries[0].window_end).toBe(pastWindow + WINDOW);
508
+ });
509
+ it("excludes event at exact window end from that window", async () => {
510
+ const now = Date.now();
511
+ const twoWindowsAgo = Math.floor(now / WINDOW) * WINDOW - 2 * WINDOW;
512
+ const oneWindowAgo = twoWindowsAgo + WINDOW;
513
+ // Event at the exact boundary (end of window 2-ago = start of window 1-ago).
514
+ emitter.emit(makeEvent({
515
+ tenant: "t-1",
516
+ cost: Credit.fromDollars(0.01),
517
+ charge: Credit.fromDollars(0.02),
518
+ timestamp: oneWindowAgo,
519
+ }));
520
+ await emitter.flush();
521
+ await aggregator.aggregate(now);
522
+ const summaries = await aggregator.querySummaries("t-1");
523
+ expect(summaries).toHaveLength(1);
524
+ // The event should be in the window starting at oneWindowAgo, not twoWindowsAgo.
525
+ expect(summaries[0].window_start).toBe(oneWindowAgo);
526
+ });
527
+ it("multi-tenant aggregation produces independent summaries", async () => {
528
+ const now = Date.now();
529
+ const pastWindow = Math.floor(now / WINDOW) * WINDOW - WINDOW;
530
+ const tenants = ["alpha", "beta", "gamma"];
531
+ for (const t of tenants) {
532
+ emitter.emit(makeEvent({
533
+ tenant: t,
534
+ cost: Credit.fromDollars(0.01),
535
+ charge: Credit.fromDollars(0.02),
536
+ capability: "chat",
537
+ timestamp: pastWindow + 10,
538
+ }));
539
+ emitter.emit(makeEvent({
540
+ tenant: t,
541
+ cost: Credit.fromDollars(0.03),
542
+ charge: Credit.fromDollars(0.06),
543
+ capability: "embeddings",
544
+ timestamp: pastWindow + 20,
545
+ }));
546
+ }
547
+ await emitter.flush();
548
+ await aggregator.aggregate(now);
549
+ for (const t of tenants) {
550
+ const summaries = await aggregator.querySummaries(t);
551
+ expect(summaries).toHaveLength(2); // chat + embeddings
552
+ const total = await aggregator.getTenantTotal(t, 0);
553
+ expect(total.eventCount).toBe(2);
554
+ expect(total.totalCost).toBe(Credit.fromDollars(0.04).toRaw());
555
+ expect(total.totalCharge).toBe(Credit.fromDollars(0.08).toRaw());
556
+ }
557
+ });
558
+ it("events spanning multiple windows are placed in correct windows", async () => {
559
+ const now = Date.now();
560
+ const threeWindowsAgo = Math.floor(now / WINDOW) * WINDOW - 3 * WINDOW;
561
+ const twoWindowsAgo = threeWindowsAgo + WINDOW;
562
+ const oneWindowAgo = twoWindowsAgo + WINDOW;
563
+ emitter.emit(makeEvent({
564
+ tenant: "t-1",
565
+ cost: Credit.fromDollars(0.01),
566
+ charge: Credit.fromDollars(0.02),
567
+ timestamp: threeWindowsAgo + 100,
568
+ }));
569
+ emitter.emit(makeEvent({
570
+ tenant: "t-1",
571
+ cost: Credit.fromDollars(0.03),
572
+ charge: Credit.fromDollars(0.06),
573
+ timestamp: twoWindowsAgo + 100,
574
+ }));
575
+ emitter.emit(makeEvent({
576
+ tenant: "t-1",
577
+ cost: Credit.fromDollars(0.05),
578
+ charge: Credit.fromDollars(0.1),
579
+ timestamp: oneWindowAgo + 100,
580
+ }));
581
+ await emitter.flush();
582
+ await aggregator.aggregate(now);
583
+ const summaries = await aggregator.querySummaries("t-1");
584
+ expect(summaries).toHaveLength(3);
585
+ // Verify each window has exactly one event with the correct cost.
586
+ const sorted = [...summaries].sort((a, b) => a.window_start - b.window_start);
587
+ expect(sorted[0].window_start).toBe(threeWindowsAgo);
588
+ expect(sorted[0].total_cost).toBe(Credit.fromDollars(0.01).toRaw());
589
+ expect(sorted[1].window_start).toBe(twoWindowsAgo);
590
+ expect(sorted[1].total_cost).toBe(Credit.fromDollars(0.03).toRaw());
591
+ expect(sorted[2].window_start).toBe(oneWindowAgo);
592
+ expect(sorted[2].total_cost).toBe(Credit.fromDollars(0.05).toRaw());
593
+ });
594
+ it("start/stop lifecycle does not leak timers", async () => {
595
+ aggregator.start(60_000);
596
+ aggregator.start(60_000); // Second start is a no-op.
597
+ aggregator.stop();
598
+ aggregator.stop(); // Double stop is safe.
599
+ });
600
+ });
601
+ // -- Aggregation accuracy verification --------------------------------------
602
+ // Shares ONE pool across all tests via truncateAllTables
603
+ describe("MeterAggregator - billing accuracy", () => {
604
+ let db;
605
+ let pool;
606
+ let emitter;
607
+ let aggregator;
608
+ beforeAll(async () => {
609
+ ({ db, pool } = await createTestDb());
610
+ });
611
+ afterAll(async () => {
612
+ await pool.close();
613
+ });
614
+ beforeEach(async () => {
615
+ await truncateAllTables(pool);
616
+ emitter = makeEmitter(db, { flushIntervalMs: 60_000 });
617
+ aggregator = new MeterAggregator(new DrizzleUsageSummaryRepository(db), { windowMs: 60_000 });
618
+ });
619
+ afterEach(async () => {
620
+ aggregator?.stop();
621
+ await emitter?.close();
622
+ });
623
+ it("aggregated totals exactly match sum of individual events", async () => {
624
+ const WINDOW = 60_000;
625
+ const now = Date.now();
626
+ const pastWindow = Math.floor(now / WINDOW) * WINDOW - WINDOW;
627
+ // Generate events with known, precise costs.
628
+ const events = [
629
+ makeEvent({
630
+ tenant: "billing-test",
631
+ cost: Credit.fromDollars(0.001),
632
+ charge: Credit.fromDollars(0.002),
633
+ timestamp: pastWindow + 10,
634
+ }),
635
+ makeEvent({
636
+ tenant: "billing-test",
637
+ cost: Credit.fromDollars(0.002),
638
+ charge: Credit.fromDollars(0.004),
639
+ timestamp: pastWindow + 20,
640
+ }),
641
+ makeEvent({
642
+ tenant: "billing-test",
643
+ cost: Credit.fromDollars(0.003),
644
+ charge: Credit.fromDollars(0.006),
645
+ timestamp: pastWindow + 30,
646
+ }),
647
+ makeEvent({
648
+ tenant: "billing-test",
649
+ cost: Credit.fromDollars(0.004),
650
+ charge: Credit.fromDollars(0.008),
651
+ timestamp: pastWindow + 40,
652
+ }),
653
+ makeEvent({
654
+ tenant: "billing-test",
655
+ cost: Credit.fromDollars(0.005),
656
+ charge: Credit.fromDollars(0.01),
657
+ timestamp: pastWindow + 50,
658
+ }),
659
+ ];
660
+ const expectedCostRaw = events.reduce((s, e) => s + e.cost.toRaw(), 0);
661
+ const expectedChargeRaw = events.reduce((s, e) => s + e.charge.toRaw(), 0);
662
+ for (const e of events)
663
+ emitter.emit(e);
664
+ await emitter.flush();
665
+ await aggregator.aggregate(now);
666
+ const total = await aggregator.getTenantTotal("billing-test", 0);
667
+ expect(total.eventCount).toBe(5);
668
+ expect(total.totalCost).toBeCloseTo(expectedCostRaw, 0);
669
+ expect(total.totalCharge).toBeCloseTo(expectedChargeRaw, 0);
670
+ });
671
+ it("per-capability breakdown sums match tenant total", async () => {
672
+ const WINDOW = 60_000;
673
+ const now = Date.now();
674
+ const pastWindow = Math.floor(now / WINDOW) * WINDOW - WINDOW;
675
+ emitter.emit(makeEvent({
676
+ tenant: "t-1",
677
+ cost: Credit.fromDollars(0.1),
678
+ charge: Credit.fromDollars(0.2),
679
+ capability: "chat",
680
+ timestamp: pastWindow + 10,
681
+ }));
682
+ emitter.emit(makeEvent({
683
+ tenant: "t-1",
684
+ cost: Credit.fromDollars(0.05),
685
+ charge: Credit.fromDollars(0.1),
686
+ capability: "embeddings",
687
+ timestamp: pastWindow + 20,
688
+ }));
689
+ emitter.emit(makeEvent({
690
+ tenant: "t-1",
691
+ cost: Credit.fromDollars(0.15),
692
+ charge: Credit.fromDollars(0.3),
693
+ capability: "voice",
694
+ timestamp: pastWindow + 30,
695
+ }));
696
+ await emitter.flush();
697
+ await aggregator.aggregate(now);
698
+ const summaries = await aggregator.querySummaries("t-1");
699
+ const sumCost = summaries.reduce((s, r) => s + r.total_cost, 0);
700
+ const sumCharge = summaries.reduce((s, r) => s + r.total_charge, 0);
701
+ const total = await aggregator.getTenantTotal("t-1", 0);
702
+ expect(sumCost).toBeCloseTo(total.totalCost, 10);
703
+ expect(sumCharge).toBeCloseTo(total.totalCharge, 10);
704
+ expect(total.totalCost).toBe(Credit.fromDollars(0.3).toRaw());
705
+ expect(total.totalCharge).toBe(Credit.fromDollars(0.6).toRaw());
706
+ expect(total.eventCount).toBe(3);
707
+ });
708
+ });
709
+ // -- Emitter edge cases -----------------------------------------------------
710
+ // MeterEmitter edge cases close/reopen pool — keep per-test isolation
711
+ describe("MeterEmitter - edge cases", () => {
712
+ let db;
713
+ let pool;
714
+ let emitter;
715
+ beforeEach(async () => {
716
+ cleanDefaultWalFiles();
717
+ const testDb = await createTestDb();
718
+ db = testDb.db;
719
+ pool = testDb.pool;
720
+ // Clear any existing meter_events data from previous tests to ensure isolation
721
+ await db.delete(meterEvents);
722
+ emitter = makeEmitter(db, { flushIntervalMs: 60_000, batchSize: 100 });
723
+ });
724
+ afterEach(async () => {
725
+ await emitter.close();
726
+ await pool.close();
727
+ cleanDefaultWalFiles();
728
+ });
729
+ it("handles large batch of events", async () => {
730
+ for (let i = 0; i < 200; i++) {
731
+ emitter.emit(makeEvent({
732
+ tenant: "bulk-tenant",
733
+ cost: Credit.fromDollars(0.001),
734
+ charge: Credit.fromDollars(0.002),
735
+ timestamp: Date.now() + i,
736
+ }));
737
+ }
738
+ await emitter.flush();
739
+ const rows = (await db.select({ cnt: sql `COUNT(*)` }).from(meterEvents).where(eq(meterEvents.tenant, "bulk-tenant")))[0];
740
+ expect(rows?.cnt).toBe(200);
741
+ });
742
+ it("handles zero-cost events", async () => {
743
+ emitter.emit(makeEvent({ tenant: "free-tier", cost: Credit.ZERO, charge: Credit.ZERO }));
744
+ await emitter.flush();
745
+ const rows = await emitter.queryEvents("free-tier");
746
+ expect(rows).toHaveLength(1);
747
+ expect(rows[0].cost).toBe(0);
748
+ expect(rows[0].charge).toBe(0);
749
+ });
750
+ it("preserves event ordering within a tenant", async () => {
751
+ const base = 1700000000000;
752
+ emitter.emit(makeEvent({ tenant: "t-1", timestamp: base + 300 }));
753
+ emitter.emit(makeEvent({ tenant: "t-1", timestamp: base + 100 }));
754
+ emitter.emit(makeEvent({ tenant: "t-1", timestamp: base + 200 }));
755
+ await emitter.flush();
756
+ // queryEvents orders by timestamp DESC.
757
+ const rows = await emitter.queryEvents("t-1");
758
+ expect(rows).toHaveLength(3);
759
+ expect(rows[0].timestamp).toBe(base + 300);
760
+ expect(rows[1].timestamp).toBe(base + 200);
761
+ expect(rows[2].timestamp).toBe(base + 100);
762
+ });
763
+ it("handles multiple flushes without losing events", async () => {
764
+ emitter.emit(makeEvent({ tenant: "t-1" }));
765
+ await emitter.flush();
766
+ emitter.emit(makeEvent({ tenant: "t-1" }));
767
+ await emitter.flush();
768
+ emitter.emit(makeEvent({ tenant: "t-1" }));
769
+ await emitter.flush();
770
+ const rows = (await db.select({ cnt: sql `COUNT(*)` }).from(meterEvents).where(eq(meterEvents.tenant, "t-1")))[0];
771
+ expect(rows?.cnt).toBe(3);
772
+ });
773
+ });
774
+ // -- Append-only guarantee --------------------------------------------------
775
+ describe("append-only guarantee", () => {
776
+ it("meter_events table has no UPDATE or DELETE operations in emitter", async () => {
777
+ const { db, pool } = await createTestDb();
778
+ const emitter = makeEmitter(db, { flushIntervalMs: 60_000 });
779
+ emitter.emit(makeEvent({ tenant: "t-1" }));
780
+ await emitter.flush();
781
+ // Verify the event exists.
782
+ const before = (await db.select({ cnt: sql `COUNT(*)` }).from(meterEvents))[0];
783
+ expect(before?.cnt).toBe(1);
784
+ // Emit more -- never replaces.
785
+ emitter.emit(makeEvent({ tenant: "t-1" }));
786
+ await emitter.flush();
787
+ const after = (await db.select({ cnt: sql `COUNT(*)` }).from(meterEvents))[0];
788
+ expect(after?.cnt).toBe(2);
789
+ await emitter.close();
790
+ await pool.close();
791
+ });
792
+ });
793
+ // -- Fail-closed policy with WAL and DLQ -----------------------------------
794
+ // These tests intentionally close the pool — keep per-test isolation
795
+ describe("MeterEmitter - fail-closed policy", () => {
796
+ let db;
797
+ let pool;
798
+ let emitter;
799
+ const TEST_WAL_PATH = "/tmp/wopr-test-wal.jsonl";
800
+ const TEST_DLQ_PATH = "/tmp/wopr-test-dlq.jsonl";
801
+ beforeEach(async () => {
802
+ // Clean up test files before each test.
803
+ try {
804
+ unlinkSync(TEST_WAL_PATH);
805
+ }
806
+ catch {
807
+ // Ignore if file doesn't exist.
808
+ }
809
+ try {
810
+ unlinkSync(TEST_DLQ_PATH);
811
+ }
812
+ catch {
813
+ // Ignore if file doesn't exist.
814
+ }
815
+ const testDb = await createTestDb();
816
+ db = testDb.db;
817
+ pool = testDb.pool;
818
+ emitter = makeEmitter(db, {
819
+ flushIntervalMs: 60_000,
820
+ walPath: TEST_WAL_PATH,
821
+ dlqPath: TEST_DLQ_PATH,
822
+ maxRetries: 3,
823
+ });
824
+ });
825
+ afterEach(async () => {
826
+ await emitter.close();
827
+ await pool.close();
828
+ // Clean up test files after each test.
829
+ try {
830
+ unlinkSync(TEST_WAL_PATH);
831
+ }
832
+ catch {
833
+ // Ignore if file doesn't exist.
834
+ }
835
+ try {
836
+ unlinkSync(TEST_DLQ_PATH);
837
+ }
838
+ catch {
839
+ // Ignore if file doesn't exist.
840
+ }
841
+ });
842
+ it("writes events to WAL before buffering", async () => {
843
+ emitter.emit(makeEvent({ tenant: "t-1" }));
844
+ // WAL should exist immediately.
845
+ expect(existsSync(TEST_WAL_PATH)).toBe(true);
846
+ const content = readFileSync(TEST_WAL_PATH, "utf8");
847
+ const lines = content.trim().split("\n");
848
+ expect(lines).toHaveLength(1);
849
+ const event = JSON.parse(lines[0]);
850
+ expect(event.tenant).toBe("t-1");
851
+ expect(event.id).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i);
852
+ });
853
+ it("clears WAL after successful flush", async () => {
854
+ emitter.emit(makeEvent({ tenant: "t-1" }));
855
+ expect(existsSync(TEST_WAL_PATH)).toBe(true);
856
+ await emitter.flush();
857
+ // WAL should not exist after successful flush.
858
+ expect(existsSync(TEST_WAL_PATH)).toBe(false);
859
+ });
860
+ it("moves events to DLQ after max retries", async () => {
861
+ emitter.emit(makeEvent({ tenant: "t-1" }));
862
+ // Close the database to force flush failures.
863
+ pool.close();
864
+ // Trigger max retries.
865
+ await emitter.flush(); // retry 1
866
+ await emitter.flush(); // retry 2
867
+ await emitter.flush(); // retry 3
868
+ // Event should move to DLQ.
869
+ expect(existsSync(TEST_DLQ_PATH)).toBe(true);
870
+ const dlqContent = readFileSync(TEST_DLQ_PATH, "utf8");
871
+ const dlqLines = dlqContent.trim().split("\n");
872
+ expect(dlqLines).toHaveLength(1);
873
+ const dlqEntry = JSON.parse(dlqLines[0]);
874
+ expect(dlqEntry.tenant).toBe("t-1");
875
+ expect(dlqEntry.dlq_retries).toBe(3);
876
+ expect(typeof dlqEntry.dlq_error).toBe("string");
877
+ expect(dlqEntry.dlq_error.length).toBeGreaterThan(0);
878
+ // Re-open for cleanup.
879
+ const testDb = await createTestDb();
880
+ db = testDb.db;
881
+ pool = testDb.pool;
882
+ });
883
+ it("replays WAL events on startup", async () => {
884
+ // Manually write events to WAL (simulating crash).
885
+ const walEvents = [
886
+ { ...makeEvent({ tenant: "t-1" }), id: "wal-event-1" },
887
+ { ...makeEvent({ tenant: "t-2" }), id: "wal-event-2" },
888
+ ];
889
+ const walContent = `${walEvents.map((e) => JSON.stringify(e)).join("\n")}\n`;
890
+ writeFileSync(TEST_WAL_PATH, walContent, "utf8");
891
+ // Create a new emitter -- it should replay the WAL.
892
+ const newEmitter = makeEmitter(db, {
893
+ flushIntervalMs: 60_000,
894
+ walPath: TEST_WAL_PATH,
895
+ dlqPath: TEST_DLQ_PATH,
896
+ });
897
+ // Wait for the async WAL replay to complete before querying.
898
+ await newEmitter.ready;
899
+ // Events should be in the database.
900
+ const rows = await db.select().from(meterEvents);
901
+ expect(rows).toHaveLength(2);
902
+ newEmitter.close();
903
+ });
904
+ it("WAL replay is idempotent (skips already-flushed events)", async () => {
905
+ // Insert an event directly into the database.
906
+ const existingEvent = { ...makeEvent({ tenant: "t-existing" }), id: "existing-id" };
907
+ await db.insert(meterEvents).values({
908
+ id: existingEvent.id,
909
+ tenant: existingEvent.tenant,
910
+ cost: existingEvent.cost.toRaw(),
911
+ charge: existingEvent.charge.toRaw(),
912
+ capability: existingEvent.capability,
913
+ provider: existingEvent.provider,
914
+ timestamp: existingEvent.timestamp,
915
+ sessionId: null,
916
+ duration: null,
917
+ });
918
+ // Write the same event to WAL (simulating crash after flush).
919
+ writeFileSync(TEST_WAL_PATH, `${JSON.stringify(existingEvent)}\n`, "utf8");
920
+ // Create a new emitter -- it should NOT duplicate the event.
921
+ const newEmitter = makeEmitter(db, {
922
+ flushIntervalMs: 60_000,
923
+ walPath: TEST_WAL_PATH,
924
+ dlqPath: TEST_DLQ_PATH,
925
+ });
926
+ const rows = (await db.select({ cnt: sql `COUNT(*)` }).from(meterEvents).where(eq(meterEvents.id, "existing-id")))[0];
927
+ expect(rows?.cnt).toBe(1);
928
+ newEmitter.close();
929
+ });
930
+ describe("generic usage fields (WOP-512)", () => {
931
+ it("persists usage, tier, and metadata fields", async () => {
932
+ emitter.emit(makeEvent({
933
+ tenant: "t-1",
934
+ capability: "tts",
935
+ provider: "elevenlabs",
936
+ usage: { units: 500, unitType: "characters" },
937
+ tier: "branded",
938
+ metadata: { voice: "adam", model: "eleven_v2" },
939
+ }));
940
+ await emitter.flush();
941
+ const rows = await emitter.queryEvents("t-1");
942
+ expect(rows[0].usage_units).toBe(500);
943
+ expect(rows[0].usage_unit_type).toBe("characters");
944
+ expect(rows[0].tier).toBe("branded");
945
+ expect(JSON.parse(rows[0].metadata)).toEqual({ voice: "adam", model: "eleven_v2" });
946
+ });
947
+ it("handles null usage/tier/metadata (backwards compatibility)", async () => {
948
+ emitter.emit(makeEvent({ tenant: "t-1" }));
949
+ await emitter.flush();
950
+ const rows = await emitter.queryEvents("t-1");
951
+ expect(rows[0].usage_units).toBeNull();
952
+ expect(rows[0].usage_unit_type).toBeNull();
953
+ expect(rows[0].tier).toBeNull();
954
+ expect(rows[0].metadata).toBeNull();
955
+ });
956
+ it("works with multiple capability types in the same flush", async () => {
957
+ emitter.emit(makeEvent({
958
+ capability: "tts",
959
+ provider: "elevenlabs",
960
+ usage: { units: 500, unitType: "characters" },
961
+ tier: "branded",
962
+ }));
963
+ emitter.emit(makeEvent({
964
+ capability: "chat-completions",
965
+ provider: "openrouter",
966
+ usage: { units: 1500, unitType: "tokens" },
967
+ tier: "branded",
968
+ }));
969
+ emitter.emit(makeEvent({
970
+ capability: "transcription",
971
+ provider: "self-hosted-whisper",
972
+ usage: { units: 120, unitType: "seconds" },
973
+ tier: "wopr",
974
+ }));
975
+ emitter.emit(makeEvent({
976
+ capability: "image-generation",
977
+ provider: "replicate",
978
+ usage: { units: 2, unitType: "images" },
979
+ tier: "branded",
980
+ }));
981
+ emitter.flush();
982
+ const rows = await emitter.queryEvents("tenant-1");
983
+ expect(rows).toHaveLength(4);
984
+ // Verify each has correct unitType
985
+ const unitTypes = rows.map((r) => r.usage_unit_type).sort();
986
+ expect(unitTypes).toEqual(["characters", "images", "seconds", "tokens"]);
987
+ });
988
+ it("BYOK tier records zero cost/charge with tier='byok'", async () => {
989
+ emitter.emit(makeEvent({
990
+ cost: Credit.ZERO,
991
+ charge: Credit.ZERO,
992
+ capability: "chat-completions",
993
+ provider: "openrouter",
994
+ usage: { units: 1000, unitType: "tokens" },
995
+ tier: "byok",
996
+ }));
997
+ emitter.flush();
998
+ const rows = await emitter.queryEvents("tenant-1");
999
+ expect(rows[0].cost).toBe(0);
1000
+ expect(rows[0].charge).toBe(0);
1001
+ expect(rows[0].tier).toBe("byok");
1002
+ expect(rows[0].usage_units).toBe(1000);
1003
+ });
1004
+ it("aggregator works unchanged with new fields present", async () => {
1005
+ const WINDOW = 60_000; // 1 minute
1006
+ const aggregator = new MeterAggregator(new DrizzleUsageSummaryRepository(db), { windowMs: WINDOW });
1007
+ const pastWindow = Math.floor(Date.now() / WINDOW) * WINDOW - WINDOW;
1008
+ emitter.emit(makeEvent({
1009
+ tenant: "t-1",
1010
+ cost: Credit.fromDollars(0.01),
1011
+ charge: Credit.fromDollars(0.02),
1012
+ timestamp: pastWindow + 10,
1013
+ usage: { units: 100, unitType: "tokens" },
1014
+ tier: "branded",
1015
+ }));
1016
+ emitter.emit(makeEvent({
1017
+ tenant: "t-1",
1018
+ cost: Credit.fromDollars(0.03),
1019
+ charge: Credit.fromDollars(0.06),
1020
+ timestamp: pastWindow + 20,
1021
+ usage: { units: 200, unitType: "tokens" },
1022
+ tier: "branded",
1023
+ }));
1024
+ emitter.flush();
1025
+ const count = await aggregator.aggregate();
1026
+ expect(count).toBe(1);
1027
+ const summaries = await aggregator.querySummaries("t-1");
1028
+ expect(summaries[0].event_count).toBe(2);
1029
+ expect(summaries[0].total_cost).toBe(Credit.fromDollars(0.04).toRaw());
1030
+ });
1031
+ it("WAL/DLQ handles events with new fields", async () => {
1032
+ const event = makeEvent({
1033
+ usage: { units: 42, unitType: "requests" },
1034
+ tier: "wopr",
1035
+ metadata: { foo: "bar" },
1036
+ });
1037
+ emitter.emit(event);
1038
+ // WAL should persist new fields
1039
+ const walContent = readFileSync(TEST_WAL_PATH, "utf8");
1040
+ const walEvent = JSON.parse(walContent.trim());
1041
+ expect(walEvent.usage.units).toBe(42);
1042
+ expect(walEvent.tier).toBe("wopr");
1043
+ expect(walEvent.metadata.foo).toBe("bar");
1044
+ });
1045
+ });
1046
+ });