@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/dist/index.cjs +1 -1
- package/dist/index.js +1 -1
- package/dist/{src-DZatkb9d.cjs → src-C8zcHWaE.cjs} +1778 -45
- package/dist/{src-D-K9opVc.js → src-HGMeYG8a.js} +1779 -46
- package/dist/test-runner.cjs +1 -1
- package/dist/test-runner.js +1 -1
- package/package.json +1 -1
- package/src/index.ts +2564 -90
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
|
-
//
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
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
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
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
|
-
|
|
2485
|
-
|
|
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
|
|
2550
|
-
await
|
|
2569
|
+
// Wait for TTL to expire, polling HEAD until deleted
|
|
2570
|
+
await waitForDeletion(`${getBaseUrl()}${streamPath}`, 1000)
|
|
2551
2571
|
|
|
2552
|
-
//
|
|
2553
|
-
const
|
|
2554
|
-
method: `
|
|
2572
|
+
// Verify with GET as well
|
|
2573
|
+
const getAfter = await fetch(`${getBaseUrl()}${streamPath}`, {
|
|
2574
|
+
method: `GET`,
|
|
2555
2575
|
})
|
|
2556
|
-
expect(
|
|
2576
|
+
expect(getAfter.status).toBe(404)
|
|
2557
2577
|
})
|
|
2558
2578
|
|
|
2559
|
-
test.concurrent(
|
|
2560
|
-
|
|
2561
|
-
|
|
2562
|
-
|
|
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
|
-
|
|
2574
|
-
|
|
2575
|
-
|
|
2576
|
-
|
|
2577
|
-
|
|
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
|
-
|
|
2580
|
-
|
|
2595
|
+
// Wait for TTL to expire (no reads or writes — stream is idle)
|
|
2596
|
+
await waitForDeletion(`${getBaseUrl()}${streamPath}`, 1000)
|
|
2581
2597
|
|
|
2582
|
-
|
|
2583
|
-
|
|
2584
|
-
|
|
2585
|
-
|
|
2586
|
-
|
|
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
|
-
//
|
|
2605
|
-
|
|
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
|
-
//
|
|
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
|
|
2631
|
-
const expiresAt = new Date(Date.now() +
|
|
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
|
|
2648
|
-
await
|
|
2656
|
+
// Wait for expiry, polling HEAD until deleted
|
|
2657
|
+
await waitForDeletion(`${getBaseUrl()}${streamPath}`, 3000)
|
|
2649
2658
|
|
|
2650
|
-
//
|
|
2651
|
-
const
|
|
2652
|
-
method: `
|
|
2659
|
+
// Verify with GET as well
|
|
2660
|
+
const getAfter = await fetch(`${getBaseUrl()}${streamPath}`, {
|
|
2661
|
+
method: `GET`,
|
|
2653
2662
|
})
|
|
2654
|
-
expect(
|
|
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
|
|
2664
|
-
const expiresAt = new Date(Date.now() +
|
|
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
|
|
2682
|
-
await
|
|
2690
|
+
// Wait for expiry, polling HEAD until deleted
|
|
2691
|
+
await waitForDeletion(`${getBaseUrl()}${streamPath}`, 3000)
|
|
2683
2692
|
|
|
2684
|
-
//
|
|
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
|
|
2698
|
-
const expiresAt = new Date(Date.now() +
|
|
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
|
|
2717
|
-
await
|
|
2725
|
+
// Wait for expiry, polling HEAD until deleted
|
|
2726
|
+
await waitForDeletion(`${getBaseUrl()}${streamPath}`, 3000)
|
|
2718
2727
|
|
|
2719
|
-
//
|
|
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
|
|
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
|
|
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
|
|
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
|
}
|