@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,224 @@
1
+ /**
2
+ * Reconcile: delete what a sink holds that the feed no longer has.
3
+ *
4
+ * ## Why a feed needs it at all
5
+ *
6
+ * The steady state deletes through tombstones, and a tombstone lives only until
7
+ * every sink registered on the feed has passed it. A record can still be left
8
+ * behind in a sink: a sink restored from a backup, a key written into the index by
9
+ * hand, or a record deleted before a reset, whose tombstone the reset cleared with
10
+ * every other row, followed by an in-place resync that, having no atomic swap,
11
+ * sends only the rows the new generation holds and so never deletes it. Reconcile is the one operation that compares the sink with the feed
12
+ * rather than replaying the feed into the sink, and so the only one that finds
13
+ * those orphans.
14
+ *
15
+ * ## The order of the two reads, and why it is safe
16
+ *
17
+ * The sink's keys are listed FIRST and the feed's live keys read SECOND. A key the
18
+ * sink lists that has a live row by the time the feed is read is kept, so a record
19
+ * written while reconcile runs is never deleted by it. A key whose row turns into
20
+ * a tombstone after the feed is read is kept too, and its tombstone deletes it on
21
+ * the next pass. And a key whose row comes back to life after the feed is read is
22
+ * deleted here and re-sent by the next pass, because its new `seq` is above the
23
+ * cursor. Reconcile runs inside the worker that holds the lease, between batches,
24
+ * so nothing else is sending to the sink while it does (ADR-0019 §8).
25
+ *
26
+ * ## What it costs
27
+ *
28
+ * It holds every key the sink lists and every live key of the feed in memory at
29
+ * once, and reads every live document to find the keys. It is a periodic repair,
30
+ * not a steady-state path: `reconcileEvery` should be measured in hours.
31
+ */
32
+ import { Array as Arr, Effect, Option, Stream } from 'effect'
33
+ import type { NonEmptyReadonlyArray } from 'effect/Array'
34
+ import { deliver, type Pending } from './deliver'
35
+ import type {
36
+ FeedUnreadable,
37
+ FeedWriteFailed,
38
+ LeaseHeld,
39
+ LeaseLost,
40
+ SinkHalted,
41
+ SinkRetryable,
42
+ } from './errors'
43
+ import type { FeedSource } from './Feed'
44
+ import { FeedChange, type FeedReconcile, type FeedSink } from './FeedSink'
45
+ import type { FeedLease, FeedSyncStore } from './FeedSyncStore'
46
+ import { FEED_ORIGIN, type FeedKey, type FeedSinkId } from './identifiers'
47
+ import { DEFAULT_FEED_PAGE_SIZE, readLimit } from './tuning'
48
+
49
+ /** What one reconcile did. */
50
+ export interface ReconcileReport {
51
+ /** How many keys the sink listed. */
52
+ readonly listed: number
53
+ /** The orphans deleted: listed by the sink, with no live row in the feed. */
54
+ readonly deleted: ReadonlyArray<FeedKey>
55
+ /** Orphans whose delete the sink refused, with its reason. They stay in the sink. */
56
+ readonly refused: ReadonlyArray<{
57
+ readonly key: FeedKey
58
+ readonly reason: string
59
+ }>
60
+ }
61
+
62
+ /** Every key of `source` whose row is live, read page by page from the origin. */
63
+ export const liveKeys = <D>(
64
+ source: FeedSource<D>,
65
+ pageSize: number,
66
+ ): Effect.Effect<ReadonlySet<FeedKey>, FeedUnreadable> =>
67
+ Effect.gen(function* () {
68
+ const live = new Set<FeedKey>()
69
+ const limit = readLimit(pageSize)
70
+ let after = FEED_ORIGIN
71
+ for (;;) {
72
+ const rows = yield* source.changesAfter(after, limit)
73
+ for (const row of rows) {
74
+ if (Option.isSome(row.doc)) {
75
+ live.add(row.key)
76
+ } else {
77
+ live.delete(row.key)
78
+ }
79
+ }
80
+ const last = rows.at(-1)
81
+ if (last === undefined || rows.length < pageSize) {
82
+ return live
83
+ }
84
+ after = last.seq
85
+ }
86
+ })
87
+
88
+ /**
89
+ * What `reconcileWith` hands its caller: the report, and which keys are now gone
90
+ * from the sink for good — not in the feed's live rows, and either deleted by this
91
+ * reconcile or not listed by the sink at all. Package-internal: the report is the
92
+ * consumer's, the predicate is for {@link unparkGone}.
93
+ *
94
+ * @internal
95
+ */
96
+ export interface Reconciled {
97
+ readonly report: ReconcileReport
98
+ readonly isGone: (key: FeedKey) => boolean
99
+ }
100
+
101
+ /**
102
+ * Reconcile under a lease the caller already holds. Package-internal: the driver
103
+ * calls it between batches and in an in-place resync, and `reconcileFeed` calls it
104
+ * after taking the lease itself.
105
+ *
106
+ * @internal
107
+ */
108
+ export const reconcileWith = <D, Receipt>(args: {
109
+ readonly source: FeedSource<D>
110
+ readonly sink: FeedSink<D, Receipt>
111
+ readonly reconcile: FeedReconcile
112
+ readonly lease: FeedLease
113
+ readonly pageSize: number
114
+ }): Effect.Effect<
115
+ Reconciled,
116
+ SinkRetryable | SinkHalted | LeaseLost | FeedUnreadable
117
+ > =>
118
+ Effect.gen(function* () {
119
+ const listed = yield* Stream.runCollect(args.reconcile.listKeys)
120
+ const live = yield* liveKeys(args.source, args.pageSize)
121
+ const orphans = Array.from(new Set(listed)).filter((key) => !live.has(key))
122
+ const deleted: Array<FeedKey> = []
123
+ const refused: Array<{ key: FeedKey; reason: string }> = []
124
+ for (const chunk of Arr.chunksOf(orphans, args.pageSize)) {
125
+ const pending: NonEmptyReadonlyArray<Pending<D>> = Arr.map(
126
+ chunk,
127
+ (key) => ({ change: FeedChange.Delete({ key }), seq: FEED_ORIGIN }),
128
+ )
129
+ const delivered = yield* deliver({
130
+ send: args.sink.apply,
131
+ confirm: args.sink.confirm,
132
+ lease: args.lease,
133
+ pending,
134
+ })
135
+ deleted.push(...delivered.sent)
136
+ refused.push(
137
+ ...delivered.parked.map((p) => ({ key: p.key, reason: p.reason })),
138
+ )
139
+ }
140
+ const listedKeys = new Set(listed)
141
+ const deletedKeys = new Set(deleted)
142
+ return {
143
+ report: { listed: listedKeys.size, deleted, refused },
144
+ isGone: (key) =>
145
+ !live.has(key) && (deletedKeys.has(key) || !listedKeys.has(key)),
146
+ }
147
+ })
148
+
149
+ /**
150
+ * Unpark every parked key a reconcile found gone from the sink for good (see
151
+ * {@link Reconciled}): a park reporting a refusal for such a key — an in-place
152
+ * rebuild's orphan, or a tombstone whose delete the tail was refused — reports a
153
+ * record that is not there. `retryParked` unparks a key with no row and re-stamps
154
+ * one that has a row, which for a tombstone sends every sink one idempotent delete.
155
+ *
156
+ * It judges by what the sink holds, not by what this reconcile deleted, so a park
157
+ * an earlier unpark failed to clear goes on the next reconcile. That is why a
158
+ * failure here is a warning and not an error: it costs a stale entry in
159
+ * `inspectFeed` until then, and failing would end a worker's session, or discard
160
+ * an operator's report, over a record that is already gone.
161
+ *
162
+ * @internal
163
+ */
164
+ export const unparkGone = (
165
+ store: FeedSyncStore,
166
+ id: FeedSinkId,
167
+ isGone: (key: FeedKey) => boolean,
168
+ ): Effect.Effect<void> =>
169
+ Effect.gen(function* () {
170
+ const parked = yield* store.readParked(id)
171
+ yield* Effect.forEach(
172
+ parked.filter((p) => isGone(p.key)),
173
+ (p) => store.retryParked({ ...id, key: p.key }),
174
+ { discard: true },
175
+ )
176
+ }).pipe(
177
+ Effect.catch((cause) =>
178
+ Effect.logWarning(
179
+ 'feed reconcile could not unpark a record it found gone; the next reconcile will',
180
+ { feed: id.feed, sink: id.sink, reason: cause.message },
181
+ ),
182
+ ),
183
+ )
184
+
185
+ /** Options for {@link reconcileFeed}. */
186
+ export interface ReconcileFeedOptions<D, Receipt> {
187
+ readonly source: FeedSource<D>
188
+ readonly sink: FeedSink<D, Receipt> & { readonly reconcile: FeedReconcile }
189
+ readonly store: FeedSyncStore
190
+ /** How many rows to read, and deletes to send, at a time. */
191
+ readonly pageSize?: number
192
+ }
193
+
194
+ /**
195
+ * Reconcile `sink` against `source` once, now. It takes the worker lease itself,
196
+ * so it fails `LeaseHeld` while a sync worker for the same feed and sink runs —
197
+ * reconcile through that worker's `reconcileEvery` instead, or stop it first.
198
+ */
199
+ export const reconcileFeed = <D, Receipt>(
200
+ options: ReconcileFeedOptions<D, Receipt>,
201
+ ): Effect.Effect<
202
+ ReconcileReport,
203
+ | LeaseHeld
204
+ | LeaseLost
205
+ | FeedUnreadable
206
+ | FeedWriteFailed
207
+ | SinkRetryable
208
+ | SinkHalted
209
+ > =>
210
+ Effect.scoped(
211
+ Effect.gen(function* () {
212
+ const id = { feed: options.source.feed, sink: options.sink.name }
213
+ const lease = yield* options.store.acquireLease(id)
214
+ const { report, isGone } = yield* reconcileWith({
215
+ source: options.source,
216
+ sink: options.sink,
217
+ reconcile: options.sink.reconcile,
218
+ lease,
219
+ pageSize: options.pageSize ?? DEFAULT_FEED_PAGE_SIZE,
220
+ })
221
+ yield* unparkGone(options.store, id, isGone)
222
+ return report
223
+ }),
224
+ )