@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,368 @@
1
+ import {and, asc, eq, getTableColumns, isNull, lt, notInArray, sql} from 'drizzle-orm';
2
+ import type {AttemptStream, StreamCloseReason} from '#core/entities/attempt-stream.js';
3
+ import {LeaseStreamMismatchError} from '#core/errors.js';
4
+ import {db, type Transaction} from './db.js';
5
+ import {attemptStreams, toAttemptStream} from './schema/attempt-streams.js';
6
+ import {logChunks} from './schema/chunks.js';
7
+
8
+ export interface AttemptStreamIdentity {
9
+ jobId: string;
10
+ stepId: string;
11
+ attempt: number;
12
+ workspaceId: string;
13
+ projectId: string;
14
+ workflowRunAttemptId: string;
15
+ }
16
+
17
+ export interface AttemptStreamLookup {
18
+ jobId: string;
19
+ stepId: string;
20
+ attempt: number;
21
+ }
22
+
23
+ /**
24
+ * Reads the stream for `(job, step, attempt)` without creating it. Used by the
25
+ * empty-body heartbeat path so a runner cannot mint rows with empty appends.
26
+ */
27
+ export async function getAttemptStream(
28
+ tx: Transaction,
29
+ lookup: AttemptStreamLookup,
30
+ ): Promise<AttemptStream | null> {
31
+ const [row] = await tx
32
+ .select()
33
+ .from(attemptStreams)
34
+ .where(
35
+ and(
36
+ eq(attemptStreams.jobId, lookup.jobId),
37
+ eq(attemptStreams.stepId, lookup.stepId),
38
+ eq(attemptStreams.attempt, lookup.attempt),
39
+ ),
40
+ );
41
+ return row ? toAttemptStream(row) : null;
42
+ }
43
+
44
+ /**
45
+ * Reads the stream for `(step, attempt)` for the session read path, which holds no lease
46
+ * and so has no `jobId`. `stepId` is a globally unique uuidv7, so `(stepId, attempt)`
47
+ * selects at most one row even though stream identity is `(jobId, stepId, attempt)`. The
48
+ * caller authorizes against the row's denormalized `workspaceId`.
49
+ */
50
+ export async function getStreamByStepAttempt(lookup: {
51
+ stepId: string;
52
+ attempt: number;
53
+ }): Promise<AttemptStream | null> {
54
+ const [row] = await db()
55
+ .select()
56
+ .from(attemptStreams)
57
+ .where(
58
+ and(eq(attemptStreams.stepId, lookup.stepId), eq(attemptStreams.attempt, lookup.attempt)),
59
+ );
60
+ return row ? toAttemptStream(row) : null;
61
+ }
62
+
63
+ export interface GetOrCreateAttemptStreamResult {
64
+ stream: AttemptStream;
65
+ created: boolean;
66
+ }
67
+
68
+ /**
69
+ * Loads the stream for `(job, step, attempt)`, creating it on first append.
70
+ * Scoped by `jobId` from the lease, so a lease never reaches another job's stream.
71
+ * The create path reports whether the row was inserted so callers can record
72
+ * first-open side effects only after their enclosing transaction commits.
73
+ */
74
+ export async function getOrCreateAttemptStreamWithStatus(
75
+ tx: Transaction,
76
+ identity: AttemptStreamIdentity,
77
+ ): Promise<GetOrCreateAttemptStreamResult> {
78
+ const [row] = await tx
79
+ .insert(attemptStreams)
80
+ .values(identity)
81
+ .onConflictDoUpdate({
82
+ target: [attemptStreams.jobId, attemptStreams.stepId, attemptStreams.attempt],
83
+ set: {updatedAt: sql`now()`},
84
+ })
85
+ // Postgres sets `xmax` only on the conflict-update path, so this keeps
86
+ // first-open detection in the single upsert round trip.
87
+ .returning({...getTableColumns(attemptStreams), created: sql<boolean>`xmax = 0`});
88
+
89
+ if (!row) throw new Error('attempt stream missing after get-or-create');
90
+ if (
91
+ row.workspaceId !== identity.workspaceId ||
92
+ row.projectId !== identity.projectId ||
93
+ row.workflowRunAttemptId !== identity.workflowRunAttemptId
94
+ ) {
95
+ throw new LeaseStreamMismatchError();
96
+ }
97
+ return {stream: toAttemptStream(row), created: row.created};
98
+ }
99
+
100
+ export async function getOrCreateAttemptStream(
101
+ tx: Transaction,
102
+ identity: AttemptStreamIdentity,
103
+ ): Promise<AttemptStream> {
104
+ const result = await getOrCreateAttemptStreamWithStatus(tx, identity);
105
+ return result.stream;
106
+ }
107
+
108
+ export type CasOutcome = 'extended' | 'retry' | 'gap';
109
+
110
+ export interface CasResult {
111
+ outcome: CasOutcome;
112
+ committedLength: number;
113
+ }
114
+
115
+ /**
116
+ * Offset-CAS extend: advances `committed_length` by `byteLen` iff it currently
117
+ * equals `offset`. On mismatch, re-reads the committed length and classifies the
118
+ * caller's chunk: a retry (fully already applied) only when `offset + byteLen`
119
+ * does not run past the committed length; otherwise a gap. A chunk that straddles
120
+ * the committed point (offset behind it, but extending past it) is a gap too, so
121
+ * the runner rewinds rather than getting a 200 that silently drops its tail.
122
+ */
123
+ export async function casExtendCommittedLength(
124
+ tx: Transaction,
125
+ params: {streamId: string; offset: number; byteLen: number},
126
+ ): Promise<CasResult> {
127
+ const extended = await tx
128
+ .update(attemptStreams)
129
+ .set({
130
+ committedLength: sql`${attemptStreams.committedLength} + ${params.byteLen}`,
131
+ updatedAt: sql`now()`,
132
+ })
133
+ .where(
134
+ and(
135
+ eq(attemptStreams.id, params.streamId),
136
+ eq(attemptStreams.committedLength, params.offset),
137
+ ),
138
+ )
139
+ .returning({committedLength: attemptStreams.committedLength});
140
+
141
+ if (extended[0]) return {outcome: 'extended', committedLength: extended[0].committedLength};
142
+
143
+ const [current] = await tx
144
+ .select({committedLength: attemptStreams.committedLength})
145
+ .from(attemptStreams)
146
+ .where(eq(attemptStreams.id, params.streamId));
147
+ const committedLength = current?.committedLength ?? 0;
148
+
149
+ const fullyApplied = params.offset + params.byteLen <= committedLength;
150
+ return {outcome: fullyApplied ? 'retry' : 'gap', committedLength};
151
+ }
152
+
153
+ export async function setDeclaredTotalBytes(
154
+ tx: Transaction,
155
+ params: {streamId: string; declaredTotalBytes: number},
156
+ ): Promise<void> {
157
+ await tx
158
+ .update(attemptStreams)
159
+ .set({declaredTotalBytes: params.declaredTotalBytes, updatedAt: sql`now()`})
160
+ .where(eq(attemptStreams.id, params.streamId));
161
+ }
162
+
163
+ /**
164
+ * Guarded close: flips `open → closed` and stamps `closed_at` iff the stream is
165
+ * still open, returning the closed row (or null when another path already closed
166
+ * it). The `WHERE state='open'` predicate is both the row lock and the
167
+ * exactly-once gate. `committed_length` is left untouched; it stays equal to the
168
+ * runner spool bytes. `truncated` is set only on the timeout path.
169
+ */
170
+ export async function markStreamClosed(
171
+ tx: Transaction,
172
+ params: {streamId: string; reason: StreamCloseReason; markTruncated: boolean},
173
+ ): Promise<AttemptStream | null> {
174
+ const [row] = await tx
175
+ .update(attemptStreams)
176
+ .set({
177
+ state: 'closed',
178
+ closeReason: params.reason,
179
+ closedAt: sql`now()`,
180
+ updatedAt: sql`now()`,
181
+ ...(params.markTruncated ? {truncated: true} : {}),
182
+ })
183
+ .where(and(eq(attemptStreams.id, params.streamId), eq(attemptStreams.state, 'open')))
184
+ .returning();
185
+
186
+ return row ? toAttemptStream(row) : null;
187
+ }
188
+
189
+ /** Open streams of a job, for the timeout sweep to force-close after the grace period. */
190
+ export async function listOpenStreamsByJob(jobId: string): Promise<AttemptStream[]> {
191
+ const rows = await db()
192
+ .select()
193
+ .from(attemptStreams)
194
+ .where(and(eq(attemptStreams.jobId, jobId), eq(attemptStreams.state, 'open')));
195
+
196
+ return rows.map(toAttemptStream);
197
+ }
198
+
199
+ /** For compaction, which is driven by the closed event that carries the stream id. */
200
+ export async function getAttemptStreamById(streamId: string): Promise<AttemptStream | null> {
201
+ const [row] = await db().select().from(attemptStreams).where(eq(attemptStreams.id, streamId));
202
+ return row ? toAttemptStream(row) : null;
203
+ }
204
+
205
+ export async function getAttemptStreamByIdInTransaction(
206
+ tx: Transaction,
207
+ streamId: string,
208
+ ): Promise<AttemptStream | null> {
209
+ const [row] = await tx.select().from(attemptStreams).where(eq(attemptStreams.id, streamId));
210
+ return row ? toAttemptStream(row) : null;
211
+ }
212
+
213
+ /**
214
+ * Final compaction step: records the object key and deletes the now-cold chunk rows in one
215
+ * transaction. The guard is `state='closed' AND object_key IS NULL`, so the publish is a
216
+ * single winner even when two compaction attempts (e.g. a heartbeat-timed-out run and its
217
+ * retry) race: each uploads to its own key, and only the first to land here writes a key and
218
+ * drops the chunks. A 0-row result means the row was either hard-deleted by retention or
219
+ * already published by another attempt; the caller re-reads to tell those apart and deletes
220
+ * its own now-orphaned upload either way. Returns whether this attempt won the publish.
221
+ */
222
+ export async function setObjectKeyAndDeleteChunks(
223
+ tx: Transaction,
224
+ params: {streamId: string; objectKey: string},
225
+ ): Promise<{updated: boolean}> {
226
+ const updated = await tx
227
+ .update(attemptStreams)
228
+ .set({objectKey: params.objectKey, updatedAt: sql`now()`})
229
+ .where(
230
+ and(
231
+ eq(attemptStreams.id, params.streamId),
232
+ eq(attemptStreams.state, 'closed'),
233
+ isNull(attemptStreams.objectKey),
234
+ ),
235
+ )
236
+ .returning({id: attemptStreams.id});
237
+
238
+ if (updated.length === 0) return {updated: false};
239
+
240
+ await tx.delete(logChunks).where(eq(logChunks.streamId, params.streamId));
241
+ return {updated: true};
242
+ }
243
+
244
+ /**
245
+ * Closed streams that never got an object key and have sat that way past the stale
246
+ * window: the reconcile cron re-drives compaction for these. Hits the partial index
247
+ * `logs_attempt_streams_uncompacted_idx`; ordered by `closed_at` so the oldest backlog
248
+ * drains first, bounded by `limit` per tick.
249
+ */
250
+ export async function listStaleUncompactedStreams(params: {
251
+ olderThanSeconds: number;
252
+ limit: number;
253
+ }): Promise<AttemptStream[]> {
254
+ const rows = await db()
255
+ .select()
256
+ .from(attemptStreams)
257
+ .where(
258
+ and(
259
+ eq(attemptStreams.state, 'closed'),
260
+ isNull(attemptStreams.objectKey),
261
+ lt(attemptStreams.closedAt, sql`now() - make_interval(secs => ${params.olderThanSeconds})`),
262
+ ),
263
+ )
264
+ .orderBy(asc(attemptStreams.closedAt))
265
+ .limit(params.limit);
266
+
267
+ return rows.map(toAttemptStream);
268
+ }
269
+
270
+ /**
271
+ * Lists open streams old enough to force-close. The age check is safe only when
272
+ * `olderThanSeconds` exceeds the lease TTL: leases are minted before a stream can
273
+ * be created, so no valid append can arrive after `created_at + leaseTTL`.
274
+ */
275
+ export async function listStaleOpenStreams(params: {
276
+ olderThanSeconds: number;
277
+ limit: number;
278
+ }): Promise<AttemptStream[]> {
279
+ const rows = await db()
280
+ .select()
281
+ .from(attemptStreams)
282
+ .where(
283
+ and(
284
+ eq(attemptStreams.state, 'open'),
285
+ lt(
286
+ attemptStreams.createdAt,
287
+ sql`now() - make_interval(secs => ${params.olderThanSeconds})`,
288
+ ),
289
+ ),
290
+ )
291
+ .orderBy(asc(attemptStreams.createdAt))
292
+ .limit(params.limit);
293
+
294
+ return rows.map(toAttemptStream);
295
+ }
296
+
297
+ export async function getOpenStreamCount(): Promise<bigint> {
298
+ const [row] = await db()
299
+ .select({value: sql<bigint>`count(*)`.mapWith(BigInt)})
300
+ .from(attemptStreams)
301
+ .where(eq(attemptStreams.state, 'open'));
302
+
303
+ return row?.value ?? 0n;
304
+ }
305
+
306
+ /**
307
+ * Lists expired closed streams for retention. `excludeIds` keeps failed or raced rows from
308
+ * starving younger rows in the same run, and avoids cursoring on `closed_at`, whose microsecond
309
+ * precision would be lost through JS `Date`.
310
+ *
311
+ * No `object_key` filter: retention deletes compacted and never-compacted streams alike.
312
+ */
313
+ export async function listExpiredClosedStreams(params: {
314
+ retentionDays: number;
315
+ limit: number;
316
+ excludeIds?: string[] | undefined;
317
+ }): Promise<AttemptStream[]> {
318
+ const rows = await db()
319
+ .select()
320
+ .from(attemptStreams)
321
+ .where(
322
+ and(
323
+ eq(attemptStreams.state, 'closed'),
324
+ lt(attemptStreams.closedAt, sql`now() - make_interval(days => ${params.retentionDays})`),
325
+ params.excludeIds && params.excludeIds.length > 0
326
+ ? notInArray(attemptStreams.id, params.excludeIds)
327
+ : undefined,
328
+ ),
329
+ )
330
+ .orderBy(asc(attemptStreams.closedAt), asc(attemptStreams.id))
331
+ .limit(params.limit)
332
+ .for('update', {skipLocked: true});
333
+
334
+ return rows.map(toAttemptStream);
335
+ }
336
+
337
+ /**
338
+ * Hard-deletes one expired stream row, guarded on the observed `object_key` so a racing
339
+ * compaction publish leaves the row for the next sweep.
340
+ */
341
+ export async function deleteExpiredStream(
342
+ tx: Transaction,
343
+ params: {streamId: string; observedObjectKey: string | null},
344
+ ): Promise<{deleted: boolean; jobId: string | null}> {
345
+ const [row] = await tx
346
+ .delete(attemptStreams)
347
+ .where(
348
+ and(
349
+ eq(attemptStreams.id, params.streamId),
350
+ params.observedObjectKey === null
351
+ ? isNull(attemptStreams.objectKey)
352
+ : eq(attemptStreams.objectKey, params.observedObjectKey),
353
+ ),
354
+ )
355
+ .returning({jobId: attemptStreams.jobId});
356
+
357
+ return {deleted: Boolean(row), jobId: row?.jobId ?? null};
358
+ }
359
+
360
+ export async function accountingHasNoStreams(tx: Transaction, jobId: string): Promise<boolean> {
361
+ const [row] = await tx
362
+ .select({id: attemptStreams.id})
363
+ .from(attemptStreams)
364
+ .where(eq(attemptStreams.jobId, jobId))
365
+ .limit(1);
366
+
367
+ return !row;
368
+ }
package/src/index.ts ADDED
@@ -0,0 +1,77 @@
1
+ import {dirname, resolve} from 'node:path';
2
+ import {fileURLToPath} from 'node:url';
3
+ import {LOG_STREAM_CLOSED, type LogsEventMap, logsEventSchemas} from '@shipfox/api-logs-dto';
4
+ import {
5
+ WORKFLOWS_JOB_TERMINATED,
6
+ WORKFLOWS_STEP_ATTEMPT_TERMINATED,
7
+ type WorkflowsEventMapDto,
8
+ } from '@shipfox/api-workflows-dto';
9
+ import {type ShipfoxModule, subscriberFactory} from '@shipfox/node-module';
10
+ import {db} from '#db/db.js';
11
+ import {migrationsPath} from '#db/index.js';
12
+ import {logsOutbox} from '#db/schema/outbox.js';
13
+ import {registerLogsServiceMetrics} from '#metrics/service.js';
14
+ import {logsRoutes} from '#presentation/routes/index.js';
15
+ import {onJobTerminated} from '#presentation/subscribers/on-job-terminated.js';
16
+ import {onLogStreamClosed} from '#presentation/subscribers/on-log-stream-closed.js';
17
+ import {onStepAttemptTerminated} from '#presentation/subscribers/on-step-attempt-terminated.js';
18
+ import {createLogsActivities} from '#temporal/activities/index.js';
19
+ import {LOGS_COMPACTION_TASK_QUEUE, LOGS_LIFECYCLE_TASK_QUEUE} from '#temporal/constants.js';
20
+
21
+ export {checkBucketReachable} from '#api/object-storage.js';
22
+
23
+ const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
24
+ const workflowsPath = resolve(packageRoot, 'dist/temporal/workflows/index.js');
25
+
26
+ const subscriber = subscriberFactory<WorkflowsEventMapDto & LogsEventMap>();
27
+
28
+ export const logsModule: ShipfoxModule = {
29
+ name: 'logs',
30
+ database: {db, migrationsPath},
31
+ routes: logsRoutes,
32
+ metrics: registerLogsServiceMetrics,
33
+ // `logs.stream.closed` is written by the close paths: the job-terminated subscriber
34
+ // force-closes streams the runner never ended, and the closed event drives compaction.
35
+ publishers: [{name: 'logs', table: logsOutbox, db, eventSchemas: logsEventSchemas}],
36
+ subscribers: [
37
+ subscriber(WORKFLOWS_STEP_ATTEMPT_TERMINATED, onStepAttemptTerminated),
38
+ subscriber(WORKFLOWS_JOB_TERMINATED, onJobTerminated),
39
+ subscriber(LOG_STREAM_CLOSED, onLogStreamClosed),
40
+ ],
41
+ workers: [
42
+ {
43
+ taskQueue: LOGS_LIFECYCLE_TASK_QUEUE,
44
+ workflowsPath,
45
+ activities: createLogsActivities,
46
+ // Temporal cron schedules are fixed after creation, so making cadence configurable would
47
+ // look adjustable while keeping the old schedule. Tune LOG_RETENTION_DAYS for the horizon.
48
+ workflows: [
49
+ {
50
+ name: 'retentionSweepCron',
51
+ id: 'logs-retention-sweep',
52
+ cronSchedule: '0 * * * *',
53
+ },
54
+ // Offset from retention's top-of-hour sweep; stale-stream age is governed by
55
+ // LOG_STREAM_REAP_AFTER_SECONDS.
56
+ {
57
+ name: 'reapStaleOpenStreamsCron',
58
+ id: 'logs-reap-stale-open-streams',
59
+ cronSchedule: '5,15,25,35,45,55 * * * *',
60
+ },
61
+ ],
62
+ },
63
+ // Compaction runs on its own queue so long uploads cannot starve the lifecycle sweep.
64
+ {
65
+ taskQueue: LOGS_COMPACTION_TASK_QUEUE,
66
+ workflowsPath,
67
+ activities: createLogsActivities,
68
+ workflows: [
69
+ {
70
+ name: 'compactionReconcileCron',
71
+ id: 'logs-compaction-reconcile',
72
+ cronSchedule: '*/10 * * * *',
73
+ },
74
+ ],
75
+ },
76
+ ],
77
+ };
@@ -0,0 +1 @@
1
+ export {registerLogsServiceMetrics} from './service.js';
@@ -0,0 +1,33 @@
1
+ import type {LogRecord} from '@shipfox/api-logs-dto';
2
+ import {instanceMetrics} from '@shipfox/node-opentelemetry';
3
+
4
+ const meter = instanceMetrics.getMeter('logs');
5
+
6
+ export type LogRecordMetricKind = LogRecord['type'];
7
+
8
+ export const recordAppendedCount = meter.createCounter<{kind: LogRecordMetricKind}>(
9
+ 'logs_record_appended',
10
+ {description: 'Log records appended to durable stream storage by record type'},
11
+ );
12
+
13
+ export const streamOpenedCount = meter.createCounter<Record<string, never>>('logs_stream_opened', {
14
+ description: 'Log streams opened by first append',
15
+ });
16
+
17
+ export const streamClosedCount = meter.createCounter<{reason: 'declared' | 'timeout'}>(
18
+ 'logs_stream_closed',
19
+ {description: 'Log streams closed by reason'},
20
+ );
21
+
22
+ export type CompactionMetricOutcome =
23
+ | 'already-compacted'
24
+ | 'compacted'
25
+ | 'failed'
26
+ | 'gone'
27
+ | 'retention-raced'
28
+ | 'superseded';
29
+
30
+ export const compactionCount = meter.createCounter<{outcome: CompactionMetricOutcome}>(
31
+ 'logs_compaction',
32
+ {description: 'Log stream compaction attempts by outcome'},
33
+ );
@@ -0,0 +1,23 @@
1
+ import {getServiceMetricsProvider} from '@shipfox/node-opentelemetry';
2
+ import {getOpenStreamCount} from '#db/streams.js';
3
+
4
+ export function registerLogsServiceMetrics(): void {
5
+ const meter = getServiceMetricsProvider().getMeter('logs');
6
+
7
+ const openStreams = meter.createObservableGauge('logs_open_streams', {
8
+ description: 'Log streams currently open for appends',
9
+ });
10
+
11
+ meter.addBatchObservableCallback(
12
+ async (observer) => {
13
+ observer.observe(openStreams, toSafeGaugeNumber(await getOpenStreamCount()));
14
+ },
15
+ [openStreams],
16
+ );
17
+ }
18
+
19
+ function toSafeGaugeNumber(value: bigint): number {
20
+ // OpenTelemetry gauges accept numbers; clamp unrepresentable DB counts rather than round them.
21
+ if (value <= BigInt(Number.MAX_SAFE_INTEGER)) return Number(value);
22
+ return Number.MAX_SAFE_INTEGER;
23
+ }
@@ -0,0 +1,24 @@
1
+ import type {ReadLogsResponseDto} from '@shipfox/api-logs-dto';
2
+ import type {LogReadResult} from '#core/read-logs.js';
3
+
4
+ export function toReadLogsDto(result: LogReadResult): ReadLogsResponseDto {
5
+ if (result.mode === 'presigned') {
6
+ return {
7
+ mode: 'presigned',
8
+ url: result.url,
9
+ state: result.state,
10
+ expires_at: result.expiresAt.toISOString(),
11
+ total_bytes: result.totalBytes,
12
+ truncated: result.truncated,
13
+ };
14
+ }
15
+
16
+ return {
17
+ mode: 'inline',
18
+ ndjson: result.ndjson,
19
+ next_cursor: result.nextCursor,
20
+ has_more: result.hasMore,
21
+ state: result.state,
22
+ truncated: result.truncated,
23
+ };
24
+ }