@kairos-es/core 1.0.0-alpha.5 → 1.0.0-alpha.7

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.
@@ -5,6 +5,15 @@
5
5
  * the cursor's exclusive lower bound) and the poll loop that together turn a paged
6
6
  * read into a live subscription.
7
7
  *
8
+ * The algorithm is `subscribeItems`, generic over the item `A` it delivers and the
9
+ * cursor `C` it resumes from, because nothing in it is about an event: any source
10
+ * that can page-read strictly after a cursor and name the cursor of what it
11
+ * returned is subscribable by the same loop. `subscribeStream` is the event-store
12
+ * shape — `SequencedEvent` ordered by `Position` — over the same loop, and it is
13
+ * what every engine calls, unchanged by the generalisation. A second kind of
14
+ * source reuses the loop rather than growing a second one, for the reason the next
15
+ * paragraph gives.
16
+ *
8
17
  * It lives in core because delivery semantics are contract semantics. An engine
9
18
  * supplies TRANSPORT through two STUBBABLE seams — `fetch`, a paged read strictly
10
19
  * after a cursor, and `listen`, an optional wake source — and inherits the delivery
@@ -15,48 +24,52 @@
15
24
  * Both seams take plain in-memory values in a test, so the wake/poll/de-dup logic
16
25
  * is exercisable with no live backend at all.
17
26
  *
18
- * All THREE are the callers, and the count matters to a reader here: it means there
19
- * is exactly ONE delivery algorithm in this repo, and it is the code below. The
27
+ * All THREE engines are callers, and the count matters to a reader here: it means
28
+ * there is exactly ONE delivery algorithm in this repo, and it is the code below. The
20
29
  * in-memory store is a caller like the rest — it hands in a `fetch` over its log
21
30
  * `Ref` and its append `PubSub` as a wake source and keeps no delivery logic of its
22
31
  * own — which is what makes the shared contract suite's subscribe cases a comparison
23
32
  * of two BACKENDS rather than of two delivery algorithms.
24
33
  *
25
- * Correctness rests on the periodic position-based reconcile — a re-read from the
34
+ * Correctness rests on the periodic cursor-based reconcile — a re-read from the
26
35
  * cursor every poll interval — and NEVER on the wake source, which only lowers
27
36
  * delivery latency below that interval. A subscription with no wake source at all
28
37
  * is therefore complete and correct, merely slower, which is why `listen` is
29
38
  * optional in the TYPE rather than by the convention of passing `Stream.never`:
30
39
  * poll-only delivery is a supported shape, not a degraded one.
31
40
  *
32
- * ## The precondition every engine inherits: ASCENDING COMMIT ORDER
41
+ * ## The precondition every caller inherits: ASCENDING COMMIT ORDER
33
42
  *
34
- * A position that is committed and VISIBLE must never be below one already
35
- * delivered. Gaps are fine: the cursor is an ascending exclusive lower bound, so a
36
- * number that is allocated and never committed, or never allocated at all, is
37
- * simply a number the machine never sees. Out-of-order commits are not fine. If one
43
+ * It binds EVERY caller, over whatever cursor it chose — an engine's `Position`, or
44
+ * any other `C` handed to `subscribeItems` — and the example below is written in
45
+ * positions only because the engines are the callers it was first written for. A
46
+ * cursor that is committed and VISIBLE must never be below one already delivered.
47
+ * Gaps are fine: the cursor is an ascending exclusive lower bound, so a number
48
+ * that is allocated and never committed, or never allocated at all, is simply a
49
+ * number the machine never sees. Out-of-order commits are not fine. If one
38
50
  * writer allocates position 5 and another allocates 6, and 6 commits first, a poll
39
51
  * landing between the two commits delivers 6 and advances the cursor past it — and
40
52
  * 5 is then lost forever, because no later read looks below the cursor and nothing
41
- * anywhere notices the hole. An engine whose concurrent writers can commit out of
53
+ * anywhere notices the hole. A caller whose concurrent writers can commit out of
42
54
  * order MUST serialise them, or this machine will silently skip. An engine that
43
55
  * takes an exclusive lock on both of its append paths discharges the obligation
44
56
  * that way; an engine whose append is a single statement discharges it
45
57
  * structurally.
46
58
  *
47
59
  * The obligation is written down HERE, in the algorithm that depends on it, rather
