@durable-streams/server 0.1.3 → 0.1.5

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.d.cts CHANGED
@@ -56,6 +56,11 @@ interface Stream {
56
56
  * Timestamp when the stream was created.
57
57
  */
58
58
  createdAt: number;
59
+ /**
60
+ * Producer states for idempotent writes.
61
+ * Maps producer ID to their epoch and sequence state.
62
+ */
63
+ producers?: Map<string, ProducerState>;
59
64
  }
60
65
  /**
61
66
  * Event data for stream lifecycle hooks.
@@ -131,6 +136,49 @@ interface TestServerOptions {
131
136
  cursorEpoch?: Date;
132
137
  }
133
138
  /**
139
+ * Producer state for idempotent writes.
140
+ * Tracks epoch and sequence number per producer ID for deduplication.
141
+ */
142
+ interface ProducerState {
143
+ /**
144
+ * Current epoch for this producer.
145
+ * Client-declared, server-validated monotonically increasing.
146
+ */
147
+ epoch: number;
148
+ /**
149
+ * Last sequence number received in this epoch.
150
+ */
151
+ lastSeq: number;
152
+ /**
153
+ * Timestamp when this producer state was last updated.
154
+ * Used for TTL-based cleanup.
155
+ */
156
+ lastUpdated: number;
157
+ }
158
+ /**
159
+ * Result of producer validation for append operations.
160
+ * For 'accepted' status, includes proposedState to commit after successful append.
161
+ */
162
+ type ProducerValidationResult = {
163
+ status: `accepted`;
164
+ isNew: boolean;
165
+ /** State to commit after successful append (deferred mutation) */
166
+ proposedState: ProducerState;
167
+ producerId: string;
168
+ } | {
169
+ status: `duplicate`;
170
+ lastSeq: number;
171
+ } | {
172
+ status: `stale_epoch`;
173
+ currentEpoch: number;
174
+ } | {
175
+ status: `invalid_epoch_seq`;
176
+ } | {
177
+ status: `sequence_gap`;
178
+ expectedSeq: number;
179
+ receivedSeq: number;
180
+ };
181
+ /**
134
182
  * Pending long-poll request.
135
183
  */
