@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.
Files changed (89) hide show
  1. package/LICENSE +28 -0
  2. package/README.md +33 -0
  3. package/dist/cjs/AppendCondition.js +26 -0
  4. package/dist/cjs/AppendCondition.js.map +1 -0
  5. package/dist/cjs/ContractStore.js +80 -0
  6. package/dist/cjs/ContractStore.js.map +1 -0
  7. package/dist/cjs/DcbEventStore.js +10 -0
  8. package/dist/cjs/DcbEventStore.js.map +1 -0
  9. package/dist/cjs/Event.js +89 -0
  10. package/dist/cjs/Event.js.map +1 -0
  11. package/dist/cjs/Projection.js +73 -0
  12. package/dist/cjs/Projection.js.map +1 -0
  13. package/dist/cjs/Query.js +221 -0
  14. package/dist/cjs/Query.js.map +1 -0
  15. package/dist/cjs/SubscribeMachine.js +235 -0
  16. package/dist/cjs/SubscribeMachine.js.map +1 -0
  17. package/dist/cjs/errors.js +22 -0
  18. package/dist/cjs/errors.js.map +1 -0
  19. package/dist/cjs/inMemory.js +191 -0
  20. package/dist/cjs/inMemory.js.map +1 -0
  21. package/dist/cjs/index.js +124 -0
  22. package/dist/cjs/index.js.map +1 -0
  23. package/dist/cjs/retryOnConflict.js +95 -0
  24. package/dist/cjs/retryOnConflict.js.map +1 -0
  25. package/dist/cjs/testing.js +54 -0
  26. package/dist/cjs/testing.js.map +1 -0
  27. package/dist/dts/AppendCondition.d.ts +23 -0
  28. package/dist/dts/AppendCondition.d.ts.map +1 -0
  29. package/dist/dts/ContractStore.d.ts +56 -0
  30. package/dist/dts/ContractStore.d.ts.map +1 -0
  31. package/dist/dts/DcbEventStore.d.ts +64 -0
  32. package/dist/dts/DcbEventStore.d.ts.map +1 -0
  33. package/dist/dts/Event.d.ts +104 -0
  34. package/dist/dts/Event.d.ts.map +1 -0
  35. package/dist/dts/Projection.d.ts +108 -0
  36. package/dist/dts/Projection.d.ts.map +1 -0
  37. package/dist/dts/Query.d.ts +145 -0
  38. package/dist/dts/Query.d.ts.map +1 -0
  39. package/dist/dts/SubscribeMachine.d.ts +179 -0
  40. package/dist/dts/SubscribeMachine.d.ts.map +1 -0
  41. package/dist/dts/errors.d.ts +17 -0
  42. package/dist/dts/errors.d.ts.map +1 -0
  43. package/dist/dts/inMemory.d.ts +76 -0
  44. package/dist/dts/inMemory.d.ts.map +1 -0
  45. package/dist/dts/index.d.ts +35 -0
  46. package/dist/dts/index.d.ts.map +1 -0
  47. package/dist/dts/retryOnConflict.d.ts +70 -0
  48. package/dist/dts/retryOnConflict.d.ts.map +1 -0
  49. package/dist/dts/testing.d.ts +39 -0
  50. package/dist/dts/testing.d.ts.map +1 -0
  51. package/dist/esm/AppendCondition.js +19 -0
  52. package/dist/esm/AppendCondition.js.map +1 -0
  53. package/dist/esm/ContractStore.js +73 -0
  54. package/dist/esm/ContractStore.js.map +1 -0
  55. package/dist/esm/DcbEventStore.js +3 -0
  56. package/dist/esm/DcbEventStore.js.map +1 -0
  57. package/dist/esm/Event.js +81 -0
  58. package/dist/esm/Event.js.map +1 -0
  59. package/dist/esm/Projection.js +66 -0
  60. package/dist/esm/Projection.js.map +1 -0
  61. package/dist/esm/Query.js +209 -0
  62. package/dist/esm/Query.js.map +1 -0
  63. package/dist/esm/SubscribeMachine.js +227 -0
  64. package/dist/esm/SubscribeMachine.js.map +1 -0
  65. package/dist/esm/errors.js +14 -0
  66. package/dist/esm/errors.js.map +1 -0
  67. package/dist/esm/inMemory.js +184 -0
  68. package/dist/esm/inMemory.js.map +1 -0
  69. package/dist/esm/index.js +38 -0
  70. package/dist/esm/index.js.map +1 -0
  71. package/dist/esm/package.json +4 -0
  72. package/dist/esm/retryOnConflict.js +87 -0
  73. package/dist/esm/retryOnConflict.js.map +1 -0
  74. package/dist/esm/testing.js +46 -0
  75. package/dist/esm/testing.js.map +1 -0
  76. package/package.json +39 -0
  77. package/src/AppendCondition.ts +18 -0
  78. package/src/ContractStore.ts +148 -0
  79. package/src/DcbEventStore.ts +77 -0
  80. package/src/Event.ts +135 -0
  81. package/src/Projection.ts +168 -0
  82. package/src/Query.ts +237 -0
  83. package/src/SubscribeMachine.ts +316 -0
  84. package/src/errors.ts +19 -0
  85. package/src/inMemory.ts +356 -0
  86. package/src/index.ts +37 -0
  87. package/src/retryOnConflict.ts +100 -0
  88. package/src/testing.ts +47 -0
  89. package/testing/package.json +6 -0
@@ -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
+ }
@@ -0,0 +1,6 @@
1
+ {
2
+ "sideEffects": [],
3
+ "main": "../dist/cjs/testing.js",
4
+ "module": "../dist/esm/testing.js",
5
+ "types": "../dist/dts/testing.d.ts"
6
+ }