@reventlessdev/online-shop-hybrid-ordering 1.0.0-alpha.210 → 1.0.0-alpha.211

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 (34) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/package.json +8 -8
  3. package/src/CatalogProduct/StateChangeSlice/SyncCatalogProduct.res +18 -0
  4. package/src/CatalogProduct/StateChangeSlice/SyncCatalogProduct.res.mjs +21 -1
  5. package/src/CatalogProduct/StateChangeSlice/SyncCatalogProduct_Behavior.res +26 -4
  6. package/src/CatalogProduct/StateChangeSlice/SyncCatalogProduct_Behavior.res.mjs +99 -39
  7. package/src/CatalogProduct/StateViewSliceStream/AvailableProducts.res +2 -0
  8. package/src/CatalogProduct/StateViewSliceStream/AvailableProducts.res.mjs +13 -2
  9. package/src/CatalogProduct/StateViewSliceStream/AvailableProducts_Projection.res +11 -0
  10. package/src/CatalogProduct/StateViewSliceStream/AvailableProducts_Projection.res.mjs +28 -19
  11. package/src/Customer/Aggregate/Customer.res +14 -1
  12. package/src/Customer/Aggregate/Customer.res.mjs +16 -3
  13. package/src/Customer/Aggregate/Customer_Behavior.res +29 -10
  14. package/src/Customer/Aggregate/Customer_Behavior.res.mjs +79 -36
  15. package/src/Customer/ReadModelStream/Customers.res +29 -4
  16. package/src/Customer/ReadModelStream/Customers.res.mjs +15 -3
  17. package/src/Customer/ReadModelStream/Customers_Projections.res +8 -5
  18. package/src/Customer/ReadModelStream/Customers_Projections.res.mjs +24 -12
  19. package/src/Extension/Products_Extension.res +9 -0
  20. package/src/Extension/Products_Extension.res.mjs +36 -19
  21. package/src/Order/StateViewSliceStream/Orders.res +4 -2
  22. package/src/Order/StateViewSliceStream/Orders.res.mjs +4 -26
  23. package/src/Order/StateViewSliceStream/Orders_Projection.res +3 -3
  24. package/src/Order/StateViewSliceStream/Orders_Projection.res.mjs +3 -3
  25. package/tests/CatalogProduct/StateChangeSlice/SyncCatalogProduct_GWT.res +45 -0
  26. package/tests/CatalogProduct/StateChangeSlice/SyncCatalogProduct_GWT.res.mjs +70 -0
  27. package/tests/CatalogProduct/StateViewSliceStream/AvailableProducts_GWT.res +19 -0
  28. package/tests/CatalogProduct/StateViewSliceStream/AvailableProducts_GWT.res.mjs +32 -2
  29. package/tests/Customer/ReadModelStream/Customers_GWT.res +7 -7
  30. package/tests/Customer/ReadModelStream/Customers_GWT.res.mjs +7 -7
  31. package/tests/Flow/OrderingFlow_GWT.res +1 -1
  32. package/tests/Flow/OrderingFlow_GWT.res.mjs +1 -1
  33. package/tests/Order/StateViewSliceStream/Orders_GWT.res +5 -5
  34. package/tests/Order/StateViewSliceStream/Orders_GWT.res.mjs +5 -5
