@durable-streams/client 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.
@@ -0,0 +1,311 @@
1
+ ---
2
+ name: writing-data
3
+ description: >
4
+ Writing data to durable streams. DurableStream.create() with contentType,
5
+ DurableStream.append() for simple writes, IdempotentProducer for
6
+ high-throughput exactly-once delivery with autoClaim, fire-and-forget
7
+ append(), flush(), close(), StaleEpochError handling, JSON mode vs byte
8
+ stream mode, stream closure. Load when writing, producing, or appending
9
+ data to a durable stream.
10
+ type: core
11
+ library: durable-streams
12
+ library_version: "0.2.1"
13
+ requires:
14
+ - getting-started
15
+ sources:
16
+ - "durable-streams/durable-streams:packages/client/src/stream.ts"
17
+ - "durable-streams/durable-streams:packages/client/src/idempotent-producer.ts"
18
+ - "durable-streams/durable-streams:packages/client/src/types.ts"
19
+ ---
20
+
21
+ This skill builds on durable-streams/getting-started. Read it first for setup and offset basics.
22
+
23
+ # Durable Streams — Writing Data
24
+
25
+ Two write APIs: `DurableStream.append()` for simple writes, `IdempotentProducer`
26
+ for sustained high-throughput writes with exactly-once delivery. Use the producer
27
+ for anything beyond a few one-off appends.
28
+
29
+ ## Setup
30
+
31
+ ```typescript
32
+ import {
33
+ DurableStream,
34
+ IdempotentProducer,
35
+ StaleEpochError,
36
+ } from "@durable-streams/client"
37
+
38
+ // Create a JSON-mode stream
39
+ const handle = await DurableStream.create({
40
+ url: "https://your-server.com/v1/stream/my-stream",
41
+ contentType: "application/json",
42
+ })
43
+
44
+ // Set up IdempotentProducer for reliable writes
45
+ const producer = new IdempotentProducer(handle, "my-service", {
46
+ autoClaim: true,
47
+ onError: (err) => {
48
+ if (err instanceof StaleEpochError) {
49
+ console.log("Another producer took over")
50
+ } else {
51
+ console.error("Write error:", err)
52
+ }
53
+ },
54
+ })
55
+
56
+ // Fire-and-forget writes — automatically batched and deduplicated
57
+ for (const event of events) {
58
+ producer.append(JSON.stringify(event))
59
+ }
60
+
61
+ // Ensure all pending writes are delivered
62
+ await producer.flush()
63
+ await producer.close()
64
+ ```
65
+
66
+ ## Core Patterns
67
+
68
+ ### Simple writes with append()
69
+
70
+ For writing a few items and waiting for completion, `append()` is straightforward:
71
+
72
+ ```typescript
73
+ import { DurableStream } from "@durable-streams/client"
74
+
75
+ const handle = await DurableStream.create({
76
+ url: "https://your-server.com/v1/stream/events",
77
+ contentType: "application/json",
78
+ })
79
+
80
+ // Each append waits for server confirmation
81
+ await handle.append({ type: "order.created", orderId: "123" })
82
+ await handle.append({ type: "order.paid", orderId: "123" })
83
+ ```
84
+
85
+ ### High-throughput writes with IdempotentProducer
86
+
87
+ For sustained writes, the producer batches, pipelines, and deduplicates automatically:
88
+
89
+ ```typescript
90
+ import { DurableStream, IdempotentProducer } from "@durable-streams/client"
91
+
92
+ const handle = await DurableStream.create({
93
+ url: "https://your-server.com/v1/stream/tokens",
94
+ contentType: "text/plain",
95
+ })
96
+
97
+ const producer = new IdempotentProducer(handle, "llm-worker", {
98
+ autoClaim: true,
99
+ lingerMs: 10, // Batch window (default: 5ms)
100
+ maxBatchBytes: 65536, // Max batch size (default: 1MB)
101
+ maxInFlight: 4, // Concurrent HTTP requests (default: 5)
102
+ onError: (err) => console.error(err),
103
+ })
104
+
105
+ for await (const token of llm.stream(prompt)) {
106
+ producer.append(token) // Fire-and-forget — don't await
107
+ }
108
+
109
+ await producer.flush() // Wait for all batches to land
110
+ await producer.close() // Clean up
111
+ ```
112
+
113
+ ### Closing a stream
114
+
115
+ Mark a stream as permanently closed (no more writes accepted):
116
+
117
+ ```typescript
118
+ // Close with optional final message
119
+ await handle.close({ body: JSON.stringify({ type: "stream.complete" }) })
120
+
121
+ // Or close without a final message
122
+ await handle.close()
123
+ ```
124
+
125
+ ### Byte stream mode with custom framing
126
+
127
+ For non-JSON streams, use your own framing (e.g., newline-delimited JSON):
128
+
129
+ ```typescript
130
+ const handle = await DurableStream.create({
131
+ url: "https://your-server.com/v1/stream/logs",
132
+ contentType: "text/plain",
133
+ })
134
+
135
+ const producer = new IdempotentProducer(handle, "logger", { autoClaim: true })
136
+
137
+ producer.append(JSON.stringify({ level: "info", msg: "started" }) + "\n")
138
+ producer.append(JSON.stringify({ level: "error", msg: "failed" }) + "\n")
139
+
140
+ await producer.flush()
141
+ ```
142
+
143
+ ## Common Mistakes
144
+
145
+ ### CRITICAL Using raw append() for sustained writes
146
+
147
+ Wrong:
148
+
149
+ ```typescript
150
+ const handle = await DurableStream.create({
151
+ url,
152
+ contentType: "application/json",
153
+ })
154
+ for (const event of events) {
155
+ await handle.append(JSON.stringify(event)) // No dedup, no batching, sequential
156
+ }
157
+ ```
158
+
159
+ Correct:
160
+
161
+ ```typescript
162
+ const handle = await DurableStream.create({
163
+ url,
164
+ contentType: "application/json",
165
+ })
166
+ const producer = new IdempotentProducer(handle, "my-service", {
167
+ autoClaim: true,
168
+ onError: (err) => console.error(err),
169
+ })
170
+ for (const event of events) {
171
+ producer.append(JSON.stringify(event)) // Fire-and-forget, batched, deduplicated
172
+ }
173
+ await producer.flush()
174
+ ```
175
+
176
+ Raw `append()` has no deduplication — on retry after network error, data may be duplicated. `IdempotentProducer` handles batching, pipelining, and exactly-once delivery.
177
+
178
+ Source: packages/client/src/idempotent-producer.ts
179
+
180
+ ### CRITICAL Not calling flush() before shutdown
181
+
182
+ Wrong:
183
+
184
+ ```typescript
185
+ for (const event of events) {
186
+ producer.append(event)
187
+ }
188
+ // Process exits — pending batch lost!
189
+ ```
190
+
191
+ Correct:
192
+
193
+ ```typescript
194
+ for (const event of events) {
195
+ producer.append(event)
196
+ }
197
+ await producer.flush()
198
+ await producer.close()
199
+ ```
200
+
201
+ `IdempotentProducer` batches writes. Without `flush()`, pending messages in the buffer are lost when the process exits.
202
+
203
+ Source: packages/client/src/idempotent-producer.ts
204
+
205
+ ### HIGH Awaiting each producer.append() call
206
+
207
+ Wrong:
208
+
209
+ ```typescript
210
+ for (const event of events) {
211
+ await producer.append(JSON.stringify(event)) // Defeats pipelining!
212
+ }
213
+ ```
214
+
215
+ Correct:
216
+
217
+ ```typescript
218
+ for (const event of events) {
219
+ producer.append(JSON.stringify(event)) // Fire-and-forget
220
+ }
221
+ await producer.flush() // Wait for all to complete
222
+ ```
223
+
224
+ `append()` is fire-and-forget by design. Awaiting it serializes every write and defeats batching and pipelining. Errors go to the `onError` callback.
225
+
226
+ Source: packages/client/src/idempotent-producer.ts
227
+
228
+ ### HIGH Passing objects to append instead of strings
229
+
230
+ Wrong:
231
+
232
+ ```typescript
233
+ producer.append({ event: "user.created" }) // throws!
234
+ ```
235
+
236
+ Correct:
237
+
238
+ ```typescript
239
+ producer.append(JSON.stringify({ event: "user.created" }))
240
+ ```
241
+
242
+ `IdempotentProducer.append()` accepts only `string` or `Uint8Array` — it does **not** auto-serialize objects, even for JSON-mode streams. Always call `JSON.stringify()` before appending.
243
+
244
+ Source: packages/client/src/idempotent-producer.ts
245
+
246
+ ### HIGH Not handling StaleEpochError for multi-worker scenarios
247
+
248
+ Wrong:
249
+
250
+ ```typescript
251
+ const producer = new IdempotentProducer(handle, "worker", {
252
+ onError: (err) => console.error(err), // Logs but doesn't stop
253
+ })
254
+ ```
255
+
256
+ Correct:
257
+
258
+ ```typescript
259
+ let fenced = false
260
+ const producer = new IdempotentProducer(handle, "worker", {
261
+ onError: (err) => {
262
+ if (err instanceof StaleEpochError) {
263
+ fenced = true // Stop producing
264
+ }
265
+ },
266
+ })
267
+
268
+ for await (const token of source) {
269
+ if (fenced) break
270
+ producer.append(token)
271
+ }
272
+ ```
273
+
274
+ When another producer claims a higher epoch, the current producer gets fenced. Without checking, your write loop continues appending to a dead producer.
275
+
276
+ Source: packages/client/src/idempotent-producer.ts
277
+
278
+ ### HIGH Manually managing epoch numbers
279
+
280
+ Wrong:
281
+
282
+ ```typescript
283
+ const producer = new IdempotentProducer(handle, "worker", { epoch: 2 })
284
+ ```
285
+
286
+ Correct:
287
+
288
+ ```typescript
289
+ const producer = new IdempotentProducer(handle, "worker", {
290
+ autoClaim: true, // Handles epoch transitions automatically
291
+ })
292
+ ```
293
+
294
+ Epoch and sequence management is an internal protocol detail. Use `autoClaim: true` and let the producer handle transitions automatically.
295
+
296
+ Source: maintainer guidance
297
+
298
+ ### HIGH Tension: Simplicity vs. reliability in writes
299
+
300
+ This skill's patterns conflict with getting-started. Getting-started simplicity favors `append()` for quick demos, but production code should use `IdempotentProducer` for exactly-once guarantees. Agents optimizing for a quick setup may use `append()` and miss deduplication and batching.
301
+
302
+ See also: durable-streams/getting-started/SKILL.md § Common Mistakes
303
+
304
+ ## See also
305
+
306
+ - [getting-started](../getting-started/SKILL.md) — Basic stream creation and reading
307
+ - [go-to-production](../go-to-production/SKILL.md) — TTL configuration and production concerns
308
+
309
+ ## Version
310
+
311
+ Targets @durable-streams/client v0.2.1.
package/src/constants.ts CHANGED
@@ -26,6 +26,12 @@ export const STREAM_CURSOR_HEADER = `Stream-Cursor`
26
26
  */
