@durable-streams/client-conformance-tests 0.1.6 → 0.1.7

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.
Files changed (29) hide show
  1. package/dist/adapters/typescript-adapter.cjs +75 -3
  2. package/dist/adapters/typescript-adapter.js +76 -4
  3. package/dist/{benchmark-runner-D-YSAvRy.js → benchmark-runner-CrE6JkbX.js} +86 -8
  4. package/dist/{benchmark-runner-BlKqhoXE.cjs → benchmark-runner-Db4he452.cjs} +87 -8
  5. package/dist/cli.cjs +1 -1
  6. package/dist/cli.js +1 -1
  7. package/dist/index.cjs +1 -1
  8. package/dist/index.d.cts +106 -6
  9. package/dist/index.d.ts +106 -6
  10. package/dist/index.js +1 -1
  11. package/dist/{protocol-3cf94Xyb.d.cts → protocol-D37G3c4e.d.cts} +80 -4
  12. package/dist/{protocol-DyEvTHPF.d.ts → protocol-Mcbiq3nQ.d.ts} +80 -4
  13. package/dist/protocol.d.cts +2 -2
  14. package/dist/protocol.d.ts +2 -2
  15. package/package.json +3 -3
  16. package/src/adapters/typescript-adapter.ts +127 -6
  17. package/src/protocol.ts +85 -1
  18. package/src/runner.ts +178 -13
  19. package/src/test-cases.ts +110 -3
  20. package/test-cases/consumer/error-handling.yaml +42 -0
  21. package/test-cases/consumer/offset-handling.yaml +209 -0
  22. package/test-cases/producer/idempotent/autoclaim.yaml +214 -0
  23. package/test-cases/producer/idempotent/batching.yaml +98 -0
  24. package/test-cases/producer/idempotent/concurrent-requests.yaml +100 -0
  25. package/test-cases/producer/idempotent/epoch-management.yaml +333 -0
  26. package/test-cases/producer/idempotent/error-handling.yaml +194 -0
  27. package/test-cases/producer/idempotent/multi-producer.yaml +322 -0
  28. package/test-cases/producer/idempotent/sequence-validation.yaml +339 -0
  29. package/test-cases/producer/idempotent-json-batching.yaml +134 -0
@@ -14,6 +14,7 @@ import {
14
14
  DurableStream,
15
15
  DurableStreamError,
16
16
  FetchError,
17
+ IdempotentProducer,
17
18
  stream,
18
19
  } from "@durable-streams/client"