136
184
  interface PendingLongPoll {
@@ -155,10 +203,32 @@ interface PendingLongPoll {
155
203
  /**
156
204
  * In-memory store for durable streams.
157
205
  */
206
+ /**
207
+ * Options for append operations.
208
+ */
209
+ interface AppendOptions {
210
+ seq?: string;
211
+ contentType?: string;
212
+ producerId?: string;
213
+ producerEpoch?: number;
214
+ producerSeq?: number;
215
+ }
216
+ /**
217
+ * Result of an append operation.
218
+ */
219
+ interface AppendResult {
220
+ message: StreamMessage | null;
221
+ producerResult?: ProducerValidationResult;
222
+ }
158
223
  declare class StreamStore {
159
224
  private streams;
160
225
  private pendingLongPolls;
161
226
  /**
227
+ * Per-producer locks for serializing validation+append operations.
228
+ * Key: "{streamPath}:{producerId}"
229
+ */
230
+ private producerLocks;
231
+ /**
162
232
  * Check if a stream is expired based on TTL or Expires-At.
163
233
  */
164
234
  private isExpired;
@@ -192,15 +262,47 @@ declare class StreamStore {
192
262
  */
193
263
  delete(path: string): boolean;
194
264
  /**
265
+ * Validate producer state WITHOUT mutating.
266
+ * Returns proposed state to commit after successful append.
267
+ * Implements Kafka-style idempotent producer validation.
268
+ *
269
+ * IMPORTANT: This function does NOT mutate producer state. The caller must
270
+ * call commitProducerState() after successful append to apply the mutation.
271
+ * This ensures atomicity: if append fails (e.g., JSON validation), producer
272
+ * state is not incorrectly advanced.
273
+ */
274
+ private validateProducer;
275
+ /**
276
+ * Commit producer state after successful append.
277
+ * This is the only place where producer state is mutated.
278
+ */
279
+ private commitProducerState;
280
+ /**
281
+ * Clean up expired producer states from a stream.
282
+ */
283
+ private cleanupExpiredProducers;
284
+ /**
285
+ * Acquire a lock for serialized producer operations.
286
+ * Returns a release function.
287
+ */
288
+ private acquireProducerLock;
289
+ /**
195
290
  * Append data to a stream.
196
291
  * @throws Error if stream doesn't exist or is expired
197
292
  * @throws Error if seq is lower than lastSeq
198
293
  * @throws Error if JSON mode and array is empty
199
294
  */
200
- append(path: string, data: Uint8Array, options?: {
201
- seq?: string;
202
- contentType?: string;
203
- }): StreamMessage;
295
+ append(path: string, data: Uint8Array, options?: AppendOptions): StreamMessage | AppendResult;
296
+ /**
297
+ * Append with producer serialization for concurrent request handling.
298
+ * This ensures that validation+append is atomic per producer.
299
+ */
300
+ appendWithProducer(path: string, data: Uint8Array, options: AppendOptions): Promise<AppendResult>;
301
+ /**
302
+ * Get the current epoch for a producer on a stream.
303
+ * Returns undefined if the producer doesn't exist or stream not found.
304
+ */
305
+ getProducerEpoch(path: string, producerId: string): number | undefined;
204
306
  /**
205
307
  * Read messages from a stream starting at the given offset.
206
308
  * @throws Error if stream doesn't exist or is expired
@@ -261,6 +363,11 @@ declare class FileBackedStreamStore {
261
363
  private fileHandlePool;
262
364
  private pendingLongPolls;
263
365
  private dataDir;
366
+ /**
367
+ * Per-producer locks for serializing validation+append operations.
368
+ * Key: "{streamPath}:{producerId}"
369
+ */
370
+ private producerLocks;
264
371
  constructor(options: FileBackedStreamStoreOptions);
265
372
  /**
266
373
  * Recover streams from disk on startup.
@@ -277,6 +384,25 @@ declare class FileBackedStreamStore {
277
384
  */
278
385
  private streamMetaToStream;
279
386
  /**
387
+ * Validate producer state WITHOUT mutating.
388
+ * Returns proposed state to commit after successful append.
389
+ *
390
+ * IMPORTANT: This function does NOT mutate producer state. The caller must
391
+ * commit the proposedState after successful append (file write + fsync + LMDB).
392
+ * This ensures atomicity: if any step fails, producer state is not advanced.
393
+ */
394
+ private validateProducer;
395
+ /**
396
+ * Acquire a lock for serialized producer operations.
397
+ * Returns a release function.
398
+ */
399
+ private acquireProducerLock;
400
+ /**
401
+ * Get the current epoch for a producer on a stream.
402
+ * Returns undefined if the producer doesn't exist or stream not found.
403
+ */
404
+ getProducerEpoch(streamPath: string, producerId: string): number | undefined;
405
+ /**
280
406
  * Check if a stream is expired based on TTL or Expires-At.
281
407
  */
282
408
  private isExpired;
@@ -299,11 +425,14 @@ declare class FileBackedStreamStore {
299
425
  get(streamPath: string): Stream | undefined;
300
426
  has(streamPath: string): boolean;
301
427
  delete(streamPath: string): boolean;
302
- append(streamPath: string, data: Uint8Array, options?: {
303
- seq?: string;
304
- contentType?: string;
428
+ append(streamPath: string, data: Uint8Array, options?: AppendOptions & {
305
429
  isInitialCreate?: boolean;
306
- }): Promise<StreamMessage | null>;
430
+ }): Promise<StreamMessage | AppendResult | null>;
431
+ /**
432
+ * Append with producer serialization for concurrent request handling.
433
+ * This ensures that validation+append is atomic per producer.
434
+ */
435
+ appendWithProducer(streamPath: string, data: Uint8Array, options: AppendOptions): Promise<AppendResult>;
307
436
  read(streamPath: string, offset?: string): {
308
437
  messages: Array<StreamMessage>;
309
438
  upToDate: boolean;
@@ -332,6 +461,36 @@ declare class FileBackedStreamStore {
332
461
 
333
462
  //#endregion
334
463
  //#region src/server.d.ts
464
+ /**
465
+ * HTTP server for testing durable streams.
466
+ * Supports both in-memory and file-backed storage modes.
467
+ */
468
+ /**
469
+ * Configuration for injected faults (for testing retry/resilience).
470
+ * Supports various fault types beyond simple HTTP errors.
471
+ */
472
+ interface InjectedFault {
473
+ /** HTTP status code to return (if set, returns error response) */
474
+ status?: number;
475
+ /** Number of times to trigger this fault (decremented on each use) */
476
+ count: number;
477
+ /** Optional Retry-After header value (seconds) */
478
+ retryAfter?: number;
479
+ /** Delay in milliseconds before responding */
480
+ delayMs?: number;
481
+ /** Drop the connection after sending headers (simulates network failure) */
482
+ dropConnection?: boolean;
483
+ /** Truncate response body to this many bytes */
484
+ truncateBodyBytes?: number;
485
+ /** Probability of triggering fault (0-1, default 1.0 = always) */
486
+ probability?: number;
487
+ /** Only match specific HTTP method (GET, POST, PUT, DELETE) */
488
+ method?: string;
489
+ /** Corrupt the response body by flipping random bits */
490
+ corruptBody?: boolean;
491
+ /** Add jitter to delay (random 0-jitterMs added to delayMs) */
492
+ jitterMs?: number;
493
+ }
335
494
  declare class DurableStreamTestServer {
336
495
  readonly store: StreamStore | FileBackedStreamStore;
337
496
  private server;
@@ -339,8 +498,8 @@ declare class DurableStreamTestServer {
339
498
  private _url;
340
499
  private activeSSEResponses;
341
500
  private isShuttingDown;
342
- /** Injected errors for testing retry/resilience */
343
- private injectedErrors;
501
+ /** Injected faults for testing retry/resilience */
502
+ private injectedFaults;
344
503
  constructor(options?: TestServerOptions);
345
504
  /**
346
505
  * Start the server.
@@ -361,17 +520,34 @@ declare class DurableStreamTestServer {
361
520
  /**
362
521
  * Inject an error to be returned on the next N requests to a path.
363
522
  * Used for testing retry/resilience behavior.
523
+ * @deprecated Use injectFault for full fault injection capabilities
364
524
  */
365
525
  injectError(path: string, status: number, count?: number, retryAfter?: number): void;
366
526
  /**
367
- * Clear all injected errors.
527
+ * Inject a fault to be triggered on the next N requests to a path.
528
+ * Supports various fault types: delays, connection drops, body corruption, etc.
529
+ */
530
+ injectFault(path: string, fault: Omit<InjectedFault, `count`> & {
531
+ count?: number;
532
+ }): void;
533
+ /**
534
+ * Clear all injected faults.
535
+ */
536
+ clearInjectedFaults(): void;
537
+ /**
538
+ * Check if there's an injected fault for this path/method and consume it.
539
+ * Returns the fault config if one should be triggered, null otherwise.
540
+ */
541
+ private consumeInjectedFault;
542
+ /**
543
+ * Apply delay from fault config (including jitter).
368
544
  */
369
- clearInjectedErrors(): void;
545
+ private applyFaultDelay;
370
546
  /**
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.
547
+ * Apply body modifications from stored fault (truncation, corruption).
548
+ * Returns modified body, or original if no modifications needed.
373
549
  */
374
- private consumeInjectedError;
550
+ private applyFaultBodyModification;
375
551
  private handleRequest;
376
552
  /**
377
553
  * Handle PUT - create stream
package/dist/index.d.ts CHANGED
@@ -56,6 +56,11 @@ interface Stream {
56
56
  * Timestamp when the stream was created.
57
57
  */
58
58
  createdAt: number;
59
+ /**
60
+ * Producer states for idempotent writes.
61
+ * Maps producer ID to their epoch and sequence state.
62
+ */
63
+ producers?: Map<string, ProducerState>;
59
64
  }
60
65
  /**
61
66
  * Event data for stream lifecycle hooks.
@@ -131,6 +136,49 @@ interface TestServerOptions {
131
136
  cursorEpoch?: Date;
132
137
  }
133
138
  /**
139
+ * Producer state for idempotent writes.
140
+ * Tracks epoch and sequence number per producer ID for deduplication.
141
+ */
142
+ interface ProducerState {
143
+ /**
144
+ * Current epoch for this producer.
145
+ * Client-declared, server-validated monotonically increasing.
146
+ */
147
+ epoch: number;
148
+ /**
149
+ * Last sequence number received in this epoch.
150
+ */
151
+ lastSeq: number;
152
+ /**
153
+ * Timestamp when this producer state was last updated.
154
+ * Used for TTL-based cleanup.
155
+ */
156
+ lastUpdated: number;
157
+ }
158
+ /**
159
+ * Result of producer validation for append operations.
160
+ * For 'accepted' status, includes proposedState to commit after successful append.
161
+ */
162
+ type ProducerValidationResult = {
163
+ status: `accepted`;
164
+ isNew: boolean;
165
+ /** State to commit after successful append (deferred mutation) */
166
+ proposedState: ProducerState;
167
+ producerId: string;
168
+ } | {
169
+ status: `duplicate`;
170
+ lastSeq: number;
171
+ } | {
172
+ status: `stale_epoch`;
173
+ currentEpoch: number;
174
+ } | {
175
+ status: `invalid_epoch_seq`;
176
+ } | {
177
+ status: `sequence_gap`;
178
+ expectedSeq: number;
179
+ receivedSeq: number;
180
+ };
181
+ /**
134
182
  * Pending long-poll request.
135
183
  */
136
184
  interface PendingLongPoll {
@@ -155,10 +203,32 @@ interface PendingLongPoll {
155
203
  /**
156
204
  * In-memory store for durable streams.
157
205
  */
206
+ /**
207
+ * Options for append operations.
208
+ */
209
+ interface AppendOptions {
210
+ seq?: string;
211
+ contentType?: string;
212
+ producerId?: string;
213
+ producerEpoch?: number;
214
+ producerSeq?: number;
215
+ }
216
+ /**
217
+ * Result of an append operation.
218
+ */
219
+ interface AppendResult {
220
+ message: StreamMessage | null;
221
+ producerResult?: ProducerValidationResult;
222
+ }
158
223
  declare class StreamStore {
159
224
  private streams;
160
225
  private pendingLongPolls;
161
226
  /**
227
+ * Per-producer locks for serializing validation+append operations.
228
+ * Key: "{streamPath}:{producerId}"
229
+ */
230
+ private producerLocks;
231
+ /**
162
232
  * Check if a stream is expired based on TTL or Expires-At.
163
233
  */
164
234
  private isExpired;
@@ -192,15 +262,47 @@ declare class StreamStore {
192
262
  */
193
263
  delete(path: string): boolean;
194
264
  /**
265
+ * Validate producer state WITHOUT mutating.
266
+ * Returns proposed state to commit after successful append.
267
+ * Implements Kafka-style idempotent producer validation.
268
+ *
269
+ * IMPORTANT: This function does NOT mutate producer state. The caller must
270
+ * call commitProducerState() after successful append to apply the mutation.
271
+ * This ensures atomicity: if append fails (e.g., JSON validation), producer
272
+ * state is not incorrectly advanced.
273
+ */
274
+ private validateProducer;
275
+ /**
276
+ * Commit producer state after successful append.
277
+ * This is the only place where producer state is mutated.
278
+ */
279
+ private commitProducerState;
280
+ /**
281
+ * Clean up expired producer states from a stream.
282
+ */
283
+ private cleanupExpiredProducers;
284
+ /**
285
+ * Acquire a lock for serialized producer operations.
286
+ * Returns a release function.
287
+ */
288
+ private acquireProducerLock;
289
+ /**
195
290
  * Append data to a stream.
196
291
  * @throws Error if stream doesn't exist or is expired
197
292
  * @throws Error if seq is lower than lastSeq
198
293
  * @throws Error if JSON mode and array is empty
199
294
  */
200
- append(path: string, data: Uint8Array, options?: {
201
- seq?: string;
202
- contentType?: string;
203
- }): StreamMessage;
295
+ append(path: string, data: Uint8Array, options?: AppendOptions): StreamMessage | AppendResult;
296
+ /**
297
+ * Append with producer serialization for concurrent request handling.
298
+ * This ensures that validation+append is atomic per producer.
299
+ */
300
+ appendWithProducer(path: string, data: Uint8Array, options: AppendOptions): Promise<AppendResult>;
301
+ /**
302
+ * Get the current epoch for a producer on a stream.
303
+ * Returns undefined if the producer doesn't exist or stream not found.
304
+ */
305
+ getProducerEpoch(path: string, producerId: string): number | undefined;
204
306
  /**
205
307
  * Read messages from a stream starting at the given offset.
206
308
  * @throws Error if stream doesn't exist or is expired
@@ -261,6 +363,11 @@ declare class FileBackedStreamStore {
261
363
  private fileHandlePool;
262
364
  private pendingLongPolls;
263
365
  private dataDir;
366
+ /**
367
+ * Per-producer locks for serializing validation+append operations.
368
+ * Key: "{streamPath}:{producerId}"
369
+ */
370
+ private producerLocks;
264
371
  constructor(options: FileBackedStreamStoreOptions);
265
372
  /**
266
373
  * Recover streams from disk on startup.
@@ -277,6 +384,25 @@ declare class FileBackedStreamStore {
277
384
  */
278
385
  private streamMetaToStream;
279
386
  /**
387
+ * Validate producer state WITHOUT mutating.
388
+ * Returns proposed state to commit after successful append.
389
+ *
390
+ * IMPORTANT: This function does NOT mutate producer state. The caller must
391
+ * commit the proposedState after successful append (file write + fsync + LMDB).
392
+ * This ensures atomicity: if any step fails, producer state is not advanced.
393
+ */
394
+ private validateProducer;
395
+ /**
396
+ * Acquire a lock for serialized producer operations.
397
+ * Returns a release function.
398
+ */
399
+ private acquireProducerLock;
400
+ /**
401
+ * Get the current epoch for a producer on a stream.
402
+ * Returns undefined if the producer doesn't exist or stream not found.
403
+ */
404
+ getProducerEpoch(streamPath: string, producerId: string): number | undefined;
405
+ /**
280
406
  * Check if a stream is expired based on TTL or Expires-At.
281
407
  */
282
408
  private isExpired;
@@ -299,11 +425,14 @@ declare class FileBackedStreamStore {
299
425
  get(streamPath: string): Stream | undefined;
300
426
  has(streamPath: string): boolean;
301
427
  delete(streamPath: string): boolean;
302
- append(streamPath: string, data: Uint8Array, options?: {
303
- seq?: string;
304
- contentType?: string;
428
+ append(streamPath: string, data: Uint8Array, options?: AppendOptions & {
305
429
  isInitialCreate?: boolean;
306
- }): Promise<StreamMessage | null>;
430
+ }): Promise<StreamMessage | AppendResult | null>;
431
+ /**
432
+ * Append with producer serialization for concurrent request handling.
433
+ * This ensures that validation+append is atomic per producer.
434
+ */
435
+ appendWithProducer(streamPath: string, data: Uint8Array, options: AppendOptions): Promise<AppendResult>;
307
436
  read(streamPath: string, offset?: string): {
308
437
  messages: Array<StreamMessage>;
309
438
  upToDate: boolean;
@@ -332,6 +461,36 @@ declare class FileBackedStreamStore {
332
461
 
333
462
  //#endregion
334
463
  //#region src/server.d.ts
464
+ /**
465
+ * HTTP server for testing durable streams.
466
+ * Supports both in-memory and file-backed storage modes.
467
+ */
468
+ /**
469
+ * Configuration for injected faults (for testing retry/resilience).
470
+ * Supports various fault types beyond simple HTTP errors.
471
+ */
472
+ interface InjectedFault {
473
+ /** HTTP status code to return (if set, returns error response) */
474
+ status?: number;
475
+ /** Number of times to trigger this fault (decremented on each use) */
476
+ count: number;
477
+ /** Optional Retry-After header value (seconds) */
478
+ retryAfter?: number;
479
+ /** Delay in milliseconds before responding */
480
+ delayMs?: number;
481
+ /** Drop the connection after sending headers (simulates network failure) */
482
+ dropConnection?: boolean;
483
+ /** Truncate response body to this many bytes */
484
+ truncateBodyBytes?: number;
485
+ /** Probability of triggering fault (0-1, default 1.0 = always) */
486
+ probability?: number;
487
+ /** Only match specific HTTP method (GET, POST, PUT, DELETE) */
488
+ method?: string;
489
+ /** Corrupt the response body by flipping random bits */
490
+ corruptBody?: boolean;
491
+ /** Add jitter to delay (random 0-jitterMs added to delayMs) */
492
+ jitterMs?: number;
493
+ }
335
494
  declare class DurableStreamTestServer {
336
495
  readonly store: StreamStore | FileBackedStreamStore;
337
496
  private server;
@@ -339,8 +498,8 @@ declare class DurableStreamTestServer {
339
498
  private _url;
340
499
  private activeSSEResponses;
341
500
  private isShuttingDown;
342
- /** Injected errors for testing retry/resilience */
343
- private injectedErrors;
501
+ /** Injected faults for testing retry/resilience */
502
+ private injectedFaults;
344
503
  constructor(options?: TestServerOptions);
345
504
  /**
346
505
  * Start the server.
@@ -361,17 +520,34 @@ declare class DurableStreamTestServer {
361
520
  /**
362
521
  * Inject an error to be returned on the next N requests to a path.
363
522
  * Used for testing retry/resilience behavior.
523
+ * @deprecated Use injectFault for full fault injection capabilities
364
524
  */
365
525
  injectError(path: string, status: number, count?: number, retryAfter?: number): void;
366
526
  /**
367
- * Clear all injected errors.
527
+ * Inject a fault to be triggered on the next N requests to a path.
528
+ * Supports various fault types: delays, connection drops, body corruption, etc.
529
+ */
530
+ injectFault(path: string, fault: Omit<InjectedFault, `count`> & {
531
+ count?: number;
532
+ }): void;
533
+ /**
534
+ * Clear all injected faults.
535
+ */
536
+ clearInjectedFaults(): void;
537
+ /**
538
+ * Check if there's an injected fault for this path/method and consume it.
539
+ * Returns the fault config if one should be triggered, null otherwise.
540
+ */
541
+ private consumeInjectedFault;
542
+ /**
543
+ * Apply delay from fault config (including jitter).
368
544
  */
369
- clearInjectedErrors(): void;
545
+ private applyFaultDelay;
370
546
  /**
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.
547
+ * Apply body modifications from stored fault (truncation, corruption).
548
+ * Returns modified body, or original if no modifications needed.
373
549
  */
374
- private consumeInjectedError;
550
+ private applyFaultBodyModification;
375
551
  private handleRequest;
376
552
  /**
377
553
  * Handle PUT - create stream