package/CHANGELOG.md CHANGED
@@ -3,6 +3,16 @@
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.211 (2026-08-16)
7
+
8
+ ### Features
9
+
10
+ * **core:** let [@retired](https://github.com/retired) name a lifecycle state, not only a boolean ([6bb346b](https://github.com/ReventlessDev/reventless-core/commit/6bb346b4f6a5f33826fc24537953482a76067177))
11
+ * **core:** mark the state that retires a row, and allow more than one ([cb1461f](https://github.com/ReventlessDev/reventless-core/commit/cb1461f024d3ca3b53fd9c8b010a054e3fcc4555))
12
+ * **example:** give the hybrid shop's archive a way back ([524d374](https://github.com/ReventlessDev/reventless-core/commit/524d3748e455c70924b7861cbd4748744186de41))
13
+ * **spec:** [@retired](https://github.com/retired) state-field annotation and its schema emission ([2d8234b](https://github.com/ReventlessDev/reventless-core/commit/2d8234b6b3dd8f479031a67eb5b4b47b5c0c2ff9))
14
+
15
+
6
16
  # 1.0.0-alpha.210 (2026-08-15)
7
17
 
8
18
  **Note:** Version bump only for package @reventlessdev/online-shop-hybrid-ordering
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reventlessdev/online-shop-hybrid-ordering",
3
- "version": "1.0.0-alpha.210",
3
+ "version": "1.0.0-alpha.211",
4
4
  "description": "Online Shop Hybrid example — Ordering plugin (Customer aggregate + Order/CatalogProduct DCB)",
5
5
  "license": "Apache-2.0",
6
6
  "jest": {
@@ -21,18 +21,18 @@
21
21
  },
22
22
  "dependencies": {
23
23
  "sury": "^11.0.0-alpha.4",
24
- "@reventlessdev/online-shop-hybrid-ordering-spec": "1.0.0-alpha.110",
24
+ "@reventlessdev/online-shop-hybrid-catalog-spec": "1.0.0-alpha.111",
25
25
  "@reventlessdev/rescript-pulumi-pulumi": "2.3.0-alpha.19",
26
- "@reventlessdev/online-shop-hybrid-catalog-spec": "1.0.0-alpha.110",
27
- "@reventlessdev/reventless-gwt": "1.0.0-alpha.183",
28
- "@reventlessdev/reventless-infra": "3.0.0-alpha.142",
29
- "@reventlessdev/reventless-local": "3.0.0-alpha.224",
30
- "@reventlessdev/reventless-spec": "3.0.0-alpha.114"
26
+ "@reventlessdev/reventless-gwt": "1.0.0-alpha.184",
27
+ "@reventlessdev/online-shop-hybrid-ordering-spec": "1.0.0-alpha.111",
28
+ "@reventlessdev/reventless-infra": "3.0.0-alpha.143",
29
+ "@reventlessdev/reventless-spec": "3.0.0-alpha.115",
30
+ "@reventlessdev/reventless-local": "3.0.0-alpha.225"
31
31
  },
32
32
  "devDependencies": {
33
33
  "rescript": "12.3.0",
34
34
  "sury-ppx": "11.0.0-alpha.2",
35
- "@reventlessdev/reventless-ppx": "1.0.0-alpha.66"
35
+ "@reventlessdev/reventless-ppx": "1.0.0-alpha.67"
36
36
  },
37
37
  "peerDependencies": {
38
38
  "rescript": "12.3.0"
@@ -7,11 +7,19 @@
7
7
  type consumedEvent =
8
8
  | CatalogProductSynced({name: string, price: Reventless.Money.t})
9
9
  | CatalogProductPriceChanged({price: Reventless.Money.t})
10
+ | CatalogProductWithdrawn
11
+ | CatalogProductRelisted
10
12
 
11
13
  @schema
12
14
  type command =
13
15
  | SyncNewProduct({productId: string, name: string, price: Reventless.Money.t})
14
16
  | ChangeSyncedPrice({productId: string, price: Reventless.Money.t})
17
+ // The withdrawal and the way back. Both carry only the id: the shadow this
18
+ // slice maintains already holds the name and price, and it survives the read
19
+ // model's delete — which is what lets a relist restore availability from state
20
+ // Ordering owns rather than asking Catalog to re-send facts it already has.
21
+ | WithdrawSyncedProduct({productId: string})
22
+ | RelistSyncedProduct({productId: string})
15
23
 
16
24
  @schema
17
25
  type error = unit // always succeeds — sync is idempotent
@@ -27,3 +35,13 @@ type event =
27
35
  productId: string,
28
36
  price: Reventless.Money.t,
29
37
  })
38
+ | CatalogProductWithdrawn({productId: string})
39
+ // Carries the shadow's name and price so the projection can restore the row
40
+ // without a second read: the fold has them in hand at exactly the moment the
41
+ // decision is made, and an event that states what it caused is what makes the
42
+ // projection a pure mapping.
43
+ | CatalogProductRelisted({
44
+ productId: string,
45
+ name: string,
46
+ price: Reventless.Money.t,
47
+ })
@@ -13,7 +13,9 @@ let consumedEventSchema = S.union([
13
13
  S.schema(s => ({
14
14
  TAG: "CatalogProductPriceChanged",
15
15
  price: s.m(Money$Reventless.schema)
16
- }))
16
+ })),
17
+ S.literal("CatalogProductWithdrawn"),
18
+ S.literal("CatalogProductRelisted")
17
19
  ]);