48
- * than in whichever engine happens to satisfy it. It is a property of this delivery
49
- * loop and of nothing else, so an engine author reading only their own source has
60
+ * than in whichever caller happens to satisfy it. It is a property of this delivery
61
+ * loop and of nothing else, so an author reading only their own source has
50
62
  * no way to discover that anything downstream needs it — and the failure mode is
51
63
  * the worst kind there is: no error, no defect, no failing test, just an event that
52
64
  * a subscriber never receives. A single engine owning both its append path and its
53
65
  * own wake loop can leave the assumption implicit; a shared machine cannot.
54
66
  *
55
- * ## `catchUpPageSize` bounds the READ, not the emission
67
+ * ## `catchUpPageSize` bounds the READ, not the emission — unless `emit: 'page'`
56
68
  *
57
- * `drainFrom` below PAGES the catch-up read — `catchUpPageSize` bounds each
58
- * `fetch` — but it COLLECTS every page into one array, which is emitted as a SINGLE
59
- * chunk. So peak memory at subscription start is O(backlog) rather than O(page): a
69
+ * Under the default `emit: 'backlog'`, which is what `subscribeStream` and so every
70
+ * engine uses, `drainFrom` below PAGES the catch-up read — `catchUpPageSize` bounds
71
+ * each `fetch` — but it COLLECTS every page into one array, which is emitted as a
72
+ * SINGLE chunk. So peak memory at subscription start is O(backlog) rather than O(page): a
60
73
  * subscriber joining behind a large log materialises the whole backlog before its
61
74
  * consumer sees the first element.
62
75
  *
@@ -67,6 +80,19 @@
67
80
  * stay small even against a one-chunk catch-up. The cost therefore lands on backlog
68
81
  * memory alone, and it lands hardest on the durable-log-with-in-memory-view mode,
69
82
  * where an ephemeral view re-catches-up the entire log on every boot.
83
+ *
84
+ * `emit: 'page'` changes exactly that and nothing else. Each fetched page is
85
+ * emitted as its own chunk and the cursor advances past it before the next read, so
86
+ * peak memory is O(page) and the consumer sees the first item after ONE read rather
87
+ * than after the whole backlog. Delivery is unchanged: the same items in the same
88
+ * order, the same de-dup, the same wake and poll. What a caller gives up is the
89
+ * one-chunk catch-up, so it must not read meaning into chunk boundaries under
90
+ * either mode. It is for a consumer that does NOT re-chunk downstream and whose
91
+ * catch-up can be the whole of a large source — a full resync of a table, say —
92
+ * where holding the backlog would be the cost the paging was meant to avoid. The
93
+ * engines keep `'backlog'` because the read side's re-chunking already bounds what
94
+ * they commit, and changing their emission would move a behaviour nobody asked to
95
+ * move.
70
96
  */
