@durable-streams/server 0.2.0 → 0.2.1
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 +490 -69
- package/dist/index.d.cts +76 -1
- package/dist/index.d.ts +76 -1
- package/dist/index.js +490 -69
- package/package.json +4 -4
- package/src/file-store.ts +259 -11
- package/src/server.ts +357 -54
- package/src/store.ts +201 -7
- package/src/types.ts +17 -0
package/src/store.ts
CHANGED
|
@@ -98,6 +98,7 @@ export interface AppendOptions {
|
|
|
98
98
|
producerId?: string
|
|
99
99
|
producerEpoch?: number
|
|
100
100
|
producerSeq?: number
|
|
101
|
+
close?: boolean // Close stream after append
|
|
101
102
|
}
|
|
102
103
|
|
|
103
104
|
/**
|
|
@@ -106,6 +107,7 @@ export interface AppendOptions {
|
|
|
106
107
|
export interface AppendResult {
|
|
107
108
|
message: StreamMessage | null
|
|
108
109
|
producerResult?: ProducerValidationResult
|
|
110
|
+
streamClosed?: boolean // Stream is now closed
|
|
109
111
|
}
|
|
110
112
|
|
|
111
113
|
export class StreamStore {
|
|
@@ -172,6 +174,7 @@ export class StreamStore {
|
|
|
172
174
|
ttlSeconds?: number
|
|
173
175
|
expiresAt?: string
|
|
174
176
|
initialData?: Uint8Array
|
|
177
|
+
closed?: boolean
|
|
175
178
|
} = {}
|
|
176
179
|
): Stream {
|
|
177
180
|
// Use getIfNotExpired to treat expired streams as non-existent
|
|
@@ -185,8 +188,10 @@ export class StreamStore {
|
|
|
185
188
|
`application/octet-stream`)
|
|
186
189
|
const ttlMatches = options.ttlSeconds === existing.ttlSeconds
|
|
187
190
|
const expiresMatches = options.expiresAt === existing.expiresAt
|
|
191
|
+
const closedMatches =
|
|
192
|
+
(options.closed ?? false) === (existing.closed ?? false)
|
|
188
193
|
|
|
189
|
-
if (contentTypeMatches && ttlMatches && expiresMatches) {
|
|
194
|
+
if (contentTypeMatches && ttlMatches && expiresMatches && closedMatches) {
|
|
190
195
|
// Idempotent success - return existing stream
|
|
191
196
|
return existing
|
|
192
197
|
} else {
|
|
@@ -205,6 +210,7 @@ export class StreamStore {
|
|
|
205
210
|
ttlSeconds: options.ttlSeconds,
|
|
206
211
|
expiresAt: options.expiresAt,
|
|
207
212
|
createdAt: Date.now(),
|
|
213
|
+
closed: options.closed ?? false,
|
|
208
214
|
}
|
|
209
215
|
|
|
210
216
|
// If initial data is provided, append it
|
|
@@ -397,6 +403,34 @@ export class StreamStore {
|
|
|
397
403
|
throw new Error(`Stream not found: ${path}`)
|
|
398
404
|
}
|
|
399
405
|
|
|
406
|
+
// Check if stream is closed
|
|
407
|
+
if (stream.closed) {
|
|
408
|
+
// Check if this is a duplicate of the closing request (idempotent producer)
|
|
409
|
+
if (
|
|
410
|
+
options.producerId &&
|
|
411
|
+
stream.closedBy &&
|
|
412
|
+
stream.closedBy.producerId === options.producerId &&
|
|
413
|
+
stream.closedBy.epoch === options.producerEpoch &&
|
|
414
|
+
stream.closedBy.seq === options.producerSeq
|
|
415
|
+
) {
|
|
416
|
+
// Idempotent success - return 204 with Stream-Closed
|
|
417
|
+
return {
|
|
418
|
+
message: null,
|
|
419
|
+
streamClosed: true,
|
|
420
|
+
producerResult: {
|
|
421
|
+
status: `duplicate`,
|
|
422
|
+
lastSeq: options.producerSeq,
|
|
423
|
+
},
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// Stream is closed - reject append
|
|
428
|
+
return {
|
|
429
|
+
message: null,
|
|
430
|
+
streamClosed: true,
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
|
|
400
434
|
// Check content type match using normalization (handles charset parameters)
|
|
401
435
|
if (options.contentType && stream.contentType) {
|
|
402
436
|
const providedType = normalizeContentType(options.contentType)
|
|
@@ -460,14 +494,30 @@ export class StreamStore {
|
|
|
460
494
|
stream.lastSeq = options.seq
|
|
461
495
|
}
|
|
462
496
|
|
|
463
|
-
//
|
|
497
|
+
// Close stream if requested
|
|
498
|
+
if (options.close) {
|
|
499
|
+
stream.closed = true
|
|
500
|
+
// Track which producer tuple closed the stream for idempotent duplicate detection
|
|
501
|
+
if (options.producerId !== undefined) {
|
|
502
|
+
stream.closedBy = {
|
|
503
|
+
producerId: options.producerId,
|
|
504
|
+
epoch: options.producerEpoch!,
|
|
505
|
+
seq: options.producerSeq!,
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
// Notify pending long-polls that stream is closed
|
|
509
|
+
this.notifyLongPollsClosed(path)
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
// Notify any pending long-polls of new messages
|
|
464
513
|
this.notifyLongPolls(path)
|
|
465
514
|
|
|
466
|
-
// Return AppendResult if producer headers were used
|
|
467
|
-
if (producerResult) {
|
|
515
|
+
// Return AppendResult if producer headers were used or stream was closed
|
|
516
|
+
if (producerResult || options.close) {
|
|
468
517
|
return {
|
|
469
518
|
message,
|
|
470
519
|
producerResult,
|
|
520
|
+
streamClosed: options.close,
|
|
471
521
|
}
|
|
472
522
|
}
|
|
473
523
|
|
|
@@ -506,6 +556,122 @@ export class StreamStore {
|
|
|
506
556
|
}
|
|
507
557
|
}
|
|
508
558
|
|
|
559
|
+
/**
|
|
560
|
+
* Close a stream without appending data.
|
|
561
|
+
* @returns The final offset, or null if stream doesn't exist
|
|
562
|
+
*/
|
|
563
|
+
closeStream(
|
|
564
|
+
path: string
|
|
565
|
+
): { finalOffset: string; alreadyClosed: boolean } | null {
|
|
566
|
+
const stream = this.getIfNotExpired(path)
|
|
567
|
+
if (!stream) {
|
|
568
|
+
return null
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
const alreadyClosed = stream.closed ?? false
|
|
572
|
+
stream.closed = true
|
|
573
|
+
|
|
574
|
+
// Notify any pending long-polls that the stream is closed
|
|
575
|
+
this.notifyLongPollsClosed(path)
|
|
576
|
+
|
|
577
|
+
return {
|
|
578
|
+
finalOffset: stream.currentOffset,
|
|
579
|
+
alreadyClosed,
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
/**
|
|
584
|
+
* Close a stream with producer headers for idempotent close-only operations.
|
|
585
|
+
* Participates in producer sequencing for deduplication.
|
|
586
|
+
* @returns The final offset and producer result, or null if stream doesn't exist
|
|
587
|
+
*/
|
|
588
|
+
async closeStreamWithProducer(
|
|
589
|
+
path: string,
|
|
590
|
+
options: {
|
|
591
|
+
producerId: string
|
|
592
|
+
producerEpoch: number
|
|
593
|
+
producerSeq: number
|
|
594
|
+
}
|
|
595
|
+
): Promise<{
|
|
596
|
+
finalOffset: string
|
|
597
|
+
alreadyClosed: boolean
|
|
598
|
+
producerResult?: ProducerValidationResult
|
|
599
|
+
} | null> {
|
|
600
|
+
// Acquire producer lock for serialization
|
|
601
|
+
const releaseLock = await this.acquireProducerLock(path, options.producerId)
|
|
602
|
+
|
|
603
|
+
try {
|
|
604
|
+
const stream = this.getIfNotExpired(path)
|
|
605
|
+
if (!stream) {
|
|
606
|
+
return null
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
// Check if already closed
|
|
610
|
+
if (stream.closed) {
|
|
611
|
+
// Check if this is the same producer tuple (duplicate - idempotent success)
|
|
612
|
+
if (
|
|
613
|
+
stream.closedBy &&
|
|
614
|
+
stream.closedBy.producerId === options.producerId &&
|
|
615
|
+
stream.closedBy.epoch === options.producerEpoch &&
|
|
616
|
+
stream.closedBy.seq === options.producerSeq
|
|
617
|
+
) {
|
|
618
|
+
return {
|
|
619
|
+
finalOffset: stream.currentOffset,
|
|
620
|
+
alreadyClosed: true,
|
|
621
|
+
producerResult: {
|
|
622
|
+
status: `duplicate`,
|
|
623
|
+
lastSeq: options.producerSeq,
|
|
624
|
+
},
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
// Different producer trying to close an already-closed stream - conflict
|
|
629
|
+
return {
|
|
630
|
+
finalOffset: stream.currentOffset,
|
|
631
|
+
alreadyClosed: true,
|
|
632
|
+
producerResult: { status: `stream_closed` },
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
// Validate producer state
|
|
637
|
+
const producerResult = this.validateProducer(
|
|
638
|
+
stream,
|
|
639
|
+
options.producerId,
|
|
640
|
+
options.producerEpoch,
|
|
641
|
+
options.producerSeq
|
|
642
|
+
)
|
|
643
|
+
|
|
644
|
+
// Return early for non-accepted results
|
|
645
|
+
if (producerResult.status !== `accepted`) {
|
|
646
|
+
return {
|
|
647
|
+
finalOffset: stream.currentOffset,
|
|
648
|
+
alreadyClosed: stream.closed ?? false,
|
|
649
|
+
producerResult,
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
// Commit producer state and close stream
|
|
654
|
+
this.commitProducerState(stream, producerResult)
|
|
655
|
+
stream.closed = true
|
|
656
|
+
stream.closedBy = {
|
|
657
|
+
producerId: options.producerId,
|
|
658
|
+
epoch: options.producerEpoch,
|
|
659
|
+
seq: options.producerSeq,
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
// Notify any pending long-polls
|
|
663
|
+
this.notifyLongPollsClosed(path)
|
|
664
|
+
|
|
665
|
+
return {
|
|
666
|
+
finalOffset: stream.currentOffset,
|
|
667
|
+
alreadyClosed: false,
|
|
668
|
+
producerResult,
|
|
669
|
+
}
|
|
670
|
+
} finally {
|
|
671
|
+
releaseLock()
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
|
|
509
675
|
/**
|
|
510
676
|
* Get the current epoch for a producer on a stream.
|
|
511
677
|
* Returns undefined if the producer doesn't exist or stream not found.
|
|
@@ -591,7 +757,11 @@ export class StreamStore {
|
|
|
591
757
|
path: string,
|
|
592
758
|
offset: string,
|
|
593
759
|
timeoutMs: number
|
|
594
|
-
): Promise<{
|
|
760
|
+
): Promise<{
|
|
761
|
+
messages: Array<StreamMessage>
|
|
762
|
+
timedOut: boolean
|
|
763
|
+
streamClosed?: boolean
|
|
764
|
+
}> {
|
|
595
765
|
const stream = this.getIfNotExpired(path)
|
|
596
766
|
if (!stream) {
|
|
597
767
|
throw new Error(`Stream not found: ${path}`)
|
|
@@ -603,12 +773,20 @@ export class StreamStore {
|
|
|
603
773
|
return { messages, timedOut: false }
|
|
604
774
|
}
|
|
605
775
|
|
|
776
|
+
// If stream is closed and client is at tail, return immediately
|
|
777
|
+
if (stream.closed && offset === stream.currentOffset) {
|
|
778
|
+
return { messages: [], timedOut: false, streamClosed: true }
|
|
779
|
+
}
|
|
780
|
+
|
|
606
781
|
// Wait for new messages
|
|
607
782
|
return new Promise((resolve) => {
|
|
608
783
|
const timeoutId = setTimeout(() => {
|
|
609
784
|
// Remove from pending
|
|
610
785
|
this.removePendingLongPoll(pending)
|
|
611
|
-
|
|
786
|
+
// Check if stream was closed during the wait
|
|
787
|
+
const currentStream = this.getIfNotExpired(path)
|
|
788
|
+
const streamClosed = currentStream?.closed ?? false
|
|
789
|
+
resolve({ messages: [], timedOut: true, streamClosed })
|
|
612
790
|
}, timeoutMs)
|
|
613
791
|
|
|
614
792
|
const pending: PendingLongPoll = {
|
|
@@ -617,7 +795,11 @@ export class StreamStore {
|
|
|
617
795
|
resolve: (msgs) => {
|
|
618
796
|
clearTimeout(timeoutId)
|
|
619
797
|
this.removePendingLongPoll(pending)
|
|
620
|
-
|
|
798
|
+
// Check if stream was closed (empty messages could mean closed)
|
|
799
|
+
const currentStream = this.getIfNotExpired(path)
|
|
800
|
+
const streamClosed =
|
|
801
|
+
currentStream?.closed && msgs.length === 0 ? true : undefined
|
|
802
|
+
resolve({ messages: msgs, timedOut: false, streamClosed })
|
|
621
803
|
},
|
|
622
804
|
timeoutId,
|
|
623
805
|
}
|
|
@@ -729,6 +911,18 @@ export class StreamStore {
|
|
|
729
911
|
}
|
|
730
912
|
}
|
|
731
913
|
|
|
914
|
+
/**
|
|
915
|
+
* Notify pending long-polls that a stream has been closed.
|
|
916
|
+
* They should wake up immediately and return Stream-Closed: true.
|
|
917
|
+
*/
|
|
918
|
+
private notifyLongPollsClosed(path: string): void {
|
|
919
|
+
const toNotify = this.pendingLongPolls.filter((p) => p.path === path)
|
|
920
|
+
for (const pending of toNotify) {
|
|
921
|
+
// Resolve with empty messages - the caller will check stream.closed
|
|
922
|
+
pending.resolve([])
|
|
923
|
+
}
|
|
924
|
+
}
|
|
925
|
+
|
|
732
926
|
private cancelLongPollsForStream(path: string): void {
|
|
733
927
|
const toCancel = this.pendingLongPolls.filter((p) => p.path === path)
|
|
734
928
|
for (const pending of toCancel) {
|
package/src/types.ts
CHANGED
|
@@ -72,6 +72,22 @@ export interface Stream {
|
|
|
72
72
|
* Maps producer ID to their epoch and sequence state.
|
|
73
73
|
*/
|
|
74
74
|
producers?: Map<string, ProducerState>
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Whether the stream is closed (no further appends permitted).
|
|
78
|
+
* Once set to true, this is permanent and durable.
|
|
79
|
+
*/
|
|
80
|
+
closed?: boolean
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* The producer tuple that closed this stream (for idempotent close).
|
|
84
|
+
* If set, duplicate close requests with this tuple return 204.
|
|
85
|
+
*/
|
|
86
|
+
closedBy?: {
|
|
87
|
+
producerId: string
|
|
88
|
+
epoch: number
|
|
89
|
+
seq: number
|
|
90
|
+
}
|
|
75
91
|
}
|
|
76
92
|
|
|
77
93
|
/**
|
|
@@ -202,6 +218,7 @@ export type ProducerValidationResult =
|
|
|
202
218
|
| { status: `stale_epoch`; currentEpoch: number }
|
|
203
219
|
| { status: `invalid_epoch_seq` }
|
|
204
220
|
| { status: `sequence_gap`; expectedSeq: number; receivedSeq: number }
|
|
221
|
+
| { status: `stream_closed` }
|
|
205
222
|
|
|
206
223
|
/**
|
|
207
224
|
* Pending long-poll request.
|