@durable-streams/server 0.1.2 → 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 +83 -26
- package/dist/index.d.cts +26 -2
- package/dist/index.d.ts +26 -2
- package/dist/index.js +83 -26
- package/package.json +2 -2
- package/src/file-store.ts +58 -16
- package/src/store.ts +59 -10
package/README.md
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
# @durable-streams/server
|
|
2
|
+
|
|
3
|
+
Node.js reference server implementation for the Durable Streams protocol.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install @durable-streams/server
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Overview
|
|
12
|
+
|
|
13
|
+
This package provides a reference implementation of the Durable Streams protocol for Node.js. It supports both in-memory and file-backed storage modes, making it suitable for development, testing, and production workloads.
|
|
14
|
+
|
|
15
|
+
For a standalone binary option, see the [Caddy-based server](https://github.com/durable-streams/durable-streams/releases).
|
|
16
|
+
|
|
17
|
+
## Quick Start
|
|
18
|
+
|
|
19
|
+
```typescript
|
|
20
|
+
import { DurableStreamTestServer } from "@durable-streams/server"
|
|
21
|
+
|
|
22
|
+
const server = new DurableStreamTestServer({
|
|
23
|
+
port: 4437,
|
|
24
|
+
host: "127.0.0.1",
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
await server.start()
|
|
28
|
+
console.log("Server running on http://127.0.0.1:4437")
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## Storage Modes
|
|
32
|
+
|
|
33
|
+
### In-Memory (Default)
|
|
34
|
+
|
|
35
|
+
Fast, ephemeral storage for development and testing:
|
|
36
|
+
|
|
37
|
+
```typescript
|
|
38
|
+
import { DurableStreamTestServer, StreamStore } from "@durable-streams/server"
|
|
39
|
+
|
|
40
|
+
const store = new StreamStore()
|
|
41
|
+
const server = new DurableStreamTestServer({
|
|
42
|
+
port: 4437,
|
|
43
|
+
store,
|
|
44
|
+
})
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
### File-Backed
|
|
48
|
+
|
|
49
|
+
Persistent storage with streams stored as log files and LMDB for metadata:
|
|
50
|
+
|
|
51
|
+
```typescript
|
|
52
|
+
import {
|
|
53
|
+
DurableStreamTestServer,
|
|
54
|
+
FileBackedStreamStore,
|
|
55
|
+
} from "@durable-streams/server"
|
|
56
|
+
|
|
57
|
+
const store = new FileBackedStreamStore({
|
|
58
|
+
path: "./data/streams",
|
|
59
|
+
})
|
|
60
|
+
const server = new DurableStreamTestServer({
|
|
61
|
+
port: 4437,
|
|
62
|
+
store,
|
|
63
|
+
})
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
## Registry Hooks
|
|
67
|
+
|
|
68
|
+
Track stream lifecycle events (creation, deletion):
|
|
69
|
+
|
|
70
|
+
```typescript
|
|
71
|
+
import {
|
|
72
|
+
DurableStreamTestServer,
|
|
73
|
+
createRegistryHooks,
|
|
74
|
+
} from "@durable-streams/server"
|
|
75
|
+
|
|
76
|
+
const server = new DurableStreamTestServer({
|
|
77
|
+
port: 4437,
|
|
78
|
+
hooks: createRegistryHooks({
|
|
79
|
+
registryPath: "__registry__",
|
|
80
|
+
}),
|
|
81
|
+
})
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
The registry maintains a system stream that tracks all stream creates and deletes, useful for building admin UIs or monitoring.
|
|
85
|
+
|
|
86
|
+
## API
|
|
87
|
+
|
|
88
|
+
### DurableStreamTestServer
|
|
89
|
+
|
|
90
|
+
```typescript
|
|
91
|
+
interface TestServerOptions {
|
|
92
|
+
port?: number
|
|
93
|
+
host?: string
|
|
94
|
+
store?: StreamStore | FileBackedStreamStore
|
|
95
|
+
hooks?: StreamLifecycleHook[]
|
|
96
|
+
cors?: boolean
|
|
97
|
+
cursorOptions?: CursorOptions
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
class DurableStreamTestServer {
|
|
101
|
+
constructor(options?: TestServerOptions)
|
|
102
|
+
start(): Promise<void>
|
|
103
|
+
stop(): Promise<void>
|
|
104
|
+
readonly port: number
|
|
105
|
+
readonly baseUrl: string
|
|
106
|
+
}
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
### StreamStore
|
|
110
|
+
|
|
111
|
+
In-memory stream storage:
|
|
112
|
+
|
|
113
|
+
```typescript
|
|
114
|
+
class StreamStore {
|
|
115
|
+
create(path: string, contentType: string, options?: CreateOptions): Stream
|
|
116
|
+
get(path: string): Stream | undefined
|
|
117
|
+
delete(path: string): boolean
|
|
118
|
+
append(path: string, data: Uint8Array, seq?: string): void
|
|
119
|
+
read(path: string, offset: string): ReadResult
|
|
120
|
+
}
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
### FileBackedStreamStore
|
|
124
|
+
|
|
125
|
+
File-backed persistent storage (log files for streams, LMDB for metadata) with the same interface as `StreamStore`.
|
|
126
|
+
|
|
127
|
+
## Exports
|
|
128
|
+
|
|
129
|
+
```typescript
|
|
130
|
+
export { DurableStreamTestServer } from "./server"
|
|
131
|
+
export { StreamStore } from "./store"
|
|
132
|
+
export { FileBackedStreamStore } from "./file-store"
|
|
133
|
+
export { encodeStreamPath, decodeStreamPath } from "./path-encoding"
|
|
134
|
+
export { createRegistryHooks } from "./registry-hook"
|
|
135
|
+
export {
|
|
136
|
+
calculateCursor,
|
|
137
|
+
handleCursorCollision,
|
|
138
|
+
generateResponseCursor,
|
|
139
|
+
DEFAULT_CURSOR_EPOCH,
|
|
140
|
+
DEFAULT_CURSOR_INTERVAL_SECONDS,
|
|
141
|
+
type CursorOptions,
|
|
142
|
+
} from "./cursor"
|
|
143
|
+
export type {
|
|
144
|
+
Stream,
|
|
145
|
+
StreamMessage,
|
|
146
|
+
TestServerOptions,
|
|
147
|
+
PendingLongPoll,
|
|
148
|
+
StreamLifecycleEvent,
|
|
149
|
+
StreamLifecycleHook,
|
|
150
|
+
} from "./types"
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
## Testing Your Implementation
|
|
154
|
+
|
|
155
|
+
Use the conformance test suite to validate protocol compliance:
|
|
156
|
+
|
|
157
|
+
```typescript
|
|
158
|
+
import { runConformanceTests } from "@durable-streams/server-conformance-tests"
|
|
159
|
+
|
|
160
|
+
runConformanceTests({
|
|
161
|
+
baseUrl: "http://localhost:4437",
|
|
162
|
+
})
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
## License
|
|
166
|
+
|
|
167
|
+
Apache-2.0
|
package/dist/index.cjs
CHANGED
|
@@ -88,12 +88,40 @@ var StreamStore = class {
|
|
|
88
88
|
streams = new Map();
|
|
89
89
|
pendingLongPolls = [];
|
|
90
90
|
/**
|
|
91
|
+
* Check if a stream is expired based on TTL or Expires-At.
|
|
92
|
+
*/
|
|
93
|
+
isExpired(stream) {
|
|
94
|
+
const now = Date.now();
|
|
95
|
+
if (stream.expiresAt) {
|
|
96
|
+
const expiryTime = new Date(stream.expiresAt).getTime();
|
|
97
|
+
if (!Number.isFinite(expiryTime) || now >= expiryTime) return true;
|
|
98
|
+
}
|
|
99
|
+
if (stream.ttlSeconds !== void 0) {
|
|
100
|
+
const expiryTime = stream.createdAt + stream.ttlSeconds * 1e3;
|
|
101
|
+
if (now >= expiryTime) return true;
|
|
102
|
+
}
|
|
103
|
+
return false;
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Get a stream, deleting it if expired.
|
|
107
|
+
* Returns undefined if stream doesn't exist or is expired.
|
|
108
|
+
*/
|
|
109
|
+
getIfNotExpired(path) {
|
|
110
|
+
const stream = this.streams.get(path);
|
|
111
|
+
if (!stream) return void 0;
|
|
112
|
+
if (this.isExpired(stream)) {
|
|
113
|
+
this.delete(path);
|
|
114
|
+
return void 0;
|
|
115
|
+
}
|
|
116
|
+
return stream;
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
91
119
|
* Create a new stream.
|
|
92
120
|
* @throws Error if stream already exists with different config
|
|
93
121
|
* @returns existing stream if config matches (idempotent)
|
|
94
122
|
*/
|
|
95
123
|
create(path, options = {}) {
|
|
96
|
-
const existing = this.
|
|
124
|
+
const existing = this.getIfNotExpired(path);
|
|
97
125
|
if (existing) {
|
|
98
126
|
const contentTypeMatches = (normalizeContentType(options.contentType) || `application/octet-stream`) === (normalizeContentType(existing.contentType) || `application/octet-stream`);
|
|
99
127
|
const ttlMatches = options.ttlSeconds === existing.ttlSeconds;
|
|
@@ -116,15 +144,16 @@ var StreamStore = class {
|
|
|
116
144
|
}
|
|
117
145
|
/**
|
|
118
146
|
* Get a stream by path.
|
|
147
|
+
* Returns undefined if stream doesn't exist or is expired.
|
|
119
148
|
*/
|
|
120
149
|
get(path) {
|
|
121
|
-
return this.
|
|
150
|
+
return this.getIfNotExpired(path);
|
|
122
151
|
}
|
|
123
152
|
/**
|
|
124
|
-
* Check if a stream exists.
|
|
153
|
+
* Check if a stream exists (and is not expired).
|
|
125
154
|
*/
|
|
126
155
|
has(path) {
|
|
127
|
-
return this.
|
|
156
|
+
return this.getIfNotExpired(path) !== void 0;
|
|
128
157
|
}
|
|
129
158
|
/**
|
|
130
159
|
* Delete a stream.
|
|
@@ -135,12 +164,12 @@ var StreamStore = class {
|
|
|
135
164
|
}
|
|
136
165
|
/**
|
|
137
166
|
* Append data to a stream.
|
|
138
|
-
* @throws Error if stream doesn't exist
|
|
167
|
+
* @throws Error if stream doesn't exist or is expired
|
|
139
168
|
* @throws Error if seq is lower than lastSeq
|
|
140
169
|
* @throws Error if JSON mode and array is empty
|
|
141
170
|
*/
|
|
142
171
|
append(path, data, options = {}) {
|
|
143
|
-
const stream = this.
|
|
172
|
+
const stream = this.getIfNotExpired(path);
|
|
144
173
|
if (!stream) throw new Error(`Stream not found: ${path}`);
|
|
145
174
|
if (options.contentType && stream.contentType) {
|
|
146
175
|
const providedType = normalizeContentType(options.contentType);
|
|
@@ -157,9 +186,10 @@ var StreamStore = class {
|
|
|
157
186
|
}
|
|
158
187
|
/**
|
|
159
188
|
* Read messages from a stream starting at the given offset.
|
|
189
|
+
* @throws Error if stream doesn't exist or is expired
|
|
160
190
|
*/
|
|
161
191
|
read(path, offset) {
|
|
162
|
-
const stream = this.
|
|
192
|
+
const stream = this.getIfNotExpired(path);
|
|
163
193
|
if (!stream) throw new Error(`Stream not found: ${path}`);
|
|
164
194
|
if (!offset || offset === `-1`) return {
|
|
165
195
|
messages: [...stream.messages],
|
|
@@ -178,9 +208,10 @@ var StreamStore = class {
|
|
|
178
208
|
/**
|
|
179
209
|
* Format messages for response.
|
|
180
210
|
* For JSON mode, wraps concatenated data in array brackets.
|
|
211
|
+
* @throws Error if stream doesn't exist or is expired
|
|
181
212
|
*/
|
|
182
213
|
formatResponse(path, messages) {
|
|
183
|
-
const stream = this.
|
|
214
|
+
const stream = this.getIfNotExpired(path);
|
|
184
215
|
if (!stream) throw new Error(`Stream not found: ${path}`);
|
|
185
216
|
const totalSize = messages.reduce((sum, m) => sum + m.data.length, 0);
|
|
186
217
|
const concatenated = new Uint8Array(totalSize);
|
|
@@ -194,9 +225,10 @@ var StreamStore = class {
|
|
|
194
225
|
}
|
|
195
226
|
/**
|
|
196
227
|
* Wait for new messages (long-poll).
|
|
228
|
+
* @throws Error if stream doesn't exist or is expired
|
|
197
229
|
*/
|
|
198
230
|
async waitForMessages(path, offset, timeoutMs) {
|
|
199
|
-
const stream = this.
|
|
231
|
+
const stream = this.getIfNotExpired(path);
|
|
200
232
|
if (!stream) throw new Error(`Stream not found: ${path}`);
|
|
201
233
|
const { messages } = this.read(path, offset);
|
|
202
234
|
if (messages.length > 0) return {
|
|
@@ -229,9 +261,10 @@ var StreamStore = class {
|
|
|
229
261
|
}
|
|
230
262
|
/**
|
|
231
263
|
* Get the current offset for a stream.
|
|
264
|
+
* Returns undefined if stream doesn't exist or is expired.
|
|
232
265
|
*/
|
|
233
266
|
getCurrentOffset(path) {
|
|
234
|
-
return this.
|
|
267
|
+
return this.getIfNotExpired(path)?.currentOffset;
|
|
235
268
|
}
|
|
236
269
|
/**
|
|
237
270
|
* Clear all streams.
|
|
@@ -605,6 +638,35 @@ var FileBackedStreamStore = class {
|
|
|
605
638
|
};
|
|
606
639
|
}
|
|
607
640
|
/**
|
|
641
|
+
* Check if a stream is expired based on TTL or Expires-At.
|
|
642
|
+
*/
|
|
643
|
+
isExpired(meta) {
|
|
644
|
+
const now = Date.now();
|
|
645
|
+
if (meta.expiresAt) {
|
|
646
|
+
const expiryTime = new Date(meta.expiresAt).getTime();
|
|
647
|
+
if (!Number.isFinite(expiryTime) || now >= expiryTime) return true;
|
|
648
|
+
}
|
|
649
|
+
if (meta.ttlSeconds !== void 0) {
|
|
650
|
+
const expiryTime = meta.createdAt + meta.ttlSeconds * 1e3;
|
|
651
|
+
if (now >= expiryTime) return true;
|
|
652
|
+
}
|
|
653
|
+
return false;
|
|
654
|
+
}
|
|
655
|
+
/**
|
|
656
|
+
* Get stream metadata, deleting it if expired.
|
|
657
|
+
* Returns undefined if stream doesn't exist or is expired.
|
|
658
|
+
*/
|
|
659
|
+
getMetaIfNotExpired(streamPath) {
|
|
660
|
+
const key = `stream:${streamPath}`;
|
|
661
|
+
const meta = this.db.get(key);
|
|
662
|
+
if (!meta) return void 0;
|
|
663
|
+
if (this.isExpired(meta)) {
|
|
664
|
+
this.delete(streamPath);
|
|
665
|
+
return void 0;
|
|
666
|
+
}
|
|
667
|
+
return meta;
|
|
668
|
+
}
|
|
669
|
+
/**
|
|
608
670
|
* Close the store, closing all file handles and database.
|
|
609
671
|
* All data is already fsynced on each append, so no final flush needed.
|
|
610
672
|
*/
|
|
@@ -613,8 +675,7 @@ var FileBackedStreamStore = class {
|
|
|
613
675
|
await this.db.close();
|
|
614
676
|
}
|
|
615
677
|
async create(streamPath, options = {}) {
|
|
616
|
-
const
|
|
617
|
-
const existing = this.db.get(key);
|
|
678
|
+
const existing = this.getMetaIfNotExpired(streamPath);
|
|
618
679
|
if (existing) {
|
|
619
680
|
const normalizeMimeType = (ct) => (ct ?? `application/octet-stream`).toLowerCase();
|
|
620
681
|
const contentTypeMatches = normalizeMimeType(options.contentType) === normalizeMimeType(existing.contentType);
|
|
@@ -623,6 +684,7 @@ var FileBackedStreamStore = class {
|
|
|
623
684
|
if (contentTypeMatches && ttlMatches && expiresMatches) return this.streamMetaToStream(existing);
|
|
624
685
|
else throw new Error(`Stream already exists with different configuration: ${streamPath}`);
|
|
625
686
|
}
|
|
687
|
+
const key = `stream:${streamPath}`;
|
|
626
688
|
const streamMeta = {
|
|
627
689
|
path: streamPath,
|
|
628
690
|
contentType: options.contentType,
|
|
@@ -656,13 +718,11 @@ var FileBackedStreamStore = class {
|
|
|
656
718
|
return this.streamMetaToStream(streamMeta);
|
|
657
719
|
}
|
|
658
720
|
get(streamPath) {
|
|
659
|
-
const
|
|
660
|
-
const meta = this.db.get(key);
|
|
721
|
+
const meta = this.getMetaIfNotExpired(streamPath);
|
|
661
722
|
return meta ? this.streamMetaToStream(meta) : void 0;
|
|
662
723
|
}
|
|
663
724
|
has(streamPath) {
|
|
664
|
-
|
|
665
|
-
return this.db.get(key) !== void 0;
|
|
725
|
+
return this.getMetaIfNotExpired(streamPath) !== void 0;
|
|
666
726
|
}
|
|
667
727
|
delete(streamPath) {
|
|
668
728
|
const key = `stream:${streamPath}`;
|
|
@@ -680,8 +740,7 @@ var FileBackedStreamStore = class {
|
|
|
680
740
|
return true;
|
|
681
741
|
}
|
|
682
742
|
async append(streamPath, data, options = {}) {
|
|
683
|
-
const
|
|
684
|
-
const streamMeta = this.db.get(key);
|
|
743
|
+
const streamMeta = this.getMetaIfNotExpired(streamPath);
|
|
685
744
|
if (!streamMeta) throw new Error(`Stream not found: ${streamPath}`);
|
|
686
745
|
if (options.contentType && streamMeta.contentType) {
|
|
687
746
|
const providedType = normalizeContentType(options.contentType);
|
|
@@ -729,13 +788,13 @@ var FileBackedStreamStore = class {
|
|
|
729
788
|
lastSeq: options.seq ?? streamMeta.lastSeq,
|
|
730
789
|
totalBytes: streamMeta.totalBytes + processedData.length + 5
|
|
731
790
|
};
|
|
791
|
+
const key = `stream:${streamPath}`;
|
|
732
792
|
this.db.putSync(key, updatedMeta);
|
|
733
793
|
this.notifyLongPolls(streamPath);
|
|
734
794
|
return message;
|
|
735
795
|
}
|
|
736
796
|
read(streamPath, offset) {
|
|
737
|
-
const
|
|
738
|
-
const streamMeta = this.db.get(key);
|
|
797
|
+
const streamMeta = this.getMetaIfNotExpired(streamPath);
|
|
739
798
|
if (!streamMeta) throw new Error(`Stream not found: ${streamPath}`);
|
|
740
799
|
const startOffset = offset ?? `0000000000000000_0000000000000000`;
|
|
741
800
|
const startParts = startOffset.split(`_`).map(Number);
|
|
@@ -787,8 +846,7 @@ var FileBackedStreamStore = class {
|
|
|
787
846
|
};
|
|
788
847
|
}
|
|
789
848
|
async waitForMessages(streamPath, offset, timeoutMs) {
|
|
790
|
-
const
|
|
791
|
-
const streamMeta = this.db.get(key);
|
|
849
|
+
const streamMeta = this.getMetaIfNotExpired(streamPath);
|
|
792
850
|
if (!streamMeta) throw new Error(`Stream not found: ${streamPath}`);
|
|
793
851
|
const { messages } = this.read(streamPath, offset);
|
|
794
852
|
if (messages.length > 0) return {
|
|
@@ -822,10 +880,10 @@ var FileBackedStreamStore = class {
|
|
|
822
880
|
/**
|
|
823
881
|
* Format messages for response.
|
|
824
882
|
* For JSON mode, wraps concatenated data in array brackets.
|
|
883
|
+
* @throws Error if stream doesn't exist or is expired
|
|
825
884
|
*/
|
|
826
885
|
formatResponse(streamPath, messages) {
|
|
827
|
-
const
|
|
828
|
-
const streamMeta = this.db.get(key);
|
|
886
|
+
const streamMeta = this.getMetaIfNotExpired(streamPath);
|
|
829
887
|
if (!streamMeta) throw new Error(`Stream not found: ${streamPath}`);
|
|
830
888
|
const totalSize = messages.reduce((sum, m) => sum + m.data.length, 0);
|
|
831
889
|
const concatenated = new Uint8Array(totalSize);
|
|
@@ -838,8 +896,7 @@ var FileBackedStreamStore = class {
|
|
|
838
896
|
return concatenated;
|
|
839
897
|
}
|
|
840
898
|
getCurrentOffset(streamPath) {
|
|
841
|
-
const
|
|
842
|
-
const streamMeta = this.db.get(key);
|
|
899
|
+
const streamMeta = this.getMetaIfNotExpired(streamPath);
|
|
843
900
|
return streamMeta?.currentOffset;
|
|
844
901
|
}
|
|
845
902
|
clear() {
|
package/dist/index.d.cts
CHANGED
|
@@ -159,6 +159,15 @@ declare class StreamStore {
|
|
|
159
159
|
private streams;
|
|
160
160
|
private pendingLongPolls;
|
|
161
161
|
/**
|
|
162
|
+
* Check if a stream is expired based on TTL or Expires-At.
|
|
163
|
+
*/
|
|
164
|
+
private isExpired;
|
|
165
|
+
/**
|
|
166
|
+
* Get a stream, deleting it if expired.
|
|
167
|
+
* Returns undefined if stream doesn't exist or is expired.
|
|
168
|
+
*/
|
|
169
|
+
private getIfNotExpired;
|
|
170
|
+
/**
|
|
162
171
|
* Create a new stream.
|
|
163
172
|
* @throws Error if stream already exists with different config
|
|
164
173
|
* @returns existing stream if config matches (idempotent)
|
|
@@ -171,10 +180,11 @@ declare class StreamStore {
|
|
|
171
180
|
}): Stream;
|
|
172
181
|
/**
|
|
173
182
|
* Get a stream by path.
|
|
183
|
+
* Returns undefined if stream doesn't exist or is expired.
|
|
174
184
|
*/
|
|
175
185
|
get(path: string): Stream | undefined;
|
|
176
186
|
/**
|
|
177
|
-
* Check if a stream exists.
|
|
187
|
+
* Check if a stream exists (and is not expired).
|
|
178
188
|
*/
|
|
179
189
|
has(path: string): boolean;
|
|
180
190
|
/**
|
|
@@ -183,7 +193,7 @@ declare class StreamStore {
|
|
|
183
193
|
delete(path: string): boolean;
|
|
184
194
|
/**
|
|
185
195
|
* Append data to a stream.
|
|
186
|
-
* @throws Error if stream doesn't exist
|
|
196
|
+
* @throws Error if stream doesn't exist or is expired
|
|
187
197
|
* @throws Error if seq is lower than lastSeq
|
|
188
198
|
* @throws Error if JSON mode and array is empty
|
|
189
199
|
*/
|
|
@@ -193,6 +203,7 @@ declare class StreamStore {
|
|
|
193
203
|
}): StreamMessage;
|
|
194
204
|
/**
|
|
195
205
|
* Read messages from a stream starting at the given offset.
|
|
206
|
+
* @throws Error if stream doesn't exist or is expired
|
|
196
207
|
*/
|
|
197
208
|
read(path: string, offset?: string): {
|
|
198
209
|
messages: Array<StreamMessage>;
|
|
@@ -201,10 +212,12 @@ declare class StreamStore {
|
|
|
201
212
|
/**
|
|
202
213
|
* Format messages for response.
|
|
203
214
|
* For JSON mode, wraps concatenated data in array brackets.
|
|
215
|
+
* @throws Error if stream doesn't exist or is expired
|
|
204
216
|
*/
|
|
205
217
|
formatResponse(path: string, messages: Array<StreamMessage>): Uint8Array;
|
|
206
218
|
/**
|
|
207
219
|
* Wait for new messages (long-poll).
|
|
220
|
+
* @throws Error if stream doesn't exist or is expired
|
|
208
221
|
*/
|
|
209
222
|
waitForMessages(path: string, offset: string, timeoutMs: number): Promise<{
|
|
210
223
|
messages: Array<StreamMessage>;
|
|
@@ -212,6 +225,7 @@ declare class StreamStore {
|
|
|
212
225
|
}>;
|
|
213
226
|
/**
|
|
214
227
|
* Get the current offset for a stream.
|
|
228
|
+
* Returns undefined if stream doesn't exist or is expired.
|
|
215
229
|
*/
|
|
216
230
|
getCurrentOffset(path: string): string | undefined;
|
|
217
231
|
/**
|
|
@@ -263,6 +277,15 @@ declare class FileBackedStreamStore {
|
|
|
263
277
|
*/
|
|
264
278
|
private streamMetaToStream;
|
|
265
279
|
/**
|
|
280
|
+
* Check if a stream is expired based on TTL or Expires-At.
|
|
281
|
+
*/
|
|
282
|
+
private isExpired;
|
|
283
|
+
/**
|
|
284
|
+
* Get stream metadata, deleting it if expired.
|
|
285
|
+
* Returns undefined if stream doesn't exist or is expired.
|
|
286
|
+
*/
|
|
287
|
+
private getMetaIfNotExpired;
|
|
288
|
+
/**
|
|
266
289
|
* Close the store, closing all file handles and database.
|
|
267
290
|
* All data is already fsynced on each append, so no final flush needed.
|
|
268
291
|
*/
|
|
@@ -292,6 +315,7 @@ declare class FileBackedStreamStore {
|
|
|
292
315
|
/**
|
|
293
316
|
* Format messages for response.
|
|
294
317
|
* For JSON mode, wraps concatenated data in array brackets.
|
|
318
|
+
* @throws Error if stream doesn't exist or is expired
|
|
295
319
|
*/
|
|
296
320
|
formatResponse(streamPath: string, messages: Array<StreamMessage>): Uint8Array;
|
|
297
321
|
getCurrentOffset(streamPath: string): string | undefined;
|
package/dist/index.d.ts
CHANGED
|
@@ -159,6 +159,15 @@ declare class StreamStore {
|
|
|
159
159
|
private streams;
|
|
160
160
|
private pendingLongPolls;
|
|
161
161
|
/**
|
|
162
|
+
* Check if a stream is expired based on TTL or Expires-At.
|
|
163
|
+
*/
|
|
164
|
+
private isExpired;
|
|
165
|
+
/**
|
|
166
|
+
* Get a stream, deleting it if expired.
|
|
167
|
+
* Returns undefined if stream doesn't exist or is expired.
|
|
168
|
+
*/
|
|
169
|
+
private getIfNotExpired;
|
|
170
|
+
/**
|
|
162
171
|
* Create a new stream.
|
|
163
172
|
* @throws Error if stream already exists with different config
|
|
164
173
|
* @returns existing stream if config matches (idempotent)
|
|
@@ -171,10 +180,11 @@ declare class StreamStore {
|
|
|
171
180
|
}): Stream;
|
|
172
181
|
/**
|
|
173
182
|
* Get a stream by path.
|
|
183
|
+
* Returns undefined if stream doesn't exist or is expired.
|
|
174
184
|
*/
|
|
175
185
|
get(path: string): Stream | undefined;
|
|
176
186
|
/**
|
|
177
|
-
* Check if a stream exists.
|
|
187
|
+
* Check if a stream exists (and is not expired).
|
|
178
188
|
*/
|
|
179
189
|
has(path: string): boolean;
|
|
180
190
|
/**
|
|
@@ -183,7 +193,7 @@ declare class StreamStore {
|
|
|
183
193
|
delete(path: string): boolean;
|
|
184
194
|
/**
|
|
185
195
|
* Append data to a stream.
|
|
186
|
-
* @throws Error if stream doesn't exist
|
|
196
|
+
* @throws Error if stream doesn't exist or is expired
|
|
187
197
|
* @throws Error if seq is lower than lastSeq
|
|
188
198
|
* @throws Error if JSON mode and array is empty
|
|
189
199
|
*/
|
|
@@ -193,6 +203,7 @@ declare class StreamStore {
|
|
|
193
203
|
}): StreamMessage;
|
|
194
204
|
/**
|
|
195
205
|
* Read messages from a stream starting at the given offset.
|
|
206
|
+
* @throws Error if stream doesn't exist or is expired
|
|
196
207
|
*/
|
|
197
208
|
read(path: string, offset?: string): {
|
|
198
209
|
messages: Array<StreamMessage>;
|
|
@@ -201,10 +212,12 @@ declare class StreamStore {
|
|
|
201
212
|
/**
|
|
202
213
|
* Format messages for response.
|
|
203
214
|
* For JSON mode, wraps concatenated data in array brackets.
|
|
215
|
+
* @throws Error if stream doesn't exist or is expired
|
|
204
216
|
*/
|
|
205
217
|
formatResponse(path: string, messages: Array<StreamMessage>): Uint8Array;
|
|
206
218
|
/**
|
|
207
219
|
* Wait for new messages (long-poll).
|
|
220
|
+
* @throws Error if stream doesn't exist or is expired
|
|
208
221
|
*/
|
|
209
222
|
waitForMessages(path: string, offset: string, timeoutMs: number): Promise<{
|
|
210
223
|
messages: Array<StreamMessage>;
|
|
@@ -212,6 +225,7 @@ declare class StreamStore {
|
|
|
212
225
|
}>;
|
|
213
226
|
/**
|
|
214
227
|
* Get the current offset for a stream.
|
|
228
|
+
* Returns undefined if stream doesn't exist or is expired.
|
|
215
229
|
*/
|
|
216
230
|
getCurrentOffset(path: string): string | undefined;
|
|
217
231
|
/**
|
|
@@ -263,6 +277,15 @@ declare class FileBackedStreamStore {
|
|
|
263
277
|
*/
|
|
264
278
|
private streamMetaToStream;
|
|
265
279
|
/**
|
|
280
|
+
* Check if a stream is expired based on TTL or Expires-At.
|
|
281
|
+
*/
|
|
282
|
+
private isExpired;
|
|
283
|
+
/**
|
|
284
|
+
* Get stream metadata, deleting it if expired.
|
|
285
|
+
* Returns undefined if stream doesn't exist or is expired.
|
|
286
|
+
*/
|
|
287
|
+
private getMetaIfNotExpired;
|
|
288
|
+
/**
|
|
266
289
|
* Close the store, closing all file handles and database.
|
|
267
290
|
* All data is already fsynced on each append, so no final flush needed.
|
|
268
291
|
*/
|
|
@@ -292,6 +315,7 @@ declare class FileBackedStreamStore {
|
|
|
292
315
|
/**
|
|
293
316
|
* Format messages for response.
|
|
294
317
|
* For JSON mode, wraps concatenated data in array brackets.
|
|
318
|
+
* @throws Error if stream doesn't exist or is expired
|
|
295
319
|
*/
|
|
296
320
|
formatResponse(streamPath: string, messages: Array<StreamMessage>): Uint8Array;
|
|
297
321
|
getCurrentOffset(streamPath: string): string | undefined;
|
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",
|
|
@@ -47,7 +47,7 @@
|
|
|
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
|
/**
|