@durable-streams/server 0.3.4 → 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 +141 -5
- package/dist/index.d.cts +24 -0
- package/dist/index.d.ts +24 -0
- package/dist/index.js +141 -5
- package/package.json +4 -4
- package/src/file-store.ts +126 -2
- package/src/server.ts +35 -1
- package/src/store.ts +104 -1
- package/src/types.ts +8 -0
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
+
"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.
|
|
43
|
-
"@durable-streams/state": "0.2.
|
|
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.
|
|
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
|
|
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.
|