@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,582 @@
1
+ /**
2
+ * The in-memory feed: a `BatchedFeedWriter`, a `FeedSource` and a `FeedSyncStore`
3
+ * over one `Ref`, and the PARITY ORACLE every other feed backend is held against,
4
+ * as the in-memory event store and projection store are for theirs.
5
+ *
6
+ * ## One state, so the store's atomic steps are atomic
7
+ *
8
+ * Rows, the sequence, the generation, every sink's cursor, lease holder and parked
9
+ * list live in ONE `Ref`, and each operation the port calls atomic is one
10
+ * `Ref.modify` of it. That is what makes this a faithful oracle for a SQL backend
11
+ * whose same operations are one transaction: a cursor advance and its parked list
12
+ * cannot be observed half-done, and tombstone collection reads the lowest cursor in
13
+ * the same step as it deletes.
14
+ *
15
+ * ## Used inside an in-memory `ProjectionStore` commit
16
+ *
17
+ * `write` buffers its body's upserts and removes and lands them in one
18
+ * `Ref.update` once the body succeeds, so the feed's half of a batch is a single
19
+ * atomic effect — what the `ProjectionStore` port asks of a non-transactional
20
+ * store's per-batch write. Make it the LAST step of the read model's `apply`: the
21
+ * in-memory store cannot roll back a view written before a feed write that failed,
22
+ * and a feed write here can fail only by a defect.
23
+ *
24
+ * ## Ephemeral, and what that does to the generation
25
+ *
26
+ * Nothing here survives the process, so the generation is a fresh value per boot,
27
+ * drawn from `Random` and never from the host. A sink that outlives the process —
28
+ * a real search index, or an in-memory sink a test keeps across two feeds — sees a
29
+ * generation it has no cursor for and resyncs, rather than holding a frozen copy of
30
+ * a feed that no longer exists. A reset through {@link withInMemoryFeed} moves the
31
+ * generation on by one.
32
+ *
33
+ * For the same reason the feed refuses to be bound to a DURABLE `ProjectionStore`:
34
+ * the view would survive a restart and the feed would not, the next boot's resync
35
+ * would publish an empty feed over the sink, and nothing would replay the view to
36
+ * fill it. That is R2's failure — durability that does not match — in a new place.
37
+ *
38
+ * ## Equality
39
+ *
40
+ * Whether an upsert changes anything is decided on the document ENCODED through
41
+ * its `Schema`, compared as JSON with object keys unordered. That is the same
42
+ * comparison a SQL backend makes on a JSON column, so a document that differs only
43
+ * in key order is no change in either. A document that fails to encode is a defect
44
+ * in the read model, not a fault of the feed.
45
+ */
46
+ import type { Position, ReadLimit } from '@kairos-es/core'
47
+ import type { CheckpointKey, ProjectionStore } from '@kairos-es/read'
48
+ import {
49
+ DateTime,
50
+ Effect,
51
+ Option,
52
+ PubSub,
53
+ Random,
54
+ Ref,
55
+ Schema,
56
+ Stream,
57
+ } from 'effect'
58
+ import { CursorSuperseded, LeaseHeld } from './errors'
59
+ import type { BatchedFeedWriter, FeedRow, FeedSource, FeedWriter } from './Feed'
60
+ import {
61
+ type FeedCursor,
62
+ type FeedLease,
63
+ type FeedSyncDescription,
64
+ type FeedSyncStore,
65
+ type ParkedChange,
66
+ type ParkedRecord,
67
+ type SinkSyncState,
68
+ sameCursor,
69
+ } from './FeedSyncStore'
70
+ import {
71
+ FEED_ORIGIN,
72
+ type FeedKey,
73
+ type FeedName,
74
+ FeedSeq,
75
+ type FeedSinkId,
76
+ Generation,
77
+ LeaseHolder,
78
+ type SinkName,
79
+ } from './identifiers'
80
+
81
+ /** The in-memory feed; see the module doc. */
82
+ export interface InMemoryFeed<D> extends BatchedFeedWriter<D> {
83
+ readonly source: FeedSource<D>
84
+ readonly syncStore: FeedSyncStore
85
+ }
86
+
87
+ /** A stored row, with its document's canonical encoding (absent for a tombstone). */
88
+ interface StoredRow<D> {
89
+ readonly row: FeedRow<D>
90
+ readonly encoded: string | undefined
91
+ }
92
+
93
+ interface SinkState {
94
+ readonly cursor: Option.Option<FeedCursor>
95
+ readonly lastAdvance: Option.Option<DateTime.Utc>
96
+ /** The lease's one home: taken and released only through `Ref.modify`. */
97
+ readonly holder: Option.Option<LeaseHolder>
98
+ readonly parked: ReadonlyMap<FeedKey, ParkedRecord>
99
+ }
100
+
101
+ interface State<D> {
102
+ readonly generation: Generation
103
+ readonly lastSeq: bigint
104
+ readonly rows: ReadonlyMap<FeedKey, StoredRow<D>>
105
+ readonly sinks: ReadonlyMap<SinkName, SinkState>
106
+ readonly boundTo: Option.Option<CheckpointKey>
107
+ }
108
+
109
+ const EMPTY_SINK: SinkState = {
110
+ cursor: Option.none(),
111
+ lastAdvance: Option.none(),
112
+ holder: Option.none(),
113
+ parked: new Map(),
114
+ }
115
+
116
+ /**
117
+ * A JSON value as a string with every object's keys sorted, so two encodings that
118
+ * differ only in key order compare equal.
119
+ */
120
+ const canonicalJson = (value: unknown): string =>
121
+ JSON.stringify(value, (_key, held: unknown) =>
122
+ held !== null && typeof held === 'object' && !Array.isArray(held)
123
+ ? Object.fromEntries(
124
+ Object.entries(held as Record<string, unknown>).sort(
125
+ ([left], [right]) => (left < right ? -1 : left > right ? 1 : 0),
126
+ ),
127
+ )
128
+ : held,
129
+ )
130
+
131
+ /** The private half of a feed that only {@link withInMemoryFeed} reaches. */
132
+ interface FeedInternals {
133
+ readonly bind: (key: CheckpointKey) => Effect.Effect<void>
134
+ readonly reset: Effect.Effect<void>
135
+ }
136
+
137
+ const internals = new WeakMap<object, FeedInternals>()
138
+
139
+ /** Options for {@link makeInMemoryFeed}. */
140
+ export interface InMemoryFeedOptions<D, I> {
141
+ readonly feed: FeedName
142
+ /** The document's schema: what an upsert encodes, and compares, through. */
143
+ readonly doc: Schema.Codec<D, I>
144
+ }
145
+
146
+ /** Build an in-memory feed at a fresh generation; see the module doc. */
147
+ export const makeInMemoryFeed = <D, I>(
148
+ options: InMemoryFeedOptions<D, I>,
149
+ ): Effect.Effect<InMemoryFeed<D>> =>
150
+ Effect.gen(function* () {
151
+ const feed = options.feed
152
+ const initial = yield* Random.nextIntBetween(1, Number.MAX_SAFE_INTEGER)
153
+ const state = yield* Ref.make<State<D>>({
154
+ generation: Generation.make(BigInt(initial)),
155
+ lastSeq: 0n,
156
+ rows: new Map(),
157
+ sinks: new Map(),
158
+ boundTo: Option.none(),
159
+ })
160
+ const wake = yield* PubSub.sliding<void>(1)
161
+ const encode = Schema.encodeEffect(options.doc)
162
+
163
+ const requireFeed = (name: FeedName): Effect.Effect<void> =>
164
+ name === feed
165
+ ? Effect.void
166
+ : Effect.die(
167
+ new Error(
168
+ `in-memory feed '${feed}' was asked about feed '${name}', which it does not hold`,
169
+ ),
170
+ )
171
+
172
+ const sinkOf = (s: State<D>, sink: SinkName): SinkState =>
173
+ s.sinks.get(sink) ?? EMPTY_SINK
174
+
175
+ const withSink = (
176
+ s: State<D>,
177
+ sink: SinkName,
178
+ f: (current: SinkState) => SinkState,
179
+ ): State<D> => ({
180
+ ...s,
181
+ sinks: new Map(s.sinks).set(sink, f(sinkOf(s, sink))),
182
+ })
183
+
184
+ // --- the write side ---------------------------------------------------
185
+
186
+ const write = <A, E, R>(
187
+ at: Position,
188
+ body: (writer: FeedWriter<D>) => Effect.Effect<A, E, R>,
189
+ ): Effect.Effect<A, E, R> =>
190
+ Effect.gen(function* () {
191
+ const buffer = new Map<FeedKey, Option.Option<D>>()
192
+ const set = (key: FeedKey, doc: Option.Option<D>) =>
193
+ Effect.sync(() => {
194
+ buffer.delete(key)
195
+ buffer.set(key, doc)
196
+ })
197
+ const result = yield* body({
198
+ upsert: (key, doc) => set(key, Option.some(doc)),
199
+ remove: (key) => set(key, Option.none()),
200
+ })
201
+ const staged: Array<{
202
+ key: FeedKey
203
+ doc: Option.Option<D>
204
+ encoded: string | undefined
205
+ }> = []
206
+ for (const [key, doc] of buffer) {
207
+ const encoded = Option.isSome(doc)
208
+ ? canonicalJson(yield* Effect.orDie(encode(doc.value)))
209
+ : undefined
210
+ staged.push({ key, doc, encoded })
211
+ }
212
+ const wrote = yield* Ref.modify(state, (s): [boolean, State<D>] => {
213
+ const rows = new Map(s.rows)
214
+ let lastSeq = s.lastSeq
215
+ for (const { key, doc, encoded } of staged) {
216
+ const stored = rows.get(key)
217
+ const unchanged =
218
+ encoded === undefined
219
+ ? stored === undefined || stored.encoded === undefined
220
+ : stored !== undefined && stored.encoded === encoded
221
+ if (unchanged) {
222
+ continue
223
+ }
224
+ lastSeq += 1n
225
+ rows.set(key, {
226
+ row: { key, doc, seq: FeedSeq.make(lastSeq), eventPosition: at },
227
+ encoded,
228
+ })
229
+ }
230
+ return lastSeq === s.lastSeq
231
+ ? [false, s]
232
+ : [true, { ...s, rows, lastSeq }]
233
+ })
234
+ if (wrote) {
235
+ yield* PubSub.publish(wake, undefined)
236
+ }
237
+ return result
238
+ })
239
+
240
+ // --- the read side ----------------------------------------------------
241
+
242
+ const changesAfter = (
243
+ after: FeedSeq,
244
+ limit: ReadLimit,
245
+ ): Effect.Effect<ReadonlyArray<FeedRow<D>>> =>
246
+ Effect.map(Ref.get(state), (s) =>
247
+ Array.from(s.rows.values(), (stored) => stored.row)
248
+ .filter((row) => row.seq > after)
249
+ .sort((left, right) =>
250
+ left.seq < right.seq ? -1 : left.seq > right.seq ? 1 : 0,
251
+ )
252
+ .slice(0, limit),
253
+ )
254
+
255
+ const source: FeedSource<D> = {
256
+ feed,
257
+ generation: Effect.map(Ref.get(state), (s) => s.generation),
258
+ changesAfter,
259
+ listen: Stream.fromPubSub(wake),
260
+ }
261
+
262
+ // --- the sync store ---------------------------------------------------
263
+
264
+ // Take-if-free and record the holder in ONE `Ref.modify`, so the check and the
265
+ // take cannot be split by a rival, and the lease has one home that `casCursor`,
266
+ // `describe` and `removeSink` read. `acquireRelease` runs the take
267
+ // uninterruptibly, so a lease taken is always paired with its release.
268
+ const acquireLease = (id: FeedSinkId) =>
269
+ Effect.gen(function* () {
270
+ yield* requireFeed(id.feed)
271
+ const holder = LeaseHolder.make(
272
+ `in-memory:${yield* Random.nextIntBetween(1, Number.MAX_SAFE_INTEGER)}`,
273
+ )
274
+ const take = Effect.flatMap(
275
+ Ref.modify(state, (s): [boolean, State<D>] =>
276
+ Option.isSome(sinkOf(s, id.sink).holder)
277
+ ? [false, s]
278
+ : [
279
+ true,
280
+ withSink(s, id.sink, (sink) => ({
281
+ ...sink,
282
+ holder: Option.some(holder),
283
+ })),
284
+ ],
285
+ ),
286
+ (taken) => (taken ? Effect.void : Effect.fail(new LeaseHeld(id))),
287
+ )
288
+ // Cleared only while it is still this holder's, so a release can never
289
+ // take the lease from anyone else.
290
+ const release = Ref.update(state, (s) => {
291
+ const current = s.sinks.get(id.sink)
292
+ return current !== undefined &&
293
+ Option.isSome(current.holder) &&
294
+ current.holder.value === holder
295
+ ? withSink(s, id.sink, (sink) => ({
296
+ ...sink,
297
+ holder: Option.none(),
298
+ }))
299
+ : s
300
+ })
301
+ yield* Effect.acquireRelease(take, () => release)
302
+ const lease: FeedLease = {
303
+ holder,
304
+ ensureHeld: Effect.void,
305
+ }
306
+ return lease
307
+ })
308
+
309
+ const casCursor: FeedSyncStore['casCursor'] = (args) =>
310
+ Effect.flatMap(
311
+ Effect.zip(requireFeed(args.feed), DateTime.now),
312
+ ([, now]) =>
313
+ Effect.flatMap(
314
+ Ref.modify(state, (s): [boolean, State<D>] => {
315
+ const current = sinkOf(s, args.sink)
316
+ const fenced =
317
+ Option.isSome(current.holder) &&
318
+ current.holder.value === args.holder
319
+ if (!fenced || !sameCursor(current.cursor, args.expected)) {
320
+ return [false, s]
321
+ }
322
+ return [
323
+ true,
324
+ withSink(s, args.sink, (sink) => ({
325
+ ...sink,
326
+ cursor: Option.some(args.next),
327
+ lastAdvance: Option.some(now),
328
+ parked: moveParked(sink.parked, args.parked),
329
+ })),
330
+ ]
331
+ }),
332
+ (landed) =>
333
+ landed
334
+ ? Effect.void
335
+ : Effect.fail(
336
+ new CursorSuperseded({ feed: args.feed, sink: args.sink }),
337
+ ),
338
+ ),
339
+ )
340
+
341
+ const retryParked: FeedSyncStore['retryParked'] = (args) =>
342
+ Effect.gen(function* () {
343
+ yield* requireFeed(args.feed)
344
+ const retried = yield* Ref.modify(state, (s): [boolean, State<D>] => {
345
+ const sink = sinkOf(s, args.sink)
346
+ if (!sink.parked.has(args.key)) {
347
+ return [false, s]
348
+ }
349
+ const parked = new Map(sink.parked)
350
+ parked.delete(args.key)
351
+ const unparked = withSink(s, args.sink, (current) => ({
352
+ ...current,
353
+ parked,
354
+ }))
355
+ const stored = s.rows.get(args.key)
356
+ if (stored === undefined) {
357
+ return [true, unparked]
358
+ }
359
+ const lastSeq = s.lastSeq + 1n
360
+ return [
361
+ true,
362
+ {
363
+ ...unparked,
364
+ lastSeq,
365
+ rows: new Map(s.rows).set(args.key, {
366
+ ...stored,
367
+ row: { ...stored.row, seq: FeedSeq.make(lastSeq) },
368
+ }),
369
+ },
370
+ ]
371
+ })
372
+ if (retried) {
373
+ yield* PubSub.publish(wake, undefined)
374
+ }
375
+ return retried
376
+ })
377
+
378
+ const collectTombstones: FeedSyncStore['collectTombstones'] = (name) =>
379
+ Effect.flatMap(requireFeed(name), () =>
380
+ Ref.modify(state, (s): [number, State<D>] => {
381
+ // Every sink with a lease or a cursor counts, whatever its cursor's
382
+ // generation, and one with no cursor blocks collection outright: the
383
+ // port's doc says why a resyncing sink still needs the tombstones it has
384
+ // not read, and why an older generation needs no arm of its own.
385
+ let lowest: bigint | undefined
386
+ for (const sink of s.sinks.values()) {
387
+ if (Option.isNone(sink.cursor)) {
388
+ return [0, s]
389
+ }
390
+ if (lowest === undefined || sink.cursor.value.seq < lowest) {
391
+ lowest = sink.cursor.value.seq
392
+ }
393
+ }
394
+ if (lowest === undefined) {
395
+ return [0, s]
396
+ }
397
+ const rows = new Map(s.rows)
398
+ let collected = 0
399
+ for (const [key, stored] of s.rows) {
400
+ if (stored.encoded === undefined && stored.row.seq <= lowest) {
401
+ rows.delete(key)
402
+ collected += 1
403
+ }
404
+ }
405
+ return collected === 0 ? [0, s] : [collected, { ...s, rows }]
406
+ }),
407
+ )
408
+
409
+ const describe: FeedSyncStore['describe'] = (name) =>
410
+ Effect.flatMap(requireFeed(name), () =>
411
+ Effect.map(Ref.get(state), (s): FeedSyncDescription => {
412
+ let head: bigint = FEED_ORIGIN
413
+ for (const stored of s.rows.values()) {
414
+ if (stored.row.seq > head) {
415
+ head = stored.row.seq
416
+ }
417
+ }
418
+ const sinks = Array.from(
419
+ s.sinks.entries(),
420
+ ([sink, state]): SinkSyncState => ({
421
+ sink,
422
+ cursor: state.cursor,
423
+ lastAdvance: state.lastAdvance,
424
+ lease: Option.map(state.holder, (holder) => ({
425
+ holder,
426
+ expiresAt: Option.none(),
427
+ })),
428
+ parked: Array.from(state.parked.values()),
429
+ pending: Array.from(s.rows.values()).filter((stored) =>
430
+ Option.match(state.cursor, {
431
+ onNone: () => stored.encoded !== undefined,
432
+ onSome: (cursor) => stored.row.seq > cursor.seq,
433
+ }),
434
+ ).length,
435
+ }),
436
+ )
437
+ return { head: FeedSeq.make(head), sinks }
438
+ }),
439
+ )
440
+
441
+ const removeSink: FeedSyncStore['removeSink'] = (id) =>
442
+ Effect.flatMap(requireFeed(id.feed), () =>
443
+ Effect.flatten(
444
+ Ref.modify(
445
+ state,
446
+ (s): [Effect.Effect<boolean, LeaseHeld>, State<D>] => {
447
+ const current = s.sinks.get(id.sink)
448
+ if (current === undefined) {
449
+ return [Effect.succeed(false), s]
450
+ }
451
+ if (Option.isSome(current.holder)) {
452
+ return [Effect.fail(new LeaseHeld(id)), s]
453
+ }
454
+ const sinks = new Map(s.sinks)
455
+ sinks.delete(id.sink)
456
+ return [Effect.succeed(true), { ...s, sinks }]
457
+ },
458
+ ),
459
+ ),
460
+ )
461
+
462
+ const syncStore: FeedSyncStore = {
463
+ acquireLease,
464
+ readCursor: (id) =>
465
+ Effect.flatMap(requireFeed(id.feed), () =>
466
+ Effect.map(Ref.get(state), (s) => sinkOf(s, id.sink).cursor),
467
+ ),
468
+ casCursor,
469
+ readParked: (id) =>
470
+ Effect.flatMap(requireFeed(id.feed), () =>
471
+ Effect.map(Ref.get(state), (s) =>
472
+ Array.from(sinkOf(s, id.sink).parked.values()),
473
+ ),
474
+ ),
475
+ retryParked,
476
+ collectTombstones,
477
+ removeSink,
478
+ describe,
479
+ }
480
+
481
+ const result: InMemoryFeed<D> = { feed, write, source, syncStore }
482
+
483
+ internals.set(result, {
484
+ bind: (key) =>
485
+ Effect.flatMap(
486
+ Ref.modify(state, (s): [Option.Option<CheckpointKey>, State<D>] =>
487
+ Option.isNone(s.boundTo)
488
+ ? [Option.none(), { ...s, boundTo: Option.some(key) }]
489
+ : [s.boundTo, s],
490
+ ),
491
+ (already) =>
492
+ Option.isSome(already) && !sameKey(already.value, key)
493
+ ? Effect.die(
494
+ new Error(
495
+ `withInMemoryFeed: feed '${feed}' is already bound to projection '${already.value.projection}' (partition '${already.value.partition}'), and one feed serves one materialisation`,
496
+ ),
497
+ )
498
+ : Effect.void,
499
+ ),
500
+ reset: Effect.andThen(
501
+ Ref.update(state, (s) => ({
502
+ ...s,
503
+ rows: new Map(),
504
+ generation: Generation.make(s.generation + 1n),
505
+ })),
506
+ PubSub.publish(wake, undefined),
507
+ ),
508
+ })
509
+
510
+ return result
511
+ })
512
+
513
+ const sameKey = (left: CheckpointKey, right: CheckpointKey): boolean =>
514
+ left.projection === right.projection && left.partition === right.partition
515
+
516
+ const moveParked = (
517
+ parked: ReadonlyMap<FeedKey, ParkedRecord>,
518
+ change: ParkedChange,
519
+ ): ReadonlyMap<FeedKey, ParkedRecord> => {
520
+ const next =
521
+ change._tag === 'Replace'
522
+ ? new Map<FeedKey, ParkedRecord>()
523
+ : new Map(parked)
524
+ if (change._tag === 'Amend') {
525
+ for (const key of change.unpark) {
526
+ next.delete(key)
527
+ }
528
+ }
529
+ for (const record of change.park) {
530
+ next.set(record.key, record)
531
+ }
532
+ return next
533
+ }
534
+
535
+ /**
536
+ * `store`, with `resetCheckpoint` for `key` replaced by one that also empties
537
+ * `feed` and moves its generation on, so every sink resyncs once the read model is
538
+ * replayed. Every other key passes through untouched, and `readCheckpoint` and
539
+ * `commit` are the store's own, so the result works through `forKey` exactly as
540
+ * the store does.
541
+ *
542
+ * Binding is checked here, at construction: a feed serves ONE materialisation,
543
+ * because one writer is what keeps its sequence ascending in commit order, so a
544
+ * feed already bound to another checkpoint key is refused, as is a durable `store`
545
+ * (see the module doc). Both refusals are defects.
546
+ */
547
+ export const withInMemoryFeed = <D>(
548
+ store: ProjectionStore,
549
+ feed: InMemoryFeed<D>,
550
+ key: CheckpointKey,
551
+ ): Effect.Effect<ProjectionStore> =>
552
+ Effect.gen(function* () {
553
+ const inner = internals.get(feed)
554
+ if (inner === undefined) {
555
+ return yield* Effect.die(
556
+ new Error(
557
+ `withInMemoryFeed: feed '${feed.feed}' was not made by makeInMemoryFeed`,
558
+ ),
559
+ )
560
+ }
561
+ if (store.durability === 'durable') {
562
+ return yield* Effect.die(
563
+ new Error(
564
+ `withInMemoryFeed: feed '${feed.feed}' is ephemeral and the store is durable; the view would outlive the feed`,
565
+ ),
566
+ )
567
+ }
568
+ yield* inner.bind(key)
569
+ const decorated: ProjectionStore = {
570
+ durability: store.durability,
571
+ readCheckpoint: store.readCheckpoint,
572
+ commit: store.commit,
573
+ resetCheckpoint: (reset) =>
574
+ sameKey(reset, key)
575
+ ? Effect.andThen(
576
+ store.resetCheckpoint(reset),
577
+ Effect.uninterruptible(inner.reset),
578
+ )
579
+ : store.resetCheckpoint(reset),
580
+ }
581
+ return decorated
582
+ })