@durable-streams/server 0.2.2 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/server.ts CHANGED
@@ -36,6 +36,10 @@ const SSE_CLOSED_FIELD = `streamClosed`
36
36
  // Stream closure header
37
37
  const STREAM_CLOSED_HEADER = `Stream-Closed`
38
38
 
39
+ // Fork headers (request headers only — not set on responses)
40
+ const STREAM_FORKED_FROM_HEADER = `Stream-Forked-From`
41
+ const STREAM_FORK_OFFSET_HEADER = `Stream-Fork-Offset`
42
+
39
43
  // Query params
40
44
  const OFFSET_QUERY_PARAM = `offset`
41
45
  const LIVE_QUERY_PARAM = `live`
@@ -427,7 +431,7 @@ export class DurableStreamTestServer {
427
431
  )
428
432
  res.setHeader(
429
433
  `access-control-allow-headers`,
430
- `content-type, authorization, Stream-Seq, Stream-TTL, Stream-Expires-At, Stream-Closed, Producer-Id, Producer-Epoch, Producer-Seq`
434
+ `content-type, authorization, Stream-Seq, Stream-TTL, Stream-Expires-At, Stream-Closed, Producer-Id, Producer-Epoch, Producer-Seq, Stream-Forked-From, Stream-Fork-Offset`
431
435
  )
