@shipfox/api-logs 2.0.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 (342) hide show
  1. package/.turbo/turbo-build.log +2 -0
  2. package/.turbo/turbo-type$colon$emit.log +1 -0
  3. package/.turbo/turbo-type.log +1 -0
  4. package/CHANGELOG.md +179 -0
  5. package/LICENSE +21 -0
  6. package/README.md +151 -0
  7. package/dist/api/object-storage.d.ts +69 -0
  8. package/dist/api/object-storage.d.ts.map +1 -0
  9. package/dist/api/object-storage.js +199 -0
  10. package/dist/api/object-storage.js.map +1 -0
  11. package/dist/config.d.ts +20 -0
  12. package/dist/config.d.ts.map +1 -0
  13. package/dist/config.js +108 -0
  14. package/dist/config.js.map +1 -0
  15. package/dist/core/append-logs.d.ts +28 -0
  16. package/dist/core/append-logs.d.ts.map +1 -0
  17. package/dist/core/append-logs.js +318 -0
  18. package/dist/core/append-logs.js.map +1 -0
  19. package/dist/core/budget.d.ts +12 -0
  20. package/dist/core/budget.d.ts.map +1 -0
  21. package/dist/core/budget.js +9 -0
  22. package/dist/core/budget.js.map +1 -0
  23. package/dist/core/close-stream.d.ts +27 -0
  24. package/dist/core/close-stream.d.ts.map +1 -0
  25. package/dist/core/close-stream.js +59 -0
  26. package/dist/core/close-stream.js.map +1 -0
  27. package/dist/core/compaction.d.ts +28 -0
  28. package/dist/core/compaction.d.ts.map +1 -0
  29. package/dist/core/compaction.js +49 -0
  30. package/dist/core/compaction.js.map +1 -0
  31. package/dist/core/entities/attempt-stream.d.ts +37 -0
  32. package/dist/core/entities/attempt-stream.d.ts.map +1 -0
  33. package/dist/core/entities/attempt-stream.js +15 -0
  34. package/dist/core/entities/attempt-stream.js.map +1 -0
  35. package/dist/core/entities/job-accounting.d.ts +12 -0
  36. package/dist/core/entities/job-accounting.d.ts.map +1 -0
  37. package/dist/core/entities/job-accounting.js +6 -0
  38. package/dist/core/entities/job-accounting.js.map +1 -0
  39. package/dist/core/entities/log-object.d.ts +13 -0
  40. package/dist/core/entities/log-object.d.ts.map +1 -0
  41. package/dist/core/entities/log-object.js +9 -0
  42. package/dist/core/entities/log-object.js.map +1 -0
  43. package/dist/core/errors.d.ts +26 -0
  44. package/dist/core/errors.d.ts.map +1 -0
  45. package/dist/core/errors.js +31 -0
  46. package/dist/core/errors.js.map +1 -0
  47. package/dist/core/finalize-attempt-stream.d.ts +22 -0
  48. package/dist/core/finalize-attempt-stream.d.ts.map +1 -0
  49. package/dist/core/finalize-attempt-stream.js +48 -0
  50. package/dist/core/finalize-attempt-stream.js.map +1 -0
  51. package/dist/core/read-logs.d.ts +33 -0
  52. package/dist/core/read-logs.d.ts.map +1 -0
  53. package/dist/core/read-logs.js +49 -0
  54. package/dist/core/read-logs.js.map +1 -0
  55. package/dist/core/reap-stale-open-streams.d.ts +27 -0
  56. package/dist/core/reap-stale-open-streams.d.ts.map +1 -0
  57. package/dist/core/reap-stale-open-streams.js +50 -0
  58. package/dist/core/reap-stale-open-streams.js.map +1 -0
  59. package/dist/core/retention.d.ts +32 -0
  60. package/dist/core/retention.d.ts.map +1 -0
  61. package/dist/core/retention.js +148 -0
  62. package/dist/core/retention.js.map +1 -0
  63. package/dist/core/session/claude/rows.d.ts +6 -0
  64. package/dist/core/session/claude/rows.d.ts.map +1 -0
  65. package/dist/core/session/claude/rows.js +145 -0
  66. package/dist/core/session/claude/rows.js.map +1 -0
  67. package/dist/core/session/claude-parser.d.ts +4 -0
  68. package/dist/core/session/claude-parser.d.ts.map +1 -0
  69. package/dist/core/session/claude-parser.js +42 -0
  70. package/dist/core/session/claude-parser.js.map +1 -0
  71. package/dist/core/session/entry-schema.d.ts +51 -0
  72. package/dist/core/session/entry-schema.d.ts.map +1 -0
  73. package/dist/core/session/entry-schema.js +61 -0
  74. package/dist/core/session/entry-schema.js.map +1 -0
  75. package/dist/core/session/object.d.ts +12 -0
  76. package/dist/core/session/object.d.ts.map +1 -0
  77. package/dist/core/session/object.js +50 -0
  78. package/dist/core/session/object.js.map +1 -0
  79. package/dist/core/session/parse-session.d.ts +5 -0
  80. package/dist/core/session/parse-session.d.ts.map +1 -0
  81. package/dist/core/session/parse-session.js +18 -0
  82. package/dist/core/session/parse-session.js.map +1 -0
  83. package/dist/core/session/pi/message-rows.d.ts +4 -0
  84. package/dist/core/session/pi/message-rows.d.ts.map +1 -0
  85. package/dist/core/session/pi/message-rows.js +113 -0
  86. package/dist/core/session/pi/message-rows.js.map +1 -0
  87. package/dist/core/session/pi/metadata.d.ts +18 -0
  88. package/dist/core/session/pi/metadata.d.ts.map +1 -0
  89. package/dist/core/session/pi/metadata.js +120 -0
  90. package/dist/core/session/pi/metadata.js.map +1 -0
  91. package/dist/core/session/pi/text.d.ts +12 -0
  92. package/dist/core/session/pi/text.d.ts.map +1 -0
  93. package/dist/core/session/pi/text.js +59 -0
  94. package/dist/core/session/pi/text.js.map +1 -0
  95. package/dist/core/session/pi-parser.d.ts +5 -0
  96. package/dist/core/session/pi-parser.d.ts.map +1 -0
  97. package/dist/core/session/pi-parser.js +73 -0
  98. package/dist/core/session/pi-parser.js.map +1 -0
  99. package/dist/core/session/rows.d.ts +7 -0
  100. package/dist/core/session/rows.d.ts.map +1 -0
  101. package/dist/core/session/rows.js +39 -0
  102. package/dist/core/session/rows.js.map +1 -0
  103. package/dist/core/session/session-record.d.ts +5 -0
  104. package/dist/core/session/session-record.d.ts.map +1 -0
  105. package/dist/core/session/session-record.js +3 -0
  106. package/dist/core/session/session-record.js.map +1 -0
  107. package/dist/db/accounting.d.ts +33 -0
  108. package/dist/db/accounting.d.ts.map +1 -0
  109. package/dist/db/accounting.js +52 -0
  110. package/dist/db/accounting.js.map +1 -0
  111. package/dist/db/chunks.d.ts +62 -0
  112. package/dist/db/chunks.d.ts.map +1 -0
  113. package/dist/db/chunks.js +88 -0
  114. package/dist/db/chunks.js.map +1 -0
  115. package/dist/db/db.d.ts +752 -0
  116. package/dist/db/db.d.ts.map +1 -0
  117. package/dist/db/db.js +24 -0
  118. package/dist/db/db.js.map +1 -0
  119. package/dist/db/index.d.ts +2 -0
  120. package/dist/db/index.d.ts.map +1 -0
  121. package/dist/db/index.js +5 -0
  122. package/dist/db/index.js.map +1 -0
  123. package/dist/db/schema/attempt-streams.d.ts +304 -0
  124. package/dist/db/schema/attempt-streams.d.ts.map +1 -0
  125. package/dist/db/schema/attempt-streams.js +100 -0
  126. package/dist/db/schema/attempt-streams.js.map +1 -0
  127. package/dist/db/schema/chunks.d.ts +166 -0
  128. package/dist/db/schema/chunks.d.ts.map +1 -0
  129. package/dist/db/schema/chunks.js +47 -0
  130. package/dist/db/schema/chunks.js.map +1 -0
  131. package/dist/db/schema/common.d.ts +29 -0
  132. package/dist/db/schema/common.d.ts.map +1 -0
  133. package/dist/db/schema/common.js +11 -0
  134. package/dist/db/schema/common.js.map +1 -0
  135. package/dist/db/schema/job-accounting.d.ts +143 -0
  136. package/dist/db/schema/job-accounting.d.ts.map +1 -0
  137. package/dist/db/schema/job-accounting.js +43 -0
  138. package/dist/db/schema/job-accounting.js.map +1 -0
  139. package/dist/db/schema/outbox.d.ts +195 -0
  140. package/dist/db/schema/outbox.d.ts.map +1 -0
  141. package/dist/db/schema/outbox.js +5 -0
  142. package/dist/db/schema/outbox.js.map +1 -0
  143. package/dist/db/streams.d.ts +141 -0
  144. package/dist/db/streams.d.ts.map +1 -0
  145. package/dist/db/streams.js +197 -0
  146. package/dist/db/streams.js.map +1 -0
  147. package/dist/index.d.ts +4 -0
  148. package/dist/index.d.ts.map +1 -0
  149. package/dist/index.js +81 -0
  150. package/dist/index.js.map +1 -0
  151. package/dist/metrics/index.d.ts +2 -0
  152. package/dist/metrics/index.d.ts.map +1 -0
  153. package/dist/metrics/index.js +3 -0
  154. package/dist/metrics/index.js.map +1 -0
  155. package/dist/metrics/instance.d.ts +14 -0
  156. package/dist/metrics/instance.d.ts.map +1 -0
  157. package/dist/metrics/instance.js +16 -0
  158. package/dist/metrics/instance.js.map +1 -0
  159. package/dist/metrics/service.d.ts +2 -0
  160. package/dist/metrics/service.d.ts.map +1 -0
  161. package/dist/metrics/service.js +20 -0
  162. package/dist/metrics/service.js.map +1 -0
  163. package/dist/presentation/dto/read-logs.d.ts +4 -0
  164. package/dist/presentation/dto/read-logs.d.ts.map +1 -0
  165. package/dist/presentation/dto/read-logs.js +22 -0
  166. package/dist/presentation/dto/read-logs.js.map +1 -0
  167. package/dist/presentation/routes/append-logs.d.ts +2 -0
  168. package/dist/presentation/routes/append-logs.d.ts.map +1 -0
  169. package/dist/presentation/routes/append-logs.js +71 -0
  170. package/dist/presentation/routes/append-logs.js.map +1 -0
  171. package/dist/presentation/routes/index.d.ts +3 -0
  172. package/dist/presentation/routes/index.d.ts.map +1 -0
  173. package/dist/presentation/routes/index.js +32 -0
  174. package/dist/presentation/routes/index.js.map +1 -0
  175. package/dist/presentation/routes/read-logs.d.ts +2 -0
  176. package/dist/presentation/routes/read-logs.d.ts.map +1 -0
  177. package/dist/presentation/routes/read-logs.js +41 -0
  178. package/dist/presentation/routes/read-logs.js.map +1 -0
  179. package/dist/presentation/subscribers/on-job-terminated.d.ts +8 -0
  180. package/dist/presentation/subscribers/on-job-terminated.d.ts.map +1 -0
  181. package/dist/presentation/subscribers/on-job-terminated.js +32 -0
  182. package/dist/presentation/subscribers/on-job-terminated.js.map +1 -0
  183. package/dist/presentation/subscribers/on-log-stream-closed.d.ts +9 -0
  184. package/dist/presentation/subscribers/on-log-stream-closed.d.ts.map +1 -0
  185. package/dist/presentation/subscribers/on-log-stream-closed.js +31 -0
  186. package/dist/presentation/subscribers/on-log-stream-closed.js.map +1 -0
  187. package/dist/presentation/subscribers/on-step-attempt-terminated.d.ts +3 -0
  188. package/dist/presentation/subscribers/on-step-attempt-terminated.d.ts.map +1 -0
  189. package/dist/presentation/subscribers/on-step-attempt-terminated.js +14 -0
  190. package/dist/presentation/subscribers/on-step-attempt-terminated.js.map +1 -0
  191. package/dist/temporal/activities/close-abandoned-streams.d.ts +12 -0
  192. package/dist/temporal/activities/close-abandoned-streams.d.ts.map +1 -0
  193. package/dist/temporal/activities/close-abandoned-streams.js +33 -0
  194. package/dist/temporal/activities/close-abandoned-streams.js.map +1 -0
  195. package/dist/temporal/activities/compact-stream.d.ts +33 -0
  196. package/dist/temporal/activities/compact-stream.d.ts.map +1 -0
  197. package/dist/temporal/activities/compact-stream.js +95 -0
  198. package/dist/temporal/activities/compact-stream.js.map +1 -0
  199. package/dist/temporal/activities/compaction-reconcile.d.ts +16 -0
  200. package/dist/temporal/activities/compaction-reconcile.d.ts.map +1 -0
  201. package/dist/temporal/activities/compaction-reconcile.js +52 -0
  202. package/dist/temporal/activities/compaction-reconcile.js.map +1 -0
  203. package/dist/temporal/activities/index.d.ts +14 -0
  204. package/dist/temporal/activities/index.d.ts.map +1 -0
  205. package/dist/temporal/activities/index.js +16 -0
  206. package/dist/temporal/activities/index.js.map +1 -0
  207. package/dist/temporal/activities/reap-stale-open-streams.d.ts +4 -0
  208. package/dist/temporal/activities/reap-stale-open-streams.d.ts.map +1 -0
  209. package/dist/temporal/activities/reap-stale-open-streams.js +11 -0
  210. package/dist/temporal/activities/reap-stale-open-streams.js.map +1 -0
  211. package/dist/temporal/activities/retention-sweep.d.ts +7 -0
  212. package/dist/temporal/activities/retention-sweep.d.ts.map +1 -0
  213. package/dist/temporal/activities/retention-sweep.js +19 -0
  214. package/dist/temporal/activities/retention-sweep.js.map +1 -0
  215. package/dist/temporal/constants.d.ts +7 -0
  216. package/dist/temporal/constants.d.ts.map +1 -0
  217. package/dist/temporal/constants.js +13 -0
  218. package/dist/temporal/constants.js.map +1 -0
  219. package/dist/temporal/workflows/close-abandoned-streams.d.ts +11 -0
  220. package/dist/temporal/workflows/close-abandoned-streams.d.ts.map +1 -0
  221. package/dist/temporal/workflows/close-abandoned-streams.js +22 -0
  222. package/dist/temporal/workflows/close-abandoned-streams.js.map +1 -0
  223. package/dist/temporal/workflows/compact-stream.d.ts +6 -0
  224. package/dist/temporal/workflows/compact-stream.d.ts.map +1 -0
  225. package/dist/temporal/workflows/compact-stream.js +25 -0
  226. package/dist/temporal/workflows/compact-stream.js.map +1 -0
  227. package/dist/temporal/workflows/compaction-reconcile-cron.d.ts +3 -0
  228. package/dist/temporal/workflows/compaction-reconcile-cron.d.ts.map +1 -0
  229. package/dist/temporal/workflows/compaction-reconcile-cron.js +15 -0
  230. package/dist/temporal/workflows/compaction-reconcile-cron.js.map +1 -0
  231. package/dist/temporal/workflows/index.d.ts +6 -0
  232. package/dist/temporal/workflows/index.d.ts.map +1 -0
  233. package/dist/temporal/workflows/index.js +7 -0
  234. package/dist/temporal/workflows/index.js.map +1 -0
  235. package/dist/temporal/workflows/reap-stale-open-streams-cron.d.ts +2 -0
  236. package/dist/temporal/workflows/reap-stale-open-streams-cron.d.ts.map +1 -0
  237. package/dist/temporal/workflows/reap-stale-open-streams-cron.js +15 -0
  238. package/dist/temporal/workflows/reap-stale-open-streams-cron.js.map +1 -0
  239. package/dist/temporal/workflows/retention-sweep-cron.d.ts +2 -0
  240. package/dist/temporal/workflows/retention-sweep-cron.d.ts.map +1 -0
  241. package/dist/temporal/workflows/retention-sweep-cron.js +23 -0
  242. package/dist/temporal/workflows/retention-sweep-cron.js.map +1 -0
  243. package/dist/tsconfig.test.tsbuildinfo +1 -0
  244. package/drizzle/0000_initial.sql +65 -0
  245. package/drizzle/meta/0000_snapshot.json +529 -0
  246. package/drizzle/meta/_journal.json +13 -0
  247. package/drizzle.config.ts +7 -0
  248. package/package.json +84 -0
  249. package/src/api/object-storage-credentials.test.ts +70 -0
  250. package/src/api/object-storage.test.ts +64 -0
  251. package/src/api/object-storage.ts +226 -0
  252. package/src/config.test.ts +118 -0
  253. package/src/config.ts +130 -0
  254. package/src/core/append-logs.test.ts +563 -0
  255. package/src/core/append-logs.ts +384 -0
  256. package/src/core/budget.test.ts +30 -0
  257. package/src/core/budget.ts +19 -0
  258. package/src/core/close-stream.ts +72 -0
  259. package/src/core/compaction.ts +68 -0
  260. package/src/core/entities/attempt-stream.ts +38 -0
  261. package/src/core/entities/job-accounting.ts +11 -0
  262. package/src/core/entities/log-object.test.ts +25 -0
  263. package/src/core/entities/log-object.ts +18 -0
  264. package/src/core/errors.ts +37 -0
  265. package/src/core/finalize-attempt-stream.test.ts +90 -0
  266. package/src/core/finalize-attempt-stream.ts +59 -0
  267. package/src/core/read-logs.ts +77 -0
  268. package/src/core/reap-stale-open-streams.test.ts +137 -0
  269. package/src/core/reap-stale-open-streams.ts +60 -0
  270. package/src/core/retention.test.ts +388 -0
  271. package/src/core/retention.ts +184 -0
  272. package/src/core/session/claude/rows.ts +223 -0
  273. package/src/core/session/claude-parser.test.ts +199 -0
  274. package/src/core/session/claude-parser.ts +38 -0
  275. package/src/core/session/entry-schema.ts +82 -0
  276. package/src/core/session/object.ts +57 -0
  277. package/src/core/session/parse-session.ts +23 -0
  278. package/src/core/session/pi/message-rows.ts +211 -0
  279. package/src/core/session/pi/metadata.ts +148 -0
  280. package/src/core/session/pi/text.ts +83 -0
  281. package/src/core/session/pi-parser.test.ts +451 -0
  282. package/src/core/session/pi-parser.ts +107 -0
  283. package/src/core/session/rows.ts +38 -0
  284. package/src/core/session/session-record.ts +4 -0
  285. package/src/db/accounting.ts +81 -0
  286. package/src/db/chunks.ts +143 -0
  287. package/src/db/db.ts +22 -0
  288. package/src/db/index.ts +4 -0
  289. package/src/db/schema/attempt-streams.ts +106 -0
  290. package/src/db/schema/chunks.ts +43 -0
  291. package/src/db/schema/common.ts +12 -0
  292. package/src/db/schema/job-accounting.ts +38 -0
  293. package/src/db/schema/outbox.ts +4 -0
  294. package/src/db/streams.test.ts +47 -0
  295. package/src/db/streams.ts +368 -0
  296. package/src/index.ts +77 -0
  297. package/src/metrics/index.ts +1 -0
  298. package/src/metrics/instance.ts +33 -0
  299. package/src/metrics/service.ts +23 -0
  300. package/src/presentation/dto/read-logs.ts +24 -0
  301. package/src/presentation/routes/append-logs.test.ts +279 -0
  302. package/src/presentation/routes/append-logs.ts +66 -0
  303. package/src/presentation/routes/index.ts +27 -0
  304. package/src/presentation/routes/read-logs.test.ts +504 -0
  305. package/src/presentation/routes/read-logs.ts +38 -0
  306. package/src/presentation/subscribers/on-job-terminated.test.ts +64 -0
  307. package/src/presentation/subscribers/on-job-terminated.ts +26 -0
  308. package/src/presentation/subscribers/on-log-stream-closed.test.ts +63 -0
  309. package/src/presentation/subscribers/on-log-stream-closed.ts +26 -0
  310. package/src/presentation/subscribers/on-step-attempt-terminated.ts +16 -0
  311. package/src/temporal/activities/close-abandoned-streams.test.ts +84 -0
  312. package/src/temporal/activities/close-abandoned-streams.ts +30 -0
  313. package/src/temporal/activities/compact-stream.test.ts +221 -0
  314. package/src/temporal/activities/compact-stream.ts +123 -0
  315. package/src/temporal/activities/compaction-reconcile.test.ts +143 -0
  316. package/src/temporal/activities/compaction-reconcile.ts +48 -0
  317. package/src/temporal/activities/index.ts +15 -0
  318. package/src/temporal/activities/reap-stale-open-streams.test.ts +48 -0
  319. package/src/temporal/activities/reap-stale-open-streams.ts +14 -0
  320. package/src/temporal/activities/retention-sweep.test.ts +38 -0
  321. package/src/temporal/activities/retention-sweep.ts +23 -0
  322. package/src/temporal/constants.ts +14 -0
  323. package/src/temporal/workflows/close-abandoned-streams.ts +25 -0
  324. package/src/temporal/workflows/compact-stream.ts +26 -0
  325. package/src/temporal/workflows/compaction-reconcile-cron.ts +14 -0
  326. package/src/temporal/workflows/index.ts +5 -0
  327. package/src/temporal/workflows/reap-stale-open-streams-cron.ts +13 -0
  328. package/src/temporal/workflows/retention-sweep-cron.ts +23 -0
  329. package/test/env.ts +37 -0
  330. package/test/factories/job-accounting.ts +38 -0
  331. package/test/fixtures/closed-stream.ts +53 -0
  332. package/test/fixtures/lease-token.ts +42 -0
  333. package/test/fixtures/ndjson.ts +34 -0
  334. package/test/globalSetup.ts +44 -0
  335. package/test/index.ts +9 -0
  336. package/test/queries.ts +54 -0
  337. package/test/setup.ts +17 -0
  338. package/tsconfig.build.json +9 -0
  339. package/tsconfig.build.tsbuildinfo +1 -0
  340. package/tsconfig.json +3 -0
  341. package/tsconfig.test.json +8 -0
  342. package/vitest.config.ts +13 -0
