@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/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
- // Notify any pending long-polls
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<{ messages: Array<StreamMessage>; timedOut: boolean }> {
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
- resolve({ messages: [], timedOut: true })
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
- resolve({ messages: msgs, timedOut: false })
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.