@peabody-soft/mbs-model-ts 1.3.0 → 1.5.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 (65) hide show
  1. package/dist/CartEditor.d.ts +10 -5
  2. package/dist/CartEditor.js +232 -125
  3. package/dist/Catalog.js +26 -18
  4. package/dist/Enums.d.ts +1 -1
  5. package/dist/Enums.js +2 -1
  6. package/dist/GeneralSettings.d.ts +4 -2
  7. package/dist/GeneralSettings.js +18 -7
  8. package/dist/Ids.d.ts +2 -0
  9. package/dist/Ids.js +10 -0
  10. package/dist/Message.js +3 -2
  11. package/dist/Model.d.ts +5 -0
  12. package/dist/Model.js +263 -50
  13. package/dist/Package.js +2 -2
  14. package/dist/Price.js +6 -2
  15. package/dist/Product.d.ts +5 -3
  16. package/dist/Product.js +30 -18
  17. package/dist/Profile.d.ts +1 -0
  18. package/dist/Profile.js +91 -4
  19. package/dist/ProtoMap.d.ts +15 -8
  20. package/dist/ProtoMap.js +267 -140
  21. package/dist/ShoppingList.d.ts +30 -1
  22. package/dist/ShoppingList.js +345 -35
  23. package/dist/SyncCache.d.ts +9 -0
  24. package/dist/SyncCache.js +93 -30
  25. package/dist/SyncState.js +1 -1
  26. package/dist/Tax.d.ts +12 -4
  27. package/dist/Tax.js +33 -4
  28. package/dist/TaxHelper.d.ts +12 -0
  29. package/dist/TaxHelper.js +93 -0
  30. package/dist/TaxType.d.ts +7 -0
  31. package/dist/TaxType.js +32 -0
  32. package/dist/WorkingDays.d.ts +3 -2
  33. package/dist/WorkingDays.js +16 -12
  34. package/dist/generated/AttachmentType.d.ts +19 -1
  35. package/dist/generated/AttachmentType.js +18 -0
  36. package/dist/generated/AuthTokens.d.ts +3 -1
  37. package/dist/generated/BusinessLink.d.ts +4 -1
  38. package/dist/generated/BusinessSyncInRequest.d.ts +11 -3
  39. package/dist/generated/BusinessSyncInResponse.d.ts +5 -4
  40. package/dist/generated/BusinessSyncOutRequest.d.ts +37 -10
  41. package/dist/generated/BusinessSyncOutRequest.js +42 -29
  42. package/dist/generated/BusinessSyncOutResponse.d.ts +14 -1
  43. package/dist/generated/Configuration.d.ts +2 -1
  44. package/dist/generated/CreateUserRequest.d.ts +8 -2
  45. package/dist/generated/Entity.d.ts +7 -3
  46. package/dist/generated/GeneralSettings.d.ts +14 -4
  47. package/dist/generated/GeneralSettings.js +9 -1
  48. package/dist/generated/Message.d.ts +4 -2
  49. package/dist/generated/Product.d.ts +10 -1
  50. package/dist/generated/Product.js +9 -1
  51. package/dist/generated/ProfileSyncOutRequest.d.ts +17 -1
  52. package/dist/generated/ProfileSyncOutResponse.d.ts +8 -3
  53. package/dist/generated/SalesOrderPayload.d.ts +7 -2
  54. package/dist/generated/ShoppingList.d.ts +6 -3
  55. package/dist/generated/ShoppingListItem.d.ts +21 -6
  56. package/dist/generated/Tax.d.ts +4 -4
  57. package/dist/generated/Tax.js +7 -8
  58. package/dist/generated/WorkingDays.d.ts +9 -1
  59. package/dist/generated/WorkingDays.js +12 -4
  60. package/dist/generated/WorkingHoursRange.d.ts +2 -2
  61. package/dist/index.d.ts +2 -2
  62. package/dist/index.js +4 -3
  63. package/package.json +55 -55
  64. package/dist/generated/TaxType.d.ts +0 -25
  65. 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";
14
+ import * as SyncKeys from "./SyncKeys";
15
+ import SyncState from "./SyncState";
13
16
  // The root of the object model, and the only thing a UI has to be given. Call initialize once,
14
17
  // then always fetch the current instance via getInstance.
