@reventlessdev/reventless-spec 3.0.0-alpha.123 → 3.0.0-alpha.125

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 (43) hide show
  1. package/CHANGELOG.md +56 -0
  2. package/package.json +5 -3
  3. package/run-certify-trait.mjs +2 -0
  4. package/run-graft-trait.mjs +2 -0
  5. package/run-trait-manifest.mjs +2 -0
  6. package/schema/platform-api.graphql +41 -3
  7. package/src/components/Aggregate.res +22 -0
  8. package/src/components/AutomationSlice.res +29 -3
  9. package/src/components/CapabilityManifest.res +52 -24
  10. package/src/components/CapabilityManifest.res.mjs +35 -11
  11. package/src/components/InboundTranslationSlice.res +14 -0
  12. package/src/components/OutboundTranslationSlice.res +24 -0
  13. package/src/components/Plugin.res +202 -422
  14. package/src/components/Plugin.res.mjs +65 -3
  15. package/src/components/StateChangeSlice.res +22 -0
  16. package/src/components/TraitCertificate.res +105 -0
  17. package/src/components/TraitCertificate.res.mjs +65 -0
  18. package/src/components/TraitManifest.res +90 -0
  19. package/src/components/TraitManifest.res.mjs +48 -0
  20. package/src/generator/CertifyTrait.res +190 -0
  21. package/src/generator/CertifyTrait.res.mjs +154 -0
  22. package/src/generator/GraftTrait.res +230 -0
  23. package/src/generator/GraftTrait.res.mjs +193 -0
  24. package/src/generator/PlatformCodegen.res +44 -30
  25. package/src/generator/PlatformCodegen.res.mjs +36 -17
  26. package/src/generator/TraitManifestCli.res +138 -0
  27. package/src/generator/TraitManifestCli.res.mjs +105 -0
  28. package/src/semantic/Capabilities.res +19 -3
  29. package/src/semantic/Capabilities.res.mjs +18 -2
  30. package/src/semantic/CapabilityNeed.res +81 -0
  31. package/src/semantic/CapabilityNeed.res.mjs +46 -0
  32. package/src/semantic/Currency.res +519 -502
  33. package/src/semantic/Currency.res.mjs +7 -655
  34. package/src/semantic/Messaging.res +127 -0
  35. package/src/semantic/Messaging.res.mjs +57 -0
  36. package/src/semantic/Money.res +123 -21
  37. package/src/semantic/Money.res.mjs +49 -2
  38. package/src/types/Trait.res +62 -0
  39. package/src/types/Trait.res.mjs +18 -0
  40. package/src/types/Transition.res +71 -0
  41. package/src/types/Transition.res.mjs +36 -0
  42. package/scripts/generate-currency.mjs +0 -215
  43. package/scripts/iso-4217-list-one.xml +0 -1956
