tapimo 0.2.0 → 0.3.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 (313) hide show
  1. package/README.md +31 -3
  2. package/dist/ApiKey.d.ts +1 -0
  3. package/dist/ApiKey.d.ts.map +1 -1
  4. package/dist/ApiKey.js +5 -2
  5. package/dist/ApiKey.js.map +1 -1
  6. package/dist/ApiKeys.d.ts +28 -1
  7. package/dist/ApiKeys.d.ts.map +1 -1
  8. package/dist/ApiKeys.js +34 -1
  9. package/dist/ApiKeys.js.map +1 -1
  10. package/dist/App.d.ts +7167 -4507
  11. package/dist/App.d.ts.map +1 -1
  12. package/dist/App.js +8 -0
  13. package/dist/App.js.map +1 -1
  14. package/dist/Scope.d.ts +11 -2
  15. package/dist/Scope.d.ts.map +1 -1
  16. package/dist/Scope.js +14 -3
  17. package/dist/Scope.js.map +1 -1
  18. package/dist/admin/App.d.ts +128 -118
  19. package/dist/admin/App.d.ts.map +1 -1
  20. package/dist/admin/apps/api-keys.d.ts +7 -0
  21. package/dist/admin/apps/api-keys.d.ts.map +1 -1
  22. package/dist/admin/apps/scopes.d.ts +8 -0
  23. package/dist/admin/apps/scopes.d.ts.map +1 -1
  24. package/dist/admin/apps/scopes.js +3 -0
  25. package/dist/admin/apps/scopes.js.map +1 -1
  26. package/dist/admin/apps/verified-tokens.d.ts +57 -57
  27. package/dist/admin/ui.generated.d.ts +1 -1
  28. package/dist/admin/ui.generated.js +13 -13
  29. package/dist/admin/ui.generated.js.map +1 -1
  30. package/dist/analytics/Analytics.d.ts +151 -0
  31. package/dist/analytics/Analytics.d.ts.map +1 -0
  32. package/dist/analytics/Analytics.js +212 -0
  33. package/dist/analytics/Analytics.js.map +1 -0
  34. package/dist/analytics/tables/requestEvents.d.ts +215 -0
  35. package/dist/analytics/tables/requestEvents.d.ts.map +1 -0
  36. package/dist/analytics/tables/requestEvents.js +264 -0
  37. package/dist/analytics/tables/requestEvents.js.map +1 -0
  38. package/dist/apps/data/App.d.ts +1024 -624
  39. package/dist/apps/data/App.d.ts.map +1 -1
  40. package/dist/apps/data/App.js +36 -0
  41. package/dist/apps/data/App.js.map +1 -1
  42. package/dist/apps/data/routes/balances.d.ts +45 -45
  43. package/dist/apps/data/routes/coingecko.d.ts +176 -176
  44. package/dist/apps/data/routes/exchanges.d.ts +455 -455
  45. package/dist/apps/data/routes/fee-amm.d.ts +101 -101
  46. package/dist/apps/data/routes/funding.d.ts +765 -0
  47. package/dist/apps/data/routes/funding.d.ts.map +1 -0
  48. package/dist/apps/data/routes/funding.js +383 -0
  49. package/dist/apps/data/routes/funding.js.map +1 -0
  50. package/dist/apps/data/routes/receipts.d.ts.map +1 -1
  51. package/dist/apps/data/routes/receipts.js +2 -8
  52. package/dist/apps/data/routes/receipts.js.map +1 -1
  53. package/dist/apps/data/routes/rpc.d.ts +29 -36
  54. package/dist/apps/data/routes/rpc.d.ts.map +1 -1
  55. package/dist/apps/data/routes/rpc.js +29 -1
  56. package/dist/apps/data/routes/rpc.js.map +1 -1
  57. package/dist/apps/data/routes/tokenlist.d.ts +36 -36
  58. package/dist/apps/data/routes/transfers.d.ts +56 -56
  59. package/dist/apps/data/routes/verified-tokens.d.ts +36 -36
  60. package/dist/apps/data/routes/webhooks.d.ts +45 -45
  61. package/dist/apps/data/routes/webhooks.d.ts.map +1 -1
  62. package/dist/apps/data/routes/webhooks.js +8 -8
  63. package/dist/apps/data/routes/webhooks.js.map +1 -1
  64. package/dist/apps/docs/App.d.ts +4 -2
  65. package/dist/apps/docs/App.d.ts.map +1 -1
  66. package/dist/apps/docs/App.js +27 -95
  67. package/dist/apps/docs/App.js.map +1 -1
  68. package/dist/apps/docs/reference.d.ts +21 -0
  69. package/dist/apps/docs/reference.d.ts.map +1 -0
  70. package/dist/apps/docs/reference.js +112 -0
  71. package/dist/apps/docs/reference.js.map +1 -0
  72. package/dist/apps/index.d.ts +1 -0
  73. package/dist/apps/index.d.ts.map +1 -1
  74. package/dist/apps/index.js +1 -0
  75. package/dist/apps/index.js.map +1 -1
  76. package/dist/apps/mcp.d.ts +76 -0
  77. package/dist/apps/mcp.d.ts.map +1 -0
  78. package/dist/apps/mcp.js +188 -0
  79. package/dist/apps/mcp.js.map +1 -0
  80. package/dist/apps/platform/App.d.ts +3005 -728
  81. package/dist/apps/platform/App.d.ts.map +1 -1
  82. package/dist/apps/platform/App.js +13 -3
  83. package/dist/apps/platform/App.js.map +1 -1
  84. package/dist/apps/platform/Billing.d.ts +279 -0
  85. package/dist/apps/platform/Billing.d.ts.map +1 -0
  86. package/dist/apps/platform/Billing.js +477 -0
  87. package/dist/apps/platform/Billing.js.map +1 -0
  88. package/dist/apps/platform/index.d.ts +1 -0
  89. package/dist/apps/platform/index.d.ts.map +1 -1
  90. package/dist/apps/platform/index.js +1 -0
  91. package/dist/apps/platform/index.js.map +1 -1
  92. package/dist/apps/platform/routes/api-keys.d.ts +341 -49
  93. package/dist/apps/platform/routes/api-keys.d.ts.map +1 -1
  94. package/dist/apps/platform/routes/api-keys.js +115 -13
  95. package/dist/apps/platform/routes/api-keys.js.map +1 -1
  96. package/dist/apps/platform/routes/billing.d.ts +1520 -0
  97. package/dist/apps/platform/routes/billing.d.ts.map +1 -0
  98. package/dist/apps/platform/routes/billing.js +795 -0
  99. package/dist/apps/platform/routes/billing.js.map +1 -0
  100. package/dist/apps/platform/routes/invitations.d.ts +201 -83
  101. package/dist/apps/platform/routes/invitations.d.ts.map +1 -1
  102. package/dist/apps/platform/routes/invitations.js +58 -0
  103. package/dist/apps/platform/routes/invitations.js.map +1 -1
  104. package/dist/apps/platform/routes/me.d.ts +17 -17
  105. package/dist/apps/platform/routes/orgs.d.ts +50 -36
  106. package/dist/apps/platform/routes/orgs.d.ts.map +1 -1
  107. package/dist/apps/platform/routes/orgs.js +14 -3
  108. package/dist/apps/platform/routes/orgs.js.map +1 -1
  109. package/dist/apps/platform/routes/projects.d.ts +97 -97
  110. package/dist/apps/platform/routes/scopes.d.ts +107 -0
  111. package/dist/apps/platform/routes/scopes.d.ts.map +1 -0
  112. package/dist/apps/platform/routes/scopes.js +66 -0
  113. package/dist/apps/platform/routes/scopes.js.map +1 -0
  114. package/dist/apps/platform/routes/usage.d.ts +588 -0
  115. package/dist/apps/platform/routes/usage.d.ts.map +1 -0
  116. package/dist/apps/platform/routes/usage.js +391 -0
  117. package/dist/apps/platform/routes/usage.js.map +1 -0
  118. package/dist/apps/relay/App.d.ts +4 -1
  119. package/dist/apps/relay/App.d.ts.map +1 -1
  120. package/dist/apps/relay/App.js +118 -10
  121. package/dist/apps/relay/App.js.map +1 -1
  122. package/dist/apps/relay/Sponsorships.d.ts.map +1 -1
  123. package/dist/apps/relay/Sponsorships.js +2 -1
  124. package/dist/apps/relay/Sponsorships.js.map +1 -1
  125. package/dist/cli/Cli.d.ts +8 -1
  126. package/dist/cli/Cli.d.ts.map +1 -1
  127. package/dist/cli/Cli.js +11 -3
  128. package/dist/cli/Cli.js.map +1 -1
  129. package/dist/cli/commands/admin.d.ts +4 -0
  130. package/dist/cli/commands/admin.d.ts.map +1 -1
  131. package/dist/cli/commands/admin.js +80 -1
  132. package/dist/cli/commands/admin.js.map +1 -1
  133. package/dist/db/Db.d.ts +7 -0
  134. package/dist/db/Db.d.ts.map +1 -1
  135. package/dist/db/Db.js +126 -0
  136. package/dist/db/Db.js.map +1 -1
  137. package/dist/db/Schema.d.ts +79 -0
  138. package/dist/db/Schema.d.ts.map +1 -1
  139. package/dist/db/Schema.js +88 -2
  140. package/dist/db/Schema.js.map +1 -1
  141. package/dist/db/tables/billingSettings.d.ts +55 -0
  142. package/dist/db/tables/billingSettings.d.ts.map +1 -0
  143. package/dist/db/tables/billingSettings.js +70 -0
  144. package/dist/db/tables/billingSettings.js.map +1 -0
  145. package/dist/db/tables/invitations.js +2 -2
  146. package/dist/db/tables/invitations.js.map +1 -1
  147. package/dist/db/tables/organizations.d.ts +6 -2
  148. package/dist/db/tables/organizations.d.ts.map +1 -1
  149. package/dist/db/tables/organizations.js +10 -4
  150. package/dist/db/tables/organizations.js.map +1 -1
  151. package/dist/db/tables/projects.js +2 -2
  152. package/dist/db/tables/projects.js.map +1 -1
  153. package/dist/db/tables/requestUsage.d.ts +121 -0
  154. package/dist/db/tables/requestUsage.d.ts.map +1 -0
  155. package/dist/db/tables/requestUsage.js +150 -0
  156. package/dist/db/tables/requestUsage.js.map +1 -0
  157. package/dist/db/tables/sponsoredTransactions.d.ts +119 -0
  158. package/dist/db/tables/sponsoredTransactions.d.ts.map +1 -1
  159. package/dist/db/tables/sponsoredTransactions.js +155 -2
  160. package/dist/db/tables/sponsoredTransactions.js.map +1 -1
  161. package/dist/db/tables/stripeCustomers.d.ts +81 -0
  162. package/dist/db/tables/stripeCustomers.d.ts.map +1 -0
  163. package/dist/db/tables/stripeCustomers.js +120 -0
  164. package/dist/db/tables/stripeCustomers.js.map +1 -0
  165. package/dist/db/tables/users.js +2 -2
  166. package/dist/db/tables/users.js.map +1 -1
  167. package/dist/handlers/internal/sponsorship.d.ts +5 -2
  168. package/dist/handlers/internal/sponsorship.d.ts.map +1 -1
  169. package/dist/handlers/internal/sponsorship.js +19 -5
  170. package/dist/handlers/internal/sponsorship.js.map +1 -1
  171. package/dist/handlers/internal/utils.d.ts +10 -3
  172. package/dist/handlers/internal/utils.d.ts.map +1 -1
  173. package/dist/handlers/internal/utils.js +14 -6
  174. package/dist/handlers/internal/utils.js.map +1 -1
  175. package/dist/index.d.ts +1 -0
  176. package/dist/index.d.ts.map +1 -1
  177. package/dist/index.js +1 -0
  178. package/dist/index.js.map +1 -1
  179. package/dist/internal/Auth.d.ts +18 -4
  180. package/dist/internal/Auth.d.ts.map +1 -1
  181. package/dist/internal/Auth.js +148 -76
  182. package/dist/internal/Auth.js.map +1 -1
  183. package/dist/internal/Cache.d.ts.map +1 -1
  184. package/dist/internal/Cache.js +4 -1
  185. package/dist/internal/Cache.js.map +1 -1
  186. package/dist/internal/EdgeCache.d.ts +10 -0
  187. package/dist/internal/EdgeCache.d.ts.map +1 -1
  188. package/dist/internal/EdgeCache.js +39 -8
  189. package/dist/internal/EdgeCache.js.map +1 -1
  190. package/dist/internal/Fees.d.ts +27 -0
  191. package/dist/internal/Fees.d.ts.map +1 -0
  192. package/dist/internal/Fees.js +48 -0
  193. package/dist/internal/Fees.js.map +1 -0
  194. package/dist/internal/FundingRoutes.d.ts +183 -0
  195. package/dist/internal/FundingRoutes.d.ts.map +1 -0
  196. package/dist/internal/FundingRoutes.js +490 -0
  197. package/dist/internal/FundingRoutes.js.map +1 -0
  198. package/dist/internal/Id.d.ts +15 -0
  199. package/dist/internal/Id.d.ts.map +1 -0
  200. package/dist/internal/Id.js +22 -0
  201. package/dist/internal/Id.js.map +1 -0
  202. package/dist/internal/Log.d.ts +2 -0
  203. package/dist/internal/Log.d.ts.map +1 -1
  204. package/dist/internal/Log.js +3 -0
  205. package/dist/internal/Log.js.map +1 -1
  206. package/dist/internal/OpenApi.d.ts +10 -1
  207. package/dist/internal/OpenApi.d.ts.map +1 -1
  208. package/dist/internal/OpenApi.js +8 -1
  209. package/dist/internal/OpenApi.js.map +1 -1
  210. package/dist/internal/RateLimit.d.ts +25 -0
  211. package/dist/internal/RateLimit.d.ts.map +1 -1
  212. package/dist/internal/RateLimit.js +51 -0
  213. package/dist/internal/RateLimit.js.map +1 -1
  214. package/dist/internal/Response.d.ts +1 -1
  215. package/dist/internal/Response.d.ts.map +1 -1
  216. package/dist/internal/Schema.d.ts +8 -0
  217. package/dist/internal/Schema.d.ts.map +1 -1
  218. package/dist/internal/Schema.js +7 -0
  219. package/dist/internal/Schema.js.map +1 -1
  220. package/dist/internal/Webhooks.d.ts.map +1 -1
  221. package/dist/internal/Webhooks.js +3 -3
  222. package/dist/internal/Webhooks.js.map +1 -1
  223. package/package.json +4 -3
  224. package/src/ApiKey.ts +9 -2
  225. package/src/ApiKeys.test.ts +51 -0
  226. package/src/ApiKeys.ts +50 -1
  227. package/src/App.test.ts +33 -86718
  228. package/src/App.ts +18 -0
  229. package/src/Client.test-d.ts +351 -0
  230. package/src/Scope.ts +16 -3
  231. package/src/admin/apps/scopes.test.ts +6 -0
  232. package/src/admin/apps/scopes.ts +3 -0
  233. package/src/admin/ui/src/components/Page.tsx +2 -3
  234. package/src/admin/ui/src/routes/__root.tsx +2 -2
  235. package/src/admin/ui/src/routes/api-keys.tsx +2 -2
  236. package/src/admin/ui.generated.ts +13 -13
  237. package/src/analytics/Analytics.test.ts +527 -0
  238. package/src/analytics/Analytics.ts +318 -0
  239. package/src/analytics/tables/requestEvents.ts +523 -0
  240. package/src/apps/data/App.test.ts +72 -0
  241. package/src/apps/data/App.ts +35 -0
  242. package/src/apps/data/routes/funding.test.ts +214 -0
  243. package/src/apps/data/routes/funding.ts +584 -0
  244. package/src/apps/data/routes/receipts.ts +2 -9
  245. package/src/apps/data/routes/rpc.test.ts +78 -2
  246. package/src/apps/data/routes/rpc.ts +31 -1
  247. package/src/apps/data/routes/webhooks.test.ts +1 -1
  248. package/src/apps/data/routes/webhooks.ts +11 -8
  249. package/src/apps/docs/App.ts +29 -100
  250. package/src/apps/docs/reference.ts +132 -0
  251. package/src/apps/index.ts +1 -0
  252. package/src/apps/mcp.test.ts +206 -0
  253. package/src/apps/mcp.ts +250 -0
  254. package/src/apps/platform/App.ts +43 -4
  255. package/src/apps/platform/Billing.test.ts +530 -0
  256. package/src/apps/platform/Billing.ts +682 -0
  257. package/src/apps/platform/index.ts +1 -0
  258. package/src/apps/platform/routes/api-keys.test.ts +172 -9
  259. package/src/apps/platform/routes/api-keys.ts +136 -20
  260. package/src/apps/platform/routes/billing.test.ts +1100 -0
  261. package/src/apps/platform/routes/billing.ts +966 -0
  262. package/src/apps/platform/routes/invitations.test.ts +55 -7
  263. package/src/apps/platform/routes/invitations.ts +68 -0
  264. package/src/apps/platform/routes/me.test.ts +230 -0
  265. package/src/apps/platform/routes/orgs.ts +26 -4
  266. package/src/apps/platform/routes/scopes.test.ts +29 -0
  267. package/src/apps/platform/routes/scopes.ts +85 -0
  268. package/src/apps/platform/routes/usage.test.ts +422 -0
  269. package/src/apps/platform/routes/usage.ts +527 -0
  270. package/src/apps/relay/App.test.ts +457 -8
  271. package/src/apps/relay/App.ts +120 -12
  272. package/src/apps/relay/Sponsorships.test.ts +1 -1
  273. package/src/apps/relay/Sponsorships.ts +2 -1
  274. package/src/cli/Cli.test.ts +25 -2
  275. package/src/cli/Cli.ts +17 -2
  276. package/src/cli/commands/admin.test.ts +83 -0
  277. package/src/cli/commands/admin.ts +89 -1
  278. package/src/db/Db.ts +137 -0
  279. package/src/db/Schema.ts +104 -2
  280. package/src/db/tables/billingSettings.test.ts +87 -0
  281. package/src/db/tables/billingSettings.ts +108 -0
  282. package/src/db/tables/invitations.ts +2 -2
  283. package/src/db/tables/organizations.test.ts +1 -1
  284. package/src/db/tables/organizations.ts +15 -5
  285. package/src/db/tables/projects.ts +2 -2
  286. package/src/db/tables/requestUsage.ts +231 -0
  287. package/src/db/tables/sponsoredTransactions.test.ts +108 -1
  288. package/src/db/tables/sponsoredTransactions.ts +230 -2
  289. package/src/db/tables/stripeCustomers.test.ts +109 -0
  290. package/src/db/tables/stripeCustomers.ts +162 -0
  291. package/src/db/tables/users.ts +2 -2
  292. package/src/handlers/internal/sponsorship.ts +34 -7
  293. package/src/handlers/internal/utils.ts +19 -7
  294. package/src/index.ts +1 -0
  295. package/src/internal/Auth.test.ts +175 -25
  296. package/src/internal/Auth.ts +175 -86
  297. package/src/internal/Cache.ts +5 -1
  298. package/src/internal/EdgeCache.test.ts +27 -4
  299. package/src/internal/EdgeCache.ts +47 -9
  300. package/src/internal/Fees.test.ts +32 -0
  301. package/src/internal/Fees.ts +51 -0
  302. package/src/internal/FundingRoutes.ts +583 -0
  303. package/src/internal/Id.test.ts +17 -0
  304. package/src/internal/Id.ts +24 -0
  305. package/src/internal/Log.test.ts +1 -0
  306. package/src/internal/Log.ts +5 -0
  307. package/src/internal/OpenApi.ts +12 -1
  308. package/src/internal/RateLimit.test.ts +84 -0
  309. package/src/internal/RateLimit.ts +70 -0
  310. package/src/internal/Response.ts +1 -1
  311. package/src/internal/Schema.ts +8 -0
  312. package/src/internal/Webhooks.test.ts +3 -3
  313. package/src/internal/Webhooks.ts +3 -3
