@durable-streams/server-conformance-tests 0.2.3 → 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/index.ts CHANGED
@@ -5,7 +5,7 @@
5
5
  * any server implementation to verify protocol compliance.
6
6
  */
7
7
 
8
- import { describe, expect, test } from "vitest"
8
+ import { describe, expect, test, vi } from "vitest"
9
9
  import * as fc from "fast-check"
10
10
  import {
11
11
  DurableStream,
@@ -1575,22 +1575,27 @@ export function runConformanceTests(options: ConformanceTestOptions): void {
1575
1575
  { method: `GET` }
1576
1576
  )
1577
1577
 
1578
- // Give the long-poll a moment to start waiting
1579
- await new Promise((r) => setTimeout(r, 100))
1580
-
1581
- // Append new data while long-poll is waiting
1582
- await fetch(`${getBaseUrl()}${streamPath}`, {
1583
- method: `POST`,
1584
- headers: { "Content-Type": `text/plain` },
1585
- body: `new data`,
1586
- })
1578
+ // Continuously append data so the long-poll picks it up regardless of
1579
+ // when the server establishes the subscription or how short its timeout is.
1580
+ const interval = setInterval(() => {
1581
+ void fetch(`${getBaseUrl()}${streamPath}`, {
1582
+ method: `POST`,
1583
+ headers: { "Content-Type": `text/plain` },
1584
+ body: `new data`,
1585
+ })
1586
+ }, 50)
1587
1587
 
1588
- // Long-poll should return with the new data (not historical)
1589
- const response = await longPollPromise
1590
- expect(response.status).toBe(200)
1591
- const text = await response.text()
1592
- expect(text).toBe(`new data`)
1593
- expect(response.headers.get(STREAM_UP_TO_DATE_HEADER)).toBe(`true`)
1588
+ try {
1589
+ // Long-poll should return with new data (not historical)
1590
+ const response = await longPollPromise
1591
+ expect(response.status).toBe(200)
1592
+ const text = await response.text()
1593
+ expect(text).toContain(`new data`)
1594
+ expect(text).not.toContain(`historical`)
1595
+ expect(response.headers.get(STREAM_UP_TO_DATE_HEADER)).toBe(`true`)
1596
+ } finally {
1597
+ clearInterval(interval)
1598
+ }
1594
1599
  })
1595
1600
 