18
20
 
19
21
  let commandSchema = S.union([
@@ -27,6 +29,14 @@ let commandSchema = S.union([
27
29
  TAG: "ChangeSyncedPrice",
28
30
  productId: s.m(DcbTag$Reventless.string),
29
31
  price: s.m(Money$Reventless.schema)
32
+ })),
33
+ S.schema(s => ({
34
+ TAG: "WithdrawSyncedProduct",
35
+ productId: s.m(DcbTag$Reventless.string)
36
+ })),
37
+ S.schema(s => ({
38
+ TAG: "RelistSyncedProduct",
39
+ productId: s.m(DcbTag$Reventless.string)
30
40
  }))
31
41
  ]);
32
42
 
@@ -41,6 +51,16 @@ let eventSchema = S.union([
41
51
  TAG: "CatalogProductPriceChanged",
42
52
  productId: s.m(DcbTag$Reventless.string),
43
53
  price: s.m(Money$Reventless.schema)
54
+ })),
55
+ S.schema(s => ({
56
+ TAG: "CatalogProductWithdrawn",
57
+ productId: s.m(DcbTag$Reventless.string)
58
+ })),
59
+ S.schema(s => ({
60
+ TAG: "CatalogProductRelisted",
61
+ productId: s.m(DcbTag$Reventless.string),
62
+ name: s.m(S.string),
63
+ price: s.m(Money$Reventless.schema)
44
64
  }))
45
65
  ]);
46
66
 
@@ -2,20 +2,26 @@
2
2
 
3
3
  // No price until one has been synced — see ChangeProductPrice_Behavior for why
4
4
  // a money field's "empty" is an absence rather than a zero.
5
- type state = {name: string, price: option<Reventless.Money.t>}
6
- let initialState = {name: "", price: None}
5
+ //
6
+ // `withdrawn` sits beside the shadow rather than replacing it: a withdrawn
7
+ // product's name and price are exactly what a relist needs, and dropping them
8
+ // here would force Catalog to re-send facts Ordering already holds.
9
+ type state = {name: string, price: option<Reventless.Money.t>, withdrawn: bool}
10
+ let initialState = {name: "", price: None, withdrawn: false}
7
11
 
8
12
  let evolve = (state, event) =>
9
13
  switch event {
10
- | CatalogProductSynced({name, price}) => {name, price: Some(price)}
14
+ | CatalogProductSynced({name, price}) => {name, price: Some(price), withdrawn: false}
11
15
  | CatalogProductPriceChanged({price}) => {...state, price: Some(price)}
16
+ | CatalogProductWithdrawn => {...state, withdrawn: true}
17
+ | CatalogProductRelisted => {...state, withdrawn: false}
12
18
  }
13
19
 
14
20
  let decide = (state, command) =>
