@durable-streams/client-conformance-tests 0.2.0 → 0.2.2

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/protocol.ts CHANGED
@@ -41,6 +41,10 @@ export interface CreateCommand {
41
41
  expiresAt?: string
42
42
  /** Custom headers to include */
43
43
  headers?: Record<string, string>
44
+ /** Create the stream in closed state */
45
+ closed?: boolean
46
+ /** Initial body data to include on creation */
47
+ data?: string
44
48
  }
45
49
 
46
50
  /**
@@ -112,6 +116,38 @@ export interface IdempotentAppendBatchCommand {
112
116
  headers?: Record<string, string>
113
117
  }
114
118
 
119
+ /**
120
+ * Close a stream via IdempotentProducer (uses producer headers for idempotency).
121
+ */
122
+ export interface IdempotentCloseCommand {
123
+ type: `idempotent-close`
124
+ path: string
125
+ /** Producer ID */
126
+ producerId: string
127
+ /** Producer epoch */
128
+ epoch: number
129
+ /** Optional final message to append atomically with close */
130
+ data?: string
131
+ /** Auto-claim epoch on 403 */
132
+ autoClaim: boolean
133
+ /** Custom headers to include */
134
+ headers?: Record<string, string>
135
+ }
136
+
137
+ /**
138
+ * Detach an IdempotentProducer (stop without closing stream).
139
+ */
140
+ export interface IdempotentDetachCommand {
141
+ type: `idempotent-detach`
142
+ path: string
143
+ /** Producer ID */
144
+ producerId: string
145
+ /** Producer epoch */
146
+ epoch: number
147
+ /** Custom headers to include */
148
+ headers?: Record<string, string>
149
+ }
150
+
115
151
  /**
116
152
  * Read from a stream (GET request).
117
153
  */
@@ -150,6 +186,35 @@ export interface DeleteCommand {
150
186
  headers?: Record<string, string>
151
187
  }
152
188
 
189
+ /**
190
+ * Close a stream (no more appends allowed).
191
+ */
192
+ export interface CloseCommand {
193
+ type: `close`
194
+ /** Stream path */
195
+ path: string
196
+ /** Optional final message to append */
197
+ data?: string
198
+ /** Content type for the final message */
199
+ contentType?: string
200
+ }
201
+
202
+ /**
203
+ * Close a stream via direct HTTP (bypasses client adapter).
204
+ * Used for testing server-side stream closure behavior.
205
+ */
206
+ export interface ServerCloseCommand {
207
+ type: `server-close`
208
+ /** Stream path */
209
+ path: string
210
+ /** Whether stream should be closed (always true for this command) */
211
+ streamClosed: true
212
+ /** Optional body data */
213
+ data?: string
214
+ /** Content type for the body */
215
+ contentType?: string
216
+ }
217
+
153
218
  /**
154
219
  * Shutdown the client adapter gracefully.
155
220
  */
@@ -334,9 +399,13 @@ export type TestCommand =
334
399
  | AppendCommand
335
400
  | IdempotentAppendCommand
336
401
  | IdempotentAppendBatchCommand
402
+ | IdempotentCloseCommand
403
+ | IdempotentDetachCommand
337
404
  | ReadCommand
338
405
  | HeadCommand
339
406
  | DeleteCommand
407
+ | CloseCommand
408
+ | ServerCloseCommand
340
409
  | ShutdownCommand
341
410
  | SetDynamicHeaderCommand
342
411
  | SetDynamicParamCommand
@@ -459,6 +528,26 @@ export interface IdempotentAppendBatchResult {
459
528
  producerSeq?: number
460
529
  }
461
530
 
