@reventlessdev/reventless-spec 3.0.0-alpha.61
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/CHANGELOG.md +625 -0
- package/LICENSE +202 -0
- package/README.md +114 -0
- package/package.json +34 -0
- package/rescript.json +25 -0
- package/run-generator.mjs +2 -0
- package/src/AnsiStyle.res +40 -0
- package/src/AnsiStyle.res.mjs +54 -0
- package/src/LogPrefix.res +168 -0
- package/src/LogPrefix.res.mjs +148 -0
- package/src/PackageVersion.res +73 -0
- package/src/PackageVersion.res.mjs +81 -0
- package/src/components/Aggregate.res +64 -0
- package/src/components/Aggregate.res.mjs +2 -0
- package/src/components/AutomationSlice.res +279 -0
- package/src/components/AutomationSlice.res.mjs +30 -0
- package/src/components/Counter.res +24 -0
- package/src/components/Counter.res.mjs +2 -0
- package/src/components/DcbDecode.res +128 -0
- package/src/components/DcbDecode.res.mjs +117 -0
- package/src/components/DcbTag.res +1256 -0
- package/src/components/DcbTag.res.mjs +869 -0
- package/src/components/DcbValidation.res +362 -0
- package/src/components/DcbValidation.res.mjs +360 -0
- package/src/components/DisplayName.res +40 -0
- package/src/components/DisplayName.res.mjs +26 -0
- package/src/components/ExtensionPoint.res +27 -0
- package/src/components/ExtensionPoint.res.mjs +2 -0
- package/src/components/InboundTranslationSlice.res +79 -0
- package/src/components/InboundTranslationSlice.res.mjs +2 -0
- package/src/components/OutboundTranslationSlice.res +108 -0
- package/src/components/OutboundTranslationSlice.res.mjs +2 -0
- package/src/components/Plugin.res +373 -0
- package/src/components/Plugin.res.mjs +249 -0
- package/src/components/ReadModel.res +199 -0
- package/src/components/ReadModel.res.mjs +18 -0
- package/src/components/Reference.res +51 -0
- package/src/components/Reference.res.mjs +37 -0
- package/src/components/StateAnnotations.res +65 -0
- package/src/components/StateAnnotations.res.mjs +15 -0
- package/src/components/StateChangeSlice.res +131 -0
- package/src/components/StateChangeSlice.res.mjs +2 -0
- package/src/components/StateViewSlice.res +99 -0
- package/src/components/StateViewSlice.res.mjs +2 -0
- package/src/components/Task.res +62 -0
- package/src/components/Task.res.mjs +2 -0
- package/src/generator/Codegen.res +703 -0
- package/src/generator/Codegen.res.mjs +485 -0
- package/src/generator/Config.res +85 -0
- package/src/generator/Config.res.mjs +78 -0
- package/src/generator/Discovery.res +204 -0
- package/src/generator/Discovery.res.mjs +213 -0
- package/src/generator/Generator_Node.res +49 -0
- package/src/generator/Generator_Node.res.mjs +18 -0
- package/src/generator/Pairing.res +386 -0
- package/src/generator/Pairing.res.mjs +353 -0
- package/src/generator/PluginGenerator.res +53 -0
- package/src/generator/PluginGenerator.res.mjs +70 -0
- package/src/types/Authorization.res +23 -0
- package/src/types/Authorization.res.mjs +33 -0
- package/src/types/Behavior.res +78 -0
- package/src/types/Behavior.res.mjs +2 -0
- package/src/types/EventMapping.res +100 -0
- package/src/types/EventMapping.res.mjs +15 -0
- package/src/types/Handler.res +30 -0
- package/src/types/Handler.res.mjs +2 -0
- package/src/types/Id.res +75 -0
- package/src/types/Id.res.mjs +37 -0
- package/src/types/Identity.res +46 -0
- package/src/types/Identity.res.mjs +51 -0
- package/src/types/Message.res +197 -0
- package/src/types/Message.res.mjs +108 -0
- package/src/types/Projection.res +220 -0
- package/src/types/Projection.res.mjs +44 -0
- package/src/types/QueryEngine.res +123 -0
- package/src/types/QueryEngine.res.mjs +12 -0
- package/src/types/ReadConsistency.res +38 -0
- package/src/types/ReadConsistency.res.mjs +29 -0
- package/src/types/Schedule.res +65 -0
- package/src/types/Schedule.res.mjs +68 -0
- package/src/types/SideEffect.res +45 -0
- package/src/types/SideEffect.res.mjs +2 -0
- package/src/types/StoredEvent.res +46 -0
- package/src/types/StoredEvent.res.mjs +32 -0
- package/src/types/Visibility.res +24 -0
- package/src/types/Visibility.res.mjs +25 -0
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
/**
|
|
2
|
+
The aggregate whose events drive this projection.
|
|
3
|
+
Used to subscribe to the correct event topic.
|
|
4
|
+
*/
|
|
5
|
+
module type Source = {
|
|
6
|
+
module Id: Id.T
|
|
7
|
+
let name: string
|
|
8
|
+
@schema
|
|
9
|
+
type event
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
The read model table that stores the projected state.
|
|
14
|
+
`subIdConfig` enables composite-key tables (id + sub-id).
|
|
15
|
+
*/
|
|
16
|
+
module type Target = {
|
|
17
|
+
module Id: Id.T
|
|
18
|
+
let name: string
|
|
19
|
+
@schema
|
|
20
|
+
type state
|
|
21
|
+
let subIdConfig: option<ReadModel.subIdConfig<state>>
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
The outcome of a projection mapping — what to do with the read model state
|
|
26
|
+
after processing a source event.
|
|
27
|
+
|
|
28
|
+
Returned from a `Projection.Mapping` `project` function.
|
|
29
|
+
Return `Ignore` (or `[]`) when an event should not affect the read model.
|
|
30
|
+
|
|
31
|
+
@example
|
|
32
|
+
```rescript
|
|
33
|
+
// CategoriesProjections.res
|
|
34
|
+
let project = ({event, id, _}) => switch event {
|
|
35
|
+
| CategoryAdded({categoryId, name}) =>
|
|
36
|
+
Set(id, {CategoriesReadModel.categoryId, name, archived: false})
|
|
37
|
+
| CategoryRenamed({name}) => Update(id, state => {...state, name})
|
|
38
|
+
| CategoryArchived(_) => Update(id, state => {...state, archived: true})
|
|
39
|
+
}
|
|
40
|
+
```
|
|
41
|
+
*/
|
|
42
|
+
type action<'id, 'state> =
|
|
43
|
+
/** Create a new state entry. The entry must not already exist. */
|
|
44
|
+
| Create('id, 'state)
|
|
45
|
+
/** Create many new state entries. Entries must not already exist. */
|
|
46
|
+
| CreateMany(array<('id, 'state)>)
|
|
47
|
+
/** Update an existing entry by applying a transform function. */
|
|
48
|
+
| Update('id, 'state => 'state)
|
|
49
|
+
/** Update many existing entries. */
|
|
50
|
+
| UpdateMany(array<'id>, ('id, 'state) => 'state)
|
|
51
|
+
/** Update an existing entry, or create it with `'state` if it does not exist. */
|
|
52
|
+
| UpdateWithDefault('id, 'state, 'state => 'state)
|
|
53
|
+
/** Update many entries or create them with a default derived from their ID. */
|
|
54
|
+
| UpdateManyWithDefault(array<'id>, 'id => 'state, ('id, 'state) => 'state)
|
|
55
|
+
/** Overwrite the state for an entry, creating it if it does not exist. */
|
|
56
|
+
| Set('id, 'state)
|
|
57
|
+
/** Overwrite the state for many entries. */
|
|
58
|
+
| SetMany(array<'id>, 'id => 'state)
|
|
59
|
+
/** Delete an existing entry. */
|
|
60
|
+
| Delete('id)
|
|
61
|
+
/** Delete many entries. */
|
|
62
|
+
| DeleteMany(array<'id>)
|
|
63
|
+
/** Delete an entry only if the predicate returns true. */
|
|
64
|
+
| DeleteIf('id, 'state => bool)
|
|
65
|
+
/** Delete many entries conditionally. */
|
|
66
|
+
| DeleteManyIf(array<'id>, ('id, 'state) => bool)
|
|
67
|
+
/**
|
|
68
|
+
Create multiple sub-state rows under the same primary ID.
|
|
69
|
+
Used when one event produces several independent sub-entries.
|
|
70
|
+
*/
|
|
71
|
+
| CreateMultiState('id, array<'state>)
|
|
72
|
+
/**
|
|
73
|
+
Replace the entire set of sub-state rows for a primary ID.
|
|
74
|
+
The transform receives the current rows and returns the new rows.
|
|
75
|
+
*/
|
|
76
|
+
| UpdateMultiState('id, array<'state> => array<'state>)
|
|
77
|
+
/** Update sub-state rows for multiple primary IDs. */
|
|
78
|
+
| UpdateManyMultiStates(array<'id>, ('id, array<'state>) => array<'state>)
|
|
79
|
+
/** No-op. Return this when an event should not affect the read model. */
|
|
80
|
+
| Ignore
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
A compiled single-source-to-single-target mapping.
|
|
84
|
+
|
|
85
|
+
Created by `Projection.Mapping.Make(Source, Target, MappingImpl)`.
|
|
86
|
+
The `project` function receives a full `Message.event'` envelope and returns
|
|
87
|
+
one `action` value.
|
|
88
|
+
*/
|
|
89
|
+
module type Mapping = {
|
|
90
|
+
//module Source: Source
|
|
91
|
+
//module Target: Target // NOTE: to be destructive substituted
|
|
92
|
+
module SourceId: Id.T
|
|
93
|
+
@schema
|
|
94
|
+
type sourceEvent
|
|
95
|
+
@schema
|
|
96
|
+
type targetState
|
|
97
|
+
|
|
98
|
+
let project: Message.event'<string, sourceEvent> => action<string, targetState>
|
|
99
|
+
let sourceEventSchema: S.t<sourceEvent>
|
|
100
|
+
let sourceName: string
|
|
101
|
+
let subIdConfig: option<ReadModel.subIdConfig<targetState>>
|
|
102
|
+
let targetStateSchema: S.t<targetState>
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
A collection of `Mapping` modules for a single read model target.
|
|
107
|
+
|
|
108
|
+
Pass a `Mappings` module to `Platform.ReadModel.Make` to register all
|
|
109
|
+
source-to-target projections for a read model.
|
|
110
|
+
*/
|
|
111
|
+
module type Mappings = {
|
|
112
|
+
module Target: Target // to be removed via destructive replace in functor call
|
|
113
|
+
module type Mapping = Mapping with type targetState = Target.state
|
|
114
|
+
let moduleUrl: string
|
|
115
|
+
let mappings: array<module(Mapping)>
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
module type MappingImpl = {
|
|
119
|
+
type sourceEvent
|
|
120
|
+
type targetState
|
|
121
|
+
let project: Message.event'<string, sourceEvent> => action<string, targetState>
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
Builds a `Projection.Mapping` from a `Source`, `Target`, and `MappingImpl`.
|
|
126
|
+
|
|
127
|
+
@example
|
|
128
|
+
```rescript
|
|
129
|
+
// CategoriesProjections.res
|
|
130
|
+
module CategoryMapping = Projection.Mapping.Make(
|
|
131
|
+
Category,
|
|
132
|
+
CategoriesReadModel,
|
|
133
|
+
{
|
|
134
|
+
let project = ({event, id, _}) => switch event {
|
|
135
|
+
| CategoryAdded({categoryId, name}) =>
|
|
136
|
+
Set(id, {CategoriesReadModel.categoryId, name, archived: false})
|
|
137
|
+
| CategoryRenamed({name}) => Update(id, state => {...state, name})
|
|
138
|
+
| CategoryArchived(_) => Update(id, state => {...state, archived: true})
|
|
139
|
+
}
|
|
140
|
+
},
|
|
141
|
+
)
|
|
142
|
+
```
|
|
143
|
+
*/
|
|
144
|
+
module Mapping = {
|
|
145
|
+
module Make = (
|
|
146
|
+
Source: Source,
|
|
147
|
+
Target: Target,
|
|
148
|
+
MappingImpl: MappingImpl with type sourceEvent := Source.event and type targetState := Target.state,
|
|
149
|
+
): (Mapping with type targetState = Target.state and type sourceEvent = Source.event and module SourceId = Source.Id) => {
|
|
150
|
+
module SourceId = Source.Id
|
|
151
|
+
@schema
|
|
152
|
+
type sourceEvent = Source.event
|
|
153
|
+
@schema
|
|
154
|
+
type targetState = Target.state
|
|
155
|
+
let project = MappingImpl.project
|
|
156
|
+
let sourceName = Source.name
|
|
157
|
+
let subIdConfig = Target.subIdConfig
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
module Mappings = {
|
|
162
|
+
module Make = (Target: Target) => {
|
|
163
|
+
module type Mapping = Mapping with type targetState = Target.state
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
Builds a `Projection.Source` module for a DCB EventLog.
|
|
169
|
+
|
|
170
|
+
A DCB-source `Mapping.Make` takes a `Source` whose `name` matches the key under
|
|
171
|
+
which `Plugin_Builder` registers the DCB topic in `allEventTopics`
|
|
172
|
+
(`<pluginName>DcbEventLog`). This helper is a thin convenience that asserts the
|
|
173
|
+
`Source` shape over an inline DCB event subset — the `event` type only needs to
|
|
174
|
+
enumerate the variants this consumer projects (others fall through as decode
|
|
175
|
+
errors and the runtime treats them as `Ignore`).
|
|
176
|
+
|
|
177
|
+
@example
|
|
178
|
+
```rescript
|
|
179
|
+
// catalog/src/Product/ReadModel/CatalogDcbSource.res
|
|
180
|
+
module CatalogDcbSource = Reventless.Projection.DcbSource.Make({
|
|
181
|
+
let name = "CatalogDcbEventLog"
|
|
182
|
+
@schema
|
|
183
|
+
type event =
|
|
184
|
+
| ProductAdded({productId: string, name: string, price: float})
|
|
185
|
+
| ProductRenamed({productId: string, name: string})
|
|
186
|
+
})
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
Then in your projections file:
|
|
190
|
+
```rescript
|
|
191
|
+
module ProductsFromDcb = Reventless.Projection.Mapping.Make(
|
|
192
|
+
CatalogDcbSource,
|
|
193
|
+
ProductsReadModel,
|
|
194
|
+
{ let project = ({event, id, _}) => switch event {
|
|
195
|
+
| ProductAdded({name}) => Set(id, {ProductsReadModel.name})
|
|
196
|
+
| ProductRenamed({name}) => Update(id, s => {...s, name})
|
|
197
|
+
} },
|
|
198
|
+
)
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
`module Id` is `Reventless.Id.String` for DCB sources — DCB events are
|
|
202
|
+
content-addressed and have no aggregate-style stream ID.
|
|
203
|
+
*/
|
|
204
|
+
module DcbSource = {
|
|
205
|
+
module type Definition = {
|
|
206
|
+
let name: string
|
|
207
|
+
@schema
|
|
208
|
+
type event
|
|
209
|
+
}
|
|
210
|
+
// Returned signature is left structurally inferred (NOT sealed under `Source`)
|
|
211
|
+
// so that variant constructors of `D.event` remain accessible to callers
|
|
212
|
+
// writing `switch msg.event { | ProductAdded(_) => ... }`. The result still
|
|
213
|
+
// satisfies `Source` structurally and is accepted by `Mapping.Make` as such.
|
|
214
|
+
module Make = (D: Definition) => {
|
|
215
|
+
module Id = Id.String
|
|
216
|
+
let name = D.name
|
|
217
|
+
type event = D.event
|
|
218
|
+
let eventSchema = D.eventSchema
|
|
219
|
+
}
|
|
220
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// Generated by ReScript, PLEASE EDIT WITH CARE
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
function Make(Source) {
|
|
5
|
+
return Target => (MappingImpl => ({
|
|
6
|
+
SourceId: Source.Id,
|
|
7
|
+
project: MappingImpl.project,
|
|
8
|
+
sourceEventSchema: Source.eventSchema,
|
|
9
|
+
sourceName: Source.name,
|
|
10
|
+
subIdConfig: Target.subIdConfig,
|
|
11
|
+
targetStateSchema: Target.stateSchema
|
|
12
|
+
}));
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
let Mapping = {
|
|
16
|
+
Make: Make
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
function Make$1(Target) {
|
|
20
|
+
return {};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
let Mappings = {
|
|
24
|
+
Make: Make$1
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
function Make$2(D) {
|
|
28
|
+
return {
|
|
29
|
+
Id: undefined,
|
|
30
|
+
name: D.name,
|
|
31
|
+
eventSchema: D.eventSchema
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
let DcbSource = {
|
|
36
|
+
Make: Make$2
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
export {
|
|
40
|
+
Mapping,
|
|
41
|
+
Mappings,
|
|
42
|
+
DcbSource,
|
|
43
|
+
}
|
|
44
|
+
/* No side effect */
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
A typed query value used as a filter operand.
|
|
3
|
+
|
|
4
|
+
Wraps the three primitive types supported by the query engine so that
|
|
5
|
+
filter comparisons are type-safe without requiring a raw JSON representation.
|
|
6
|
+
*/
|
|
7
|
+
type value =
|
|
8
|
+
| String(string)
|
|
9
|
+
| Int(int)
|
|
10
|
+
| Bool(bool)
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
Filter operations for read model queries.
|
|
14
|
+
|
|
15
|
+
`Filter.config` is a triple `(fieldName, comparator, value)` applied on top of
|
|
16
|
+
the primary key lookup to narrow the result set.
|
|
17
|
+
|
|
18
|
+
@example
|
|
19
|
+
```rescript
|
|
20
|
+
// Only return categories where archived = false
|
|
21
|
+
let activeFilter: QueryEngine.Filter.config = ("archived", Equal, Bool(false))
|
|
22
|
+
```
|
|
23
|
+
*/
|
|
24
|
+
module Filter = {
|
|
25
|
+
/** Comparison operators available for field-level filters. */
|
|
26
|
+
type comparator =
|
|
27
|
+
| Equal
|
|
28
|
+
| Unequal
|
|
29
|
+
| LessOrEqual
|
|
30
|
+
| Less
|
|
31
|
+
| GreaterOrEqual
|
|
32
|
+
| Greater
|
|
33
|
+
/** Field must exist (have a value). */
|
|
34
|
+
| Exists
|
|
35
|
+
/** Field must not exist. */
|
|
36
|
+
| NotExists
|
|
37
|
+
/** String / set membership: field contains the given substring or element. */
|
|
38
|
+
| Contains
|
|
39
|
+
| NotContains
|
|
40
|
+
| BeginsWith
|
|
41
|
+
|
|
42
|
+
/** A filter specification: `(fieldName, comparator, value)`. */
|
|
43
|
+
type config = (string, comparator, value)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
Sub-ID filter operations for composite-key read model queries.
|
|
48
|
+
|
|
49
|
+
`SubId.config` narrows results within a partition already selected by the primary ID.
|
|
50
|
+
|
|
51
|
+
@example
|
|
52
|
+
```rescript
|
|
53
|
+
// Only return rows where subId begins with "cat-"
|
|
54
|
+
let categoryFilter: QueryEngine.SubId.config = ("categoryId", BeginsWith, String("cat-"))
|
|
55
|
+
```
|
|
56
|
+
*/
|
|
57
|
+
module SubId = {
|
|
58
|
+
/** Comparison operators available for sub-ID range conditions. */
|
|
59
|
+
type comparator =
|
|
60
|
+
| Equal
|
|
61
|
+
| Unequal
|
|
62
|
+
| LessOrEqual
|
|
63
|
+
| Less
|
|
64
|
+
| GreaterOrEqual
|
|
65
|
+
| Greater
|
|
66
|
+
| BeginsWith
|
|
67
|
+
|
|
68
|
+
/** A sub-ID filter specification: `(subIdField, comparator, value)`. */
|
|
69
|
+
type config = (string, comparator, value)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
Fetches rows from a read model by primary key, with optional sub-ID range and
|
|
74
|
+
additional attribute filters.
|
|
75
|
+
|
|
76
|
+
@example
|
|
77
|
+
```rescript
|
|
78
|
+
let rows = await queryEngine.query(
|
|
79
|
+
~readModelName="Categories",
|
|
80
|
+
~id=String("cat-1"),
|
|
81
|
+
~filterConfigs=[("archived", Filter.Equal, Bool(false))],
|
|
82
|
+
)
|
|
83
|
+
```
|
|
84
|
+
*/
|
|
85
|
+
type query = (
|
|
86
|
+
~readModelName: string,
|
|
87
|
+
~key: string=?,
|
|
88
|
+
~id: value,
|
|
89
|
+
~subIdConfig: SubId.config=?,
|
|
90
|
+
~filterConfigs: array<Filter.config>=?,
|
|
91
|
+
~ascending: bool=?,
|
|
92
|
+
~limit: int=?,
|
|
93
|
+
) => promise<array<JSON.t>>
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
Scans an entire read model table with attribute filters (no primary key).
|
|
97
|
+
|
|
98
|
+
Use only when no primary key is available; scans are more expensive than queries.
|
|
99
|
+
|
|
100
|
+
@example
|
|
101
|
+
```rescript
|
|
102
|
+
let allActive = await queryEngine.scan(
|
|
103
|
+
~readModelName="Categories",
|
|
104
|
+
~filterConfigs=[("archived", Filter.Equal, Bool(false))],
|
|
105
|
+
~limit=100,
|
|
106
|
+
)
|
|
107
|
+
```
|
|
108
|
+
*/
|
|
109
|
+
type scan = (
|
|
110
|
+
~readModelName: string,
|
|
111
|
+
~filterConfigs: array<Filter.config>,
|
|
112
|
+
~limit: int,
|
|
113
|
+
) => promise<array<JSON.t>>
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
Runtime query engine injected into task handlers, side effects, and extension point mappings.
|
|
117
|
+
|
|
118
|
+
Provides provider-agnostic read access to all read models in the plugin.
|
|
119
|
+
*/
|
|
120
|
+
type operations = {
|
|
121
|
+
scan: scan,
|
|
122
|
+
query: query,
|
|
123
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// Per-slice decision-read consistency mode for DCB StateChangeSlices.
|
|
2
|
+
//
|
|
3
|
+
// Controls how a slice's decision-model read (the conditional read that
|
|
4
|
+
// rebuilds `state` before `decide`) chooses DynamoDB read consistency across
|
|
5
|
+
// the optimistic-concurrency retry loop in `StateChangeSlice_Callback`.
|
|
6
|
+
//
|
|
7
|
+
// Correctness is identical in every mode. A DynamoDB conditional write is
|
|
8
|
+
// always evaluated against the latest committed data, so a stale (eventually
|
|
9
|
+
// consistent) read can only ever cause a *rejected* append — which the retry
|
|
10
|
+
// loop re-reads and resolves — never a wrong write. Strong reads are therefore
|
|
11
|
+
// purely a cost/latency lever against the *replica-lag* class of conflicts;
|
|
12
|
+
// they do nothing for genuine concurrent-writer conflicts (those serialize at
|
|
13
|
+
// the fence regardless). See `docs/analysis/dcb-high-contention-handling.md`.
|
|
14
|
+
//
|
|
15
|
+
// The variant ships with three cases; further cases can be added without
|
|
16
|
+
// breaking existing `@@reventless.consistency(...)` declarations.
|
|
17
|
+
|
|
18
|
+
@schema
|
|
19
|
+
type t =
|
|
20
|
+
| // Default: eventual read on the first attempt (cheaper RCU), then strong on
|
|
21
|
+
// every retry so a replica-lag conflict self-heals deterministically instead
|
|
22
|
+
// of burning further retries.
|
|
23
|
+
EscalateOnRetry
|
|
24
|
+
| // Always strong. For known-hot slices where replica-lag conflicts dominate
|
|
25
|
+
// and the first eventual read is not worth the wasted attempt.
|
|
26
|
+
AlwaysStrong
|
|
27
|
+
| // Always eventual, even on retry. For very cost-sensitive, low-contention
|
|
28
|
+
// slices that never want to pay strong-read RCU.
|
|
29
|
+
AlwaysEventual
|
|
30
|
+
|
|
31
|
+
let default = EscalateOnRetry
|
|
32
|
+
|
|
33
|
+
let toString = (v: t) =>
|
|
34
|
+
switch v {
|
|
35
|
+
| EscalateOnRetry => "EscalateOnRetry"
|
|
36
|
+
| AlwaysStrong => "AlwaysStrong"
|
|
37
|
+
| AlwaysEventual => "AlwaysEventual"
|
|
38
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// Generated by ReScript, PLEASE EDIT WITH CARE
|
|
2
|
+
|
|
3
|
+
import * as S from "sury/src/S.res.mjs";
|
|
4
|
+
|
|
5
|
+
let schema = S.union([
|
|
6
|
+
S.literal("EscalateOnRetry"),
|
|
7
|
+
S.literal("AlwaysStrong"),
|
|
8
|
+
S.literal("AlwaysEventual")
|
|
9
|
+
]);
|
|
10
|
+
|
|
11
|
+
function toString(v) {
|
|
12
|
+
switch (v) {
|
|
13
|
+
case "EscalateOnRetry" :
|
|
14
|
+
return "EscalateOnRetry";
|
|
15
|
+
case "AlwaysStrong" :
|
|
16
|
+
return "AlwaysStrong";
|
|
17
|
+
case "AlwaysEventual" :
|
|
18
|
+
return "AlwaysEventual";
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
let $$default = "EscalateOnRetry";
|
|
23
|
+
|
|
24
|
+
export {
|
|
25
|
+
schema,
|
|
26
|
+
$$default as default,
|
|
27
|
+
toString,
|
|
28
|
+
}
|
|
29
|
+
/* schema Not a pure module */
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
@schema
|
|
2
|
+
type year = int
|
|
3
|
+
@schema
|
|
4
|
+
type month = int
|
|
5
|
+
@schema
|
|
6
|
+
type day = int
|
|
7
|
+
@schema
|
|
8
|
+
type hour = int
|
|
9
|
+
@schema
|
|
10
|
+
type minute = int
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
The repetition rule for a scheduled event.
|
|
14
|
+
|
|
15
|
+
- `Single(year, month, day, hour, minute)` — fires once at an absolute UTC time
|
|
16
|
+
- `Minutes(n)` / `Hours(n)` / `Days(n)` — fires every n minutes / hours / days
|
|
17
|
+
- `Daily(hour, minute)` — fires every day at the given UTC time
|
|
18
|
+
- `Weekdays(hour, minute)` — fires Monday–Friday at the given UTC time
|
|
19
|
+
- `WeekdaysAndSaturday(hour, minute)` — fires Monday–Saturday at the given UTC time
|
|
20
|
+
|
|
21
|
+
@example
|
|
22
|
+
```rescript
|
|
23
|
+
// Fire every 15 minutes
|
|
24
|
+
let rate = Schedule.Minutes(15)
|
|
25
|
+
|
|
26
|
+
// Fire once on 2024-12-31 at 23:59 UTC
|
|
27
|
+
let oneTime = Schedule.Single(2024, 12, 31, 23, 59)
|
|
28
|
+
```
|
|
29
|
+
*/
|
|
30
|
+
@schema
|
|
31
|
+
type rate =
|
|
32
|
+
| Single(year, month, day, hour, minute)
|
|
33
|
+
| Minutes(int)
|
|
34
|
+
| Hours(int)
|
|
35
|
+
| Days(int)
|
|
36
|
+
| Daily(hour, minute)
|
|
37
|
+
| Weekdays(hour, minute)
|
|
38
|
+
| WeekdaysAndSaturday(hour, minute)
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
A named recurring or one-time schedule.
|
|
42
|
+
|
|
43
|
+
`payload` is an opaque string forwarded to the task handler when the schedule fires.
|
|
44
|
+
|
|
45
|
+
@example
|
|
46
|
+
```rescript
|
|
47
|
+
let dailySync: Schedule.schedule = {
|
|
48
|
+
name: "DailyCatalogSync",
|
|
49
|
+
rate: Daily(2, 0), // 02:00 UTC every day
|
|
50
|
+
payload: `{"action": "sync"}`,
|
|
51
|
+
}
|
|
52
|
+
```
|
|
53
|
+
*/
|
|
54
|
+
@schema
|
|
55
|
+
type schedule = {
|
|
56
|
+
name: string,
|
|
57
|
+
rate: rate,
|
|
58
|
+
payload: string,
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Creates a schedule in the underlying infrastructure (e.g. EventBridge Scheduler). */
|
|
62
|
+
type create = schedule => promise<unit>
|
|
63
|
+
|
|
64
|
+
/** Deletes a schedule by name from the underlying infrastructure. */
|
|
65
|
+
type delete = /* ~name: */ string => promise<unit>
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// Generated by ReScript, PLEASE EDIT WITH CARE
|
|
2
|
+
|
|
3
|
+
import * as S from "sury/src/S.res.mjs";
|
|
4
|
+
|
|
5
|
+
let rateSchema = S.union([
|
|
6
|
+
S.schema(s => ({
|
|
7
|
+
TAG: "Single",
|
|
8
|
+
_0: s.m(S.int),
|
|
9
|
+
_1: s.m(S.int),
|
|
10
|
+
_2: s.m(S.int),
|
|
11
|
+
_3: s.m(S.int),
|
|
12
|
+
_4: s.m(S.int)
|
|
13
|
+
})),
|
|
14
|
+
S.schema(s => ({
|
|
15
|
+
TAG: "Minutes",
|
|
16
|
+
_0: s.m(S.int)
|
|
17
|
+
})),
|
|
18
|
+
S.schema(s => ({
|
|
19
|
+
TAG: "Hours",
|
|
20
|
+
_0: s.m(S.int)
|
|
21
|
+
})),
|
|
22
|
+
S.schema(s => ({
|
|
23
|
+
TAG: "Days",
|
|
24
|
+
_0: s.m(S.int)
|
|
25
|
+
})),
|
|
26
|
+
S.schema(s => ({
|
|
27
|
+
TAG: "Daily",
|
|
28
|
+
_0: s.m(S.int),
|
|
29
|
+
_1: s.m(S.int)
|
|
30
|
+
})),
|
|
31
|
+
S.schema(s => ({
|
|
32
|
+
TAG: "Weekdays",
|
|
33
|
+
_0: s.m(S.int),
|
|
34
|
+
_1: s.m(S.int)
|
|
35
|
+
})),
|
|
36
|
+
S.schema(s => ({
|
|
37
|
+
TAG: "WeekdaysAndSaturday",
|
|
38
|
+
_0: s.m(S.int),
|
|
39
|
+
_1: s.m(S.int)
|
|
40
|
+
}))
|
|
41
|
+
]);
|
|
42
|
+
|
|
43
|
+
let scheduleSchema = S.schema(s => ({
|
|
44
|
+
name: s.m(S.string),
|
|
45
|
+
rate: s.m(rateSchema),
|
|
46
|
+
payload: s.m(S.string)
|
|
47
|
+
}));
|
|
48
|
+
|
|
49
|
+
let yearSchema = S.int;
|
|
50
|
+
|
|
51
|
+
let monthSchema = S.int;
|
|
52
|
+
|
|
53
|
+
let daySchema = S.int;
|
|
54
|
+
|
|
55
|
+
let hourSchema = S.int;
|
|
56
|
+
|
|
57
|
+
let minuteSchema = S.int;
|
|
58
|
+
|
|
59
|
+
export {
|
|
60
|
+
yearSchema,
|
|
61
|
+
monthSchema,
|
|
62
|
+
daySchema,
|
|
63
|
+
hourSchema,
|
|
64
|
+
minuteSchema,
|
|
65
|
+
rateSchema,
|
|
66
|
+
scheduleSchema,
|
|
67
|
+
}
|
|
68
|
+
/* rateSchema Not a pure module */
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
The event source that a `SideEffect.T` module listens to.
|
|
3
|
+
|
|
4
|
+
Mirrors the aggregate `EventLog.T` shape — the adapter uses `name` and `Id`
|
|
5
|
+
to subscribe to the correct event topic.
|
|
6
|
+
*/
|
|
7
|
+
module type Source = {
|
|
8
|
+
let name: string
|
|
9
|
+
module Id: Id.T
|
|
10
|
+
/** The event type whose emissions trigger this side effect. */
|
|
11
|
+
@schema
|
|
12
|
+
type event
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
Module type for an imperative side effect triggered by aggregate events.
|
|
17
|
+
|
|
18
|
+
Side effects are registered on a `Task` and executed after the event is stored.
|
|
19
|
+
They have read access to the query engine but do NOT emit new events or commands.
|
|
20
|
+
|
|
21
|
+
@example
|
|
22
|
+
```rescript
|
|
23
|
+
module NotifyOnCategoryAdded: SideEffect.T = {
|
|
24
|
+
module Source = Category
|
|
25
|
+
let execute = async (id, _meta, event, _queryEngine) => switch event {
|
|
26
|
+
| CategoryAdded({name}) =>
|
|
27
|
+
Console.log2("Category added:", name)
|
|
28
|
+
| _ => ()
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
```
|
|
32
|
+
*/
|
|
33
|
+
module type T = {
|
|
34
|
+
module Source: Source
|
|
35
|
+
let moduleUrl: string
|
|
36
|
+
/**
|
|
37
|
+
Called once for each event emitted by `Source`.
|
|
38
|
+
|
|
39
|
+
- `id` — the aggregate ID that emitted the event
|
|
40
|
+
- `meta` — the message envelope metadata
|
|
41
|
+
- `event` — the domain event payload
|
|
42
|
+
- `queryEngine` — read-only access to all plugin read models
|
|
43
|
+
*/
|
|
44
|
+
let execute: (Source.Id.t, Message.meta, Source.event, QueryEngine.operations) => promise<unit>
|
|
45
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// Logical envelope for an event as it lives in storage (EventLog or DcbEventLog).
|
|
2
|
+
// The DynamoDB adapters flatten `meta` to top-level item attributes so meta keys
|
|
3
|
+
// stay GSI-projectable; the in-memory adapter stores the nested record as-is.
|
|
4
|
+
//
|
|
5
|
+
// This is the single source of truth for the on-disk shape — both
|
|
6
|
+
// EventLog_Operations and DcbEventLogStorage_DynamoDb_Runtime go through it
|
|
7
|
+
// rather than building Dict items by hand.
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
A stored event record. Used by both backends:
|
|
11
|
+
- Aggregate EventLog: `id` is the aggregate id, `position` is the zero-padded sequence string, `tags` is absent.
|
|
12
|
+
- DcbEventLog: `id` is the synthesised partition key (derived from tags), `position` is "<unixMs>-<uuid>", `tags` is present.
|
|
13
|
+
|
|
14
|
+
`event` is the variant constructor name (the on-disk "event" column).
|
|
15
|
+
`data` is the sury-encoded variant payload with the TAG discriminator stripped.
|
|
16
|
+
`recordedAt` is the storage timestamp, set by the storage adapter at append.
|
|
17
|
+
*/
|
|
18
|
+
type storedEvent<'id> = {
|
|
19
|
+
id: 'id,
|
|
20
|
+
position: string,
|
|
21
|
+
event: string,
|
|
22
|
+
data: JSON.t,
|
|
23
|
+
meta: Message.meta,
|
|
24
|
+
recordedAt: string,
|
|
25
|
+
tags?: array<DcbTag.tag>,
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Build a sury schema for `storedEvent<'id>` given the `'id` schema. */
|
|
29
|
+
let toStoredEventSchema = (idSchema: S.t<'id>): S.t<storedEvent<'id>> =>
|
|
30
|
+
S.object(s => {
|
|
31
|
+
id: s.field("id", idSchema),
|
|
32
|
+
position: s.field("position", S.string),
|
|
33
|
+
event: s.field("event", S.string),
|
|
34
|
+
data: s.field("data", S.json),
|
|
35
|
+
meta: s.field("meta", Message.metaSchema),
|
|
36
|
+
recordedAt: s.field("recordedAt", S.string),
|
|
37
|
+
tags: ?s.field("tags", S.option(S.array(DcbTag.tagSchema))),
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
/** Decode a `storedEvent<'id>` from JSON. */
|
|
41
|
+
let decode = (json, idSchema) =>
|
|
42
|
+
json->S.parseJsonOrThrow(toStoredEventSchema(idSchema))
|
|
43
|
+
|
|
44
|
+
/** Encode a `storedEvent<'id>` to JSON. */
|
|
45
|
+
let encode = (stored, idSchema) =>
|
|
46
|
+
stored->S.reverseConvertToJsonOrThrow(toStoredEventSchema(idSchema))
|