@reventlessdev/online-shop-hybrid-ordering 1.0.0-alpha.235 → 1.0.0-alpha.237

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 (35) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/package.json +11 -11
  3. package/src/CatalogProduct/StateChange/SyncCatalogProduct.res +3 -14
  4. package/src/CatalogProduct/StateChange/SyncCatalogProduct_Behavior.res +3 -3
  5. package/src/CatalogProduct/StateViewStream/AvailableProducts_Projection.res +1 -2
  6. package/src/Customer/OutboundTranslation/GeocodeCustomerAddress_Translation.res +8 -10
  7. package/src/Customer/ReadModelStream/Customers_Projections.res +1 -1
  8. package/src/Extension/Products_Extension.res +2 -4
  9. package/src/ExtensionPoint/Orders_ExtensionPointMapping.res +12 -12
  10. package/src/LifecycleModel.res +70 -10
  11. package/src/Notification/Automation/NotificationIntake.res +11 -11
  12. package/src/Notification/Automation/NotificationIntake_Automation.res +2 -1
  13. package/src/Notification/OutboundTranslation/AnnounceRecipientContact_Translation.res +3 -2
  14. package/src/Notification/OutboundTranslation/SendNotification_Translation.res +22 -14
  15. package/src/Notification/OutboundTranslation/SendNotification_Translation.res.mjs +2 -5
  16. package/src/Notification/StateChange/NotificationPreferences_Behavior.res +12 -3
  17. package/src/Notification/StateViewStream/NotificationDeliveries_Projection.res +35 -14
  18. package/src/Order/StateChange/CancelOrder.res +1 -4
  19. package/src/Order/StateChange/PlaceOrder.res +50 -48
  20. package/src/Order/StateChange/PlaceOrder.res.mjs +2 -1
  21. package/src/Order/StateChange/PlaceOrder_Behavior.res +6 -7
  22. package/src/Order/StateChange/ShipOrder.res +2 -4
  23. package/src/Plugin.res +140 -25
  24. package/tests/CatalogProduct/StateChange/SyncCatalogProduct_GWT.res +8 -2
  25. package/tests/Customer/AddressGeocodingConformance_GWT.res +8 -11
  26. package/tests/Customer/OutboundTranslation/GeocodeCustomerAddress_GWT.res +26 -14
  27. package/tests/Customer/ReadModelStream/Customers_GWT.res +3 -1
  28. package/tests/Flow/OrderingFlow_GWT.res +12 -6
  29. package/tests/Notification/Automation/NotificationIntake_GWT.res +3 -2
  30. package/tests/Notification/NotificationConformance_GWT.res +94 -84
  31. package/tests/Notification/OutboundTranslation/SendNotification_GWT.res +30 -8
  32. package/tests/Notification/OutboundTranslation/SendNotification_GWT.res.mjs +20 -4
  33. package/tests/Notification/StateViewStream/NotificationDeliveries_GWT.res +9 -10
  34. package/tests/Order/StateChange/PlaceOrder_GWT.res +24 -22
  35. package/tests/Order/StateViewStream/Orders_GWT.res +5 -4
package/CHANGELOG.md CHANGED
@@ -3,6 +3,21 @@
3
3
  All notable changes to this project will be documented in this file.
4
4
  See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
5
5
 
