@durable-streams/server 0.3.1 → 0.3.3

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.
package/src/file-store.ts CHANGED
@@ -8,10 +8,10 @@ import * as path from "node:path"
8
8
  import { randomBytes } from "node:crypto"
9
9
  import { open as openLMDB } from "lmdb"
10
10
  import { SieveCache } from "@neophi/sieve-cache"
11
- import { StreamFileManager } from "./file-manager"
11
+ import { serverLog } from "./log"
12
12
  import { encodeStreamPath } from "./path-encoding"
13
13
  import {
14
- formatJsonResponse,
14
+ formatJsonMessages,
15
15
  normalizeContentType,
16
16
  processJsonAppend,
17
17
  } from "./store"
@@ -54,7 +54,7 @@ interface StreamMetadata {
54
54
  segmentCount: number
55
55
  totalBytes: number
56
56
  /**
57
- * Unique directory name for this stream instance.
57
+ * Unique file stem for this stream instance.
58
58
  * Format: {encoded_path}~{timestamp}~{random_hex}
59
59
  * This allows safe async deletion and immediate reuse of stream paths.
60
60
  */
@@ -104,6 +104,15 @@ interface StreamMetadata {
104
104
  */
105
105
  interface PooledHandle {
106
106
  stream: fs.WriteStream
107
+ /**
108
+ * Coalesced fsync leader. When set and `scheduled` is true, new fsync callers
109
+ * piggyback on this batch; when `scheduled` is false the batch has already
110
+ * entered its syscall and new callers must start a new batch.
111
+ */
112
+ syncLeader: {
113
+ promise: Promise<void>
114
+ scheduled: boolean
115
+ } | null
107
116
  }
108
117
 
109
118
  class FileHandlePool {
@@ -114,7 +123,7 @@ class FileHandlePool {
114
123
  evictHook: (_key: string, handle: PooledHandle) => {
115
124
  // Close the handle when evicted (sync version - fire and forget)
116
125
  this.closeHandle(handle).catch((err: Error) => {
117
- console.error(`[FileHandlePool] Error closing evicted handle:`, err)
126
+ serverLog.error(`[FileHandlePool] Error closing evicted handle:`, err)
118
127
  })
119
128
  },
120
129
  })
@@ -125,49 +134,80 @@ class FileHandlePool {
125
134
 
126
135
  if (!handle) {
127
136
  const stream = fs.createWriteStream(filePath, { flags: `a` })
128
- handle = { stream }
137
+ handle = { stream, syncLeader: null }
129
138
  this.cache.set(filePath, handle)
130
139
  }
131
140
 
132
141
  return handle.stream
133
142
  }
134
143
 
144
+ /**
145
+ * Open a write stream eagerly so the first write does not pay the lazy
146
+ * `open()` stall. Resolves once the underlying fd is ready.
147
+ */
148
+ async openWriteStream(filePath: string): Promise<fs.WriteStream> {
149
+ const stream = this.getWriteStream(filePath)
150
+ const fd = (stream as unknown as { fd: number | null }).fd
151
+ if (typeof fd === `number`) return stream
152
+ await new Promise<void>((resolve, reject) => {
153
+ stream.once(`open`, () => resolve())
154
+ stream.once(`error`, (err) => reject(err))
155
+ })
156
+ return stream
157
+ }
158
+
135
159
  /**
136
160
  * Flush a specific file to disk immediately.
137
- * This is called after each append to ensure durability.
161
+ * Concurrent callers on the same fd share one in-flight fdatasync: the
162
+ * first caller issues the syscall, later arrivals during that window wait
163
+ * for it to finish and then issue a fresh syscall (because their writes
164
+ * may have landed after the in-flight syscall started). This preserves
165
+ * durability without adding scheduling latency.
138
166
  */
139
- async fsyncFile(filePath: string): Promise<void> {
167
+ fsyncFile(filePath: string): Promise<void> {
140
168
  const handle = this.cache.get(filePath)
141
- if (!handle) return
142
-
143
- return new Promise<void>((resolve, reject) => {
144
- // Use fdatasync (faster than fsync, skips metadata)
145
- // Cast to any to access fd property (exists at runtime but not in types)
146
- const fd = (handle.stream as any).fd
147
-
148
- // If fd is null, stream hasn't been opened yet - wait for open event
149
- if (typeof fd !== `number`) {
150
- const onOpen = (openedFd: number): void => {
151
- handle.stream.off(`error`, onError)
152
- fs.fdatasync(openedFd, (err) => {
153
- if (err) reject(err)
154
- else resolve()
155
- })
156
- }
157
- const onError = (err: Error): void => {
158
- handle.stream.off(`open`, onOpen)
159
- reject(err)
160
- }
161
- handle.stream.once(`open`, onOpen)
162
- handle.stream.once(`error`, onError)
163
- return
164
- }
169
+ if (!handle) {
170
+ return Promise.reject(
171
+ new Error(
172
+ `[FileHandlePool] Cannot fsync: handle not found for ${filePath}`
173
+ )
174
+ )
175
+ }
176
+
177
+ const existing = handle.syncLeader
178
+ if (existing && existing.scheduled) {
179
+ return existing.promise
180
+ }
165
181
 
182
+ let resolveFn!: () => void
183
+ let rejectFn!: (err: Error) => void
184
+ const promise = new Promise<void>((res, rej) => {
185
+ resolveFn = res
186
+ rejectFn = rej
187
+ })
188
+ const leader = { promise, scheduled: true }
189
+ handle.syncLeader = leader
190
+
191
+ const runSyscall = (fd: number): void => {
192
+ leader.scheduled = false
166
193
  fs.fdatasync(fd, (err) => {
167
- if (err) reject(err)
168
- else resolve()
194
+ if (handle.syncLeader === leader) handle.syncLeader = null
195
+ if (err) rejectFn(err)
196
+ else resolveFn()
169
197
  })
170
- })
198
+ }
199
+
200
+ const fd = (handle.stream as unknown as { fd: number | null }).fd
201
+ if (typeof fd === `number`) {
202
+ runSyscall(fd)
203
+ } else {
204
+ handle.stream.once(`open`, (openedFd: number) => runSyscall(openedFd))
205
+ handle.stream.once(`error`, (err: Error) => {
206
+ if (handle.syncLeader === leader) handle.syncLeader = null
207
+ rejectFn(err)
208
+ })
209
+ }
210
+ return promise
171
211
  }
172
212
 
173
213
  async closeAll(): Promise<void> {
@@ -212,18 +252,21 @@ export interface FileBackedStreamStoreOptions {
212
252
  */
213
253
  function generateUniqueDirectoryName(streamPath: string): string {
214
254
  const encoded = encodeStreamPath(streamPath)
215
- const timestamp = Date.now().toString(36) // Base36 for shorter strings
216
- const random = randomBytes(4).toString(`hex`) // 8 chars hex
255
+ const timestamp = Date.now().toString(36)
256
+ const random = randomBytes(4).toString(`hex`)
217
257
  return `${encoded}~${timestamp}~${random}`
218
258
  }
219
259
 
260
+ function segmentFile(dataDir: string, dirName: string): string {
261
+ return path.join(dataDir, `streams`, `${dirName}.log`)
262
+ }
263
+
220
264
  /**
221
265
  * File-backed implementation of StreamStore.
222
266
  * Maintains the same interface as the in-memory StreamStore for drop-in compatibility.
223
267
  */
224
268
  export class FileBackedStreamStore {
225
269
  private db: Database
226
- private fileManager: StreamFileManager
227
270
  private fileHandlePool: FileHandlePool
228
271
  private pendingLongPolls: Array<PendingLongPoll> = []
229
272
  private dataDir: string
@@ -232,6 +275,13 @@ export class FileBackedStreamStore {
232
275
  * Key: "{streamPath}:{producerId}"
233
276
  */
234
277
  private producerLocks = new Map<string, Promise<unknown>>()
278
+ /**
279
+ * Per-stream append locks. Serializes the read-modify-write of currentOffset
280
+ * across all concurrent appenders on the same stream so the LMDB-tracked
281
+ * offset cannot drift behind the file's actual byte position.
282
+ * Key: streamPath
283
+ */
284
+ private streamAppendLocks = new Map<string, Promise<unknown>>()
235
285
 
236
286
  constructor(options: FileBackedStreamStoreOptions) {
237
287
  this.dataDir = options.dataDir
@@ -240,10 +290,13 @@ export class FileBackedStreamStore {
240
290
  this.db = openLMDB({
241
291
  path: path.join(this.dataDir, `metadata.lmdb`),
242
292
  compression: true,
293
+ noMemInit: true,
294
+ cache: true,
295
+ sharedStructuresKey: Symbol.for(`structures`),
243
296
  })
244
297
 
245
- // Initialize file manager
246
- this.fileManager = new StreamFileManager(path.join(this.dataDir, `streams`))
298
+ // Pre-create the streams directory
299
+ fs.mkdirSync(path.join(this.dataDir, `streams`), { recursive: true })
247
300
 
248
301
  // Initialize file handle pool with SIEVE cache
249
302
  const maxFileHandles = options.maxFileHandles ?? 100
@@ -258,7 +311,7 @@ export class FileBackedStreamStore {
258
311
  * Validates that LMDB metadata matches actual file contents and reconciles any mismatches.
259
312
  */
260
313
  private recover(): void {
261
- console.log(`[FileBackedStreamStore] Starting recovery...`)
314
+ serverLog.info(`[FileBackedStreamStore] Starting recovery...`)
262
315
 
263
316
  let recovered = 0
264
317
  let reconciled = 0
@@ -281,17 +334,11 @@ export class FileBackedStreamStore {
281
334
  const streamMeta = value as StreamMetadata
282
335
  const streamPath = key.replace(`stream:`, ``)
283
336
 
284
- // Get segment file path
285
- const segmentPath = path.join(
286
- this.dataDir,
287
- `streams`,
288
- streamMeta.directoryName,
289
- `segment_00000.log`
290
- )
337
+ const segmentPath = segmentFile(this.dataDir, streamMeta.directoryName)
291
338
 
292
339
  // Check if file exists
293
340
  if (!fs.existsSync(segmentPath)) {
294
- console.warn(
341
+ serverLog.warn(
295
342
  `[FileBackedStreamStore] Recovery: Stream file missing for ${streamPath}, removing from LMDB`
296
343
  )
297
344
  this.db.removeSync(key)
@@ -316,7 +363,7 @@ export class FileBackedStreamStore {
316
363
 
317
364
  // Check if offset matches
318
365
  if (trueOffset !== streamMeta.currentOffset) {
319
- console.warn(
366
+ serverLog.warn(
320
367
  `[FileBackedStreamStore] Recovery: Offset mismatch for ${streamPath}: ` +
321
368
  `LMDB says ${streamMeta.currentOffset}, file says ${trueOffset}. Reconciling to file.`
322
369
  )
@@ -332,12 +379,12 @@ export class FileBackedStreamStore {
332
379
 
333
380
  recovered++
334
381
  } catch (err) {
335
- console.error(`[FileBackedStreamStore] Error recovering stream:`, err)
382
+ serverLog.error(`[FileBackedStreamStore] Error recovering stream:`, err)
336
383
  errors++
337
384
  }
338
385
  }
339
386
 
340
- console.log(
387
+ serverLog.info(
341
388
  `[FileBackedStreamStore] Recovery complete: ${recovered} streams, ` +
342
389
  `${reconciled} reconciled, ${errors} errors`
343
390
  )
@@ -351,39 +398,21 @@ export class FileBackedStreamStore {
351
398
  try {
352
399
  const fileContent = fs.readFileSync(segmentPath)
353
400
  let filePos = 0
354
- let currentDataOffset = 0
355
401
 
356
402
  while (filePos < fileContent.length) {
357
- // Read message length (4 bytes)
358
- if (filePos + 4 > fileContent.length) {
359
- // Truncated length header - stop here
360
- break
361
- }
403
+ if (filePos + 4 > fileContent.length) break
362
404
 
363
405
  const messageLength = fileContent.readUInt32BE(filePos)
364
- filePos += 4
406
+ const frameEnd = filePos + 4 + messageLength + 1
365
407
 
366
- // Check if we have the full message
367
- if (filePos + messageLength > fileContent.length) {
368
- // Truncated message data - stop here
369
- break
370
- }
371
-
372
- filePos += messageLength
408
+ if (frameEnd > fileContent.length) break
373
409
 
374
- // Skip newline
375
- if (filePos < fileContent.length) {
376
- filePos += 1
377
- }
378
-
379
- // Update offset with this complete message
380
- currentDataOffset += messageLength
410
+ filePos = frameEnd
381
411
  }
382
412
 
383
- // Return offset in format "readSeq_byteOffset" with zero-padding
384
- return `0000000000000000_${String(currentDataOffset).padStart(16, `0`)}`
413
+ return `0000000000000000_${String(filePos).padStart(16, `0`)}`
385
414
  } catch (err) {
386
- console.error(
415
+ serverLog.error(
387
416
  `[FileBackedStreamStore] Error scanning file ${segmentPath}:`,
388
417
  err
389
418
  )
@@ -535,6 +564,33 @@ export class FileBackedStreamStore {
535
564
  }
536
565
  }
537
566
 
567
+ /**
568
+ * Acquire a per-stream append lock that serializes the read-modify-write
569
+ * of currentOffset across all concurrent appenders on the same stream.
570
+ * Without this, two concurrent appends can read the same starting
571
+ * currentOffset, both compute their newOffset, both write a frame to the
572
+ * file, but only one of their LMDB updates wins — leaving currentOffset
573
+ * lagging the file's actual byte position. Returns a release function.
574
+ */
575
+ private async acquireStreamAppendLock(
576
+ streamPath: string
577
+ ): Promise<() => void> {
578
+ while (this.streamAppendLocks.has(streamPath)) {
579
+ await this.streamAppendLocks.get(streamPath)
580
+ }
581
+
582
+ let releaseLock: () => void
583
+ const lockPromise = new Promise<void>((resolve) => {
584
+ releaseLock = resolve
585
+ })
586
+ this.streamAppendLocks.set(streamPath, lockPromise)
587
+
588
+ return () => {
589
+ this.streamAppendLocks.delete(streamPath)
590
+ releaseLock!()
591
+ }
592
+ }
593
+
538
594
  /**
539
595
  * Get the current epoch for a producer on a stream.
540
596
  * Returns undefined if the producer doesn't exist or stream not found.
@@ -795,6 +851,8 @@ export class FileBackedStreamStore {
795
851
  // Define key for LMDB operations
796
852
  const key = `stream:${streamPath}`
797
853
 
854
+ const t0 = performance.now()
855
+
798
856
  // Initialize metadata
799
857
  // Note: We set closed to false initially, then set it true after appending initial data
800
858
  // This prevents the closed check from rejecting the initial append
@@ -816,17 +874,11 @@ export class FileBackedStreamStore {
816
874
  refCount: 0,
817
875
  }
818
876
 
819
- // Create stream directory and empty segment file immediately
820
- // This ensures the stream is fully initialized and can be recovered
821
- const streamDir = path.join(
822
- this.dataDir,
823
- `streams`,
824
- streamMeta.directoryName
825
- )
877
+ const tAfterMeta = performance.now()
878
+
879
+ const segmentPath = segmentFile(this.dataDir, streamMeta.directoryName)
826
880
  try {
827
- fs.mkdirSync(streamDir, { recursive: true })
828
- const segmentPath = path.join(streamDir, `segment_00000.log`)
829
- fs.writeFileSync(segmentPath, ``)
881
+ await this.db.put(key, streamMeta)
830
882
  } catch (err) {
831
883
  // Rollback source refcount on failure
832
884
  if (isFork && sourceMeta) {
@@ -840,15 +892,24 @@ export class FileBackedStreamStore {
840
892
  this.db.putSync(sourceKey, updatedSource)
841
893
  }
842
894
  }
843
- console.error(
844
- `[FileBackedStreamStore] Error creating stream directory:`,
895
+ serverLog.error(
896
+ `[FileBackedStreamStore] Error creating stream (LMDB put):`,
845
897
  err
846
898
  )
847
899
  throw err
848
900
  }
849
-
850
- // Save to LMDB
851
- this.db.putSync(key, streamMeta)
901
+ const tAfterLmdb = performance.now()
902
+ try {
903
+ await this.fileHandlePool.openWriteStream(segmentPath)
904
+ } catch (err) {
905
+ this.db.removeSync(key)
906
+ serverLog.error(
907
+ `[FileBackedStreamStore] Error creating stream (file open):`,
908
+ err
909
+ )
910
+ throw err
911
+ }
912
+ const tAfterOpen = performance.now()
852
913
 
853
914
  // Append initial data if provided
854
915
  if (options.initialData && options.initialData.length > 0) {
@@ -875,16 +936,33 @@ export class FileBackedStreamStore {
875
936
  throw err
876
937
  }
877
938
  }
939
+ const tAfterAppend = performance.now()
878
940
 
879
941
  // Now set closed flag if requested (after initial append succeeded)
880
942
  if (options.closed) {
881
943
  const updatedMeta = this.db.get(key) as StreamMetadata
882
944
  updatedMeta.closed = true
883
- this.db.putSync(key, updatedMeta)
945
+ await this.db.put(key, updatedMeta)
884
946
  }
885
947
 
886
948
  // Re-fetch updated metadata
887
949
  const updated = this.db.get(key) as StreamMetadata
950
+ const totalMs = performance.now() - t0
951
+ if (totalMs > 50) {
952
+ serverLog.event(
953
+ {
954
+ event: `store.create`,
955
+ path: streamPath,
956
+ totalMs: +totalMs.toFixed(2),
957
+ metaMs: +(tAfterMeta - t0).toFixed(2),
958
+ lmdbMs: +(tAfterLmdb - tAfterMeta).toFixed(2),
959
+ openMs: +(tAfterOpen - tAfterLmdb).toFixed(2),
960
+ appendMs: +(tAfterAppend - tAfterOpen).toFixed(2),
961
+ initBytes: options.initialData?.length ?? 0,
962
+ },
963
+ `store.create slow`
964
+ )
965
+ }
888
966
  return this.streamMetaToStream(updated)
889
967
  }
890
968
 
@@ -941,26 +1019,18 @@ export class FileBackedStreamStore {
941
1019
  // Cancel any pending long-polls for this stream
942
1020
  this.cancelLongPollsForStream(streamPath)
943
1021
 
944
- // Close any open file handle for this stream's segment file
945
- const segmentPath = path.join(
946
- this.dataDir,
947
- `streams`,
948
- streamMeta.directoryName,
949
- `segment_00000.log`
950
- )
951
- this.fileHandlePool.closeFileHandle(segmentPath).catch((err: Error) => {
952
- console.error(`[FileBackedStreamStore] Error closing file handle:`, err)
953
- })
1022
+ const segmentPath = segmentFile(this.dataDir, streamMeta.directoryName)
954
1023
 
955
1024
  // Delete from LMDB
956
1025
  this.db.removeSync(key)
957
1026
 
958
- // Delete files using unique directory name (async, but don't wait)
959
- this.fileManager
960
- .deleteDirectoryByName(streamMeta.directoryName)
1027
+ // Close handle then delete file (chained to avoid EBUSY on Windows)
1028
+ this.fileHandlePool
1029
+ .closeFileHandle(segmentPath)
1030
+ .then(() => fs.promises.unlink(segmentPath))
961
1031
  .catch((err: Error) => {
962
- console.error(
963
- `[FileBackedStreamStore] Error deleting stream directory:`,
1032
+ serverLog.error(
1033
+ `[FileBackedStreamStore] Error cleaning up stream file:`,
964
1034
  err
965
1035
  )
966
1036
  })
@@ -985,10 +1055,28 @@ export class FileBackedStreamStore {
985
1055
  }
986
1056
  }
987
1057
 
1058
+ /**
1059
+ * Public append entry point. Serializes concurrent appends to the same
1060
+ * stream so the read-modify-write of currentOffset cannot interleave —
1061
+ * see acquireStreamAppendLock for the underlying race.
1062
+ */
988
1063
  async append(
989
1064
  streamPath: string,
990
1065
  data: Uint8Array,
991
1066
  options: AppendOptions & { isInitialCreate?: boolean } = {}
1067
+ ): Promise<StreamMessage | AppendResult | null> {
1068
+ const releaseLock = await this.acquireStreamAppendLock(streamPath)
1069
+ try {
1070
+ return await this.appendInner(streamPath, data, options)
1071
+ } finally {
1072
+ releaseLock()
1073
+ }
1074
+ }
1075
+
1076
+ private async appendInner(
1077
+ streamPath: string,
1078
+ data: Uint8Array,
1079
+ options: AppendOptions & { isInitialCreate?: boolean } = {}
992
1080
  ): Promise<StreamMessage | AppendResult | null> {
993
1081
  const streamMeta = this.getMetaIfNotExpired(streamPath)
994
1082
 
@@ -1092,17 +1180,13 @@ export class FileBackedStreamStore {
1092
1180
  const readSeq = parts[0]!
1093
1181
  const byteOffset = parts[1]!
1094
1182
 
1095
- // Calculate new offset with zero-padding for lexicographic sorting (only data bytes, not framing)
1096
- const newByteOffset = byteOffset + processedData.length
1183
+ const FRAME_OVERHEAD = 5 // 4-byte length prefix + 1-byte newline
1184
+ const newByteOffset = byteOffset + FRAME_OVERHEAD + processedData.length
1097
1185
  const newOffset = `${String(readSeq).padStart(16, `0`)}_${String(newByteOffset).padStart(16, `0`)}`
1098
1186
 
1099
- // Get segment file path (directory was created in create())
1100
- const streamDir = path.join(
1101
- this.dataDir,
1102
- `streams`,
1103
- streamMeta.directoryName
1104
- )
1105
- const segmentPath = path.join(streamDir, `segment_00000.log`)
1187
+ const segmentPath = segmentFile(this.dataDir, streamMeta.directoryName)
1188
+
1189
+ const tAppendStart = performance.now()
1106
1190
 
1107
1191
  // Get write stream from pool
1108
1192
  const stream = this.fileHandlePool.getWriteStream(segmentPath)
@@ -1124,6 +1208,8 @@ export class FileBackedStreamStore {
1124
1208
  })
1125
1209
  })
1126
1210
 
1211
+ const tAfterWrite = performance.now()
1212
+
1127
1213
  // 2. Create message object for return value
1128
1214
  const message: StreamMessage = {
1129
1215
  data: processedData,
@@ -1134,6 +1220,8 @@ export class FileBackedStreamStore {
1134
1220
  // 3. Flush to disk (blocks here until durable)
1135
1221
  await this.fileHandlePool.fsyncFile(segmentPath)
1136
1222
 
1223
+ const tAfterFsync = performance.now()
1224
+
1137
1225
  // 4. Update LMDB metadata atomically (only after flush, so metadata reflects durability)
1138
1226
  // This includes both the offset update and producer state update
1139
1227
  // Producer state is committed HERE (not in validateProducer) for atomicity
@@ -1162,7 +1250,25 @@ export class FileBackedStreamStore {
1162
1250
  closedBy: closedBy ?? streamMeta.closedBy,
1163
1251
  }
1164
1252
  const key = `stream:${streamPath}`
1165
- this.db.putSync(key, updatedMeta)
1253
+ await this.db.put(key, updatedMeta)
1254
+
1255
+ const tAfterLmdb = performance.now()
1256
+ const appendTotal = tAfterLmdb - tAppendStart
1257
+ if (appendTotal > 50) {
1258
+ serverLog.event(
1259
+ {
1260
+ event: `store.append`,
1261
+ path: streamPath,
1262
+ totalMs: +appendTotal.toFixed(2),
1263
+ writeMs: +(tAfterWrite - tAppendStart).toFixed(2),
1264
+ fsyncMs: +(tAfterFsync - tAfterWrite).toFixed(2),
1265
+ lmdbMs: +(tAfterLmdb - tAfterFsync).toFixed(2),
1266
+ bytes: processedData.length,
1267
+ isInitial: options.isInitialCreate ?? false,
1268
+ },
1269
+ `store.append slow`
1270
+ )
1271
+ }
1166
1272
 
1167
1273
  // 5. Notify long-polls (data is now readable from disk)
1168
1274
  this.notifyLongPolls(streamPath)
@@ -1338,7 +1444,7 @@ export class FileBackedStreamStore {
1338
1444
  },
1339
1445
  producers: updatedProducers,
1340
1446
  }
1341
- this.db.putSync(key, updatedMeta)
1447
+ await this.db.put(key, updatedMeta)
1342
1448
 
1343
1449
  // Notify any pending long-polls
1344
1450
  this.notifyLongPollsClosed(streamPath)
@@ -1397,8 +1503,10 @@ export class FileBackedStreamStore {
1397
1503
  // Skip newline
1398
1504
  filePos += 1
1399
1505
 
1400
- // Calculate this message's logical offset (end position)
1401
- physicalDataOffset += messageLength
1506
+ // Calculate this message's logical offset (end position).
1507
+ // Frames in our file layout are 4-byte length + data + 1-byte newline,
1508
+ // and stream offsets advance by the full frame size — see append().
1509
+ physicalDataOffset += messageLength + 5
1402
1510
  const logicalOffset = baseByteOffset + physicalDataOffset
1403
1511
 
1404
1512
  // Stop if we've exceeded the cap
@@ -1416,7 +1524,10 @@ export class FileBackedStreamStore {
1416
1524
  }
1417
1525
  }
1418
1526
  } catch (err) {
1419
- console.error(`[FileBackedStreamStore] Error reading segment file:`, err)
1527
+ serverLog.error(
1528
+ `[FileBackedStreamStore] Error reading segment file:`,
1529
+ err
1530
+ )
1420
1531
  }
1421
1532
 
1422
1533
  return messages
@@ -1460,12 +1571,7 @@ export class FileBackedStreamStore {
1460
1571
  // Read source's own segment file
1461
1572
  // For a fork source, its own data starts at physical byte 0 in its segment file,
1462
1573
  // but the logical offsets need to account for its own forkOffset base
1463
- const segmentPath = path.join(
1464
- this.dataDir,
1465
- `streams`,
1466
- sourceMeta.directoryName,
1467
- `segment_00000.log`
1468
- )
1574
+ const segmentPath = segmentFile(this.dataDir, sourceMeta.directoryName)
1469
1575
 
1470
1576
  // The base offset for this source's own data is its forkOffset (if it's a fork) or 0
1471
1577
  const sourceBaseByte = sourceMeta.forkOffset
@@ -1526,12 +1632,7 @@ export class FileBackedStreamStore {
1526
1632
 
1527
1633
  // Read fork's own segment file with offset translation
1528
1634
  // Physical bytes in file start at 0, but logical offsets start at forkOffset
1529
- const segmentPath = path.join(
1530
- this.dataDir,
1531
- `streams`,
1532
- streamMeta.directoryName,
1533
- `segment_00000.log`
1534
- )
1635
+ const segmentPath = segmentFile(this.dataDir, streamMeta.directoryName)
1535
1636
  const ownMessages = this.readMessagesFromSegmentFile(
1536
1637
  segmentPath,
1537
1638
  startByte,
@@ -1540,12 +1641,7 @@ export class FileBackedStreamStore {
1540
1641
  messages.push(...ownMessages)
1541
1642
  } else {
1542
1643
  // Non-forked stream: read from segment file directly
1543
- const segmentPath = path.join(
1544
- this.dataDir,
1545
- `streams`,
1546
- streamMeta.directoryName,
1547
- `segment_00000.log`
1548
- )
1644
+ const segmentPath = segmentFile(this.dataDir, streamMeta.directoryName)
1549
1645
  const ownMessages = this.readMessagesFromSegmentFile(
1550
1646
  segmentPath,
1551
1647
  startByte,
@@ -1649,6 +1745,10 @@ export class FileBackedStreamStore {
1649
1745
  throw new Error(`Stream not found: ${streamPath}`)
1650
1746
  }
1651
1747
 
1748
+ if (normalizeContentType(streamMeta.contentType) === `application/json`) {
1749
+ return formatJsonMessages(messages)
1750
+ }
1751
+
1652
1752
  // Concatenate all message data
1653
1753
  const totalSize = messages.reduce((sum, m) => sum + m.data.length, 0)
1654
1754
  const concatenated = new Uint8Array(totalSize)
@@ -1658,11 +1758,6 @@ export class FileBackedStreamStore {
1658
1758
  offset += msg.data.length
1659
1759
  }
1660
1760
 
1661
- // For JSON mode, wrap in array brackets
1662
- if (normalizeContentType(streamMeta.contentType) === `application/json`) {
1663
- return formatJsonResponse(concatenated)
1664
- }
1665
-
1666
1761
  return concatenated
1667
1762
  }
1668
1763
 
@@ -1695,7 +1790,7 @@ export class FileBackedStreamStore {
1695
1790
 
1696
1791
  // Clear file handle pool
1697
1792
  this.fileHandlePool.closeAll().catch((err: Error) => {
1698
- console.error(`[FileBackedStreamStore] Error closing handles:`, err)
1793
+ serverLog.error(`[FileBackedStreamStore] Error closing handles:`, err)
1699
1794
  })
1700
1795
 
1701
1796
  // Note: Files are not deleted in clear() with unique directory names