27
27
  export const STREAM_UP_TO_DATE_HEADER = `Stream-Up-To-Date`
28
28
 
29
+ /**
30
+ * Response/request header indicating stream is closed (EOF).
31
+ * When present with value "true", the stream is permanently closed.
32
+ */
33
+ export const STREAM_CLOSED_HEADER = `Stream-Closed`
34
+
29
35
  // ============================================================================
30
36
  // Request Headers
31
37
  // ============================================================================
@@ -97,6 +103,11 @@ export const LIVE_QUERY_PARAM = `live`
97
103
  */
98
104
  export const CURSOR_QUERY_PARAM = `cursor`
99
105
 
106
+ /**
107
+ * Response header indicating SSE data encoding (e.g., base64 for binary streams).
108
+ */
109
+ export const STREAM_SSE_DATA_ENCODING_HEADER = `stream-sse-data-encoding`
110
+
100
111
  // ============================================================================
101
112
  // SSE Control Event Fields (camelCase per PROTOCOL.md Section 5.7)
102
113
  // ============================================================================
@@ -113,13 +124,19 @@ export const SSE_OFFSET_FIELD = `streamNextOffset`
113
124
  */
114
125
  export const SSE_CURSOR_FIELD = `streamCursor`
115
126
 
127
+ /**
128
+ * SSE control event field for stream closed state.
129
+ * Note: Different from HTTP header name (camelCase vs Header-Case).
130
+ */
131
+ export const SSE_CLOSED_FIELD = `streamClosed`
132
+
116
133
  // ============================================================================