432
436
  res.setHeader(
433
437
  `access-control-expose-headers`,
@@ -511,7 +515,15 @@ export class DurableStreamTestServer {
511
515
  }
512
516
  } catch (err) {
513
517
  if (err instanceof Error) {
514
- if (err.message.includes(`not found`)) {
518
+ if (err.message.includes(`active forks`)) {
519
+ res.writeHead(409, { "content-type": `text/plain` })
520
+ res.end(
521
+ `stream was deleted but still has active forks — path cannot be reused until all forks are removed`
522
+ )
523
+ } else if (err.message.includes(`soft-deleted`)) {
524
+ res.writeHead(410, { "content-type": `text/plain` })
525
+ res.end(`Stream is gone`)
526
+ } else if (err.message.includes(`not found`)) {
515
527
  res.writeHead(404, { "content-type": `text/plain` })
516
528
  res.end(`Stream not found`)
517
529
  } else if (
@@ -570,6 +582,14 @@ export class DurableStreamTestServer {
570
582
  const closedHeader = req.headers[STREAM_CLOSED_HEADER.toLowerCase()]
571
583
  const createClosed = closedHeader === `true`
572
584
 
585
+ // Parse fork headers
586
+ const forkedFromHeader = req.headers[
587
+ STREAM_FORKED_FROM_HEADER.toLowerCase()
588
+ ] as string | undefined
589
+ const forkOffsetHeader = req.headers[
590
+ STREAM_FORK_OFFSET_HEADER.toLowerCase()
591
+ ] as string | undefined
592
+
573
593
  // Validate TTL and Expires-At headers
574
594
  if (ttlHeader && expiresAtHeader) {
575
595
  res.writeHead(400, { "content-type": `text/plain` })
@@ -606,21 +626,59 @@ export class DurableStreamTestServer {
606
626
  }
607
627
  }
608
628
 
629
+ // Validate fork offset format if provided
630
+ if (forkOffsetHeader) {
631
+ const validOffsetPattern = /^\d+_\d+$/
632
+ if (!validOffsetPattern.test(forkOffsetHeader)) {
633
+ res.writeHead(400, { "content-type": `text/plain` })
634
+ res.end(`Invalid Stream-Fork-Offset format`)
635
+ return
636
+ }
637
+ }
638
+
609
639
  // Read body if present
610
640
  const body = await this.readBody(req)
611
641
 
612
642
  const isNew = !this.store.has(path)
613
643
 
614
644
  // Support both sync (StreamStore) and async (FileBackedStreamStore) create
615
- await Promise.resolve(
616
- this.store.create(path, {
617
- contentType,
618
- ttlSeconds,
619
- expiresAt: expiresAtHeader,
620
- initialData: body.length > 0 ? body : undefined,
621
- closed: createClosed,
622
- })
623
- )
645
+ try {
646
+ await Promise.resolve(
647
+ this.store.create(path, {
648
+ contentType,
649
+ ttlSeconds,
650
+ expiresAt: expiresAtHeader,
651
+ initialData: body.length > 0 ? body : undefined,
652
+ closed: createClosed,
653
+ forkedFrom: forkedFromHeader,
654
+ forkOffset: forkOffsetHeader,
655
+ })
656
+ )
657
+ } catch (err) {
658
+ if (err instanceof Error) {
659
+ if (err.message.includes(`Source stream not found`)) {
660
+ res.writeHead(404, { "content-type": `text/plain` })
661
+ res.end(`Source stream not found`)
662
+ return
663
+ }
664
+ if (err.message.includes(`Invalid fork offset`)) {
665
+ res.writeHead(400, { "content-type": `text/plain` })
666
+ res.end(`Fork offset beyond source stream length`)
667
+ return
668
+ }
669
+ if (err.message.includes(`soft-deleted`)) {
670
+ res.writeHead(409, { "content-type": `text/plain` })
671
+ res.end(`source stream was deleted but still has active forks`)
672
+ return
673
+ }
674
+ if (err.message.includes(`Content type mismatch`)) {
675
+ res.writeHead(409, { "content-type": `text/plain` })
676
+ res.end(`Content type mismatch with source stream`)
677
+ return
678
+ }
679
+ }
680
+ throw err
681
+ }
624
682
 
625
683
  const stream = this.store.get(path)!
626
684
 
@@ -630,7 +688,7 @@ export class DurableStreamTestServer {
630
688
  this.options.onStreamCreated({
631
689
  type: `created`,
632
690
  path,
633
- contentType,
691
+ contentType: stream.contentType ?? contentType,
634
692
  timestamp: Date.now(),
635
693
  })
636
694
  )
@@ -638,7 +696,7 @@ export class DurableStreamTestServer {
638
696
 
639
697
  // Return 201 for new streams, 200 for idempotent creates
640
698
  const headers: Record<string, string> = {
641
- "content-type": contentType,
699
+ "content-type": stream.contentType ?? contentType,
642
700
  [STREAM_OFFSET_HEADER]: stream.currentOffset,
643
701
  }
644
702
 
@@ -667,6 +725,13 @@ export class DurableStreamTestServer {
667
725
  return
668
726
  }
669
727
 
728
+ // Check for soft-deleted streams
729
+ if (stream.softDeleted) {
730
+ res.writeHead(410, { "content-type": `text/plain` })
731
+ res.end()
732
+ return
733
+ }
734
+
670
735
  const headers: Record<string, string> = {
671
736
  [STREAM_OFFSET_HEADER]: stream.currentOffset,
672
737
  // HEAD responses should not be cached to avoid stale tail offsets (Protocol Section 5.4)
@@ -682,6 +747,14 @@ export class DurableStreamTestServer {
682
747
  headers[STREAM_CLOSED_HEADER] = `true`
683
748
  }
684
749
 
750
+ // Include TTL/Expiry metadata
751
+ if (stream.ttlSeconds !== undefined) {
752
+ headers[STREAM_TTL_HEADER] = String(stream.ttlSeconds)
753
+ }
754
+ if (stream.expiresAt) {
755
+ headers[STREAM_EXPIRES_AT_HEADER] = stream.expiresAt
756
+ }
757
+
685
758
  // Generate ETag: {path}:-1:{offset}[:c] (includes closure status)
686
759
  // The :c suffix ensures ETag changes when a stream is closed, even without new data
687
760
  const closedSuffix = stream.closed ? `:c` : ``
@@ -708,6 +781,13 @@ export class DurableStreamTestServer {
708
781
  return
709
782
  }
710
783
 
784
+ // Check for soft-deleted streams
785
+ if (stream.softDeleted) {
786
+ res.writeHead(410, { "content-type": `text/plain` })
787
+ res.end(`Stream is gone`)
788
+ return
789
+ }
790
+
711
791
  const offset = url.searchParams.get(OFFSET_QUERY_PARAM) ?? undefined
712
792
  const live = url.searchParams.get(LIVE_QUERY_PARAM)
713
793
  const cursor = url.searchParams.get(CURSOR_QUERY_PARAM) ?? undefined
@@ -802,6 +882,7 @@ export class DurableStreamTestServer {
802
882
 
803
883
  // Read current messages
804
884
  let { messages, upToDate } = this.store.read(path, effectiveOffset)
885
+ this.store.touchAccess(path)
805
886
 
806
887
  // Only wait in long-poll if:
807
888
  // 1. long-poll mode is enabled
@@ -828,6 +909,7 @@ export class DurableStreamTestServer {
828
909
  effectiveOffset ?? stream.currentOffset,
829
910
  this.options.longPollTimeout
830
911
  )
912
+ this.store.touchAccess(path)
831
913
 
832
914
  // If stream was closed during wait, return immediately with Stream-Closed
833
915
  if (result.streamClosed) {
@@ -1005,6 +1087,7 @@ export class DurableStreamTestServer {
1005
1087
  while (isConnected && !this.isShuttingDown) {
1006
1088
  // Read current messages from offset
1007
1089
  const { messages, upToDate } = this.store.read(path, currentOffset)
1090
+ this.store.touchAccess(path)
1008
1091
 
1009
1092
  // Send data events for each message
1010
1093
  for (const message of messages) {
@@ -1093,6 +1176,7 @@ export class DurableStreamTestServer {
1093
1176
  currentOffset,
1094
1177
  this.options.longPollTimeout
1095
1178
  )
1179
+ this.store.touchAccess(path)
1096
1180
 
1097
1181
  // Check if we should exit after wait returns (values can change during await)
1098
1182
  // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
@@ -1362,6 +1446,7 @@ export class DurableStreamTestServer {
1362
1446
  this.store.append(path, body, appendOptions)
1363
1447
  )
1364
1448
  }
1449
+ this.store.touchAccess(path)
1365
1450
 
1366
1451
  // Handle AppendResult with producer validation or streamClosed
1367
1452
  if (result && typeof result === `object` && `message` in result) {
@@ -1491,14 +1576,21 @@ export class DurableStreamTestServer {
1491
1576
  * Handle DELETE - delete stream
1492
1577
  */
1493
1578
  private async handleDelete(path: string, res: ServerResponse): Promise<void> {
1494
- if (!this.store.has(path)) {
1579
+ // Check for soft-deleted streams before attempting delete
1580
+ const existing = this.store.get(path)
1581
+ if (existing?.softDeleted) {
1582
+ res.writeHead(410, { "content-type": `text/plain` })
1583
+ res.end(`Stream is gone`)
1584
+ return
1585
+ }
1586
+
1587
+ const deleted = this.store.delete(path)
1588
+ if (!deleted) {
1495
1589
  res.writeHead(404, { "content-type": `text/plain` })
1496
1590
  res.end(`Stream not found`)
1497
1591
  return
1498
1592
  }
1499
1593
 
1500
- this.store.delete(path)
1501
-
1502
1594
  // Call lifecycle hook
1503
1595
  if (this.options.onStreamDeleted) {
1504
1596
  await Promise.resolve(