@@ -0,0 +1,127 @@
1
+ /**
2
+ Sending a message to a person, and deciding whether to try again.
3
+
4
+ The transport is provider-specific and lives with its provider. What is here is
5
+ provider-neutral: who a message can be addressed to, what a send can answer, the
6
+ retry rule — decided once, so no transport invents its own.
7
+
8
+ ## One value carries the channel and the address
9
+
10
+ A `(channel, address)` pair can be built wrong: `Sms` beside an email address
11
+ compiles and fails at the provider. `recipient` fuses them, so the wrong pair
12
+ does not exist, and the channel is read back off the value that carries it.
13
+ */
14
+
15
+ /** A delivery route. The selector a recipient chooses per notification kind, and
16
+ the granularity a platform provisions at. */
17
+ type channel =
18
+ | Email
19
+ | Sms
20
+ | Push
21
+
22
+ /** An addressed recipient: the channel and the address it needs, inseparable.
23
+ Each address is the branded scalar for its channel, so an unparseable one is
24
+ refused where it is built rather than by the provider. */
25
+ type recipient =
26
+ | ToEmail(Email.t)
27
+ | ToSms(Phone.t)
28
+ | /** The token the device registered with the push service. Opaque and
29
+ provider-shaped — unlike an address, nobody else has a grammar for it. */
30
+ ToPush({deviceToken: string})
31
+
32
+ /** The channel a recipient is addressed on. */
33
+ let channelOf = (recipient: recipient): channel =>
34
+ switch recipient {
35
+ | ToEmail(_) => Email
36
+ | ToSms(_) => Sms
37
+ | ToPush(_) => Push
38
+ }
39
+
40
+ /** The channel's name, for a message a human reads and for a preference key. */
41
+ let channelToString = (channel: channel): string =>
42
+ switch channel {
43
+ | Email => "Email"
44
+ | Sms => "Sms"
45
+ | Push => "Push"
46
+ }
47
+
48
+ /**
49
+ What to say.
50
+
51
+ `subject` is carried for the channels that have one — an email header, a push
52
+ notification's title — and ignored by those that do not. Optional rather than an
53
+ empty string, so "this message has no subject" and "its subject is blank" stay
54
+ distinguishable.
55
+ */
56
+ type message = {subject?: string, body: string}
57
+
58
+ /** The provider accepted the message. `ref` is its own id for it — what a
59
+ support conversation about a missing message is conducted with, and the only
60
+ thing a caller can record that the provider will recognise. */
61
+ type receipt = {ref: string}
62
+
63
+ /**
64
+ Why a send produced no receipt.
65
+
66
+ Three constructors because the retry decision turns on the distinction, and
67
+ getting it wrong is expensive in both directions: retrying a refused address
68
+ burns the budget on an outcome that will not change, and abandoning a transient
69
+ outage writes off a message that would have gone.
70
+ */
71
+ type failure =
72
+ | /** The provider could not be reached, or refused the call. Retry. */
73
+ Unavailable(string)
74
+ | /** This deployment provisions nothing for this channel. Do not retry — no
75
+ number of attempts provisions one. */
76
+ UnsupportedChannel(channel)
77
+ | /** The provider answered and will not take this message: an address it
78
+ rejects, a recipient it suppresses. Do not retry. */
79
+ Refused(string)
80
+
81
+ /** The retry rule, stated once. Everything that sweeps a failed send derives
82
+ from it rather than re-reading the constructors. */
83
+ let retriable = (failure: failure): bool =>
84
+ switch failure {
85
+ | Unavailable(_) => true
86
+ | UnsupportedChannel(_)
87
+ | Refused(_) => false
88
+ }
89
+
90
+ /** A reason for a human, for a caller that records the outcome rather than
91
+ acting on it. */
92
+ let failureReason = (failure: failure): string =>
93
+ switch failure {
94
+ | Unavailable(reason) => reason
95
+ | UnsupportedChannel(channel) =>
96
+ `this deployment provisions no ${channel->channelToString} channel`
97
+ | Refused(reason) => reason
98
+ }
99
+
100
+ /**
101
+ The port a caller reaches a messaging provider through, so swapping the
102
+ implementation is a change of supplier rather than of call site.
103
+ */
104
+ type send = (~recipient: recipient, ~message: message) => promise<result<receipt, failure>>
105
+
106
+ /**
107
+ The capability as a slice receives it: what can be attempted, and how.
108
+
109
+ `channels` is here because a recipient cannot be offered a choice the deployment
110
+ cannot honour. A platform provisions email only, or email and SMS; a preference
111
+ centre that listed all three would collect a subscription every send then answers
112
+ `UnsupportedChannel` for. Published rather than inferred from a failed send,
113
+ because discovering a channel by failing on it costs a real message.
114
+
115
+ Empty means no channel at all — the shape `none` takes, and the one a deploy-time
116
+ gate exists to catch before it ships.
117
+ */
118
+ type provider = {
119
+ channels: array<channel>,
120
+ send: send,
121
+ }
122
+
123
+ /** Whether this provider can attempt a recipient's channel at all. The check a
124
+ caller makes before spending a send, and the same rule the provider applies
125
+ internally, so the two cannot disagree. */
126
+ let supports = (provider: provider, ~recipient: recipient): bool =>
127
+ provider.channels->Array.includes(recipient->channelOf)
@@ -0,0 +1,57 @@
1
+ // Generated by ReScript, PLEASE EDIT WITH CARE
2
+
3
+
4
+ function channelOf(recipient) {
5
+ switch (recipient.TAG) {
6
+ case "ToEmail" :
7
+ return "Email";
8
+ case "ToSms" :
9
+ return "Sms";
10
+ case "ToPush" :
11
+ return "Push";
12
+ }
13
+ }
14
+
15
+ function channelToString(channel) {
16
+ switch (channel) {
17
+ case "Email" :
18
+ return "Email";
19
+ case "Sms" :
20
+ return "Sms";
21
+ case "Push" :
22
+ return "Push";
23
+ }
24
+ }
25
+
26
+ function retriable(failure) {
27
+ switch (failure.TAG) {
28
+ case "Unavailable" :
29
+ return true;
30
+ case "UnsupportedChannel" :
31
+ case "Refused" :
32
+ return false;
33
+ }
34
+ }
35
+
36
+ function failureReason(failure) {
37
+ switch (failure.TAG) {
38
+ case "UnsupportedChannel" :
39
+ return `this deployment provisions no ` + channelToString(failure._0) + ` channel`;
40
+ case "Unavailable" :
41
+ case "Refused" :
42
+ return failure._0;
43
+ }
44
+ }
45
+
46
+ function supports(provider, recipient) {
47
+ return provider.channels.includes(channelOf(recipient));
48
+ }
49
+
50
+ export {
51
+ channelOf,
52
+ channelToString,
53
+ retriable,
54
+ failureReason,
55
+ supports,
56
+ }
57
+ /* No side effect */
@@ -5,7 +5,7 @@ those units belong to.
5
5
  ## Why the currency travels with the number
