@peabody-soft/mbs-model-ts 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (212) hide show
  1. package/LICENCE +661 -0
  2. package/dist/Address.d.ts +33 -0
  3. package/dist/Address.js +97 -0
  4. package/dist/Attachment.d.ts +16 -0
  5. package/dist/Attachment.js +49 -0
  6. package/dist/AttachmentBaseUrl.d.ts +11 -0
  7. package/dist/AttachmentBaseUrl.js +41 -0
  8. package/dist/AuthTokens.d.ts +13 -0
  9. package/dist/AuthTokens.js +30 -0
  10. package/dist/Business.d.ts +44 -0
  11. package/dist/Business.js +87 -0
  12. package/dist/BusinessIdentifier.d.ts +8 -0
  13. package/dist/BusinessIdentifier.js +14 -0
  14. package/dist/BusinessLink.d.ts +21 -0
  15. package/dist/BusinessLink.js +61 -0
  16. package/dist/BusinessProfile.d.ts +11 -0
  17. package/dist/BusinessProfile.js +27 -0
  18. package/dist/CartEditor.d.ts +59 -0
  19. package/dist/CartEditor.js +324 -0
  20. package/dist/Catalog.d.ts +28 -0
  21. package/dist/Catalog.js +115 -0
  22. package/dist/Constants.d.ts +10 -0
  23. package/dist/Constants.js +43 -0
  24. package/dist/ContainerNames.d.ts +2 -0
  25. package/dist/ContainerNames.js +21 -0
  26. package/dist/Database.d.ts +15 -0
  27. package/dist/Database.js +1 -0
  28. package/dist/Entity.d.ts +27 -0
  29. package/dist/Entity.js +69 -0
  30. package/dist/Enums.d.ts +16 -0
  31. package/dist/Enums.js +18 -0
  32. package/dist/Exceptions.d.ts +4 -0
  33. package/dist/Exceptions.js +8 -0
  34. package/dist/GeneralSettings.d.ts +46 -0
  35. package/dist/GeneralSettings.js +99 -0
  36. package/dist/HttpStatusCodes.d.ts +2 -0
  37. package/dist/HttpStatusCodes.js +34 -0
  38. package/dist/Ids.d.ts +17 -0
  39. package/dist/Ids.js +78 -0
  40. package/dist/Item.d.ts +12 -0
  41. package/dist/Item.js +30 -0
  42. package/dist/LatLong.d.ts +7 -0
  43. package/dist/LatLong.js +17 -0
  44. package/dist/LiveData.d.ts +30 -0
  45. package/dist/LiveData.js +76 -0
  46. package/dist/Measures.d.ts +20 -0
  47. package/dist/Measures.js +34 -0
  48. package/dist/Message.d.ts +16 -0
  49. package/dist/Message.js +32 -0
  50. package/dist/MockDatabase.d.ts +19 -0
  51. package/dist/MockDatabase.js +73 -0
  52. package/dist/Model.d.ts +63 -0
  53. package/dist/Model.js +629 -0
  54. package/dist/Order.d.ts +31 -0
  55. package/dist/Order.js +70 -0
  56. package/dist/Package.d.ts +25 -0
  57. package/dist/Package.js +52 -0
  58. package/dist/PackageIdentifier.d.ts +8 -0
  59. package/dist/PackageIdentifier.js +14 -0
  60. package/dist/PaymentMethodDetail.d.ts +8 -0
  61. package/dist/PaymentMethodDetail.js +17 -0
  62. package/dist/Photo.d.ts +11 -0
  63. package/dist/Photo.js +29 -0
  64. package/dist/Price.d.ts +17 -0
  65. package/dist/Price.js +52 -0
  66. package/dist/PriceList.d.ts +3 -0
  67. package/dist/PriceList.js +5 -0
  68. package/dist/Product.d.ts +33 -0
  69. package/dist/Product.js +84 -0
  70. package/dist/ProductCategory.d.ts +10 -0
  71. package/dist/ProductCategory.js +18 -0
  72. package/dist/Profile.d.ts +20 -0
  73. package/dist/Profile.js +55 -0
  74. package/dist/ProtoMap.d.ts +71 -0
  75. package/dist/ProtoMap.js +1126 -0
  76. package/dist/Routes.d.ts +7 -0
  77. package/dist/Routes.js +12 -0
  78. package/dist/Service.d.ts +16 -0
  79. package/dist/Service.js +1 -0
  80. package/dist/ServiceExceptions.d.ts +40 -0
  81. package/dist/ServiceExceptions.js +84 -0
  82. package/dist/ServiceProxy.d.ts +25 -0
  83. package/dist/ServiceProxy.js +117 -0
  84. package/dist/ShoppingList.d.ts +64 -0
  85. package/dist/ShoppingList.js +188 -0
  86. package/dist/SyncCache.d.ts +28 -0
  87. package/dist/SyncCache.js +137 -0
  88. package/dist/SyncKeys.d.ts +5 -0
  89. package/dist/SyncKeys.js +41 -0
  90. package/dist/SyncState.d.ts +8 -0
  91. package/dist/SyncState.js +30 -0
  92. package/dist/Tax.d.ts +10 -0
  93. package/dist/Tax.js +30 -0
  94. package/dist/UserProfile.d.ts +16 -0
  95. package/dist/UserProfile.js +40 -0
  96. package/dist/WorkingDays.d.ts +33 -0
  97. package/dist/WorkingDays.js +121 -0
  98. package/dist/generated/Address.d.ts +77 -0
  99. package/dist/generated/Address.js +110 -0
  100. package/dist/generated/AddressList.d.ts +39 -0
  101. package/dist/generated/AddressList.js +60 -0
  102. package/dist/generated/AddressType.d.ts +15 -0
  103. package/dist/generated/AddressType.js +20 -0
  104. package/dist/generated/Attachment.d.ts +66 -0
  105. package/dist/generated/Attachment.js +89 -0
  106. package/dist/generated/AttachmentType.d.ts +154 -0
  107. package/dist/generated/AttachmentType.js +159 -0
  108. package/dist/generated/AuthTokens.d.ts +56 -0
  109. package/dist/generated/AuthTokens.js +74 -0
  110. package/dist/generated/AuthenticationMethod.d.ts +24 -0
  111. package/dist/generated/AuthenticationMethod.js +29 -0
  112. package/dist/generated/Business.d.ts +54 -0
  113. package/dist/generated/Business.js +75 -0
  114. package/dist/generated/BusinessIdentifier.d.ts +36 -0
  115. package/dist/generated/BusinessIdentifier.js +60 -0
  116. package/dist/generated/BusinessIdentifierType.d.ts +49 -0
  117. package/dist/generated/BusinessIdentifierType.js +54 -0
  118. package/dist/generated/BusinessLink.d.ts +76 -0
  119. package/dist/generated/BusinessLink.js +99 -0
  120. package/dist/generated/BusinessSyncInRequest.d.ts +42 -0
  121. package/dist/generated/BusinessSyncInRequest.js +60 -0
  122. package/dist/generated/BusinessSyncInResponse.d.ts +42 -0
  123. package/dist/generated/BusinessSyncInResponse.js +60 -0
  124. package/dist/generated/BusinessSyncOutRequest.d.ts +82 -0
  125. package/dist/generated/BusinessSyncOutRequest.js +107 -0
  126. package/dist/generated/BusinessSyncOutResponse.d.ts +106 -0
  127. package/dist/generated/BusinessSyncOutResponse.js +139 -0
  128. package/dist/generated/Configuration.d.ts +48 -0
  129. package/dist/generated/Configuration.js +63 -0
  130. package/dist/generated/Constants.d.ts +474 -0
  131. package/dist/generated/Constants.js +479 -0
  132. package/dist/generated/CreateUserRequest.d.ts +37 -0
  133. package/dist/generated/CreateUserRequest.js +59 -0
  134. package/dist/generated/DimensionUnit.d.ts +28 -0
  135. package/dist/generated/DimensionUnit.js +33 -0
  136. package/dist/generated/Dimensions.d.ts +41 -0
  137. package/dist/generated/Dimensions.js +76 -0
  138. package/dist/generated/Entity.d.ts +62 -0
  139. package/dist/generated/Entity.js +83 -0
  140. package/dist/generated/FulfillmentMode.d.ts +32 -0
  141. package/dist/generated/FulfillmentMode.js +37 -0
  142. package/dist/generated/GeneralSettings.d.ts +141 -0
  143. package/dist/generated/GeneralSettings.js +186 -0
  144. package/dist/generated/Item.d.ts +54 -0
  145. package/dist/generated/Item.js +82 -0
  146. package/dist/generated/LatLong.d.ts +36 -0
  147. package/dist/generated/LatLong.js +59 -0
  148. package/dist/generated/Message.d.ts +80 -0
  149. package/dist/generated/Message.js +114 -0
  150. package/dist/generated/MessageType.d.ts +34 -0
  151. package/dist/generated/MessageType.js +39 -0
  152. package/dist/generated/OrderStatus.d.ts +50 -0
  153. package/dist/generated/OrderStatus.js +55 -0
  154. package/dist/generated/Package.d.ts +63 -0
  155. package/dist/generated/Package.js +92 -0
  156. package/dist/generated/PackageIdentifier.d.ts +36 -0
  157. package/dist/generated/PackageIdentifier.js +60 -0
  158. package/dist/generated/PackageIdentifierType.d.ts +49 -0
  159. package/dist/generated/PackageIdentifierType.js +54 -0
  160. package/dist/generated/PartyPreferencesPayload.d.ts +41 -0
  161. package/dist/generated/PartyPreferencesPayload.js +60 -0
  162. package/dist/generated/PaymentMethod.d.ts +20 -0
  163. package/dist/generated/PaymentMethod.js +25 -0
  164. package/dist/generated/PaymentMethodDetail.d.ts +37 -0
  165. package/dist/generated/PaymentMethodDetail.js +59 -0
  166. package/dist/generated/Price.d.ts +52 -0
  167. package/dist/generated/Price.js +75 -0
  168. package/dist/generated/PriceList.d.ts +34 -0
  169. package/dist/generated/PriceList.js +51 -0
  170. package/dist/generated/Product.d.ts +77 -0
  171. package/dist/generated/Product.js +106 -0
  172. package/dist/generated/ProductCategory.d.ts +32 -0
  173. package/dist/generated/ProductCategory.js +51 -0
  174. package/dist/generated/ProductType.d.ts +42 -0
  175. package/dist/generated/ProductType.js +47 -0
  176. package/dist/generated/ProfileSyncOutRequest.d.ts +51 -0
  177. package/dist/generated/ProfileSyncOutRequest.js +75 -0
  178. package/dist/generated/ProfileSyncOutResponse.d.ts +56 -0
  179. package/dist/generated/ProfileSyncOutResponse.js +77 -0
  180. package/dist/generated/ReservedPackage.d.ts +34 -0
  181. package/dist/generated/ReservedPackage.js +39 -0
  182. package/dist/generated/Role.d.ts +71 -0
  183. package/dist/generated/Role.js +76 -0
  184. package/dist/generated/SalesOrderPayload.d.ts +85 -0
  185. package/dist/generated/SalesOrderPayload.js +123 -0
  186. package/dist/generated/ShoppingList.d.ts +99 -0
  187. package/dist/generated/ShoppingList.js +132 -0
  188. package/dist/generated/ShoppingListItem.d.ts +102 -0
  189. package/dist/generated/ShoppingListItem.js +139 -0
  190. package/dist/generated/TaskStatus.d.ts +20 -0
  191. package/dist/generated/TaskStatus.js +25 -0
  192. package/dist/generated/Tax.d.ts +37 -0
  193. package/dist/generated/Tax.js +60 -0
  194. package/dist/generated/TaxType.d.ts +25 -0
  195. package/dist/generated/TaxType.js +30 -0
  196. package/dist/generated/UrlAndToken.d.ts +54 -0
  197. package/dist/generated/UrlAndToken.js +67 -0
  198. package/dist/generated/User.d.ts +63 -0
  199. package/dist/generated/User.js +89 -0
  200. package/dist/generated/Weight.d.ts +33 -0
  201. package/dist/generated/Weight.js +60 -0
  202. package/dist/generated/WeightUnit.d.ts +28 -0
  203. package/dist/generated/WeightUnit.js +33 -0
  204. package/dist/generated/WorkingDays.d.ts +42 -0
  205. package/dist/generated/WorkingDays.js +68 -0
  206. package/dist/generated/WorkingHours.d.ts +33 -0
  207. package/dist/generated/WorkingHours.js +52 -0
  208. package/dist/generated/WorkingHoursRange.d.ts +42 -0
  209. package/dist/generated/WorkingHoursRange.js +59 -0
  210. package/dist/index.d.ts +48 -0
  211. package/dist/index.js +60 -0
  212. package/package.json +55 -0
