@durable-streams/server 0.2.3 → 0.3.0
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 +541 -89
- package/dist/index.d.cts +97 -6
- package/dist/index.d.ts +97 -6
- package/dist/index.js +541 -89
- package/package.json +3 -3
- package/src/file-store.ts +491 -90
- package/src/server.ts +108 -16
- package/src/store.ts +363 -36
- package/src/types.ts +29 -0
package/src/store.ts
CHANGED
|
@@ -134,9 +134,9 @@ export class StreamStore {
|
|
|
134
134
|
}
|
|
135
135
|
}
|
|
136
136
|
|
|
137
|
-
// Check TTL (
|
|
137
|
+
// Check TTL (sliding window from last access)
|
|
138
138
|
if (stream.ttlSeconds !== undefined) {
|
|
139
|
-
const expiryTime = stream.
|
|
139
|
+
const expiryTime = stream.lastAccessedAt + stream.ttlSeconds * 1000
|
|
140
140
|
if (now >= expiryTime) {
|
|
141
141
|
return true
|
|
142
142
|
}
|
|
@@ -146,8 +146,9 @@ export class StreamStore {
|
|
|
146
146
|
}
|
|
147
147
|
|
|
148
148
|
/**
|
|
149
|
-
* Get a stream,
|
|
150
|
-
* Returns undefined if stream doesn't exist or is expired.
|
|
149
|
+
* Get a stream, handling expiry.
|
|
150
|
+
* Returns undefined if stream doesn't exist or is expired (and has no refs).
|
|
151
|
+
* Expired streams with refCount > 0 are soft-deleted instead of fully deleted.
|
|
151
152
|
*/
|
|
152
153
|
private getIfNotExpired(path: string): Stream | undefined {
|
|
153
154
|
const stream = this.streams.get(path)
|
|
@@ -155,6 +156,11 @@ export class StreamStore {
|
|
|
155
156
|
return undefined
|
|
156
157
|
}
|
|
157
158
|
if (this.isExpired(stream)) {
|
|
159
|
+
if (stream.refCount > 0) {
|
|
160
|
+
// Expired with refs: soft-delete instead of full delete
|
|
161
|
+
stream.softDeleted = true
|
|
162
|
+
return stream
|
|
163
|
+
}
|
|
158
164
|
// Delete expired stream
|
|
159
165
|
this.delete(path)
|
|
160
166
|
return undefined
|
|
@@ -162,9 +168,20 @@ export class StreamStore {
|
|
|
162
168
|
return stream
|
|
163
169
|
}
|
|
164
170
|
|
|
171
|
+
/**
|
|
172
|
+
* Update lastAccessedAt to now. Called on reads and appends (not HEAD).
|
|
173
|
+
*/
|
|
174
|
+
touchAccess(path: string): void {
|
|
175
|
+
const stream = this.streams.get(path)
|
|
176
|
+
if (stream) {
|
|
177
|
+
stream.lastAccessedAt = Date.now()
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
165
181
|
/**
|
|
166
182
|
* Create a new stream.
|
|
167
183
|
* @throws Error if stream already exists with different config
|
|
184
|
+
* @throws Error if fork source not found, soft-deleted, or offset invalid
|
|
168
185
|
* @returns existing stream if config matches (idempotent)
|
|
169
186
|
*/
|
|
170
187
|
create(
|
|
@@ -175,75 +192,271 @@ export class StreamStore {
|
|
|
175
192
|
expiresAt?: string
|
|
176
193
|
initialData?: Uint8Array
|
|
177
194
|
closed?: boolean
|
|
195
|
+
forkedFrom?: string
|
|
196
|
+
forkOffset?: string
|
|
178
197
|
} = {}
|
|
179
198
|
): Stream {
|
|
180
|
-
//
|
|
181
|
-
const
|
|
182
|
-
if (
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
(
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
const ttlMatches = options.ttlSeconds === existing.ttlSeconds
|
|
190
|
-
const expiresMatches = options.expiresAt === existing.expiresAt
|
|
191
|
-
const closedMatches =
|
|
192
|
-
(options.closed ?? false) === (existing.closed ?? false)
|
|
193
|
-
|
|
194
|
-
if (contentTypeMatches && ttlMatches && expiresMatches && closedMatches) {
|
|
195
|
-
// Idempotent success - return existing stream
|
|
196
|
-
return existing
|
|
197
|
-
} else {
|
|
198
|
-
// Config mismatch - conflict
|
|
199
|
+
// Check if stream already exists
|
|
200
|
+
const existingRaw = this.streams.get(path)
|
|
201
|
+
if (existingRaw) {
|
|
202
|
+
if (this.isExpired(existingRaw)) {
|
|
203
|
+
// Expired: delete and proceed with creation
|
|
204
|
+
this.streams.delete(path)
|
|
205
|
+
this.cancelLongPollsForStream(path)
|
|
206
|
+
} else if (existingRaw.softDeleted) {
|
|
207
|
+
// Soft-deleted streams block new creation
|
|
199
208
|
throw new Error(
|
|
200
|
-
`Stream
|
|
209
|
+
`Stream has active forks — path cannot be reused until all forks are removed: ${path}`
|
|
201
210
|
)
|
|
211
|
+
} else {
|
|
212
|
+
// Check if config matches (idempotent create)
|
|
213
|
+
const contentTypeMatches =
|
|
214
|
+
(normalizeContentType(options.contentType) ||
|
|
215
|
+
`application/octet-stream`) ===
|
|
216
|
+
(normalizeContentType(existingRaw.contentType) ||
|
|
217
|
+
`application/octet-stream`)
|
|
218
|
+
const ttlMatches = options.ttlSeconds === existingRaw.ttlSeconds
|
|
219
|
+
const expiresMatches = options.expiresAt === existingRaw.expiresAt
|
|
220
|
+
const closedMatches =
|
|
221
|
+
(options.closed ?? false) === (existingRaw.closed ?? false)
|
|
222
|
+
const forkedFromMatches =
|
|
223
|
+
(options.forkedFrom ?? undefined) === existingRaw.forkedFrom
|
|
224
|
+
// Only compare forkOffset when explicitly provided; when omitted the
|
|
225
|
+
// server resolves a default at creation time, so a second PUT that
|
|
226
|
+
// also omits it should still be considered idempotent.
|
|
227
|
+
const forkOffsetMatches =
|
|
228
|
+
options.forkOffset === undefined ||
|
|
229
|
+
options.forkOffset === existingRaw.forkOffset
|
|
230
|
+
|
|
231
|
+
if (
|
|
232
|
+
contentTypeMatches &&
|
|
233
|
+
ttlMatches &&
|
|
234
|
+
expiresMatches &&
|
|
235
|
+
closedMatches &&
|
|
236
|
+
forkedFromMatches &&
|
|
237
|
+
forkOffsetMatches
|
|
238
|
+
) {
|
|
239
|
+
// Idempotent success - return existing stream
|
|
240
|
+
return existingRaw
|
|
241
|
+
} else {
|
|
242
|
+
// Config mismatch - conflict
|
|
243
|
+
throw new Error(
|
|
244
|
+
`Stream already exists with different configuration: ${path}`
|
|
245
|
+
)
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// Fork creation: validate source stream and resolve fork parameters
|
|
251
|
+
const isFork = !!options.forkedFrom
|
|
252
|
+
let forkOffset = `0000000000000000_0000000000000000`
|
|
253
|
+
let sourceContentType: string | undefined
|
|
254
|
+
let sourceStream: Stream | undefined
|
|
255
|
+
|
|
256
|
+
if (isFork) {
|
|
257
|
+
sourceStream = this.streams.get(options.forkedFrom!)
|
|
258
|
+
if (!sourceStream) {
|
|
259
|
+
throw new Error(`Source stream not found: ${options.forkedFrom}`)
|
|
260
|
+
}
|
|
261
|
+
if (sourceStream.softDeleted) {
|
|
262
|
+
throw new Error(`Source stream is soft-deleted: ${options.forkedFrom}`)
|
|
263
|
+
}
|
|
264
|
+
if (this.isExpired(sourceStream)) {
|
|
265
|
+
throw new Error(`Source stream not found: ${options.forkedFrom}`)
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
sourceContentType = sourceStream.contentType
|
|
269
|
+
|
|
270
|
+
// Resolve fork offset: use provided or source's currentOffset
|
|
271
|
+
if (options.forkOffset) {
|
|
272
|
+
forkOffset = options.forkOffset
|
|
273
|
+
} else {
|
|
274
|
+
forkOffset = sourceStream.currentOffset
|
|
202
275
|
}
|
|
276
|
+
|
|
277
|
+
// Validate: zeroOffset <= forkOffset <= source.currentOffset
|
|
278
|
+
const zeroOffset = `0000000000000000_0000000000000000`
|
|
279
|
+
if (forkOffset < zeroOffset || sourceStream.currentOffset < forkOffset) {
|
|
280
|
+
throw new Error(`Invalid fork offset: ${forkOffset}`)
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// Increment source refcount
|
|
284
|
+
sourceStream.refCount++
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// Determine content type: use options, or inherit from source if fork
|
|
288
|
+
let contentType = options.contentType
|
|
289
|
+
if (!contentType || contentType.trim() === ``) {
|
|
290
|
+
if (isFork) {
|
|
291
|
+
contentType = sourceContentType
|
|
292
|
+
}
|
|
293
|
+
} else if (
|
|
294
|
+
isFork &&
|
|
295
|
+
normalizeContentType(contentType) !==
|
|
296
|
+
normalizeContentType(sourceContentType)
|
|
297
|
+
) {
|
|
298
|
+
throw new Error(`Content type mismatch with source stream`)
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// Compute effective expiry for forks
|
|
302
|
+
let effectiveExpiresAt = options.expiresAt
|
|
303
|
+
let effectiveTtlSeconds = options.ttlSeconds
|
|
304
|
+
if (isFork) {
|
|
305
|
+
const resolved = this.resolveForkExpiry(options, sourceStream!)
|
|
306
|
+
effectiveExpiresAt = resolved.expiresAt
|
|
307
|
+
effectiveTtlSeconds = resolved.ttlSeconds
|
|
203
308
|
}
|
|
204
309
|
|
|
205
310
|
const stream: Stream = {
|
|
206
311
|
path,
|
|
207
|
-
contentType
|
|
312
|
+
contentType,
|
|
208
313
|
messages: [],
|
|
209
|
-
currentOffset: `0000000000000000_0000000000000000`,
|
|
210
|
-
ttlSeconds:
|
|
211
|
-
expiresAt:
|
|
314
|
+
currentOffset: isFork ? forkOffset : `0000000000000000_0000000000000000`,
|
|
315
|
+
ttlSeconds: effectiveTtlSeconds,
|
|
316
|
+
expiresAt: effectiveExpiresAt,
|
|
212
317
|
createdAt: Date.now(),
|
|
318
|
+
lastAccessedAt: Date.now(),
|
|
213
319
|
closed: options.closed ?? false,
|
|
320
|
+
refCount: 0,
|
|
321
|
+
forkedFrom: isFork ? options.forkedFrom : undefined,
|
|
322
|
+
forkOffset: isFork ? forkOffset : undefined,
|
|
214
323
|
}
|
|
215
324
|
|
|
216
325
|
// If initial data is provided, append it
|
|
217
326
|
if (options.initialData && options.initialData.length > 0) {
|
|
218
|
-
|
|
327
|
+
try {
|
|
328
|
+
this.appendToStream(stream, options.initialData, true) // isInitialCreate = true
|
|
329
|
+
} catch (err) {
|
|
330
|
+
// Rollback source refcount on failure
|
|
331
|
+
if (isFork && sourceStream) {
|
|
332
|
+
sourceStream.refCount--
|
|
333
|
+
}
|
|
334
|
+
throw err
|
|
335
|
+
}
|
|
219
336
|
}
|
|
220
337
|
|
|
221
338
|
this.streams.set(path, stream)
|
|
222
339
|
return stream
|
|
223
340
|
}
|
|
224
341
|
|
|
342
|
+
/**
|
|
343
|
+
* Resolve fork expiry per the decision table.
|
|
344
|
+
* Forks have independent lifetimes — no capping at source expiry.
|
|
345
|
+
*/
|
|
346
|
+
private resolveForkExpiry(
|
|
347
|
+
opts: { ttlSeconds?: number; expiresAt?: string },
|
|
348
|
+
sourceMeta: Stream
|
|
349
|
+
): { ttlSeconds?: number; expiresAt?: string } {
|
|
350
|
+
// Fork explicitly requests TTL — use it
|
|
351
|
+
if (opts.ttlSeconds !== undefined) {
|
|
352
|
+
return { ttlSeconds: opts.ttlSeconds }
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// Fork explicitly requests Expires-At — use it
|
|
356
|
+
if (opts.expiresAt) {
|
|
357
|
+
return { expiresAt: opts.expiresAt }
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// No expiry requested — inherit from source
|
|
361
|
+
if (sourceMeta.ttlSeconds !== undefined) {
|
|
362
|
+
return { ttlSeconds: sourceMeta.ttlSeconds }
|
|
363
|
+
}
|
|
364
|
+
if (sourceMeta.expiresAt) {
|
|
365
|
+
return { expiresAt: sourceMeta.expiresAt }
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// Source has no expiry either
|
|
369
|
+
return {}
|
|
370
|
+
}
|
|
371
|
+
|
|
225
372
|
/**
|
|
226
373
|
* Get a stream by path.
|
|
227
374
|
* Returns undefined if stream doesn't exist or is expired.
|
|
375
|
+
* Returns soft-deleted streams (caller should check stream.softDeleted).
|
|
228
376
|
*/
|
|
229
377
|
get(path: string): Stream | undefined {
|
|
230
|
-
|
|
378
|
+
const stream = this.streams.get(path)
|
|
379
|
+
if (!stream) {
|
|
380
|
+
return undefined
|
|
381
|
+
}
|
|
382
|
+
if (this.isExpired(stream)) {
|
|
383
|
+
if (stream.refCount > 0) {
|
|
384
|
+
// Expired with refs: soft-delete instead of full delete
|
|
385
|
+
stream.softDeleted = true
|
|
386
|
+
return stream
|
|
387
|
+
}
|
|
388
|
+
this.delete(path)
|
|
389
|
+
return undefined
|
|
390
|
+
}
|
|
391
|
+
return stream
|
|
231
392
|
}
|
|
232
393
|
|
|
233
394
|
/**
|
|
234
|
-
* Check if a stream exists
|
|
395
|
+
* Check if a stream exists, is not expired, and is not soft-deleted.
|
|
235
396
|
*/
|
|
236
397
|
has(path: string): boolean {
|
|
237
|
-
|
|
398
|
+
const stream = this.get(path)
|
|
399
|
+
if (!stream) return false
|
|
400
|
+
if (stream.softDeleted) return false
|
|
401
|
+
return true
|
|
238
402
|
}
|
|
239
403
|
|
|
240
404
|
/**
|
|
241
405
|
* Delete a stream.
|
|
406
|
+
* If the stream has forks (refCount > 0), it is soft-deleted instead of fully removed.
|
|
407
|
+
* Returns true if the stream was found and deleted (or soft-deleted).
|
|
242
408
|
*/
|
|
243
409
|
delete(path: string): boolean {
|
|
244
|
-
|
|
410
|
+
const stream = this.streams.get(path)
|
|
411
|
+
if (!stream) {
|
|
412
|
+
return false
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
// Already soft-deleted: idempotent success
|
|
416
|
+
if (stream.softDeleted) {
|
|
417
|
+
return true
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
// If there are forks referencing this stream, soft-delete
|
|
421
|
+
if (stream.refCount > 0) {
|
|
422
|
+
stream.softDeleted = true
|
|
423
|
+
return true
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
// RefCount == 0: full delete with cascading GC
|
|
427
|
+
this.deleteWithCascade(path)
|
|
428
|
+
return true
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/**
|
|
432
|
+
* Fully delete a stream and cascade to soft-deleted parents
|
|
433
|
+
* whose refcount drops to zero.
|
|
434
|
+
*/
|
|
435
|
+
private deleteWithCascade(path: string): void {
|
|
436
|
+
const stream = this.streams.get(path)
|
|
437
|
+
if (!stream) return
|
|
438
|
+
|
|
439
|
+
const forkedFrom = stream.forkedFrom
|
|
440
|
+
|
|
441
|
+
// Delete this stream's data
|
|
442
|
+
this.streams.delete(path)
|
|
245
443
|
this.cancelLongPollsForStream(path)
|
|
246
|
-
|
|
444
|
+
|
|
445
|
+
// If this stream is a fork, decrement the source's refcount
|
|
446
|
+
if (forkedFrom) {
|
|
447
|
+
const parent = this.streams.get(forkedFrom)
|
|
448
|
+
if (parent) {
|
|
449
|
+
parent.refCount--
|
|
450
|
+
if (parent.refCount < 0) {
|
|
451
|
+
parent.refCount = 0
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
// If parent refcount hit 0 and parent is soft-deleted, cascade
|
|
455
|
+
if (parent.refCount === 0 && parent.softDeleted) {
|
|
456
|
+
this.deleteWithCascade(forkedFrom)
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
}
|
|
247
460
|
}
|
|
248
461
|
|
|
249
462
|
/**
|
|
@@ -403,6 +616,11 @@ export class StreamStore {
|
|
|
403
616
|
throw new Error(`Stream not found: ${path}`)
|
|
404
617
|
}
|
|
405
618
|
|
|
619
|
+
// Guard against soft-deleted streams
|
|
620
|
+
if (stream.softDeleted) {
|
|
621
|
+
throw new Error(`Stream is soft-deleted: ${path}`)
|
|
622
|
+
}
|
|
623
|
+
|
|
406
624
|
// Check if stream is closed
|
|
407
625
|
if (stream.closed) {
|
|
408
626
|
// Check if this is a duplicate of the closing request (idempotent producer)
|
|
@@ -568,6 +786,10 @@ export class StreamStore {
|
|
|
568
786
|
return null
|
|
569
787
|
}
|
|
570
788
|
|
|
789
|
+
if (stream.softDeleted) {
|
|
790
|
+
throw new Error(`Stream is soft-deleted: ${path}`)
|
|
791
|
+
}
|
|
792
|
+
|
|
571
793
|
const alreadyClosed = stream.closed ?? false
|
|
572
794
|
stream.closed = true
|
|
573
795
|
|
|
@@ -686,6 +908,7 @@ export class StreamStore {
|
|
|
686
908
|
|
|
687
909
|
/**
|
|
688
910
|
* Read messages from a stream starting at the given offset.
|
|
911
|
+
* For forked streams, stitches messages from the source chain and the fork's own messages.
|
|
689
912
|
* @throws Error if stream doesn't exist or is expired
|
|
690
913
|
*/
|
|
691
914
|
read(
|
|
@@ -699,16 +922,31 @@ export class StreamStore {
|
|
|
699
922
|
|
|
700
923
|
// No offset or -1 means start from beginning
|
|
701
924
|
if (!offset || offset === `-1`) {
|
|
925
|
+
if (stream.forkedFrom) {
|
|
926
|
+
// Read all inherited messages from source chain, plus fork's own
|
|
927
|
+
const inherited = this.readForkedMessages(
|
|
928
|
+
stream.forkedFrom,
|
|
929
|
+
undefined,
|
|
930
|
+
stream.forkOffset!
|
|
931
|
+
)
|
|
932
|
+
return {
|
|
933
|
+
messages: [...inherited, ...stream.messages],
|
|
934
|
+
upToDate: true,
|
|
935
|
+
}
|
|
936
|
+
}
|
|
702
937
|
return {
|
|
703
938
|
messages: [...stream.messages],
|
|
704
939
|
upToDate: true,
|
|
705
940
|
}
|
|
706
941
|
}
|
|
707
942
|
|
|
708
|
-
|
|
943
|
+
if (stream.forkedFrom) {
|
|
944
|
+
return this.readFromFork(stream, offset)
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
// Non-forked stream: find messages after the given offset
|
|
709
948
|
const offsetIndex = this.findOffsetIndex(stream, offset)
|
|
710
949
|
if (offsetIndex === -1) {
|
|
711
|
-
// Offset is at or past the end
|
|
712
950
|
return {
|
|
713
951
|
messages: [],
|
|
714
952
|
upToDate: true,
|
|
@@ -721,6 +959,88 @@ export class StreamStore {
|
|
|
721
959
|
}
|
|
722
960
|
}
|
|
723
961
|
|
|
962
|
+
/**
|
|
963
|
+
* Read from a forked stream, stitching inherited and own messages.
|
|
964
|
+
*/
|
|
965
|
+
private readFromFork(
|
|
966
|
+
stream: Stream,
|
|
967
|
+
offset: string
|
|
968
|
+
): { messages: Array<StreamMessage>; upToDate: boolean } {
|
|
969
|
+
const messages: Array<StreamMessage> = []
|
|
970
|
+
|
|
971
|
+
// If offset is before the forkOffset, read from source chain
|
|
972
|
+
if (offset < stream.forkOffset!) {
|
|
973
|
+
const inherited = this.readForkedMessages(
|
|
974
|
+
stream.forkedFrom!,
|
|
975
|
+
offset,
|
|
976
|
+
stream.forkOffset!
|
|
977
|
+
)
|
|
978
|
+
messages.push(...inherited)
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
// Read fork's own messages (offset >= forkOffset)
|
|
982
|
+
const ownMessages = this.readOwnMessages(stream, offset)
|
|
983
|
+
messages.push(...ownMessages)
|
|
984
|
+
|
|
985
|
+
return {
|
|
986
|
+
messages,
|
|
987
|
+
upToDate: true,
|
|
988
|
+
}
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
/**
|
|
992
|
+
* Read a stream's own messages starting after the given offset.
|
|
993
|
+
*/
|
|
994
|
+
private readOwnMessages(
|
|
995
|
+
stream: Stream,
|
|
996
|
+
offset: string
|
|
997
|
+
): Array<StreamMessage> {
|
|
998
|
+
const offsetIndex = this.findOffsetIndex(stream, offset)
|
|
999
|
+
if (offsetIndex === -1) {
|
|
1000
|
+
return []
|
|
1001
|
+
}
|
|
1002
|
+
return stream.messages.slice(offsetIndex)
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
/**
|
|
1006
|
+
* Recursively read messages from a fork's source chain.
|
|
1007
|
+
* Reads from source (and its sources if also forked), capped at forkOffset.
|
|
1008
|
+
* Does NOT check softDeleted — forks must read through soft-deleted sources.
|
|
1009
|
+
*/
|
|
1010
|
+
private readForkedMessages(
|
|
1011
|
+
sourcePath: string,
|
|
1012
|
+
offset: string | undefined,
|
|
1013
|
+
capOffset: string
|
|
1014
|
+
): Array<StreamMessage> {
|
|
1015
|
+
const source = this.streams.get(sourcePath)
|
|
1016
|
+
if (!source) {
|
|
1017
|
+
return []
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
const messages: Array<StreamMessage> = []
|
|
1021
|
+
|
|
1022
|
+
// If source is also a fork and offset is before source's forkOffset,
|
|
1023
|
+
// recursively read from source's source
|
|
1024
|
+
if (source.forkedFrom && (!offset || offset < source.forkOffset!)) {
|
|
1025
|
+
const inherited = this.readForkedMessages(
|
|
1026
|
+
source.forkedFrom,
|
|
1027
|
+
offset,
|
|
1028
|
+
// Cap at the minimum of source's forkOffset and our capOffset
|
|
1029
|
+
source.forkOffset! < capOffset ? source.forkOffset! : capOffset
|
|
1030
|
+
)
|
|
1031
|
+
messages.push(...inherited)
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
// Read source's own messages, capped at capOffset
|
|
1035
|
+
for (const msg of source.messages) {
|
|
1036
|
+
if (offset && msg.offset <= offset) continue
|
|
1037
|
+
if (msg.offset > capOffset) break
|
|
1038
|
+
messages.push(msg)
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
return messages
|
|
1042
|
+
}
|
|
1043
|
+
|
|
724
1044
|
/**
|
|
725
1045
|
* Format messages for response.
|
|
726
1046
|
* For JSON mode, wraps concatenated data in array brackets.
|
|
@@ -767,6 +1087,13 @@ export class StreamStore {
|
|
|
767
1087
|
throw new Error(`Stream not found: ${path}`)
|
|
768
1088
|
}
|
|
769
1089
|
|
|
1090
|
+
// For forks: if offset is in the inherited range (< forkOffset),
|
|
1091
|
+
// read and return immediately instead of long-polling
|
|
1092
|
+
if (stream.forkedFrom && offset < stream.forkOffset!) {
|
|
1093
|
+
const { messages } = this.read(path, offset)
|
|
1094
|
+
return { messages, timedOut: false }
|
|
1095
|
+
}
|
|
1096
|
+
|
|
770
1097
|
// Check if there are already new messages
|
|
771
1098
|
const { messages } = this.read(path, offset)
|
|
772
1099
|
if (messages.length > 0) {
|
package/src/types.ts
CHANGED
|
@@ -67,6 +67,13 @@ export interface Stream {
|
|
|
67
67
|
*/
|
|
68
68
|
createdAt: number
|
|
69
69
|
|
|
70
|
+
/**
|
|
71
|
+
* Timestamp of the last read or write (for TTL renewal).
|
|
72
|
+
* Initialized to createdAt. Updated on GET reads and POST appends.
|
|
73
|
+
* HEAD requests do NOT update this field.
|
|
74
|
+
*/
|
|
75
|
+
lastAccessedAt: number
|
|
76
|
+
|
|
70
77
|
/**
|
|
71
78
|
* Producer states for idempotent writes.
|
|
72
79
|
* Maps producer ID to their epoch and sequence state.
|
|
@@ -88,6 +95,28 @@ export interface Stream {
|
|
|
88
95
|
epoch: number
|
|
89
96
|
seq: number
|
|
90
97
|
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Source stream path (set when this stream is a fork).
|
|
101
|
+
*/
|
|
102
|
+
forkedFrom?: string
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Divergence offset from the source stream.
|
|
106
|
+
* Format: "0000000000000000_0000000000000000"
|
|
107
|
+
*/
|
|
108
|
+
forkOffset?: string
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Number of forks referencing this stream.
|
|
112
|
+
* Defaults to 0.
|
|
113
|
+
*/
|
|
114
|
+
refCount: number
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Whether this stream is logically deleted but retained for fork readers.
|
|
118
|
+
*/
|
|
119
|
+
softDeleted?: boolean
|
|
91
120
|
}
|
|
92
121
|
|
|
93
122
|
/**
|