6
6
 
7
7
  A minor unit is currency-dependent — ISO 4217 gives EUR two decimal places, **JPY
8
- zero** and **TND three** — so a bare `1000` is €10.00 or ¥1000 or 1.000 TND, and
8
+ zero** and TND three — so a bare `1000` is €10.00 or ¥1000 or 1.000 TND, and
9
9
  there is no way to tell which. It cannot be rendered, compared, summed or
10
10
  sanity-checked without knowing. The currency is part of the number's meaning, not
11
11
  metadata beside it.
@@ -22,6 +22,20 @@ instead — see below.
22
22
  `0.1 +. 0.2` is not `0.3`, and money is summed. Minor units keep every amount an
23
23
  exact integer, so addition is exact and equality means what it says.
24
24
 
25
+ This was tried the other way — an amount holding the decimal a person types —
26
+ and reverted. What it bought was a readable log; what it cost was the guarantee.
27
+ Every sum outside `add` became a float sum, including the ones a read side does
28
+ over a page of rows, and the failure is a figure that is a cent out and looks
29
+ entirely plausible. Binary floating point is not what money is counted in, and
30
+ holding the exact value in a `float` only works while nothing does arithmetic on
31
+ it — which is not a property a framework type can promise on behalf of everyone
32
+ downstream.
33
+
34
+ **The complaint that prompted the attempt was real and is answered elsewhere.** A
35
+ form generated from this schema must not ask anyone for `1050`; it asks for
36
+ `10.50` and converts at its own boundary, with `ofMajor`/`toMajor` below. That is
37
+ a presentation concern, and this type had been letting it leak.
38
+
25
39
  ## Why `float` for a whole number
26
40
 
27
41
  Because ReScript's `int` is int32, and sury enforces that — an `int` amount caps
@@ -72,12 +86,15 @@ let validateAmount = (amount: float): result<float, string> =>
72
86
  Ok(amount)
73
87
  }
74
88
 
