@kairos-es/feed 0.0.0-stage → 0.0.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.
Files changed (132) hide show
  1. package/LICENSE +28 -0
  2. package/README.md +109 -2
  3. package/dist/cjs/Feed.js +6 -0
  4. package/dist/cjs/Feed.js.map +1 -0
  5. package/dist/cjs/FeedSink.js +54 -0
  6. package/dist/cjs/FeedSink.js.map +1 -0
  7. package/dist/cjs/FeedSyncStore.js +10 -0
  8. package/dist/cjs/FeedSyncStore.js.map +1 -0
  9. package/dist/cjs/deliver.js +86 -0
  10. package/dist/cjs/deliver.js.map +1 -0
  11. package/dist/cjs/errors.js +114 -0
  12. package/dist/cjs/errors.js.map +1 -0
  13. package/dist/cjs/identifiers.js +63 -0
  14. package/dist/cjs/identifiers.js.map +1 -0
  15. package/dist/cjs/inMemoryFeed.js +328 -0
  16. package/dist/cjs/inMemoryFeed.js.map +1 -0
  17. package/dist/cjs/inMemorySink.js +142 -0
  18. package/dist/cjs/inMemorySink.js.map +1 -0
  19. package/dist/cjs/index.js +184 -0
  20. package/dist/cjs/index.js.map +1 -0
  21. package/dist/cjs/inspect.js +68 -0
  22. package/dist/cjs/inspect.js.map +1 -0
  23. package/dist/cjs/readiness.js +100 -0
  24. package/dist/cjs/readiness.js.map +1 -0
  25. package/dist/cjs/reconcile.js +164 -0
  26. package/dist/cjs/reconcile.js.map +1 -0
  27. package/dist/cjs/runFeedSync.js +603 -0
  28. package/dist/cjs/runFeedSync.js.map +1 -0
  29. package/dist/cjs/tuning.js +60 -0
  30. package/dist/cjs/tuning.js.map +1 -0
  31. package/dist/dts-cjs/Feed.d.ts +99 -0
  32. package/dist/dts-cjs/Feed.d.ts.map +1 -0
  33. package/dist/dts-cjs/FeedSink.d.ts +189 -0
  34. package/dist/dts-cjs/FeedSink.d.ts.map +1 -0
  35. package/dist/dts-cjs/FeedSyncStore.d.ts +281 -0
  36. package/dist/dts-cjs/FeedSyncStore.d.ts.map +1 -0
  37. package/dist/dts-cjs/deliver.d.ts +16 -0
  38. package/dist/dts-cjs/deliver.d.ts.map +1 -0
  39. package/dist/dts-cjs/errors.d.ts +131 -0
  40. package/dist/dts-cjs/errors.d.ts.map +1 -0
  41. package/dist/dts-cjs/identifiers.d.ts +67 -0
  42. package/dist/dts-cjs/identifiers.d.ts.map +1 -0
  43. package/dist/dts-cjs/inMemoryFeed.d.ts +32 -0
  44. package/dist/dts-cjs/inMemoryFeed.d.ts.map +1 -0
  45. package/dist/dts-cjs/inMemorySink.d.ts +61 -0
  46. package/dist/dts-cjs/inMemorySink.d.ts.map +1 -0
  47. package/dist/dts-cjs/index.d.ts +63 -0
  48. package/dist/dts-cjs/index.d.ts.map +1 -0
  49. package/dist/dts-cjs/inspect.d.ts +71 -0
  50. package/dist/dts-cjs/inspect.d.ts.map +1 -0
  51. package/dist/dts-cjs/package.json +3 -0
  52. package/dist/dts-cjs/readiness.d.ts +84 -0
  53. package/dist/dts-cjs/readiness.d.ts.map +1 -0
  54. package/dist/dts-cjs/reconcile.d.ts +68 -0
  55. package/dist/dts-cjs/reconcile.d.ts.map +1 -0
  56. package/dist/dts-cjs/runFeedSync.d.ts +220 -0
  57. package/dist/dts-cjs/runFeedSync.d.ts.map +1 -0
  58. package/dist/dts-cjs/tuning.d.ts +40 -0
  59. package/dist/dts-cjs/tuning.d.ts.map +1 -0
  60. package/dist/dts-esm/Feed.d.ts +99 -0
  61. package/dist/dts-esm/Feed.d.ts.map +1 -0
  62. package/dist/dts-esm/FeedSink.d.ts +189 -0
  63. package/dist/dts-esm/FeedSink.d.ts.map +1 -0
  64. package/dist/dts-esm/FeedSyncStore.d.ts +281 -0
  65. package/dist/dts-esm/FeedSyncStore.d.ts.map +1 -0
  66. package/dist/dts-esm/deliver.d.ts +16 -0
  67. package/dist/dts-esm/deliver.d.ts.map +1 -0
  68. package/dist/dts-esm/errors.d.ts +131 -0
  69. package/dist/dts-esm/errors.d.ts.map +1 -0
  70. package/dist/dts-esm/identifiers.d.ts +67 -0
  71. package/dist/dts-esm/identifiers.d.ts.map +1 -0
  72. package/dist/dts-esm/inMemoryFeed.d.ts +32 -0
  73. package/dist/dts-esm/inMemoryFeed.d.ts.map +1 -0
  74. package/dist/dts-esm/inMemorySink.d.ts +61 -0
  75. package/dist/dts-esm/inMemorySink.d.ts.map +1 -0
  76. package/dist/dts-esm/index.d.ts +63 -0
  77. package/dist/dts-esm/index.d.ts.map +1 -0
  78. package/dist/dts-esm/inspect.d.ts +71 -0
  79. package/dist/dts-esm/inspect.d.ts.map +1 -0
  80. package/dist/dts-esm/package.json +3 -0
  81. package/dist/dts-esm/readiness.d.ts +84 -0
  82. package/dist/dts-esm/readiness.d.ts.map +1 -0
  83. package/dist/dts-esm/reconcile.d.ts +68 -0
  84. package/dist/dts-esm/reconcile.d.ts.map +1 -0
  85. package/dist/dts-esm/runFeedSync.d.ts +220 -0
  86. package/dist/dts-esm/runFeedSync.d.ts.map +1 -0
  87. package/dist/dts-esm/tuning.d.ts +40 -0
  88. package/dist/dts-esm/tuning.d.ts.map +1 -0
  89. package/dist/esm/Feed.js +2 -0
  90. package/dist/esm/Feed.js.map +1 -0
  91. package/dist/esm/FeedSink.js +46 -0
  92. package/dist/esm/FeedSink.js.map +1 -0
  93. package/dist/esm/FeedSyncStore.js +3 -0
  94. package/dist/esm/FeedSyncStore.js.map +1 -0
  95. package/dist/esm/deliver.js +78 -0
  96. package/dist/esm/deliver.js.map +1 -0
  97. package/dist/esm/errors.js +98 -0
  98. package/dist/esm/errors.js.map +1 -0
  99. package/dist/esm/identifiers.js +56 -0
  100. package/dist/esm/identifiers.js.map +1 -0
  101. package/dist/esm/inMemoryFeed.js +320 -0
  102. package/dist/esm/inMemoryFeed.js.map +1 -0
  103. package/dist/esm/inMemorySink.js +134 -0
  104. package/dist/esm/inMemorySink.js.map +1 -0
  105. package/dist/esm/index.js +62 -0
  106. package/dist/esm/index.js.map +1 -0
  107. package/dist/esm/inspect.js +58 -0
  108. package/dist/esm/inspect.js.map +1 -0
  109. package/dist/esm/package.json +4 -0
  110. package/dist/esm/readiness.js +92 -0
  111. package/dist/esm/readiness.js.map +1 -0
  112. package/dist/esm/reconcile.js +153 -0
  113. package/dist/esm/reconcile.js.map +1 -0
  114. package/dist/esm/runFeedSync.js +595 -0
  115. package/dist/esm/runFeedSync.js.map +1 -0
  116. package/dist/esm/tuning.js +51 -0
  117. package/dist/esm/tuning.js.map +1 -0
  118. package/package.json +34 -3
  119. package/src/Feed.ts +111 -0
  120. package/src/FeedSink.ts +130 -0
  121. package/src/FeedSyncStore.ts +326 -0
  122. package/src/deliver.ts +108 -0
  123. package/src/errors.ts +134 -0
  124. package/src/identifiers.ts +92 -0
  125. package/src/inMemoryFeed.ts +582 -0
  126. package/src/inMemorySink.ts +215 -0
  127. package/src/index.ts +136 -0
  128. package/src/inspect.ts +114 -0
  129. package/src/readiness.ts +137 -0
  130. package/src/reconcile.ts +224 -0
  131. package/src/runFeedSync.ts +882 -0
  132. package/src/tuning.ts +65 -0
