@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@durable-streams/server",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "description": "Node.js reference server implementation for Durable Streams",
5
5
  "author": "Durable Stream contributors",
6
6
  "license": "Apache-2.0",
@@ -39,15 +39,15 @@
39
39
  "dependencies": {
40
40
  "@neophi/sieve-cache": "^1.0.0",
41
41
  "lmdb": "^3.3.0",
42
- "@durable-streams/client": "0.2.0",
43
- "@durable-streams/state": "0.2.0"
42
+ "@durable-streams/client": "0.2.1",
43
+ "@durable-streams/state": "0.2.1"
44
44
  },
45
45
  "devDependencies": {
46
46
  "@types/node": "^22.0.0",
47
47
  "tsdown": "^0.9.0",
48
48
  "typescript": "^5.0.0",
49
49
  "vitest": "^4.0.0",
50
- "@durable-streams/server-conformance-tests": "0.2.0"
50
+ "@durable-streams/server-conformance-tests": "0.2.1"
51
51
  },
52
52
  "files": [
53
53
  "dist",
package/src/file-store.ts CHANGED
@@ -58,6 +58,21 @@ interface StreamMetadata {
58
58
  * Stored as a plain object for LMDB serialization.
59
59
  */
60
60
  producers?: Record<string, SerializableProducerState>
61
+ /**
62
+ * Whether the stream is closed (no further appends permitted).
63
+ * Once set to true, this is permanent and durable.
64
+ */
65
+ closed?: boolean
66
+ /**
67
+ * The producer tuple that closed this stream (for idempotent close).
68
+ * If set, duplicate close requests with this tuple return 204.
69
+ * CRITICAL: Must be persisted for duplicate detection after restart.
70
+ */
71
+ closedBy?: {
72
+ producerId: string
73
+ epoch: number
74
+ seq: number
75
+ }
61
76
  }
62
77
 
63
78
  /**
@@ -365,6 +380,8 @@ export class FileBackedStreamStore {
365
380
  expiresAt: meta.expiresAt,
366
381
  createdAt: meta.createdAt,
367
382
  producers,
383
+ closed: meta.closed,
384
+ closedBy: meta.closedBy,
368
385
  }
369
386
  }
370
387
 
@@ -554,6 +571,7 @@ export class FileBackedStreamStore {
554
571
  ttlSeconds?: number
555
572
  expiresAt?: string
556
573
  initialData?: Uint8Array
574
+ closed?: boolean
557
575
  } = {}
558
576
  ): Promise<Stream> {
559
577
  // Use getMetaIfNotExpired to treat expired streams as non-existent
@@ -569,8 +587,10 @@ export class FileBackedStreamStore {
569
587
  normalizeMimeType(existing.contentType)
570
588
  const ttlMatches = options.ttlSeconds === existing.ttlSeconds
571
589
  const expiresMatches = options.expiresAt === existing.expiresAt
590
+ const closedMatches =
591
+ (options.closed ?? false) === (existing.closed ?? false)
572
592
 
573
- if (contentTypeMatches && ttlMatches && expiresMatches) {
593
+ if (contentTypeMatches && ttlMatches && expiresMatches && closedMatches) {
574
594
  // Idempotent success - return existing stream
575
595
  return this.streamMetaToStream(existing)
576
596
  } else {
@@ -585,6 +605,8 @@ export class FileBackedStreamStore {
585
605
  const key = `stream:${streamPath}`
586
606
 
587
607
  // Initialize metadata
608
+ // Note: We set closed to false initially, then set it true after appending initial data
609
+ // This prevents the closed check from rejecting the initial append
588
610
  const streamMeta: StreamMetadata = {
589
611
  path: streamPath,
590
612
  contentType: options.contentType,
@@ -596,6 +618,7 @@ export class FileBackedStreamStore {
596
618
  segmentCount: 1,
597
619
  totalBytes: 0,
598
620
  directoryName: generateUniqueDirectoryName(streamPath),
621
+ closed: false, // Set to false initially, will be updated after initial append if needed
599
622
  }
600
623
 
601
624
  // Create stream directory and empty segment file immediately
@@ -626,12 +649,18 @@ export class FileBackedStreamStore {
626
649
  contentType: options.contentType,
627
650
  isInitialCreate: true,
628
651
  })
629
- // Re-fetch updated metadata
630
- const updated = this.db.get(key) as StreamMetadata
631
- return this.streamMetaToStream(updated)
632
652
  }
633
653
 
634
- return this.streamMetaToStream(streamMeta)
654
+ // Now set closed flag if requested (after initial append succeeded)
655
+ if (options.closed) {
656
+ const updatedMeta = this.db.get(key) as StreamMetadata
657
+ updatedMeta.closed = true
658
+ this.db.putSync(key, updatedMeta)
659
+ }
660
+
661
+ // Re-fetch updated metadata
662
+ const updated = this.db.get(key) as StreamMetadata
663
+ return this.streamMetaToStream(updated)
635
664
  }
636
665
 
637
666
  get(streamPath: string): Stream | undefined {
@@ -694,6 +723,34 @@ export class FileBackedStreamStore {
694
723
  throw new Error(`Stream not found: ${streamPath}`)
695
724
  }
696
725
 
726
+ // Check if stream is closed
727
+ if (streamMeta.closed) {
728
+ // Check if this is a duplicate of the closing request (idempotent retry)
729
+ if (
730
+ options.producerId &&
731
+ streamMeta.closedBy &&
732
+ streamMeta.closedBy.producerId === options.producerId &&
733
+ streamMeta.closedBy.epoch === options.producerEpoch &&
734
+ streamMeta.closedBy.seq === options.producerSeq
735
+ ) {
736
+ // Idempotent success - return 204 with Stream-Closed
737
+ return {
738
+ message: null,
739
+ streamClosed: true,
740
+ producerResult: {
741
+ status: `duplicate`,
742
+ lastSeq: options.producerSeq,
743
+ },
744
+ }
745
+ }
746
+
747
+ // Different request - stream is closed, reject
748
+ return {
749
+ message: null,
750
+ streamClosed: true,
751
+ }
752
+ }
753
+
697
754
  // Check content type match using normalization (handles charset parameters)
698
755
  if (options.contentType && streamMeta.contentType) {
699
756
  const providedType = normalizeContentType(options.contentType)
@@ -806,12 +863,25 @@ export class FileBackedStreamStore {
806
863
  if (producerResult && producerResult.status === `accepted`) {
807
864
  updatedProducers[producerResult.producerId] = producerResult.proposedState
808
865
  }
866
+
867
+ // Build closedBy if closing with producer headers
868
+ let closedBy: StreamMetadata[`closedBy`] = undefined
869
+ if (options.close && options.producerId) {
870
+ closedBy = {
871
+ producerId: options.producerId,
872
+ epoch: options.producerEpoch!,
873
+ seq: options.producerSeq!,
874
+ }
875
+ }
876
+
809
877
  const updatedMeta: StreamMetadata = {
810
878
  ...streamMeta,
811
879
  currentOffset: newOffset,
812
880
  lastSeq: options.seq ?? streamMeta.lastSeq,
813
881
  totalBytes: streamMeta.totalBytes + processedData.length + 5, // +4 for length, +1 for newline
814
882
  producers: updatedProducers,
883
+ closed: options.close ? true : streamMeta.closed,
884
+ closedBy: closedBy ?? streamMeta.closedBy,
815
885
  }
816
886
  const key = `stream:${streamPath}`
817
887
  this.db.putSync(key, updatedMeta)
@@ -819,11 +889,17 @@ export class FileBackedStreamStore {
819
889
  // 5. Notify long-polls (data is now readable from disk)
820
890
  this.notifyLongPolls(streamPath)
821
891
 
822
- // 6. Return AppendResult if producer headers were used
823
- if (producerResult) {
892
+ // 5a. If stream was closed, also notify long-polls of closure
893
+ if (options.close) {
894
+ this.notifyLongPollsClosed(streamPath)
895
+ }
896
+
897
+ // 6. Return AppendResult if producer headers were used or stream was closed
898
+ if (producerResult || options.close) {
824
899
  return {
825
900
  message,
826
901
  producerResult,
902
+ streamClosed: options.close,
827
903
  }
828
904
  }
829
905
 
@@ -865,6 +941,140 @@ export class FileBackedStreamStore {
865
941
  }
866
942
  }
867
943
 
944
+ /**
945
+ * Close a stream without appending data.
946
+ * @returns The final offset, or null if stream doesn't exist
947
+ */
948
+ closeStream(
949
+ streamPath: string
950
+ ): { finalOffset: string; alreadyClosed: boolean } | null {
951
+ const streamMeta = this.getMetaIfNotExpired(streamPath)
952
+ if (!streamMeta) {
953
+ return null
954
+ }
955
+
956
+ const alreadyClosed = streamMeta.closed ?? false
957
+
958
+ // Update LMDB to mark stream as closed
959
+ const key = `stream:${streamPath}`
960
+ const updatedMeta: StreamMetadata = {
961
+ ...streamMeta,
962
+ closed: true,
963
+ }
964
+ this.db.putSync(key, updatedMeta)
965
+
966
+ // Notify any pending long-polls that the stream is closed
967
+ this.notifyLongPollsClosed(streamPath)
968
+
969
+ return {
970
+ finalOffset: streamMeta.currentOffset,
971
+ alreadyClosed,
972
+ }
973
+ }
974
+
975
+ /**
976
+ * Close a stream with producer headers for idempotent close-only operations.
977
+ * Participates in producer sequencing for deduplication.
978
+ * @returns The final offset and producer result, or null if stream doesn't exist
979
+ */
980
+ async closeStreamWithProducer(
981
+ streamPath: string,
982
+ options: {
983
+ producerId: string
984
+ producerEpoch: number
985
+ producerSeq: number
986
+ }
987
+ ): Promise<{
988
+ finalOffset: string
989
+ alreadyClosed: boolean
990
+ producerResult?: ProducerValidationResult
991
+ } | null> {
992
+ // Acquire producer lock for serialization
993
+ const releaseLock = await this.acquireProducerLock(
994
+ streamPath,
995
+ options.producerId
996
+ )
997
+
998
+ try {
999
+ const streamMeta = this.getMetaIfNotExpired(streamPath)
1000
+ if (!streamMeta) {
1001
+ return null
1002
+ }
1003
+
1004
+ // Check if already closed
1005
+ if (streamMeta.closed) {
1006
+ // Check if this is the same producer tuple (duplicate - idempotent success)
1007
+ if (
1008
+ streamMeta.closedBy &&
1009
+ streamMeta.closedBy.producerId === options.producerId &&
1010
+ streamMeta.closedBy.epoch === options.producerEpoch &&
1011
+ streamMeta.closedBy.seq === options.producerSeq
1012
+ ) {
1013
+ return {
1014
+ finalOffset: streamMeta.currentOffset,
1015
+ alreadyClosed: true,
1016
+ producerResult: {
1017
+ status: `duplicate`,
1018
+ lastSeq: options.producerSeq,
1019
+ },
1020
+ }
1021
+ }
1022
+
1023
+ // Different producer trying to close an already-closed stream - conflict
1024
+ return {
1025
+ finalOffset: streamMeta.currentOffset,
1026
+ alreadyClosed: true,
1027
+ producerResult: { status: `stream_closed` },
1028
+ }
1029
+ }
1030
+
1031
+ // Validate producer state
1032
+ const producerResult = this.validateProducer(
1033
+ streamMeta,
1034
+ options.producerId,
1035
+ options.producerEpoch,
1036
+ options.producerSeq
1037
+ )
1038
+
1039
+ // Return early for non-accepted results
1040
+ if (producerResult.status !== `accepted`) {
1041
+ return {
1042
+ finalOffset: streamMeta.currentOffset,
1043
+ alreadyClosed: streamMeta.closed ?? false,
1044
+ producerResult,
1045
+ }
1046
+ }
1047
+
1048
+ // Commit producer state and close stream atomically in LMDB
1049
+ const key = `stream:${streamPath}`
1050
+ const updatedProducers = { ...streamMeta.producers }
1051
+ updatedProducers[producerResult.producerId] = producerResult.proposedState
1052
+
1053
+ const updatedMeta: StreamMetadata = {
1054
+ ...streamMeta,
1055
+ closed: true,
1056
+ closedBy: {
1057
+ producerId: options.producerId,
1058
+ epoch: options.producerEpoch,
1059
+ seq: options.producerSeq,
1060
+ },
1061
+ producers: updatedProducers,
1062
+ }
1063
+ this.db.putSync(key, updatedMeta)
1064
+
1065
+ // Notify any pending long-polls
1066
+ this.notifyLongPollsClosed(streamPath)
1067
+
1068
+ return {
1069
+ finalOffset: streamMeta.currentOffset,
1070
+ alreadyClosed: false,
1071
+ producerResult,
1072
+ }
1073
+ } finally {
1074
+ releaseLock()
1075
+ }
1076
+ }
1077
+
868
1078
  read(
869
1079
  streamPath: string,
870
1080
  offset?: string
@@ -961,17 +1171,31 @@ export class FileBackedStreamStore {
961
1171
  streamPath: string,
962
1172
  offset: string,
963
1173
  timeoutMs: number
964
- ): Promise<{ messages: Array<StreamMessage>; timedOut: boolean }> {
1174
+ ): Promise<{
1175
+ messages: Array<StreamMessage>
1176
+ timedOut: boolean
1177
+ streamClosed?: boolean
1178
+ }> {
965
1179
  const streamMeta = this.getMetaIfNotExpired(streamPath)
966
1180
 
967
1181
  if (!streamMeta) {
968
1182
  throw new Error(`Stream not found: ${streamPath}`)
969
1183
  }
970
1184
 
1185
+ // If stream is closed and client is at tail, return immediately
1186
+ if (streamMeta.closed && offset === streamMeta.currentOffset) {
1187
+ return { messages: [], timedOut: false, streamClosed: true }
1188
+ }
1189
+
971
1190
  // Check if there are already new messages
972
1191
  const { messages } = this.read(streamPath, offset)
973
1192
  if (messages.length > 0) {
974
- return { messages, timedOut: false }
1193
+ return { messages, timedOut: false, streamClosed: streamMeta.closed }
1194
+ }
1195
+
1196
+ // If stream is closed (but client not at tail), return what we have
1197
+ if (streamMeta.closed) {
1198
+ return { messages: [], timedOut: false, streamClosed: true }
975
1199
  }
976
1200
 
977
1201
  // Wait for new messages
@@ -979,7 +1203,13 @@ export class FileBackedStreamStore {
979
1203
  const timeoutId = setTimeout(() => {
980
1204
  // Remove from pending
981
1205
  this.removePendingLongPoll(pending)
982
- resolve({ messages: [], timedOut: true })
1206
+ // Check if stream was closed during wait
1207
+ const currentMeta = this.getMetaIfNotExpired(streamPath)
1208
+ resolve({
1209
+ messages: [],
1210
+ timedOut: true,
1211
+ streamClosed: currentMeta?.closed,
1212
+ })
983
1213
  }, timeoutMs)
984
1214
 
985
1215
  const pending: PendingLongPoll = {
@@ -988,7 +1218,13 @@ export class FileBackedStreamStore {
988
1218
  resolve: (msgs) => {
989
1219
  clearTimeout(timeoutId)
990
1220
  this.removePendingLongPoll(pending)
991
- resolve({ messages: msgs, timedOut: false })
1221
+ // Check if stream was closed
1222
+ const currentMeta = this.getMetaIfNotExpired(streamPath)
1223
+ resolve({
1224
+ messages: msgs,
1225
+ timedOut: false,
1226
+ streamClosed: currentMeta?.closed,
1227
+ })
992
1228
  },
993
1229
  timeoutId,
994
1230
  }
@@ -1113,6 +1349,18 @@ export class FileBackedStreamStore {
1113
1349
  }
1114
1350
  }
1115
1351
 
1352
+ /**
1353
+ * Notify pending long-polls that a stream has been closed.
1354
+ * They should wake up immediately and return Stream-Closed: true.
1355
+ */
1356
+ private notifyLongPollsClosed(streamPath: string): void {
1357
+ const toNotify = this.pendingLongPolls.filter((p) => p.path === streamPath)
1358
+ for (const pending of toNotify) {
1359
+ // Resolve with empty messages - the caller will check stream.closed
1360
+ pending.resolve([])
1361
+ }
1362
+ }
1363
+
1116
1364
  private cancelLongPollsForStream(streamPath: string): void {
1117
1365
  const toCancel = this.pendingLongPolls.filter((p) => p.path === streamPath)
1118
1366
  for (const pending of toCancel) {