15
21
  switch command {
16
22
  | SyncNewProduct({productId, name, price}) =>
17
23
  // Idempotent: re-syncing identical data emits nothing (at-least-once delivery).
18
- if state.name == name && state.price == Some(price) {
24
+ if state.name == name && state.price == Some(price) && !state.withdrawn {
19
25
  Ok([])
20
26
  } else {
21
27
  Ok([CatalogProductSynced({productId, name, price})])
@@ -26,4 +32,20 @@ let decide = (state, command) =>
26
32
  } else {
27
33
  Ok([CatalogProductPriceChanged({productId, price})])
28
34
  }
35
+ | WithdrawSyncedProduct({productId: theId}) =>
36
+ if state.withdrawn {
37
+ Ok([])
38
+ } else {
39
+ Ok([CatalogProductWithdrawn({productId: theId})])
40
+ }
41
+ | RelistSyncedProduct({productId: theId}) =>
42
+ switch (state.withdrawn, state.price) {
43
+ | (false, _) => Ok([]) // idempotent — already orderable
44
+ // A relist of a product never synced has nothing to restore. Emitting an
45
+ // event with an invented name and a zero price would put a row in the
46
+ // shopper's catalog that no Catalog product backs.
47
+ | (true, None) => Ok([])
48
+ | (true, Some(price)) =>
49
+ Ok([CatalogProductRelisted({productId: theId, name: state.name, price})])
50
+ }
29
51
  }
@@ -3,63 +3,123 @@
3
3
  import * as Primitive_object from "@rescript/runtime/lib/es6/Primitive_object.js";
4
4
 
5
5
  function evolve(state, event) {
6
- if (event.TAG === "CatalogProductSynced") {
7
- return {
8
- name: event.name,
9
- price: event.price
10
- };
11
- } else {
12
- return {
13
- name: state.name,
14
- price: event.price
15
- };
16
- }
17
- }
18
-
19
- function decide(state, command) {
20
- if (command.TAG === "SyncNewProduct") {
21
- let price = command.price;
22
- let name = command.name;
23
- if (state.name === name && Primitive_object.equal(state.price, price)) {
6
+ if (typeof event !== "object") {
7
+ if (event === "CatalogProductWithdrawn") {
24
8
  return {
25
- TAG: "Ok",
26
- _0: []
9
+ name: state.name,
10
+ price: state.price,
11
+ withdrawn: true
27
12
  };
28
13
  } else {
29
14
  return {
30
- TAG: "Ok",
31
- _0: [{
32
- TAG: "CatalogProductSynced",
33
- productId: command.productId,
34
- name: name,
35
- price: price
36
- }]
15
+ name: state.name,
16
+ price: state.price,
17
+ withdrawn: false
37
18
  };
38
19
  }
39
- }
40
- let price$1 = command.price;
41
- if (Primitive_object.equal(state.price, price$1)) {
20
+ } else if (event.TAG === "CatalogProductSynced") {
42
21
  return {
43
- TAG: "Ok",
44
- _0: []
22
+ name: event.name,
23
+ price: event.price,
24
+ withdrawn: false
45
25
  };
46
26
  } else {
47
27
  return {
48
- TAG: "Ok",
49
- _0: [{
50
- TAG: "CatalogProductPriceChanged",
51
- productId: command.productId,
52
- price: price$1
53
- }]
28
+ name: state.name,
29
+ price: event.price,
30
+ withdrawn: state.withdrawn
54
31
  };
55
32
  }
56
33
  }
57
34
 
35
+ function decide(state, command) {
36
+ switch (command.TAG) {
37
+ case "SyncNewProduct" :
38
+ let price = command.price;
39
+ let name = command.name;
40
+ if (state.name === name && Primitive_object.equal(state.price, price) && !state.withdrawn) {
41
+ return {
42
+ TAG: "Ok",
43
+ _0: []
44
+ };
45
+ } else {
46
+ return {
47
+ TAG: "Ok",
48
+ _0: [{
49
+ TAG: "CatalogProductSynced",
50
+ productId: command.productId,
51
+ name: name,
52
+ price: price
53
+ }]
54
+ };
55
+ }
56
+ case "ChangeSyncedPrice" :
57
+ let price$1 = command.price;
58
+ if (Primitive_object.equal(state.price, price$1)) {
59
+ return {
60
+ TAG: "Ok",
61
+ _0: []
62
+ };
63
+ } else {
64
+ return {
65
+ TAG: "Ok",
66
+ _0: [{
67
+ TAG: "CatalogProductPriceChanged",
68
+ productId: command.productId,
69
+ price: price$1
70
+ }]
71
+ };
72
+ }
73
+ case "WithdrawSyncedProduct" :
74
+ if (state.withdrawn) {
75
+ return {
76
+ TAG: "Ok",
77
+ _0: []
78
+ };
79
+ } else {
80
+ return {
81
+ TAG: "Ok",
82
+ _0: [{
83
+ TAG: "CatalogProductWithdrawn",
84
+ productId: command.productId
85
+ }]
86
+ };
87
+ }
88
+ case "RelistSyncedProduct" :
89
+ let match = state.withdrawn;
90
+ let match$1 = state.price;
91
+ if (match) {
92
+ if (match$1 !== undefined) {
93
+ return {
94
+ TAG: "Ok",
95
+ _0: [{
96
+ TAG: "CatalogProductRelisted",
97
+ productId: command.productId,
98
+ name: state.name,
99
+ price: match$1
100
+ }]
101
+ };
102
+ } else {
103
+ return {
104
+ TAG: "Ok",
105
+ _0: []
106
+ };
107
+ }
108
+ } else {
109
+ return {
110
+ TAG: "Ok",
111
+ _0: []
112
+ };
113
+ }
114
+ }
115
+ }
116
+
58
117
  let Spec;
59
118
 
60
119
  let initialState = {
61
120
  name: "",
62
- price: undefined
121
+ price: undefined,
122
+ withdrawn: false
63
123
  };
64
124
 
65
125
  let moduleUrl = "@reventlessdev/online-shop-hybrid-ordering/src/CatalogProduct/StateChangeSlice/SyncCatalogProduct_Behavior.res.mjs";
@@ -10,6 +10,8 @@
10
10
  type consumedEvent =
11
11
  | CatalogProductSynced({productId: string, name: string, price: Reventless.Money.t})
12
12
  | CatalogProductPriceChanged({productId: string, price: Reventless.Money.t})
13
+ | CatalogProductWithdrawn({productId: string})
14
+ | CatalogProductRelisted({productId: string, name: string, price: Reventless.Money.t})
13
15
 
14
16
  @schema
15
17
  type state = {productId: string, name: string, price: Reventless.Money.t}
@@ -16,6 +16,16 @@ let consumedEventSchema = S.union([
16
16
  TAG: "CatalogProductPriceChanged",
17
17
  productId: s.m(S.string),
18
18
  price: s.m(Money$Reventless.schema)
19
+ })),
20
+ S.schema(s => ({
21
+ TAG: "CatalogProductWithdrawn",
22
+ productId: s.m(S.string)
23
+ })),
24
+ S.schema(s => ({
25
+ TAG: "CatalogProductRelisted",
26
+ productId: s.m(S.string),
27
+ name: s.m(S.string),
28
+ price: s.m(Money$Reventless.schema)
19
29
  }))
20
30
  ]);
