@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/ProtoMap.js CHANGED
@@ -126,25 +126,31 @@ export function getBusinessResponseAttachmentBaseUrlExpiry(response) {
126
126
  // One order as its own blob, for a caller holding a message rather than a whole response. The
127
127
  // payload is the only thing that says which a message is, so one carrying the other is refused
128
128
  // here rather than stored where a read would never find it.
129
- export function orderToBlob(order) {
129
+ // heldETag is what the container holds that order at, and null when it holds none - see messageToBlob.
130
+ export function orderToBlob(order, heldETag) {
130
131
  if (order.payload.oneofKind !== "salesOrder")
131
132
  throw new InvalidArgumentException("That message carries no sales order, so it is not an order");
132
- return messageToBlob(order);
133
+ return messageToBlob(order, heldETag);
133
134
  }
134
135
  // Whether a message carries party preferences. Here rather than at a call site, because reading a
135
136
  // field of a message is this file's job.
136
137
  export function isPartyPreferencesMessage(message) {
137
138
  return message.payload.oneofKind === "partyPreferences";
138
139
  }
139
- // When a message was last written, which is what the store versions a blob by and so the field
140
- // to compare two copies of one thing on.
140
+ // When a message was last written: the field to compare two copies of one thing on when they are
141
+ // held at different ids, and so have no version to be compared by.
141
142
  export function getMessageUpdatedAt(message) {
142
143
  return getEntity(message.entity).updatedAt;
143
144
  }
144
- export function partyPreferencesToBlob(partyPreferences) {
145
+ // Here rather than at a call site, because reading a field of a message is this file's job.
146
+ export function getMessageId(message) {
147
+ return getEntity(message.entity).id;
148
+ }
149
+ // heldETag as for orderToBlob
150
+ export function partyPreferencesToBlob(partyPreferences, heldETag) {
145
151
  if (partyPreferences.payload.oneofKind !== "partyPreferences")
146
152
  throw new InvalidArgumentException("That message carries no party preferences");
147
- return messageToBlob(partyPreferences);
153
+ return messageToBlob(partyPreferences, heldETag);
148
154
  }
149
155
  // The response the blobs of a business container make up: everything cached for that business, in
150
156
  // the shape the call that brought it returned, and null when they hold nothing of it yet.
@@ -235,10 +241,13 @@ export function orderMessagesFromBlobs(blobs) {
235
241
  // which caller it was sent to - see THE MESSAGES OF A BUSINESS ARE NOT PUBLIC.
236
242
  // Split out of partyPreferencesMessageFromBlobs so a caller already holding a response's messages
237
243
  // - such as Model.businessSyncIn folding the anonymous container in - can settle its cart without
238
- // asking the store for the same blobs again.
244
+ // asking the store for the same blobs again. buildBusiness takes the cart it shows from here too, so
245
+ // that the cart shown, the cart amended and the cart carried over a sign in are always one message.
239
246
  export function partyPreferencesMessageFromMessages(messages) {
240
247
  let result = null;
241
248
  for (const message of messages) {
249
+ if (isDeletedEntity(getEntity(message.entity)))
250
+ continue; // A tombstone is not a cart, whatever it carries
242
251
  if (message.payload.oneofKind !== "partyPreferences")
243
252
  continue;
244
253
  if (result === null ||
@@ -254,11 +263,20 @@ export function partyPreferencesMessageFromBlobs(blobs) {
254
263
  }
255
264
  // #endregion
256
265
  // #region Requests and credentials
257
- // The request that asks for everything changed since these cursors.
266
+ // The request that asks for everything changed since these cursors, about the business it is given: a bigint
267
+ // is the entity id of the Business, which a caller that has read it before has, and a string is its
268
+ // url friendly businessId, which is all that one reading it for the first time has.
258
269
  // attachmentBaseUrlExpiry is not a cursor, just the expiry of the url already held, sent back unchanged (zero when none is held).
259
- export function buildBusinessSyncOutRequest(businessId, syncInState, attachmentBaseUrlExpiry) {
270
+ export function buildBusinessSyncOutRequest(business, syncInState, attachmentBaseUrlExpiry) {
271
+ return createBusinessSyncOutRequest(typeof business === "bigint"
272
+ ? { oneofKind: "businessEntityId", businessEntityId: business }
273
+ : { oneofKind: "businessId", businessId: business }, syncInState, attachmentBaseUrlExpiry);
274
+ }
275
+ // Takes the business as the request holds it, so that the request after a page can name it exactly as
276
+ // the one before did, whichever way that was
277
+ function createBusinessSyncOutRequest(business, syncInState, attachmentBaseUrlExpiry) {
260
278
  return proto_BusinessSyncOutRequest.create({
261
- businessId: businessId,
279
+ business: business,
262
280
  businessUpdateNumber: syncInState.getUpdateNumber(proto_Constants.BUSINESS_ENTITY_TYPE_ID),
263
281
  attachmentUpdateNumber: syncInState.getUpdateNumber(proto_Constants.ATTACHMENT_ENTITY_TYPE_ID),
264
282
  attachmentBaseUrlExpiry: attachmentBaseUrlExpiry,
@@ -267,22 +285,45 @@ export function buildBusinessSyncOutRequest(businessId, syncInState, attachmentB
267
285
  itemUpdateNumber: syncInState.getUpdateNumber(proto_Constants.ITEM_ENTITY_TYPE_ID),
268
286
  });
269
287
  }
270
- // The first request of a business sync in, with cursors raised from what is already cached rather
271
- // than sent from zero - the same way nextBusinessSyncOutRequest raises them from a page just
272
- // arrived, but from a response already held.
273
- export function buildBusinessSyncOutRequestFromCache(businessId, cached) {
274
- const cursors = new SyncState(SyncKeys.BUSINESS_SYNC_IN);
275
- if (cached !== null)
276
- for (const entity of getBusinessResponseEntities(cached))
277
- cursors.raiseUpdateNumber(entity.entityTypeId, entity.updateNumber);
278
- return buildBusinessSyncOutRequest(businessId, cursors, getBusinessResponseAttachmentBaseUrlExpiry(cached));
288
+ // How far a container has been read, as the blobs that keep it: one for each key that a page has
289
+ // moved, at an id of its own - see Ids.getSyncCursorBlobId. The cursor is the eTag and there is
290
+ // nothing else to keep, so there are no bytes. A cursor is therefore only ever raised: the store
291
+ // refuses a blob that is not the greater, so a cursor is not lowered by a page that is stale or sent
292
+ // again, nor by another tab that wrote at the same time.
293
+ export function cursorsToBlobs(cursors, movedKeys) {
294
+ const blobs = [];
295
+ for (const key of movedKeys)
296
+ blobs.push({
297
+ id: Ids.getSyncCursorBlobId(key),
298
+ eTag: cursors.getUpdateNumber(key),
299
+ bytes: NO_BYTES,
300
+ });
301
+ return blobs;
302
+ }
303
+ // The cursors a container holds, from the blobs it holds at the cursor ids of keys, in the order of
304
+ // keys and null where it holds none: a state at zero for one that was never read, or was filled by a
305
+ // version that did not keep them, which asks for everything again and is sent it again, a cost in
306
+ // bandwidth and no more: the store keeps a blob only when its eTag is greater.
307
+ export function cursorsFromBlobs(keys, blobs) {
308
+ const cursors = new SyncState(keys);
309
+ for (const [index, key] of keys.entries()) {
310
+ const eTag = blobs[index]?.eTag ?? null;
311
+ if (eTag !== null)
312
+ cursors.raiseUpdateNumber(key, eTag);
313
+ }
314
+ return cursors;
315
+ }
316
+ // Raises these cursors to what a page of a business read brought, tombstones included, and answers
317
+ // the keys that moved. The only thing that moves them: what this client pushed does not.
318
+ export function raiseBusinessCursors(cursors, response) {
319
+ return raiseCursorsFrom(cursors, getBusinessResponseEntities(response));
279
320
  }
280
321
  // The request that pushes back what this customer has changed. Messages are the only entity type a
281
322
  // storefront may push, and one request is one batch: an order and the cart it emptied go together,
282
323
  // so that a customer can never be left holding an order and a cart still full of the same items.
283
- // messageUpdateNumber is the cursor of what this customer already holds cached, so the response
284
- // brings back only what has changed since - this push's own messages, under the update numbers the
285
- // server assigns them, and anything else of theirs a later sync out would also pick up.
324
+ // messageUpdateNumber is how far the reads of this customer's container have got with messages, and
325
+ // not what a push has since stored: the response is this push's own messages, under the update
326
+ // numbers the server assigns them, and nothing else.
286
327
  export function buildBusinessSyncInRequest(messageUpdateNumber, messages) {
287
328
  return proto_BusinessSyncInRequest.create({
288
329
  messageUpdateNumber: messageUpdateNumber,
@@ -290,7 +331,7 @@ export function buildBusinessSyncInRequest(messageUpdateNumber, messages) {
290
331
  });
291
332
  }
292
333
  // The messages a sync in response carried - this push's own, now bearing the update numbers the
293
- // server assigned them, and anything else of this customer's that changed since the cursor sent.
334
+ // server assigned them, and no others. Nothing is to be made of those numbers: they are not how far a read has got.
294
335
  export function getSyncInResponseMessages(response) {
295
336
  return response.messages;
296
337
  }
@@ -309,14 +350,9 @@ export function buildProfileSyncOutRequest(syncInState, joinBusinessId = BigInt(
309
350
  joinBusinessId: joinBusinessId,
310
351
  });
311
352
  }
312
- // The same, seeded from what the profile container already holds cached rather than from a fresh
313
- // SyncState - see buildBusinessSyncOutRequestFromCache, which this mirrors.
314
- export function buildProfileSyncOutRequestFromCache(cached, joinBusinessId = BigInt(proto_Constants.ENTITY_ID_NULL)) {
315
- const cursors = new SyncState(SyncKeys.PROFILE_SYNC_IN);
316
- if (cached !== null)
317
- for (const entity of getProfileResponseEntities(cached))
318
- cursors.raiseUpdateNumber(entity.entityTypeId, entity.updateNumber);
319
- return buildProfileSyncOutRequest(cursors, joinBusinessId);
353
+ // The same for a profile read
354
+ export function raiseProfileCursors(cursors, response) {
355
+ return raiseCursorsFrom(cursors, getProfileResponseEntities(response));
320
356
  }
321
357
  // The user a sign up asks the server to create, as CreateUserRequest.user. Built from the domain
322
358
  // object rather than carrying one of its own: this user has no entity yet - the server assigns id,
@@ -362,11 +398,63 @@ export function hasJoinedBusinessLink(joinBusinessId, response) {
362
398
  export function hasJoinedBusiness(joinBusinessId, response) {
363
399
  if (joinBusinessId === BigInt(proto_Constants.ENTITY_ID_NULL))
364
400
  return false;
401
+ // A tombstone of the business is not the business, which is the whole of what a join is answered with it for
365
402
  for (const business of response.businesses)
366
- if (getEntity(business.entity).id === joinBusinessId)
403
+ if (getEntity(business.entity).id === joinBusinessId &&
404
+ !isDeletedEntity(getEntity(business.entity)))
367
405
  return true;
368
406
  return false;
369
407
  }
408
+ // The country of the business a response holds, ISO 3166-1 numeric, or undefined when it holds none. A
409
+ // tombstone is not a business and has no country: it is only its header, so the zero it carries must
410
+ // not be taken for one - a cart's taxes are raw codes, which mean something only within a country.
411
+ export function getBusinessResponseCountryCode(response) {
412
+ const business = response?.business;
413
+ if (business === undefined || isDeletedEntity(getEntity(business.entity)))
414
+ return undefined;
415
+ return business.countryCode;
416
+ }
417
+ // The entity id of the business a response holds, or null when it holds none - which is a response
418
+ // with no business in it, or one with a tombstone, a deleted business being no business to name. Once
419
+ // a business has been read, this is what every later request names it by: its entity id is stable
420
+ // and a url friendly id is only how it was first found.
421
+ export function getBusinessResponseBusinessEntityId(response) {
422
+ const entity = response?.business?.entity;
423
+ if (entity === undefined || isDeletedEntity(entity))
424
+ return null;
425
+ return entity.id;
426
+ }
427
+ // The url friendly id of the business a response holds - the one that a business is found by, which is not its entity id
428
+ // - or null when it holds none, or only a tombstone, which is only a header and has no business id to tell.
429
+ export function getBusinessResponseBusinessId(response) {
430
+ const business = response?.business;
431
+ if (business === undefined || isDeletedEntity(getEntity(business.entity)))
432
+ return null;
433
+ return business.businessId;
434
+ }
435
+ // Whether the general settings that a response holds say that the business has turned online shopping on. Settings that
436
+ // are not there - the business has made none, or has deleted them, or this has not read them yet - say that it has not, as
437
+ // settings that say so do. That is what a storefront is told by a business that has not turned it on: the server sends it
438
+ // whatever it says, and a client decides, from what it holds once it has read everything it was sent.
439
+ export function isBusinessResponseOnlineShoppingOn(response) {
440
+ if (response === null)
441
+ return false;
442
+ return getGeneralSettingsOf(response)?.onlineShoppingEnabled ?? false;
443
+ }
444
+ // The general settings that a response holds, as the wire carries them, or undefined when it holds none: the one public
445
+ // configuration that carries them, unless it has been deleted, which is only its header. What is read of the settings - the
446
+ // business that is built from them, and whether it is open - is read from here, so that the two cannot be about different ones.
447
+ function getGeneralSettingsOf(response) {
448
+ let settings;
449
+ for (const configuration of response.configurations) {
450
+ if (isDeletedEntity(getEntity(configuration.entity)))
451
+ continue;
452
+ if (configuration.data.oneofKind !== "generalSettings")
453
+ continue; // Not a configuration this library understands
454
+ settings = configuration.data.generalSettings;
455
+ }
456
+ return settings;
457
+ }
370
458
  // The credentials of one call, as the wire carries them. An absent token is left off the message
371
459
  // rather than sent empty, because the protocol tells the two apart.
372
460
  export function buildAuthTokens(authTokens) {
@@ -470,7 +558,7 @@ export function nextBusinessSyncOutRequest(request, response) {
470
558
  return null;
471
559
  // A page that renewed the url means a later one is now held; otherwise what was sent still stands
472
560
  const renewedExpiry = getBusinessResponseAttachmentBaseUrlExpiry(response);
473
- return buildBusinessSyncOutRequest(request.businessId, cursors, renewedExpiry === 0n ? request.attachmentBaseUrlExpiry : renewedExpiry);
561
+ return createBusinessSyncOutRequest(request.business, cursors, renewedExpiry === 0n ? request.attachmentBaseUrlExpiry : renewedExpiry);
474
562
  }
475
563
  // The same for a profile read. Any joinBusinessId the first request carried is deliberately left
476
564
  // off the ones that follow: a join is done once, by the call that asked for it.
@@ -512,6 +600,16 @@ function profileResponseEntityCount(response) {
512
600
  response.businessLinks.length +
513
601
  response.businesses.length);
514
602
  }
603
+ // Raises the cursors the entities of one page advance, and answers the keys that moved - one for
604
+ // all the entities of a table, however many of them raised it. A tombstone counts exactly as a value
605
+ // does, as it does for raiseCursor.
606
+ function raiseCursorsFrom(cursors, entities) {
607
+ const movedKeys = new Set();
608
+ for (const entity of entities)
609
+ if (cursors.raiseUpdateNumber(entity.entityTypeId, entity.updateNumber))
610
+ movedKeys.add(SyncKeys.keyOf(entity.entityTypeId));
611
+ return movedKeys;
612
+ }
515
613
  // Raises the cursor one entity advances, and answers whether it moved. A tombstone counts exactly
516
614
  // as a value does - skipping one would have the server send it again for ever.
517
615
  function raiseCursor(cursors, entity) {
@@ -549,6 +647,9 @@ export function buildBusiness(response) {
549
647
  return null;
550
648
  const business = businessFromProto(businessProto);
551
649
  const updatableBusiness = business;
650
+ // A tax travels as its raw code, which means something only within the business's country - so
651
+ // every tax below is read against this one
652
+ const countryCode = businessProto.countryCode;
552
653
  // Resolved once here and handed to every Photo built below, since a photo takes the base url in
553
654
  // effect when it is built rather than looking one up later - see the comment on Photo's constructor.
554
655
  const attachmentBaseUrlProto = response.attachmentBaseUrl;
@@ -567,13 +668,9 @@ export function buildBusiness(response) {
567
668
  owned.push(attachment);
568
669
  }
569
670
  // The settings, unwrapped from the one public configuration that carries them
570
- for (const configuration of response.configurations) {
571
- if (isDeletedEntity(getEntity(configuration.entity)))
572
- continue;
573
- if (configuration.data.oneofKind !== "generalSettings")
574
- continue; // Not a configuration this library understands
575
- updatableBusiness.settings = generalSettingsFromProto(configuration.data.generalSettings);
576
- }
671
+ const generalSettings = getGeneralSettingsOf(response);
672
+ if (generalSettings !== undefined)
673
+ updatableBusiness.settings = generalSettingsFromProto(generalSettings, countryCode);
577
674
  // The photos of the store itself
578
675
  updatableBusiness.photos = takePhotos(attachmentsByOwner, business.id, attachmentBaseUrl);
579
676
  // Prices, by the package each applies to. Only the MeraBills Online list is this storefront's -
@@ -613,8 +710,9 @@ export function buildBusiness(response) {
613
710
  siblings.push(pkg);
614
711
  }
615
712
  // Products, built with their photos and their buyable packages, and parented to the business
616
- // (not their category, which only groups them). A product that is not buyable - disabled, or
617
- // left with no buyable package by the filtering above - is left out for the same reason.
713
+ // (not their category, which only groups them). A product that is not buyable - disabled, sold
714
+ // only offline (the server sends those, flagged), or left with no buyable package by the filtering
715
+ // above - is left out for the same reason.
618
716
  const products = [];
619
717
  for (const productProto of response.products) {
620
718
  if (isDeletedItem(productProto.item))
@@ -622,11 +720,11 @@ export function buildBusiness(response) {
622
720
  const productId = getItemId(productProto.item);
623
721
  const packages = packagesByProduct.get(productId) ?? [];
624
722
  const productPhotos = takePhotos(attachmentsByOwner, productId, attachmentBaseUrl);
625
- const product = productFromProto(productProto, productPhotos, Object.freeze([...packages]));
723
+ const product = productFromProto(productProto, productPhotos, Object.freeze([...packages]), countryCode);
626
724
  if (!product.isBuyable)
627
725
  continue;
628
726
  UpdatableEntity.setParent(product, business);
629
- for (const pkg of packages)
727
+ for (const pkg of product.packages)
630
728
  UpdatableEntity.setParent(pkg, product);
631
729
  products.push(product);
632
730
  }
@@ -655,35 +753,28 @@ export function buildBusiness(response) {
655
753
  // The orders of this customer, and their party preferences: the cart, and the fulfillment mode
656
754
  // to default their next order to. A message of any other payload type is dropped.
657
755
  const orders = [];
658
- let cart = null;
659
- let cartUpdateNumber = 0n;
660
- let defaultFulfillmentMode = proto_FulfillmentMode.DELIVERY;
661
756
  for (const message of response.messages) {
662
757
  if (isDeletedEntity(getEntity(message.entity)))
663
758
  continue;
664
- switch (message.payload.oneofKind) {
665
- case "salesOrder": {
666
- orders.push(orderFromProto(message, message.payload.salesOrder));
667
- break;
668
- }
669
- case "partyPreferences": {
670
- // One customer has one party preferences message, so two means two callers' messages are
671
- // in one container - see partyPreferencesMessageFromBlobs, which breaks the tie the same way
672
- const updateNumber = getEntity(message.entity).updateNumber;
673
- if (cart !== null && updateNumber <= cartUpdateNumber)
674
- break;
675
- // An emptied cart is a message whose payload carries no shopping list, which is what
676
- // placing an order leaves behind - so it answers null and not an empty list, the same as
677
- // never having had a cart at all
678
- cart = cartShoppingListFromProto(message.payload.partyPreferences);
679
- cartUpdateNumber = updateNumber;
680
- // Whatever this same message carries, DELIVERY only ever coming from the fallback above -
681
- // not from a message that happens to hold UNSPECIFIED_FULFILLMENT_MODE itself
682
- defaultFulfillmentMode = message.payload.partyPreferences.fulfillmentMode;
683
- break;
684
- }
685
- // No default: MONEY_TRANSFER has no payload in this protocol, and OTHER is not ours
686
- }
759
+ // The party preferences are one message out of however many there are, so they are settled below
760
+ if (message.payload.oneofKind === "salesOrder")
761
+ orders.push(orderFromProto(message, message.payload.salesOrder, countryCode));
762
+ }
763
+ // One customer has one party preferences message, so two means two callers' messages are in one container. The one that is
764
+ // taken is the one that partyPreferencesMessageFromMessages picks, which is what a cart that is amended or sent again is
765
+ // read from as well: a rule of its own here would show one cart and amend another. It is the newer of the two even when it
766
+ // is the emptied one, and the older one's list is not shown for want of it.
767
+ const partyPreferences = partyPreferencesMessageFromMessages(response.messages);
768
+ let cart = null;
769
+ let defaultFulfillmentMode = proto_FulfillmentMode.DELIVERY;
770
+ if (partyPreferences !== null && partyPreferences.payload.oneofKind === "partyPreferences") {
771
+ // An emptied cart is a message whose payload carries no shopping list, which is what
772
+ // placing an order leaves behind - so it answers null and not an empty list, the same as
773
+ // never having had a cart at all
774
+ cart = cartShoppingListFromProto(partyPreferences.payload.partyPreferences, countryCode);
775
+ // Whatever this same message carries, DELIVERY only ever coming from the fallback above -
776
+ // not from a message that happens to hold UNSPECIFIED_FULFILLMENT_MODE itself
777
+ defaultFulfillmentMode = partyPreferences.payload.partyPreferences.fulfillmentMode;
687
778
  }
688
779
  for (const order of orders)
689
780
  UpdatableEntity.setParent(order, business);
@@ -715,20 +806,22 @@ export function buildProfile(profileId, response) {
715
806
  return null;
716
807
  return new Profile(profileId, user, businessLinks, businesses);
717
808
  }
718
- // One order on its own, for a caller holding a message rather than a whole response
719
- export function buildOrder(message) {
809
+ // One order on its own, for a caller holding a message rather than a whole response. The country of
810
+ // the business it was placed with is the caller's to give, since a tax travels as a raw code.
811
+ export function buildOrder(message, countryCode) {
720
812
  if (message.payload.oneofKind !== "salesOrder")
721
813
  return null;
722
- return orderFromProto(message, message.payload.salesOrder);
814
+ return orderFromProto(message, message.payload.salesOrder, countryCode);
723
815
  }
724
816
  // The shopping list a cart message carries, empty rather than absent when the cart has been
725
817
  // emptied. Unlike Business.cart (see the party preferences case of buildBusiness, which answers
726
818
  // null so "emptied" and "never had one" look the same to a customer), a caller here - createOrder,
727
819
  // the visitor cart carried over on sign in - works with an actual list either way.
728
- export function buildShoppingList(message) {
820
+ // countryCode is that of the business the cart is for, as for buildOrder.
821
+ export function buildShoppingList(message, countryCode) {
729
822
  if (message.payload.oneofKind !== "partyPreferences")
730
823
  throw new InvalidArgumentException("That message carries no party preferences, so it is not a cart");
731
- return cartShoppingListFromProto(message.payload.partyPreferences) ?? ShoppingList.EMPTY;
824
+ return (cartShoppingListFromProto(message.payload.partyPreferences, countryCode) ?? ShoppingList.EMPTY);
732
825
  }
733
826
  // #endregion
734
827
  // #region What a customer pushes
@@ -758,12 +851,12 @@ export function buildPartyPreferencesMessage(messageId, existing, senderId, shop
758
851
  const preferences = existing !== null && existing.payload.oneofKind === "partyPreferences"
759
852
  ? existing.payload.partyPreferences
760
853
  : proto_PartyPreferencesPayload.create();
761
- // Later than the copy it replaces, whatever this client's clock reads. A store keeps a blob only
762
- // where its eTag is the greater, and that eTag is this field - so a client whose clock sits behind
763
- // the time on the cart it is amending, which Constants.MAX_CLOCK_SKEW says is a thing that
764
- // happens, would push a change the server accepted and then have its own store refuse it, leaving
765
- // the two disagreeing about the cart the customer is looking at. One millisecond past the copy
766
- // being replaced is enough, and the server keeps whatever is sent, so both sides still agree.
854
+ // Later than the copy it replaces, whatever this client's clock reads. An amendment written here
855
+ // without the server is stored at the update number of the copy it amends, where this field is
856
+ // what decides - so a client whose clock sits behind the time on the cart it is amending, which
857
+ // Constants.MAX_CLOCK_SKEW says is a thing that happens, would have its own store refuse the
858
+ // edit, leaving the customer looking at a cart that is not the one it holds. One millisecond past
859
+ // the copy being replaced is enough, and the server keeps whatever is sent, so both sides still agree.
767
860
  const updatedAt = existingEntity === null || existingEntity.updatedAt < now ? now : existingEntity.updatedAt + 1n;
768
861
  const partyPreferences = proto_PartyPreferencesPayload.create({
769
862
  ...preferences,
@@ -815,25 +908,14 @@ export function buildOrderMessage(orderId, senderId, shoppingList, fulfillmentMo
815
908
  export function messagesToCacheWrite(messages) {
816
909
  return toCacheWrite(messages.map((message) => toWireEntity(message.entity, proto_Message.toBinary(message))));
817
910
  }
818
- // The highest update number among this customer's messages already cached, cart and orders alike -
819
- // what a push sends as its cursor. Computed fresh off the store each time rather than kept anywhere:
820
- // no cursor is persisted in this library, and this is no exception - see THE SYNC STATES in
821
- // ../Prompt.txt. Zero when none are cached, which is how a first push asks for everything back.
822
- export function highestCachedMessageUpdateNumber(blobs) {
823
- let highest = 0n;
824
- for (const message of messagesFromBlobs(blobs)) {
825
- const updateNumber = getEntity(message.entity).updateNumber;
826
- if (updateNumber > highest)
827
- highest = updateNumber;
828
- }
829
- return highest;
830
- }
831
911
  // The Entity of something this client is pushing. The update number goes out as zero: the protocol
832
912
  // replaces a caller's own with the server's and answers what it assigned, so there is nothing to
833
- // send and nothing to guess. updateInfo goes out as zero too, which is not the same kind of zero -
834
- // it should be the id of this store, and this store has no id yet, so a read of this business will
835
- // send these messages back rather than recognising them as its own. That costs bandwidth and
836
- // nothing else, since the eTag of the copy stored here is the one the server kept.
913
+ // send and nothing to guess. It is also what a message not pushed yet is left holding in the store,
914
+ // where messageToBlob versions it from the copy it amends. updateInfo goes out as zero too: it
915
+ // would be the id of the store that made the change, and this client has no store of its own that
916
+ // the server knows. It needs none: a read of this business sends these messages back, as the
917
+ // server leaves nobody's changes out of a storefront's read, and the eTag of the copy stored here
918
+ // is the one the server kept, so, that changes nothing it holds.
837
919
  function pushedEntity(id, createdAt, updatedAt) {
838
920
  return proto_Entity.create({ id: id, createdAt: createdAt, updatedAt: updatedAt });
839
921
  }
@@ -841,8 +923,12 @@ function pushedEntity(id, createdAt, updatedAt) {
841
923
  // building or pushing a cart message rather than after - a caller may call it on every event it
842
924
  // cares about and only a cart the server has not got costs a request.
843
925
  // Compares raw lists rather than built messages, because a message's updatedAt always differs from
844
- // call to call; sound because the server echoes a pushed list back unchanged, so what is stored is
845
- // exactly what was last sent.
926
+ // call to call; sound because the server echoes a pushed list back with the items it was sent, so what
927
+ // is stored is what was last sent - but not in the order it was sent in: the server keeps the items of a
928
+ // list by package id and sends them in that order, and the order of the list is each item's
929
+ // shoppingListOrder, which is compared with the rest of the item. So the items of both lists are put
930
+ // in package id order before they are compared, or a cart that was not picked in package id order would
931
+ // differ from its own echo and be pushed again, which re-stamps its updatedAt for every other device.
846
932
  // Also true whenever stored has never been pushed (updateNumber 0, left by updateCartLocalOnly),
847
933
  // regardless of content, so an unsent local edit still goes out.
848
934
  export function hasShoppingListChanged(stored, shoppingList) {
@@ -850,7 +936,18 @@ export function hasShoppingListChanged(stored, shoppingList) {
850
936
  return pushedShoppingList(shoppingList, false) !== undefined;
851
937
  if (getEntity(stored.entity).updateNumber === 0n)
852
938
  return true;
853
- return !proto_ShoppingList.equals(getCartShoppingList(stored), pushedShoppingList(shoppingList, false));
939
+ return !proto_ShoppingList.equals(inPackageIdOrder(getCartShoppingList(stored)), inPackageIdOrder(pushedShoppingList(shoppingList, false)));
940
+ }
941
+ // The same list with its items by package id, the order in which the server sends them
942
+ function inPackageIdOrder(list) {
943
+ if (list === undefined)
944
+ return undefined;
945
+ const byPackageId = (first, second) => first.packageId < second.packageId ? -1 : first.packageId > second.packageId ? 1 : 0;
946
+ return proto_ShoppingList.create({
947
+ ...list,
948
+ existingItems: [...list.existingItems].sort(byPackageId),
949
+ deletedItems: [...list.deletedItems].sort(byPackageId),
950
+ });
854
951
  }
855
952
  // The list inside a cart message, or absent for one carrying no list - which is an emptied cart
856
953
  function getCartShoppingList(message) {
@@ -907,34 +1004,59 @@ function shoppingListItemToProto(item) {
907
1004
  });
908
1005
  }
909
1006
  function taxToProto(tax) {
910
- return proto_Tax.create({ type: tax.type, rate: taxRateToScaledValue(tax.rate) });
1007
+ return proto_Tax.create({ taxCode: tax.taxCode, rate: taxRateToScaledValue(tax.rate) });
911
1008
  }
912
1009
  // #endregion
913
1010
  // #region Entity and item bookkeeping
914
- // One pass over the entities of a response, splitting them into what is stored and what is removed:
915
- // a live entity becomes a blob, and a tombstone becomes an id to delete. The blob id is the entity
916
- // id, which is what makes one read of a container enough to sort it by entity type, and the eTag is
917
- // Entity.updatedAt.
1011
+ // One pass over the entities of a response, each becoming a blob at its own id, which is what makes
1012
+ // one read of a container enough to sort it by entity type. Every blob is versioned by entityETag, a
1013
+ // tombstone's too: a delete is a write of its own, so a tombstone carries a greater update number
1014
+ // than any copy of what it deleted and replaces it, and an older copy of it, sent again, is the
1015
+ // lesser of the two and is refused. A later write of the same id is the greater again, and is kept.
918
1016
  // Mutable arrays, so that businessResponseToCacheWrite can add the attachment base url to the blobs.
919
1017
  function toCacheWrite(entities) {
920
1018
  const blobs = [];
921
- const deletedIds = [];
922
- for (const entity of entities) {
923
- if (entity.isDeleted)
924
- deletedIds.push(entity.id);
925
- else
926
- blobs.push({ id: entity.id, eTag: entity.updatedAt, bytes: entity.bytes });
927
- }
928
- return { blobs: blobs, deletedIds: deletedIds };
929
- }
930
- function messageToBlob(message) {
1019
+ for (const entity of entities)
1020
+ blobs.push({
1021
+ id: entity.id,
1022
+ eTag: entityETag(entity.updateNumber, entity.updatedAt),
1023
+ bytes: entity.bytes,
1024
+ });
1025
+ return { blobs: blobs, deletedIds: [] };
1026
+ }
1027
+ // What a store versions the blob of an entity by: its update number, and below it its updatedAt, in
1028
+ // one bigint so that the greater wins as the store says. The server assigns an update number to every
1029
+ // write, deletes included, so it rises with every change to an entity whatever any clock reads, and
1030
+ // decides between two copies that are not the same version. updatedAt decides between those that
1031
+ // are: only a copy this client wrote itself, at the update number of the one it amended, is another
1032
+ // of the same number. Wider than 64 bits, which a bigint holds exactly and a store must compare as one.
1033
+ export function entityETag(updateNumber, updatedAt) {
1034
+ if (updateNumber < 0n || updatedAt < 0n)
1035
+ throw new InvalidArgumentException("An update number and an updatedAt are never negative");
1036
+ return (updateNumber << UPDATED_AT_BITS) | updatedAt;
1037
+ }
1038
+ // A message this client wrote, which has no update number of its own until a server assigns one, and
1039
+ // has none in the store as long as it is not pushed (see pushedEntity). It is versioned at the update
1040
+ // number of the copy it amends, which is the one heldETag carries, and so it is the later copy of that
1041
+ // version by its updatedAt: a copy of the version it amends, sent again by the server, is not, and a
1042
+ // later version of the server's, with a greater update number, replaces it. One that amends nothing
1043
+ // is at zero. A message that has an update number of its own is versioned by it, as any entity is.
1044
+ function messageToBlob(message, heldETag) {
931
1045
  const entity = getEntity(message.entity);
1046
+ const updateNumber = entity.updateNumber !== 0n
1047
+ ? entity.updateNumber
1048
+ : heldETag === null
1049
+ ? 0n
1050
+ : heldETag >> UPDATED_AT_BITS;
932
1051
  return {
933
1052
  id: entity.id,
934
- eTag: entity.updatedAt,
1053
+ eTag: entityETag(updateNumber, entity.updatedAt),
935
1054
  bytes: proto_Message.toBinary(message),
936
1055
  };
937
1056
  }
1057
+ // The messages a container holds that are messages: a tombstone is held, and is not one. It is left out
1058
+ // whatever it carries, and not by what is found in it - a payload says what a message is, and a
1059
+ // tombstone sent by a server that left the payload on it would otherwise be read as a cart or an order.
938
1060
  function messagesFromBlobs(blobs) {
939
1061
  const result = [];
940
1062
  for (const blob of blobs) {
@@ -942,7 +1064,9 @@ function messagesFromBlobs(blobs) {
942
1064
  continue; // A fixed blob, not an entity
943
1065
  if (Ids.getEntityTypeId(blob.id) !== proto_Constants.MESSAGE_ENTITY_TYPE_ID)
944
1066
  continue;
945
- result.push(proto_Message.fromBinary(blob.bytes));
1067
+ const message = proto_Message.fromBinary(blob.bytes);
1068
+ if (!isDeletedEntity(getEntity(message.entity)))
1069
+ result.push(message);
946
1070
  }
947
1071
  return result;
948
1072
  }
@@ -956,7 +1080,7 @@ function toWireEntity(entity, bytes) {
956
1080
  updateNumber: entity.updateNumber,
957
1081
  updatedAt: entity.updatedAt,
958
1082
  isDeleted: isDeleted,
959
- bytes: isDeleted ? EMPTY_BYTES : bytes,
1083
+ bytes: bytes,
960
1084
  };
961
1085
  }
962
1086
  // A tombstone: the update type is held in the last two bits of the update number
@@ -1000,10 +1124,6 @@ function stringFromProto(value) {
1000
1124
  function stringToProto(value) {
1001
1125
  return value === null || value.length === 0 ? undefined : value;
1002
1126
  }
1003
- // An absent optional bool is null - for Product.isManufacturedByBusiness that means "not said either way"
1004
- function booleanFromProto(value) {
1005
- return value === undefined ? null : value;
1006
- }
1007
1127
  function attachmentBaseUrlFromProto(urlAndToken) {
1008
1128
  return new AttachmentBaseUrl(urlAndToken.resourceUrl, urlAndToken.sasToken, urlAndToken.expiry);
1009
1129
  }
@@ -1038,8 +1158,10 @@ function addressListFromProto(addressList) {
1038
1158
  return AddressList.EMPTY;
1039
1159
  return new AddressList(addressList.defaultAddressId, Object.freeze(addressList.addresses.map(addressFromProto)));
1040
1160
  }
1041
- function taxFromProto(tax) {
1042
- return Tax.fromScaledValues(tax.type, tax.rate);
1161
+ // The wire carries a tax's raw code, meaningful only within a country - that of the business the tax
1162
+ // is on, which every caller therefore has to name
1163
+ function taxFromProto(tax, countryCode) {
1164
+ return Tax.fromScaledValues(countryCode, tax.taxCode, tax.rate);
1043
1165
  }
1044
1166
  function latLongFromProto(latLong) {
1045
1167
  if (latLong === undefined)
@@ -1075,18 +1197,19 @@ function workingHoursFromProto(workingHours) {
1075
1197
  function workingDaysFromProto(workingDays) {
1076
1198
  if (workingDays === undefined)
1077
1199
  return WorkingDays.ALL_DAYS;
1078
- return new WorkingDays(Object.freeze([...workingDays.days]), Object.freeze(workingDays.workingHours.map(workingHoursFromProto)));
1200
+ // Not an optional field, so an unspecified zone arrives as an empty string rather than absent
1201
+ return new WorkingDays(Object.freeze([...workingDays.days]), workingDays.timeZone.length === 0 ? null : workingDays.timeZone, Object.freeze(workingDays.workingHours.map(workingHoursFromProto)));
1079
1202
  }
1080
- function shoppingListItemFromProto(item) {
1081
- return ShoppingListItem.fromScaledValues(item.packageId, item.quantity, item.unitPrice, item.shoppingListOrder, item.isOriginal, item.isModified, item.isStockKeepingRequired, item.priceDiscount, item.discount, Object.freeze(item.taxes.map(taxFromProto)), item.expiry, stringFromProto(item.description));
1203
+ function shoppingListItemFromProto(item, countryCode) {
1204
+ return ShoppingListItem.fromScaledValues(item.packageId, item.quantity, item.unitPrice, item.shoppingListOrder, item.isOriginal, item.isModified, item.isStockKeepingRequired, item.priceDiscount, item.discount, Object.freeze(item.taxes.map((tax) => taxFromProto(tax, countryCode))), item.expiry, stringFromProto(item.description));
1082
1205
  }
1083
- function shoppingListFromProto(shoppingList) {
1206
+ function shoppingListFromProto(shoppingList, countryCode) {
1084
1207
  if (shoppingList === undefined)
1085
1208
  return ShoppingList.EMPTY;
1086
- return ShoppingList.fromScaledValues(shoppingList.isFinal, shoppingList.autoAddDeliveryCharge, shoppingList.autoAddServiceCharge, shoppingList.inventoryLocationId, shoppingList.priceListId, shoppingList.totalAmountBeforeDiscount, shoppingList.discount, shoppingList.existingItemCount, shoppingList.deletedItemCount, Object.freeze(shoppingList.existingItems.map(shoppingListItemFromProto)), Object.freeze(shoppingList.deletedItems.map(shoppingListItemFromProto)));
1209
+ return ShoppingList.fromScaledValues(shoppingList.isFinal, shoppingList.autoAddDeliveryCharge, shoppingList.autoAddServiceCharge, shoppingList.inventoryLocationId, shoppingList.priceListId, shoppingList.totalAmountBeforeDiscount, shoppingList.discount, shoppingList.existingItemCount, shoppingList.deletedItemCount, Object.freeze(shoppingList.existingItems.map((item) => shoppingListItemFromProto(item, countryCode))), Object.freeze(shoppingList.deletedItems.map((item) => shoppingListItemFromProto(item, countryCode))));
1087
1210
  }
1088
- function generalSettingsFromProto(settings) {
1089
- return GeneralSettings.fromScaledValues(Object.freeze(settings.paymentMethodDetails.map(paymentMethodDetailFromProto)), stringFromProto(settings.customerServicePhone), settings.isWhatsAppSupported, settings.isSmsSupported, addressListFromProto(settings.businessAddresses), Object.freeze(settings.businessIdentifiers.map(businessIdentifierFromProto)), Object.freeze([...settings.fulfillmentModes]), settings.deliveryCharge, Object.freeze(settings.deliveryChargeTaxes.map(taxFromProto)), settings.freeDeliveryTotal, settings.deliveryRadiusKms, stringFromProto(settings.storeTagline), workingDaysFromProto(settings.workingDays), latLongFromProto(settings.primaryStoreLocation), stringFromProto(settings.googleBusinessCategoryId), stringFromProto(settings.googleBusinessCategoryName), stringFromProto(settings.onlineStoreThemeId));
1211
+ function generalSettingsFromProto(settings, countryCode) {
1212
+ return GeneralSettings.fromScaledValues(Object.freeze(settings.paymentMethodDetails.map(paymentMethodDetailFromProto)), stringFromProto(settings.customerServicePhone), settings.isWhatsAppSupported, settings.isSmsSupported, addressListFromProto(settings.businessAddresses), Object.freeze(settings.businessIdentifiers.map(businessIdentifierFromProto)), Object.freeze([...settings.fulfillmentModes]), settings.deliveryCharge, Object.freeze(settings.deliveryChargeTaxes.map((tax) => taxFromProto(tax, countryCode))), settings.freeDeliveryTotal, settings.deliveryRadiusKms, stringFromProto(settings.storeTagline), workingDaysFromProto(settings.workingDays), latLongFromProto(settings.primaryStoreLocation), stringFromProto(settings.googleBusinessCategoryId), stringFromProto(settings.googleBusinessCategoryName), stringFromProto(settings.onlineStoreThemeId), settings.onlineShoppingEnabled);
1090
1213
  }
1091
1214
  // #endregion
1092
1215
  // #region Entities
@@ -1131,25 +1254,26 @@ function packageFromProto(packageProto, photos, price) {
1131
1254
  const entity = getEntity(item.entity);
1132
1255
  return new Package(toEntityHeader(entity), item.name, stringFromProto(item.description), item.parentItemId, item.isDisabled, packageProto.isSoldLoose, packageProto.itemCode, Object.freeze(packageProto.identifiers.map(packageIdentifierFromProto)), dimensionsFromProto(packageProto.dimensions), weightFromProto(packageProto.weight), photos, price);
1133
1256
  }
1134
- function productFromProto(productProto, photos, packages) {
1257
+ function productFromProto(productProto, photos, packages, countryCode) {
1135
1258
  const item = getItem(productProto.item);
1136
1259
  const entity = getEntity(item.entity);
1137
- return new Product(toEntityHeader(entity), item.name, stringFromProto(item.description), item.parentItemId, item.isDisabled, productProto.productType, stringFromProto(productProto.brand), booleanFromProto(productProto.isManufacturedByBusiness), stringFromProto(productProto.manufacturer), productProto.manufacturerAddress === undefined
1260
+ return new Product(toEntityHeader(entity), item.name, stringFromProto(item.description), item.parentItemId, item.isDisabled, productProto.productType, stringFromProto(productProto.brand), productProto.isManufacturedByBusiness ?? true, // Absent means true (Product.proto)
1261
+ stringFromProto(productProto.manufacturer), productProto.manufacturerAddress === undefined
1138
1262
  ? null
1139
- : addressFromProto(productProto.manufacturerAddress), Object.freeze(productProto.taxes.map(taxFromProto)), Object.freeze([...productProto.features]), photos, packages);
1263
+ : addressFromProto(productProto.manufacturerAddress), Object.freeze(productProto.taxes.map((tax) => taxFromProto(tax, countryCode))), Object.freeze([...productProto.features]), productProto.forOfflineSaleOnly, photos, packages);
1140
1264
  }
1141
- function orderFromProto(message, salesOrder) {
1265
+ function orderFromProto(message, salesOrder, countryCode) {
1142
1266
  const entity = getEntity(message.entity);
1143
- return Order.fromScaledValues(toEntityHeader(entity), message.senderId, message.status, stringFromProto(message.senderName), stringFromProto(message.senderPhone), salesOrder.status, salesOrder.fulfillmentMode, shoppingListFromProto(salesOrder.shoppingList), salesOrder.shoppingListTotal, salesOrder.shoppingListCount, salesOrder.deliveryAddressId, salesOrder.deliveryAddress === undefined ? null : addressFromProto(salesOrder.deliveryAddress), salesOrder.creationDate, stringFromProto(salesOrder.note), salesOrder.paymentReceived);
1267
+ return Order.fromScaledValues(toEntityHeader(entity), message.senderId, message.status, stringFromProto(message.senderName), stringFromProto(message.senderPhone), salesOrder.status, salesOrder.fulfillmentMode, shoppingListFromProto(salesOrder.shoppingList, countryCode), salesOrder.shoppingListTotal, salesOrder.shoppingListCount, salesOrder.deliveryAddressId, salesOrder.deliveryAddress === undefined ? null : addressFromProto(salesOrder.deliveryAddress), salesOrder.creationDate, stringFromProto(salesOrder.note), salesOrder.paymentReceived);
1144
1268
  }
1145
1269
  // The one part of a cart message a customer is shown. Absent rather than empty when the cart holds
1146
1270
  // nothing, so that "emptied by placing an order" and "never had one" are the same answer - which is
1147
1271
  // also what makes createOrder's emptied cart indistinguishable from a cart that was never created.
1148
1272
  // Everything else the message carries is the server's: who sent it, how far the business has got
1149
1273
  // with reading it, and the fulfillment mode Java keeps there, which belongs to an order here.
1150
- function cartShoppingListFromProto(partyPreferences) {
1274
+ function cartShoppingListFromProto(partyPreferences, countryCode) {
1151
1275
  const shoppingList = partyPreferences.shoppingList;
1152
- return shoppingList === undefined ? null : shoppingListFromProto(shoppingList);
1276
+ return shoppingList === undefined ? null : shoppingListFromProto(shoppingList, countryCode);
1153
1277
  }
1154
1278
  // #endregion
1155
1279
  // #region Photos
@@ -1163,5 +1287,8 @@ function takePhotos(attachmentsByOwner, owningEntityId, attachmentBaseUrl) {
1163
1287
  return Object.freeze(attachments.map((attachment) => new Photo(attachment, attachmentBaseUrl)));
1164
1288
  }
1165
1289
  // #endregion
1166
- const EMPTY_BYTES = new Uint8Array(0);
1290
+ // The width of the updatedAt an eTag carries below its update number. An int64 is never wider.
1291
+ const UPDATED_AT_BITS = 64n;
1292
+ // What a blob that is only its eTag holds
1293
+ const NO_BYTES = new Uint8Array(0);
1167
1294
  const NO_PHOTOS = Object.freeze([]);