531
+ /**
532
+ * Successful idempotent-close result.
533
+ */
534
+ export interface IdempotentCloseResult {
535
+ type: `idempotent-close`
536
+ success: true
537
+ status: number
538
+ /** Final stream offset after close */
539
+ finalOffset?: string
540
+ }
541
+
542
+ /**
543
+ * Successful idempotent-detach result.
544
+ */
545
+ export interface IdempotentDetachResult {
546
+ type: `idempotent-detach`
547
+ success: true
548
+ status: number
549
+ }
550
+
462
551
  /**
463
552
  * A chunk of data read from the stream.
464
553
  */
@@ -484,6 +573,8 @@ export interface ReadResult {
484
573
  offset?: string
485
574
  /** Whether stream is up-to-date (caught up to head) */
486
575
  upToDate?: boolean
576
+ /** Whether the stream has been permanently closed (no more appends) */
577
+ streamClosed?: boolean
487
578
  /** Cursor value if provided */
488
579
  cursor?: string
489
580
  /** Response headers */
@@ -509,6 +600,8 @@ export interface HeadResult {
509
600
  ttlSeconds?: number
510
601
  /** Absolute expiry (ISO 8601) */
511
602
  expiresAt?: string
603
+ /** Whether the stream has been permanently closed (no more appends) */
604
+ streamClosed?: boolean
512
605
  headers?: Record<string, string>
513
606
  }
514
607
 
@@ -522,6 +615,16 @@ export interface DeleteResult {
522
615
  headers?: Record<string, string>
523
616
  }
524
617
 
618
+ /**
619
+ * Successful close result.
620
+ */
621
+ export interface CloseResult {
622
+ type: `close`
623
+ success: true
624
+ /** Final offset after closing (may include final message) */
625
+ finalOffset: string
626
+ }
627
+
525
628
  /**
526
629
  * Successful shutdown result.
527
630
  */
@@ -612,9 +715,12 @@ export type TestResult =
612
715
  | AppendResult
613
716
  | IdempotentAppendResult
614
717
  | IdempotentAppendBatchResult
718
+ | IdempotentCloseResult
719
+ | IdempotentDetachResult
615
720
  | ReadResult
616
721
  | HeadResult
617
722
  | DeleteResult
723
+ | CloseResult
618
724
  | ShutdownResult
619
725
  | SetDynamicHeaderResult
620
726
  | SetDynamicParamResult
@@ -683,6 +789,8 @@ export const ErrorCodes = {
683
789
  NOT_FOUND: `NOT_FOUND`,
684
790
  /** Sequence number conflict (409) */
685
791
  SEQUENCE_CONFLICT: `SEQUENCE_CONFLICT`,
792
+ /** Stream is closed (409 with Stream-Closed header) */
793
+ STREAM_CLOSED: `STREAM_CLOSED`,
686
794
  /** Invalid offset format */
687
795
  INVALID_OFFSET: `INVALID_OFFSET`,
688
796
  /** Server returned unexpected status */
package/src/runner.ts CHANGED
@@ -21,6 +21,7 @@ import {
21
21
  import type { Interface as ReadlineInterface } from "node:readline"
22
22
  import type {
23
23
  AppendResult,
24
+ CloseResult,
24
25
  ErrorResult,
25
26
  HeadResult,
26
27
  ReadResult,
@@ -273,6 +274,8 @@ async function executeOperation(
273
274
  ttlSeconds: op.ttlSeconds,
274
275
  expiresAt: op.expiresAt,
275
276
  headers: op.headers,
277
+ closed: op.closed,
278
+ data: op.data,
276
279
  },
277
280
  commandTimeout
278
281
  )
@@ -441,6 +444,55 @@ async function executeOperation(
441
444
  return { result }
442
445
  }
443
446
 
447
+ case `idempotent-close`: {
448
+ const path = resolveVariables(op.path, variables)
449
+ const data = op.data ? resolveVariables(op.data, variables) : undefined
450
+
451
+ const result = await client.send(
452
+ {
453
+ type: `idempotent-close`,
454
+ path,
455
+ producerId: op.producerId,
456
+ epoch: op.epoch ?? 0,
457
+ data,
458
+ autoClaim: op.autoClaim ?? false,
459
+ headers: op.headers,
460
+ },
461
+ commandTimeout
462
+ )
463
+
464
+ if (verbose) {
465
+ console.log(
466
+ ` idempotent-close ${path}: ${result.success ? `ok` : `failed`}`
467
+ )
468
+ }
469
+
470
+ return { result }
471
+ }
472
+
473
+ case `idempotent-detach`: {
474
+ const path = resolveVariables(op.path, variables)
475
+
476
+ const result = await client.send(
477
+ {
478
+ type: `idempotent-detach`,
479
+ path,
480
+ producerId: op.producerId,
481
+ epoch: op.epoch ?? 0,
482
+ headers: op.headers,
483
+ },
484
+ commandTimeout
485
+ )
486
+
487
+ if (verbose) {
488
+ console.log(
489
+ ` idempotent-detach ${path}: ${result.success ? `ok` : `failed`}`
490
+ )
491
+ }
492
+
493
+ return { result }
494
+ }
495
+
444
496
  case `read`: {
445
497
  const path = resolveVariables(op.path, variables)
446
498
  const offset = op.offset
@@ -546,6 +598,72 @@ async function executeOperation(
546
598
  return { result }
547
599
  }
548
600
 
601
+ case `close`: {
602
+ const path = resolveVariables(op.path, variables)
603
+
604
+ const result = await client.send(
605
+ {
606
+ type: `close`,
607
+ path,
608
+ data: op.data,
609
+ contentType: op.contentType,
610
+ },
611
+ commandTimeout
612
+ )
613
+
614
+ if (verbose) {
615
+ console.log(` close ${path}: ${result.success ? `ok` : `failed`}`)
616
+ }
617
+
618
+ return { result }
619
+ }
620
+
621
+ case `server-close`: {
622
+ // Direct HTTP POST to server with Stream-Closed: true header
623
+ // Used for testing server-side stream closure behavior
624
+ const path = resolveVariables(op.path, variables)
625
+
626
+ try {
627
+ // Build headers including Stream-Closed
628
+ const headers: Record<string, string> = {
629
+ "Stream-Closed": `true`,
630
+ ...op.headers,
631
+ }
632
+
633
+ // Set content-type if body is provided
634
+ if (op.data && op.contentType) {
635
+ headers[`content-type`] = op.contentType
636
+ }
637
+
638
+ const response = await fetch(`${ctx.serverUrl}${path}`, {
639
+ method: `POST`,
640
+ body: op.data,
641
+ headers,
642
+ })
643
+
644
+ const status = response.status
645
+ const finalOffset =
646
+ response.headers.get(`Stream-Next-Offset`) ?? undefined
647
+
648
+ if (verbose) {
649
+ console.log(` server-close ${path}: status=${status}`)
650
+ }
651
+
652
+ // Build result for expectation verification
653
+ const result: TestResult = {
654
+ type: `close`,
655
+ success: true,
656
+ finalOffset: finalOffset ?? ``,
657
+ }
658
+
659
+ return { result }
660
+ } catch (err) {
661
+ return {
662
+ error: `Server close failed: ${err instanceof Error ? err.message : String(err)}`,
663
+ }
664
+ }
665
+ }
666
+
549
667
  case `wait`: {
550
668
  await new Promise((resolve) => setTimeout(resolve, op.ms))
551
669
  return {}
@@ -884,6 +1002,10 @@ function isHeadResult(result: TestResult): result is HeadResult {
884
1002
  return result.type === `head` && result.success
885
1003
  }
886
1004
 
1005
+ function isCloseResult(result: TestResult): result is CloseResult {
1006
+ return result.type === `close` && result.success
1007
+ }
1008
+
887
1009
  function isErrorResult(result: TestResult): result is ErrorResult {
888
1010
  return result.type === `error` && !result.success
889
1011
  }
@@ -976,6 +1098,27 @@ function validateExpectation(
976
1098
  }
977
1099
  }
978
1100
 
1101
+ // Check streamClosed (for read results)
1102
+ if (expect.streamClosed !== undefined && isReadResult(result)) {
1103
+ if (result.streamClosed !== expect.streamClosed) {
1104
+ return `Expected streamClosed=${expect.streamClosed}, got ${result.streamClosed}`
1105
+ }
1106
+ }
1107
+
1108
+ // Check streamClosed (for head results)
1109
+ if (expect.streamClosed !== undefined && isHeadResult(result)) {
1110
+ if (result.streamClosed !== expect.streamClosed) {
1111
+ return `Expected streamClosed=${expect.streamClosed}, got ${result.streamClosed}`
1112
+ }
1113
+ }
1114
+
1115
+ // Check finalOffset (for close results)
1116
+ if (expect.finalOffset !== undefined && isCloseResult(result)) {
1117
+ if (result.finalOffset !== expect.finalOffset) {
1118
+ return `Expected finalOffset=${expect.finalOffset}, got ${result.finalOffset}`
1119
+ }
1120
+ }
1121
+
979
1122
  // Check chunkCount
980
1123
  if (expect.chunkCount !== undefined && isReadResult(result)) {
981
1124
  if (result.chunks.length !== expect.chunkCount) {
package/src/test-cases.ts CHANGED
@@ -92,6 +92,10 @@ export interface CreateOperation {
92
92
  expiresAt?: string
93
93
  /** Custom headers */
94
94
  headers?: Record<string, string>
95
+ /** Create stream in closed state */
96
+ closed?: boolean
97
+ /** Initial body data to include on creation */
98
+ data?: string
95
99
  /** Expected result */
96
100
  expect?: CreateExpectation
97
101
  }
@@ -206,6 +210,55 @@ export interface IdempotentAppendBatchExpectation extends BaseExpectation {
206
210
  allSucceed?: boolean
207
211
  }
208
212
 
213
+ /**
214
+ * Close a stream via IdempotentProducer (uses producer headers for idempotency).
215
+ */
216
+ export interface IdempotentCloseOperation {
217
+ action: `idempotent-close`
218
+ path: string
219
+ /** Producer ID */
220
+ producerId: string
221
+ /** Producer epoch */
222
+ epoch?: number
223
+ /** Optional final message to append atomically with close */
224
+ data?: string
225
+ /** Auto-claim epoch on 403 */
226
+ autoClaim?: boolean
227
+ headers?: Record<string, string>
228
+ expect?: IdempotentCloseExpectation
229
+ }
230
+
231
+ /**
232
+ * Expectation for idempotent-close operation.
233
+ */
234
+ export interface IdempotentCloseExpectation extends BaseExpectation {
235
+ /** Store the final offset */
236
+ storeOffsetAs?: string
237
+ /** Expected finalOffset */
238
+ finalOffset?: string
239
+ }
240
+
241
+ /**
242
+ * Detach an IdempotentProducer (stop without closing stream).
243
+ */
244
+ export interface IdempotentDetachOperation {
245
+ action: `idempotent-detach`
246
+ path: string
247
+ /** Producer ID */
248
+ producerId: string
249
+ /** Producer epoch */
250
+ epoch?: number
251
+ headers?: Record<string, string>
252
+ expect?: IdempotentDetachExpectation
253
+ }
254
+
255
+ /**
256
+ * Expectation for idempotent-detach operation.
257
+ */
258
+ export interface IdempotentDetachExpectation extends BaseExpectation {
259
+ // No specific expectations beyond status
260
+ }
261
+
209
262
  /**
210
263
  * Read from a stream.
211
264
  */
@@ -250,6 +303,35 @@ export interface DeleteOperation {
250
303
  expect?: DeleteExpectation
251
304
  }
252
305
 
306
+ /**
307
+ * Close a stream (no more appends allowed).
308
+ */
309
+ export interface CloseOperation {
310
+ action: `close`
311
+ path: string
312
+ /** Optional final message to append */
313
+ data?: string
314
+ /** Content type for the final message */
315
+ contentType?: string
316
+ headers?: Record<string, string>
317
+ expect?: CloseExpectation
318
+ }
319
+
320
+ /**
321
+ * Close a stream via direct HTTP (bypasses client adapter).
322
+ * Used for testing server-side stream closure behavior.
323
+ */
324
+ export interface ServerCloseOperation {
325
+ action: `server-close`
326
+ path: string
327
+ /** Optional body data */
328
+ data?: string
329
+ /** Content type for the body */
330
+ contentType?: string
331
+ headers?: Record<string, string>
332
+ expect?: ServerCloseExpectation
333
+ }
334
+
253
335
  /**
254
336
  * Wait for a duration (for timing-sensitive tests).
255
337
  */
@@ -467,9 +549,13 @@ export type TestOperation =
467
549
  | AppendBatchOperation
468
550
  | IdempotentAppendOperation
469
551
  | IdempotentAppendBatchOperation
552
+ | IdempotentCloseOperation
553
+ | IdempotentDetachOperation
470
554
  | ReadOperation
471
555
  | HeadOperation
472
556
  | DeleteOperation
557
+ | CloseOperation
558
+ | ServerCloseOperation
473
559
  | WaitOperation
474
560
  | SetOperation
475
561
  | AssertOperation
@@ -556,6 +642,8 @@ export interface ReadExpectation extends BaseExpectation {
556
642
  maxChunks?: number
557
643
  /** Should be up-to-date after read */
558
644
  upToDate?: boolean
645
+ /** Whether the stream has been permanently closed */
646
+ streamClosed?: boolean
559
647
  /** Store final offset */
560
648
  storeOffsetAs?: string
561
649
  /** Store all data concatenated */
@@ -572,12 +660,26 @@ export interface HeadExpectation extends BaseExpectation {
572
660
  contentType?: string
573
661
  /** Should have an offset */
574
662
  hasOffset?: boolean
663
+ /** Whether the stream has been permanently closed */
664
+ streamClosed?: boolean
575
665
  }
576
666
 
577
667
  export interface DeleteExpectation extends BaseExpectation {
578
668
  status?: 200 | 204 | 404 | number
579
669
  }
580
670
 
671
+ export interface CloseExpectation extends BaseExpectation {
672
+ /** Expected final offset after closing */
673
+ finalOffset?: string
674
+ }
675
+
676
+ export interface ServerCloseExpectation {
677
+ /** Expected HTTP status code */
678
+ status?: number
679
+ /** Expected final offset after closing */
680
+ finalOffset?: string
681
+ }
682
+
581
683
  /**
582
684
  * Load all test suites from a directory.
583
685
  */
@@ -66,6 +66,7 @@ tests:
66
66
  setup:
67
67
  - action: create
68
68
  as: streamPath
69
+ contentType: text/plain
69
70
  - action: append
70
71
  path: ${streamPath}
71
72
  data: "x"
@@ -121,6 +121,7 @@ tests:
121
121
  setup:
122
122
  - action: create
123
123
  as: streamPath
124
+ contentType: text/plain
124
125
  operations:
125
126
  # SSE with offset=now on empty stream
126
127
  - action: read
@@ -217,6 +218,7 @@ tests:
217
218
  setup:
218
219
  - action: create
219
220
  as: streamPath
221
+ contentType: text/plain
220
222
  - action: append
221
223
  path: ${streamPath}
222
224
  data: "historicalsse"
@@ -366,6 +368,7 @@ tests:
366
368
  setup:
367
369
  - action: create
368
370
  as: streamPath
371
+ contentType: text/plain
369
372
  - action: append
370
373
  path: ${streamPath}
371
374
  data: "old"