@@ -0,0 +1,882 @@
1
+ /**
2
+ * `runFeedSync` — the sync worker: one per feed and sink, holding the lease,
3
+ * copying the feed's rows to the sink and moving its cursor only once the sink
4
+ * confirms them (ADR-0019).
5
+ *
6
+ * ## The lifecycle
7
+ *
8
+ * A worker runs in SESSIONS. A session takes the lease, then loops:
9
+ *
10
+ * 1. Read the cursor and the feed's generation. No cursor, or a cursor at another
11
+ * generation, means the sink holds a copy of something the feed no longer is,
12
+ * so the session RESYNCS (below) and loops.
13
+ * 2. Otherwise it TAILS: the rows after the cursor, through core's generic
14
+ * subscribe machine with `emit: 'page'`, so each page is sent as it is read and
15
+ * the worker holds one page in memory, never the backlog. Each page is checked
16
+ * against the generation first, and so is every poll interval on its own, since
17
+ * a reset that writes nothing yields no page; a generation that moved since the
18
+ * cursor was saved ends the tail, and step 1 sees the mismatch.
19
+ *
20
+ * Every page goes through one delivery: send, park the keys the sink refuses with
21
+ * its reasons, send the rest again, confirm. Then ONE compare-and-set moves the
22
+ * cursor past the whole page and moves the parked list with it — the refused keys
23
+ * parked, every key that was taken unparked — and fenced on the lease holder, so a
24
+ * worker that lost its lease moves nothing. Then tombstones every sink has passed
25
+ * are collected.
26
+ *
27
+ * The rules that sequence keeps, each a consequence of a sink not being
28
+ * transactional:
29
+ *
30
+ * - **The cursor moves only after `confirm`.** A crash between `apply` and
31
+ * `confirm` resends the batch; a sink is idempotent, so that costs a resend and
32
+ * nothing else.
33
+ * - **A refused key does not hold the feed up.** It is parked and the cursor moves
34
+ * past it. It is retried when its row next changes, because a change gives the
35
+ * row a `seq` above the cursor, and it leaves the parked list once it is sent
36
+ * successfully — a tombstone for a parked key included, which is how a refused
37
+ * record that is then deleted stops being parked without anyone acting.
38
+ * - **A retryable failure keeps the cursor.** The session ends, and supervision
39
+ * (below) starts a new one after a back-off no shorter than the sink's
40
+ * `retryAfter`; the new session sends the same page again.
41
+ *
42
+ * ## Resync
43
+ *
44
+ * Every row, page by page from the origin, goes into `resync.begin`'s fresh
45
+ * target; then the generation is checked once more, the target is published over
46
+ * the live copy, and the cursor is saved at the new generation with the parked list
47
+ * REPLACED by the refusals the resync met. Resync progress is held in memory only,
48
+ * so a crash restarts it from the origin, with a new `begin` that drops the
49
+ * half-built target the crash left.
50
+ *
51
+ * ### Waiting for the materialisation
52
+ *
53
+ * The feed's end is not the rebuild's end. After a reset the materialisation
54
+ * replays the log and writes the feed as it goes, so a resync that reaches the
55
+ * feed's last row has reached wherever the replay had got to, and publishing there
56
+ * swaps a complete live copy for a partial one. `readiness.ts` owns that argument.
57
+ * So `runFeedSync` takes an optional `readiness`: asked once as each attempt
58
+ * starts, it hands back a probe, and the attempt does not publish until the probe
59
+ * says the materialisation has caught up. Until then the attempt polls: once per
60
+ * poll interval it checks the generation, abandoning the attempt as stale if a
61
+ * reset moved it again, asks the probe, and reads the feed on from where it left
62
+ * off, loading what the replay wrote meanwhile. The probe is asked BEFORE that
63
+ * read, so the read that follows a `true` sees every row the materialisation had
64
+ * committed by the time it answered, since a feed row commits with the checkpoint
65
+ * that produced it. The lease is still checked immediately before `publish`, and
66
+ * the generation once more just before that, however long the wait was.
67
+ *
68
+ * With no `readiness` the attempt publishes at the feed's end, which is exactly the
69
+ * behaviour above, and search can serve a PARTIAL copy for as long as the replay
70
+ * takes. An in-place rebuild has no publish; there the wait holds the closing
71
+ * reconcile and the cursor save instead, so the reconcile never deletes a record
72
+ * the replay has not reached yet. Without a `readiness` an in-place rebuild
73
+ * reconciles at the feed's end too, and so deletes from the live copy every record
74
+ * the replay had not yet written again; the tail sends each back as the replay
75
+ * reaches it.
76
+ *
77
+ * The wait is UNBOUNDED. A probe that never says yes holds the attempt for good,
78
+ * with no error and no restart, because publishing early is the very failure the
79
+ * wait exists to prevent and no timeout could tell a stuck replay from a long one.
80
+ * It is made visible instead: `inspectFeed` reports the sink `resyncDue` for as long
81
+ * as it lasts, since the cursor stays at the old generation, and every
82
+ * `HELD_WAIT_WARN_POLLS` poll intervals (`tuning.ts`) the attempt logs a warning,
83
+ * "feed sync rebuild held on readiness", with the feed, the sink, the generation,
84
+ * the polls so far and how long it has been held.
85
+ *
86
+ * Each `begin` names a target of its own, never one an earlier attempt used, and
87
+ * that is what bounds a lease lapse here. A worker that has lost its lease can
88
+ * still land one call after the new holder's (`FeedSyncStore.ts` says when). Were
89
+ * the target named after its generation alone, that call could be a `publish` of
90
+ * the NEW holder's half-built target, which then goes on loading into a fresh one
91
+ * holding only the later pages, publishes it, and saves its cursor at the head:
92
+ * whole pages lost, and nothing to bring them back. With a target per attempt, a
93
+ * stale `publish` moves only the stale worker's own target — or fails, retryably,
94
+ * when a newer `begin` has dropped it — and a stale `begin` drops the new holder's
95
+ * target, whose `publish` then fails retryably and whose resync starts again.
96
+ * What a stale `publish` of its own target still costs is `FeedSyncStore.ts`'s to
97
+ * say, with the rest of the lease's window.
98
+ *
99
+ * `begin`, `load`, `apply` and `publish` each run behind `ensureHeld`; `confirm`
100
+ * and a reconcile's key listing change nothing, and do not.
101
+ *
102
+ * Tombstones are sent as deletes, although the target starts empty. The feed does
103
+ * not stand still while a resync pages through it: a key loaded on an early page
104
+ * can be deleted by the read model before the resync is done, and its tombstone
105
+ * then sits at a `seq` a LATER page reads. Dropping it would load the key, save
106
+ * the cursor past its delete, and publish a target that holds a record the feed no
107
+ * longer has, with nothing ever coming to remove it. A tombstone for a key the
108
+ * target never held costs a send and changes nothing, since a delete of an absent
109
+ * key is a no-op by the sink laws.
110
+ *
111
+ * The same movement decides the parked list. A key can be met more than once in
112
+ * one resync — refused on an early page, then changed and met again later — so
113
+ * the list saved is what each key's LAST change in the resync came to: parked if
114
+ * that change was refused, absent if it was taken. Keeping every refusal the
115
+ * resync met would leave parked a key the target holds, or one the feed deleted.
116
+ *
117
+ * A sink with no `resync` is resynced IN PLACE: every row is sent to the live copy,
118
+ * live rows as upserts and tombstones as deletes, and then a reconcile deletes what
119
+ * the feed no longer holds. The live copy serves a mixture of old and new from the
120
+ * first page on, which is the price of a target with no atomic swap. Its refusals
121
+ * land in the parked list it saves like any resync's, with two consequences of
122
+ * writing over the live copy: a refused upsert leaves the PREVIOUS generation's
123
+ * document of that key live, and an orphan whose delete the closing reconcile was
124
+ * refused stays live too. Both are parked with the sink's reason, the orphan at the
125
+ * origin `seq` since it has no row, and the closing reconcile's report goes to
126
+ * `onReconcile` like a scheduled one's. A sink with neither part could never be
127
+ * rebuilt, and is refused at construction.
128
+ *
129
+ * ## Supervision
130
+ *
131
+ * Every failure a session meets ends it, and the worker's own loop decides what
132
+ * next. It follows `@kairos-es/automation`'s daemon rather than generalising the
133
+ * read side's supervisor, whose own doc says a progress signal the caller owns does
134
+ * not qualify — and the signal here, a cursor advance, is the worker's own.
135
+ *
136
+ * - `LeaseHeld`: another worker is running. This one waits a poll interval and
137
+ * tries again, and the wait is not a failure: a standby worker is a healthy one,
138
+ * and it takes over within a poll interval of the first worker's lease going.
139
+ * - Anything else: a back-off of `restartMinDelay` doubling to `restartMaxDelay`,
140
+ * scaled by a jitter drawn from `Random` so a fleet of workers does not retry in
141
+ * lockstep, and never shorter than a `SinkRetryable`'s `retryAfter`. A restart
142
+ * that follows no cursor advance since the previous restart counts towards
143
+ * `maxNoProgressRestarts`; reaching it ends the worker with `FeedSyncStalled`,
144
+ * for the caller to report. Any advance resets the count and the curve.
145
+ *
146
+ * Defects are not caught: they are bugs, in the driver or in a sink or store that
147
+ * should have classified its fault, and supervision must not retry a bug.
148
+ *
149
+ * ## The scope
150
+ *
151
+ * The worker never returns. Run it in a fibre of its own beside the projections,
152
+ * and close the scope to stop it: each session runs in a child scope of the
153
+ * caller's, so closing the caller's scope releases the lease.
154
+ */
155
+
156
+ import { type ReadLimit, subscribeItems } from '@kairos-es/core'
157
+ import {
158
+ Array as Arr,
159
+ Clock,
160
+ Data,
161
+ DateTime,
162
+ Duration,
163
+ Effect,
164
+ Option,
165
+ Random,
166
+ Ref,
167
+ Scope,
168
+ Stream,
169
+ } from 'effect'
170
+ import type { NonEmptyReadonlyArray } from 'effect/Array'
171
+ import { type Delivered, deliver } from './deliver'
172
+ import {
173
+ type CursorSuperseded,
174
+ FeedSyncStalled,
175
+ type FeedUnreadable,
176
+ type FeedWriteFailed,
177
+ type LeaseHeld,
178
+ type LeaseLost,
179
+ type SinkHalted,
180
+ type SinkRejected,
181
+ type SinkRetryable,
182
+ } from './errors'
183
+ import type { FeedRow, FeedSource } from './Feed'
184
+ import {
185
+ changeOf,
186
+ type FeedChange,
187
+ type FeedReconcile,
188
+ type FeedResync,
189
+ type FeedSink,
190
+ } from './FeedSink'
191
+ import type {
192
+ FeedCursor,
193
+ FeedLease,
194
+ FeedSyncStore,
195
+ ParkedChange,
196
+ ParkedRecord,
197
+ } from './FeedSyncStore'
198
+ import {
199
+ FEED_ORIGIN,
200
+ type FeedKey,
201
+ type FeedName,
202
+ type FeedSeq,
203
+ type Generation,
204
+ type SinkName,
205
+ } from './identifiers'
206
+ import type { FeedReadiness, FeedReadinessProbe } from './readiness'
207
+ import {
208
+ type Reconciled,
209
+ type ReconcileReport,
210
+ reconcileWith,
211
+ unparkGone,
212
+ } from './reconcile'
213
+ import {
214
+ DEFAULT_FEED_MAX_NO_PROGRESS_RESTARTS,
215
+ DEFAULT_FEED_PAGE_SIZE,
216
+ DEFAULT_FEED_POLL_INTERVAL,
217
+ DEFAULT_FEED_RESTART_MAX_DELAY,
218
+ DEFAULT_FEED_RESTART_MIN_DELAY,
219
+ HELD_WAIT_WARN_POLLS,
220
+ positiveDuration,
221
+ readLimit,
222
+ } from './tuning'
223
+
224
+ /** What `onRestart` is told before each restart's back-off. */
225
+ export interface FeedSyncRestart {
226
+ readonly feed: FeedName
227
+ readonly sink: SinkName
228
+ /** Consecutive restarts with no cursor advance, this one included. */
229
+ readonly restarts: number
230
+ readonly delay: Duration.Duration
231
+ /** The failure that ended the session. */
232
+ readonly reason: unknown
233
+ }
234
+
235
+ /** The worker's knobs. Every one has a default; see `tuning.ts`'s module doc. */
236
+ export interface FeedSyncTuning {
237
+ /** The upper bound on an idle sleep. Default 1 second. */
238
+ readonly pollInterval?: Duration.Input
239
+ /** Rows read, and changes sent, per page. Default core's catch-up page, 512. */
240
+ readonly pageSize?: number
241
+ /** The first restart back-off. Default 100 ms. */
242
+ readonly restartMinDelay?: Duration.Input
243
+ /** The restart back-off's cap, before jitter. Default 30 seconds. */
244
+ readonly restartMaxDelay?: Duration.Input
245
+ /** Consecutive restarts with no cursor advance before `FeedSyncStalled`. Default 40. */
246
+ readonly maxNoProgressRestarts?: number
247
+ readonly onRestart?: (info: FeedSyncRestart) => Effect.Effect<void>
248
+ /**
249
+ * Told what each reconcile the worker runs did: each scheduled one, and the one
250
+ * that closes an in-place rebuild.
251
+ */
252
+ readonly onReconcile?: (report: ReconcileReport) => Effect.Effect<void>
253
+ }
254
+
255
+ /** Options for {@link runFeedSync}. */
256
+ export interface RunFeedSyncOptions<D, Receipt> {
257
+ readonly source: FeedSource<D>
258
+ readonly sink: FeedSink<D, Receipt>
259
+ readonly store: FeedSyncStore
260
+ readonly tuning?: FeedSyncTuning
261
+ /**
262
+ * Reconcile the sink against the feed this often, between batches, under the
263
+ * worker's own lease. Needs the sink's `reconcile`; absent, the worker
264
+ * reconciles only as part of an in-place resync.
265
+ */
266
+ readonly reconcileEvery?: Duration.Input
267
+ /**
268
+ * Hold each resync's `publish` — or, in place, its closing reconcile — until the
269
+ * materialisation writing the feed has caught up with the log, for as long as
270
+ * that takes; see the module doc's Resync section. Absent, a resync publishes at
271
+ * the feed's end, and search can be PARTIAL while a rebuild's replay runs.
272
+ * `materialisationCaughtUp` builds the common case from the read model.
273
+ */
274
+ readonly readiness?: FeedReadiness
275
+ }
276
+
277
+ /** A session's failures: each ends the session, and supervision decides. */
278
+ type SessionFailure =
279
+ | SinkRetryable
280
+ | SinkHalted
281
+ | FeedUnreadable
282
+ | FeedWriteFailed
283
+ | CursorSuperseded
284
+ | LeaseLost
285
+ | LeaseHeld
286
+
287
+ /** The tail's signal that the generation moved under it. Never leaves a session. */
288
+ class GenerationMoved extends Data.TaggedError('GenerationMoved') {}
289
+
290
+ /** The jitter range applied to every restart back-off. */
291
+ const JITTER_LOW = 0.8
292
+ const JITTER_HIGH = 1.2
293
+
294
+ /**
295
+ * Run the sync worker for `options.source` and `options.sink`; see the module doc.
296
+ * Dies at once, before taking the lease, on a sink that has neither `resync` nor
297
+ * `reconcile`, on a `reconcileEvery` for a sink with no `reconcile`, and on tuning
298
+ * that is out of range.
299
+ */
300
+ export const runFeedSync = <D, Receipt>(
301
+ options: RunFeedSyncOptions<D, Receipt>,
302
+ ): Effect.Effect<never, FeedSyncStalled, Scope.Scope> =>
303
+ Effect.gen(function* () {
304
+ const config = yield* Effect.sync(() => resolveOptions(options))
305
+ const parent = yield* Effect.scope
306
+ const progress = yield* Ref.make(0)
307
+ const id = { feed: options.source.feed, sink: options.sink.name }
308
+ let seen = 0
309
+ let restarts = 0
310
+ for (;;) {
311
+ const child = yield* Scope.fork(parent)
312
+ const failure = yield* Effect.flip(
313
+ session(options, config, progress).pipe(Scope.use(child)),
314
+ )
315
+ if (failure._tag === 'LeaseHeld') {
316
+ yield* Effect.sleep(config.pollInterval)
317
+ continue
318
+ }
319
+ const advanced = yield* Ref.get(progress)
320
+ if (advanced !== seen) {
321
+ seen = advanced
322
+ restarts = 0
323
+ }
324
+ restarts += 1
325
+ if (restarts >= config.maxNoProgressRestarts) {
326
+ yield* Effect.logError('feed sync stalled', {
327
+ feed: id.feed,
328
+ sink: id.sink,
329
+ restarts,
330
+ reason: String(failure),
331
+ })
332
+ return yield* new FeedSyncStalled({ ...id, restarts, reason: failure })
333
+ }
334
+ const backoff = yield* restartDelay(config, restarts)
335
+ const delay =
336
+ failure._tag === 'SinkRetryable' && failure.retryAfter !== undefined
337
+ ? Duration.max(backoff, failure.retryAfter)
338
+ : backoff
339
+ yield* Effect.logWarning('feed sync restarting', {
340
+ feed: id.feed,
341
+ sink: id.sink,
342
+ restarts,
343
+ delayMillis: Duration.toMillis(delay),
344
+ reason: String(failure),
345
+ })
346
+ if (config.onRestart !== undefined) {
347
+ yield* config.onRestart({ ...id, restarts, delay, reason: failure })
348
+ }
349
+ yield* Effect.sleep(delay)
350
+ }
351
+ })
352
+
353
+ /**
354
+ * How the sink is rebuilt after a reset, decided ONCE, at construction, from the
355
+ * parts the sink has. A sink with `resync` is rebuilt by a swap even when it also
356
+ * has `reconcile`; one with `reconcile` alone is rebuilt in place; one with
357
+ * neither is refused before this is ever built. So no later step re-asks which
358
+ * parts the sink has, and none carries a branch for a combination that cannot
359
+ * reach it.
360
+ */
361
+ type Rebuild<D, Receipt> =
362
+ | { readonly _tag: 'Swap'; readonly resync: FeedResync<D, Receipt> }
363
+ | { readonly _tag: 'InPlace'; readonly reconcile: FeedReconcile }
364
+
365
+ /** A scheduled reconcile: how often, and the key listing it runs over. */
366
+ interface PeriodicReconcile {
367
+ readonly every: Duration.Duration
368
+ readonly reconcile: FeedReconcile
369
+ }
370
+
371
+ /** The options with every default applied and every value checked. */
372
+ interface ResolvedConfig<D, Receipt> {
373
+ readonly pollInterval: Duration.Duration
374
+ readonly pageSize: number
375
+ readonly limit: ReadLimit
376
+ readonly restartMinDelay: Duration.Duration
377
+ readonly restartMaxDelay: Duration.Duration
378
+ readonly maxNoProgressRestarts: number
379
+ readonly onRestart: FeedSyncTuning['onRestart']
380
+ readonly onReconcile: FeedSyncTuning['onReconcile']
381
+ readonly rebuild: Rebuild<D, Receipt>
382
+ /** `None` when no `reconcileEvery` was asked for; see `resolveOptions`. */
383
+ readonly periodic: Option.Option<PeriodicReconcile>
384
+ /** `None` when no `readiness` was given: a resync publishes at the feed's end. */
385
+ readonly readiness: Option.Option<FeedReadiness>
386
+ }
387
+
388
+ const resolveOptions = <D, Receipt>(
389
+ options: RunFeedSyncOptions<D, Receipt>,
390
+ ): ResolvedConfig<D, Receipt> => {
391
+ const where = `runFeedSync(feed '${options.source.feed}', sink '${options.sink.name}')`
392
+ const { resync, reconcile } = options.sink
393
+ const rebuild = rebuildOf(where, resync, reconcile)
394
+ if (options.reconcileEvery !== undefined && reconcile === undefined) {
395
+ throw new Error(
396
+ `${where}: reconcileEvery is set, and the sink has no reconcile`,
397
+ )
398
+ }
399
+ const tuning = options.tuning ?? {}
400
+ const pageSize = tuning.pageSize ?? DEFAULT_FEED_PAGE_SIZE
401
+ const limit = readLimit(pageSize)
402
+ const maxNoProgressRestarts =
403
+ tuning.maxNoProgressRestarts ?? DEFAULT_FEED_MAX_NO_PROGRESS_RESTARTS
404
+ if (!Number.isInteger(maxNoProgressRestarts) || maxNoProgressRestarts <= 0) {
405
+ throw new Error(
406
+ `${where}: maxNoProgressRestarts must be a positive integer`,
407
+ )
408
+ }
409
+ const pollInterval = positiveDuration(
410
+ 'pollInterval',
411
+ tuning.pollInterval ?? DEFAULT_FEED_POLL_INTERVAL,
412
+ )
413
+ const restartMinDelay = positiveDuration(
414
+ 'restartMinDelay',
415
+ tuning.restartMinDelay ?? DEFAULT_FEED_RESTART_MIN_DELAY,
416
+ )
417
+ const restartMaxDelay = positiveDuration(
418
+ 'restartMaxDelay',
419
+ tuning.restartMaxDelay ?? DEFAULT_FEED_RESTART_MAX_DELAY,
420
+ )
421
+ return {
422
+ pollInterval,
423
+ pageSize,
424
+ limit,
425
+ restartMinDelay,
426
+ restartMaxDelay,
427
+ maxNoProgressRestarts,
428
+ onRestart: tuning.onRestart,
429
+ onReconcile: tuning.onReconcile,
430
+ rebuild,
431
+ periodic:
432
+ options.reconcileEvery === undefined || reconcile === undefined
433
+ ? Option.none()
434
+ : Option.some({
435
+ every: positiveDuration('reconcileEvery', options.reconcileEvery),
436
+ reconcile,
437
+ }),
438
+ readiness: Option.fromUndefinedOr(options.readiness),
439
+ }
440
+ }
441
+
442
+ /** The sink's {@link Rebuild}, refusing a sink that could never be rebuilt. */
443
+ const rebuildOf = <D, Receipt>(
444
+ where: string,
445
+ resync: FeedResync<D, Receipt> | undefined,
446
+ reconcile: FeedReconcile | undefined,
447
+ ): Rebuild<D, Receipt> => {
448
+ if (resync !== undefined) {
449
+ return { _tag: 'Swap', resync }
450
+ }
451
+ if (reconcile !== undefined) {
452
+ return { _tag: 'InPlace', reconcile }
453
+ }
454
+ throw new Error(
455
+ `${where}: the sink has neither resync nor reconcile, so it could never be rebuilt after a reset`,
456
+ )
457
+ }
458
+
459
+ /**
460
+ * The delay before restart `n` (1-based): `min(cap, min × 2^(n-1))`, scaled by a
461
+ * jitter drawn from `Random`, so a seeded `Random` makes it repeat.
462
+ */
463
+ const restartDelay = <D, Receipt>(
464
+ config: ResolvedConfig<D, Receipt>,
465
+ n: number,
466
+ ): Effect.Effect<Duration.Duration> =>
467
+ Effect.map(Random.nextBetween(JITTER_LOW, JITTER_HIGH), (jitter) => {
468
+ const raw = Duration.toMillis(config.restartMinDelay) * 2 ** (n - 1)
469
+ const capped = Math.min(raw, Duration.toMillis(config.restartMaxDelay))
470
+ return Duration.millis(Math.round(capped * jitter))
471
+ })
472
+
473
+ /** What a generation check found: the feed's generation now, and whether it moved. */
474
+ interface GenerationCheck {
475
+ readonly current: Generation
476
+ /** True when the feed is no longer at the generation held — or none was held. */
477
+ readonly moved: boolean
478
+ }
479
+
480
+ /**
481
+ * Read the feed's generation and say whether it is still `held`. The ONE place the
482
+ * driver asks whether a reset has happened: before choosing to tail or resync, per
483
+ * tail page, per poll interval of the tail's watch, per poll interval of a resync
484
+ * waiting on its readiness, and before a resync publishes.
485
+ * A `held` of `None` — no cursor yet — counts as moved, since the sink then holds
486
+ * a copy of no generation at all.
487
+ */
488
+ const checkGeneration = <D>(
489
+ source: FeedSource<D>,
490
+ held: Option.Option<Generation>,
491
+ ): Effect.Effect<GenerationCheck, FeedUnreadable> =>
492
+ Effect.map(source.generation, (current) => ({
493
+ current,
494
+ moved: Option.isNone(held) || held.value !== current,
495
+ }))
496
+
497
+ /** How the cursor moves at the end of a page or a resync. */
498
+ interface Advance {
499
+ readonly expected: Option.Option<FeedCursor>
500
+ readonly next: FeedCursor
501
+ readonly parked: ParkedChange
502
+ }
503
+
504
+ /**
505
+ * Everything one session threads through its tail and resync, built ONCE when the
506
+ * lease is taken. The lease, the store and the progress counter are closed over by
507
+ * `deliverRows`, `advance` and `reconcile` rather than handed to each step, so a
508
+ * step cannot send without the lease check or advance without counting progress.
509
+ */
510
+ interface SessionContext<D, Receipt> {
511
+ readonly source: FeedSource<D>
512
+ readonly sink: FeedSink<D, Receipt>
513
+ readonly config: ResolvedConfig<D, Receipt>
514
+ readonly lease: FeedLease
515
+ /** Deliver `rows` through `send` — the live `apply`, or a resync target's `load`. */
516
+ readonly deliverRows: (
517
+ send: (
518
+ batch: NonEmptyReadonlyArray<FeedChange<D>>,
519
+ ) => Effect.Effect<Receipt, SinkRetryable | SinkRejected | SinkHalted>,
520
+ rows: NonEmptyReadonlyArray<FeedRow<D>>,
521
+ ) => Effect.Effect<
522
+ Delivered,
523
+ SinkRetryable | SinkHalted | LeaseLost | FeedUnreadable
524
+ >
525
+ /**
526
+ * Move the cursor, fenced on this session's holder, with the parked list; count
527
+ * the advance as progress; collect the tombstones every sink has now passed.
528
+ */
529
+ readonly advance: (
530
+ args: Advance,
531
+ ) => Effect.Effect<void, CursorSuperseded | FeedWriteFailed>
532
+ /** Unpark what a reconcile found gone; see `unparkGone` in `reconcile.ts`. */
533
+ readonly unparkGone: (isGone: Reconciled['isGone']) => Effect.Effect<void>
534
+ /** Reconcile the live copy over `reconcile`, under this session's lease. */
535
+ readonly reconcile: (
536
+ reconcile: FeedReconcile,
537
+ ) => Effect.Effect<
538
+ Reconciled,
539
+ SinkRetryable | SinkHalted | LeaseLost | FeedUnreadable
540
+ >
541
+ }
542
+
543
+ const makeSessionContext = <D, Receipt>(
544
+ options: RunFeedSyncOptions<D, Receipt>,
545
+ config: ResolvedConfig<D, Receipt>,
546
+ progress: Ref.Ref<number>,
547
+ lease: FeedLease,
548
+ ): SessionContext<D, Receipt> => {
549
+ const { source, sink, store } = options
550
+ return {
551
+ source,
552
+ sink,
553
+ config,
554
+ lease,
555
+ deliverRows: (send, rows) =>
556
+ deliver({
557
+ send,
558
+ confirm: sink.confirm,
559
+ lease,
560
+ pending: Arr.map(rows, (row) => ({
561
+ change: changeOf(row),
562
+ seq: row.seq,
563
+ })),
564
+ }),
565
+ advance: (args) =>
566
+ Effect.gen(function* () {
567
+ yield* store.casCursor({
568
+ feed: source.feed,
569
+ sink: sink.name,
570
+ holder: lease.holder,
571
+ expected: args.expected,
572
+ next: args.next,
573
+ parked: args.parked,
574
+ })
575
+ yield* Ref.update(progress, (n) => n + 1)
576
+ yield* store.collectTombstones(source.feed)
577
+ }),
578
+ unparkGone: (isGone) =>
579
+ unparkGone(store, { feed: source.feed, sink: sink.name }, isGone),
580
+ reconcile: (reconcile) =>
581
+ reconcileWith({
582
+ source,
583
+ sink,
584
+ reconcile,
585
+ lease,
586
+ pageSize: config.pageSize,
587
+ }),
588
+ }
589
+ }
590
+
591
+ /** One session: the lease, then resync or tail, forever, until something fails. */
592
+ const session = <D, Receipt>(
593
+ options: RunFeedSyncOptions<D, Receipt>,
594
+ config: ResolvedConfig<D, Receipt>,
595
+ progress: Ref.Ref<number>,
596
+ ): Effect.Effect<never, SessionFailure, Scope.Scope> =>
597
+ Effect.gen(function* () {
598
+ const id = { feed: options.source.feed, sink: options.sink.name }
599
+ const lease = yield* options.store.acquireLease(id)
600
+ const ctx = makeSessionContext(options, config, progress, lease)
601
+ const step = Effect.gen(function* () {
602
+ const cursor = yield* options.store.readCursor(id)
603
+ const check = yield* checkGeneration(
604
+ ctx.source,
605
+ Option.map(cursor, (c) => c.generation),
606
+ )
607
+ if (Option.isSome(cursor) && !check.moved) {
608
+ return yield* tail(ctx, cursor.value).pipe(
609
+ Effect.catchTag('GenerationMoved', () => Effect.void),
610
+ )
611
+ }
612
+ return yield* resync(ctx, {
613
+ generation: check.current,
614
+ previous: cursor,
615
+ })
616
+ })
617
+ return yield* Effect.forever(step)
618
+ })
619
+
620
+ /** The tail: pages after the cursor, each delivered then advanced past. */
621
+ const tail = <D, Receipt>(
622
+ ctx: SessionContext<D, Receipt>,
623
+ start: FeedCursor,
624
+ ): Effect.Effect<void, SessionFailure | GenerationMoved, Scope.Scope> =>
625
+ Effect.gen(function* () {
626
+ const { source, sink, config } = ctx
627
+ const current = yield* Ref.make(start)
628
+
629
+ // Fails `GenerationMoved` once the feed has left the cursor's generation.
630
+ const ensureGeneration = Effect.gen(function* () {
631
+ const cursor = yield* Ref.get(current)
632
+ const check = yield* checkGeneration(
633
+ source,
634
+ Option.some(cursor.generation),
635
+ )
636
+ if (check.moved) {
637
+ return yield* new GenerationMoved()
638
+ }
639
+ return cursor
640
+ })
641
+
642
+ const onPage = (
643
+ rows: NonEmptyReadonlyArray<FeedRow<D>>,
644
+ ): Effect.Effect<void, SessionFailure | GenerationMoved> =>
645
+ Effect.gen(function* () {
646
+ const cursor = yield* ensureGeneration
647
+ const delivered = yield* ctx.deliverRows(sink.apply, rows)
648
+ const next = {
649
+ generation: cursor.generation,
650
+ seq: Arr.lastNonEmpty(rows).seq,
651
+ }
652
+ yield* ctx.advance({
653
+ expected: Option.some(cursor),
654
+ next,
655
+ parked: {
656
+ _tag: 'Amend',
657
+ park: delivered.parked,
658
+ unpark: delivered.sent,
659
+ },
660
+ })
661
+ yield* Ref.set(current, next)
662
+ })
663
+
664
+ // A periodic reconcile parks nothing of its own: an orphan it could not delete
665
+ // is still in the copy, so the next period finds it and tries again, and the
666
+ // report says so meanwhile. What it does owe the parked list is every park of a
667
+ // key now gone from the sink, or `inspectFeed` would go on reporting a refusal
668
+ // for a record that is no longer there; `unparkGone` says why its failure only
669
+ // warns.
670
+ const onReconcile = (reconcile: FeedReconcile) =>
671
+ Effect.gen(function* () {
672
+ const { report, isGone } = yield* ctx.reconcile(reconcile)
673
+ if (config.onReconcile !== undefined) {
674
+ yield* config.onReconcile(report)
675
+ }
676
+ yield* ctx.unparkGone(isGone)
677
+ })
678
+
679
+ const pages = subscribeItems<FeedRow<D>, FeedSeq, FeedUnreadable>({
680
+ fetch: source.changesAfter,
681
+ cursorOf: (row) => row.seq,
682
+ initial: start.seq,
683
+ ...(source.listen === undefined ? {} : { listen: source.listen }),
684
+ pollInterval: Duration.toMillis(config.pollInterval),
685
+ catchUpPageSize: config.pageSize,
686
+ emit: 'page',
687
+ }).pipe(
688
+ Stream.chunks,
689
+ Stream.map((rows) => ({ _tag: 'Page' as const, rows })),
690
+ )
691
+
692
+ // A reset that writes no row would otherwise go unseen: the tail checks the
693
+ // generation per PAGE, and an emptied feed yields no page. So the generation is
694
+ // also read once per poll interval on its own, and a move ends the tail.
695
+ const generationWatch = Stream.tick(config.pollInterval).pipe(
696
+ Stream.drop(1),
697
+ Stream.as({ _tag: 'Watch' as const }),
698
+ )
699
+ const reconcileTicks = Option.match(config.periodic, {
700
+ onNone: () => Stream.empty,
701
+ onSome: ({ every, reconcile }) =>
702
+ Stream.tick(every).pipe(
703
+ // `tick` fires once at the start; a reconcile is due one period in.
704
+ Stream.drop(1),
705
+ Stream.as({ _tag: 'Reconcile' as const, reconcile }),
706
+ ),
707
+ })
708
+
709
+ // One consumer, so a page, a watch and a reconcile never overlap: nothing else
710
+ // sends while a reconcile runs, which is what lets it run under this lease.
711
+ yield* Stream.runForEach(
712
+ Stream.merge(pages, Stream.merge(generationWatch, reconcileTicks)),
713
+ (event) =>
714
+ event._tag === 'Page'
715
+ ? onPage(event.rows)
716
+ : event._tag === 'Watch'
717
+ ? Effect.asVoid(ensureGeneration)
718
+ : onReconcile(event.reconcile),
719
+ )
720
+ })
721
+
722
+ /**
723
+ * One resync attempt's two ends: where its rows are sent, and what makes the
724
+ * result the copy the sink serves. Built from the session's {@link Rebuild}, so
725
+ * the resync loop itself never asks which kind of sink it has.
726
+ */
727
+ interface Rebuilding<D, Receipt> {
728
+ readonly send: (
729
+ batch: NonEmptyReadonlyArray<FeedChange<D>>,
730
+ ) => Effect.Effect<Receipt, SinkRetryable | SinkRejected | SinkHalted>
731
+ /**
732
+ * Make the result the served copy, and say what that step itself sent and had
733
+ * refused, so its refusals are parked exactly as a page's are. A swap's
734
+ * `publish` sends no record and so reports nothing; an in-place rebuild's
735
+ * closing reconcile sends the deletes of the orphans, and an orphan whose delete
736
+ * the sink refused is STILL IN THE COPY, so leaving it out of the parked list
737
+ * would save a cursor at the new generation over a copy that holds a record the
738
+ * feed deleted, and say nothing about it. The tail's periodic reconcile parks
739
+ * nothing, since it tries again next period, but it unparks whatever it finds
740
+ * gone from the sink.
741
+ */
742
+ readonly finish: Effect.Effect<Delivered, SessionFailure>
743
+ }
744
+
745
+ /** What a swap's `publish` sent: no record at all. */
746
+ const NOTHING_SENT: Delivered = { sent: [], parked: [] }
747
+
748
+ const startRebuild = <D, Receipt>(
749
+ ctx: SessionContext<D, Receipt>,
750
+ generation: Generation,
751
+ ): Effect.Effect<Rebuilding<D, Receipt>, SessionFailure> => {
752
+ const { rebuild } = ctx.config
753
+ if (rebuild._tag === 'InPlace') {
754
+ return Effect.succeed({
755
+ send: ctx.sink.apply,
756
+ finish: Effect.gen(function* () {
757
+ const { report } = yield* ctx.reconcile(rebuild.reconcile)
758
+ if (ctx.config.onReconcile !== undefined) {
759
+ yield* ctx.config.onReconcile(report)
760
+ }
761
+ // An orphan has no row, so its park carries the origin as its `seq`: there
762
+ // is no change of it to point at, and `retryParked` on a key with no row
763
+ // simply unparks it.
764
+ const parkedAt = yield* DateTime.now
765
+ return {
766
+ sent: report.deleted,
767
+ parked: report.refused.map(({ key, reason }) => ({
768
+ key,
769
+ seq: FEED_ORIGIN,
770
+ reason,
771
+ parkedAt,
772
+ })),
773
+ }
774
+ }),
775
+ })
776
+ }
777
+ const { resync } = rebuild
778
+ return Effect.gen(function* () {
779
+ // `begin` destroys: it empties a target and drops every other one, which can
780
+ // be the half-built target of the worker that holds the lease now. So it is a
781
+ // send like any other, and runs only under a lease just seen held.
782
+ yield* ctx.lease.ensureHeld
783
+ const target = yield* resync.begin(generation)
784
+ return {
785
+ send: (batch) => resync.load(target, batch),
786
+ finish: Effect.as(
787
+ Effect.andThen(ctx.lease.ensureHeld, resync.publish(target)),
788
+ NOTHING_SENT,
789
+ ),
790
+ }
791
+ })
792
+ }
793
+
794
+ /** Always ready: the probe of a worker given no `readiness`. */
795
+ const ALWAYS_READY: FeedReadinessProbe = Effect.succeed(true)
796
+
797
+ /** A full resync at `generation`; see the module doc. */
798
+ const resync = <D, Receipt>(
799
+ ctx: SessionContext<D, Receipt>,
800
+ at: {
801
+ readonly generation: Generation
802
+ readonly previous: Option.Option<FeedCursor>
803
+ },
804
+ ): Effect.Effect<void, SessionFailure> =>
805
+ Effect.gen(function* () {
806
+ const { source, config } = ctx
807
+ // Asked before anything is read, so the point the probe judges by is fixed
808
+ // before the first page: see the module doc's "Waiting for the materialisation".
809
+ const ready = yield* Option.match(config.readiness, {
810
+ onNone: () => Effect.succeed(ALWAYS_READY),
811
+ onSome: (readiness) => readiness(at.generation),
812
+ })
813
+ const rebuilding = yield* startRebuild(ctx, at.generation)
814
+ const stale = Effect.map(
815
+ checkGeneration(source, Option.some(at.generation)),
816
+ (check) => check.moved,
817
+ )
818
+
819
+ // By key, so a later page's outcome for a key replaces an earlier one's: the
820
+ // list saved is what each key's LAST change in this resync came to.
821
+ const parked = new Map<FeedKey, ParkedRecord>()
822
+ const settle = (delivered: Delivered) => {
823
+ for (const sent of delivered.sent) {
824
+ parked.delete(sent)
825
+ }
826
+ for (const record of delivered.parked) {
827
+ parked.set(record.key, record)
828
+ }
829
+ }
830
+ let last: FeedSeq = FEED_ORIGIN
831
+ const heldSince = yield* Clock.currentTimeMillis
832
+ let polls = 0
833
+ for (;;) {
834
+ // Before the read, so a `true` covers every row the read then finds.
835
+ const caughtUp = yield* ready
836
+ for (;;) {
837
+ const rows = yield* source.changesAfter(last, config.limit)
838
+ if (!Arr.isReadonlyArrayNonEmpty(rows)) {
839
+ break
840
+ }
841
+ // Tombstones included: see the module doc's Resync section.
842
+ settle(yield* ctx.deliverRows(rebuilding.send, rows))
843
+ last = Arr.lastNonEmpty(rows).seq
844
+ if (rows.length < config.pageSize) {
845
+ break
846
+ }
847
+ }
848
+ if (caughtUp) {
849
+ break
850
+ }
851
+ yield* Effect.sleep(config.pollInterval)
852
+ if (yield* stale) {
853
+ // Reset again while this waited: abandon the attempt, as below.
854
+ return
855
+ }
856
+ polls += 1
857
+ // The wait has no bound — see the module doc — so a probe that never says
858
+ // yes must at least be seen not to. Every string or number, never a bigint,
859
+ // so a logger that JSON-encodes the line keeps it.
860
+ if (polls % HELD_WAIT_WARN_POLLS === 0) {
861
+ yield* Effect.logWarning('feed sync rebuild held on readiness', {
862
+ feed: source.feed,
863
+ sink: ctx.sink.name,
864
+ generation: String(at.generation),
865
+ polls,
866
+ heldMillis: (yield* Clock.currentTimeMillis) - heldSince,
867
+ })
868
+ }
869
+ }
870
+
871
+ if (yield* stale) {
872
+ // Reset again while this ran: what was loaded is already stale. Leave the
873
+ // cursor alone, and the session's next step starts the resync over.
874
+ return
875
+ }
876
+ settle(yield* rebuilding.finish)
877
+ yield* ctx.advance({
878
+ expected: at.previous,
879
+ next: { generation: at.generation, seq: last },
880
+ parked: { _tag: 'Replace', park: Array.from(parked.values()) },
881
+ })
882
+ })