117
134
  // Internal Constants
118
135
  // ============================================================================
119
136
 
120
137
  /**
121
- * Content types that support SSE mode.
122
- * SSE is only valid for text/* or application/json streams.
138
+ * Content types that are natively compatible with SSE (UTF-8 text).
139
+ * Binary content types are also supported via automatic base64 encoding.
123
140
  */
124
141
  export const SSE_COMPATIBLE_CONTENT_TYPES: ReadonlyArray<string> = [
125
142
  `text/`,
package/src/error.ts CHANGED
@@ -178,6 +178,26 @@ export class MissingStreamUrlError extends Error {
178
178
  }
179
179
  }
180
180
 
181
+ /**
182
+ * Error thrown when attempting to append to a closed stream.
183
+ */
184
+ export class StreamClosedError extends DurableStreamError {
185
+ readonly code = `STREAM_CLOSED` as const
186
+ readonly status = 409
187
+ readonly streamClosed = true
188
+
189
+ /**
190
+ * The final offset of the stream, if available from the response.
191
+ */
192
+ readonly finalOffset?: string
193
+
194
+ constructor(url?: string, finalOffset?: string) {
195
+ super(`Cannot append to closed stream`, `STREAM_CLOSED`, 409, url)
196
+ this.name = `StreamClosedError`
197
+ this.finalOffset = finalOffset
198
+ }
199
+ }
200
+
181
201
  /**
182
202
  * Error thrown when signal option is invalid.
183
203
  */