@@ -0,0 +1,231 @@
1
+ import { type ColumnType, type Selectable, sql } from 'kysely'
2
+
3
+ import type * as Db from '../Db.js'
4
+ import type * as db_Schema from '../Schema.js'
5
+
6
+ /** A `bigint` count column: read as a string from pg, written as a number. */
7
+ type Count = ColumnType<string, number, number>
8
+
9
+ /** Environment a request-usage row attributes to. */
10
+ export type Environment = db_Schema.RequestUsageBucket['environment']
11
+
12
+ /** Exactly-once state of a meter event. */
13
+ export type State = db_Schema.RequestUsageMeterEvent['state']
14
+
15
+ /** Columns of the `request_usage_buckets` table (pg representation). */
16
+ export type BucketTable = {
17
+ bucketStart: string
18
+ environment: Environment
19
+ orgId: string
20
+ reportedCount: Count
21
+ updatedAt: string
22
+ }
23
+
24
+ /** Columns of the `request_usage_meter_events` table (pg representation). */
25
+ export type MeterEventTable = {
26
+ bucketStart: string
27
+ deltaCount: Count
28
+ environment: Environment
29
+ error: string | null
30
+ firstAttemptedAt: string
31
+ identifier: string
32
+ orgId: string
33
+ reason: string | null
34
+ reportedAt: string | null
35
+ sequence: number
36
+ state: State
37
+ stripeCustomerId: string
38
+ }
39
+
40
+ /** A stored per-hour request-count watermark. */
41
+ export type Bucket = db_Schema.RequestUsageBucket
42
+
43
+ /** A stored request-count meter event. */
44
+ export type MeterEvent = db_Schema.RequestUsageMeterEvent
45
+
46
+ /** Identifies one hourly bucket for an org in an environment. */
47
+ export type BucketKey = {
48
+ /** UTC hour start (ISO 8601). */
49
+ bucketStart: string
50
+ /** Environment the requests were served under. */
51
+ environment: Environment
52
+ /** Organization id (`org_…`). */
53
+ orgId: string
54
+ }
55
+
56
+ /**
57
+ * Reads a bucket watermark.
58
+ *
59
+ * @param db - The database.
60
+ * @param key - The bucket key.
61
+ * @returns The watermark, or `undefined` when none has been reported yet.
62
+ */
63
+ export async function getBucket(db: Db.Db, key: BucketKey): Promise<Bucket | undefined> {
64
+ const row = await db.kysely
65
+ .selectFrom('request_usage_buckets')
66
+ .selectAll()
67
+ .where('orgId', '=', key.orgId)
68
+ .where('environment', '=', key.environment)
69
+ .where('bucketStart', '=', key.bucketStart)
70
+ .executeTakeFirst()
71
+ return row ? toBucket(row) : undefined
72
+ }
73
+
74
+ /**
75
+ * Lists the meter events still awaiting a Stripe acknowledgement in an
76
+ * environment, oldest first — the reporter's crash-recovery drive.
77
+ *
78
+ * @param db - The database.
79
+ * @param environment - The environment to scan.
80
+ * @returns The pending meter events.
81
+ */
82
+ export async function listPending(db: Db.Db, environment: Environment): Promise<MeterEvent[]> {
83
+ const rows = await db.kysely
84
+ .selectFrom('request_usage_meter_events')
85
+ .selectAll()
86
+ .where('environment', '=', environment)
87
+ .where('state', '=', 'pending')
88
+ .orderBy('firstAttemptedAt', 'asc')
89
+ .orderBy('identifier', 'asc')
90
+ .execute()
91
+ return rows.map(toMeterEvent)
92
+ }
93
+
94
+ /**
95
+ * Counts the meter events already reported for a bucket — the next delta's
96
+ * `sequence`, so a new seal never collides with a settled one.
97
+ *
98
+ * @param db - The database.
99
+ * @param key - The bucket key.
100
+ * @returns The count of reported events for the bucket.
101
+ */
102
+ export async function countReported(db: Db.Db, key: BucketKey): Promise<number> {
103
+ const row = await db.kysely
104
+ .selectFrom('request_usage_meter_events')
105
+ .select((eb) => eb.fn.countAll<string>().as('count'))
106
+ .where('orgId', '=', key.orgId)
107
+ .where('environment', '=', key.environment)
108
+ .where('bucketStart', '=', key.bucketStart)
109
+ .where('state', '=', 'reported')
110
+ .executeTakeFirstOrThrow()
111
+ return Number(row.count)
112
+ }
113
+
114
+ /**
115
+ * Inserts a frozen `pending` meter event, claiming a delta. Idempotent on the
116
+ * deterministic `identifier`: a re-seal after a crash resolves to the existing
117
+ * row rather than duplicating the delta.
118
+ *
119
+ * @param db - The database.
120
+ * @param input - The event to insert.
121
+ * @returns Whether this call inserted the row.
122
+ */
123
+ export async function insertPending(db: Db.Db, input: insertPending.Input): Promise<boolean> {
124
+ const now = new Date().toISOString()
125
+ const inserted = await db.kysely
126
+ .insertInto('request_usage_meter_events')
127
+ .values({
128
+ bucketStart: input.bucketStart,
129
+ deltaCount: input.deltaCount,
130
+ environment: input.environment,
131
+ error: null,
132
+ firstAttemptedAt: now,
133
+ identifier: input.identifier,
134
+ orgId: input.orgId,
135
+ reason: null,
136
+ reportedAt: null,
137
+ sequence: input.sequence,
138
+ state: 'pending',
139
+ stripeCustomerId: input.stripeCustomerId,
140
+ })
141
+ .onConflict((oc) => oc.doNothing())
142
+ .returning('identifier')
143
+ .executeTakeFirst()
144
+ return Boolean(inserted)
145
+ }
146
+
147
+ export declare namespace insertPending {
148
+ /** Fields accepted when sealing a pending meter event. */
149
+ type Input = {
150
+ /** UTC hour start the delta bills for (ISO 8601). */
151
+ bucketStart: string
152
+ /** Frozen request count this event bills (positive). */
153
+ deltaCount: number
154
+ /** Environment the requests were served under. */
155
+ environment: Environment
156
+ /** Deterministic id; also the Stripe identifier and idempotency key. */
157
+ identifier: string
158
+ /** Organization id (`org_…`). */
159
+ orgId: string
160
+ /** Delta sequence within the bucket. */
161
+ sequence: number
162
+ /** Stripe customer id the event bills against. */
163
+ stripeCustomerId: string
164
+ }
165
+ }
166
+
167
+ /**
168
+ * Marks a pending event reported and advances its bucket watermark by the
169
+ * event's frozen delta, atomically. A no-op once reported, so a retry after a
170
+ * crash between the Stripe ack and this write never double-advances.
171
+ *
172
+ * @param db - The database.
173
+ * @param identifier - The event identifier.
174
+ * @param at - When Stripe acknowledged the event (ISO 8601).
175
+ * @returns Whether this call settled the event (false when already reported).
176
+ */
177
+ export function settle(db: Db.Db, identifier: string, at: string): Promise<boolean> {
178
+ return db.transaction(async (tx) => {
179
+ const updated = await tx.kysely
180
+ .updateTable('request_usage_meter_events')
181
+ .set({ error: null, reportedAt: at, state: 'reported' })
182
+ .where('identifier', '=', identifier)
183
+ .where('state', '=', 'pending')
184
+ .returningAll()
185
+ .executeTakeFirst()
186
+ if (!updated) return false
187
+ await tx.kysely
188
+ .insertInto('request_usage_buckets')
189
+ .values({
190
+ bucketStart: updated.bucketStart,
191
+ environment: updated.environment,
192
+ orgId: updated.orgId,
193
+ reportedCount: Number(updated.deltaCount),
194
+ updatedAt: at,
195
+ })
196
+ .onConflict((oc) =>
197
+ oc.columns(['orgId', 'environment', 'bucketStart']).doUpdateSet({
198
+ reportedCount: sql`request_usage_buckets.reported_count + ${updated.deltaCount}`,
199
+ updatedAt: at,
200
+ }),
201
+ )
202
+ .execute()
203
+ return true
204
+ })
205
+ }
206
+
207
+ /**
208
+ * Records a transient failure on a pending event; it stays pending for retry.
209
+ *
210
+ * @param db - The database.
211
+ * @param identifier - The event identifier.
212
+ * @param error - The failure detail.
213
+ */
214
+ export async function markError(db: Db.Db, identifier: string, error: string): Promise<void> {
215
+ await db.kysely
216
+ .updateTable('request_usage_meter_events')
217
+ .set({ error: error.slice(0, 500) })
218
+ .where('identifier', '=', identifier)
219
+ .where('state', '=', 'pending')
220
+ .execute()
221
+ }
222
+
223
+ /** Maps a bucket row to its domain record. */
224
+ function toBucket(row: Selectable<BucketTable>): Bucket {
225
+ return { ...row, reportedCount: Number(row.reportedCount) }
226
+ }
227
+
228
+ /** Maps a meter-event row to its domain record. */
229
+ function toMeterEvent(row: Selectable<MeterEventTable>): MeterEvent {
230
+ return { ...row, deltaCount: Number(row.deltaCount) }
231
+ }
@@ -25,7 +25,7 @@ function redact(record: SponsoredTransactions.Record) {
25
25
  ...record,
26
26
  createdAt: '<timestamp>',
27
27
  finalizedAt: record.finalizedAt === null ? null : '<timestamp>',
28
- id: /^stx_[\w-]{21}$/.test(record.id) ? 'stx_<id>' : record.id,
28
+ id: /^stx_[0-9A-Za-z]{24}$/.test(record.id) ? 'stx_<id>' : record.id,
29
29
  }
