@durable-streams/server 0.3.0 → 0.3.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/dist/index.cjs CHANGED
@@ -430,9 +430,9 @@ var StreamStore = class {
430
430
  epoch: options.producerEpoch,
431
431
  seq: options.producerSeq
432
432
  };
433
- this.notifyLongPollsClosed(path);
434
433
  }
435
434
  this.notifyLongPolls(path);
435
+ if (options.close) this.notifyLongPollsClosed(path);
436
436
  if (producerResult || options.close) return {
437
437
  message,
438
438
  producerResult,
@@ -984,6 +984,13 @@ var FileBackedStreamStore = class {
984
984
  * Key: "{streamPath}:{producerId}"
985
985
  */
986
986
  producerLocks = new Map();
987
+ /**
988
+ * Per-stream append locks. Serializes the read-modify-write of currentOffset
989
+ * across all concurrent appenders on the same stream so the LMDB-tracked
990
+ * offset cannot drift behind the file's actual byte position.
991
+ * Key: streamPath
992
+ */
993
+ streamAppendLocks = new Map();
987
994
  constructor(options) {
988
995
  this.dataDir = options.dataDir;
989
996
  this.db = (0, lmdb.open)({
@@ -1180,6 +1187,26 @@ var FileBackedStreamStore = class {
1180
1187
  };
1181
1188
  }
1182
1189
  /**
1190
+ * Acquire a per-stream append lock that serializes the read-modify-write
1191
+ * of currentOffset across all concurrent appenders on the same stream.
1192
+ * Without this, two concurrent appends can read the same starting
1193
+ * currentOffset, both compute their newOffset, both write a frame to the
1194
+ * file, but only one of their LMDB updates wins — leaving currentOffset
1195
+ * lagging the file's actual byte position. Returns a release function.
1196
+ */
1197
+ async acquireStreamAppendLock(streamPath) {
1198
+ while (this.streamAppendLocks.has(streamPath)) await this.streamAppendLocks.get(streamPath);
1199
+ let releaseLock;
1200
+ const lockPromise = new Promise((resolve) => {
1201
+ releaseLock = resolve;
1202
+ });
1203
+ this.streamAppendLocks.set(streamPath, lockPromise);
1204
+ return () => {
1205
+ this.streamAppendLocks.delete(streamPath);
1206
+ releaseLock();
1207
+ };
1208
+ }
1209
+ /**
1183
1210
  * Get the current epoch for a producer on a stream.
1184
1211
  * Returns undefined if the producer doesn't exist or stream not found.
1185
1212
  */
@@ -1437,7 +1464,20 @@ var FileBackedStreamStore = class {
1437
1464
  }
1438
1465
  }
1439
1466
  }
1467
+ /**
1468
+ * Public append entry point. Serializes concurrent appends to the same
1469
+ * stream so the read-modify-write of currentOffset cannot interleave —
1470
+ * see acquireStreamAppendLock for the underlying race.
1471
+ */
1440
1472
  async append(streamPath, data, options = {}) {
1473
+ const releaseLock = await this.acquireStreamAppendLock(streamPath);
1474
+ try {
1475
+ return await this.appendInner(streamPath, data, options);
1476
+ } finally {
1477
+ releaseLock();
1478
+ }
1479
+ }
1480
+ async appendInner(streamPath, data, options = {}) {
1441
1481
  const streamMeta = this.getMetaIfNotExpired(streamPath);
1442
1482
  if (!streamMeta) throw new Error(`Stream not found: ${streamPath}`);
1443
1483
  if (streamMeta.softDeleted) throw new Error(`Stream is soft-deleted: ${streamPath}`);
@@ -2282,13 +2322,13 @@ var DurableStreamTestServer = class {
2282
2322
  */
2283
2323
  async handleCreate(path, req, res) {
2284
2324
  let contentType = req.headers[`content-type`];
2285
- if (!contentType || contentType.trim() === `` || !/^[\w-]+\/[\w-]+/.test(contentType)) contentType = `application/octet-stream`;
2325
+ const forkedFromHeader = req.headers[STREAM_FORKED_FROM_HEADER.toLowerCase()];
2326
+ const forkOffsetHeader = req.headers[STREAM_FORK_OFFSET_HEADER.toLowerCase()];
2327
+ if (!contentType || contentType.trim() === `` || !/^[\w-]+\/[\w-]+/.test(contentType)) contentType = forkedFromHeader ? void 0 : `application/octet-stream`;
2286
2328
  const ttlHeader = req.headers[STREAM_TTL_HEADER.toLowerCase()];
2287
2329
  const expiresAtHeader = req.headers[STREAM_EXPIRES_AT_HEADER.toLowerCase()];
2288
2330
  const closedHeader = req.headers[STREAM_CLOSED_HEADER.toLowerCase()];
2289
2331
  const createClosed = closedHeader === `true`;
2290
- const forkedFromHeader = req.headers[STREAM_FORKED_FROM_HEADER.toLowerCase()];
2291
- const forkOffsetHeader = req.headers[STREAM_FORK_OFFSET_HEADER.toLowerCase()];
2292
2332
  if (ttlHeader && expiresAtHeader) {
2293
2333
  res.writeHead(400, { "content-type": `text/plain` });
2294
2334
  res.end(`Cannot specify both Stream-TTL and Stream-Expires-At`);
@@ -2363,14 +2403,15 @@ var DurableStreamTestServer = class {
2363
2403
  throw err;
2364
2404
  }
2365
2405
  const stream = this.store.get(path);
2406
+ const resolvedContentType = stream.contentType ?? contentType ?? `application/octet-stream`;
2366
2407
  if (isNew && this.options.onStreamCreated) await Promise.resolve(this.options.onStreamCreated({
2367
2408
  type: `created`,
2368
2409
  path,
2369
- contentType: stream.contentType ?? contentType,
2410
+ contentType: resolvedContentType,
2370
2411
  timestamp: Date.now()
2371
2412
  }));
2372
2413
  const headers = {
2373
- "content-type": stream.contentType ?? contentType,
2414
+ "content-type": resolvedContentType,
2374
2415
  [STREAM_OFFSET_HEADER]: stream.currentOffset
2375
2416
  };
2376
2417
  if (isNew) headers[`location`] = `${this._url}${path}`;
@@ -2621,7 +2662,7 @@ var DurableStreamTestServer = class {
2621
2662
  const result = await this.store.waitForMessages(path, currentOffset, this.options.longPollTimeout);
2622
2663
  this.store.touchAccess(path);
2623
2664
  if (this.isShuttingDown || !isConnected) break;
2624
- if (result.streamClosed) {
2665
+ if (result.streamClosed && result.messages.length === 0) {
2625
2666
  const finalControlData = {
2626
2667
  [SSE_OFFSET_FIELD]: currentOffset,
2627
2668
  [SSE_CLOSED_FIELD]: true
package/dist/index.d.cts CHANGED
@@ -475,6 +475,13 @@ declare class FileBackedStreamStore {
475
475
  * Key: "{streamPath}:{producerId}"
476
476
  */
477
477
  private producerLocks;
478
+ /**
479
+ * Per-stream append locks. Serializes the read-modify-write of currentOffset
480
+ * across all concurrent appenders on the same stream so the LMDB-tracked
481
+ * offset cannot drift behind the file's actual byte position.
482
+ * Key: streamPath
483
+ */
484
+ private streamAppendLocks;
478
485
  constructor(options: FileBackedStreamStoreOptions);
479
486
  /**
480
487
  * Recover streams from disk on startup.
@@ -505,6 +512,15 @@ declare class FileBackedStreamStore {
505
512
  */
506
513
  private acquireProducerLock;
507
514
  /**
515
+ * Acquire a per-stream append lock that serializes the read-modify-write
516
+ * of currentOffset across all concurrent appenders on the same stream.
517
+ * Without this, two concurrent appends can read the same starting
518
+ * currentOffset, both compute their newOffset, both write a frame to the
519
+ * file, but only one of their LMDB updates wins — leaving currentOffset
520
+ * lagging the file's actual byte position. Returns a release function.
521
+ */
522
+ private acquireStreamAppendLock;
523
+ /**
508
524
  * Get the current epoch for a producer on a stream.
509
525
  * Returns undefined if the producer doesn't exist or stream not found.
510
526
  */
@@ -550,9 +566,15 @@ declare class FileBackedStreamStore {
550
566
  * whose refcount drops to zero.
551
567
  */
552
568
  private deleteWithCascade;
569
+ /**
570
+ * Public append entry point. Serializes concurrent appends to the same
571
+ * stream so the read-modify-write of currentOffset cannot interleave —
572
+ * see acquireStreamAppendLock for the underlying race.
573
+ */
553
574
  append(streamPath: string, data: Uint8Array, options?: AppendOptions & {
554
575
  isInitialCreate?: boolean;
555
576
  }): Promise<StreamMessage | AppendResult | null>;
577
+ private appendInner;
556
578
  /**
557
579
  * Append with producer serialization for concurrent request handling.
558
580
  * This ensures that validation+append is atomic per producer.
package/dist/index.d.ts CHANGED
@@ -475,6 +475,13 @@ declare class FileBackedStreamStore {
475
475
  * Key: "{streamPath}:{producerId}"
476
476
  */
477
477
  private producerLocks;
478
+ /**
479
+ * Per-stream append locks. Serializes the read-modify-write of currentOffset
480
+ * across all concurrent appenders on the same stream so the LMDB-tracked
481
+ * offset cannot drift behind the file's actual byte position.
482
+ * Key: streamPath
483
+ */
484
+ private streamAppendLocks;
478
485
  constructor(options: FileBackedStreamStoreOptions);
479
486
  /**
480
487
  * Recover streams from disk on startup.
@@ -505,6 +512,15 @@ declare class FileBackedStreamStore {
505
512
  */
506
513
  private acquireProducerLock;
507
514
  /**
515
+ * Acquire a per-stream append lock that serializes the read-modify-write
516
+ * of currentOffset across all concurrent appenders on the same stream.
517
+ * Without this, two concurrent appends can read the same starting
518
+ * currentOffset, both compute their newOffset, both write a frame to the
519
+ * file, but only one of their LMDB updates wins — leaving currentOffset
520
+ * lagging the file's actual byte position. Returns a release function.
521
+ */
522
+ private acquireStreamAppendLock;
523
+ /**
508
524
  * Get the current epoch for a producer on a stream.
509
525
  * Returns undefined if the producer doesn't exist or stream not found.
510
526
  */
@@ -550,9 +566,15 @@ declare class FileBackedStreamStore {
550
566
  * whose refcount drops to zero.
551
567
  */
552
568
  private deleteWithCascade;
569
+ /**
570
+ * Public append entry point. Serializes concurrent appends to the same
571
+ * stream so the read-modify-write of currentOffset cannot interleave —
572
+ * see acquireStreamAppendLock for the underlying race.
573
+ */
553
574
  append(streamPath: string, data: Uint8Array, options?: AppendOptions & {
554
575
  isInitialCreate?: boolean;
555
576
  }): Promise<StreamMessage | AppendResult | null>;
577
+ private appendInner;
556
578
  /**
557
579
  * Append with producer serialization for concurrent request handling.
558
580
  * This ensures that validation+append is atomic per producer.
package/dist/index.js CHANGED
@@ -407,9 +407,9 @@ var StreamStore = class {
407
407
  epoch: options.producerEpoch,
408
408
  seq: options.producerSeq
409
409
  };
410
- this.notifyLongPollsClosed(path$2);
411
410
  }
412
411
  this.notifyLongPolls(path$2);
412
+ if (options.close) this.notifyLongPollsClosed(path$2);
413
413
  if (producerResult || options.close) return {
414
414
  message,
415
415
  producerResult,
@@ -961,6 +961,13 @@ var FileBackedStreamStore = class {
961
961
  * Key: "{streamPath}:{producerId}"
962
962
  */
963
963
  producerLocks = new Map();
964
+ /**
965
+ * Per-stream append locks. Serializes the read-modify-write of currentOffset
966
+ * across all concurrent appenders on the same stream so the LMDB-tracked
967
+ * offset cannot drift behind the file's actual byte position.
968
+ * Key: streamPath
969
+ */
970
+ streamAppendLocks = new Map();
964
971
  constructor(options) {
965
972
  this.dataDir = options.dataDir;
966
973
  this.db = open({
@@ -1157,6 +1164,26 @@ var FileBackedStreamStore = class {
1157
1164
  };
1158
1165
  }
1159
1166
  /**
1167
+ * Acquire a per-stream append lock that serializes the read-modify-write
1168
+ * of currentOffset across all concurrent appenders on the same stream.
1169
+ * Without this, two concurrent appends can read the same starting
1170
+ * currentOffset, both compute their newOffset, both write a frame to the
1171
+ * file, but only one of their LMDB updates wins — leaving currentOffset
1172
+ * lagging the file's actual byte position. Returns a release function.
1173
+ */
1174
+ async acquireStreamAppendLock(streamPath) {
1175
+ while (this.streamAppendLocks.has(streamPath)) await this.streamAppendLocks.get(streamPath);
1176
+ let releaseLock;
1177
+ const lockPromise = new Promise((resolve) => {
1178
+ releaseLock = resolve;
1179
+ });
1180
+ this.streamAppendLocks.set(streamPath, lockPromise);
1181
+ return () => {
1182
+ this.streamAppendLocks.delete(streamPath);
1183
+ releaseLock();
1184
+ };
1185
+ }
1186
+ /**
1160
1187
  * Get the current epoch for a producer on a stream.
1161
1188
  * Returns undefined if the producer doesn't exist or stream not found.
1162
1189
  */
@@ -1414,7 +1441,20 @@ var FileBackedStreamStore = class {
1414
1441
  }
1415
1442
  }
1416
1443
  }
1444
+ /**
1445
+ * Public append entry point. Serializes concurrent appends to the same
1446
+ * stream so the read-modify-write of currentOffset cannot interleave —
1447
+ * see acquireStreamAppendLock for the underlying race.
1448
+ */
1417
1449
  async append(streamPath, data, options = {}) {
1450
+ const releaseLock = await this.acquireStreamAppendLock(streamPath);
1451
+ try {
1452
+ return await this.appendInner(streamPath, data, options);
1453
+ } finally {
1454
+ releaseLock();
1455
+ }
1456
+ }
1457
+ async appendInner(streamPath, data, options = {}) {
1418
1458
  const streamMeta = this.getMetaIfNotExpired(streamPath);
1419
1459
  if (!streamMeta) throw new Error(`Stream not found: ${streamPath}`);
1420
1460
  if (streamMeta.softDeleted) throw new Error(`Stream is soft-deleted: ${streamPath}`);
@@ -2259,13 +2299,13 @@ var DurableStreamTestServer = class {
2259
2299
  */
2260
2300
  async handleCreate(path$2, req, res) {
2261
2301
  let contentType = req.headers[`content-type`];
2262
- if (!contentType || contentType.trim() === `` || !/^[\w-]+\/[\w-]+/.test(contentType)) contentType = `application/octet-stream`;
2302
+ const forkedFromHeader = req.headers[STREAM_FORKED_FROM_HEADER.toLowerCase()];
2303
+ const forkOffsetHeader = req.headers[STREAM_FORK_OFFSET_HEADER.toLowerCase()];
2304
+ if (!contentType || contentType.trim() === `` || !/^[\w-]+\/[\w-]+/.test(contentType)) contentType = forkedFromHeader ? void 0 : `application/octet-stream`;
2263
2305
  const ttlHeader = req.headers[STREAM_TTL_HEADER.toLowerCase()];
2264
2306
  const expiresAtHeader = req.headers[STREAM_EXPIRES_AT_HEADER.toLowerCase()];
2265
2307
  const closedHeader = req.headers[STREAM_CLOSED_HEADER.toLowerCase()];
2266
2308
  const createClosed = closedHeader === `true`;
2267
- const forkedFromHeader = req.headers[STREAM_FORKED_FROM_HEADER.toLowerCase()];
2268
- const forkOffsetHeader = req.headers[STREAM_FORK_OFFSET_HEADER.toLowerCase()];
2269
2309
  if (ttlHeader && expiresAtHeader) {
2270
2310
  res.writeHead(400, { "content-type": `text/plain` });
2271
2311
  res.end(`Cannot specify both Stream-TTL and Stream-Expires-At`);
@@ -2340,14 +2380,15 @@ var DurableStreamTestServer = class {
2340
2380
  throw err;
2341
2381
  }
2342
2382
  const stream = this.store.get(path$2);
2383
+ const resolvedContentType = stream.contentType ?? contentType ?? `application/octet-stream`;
2343
2384
  if (isNew && this.options.onStreamCreated) await Promise.resolve(this.options.onStreamCreated({
2344
2385
  type: `created`,
2345
2386
  path: path$2,
2346
- contentType: stream.contentType ?? contentType,
2387
+ contentType: resolvedContentType,
2347
2388
  timestamp: Date.now()
2348
2389
  }));
2349
2390
  const headers = {
2350
- "content-type": stream.contentType ?? contentType,
2391
+ "content-type": resolvedContentType,
2351
2392
  [STREAM_OFFSET_HEADER]: stream.currentOffset
2352
2393
  };
2353
2394
  if (isNew) headers[`location`] = `${this._url}${path$2}`;
@@ -2598,7 +2639,7 @@ var DurableStreamTestServer = class {
2598
2639
  const result = await this.store.waitForMessages(path$2, currentOffset, this.options.longPollTimeout);
2599
2640
  this.store.touchAccess(path$2);
2600
2641
  if (this.isShuttingDown || !isConnected) break;
2601
- if (result.streamClosed) {
2642
+ if (result.streamClosed && result.messages.length === 0) {
2602
2643
  const finalControlData = {
2603
2644
  [SSE_OFFSET_FIELD]: currentOffset,
2604
2645
  [SSE_CLOSED_FIELD]: true
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@durable-streams/server",
3
- "version": "0.3.0",
3
+ "version": "0.3.2",
4
4
  "description": "Node.js reference server implementation for Durable Streams",
5
5
  "author": "Durable Stream contributors",
6
6
  "license": "Apache-2.0",
@@ -39,15 +39,15 @@
39
39
  "dependencies": {
40
40
  "@neophi/sieve-cache": "^1.0.0",
41
41
  "lmdb": "^3.3.0",
42
- "@durable-streams/client": "0.2.3",
43
- "@durable-streams/state": "0.2.4"
42
+ "@durable-streams/client": "0.2.4",
43
+ "@durable-streams/state": "0.2.6"
44
44
  },
45
45
  "devDependencies": {
46
46
  "@types/node": "^22.0.0",
47
47
  "tsdown": "^0.9.0",
48
48
  "typescript": "^5.0.0",
49
49
  "vitest": "^4.0.0",
50
- "@durable-streams/server-conformance-tests": "0.3.0"
50
+ "@durable-streams/server-conformance-tests": "0.3.1"
51
51
  },
52
52
  "files": [
53
53
  "dist",
package/src/file-store.ts CHANGED
@@ -232,6 +232,13 @@ export class FileBackedStreamStore {
232
232
  * Key: "{streamPath}:{producerId}"
233
233
  */
234
234
  private producerLocks = new Map<string, Promise<unknown>>()
235
+ /**
236
+ * Per-stream append locks. Serializes the read-modify-write of currentOffset
237
+ * across all concurrent appenders on the same stream so the LMDB-tracked
238
+ * offset cannot drift behind the file's actual byte position.
239
+ * Key: streamPath
240
+ */
241
+ private streamAppendLocks = new Map<string, Promise<unknown>>()
235
242
 
236
243
  constructor(options: FileBackedStreamStoreOptions) {
237
244
  this.dataDir = options.dataDir
@@ -535,6 +542,33 @@ export class FileBackedStreamStore {
535
542
  }
536
543
  }
537
544
 
545
+ /**
546
+ * Acquire a per-stream append lock that serializes the read-modify-write
547
+ * of currentOffset across all concurrent appenders on the same stream.
548
+ * Without this, two concurrent appends can read the same starting
549
+ * currentOffset, both compute their newOffset, both write a frame to the
550
+ * file, but only one of their LMDB updates wins — leaving currentOffset
551
+ * lagging the file's actual byte position. Returns a release function.
552
+ */
553
+ private async acquireStreamAppendLock(
554
+ streamPath: string
555
+ ): Promise<() => void> {
556
+ while (this.streamAppendLocks.has(streamPath)) {
557
+ await this.streamAppendLocks.get(streamPath)
558
+ }
559
+
560
+ let releaseLock: () => void
561
+ const lockPromise = new Promise<void>((resolve) => {
562
+ releaseLock = resolve
563
+ })
564
+ this.streamAppendLocks.set(streamPath, lockPromise)
565
+
566
+ return () => {
567
+ this.streamAppendLocks.delete(streamPath)
568
+ releaseLock!()
569
+ }
570
+ }
571
+
538
572
  /**
539
573
  * Get the current epoch for a producer on a stream.
540
574
  * Returns undefined if the producer doesn't exist or stream not found.
@@ -985,10 +1019,28 @@ export class FileBackedStreamStore {
985
1019
  }
986
1020
  }
987
1021
 
1022
+ /**
1023
+ * Public append entry point. Serializes concurrent appends to the same
1024
+ * stream so the read-modify-write of currentOffset cannot interleave —
1025
+ * see acquireStreamAppendLock for the underlying race.
1026
+ */
988
1027
  async append(
989
1028
  streamPath: string,
990
1029
  data: Uint8Array,
991
1030
  options: AppendOptions & { isInitialCreate?: boolean } = {}
1031
+ ): Promise<StreamMessage | AppendResult | null> {
1032
+ const releaseLock = await this.acquireStreamAppendLock(streamPath)
1033
+ try {
1034
+ return await this.appendInner(streamPath, data, options)
1035
+ } finally {
1036
+ releaseLock()
1037
+ }
1038
+ }
1039
+
1040
+ private async appendInner(
1041
+ streamPath: string,
1042
+ data: Uint8Array,
1043
+ options: AppendOptions & { isInitialCreate?: boolean } = {}
992
1044
  ): Promise<StreamMessage | AppendResult | null> {
993
1045
  const streamMeta = this.getMetaIfNotExpired(streamPath)
994
1046
 
package/src/server.ts CHANGED
@@ -562,13 +562,24 @@ export class DurableStreamTestServer {
562
562
  ): Promise<void> {
563
563
  let contentType = req.headers[`content-type`]
564
564
 
565
- // Sanitize content-type: if empty or invalid, use default
565
+ // Parse fork headers (must come before content-type sanitization so
566
+ // forks can fall through to the store's content-type inheritance)
567
+ const forkedFromHeader = req.headers[
568
+ STREAM_FORKED_FROM_HEADER.toLowerCase()
569
+ ] as string | undefined
570
+ const forkOffsetHeader = req.headers[
571
+ STREAM_FORK_OFFSET_HEADER.toLowerCase()
572
+ ] as string | undefined
573
+
574
+ // Sanitize content-type: if empty or invalid, use default — but only
575
+ // for non-fork creates. For forks, an omitted Content-Type means "inherit
576
+ // from source", which is resolved by the store.
566
577
  if (
567
578
  !contentType ||
568
579
  contentType.trim() === `` ||
569
580
  !/^[\w-]+\/[\w-]+/.test(contentType)
570
581
  ) {
571
- contentType = `application/octet-stream`
582
+ contentType = forkedFromHeader ? undefined : `application/octet-stream`
572
583
  }
573
584
 
574
585
  const ttlHeader = req.headers[STREAM_TTL_HEADER.toLowerCase()] as
@@ -582,14 +593,6 @@ export class DurableStreamTestServer {
582
593
  const closedHeader = req.headers[STREAM_CLOSED_HEADER.toLowerCase()]
583
594
  const createClosed = closedHeader === `true`
584
595
 
585
- // Parse fork headers
586
- const forkedFromHeader = req.headers[
587
- STREAM_FORKED_FROM_HEADER.toLowerCase()
588
- ] as string | undefined
589
- const forkOffsetHeader = req.headers[
590
- STREAM_FORK_OFFSET_HEADER.toLowerCase()
591
- ] as string | undefined
592
-
593
596
  // Validate TTL and Expires-At headers
594
597
  if (ttlHeader && expiresAtHeader) {
595
598
  res.writeHead(400, { "content-type": `text/plain` })
@@ -681,6 +684,8 @@ export class DurableStreamTestServer {
681
684
  }
682
685
 
683
686
  const stream = this.store.get(path)!
687
+ const resolvedContentType =
688
+ stream.contentType ?? contentType ?? `application/octet-stream`
684
689
 
685
690
  // Call lifecycle hook for new streams
686
691
  if (isNew && this.options.onStreamCreated) {
@@ -688,7 +693,7 @@ export class DurableStreamTestServer {
688
693
  this.options.onStreamCreated({
689
694
  type: `created`,
690
695
  path,
691
- contentType: stream.contentType ?? contentType,
696
+ contentType: resolvedContentType,
692
697
  timestamp: Date.now(),
693
698
  })
694
699
  )
@@ -696,7 +701,7 @@ export class DurableStreamTestServer {
696
701
 
697
702
  // Return 201 for new streams, 200 for idempotent creates
698
703
  const headers: Record<string, string> = {
699
- "content-type": stream.contentType ?? contentType,
704
+ "content-type": resolvedContentType,
700
705
  [STREAM_OFFSET_HEADER]: stream.currentOffset,
701
706
  }
702
707
 
@@ -1182,8 +1187,10 @@ export class DurableStreamTestServer {
1182
1187
  // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
1183
1188
  if (this.isShuttingDown || !isConnected) break
1184
1189
 
1185
- // Check if stream was closed during wait
1186
- if (result.streamClosed) {
1190
+ // Check if stream was closed during wait. If the close also appended
1191
+ // final data, let the next loop iteration deliver those messages
1192
+ // before emitting the streamClosed control event.
1193
+ if (result.streamClosed && result.messages.length === 0) {
1187
1194
  const finalControlData: Record<string, string | boolean> = {
1188
1195
  [SSE_OFFSET_FIELD]: currentOffset,
1189
1196
  [SSE_CLOSED_FIELD]: true,
package/src/store.ts CHANGED
@@ -723,13 +723,17 @@ export class StreamStore {
723
723
  seq: options.producerSeq!,
724
724
  }
725
725
  }
726
- // Notify pending long-polls that stream is closed
727
- this.notifyLongPollsClosed(path)
728
726
  }
729
727
 
730
- // Notify any pending long-polls of new messages
728
+ // Notify pending long-polls of new messages before empty close signals.
729
+ // Append-and-close must deliver the final message with streamClosed
730
+ // metadata instead of waking readers with an empty close event first.
731
731
  this.notifyLongPolls(path)
732
732
 
733
+ if (options.close) {
734
+ this.notifyLongPollsClosed(path)
735
+ }
736
+
733
737
  // Return AppendResult if producer headers were used or stream was closed
734
738
  if (producerResult || options.close) {
735
739
  return {