@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.
@@ -1,4 +1,4 @@
1
- import { describe, expect, test } from "vitest";
1
+ import { describe, expect, test, vi } from "vitest";
2
2
  import * as fc from "fast-check";
3
3
  import { DurableStream, STREAM_OFFSET_HEADER, STREAM_SEQ_HEADER, STREAM_UP_TO_DATE_HEADER } from "@durable-streams/client";
4
4
 
@@ -991,17 +991,23 @@ function runConformanceTests(options) {
991
991
  body: `historical`
992
992
  });
993
993
  const longPollPromise = fetch(`${getBaseUrl()}${streamPath}?offset=now&live=long-poll`, { method: `GET` });
994
- await new Promise((r) => setTimeout(r, 100));
995
- await fetch(`${getBaseUrl()}${streamPath}`, {
996
- method: `POST`,
997
- headers: { "Content-Type": `text/plain` },
998
- body: `new data`
999
- });
1000
- const response = await longPollPromise;
1001
- expect(response.status).toBe(200);
1002
- const text = await response.text();
1003
- expect(text).toBe(`new data`);
1004
- expect(response.headers.get(STREAM_UP_TO_DATE_HEADER)).toBe(`true`);
994
+ const interval = setInterval(() => {
995
+ fetch(`${getBaseUrl()}${streamPath}`, {
996
+ method: `POST`,
997
+ headers: { "Content-Type": `text/plain` },
998
+ body: `new data`
999
+ });
1000
+ }, 50);
1001
+ try {
1002
+ const response = await longPollPromise;
1003
+ expect(response.status).toBe(200);
1004
+ const text = await response.text();
1005
+ expect(text).toContain(`new data`);
1006
+ expect(text).not.toContain(`historical`);
1007
+ expect(response.headers.get(STREAM_UP_TO_DATE_HEADER)).toBe(`true`);
1008
+ } finally {
1009
+ clearInterval(interval);
1010
+ }
1005
1011
  });
1006
1012
  test(`should support offset=now with SSE mode`, async () => {
1007
1013
  const streamPath = `/v1/stream/offset-now-sse-test-${Date.now()}`;
@@ -1536,10 +1542,7 @@ function runConformanceTests(options) {
1536
1542
  });
1537
1543
  const response = await fetch(`${getBaseUrl()}${streamPath}`, { method: `HEAD` });
1538
1544
  const ttl = response.headers.get(`Stream-TTL`);
1539
- if (ttl) {
1540
- expect(parseInt(ttl)).toBeGreaterThan(0);
1541
- expect(parseInt(ttl)).toBeLessThanOrEqual(3600);
1542
- }
1545
+ expect(ttl).toBe(`3600`);
1543
1546
  });