6
+ # 1.0.0-alpha.237 (2026-09-09)
7
+
8
+ **Note:** Version bump only for package @reventlessdev/online-shop-hybrid-ordering
9
+
10
+
11
+
12
+
13
+
14
+ # 1.0.0-alpha.236 (2026-09-08)
15
+
16
+ ### Features
17
+
18
+ * **spec:** a field declares the value a form opens it on ([1d541fe](https://github.com/ReventlessDev/reventless-core/commit/1d541fe497e1df2ff48b72e26a06a1b8fd3c9e06))
19
+
20
+
6
21
  # 1.0.0-alpha.235 (2026-09-08)
7
22
 
8
23
  ### Features
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reventlessdev/online-shop-hybrid-ordering",
3
- "version": "1.0.0-alpha.235",
3
+ "version": "1.0.0-alpha.237",
4
4
  "description": "Online Shop Hybrid example — Ordering plugin (Customer aggregate + Order/CatalogProduct DCB)",
5
5
  "license": "Apache-2.0",
6
6
  "jest": {
@@ -21,20 +21,20 @@
21
21
  },
22
22
  "dependencies": {
23
23
  "sury": "11.0.0-rc.2",
24
- "@reventlessdev/online-shop-hybrid-catalog-spec": "1.0.0-alpha.128",
25
- "@reventlessdev/online-shop-hybrid-ordering-spec": "1.0.0-alpha.128",
26
- "@reventlessdev/reventless-infra": "3.0.0-alpha.160",
27
- "@reventlessdev/rescript-pulumi-pulumi": "2.3.0-alpha.19",
28
- "@reventlessdev/reventless-gwt": "1.0.0-alpha.206",
29
- "@reventlessdev/reventless-local": "3.0.0-alpha.248",
30
- "@reventlessdev/reventless-spec": "3.0.0-alpha.132",
31
- "@reventlessdev/trait-address-geocoding": "1.0.0-alpha.8",
32
- "@reventlessdev/trait-notification": "1.0.0-alpha.8"
24
+ "@reventlessdev/online-shop-hybrid-catalog-spec": "1.0.0-alpha.130",
25
+ "@reventlessdev/online-shop-hybrid-ordering-spec": "1.0.0-alpha.130",
26
+ "@reventlessdev/rescript-pulumi-pulumi": "2.3.0-alpha.20",
27
+ "@reventlessdev/reventless-gwt": "1.0.0-alpha.208",
28
+ "@reventlessdev/reventless-infra": "3.0.0-alpha.162",
29
+ "@reventlessdev/reventless-local": "3.0.0-alpha.250",
30
+ "@reventlessdev/reventless-spec": "3.0.0-alpha.134",
31
+ "@reventlessdev/trait-address-geocoding": "1.0.0-alpha.10",
32
+ "@reventlessdev/trait-notification": "1.0.0-alpha.10"
33
33
  },
34
34
  "devDependencies": {
35
35
  "rescript": "12.3.0",
36
36
  "sury-ppx": "11.0.0-rc.2",
37
- "@reventlessdev/reventless-ppx": "1.0.0-alpha.78"
37
+ "@reventlessdev/reventless-ppx": "1.0.0-alpha.80"
38
38
  },
39
39
  "peerDependencies": {
40
40
  "rescript": "12.3.0"
@@ -35,25 +35,14 @@ type error = unit // always succeeds — sync is idempotent
35
35
 
36
36
  @schema
37
37
  type event =
38
- | CatalogProductSynced({
39
- productId: string,
40
- name: string,
41
- price: Reventless.Money.t,
42
- })
43
- | CatalogProductPriceChanged({
44
- productId: string,
45
- price: Reventless.Money.t,
46
- })
38
+ | CatalogProductSynced({productId: string, name: string, price: Reventless.Money.t})
39
+ | CatalogProductPriceChanged({productId: string, price: Reventless.Money.t})
47
40
  | CatalogProductWithdrawn({productId: string})
48
41
  // Carries the shadow's name and price so the projection can restore the row
49
42
  // without a second read: the fold has them in hand at exactly the moment the
50
43
  // decision is made, and an event that states what it caused is what makes the
51
44
  // projection a pure mapping.
52
- | CatalogProductRelisted({
53
- productId: string,
54
- name: string,
55
- price: Reventless.Money.t,
56
- })
45
+ | CatalogProductRelisted({productId: string, name: string, price: Reventless.Money.t})
57
46
  | CatalogProductImageChanged({
58
47
  productId: string,
59
48
  @storageRef("Catalog.productImages") productImage?: Reventless.UploadableImage.t,
@@ -24,7 +24,7 @@ let evolve = (state, event) =>
24
24
  | CatalogProductPriceChanged({price}) => {...state, price: Some(price)}
25
25
  | CatalogProductWithdrawn => {...state, withdrawn: true}
26
26
  | CatalogProductRelisted => {...state, withdrawn: false}
27
- | CatalogProductImageChanged({productImage: ?productImage}) => {...state, productImage}
27
+ | CatalogProductImageChanged({?productImage}) => {...state, productImage}
28
28
  }
29
29
 
30
30
  let decide = (state, command) =>
@@ -62,10 +62,10 @@ let decide = (state, command) =>
62
62
  // ordinary shape of at-least-once delivery, not a change.
63
63
  // Compared as options, so "the picture is gone" is recognised as a change and
64
64
  // re-announcing the same absence is not.
65
- | ChangeSyncedProductImage({productId, productImage: ?productImage}) =>
65
+ | ChangeSyncedProductImage({productId, ?productImage}) =>
66
66
  if state.productImage == productImage {
67
67
  Ok([])
68
68
  } else {
69
- Ok([CatalogProductImageChanged({productId, productImage: ?productImage})])
69
+ Ok([CatalogProductImageChanged({productId, ?productImage})])
70
70
  }
71
71
  }
@@ -3,8 +3,7 @@
3
3
  let project = ({event}) =>
4
4
  switch event {
5
5
  | CatalogProductSynced({productId, name, price}) => [Set(productId, {productId, name, price})]
6
- | CatalogProductPriceChanged({productId, price}) =>
7
- [Update(productId, p => {...p, price})]
6
+ | CatalogProductPriceChanged({productId, price}) => [Update(productId, p => {...p, price})]
8
7
  // `Delete`, not a flag. This view answers "what can I order", so a withdrawn
9
8
  // product leaves it. Marking it instead — `@retired` on the shopper view —
10
9
  // would look tidy and be wrong twice over: a shopper is not elevated, so the
@@ -21,19 +21,17 @@ let translate = async (_id, item, ~capabilities: Reventless.Capabilities.t) =>
21
21
  ~capabilities,
22
22
  ~located=(~point, ~resolvedFrom) => SetLocation({location: point, resolvedFrom}),
23
23
  // A verdict for a human, not a coordinate.
24
- ~unresolvable=(~subject, ~reason) =>
25
- MarkAddressUnresolvable({address: subject, reason}),
24
+ ~unresolvable=(~subject, ~reason) => MarkAddressUnresolvable({address: subject, reason}),
26
25
  )
27
26
  )->Result.map(command => Some((item.customerId, command)))
28
27
 
29
28
  // The budget is spent and the geocoder never answered. Recording the verdict
30
29
  // beats leaving the TODO pending forever — and it is why the capability must be
31
30
  // declared, since an unprovisioned geocoder reaches here every time.
32
- let onExhausted = (_id, item: outboundItem, ~lastError) =>
33
- Some((
34
- item.customerId,
35
- MarkAddressUnresolvable({
36
- address: item.address,
37
- reason: Geocode.exhaustedReason(lastError),
38
- }),
39
- ))
31
+ let onExhausted = (_id, item: outboundItem, ~lastError) => Some((
32
+ item.customerId,
33
+ MarkAddressUnresolvable({
34
+ address: item.address,
35
+ reason: Geocode.exhaustedReason(lastError),
36
+ }),
37
+ ))
@@ -71,7 +71,7 @@ module CustomerOrdersMapping = Mapping.Make(
71
71
  UpdateWithDefault(
72
72
  customerId,
73
73
  {
74
- Customers.customerId: customerId,
74
+ Customers.customerId,
75
75
  email: "",
76
76
  address: "",
77
77
  // No address from this side; `Registered` fills it in when it arrives.
@@ -25,10 +25,8 @@ module Mapping = {
25
25
  | ProductRelisted({productId: theId}) => [
26
26
  PublishStateChangeSliceCommand(RelistSyncedProduct({productId: theId})),
27
27
  ]
28
- | ProductImageChanged({productId, productImage: ?productImage}) => [
29
- PublishStateChangeSliceCommand(
30
- ChangeSyncedProductImage({productId, productImage: ?productImage}),
31
- ),
28
+ | ProductImageChanged({productId, ?productImage}) => [
29
+ PublishStateChangeSliceCommand(ChangeSyncedProductImage({productId, ?productImage})),
32
30
  ]
33
31
  }
34
32
 
@@ -15,18 +15,18 @@ module Delegate = {
15
15
 
16
16
  let mapIncomingCommand = (_id, _command, _meta) => []
17
17
 
18
- let mapOutgoingEvent = Some((_id, event, _meta, _queryEngine) =>
19
- switch event {
20
- | Delegate.OrderPlaced({orderId, customerId, productIds}) =>
21
- productIds->Array.map(pid =>
22
- PublishEvent(
18
+ let mapOutgoingEvent = Some(
19
+ (_id, event, _meta, _queryEngine) =>
20
+ switch event {
21
+ | Delegate.OrderPlaced({orderId, customerId, productIds}) =>
22
+ productIds->Array.map(pid => PublishEvent(
23
23
  pid,
24
24
  OrderingSpec.Orders_ExtensionPoint.ItemOrdered({productId: pid, orderId, customerId}),
25
- )
26
- )
27
- | Delegate.OrderCancelled({orderId, productIds}) =>
28
- productIds->Array.map(pid =>
29
- PublishEvent(pid, OrderingSpec.Orders_ExtensionPoint.ItemOrderCancelled({productId: pid, orderId}))
30
- )
31
- }
25
+ ))
26
+ | Delegate.OrderCancelled({orderId, productIds}) =>
27
+ productIds->Array.map(pid => PublishEvent(
28
+ pid,
29
+ OrderingSpec.Orders_ExtensionPoint.ItemOrderCancelled({productId: pid, orderId}),
30
+ ))
31
+ },
32
32
  )
@@ -7,14 +7,74 @@
7
7
  // annotation where it is silent.
8
8
 
9
9
  let model: array<Reventless.Plugin.derivedEdge> = [
10
- {component: "CancelOrder", command: "CancelOrder", level: Reventless.Plugin.Instance, allowedStates: ["Placed"], targets: ["Cancelled"]},
11
- {component: "CancelOrder", command: "ReopenOrder", level: Reventless.Plugin.Instance, allowedStates: ["Cancelled"], targets: ["Placed"]},
12
- {component: "Customer", command: "Deactivate", level: Reventless.Plugin.Instance, allowedStates: ["Active"], targets: ["Deactivated"]},
13
- {component: "Customer", command: "Reactivate", level: Reventless.Plugin.Instance, allowedStates: ["Deactivated"], targets: ["Active"]},
14
- {component: "Customer", command: "Register", level: Reventless.Plugin.Collection, allowedStates: [], targets: ["Active"]},
15
- {component: "Customer", command: "SetAddressLocation", level: Reventless.Plugin.Instance, allowedStates: ["Active"], targets: []},
16
- {component: "Customer", command: "UpdateAddress", level: Reventless.Plugin.Instance, allowedStates: ["Active"], targets: []},
17
- {component: "Customer", command: "UpdateEmail", level: Reventless.Plugin.Instance, allowedStates: ["Active"], targets: []},
18
- {component: "PlaceOrder", command: "PlaceOrder", level: Reventless.Plugin.Collection, allowedStates: [], targets: ["Placed"]},
19
- {component: "ShipOrder", command: "ShipOrder", level: Reventless.Plugin.Instance, allowedStates: ["Placed"], targets: ["Shipped"]},
10
+ {
11
+ component: "CancelOrder",
12
+ command: "CancelOrder",
13
+ level: Reventless.Plugin.Instance,
14
+ allowedStates: ["Placed"],
15
+ targets: ["Cancelled"],
16
+ },
17
+ {
18
+ component: "CancelOrder",
19
+ command: "ReopenOrder",
20
+ level: Reventless.Plugin.Instance,
21
+ allowedStates: ["Cancelled"],
22
+ targets: ["Placed"],
23
+ },
24
+ {
25
+ component: "Customer",
26
+ command: "Deactivate",
27
+ level: Reventless.Plugin.Instance,
28
+ allowedStates: ["Active"],
29
+ targets: ["Deactivated"],
30
+ },
31
+ {
32
+ component: "Customer",
33
+ command: "Reactivate",
34
+ level: Reventless.Plugin.Instance,
35
+ allowedStates: ["Deactivated"],
36
+ targets: ["Active"],
37
+ },
38
+ {
39
+ component: "Customer",
40
+ command: "Register",
41
+ level: Reventless.Plugin.Collection,
42
+ allowedStates: [],
43
+ targets: ["Active"],
44
+ },
45
+ {
46
+ component: "Customer",
47
+ command: "SetAddressLocation",
48
+ level: Reventless.Plugin.Instance,
49
+ allowedStates: ["Active"],
50
+ targets: [],
51
+ },
52
+ {
53
+ component: "Customer",
54
+ command: "UpdateAddress",
55
+ level: Reventless.Plugin.Instance,
56
+ allowedStates: ["Active"],
57
+ targets: [],
58
+ },
59
+ {
60
+ component: "Customer",
61
+ command: "UpdateEmail",
62
+ level: Reventless.Plugin.Instance,
63
+ allowedStates: ["Active"],
64
+ targets: [],
65
+ },
66
+ {
67
+ component: "PlaceOrder",
68
+ command: "PlaceOrder",
69
+ level: Reventless.Plugin.Collection,
70
+ allowedStates: [],
71
+ targets: ["Placed"],
72
+ },
73
+ {
74
+ component: "ShipOrder",
75
+ command: "ShipOrder",
76
+ level: Reventless.Plugin.Instance,
77
+ allowedStates: ["Placed"],
78
+ targets: ["Shipped"],
79
+ },
20
80
  ]
@@ -23,17 +23,17 @@ type todoItem = {ruleId: string, recipientId: string, orderId: string}
23
23
 
24
24
  @schema
25
25
  type command =
26
- RequestNotification({
27
- recipientId: string,
28
- category: NotificationPreferences.category,
29
- reference: string,
30
- subjectType: string,
31
- subjectRef: string,
32
- subject: string,
33
- body: string,
34
- sourceId: string,
35
- origin: NotificationPreferences.origin,
36
- })
26
+ | RequestNotification({
27
+ recipientId: string,
28
+ category: NotificationPreferences.category,
29
+ reference: string,
30
+ subjectType: string,
31
+ subjectRef: string,
32
+ subject: string,
33
+ body: string,
34
+ sourceId: string,
35
+ origin: NotificationPreferences.origin,
36
+ })
37
37
 
38
38
  let maxRetries = 3
39
39
  let heartbeatInterval = 60
@@ -112,7 +112,8 @@ module FromOrderingDcb = Mapping.Make(
112
112
  | NotificationDeferred({reference}) =>
113
113
  Some(reference)
114
114
  | OrderPlaced(_)
115
- | OrderShipped(_) => None
115
+ | OrderShipped(_) =>
116
+ None
116
117
  }
117
118
  },
118
119
  )
@@ -14,8 +14,9 @@ let collect = (event, ~sourceId) =>
14
14
  // item completes on this returning `Ok`, which is what makes the directory's own
15
15
  // `AnnounceRecipient` free to be idempotent: a re-announced address publishes no
16
16
  // event, and no row is left waiting for one.
17
- let translate = async (_id, item: outboundItem, ~capabilities as _) =>
18
- Ok(Some((item.recipientId, AnnounceRecipient({recipientId: item.recipientId, email: item.email}))))
17
+ let translate = async (_id, item: outboundItem, ~capabilities as _) => Ok(
18
+ Some((item.recipientId, AnnounceRecipient({recipientId: item.recipientId, email: item.email}))),
19
+ )
19
20
 
20
21
  // Unreachable in practice — `translate` cannot fail — but a slice that stays
21
22
  // silent has to say so on purpose. If a publish failure ever did exhaust the
@@ -20,15 +20,24 @@ let recipientFor = (item: outboundItem) =>
20
20
  ->Reventless.Email.fromString
21
21
  ->Result.map(email => Reventless.Messaging.ToEmail(email))
22
22
  | Sms =>
23
- item.address->Reventless.Phone.fromString->Result.map(phone => Reventless.Messaging.ToSms(phone))
24
- | Push => Ok(Reventless.Messaging.ToPush({deviceToken: item.address}))
23
+ item.address
24
+ ->Reventless.Phone.fromString
25
+ ->Result.map(phone => Reventless.Messaging.ToSms(phone))
26
+ // The directory stores a channel beside one flat address, and no push address is
27
+ // one string: two services issue a token, Web Push issues an endpoint with its
28
+ // encryption keys, and which service issued this one is not recorded either. So
29
+ // the pair cannot be re-fused, and refusing says so once rather than guessing a
30
+ // service and failing at whichever provider. A push transport arrives with the
31
+ // stored shape it needs, in the commit that justifies the columns.
32
+ | Push =>
33
+ Error("a push address names its issuing service, and the directory stores one flat address")
25
34
  }
26
35
 
27
36
  let translate = async (_id, item: outboundItem, ~capabilities: Reventless.Capabilities.t) =>
28
37
  switch recipientFor(item) {
29
- // An address the directory holds that its own channel's grammar refuses. Not
30
- // retryable and not the provider's fault, so it is recorded as a failure
31
- // rather than raised: the recipient's row is what needs fixing.
38
+ // An address the directory holds that its own channel cannot use. Not retryable
39
+ // and not the provider's fault, so it is recorded as a failure rather than
40
+ // raised: the recipient's row is what needs fixing.
32
41
  | Error(why) =>
33
42
  Ok(
34
43
  Some((
@@ -78,12 +87,11 @@ let translate = async (_id, item: outboundItem, ~capabilities: Reventless.Capabi
78
87
  // The budget is spent and the provider never answered. Recording it beats leaving
79
88
  // the row pending forever — and it is the second reason the capability must be
80
89
  // declared, since an unprovisioned sender reaches here every single time.
81
- let onExhausted = (_id, item: outboundItem, ~lastError) =>
82
- Some((
83
- item.recipientId,
84
- RecordDeliveryFailure({
85
- recipientId: item.recipientId,
86
- reference: item.reference,
87
- reason: lastError->Option.getOr("the messaging provider never answered"),
88
- }),
89
- ))
90
+ let onExhausted = (_id, item: outboundItem, ~lastError) => Some((
91
+ item.recipientId,
92
+ RecordDeliveryFailure({
93
+ recipientId: item.recipientId,
94
+ reference: item.reference,
95
+ reason: lastError->Option.getOr("the messaging provider never answered"),
96
+ }),
97
+ ))
@@ -36,11 +36,8 @@ function recipientFor(item) {
36
36
  }));
37
37
  case "Push" :
38
38
  return {
39
- TAG: "Ok",
40
- _0: {
41
- TAG: "ToPush",
42
- deviceToken: item.address
43
- }
39
+ TAG: "Error",
40
+ _0: "a push address names its issuing service, and the directory stores one flat address"
44
41
  };
45
42
  }
46
43
  }
@@ -82,7 +82,8 @@ let evolve = (state, event: consumedEvent) =>
82
82
  let named = (recipientId, fact: Rules.fact) =>
83
83
  switch fact {
84
84
  | Claimed(_)
85
- | Released(_) => None
85
+ | Released(_) =>
86
+ None
86
87
  | Deferred({reference, source}) =>
87
88
  Some(NotificationDeferred({recipientId, reference, sourceKey: source}))
88
89
  | Announced({address}) => Some(RecipientAnnounced({recipientId, email: address}))
@@ -155,9 +156,17 @@ let decide = (state, command) =>
155
156
  | AnnounceRecipient({recipientId, email}) =>
156
157
  through(state, recipientId, Announce({channel: Email, address: email}))
157
158
  | Subscribe({recipientId, category, channel}) =>
158
- through(state, recipientId, Subscribe({category: categoryKey(category), channel: channelKey(channel)}))
159
+ through(
160
+ state,
161
+ recipientId,
162
+ Subscribe({category: categoryKey(category), channel: channelKey(channel)}),
163
+ )
159
164
  | Unsubscribe({recipientId, category, channel}) =>
160
- through(state, recipientId, Unsubscribe({category: categoryKey(category), channel: channelKey(channel)}))
165
+ through(
166
+ state,
167
+ recipientId,
168
+ Unsubscribe({category: categoryKey(category), channel: channelKey(channel)}),
169
+ )
161
170
 
162
171
  // The one arm the mapping cannot be pure about: the words and the subject
163
172
  // belong to the requester, and the trait's facts carry neither, because a trait
@@ -21,7 +21,15 @@ let channelName = (channel: NotificationPreferences.channel) =>
21
21
  // dropping it is what makes that visible instead of fabricating a half-row.
22
22
  let project = ({event, meta}) =>
23
23
  switch event {
24
- | NotificationRequested({recipientId, category, reference, channel, subjectType, subjectRef, origin}) => [
24
+ | NotificationRequested({
25
+ recipientId,
26
+ category,
27
+ reference,
28
+ channel,
29
+ subjectType,
30
+ subjectRef,
31
+ origin,
32
+ }) => [
25
33
  Set(
26
34
  reference,
27
35
  {
@@ -60,7 +68,14 @@ let project = ({event, meta}) =>
60
68
  },
61
69
  ),
62
70
  ]
63
- | NotificationUndeliverable({recipientId, category, reference, subjectType, subjectRef, origin}) => [
71
+ | NotificationUndeliverable({
72
+ recipientId,
73
+ category,
74
+ reference,
75
+ subjectType,
76
+ subjectRef,
77
+ origin,
78
+ }) => [
64
79
  Set(
65
80
  reference,
66
81
  {
@@ -79,19 +94,25 @@ let project = ({event, meta}) =>
79
94
  ),
80
95
  ]
81
96
  | NotificationDelivered({reference, providerRef}) => [
82
- Update(reference, state => {
83
- ...state,
84
- outcome: Delivered,
85
- detail: providerRef,
86
- settledAt: Some(meta.time),
87
- }),
97
+ Update(
98
+ reference,
99
+ state => {
100
+ ...state,
101
+ outcome: Delivered,
102
+ detail: providerRef,
103
+ settledAt: Some(meta.time),
104
+ },
105
+ ),
88
106
  ]
89
107
  | NotificationFailed({reference, reason}) => [
90
- Update(reference, state => {
91
- ...state,
92
- outcome: Failed,
93
- detail: reason,
94
- settledAt: Some(meta.time),
95
- }),
108
+ Update(
109
+ reference,
110
+ state => {
111
+ ...state,
112
+ outcome: Failed,
113
+ detail: reason,
114
+ settledAt: Some(meta.time),
115
+ },
116
+ ),
96
117
  ]
97
118
  }
@@ -23,10 +23,7 @@ type error =
23
23
 
24
24
  @schema
25
25
  type event =
26
- | OrderCancelled({
27
- orderId: string,
28
- productIds: array<string>,
29
- })
26
+ | OrderCancelled({orderId: string, productIds: array<string>})
30
27
  | OrderReopened({orderId: string})
31
28
 
32
29
  // `ReopenOrder` is the way back out of `Cancelled`, and a real edge of the
@@ -47,28 +47,30 @@ type shippingMethod =
47
47
  @schema
48
48
  type lineItem = {
49
49
  @ref("AvailableProducts") productId: string,
50
- quantity: int,
50
+ // A line is for one of the thing unless the shopper says otherwise. Without
51
+ // it a checkout seeded from a picked shelf opens each line blank.
52
+ @default(1) quantity: int,
51
53
  }
52
54
 
53
55
  @schema
54
56
  type command =
55
- PlaceOrder({
56
- @partitionTag orderId: string,
57
- // customerId is payload, not a query key — @noDcbTag stops it auto-tagging.
58
- // It is also the order's owner: the resolver overwrites this with the
59
- // authenticated caller's id before the command is published, so what a
60
- // client sends here is ignored rather than trusted. An operator placing an
61
- // order on someone's behalf is exempt and keeps the value they sent.
62
- @noDcbTag @owner customerId: string,
63
- lineItems: array<lineItem>,
64
- shippingMethod: shippingMethod,
65
- // A requested delivery slot, chosen at checkout. An optional field — a
66
- // Pickup order (or a caller that names no preference) simply omits it, and
67
- // an order placed before this field existed carries no key, so adding it
68
- // costs the log nothing (the additive path in the plan's adoption table).
69
- // One `DateRange.t`, not a guessed `start*`/`end*` name pair.
70
- deliveryWindow?: Reventless.DateRange.t,
71
- })
57
+ | PlaceOrder({
58
+ @partitionTag orderId: string,
59
+ // customerId is payload, not a query key — @noDcbTag stops it auto-tagging.
60
+ // It is also the order's owner: the resolver overwrites this with the
61
+ // authenticated caller's id before the command is published, so what a
62
+ // client sends here is ignored rather than trusted. An operator placing an
63
+ // order on someone's behalf is exempt and keeps the value they sent.
64
+ @noDcbTag @owner customerId: string,
65
+ lineItems: array<lineItem>,
66
+ shippingMethod: shippingMethod,
67
+ // A requested delivery slot, chosen at checkout. An optional field — a
68
+ // Pickup order (or a caller that names no preference) simply omits it, and
69
+ // an order placed before this field existed carries no key, so adding it
70
+ // costs the log nothing (the additive path in the plan's adoption table).
71
+ // One `DateRange.t`, not a guessed `start*`/`end*` name pair.
72
+ deliveryWindow?: Reventless.DateRange.t,
73
+ })
72
74
 
73
75
  @schema
74
76
  type error =
@@ -100,33 +102,33 @@ type orderLine = {
100
102
 
101
103
  @schema
102
104
  type event =
103
- OrderPlaced({
104
- @partitionTag orderId: string,
105
- customerId: string,
106
- // Redundant against `lines`, and deliberately so. The extension point
107
- // decomposes this into one published `ItemOrdered` per product and
108
- // `CancelOrder` folds it, so both keep working untouched — and the public
109
- // contract in `ordering-spec` does not move, which is what lets the shop
110
- // show quantities without redeploying Catalog in lockstep.
111
- productIds: array<string>,
112
- lines: array<orderLine>,
113
- total: Reventless.Money.t,
114
- shippingMethod: shippingMethod,
115
- deliveryWindow?: Reventless.DateRange.t,
116
- // What the first product was called when this order was placed.
117
- //
118
- // **Captured, not looked up.** An order is a record of what somebody bought,
119
- // and the catalog goes on changing afterwards — a rename, a withdrawal, a
120
- // reshoot. Reading the name live would rewrite history every time the shop
121
- // tidied its shelves, and would leave an order for a withdrawn product with
122
- // nothing to show at all.
123
- //
124
- // Optional because every order placed before this field existed carries no
125
- // key, which is what makes adding it cost the log nothing. A reader treats
126
- // absent as "not recorded" rather than as a name.
127
- firstProductName?: string,
128
- // The picture as it was when the order was placed, frozen for the reason the
129
- // name is. A reshoot, a withdrawal or a deletion afterwards leaves this
130
- // order showing what the shopper actually bought.
131
- @storageRef("Catalog.productImages") firstProductImage?: Reventless.UploadableImage.t,
132
- })
105
+ | OrderPlaced({
106
+ @partitionTag orderId: string,
107
+ customerId: string,
108
+ // Redundant against `lines`, and deliberately so. The extension point
109
+ // decomposes this into one published `ItemOrdered` per product and
110
+ // `CancelOrder` folds it, so both keep working untouched — and the public
111
+ // contract in `ordering-spec` does not move, which is what lets the shop
112
+ // show quantities without redeploying Catalog in lockstep.
113
+ productIds: array<string>,
114
+ lines: array<orderLine>,
115
+ total: Reventless.Money.t,
116
+ shippingMethod: shippingMethod,
117
+ deliveryWindow?: Reventless.DateRange.t,
118
+ // What the first product was called when this order was placed.
119
+ //
120
+ // **Captured, not looked up.** An order is a record of what somebody bought,
121
+ // and the catalog goes on changing afterwards — a rename, a withdrawal, a
122
+ // reshoot. Reading the name live would rewrite history every time the shop
123
+ // tidied its shelves, and would leave an order for a withdrawn product with
124
+ // nothing to show at all.
125
+ //
126
+ // Optional because every order placed before this field existed carries no
127
+ // key, which is what makes adding it cost the log nothing. A reader treats
128
+ // absent as "not recorded" rather than as a name.
129
+ firstProductName?: string,
130
+ // The picture as it was when the order was placed, frozen for the reason the
131
+ // name is. A reshoot, a withdrawal or a deletion afterwards leaves this
132
+ // order showing what the shopper actually bought.
133
+ @storageRef("Catalog.productImages") firstProductImage?: Reventless.UploadableImage.t,
134
+ })