30
30
  }
31
31
 
@@ -41,11 +41,14 @@ describe('upsert', () => {
41
41
  "billable": true,
42
42
  "chainId": 42431,
43
43
  "createdAt": "<timestamp>",
44
+ "currency": null,
44
45
  "environment": "production",
45
46
  "feeAmount": null,
47
+ "feeMax": null,
46
48
  "feeToken": null,
47
49
  "finalizedAt": null,
48
50
  "id": "stx_<id>",
51
+ "meterReportedAt": null,
49
52
  "orgId": "org_1",
50
53
  "projectId": "prj_1",
51
54
  "signPayload": "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
@@ -216,3 +219,107 @@ describe('fail', () => {
216
219
  await db.close()
217
220
  })
218
221
  })
222
+
223
+ describe('migration 0032', () => {
224
+ test('behavior: rescales legacy attodollar fee amounts idempotently', async () => {
225
+ const db = create()
226
+ await db.migrate()
227
+ // A pre-scaling row stored raw attodollars: 2.1e18 + 1, non-divisible so
228
+ // the rescale must round up.
229
+ const legacy = await SponsoredTransactions.upsert(db, { ...input, signPayload: `0x${'31'.repeat(32)}` }) // prettier-ignore
230
+ await SponsoredTransactions.finalize(db, legacy.id, {
231
+ feeAmount: '2100000000000000001',
232
+ finalizedAt: new Date().toISOString(),
233
+ })
234
+ // An already-scaled row sits far below the threshold and stays untouched.
235
+ const scaled = await SponsoredTransactions.upsert(db, { ...input, signPayload: `0x${'32'.repeat(32)}` }) // prettier-ignore
236
+ await SponsoredTransactions.finalize(db, scaled.id, {
237
+ feeAmount: '420000',
238
+ finalizedAt: new Date().toISOString(),
239
+ })
240
+
241
+ const rescale = Db.migrations.find((migration) => migration.name === '0032_sponsored_transactions_fee_amount_rescale') // prettier-ignore
242
+ // Applying twice proves the threshold guard: a re-run never rescales again.
243
+ for (let run = 0; run < 2; run++) {
244
+ await rescale!.up(db.kysely).execute()
245
+ expect((await SponsoredTransactions.get(db, legacy.id))?.feeAmount).toBe('2100001')
246
+ expect((await SponsoredTransactions.get(db, scaled.id))?.feeAmount).toBe('420000')
247
+ }
248
+ await db.close()
249
+ })
250
+ })
251
+
252
+ describe('spend', () => {
253
+ test('behavior: sums finalized fees plus pending caps within scope', async () => {
254
+ const db = create()
255
+ await db.migrate()
256
+ const chainIds = [input.chainId]
257
+ // Finalized in scope: counts its actual fee, never its cap.
258
+ const finalized = await SponsoredTransactions.upsert(db, { ...input, feeMax: '900000', signPayload: `0x${'01'.repeat(32)}` }) // prettier-ignore
259
+ await SponsoredTransactions.finalize(db, finalized.id, {
260
+ feeAmount: '100000',
261
+ finalizedAt: new Date().toISOString(),
262
+ })
263
+ // Pending in scope: counts its signed cap.
264
+ await SponsoredTransactions.upsert(db, { ...input, feeMax: '250000', signPayload: `0x${'02'.repeat(32)}` }) // prettier-ignore
265
+ // Pending without a recorded cap (pre-column row): contributes nothing.
266
+ await SponsoredTransactions.upsert(db, { ...input, signPayload: `0x${'03'.repeat(32)}` })
267
+ // Failed: out of scope regardless of cap.
268
+ const failed = await SponsoredTransactions.upsert(db, { ...input, feeMax: '700000', signPayload: `0x${'04'.repeat(32)}` }) // prettier-ignore
269
+ await SponsoredTransactions.fail(db, failed.id, new Date().toISOString())
270
+ // Non-billable and foreign-chain rows: out of scope.
271
+ await SponsoredTransactions.upsert(db, { ...input, billable: false, environment: 'sandbox', feeMax: '500000', signPayload: `0x${'05'.repeat(32)}` }) // prettier-ignore
272
+ await SponsoredTransactions.upsert(db, { ...input, chainId: 1337, feeMax: '500000', signPayload: `0x${'06'.repeat(32)}` }) // prettier-ignore
273
+ // Other orgs: out of scope.
274
+ await SponsoredTransactions.upsert(db, { ...input, feeMax: '500000', orgId: 'org_2', signPayload: `0x${'07'.repeat(32)}` }) // prettier-ignore
275
+
276
+ expect(await SponsoredTransactions.spend(db, { chainIds, orgId: 'org_1', since: '2000-01-01T00:00:00.000Z' })).toBe(350000n) // prettier-ignore
277
+ await db.close()
278
+ })
279
+
280
+ test('behavior: rows created before the window start are out of scope', async () => {
281
+ const db = create()
282
+ await db.migrate()
283
+ await SponsoredTransactions.upsert(db, { ...input, feeMax: '250000', signPayload: `0x${'08'.repeat(32)}` }) // prettier-ignore
284
+ const future = new Date(Date.now() + 60_000).toISOString()
285
+ expect(await SponsoredTransactions.spend(db, { chainIds: [input.chainId], orgId: 'org_1', since: future })).toBe(0n) // prettier-ignore
286
+ await db.close()
287
+ })
288
+ })
289
+
290
+ describe('listUnreported', () => {
291
+ test('behavior: lists finalized billable in-scope rows, oldest first', async () => {
292
+ const db = create()
293
+ await db.migrate()
294
+ const chainIds = [input.chainId]
295
+ const first = await SponsoredTransactions.upsert(db, { ...input, signPayload: `0x${'11'.repeat(32)}` }) // prettier-ignore
296
+ await SponsoredTransactions.finalize(db, first.id, { feeAmount: '100', finalizedAt: '2026-01-01T00:00:00.000Z' }) // prettier-ignore
297
+ const second = await SponsoredTransactions.upsert(db, { ...input, signPayload: `0x${'12'.repeat(32)}` }) // prettier-ignore
298
+ await SponsoredTransactions.finalize(db, second.id, { feeAmount: '200', finalizedAt: '2026-01-02T00:00:00.000Z' }) // prettier-ignore
299
+ // Pending, sandbox, and foreign-chain rows never report.
300
+ await SponsoredTransactions.upsert(db, { ...input, signPayload: `0x${'13'.repeat(32)}` })
301
+ await SponsoredTransactions.upsert(db, { ...input, billable: false, environment: 'sandbox', signPayload: `0x${'14'.repeat(32)}` }) // prettier-ignore
302
+
303
+ const rows = await SponsoredTransactions.listUnreported(db, { chainIds })
304
+ expect(rows.map((row) => row.id)).toStrictEqual([first.id, second.id])
305
+
306
+ await SponsoredTransactions.markReported(db, first.id, new Date().toISOString())
307
+ const remaining = await SponsoredTransactions.listUnreported(db, { chainIds })
308
+ expect(remaining.map((row) => row.id)).toStrictEqual([second.id])
309
+ await db.close()
310
+ })
311
+ })
312
+
313
+ describe('markReported', () => {
314
+ test('behavior: marks once; re-marking never overwrites the first timestamp', async () => {
315
+ const db = create()
316
+ await db.migrate()
317
+ const record = await SponsoredTransactions.upsert(db, { ...input, signPayload: `0x${'15'.repeat(32)}` }) // prettier-ignore
318
+ await SponsoredTransactions.finalize(db, record.id, { feeAmount: '100', finalizedAt: '2026-01-01T00:00:00.000Z' }) // prettier-ignore
319
+ await SponsoredTransactions.markReported(db, record.id, '2026-01-02T00:00:00.000Z')
320
+ await SponsoredTransactions.markReported(db, record.id, '2026-01-03T00:00:00.000Z')
321
+ const row = await SponsoredTransactions.get(db, record.id)
322
+ expect(row?.meterReportedAt).toMatchInlineSnapshot(`"2026-01-02T00:00:00.000Z"`)
323
+ await db.close()
324
+ })
325
+ })
@@ -1,7 +1,7 @@
1
1
  import type { ExpressionBuilder, Selectable } from 'kysely'
