@durable-streams/server 0.3.3 → 0.3.5

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
@@ -170,13 +170,17 @@ var StreamStore = class {
170
170
  const closedMatches = (options.closed ?? false) === (existingRaw.closed ?? false);
171
171
  const forkedFromMatches = (options.forkedFrom ?? void 0) === existingRaw.forkedFrom;
172
172
  const forkOffsetMatches = options.forkOffset === void 0 || options.forkOffset === existingRaw.forkOffset;
173
- if (contentTypeMatches && ttlMatches && expiresMatches && closedMatches && forkedFromMatches && forkOffsetMatches) return existingRaw;
173
+ const requestedSub = options.forkSubOffset ?? 0;
174
+ const existingSub = existingRaw.forkSubOffset ?? 0;
175
+ const forkSubOffsetMatches = requestedSub === existingSub;
176
+ if (contentTypeMatches && ttlMatches && expiresMatches && closedMatches && forkedFromMatches && forkOffsetMatches && forkSubOffsetMatches) return existingRaw;
174
177
  else throw new Error(`Stream already exists with different configuration: ${path}`);
175
178
  }
176
179
  const isFork = !!options.forkedFrom;
177
180
  let forkOffset = `0000000000000000_0000000000000000`;
178
181
  let sourceContentType;
179
182
  let sourceStream;