71
97
  import {
72
98
  Effect,
@@ -107,6 +133,15 @@ export const DEFAULT_CATCH_UP_PAGE_SIZE = 512
107
133
  */
108
134
  export const DEFAULT_POLL_INTERVAL_MILLIS = 1000
109
135
 
136
+ /** A usable `pollInterval`: see `subscriptionTuningFields` for the bounds' argument. */
137
+ const PollIntervalMillis = Schema.Number.check(
138
+ Schema.isGreaterThan(0),
139
+ Schema.isFinite(),
140
+ )
141
+
142
+ /** A usable `catchUpPageSize`: see `subscriptionTuningFields` for the bounds' argument. */
143
+ const CatchUpPageSize = Schema.Int.check(Schema.isGreaterThan(0))
144
+
110
145
  /**
111
146
  * The two subscribe-tuning config FIELDS, for an engine whose config is a `Schema`
112
147
  * to spread into its own struct.
@@ -137,14 +172,36 @@ export const DEFAULT_POLL_INTERVAL_MILLIS = 1000
137
172
  * absent key, never an explicit `undefined`.
138
173
  */
139
174
  export const subscriptionTuningFields = {
140
- pollInterval: Schema.optionalKey(
141
- Schema.Number.check(Schema.isGreaterThan(0), Schema.isFinite()),
142
- ),
143
- catchUpPageSize: Schema.optionalKey(
144
- Schema.Int.check(Schema.isGreaterThan(0)),
145
- ),
175
+ pollInterval: Schema.optionalKey(PollIntervalMillis),
176
+ catchUpPageSize: Schema.optionalKey(CatchUpPageSize),
146
177
  } as const
147
178
 
179
+ /**
180
+ * Why a caller-supplied tuning knob is unusable, naming the field and the value, or
181
+ * `undefined` when both are usable or absent. It judges with the SAME two schemas
182
+ * the config fields are built from, so `subscribeItems`, whose caller may have no
183
+ * config to decode, accepts exactly what an engine's config accepts and the bounds
184
+ * have one home.
185
+ */
186
+ const tuningFault = (deps: {
187
+ readonly pollInterval?: number
188
+ readonly catchUpPageSize?: number
189
+ }): string | undefined => {
190
+ if (
191
+ deps.pollInterval !== undefined &&
192
+ !Schema.is(PollIntervalMillis)(deps.pollInterval)
193
+ ) {
194
+ return `subscribeItems: pollInterval must be a finite number of milliseconds greater than 0, got ${deps.pollInterval}`
195
+ }
196
+ if (
197
+ deps.catchUpPageSize !== undefined &&
198
+ !Schema.is(CatchUpPageSize)(deps.catchUpPageSize)
199
+ ) {
200
+ return `subscribeItems: catchUpPageSize must be an integer greater than 0, got ${deps.catchUpPageSize}`
201
+ }
202
+ return undefined
203
+ }
204
+
148
205
  /**
149
206
  * Retry a TRANSIENT poll fault (an `E` from one reconcile read) on a capped
150
207
  * exponential backoff rather than letting `Stream.orDie` kill a subscription that
@@ -161,9 +218,79 @@ const POLL_RETRY_SCHEDULE = Schedule.max([
161
218
  ])
162
219
 
163
220
  /**
164
- * The two seams the subscribe machine runs over, plus the two tuning knobs. `E` is
165
- * the engine's own infrastructure error — whatever its reads fail with — and it
166
- * stays on the channel through to `makeContractStore`, which dies it.
221
+ * How a subscription hands its catch-up to the consumer: the whole backlog as ONE
222
+ * chunk (`'backlog'`), or each fetched page as a chunk of its own (`'page'`). The
223
+ * module doc's "`catchUpPageSize` bounds the READ" section owns what each costs.
224
+ *
225
+ * A closed union of two words rather than a boolean, so a call site says WHICH
226
+ * behaviour it chose instead of what it turned off, and a third shape, should one
227
+ * ever be argued for, is added beside these rather than encoded in a second flag.
228
+ */
229
+ export type SubscriptionEmission = 'backlog' | 'page'
230
+
231
+ /**
232
+ * The machine's inputs over ANY item `A` with an ascending cursor `C`: the two seams,
233
+ * where the cursor comes from and starts, and the tuning.
234
+ *
235
+ * Generic in the item and the cursor because nothing in the algorithm is about an
236
+ * EVENT. The wake queue, the poll, the paged reconcile and the exclusive-lower-bound
237
+ * de-dup need only three things of what they deliver: a page-read strictly after a
238
+ * cursor, a way to read the cursor OFF a delivered item, and a starting cursor. A
239
+ * log of `SequencedEvent`s ordered by `Position` is one such source; a feed of rows
240
+ * ordered by a feed-local sequence is another, and a second copy of this loop for it
241
+ * would be exactly the divergence the module doc argues against.
242
+ *
243
+ * The machine never COMPARES two cursors. `fetch` is the only thing that interprets
244
+ * one — its exclusive lower bound is what does the de-dup — so `C` needs no ordering
245
+ * instance here, and the ascending-commit-order obligation in the module doc is the
246
+ * caller's, stated over whatever `C` it chose.
247
+ *
248
+ * `initial` is REQUIRED, unlike `subscribeStream`'s `after`. Only a cursor type the
249
+ * machine knows can have a default — `ORIGIN` for `Position` — and for any other the
250
+ * caller is the only party that knows what "before everything" is.
251
+ *
252
+ * `pollInterval` and `catchUpPageSize` are optional here and fall back to
253
+ * `DEFAULT_POLL_INTERVAL_MILLIS` and `DEFAULT_CATCH_UP_PAGE_SIZE`: a caller with no
254
+ * config of its own takes the same figures every engine does, from the same place.
255
+ * The event-store shape, `SubscriptionDeps`, keeps them required, because every
256
+ * engine already resolves them from its config and that type is unchanged.
257
+ */
258
+ export interface ItemSubscriptionDeps<A, C, E> {
259
+ /**
260
+ * Page-read the items strictly after `after`, up to `limit`, ascending by cursor.
261
+ * Returning FEWER than `limit` signals the tail is drained. The exclusive lower
262
+ * bound IS the de-dup: a re-read from the cursor never returns what was delivered.
263
+ */
264
+ readonly fetch: (
265
+ after: C,
266
+ limit: ReadLimit,
267
+ ) => Effect.Effect<ReadonlyArray<A>, E>
268
+ /** The cursor of a delivered item: the next `fetch` starts strictly after it. */
269
+ readonly cursorOf: (item: A) => C
270
+ /** The cursor to start from, exclusive: the first `fetch` reads strictly after it. */
271
+ readonly initial: C
272
+ /** The raw wake source — see `SubscriptionDeps.listen`, whose rules it shares. */
273
+ readonly listen?: Stream.Stream<unknown, E>
274
+ /**
275
+ * The poll interval in milliseconds (default `DEFAULT_POLL_INTERVAL_MILLIS`):
276
+ * finite and greater than 0, or `subscribeItems` dies.
277
+ */
278
+ readonly pollInterval?: number
279
+ /**
280
+ * The catch-up page size (default `DEFAULT_CATCH_UP_PAGE_SIZE`): an integer
281
+ * greater than 0, or `subscribeItems` dies.
282
+ */
283
+ readonly catchUpPageSize?: number
284
+ /** The catch-up's chunking (default `'backlog'`); see `SubscriptionEmission`. */
285
+ readonly emit?: SubscriptionEmission
286
+ }
287
+
288
+ /**
289
+ * The two seams the event-store subscribe machine runs over, plus the two tuning
290
+ * knobs: `ItemSubscriptionDeps` fixed to `SequencedEvent` and `Position`, the shape
291
+ * every engine's `subscribe` already hands to `subscribeStream`. `E` is the engine's
292
+ * own infrastructure error — whatever its reads fail with — and it stays on the
293
+ * channel through to `makeContractStore`, which dies it.
167
294
  *
168
295
  * ONE type parameter and not two, deliberately: a wake source with an error type of
169
296
  * its own is `Stream.orDie`d by the caller, yielding a `Stream<unknown, never>`
@@ -199,19 +326,47 @@ export interface SubscriptionDeps<E> {
199
326
  }
200
327
 
201
328
  /**
202
- * Build the subscription `Stream`: catch up from `after`, then track the live tail.
203
- * The cursor threads through the `Stream.paginate` state, starting at `after` and
204
- * advancing to the last delivered position each batch; the stream never completes
205
- * on its own (the next state is always `Option.some`), so the enclosing scope
206
- * interrupts it on close. An `E` mid-stream stays on the channel; `makeContractStore` dies it
207
- * (subscribe is `E = never` on the contract).
329
+ * Build a subscription `Stream` over any ascending-cursor source: catch up from
330
+ * `initial`, then track the live tail. The cursor threads through the
331
+ * `Stream.paginate` state, starting at `initial` and advancing to the cursor of the
332
+ * last delivered item each batch; the stream never completes on its own (the next
333
+ * state is always `Option.some`), so the enclosing scope interrupts it on close. An
334
+ * `E` mid-stream stays on the channel, for the caller to die or to supervise.
335
+ *
336
+ * This is THE delivery algorithm, and `subscribeStream` runs the same machine. Under
337
+ * `emit: 'backlog'` a batch is everything a drain to the tail returned; under
338
+ * `'page'` it is one fetched page, so the paginate step and the page coincide and a
339
+ * full page is followed at once by the next read rather than by a wait.
340
+ *
341
+ * A `pollInterval` or `catchUpPageSize` the config fields would refuse is a DEFECT
342
+ * here, raised by the stream before it reads or forks anything, with a message
343
+ * naming the field. This caller may have no config whose decode would have caught
344
+ * it, and unchecked the two failures are silent or late: a page size of 0 gives a
345
+ * stream that never emits and never fails, and 1.5 dies only at the first read,
346
+ * inside `ReadLimit`, in words that name neither knob.
208
347
  */
209
- export const subscribeStream = <E>(
210
- deps: SubscriptionDeps<E>,
211
- after: Position | undefined,
212
- ): Stream.Stream<SequencedEvent, E, Scope.Scope> =>
348
+ export const subscribeItems = <A, C, E>(
349
+ deps: ItemSubscriptionDeps<A, C, E>,
350
+ ): Stream.Stream<A, E, Scope.Scope> => {
351
+ const fault = tuningFault(deps)
352
+ return fault === undefined ? machine(deps) : Stream.die(new Error(fault))
353
+ }
354
+
355
+ /**
356
+ * The machine itself, with no check of its tuning. `subscribeStream` runs it
357
+ * directly, and deliberately: the event path stays exactly as it was, with no
358
+ * second check of figures each SQL engine has already decoded from its config
359
+ * through the same bounds, and the in-memory store's are a test's own options.
360
+ */
361
+ const machine = <A, C, E>(
362
+ deps: ItemSubscriptionDeps<A, C, E>,
363
+ ): Stream.Stream<A, E, Scope.Scope> =>
213
364
  Stream.unwrap(
214
365
  Effect.gen(function* () {
366
+ const pageSize = deps.catchUpPageSize ?? DEFAULT_CATCH_UP_PAGE_SIZE
367
+ const pollInterval = deps.pollInterval ?? DEFAULT_POLL_INTERVAL_MILLIS
368
+ const limit = ReadLimit.make(pageSize)
369
+
215
370
  // The wake primitive: a sliding(1) queue holding at most one pending
216
371
  // signal. The wake source `offer`s an opaque wake on every notification;
217
372
  // the poll loop `take`s (with a timeout). The lost-wakeup guard is
@@ -242,82 +397,102 @@ export const subscribeStream = <E>(
242
397
  // notification is an opaque wake signal.
243
398
  yield* listener.pipe(Stream.runDrain, Effect.forkScoped)
244
399
 
400
+ // One page strictly after `from`: the step `drainFrom` loops, and the whole
401
+ // of a `'page'` read.
402
+ const fetchPage = (from: C): Effect.Effect<ReadonlyArray<A>, E> =>
403
+ deps.fetch(from, limit)
404
+
245
405
  // Reconcile: page-read from `from` until a short page, advancing to the last
246
- // delivered position. Retries a TRANSIENT poll fault on a capped schedule so
406
+ // delivered cursor. Retries a TRANSIENT poll fault on a capped schedule so
247
407
  // a single connection blip does not kill a days-long subscription; a
248
- // persistent fault propagates and dies.
249
- const drainFrom = (
250
- from: Position,
251
- ): Effect.Effect<
252
- {
253
- readonly events: ReadonlyArray<SequencedEvent>
254
- readonly last: Position
255
- },
256
- E
257
- > =>
408
+ // persistent fault propagates. The retry wraps the WHOLE drain, so a fault on
409
+ // page three re-reads pages one and two: nothing has been emitted yet, so
410
+ // that costs reads and never a duplicate.
411
+ const drainFrom = (from: C): Effect.Effect<ReadonlyArray<A>, E> =>
258
412
  Effect.gen(function* () {
259
- const collected: Array<SequencedEvent> = []
413
+ const collected: Array<A> = []
260
414
  let cursor = from
261
415
  // Loop pages until a short page signals the tail is drained.
262
416
  while (true) {
263
- const events = yield* deps.fetch(
264
- cursor,
265
- ReadLimit.make(deps.catchUpPageSize),
266
- )
267
- for (const event of events) {
268
- collected.push(event)
417
+ const items = yield* fetchPage(cursor)
418
+ for (const item of items) {
419
+ collected.push(item)
269
420
  }
270
- const lastEvent = events.at(-1)
271
- if (
272
- lastEvent === undefined ||
273
- events.length < deps.catchUpPageSize
274
- ) {
421
+ const lastItem = items.at(-1)
422
+ if (lastItem === undefined || items.length < pageSize) {
275
423
  break
276
424
  }
277
- cursor = lastEvent.position
425
+ cursor = deps.cursorOf(lastItem)
278
426
  }
279
- const last = collected.at(-1)?.position ?? from
280
- return { events: collected, last }
427
+ return collected
281
428
  }).pipe(Effect.retry(POLL_RETRY_SCHEDULE))
282
429
 
430
+ // The read one paginate step performs, by emission mode. Under `'page'` the
431
+ // retry wraps the single page, which is the whole of what is unemitted.
432
+ const read =
433
+ (deps.emit ?? 'backlog') === 'page'
434
+ ? (from: C) => fetchPage(from).pipe(Effect.retry(POLL_RETRY_SCHEDULE))
435
+ : drainFrom
436
+
283
437
  /**
284
- * Produce the NEXT non-empty batch from a cursor position, together with
285
- * the advanced cursor — a pure `Position → Effect<[events, next]>`. The
286
- * `> boundary` de-dup is enforced by `fetch`'s exclusive lower bound, so a
287
- * live re-read after a wake never re-emits an already-delivered position.
438
+ * Produce the NEXT non-empty batch from a cursor, together with the advanced
439
+ * cursor — a pure `C → Effect<[items, next]>`. The `> boundary` de-dup is
440
+ * enforced by `fetch`'s exclusive lower bound, so a live re-read after a wake
441
+ * never re-emits an already-delivered item.
288
442
  *
289
- * Drain the wake queue BEFORE reading (lost-wakeup guard), then drain from
290
- * the cursor. If a drain yields nothing, wait for a wake but only up to the
291
- * poll interval, then loop and re-read ANYWAY — the periodic position-based
292
- * poll that makes delivery correct independently of any wake. This effect
293
- * only completes by returning a non-empty batch; the scope interrupts it on
294
- * close.
443
+ * Drain the wake queue BEFORE reading (lost-wakeup guard), then read from the
444
+ * cursor. If a read yields nothing, wait for a wake but only up to the poll
445
+ * interval, then loop and re-read ANYWAY — the periodic cursor-based poll that
446
+ * makes delivery correct independently of any wake. This effect only
447
+ * completes by returning a non-empty batch; the scope interrupts it on close.
295
448
  */
296
449
  const nextBatch = (
297
- from: Position,
298
- ): Effect.Effect<readonly [ReadonlyArray<SequencedEvent>, Position], E> =>
450
+ from: C,
451
+ ): Effect.Effect<readonly [ReadonlyArray<A>, C], E> =>
299
452
  Effect.gen(function* () {
300
453
  while (true) {
301
454
  // `Queue.clear` and not `Queue.takeAll`: the drain must NOT wait, and
302
455
  // `takeAll` suspends until at least one signal is present.
303
456
  yield* Queue.clear(wake)
304
- const { events, last } = yield* drainFrom(from)
305
- if (events.length > 0) {
306
- return [events, last] as const
457
+ const items = yield* read(from)
458
+ const last = items.at(-1)
459
+ if (last !== undefined) {
460
+ return [items, deps.cursorOf(last)] as const
307
461
  }
308
462
  // Nothing new: block on a wake, but only up to the poll interval — on
309
463
  // timeout we fall through and the loop re-reads regardless.
310
- yield* Effect.ignore(
311
- Effect.timeout(Queue.take(wake), deps.pollInterval),
312
- )
464
+ yield* Effect.ignore(Effect.timeout(Queue.take(wake), pollInterval))
313
465
  }
314
466
  })
315
467
 
316
- return Stream.paginate(after ?? ORIGIN, (from) =>
468
+ return Stream.paginate(deps.initial, (from) =>
317
469
  Effect.map(
318
470
  nextBatch(from),
319
- ([events, last]) => [events, Option.some(last)] as const,
471
+ ([items, next]) => [items, Option.some(next)] as const,
320
472
  ),
321
473
  )
322
474
  }),
323
475
  )
476
+
477
+ /**
478
+ * Build the event subscription `Stream`: catch up from `after`, then track the live
479
+ * tail. A THIN wrapper over the machine `subscribeItems` runs, fixing the item to
480
+ * `SequencedEvent`, the cursor to its `position`, the start to `ORIGIN` when `after`
481
+ * is absent, and the emission to the default `'backlog'` — so every engine's call
482
+ * site, and every figure it delivers, is what it was before the machine became
483
+ * generic. It skips `subscribeItems`' tuning check, for the reason on `machine`. An `E`
484
+ * mid-stream stays on the channel; `makeContractStore` dies it (subscribe is
485
+ * `E = never` on the contract).
486
+ */
487
+ export const subscribeStream = <E>(
488
+ deps: SubscriptionDeps<E>,
489
+ after: Position | undefined,
490
+ ): Stream.Stream<SequencedEvent, E, Scope.Scope> =>
491
+ machine<SequencedEvent, Position, E>({
492
+ fetch: deps.fetch,
493
+ cursorOf: (event) => event.position,
494
+ initial: after ?? ORIGIN,
495
+ ...(deps.listen === undefined ? {} : { listen: deps.listen }),
496
+ pollInterval: deps.pollInterval,
497
+ catchUpPageSize: deps.catchUpPageSize,
498
+ })