@durable-streams/server 0.1.1 → 0.1.3
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/README.md +167 -0
- package/dist/index.cjs +1682 -0
- package/dist/index.d.cts +518 -0
- package/dist/index.d.ts +26 -2
- package/dist/index.js +83 -26
- package/package.json +4 -4
- package/src/file-store.ts +58 -16
- package/src/store.ts +59 -10
package/dist/index.js
CHANGED
|
@@ -65,12 +65,40 @@ var StreamStore = class {
|
|
|
65
65
|
streams = new Map();
|
|
66
66
|
pendingLongPolls = [];
|
|
67
67
|
/**
|
|
68
|
+
* Check if a stream is expired based on TTL or Expires-At.
|
|
69
|
+
*/
|
|
70
|
+
isExpired(stream) {
|
|
71
|
+
const now = Date.now();
|
|
72
|
+
if (stream.expiresAt) {
|
|
73
|
+
const expiryTime = new Date(stream.expiresAt).getTime();
|
|
74
|
+
if (!Number.isFinite(expiryTime) || now >= expiryTime) return true;
|
|
75
|
+
}
|
|
76
|
+
if (stream.ttlSeconds !== void 0) {
|
|
77
|
+
const expiryTime = stream.createdAt + stream.ttlSeconds * 1e3;
|
|
78
|
+
if (now >= expiryTime) return true;
|
|
79
|
+
}
|
|
80
|
+
return false;
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Get a stream, deleting it if expired.
|
|
84
|
+
* Returns undefined if stream doesn't exist or is expired.
|
|
85
|
+
*/
|
|
86
|
+
getIfNotExpired(path$2) {
|
|
87
|
+
const stream = this.streams.get(path$2);
|
|
88
|
+
if (!stream) return void 0;
|
|
89
|
+
if (this.isExpired(stream)) {
|
|
90
|
+
this.delete(path$2);
|
|
91
|
+
return void 0;
|
|
92
|
+
}
|
|
93
|
+
return stream;
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
68
96
|
* Create a new stream.
|
|
69
97
|
* @throws Error if stream already exists with different config
|
|
70
98
|
* @returns existing stream if config matches (idempotent)
|
|
71
99
|
*/
|
|
72
100
|
create(path$2, options = {}) {
|
|
73
|
-
const existing = this.
|
|
101
|
+
const existing = this.getIfNotExpired(path$2);
|
|
74
102
|
if (existing) {
|
|
75
103
|
const contentTypeMatches = (normalizeContentType(options.contentType) || `application/octet-stream`) === (normalizeContentType(existing.contentType) || `application/octet-stream`);
|
|
76
104
|
const ttlMatches = options.ttlSeconds === existing.ttlSeconds;
|
|
@@ -93,15 +121,16 @@ var StreamStore = class {
|
|
|
93
121
|
}
|
|
94
122
|
/**
|
|
95
123
|
* Get a stream by path.
|
|
124
|
+
* Returns undefined if stream doesn't exist or is expired.
|
|
96
125
|
*/
|
|
97
126
|
get(path$2) {
|
|
98
|
-
return this.
|
|
127
|
+
return this.getIfNotExpired(path$2);
|
|
99
128
|
}
|
|
100
129
|
/**
|
|
101
|
-
* Check if a stream exists.
|
|
130
|
+
* Check if a stream exists (and is not expired).
|
|
102
131
|
*/
|
|
103
132
|
has(path$2) {
|
|
104
|
-
return this.
|
|
133
|
+
return this.getIfNotExpired(path$2) !== void 0;
|
|
105
134
|
}
|
|
106
135
|
/**
|
|
107
136
|
* Delete a stream.
|
|
@@ -112,12 +141,12 @@ var StreamStore = class {
|
|
|
112
141
|
}
|
|
113
142
|
/**
|
|
114
143
|
* Append data to a stream.
|
|
115
|
-
* @throws Error if stream doesn't exist
|
|
144
|
+
* @throws Error if stream doesn't exist or is expired
|
|
116
145
|
* @throws Error if seq is lower than lastSeq
|
|
117
146
|
* @throws Error if JSON mode and array is empty
|
|
118
147
|
*/
|
|
119
148
|
append(path$2, data, options = {}) {
|
|
120
|
-
const stream = this.
|
|
149
|
+
const stream = this.getIfNotExpired(path$2);
|
|
121
150
|
if (!stream) throw new Error(`Stream not found: ${path$2}`);
|
|
122
151
|
if (options.contentType && stream.contentType) {
|
|
123
152
|
const providedType = normalizeContentType(options.contentType);
|
|
@@ -134,9 +163,10 @@ var StreamStore = class {
|
|
|
134
163
|
}
|
|
135
164
|
/**
|
|
136
165
|
* Read messages from a stream starting at the given offset.
|
|
166
|
+
* @throws Error if stream doesn't exist or is expired
|
|
137
167
|
*/
|
|
138
168
|
read(path$2, offset) {
|
|
139
|
-
const stream = this.
|
|
169
|
+
const stream = this.getIfNotExpired(path$2);
|
|
140
170
|
if (!stream) throw new Error(`Stream not found: ${path$2}`);
|
|
141
171
|
if (!offset || offset === `-1`) return {
|
|
142
172
|
messages: [...stream.messages],
|
|
@@ -155,9 +185,10 @@ var StreamStore = class {
|
|
|
155
185
|
/**
|
|
156
186
|
* Format messages for response.
|
|
157
187
|
* For JSON mode, wraps concatenated data in array brackets.
|
|
188
|
+
* @throws Error if stream doesn't exist or is expired
|
|
158
189
|
*/
|
|
159
190
|
formatResponse(path$2, messages) {
|
|
160
|
-
const stream = this.
|
|
191
|
+
const stream = this.getIfNotExpired(path$2);
|
|
161
192
|
if (!stream) throw new Error(`Stream not found: ${path$2}`);
|
|
162
193
|
const totalSize = messages.reduce((sum, m) => sum + m.data.length, 0);
|
|
163
194
|
const concatenated = new Uint8Array(totalSize);
|
|
@@ -171,9 +202,10 @@ var StreamStore = class {
|
|
|
171
202
|
}
|
|
172
203
|
/**
|
|
173
204
|
* Wait for new messages (long-poll).
|
|
205
|
+
* @throws Error if stream doesn't exist or is expired
|
|
174
206
|
*/
|
|
175
207
|
async waitForMessages(path$2, offset, timeoutMs) {
|
|
176
|
-
const stream = this.
|
|
208
|
+
const stream = this.getIfNotExpired(path$2);
|
|
177
209
|
if (!stream) throw new Error(`Stream not found: ${path$2}`);
|
|
178
210
|
const { messages } = this.read(path$2, offset);
|
|
179
211
|
if (messages.length > 0) return {
|
|
@@ -206,9 +238,10 @@ var StreamStore = class {
|
|
|
206
238
|
}
|
|
207
239
|
/**
|
|
208
240
|
* Get the current offset for a stream.
|
|
241
|
+
* Returns undefined if stream doesn't exist or is expired.
|
|
209
242
|
*/
|
|
210
243
|
getCurrentOffset(path$2) {
|
|
211
|
-
return this.
|
|
244
|
+
return this.getIfNotExpired(path$2)?.currentOffset;
|
|
212
245
|
}
|
|
213
246
|
/**
|
|
214
247
|
* Clear all streams.
|
|
@@ -582,6 +615,35 @@ var FileBackedStreamStore = class {
|
|
|
582
615
|
};
|
|
583
616
|
}
|
|
584
617
|
/**
|
|
618
|
+
* Check if a stream is expired based on TTL or Expires-At.
|
|
619
|
+
*/
|
|
620
|
+
isExpired(meta) {
|
|
621
|
+
const now = Date.now();
|
|
622
|
+
if (meta.expiresAt) {
|
|
623
|
+
const expiryTime = new Date(meta.expiresAt).getTime();
|
|
624
|
+
if (!Number.isFinite(expiryTime) || now >= expiryTime) return true;
|
|
625
|
+
}
|
|
626
|
+
if (meta.ttlSeconds !== void 0) {
|
|
627
|
+
const expiryTime = meta.createdAt + meta.ttlSeconds * 1e3;
|
|
628
|
+
if (now >= expiryTime) return true;
|
|
629
|
+
}
|
|
630
|
+
return false;
|
|
631
|
+
}
|
|
632
|
+
/**
|
|
633
|
+
* Get stream metadata, deleting it if expired.
|
|
634
|
+
* Returns undefined if stream doesn't exist or is expired.
|
|
635
|
+
*/
|
|
636
|
+
getMetaIfNotExpired(streamPath) {
|
|
637
|
+
const key = `stream:${streamPath}`;
|
|
638
|
+
const meta = this.db.get(key);
|
|
639
|
+
if (!meta) return void 0;
|
|
640
|
+
if (this.isExpired(meta)) {
|
|
641
|
+
this.delete(streamPath);
|
|
642
|
+
return void 0;
|
|
643
|
+
}
|
|
644
|
+
return meta;
|
|
645
|
+
}
|
|
646
|
+
/**
|
|
585
647
|
* Close the store, closing all file handles and database.
|
|
586
648
|
* All data is already fsynced on each append, so no final flush needed.
|
|
587
649
|
*/
|
|
@@ -590,8 +652,7 @@ var FileBackedStreamStore = class {
|
|
|
590
652
|
await this.db.close();
|
|
591
653
|
}
|
|
592
654
|
async create(streamPath, options = {}) {
|
|
593
|
-
const
|
|
594
|
-
const existing = this.db.get(key);
|
|
655
|
+
const existing = this.getMetaIfNotExpired(streamPath);
|
|
595
656
|
if (existing) {
|
|
596
657
|
const normalizeMimeType = (ct) => (ct ?? `application/octet-stream`).toLowerCase();
|
|
597
658
|
const contentTypeMatches = normalizeMimeType(options.contentType) === normalizeMimeType(existing.contentType);
|
|
@@ -600,6 +661,7 @@ var FileBackedStreamStore = class {
|
|
|
600
661
|
if (contentTypeMatches && ttlMatches && expiresMatches) return this.streamMetaToStream(existing);
|
|
601
662
|
else throw new Error(`Stream already exists with different configuration: ${streamPath}`);
|
|
602
663
|
}
|
|
664
|
+
const key = `stream:${streamPath}`;
|
|
603
665
|
const streamMeta = {
|
|
604
666
|
path: streamPath,
|
|
605
667
|
contentType: options.contentType,
|
|
@@ -633,13 +695,11 @@ var FileBackedStreamStore = class {
|
|
|
633
695
|
return this.streamMetaToStream(streamMeta);
|
|
634
696
|
}
|
|
635
697
|
get(streamPath) {
|
|
636
|
-
const
|
|
637
|
-
const meta = this.db.get(key);
|
|
698
|
+
const meta = this.getMetaIfNotExpired(streamPath);
|
|
638
699
|
return meta ? this.streamMetaToStream(meta) : void 0;
|
|
639
700
|
}
|
|
640
701
|
has(streamPath) {
|
|
641
|
-
|
|
642
|
-
return this.db.get(key) !== void 0;
|
|
702
|
+
return this.getMetaIfNotExpired(streamPath) !== void 0;
|
|
643
703
|
}
|
|
644
704
|
delete(streamPath) {
|
|
645
705
|
const key = `stream:${streamPath}`;
|
|
@@ -657,8 +717,7 @@ var FileBackedStreamStore = class {
|
|
|
657
717
|
return true;
|
|
658
718
|
}
|
|
659
719
|
async append(streamPath, data, options = {}) {
|
|
660
|
-
const
|
|
661
|
-
const streamMeta = this.db.get(key);
|
|
720
|
+
const streamMeta = this.getMetaIfNotExpired(streamPath);
|
|
662
721
|
if (!streamMeta) throw new Error(`Stream not found: ${streamPath}`);
|
|
663
722
|
if (options.contentType && streamMeta.contentType) {
|
|
664
723
|
const providedType = normalizeContentType(options.contentType);
|
|
@@ -706,13 +765,13 @@ var FileBackedStreamStore = class {
|
|
|
706
765
|
lastSeq: options.seq ?? streamMeta.lastSeq,
|
|
707
766
|
totalBytes: streamMeta.totalBytes + processedData.length + 5
|
|
708
767
|
};
|
|
768
|
+
const key = `stream:${streamPath}`;
|
|
709
769
|
this.db.putSync(key, updatedMeta);
|
|
710
770
|
this.notifyLongPolls(streamPath);
|
|
711
771
|
return message;
|
|
712
772
|
}
|
|
713
773
|
read(streamPath, offset) {
|
|
714
|
-
const
|
|
715
|
-
const streamMeta = this.db.get(key);
|
|
774
|
+
const streamMeta = this.getMetaIfNotExpired(streamPath);
|
|
716
775
|
if (!streamMeta) throw new Error(`Stream not found: ${streamPath}`);
|
|
717
776
|
const startOffset = offset ?? `0000000000000000_0000000000000000`;
|
|
718
777
|
const startParts = startOffset.split(`_`).map(Number);
|
|
@@ -764,8 +823,7 @@ var FileBackedStreamStore = class {
|
|
|
764
823
|
};
|
|
765
824
|
}
|
|
766
825
|
async waitForMessages(streamPath, offset, timeoutMs) {
|
|
767
|
-
const
|
|
768
|
-
const streamMeta = this.db.get(key);
|
|
826
|
+
const streamMeta = this.getMetaIfNotExpired(streamPath);
|
|
769
827
|
if (!streamMeta) throw new Error(`Stream not found: ${streamPath}`);
|
|
770
828
|
const { messages } = this.read(streamPath, offset);
|
|
771
829
|
if (messages.length > 0) return {
|
|
@@ -799,10 +857,10 @@ var FileBackedStreamStore = class {
|
|
|
799
857
|
/**
|
|
800
858
|
* Format messages for response.
|
|
801
859
|
* For JSON mode, wraps concatenated data in array brackets.
|
|
860
|
+
* @throws Error if stream doesn't exist or is expired
|
|
802
861
|
*/
|
|
803
862
|
formatResponse(streamPath, messages) {
|
|
804
|
-
const
|
|
805
|
-
const streamMeta = this.db.get(key);
|
|
863
|
+
const streamMeta = this.getMetaIfNotExpired(streamPath);
|
|
806
864
|
if (!streamMeta) throw new Error(`Stream not found: ${streamPath}`);
|
|
807
865
|
const totalSize = messages.reduce((sum, m) => sum + m.data.length, 0);
|
|
808
866
|
const concatenated = new Uint8Array(totalSize);
|
|
@@ -815,8 +873,7 @@ var FileBackedStreamStore = class {
|
|
|
815
873
|
return concatenated;
|
|
816
874
|
}
|
|
817
875
|
getCurrentOffset(streamPath) {
|
|
818
|
-
const
|
|
819
|
-
const streamMeta = this.db.get(key);
|
|
876
|
+
const streamMeta = this.getMetaIfNotExpired(streamPath);
|
|
820
877
|
return streamMeta?.currentOffset;
|
|
821
878
|
}
|
|
822
879
|
clear() {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@durable-streams/server",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.3",
|
|
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.1.
|
|
43
|
-
"@durable-streams/state": "0.1.
|
|
42
|
+
"@durable-streams/client": "0.1.2",
|
|
43
|
+
"@durable-streams/state": "0.1.2"
|
|
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": "^3.2.4",
|
|
50
|
-
"@durable-streams/server-conformance-tests": "0.1.
|
|
50
|
+
"@durable-streams/server-conformance-tests": "0.1.3"
|
|
51
51
|
},
|
|
52
52
|
"files": [
|
|
53
53
|
"dist",
|
package/src/file-store.ts
CHANGED
|
@@ -332,6 +332,50 @@ export class FileBackedStreamStore {
|
|
|
332
332
|
}
|
|
333
333
|
}
|
|
334
334
|
|
|
335
|
+
/**
|
|
336
|
+
* Check if a stream is expired based on TTL or Expires-At.
|
|
337
|
+
*/
|
|
338
|
+
private isExpired(meta: StreamMetadata): boolean {
|
|
339
|
+
const now = Date.now()
|
|
340
|
+
|
|
341
|
+
// Check absolute expiry time
|
|
342
|
+
if (meta.expiresAt) {
|
|
343
|
+
const expiryTime = new Date(meta.expiresAt).getTime()
|
|
344
|
+
// Treat invalid dates (NaN) as expired (fail closed)
|
|
345
|
+
if (!Number.isFinite(expiryTime) || now >= expiryTime) {
|
|
346
|
+
return true
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// Check TTL (relative to creation time)
|
|
351
|
+
if (meta.ttlSeconds !== undefined) {
|
|
352
|
+
const expiryTime = meta.createdAt + meta.ttlSeconds * 1000
|
|
353
|
+
if (now >= expiryTime) {
|
|
354
|
+
return true
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
return false
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* Get stream metadata, deleting it if expired.
|
|
363
|
+
* Returns undefined if stream doesn't exist or is expired.
|
|
364
|
+
*/
|
|
365
|
+
private getMetaIfNotExpired(streamPath: string): StreamMetadata | undefined {
|
|
366
|
+
const key = `stream:${streamPath}`
|
|
367
|
+
const meta = this.db.get(key) as StreamMetadata | undefined
|
|
368
|
+
if (!meta) {
|
|
369
|
+
return undefined
|
|
370
|
+
}
|
|
371
|
+
if (this.isExpired(meta)) {
|
|
372
|
+
// Delete expired stream
|
|
373
|
+
this.delete(streamPath)
|
|
374
|
+
return undefined
|
|
375
|
+
}
|
|
376
|
+
return meta
|
|
377
|
+
}
|
|
378
|
+
|
|
335
379
|
/**
|
|
336
380
|
* Close the store, closing all file handles and database.
|
|
337
381
|
* All data is already fsynced on each append, so no final flush needed.
|
|
@@ -354,8 +398,8 @@ export class FileBackedStreamStore {
|
|
|
354
398
|
initialData?: Uint8Array
|
|
355
399
|
} = {}
|
|
356
400
|
): Promise<Stream> {
|
|
357
|
-
|
|
358
|
-
const existing = this.
|
|
401
|
+
// Use getMetaIfNotExpired to treat expired streams as non-existent
|
|
402
|
+
const existing = this.getMetaIfNotExpired(streamPath)
|
|
359
403
|
|
|
360
404
|
if (existing) {
|
|
361
405
|
// Check if config matches (idempotent create)
|
|
@@ -379,6 +423,9 @@ export class FileBackedStreamStore {
|
|
|
379
423
|
}
|
|
380
424
|
}
|
|
381
425
|
|
|
426
|
+
// Define key for LMDB operations
|
|
427
|
+
const key = `stream:${streamPath}`
|
|
428
|
+
|
|
382
429
|
// Initialize metadata
|
|
383
430
|
const streamMeta: StreamMetadata = {
|
|
384
431
|
path: streamPath,
|
|
@@ -430,14 +477,12 @@ export class FileBackedStreamStore {
|
|
|
430
477
|
}
|
|
431
478
|
|
|
432
479
|
get(streamPath: string): Stream | undefined {
|
|
433
|
-
const
|
|
434
|
-
const meta = this.db.get(key) as StreamMetadata | undefined
|
|
480
|
+
const meta = this.getMetaIfNotExpired(streamPath)
|
|
435
481
|
return meta ? this.streamMetaToStream(meta) : undefined
|
|
436
482
|
}
|
|
437
483
|
|
|
438
484
|
has(streamPath: string): boolean {
|
|
439
|
-
|
|
440
|
-
return this.db.get(key) !== undefined
|
|
485
|
+
return this.getMetaIfNotExpired(streamPath) !== undefined
|
|
441
486
|
}
|
|
442
487
|
|
|
443
488
|
delete(streamPath: string): boolean {
|
|
@@ -489,8 +534,7 @@ export class FileBackedStreamStore {
|
|
|
489
534
|
isInitialCreate?: boolean
|
|
490
535
|
} = {}
|
|
491
536
|
): Promise<StreamMessage | null> {
|
|
492
|
-
const
|
|
493
|
-
const streamMeta = this.db.get(key) as StreamMetadata | undefined
|
|
537
|
+
const streamMeta = this.getMetaIfNotExpired(streamPath)
|
|
494
538
|
|
|
495
539
|
if (!streamMeta) {
|
|
496
540
|
throw new Error(`Stream not found: ${streamPath}`)
|
|
@@ -583,6 +627,7 @@ export class FileBackedStreamStore {
|
|
|
583
627
|
lastSeq: options.seq ?? streamMeta.lastSeq,
|
|
584
628
|
totalBytes: streamMeta.totalBytes + processedData.length + 5, // +4 for length, +1 for newline
|
|
585
629
|
}
|
|
630
|
+
const key = `stream:${streamPath}`
|
|
586
631
|
this.db.putSync(key, updatedMeta)
|
|
587
632
|
|
|
588
633
|
// 5. Notify long-polls (data is now readable from disk)
|
|
@@ -596,8 +641,7 @@ export class FileBackedStreamStore {
|
|
|
596
641
|
streamPath: string,
|
|
597
642
|
offset?: string
|
|
598
643
|
): { messages: Array<StreamMessage>; upToDate: boolean } {
|
|
599
|
-
const
|
|
600
|
-
const streamMeta = this.db.get(key) as StreamMetadata | undefined
|
|
644
|
+
const streamMeta = this.getMetaIfNotExpired(streamPath)
|
|
601
645
|
|
|
602
646
|
if (!streamMeta) {
|
|
603
647
|
throw new Error(`Stream not found: ${streamPath}`)
|
|
@@ -690,8 +734,7 @@ export class FileBackedStreamStore {
|
|
|
690
734
|
offset: string,
|
|
691
735
|
timeoutMs: number
|
|
692
736
|
): Promise<{ messages: Array<StreamMessage>; timedOut: boolean }> {
|
|
693
|
-
const
|
|
694
|
-
const streamMeta = this.db.get(key) as StreamMetadata | undefined
|
|
737
|
+
const streamMeta = this.getMetaIfNotExpired(streamPath)
|
|
695
738
|
|
|
696
739
|
if (!streamMeta) {
|
|
697
740
|
throw new Error(`Stream not found: ${streamPath}`)
|
|
@@ -729,13 +772,13 @@ export class FileBackedStreamStore {
|
|
|
729
772
|
/**
|
|
730
773
|
* Format messages for response.
|
|
731
774
|
* For JSON mode, wraps concatenated data in array brackets.
|
|
775
|
+
* @throws Error if stream doesn't exist or is expired
|
|
732
776
|
*/
|
|
733
777
|
formatResponse(
|
|
734
778
|
streamPath: string,
|
|
735
779
|
messages: Array<StreamMessage>
|
|
736
780
|
): Uint8Array {
|
|
737
|
-
const
|
|
738
|
-
const streamMeta = this.db.get(key) as StreamMetadata | undefined
|
|
781
|
+
const streamMeta = this.getMetaIfNotExpired(streamPath)
|
|
739
782
|
|
|
740
783
|
if (!streamMeta) {
|
|
741
784
|
throw new Error(`Stream not found: ${streamPath}`)
|
|
@@ -759,8 +802,7 @@ export class FileBackedStreamStore {
|
|
|
759
802
|
}
|
|
760
803
|
|
|
761
804
|
getCurrentOffset(streamPath: string): string | undefined {
|
|
762
|
-
const
|
|
763
|
-
const streamMeta = this.db.get(key) as StreamMetadata | undefined
|
|
805
|
+
const streamMeta = this.getMetaIfNotExpired(streamPath)
|
|
764
806
|
return streamMeta?.currentOffset
|
|
765
807
|
}
|
|
766
808
|
|
package/src/store.ts
CHANGED
|
@@ -83,6 +83,49 @@ export class StreamStore {
|
|
|
83
83
|
private streams = new Map<string, Stream>()
|
|
84
84
|
private pendingLongPolls: Array<PendingLongPoll> = []
|
|
85
85
|
|
|
86
|
+
/**
|
|
87
|
+
* Check if a stream is expired based on TTL or Expires-At.
|
|
88
|
+
*/
|
|
89
|
+
private isExpired(stream: Stream): boolean {
|
|
90
|
+
const now = Date.now()
|
|
91
|
+
|
|
92
|
+
// Check absolute expiry time
|
|
93
|
+
if (stream.expiresAt) {
|
|
94
|
+
const expiryTime = new Date(stream.expiresAt).getTime()
|
|
95
|
+
// Treat invalid dates (NaN) as expired (fail closed)
|
|
96
|
+
if (!Number.isFinite(expiryTime) || now >= expiryTime) {
|
|
97
|
+
return true
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Check TTL (relative to creation time)
|
|
102
|
+
if (stream.ttlSeconds !== undefined) {
|
|
103
|
+
const expiryTime = stream.createdAt + stream.ttlSeconds * 1000
|
|
104
|
+
if (now >= expiryTime) {
|
|
105
|
+
return true
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return false
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Get a stream, deleting it if expired.
|
|
114
|
+
* Returns undefined if stream doesn't exist or is expired.
|
|
115
|
+
*/
|
|
116
|
+
private getIfNotExpired(path: string): Stream | undefined {
|
|
117
|
+
const stream = this.streams.get(path)
|
|
118
|
+
if (!stream) {
|
|
119
|
+
return undefined
|
|
120
|
+
}
|
|
121
|
+
if (this.isExpired(stream)) {
|
|
122
|
+
// Delete expired stream
|
|
123
|
+
this.delete(path)
|
|
124
|
+
return undefined
|
|
125
|
+
}
|
|
126
|
+
return stream
|
|
127
|
+
}
|
|
128
|
+
|
|
86
129
|
/**
|
|
87
130
|
* Create a new stream.
|
|
88
131
|
* @throws Error if stream already exists with different config
|
|
@@ -97,7 +140,8 @@ export class StreamStore {
|
|
|
97
140
|
initialData?: Uint8Array
|
|
98
141
|
} = {}
|
|
99
142
|
): Stream {
|
|
100
|
-
|
|
143
|
+
// Use getIfNotExpired to treat expired streams as non-existent
|
|
144
|
+
const existing = this.getIfNotExpired(path)
|
|
101
145
|
if (existing) {
|
|
102
146
|
// Check if config matches (idempotent create)
|
|
103
147
|
const contentTypeMatches =
|
|
@@ -140,16 +184,17 @@ export class StreamStore {
|
|
|
140
184
|
|
|
141
185
|
/**
|
|
142
186
|
* Get a stream by path.
|
|
187
|
+
* Returns undefined if stream doesn't exist or is expired.
|
|
143
188
|
*/
|
|
144
189
|
get(path: string): Stream | undefined {
|
|
145
|
-
return this.
|
|
190
|
+
return this.getIfNotExpired(path)
|
|
146
191
|
}
|
|
147
192
|
|
|
148
193
|
/**
|
|
149
|
-
* Check if a stream exists.
|
|
194
|
+
* Check if a stream exists (and is not expired).
|
|
150
195
|
*/
|
|
151
196
|
has(path: string): boolean {
|
|
152
|
-
return this.
|
|
197
|
+
return this.getIfNotExpired(path) !== undefined
|
|
153
198
|
}
|
|
154
199
|
|
|
155
200
|
/**
|
|
@@ -163,7 +208,7 @@ export class StreamStore {
|
|
|
163
208
|
|
|
164
209
|
/**
|
|
165
210
|
* Append data to a stream.
|
|
166
|
-
* @throws Error if stream doesn't exist
|
|
211
|
+
* @throws Error if stream doesn't exist or is expired
|
|
167
212
|
* @throws Error if seq is lower than lastSeq
|
|
168
213
|
* @throws Error if JSON mode and array is empty
|
|
169
214
|
*/
|
|
@@ -172,7 +217,7 @@ export class StreamStore {
|
|
|
172
217
|
data: Uint8Array,
|
|
173
218
|
options: { seq?: string; contentType?: string } = {}
|
|
174
219
|
): StreamMessage {
|
|
175
|
-
const stream = this.
|
|
220
|
+
const stream = this.getIfNotExpired(path)
|
|
176
221
|
if (!stream) {
|
|
177
222
|
throw new Error(`Stream not found: ${path}`)
|
|
178
223
|
}
|
|
@@ -210,12 +255,13 @@ export class StreamStore {
|
|
|
210
255
|
|
|
211
256
|
/**
|
|
212
257
|
* Read messages from a stream starting at the given offset.
|
|
258
|
+
* @throws Error if stream doesn't exist or is expired
|
|
213
259
|
*/
|
|
214
260
|
read(
|
|
215
261
|
path: string,
|
|
216
262
|
offset?: string
|
|
217
263
|
): { messages: Array<StreamMessage>; upToDate: boolean } {
|
|
218
|
-
const stream = this.
|
|
264
|
+
const stream = this.getIfNotExpired(path)
|
|
219
265
|
if (!stream) {
|
|
220
266
|
throw new Error(`Stream not found: ${path}`)
|
|
221
267
|
}
|
|
@@ -247,9 +293,10 @@ export class StreamStore {
|
|
|
247
293
|
/**
|
|
248
294
|
* Format messages for response.
|
|
249
295
|
* For JSON mode, wraps concatenated data in array brackets.
|
|
296
|
+
* @throws Error if stream doesn't exist or is expired
|
|
250
297
|
*/
|
|
251
298
|
formatResponse(path: string, messages: Array<StreamMessage>): Uint8Array {
|
|
252
|
-
const stream = this.
|
|
299
|
+
const stream = this.getIfNotExpired(path)
|
|
253
300
|
if (!stream) {
|
|
254
301
|
throw new Error(`Stream not found: ${path}`)
|
|
255
302
|
}
|
|
@@ -273,13 +320,14 @@ export class StreamStore {
|
|
|
273
320
|
|
|
274
321
|
/**
|
|
275
322
|
* Wait for new messages (long-poll).
|
|
323
|
+
* @throws Error if stream doesn't exist or is expired
|
|
276
324
|
*/
|
|
277
325
|
async waitForMessages(
|
|
278
326
|
path: string,
|
|
279
327
|
offset: string,
|
|
280
328
|
timeoutMs: number
|
|
281
329
|
): Promise<{ messages: Array<StreamMessage>; timedOut: boolean }> {
|
|
282
|
-
const stream = this.
|
|
330
|
+
const stream = this.getIfNotExpired(path)
|
|
283
331
|
if (!stream) {
|
|
284
332
|
throw new Error(`Stream not found: ${path}`)
|
|
285
333
|
}
|
|
@@ -315,9 +363,10 @@ export class StreamStore {
|
|
|
315
363
|
|
|
316
364
|
/**
|
|
317
365
|
* Get the current offset for a stream.
|
|
366
|
+
* Returns undefined if stream doesn't exist or is expired.
|
|
318
367
|
*/
|
|
319
368
|
getCurrentOffset(path: string): string | undefined {
|
|
320
|
-
return this.
|
|
369
|
+
return this.getIfNotExpired(path)?.currentOffset
|
|
321
370
|
}
|
|
322
371
|
|
|
323
372
|
/**
|