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