@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/src/store.ts CHANGED
@@ -134,9 +134,9 @@ export class StreamStore {
134
134
  }
135
135
  }
136
136
 
137
- // Check TTL (relative to creation time)
137
+ // Check TTL (sliding window from last access)
138
138
  if (stream.ttlSeconds !== undefined) {
139
- const expiryTime = stream.createdAt + stream.ttlSeconds * 1000
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, deleting it if expired.
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
- // Use getIfNotExpired to treat expired streams as non-existent
181
- const existing = this.getIfNotExpired(path)
182
- if (existing) {
183
- // Check if config matches (idempotent create)
184
- const contentTypeMatches =
185
- (normalizeContentType(options.contentType) ||
186
- `application/octet-stream`) ===
187
- (normalizeContentType(existing.contentType) ||
188
- `application/octet-stream`)
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 already exists with different configuration: ${path}`
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: options.contentType,
312
+ contentType,
208
313
  messages: [],
209
- currentOffset: `0000000000000000_0000000000000000`,
210
- ttlSeconds: options.ttlSeconds,
211
- expiresAt: options.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
- this.appendToStream(stream, options.initialData, true) // isInitialCreate = true
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
- return this.getIfNotExpired(path)
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 (and is not expired).
395
+ * Check if a stream exists, is not expired, and is not soft-deleted.
235
396
  */
236
397
  has(path: string): boolean {
237
- return this.getIfNotExpired(path) !== undefined
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
- // Cancel any pending long-polls for this stream
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
- return this.streams.delete(path)
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
- // Find messages after the given offset
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
  /**