@peabody-soft/mbs-model-ts 1.6.0 → 1.7.1

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.
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,18 +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, or nothing to buy, is
298
- // decided here (see syncBusinessContainer). The cache is kept, except that a business that is no
299
- // longer the one the businessId names is replaced by the one that is.
300
- // isStartedOver marks the one self-call, when the link the profile found for the businessId is of
301
- // 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.
302
308
  async businessSyncIn(isStartedOver = false) {
303
309
  const businessId = this._businessId;
304
310
  if (businessId === null || businessId.length === 0)
@@ -335,11 +341,11 @@ class Model {
335
341
  const authTokens = await this.requireBusinessAuthTokens(containerName, link);
336
342
  // The link names the business by entity id, because the link token that signs this call is for it
337
343
  const isBusinessNamed = await this.syncBusinessContainer(containerName, businessId, link.businessId, ProtoMap.buildAuthTokens(authTokens));
338
- // The link's business has another businessId now, which the profile that found the link by this
339
- // one did not know. The container is given up, so the profile is read again: the link is then
340
- // found by this businessId or not, and a businessId naming another business is joined. Only
341
- // once: a re-read profile that still finds the same link for the same businessId means the
342
- // 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
343
349
  if (!isBusinessNamed) {
344
350
  if (isStartedOver)
345
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`);
@@ -353,8 +359,8 @@ class Model {
353
359
  return;
354
360
  // The visitor's cart holds taxes as raw codes, meaningful only within the country of the
355
361
  // business it was built in. That country is read from the anonymous container. A cart is stored
356
- // only after its business has been (see updateCartLocalOnly); failing that, the business being
357
- // 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.
358
364
  const anonymousCountryCode = ProtoMap.getBusinessResponseCountryCode(anonymous) ?? this._business.value?.countryCode;
359
365
  if (anonymousCountryCode === undefined)
360
366
  throw new IllegalOperationException("The business the visitor's cart was built in is not known, so its taxes cannot be read");
@@ -374,27 +380,27 @@ class Model {
374
380
  async syncAnonymousBusiness(businessId) {
375
381
  await this.syncBusinessContainer(ContainerNames.forBusiness(businessId), businessId, null, null);
376
382
  }
377
- // Reads one business into containerName, cache first: what is held publishes before the network
378
- // is touched, so a returning customer sees the shop at once. Cursors start where earlier reads got
379
- // 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.
380
386
  // The first request names the business by entity id where known (businessEntityId from a link,
381
387
  // else the one in the container), else by businessId, and every later page does the same.
382
- // firstAuthTokens are the link's and sign the reads by entity id. A read by businessId is signed
383
- // 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.
384
390
  //
385
391
  // Whether the business can be opened is decided from the container once every page is written,
386
392
  // since a page carries only what changed. After every read, in order:
387
393
  // 1. The container holds the business businessId names. A business keeps its entity id when its
388
394
  // businessId changes and another can take the old one, so a container read by entity id may
389
- // 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,
390
396
  // which finds the right business or none (NotFoundException from the server).
391
- // A read named by a link cannot be redone that way (a link token is refused with a
392
- // businessId). Its container is given up and this answers false, so the caller starts again
393
- // 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.
394
400
  // 2. Online shopping is on in the general settings (no settings means off). The server does not
395
- // refuse a business for this, so this throws NotFoundException.
396
- // 3. The business has something to buy: an enabled product, not sold only offline, with a priced
397
- // package. Otherwise it throws NotFoundException too.
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.
398
404
  async syncBusinessContainer(containerName, businessId, businessEntityId, firstAuthTokens) {
399
405
  const cache = Model.getCache();
400
406
  const cached = await cache.readBusiness(containerName);
@@ -405,14 +411,15 @@ class Model {
405
411
  // does. It may not be what the businessId finds now: a business can give up its businessId to
406
412
  // another, or change it.
407
413
  const cachedEntityId = ProtoMap.getBusinessResponseBusinessEntityId(cached);
408
- // 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.
409
416
  let changeCount;
410
417
  try {
411
418
  changeCount = await this.readBusinessPages(containerName, businessEntityId ?? cachedEntityId ?? businessId, await cache.readBusinessCursors(containerName), ProtoMap.getBusinessResponseAttachmentBaseUrlExpiry(cached), firstAuthTokens);
412
419
  }
413
420
  catch (error) {
414
- // The server says the business cannot be opened, so what was published from the cache is not
415
- // 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
416
423
  if (error instanceof NotFoundException)
417
424
  this.publishBusiness(null);
418
425
  throw error;
@@ -420,14 +427,15 @@ class Model {
420
427
  let held = await cache.readBusiness(containerName);
421
428
  // Whether what was published is no longer what the container holds
422
429
  let isPublishedStale = changeCount !== 0;
423
- // 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
424
432
  if (businessEntityId === null && cachedEntityId !== null && !Model.isBusinessNamed(held, businessId)) {
425
- // Nothing read of one business says anything about another, so none of the container is kept,
426
- // nor how far it was read. The business the businessId names is read from the beginning, with
427
- // no attachment url held: the expiry is zero, not the container's, because the url it held
428
- // was the old business's and went with it. A server sent that expiry would take the new
429
- // business to have the url and send none, leaving every photo without one. Signed with
430
- // 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
431
439
  await cache.deleteBusiness(containerName);
432
440
  this.publishBusiness(null);
433
441
  await this.readBusinessPages(containerName, businessId, new SyncState(SyncKeys.BUSINESS_SYNC_IN), 0n, null);
@@ -437,44 +445,36 @@ class Model {
437
445
  // The businessId finds the business that has it, so any other is the server contradicting itself
438
446
  if (businessEntityId === null && !Model.isBusinessNamed(held, businessId))
439
447
  throw new InternalServerException(`The business that was read for the businessId ${businessId} has the businessId ${ProtoMap.getBusinessResponseBusinessId(held)}`);
440
- // Named by a link whose business no longer has the businessId. It cannot be re-read by
441
- // businessId (a link token is refused with one), so the container is given up as above and the
442
- // 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
443
451
  if (businessEntityId !== null && !Model.isBusinessNamed(held, businessId)) {
444
452
  await cache.deleteBusiness(containerName);
445
453
  this.publishBusiness(null);
446
454
  return false;
447
455
  }
448
- // A container holding no business says nothing about online shopping. That is a failure of its
449
- // 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)
450
458
  if (ProtoMap.getBusinessResponseBusinessEntityId(held) !== null && !ProtoMap.isBusinessResponseOnlineShoppingOn(held)) {
451
- // What was published from the cache is the business as it was when open, not as it is now.
452
- // Nothing of a business that cannot be opened stays published, or a customer could be joined
453
- // 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)
454
462
  this.publishBusiness(null);
455
463
  throw new NotFoundException("The business has not turned online shopping on");
456
464
  }
457
- // What the container holds now, built once for this check and the publish below. When the read
458
- // changed nothing, it is the one built from the cache.
459
- const business = isPublishedStale ? Model.buildPublishable(held) : cachedBusiness;
460
- if (business !== null && business.catalog.productCount === 0) {
461
- // A shop with nothing to buy is not found and not shown, even if the cache published it
462
- this.publishBusiness(null);
463
- throw new NotFoundException("The business has nothing that a customer can buy");
464
- }
465
- // A read the store took nothing from re-renders nobody: the cache was published above, and
466
- // nothing here changes what a later read answers
465
+ // Publish what the container now holds if it changed during the read. Otherwise the cache
466
+ // published at the start is still current.
467
467
  if (isPublishedStale)
468
- this.publish(business);
468
+ this.publishBusiness(held);
469
469
  // What is published now was read to the end, is the business the businessId names, and is open
470
470
  this._isPublishedBusinessVetted = true;
471
471
  return true;
472
472
  }
473
473
  // Reads every page the server has for the given cursors and writes each into the container as it
474
- // arrives. The business is named by entity id or businessId, decided by the first request, and
475
- // every later page names it the same way. Answers what the store accepted (the blobs it stored).
476
- // firstAuthTokens are those of a link. A link token is refused with a businessId, so a read
477
- // 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.
478
478
  async readBusinessPages(containerName, business, cursors, attachmentBaseUrlExpiry, firstAuthTokens) {
479
479
  const cache = Model.getCache();
480
480
  let request = ProtoMap.buildBusinessSyncOutRequest(business, cursors, attachmentBaseUrlExpiry);
@@ -492,30 +492,28 @@ class Model {
492
492
  const next = ProtoMap.nextBusinessSyncOutRequest(request, page);
493
493
  if (next === null)
494
494
  return changeCount;
495
- // 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
496
496
  request = next;
497
497
  authTokens = ProtoMap.refreshAuthTokens(authTokens, issued);
498
498
  }
499
499
  }
500
- // True when the response holds the business businessId names, or no business, which says nothing
501
- // against it. Only a business with another businessId is another business. Ids are compared as
502
- // 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)
503
503
  static isBusinessNamed(response, businessId) {
504
504
  const heldBusinessId = ProtoMap.getBusinessResponseBusinessId(response);
505
505
  return (heldBusinessId === null ||
506
506
  canonicalizeBusinessId(heldBusinessId) === canonicalizeBusinessId(businessId));
507
507
  }
508
508
  // The BusinessLink of this profile for the business, joining as a customer first (once) if not
509
- // linked. A business in the profile with no matching link is a server inconsistency a join would
510
- // 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.
511
511
  async requireBusinessLink(businessId, profile) {
512
512
  if (profile.user === null)
513
513
  throw new IllegalOperationException("No profile has been read, so who is browsing is not known");
514
514
  const link = Model.findBusinessLink(profile, businessId);
515
515
  if (link !== null)
516
516
  return link;
517
- if (Model.findBusinessEntity(profile, businessId) !== null)
518
- throw new InternalServerException(`This customer has no BusinessLink to business ${businessId}`);
519
517
  const joinBusinessId = await this.readJoinBusinessId(businessId);
520
518
  await this.profileSyncIn(ProtoMap.buildProfileSyncOutRequest(await Model.getCache().readProfileCursors(this.requireProfileContainerName()), joinBusinessId), await this.requireProfileAuthTokens());
521
519
  // Tried once: a join that leaves no link is the server contradicting itself, and retrying
@@ -536,7 +534,7 @@ class Model {
536
534
  return alreadyHeld.id;
537
535
  await this.syncAnonymousBusiness(businessId);
538
536
  // A missing business is a NotFoundException from the read above. Reaching here with none
539
- // 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
540
538
  const business = this._business.value;
541
539
  if (business === null)
542
540
  throw new InternalServerException(`The read of business ${businessId} answered with no Business`);
@@ -571,9 +569,10 @@ class Model {
571
569
  // The credentials that sign a call about a customer's business container. The authentication
572
570
  // token comes from memory, as in buildProfileAuthTokens: a stored AuthTokens does not keep it.
573
571
  // The user token and link token are read separately: storeIssuedTokens overwrites the whole blob
574
- // per call, so relying on it alone would lose whichever token the last call did not reissue. Each
575
- // falls back to its own source: the profile container's user token, and this link's linkToken. A
576
- // 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.
577
576
  // A link with no linkToken is a server inconsistency, and throws rather than sending unsigned.
578
577
  async requireBusinessAuthTokens(containerName, link) {
579
578
  const authenticationMethod = this._authenticationMethod;
@@ -595,9 +594,9 @@ class Model {
595
594
  await cache.writeAuthTokens(containerName, authTokens);
596
595
  return authTokens;
597
596
  }
598
- // The profile of whoever is signed in, paged like a business read. What it earns files in the
599
- // profile container. The call is about the caller alone, so its AuthTokens carries no link
600
- // 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.
601
600
  async profileSyncIn(firstRequest, firstAuthTokens) {
602
601
  const containerName = this.getProfileContainerName();
603
602
  if (containerName === null)
@@ -624,7 +623,7 @@ class Model {
624
623
  break;
625
624
  request = next;
626
625
  // Each call after the first is signed with what the previous page reissued, since a token can
627
- // fall due part way through a long read
626
+ // expire part way through a long read
628
627
  authTokens = ProtoMap.refreshAuthTokens(authTokens, ProtoMap.getResponseAuthTokens(page));
629
628
  }
630
629
  // As above: nothing the store took, nothing published
@@ -633,8 +632,8 @@ class Model {
633
632
  response = await Model.getCache().readProfile(containerName);
634
633
  this.publishProfile(response);
635
634
  }
636
- // Last, so everything that arrived is cached and published first. The pages were not at fault,
637
- // 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.
638
637
  Model.requireJoinedBusiness(joinBusinessId, hasJoinedLink, hasJoinedBusiness);
639
638
  return response;
640
639
  }
@@ -657,11 +656,11 @@ class Model {
657
656
  // Without both, the caller is a customer of something it cannot reach. Shared by the two calls
658
657
  // that can carry a joinBusinessId.
659
658
  //
660
- // A ServiceException, not a caller error: the server answered 200 and left out what was asked
661
- // 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.
662
661
  // Only a joinBusinessId the server could act on is checked (Java Business.isValidBusinessId,
663
- // which Constants.ENTITY_ID_NULL also fails, so "no join asked" needs no case). The server refuses
664
- // 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.
665
664
  static requireJoinedBusiness(joinBusinessId, hasBusinessLink, hasBusiness) {
666
665
  if (Ids.getEntityTypeId(joinBusinessId) !== proto_Constants.BUSINESS_ENTITY_TYPE_ID)
667
666
  return;
@@ -674,11 +673,11 @@ class Model {
674
673
  : "BusinessLink";
675
674
  throw new InternalServerException(`A join of business ${joinBusinessId} was answered with no ${missing}`);
676
675
  }
677
- // What every profile response leaves in the store: its entities cached, and what it reissued
678
- // filed against the same container. Answers how much the store took, which decides whether
679
- // anything is published. Shared by the three calls that receive one (a page of a read, a sign up,
680
- // an update) so they cache and file tokens the same way. It does not publish: a read publishes
681
- // 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.
682
681
  // Only a page of a read moves the container's cursors. A sign up and an update are changes this
683
682
  // client made and the next read brings them back, so they are stored without moving any.
684
683
  async cacheProfileResponse(containerName, response, isPageOfRead) {
@@ -689,11 +688,11 @@ class Model {
689
688
  await this.storeIssuedTokens(ProtoMap.getResponseAuthTokens(response), containerName);
690
689
  return changeCount;
691
690
  }
692
- // What a profile call other than a read leaves behind: the response cached and published, as for
693
- // a paged read without the paging. A sign up and an update publish by the rule a read follows,
694
- // on what the store took. An update publishes nothing today because the server puts no entities
695
- // in its response, only what it reissued. If it ever answers with the stored User, whose update
696
- // 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.
697
696
  async writeProfileResponse(response) {
698
697
  const containerName = this.getProfileContainerName();
699
698
  if (containerName === null)
@@ -702,14 +701,14 @@ class Model {
702
701
  this.publishProfile(await Model.getCache().readProfile(containerName));
703
702
  return response;
704
703
  }
705
- // Files what a call earned against the container the call was about: a profile call's tokens in
706
- // the subject's profile container, a business call's in that business's container. The call
707
- // decides this, since a token says nothing about what it is for. A business sync out that
708
- // 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.
709
708
  // A profile call carries no link token here: a membership's link token travels inside its
710
709
  // BusinessLink, stored with the profile.
711
- // A failed write is swallowed: the server issues another next call, so losing one costs a round
712
- // 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.
713
712
  async storeIssuedTokens(issued, containerName) {
714
713
  if (issued === undefined)
715
714
  return;
@@ -723,8 +722,9 @@ class Model {
723
722
  // #endregion
724
723
  // #region Containers and credentials
725
724
  // Where this subject's profile and tokens are cached. Keyed by authentication method and subject
726
- // id, since two identity providers could issue the same subject id. Null when signed out. Several
727
- // 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.
728
728
  getProfileContainerName() {
729
729
  return this._authenticationMethod === null || this._subjectId === null
730
730
  ? null
@@ -761,16 +761,16 @@ class Model {
761
761
  // #endregion
762
762
  // #region Publishing
763
763
  //
764
- // A read publishes on how much the store accepted (blobs stored plus ids deleted, summed over
765
- // every page), not on what the response carried. The store keeps a blob only when its eTag is
766
- // later, so a response can be rejected in full (a cursor that moved without its eTag resends an
767
- // 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.
768
768
  // All four calls follow this, so an update publishes nothing (see writeProfileResponse).
769
769
  //
770
770
  // A business with online shopping off is not published, even from the cache, or a customer could
771
771
  // be joined to it. A read that finds the published business is no longer open, or that the server
772
- // refuses, takes it back by publishing none. What came from the cache counts as open only once the
773
- // 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).
774
774
  publishBusiness(response) {
775
775
  this.publish(Model.buildPublishable(response));
776
776
  }