@peabody-soft/mbs-model-ts 1.11.0 → 1.13.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 (44) hide show
  1. package/dist/Constants.d.ts +1 -0
  2. package/dist/Constants.js +3 -0
  3. package/dist/Exceptions.d.ts +5 -0
  4. package/dist/Exceptions.js +16 -2
  5. package/dist/Ids.d.ts +1 -0
  6. package/dist/Ids.js +5 -3
  7. package/dist/InMemoryLock.d.ts +5 -0
  8. package/dist/InMemoryLock.js +20 -0
  9. package/dist/Lock.d.ts +3 -0
  10. package/dist/Lock.js +1 -0
  11. package/dist/Message.d.ts +1 -5
  12. package/dist/Message.js +2 -12
  13. package/dist/Model.d.ts +17 -4
  14. package/dist/Model.js +304 -113
  15. package/dist/Order.d.ts +2 -2
  16. package/dist/Order.js +4 -4
  17. package/dist/ProtoMap.d.ts +13 -4
  18. package/dist/ProtoMap.js +139 -71
  19. package/dist/Service.d.ts +3 -4
  20. package/dist/ServiceProxy.d.ts +3 -4
  21. package/dist/ServiceProxy.js +4 -6
  22. package/dist/SyncCache.d.ts +6 -2
  23. package/dist/SyncCache.js +83 -44
  24. package/dist/SyncKeys.js +3 -2
  25. package/dist/generated/BusinessSyncInRequest.d.ts +30 -16
  26. package/dist/generated/BusinessSyncInRequest.js +9 -1
  27. package/dist/generated/BusinessSyncInResponse.d.ts +9 -6
  28. package/dist/generated/Constants.d.ts +2 -12
  29. package/dist/generated/Constants.js +2 -12
  30. package/dist/generated/Message.d.ts +5 -16
  31. package/dist/generated/Message.js +8 -22
  32. package/dist/generated/Party.d.ts +52 -0
  33. package/dist/generated/Party.js +68 -0
  34. package/dist/generated/PartyPreferencesPayload.d.ts +2 -1
  35. package/dist/generated/ProfileSyncOutRequest.d.ts +2 -0
  36. package/dist/generated/ProfileSyncOutResponse.d.ts +2 -2
  37. package/dist/generated/SalesOrderPayload.d.ts +9 -8
  38. package/dist/generated/User.d.ts +3 -1
  39. package/dist/generated/UserRequest.d.ts +52 -0
  40. package/dist/generated/{CreateUserRequest.js → UserRequest.js} +11 -11
  41. package/dist/index.d.ts +4 -2
  42. package/dist/index.js +7 -5
  43. package/package.json +2 -2
  44. package/dist/generated/CreateUserRequest.d.ts +0 -43
package/dist/Model.js CHANGED
@@ -1,14 +1,15 @@
1
1
  import { Constants as proto_Constants } from "./generated/Constants";
2
- import { CreateUserRequest as proto_CreateUserRequest } from "./generated/CreateUserRequest";
3
2
  import { FulfillmentMode as proto_FulfillmentMode } from "./generated/FulfillmentMode";
4
3
  import AuthTokens from "./AuthTokens";
4
+ import { MAX_PROFILE_STALENESS_MS } from "./Constants";
5
5
  import * as ContainerNames from "./ContainerNames";
6
6
  import { IllegalOperationException, InvalidArgumentException } from "./Exceptions";
7
7
  import Ids from "./Ids";
8
+ import InMemoryLock from "./InMemoryLock";
8
9
  import LiveData from "./LiveData";
9
10
  import { canonicalizeBusinessId } from "./Profile";
10
11
  import * as ProtoMap from "./ProtoMap";
11
- import { InternalServerException, NotFoundException } from "./ServiceExceptions";
12
+ import { ConflictException, InternalServerException, NotFoundException } from "./ServiceExceptions";
12
13
  import ShoppingList from "./ShoppingList";
13
14
  import SyncCache from "./SyncCache";
14
15
  import * as SyncKeys from "./SyncKeys";
@@ -16,15 +17,26 @@ import SyncState from "./SyncState";
16
17
  // The root of the object model, and the only object a UI needs. Call initialize once, then get the
17
18
  // current instance with getInstance.
18
19
  //
19
- // THERE IS NO LOCKING. A caller makes one call at a time and awaits it. Otherwise:
20
+ // CALLS ARE NOT QUEUED. A caller makes one call at a time and awaits it. Otherwise:
20
21
  // - A setBusinessId still reading when signIn reads the business too can publish the anonymous
21
22
  // container's business over the customer's. The published business then has no cart or orders,
22
23
  // and nothing publishes them again.
