@kairos-es/core 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 +33 -0
- package/dist/cjs/AppendCondition.js +26 -0
- package/dist/cjs/AppendCondition.js.map +1 -0
- package/dist/cjs/ContractStore.js +80 -0
- package/dist/cjs/ContractStore.js.map +1 -0
- package/dist/cjs/DcbEventStore.js +10 -0
- package/dist/cjs/DcbEventStore.js.map +1 -0
- package/dist/cjs/Event.js +89 -0
- package/dist/cjs/Event.js.map +1 -0
- package/dist/cjs/Projection.js +73 -0
- package/dist/cjs/Projection.js.map +1 -0
- package/dist/cjs/Query.js +221 -0
- package/dist/cjs/Query.js.map +1 -0
- package/dist/cjs/SubscribeMachine.js +235 -0
- package/dist/cjs/SubscribeMachine.js.map +1 -0
- package/dist/cjs/errors.js +22 -0
- package/dist/cjs/errors.js.map +1 -0
- package/dist/cjs/inMemory.js +191 -0
- package/dist/cjs/inMemory.js.map +1 -0
- package/dist/cjs/index.js +124 -0
- package/dist/cjs/index.js.map +1 -0
- package/dist/cjs/retryOnConflict.js +95 -0
- package/dist/cjs/retryOnConflict.js.map +1 -0
- package/dist/cjs/testing.js +54 -0
- package/dist/cjs/testing.js.map +1 -0
- package/dist/dts/AppendCondition.d.ts +23 -0
- package/dist/dts/AppendCondition.d.ts.map +1 -0
- package/dist/dts/ContractStore.d.ts +56 -0
- package/dist/dts/ContractStore.d.ts.map +1 -0
- package/dist/dts/DcbEventStore.d.ts +64 -0
- package/dist/dts/DcbEventStore.d.ts.map +1 -0
- package/dist/dts/Event.d.ts +104 -0
- package/dist/dts/Event.d.ts.map +1 -0
- package/dist/dts/Projection.d.ts +108 -0
- package/dist/dts/Projection.d.ts.map +1 -0
- package/dist/dts/Query.d.ts +145 -0
- package/dist/dts/Query.d.ts.map +1 -0
- package/dist/dts/SubscribeMachine.d.ts +179 -0
- package/dist/dts/SubscribeMachine.d.ts.map +1 -0
- package/dist/dts/errors.d.ts +17 -0
- package/dist/dts/errors.d.ts.map +1 -0
- package/dist/dts/inMemory.d.ts +76 -0
- package/dist/dts/inMemory.d.ts.map +1 -0
- package/dist/dts/index.d.ts +35 -0
- package/dist/dts/index.d.ts.map +1 -0
- package/dist/dts/retryOnConflict.d.ts +70 -0
- package/dist/dts/retryOnConflict.d.ts.map +1 -0
- package/dist/dts/testing.d.ts +39 -0
- package/dist/dts/testing.d.ts.map +1 -0
- package/dist/esm/AppendCondition.js +19 -0
- package/dist/esm/AppendCondition.js.map +1 -0
- package/dist/esm/ContractStore.js +73 -0
- package/dist/esm/ContractStore.js.map +1 -0
- package/dist/esm/DcbEventStore.js +3 -0
- package/dist/esm/DcbEventStore.js.map +1 -0
- package/dist/esm/Event.js +81 -0
- package/dist/esm/Event.js.map +1 -0
- package/dist/esm/Projection.js +66 -0
- package/dist/esm/Projection.js.map +1 -0
- package/dist/esm/Query.js +209 -0
- package/dist/esm/Query.js.map +1 -0
- package/dist/esm/SubscribeMachine.js +227 -0
- package/dist/esm/SubscribeMachine.js.map +1 -0
- package/dist/esm/errors.js +14 -0
- package/dist/esm/errors.js.map +1 -0
- package/dist/esm/inMemory.js +184 -0
- package/dist/esm/inMemory.js.map +1 -0
- package/dist/esm/index.js +38 -0
- package/dist/esm/index.js.map +1 -0
- package/dist/esm/package.json +4 -0
- package/dist/esm/retryOnConflict.js +87 -0
- package/dist/esm/retryOnConflict.js.map +1 -0
- package/dist/esm/testing.js +46 -0
- package/dist/esm/testing.js.map +1 -0
- package/package.json +39 -0
- package/src/AppendCondition.ts +18 -0
- package/src/ContractStore.ts +148 -0
- package/src/DcbEventStore.ts +77 -0
- package/src/Event.ts +135 -0
- package/src/Projection.ts +168 -0
- package/src/Query.ts +237 -0
- package/src/SubscribeMachine.ts +316 -0
- package/src/errors.ts +19 -0
- package/src/inMemory.ts +356 -0
- package/src/index.ts +37 -0
- package/src/retryOnConflict.ts +100 -0
- package/src/testing.ts +47 -0
- package/testing/package.json +6 -0
package/src/inMemory.ts
ADDED
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The in-memory `DcbEventStore` layer: the canonical, readable form of the DCB
|
|
3
|
+
* check-then-append algorithm (ported from `popo.py`).
|
|
4
|
+
*
|
|
5
|
+
* The log is a `Ref<ReadonlyArray<SequencedEvent>>` in ascending position
|
|
6
|
+
* order. The whole check-then-append is run inside a single
|
|
7
|
+
* `Effect.makeSemaphore(1)`+`withPermits(1)` critical section: the conditional
|
|
8
|
+
* read and the commit must not be separable, and that atomicity is the entire
|
|
9
|
+
* concurrency guarantee (exactly-one-wins under a race). Positions come from a
|
|
10
|
+
* monotonic counter starting at 1n (contiguous here; the contract only promises
|
|
11
|
+
* strictly-increasing, gaps permitted).
|
|
12
|
+
*
|
|
13
|
+
* `subscribe` is core's shared `subscribeStream` and NOT an algorithm of this
|
|
14
|
+
* engine's own. The engine supplies the two transport seams — a paged `fetch` over
|
|
15
|
+
* the log `Ref` and the append hub as a wake source — and inherits catch-up, the
|
|
16
|
+
* cursor de-dup, the poll and the reconcile whole, exactly as it supplies mechanics
|
|
17
|
+
* to `makeContractStore` and inherits the contract policy whole. That inheritance
|
|
18
|
+
* matters more here than in any other engine, because this store is the parity
|
|
19
|
+
* ORACLE the rest are measured against: an oracle that delivered by an algorithm of
|
|
20
|
+
* its own would turn the shared suite's subscribe cases into a comparison of two
|
|
21
|
+
* implementations, where what they are for is comparing two backends.
|
|
22
|
+
*
|
|
23
|
+
* One append is one commit under one permit, so no writer can commit a position
|
|
24
|
+
* below one a concurrent writer has already made visible — which is how this engine
|
|
25
|
+
* discharges the ascending-commit-order precondition `SubscribeMachine.ts` states.
|
|
26
|
+
*/
|
|
27
|
+
import type { Array as Arr, Scope } from 'effect'
|
|
28
|
+
import { Effect, Layer, PubSub, Ref, Stream } from 'effect'
|
|
29
|
+
import type { AppendCondition } from './AppendCondition'
|
|
30
|
+
import { makeContractStore, type RawDcbEventStore } from './ContractStore'
|
|
31
|
+
import type { ReadOptions, ReadResponse } from './DcbEventStore'
|
|
32
|
+
import { DcbEventStore } from './DcbEventStore'
|
|
33
|
+
import type { DcbEvent, Position, ReadLimit, SequencedEvent } from './Event'
|
|
34
|
+
import { ORIGIN } from './Event'
|
|
35
|
+
import { AppendConditionFailed } from './errors'
|
|
36
|
+
import type { Query } from './Query'
|
|
37
|
+
import { matchesSequenced } from './Query'
|
|
38
|
+
import {
|
|
39
|
+
DEFAULT_CATCH_UP_PAGE_SIZE,
|
|
40
|
+
DEFAULT_POLL_INTERVAL_MILLIS,
|
|
41
|
+
subscribeStream,
|
|
42
|
+
} from './SubscribeMachine'
|
|
43
|
+
|
|
44
|
+
/** Filter a snapshot by `query` and `after` (strict `position > after`). */
|
|
45
|
+
const filterLog = (
|
|
46
|
+
log: ReadonlyArray<SequencedEvent>,
|
|
47
|
+
query: Query,
|
|
48
|
+
after: Position | undefined,
|
|
49
|
+
): ReadonlyArray<SequencedEvent> =>
|
|
50
|
+
log.filter(
|
|
51
|
+
(sequenced) =>
|
|
52
|
+
(after === undefined || sequenced.position > after) &&
|
|
53
|
+
matchesSequenced(sequenced, query),
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
/** The position of the last event in a slice, or `undefined` when empty. */
|
|
57
|
+
const lastPosition = (
|
|
58
|
+
events: ReadonlyArray<SequencedEvent>,
|
|
59
|
+
): Position | undefined => events.at(-1)?.position
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Compute the read result over a snapshot: filter, apply the limit, and derive
|
|
63
|
+
* `head` per the contract (no limit => global last position or ORIGIN; limit =>
|
|
64
|
+
* last returned position or ORIGIN).
|
|
65
|
+
*/
|
|
66
|
+
const readSnapshot = (
|
|
67
|
+
log: ReadonlyArray<SequencedEvent>,
|
|
68
|
+
query: Query,
|
|
69
|
+
options: ReadOptions | undefined,
|
|
70
|
+
): ReadResponse => {
|
|
71
|
+
const matched = filterLog(log, query, options?.after)
|
|
72
|
+
const limited =
|
|
73
|
+
options?.limit === undefined ? matched : matched.slice(0, options.limit)
|
|
74
|
+
|
|
75
|
+
// No limit => head is the global last position (ignoring the filter). With a
|
|
76
|
+
// limit => head is the last returned event's position. Either way, ORIGIN
|
|
77
|
+
// when there is nothing to report.
|
|
78
|
+
const head =
|
|
79
|
+
(options?.limit === undefined
|
|
80
|
+
? lastPosition(log)
|
|
81
|
+
: lastPosition(limited)) ?? ORIGIN
|
|
82
|
+
|
|
83
|
+
return { events: Stream.fromIterable(limited), head }
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* The index of the first event strictly after `after`, by BINARY SEARCH.
|
|
88
|
+
*
|
|
89
|
+
* The log is append-only and ascending by position, so the cursor cut is a boundary
|
|
90
|
+
* in a sorted array and costs O(log n). That is what makes a live subscription's
|
|
91
|
+
* idle poll affordable at any log size: the poll re-reads from its cursor every
|
|
92
|
+
* interval whether or not anything arrived, so a linear cut would charge every open
|
|
93
|
+
* subscription O(n) per interval for as long as it stayed open.
|
|
94
|
+
*/
|
|
95
|
+
const firstIndexAfter = (
|
|
96
|
+
log: ReadonlyArray<SequencedEvent>,
|
|
97
|
+
after: Position,
|
|
98
|
+
): number => {
|
|
99
|
+
let low = 0
|
|
100
|
+
let high = log.length
|
|
101
|
+
while (low < high) {
|
|
102
|
+
// `>>> 1` rather than a divide: an integer midpoint with no rounding step.
|
|
103
|
+
const mid = (low + high) >>> 1
|
|
104
|
+
if (log[mid].position > after) {
|
|
105
|
+
high = mid
|
|
106
|
+
} else {
|
|
107
|
+
low = mid + 1
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return low
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* `subscribeStream`'s `fetch` seam: up to `limit` events matching `query` strictly
|
|
115
|
+
* after `from`, ascending.
|
|
116
|
+
*
|
|
117
|
+
* The cursor cut comes FIRST and the query match second, so the predicate only ever
|
|
118
|
+
* sees candidates the cursor admits, and the scan stops the moment `limit` of them
|
|
119
|
+
* have matched rather than filtering a tail it would discard.
|
|
120
|
+
*
|
|
121
|
+
* Deliberately not expressed through `filterLog`, and the reason is COST rather than
|
|
122
|
+
* the answer: that form returns the same events and scans the whole log per page.
|
|
123
|
+
*
|
|
124
|
+
* What is not interchangeable is what the cap COUNTS. The machine's paging contract
|
|
125
|
+
* reads a SHORT page as "the tail is drained", so the cap has to count MATCHES and a
|
|
126
|
+
* short page has to mean the scan ran off the end of the log. A cap counting
|
|
127
|
+
* CANDIDATES — `limit` events taken by POSITION, with the predicate applied within
|
|
128
|
+
* them — reports a short page for a window that merely held few matches; and a window
|
|
129
|
+
* holding NONE reports a drained tail while advancing the cursor nowhere, so the
|
|
130
|
+
* subscription is WEDGED rather than delayed, with no later poll able to move it.
|
|
131
|
+
* `InMemoryStoreOptions.catchUpPageSize` is what puts a test at that boundary.
|
|
132
|
+
*/
|
|
133
|
+
const fetchPage = (
|
|
134
|
+
log: ReadonlyArray<SequencedEvent>,
|
|
135
|
+
query: Query,
|
|
136
|
+
from: Position,
|
|
137
|
+
limit: ReadLimit,
|
|
138
|
+
): ReadonlyArray<SequencedEvent> => {
|
|
139
|
+
const page: Array<SequencedEvent> = []
|
|
140
|
+
for (
|
|
141
|
+
let index = firstIndexAfter(log, from);
|
|
142
|
+
index < log.length && page.length < limit;
|
|
143
|
+
index += 1
|
|
144
|
+
) {
|
|
145
|
+
const sequenced = log[index]
|
|
146
|
+
if (matchesSequenced(sequenced, query)) {
|
|
147
|
+
page.push(sequenced)
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
return page
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Options for the in-memory store factory.
|
|
155
|
+
*
|
|
156
|
+
* `beforeCommit` runs inside the append critical section, between the
|
|
157
|
+
* conflict-check and the commit. It defaults to `Effect.void` — a synchronous
|
|
158
|
+
* no-op — so the shipped critical section stays atomic by construction
|
|
159
|
+
* (`Ref.get` -> filter -> `Ref.set`, no suspension), which is the whole
|
|
160
|
+
* exactly-one-wins guarantee. It is a test seam: the concurrency contract test
|
|
161
|
+
* overrides it with `Effect.yieldNow()` to force a check/commit suspension, so
|
|
162
|
+
* the guarantee genuinely depends on `withPermits(1)` serialising across that
|
|
163
|
+
* boundary — without an injected suspension the test is vacuous (it passes even
|
|
164
|
+
* with the mutex removed). Real async backends (e.g. Postgres) have this
|
|
165
|
+
* boundary intrinsically; the in-memory store injects it only under test.
|
|
166
|
+
*
|
|
167
|
+
* `suppressWake` stops `append` publishing to the hub, so no wake ever reaches a
|
|
168
|
+
* live subscriber and the only path left to a delivery is the subscribe machine's
|
|
169
|
+
* periodic poll. It defaults to `false` — the shipped store always wakes — and it
|
|
170
|
+
* earns its place for the same reason `beforeCommit` does: the machine's stated
|
|
171
|
+
* guarantee is that the POLL and not the wake source is what makes delivery correct,
|
|
172
|
+
* and every ordinary subscribe test could be passing purely via the hub, which
|
|
173
|
+
* leaves that load-bearing claim asserted only in a comment. Suppressing the PUBLISH
|
|
174
|
+
* rather than the machine's `listen` keeps `subscribe`'s wiring identical to
|
|
175
|
+
* production's: what changes is that the wake never fires, which is exactly what a
|
|
176
|
+
* wake source that died silently does to a real engine.
|
|
177
|
+
*
|
|
178
|
+
* `catchUpPageSize` lowers the catch-up page size the subscribe machine runs this
|
|
179
|
+
* store's subscriptions at, defaulting to core's `DEFAULT_CATCH_UP_PAGE_SIZE` — and it
|
|
180
|
+
* earns its place the same way its siblings here do. At that default a catch-up drains
|
|
181
|
+
* in ONE page for any backlog a test would write, so the page loop's exit condition is
|
|
182
|
+
* taken on the FIRST page every time and `fetchPage`'s whole reason for existing is a
|
|
183
|
+
* claim about a boundary nothing reaches. Lowering the page brings the SECOND one
|
|
184
|
+
* within reach: a store that cannot be made to page cannot be asked whether it pages
|
|
185
|
+
* correctly.
|
|
186
|
+
*
|
|
187
|
+
* `pollInterval` lowers the ceiling on how long the subscribe machine waits for a
|
|
188
|
+
* wake before re-reading from its cursor anyway (milliseconds), defaulting to core's
|
|
189
|
+
* `DEFAULT_POLL_INTERVAL_MILLIS`. The oracle takes its subscribe tuning from exactly
|
|
190
|
+
* where the engines it judges take theirs, so a figure chosen here instead would be
|
|
191
|
+
* this store drifting from all of them by a factor nothing outside its own tests ever
|
|
192
|
+
* asked for.
|
|
193
|
+
*
|
|
194
|
+
* A seam rather than a shorter shipped default, and what that costs is MEASURED
|
|
195
|
+
* rather than assumed: at core's default, NO subscribe case in the shared contract
|
|
196
|
+
* suite reaches a poll timeout against this store. Those cases run on a real clock
|
|
197
|
+
* and finish in a few hundred milliseconds, and the hub wake carries every delivery
|
|
198
|
+
* they assert, so the poll — the half that carries delivery whenever no wake is
|
|
199
|
+
* heard — is never what delivered one. A case that wants the POLL to deliver here
|
|
200
|
+
* must therefore ask for an interval short enough to elapse inside it, and taking the
|
|
201
|
+
* wake away is what makes such a case prove anything. Asking is cheap: the machine
|
|
202
|
+
* treats the interval as a CEILING on a wait and never as a tick rate, so a wake
|
|
203
|
+
* still returns at once and only an IDLE subscription pays — one `fetch` per
|
|
204
|
+
* interval, which here is a binary search plus a bounded scan over an array.
|
|
205
|
+
*/
|
|
206
|
+
export interface InMemoryStoreOptions {
|
|
207
|
+
readonly beforeCommit?: Effect.Effect<void>
|
|
208
|
+
readonly suppressWake?: boolean
|
|
209
|
+
readonly catchUpPageSize?: number
|
|
210
|
+
readonly pollInterval?: number
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Build the in-memory store MECHANICS (a `RawDcbEventStore`), wiring the Ref,
|
|
215
|
+
* semaphore and hub. The contract-enforcement policy (`assertServableQuery`,
|
|
216
|
+
* `ReadLimit` decode, error-vs-defect classification) is NOT here — it is added
|
|
217
|
+
* uniformly by `makeContractStore`, so this engine is pure mechanics.
|
|
218
|
+
*/
|
|
219
|
+
const make = (
|
|
220
|
+
options?: InMemoryStoreOptions,
|
|
221
|
+
): Effect.Effect<RawDcbEventStore<never>> =>
|
|
222
|
+
Effect.gen(function* () {
|
|
223
|
+
const beforeCommit = options?.beforeCommit ?? Effect.void
|
|
224
|
+
const suppressWake = options?.suppressWake ?? false
|
|
225
|
+
const catchUpPageSize =
|
|
226
|
+
options?.catchUpPageSize ?? DEFAULT_CATCH_UP_PAGE_SIZE
|
|
227
|
+
const pollInterval = options?.pollInterval ?? DEFAULT_POLL_INTERVAL_MILLIS
|
|
228
|
+
const log = yield* Ref.make<ReadonlyArray<SequencedEvent>>([])
|
|
229
|
+
const mutex = yield* Effect.makeSemaphore(1)
|
|
230
|
+
const hub = yield* PubSub.unbounded<SequencedEvent>()
|
|
231
|
+
const nextPosition = yield* Ref.make<bigint>(1n)
|
|
232
|
+
|
|
233
|
+
const read = (
|
|
234
|
+
query: Query,
|
|
235
|
+
options?: ReadOptions,
|
|
236
|
+
): Effect.Effect<ReadResponse> =>
|
|
237
|
+
// The lock keeps read consistent with concurrent appends; popo.py's RLock
|
|
238
|
+
// is reentrant, so append's inner read re-enters. Here append inlines the
|
|
239
|
+
// read logic instead, so a plain lock suffices.
|
|
240
|
+
mutex.withPermits(1)(
|
|
241
|
+
Effect.map(Ref.get(log), (snapshot) =>
|
|
242
|
+
readSnapshot(snapshot, query, options),
|
|
243
|
+
),
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
const append = (
|
|
247
|
+
events: Arr.NonEmptyReadonlyArray<DcbEvent>,
|
|
248
|
+
condition?: AppendCondition,
|
|
249
|
+
): Effect.Effect<Position, AppendConditionFailed> =>
|
|
250
|
+
// Critical section: check + commit are atomic (the exactly-one-wins guarantee).
|
|
251
|
+
mutex.withPermits(1)(
|
|
252
|
+
Effect.gen(function* () {
|
|
253
|
+
const snapshot = yield* Ref.get(log)
|
|
254
|
+
|
|
255
|
+
if (condition !== undefined) {
|
|
256
|
+
const conflicts = filterLog(
|
|
257
|
+
snapshot,
|
|
258
|
+
condition.failIfEventsMatch,
|
|
259
|
+
condition.after,
|
|
260
|
+
)
|
|
261
|
+
// Detect by presence: any matching event after `condition.after`
|
|
262
|
+
// fails the append. The error carries only the condition (see
|
|
263
|
+
// `AppendConditionFailed`); a correct retry re-reads to the new head.
|
|
264
|
+
if (conflicts.length > 0) {
|
|
265
|
+
return yield* new AppendConditionFailed({ condition })
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// Optional test seam between the conflict-check and the commit (see
|
|
270
|
+
// `InMemoryStoreOptions.beforeCommit`). Defaults to `Effect.void`, a
|
|
271
|
+
// synchronous no-op, so the shipped critical section stays atomic by
|
|
272
|
+
// construction; the concurrency contract test injects `Effect.yieldNow()`
|
|
273
|
+
// here to make the exactly-one-wins guard genuinely depend on the mutex.
|
|
274
|
+
yield* beforeCommit
|
|
275
|
+
|
|
276
|
+
const start = yield* Ref.get(nextPosition)
|
|
277
|
+
const appended: Array<SequencedEvent> = events.map(
|
|
278
|
+
(event, index) => ({
|
|
279
|
+
event,
|
|
280
|
+
position: (start + BigInt(index)) as Position,
|
|
281
|
+
}),
|
|
282
|
+
)
|
|
283
|
+
const end = start + BigInt(events.length)
|
|
284
|
+
yield* Ref.set(log, [...snapshot, ...appended])
|
|
285
|
+
yield* Ref.set(nextPosition, end)
|
|
286
|
+
// The hub is a WAKE SOURCE and nothing else: the subscribe machine never
|
|
287
|
+
// reads what it carries, and its poll delivers with or without one. That
|
|
288
|
+
// is why suppressing the publish is a legitimate test seam rather than a
|
|
289
|
+
// broken store (see `InMemoryStoreOptions.suppressWake`).
|
|
290
|
+
if (!suppressWake) {
|
|
291
|
+
yield* PubSub.publishAll(hub, appended)
|
|
292
|
+
}
|
|
293
|
+
// `events` is non-empty, so the last new position is `end - 1`.
|
|
294
|
+
return (end - 1n) as Position
|
|
295
|
+
}),
|
|
296
|
+
)
|
|
297
|
+
|
|
298
|
+
const subscribe = (
|
|
299
|
+
query: Query,
|
|
300
|
+
after?: Position,
|
|
301
|
+
): Stream.Stream<SequencedEvent, never, Scope.Scope> =>
|
|
302
|
+
// Take the hub seat HERE, before handing the machine its wake source, so the
|
|
303
|
+
// subscription is established no later than the machine's first `fetch`. That
|
|
304
|
+
// ordering is what keeps delivery independent of the poll's timer: an append
|
|
305
|
+
// either lands before that first read, and the read sees it, or after the seat
|
|
306
|
+
// exists, and its wake is queued. The machine drains a wake source on a forked
|
|
307
|
+
// fibre, so a seat taken inside that fibre would leave a window in which an
|
|
308
|
+
// append publishes to nobody — recoverable by the next poll, but only on a
|
|
309
|
+
// real clock, and much of this store's use is under `TestClock`.
|
|
310
|
+
//
|
|
311
|
+
// No mutex: `Ref.get` already reads one committed snapshot, and the machine's
|
|
312
|
+
// exclusive cursor is what makes the catch-up/live seam exactly-once, so there
|
|
313
|
+
// is nothing left here for a critical section to protect. `append` keeps its
|
|
314
|
+
// permit, which is the whole exactly-one-wins guarantee.
|
|
315
|
+
Stream.unwrapScoped(
|
|
316
|
+
Effect.gen(function* () {
|
|
317
|
+
const wakes = yield* PubSub.subscribe(hub)
|
|
318
|
+
return subscribeStream<never>(
|
|
319
|
+
{
|
|
320
|
+
fetch: (from, limit) =>
|
|
321
|
+
Effect.map(Ref.get(log), (snapshot) =>
|
|
322
|
+
fetchPage(snapshot, query, from, limit),
|
|
323
|
+
),
|
|
324
|
+
listen: Stream.fromQueue(wakes),
|
|
325
|
+
pollInterval,
|
|
326
|
+
catchUpPageSize,
|
|
327
|
+
},
|
|
328
|
+
after,
|
|
329
|
+
)
|
|
330
|
+
}),
|
|
331
|
+
)
|
|
332
|
+
|
|
333
|
+
return { read, append, subscribe } as const
|
|
334
|
+
})
|
|
335
|
+
|
|
336
|
+
/** The in-memory `DcbEventStore` layer (production; no test seam). */
|
|
337
|
+
export const DcbEventStoreInMemory: Layer.Layer<DcbEventStore> = Layer.effect(
|
|
338
|
+
DcbEventStore,
|
|
339
|
+
Effect.map(make(), makeContractStore),
|
|
340
|
+
)
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* Test-only in-memory layer factory: the same store as `DcbEventStoreInMemory`, but
|
|
344
|
+
* with `InMemoryStoreOptions`' seams open. Kept off the package's public surface
|
|
345
|
+
* (deliberately not re-exported from `index.ts`); production code always uses
|
|
346
|
+
* `DcbEventStoreInMemory`. Every seam exists to make a claim about this store
|
|
347
|
+
* FALSIFIABLE rather than merely commented — the store contract suite forces the
|
|
348
|
+
* check/commit interleaving the exactly-one-wins case needs, one core test takes the
|
|
349
|
+
* wake away and shortens the poll so that what delivers can only be the poll, and
|
|
350
|
+
* another lowers the catch-up page to reach the second-page boundary the shipped page
|
|
351
|
+
* size puts out of reach.
|
|
352
|
+
*/
|
|
353
|
+
export const makeInMemoryTestLayer = (
|
|
354
|
+
options: InMemoryStoreOptions,
|
|
355
|
+
): Layer.Layer<DcbEventStore> =>
|
|
356
|
+
Layer.effect(DcbEventStore, Effect.map(make(options), makeContractStore))
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @kairos-es/core: DCB-first event-sourcing contract and in-memory store for
|
|
3
|
+
* Effect v3.
|
|
4
|
+
*
|
|
5
|
+
* Exposes the domain types (`DcbEvent`, `SequencedEvent`, `Position`, `Tag`,
|
|
6
|
+
* `EventType`), the selection language (`Query`, `QueryItem`, the pure match
|
|
7
|
+
* predicate + smart constructors, and the servable-shape dispatch every engine
|
|
8
|
+
* compiles its plans through), the `AppendCondition`, tagged errors, the
|
|
9
|
+
* `DcbEventStore` contract, the in-memory layer, the shared subscribe machine
|
|
10
|
+
* every engine's `subscribe` is built from (`SubscribeMachine.ts`), the pure
|
|
11
|
+
* projection primitives (`Projection`, `DecodedEvent`, `composeProjections`), and
|
|
12
|
+
* `retryOnConflict`.
|
|
13
|
+
*
|
|
14
|
+
* Core keeps only the *pure* fold: projections fold an already-`DecodedEvent`
|
|
15
|
+
* (decoding needs `Schema` + a `Serializer`, which core must not depend on).
|
|
16
|
+
* The event-def builder and the decode-aware `buildDecisionModel` live in
|
|
17
|
+
* `@kairos-es/codec`.
|
|
18
|
+
*
|
|
19
|
+
* `retryOnConflict` is here because it is the recovery half of the append
|
|
20
|
+
* condition — the only consistency mechanism there is. It matches the
|
|
21
|
+
* `AppendConditionFailed` core defines and needs nothing beyond `effect`'s
|
|
22
|
+
* `Schedule`, which core already peers, so homing it downstream only made a
|
|
23
|
+
* write-only consumer depend on a package it uses nothing else of (ADR-0008).
|
|
24
|
+
*/
|
|
25
|
+
export * from './AppendCondition'
|
|
26
|
+
export * from './ContractStore'
|
|
27
|
+
export * from './DcbEventStore'
|
|
28
|
+
export * from './Event'
|
|
29
|
+
export * from './errors'
|
|
30
|
+
// Named re-export (not `export *`): `DcbEventStoreInMemory` is the whole public
|
|
31
|
+
// in-memory surface. `makeInMemoryTestLayer` / `InMemoryStoreOptions` are a
|
|
32
|
+
// test-only seam and are deliberately kept off the package's public exports.
|
|
33
|
+
export { DcbEventStoreInMemory } from './inMemory'
|
|
34
|
+
export * from './Projection'
|
|
35
|
+
export * from './Query'
|
|
36
|
+
export * from './retryOnConflict'
|
|
37
|
+
export * from './SubscribeMachine'
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `retryOnConflict` — the decider-level retry for `AppendConditionFailed`
|
|
3
|
+
* (ADR-0008).
|
|
4
|
+
*
|
|
5
|
+
* `append` surfaces a conflict as a SIGNAL only: a tagged `AppendConditionFailed`
|
|
6
|
+
* flattened to the tripped condition, carrying no conflicting position,
|
|
7
|
+
* deliberately so that a retry cannot resume from a stale conflict point. A
|
|
8
|
+
* correct DCB retry must therefore re-derive its append-condition lower bound
|
|
9
|
+
* from a FRESH READ to the current head — which means re-running the whole
|
|
10
|
+
* read-decide-append cycle, not re-submitting the same events.
|
|
11
|
+
*
|
|
12
|
+
* That is why this is a combinator over a command and NOT a `Layer` decorating
|
|
13
|
+
* `DcbEventStore.append`. A decorator sees only the events and the condition; it
|
|
14
|
+
* cannot re-read the log and it cannot re-decide, so it is STRUCTURALLY incapable
|
|
15
|
+
* of a correct DCB retry — the only thing it could do is re-submit against a
|
|
16
|
+
* condition that is already known to be stale, which either fails again forever
|
|
17
|
+
* or, worse, succeeds against a boundary the decision was never made under. No
|
|
18
|
+
* retrying `Layer` is shipped, and if a production command-runner service ever
|
|
19
|
+
* emerges, a `Layer` over THAT (a whole cycle) is the shape to add.
|
|
20
|
+
*
|
|
21
|
+
* It lives HERE, in `@kairos-es/core`, because conflict retry is a WRITE-side
|
|
22
|
+
* concern and core is where the write side is defined: core declares the `append`
|
|
23
|
+
* contract, defines the `AppendConditionFailed` this combinator matches, and
|
|
24
|
+
* already peers `effect` for `Schedule`, so the move costs nothing and adds no
|
|
25
|
+
* dependency in either direction. It shipped from `@kairos-es/read` first on the
|
|
26
|
+
* argument that it added no peer surface there either — true, but an argument for
|
|
27
|
+
* where it MAY live, not where it SHOULD. What that home actually cost was a
|
|
28
|
+
* write-only consumer having to depend on the READ package to retry its own
|
|
29
|
+
* appends, for a combinator that touches no projection, no checkpoint and no view
|
|
30
|
+
* (ADR-0008, homing amended).
|
|
31
|
+
*
|
|
32
|
+
* Core stays pure with it here. `Schedule`'s driver reads the time through
|
|
33
|
+
* `Clock.currentTimeMillis` and sleeps through `Clock.sleep`, both resolved from
|
|
34
|
+
* the fibre's default services, so `TestClock` still governs every delay; the
|
|
35
|
+
* jitter draws from `Random.next`, a default service on the same footing as the
|
|
36
|
+
* clock rather than a host `Math.random`. No `node:*`, no wall-clock reading, no
|
|
37
|
+
* ungoverned RNG.
|
|
38
|
+
*
|
|
39
|
+
* Effect v3 caveat: the `Schedule` API below is the THREE-type-parameter
|
|
40
|
+
* `Schedule<Out, In, R>` that `effect@3` exports, with `Schedule.intersect`. The
|
|
41
|
+
* currently-published docs show a newer four-type-parameter `Schedule` without
|
|
42
|
+
* `intersect` — do not copy them; this file is written against the installed
|
|
43
|
+
* `effect@3` type definitions.
|
|
44
|
+
*/
|
|
45
|
+
import { Effect, Predicate, Schedule } from 'effect'
|
|
46
|
+
import type { AppendConditionFailed } from './errors'
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The default conflict backoff: exponential from 20ms, jittered, capped at five
|
|
50
|
+
* retries.
|
|
51
|
+
*
|
|
52
|
+
* `Schedule.intersect` continues only while BOTH schedules continue, so the
|
|
53
|
+
* `recurs(5)` arm is what bounds the attempts and the exponential arm is what
|
|
54
|
+
* spaces them. Jitter matters more here than in a typical backoff: contending
|
|
55
|
+
* deciders conflict precisely because they read the same boundary at the same
|
|
56
|
+
* time, so an unjittered backoff would re-synchronise them into the same conflict
|
|
57
|
+
* on every round. The figures are the conventional starting point (Emmett,
|
|
58
|
+
* Kurrent and Dudycz all land in this neighbourhood) and are a DEFAULT — a
|
|
59
|
+
* command with a tighter latency budget, or one contending harder, should pass its
|
|
60
|
+
* own schedule.
|
|
61
|
+
*/
|
|
62
|
+
const conflictBackoff = Schedule.intersect(
|
|
63
|
+
Schedule.jittered(Schedule.exponential('20 millis', 2)),
|
|
64
|
+
Schedule.recurs(5),
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Retry `command` while — and only while — it fails `AppendConditionFailed`.
|
|
69
|
+
*
|
|
70
|
+
* `command` must be the WHOLE read-decide-append cycle (read a decision model to
|
|
71
|
+
* the current head, decide, append under that read's condition), because each
|
|
72
|
+
* retry's correctness comes from the re-read: the fresh read re-derives the
|
|
73
|
+
* append condition's `after` from the new head, so the retried append is judged
|
|
74
|
+
* against the boundary the retried decision was actually made under. Wrapping only
|
|
75
|
+
* the `append` re-submits a stale condition and is wrong.
|
|
76
|
+
*
|
|
77
|
+
* A non-conflict `E` is preserved on the error channel and escapes IMMEDIATELY
|
|
78
|
+
* with zero retries: the `while` predicate is false for it, so a domain rejection
|
|
79
|
+
* ("course is full") never pays a backoff and never retries into a decision the
|
|
80
|
+
* log has already refused. `A`, the non-conflict `E`, and `R` all pass through
|
|
81
|
+
* untouched.
|
|
82
|
+
*
|
|
83
|
+
* The predicate matches on the `_tag` rather than `instanceof`. The tag is the
|
|
84
|
+
* error's identity across module realms, and a published package ships dual
|
|
85
|
+
* ESM/CJS, so an `instanceof` check can see two distinct classes for the same
|
|
86
|
+
* error when a consumer's graph loads both builds — a conflict would then escape
|
|
87
|
+
* unretried and silently.
|
|
88
|
+
*/
|
|
89
|
+
export const retryOnConflict = <A, E, R>(
|
|
90
|
+
command: Effect.Effect<A, AppendConditionFailed | E, R>,
|
|
91
|
+
schedule: Schedule.Schedule<
|
|
92
|
+
unknown,
|
|
93
|
+
AppendConditionFailed | E,
|
|
94
|
+
R
|
|
95
|
+
> = conflictBackoff,
|
|
96
|
+
): Effect.Effect<A, AppendConditionFailed | E, R> =>
|
|
97
|
+
Effect.retry(command, {
|
|
98
|
+
schedule,
|
|
99
|
+
while: Predicate.isTagged('AppendConditionFailed'),
|
|
100
|
+
})
|
package/src/testing.ts
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deterministic test-support utilities, shared across every kairos-es suite via
|
|
3
|
+
* the published `@kairos-es/core/testing` subpath.
|
|
4
|
+
*
|
|
5
|
+
* WHY a published subpath rather than a test-only package: three suites (core's
|
|
6
|
+
* own tests, the store contract suite, and the codec's `UuidTest` layer) each
|
|
7
|
+
* carried a byte-identical copy of the same counter-based uuid scheme and the
|
|
8
|
+
* `EPOCH` domain time. Copies drift. The one place every consumer already
|
|
9
|
+
* depends on is `@kairos-es/core`, so the single source of truth lives here as a
|
|
10
|
+
* *published* subpath — never a runtime dependency from a published package onto
|
|
11
|
+
* an unpublished/test-only one, which would create a packaging cycle.
|
|
12
|
+
*
|
|
13
|
+
* Everything here is PURE and DETERMINISTIC: no RNG, no wall-clock. That is the
|
|
14
|
+
* whole point — suites must produce the same bytes and positions on every run,
|
|
15
|
+
* so `data`/`uuid`/`occurredAt` never introduce nondeterminism into assertions.
|
|
16
|
+
*/
|
|
17
|
+
import { DateTime } from 'effect'
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* A fixed domain time (`DateTime` at the Unix epoch) for test events.
|
|
21
|
+
*
|
|
22
|
+
* Ordering in the log is by `position` alone, so an event's `occurredAt` is
|
|
23
|
+
* informational; pinning it to the epoch keeps serialised bytes and comparisons
|
|
24
|
+
* stable across runs. Built via `DateTime.unsafeMake(0)` (the value is a
|
|
25
|
+
* compile-time constant, not read from the host clock).
|
|
26
|
+
*/
|
|
27
|
+
export const EPOCH = DateTime.unsafeMake(0)
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Build a fresh, deterministic uuid generator: a zero-based counter producing
|
|
31
|
+
* RFC-4122 v4-shaped ids `00000000-0000-4000-8000-<counter as 12 hex digits>`,
|
|
32
|
+
* starting at `...000000000000` and incrementing on each call.
|
|
33
|
+
*
|
|
34
|
+
* A *factory* (not a shared singleton) so each layer/suite instance owns its own
|
|
35
|
+
* counter and the sequence is repeatable in isolation — two independent suites
|
|
36
|
+
* do not interfere, yet each is stable on its own. No RNG: the ids are
|
|
37
|
+
* comparable and predictable, which is what assertions need, while still being
|
|
38
|
+
* distinct enough to tell events apart.
|
|
39
|
+
*/
|
|
40
|
+
export const makeSequentialUuid = (): (() => string) => {
|
|
41
|
+
let counter = 0
|
|
42
|
+
return () => {
|
|
43
|
+
const suffix = counter.toString(16).padStart(12, '0')
|
|
44
|
+
counter += 1
|
|
45
|
+
return `00000000-0000-4000-8000-${suffix}`
|
|
46
|
+
}
|
|
47
|
+
}
|