@durable-streams/client-conformance-tests 0.1.9 → 0.2.1

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.
@@ -36,6 +36,10 @@ interface CreateCommand {
36
36
  expiresAt?: string;
37
37
  /** Custom headers to include */
38
38
  headers?: Record<string, string>;
39
+ /** Create the stream in closed state */
40
+ closed?: boolean;
41
+ /** Initial body data to include on creation */
42
+ data?: string;
39
43
  }
40
44
  /**
41
45
  * Connect to an existing stream without creating it.
@@ -103,6 +107,36 @@ interface IdempotentAppendBatchCommand {
103
107
  headers?: Record<string, string>;
104
108
  }
105
109
  /**
110
+ * Close a stream via IdempotentProducer (uses producer headers for idempotency).
111
+ */
112
+ interface IdempotentCloseCommand {
113
+ type: `idempotent-close`;
114
+ path: string;
115
+ /** Producer ID */
116
+ producerId: string;
117
+ /** Producer epoch */
118
+ epoch: number;
119
+ /** Optional final message to append atomically with close */
120
+ data?: string;
121
+ /** Auto-claim epoch on 403 */
122
+ autoClaim: boolean;
123
+ /** Custom headers to include */
124
+ headers?: Record<string, string>;
125
+ }
126
+ /**
127
+ * Detach an IdempotentProducer (stop without closing stream).
128
+ */
129
+ interface IdempotentDetachCommand {
130
+ type: `idempotent-detach`;
131
+ path: string;
132
+ /** Producer ID */
133
+ producerId: string;
134
+ /** Producer epoch */
135
+ epoch: number;
136
+ /** Custom headers to include */
137
+ headers?: Record<string, string>;
138
+ }
139
+ /**
106
140
  * Read from a stream (GET request).
107
141
  */