183
+ let forkSubOffsetPrefix;
180
184
  if (isFork) {
181
185
  sourceStream = this.streams.get(options.forkedFrom);
182
186
  if (!sourceStream) throw new Error(`Source stream not found: ${options.forkedFrom}`);
@@ -187,6 +191,7 @@ var StreamStore = class {
187
191
  else forkOffset = sourceStream.currentOffset;
188
192
  const zeroOffset = `0000000000000000_0000000000000000`;
189
193
  if (forkOffset < zeroOffset || sourceStream.currentOffset < forkOffset) throw new Error(`Invalid fork offset: ${forkOffset}`);
194
+ if (options.forkSubOffset && options.forkSubOffset > 0) forkSubOffsetPrefix = this.resolveForkSubOffset(sourceStream, forkOffset, options.forkSubOffset, normalizeContentType(sourceContentType) === `application/json`);
190
195
  sourceStream.refCount++;
191
196
  }
192
197
  let contentType = options.contentType;
@@ -214,6 +219,20 @@ var StreamStore = class {
214
219
  forkedFrom: isFork ? options.forkedFrom : void 0,
215
220
  forkOffset: isFork ? forkOffset : void 0
216
221
  };
222
+ if (forkSubOffsetPrefix && forkSubOffsetPrefix.length > 0) {
223
+ const parts = stream.currentOffset.split(`_`).map(Number);
224
+ const readSeq = parts[0];
225
+ const byteOffset = parts[1];
226
+ const newByteOffset = byteOffset + forkSubOffsetPrefix.length + 5;
227
+ const newOffset = `${String(readSeq).padStart(16, `0`)}_${String(newByteOffset).padStart(16, `0`)}`;
228
+ stream.messages.push({
229
+ data: forkSubOffsetPrefix,
230
+ offset: newOffset,
231
+ timestamp: Date.now()
232
+ });
233
+ stream.currentOffset = newOffset;
234
+ stream.forkSubOffset = options.forkSubOffset;
235
+ }
217
236
  if (options.initialData && options.initialData.length > 0) try {
218
237
  this.appendToStream(stream, options.initialData, true);
219
238
  } catch (err) {
@@ -737,6 +756,36 @@ var StreamStore = class {
737
756
  list() {
738
757
  return Array.from(this.streams.keys());
739
758
  }
759
+ /**
760
+ * Resolve a sub-offset against a source stream and return the prefix bytes
761
+ * to materialize as the fork's first own message. Reads from the source
762
+ * (across its fork chain if any) starting at forkOffset; the first message
763
+ * returned is the one that starts at forkOffset. Throws if the sub-offset
764
+ * cannot be satisfied (no message past forkOffset, or overshoots its
765
+ * content extent).
766
+ */
767
+ resolveForkSubOffset(sourceStream, forkOffset, subOffset, isJSON) {
768
+ let sourceMessages;
769
+ if (sourceStream.forkedFrom) sourceMessages = [...this.readForkedMessages(sourceStream.forkedFrom, forkOffset, sourceStream.forkOffset), ...this.readOwnMessages(sourceStream, forkOffset)];
770
+ else sourceMessages = this.readOwnMessages(sourceStream, forkOffset);
771
+ if (sourceMessages.length === 0) throw new Error(`Invalid fork sub-offset: no data past forkOffset`);
772
+ const first = sourceMessages[0];
773
+ if (isJSON) {
774
+ const text = new TextDecoder().decode(first.data);
775
+ const trimmed = text.endsWith(`,`) ? text.slice(0, -1) : text;
776
+ let values;
777
+ try {
778
+ values = JSON.parse(`[${trimmed}]`);
779
+ } catch {
780
+ throw new Error(`Invalid fork sub-offset: source JSON is unparseable`);
781
+ }
782
+ if (subOffset > values.length) throw new Error(`Invalid fork sub-offset: overshoots source message count`);
783
+ const prefix = values.slice(0, subOffset).map((v) => JSON.stringify(v));
784
+ return new TextEncoder().encode(prefix.join(`,`) + `,`);
785
+ }
786
+ if (subOffset > first.data.length) throw new Error(`Invalid fork sub-offset: overshoots source message length`);
787
+ return first.data.slice(0, subOffset);
788
+ }
740
789
  appendToStream(stream, data, isInitialCreate = false) {
741
790
  let processedData = data;
742
791
  if (normalizeContentType(stream.contentType) === `application/json`) {
@@ -1318,13 +1367,17 @@ var FileBackedStreamStore = class {
1318
1367
  const closedMatches = (options.closed ?? false) === (existingRaw.closed ?? false);
1319
1368
  const forkedFromMatches = (options.forkedFrom ?? void 0) === existingRaw.forkedFrom;
1320
1369
  const forkOffsetMatches = options.forkOffset === void 0 || options.forkOffset === existingRaw.forkOffset;
1321
- if (contentTypeMatches && ttlMatches && expiresMatches && closedMatches && forkedFromMatches && forkOffsetMatches) return this.streamMetaToStream(existingRaw);
1370
+ const requestedSub = options.forkSubOffset ?? 0;
1371
+ const existingSub = existingRaw.forkSubOffset ?? 0;
1372
+ const forkSubOffsetMatches = requestedSub === existingSub;
1373
+ if (contentTypeMatches && ttlMatches && expiresMatches && closedMatches && forkedFromMatches && forkOffsetMatches && forkSubOffsetMatches) return this.streamMetaToStream(existingRaw);
1322
1374
  else throw new Error(`Stream already exists with different configuration: ${streamPath}`);
1323
1375
  }
1324
1376
  const isFork = !!options.forkedFrom;
1325
1377
  let forkOffset = `0000000000000000_0000000000000000`;
1326
1378
  let sourceContentType;
1327
1379
  let sourceMeta;
1380
+ let forkSubOffsetPrefix;
1328
1381
  if (isFork) {
1329
1382
  const sourceKey = `stream:${options.forkedFrom}`;
1330
1383
  sourceMeta = this.db.get(sourceKey);
@@ -1336,6 +1389,7 @@ var FileBackedStreamStore = class {
1336
1389
  else forkOffset = sourceMeta.currentOffset;
1337
1390
  const zeroOffset = `0000000000000000_0000000000000000`;
1338
1391
  if (forkOffset < zeroOffset || sourceMeta.currentOffset < forkOffset) throw new Error(`Invalid fork offset: ${forkOffset}`);
1392
+ if (options.forkSubOffset && options.forkSubOffset > 0) forkSubOffsetPrefix = this.resolveForkSubOffset(options.forkedFrom, forkOffset, options.forkSubOffset, normalizeContentType(sourceContentType) === `application/json`);
1339
1393
  const freshSource = this.db.get(sourceKey);
1340
1394
  const updatedSource = {
1341
1395
  ...freshSource,
@@ -1371,11 +1425,39 @@ var FileBackedStreamStore = class {
1371
1425
  closed: false,
1372
1426
  forkedFrom: isFork ? options.forkedFrom : void 0,
1373
1427
  forkOffset: isFork ? forkOffset : void 0,
1428
+ forkSubOffset: void 0,
1374
1429
  refCount: 0
1375
1430
  };
1376
1431
  const tAfterMeta = performance.now();
1377
1432
  const segmentPath = segmentFile(this.dataDir, streamMeta.directoryName);
1378
1433
  try {
1434
+ if (forkSubOffsetPrefix && forkSubOffsetPrefix.length > 0) {
1435
+ const lengthBuf = Buffer.allocUnsafe(4);
1436
+ lengthBuf.writeUInt32BE(forkSubOffsetPrefix.length, 0);
1437
+ const frameBuf = Buffer.concat([
1438
+ lengthBuf,
1439
+ Buffer.from(forkSubOffsetPrefix),
1440
+ Buffer.from(`\n`)
1441
+ ]);
1442
+ const fd = node_fs.openSync(segmentPath, `wx`);
1443
+ try {
1444
+ let written = 0;
1445
+ while (written < frameBuf.length) {
1446
+ const bytesWritten = node_fs.writeSync(fd, frameBuf, written, frameBuf.length - written, written);
1447
+ if (bytesWritten === 0) throw new Error(`failed to write sub-offset prefix frame`);
1448
+ written += bytesWritten;
1449
+ }
1450
+ node_fs.fsyncSync(fd);
1451
+ } finally {
1452
+ node_fs.closeSync(fd);
1453
+ }
1454
+ const parts = streamMeta.currentOffset.split(`_`).map(Number);
1455
+ const readSeq = parts[0];
1456
+ const byteOffset = parts[1];
1457
+ const newByteOffset = byteOffset + forkSubOffsetPrefix.length + 5;
1458
+ streamMeta.currentOffset = `${String(readSeq).padStart(16, `0`)}_${String(newByteOffset).padStart(16, `0`)}`;
1459
+ streamMeta.forkSubOffset = options.forkSubOffset;
1460
+ }
1379
1461
  await this.db.put(key, streamMeta);
1380
1462
  } catch (err) {
1381
1463
  if (isFork && sourceMeta) {
@@ -1389,7 +1471,7 @@ var FileBackedStreamStore = class {
1389
1471
  this.db.putSync(sourceKey, updatedSource);
1390
1472
  }
1391
1473
  }
1392
- serverLog.error(`[FileBackedStreamStore] Error creating stream (LMDB put):`, err);
1474
+ serverLog.error(`[FileBackedStreamStore] Error creating stream before metadata commit:`, err);
1393
1475
  throw err;
1394
1476
  }
1395
1477
  const tAfterLmdb = performance.now();
@@ -1774,6 +1856,35 @@ var FileBackedStreamStore = class {
1774
1856
  messages.push(...ownMessages);
1775
1857
  return messages;
1776
1858
  }
1859
+ /**
1860
+ * Resolve a fork sub-offset against the source: read the message that
1861
+ * starts at forkOffset and return prefix bytes to materialize as the
1862
+ * fork's first own message. For JSON, parses comma-joined values.
1863
+ */
1864
+ resolveForkSubOffset(sourcePath, forkOffset, subOffset, isJSON) {
1865
+ const forkByte = Number(forkOffset.split(`_`)[1] ?? 0);
1866
+ const sourceMeta = this.db.get(`stream:${sourcePath}`);
1867
+ if (!sourceMeta) throw new Error(`Source stream not found: ${sourcePath}`);
1868
+ const currentByte = Number(sourceMeta.currentOffset.split(`_`)[1] ?? 0);
1869
+ const messages = this.readForkedMessages(sourcePath, forkByte, currentByte);
1870
+ if (messages.length === 0) throw new Error(`Invalid fork sub-offset: no data past forkOffset`);
1871
+ const first = messages[0];
1872
+ if (isJSON) {
1873
+ const text = new TextDecoder().decode(first.data);
1874
+ const trimmed = text.endsWith(`,`) ? text.slice(0, -1) : text;
1875
+ let values;
1876
+ try {
1877
+ values = JSON.parse(`[${trimmed}]`);
1878
+ } catch {
1879
+ throw new Error(`Invalid fork sub-offset: source JSON is unparseable`);
1880
+ }
1881
+ if (subOffset > values.length) throw new Error(`Invalid fork sub-offset: overshoots source message count`);
1882
+ const prefix = values.slice(0, subOffset).map((v) => JSON.stringify(v));
1883
+ return new TextEncoder().encode(prefix.join(`,`) + `,`);
1884
+ }
1885
+ if (subOffset > first.data.length) throw new Error(`Invalid fork sub-offset: overshoots source message length`);
1886
+ return first.data.slice(0, subOffset);
1887
+ }
1777
1888
  read(streamPath, offset) {
1778
1889
  const streamMeta = this.getMetaIfNotExpired(streamPath);
1779
1890
  if (!streamMeta) throw new Error(`Stream not found: ${streamPath}`);
@@ -3030,6 +3141,7 @@ const STREAM_SSE_DATA_ENCODING_HEADER = `Stream-SSE-Data-Encoding`;
3030
3141
  const SSE_UP_TO_DATE_FIELD = `upToDate`;
3031
3142
  const STREAM_FORKED_FROM_HEADER = `Stream-Forked-From`;
3032
3143
  const STREAM_FORK_OFFSET_HEADER = `Stream-Fork-Offset`;
3144
+ const STREAM_FORK_SUB_OFFSET_HEADER = `Stream-Fork-Sub-Offset`;
3033
3145
  /**
3034
3146
  * Encode data for SSE format.
3035
3147
  * Per SSE spec, each line in the payload needs its own "data:" prefix.
@@ -3255,7 +3367,7 @@ var DurableStreamTestServer = class {
3255
3367
  const method = req.method?.toUpperCase();
3256
3368
  res.setHeader(`access-control-allow-origin`, `*`);
3257
3369
  res.setHeader(`access-control-allow-methods`, `GET, POST, PUT, DELETE, HEAD, OPTIONS`);
3258
- res.setHeader(`access-control-allow-headers`, `content-type, authorization, Stream-Seq, Stream-TTL, Stream-Expires-At, Stream-Closed, Producer-Id, Producer-Epoch, Producer-Seq, Stream-Forked-From, Stream-Fork-Offset`);
3370
+ res.setHeader(`access-control-allow-headers`, `content-type, authorization, Stream-Seq, Stream-TTL, Stream-Expires-At, Stream-Closed, Producer-Id, Producer-Epoch, Producer-Seq, Stream-Forked-From, Stream-Fork-Offset, Stream-Fork-Sub-Offset`);
3259
3371
  res.setHeader(`access-control-expose-headers`, `Stream-Next-Offset, Stream-Cursor, Stream-Up-To-Date, Stream-Closed, Producer-Epoch, Producer-Seq, Producer-Expected-Seq, Producer-Received-Seq, etag, content-type, content-encoding, vary`);
3260
3372
  res.setHeader(`x-content-type-options`, `nosniff`);
3261
3373
  res.setHeader(`cross-origin-resource-policy`, `cross-origin`);
@@ -3345,6 +3457,9 @@ var DurableStreamTestServer = class {
3345
3457
  let contentType = req.headers[`content-type`];
3346
3458
  const forkedFromHeader = req.headers[STREAM_FORKED_FROM_HEADER.toLowerCase()];
3347
3459
  const forkOffsetHeader = req.headers[STREAM_FORK_OFFSET_HEADER.toLowerCase()];
3460
+ const forkSubOffsetHeaderRaw = req.headers[STREAM_FORK_SUB_OFFSET_HEADER.toLowerCase()];
3461
+ const forkSubOffsetHeaderPresent = forkSubOffsetHeaderRaw !== void 0;
3462
+ const forkSubOffsetHeader = Array.isArray(forkSubOffsetHeaderRaw) ? forkSubOffsetHeaderRaw[0] : forkSubOffsetHeaderRaw;
3348
3463
  if (!contentType || contentType.trim() === `` || !/^[\w-]+\/[\w-]+/.test(contentType)) contentType = forkedFromHeader ? void 0 : `application/octet-stream`;
3349
3464
  const ttlHeader = req.headers[__durable_streams_client.STREAM_TTL_HEADER.toLowerCase()];
3350
3465
  const expiresAtHeader = req.headers[__durable_streams_client.STREAM_EXPIRES_AT_HEADER.toLowerCase()];
@@ -3386,6 +3501,21 @@ var DurableStreamTestServer = class {
3386
3501
  return;
3387
3502
  }
3388
3503
  }
3504
+ let forkSubOffset;
3505
+ if (forkSubOffsetHeaderPresent) {
3506
+ if (!forkedFromHeader) {
3507
+ res.writeHead(400, { "content-type": `text/plain` });
3508
+ res.end(`Stream-Fork-Sub-Offset requires Stream-Forked-From`);
3509
+ return;
3510
+ }
3511
+ const subOffsetPattern = /^(0|[1-9]\d*)$/;
3512
+ if (forkSubOffsetHeader === void 0 || !subOffsetPattern.test(forkSubOffsetHeader)) {
3513
+ res.writeHead(400, { "content-type": `text/plain` });
3514
+ res.end(`Invalid Stream-Fork-Sub-Offset format`);
3515
+ return;
3516
+ }
3517
+ forkSubOffset = parseInt(forkSubOffsetHeader, 10);
3518
+ }
3389
3519
  const body = await this.readBody(req);
3390
3520
  const isNew = !this.store.has(path);
3391
3521
  try {
@@ -3396,7 +3526,8 @@ var DurableStreamTestServer = class {
3396
3526
  initialData: body.length > 0 ? body : void 0,
3397
3527
  closed: createClosed,
3398
3528
  forkedFrom: forkedFromHeader,
3399
- forkOffset: forkOffsetHeader
3529
+ forkOffset: forkOffsetHeader,
3530
+ forkSubOffset
3400
3531
  }));
3401
3532
  } catch (err) {
3402
3533
  if (err instanceof Error) {
@@ -3405,6 +3536,11 @@ var DurableStreamTestServer = class {
3405
3536
  res.end(`Source stream not found`);
3406
3537
  return;
3407
3538
  }
3539
+ if (err.message.includes(`Invalid fork sub-offset`)) {
3540
+ res.writeHead(400, { "content-type": `text/plain` });
3541
+ res.end(`Invalid fork sub-offset`);
3542
+ return;
3543
+ }
3408
3544
  if (err.message.includes(`Invalid fork offset`)) {
3409
3545
  res.writeHead(400, { "content-type": `text/plain` });
3410
3546
  res.end(`Fork offset beyond source stream length`);
package/dist/index.d.cts CHANGED
@@ -99,6 +99,13 @@ interface Stream {
99
99
  */
100
100
  forkOffset?: string;
101
101
  /**
102
+ * User-supplied sub-offset value refining `forkOffset` (Section 4.2 of
103
+ * PROTOCOL.md). Stored verbatim for idempotent re-creation matching:
104
+ * bytes for non-JSON forks, flattened message count for JSON forks.
105
+ * `undefined` and `0` are equivalent.
106
+ */
107
+ forkSubOffset?: number;
108
+ /**
102
109
  * Number of forks referencing this stream.
103
110
  * Defaults to 0.
104
111
  */
@@ -313,6 +320,7 @@ declare class StreamStore {
313
320
  closed?: boolean;
314
321
  forkedFrom?: string;
315
322
  forkOffset?: string;
323
+ forkSubOffset?: number;
316
324
  }): Stream;
317
325
  /**
318
326
  * Resolve fork expiry per the decision table.
@@ -459,6 +467,15 @@ declare class StreamStore {
459
467
  * Get all stream paths.
460
468
  */
461
469
  list(): Array<string>;
470
+ /**
471
+ * Resolve a sub-offset against a source stream and return the prefix bytes
472
+ * to materialize as the fork's first own message. Reads from the source
473
+ * (across its fork chain if any) starting at forkOffset; the first message
474
+ * returned is the one that starts at forkOffset. Throws if the sub-offset
475
+ * cannot be satisfied (no message past forkOffset, or overshoots its
476
+ * content extent).
477
+ */
478
+ private resolveForkSubOffset;
462
479
  private appendToStream;
463
480
  private findOffsetIndex;
464
481
  private notifyLongPolls;
@@ -571,6 +588,7 @@ declare class FileBackedStreamStore {
571
588
  closed?: boolean;
572
589
  forkedFrom?: string;
573
590
  forkOffset?: string;
591
+ forkSubOffset?: number;
574
592
  }): Promise<Stream>;
575
593
  get(streamPath: string): Stream | undefined;
576
594
  has(streamPath: string): boolean;
@@ -631,6 +649,12 @@ declare class FileBackedStreamStore {
631
649
  * Does NOT check softDeleted -- forks must read through soft-deleted sources.
632
650
  */
633
651
  private readForkedMessages;
652
+ /**
653
+ * Resolve a fork sub-offset against the source: read the message that
654
+ * starts at forkOffset and return prefix bytes to materialize as the
655
+ * fork's first own message. For JSON, parses comma-joined values.
656
+ */
657
+ private resolveForkSubOffset;
634
658
  read(streamPath: string, offset?: string): {
635
659
  messages: Array<StreamMessage>;
636
660
  upToDate: boolean;
package/dist/index.d.ts CHANGED
@@ -99,6 +99,13 @@ interface Stream {
99
99
  */
100
100
  forkOffset?: string;
101
101
  /**
102
+ * User-supplied sub-offset value refining `forkOffset` (Section 4.2 of
103
+ * PROTOCOL.md). Stored verbatim for idempotent re-creation matching:
104
+ * bytes for non-JSON forks, flattened message count for JSON forks.
105
+ * `undefined` and `0` are equivalent.
106
+ */
107
+ forkSubOffset?: number;
108
+ /**
102
109
  * Number of forks referencing this stream.
103
110
  * Defaults to 0.
104
111
  */
@@ -313,6 +320,7 @@ declare class StreamStore {
313
320
  closed?: boolean;
314
321
  forkedFrom?: string;
315
322
  forkOffset?: string;
323
+ forkSubOffset?: number;
316
324
  }): Stream;
317
325
  /**
318
326
  * Resolve fork expiry per the decision table.
@@ -459,6 +467,15 @@ declare class StreamStore {
459
467
  * Get all stream paths.
460
468
  */
461
469
  list(): Array<string>;
470
+ /**
471
+ * Resolve a sub-offset against a source stream and return the prefix bytes
472
+ * to materialize as the fork's first own message. Reads from the source
473
+ * (across its fork chain if any) starting at forkOffset; the first message
474
+ * returned is the one that starts at forkOffset. Throws if the sub-offset
475
+ * cannot be satisfied (no message past forkOffset, or overshoots its
476
+ * content extent).
477
+ */
478
+ private resolveForkSubOffset;
462
479
  private appendToStream;
463
480
  private findOffsetIndex;
464
481
  private notifyLongPolls;
@@ -571,6 +588,7 @@ declare class FileBackedStreamStore {
571
588
  closed?: boolean;
572
589
  forkedFrom?: string;
573
590
  forkOffset?: string;
591
+ forkSubOffset?: number;
574
592
  }): Promise<Stream>;
575
593
  get(streamPath: string): Stream | undefined;
576
594
  has(streamPath: string): boolean;
@@ -631,6 +649,12 @@ declare class FileBackedStreamStore {
631
649
  * Does NOT check softDeleted -- forks must read through soft-deleted sources.
632
650
  */
633
651
  private readForkedMessages;
652
+ /**
653
+ * Resolve a fork sub-offset against the source: read the message that
654
+ * starts at forkOffset and return prefix bytes to materialize as the
655
+ * fork's first own message. For JSON, parses comma-joined values.
656
+ */
657
+ private resolveForkSubOffset;
634
658
  read(streamPath: string, offset?: string): {
635
659
  messages: Array<StreamMessage>;
636
660
  upToDate: boolean;
package/dist/index.js CHANGED
@@ -146,13 +146,17 @@ var StreamStore = class {
146
146
  const closedMatches = (options.closed ?? false) === (existingRaw.closed ?? false);
147
147
  const forkedFromMatches = (options.forkedFrom ?? void 0) === existingRaw.forkedFrom;
148
148
  const forkOffsetMatches = options.forkOffset === void 0 || options.forkOffset === existingRaw.forkOffset;
149
- if (contentTypeMatches && ttlMatches && expiresMatches && closedMatches && forkedFromMatches && forkOffsetMatches) return existingRaw;
149
+ const requestedSub = options.forkSubOffset ?? 0;
150
+ const existingSub = existingRaw.forkSubOffset ?? 0;
151
+ const forkSubOffsetMatches = requestedSub === existingSub;
152
+ if (contentTypeMatches && ttlMatches && expiresMatches && closedMatches && forkedFromMatches && forkOffsetMatches && forkSubOffsetMatches) return existingRaw;
150
153
  else throw new Error(`Stream already exists with different configuration: ${path$1}`);
151
154
  }
152
155
  const isFork = !!options.forkedFrom;
153
156
  let forkOffset = `0000000000000000_0000000000000000`;
154
157
  let sourceContentType;
155
158
  let sourceStream;
159
+ let forkSubOffsetPrefix;
156
160
  if (isFork) {
157
161
  sourceStream = this.streams.get(options.forkedFrom);
158
162
  if (!sourceStream) throw new Error(`Source stream not found: ${options.forkedFrom}`);
@@ -163,6 +167,7 @@ var StreamStore = class {
163
167
  else forkOffset = sourceStream.currentOffset;
164
168
  const zeroOffset = `0000000000000000_0000000000000000`;
165
169
  if (forkOffset < zeroOffset || sourceStream.currentOffset < forkOffset) throw new Error(`Invalid fork offset: ${forkOffset}`);
170
+ if (options.forkSubOffset && options.forkSubOffset > 0) forkSubOffsetPrefix = this.resolveForkSubOffset(sourceStream, forkOffset, options.forkSubOffset, normalizeContentType(sourceContentType) === `application/json`);
166
171
  sourceStream.refCount++;
167
172
  }
168
173
  let contentType = options.contentType;
@@ -190,6 +195,20 @@ var StreamStore = class {
190
195
  forkedFrom: isFork ? options.forkedFrom : void 0,
191
196
  forkOffset: isFork ? forkOffset : void 0
192
197
  };
198
+ if (forkSubOffsetPrefix && forkSubOffsetPrefix.length > 0) {
199
+ const parts = stream.currentOffset.split(`_`).map(Number);
200
+ const readSeq = parts[0];
201
+ const byteOffset = parts[1];
202
+ const newByteOffset = byteOffset + forkSubOffsetPrefix.length + 5;
203
+ const newOffset = `${String(readSeq).padStart(16, `0`)}_${String(newByteOffset).padStart(16, `0`)}`;
204
+ stream.messages.push({
205
+ data: forkSubOffsetPrefix,
206
+ offset: newOffset,
207
+ timestamp: Date.now()
208
+ });
209
+ stream.currentOffset = newOffset;
210
+ stream.forkSubOffset = options.forkSubOffset;
211
+ }
193
212
  if (options.initialData && options.initialData.length > 0) try {
194
213
  this.appendToStream(stream, options.initialData, true);
195
214
  } catch (err) {
@@ -713,6 +732,36 @@ var StreamStore = class {
713
732
  list() {
714
733
  return Array.from(this.streams.keys());
715
734
  }
735
+ /**
736
+ * Resolve a sub-offset against a source stream and return the prefix bytes
737
+ * to materialize as the fork's first own message. Reads from the source
738
+ * (across its fork chain if any) starting at forkOffset; the first message
739
+ * returned is the one that starts at forkOffset. Throws if the sub-offset
740
+ * cannot be satisfied (no message past forkOffset, or overshoots its
741
+ * content extent).
742
+ */
743
+ resolveForkSubOffset(sourceStream, forkOffset, subOffset, isJSON) {
744
+ let sourceMessages;
745
+ if (sourceStream.forkedFrom) sourceMessages = [...this.readForkedMessages(sourceStream.forkedFrom, forkOffset, sourceStream.forkOffset), ...this.readOwnMessages(sourceStream, forkOffset)];
746
+ else sourceMessages = this.readOwnMessages(sourceStream, forkOffset);
747
+ if (sourceMessages.length === 0) throw new Error(`Invalid fork sub-offset: no data past forkOffset`);
748
+ const first = sourceMessages[0];
749
+ if (isJSON) {
750
+ const text = new TextDecoder().decode(first.data);
751
+ const trimmed = text.endsWith(`,`) ? text.slice(0, -1) : text;
752
+ let values;
753
+ try {
754
+ values = JSON.parse(`[${trimmed}]`);
755
+ } catch {
756
+ throw new Error(`Invalid fork sub-offset: source JSON is unparseable`);
757
+ }
758
+ if (subOffset > values.length) throw new Error(`Invalid fork sub-offset: overshoots source message count`);
759
+ const prefix = values.slice(0, subOffset).map((v) => JSON.stringify(v));
760
+ return new TextEncoder().encode(prefix.join(`,`) + `,`);
761
+ }
762
+ if (subOffset > first.data.length) throw new Error(`Invalid fork sub-offset: overshoots source message length`);
763
+ return first.data.slice(0, subOffset);
764
+ }
716
765
  appendToStream(stream, data, isInitialCreate = false) {
717
766
  let processedData = data;
718
767
  if (normalizeContentType(stream.contentType) === `application/json`) {
@@ -1294,13 +1343,17 @@ var FileBackedStreamStore = class {
1294
1343
  const closedMatches = (options.closed ?? false) === (existingRaw.closed ?? false);
1295
1344
  const forkedFromMatches = (options.forkedFrom ?? void 0) === existingRaw.forkedFrom;
1296
1345
  const forkOffsetMatches = options.forkOffset === void 0 || options.forkOffset === existingRaw.forkOffset;
1297
- if (contentTypeMatches && ttlMatches && expiresMatches && closedMatches && forkedFromMatches && forkOffsetMatches) return this.streamMetaToStream(existingRaw);
1346
+ const requestedSub = options.forkSubOffset ?? 0;
1347
+ const existingSub = existingRaw.forkSubOffset ?? 0;
1348
+ const forkSubOffsetMatches = requestedSub === existingSub;
1349
+ if (contentTypeMatches && ttlMatches && expiresMatches && closedMatches && forkedFromMatches && forkOffsetMatches && forkSubOffsetMatches) return this.streamMetaToStream(existingRaw);
1298
1350
  else throw new Error(`Stream already exists with different configuration: ${streamPath}`);
1299
1351
  }
1300
1352
  const isFork = !!options.forkedFrom;
1301
1353
  let forkOffset = `0000000000000000_0000000000000000`;
1302
1354
  let sourceContentType;
1303
1355
  let sourceMeta;
1356
+ let forkSubOffsetPrefix;
1304
1357
  if (isFork) {
1305
1358
  const sourceKey = `stream:${options.forkedFrom}`;
1306
1359
  sourceMeta = this.db.get(sourceKey);
@@ -1312,6 +1365,7 @@ var FileBackedStreamStore = class {
1312
1365
  else forkOffset = sourceMeta.currentOffset;
1313
1366
  const zeroOffset = `0000000000000000_0000000000000000`;
1314
1367
  if (forkOffset < zeroOffset || sourceMeta.currentOffset < forkOffset) throw new Error(`Invalid fork offset: ${forkOffset}`);
1368
+ if (options.forkSubOffset && options.forkSubOffset > 0) forkSubOffsetPrefix = this.resolveForkSubOffset(options.forkedFrom, forkOffset, options.forkSubOffset, normalizeContentType(sourceContentType) === `application/json`);
1315
1369
  const freshSource = this.db.get(sourceKey);
1316
1370
  const updatedSource = {
1317
1371
  ...freshSource,
@@ -1347,11 +1401,39 @@ var FileBackedStreamStore = class {
1347
1401
  closed: false,
1348
1402
  forkedFrom: isFork ? options.forkedFrom : void 0,
1349
1403
  forkOffset: isFork ? forkOffset : void 0,
1404
+ forkSubOffset: void 0,
1350
1405
  refCount: 0
1351
1406
  };
1352
1407
  const tAfterMeta = performance.now();
1353
1408
  const segmentPath = segmentFile(this.dataDir, streamMeta.directoryName);
1354
1409
  try {
1410
+ if (forkSubOffsetPrefix && forkSubOffsetPrefix.length > 0) {
1411
+ const lengthBuf = Buffer.allocUnsafe(4);
1412
+ lengthBuf.writeUInt32BE(forkSubOffsetPrefix.length, 0);
1413
+ const frameBuf = Buffer.concat([
1414
+ lengthBuf,
1415
+ Buffer.from(forkSubOffsetPrefix),
1416
+ Buffer.from(`\n`)
1417
+ ]);
1418
+ const fd = fs.openSync(segmentPath, `wx`);
1419
+ try {
1420
+ let written = 0;
1421
+ while (written < frameBuf.length) {
1422
+ const bytesWritten = fs.writeSync(fd, frameBuf, written, frameBuf.length - written, written);
1423
+ if (bytesWritten === 0) throw new Error(`failed to write sub-offset prefix frame`);
1424
+ written += bytesWritten;
1425
+ }
1426
+ fs.fsyncSync(fd);
1427
+ } finally {
1428
+ fs.closeSync(fd);
1429
+ }
1430
+ const parts = streamMeta.currentOffset.split(`_`).map(Number);
1431
+ const readSeq = parts[0];
1432
+ const byteOffset = parts[1];
1433
+ const newByteOffset = byteOffset + forkSubOffsetPrefix.length + 5;
1434
+ streamMeta.currentOffset = `${String(readSeq).padStart(16, `0`)}_${String(newByteOffset).padStart(16, `0`)}`;
1435
+ streamMeta.forkSubOffset = options.forkSubOffset;
1436
+ }
1355
1437
  await this.db.put(key, streamMeta);
1356
1438
  } catch (err) {
1357
1439
  if (isFork && sourceMeta) {
@@ -1365,7 +1447,7 @@ var FileBackedStreamStore = class {
1365
1447
  this.db.putSync(sourceKey, updatedSource);
1366
1448
  }
1367
1449
  }
1368
- serverLog.error(`[FileBackedStreamStore] Error creating stream (LMDB put):`, err);
1450
+ serverLog.error(`[FileBackedStreamStore] Error creating stream before metadata commit:`, err);
1369
1451
  throw err;
1370
1452
  }
1371
1453
  const tAfterLmdb = performance.now();
@@ -1750,6 +1832,35 @@ var FileBackedStreamStore = class {
1750
1832
  messages.push(...ownMessages);
1751
1833
  return messages;
1752
1834
  }
1835
+ /**
1836
+ * Resolve a fork sub-offset against the source: read the message that
1837
+ * starts at forkOffset and return prefix bytes to materialize as the
1838
+ * fork's first own message. For JSON, parses comma-joined values.
1839
+ */
1840
+ resolveForkSubOffset(sourcePath, forkOffset, subOffset, isJSON) {
1841
+ const forkByte = Number(forkOffset.split(`_`)[1] ?? 0);
1842
+ const sourceMeta = this.db.get(`stream:${sourcePath}`);
1843
+ if (!sourceMeta) throw new Error(`Source stream not found: ${sourcePath}`);
1844
+ const currentByte = Number(sourceMeta.currentOffset.split(`_`)[1] ?? 0);
1845
+ const messages = this.readForkedMessages(sourcePath, forkByte, currentByte);
1846
+ if (messages.length === 0) throw new Error(`Invalid fork sub-offset: no data past forkOffset`);
1847
+ const first = messages[0];
1848
+ if (isJSON) {
1849
+ const text = new TextDecoder().decode(first.data);
1850
+ const trimmed = text.endsWith(`,`) ? text.slice(0, -1) : text;
1851
+ let values;
1852
+ try {
1853
+ values = JSON.parse(`[${trimmed}]`);
1854
+ } catch {
1855
+ throw new Error(`Invalid fork sub-offset: source JSON is unparseable`);
1856
+ }
1857
+ if (subOffset > values.length) throw new Error(`Invalid fork sub-offset: overshoots source message count`);
1858
+ const prefix = values.slice(0, subOffset).map((v) => JSON.stringify(v));
1859
+ return new TextEncoder().encode(prefix.join(`,`) + `,`);
1860
+ }
1861
+ if (subOffset > first.data.length) throw new Error(`Invalid fork sub-offset: overshoots source message length`);
1862
+ return first.data.slice(0, subOffset);
1863
+ }
1753
1864
  read(streamPath, offset) {
1754
1865
  const streamMeta = this.getMetaIfNotExpired(streamPath);
1755
1866
  if (!streamMeta) throw new Error(`Stream not found: ${streamPath}`);
@@ -3006,6 +3117,7 @@ const STREAM_SSE_DATA_ENCODING_HEADER = `Stream-SSE-Data-Encoding`;
3006
3117
  const SSE_UP_TO_DATE_FIELD = `upToDate`;
3007
3118
  const STREAM_FORKED_FROM_HEADER = `Stream-Forked-From`;
3008
3119
  const STREAM_FORK_OFFSET_HEADER = `Stream-Fork-Offset`;
3120
+ const STREAM_FORK_SUB_OFFSET_HEADER = `Stream-Fork-Sub-Offset`;
3009
3121
  /**
3010
3122
  * Encode data for SSE format.
3011
3123
  * Per SSE spec, each line in the payload needs its own "data:" prefix.
@@ -3231,7 +3343,7 @@ var DurableStreamTestServer = class {
3231
3343
  const method = req.method?.toUpperCase();
3232
3344
  res.setHeader(`access-control-allow-origin`, `*`);
3233
3345
  res.setHeader(`access-control-allow-methods`, `GET, POST, PUT, DELETE, HEAD, OPTIONS`);
3234
- res.setHeader(`access-control-allow-headers`, `content-type, authorization, Stream-Seq, Stream-TTL, Stream-Expires-At, Stream-Closed, Producer-Id, Producer-Epoch, Producer-Seq, Stream-Forked-From, Stream-Fork-Offset`);
3346
+ res.setHeader(`access-control-allow-headers`, `content-type, authorization, Stream-Seq, Stream-TTL, Stream-Expires-At, Stream-Closed, Producer-Id, Producer-Epoch, Producer-Seq, Stream-Forked-From, Stream-Fork-Offset, Stream-Fork-Sub-Offset`);
3235
3347
  res.setHeader(`access-control-expose-headers`, `Stream-Next-Offset, Stream-Cursor, Stream-Up-To-Date, Stream-Closed, Producer-Epoch, Producer-Seq, Producer-Expected-Seq, Producer-Received-Seq, etag, content-type, content-encoding, vary`);
3236
3348
  res.setHeader(`x-content-type-options`, `nosniff`);
3237
3349
  res.setHeader(`cross-origin-resource-policy`, `cross-origin`);
@@ -3321,6 +3433,9 @@ var DurableStreamTestServer = class {
3321
3433
  let contentType = req.headers[`content-type`];
3322
3434
  const forkedFromHeader = req.headers[STREAM_FORKED_FROM_HEADER.toLowerCase()];
3323
3435
  const forkOffsetHeader = req.headers[STREAM_FORK_OFFSET_HEADER.toLowerCase()];
3436
+ const forkSubOffsetHeaderRaw = req.headers[STREAM_FORK_SUB_OFFSET_HEADER.toLowerCase()];
3437
+ const forkSubOffsetHeaderPresent = forkSubOffsetHeaderRaw !== void 0;
3438
+ const forkSubOffsetHeader = Array.isArray(forkSubOffsetHeaderRaw) ? forkSubOffsetHeaderRaw[0] : forkSubOffsetHeaderRaw;
3324
3439
  if (!contentType || contentType.trim() === `` || !/^[\w-]+\/[\w-]+/.test(contentType)) contentType = forkedFromHeader ? void 0 : `application/octet-stream`;
3325
3440
  const ttlHeader = req.headers[STREAM_TTL_HEADER.toLowerCase()];
3326
3441
  const expiresAtHeader = req.headers[STREAM_EXPIRES_AT_HEADER.toLowerCase()];
@@ -3362,6 +3477,21 @@ var DurableStreamTestServer = class {
3362
3477
  return;
3363
3478
  }
3364
3479
  }
3480
+ let forkSubOffset;
3481
+ if (forkSubOffsetHeaderPresent) {
3482
+ if (!forkedFromHeader) {
3483
+ res.writeHead(400, { "content-type": `text/plain` });
3484
+ res.end(`Stream-Fork-Sub-Offset requires Stream-Forked-From`);
3485
+ return;
3486
+ }
3487
+ const subOffsetPattern = /^(0|[1-9]\d*)$/;
3488
+ if (forkSubOffsetHeader === void 0 || !subOffsetPattern.test(forkSubOffsetHeader)) {
3489
+ res.writeHead(400, { "content-type": `text/plain` });
3490
+ res.end(`Invalid Stream-Fork-Sub-Offset format`);
3491
+ return;
3492
+ }
3493
+ forkSubOffset = parseInt(forkSubOffsetHeader, 10);
3494
+ }
3365
3495
  const body = await this.readBody(req);
3366
3496
  const isNew = !this.store.has(path$1);
3367
3497
  try {
@@ -3372,7 +3502,8 @@ var DurableStreamTestServer = class {
3372
3502
  initialData: body.length > 0 ? body : void 0,
3373
3503
  closed: createClosed,
3374
3504
  forkedFrom: forkedFromHeader,
3375
- forkOffset: forkOffsetHeader
3505
+ forkOffset: forkOffsetHeader,
3506
+ forkSubOffset
3376
3507
  }));
3377
3508
  } catch (err) {
3378
3509
  if (err instanceof Error) {
@@ -3381,6 +3512,11 @@ var DurableStreamTestServer = class {
3381
3512
  res.end(`Source stream not found`);
3382
3513
  return;
3383
3514
  }
3515
+ if (err.message.includes(`Invalid fork sub-offset`)) {
3516
+ res.writeHead(400, { "content-type": `text/plain` });
3517
+ res.end(`Invalid fork sub-offset`);
3518
+ return;
3519
+ }
3384
3520
  if (err.message.includes(`Invalid fork offset`)) {
3385
3521
  res.writeHead(400, { "content-type": `text/plain` });
3386
3522
  res.end(`Fork offset beyond source stream length`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@durable-streams/server",
3
- "version": "0.3.3",
3
+ "version": "0.3.5",
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.4",
43
- "@durable-streams/state": "0.2.7"
42
+ "@durable-streams/client": "0.2.6",
43
+ "@durable-streams/state": "0.2.9"
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.2"
50
+ "@durable-streams/server-conformance-tests": "0.3.4"
51
51
  },
52
52
  "files": [
53
53
  "dist",
package/src/file-store.ts CHANGED
@@ -87,6 +87,12 @@ interface StreamMetadata {
87
87
  * Divergence offset from the source stream.
88
88
  */
89
89
  forkOffset?: string
90
+ /**
91
+ * User-supplied sub-offset value refining `forkOffset` (Section 4.2 of
92
+ * PROTOCOL.md). Stored verbatim for idempotent re-creation matching:
93
+ * bytes for non-JSON forks, flattened message count for JSON forks.
94
+ */
95
+ forkSubOffset?: number
90
96
  /**
91
97
  * Number of forks referencing this stream.
92
98
  * Defaults to 0. Optional for backward-compatible deserialization from LMDB.
@@ -726,6 +732,7 @@ export class FileBackedStreamStore {
726
732
  closed?: boolean
727
733
  forkedFrom?: string
728
734
  forkOffset?: string
735
+ forkSubOffset?: number
729
736
  } = {}
730
737
  ): Promise<Stream> {
731
738
  // Use getMetaIfNotExpired to treat expired streams as non-existent
@@ -762,6 +769,12 @@ export class FileBackedStreamStore {
762
769
  const forkOffsetMatches =
763
770
  options.forkOffset === undefined ||
764
771
  options.forkOffset === existingRaw.forkOffset
772
+ // Sub-offset: undefined and 0 are equivalent. Compare the raw
773
+ // user-supplied integer (count for JSON, bytes for binary) so the
774
+ // comparison is independent of how it was resolved internally.
775
+ const requestedSub = options.forkSubOffset ?? 0
776
+ const existingSub = existingRaw.forkSubOffset ?? 0
777
+ const forkSubOffsetMatches = requestedSub === existingSub
765
778
 
766
779
  if (
767
780
  contentTypeMatches &&
@@ -769,7 +782,8 @@ export class FileBackedStreamStore {
769
782
  expiresMatches &&
770
783
  closedMatches &&
771
784
  forkedFromMatches &&
772
- forkOffsetMatches
785
+ forkOffsetMatches &&
786
+ forkSubOffsetMatches
773
787
  ) {
774
788
  // Idempotent success - return existing stream
775
789
  return this.streamMetaToStream(existingRaw)
@@ -787,6 +801,7 @@ export class FileBackedStreamStore {
787
801
  let forkOffset = `0000000000000000_0000000000000000`
788
802
  let sourceContentType: string | undefined
789
803
  let sourceMeta: StreamMetadata | undefined
804
+ let forkSubOffsetPrefix: Uint8Array | undefined
790
805
 
791
806
  if (isFork) {
792
807
  const sourceKey = `stream:${options.forkedFrom!}`
@@ -816,6 +831,17 @@ export class FileBackedStreamStore {
816
831
  throw new Error(`Invalid fork offset: ${forkOffset}`)
817
832
  }
818
833
 
834
+ // Resolve sub-offset against the source. Returns the prefix bytes to
835
+ // materialize as the fork's first own message.
836
+ if (options.forkSubOffset && options.forkSubOffset > 0) {
837
+ forkSubOffsetPrefix = this.resolveForkSubOffset(
838
+ options.forkedFrom!,
839
+ forkOffset,
840
+ options.forkSubOffset,
841
+ normalizeContentType(sourceContentType) === `application/json`
842
+ )
843
+ }
844
+
819
845
  // Atomically increment source refcount in LMDB
820
846
  const freshSource = this.db.get(sourceKey) as StreamMetadata
821
847
  const updatedSource: StreamMetadata = {
@@ -871,6 +897,7 @@ export class FileBackedStreamStore {
871
897
  closed: false, // Set to false initially, will be updated after initial append if needed
872
898
  forkedFrom: isFork ? options.forkedFrom : undefined,
873
899
  forkOffset: isFork ? forkOffset : undefined,
900
+ forkSubOffset: undefined,
874
901
  refCount: 0,
875
902
  }
876
903
 
@@ -878,6 +905,52 @@ export class FileBackedStreamStore {
878
905
 
879
906
  const segmentPath = segmentFile(this.dataDir, streamMeta.directoryName)
880
907
  try {
908
+ // Materialize the sub-offset prefix as the first framed message
909
+ // in the new segment before persisting metadata or opening the
910
+ // write stream. The prefix must be fsynced before the LMDB commit
911
+ // so metadata never acknowledges bytes that are not durable.
912
+ if (forkSubOffsetPrefix && forkSubOffsetPrefix.length > 0) {
913
+ const lengthBuf = Buffer.allocUnsafe(4)
914
+ lengthBuf.writeUInt32BE(forkSubOffsetPrefix.length, 0)
915
+ const frameBuf = Buffer.concat([
916
+ lengthBuf,
917
+ Buffer.from(forkSubOffsetPrefix),
918
+ Buffer.from(`\n`),
919
+ ])
920
+
921
+ const fd = fs.openSync(segmentPath, `wx`)
922
+ try {
923
+ let written = 0
924
+ while (written < frameBuf.length) {
925
+ const bytesWritten = fs.writeSync(
926
+ fd,
927
+ frameBuf,
928
+ written,
929
+ frameBuf.length - written,
930
+ written
931
+ )
932
+ if (bytesWritten === 0) {
933
+ throw new Error(`failed to write sub-offset prefix frame`)
934
+ }
935
+ written += bytesWritten
936
+ }
937
+ fs.fsyncSync(fd)
938
+ } finally {
939
+ fs.closeSync(fd)
940
+ }
941
+
942
+ const parts = streamMeta.currentOffset.split(`_`).map(Number)
943
+ const readSeq = parts[0]!
944
+ const byteOffset = parts[1]!
945
+ // Match append()'s frame-inclusive offset advance (4-byte length
946
+ // prefix + payload + 1-byte newline) so reads with a capByte don't
947
+ // truncate the materialized prefix when later chained-fork resolves.
948
+ const newByteOffset = byteOffset + forkSubOffsetPrefix.length + 5
949
+ streamMeta.currentOffset = `${String(readSeq).padStart(16, `0`)}_${String(newByteOffset).padStart(16, `0`)}`
950
+ // Persist the user-supplied sub-offset verbatim for idempotent
951
+ // re-creation matching, not the encoded byte length.
952
+ streamMeta.forkSubOffset = options.forkSubOffset
953
+ }
881
954
  await this.db.put(key, streamMeta)
882
955
  } catch (err) {
883
956
  // Rollback source refcount on failure
@@ -893,7 +966,7 @@ export class FileBackedStreamStore {
893
966
  }
894
967
  }
895
968
  serverLog.error(
896
- `[FileBackedStreamStore] Error creating stream (LMDB put):`,
969
+ `[FileBackedStreamStore] Error creating stream before metadata commit:`,
897
970
  err
898
971
  )
899
972
  throw err
@@ -1589,6 +1662,57 @@ export class FileBackedStreamStore {
1589
1662
  return messages
1590
1663
  }
1591
1664
 
1665
+ /**
1666
+ * Resolve a fork sub-offset against the source: read the message that
1667
+ * starts at forkOffset and return prefix bytes to materialize as the
1668
+ * fork's first own message. For JSON, parses comma-joined values.
1669
+ */
1670
+ private resolveForkSubOffset(
1671
+ sourcePath: string,
1672
+ forkOffset: string,
1673
+ subOffset: number,
1674
+ isJSON: boolean
1675
+ ): Uint8Array {
1676
+ const forkByte = Number(forkOffset.split(`_`)[1] ?? 0)
1677
+ // Read source past forkOffset (cap at source's currentOffset by passing
1678
+ // a large cap; we only care about the first message past forkOffset).
1679
+ const sourceMeta = this.db.get(`stream:${sourcePath}`) as
1680
+ | StreamMetadata
1681
+ | undefined
1682
+ if (!sourceMeta) {
1683
+ throw new Error(`Source stream not found: ${sourcePath}`)
1684
+ }
1685
+ const currentByte = Number(sourceMeta.currentOffset.split(`_`)[1] ?? 0)
1686
+ const messages = this.readForkedMessages(sourcePath, forkByte, currentByte)
1687
+ if (messages.length === 0) {
1688
+ throw new Error(`Invalid fork sub-offset: no data past forkOffset`)
1689
+ }
1690
+ const first = messages[0]!
1691
+ if (isJSON) {
1692
+ const text = new TextDecoder().decode(first.data)
1693
+ const trimmed = text.endsWith(`,`) ? text.slice(0, -1) : text
1694
+ let values: Array<unknown>
1695
+ try {
1696
+ values = JSON.parse(`[${trimmed}]`)
1697
+ } catch {
1698
+ throw new Error(`Invalid fork sub-offset: source JSON is unparseable`)
1699
+ }
1700
+ if (subOffset > values.length) {
1701
+ throw new Error(
1702
+ `Invalid fork sub-offset: overshoots source message count`
1703
+ )
1704
+ }
1705
+ const prefix = values.slice(0, subOffset).map((v) => JSON.stringify(v))
1706
+ return new TextEncoder().encode(prefix.join(`,`) + `,`)
1707
+ }
1708
+ if (subOffset > first.data.length) {
1709
+ throw new Error(
1710
+ `Invalid fork sub-offset: overshoots source message length`
1711
+ )
1712
+ }
1713
+ return first.data.slice(0, subOffset)
1714
+ }
1715
+
1592
1716
  read(
1593
1717
  streamPath: string,
1594
1718
  offset?: string
package/src/server.ts CHANGED
@@ -42,6 +42,7 @@ const SSE_UP_TO_DATE_FIELD = `upToDate`
42
42
  // Fork headers (request headers only — not set on responses)
43
43
  const STREAM_FORKED_FROM_HEADER = `Stream-Forked-From`
44
44
  const STREAM_FORK_OFFSET_HEADER = `Stream-Fork-Offset`
45
+ const STREAM_FORK_SUB_OFFSET_HEADER = `Stream-Fork-Sub-Offset`
45
46
 
46
47
  /**
47
48
  * Encode data for SSE format.
@@ -449,7 +450,7 @@ export class DurableStreamTestServer {
449
450
  )
450
451
  res.setHeader(
451
452
  `access-control-allow-headers`,
452
- `content-type, authorization, Stream-Seq, Stream-TTL, Stream-Expires-At, Stream-Closed, Producer-Id, Producer-Epoch, Producer-Seq, Stream-Forked-From, Stream-Fork-Offset`
453
+ `content-type, authorization, Stream-Seq, Stream-TTL, Stream-Expires-At, Stream-Closed, Producer-Id, Producer-Epoch, Producer-Seq, Stream-Forked-From, Stream-Fork-Offset, Stream-Fork-Sub-Offset`
453
454
  )
454
455
  res.setHeader(
455
456
  `access-control-expose-headers`,
@@ -598,6 +599,13 @@ export class DurableStreamTestServer {
598
599
  const forkOffsetHeader = req.headers[
599
600
  STREAM_FORK_OFFSET_HEADER.toLowerCase()
600
601
  ] as string | undefined
602
+ const forkSubOffsetHeaderRaw =
603
+ req.headers[STREAM_FORK_SUB_OFFSET_HEADER.toLowerCase()]
604
+ // Distinguish "header absent" from "header present but empty"
605
+ const forkSubOffsetHeaderPresent = forkSubOffsetHeaderRaw !== undefined
606
+ const forkSubOffsetHeader = Array.isArray(forkSubOffsetHeaderRaw)
607
+ ? forkSubOffsetHeaderRaw[0]
608
+ : forkSubOffsetHeaderRaw
601
609
 
602
610
  // Sanitize content-type: if empty or invalid, use default — but only
603
611
  // for non-fork creates. For forks, an omitted Content-Type means "inherit
@@ -667,6 +675,26 @@ export class DurableStreamTestServer {
667
675
  }
668
676
  }
669
677
 
678
+ // Validate sub-offset if header was present (including empty value)
679
+ let forkSubOffset: number | undefined
680
+ if (forkSubOffsetHeaderPresent) {
681
+ if (!forkedFromHeader) {
682
+ res.writeHead(400, { "content-type": `text/plain` })
683
+ res.end(`Stream-Fork-Sub-Offset requires Stream-Forked-From`)
684
+ return
685
+ }
686
+ const subOffsetPattern = /^(0|[1-9]\d*)$/
687
+ if (
688
+ forkSubOffsetHeader === undefined ||
689
+ !subOffsetPattern.test(forkSubOffsetHeader)
690
+ ) {
691
+ res.writeHead(400, { "content-type": `text/plain` })
692
+ res.end(`Invalid Stream-Fork-Sub-Offset format`)
693
+ return
694
+ }
695
+ forkSubOffset = parseInt(forkSubOffsetHeader, 10)
696
+ }
697
+
670
698
  // Read body if present
671
699
  const body = await this.readBody(req)
672
700
 
@@ -683,6 +711,7 @@ export class DurableStreamTestServer {
683
711
  closed: createClosed,
684
712
  forkedFrom: forkedFromHeader,
685
713
  forkOffset: forkOffsetHeader,
714
+ forkSubOffset,
686
715
  })
687
716
  )
688
717
  } catch (err) {
@@ -692,6 +721,11 @@ export class DurableStreamTestServer {
692
721
  res.end(`Source stream not found`)
693
722
  return
694
723
  }
724
+ if (err.message.includes(`Invalid fork sub-offset`)) {
725
+ res.writeHead(400, { "content-type": `text/plain` })
726
+ res.end(`Invalid fork sub-offset`)
727
+ return
728
+ }
695
729
  if (err.message.includes(`Invalid fork offset`)) {
696
730
  res.writeHead(400, { "content-type": `text/plain` })
697
731
  res.end(`Fork offset beyond source stream length`)
package/src/store.ts CHANGED
@@ -247,6 +247,7 @@ export class StreamStore {
247
247
  closed?: boolean
248
248
  forkedFrom?: string
249
249
  forkOffset?: string
250
+ forkSubOffset?: number
250
251
  } = {}
251
252
  ): Stream {
252
253
  // Check if stream already exists
@@ -280,6 +281,12 @@ export class StreamStore {
280
281
  const forkOffsetMatches =
281
282
  options.forkOffset === undefined ||
282
283
  options.forkOffset === existingRaw.forkOffset
284
+ // Sub-offset: undefined and 0 are equivalent. Compare the raw
285
+ // user-supplied integer (count for JSON, bytes for binary) so the
286
+ // comparison is independent of how it was resolved internally.
287
+ const requestedSub = options.forkSubOffset ?? 0
288
+ const existingSub = existingRaw.forkSubOffset ?? 0
289
+ const forkSubOffsetMatches = requestedSub === existingSub
283
290
 
284
291
  if (
285
292
  contentTypeMatches &&
@@ -287,7 +294,8 @@ export class StreamStore {
287
294
  expiresMatches &&
288
295
  closedMatches &&
289
296
  forkedFromMatches &&
290
- forkOffsetMatches
297
+ forkOffsetMatches &&
298
+ forkSubOffsetMatches
291
299
  ) {
292
300
  // Idempotent success - return existing stream
293
301
  return existingRaw
@@ -305,6 +313,7 @@ export class StreamStore {
305
313
  let forkOffset = `0000000000000000_0000000000000000`
306
314
  let sourceContentType: string | undefined
307
315
  let sourceStream: Stream | undefined
316
+ let forkSubOffsetPrefix: Uint8Array | undefined
308
317
 
309
318
  if (isFork) {
310
319
  sourceStream = this.streams.get(options.forkedFrom!)
@@ -333,6 +342,18 @@ export class StreamStore {
333
342
  throw new Error(`Invalid fork offset: ${forkOffset}`)
334
343
  }
335
344
 
345
+ // Resolve sub-offset against the source. Both binary and JSON return
346
+ // a synthetic prefix to materialize as the fork's first own message,
347
+ // because in this store one POST = one message regardless of mode.
348
+ if (options.forkSubOffset && options.forkSubOffset > 0) {
349
+ forkSubOffsetPrefix = this.resolveForkSubOffset(
350
+ sourceStream,
351
+ forkOffset,
352
+ options.forkSubOffset,
353
+ normalizeContentType(sourceContentType) === `application/json`
354
+ )
355
+ }
356
+
336
357
  // Increment source refcount
337
358
  sourceStream.refCount++
338
359
  }
@@ -375,6 +396,27 @@ export class StreamStore {
375
396
  forkOffset: isFork ? forkOffset : undefined,
376
397
  }
377
398
 
399
+ // Materialize sub-offset prefix as the fork's first own message.
400
+ if (forkSubOffsetPrefix && forkSubOffsetPrefix.length > 0) {
401
+ const parts = stream.currentOffset.split(`_`).map(Number)
402
+ const readSeq = parts[0]!
403
+ const byteOffset = parts[1]!
404
+ // Match append()'s frame-inclusive offset advance (4-byte length
405
+ // prefix + payload + 1-byte newline) so reads with a capByte don't
406
+ // truncate the materialized prefix when later chained-fork resolves.
407
+ const newByteOffset = byteOffset + forkSubOffsetPrefix.length + 5
408
+ const newOffset = `${String(readSeq).padStart(16, `0`)}_${String(newByteOffset).padStart(16, `0`)}`
409
+ stream.messages.push({
410
+ data: forkSubOffsetPrefix,
411
+ offset: newOffset,
412
+ timestamp: Date.now(),
413
+ })
414
+ stream.currentOffset = newOffset
415
+ // Persist the user-supplied sub-offset verbatim for idempotent
416
+ // re-creation matching, not the encoded byte length.
417
+ stream.forkSubOffset = options.forkSubOffset
418
+ }
419
+
378
420
  // If initial data is provided, append it
379
421
  if (options.initialData && options.initialData.length > 0) {
380
422
  try {
@@ -1236,6 +1278,67 @@ export class StreamStore {
1236
1278
  // Private helpers
1237
1279
  // ============================================================================
1238
1280
 
1281
+ /**
1282
+ * Resolve a sub-offset against a source stream and return the prefix bytes
1283
+ * to materialize as the fork's first own message. Reads from the source
1284
+ * (across its fork chain if any) starting at forkOffset; the first message
1285
+ * returned is the one that starts at forkOffset. Throws if the sub-offset
1286
+ * cannot be satisfied (no message past forkOffset, or overshoots its
1287
+ * content extent).
1288
+ */
1289
+ private resolveForkSubOffset(
1290
+ sourceStream: Stream,
1291
+ forkOffset: string,
1292
+ subOffset: number,
1293
+ isJSON: boolean
1294
+ ): Uint8Array {
1295
+ // Read source past forkOffset across its fork chain
1296
+ let sourceMessages: Array<StreamMessage>
1297
+ if (sourceStream.forkedFrom) {
1298
+ sourceMessages = [
1299
+ ...this.readForkedMessages(
1300
+ sourceStream.forkedFrom,
1301
+ forkOffset,
1302
+ sourceStream.forkOffset!
1303
+ ),
1304
+ ...this.readOwnMessages(sourceStream, forkOffset),
1305
+ ]
1306
+ } else {
1307
+ sourceMessages = this.readOwnMessages(sourceStream, forkOffset)
1308
+ }
1309
+ if (sourceMessages.length === 0) {
1310
+ throw new Error(`Invalid fork sub-offset: no data past forkOffset`)
1311
+ }
1312
+ const first = sourceMessages[0]!
1313
+ if (isJSON) {
1314
+ // The message data is comma-joined JSON values with a trailing comma
1315
+ // (e.g., `{"a":1},{"b":2},`). Wrap in [...] to parse, take first N
1316
+ // elements, re-encode in the same comma-joined format.
1317
+ const text = new TextDecoder().decode(first.data)
1318
+ const trimmed = text.endsWith(`,`) ? text.slice(0, -1) : text
1319
+ let values: Array<unknown>
1320
+ try {
1321
+ values = JSON.parse(`[${trimmed}]`)
1322
+ } catch {
1323
+ throw new Error(`Invalid fork sub-offset: source JSON is unparseable`)
1324
+ }
1325
+ if (subOffset > values.length) {
1326
+ throw new Error(
1327
+ `Invalid fork sub-offset: overshoots source message count`
1328
+ )
1329
+ }
1330
+ const prefix = values.slice(0, subOffset).map((v) => JSON.stringify(v))
1331
+ return new TextEncoder().encode(prefix.join(`,`) + `,`)
1332
+ }
1333
+ // Binary: take first subOffset bytes
1334
+ if (subOffset > first.data.length) {
1335
+ throw new Error(
1336
+ `Invalid fork sub-offset: overshoots source message length`
1337
+ )
1338
+ }
1339
+ return first.data.slice(0, subOffset)
1340
+ }
1341
+
1239
1342
  private appendToStream(
1240
1343
  stream: Stream,
1241
1344
  data: Uint8Array,
package/src/types.ts CHANGED
@@ -107,6 +107,14 @@ export interface Stream {
107
107
  */
108
108
  forkOffset?: string
109
109
 
110
+ /**
111
+ * User-supplied sub-offset value refining `forkOffset` (Section 4.2 of
112
+ * PROTOCOL.md). Stored verbatim for idempotent re-creation matching:
113
+ * bytes for non-JSON forks, flattened message count for JSON forks.
114
+ * `undefined` and `0` are equivalent.
115
+ */
116
+ forkSubOffset?: number
117
+
110
118
  /**
111
119
  * Number of forks referencing this stream.
112
120
  * Defaults to 0.