@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.
@@ -0,0 +1,518 @@
1
+ //#region src/types.d.ts
2
+ /**
3
+ * Types for the in-memory durable streams test server.
4
+ */
5
+ /**
6
+ * A single message in a stream.
7
+ */
8
+ interface StreamMessage {
9
+ /**
10
+ * The raw bytes of the message.
11
+ */
12
+ data: Uint8Array;
13
+ /**
14
+ * The offset after this message.
15
+ * Format: "<read-seq>_<byte-offset>"
16
+ */
17
+ offset: string;
18
+ /**
19
+ * Timestamp when the message was appended.
20
+ */
21
+ timestamp: number;
22
+ }
23
+ /**
24
+ * Stream metadata and data.
25
+ */
26
+ interface Stream {
27
+ /**
28
+ * The stream URL path (key).
29
+ */
30
+ path: string;
31
+ /**
32
+ * Content type of the stream.
33
+ */
34
+ contentType?: string;
35
+ /**
36
+ * Messages in the stream.
37
+ */
38
+ messages: Array<StreamMessage>;
39
+ /**
40
+ * Current offset (next offset to write to).
41
+ */
42
+ currentOffset: string;
43
+ /**
44
+ * Last sequence number for writer coordination.
45
+ */
46
+ lastSeq?: string;
47
+ /**
48
+ * TTL in seconds.
49
+ */
50
+ ttlSeconds?: number;
51
+ /**
52
+ * Absolute expiry time (ISO 8601).
53
+ */
54
+ expiresAt?: string;
55
+ /**
56
+ * Timestamp when the stream was created.
57
+ */
58
+ createdAt: number;
59
+ }
60
+ /**
61
+ * Event data for stream lifecycle hooks.
62
+ */
63
+ interface StreamLifecycleEvent {
64
+ /**
65
+ * Type of event.
66
+ */
67
+ type: `created` | `deleted`;
68
+ /**
69
+ * Stream path.
70
+ */
71
+ path: string;
72
+ /**
73
+ * Content type (only for 'created' events).
74
+ */
75
+ contentType?: string;
76
+ /**
77
+ * Timestamp of the event.
78
+ */
79
+ timestamp: number;
80
+ }
81
+ /**
82
+ * Hook function called when a stream is created or deleted.
83
+ */
84
+ type StreamLifecycleHook = (event: StreamLifecycleEvent) => void | Promise<void>;
85
+ /**
86
+ * Options for creating the test server.
87
+ */
88
+ interface TestServerOptions {
89
+ /**
90
+ * Port to listen on. Default: 0 (auto-assign).
91
+ */
92
+ port?: number;
93
+ /**
94
+ * Host to bind to. Default: "127.0.0.1".
95
+ */
96
+ host?: string;
97
+ /**
98
+ * Default long-poll timeout in milliseconds.
99
+ * Default: 30000 (30 seconds).
100
+ */
101
+ longPollTimeout?: number;
102
+ /**
103
+ * Data directory for file-backed storage.
104
+ * If provided, enables file-backed mode using LMDB and append-only logs.
105
+ * If omitted, uses in-memory storage.
106
+ */
107
+ dataDir?: string;
108
+ /**
109
+ * Hook called when a stream is created.
110
+ */
111
+ onStreamCreated?: StreamLifecycleHook;
112
+ /**
113
+ * Hook called when a stream is deleted.
114
+ */
115
+ onStreamDeleted?: StreamLifecycleHook;
116
+ /**
117
+ * Enable gzip/deflate compression for responses.
118
+ * Default: true.
119
+ */
120
+ compression?: boolean;
121
+ /**
122
+ * Interval in seconds for cursor calculation.
123
+ * Used for CDN cache collapsing to prevent infinite cache loops.
124
+ * Default: 20 seconds.
125
+ */
126
+ cursorIntervalSeconds?: number;
127
+ /**
128
+ * Epoch timestamp for cursor interval calculation.
129
+ * Default: October 9, 2024 00:00:00 UTC.
130
+ */
131
+ cursorEpoch?: Date;
132
+ }
133
+ /**
134
+ * Pending long-poll request.
135
+ */
136
+ interface PendingLongPoll {
137
+ /**
138
+ * Stream path.
139
+ */
140
+ path: string;
141
+ /**
142
+ * Offset to wait for.
143
+ */
144
+ offset: string;
145
+ /**
146
+ * Resolve function.
147
+ */
148
+ resolve: (messages: Array<StreamMessage>) => void;
149
+ /**
150
+ * Timeout ID.
151
+ */
152
+ timeoutId: ReturnType<typeof setTimeout>;
153
+ } //#endregion
154
+ //#region src/store.d.ts
155
+ /**
156
+ * In-memory store for durable streams.
157
+ */
158
+ declare class StreamStore {
159
+ private streams;
160
+ private pendingLongPolls;
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
+ /**
171
+ * Create a new stream.
172
+ * @throws Error if stream already exists with different config
173
+ * @returns existing stream if config matches (idempotent)
174
+ */
175
+ create(path: string, options?: {
176
+ contentType?: string;
177
+ ttlSeconds?: number;
178
+ expiresAt?: string;
179
+ initialData?: Uint8Array;
180
+ }): Stream;
181
+ /**
182
+ * Get a stream by path.
183
+ * Returns undefined if stream doesn't exist or is expired.
184
+ */
185
+ get(path: string): Stream | undefined;
186
+ /**
187
+ * Check if a stream exists (and is not expired).
188
+ */
189
+ has(path: string): boolean;
190
+ /**
191
+ * Delete a stream.
192
+ */
193
+ delete(path: string): boolean;
194
+ /**
195
+ * Append data to a stream.
196
+ * @throws Error if stream doesn't exist or is expired
197
+ * @throws Error if seq is lower than lastSeq
198
+ * @throws Error if JSON mode and array is empty
199
+ */
200
+ append(path: string, data: Uint8Array, options?: {
201
+ seq?: string;
202
+ contentType?: string;
203
+ }): StreamMessage;
204
+ /**
205
+ * Read messages from a stream starting at the given offset.
206
+ * @throws Error if stream doesn't exist or is expired
207
+ */
208
+ read(path: string, offset?: string): {
209
+ messages: Array<StreamMessage>;
210
+ upToDate: boolean;
211
+ };
212
+ /**
213
+ * Format messages for response.
214
+ * For JSON mode, wraps concatenated data in array brackets.
215
+ * @throws Error if stream doesn't exist or is expired
216
+ */
217
+ formatResponse(path: string, messages: Array<StreamMessage>): Uint8Array;
218
+ /**
219
+ * Wait for new messages (long-poll).
220
+ * @throws Error if stream doesn't exist or is expired
221
+ */
222
+ waitForMessages(path: string, offset: string, timeoutMs: number): Promise<{
223
+ messages: Array<StreamMessage>;
224
+ timedOut: boolean;
225
+ }>;
226
+ /**
227
+ * Get the current offset for a stream.
228
+ * Returns undefined if stream doesn't exist or is expired.
229
+ */
230
+ getCurrentOffset(path: string): string | undefined;
231
+ /**
232
+ * Clear all streams.
233
+ */
234
+ clear(): void;
235
+ /**
236
+ * Cancel all pending long-polls (used during shutdown).
237
+ */
238
+ cancelAllWaits(): void;
239
+ /**
240
+ * Get all stream paths.
241
+ */
242
+ list(): Array<string>;
243
+ private appendToStream;
244
+ private findOffsetIndex;
245
+ private notifyLongPolls;
246
+ private cancelLongPollsForStream;
247
+ private removePendingLongPoll;
248
+ } //#endregion
249
+ //#region src/file-store.d.ts
250
+ interface FileBackedStreamStoreOptions {
251
+ dataDir: string;
252
+ maxFileHandles?: number;
253
+ }
254
+ /**
255
+ * File-backed implementation of StreamStore.
256
+ * Maintains the same interface as the in-memory StreamStore for drop-in compatibility.
257
+ */
258
+ declare class FileBackedStreamStore {
259
+ private db;
260
+ private fileManager;
261
+ private fileHandlePool;
262
+ private pendingLongPolls;
263
+ private dataDir;
264
+ constructor(options: FileBackedStreamStoreOptions);
265
+ /**
266
+ * Recover streams from disk on startup.
267
+ * Validates that LMDB metadata matches actual file contents and reconciles any mismatches.
268
+ */
269
+ private recover;
270
+ /**
271
+ * Scan a segment file to compute the true last offset.
272
+ * Handles partial/truncated messages at the end.
273
+ */
274
+ private scanFileForTrueOffset;
275
+ /**
276
+ * Convert LMDB metadata to Stream object.
277
+ */
278
+ private streamMetaToStream;
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
+ /**
289
+ * Close the store, closing all file handles and database.
290
+ * All data is already fsynced on each append, so no final flush needed.
291
+ */
292
+ close(): Promise<void>;
293
+ create(streamPath: string, options?: {
294
+ contentType?: string;
295
+ ttlSeconds?: number;
296
+ expiresAt?: string;
297
+ initialData?: Uint8Array;
298
+ }): Promise<Stream>;
299
+ get(streamPath: string): Stream | undefined;
300
+ has(streamPath: string): boolean;
301
+ delete(streamPath: string): boolean;
302
+ append(streamPath: string, data: Uint8Array, options?: {
303
+ seq?: string;
304
+ contentType?: string;
305
+ isInitialCreate?: boolean;
306
+ }): Promise<StreamMessage | null>;
307
+ read(streamPath: string, offset?: string): {
308
+ messages: Array<StreamMessage>;
309
+ upToDate: boolean;
310
+ };
311
+ waitForMessages(streamPath: string, offset: string, timeoutMs: number): Promise<{
312
+ messages: Array<StreamMessage>;
313
+ timedOut: boolean;
314
+ }>;
315
+ /**
316
+ * Format messages for response.
317
+ * For JSON mode, wraps concatenated data in array brackets.
318
+ * @throws Error if stream doesn't exist or is expired
319
+ */
320
+ formatResponse(streamPath: string, messages: Array<StreamMessage>): Uint8Array;
321
+ getCurrentOffset(streamPath: string): string | undefined;
322
+ clear(): void;
323
+ /**
324
+ * Cancel all pending long-polls (used during shutdown).
325
+ */
326
+ cancelAllWaits(): void;
327
+ list(): Array<string>;
328
+ private notifyLongPolls;
329
+ private cancelLongPollsForStream;
330
+ private removePendingLongPoll;
331
+ }
332
+
333
+ //#endregion
334
+ //#region src/server.d.ts
335
+ declare class DurableStreamTestServer {
336
+ readonly store: StreamStore | FileBackedStreamStore;
337
+ private server;
338
+ private options;
339
+ private _url;
340
+ private activeSSEResponses;
341
+ private isShuttingDown;
342
+ /** Injected errors for testing retry/resilience */
343
+ private injectedErrors;
344
+ constructor(options?: TestServerOptions);
345
+ /**
346
+ * Start the server.
347
+ */
348
+ start(): Promise<string>;
349
+ /**
350
+ * Stop the server.
351
+ */
352
+ stop(): Promise<void>;
353
+ /**
354
+ * Get the server URL.
355
+ */
356
+ get url(): string;
357
+ /**
358
+ * Clear all streams.
359
+ */
360
+ clear(): void;
361
+ /**
362
+ * Inject an error to be returned on the next N requests to a path.
363
+ * Used for testing retry/resilience behavior.
364
+ */
365
+ injectError(path: string, status: number, count?: number, retryAfter?: number): void;
366
+ /**
367
+ * Clear all injected errors.
368
+ */
369
+ clearInjectedErrors(): void;
370
+ /**
371
+ * Check if there's an injected error for this path and consume it.
372
+ * Returns the error config if one should be returned, null otherwise.
373
+ */
374
+ private consumeInjectedError;
375
+ private handleRequest;
376
+ /**
377
+ * Handle PUT - create stream
378
+ */
379
+ private handleCreate;
380
+ /**
381
+ * Handle HEAD - get metadata
382
+ */
383
+ private handleHead;
384
+ /**
385
+ * Handle GET - read data
386
+ */
387
+ private handleRead;
388
+ /**
389
+ * Handle SSE (Server-Sent Events) mode
390
+ */
391
+ private handleSSE;
392
+ /**
393
+ * Handle POST - append data
394
+ */
395
+ private handleAppend;
396
+ /**
397
+ * Handle DELETE - delete stream
398
+ */
399
+ private handleDelete;
400
+ /**
401
+ * Handle test control endpoints for error injection.
402
+ * POST /_test/inject-error - inject an error
403
+ * DELETE /_test/inject-error - clear all injected errors
404
+ */
405
+ private handleTestInjectError;
406
+ private readBody;
407
+ }
408
+
409
+ //#endregion
410
+ //#region src/path-encoding.d.ts
411
+ /**
412
+ * Encode a stream path to a filesystem-safe directory name using base64url encoding.
413
+ * Long paths (>200 chars) are hashed to keep directory names manageable.
414
+ *
415
+ * @example
416
+ * encodeStreamPath("/stream/users:created") → "L3N0cmVhbS91c2VyczpjcmVhdGVk"
417
+ */
418
+ declare function encodeStreamPath(path: string): string;
419
+ /**
420
+ * Decode a filesystem-safe directory name back to the original stream path.
421
+ *
422
+ * @example
423
+ * decodeStreamPath("L3N0cmVhbS91c2VyczpjcmVhdGVk") → "/stream/users:created"
424
+ */
425
+ declare function decodeStreamPath(encoded: string): string;
426
+
427
+ //#endregion
428
+ //#region src/registry-hook.d.ts
429
+ /**
430
+ * Creates lifecycle hooks that write to a __registry__ stream.
431
+ * Any client can read this stream to discover all streams and their lifecycle events.
432
+ */
433
+ declare function createRegistryHooks(store: StreamStore | FileBackedStreamStore, serverUrl: string): {
434
+ onStreamCreated: StreamLifecycleHook;
435
+ onStreamDeleted: StreamLifecycleHook;
436
+ };
437
+
438
+ //#endregion
439
+ //#region src/cursor.d.ts
440
+ /**
441
+ * Stream cursor calculation for CDN cache collapsing.
442
+ *
443
+ * This module implements interval-based cursor generation to prevent
444
+ * infinite CDN cache loops while enabling request collapsing.
445
+ *
446
+ * The mechanism works by:
447
+ * 1. Dividing time into fixed intervals (default 20 seconds)
448
+ * 2. Computing interval number from an epoch (October 9, 2024)
449
+ * 3. Returning cursor values that change at interval boundaries
450
+ * 4. Ensuring monotonic cursor progression (never going backwards)
451
+ */
452
+ /**
453
+ * Default epoch for cursor calculation: October 9, 2024 00:00:00 UTC.
454
+ * This is the reference point from which intervals are counted.
455
+ * Using a past date ensures cursors are always positive.
456
+ */
457
+ declare const DEFAULT_CURSOR_EPOCH: Date;
458
+ /**
459
+ * Default interval duration in seconds.
460
+ */
461
+ declare const DEFAULT_CURSOR_INTERVAL_SECONDS = 20;
462
+ /**
463
+ * Configuration options for cursor calculation.
464
+ */
465
+ interface CursorOptions {
466
+ /**
467
+ * Interval duration in seconds.
468
+ * Default: 20 seconds.
469
+ */
470
+ intervalSeconds?: number;
471
+ /**
472
+ * Epoch timestamp for interval calculation.
473
+ * Default: October 9, 2024 00:00:00 UTC.
474
+ */
475
+ epoch?: Date;
476
+ }
477
+ /**
478
+ * Calculate the current cursor value based on time intervals.
479
+ *
480
+ * @param options - Configuration for cursor calculation
481
+ * @returns The current cursor value as a string
482
+ */
483
+ declare function calculateCursor(options?: CursorOptions): string;
484
+ /**
485
+ * Generate a cursor for a response, ensuring monotonic progression.
486
+ *
487
+ * This function ensures the returned cursor is always greater than or equal
488
+ * to the current time interval, and strictly greater than any client-provided
489
+ * cursor. This prevents cache loops where a client could cycle between
490
+ * cursor values.
491
+ *
492
+ * Algorithm:
493
+ * - If no client cursor: return current interval
494
+ * - If client cursor < current interval: return current interval
495
+ * - If client cursor >= current interval: return client cursor + jitter
496
+ *
497
+ * This guarantees monotonic cursor progression and prevents A→B→A cycles.
498
+ *
499
+ * @param clientCursor - The cursor provided by the client (if any)
500
+ * @param options - Configuration for cursor calculation
501
+ * @returns The cursor value to include in the response
502
+ */
503
+ declare function generateResponseCursor(clientCursor: string | undefined, options?: CursorOptions): string;
504
+ /**
505
+ * Handle cursor collision by adding random jitter.
506
+ *
507
+ * @deprecated Use generateResponseCursor instead, which handles all cases
508
+ * including monotonicity guarantees.
509
+ *
510
+ * @param currentCursor - The newly calculated cursor value
511
+ * @param previousCursor - The cursor provided by the client (if any)
512
+ * @param options - Configuration for cursor calculation
513
+ * @returns The cursor value to return, with jitter applied if there's a collision
514
+ */
515
+ declare function handleCursorCollision(currentCursor: string, previousCursor: string | undefined, options?: CursorOptions): string;
516
+
517
+ //#endregion
518
+ export { CursorOptions, DEFAULT_CURSOR_EPOCH, DEFAULT_CURSOR_INTERVAL_SECONDS, DurableStreamTestServer, FileBackedStreamStore, PendingLongPoll, Stream, StreamLifecycleEvent, StreamLifecycleHook, StreamMessage, StreamStore, TestServerOptions, calculateCursor, createRegistryHooks, decodeStreamPath, encodeStreamPath, generateResponseCursor, handleCursorCollision };
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;