@@ -0,0 +1,48 @@
1
+ import {logger} from '@shipfox/node-opentelemetry';
2
+ import {temporalClient} from '@shipfox/node-temporal';
3
+ import {config} from '#config.js';
4
+ import {listStaleUncompactedStreams} from '#db/streams.js';
5
+ import {LOGS_COMPACTION_TASK_QUEUE} from '#temporal/constants.js';
6
+
7
+ // Bounded per tick; remaining stale streams are picked up on the next cron run.
8
+ const RECONCILE_BATCH_LIMIT = 100;
9
+
10
+ /**
11
+ * Backstop for the event-triggered path: finds closed streams that never got an object
12
+ * key past the stale window and re-starts compaction for each. Starting by the same
13
+ * `logs-compact:{streamId}` workflow id re-drives a stream whose bounded-retry run already
14
+ * failed and closed, while skipping one whose run is still RUNNING (AlreadyStarted). Starts
15
+ * independent workflows, not children, so a re-driven run is not tied to this cron tick.
16
+ *
17
+ * One stream's unexpected start failure is logged and skipped, never thrown: a single poison
18
+ * stream must not abort the batch and leave the rest of the backlog un-re-driven (the next
19
+ * tick retries it). The activity only fails on the initial DB query.
20
+ */
21
+ export async function compactionReconcileActivity(): Promise<{restarted: number; failed: number}> {
22
+ const stale = await listStaleUncompactedStreams({
23
+ olderThanSeconds: config.LOG_COMPACTION_RECONCILE_STALE_SECONDS,
24
+ limit: RECONCILE_BATCH_LIMIT,
25
+ });
26
+
27
+ let restarted = 0;
28
+ let failed = 0;
29
+ for (const stream of stale) {
30
+ try {
31
+ await temporalClient().workflow.start('compactStream', {
32
+ taskQueue: LOGS_COMPACTION_TASK_QUEUE,
33
+ workflowId: `logs-compact:${stream.id}`,
34
+ args: [{streamId: stream.id}],
35
+ });
36
+ restarted += 1;
37
+ } catch (error) {
38
+ if (error instanceof Error && error.name === 'WorkflowExecutionAlreadyStartedError') continue;
39
+ failed += 1;
40
+ logger().error(
41
+ {err: error, streamId: stream.id},
42
+ 'Failed to re-drive stale stream compaction',
43
+ );
44
+ }
45
+ }
46
+
47
+ return {restarted, failed};
48
+ }
@@ -0,0 +1,15 @@
1
+ import {closeAbandonedStreamsActivity} from './close-abandoned-streams.js';
2
+ import {compactStreamActivity} from './compact-stream.js';
3
+ import {compactionReconcileActivity} from './compaction-reconcile.js';
4
+ import {reapStaleOpenStreamsActivity} from './reap-stale-open-streams.js';
5
+ import {retentionSweepActivity} from './retention-sweep.js';
6
+
7
+ export function createLogsActivities() {
8
+ return {
9
+ closeAbandonedStreamsActivity,
10
+ compactStreamActivity,
11
+ compactionReconcileActivity,
12
+ reapStaleOpenStreamsActivity,
13
+ retentionSweepActivity,
14
+ };
15
+ }
@@ -0,0 +1,48 @@
1
+ import {MockActivityEnvironment} from '@temporalio/testing';
2
+ import {eq, sql} from 'drizzle-orm';
3
+ import {appendLogs} from '#core/append-logs.js';
4
+ import {db} from '#db/db.js';
5
+ import {attemptStreams} from '#db/schema/attempt-streams.js';
6
+ import {ndjsonBody, outputLine} from '#test/fixtures/ndjson.js';
7
+ import {findStream} from '#test/queries.js';
8
+ import {reapStaleOpenStreamsActivity} from './reap-stale-open-streams.js';
9
+
10
+ interface Ctx {
11
+ jobId: string;
12
+ stepId: string;
13
+ workspaceId: string;
14
+ projectId: string;
15
+ workflowRunAttemptId: string;
16
+ }
17
+
18
+ function newCtx(): Ctx {
19
+ return {
20
+ jobId: crypto.randomUUID(),
21
+ stepId: crypto.randomUUID(),
22
+ workspaceId: crypto.randomUUID(),
23
+ projectId: crypto.randomUUID(),
24
+ workflowRunAttemptId: crypto.randomUUID(),
25
+ };
26
+ }
27
+
28
+ describe('reapStaleOpenStreamsActivity', () => {
29
+ it('reaps a stale open stream end to end (config wiring under a real activity context)', async () => {
30
+ const ctx = newCtx();
31
+ await appendLogs({...ctx, attempt: 1, offset: 0, body: ndjsonBody(outputLine('partial\n'))});
32
+ const open = await findStream({...ctx, attempt: 1});
33
+ // Past the default LOG_STREAM_REAP_AFTER_SECONDS (7200) the activity reads from config.
34
+ await db()
35
+ .update(attemptStreams)
36
+ .set({createdAt: sql`now() - interval '3 hours'`})
37
+ .where(eq(attemptStreams.id, open?.id as string));
38
+
39
+ const result = (await new MockActivityEnvironment().run(
40
+ reapStaleOpenStreamsActivity,
41
+ )) as Awaited<ReturnType<typeof reapStaleOpenStreamsActivity>>;
42
+
43
+ expect(result.reaped).toBeGreaterThanOrEqual(1);
44
+ const after = await findStream({...ctx, attempt: 1});
45
+ expect(after?.state).toBe('closed');
46
+ expect(after?.closeReason).toBe('timeout');
47
+ });
48
+ });
@@ -0,0 +1,14 @@
1
+ import {config} from '#config.js';
2
+ import {
3
+ type ReapStaleOpenStreamsResult,
4
+ reapStaleOpenStreams,
5
+ } from '#core/reap-stale-open-streams.js';
6
+ import {REAP_BATCH_LIMIT} from '#temporal/constants.js';
7
+
8
+ /** Cron-driven backstop that force-closes open streams the one-shot job-terminal close missed. */
9
+ export function reapStaleOpenStreamsActivity(): Promise<ReapStaleOpenStreamsResult> {
10
+ return reapStaleOpenStreams({
11
+ olderThanSeconds: config.LOG_STREAM_REAP_AFTER_SECONDS,
12
+ batchLimit: REAP_BATCH_LIMIT,
13
+ });
14
+ }
@@ -0,0 +1,38 @@
1
+ import {MockActivityEnvironment} from '@temporalio/testing';
2
+ import {eq, sql} from 'drizzle-orm';
3
+ import {db} from '#db/db.js';
4
+ import {attemptStreams} from '#db/schema/attempt-streams.js';
5
+ import {arrangeClosedStream, type ClosedStreamIdentity} from '#test/fixtures/closed-stream.js';
6
+ import {ndjsonBody, outputLine} from '#test/fixtures/ndjson.js';
7
+ import {findStream} from '#test/queries.js';
8
+ import {retentionSweepActivity} from './retention-sweep.js';
9
+
10
+ function newIdentity(): ClosedStreamIdentity {
11
+ return {
12
+ jobId: crypto.randomUUID(),
13
+ stepId: crypto.randomUUID(),
14
+ attempt: 1,
15
+ workspaceId: crypto.randomUUID(),
16
+ projectId: crypto.randomUUID(),
17
+ workflowRunAttemptId: crypto.randomUUID(),
18
+ };
19
+ }
20
+
21
+ describe('retentionSweepActivity', () => {
22
+ it('sweeps an expired stream end to end (config wiring + heartbeat under a real activity context)', async () => {
23
+ const id = newIdentity();
24
+ const stream = await arrangeClosedStream(id, {chunks: [ndjsonBody(outputLine('x'))]});
25
+ // Past the default LOG_RETENTION_DAYS (90) the activity reads from config.
26
+ await db()
27
+ .update(attemptStreams)
28
+ .set({closedAt: sql`now() - interval '100 days'`})
29
+ .where(eq(attemptStreams.id, stream.id));
30
+
31
+ const result = (await new MockActivityEnvironment().run(retentionSweepActivity)) as Awaited<
32
+ ReturnType<typeof retentionSweepActivity>
33
+ >;
34
+
35
+ expect(await findStream(id)).toBeNull();
36
+ expect(result.timedOut).toBe(false);
37
+ });
38
+ });
@@ -0,0 +1,23 @@
1
+ import {Context} from '@temporalio/activity';
2
+ import {config} from '#config.js';
3
+ import {type RetentionSweepResult, runRetentionSweep} from '#core/retention.js';
4
+ import {
5
+ RETENTION_BATCH_LIMIT,
6
+ RETENTION_MAX_ITERATIONS,
7
+ RETENTION_TIME_BUDGET_MS,
8
+ } from '#temporal/constants.js';
9
+
10
+ /**
11
+ * Cron-driven sweep for expired closed log streams. Heartbeats per stream; the core loop owns
12
+ * the wall-clock budget because Temporal timeouts do not stop already-running JS.
13
+ */
14
+ export function retentionSweepActivity(): Promise<RetentionSweepResult> {
15
+ const ctx = Context.current();
16
+ return runRetentionSweep({
17
+ retentionDays: config.LOG_RETENTION_DAYS,
18
+ batchLimit: RETENTION_BATCH_LIMIT,
19
+ timeBudgetMs: RETENTION_TIME_BUDGET_MS,
20
+ maxIterations: RETENTION_MAX_ITERATIONS,
21
+ onProgress: () => ctx.heartbeat(),
22
+ });
23
+ }
@@ -0,0 +1,14 @@
1
+ export const LOGS_LIFECYCLE_TASK_QUEUE = 'logs-lifecycle';
2
+
3
+ // Compaction uploads can run for minutes; keeping them off the lifecycle queue stops a
4
+ // burst of large compactions from starving the short, time-sensitive close-abandoned sweep.
5
+ export const LOGS_COMPACTION_TASK_QUEUE = 'logs-compaction';
6
+
7
+ // The wall-clock budget is the real sweep bound; Temporal's timeout does not stop JS already
8
+ // running in the worker.
9
+ export const RETENTION_BATCH_LIMIT = 200;
10
+ export const RETENTION_TIME_BUDGET_MS = 4 * 60_000;
11
+ export const RETENTION_MAX_ITERATIONS = 1_000;
12
+
13
+ // Bounded per tick; remaining stale open streams are picked up on the next cron run.
14
+ export const REAP_BATCH_LIMIT = 100;
@@ -0,0 +1,25 @@
1
+ import {log, proxyActivities, sleep} from '@temporalio/workflow';
2
+ import type {createLogsActivities} from '../activities/index.js';
3
+
4
+ const {closeAbandonedStreamsActivity} = proxyActivities<ReturnType<typeof createLogsActivities>>({
5
+ startToCloseTimeout: '5 minutes',
6
+ });
7
+
8
+ export interface CloseAbandonedStreamsInput {
9
+ jobId: string;
10
+ graceSeconds: number;
11
+ }
12
+
13
+ /**
14
+ * Started by the `WORKFLOWS_JOB_TERMINATED` subscriber, deduped per job. Waits out
15
+ * the grace period (so a last in-flight append can land and the runner's own
16
+ * declared close can win), then force-closes whatever is still open.
17
+ */
18
+ export async function closeAbandonedStreams(input: CloseAbandonedStreamsInput): Promise<void> {
19
+ await sleep(input.graceSeconds * 1000);
20
+
21
+ const {closed} = await closeAbandonedStreamsActivity({jobId: input.jobId});
22
+ if (closed > 0) {
23
+ log.info('Force-closed abandoned log streams', {jobId: input.jobId, closed});
24
+ }
25
+ }
@@ -0,0 +1,26 @@
1
+ import {log, proxyActivities} from '@temporalio/workflow';
2
+ import type {createLogsActivities} from '../activities/index.js';
3
+
4
+ // Bounded generous retry (not unlimited): a never-finishing RUNNING workflow would reject
5
+ // the reconcile cron's restart of the same workflow id. After the attempts are spent the
6
+ // run fails and closes, so the cron can re-drive it.
7
+ const {compactStreamActivity} = proxyActivities<ReturnType<typeof createLogsActivities>>({
8
+ startToCloseTimeout: '1 hour',
9
+ heartbeatTimeout: '2 minutes',
10
+ retry: {
11
+ initialInterval: '10 seconds',
12
+ backoffCoefficient: 2,
13
+ maximumInterval: '5 minutes',
14
+ maximumAttempts: 12,
15
+ },
16
+ });
17
+
18
+ export interface CompactStreamInput {
19
+ streamId: string;
20
+ }
21
+
22
+ /** Started per closed stream (deduped by `logs-compact:{streamId}`) by the closed-event subscriber and the reconcile cron. */
23
+ export async function compactStream(input: CompactStreamInput): Promise<void> {
24
+ const result = await compactStreamActivity({streamId: input.streamId});
25
+ log.info('Compacted log stream', {streamId: input.streamId, ...result});
26
+ }
@@ -0,0 +1,14 @@
1
+ import {log, proxyActivities} from '@temporalio/workflow';
2
+ import type {createLogsActivities} from '../activities/index.js';
3
+
4
+ const {compactionReconcileActivity} = proxyActivities<ReturnType<typeof createLogsActivities>>({
5
+ startToCloseTimeout: '5 minutes',
6
+ });
7
+
8
+ /** Cron-scheduled backstop that re-drives closed streams whose compaction never started or permanently failed. */
9
+ export async function compactionReconcileCron(): Promise<void> {
10
+ const {restarted, failed} = await compactionReconcileActivity();
11
+ if (restarted > 0 || failed > 0) {
12
+ log.info('Re-drove stale uncompacted log streams', {restarted, failed});
13
+ }
14
+ }
@@ -0,0 +1,5 @@
1
+ export {closeAbandonedStreams} from './close-abandoned-streams.js';
2
+ export {compactStream} from './compact-stream.js';
3
+ export {compactionReconcileCron} from './compaction-reconcile-cron.js';
4
+ export {reapStaleOpenStreamsCron} from './reap-stale-open-streams-cron.js';
5
+ export {retentionSweepCron} from './retention-sweep-cron.js';
@@ -0,0 +1,13 @@
1
+ import {log, proxyActivities} from '@temporalio/workflow';
2
+ import type {createLogsActivities} from '../activities/index.js';
3
+
4
+ const {reapStaleOpenStreamsActivity} = proxyActivities<ReturnType<typeof createLogsActivities>>({
5
+ startToCloseTimeout: '5 minutes',
6
+ });
7
+
8
+ export async function reapStaleOpenStreamsCron(): Promise<void> {
9
+ const {reaped, failed} = await reapStaleOpenStreamsActivity();
10
+ if (reaped > 0 || failed > 0) {
11
+ log.info('Reaped stale open log streams', {reaped, failed});
12
+ }
13
+ }
@@ -0,0 +1,23 @@
1
+ import {log, proxyActivities} from '@temporalio/workflow';
2
+ import type {createLogsActivities} from '../activities/index.js';
3
+
4
+ const {retentionSweepActivity} = proxyActivities<ReturnType<typeof createLogsActivities>>({
5
+ startToCloseTimeout: '5 minutes',
6
+ heartbeatTimeout: '1 minute',
7
+ // Retry on the next cron tick so a timed-out attempt cannot overlap its own still-running loop.
8
+ retry: {maximumAttempts: 1},
9
+ });
10
+
11
+ export async function retentionSweepCron(): Promise<void> {
12
+ const {deleted, raced, failed, accountingPruned, iterations, timedOut} =
13
+ await retentionSweepActivity();
14
+ // Keep a workflow-level audit trail for this destructive sweep.
15
+ log.info('Retention sweep complete', {
16
+ deleted,
17
+ raced,
18
+ failed,
19
+ accountingPruned,
20
+ iterations,
21
+ timedOut,
22
+ });
23
+ }
package/test/env.ts ADDED
@@ -0,0 +1,37 @@
1
+ process.env.POSTGRES_HOST ??= 'localhost';
2
+ process.env.POSTGRES_PORT ??= '5432';
3
+ process.env.POSTGRES_USERNAME ??= 'shipfox';
4
+ process.env.POSTGRES_PASSWORD ??= 'password';
5
+ process.env.POSTGRES_DATABASE = 'api_test';
6
+ process.env.POSTGRES_MAX_CONNECTIONS ??= '5';
7
+ process.env.AUTH_JWT_SECRET = 'test-secret';
8
+ process.env.AUTH_JOB_LEASE_TOKEN_SECRET = 'test-lease-secret';
9
+ process.env.AUTH_RUNNER_SESSION_TOKEN_SECRET = 'test-runner-session-secret';
10
+ process.env.SECRETS_ENCRYPTION_KEK = 'ZmVkY2JhOTg3NjU0MzIxMGZlZGNiYTk4NzY1NDMyMTA=';
11
+
12
+ // Small accrual budget so cap/budget tests use tiny payloads: base 100 bytes,
13
+ // rate 60 bytes/min (1 byte/second).
14
+ process.env.LOG_BUDGET_BASE_BYTES = '100';
15
+ process.env.LOG_BUDGET_RATE_BYTES_PER_MINUTE = '60';
16
+
17
+ // Modest body limit (64 KiB) so the route 413 test uses a small payload.
18
+ process.env.LOG_APPEND_BODY_LIMIT_BYTES = '65536';
19
+
20
+ // Small agent_session line cap so the over-cap test uses a tiny payload. Must stay
21
+ // <= LOG_APPEND_BODY_LIMIT_BYTES for the startup invariant.
22
+ process.env.LOG_MAX_SESSION_LINE_BYTES = '512';
23
+
24
+ // Tiny inline read page so the read-path drain/has_more test pages with small fixtures.
25
+ process.env.LOG_READ_INLINE_MAX_BYTES = '256';
26
+
27
+ // Real Garage dev credentials (bootstrap.sh creates the shipfox-logs-test bucket and grants
28
+ // this key). Compaction tests upload to and read back from live Garage from compose.yml.
29
+ process.env.LOG_STORAGE_S3_ENDPOINT ??= 'http://localhost:3900';
30
+ process.env.LOG_STORAGE_S3_REGION = 'garage';
31
+ process.env.LOG_STORAGE_S3_BUCKET = 'shipfox-logs-test';
32
+ process.env.LOG_STORAGE_S3_ACCESS_KEY_ID = 'GK000000000000000000000000';
33
+ process.env.LOG_STORAGE_S3_SECRET_ACCESS_KEY =
34
+ '0000000000000000000000000000000000000000000000000000000000000000';
35
+ process.env.LOG_STORAGE_S3_FORCE_PATH_STYLE = 'true';
36
+
37
+ process.env.TZ = 'UTC';
@@ -0,0 +1,38 @@
1
+ import {Factory} from 'fishery';
2
+ import {db} from '#db/db.js';
3
+ import {jobAccounting} from '#db/schema/job-accounting.js';
4
+
5
+ interface JobAccountingAttrs {
6
+ jobId: string;
7
+ workspaceId: string;
8
+ storedBytesUsed: number;
9
+ startedAt: Date;
10
+ cappedAt: Date | null;
11
+ }
12
+
13
+ // Arranges a pre-existing accounting row so a test can control the budget clock
14
+ // origin (`startedAt`) or pre-cap a job. `appendLogs` creates the row with
15
+ // `INSERT ... ON CONFLICT DO NOTHING`, so a pre-arranged row is preserved.
16
+ export const jobAccountingFactory = Factory.define<JobAccountingAttrs>(({onCreate}) => {
17
+ onCreate(async (attrs) => {
18
+ await db()
19
+ .insert(jobAccounting)
20
+ .values({
21
+ jobId: attrs.jobId,
22
+ workspaceId: attrs.workspaceId,
23
+ storedBytesUsed: attrs.storedBytesUsed,
24
+ startedAt: attrs.startedAt,
25
+ cappedAt: attrs.cappedAt,
26
+ })
27
+ .onConflictDoNothing({target: jobAccounting.jobId});
28
+ return attrs;
29
+ });
30
+
31
+ return {
32
+ jobId: crypto.randomUUID(),
33
+ workspaceId: crypto.randomUUID(),
34
+ storedBytesUsed: 0,
35
+ startedAt: new Date(),
36
+ cappedAt: null,
37
+ };
38
+ });
@@ -0,0 +1,53 @@
1
+ import type {Buffer} from 'node:buffer';
2
+ import {closeStream} from '#core/close-stream.js';
3
+ import type {AttemptStream} from '#core/entities/attempt-stream.js';
4
+ import {insertChunk} from '#db/chunks.js';
5
+ import {db} from '#db/db.js';
6
+ import {getOrCreateAttemptStream} from '#db/streams.js';
7
+
8
+ export interface ClosedStreamIdentity {
9
+ jobId: string;
10
+ stepId: string;
11
+ attempt: number;
12
+ workspaceId: string;
13
+ projectId: string;
14
+ workflowRunAttemptId: string;
15
+ }
16
+
17
+ export interface ArrangeClosedStreamOptions {
18
+ /** Runner chunk bodies, inserted in order with increasing `stream_offset`. */
19
+ chunks?: Buffer[];
20
+ /** Close via the timeout path, appending a `runner_lost` control chunk (no runner chunks). */
21
+ tombstone?: boolean;
22
+ }
23
+
24
+ /** Arranges a closed stream with the given chunks, the precondition compaction runs against. */
25
+ export function arrangeClosedStream(
26
+ identity: ClosedStreamIdentity,
27
+ options: ArrangeClosedStreamOptions = {},
28
+ ): Promise<AttemptStream> {
29
+ const chunks = options.chunks ?? [];
30
+
31
+ return db().transaction(async (tx) => {
32
+ const stream = await getOrCreateAttemptStream(tx, identity);
33
+
34
+ let offset = 0;
35
+ for (const data of chunks) {
36
+ await insertChunk(tx, {
37
+ streamId: stream.id,
38
+ streamOffset: offset,
39
+ byteLen: data.length,
40
+ data,
41
+ origin: 'runner',
42
+ });
43
+ offset += data.length;
44
+ }
45
+
46
+ const closed = await closeStream(tx, {
47
+ streamId: stream.id,
48
+ reason: options.tombstone ? 'timeout' : 'declared',
49
+ });
50
+
51
+ return closed ?? stream;
52
+ });
53
+ }
@@ -0,0 +1,42 @@
1
+ import {JOB_LEASE_TOKEN_AUDIENCE} from '@shipfox/api-auth-dto';
2
+ import {signHs256} from '@shipfox/node-jwt';
3
+
4
+ // Matches test/env.ts; the lease-token auth method reads this same value from config.
5
+ const SECRET = process.env.AUTH_JOB_LEASE_TOKEN_SECRET ?? 'test-lease-secret';
6
+
7
+ export interface MintLeaseTokenParams {
8
+ jobId: string;
9
+ jobExecutionId: string;
10
+ workspaceId?: string;
11
+ projectId?: string;
12
+ workflowRunId?: string;
13
+ workflowRunAttemptId?: string;
14
+ currentStepId?: string;
15
+ currentStepAttempt?: number;
16
+ secret?: string;
17
+ expiresIn?: string;
18
+ audience?: string;
19
+ }
20
+
21
+ export function mintLeaseToken(params: MintLeaseTokenParams): Promise<string> {
22
+ return signHs256({
23
+ payload: {
24
+ jobId: params.jobId,
25
+ jobExecutionId: params.jobExecutionId,
26
+ workflowRunId: params.workflowRunId ?? crypto.randomUUID(),
27
+ workflowRunAttemptId: params.workflowRunAttemptId ?? crypto.randomUUID(),
28
+ projectId: params.projectId ?? crypto.randomUUID(),
29
+ workspaceId: params.workspaceId ?? crypto.randomUUID(),
30
+ runnerSessionId: crypto.randomUUID(),
31
+ ...(params.currentStepId && params.currentStepAttempt !== undefined
32
+ ? {
33
+ currentStepId: params.currentStepId,
34
+ currentStepAttempt: params.currentStepAttempt,
35
+ }
36
+ : {}),
37
+ },
38
+ secret: params.secret ?? SECRET,
39
+ expiresIn: params.expiresIn ?? '90m',
40
+ audience: params.audience ?? JOB_LEASE_TOKEN_AUDIENCE,
41
+ });
42
+ }
@@ -0,0 +1,34 @@
1
+ import {Buffer} from 'node:buffer';
2
+
3
+ export function outputLine(data: string, stream: 'stdout' | 'stderr' = 'stdout'): string {
4
+ return `${JSON.stringify({v: 1, ts: 1, type: 'output', stream, data})}\n`;
5
+ }
6
+
7
+ export function recordLine(fields: Record<string, unknown>): string {
8
+ return `${JSON.stringify({v: 1, ts: 1, ...fields})}\n`;
9
+ }
10
+
11
+ export function endLine(totalBytes: number): string {
12
+ return recordLine({type: 'end', total_bytes: totalBytes});
13
+ }
14
+
15
+ export function sessionLine(data: string): string {
16
+ return recordLine({type: 'agent_session', data});
17
+ }
18
+
19
+ export function groupStartLine(
20
+ groupId: string,
21
+ name: string,
22
+ parentGroupId: string | null = null,
23
+ ): string {
24
+ return recordLine({type: 'group_start', group_id: groupId, parent_group_id: parentGroupId, name});
25
+ }
26
+
27
+ export function ndjsonBody(...lines: string[]): Buffer {
28
+ return Buffer.from(lines.join(''), 'utf8');
29
+ }
30
+
31
+ /** A single output record whose decoded `data` payload is exactly `payloadBytes` ASCII bytes. */
32
+ export function outputOfBytes(payloadBytes: number): Buffer {
33
+ return ndjsonBody(outputLine('x'.repeat(payloadBytes)));
34
+ }
@@ -0,0 +1,44 @@
1
+ import './env.js';
2
+ import {runMigrations} from '@shipfox/node-drizzle';
3
+ import {closePostgresClient, createPostgresClient} from '@shipfox/node-postgres';
4
+ import {sql} from 'drizzle-orm';
5
+ import {checkBucketReachable, closeS3Client} from '#api/object-storage.js';
6
+ import {closeDb, db} from '#db/db.js';
7
+ import {migrationsPath} from '#db/index.js';
8
+
9
+ const BUCKET_READY_TIMEOUT_MS = 30_000;
10
+ const BUCKET_READY_POLL_INTERVAL_MS = 500;
11
+
12
+ // `garage-init` exits 0 once it has posted the key/bucket ACLs, but Garage does not guarantee
13
+ // those are immediately usable for S3 uploads. Poll HeadBucket so the suite never starts before
14
+ // the store will accept the configured credentials — without this, the compaction tests race the
15
+ // ACL propagation and fail with `AccessDenied: No such key`.
16
+ async function waitForBucketReachable(timeoutMs: number): Promise<void> {
17
+ const deadline = Date.now() + timeoutMs;
18
+ while (Date.now() < deadline) {
19
+ if (await checkBucketReachable()) return;
20
+ await new Promise((resolve) => setTimeout(resolve, BUCKET_READY_POLL_INTERVAL_MS));
21
+ }
22
+ if (!(await checkBucketReachable())) {
23
+ throw new Error(
24
+ `Garage bucket not reachable after ${timeoutMs / 1000}s; ` +
25
+ 'check that the garage + garage-init compose services are up and the access key is provisioned.',
26
+ );
27
+ }
28
+ }
29
+
30
+ export async function setup() {
31
+ createPostgresClient();
32
+
33
+ await runMigrations(db(), migrationsPath, '__drizzle_migrations_logs');
34
+ await db().execute(sql`TRUNCATE logs_chunks CASCADE`);
35
+ await db().execute(sql`TRUNCATE logs_attempt_streams CASCADE`);
36
+ await db().execute(sql`TRUNCATE logs_job_accounting CASCADE`);
37
+ await db().execute(sql`TRUNCATE logs_outbox CASCADE`);
38
+
39
+ closeDb();
40
+ await closePostgresClient();
41
+
42
+ await waitForBucketReachable(BUCKET_READY_TIMEOUT_MS);
43
+ closeS3Client();
44
+ }
package/test/index.ts ADDED
@@ -0,0 +1,9 @@
1
+ export {jobAccountingFactory} from './factories/job-accounting.js';
2
+ export {mintLeaseToken} from './fixtures/lease-token.js';
3
+ export {
4
+ findAccounting,
5
+ findStream,
6
+ listChunks,
7
+ listStreamClosedEvents,
8
+ type StreamIdentity,
9
+ } from './queries.js';
@@ -0,0 +1,54 @@
1
+ import {LOG_STREAM_CLOSED, type LogStreamClosedEvent} from '@shipfox/api-logs-dto';
2
+ import {and, asc, eq} from 'drizzle-orm';
3
+ import type {AttemptStream} from '#core/entities/attempt-stream.js';
4
+ import type {JobAccounting} from '#core/entities/job-accounting.js';
5
+ import {db} from '#db/db.js';
6
+ import {attemptStreams, toAttemptStream} from '#db/schema/attempt-streams.js';
7
+ import {type LogChunkDb, logChunks} from '#db/schema/chunks.js';
8
+ import {jobAccounting, toJobAccounting} from '#db/schema/job-accounting.js';
9
+ import {logsOutbox} from '#db/schema/outbox.js';
10
+
11
+ export interface StreamIdentity {
12
+ jobId: string;
13
+ stepId: string;
14
+ attempt: number;
15
+ }
16
+
17
+ export async function findStream(identity: StreamIdentity): Promise<AttemptStream | null> {
18
+ const [row] = await db()
19
+ .select()
20
+ .from(attemptStreams)
21
+ .where(
22
+ and(
23
+ eq(attemptStreams.jobId, identity.jobId),
24
+ eq(attemptStreams.stepId, identity.stepId),
25
+ eq(attemptStreams.attempt, identity.attempt),
26
+ ),
27
+ );
28
+ return row ? toAttemptStream(row) : null;
29
+ }
30
+
31
+ export async function findAccounting(jobId: string): Promise<JobAccounting | null> {
32
+ const [row] = await db().select().from(jobAccounting).where(eq(jobAccounting.jobId, jobId));
33
+ return row ? toJobAccounting(row) : null;
34
+ }
35
+
36
+ export async function listChunks(streamId: string): Promise<LogChunkDb[]> {
37
+ return await db()
38
+ .select()
39
+ .from(logChunks)
40
+ .where(eq(logChunks.streamId, streamId))
41
+ .orderBy(asc(logChunks.seq));
42
+ }
43
+
44
+ /** The `logs.stream.closed` outbox events written for a given stream (filtered in memory; the table is not truncated between tests). */
45
+ export async function listStreamClosedEvents(streamId: string): Promise<LogStreamClosedEvent[]> {
46
+ const rows = await db()
47
+ .select()
48
+ .from(logsOutbox)
49
+ .where(eq(logsOutbox.eventType, LOG_STREAM_CLOSED));
50
+
51
+ return rows
52
+ .map((row) => row.payload as LogStreamClosedEvent)
53
+ .filter((payload) => payload.streamId === streamId);
54
+ }
package/test/setup.ts ADDED
@@ -0,0 +1,17 @@
1
+ import './env.js';
2
+ import {closePostgresClient, createPostgresClient} from '@shipfox/node-postgres';
3
+ import {afterAll, afterEach, beforeAll, vi} from '@shipfox/vitest/vi';
4
+ import {closeDb} from '#db/db.js';
5
+
6
+ beforeAll(() => {
7
+ createPostgresClient();
8
+ });
9
+
10
+ afterEach(() => {
11
+ vi.restoreAllMocks();
12
+ });
13
+
14
+ afterAll(async () => {
15
+ closeDb();
16
+ await closePostgresClient();
17
+ });