@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.
- package/LICENSE +28 -0
- package/README.md +109 -2
- package/dist/cjs/Feed.js +6 -0
- package/dist/cjs/Feed.js.map +1 -0
- package/dist/cjs/FeedSink.js +54 -0
- package/dist/cjs/FeedSink.js.map +1 -0
- package/dist/cjs/FeedSyncStore.js +10 -0
- package/dist/cjs/FeedSyncStore.js.map +1 -0
- package/dist/cjs/deliver.js +86 -0
- package/dist/cjs/deliver.js.map +1 -0
- package/dist/cjs/errors.js +114 -0
- package/dist/cjs/errors.js.map +1 -0
- package/dist/cjs/identifiers.js +63 -0
- package/dist/cjs/identifiers.js.map +1 -0
- package/dist/cjs/inMemoryFeed.js +328 -0
- package/dist/cjs/inMemoryFeed.js.map +1 -0
- package/dist/cjs/inMemorySink.js +142 -0
- package/dist/cjs/inMemorySink.js.map +1 -0
- package/dist/cjs/index.js +184 -0
- package/dist/cjs/index.js.map +1 -0
- package/dist/cjs/inspect.js +68 -0
- package/dist/cjs/inspect.js.map +1 -0
- package/dist/cjs/readiness.js +100 -0
- package/dist/cjs/readiness.js.map +1 -0
- package/dist/cjs/reconcile.js +164 -0
- package/dist/cjs/reconcile.js.map +1 -0
- package/dist/cjs/runFeedSync.js +603 -0
- package/dist/cjs/runFeedSync.js.map +1 -0
- package/dist/cjs/tuning.js +60 -0
- package/dist/cjs/tuning.js.map +1 -0
- package/dist/dts-cjs/Feed.d.ts +99 -0
- package/dist/dts-cjs/Feed.d.ts.map +1 -0
- package/dist/dts-cjs/FeedSink.d.ts +189 -0
- package/dist/dts-cjs/FeedSink.d.ts.map +1 -0
- package/dist/dts-cjs/FeedSyncStore.d.ts +281 -0
- package/dist/dts-cjs/FeedSyncStore.d.ts.map +1 -0
- package/dist/dts-cjs/deliver.d.ts +16 -0
- package/dist/dts-cjs/deliver.d.ts.map +1 -0
- package/dist/dts-cjs/errors.d.ts +131 -0
- package/dist/dts-cjs/errors.d.ts.map +1 -0
- package/dist/dts-cjs/identifiers.d.ts +67 -0
- package/dist/dts-cjs/identifiers.d.ts.map +1 -0
- package/dist/dts-cjs/inMemoryFeed.d.ts +32 -0
- package/dist/dts-cjs/inMemoryFeed.d.ts.map +1 -0
- package/dist/dts-cjs/inMemorySink.d.ts +61 -0
- package/dist/dts-cjs/inMemorySink.d.ts.map +1 -0
- package/dist/dts-cjs/index.d.ts +63 -0
- package/dist/dts-cjs/index.d.ts.map +1 -0
- package/dist/dts-cjs/inspect.d.ts +71 -0
- package/dist/dts-cjs/inspect.d.ts.map +1 -0
- package/dist/dts-cjs/package.json +3 -0
- package/dist/dts-cjs/readiness.d.ts +84 -0
- package/dist/dts-cjs/readiness.d.ts.map +1 -0
- package/dist/dts-cjs/reconcile.d.ts +68 -0
- package/dist/dts-cjs/reconcile.d.ts.map +1 -0
- package/dist/dts-cjs/runFeedSync.d.ts +220 -0
- package/dist/dts-cjs/runFeedSync.d.ts.map +1 -0
- package/dist/dts-cjs/tuning.d.ts +40 -0
- package/dist/dts-cjs/tuning.d.ts.map +1 -0
- package/dist/dts-esm/Feed.d.ts +99 -0
- package/dist/dts-esm/Feed.d.ts.map +1 -0
- package/dist/dts-esm/FeedSink.d.ts +189 -0
- package/dist/dts-esm/FeedSink.d.ts.map +1 -0
- package/dist/dts-esm/FeedSyncStore.d.ts +281 -0
- package/dist/dts-esm/FeedSyncStore.d.ts.map +1 -0
- package/dist/dts-esm/deliver.d.ts +16 -0
- package/dist/dts-esm/deliver.d.ts.map +1 -0
- package/dist/dts-esm/errors.d.ts +131 -0
- package/dist/dts-esm/errors.d.ts.map +1 -0
- package/dist/dts-esm/identifiers.d.ts +67 -0
- package/dist/dts-esm/identifiers.d.ts.map +1 -0
- package/dist/dts-esm/inMemoryFeed.d.ts +32 -0
- package/dist/dts-esm/inMemoryFeed.d.ts.map +1 -0
- package/dist/dts-esm/inMemorySink.d.ts +61 -0
- package/dist/dts-esm/inMemorySink.d.ts.map +1 -0
- package/dist/dts-esm/index.d.ts +63 -0
- package/dist/dts-esm/index.d.ts.map +1 -0
- package/dist/dts-esm/inspect.d.ts +71 -0
- package/dist/dts-esm/inspect.d.ts.map +1 -0
- package/dist/dts-esm/package.json +3 -0
- package/dist/dts-esm/readiness.d.ts +84 -0
- package/dist/dts-esm/readiness.d.ts.map +1 -0
- package/dist/dts-esm/reconcile.d.ts +68 -0
- package/dist/dts-esm/reconcile.d.ts.map +1 -0
- package/dist/dts-esm/runFeedSync.d.ts +220 -0
- package/dist/dts-esm/runFeedSync.d.ts.map +1 -0
- package/dist/dts-esm/tuning.d.ts +40 -0
- package/dist/dts-esm/tuning.d.ts.map +1 -0
- package/dist/esm/Feed.js +2 -0
- package/dist/esm/Feed.js.map +1 -0
- package/dist/esm/FeedSink.js +46 -0
- package/dist/esm/FeedSink.js.map +1 -0
- package/dist/esm/FeedSyncStore.js +3 -0
- package/dist/esm/FeedSyncStore.js.map +1 -0
- package/dist/esm/deliver.js +78 -0
- package/dist/esm/deliver.js.map +1 -0
- package/dist/esm/errors.js +98 -0
- package/dist/esm/errors.js.map +1 -0
- package/dist/esm/identifiers.js +56 -0
- package/dist/esm/identifiers.js.map +1 -0
- package/dist/esm/inMemoryFeed.js +320 -0
- package/dist/esm/inMemoryFeed.js.map +1 -0
- package/dist/esm/inMemorySink.js +134 -0
- package/dist/esm/inMemorySink.js.map +1 -0
- package/dist/esm/index.js +62 -0
- package/dist/esm/index.js.map +1 -0
- package/dist/esm/inspect.js +58 -0
- package/dist/esm/inspect.js.map +1 -0
- package/dist/esm/package.json +4 -0
- package/dist/esm/readiness.js +92 -0
- package/dist/esm/readiness.js.map +1 -0
- package/dist/esm/reconcile.js +153 -0
- package/dist/esm/reconcile.js.map +1 -0
- package/dist/esm/runFeedSync.js +595 -0
- package/dist/esm/runFeedSync.js.map +1 -0
- package/dist/esm/tuning.js +51 -0
- package/dist/esm/tuning.js.map +1 -0
- package/package.json +34 -3
- package/src/Feed.ts +111 -0
- package/src/FeedSink.ts +130 -0
- package/src/FeedSyncStore.ts +326 -0
- package/src/deliver.ts +108 -0
- package/src/errors.ts +134 -0
- package/src/identifiers.ts +92 -0
- package/src/inMemoryFeed.ts +582 -0
- package/src/inMemorySink.ts +215 -0
- package/src/index.ts +136 -0
- package/src/inspect.ts +114 -0
- package/src/readiness.ts +137 -0
- package/src/reconcile.ts +224 -0
- package/src/runFeedSync.ts +882 -0
- 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
|
+
})
|