@@ -17,11 +17,12 @@ import {
17
17
  PRODUCER_ID_HEADER,
18
18
  PRODUCER_RECEIVED_SEQ_HEADER,
19
19
  PRODUCER_SEQ_HEADER,
20
+ STREAM_CLOSED_HEADER,
20
21
  STREAM_OFFSET_HEADER,
21
22
  } from "./constants"
22
23
  import type { queueAsPromised } from "fastq"
23
24
  import type { DurableStream } from "./stream"
24
- import type { IdempotentProducerOptions, Offset } from "./types"
25
+ import type { CloseResult, IdempotentProducerOptions, Offset } from "./types"
25
26
 
26
27
  /**
27
28
  * Error thrown when a producer's epoch is stale (zombie fencing).
@@ -138,6 +139,8 @@ export class IdempotentProducer {
138
139
  readonly #queue: queueAsPromised<BatchTask>
139
140
  readonly #maxInFlight: number
140
141
  #closed = false
142
+ #closeResult: CloseResult | null = null
143
+ #pendingFinalMessage?: Uint8Array | string
141
144
 
142
145
  // When autoClaim is true, we must wait for the first batch to complete
143
146
  // before allowing pipelining (to know what epoch was claimed)
@@ -318,11 +321,17 @@ export class IdempotentProducer {
318
321
  }
319
322
 
320
323
  /**
321
- * Flush pending messages and close the producer.
324
+ * Stop the producer without closing the underlying stream.
322
325
  *
323
- * After calling close(), further append() calls will throw.
326
+ * Use this when you want to:
327
+ * - Hand off writing to another producer
328
+ * - Keep the stream open for future writes
329
+ * - Stop this producer but not signal EOF to readers
330
+ *
331
+ * Flushes any pending messages before detaching.
332
+ * After calling detach(), further append() calls will throw.
324
333
  */