2
2
  import { sql } from 'kysely'
3
- import { nanoid } from 'nanoid'
4
3
 
4
+ import * as Id from '../../internal/Id.js'
5
5
  import type * as Db from '../Db.js'
6
6
  import type * as db_Schema from '../Schema.js'
7
7
 
@@ -28,11 +28,14 @@ export async function upsert(db: Db.Db, input: upsert.Input): Promise<Record> {
28
28
  billable: input.billable,
29
29
  chainId: input.chainId,
30
30
  createdAt: new Date().toISOString(),
31
+ currency: input.currency ?? null,
31
32
  environment: input.environment,
32
33
  feeAmount: null,
34
+ feeMax: input.feeMax ?? null,
33
35
  feeToken: input.feeToken ?? null,
34
36
  finalizedAt: null,
35
- id: `stx_${nanoid()}`,
37
+ id: Id.generate('stx'),
38
+ meterReportedAt: null,
36
39
  orgId: input.orgId,
37
40
  projectId: input.projectId,
38
41
  signPayload: input.signPayload,
@@ -45,6 +48,9 @@ export async function upsert(db: Db.Db, input: upsert.Input): Promise<Record> {
45
48
  .values(record)
46
49
  .onConflict((oc) =>
47
50
  oc.column('signPayload').doUpdateSet((eb) => ({
51
+ // Same sign payload = same envelope = same cap; only fills older nulls.
52
+ currency: eb.fn.coalesce(eb.ref('sponsored_transactions.currency'), eb.ref('excluded.currency')), // prettier-ignore
53
+ feeMax: eb.fn.coalesce(eb.ref('sponsored_transactions.feeMax'), eb.ref('excluded.feeMax')),
48
54
  // A raw submission fills the intent's hash; a duplicate intent never
49
55
  // clears an already-known hash.
50
56
  transactionHash: eb.fn.coalesce(
@@ -69,8 +75,12 @@ export declare namespace upsert {
69
75
  billable: boolean
70
76
  /** Chain the sponsored transaction targets. */
71
77
  chainId: number
78
+ /** Lowercase fee currency snapshot (`usd`), when resolved. */
79
+ currency?: string | undefined
72
80
  /** Key environment the sponsorship was requested under. */
73
81
  environment: Table['environment']
82
+ /** Signed fee cap (`gas × maxFeePerGas`) in base units, when known. */
83
+ feeMax?: string | undefined
74
84
  /** Fee token the sponsorship resolved, when known. */
75
85
  feeToken?: string | undefined
76
86
  /** Organization id (`org_…`) the spend attributes to. */
@@ -224,6 +234,219 @@ export async function fail(db: Db.Db, id: string, finalizedAt: string): Promise<
224
234
  .execute()
225
235
  }
226
236
 
237
+ /**
238
+ * Records a sponsorship and enforces the period spend limit atomically. A
239
+ * per-org advisory lock serializes concurrent sponsorships so each one's
240
+ * signed cap is counted before the limit check; without it a burst reads
241
+ * pre-burst spend and overshoots. Throws {@link PeriodSpendLimitError} (rolling
242
+ * back the row) when the limit would be exceeded.
243
+ *
244
+ * @param db - The database (primary; never a cached replica).
245
+ * @param input - The sponsorship to record.
246
+ * @param limit - The period spend limit to enforce.
247
+ * @returns The stored record.
248
+ */
249
+ export async function reserve(
250
+ db: Db.Db,
251
+ input: upsert.Input,
252
+ limit: reserve.Limit,
253
+ ): Promise<Record> {
254
+ return db.transaction(async (tx) => {
255
+ // `hashtext` maps the org id into the advisory-lock keyspace; the class
256
+ // namespaces it against other advisory-lock users. Released at tx end.
257
+ await sql`select pg_advisory_xact_lock(${billingLockClass}, hashtext(${input.orgId}))`.execute(tx.kysely) // prettier-ignore
258
+ const record = await upsert(tx, input)
259
+ if (record.billable) {
260
+ const committed = await spend(tx, {
261
+ chainIds: limit.chainIds,
262
+ orgId: input.orgId,
263
+ since: limit.since,
264
+ })
265
+ if (committed > limit.max) throw new PeriodSpendLimitError()
266
+ }
267
+ return record
268
+ })
269
+ }
270
+
271
+ /** Advisory-lock class namespacing per-org sponsorship serialization. */
272
+ const billingLockClass = 1112294220
273
+
274
+ export declare namespace reserve {
275
+ /** The period spend limit enforced by {@link reserve}. */
276
+ type Limit = {
277
+ /** Chains whose spend counts (mainnet chain ids). */
278
+ chainIds: readonly number[]
279
+ /** Limit in fee-token base units. */
280
+ max: bigint
281
+ /** Window start (ISO 8601). */
282
+ since: string
283
+ }
284
+ }
285
+
286
+ /**
287
+ * Billable spend committed since a cutoff, in fee-token base units: finalized
288
+ * rows at their actual fee plus pending rows at their signed fee cap, so
289
+ * in-flight sponsorships consume limit budget until receipts land.
290
+ *
291
+ * @param db - The database.
292
+ * @param options - Aggregation scope.
293
+ * @returns The committed spend in base units.
294
+ */
295
+ export async function spend(db: Db.Db, options: spend.Options): Promise<bigint> {
296
+ const { chainIds, environment = 'production', orgId, since } = options
297
+ const query = db.kysely
298
+ .selectFrom('sponsored_transactions')
299
+ .select([
300
+ sql<string>`coalesce(sum(case when status = 'finalized' then fee_amount::numeric else 0 end), 0)`.as('finalized'), // prettier-ignore
301
+ sql<string>`coalesce(sum(case when status = 'pending' then fee_max::numeric else 0 end), 0)`.as('pending'), // prettier-ignore
302
+ ])
303
+ .where('orgId', '=', orgId)
304
+ .where('environment', '=', environment)
305
+ // Redundant with the CASE arms, but lets the billing index's
306
+ // (org_id, status, created_at) shape serve the range scan.
307
+ .where('status', 'in', ['finalized', 'pending'])
308
+ .where('chainId', 'in', [...chainIds])
309
+ .where('createdAt', '>=', since)
310
+ // Production mirrors the sponsorship gate — only billable rows count. Sandbox
311
+ // rows are non-billable by construction, so the figure is display-only.
312
+ const row = await (environment === 'production' ? query.where('billable', '=', true) : query) //
313
+ .executeTakeFirstOrThrow()
314
+ return BigInt(row.finalized) + BigInt(row.pending)
315
+ }
316
+
317
+ export declare namespace spend {
318
+ /** Options for {@link spend}. */
319
+ type Options = {
320
+ /** Chains whose spend counts (mainnet chain ids). */
321
+ chainIds: readonly number[]
322
+ /** Environment whose spend counts; production mirrors the billing gate, sandbox is display-only. Defaults to `production`. */
323
+ environment?: Table['environment'] | undefined
324
+ /** Organization id (`org_…`) the spend attributes to. */
325
+ orgId: string
326
+ /** Window start (ISO 8601); rows created earlier are out of scope. */
327
+ since: string
328
+ }
329
+ }
330
+
331
+ /**
332
+ * Sponsorship usage bucketed by time for one organization: row counts and
333
+ * committed fees per bucket. Fee semantics mirror {@link spend}: finalized
334
+ * rows at their actual fee, pending rows at their signed cap, failed rows at
335
+ * zero. Buckets with no rows are omitted.
336
+ *
337
+ * @param db - The database.
338
+ * @param options - Aggregation scope.
339
+ * @returns Time-ordered usage buckets.
340
+ */
341
+ export async function usage(db: Db.Db, options: usage.Options): Promise<usage.Bucket[]> {
342
+ // TODO: convert to an OLAP-friendly approach (rollup table or columnar store)
343
+ // when per-org range scans grow slow.
344
+ const { environment, from, interval, orgId, projectId, to } = options
345
+ // `sql.lit` inlines the enum-validated interval so the GROUP BY expression
346
+ // matches the SELECT expression textually; truncation happens in UTC wall
347
+ // time regardless of the session time zone.
348
+ const bucket = sql`date_trunc(${sql.lit(interval)}, (created_at::timestamptz) at time zone 'UTC')`
349
+ let query = db.kysely
350
+ .selectFrom('sponsored_transactions')
351
+ .select([
352
+ sql<string>`to_char(${bucket}, 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"')`.as('timestamp'),
353
+ sql<string>`count(*)`.as('count'),
354
+ sql<string>`coalesce(sum(case when status = 'finalized' then fee_amount::numeric when status = 'pending' then fee_max::numeric else 0 end), 0)`.as('feeTotal'), // prettier-ignore
355
+ ])
356
+ .where('orgId', '=', orgId)
357
+ // Text-range bounds keep the (org_id, created_at) index serving the scan.
358
+ .where('createdAt', '>=', from)
359
+ .where('createdAt', '<', to)
360
+ .groupBy(bucket)
361
+ .orderBy(bucket)
362
+ if (environment !== undefined) query = query.where('environment', '=', environment)
363
+ if (projectId !== undefined) query = query.where('projectId', '=', projectId)
364
+ const rows = await query.execute()
365
+ return rows.map((row) => ({
366
+ count: Number(row.count),
367
+ feeTotal: BigInt(row.feeTotal),
368
+ timestamp: row.timestamp,
369
+ }))
370
+ }
371
+
372
+ export declare namespace usage {
373
+ /** One time bucket of sponsorship usage. */
374
+ type Bucket = {
375
+ /** Sponsored transactions recorded in the bucket, any status. */
376
+ count: number
377
+ /** Committed fees in fee-token base units: finalized fees plus pending caps. */
378
+ feeTotal: bigint
379
+ /** Bucket start (ISO 8601), aligned to UTC calendar boundaries. */
380
+ timestamp: string
381
+ }
382
+
383
+ /** Options for {@link usage}. */
384
+ type Options = {
385
+ /** Key environment to restrict to; omit for all. */
386
+ environment?: Table['environment'] | undefined
387
+ /** Window start (ISO 8601), inclusive. */
388
+ from: string
389
+ /** Bucket width, aligned to UTC calendar boundaries. */
390
+ interval: 'day' | 'hour' | 'month' | 'week'
391
+ /** Organization id (`org_…`) the usage attributes to. */
392
+ orgId: string
393
+ /** Project id (`prj_…`) to restrict to; omit for all. */
394
+ projectId?: string | undefined
395
+ /** Window end (ISO 8601), exclusive. */
396
+ to: string
397
+ }
398
+ }
399
+
400
+ /**
401
+ * Lists finalized billable rows not yet reported to the billing meter, oldest
402
+ * first, for the metering job.
403
+ *
404
+ * @param db - The database.
405
+ * @param options - Reporting scope.
406
+ * @returns The unreported records.
407
+ */
408
+ export function listUnreported(db: Db.Db, options: listUnreported.Options): Promise<Record[]> {
409
+ let query = db.kysely
410
+ .selectFrom('sponsored_transactions')
411
+ .selectAll()
412
+ .where('status', '=', 'finalized')
413
+ .where('billable', '=', true)
414
+ .where('chainId', 'in', [...options.chainIds])
415
+ .where('meterReportedAt', 'is', null)
416
+ // `id` breaks same-millisecond `finalizedAt` ties for a deterministic scan.
417
+ .orderBy('finalizedAt', 'asc')
418
+ .orderBy('id', 'asc')
419
+ if (options.limit !== undefined) query = query.limit(options.limit)
420
+ return query.execute()
421
+ }
422
+
423
+ export declare namespace listUnreported {
424
+ /** Options for {@link listUnreported}. */
425
+ type Options = {
426
+ /** Chains whose spend is metered (mainnet chain ids). */
427
+ chainIds: readonly number[]
428
+ /** Maximum rows to return. */
429
+ limit?: number | undefined
430
+ }
431
+ }
432
+
433
+ /**
434
+ * Marks a row as reported to the billing meter. A no-op once marked — the
435
+ * durable exactly-once guard for the metering job.
436
+ *
437
+ * @param db - The database.
438
+ * @param id - The sponsored-transaction id (`stx_…`).
439
+ * @param at - When the report was acknowledged (ISO 8601).
440
+ */
441
+ export async function markReported(db: Db.Db, id: string, at: string): Promise<void> {
442
+ await db.kysely
443
+ .updateTable('sponsored_transactions')
444
+ .set({ meterReportedAt: at })
445
+ .where('id', '=', id)
446
+ .where('meterReportedAt', 'is', null)
447
+ .execute()
448
+ }
449
+
227
450
  /** Intent status predicate; failed rows are recovery candidates only briefly. */
228
451
  function intentStatus(failedSince: string | undefined) {
229
452
  return (eb: ExpressionBuilder<Db.Database, 'sponsored_transactions'>) => {
@@ -234,3 +457,8 @@ function intentStatus(failedSince: string | undefined) {
234
457
  ])
235
458
  }
236
459
  }
460
+
461
+ /** Thrown by {@link reserve} when recording would exceed the org's period spend limit. */
462
+ export class PeriodSpendLimitError extends Error {
463
+ override name = 'SponsoredTransactions.PeriodSpendLimitError'
464
+ }