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