@peabody-soft/mbs-model-ts 1.7.0 → 1.8.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 (64) hide show
  1. package/dist/Attachment.js +2 -2
  2. package/dist/AttachmentBaseUrl.js +4 -4
  3. package/dist/AuthTokens.js +7 -7
  4. package/dist/Business.js +11 -10
  5. package/dist/BusinessIdentifier.js +3 -2
  6. package/dist/BusinessLink.js +3 -2
  7. package/dist/CartEditor.js +89 -81
  8. package/dist/Catalog.d.ts +19 -7
  9. package/dist/Catalog.js +121 -41
  10. package/dist/Constants.d.ts +1 -0
  11. package/dist/Constants.js +7 -5
  12. package/dist/ContainerNames.js +6 -6
  13. package/dist/Entity.js +4 -3
  14. package/dist/Enums.d.ts +1 -0
  15. package/dist/Enums.js +3 -2
  16. package/dist/Exceptions.js +2 -2
  17. package/dist/GeneralSettings.d.ts +5 -4
  18. package/dist/GeneralSettings.js +17 -15
  19. package/dist/HttpStatusCodes.js +7 -7
  20. package/dist/Ids.js +4 -5
  21. package/dist/Item.js +8 -5
  22. package/dist/LiveData.js +2 -2
  23. package/dist/Measures.js +3 -2
  24. package/dist/Message.js +1 -1
  25. package/dist/MockDatabase.js +6 -5
  26. package/dist/Model.js +166 -162
  27. package/dist/Order.js +10 -12
  28. package/dist/Package.d.ts +6 -2
  29. package/dist/Package.js +10 -5
  30. package/dist/PackageIdentifier.js +2 -2
  31. package/dist/Price.js +6 -5
  32. package/dist/PriceList.d.ts +13 -0
  33. package/dist/PriceList.js +34 -2
  34. package/dist/Product.d.ts +6 -2
  35. package/dist/Product.js +15 -10
  36. package/dist/Profile.js +15 -14
  37. package/dist/ProtoMap.js +189 -206
  38. package/dist/ServiceExceptions.js +6 -6
  39. package/dist/ServiceProxy.js +15 -14
  40. package/dist/ShoppingList.js +102 -100
  41. package/dist/SyncCache.js +76 -68
  42. package/dist/SyncKeys.js +5 -5
  43. package/dist/SyncState.js +14 -6
  44. package/dist/Tax.js +11 -10
  45. package/dist/TaxHelper.js +9 -8
  46. package/dist/TaxType.js +7 -6
  47. package/dist/UserProfile.js +7 -7
  48. package/dist/WorkingDays.js +21 -18
  49. package/dist/generated/BusinessSyncOutResponse.d.ts +4 -1
  50. package/dist/generated/Constants.d.ts +0 -4
  51. package/dist/generated/Constants.js +0 -4
  52. package/dist/generated/GeneralSettings.d.ts +5 -4
  53. package/dist/generated/GeneralSettings.js +8 -6
  54. package/dist/generated/Item.d.ts +4 -3
  55. package/dist/generated/Message.d.ts +5 -0
  56. package/dist/generated/OnlineStoreTheme.d.ts +33 -0
  57. package/dist/generated/OnlineStoreTheme.js +38 -0
  58. package/dist/generated/PriceList.d.ts +3 -2
  59. package/dist/generated/ProfileSyncOutRequest.d.ts +16 -15
  60. package/dist/generated/ProfileSyncOutResponse.d.ts +4 -6
  61. package/dist/generated/SalesOrderPayload.d.ts +6 -3
  62. package/dist/index.d.ts +4 -0
  63. package/dist/index.js +11 -8
  64. package/package.json +2 -2
package/dist/Model.js CHANGED
@@ -13,23 +13,25 @@ import ShoppingList from "./ShoppingList";
13
13
  import SyncCache from "./SyncCache";
14
14
  import * as SyncKeys from "./SyncKeys";
15
15
  import SyncState from "./SyncState";
16
- // The root of the object model, and all a UI has to be given. Call initialize once, then get the
16
+ // The root of the object model, and the only object a UI needs. Call initialize once, then get the
17
17
  // current instance with getInstance.
18
18
  //
19
19
  // THERE IS NO LOCKING. A caller makes one call at a time and awaits it. Otherwise:
20
20
  // - A setBusinessId still reading when signIn reads the business too can publish the anonymous
21
- // container's business over the customer's, with no cart or orders. Nothing republishes them.
21
+ // container's business over the customer's. The published business then has no cart or orders,
22
+ // and nothing publishes them again.
22
23
  // - Two reads at once interleave their pages and writes, as do two tabs on one store (each holds a
23
24
  // Model), so a store supports one tab at a time.
24
25
  // A queue inside Model would not fix the tabs, and each public method would need an unqueued twin
25
26
  // (signIn calls signOut, businessSyncIn calls updateCart). A caller that calls from several places,
