@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/src/store.ts CHANGED
@@ -2,7 +2,17 @@
2
2
  * In-memory stream storage.
3
3
  */
4
4
 
5
- import type { PendingLongPoll, Stream, StreamMessage } from "./types"
5
+ import type {
6
+ PendingLongPoll,
7
+ ProducerValidationResult,
8
+ Stream,
9
+ StreamMessage,
10
+ } from "./types"
11
+
12
+ /**
13
+ * TTL for in-memory producer state cleanup (7 days).
14
+ */
15
+ const PRODUCER_STATE_TTL_MS = 7 * 24 * 60 * 60 * 1000
6
16
 
7
17
  /**
8
18
  * Normalize content-type by extracting the media type (before any semicolon).
@@ -79,9 +89,33 @@ export function formatJsonResponse(data: Uint8Array): Uint8Array {
79
89
  /**
80
90
  * In-memory store for durable streams.
81
91
  */
92
+ /**
93
+ * Options for append operations.
94
+ */
95
+ export interface AppendOptions {
96
+ seq?: string
97
+ contentType?: string
98
+ producerId?: string
99
+ producerEpoch?: number
100
+ producerSeq?: number
101
+ }
102
+
103
+ /**
104
+ * Result of an append operation.
105
+ */
106
+ export interface AppendResult {
107
+ message: StreamMessage | null
108
+ producerResult?: ProducerValidationResult
109
+ }
110
+
82
111
  export class StreamStore {
83
112
  private streams = new Map<string, Stream>()
84
113
  private pendingLongPolls: Array<PendingLongPoll> = []
114
+ /**
115
+ * Per-producer locks for serializing validation+append operations.
116
+ * Key: "{streamPath}:{producerId}"
117
+ */
118
+ private producerLocks = new Map<string, Promise<unknown>>()
85
119
 
86
120
  /**
87
121
  * Check if a stream is expired based on TTL or Expires-At.
@@ -206,6 +240,147 @@ export class StreamStore {
206
240
  return this.streams.delete(path)
207
241
  }
208
242
 
243
+ /**
244
+ * Validate producer state WITHOUT mutating.
245
+ * Returns proposed state to commit after successful append.
246
+ * Implements Kafka-style idempotent producer validation.
247
+ *
248
+ * IMPORTANT: This function does NOT mutate producer state. The caller must
249
+ * call commitProducerState() after successful append to apply the mutation.
250
+ * This ensures atomicity: if append fails (e.g., JSON validation), producer
251
+ * state is not incorrectly advanced.
252
+ */
253
+ private validateProducer(
254
+ stream: Stream,
255
+ producerId: string,
256
+ epoch: number,
257
+ seq: number
258
+ ): ProducerValidationResult {
259
+ // Initialize producers map if needed (safe - just ensures map exists)
260
+ if (!stream.producers) {
261
+ stream.producers = new Map()
262
+ }
263
+
264
+ // Clean up expired producer states on access
265
+ this.cleanupExpiredProducers(stream)
266
+
267
+ const state = stream.producers.get(producerId)
268
+ const now = Date.now()
269
+
270
+ // New producer - accept if seq is 0
271
+ if (!state) {
272
+ if (seq !== 0) {
273
+ return {
274
+ status: `sequence_gap`,
275
+ expectedSeq: 0,
276
+ receivedSeq: seq,
277
+ }
278
+ }
279
+ // Return proposed state, don't mutate yet
280
+ return {
281
+ status: `accepted`,
282
+ isNew: true,
283
+ producerId,
284
+ proposedState: { epoch, lastSeq: 0, lastUpdated: now },
285
+ }
286
+ }
287
+
288
+ // Epoch validation (client-declared, server-validated)
289
+ if (epoch < state.epoch) {
290
+ return { status: `stale_epoch`, currentEpoch: state.epoch }
291
+ }
292
+
293
+ if (epoch > state.epoch) {
294
+ // New epoch must start at seq=0
295
+ if (seq !== 0) {
296
+ return { status: `invalid_epoch_seq` }
297
+ }
298
+ // Return proposed state for new epoch, don't mutate yet
299
+ return {
300
+ status: `accepted`,
301
+ isNew: true,
302
+ producerId,
303
+ proposedState: { epoch, lastSeq: 0, lastUpdated: now },
304
+ }
305
+ }
306
+
307
+ // Same epoch: sequence validation
308
+ if (seq <= state.lastSeq) {
309
+ return { status: `duplicate`, lastSeq: state.lastSeq }
310
+ }
311
+
312
+ if (seq === state.lastSeq + 1) {
313
+ // Return proposed state, don't mutate yet
314
+ return {
315
+ status: `accepted`,
316
+ isNew: false,
317
+ producerId,
318
+ proposedState: { epoch, lastSeq: seq, lastUpdated: now },
319
+ }
320
+ }
321
+
322
+ // Sequence gap
323
+ return {
324
+ status: `sequence_gap`,
325
+ expectedSeq: state.lastSeq + 1,
326
+ receivedSeq: seq,
327
+ }
328
+ }
329
+
330
+ /**
331
+ * Commit producer state after successful append.
332
+ * This is the only place where producer state is mutated.
333
+ */
334
+ private commitProducerState(
335
+ stream: Stream,
336
+ result: ProducerValidationResult
337
+ ): void {
338
+ if (result.status !== `accepted`) return
339
+ stream.producers!.set(result.producerId, result.proposedState)
340
+ }
341
+
342
+ /**
343
+ * Clean up expired producer states from a stream.
344
+ */
345
+ private cleanupExpiredProducers(stream: Stream): void {
346
+ if (!stream.producers) return
347
+
348
+ const now = Date.now()
349
+ for (const [id, state] of stream.producers) {
350
+ if (now - state.lastUpdated > PRODUCER_STATE_TTL_MS) {
351
+ stream.producers.delete(id)
352
+ }
353
+ }
354
+ }
355
+
356
+ /**
357
+ * Acquire a lock for serialized producer operations.
358
+ * Returns a release function.
359
+ */
360
+ private async acquireProducerLock(
361
+ path: string,
362
+ producerId: string
363
+ ): Promise<() => void> {
364
+ const lockKey = `${path}:${producerId}`
365
+
366
+ // Wait for any existing lock
367
+ while (this.producerLocks.has(lockKey)) {
368
+ await this.producerLocks.get(lockKey)
369
+ }
370
+
371
+ // Create our lock
372
+ let releaseLock: () => void
373
+ const lockPromise = new Promise<void>((resolve) => {
374
+ releaseLock = resolve
375
+ })
376
+ this.producerLocks.set(lockKey, lockPromise)
377
+
378
+ return () => {
379
+ this.producerLocks.delete(lockKey)
380
+ releaseLock!()
381
+ }
382
+ }
383
+
209
384
  /**
210
385
  * Append data to a stream.
211
386
  * @throws Error if stream doesn't exist or is expired
@@ -215,8 +390,8 @@ export class StreamStore {
215
390
  append(
216
391
  path: string,
217
392
  data: Uint8Array,
218
- options: { seq?: string; contentType?: string } = {}
219
- ): StreamMessage {
393
+ options: AppendOptions = {}
394
+ ): StreamMessage | AppendResult {
220
395
  const stream = this.getIfNotExpired(path)
221
396
  if (!stream) {
222
397
  throw new Error(`Stream not found: ${path}`)
@@ -233,26 +408,116 @@ export class StreamStore {
233
408
  }
234
409
  }
235
410
 
236
- // Check sequence for writer coordination
411
+ // Handle producer validation FIRST if producer headers are present
412
+ // This must happen before Stream-Seq check so that retries with both
413
+ // producer headers AND Stream-Seq can return 204 (duplicate) instead of
414
+ // failing the Stream-Seq conflict check.
415
+ // NOTE: validateProducer does NOT mutate state - it returns proposed state
416
+ // that we commit AFTER successful append (for atomicity)
417
+ let producerResult: ProducerValidationResult | undefined
418
+ if (
419
+ options.producerId !== undefined &&
420
+ options.producerEpoch !== undefined &&
421
+ options.producerSeq !== undefined
422
+ ) {
423
+ producerResult = this.validateProducer(
424
+ stream,
425
+ options.producerId,
426
+ options.producerEpoch,
427
+ options.producerSeq
428
+ )
429
+
430
+ // Return early for non-accepted results (duplicate, stale epoch, gap)
431
+ // IMPORTANT: Return 204 for duplicate BEFORE Stream-Seq check
432
+ if (producerResult.status !== `accepted`) {
433
+ return { message: null, producerResult }
434
+ }
435
+ }
436
+
437
+ // Check sequence for writer coordination (Stream-Seq, separate from Producer-Seq)
438
+ // This happens AFTER producer validation so retries can be deduplicated
237
439
  if (options.seq !== undefined) {
238
440
  if (stream.lastSeq !== undefined && options.seq <= stream.lastSeq) {
239
441
  throw new Error(
240
442
  `Sequence conflict: ${options.seq} <= ${stream.lastSeq}`
241
443
  )
242
444
  }
243
- stream.lastSeq = options.seq
244
445
  }
245
446
 
246
- // appendToStream returns null only for empty arrays in create mode,
247
- // but public append() never sets isInitialCreate, so empty arrays throw before this
447
+ // appendToStream can throw (e.g., for JSON validation errors)
448
+ // This is done BEFORE committing any state changes for atomicity
248
449
  const message = this.appendToStream(stream, data)!
249
450
 
451
+ // === STATE MUTATION HAPPENS HERE (only after successful append) ===
452
+
453
+ // Commit producer state after successful append
454
+ if (producerResult) {
455
+ this.commitProducerState(stream, producerResult)
456
+ }
457
+
458
+ // Update Stream-Seq after append succeeds
459
+ if (options.seq !== undefined) {
460
+ stream.lastSeq = options.seq
461
+ }
462
+
250
463
  // Notify any pending long-polls
251
464
  this.notifyLongPolls(path)
252
465
 
466
+ // Return AppendResult if producer headers were used
467
+ if (producerResult) {
468
+ return {
469
+ message,
470
+ producerResult,
471
+ }
472
+ }
473
+
253
474
  return message
254
475
  }
255
476
 
477
+ /**
478
+ * Append with producer serialization for concurrent request handling.
479
+ * This ensures that validation+append is atomic per producer.
480
+ */
481
+ async appendWithProducer(
482
+ path: string,
483
+ data: Uint8Array,
484
+ options: AppendOptions
485
+ ): Promise<AppendResult> {
486
+ if (!options.producerId) {
487
+ // No producer - just do a normal append
488
+ const result = this.append(path, data, options)
489
+ if (`message` in result) {
490
+ return result
491
+ }
492
+ return { message: result }
493
+ }
494
+
495
+ // Acquire lock for this producer
496
+ const releaseLock = await this.acquireProducerLock(path, options.producerId)
497
+
498
+ try {
499
+ const result = this.append(path, data, options)
500
+ if (`message` in result) {
501
+ return result
502
+ }
503
+ return { message: result }
504
+ } finally {
505
+ releaseLock()
506
+ }
507
+ }
508
+
509
+ /**
510
+ * Get the current epoch for a producer on a stream.
511
+ * Returns undefined if the producer doesn't exist or stream not found.
512
+ */
513
+ getProducerEpoch(path: string, producerId: string): number | undefined {
514
+ const stream = this.getIfNotExpired(path)
515
+ if (!stream?.producers) {
516
+ return undefined
517
+ }
518
+ return stream.producers.get(producerId)?.epoch
519
+ }
520
+
256
521
  /**
257
522
  * Read messages from a stream starting at the given offset.
258
523
  * @throws Error if stream doesn't exist or is expired
package/src/types.ts CHANGED
@@ -66,6 +66,12 @@ export interface Stream {
66
66
  * Timestamp when the stream was created.
67
67
  */
68
68
  createdAt: number
69
+
70
+ /**
71
+ * Producer states for idempotent writes.
72
+ * Maps producer ID to their epoch and sequence state.
73
+ */
74
+ producers?: Map<string, ProducerState>
69
75
  }
