@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,227 @@
1
+ /**
2
+ * The subscribe machine every engine's `subscribe` is built from — the in-memory
3
+ * store, `@kairos-es/store-postgres` and `@kairos-es/store-sqlite`: the wake queue,
4
+ * the wake-source drain, the paged catch-up reconcile, the `> boundary` de-dup (via
5
+ * the cursor's exclusive lower bound) and the poll loop that together turn a paged
6
+ * read into a live subscription.
7
+ *
8
+ * It lives in core because delivery semantics are contract semantics. An engine
9
+ * supplies TRANSPORT through two STUBBABLE seams — `fetch`, a paged read strictly
10
+ * after a cursor, and `listen`, an optional wake source — and inherits the delivery
11
+ * behaviour whole, exactly as it supplies mechanics to `makeContractStore` and
12
+ * inherits the contract policy whole. Two engines that each grew their own wake
13
+ * loop would be identical only by inspection, and `subscribe` is the verb where
14
+ * that is least affordable: a divergence there loses events rather than failing.
15
+ * Both seams take plain in-memory values in a test, so the wake/poll/de-dup logic
16
+ * is exercisable with no live backend at all.
17
+ *
18
+ * All THREE are the callers, and the count matters to a reader here: it means there
19
+ * is exactly ONE delivery algorithm in this repo, and it is the code below. The
20
+ * in-memory store is a caller like the rest — it hands in a `fetch` over its log
21
+ * `Ref` and its append `PubSub` as a wake source and keeps no delivery logic of its
22
+ * own — which is what makes the shared contract suite's subscribe cases a comparison
23
+ * of two BACKENDS rather than of two delivery algorithms.
24
+ *
25
+ * Correctness rests on the periodic position-based reconcile — a re-read from the
26
+ * cursor every poll interval — and NEVER on the wake source, which only lowers
27
+ * delivery latency below that interval. A subscription with no wake source at all
28
+ * is therefore complete and correct, merely slower, which is why `listen` is
29
+ * optional in the TYPE rather than by the convention of passing `Stream.never`:
30
+ * poll-only delivery is a supported shape, not a degraded one.
31
+ *
32
+ * ## The precondition every engine inherits: ASCENDING COMMIT ORDER
33
+ *
34
+ * A position that is committed and VISIBLE must never be below one already
35
+ * delivered. Gaps are fine: the cursor is an ascending exclusive lower bound, so a
36
+ * number that is allocated and never committed, or never allocated at all, is
37
+ * simply a number the machine never sees. Out-of-order commits are not fine. If one
38
+ * writer allocates position 5 and another allocates 6, and 6 commits first, a poll
39
+ * landing between the two commits delivers 6 and advances the cursor past it — and
40
+ * 5 is then lost forever, because no later read looks below the cursor and nothing
41
+ * anywhere notices the hole. An engine whose concurrent writers can commit out of
42
+ * order MUST serialise them, or this machine will silently skip. An engine that
43
+ * takes an exclusive lock on both of its append paths discharges the obligation
44
+ * that way; an engine whose append is a single statement discharges it
45
+ * structurally.
46
+ *
47
+ * The obligation is written down HERE, in the algorithm that depends on it, rather
48
+ * than in whichever engine happens to satisfy it. It is a property of this delivery
49
+ * loop and of nothing else, so an engine author reading only their own source has
50
+ * no way to discover that anything downstream needs it — and the failure mode is
51
+ * the worst kind there is: no error, no defect, no failing test, just an event that
52
+ * a subscriber never receives. A single engine owning both its append path and its
53
+ * own wake loop can leave the assumption implicit; a shared machine cannot.
54
+ *
55
+ * ## `catchUpPageSize` bounds the READ, not the emission
56
+ *
57
+ * `drainFrom` below PAGES the catch-up read — `catchUpPageSize` bounds each
58
+ * `fetch` — but it COLLECTS every page into one array, which is emitted as a SINGLE
59
+ * chunk. So peak memory at subscription start is O(backlog) rather than O(page): a
60
+ * subscriber joining behind a large log materialises the whole backlog before its
61
+ * consumer sees the first element.
62
+ *
63
+ * That is a memory characteristic and nothing more, which is why ONE chunk is
64
+ * enough here: downstream commit boundaries do not depend on the upstream chunking
65
+ * at all, because the read side's `Stream.groupedWithin` re-chunks the stream into
66
+ * bounded micro-batches whatever arrives (ADR-0007), so a projection's transactions
67
+ * stay small even against a one-chunk catch-up. The cost therefore lands on backlog
68
+ * memory alone, and it lands hardest on the durable-log-with-in-memory-view mode,
69
+ * where an ephemeral view re-catches-up the entire log on every boot.
70
+ */
71
+ import { Chunk, Effect, Option, Queue, Schedule, Schema, Stream } from 'effect';
72
+ import { ORIGIN, ReadLimit } from "./Event.js";
73
+ /**
74
+ * A short catch-up page size default for the reconcile loop. Reads page by page
75
+ * until a short page signals the tail is drained, so a large backlog is streamed in
76
+ * bounded chunks rather than one unbounded read. Every engine takes its
77
+ * `catchUpPageSize` from here — each SQL engine as its config's default, the
78
+ * in-memory store as its options' — so backends cannot drift on tuning.
79
+ */
80
+ export const DEFAULT_CATCH_UP_PAGE_SIZE = 512;
81
+ /**
82
+ * The default upper bound a subscriber waits for a wake before re-reading from its
83
+ * cursor ANYWAY (milliseconds). Every engine takes its `pollInterval` from here —
84
+ * each SQL engine as its config's default, the in-memory store as its options' — so
85
+ * backends cannot drift on tuning.
86
+ *
87
+ * This periodic position-based poll — not the wake source — IS the correctness
88
+ * guarantee: every committed position is reconciled within this interval whether or
89
+ * not a wake for it was ever heard. That covers a wake fired before the source was
90
+ * fully established, a coalesced or dropped wake, and a wake source that died
91
+ * silently and never said so. A wake source only lowers delivery latency BELOW this
92
+ * interval; it never carries delivery, so no engine's wake mechanism can make the
93
+ * right figure here engine-specific. It trades worst-case delivery latency against
94
+ * idle read volume and nothing else. Upstream `postgres_tt.py` picks the same
95
+ * one-second fallback poll (`conn.wait(..., interval=1)`).
96
+ */
97
+ export const DEFAULT_POLL_INTERVAL_MILLIS = 1000;
98
+ /**
99
+ * The two subscribe-tuning config FIELDS, for an engine whose config is a `Schema`
100
+ * to spread into its own struct.
101
+ *
102
+ * These knobs are the machine's, not any engine's: they name what the loop above
103
+ * does, the figures they fall back to are the two constants declared beside them,
104
+ * and neither is derivable from a backend's own properties — which is why the
105
+ * defaults already live here. Shipping the FIELD rather than only the default is
106
+ * what closes the last gap in that arrangement. An engine that spreads these gets
107
+ * the key NAME, the VALIDATION and the DEFAULT from one place, so a caller who has
108
+ * tuned one engine's subscribe reads another's without translating, and the two
109
+ * cannot drift in what they ACCEPT any more than in what they assume. Two engines
110
+ * hand-authoring the identical `Schema.optionalWith(…)` twice were identical only
111
+ * by inspection.
112
+ *
113
+ * The bounds differ between the two because the quantities do. `pollInterval` is a
114
+ * duration, so a fraction of a millisecond is meaningful and only `positive` and
115
+ * `finite` matter: at zero or below the loop would spin without ever waiting for a
116
+ * wake, and at `Infinity` the periodic re-read — the half that carries delivery —
117
+ * would never come, which silently converts the wake source from an optimisation
118
+ * into the guarantee. `catchUpPageSize` is a COUNT, so it must be an integer
119
+ * (`Schema.Int` excludes `Infinity` with it): the reconcile loop compares a page's
120
+ * length against it to decide the tail is drained, and a page of zero would report
121
+ * "drained" forever without reading anything.
122
+ *
123
+ * `exact: true` on both keeps the decoded type at `key?: T` rather than
124
+ * `T | undefined`, matching the repo's `exactOptionalPropertyTypes` discipline — an
125
+ * absent key, never an explicit `undefined`.
126
+ */
127
+ export const subscriptionTuningFields = {
128
+ pollInterval: /*#__PURE__*/Schema.optionalWith(/*#__PURE__*/Schema.Number.pipe(/*#__PURE__*/Schema.positive(), /*#__PURE__*/Schema.finite()), {
129
+ exact: true
130
+ }),
131
+ catchUpPageSize: /*#__PURE__*/Schema.optionalWith(/*#__PURE__*/Schema.Int.pipe(/*#__PURE__*/Schema.positive()), {
132
+ exact: true
133
+ })
134
+ };
135
+ /**
136
+ * Retry a TRANSIENT poll fault (an `E` from one reconcile read) on a capped
137
+ * exponential backoff rather than letting `Stream.orDie` kill a subscription that
138
+ * may have been alive for days over a single connection blip. A fault that PERSISTS
139
+ * past the cap propagates (the decorator dies it) — infrastructure stays off the
140
+ * contract channel while the stream survives ordinary transients.
141
+ */
142
+ const POLL_RETRY_SCHEDULE = /*#__PURE__*/Schedule.exponential('100 millis', 2).pipe(/*#__PURE__*/Schedule.union(/*#__PURE__*/Schedule.spaced('2 seconds')), /*#__PURE__*/Schedule.intersect(/*#__PURE__*/Schedule.recurs(20)));
143
+ /**
144
+ * Build the subscription `Stream`: catch up from `after`, then track the live tail.
145
+ * The cursor threads through the `unfoldChunkEffect` unfold state, starting at
146
+ * `after` and advancing to the last delivered position each batch; the stream never
147
+ * completes on its own (always `Option.some`), so the enclosing scope interrupts it
148
+ * on close. An `E` mid-stream stays on the channel; `makeContractStore` dies it
149
+ * (subscribe is `E = never` on the contract).
150
+ */
151
+ export const subscribeStream = (deps, after) => Stream.unwrapScoped(Effect.gen(function* () {
152
+ // The wake primitive: a sliding(1) queue holding at most one pending
153
+ // signal. The wake source `offer`s an opaque wake on every notification;
154
+ // the poll loop `take`s (with a timeout). The lost-wakeup guard is
155
+ // STRUCTURAL — drain the queue BEFORE reading, so a wake that lands
156
+ // mid-read leaves a fresh signal that makes the next `take` return at once
157
+ // rather than sleeping through a real change.
158
+ const wake = yield* Queue.sliding(1);
159
+ // Tap every notification into the wake queue. If the wake source DOES
160
+ // surface an error, re-subscribe with capped-exponential backoff. Many wake
161
+ // sources do not error on a dropped connection at all — they simply go
162
+ // quiet — so this retry is NOT the reconnect guarantee: the poll fallback
163
+ // below is. The reconcile loop re-reads from the cursor on every poll, so a
164
+ // silently-dropped (and later re-established) wake source loses no
165
+ // positions. Absent a wake source entirely, `Stream.never` makes this a
166
+ // fibre that parks forever and delivery falls to the poll alone.
167
+ const listener = (deps.listen ?? Stream.never).pipe(Stream.tap(() => Queue.offer(wake, undefined)), Stream.retry(Schedule.exponential('50 millis').pipe(Schedule.union(Schedule.spaced('1 seconds')))));
168
+ // Drain the wake source into the wake queue on a background fibre; the
169
+ // scope releases it. We never read its elements directly — every
170
+ // notification is an opaque wake signal.
171
+ yield* listener.pipe(Stream.runDrain, Effect.forkScoped);
172
+ // Reconcile: page-read from `from` until a short page, advancing to the last
173
+ // delivered position. Retries a TRANSIENT poll fault on a capped schedule so
174
+ // a single connection blip does not kill a days-long subscription; a
175
+ // persistent fault propagates and dies.
176
+ const drainFrom = from => Effect.gen(function* () {
177
+ const collected = [];
178
+ let cursor = from;
179
+ // Loop pages until a short page signals the tail is drained.
180
+ while (true) {
181
+ const events = yield* deps.fetch(cursor, ReadLimit.make(deps.catchUpPageSize));
182
+ for (const event of events) {
183
+ collected.push(event);
184
+ }
185
+ const lastEvent = events.at(-1);
186
+ if (lastEvent === undefined || events.length < deps.catchUpPageSize) {
187
+ break;
188
+ }
189
+ cursor = lastEvent.position;
190
+ }
191
+ const last = collected.at(-1)?.position ?? from;
192
+ return {
193
+ events: collected,
194
+ last
195
+ };
196
+ }).pipe(Effect.retry(POLL_RETRY_SCHEDULE));
197
+ /**
198
+ * Produce the NEXT non-empty batch from a cursor position, together with
199
+ * the advanced cursor — a pure `Position → Effect<[Chunk, next]>`. The
200
+ * `> boundary` de-dup is enforced by `fetch`'s exclusive lower bound, so a
201
+ * live re-read after a wake never re-emits an already-delivered position.
202
+ *
203
+ * Drain the wake queue BEFORE reading (lost-wakeup guard), then drain from
204
+ * the cursor. If a drain yields nothing, wait for a wake but only up to the
205
+ * poll interval, then loop and re-read ANYWAY — the periodic position-based
206
+ * poll that makes delivery correct independently of any wake. This effect
207
+ * only completes by returning a non-empty chunk; the scope interrupts it on
208
+ * close.
209
+ */
210
+ const nextBatch = from => Effect.gen(function* () {
211
+ while (true) {
212
+ yield* Queue.takeAll(wake);
213
+ const {
214
+ events,
215
+ last
216
+ } = yield* drainFrom(from);
217
+ if (events.length > 0) {
218
+ return [Chunk.fromIterable(events), last];
219
+ }
220
+ // Nothing new: block on a wake, but only up to the poll interval — on
221
+ // timeout we fall through and the loop re-reads regardless.
222
+ yield* Effect.ignore(Effect.timeout(Queue.take(wake), deps.pollInterval));
223
+ }
224
+ });
225
+ return Stream.unfoldChunkEffect(after ?? ORIGIN, from => Effect.map(nextBatch(from), Option.some));
226
+ }));
227
+ //# sourceMappingURL=SubscribeMachine.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"SubscribeMachine.js","names":["Chunk","Effect","Option","Queue","Schedule","Schema","Stream","ORIGIN","ReadLimit","DEFAULT_CATCH_UP_PAGE_SIZE","DEFAULT_POLL_INTERVAL_MILLIS","subscriptionTuningFields","pollInterval","optionalWith","Number","pipe","positive","finite","exact","catchUpPageSize","Int","POLL_RETRY_SCHEDULE","exponential","union","spaced","intersect","recurs","subscribeStream","deps","after","unwrapScoped","gen","wake","sliding","listener","listen","never","tap","offer","undefined","retry","runDrain","forkScoped","drainFrom","from","collected","cursor","events","fetch","make","event","push","lastEvent","at","length","position","last","nextBatch","takeAll","fromIterable","ignore","timeout","take","unfoldChunkEffect","map","some"],"sources":["../../src/SubscribeMachine.ts"],"sourcesContent":[null],"mappings":"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","ignoreList":[]}
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Tagged errors for the store contract. Conflicts are values on the error
3
+ * channel, never defects.
4
+ */
5
+ import { Data } from 'effect';
6
+ /**
7
+ * Raised by `append` when the append condition's `failIfEventsMatch` query
8
+ * matches at least one event after `condition.after`. Carries only the
9
+ * offending condition: a correct retry re-reads the store to its new head and
10
+ * rebuilds its condition from there, so no conflicting position is carried (it
11
+ * would already be stale by the time it was acted on).
12
+ */
13
+ export class AppendConditionFailed extends /*#__PURE__*/Data.TaggedError('AppendConditionFailed') {}
14
+ //# sourceMappingURL=errors.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"errors.js","names":["Data","AppendConditionFailed","TaggedError"],"sources":["../../src/errors.ts"],"sourcesContent":[null],"mappings":"AAAA;;;;AAIA,SAASA,IAAI,QAAQ,QAAQ;AAG7B;;;;;;;AAOA,OAAM,MAAOC,qBAAsB,sBAAQD,IAAI,CAACE,WAAW,CACzD,uBAAuB,CAGvB","ignoreList":[]}
@@ -0,0 +1,184 @@
1
+ import { Effect, Layer, PubSub, Ref, Stream } from 'effect';
2
+ import { makeContractStore } from "./ContractStore.js";
3
+ import { DcbEventStore } from "./DcbEventStore.js";
4
+ import { ORIGIN } from "./Event.js";
5
+ import { AppendConditionFailed } from "./errors.js";
6
+ import { matchesSequenced } from "./Query.js";
7
+ import { DEFAULT_CATCH_UP_PAGE_SIZE, DEFAULT_POLL_INTERVAL_MILLIS, subscribeStream } from "./SubscribeMachine.js";
8
+ /** Filter a snapshot by `query` and `after` (strict `position > after`). */
9
+ const filterLog = (log, query, after) => log.filter(sequenced => (after === undefined || sequenced.position > after) && matchesSequenced(sequenced, query));
10
+ /** The position of the last event in a slice, or `undefined` when empty. */
11
+ const lastPosition = events => events.at(-1)?.position;
12
+ /**
13
+ * Compute the read result over a snapshot: filter, apply the limit, and derive
14
+ * `head` per the contract (no limit => global last position or ORIGIN; limit =>
15
+ * last returned position or ORIGIN).
16
+ */
17
+ const readSnapshot = (log, query, options) => {
18
+ const matched = filterLog(log, query, options?.after);
19
+ const limited = options?.limit === undefined ? matched : matched.slice(0, options.limit);
20
+ // No limit => head is the global last position (ignoring the filter). With a
21
+ // limit => head is the last returned event's position. Either way, ORIGIN
22
+ // when there is nothing to report.
23
+ const head = (options?.limit === undefined ? lastPosition(log) : lastPosition(limited)) ?? ORIGIN;
24
+ return {
25
+ events: Stream.fromIterable(limited),
26
+ head
27
+ };
28
+ };
29
+ /**
30
+ * The index of the first event strictly after `after`, by BINARY SEARCH.
31
+ *
32
+ * The log is append-only and ascending by position, so the cursor cut is a boundary
33
+ * in a sorted array and costs O(log n). That is what makes a live subscription's
34
+ * idle poll affordable at any log size: the poll re-reads from its cursor every
35
+ * interval whether or not anything arrived, so a linear cut would charge every open
36
+ * subscription O(n) per interval for as long as it stayed open.
37
+ */
38
+ const firstIndexAfter = (log, after) => {
39
+ let low = 0;
40
+ let high = log.length;
41
+ while (low < high) {
42
+ // `>>> 1` rather than a divide: an integer midpoint with no rounding step.
43
+ const mid = low + high >>> 1;
44
+ if (log[mid].position > after) {
45
+ high = mid;
46
+ } else {
47
+ low = mid + 1;
48
+ }
49
+ }
50
+ return low;
51
+ };
52
+ /**
53
+ * `subscribeStream`'s `fetch` seam: up to `limit` events matching `query` strictly
54
+ * after `from`, ascending.
55
+ *
56
+ * The cursor cut comes FIRST and the query match second, so the predicate only ever
57
+ * sees candidates the cursor admits, and the scan stops the moment `limit` of them
58
+ * have matched rather than filtering a tail it would discard.
59
+ *
60
+ * Deliberately not expressed through `filterLog`, and the reason is COST rather than
61
+ * the answer: that form returns the same events and scans the whole log per page.
62
+ *
63
+ * What is not interchangeable is what the cap COUNTS. The machine's paging contract
64
+ * reads a SHORT page as "the tail is drained", so the cap has to count MATCHES and a
65
+ * short page has to mean the scan ran off the end of the log. A cap counting
66
+ * CANDIDATES — `limit` events taken by POSITION, with the predicate applied within
67
+ * them — reports a short page for a window that merely held few matches; and a window
68
+ * holding NONE reports a drained tail while advancing the cursor nowhere, so the
69
+ * subscription is WEDGED rather than delayed, with no later poll able to move it.
70
+ * `InMemoryStoreOptions.catchUpPageSize` is what puts a test at that boundary.
71
+ */
72
+ const fetchPage = (log, query, from, limit) => {
73
+ const page = [];
74
+ for (let index = firstIndexAfter(log, from); index < log.length && page.length < limit; index += 1) {
75
+ const sequenced = log[index];
76
+ if (matchesSequenced(sequenced, query)) {
77
+ page.push(sequenced);
78
+ }
79
+ }
80
+ return page;
81
+ };
82
+ /**
83
+ * Build the in-memory store MECHANICS (a `RawDcbEventStore`), wiring the Ref,
84
+ * semaphore and hub. The contract-enforcement policy (`assertServableQuery`,
85
+ * `ReadLimit` decode, error-vs-defect classification) is NOT here — it is added
86
+ * uniformly by `makeContractStore`, so this engine is pure mechanics.
87
+ */
88
+ const make = options => Effect.gen(function* () {
89
+ const beforeCommit = options?.beforeCommit ?? Effect.void;
90
+ const suppressWake = options?.suppressWake ?? false;
91
+ const catchUpPageSize = options?.catchUpPageSize ?? DEFAULT_CATCH_UP_PAGE_SIZE;
92
+ const pollInterval = options?.pollInterval ?? DEFAULT_POLL_INTERVAL_MILLIS;
93
+ const log = yield* Ref.make([]);
94
+ const mutex = yield* Effect.makeSemaphore(1);
95
+ const hub = yield* PubSub.unbounded();
96
+ const nextPosition = yield* Ref.make(1n);
97
+ const read = (query, options) =>
98
+ // The lock keeps read consistent with concurrent appends; popo.py's RLock
99
+ // is reentrant, so append's inner read re-enters. Here append inlines the
100
+ // read logic instead, so a plain lock suffices.
101
+ mutex.withPermits(1)(Effect.map(Ref.get(log), snapshot => readSnapshot(snapshot, query, options)));
102
+ const append = (events, condition) =>
103
+ // Critical section: check + commit are atomic (the exactly-one-wins guarantee).
104
+ mutex.withPermits(1)(Effect.gen(function* () {
105
+ const snapshot = yield* Ref.get(log);
106
+ if (condition !== undefined) {
107
+ const conflicts = filterLog(snapshot, condition.failIfEventsMatch, condition.after);
108
+ // Detect by presence: any matching event after `condition.after`
109
+ // fails the append. The error carries only the condition (see
110
+ // `AppendConditionFailed`); a correct retry re-reads to the new head.
111
+ if (conflicts.length > 0) {
112
+ return yield* new AppendConditionFailed({
113
+ condition
114
+ });
115
+ }
116
+ }
117
+ // Optional test seam between the conflict-check and the commit (see
118
+ // `InMemoryStoreOptions.beforeCommit`). Defaults to `Effect.void`, a
119
+ // synchronous no-op, so the shipped critical section stays atomic by
120
+ // construction; the concurrency contract test injects `Effect.yieldNow()`
121
+ // here to make the exactly-one-wins guard genuinely depend on the mutex.
122
+ yield* beforeCommit;
123
+ const start = yield* Ref.get(nextPosition);
124
+ const appended = events.map((event, index) => ({
125
+ event,
126
+ position: start + BigInt(index)
127
+ }));
128
+ const end = start + BigInt(events.length);
129
+ yield* Ref.set(log, [...snapshot, ...appended]);
130
+ yield* Ref.set(nextPosition, end);
131
+ // The hub is a WAKE SOURCE and nothing else: the subscribe machine never
132
+ // reads what it carries, and its poll delivers with or without one. That
133
+ // is why suppressing the publish is a legitimate test seam rather than a
134
+ // broken store (see `InMemoryStoreOptions.suppressWake`).
135
+ if (!suppressWake) {
136
+ yield* PubSub.publishAll(hub, appended);
137
+ }
138
+ // `events` is non-empty, so the last new position is `end - 1`.
139
+ return end - 1n;
140
+ }));
141
+ const subscribe = (query, after) =>
142
+ // Take the hub seat HERE, before handing the machine its wake source, so the
143
+ // subscription is established no later than the machine's first `fetch`. That
144
+ // ordering is what keeps delivery independent of the poll's timer: an append
145
+ // either lands before that first read, and the read sees it, or after the seat
146
+ // exists, and its wake is queued. The machine drains a wake source on a forked
147
+ // fibre, so a seat taken inside that fibre would leave a window in which an
148
+ // append publishes to nobody — recoverable by the next poll, but only on a
149
+ // real clock, and much of this store's use is under `TestClock`.
150
+ //
151
+ // No mutex: `Ref.get` already reads one committed snapshot, and the machine's
152
+ // exclusive cursor is what makes the catch-up/live seam exactly-once, so there
153
+ // is nothing left here for a critical section to protect. `append` keeps its
154
+ // permit, which is the whole exactly-one-wins guarantee.
155
+ Stream.unwrapScoped(Effect.gen(function* () {
156
+ const wakes = yield* PubSub.subscribe(hub);
157
+ return subscribeStream({
158
+ fetch: (from, limit) => Effect.map(Ref.get(log), snapshot => fetchPage(snapshot, query, from, limit)),
159
+ listen: Stream.fromQueue(wakes),
160
+ pollInterval,
161
+ catchUpPageSize
162
+ }, after);
163
+ }));
164
+ return {
165
+ read,
166
+ append,
167
+ subscribe
168
+ };
169
+ });
170
+ /** The in-memory `DcbEventStore` layer (production; no test seam). */
171
+ export const DcbEventStoreInMemory = /*#__PURE__*/Layer.effect(DcbEventStore, /*#__PURE__*/Effect.map(/*#__PURE__*/make(), makeContractStore));
172
+ /**
173
+ * Test-only in-memory layer factory: the same store as `DcbEventStoreInMemory`, but
174
+ * with `InMemoryStoreOptions`' seams open. Kept off the package's public surface
175
+ * (deliberately not re-exported from `index.ts`); production code always uses
176
+ * `DcbEventStoreInMemory`. Every seam exists to make a claim about this store
177
+ * FALSIFIABLE rather than merely commented — the store contract suite forces the
178
+ * check/commit interleaving the exactly-one-wins case needs, one core test takes the
179
+ * wake away and shortens the poll so that what delivers can only be the poll, and
180
+ * another lowers the catch-up page to reach the second-page boundary the shipped page
181
+ * size puts out of reach.
182
+ */
183
+ export const makeInMemoryTestLayer = options => Layer.effect(DcbEventStore, Effect.map(make(options), makeContractStore));
184
+ //# sourceMappingURL=inMemory.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"inMemory.js","names":["Effect","Layer","PubSub","Ref","Stream","makeContractStore","DcbEventStore","ORIGIN","AppendConditionFailed","matchesSequenced","DEFAULT_CATCH_UP_PAGE_SIZE","DEFAULT_POLL_INTERVAL_MILLIS","subscribeStream","filterLog","log","query","after","filter","sequenced","undefined","position","lastPosition","events","at","readSnapshot","options","matched","limited","limit","slice","head","fromIterable","firstIndexAfter","low","high","length","mid","fetchPage","from","page","index","push","make","gen","beforeCommit","void","suppressWake","catchUpPageSize","pollInterval","mutex","makeSemaphore","hub","unbounded","nextPosition","read","withPermits","map","get","snapshot","append","condition","conflicts","failIfEventsMatch","start","appended","event","BigInt","end","set","publishAll","subscribe","unwrapScoped","wakes","fetch","listen","fromQueue","DcbEventStoreInMemory","effect","makeInMemoryTestLayer"],"sources":["../../src/inMemory.ts"],"sourcesContent":[null],"mappings":"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","ignoreList":[]}
@@ -0,0 +1,38 @@
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.js";
26
+ export * from "./ContractStore.js";
27
+ export * from "./DcbEventStore.js";
28
+ export * from "./Event.js";
29
+ export * from "./errors.js";
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.js";
34
+ export * from "./Projection.js";
35
+ export * from "./Query.js";
36
+ export * from "./retryOnConflict.js";
37
+ export * from "./SubscribeMachine.js";
38
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","names":["DcbEventStoreInMemory"],"sources":["../../src/index.ts"],"sourcesContent":[null],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;AAwBA,cAAc,sBAAmB;AACjC,cAAc,oBAAiB;AAC/B,cAAc,oBAAiB;AAC/B,cAAc,YAAS;AACvB,cAAc,aAAU;AACxB;AACA;AACA;AACA,SAASA,qBAAqB,QAAQ,eAAY;AAClD,cAAc,iBAAc;AAC5B,cAAc,YAAS;AACvB,cAAc,sBAAmB;AACjC,cAAc,uBAAoB","ignoreList":[]}
@@ -0,0 +1,4 @@
1
+ {
2
+ "type": "module",
3
+ "sideEffects": []
4
+ }
@@ -0,0 +1,87 @@
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
+ /**
47
+ * The default conflict backoff: exponential from 20ms, jittered, capped at five
48
+ * retries.
49
+ *
50
+ * `Schedule.intersect` continues only while BOTH schedules continue, so the
51
+ * `recurs(5)` arm is what bounds the attempts and the exponential arm is what
52
+ * spaces them. Jitter matters more here than in a typical backoff: contending
53
+ * deciders conflict precisely because they read the same boundary at the same
54
+ * time, so an unjittered backoff would re-synchronise them into the same conflict
55
+ * on every round. The figures are the conventional starting point (Emmett,
56
+ * Kurrent and Dudycz all land in this neighbourhood) and are a DEFAULT — a
57
+ * command with a tighter latency budget, or one contending harder, should pass its
58
+ * own schedule.
59
+ */
60
+ const conflictBackoff = /*#__PURE__*/Schedule.intersect(/*#__PURE__*/Schedule.jittered(/*#__PURE__*/Schedule.exponential('20 millis', 2)), /*#__PURE__*/Schedule.recurs(5));
61
+ /**
62
+ * Retry `command` while — and only while — it fails `AppendConditionFailed`.
63
+ *
64
+ * `command` must be the WHOLE read-decide-append cycle (read a decision model to
65
+ * the current head, decide, append under that read's condition), because each
66
+ * retry's correctness comes from the re-read: the fresh read re-derives the
67
+ * append condition's `after` from the new head, so the retried append is judged
68
+ * against the boundary the retried decision was actually made under. Wrapping only
69
+ * the `append` re-submits a stale condition and is wrong.
70
+ *
71
+ * A non-conflict `E` is preserved on the error channel and escapes IMMEDIATELY
72
+ * with zero retries: the `while` predicate is false for it, so a domain rejection
73
+ * ("course is full") never pays a backoff and never retries into a decision the
74
+ * log has already refused. `A`, the non-conflict `E`, and `R` all pass through
75
+ * untouched.
76
+ *
77
+ * The predicate matches on the `_tag` rather than `instanceof`. The tag is the
78
+ * error's identity across module realms, and a published package ships dual
79
+ * ESM/CJS, so an `instanceof` check can see two distinct classes for the same
80
+ * error when a consumer's graph loads both builds — a conflict would then escape
81
+ * unretried and silently.
82
+ */
83
+ export const retryOnConflict = (command, schedule = conflictBackoff) => Effect.retry(command, {
84
+ schedule,
85
+ while: Predicate.isTagged('AppendConditionFailed')
86
+ });
87
+ //# sourceMappingURL=retryOnConflict.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"retryOnConflict.js","names":["Effect","Predicate","Schedule","conflictBackoff","intersect","jittered","exponential","recurs","retryOnConflict","command","schedule","retry","while","isTagged"],"sources":["../../src/retryOnConflict.ts"],"sourcesContent":[null],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA4CA,SAASA,MAAM,EAAEC,SAAS,EAAEC,QAAQ,QAAQ,QAAQ;AAGpD;;;;;;;;;;;;;;AAcA,MAAMC,eAAe,gBAAGD,QAAQ,CAACE,SAAS,cACxCF,QAAQ,CAACG,QAAQ,cAACH,QAAQ,CAACI,WAAW,CAAC,WAAW,EAAE,CAAC,CAAC,CAAC,eACvDJ,QAAQ,CAACK,MAAM,CAAC,CAAC,CAAC,CACnB;AAED;;;;;;;;;;;;;;;;;;;;;;AAsBA,OAAO,MAAMC,eAAe,GAAGA,CAC7BC,OAAuD,EACvDC,QAAA,GAIIP,eAAe,KAEnBH,MAAM,CAACW,KAAK,CAACF,OAAO,EAAE;EACpBC,QAAQ;EACRE,KAAK,EAAEX,SAAS,CAACY,QAAQ,CAAC,uBAAuB;CAClD,CAAC","ignoreList":[]}
@@ -0,0 +1,46 @@
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
+ * A fixed domain time (`DateTime` at the Unix epoch) for test events.
20
+ *
21
+ * Ordering in the log is by `position` alone, so an event's `occurredAt` is
22
+ * informational; pinning it to the epoch keeps serialised bytes and comparisons
23
+ * stable across runs. Built via `DateTime.unsafeMake(0)` (the value is a
24
+ * compile-time constant, not read from the host clock).
25
+ */
26
+ export const EPOCH = /*#__PURE__*/DateTime.unsafeMake(0);
27
+ /**
28
+ * Build a fresh, deterministic uuid generator: a zero-based counter producing
29
+ * RFC-4122 v4-shaped ids `00000000-0000-4000-8000-<counter as 12 hex digits>`,
30
+ * starting at `...000000000000` and incrementing on each call.
31
+ *
32
+ * A *factory* (not a shared singleton) so each layer/suite instance owns its own
33
+ * counter and the sequence is repeatable in isolation — two independent suites
34
+ * do not interfere, yet each is stable on its own. No RNG: the ids are
35
+ * comparable and predictable, which is what assertions need, while still being
36
+ * distinct enough to tell events apart.
37
+ */
38
+ export const makeSequentialUuid = () => {
39
+ let counter = 0;
40
+ return () => {
41
+ const suffix = counter.toString(16).padStart(12, '0');
42
+ counter += 1;
43
+ return `00000000-0000-4000-8000-${suffix}`;
44
+ };
45
+ };
46
+ //# sourceMappingURL=testing.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"testing.js","names":["DateTime","EPOCH","unsafeMake","makeSequentialUuid","counter","suffix","toString","padStart"],"sources":["../../src/testing.ts"],"sourcesContent":[null],"mappings":"AAAA;;;;;;;;;;;;;;;;AAgBA,SAASA,QAAQ,QAAQ,QAAQ;AAEjC;;;;;;;;AAQA,OAAO,MAAMC,KAAK,gBAAGD,QAAQ,CAACE,UAAU,CAAC,CAAC,CAAC;AAE3C;;;;;;;;;;;AAWA,OAAO,MAAMC,kBAAkB,GAAGA,CAAA,KAAqB;EACrD,IAAIC,OAAO,GAAG,CAAC;EACf,OAAO,MAAK;IACV,MAAMC,MAAM,GAAGD,OAAO,CAACE,QAAQ,CAAC,EAAE,CAAC,CAACC,QAAQ,CAAC,EAAE,EAAE,GAAG,CAAC;IACrDH,OAAO,IAAI,CAAC;IACZ,OAAO,2BAA2BC,MAAM,EAAE;EAC5C,CAAC;AACH,CAAC","ignoreList":[]}