325
- async close(): Promise<void> {
334
+ async detach(): Promise<void> {
326
335
  if (this.#closed) return
327
336
 
328
337
  this.#closed = true
@@ -330,8 +339,138 @@ export class IdempotentProducer {
330
339
  try {
331
340
  await this.flush()
332
341
  } catch {
333
- // Ignore errors during close
342
+ // Ignore errors during detach
343
+ }
344
+ }
345
+
346
+ /**
347
+ * Flush pending messages and close the underlying stream (EOF).
348
+ *
349
+ * This is the typical way to end a producer session. It:
350
+ * 1. Flushes all pending messages
351
+ * 2. Optionally appends a final message
352
+ * 3. Closes the stream (no further appends permitted)
353
+ *
354
+ * **Idempotent**: Unlike `DurableStream.close({ body })`, this method is
355
+ * idempotent even with a final message because it uses producer headers
356
+ * for deduplication. Safe to retry on network failures.
357
+ *
358
+ * @param finalMessage - Optional final message to append atomically with close
359
+ * @returns CloseResult with the final offset
360
+ */
361
+ async close(finalMessage?: Uint8Array | string): Promise<CloseResult> {
362
+ if (this.#closed) {
363
+ // Already closed - return cached result for idempotency
364
+ if (this.#closeResult) {
365
+ return this.#closeResult
366
+ }
367
+ // Retry path: flush() threw on a previous attempt, so we need to re-run
368
+ // the entire close sequence with the stored finalMessage
369
+ await this.flush()
370
+ const result = await this.#doClose(this.#pendingFinalMessage)
371
+ this.#closeResult = result
372
+ return result
373
+ }
374
+
375
+ this.#closed = true
376
+
377
+ // Store finalMessage for retry safety (if flush() throws, we can retry)
378
+ this.#pendingFinalMessage = finalMessage
379
+
380
+ // Flush pending messages first
381
+ await this.flush()
382
+
383
+ // Close the stream with optional final message
384
+ const result = await this.#doClose(finalMessage)
385
+ this.#closeResult = result
386
+ return result
387
+ }
388
+
389
+ /**
390
+ * Actually close the stream with optional final message.
391
+ * Uses producer headers for idempotency.
392
+ */
393
+ async #doClose(finalMessage?: Uint8Array | string): Promise<CloseResult> {
394
+ const contentType = this.#stream.contentType ?? `application/octet-stream`
395
+ const isJson = normalizeContentType(contentType) === `application/json`
396
+
397
+ // Build body if final message is provided
398
+ let body: BodyInit | undefined
399
+ if (finalMessage !== undefined) {
400
+ const bodyBytes =
401
+ typeof finalMessage === `string`
402
+ ? new TextEncoder().encode(finalMessage)
403
+ : finalMessage
404
+
405
+ if (isJson) {
406
+ // For JSON mode, wrap in array
407
+ const jsonStr = new TextDecoder().decode(bodyBytes)
408
+ body = `[${jsonStr}]`
409
+ } else {
410
+ body = bodyBytes as unknown as BodyInit
411
+ }
412
+ }
413
+
414
+ // Capture the sequence number for this request (for retry safety)
415
+ // We only increment #nextSeq after a successful response
416
+ const seqForThisRequest = this.#nextSeq
417
+
418
+ // Build headers with producer info and Stream-Closed
419
+ const headers: Record<string, string> = {
420
+ "content-type": contentType,
421
+ [PRODUCER_ID_HEADER]: this.#producerId,
422
+ [PRODUCER_EPOCH_HEADER]: this.#epoch.toString(),
423
+ [PRODUCER_SEQ_HEADER]: seqForThisRequest.toString(),
424
+ [STREAM_CLOSED_HEADER]: `true`,
425
+ }
426
+
427
+ const response = await this.#fetchClient(this.#stream.url, {
428
+ method: `POST`,
429
+ headers,
430
+ body,
431
+ signal: this.#signal,
432
+ })
433
+
434
+ // Handle 204 (duplicate close - idempotent success)
435
+ if (response.status === 204) {
436
+ // Only increment seq on success (retry-safe)
437
+ this.#nextSeq = seqForThisRequest + 1
438
+ const finalOffset = response.headers.get(STREAM_OFFSET_HEADER) ?? ``
439
+ return { finalOffset }
440
+ }
441
+
442
+ // Handle success
443
+ if (response.status === 200) {
444
+ // Only increment seq on success (retry-safe)
445
+ this.#nextSeq = seqForThisRequest + 1
446
+ const finalOffset = response.headers.get(STREAM_OFFSET_HEADER) ?? ``
447
+ return { finalOffset }
334
448
  }
449
+
450
+ // Handle errors
451
+ if (response.status === 403) {
452
+ // Stale epoch
453
+ const currentEpochStr = response.headers.get(PRODUCER_EPOCH_HEADER)
454
+ const currentEpoch = currentEpochStr
455
+ ? parseInt(currentEpochStr, 10)
456
+ : this.#epoch
457
+
458
+ if (this.#autoClaim) {
459
+ // Auto-claim: retry with epoch+1
460
+ const newEpoch = currentEpoch + 1
461
+ this.#epoch = newEpoch
462
+ // Reset sequence for new epoch - set to 0 so the recursive call uses seq 0
463
+ // (the first operation in a new epoch should be seq 0)
464
+ this.#nextSeq = 0
465
+ return this.#doClose(finalMessage)
466
+ }
467
+
468
+ throw new StaleEpochError(currentEpoch)
469
+ }
470
+
471
+ // Other errors
472
+ const error = await FetchError.fromResponse(response, this.#stream.url)
473
+ throw error
335
474
  }
336
475
 
337
476
  /**
package/src/index.ts CHANGED
@@ -65,6 +65,10 @@ export type {
65
65
  HeadResult,
66
66
  LegacyLiveMode,
67
67
 
68
+ // Close types
69
+ CloseResult,
70
+ CloseOptions,
71
+
68
72
  // Idempotent producer types
69
73
  IdempotentProducerOptions,
70
74
  IdempotentAppendResult,
@@ -89,6 +93,7 @@ export {
89
93
  DurableStreamError,
90
94
  MissingStreamUrlError,
91
95
  InvalidSignalError,
96
+ StreamClosedError,
92
97
  } from "./error"
93
98
 
94
99
  // ============================================================================
@@ -110,6 +115,7 @@ export {
110
115
  STREAM_OFFSET_HEADER,
111
116
  STREAM_CURSOR_HEADER,
112
117
  STREAM_UP_TO_DATE_HEADER,
118
+ STREAM_CLOSED_HEADER,
113
119
  STREAM_SEQ_HEADER,
114
120
  STREAM_TTL_HEADER,
115
121
  STREAM_EXPIRES_AT_HEADER,
@@ -117,6 +123,7 @@ export {
117
123
  LIVE_QUERY_PARAM,
118
124
  CURSOR_QUERY_PARAM,
119
125
  SSE_COMPATIBLE_CONTENT_TYPES,
126
+ SSE_CLOSED_FIELD,
120
127
  DURABLE_STREAM_PROTOCOL_QUERY_PARAMS,
121
128
  // Idempotent producer headers
122
129
  PRODUCER_ID_HEADER,