package/dist/Model.js ADDED
@@ -0,0 +1,629 @@
1
+ import { Constants as proto_Constants } from "./generated/Constants";
2
+ import { CreateUserRequest as proto_CreateUserRequest } from "./generated/CreateUserRequest";
3
+ import { FulfillmentMode as proto_FulfillmentMode } from "./generated/FulfillmentMode";
4
+ import AuthTokens from "./AuthTokens";
5
+ import * as ContainerNames from "./ContainerNames";
6
+ import { IllegalOperationException, InvalidArgumentException } from "./Exceptions";
7
+ import Ids from "./Ids";
8
+ import LiveData from "./LiveData";
9
+ import * as ProtoMap from "./ProtoMap";
10
+ import { InternalServerException } from "./ServiceExceptions";
11
+ import SyncCache from "./SyncCache";
12
+ // The root of the object model, and the only thing a UI has to be given. Call initialize once,
13
+ // then always fetch the current instance via getInstance.
14
+ class Model {
15
+ constructor() {
16
+ this._business = new LiveData(null);
17
+ this._profile = new LiveData(null);
18
+ this._authenticationMethod = null;
19
+ this._subjectId = null;
20
+ this._authenticationToken = null;
21
+ this._businessId = null;
22
+ }
23
+ // Sets the service and store to use, and forgets who is signed in and what was being browsed
24
+ static initialize(service, database) {
25
+ Model._service = service;
26
+ Model._cache = new SyncCache(database);
27
+ Model._instance = null;
28
+ }
29
+ // The model as it is now. Ask for it every time; never keep it - see the note on this class.
30
+ static getInstance() {
31
+ return Model._instance ?? (Model._instance = new Model());
32
+ }
33
+ // The business being browsed. Null until setBusinessId has named one and it has loaded.
34
+ get business() {
35
+ return this._business;
36
+ }
37
+ // The customer, their businesses, and the link to each - one LiveData since useful questions
38
+ // span all three. Null until signed in and loaded.
39
+ get profile() {
40
+ return this._profile;
41
+ }
42
+ // Signs in with the given credentials and syncs this subject's profile in - for a subject the
43
+ // server already has a profile for; createUser is what a subject with none yet uses instead. Also
44
+ // reads in the business being browsed, if any, now signed in as this customer.
45
+ async signIn(authenticationMethod, subjectId, authenticationToken) {
46
+ this.beginSignIn(authenticationMethod, subjectId, authenticationToken);
47
+ const authTokens = await this.requireProfileAuthTokens();
48
+ await this.profileSyncIn(ProtoMap.buildProfileSyncOutRequestFromCache(await Model.getCache().readProfile(this.requireProfileContainerName())), authTokens);
49
+ if (this._businessId !== null)
50
+ await this.businessSyncIn();
51
+ }
52
+ // Signs up with the given credentials, creating userToCreate as this subject's profile - for a
53
+ // subject the server has no profile for yet; signIn is what an already known subject uses instead.
54
+ // Also reads in the business being browsed, if any, now signed in as this customer.
55
+ async createUser(authenticationMethod, subjectId, authenticationToken, userToCreate) {
56
+ this.beginSignIn(authenticationMethod, subjectId, authenticationToken);
57
+ const authTokens = await this.requireProfileAuthTokens();
58
+ await this.createProfileOnServer(proto_CreateUserRequest.create({ user: ProtoMap.buildUser(userToCreate) }), authTokens);
59
+ if (this._businessId !== null)
60
+ await this.businessSyncIn();
61
+ }
62
+ // Updates this customer's own profile with what userProfile now holds. Service.updateUser matches
63
+ // the caller against the User it is sent by entity id - see ProtoMap.buildUpdateUser - so
64
+ // userProfile must be this customer's own, previously read in rather than built fresh.
65
+ // Requires a profile already read, the same "signed in" a business write needs of its profile half
66
+ // - see requireCustomerOfBusiness. Not paged, and today publishes nothing: its response carries no
67
+ // entities, only whatever the server reissued - see writeProfileResponse.
68
+ async updateUser(userProfile) {
69
+ if (this._profile.value === null)
70
+ throw new IllegalOperationException("Nobody is signed in, so this call cannot be made");
71
+ await this.writeProfileResponse(await Model.getService().updateUser(ProtoMap.buildUpdateUser(userProfile), await this.requireProfileAuthTokens()));
72
+ }
73
+ // Discards this model outright: the next getInstance() builds a fresh one, signed out and
74
+ // browsing nothing.
75
+ async signOut() {
76
+ Model._instance = null;
77
+ }
78
+ // Names the business to browse and reads it in, publishing null first so a stale or
79
+ // about-to-be-replaced Business is never shown as the one now being browsed.
80
+ async setBusinessId(businessId) {
81
+ this._businessId = businessId;
82
+ this.publishBusiness(null);
83
+ await this.businessSyncIn();
84
+ }
85
+ // #region What a customer writes
86
+ //
87
+ // The only writes in this library: this customer's cart at the business being browsed, and an
88
+ // order placed from it. An order pushes to the server and touches the store only once the server
89
+ // accepts, so a failed call leaves nothing to reconcile and a caller just retries: an order
90
+ // stored here that the server never saw would be one a customer believes they placed and a
91
+ // merchant has never heard of. A cart is not held to that, and updateCartLocalOnly is the whole
92
+ // of the difference.
93
+ // Both need this customer to be a member of the business being browsed - three separate things
94
+ // to be missing, so three separate failures; see requireCustomerOfBusiness.
95
+ // Creates or replaces this customer's cart, in the store and on the server both. An empty list
96
+ // empties the cart - an emptied cart and one never created are the same state here, on the wire
97
+ // and in what Business.cart answers.
98
+ // Signed out this is updateCartLocalOnly, there being no cart on the server for a visitor the
99
+ // server has not been told about. Signed in it is a push, so it costs a request: a caller
100
+ // editing a cart a tap at a time writes with updateCartLocalOnly and calls this one on whatever
101
+ // schedule it likes.
102
+ // Calling it with a cart the server already has costs nothing: it returns without a request. So a
103
+ // caller is free to call it on every event it cares about - a page hidden, a timer, both at
104
+ // once - without tracking what it last sent, and what it checks against is the store's own copy
105
+ // rather than a caller's idea of one. It is not a substitute for reading the business, though: a
106
+ // push answers with whatever else of this customer's changed, and a call that does not go out
107
+ // brings none of that back.
108
+ async updateCart(shoppingList) {
109
+ if (this._profile.value === null)
110
+ return this.updateCartLocalOnly(shoppingList);
111
+ const target = this.requireCustomerOfBusiness();
112
+ const stored = await Model.getCache().readCart(target.containerName);
113
+ const cartMessage = ProtoMap.buildCartMessage(Ids.getPartyPreferencesMessageId(target.businessLink.id), stored, target.senderId, shoppingList, BigInt(Date.now()));
114
+ if (!ProtoMap.isCartWorthPushing(stored, cartMessage))
115
+ return;
116
+ await this.pushBusinessMessages(target, [cartMessage]);
117
+ }
118
+ // The same, stored here and not sent. This is the call to make on every edit: a cart is worth
119
+ // keeping the moment a customer changes it, and a request per tap of a quantity stepper is not
120
+ // worth making. updateCart then sends whatever the caller has reached whenever it decides to -
121
+ // debounced, on the page being hidden, before signing in.
122
+ //
123
+ // Little is lost by a cart that is only ever stored. It survives the tab closing, the next
124
+ // updateCart sends it, and createOrder reads the cart from the store and carries that list in
125
+ // the order message itself - so an order placed from a cart never sent on its own is still the
126
+ // right order. What a caller gives up is that the server, and so this customer's other devices,
127
+ // learn of a change only when the next updateCart goes out.
128
+ //
129
+ // The store keeps whichever copy has the greater eTag, and buildCartMessage dates an amendment
130
+ // past the copy it replaces, so a cart stored here cannot be undone by a sync in bringing the
131
+ // server's older one back.
132
+ async updateCartLocalOnly(shoppingList) {
133
+ // No membership to derive a cart id from while nobody is signed in (see
134
+ // Ids.getPartyPreferencesMessageId) and none to be had until somebody signs in, so one is
135
+ // drawn - once, and only once: buildCartMessage amends the cart already stored and keeps its
136
+ // own id, so a second write cannot leave two behind. The sender is ENTITY_ID_NULL for want of
137
+ // a User, and the first push after signing in names the real one.
138
+ if (this._profile.value === null)
139
+ return this.storeCart(ContainerNames.forBusiness(this.requireBusinessBeingBrowsed()), Ids.getNewId(proto_Constants.MESSAGE_ENTITY_TYPE_ID), BigInt(proto_Constants.ENTITY_ID_NULL), shoppingList);
140
+ const target = this.requireCustomerOfBusiness();
141
+ return this.storeCart(target.containerName, Ids.getPartyPreferencesMessageId(target.businessLink.id), target.senderId, shoppingList);
142
+ }
143
+ // Places an order for what is in the cart, and empties it - the list is used up becoming the
144
+ // order, as Java SalesOrderViewModel.saveChanges does on MODE_CREATE. Takes no shopping list
145
+ // itself for that reason: the cart is the order.
146
+ // A delivery must name an address, either an id from this customer's own list or a full address
147
+ // sent directly - see requireDeliveryAddress.
148
+ async createOrder(fulfillmentMode, deliveryAddressId, deliveryAddress, note) {
149
+ const target = this.requireCustomerOfBusiness();
150
+ // Read from the store, not the published Business, so the order reflects the cart as the
151
+ // server last confirmed it, not a value some observer may still hold
152
+ const cartMessage = await Model.getCache().readCart(target.containerName);
153
+ const shoppingList = cartMessage === null ? null : ProtoMap.buildCart(cartMessage);
154
+ if (shoppingList === null || shoppingList.existingItems.length === 0)
155
+ throw new IllegalOperationException("The cart holds nothing, so there is no order to place");
156
+ Model.requireDeliveryAddress(target.addresses, fulfillmentMode, deliveryAddressId, deliveryAddress);
157
+ // One creation time for both, and it is what the order was placed at
158
+ const now = BigInt(Date.now());
159
+ // Order and emptied cart in one request: two calls could leave a customer with an order placed
160
+ // but a cart still full, if the second one failed
161
+ await this.pushBusinessMessages(target, [
162
+ ProtoMap.buildOrderMessage(Ids.getNewId(proto_Constants.MESSAGE_ENTITY_TYPE_ID), target.senderId, shoppingList, fulfillmentMode, deliveryAddressId, deliveryAddress, note, now),
163
+ ProtoMap.buildCartMessage(Ids.getPartyPreferencesMessageId(target.businessLink.id), cartMessage, target.senderId, null, now),
164
+ ]);
165
+ }
166
+ // #endregion
167
+ // Throws if a profile is already held; otherwise stores the credentials every later call signs
168
+ // with. The one step signIn and createUser share before parting ways over how each gets a profile.
169
+ beginSignIn(authenticationMethod, subjectId, authenticationToken) {
170
+ if (this._profile.value !== null)
171
+ throw new IllegalOperationException("A user is already signed in");
172
+ this._authenticationMethod = authenticationMethod;
173
+ this._subjectId = subjectId;
174
+ this._authenticationToken = authenticationToken;
175
+ }
176
+ // #region Pushing
177
+ // One push, plus everything that follows a server accepting it. The messages go out in one
178
+ // request, over this business container's own cursor, and the server answers with what has
179
+ // changed since - these messages under their assigned update numbers, plus anything else of this
180
+ // customer's a later sync would also pick up. A server that refuses the push fails the call
181
+ // itself, same as any other Service call on a bad status, so there is nothing left here to check:
182
+ // reaching the write below already means the server took it.
183
+ async pushBusinessMessages(target, messages) {
184
+ const cache = Model.getCache();
185
+ const messageUpdateNumber = await cache.readMessageUpdateNumber(target.containerName);
186
+ const authTokens = await this.requireBusinessAuthTokens(target.containerName, target.businessLink);
187
+ const response = await Model.getService().businessSyncIn(ProtoMap.buildBusinessSyncInRequest(messageUpdateNumber, messages), ProtoMap.buildAuthTokens(authTokens));
188
+ await this.storeIssuedTokens(ProtoMap.getResponseAuthTokens(response), target.containerName);
189
+ // Stored exactly as the response carried it, entities and all - no stamping step, because the
190
+ // server already answered with the messages themselves rather than a bare number to reapply
191
+ const changeCount = await cache.writeMessages(target.containerName, ProtoMap.getSyncInResponseMessages(response));
192
+ // What the store took decides whether anything is published - see the Publishing region
193
+ if (changeCount !== 0)
194
+ this.publishBusiness(await cache.readBusiness(target.containerName));
195
+ }
196
+ // One cart into one container, amending whatever is there already. Both halves of
197
+ // updateCartLocalOnly end here, differing only in which container, which id and which sender:
198
+ // the container an anonymous read filled, with a drawn id and no sender, or the membership's,
199
+ // with the id derived from it and the customer's own.
200
+ // A visitor's copy goes into the container the anonymous read filled, which is what carries it
201
+ // across a sign in: the container named for a new membership is seeded from that one - see
202
+ // syncCustomerBusiness.
203
+ async storeCart(containerName, cartMessageId, senderId, shoppingList) {
204
+ const cache = Model.getCache();
205
+ const changeCount = await cache.writeCart(containerName, ProtoMap.buildCartMessage(cartMessageId, await cache.readCart(containerName), senderId, shoppingList, BigInt(Date.now())));
206
+ // Published on what the store took, the same rule every read and push keeps
207
+ if (changeCount !== 0)
208
+ this.publishBusiness(await cache.readBusiness(containerName));
209
+ }
210
+ // The business being browsed, refused by name when there is none or it has not been read yet.
211
+ // Shared by every write, because neither has anything to write to without it.
212
+ requireBusinessBeingBrowsed() {
213
+ const businessId = this._businessId;
214
+ if (businessId === null)
215
+ throw new IllegalOperationException("No business is being browsed, so there is nothing to write to");
216
+ if (this._business.value === null)
217
+ throw new IllegalOperationException(`Business ${businessId} has not been read yet, so there is nothing to write to`);
218
+ return businessId;
219
+ }
220
+ // Everything a write needs, gathered before anything is built. Each precondition is refused by
221
+ // name rather than one unhelpful "not ready": no business browsed, one not yet loaded, no profile
222
+ // loaded, or a customer who isn't a member - the last being what the server would refuse anyway,
223
+ // for want of a link token to sign with.
224
+ requireCustomerOfBusiness() {
225
+ const businessId = this.requireBusinessBeingBrowsed();
226
+ const profile = this._profile.value;
227
+ const user = profile?.user ?? null;
228
+ if (profile === null || user === null)
229
+ throw new IllegalOperationException("No profile has been read, so who is writing is not known");
230
+ const businessLink = profile.getBusinessLinkFor(businessId);
231
+ if (businessLink === null)
232
+ throw new IllegalOperationException(`This customer is not a member of business ${businessId}, so they may not write to it`);
233
+ return {
234
+ businessLink: businessLink,
235
+ containerName: Model.getBusinessContainerName(businessLink),
236
+ senderId: user.id,
237
+ addresses: user.addresses,
238
+ };
239
+ }
240
+ // Where an order is delivered: an id into this customer's own address list (the same list
241
+ // Order.deliveryAddressId resolves against) or a full address sent directly. An id not in that
242
+ // list would leave the business holding an order it can't read, so it's refused here rather than
243
+ // sent; a full address carries everything it needs and so isn't checked against the list.
244
+ static requireDeliveryAddress(addresses, fulfillmentMode, deliveryAddressId, deliveryAddress) {
245
+ if (deliveryAddress !== null)
246
+ return;
247
+ if (deliveryAddressId === BigInt(proto_Constants.ENTITY_ID_NULL)) {
248
+ // Pick-up is the case that names neither, which is normal - see Order.deliveryAddressId
249
+ if (fulfillmentMode === proto_FulfillmentMode.DELIVERY)
250
+ throw new InvalidArgumentException("An order to be delivered has to name a delivery address");
251
+ return;
252
+ }
253
+ if (addresses.getAddress(deliveryAddressId) === null)
254
+ throw new InvalidArgumentException(`This customer has no address ${deliveryAddressId}, so an order cannot be delivered to it`);
255
+ }
256
+ // #endregion
257
+ // #region The four calls of Service, each as this model makes it
258
+ //
259
+ // Each is complete in itself: it pages, caches every page as it arrives, files what the server
260
+ // issued, and publishes what the cache then holds.
261
+ // Reads in the business being browsed. An anonymous visitor reads it straight, under its own
262
+ // businessId. A signed-in customer reads it under their own container instead - see
263
+ // getBusinessContainerName - joining as a customer first if not already linked - see
264
+ // requireBusinessLink.
265
+ async businessSyncIn() {
266
+ const businessId = this._businessId;
267
+ if (businessId === null || businessId.length === 0)
268
+ throw new IllegalOperationException("No business is being browsed, so there is nothing to read");
269
+ const profile = this._profile.value;
270
+ if (profile === null) {
271
+ await this.syncAnonymousBusiness(businessId);
272
+ return;
273
+ }
274
+ const link = await this.requireBusinessLink(businessId, profile);
275
+ const cache = Model.getCache();
276
+ const containerName = Model.getBusinessContainerName(link);
277
+ // Seeded from the plain businessId container's cache, the one time this customer's own
278
+ // container does not exist yet - sparing a full redownload of a business already browsed, and
279
+ // carrying over the cart of the visit before this sign in.
280
+ if ((await cache.readBusiness(containerName)) === null) {
281
+ const anonymousName = ContainerNames.forBusiness(businessId);
282
+ const anonymous = await cache.readBusiness(anonymousName);
283
+ if (anonymous !== null) {
284
+ await cache.writeBusiness(containerName, anonymous);
285
+ // That cart now belongs to this membership, so the copy left behind is dropped. The plain
286
+ // container is what the next anonymous visitor reads, and what the next customer to sign in
287
+ // is seeded from, and a cart is one customer's. Deleted after the copy and not before, so a
288
+ // failed copy leaves the cart where it was rather than losing it.
289
+ await cache.deleteCart(anonymousName);
290
+ }
291
+ }
292
+ const authTokens = await this.requireBusinessAuthTokens(containerName, link);
293
+ await this.syncBusinessContainer(containerName, businessId, ProtoMap.buildAuthTokens(authTokens));
294
+ }
295
+ // The plain businessId-named container: an anonymous read, before signing in or joining, or to
296
+ // learn a business's entity id while joining - see readJoinBusinessId.
297
+ async syncAnonymousBusiness(businessId) {
298
+ await this.syncBusinessContainer(ContainerNames.forBusiness(businessId), businessId, null);
299
+ }
300
+ // One business, cache first: what's already held publishes before the network is touched, so a
301
+ // returning customer sees the shop at once instead of a loading state. Cursors start from
302
+ // whatever containerName already holds, not zero, so a repeat call asks only what changed - see
303
+ // ProtoMap.buildBusinessSyncOutRequestFromCache.
304
+ async syncBusinessContainer(containerName, businessId, firstAuthTokens) {
305
+ const cache = Model.getCache();
306
+ const cached = await cache.readBusiness(containerName);
307
+ this.publishBusiness(cached);
308
+ let request = ProtoMap.buildBusinessSyncOutRequestFromCache(businessId, cached);
309
+ let authTokens = firstAuthTokens;
310
+ // What the store accepted - blobs stored plus ids deleted - which decides the publish at the end
311
+ let changeCount = 0;
312
+ for (;;) {
313
+ const page = await Model.getService().businessSyncOut(request, authTokens);
314
+ const issued = ProtoMap.getResponseAuthTokens(page);
315
+ // Written as it arrives, not at the end, so an interrupted read merely repeats itself instead
316
+ // of losing pages already received. Entities and tombstones go in together, one write per page.
317
+ changeCount += await cache.writeBusiness(containerName, page);
318
+ await this.storeIssuedTokens(issued, containerName);
319
+ // Null when this was the last page: fewer than a full page came back, or a full one advanced
320
+ // no cursor - which only a server breaking its own ordering can do
321
+ const next = ProtoMap.nextBusinessSyncOutRequest(request, page);
322
+ if (next === null)
323
+ break;
324
+ // The next page asks from where this one got to, signed with any token this one reissued
325
+ request = next;
326
+ authTokens = ProtoMap.refreshAuthTokens(authTokens, issued);
327
+ }
328
+ // A read the store took nothing from re-renders nobody - already published above from cache,
329
+ // and nothing here changes what a later read of this container answers
330
+ if (changeCount === 0)
331
+ return;
332
+ this.publishBusiness(await cache.readBusiness(containerName));
333
+ }
334
+ // The BusinessLink of this profile for the named business, joining as a customer first (once) if
335
+ // not linked yet. A business in the profile with no matching link is a server inconsistency, not
336
+ // something a join would fix, so that case throws directly.
337
+ async requireBusinessLink(businessId, profile) {
338
+ if (profile.user === null)
339
+ throw new IllegalOperationException("No profile has been read, so who is browsing is not known");
340
+ const link = Model.findBusinessLink(profile, businessId);
341
+ if (link !== null)
342
+ return link;
343
+ if (Model.findBusinessEntity(profile, businessId) !== null)
344
+ throw new InternalServerException(`This customer has no BusinessLink to business ${businessId}`);
345
+ const joinBusinessId = await this.readJoinBusinessId(businessId);
346
+ await this.profileSyncIn(ProtoMap.buildProfileSyncOutRequestFromCache(await Model.getCache().readProfile(this.requireProfileContainerName()), joinBusinessId), await this.requireProfileAuthTokens());
347
+ // Attempted once: a join that still leaves no link is the server contradicting itself, not
348
+ // something retrying would fix.
349
+ const joined = this._profile.value;
350
+ const joinedLink = joined === null ? null : Model.findBusinessLink(joined, businessId);
351
+ if (joinedLink === null)
352
+ throw new InternalServerException(`Joining business ${businessId} did not leave this customer's profile naming it`);
353
+ return joinedLink;
354
+ }
355
+ // The entity id to join, for a business this profile doesn't list yet. Read off the Business
356
+ // already held, if an anonymous visit left one behind, or off a fresh anonymous read otherwise.
357
+ async readJoinBusinessId(businessId) {
358
+ const alreadyHeld = this._business.value;
359
+ if (alreadyHeld !== null)
360
+ return alreadyHeld.id;
361
+ await this.syncAnonymousBusiness(businessId);
362
+ const business = this._business.value;
363
+ if (business === null)
364
+ throw new InternalServerException(`Business ${businessId} does not exist`);
365
+ return business.id;
366
+ }
367
+ static findBusinessEntity(profile, businessId) {
368
+ for (const business of profile.businesses)
369
+ if (business.businessId === businessId)
370
+ return business;
371
+ return null;
372
+ }
373
+ // Null unless both the business and a link naming this profile's own user are present - a
374
+ // membership is both of those together, not either alone.
375
+ static findBusinessLink(profile, businessId) {
376
+ const user = profile.user;
377
+ const businessEntity = Model.findBusinessEntity(profile, businessId);
378
+ if (user === null || businessEntity === null)
379
+ return null;
380
+ for (const link of profile.businessLinks)
381
+ if (link.businessId === businessEntity.id && link.linkedEntityId === user.id)
382
+ return link;
383
+ return null;
384
+ }
385
+ // Where this customer's own copy of a business lives, named for the BusinessLink rather than
386
+ // Business.businessId so a signed-in customer's data never mixes with an anonymous visit to the
387
+ // same storefront. Hex for the same reason as Ids.toStorageHexString: a fixed-width,
388
+ // case-sensitive name.
389
+ static getBusinessContainerName(link) {
390
+ return ContainerNames.forBusiness(Ids.toStorageHexString(link.id));
391
+ }
392
+ // The credentials that sign a call about a customer's own business container. The authentication
393
+ // token always comes from memory, never a cache read, for the same reason buildProfileAuthTokens
394
+ // reads it that way: a stored AuthTokens never persists it.
395
+ // The user token and link token are read independently off whatever this container has reissued,
396
+ // rather than as one object - storeIssuedTokens overwrites the whole blob per call, so trusting
397
+ // only that would lose whichever of the two a call didn't reissue. Each falls back to its own
398
+ // source instead: the profile container's user token, and this link's own linkToken - exactly
399
+ // what a fresh membership lacks, and what the write below now gives this container to reissue
400
+ // over. A link with no linkToken at all could never sign a call, so that is a server
401
+ // inconsistency, thrown rather than sent unsigned.
402
+ async requireBusinessAuthTokens(containerName, link) {
403
+ const authenticationMethod = this._authenticationMethod;
404
+ const authenticationToken = this._authenticationToken;
405
+ if (authenticationMethod === null || authenticationToken === null)
406
+ throw new IllegalOperationException("Nobody is signed in, so this call cannot be made");
407
+ const cache = Model.getCache();
408
+ const issuedHere = await cache.readAuthTokens(containerName);
409
+ const linkToken = issuedHere?.linkToken ?? link.linkToken;
410
+ if (linkToken === null)
411
+ throw new InternalServerException(`BusinessLink ${link.id} carries no linkToken, so it cannot sign a call`);
412
+ const profileContainerName = this.getProfileContainerName();
413
+ const userToken = issuedHere?.userToken ??
414
+ (profileContainerName === null
415
+ ? null
416
+ : ((await cache.readAuthTokens(profileContainerName))?.userToken ?? null));
417
+ const authTokens = new AuthTokens(authenticationMethod, authenticationToken, userToken, linkToken);
418
+ if (issuedHere === null)
419
+ await cache.writeAuthTokens(containerName, authTokens);
420
+ return authTokens;
421
+ }
422
+ // The profile of whoever is signed in, paged the same way. Everything it earns files in the
423
+ // profile container - this call is entirely about the caller, so its AuthTokens never carries a
424
+ // link token; the only linkToken the protocol sends travels inside a BusinessLink, stored as part
425
+ // of the profile.
426
+ async profileSyncIn(firstRequest, firstAuthTokens) {
427
+ const containerName = this.getProfileContainerName();
428
+ if (containerName === null)
429
+ return null;
430
+ const cached = await Model.getCache().readProfile(containerName);
431
+ this.publishProfile(cached);
432
+ let request = firstRequest;
433
+ let authTokens = firstAuthTokens;
434
+ let changeCount = 0;
435
+ // Tracked across every page, not just the first: a profile long enough to page could put the
436
+ // joined link and business on different pages, and two flags cost nothing next to relying on that.
437
+ const joinBusinessId = firstRequest.joinBusinessId;
438
+ let hasJoinedLink = false;
439
+ let hasJoinedBusiness = false;
440
+ for (;;) {
441
+ const page = await Model.getService().profileSyncOut(request, authTokens);
442
+ changeCount += await this.cacheProfileResponse(containerName, page);
443
+ if (ProtoMap.hasJoinedBusinessLink(joinBusinessId, page))
444
+ hasJoinedLink = true;
445
+ if (ProtoMap.hasJoinedBusiness(joinBusinessId, page))
446
+ hasJoinedBusiness = true;
447
+ const next = ProtoMap.nextProfileSyncOutRequest(request, page);
448
+ if (next === null)
449
+ break;
450
+ request = next;
451
+ // Each call after the first is signed with whatever the page before it reissued - a read of
452
+ // many pages is where a token can fall due part way through
453
+ authTokens = ProtoMap.refreshAuthTokens(authTokens, ProtoMap.getResponseAuthTokens(page));
454
+ }
455
+ // As above: nothing the store took, nothing published
456
+ let response = cached;
457
+ if (changeCount !== 0) {
458
+ response = await Model.getCache().readProfile(containerName);
459
+ this.publishProfile(response);
460
+ }
461
+ // Last, so everything that arrived is cached and published first: those pages weren't at
462
+ // fault, the join was
463
+ Model.requireJoinedBusiness(joinBusinessId, hasJoinedLink, hasJoinedBusiness);
464
+ return response;
465
+ }
466
+ // The sign-up call. Never paged: resending it would create a second user, and a caller signing up
467
+ // has at most their new User, one BusinessLink and the Business it names. It can join a business
468
+ // in the same call, and a join's requirements are the same as on a profile read - one response
469
+ // instead of several is the only difference.
470
+ async createProfileOnServer(request, authTokens) {
471
+ const response = await this.writeProfileResponse(await Model.getService().createUser(request, authTokens));
472
+ const joinBusinessId = request.joinBusinessId;
473
+ // Raised after the response is cached and published, as on a profile read - sharper here since
474
+ // the User exists regardless of the join, so discarding this response and signing up again
475
+ // would create a second one
476
+ Model.requireJoinedBusiness(joinBusinessId, ProtoMap.hasJoinedBusinessLink(joinBusinessId, response), ProtoMap.hasJoinedBusiness(joinBusinessId, response));
477
+ return response;
478
+ }
479
+ // #endregion
480
+ // #region Driving one call
481
+ // A join that was asked for but not answered. Shared by both calls that can carry a
482
+ // joinBusinessId, since what a join must return doesn't depend on which one asked: the
483
+ // BusinessLink (the membership) and the Business it names. Both are needed to reach a business -
484
+ // the link for its token, the Business for the url-friendly id everything else is built from - so
485
+ // either one missing leaves the caller told it's a customer of something it can't reach.
486
+ //
487
+ // A ServiceException, not one of Exceptions.ts: nothing the caller passed was wrong - the server
488
+ // answered 200 and left out what was asked for - so a caller can retry or carry on with what's
489
+ // cached. 500 is the closest code, and the one that says retrying is worth it.
490
+ //
491
+ // Only a joinBusinessId a server could act on is checked (Java Business.isValidBusinessId's test,
492
+ // which Constants.ENTITY_ID_NULL also fails, so "no join asked" needs no case of its own). An id
493
+ // that isn't a business entity id isn't a failure here: a server ignores it rather than refusing
494
+ // it (see THE MOCKS), and sending one is the caller's bug, not the server's.
495
+ static requireJoinedBusiness(joinBusinessId, hasBusinessLink, hasBusiness) {
496
+ if (Ids.getEntityTypeId(joinBusinessId) !== proto_Constants.BUSINESS_ENTITY_TYPE_ID)
497
+ return;
498
+ if (hasBusinessLink && hasBusiness)
499
+ return;
500
+ const missing = hasBusinessLink === hasBusiness
501
+ ? "BusinessLink or Business"
502
+ : hasBusinessLink
503
+ ? "Business"
504
+ : "BusinessLink";
505
+ throw new InternalServerException(`A join of business ${joinBusinessId} was answered with no ${missing}`);
506
+ }
507
+ // What every profile response leaves in the store: its entities cached, and whatever it reissued
508
+ // filed against the same container. Answers how much of it the store took, which is what decides
509
+ // whether anything is published. Shared by all three calls that receive one - each page of a read,
510
+ // a sign up and an update - so none of them can come to cache a response differently from the
511
+ // others, or file a token in a different place. Publishing is not in here: a read publishes once
512
+ // for a whole loop of these, and the other two once each.
513
+ async cacheProfileResponse(containerName, response) {
514
+ const changeCount = await Model.getCache().writeProfile(containerName, response);
515
+ await this.storeIssuedTokens(ProtoMap.getResponseAuthTokens(response), containerName);
516
+ return changeCount;
517
+ }
518
+ // What a non-sync-out profile call leaves behind: the response cached and published, same as a
519
+ // paged read, minus paging neither needs. Both calls that come through here - a sign up and an
520
+ // update - publish on exactly the rule a read keeps, which is what the store took. An update
521
+ // publishes nothing today only because the server puts no entities in that response: it answers
522
+ // with whatever it reissued and nothing else, the caller already knowing what it sent. The day it
523
+ // answers with the stored User - whose update number and updated time a caller cannot know - this
524
+ // publishes it with no change here.
525
+ async writeProfileResponse(response) {
526
+ const containerName = this.getProfileContainerName();
527
+ if (containerName === null)
528
+ return response;
529
+ if ((await this.cacheProfileResponse(containerName, response)) !== 0)
530
+ this.publishProfile(await Model.getCache().readProfile(containerName));
531
+ return response;
532
+ }
533
+ // Files what a call earned against the container that call was about: a profile call's tokens in
534
+ // the subject's profile container, a business call's in that business's container. Which call it
535
+ // was decides this, not which token arrived - a token carries nothing saying what it's about, so
536
+ // only the caller, which knows what it asked, can attribute one. A business sync out that reissues
537
+ // a user token still files that copy with the business it came back from.
538
+ // A profile call never carries a link token here - a membership's link token travels inside its
539
+ // BusinessLink entity instead, stored with the rest of the profile.
540
+ // A failed write is swallowed: the server issues another next call, so losing one costs a round
541
+ // trip, where surfacing it would turn a working read into a thrown one.
542
+ async storeIssuedTokens(issued, containerName) {
543
+ if (issued === undefined)
544
+ return;
545
+ try {
546
+ await Model.getCache().writeAuthTokens(containerName, ProtoMap.authTokensFromProto(issued));
547
+ }
548
+ catch {
549
+ // Swallowed - see the comment above
550
+ }
551
+ }
552
+ // #endregion
553
+ // #region Containers and credentials
554
+ // Where this subject's profile and tokens are cached, keyed by authentication method plus
555
+ // subject id since two identity providers could otherwise issue the same subject id. Null when
556
+ // signed out; several can coexist, so signing back in finds this subject's own profile.
557
+ getProfileContainerName() {
558
+ return this._authenticationMethod === null || this._subjectId === null
559
+ ? null
560
+ : ContainerNames.forProfile(`${this._authenticationMethod}.${this._subjectId}`);
561
+ }
562
+ // The profile container name, when a profile is already known to exist - throws rather than
563
+ // silently building on a signed-out state the caller has already ruled out.
564
+ requireProfileContainerName() {
565
+ const containerName = this.getProfileContainerName();
566
+ if (containerName === null)
567
+ throw new IllegalOperationException("Nobody is signed in, so this call cannot be made");
568
+ return containerName;
569
+ }
570
+ // Signs a call about the caller alone, or null if signed out. Rereads the user token from the
571
+ // profile container each time, since a call can reissue it. Carries no linkToken - see
572
+ // requireBusinessAuthTokens for a business call.
573
+ async buildProfileAuthTokens() {
574
+ const authenticationMethod = this._authenticationMethod;
575
+ const authenticationToken = this._authenticationToken;
576
+ const profileContainerName = this.getProfileContainerName();
577
+ if (authenticationMethod === null ||
578
+ authenticationToken === null ||
579
+ profileContainerName === null)
580
+ return null;
581
+ const issued = await Model.getCache().readAuthTokens(profileContainerName);
582
+ return ProtoMap.buildAuthTokens(new AuthTokens(authenticationMethod, authenticationToken, issued?.userToken ?? null, null));
583
+ }
584
+ // Every profile-only call must be signed; a signed-out visitor cannot make one.
585
+ async requireProfileAuthTokens() {
586
+ const authTokens = await this.buildProfileAuthTokens();
587
+ if (authTokens === null)
588
+ throw new IllegalOperationException("Nobody is signed in, so this call cannot be made");
589
+ return authTokens;
590
+ }
591
+ // #endregion
592
+ // #region Publishing
593
+ //
594
+ // What a read publishes turns on how much the store accepted - blobs stored plus ids deleted,
595
+ // summed across every page - not on what the response carried. They differ because the store
596
+ // keeps a blob only when its eTag is later, so a response the server sent can still be rejected
597
+ // in full - a cursor that moved without its eTag moving is resent an entity already cached.
598
+ // Publishing that would re-render every observer over an identical value, which the count avoids,
599
+ // and nothing is lost: a response the store took nothing from can't change what a later read
600
+ // answers.
601
+ // All four calls use this, which is why an update publishes nothing - see writeProfileResponse.
602
+ publishBusiness(response) {
603
+ this._business.value =
604
+ response === null ? null : ProtoMap.buildBusiness(response);
605
+ }
606
+ publishProfile(response) {
607
+ this._profile.value =
608
+ response === null || this._subjectId === null
609
+ ? null
610
+ : ProtoMap.buildProfile(this._subjectId, response);
611
+ }
612
+ // #endregion
613
+ static getService() {
614
+ const service = Model._service;
615
+ if (service === null)
616
+ throw new IllegalOperationException("Model.initialize has not been called");
617
+ return service;
618
+ }
619
+ static getCache() {
620
+ const cache = Model._cache;
621
+ if (cache === null)
622
+ throw new IllegalOperationException("Model.initialize has not been called");
623
+ return cache;
624
+ }
625
+ }
626
+ Model._service = null;
627
+ Model._cache = null;
628
+ Model._instance = null;
629
+ export default Model;