75
- /** The amount's own schema. The check sits on the field rather than on the pair
76
- because wholeness is a property of the amount — and because sury 11-alpha
77
- miscompiles a refinement wrapping a *record* schema (it hoists the result
78
- object above the field reads, so both parse and serialize throw
79
- `Cannot access 'v0' before initialization`). Refining the field is both the
80
- honest placement and the one that works. */
89
+ /** The amount's own schema.
90
+
91
+ The check sits on the field rather than on the pair because wholeness is a
92
+ property of the amount: it does not need the currency, and a rule placed
93
+ where it needs nothing else is a rule that cannot be read wrong. (A
94
+ record-level refinement is available — sury 11.0.0-rc.2 compiles one
95
+ correctly, where 11-alpha hoisted the result object above the field reads and
96
+ threw `Cannot access 'v0' before initialization` — it is simply not what this
97
+ check wants.) */
81
98
  let amountSchema: S.t<float> =
82
99
  S.float->S.refine(
83
100
  amount =>
@@ -109,34 +126,41 @@ let make = (~amount: float, ~currency: Currency.t): t => {amount, currency}
109
126
  euros" are different claims, and only the second one adds to a total. */
110
127
  let zero = (~currency: Currency.t): t => {amount: 0.0, currency}
111
128
 
129
+ /** Ten to the power of the currency's decimal count — the factor between a major
130
+ amount and the minor units it is. Named because the two conversions below
131
+ both need it and neither should spell out `Math.pow` again. */
132
+ let scale = (currency: Currency.t): float =>
133
+ Math.pow(10.0, ~exp=Int.toFloat(Currency.exponent(currency)))
134
+
112
135
  /**
113
136
  Convert a major-unit decimal (`10.5`) into minor units (`1050`), using the
114
137
  currency's own exponent.
115
138
 
116
139
  This is the one place a decimal is allowed to become money, and it is here rather
117
140
  than at each call site precisely so that `*. 100.0` is written once and is
118
- correct for JPY and TND — where it would be `*. 1.0` and `*. 1000.0`.
141
+ correct for JPY and TND — where it would be `*. 1.0` and `*. 1000.0`. It is what
142
+ a form, a supplier feed or any other boundary that speaks in decimals converts
143
+ through.
119
144
 
120
- Rounds half away from zero (`10.005` EUR → `1001`), which is what a reader
121
- expects of a price. That is a *boundary conversion* and not an arithmetic
122
- policy: the half-even question that FX and tax rounding turn on is a separate
123
- decision, and nothing here forecloses it.
145
+ Rounds half away from zero, in both directions: `Math.round` alone rounds half
146
+ toward positive infinity, so `-2.5` would come back `-2` and a refund would round
147
+ the other way from the charge it reverses. That is a *boundary conversion* and
148
+ not an arithmetic policy: the half-even question that FX and tax rounding turn on
149
+ is a separate decision, and nothing here forecloses it.
124
150
  */
125
151
  let ofMajor = (~amount: float, ~currency: Currency.t): t => {
126
- let scale = Math.pow(10.0, ~exp=Int.toFloat(Currency.exponent(currency)))
127
- {amount: Math.round(amount *. scale), currency}
152
+ let scaled = amount *. scale(currency)
153
+ {amount: scaled < 0.0 ? -.Math.round(-.scaled) : Math.round(scaled), currency}
128
154
  }
129
155
 
130
- /** The amount as a major-unit decimal — for charts, averages and anything that
131
- has to be a number rather than money. Lossy by nature: the result is a float
132
- again, so it is an output, not something to compute a balance in. */
133
- let toMajor = (m: t): float =>
134
- m.amount /. Math.pow(10.0, ~exp=Int.toFloat(Currency.exponent(m.currency)))
156
+ /** The amount as a major-unit decimal — what a form shows, and what a chart axis
157
+ or an average needs. Lossy by nature: the result is a float again, so it is an
158
+ output, not something to compute a balance in. */
159
+ let toMajor = (m: t): float => m.amount /. scale(m.currency)
135
160
 
136
161
  /**
137
162
  The amount as text: the decimal point placed by the currency's exponent, digits
138
- grouped in threes, and the ISO code after it — `"1,234.50 EUR"`, `"1,000 JPY"`,
139
- `"1.000 TND"`.
163
+ grouped in threes, and the ISO code after it — `"1,234.50 EUR"`, `"1,000 JPY"`.
140
164
 
141
165
  Deliberately locale-independent, matching the rest of the framework's
142
166
  formatters: the same value reads the same in every log line and every test. A
@@ -176,6 +200,9 @@ branded-scalar shape was that mixing currencies could not be written at all; the
176
200
  answer is to *check* it rather than to delete the information that makes checking
177
201
  possible. An aggregate that must hold one currency rejects a line item in
178
202
  another — a decider's concern, and one it can now actually express.
203
+
204
+ The addition itself is integer addition, because both amounts are whole minor
205
+ units. That is the whole reason they are.
179
206
  */
180
207
  let add = (a: t, b: t): result<t, string> =>
181
208
  a.currency == b.currency
@@ -185,6 +212,81 @@ let add = (a: t, b: t): result<t, string> =>
185
212
  `Converting between them needs a rate, which is not something an amount carries.`,
186
213
  )