1544
1547
  test(`should return Expires-At metadata if configured`, async () => {
1545
1548
  const streamPath = `/v1/stream/head-expires-metadata-test-${Date.now()}`;
@@ -1559,6 +1562,16 @@ function runConformanceTests(options) {
1559
1562
  describe(`TTL Expiration Behavior`, () => {
1560
1563
  const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
1561
1564
  const uniquePath = (prefix) => `/v1/stream/${prefix}-${Date.now()}-${Math.random().toString(36).slice(2)}`;
1565
+ const waitForDeletion = async (url, initialSleepMs, expectedStatuses = [404], timeoutMs = 5e3) => {
1566
+ await sleep(initialSleepMs);
1567
+ await vi.waitFor(async () => {
1568
+ const head = await fetch(url, { method: `HEAD` });
1569
+ expect(expectedStatuses).toContain(head.status);
1570
+ }, {
1571
+ timeout: timeoutMs,
1572
+ interval: 200
1573
+ });
1574
+ };
1562
1575
  test.concurrent(`should return 404 on HEAD after TTL expires`, async () => {
1563
1576
  const streamPath = uniquePath(`ttl-expire-head`);
1564
1577
  const createResponse = await fetch(`${getBaseUrl()}${streamPath}`, {
@@ -1571,11 +1584,11 @@ function runConformanceTests(options) {
1571
1584
  expect(createResponse.status).toBe(201);
1572
1585
  const headBefore = await fetch(`${getBaseUrl()}${streamPath}`, { method: `HEAD` });
1573
1586
  expect(headBefore.status).toBe(200);
1574
- await sleep(1500);
1575
- const headAfter = await fetch(`${getBaseUrl()}${streamPath}`, { method: `HEAD` });
1576
- expect(headAfter.status).toBe(404);
1587
+ await waitForDeletion(`${getBaseUrl()}${streamPath}`, 1e3);
1588
+ const getAfter = await fetch(`${getBaseUrl()}${streamPath}`, { method: `GET` });
1589
+ expect(getAfter.status).toBe(404);
1577
1590
  });
1578
- test.concurrent(`should return 404 on GET after TTL expires`, async () => {
1591
+ test.concurrent(`should return 404 on GET after TTL expires (idle)`, async () => {
1579
1592
  const streamPath = uniquePath(`ttl-expire-get`);
1580
1593
  const createResponse = await fetch(`${getBaseUrl()}${streamPath}`, {
1581
1594
  method: `PUT`,
@@ -1586,13 +1599,11 @@ function runConformanceTests(options) {
1586
1599
  body: `test data`
1587
1600
  });
1588
1601
  expect(createResponse.status).toBe(201);
1589
- const getBefore = await fetch(`${getBaseUrl()}${streamPath}`, { method: `GET` });
1590
- expect(getBefore.status).toBe(200);
1591
- await sleep(1500);
1602
+ await waitForDeletion(`${getBaseUrl()}${streamPath}`, 1e3);
1592
1603
  const getAfter = await fetch(`${getBaseUrl()}${streamPath}`, { method: `GET` });
1593
1604
  expect(getAfter.status).toBe(404);
1594
1605
  });
1595
- test.concurrent(`should return 404 on POST append after TTL expires`, async () => {
1606
+ test.concurrent(`should return 404 on POST append after TTL expires (idle)`, async () => {
1596
1607
  const streamPath = uniquePath(`ttl-expire-post`);
1597
1608
  const createResponse = await fetch(`${getBaseUrl()}${streamPath}`, {
1598
1609
  method: `PUT`,
@@ -1602,13 +1613,7 @@ function runConformanceTests(options) {
1602
1613
  }
1603
1614
  });
1604
1615
  expect(createResponse.status).toBe(201);
1605
- const postBefore = await fetch(`${getBaseUrl()}${streamPath}`, {
1606
- method: `POST`,
1607
- headers: { "Content-Type": `text/plain` },
1608
- body: `appended data`
1609
- });
1610
- expect(postBefore.status).toBe(204);
1611
- await sleep(1500);
1616
+ await waitForDeletion(`${getBaseUrl()}${streamPath}`, 1e3);
1612
1617
  const postAfter = await fetch(`${getBaseUrl()}${streamPath}`, {
1613
1618
  method: `POST`,
1614
1619
  headers: { "Content-Type": `text/plain` },
@@ -1618,7 +1623,7 @@ function runConformanceTests(options) {
1618
1623
  });
1619
1624
  test.concurrent(`should return 404 on HEAD after Expires-At passes`, async () => {
1620
1625
  const streamPath = uniquePath(`expires-at-head`);
1621
- const expiresAt = new Date(Date.now() + 1e3).toISOString();
1626
+ const expiresAt = new Date(Date.now() + 3e3).toISOString();
1622
1627
  const createResponse = await fetch(`${getBaseUrl()}${streamPath}`, {
1623
1628
  method: `PUT`,
1624
1629
  headers: {
@@ -1629,13 +1634,13 @@ function runConformanceTests(options) {
1629
1634
  expect(createResponse.status).toBe(201);
1630
1635
  const headBefore = await fetch(`${getBaseUrl()}${streamPath}`, { method: `HEAD` });
1631
1636
  expect(headBefore.status).toBe(200);
1632
- await sleep(1500);
1633
- const headAfter = await fetch(`${getBaseUrl()}${streamPath}`, { method: `HEAD` });
1634
- expect(headAfter.status).toBe(404);
1637
+ await waitForDeletion(`${getBaseUrl()}${streamPath}`, 3e3);
1638
+ const getAfter = await fetch(`${getBaseUrl()}${streamPath}`, { method: `GET` });
1639
+ expect(getAfter.status).toBe(404);
1635
1640
  });
1636
1641
  test.concurrent(`should return 404 on GET after Expires-At passes`, async () => {
1637
1642
  const streamPath = uniquePath(`expires-at-get`);
1638
- const expiresAt = new Date(Date.now() + 1e3).toISOString();
1643
+ const expiresAt = new Date(Date.now() + 3e3).toISOString();
1639
1644
  const createResponse = await fetch(`${getBaseUrl()}${streamPath}`, {
1640
1645
  method: `PUT`,
1641
1646
  headers: {
@@ -1647,13 +1652,13 @@ function runConformanceTests(options) {
1647
1652
  expect(createResponse.status).toBe(201);
1648
1653
  const getBefore = await fetch(`${getBaseUrl()}${streamPath}`, { method: `GET` });
1649
1654
  expect(getBefore.status).toBe(200);
1650
- await sleep(1500);
1655
+ await waitForDeletion(`${getBaseUrl()}${streamPath}`, 3e3);
1651
1656
  const getAfter = await fetch(`${getBaseUrl()}${streamPath}`, { method: `GET` });
1652
1657
  expect(getAfter.status).toBe(404);
1653
1658
  });
1654
1659
  test.concurrent(`should return 404 on POST append after Expires-At passes`, async () => {
1655
1660
  const streamPath = uniquePath(`expires-at-post`);
1656
- const expiresAt = new Date(Date.now() + 1e3).toISOString();
1661
+ const expiresAt = new Date(Date.now() + 3e3).toISOString();
1657
1662
  const createResponse = await fetch(`${getBaseUrl()}${streamPath}`, {
1658
1663
  method: `PUT`,
1659
1664
  headers: {
@@ -1668,7 +1673,7 @@ function runConformanceTests(options) {
1668
1673
  body: `appended data`
1669
1674
  });
1670
1675
  expect(postBefore.status).toBe(204);
1671
- await sleep(1500);
1676
+ await waitForDeletion(`${getBaseUrl()}${streamPath}`, 3e3);
1672
1677
  const postAfter = await fetch(`${getBaseUrl()}${streamPath}`, {
1673
1678
  method: `POST`,
1674
1679
  headers: { "Content-Type": `text/plain` },
@@ -1687,7 +1692,7 @@ function runConformanceTests(options) {
1687
1692
  body: `original data`
1688
1693
  });
1689
1694
  expect(createResponse.status).toBe(201);
1690
- await sleep(1500);
1695
+ await waitForDeletion(`${getBaseUrl()}${streamPath}`, 1e3);
1691
1696
  const recreateResponse = await fetch(`${getBaseUrl()}${streamPath}`, {
1692
1697
  method: `PUT`,
1693
1698
  headers: {
@@ -1702,6 +1707,81 @@ function runConformanceTests(options) {
1702
1707
  const body = await getResponse.text();
1703
1708
  expect(body).toContain(`new data`);
1704
1709
  });
1710
+ test.concurrent(`should extend TTL on write (sliding window)`, async () => {
1711
+ const streamPath = uniquePath(`ttl-renew-write`);
1712
+ const createResponse = await fetch(`${getBaseUrl()}${streamPath}`, {
1713
+ method: `PUT`,
1714
+ headers: {
1715
+ "Content-Type": `text/plain`,
1716
+ "Stream-TTL": `2`
1717
+ }
1718
+ });
1719
+ expect(createResponse.status).toBe(201);
1720
+ await sleep(1500);
1721
+ const appendResponse = await fetch(`${getBaseUrl()}${streamPath}`, {
1722
+ method: `POST`,
1723
+ headers: { "Content-Type": `text/plain` },
1724
+ body: `keep alive`
1725
+ });
1726
+ expect(appendResponse.status).toBe(204);
1727
+ await sleep(1500);
1728
+ const headResponse = await fetch(`${getBaseUrl()}${streamPath}`, { method: `HEAD` });
1729
+ expect(headResponse.status).toBe(200);
1730
+ });
1731
+ test.concurrent(`should extend TTL on read (sliding window)`, async () => {
1732
+ const streamPath = uniquePath(`ttl-renew-read`);
1733
+ const createResponse = await fetch(`${getBaseUrl()}${streamPath}`, {
1734
+ method: `PUT`,
1735
+ headers: {
1736
+ "Content-Type": `text/plain`,
1737
+ "Stream-TTL": `2`
1738
+ },
1739
+ body: `test data`
1740
+ });
1741
+ expect(createResponse.status).toBe(201);
1742
+ await sleep(1500);
1743
+ const readResponse = await fetch(`${getBaseUrl()}${streamPath}`, { method: `GET` });
1744
+ expect(readResponse.status).toBe(200);
1745
+ await sleep(1500);
1746
+ const headResponse = await fetch(`${getBaseUrl()}${streamPath}`, { method: `HEAD` });
1747
+ expect(headResponse.status).toBe(200);
1748
+ });
1749
+ test.concurrent(`should NOT extend TTL on HEAD`, async () => {
1750
+ const streamPath = uniquePath(`ttl-no-renew-head`);
1751
+ const createResponse = await fetch(`${getBaseUrl()}${streamPath}`, {
1752
+ method: `PUT`,
1753
+ headers: {
1754
+ "Content-Type": `text/plain`,
1755
+ "Stream-TTL": `2`
1756
+ }
1757
+ });
1758
+ expect(createResponse.status).toBe(201);
1759
+ await sleep(1500);
1760
+ const headMid = await fetch(`${getBaseUrl()}${streamPath}`, { method: `HEAD` });
1761
+ expect(headMid.status).toBe(200);
1762
+ await waitForDeletion(`${getBaseUrl()}${streamPath}`, 500);
1763
+ const getAfter = await fetch(`${getBaseUrl()}${streamPath}`, { method: `GET` });
1764
+ expect(getAfter.status).toBe(404);
1765
+ });
1766
+ test.concurrent(`should NOT extend Expires-At on read or write`, async () => {
1767
+ const streamPath = uniquePath(`expires-at-no-renew`);
1768
+ const expiresAt = new Date(Date.now() + 4e3).toISOString();
1769
+ const createResponse = await fetch(`${getBaseUrl()}${streamPath}`, {
1770
+ method: `PUT`,
1771
+ headers: {
1772
+ "Content-Type": `text/plain`,
1773
+ "Stream-Expires-At": expiresAt
1774
+ },
1775
+ body: `test data`
1776
+ });
1777
+ expect(createResponse.status).toBe(201);
1778
+ await sleep(2e3);
1779
+ const readResponse = await fetch(`${getBaseUrl()}${streamPath}`, { method: `GET` });
1780
+ expect(readResponse.status).toBe(200);
1781
+ await waitForDeletion(`${getBaseUrl()}${streamPath}`, 2e3);
1782
+ const getAfter = await fetch(`${getBaseUrl()}${streamPath}`, { method: `GET` });
1783
+ expect(getAfter.status).toBe(404);
1784
+ });
1705
1785
  });
1706
1786
  describe(`Caching and ETag`, () => {
1707
1787
  test(`should generate ETag on GET responses`, async () => {
@@ -2824,8 +2904,11 @@ function runConformanceTests(options) {
2824
2904
  const finalResult = await readEntireStream(streamPath);
2825
2905
  expect(finalResult).toEqual(expected);
2826
2906
  }
2827
- ), { numRuns: 20 });
2828
- });
2907
+ ), {
2908
+ numRuns: 20,
2909
+ interruptAfterTimeLimit: 1e4
2910
+ });
2911
+ }, 3e4);
2829
2912
  test(`single byte values cover full range (0-255) with concurrent readers during write`, async () => {
2830
2913
  await fc.assert(fc.asyncProperty(
2831
2914
  // Generate a byte value from 0-255
@@ -2858,8 +2941,11 @@ function runConformanceTests(options) {
2858
2941
  const finalResult = await readEntireStream(streamPath);
2859
2942
  expect(finalResult).toEqual(expected);
2860
2943
  }
2861
- ), { numRuns: 50 });
2862
- });
2944
+ ), {
2945
+ numRuns: 50,
2946
+ interruptAfterTimeLimit: 1e4
2947
+ });
2948
+ }, 3e4);
2863
2949
  });
2864
2950
  describe(`Operation Sequence Properties`, () => {
2865
2951
  test(`random operation sequences maintain stream invariants`, async () => {
@@ -2936,8 +3022,11 @@ function runConformanceTests(options) {
2936
3022
  }
2937
3023
  return true;
2938
3024
  }
2939
- ), { numRuns: 15 });
2940
- });
3025
+ ), {
3026
+ numRuns: 15,
3027
+ interruptAfterTimeLimit: 3e4
3028
+ });
3029
+ }, 6e4);
2941
3030
  test(`offsets are always monotonically increasing`, async () => {
2942
3031
  await fc.assert(fc.asyncProperty(
2943
3032
  // Generate multiple chunks to append
@@ -5020,6 +5109,1650 @@ function runConformanceTests(options) {
5020
5109
  });
5021
5110
  });
5022
5111
  });
5112
+ describe(`Fork - Creation`, () => {
5113
+ const STREAM_FORKED_FROM_HEADER = `Stream-Forked-From`;
5114
+ const STREAM_FORK_OFFSET_HEADER = `Stream-Fork-Offset`;
5115
+ const STREAM_CLOSED_HEADER_FORK = `Stream-Closed`;
5116
+ const uniqueId = () => `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
5117
+ test(`should fork at current head (default)`, async () => {
5118
+ const id = uniqueId();
5119
+ const sourcePath = `/v1/stream/fork-create-head-src-${id}`;
5120
+ const forkPath = `/v1/stream/fork-create-head-fork-${id}`;
5121
+ const createRes = await fetch(`${getBaseUrl()}${sourcePath}`, {
5122
+ method: `PUT`,
5123
+ headers: { "Content-Type": `text/plain` },
5124
+ body: `source data`
5125
+ });
5126
+ expect(createRes.status).toBe(201);
5127
+ const sourceOffset = createRes.headers.get(STREAM_OFFSET_HEADER);
5128
+ expect(sourceOffset).toBeDefined();
5129
+ const forkRes = await fetch(`${getBaseUrl()}${forkPath}`, {
5130
+ method: `PUT`,
5131
+ headers: {
5132
+ "Content-Type": `text/plain`,
5133
+ [STREAM_FORKED_FROM_HEADER]: sourcePath
5134
+ }
5135
+ });
5136
+ expect(forkRes.status).toBe(201);
5137
+ });
5138
+ test(`should fork at a specific offset`, async () => {
5139
+ const id = uniqueId();
5140
+ const sourcePath = `/v1/stream/fork-create-offset-src-${id}`;
5141
+ const forkPath = `/v1/stream/fork-create-offset-fork-${id}`;
5142
+ const createRes = await fetch(`${getBaseUrl()}${sourcePath}`, {
5143
+ method: `PUT`,
5144
+ headers: { "Content-Type": `text/plain` },
5145
+ body: `first`
5146
+ });
5147
+ expect(createRes.status).toBe(201);
5148
+ const midOffset = createRes.headers.get(STREAM_OFFSET_HEADER);
5149
+ await fetch(`${getBaseUrl()}${sourcePath}`, {
5150
+ method: `POST`,
5151
+ headers: { "Content-Type": `text/plain` },
5152
+ body: `second`
5153
+ });
5154
+ const forkRes = await fetch(`${getBaseUrl()}${forkPath}`, {
5155
+ method: `PUT`,
5156
+ headers: {
5157
+ "Content-Type": `text/plain`,
5158
+ [STREAM_FORKED_FROM_HEADER]: sourcePath,
5159
+ [STREAM_FORK_OFFSET_HEADER]: midOffset
5160
+ }
5161
+ });
5162
+ expect(forkRes.status).toBe(201);
5163
+ const readRes = await fetch(`${getBaseUrl()}${forkPath}?offset=-1`);
5164
+ expect(readRes.status).toBe(200);
5165
+ const body = await readRes.text();
5166
+ expect(body).toBe(`first`);
5167
+ });
5168
+ test(`should fork at zero offset (empty inherited data)`, async () => {
5169
+ const id = uniqueId();
5170
+ const sourcePath = `/v1/stream/fork-create-zero-src-${id}`;
5171
+ const forkPath = `/v1/stream/fork-create-zero-fork-${id}`;
5172
+ await fetch(`${getBaseUrl()}${sourcePath}`, {
5173
+ method: `PUT`,
5174
+ headers: { "Content-Type": `text/plain` },
5175
+ body: `source data`
5176
+ });
5177
+ const zeroOffset = `0000000000000000_0000000000000000`;
5178
+ const forkRes = await fetch(`${getBaseUrl()}${forkPath}`, {
5179
+ method: `PUT`,
5180
+ headers: {
5181
+ "Content-Type": `text/plain`,
5182
+ [STREAM_FORKED_FROM_HEADER]: sourcePath,
5183
+ [STREAM_FORK_OFFSET_HEADER]: zeroOffset
5184
+ }
5185
+ });
5186
+ expect(forkRes.status).toBe(201);
5187
+ const readRes = await fetch(`${getBaseUrl()}${forkPath}?offset=-1`);
5188
+ expect(readRes.status).toBe(200);
5189
+ const body = await readRes.text();
5190
+ expect(body).toBe(``);
5191
+ expect(readRes.headers.get(STREAM_UP_TO_DATE_HEADER)).toBe(`true`);
5192
+ });
5193
+ test(`should fork at head offset (all source data inherited)`, async () => {
5194
+ const id = uniqueId();
5195
+ const sourcePath = `/v1/stream/fork-create-all-src-${id}`;
5196
+ const forkPath = `/v1/stream/fork-create-all-fork-${id}`;
5197
+ await fetch(`${getBaseUrl()}${sourcePath}`, {
5198
+ method: `PUT`,
5199
+ headers: { "Content-Type": `text/plain` },
5200
+ body: `chunk1`
5201
+ });
5202
+ await fetch(`${getBaseUrl()}${sourcePath}`, {
5203
+ method: `POST`,
5204
+ headers: { "Content-Type": `text/plain` },
5205
+ body: `chunk2`
5206
+ });
5207
+ const headRes = await fetch(`${getBaseUrl()}${sourcePath}`, { method: `HEAD` });
5208
+ const headOffset = headRes.headers.get(STREAM_OFFSET_HEADER);
5209
+ const forkRes = await fetch(`${getBaseUrl()}${forkPath}`, {
5210
+ method: `PUT`,
5211
+ headers: {
5212
+ "Content-Type": `text/plain`,
5213
+ [STREAM_FORKED_FROM_HEADER]: sourcePath,
5214
+ [STREAM_FORK_OFFSET_HEADER]: headOffset
5215
+ }
5216
+ });
5217
+ expect(forkRes.status).toBe(201);
5218
+ const readRes = await fetch(`${getBaseUrl()}${forkPath}?offset=-1`);
5219
+ expect(readRes.status).toBe(200);
5220
+ const body = await readRes.text();
5221
+ expect(body).toBe(`chunk1chunk2`);
5222
+ });
5223
+ test(`should return 404 when forking a nonexistent stream`, async () => {
5224
+ const id = uniqueId();
5225
+ const forkPath = `/v1/stream/fork-create-404-fork-${id}`;
5226
+ const forkRes = await fetch(`${getBaseUrl()}${forkPath}`, {
5227
+ method: `PUT`,
5228
+ headers: {
5229
+ "Content-Type": `text/plain`,
5230
+ [STREAM_FORKED_FROM_HEADER]: `/v1/stream/nonexistent-${id}`
5231
+ }
5232
+ });
5233
+ expect(forkRes.status).toBe(404);
5234
+ });
5235
+ test(`should return 400 when forking at offset beyond stream length`, async () => {
5236
+ const id = uniqueId();
5237
+ const sourcePath = `/v1/stream/fork-create-beyond-src-${id}`;
5238
+ const forkPath = `/v1/stream/fork-create-beyond-fork-${id}`;
5239
+ await fetch(`${getBaseUrl()}${sourcePath}`, {
5240
+ method: `PUT`,
5241
+ headers: { "Content-Type": `text/plain` },
5242
+ body: `small data`
5243
+ });
5244
+ const forkRes = await fetch(`${getBaseUrl()}${forkPath}`, {
5245
+ method: `PUT`,
5246
+ headers: {
5247
+ "Content-Type": `text/plain`,
5248
+ [STREAM_FORKED_FROM_HEADER]: sourcePath,
5249
+ [STREAM_FORK_OFFSET_HEADER]: `9999999999999999_9999999999999999`
5250
+ }
5251
+ });
5252
+ expect(forkRes.status).toBe(400);
5253
+ });
5254
+ test(`should return 409 when forking to path already in use with different config`, async () => {
5255
+ const id = uniqueId();
5256
+ const sourcePath = `/v1/stream/fork-create-conflict-src-${id}`;
5257
+ const forkPath = `/v1/stream/fork-create-conflict-fork-${id}`;
5258
+ await fetch(`${getBaseUrl()}${sourcePath}`, {
5259
+ method: `PUT`,
5260
+ headers: { "Content-Type": `text/plain` },
5261
+ body: `source`
5262
+ });
5263
+ await fetch(`${getBaseUrl()}${forkPath}`, {
5264
+ method: `PUT`,
5265
+ headers: { "Content-Type": `application/json` }
5266
+ });
5267
+ const forkRes = await fetch(`${getBaseUrl()}${forkPath}`, {
5268
+ method: `PUT`,
5269
+ headers: {
5270
+ "Content-Type": `text/plain`,
5271
+ [STREAM_FORKED_FROM_HEADER]: sourcePath
5272
+ }
5273
+ });
5274
+ expect(forkRes.status).toBe(409);
5275
+ });
5276
+ test(`should fork a closed stream — fork starts open`, async () => {
5277
+ const id = uniqueId();
5278
+ const sourcePath = `/v1/stream/fork-create-closed-src-${id}`;
5279
+ const forkPath = `/v1/stream/fork-create-closed-fork-${id}`;
5280
+ await fetch(`${getBaseUrl()}${sourcePath}`, {
5281
+ method: `PUT`,
5282
+ headers: { "Content-Type": `text/plain` },
5283
+ body: `closed data`
5284
+ });
5285
+ await fetch(`${getBaseUrl()}${sourcePath}`, {
5286
+ method: `POST`,
5287
+ headers: { [STREAM_CLOSED_HEADER_FORK]: `true` }
5288
+ });
5289
+ const forkRes = await fetch(`${getBaseUrl()}${forkPath}`, {
5290
+ method: `PUT`,
5291
+ headers: {
5292
+ "Content-Type": `text/plain`,
5293
+ [STREAM_FORKED_FROM_HEADER]: sourcePath
5294
+ }
5295
+ });
5296
+ expect(forkRes.status).toBe(201);
5297
+ expect(forkRes.headers.get(STREAM_CLOSED_HEADER_FORK)).toBeNull();
5298
+ const appendRes = await fetch(`${getBaseUrl()}${forkPath}`, {
5299
+ method: `POST`,
5300
+ headers: { "Content-Type": `text/plain` },
5301
+ body: ` fork data`
5302
+ });
5303
+ expect(appendRes.status).toBe(204);
5304
+ });
5305
+ test(`should fork an empty stream`, async () => {
5306
+ const id = uniqueId();
5307
+ const sourcePath = `/v1/stream/fork-create-empty-src-${id}`;
5308
+ const forkPath = `/v1/stream/fork-create-empty-fork-${id}`;
5309
+ await fetch(`${getBaseUrl()}${sourcePath}`, {
5310
+ method: `PUT`,
5311
+ headers: { "Content-Type": `text/plain` }
5312
+ });
5313
+ const forkRes = await fetch(`${getBaseUrl()}${forkPath}`, {
5314
+ method: `PUT`,
5315
+ headers: {
5316
+ "Content-Type": `text/plain`,
5317
+ [STREAM_FORKED_FROM_HEADER]: sourcePath
5318
+ }
5319
+ });
5320
+ expect(forkRes.status).toBe(201);
5321
+ const readRes = await fetch(`${getBaseUrl()}${forkPath}?offset=-1`);
5322
+ expect(readRes.status).toBe(200);
5323
+ const body = await readRes.text();
5324
+ expect(body).toBe(``);
5325
+ expect(readRes.headers.get(STREAM_UP_TO_DATE_HEADER)).toBe(`true`);
5326
+ });
5327
+ test(`should fork preserving content-type when specified`, async () => {
5328
+ const id = uniqueId();
5329
+ const sourcePath = `/v1/stream/fork-create-ct-src-${id}`;
5330
+ const forkPath = `/v1/stream/fork-create-ct-fork-${id}`;
5331
+ await fetch(`${getBaseUrl()}${sourcePath}`, {
5332
+ method: `PUT`,
5333
+ headers: { "Content-Type": `application/json` },
5334
+ body: `[{"key":"value"}]`
5335
+ });
5336
+ const forkRes = await fetch(`${getBaseUrl()}${forkPath}`, {
5337
+ method: `PUT`,
5338
+ headers: {
5339
+ "Content-Type": `application/json`,
5340
+ [STREAM_FORKED_FROM_HEADER]: sourcePath
5341
+ }
5342
+ });
5343
+ expect(forkRes.status).toBe(201);
5344
+ expect(forkRes.headers.get(`content-type`)).toBe(`application/json`);
5345
+ const headRes = await fetch(`${getBaseUrl()}${forkPath}`, { method: `HEAD` });
5346
+ expect(headRes.headers.get(`content-type`)).toBe(`application/json`);
5347
+ });
5348
+ });
5349
+ describe(`Fork - Reading`, () => {
5350
+ const STREAM_FORKED_FROM_HEADER = `Stream-Forked-From`;
5351
+ const STREAM_FORK_OFFSET_HEADER = `Stream-Fork-Offset`;
5352
+ const uniqueId = () => `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
5353
+ test(`should read entire fork (source + fork data)`, async () => {
5354
+ const id = uniqueId();
5355
+ const sourcePath = `/v1/stream/fork-read-entire-src-${id}`;
5356
+ const forkPath = `/v1/stream/fork-read-entire-fork-${id}`;
5357
+ await fetch(`${getBaseUrl()}${sourcePath}`, {
5358
+ method: `PUT`,
5359
+ headers: { "Content-Type": `text/plain` },
5360
+ body: `source`
5361
+ });
5362
+ await fetch(`${getBaseUrl()}${forkPath}`, {
5363
+ method: `PUT`,
5364
+ headers: {
5365
+ "Content-Type": `text/plain`,
5366
+ [STREAM_FORKED_FROM_HEADER]: sourcePath
5367
+ }
5368
+ });
5369
+ await fetch(`${getBaseUrl()}${forkPath}`, {
5370
+ method: `POST`,
5371
+ headers: { "Content-Type": `text/plain` },
5372
+ body: ` fork`
5373
+ });
5374
+ const readRes = await fetch(`${getBaseUrl()}${forkPath}?offset=-1`);
5375
+ expect(readRes.status).toBe(200);
5376
+ const body = await readRes.text();
5377
+ expect(body).toBe(`source fork`);
5378
+ });
5379
+ test(`should read only inherited portion`, async () => {
5380
+ const id = uniqueId();
5381
+ const sourcePath = `/v1/stream/fork-read-inherited-src-${id}`;
5382
+ const forkPath = `/v1/stream/fork-read-inherited-fork-${id}`;
5383
+ await fetch(`${getBaseUrl()}${sourcePath}`, {
5384
+ method: `PUT`,
5385
+ headers: { "Content-Type": `text/plain` },
5386
+ body: `inherited data`
5387
+ });
5388
+ await fetch(`${getBaseUrl()}${forkPath}`, {
5389
+ method: `PUT`,
5390
+ headers: {
5391
+ "Content-Type": `text/plain`,
5392
+ [STREAM_FORKED_FROM_HEADER]: sourcePath
5393
+ }
5394
+ });
5395
+ const readRes = await fetch(`${getBaseUrl()}${forkPath}?offset=-1`);
5396
+ expect(readRes.status).toBe(200);
5397
+ const body = await readRes.text();
5398
+ expect(body).toBe(`inherited data`);
5399
+ expect(readRes.headers.get(STREAM_UP_TO_DATE_HEADER)).toBe(`true`);
5400
+ });
5401
+ test(`should read only fork's own data (starting past fork offset)`, async () => {
5402
+ const id = uniqueId();
5403
+ const sourcePath = `/v1/stream/fork-read-own-src-${id}`;
5404
+ const forkPath = `/v1/stream/fork-read-own-fork-${id}`;
5405
+ await fetch(`${getBaseUrl()}${sourcePath}`, {
5406
+ method: `PUT`,
5407
+ headers: { "Content-Type": `text/plain` },
5408
+ body: `source`
5409
+ });
5410
+ const sourceHead = await fetch(`${getBaseUrl()}${sourcePath}`, { method: `HEAD` });
5411
+ const forkOffset = sourceHead.headers.get(STREAM_OFFSET_HEADER);
5412
+ await fetch(`${getBaseUrl()}${forkPath}`, {
5413
+ method: `PUT`,
5414
+ headers: {
5415
+ "Content-Type": `text/plain`,
5416
+ [STREAM_FORKED_FROM_HEADER]: sourcePath
5417
+ }
5418
+ });
5419
+ await fetch(`${getBaseUrl()}${forkPath}`, {
5420
+ method: `POST`,
5421
+ headers: { "Content-Type": `text/plain` },
5422
+ body: `fork only`
5423
+ });
5424
+ const readRes = await fetch(`${getBaseUrl()}${forkPath}?offset=${forkOffset}`);
5425
+ expect(readRes.status).toBe(200);
5426
+ const body = await readRes.text();
5427
+ expect(body).toBe(`fork only`);
5428
+ });
5429
+ test(`should read across fork boundary`, async () => {
5430
+ const id = uniqueId();
5431
+ const sourcePath = `/v1/stream/fork-read-boundary-src-${id}`;
5432
+ const forkPath = `/v1/stream/fork-read-boundary-fork-${id}`;
5433
+ await fetch(`${getBaseUrl()}${sourcePath}`, {
5434
+ method: `PUT`,
5435
+ headers: { "Content-Type": `text/plain` },
5436
+ body: `A`
5437
+ });
5438
+ await fetch(`${getBaseUrl()}${sourcePath}`, {
5439
+ method: `POST`,
5440
+ headers: { "Content-Type": `text/plain` },
5441
+ body: `B`
5442
+ });
5443
+ await fetch(`${getBaseUrl()}${forkPath}`, {
5444
+ method: `PUT`,
5445
+ headers: {
5446
+ "Content-Type": `text/plain`,
5447
+ [STREAM_FORKED_FROM_HEADER]: sourcePath
5448
+ }
5449
+ });
5450
+ await fetch(`${getBaseUrl()}${forkPath}`, {
5451
+ method: `POST`,
5452
+ headers: { "Content-Type": `text/plain` },
5453
+ body: `C`
5454
+ });
5455
+ const readRes = await fetch(`${getBaseUrl()}${forkPath}?offset=-1`);
5456
+ expect(readRes.status).toBe(200);
5457
+ const body = await readRes.text();
5458
+ expect(body).toBe(`ABC`);
5459
+ });
5460
+ test(`should not show source appends after fork`, async () => {
5461
+ const id = uniqueId();
5462
+ const sourcePath = `/v1/stream/fork-read-isolation-src-${id}`;
5463
+ const forkPath = `/v1/stream/fork-read-isolation-fork-${id}`;
5464
+ await fetch(`${getBaseUrl()}${sourcePath}`, {
5465
+ method: `PUT`,
5466
+ headers: { "Content-Type": `text/plain` },
5467
+ body: `before`
5468
+ });
5469
+ await fetch(`${getBaseUrl()}${forkPath}`, {
5470
+ method: `PUT`,
5471
+ headers: {
5472
+ "Content-Type": `text/plain`,
5473
+ [STREAM_FORKED_FROM_HEADER]: sourcePath
5474
+ }
5475
+ });
5476
+ await fetch(`${getBaseUrl()}${sourcePath}`, {
5477
+ method: `POST`,
5478
+ headers: { "Content-Type": `text/plain` },
5479
+ body: ` after`
5480
+ });
5481
+ const readRes = await fetch(`${getBaseUrl()}${forkPath}?offset=-1`);
5482
+ expect(readRes.status).toBe(200);
5483
+ const body = await readRes.text();
5484
+ expect(body).toBe(`before`);
5485
+ });
5486
+ test(`should NOT include fork headers on HEAD/GET/PUT responses (forks are transparent)`, async () => {
5487
+ const id = uniqueId();
5488
+ const sourcePath = `/v1/stream/fork-read-headers-src-${id}`;
5489
+ const forkPath = `/v1/stream/fork-read-headers-fork-${id}`;
5490
+ await fetch(`${getBaseUrl()}${sourcePath}`, {
5491
+ method: `PUT`,
5492
+ headers: { "Content-Type": `text/plain` },
5493
+ body: `data`
5494
+ });
5495
+ const putRes = await fetch(`${getBaseUrl()}${forkPath}`, {
5496
+ method: `PUT`,
5497
+ headers: {
5498
+ "Content-Type": `text/plain`,
5499
+ [STREAM_FORKED_FROM_HEADER]: sourcePath
5500
+ }
5501
+ });
5502
+ expect(putRes.headers.get(STREAM_FORKED_FROM_HEADER)).toBeNull();
5503
+ expect(putRes.headers.get(STREAM_FORK_OFFSET_HEADER)).toBeNull();
5504
+ const headRes = await fetch(`${getBaseUrl()}${forkPath}`, { method: `HEAD` });
5505
+ expect(headRes.headers.get(STREAM_FORKED_FROM_HEADER)).toBeNull();
5506
+ expect(headRes.headers.get(STREAM_FORK_OFFSET_HEADER)).toBeNull();
5507
+ const getRes = await fetch(`${getBaseUrl()}${forkPath}?offset=-1`);
5508
+ await getRes.text();
5509
+ expect(getRes.headers.get(STREAM_FORKED_FROM_HEADER)).toBeNull();
5510
+ expect(getRes.headers.get(STREAM_FORK_OFFSET_HEADER)).toBeNull();
5511
+ });
5512
+ });
5513
+ describe(`Fork - Appending`, () => {
5514
+ const STREAM_FORKED_FROM_HEADER = `Stream-Forked-From`;
5515
+ const STREAM_CLOSED_HEADER_FORK = `Stream-Closed`;
5516
+ const uniqueId = () => `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
5517
+ test(`should append to a fork`, async () => {
5518
+ const id = uniqueId();
5519
+ const sourcePath = `/v1/stream/fork-append-src-${id}`;
5520
+ const forkPath = `/v1/stream/fork-append-fork-${id}`;
5521
+ await fetch(`${getBaseUrl()}${sourcePath}`, {
5522
+ method: `PUT`,
5523
+ headers: { "Content-Type": `text/plain` },
5524
+ body: `source`
5525
+ });
5526
+ await fetch(`${getBaseUrl()}${forkPath}`, {
5527
+ method: `PUT`,
5528
+ headers: {
5529
+ "Content-Type": `text/plain`,
5530
+ [STREAM_FORKED_FROM_HEADER]: sourcePath
5531
+ }
5532
+ });
5533
+ const appendRes = await fetch(`${getBaseUrl()}${forkPath}`, {
5534
+ method: `POST`,
5535
+ headers: { "Content-Type": `text/plain` },
5536
+ body: ` appended`
5537
+ });
5538
+ expect(appendRes.status).toBe(204);
5539
+ expect(appendRes.headers.get(STREAM_OFFSET_HEADER)).toBeDefined();
5540
+ const readRes = await fetch(`${getBaseUrl()}${forkPath}?offset=-1`);
5541
+ const body = await readRes.text();
5542
+ expect(body).toBe(`source appended`);
5543
+ });
5544
+ test(`should support idempotent producer on fork`, async () => {
5545
+ const id = uniqueId();
5546
+ const sourcePath = `/v1/stream/fork-append-idempotent-src-${id}`;
5547
+ const forkPath = `/v1/stream/fork-append-idempotent-fork-${id}`;
5548
+ await fetch(`${getBaseUrl()}${sourcePath}`, {
5549
+ method: `PUT`,
5550
+ headers: { "Content-Type": `text/plain` },
5551
+ body: `source`
5552
+ });
5553
+ await fetch(`${getBaseUrl()}${forkPath}`, {
5554
+ method: `PUT`,
5555
+ headers: {
5556
+ "Content-Type": `text/plain`,
5557
+ [STREAM_FORKED_FROM_HEADER]: sourcePath
5558
+ }
5559
+ });
5560
+ const append1 = await fetch(`${getBaseUrl()}${forkPath}`, {
5561
+ method: `POST`,
5562
+ headers: {
5563
+ "Content-Type": `text/plain`,
5564
+ "Producer-Id": `fork-producer-${id}`,
5565
+ "Producer-Epoch": `0`,
5566
+ "Producer-Seq": `0`
5567
+ },
5568
+ body: `msg1`
5569
+ });
5570
+ expect(append1.status).toBe(200);
5571
+ const append1Retry = await fetch(`${getBaseUrl()}${forkPath}`, {
5572
+ method: `POST`,
5573
+ headers: {
5574
+ "Content-Type": `text/plain`,
5575
+ "Producer-Id": `fork-producer-${id}`,
5576
+ "Producer-Epoch": `0`,
5577
+ "Producer-Seq": `0`
5578
+ },
5579
+ body: `msg1`
5580
+ });
5581
+ expect(append1Retry.status).toBe(204);
5582
+ const readRes = await fetch(`${getBaseUrl()}${forkPath}?offset=-1`);
5583
+ const body = await readRes.text();
5584
+ expect(body).toBe(`sourcemsg1`);
5585
+ });
5586
+ test(`should close forked stream independently`, async () => {
5587
+ const id = uniqueId();
5588
+ const sourcePath = `/v1/stream/fork-append-close-src-${id}`;
5589
+ const forkPath = `/v1/stream/fork-append-close-fork-${id}`;
5590
+ await fetch(`${getBaseUrl()}${sourcePath}`, {
5591
+ method: `PUT`,
5592
+ headers: { "Content-Type": `text/plain` },
5593
+ body: `source`
5594
+ });
5595
+ await fetch(`${getBaseUrl()}${forkPath}`, {
5596
+ method: `PUT`,
5597
+ headers: {
5598
+ "Content-Type": `text/plain`,
5599
+ [STREAM_FORKED_FROM_HEADER]: sourcePath
5600
+ }
5601
+ });
5602
+ await fetch(`${getBaseUrl()}${forkPath}`, {
5603
+ method: `POST`,
5604
+ headers: { "Content-Type": `text/plain` },
5605
+ body: ` final`
5606
+ });
5607
+ const closeRes = await fetch(`${getBaseUrl()}${forkPath}`, {
5608
+ method: `POST`,
5609
+ headers: { [STREAM_CLOSED_HEADER_FORK]: `true` }
5610
+ });
5611
+ expect([200, 204]).toContain(closeRes.status);
5612
+ expect(closeRes.headers.get(STREAM_CLOSED_HEADER_FORK)).toBe(`true`);
5613
+ const sourceHead = await fetch(`${getBaseUrl()}${sourcePath}`, { method: `HEAD` });
5614
+ expect(sourceHead.headers.get(STREAM_CLOSED_HEADER_FORK)).toBeNull();
5615
+ });
5616
+ test(`should not affect fork when source is closed`, async () => {
5617
+ const id = uniqueId();
5618
+ const sourcePath = `/v1/stream/fork-append-src-close-src-${id}`;
5619
+ const forkPath = `/v1/stream/fork-append-src-close-fork-${id}`;
5620
+ await fetch(`${getBaseUrl()}${sourcePath}`, {
5621
+ method: `PUT`,
5622
+ headers: { "Content-Type": `text/plain` },
5623
+ body: `source`
5624
+ });
5625
+ await fetch(`${getBaseUrl()}${forkPath}`, {
5626
+ method: `PUT`,
5627
+ headers: {
5628
+ "Content-Type": `text/plain`,
5629
+ [STREAM_FORKED_FROM_HEADER]: sourcePath
5630
+ }
5631
+ });
5632
+ await fetch(`${getBaseUrl()}${sourcePath}`, {
5633
+ method: `POST`,
5634
+ headers: { [STREAM_CLOSED_HEADER_FORK]: `true` }
5635
+ });
5636
+ const appendRes = await fetch(`${getBaseUrl()}${forkPath}`, {
5637
+ method: `POST`,
5638
+ headers: { "Content-Type": `text/plain` },
5639
+ body: ` fork data`
5640
+ });
5641
+ expect(appendRes.status).toBe(204);
5642
+ const forkHead = await fetch(`${getBaseUrl()}${forkPath}`, { method: `HEAD` });
5643
+ expect(forkHead.headers.get(STREAM_CLOSED_HEADER_FORK)).toBeNull();
5644
+ });
5645
+ test(`should append to source after fork — source independent`, async () => {
5646
+ const id = uniqueId();
5647
+ const sourcePath = `/v1/stream/fork-append-src-indep-src-${id}`;
5648
+ const forkPath = `/v1/stream/fork-append-src-indep-fork-${id}`;
5649
+ await fetch(`${getBaseUrl()}${sourcePath}`, {
5650
+ method: `PUT`,
5651
+ headers: { "Content-Type": `text/plain` },
5652
+ body: `initial`
5653
+ });
5654
+ await fetch(`${getBaseUrl()}${forkPath}`, {
5655
+ method: `PUT`,
5656
+ headers: {
5657
+ "Content-Type": `text/plain`,
5658
+ [STREAM_FORKED_FROM_HEADER]: sourcePath
5659
+ }
5660
+ });
5661
+ const appendRes = await fetch(`${getBaseUrl()}${sourcePath}`, {
5662
+ method: `POST`,
5663
+ headers: { "Content-Type": `text/plain` },
5664
+ body: ` extra`
5665
+ });
5666
+ expect(appendRes.status).toBe(204);
5667
+ const sourceRead = await fetch(`${getBaseUrl()}${sourcePath}?offset=-1`);
5668
+ const sourceBody = await sourceRead.text();
5669
+ expect(sourceBody).toBe(`initial extra`);
5670
+ const forkRead = await fetch(`${getBaseUrl()}${forkPath}?offset=-1`);
5671
+ const forkBody = await forkRead.text();
5672
+ expect(forkBody).toBe(`initial`);
5673
+ });
5674
+ });
5675
+ describe(`Fork - Recursive`, () => {
5676
+ const STREAM_FORKED_FROM_HEADER = `Stream-Forked-From`;
5677
+ const uniqueId = () => `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
5678
+ test(`should create a three-level fork chain`, async () => {
5679
+ const id = uniqueId();
5680
+ const level0 = `/v1/stream/fork-recursive-l0-${id}`;
5681
+ const level1 = `/v1/stream/fork-recursive-l1-${id}`;
5682
+ const level2 = `/v1/stream/fork-recursive-l2-${id}`;
5683
+ await fetch(`${getBaseUrl()}${level0}`, {
5684
+ method: `PUT`,
5685
+ headers: { "Content-Type": `text/plain` },
5686
+ body: `L0`
5687
+ });
5688
+ const fork1Res = await fetch(`${getBaseUrl()}${level1}`, {
5689
+ method: `PUT`,
5690
+ headers: {
5691
+ "Content-Type": `text/plain`,
5692
+ [STREAM_FORKED_FROM_HEADER]: level0
5693
+ }
5694
+ });
5695
+ expect(fork1Res.status).toBe(201);
5696
+ const fork2Res = await fetch(`${getBaseUrl()}${level2}`, {
5697
+ method: `PUT`,
5698
+ headers: {
5699
+ "Content-Type": `text/plain`,
5700
+ [STREAM_FORKED_FROM_HEADER]: level1
5701
+ }
5702
+ });
5703
+ expect(fork2Res.status).toBe(201);
5704
+ });
5705
+ test(`should fork at mid-point of inherited data`, async () => {
5706
+ const id = uniqueId();
5707
+ const level0 = `/v1/stream/fork-recursive-mid-l0-${id}`;
5708
+ const level1 = `/v1/stream/fork-recursive-mid-l1-${id}`;
5709
+ const level2 = `/v1/stream/fork-recursive-mid-l2-${id}`;
5710
+ await fetch(`${getBaseUrl()}${level0}`, {
5711
+ method: `PUT`,
5712
+ headers: { "Content-Type": `text/plain` },
5713
+ body: `A`
5714
+ });
5715
+ await fetch(`${getBaseUrl()}${level0}`, {
5716
+ method: `POST`,
5717
+ headers: { "Content-Type": `text/plain` },
5718
+ body: `B`
5719
+ });
5720
+ await fetch(`${getBaseUrl()}${level1}`, {
5721
+ method: `PUT`,
5722
+ headers: {
5723
+ "Content-Type": `text/plain`,
5724
+ [STREAM_FORKED_FROM_HEADER]: level0
5725
+ }
5726
+ });
5727
+ await fetch(`${getBaseUrl()}${level1}`, {
5728
+ method: `POST`,
5729
+ headers: { "Content-Type": `text/plain` },
5730
+ body: `C`
5731
+ });
5732
+ const l1Head = await fetch(`${getBaseUrl()}${level1}`, { method: `HEAD` });
5733
+ expect(l1Head.headers.get(STREAM_OFFSET_HEADER)).toBeDefined();
5734
+ const fork2Res = await fetch(`${getBaseUrl()}${level2}`, {
5735
+ method: `PUT`,
5736
+ headers: {
5737
+ "Content-Type": `text/plain`,
5738
+ [STREAM_FORKED_FROM_HEADER]: level1
5739
+ }
5740
+ });
5741
+ expect(fork2Res.status).toBe(201);
5742
+ const readRes = await fetch(`${getBaseUrl()}${level2}?offset=-1`);
5743
+ const body = await readRes.text();
5744
+ expect(body).toBe(`ABC`);
5745
+ });
5746
+ test(`should read correctly across three levels`, async () => {
5747
+ const id = uniqueId();
5748
+ const level0 = `/v1/stream/fork-recursive-read-l0-${id}`;
5749
+ const level1 = `/v1/stream/fork-recursive-read-l1-${id}`;
5750
+ const level2 = `/v1/stream/fork-recursive-read-l2-${id}`;
5751
+ await fetch(`${getBaseUrl()}${level0}`, {
5752
+ method: `PUT`,
5753
+ headers: { "Content-Type": `text/plain` },
5754
+ body: `A`
5755
+ });
5756
+ await fetch(`${getBaseUrl()}${level1}`, {
5757
+ method: `PUT`,
5758
+ headers: {
5759
+ "Content-Type": `text/plain`,
5760
+ [STREAM_FORKED_FROM_HEADER]: level0
5761
+ }
5762
+ });
5763
+ await fetch(`${getBaseUrl()}${level1}`, {
5764
+ method: `POST`,
5765
+ headers: { "Content-Type": `text/plain` },
5766
+ body: `B`
5767
+ });
5768
+ await fetch(`${getBaseUrl()}${level2}`, {
5769
+ method: `PUT`,
5770
+ headers: {
5771
+ "Content-Type": `text/plain`,
5772
+ [STREAM_FORKED_FROM_HEADER]: level1
5773
+ }
5774
+ });
5775
+ await fetch(`${getBaseUrl()}${level2}`, {
5776
+ method: `POST`,
5777
+ headers: { "Content-Type": `text/plain` },
5778
+ body: `C`
5779
+ });
5780
+ const r0 = await (await fetch(`${getBaseUrl()}${level0}?offset=-1`)).text();
5781
+ expect(r0).toBe(`A`);
5782
+ const r1 = await (await fetch(`${getBaseUrl()}${level1}?offset=-1`)).text();
5783
+ expect(r1).toBe(`AB`);
5784
+ const r2 = await (await fetch(`${getBaseUrl()}${level2}?offset=-1`)).text();
5785
+ expect(r2).toBe(`ABC`);
5786
+ });
5787
+ test(`should append at each level independently`, async () => {
5788
+ const id = uniqueId();
5789
+ const level0 = `/v1/stream/fork-recursive-indep-l0-${id}`;
5790
+ const level1 = `/v1/stream/fork-recursive-indep-l1-${id}`;
5791
+ const level2 = `/v1/stream/fork-recursive-indep-l2-${id}`;
5792
+ await fetch(`${getBaseUrl()}${level0}`, {
5793
+ method: `PUT`,
5794
+ headers: { "Content-Type": `text/plain` },
5795
+ body: `X`
5796
+ });
5797
+ await fetch(`${getBaseUrl()}${level1}`, {
5798
+ method: `PUT`,
5799
+ headers: {
5800
+ "Content-Type": `text/plain`,
5801
+ [STREAM_FORKED_FROM_HEADER]: level0
5802
+ }
5803
+ });
5804
+ await fetch(`${getBaseUrl()}${level1}`, {
5805
+ method: `POST`,
5806
+ headers: { "Content-Type": `text/plain` },
5807
+ body: `Y`
5808
+ });
5809
+ await fetch(`${getBaseUrl()}${level2}`, {
5810
+ method: `PUT`,
5811
+ headers: {
5812
+ "Content-Type": `text/plain`,
5813
+ [STREAM_FORKED_FROM_HEADER]: level1
5814
+ }
5815
+ });
5816
+ await fetch(`${getBaseUrl()}${level2}`, {
5817
+ method: `POST`,
5818
+ headers: { "Content-Type": `text/plain` },
5819
+ body: `Z`
5820
+ });
5821
+ await fetch(`${getBaseUrl()}${level0}`, {
5822
+ method: `POST`,
5823
+ headers: { "Content-Type": `text/plain` },
5824
+ body: `0`
5825
+ });
5826
+ await fetch(`${getBaseUrl()}${level1}`, {
5827
+ method: `POST`,
5828
+ headers: { "Content-Type": `text/plain` },
5829
+ body: `1`
5830
+ });
5831
+ const r0 = await (await fetch(`${getBaseUrl()}${level0}?offset=-1`)).text();
5832
+ expect(r0).toBe(`X0`);
5833
+ const r1 = await (await fetch(`${getBaseUrl()}${level1}?offset=-1`)).text();
5834
+ expect(r1).toBe(`XY1`);
5835
+ const r2 = await (await fetch(`${getBaseUrl()}${level2}?offset=-1`)).text();
5836
+ expect(r2).toBe(`XYZ`);
5837
+ });
5838
+ });
5839
+ describe(`Fork - Live Modes`, () => {
5840
+ const STREAM_FORKED_FROM_HEADER = `Stream-Forked-From`;
5841
+ const uniqueId = () => `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
5842
+ test(`should return inherited data immediately on long-poll`, async () => {
5843
+ const id = uniqueId();
5844
+ const sourcePath = `/v1/stream/fork-live-inherited-src-${id}`;
5845
+ const forkPath = `/v1/stream/fork-live-inherited-fork-${id}`;
5846
+ await fetch(`${getBaseUrl()}${sourcePath}`, {
5847
+ method: `PUT`,
5848
+ headers: { "Content-Type": `text/plain` },
5849
+ body: `inherited data`
5850
+ });
5851
+ await fetch(`${getBaseUrl()}${forkPath}`, {
5852
+ method: `PUT`,
5853
+ headers: {
5854
+ "Content-Type": `text/plain`,
5855
+ [STREAM_FORKED_FROM_HEADER]: sourcePath
5856
+ }
5857
+ });
5858
+ const controller = new AbortController();
5859
+ const timeoutId = setTimeout(() => controller.abort(), 3e3);
5860
+ try {
5861
+ const response = await fetch(`${getBaseUrl()}${forkPath}?offset=-1&live=long-poll`, {
5862
+ method: `GET`,
5863
+ signal: controller.signal
5864
+ });
5865
+ clearTimeout(timeoutId);
5866
+ expect(response.status).toBe(200);
5867
+ const body = await response.text();
5868
+ expect(body).toBe(`inherited data`);
5869
+ expect(response.headers.get(STREAM_UP_TO_DATE_HEADER)).toBe(`true`);
5870
+ } catch (e) {
5871
+ clearTimeout(timeoutId);
5872
+ if (!(e instanceof Error && e.name === `AbortError`)) throw e;
5873
+ expect(true).toBe(false);
5874
+ }
5875
+ });
5876
+ test(`should wait for fork appends, not source appends, on long-poll at tail`, async () => {
5877
+ const id = uniqueId();
5878
+ const sourcePath = `/v1/stream/fork-live-tail-src-${id}`;
5879
+ const forkPath = `/v1/stream/fork-live-tail-fork-${id}`;
5880
+ await fetch(`${getBaseUrl()}${sourcePath}`, {
5881
+ method: `PUT`,
5882
+ headers: { "Content-Type": `text/plain` },
5883
+ body: `source`
5884
+ });
5885
+ await fetch(`${getBaseUrl()}${forkPath}`, {
5886
+ method: `PUT`,
5887
+ headers: {
5888
+ "Content-Type": `text/plain`,
5889
+ [STREAM_FORKED_FROM_HEADER]: sourcePath
5890
+ }
5891
+ });
5892
+ const forkHead = await fetch(`${getBaseUrl()}${forkPath}`, { method: `GET` });
5893
+ await forkHead.text();
5894
+ const forkOffset = forkHead.headers.get(STREAM_OFFSET_HEADER);
5895
+ const longPollPromise = fetch(`${getBaseUrl()}${forkPath}?offset=${forkOffset}&live=long-poll`, { method: `GET` });
5896
+ await new Promise((r) => setTimeout(r, 50));
5897
+ await fetch(`${getBaseUrl()}${sourcePath}`, {
5898
+ method: `POST`,
5899
+ headers: { "Content-Type": `text/plain` },
5900
+ body: ` source extra`
5901
+ });
5902
+ await fetch(`${getBaseUrl()}${forkPath}`, {
5903
+ method: `POST`,
5904
+ headers: { "Content-Type": `text/plain` },
5905
+ body: ` fork new`
5906
+ });
5907
+ const response = await longPollPromise;
5908
+ expect(response.status).toBe(200);
5909
+ const body = await response.text();
5910
+ expect(body).toBe(` fork new`);
5911
+ }, getLongPollTestTimeoutMs());
5912
+ test(`should stream fork data via SSE`, async () => {
5913
+ const id = uniqueId();
5914
+ const sourcePath = `/v1/stream/fork-live-sse-src-${id}`;
5915
+ const forkPath = `/v1/stream/fork-live-sse-fork-${id}`;
5916
+ await fetch(`${getBaseUrl()}${sourcePath}`, {
5917
+ method: `PUT`,
5918
+ headers: { "Content-Type": `text/plain` },
5919
+ body: `inherited`
5920
+ });
5921
+ await fetch(`${getBaseUrl()}${forkPath}`, {
5922
+ method: `PUT`,
5923
+ headers: {
5924
+ "Content-Type": `text/plain`,
5925
+ [STREAM_FORKED_FROM_HEADER]: sourcePath
5926
+ }
5927
+ });
5928
+ await fetch(`${getBaseUrl()}${forkPath}`, {
5929
+ method: `POST`,
5930
+ headers: { "Content-Type": `text/plain` },
5931
+ body: ` forked`
5932
+ });
5933
+ const { response, received } = await fetchSSE(`${getBaseUrl()}${forkPath}?offset=-1&live=sse`, {
5934
+ untilContent: `forked`,
5935
+ timeoutMs: 5e3,
5936
+ maxChunks: 20
5937
+ });
5938
+ expect(response.status).toBe(200);
5939
+ expect(received).toContain(`inherited`);
5940
+ expect(received).toContain(`forked`);
5941
+ });
5942
+ test(`should handle long-poll handover at fork offset`, async () => {
5943
+ const id = uniqueId();
5944
+ const sourcePath = `/v1/stream/fork-live-handover-src-${id}`;
5945
+ const forkPath = `/v1/stream/fork-live-handover-fork-${id}`;
5946
+ await fetch(`${getBaseUrl()}${sourcePath}`, {
5947
+ method: `PUT`,
5948
+ headers: { "Content-Type": `text/plain` },
5949
+ body: `source data`
5950
+ });
5951
+ await fetch(`${getBaseUrl()}${forkPath}`, {
5952
+ method: `PUT`,
5953
+ headers: {
5954
+ "Content-Type": `text/plain`,
5955
+ [STREAM_FORKED_FROM_HEADER]: sourcePath
5956
+ }
5957
+ });
5958
+ const readRes = await fetch(`${getBaseUrl()}${forkPath}?offset=-1&live=long-poll`);
5959
+ expect(readRes.status).toBe(200);
5960
+ const firstBody = await readRes.text();
5961
+ expect(firstBody).toBe(`source data`);
5962
+ const nextOffset = readRes.headers.get(STREAM_OFFSET_HEADER);
5963
+ await fetch(`${getBaseUrl()}${forkPath}`, {
5964
+ method: `POST`,
5965
+ headers: { "Content-Type": `text/plain` },
5966
+ body: ` fork append`
5967
+ });
5968
+ const readRes2 = await fetch(`${getBaseUrl()}${forkPath}?offset=${nextOffset}`);
5969
+ expect(readRes2.status).toBe(200);
5970
+ const secondBody = await readRes2.text();
5971
+ expect(secondBody).toBe(` fork append`);
5972
+ });
5973
+ });
5974
+ describe(`Fork - Deletion and Lifecycle`, () => {
5975
+ const STREAM_FORKED_FROM_HEADER = `Stream-Forked-From`;
5976
+ const uniqueId = () => `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
5977
+ const waitForStatus = async (url, expectedStatus, timeoutMs = 5e3) => {
5978
+ await vi.waitFor(async () => {
5979
+ const res = await fetch(url, { method: `HEAD` });
5980
+ expect(res.status).toBe(expectedStatus);
5981
+ }, {
5982
+ timeout: timeoutMs,
5983
+ interval: 200
5984
+ });
5985
+ };
5986
+ test(`should delete fork without affecting source`, async () => {
5987
+ const id = uniqueId();
5988
+ const sourcePath = `/v1/stream/fork-del-src-unaffected-src-${id}`;
5989
+ const forkPath = `/v1/stream/fork-del-src-unaffected-fork-${id}`;
5990
+ await fetch(`${getBaseUrl()}${sourcePath}`, {
5991
+ method: `PUT`,
5992
+ headers: { "Content-Type": `text/plain` },
5993
+ body: `source data`
5994
+ });
5995
+ await fetch(`${getBaseUrl()}${forkPath}`, {
5996
+ method: `PUT`,
5997
+ headers: {
5998
+ "Content-Type": `text/plain`,
5999
+ [STREAM_FORKED_FROM_HEADER]: sourcePath
6000
+ }
6001
+ });
6002
+ const deleteRes = await fetch(`${getBaseUrl()}${forkPath}`, { method: `DELETE` });
6003
+ expect(deleteRes.status).toBe(204);
6004
+ const forkRead = await fetch(`${getBaseUrl()}${forkPath}`, { method: `GET` });
6005
+ expect(forkRead.status).toBe(404);
6006
+ const sourceRead = await fetch(`${getBaseUrl()}${sourcePath}`, { method: `GET` });
6007
+ expect(sourceRead.status).toBe(200);
6008
+ const body = await sourceRead.text();
6009
+ expect(body).toBe(`source data`);
6010
+ });
6011
+ test(`should soft-delete source while fork exists — fork still reads`, async () => {
6012
+ const id = uniqueId();
6013
+ const sourcePath = `/v1/stream/fork-del-soft-src-${id}`;
6014
+ const forkPath = `/v1/stream/fork-del-soft-fork-${id}`;
6015
+ await fetch(`${getBaseUrl()}${sourcePath}`, {
6016
+ method: `PUT`,
6017
+ headers: { "Content-Type": `text/plain` },
6018
+ body: `preserved data`
6019
+ });
6020
+ await fetch(`${getBaseUrl()}${forkPath}`, {
6021
+ method: `PUT`,
6022
+ headers: {
6023
+ "Content-Type": `text/plain`,
6024
+ [STREAM_FORKED_FROM_HEADER]: sourcePath
6025
+ }
6026
+ });
6027
+ const deleteRes = await fetch(`${getBaseUrl()}${sourcePath}`, { method: `DELETE` });
6028
+ expect(deleteRes.status).toBe(204);
6029
+ const sourceHead = await fetch(`${getBaseUrl()}${sourcePath}`, { method: `HEAD` });
6030
+ expect(sourceHead.status).toBe(410);
6031
+ const forkRead = await fetch(`${getBaseUrl()}${forkPath}?offset=-1`);
6032
+ expect(forkRead.status).toBe(200);
6033
+ const body = await forkRead.text();
6034
+ expect(body).toBe(`preserved data`);
6035
+ });
6036
+ test(`should block re-creation of soft-deleted source (PUT returns 409)`, async () => {
6037
+ const id = uniqueId();
6038
+ const sourcePath = `/v1/stream/fork-del-block-recreate-src-${id}`;
6039
+ const forkPath = `/v1/stream/fork-del-block-recreate-fork-${id}`;
6040
+ await fetch(`${getBaseUrl()}${sourcePath}`, {
6041
+ method: `PUT`,
6042
+ headers: { "Content-Type": `text/plain` },
6043
+ body: `original`
6044
+ });
6045
+ await fetch(`${getBaseUrl()}${forkPath}`, {
6046
+ method: `PUT`,
6047
+ headers: {
6048
+ "Content-Type": `text/plain`,
6049
+ [STREAM_FORKED_FROM_HEADER]: sourcePath
6050
+ }
6051
+ });
6052
+ await fetch(`${getBaseUrl()}${sourcePath}`, { method: `DELETE` });
6053
+ const recreateRes = await fetch(`${getBaseUrl()}${sourcePath}`, {
6054
+ method: `PUT`,
6055
+ headers: { "Content-Type": `text/plain` }
6056
+ });
6057
+ expect(recreateRes.status).toBe(409);
6058
+ });
6059
+ test(`should return 410 for GET on soft-deleted source`, async () => {
6060
+ const id = uniqueId();
6061
+ const sourcePath = `/v1/stream/fork-del-soft-get-${id}`;
6062
+ const forkPath = `/v1/stream/fork-del-soft-get-fork-${id}`;
6063
+ await fetch(`${getBaseUrl()}${sourcePath}`, {
6064
+ method: `PUT`,
6065
+ headers: { "Content-Type": `text/plain` },
6066
+ body: `data`
6067
+ });
6068
+ await fetch(`${getBaseUrl()}${forkPath}`, {
6069
+ method: `PUT`,
6070
+ headers: {
6071
+ "Content-Type": `text/plain`,
6072
+ [STREAM_FORKED_FROM_HEADER]: sourcePath
6073
+ }
6074
+ });
6075
+ await fetch(`${getBaseUrl()}${sourcePath}`, { method: `DELETE` });
6076
+ const getRes = await fetch(`${getBaseUrl()}${sourcePath}?offset=-1`);
6077
+ expect(getRes.status).toBe(410);
6078
+ });
6079
+ test(`should return 410 for POST on soft-deleted source`, async () => {
6080
+ const id = uniqueId();
6081
+ const sourcePath = `/v1/stream/fork-del-soft-post-${id}`;
6082
+ const forkPath = `/v1/stream/fork-del-soft-post-fork-${id}`;
6083
+ await fetch(`${getBaseUrl()}${sourcePath}`, {
6084
+ method: `PUT`,
6085
+ headers: { "Content-Type": `text/plain` },
6086
+ body: `data`
6087
+ });
6088
+ await fetch(`${getBaseUrl()}${forkPath}`, {
6089
+ method: `PUT`,
6090
+ headers: {
6091
+ "Content-Type": `text/plain`,
6092
+ [STREAM_FORKED_FROM_HEADER]: sourcePath
6093
+ }
6094
+ });
6095
+ await fetch(`${getBaseUrl()}${sourcePath}`, { method: `DELETE` });
6096
+ const postRes = await fetch(`${getBaseUrl()}${sourcePath}`, {
6097
+ method: `POST`,
6098
+ headers: { "Content-Type": `text/plain` },
6099
+ body: `more data`
6100
+ });
6101
+ expect(postRes.status).toBe(410);
6102
+ });
6103
+ test(`should return 410 for DELETE on soft-deleted source`, async () => {
6104
+ const id = uniqueId();
6105
+ const sourcePath = `/v1/stream/fork-del-soft-del-${id}`;
6106
+ const forkPath = `/v1/stream/fork-del-soft-del-fork-${id}`;
6107
+ await fetch(`${getBaseUrl()}${sourcePath}`, {
6108
+ method: `PUT`,
6109
+ headers: { "Content-Type": `text/plain` },
6110
+ body: `data`
6111
+ });
6112
+ await fetch(`${getBaseUrl()}${forkPath}`, {
6113
+ method: `PUT`,
6114
+ headers: {
6115
+ "Content-Type": `text/plain`,
6116
+ [STREAM_FORKED_FROM_HEADER]: sourcePath
6117
+ }
6118
+ });
6119
+ await fetch(`${getBaseUrl()}${sourcePath}`, { method: `DELETE` });
6120
+ const deleteRes = await fetch(`${getBaseUrl()}${sourcePath}`, { method: `DELETE` });
6121
+ expect(deleteRes.status).toBe(410);
6122
+ });
6123
+ test(`should return 409 for fork from soft-deleted source`, async () => {
6124
+ const id = uniqueId();
6125
+ const sourcePath = `/v1/stream/fork-del-soft-refork-${id}`;
6126
+ const forkPath = `/v1/stream/fork-del-soft-refork-fork1-${id}`;
6127
+ const fork2Path = `/v1/stream/fork-del-soft-refork-fork2-${id}`;
6128
+ await fetch(`${getBaseUrl()}${sourcePath}`, {
6129
+ method: `PUT`,
6130
+ headers: { "Content-Type": `text/plain` },
6131
+ body: `data`
6132
+ });
6133
+ await fetch(`${getBaseUrl()}${forkPath}`, {
6134
+ method: `PUT`,
6135
+ headers: {
6136
+ "Content-Type": `text/plain`,
6137
+ [STREAM_FORKED_FROM_HEADER]: sourcePath
6138
+ }
6139
+ });
6140
+ await fetch(`${getBaseUrl()}${sourcePath}`, { method: `DELETE` });
6141
+ const fork2Res = await fetch(`${getBaseUrl()}${fork2Path}`, {
6142
+ method: `PUT`,
6143
+ headers: {
6144
+ "Content-Type": `text/plain`,
6145
+ [STREAM_FORKED_FROM_HEADER]: sourcePath
6146
+ }
6147
+ });
6148
+ expect(fork2Res.status).toBe(409);
6149
+ });
6150
+ test(`should return 409 for fork with content-type mismatch`, async () => {
6151
+ const id = uniqueId();
6152
+ const sourcePath = `/v1/stream/fork-ct-mismatch-src-${id}`;
6153
+ const forkPath = `/v1/stream/fork-ct-mismatch-fork-${id}`;
6154
+ await fetch(`${getBaseUrl()}${sourcePath}`, {
6155
+ method: `PUT`,
6156
+ headers: { "Content-Type": `text/plain` },
6157
+ body: `data`
6158
+ });
6159
+ const forkRes = await fetch(`${getBaseUrl()}${forkPath}`, {
6160
+ method: `PUT`,
6161
+ headers: {
6162
+ "Content-Type": `application/json`,
6163
+ [STREAM_FORKED_FROM_HEADER]: sourcePath
6164
+ }
6165
+ });
6166
+ expect(forkRes.status).toBe(409);
6167
+ });
6168
+ test(`should cascade GC when last fork is deleted`, async () => {
6169
+ const id = uniqueId();
6170
+ const sourcePath = `/v1/stream/fork-del-cascade-src-${id}`;
6171
+ const forkPath = `/v1/stream/fork-del-cascade-fork-${id}`;
6172
+ await fetch(`${getBaseUrl()}${sourcePath}`, {
6173
+ method: `PUT`,
6174
+ headers: { "Content-Type": `text/plain` },
6175
+ body: `cascade data`
6176
+ });
6177
+ await fetch(`${getBaseUrl()}${forkPath}`, {
6178
+ method: `PUT`,
6179
+ headers: {
6180
+ "Content-Type": `text/plain`,
6181
+ [STREAM_FORKED_FROM_HEADER]: sourcePath
6182
+ }
6183
+ });
6184
+ await fetch(`${getBaseUrl()}${sourcePath}`, { method: `DELETE` });
6185
+ const sourceHead1 = await fetch(`${getBaseUrl()}${sourcePath}`, { method: `HEAD` });
6186
+ expect(sourceHead1.status).toBe(410);
6187
+ const deleteFork = await fetch(`${getBaseUrl()}${forkPath}`, { method: `DELETE` });
6188
+ expect(deleteFork.status).toBe(204);
6189
+ await waitForStatus(`${getBaseUrl()}${sourcePath}`, 404);
6190
+ });
6191
+ test(`should cascade GC through three levels`, async () => {
6192
+ const id = uniqueId();
6193
+ const level0 = `/v1/stream/fork-del-cascade3-l0-${id}`;
6194
+ const level1 = `/v1/stream/fork-del-cascade3-l1-${id}`;
6195
+ const level2 = `/v1/stream/fork-del-cascade3-l2-${id}`;
6196
+ await fetch(`${getBaseUrl()}${level0}`, {
6197
+ method: `PUT`,
6198
+ headers: { "Content-Type": `text/plain` },
6199
+ body: `root`
6200
+ });
6201
+ await fetch(`${getBaseUrl()}${level1}`, {
6202
+ method: `PUT`,
6203
+ headers: {
6204
+ "Content-Type": `text/plain`,
6205
+ [STREAM_FORKED_FROM_HEADER]: level0
6206
+ }
6207
+ });
6208
+ await fetch(`${getBaseUrl()}${level2}`, {
6209
+ method: `PUT`,
6210
+ headers: {
6211
+ "Content-Type": `text/plain`,
6212
+ [STREAM_FORKED_FROM_HEADER]: level1
6213
+ }
6214
+ });
6215
+ await fetch(`${getBaseUrl()}${level0}`, { method: `DELETE` });
6216
+ await fetch(`${getBaseUrl()}${level1}`, { method: `DELETE` });
6217
+ expect((await fetch(`${getBaseUrl()}${level0}`, { method: `HEAD` })).status).toBe(410);
6218
+ expect((await fetch(`${getBaseUrl()}${level1}`, { method: `HEAD` })).status).toBe(410);
6219
+ const deleteLevel2 = await fetch(`${getBaseUrl()}${level2}`, { method: `DELETE` });
6220
+ expect(deleteLevel2.status).toBe(204);
6221
+ expect((await fetch(`${getBaseUrl()}${level2}`, { method: `HEAD` })).status).toBe(404);
6222
+ await waitForStatus(`${getBaseUrl()}${level1}`, 404);
6223
+ await waitForStatus(`${getBaseUrl()}${level0}`, 404);
6224
+ });
6225
+ test(`should preserve data when deleting middle of chain`, async () => {
6226
+ const id = uniqueId();
6227
+ const level0 = `/v1/stream/fork-del-middle-l0-${id}`;
6228
+ const level1 = `/v1/stream/fork-del-middle-l1-${id}`;
6229
+ const level2 = `/v1/stream/fork-del-middle-l2-${id}`;
6230
+ await fetch(`${getBaseUrl()}${level0}`, {
6231
+ method: `PUT`,
6232
+ headers: { "Content-Type": `text/plain` },
6233
+ body: `A`
6234
+ });
6235
+ await fetch(`${getBaseUrl()}${level1}`, {
6236
+ method: `PUT`,
6237
+ headers: {
6238
+ "Content-Type": `text/plain`,
6239
+ [STREAM_FORKED_FROM_HEADER]: level0
6240
+ }
6241
+ });
6242
+ await fetch(`${getBaseUrl()}${level1}`, {
6243
+ method: `POST`,
6244
+ headers: { "Content-Type": `text/plain` },
6245
+ body: `B`
6246
+ });
6247
+ await fetch(`${getBaseUrl()}${level2}`, {
6248
+ method: `PUT`,
6249
+ headers: {
6250
+ "Content-Type": `text/plain`,
6251
+ [STREAM_FORKED_FROM_HEADER]: level1
6252
+ }
6253
+ });
6254
+ await fetch(`${getBaseUrl()}${level2}`, {
6255
+ method: `POST`,
6256
+ headers: { "Content-Type": `text/plain` },
6257
+ body: `C`
6258
+ });
6259
+ await fetch(`${getBaseUrl()}${level1}`, { method: `DELETE` });
6260
+ expect((await fetch(`${getBaseUrl()}${level1}`, { method: `HEAD` })).status).toBe(410);
6261
+ const readRes = await fetch(`${getBaseUrl()}${level2}?offset=-1`);
6262
+ expect(readRes.status).toBe(200);
6263
+ const body = await readRes.text();
6264
+ expect(body).toBe(`ABC`);
6265
+ const l0Read = await fetch(`${getBaseUrl()}${level0}?offset=-1`);
6266
+ expect(l0Read.status).toBe(200);
6267
+ expect(await l0Read.text()).toBe(`A`);
6268
+ });
6269
+ test(`should keep source alive when all forks are deleted`, async () => {
6270
+ const id = uniqueId();
6271
+ const sourcePath = `/v1/stream/fork-del-allgone-src-${id}`;
6272
+ const fork1Path = `/v1/stream/fork-del-allgone-f1-${id}`;
6273
+ const fork2Path = `/v1/stream/fork-del-allgone-f2-${id}`;
6274
+ await fetch(`${getBaseUrl()}${sourcePath}`, {
6275
+ method: `PUT`,
6276
+ headers: { "Content-Type": `text/plain` },
6277
+ body: `alive`
6278
+ });
6279
+ await fetch(`${getBaseUrl()}${fork1Path}`, {
6280
+ method: `PUT`,
6281
+ headers: {
6282
+ "Content-Type": `text/plain`,
6283
+ [STREAM_FORKED_FROM_HEADER]: sourcePath
6284
+ }
6285
+ });
6286
+ await fetch(`${getBaseUrl()}${fork2Path}`, {
6287
+ method: `PUT`,
6288
+ headers: {
6289
+ "Content-Type": `text/plain`,
6290
+ [STREAM_FORKED_FROM_HEADER]: sourcePath
6291
+ }
6292
+ });
6293
+ await fetch(`${getBaseUrl()}${fork1Path}`, { method: `DELETE` });
6294
+ await fetch(`${getBaseUrl()}${fork2Path}`, { method: `DELETE` });
6295
+ const sourceRead = await fetch(`${getBaseUrl()}${sourcePath}?offset=-1`);
6296
+ expect(sourceRead.status).toBe(200);
6297
+ const body = await sourceRead.text();
6298
+ expect(body).toBe(`alive`);
6299
+ });
6300
+ });
6301
+ describe(`Fork - TTL and Expiry`, () => {
6302
+ const STREAM_FORKED_FROM_HEADER = `Stream-Forked-From`;
6303
+ const uniqueId = () => `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
6304
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
6305
+ const waitForDeletion = async (url, initialSleepMs, expectedStatuses = [404], timeoutMs = 5e3) => {
6306
+ await sleep(initialSleepMs);
6307
+ await vi.waitFor(async () => {
6308
+ const head = await fetch(url, { method: `HEAD` });
6309
+ expect(expectedStatuses).toContain(head.status);
6310
+ }, {
6311
+ timeout: timeoutMs,
6312
+ interval: 200
6313
+ });
6314
+ };
6315
+ test(`should inherit source expiry when none specified`, async () => {
6316
+ const id = uniqueId();
6317
+ const sourcePath = `/v1/stream/fork-ttl-inherit-src-${id}`;
6318
+ const forkPath = `/v1/stream/fork-ttl-inherit-fork-${id}`;
6319
+ const expiresAt = new Date(Date.now() + 36e5).toISOString();
6320
+ await fetch(`${getBaseUrl()}${sourcePath}`, {
6321
+ method: `PUT`,
6322
+ headers: {
6323
+ "Content-Type": `text/plain`,
6324
+ "Stream-Expires-At": expiresAt
6325
+ },
6326
+ body: `data`
6327
+ });
6328
+ const forkRes = await fetch(`${getBaseUrl()}${forkPath}`, {
6329
+ method: `PUT`,
6330
+ headers: {
6331
+ "Content-Type": `text/plain`,
6332
+ [STREAM_FORKED_FROM_HEADER]: sourcePath
6333
+ }
6334
+ });
6335
+ expect(forkRes.status).toBe(201);
6336
+ const forkHead = await fetch(`${getBaseUrl()}${forkPath}`, { method: `HEAD` });
6337
+ expect(forkHead.status).toBe(200);
6338
+ const forkExpires = forkHead.headers.get(`Stream-Expires-At`);
6339
+ if (forkExpires) expect(new Date(forkExpires).getTime()).toBeLessThanOrEqual(new Date(expiresAt).getTime());
6340
+ });
6341
+ test(`should allow fork with shorter TTL`, async () => {
6342
+ const id = uniqueId();
6343
+ const sourcePath = `/v1/stream/fork-ttl-shorter-src-${id}`;
6344
+ const forkPath = `/v1/stream/fork-ttl-shorter-fork-${id}`;
6345
+ await fetch(`${getBaseUrl()}${sourcePath}`, {
6346
+ method: `PUT`,
6347
+ headers: {
6348
+ "Content-Type": `text/plain`,
6349
+ "Stream-TTL": `3600`
6350
+ },
6351
+ body: `data`
6352
+ });
6353
+ const forkRes = await fetch(`${getBaseUrl()}${forkPath}`, {
6354
+ method: `PUT`,
6355
+ headers: {
6356
+ "Content-Type": `text/plain`,
6357
+ [STREAM_FORKED_FROM_HEADER]: sourcePath,
6358
+ "Stream-TTL": `1800`
6359
+ }
6360
+ });
6361
+ expect([200, 201]).toContain(forkRes.status);
6362
+ });
6363
+ test.concurrent(`should expire fork based on TTL (releases refcount)`, async () => {
6364
+ const id = uniqueId();
6365
+ const sourcePath = `/v1/stream/fork-ttl-expire-src-${id}`;
6366
+ const forkPath = `/v1/stream/fork-ttl-expire-fork-${id}`;
6367
+ await fetch(`${getBaseUrl()}${sourcePath}`, {
6368
+ method: `PUT`,
6369
+ headers: {
6370
+ "Content-Type": `text/plain`,
6371
+ "Stream-TTL": `60`
6372
+ },
6373
+ body: `data`
6374
+ });
6375
+ await fetch(`${getBaseUrl()}${forkPath}`, {
6376
+ method: `PUT`,
6377
+ headers: {
6378
+ "Content-Type": `text/plain`,
6379
+ [STREAM_FORKED_FROM_HEADER]: sourcePath,
6380
+ "Stream-TTL": `1`
6381
+ }
6382
+ });
6383
+ const forkHeadBefore = await fetch(`${getBaseUrl()}${forkPath}`, { method: `HEAD` });
6384
+ expect(forkHeadBefore.status).toBe(200);
6385
+ await waitForDeletion(`${getBaseUrl()}${forkPath}`, 1e3);
6386
+ const forkGetAfter = await fetch(`${getBaseUrl()}${forkPath}`, { method: `GET` });
6387
+ expect(forkGetAfter.status).toBe(404);
6388
+ });
6389
+ test.concurrent(`should expire source with living forks (source goes 410)`, async () => {
6390
+ const id = uniqueId();
6391
+ const sourcePath = `/v1/stream/fork-ttl-src-expire-src-${id}`;
6392
+ const forkPath = `/v1/stream/fork-ttl-src-expire-fork-${id}`;
6393
+ await fetch(`${getBaseUrl()}${sourcePath}`, {
6394
+ method: `PUT`,
6395
+ headers: {
6396
+ "Content-Type": `text/plain`,
6397
+ "Stream-TTL": `1`
6398
+ },
6399
+ body: `data`
6400
+ });
6401
+ await fetch(`${getBaseUrl()}${forkPath}`, {
6402
+ method: `PUT`,
6403
+ headers: {
6404
+ "Content-Type": `text/plain`,
6405
+ [STREAM_FORKED_FROM_HEADER]: sourcePath
6406
+ }
6407
+ });
6408
+ await waitForDeletion(`${getBaseUrl()}${sourcePath}`, 1e3, [404, 410]);
6409
+ const sourceGet = await fetch(`${getBaseUrl()}${sourcePath}`, { method: `GET` });
6410
+ expect([404, 410]).toContain(sourceGet.status);
6411
+ await waitForDeletion(`${getBaseUrl()}${forkPath}`, 0, [404, 410]);
6412
+ const forkGet = await fetch(`${getBaseUrl()}${forkPath}`, { method: `GET` });
6413
+ expect([404, 410]).toContain(forkGet.status);
6414
+ });
6415
+ test(`should inherit source TTL value when none specified`, async () => {
6416
+ const id = uniqueId();
6417
+ const sourcePath = `/v1/stream/fork-ttl-inherit-ttl-src-${id}`;
6418
+ const forkPath = `/v1/stream/fork-ttl-inherit-ttl-fork-${id}`;
6419
+ await fetch(`${getBaseUrl()}${sourcePath}`, {
6420
+ method: `PUT`,
6421
+ headers: {
6422
+ "Content-Type": `text/plain`,
6423
+ "Stream-TTL": `3600`
6424
+ },
6425
+ body: `data`
6426
+ });
6427
+ const forkRes = await fetch(`${getBaseUrl()}${forkPath}`, {
6428
+ method: `PUT`,
6429
+ headers: {
6430
+ "Content-Type": `text/plain`,
6431
+ [STREAM_FORKED_FROM_HEADER]: sourcePath
6432
+ }
6433
+ });
6434
+ expect(forkRes.status).toBe(201);
6435
+ const forkHead = await fetch(`${getBaseUrl()}${forkPath}`, { method: `HEAD` });
6436
+ expect(forkHead.status).toBe(200);
6437
+ const forkTTL = forkHead.headers.get(`Stream-TTL`);
6438
+ expect(forkTTL).toBe(`3600`);
6439
+ });
6440
+ test(`should use fork's own TTL when specified`, async () => {
6441
+ const id = uniqueId();
6442
+ const sourcePath = `/v1/stream/fork-own-ttl-src-${id}`;
6443
+ const forkPath = `/v1/stream/fork-own-ttl-fork-${id}`;
6444
+ await fetch(`${getBaseUrl()}${sourcePath}`, {
6445
+ method: `PUT`,
6446
+ headers: {
6447
+ "Content-Type": `text/plain`,
6448
+ "Stream-TTL": `3600`
6449
+ },
6450
+ body: `data`
6451
+ });
6452
+ const forkRes = await fetch(`${getBaseUrl()}${forkPath}`, {
6453
+ method: `PUT`,
6454
+ headers: {
6455
+ "Content-Type": `text/plain`,
6456
+ [STREAM_FORKED_FROM_HEADER]: sourcePath,
6457
+ "Stream-TTL": `7200`
6458
+ }
6459
+ });
6460
+ expect(forkRes.status).toBe(201);
6461
+ const forkHead = await fetch(`${getBaseUrl()}${forkPath}`, { method: `HEAD` });
6462
+ expect(forkHead.status).toBe(200);
6463
+ const forkTTL = forkHead.headers.get(`Stream-TTL`);
6464
+ expect(forkTTL).toBe(`7200`);
6465
+ });
6466
+ test.concurrent(`should allow fork to outlive source via TTL renewal`, async () => {
6467
+ const id = uniqueId();
6468
+ const sourcePath = `/v1/stream/fork-outlive-src-${id}`;
6469
+ const forkPath = `/v1/stream/fork-outlive-fork-${id}`;
6470
+ await fetch(`${getBaseUrl()}${sourcePath}`, {
6471
+ method: `PUT`,
6472
+ headers: {
6473
+ "Content-Type": `text/plain`,
6474
+ "Stream-TTL": `2`
6475
+ },
6476
+ body: `source data`
6477
+ });
6478
+ const forkRes = await fetch(`${getBaseUrl()}${forkPath}`, {
6479
+ method: `PUT`,
6480
+ headers: {
6481
+ "Content-Type": `text/plain`,
6482
+ [STREAM_FORKED_FROM_HEADER]: sourcePath,
6483
+ "Stream-TTL": `2`
6484
+ }
6485
+ });
6486
+ expect(forkRes.status).toBe(201);
6487
+ await sleep(1500);
6488
+ const forkRead = await fetch(`${getBaseUrl()}${forkPath}`, { method: `GET` });
6489
+ expect(forkRead.status).toBe(200);
6490
+ await waitForDeletion(`${getBaseUrl()}${sourcePath}`, 500, [404, 410]);
6491
+ const sourceGet = await fetch(`${getBaseUrl()}${sourcePath}`, { method: `GET` });
6492
+ expect([404, 410]).toContain(sourceGet.status);
6493
+ const forkHead = await fetch(`${getBaseUrl()}${forkPath}`, { method: `HEAD` });
6494
+ expect(forkHead.status).toBe(200);
6495
+ });
6496
+ test(`should allow fork Expires-At beyond source TTL expiry`, async () => {
6497
+ const id = uniqueId();
6498
+ const sourcePath = `/v1/stream/fork-expires-beyond-src-${id}`;
6499
+ const forkPath = `/v1/stream/fork-expires-beyond-fork-${id}`;
6500
+ await fetch(`${getBaseUrl()}${sourcePath}`, {
6501
+ method: `PUT`,
6502
+ headers: {
6503
+ "Content-Type": `text/plain`,
6504
+ "Stream-TTL": `10`
6505
+ },
6506
+ body: `data`
6507
+ });
6508
+ const farFuture = new Date(Date.now() + 36e5).toISOString();
6509
+ const forkRes = await fetch(`${getBaseUrl()}${forkPath}`, {
6510
+ method: `PUT`,
6511
+ headers: {
6512
+ "Content-Type": `text/plain`,
6513
+ [STREAM_FORKED_FROM_HEADER]: sourcePath,
6514
+ "Stream-Expires-At": farFuture
6515
+ }
6516
+ });
6517
+ expect(forkRes.status).toBe(201);
6518
+ const forkHead = await fetch(`${getBaseUrl()}${forkPath}`, { method: `HEAD` });
6519
+ expect(forkHead.status).toBe(200);
6520
+ const forkExpiresAt = forkHead.headers.get(`Stream-Expires-At`);
6521
+ if (forkExpiresAt) expect(new Date(forkExpiresAt).getTime()).toBeGreaterThan(Date.now() + 35e5);
6522
+ });
6523
+ test(`should allow fork TTL longer than source TTL (no capping)`, async () => {
6524
+ const id = uniqueId();
6525
+ const sourcePath = `/v1/stream/fork-ttl-nocap-src-${id}`;
6526
+ const forkPath = `/v1/stream/fork-ttl-nocap-fork-${id}`;
6527
+ await fetch(`${getBaseUrl()}${sourcePath}`, {
6528
+ method: `PUT`,
6529
+ headers: {
6530
+ "Content-Type": `text/plain`,
6531
+ "Stream-TTL": `10`
6532
+ },
6533
+ body: `data`
6534
+ });
6535
+ const forkRes = await fetch(`${getBaseUrl()}${forkPath}`, {
6536
+ method: `PUT`,
6537
+ headers: {
6538
+ "Content-Type": `text/plain`,
6539
+ [STREAM_FORKED_FROM_HEADER]: sourcePath,
6540
+ "Stream-TTL": `99999`
6541
+ }
6542
+ });
6543
+ expect([200, 201]).toContain(forkRes.status);
6544
+ const forkHead = await fetch(`${getBaseUrl()}${forkPath}`, { method: `HEAD` });
6545
+ expect(forkHead.status).toBe(200);
6546
+ const forkTTL = forkHead.headers.get(`Stream-TTL`);
6547
+ expect(forkTTL).toBe(`99999`);
6548
+ });
6549
+ });
6550
+ describe(`Fork - JSON Mode`, () => {
6551
+ const STREAM_FORKED_FROM_HEADER = `Stream-Forked-From`;
6552
+ const uniqueId = () => `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
6553
+ test(`should fork a JSON stream`, async () => {
6554
+ const id = uniqueId();
6555
+ const sourcePath = `/v1/stream/fork-json-src-${id}`;
6556
+ const forkPath = `/v1/stream/fork-json-fork-${id}`;
6557
+ await fetch(`${getBaseUrl()}${sourcePath}`, {
6558
+ method: `PUT`,
6559
+ headers: { "Content-Type": `application/json` },
6560
+ body: `[{"event":"one"}]`
6561
+ });
6562
+ const forkRes = await fetch(`${getBaseUrl()}${forkPath}`, {
6563
+ method: `PUT`,
6564
+ headers: {
6565
+ "Content-Type": `application/json`,
6566
+ [STREAM_FORKED_FROM_HEADER]: sourcePath
6567
+ }
6568
+ });
6569
+ expect(forkRes.status).toBe(201);
6570
+ expect(forkRes.headers.get(`content-type`)).toBe(`application/json`);
6571
+ const readRes = await fetch(`${getBaseUrl()}${forkPath}?offset=-1`);
6572
+ expect(readRes.status).toBe(200);
6573
+ expect(readRes.headers.get(`content-type`)).toBe(`application/json`);
6574
+ const body = JSON.parse(await readRes.text());
6575
+ expect(Array.isArray(body)).toBe(true);
6576
+ expect(body).toEqual([{ event: `one` }]);
6577
+ });
6578
+ test(`should read forked JSON across boundary`, async () => {
6579
+ const id = uniqueId();
6580
+ const sourcePath = `/v1/stream/fork-json-boundary-src-${id}`;
6581
+ const forkPath = `/v1/stream/fork-json-boundary-fork-${id}`;
6582
+ await fetch(`${getBaseUrl()}${sourcePath}`, {
6583
+ method: `PUT`,
6584
+ headers: { "Content-Type": `application/json` },
6585
+ body: `[{"from":"source"}]`
6586
+ });
6587
+ await fetch(`${getBaseUrl()}${forkPath}`, {
6588
+ method: `PUT`,
6589
+ headers: {
6590
+ "Content-Type": `application/json`,
6591
+ [STREAM_FORKED_FROM_HEADER]: sourcePath
6592
+ }
6593
+ });
6594
+ await fetch(`${getBaseUrl()}${forkPath}`, {
6595
+ method: `POST`,
6596
+ headers: { "Content-Type": `application/json` },
6597
+ body: `[{"from":"fork"}]`
6598
+ });
6599
+ const readRes = await fetch(`${getBaseUrl()}${forkPath}?offset=-1`);
6600
+ expect(readRes.status).toBe(200);
6601
+ const body = JSON.parse(await readRes.text());
6602
+ expect(Array.isArray(body)).toBe(true);
6603
+ expect(body).toEqual([{ from: `source` }, { from: `fork` }]);
6604
+ });
6605
+ });
6606
+ describe(`Fork - Edge Cases`, () => {
6607
+ const STREAM_FORKED_FROM_HEADER = `Stream-Forked-From`;
6608
+ const STREAM_FORK_OFFSET_HEADER = `Stream-Fork-Offset`;
6609
+ const uniqueId = () => `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
6610
+ test(`should handle fork then immediately delete source`, async () => {
6611
+ const id = uniqueId();
6612
+ const sourcePath = `/v1/stream/fork-edge-imm-del-src-${id}`;
6613
+ const forkPath = `/v1/stream/fork-edge-imm-del-fork-${id}`;
6614
+ await fetch(`${getBaseUrl()}${sourcePath}`, {
6615
+ method: `PUT`,
6616
+ headers: { "Content-Type": `text/plain` },
6617
+ body: `ephemeral`
6618
+ });
6619
+ await fetch(`${getBaseUrl()}${forkPath}`, {
6620
+ method: `PUT`,
6621
+ headers: {
6622
+ "Content-Type": `text/plain`,
6623
+ [STREAM_FORKED_FROM_HEADER]: sourcePath
6624
+ }
6625
+ });
6626
+ await fetch(`${getBaseUrl()}${sourcePath}`, { method: `DELETE` });
6627
+ const readRes = await fetch(`${getBaseUrl()}${forkPath}?offset=-1`);
6628
+ expect(readRes.status).toBe(200);
6629
+ const body = await readRes.text();
6630
+ expect(body).toBe(`ephemeral`);
6631
+ });
6632
+ test(`should handle many forks of same stream (10 forks)`, async () => {
6633
+ const id = uniqueId();
6634
+ const sourcePath = `/v1/stream/fork-edge-many-src-${id}`;
6635
+ await fetch(`${getBaseUrl()}${sourcePath}`, {
6636
+ method: `PUT`,
6637
+ headers: { "Content-Type": `text/plain` },
6638
+ body: `shared data`
6639
+ });
6640
+ const forkPaths = [];
6641
+ for (let i = 0; i < 10; i++) {
6642
+ const forkPath = `/v1/stream/fork-edge-many-f${i}-${id}`;
6643
+ const forkRes = await fetch(`${getBaseUrl()}${forkPath}`, {
6644
+ method: `PUT`,
6645
+ headers: {
6646
+ "Content-Type": `text/plain`,
6647
+ [STREAM_FORKED_FROM_HEADER]: sourcePath
6648
+ }
6649
+ });
6650
+ expect(forkRes.status).toBe(201);
6651
+ forkPaths.push(forkPath);
6652
+ }
6653
+ for (const fp of forkPaths) {
6654
+ const readRes = await fetch(`${getBaseUrl()}${fp}?offset=-1`);
6655
+ expect(readRes.status).toBe(200);
6656
+ const body = await readRes.text();
6657
+ expect(body).toBe(`shared data`);
6658
+ }
6659
+ for (const fp of forkPaths) await fetch(`${getBaseUrl()}${fp}`, { method: `DELETE` });
6660
+ });
6661
+ test(`should fork at every offset position`, async () => {
6662
+ const id = uniqueId();
6663
+ const sourcePath = `/v1/stream/fork-edge-every-offset-src-${id}`;
6664
+ const createRes = await fetch(`${getBaseUrl()}${sourcePath}`, {
6665
+ method: `PUT`,
6666
+ headers: { "Content-Type": `text/plain` },
6667
+ body: `A`
6668
+ });
6669
+ const offset0 = `0000000000000000_0000000000000000`;
6670
+ const offset1 = createRes.headers.get(STREAM_OFFSET_HEADER);
6671
+ const append1 = await fetch(`${getBaseUrl()}${sourcePath}`, {
6672
+ method: `POST`,
6673
+ headers: { "Content-Type": `text/plain` },
6674
+ body: `B`
6675
+ });
6676
+ const offset2 = append1.headers.get(STREAM_OFFSET_HEADER);
6677
+ const append2 = await fetch(`${getBaseUrl()}${sourcePath}`, {
6678
+ method: `POST`,
6679
+ headers: { "Content-Type": `text/plain` },
6680
+ body: `C`
6681
+ });
6682
+ const offset3 = append2.headers.get(STREAM_OFFSET_HEADER);
6683
+ const f0 = `/v1/stream/fork-edge-every-f0-${id}`;
6684
+ const f0Res = await fetch(`${getBaseUrl()}${f0}`, {
6685
+ method: `PUT`,
6686
+ headers: {
6687
+ "Content-Type": `text/plain`,
6688
+ [STREAM_FORKED_FROM_HEADER]: sourcePath,
6689
+ [STREAM_FORK_OFFSET_HEADER]: offset0
6690
+ }
6691
+ });
6692
+ expect(f0Res.status).toBe(201);
6693
+ const f0Body = await (await fetch(`${getBaseUrl()}${f0}?offset=-1`)).text();
6694
+ expect(f0Body).toBe(``);
6695
+ const f1 = `/v1/stream/fork-edge-every-f1-${id}`;
6696
+ await fetch(`${getBaseUrl()}${f1}`, {
6697
+ method: `PUT`,
6698
+ headers: {
6699
+ "Content-Type": `text/plain`,
6700
+ [STREAM_FORKED_FROM_HEADER]: sourcePath,
6701
+ [STREAM_FORK_OFFSET_HEADER]: offset1
6702
+ }
6703
+ });
6704
+ const f1Body = await (await fetch(`${getBaseUrl()}${f1}?offset=-1`)).text();
6705
+ expect(f1Body).toBe(`A`);
6706
+ const f2 = `/v1/stream/fork-edge-every-f2-${id}`;
6707
+ await fetch(`${getBaseUrl()}${f2}`, {
6708
+ method: `PUT`,
6709
+ headers: {
6710
+ "Content-Type": `text/plain`,
6711
+ [STREAM_FORKED_FROM_HEADER]: sourcePath,
6712
+ [STREAM_FORK_OFFSET_HEADER]: offset2
6713
+ }
6714
+ });
6715
+ const f2Body = await (await fetch(`${getBaseUrl()}${f2}?offset=-1`)).text();
6716
+ expect(f2Body).toBe(`AB`);
6717
+ const f3 = `/v1/stream/fork-edge-every-f3-${id}`;
6718
+ await fetch(`${getBaseUrl()}${f3}`, {
6719
+ method: `PUT`,
6720
+ headers: {
6721
+ "Content-Type": `text/plain`,
6722
+ [STREAM_FORKED_FROM_HEADER]: sourcePath,
6723
+ [STREAM_FORK_OFFSET_HEADER]: offset3
6724
+ }
6725
+ });
6726
+ const f3Body = await (await fetch(`${getBaseUrl()}${f3}?offset=-1`)).text();
6727
+ expect(f3Body).toBe(`ABC`);
6728
+ });
6729
+ test(`should handle idempotent fork creation (PUT twice)`, async () => {
6730
+ const id = uniqueId();
6731
+ const sourcePath = `/v1/stream/fork-edge-idempotent-src-${id}`;
6732
+ const forkPath = `/v1/stream/fork-edge-idempotent-fork-${id}`;
6733
+ await fetch(`${getBaseUrl()}${sourcePath}`, {
6734
+ method: `PUT`,
6735
+ headers: { "Content-Type": `text/plain` },
6736
+ body: `data`
6737
+ });
6738
+ const fork1 = await fetch(`${getBaseUrl()}${forkPath}`, {
6739
+ method: `PUT`,
6740
+ headers: {
6741
+ "Content-Type": `text/plain`,
6742
+ [STREAM_FORKED_FROM_HEADER]: sourcePath
6743
+ }
6744
+ });
6745
+ expect(fork1.status).toBe(201);
6746
+ const fork2 = await fetch(`${getBaseUrl()}${forkPath}`, {
6747
+ method: `PUT`,
6748
+ headers: {
6749
+ "Content-Type": `text/plain`,
6750
+ [STREAM_FORKED_FROM_HEADER]: sourcePath
6751
+ }
6752
+ });
6753
+ expect(fork2.status).toBe(200);
6754
+ });
6755
+ });
5023
6756
  }
5024
6757
 
5025
6758
  //#endregion