@durable-streams/server 0.1.2 → 0.1.4

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.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.streams.get(path$2);
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.streams.get(path$2);
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.streams.has(path$2);
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.streams.get(path$2);
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.streams.get(path$2);
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.streams.get(path$2);
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.streams.get(path$2);
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.streams.get(path$2)?.currentOffset;
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 key = `stream:${streamPath}`;
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 key = `stream:${streamPath}`;
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
- const key = `stream:${streamPath}`;
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 key = `stream:${streamPath}`;
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 key = `stream:${streamPath}`;
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 key = `stream:${streamPath}`;
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 key = `stream:${streamPath}`;
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 key = `stream:${streamPath}`;
819
- const streamMeta = this.db.get(key);
876
+ const streamMeta = this.getMetaIfNotExpired(streamPath);
820
877
  return streamMeta?.currentOffset;
821
878
  }
822
879
  clear() {
@@ -995,10 +1052,12 @@ const CURSOR_QUERY_PARAM = `cursor`;
995
1052
  /**
996
1053
  * Encode data for SSE format.
997
1054
  * Per SSE spec, each line in the payload needs its own "data:" prefix.
998
- * Newlines in the payload become separate data: lines.
1055
+ * Line terminators in the payload (CR, LF, or CRLF) become separate data: lines.
1056
+ * This prevents CRLF injection attacks where malicious payloads could inject
1057
+ * fake SSE events using CR-only line terminators.
999
1058
  */
1000
1059
  function encodeSSEData(payload) {
1001
- const lines = payload.split(`\n`);
1060
+ const lines = payload.split(/\r\n|\r|\n/);
1002
1061
  return lines.map((line) => `data: ${line}`).join(`\n`) + `\n\n`;
1003
1062
  }
1004
1063
  /**
@@ -1037,8 +1096,8 @@ var DurableStreamTestServer = class {
1037
1096
  _url = null;
1038
1097
  activeSSEResponses = new Set();
1039
1098
  isShuttingDown = false;
1040
- /** Injected errors for testing retry/resilience */
1041
- injectedErrors = new Map();
1099
+ /** Injected faults for testing retry/resilience */
1100
+ injectedFaults = new Map();
1042
1101
  constructor(options = {}) {
1043
1102
  if (options.dataDir) this.store = new FileBackedStreamStore({ dataDir: options.dataDir });
1044
1103
  else this.store = new StreamStore();
@@ -1123,30 +1182,71 @@ var DurableStreamTestServer = class {
1123
1182
  /**
1124
1183
  * Inject an error to be returned on the next N requests to a path.
1125
1184
  * Used for testing retry/resilience behavior.
1185
+ * @deprecated Use injectFault for full fault injection capabilities
1126
1186
  */
1127
1187
  injectError(path$2, status, count = 1, retryAfter) {
1128
- this.injectedErrors.set(path$2, {
1188
+ this.injectedFaults.set(path$2, {
1129
1189
  status,
1130
1190
  count,
1131
1191
  retryAfter
1132
1192
  });
1133
1193
  }
1134
1194
  /**
1135
- * Clear all injected errors.
1195
+ * Inject a fault to be triggered on the next N requests to a path.
1196
+ * Supports various fault types: delays, connection drops, body corruption, etc.
1136
1197
  */
1137
- clearInjectedErrors() {
1138
- this.injectedErrors.clear();
1198
+ injectFault(path$2, fault) {
1199
+ this.injectedFaults.set(path$2, {
1200
+ count: 1,
1201
+ ...fault
1202
+ });
1203
+ }
1204
+ /**
1205
+ * Clear all injected faults.
1206
+ */
1207
+ clearInjectedFaults() {
1208
+ this.injectedFaults.clear();
1139
1209
  }
1140
1210
  /**
1141
- * Check if there's an injected error for this path and consume it.
1142
- * Returns the error config if one should be returned, null otherwise.
1211
+ * Check if there's an injected fault for this path/method and consume it.
1212
+ * Returns the fault config if one should be triggered, null otherwise.
1143
1213
  */
1144
- consumeInjectedError(path$2) {
1145
- const error = this.injectedErrors.get(path$2);
1146
- if (!error) return null;
1147
- error.count--;
1148
- if (error.count <= 0) this.injectedErrors.delete(path$2);
1149
- return error;
1214
+ consumeInjectedFault(path$2, method) {
1215
+ const fault = this.injectedFaults.get(path$2);
1216
+ if (!fault) return null;
1217
+ if (fault.method && fault.method.toUpperCase() !== method.toUpperCase()) return null;
1218
+ if (fault.probability !== void 0 && Math.random() > fault.probability) return null;
1219
+ fault.count--;
1220
+ if (fault.count <= 0) this.injectedFaults.delete(path$2);
1221
+ return fault;
1222
+ }
1223
+ /**
1224
+ * Apply delay from fault config (including jitter).
1225
+ */
1226
+ async applyFaultDelay(fault) {
1227
+ if (fault.delayMs !== void 0 && fault.delayMs > 0) {
1228
+ const jitter = fault.jitterMs ? Math.random() * fault.jitterMs : 0;
1229
+ await new Promise((resolve) => setTimeout(resolve, fault.delayMs + jitter));
1230
+ }
1231
+ }
1232
+ /**
1233
+ * Apply body modifications from stored fault (truncation, corruption).
1234
+ * Returns modified body, or original if no modifications needed.
1235
+ */
1236
+ applyFaultBodyModification(res, body) {
1237
+ const fault = res._injectedFault;
1238
+ if (!fault) return body;
1239
+ let modified = body;
1240
+ if (fault.truncateBodyBytes !== void 0 && modified.length > fault.truncateBodyBytes) modified = modified.slice(0, fault.truncateBodyBytes);
1241
+ if (fault.corruptBody && modified.length > 0) {
1242
+ modified = new Uint8Array(modified);
1243
+ const numCorrupt = Math.max(1, Math.floor(modified.length * .03));
1244
+ for (let i = 0; i < numCorrupt; i++) {
1245
+ const pos = Math.floor(Math.random() * modified.length);
1246
+ modified[pos] = modified[pos] ^ 1 << Math.floor(Math.random() * 8);
1247
+ }
1248
+ }
1249
+ return modified;
1150
1250
  }
1151
1251
  async handleRequest(req, res) {
1152
1252
  const url = new URL(req.url ?? `/`, `http://${req.headers.host}`);
@@ -1156,6 +1256,8 @@ var DurableStreamTestServer = class {
1156
1256
  res.setHeader(`access-control-allow-methods`, `GET, POST, PUT, DELETE, HEAD, OPTIONS`);
1157
1257
  res.setHeader(`access-control-allow-headers`, `content-type, authorization, Stream-Seq, Stream-TTL, Stream-Expires-At`);
1158
1258
  res.setHeader(`access-control-expose-headers`, `Stream-Next-Offset, Stream-Cursor, Stream-Up-To-Date, etag, content-type, content-encoding, vary`);
1259
+ res.setHeader(`x-content-type-options`, `nosniff`);
1260
+ res.setHeader(`cross-origin-resource-policy`, `cross-origin`);
1159
1261
  if (method === `OPTIONS`) {
1160
1262
  res.writeHead(204);
1161
1263
  res.end();
@@ -1165,13 +1267,21 @@ var DurableStreamTestServer = class {
1165
1267
  await this.handleTestInjectError(method, req, res);
1166
1268
  return;
1167
1269
  }
1168
- const injectedError = this.consumeInjectedError(path$2);
1169
- if (injectedError) {
1170
- const headers = { "content-type": `text/plain` };
1171
- if (injectedError.retryAfter !== void 0) headers[`retry-after`] = injectedError.retryAfter.toString();
1172
- res.writeHead(injectedError.status, headers);
1173
- res.end(`Injected error for testing`);
1174
- return;
1270
+ const fault = this.consumeInjectedFault(path$2, method ?? `GET`);
1271
+ if (fault) {
1272
+ await this.applyFaultDelay(fault);
1273
+ if (fault.dropConnection) {
1274
+ res.socket?.destroy();
1275
+ return;
1276
+ }
1277
+ if (fault.status !== void 0) {
1278
+ const headers = { "content-type": `text/plain` };
1279
+ if (fault.retryAfter !== void 0) headers[`retry-after`] = fault.retryAfter.toString();
1280
+ res.writeHead(fault.status, headers);
1281
+ res.end(`Injected error for testing`);
1282
+ return;
1283
+ }
1284
+ if (fault.truncateBodyBytes !== void 0 || fault.corruptBody) res._injectedFault = fault;
1175
1285
  }
1176
1286
  try {
1177
1287
  switch (method) {
@@ -1286,7 +1396,10 @@ var DurableStreamTestServer = class {
1286
1396
  res.end();
1287
1397
  return;
1288
1398
  }
1289
- const headers = { [STREAM_OFFSET_HEADER]: stream.currentOffset };
1399
+ const headers = {
1400
+ [STREAM_OFFSET_HEADER]: stream.currentOffset,
1401
+ "cache-control": `no-store`
1402
+ };
1290
1403
  if (stream.contentType) headers[`content-type`] = stream.contentType;
1291
1404
  headers[`etag`] = `"${Buffer.from(path$2).toString(`base64`)}:-1:${stream.currentOffset}"`;
1292
1405
  res.writeHead(200, headers);
@@ -1377,6 +1490,7 @@ var DurableStreamTestServer = class {
1377
1490
  headers[`vary`] = `accept-encoding`;
1378
1491
  }
1379
1492
  }
1493
+ finalData = this.applyFaultBodyModification(res, finalData);
1380
1494
  res.writeHead(200, headers);
1381
1495
  res.end(Buffer.from(finalData));
1382
1496
  }
@@ -1389,7 +1503,9 @@ var DurableStreamTestServer = class {
1389
1503
  "content-type": `text/event-stream`,
1390
1504
  "cache-control": `no-cache`,
1391
1505
  connection: `keep-alive`,
1392
- "access-control-allow-origin": `*`
1506
+ "access-control-allow-origin": `*`,
1507
+ "x-content-type-options": `nosniff`,
1508
+ "cross-origin-resource-policy": `cross-origin`
1393
1509
  });
1394
1510
  let currentOffset = initialOffset;
1395
1511
  let isConnected = true;
@@ -1460,7 +1576,7 @@ var DurableStreamTestServer = class {
1460
1576
  seq,
1461
1577
  contentType
1462
1578
  }));
1463
- res.writeHead(200, { [STREAM_OFFSET_HEADER]: message.offset });
1579
+ res.writeHead(204, { [STREAM_OFFSET_HEADER]: message.offset });
1464
1580
  res.end();
1465
1581
  }
1466
1582
  /**
@@ -1491,12 +1607,29 @@ var DurableStreamTestServer = class {
1491
1607
  const body = await this.readBody(req);
1492
1608
  try {
1493
1609
  const config = JSON.parse(new TextDecoder().decode(body));
1494
- if (!config.path || !config.status) {
1610
+ if (!config.path) {
1495
1611
  res.writeHead(400, { "content-type": `text/plain` });
1496
- res.end(`Missing required fields: path, status`);
1612
+ res.end(`Missing required field: path`);
1497
1613
  return;
1498
1614
  }
1499
- this.injectError(config.path, config.status, config.count ?? 1, config.retryAfter);
1615
+ const hasFaultType = config.status !== void 0 || config.delayMs !== void 0 || config.dropConnection || config.truncateBodyBytes !== void 0 || config.corruptBody;
1616
+ if (!hasFaultType) {
1617
+ res.writeHead(400, { "content-type": `text/plain` });
1618
+ res.end(`Must specify at least one fault type: status, delayMs, dropConnection, truncateBodyBytes, or corruptBody`);
1619
+ return;
1620
+ }
1621
+ this.injectFault(config.path, {
1622
+ status: config.status,
1623
+ count: config.count ?? 1,
1624
+ retryAfter: config.retryAfter,
1625
+ delayMs: config.delayMs,
1626
+ dropConnection: config.dropConnection,
1627
+ truncateBodyBytes: config.truncateBodyBytes,
1628
+ probability: config.probability,
1629
+ method: config.method,
1630
+ corruptBody: config.corruptBody,
1631
+ jitterMs: config.jitterMs
1632
+ });
1500
1633
  res.writeHead(200, { "content-type": `application/json` });
1501
1634
  res.end(JSON.stringify({ ok: true }));
1502
1635
  } catch {
@@ -1504,7 +1637,7 @@ var DurableStreamTestServer = class {
1504
1637
  res.end(`Invalid JSON body`);
1505
1638
  }
1506
1639
  } else if (method === `DELETE`) {
1507
- this.clearInjectedErrors();
1640
+ this.clearInjectedFaults();
1508
1641
  res.writeHead(200, { "content-type": `application/json` });
1509
1642
  res.end(JSON.stringify({ ok: true }));
1510
1643
  } else {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@durable-streams/server",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
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.2",
43
- "@durable-streams/state": "0.1.2"
42
+ "@durable-streams/client": "0.1.3",
43
+ "@durable-streams/state": "0.1.3"
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
- "vitest": "^3.2.4",
50
- "@durable-streams/server-conformance-tests": "0.1.2"
49
+ "vitest": "^4.0.0",
50
+ "@durable-streams/server-conformance-tests": "0.1.6"
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
- const key = `stream:${streamPath}`
358
- const existing = this.db.get(key) as StreamMetadata | undefined
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 key = `stream:${streamPath}`
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
- const key = `stream:${streamPath}`
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 key = `stream:${streamPath}`
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 key = `stream:${streamPath}`
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 key = `stream:${streamPath}`
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 key = `stream:${streamPath}`
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 key = `stream:${streamPath}`
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