21
31
 
@@ -42,10 +52,11 @@ let stateSchema$1 = S.Metadata.set(stateSchema, StateAnnotations$Reventless.stat
42
52
  scanSort: [],
43
53
  semantic: [],
44
54
  metric: [],
45
- status: undefined,
55
+ lifecycle: undefined,
46
56
  groupBy: undefined,
47
57
  visibility: "Internal",
48
- live: undefined
58
+ live: undefined,
59
+ retired: undefined
49
60
  });
50
61
 
51
62
  let name = "AvailableProducts";
@@ -5,4 +5,15 @@ let project = ({event}) =>
5
5
  | CatalogProductSynced({productId, name, price}) => [Set(productId, {productId, name, price})]
6
6
  | CatalogProductPriceChanged({productId, price}) =>
7
7
  [Update(productId, p => {...p, price})]
8
+ // `Delete`, not a flag. This view answers "what can I order", so a withdrawn
9
+ // product leaves it. Marking it instead — `@retired` on the shopper view —
10
+ // would look tidy and be wrong twice over: a shopper is not elevated, so the
11
+ // narrowing would hide it correctly, but an operator IS, and would find
12
+ // discontinued stock in the shopper's catalog. It would also make Ordering
13
+ // restate a lifecycle Catalog owns.
14
+ | CatalogProductWithdrawn({productId}) => [Delete(productId)]
15
+ // Set again, from the relist event's own payload — which the aggregate filled
16
+ // from the shadow it kept through the withdrawal. Catalog is never asked to
17
+ // re-send what Ordering already has.
18
+ | CatalogProductRelisted({productId, name, price}) => [Set(productId, {productId, name, price})]
8
19
  }
@@ -3,27 +3,36 @@
3
3
 