26
- // like a UI, queues its own calls.
27
+ // like a UI, queues the calls itself.
27
28
  class Model {
28
29
  constructor() {
29
30
  // True when the published business, if any, has been read to the end and found to be the one
30
- // _businessId names and to be open. A read's cache-first publish is of what an earlier read left,
31
- // and is neither until the read ends. A read that does not end (network failure, a refusal)
32
- // leaves it published and unvetted. See readJoinBusinessId, which does not join such a business.
31
+ // _businessId names and to be open. The cache-first publish of a read holds what an earlier read
32
+ // stored, which is not known to be either until the read ends. A read that does not end (network
33
+ // failure, a rejection) leaves the business published and unvetted. See readJoinBusinessId, which
34
+ // does not join such a business.
33
35
  this._isPublishedBusinessVetted = false;
34
36
  this._business = new LiveData(null);
35
37
  this._profile = new LiveData(null);
@@ -38,7 +40,8 @@ class Model {
38
40
  this._authenticationToken = null;
39
41
  this._businessId = null;
40
42
  }
41
- // Sets the service and store to use, and forgets who is signed in and what was being browsed
43
+ // Sets the service and store to use. Discards the current instance, so nobody is signed in and no
44
+ // business is browsed.
42
45
  static initialize(service, database) {
43
46
  Model._service = service;
44
47
  Model._cache = new SyncCache(database);
@@ -52,17 +55,17 @@ class Model {
52
55
  get business() {
53
56
  return this._business;
54
57
  }
55
- // The customer, their businesses, and the link to each - one LiveData since useful questions
56
- // span all three. Null until signed in and loaded.
58
+ // The customer, their businesses, and the link to each, in one LiveData because a UI usually
59
+ // needs all three together. Null until signed in and loaded.
57
60
  get profile() {
58
61
  return this._profile;
59
62
  }
60
63
  // Signs in and syncs this subject's profile in, for a subject the server has a profile for (use
61
64
  // createUser otherwise). Also reads in the business being browsed, if any, as this customer.
62
65
  //
63
- // Anything thrown after beginSignIn's guard (a bad credential, a refused push while reading the
66
+ // Anything thrown after beginSignIn's guard (a bad credential, a rejected push while reading the
64
67
  // business) calls signOut. profileSyncIn publishes a cached profile before it reaches the
65
- // network, so a retry could otherwise find _profile set and trip the "already signed in" guard.
68
+ // network, so a retry could otherwise find _profile set and fail the "already signed in" guard.
66
69
  // Throws NotFoundException if the business being browsed cannot be opened. See businessSyncIn.
67
70
  async signIn(authenticationMethod, subjectId, authenticationToken) {
68
71
  this.beginSignIn(authenticationMethod, subjectId, authenticationToken);
@@ -80,7 +83,7 @@ class Model {
80
83
  // Signs up and creates userToCreate as this subject's profile, for a subject the server has no
81
84
  // profile for (use signIn otherwise). Also reads in the business being browsed, if any, as this
82
85
  // customer. Throws NotFoundException if that business cannot be opened (see businessSyncIn), after
83
- // the User exists. Nothing undoes it: the customer stays signed up and signed in.
86
+ // the User exists. The sign-up is not undone: the customer stays signed up and signed in.
84
87
  async createUser(authenticationMethod, subjectId, authenticationToken, userToCreate) {
85
88
  this.beginSignIn(authenticationMethod, subjectId, authenticationToken);
86
89
  const authTokens = await this.requireProfileAuthTokens();
@@ -90,9 +93,9 @@ class Model {
90
93
  }
91
94
  // Updates this customer's own profile with what userProfile holds. Service.updateUser matches the
92
95
  // caller against the User by entity id (see ProtoMap.buildUpdateUser), so userProfile must be this
93
- // customer's own, read in earlier, not built fresh. Needs a profile already read (see
94
- // requireCustomerOfBusiness). Not paged. Publishes nothing today: the response carries no
95
- // entities, only reissued tokens (see writeProfileResponse).
96
+ // customer's own, read in earlier, not built fresh. Throws if no profile has been read. Not
97
+ // paged. Publishes nothing today: the response carries no entities, only reissued tokens (see
98
+ // writeProfileResponse).
96
99
  async updateUser(userProfile) {
97
100
  if (this._profile.value === null)
98
101
  throw new IllegalOperationException("Nobody is signed in, so this call cannot be made");
@@ -103,18 +106,18 @@ class Model {
103
106
  Model._instance = null;
104
107
  }
105
108
  // Names the business to browse and reads it in. Publishes null first when replacing a Business,
106
- // so the old one is not shown as the one now browsed. A re-read of the current shop does not,
107
- // since that would blank the UI and undo syncBusinessContainer's cache-first publish.
109
+ // so the old one is not shown as the one now browsed. Re-reading the current business does not,
110
+ // because that would blank the UI and discard syncBusinessContainer's cache-first publish.
108
111
  // Throws NotFoundException if the business cannot be opened (see businessSyncIn). What was cached
109
- // is published before the network is asked, unless online shopping is off (see publishBusiness),
110
- // and is taken back if the read finds the business cannot be opened.
111
- // The businessId is held in canonical form (see canonicalizeBusinessId), as the server finds it,
112
- // so /WARM-LOAF and /warm-loaf are one storefront, one container and one download. The server
113
- // canonicalizes what it is sent.
112
+ // is published before the network call, unless online shopping is off (see publishBusiness), and
113
+ // is replaced with null if the read finds the business cannot be opened.
114
+ // The businessId is held in canonical form (see canonicalizeBusinessId), the form the server finds
115
+ // a business by, so /WARM-LOAF and /warm-loaf are one storefront, one container and one download.
116
+ // The server canonicalizes the id it is sent.
114
117
  async setBusinessId(businessId) {
115
- // An id of only spaces has no canonical form (Java refuses it), so it is held as written. The
116
- // server then answers with the NotFoundException a UI shows for a missing business, and this
117
- // does not call it a mistake.
118
+ // An id of only spaces has no canonical form (Java rejects it), so it is held as written. The
119
+ // server then answers with the NotFoundException a UI shows for a missing business. This method
120
+ // does not throw for it.
118
121
  const canonicalBusinessId = canonicalizeBusinessId(businessId) || businessId;
119
122
  const isDifferentBusiness = this._businessId !== canonicalBusinessId;
120
123
  this._businessId = canonicalBusinessId;
@@ -126,18 +129,19 @@ class Model {
126
129
  //
127
130
  // The only writes in this library: this customer's cart at the business being browsed, and an
128
131
  // order placed from it. Both need a customer who is a member of the business (see
129
- // requireCustomerOfBusiness).
132
+ // requireCustomerOfBusiness), except that a signed-out visitor can keep a cart locally (see
133
+ // updateCartLocalOnly).
130
134
  // An order reaches the store only after the server accepts it, so a customer never holds an order
131
- // the merchant has not heard of, and a failed call leaves nothing to reconcile. A cart is not held
132
- // to that (see updateCartLocalOnly).
135
+ // the server has not accepted, and a failed call leaves nothing to reconcile. A cart can be
136
+ // stored before the server has it (see updateCartLocalOnly).
133
137
  // Creates or replaces this customer's cart, on the server and in the store. An empty list
134
138
  // empties the cart, the same state as never having had one.
135
- // Signed out, this is updateCartLocalOnly, as the server has no cart for a visitor. Signed in, it
136
- // costs a request, so an editor should write each change with updateCartLocalOnly and call this on
137
- // its own schedule (debounced, on blur, a timer).
139
+ // Signed out, this calls updateCartLocalOnly, as the server has no cart for a visitor. Signed in,
140
+ // it costs a request, so an editor should write each change with updateCartLocalOnly and call this
141
+ // on a schedule (debounced, on blur, on a timer).
138
142
  // A call that matches the stored copy returns without a request (hasShoppingListChanged). It does
139
- // not replace reading the business: a push answers with the messages it pushed only, so it brings
140
- // back nobody else's changes.
143
+ // not replace a read of the business: the response to a push holds only the pushed messages, so
144
+ // it brings no changes made by others.
141
145
  async updateCart(shoppingList) {
142
146
  if (this._profile.value === null)
143
147
  return this.updateCartLocalOnly(shoppingList);
@@ -148,37 +152,38 @@ class Model {
148
152
  const partyPreferencesMessage = ProtoMap.buildPartyPreferencesMessage(Ids.getPartyPreferencesMessageId(target.businessLink.id), stored, target.senderId, shoppingList, null, BigInt(Date.now()));
149
153
  await this.pushBusinessMessages(target, [partyPreferencesMessage]);
150
154
  }
151
- // The same, stored locally and not sent. Call it on every edit (a request per tap of a quantity
152
- // stepper is not worth making), and updateCart on your own schedule to send.
155
+ // The same, but stored locally and not sent. Call it on every edit (a request per tap of a
156
+ // quantity stepper is too many), and call updateCart on a schedule to send.
153
157
  // Unsent, nothing is lost: it survives a closed tab, the next updateCart sends it, and createOrder
154
- // reads from the store. The cost is that the server, and so this customer's other devices, hear of
158
+ // reads from the store. The cost is that the server, and so this customer's other devices, receive
155
159
  // the change late.
156
160
  // A stale sync cannot bring an older server copy back over it: the store versions an amendment at
157
161
  // the update number of the copy it amends, buildPartyPreferencesMessage dates it past that copy,
158
162
  // and the later copy wins.
159
163
  async updateCartLocalOnly(shoppingList) {
160
- // No membership to derive a message id from while signed out, so a fresh one is drawn. It
161
- // names the cart only for the first edit: buildPartyPreferencesMessage reuses the stored
162
- // cart's id, so later edits amend one entity. The sender is ENTITY_ID_NULL for want of a party;
163
- // the first push after signing in names the real one.
164
+ // A signed-out visitor has no membership to derive a message id from, so a fresh id is drawn.
165
+ // It is the cart's id only for the first edit: buildPartyPreferencesMessage reuses the stored
166
+ // cart's id, so later edits amend one entity. With no party, the sender is ENTITY_ID_NULL. The
167
+ // first push after signing in carries the real one.
164
168
  if (this._profile.value === null)
165
169
  return this.storeCartInCache(ContainerNames.forBusiness(this.requireBusinessBeingBrowsed()), Ids.getNewId(proto_Constants.MESSAGE_ENTITY_TYPE_ID), BigInt(proto_Constants.ENTITY_ID_NULL), shoppingList);
166
170
  const target = this.requireCustomerOfBusiness();
167
171
  return this.storeCartInCache(target.containerName, Ids.getPartyPreferencesMessageId(target.businessLink.id), target.senderId, shoppingList);
168
172
  }
169
173
  // Places an order for what is in the cart and empties the cart, as Java
170
- // SalesOrderViewModel.saveChanges does on MODE_CREATE. So it takes no shopping list.
171
- // The emptied cart is stamped with this fulfillmentMode, which Business.defaultFulfillmentMode
172
- // reads back.
173
- // The caller mints orderId with Ids.getNewId(Constants.MESSAGE_ENTITY_TYPE_ID), so it can find the
174
- // placed order, and can retry a call a NetworkConnectionException left unresolved with the same
175
- // id, without placing a second order. getPartyPreferencesMessageId gives a cart the same guarantee.
174
+ // SalesOrderViewModel.saveChanges does on MODE_CREATE. It takes no shopping list because it reads
175
+ // the cart from the store.
176
+ // The emptied cart records this fulfillmentMode, which Business.defaultFulfillmentMode returns.
177
+ // The caller creates orderId with Ids.getNewId(Constants.MESSAGE_ENTITY_TYPE_ID), so it can find
178
+ // the placed order, and can retry a call that a NetworkConnectionException left unresolved using
179
+ // the same id, without placing a second order. getPartyPreferencesMessageId gives a cart the same
180
+ // guarantee.
176
181
  // A delivery must name an address: an id from this customer's list, or a full address sent
177
182
  // directly (see requireDeliveryAddress).
178
183
  async createOrder(orderId, fulfillmentMode, deliveryAddressId, deliveryAddress, note) {
179
184
  const target = this.requireCustomerOfBusiness();
180
- // Read from the store, not the published Business, so the order reflects the cart as stored,
181
- // not a value an observer may still hold
185
+ // Read from the store rather than the published Business, so the order matches the stored cart
186
+ // and not a copy an observer may hold
182
187
  const partyPreferencesMessage = await Model.getCache().readPartyPreferences(target.containerName);
183
188
  const shoppingList = partyPreferencesMessage === null
184
189
  ? ShoppingList.EMPTY
@@ -186,7 +191,7 @@ class Model {
186
191
  if (shoppingList.existingItems.length === 0)
187
192
  throw new IllegalOperationException("The cart holds nothing, so there is no order to place");
188
193
  Model.requireDeliveryAddress(target.addresses, fulfillmentMode, deliveryAddressId, deliveryAddress);
189
- // One time for both messages, which is when the order was placed
194
+ // One timestamp for both messages: the time the order was placed
190
195
  const now = BigInt(Date.now());
191
196
  // One request for the order and the emptied cart: two calls could leave an order placed and the
192
197
  // cart still full if the second failed
@@ -207,12 +212,12 @@ class Model {
207
212
  this._authenticationToken = authenticationToken;
208
213
  }
209
214
  // #region Pushing
210
- // One push, plus everything that follows the server accepting it. The messages go out in one
211
- // request with how far this business container's reads have got. The server answers with these
212
- // messages under their assigned update numbers and nothing more: a push is not a read. They are
213
- // stored and move no cursor, so the next read receives them again, with anything somebody else
214
- // changed in the meantime. A refused push fails the call itself, like any Service call on a bad
215
- // status, so reaching the write below means the server took it.
215
+ // Pushes the messages in one request, together with the update number this business container's
216
+ // reads have reached, then stores the response. The server answers with these messages under their
217
+ // assigned update numbers and nothing else: a push is not a read. They are stored without moving
218
+ // any cursor, so the next read receives them again, along with anything others changed in the
219
+ // meantime. A rejected push throws from the Service call itself, as for any bad status, so
220
+ // reaching the write below means the server accepted it.
216
221
  async pushBusinessMessages(target, messages) {
217
222
  const cache = Model.getCache();
218
223
  const messageUpdateNumber = await cache.readMessageUpdateNumber(target.containerName);
@@ -227,9 +232,7 @@ class Model {
227
232
  this.publishBusiness(await cache.readBusiness(target.containerName));
228
233
  }
229
234
  // Amends the cart containerName holds and writes the result to the cache. Nothing is pushed.
230
- // The two paths of updateCartLocalOnly differ in container, id and sender: a visitor's has a
231
- // freshly drawn id and no sender, a customer's has the id their membership derives and their own
232
- // sender id.
235
+ // The two callers differ in container, id and sender: see updateCartLocalOnly.
233
236
  async storeCartInCache(containerName, messageId, senderId, shoppingList) {
234
237
  const cache = Model.getCache();
235
238
  const changeCount = await cache.writePartyPreferences(containerName, ProtoMap.buildPartyPreferencesMessage(messageId, await cache.readPartyPreferences(containerName), senderId, shoppingList, null, BigInt(Date.now())));
@@ -249,7 +252,8 @@ class Model {
249
252
  }
250
253
  // Everything a write needs, gathered before anything is built. Each missing precondition throws
251
254
  // its own error: no business browsed, business not loaded, no profile loaded, or a customer who
252
- // is not a member. The server would refuse the last anyway, with no link token to sign with.
255
+ // is not a member. The server would reject the last anyway, since there is no link token to sign
256
+ // with.
253
257
  requireCustomerOfBusiness() {
254
258
  const businessId = this.requireBusinessBeingBrowsed();
255
259
  const business = this._business.value;
@@ -270,13 +274,13 @@ class Model {
270
274
  }
271
275
  // Checks where an order is delivered: an id in this customer's address list (the list
272
276
  // Order.deliveryAddressId resolves against) or a full address sent directly. An id not in the
273
- // list would leave the business with an order it cannot read, so it is refused here. A full
277
+ // list would leave the business with an order it cannot read, so it is rejected here. A full
274
278
  // address needs no check against the list.
275
279
  static requireDeliveryAddress(addresses, fulfillmentMode, deliveryAddressId, deliveryAddress) {
276
280
  if (deliveryAddress !== null)
277
281
  return;
278
282
  if (deliveryAddressId === BigInt(proto_Constants.ENTITY_ID_NULL)) {
279
- // Pick-up names neither, which is normal. See Order.deliveryAddressId
283
+ // A pick-up has neither, which is normal. See Order.deliveryAddressId
280
284
  if (fulfillmentMode === proto_FulfillmentMode.DELIVERY)
281
285
  throw new InvalidArgumentException("An order to be delivered has to name a delivery address");
282
286
  return;
@@ -287,19 +291,20 @@ class Model {
287
291
  // #endregion
288
292
  // #region The four calls of Service, each as this model makes it
289
293
  //
290
- // Each pages, caches every page as it arrives, files what the server issued, and publishes what
291
- // the cache then holds.
294
+ // Each caches the response (a read caches every page as it arrives), files the tokens the server
295
+ // issued, and publishes what the cache then holds. A read is paged. A sign up and an update are not.
292
296
  // Reads in the business being browsed: an anonymous visitor under its businessId, a signed-in
293
297
  // customer under their own container (see getBusinessContainerName), joining as a customer first
294
298
  // if not linked (see requireBusinessLink).
295
299
  // Throws NotFoundException when the business cannot be opened, on a first read or a later one (a
296
- // business can stop being open after it was cached). The server says so when it does not exist,
297
- // is deleted, or does not allow customers to join. Online shopping off is decided here (see
298
- // syncBusinessContainer). Having nothing to buy does not: that business is open, with an empty
299
- // catalog. The cache is kept, except that a business that is no longer the one the businessId
300
- // names is replaced by the one that is.
301
- // isStartedOver marks the one self-call, when the link the profile found for the businessId is of
302
- // a business that no longer has it. The profile is read again and this starts over from it.
300
+ // business can stop being open after it was cached). The server throws it when the business does
301
+ // not exist, is deleted, or does not allow customers to join. This method throws it too when
302
+ // online shopping is off (see syncBusinessContainer). A business with nothing to buy is open,
303
+ // with an empty catalog. The cache is kept, unless it holds a business other than the one the
304
+ // businessId now names, which is replaced.
305
+ // isStartedOver is true on the single recursive call. That call happens when the profile's link
306
+ // for the businessId belongs to a business that no longer has it: the profile is read again and
307
+ // this method starts over.
303
308
  async businessSyncIn(isStartedOver = false) {
304
309
  const businessId = this._businessId;
305
310
  if (businessId === null || businessId.length === 0)
@@ -336,11 +341,11 @@ class Model {
336
341
  const authTokens = await this.requireBusinessAuthTokens(containerName, link);
337
342
  // The link names the business by entity id, because the link token that signs this call is for it
338
343
  const isBusinessNamed = await this.syncBusinessContainer(containerName, businessId, link.businessId, ProtoMap.buildAuthTokens(authTokens));
339
- // The link's business has another businessId now, which the profile that found the link by this
340
- // one did not know. The container is given up, so the profile is read again: the link is then
341
- // found by this businessId or not, and a businessId naming another business is joined. Only
342
- // once: a re-read profile that still finds the same link for the same businessId means the
343
- // server contradicts itself
344
+ // The link's business has a different businessId than the profile knew when it found the link
345
+ // by this one. The container is discarded and the profile is read again. The link is then found
346
+ // by this businessId or not, and if the businessId names another business, that business is
347
+ // joined. This happens only once: if a re-read profile still finds the same link for the same
348
+ // businessId, the server contradicts itself
344
349
  if (!isBusinessNamed) {
345
350
  if (isStartedOver)
346
351
  throw new InternalServerException(`The business that the link for the businessId ${businessId} is of does not have it, though the profile was read again`);
@@ -354,8 +359,8 @@ class Model {
354
359
  return;
355
360
  // The visitor's cart holds taxes as raw codes, meaningful only within the country of the
356
361
  // business it was built in. That country is read from the anonymous container. A cart is stored
357
- // only after its business has been (see updateCartLocalOnly); failing that, the business being
358
- // browsed is the same one.
362
+ // only after its business has been read (see updateCartLocalOnly), so the container normally
363
+ // has the country. If it has none, the business being browsed is the same business.
359
364
  const anonymousCountryCode = ProtoMap.getBusinessResponseCountryCode(anonymous) ?? this._business.value?.countryCode;
360
365
  if (anonymousCountryCode === undefined)
361
366
  throw new IllegalOperationException("The business the visitor's cart was built in is not known, so its taxes cannot be read");
@@ -375,28 +380,27 @@ class Model {
375
380
  async syncAnonymousBusiness(businessId) {
376
381
  await this.syncBusinessContainer(ContainerNames.forBusiness(businessId), businessId, null, null);
377
382
  }
378
- // Reads one business into containerName, cache first: what is held publishes before the network
379
- // is touched, so a returning customer sees the shop at once. Cursors start where earlier reads got
380
- // to (see SyncCache.readBusinessCursors), so a repeat call asks only what changed.
383
+ // Reads one business into containerName, cache first: the cached business is published before any
384
+ // network call, so a returning customer sees the shop at once. Cursors start where earlier reads
385
+ // got to (see SyncCache.readBusinessCursors), so a repeat call requests only what changed.
381
386
  // The first request names the business by entity id where known (businessEntityId from a link,
382
387
  // else the one in the container), else by businessId, and every later page does the same.
383
- // firstAuthTokens are the link's and sign the reads by entity id. A read by businessId is signed
384
- // with nothing.
388
+ // firstAuthTokens are the link's and sign the reads by entity id. A read by businessId is sent
389
+ // with no tokens.
385
390
  //
386
391
  // Whether the business can be opened is decided from the container once every page is written,
387
392
  // since a page carries only what changed. After every read, in order:
388
393
  // 1. The container holds the business businessId names. A business keeps its entity id when its
389
394
  // businessId changes and another can take the old one, so a container read by entity id may
390
- // hold the wrong business. It is given up and read again from the start by businessId, once,
395
+ // hold the wrong business. It is discarded and read again from the start by businessId, once,
391
396
  // which finds the right business or none (NotFoundException from the server).
392
- // A read named by a link cannot be redone that way (a link token is refused with a
393
- // businessId). Its container is given up and this answers false, so the caller starts again
394
- // from a re-read profile (see businessSyncIn). Otherwise this answers true.
397
+ // A read named by a link cannot be redone that way (a link token is rejected with a
398
+ // businessId). Its container is discarded and this returns false, so the caller starts again
399
+ // from a re-read profile (see businessSyncIn). Otherwise this returns true.
395
400
  // 2. Online shopping is on in the general settings (no settings means off). The server does not
396
- // refuse a business for this, so this throws NotFoundException.
397
- // Nothing more is asked of a business. One with nothing a customer can buy (no product that is
398
- // enabled, not sold only offline, and has a priced package) is open: it is published with an empty
399
- // catalog, and a UI tells its customers there is nothing to buy.
401
+ // reject a business for this, so this throws NotFoundException.
402
+ // A business with nothing to buy (no enabled product that is sold online and has a priced
403
+ // package) is open, and is published with an empty catalog.
400
404
  async syncBusinessContainer(containerName, businessId, businessEntityId, firstAuthTokens) {
401
405
  const cache = Model.getCache();
402
406
  const cached = await cache.readBusiness(containerName);
@@ -407,14 +411,15 @@ class Model {
407
411
  // does. It may not be what the businessId finds now: a business can give up its businessId to
408
412
  // another, or change it.
409
413
  const cachedEntityId = ProtoMap.getBusinessResponseBusinessEntityId(cached);
410
- // What the store accepted (the blobs it stored). Decides the publish at the end.
414
+ // How much the store accepted: blobs stored plus ids deleted, as in the Publishing region.
415
+ // Decides the publish at the end.
411
416
  let changeCount;
412
417
  try {
413
418
  changeCount = await this.readBusinessPages(containerName, businessEntityId ?? cachedEntityId ?? businessId, await cache.readBusinessCursors(containerName), ProtoMap.getBusinessResponseAttachmentBaseUrlExpiry(cached), firstAuthTokens);
414
419
  }
415
420
  catch (error) {
416
- // The server says the business cannot be opened, so what was published from the cache is not
417
- // what is there. The container is left as it is: deciding that a business is gone is the caller's
421
+ // The server says the business cannot be opened, so the business published from the cache is
422
+ // out of date. The container is left as it is, because the caller decides whether the business is gone
418
423
  if (error instanceof NotFoundException)
419
424
  this.publishBusiness(null);
420
425
  throw error;
@@ -422,14 +427,15 @@ class Model {
422
427
  let held = await cache.readBusiness(containerName);
423
428
  // Whether what was published is no longer what the container holds
424
429
  let isPublishedStale = changeCount !== 0;
425
- // Named by the cached entity id, not by a link, which names only its own business
430
+ // For a read named by the cached entity id. A link names only its own business, so a read named
431
+ // by a link is handled below
426
432
  if (businessEntityId === null && cachedEntityId !== null && !Model.isBusinessNamed(held, businessId)) {
427
- // Nothing read of one business says anything about another, so none of the container is kept,
428
- // nor how far it was read. The business the businessId names is read from the beginning, with
429
- // no attachment url held: the expiry is zero, not the container's, because the url it held
430
- // was the old business's and went with it. A server sent that expiry would take the new
431
- // business to have the url and send none, leaving every photo without one. Signed with
432
- // nothing, as a link token is refused with a businessId
433
+ // Nothing read from one business applies to another, so the whole container is discarded,
434
+ // along with its cursors. The business the businessId names is read from the beginning, with
435
+ // an attachment url expiry of zero instead of the container's, because the url the container
436
+ // held belonged to the old business and is gone. If the server were sent the container's
437
+ // expiry, it would take the new business to have the url and send none, leaving every photo
438
+ // without one. The read is sent with no tokens, as a link token is rejected with a businessId
433
439
  await cache.deleteBusiness(containerName);
434
440
  this.publishBusiness(null);
435
441
  await this.readBusinessPages(containerName, businessId, new SyncState(SyncKeys.BUSINESS_SYNC_IN), 0n, null);
@@ -439,27 +445,25 @@ class Model {
439
445
  // The businessId finds the business that has it, so any other is the server contradicting itself
440
446
  if (businessEntityId === null && !Model.isBusinessNamed(held, businessId))
441
447
  throw new InternalServerException(`The business that was read for the businessId ${businessId} has the businessId ${ProtoMap.getBusinessResponseBusinessId(held)}`);
442
- // Named by a link whose business no longer has the businessId. It cannot be re-read by
443
- // businessId (a link token is refused with one), so the container is given up as above and the
444
- // caller starts again
448
+ // For a read named by a link whose business no longer has the businessId. It cannot be re-read
449
+ // by businessId (a link token is rejected with one), so the container is discarded as above and
450
+ // the caller starts again
445
451
  if (businessEntityId !== null && !Model.isBusinessNamed(held, businessId)) {
446
452
  await cache.deleteBusiness(containerName);
447
453
  this.publishBusiness(null);
448
454
  return false;
449
455
  }
450
- // A container holding no business says nothing about online shopping. That is a failure of its
451
- // own, not a business with it off (see readJoinBusinessId)
456
+ // A container with no business has no online shopping setting to check. A read that ends with no
457
+ // business is a server failure, not a business with online shopping off (see readJoinBusinessId)
452
458
  if (ProtoMap.getBusinessResponseBusinessEntityId(held) !== null && !ProtoMap.isBusinessResponseOnlineShoppingOn(held)) {
453
- // What was published from the cache is the business as it was when open, not as it is now.
454
- // Nothing of a business that cannot be opened stays published, or a customer could be joined
455
- // to it (see readJoinBusinessId)
459
+ // What was published from the cache is the business as it was when open. A business that
460
+ // cannot be opened must not stay published, or a customer could be joined to it (see
461
+ // readJoinBusinessId)
456
462
  this.publishBusiness(null);
457
463
  throw new NotFoundException("The business has not turned online shopping on");
458
464
  }
459
- // A read the store took nothing from re-renders nobody: the cache was published above, and
460
- // nothing here changes what a later read answers.
461
- // A business with nothing a customer can buy is published like any other. It is open, and says
462
- // so with an empty catalog (Catalog.productCount), which a UI words for its customers.
465
+ // Publish what the container now holds if it changed during the read. Otherwise the cache
466
+ // published at the start is still current.
463
467
  if (isPublishedStale)
464
468
  this.publishBusiness(held);
465
469
  // What is published now was read to the end, is the business the businessId names, and is open
@@ -467,10 +471,10 @@ class Model {
467
471
  return true;
468
472
  }
469
473
  // Reads every page the server has for the given cursors and writes each into the container as it
470
- // arrives. The business is named by entity id or businessId, decided by the first request, and
471
- // every later page names it the same way. Answers what the store accepted (the blobs it stored).
472
- // firstAuthTokens are those of a link. A link token is refused with a businessId, so a read
473
- // naming the business by it is sent none, even if given some.
474
+ // arrives. The first request names the business by entity id or by businessId, and every later
475
+ // page names it the same way. Returns how much the store accepted: blobs stored plus ids deleted,
476
+ // summed over the pages. firstAuthTokens are those of a link. A link token is rejected with a
477
+ // businessId, so a read that names the business by it sends no tokens, even if given some.
474
478
  async readBusinessPages(containerName, business, cursors, attachmentBaseUrlExpiry, firstAuthTokens) {
475
479
  const cache = Model.getCache();
476
480
  let request = ProtoMap.buildBusinessSyncOutRequest(business, cursors, attachmentBaseUrlExpiry);
@@ -488,30 +492,28 @@ class Model {
488
492
  const next = ProtoMap.nextBusinessSyncOutRequest(request, page);
489
493
  if (next === null)
490
494
  return changeCount;
491
- // The next page asks from where this one got to, signed with any token this one reissued
495
+ // The next request continues from where this page ended, signed with any token this page reissued
492
496
  request = next;
493
497
  authTokens = ProtoMap.refreshAuthTokens(authTokens, issued);
494
498
  }
495
499
  }
496
- // True when the response holds the business businessId names, or no business, which says nothing
497
- // against it. Only a business with another businessId is another business. Ids are compared as
498
- // the server finds them, ignoring case and spacing (see canonicalizeBusinessId)
500
+ // True when the response holds the business businessId names, or holds no business, which does
501
+ // not contradict it. Ids are compared as the server finds them, ignoring case and spacing (see
502
+ // canonicalizeBusinessId)
499
503
  static isBusinessNamed(response, businessId) {
500
504
  const heldBusinessId = ProtoMap.getBusinessResponseBusinessId(response);
501
505
  return (heldBusinessId === null ||
502
506
  canonicalizeBusinessId(heldBusinessId) === canonicalizeBusinessId(businessId));
503
507
  }
504
508
  // The BusinessLink of this profile for the business, joining as a customer first (once) if not
505
- // linked. A business in the profile with no matching link is a server inconsistency a join would
506
- // not fix, so that throws.
509
+ // linked. The profile can hold the business without a link to it: the link of a membership that
510
+ // ended is deleted and the business stays. The join then makes a new link.
507
511
  async requireBusinessLink(businessId, profile) {
508
512
  if (profile.user === null)
509
513
  throw new IllegalOperationException("No profile has been read, so who is browsing is not known");
510
514
  const link = Model.findBusinessLink(profile, businessId);
511
515
  if (link !== null)
512
516
  return link;
513
- if (Model.findBusinessEntity(profile, businessId) !== null)
514
- throw new InternalServerException(`This customer has no BusinessLink to business ${businessId}`);
515
517
  const joinBusinessId = await this.readJoinBusinessId(businessId);
516
518
  await this.profileSyncIn(ProtoMap.buildProfileSyncOutRequest(await Model.getCache().readProfileCursors(this.requireProfileContainerName()), joinBusinessId), await this.requireProfileAuthTokens());
517
519
  // Tried once: a join that leaves no link is the server contradicting itself, and retrying
@@ -532,7 +534,7 @@ class Model {
532
534
  return alreadyHeld.id;
533
535
  await this.syncAnonymousBusiness(businessId);
534
536
  // A missing business is a NotFoundException from the read above. Reaching here with none
535
- // published means the server answered with no Business, a failure of its own
537
+ // published means the server answered with no Business, which is a server failure
536
538
  const business = this._business.value;
537
539
  if (business === null)
538
540
  throw new InternalServerException(`The read of business ${businessId} answered with no Business`);
@@ -567,9 +569,10 @@ class Model {
567
569
  // The credentials that sign a call about a customer's business container. The authentication
568
570
  // token comes from memory, as in buildProfileAuthTokens: a stored AuthTokens does not keep it.
569
571
  // The user token and link token are read separately: storeIssuedTokens overwrites the whole blob
570
- // per call, so relying on it alone would lose whichever token the last call did not reissue. Each
571
- // falls back to its own source: the profile container's user token, and this link's linkToken. A
572
- // fresh membership has no issued tokens, so the write below seeds this container with them.
572
+ // per call, so relying on it alone would lose any token the last call did not reissue. A token
573
+ // not stored here comes from another source: the user token from the profile container, and the
574
+ // link token from the link. A fresh membership has no issued tokens, so the write below seeds
575
+ // this container with them.
573
576
  // A link with no linkToken is a server inconsistency, and throws rather than sending unsigned.
574
577
  async requireBusinessAuthTokens(containerName, link) {
575
578
  const authenticationMethod = this._authenticationMethod;
@@ -591,9 +594,9 @@ class Model {
591
594
  await cache.writeAuthTokens(containerName, authTokens);
592
595
  return authTokens;
593
596
  }
594
- // The profile of whoever is signed in, paged like a business read. What it earns files in the
595
- // profile container. The call is about the caller alone, so its AuthTokens carries no link
596
- // token. A linkToken travels only inside a BusinessLink, stored with the profile.
597
+ // Reads the profile of whoever is signed in, paged like a business read. The tokens it receives
598
+ // are stored in the profile container. The call is about the caller alone, so its AuthTokens has
599
+ // no link token. A linkToken travels only inside a BusinessLink, stored with the profile.
597
600
  async profileSyncIn(firstRequest, firstAuthTokens) {
598
601
  const containerName = this.getProfileContainerName();
599
602
  if (containerName === null)
@@ -620,7 +623,7 @@ class Model {
620
623
  break;
621
624
  request = next;
622
625
  // Each call after the first is signed with what the previous page reissued, since a token can
623
- // fall due part way through a long read
626
+ // expire part way through a long read
624
627
  authTokens = ProtoMap.refreshAuthTokens(authTokens, ProtoMap.getResponseAuthTokens(page));
625
628
  }
626
629
  // As above: nothing the store took, nothing published
@@ -629,8 +632,8 @@ class Model {
629
632
  response = await Model.getCache().readProfile(containerName);
630
633
  this.publishProfile(response);
631
634
  }
632
- // Last, so everything that arrived is cached and published first. The pages were not at fault,
633
- // the join was
635
+ // Checked last, so everything that arrived is cached and published before this can throw. The
636
+ // pages are valid even when the join is not.
634
637
  Model.requireJoinedBusiness(joinBusinessId, hasJoinedLink, hasJoinedBusiness);
635
638
  return response;
636
639
  }
@@ -653,11 +656,11 @@ class Model {
653
656
  // Without both, the caller is a customer of something it cannot reach. Shared by the two calls
654
657
  // that can carry a joinBusinessId.
655
658
  //
656
- // A ServiceException, not a caller error: the server answered 200 and left out what was asked
657
- // for. 500 says retrying is worth it.
659
+ // Throws a ServiceException, since the fault is the server's and not the caller's: the server
660
+ // answered 200 and left out what was asked for. The 500 status means a retry is worthwhile.
658
661
  // Only a joinBusinessId the server could act on is checked (Java Business.isValidBusinessId,
659
- // which Constants.ENTITY_ID_NULL also fails, so "no join asked" needs no case). The server refuses
660
- // any other (400), which fails the call itself.
662
+ // which Constants.ENTITY_ID_NULL also fails, so "no join asked" needs no case). The server rejects
663
+ // any other with a 400, which fails the call itself.
661
664
  static requireJoinedBusiness(joinBusinessId, hasBusinessLink, hasBusiness) {
662
665
  if (Ids.getEntityTypeId(joinBusinessId) !== proto_Constants.BUSINESS_ENTITY_TYPE_ID)
663
666
  return;
@@ -670,11 +673,11 @@ class Model {
670
673
  : "BusinessLink";
671
674
  throw new InternalServerException(`A join of business ${joinBusinessId} was answered with no ${missing}`);
672
675
  }
673
- // What every profile response leaves in the store: its entities cached, and what it reissued
674
- // filed against the same container. Answers how much the store took, which decides whether
675
- // anything is published. Shared by the three calls that receive one (a page of a read, a sign up,
676
- // an update) so they cache and file tokens the same way. It does not publish: a read publishes
677
- // once for a whole loop of these, the other two once each.
676
+ // Caches the entities of a profile response and files the tokens it reissued under the same
677
+ // container. Returns how much the store accepted, which decides whether anything is published.
678
+ // The three calls that receive a profile response (a page of a read, a sign up, an update) share
679
+ // this, so they cache and file tokens the same way. It does not publish: a read publishes once
680
+ // after all its pages, the other two once each.
678
681
  // Only a page of a read moves the container's cursors. A sign up and an update are changes this
679
682
  // client made and the next read brings them back, so they are stored without moving any.
680
683
  async cacheProfileResponse(containerName, response, isPageOfRead) {
@@ -685,11 +688,11 @@ class Model {
685
688
  await this.storeIssuedTokens(ProtoMap.getResponseAuthTokens(response), containerName);
686
689
  return changeCount;
687
690
  }
688
- // What a profile call other than a read leaves behind: the response cached and published, as for
689
- // a paged read without the paging. A sign up and an update publish by the rule a read follows,
690
- // on what the store took. An update publishes nothing today because the server puts no entities
691
- // in its response, only what it reissued. If it ever answers with the stored User, whose update
692
- // number and updated time a caller cannot know, this publishes it unchanged.
691
+ // Handles the response of a profile call other than a read: caches and publishes it, as for a
692
+ // paged read without the paging. Like a read, a sign up and an update publish only when the store
693
+ // accepted something. An update publishes nothing today because the server puts no entities in
694
+ // its response, only reissued tokens. If it ever answers with the stored User, whose update
695
+ // number and updated time a caller cannot know, this publishes it as the server sent it.
693
696
  async writeProfileResponse(response) {
694
697
  const containerName = this.getProfileContainerName();
695
698
  if (containerName === null)
@@ -698,14 +701,14 @@ class Model {
698
701
  this.publishProfile(await Model.getCache().readProfile(containerName));
699
702
  return response;
700
703
  }
701
- // Files what a call earned against the container the call was about: a profile call's tokens in
702
- // the subject's profile container, a business call's in that business's container. The call
703
- // decides this, since a token says nothing about what it is for. A business sync out that
704
- // reissues a user token still files that copy with the business it came from.
704
+ // Stores the tokens a call received in the container the call was about: a profile call's tokens
705
+ // in the subject's profile container, a business call's in that business's container. The caller
706
+ // picks the container, because a token does not record what it is for. A business sync out that
707
+ // reissues a user token stores that copy with the business it came from.
705
708
  // A profile call carries no link token here: a membership's link token travels inside its
706
709
  // BusinessLink, stored with the profile.
707
- // A failed write is swallowed: the server issues another next call, so losing one costs a round
708
- // trip, and surfacing it would turn a working read into a thrown one.
710
+ // A failed write is swallowed: the server issues new tokens on the next call, so a lost one is
711
+ // replaced, and throwing would turn a successful read into a failed one.
709
712
  async storeIssuedTokens(issued, containerName) {
710
713
  if (issued === undefined)
711
714
  return;
@@ -719,8 +722,9 @@ class Model {
719
722
  // #endregion
720
723
  // #region Containers and credentials
721
724
  // Where this subject's profile and tokens are cached. Keyed by authentication method and subject
722
- // id, since two identity providers could issue the same subject id. Null when signed out. Several
723
- // can coexist, so signing back in finds the subject's own profile.
725
+ // id, since two identity providers could issue the same subject id. Null when signed out.
726
+ // Containers for several subjects can coexist, so signing back in finds that subject's own
727
+ // profile.
724
728
  getProfileContainerName() {
725
729
  return this._authenticationMethod === null || this._subjectId === null
726
730
  ? null
@@ -757,16 +761,16 @@ class Model {
757
761
  // #endregion
758
762
  // #region Publishing
759
763
  //
760
- // A read publishes on how much the store accepted (blobs stored plus ids deleted, summed over
761
- // every page), not on what the response carried. The store keeps a blob only when its eTag is
762
- // later, so a response can be rejected in full (a cursor that moved without its eTag resends an
763
- // entity already cached). Publishing that would re-render every observer over an identical value.
764
+ // A read publishes only when the store accepted something (blobs stored plus ids deleted, summed
765
+ // over every page), whatever the response carried. The store keeps a blob only when its eTag is
766
+ // later, so it can reject a response in full (a cursor that moved without its eTag resends an
767
+ // entity already cached). Publishing then would notify every observer of an identical value.
764
768
  // All four calls follow this, so an update publishes nothing (see writeProfileResponse).
765
769
  //
766
770
  // A business with online shopping off is not published, even from the cache, or a customer could
767
771
  // be joined to it. A read that finds the published business is no longer open, or that the server
768
- // refuses, takes it back by publishing none. What came from the cache counts as open only once the
769
- // read has ended (see _isPublishedBusinessVetted, which readJoinBusinessId goes by).
772
+ // rejects, publishes null in its place. A business published from the cache counts as open only
773
+ // once the read has ended (see _isPublishedBusinessVetted, which readJoinBusinessId checks).
770
774
  publishBusiness(response) {
771
775
  this.publish(Model.buildPublishable(response));
772
776
  }