70
76
 
71
77
  /**
@@ -157,6 +163,46 @@ export interface TestServerOptions {
157
163
  cursorEpoch?: Date
158
164
  }
159
165
 
166
+ /**
167
+ * Producer state for idempotent writes.
168
+ * Tracks epoch and sequence number per producer ID for deduplication.
169
+ */
170
+ export interface ProducerState {
171
+ /**
172
+ * Current epoch for this producer.
173
+ * Client-declared, server-validated monotonically increasing.
174
+ */
175
+ epoch: number
176
+
177
+ /**
178
+ * Last sequence number received in this epoch.
179
+ */
180
+ lastSeq: number
181
+
182
+ /**
183
+ * Timestamp when this producer state was last updated.
184
+ * Used for TTL-based cleanup.
185
+ */
186
+ lastUpdated: number
187
+ }
188
+
189
+ /**
190
+ * Result of producer validation for append operations.
191
+ * For 'accepted' status, includes proposedState to commit after successful append.
192
+ */
193
+ export type ProducerValidationResult =
194
+ | {
195
+ status: `accepted`
196
+ isNew: boolean
197
+ /** State to commit after successful append (deferred mutation) */
198
+ proposedState: ProducerState
199
+ producerId: string
200
+ }
201
+ | { status: `duplicate`; lastSeq: number }
202
+ | { status: `stale_epoch`; currentEpoch: number }
203
+ | { status: `invalid_epoch_seq` }
204
+ | { status: `sequence_gap`; expectedSeq: number; receivedSeq: number }
205
+
160
206
  /**
161
207
  * Pending long-poll request.
162
208
  */