4
4
  function project(param) {
5
5
  let event = param.event;
6
- if (event.TAG === "CatalogProductSynced") {
7
- let productId = event.productId;
8
- return [{
9
- TAG: "Set",
10
- _0: productId,
11
- _1: {
12
- productId: productId,
13
- name: event.name,
14
- price: event.price
15
- }
16
- }];
6
+ switch (event.TAG) {
7
+ case "CatalogProductPriceChanged" :
8
+ let price = event.price;
9
+ return [{
10
+ TAG: "Update",
11
+ _0: event.productId,
12
+ _1: p => ({
13
+ productId: p.productId,
14
+ name: p.name,
15
+ price: price
16
+ })
17
+ }];
18
+ case "CatalogProductWithdrawn" :
19
+ return [{
20
+ TAG: "Delete",
21
+ _0: event.productId
22
+ }];
23
+ case "CatalogProductSynced" :
24
+ case "CatalogProductRelisted" :
25
+ break;
17
26
  }
18
- let price = event.price;
27
+ let productId = event.productId;
19
28
  return [{
20
- TAG: "Update",
21
- _0: event.productId,
22
- _1: p => ({
23
- productId: p.productId,
24
- name: p.name,
25
- price: price
26
- })
29
+ TAG: "Set",
30
+ _0: productId,
31
+ _1: {
32
+ productId: productId,
33
+ name: event.name,
34
+ price: event.price
35
+ }
27
36
  }];
28
37
  }
29
38
 
@@ -37,7 +37,19 @@ type command =
37
37
  // address that has since changed is dropped rather than applied.
38
38
  | @noApi SetLocation({location: Reventless.GeoPoint.t, resolvedFrom: string})
39
39
  | @noApi MarkAddressUnresolvable({address: string, reason: string})
40
- | Deactivate
40
+ | @allowedStates([Customers.Active]) Deactivate
41
+ // The way back. Deactivation withdraws a customer from ordinary use; it does
42
+ // not erase them, so restoring one needs no payload — the profile is still in
43
+ // the state, and asking a caller to re-supply an email they never changed
44
+ // would be a second chance to get it wrong.
45
+ //
46
+ // `@allowedStates([Deactivated])` is the whole of "offer this on a deactivated
47
+ // customer and nowhere else". It works because the view's retirement IS a state
48
+ // of its lifecycle rather than a boolean beside one, so the states a command
49
+ // names and the states a row can be in are the same vocabulary. The refusal
50
+ // still lives in `decide`; this only stops a menu offering what the write side
51
+ // would reject.
52
+ | @allowedStates([Customers.Deactivated]) Reactivate
41
53
 
42
54
  @schema
43
55
  type event =
@@ -60,6 +72,7 @@ type event =
60
72
  // two things is one nobody can act on.
61
73
  | AddressUnresolvable({address: string, reason: string})
62
74
  | Deactivated
75
+ | Reactivated
63
76
 
64
77
  @schema
65
78
  type error =
@@ -33,7 +33,8 @@ let commandSchema = S.union([
33
33
  address: s.m(S.string),
34
34
  reason: s.m(S.string)
35
35
  })),
36
- S.literal("Deactivate")
36
+ S.literal("Deactivate"),
37
+ S.literal("Reactivate")
37
38
  ]);
38
39
 
39
40
  let eventSchema = S.union([
@@ -65,7 +66,8 @@ let eventSchema = S.union([
65
66
  address: s.m(S.string),
66
67
  reason: s.m(S.string)
67
68
  })),
68
- S.literal("Deactivated")
69
+ S.literal("Deactivated"),
70
+ S.literal("Reactivated")
69
71
  ]);
70
72
 
71
73
  let errorSchema = S.union([
@@ -79,6 +81,17 @@ let commandSchema$1 = Api$ReventlessInfra.markNoApiVariants(commandSchema, [
79
81
  "MarkAddressUnresolvable"
80
82
  ]);
81
83
 
84
+ let commandSchema$2 = Api$ReventlessInfra.markAllowedStates(commandSchema$1, [
85
+ [
86
+ "Deactivate",
87
+ ["Active"]
88
+ ],
89
+ [
90
+ "Reactivate",
91
+ ["Deactivated"]
92
+ ]
93
+ ]);
94
+
82
95
  function commandAuthorization(param) {
83
96
  return "AllowAuthenticated";
84
97
  }
@@ -94,7 +107,7 @@ export {
94
107
  Id,
95
108
  eventSchema,
96
109
  errorSchema,
97
- commandSchema$1 as commandSchema,
110
+ commandSchema$2 as commandSchema,
98
111
  moduleUrl,
99
112
  commandAuthorization,
100
113
  }
@@ -20,7 +20,17 @@ type state =
20
20
  location: option<Reventless.GeoPoint.t>,
21
21
  locationResolvedFrom: option<string>,
22
22
  })
