@peabody-soft/mbs-model-ts 1.5.0 → 1.7.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 (52) hide show
  1. package/dist/Address.js +8 -11
  2. package/dist/Attachment.js +7 -8
  3. package/dist/AttachmentBaseUrl.js +10 -10
  4. package/dist/AuthTokens.js +12 -7
  5. package/dist/Business.js +19 -19
  6. package/dist/BusinessIdentifier.js +2 -2
  7. package/dist/BusinessLink.js +16 -16
  8. package/dist/BusinessProfile.js +4 -4
  9. package/dist/CartEditor.js +137 -166
  10. package/dist/Catalog.js +19 -25
  11. package/dist/Constants.js +10 -10
  12. package/dist/ContainerNames.js +10 -11
  13. package/dist/Entity.js +11 -9
  14. package/dist/Enums.js +2 -2
  15. package/dist/Exceptions.js +2 -2
  16. package/dist/GeneralSettings.js +23 -21
  17. package/dist/HttpStatusCodes.js +10 -11
  18. package/dist/Ids.js +24 -30
  19. package/dist/Item.js +6 -7
  20. package/dist/LatLong.js +3 -3
  21. package/dist/LiveData.js +6 -6
  22. package/dist/Measures.js +2 -2
  23. package/dist/Message.js +6 -6
  24. package/dist/MockDatabase.js +2 -2
  25. package/dist/Model.js +294 -365
  26. package/dist/Order.js +20 -17
  27. package/dist/Package.js +7 -8
  28. package/dist/PackageIdentifier.js +2 -2
  29. package/dist/PaymentMethodDetail.js +4 -4
  30. package/dist/Photo.js +6 -7
  31. package/dist/Price.js +8 -9
  32. package/dist/PriceList.js +2 -2
  33. package/dist/Product.js +17 -22
  34. package/dist/ProductCategory.js +3 -4
  35. package/dist/Profile.js +26 -27
  36. package/dist/ProtoMap.d.ts +3 -0
  37. package/dist/ProtoMap.js +288 -294
  38. package/dist/Routes.js +3 -3
  39. package/dist/ServiceExceptions.js +11 -12
  40. package/dist/ServiceProxy.js +19 -23
  41. package/dist/ShoppingList.js +112 -130
  42. package/dist/SyncCache.d.ts +3 -0
  43. package/dist/SyncCache.js +92 -75
  44. package/dist/SyncKeys.js +8 -8
  45. package/dist/SyncState.js +5 -5
  46. package/dist/Tax.js +16 -21
  47. package/dist/TaxHelper.js +22 -30
  48. package/dist/TaxType.js +8 -9
  49. package/dist/UserProfile.js +4 -4
  50. package/dist/WorkingDays.js +15 -15
  51. package/dist/index.js +9 -9
  52. package/package.json +1 -1
package/dist/Model.js CHANGED
@@ -13,23 +13,23 @@ 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 the only thing a UI has to be given. Call initialize once,
17
- // then always fetch the current instance via getInstance.
16
+ // The root of the object model, and all a UI has to be given. Call initialize once, then get the
17
+ // current instance with getInstance.
18
18
  //