18
+ //
19
+ // THERE IS NO LOCKING, and nothing here makes one call wait for another: a caller makes one call at a time, and awaits it. A
20
+ // setBusinessId that is still reading when signIn reads the business too can publish the business of the anonymous container
21
+ // over the one the customer's link names, with no cart and no orders, and nothing republishes them; two reads at once
22
+ // interleave their pages and their writes. And a store has one tab using it at a time: two tabs on one store each hold a Model
23
+ // of their own, and one tab's deleteBusiness and read again can interleave with the pages another is writing.
24
+ // A queue inside Model would settle the first and not the second, and would not be a small one: signIn calls signOut and
25
+ // businessSyncIn calls updateCart, so each public method would need a twin that does not queue, for these to call. A caller
26
+ // that calls from several places - a UI - queues its calls itself.
15
27
  class Model {
16
28
  constructor() {
29
+ // Whether the business that is published, if one is, is one that a read has been through to the end and found to be the
30
+ // one that _businessId names and to be open. The cache-first publish of a read is of what an earlier read left, and is
31
+ // neither of those until the read ends - a read that does not end (the network failing, or a refusal) leaves it published
32
+ // and unvetted. See readJoinBusinessId, which does not take such a business to join.
33
+ this._isPublishedBusinessVetted = false;
17
34
  this._business = new LiveData(null);
18
35
  this._profile = new LiveData(null);
19
36
  this._authenticationMethod = null;
@@ -50,11 +67,12 @@ class Model {
50
67
  // publishes a cached profile before it ever reaches the network, so without this, a retry after
51
68
  // such a failure could find _profile already set and trip that same guard despite never having
52
69
  // actually signed in.
70
+ // Throws NotFoundException if the business being browsed cannot be opened - see businessSyncIn.
53
71
  async signIn(authenticationMethod, subjectId, authenticationToken) {
54
72
  this.beginSignIn(authenticationMethod, subjectId, authenticationToken);
55
73
  try {
56
74
  const authTokens = await this.requireProfileAuthTokens();
57
- await this.profileSyncIn(ProtoMap.buildProfileSyncOutRequestFromCache(await Model.getCache().readProfile(this.requireProfileContainerName())), authTokens);
75
+ await this.profileSyncIn(ProtoMap.buildProfileSyncOutRequest(await Model.getCache().readProfileCursors(this.requireProfileContainerName())), authTokens);
58
76
  if (this._businessId !== null)
59
77
  await this.businessSyncIn();
60
78
  }
@@ -66,6 +84,9 @@ class Model {
66
84
  // Signs up with the given credentials, creating userToCreate as this subject's profile - for a
67
85
  // subject the server has no profile for yet; signIn is what an already known subject uses instead.
68
86
  // Also reads in the business being browsed, if any, now signed in as this customer.
87
+ // Throws NotFoundException if the business being browsed cannot be opened - see businessSyncIn. That is
88
+ // after the User has been created, and nothing undoes it: the customer stays signed up, and signed in, with
89
+ // a profile held, whatever the read of the business answered.
69
90
  async createUser(authenticationMethod, subjectId, authenticationToken, userToCreate) {
70
91
  this.beginSignIn(authenticationMethod, subjectId, authenticationToken);
71
92
  const authTokens = await this.requireProfileAuthTokens();
@@ -94,9 +115,20 @@ class Model {
94
115
  // This is also how a caller re-reads the shop it is already on, and nulling unconditionally
95
116
  // would blank the UI for the length of the read and undo syncBusinessContainer's own
96
117
  // cache-first publish two lines later.
118
+ // Throws NotFoundException if the business cannot be opened - see businessSyncIn. Whatever was cached
119
+ // is published before the network is asked, unless it is a business that has not turned online shopping
120
+ // on - see publishBusiness - and is taken back, by publishing none, if the read then finds that the business
121
+ // cannot be opened.
122
+ // The businessId is held and used as the server finds a business by it, in lower case and without the spaces round it
123
+ // (see canonicalizeBusinessId): a url is typed in any case, and /WARM-LOAF and /warm-loaf are one storefront, so, they are
124
+ // one container, one download, and not a different business to be shown as none first. What is sent to the server is that
125
+ // form too, which it makes of whatever it is sent, so nothing is lost by it.
97
126
  async setBusinessId(businessId) {
98
- const isDifferentBusiness = this._businessId !== businessId;
99
- this._businessId = businessId;
127
+ // An id of nothing but spaces has no canonical form - Java refuses it - and is held as it was written: it is the server that
128
+ // says there is no such business, with the NotFoundException that a UI shows for one, and not this that calls it a mistake
129
+ const canonicalBusinessId = canonicalizeBusinessId(businessId) || businessId;
130
+ const isDifferentBusiness = this._businessId !== canonicalBusinessId;
131
+ this._businessId = canonicalBusinessId;
100
132
  if (isDifferentBusiness)
101
133
  this.publishBusiness(null);
102
134
  await this.businessSyncIn();
@@ -118,8 +150,8 @@ class Model {
118
150
  // with updateCartLocalOnly and call this on its own schedule (debounced, on blur, a timer).
119
151
  // Safe to call often: hasShoppingListChanged checks against the store's own copy rather than
120
152
  // 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.
153
+ // request. Not a substitute for reading the business, though - a push answers with the messages it
154
+ // pushed and nothing else, and a call that does not go out brings no change of anybody else's back.
123
155
  async updateCart(shoppingList) {
124
156
  if (this._profile.value === null)
125
157
  return this.updateCartLocalOnly(shoppingList);
@@ -136,13 +168,13 @@ class Model {
136
168
  // Nothing is lost by leaving it unsent: it survives a closed tab, the next updateCart sends it,
137
169
  // and createOrder reads straight from the store. All a caller gives up is that the server, and so
138
170
  // 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.
171
+ // It also cannot be undone by a stale sync in bringing an older server copy back: the store
172
+ // versions an amendment at the update number of the copy it amends, where the later copy wins, and
173
+ // buildPartyPreferencesMessage always dates an amendment past what it replaces.
142
174
  async updateCartLocalOnly(shoppingList) {
143
175
  // No membership to derive a message id from while signed out, so one is drawn instead - once,
144
176
  // 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;
177
+ // so a second edit cannot leave two behind. The sender is ENTITY_ID_NULL for want of a party;
146
178
  // the first push after signing in names the real one.
147
179
  if (this._profile.value === null)
148
180
  return this.storeCartInCache(ContainerNames.forBusiness(this.requireBusinessBeingBrowsed()), Ids.getNewId(proto_Constants.MESSAGE_ENTITY_TYPE_ID), BigInt(proto_Constants.ENTITY_ID_NULL), shoppingList);
@@ -169,7 +201,7 @@ class Model {
169
201
  const partyPreferencesMessage = await Model.getCache().readPartyPreferences(target.containerName);
170
202
  const shoppingList = partyPreferencesMessage === null
171
203
  ? ShoppingList.EMPTY
172
- : ProtoMap.buildShoppingList(partyPreferencesMessage);
204
+ : ProtoMap.buildShoppingList(partyPreferencesMessage, target.countryCode);
173
205
  if (shoppingList.existingItems.length === 0)
174
206
  throw new IllegalOperationException("The cart holds nothing, so there is no order to place");
175
207
  Model.requireDeliveryAddress(target.addresses, fulfillmentMode, deliveryAddressId, deliveryAddress);
@@ -197,9 +229,10 @@ class Model {
197
229
  }
198
230
  // #region Pushing
199
231
  // 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
232
+ // request, with how far this business container's reads have got, and the server answers with
233
+ // these messages under their assigned update numbers, and no more: a push is not a read. They are
234
+ // stored, and move no cursor, so, the next read is sent them again, and anything somebody else
235
+ // changed in the meantime comes with them. A server that refuses the push fails the call
203
236
  // itself, same as any other Service call on a bad status, so there is nothing left here to check:
204
237
  // reaching the write below already means the server took it.
205
238
  async pushBusinessMessages(target, messages) {
@@ -242,6 +275,7 @@ class Model {
242
275
  // for want of a link token to sign with.
243
276
  requireCustomerOfBusiness() {
244
277
  const businessId = this.requireBusinessBeingBrowsed();
278
+ const business = this._business.value;
245
279
  const profile = this._profile.value;
246
280
  const user = profile?.user ?? null;
247
281
  if (profile === null || user === null)
@@ -252,8 +286,9 @@ class Model {
252
286
  return {
253
287
  businessLink: businessLink,
254
288
  containerName: Model.getBusinessContainerName(businessLink),
255
- senderId: user.id,
289
+ senderId: businessLink.partyId,
256
290
  addresses: user.addresses,
291
+ countryCode: business.countryCode,
257
292
  };
258
293
  }
259
294
  // Where an order is delivered: an id into this customer's own address list (the same list
@@ -281,7 +316,18 @@ class Model {
281
316
  // businessId. A signed-in customer reads it under their own container instead - see
282
317
  // getBusinessContainerName - joining as a customer first if not already linked - see
283
318
  // requireBusinessLink.
284
- async businessSyncIn() {
319
+ // Throws NotFoundException, from either, when the business is not one a storefront can open,
320
+ // whether or not this customer has joined it, and that is the answer to any read of it, the first
321
+ // or a later one: a business can stop being open after it was cached. The server says so when the
322
+ // business does not exist, has been deleted, or does not allow customers to join it. Whether it
323
+ // has turned online shopping on the server does not say: this decides, once everything the server
324
+ // sent has been read, from the settings that it then holds. What was cached is left as it is,
325
+ // except that a business that is no longer the one that the businessId names is given up for the
326
+ // one that is, if there is one: see syncBusinessContainer.
327
+ // isStartedOver is for the one time that this calls itself: the link that a customer's profile found by the businessId is of
328
+ // a business that no longer has it, which syncBusinessContainer finds out once it has read the business. The profile is read again,
329
+ // as it is what said so, and this starts again from what it now says - which may be a business that the customer has to join, or none.
330
+ async businessSyncIn(isStartedOver = false) {
285
331
  const businessId = this._businessId;
286
332
  if (businessId === null || businessId.length === 0)
287
333
  throw new IllegalOperationException("No business is being browsed, so there is nothing to read");
@@ -295,34 +341,62 @@ class Model {
295
341
  const containerName = Model.getBusinessContainerName(link);
296
342
  const anonymousName = ContainerNames.forBusiness(businessId);
297
343
  // 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.
344
+ // under now - the same rule the server's own pages take: the later of two copies per entity by
345
+ // update number is kept, so a tombstone wins over what it deleted. Unconditional, since a
346
+ // returning customer's own container can easily be the staler of the two. It moves no cursor:
347
+ // that container has not been read for any of this, and is read from where its own reads got to.
301
348
  // Not the cart, which is settled below: the two containers hold it under different ids, so a
302
349
  // 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);
350
+ // Nor one that holds another business than the link is of: what was read under the businessId while nobody was
351
+ // signed in is of the business that the businessId named then, which is not the one the link is of once the
352
+ // businessId has been given up and taken by another. Folded in, the one container would hold two businesses, and
353
+ // which of them it answers with would be decided by nothing - and a cart is the cart of the business it was built in.
354
+ // One that holds no business at all has nothing to contradict the link with, and is folded in: a cart can be all that a
355
+ // visit left of it.
356
+ const anonymousContainer = await cache.readBusiness(anonymousName);
357
+ const anonymousEntityId = ProtoMap.getBusinessResponseBusinessEntityId(anonymousContainer);
358
+ const anonymous = anonymousEntityId !== null && anonymousEntityId !== link.businessId ? null : anonymousContainer;
304
359
  // Out of the messages just read, rather than a second read of the same container -
305
360
  // partyPreferencesMessageFromMessages picks the same one readPartyPreferences(anonymousName)
306
361
  // would.
307
362
  const anonymousPartyPreferences = anonymous === null ? null : ProtoMap.partyPreferencesMessageFromMessages(anonymous.messages);
308
363
  if (anonymous !== null) {
309
364
  anonymous.messages = anonymous.messages.filter((message) => !ProtoMap.isPartyPreferencesMessage(message));
310
- await cache.writeBusiness(containerName, anonymous);
365
+ await cache.storeBusiness(containerName, anonymous);
311
366
  }
312
367
  const authTokens = await this.requireBusinessAuthTokens(containerName, link);
313
- await this.syncBusinessContainer(containerName, businessId, ProtoMap.buildAuthTokens(authTokens));
368
+ // The link names the business by its entity id, which the link token that signs this call has to be of
369
+ const isBusinessNamed = await this.syncBusinessContainer(containerName, businessId, link.businessId, ProtoMap.buildAuthTokens(authTokens));
370
+ // The business that the link is of has another businessId now, and the profile that found the link by this one did not know
371
+ // it. Its container has been given up, and what is asked next of the server is what the profile says: read again, so that
372
+ // the link is found by this businessId or not, and the businessId that names another business is joined. Once - a profile
373
+ // that is read again and still finds the same link for the same businessId is the server contradicting itself
374
+ if (!isBusinessNamed) {
375
+ if (isStartedOver)
376
+ throw new InternalServerException(`The business that the link for the businessId ${businessId} is of does not have it, though the profile was read again`);
377
+ await this.profileSyncIn(ProtoMap.buildProfileSyncOutRequest(await cache.readProfileCursors(this.requireProfileContainerName())), await this.requireProfileAuthTokens());
378
+ await this.businessSyncIn(true);
379
+ return;
380
+ }
314
381
  // THE CART A VISITOR BUILT BEFORE SIGNING IN. Signing in changes which container is being
315
382
  // browsed, so without this it is simply left behind - and a customer has to build it again
316
383
  // having just proved who they are.
317
384
  if (anonymousPartyPreferences === null)
318
385
  return;
386
+ // The visitor's cart holds taxes as raw codes, which mean something only within the country of
387
+ // the business it was built in. The anonymous container is where that was read from, and a cart
388
+ // is only ever stored once its business has been (see updateCartLocalOnly); failing that, the
389
+ // business being browsed is the same one.
390
+ const anonymousCountryCode = ProtoMap.getBusinessResponseCountryCode(anonymous) ?? this._business.value?.countryCode;
391
+ if (anonymousCountryCode === undefined)
392
+ throw new IllegalOperationException("The business the visitor's cart was built in is not known, so its taxes cannot be read");
319
393
  // Read after the sync, so this is weighed against what the server just settled rather than a
320
394
  // local copy about to be replaced. Last written wins, by updatedAt.
321
395
  const userPartyPreferences = await cache.readPartyPreferences(containerName);
322
396
  if (userPartyPreferences === null ||
323
397
  ProtoMap.getMessageUpdatedAt(anonymousPartyPreferences) >
324
398
  ProtoMap.getMessageUpdatedAt(userPartyPreferences))
325
- await this.updateCart(ProtoMap.buildShoppingList(anonymousPartyPreferences));
399
+ await this.updateCart(ProtoMap.buildShoppingList(anonymousPartyPreferences, anonymousCountryCode));
326
400
  // Dropped either way, carried over or beaten by the customer's own: the anonymous container is
327
401
  // what the next visitor on this device reads, and a settled cart has no business staying there.
328
402
  await cache.deletePartyPreferences(anonymousName);
@@ -330,19 +404,130 @@ class Model {
330
404
  // The plain businessId-named container: an anonymous read, before signing in or joining, or to
331
405
  // learn a business's entity id while joining - see readJoinBusinessId.
332
406
  async syncAnonymousBusiness(businessId) {
333
- await this.syncBusinessContainer(ContainerNames.forBusiness(businessId), businessId, null);
407
+ await this.syncBusinessContainer(ContainerNames.forBusiness(businessId), businessId, null, null);
334
408
  }
335
409
  // One business, cache first: what's already held publishes before the network is touched, so a
336
410
  // 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) {
411
+ // how far the reads of containerName have got, which it keeps, and not from zero, so a repeat call
412
+ // asks only what changed - see SyncCache.readBusinessCursors. Each page moves them as it is written.
413
+ // The business is named to the server by its entity id where this knows it, and by its businessId
414
+ // only where it does not: a caller that has a BusinessLink has its businessEntityId, and one that
415
+ // has read the business before has it in the container, from that read. Which of the two is
416
+ // decided once, by the first request, and every page after it is about the same business named the
417
+ // same way. The first read of a business by an anonymous visitor names it by its businessId, and its
418
+ // answer is what makes every later one not have to - except the read that is made again, below,
419
+ // when the business that the container holds has turned out not to be the one that the businessId names.
420
+ //
421
+ // What decides whether the business can be opened is what the container holds once every page has been
422
+ // read and written, and not what the pages carried, which is only what changed: a read that has nothing
423
+ // new to send says nothing of what has not changed. It is looked at after every read, whether the read
424
+ // brought anything or not, and in this order:
425
+ // 1. That the business the container holds is the one that businessId names. A business keeps its entity
426
+ // id when its businessId changes, and another can take the businessId it gave up, so a container that is
427
+ // read by the entity id it was left with may hold a business that is not the one that businessId names
428
+ // now. The business arrives changed when its businessId does, which is how this is seen, and it is seen
429
+ // after the next read however long that is, as it is the container that is looked at. If it is not that
430
+ // business, the container is given up and the business is read from the beginning by its businessId,
431
+ // once: that finds a business that has it, or none, which the server says with a NotFoundException.
432
+ // A read that a link names cannot be read again by the businessId, as a link token is refused with
433
+ // one, and the link names the one business that it is of. Its container is given up all the same, and
434
+ // it answers false: the link was found from what the profile said of the businessId, which is out of
435
+ // date, so the caller starts again from a profile that is read again - see businessSyncIn.
436
+ // Answers true when the container holds the business that businessId names.
437
+ // 2. That the business has turned online shopping on, which is the general settings it holds saying so.
438
+ // Settings it holds none of, because it has made none or has deleted them, do not. The server does not
439
+ // refuse a business for this, so this is where a business that has not is a NotFoundException, the
440
+ // same one as the server's for the businesses it does refuse, to every caller and every read.
441
+ // 3. That the business has something that a customer can buy: a product that is enabled, that it does not sell
442
+ // only offline, and that has a package with a price. A business with none, because it has made none, has
443
+ // turned them all off, or sells them all offline, is not a shop that there is anything to show or to order
444
+ // from, and is a NotFoundException as well. That is decided from the whole of what the container holds, as
445
+ // the first two are, and so it is decided when the last page has been written.
446
+ // firstAuthTokens are those of the link that businessEntityId is of, and none when that is none: they sign the reads that
447
+ // name the business by it, and a read by the businessId is signed with nothing, however it came to be made.
448
+ async syncBusinessContainer(containerName, businessId, businessEntityId, firstAuthTokens) {
340
449
  const cache = Model.getCache();
341
450
  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
451
+ const cachedBusiness = Model.buildPublishable(cached);
452
+ this.publish(cachedBusiness); // Unless it is a business that is not open - see publishBusiness
453
+ this._isPublishedBusinessVetted = false; // Until this has read it to the end
454
+ // The entity id that an earlier read of this container left in it: what a business is named by when no link names
455
+ // it. It is how the business was found then, and it may not be what the businessId finds now: a business can have
456
+ // its businessId given up and taken by another, or changed.
457
+ const cachedEntityId = ProtoMap.getBusinessResponseBusinessEntityId(cached);
458
+ // What the store accepted - the blobs it stored - which decides the publish at the end
459
+ let changeCount;
460
+ try {
461
+ changeCount = await this.readBusinessPages(containerName, businessEntityId ?? cachedEntityId ?? businessId, await cache.readBusinessCursors(containerName), ProtoMap.getBusinessResponseAttachmentBaseUrlExpiry(cached), firstAuthTokens);
462
+ }
463
+ catch (error) {
464
+ // The server says that the business cannot be opened: what was published from the cache, before it was asked, is
465
+ // not what is there. The container is left as it is - deciding that a business is gone is the caller's
466
+ if (error instanceof NotFoundException)
467
+ this.publishBusiness(null);
468
+ throw error;
469
+ }
470
+ let held = await cache.readBusiness(containerName);
471
+ // Whether what was published is no longer what the container holds
472
+ let isPublishedStale = changeCount !== 0;
473
+ // Named by the cached entity id, and not by a link, which names the business that it is of and no other
474
+ if (businessEntityId === null && cachedEntityId !== null && !Model.isBusinessNamed(held, businessId)) {
475
+ // What has been read of one business is no guide to what has not been read of another, so, none of the container
476
+ // is kept, nor how far it was read, and the business that the businessId names is read from the beginning. That is with
477
+ // no attachment url held either, which is why the expiry is zero and not that of the container's: the url it held was the
478
+ // old business's, which has been given up with it, and a server sent its expiry would take the new business to have it and
479
+ // send none, leaving every photo of it without one. Signed with nothing, a link token being refused with a businessId
480
+ await cache.deleteBusiness(containerName);
481
+ this.publishBusiness(null);
482
+ await this.readBusinessPages(containerName, businessId, new SyncState(SyncKeys.BUSINESS_SYNC_IN), 0n, null);
483
+ held = await cache.readBusiness(containerName);
484
+ isPublishedStale = true;
485
+ }
486
+ // What the businessId finds is the business that has it, so, one that does not is the server contradicting itself
487
+ if (businessEntityId === null && !Model.isBusinessNamed(held, businessId))
488
+ throw new InternalServerException(`The business that was read for the businessId ${businessId} has the businessId ${ProtoMap.getBusinessResponseBusinessId(held)}`);
489
+ // Named by a link, which is of a business that no longer has the businessId. It cannot be read again by the businessId,
490
+ // as a link token is refused with one, so, the container is given up as above and it is the caller's to start again
491
+ if (businessEntityId !== null && !Model.isBusinessNamed(held, businessId)) {
492
+ await cache.deleteBusiness(containerName);
493
+ this.publishBusiness(null);
494
+ return false;
495
+ }
496
+ // A container that holds no business is not one that this can say anything of: that is a failure of its own, and
497
+ // not a business that has not turned online shopping on - see readJoinBusinessId
498
+ if (ProtoMap.getBusinessResponseBusinessEntityId(held) !== null && !ProtoMap.isBusinessResponseOnlineShoppingOn(held)) {
499
+ // What was published from the cache, before the network was asked, is of the business as it was when it was open,
500
+ // which is not what it is now: nothing of a business that cannot be opened is published, or a customer could be
501
+ // joined to it - see readJoinBusinessId
502
+ this.publishBusiness(null);
503
+ throw new NotFoundException("The business has not turned online shopping on");
504
+ }
505
+ // What the container holds now, built once for this and for the publish below: the one that was built from the cache
506
+ // before the network was asked, when the read has changed nothing of it
507
+ const business = isPublishedStale ? Model.buildPublishable(held) : cachedBusiness;
508
+ if (business !== null && business.catalog.productCount === 0) {
509
+ // Nothing that a customer can buy is a shop that is not found, and is not shown, whatever the cache published
510
+ this.publishBusiness(null);
511
+ throw new NotFoundException("The business has nothing that a customer can buy");
512
+ }
513
+ // A read the store took nothing from re-renders nobody - already published above from cache,
514
+ // and nothing here changes what a later read of this container answers
515
+ if (isPublishedStale)
516
+ this.publish(business);
517
+ // What is published now is what was read to the end, and is the business that the businessId names, and is open
518
+ this._isPublishedBusinessVetted = true;
519
+ return true;
520
+ }
521
+ // Reads every page of a business that the server has for the cursors it is given, and writes each into the
522
+ // container as it arrives. The business is named by its entity id or by its businessId, which is decided by
523
+ // the first request, and every page after it is about the same business named the same way. Answers what the
524
+ // store accepted - the blobs it stored.
525
+ // firstAuthTokens are those of a link, and a link token is refused with a businessId, so a read that names the business by
526
+ // it is sent none, whatever it is given: no request that names a businessId carries one, from any of the paths that make it.
527
+ async readBusinessPages(containerName, business, cursors, attachmentBaseUrlExpiry, firstAuthTokens) {
528
+ const cache = Model.getCache();
529
+ let request = ProtoMap.buildBusinessSyncOutRequest(business, cursors, attachmentBaseUrlExpiry);
530
+ let authTokens = typeof business === "bigint" ? firstAuthTokens : null;
346
531
  let changeCount = 0;
347
532
  for (;;) {
348
533
  const page = await Model.getService().businessSyncOut(request, authTokens);
@@ -355,16 +540,19 @@ class Model {
355
540
  // no cursor - which only a server breaking its own ordering can do
356
541
  const next = ProtoMap.nextBusinessSyncOutRequest(request, page);
357
542
  if (next === null)
358
- break;
543
+ return changeCount;
359
544
  // The next page asks from where this one got to, signed with any token this one reissued
360
545
  request = next;
361
546
  authTokens = ProtoMap.refreshAuthTokens(authTokens, issued);
362
547
  }
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));
548
+ }
549
+ // Whether a response holds the business that businessId names, or holds none at all, which says nothing against it:
550
+ // only a business that has another businessId is another business. A business id is compared as the server finds
551
+ // one, in whatever case it is written and however it is padded
552
+ static isBusinessNamed(response, businessId) {
553
+ const heldBusinessId = ProtoMap.getBusinessResponseBusinessId(response);
554
+ return (heldBusinessId === null ||
555
+ canonicalizeBusinessId(heldBusinessId) === canonicalizeBusinessId(businessId));
368
556
  }
369
557
  // The BusinessLink of this profile for the named business, joining as a customer first (once) if
370
558
  // not linked yet. A business in the profile with no matching link is a server inconsistency, not
@@ -378,7 +566,7 @@ class Model {
378
566
  if (Model.findBusinessEntity(profile, businessId) !== null)
379
567
  throw new InternalServerException(`This customer has no BusinessLink to business ${businessId}`);
380
568
  const joinBusinessId = await this.readJoinBusinessId(businessId);
381
- await this.profileSyncIn(ProtoMap.buildProfileSyncOutRequestFromCache(await Model.getCache().readProfile(this.requireProfileContainerName()), joinBusinessId), await this.requireProfileAuthTokens());
569
+ await this.profileSyncIn(ProtoMap.buildProfileSyncOutRequest(await Model.getCache().readProfileCursors(this.requireProfileContainerName()), joinBusinessId), await this.requireProfileAuthTokens());
382
570
  // Attempted once: a join that still leaves no link is the server contradicting itself, not
383
571
  // something retrying would fix.
384
572
  const joined = this._profile.value;
@@ -388,20 +576,25 @@ class Model {
388
576
  return joinedLink;
389
577
  }
390
578
  // 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.
579
+ // already held, if an anonymous visit left one behind and read it to the end, or off a fresh anonymous read otherwise.
580
+ // A business that is only published from the cache, by a read that has not ended, is not one that has been found to
581
+ // be open and to be the one that the businessId names, and a customer is not joined to it on that account.
392
582
  async readJoinBusinessId(businessId) {
393
583
  const alreadyHeld = this._business.value;
394
- if (alreadyHeld !== null)
584
+ if (alreadyHeld !== null && this._isPublishedBusinessVetted)
395
585
  return alreadyHeld.id;
396
586
  await this.syncAnonymousBusiness(businessId);
587
+ // A business that is not there is a NotFoundException of the read above. Reaching here with none
588
+ // published is the server answering with no Business at all, which is its own failure
397
589
  const business = this._business.value;
398
590
  if (business === null)
399
- throw new InternalServerException(`Business ${businessId} does not exist`);
591
+ throw new InternalServerException(`The read of business ${businessId} answered with no Business`);
400
592
  return business.id;
401
593
  }
402
594
  static findBusinessEntity(profile, businessId) {
595
+ const wanted = canonicalizeBusinessId(businessId);
403
596
  for (const business of profile.businesses)
404
- if (business.businessId === businessId)
597
+ if (canonicalizeBusinessId(business.businessId) === wanted)
405
598
  return business;
406
599
  return null;
407
600
  }
@@ -473,7 +666,7 @@ class Model {
473
666
  let hasJoinedBusiness = false;
474
667
  for (;;) {
475
668
  const page = await Model.getService().profileSyncOut(request, authTokens);
476
- changeCount += await this.cacheProfileResponse(containerName, page);
669
+ changeCount += await this.cacheProfileResponse(containerName, page, true);
477
670
  if (ProtoMap.hasJoinedBusinessLink(joinBusinessId, page))
478
671
  hasJoinedLink = true;
479
672
  if (ProtoMap.hasJoinedBusiness(joinBusinessId, page))
@@ -524,8 +717,8 @@ class Model {
524
717
  //
525
718
  // Only a joinBusinessId a server could act on is checked (Java Business.isValidBusinessId's
526
719
  // 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.
720
+ // An id that is not a business entity id never gets as far as here: a server refuses it (400),
721
+ // which fails the call itself, and sending one is the caller's bug, not the server's.
529
722
  static requireJoinedBusiness(joinBusinessId, hasBusinessLink, hasBusiness) {
530
723
  if (Ids.getEntityTypeId(joinBusinessId) !== proto_Constants.BUSINESS_ENTITY_TYPE_ID)
531
724
  return;
@@ -544,8 +737,13 @@ class Model {
544
737
  // a sign up and an update - so none of them can come to cache a response differently from the
545
738
  // others, or file a token in a different place. Publishing is not in here: a read publishes once
546
739
  // 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);
740
+ // Only a page of a read moves the cursors of the container. A sign up and an update are changes this
741
+ // client made, and the next read brings them back, so, they are stored without moving any.
742
+ async cacheProfileResponse(containerName, response, isPageOfRead) {
743
+ const cache = Model.getCache();
744
+ const changeCount = isPageOfRead
745
+ ? await cache.writeProfile(containerName, response)
746
+ : await cache.storeProfile(containerName, response);
549
747
  await this.storeIssuedTokens(ProtoMap.getResponseAuthTokens(response), containerName);
550
748
  return changeCount;
551
749
  }
@@ -560,7 +758,7 @@ class Model {
560
758
  const containerName = this.getProfileContainerName();
561
759
  if (containerName === null)
562
760
  return response;
563
- if ((await this.cacheProfileResponse(containerName, response)) !== 0)
761
+ if ((await this.cacheProfileResponse(containerName, response, false)) !== 0)
564
762
  this.publishProfile(await Model.getCache().readProfile(containerName));
565
763
  return response;
566
764
  }
@@ -633,9 +831,24 @@ class Model {
633
831
  // and nothing is lost: a response the store took nothing from can't change what a later read
634
832
  // answers.
635
833
  // All four calls use this, which is why an update publishes nothing - see writeProfileResponse.
834
+ // A business that has not turned online shopping on is not published, whatever the container holds of it: what is
835
+ // published is then no business, as it is a business that cannot be opened, which a read throws for. Without this,
836
+ // the cache-first publish of a container that a read of such a business left behind would show it again before the
837
+ // next read is refused, and a customer could be joined to it. A read that finds that a business that it published from
838
+ // the cache has stopped being open, or that the server says cannot be opened, takes it back: it publishes none.
839
+ // What is published from the cache is not taken to be open all the same until the read has ended (see
840
+ // _isPublishedBusinessVetted), which is what readJoinBusinessId goes by.
636
841
  publishBusiness(response) {
637
- this._business.value =
638
- response === null ? null : ProtoMap.buildBusiness(response);
842
+ this.publish(Model.buildPublishable(response));
843
+ }
844
+ publish(business) {
845
+ this._business.value = business;
846
+ }
847
+ // The business that a response holds, or none when there is no response or the business has not turned online shopping on
848
+ static buildPublishable(response) {
849
+ return response === null || !ProtoMap.isBusinessResponseOnlineShoppingOn(response)
850
+ ? null
851
+ : ProtoMap.buildBusiness(response);
639
852
  }
640
853
  publishProfile(response) {
641
854
  this._profile.value =
package/dist/Package.js CHANGED
@@ -37,8 +37,8 @@ export default class Package extends Item {
37
37
  get photos() {
38
38
  return this._photos;
39
39
  }
40
- // What this package costs; null when the business has not priced it, in which case it cannot be
41
- // bought.
40
+ // What this package costs, with the taxes of its product included (Product.taxes); null when the
41
+ // business has not priced it, in which case it cannot be bought.
42
42
  get price() {
43
43
  return this._price;
44
44
  }
package/dist/Price.js CHANGED
@@ -2,6 +2,10 @@ import { amountFromScaledValue } from "./Constants";
2
2
  import Item from "./Item";
3
3
  // What one package costs: the regular price and the offer price. Exposed unscaled as numbers; they
4
4
  // travel multiplied by Constants.AMOUNT_FACTOR and stay well below 2^53, so nothing is lost.
5
+ //
6
+ // BOTH INCLUDE TAXES: the taxes of the product the package belongs to (Product.taxes) are in the
7
+ // price, not added to it, so this is what a customer pays and what a catalog shows. A shopping
8
+ // list stores a price without them, which is why a cart takes the taxes out of one (CartEditor).
5
9
  export default class Price extends Item {
6
10
  // Only ProtoMap calls this: it is the one place that sees prices still multiplied by
7
11
  // Constants.AMOUNT_FACTOR.
@@ -30,8 +34,8 @@ export default class Price extends Item {
30
34
  get offerPrice() {
31
35
  return this._offerPrice;
32
36
  }
33
- // What a customer actually pays: the offer price when there is one, else the regular price.
34
- // Computed here (not sent), matching the "on offer" definition from Price.proto.
37
+ // What a customer actually pays, taxes included: the offer price when there is one, else the
38
+ // regular price. Computed here (not sent), matching the "on offer" definition from Price.proto.
35
39
  get finalPrice() {
36
40
  return this.isOnOffer ? this._offerPrice : this._regularPrice;
37
41
  }
package/dist/Product.d.ts CHANGED
@@ -14,20 +14,22 @@ export default class Product extends Item {
14
14
  private readonly _manufacturerAddress;
15
15
  private readonly _taxes;
16
16
  private readonly _features;
17
+ private readonly _isForOfflineSaleOnly;
17
18
  private readonly _photos;
18
- private readonly _packages;
19
- constructor(header: EntityHeader, name: string, description: string | null, productCategoryId: bigint, isDisabled: boolean, _productType: proto_ProductType, _brand: string | null, _isManufacturedByBusiness: boolean | null, _manufacturer: string | null, _manufacturerAddress: Address | null, _taxes: ReadonlyArray<Tax>, _features: ReadonlyArray<string>, _photos: ReadonlyArray<Photo>, _packages: ReadonlyArray<Package>);
19
+ constructor(header: EntityHeader, name: string, description: string | null, productCategoryId: bigint, isDisabled: boolean, _productType: proto_ProductType, _brand: string | null, _isManufacturedByBusiness: boolean, _manufacturer: string | null, _manufacturerAddress: Address | null, _taxes: ReadonlyArray<Tax>, _features: ReadonlyArray<string>, _isForOfflineSaleOnly: boolean, _photos: ReadonlyArray<Photo>, packages: ReadonlyArray<Package>);
20
20
  get productCategoryId(): bigint;
21
21
  get productType(): proto_ProductType;
22
22
  get brand(): string | null;
23
- get isManufacturedByBusiness(): boolean | null;
23
+ get isManufacturedByBusiness(): boolean;
24
24
  get manufacturer(): string | null;
25
25
  get manufacturerAddress(): Address | null;
26
26
  get taxes(): ReadonlyArray<Tax>;
27
27
  get features(): ReadonlyArray<string>;
28
+ get isForOfflineSaleOnly(): boolean;
28
29
  get photos(): ReadonlyArray<Photo>;
29
30
  get packages(): ReadonlyArray<Package>;
30
31
  get lowestPrice(): Price | null;
31
32
  get isOnOffer(): boolean;
32
33
  get isBuyable(): boolean;
34
+ private readonly _packages;
33
35
  }