@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
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The DCB selection language: `Query` is an OR of `QueryItem`s. Within an item,
|
|
3
|
+
* `types` is an OR-filter (empty means any type) and `tags` is an AND superset
|
|
4
|
+
* test (the event must carry all of the item's tags; extra event tags are
|
|
5
|
+
* fine). The empty query (`items: []`) matches every event.
|
|
6
|
+
*
|
|
7
|
+
* The match predicate is a single pure module function over plain data;
|
|
8
|
+
* `Query` values carry no methods. See `matchesQuery` / `matchesQueryItem`.
|
|
9
|
+
*
|
|
10
|
+
* Smart constructors enforce the indexed fast-path restriction at the
|
|
11
|
+
* constructor surface (not via a runtime throw): every query built through them
|
|
12
|
+
* is the wildcard (`query.all`), a single type-only item (`query.types`), or an
|
|
13
|
+
* OR of tagged items each carrying at least one tag (`query.item` + `query.or`,
|
|
14
|
+
* or `query.tags` for the single-item case). The `query.item` constructor
|
|
15
|
+
* requires a non-empty `tags` at the type level, so there is no public way to
|
|
16
|
+
* build an unrestricted item (empty types and empty tags). `QueryItem`/`Query`
|
|
17
|
+
* are plain data, so internal decision-model plumbing can still assemble any
|
|
18
|
+
* shape directly; the restriction binds the public surface, where consumers
|
|
19
|
+
* build their consistency boundaries.
|
|
20
|
+
*
|
|
21
|
+
* The grammar's DISPATCH lives here too, beside the grammar it dispatches over:
|
|
22
|
+
* `ServableQueryShape` / `classifyServableQuery` own the three shapes an engine
|
|
23
|
+
* serves and the routing of the degenerate empty item.
|
|
24
|
+
*/
|
|
25
|
+
import { Array as Arr, Schema } from 'effect';
|
|
26
|
+
import { type DcbEvent, EventType, type SequencedEvent, Tag } from './Event.js';
|
|
27
|
+
/**
|
|
28
|
+
* One clause of a query: a set of types (OR, empty = any) AND a set of tags
|
|
29
|
+
* (superset test, empty = no tag constraint).
|
|
30
|
+
*/
|
|
31
|
+
export declare const QueryItem: Schema.Struct<{
|
|
32
|
+
types: Schema.Array$<Schema.brand<Schema.filter<typeof Schema.String>, "EventType">>;
|
|
33
|
+
tags: Schema.Array$<Schema.brand<Schema.filter<typeof Schema.String>, "Tag">>;
|
|
34
|
+
}>;
|
|
35
|
+
export type QueryItem = typeof QueryItem.Type;
|
|
36
|
+
/** A query: an OR of items. Empty `items` matches everything. */
|
|
37
|
+
export declare const Query: Schema.Struct<{
|
|
38
|
+
items: Schema.Array$<Schema.Struct<{
|
|
39
|
+
types: Schema.Array$<Schema.brand<Schema.filter<typeof Schema.String>, "EventType">>;
|
|
40
|
+
tags: Schema.Array$<Schema.brand<Schema.filter<typeof Schema.String>, "Tag">>;
|
|
41
|
+
}>>;
|
|
42
|
+
}>;
|
|
43
|
+
export type Query = typeof Query.Type;
|
|
44
|
+
/**
|
|
45
|
+
* True when `event` satisfies `item`: its type is in `item.types` (or the item
|
|
46
|
+
* has no type constraint) AND the event's tags are a superset of `item.tags`.
|
|
47
|
+
*/
|
|
48
|
+
export declare const matchesQueryItem: (event: DcbEvent, item: QueryItem) => boolean;
|
|
49
|
+
/**
|
|
50
|
+
* The one true match predicate (ported from `popo.py:48-54`): an empty query is
|
|
51
|
+
* a wildcard; otherwise the event must match at least one item (OR across
|
|
52
|
+
* items).
|
|
53
|
+
*/
|
|
54
|
+
export declare const matchesQuery: (event: DcbEvent, query: Query) => boolean;
|
|
55
|
+
/** Convenience predicate over a `SequencedEvent`. */
|
|
56
|
+
export declare const matchesSequenced: (sequenced: SequencedEvent, query: Query) => boolean;
|
|
57
|
+
/**
|
|
58
|
+
* Assert `query` is *servable* by the indexed fast paths, throwing a plain
|
|
59
|
+
* `Error` describing the violation otherwise. This enforces at the store
|
|
60
|
+
* boundary the restriction the smart constructors enforce at the type level
|
|
61
|
+
* (`Query.ts` module docs) but which plain-data assembly — e.g. decision-model
|
|
62
|
+
* composition, `Projection.ts:31-36` — can still bypass.
|
|
63
|
+
*
|
|
64
|
+
* The servable grammar is exactly one of:
|
|
65
|
+
* - the wildcard (`items.length === 0`), which matches every event;
|
|
66
|
+
* - a *single* item (`items.length === 1`), any types/tags, including a
|
|
67
|
+
* type-only item; or
|
|
68
|
+
* - a *multi*-item query (`items.length > 1`) in which every item carries at
|
|
69
|
+
* least one tag.
|
|
70
|
+
*
|
|
71
|
+
* A multi-item query that mixes a tagless (type-only) item with tagged items is
|
|
72
|
+
* NOT servable: the by-type path and the by-tag path are indexed differently,
|
|
73
|
+
* so an OR spanning both cannot ride a single index. This is the previously
|
|
74
|
+
* un-enforced edge (`Projection.ts:31-36`).
|
|
75
|
+
*
|
|
76
|
+
* This function is pure (no clock, no RNG, no effect). Every engine applies it at
|
|
77
|
+
* its read/subscribe/append entry points, through the one shared
|
|
78
|
+
* `makeContractStore` decorator, so the rule is inherited rather than restated per
|
|
79
|
+
* backend; a throw inside a store `Effect` becomes a *defect* — a programming
|
|
80
|
+
* error, not an error-channel value — which is the intended "defect in every
|
|
81
|
+
* engine" behaviour.
|
|
82
|
+
*/
|
|
83
|
+
export declare const assertServableQuery: (query: Query) => void;
|
|
84
|
+
/**
|
|
85
|
+
* The three shapes a servable query is SERVED by, and therefore the only three an
|
|
86
|
+
* engine's plan compiler dispatches over: the wildcard, a single type-only item,
|
|
87
|
+
* and an all-tagged query. `assertServableQuery` has already refused the mixed
|
|
88
|
+
* tagless-plus-tagged multi-item query, so these three are exhaustive over
|
|
89
|
+
* everything that reaches a store.
|
|
90
|
+
*
|
|
91
|
+
* `typeOnly` carries a NON-EMPTY list of types, and that is the whole guard
|
|
92
|
+
* against the degenerate empty item: see `classifyServableQuery`.
|
|
93
|
+
*/
|
|
94
|
+
export type ServableQueryShape = {
|
|
95
|
+
readonly kind: 'wildcard';
|
|
96
|
+
} | {
|
|
97
|
+
readonly kind: 'typeOnly';
|
|
98
|
+
readonly types: Arr.NonEmptyReadonlyArray<EventType>;
|
|
99
|
+
} | {
|
|
100
|
+
readonly kind: 'tagged';
|
|
101
|
+
};
|
|
102
|
+
/**
|
|
103
|
+
* Classify a servable `Query` into the shape that serves it.
|
|
104
|
+
*
|
|
105
|
+
* ## Why the dispatch lives beside the grammar
|
|
106
|
+
*
|
|
107
|
+
* The three shapes ARE the three bullets `assertServableQuery` above admits, so an
|
|
108
|
+
* engine that re-derives the dispatch is a second answer to "which shape is
|
|
109
|
+
* this?", and the shapes only stay in step while there is one answer. Core states
|
|
110
|
+
* the grammar, so stating the dispatch over it here is that one fact once rather
|
|
111
|
+
* than once per backend. It is pure — no clock, no RNG, no effect — and knows
|
|
112
|
+
* nothing of SQL: what a shape COMPILES to stays entirely the engine's.
|
|
113
|
+
*
|
|
114
|
+
* ## The degenerate empty item routes to the WILDCARD, structurally
|
|
115
|
+
*
|
|
116
|
+
* A single item with neither types nor tags — `{ items: [{ types: [], tags: [] }] }`
|
|
117
|
+
* — matches EVERY event: `matchesQueryItem` reads empty types as "any type" and
|
|
118
|
+
* empty tags as a vacuous superset test. It must therefore be served by the
|
|
119
|
+
* wildcard. Served by the type-only path it would compile to a value list with
|
|
120
|
+
* nothing in it, and a membership test against an empty list matches NOTHING — the
|
|
121
|
+
* exact inversion of what the query means. Nothing upstream stops it reaching an
|
|
122
|
+
* engine: `assertServableQuery` admits a SINGLE item of any shape, and the servable
|
|
123
|
+
* grammar forbids a tagless item only in a MULTI-item query, so this dispatch is
|
|
124
|
+
* the last place the divergence can be closed.
|
|
125
|
+
*
|
|
126
|
+
* It is closed by the SHAPE and not by a branch in front of it. `typeOnly` carries
|
|
127
|
+
* `NonEmptyReadonlyArray`, so there is no way to construct it from an empty list;
|
|
128
|
+
* the emptiness test IS the constructor, and deleting it does not silently restore
|
|
129
|
+
* the wrong routing, it stops compiling. A guard clause placed before the
|
|
130
|
+
* construction would have been deletable by anyone tidying what looks like a
|
|
131
|
+
* redundant check on a list that "obviously" has types in it.
|
|
132
|
+
*/
|
|
133
|
+
export declare const classifyServableQuery: (query: Query) => ServableQueryShape;
|
|
134
|
+
/** Namespaced smart constructors for `Query`. */
|
|
135
|
+
export declare const query: {
|
|
136
|
+
readonly all: () => Query;
|
|
137
|
+
readonly item: (fields: {
|
|
138
|
+
readonly tags: readonly [Tag, ...ReadonlyArray<Tag>];
|
|
139
|
+
readonly types?: ReadonlyArray<EventType>;
|
|
140
|
+
}) => QueryItem;
|
|
141
|
+
readonly or: (items: readonly [QueryItem, ...ReadonlyArray<QueryItem>]) => Query;
|
|
142
|
+
readonly tags: (tags: readonly [Tag, ...ReadonlyArray<Tag>], types?: ReadonlyArray<EventType>) => Query;
|
|
143
|
+
readonly types: (types: readonly [EventType, ...ReadonlyArray<EventType>]) => Query;
|
|
144
|
+
};
|
|
145
|
+
//# sourceMappingURL=Query.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"Query.d.ts","sourceRoot":"","sources":["../../src/Query.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AACH,OAAO,EAAE,KAAK,IAAI,GAAG,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAA;AAC7C,OAAO,EAAE,KAAK,QAAQ,EAAE,SAAS,EAAE,KAAK,cAAc,EAAE,GAAG,EAAE,MAAM,SAAS,CAAA;AAE5E;;;GAGG;AACH,eAAO,MAAM,SAAS;;;EAGpB,CAAA;AACF,MAAM,MAAM,SAAS,GAAG,OAAO,SAAS,CAAC,IAAI,CAAA;AAE7C,iEAAiE;AACjE,eAAO,MAAM,KAAK;;;;;EAEhB,CAAA;AACF,MAAM,MAAM,KAAK,GAAG,OAAO,KAAK,CAAC,IAAI,CAAA;AAErC;;;GAGG;AACH,eAAO,MAAM,gBAAgB,GAAI,OAAO,QAAQ,EAAE,MAAM,SAAS,KAAG,OAOnE,CAAA;AAED;;;;GAIG;AACH,eAAO,MAAM,YAAY,GAAI,OAAO,QAAQ,EAAE,OAAO,KAAK,KAAG,OAK5D,CAAA;AAED,qDAAqD;AACrD,eAAO,MAAM,gBAAgB,GAC3B,WAAW,cAAc,EACzB,OAAO,KAAK,KACX,OAA+C,CAAA;AAElD;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AACH,eAAO,MAAM,mBAAmB,GAAI,OAAO,KAAK,KAAG,IAelD,CAAA;AAED;;;;;;;;;GASG;AACH,MAAM,MAAM,kBAAkB,GAC1B;IAAE,QAAQ,CAAC,IAAI,EAAE,UAAU,CAAA;CAAE,GAC7B;IACE,QAAQ,CAAC,IAAI,EAAE,UAAU,CAAA;IACzB,QAAQ,CAAC,KAAK,EAAE,GAAG,CAAC,qBAAqB,CAAC,SAAS,CAAC,CAAA;CACrD,GACD;IAAE,QAAQ,CAAC,IAAI,EAAE,QAAQ,CAAA;CAAE,CAAA;AAE/B;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA8BG;AACH,eAAO,MAAM,qBAAqB,GAAI,OAAO,KAAK,KAAG,kBAWpD,CAAA;AAoDD,iDAAiD;AACjD,eAAO,MAAM,KAAK;wBAlCF,KAAK;4BATC;QACpB,QAAQ,CAAC,IAAI,EAAE,SAAS,CAAC,GAAG,EAAE,GAAG,aAAa,CAAC,GAAG,CAAC,CAAC,CAAA;QACpD,QAAQ,CAAC,KAAK,CAAC,EAAE,aAAa,CAAC,SAAS,CAAC,CAAA;KAC1C,KAAG,SAAS;yBAkCJ,SAAS,CAAC,SAAS,EAAE,GAAG,aAAa,CAAC,SAAS,CAAC,CAAC,KACvD,KAAK;0BAtBA,SAAS,CAAC,GAAG,EAAE,GAAG,aAAa,CAAC,GAAG,CAAC,CAAC,UACnC,aAAa,CAAC,SAAS,CAAC,KAC/B,KAAK;4BAUC,SAAS,CAAC,SAAS,EAAE,GAAG,aAAa,CAAC,SAAS,CAAC,CAAC,KACvD,KAAK;CAoBE,CAAA"}
|
|
@@ -0,0 +1,179 @@
|
|
|
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 { Effect, Schema, type Scope, Stream } from 'effect';
|
|
72
|
+
import type { Position, SequencedEvent } from './Event.js';
|
|
73
|
+
import { ReadLimit } from './Event.js';
|
|
74
|
+
/**
|
|
75
|
+
* A short catch-up page size default for the reconcile loop. Reads page by page
|
|
76
|
+
* until a short page signals the tail is drained, so a large backlog is streamed in
|
|
77
|
+
* bounded chunks rather than one unbounded read. Every engine takes its
|
|
78
|
+
* `catchUpPageSize` from here — each SQL engine as its config's default, the
|
|
79
|
+
* in-memory store as its options' — so backends cannot drift on tuning.
|
|
80
|
+
*/
|
|
81
|
+
export declare const DEFAULT_CATCH_UP_PAGE_SIZE = 512;
|
|
82
|
+
/**
|
|
83
|
+
* The default upper bound a subscriber waits for a wake before re-reading from its
|
|
84
|
+
* cursor ANYWAY (milliseconds). Every engine takes its `pollInterval` from here —
|
|
85
|
+
* each SQL engine as its config's default, the in-memory store as its options' — so
|
|
86
|
+
* backends cannot drift on tuning.
|
|
87
|
+
*
|
|
88
|
+
* This periodic position-based poll — not the wake source — IS the correctness
|
|
89
|
+
* guarantee: every committed position is reconciled within this interval whether or
|
|
90
|
+
* not a wake for it was ever heard. That covers a wake fired before the source was
|
|
91
|
+
* fully established, a coalesced or dropped wake, and a wake source that died
|
|
92
|
+
* silently and never said so. A wake source only lowers delivery latency BELOW this
|
|
93
|
+
* interval; it never carries delivery, so no engine's wake mechanism can make the
|
|
94
|
+
* right figure here engine-specific. It trades worst-case delivery latency against
|
|
95
|
+
* idle read volume and nothing else. Upstream `postgres_tt.py` picks the same
|
|
96
|
+
* one-second fallback poll (`conn.wait(..., interval=1)`).
|
|
97
|
+
*/
|
|
98
|
+
export declare const DEFAULT_POLL_INTERVAL_MILLIS = 1000;
|
|
99
|
+
/**
|
|
100
|
+
* The two subscribe-tuning config FIELDS, for an engine whose config is a `Schema`
|
|
101
|
+
* to spread into its own struct.
|
|
102
|
+
*
|
|
103
|
+
* These knobs are the machine's, not any engine's: they name what the loop above
|
|
104
|
+
* does, the figures they fall back to are the two constants declared beside them,
|
|
105
|
+
* and neither is derivable from a backend's own properties — which is why the
|
|
106
|
+
* defaults already live here. Shipping the FIELD rather than only the default is
|
|
107
|
+
* what closes the last gap in that arrangement. An engine that spreads these gets
|
|
108
|
+
* the key NAME, the VALIDATION and the DEFAULT from one place, so a caller who has
|
|
109
|
+
* tuned one engine's subscribe reads another's without translating, and the two
|
|
110
|
+
* cannot drift in what they ACCEPT any more than in what they assume. Two engines
|
|
111
|
+
* hand-authoring the identical `Schema.optionalWith(…)` twice were identical only
|
|
112
|
+
* by inspection.
|
|
113
|
+
*
|
|
114
|
+
* The bounds differ between the two because the quantities do. `pollInterval` is a
|
|
115
|
+
* duration, so a fraction of a millisecond is meaningful and only `positive` and
|
|
116
|
+
* `finite` matter: at zero or below the loop would spin without ever waiting for a
|
|
117
|
+
* wake, and at `Infinity` the periodic re-read — the half that carries delivery —
|
|
118
|
+
* would never come, which silently converts the wake source from an optimisation
|
|
119
|
+
* into the guarantee. `catchUpPageSize` is a COUNT, so it must be an integer
|
|
120
|
+
* (`Schema.Int` excludes `Infinity` with it): the reconcile loop compares a page's
|
|
121
|
+
* length against it to decide the tail is drained, and a page of zero would report
|
|
122
|
+
* "drained" forever without reading anything.
|
|
123
|
+
*
|
|
124
|
+
* `exact: true` on both keeps the decoded type at `key?: T` rather than
|
|
125
|
+
* `T | undefined`, matching the repo's `exactOptionalPropertyTypes` discipline — an
|
|
126
|
+
* absent key, never an explicit `undefined`.
|
|
127
|
+
*/
|
|
128
|
+
export declare const subscriptionTuningFields: {
|
|
129
|
+
readonly pollInterval: Schema.optionalWith<Schema.filter<Schema.filter<typeof Schema.Number>>, {
|
|
130
|
+
exact: true;
|
|
131
|
+
}>;
|
|
132
|
+
readonly catchUpPageSize: Schema.optionalWith<Schema.filter<typeof Schema.Int>, {
|
|
133
|
+
exact: true;
|
|
134
|
+
}>;
|
|
135
|
+
};
|
|
136
|
+
/**
|
|
137
|
+
* The two seams the subscribe machine runs over, plus the two tuning knobs. `E` is
|
|
138
|
+
* the engine's own infrastructure error — whatever its reads fail with — and it
|
|
139
|
+
* stays on the channel through to `makeContractStore`, which dies it.
|
|
140
|
+
*
|
|
141
|
+
* ONE type parameter and not two, deliberately: a wake source with an error type of
|
|
142
|
+
* its own is `Stream.orDie`d by the caller, yielding a `Stream<unknown, never>`
|
|
143
|
+
* that is assignable here under Effect v3's covariant error channel. A second
|
|
144
|
+
* parameter would buy nothing and would have to be spelled at every call site. Nor
|
|
145
|
+
* is there an `R`: neither seam contributes a requirement, and unused generality is
|
|
146
|
+
* not free.
|
|
147
|
+
*/
|
|
148
|
+
export interface SubscriptionDeps<E> {
|
|
149
|
+
/**
|
|
150
|
+
* Page-read the events strictly after `from`, up to `limit`, ascending by
|
|
151
|
+
* position. Returning FEWER than `limit` signals the tail is drained.
|
|
152
|
+
*/
|
|
153
|
+
readonly fetch: (from: Position, limit: ReadLimit) => Effect.Effect<ReadonlyArray<SequencedEvent>, E>;
|
|
154
|
+
/**
|
|
155
|
+
* The raw wake source: each element is an OPAQUE wake signal. The machine taps it
|
|
156
|
+
* into its own wake queue and drains it on a background fibre; it never reads the
|
|
157
|
+
* elements, so anything an engine can push on a change will do.
|
|
158
|
+
*
|
|
159
|
+
* OPTIONAL, and omitting it defaults to `Stream.never` at the point of use — an
|
|
160
|
+
* engine with no wake mechanism gets poll-only delivery, which the poll loop
|
|
161
|
+
* already makes correct. Under `exactOptionalPropertyTypes` that means omitting
|
|
162
|
+
* the key entirely rather than passing `undefined`.
|
|
163
|
+
*/
|
|
164
|
+
readonly listen?: Stream.Stream<unknown, E>;
|
|
165
|
+
/** The poll interval in milliseconds (see `DEFAULT_POLL_INTERVAL_MILLIS`). */
|
|
166
|
+
readonly pollInterval: number;
|
|
167
|
+
/** The catch-up page size (see `DEFAULT_CATCH_UP_PAGE_SIZE`). */
|
|
168
|
+
readonly catchUpPageSize: number;
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* Build the subscription `Stream`: catch up from `after`, then track the live tail.
|
|
172
|
+
* The cursor threads through the `unfoldChunkEffect` unfold state, starting at
|
|
173
|
+
* `after` and advancing to the last delivered position each batch; the stream never
|
|
174
|
+
* completes on its own (always `Option.some`), so the enclosing scope interrupts it
|
|
175
|
+
* on close. An `E` mid-stream stays on the channel; `makeContractStore` dies it
|
|
176
|
+
* (subscribe is `E = never` on the contract).
|
|
177
|
+
*/
|
|
178
|
+
export declare const subscribeStream: <E>(deps: SubscriptionDeps<E>, after: Position | undefined) => Stream.Stream<SequencedEvent, E, Scope.Scope>;
|
|
179
|
+
//# sourceMappingURL=SubscribeMachine.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"SubscribeMachine.d.ts","sourceRoot":"","sources":["../../src/SubscribeMachine.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAqEG;AACH,OAAO,EAEL,MAAM,EAIN,MAAM,EACN,KAAK,KAAK,EACV,MAAM,EACP,MAAM,QAAQ,CAAA;AACf,OAAO,KAAK,EAAE,QAAQ,EAAE,cAAc,EAAE,MAAM,SAAS,CAAA;AACvD,OAAO,EAAU,SAAS,EAAE,MAAM,SAAS,CAAA;AAE3C;;;;;;GAMG;AACH,eAAO,MAAM,0BAA0B,MAAM,CAAA;AAE7C;;;;;;;;;;;;;;;GAeG;AACH,eAAO,MAAM,4BAA4B,OAAO,CAAA;AAEhD;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AACH,eAAO,MAAM,wBAAwB;;;;;;;CAQ3B,CAAA;AAcV;;;;;;;;;;;GAWG;AACH,MAAM,WAAW,gBAAgB,CAAC,CAAC;IACjC;;;OAGG;IACH,QAAQ,CAAC,KAAK,EAAE,CACd,IAAI,EAAE,QAAQ,EACd,KAAK,EAAE,SAAS,KACb,MAAM,CAAC,MAAM,CAAC,aAAa,CAAC,cAAc,CAAC,EAAE,CAAC,CAAC,CAAA;IACpD;;;;;;;;;OASG;IACH,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC,MAAM,CAAC,OAAO,EAAE,CAAC,CAAC,CAAA;IAC3C,8EAA8E;IAC9E,QAAQ,CAAC,YAAY,EAAE,MAAM,CAAA;IAC7B,iEAAiE;IACjE,QAAQ,CAAC,eAAe,EAAE,MAAM,CAAA;CACjC;AAED;;;;;;;GAOG;AACH,eAAO,MAAM,eAAe,GAAI,CAAC,EAC/B,MAAM,gBAAgB,CAAC,CAAC,CAAC,EACzB,OAAO,QAAQ,GAAG,SAAS,KAC1B,MAAM,CAAC,MAAM,CAAC,cAAc,EAAE,CAAC,EAAE,KAAK,CAAC,KAAK,CAyG5C,CAAA"}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { AppendCondition } from './AppendCondition.js';
|
|
2
|
+
declare const AppendConditionFailed_base: new <A extends Record<string, any> = {}>(args: import("effect/Types").VoidIfEmpty<{ readonly [P in keyof A as P extends "_tag" ? never : P]: A[P]; }>) => import("effect/Cause").YieldableError & {
|
|
3
|
+
readonly _tag: "AppendConditionFailed";
|
|
4
|
+
} & Readonly<A>;
|
|
5
|
+
/**
|
|
6
|
+
* Raised by `append` when the append condition's `failIfEventsMatch` query
|
|
7
|
+
* matches at least one event after `condition.after`. Carries only the
|
|
8
|
+
* offending condition: a correct retry re-reads the store to its new head and
|
|
9
|
+
* rebuilds its condition from there, so no conflicting position is carried (it
|
|
10
|
+
* would already be stale by the time it was acted on).
|
|
11
|
+
*/
|
|
12
|
+
export declare class AppendConditionFailed extends AppendConditionFailed_base<{
|
|
13
|
+
readonly condition: AppendCondition;
|
|
14
|
+
}> {
|
|
15
|
+
}
|
|
16
|
+
export {};
|
|
17
|
+
//# sourceMappingURL=errors.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"errors.d.ts","sourceRoot":"","sources":["../../src/errors.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,mBAAmB,CAAA;;;;AAExD;;;;;;GAMG;AACH,qBAAa,qBAAsB,SAAQ,2BAEzC;IACA,QAAQ,CAAC,SAAS,EAAE,eAAe,CAAA;CACpC,CAAC;CAAG"}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { Effect, Layer } from 'effect';
|
|
2
|
+
import { DcbEventStore } from './DcbEventStore.js';
|
|
3
|
+
/**
|
|
4
|
+
* Options for the in-memory store factory.
|
|
5
|
+
*
|
|
6
|
+
* `beforeCommit` runs inside the append critical section, between the
|
|
7
|
+
* conflict-check and the commit. It defaults to `Effect.void` — a synchronous
|
|
8
|
+
* no-op — so the shipped critical section stays atomic by construction
|
|
9
|
+
* (`Ref.get` -> filter -> `Ref.set`, no suspension), which is the whole
|
|
10
|
+
* exactly-one-wins guarantee. It is a test seam: the concurrency contract test
|
|
11
|
+
* overrides it with `Effect.yieldNow()` to force a check/commit suspension, so
|
|
12
|
+
* the guarantee genuinely depends on `withPermits(1)` serialising across that
|
|
13
|
+
* boundary — without an injected suspension the test is vacuous (it passes even
|
|
14
|
+
* with the mutex removed). Real async backends (e.g. Postgres) have this
|
|
15
|
+
* boundary intrinsically; the in-memory store injects it only under test.
|
|
16
|
+
*
|
|
17
|
+
* `suppressWake` stops `append` publishing to the hub, so no wake ever reaches a
|
|
18
|
+
* live subscriber and the only path left to a delivery is the subscribe machine's
|
|
19
|
+
* periodic poll. It defaults to `false` — the shipped store always wakes — and it
|
|
20
|
+
* earns its place for the same reason `beforeCommit` does: the machine's stated
|
|
21
|
+
* guarantee is that the POLL and not the wake source is what makes delivery correct,
|
|
22
|
+
* and every ordinary subscribe test could be passing purely via the hub, which
|
|
23
|
+
* leaves that load-bearing claim asserted only in a comment. Suppressing the PUBLISH
|
|
24
|
+
* rather than the machine's `listen` keeps `subscribe`'s wiring identical to
|
|
25
|
+
* production's: what changes is that the wake never fires, which is exactly what a
|
|
26
|
+
* wake source that died silently does to a real engine.
|
|
27
|
+
*
|
|
28
|
+
* `catchUpPageSize` lowers the catch-up page size the subscribe machine runs this
|
|
29
|
+
* store's subscriptions at, defaulting to core's `DEFAULT_CATCH_UP_PAGE_SIZE` — and it
|
|
30
|
+
* earns its place the same way its siblings here do. At that default a catch-up drains
|
|
31
|
+
* in ONE page for any backlog a test would write, so the page loop's exit condition is
|
|
32
|
+
* taken on the FIRST page every time and `fetchPage`'s whole reason for existing is a
|
|
33
|
+
* claim about a boundary nothing reaches. Lowering the page brings the SECOND one
|
|
34
|
+
* within reach: a store that cannot be made to page cannot be asked whether it pages
|
|
35
|
+
* correctly.
|
|
36
|
+
*
|
|
37
|
+
* `pollInterval` lowers the ceiling on how long the subscribe machine waits for a
|
|
38
|
+
* wake before re-reading from its cursor anyway (milliseconds), defaulting to core's
|
|
39
|
+
* `DEFAULT_POLL_INTERVAL_MILLIS`. The oracle takes its subscribe tuning from exactly
|
|
40
|
+
* where the engines it judges take theirs, so a figure chosen here instead would be
|
|
41
|
+
* this store drifting from all of them by a factor nothing outside its own tests ever
|
|
42
|
+
* asked for.
|
|
43
|
+
*
|
|
44
|
+
* A seam rather than a shorter shipped default, and what that costs is MEASURED
|
|
45
|
+
* rather than assumed: at core's default, NO subscribe case in the shared contract
|
|
46
|
+
* suite reaches a poll timeout against this store. Those cases run on a real clock
|
|
47
|
+
* and finish in a few hundred milliseconds, and the hub wake carries every delivery
|
|
48
|
+
* they assert, so the poll — the half that carries delivery whenever no wake is
|
|
49
|
+
* heard — is never what delivered one. A case that wants the POLL to deliver here
|
|
50
|
+
* must therefore ask for an interval short enough to elapse inside it, and taking the
|
|
51
|
+
* wake away is what makes such a case prove anything. Asking is cheap: the machine
|
|
52
|
+
* treats the interval as a CEILING on a wait and never as a tick rate, so a wake
|
|
53
|
+
* still returns at once and only an IDLE subscription pays — one `fetch` per
|
|
54
|
+
* interval, which here is a binary search plus a bounded scan over an array.
|
|
55
|
+
*/
|
|
56
|
+
export interface InMemoryStoreOptions {
|
|
57
|
+
readonly beforeCommit?: Effect.Effect<void>;
|
|
58
|
+
readonly suppressWake?: boolean;
|
|
59
|
+
readonly catchUpPageSize?: number;
|
|
60
|
+
readonly pollInterval?: number;
|
|
61
|
+
}
|
|
62
|
+
/** The in-memory `DcbEventStore` layer (production; no test seam). */
|
|
63
|
+
export declare const DcbEventStoreInMemory: Layer.Layer<DcbEventStore>;
|
|
64
|
+
/**
|
|
65
|
+
* Test-only in-memory layer factory: the same store as `DcbEventStoreInMemory`, but
|
|
66
|
+
* with `InMemoryStoreOptions`' seams open. Kept off the package's public surface
|
|
67
|
+
* (deliberately not re-exported from `index.ts`); production code always uses
|
|
68
|
+
* `DcbEventStoreInMemory`. Every seam exists to make a claim about this store
|
|
69
|
+
* FALSIFIABLE rather than merely commented — the store contract suite forces the
|
|
70
|
+
* check/commit interleaving the exactly-one-wins case needs, one core test takes the
|
|
71
|
+
* wake away and shortens the poll so that what delivers can only be the poll, and
|
|
72
|
+
* another lowers the catch-up page to reach the second-page boundary the shipped page
|
|
73
|
+
* size puts out of reach.
|
|
74
|
+
*/
|
|
75
|
+
export declare const makeInMemoryTestLayer: (options: InMemoryStoreOptions) => Layer.Layer<DcbEventStore>;
|
|
76
|
+
//# sourceMappingURL=inMemory.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"inMemory.d.ts","sourceRoot":"","sources":["../../src/inMemory.ts"],"names":[],"mappings":"AA2BA,OAAO,EAAE,MAAM,EAAE,KAAK,EAAuB,MAAM,QAAQ,CAAA;AAI3D,OAAO,EAAE,aAAa,EAAE,MAAM,iBAAiB,CAAA;AAyH/C;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAoDG;AACH,MAAM,WAAW,oBAAoB;IACnC,QAAQ,CAAC,YAAY,CAAC,EAAE,MAAM,CAAC,MAAM,CAAC,IAAI,CAAC,CAAA;IAC3C,QAAQ,CAAC,YAAY,CAAC,EAAE,OAAO,CAAA;IAC/B,QAAQ,CAAC,eAAe,CAAC,EAAE,MAAM,CAAA;IACjC,QAAQ,CAAC,YAAY,CAAC,EAAE,MAAM,CAAA;CAC/B;AA6HD,sEAAsE;AACtE,eAAO,MAAM,qBAAqB,EAAE,KAAK,CAAC,KAAK,CAAC,aAAa,CAG5D,CAAA;AAED;;;;;;;;;;GAUG;AACH,eAAO,MAAM,qBAAqB,GAChC,SAAS,oBAAoB,KAC5B,KAAK,CAAC,KAAK,CAAC,aAAa,CAC+C,CAAA"}
|
|
@@ -0,0 +1,35 @@
|
|
|
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
|
+
export { DcbEventStoreInMemory } from './inMemory.js';
|
|
31
|
+
export * from './Projection.js';
|
|
32
|
+
export * from './Query.js';
|
|
33
|
+
export * from './retryOnConflict.js';
|
|
34
|
+
export * from './SubscribeMachine.js';
|
|
35
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AACH,cAAc,mBAAmB,CAAA;AACjC,cAAc,iBAAiB,CAAA;AAC/B,cAAc,iBAAiB,CAAA;AAC/B,cAAc,SAAS,CAAA;AACvB,cAAc,UAAU,CAAA;AAIxB,OAAO,EAAE,qBAAqB,EAAE,MAAM,YAAY,CAAA;AAClD,cAAc,cAAc,CAAA;AAC5B,cAAc,SAAS,CAAA;AACvB,cAAc,mBAAmB,CAAA;AACjC,cAAc,oBAAoB,CAAA"}
|
|
@@ -0,0 +1,70 @@
|
|
|
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, Schedule } from 'effect';
|
|
46
|
+
import type { AppendConditionFailed } from './errors.js';
|
|
47
|
+
/**
|
|
48
|
+
* Retry `command` while — and only while — it fails `AppendConditionFailed`.
|
|
49
|
+
*
|
|
50
|
+
* `command` must be the WHOLE read-decide-append cycle (read a decision model to
|
|
51
|
+
* the current head, decide, append under that read's condition), because each
|
|
52
|
+
* retry's correctness comes from the re-read: the fresh read re-derives the
|
|
53
|
+
* append condition's `after` from the new head, so the retried append is judged
|
|
54
|
+
* against the boundary the retried decision was actually made under. Wrapping only
|
|
55
|
+
* the `append` re-submits a stale condition and is wrong.
|
|
56
|
+
*
|
|
57
|
+
* A non-conflict `E` is preserved on the error channel and escapes IMMEDIATELY
|
|
58
|
+
* with zero retries: the `while` predicate is false for it, so a domain rejection
|
|
59
|
+
* ("course is full") never pays a backoff and never retries into a decision the
|
|
60
|
+
* log has already refused. `A`, the non-conflict `E`, and `R` all pass through
|
|
61
|
+
* untouched.
|
|
62
|
+
*
|
|
63
|
+
* The predicate matches on the `_tag` rather than `instanceof`. The tag is the
|
|
64
|
+
* error's identity across module realms, and a published package ships dual
|
|
65
|
+
* ESM/CJS, so an `instanceof` check can see two distinct classes for the same
|
|
66
|
+
* error when a consumer's graph loads both builds — a conflict would then escape
|
|
67
|
+
* unretried and silently.
|
|
68
|
+
*/
|
|
69
|
+
export declare const retryOnConflict: <A, E, R>(command: Effect.Effect<A, AppendConditionFailed | E, R>, schedule?: Schedule.Schedule<unknown, AppendConditionFailed | E, R>) => Effect.Effect<A, AppendConditionFailed | E, R>;
|
|
70
|
+
//# sourceMappingURL=retryOnConflict.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"retryOnConflict.d.ts","sourceRoot":"","sources":["../../src/retryOnConflict.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2CG;AACH,OAAO,EAAE,MAAM,EAAa,QAAQ,EAAE,MAAM,QAAQ,CAAA;AACpD,OAAO,KAAK,EAAE,qBAAqB,EAAE,MAAM,UAAU,CAAA;AAqBrD;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,eAAO,MAAM,eAAe,GAAI,CAAC,EAAE,CAAC,EAAE,CAAC,EACrC,SAAS,MAAM,CAAC,MAAM,CAAC,CAAC,EAAE,qBAAqB,GAAG,CAAC,EAAE,CAAC,CAAC,EACvD,WAAU,QAAQ,CAAC,QAAQ,CACzB,OAAO,EACP,qBAAqB,GAAG,CAAC,EACzB,CAAC,CACgB,KAClB,MAAM,CAAC,MAAM,CAAC,CAAC,EAAE,qBAAqB,GAAG,CAAC,EAAE,CAAC,CAI5C,CAAA"}
|
|
@@ -0,0 +1,39 @@
|
|
|
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 declare const EPOCH: DateTime.Utc;
|
|
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 declare const makeSequentialUuid: () => (() => string);
|
|
39
|
+
//# sourceMappingURL=testing.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"testing.d.ts","sourceRoot":"","sources":["../../src/testing.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AACH,OAAO,EAAE,QAAQ,EAAE,MAAM,QAAQ,CAAA;AAEjC;;;;;;;GAOG;AACH,eAAO,MAAM,KAAK,cAAyB,CAAA;AAE3C;;;;;;;;;;GAUG;AACH,eAAO,MAAM,kBAAkB,QAAO,CAAC,MAAM,MAAM,CAOlD,CAAA"}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The optimistic-concurrency guard for `append`. The append fails if any event
|
|
3
|
+
* matching `failIfEventsMatch` exists strictly after `after`. This is the whole
|
|
4
|
+
* DCB consistency mechanism: "fail if anything I based my decision on has
|
|
5
|
+
* changed since I read it."
|
|
6
|
+
*
|
|
7
|
+
* `after` is exact-optional (absent, never `undefined`) per
|
|
8
|
+
* `exactOptionalPropertyTypes`. Absent means "scan the whole log".
|
|
9
|
+
*/
|
|
10
|
+
import { Schema } from 'effect';
|
|
11
|
+
import { Position } from "./Event.js";
|
|
12
|
+
import { Query } from "./Query.js";
|
|
13
|
+
export const AppendCondition = /*#__PURE__*/Schema.Struct({
|
|
14
|
+
failIfEventsMatch: Query,
|
|
15
|
+
after: /*#__PURE__*/Schema.optionalWith(Position, {
|
|
16
|
+
exact: true
|
|
17
|
+
})
|
|
18
|
+
});
|
|
19
|
+
//# sourceMappingURL=AppendCondition.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"AppendCondition.js","names":["Schema","Position","Query","AppendCondition","Struct","failIfEventsMatch","after","optionalWith","exact"],"sources":["../../src/AppendCondition.ts"],"sourcesContent":[null],"mappings":"AAAA;;;;;;;;;AASA,SAASA,MAAM,QAAQ,QAAQ;AAC/B,SAASC,QAAQ,QAAQ,YAAS;AAClC,SAASC,KAAK,QAAQ,YAAS;AAE/B,OAAO,MAAMC,eAAe,gBAAGH,MAAM,CAACI,MAAM,CAAC;EAC3CC,iBAAiB,EAAEH,KAAK;EACxBI,KAAK,eAAEN,MAAM,CAACO,YAAY,CAACN,QAAQ,EAAE;IAAEO,KAAK,EAAE;EAAI,CAAE;CACrD,CAAC","ignoreList":[]}
|