187
214
 
215
+ /** The largest whole number a `float` holds exactly, 2^53 - 1. Bound here
216
+ because `Float.Constants` does not carry it, and named because it is the
217
+ range every guarantee in this module is stated over. */
218
+ @val @scope("Number") external maxSafeInteger: float = "MAX_SAFE_INTEGER"
219
+
220
+ /**
221
+ Multiply an amount by a count — a line item's unit price by its quantity.
222
+
223
+ `~by` is an `int` because the operand is a *count* and not a rate: three of
224
+ something, not 15% of something. Whole minor units times a whole count is
225
+ whole-number arithmetic, so the result is an exact amount and no rounding
226
+ question arises at all. That is the property this function exists to keep, and it
227
+ is why a call site should not write `amount *. Int.toFloat(quantity)` itself.
228
+
229
+ A rate is the other operation, and deliberately not this one. 15% of €10.50 is
230
+ €8.925, which is not an amount of euros in the first place — so applying a rate
231
+ is a rounding decision the domain has to state, through `toMajor`, the
232
+ arithmetic, and `ofMajor` back, with the rounding visible where someone chose it.
233
+
234
+ Refuses a product past the range where a `float` is an exact integer. Handing
235
+ back an inexact amount is the failure the wholeness check exists to prevent, and
236
+ it would not do to introduce it here.
237
+ */
238
+ let times = (m: t, ~by: int): result<t, string> => {
239
+ let product = m.amount *. Int.toFloat(by)
240
+ Math.abs(product) <= maxSafeInteger
241
+ ? Ok({amount: product, currency: m.currency})
242
+ : Error(
243
+ `cannot multiply ${format(m)} by ${Int.toString(by)}: the result is past the ` ++
244
+ `largest amount that stays exact, ${Float.toString(maxSafeInteger)} minor units.`,
245
+ )
246
+ }
247
+
248
+ /**
249
+ Split an amount into `into` parts that add back up to it.
250
+
251
+ The last minor unit has to go somewhere. Dividing and rounding each part on its
252
+ own loses or invents one — €10.00 into three is €3.33 three times, which is
253
+ €9.99 — so the remainder is handed out instead: the first parts get one minor
254
+ unit more than the rest. €10.00 into three is €3.34, €3.33, €3.33, and those add
255
+ up to €10.00.
256
+
257
+ *Which* parts get the extra unit is arbitrary, but it has to be decided rather
258
+ than left to a rounding mode, and "the earliest" is the decision here. A caller
259
+ that needs it somewhere else — the largest share, the payer's — reorders what it
260
+ gets back. A caller that needs uneven shares wants a split by ratio, which is a
261
+ different function and not this one.
262
+
263
+ A negative amount splits away from zero the same way: -€10.00 into three is
264
+ -€3.34, -€3.33, -€3.33. A refund divides like a charge.
265
+ */
266
+ let allocate = (m: t, ~into: int): result<array<t>, string> =>
267
+ if into <= 0 {
268
+ Error(
269
+ `cannot split ${format(m)} into ${Int.toString(into)} parts: ` ++
270
+ `a split is into at least one part.`,
271
+ )
272
+ } else {
273
+ let parts = Int.toFloat(into)
274
+ // Truncated toward zero, so the remainder carries the sign of the amount and
275
+ // every part stays on the same side of zero as the whole it came from.
276
+ let share = Math.trunc(m.amount /. parts)
277
+ // Taken from the share actually used rather than from the ideal one, which
278
+ // is what makes the parts add back up by construction.
279
+ let remainder = m.amount -. share *. parts
280
+ let step = remainder < 0.0 ? -1.0 : 1.0
281
+ let extra = Math.abs(remainder)
282
+ Ok(
283
+ Array.fromInitializer(~length=into, i => {
284
+ amount: share +. (Int.toFloat(i) < extra ? step : 0.0),
285
+ currency: m.currency,
286
+ }),
287
+ )
288
+ }
289
+
188
290
  /** Add a run of amounts, refusing at the first currency that does not match the
189
291
  first amount's. `None` for an empty run — the sum of no amounts has no
190
292
  currency to be in. */