23
- // - Two reads at once interleave their pages and writes, as do two tabs on one store (each holds a
24
- // Model), so a store supports one tab at a time.
25
- // A queue inside Model would not fix the tabs, and each public method would need an unqueued twin
26
- // (signIn calls signOut, businessSyncIn calls updateCart). A caller that calls from several places,
27
- // like a UI, queues the calls itself.
24
+ // - Two reads at once interleave their pages and writes.
25
+ // A queue inside Model would need an unqueued twin of each public method (signIn calls signOut,
26
+ // businessSyncIn calls updateCart). A caller that calls from several places, like a UI, queues the
27
+ // calls itself. Only changeUser takes a Lock, so that two tabs do not start from the same user.
28
+ //
29
+ // SEVERAL TABS can use one store, each with its own Model. The store applies a batch atomically and
30
+ // keeps the greater eTag, so what the tabs store converges on what the server holds. A profile
31
+ // container is shared by every tab the customer has open, on any shop, so concurrent use matters
32
+ // more there than in a business container, which only tabs on the same shop share. A sequence of
33
+ // calls is not atomic across tabs:
34
+ // - Deleting a container (deleteBusiness, or an eviction by the store) while another tab writes
35
+ // pages to it can leave its cursors ahead of its entities.
36
+ // - The Business and Profile a Model publishes change only when that Model reads or writes. A
37
+ // cart or a user built from them can be older than what the store holds, and sending it replaces
38
+ // the newer one on the server. changeUser starts from the stored user, and a caller can read the
39
+ // profile again with refreshProfileIfStale.
28
40
  class Model {
29
41
  constructor() {
30
42
  // True when the published business, if any, has been read to the end and found to be the one
@@ -33,6 +45,13 @@ class Model {
33
45
  // failure, a rejection) leaves the business published and unvetted. See readJoinBusinessId, which
34
46
  // does not join such a business.
35
47
  this._isPublishedBusinessVetted = false;
48
+ // When the last read of the profile ended, as Date.now() gave it. Null before the first.
49
+ this._profileReadAt = null;
50
+ // The profile cursors the published profile was read at, from getProfileCursorsKey. Null until a
51
+ // profile is published from the store.
52
+ this._publishedProfileCursors = null;
53
+ // The read that refreshProfileIfStale is running, so an overlapping call can share it
54
+ this._profileRefresh = null;
36
55
  this._business = new LiveData(null);
37
56
  this._profile = new LiveData(null);
38
57
  this._authenticationMethod = null;
@@ -41,10 +60,13 @@ class Model {
41
60
  this._businessId = null;
42
61
  }
43
62
  // Sets the service and store to use. Discards the current instance, so nobody is signed in and no
44
- // business is browsed.
45
- static initialize(service, database) {
63
+ // business is browsed. lock makes changeUser exclusive: pass one that spans every tab that shares
64
+ // the store (see Lock), or leave the default, which excludes calls in this tab only. A call still
65
+ // running on the discarded instance is not excluded against the new one.
66
+ static initialize(service, database, lock = new InMemoryLock()) {
46
67
  Model._service = service;
47
68
  Model._cache = new SyncCache(database);
69
+ Model._lock = lock;
48
70
  Model._instance = null;
49
71
  }
50
72
  // The current model. Fetch it each time and do not keep it: signOut discards it.
@@ -63,7 +85,7 @@ class Model {
63
85
  // Signs in and syncs this subject's profile in, for a subject the server has a profile for (use
64
86
  // createUser otherwise). Also reads in the business being browsed, if any, as this customer.
65
87
  //
66
- // Anything thrown after beginSignIn's guard (a bad credential, a rejected push while reading the
88
+ // Anything thrown after beginSignIn's guard (a bad credential, a rejected request while reading the
67
89
  // business) calls signOut. profileSyncIn publishes a cached profile before it reaches the
68
90
  // network, so a retry could otherwise find _profile set and fail the "already signed in" guard.
69
91
  // Throws NotFoundException if the business being browsed cannot be opened. See businessSyncIn.
@@ -84,22 +106,114 @@ class Model {
84
106
  // profile for (use signIn otherwise). Also reads in the business being browsed, if any, as this
85
107
  // customer. Throws NotFoundException if that business cannot be opened (see businessSyncIn), after
86
108
  // the User exists. The sign-up is not undone: the customer stays signed up and signed in.
109
+ // - The user is created under an id this model makes for the subject, not userToCreate.id. One id
110
+ // is made per subject and saved in the profile container before the call, and every attempt of
111
+ // that subject sends it until the sign up is stored or refused with a ConflictException. It is
112
+ // kept after a network error, a 5xx and any other error (a 400, a 401), and a successful signIn
113
+ // does not delete it. It is deleted once the answer is stored, and by a ConflictException.
114
+ // - A ConflictException means the server could not create the user: the subject has one already,
115
+ // or the id could not be used. It is rethrown, and this method does not sign in: the caller signs
116
+ // in, which reads the profile. If signIn answers UnauthorizedException the subject has no
117
+ // profile, and the caller creates again. The saved id is already deleted, so that sign up makes
118
+ // a new one.
119
+ // - When the business being browsed is published and was read to the end, the call joins it too,
120
+ // so the answer holds the link and the business and no second call is needed. If the server then
121
+ // answers a NotFoundException, the user was created but the join was rejected, and no answer came
122
+ // back: the profile is read without a join, and the business read that follows makes the join
123
+ // again, which throws. A NotFoundException when no join was sent is rethrown at once.
87
124
  async createUser(authenticationMethod, subjectId, authenticationToken, userToCreate) {
88
125
  this.beginSignIn(authenticationMethod, subjectId, authenticationToken);
89
126
  const authTokens = await this.requireProfileAuthTokens();
90
- await this.createProfileOnServer(proto_CreateUserRequest.create({ user: ProtoMap.buildUser(userToCreate) }), authTokens);
127
+ const containerName = this.requireProfileContainerName();
128
+ const cache = Model.getCache();
129
+ let userId = await cache.readUserId(containerName);
130
+ if (userId === null) {
131
+ userId = Ids.getNewId(proto_Constants.USER_ENTITY_TYPE_ID);
132
+ await cache.writeUserId(containerName, userId);
133
+ }
134
+ // A subject with no profile has nothing synced, so every cursor is zero
135
+ const noCursors = new SyncState(SyncKeys.PROFILE_SYNC_IN);
136
+ const joinBusinessId = this.getVettedBusiness()?.id ?? BigInt(proto_Constants.ENTITY_ID_NULL);
137
+ try {
138
+ await this.createProfileOnServer(ProtoMap.buildUserRequest(ProtoMap.buildUser(userToCreate, userId), noCursors, joinBusinessId), authTokens);
139
+ }
140
+ catch (error) {
141
+ if (error instanceof ConflictException) {
142
+ await cache.deleteUserId(containerName);
143
+ throw error;
144
+ }
145
+ const isJoinRefused = error instanceof NotFoundException &&
146
+ joinBusinessId !== BigInt(proto_Constants.ENTITY_ID_NULL);
147
+ if (!isJoinRefused)
148
+ throw error;
149
+ await this.profileSyncIn(ProtoMap.buildProfileSyncOutRequest(noCursors), await this.requireProfileAuthTokens());
150
+ await cache.deleteUserId(containerName);
151
+ }
91
152
  if (this._businessId !== null)
92
153
  await this.businessSyncIn();
93
154
  }
94
155
  // Updates this customer's own profile with what userProfile holds. Service.updateUser matches the
95
156
  // caller against the User by entity id (see ProtoMap.buildUpdateUser), so userProfile must be this
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).
157
+ // customer's own, read in earlier, not built fresh. Throws if no profile has been read. The answer
158
+ // is cached and published as a profile read is, so the User as the server stored it replaces the
159
+ // held one (see readProfilePages).
99
160
  async updateUser(userProfile) {
100
161
  if (this._profile.value === null)
101
162
  throw new IllegalOperationException("Nobody is signed in, so this call cannot be made");
102
- await this.writeProfileResponse(await Model.getService().updateUser(ProtoMap.buildUpdateUser(userProfile), await this.requireProfileAuthTokens()));
163
+ await this.sendUserUpdate(this.requireProfileContainerName(), userProfile);
164
+ }
165
+ // Changes this customer's own user. Reads the profile, calls change with the user the profile
166
+ // container stores, and sends the user change returns, as updateUser does. Use it instead of
167
+ // updateUser when the new user is the current one with something altered, for example an address
168
+ // added. The current user includes what other tabs and devices have written, which a user built
169
+ // from the published profile may lack, and sending that user would erase it.
170
+ // Calls for one profile run one at a time. When the Lock given to initialize spans tabs, a second
171
+ // tab that changes the user waits for this change and starts from its result. The server still
172
+ // takes the last write of two devices that change the user at once.
173
+ // change must return a user with the id it is given, and must not call changeUser, which would
174
+ // wait for its own lock. Throws IllegalOperationException if no profile has been read,
175
+ // InvalidArgumentException if change returns another id, and what change throws.
176
+ async changeUser(change) {
177
+ if (this._profile.value === null)
178
+ throw new IllegalOperationException("Nobody is signed in, so this call cannot be made");
179
+ const containerName = this.requireProfileContainerName();
180
+ await Model._lock.runExclusive(containerName, async () => {
181
+ await this.syncProfile(containerName);
182
+ const current = await this.readStoredUser(containerName);
183
+ if (current === null)
184
+ throw new IllegalOperationException("No user has been read, so there is none to change");
185
+ const changed = change(current);
186
+ if (changed.id !== current.id)
187
+ throw new InvalidArgumentException("A change of the user must keep the id of the user");
188
+ await this.sendUserUpdate(containerName, changed);
189
+ });
190
+ }
191
+ // Reads the profile if the last read of it is older than MAX_PROFILE_STALENESS_MS, and returns
192
+ // whether it did. Every read of the profile counts, a sign in and an update included. Does
193
+ // nothing when nobody is signed in or the first read is still under way. The read fetches only
194
+ // what changed, and calls that overlap share one read, so a UI can call it from every event that
195
+ // shows a tab again. The profile is published if it differs from what was published, including
196
+ // when another tab stored the answer first. Throws what a read throws, and a failed read leaves
197
+ // the profile stale, so the next call reads again. A clock set back counts as stale.
198
+ async refreshProfileIfStale() {
199
+ if (this._profile.value === null)
200
+ return false;
201
+ if (this._profileRefresh !== null)
202
+ return this._profileRefresh;
203
+ const readAt = this._profileReadAt;
204
+ if (readAt === null)
205
+ return false;
206
+ const age = Date.now() - readAt;
207
+ if (age >= 0 && age < MAX_PROFILE_STALENESS_MS)
208
+ return false;
209
+ const refresh = this.refreshProfile();
210
+ this._profileRefresh = refresh;
211
+ try {
212
+ return await refresh;
213
+ }
214
+ finally {
215
+ this._profileRefresh = null;
216
+ }
103
217
  }
104
218
  // Discards this model. The next getInstance() builds a fresh one, signed out and browsing nothing.
105
219
  async signOut() {
@@ -131,7 +245,7 @@ class Model {
131
245
  // order placed from it. Both need a customer who is a member of the business (see
132
246
  // requireCustomerOfBusiness), except that a signed-out visitor can keep a cart locally (see
133
247
  // updateCartLocalOnly).
134
- // An order reaches the store only after the server accepts it, so a customer never holds an order
248
+ // An order reaches the store only after the server accepts it, so a customer never has an order
135
249
  // the server has not accepted, and a failed call leaves nothing to reconcile. A cart can be
136
250
  // stored before the server has it (see updateCartLocalOnly).
137
251
  // Creates or replaces this customer's cart, on the server and in the store. An empty list
@@ -139,9 +253,11 @@ class Model {
139
253
  // Signed out, this calls updateCartLocalOnly, as the server has no cart for a visitor. Signed in,
140
254
  // it costs a request, so an editor should write each change with updateCartLocalOnly and call this
141
255
  // on a schedule (debounced, on blur, on a timer).
142
- // A call that matches the stored copy returns without a request (hasShoppingListChanged). It does
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.
256
+ // A call that matches the stored copy returns without a request (hasShoppingListChanged), and so
257
+ // reads nothing. A call that sends gets back the customer's messages that are newer than the last
258
+ // read, the cart among them: a change that the business made to an order comes with it. It brings
259
+ // nothing of the rest of the business, so it does not replace a read of it.
260
+ // The cart is sent as a whole and replaces the stored cart if it is dated later.
145
261
  async updateCart(shoppingList) {
146
262
  if (this._profile.value === null)
147
263
  return this.updateCartLocalOnly(shoppingList);
@@ -150,7 +266,7 @@ class Model {
150
266
  if (!ProtoMap.hasShoppingListChanged(stored, shoppingList))
151
267
  return;
152
268
  const partyPreferencesMessage = ProtoMap.buildPartyPreferencesMessage(Ids.getPartyPreferencesMessageId(target.businessLink.id), stored, target.senderId, shoppingList, null, BigInt(Date.now()));
153
- await this.pushBusinessMessages(target, [partyPreferencesMessage]);
269
+ await this.sendBusinessMessages(target, [partyPreferencesMessage]);
154
270
  }
155
271
  // The same, but stored locally and not sent. Call it on every edit (a request per tap of a
156
272
  // quantity stepper is too many), and call updateCart on a schedule to send.
@@ -161,12 +277,17 @@ class Model {
161
277
  // the update number of the copy it amends, buildPartyPreferencesMessage dates it past that copy,
162
278
  // and the later copy wins.
163
279
  async updateCartLocalOnly(shoppingList) {
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.
168
- if (this._profile.value === null)
169
- return this.storeCartInCache(ContainerNames.forBusiness(this.requireBusinessBeingBrowsed()), Ids.getNewId(proto_Constants.MESSAGE_ENTITY_TYPE_ID), BigInt(proto_Constants.ENTITY_ID_NULL), shoppingList);
280
+ // A signed-out visitor has no membership to derive a message id from, so a fresh id is drawn for
281
+ // the first edit and the stored cart keeps it, so later edits amend one entity. With no party, the
282
+ // sender is ENTITY_ID_NULL. A visitor's cart is never sent as it is: signing in sends a new cart
283
+ // under the id of the membership (see businessSyncIn).
284
+ if (this._profile.value === null) {
285
+ const containerName = ContainerNames.forBusiness(this.requireBusinessBeingBrowsed());
286
+ const stored = await Model.getCache().readPartyPreferences(containerName);
287
+ return this.storeCartInCache(containerName, stored === null
288
+ ? Ids.getNewId(proto_Constants.MESSAGE_ENTITY_TYPE_ID)
289
+ : ProtoMap.getMessageId(stored), BigInt(proto_Constants.ENTITY_ID_NULL), shoppingList);
290
+ }
170
291
  const target = this.requireCustomerOfBusiness();
171
292
  return this.storeCartInCache(target.containerName, Ids.getPartyPreferencesMessageId(target.businessLink.id), target.senderId, shoppingList);
172
293
  }
@@ -176,10 +297,14 @@ class Model {
176
297
  // The emptied cart records this fulfillmentMode, which Business.defaultFulfillmentMode returns.
177
298
  // The caller creates orderId with Ids.getNewId(Constants.MESSAGE_ENTITY_TYPE_ID), so it can find
178
299
  // 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.
300
+ // the same id, without placing a second order: the server stores an order only if no message has
301
+ // its id, so a retry changes nothing, even after the business has started the order. The cart has
302
+ // one id for the same reason (see getPartyPreferencesMessageId).
181
303
  // A delivery must name an address: an id from this customer's list, or a full address sent
182
- // directly (see requireDeliveryAddress).
304
+ // directly (see requireDeliveryAddress). Either way the address goes to the business with the
305
+ // order, in the party of the customer, so a full address has an id that is not zero.
306
+ // The party of the customer goes with every order: their name and phone number, and the delivery
307
+ // address of an order that is delivered. It is not sent with a cart.
183
308
  async createOrder(orderId, fulfillmentMode, deliveryAddressId, deliveryAddress, note) {
184
309
  const target = this.requireCustomerOfBusiness();
185
310
  // Read from the store rather than the published Business, so the order matches the stored cart
@@ -190,16 +315,25 @@ class Model {
190
315
  : ProtoMap.buildShoppingList(partyPreferencesMessage, target.countryCode);
191
316
  if (shoppingList.existingItems.length === 0)
192
317
  throw new IllegalOperationException("The cart holds nothing, so there is no order to place");
193
- Model.requireDeliveryAddress(target.addresses, fulfillmentMode, deliveryAddressId, deliveryAddress);
318
+ Model.requireDeliveryAddress(target.user.addresses, fulfillmentMode, deliveryAddressId, deliveryAddress);
319
+ // A delivery is where the party of the customer gets an address, as in the merchant app, which adds
320
+ // the delivery address of an order to the party and none for a pick-up. It is the address of the
321
+ // order: the one sent whole, else the one the id names in the customer's list.
322
+ const partyAddress = fulfillmentMode === proto_FulfillmentMode.DELIVERY
323
+ ? (deliveryAddress ?? target.user.addresses.getAddress(deliveryAddressId))
324
+ : null;
325
+ if (partyAddress !== null && partyAddress.id === BigInt(proto_Constants.ENTITY_ID_NULL))
326
+ throw new InvalidArgumentException("An address to deliver to has an id of its own, which is how the party of the customer keeps it");
194
327
  // One timestamp for both messages: the time the order was placed
195
328
  const now = BigInt(Date.now());
196
- // One request for the order and the emptied cart: two calls could leave an order placed and the
197
- // cart still full if the second failed
198
- await this.pushBusinessMessages(target, [
329
+ // One request for the order, the emptied cart and the party: two calls could leave an order placed
330
+ // and the cart still full if the second failed. The party goes with every order, one sent again
331
+ // included, and with nothing else: the server refuses a party that has no order with it.
332
+ await this.sendBusinessMessages(target, [
199
333
  ProtoMap.buildOrderMessage(orderId, target.senderId, shoppingList, fulfillmentMode, deliveryAddressId, deliveryAddress, note, now),
200
334
  // The customer's latest choice of fulfillmentMode, for defaultFulfillmentMode to offer next time
201
335
  ProtoMap.buildPartyPreferencesMessage(Ids.getPartyPreferencesMessageId(target.businessLink.id), partyPreferencesMessage, target.senderId, null, fulfillmentMode, now),
202
- ]);
336
+ ], ProtoMap.buildParty(target.user, partyAddress));
203
337
  }
204
338
  // #endregion
205
339
  // Throws if a profile is already held, otherwise stores the credentials every later call signs
@@ -211,32 +345,40 @@ class Model {
211
345
  this._subjectId = subjectId;
212
346
  this._authenticationToken = authenticationToken;
213
347
  }
214
- // #region Pushing
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.
221
- async pushBusinessMessages(target, messages) {
348
+ // #region Sending
349
+ // Sends the messages, and the party of the customer if there is one, in one request, together with
350
+ // the update number this business container's reads have reached, then stores the response.
351
+ // The server answers with a sync out of the customer's messages, made after it writes: the stored
352
+ // messages that are newer than that update number, in the order of their update numbers, with the
353
+ // ones that were sent among them, and not in the order they were sent in. It is stored as a page of
354
+ // a read is, and moves the message cursor the same way. A message that was not stored (an order
355
+ // whose id exists, a cart dated no later than the stored one) is in it only if the stored message
356
+ // is newer than the cursor, and then it is the stored message. The cache has every message at or
357
+ // below the cursor, so what a caller needs afterwards is read from the cache by id.
358
+ // A full page may have more behind it, which is read as a read reads. A rejected request throws
359
+ // from the Service call itself, as for any bad status, so reaching the write below means the
360
+ // server accepted it.
361
+ async sendBusinessMessages(target, messages, party = null) {
222
362
  const cache = Model.getCache();
223
363
  const messageUpdateNumber = await cache.readMessageUpdateNumber(target.containerName);
224
364
  const authTokens = await this.requireBusinessAuthTokens(target.containerName, target.businessLink);
225
- const response = await Model.getService().businessSyncIn(ProtoMap.buildBusinessSyncInRequest(messageUpdateNumber, messages), ProtoMap.buildAuthTokens(authTokens));
365
+ const request = ProtoMap.buildBusinessSyncInRequest(messageUpdateNumber, messages, party);
366
+ const response = await Model.getService().businessSyncIn(request, ProtoMap.buildAuthTokens(authTokens));
226
367
  await this.storeIssuedTokens(ProtoMap.getResponseAuthTokens(response), target.containerName);
227
- // Stored as the response carried them: the server answers with the messages themselves, so
228
- // nothing is stamped
229
- const changeCount = await cache.writeMessages(target.containerName, ProtoMap.getSyncInResponseMessages(response));
368
+ let changeCount = await cache.writeMessages(target.containerName, ProtoMap.getSyncInResponseMessages(response));
369
+ // Read on from the cursor the page moved, with the tokens the response may have renewed
370
+ if (ProtoMap.isSyncInResponseFull(request, response))
371
+ changeCount += await this.readBusinessPages(target.containerName, target.businessLink.businessId, await cache.readBusinessCursors(target.containerName), ProtoMap.getBusinessResponseAttachmentBaseUrlExpiry(await cache.readBusiness(target.containerName)), ProtoMap.buildAuthTokens(await this.requireBusinessAuthTokens(target.containerName, target.businessLink)));
230
372
  // What the store took decides whether anything is published - see the Publishing region
231
373
  if (changeCount !== 0)
232
374
  this.publishBusiness(await cache.readBusiness(target.containerName));
233
375
  }
234
- // Amends the cart containerName holds and writes the result to the cache. Nothing is pushed.
376
+ // Amends the cart containerName stores and writes the result to the cache. Nothing is sent.
235
377
  // The two callers differ in container, id and sender: see updateCartLocalOnly.
236
378
  async storeCartInCache(containerName, messageId, senderId, shoppingList) {
237
379
  const cache = Model.getCache();
238
380
  const changeCount = await cache.writePartyPreferences(containerName, ProtoMap.buildPartyPreferencesMessage(messageId, await cache.readPartyPreferences(containerName), senderId, shoppingList, null, BigInt(Date.now())));
239
- // Published on what the store took, as for every read and push
381
+ // Published on what the store took, as for every read and sync in
240
382
  if (changeCount !== 0)
241
383
  this.publishBusiness(await cache.readBusiness(containerName));
242
384
  }
@@ -268,7 +410,7 @@ class Model {
268
410
  businessLink: businessLink,
269
411
  containerName: Model.getBusinessContainerName(businessLink),
270
412
  senderId: businessLink.partyId,
271
- addresses: user.addresses,
413
+ user: user,
272
414
  countryCode: business.countryCode,
273
415
  };
274
416
  }
@@ -292,7 +434,8 @@ class Model {
292
434
  // #region The four calls of Service, each as this model makes it
293
435
  //
294
436
  // 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.
437
+ // issued, and publishes what the cache then holds. A read is paged, and the answer to a sign up or
438
+ // an update is the first page of a profile read.
296
439
  // Reads in the business being browsed: an anonymous visitor under its businessId, a signed-in
297
440
  // customer under their own container (see getBusinessContainerName), joining as a customer first
298
441
  // if not linked (see requireBusinessLink).
@@ -515,9 +658,26 @@ class Model {
515
658
  if (link !== null)
516
659
  return link;
517
660
  const joinBusinessId = await this.readJoinBusinessId(businessId);
518
- await this.profileSyncIn(ProtoMap.buildProfileSyncOutRequest(await Model.getCache().readProfileCursors(this.requireProfileContainerName()), joinBusinessId), await this.requireProfileAuthTokens());
519
- // Tried once: a join that leaves no link is the server contradicting itself, and retrying
520
- // would not fix it.
661
+ const containerName = this.requireProfileContainerName();
662
+ const cache = Model.getCache();
663
+ try {
664
+ await this.profileSyncIn(ProtoMap.buildProfileSyncOutRequest(await cache.readProfileCursors(containerName), joinBusinessId), await this.requireProfileAuthTokens());
665
+ }
666
+ catch (error) {
667
+ if (!(error instanceof JoinNotAnsweredException))
668
+ throw error;
669
+ // The answer lacks the link and the business because the cursors are past them. Either
670
+ // another tab stored them first, and profileSyncIn has published them from the store, or the
671
+ // container lost them (it was cleared while a read was under way). In the second case the
672
+ // cursors are deleted and the profile is read again from the start, once.
673
+ const published = this._profile.value;
674
+ if (published === null || Model.findBusinessLink(published, businessId) === null) {
675
+ await cache.resetProfileCursors(containerName);
676
+ await this.profileSyncIn(ProtoMap.buildProfileSyncOutRequest(await cache.readProfileCursors(containerName), joinBusinessId), await this.requireProfileAuthTokens());
677
+ }
678
+ }
679
+ // A join that still leaves no link after the profile was read again from the start is the
680
+ // server contradicting itself, and retrying would not fix it.
521
681
  const joined = this._profile.value;
522
682
  const joinedLink = joined === null ? null : Model.findBusinessLink(joined, businessId);
523
683
  if (joinedLink === null)
@@ -529,8 +689,8 @@ class Model {
529
689
  // A business published from the cache by a read that has not ended is not known to be open or to
530
690
  // be the one the businessId names, so a customer is not joined to it.
531
691
  async readJoinBusinessId(businessId) {
532
- const alreadyHeld = this._business.value;
533
- if (alreadyHeld !== null && this._isPublishedBusinessVetted)
692
+ const alreadyHeld = this.getVettedBusiness();
693
+ if (alreadyHeld !== null)
534
694
  return alreadyHeld.id;
535
695
  await this.syncAnonymousBusiness(businessId);
536
696
  // A missing business is a NotFoundException from the read above. Reaching here with none
@@ -540,6 +700,12 @@ class Model {
540
700
  throw new InternalServerException(`The read of business ${businessId} answered with no Business`);
541
701
  return business.id;
542
702
  }
703
+ // The business being browsed, if it is published and has been read to the end and found open. A
704
+ // business published from the cache by a read that has not ended is not known to be either, so
705
+ // this is null for it.
706
+ getVettedBusiness() {
707
+ return this._isPublishedBusinessVetted ? this._business.value : null;
708
+ }
543
709
  static findBusinessEntity(profile, businessId) {
544
710
  const wanted = canonicalizeBusinessId(businessId);
545
711
  for (const business of profile.businesses)
@@ -600,9 +766,47 @@ class Model {
600
766
  async profileSyncIn(firstRequest, firstAuthTokens) {
601
767
  const containerName = this.getProfileContainerName();
602
768
  if (containerName === null)
769
+ return;
770
+ await this.publishProfileFromStore(containerName);
771
+ await this.readProfilePages(containerName, firstRequest, firstAuthTokens, () => Model.getService().profileSyncOut(firstRequest, firstAuthTokens));
772
+ }
773
+ // Reads what changed in the profile since the cursors the container holds, as a sign in does, but
774
+ // without publishing the stored profile first.
775
+ async syncProfile(containerName) {
776
+ const request = ProtoMap.buildProfileSyncOutRequest(await Model.getCache().readProfileCursors(containerName));
777
+ const authTokens = await this.requireProfileAuthTokens();
778
+ await this.readProfilePages(containerName, request, authTokens, () => Model.getService().profileSyncOut(request, authTokens));
779
+ }
780
+ // The user the profile container stores, or null when it holds none
781
+ async readStoredUser(containerName) {
782
+ const stored = await Model.getCache().readProfile(containerName);
783
+ if (stored === null || this._subjectId === null)
603
784
  return null;
604
- const cached = await Model.getCache().readProfile(containerName);
605
- this.publishProfile(cached);
785
+ return ProtoMap.buildProfile(this._subjectId, stored)?.user ?? null;
786
+ }
787
+ // Sends user over the one the server has, and stores and publishes the answer, which is the first
788
+ // page of a profile read.
789
+ async sendUserUpdate(containerName, user) {
790
+ const request = ProtoMap.buildUserRequest(ProtoMap.buildUpdateUser(user), await Model.getCache().readProfileCursors(containerName));
791
+ const authTokens = await this.requireProfileAuthTokens();
792
+ await this.readProfilePages(containerName, ProtoMap.getUserRequestSyncOutRequest(request), authTokens, () => Model.getService().updateUser(request, authTokens));
793
+ }
794
+ // The sign-up call. Its answer is the first page of a profile read, which readProfilePages
795
+ // continues if the page is full. The id saved for the sign up is deleted once that page is stored.
796
+ async createProfileOnServer(request, authTokens) {
797
+ const containerName = this.requireProfileContainerName();
798
+ await this.readProfilePages(containerName, ProtoMap.getUserRequestSyncOutRequest(request), authTokens, () => Model.getService().createUser(request, authTokens), () => Model.getCache().deleteUserId(containerName));
799
+ }
800
+ // Caches and publishes a profile read of the pages that firstRequest starts. sendFirstPage makes
801
+ // the first call: profileSyncOut for a read, createUser or updateUser for a write, which answers
802
+ // with the first page of the read that its profileSyncOutRequest describes. Later pages are read
803
+ // with profileSyncOut, because a write cannot be sent again.
804
+ // Each page is cached, moving the cursors, and the tokens it reissues are stored. afterFirstPage
805
+ // runs once the first page and its tokens are stored. The profile is published once, and only if
806
+ // the store took something. The join is checked last, so everything that arrived is cached and
807
+ // published before it can throw: the pages are valid even when the join is not.
808
+ async readProfilePages(containerName, firstRequest, firstAuthTokens, sendFirstPage, afterFirstPage = null) {
809
+ const cache = Model.getCache();
606
810
  let request = firstRequest;
607
811
  let authTokens = firstAuthTokens;
608
812
  let changeCount = 0;
@@ -611,9 +815,14 @@ class Model {
611
815
  const joinBusinessId = firstRequest.joinBusinessId;
612
816
  let hasJoinedLink = false;
613
817
  let hasJoinedBusiness = false;
818
+ let page = await sendFirstPage();
614
819
  for (;;) {
615
- const page = await Model.getService().profileSyncOut(request, authTokens);
616
- changeCount += await this.cacheProfileResponse(containerName, page, true);
820
+ changeCount += await cache.writeProfile(containerName, page);
821
+ await this.storeIssuedTokens(ProtoMap.getResponseAuthTokens(page), containerName);
822
+ if (afterFirstPage !== null) {
823
+ await afterFirstPage();
824
+ afterFirstPage = null;
825
+ }
617
826
  if (ProtoMap.hasJoinedBusinessLink(joinBusinessId, page))
618
827
  hasJoinedLink = true;
619
828
  if (ProtoMap.hasJoinedBusiness(joinBusinessId, page))
@@ -625,36 +834,20 @@ class Model {
625
834
  // Each call after the first is signed with what the previous page reissued, since a token can
626
835
  // expire part way through a long read
627
836
  authTokens = ProtoMap.refreshAuthTokens(authTokens, ProtoMap.getResponseAuthTokens(page));
837
+ page = await Model.getService().profileSyncOut(request, authTokens);
628
838
  }
629
- // As above: nothing the store took, nothing published
630
- let response = cached;
631
- if (changeCount !== 0) {
632
- response = await Model.getCache().readProfile(containerName);
633
- this.publishProfile(response);
634
- }
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.
839
+ this._profileReadAt = Date.now();
840
+ if (changeCount !== 0)
841
+ await this.publishProfileFromStore(containerName);
842
+ // After a sign up the User exists whatever became of the join, so what came back is kept and
843
+ // published before this throws
637
844
  Model.requireJoinedBusiness(joinBusinessId, hasJoinedLink, hasJoinedBusiness);
638
- return response;
639
- }
640
- // The sign-up call. Not paged: resending it would create a second user, and a new caller has at
641
- // most their User, one BusinessLink and the Business it names. It can join a business in the same
642
- // call, with the same join requirements as a profile read, in one response.
643
- async createProfileOnServer(request, authTokens) {
644
- const response = await this.writeProfileResponse(await Model.getService().createUser(request, authTokens));
645
- const joinBusinessId = request.joinBusinessId;
646
- // Raised after the response is cached and published, as on a profile read. It matters more
647
- // here: the User exists regardless of the join, so discarding the response and signing up
648
- // again would create a second one
649
- Model.requireJoinedBusiness(joinBusinessId, ProtoMap.hasJoinedBusinessLink(joinBusinessId, response), ProtoMap.hasJoinedBusiness(joinBusinessId, response));
650
- return response;
651
845
  }
652
846
  // #endregion
653
847
  // #region Driving one call
654
848
  // Throws when a requested join is not answered with both the BusinessLink (the membership, whose
655
849
  // token signs calls) and the Business it names (whose businessId everything else is built from).
656
- // Without both, the caller is a customer of something it cannot reach. Shared by the two calls
657
- // that can carry a joinBusinessId.
850
+ // Without both, the caller is a customer of something it cannot reach.
658
851
  //
659
852
  // Throws a ServiceException, since the fault is the server's and not the caller's: the server
660
853
  // answered 200 and left out what was asked for. The 500 status means a retry is worthwhile.
@@ -671,35 +864,7 @@ class Model {
671
864
  : hasBusinessLink
672
865
  ? "Business"
673
866
  : "BusinessLink";
674
- throw new InternalServerException(`A join of business ${joinBusinessId} was answered with no ${missing}`);
675
- }
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.
681
- // Only a page of a read moves the container's cursors. A sign up and an update are changes this
682
- // client made and the next read brings them back, so they are stored without moving any.
683
- async cacheProfileResponse(containerName, response, isPageOfRead) {
684
- const cache = Model.getCache();
685
- const changeCount = isPageOfRead
686
- ? await cache.writeProfile(containerName, response)
687
- : await cache.storeProfile(containerName, response);
688
- await this.storeIssuedTokens(ProtoMap.getResponseAuthTokens(response), containerName);
689
- return changeCount;
690
- }
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.
696
- async writeProfileResponse(response) {
697
- const containerName = this.getProfileContainerName();
698
- if (containerName === null)
699
- return response;
700
- if ((await this.cacheProfileResponse(containerName, response, false)) !== 0)
701
- this.publishProfile(await Model.getCache().readProfile(containerName));
702
- return response;
867
+ throw new JoinNotAnsweredException(`A join of business ${joinBusinessId} was answered with no ${missing}`);
703
868
  }
704
869
  // Stores the tokens a call received in the container the call was about: a profile call's tokens
705
870
  // in the subject's profile container, a business call's in that business's container. The caller
@@ -765,12 +930,16 @@ class Model {
765
930
  // over every page), whatever the response carried. The store keeps a blob only when its eTag is
766
931
  // later, so it can reject a response in full (a cursor that moved without its eTag resends an
767
932
  // entity already cached). Publishing then would notify every observer of an identical value.
768
- // All four calls follow this, so an update publishes nothing (see writeProfileResponse).
933
+ // A sign up and an update follow this too, because their answer is the first page of a profile read
934
+ // (see readProfilePages).
769
935
  //
770
936
  // A business with online shopping off is not published, even from the cache, or a customer could
771
937
  // be joined to it. A read that finds the published business is no longer open, or that the server
772
938
  // rejects, publishes null in its place. A business published from the cache counts as open only
773
939
  // once the read has ended (see _isPublishedBusinessVetted, which readJoinBusinessId checks).
940
+ static getProfileCursorsKey(cursors) {
941
+ return SyncKeys.PROFILE_SYNC_IN.map((key) => cursors.getUpdateNumber(key)).join(",");
942
+ }
774
943
  publishBusiness(response) {
775
944
  this.publish(Model.buildPublishable(response));
776
945
  }
@@ -789,6 +958,23 @@ class Model {
789
958
  ? null
790
959
  : ProtoMap.buildProfile(this._subjectId, response);
791
960
  }
961
+ // Publishes what the profile container stores, and notes the cursors it was read at, so that
962
+ // refreshProfileIfStale can tell whether the store has moved since.
963
+ async publishProfileFromStore(containerName) {
964
+ const cache = Model.getCache();
965
+ const cursorsKey = Model.getProfileCursorsKey(await cache.readProfileCursors(containerName));
966
+ this.publishProfile(await cache.readProfile(containerName));
967
+ this._publishedProfileCursors = cursorsKey;
968
+ }
969
+ // The read behind refreshProfileIfStale
970
+ async refreshProfile() {
971
+ const containerName = this.requireProfileContainerName();
972
+ await this.syncProfile(containerName);
973
+ const cursors = await Model.getCache().readProfileCursors(containerName);
974
+ if (Model.getProfileCursorsKey(cursors) !== this._publishedProfileCursors)
975
+ await this.publishProfileFromStore(containerName);
976
+ return true;
977
+ }
792
978
  // #endregion
793
979
  static getService() {
794
980
  const service = Model._service;
@@ -805,5 +991,10 @@ class Model {
805
991
  }
806
992
  Model._service = null;
807
993
  Model._cache = null;
994
+ Model._lock = new InMemoryLock();
808
995
  Model._instance = null;
809
996
  export default Model;
997
+ // A join the server answered with no link or business. It is a 500 like any other fault of the
998
+ // server. requireBusinessLink catches it to read the profile again from the start.
999
+ class JoinNotAnsweredException extends InternalServerException {
1000
+ }