23
- | Deactivated
23
+ // Carries the profile it was holding when it was withdrawn. Deactivation is
24
+ // not deletion — the orders still name this customer — so the state that comes
25
+ // back on reactivation has to be the state that went in. A payloadless
26
+ // `Deactivated` would make `Reactivate` either impossible or a second
27
+ // registration, and a second registration is a different fact.
28
+ | Deactivated({
29
+ email: string,
30
+ address: string,
31
+ location: option<Reventless.GeoPoint.t>,
32
+ locationResolvedFrom: option<string>,
33
+ })
24
34
 
25
35
  let initialState = NotCreated
26
36
 
@@ -44,8 +54,12 @@ let evolve = (state, event) =>
44
54
  // handing it back for another round.
45
55
  | (Active(s), AddressUnresolvable({address})) =>
46
56
  Active({...s, location: None, locationResolvedFrom: Some(address)})
47
- | (Active(_), Customer.Deactivated) => Deactivated
48
- | (Deactivated, _) => state
57
+ | (Active({email, address, location, locationResolvedFrom}), Customer.Deactivated) =>
58
+ Deactivated({email, address, location, locationResolvedFrom})
59
+ | (Deactivated({email, address, location, locationResolvedFrom}), Reactivated) =>
60
+ Active({email, address, location, locationResolvedFrom})
61
+ | (Deactivated(_), _) => state
62
+ | (Active(_), Reactivated) => state
49
63
  | (NotCreated, _) => state
50
64
  }
51
65
 
@@ -58,6 +72,7 @@ let decide = (state, command) =>
58
72
  | (NotCreated, SetLocation(_)) => Error(CustomerNotFound)
59
73
  | (NotCreated, MarkAddressUnresolvable(_)) => Error(CustomerNotFound)
60
74
  | (NotCreated, Deactivate) => Error(CustomerNotFound)
75
+ | (NotCreated, Reactivate) => Error(CustomerNotFound)
61
76
 
62
77
  | (Active(_), Register(_)) => Error(CustomerAlreadyRegistered)
63
78
  | (Active(s), UpdateEmail({email})) if email == s.email => Ok([])
@@ -94,15 +109,19 @@ let decide = (state, command) =>
94
109
  Ok([AddressUnresolvable({address, reason})])
95
110
 
96
111
  | (Active(_), Deactivate) => Ok([Customer.Deactivated])
112
+ // Already where the caller is asking it to be.
113
+ | (Active(_), Reactivate) => Ok([])
97
114
 
98
- | (Deactivated, Register(_)) => Error(CustomerAlreadyDeactivated)
99
- | (Deactivated, UpdateEmail(_)) => Error(CustomerAlreadyDeactivated)
100
- | (Deactivated, UpdateAddress(_)) => Error(CustomerAlreadyDeactivated)
101
- | (Deactivated, SetAddressLocation(_)) => Error(CustomerAlreadyDeactivated)
115
+ | (Deactivated(_), Register(_)) => Error(CustomerAlreadyDeactivated)
116
+ | (Deactivated(_), UpdateEmail(_)) => Error(CustomerAlreadyDeactivated)
117
+ | (Deactivated(_), UpdateAddress(_)) => Error(CustomerAlreadyDeactivated)
118
+ | (Deactivated(_), SetAddressLocation(_)) => Error(CustomerAlreadyDeactivated)
102
119
  // A deactivated customer has no location work owing — swallow rather than
103
120
  // error, so an in-flight geocode landing after deactivation does not park a
104
121
  // TODO row in Failed forever.
105
- | (Deactivated, SetLocation(_)) => Ok([])
106
- | (Deactivated, MarkAddressUnresolvable(_)) => Ok([])
107
- | (Deactivated, Deactivate) => Ok([]) // idempotent
122
+ | (Deactivated(_), SetLocation(_)) => Ok([])
123
+ | (Deactivated(_), MarkAddressUnresolvable(_)) => Ok([])
124
+ | (Deactivated(_), Deactivate) => Ok([]) // idempotent
125
+ // The one command this state exists to accept.
126
+ | (Deactivated(_), Reactivate) => Ok([Reactivated])
108
127
  }