@@ -54,10 +54,14 @@ function zero(currency) {
54
54
  };
55
55
  }
56
56
 
57
+ function scale(currency) {
58
+ return Math.pow(10.0, Currency$Reventless.exponent(currency));
59
+ }
60
+
57
61
  function ofMajor(amount, currency) {
58
- let scale = Math.pow(10.0, Currency$Reventless.exponent(currency));
62
+ let scaled = amount * Math.pow(10.0, Currency$Reventless.exponent(currency));
59
63
  return {
60
- amount: Math.round(amount * scale),
64
+ amount: scaled < 0.0 ? - Math.round(- scaled) : Math.round(scaled),
61
65
  currency: currency
62
66
  };
63
67
  }
@@ -108,6 +112,46 @@ function add(a, b) {
108
112
  }
109
113
  }
110
114
 
115
+ function times(m, by) {
116
+ let product = m.amount * by;
117
+ if (Math.abs(product) <= Number.MAX_SAFE_INTEGER) {
118
+ return {
119
+ TAG: "Ok",
120
+ _0: {
121
+ amount: product,
122
+ currency: m.currency
123
+ }
124
+ };
125
+ } else {
126
+ return {
127
+ TAG: "Error",
128
+ _0: `cannot multiply ` + format(m) + ` by ` + by.toString() + `: the result is past the ` + (`largest amount that stays exact, ` + Number.MAX_SAFE_INTEGER.toString() + ` minor units.`)
129
+ };
130
+ }
131
+ }
132
+
133
+ function allocate(m, into) {
134
+ if (into <= 0) {
135
+ return {
136
+ TAG: "Error",
137
+ _0: `cannot split ` + format(m) + ` into ` + into.toString() + ` parts: a split is into at least one part.`
138
+ };
139
+ }
140
+ let share = Math.trunc(m.amount / into);
141
+ let remainder = m.amount - share * into;
142
+ let step = remainder < 0.0 ? -1.0 : 1.0;
143
+ let extra = Math.abs(remainder);
144
+ return {
145
+ TAG: "Ok",
146
+ _0: Stdlib_Array.fromInitializer(into, i => ({
147
+ amount: share + (
148
+ i < extra ? step : 0.0
149
+ ),
150
+ currency: m.currency
151
+ }))
152
+ };
153
+ }
154
+
111
155
  function sum(amounts) {
112
156
  if (amounts.length !== 0) {
113
157
  return Stdlib_Array.reduce(amounts, {
@@ -126,10 +170,13 @@ export {
126
170
  schema$1 as schema,
127
171
  make,
128
172
  zero,
173
+ scale,
129
174
  ofMajor,
130
175
  toMajor,
131
176
  format,
132
177
  add,
178
+ times,
179
+ allocate,
133
180
  sum,
134
181
  }
135
182
  /* amountSchema Not a pure module */
@@ -0,0 +1,62 @@
1
+ // What a domain trait says about itself, so that a graft leaves a trace.
2
+ //
3
+ // A graft becomes ordinary host source — that is the design, and it is why every
4
+ // other signal a trait leaves is source-side: the package dependency, the variant
5
+ // spread, the `module X = Trait_Rules` alias, the conformance binding under
6
+ // `tests/`. None of them survives into a deployed plugin, so a running estate
7
+ // cannot answer "which of my components came from a trait" at all.
8
+ //
9
+ // This is the one fact that does survive, because it travels the way
10
+ // `capabilityNeeds` does: a value the trait exports and the host names, collected
11
+ // into `pluginStructure` while it is assembled and re-emitted whole on every
12
+ // registration.
13
+ //
14
+ // **Nothing here is typed by a human.** The trait exports its own identity, so a
15
+ // renamed or removed trait is a build error rather than a stale row; the version
16
+ // is read from the trait's own package rather than restated; and the component is
17
+ // filled in by the structure, which is the only party that knows which component
18
+ // declared it. That is the whole reason this is a value rather than an
19
+ // annotation — an attribute's fields would all be strings the compiler never
20
+ // checks, which is the failure this program exists to remove.
21
+ //
22
+ // **A declaration is a claim about origin, never about behaviour.** After a graft
23
+ // the developer owns the files and may edit them freely. So this says "grafted
24
+ // from trait X", and it does NOT say "still behaves like trait X" — the thing that
25
+ // answers the second question is the trait's conformance suite, which runs in the
26
+ // consumer's build and reports separately. A reader that paints a declared graft
27
+ // as verified is drawing a conclusion this field cannot support.
28
+
29
+ /** Whether the graft reports back into the host it is grafted onto.
30
+
31
+ `WritesBack` publishes commands to its host — the geocoding shape, where the
32
+ slice reports its answer back onto the aggregate. `Observes` reads host events
33
+ and writes nothing back. `SelfContained` brings its own components and grafts
34
+ only by reading — the notification shape, which is what broke the
35
+ write-back assumption the first two specimens shared. */
36
+ type posture =
37
+ | WritesBack
38
+ | Observes
39
+ | SelfContained
40
+
41
+ let postureToString = (p: posture): string =>
42
+ switch p {
43
+ | WritesBack => "WritesBack"
44
+ | Observes => "Observes"
45
+ | SelfContained => "SelfContained"
46
+ }
47
+
48
+ /**
49
+ One trait's own account of itself, exported by the trait package.
50
+
51
+ `version` is read from the trait's package at load time rather than restated in
52
+ source — see `PackageVersion.fromModuleUrl`, which the certification CLI already
53
+ resolves trait versions with. A trait whose package.json cannot be found reports
54
+ `"0.0.0"`, which is visibly wrong rather than quietly stale.
55
+ */
56
+ type t = {
57
+ /** The package, as it is depended on: `"@reventlessdev/trait-attachments"`. */
58
+ trait: string,
59
+ /** Resolved from the trait's own package, never written by hand. */
60
+ version: string,
61
+ posture: posture,
62
+ }
@@ -0,0 +1,18 @@
1
+ // Generated by ReScript, PLEASE EDIT WITH CARE
2
+
3
+
4
+ function postureToString(p) {
5
+ switch (p) {
6
+ case "WritesBack" :
7
+ return "WritesBack";
8
+ case "Observes" :
9
+ return "Observes";
10
+ case "SelfContained" :
11
+ return "SelfContained";
12
+ }
13
+ }
14
+
15
+ export {
16
+ postureToString,
17
+ }
18
+ /* No side effect */
@@ -0,0 +1,71 @@
1
+ // The lifecycle edge a command owns, declared as a value rather than as an
2
+ // attribute on the constructor.
3
+ //
4
+ // `@transition([Orders.Placed] => Orders.Shipped)` says the same thing and is
5
+ // still the shorter spelling for a command a host declares itself. It cannot say
6
+ // it for a command a host did NOT declare: a variant spread splices members,
7
+ // while the annotation lowers to a dict on the parent union, so a spliced
8
+ // command arrives carrying no edge at all. Nor can the annotation be checked —
9
+ // the PPX extracts leaf identifiers as strings, and the states belong to another
10
+ // component's enum, so a misspelling survives to the plugin structure.
11
+ //
12
+ // A `command => t<'state>` switch answers both. It is exhaustive, so a spliced
13
+ // constructor is a compile error until the host says what it does; and `'state`
14
+ // is the view's own lifecycle enum, so `Customers.Active` is a constructor the
15
+ // compiler resolves rather than a string nobody reads.
16
+ //
17
+ // `'state` is one type across the whole switch, which is a third thing the
18
+ // annotation cannot do: every arm of one component's edges must name the same
19
+ // lifecycle, and a from-set drawn from one enum with a target from another does
20
+ // not compile.
21
+ //
22
+ // The type stays parameterised all the way down rather than storing names,
23
+ // because erasing a constructor to its own name means asserting its runtime
24
+ // representation — and this is the module that exists so nothing has to be
25
+ // asserted. The erasure happens once, at the framework's type-erasure boundary
26
+ // (`Plugin_Structure`), which already reads every spec member that way.
27
+ //
28
+ // The reference costs nothing at run time. A lifecycle enum's arms are
29
+ // payload-less, so `[Customers.Active]` compiles to `["Active"]` and the
30
+ // generated module imports nothing from the view — which is also why it cannot
31
+ // cycle: a view spec holds no reference back to the aggregate it projects.
32
+ //
33
+ // Read the same way `commandAuthorization` is: `Plugin_Structure.toCommandDef`
34
+ // evaluates it against a synthetic value per constructor.
35
+ //
36
+ // No `@schema`: nothing serialises a transition. It is read once, while the
37
+ // plugin structure is assembled, and what leaves is the pair of names the
38
+ // structure already carried.
39
+ type t<'state> =
40
+ /** No edge declared: legal in every state, moves the row nowhere. The honest
41
+ answer for a report a slice publishes, which must not be refused because
42
+ the row moved on while the report was in flight. */
43
+ | Unrestricted
44
+ /** Brings the row into existence, so there is no state it could come from.
45
+ Distinct from `Unrestricted`, which draws no edge at all. */
46
+ | Creates('state)
47
+ /** Legal in these states, and moves the row nowhere. A positive claim rather
48
+ than an omission. */
49
+ | Guards(array<'state>)
50
+ /** Legal in these states, and lands the row in that one. */
51
+ | Moves(array<'state>, 'state)
52
+
53
+ /** The from-set, or `None` for a command that names no states to come from —
54
+ which `Creates` and `Unrestricted` both do, for different reasons the target
55
+ tells apart. */
56
+ let allowedStates = (transition: t<'state>): option<array<'state>> =>
57
+ switch transition {
58
+ | Unrestricted
59
+ | Creates(_) => None
60
+ | Guards(states)
61
+ | Moves(states, _) => Some(states)
62
+ }
63
+
64
+ /** The state the command's handler writes, or `None` for one that moves nothing. */
65
+ let targetState = (transition: t<'state>): option<'state> =>
66
+ switch transition {
67
+ | Unrestricted
68
+ | Guards(_) => None
69
+ | Creates(state)
70
+ | Moves(_, state) => Some(state)
71
+ }
@@ -0,0 +1,36 @@
1
+ // Generated by ReScript, PLEASE EDIT WITH CARE
2
+
3
+ import * as Primitive_option from "@rescript/runtime/lib/es6/Primitive_option.js";
4
+
5
+ function allowedStates(transition) {
6
+ if (typeof transition !== "object") {
7
+ return;
8
+ }
9
+ switch (transition.TAG) {
10
+ case "Creates" :
11
+ return;
12
+ case "Guards" :
13
+ case "Moves" :
14
+ return transition._0;
15
+ }
16
+ }
17
+
18
+ function targetState(transition) {
19
+ if (typeof transition !== "object") {
20
+ return;
21
+ }
22
+ switch (transition.TAG) {
23
+ case "Creates" :
24
+ return Primitive_option.some(transition._0);
25
+ case "Guards" :
26
+ return;
27
+ case "Moves" :
28
+ return Primitive_option.some(transition._1);
29
+ }
30
+ }
31
+
32
+ export {
33
+ allowedStates,
34
+ targetState,
35
+ }
36
+ /* No side effect */