108
142
  interface ReadCommand {
@@ -138,6 +172,33 @@ interface DeleteCommand {
138
172
  headers?: Record<string, string>;
139
173
  }
140
174
  /**
175
+ * Close a stream (no more appends allowed).
176
+ */
177
+ interface CloseCommand {
178
+ type: `close`;
179
+ /** Stream path */
180
+ path: string;
181
+ /** Optional final message to append */
182
+ data?: string;
183
+ /** Content type for the final message */
184
+ contentType?: string;
185
+ }
186
+ /**
187
+ * Close a stream via direct HTTP (bypasses client adapter).
188
+ * Used for testing server-side stream closure behavior.
189
+ */
190
+ interface ServerCloseCommand {
191
+ type: `server-close`;
192
+ /** Stream path */
193
+ path: string;
194
+ /** Whether stream should be closed (always true for this command) */
195
+ streamClosed: true;
196
+ /** Optional body data */
197
+ data?: string;
198
+ /** Content type for the body */
199
+ contentType?: string;
200
+ }
201
+ /**
141
202
  * Shutdown the client adapter gracefully.
142
203
  */
143
204
  interface ShutdownCommand {
@@ -280,7 +341,7 @@ interface BenchmarkThroughputReadOp {
280
341
  /**
281
342
  * All possible commands from test runner to client.
282
343
  */
283
- type TestCommand = InitCommand | CreateCommand | ConnectCommand | AppendCommand | IdempotentAppendCommand | IdempotentAppendBatchCommand | ReadCommand | HeadCommand | DeleteCommand | ShutdownCommand | SetDynamicHeaderCommand | SetDynamicParamCommand | ClearDynamicCommand | BenchmarkCommand | ValidateCommand;
344
+ type TestCommand = InitCommand | CreateCommand | ConnectCommand | AppendCommand | IdempotentAppendCommand | IdempotentAppendBatchCommand | IdempotentCloseCommand | IdempotentDetachCommand | ReadCommand | HeadCommand | DeleteCommand | CloseCommand | ServerCloseCommand | ShutdownCommand | SetDynamicHeaderCommand | SetDynamicParamCommand | ClearDynamicCommand | BenchmarkCommand | ValidateCommand;
284
345
  /**
285
346
  * Successful initialization result.
286
347
  */
@@ -387,6 +448,24 @@ interface IdempotentAppendBatchResult {
387
448
  producerSeq?: number;
388
449
  }
389
450
  /**
451
+ * Successful idempotent-close result.
452
+ */
453
+ interface IdempotentCloseResult {
454
+ type: `idempotent-close`;
455
+ success: true;
456
+ status: number;
457
+ /** Final stream offset after close */
458
+ finalOffset?: string;
459
+ }
460
+ /**
461
+ * Successful idempotent-detach result.
462
+ */
463
+ interface IdempotentDetachResult {
464
+ type: `idempotent-detach`;
465
+ success: true;
466
+ status: number;
467
+ }
468
+ /**
390
469
  * A chunk of data read from the stream.
391
470
  */
392
471
  interface ReadChunk {
@@ -410,6 +489,8 @@ interface ReadResult {
410
489
  offset?: string;
411
490
  /** Whether stream is up-to-date (caught up to head) */
412
491
  upToDate?: boolean;
492
+ /** Whether the stream has been permanently closed (no more appends) */
493
+ streamClosed?: boolean;
413
494
  /** Cursor value if provided */
414
495
  cursor?: string;
415
496
  /** Response headers */
@@ -434,6 +515,8 @@ interface HeadResult {
434
515
  ttlSeconds?: number;
435
516
  /** Absolute expiry (ISO 8601) */
436
517
  expiresAt?: string;
518
+ /** Whether the stream has been permanently closed (no more appends) */
519
+ streamClosed?: boolean;
437
520
  headers?: Record<string, string>;
438
521
  }
439
522
  /**
@@ -446,6 +529,15 @@ interface DeleteResult {
446
529
  headers?: Record<string, string>;
447
530
  }
448
531
  /**
532
+ * Successful close result.
533
+ */
534
+ interface CloseResult {
535
+ type: `close`;
536
+ success: true;
537
+ /** Final offset after closing (may include final message) */
538
+ finalOffset: string;
539
+ }
540
+ /**
449
541
  * Successful shutdown result.
450
542
  */
451
543
  interface ShutdownResult {
@@ -521,7 +613,7 @@ interface ErrorResult {
521
613
  /**
522
614
  * All possible results from client to test runner.
523
615
  */
524
- type TestResult = InitResult | CreateResult | ConnectResult | AppendResult | IdempotentAppendResult | IdempotentAppendBatchResult | ReadResult | HeadResult | DeleteResult | ShutdownResult | SetDynamicHeaderResult | SetDynamicParamResult | ClearDynamicResult | ValidateResult | BenchmarkResult | ErrorResult;
616
+ type TestResult = InitResult | CreateResult | ConnectResult | AppendResult | IdempotentAppendResult | IdempotentAppendBatchResult | IdempotentCloseResult | IdempotentDetachResult | ReadResult | HeadResult | DeleteResult | CloseResult | ShutdownResult | SetDynamicHeaderResult | SetDynamicParamResult | ClearDynamicResult | ValidateResult | BenchmarkResult | ErrorResult;
525
617
  /**
526
618
  * Parse a JSON line into a TestCommand.
527
619
  */
@@ -560,6 +652,8 @@ declare const ErrorCodes: {
560
652
  readonly NOT_FOUND: "NOT_FOUND";
561
653
  /** Sequence number conflict (409) */
562
654
  readonly SEQUENCE_CONFLICT: "SEQUENCE_CONFLICT";
655
+ /** Stream is closed (409 with Stream-Closed header) */
656
+ readonly STREAM_CLOSED: "STREAM_CLOSED";
563
657
  /** Invalid offset format */
564
658
  readonly INVALID_OFFSET: "INVALID_OFFSET";
565
659
  /** Server returned unexpected status */
@@ -607,4 +701,4 @@ declare function calculateStats(durationsNs: Array<bigint>): BenchmarkStats;
607
701
  * Format a BenchmarkStats object for display.
608
702
  */
609
703
  declare function formatStats(stats: BenchmarkStats, unit?: string): Record<string, string>; //#endregion
610
- export { AppendCommand, AppendResult, BenchmarkAppendOp, BenchmarkCommand, BenchmarkCreateOp, BenchmarkOperation, BenchmarkReadOp, BenchmarkResult, BenchmarkRoundtripOp, BenchmarkStats, BenchmarkThroughputAppendOp, BenchmarkThroughputReadOp, ClearDynamicCommand, ClearDynamicResult, ConnectCommand, ConnectResult, CreateCommand, CreateResult, DeleteCommand, DeleteResult, ErrorCode, ErrorCodes as ErrorCodes$1, ErrorResult, HeadCommand, HeadResult, IdempotentAppendBatchCommand, IdempotentAppendBatchResult, IdempotentAppendCommand, IdempotentAppendResult, InitCommand, InitResult, ReadChunk, ReadCommand, ReadResult, SetDynamicHeaderCommand, SetDynamicHeaderResult, SetDynamicParamCommand, SetDynamicParamResult, ShutdownCommand, ShutdownResult, TestCommand, TestResult, ValidateCommand, ValidateIdempotentProducer, ValidateResult, ValidateRetryOptions, ValidateTarget, calculateStats as calculateStats$1, decodeBase64 as decodeBase64$1, encodeBase64 as encodeBase64$1, formatStats as formatStats$1, parseCommand as parseCommand$1, parseResult as parseResult$1, serializeCommand as serializeCommand$1, serializeResult as serializeResult$1 };
704
+ export { AppendCommand, AppendResult, BenchmarkAppendOp, BenchmarkCommand, BenchmarkCreateOp, BenchmarkOperation, BenchmarkReadOp, BenchmarkResult, BenchmarkRoundtripOp, BenchmarkStats, BenchmarkThroughputAppendOp, BenchmarkThroughputReadOp, ClearDynamicCommand, ClearDynamicResult, CloseCommand, CloseResult, ConnectCommand, ConnectResult, CreateCommand, CreateResult, DeleteCommand, DeleteResult, ErrorCode, ErrorCodes, ErrorResult, HeadCommand, HeadResult, IdempotentAppendBatchCommand, IdempotentAppendBatchResult, IdempotentAppendCommand, IdempotentAppendResult, IdempotentCloseCommand, IdempotentCloseResult, IdempotentDetachCommand, IdempotentDetachResult, InitCommand, InitResult, ReadChunk, ReadCommand, ReadResult, ServerCloseCommand, SetDynamicHeaderCommand, SetDynamicHeaderResult, SetDynamicParamCommand, SetDynamicParamResult, ShutdownCommand, ShutdownResult, TestCommand, TestResult, ValidateCommand, ValidateIdempotentProducer, ValidateResult, ValidateRetryOptions, ValidateTarget, calculateStats, decodeBase64, encodeBase64, formatStats, parseCommand, parseResult, serializeCommand, serializeResult };
@@ -46,6 +46,7 @@ const ErrorCodes = {
46
46
  CONFLICT: `CONFLICT`,
47
47
  NOT_FOUND: `NOT_FOUND`,
48
48
  SEQUENCE_CONFLICT: `SEQUENCE_CONFLICT`,
49
+ STREAM_CLOSED: `STREAM_CLOSED`,
49
50
  INVALID_OFFSET: `INVALID_OFFSET`,
50
51
  UNEXPECTED_STATUS: `UNEXPECTED_STATUS`,
51
52
  PARSE_ERROR: `PARSE_ERROR`,
package/dist/protocol.cjs CHANGED
@@ -1,4 +1,4 @@
1
- const require_protocol = require('./protocol-IioVPNaP.cjs');
1
+ const require_protocol = require('./protocol-sDk3deGa.cjs');
2
2
 
3
3
  exports.ErrorCodes = require_protocol.ErrorCodes
4
4
  exports.calculateStats = require_protocol.calculateStats
@@ -1,2 +1,2 @@
1
- import { AppendCommand, AppendResult, BenchmarkAppendOp, BenchmarkCommand, BenchmarkCreateOp, BenchmarkOperation, BenchmarkReadOp, BenchmarkResult, BenchmarkRoundtripOp, BenchmarkStats, BenchmarkThroughputAppendOp, BenchmarkThroughputReadOp, ClearDynamicCommand, ClearDynamicResult, ConnectCommand, ConnectResult, CreateCommand, CreateResult, DeleteCommand, DeleteResult, ErrorCode, ErrorCodes, ErrorResult, HeadCommand, HeadResult, IdempotentAppendBatchCommand, IdempotentAppendBatchResult, IdempotentAppendCommand, IdempotentAppendResult, InitCommand, InitResult, ReadChunk, ReadCommand, ReadResult, SetDynamicHeaderCommand, SetDynamicHeaderResult, SetDynamicParamCommand, SetDynamicParamResult, ShutdownCommand, ShutdownResult, TestCommand, TestResult, ValidateCommand, ValidateIdempotentProducer, ValidateResult, ValidateRetryOptions, ValidateTarget, calculateStats, decodeBase64, encodeBase64, formatStats, parseCommand, parseResult, serializeCommand, serializeResult } from "./protocol-BxZTqJmO.cjs";
2
- export { AppendCommand, AppendResult, BenchmarkAppendOp, BenchmarkCommand, BenchmarkCreateOp, BenchmarkOperation, BenchmarkReadOp, BenchmarkResult, BenchmarkRoundtripOp, BenchmarkStats, BenchmarkThroughputAppendOp, BenchmarkThroughputReadOp, ClearDynamicCommand, ClearDynamicResult, ConnectCommand, ConnectResult, CreateCommand, CreateResult, DeleteCommand, DeleteResult, ErrorCode, ErrorCodes, ErrorResult, HeadCommand, HeadResult, IdempotentAppendBatchCommand, IdempotentAppendBatchResult, IdempotentAppendCommand, IdempotentAppendResult, InitCommand, InitResult, ReadChunk, ReadCommand, ReadResult, SetDynamicHeaderCommand, SetDynamicHeaderResult, SetDynamicParamCommand, SetDynamicParamResult, ShutdownCommand, ShutdownResult, TestCommand, TestResult, ValidateCommand, ValidateIdempotentProducer, ValidateResult, ValidateRetryOptions, ValidateTarget, calculateStats, decodeBase64, encodeBase64, formatStats, parseCommand, parseResult, serializeCommand, serializeResult };
1
+ import { AppendCommand, AppendResult, BenchmarkAppendOp, BenchmarkCommand, BenchmarkCreateOp, BenchmarkOperation, BenchmarkReadOp, BenchmarkResult, BenchmarkRoundtripOp, BenchmarkStats, BenchmarkThroughputAppendOp, BenchmarkThroughputReadOp, ClearDynamicCommand, ClearDynamicResult, CloseCommand, CloseResult, ConnectCommand, ConnectResult, CreateCommand, CreateResult, DeleteCommand, DeleteResult, ErrorCode, ErrorCodes, ErrorResult, HeadCommand, HeadResult, IdempotentAppendBatchCommand, IdempotentAppendBatchResult, IdempotentAppendCommand, IdempotentAppendResult, IdempotentCloseCommand, IdempotentCloseResult, IdempotentDetachCommand, IdempotentDetachResult, InitCommand, InitResult, ReadChunk, ReadCommand, ReadResult, ServerCloseCommand, SetDynamicHeaderCommand, SetDynamicHeaderResult, SetDynamicParamCommand, SetDynamicParamResult, ShutdownCommand, ShutdownResult, TestCommand, TestResult, ValidateCommand, ValidateIdempotentProducer, ValidateResult, ValidateRetryOptions, ValidateTarget, calculateStats, decodeBase64, encodeBase64, formatStats, parseCommand, parseResult, serializeCommand, serializeResult } from "./protocol-COHkkGmU.cjs";
2
+ export { AppendCommand, AppendResult, BenchmarkAppendOp, BenchmarkCommand, BenchmarkCreateOp, BenchmarkOperation, BenchmarkReadOp, BenchmarkResult, BenchmarkRoundtripOp, BenchmarkStats, BenchmarkThroughputAppendOp, BenchmarkThroughputReadOp, ClearDynamicCommand, ClearDynamicResult, CloseCommand, CloseResult, ConnectCommand, ConnectResult, CreateCommand, CreateResult, DeleteCommand, DeleteResult, ErrorCode, ErrorCodes, ErrorResult, HeadCommand, HeadResult, IdempotentAppendBatchCommand, IdempotentAppendBatchResult, IdempotentAppendCommand, IdempotentAppendResult, IdempotentCloseCommand, IdempotentCloseResult, IdempotentDetachCommand, IdempotentDetachResult, InitCommand, InitResult, ReadChunk, ReadCommand, ReadResult, ServerCloseCommand, SetDynamicHeaderCommand, SetDynamicHeaderResult, SetDynamicParamCommand, SetDynamicParamResult, ShutdownCommand, ShutdownResult, TestCommand, TestResult, ValidateCommand, ValidateIdempotentProducer, ValidateResult, ValidateRetryOptions, ValidateTarget, calculateStats, decodeBase64, encodeBase64, formatStats, parseCommand, parseResult, serializeCommand, serializeResult };
@@ -1,2 +1,2 @@
1
- import { AppendCommand, AppendResult, BenchmarkAppendOp, BenchmarkCommand, BenchmarkCreateOp, BenchmarkOperation, BenchmarkReadOp, BenchmarkResult, BenchmarkRoundtripOp, BenchmarkStats, BenchmarkThroughputAppendOp, BenchmarkThroughputReadOp, ClearDynamicCommand, ClearDynamicResult, ConnectCommand, ConnectResult, CreateCommand, CreateResult, DeleteCommand, DeleteResult, ErrorCode, ErrorCodes$1 as ErrorCodes, ErrorResult, HeadCommand, HeadResult, IdempotentAppendBatchCommand, IdempotentAppendBatchResult, IdempotentAppendCommand, IdempotentAppendResult, InitCommand, InitResult, ReadChunk, ReadCommand, ReadResult, SetDynamicHeaderCommand, SetDynamicHeaderResult, SetDynamicParamCommand, SetDynamicParamResult, ShutdownCommand, ShutdownResult, TestCommand, TestResult, ValidateCommand, ValidateIdempotentProducer, ValidateResult, ValidateRetryOptions, ValidateTarget, calculateStats$1 as calculateStats, decodeBase64$1 as decodeBase64, encodeBase64$1 as encodeBase64, formatStats$1 as formatStats, parseCommand$1 as parseCommand, parseResult$1 as parseResult, serializeCommand$1 as serializeCommand, serializeResult$1 as serializeResult } from "./protocol-JuFzdV5x.js";
2
- export { AppendCommand, AppendResult, BenchmarkAppendOp, BenchmarkCommand, BenchmarkCreateOp, BenchmarkOperation, BenchmarkReadOp, BenchmarkResult, BenchmarkRoundtripOp, BenchmarkStats, BenchmarkThroughputAppendOp, BenchmarkThroughputReadOp, ClearDynamicCommand, ClearDynamicResult, ConnectCommand, ConnectResult, CreateCommand, CreateResult, DeleteCommand, DeleteResult, ErrorCode, ErrorCodes, ErrorResult, HeadCommand, HeadResult, IdempotentAppendBatchCommand, IdempotentAppendBatchResult, IdempotentAppendCommand, IdempotentAppendResult, InitCommand, InitResult, ReadChunk, ReadCommand, ReadResult, SetDynamicHeaderCommand, SetDynamicHeaderResult, SetDynamicParamCommand, SetDynamicParamResult, ShutdownCommand, ShutdownResult, TestCommand, TestResult, ValidateCommand, ValidateIdempotentProducer, ValidateResult, ValidateRetryOptions, ValidateTarget, calculateStats, decodeBase64, encodeBase64, formatStats, parseCommand, parseResult, serializeCommand, serializeResult };
1
+ import { AppendCommand, AppendResult, BenchmarkAppendOp, BenchmarkCommand, BenchmarkCreateOp, BenchmarkOperation, BenchmarkReadOp, BenchmarkResult, BenchmarkRoundtripOp, BenchmarkStats, BenchmarkThroughputAppendOp, BenchmarkThroughputReadOp, ClearDynamicCommand, ClearDynamicResult, CloseCommand, CloseResult, ConnectCommand, ConnectResult, CreateCommand, CreateResult, DeleteCommand, DeleteResult, ErrorCode, ErrorCodes$1 as ErrorCodes, ErrorResult, HeadCommand, HeadResult, IdempotentAppendBatchCommand, IdempotentAppendBatchResult, IdempotentAppendCommand, IdempotentAppendResult, IdempotentCloseCommand, IdempotentCloseResult, IdempotentDetachCommand, IdempotentDetachResult, InitCommand, InitResult, ReadChunk, ReadCommand, ReadResult, ServerCloseCommand, SetDynamicHeaderCommand, SetDynamicHeaderResult, SetDynamicParamCommand, SetDynamicParamResult, ShutdownCommand, ShutdownResult, TestCommand, TestResult, ValidateCommand, ValidateIdempotentProducer, ValidateResult, ValidateRetryOptions, ValidateTarget, calculateStats$1 as calculateStats, decodeBase64$1 as decodeBase64, encodeBase64$1 as encodeBase64, formatStats$1 as formatStats, parseCommand$1 as parseCommand, parseResult$1 as parseResult, serializeCommand$1 as serializeCommand, serializeResult$1 as serializeResult } from "./protocol-9WN0gRRQ.js";
2
+ export { AppendCommand, AppendResult, BenchmarkAppendOp, BenchmarkCommand, BenchmarkCreateOp, BenchmarkOperation, BenchmarkReadOp, BenchmarkResult, BenchmarkRoundtripOp, BenchmarkStats, BenchmarkThroughputAppendOp, BenchmarkThroughputReadOp, ClearDynamicCommand, ClearDynamicResult, CloseCommand, CloseResult, ConnectCommand, ConnectResult, CreateCommand, CreateResult, DeleteCommand, DeleteResult, ErrorCode, ErrorCodes, ErrorResult, HeadCommand, HeadResult, IdempotentAppendBatchCommand, IdempotentAppendBatchResult, IdempotentAppendCommand, IdempotentAppendResult, IdempotentCloseCommand, IdempotentCloseResult, IdempotentDetachCommand, IdempotentDetachResult, InitCommand, InitResult, ReadChunk, ReadCommand, ReadResult, ServerCloseCommand, SetDynamicHeaderCommand, SetDynamicHeaderResult, SetDynamicParamCommand, SetDynamicParamResult, ShutdownCommand, ShutdownResult, TestCommand, TestResult, ValidateCommand, ValidateIdempotentProducer, ValidateResult, ValidateRetryOptions, ValidateTarget, calculateStats, decodeBase64, encodeBase64, formatStats, parseCommand, parseResult, serializeCommand, serializeResult };
package/dist/protocol.js CHANGED
@@ -1,3 +1,3 @@
1
- import { ErrorCodes, calculateStats, decodeBase64, encodeBase64, formatStats, parseCommand, parseResult, serializeCommand, serializeResult } from "./protocol-1p0soayz.js";
1
+ import { ErrorCodes, calculateStats, decodeBase64, encodeBase64, formatStats, parseCommand, parseResult, serializeCommand, serializeResult } from "./protocol-BnqUAMKe.js";
2
2
 
3
3
  export { ErrorCodes, calculateStats, decodeBase64, encodeBase64, formatStats, parseCommand, parseResult, serializeCommand, serializeResult };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@durable-streams/client-conformance-tests",
3
3
  "description": "Conformance test suite for Durable Streams client implementations (producer and consumer)",
4
- "version": "0.1.9",
4
+ "version": "0.2.1",
5
5
  "author": "Durable Stream contributors",
6
6
  "bin": {
7
7
  "client-conformance-tests": "./dist/cli.js",
@@ -14,8 +14,8 @@
14
14
  "fast-check": "^4.4.0",
15
15
  "tsx": "^4.19.2",
16
16
  "yaml": "^2.7.1",
17
- "@durable-streams/client": "0.2.0",
18
- "@durable-streams/server": "0.1.7"
17
+ "@durable-streams/client": "0.2.1",
18
+ "@durable-streams/server": "0.2.1"
19
19
  },
20
20
  "devDependencies": {
21
21
  "tsdown": "^0.9.0",
@@ -15,6 +15,7 @@ import {
15
15
  DurableStreamError,
16
16
  FetchError,
17
17
  IdempotentProducer,
18
+ StreamClosedError,
18
19
  stream,
19
20
  } from "@durable-streams/client"
20
21
  import {
@@ -40,6 +41,53 @@ let serverUrl = ``
40
41
  // Track content-type per stream path for append operations
41
42
  const streamContentTypes = new Map<string, string>()
42
43
 
44
+ // Track IdempotentProducer instances to maintain state across operations
45
+ // Key: "path|producerId|epoch"
46
+ const producerCache = new Map<string, IdempotentProducer>()
47
+
48
+ function getProducerCacheKey(
49
+ path: string,
50
+ producerId: string,
51
+ epoch: number
52
+ ): string {
53
+ return `${path}|${producerId}|${epoch}`
54
+ }
55
+
56
+ function getOrCreateProducer(
57
+ path: string,
58
+ producerId: string,
59
+ epoch: number,
60
+ autoClaim: boolean = false
61
+ ): IdempotentProducer {
62
+ const key = getProducerCacheKey(path, producerId, epoch)
63
+ let producer = producerCache.get(key)
64
+ if (!producer) {
65
+ const contentType =
66
+ streamContentTypes.get(path) ?? `application/octet-stream`
67
+ const ds = new DurableStream({
68
+ url: `${serverUrl}${path}`,
69
+ contentType,
70
+ })
71
+ producer = new IdempotentProducer(ds, producerId, {
72
+ epoch,
73
+ autoClaim,
74
+ maxInFlight: 1,
75
+ lingerMs: 0, // Send immediately for testing
76
+ })
77
+ producerCache.set(key, producer)
78
+ }
79
+ return producer
80
+ }
81
+
82
+ function removeProducerFromCache(
83
+ path: string,
84
+ producerId: string,
85
+ epoch: number
86
+ ): void {
87
+ const key = getProducerCacheKey(path, producerId, epoch)
88
+ producerCache.delete(key)
89
+ }
90
+
43
91
  // Dynamic headers/params state
44
92
  interface DynamicValue {
45
93
  type: `counter` | `timestamp` | `token`
@@ -123,6 +171,7 @@ async function handleCommand(command: TestCommand): Promise<TestResult> {
123
171
  streamContentTypes.clear()
124
172
  dynamicHeaders.clear()
125
173
  dynamicParams.clear()
174
+ producerCache.clear()
126
175
  return {
127
176
  type: `init`,
128
177
  success: true,
@@ -160,6 +209,8 @@ async function handleCommand(command: TestCommand): Promise<TestResult> {
160
209
  ttlSeconds: command.ttlSeconds,
161
210
  expiresAt: command.expiresAt,
162
211
  headers: command.headers,
212
+ closed: command.closed,
213
+ body: command.data,
163
214
  })
164
215
 
165
216
  // Cache the content-type
@@ -326,6 +377,7 @@ async function handleCommand(command: TestCommand): Promise<TestResult> {
326
377
  const chunks: Array<ReadChunk> = []
327
378
  let finalOffset = command.offset ?? response.offset
328
379
  let upToDate = response.upToDate
380
+ let streamClosed = response.streamClosed
329
381
 
330
382
  // Collect chunks using body() for non-live mode or bodyStream() for live
331
383
  const maxChunks = command.maxChunks ?? 100
@@ -359,6 +411,7 @@ async function handleCommand(command: TestCommand): Promise<TestResult> {
359
411
  }
360
412
  finalOffset = response.offset
361
413
  upToDate = response.upToDate
414
+ streamClosed = response.streamClosed
362
415
  } else {
363
416
  // For live mode, use subscribeBytes which provides per-chunk metadata
364
417
  const decoder = new TextDecoder()
@@ -405,6 +458,7 @@ async function handleCommand(command: TestCommand): Promise<TestResult> {
405
458
 
406
459
  finalOffset = chunk.offset
407
460
  upToDate = chunk.upToDate
461
+ streamClosed = chunk.streamClosed
408
462
 
409
463
  // For waitForUpToDate, stop when we've reached up-to-date
410
464
  if (command.waitForUpToDate && chunk.upToDate) {
@@ -435,6 +489,7 @@ async function handleCommand(command: TestCommand): Promise<TestResult> {
435
489
  // For empty streams, capture the final upToDate from response
436
490
  upToDate = response.upToDate
437
491
  finalOffset = response.offset
492
+ streamClosed = response.streamClosed
438
493
  resolve()
439
494
  }
440
495
  })
@@ -463,6 +518,7 @@ async function handleCommand(command: TestCommand): Promise<TestResult> {
463
518
  chunks,
464
519
  offset: finalOffset,
465
520
  upToDate,
521
+ streamClosed,
466
522
  headersSent:
467
523
  Object.keys(headersSent).length > 0 ? headersSent : undefined,
468
524
  paramsSent:
@@ -492,6 +548,7 @@ async function handleCommand(command: TestCommand): Promise<TestResult> {
492
548
  status: 200,
493
549
  offset: result.offset,
494
550
  contentType: result.contentType,
551
+ streamClosed: result.streamClosed,
495
552
  // Note: HeadResult from client doesn't expose TTL info currently
496
553
  }
497
554
  } catch (err) {
@@ -520,6 +577,34 @@ async function handleCommand(command: TestCommand): Promise<TestResult> {
520
577
  }
521
578
  }
522
579
 
580
+ case `close`: {
581
+ try {
582
+ const url = `${serverUrl}${command.path}`
583
+
584
+ // Get content-type from cache or use default
585
+ const contentType =
586
+ streamContentTypes.get(command.path) ?? `application/octet-stream`
587
+
588
+ const ds = new DurableStream({
589
+ url,
590
+ contentType: command.contentType ?? contentType,
591
+ })
592
+
593
+ const closeResult = await ds.close({
594
+ body: command.data,
595
+ contentType: command.contentType,
596
+ })
597
+
598
+ return {
599
+ type: `close`,
600
+ success: true,
601
+ finalOffset: closeResult.finalOffset,
602
+ }
603
+ } catch (err) {
604
+ return errorResult(`close`, err)
605
+ }
606
+ }
607
+
523
608
  case `shutdown`: {
524
609
  return {
525
610
  type: `shutdown`,
@@ -565,39 +650,23 @@ async function handleCommand(command: TestCommand): Promise<TestResult> {
565
650
 
566
651
  case `idempotent-append`: {
567
652
  try {
568
- const url = `${serverUrl}${command.path}`
569
-
570
- // Get content-type from cache or use default
571
- const contentType =
572
- streamContentTypes.get(command.path) ?? `application/octet-stream`
573
-
574
- const ds = new DurableStream({
575
- url,
576
- contentType,
577
- })
578
-
579
- const producer = new IdempotentProducer(ds, command.producerId, {
580
- epoch: command.epoch,
581
- autoClaim: command.autoClaim,
582
- maxInFlight: 1,
583
- lingerMs: 0, // Send immediately for testing
584
- })
585
-
586
- try {
587
- // append() is fire-and-forget (synchronous), then flush() sends the batch
588
- // Data is already pre-serialized, pass directly to append()
589
- producer.append(command.data)
590
- await producer.flush()
591
- await producer.close()
653
+ const producer = getOrCreateProducer(
654
+ command.path,
655
+ command.producerId,
656
+ command.epoch,
657
+ command.autoClaim
658
+ )
659
+
660
+ // append() is fire-and-forget (synchronous), then flush() sends the batch
661
+ // Data is already pre-serialized, pass directly to append()
662
+ producer.append(command.data)
663
+ await producer.flush()
664
+ // Don't detach - keep producer for subsequent operations
592
665
 
593
- return {
594
- type: `idempotent-append`,
595
- success: true,
596
- status: 200,
597
- }
598
- } catch (err) {
599
- await producer.close()
600
- throw err
666
+ return {
667
+ type: `idempotent-append`,
668
+ success: true,
669
+ status: 200,
601
670
  }
602
671
  } catch (err) {
603
672
  return errorResult(`idempotent-append`, err)
@@ -640,7 +709,8 @@ async function handleCommand(command: TestCommand): Promise<TestResult> {
640
709
 
641
710
  // flush() sends the batch and waits for completion
642
711
  await producer.flush()
643
- await producer.close()
712
+ // Use detach() to stop producer without closing the stream
713
+ await producer.detach()
644
714
 
645
715
  return {
646
716
  type: `idempotent-append-batch`,
@@ -648,7 +718,7 @@ async function handleCommand(command: TestCommand): Promise<TestResult> {
648
718
  status: 200,
649
719
  }
650
720
  } catch (err) {
651
- await producer.close()
721
+ await producer.detach()
652
722
  throw err
653
723
  }
654
724
  } catch (err) {
@@ -656,6 +726,56 @@ async function handleCommand(command: TestCommand): Promise<TestResult> {
656
726
  }
657
727
  }
658
728
 
729
+ case `idempotent-close`: {
730
+ try {
731
+ const producer = getOrCreateProducer(
732
+ command.path,
733
+ command.producerId,
734
+ command.epoch,
735
+ command.autoClaim
736
+ )
737
+
738
+ // Close the stream with optional final message
739
+ const result = await producer.close(command.data)
740
+
741
+ // Keep producer in cache - subsequent close() calls should be idempotent
742
+ // The producer's internal #closed flag will handle idempotency
743
+
744
+ return {
745
+ type: `idempotent-close`,
746
+ success: true,
747
+ status: 200,
748
+ finalOffset: result.finalOffset,
749
+ }
750
+ } catch (err) {
751
+ return errorResult(`idempotent-close`, err)
752
+ }
753
+ }
754
+
755
+ case `idempotent-detach`: {
756
+ try {
757
+ const producer = getOrCreateProducer(
758
+ command.path,
759
+ command.producerId,
760
+ command.epoch
761
+ )
762
+
763
+ // Detach the producer without closing the stream
764
+ await producer.detach()
765
+
766
+ // Remove from cache since producer is detached
767
+ removeProducerFromCache(command.path, command.producerId, command.epoch)
768
+
769
+ return {
770
+ type: `idempotent-detach`,
771
+ success: true,
772
+ status: 200,
773
+ }
774
+ } catch (err) {
775
+ return errorResult(`idempotent-detach`, err)
776
+ }
777
+ }
778
+
659
779
  case `validate`: {
660
780
  // Test client-side input validation
661
781
  const { target } = command
@@ -728,6 +848,18 @@ function errorResult(
728
848
  commandType: TestCommand[`type`],
729
849
  err: unknown
730
850
  ): TestResult {
851
+ // Handle StreamClosedError specifically
852
+ if (err instanceof StreamClosedError) {
853
+ return {
854
+ type: `error`,
855
+ success: false,
856
+ commandType,
857
+ status: 409,
858
+ errorCode: ErrorCodes.STREAM_CLOSED,
859
+ message: err.message,
860
+ }
861
+ }
862
+
731
863
  if (err instanceof DurableStreamError) {
732
864
  let errorCode: ErrorCode = ErrorCodes.INTERNAL_ERROR
733
865
  let status: number | undefined
@@ -742,6 +874,9 @@ function errorResult(
742
874
  } else if (err.code === `CONFLICT_SEQ`) {
743
875
  errorCode = ErrorCodes.SEQUENCE_CONFLICT
744
876
  status = 409
877
+ } else if (err.code === `STREAM_CLOSED`) {
878
+ errorCode = ErrorCodes.STREAM_CLOSED
879
+ status = 409
745
880
  } else if (err.code === `BAD_REQUEST`) {
746
881
  errorCode = ErrorCodes.INVALID_OFFSET
747
882
  status = 400
@@ -766,8 +901,12 @@ function errorResult(
766
901
  if (err.status === 404) {
767
902
  errorCode = ErrorCodes.NOT_FOUND
768
903
  } else if (err.status === 409) {
769
- // Check for sequence conflict vs general conflict
770
- if (msg.includes(`sequence`)) {
904
+ // Check for stream closed header first
905
+ const streamClosedHeader =
906
+ err.headers[`stream-closed`] ?? err.headers[`Stream-Closed`]
907
+ if (streamClosedHeader?.toLowerCase() === `true`) {
908
+ errorCode = ErrorCodes.STREAM_CLOSED
909
+ } else if (msg.includes(`sequence`)) {
771
910
  errorCode = ErrorCodes.SEQUENCE_CONFLICT
772
911
  } else {
773
912
  errorCode = ErrorCodes.CONFLICT