@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,143 @@
1
+ import {Buffer} from 'node:buffer';
2
+ import {and, asc, eq, gt, lte, sql} from 'drizzle-orm';
3
+ import {db, type Transaction} from './db.js';
4
+ import {type ChunkOrigin, logChunks} from './schema/chunks.js';
5
+
6
+ export interface InsertChunkParams {
7
+ streamId: string;
8
+ streamOffset: number;
9
+ byteLen: number;
10
+ data: Buffer;
11
+ origin: ChunkOrigin;
12
+ }
13
+
14
+ export async function insertChunk(tx: Transaction, params: InsertChunkParams): Promise<void> {
15
+ await tx.insert(logChunks).values({
16
+ streamId: params.streamId,
17
+ streamOffset: params.streamOffset,
18
+ byteLen: params.byteLen,
19
+ data: params.data,
20
+ origin: params.origin,
21
+ });
22
+ }
23
+
24
+ export interface ChunkPageRow {
25
+ seq: number;
26
+ data: Buffer;
27
+ }
28
+
29
+ /**
30
+ * One keyset page of a stream's chunk bytes in `seq` order. Compaction walks pages
31
+ * with `afterSeq = lastSeqOfPreviousPage`, so memory stays flat regardless of stream
32
+ * size, and reads run outside any transaction (no tx is pinned for the long upload).
33
+ * Safe against late writers because a closed stream never accepts another chunk.
34
+ */
35
+ export async function readChunksKeyset(params: {
36
+ streamId: string;
37
+ afterSeq: number;
38
+ limit: number;
39
+ }): Promise<ChunkPageRow[]> {
40
+ const rows = await db()
41
+ .select({seq: logChunks.seq, data: logChunks.data})
42
+ .from(logChunks)
43
+ .where(and(eq(logChunks.streamId, params.streamId), gt(logChunks.seq, params.afterSeq)))
44
+ .orderBy(asc(logChunks.seq))
45
+ .limit(params.limit);
46
+ return rows;
47
+ }
48
+
49
+ export interface ChunkPage {
50
+ /** Concatenated chunk bytes for the page, in `seq` order: ready-to-serve NDJSON. */
51
+ data: Buffer;
52
+ /** The last `seq` included; the next read passes this as `afterSeq`. */
53
+ nextSeq: number;
54
+ /** Whether at least one more chunk exists past `nextSeq`. */
55
+ hasMore: boolean;
56
+ }
57
+
58
+ // Caps the per-read metadata scan so a stream with pathologically many tiny chunks
59
+ // can't make one read walk the whole stream; the client re-polls (hasMore) past it.
60
+ const CHUNK_PAGE_SCAN_CAP = 4096;
61
+
62
+ /**
63
+ * A byte-bounded page of a stream's chunk bytes in `seq` order, for the inline read path.
64
+ * Walking by `seq` (not the runner byte offset) is what makes server-injected control tombstones
65
+ * interleave with normalized runner records exactly as compaction concatenates them, so the inline
66
+ * NDJSON is byte-identical to the compacted object.
67
+ *
68
+ * Returns at most ~`maxBytes`, but always at least one whole chunk so the cursor advances
69
+ * even when a single chunk exceeds `maxBytes`. The cheap `(seq, byte_len)` scan picks the
70
+ * prefix first, so the heavy `data` column is only materialized for the chunks actually
71
+ * returned.
72
+ */
73
+ export async function readChunkPageBySeq(params: {
74
+ streamId: string;
75
+ afterSeq: number;
76
+ maxBytes: number;
77
+ }): Promise<ChunkPage> {
78
+ const meta = await db()
79
+ .select({seq: logChunks.seq, byteLen: logChunks.byteLen})
80
+ .from(logChunks)
81
+ .where(and(eq(logChunks.streamId, params.streamId), gt(logChunks.seq, params.afterSeq)))
82
+ .orderBy(asc(logChunks.seq))
83
+ .limit(CHUNK_PAGE_SCAN_CAP + 1);
84
+
85
+ if (meta.length === 0) {
86
+ return {data: Buffer.alloc(0), nextSeq: params.afterSeq, hasMore: false};
87
+ }
88
+
89
+ let accumulatedBytes = 0;
90
+ let includedCount = 0;
91
+ let nextSeq = params.afterSeq;
92
+ for (const row of meta) {
93
+ if (includedCount > 0 && accumulatedBytes + row.byteLen > params.maxBytes) break;
94
+ accumulatedBytes += row.byteLen;
95
+ nextSeq = row.seq;
96
+ includedCount += 1;
97
+ if (includedCount >= CHUNK_PAGE_SCAN_CAP) break;
98
+ }
99
+
100
+ const hasMore = includedCount < meta.length;
101
+
102
+ const rows = await db()
103
+ .select({data: logChunks.data})
104
+ .from(logChunks)
105
+ .where(
106
+ and(
107
+ eq(logChunks.streamId, params.streamId),
108
+ gt(logChunks.seq, params.afterSeq),
109
+ lte(logChunks.seq, nextSeq),
110
+ ),
111
+ )
112
+ .orderBy(asc(logChunks.seq));
113
+
114
+ return {data: Buffer.concat(rows.map((row) => row.data)), nextSeq, hasMore};
115
+ }
116
+
117
+ export interface ChunkStats {
118
+ count: number;
119
+ maxSeq: number;
120
+ uncompressedBytes: number;
121
+ }
122
+
123
+ /**
124
+ * Aggregate of a stream's chunks: row count, highest `seq`, and total stored bytes.
125
+ * Compaction reads this once before uploading, both to stamp object metadata and as the
126
+ * baseline its streamed totals are checked against before the chunk rows are deleted.
127
+ */
128
+ export async function chunkStats(streamId: string): Promise<ChunkStats> {
129
+ const [row] = await db()
130
+ .select({
131
+ count: sql<number>`count(*)::int`,
132
+ maxSeq: sql<number>`coalesce(max(${logChunks.seq}), 0)::int`,
133
+ uncompressedBytes: sql<string>`coalesce(sum(${logChunks.byteLen}), 0)::text`,
134
+ })
135
+ .from(logChunks)
136
+ .where(eq(logChunks.streamId, streamId));
137
+
138
+ return {
139
+ count: row?.count ?? 0,
140
+ maxSeq: row?.maxSeq ?? 0,
141
+ uncompressedBytes: Number(row?.uncompressedBytes ?? '0'),
142
+ };
143
+ }
package/src/db/db.ts ADDED
@@ -0,0 +1,22 @@
1
+ import {drizzle, type NodePgDatabase} from '@shipfox/node-drizzle';
2
+ import {pgClient} from '@shipfox/node-postgres';
3
+ import {attemptStreams} from './schema/attempt-streams.js';
4
+ import {logChunks} from './schema/chunks.js';
5
+ import {jobAccounting} from './schema/job-accounting.js';
6
+ import {logsOutbox} from './schema/outbox.js';
7
+
8
+ export const schema = {jobAccounting, attemptStreams, logChunks, logsOutbox};
9
+
10
+ export type Database = NodePgDatabase<typeof schema>;
11
+ export type Transaction = Parameters<Parameters<Database['transaction']>[0]>[0];
12
+
13
+ let _db: Database | undefined;
14
+
15
+ export function db() {
16
+ if (!_db) _db = drizzle(pgClient(), {schema});
17
+ return _db;
18
+ }
19
+
20
+ export function closeDb(): void {
21
+ _db = undefined;
22
+ }
@@ -0,0 +1,4 @@
1
+ import {dirname, resolve} from 'node:path';
2
+ import {fileURLToPath} from 'node:url';
3
+
4
+ export const migrationsPath = resolve(dirname(fileURLToPath(import.meta.url)), '../../drizzle');
@@ -0,0 +1,106 @@
1
+ import {uuidv7PrimaryKey} from '@shipfox/node-drizzle';
2
+ import {sql} from 'drizzle-orm';
3
+ import {
4
+ bigint,
5
+ boolean,
6
+ index,
7
+ integer,
8
+ text,
9
+ timestamp,
10
+ uniqueIndex,
11
+ uuid,
12
+ } from 'drizzle-orm/pg-core';
13
+ import type {AttemptStream} from '#core/entities/attempt-stream.js';
14
+ import {pgTable} from './common.js';
15
+
16
+ /**
17
+ * One stream per (job, step, attempt). Identity is scoped by `job_id` (from the
18
+ * lease), so a lease can only ever reach its own job's streams — cross-job log
19
+ * injection is structurally impossible. `workspace_id`, `project_id`, and `workflow_run_attempt_id`
20
+ * are stamped from the lease at create time; they are functionally determined by
21
+ * `job_id` via workflows FKs, so they are denormalized here for self-contained
22
+ * authorization (per-project read filtering, audit) without joining back to
23
+ * workflows. `committed_length` is the offset-CAS axis (raw NDJSON spool bytes the
24
+ * server has durably accepted from the runner).
25
+ *
26
+ * `truncated` is an out-of-band terminal flag set when the timeout sweep
27
+ * force-closes a stream the runner never ended; the `AttemptStream` entity is the
28
+ * canonical reference for what it means.
29
+ *
30
+ * Per-row `committed_length` and `declared_total_bytes` are bounded by the
31
+ * per-job budget, so `mode: 'number'` is safe on the hot path. Any cross-row
32
+ * aggregate (workspace or system-wide totals) MUST read as bigint at the
33
+ * query site — the global sum is unbounded and would silently lose precision
34
+ * past 2^53 as a JS number.
35
+ */
36
+ export const attemptStreams = pgTable(
37
+ 'attempt_streams',
38
+ {
39
+ id: uuidv7PrimaryKey(),
40
+ jobId: uuid('job_id').notNull(),
41
+ stepId: uuid('step_id').notNull(),
42
+ attempt: integer('attempt').notNull(),
43
+ workspaceId: uuid('workspace_id').notNull(),
44
+ projectId: uuid('project_id').notNull(),
45
+ workflowRunAttemptId: uuid('workflow_run_attempt_id').notNull(),
46
+ committedLength: bigint('committed_length', {mode: 'number'}).notNull().default(0),
47
+ state: text('state', {enum: ['open', 'closed']})
48
+ .notNull()
49
+ .default('open'),
50
+ closeReason: text('close_reason', {enum: ['declared', 'timeout']}),
51
+ declaredTotalBytes: bigint('declared_total_bytes', {mode: 'number'}),
52
+ truncated: boolean('truncated').notNull().default(false),
53
+ objectKey: text('object_key'),
54
+ createdAt: timestamp('created_at', {withTimezone: true}).notNull().defaultNow(),
55
+ updatedAt: timestamp('updated_at', {withTimezone: true}).notNull().defaultNow(),
56
+ closedAt: timestamp('closed_at', {withTimezone: true}),
57
+ },
58
+ (table) => [
59
+ uniqueIndex('logs_attempt_streams_identity_unique').on(
60
+ table.jobId,
61
+ table.stepId,
62
+ table.attempt,
63
+ ),
64
+ // The session read path looks up a stream by (step, attempt) without the job id, so the
65
+ // identity unique (which leads with job_id) can't serve it. This index keeps that lookup
66
+ // off a sequential scan as the table grows.
67
+ index('logs_attempt_streams_step_attempt_idx').on(table.stepId, table.attempt),
68
+ // Timeout-close sweeps an open job's streams by job_id; the partial index keeps
69
+ // it off the closed tail.
70
+ index('logs_attempt_streams_open_by_job_idx').on(table.jobId).where(sql`"state" = 'open'`),
71
+ // Open-age reaping needs an index that stays off the closed tail; created_at is
72
+ // immutable, so appends do not churn it.
73
+ index('logs_attempt_streams_open_age_idx').on(table.createdAt).where(sql`"state" = 'open'`),
74
+ // Retention scans closed streams by close age; partial so it never carries the
75
+ // open (in-flight) set.
76
+ index('logs_attempt_streams_retention_idx').on(table.closedAt).where(sql`"state" = 'closed'`),
77
+ // Reconcile re-drives closed streams that never got an object key.
78
+ index('logs_attempt_streams_uncompacted_idx')
79
+ .on(table.closedAt)
80
+ .where(sql`"state" = 'closed' and "object_key" is null`),
81
+ ],
82
+ );
83
+
84
+ export type AttemptStreamDb = typeof attemptStreams.$inferSelect;
85
+ export type AttemptStreamInsertDb = typeof attemptStreams.$inferInsert;
86
+
87
+ export function toAttemptStream(row: AttemptStreamDb): AttemptStream {
88
+ return {
89
+ id: row.id,
90
+ jobId: row.jobId,
91
+ stepId: row.stepId,
92
+ attempt: row.attempt,
93
+ workspaceId: row.workspaceId,
94
+ projectId: row.projectId,
95
+ workflowRunAttemptId: row.workflowRunAttemptId,
96
+ committedLength: row.committedLength,
97
+ state: row.state,
98
+ closeReason: row.closeReason,
99
+ declaredTotalBytes: row.declaredTotalBytes,
100
+ truncated: row.truncated,
101
+ objectKey: row.objectKey,
102
+ createdAt: row.createdAt,
103
+ updatedAt: row.updatedAt,
104
+ closedAt: row.closedAt,
105
+ };
106
+ }
@@ -0,0 +1,43 @@
1
+ import {uuidv7PrimaryKey} from '@shipfox/node-drizzle';
2
+ import {bigint, bigserial, index, integer, text, timestamp, uuid} from 'drizzle-orm/pg-core';
3
+ import {attemptStreams} from './attempt-streams.js';
4
+ import {bytea, pgTable} from './common.js';
5
+
6
+ /**
7
+ * Hot, append-only log bytes for open streams, pending compaction.
8
+ *
9
+ * Two byte axes meet here and MUST stay distinct:
10
+ *
11
+ * Runner spool (CAS axis) Stored chunk stream (read axis, by seq)
12
+ * offset 0 ┌────────────┐ ───► seq 1 │ normalized chunk A │ origin=runner
13
+ * 100 ├─ chunk A ──┤ ───► seq 2 │ normalized chunk B │ origin=runner
14
+ * 250 ├─ chunk B ──┤ ───► seq 3 │ {capped} │ origin=control ← server-injected;
15
+ * └────────────┘ does NOT advance committed_length
16
+ *
17
+ * `origin` is `runner` for bytes accepted from a runner append after ingest normalization, and
18
+ * `control` for a server-injected tombstone. `stream_offset` is the runner-axis position of a
19
+ * runner chunk; for a server `control` chunk it is informational. `seq` (insertion order) is the
20
+ * read axis the reader walks, so server records interleave correctly with normalized runner
21
+ * records.
22
+ */
23
+ export const logChunks = pgTable(
24
+ 'chunks',
25
+ {
26
+ id: uuidv7PrimaryKey(),
27
+ streamId: uuid('stream_id')
28
+ .notNull()
29
+ .references(() => attemptStreams.id, {onDelete: 'cascade'}),
30
+ seq: bigserial('seq', {mode: 'number'}).notNull(),
31
+ streamOffset: bigint('stream_offset', {mode: 'number'}).notNull(),
32
+ byteLen: integer('byte_len').notNull(),
33
+ data: bytea('data').notNull(),
34
+ origin: text('origin', {enum: ['runner', 'control']}).notNull(),
35
+ createdAt: timestamp('created_at', {withTimezone: true}).notNull().defaultNow(),
36
+ },
37
+ (table) => [index('logs_chunks_stream_seq_idx').on(table.streamId, table.seq)],
38
+ );
39
+
40
+ export type LogChunkDb = typeof logChunks.$inferSelect;
41
+ export type LogChunkInsertDb = typeof logChunks.$inferInsert;
42
+
43
+ export type ChunkOrigin = 'runner' | 'control';
@@ -0,0 +1,12 @@
1
+ import type {Buffer} from 'node:buffer';
2
+ import {customType, pgTableCreator} from 'drizzle-orm/pg-core';
3
+
4
+ export const pgTable = pgTableCreator((name) => `logs_${name}`);
5
+
6
+ // drizzle-orm 0.45 has no native bytea column; the node-postgres driver returns
7
+ // and accepts Buffer for bytea, so a customType is the supported path.
8
+ export const bytea = customType<{data: Buffer; driverData: Buffer}>({
9
+ dataType() {
10
+ return 'bytea';
11
+ },
12
+ });
@@ -0,0 +1,38 @@
1
+ import {bigint, timestamp, uuid} from 'drizzle-orm/pg-core';
2
+ import type {JobAccounting} from '#core/entities/job-accounting.js';
3
+ import {pgTable} from './common.js';
4
+
5
+ /**
6
+ * Accrual-budget state per job run, keyed by the job id carried in the lease.
7
+ * `stored_bytes_used` counts the normalized NDJSON bytes the server has stored for the job
8
+ * (envelope and control records included), so the budget bounds exactly what lands in Postgres.
9
+ * `started_at` is the budget clock origin (first append).
10
+ * `capped_at`, once set, makes every later append a no-op drop.
11
+ *
12
+ * Per-row `stored_bytes_used` is bounded by the per-job budget, so `mode:
13
+ * 'number'` is safe on the hot path. Any cross-row aggregate (workspace or
14
+ * system-wide totals) MUST read as bigint at the query site — the global sum
15
+ * is unbounded and would silently lose precision past 2^53 as a JS number.
16
+ */
17
+ export const jobAccounting = pgTable('job_accounting', {
18
+ jobId: uuid('job_id').primaryKey(),
19
+ workspaceId: uuid('workspace_id').notNull(),
20
+ storedBytesUsed: bigint('stored_bytes_used', {mode: 'number'}).notNull().default(0),
21
+ startedAt: timestamp('started_at', {withTimezone: true}).notNull().defaultNow(),
22
+ cappedAt: timestamp('capped_at', {withTimezone: true}),
23
+ createdAt: timestamp('created_at', {withTimezone: true}).notNull().defaultNow(),
24
+ updatedAt: timestamp('updated_at', {withTimezone: true}).notNull().defaultNow(),
25
+ });
26
+
27
+ export type JobAccountingDb = typeof jobAccounting.$inferSelect;
28
+ export type JobAccountingInsertDb = typeof jobAccounting.$inferInsert;
29
+
30
+ export function toJobAccounting(row: JobAccountingDb): JobAccounting {
31
+ return {
32
+ jobId: row.jobId,
33
+ workspaceId: row.workspaceId,
34
+ storedBytesUsed: row.storedBytesUsed,
35
+ startedAt: row.startedAt,
36
+ cappedAt: row.cappedAt,
37
+ };
38
+ }
@@ -0,0 +1,4 @@
1
+ import {createOutboxTable} from '@shipfox/node-outbox';
2
+ import {pgTable} from './common.js';
3
+
4
+ export const logsOutbox = createOutboxTable(pgTable);
@@ -0,0 +1,47 @@
1
+ import {appendLogs} from '#core/append-logs.js';
2
+ import {endLine, ndjsonBody, outputLine} from '#test/fixtures/ndjson.js';
3
+ import {getOpenStreamCount} from './streams.js';
4
+
5
+ interface Ctx {
6
+ jobId: string;
7
+ stepId: string;
8
+ workspaceId: string;
9
+ projectId: string;
10
+ workflowRunAttemptId: string;
11
+ }
12
+
13
+ function newCtx(): Ctx {
14
+ return {
15
+ jobId: crypto.randomUUID(),
16
+ stepId: crypto.randomUUID(),
17
+ workspaceId: crypto.randomUUID(),
18
+ projectId: crypto.randomUUID(),
19
+ workflowRunAttemptId: crypto.randomUUID(),
20
+ };
21
+ }
22
+
23
+ describe('getOpenStreamCount', () => {
24
+ it('reports the current open stream count', async () => {
25
+ const before = await getOpenStreamCount();
26
+
27
+ const after = await getOpenStreamCount();
28
+ expect(after - before).toBe(0n);
29
+ });
30
+
31
+ it('counts newly opened streams without counting streams closed in the same test', async () => {
32
+ const before = await getOpenStreamCount();
33
+ const open = newCtx();
34
+ const closed = newCtx();
35
+ await appendLogs({...open, attempt: 1, offset: 0, body: ndjsonBody(outputLine('open\n'))});
36
+ await appendLogs({
37
+ ...closed,
38
+ attempt: 1,
39
+ offset: 0,
40
+ body: ndjsonBody(outputLine('closed\n'), endLine(7)),
41
+ });
42
+
43
+ const after = await getOpenStreamCount();
44
+
45
+ expect(after - before).toBe(1n);
46
+ });
47
+ });