@durable-streams/server 0.1.4 → 0.1.6
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.cjs +412 -17
- package/dist/index.d.cts +140 -9
- package/dist/index.d.ts +140 -9
- package/dist/index.js +412 -17
- package/package.json +4 -4
- package/src/file-store.ts +238 -10
- package/src/server.ts +202 -17
- package/src/store.ts +272 -7
- package/src/types.ts +46 -0
package/src/store.ts
CHANGED
|
@@ -2,7 +2,17 @@
|
|
|
2
2
|
* In-memory stream storage.
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
-
import type {
|
|
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:
|
|
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
|
-
//
|
|
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
|
|
247
|
-
//
|
|
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
|
*/
|