19
- // THERE IS NO LOCKING, and nothing here makes one call wait for another: a caller makes one call at a time, and awaits it. A
20
- // setBusinessId that is still reading when signIn reads the business too can publish the business of the anonymous container
21
- // over the one the customer's link names, with no cart and no orders, and nothing republishes them; two reads at once
22
- // interleave their pages and their writes. And a store has one tab using it at a time: two tabs on one store each hold a Model
23
- // of their own, and one tab's deleteBusiness and read again can interleave with the pages another is writing.
24
- // A queue inside Model would settle the first and not the second, and would not be a small one: signIn calls signOut and
25
- // businessSyncIn calls updateCart, so each public method would need a twin that does not queue, for these to call. A caller
26
- // that calls from several places - a UI - queues its calls itself.
19
+ // THERE IS NO LOCKING. A caller makes one call at a time and awaits it. Otherwise:
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.
22
+ // - Two reads at once interleave their pages and writes, as do two tabs on one store (each holds a
23
+ // Model), so a store supports one tab at a time.
24
+ // A queue inside Model would not fix the tabs, and each public method would need an unqueued twin
25
+ // (signIn calls signOut, businessSyncIn calls updateCart). A caller that calls from several places,
26
+ // like a UI, queues its own calls.
27
27
  class Model {
28
28
  constructor() {
29
- // Whether the business that is published, if one is, is one that a read has been through to the end and found to be the
30
- // one that _businessId names and to be open. The cache-first publish of a read is of what an earlier read left, and is
31
- // neither of those until the read ends - a read that does not end (the network failing, or a refusal) leaves it published
32
- // and unvetted. See readJoinBusinessId, which does not take such a business to join.
29
+ // 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.
33
33
  this._isPublishedBusinessVetted = false;
34
34
  this._business = new LiveData(null);
35
35
  this._profile = new LiveData(null);
@@ -44,7 +44,7 @@ class Model {
44
44
  Model._cache = new SyncCache(database);
45
45
  Model._instance = null;
46
46
  }
47
- // The model as it is now. Ask for it every time; never keep it - see the note on this class.
47
+ // The current model. Fetch it each time and do not keep it: signOut discards it.
48
48
  static getInstance() {
49
49
  return Model._instance ?? (Model._instance = new Model());
50
50
  }
@@ -57,17 +57,13 @@ class Model {
57
57
  get profile() {
58
58
  return this._profile;
59
59
  }
60
- // Signs in with the given credentials and syncs this subject's profile in - for a subject the
61
- // server already has a profile for; createUser is what a subject with none yet uses instead. Also
62
- // reads in the business being browsed, if any, now signed in as this customer.
60
+ // Signs in and syncs this subject's profile in, for a subject the server has a profile for (use
61
+ // createUser otherwise). Also reads in the business being browsed, if any, as this customer.
63
62
  //
64
- // Idempotent on failure: beginSignIn's own "already signed in" guard aside, anything that throws
65
- // after it - a bad credential, a refused push while reading in the business being browsed -
66
- // discards this instance via signOut rather than leaving it half signed in. profileSyncIn
67
- // publishes a cached profile before it ever reaches the network, so without this, a retry after
68
- // such a failure could find _profile already set and trip that same guard despite never having
69
- // actually signed in.
70
- // Throws NotFoundException if the business being browsed cannot be opened - see businessSyncIn.
63
+ // Anything thrown after beginSignIn's guard (a bad credential, a refused push while reading the
64
+ // 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.
66
+ // Throws NotFoundException if the business being browsed cannot be opened. See businessSyncIn.
71
67
  async signIn(authenticationMethod, subjectId, authenticationToken) {
72
68
  this.beginSignIn(authenticationMethod, subjectId, authenticationToken);
73
69
  try {
@@ -81,12 +77,10 @@ class Model {
81
77
  throw error;
82
78
  }
83
79
  }
84
- // Signs up with the given credentials, creating userToCreate as this subject's profile - for a
85
- // subject the server has no profile for yet; signIn is what an already known subject uses instead.
86
- // Also reads in the business being browsed, if any, now signed in as this customer.
87
- // Throws NotFoundException if the business being browsed cannot be opened - see businessSyncIn. That is
88
- // after the User has been created, and nothing undoes it: the customer stays signed up, and signed in, with
89
- // a profile held, whatever the read of the business answered.
80
+ // Signs up and creates userToCreate as this subject's profile, for a subject the server has no
81
+ // profile for (use signIn otherwise). Also reads in the business being browsed, if any, as this
82
+ // 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.
90
84
  async createUser(authenticationMethod, subjectId, authenticationToken, userToCreate) {
91
85
  this.beginSignIn(authenticationMethod, subjectId, authenticationToken);
92
86
  const authTokens = await this.requireProfileAuthTokens();
@@ -94,38 +88,33 @@ class Model {
94
88
  if (this._businessId !== null)
95
89
  await this.businessSyncIn();
96
90
  }
97
- // Updates this customer's own profile with what userProfile now holds. Service.updateUser matches
98
- // the caller against the User it is sent by entity id - see ProtoMap.buildUpdateUser - so
99
- // userProfile must be this customer's own, previously read in rather than built fresh.
100
- // Requires a profile already read, the same "signed in" a business write needs of its profile half
101
- // - see requireCustomerOfBusiness. Not paged, and today publishes nothing: its response carries no
102
- // entities, only whatever the server reissued - see writeProfileResponse.
91
+ // Updates this customer's own profile with what userProfile holds. Service.updateUser matches the
92
+ // 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).
103
96
  async updateUser(userProfile) {
104
97
  if (this._profile.value === null)
105
98
  throw new IllegalOperationException("Nobody is signed in, so this call cannot be made");
106
99
  await this.writeProfileResponse(await Model.getService().updateUser(ProtoMap.buildUpdateUser(userProfile), await this.requireProfileAuthTokens()));
107
100
  }
108
- // Discards this model outright: the next getInstance() builds a fresh one, signed out and
109
- // browsing nothing.
101
+ // Discards this model. The next getInstance() builds a fresh one, signed out and browsing nothing.
110
102
  async signOut() {
111
103
  Model._instance = null;
112
104
  }
113
- // Names the business to browse and reads it in, publishing null first so a Business being
114
- // replaced is never shown as the one now being browsed - but only when it IS being replaced.
115
- // This is also how a caller re-reads the shop it is already on, and nulling unconditionally
116
- // would blank the UI for the length of the read and undo syncBusinessContainer's own
117
- // cache-first publish two lines later.
118
- // Throws NotFoundException if the business cannot be opened - see businessSyncIn. Whatever was cached
119
- // is published before the network is asked, unless it is a business that has not turned online shopping
120
- // on - see publishBusiness - and is taken back, by publishing none, if the read then finds that the business
121
- // cannot be opened.
122
- // The businessId is held and used as the server finds a business by it, in lower case and without the spaces round it
123
- // (see canonicalizeBusinessId): a url is typed in any case, and /WARM-LOAF and /warm-loaf are one storefront, so, they are
124
- // one container, one download, and not a different business to be shown as none first. What is sent to the server is that
125
- // form too, which it makes of whatever it is sent, so nothing is lost by it.
105
+ // 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.
108
+ // 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.
126
114
  async setBusinessId(businessId) {
127
- // An id of nothing but spaces has no canonical form - Java refuses it - and is held as it was written: it is the server that
128
- // says there is no such business, with the NotFoundException that a UI shows for one, and not this that calls it a mistake
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.
129
118
  const canonicalBusinessId = canonicalizeBusinessId(businessId) || businessId;
130
119
  const isDifferentBusiness = this._businessId !== canonicalBusinessId;
131
120
  this._businessId = canonicalBusinessId;
@@ -136,22 +125,19 @@ class Model {
136
125
  // #region What a customer writes
137
126
  //
138
127
  // The only writes in this library: this customer's cart at the business being browsed, and an
139
- // order placed from it. An order pushes to the server and touches the store only once the server
140
- // accepts, so a failed call leaves nothing to reconcile and a caller just retries: an order
141
- // stored here that the server never saw would be one a customer believes they placed and a
142
- // merchant has never heard of. A cart is not held to that, and updateCartLocalOnly is the whole
143
- // of the difference.
144
- // Both need this customer to be a member of the business being browsed - three separate things
145
- // to be missing, so three separate failures; see requireCustomerOfBusiness.
146
- // Creates or replaces this customer's cart, on the server and in the store both. An empty list
147
- // empties the cart - the same state as never having had one.
148
- // Signed out, this is just updateCartLocalOnly - there is no server cart yet for a visitor the
149
- // server has not heard of. Signed in, it costs a request, so an editor should write every change
150
- // with updateCartLocalOnly and call this on its own schedule (debounced, on blur, a timer).
151
- // Safe to call often: hasShoppingListChanged checks against the store's own copy rather than
152
- // whatever the caller last sent, so a call that matches what is already there returns without a
153
- // request. Not a substitute for reading the business, though - a push answers with the messages it
154
- // pushed and nothing else, and a call that does not go out brings no change of anybody else's back.
128
+ // order placed from it. Both need a customer who is a member of the business (see
129
+ // requireCustomerOfBusiness).
130
+ // 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).
133
+ // Creates or replaces this customer's cart, on the server and in the store. An empty list
134
+ // 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).
138
+ // 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.
155
141
  async updateCart(shoppingList) {
156
142
  if (this._profile.value === null)
157
143
  return this.updateCartLocalOnly(shoppingList);
@@ -162,42 +148,37 @@ class Model {
162
148
  const partyPreferencesMessage = ProtoMap.buildPartyPreferencesMessage(Ids.getPartyPreferencesMessageId(target.businessLink.id), stored, target.senderId, shoppingList, null, BigInt(Date.now()));
163
149
  await this.pushBusinessMessages(target, [partyPreferencesMessage]);
164
150
  }
165
- // The same, stored locally and not sent. Call this on every edit - a request per tap of a
166
- // quantity stepper is not worth making - then call updateCart on your own schedule (debounced,
167
- // on blur, before signing in) to actually send it.
168
- // Nothing is lost by leaving it unsent: it survives a closed tab, the next updateCart sends it,
169
- // and createOrder reads straight from the store. All a caller gives up is that the server, and so
170
- // this customer's other devices, hear about the change late.
171
- // It also cannot be undone by a stale sync in bringing an older server copy back: the store
172
- // versions an amendment at the update number of the copy it amends, where the later copy wins, and
173
- // buildPartyPreferencesMessage always dates an amendment past what it replaces.
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.
153
+ // 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
155
+ // the change late.
156
+ // A stale sync cannot bring an older server copy back over it: the store versions an amendment at
157
+ // the update number of the copy it amends, buildPartyPreferencesMessage dates it past that copy,
158
+ // and the later copy wins.
174
159
  async updateCartLocalOnly(shoppingList) {
175
- // No membership to derive a message id from while signed out, so one is drawn instead - once,
176
- // and only once: buildPartyPreferencesMessage keeps amending the same entity by reusing its id,
177
- // so a second edit cannot leave two behind. The sender is ENTITY_ID_NULL for want of a party;
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;
178
163
  // the first push after signing in names the real one.
179
164
  if (this._profile.value === null)
180
165
  return this.storeCartInCache(ContainerNames.forBusiness(this.requireBusinessBeingBrowsed()), Ids.getNewId(proto_Constants.MESSAGE_ENTITY_TYPE_ID), BigInt(proto_Constants.ENTITY_ID_NULL), shoppingList);
181
166
  const target = this.requireCustomerOfBusiness();
182
167
  return this.storeCartInCache(target.containerName, Ids.getPartyPreferencesMessageId(target.businessLink.id), target.senderId, shoppingList);
183
168
  }
184
- // Places an order for what is in the cart, and empties it - the list is used up becoming the
185
- // order, as Java SalesOrderViewModel.saveChanges does on MODE_CREATE. Takes no shopping list
186
- // itself for that reason: the cart is the order.
187
- // The emptied party preferences message is also stamped with this fulfillmentMode, so it becomes
188
- // this order's rather than whatever the cart last held - see Business.defaultFulfillmentMode,
189
- // which is what reads it back.
190
- // orderId is the caller's to mint, with Ids.getNewId(Constants.MESSAGE_ENTITY_TYPE_ID) - not
191
- // minted here - so that a caller holds it before the call is made: to find the order once placed
192
- // without a server-assigned id to wait for, and to retry a call a NetworkConnectionException left
193
- // unresolved by sending the same id again rather than placing a second order, the same guarantee
194
- // getPartyPreferencesMessageId gives a cart.
195
- // A delivery must name an address, either an id from this customer's own list or a full address
196
- // sent directly - see requireDeliveryAddress.
169
+ // 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.
176
+ // A delivery must name an address: an id from this customer's list, or a full address sent
177
+ // directly (see requireDeliveryAddress).
197
178
  async createOrder(orderId, fulfillmentMode, deliveryAddressId, deliveryAddress, note) {
198
179
  const target = this.requireCustomerOfBusiness();
199
- // Read from the store, not the published Business, so the order reflects the cart as the
200
- // server last confirmed it, not a value some observer may still hold
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
201
182
  const partyPreferencesMessage = await Model.getCache().readPartyPreferences(target.containerName);
202
183
  const shoppingList = partyPreferencesMessage === null
203
184
  ? ShoppingList.EMPTY
@@ -205,21 +186,19 @@ class Model {
205
186
  if (shoppingList.existingItems.length === 0)
206
187
  throw new IllegalOperationException("The cart holds nothing, so there is no order to place");
207
188
  Model.requireDeliveryAddress(target.addresses, fulfillmentMode, deliveryAddressId, deliveryAddress);
208
- // One creation time for both, and it is what the order was placed at
189
+ // One time for both messages, which is when the order was placed
209
190
  const now = BigInt(Date.now());
210
- // Order and emptied cart in one request: two calls could leave a customer with an order placed
211
- // but a cart still full, if the second one failed
191
+ // One request for the order and the emptied cart: two calls could leave an order placed and the
192
+ // cart still full if the second failed
212
193
  await this.pushBusinessMessages(target, [
213
194
  ProtoMap.buildOrderMessage(orderId, target.senderId, shoppingList, fulfillmentMode, deliveryAddressId, deliveryAddress, note, now),
214
- // fulfillmentMode is the order's, not left as whatever the cart held: placing an order is
215
- // this customer's own choice of how it is fulfilled, freshly made, so it is what
216
- // defaultFulfillmentMode should offer next time rather than a possibly stale earlier one.
195
+ // The customer's latest choice of fulfillmentMode, for defaultFulfillmentMode to offer next time
217
196
  ProtoMap.buildPartyPreferencesMessage(Ids.getPartyPreferencesMessageId(target.businessLink.id), partyPreferencesMessage, target.senderId, null, fulfillmentMode, now),
218
197
  ]);
219
198
  }
220
199
  // #endregion
221
- // Throws if a profile is already held; otherwise stores the credentials every later call signs
222
- // with. The one step signIn and createUser share before parting ways over how each gets a profile.
200
+ // Throws if a profile is already held, otherwise stores the credentials every later call signs
201
+ // with. The step signIn and createUser share.
223
202
  beginSignIn(authenticationMethod, subjectId, authenticationToken) {
224
203
  if (this._profile.value !== null)
225
204
  throw new IllegalOperationException("A user is already signed in");
@@ -228,39 +207,38 @@ class Model {
228
207
  this._authenticationToken = authenticationToken;
229
208
  }
230
209
  // #region Pushing
231
- // One push, plus everything that follows a server accepting it. The messages go out in one
232
- // request, with how far this business container's reads have got, and the server answers with
233
- // these messages under their assigned update numbers, and no more: a push is not a read. They are
234
- // stored, and move no cursor, so, the next read is sent them again, and anything somebody else
235
- // changed in the meantime comes with them. A server that refuses the push fails the call
236
- // itself, same as any other Service call on a bad status, so there is nothing left here to check:
237
- // reaching the write below already means the server took it.
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.
238
216
  async pushBusinessMessages(target, messages) {
239
217
  const cache = Model.getCache();
240
218
  const messageUpdateNumber = await cache.readMessageUpdateNumber(target.containerName);
241
219
  const authTokens = await this.requireBusinessAuthTokens(target.containerName, target.businessLink);
242
220
  const response = await Model.getService().businessSyncIn(ProtoMap.buildBusinessSyncInRequest(messageUpdateNumber, messages), ProtoMap.buildAuthTokens(authTokens));
243
221
  await this.storeIssuedTokens(ProtoMap.getResponseAuthTokens(response), target.containerName);
244
- // Stored exactly as the response carried it, entities and all - no stamping step, because the
245
- // server already answered with the messages themselves rather than a bare number to reapply
222
+ // Stored as the response carried them: the server answers with the messages themselves, so
223
+ // nothing is stamped
246
224
  const changeCount = await cache.writeMessages(target.containerName, ProtoMap.getSyncInResponseMessages(response));
247
225
  // What the store took decides whether anything is published - see the Publishing region
248
226
  if (changeCount !== 0)
249
227
  this.publishBusiness(await cache.readBusiness(target.containerName));
250
228
  }
251
- // Amends whatever cart containerName already holds and writes the result back to the cache -
252
- // nothing is pushed to the server. updateCartLocalOnly's two callers differ only in which
253
- // container, which id, and which sender: a visitor's has a freshly drawn id and no sender, a
254
- // customer's has the id their membership derives and their own sender id.
229
+ // 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.
255
233
  async storeCartInCache(containerName, messageId, senderId, shoppingList) {
256
234
  const cache = Model.getCache();
257
235
  const changeCount = await cache.writePartyPreferences(containerName, ProtoMap.buildPartyPreferencesMessage(messageId, await cache.readPartyPreferences(containerName), senderId, shoppingList, null, BigInt(Date.now())));
258
- // Published on what the store took, the same rule every read and push keeps
236
+ // Published on what the store took, as for every read and push
259
237
  if (changeCount !== 0)
260
238
  this.publishBusiness(await cache.readBusiness(containerName));
261
239
  }
262
- // The business being browsed, refused by name when there is none or it has not been read yet.
263
- // Shared by every write, because neither has anything to write to without it.
240
+ // The id of the business being browsed, for a write. Throws if there is none or it has not been
241
+ // read yet.
264
242
  requireBusinessBeingBrowsed() {
265
243
  const businessId = this._businessId;
266
244
  if (businessId === null)
@@ -269,10 +247,9 @@ class Model {
269
247
  throw new IllegalOperationException(`Business ${businessId} has not been read yet, so there is nothing to write to`);
270
248
  return businessId;
271
249
  }
272
- // Everything a write needs, gathered before anything is built. Each precondition is refused by
273
- // name rather than one unhelpful "not ready": no business browsed, one not yet loaded, no profile
274
- // loaded, or a customer who isn't a member - the last being what the server would refuse anyway,
275
- // for want of a link token to sign with.
250
+ // Everything a write needs, gathered before anything is built. Each missing precondition throws
251
+ // 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.
276
253
  requireCustomerOfBusiness() {
277
254
  const businessId = this.requireBusinessBeingBrowsed();
278
255
  const business = this._business.value;
@@ -291,15 +268,15 @@ class Model {
291
268
  countryCode: business.countryCode,
292
269
  };
293
270
  }
294
- // Where an order is delivered: an id into this customer's own address list (the same list
295
- // Order.deliveryAddressId resolves against) or a full address sent directly. An id not in that
296
- // list would leave the business holding an order it can't read, so it's refused here rather than
297
- // sent; a full address carries everything it needs and so isn't checked against the list.
271
+ // Checks where an order is delivered: an id in this customer's address list (the list
272
+ // 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
274
+ // address needs no check against the list.
298
275
  static requireDeliveryAddress(addresses, fulfillmentMode, deliveryAddressId, deliveryAddress) {
299
276
  if (deliveryAddress !== null)
300
277
  return;
301
278
  if (deliveryAddressId === BigInt(proto_Constants.ENTITY_ID_NULL)) {
302
- // Pick-up is the case that names neither, which is normal - see Order.deliveryAddressId
279
+ // Pick-up names neither, which is normal. See Order.deliveryAddressId
303
280
  if (fulfillmentMode === proto_FulfillmentMode.DELIVERY)
304
281
  throw new InvalidArgumentException("An order to be delivered has to name a delivery address");
305
282
  return;
@@ -310,23 +287,19 @@ class Model {
310
287
  // #endregion
311
288
  // #region The four calls of Service, each as this model makes it
312
289
  //
313
- // Each is complete in itself: it pages, caches every page as it arrives, files what the server
314
- // issued, and publishes what the cache then holds.
315
- // Reads in the business being browsed. An anonymous visitor reads it straight, under its own
316
- // businessId. A signed-in customer reads it under their own container instead - see
317
- // getBusinessContainerName - joining as a customer first if not already linked - see
318
- // requireBusinessLink.
319
- // Throws NotFoundException, from either, when the business is not one a storefront can open,
320
- // whether or not this customer has joined it, and that is the answer to any read of it, the first
321
- // or a later one: a business can stop being open after it was cached. The server says so when the
322
- // business does not exist, has been deleted, or does not allow customers to join it. Whether it
323
- // has turned online shopping on the server does not say: this decides, once everything the server
324
- // sent has been read, from the settings that it then holds. What was cached is left as it is,
325
- // except that a business that is no longer the one that the businessId names is given up for the
326
- // one that is, if there is one: see syncBusinessContainer.
327
- // isStartedOver is for the one time that this calls itself: the link that a customer's profile found by the businessId is of
328
- // a business that no longer has it, which syncBusinessContainer finds out once it has read the business. The profile is read again,
329
- // as it is what said so, and this starts again from what it now says - which may be a business that the customer has to join, or none.
290
+ // Each pages, caches every page as it arrives, files what the server issued, and publishes what
291
+ // the cache then holds.
292
+ // Reads in the business being browsed: an anonymous visitor under its businessId, a signed-in
293
+ // customer under their own container (see getBusinessContainerName), joining as a customer first
294
+ // if not linked (see requireBusinessLink).
295
+ // 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.
330
303
  async businessSyncIn(isStartedOver = false) {
331
304
  const businessId = this._businessId;
332
305
  if (businessId === null || businessId.length === 0)
@@ -340,24 +313,20 @@ class Model {
340
313
  const cache = Model.getCache();
341
314
  const containerName = Model.getBusinessContainerName(link);
342
315
  const anonymousName = ContainerNames.forBusiness(businessId);
343
- // What this device read while nobody was signed in, folded into the container it is read
344
- // under now - the same rule the server's own pages take: the later of two copies per entity by
345
- // update number is kept, so a tombstone wins over what it deleted. Unconditional, since a
346
- // returning customer's own container can easily be the staler of the two. It moves no cursor:
347
- // that container has not been read for any of this, and is read from where its own reads got to.
348
- // Not the cart, which is settled below: the two containers hold it under different ids, so a
349
- // merge keyed on entity id would file the visitor's as a second cart instead of replacing one.
350
- // Nor one that holds another business than the link is of: what was read under the businessId while nobody was
351
- // signed in is of the business that the businessId named then, which is not the one the link is of once the
352
- // businessId has been given up and taken by another. Folded in, the one container would hold two businesses, and
353
- // which of them it answers with would be decided by nothing - and a cart is the cart of the business it was built in.
354
- // One that holds no business at all has nothing to contradict the link with, and is folded in: a cart can be all that a
355
- // visit left of it.
316
+ // Folds what this device read while nobody was signed in into the customer's container. The
317
+ // later copy of an entity, by update number, wins, as for the server's own pages, so a tombstone
318
+ // beats what it deleted. No cursor moves.
319
+ // Left out:
320
+ // - The cart, settled below. The containers hold it under different ids, so a merge would file
321
+ // the visitor's as a second cart.
322
+ // - A container holding a different business than the link's. Its businessId may since have
323
+ // been given up and taken by another, and a cart belongs to the business it was built in.
324
+ // A container with no business is folded in, since a cart can be all a visit left.
356
325
  const anonymousContainer = await cache.readBusiness(anonymousName);
357
326
  const anonymousEntityId = ProtoMap.getBusinessResponseBusinessEntityId(anonymousContainer);
358
327
  const anonymous = anonymousEntityId !== null && anonymousEntityId !== link.businessId ? null : anonymousContainer;
359
- // Out of the messages just read, rather than a second read of the same container -
360
- // partyPreferencesMessageFromMessages picks the same one readPartyPreferences(anonymousName)
328
+ // Taken from the messages just read, not a second read of the container.
329
+ // partyPreferencesMessageFromMessages picks the same message readPartyPreferences(anonymousName)
361
330
  // would.
362
331
  const anonymousPartyPreferences = anonymous === null ? null : ProtoMap.partyPreferencesMessageFromMessages(anonymous.messages);
363
332
  if (anonymous !== null) {
@@ -365,12 +334,13 @@ class Model {
365
334
  await cache.storeBusiness(containerName, anonymous);
366
335
  }
367
336
  const authTokens = await this.requireBusinessAuthTokens(containerName, link);
368
- // The link names the business by its entity id, which the link token that signs this call has to be of
337
+ // The link names the business by entity id, because the link token that signs this call is for it
369
338
  const isBusinessNamed = await this.syncBusinessContainer(containerName, businessId, link.businessId, ProtoMap.buildAuthTokens(authTokens));
370
- // The business that the link is of has another businessId now, and the profile that found the link by this one did not know
371
- // it. Its container has been given up, and what is asked next of the server is what the profile says: read again, so that
372
- // the link is found by this businessId or not, and the businessId that names another business is joined. Once - a profile
373
- // that is read again and still finds the same link for the same businessId is the server contradicting itself
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
374
344
  if (!isBusinessNamed) {
375
345
  if (isStartedOver)
376
346
  throw new InternalServerException(`The business that the link for the businessId ${businessId} is of does not have it, though the profile was read again`);
@@ -378,91 +348,73 @@ class Model {
378
348
  await this.businessSyncIn(true);
379
349
  return;
380
350
  }
381
- // THE CART A VISITOR BUILT BEFORE SIGNING IN. Signing in changes which container is being
382
- // browsed, so without this it is simply left behind - and a customer has to build it again
383
- // having just proved who they are.
351
+ // THE CART A VISITOR BUILT BEFORE SIGNING IN. Signing in changes the container being browsed,
352
+ // so without this the cart is left behind and the customer has to build it again.
384
353
  if (anonymousPartyPreferences === null)
385
354
  return;
386
- // The visitor's cart holds taxes as raw codes, which mean something only within the country of
387
- // the business it was built in. The anonymous container is where that was read from, and a cart
388
- // is only ever stored once its business has been (see updateCartLocalOnly); failing that, the
389
- // business being browsed is the same one.
355
+ // The visitor's cart holds taxes as raw codes, meaningful only within the country of the
356
+ // 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.
390
359
  const anonymousCountryCode = ProtoMap.getBusinessResponseCountryCode(anonymous) ?? this._business.value?.countryCode;
391
360
  if (anonymousCountryCode === undefined)
392
361
  throw new IllegalOperationException("The business the visitor's cart was built in is not known, so its taxes cannot be read");
393
- // Read after the sync, so this is weighed against what the server just settled rather than a
394
- // local copy about to be replaced. Last written wins, by updatedAt.
362
+ // Read after the sync, so the comparison is against what the server just settled, not a local
363
+ // copy about to be replaced. The later updatedAt wins.
395
364
  const userPartyPreferences = await cache.readPartyPreferences(containerName);
396
365
  if (userPartyPreferences === null ||
397
366
  ProtoMap.getMessageUpdatedAt(anonymousPartyPreferences) >
398
367
  ProtoMap.getMessageUpdatedAt(userPartyPreferences))
399
368
  await this.updateCart(ProtoMap.buildShoppingList(anonymousPartyPreferences, anonymousCountryCode));
400
- // Dropped either way, carried over or beaten by the customer's own: the anonymous container is
401
- // what the next visitor on this device reads, and a settled cart has no business staying there.
369
+ // Dropped either way, carried over or beaten by the customer's own. The next visitor on this
370
+ // device reads the anonymous container, and a settled cart should not stay there.
402
371
  await cache.deletePartyPreferences(anonymousName);
403
372
  }
404
- // The plain businessId-named container: an anonymous read, before signing in or joining, or to
405
- // learn a business's entity id while joining - see readJoinBusinessId.
373
+ // Reads the container named by the plain businessId: an anonymous read before signing in or
374
+ // joining, or to learn a business's entity id while joining (see readJoinBusinessId).
406
375
  async syncAnonymousBusiness(businessId) {
407
376
  await this.syncBusinessContainer(ContainerNames.forBusiness(businessId), businessId, null, null);
408
377
  }
409
- // One business, cache first: what's already held publishes before the network is touched, so a
410
- // returning customer sees the shop at once instead of a loading state. Cursors start from
411
- // how far the reads of containerName have got, which it keeps, and not from zero, so a repeat call
412
- // asks only what changed - see SyncCache.readBusinessCursors. Each page moves them as it is written.
413
- // The business is named to the server by its entity id where this knows it, and by its businessId
414
- // only where it does not: a caller that has a BusinessLink has its businessEntityId, and one that
415
- // has read the business before has it in the container, from that read. Which of the two is
416
- // decided once, by the first request, and every page after it is about the same business named the
417
- // same way. The first read of a business by an anonymous visitor names it by its businessId, and its
418
- // answer is what makes every later one not have to - except the read that is made again, below,
419
- // when the business that the container holds has turned out not to be the one that the businessId names.
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.
381
+ // The first request names the business by entity id where known (businessEntityId from a link,
382
+ // 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.
420
385
  //
421
- // What decides whether the business can be opened is what the container holds once every page has been
422
- // read and written, and not what the pages carried, which is only what changed: a read that has nothing
423
- // new to send says nothing of what has not changed. It is looked at after every read, whether the read
424
- // brought anything or not, and in this order:
425
- // 1. That the business the container holds is the one that businessId names. A business keeps its entity
426
- // id when its businessId changes, and another can take the businessId it gave up, so a container that is
427
- // read by the entity id it was left with may hold a business that is not the one that businessId names
428
- // now. The business arrives changed when its businessId does, which is how this is seen, and it is seen
429
- // after the next read however long that is, as it is the container that is looked at. If it is not that
430
- // business, the container is given up and the business is read from the beginning by its businessId,
431
- // once: that finds a business that has it, or none, which the server says with a NotFoundException.
432
- // A read that a link names cannot be read again by the businessId, as a link token is refused with
433
- // one, and the link names the one business that it is of. Its container is given up all the same, and
434
- // it answers false: the link was found from what the profile said of the businessId, which is out of
435
- // date, so the caller starts again from a profile that is read again - see businessSyncIn.
436
- // Answers true when the container holds the business that businessId names.
437
- // 2. That the business has turned online shopping on, which is the general settings it holds saying so.
438
- // Settings it holds none of, because it has made none or has deleted them, do not. The server does not
439
- // refuse a business for this, so this is where a business that has not is a NotFoundException, the
440
- // same one as the server's for the businesses it does refuse, to every caller and every read.
441
- // 3. That the business has something that a customer can buy: a product that is enabled, that it does not sell
442
- // only offline, and that has a package with a price. A business with none, because it has made none, has
443
- // turned them all off, or sells them all offline, is not a shop that there is anything to show or to order
444
- // from, and is a NotFoundException as well. That is decided from the whole of what the container holds, as
445
- // the first two are, and so it is decided when the last page has been written.
446
- // firstAuthTokens are those of the link that businessEntityId is of, and none when that is none: they sign the reads that
447
- // name the business by it, and a read by the businessId is signed with nothing, however it came to be made.
386
+ // Whether the business can be opened is decided from the container once every page is written,
387
+ // since a page carries only what changed. After every read, in order:
388
+ // 1. The container holds the business businessId names. A business keeps its entity id when its
389
+ // 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,
391
+ // 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.
395
+ // 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.
448
400
  async syncBusinessContainer(containerName, businessId, businessEntityId, firstAuthTokens) {
449
401
  const cache = Model.getCache();
450
402
  const cached = await cache.readBusiness(containerName);
451
403
  const cachedBusiness = Model.buildPublishable(cached);
452
- this.publish(cachedBusiness); // Unless it is a business that is not open - see publishBusiness
453
- this._isPublishedBusinessVetted = false; // Until this has read it to the end
454
- // The entity id that an earlier read of this container left in it: what a business is named by when no link names
455
- // it. It is how the business was found then, and it may not be what the businessId finds now: a business can have
456
- // its businessId given up and taken by another, or changed.
404
+ this.publish(cachedBusiness); // Null if online shopping is off, see publishBusiness
405
+ this._isPublishedBusinessVetted = false; // Until the read ends
406
+ // The entity id an earlier read left in this container. It names the business when no link
407
+ // does. It may not be what the businessId finds now: a business can give up its businessId to
408
+ // another, or change it.
457
409
  const cachedEntityId = ProtoMap.getBusinessResponseBusinessEntityId(cached);
458
- // What the store accepted - the blobs it stored - which decides the publish at the end
410
+ // What the store accepted (the blobs it stored). Decides the publish at the end.
459
411
  let changeCount;
460
412
  try {
461
413
  changeCount = await this.readBusinessPages(containerName, businessEntityId ?? cachedEntityId ?? businessId, await cache.readBusinessCursors(containerName), ProtoMap.getBusinessResponseAttachmentBaseUrlExpiry(cached), firstAuthTokens);
462
414
  }
463
415
  catch (error) {
464
- // The server says that the business cannot be opened: what was published from the cache, before it was asked, is
465
- // not what is there. The container is left as it is - deciding that a business is gone is the caller's
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
466
418
  if (error instanceof NotFoundException)
467
419
  this.publishBusiness(null);
468
420
  throw error;
@@ -470,60 +422,55 @@ class Model {
470
422
  let held = await cache.readBusiness(containerName);
471
423
  // Whether what was published is no longer what the container holds
472
424
  let isPublishedStale = changeCount !== 0;
473
- // Named by the cached entity id, and not by a link, which names the business that it is of and no other
425
+ // Named by the cached entity id, not by a link, which names only its own business
474
426
  if (businessEntityId === null && cachedEntityId !== null && !Model.isBusinessNamed(held, businessId)) {
475
- // What has been read of one business is no guide to what has not been read of another, so, none of the container
476
- // is kept, nor how far it was read, and the business that the businessId names is read from the beginning. That is with
477
- // no attachment url held either, which is why the expiry is zero and not that of the container's: the url it held was the
478
- // old business's, which has been given up with it, and a server sent its expiry would take the new business to have it and
479
- // send none, leaving every photo of it without one. Signed with nothing, a link token being refused with a 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
480
433
  await cache.deleteBusiness(containerName);
481
434
  this.publishBusiness(null);
482
435
  await this.readBusinessPages(containerName, businessId, new SyncState(SyncKeys.BUSINESS_SYNC_IN), 0n, null);
483
436
  held = await cache.readBusiness(containerName);
484
437
  isPublishedStale = true;
485
438
  }
486
- // What the businessId finds is the business that has it, so, one that does not is the server contradicting itself
439
+ // The businessId finds the business that has it, so any other is the server contradicting itself
487
440
  if (businessEntityId === null && !Model.isBusinessNamed(held, businessId))
488
441
  throw new InternalServerException(`The business that was read for the businessId ${businessId} has the businessId ${ProtoMap.getBusinessResponseBusinessId(held)}`);
489
- // Named by a link, which is of a business that no longer has the businessId. It cannot be read again by the businessId,
490
- // as a link token is refused with one, so, the container is given up as above and it is the caller's to start again
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
491
445
  if (businessEntityId !== null && !Model.isBusinessNamed(held, businessId)) {
492
446
  await cache.deleteBusiness(containerName);
493
447
  this.publishBusiness(null);
494
448
  return false;
495
449
  }
496
- // A container that holds no business is not one that this can say anything of: that is a failure of its own, and
497
- // not a business that has not turned online shopping on - see readJoinBusinessId
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)
498
452
  if (ProtoMap.getBusinessResponseBusinessEntityId(held) !== null && !ProtoMap.isBusinessResponseOnlineShoppingOn(held)) {
499
- // What was published from the cache, before the network was asked, is of the business as it was when it was open,
500
- // which is not what it is now: nothing of a business that cannot be opened is published, or a customer could be
501
- // joined to it - see readJoinBusinessId
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)
502
456
  this.publishBusiness(null);
503
457
  throw new NotFoundException("The business has not turned online shopping on");
504
458
  }
505
- // What the container holds now, built once for this and for the publish below: the one that was built from the cache
506
- // before the network was asked, when the read has changed nothing of it
507
- const business = isPublishedStale ? Model.buildPublishable(held) : cachedBusiness;
508
- if (business !== null && business.catalog.productCount === 0) {
509
- // Nothing that a customer can buy is a shop that is not found, and is not shown, whatever the cache published
510
- this.publishBusiness(null);
511
- throw new NotFoundException("The business has nothing that a customer can buy");
512
- }
513
- // A read the store took nothing from re-renders nobody - already published above from cache,
514
- // and nothing here changes what a later read of this container answers
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.
515
463
  if (isPublishedStale)
516
- this.publish(business);
517
- // What is published now is what was read to the end, and is the business that the businessId names, and is open
464
+ this.publishBusiness(held);
465
+ // What is published now was read to the end, is the business the businessId names, and is open
518
466
  this._isPublishedBusinessVetted = true;
519
467
  return true;
520
468
  }
521
- // Reads every page of a business that the server has for the cursors it is given, and writes each into the
522
- // container as it arrives. The business is named by its entity id or by its businessId, which is decided by
523
- // the first request, and every page after it is about the same business named the same way. Answers what the
524
- // store accepted - the blobs it stored.
525
- // firstAuthTokens are those of a link, and a link token is refused with a businessId, so a read that names the business by
526
- // it is sent none, whatever it is given: no request that names a businessId carries one, from any of the paths that make it.
469
+ // 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.
527
474
  async readBusinessPages(containerName, business, cursors, attachmentBaseUrlExpiry, firstAuthTokens) {
528
475
  const cache = Model.getCache();
529
476
  let request = ProtoMap.buildBusinessSyncOutRequest(business, cursors, attachmentBaseUrlExpiry);
@@ -532,12 +479,12 @@ class Model {
532
479
  for (;;) {
533
480
  const page = await Model.getService().businessSyncOut(request, authTokens);
534
481
  const issued = ProtoMap.getResponseAuthTokens(page);
535
- // Written as it arrives, not at the end, so an interrupted read merely repeats itself instead
536
- // of losing pages already received. Entities and tombstones go in together, one write per page.
482
+ // Written as each page arrives, so an interrupted read repeats work and loses no pages
483
+ // already received. Entities and tombstones go in together, one write per page.
537
484
  changeCount += await cache.writeBusiness(containerName, page);
538
485
  await this.storeIssuedTokens(issued, containerName);
539
- // Null when this was the last page: fewer than a full page came back, or a full one advanced
540
- // no cursor - which only a server breaking its own ordering can do
486
+ // Null on the last page: fewer than a full page came back, or a full one advanced no cursor
487
+ // (only a server breaking its own ordering does that)
541
488
  const next = ProtoMap.nextBusinessSyncOutRequest(request, page);
542
489
  if (next === null)
543
490
  return changeCount;
@@ -546,17 +493,17 @@ class Model {
546
493
  authTokens = ProtoMap.refreshAuthTokens(authTokens, issued);
547
494
  }
548
495
  }
549
- // Whether a response holds the business that businessId names, or holds none at all, which says nothing against it:
550
- // only a business that has another businessId is another business. A business id is compared as the server finds
551
- // one, in whatever case it is written and however it is padded
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)
552
499
  static isBusinessNamed(response, businessId) {
553
500
  const heldBusinessId = ProtoMap.getBusinessResponseBusinessId(response);
554
501
  return (heldBusinessId === null ||
555
502
  canonicalizeBusinessId(heldBusinessId) === canonicalizeBusinessId(businessId));
556
503
  }
557
- // The BusinessLink of this profile for the named business, joining as a customer first (once) if
558
- // not linked yet. A business in the profile with no matching link is a server inconsistency, not
559
- // something a join would fix, so that case throws directly.
504
+ // 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.
560
507
  async requireBusinessLink(businessId, profile) {
561
508
  if (profile.user === null)
562
509
  throw new IllegalOperationException("No profile has been read, so who is browsing is not known");
@@ -567,25 +514,25 @@ class Model {
567
514
  throw new InternalServerException(`This customer has no BusinessLink to business ${businessId}`);
568
515
  const joinBusinessId = await this.readJoinBusinessId(businessId);
569
516
  await this.profileSyncIn(ProtoMap.buildProfileSyncOutRequest(await Model.getCache().readProfileCursors(this.requireProfileContainerName()), joinBusinessId), await this.requireProfileAuthTokens());
570
- // Attempted once: a join that still leaves no link is the server contradicting itself, not
571
- // something retrying would fix.
517
+ // Tried once: a join that leaves no link is the server contradicting itself, and retrying
518
+ // would not fix it.
572
519
  const joined = this._profile.value;
573
520
  const joinedLink = joined === null ? null : Model.findBusinessLink(joined, businessId);
574
521
  if (joinedLink === null)
575
522
  throw new InternalServerException(`Joining business ${businessId} did not leave this customer's profile naming it`);
576
523
  return joinedLink;
577
524
  }
578
- // The entity id to join, for a business this profile doesn't list yet. Read off the Business
579
- // already held, if an anonymous visit left one behind and read it to the end, or off a fresh anonymous read otherwise.
580
- // A business that is only published from the cache, by a read that has not ended, is not one that has been found to
581
- // be open and to be the one that the businessId names, and a customer is not joined to it on that account.
525
+ // The entity id to join, for a business this profile does not list yet. Taken from the Business
526
+ // already held, if an anonymous visit read it to the end, else from a fresh anonymous read.
527
+ // A business published from the cache by a read that has not ended is not known to be open or to
528
+ // be the one the businessId names, so a customer is not joined to it.
582
529
  async readJoinBusinessId(businessId) {
583
530
  const alreadyHeld = this._business.value;
584
531
  if (alreadyHeld !== null && this._isPublishedBusinessVetted)
585
532
  return alreadyHeld.id;
586
533
  await this.syncAnonymousBusiness(businessId);
587
- // A business that is not there is a NotFoundException of the read above. Reaching here with none
588
- // published is the server answering with no Business at all, which is its own failure
534
+ // 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
589
536
  const business = this._business.value;
590
537
  if (business === null)
591
538
  throw new InternalServerException(`The read of business ${businessId} answered with no Business`);
@@ -598,8 +545,8 @@ class Model {
598
545
  return business;
599
546
  return null;
600
547
  }
601
- // Null unless both the business and a link naming this profile's own user are present - a
602
- // membership is both of those together, not either alone.
548
+ // Null unless both the business and a link naming this profile's user are present: a membership
549
+ // needs both.
603
550
  static findBusinessLink(profile, businessId) {
604
551
  const user = profile.user;
605
552
  const businessEntity = Model.findBusinessEntity(profile, businessId);
@@ -610,22 +557,20 @@ class Model {
610
557
  return link;
611
558
  return null;
612
559
  }
613
- // Where this customer's own copy of a business lives, named for the BusinessLink rather than
614
- // Business.businessId so a signed-in customer's data never mixes with an anonymous visit to the
615
- // same storefront. Hex for the same reason as Ids.toStorageHexString: a fixed-width,
560
+ // Where this customer's copy of a business lives. Named for the BusinessLink, not
561
+ // Business.businessId, so a signed-in customer's data does not mix with an anonymous visit to the
562
+ // same storefront. Hex for the reason given at Ids.toStorageHexString: a fixed-width,
616
563
  // case-sensitive name.
617
564
  static getBusinessContainerName(link) {
618
565
  return ContainerNames.forBusiness(Ids.toStorageHexString(link.id));
619
566
  }
620
- // The credentials that sign a call about a customer's own business container. The authentication
621
- // token always comes from memory, not a cache read - same as buildProfileAuthTokens, since a
622
- // stored AuthTokens never persists it.
623
- // The user token and link token are read independently, not as one object: storeIssuedTokens
624
- // overwrites the whole blob per call, so trusting only that would lose whichever token the last
625
- // call didn't reissue. Each falls back to its own source instead - the profile container's user
626
- // token, this link's own linkToken - which is exactly what a fresh membership lacks and what the
627
- // write below now gives this container to reissue over.
628
- // A link with no linkToken at all is a server inconsistency: thrown rather than sent unsigned.
567
+ // The credentials that sign a call about a customer's business container. The authentication
568
+ // token comes from memory, as in buildProfileAuthTokens: a stored AuthTokens does not keep it.
569
+ // 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.
573
+ // A link with no linkToken is a server inconsistency, and throws rather than sending unsigned.
629
574
  async requireBusinessAuthTokens(containerName, link) {
630
575
  const authenticationMethod = this._authenticationMethod;
631
576
  const authenticationToken = this._authenticationToken;
@@ -646,10 +591,9 @@ class Model {
646
591
  await cache.writeAuthTokens(containerName, authTokens);
647
592
  return authTokens;
648
593
  }
649
- // The profile of whoever is signed in, paged the same way. Everything it earns files in the
650
- // profile container - this call is entirely about the caller, so its AuthTokens never carries a
651
- // link token; the only linkToken the protocol sends travels inside a BusinessLink, stored as part
652
- // of the profile.
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.
653
597
  async profileSyncIn(firstRequest, firstAuthTokens) {
654
598
  const containerName = this.getProfileContainerName();
655
599
  if (containerName === null)
@@ -659,8 +603,8 @@ class Model {
659
603
  let request = firstRequest;
660
604
  let authTokens = firstAuthTokens;
661
605
  let changeCount = 0;
662
- // Tracked across every page, not just the first: a profile long enough to page could put the
663
- // joined link and business on different pages, and two flags cost nothing next to relying on that.
606
+ // Tracked across all pages: a profile long enough to page could put the joined link and
607
+ // business on different pages.
664
608
  const joinBusinessId = firstRequest.joinBusinessId;
665
609
  let hasJoinedLink = false;
666
610
  let hasJoinedBusiness = false;
@@ -675,8 +619,8 @@ class Model {
675
619
  if (next === null)
676
620
  break;
677
621
  request = next;
678
- // Each call after the first is signed with whatever the page before it reissued - a read of
679
- // many pages is where a token can fall due part way through
622
+ // 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
680
624
  authTokens = ProtoMap.refreshAuthTokens(authTokens, ProtoMap.getResponseAuthTokens(page));
681
625
  }
682
626
  // As above: nothing the store took, nothing published
@@ -685,40 +629,35 @@ class Model {
685
629
  response = await Model.getCache().readProfile(containerName);
686
630
  this.publishProfile(response);
687
631
  }
688
- // Last, so everything that arrived is cached and published first: those pages weren't at
689
- // fault, the join was
632
+ // Last, so everything that arrived is cached and published first. The pages were not at fault,
633
+ // the join was
690
634
  Model.requireJoinedBusiness(joinBusinessId, hasJoinedLink, hasJoinedBusiness);
691
635
  return response;
692
636
  }
693
- // The sign-up call. Never paged: resending it would create a second user, and a caller signing up
694
- // has at most their new User, one BusinessLink and the Business it names. It can join a business
695
- // in the same call, and a join's requirements are the same as on a profile read - one response
696
- // instead of several is the only difference.
637
+ // The sign-up call. Not paged: resending it would create a second user, and a new caller has at
638
+ // most their User, one BusinessLink and the Business it names. It can join a business in the same
639
+ // call, with the same join requirements as a profile read, in one response.
697
640
  async createProfileOnServer(request, authTokens) {
698
641
  const response = await this.writeProfileResponse(await Model.getService().createUser(request, authTokens));
699
642
  const joinBusinessId = request.joinBusinessId;
700
- // Raised after the response is cached and published, as on a profile read - sharper here since
701
- // the User exists regardless of the join, so discarding this response and signing up again
702
- // would create a second one
643
+ // Raised after the response is cached and published, as on a profile read. It matters more
644
+ // here: the User exists regardless of the join, so discarding the response and signing up
645
+ // again would create a second one
703
646
  Model.requireJoinedBusiness(joinBusinessId, ProtoMap.hasJoinedBusinessLink(joinBusinessId, response), ProtoMap.hasJoinedBusiness(joinBusinessId, response));
704
647
  return response;
705
648
  }
706
649
  // #endregion
707
650
  // #region Driving one call
708
- // A join that was asked for but not answered. Shared by both calls that can carry a
709
- // joinBusinessId, since a join answers the same two things regardless of which one asked: the
710
- // BusinessLink (the membership) and the Business it names. Both are needed to reach a business -
711
- // the link for its token, the Business for the url-friendly id everything else is built from -
712
- // so either one missing leaves the caller told it is a customer of something it cannot reach.
651
+ // Throws when a requested join is not answered with both the BusinessLink (the membership, whose
652
+ // token signs calls) and the Business it names (whose businessId everything else is built from).
653
+ // Without both, the caller is a customer of something it cannot reach. Shared by the two calls
654
+ // that can carry a joinBusinessId.
713
655
  //
714
- // A ServiceException, not one of Exceptions.ts: nothing the caller passed was wrong - the server
715
- // answered 200 and left out what was asked for - so a caller can retry or carry on with what is
716
- // cached. 500 is the closest code, and the one that says retrying is worth it.
717
- //
718
- // Only a joinBusinessId a server could act on is checked (Java Business.isValidBusinessId's
719
- // test, which Constants.ENTITY_ID_NULL also fails, so "no join asked" needs no case of its own).
720
- // An id that is not a business entity id never gets as far as here: a server refuses it (400),
721
- // which fails the call itself, and sending one is the caller's bug, not the server's.
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.
658
+ // 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.
722
661
  static requireJoinedBusiness(joinBusinessId, hasBusinessLink, hasBusiness) {
723
662
  if (Ids.getEntityTypeId(joinBusinessId) !== proto_Constants.BUSINESS_ENTITY_TYPE_ID)
724
663
  return;
@@ -731,14 +670,13 @@ class Model {
731
670
  : "BusinessLink";
732
671
  throw new InternalServerException(`A join of business ${joinBusinessId} was answered with no ${missing}`);
733
672
  }
734
- // What every profile response leaves in the store: its entities cached, and whatever it reissued
735
- // filed against the same container. Answers how much of it the store took, which is what decides
736
- // whether anything is published. Shared by all three calls that receive one - each page of a read,
737
- // a sign up and an update - so none of them can come to cache a response differently from the
738
- // others, or file a token in a different place. Publishing is not in here: a read publishes once
739
- // for a whole loop of these, and the other two once each.
740
- // Only a page of a read moves the cursors of the container. A sign up and an update are changes this
741
- // client made, and the next read brings them back, so, they are stored without moving any.
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.
678
+ // Only a page of a read moves the container's cursors. A sign up and an update are changes this
679
+ // client made and the next read brings them back, so they are stored without moving any.
742
680
  async cacheProfileResponse(containerName, response, isPageOfRead) {
743
681
  const cache = Model.getCache();
744
682
  const changeCount = isPageOfRead
@@ -747,13 +685,11 @@ class Model {
747
685
  await this.storeIssuedTokens(ProtoMap.getResponseAuthTokens(response), containerName);
748
686
  return changeCount;
749
687
  }
750
- // What a non-sync-out profile call leaves behind: the response cached and published, same as a
751
- // paged read, minus paging neither needs. Both calls that come through here - a sign up and an
752
- // update - publish on exactly the rule a read keeps, which is what the store took. An update
753
- // publishes nothing today only because the server puts no entities in that response: it answers
754
- // with whatever it reissued and nothing else, the caller already knowing what it sent. The day it
755
- // answers with the stored User - whose update number and updated time a caller cannot know - this
756
- // publishes it with no change here.
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.
757
693
  async writeProfileResponse(response) {
758
694
  const containerName = this.getProfileContainerName();
759
695
  if (containerName === null)
@@ -762,15 +698,14 @@ class Model {
762
698
  this.publishProfile(await Model.getCache().readProfile(containerName));
763
699
  return response;
764
700
  }
765
- // Files what a call earned against the container that call was about: a profile call's tokens in
766
- // the subject's profile container, a business call's in that business's container. Which call it
767
- // was decides this, not which token arrived - a token carries nothing saying what it's about, so
768
- // only the caller, which knows what it asked, can attribute one. A business sync out that reissues
769
- // a user token still files that copy with the business it came back from.
770
- // A profile call never carries a link token here - a membership's link token travels inside its
771
- // BusinessLink entity instead, stored with the rest of the profile.
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.
705
+ // A profile call carries no link token here: a membership's link token travels inside its
706
+ // BusinessLink, stored with the profile.
772
707
  // A failed write is swallowed: the server issues another next call, so losing one costs a round
773
- // trip, where surfacing it would turn a working read into a thrown one.
708
+ // trip, and surfacing it would turn a working read into a thrown one.
774
709
  async storeIssuedTokens(issued, containerName) {
775
710
  if (issued === undefined)
776
711
  return;
@@ -783,16 +718,15 @@ class Model {
783
718
  }
784
719
  // #endregion
785
720
  // #region Containers and credentials
786
- // Where this subject's profile and tokens are cached, keyed by authentication method plus
787
- // subject id since two identity providers could otherwise issue the same subject id. Null when
788
- // signed out; several can coexist, so signing back in finds this subject's own profile.
721
+ // 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.
789
724
  getProfileContainerName() {
790
725
  return this._authenticationMethod === null || this._subjectId === null
791
726
  ? null
792
727
  : ContainerNames.forProfile(`${this._authenticationMethod}.${this._subjectId}`);
793
728
  }
794
- // The profile container name, when a profile is already known to exist - throws rather than
795
- // silently building on a signed-out state the caller has already ruled out.
729
+ // The profile container name, for when a profile is known to exist. Throws if signed out.
796
730
  requireProfileContainerName() {
797
731
  const containerName = this.getProfileContainerName();
798
732
  if (containerName === null)
@@ -800,7 +734,7 @@ class Model {
800
734
  return containerName;
801
735
  }
802
736
  // Signs a call about the caller alone, or null if signed out. Rereads the user token from the
803
- // profile container each time, since a call can reissue it. Carries no linkToken - see
737
+ // profile container each time, since a call can reissue it. Carries no linkToken. See
804
738
  // requireBusinessAuthTokens for a business call.
805
739
  async buildProfileAuthTokens() {
806
740
  const authenticationMethod = this._authenticationMethod;
@@ -823,28 +757,23 @@ class Model {
823
757
  // #endregion
824
758
  // #region Publishing
825
759
  //
826
- // What a read publishes turns on how much the store accepted - blobs stored plus ids deleted,
827
- // summed across every page - not on what the response carried. They differ because the store
828
- // keeps a blob only when its eTag is later, so a response the server sent can still be rejected
829
- // in full - a cursor that moved without its eTag moving is resent an entity already cached.
830
- // Publishing that would re-render every observer over an identical value, which the count avoids,
831
- // and nothing is lost: a response the store took nothing from can't change what a later read
832
- // answers.
833
- // All four calls use this, which is why an update publishes nothing - see writeProfileResponse.
834
- // A business that has not turned online shopping on is not published, whatever the container holds of it: what is
835
- // published is then no business, as it is a business that cannot be opened, which a read throws for. Without this,
836
- // the cache-first publish of a container that a read of such a business left behind would show it again before the
837
- // next read is refused, and a customer could be joined to it. A read that finds that a business that it published from
838
- // the cache has stopped being open, or that the server says cannot be opened, takes it back: it publishes none.
839
- // What is published from the cache is not taken to be open all the same until the read has ended (see
840
- // _isPublishedBusinessVetted), which is what readJoinBusinessId goes by.
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
+ // All four calls follow this, so an update publishes nothing (see writeProfileResponse).
765
+ //
766
+ // A business with online shopping off is not published, even from the cache, or a customer could
767
+ // 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).
841
770
  publishBusiness(response) {
842
771
  this.publish(Model.buildPublishable(response));
843
772
  }
844
773
  publish(business) {
845
774
  this._business.value = business;
846
775
  }
847
- // The business that a response holds, or none when there is no response or the business has not turned online shopping on
776
+ // The business a response holds, or null when there is no response or online shopping is off
848
777
  static buildPublishable(response) {
849
778
  return response === null || !ProtoMap.isBusinessResponseOnlineShoppingOn(response)
850
779
  ? null