1596
1601
  test(`should support offset=now with SSE mode`, async () => {
@@ -2481,10 +2486,8 @@ export function runConformanceTests(options: ConformanceTestOptions): void {
2481
2486
 
2482
2487
  // SHOULD return TTL metadata
2483
2488
  const ttl = response.headers.get(`Stream-TTL`)
2484
- if (ttl) {
2485
- expect(parseInt(ttl)).toBeGreaterThan(0)
2486
- expect(parseInt(ttl)).toBeLessThanOrEqual(3600)
2487
- }
2489
+ // Stream-TTL returns the window value, not remaining time
2490
+ expect(ttl).toBe(`3600`)
2488
2491
  })
2489
2492
 
2490
2493
  test(`should return Expires-At metadata if configured`, async () => {
@@ -2526,6 +2529,23 @@ export function runConformanceTests(options: ConformanceTestOptions): void {
2526
2529
  const uniquePath = (prefix: string) =>
2527
2530
  `/v1/stream/${prefix}-${Date.now()}-${Math.random().toString(36).slice(2)}`
2528
2531
 
2532
+ // Poll HEAD until the stream is deleted, tolerating slight timing delays
2533
+ const waitForDeletion = async (
2534
+ url: string,
2535
+ initialSleepMs: number,
2536
+ expectedStatuses: Array<number> = [404],
2537
+ timeoutMs: number = 5000
2538
+ ) => {
2539
+ await sleep(initialSleepMs)
2540
+ await vi.waitFor(
2541
+ async () => {
2542
+ const head = await fetch(url, { method: `HEAD` })
2543
+ expect(expectedStatuses).toContain(head.status)
2544
+ },
2545
+ { timeout: timeoutMs, interval: 200 }
2546
+ )
2547
+ }
2548
+
2529
2549
  // Run tests concurrently to avoid 6x 1.5s wait time
2530
2550
  test.concurrent(`should return 404 on HEAD after TTL expires`, async () => {
2531
2551
  const streamPath = uniquePath(`ttl-expire-head`)
@@ -2546,48 +2566,45 @@ export function runConformanceTests(options: ConformanceTestOptions): void {
2546
2566
  })
2547
2567
  expect(headBefore.status).toBe(200)
2548
2568
 
2549
- // Wait for TTL to expire (1 second + buffer)
2550
- await sleep(1500)
2569
+ // Wait for TTL to expire, polling HEAD until deleted
2570
+ await waitForDeletion(`${getBaseUrl()}${streamPath}`, 1000)
2551
2571
 
2552
- // Stream should no longer exist
2553
- const headAfter = await fetch(`${getBaseUrl()}${streamPath}`, {
2554
- method: `HEAD`,
2572
+ // Verify with GET as well
2573
+ const getAfter = await fetch(`${getBaseUrl()}${streamPath}`, {
2574
+ method: `GET`,
2555
2575
  })
2556
- expect(headAfter.status).toBe(404)
2576
+ expect(getAfter.status).toBe(404)
2557
2577
  })
2558
2578
 
2559
- test.concurrent(`should return 404 on GET after TTL expires`, async () => {
2560
- const streamPath = uniquePath(`ttl-expire-get`)
2561
-
2562
- // Create stream with 1 second TTL and some data
2563
- const createResponse = await fetch(`${getBaseUrl()}${streamPath}`, {
2564
- method: `PUT`,
2565
- headers: {
2566
- "Content-Type": `text/plain`,
2567
- "Stream-TTL": `1`,
2568
- },
2569
- body: `test data`,
2570
- })
2571
- expect(createResponse.status).toBe(201)
2579
+ test.concurrent(
2580
+ `should return 404 on GET after TTL expires (idle)`,
2581
+ async () => {
2582
+ const streamPath = uniquePath(`ttl-expire-get`)
2572
2583
 
2573
- // Verify stream is readable immediately
2574
- const getBefore = await fetch(`${getBaseUrl()}${streamPath}`, {
2575
- method: `GET`,
2576
- })
2577
- expect(getBefore.status).toBe(200)
2584
+ // Create stream with 1 second TTL and some data
2585
+ const createResponse = await fetch(`${getBaseUrl()}${streamPath}`, {
2586
+ method: `PUT`,
2587
+ headers: {
2588
+ "Content-Type": `text/plain`,
2589
+ "Stream-TTL": `1`,
2590
+ },
2591
+ body: `test data`,
2592
+ })
2593
+ expect(createResponse.status).toBe(201)
2578
2594
 
2579
- // Wait for TTL to expire
2580
- await sleep(1500)
2595
+ // Wait for TTL to expire (no reads or writes — stream is idle)
2596
+ await waitForDeletion(`${getBaseUrl()}${streamPath}`, 1000)
2581
2597
 
2582
- // Stream should no longer exist
2583
- const getAfter = await fetch(`${getBaseUrl()}${streamPath}`, {
2584
- method: `GET`,
2585
- })
2586
- expect(getAfter.status).toBe(404)
2587
- })
2598
+ // Verify with GET as well
2599
+ const getAfter = await fetch(`${getBaseUrl()}${streamPath}`, {
2600
+ method: `GET`,
2601
+ })
2602
+ expect(getAfter.status).toBe(404)
2603
+ }
2604
+ )
2588
2605
 
2589
2606
  test.concurrent(
2590
- `should return 404 on POST append after TTL expires`,
2607
+ `should return 404 on POST append after TTL expires (idle)`,
2591
2608
  async () => {
2592
2609
  const streamPath = uniquePath(`ttl-expire-post`)
2593
2610
 
@@ -2601,18 +2618,10 @@ export function runConformanceTests(options: ConformanceTestOptions): void {
2601
2618
  })
2602
2619
  expect(createResponse.status).toBe(201)
2603
2620
 
2604
- // Verify append works immediately
2605
- const postBefore = await fetch(`${getBaseUrl()}${streamPath}`, {
2606
- method: `POST`,
2607
- headers: { "Content-Type": `text/plain` },
2608
- body: `appended data`,
2609
- })
2610
- expect(postBefore.status).toBe(204)
2611
-
2612
- // Wait for TTL to expire
2613
- await sleep(1500)
2621
+ // Wait for TTL to expire (no reads or writes — stream is idle)
2622
+ await waitForDeletion(`${getBaseUrl()}${streamPath}`, 1000)
2614
2623
 
2615
- // Append should fail - stream no longer exists
2624
+ // Verify append fails - stream no longer exists
2616
2625
  const postAfter = await fetch(`${getBaseUrl()}${streamPath}`, {
2617
2626
  method: `POST`,
2618
2627
  headers: { "Content-Type": `text/plain` },
@@ -2627,8 +2636,8 @@ export function runConformanceTests(options: ConformanceTestOptions): void {
2627
2636
  async () => {
2628
2637
  const streamPath = uniquePath(`expires-at-head`)
2629
2638
 
2630
- // Create stream that expires in 1 second
2631
- const expiresAt = new Date(Date.now() + 1000).toISOString()
2639
+ // Create stream that expires in 3 seconds (wide window to tolerate clock skew)
2640
+ const expiresAt = new Date(Date.now() + 3000).toISOString()
2632
2641
  const createResponse = await fetch(`${getBaseUrl()}${streamPath}`, {
2633
2642
  method: `PUT`,
2634
2643
  headers: {
@@ -2644,14 +2653,14 @@ export function runConformanceTests(options: ConformanceTestOptions): void {
2644
2653
  })
2645
2654
  expect(headBefore.status).toBe(200)
2646
2655
 
2647
- // Wait for expiry time to pass
2648
- await sleep(1500)
2656
+ // Wait for expiry, polling HEAD until deleted
2657
+ await waitForDeletion(`${getBaseUrl()}${streamPath}`, 3000)
2649
2658
 
2650
- // Stream should no longer exist
2651
- const headAfter = await fetch(`${getBaseUrl()}${streamPath}`, {
2652
- method: `HEAD`,
2659
+ // Verify with GET as well
2660
+ const getAfter = await fetch(`${getBaseUrl()}${streamPath}`, {
2661
+ method: `GET`,
2653
2662
  })
2654
- expect(headAfter.status).toBe(404)
2663
+ expect(getAfter.status).toBe(404)
2655
2664
  }
2656
2665
  )
2657
2666
 
@@ -2660,8 +2669,8 @@ export function runConformanceTests(options: ConformanceTestOptions): void {
2660
2669
  async () => {
2661
2670
  const streamPath = uniquePath(`expires-at-get`)
2662
2671
 
2663
- // Create stream that expires in 1 second
2664
- const expiresAt = new Date(Date.now() + 1000).toISOString()
2672
+ // Create stream that expires in 3 seconds (wide window to tolerate clock skew)
2673
+ const expiresAt = new Date(Date.now() + 3000).toISOString()
2665
2674
  const createResponse = await fetch(`${getBaseUrl()}${streamPath}`, {
2666
2675
  method: `PUT`,
2667
2676
  headers: {
@@ -2678,10 +2687,10 @@ export function runConformanceTests(options: ConformanceTestOptions): void {
2678
2687
  })
2679
2688
  expect(getBefore.status).toBe(200)
2680
2689
 
2681
- // Wait for expiry time to pass
2682
- await sleep(1500)
2690
+ // Wait for expiry, polling HEAD until deleted
2691
+ await waitForDeletion(`${getBaseUrl()}${streamPath}`, 3000)
2683
2692
 
2684
- // Stream should no longer exist
2693
+ // Verify with GET as well
2685
2694
  const getAfter = await fetch(`${getBaseUrl()}${streamPath}`, {
2686
2695
  method: `GET`,
2687
2696
  })
@@ -2694,8 +2703,8 @@ export function runConformanceTests(options: ConformanceTestOptions): void {
2694
2703
  async () => {
2695
2704
  const streamPath = uniquePath(`expires-at-post`)
2696
2705
 
2697
- // Create stream that expires in 1 second
2698
- const expiresAt = new Date(Date.now() + 1000).toISOString()
2706
+ // Create stream that expires in 3 seconds (wide window to tolerate clock skew)
2707
+ const expiresAt = new Date(Date.now() + 3000).toISOString()
2699
2708
  const createResponse = await fetch(`${getBaseUrl()}${streamPath}`, {
2700
2709
  method: `PUT`,
2701
2710
  headers: {
@@ -2713,10 +2722,10 @@ export function runConformanceTests(options: ConformanceTestOptions): void {
2713
2722
  })
2714
2723
  expect(postBefore.status).toBe(204)
2715
2724
 
2716
- // Wait for expiry time to pass
2717
- await sleep(1500)
2725
+ // Wait for expiry, polling HEAD until deleted
2726
+ await waitForDeletion(`${getBaseUrl()}${streamPath}`, 3000)
2718
2727
 
2719
- // Append should fail - stream no longer exists
2728
+ // Verify append fails - stream no longer exists
2720
2729
  const postAfter = await fetch(`${getBaseUrl()}${streamPath}`, {
2721
2730
  method: `POST`,
2722
2731
  headers: { "Content-Type": `text/plain` },
@@ -2742,8 +2751,8 @@ export function runConformanceTests(options: ConformanceTestOptions): void {
2742
2751
  })
2743
2752
  expect(createResponse.status).toBe(201)
2744
2753
 
2745
- // Wait for TTL to expire
2746
- await sleep(1500)
2754
+ // Wait for TTL to expire, polling HEAD until deleted
2755
+ await waitForDeletion(`${getBaseUrl()}${streamPath}`, 1000)
2747
2756
 
2748
2757
  // Recreate stream with different config - should succeed (201)
2749
2758
  const recreateResponse = await fetch(`${getBaseUrl()}${streamPath}`, {
@@ -2765,6 +2774,142 @@ export function runConformanceTests(options: ConformanceTestOptions): void {
2765
2774
  expect(body).toContain(`new data`)
2766
2775
  }
2767
2776
  )
2777
+
2778
+ test.concurrent(`should extend TTL on write (sliding window)`, async () => {
2779
+ const streamPath = uniquePath(`ttl-renew-write`)
2780
+
2781
+ // Create stream with 2 second TTL
2782
+ const createResponse = await fetch(`${getBaseUrl()}${streamPath}`, {
2783
+ method: `PUT`,
2784
+ headers: {
2785
+ "Content-Type": `text/plain`,
2786
+ "Stream-TTL": `2`,
2787
+ },
2788
+ })
2789
+ expect(createResponse.status).toBe(201)
2790
+
2791
+ // Wait 1.5s (past the midpoint)
2792
+ await sleep(1500)
2793
+
2794
+ // Append — this should reset TTL to 2s from now
2795
+ const appendResponse = await fetch(`${getBaseUrl()}${streamPath}`, {
2796
+ method: `POST`,
2797
+ headers: { "Content-Type": `text/plain` },
2798
+ body: `keep alive`,
2799
+ })
2800
+ expect(appendResponse.status).toBe(204)
2801
+
2802
+ // Wait another 1.5s — total 3s since creation, but only 1.5s since last write
2803
+ await sleep(1500)
2804
+
2805
+ // Stream should still be alive (TTL was reset by the write)
2806
+ const headResponse = await fetch(`${getBaseUrl()}${streamPath}`, {
2807
+ method: `HEAD`,
2808
+ })
2809
+ expect(headResponse.status).toBe(200)
2810
+ })
2811
+
2812
+ test.concurrent(`should extend TTL on read (sliding window)`, async () => {
2813
+ const streamPath = uniquePath(`ttl-renew-read`)
2814
+
2815
+ // Create stream with 2 second TTL and some data
2816
+ const createResponse = await fetch(`${getBaseUrl()}${streamPath}`, {
2817
+ method: `PUT`,
2818
+ headers: {
2819
+ "Content-Type": `text/plain`,
2820
+ "Stream-TTL": `2`,
2821
+ },
2822
+ body: `test data`,
2823
+ })
2824
+ expect(createResponse.status).toBe(201)
2825
+
2826
+ // Wait 1.5s
2827
+ await sleep(1500)
2828
+
2829
+ // Read — this should reset TTL to 2s from now
2830
+ const readResponse = await fetch(`${getBaseUrl()}${streamPath}`, {
2831
+ method: `GET`,
2832
+ })
2833
+ expect(readResponse.status).toBe(200)
2834
+
2835
+ // Wait another 1.5s — total 3s since creation, but only 1.5s since last read
2836
+ await sleep(1500)
2837
+
2838
+ // Stream should still be alive (TTL was reset by the read)
2839
+ const headResponse = await fetch(`${getBaseUrl()}${streamPath}`, {
2840
+ method: `HEAD`,
2841
+ })
2842
+ expect(headResponse.status).toBe(200)
2843
+ })
2844
+
2845
+ test.concurrent(`should NOT extend TTL on HEAD`, async () => {
2846
+ const streamPath = uniquePath(`ttl-no-renew-head`)
2847
+
2848
+ // Create stream with 2 second TTL
2849
+ const createResponse = await fetch(`${getBaseUrl()}${streamPath}`, {
2850
+ method: `PUT`,
2851
+ headers: {
2852
+ "Content-Type": `text/plain`,
2853
+ "Stream-TTL": `2`,
2854
+ },
2855
+ })
2856
+ expect(createResponse.status).toBe(201)
2857
+
2858
+ // Wait 1.5s
2859
+ await sleep(1500)
2860
+
2861
+ // HEAD — should NOT reset TTL
2862
+ const headMid = await fetch(`${getBaseUrl()}${streamPath}`, {
2863
+ method: `HEAD`,
2864
+ })
2865
+ expect(headMid.status).toBe(200)
2866
+
2867
+ // Stream should be expired (HEAD did not extend TTL)
2868
+ // Poll until deleted — original 2s TTL minus ~1.5s already waited
2869
+ await waitForDeletion(`${getBaseUrl()}${streamPath}`, 500)
2870
+
2871
+ // Verify with GET as well
2872
+ const getAfter = await fetch(`${getBaseUrl()}${streamPath}`, {
2873
+ method: `GET`,
2874
+ })
2875
+ expect(getAfter.status).toBe(404)
2876
+ })
2877
+
2878
+ test.concurrent(
2879
+ `should NOT extend Expires-At on read or write`,
2880
+ async () => {
2881
+ const streamPath = uniquePath(`expires-at-no-renew`)
2882
+
2883
+ // Create stream that expires in 4 seconds (wide window to tolerate clock skew)
2884
+ const expiresAt = new Date(Date.now() + 4000).toISOString()
2885
+ const createResponse = await fetch(`${getBaseUrl()}${streamPath}`, {
2886
+ method: `PUT`,
2887
+ headers: {
2888
+ "Content-Type": `text/plain`,
2889
+ "Stream-Expires-At": expiresAt,
2890
+ },
2891
+ body: `test data`,
2892
+ })
2893
+ expect(createResponse.status).toBe(201)
2894
+
2895
+ // Read at 2s — if this were TTL, it would extend; for Expires-At it should not
2896
+ await sleep(2000)
2897
+ const readResponse = await fetch(`${getBaseUrl()}${streamPath}`, {
2898
+ method: `GET`,
2899
+ })
2900
+ expect(readResponse.status).toBe(200)
2901
+
2902
+ // Stream should be expired despite recent read
2903
+ // Poll until deleted — original 4s Expires-At minus ~2s already waited
2904
+ await waitForDeletion(`${getBaseUrl()}${streamPath}`, 2000)
2905
+
2906
+ // Verify with GET as well
2907
+ const getAfter = await fetch(`${getBaseUrl()}${streamPath}`, {
2908
+ method: `GET`,
2909
+ })
2910
+ expect(getAfter.status).toBe(404)
2911
+ }
2912
+ )
2768
2913
  })
2769
2914
 
2770
2915
  // ============================================================================
@@ -4504,9 +4649,9 @@ export function runConformanceTests(options: ConformanceTestOptions): void {
4504
4649
  expect(finalResult).toEqual(expected)
4505
4650
  }
4506
4651
  ),
4507
- { numRuns: 20 } // Limit runs since each creates a stream
4652
+ { numRuns: 20, interruptAfterTimeLimit: 10_000 }
4508
4653
  )
4509
- })
4654
+ }, 30_000)
4510
4655
 
4511
4656
  test(`single byte values cover full range (0-255) with concurrent readers during write`, async () => {
4512
4657
  await fc.assert(
@@ -4559,9 +4704,9 @@ export function runConformanceTests(options: ConformanceTestOptions): void {
4559
4704
  expect(finalResult).toEqual(expected)
4560
4705
  }
4561
4706
  ),
4562
- { numRuns: 50 } // Test a good sample of byte values
4707
+ { numRuns: 50, interruptAfterTimeLimit: 10_000 }
4563
4708
  )
4564
- })
4709
+ }, 30_000)
4565
4710
  })
4566
4711
 
4567
4712
  describe(`Operation Sequence Properties`, () => {
@@ -4694,9 +4839,9 @@ export function runConformanceTests(options: ConformanceTestOptions): void {
4694
4839
  return true
4695
4840
  }
4696
4841
  ),
4697
- { numRuns: 15 }
4842
+ { numRuns: 15, interruptAfterTimeLimit: 30_000 }
4698
4843
  )
4699
- })
4844
+ }, 60_000)
4700
4845
 
4701
4846
  test(`offsets are always monotonically increasing`, async () => {
4702
4847
  await fc.assert(
@@ -7556,4 +7701,2333 @@ export function runConformanceTests(options: ConformanceTestOptions): void {
7556
7701
  })
7557
7702
  })
7558
7703
  })
7704
+
7705
+ // ============================================================================
7706
+ // Fork - Creation
7707
+ // ============================================================================
7708
+
7709
+ describe(`Fork - Creation`, () => {
7710
+ const STREAM_FORKED_FROM_HEADER = `Stream-Forked-From`
7711
+ const STREAM_FORK_OFFSET_HEADER = `Stream-Fork-Offset`
7712
+ const STREAM_CLOSED_HEADER_FORK = `Stream-Closed`
7713
+
7714
+ const uniqueId = () =>
7715
+ `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
7716
+
7717
+ test(`should fork at current head (default)`, async () => {
7718
+ const id = uniqueId()
7719
+ const sourcePath = `/v1/stream/fork-create-head-src-${id}`
7720
+ const forkPath = `/v1/stream/fork-create-head-fork-${id}`
7721
+
7722
+ // Create source with data
7723
+ const createRes = await fetch(`${getBaseUrl()}${sourcePath}`, {
7724
+ method: `PUT`,
7725
+ headers: { "Content-Type": `text/plain` },
7726
+ body: `source data`,
7727
+ })
7728
+ expect(createRes.status).toBe(201)
7729
+
7730
+ const sourceOffset = createRes.headers.get(STREAM_OFFSET_HEADER)
7731
+ expect(sourceOffset).toBeDefined()
7732
+
7733
+ // Fork without specifying offset → defaults to head
7734
+ const forkRes = await fetch(`${getBaseUrl()}${forkPath}`, {
7735
+ method: `PUT`,
7736
+ headers: {
7737
+ "Content-Type": `text/plain`,
7738
+ [STREAM_FORKED_FROM_HEADER]: sourcePath,
7739
+ },
7740
+ })
7741
+ expect(forkRes.status).toBe(201)
7742
+ })
7743
+
7744
+ test(`should fork at a specific offset`, async () => {
7745
+ const id = uniqueId()
7746
+ const sourcePath = `/v1/stream/fork-create-offset-src-${id}`
7747
+ const forkPath = `/v1/stream/fork-create-offset-fork-${id}`
7748
+
7749
+ // Create source
7750
+ const createRes = await fetch(`${getBaseUrl()}${sourcePath}`, {
7751
+ method: `PUT`,
7752
+ headers: { "Content-Type": `text/plain` },
7753
+ body: `first`,
7754
+ })
7755
+ expect(createRes.status).toBe(201)
7756
+ const midOffset = createRes.headers.get(STREAM_OFFSET_HEADER)!
7757
+
7758
+ // Append more data
7759
+ await fetch(`${getBaseUrl()}${sourcePath}`, {
7760
+ method: `POST`,
7761
+ headers: { "Content-Type": `text/plain` },
7762
+ body: `second`,
7763
+ })
7764
+
7765
+ // Fork at the mid offset (only inheriting "first")
7766
+ const forkRes = await fetch(`${getBaseUrl()}${forkPath}`, {
7767
+ method: `PUT`,
7768
+ headers: {
7769
+ "Content-Type": `text/plain`,
7770
+ [STREAM_FORKED_FROM_HEADER]: sourcePath,
7771
+ [STREAM_FORK_OFFSET_HEADER]: midOffset,
7772
+ },
7773
+ })
7774
+ expect(forkRes.status).toBe(201)
7775
+
7776
+ // Read fork → should only see "first"
7777
+ const readRes = await fetch(`${getBaseUrl()}${forkPath}?offset=-1`)
7778
+ expect(readRes.status).toBe(200)
7779
+ const body = await readRes.text()
7780
+ expect(body).toBe(`first`)
7781
+ })
7782
+
7783
+ test(`should fork at zero offset (empty inherited data)`, async () => {
7784
+ const id = uniqueId()
7785
+ const sourcePath = `/v1/stream/fork-create-zero-src-${id}`
7786
+ const forkPath = `/v1/stream/fork-create-zero-fork-${id}`
7787
+
7788
+ // Create source with data
7789
+ await fetch(`${getBaseUrl()}${sourcePath}`, {
7790
+ method: `PUT`,
7791
+ headers: { "Content-Type": `text/plain` },
7792
+ body: `source data`,
7793
+ })
7794
+
7795
+ // Fork at zero offset
7796
+ const zeroOffset = `0000000000000000_0000000000000000`
7797
+ const forkRes = await fetch(`${getBaseUrl()}${forkPath}`, {
7798
+ method: `PUT`,
7799
+ headers: {
7800
+ "Content-Type": `text/plain`,
7801
+ [STREAM_FORKED_FROM_HEADER]: sourcePath,
7802
+ [STREAM_FORK_OFFSET_HEADER]: zeroOffset,
7803
+ },
7804
+ })
7805
+ expect(forkRes.status).toBe(201)
7806
+
7807
+ // Read fork → should be empty (no inherited data)
7808
+ const readRes = await fetch(`${getBaseUrl()}${forkPath}?offset=-1`)
7809
+ expect(readRes.status).toBe(200)
7810
+ const body = await readRes.text()
7811
+ expect(body).toBe(``)
7812
+ expect(readRes.headers.get(STREAM_UP_TO_DATE_HEADER)).toBe(`true`)
7813
+ })
7814
+
7815
+ test(`should fork at head offset (all source data inherited)`, async () => {
7816
+ const id = uniqueId()
7817
+ const sourcePath = `/v1/stream/fork-create-all-src-${id}`
7818
+ const forkPath = `/v1/stream/fork-create-all-fork-${id}`
7819
+
7820
+ // Create source with data
7821
+ await fetch(`${getBaseUrl()}${sourcePath}`, {
7822
+ method: `PUT`,
7823
+ headers: { "Content-Type": `text/plain` },
7824
+ body: `chunk1`,
7825
+ })
7826
+ await fetch(`${getBaseUrl()}${sourcePath}`, {
7827
+ method: `POST`,
7828
+ headers: { "Content-Type": `text/plain` },
7829
+ body: `chunk2`,
7830
+ })
7831
+
7832
+ // Get head offset
7833
+ const headRes = await fetch(`${getBaseUrl()}${sourcePath}`, {
7834
+ method: `HEAD`,
7835
+ })
7836
+ const headOffset = headRes.headers.get(STREAM_OFFSET_HEADER)!
7837
+
7838
+ // Fork at head offset → all data inherited
7839
+ const forkRes = await fetch(`${getBaseUrl()}${forkPath}`, {
7840
+ method: `PUT`,
7841
+ headers: {
7842
+ "Content-Type": `text/plain`,
7843
+ [STREAM_FORKED_FROM_HEADER]: sourcePath,
7844
+ [STREAM_FORK_OFFSET_HEADER]: headOffset,
7845
+ },
7846
+ })
7847
+ expect(forkRes.status).toBe(201)
7848
+
7849
+ // Read fork → should see all source data
7850
+ const readRes = await fetch(`${getBaseUrl()}${forkPath}?offset=-1`)
7851
+ expect(readRes.status).toBe(200)
7852
+ const body = await readRes.text()
7853
+ expect(body).toBe(`chunk1chunk2`)
7854
+ })
7855
+
7856
+ test(`should return 404 when forking a nonexistent stream`, async () => {
7857
+ const id = uniqueId()
7858
+ const forkPath = `/v1/stream/fork-create-404-fork-${id}`
7859
+
7860
+ const forkRes = await fetch(`${getBaseUrl()}${forkPath}`, {
7861
+ method: `PUT`,
7862
+ headers: {
7863
+ "Content-Type": `text/plain`,
7864
+ [STREAM_FORKED_FROM_HEADER]: `/v1/stream/nonexistent-${id}`,
7865
+ },
7866
+ })
7867
+ expect(forkRes.status).toBe(404)
7868
+ })
7869
+
7870
+ test(`should return 400 when forking at offset beyond stream length`, async () => {
7871
+ const id = uniqueId()
7872
+ const sourcePath = `/v1/stream/fork-create-beyond-src-${id}`
7873
+ const forkPath = `/v1/stream/fork-create-beyond-fork-${id}`
7874
+
7875
+ // Create source with data
7876
+ await fetch(`${getBaseUrl()}${sourcePath}`, {
7877
+ method: `PUT`,
7878
+ headers: { "Content-Type": `text/plain` },
7879
+ body: `small data`,
7880
+ })
7881
+
7882
+ // Fork at an offset far beyond what exists
7883
+ const forkRes = await fetch(`${getBaseUrl()}${forkPath}`, {
7884
+ method: `PUT`,
7885
+ headers: {
7886
+ "Content-Type": `text/plain`,
7887
+ [STREAM_FORKED_FROM_HEADER]: sourcePath,
7888
+ [STREAM_FORK_OFFSET_HEADER]: `9999999999999999_9999999999999999`,
7889
+ },
7890
+ })
7891
+ expect(forkRes.status).toBe(400)
7892
+ })
7893
+
7894
+ test(`should return 409 when forking to path already in use with different config`, async () => {
7895
+ const id = uniqueId()
7896
+ const sourcePath = `/v1/stream/fork-create-conflict-src-${id}`
7897
+ const forkPath = `/v1/stream/fork-create-conflict-fork-${id}`
7898
+
7899
+ // Create source with text/plain
7900
+ await fetch(`${getBaseUrl()}${sourcePath}`, {
7901
+ method: `PUT`,
7902
+ headers: { "Content-Type": `text/plain` },
7903
+ body: `source`,
7904
+ })
7905
+
7906
+ // Create a regular stream at the fork path with application/json
7907
+ await fetch(`${getBaseUrl()}${forkPath}`, {
7908
+ method: `PUT`,
7909
+ headers: { "Content-Type": `application/json` },
7910
+ })
7911
+
7912
+ // Try to fork to the already-used path (different content type) → 409
7913
+ const forkRes = await fetch(`${getBaseUrl()}${forkPath}`, {
7914
+ method: `PUT`,
7915
+ headers: {
7916
+ "Content-Type": `text/plain`,
7917
+ [STREAM_FORKED_FROM_HEADER]: sourcePath,
7918
+ },
7919
+ })
7920
+ expect(forkRes.status).toBe(409)
7921
+ })
7922
+
7923
+ test(`should fork a closed stream — fork starts open`, async () => {
7924
+ const id = uniqueId()
7925
+ const sourcePath = `/v1/stream/fork-create-closed-src-${id}`
7926
+ const forkPath = `/v1/stream/fork-create-closed-fork-${id}`
7927
+
7928
+ // Create and close source
7929
+ await fetch(`${getBaseUrl()}${sourcePath}`, {
7930
+ method: `PUT`,
7931
+ headers: { "Content-Type": `text/plain` },
7932
+ body: `closed data`,
7933
+ })
7934
+ await fetch(`${getBaseUrl()}${sourcePath}`, {
7935
+ method: `POST`,
7936
+ headers: { [STREAM_CLOSED_HEADER_FORK]: `true` },
7937
+ })
7938
+
7939
+ // Fork the closed stream
7940
+ const forkRes = await fetch(`${getBaseUrl()}${forkPath}`, {
7941
+ method: `PUT`,
7942
+ headers: {
7943
+ "Content-Type": `text/plain`,
7944
+ [STREAM_FORKED_FROM_HEADER]: sourcePath,
7945
+ },
7946
+ })
7947
+ expect(forkRes.status).toBe(201)
7948
+ // Fork should NOT be closed
7949
+ expect(forkRes.headers.get(STREAM_CLOSED_HEADER_FORK)).toBeNull()
7950
+
7951
+ // Should be able to append to fork
7952
+ const appendRes = await fetch(`${getBaseUrl()}${forkPath}`, {
7953
+ method: `POST`,
7954
+ headers: { "Content-Type": `text/plain` },
7955
+ body: ` fork data`,
7956
+ })
7957
+ expect(appendRes.status).toBe(204)
7958
+ })
7959
+
7960
+ test(`should fork an empty stream`, async () => {
7961
+ const id = uniqueId()
7962
+ const sourcePath = `/v1/stream/fork-create-empty-src-${id}`
7963
+ const forkPath = `/v1/stream/fork-create-empty-fork-${id}`
7964
+
7965
+ // Create empty source
7966
+ await fetch(`${getBaseUrl()}${sourcePath}`, {
7967
+ method: `PUT`,
7968
+ headers: { "Content-Type": `text/plain` },
7969
+ })
7970
+
7971
+ // Fork it
7972
+ const forkRes = await fetch(`${getBaseUrl()}${forkPath}`, {
7973
+ method: `PUT`,
7974
+ headers: {
7975
+ "Content-Type": `text/plain`,
7976
+ [STREAM_FORKED_FROM_HEADER]: sourcePath,
7977
+ },
7978
+ })
7979
+ expect(forkRes.status).toBe(201)
7980
+
7981
+ // Read fork → empty
7982
+ const readRes = await fetch(`${getBaseUrl()}${forkPath}?offset=-1`)
7983
+ expect(readRes.status).toBe(200)
7984
+ const body = await readRes.text()
7985
+ expect(body).toBe(``)
7986
+ expect(readRes.headers.get(STREAM_UP_TO_DATE_HEADER)).toBe(`true`)
7987
+ })
7988
+
7989
+ test(`should fork preserving content-type when specified`, async () => {
7990
+ const id = uniqueId()
7991
+ const sourcePath = `/v1/stream/fork-create-ct-src-${id}`
7992
+ const forkPath = `/v1/stream/fork-create-ct-fork-${id}`
7993
+
7994
+ // Create source with application/json
7995
+ await fetch(`${getBaseUrl()}${sourcePath}`, {
7996
+ method: `PUT`,
7997
+ headers: { "Content-Type": `application/json` },
7998
+ body: `[{"key":"value"}]`,
7999
+ })
8000
+
8001
+ // Fork with matching content-type
8002
+ const forkRes = await fetch(`${getBaseUrl()}${forkPath}`, {
8003
+ method: `PUT`,
8004
+ headers: {
8005
+ "Content-Type": `application/json`,
8006
+ [STREAM_FORKED_FROM_HEADER]: sourcePath,
8007
+ },
8008
+ })
8009
+ expect(forkRes.status).toBe(201)
8010
+ expect(forkRes.headers.get(`content-type`)).toBe(`application/json`)
8011
+
8012
+ // HEAD on fork should also show the content type
8013
+ const headRes = await fetch(`${getBaseUrl()}${forkPath}`, {
8014
+ method: `HEAD`,
8015
+ })
8016
+ expect(headRes.headers.get(`content-type`)).toBe(`application/json`)
8017
+ })
8018
+ })
8019
+
8020
+ // ============================================================================
8021
+ // Fork - Reading
8022
+ // ============================================================================
8023
+
8024
+ describe(`Fork - Reading`, () => {
8025
+ const STREAM_FORKED_FROM_HEADER = `Stream-Forked-From`
8026
+ const STREAM_FORK_OFFSET_HEADER = `Stream-Fork-Offset`
8027
+
8028
+ const uniqueId = () =>
8029
+ `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
8030
+
8031
+ test(`should read entire fork (source + fork data)`, async () => {
8032
+ const id = uniqueId()
8033
+ const sourcePath = `/v1/stream/fork-read-entire-src-${id}`
8034
+ const forkPath = `/v1/stream/fork-read-entire-fork-${id}`
8035
+
8036
+ // Create source with data
8037
+ await fetch(`${getBaseUrl()}${sourcePath}`, {
8038
+ method: `PUT`,
8039
+ headers: { "Content-Type": `text/plain` },
8040
+ body: `source`,
8041
+ })
8042
+
8043
+ // Fork at head
8044
+ await fetch(`${getBaseUrl()}${forkPath}`, {
8045
+ method: `PUT`,
8046
+ headers: {
8047
+ "Content-Type": `text/plain`,
8048
+ [STREAM_FORKED_FROM_HEADER]: sourcePath,
8049
+ },
8050
+ })
8051
+
8052
+ // Append to fork
8053
+ await fetch(`${getBaseUrl()}${forkPath}`, {
8054
+ method: `POST`,
8055
+ headers: { "Content-Type": `text/plain` },
8056
+ body: ` fork`,
8057
+ })
8058
+
8059
+ // Read from beginning → should stitch source + fork data
8060
+ const readRes = await fetch(`${getBaseUrl()}${forkPath}?offset=-1`)
8061
+ expect(readRes.status).toBe(200)
8062
+ const body = await readRes.text()
8063
+ expect(body).toBe(`source fork`)
8064
+ })
8065
+
8066
+ test(`should read only inherited portion`, async () => {
8067
+ const id = uniqueId()
8068
+ const sourcePath = `/v1/stream/fork-read-inherited-src-${id}`
8069
+ const forkPath = `/v1/stream/fork-read-inherited-fork-${id}`
8070
+
8071
+ // Create source with data
8072
+ await fetch(`${getBaseUrl()}${sourcePath}`, {
8073
+ method: `PUT`,
8074
+ headers: { "Content-Type": `text/plain` },
8075
+ body: `inherited data`,
8076
+ })
8077
+
8078
+ // Fork at head
8079
+ await fetch(`${getBaseUrl()}${forkPath}`, {
8080
+ method: `PUT`,
8081
+ headers: {
8082
+ "Content-Type": `text/plain`,
8083
+ [STREAM_FORKED_FROM_HEADER]: sourcePath,
8084
+ },
8085
+ })
8086
+
8087
+ // Read fork from -1 (no fork-only data yet)
8088
+ const readRes = await fetch(`${getBaseUrl()}${forkPath}?offset=-1`)
8089
+ expect(readRes.status).toBe(200)
8090
+ const body = await readRes.text()
8091
+ expect(body).toBe(`inherited data`)
8092
+ expect(readRes.headers.get(STREAM_UP_TO_DATE_HEADER)).toBe(`true`)
8093
+ })
8094
+
8095
+ test(`should read only fork's own data (starting past fork offset)`, async () => {
8096
+ const id = uniqueId()
8097
+ const sourcePath = `/v1/stream/fork-read-own-src-${id}`
8098
+ const forkPath = `/v1/stream/fork-read-own-fork-${id}`
8099
+
8100
+ // Create source
8101
+ await fetch(`${getBaseUrl()}${sourcePath}`, {
8102
+ method: `PUT`,
8103
+ headers: { "Content-Type": `text/plain` },
8104
+ body: `source`,
8105
+ })
8106
+
8107
+ // Get source head offset
8108
+ const sourceHead = await fetch(`${getBaseUrl()}${sourcePath}`, {
8109
+ method: `HEAD`,
8110
+ })
8111
+ const forkOffset = sourceHead.headers.get(STREAM_OFFSET_HEADER)!
8112
+
8113
+ // Fork at head
8114
+ await fetch(`${getBaseUrl()}${forkPath}`, {
8115
+ method: `PUT`,
8116
+ headers: {
8117
+ "Content-Type": `text/plain`,
8118
+ [STREAM_FORKED_FROM_HEADER]: sourcePath,
8119
+ },
8120
+ })
8121
+
8122
+ // Append to fork
8123
+ await fetch(`${getBaseUrl()}${forkPath}`, {
8124
+ method: `POST`,
8125
+ headers: { "Content-Type": `text/plain` },
8126
+ body: `fork only`,
8127
+ })
8128
+
8129
+ // Read from fork offset → should only get fork's own data
8130
+ const readRes = await fetch(
8131
+ `${getBaseUrl()}${forkPath}?offset=${forkOffset}`
8132
+ )
8133
+ expect(readRes.status).toBe(200)
8134
+ const body = await readRes.text()
8135
+ expect(body).toBe(`fork only`)
8136
+ })
8137
+
8138
+ test(`should read across fork boundary`, async () => {
8139
+ const id = uniqueId()
8140
+ const sourcePath = `/v1/stream/fork-read-boundary-src-${id}`
8141
+ const forkPath = `/v1/stream/fork-read-boundary-fork-${id}`
8142
+
8143
+ // Create source with multiple chunks
8144
+ await fetch(`${getBaseUrl()}${sourcePath}`, {
8145
+ method: `PUT`,
8146
+ headers: { "Content-Type": `text/plain` },
8147
+ body: `A`,
8148
+ })
8149
+ await fetch(`${getBaseUrl()}${sourcePath}`, {
8150
+ method: `POST`,
8151
+ headers: { "Content-Type": `text/plain` },
8152
+ body: `B`,
8153
+ })
8154
+
8155
+ // Fork at head (inherits A and B)
8156
+ await fetch(`${getBaseUrl()}${forkPath}`, {
8157
+ method: `PUT`,
8158
+ headers: {
8159
+ "Content-Type": `text/plain`,
8160
+ [STREAM_FORKED_FROM_HEADER]: sourcePath,
8161
+ },
8162
+ })
8163
+
8164
+ // Append to fork
8165
+ await fetch(`${getBaseUrl()}${forkPath}`, {
8166
+ method: `POST`,
8167
+ headers: { "Content-Type": `text/plain` },
8168
+ body: `C`,
8169
+ })
8170
+
8171
+ // Read entire fork → should seamlessly stitch A + B + C
8172
+ const readRes = await fetch(`${getBaseUrl()}${forkPath}?offset=-1`)
8173
+ expect(readRes.status).toBe(200)
8174
+ const body = await readRes.text()
8175
+ expect(body).toBe(`ABC`)
8176
+ })
8177
+
8178
+ test(`should not show source appends after fork`, async () => {
8179
+ const id = uniqueId()
8180
+ const sourcePath = `/v1/stream/fork-read-isolation-src-${id}`
8181
+ const forkPath = `/v1/stream/fork-read-isolation-fork-${id}`
8182
+
8183
+ // Create source
8184
+ await fetch(`${getBaseUrl()}${sourcePath}`, {
8185
+ method: `PUT`,
8186
+ headers: { "Content-Type": `text/plain` },
8187
+ body: `before`,
8188
+ })
8189
+
8190
+ // Fork at head
8191
+ await fetch(`${getBaseUrl()}${forkPath}`, {
8192
+ method: `PUT`,
8193
+ headers: {
8194
+ "Content-Type": `text/plain`,
8195
+ [STREAM_FORKED_FROM_HEADER]: sourcePath,
8196
+ },
8197
+ })
8198
+
8199
+ // Append to SOURCE after fork
8200
+ await fetch(`${getBaseUrl()}${sourcePath}`, {
8201
+ method: `POST`,
8202
+ headers: { "Content-Type": `text/plain` },
8203
+ body: ` after`,
8204
+ })
8205
+
8206
+ // Read fork → should NOT see "after"
8207
+ const readRes = await fetch(`${getBaseUrl()}${forkPath}?offset=-1`)
8208
+ expect(readRes.status).toBe(200)
8209
+ const body = await readRes.text()
8210
+ expect(body).toBe(`before`)
8211
+ })
8212
+
8213
+ test(`should NOT include fork headers on HEAD/GET/PUT responses (forks are transparent)`, async () => {
8214
+ const id = uniqueId()
8215
+ const sourcePath = `/v1/stream/fork-read-headers-src-${id}`
8216
+ const forkPath = `/v1/stream/fork-read-headers-fork-${id}`
8217
+
8218
+ // Create source
8219
+ await fetch(`${getBaseUrl()}${sourcePath}`, {
8220
+ method: `PUT`,
8221
+ headers: { "Content-Type": `text/plain` },
8222
+ body: `data`,
8223
+ })
8224
+
8225
+ // Fork
8226
+ const putRes = await fetch(`${getBaseUrl()}${forkPath}`, {
8227
+ method: `PUT`,
8228
+ headers: {
8229
+ "Content-Type": `text/plain`,
8230
+ [STREAM_FORKED_FROM_HEADER]: sourcePath,
8231
+ },
8232
+ })
8233
+ expect(putRes.headers.get(STREAM_FORKED_FROM_HEADER)).toBeNull()
8234
+ expect(putRes.headers.get(STREAM_FORK_OFFSET_HEADER)).toBeNull()
8235
+
8236
+ // HEAD on fork
8237
+ const headRes = await fetch(`${getBaseUrl()}${forkPath}`, {
8238
+ method: `HEAD`,
8239
+ })
8240
+ expect(headRes.headers.get(STREAM_FORKED_FROM_HEADER)).toBeNull()
8241
+ expect(headRes.headers.get(STREAM_FORK_OFFSET_HEADER)).toBeNull()
8242
+
8243
+ // GET on fork
8244
+ const getRes = await fetch(`${getBaseUrl()}${forkPath}?offset=-1`)
8245
+ await getRes.text() // consume body
8246
+ expect(getRes.headers.get(STREAM_FORKED_FROM_HEADER)).toBeNull()
8247
+ expect(getRes.headers.get(STREAM_FORK_OFFSET_HEADER)).toBeNull()
8248
+ })
8249
+ })
8250
+
8251
+ // ============================================================================
8252
+ // Fork - Appending
8253
+ // ============================================================================
8254
+
8255
+ describe(`Fork - Appending`, () => {
8256
+ const STREAM_FORKED_FROM_HEADER = `Stream-Forked-From`
8257
+ const STREAM_CLOSED_HEADER_FORK = `Stream-Closed`
8258
+
8259
+ const uniqueId = () =>
8260
+ `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
8261
+
8262
+ test(`should append to a fork`, async () => {
8263
+ const id = uniqueId()
8264
+ const sourcePath = `/v1/stream/fork-append-src-${id}`
8265
+ const forkPath = `/v1/stream/fork-append-fork-${id}`
8266
+
8267
+ // Create source
8268
+ await fetch(`${getBaseUrl()}${sourcePath}`, {
8269
+ method: `PUT`,
8270
+ headers: { "Content-Type": `text/plain` },
8271
+ body: `source`,
8272
+ })
8273
+
8274
+ // Fork
8275
+ await fetch(`${getBaseUrl()}${forkPath}`, {
8276
+ method: `PUT`,
8277
+ headers: {
8278
+ "Content-Type": `text/plain`,
8279
+ [STREAM_FORKED_FROM_HEADER]: sourcePath,
8280
+ },
8281
+ })
8282
+
8283
+ // Append to fork
8284
+ const appendRes = await fetch(`${getBaseUrl()}${forkPath}`, {
8285
+ method: `POST`,
8286
+ headers: { "Content-Type": `text/plain` },
8287
+ body: ` appended`,
8288
+ })
8289
+ expect(appendRes.status).toBe(204)
8290
+ expect(appendRes.headers.get(STREAM_OFFSET_HEADER)).toBeDefined()
8291
+
8292
+ // Read fork
8293
+ const readRes = await fetch(`${getBaseUrl()}${forkPath}?offset=-1`)
8294
+ const body = await readRes.text()
8295
+ expect(body).toBe(`source appended`)
8296
+ })
8297
+
8298
+ test(`should support idempotent producer on fork`, async () => {
8299
+ const id = uniqueId()
8300
+ const sourcePath = `/v1/stream/fork-append-idempotent-src-${id}`
8301
+ const forkPath = `/v1/stream/fork-append-idempotent-fork-${id}`
8302
+
8303
+ // Create source
8304
+ await fetch(`${getBaseUrl()}${sourcePath}`, {
8305
+ method: `PUT`,
8306
+ headers: { "Content-Type": `text/plain` },
8307
+ body: `source`,
8308
+ })
8309
+
8310
+ // Fork
8311
+ await fetch(`${getBaseUrl()}${forkPath}`, {
8312
+ method: `PUT`,
8313
+ headers: {
8314
+ "Content-Type": `text/plain`,
8315
+ [STREAM_FORKED_FROM_HEADER]: sourcePath,
8316
+ },
8317
+ })
8318
+
8319
+ // Append with producer headers
8320
+ const append1 = await fetch(`${getBaseUrl()}${forkPath}`, {
8321
+ method: `POST`,
8322
+ headers: {
8323
+ "Content-Type": `text/plain`,
8324
+ "Producer-Id": `fork-producer-${id}`,
8325
+ "Producer-Epoch": `0`,
8326
+ "Producer-Seq": `0`,
8327
+ },
8328
+ body: `msg1`,
8329
+ })
8330
+ expect(append1.status).toBe(200)
8331
+
8332
+ // Retry with same producer headers → deduplicated
8333
+ const append1Retry = await fetch(`${getBaseUrl()}${forkPath}`, {
8334
+ method: `POST`,
8335
+ headers: {
8336
+ "Content-Type": `text/plain`,
8337
+ "Producer-Id": `fork-producer-${id}`,
8338
+ "Producer-Epoch": `0`,
8339
+ "Producer-Seq": `0`,
8340
+ },
8341
+ body: `msg1`,
8342
+ })
8343
+ expect(append1Retry.status).toBe(204) // Duplicate → 204
8344
+
8345
+ // Read fork → only one copy of msg1
8346
+ const readRes = await fetch(`${getBaseUrl()}${forkPath}?offset=-1`)
8347
+ const body = await readRes.text()
8348
+ expect(body).toBe(`sourcemsg1`)
8349
+ })
8350
+
8351
+ test(`should close forked stream independently`, async () => {
8352
+ const id = uniqueId()
8353
+ const sourcePath = `/v1/stream/fork-append-close-src-${id}`
8354
+ const forkPath = `/v1/stream/fork-append-close-fork-${id}`
8355
+
8356
+ // Create source
8357
+ await fetch(`${getBaseUrl()}${sourcePath}`, {
8358
+ method: `PUT`,
8359
+ headers: { "Content-Type": `text/plain` },
8360
+ body: `source`,
8361
+ })
8362
+
8363
+ // Fork
8364
+ await fetch(`${getBaseUrl()}${forkPath}`, {
8365
+ method: `PUT`,
8366
+ headers: {
8367
+ "Content-Type": `text/plain`,
8368
+ [STREAM_FORKED_FROM_HEADER]: sourcePath,
8369
+ },
8370
+ })
8371
+
8372
+ // Append then close fork
8373
+ await fetch(`${getBaseUrl()}${forkPath}`, {
8374
+ method: `POST`,
8375
+ headers: { "Content-Type": `text/plain` },
8376
+ body: ` final`,
8377
+ })
8378
+ const closeRes = await fetch(`${getBaseUrl()}${forkPath}`, {
8379
+ method: `POST`,
8380
+ headers: { [STREAM_CLOSED_HEADER_FORK]: `true` },
8381
+ })
8382
+ expect([200, 204]).toContain(closeRes.status)
8383
+ expect(closeRes.headers.get(STREAM_CLOSED_HEADER_FORK)).toBe(`true`)
8384
+
8385
+ // Source should still be open
8386
+ const sourceHead = await fetch(`${getBaseUrl()}${sourcePath}`, {
8387
+ method: `HEAD`,
8388
+ })
8389
+ expect(sourceHead.headers.get(STREAM_CLOSED_HEADER_FORK)).toBeNull()
8390
+ })
8391
+
8392
+ test(`should not affect fork when source is closed`, async () => {
8393
+ const id = uniqueId()
8394
+ const sourcePath = `/v1/stream/fork-append-src-close-src-${id}`
8395
+ const forkPath = `/v1/stream/fork-append-src-close-fork-${id}`
8396
+
8397
+ // Create source
8398
+ await fetch(`${getBaseUrl()}${sourcePath}`, {
8399
+ method: `PUT`,
8400
+ headers: { "Content-Type": `text/plain` },
8401
+ body: `source`,
8402
+ })
8403
+
8404
+ // Fork
8405
+ await fetch(`${getBaseUrl()}${forkPath}`, {
8406
+ method: `PUT`,
8407
+ headers: {
8408
+ "Content-Type": `text/plain`,
8409
+ [STREAM_FORKED_FROM_HEADER]: sourcePath,
8410
+ },
8411
+ })
8412
+
8413
+ // Close source
8414
+ await fetch(`${getBaseUrl()}${sourcePath}`, {
8415
+ method: `POST`,
8416
+ headers: { [STREAM_CLOSED_HEADER_FORK]: `true` },
8417
+ })
8418
+
8419
+ // Fork should still accept appends
8420
+ const appendRes = await fetch(`${getBaseUrl()}${forkPath}`, {
8421
+ method: `POST`,
8422
+ headers: { "Content-Type": `text/plain` },
8423
+ body: ` fork data`,
8424
+ })
8425
+ expect(appendRes.status).toBe(204)
8426
+
8427
+ // Fork should not be closed
8428
+ const forkHead = await fetch(`${getBaseUrl()}${forkPath}`, {
8429
+ method: `HEAD`,
8430
+ })
8431
+ expect(forkHead.headers.get(STREAM_CLOSED_HEADER_FORK)).toBeNull()
8432
+ })
8433
+
8434
+ test(`should append to source after fork — source independent`, async () => {
8435
+ const id = uniqueId()
8436
+ const sourcePath = `/v1/stream/fork-append-src-indep-src-${id}`
8437
+ const forkPath = `/v1/stream/fork-append-src-indep-fork-${id}`
8438
+
8439
+ // Create source
8440
+ await fetch(`${getBaseUrl()}${sourcePath}`, {
8441
+ method: `PUT`,
8442
+ headers: { "Content-Type": `text/plain` },
8443
+ body: `initial`,
8444
+ })
8445
+
8446
+ // Fork
8447
+ await fetch(`${getBaseUrl()}${forkPath}`, {
8448
+ method: `PUT`,
8449
+ headers: {
8450
+ "Content-Type": `text/plain`,
8451
+ [STREAM_FORKED_FROM_HEADER]: sourcePath,
8452
+ },
8453
+ })
8454
+
8455
+ // Append to source
8456
+ const appendRes = await fetch(`${getBaseUrl()}${sourcePath}`, {
8457
+ method: `POST`,
8458
+ headers: { "Content-Type": `text/plain` },
8459
+ body: ` extra`,
8460
+ })
8461
+ expect(appendRes.status).toBe(204)
8462
+
8463
+ // Source should have all data
8464
+ const sourceRead = await fetch(`${getBaseUrl()}${sourcePath}?offset=-1`)
8465
+ const sourceBody = await sourceRead.text()
8466
+ expect(sourceBody).toBe(`initial extra`)
8467
+
8468
+ // Fork should NOT see the extra data
8469
+ const forkRead = await fetch(`${getBaseUrl()}${forkPath}?offset=-1`)
8470
+ const forkBody = await forkRead.text()
8471
+ expect(forkBody).toBe(`initial`)
8472
+ })
8473
+ })
8474
+
8475
+ // ============================================================================
8476
+ // Fork - Recursive
8477
+ // ============================================================================
8478
+
8479
+ describe(`Fork - Recursive`, () => {
8480
+ const STREAM_FORKED_FROM_HEADER = `Stream-Forked-From`
8481
+
8482
+ const uniqueId = () =>
8483
+ `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
8484
+
8485
+ test(`should create a three-level fork chain`, async () => {
8486
+ const id = uniqueId()
8487
+ const level0 = `/v1/stream/fork-recursive-l0-${id}`
8488
+ const level1 = `/v1/stream/fork-recursive-l1-${id}`
8489
+ const level2 = `/v1/stream/fork-recursive-l2-${id}`
8490
+
8491
+ // Create level 0 (root)
8492
+ await fetch(`${getBaseUrl()}${level0}`, {
8493
+ method: `PUT`,
8494
+ headers: { "Content-Type": `text/plain` },
8495
+ body: `L0`,
8496
+ })
8497
+
8498
+ // Fork level 1 from level 0
8499
+ const fork1Res = await fetch(`${getBaseUrl()}${level1}`, {
8500
+ method: `PUT`,
8501
+ headers: {
8502
+ "Content-Type": `text/plain`,
8503
+ [STREAM_FORKED_FROM_HEADER]: level0,
8504
+ },
8505
+ })
8506
+ expect(fork1Res.status).toBe(201)
8507
+
8508
+ // Fork level 2 from level 1
8509
+ const fork2Res = await fetch(`${getBaseUrl()}${level2}`, {
8510
+ method: `PUT`,
8511
+ headers: {
8512
+ "Content-Type": `text/plain`,
8513
+ [STREAM_FORKED_FROM_HEADER]: level1,
8514
+ },
8515
+ })
8516
+ expect(fork2Res.status).toBe(201)
8517
+ })
8518
+
8519
+ test(`should fork at mid-point of inherited data`, async () => {
8520
+ const id = uniqueId()
8521
+ const level0 = `/v1/stream/fork-recursive-mid-l0-${id}`
8522
+ const level1 = `/v1/stream/fork-recursive-mid-l1-${id}`
8523
+ const level2 = `/v1/stream/fork-recursive-mid-l2-${id}`
8524
+
8525
+ // Create level 0 with data
8526
+ await fetch(`${getBaseUrl()}${level0}`, {
8527
+ method: `PUT`,
8528
+ headers: { "Content-Type": `text/plain` },
8529
+ body: `A`,
8530
+ })
8531
+ await fetch(`${getBaseUrl()}${level0}`, {
8532
+ method: `POST`,
8533
+ headers: { "Content-Type": `text/plain` },
8534
+ body: `B`,
8535
+ })
8536
+
8537
+ // Fork level 1 at head of level 0 (inherits A+B)
8538
+ await fetch(`${getBaseUrl()}${level1}`, {
8539
+ method: `PUT`,
8540
+ headers: {
8541
+ "Content-Type": `text/plain`,
8542
+ [STREAM_FORKED_FROM_HEADER]: level0,
8543
+ },
8544
+ })
8545
+
8546
+ // Append to level 1
8547
+ await fetch(`${getBaseUrl()}${level1}`, {
8548
+ method: `POST`,
8549
+ headers: { "Content-Type": `text/plain` },
8550
+ body: `C`,
8551
+ })
8552
+
8553
+ // Get the offset after inheriting A+B (before C) from level 1
8554
+ // This is the fork offset of level 1
8555
+ const l1Head = await fetch(`${getBaseUrl()}${level1}`, {
8556
+ method: `HEAD`,
8557
+ })
8558
+ // Verify HEAD returns expected offset
8559
+ expect(l1Head.headers.get(STREAM_OFFSET_HEADER)).toBeDefined()
8560
+
8561
+ // Fork level 2 from level 1 at head (inherits A+B+C)
8562
+ const fork2Res = await fetch(`${getBaseUrl()}${level2}`, {
8563
+ method: `PUT`,
8564
+ headers: {
8565
+ "Content-Type": `text/plain`,
8566
+ [STREAM_FORKED_FROM_HEADER]: level1,
8567
+ },
8568
+ })
8569
+ expect(fork2Res.status).toBe(201)
8570
+
8571
+ // Read level 2 → should see A+B+C
8572
+ const readRes = await fetch(`${getBaseUrl()}${level2}?offset=-1`)
8573
+ const body = await readRes.text()
8574
+ expect(body).toBe(`ABC`)
8575
+ })
8576
+
8577
+ test(`should read correctly across three levels`, async () => {
8578
+ const id = uniqueId()
8579
+ const level0 = `/v1/stream/fork-recursive-read-l0-${id}`
8580
+ const level1 = `/v1/stream/fork-recursive-read-l1-${id}`
8581
+ const level2 = `/v1/stream/fork-recursive-read-l2-${id}`
8582
+
8583
+ // Level 0: A
8584
+ await fetch(`${getBaseUrl()}${level0}`, {
8585
+ method: `PUT`,
8586
+ headers: { "Content-Type": `text/plain` },
8587
+ body: `A`,
8588
+ })
8589
+
8590
+ // Level 1: fork of level 0, then append B
8591
+ await fetch(`${getBaseUrl()}${level1}`, {
8592
+ method: `PUT`,
8593
+ headers: {
8594
+ "Content-Type": `text/plain`,
8595
+ [STREAM_FORKED_FROM_HEADER]: level0,
8596
+ },
8597
+ })
8598
+ await fetch(`${getBaseUrl()}${level1}`, {
8599
+ method: `POST`,
8600
+ headers: { "Content-Type": `text/plain` },
8601
+ body: `B`,
8602
+ })
8603
+
8604
+ // Level 2: fork of level 1, then append C
8605
+ await fetch(`${getBaseUrl()}${level2}`, {
8606
+ method: `PUT`,
8607
+ headers: {
8608
+ "Content-Type": `text/plain`,
8609
+ [STREAM_FORKED_FROM_HEADER]: level1,
8610
+ },
8611
+ })
8612
+ await fetch(`${getBaseUrl()}${level2}`, {
8613
+ method: `POST`,
8614
+ headers: { "Content-Type": `text/plain` },
8615
+ body: `C`,
8616
+ })
8617
+
8618
+ // Read each level
8619
+ const r0 = await (
8620
+ await fetch(`${getBaseUrl()}${level0}?offset=-1`)
8621
+ ).text()
8622
+ expect(r0).toBe(`A`)
8623
+
8624
+ const r1 = await (
8625
+ await fetch(`${getBaseUrl()}${level1}?offset=-1`)
8626
+ ).text()
8627
+ expect(r1).toBe(`AB`)
8628
+
8629
+ const r2 = await (
8630
+ await fetch(`${getBaseUrl()}${level2}?offset=-1`)
8631
+ ).text()
8632
+ expect(r2).toBe(`ABC`)
8633
+ })
8634
+
8635
+ test(`should append at each level independently`, async () => {
8636
+ const id = uniqueId()
8637
+ const level0 = `/v1/stream/fork-recursive-indep-l0-${id}`
8638
+ const level1 = `/v1/stream/fork-recursive-indep-l1-${id}`
8639
+ const level2 = `/v1/stream/fork-recursive-indep-l2-${id}`
8640
+
8641
+ // Level 0: X
8642
+ await fetch(`${getBaseUrl()}${level0}`, {
8643
+ method: `PUT`,
8644
+ headers: { "Content-Type": `text/plain` },
8645
+ body: `X`,
8646
+ })
8647
+
8648
+ // Level 1: fork, append Y
8649
+ await fetch(`${getBaseUrl()}${level1}`, {
8650
+ method: `PUT`,
8651
+ headers: {
8652
+ "Content-Type": `text/plain`,
8653
+ [STREAM_FORKED_FROM_HEADER]: level0,
8654
+ },
8655
+ })
8656
+ await fetch(`${getBaseUrl()}${level1}`, {
8657
+ method: `POST`,
8658
+ headers: { "Content-Type": `text/plain` },
8659
+ body: `Y`,
8660
+ })
8661
+
8662
+ // Level 2: fork of level 1, append Z
8663
+ await fetch(`${getBaseUrl()}${level2}`, {
8664
+ method: `PUT`,
8665
+ headers: {
8666
+ "Content-Type": `text/plain`,
8667
+ [STREAM_FORKED_FROM_HEADER]: level1,
8668
+ },
8669
+ })
8670
+ await fetch(`${getBaseUrl()}${level2}`, {
8671
+ method: `POST`,
8672
+ headers: { "Content-Type": `text/plain` },
8673
+ body: `Z`,
8674
+ })
8675
+
8676
+ // Now append more to level 0 → should not affect levels 1 or 2
8677
+ await fetch(`${getBaseUrl()}${level0}`, {
8678
+ method: `POST`,
8679
+ headers: { "Content-Type": `text/plain` },
8680
+ body: `0`,
8681
+ })
8682
+
8683
+ // Append more to level 1 → should not affect level 2
8684
+ await fetch(`${getBaseUrl()}${level1}`, {
8685
+ method: `POST`,
8686
+ headers: { "Content-Type": `text/plain` },
8687
+ body: `1`,
8688
+ })
8689
+
8690
+ const r0 = await (
8691
+ await fetch(`${getBaseUrl()}${level0}?offset=-1`)
8692
+ ).text()
8693
+ expect(r0).toBe(`X0`)
8694
+
8695
+ const r1 = await (
8696
+ await fetch(`${getBaseUrl()}${level1}?offset=-1`)
8697
+ ).text()
8698
+ expect(r1).toBe(`XY1`)
8699
+
8700
+ const r2 = await (
8701
+ await fetch(`${getBaseUrl()}${level2}?offset=-1`)
8702
+ ).text()
8703
+ expect(r2).toBe(`XYZ`)
8704
+ })
8705
+ })
8706
+
8707
+ // ============================================================================
8708
+ // Fork - Live Modes
8709
+ // ============================================================================
8710
+
8711
+ describe(`Fork - Live Modes`, () => {
8712
+ const STREAM_FORKED_FROM_HEADER = `Stream-Forked-From`
8713
+
8714
+ const uniqueId = () =>
8715
+ `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
8716
+
8717
+ test(`should return inherited data immediately on long-poll`, async () => {
8718
+ const id = uniqueId()
8719
+ const sourcePath = `/v1/stream/fork-live-inherited-src-${id}`
8720
+ const forkPath = `/v1/stream/fork-live-inherited-fork-${id}`
8721
+
8722
+ // Create source with data
8723
+ await fetch(`${getBaseUrl()}${sourcePath}`, {
8724
+ method: `PUT`,
8725
+ headers: { "Content-Type": `text/plain` },
8726
+ body: `inherited data`,
8727
+ })
8728
+
8729
+ // Fork at head
8730
+ await fetch(`${getBaseUrl()}${forkPath}`, {
8731
+ method: `PUT`,
8732
+ headers: {
8733
+ "Content-Type": `text/plain`,
8734
+ [STREAM_FORKED_FROM_HEADER]: sourcePath,
8735
+ },
8736
+ })
8737
+
8738
+ // Long-poll at -1 → should immediately return inherited data
8739
+ const controller = new AbortController()
8740
+ const timeoutId = setTimeout(() => controller.abort(), 3000)
8741
+
8742
+ try {
8743
+ const response = await fetch(
8744
+ `${getBaseUrl()}${forkPath}?offset=-1&live=long-poll`,
8745
+ { method: `GET`, signal: controller.signal }
8746
+ )
8747
+ clearTimeout(timeoutId)
8748
+
8749
+ expect(response.status).toBe(200)
8750
+ const body = await response.text()
8751
+ expect(body).toBe(`inherited data`)
8752
+ expect(response.headers.get(STREAM_UP_TO_DATE_HEADER)).toBe(`true`)
8753
+ } catch (e) {
8754
+ clearTimeout(timeoutId)
8755
+ if (!(e instanceof Error && e.name === `AbortError`)) throw e
8756
+ // Should not reach here — data should be returned immediately
8757
+ expect(true).toBe(false)
8758
+ }
8759
+ })
8760
+
8761
+ test(
8762
+ `should wait for fork appends, not source appends, on long-poll at tail`,
8763
+ async () => {
8764
+ const id = uniqueId()
8765
+ const sourcePath = `/v1/stream/fork-live-tail-src-${id}`
8766
+ const forkPath = `/v1/stream/fork-live-tail-fork-${id}`
8767
+
8768
+ // Create source with data
8769
+ await fetch(`${getBaseUrl()}${sourcePath}`, {
8770
+ method: `PUT`,
8771
+ headers: { "Content-Type": `text/plain` },
8772
+ body: `source`,
8773
+ })
8774
+
8775
+ // Fork at head
8776
+ await fetch(`${getBaseUrl()}${forkPath}`, {
8777
+ method: `PUT`,
8778
+ headers: {
8779
+ "Content-Type": `text/plain`,
8780
+ [STREAM_FORKED_FROM_HEADER]: sourcePath,
8781
+ },
8782
+ })
8783
+
8784
+ // Get the fork's current head offset
8785
+ const forkHead = await fetch(`${getBaseUrl()}${forkPath}`, {
8786
+ method: `GET`,
8787
+ })
8788
+ await forkHead.text() // consume body
8789
+ const forkOffset = forkHead.headers.get(STREAM_OFFSET_HEADER)!
8790
+
8791
+ // Start long-poll at fork tail
8792
+ const longPollPromise = fetch(
8793
+ `${getBaseUrl()}${forkPath}?offset=${forkOffset}&live=long-poll`,
8794
+ { method: `GET` }
8795
+ )
8796
+
8797
+ // Give the long-poll a moment to register
8798
+ await new Promise((r) => setTimeout(r, 50))
8799
+
8800
+ // Append to source (should NOT wake up fork long-poll)
8801
+ await fetch(`${getBaseUrl()}${sourcePath}`, {
8802
+ method: `POST`,
8803
+ headers: { "Content-Type": `text/plain` },
8804
+ body: ` source extra`,
8805
+ })
8806
+
8807
+ // Now append to fork (should wake up the long-poll)
8808
+ await fetch(`${getBaseUrl()}${forkPath}`, {
8809
+ method: `POST`,
8810
+ headers: { "Content-Type": `text/plain` },
8811
+ body: ` fork new`,
8812
+ })
8813
+
8814
+ const response = await longPollPromise
8815
+ expect(response.status).toBe(200)
8816
+ const body = await response.text()
8817
+ expect(body).toBe(` fork new`)
8818
+ },
8819
+ getLongPollTestTimeoutMs()
8820
+ )
8821
+
8822
+ test(`should stream fork data via SSE`, async () => {
8823
+ const id = uniqueId()
8824
+ const sourcePath = `/v1/stream/fork-live-sse-src-${id}`
8825
+ const forkPath = `/v1/stream/fork-live-sse-fork-${id}`
8826
+
8827
+ // Create source with data
8828
+ await fetch(`${getBaseUrl()}${sourcePath}`, {
8829
+ method: `PUT`,
8830
+ headers: { "Content-Type": `text/plain` },
8831
+ body: `inherited`,
8832
+ })
8833
+
8834
+ // Fork and append
8835
+ await fetch(`${getBaseUrl()}${forkPath}`, {
8836
+ method: `PUT`,
8837
+ headers: {
8838
+ "Content-Type": `text/plain`,
8839
+ [STREAM_FORKED_FROM_HEADER]: sourcePath,
8840
+ },
8841
+ })
8842
+ await fetch(`${getBaseUrl()}${forkPath}`, {
8843
+ method: `POST`,
8844
+ headers: { "Content-Type": `text/plain` },
8845
+ body: ` forked`,
8846
+ })
8847
+
8848
+ // SSE from beginning
8849
+ const { response, received } = await fetchSSE(
8850
+ `${getBaseUrl()}${forkPath}?offset=-1&live=sse`,
8851
+ { untilContent: `forked`, timeoutMs: 5000, maxChunks: 20 }
8852
+ )
8853
+
8854
+ expect(response.status).toBe(200)
8855
+ expect(received).toContain(`inherited`)
8856
+ expect(received).toContain(`forked`)
8857
+ })
8858
+
8859
+ test(`should handle long-poll handover at fork offset`, async () => {
8860
+ const id = uniqueId()
8861
+ const sourcePath = `/v1/stream/fork-live-handover-src-${id}`
8862
+ const forkPath = `/v1/stream/fork-live-handover-fork-${id}`
8863
+
8864
+ // Create source
8865
+ await fetch(`${getBaseUrl()}${sourcePath}`, {
8866
+ method: `PUT`,
8867
+ headers: { "Content-Type": `text/plain` },
8868
+ body: `source data`,
8869
+ })
8870
+
8871
+ // Fork
8872
+ await fetch(`${getBaseUrl()}${forkPath}`, {
8873
+ method: `PUT`,
8874
+ headers: {
8875
+ "Content-Type": `text/plain`,
8876
+ [STREAM_FORKED_FROM_HEADER]: sourcePath,
8877
+ },
8878
+ })
8879
+
8880
+ // Read inherited data to get fork offset
8881
+ const readRes = await fetch(
8882
+ `${getBaseUrl()}${forkPath}?offset=-1&live=long-poll`
8883
+ )
8884
+ expect(readRes.status).toBe(200)
8885
+ const firstBody = await readRes.text()
8886
+ expect(firstBody).toBe(`source data`)
8887
+ const nextOffset = readRes.headers.get(STREAM_OFFSET_HEADER)!
8888
+
8889
+ // Append to fork
8890
+ await fetch(`${getBaseUrl()}${forkPath}`, {
8891
+ method: `POST`,
8892
+ headers: { "Content-Type": `text/plain` },
8893
+ body: ` fork append`,
8894
+ })
8895
+
8896
+ // Continue reading from next offset → should get fork data
8897
+ // Use catch-up mode since data is already appended
8898
+ const readRes2 = await fetch(
8899
+ `${getBaseUrl()}${forkPath}?offset=${nextOffset}`
8900
+ )
8901
+ expect(readRes2.status).toBe(200)
8902
+ const secondBody = await readRes2.text()
8903
+ expect(secondBody).toBe(` fork append`)
8904
+ })
8905
+ })
8906
+
8907
+ // ============================================================================
8908
+ // Fork - Deletion and Lifecycle
8909
+ // ============================================================================
8910
+
8911
+ describe(`Fork - Deletion and Lifecycle`, () => {
8912
+ const STREAM_FORKED_FROM_HEADER = `Stream-Forked-From`
8913
+
8914
+ const uniqueId = () =>
8915
+ `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
8916
+
8917
+ const waitForStatus = async (
8918
+ url: string,
8919
+ expectedStatus: number,
8920
+ timeoutMs: number = 5000
8921
+ ) => {
8922
+ await vi.waitFor(
8923
+ async () => {
8924
+ const res = await fetch(url, { method: `HEAD` })
8925
+ expect(res.status).toBe(expectedStatus)
8926
+ },
8927
+ { timeout: timeoutMs, interval: 200 }
8928
+ )
8929
+ }
8930
+
8931
+ test(`should delete fork without affecting source`, async () => {
8932
+ const id = uniqueId()
8933
+ const sourcePath = `/v1/stream/fork-del-src-unaffected-src-${id}`
8934
+ const forkPath = `/v1/stream/fork-del-src-unaffected-fork-${id}`
8935
+
8936
+ // Create source
8937
+ await fetch(`${getBaseUrl()}${sourcePath}`, {
8938
+ method: `PUT`,
8939
+ headers: { "Content-Type": `text/plain` },
8940
+ body: `source data`,
8941
+ })
8942
+
8943
+ // Fork
8944
+ await fetch(`${getBaseUrl()}${forkPath}`, {
8945
+ method: `PUT`,
8946
+ headers: {
8947
+ "Content-Type": `text/plain`,
8948
+ [STREAM_FORKED_FROM_HEADER]: sourcePath,
8949
+ },
8950
+ })
8951
+
8952
+ // Delete fork
8953
+ const deleteRes = await fetch(`${getBaseUrl()}${forkPath}`, {
8954
+ method: `DELETE`,
8955
+ })
8956
+ expect(deleteRes.status).toBe(204)
8957
+
8958
+ // Fork should be gone
8959
+ const forkRead = await fetch(`${getBaseUrl()}${forkPath}`, {
8960
+ method: `GET`,
8961
+ })
8962
+ expect(forkRead.status).toBe(404)
8963
+
8964
+ // Source should still be alive
8965
+ const sourceRead = await fetch(`${getBaseUrl()}${sourcePath}`, {
8966
+ method: `GET`,
8967
+ })
8968
+ expect(sourceRead.status).toBe(200)
8969
+ const body = await sourceRead.text()
8970
+ expect(body).toBe(`source data`)
8971
+ })
8972
+
8973
+ test(`should soft-delete source while fork exists — fork still reads`, async () => {
8974
+ const id = uniqueId()
8975
+ const sourcePath = `/v1/stream/fork-del-soft-src-${id}`
8976
+ const forkPath = `/v1/stream/fork-del-soft-fork-${id}`
8977
+
8978
+ // Create source
8979
+ await fetch(`${getBaseUrl()}${sourcePath}`, {
8980
+ method: `PUT`,
8981
+ headers: { "Content-Type": `text/plain` },
8982
+ body: `preserved data`,
8983
+ })
8984
+
8985
+ // Fork
8986
+ await fetch(`${getBaseUrl()}${forkPath}`, {
8987
+ method: `PUT`,
8988
+ headers: {
8989
+ "Content-Type": `text/plain`,
8990
+ [STREAM_FORKED_FROM_HEADER]: sourcePath,
8991
+ },
8992
+ })
8993
+
8994
+ // Delete source
8995
+ const deleteRes = await fetch(`${getBaseUrl()}${sourcePath}`, {
8996
+ method: `DELETE`,
8997
+ })
8998
+ expect(deleteRes.status).toBe(204)
8999
+
9000
+ // Source should be soft-deleted (410 Gone)
9001
+ const sourceHead = await fetch(`${getBaseUrl()}${sourcePath}`, {
9002
+ method: `HEAD`,
9003
+ })
9004
+ expect(sourceHead.status).toBe(410)
9005
+
9006
+ // Fork should still be readable with inherited data
9007
+ const forkRead = await fetch(`${getBaseUrl()}${forkPath}?offset=-1`)
9008
+ expect(forkRead.status).toBe(200)
9009
+ const body = await forkRead.text()
9010
+ expect(body).toBe(`preserved data`)
9011
+ })
9012
+
9013
+ test(`should block re-creation of soft-deleted source (PUT returns 409)`, async () => {
9014
+ const id = uniqueId()
9015
+ const sourcePath = `/v1/stream/fork-del-block-recreate-src-${id}`
9016
+ const forkPath = `/v1/stream/fork-del-block-recreate-fork-${id}`
9017
+
9018
+ // Create source
9019
+ await fetch(`${getBaseUrl()}${sourcePath}`, {
9020
+ method: `PUT`,
9021
+ headers: { "Content-Type": `text/plain` },
9022
+ body: `original`,
9023
+ })
9024
+
9025
+ // Fork
9026
+ await fetch(`${getBaseUrl()}${forkPath}`, {
9027
+ method: `PUT`,
9028
+ headers: {
9029
+ "Content-Type": `text/plain`,
9030
+ [STREAM_FORKED_FROM_HEADER]: sourcePath,
9031
+ },
9032
+ })
9033
+
9034
+ // Delete source (soft-delete)
9035
+ await fetch(`${getBaseUrl()}${sourcePath}`, { method: `DELETE` })
9036
+
9037
+ // Try to re-create source → 409
9038
+ const recreateRes = await fetch(`${getBaseUrl()}${sourcePath}`, {
9039
+ method: `PUT`,
9040
+ headers: { "Content-Type": `text/plain` },
9041
+ })
9042
+ expect(recreateRes.status).toBe(409)
9043
+ })
9044
+
9045
+ test(`should return 410 for GET on soft-deleted source`, async () => {
9046
+ const id = uniqueId()
9047
+ const sourcePath = `/v1/stream/fork-del-soft-get-${id}`
9048
+ const forkPath = `/v1/stream/fork-del-soft-get-fork-${id}`
9049
+
9050
+ await fetch(`${getBaseUrl()}${sourcePath}`, {
9051
+ method: `PUT`,
9052
+ headers: { "Content-Type": `text/plain` },
9053
+ body: `data`,
9054
+ })
9055
+ await fetch(`${getBaseUrl()}${forkPath}`, {
9056
+ method: `PUT`,
9057
+ headers: {
9058
+ "Content-Type": `text/plain`,
9059
+ [STREAM_FORKED_FROM_HEADER]: sourcePath,
9060
+ },
9061
+ })
9062
+ await fetch(`${getBaseUrl()}${sourcePath}`, { method: `DELETE` })
9063
+
9064
+ const getRes = await fetch(`${getBaseUrl()}${sourcePath}?offset=-1`)
9065
+ expect(getRes.status).toBe(410)
9066
+ })
9067
+
9068
+ test(`should return 410 for POST on soft-deleted source`, async () => {
9069
+ const id = uniqueId()
9070
+ const sourcePath = `/v1/stream/fork-del-soft-post-${id}`
9071
+ const forkPath = `/v1/stream/fork-del-soft-post-fork-${id}`
9072
+
9073
+ await fetch(`${getBaseUrl()}${sourcePath}`, {
9074
+ method: `PUT`,
9075
+ headers: { "Content-Type": `text/plain` },
9076
+ body: `data`,
9077
+ })
9078
+ await fetch(`${getBaseUrl()}${forkPath}`, {
9079
+ method: `PUT`,
9080
+ headers: {
9081
+ "Content-Type": `text/plain`,
9082
+ [STREAM_FORKED_FROM_HEADER]: sourcePath,
9083
+ },
9084
+ })
9085
+ await fetch(`${getBaseUrl()}${sourcePath}`, { method: `DELETE` })
9086
+
9087
+ const postRes = await fetch(`${getBaseUrl()}${sourcePath}`, {
9088
+ method: `POST`,
9089
+ headers: { "Content-Type": `text/plain` },
9090
+ body: `more data`,
9091
+ })
9092
+ expect(postRes.status).toBe(410)
9093
+ })
9094
+
9095
+ test(`should return 410 for DELETE on soft-deleted source`, async () => {
9096
+ const id = uniqueId()
9097
+ const sourcePath = `/v1/stream/fork-del-soft-del-${id}`
9098
+ const forkPath = `/v1/stream/fork-del-soft-del-fork-${id}`
9099
+
9100
+ await fetch(`${getBaseUrl()}${sourcePath}`, {
9101
+ method: `PUT`,
9102
+ headers: { "Content-Type": `text/plain` },
9103
+ body: `data`,
9104
+ })
9105
+ await fetch(`${getBaseUrl()}${forkPath}`, {
9106
+ method: `PUT`,
9107
+ headers: {
9108
+ "Content-Type": `text/plain`,
9109
+ [STREAM_FORKED_FROM_HEADER]: sourcePath,
9110
+ },
9111
+ })
9112
+ await fetch(`${getBaseUrl()}${sourcePath}`, { method: `DELETE` })
9113
+
9114
+ const deleteRes = await fetch(`${getBaseUrl()}${sourcePath}`, {
9115
+ method: `DELETE`,
9116
+ })
9117
+ expect(deleteRes.status).toBe(410)
9118
+ })
9119
+
9120
+ test(`should return 409 for fork from soft-deleted source`, async () => {
9121
+ const id = uniqueId()
9122
+ const sourcePath = `/v1/stream/fork-del-soft-refork-${id}`
9123
+ const forkPath = `/v1/stream/fork-del-soft-refork-fork1-${id}`
9124
+ const fork2Path = `/v1/stream/fork-del-soft-refork-fork2-${id}`
9125
+
9126
+ await fetch(`${getBaseUrl()}${sourcePath}`, {
9127
+ method: `PUT`,
9128
+ headers: { "Content-Type": `text/plain` },
9129
+ body: `data`,
9130
+ })
9131
+ await fetch(`${getBaseUrl()}${forkPath}`, {
9132
+ method: `PUT`,
9133
+ headers: {
9134
+ "Content-Type": `text/plain`,
9135
+ [STREAM_FORKED_FROM_HEADER]: sourcePath,
9136
+ },
9137
+ })
9138
+ await fetch(`${getBaseUrl()}${sourcePath}`, { method: `DELETE` })
9139
+
9140
+ const fork2Res = await fetch(`${getBaseUrl()}${fork2Path}`, {
9141
+ method: `PUT`,
9142
+ headers: {
9143
+ "Content-Type": `text/plain`,
9144
+ [STREAM_FORKED_FROM_HEADER]: sourcePath,
9145
+ },
9146
+ })
9147
+ expect(fork2Res.status).toBe(409)
9148
+ })
9149
+
9150
+ test(`should return 409 for fork with content-type mismatch`, async () => {
9151
+ const id = uniqueId()
9152
+ const sourcePath = `/v1/stream/fork-ct-mismatch-src-${id}`
9153
+ const forkPath = `/v1/stream/fork-ct-mismatch-fork-${id}`
9154
+
9155
+ await fetch(`${getBaseUrl()}${sourcePath}`, {
9156
+ method: `PUT`,
9157
+ headers: { "Content-Type": `text/plain` },
9158
+ body: `data`,
9159
+ })
9160
+
9161
+ const forkRes = await fetch(`${getBaseUrl()}${forkPath}`, {
9162
+ method: `PUT`,
9163
+ headers: {
9164
+ "Content-Type": `application/json`,
9165
+ [STREAM_FORKED_FROM_HEADER]: sourcePath,
9166
+ },
9167
+ })
9168
+ expect(forkRes.status).toBe(409)
9169
+ })
9170
+
9171
+ test(`should cascade GC when last fork is deleted`, async () => {
9172
+ const id = uniqueId()
9173
+ const sourcePath = `/v1/stream/fork-del-cascade-src-${id}`
9174
+ const forkPath = `/v1/stream/fork-del-cascade-fork-${id}`
9175
+
9176
+ // Create source
9177
+ await fetch(`${getBaseUrl()}${sourcePath}`, {
9178
+ method: `PUT`,
9179
+ headers: { "Content-Type": `text/plain` },
9180
+ body: `cascade data`,
9181
+ })
9182
+
9183
+ // Fork
9184
+ await fetch(`${getBaseUrl()}${forkPath}`, {
9185
+ method: `PUT`,
9186
+ headers: {
9187
+ "Content-Type": `text/plain`,
9188
+ [STREAM_FORKED_FROM_HEADER]: sourcePath,
9189
+ },
9190
+ })
9191
+
9192
+ // Delete source (soft-delete because fork exists)
9193
+ await fetch(`${getBaseUrl()}${sourcePath}`, { method: `DELETE` })
9194
+
9195
+ // Source should be 410 (soft-deleted)
9196
+ const sourceHead1 = await fetch(`${getBaseUrl()}${sourcePath}`, {
9197
+ method: `HEAD`,
9198
+ })
9199
+ expect(sourceHead1.status).toBe(410)
9200
+
9201
+ // Delete fork → should trigger cascading GC of source
9202
+ const deleteFork = await fetch(`${getBaseUrl()}${forkPath}`, {
9203
+ method: `DELETE`,
9204
+ })
9205
+ expect(deleteFork.status).toBe(204)
9206
+
9207
+ // Source should eventually be fully gone (404) — cascade GC timing is not guaranteed by the protocol
9208
+ await waitForStatus(`${getBaseUrl()}${sourcePath}`, 404)
9209
+ })
9210
+
9211
+ test(`should cascade GC through three levels`, async () => {
9212
+ const id = uniqueId()
9213
+ const level0 = `/v1/stream/fork-del-cascade3-l0-${id}`
9214
+ const level1 = `/v1/stream/fork-del-cascade3-l1-${id}`
9215
+ const level2 = `/v1/stream/fork-del-cascade3-l2-${id}`
9216
+
9217
+ // Create chain: level0 → level1 → level2
9218
+ await fetch(`${getBaseUrl()}${level0}`, {
9219
+ method: `PUT`,
9220
+ headers: { "Content-Type": `text/plain` },
9221
+ body: `root`,
9222
+ })
9223
+ await fetch(`${getBaseUrl()}${level1}`, {
9224
+ method: `PUT`,
9225
+ headers: {
9226
+ "Content-Type": `text/plain`,
9227
+ [STREAM_FORKED_FROM_HEADER]: level0,
9228
+ },
9229
+ })
9230
+ await fetch(`${getBaseUrl()}${level2}`, {
9231
+ method: `PUT`,
9232
+ headers: {
9233
+ "Content-Type": `text/plain`,
9234
+ [STREAM_FORKED_FROM_HEADER]: level1,
9235
+ },
9236
+ })
9237
+
9238
+ // Delete level0 and level1 (both soft-deleted due to refs)
9239
+ await fetch(`${getBaseUrl()}${level0}`, { method: `DELETE` })
9240
+ await fetch(`${getBaseUrl()}${level1}`, { method: `DELETE` })
9241
+
9242
+ // Both should be 410
9243
+ expect(
9244
+ (await fetch(`${getBaseUrl()}${level0}`, { method: `HEAD` })).status
9245
+ ).toBe(410)
9246
+ expect(
9247
+ (await fetch(`${getBaseUrl()}${level1}`, { method: `HEAD` })).status
9248
+ ).toBe(410)
9249
+
9250
+ // Delete level2 → cascade should eventually clean up level1 and level0
9251
+ const deleteLevel2 = await fetch(`${getBaseUrl()}${level2}`, {
9252
+ method: `DELETE`,
9253
+ })
9254
+ expect(deleteLevel2.status).toBe(204)
9255
+
9256
+ // level2 was directly deleted — should be gone immediately
9257
+ expect(
9258
+ (await fetch(`${getBaseUrl()}${level2}`, { method: `HEAD` })).status
9259
+ ).toBe(404)
9260
+
9261
+ // level1 and level0 should eventually be cleaned up via cascade GC
9262
+ await waitForStatus(`${getBaseUrl()}${level1}`, 404)
9263
+ await waitForStatus(`${getBaseUrl()}${level0}`, 404)
9264
+ })
9265
+
9266
+ test(`should preserve data when deleting middle of chain`, async () => {
9267
+ const id = uniqueId()
9268
+ const level0 = `/v1/stream/fork-del-middle-l0-${id}`
9269
+ const level1 = `/v1/stream/fork-del-middle-l1-${id}`
9270
+ const level2 = `/v1/stream/fork-del-middle-l2-${id}`
9271
+
9272
+ // Create chain: level0 → level1 → level2
9273
+ await fetch(`${getBaseUrl()}${level0}`, {
9274
+ method: `PUT`,
9275
+ headers: { "Content-Type": `text/plain` },
9276
+ body: `A`,
9277
+ })
9278
+ await fetch(`${getBaseUrl()}${level1}`, {
9279
+ method: `PUT`,
9280
+ headers: {
9281
+ "Content-Type": `text/plain`,
9282
+ [STREAM_FORKED_FROM_HEADER]: level0,
9283
+ },
9284
+ })
9285
+ await fetch(`${getBaseUrl()}${level1}`, {
9286
+ method: `POST`,
9287
+ headers: { "Content-Type": `text/plain` },
9288
+ body: `B`,
9289
+ })
9290
+ await fetch(`${getBaseUrl()}${level2}`, {
9291
+ method: `PUT`,
9292
+ headers: {
9293
+ "Content-Type": `text/plain`,
9294
+ [STREAM_FORKED_FROM_HEADER]: level1,
9295
+ },
9296
+ })
9297
+ await fetch(`${getBaseUrl()}${level2}`, {
9298
+ method: `POST`,
9299
+ headers: { "Content-Type": `text/plain` },
9300
+ body: `C`,
9301
+ })
9302
+
9303
+ // Delete level1 (middle) → soft-delete because level2 refs it
9304
+ await fetch(`${getBaseUrl()}${level1}`, { method: `DELETE` })
9305
+
9306
+ // Level1 should be 410 (soft-deleted)
9307
+ expect(
9308
+ (await fetch(`${getBaseUrl()}${level1}`, { method: `HEAD` })).status
9309
+ ).toBe(410)
9310
+
9311
+ // Level2 should still read all inherited data: A+B+C
9312
+ const readRes = await fetch(`${getBaseUrl()}${level2}?offset=-1`)
9313
+ expect(readRes.status).toBe(200)
9314
+ const body = await readRes.text()
9315
+ expect(body).toBe(`ABC`)
9316
+
9317
+ // Level0 should still be alive and readable
9318
+ const l0Read = await fetch(`${getBaseUrl()}${level0}?offset=-1`)
9319
+ expect(l0Read.status).toBe(200)
9320
+ expect(await l0Read.text()).toBe(`A`)
9321
+ })
9322
+
9323
+ test(`should keep source alive when all forks are deleted`, async () => {
9324
+ const id = uniqueId()
9325
+ const sourcePath = `/v1/stream/fork-del-allgone-src-${id}`
9326
+ const fork1Path = `/v1/stream/fork-del-allgone-f1-${id}`
9327
+ const fork2Path = `/v1/stream/fork-del-allgone-f2-${id}`
9328
+
9329
+ // Create source
9330
+ await fetch(`${getBaseUrl()}${sourcePath}`, {
9331
+ method: `PUT`,
9332
+ headers: { "Content-Type": `text/plain` },
9333
+ body: `alive`,
9334
+ })
9335
+
9336
+ // Create two forks
9337
+ await fetch(`${getBaseUrl()}${fork1Path}`, {
9338
+ method: `PUT`,
9339
+ headers: {
9340
+ "Content-Type": `text/plain`,
9341
+ [STREAM_FORKED_FROM_HEADER]: sourcePath,
9342
+ },
9343
+ })
9344
+ await fetch(`${getBaseUrl()}${fork2Path}`, {
9345
+ method: `PUT`,
9346
+ headers: {
9347
+ "Content-Type": `text/plain`,
9348
+ [STREAM_FORKED_FROM_HEADER]: sourcePath,
9349
+ },
9350
+ })
9351
+
9352
+ // Delete both forks
9353
+ await fetch(`${getBaseUrl()}${fork1Path}`, { method: `DELETE` })
9354
+ await fetch(`${getBaseUrl()}${fork2Path}`, { method: `DELETE` })
9355
+
9356
+ // Source should still be alive and readable
9357
+ const sourceRead = await fetch(`${getBaseUrl()}${sourcePath}?offset=-1`)
9358
+ expect(sourceRead.status).toBe(200)
9359
+ const body = await sourceRead.text()
9360
+ expect(body).toBe(`alive`)
9361
+ })
9362
+ })
9363
+
9364
+ // ============================================================================
9365
+ // Fork - TTL and Expiry
9366
+ // ============================================================================
9367
+
9368
+ describe(`Fork - TTL and Expiry`, () => {
9369
+ const STREAM_FORKED_FROM_HEADER = `Stream-Forked-From`
9370
+
9371
+ const uniqueId = () =>
9372
+ `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
9373
+ const sleep = (ms: number) =>
9374
+ new Promise((resolve) => setTimeout(resolve, ms))
9375
+
9376
+ // Poll HEAD until the stream is deleted, tolerating slight timing delays
9377
+ const waitForDeletion = async (
9378
+ url: string,
9379
+ initialSleepMs: number,
9380
+ expectedStatuses: Array<number> = [404],
9381
+ timeoutMs: number = 5000
9382
+ ) => {
9383
+ await sleep(initialSleepMs)
9384
+ await vi.waitFor(
9385
+ async () => {
9386
+ const head = await fetch(url, { method: `HEAD` })
9387
+ expect(expectedStatuses).toContain(head.status)
9388
+ },
9389
+ { timeout: timeoutMs, interval: 200 }
9390
+ )
9391
+ }
9392
+
9393
+ test(`should inherit source expiry when none specified`, async () => {
9394
+ const id = uniqueId()
9395
+ const sourcePath = `/v1/stream/fork-ttl-inherit-src-${id}`
9396
+ const forkPath = `/v1/stream/fork-ttl-inherit-fork-${id}`
9397
+
9398
+ const expiresAt = new Date(Date.now() + 3600000).toISOString()
9399
+
9400
+ // Create source with expiry
9401
+ await fetch(`${getBaseUrl()}${sourcePath}`, {
9402
+ method: `PUT`,
9403
+ headers: {
9404
+ "Content-Type": `text/plain`,
9405
+ "Stream-Expires-At": expiresAt,
9406
+ },
9407
+ body: `data`,
9408
+ })
9409
+
9410
+ // Fork without specifying expiry → should inherit
9411
+ const forkRes = await fetch(`${getBaseUrl()}${forkPath}`, {
9412
+ method: `PUT`,
9413
+ headers: {
9414
+ "Content-Type": `text/plain`,
9415
+ [STREAM_FORKED_FROM_HEADER]: sourcePath,
9416
+ },
9417
+ })
9418
+ expect(forkRes.status).toBe(201)
9419
+
9420
+ // Fork should have expiry metadata
9421
+ const forkHead = await fetch(`${getBaseUrl()}${forkPath}`, {
9422
+ method: `HEAD`,
9423
+ })
9424
+ expect(forkHead.status).toBe(200)
9425
+ // If server returns Stream-Expires-At, verify it's set
9426
+ const forkExpires = forkHead.headers.get(`Stream-Expires-At`)
9427
+ if (forkExpires) {
9428
+ expect(new Date(forkExpires).getTime()).toBeLessThanOrEqual(
9429
+ new Date(expiresAt).getTime()
9430
+ )
9431
+ }
9432
+ })
9433
+
9434
+ test(`should allow fork with shorter TTL`, async () => {
9435
+ const id = uniqueId()
9436
+ const sourcePath = `/v1/stream/fork-ttl-shorter-src-${id}`
9437
+ const forkPath = `/v1/stream/fork-ttl-shorter-fork-${id}`
9438
+
9439
+ // Create source with long TTL
9440
+ await fetch(`${getBaseUrl()}${sourcePath}`, {
9441
+ method: `PUT`,
9442
+ headers: {
9443
+ "Content-Type": `text/plain`,
9444
+ "Stream-TTL": `3600`,
9445
+ },
9446
+ body: `data`,
9447
+ })
9448
+
9449
+ // Fork with shorter TTL
9450
+ const forkRes = await fetch(`${getBaseUrl()}${forkPath}`, {
9451
+ method: `PUT`,
9452
+ headers: {
9453
+ "Content-Type": `text/plain`,
9454
+ [STREAM_FORKED_FROM_HEADER]: sourcePath,
9455
+ "Stream-TTL": `1800`,
9456
+ },
9457
+ })
9458
+ expect([200, 201]).toContain(forkRes.status)
9459
+ })
9460
+
9461
+ test.concurrent(
9462
+ `should expire fork based on TTL (releases refcount)`,
9463
+ async () => {
9464
+ const id = uniqueId()
9465
+ const sourcePath = `/v1/stream/fork-ttl-expire-src-${id}`
9466
+ const forkPath = `/v1/stream/fork-ttl-expire-fork-${id}`
9467
+
9468
+ // Create source with 60s TTL
9469
+ await fetch(`${getBaseUrl()}${sourcePath}`, {
9470
+ method: `PUT`,
9471
+ headers: {
9472
+ "Content-Type": `text/plain`,
9473
+ "Stream-TTL": `60`,
9474
+ },
9475
+ body: `data`,
9476
+ })
9477
+
9478
+ // Fork with 1s TTL
9479
+ await fetch(`${getBaseUrl()}${forkPath}`, {
9480
+ method: `PUT`,
9481
+ headers: {
9482
+ "Content-Type": `text/plain`,
9483
+ [STREAM_FORKED_FROM_HEADER]: sourcePath,
9484
+ "Stream-TTL": `1`,
9485
+ },
9486
+ })
9487
+
9488
+ // Fork should exist initially
9489
+ const forkHeadBefore = await fetch(`${getBaseUrl()}${forkPath}`, {
9490
+ method: `HEAD`,
9491
+ })
9492
+ expect(forkHeadBefore.status).toBe(200)
9493
+
9494
+ // Wait for fork to expire, polling HEAD until deleted
9495
+ await waitForDeletion(`${getBaseUrl()}${forkPath}`, 1000)
9496
+
9497
+ // Verify with GET as well
9498
+ const forkGetAfter = await fetch(`${getBaseUrl()}${forkPath}`, {
9499
+ method: `GET`,
9500
+ })
9501
+ expect(forkGetAfter.status).toBe(404)
9502
+ }
9503
+ )
9504
+
9505
+ test.concurrent(
9506
+ `should expire source with living forks (source goes 410)`,
9507
+ async () => {
9508
+ const id = uniqueId()
9509
+ const sourcePath = `/v1/stream/fork-ttl-src-expire-src-${id}`
9510
+ const forkPath = `/v1/stream/fork-ttl-src-expire-fork-${id}`
9511
+
9512
+ // Create source with 1s TTL
9513
+ await fetch(`${getBaseUrl()}${sourcePath}`, {
9514
+ method: `PUT`,
9515
+ headers: {
9516
+ "Content-Type": `text/plain`,
9517
+ "Stream-TTL": `1`,
9518
+ },
9519
+ body: `data`,
9520
+ })
9521
+
9522
+ // Fork (inherits source expiry — also 1s)
9523
+ await fetch(`${getBaseUrl()}${forkPath}`, {
9524
+ method: `PUT`,
9525
+ headers: {
9526
+ "Content-Type": `text/plain`,
9527
+ [STREAM_FORKED_FROM_HEADER]: sourcePath,
9528
+ },
9529
+ })
9530
+
9531
+ // Wait for source to expire, polling HEAD until deleted
9532
+ await waitForDeletion(`${getBaseUrl()}${sourcePath}`, 1000, [404, 410])
9533
+
9534
+ // Verify source with GET as well
9535
+ const sourceGet = await fetch(`${getBaseUrl()}${sourcePath}`, {
9536
+ method: `GET`,
9537
+ })
9538
+ expect([404, 410]).toContain(sourceGet.status)
9539
+
9540
+ // Fork should also expire (inherited same expiry)
9541
+ await waitForDeletion(`${getBaseUrl()}${forkPath}`, 0, [404, 410])
9542
+
9543
+ // Verify fork with GET as well
9544
+ const forkGet = await fetch(`${getBaseUrl()}${forkPath}`, {
9545
+ method: `GET`,
9546
+ })
9547
+ expect([404, 410]).toContain(forkGet.status)
9548
+ }
9549
+ )
9550
+
9551
+ test(`should inherit source TTL value when none specified`, async () => {
9552
+ const id = uniqueId()
9553
+ const sourcePath = `/v1/stream/fork-ttl-inherit-ttl-src-${id}`
9554
+ const forkPath = `/v1/stream/fork-ttl-inherit-ttl-fork-${id}`
9555
+
9556
+ // Create source with TTL
9557
+ await fetch(`${getBaseUrl()}${sourcePath}`, {
9558
+ method: `PUT`,
9559
+ headers: {
9560
+ "Content-Type": `text/plain`,
9561
+ "Stream-TTL": `3600`,
9562
+ },
9563
+ body: `data`,
9564
+ })
9565
+
9566
+ // Fork without specifying expiry → should inherit TTL value
9567
+ const forkRes = await fetch(`${getBaseUrl()}${forkPath}`, {
9568
+ method: `PUT`,
9569
+ headers: {
9570
+ "Content-Type": `text/plain`,
9571
+ [STREAM_FORKED_FROM_HEADER]: sourcePath,
9572
+ },
9573
+ })
9574
+ expect(forkRes.status).toBe(201)
9575
+
9576
+ // Fork should have TTL metadata matching source
9577
+ const forkHead = await fetch(`${getBaseUrl()}${forkPath}`, {
9578
+ method: `HEAD`,
9579
+ })
9580
+ expect(forkHead.status).toBe(200)
9581
+ const forkTTL = forkHead.headers.get(`Stream-TTL`)
9582
+ expect(forkTTL).toBe(`3600`)
9583
+ })
9584
+
9585
+ test(`should use fork's own TTL when specified`, async () => {
9586
+ const id = uniqueId()
9587
+ const sourcePath = `/v1/stream/fork-own-ttl-src-${id}`
9588
+ const forkPath = `/v1/stream/fork-own-ttl-fork-${id}`
9589
+
9590
+ // Create source with TTL=3600
9591
+ await fetch(`${getBaseUrl()}${sourcePath}`, {
9592
+ method: `PUT`,
9593
+ headers: {
9594
+ "Content-Type": `text/plain`,
9595
+ "Stream-TTL": `3600`,
9596
+ },
9597
+ body: `data`,
9598
+ })
9599
+
9600
+ // Fork with different TTL
9601
+ const forkRes = await fetch(`${getBaseUrl()}${forkPath}`, {
9602
+ method: `PUT`,
9603
+ headers: {
9604
+ "Content-Type": `text/plain`,
9605
+ [STREAM_FORKED_FROM_HEADER]: sourcePath,
9606
+ "Stream-TTL": `7200`,
9607
+ },
9608
+ })
9609
+ expect(forkRes.status).toBe(201)
9610
+
9611
+ // Fork should have its own TTL value
9612
+ const forkHead = await fetch(`${getBaseUrl()}${forkPath}`, {
9613
+ method: `HEAD`,
9614
+ })
9615
+ expect(forkHead.status).toBe(200)
9616
+ const forkTTL = forkHead.headers.get(`Stream-TTL`)
9617
+ expect(forkTTL).toBe(`7200`)
9618
+ })
9619
+
9620
+ test.concurrent(
9621
+ `should allow fork to outlive source via TTL renewal`,
9622
+ async () => {
9623
+ const id = uniqueId()
9624
+ const sourcePath = `/v1/stream/fork-outlive-src-${id}`
9625
+ const forkPath = `/v1/stream/fork-outlive-fork-${id}`
9626
+
9627
+ // Create source with 2s TTL
9628
+ await fetch(`${getBaseUrl()}${sourcePath}`, {
9629
+ method: `PUT`,
9630
+ headers: {
9631
+ "Content-Type": `text/plain`,
9632
+ "Stream-TTL": `2`,
9633
+ },
9634
+ body: `source data`,
9635
+ })
9636
+
9637
+ // Fork with 2s TTL
9638
+ const forkRes = await fetch(`${getBaseUrl()}${forkPath}`, {
9639
+ method: `PUT`,
9640
+ headers: {
9641
+ "Content-Type": `text/plain`,
9642
+ [STREAM_FORKED_FROM_HEADER]: sourcePath,
9643
+ "Stream-TTL": `2`,
9644
+ },
9645
+ })
9646
+ expect(forkRes.status).toBe(201)
9647
+
9648
+ // Wait 1.5s, then read the fork (extends fork's TTL, source is idle)
9649
+ await sleep(1500)
9650
+ const forkRead = await fetch(`${getBaseUrl()}${forkPath}`, {
9651
+ method: `GET`,
9652
+ })
9653
+ expect(forkRead.status).toBe(200)
9654
+
9655
+ // Source should be expired (2s TTL, idle since creation)
9656
+ // Poll until deleted — original 2s TTL minus ~1.5s already waited
9657
+ await waitForDeletion(`${getBaseUrl()}${sourcePath}`, 500, [404, 410])
9658
+
9659
+ // Verify source with GET as well
9660
+ const sourceGet = await fetch(`${getBaseUrl()}${sourcePath}`, {
9661
+ method: `GET`,
9662
+ })
9663
+ expect([404, 410]).toContain(sourceGet.status)
9664
+
9665
+ // Fork still alive (TTL was renewed by read)
9666
+ const forkHead = await fetch(`${getBaseUrl()}${forkPath}`, {
9667
+ method: `HEAD`,
9668
+ })
9669
+ expect(forkHead.status).toBe(200)
9670
+ }
9671
+ )
9672
+
9673
+ test(`should allow fork Expires-At beyond source TTL expiry`, async () => {
9674
+ const id = uniqueId()
9675
+ const sourcePath = `/v1/stream/fork-expires-beyond-src-${id}`
9676
+ const forkPath = `/v1/stream/fork-expires-beyond-fork-${id}`
9677
+
9678
+ // Create source with short TTL (10s)
9679
+ await fetch(`${getBaseUrl()}${sourcePath}`, {
9680
+ method: `PUT`,
9681
+ headers: {
9682
+ "Content-Type": `text/plain`,
9683
+ "Stream-TTL": `10`,
9684
+ },
9685
+ body: `data`,
9686
+ })
9687
+
9688
+ // Fork with Expires-At far in the future (no capping)
9689
+ const farFuture = new Date(Date.now() + 3600000).toISOString()
9690
+ const forkRes = await fetch(`${getBaseUrl()}${forkPath}`, {
9691
+ method: `PUT`,
9692
+ headers: {
9693
+ "Content-Type": `text/plain`,
9694
+ [STREAM_FORKED_FROM_HEADER]: sourcePath,
9695
+ "Stream-Expires-At": farFuture,
9696
+ },
9697
+ })
9698
+ expect(forkRes.status).toBe(201)
9699
+
9700
+ // Fork should have its own Expires-At, not capped at source
9701
+ const forkHead = await fetch(`${getBaseUrl()}${forkPath}`, {
9702
+ method: `HEAD`,
9703
+ })
9704
+ expect(forkHead.status).toBe(200)
9705
+ const forkExpiresAt = forkHead.headers.get(`Stream-Expires-At`)
9706
+ if (forkExpiresAt) {
9707
+ // Fork expiry should be ~1 hour from now, not ~10s
9708
+ expect(new Date(forkExpiresAt).getTime()).toBeGreaterThan(
9709
+ Date.now() + 3500000
9710
+ )
9711
+ }
9712
+ })
9713
+
9714
+ test(`should allow fork TTL longer than source TTL (no capping)`, async () => {
9715
+ const id = uniqueId()
9716
+ const sourcePath = `/v1/stream/fork-ttl-nocap-src-${id}`
9717
+ const forkPath = `/v1/stream/fork-ttl-nocap-fork-${id}`
9718
+
9719
+ // Create source with TTL=10
9720
+ await fetch(`${getBaseUrl()}${sourcePath}`, {
9721
+ method: `PUT`,
9722
+ headers: {
9723
+ "Content-Type": `text/plain`,
9724
+ "Stream-TTL": `10`,
9725
+ },
9726
+ body: `data`,
9727
+ })
9728
+
9729
+ // Fork with TTL=99999 — previously would be capped, now independent
9730
+ const forkRes = await fetch(`${getBaseUrl()}${forkPath}`, {
9731
+ method: `PUT`,
9732
+ headers: {
9733
+ "Content-Type": `text/plain`,
9734
+ [STREAM_FORKED_FROM_HEADER]: sourcePath,
9735
+ "Stream-TTL": `99999`,
9736
+ },
9737
+ })
9738
+ expect([200, 201]).toContain(forkRes.status)
9739
+
9740
+ // Fork should have its own TTL, not capped
9741
+ const forkHead = await fetch(`${getBaseUrl()}${forkPath}`, {
9742
+ method: `HEAD`,
9743
+ })
9744
+ expect(forkHead.status).toBe(200)
9745
+ const forkTTL = forkHead.headers.get(`Stream-TTL`)
9746
+ expect(forkTTL).toBe(`99999`)
9747
+ })
9748
+ })
9749
+
9750
+ // ============================================================================
9751
+ // Fork - JSON Mode
9752
+ // ============================================================================
9753
+
9754
+ describe(`Fork - JSON Mode`, () => {
9755
+ const STREAM_FORKED_FROM_HEADER = `Stream-Forked-From`
9756
+
9757
+ const uniqueId = () =>
9758
+ `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
9759
+
9760
+ test(`should fork a JSON stream`, async () => {
9761
+ const id = uniqueId()
9762
+ const sourcePath = `/v1/stream/fork-json-src-${id}`
9763
+ const forkPath = `/v1/stream/fork-json-fork-${id}`
9764
+
9765
+ // Create JSON source with data
9766
+ await fetch(`${getBaseUrl()}${sourcePath}`, {
9767
+ method: `PUT`,
9768
+ headers: { "Content-Type": `application/json` },
9769
+ body: `[{"event":"one"}]`,
9770
+ })
9771
+
9772
+ // Fork with matching content type
9773
+ const forkRes = await fetch(`${getBaseUrl()}${forkPath}`, {
9774
+ method: `PUT`,
9775
+ headers: {
9776
+ "Content-Type": `application/json`,
9777
+ [STREAM_FORKED_FROM_HEADER]: sourcePath,
9778
+ },
9779
+ })
9780
+ expect(forkRes.status).toBe(201)
9781
+ expect(forkRes.headers.get(`content-type`)).toBe(`application/json`)
9782
+
9783
+ // Read fork → should be a JSON array
9784
+ const readRes = await fetch(`${getBaseUrl()}${forkPath}?offset=-1`)
9785
+ expect(readRes.status).toBe(200)
9786
+ expect(readRes.headers.get(`content-type`)).toBe(`application/json`)
9787
+ const body = JSON.parse(await readRes.text())
9788
+ expect(Array.isArray(body)).toBe(true)
9789
+ expect(body).toEqual([{ event: `one` }])
9790
+ })
9791
+
9792
+ test(`should read forked JSON across boundary`, async () => {
9793
+ const id = uniqueId()
9794
+ const sourcePath = `/v1/stream/fork-json-boundary-src-${id}`
9795
+ const forkPath = `/v1/stream/fork-json-boundary-fork-${id}`
9796
+
9797
+ // Create JSON source
9798
+ await fetch(`${getBaseUrl()}${sourcePath}`, {
9799
+ method: `PUT`,
9800
+ headers: { "Content-Type": `application/json` },
9801
+ body: `[{"from":"source"}]`,
9802
+ })
9803
+
9804
+ // Fork at head with matching content type
9805
+ await fetch(`${getBaseUrl()}${forkPath}`, {
9806
+ method: `PUT`,
9807
+ headers: {
9808
+ "Content-Type": `application/json`,
9809
+ [STREAM_FORKED_FROM_HEADER]: sourcePath,
9810
+ },
9811
+ })
9812
+
9813
+ // Append to fork
9814
+ await fetch(`${getBaseUrl()}${forkPath}`, {
9815
+ method: `POST`,
9816
+ headers: { "Content-Type": `application/json` },
9817
+ body: `[{"from":"fork"}]`,
9818
+ })
9819
+
9820
+ // Read entire fork → should be a valid JSON array with both items
9821
+ const readRes = await fetch(`${getBaseUrl()}${forkPath}?offset=-1`)
9822
+ expect(readRes.status).toBe(200)
9823
+ const body = JSON.parse(await readRes.text())
9824
+ expect(Array.isArray(body)).toBe(true)
9825
+ expect(body).toEqual([{ from: `source` }, { from: `fork` }])
9826
+ })
9827
+ })
9828
+
9829
+ // ============================================================================
9830
+ // Fork - Edge Cases
9831
+ // ============================================================================
9832
+
9833
+ describe(`Fork - Edge Cases`, () => {
9834
+ const STREAM_FORKED_FROM_HEADER = `Stream-Forked-From`
9835
+ const STREAM_FORK_OFFSET_HEADER = `Stream-Fork-Offset`
9836
+
9837
+ const uniqueId = () =>
9838
+ `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
9839
+
9840
+ test(`should handle fork then immediately delete source`, async () => {
9841
+ const id = uniqueId()
9842
+ const sourcePath = `/v1/stream/fork-edge-imm-del-src-${id}`
9843
+ const forkPath = `/v1/stream/fork-edge-imm-del-fork-${id}`
9844
+
9845
+ // Create source
9846
+ await fetch(`${getBaseUrl()}${sourcePath}`, {
9847
+ method: `PUT`,
9848
+ headers: { "Content-Type": `text/plain` },
9849
+ body: `ephemeral`,
9850
+ })
9851
+
9852
+ // Fork
9853
+ await fetch(`${getBaseUrl()}${forkPath}`, {
9854
+ method: `PUT`,
9855
+ headers: {
9856
+ "Content-Type": `text/plain`,
9857
+ [STREAM_FORKED_FROM_HEADER]: sourcePath,
9858
+ },
9859
+ })
9860
+
9861
+ // Immediately delete source
9862
+ await fetch(`${getBaseUrl()}${sourcePath}`, { method: `DELETE` })
9863
+
9864
+ // Fork should still work
9865
+ const readRes = await fetch(`${getBaseUrl()}${forkPath}?offset=-1`)
9866
+ expect(readRes.status).toBe(200)
9867
+ const body = await readRes.text()
9868
+ expect(body).toBe(`ephemeral`)
9869
+ })
9870
+
9871
+ test(`should handle many forks of same stream (10 forks)`, async () => {
9872
+ const id = uniqueId()
9873
+ const sourcePath = `/v1/stream/fork-edge-many-src-${id}`
9874
+
9875
+ // Create source
9876
+ await fetch(`${getBaseUrl()}${sourcePath}`, {
9877
+ method: `PUT`,
9878
+ headers: { "Content-Type": `text/plain` },
9879
+ body: `shared data`,
9880
+ })
9881
+
9882
+ // Create 10 forks
9883
+ const forkPaths: Array<string> = []
9884
+ for (let i = 0; i < 10; i++) {
9885
+ const forkPath = `/v1/stream/fork-edge-many-f${i}-${id}`
9886
+ const forkRes = await fetch(`${getBaseUrl()}${forkPath}`, {
9887
+ method: `PUT`,
9888
+ headers: {
9889
+ "Content-Type": `text/plain`,
9890
+ [STREAM_FORKED_FROM_HEADER]: sourcePath,
9891
+ },
9892
+ })
9893
+ expect(forkRes.status).toBe(201)
9894
+ forkPaths.push(forkPath)
9895
+ }
9896
+
9897
+ // Each fork should read the same data
9898
+ for (const fp of forkPaths) {
9899
+ const readRes = await fetch(`${getBaseUrl()}${fp}?offset=-1`)
9900
+ expect(readRes.status).toBe(200)
9901
+ const body = await readRes.text()
9902
+ expect(body).toBe(`shared data`)
9903
+ }
9904
+
9905
+ // Delete all forks
9906
+ for (const fp of forkPaths) {
9907
+ await fetch(`${getBaseUrl()}${fp}`, { method: `DELETE` })
9908
+ }
9909
+ })
9910
+
9911
+ test(`should fork at every offset position`, async () => {
9912
+ const id = uniqueId()
9913
+ const sourcePath = `/v1/stream/fork-edge-every-offset-src-${id}`
9914
+
9915
+ // Create source with multiple chunks
9916
+ const createRes = await fetch(`${getBaseUrl()}${sourcePath}`, {
9917
+ method: `PUT`,
9918
+ headers: { "Content-Type": `text/plain` },
9919
+ body: `A`,
9920
+ })
9921
+ const offset0 = `0000000000000000_0000000000000000` // before any data
9922
+ const offset1 = createRes.headers.get(STREAM_OFFSET_HEADER)! // after A
9923
+
9924
+ const append1 = await fetch(`${getBaseUrl()}${sourcePath}`, {
9925
+ method: `POST`,
9926
+ headers: { "Content-Type": `text/plain` },
9927
+ body: `B`,
9928
+ })
9929
+ const offset2 = append1.headers.get(STREAM_OFFSET_HEADER)! // after B
9930
+
9931
+ const append2 = await fetch(`${getBaseUrl()}${sourcePath}`, {
9932
+ method: `POST`,
9933
+ headers: { "Content-Type": `text/plain` },
9934
+ body: `C`,
9935
+ })
9936
+ const offset3 = append2.headers.get(STREAM_OFFSET_HEADER)! // after C
9937
+
9938
+ // Fork at offset0 (empty inherited)
9939
+ const f0 = `/v1/stream/fork-edge-every-f0-${id}`
9940
+ const f0Res = await fetch(`${getBaseUrl()}${f0}`, {
9941
+ method: `PUT`,
9942
+ headers: {
9943
+ "Content-Type": `text/plain`,
9944
+ [STREAM_FORKED_FROM_HEADER]: sourcePath,
9945
+ [STREAM_FORK_OFFSET_HEADER]: offset0,
9946
+ },
9947
+ })
9948
+ expect(f0Res.status).toBe(201)
9949
+ const f0Body = await (
9950
+ await fetch(`${getBaseUrl()}${f0}?offset=-1`)
9951
+ ).text()
9952
+ expect(f0Body).toBe(``)
9953
+
9954
+ // Fork at offset1 (inherits A)
9955
+ const f1 = `/v1/stream/fork-edge-every-f1-${id}`
9956
+ await fetch(`${getBaseUrl()}${f1}`, {
9957
+ method: `PUT`,
9958
+ headers: {
9959
+ "Content-Type": `text/plain`,
9960
+ [STREAM_FORKED_FROM_HEADER]: sourcePath,
9961
+ [STREAM_FORK_OFFSET_HEADER]: offset1,
9962
+ },
9963
+ })
9964
+ const f1Body = await (
9965
+ await fetch(`${getBaseUrl()}${f1}?offset=-1`)
9966
+ ).text()
9967
+ expect(f1Body).toBe(`A`)
9968
+
9969
+ // Fork at offset2 (inherits A+B)
9970
+ const f2 = `/v1/stream/fork-edge-every-f2-${id}`
9971
+ await fetch(`${getBaseUrl()}${f2}`, {
9972
+ method: `PUT`,
9973
+ headers: {
9974
+ "Content-Type": `text/plain`,
9975
+ [STREAM_FORKED_FROM_HEADER]: sourcePath,
9976
+ [STREAM_FORK_OFFSET_HEADER]: offset2,
9977
+ },
9978
+ })
9979
+ const f2Body = await (
9980
+ await fetch(`${getBaseUrl()}${f2}?offset=-1`)
9981
+ ).text()
9982
+ expect(f2Body).toBe(`AB`)
9983
+
9984
+ // Fork at offset3 (inherits A+B+C)
9985
+ const f3 = `/v1/stream/fork-edge-every-f3-${id}`
9986
+ await fetch(`${getBaseUrl()}${f3}`, {
9987
+ method: `PUT`,
9988
+ headers: {
9989
+ "Content-Type": `text/plain`,
9990
+ [STREAM_FORKED_FROM_HEADER]: sourcePath,
9991
+ [STREAM_FORK_OFFSET_HEADER]: offset3,
9992
+ },
9993
+ })
9994
+ const f3Body = await (
9995
+ await fetch(`${getBaseUrl()}${f3}?offset=-1`)
9996
+ ).text()
9997
+ expect(f3Body).toBe(`ABC`)
9998
+ })
9999
+
10000
+ test(`should handle idempotent fork creation (PUT twice)`, async () => {
10001
+ const id = uniqueId()
10002
+ const sourcePath = `/v1/stream/fork-edge-idempotent-src-${id}`
10003
+ const forkPath = `/v1/stream/fork-edge-idempotent-fork-${id}`
10004
+
10005
+ // Create source
10006
+ await fetch(`${getBaseUrl()}${sourcePath}`, {
10007
+ method: `PUT`,
10008
+ headers: { "Content-Type": `text/plain` },
10009
+ body: `data`,
10010
+ })
10011
+
10012
+ // First fork PUT → 201
10013
+ const fork1 = await fetch(`${getBaseUrl()}${forkPath}`, {
10014
+ method: `PUT`,
10015
+ headers: {
10016
+ "Content-Type": `text/plain`,
10017
+ [STREAM_FORKED_FROM_HEADER]: sourcePath,
10018
+ },
10019
+ })
10020
+ expect(fork1.status).toBe(201)
10021
+
10022
+ // Second fork PUT with same headers → 200 (idempotent)
10023
+ const fork2 = await fetch(`${getBaseUrl()}${forkPath}`, {
10024
+ method: `PUT`,
10025
+ headers: {
10026
+ "Content-Type": `text/plain`,
10027
+ [STREAM_FORKED_FROM_HEADER]: sourcePath,
10028
+ },
10029
+ })
10030
+ expect(fork2.status).toBe(200)
10031
+ })
10032
+ })
7559
10033
  }