19
20
  import {
@@ -439,7 +440,7 @@ async function handleCommand(command: TestCommand): Promise<TestResult> {
439
440
  return {
440
441
  type: `read`,
441
442
  success: true,
442
- status: 200,
443
+ status: response.status,
443
444
  chunks,
444
445
  offset: finalOffset,
445
446
  upToDate,
@@ -543,6 +544,117 @@ async function handleCommand(command: TestCommand): Promise<TestResult> {
543
544
  }
544
545
  }
545
546
 
547
+ case `idempotent-append`: {
548
+ try {
549
+ const url = `${serverUrl}${command.path}`
550
+
551
+ // Get content-type from cache or use default
552
+ const contentType =
553
+ streamContentTypes.get(command.path) ?? `application/octet-stream`
554
+
555
+ const ds = new DurableStream({
556
+ url,
557
+ contentType,
558
+ })
559
+
560
+ const producer = new IdempotentProducer(ds, command.producerId, {
561
+ epoch: command.epoch,
562
+ autoClaim: command.autoClaim,
563
+ maxInFlight: 1,
564
+ lingerMs: 0, // Send immediately for testing
565
+ })
566
+
567
+ // For JSON streams, parse the string data into a native object
568
+ // (IdempotentProducer expects native objects for JSON streams)
569
+ const normalizedContentType = contentType
570
+ .split(`;`)[0]
571
+ ?.trim()
572
+ .toLowerCase()
573
+ const isJson = normalizedContentType === `application/json`
574
+ const data = isJson ? JSON.parse(command.data) : command.data
575
+
576
+ try {
577
+ // append() is fire-and-forget (synchronous), then flush() sends the batch
578
+ producer.append(data)
579
+ await producer.flush()
580
+ await producer.close()
581
+
582
+ return {
583
+ type: `idempotent-append`,
584
+ success: true,
585
+ status: 200,
586
+ }
587
+ } catch (err) {
588
+ await producer.close()
589
+ throw err
590
+ }
591
+ } catch (err) {
592
+ return errorResult(`idempotent-append`, err)
593
+ }
594
+ }
595
+
596
+ case `idempotent-append-batch`: {
597
+ try {
598
+ const url = `${serverUrl}${command.path}`
599
+
600
+ // Get content-type from cache or use default
601
+ const contentType =
602
+ streamContentTypes.get(command.path) ?? `application/octet-stream`
603
+
604
+ const ds = new DurableStream({
605
+ url,
606
+ contentType,
607
+ })
608
+
609
+ // Use provided maxInFlight or default to 1 for compatibility
610
+ const maxInFlight = command.maxInFlight ?? 1
611
+
612
+ // When testing concurrency (maxInFlight > 1), use small batches to force
613
+ // multiple concurrent requests. Otherwise batch all items together.
614
+ const testingConcurrency = maxInFlight > 1
615
+ const producer = new IdempotentProducer(ds, command.producerId, {
616
+ epoch: command.epoch,
617
+ autoClaim: command.autoClaim,
618
+ maxInFlight,
619
+ lingerMs: testingConcurrency ? 0 : 1000,
620
+ maxBatchBytes: testingConcurrency ? 1 : 1024 * 1024,
621
+ })
622
+
623
+ // For JSON streams, parse string items into native objects
624
+ // (IdempotentProducer expects native objects for JSON streams)
625
+ const normalizedContentType = contentType
626
+ .split(`;`)[0]
627
+ ?.trim()
628
+ .toLowerCase()
629
+ const isJson = normalizedContentType === `application/json`
630
+ const items = isJson
631
+ ? command.items.map((item: string) => JSON.parse(item))
632
+ : command.items
633
+
634
+ try {
635
+ // append() is fire-and-forget (synchronous), adds to pending batch
636
+ for (const item of items) {
637
+ producer.append(item)
638
+ }
639
+
640
+ // flush() sends the batch and waits for completion
641
+ await producer.flush()
642
+ await producer.close()
643
+
644
+ return {
645
+ type: `idempotent-append-batch`,
646
+ success: true,
647
+ status: 200,
648
+ }
649
+ } catch (err) {
650
+ await producer.close()
651
+ throw err
652
+ }
653
+ } catch (err) {
654
+ return errorResult(`idempotent-append-batch`, err)
655
+ }
656
+ }
657
+
546
658
  default:
547
659
  return {
548
660
  type: `error`,
@@ -702,7 +814,7 @@ async function handleBenchmark(command: BenchmarkCommand): Promise<TestResult> {
702
814
 
703
815
  // Wait for data
704
816
  return new Promise<Uint8Array>((resolve) => {
705
- const unsubscribe = res.subscribeBytes((chunk) => {
817
+ const unsubscribe = res.subscribeBytes(async (chunk) => {
706
818
  if (chunk.data.length > 0) {
707
819
  unsubscribe()
708
820
  res.cancel()
@@ -749,10 +861,19 @@ async function handleBenchmark(command: BenchmarkCommand): Promise<TestResult> {
749
861
  // Generate payload (using fill for speed - don't want to measure PRNG)
750
862
  const payload = new Uint8Array(operation.size).fill(42)
751
863
 
752
- // Submit all messages at once - client batching will handle the rest
753
- await Promise.all(
754
- Array.from({ length: operation.count }, () => ds.append(payload))
755
- )
864
+ // Use IdempotentProducer for automatic batching and pipelining
865
+ const producer = new IdempotentProducer(ds, `bench-producer`, {
866
+ lingerMs: 0, // No linger - send batches immediately when ready
867
+ onError: (err) => console.error(`Batch failed:`, err),
868
+ })
869
+
870
+ // Fire-and-forget: don't await individual appends, producer batches in background
871
+ for (let i = 0; i < operation.count; i++) {
872
+ producer.append(payload)
873
+ }
874
+
875
+ // Wait for all messages to be delivered
876
+ await producer.flush()
756
877
 
757
878
  metrics.bytesTransferred = operation.count * operation.size
758
879
  metrics.messagesProcessed = operation.count
package/src/protocol.ts CHANGED
@@ -62,10 +62,54 @@ export interface AppendCommand {
62
62
  data: string
63
63
  /** Whether data is base64 encoded binary */
64
64
  binary?: boolean
65
- /** Optional sequence number for ordering */
65
+ /** Optional sequence number for ordering (Stream-Seq header) */
66
66
  seq?: number
67
67
  /** Custom headers to include */
68
68
  headers?: Record<string, string>
69
+ /** Producer ID for idempotent producers */
70
+ producerId?: string
71
+ /** Producer epoch for idempotent producers */
72
+ producerEpoch?: number
73
+ /** Producer sequence for idempotent producers */
74
+ producerSeq?: number
75
+ }
76
+
77
+ /**
78
+ * Append via IdempotentProducer client (tests client-side exactly-once semantics).
79
+ */
80
+ export interface IdempotentAppendCommand {
81
+ type: `idempotent-append`
82
+ path: string
83
+ /** Data to append (string - will be JSON parsed for JSON streams) */
84
+ data: string
85
+ /** Producer ID */
86
+ producerId: string
87
+ /** Producer epoch */
88
+ epoch: number
89
+ /** Auto-claim epoch on 403 */
90
+ autoClaim: boolean
91
+ /** Custom headers to include */
92
+ headers?: Record<string, string>
93
+ }
94
+
95
+ /**
96
+ * Batch append via IdempotentProducer client (tests client-side JSON batching).
97
+ */
98
+ export interface IdempotentAppendBatchCommand {
99
+ type: `idempotent-append-batch`
100
+ path: string
101
+ /** Items to append - will be batched by the client */
102
+ items: Array<string>
103
+ /** Producer ID */
104
+ producerId: string
105
+ /** Producer epoch */
106
+ epoch: number
107
+ /** Auto-claim epoch on 403 */
108
+ autoClaim: boolean
109
+ /** Max concurrent batches in flight (default 1, set higher to test 409 retry) */
110
+ maxInFlight?: number
111
+ /** Custom headers to include */
112
+ headers?: Record<string, string>
69
113
  }
70
114
 
71
115
  /**
@@ -235,6 +279,8 @@ export type TestCommand =
235
279
  | CreateCommand
236
280
  | ConnectCommand
237
281
  | AppendCommand
282
+ | IdempotentAppendCommand
283
+ | IdempotentAppendBatchCommand
238
284
  | ReadCommand
239
285
  | HeadCommand
240
286
  | DeleteCommand
@@ -313,6 +359,42 @@ export interface AppendResult {
313
359
  headersSent?: Record<string, string>
314
360
  /** Params that were sent in the request (for dynamic param testing) */
315
361
  paramsSent?: Record<string, string>
362
+ /** Whether this was a duplicate (204 response) - for idempotent producers */
363
+ duplicate?: boolean
364
+ /** Current producer epoch from server (on 200 or 403) */
365
+ producerEpoch?: number
366
+ /** Server's highest accepted sequence for this (stream, producerId, epoch) - returned in Producer-Seq header on 200/204 */
367
+ producerSeq?: number
368
+ /** Expected producer sequence (on 409 sequence gap) */
369
+ producerExpectedSeq?: number
370
+ /** Received producer sequence (on 409 sequence gap) */
371
+ producerReceivedSeq?: number
372
+ }
373
+
374
+ /**
375
+ * Successful idempotent-append result.
376
+ */
377
+ export interface IdempotentAppendResult {
378
+ type: `idempotent-append`
379
+ success: true
380
+ status: number
381
+ /** New offset after append */
382
+ offset?: string
383
+ /** Whether this was a duplicate */
384
+ duplicate?: boolean
385
+ /** Server's highest accepted sequence for this (stream, producerId, epoch) - returned in Producer-Seq header */
386
+ producerSeq?: number
387
+ }
388
+
389
+ /**
390
+ * Successful idempotent-append-batch result.
391
+ */
392
+ export interface IdempotentAppendBatchResult {
393
+ type: `idempotent-append-batch`
394
+ success: true
395
+ status: number
396
+ /** Server's highest accepted sequence for this (stream, producerId, epoch) - returned in Producer-Seq header */
397
+ producerSeq?: number
316
398
  }
317
399
 
318
400
  /**
@@ -458,6 +540,8 @@ export type TestResult =
458
540
  | CreateResult
459
541
  | ConnectResult
460
542
  | AppendResult
543
+ | IdempotentAppendResult
544
+ | IdempotentAppendBatchResult
461
545
  | ReadResult
462
546
  | HeadResult
463
547
  | DeleteResult
package/src/runner.ts CHANGED
@@ -366,6 +366,71 @@ async function executeOperation(
366
366
  }
367
367
  }
368
368
 
369
+ case `idempotent-append`: {
370
+ const path = resolveVariables(op.path, variables)
371
+ const data = resolveVariables(op.data, variables)
372
+
373
+ const result = await client.send(
374
+ {
375
+ type: `idempotent-append`,
376
+ path,
377
+ data,
378
+ producerId: op.producerId,
379
+ epoch: op.epoch ?? 0,
380
+ autoClaim: op.autoClaim ?? false,
381
+ headers: op.headers,
382
+ },
383
+ commandTimeout
384
+ )
385
+
386
+ if (verbose) {
387
+ console.log(
388
+ ` idempotent-append ${path}: ${result.success ? `ok` : `failed`}`
389
+ )
390
+ }
391
+
392
+ if (
393
+ result.success &&
394
+ result.type === `idempotent-append` &&
395
+ op.expect?.storeOffsetAs
396
+ ) {
397
+ variables.set(op.expect.storeOffsetAs, result.offset ?? ``)
398
+ }
399
+
400
+ return { result }
401
+ }
402
+
403
+ case `idempotent-append-batch`: {
404
+ const path = resolveVariables(op.path, variables)
405
+
406
+ // Send items to client which will batch them internally
407
+ const items = op.items.map((item) =>
408
+ resolveVariables(item.data, variables)
409
+ )
410
+
411
+ const result = await client.send(
412
+ {
413
+ type: `idempotent-append-batch`,
414
+ path,
415
+ items,
416
+ producerId: op.producerId,
417
+ epoch: op.epoch ?? 0,
418
+ autoClaim: op.autoClaim ?? false,
419
+ maxInFlight: op.maxInFlight,
420
+ headers: op.headers,
421
+ },
422
+ commandTimeout
423
+ )
424
+
425
+ if (verbose) {
426
+ console.log(
427
+ ` idempotent-append-batch ${path}: ${result.success ? `ok` : `failed`}`
428
+ )
429
+ }
430
+
431
+ return { result }
432
+ }
433
+
369
434
  case `read`: {
370
435
  const path = resolveVariables(op.path, variables)
371
436
  const offset = op.offset
@@ -538,7 +603,8 @@ async function executeOperation(
538
603
 
539
604
  case `server-append`: {
540
605
  // Direct HTTP append to server, bypassing client adapter
541
- // Used for concurrent operations when adapter is blocked on a read
606
+ // Used for concurrent operations when adapter is blocked on a read,
607
+ // and for testing protocol-level behavior like idempotent producers
542
608
  const path = resolveVariables(op.path, variables)
543
609
  const data = resolveVariables(op.data, variables)
544
610
 
@@ -550,28 +616,72 @@ async function executeOperation(
550
616
  const contentType =
551
617
  headResponse.headers.get(`content-type`) ?? `application/octet-stream`
552
618
 
619
+ // Build headers, including producer headers if present
620
+ const headers: Record<string, string> = {
621
+ "content-type": contentType,
622
+ ...op.headers,
623
+ }
624
+ if (op.producerId !== undefined) {
625
+ headers[`Producer-Id`] = op.producerId
626
+ }
627
+ if (op.producerEpoch !== undefined) {
628
+ headers[`Producer-Epoch`] = op.producerEpoch.toString()
629
+ }
630
+ if (op.producerSeq !== undefined) {
631
+ headers[`Producer-Seq`] = op.producerSeq.toString()
632
+ }
633
+
553
634
  const response = await fetch(`${ctx.serverUrl}${path}`, {
554
635
  method: `POST`,
555
636
  body: data,
556
- headers: {
557
- "content-type": contentType,
558
- ...op.headers,
559
- },
637
+ headers,
560
638
  })
561
639
 
640
+ const status = response.status
641
+ const offset = response.headers.get(`Stream-Next-Offset`) ?? undefined
642
+ const duplicate = status === 204
643
+ const producerEpoch = response.headers.get(`Producer-Epoch`)
644
+ const producerSeq = response.headers.get(`Producer-Seq`)
645
+ const producerExpectedSeq = response.headers.get(
646
+ `Producer-Expected-Seq`
647
+ )
648
+ const producerReceivedSeq = response.headers.get(
649
+ `Producer-Received-Seq`
650
+ )
651
+
562
652
  if (verbose) {
563
653
  console.log(
564
- ` server-append ${path}: ${response.ok ? `ok` : `failed (${response.status})`}`
654
+ ` server-append ${path}: status=${status}${duplicate ? ` (duplicate)` : ``}`
565
655
  )
566
656
  }
567
657
 
568
- if (!response.ok) {
569
- return {
570
- error: `Server append failed with status ${response.status}`,
571
- }
658
+ // Build result for expectation verification
659
+ // success: true means we got a valid protocol response (even 403/409)
660
+ // The status field indicates the actual operation result
661
+ const result: TestResult = {
662
+ type: `append`,
663
+ success: true,
664
+ status,
665
+ offset,
666
+ duplicate,
667
+ producerEpoch: producerEpoch
668
+ ? parseInt(producerEpoch, 10)
669
+ : undefined,
670
+ producerSeq: producerSeq ? parseInt(producerSeq, 10) : undefined,
671
+ producerExpectedSeq: producerExpectedSeq
672
+ ? parseInt(producerExpectedSeq, 10)
673
+ : undefined,
674
+ producerReceivedSeq: producerReceivedSeq
675
+ ? parseInt(producerReceivedSeq, 10)
676
+ : undefined,
572
677
  }
573
678
 
574
- return {}
679
+ // Store offset if requested
680
+ if (op.expect?.storeOffsetAs && offset) {
681
+ variables.set(op.expect.storeOffsetAs, offset)
682
+ }
683
+
684
+ return { result }
575
685
  } catch (err) {
576
686
  return {
577
687
  error: `Server append failed: ${err instanceof Error ? err.message : String(err)}`,
@@ -796,6 +906,22 @@ function validateExpectation(
796
906
  }
797
907
  }
798
908
 
909
+ // Check dataExact - verifies exact messages in order
910
+ if (expect.dataExact !== undefined && isReadResult(result)) {
911
+ const expectedMessages = expect.dataExact as Array<string>
912
+ const actualMessages = result.chunks.map((c) => c.data)
913
+
914
+ if (actualMessages.length !== expectedMessages.length) {
915
+ return `Expected ${expectedMessages.length} messages, got ${actualMessages.length}. Expected: [${expectedMessages.join(`, `)}], got: [${actualMessages.join(`, `)}]`
916
+ }
917
+
918
+ for (let i = 0; i < expectedMessages.length; i++) {
919
+ if (actualMessages[i] !== expectedMessages[i]) {
920
+ return `Message ${i} mismatch: expected "${expectedMessages[i]}", got "${actualMessages[i]}"`
921
+ }
922
+ }
923
+ }
924
+
799
925
  // Check upToDate
800
926
  if (expect.upToDate !== undefined && isReadResult(result)) {
801
927
  if (result.upToDate !== expect.upToDate) {
@@ -878,6 +1004,41 @@ function validateExpectation(
878
1004
  }
879
1005
  }
880
1006
 
1007
+ // Check duplicate (for idempotent producer 204 responses)
1008
+ if (expect.duplicate !== undefined && isAppendResult(result)) {
1009
+ if (result.duplicate !== expect.duplicate) {
1010
+ return `Expected duplicate=${expect.duplicate}, got ${result.duplicate}`
1011
+ }
1012
+ }
1013
+
1014
+ // Check producerEpoch (returned on 200/403)
1015
+ if (expect.producerEpoch !== undefined && isAppendResult(result)) {
1016
+ if (result.producerEpoch !== expect.producerEpoch) {
1017
+ return `Expected producerEpoch=${expect.producerEpoch}, got ${result.producerEpoch}`
1018
+ }
1019
+ }
1020
+
1021
+ // Check producerSeq (returned on 200/204 - highest accepted sequence)
1022
+ if (expect.producerSeq !== undefined && isAppendResult(result)) {
1023
+ if (result.producerSeq !== expect.producerSeq) {
1024
+ return `Expected producerSeq=${expect.producerSeq}, got ${result.producerSeq}`
1025
+ }
1026
+ }
1027
+
1028
+ // Check producerExpectedSeq (returned on 409 sequence gap)
1029
+ if (expect.producerExpectedSeq !== undefined && isAppendResult(result)) {
1030
+ if (result.producerExpectedSeq !== expect.producerExpectedSeq) {
1031
+ return `Expected producerExpectedSeq=${expect.producerExpectedSeq}, got ${result.producerExpectedSeq}`
1032
+ }
1033
+ }
1034
+
1035
+ // Check producerReceivedSeq (returned on 409 sequence gap)
1036
+ if (expect.producerReceivedSeq !== undefined && isAppendResult(result)) {
1037
+ if (result.producerReceivedSeq !== expect.producerReceivedSeq) {
1038
+ return `Expected producerReceivedSeq=${expect.producerReceivedSeq}, got ${result.producerReceivedSeq}`
1039
+ }
1040
+ }
1041
+
881
1042
  return null
882
1043
  }
883
1044
 
@@ -1061,8 +1222,12 @@ export async function runConformanceTests(
1061
1222
  const totalTests = countTests(suites)
1062
1223
  console.log(`\nRunning ${totalTests} client conformance tests...\n`)
1063
1224
 
1064
- // Start reference server
1065
- const server = new DurableStreamTestServer({ port: options.serverPort ?? 0 })
1225
+ // Start reference server with short long-poll timeout for testing
1226
+ // Tests use timeoutMs: 1000, so server timeout should be shorter
1227
+ const server = new DurableStreamTestServer({
1228
+ port: options.serverPort ?? 0,
1229
+ longPollTimeout: 500, // 500ms timeout for testing
1230
+ })
1066
1231
  await server.start()
1067
1232
  const serverUrl = server.url
1068
1233
 
package/src/test-cases.ts CHANGED
@@ -118,10 +118,16 @@ export interface AppendOperation {
118
118
  data?: string
119
119
  /** Binary data (base64 encoded) */
120
120
  binaryData?: string
121
- /** Sequence number for ordering */
121
+ /** Sequence number for ordering (Stream-Seq header) */
122
122
  seq?: number
123
123
  headers?: Record<string, string>
124
124
  expect?: AppendExpectation
125
+ /** Producer ID for idempotent producers */
126
+ producerId?: string
127
+ /** Producer epoch for idempotent producers */
128
+ producerEpoch?: number
129
+ /** Producer sequence for idempotent producers */
130
+ producerSeq?: number
125
131
  }
126
132
 
127
133
  /**
@@ -140,6 +146,64 @@ export interface AppendBatchOperation {
140
146
  expect?: AppendBatchExpectation
141
147
  }
142
148
 
149
+ /**
150
+ * Append via IdempotentProducer client (tests client-side exactly-once semantics).
151
+ */
152
+ export interface IdempotentAppendOperation {
153
+ action: `idempotent-append`
154
+ path: string
155
+ /** Producer ID */
156
+ producerId: string
157
+ /** Producer epoch */
158
+ epoch?: number
159
+ /** Data to append (string or JSON for JSON streams) */
160
+ data: string
161
+ /** Auto-claim epoch on 403 */
162
+ autoClaim?: boolean
163
+ headers?: Record<string, string>
164
+ expect?: IdempotentAppendExpectation
165
+ }
166
+
167
+ /**
168
+ * Batch append via IdempotentProducer client (tests client-side JSON batching).
169
+ */
170
+ export interface IdempotentAppendBatchOperation {
171
+ action: `idempotent-append-batch`
172
+ path: string
173
+ /** Producer ID */
174
+ producerId: string
175
+ /** Producer epoch */
176
+ epoch?: number
177
+ /** Items to append (will be batched by the client) */
178
+ items: Array<{
179
+ data: string
180
+ }>
181
+ /** Auto-claim epoch on 403 */
182
+ autoClaim?: boolean
183
+ /** Max concurrent batches in flight (default 1, set higher to test 409 retry) */
184
+ maxInFlight?: number
185
+ headers?: Record<string, string>
186
+ expect?: IdempotentAppendBatchExpectation
187
+ }
188
+
189
+ /**
190
+ * Expectation for idempotent-append operation.
191
+ */
192
+ export interface IdempotentAppendExpectation extends BaseExpectation {
193
+ /** Expected duplicate flag */
194
+ duplicate?: boolean
195
+ /** Store the returned offset */
196
+ storeOffsetAs?: string
197
+ }
198
+
199
+ /**
200
+ * Expectation for idempotent-append-batch operation.
201
+ */
202
+ export interface IdempotentAppendBatchExpectation extends BaseExpectation {
203
+ /** All items should succeed */
204
+ allSucceed?: boolean
205
+ }
206
+
143
207
  /**
144
208
  * Read from a stream.
145
209
  */
@@ -223,13 +287,42 @@ export interface AssertOperation {
223
287
 
224
288
  /**
225
289
  * Append to stream via direct server HTTP (bypasses client adapter).
226
- * Used for concurrent operations when adapter is blocked on a read.
290
+ * Used for concurrent operations when adapter is blocked on a read,
291
+ * and for testing protocol-level behavior like idempotent producers.
227
292
  */
228
293
  export interface ServerAppendOperation {
229
294
  action: `server-append`
230
295
  path: string
231
296
  data: string
232
297
  headers?: Record<string, string>
298
+ /** Producer ID for idempotent producers */
299
+ producerId?: string
300
+ /** Producer epoch for idempotent producers */
301
+ producerEpoch?: number
302
+ /** Producer sequence for idempotent producers */
303
+ producerSeq?: number
304
+ /** Expected result */
305
+ expect?: ServerAppendExpectation
306
+ }
307
+
308
+ /**
309
+ * Expectation for server-append operation.
310
+ */
311
+ export interface ServerAppendExpectation {
312
+ /** Expected HTTP status code */
313
+ status?: number
314
+ /** Store the returned offset */
315
+ storeOffsetAs?: string
316
+ /** Expected duplicate flag (true for 204 idempotent success) */
317
+ duplicate?: boolean
318
+ /** Expected producer epoch in response */
319
+ producerEpoch?: number
320
+ /** Expected producer seq in response (highest accepted sequence) */
321
+ producerSeq?: number
322
+ /** Expected producer expected seq (on 409 sequence gap) */
323
+ producerExpectedSeq?: number
324
+ /** Expected producer received seq (on 409 sequence gap) */
325
+ producerReceivedSeq?: number
233
326
  }
234
327
 
235
328
  /**
@@ -320,6 +413,8 @@ export type TestOperation =
320
413
  | ConnectOperation
321
414
  | AppendOperation
322
415
  | AppendBatchOperation
416
+ | IdempotentAppendOperation
417
+ | IdempotentAppendBatchOperation
323
418
  | ReadOperation
324
419
  | HeadOperation
325
420
  | DeleteOperation
@@ -360,13 +455,23 @@ export interface ConnectExpectation extends BaseExpectation {
360
455
  }
361
456
 
362
457
  export interface AppendExpectation extends BaseExpectation {
363
- status?: 200 | 404 | 409 | number
458
+ status?: 200 | 204 | 400 | 403 | 404 | 409 | number
364
459
  /** Store the returned offset */
365
460
  storeOffsetAs?: string
366
461
  /** Expected headers that were sent (for dynamic header testing) */
367
462
  headersSent?: Record<string, string>
368
463
  /** Expected params that were sent (for dynamic param testing) */
369
464
  paramsSent?: Record<string, string>
465
+ /** Expected duplicate flag (true for 204 idempotent success) */
466
+ duplicate?: boolean
467
+ /** Expected producer epoch in response */
468
+ producerEpoch?: number
469
+ /** Expected producer seq in response (highest accepted sequence) */
470
+ producerSeq?: number
471
+ /** Expected producer expected seq (on 409 sequence gap) */
472
+ producerExpectedSeq?: number
473
+ /** Expected producer received seq (on 409 sequence gap) */
474
+ producerReceivedSeq?: number
370
475
  }
371
476
 
372
477
  export interface AppendBatchExpectation extends BaseExpectation {
@@ -386,6 +491,8 @@ export interface ReadExpectation extends BaseExpectation {
386
491
  dataContains?: string
387
492
  /** Expected data to contain all of these substrings */
388
493
  dataContainsAll?: Array<string>
494
+ /** Expected exact messages in order (for JSON streams, verifies each chunk) */
495
+ dataExact?: Array<string>
389
496
  /** Expected number of chunks */
390
497
  chunkCount?: number
391
498
  /** Minimum number of chunks */