@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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@durable-streams/server",
|
|
3
|
-
"version": "0.2.
|
|
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.
|
|
43
|
-
"@durable-streams/state": "0.2.
|
|
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.
|
|
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
|
-
|
|
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
|
-
//
|
|
823
|
-
if (
|
|
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<{
|
|
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
|
-
|
|
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
|
-
|
|
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) {
|