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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (91) hide show
  1. package/dist/Address.js +8 -11
  2. package/dist/Attachment.js +7 -8
  3. package/dist/AttachmentBaseUrl.js +10 -10
  4. package/dist/AuthTokens.js +12 -7
  5. package/dist/Business.js +19 -19
  6. package/dist/BusinessIdentifier.js +2 -2
  7. package/dist/BusinessLink.js +16 -16
  8. package/dist/BusinessProfile.js +4 -4
  9. package/dist/CartEditor.d.ts +10 -5
  10. package/dist/CartEditor.js +258 -180
  11. package/dist/Catalog.js +31 -29
  12. package/dist/Constants.js +10 -10
  13. package/dist/ContainerNames.js +10 -11
  14. package/dist/Entity.js +11 -9
  15. package/dist/Enums.d.ts +1 -1
  16. package/dist/Enums.js +4 -3
  17. package/dist/Exceptions.js +2 -2
  18. package/dist/GeneralSettings.d.ts +4 -2
  19. package/dist/GeneralSettings.js +30 -18
  20. package/dist/HttpStatusCodes.js +10 -11
  21. package/dist/Ids.d.ts +2 -0
  22. package/dist/Ids.js +28 -24
  23. package/dist/Item.js +6 -7
  24. package/dist/LatLong.js +3 -3
  25. package/dist/LiveData.js +6 -6
  26. package/dist/Measures.js +2 -2
  27. package/dist/Message.js +6 -5
  28. package/dist/MockDatabase.js +2 -2
  29. package/dist/Model.d.ts +5 -0
  30. package/dist/Model.js +400 -254
  31. package/dist/Order.js +20 -17
  32. package/dist/Package.js +7 -8
  33. package/dist/PackageIdentifier.js +2 -2
  34. package/dist/PaymentMethodDetail.js +4 -4
  35. package/dist/Photo.js +6 -7
  36. package/dist/Price.js +9 -6
  37. package/dist/PriceList.js +2 -2
  38. package/dist/Product.d.ts +5 -3
  39. package/dist/Product.js +26 -19
  40. package/dist/ProductCategory.js +3 -4
  41. package/dist/Profile.d.ts +1 -0
  42. package/dist/Profile.js +94 -8
  43. package/dist/ProtoMap.d.ts +18 -8
  44. package/dist/ProtoMap.js +451 -331
  45. package/dist/Routes.js +3 -3
  46. package/dist/ServiceExceptions.js +11 -12
  47. package/dist/ServiceProxy.js +19 -23
  48. package/dist/ShoppingList.d.ts +30 -1
  49. package/dist/ShoppingList.js +339 -47
  50. package/dist/SyncCache.d.ts +12 -0
  51. package/dist/SyncCache.js +136 -56
  52. package/dist/SyncKeys.js +8 -8
  53. package/dist/SyncState.js +5 -5
  54. package/dist/Tax.d.ts +12 -4
  55. package/dist/Tax.js +37 -13
  56. package/dist/TaxHelper.d.ts +12 -0
  57. package/dist/TaxHelper.js +85 -0
  58. package/dist/TaxType.d.ts +7 -0
  59. package/dist/TaxType.js +31 -0
  60. package/dist/UserProfile.js +4 -4
  61. package/dist/WorkingDays.js +15 -15
  62. package/dist/generated/AttachmentType.d.ts +19 -1
  63. package/dist/generated/AttachmentType.js +18 -0
  64. package/dist/generated/AuthTokens.d.ts +3 -1
  65. package/dist/generated/BusinessLink.d.ts +4 -1
  66. package/dist/generated/BusinessSyncInRequest.d.ts +11 -3
  67. package/dist/generated/BusinessSyncInResponse.d.ts +5 -4
  68. package/dist/generated/BusinessSyncOutRequest.d.ts +37 -10
  69. package/dist/generated/BusinessSyncOutRequest.js +42 -29
  70. package/dist/generated/BusinessSyncOutResponse.d.ts +14 -1
  71. package/dist/generated/Configuration.d.ts +2 -1
  72. package/dist/generated/CreateUserRequest.d.ts +8 -2
  73. package/dist/generated/Entity.d.ts +7 -3
  74. package/dist/generated/GeneralSettings.d.ts +14 -4
  75. package/dist/generated/GeneralSettings.js +9 -1
  76. package/dist/generated/Message.d.ts +4 -2
  77. package/dist/generated/Product.d.ts +10 -1
  78. package/dist/generated/Product.js +9 -1
  79. package/dist/generated/ProfileSyncOutRequest.d.ts +17 -1
  80. package/dist/generated/ProfileSyncOutResponse.d.ts +8 -3
  81. package/dist/generated/SalesOrderPayload.d.ts +7 -2
  82. package/dist/generated/ShoppingList.d.ts +6 -3
  83. package/dist/generated/ShoppingListItem.d.ts +21 -6
  84. package/dist/generated/Tax.d.ts +4 -4
  85. package/dist/generated/Tax.js +7 -8
  86. package/dist/generated/WorkingHoursRange.d.ts +2 -2
  87. package/dist/index.d.ts +2 -2
  88. package/dist/index.js +11 -10
  89. package/package.json +55 -55
  90. package/dist/generated/TaxType.d.ts +0 -25
  91. package/dist/generated/TaxType.js +0 -30
package/dist/ProtoMap.js CHANGED
@@ -62,7 +62,8 @@ import Tax from "./Tax";
62
62
  import UserProfile from "./UserProfile";
63
63
  import WorkingDays, { WorkingHours, WorkingHoursRange } from "./WorkingDays";
64
64
  // #region A response and the blobs it is cached as
65
- // Every entity of a response, in declaration order (parents before children). A tombstone is included, flagged.
65
+ // Every entity of a response, in declaration order (parents before children). Tombstones are
66
+ // included, flagged.
66
67
  export function getBusinessResponseEntities(response) {
67
68
  const result = [];
68
69
  if (response.business !== undefined)
@@ -85,8 +86,8 @@ export function getBusinessResponseEntities(response) {
85
86
  result.push(toItemWireEntity(price.item, proto_Price.toBinary(price)));
86
87
  return result;
87
88
  }
88
- // Every entity of a profile sync out response: the caller, their links, and the businesses those links name.
89
- // The three have separate cursors, so unlike a business response the order between the arrays does not matter.
89
+ // Every entity of a profile response: the caller, their links, and the businesses those name. The
90
+ // three have separate cursors, so unlike a business response the array order does not matter.
90
91
  export function getProfileResponseEntities(response) {
91
92
  const result = [];
92
93
  if (response.user !== undefined)
@@ -97,13 +98,12 @@ export function getProfileResponseEntities(response) {
97
98
  result.push(toWireEntity(business.entity, proto_Business.toBinary(business)));
98
99
  return result;
99
100
  }
100
- // What a business response is cached as: one blob per live entity, the ids its tombstones named,
101
- // and the attachment base url in a blob of its own, since it belongs to the business rather than to
102
- // any entity of it.
101
+ // One blob per entity, tombstones included and as sent, plus the attachment base url in a blob of its
102
+ // own (it belongs to the business, not to an entity).
103
103
  export function businessResponseToCacheWrite(response) {
104
104
  const write = toCacheWrite(getBusinessResponseEntities(response));
105
- // Its eTag is its expiry, which is exactly its cursor: a client sends back the expiry it holds
106
- // and is sent a later url only when one exists
105
+ // Its eTag is its expiry, which is also its cursor: a client sends back the expiry it holds and
106
+ // gets a later url only if one exists.
107
107
  const attachmentBaseUrl = response.attachmentBaseUrl;
108
108
  if (attachmentBaseUrl !== undefined)
109
109
  write.blobs.push({
@@ -113,43 +113,44 @@ export function businessResponseToCacheWrite(response) {
113
113
  });
114
114
  return write;
115
115
  }
116
- // The same for a profile response, which carries nothing but entities.
116
+ // The same for a profile response, which holds only entities.
117
117
  export function profileResponseToCacheWrite(response) {
118
118
  return toCacheWrite(getProfileResponseEntities(response));
119
119
  }
120
- // The expiry of the attachment base url a response carried, and zero when it carried none, which
121
- // is exactly what the next request sends back.
122
- // Here rather than at a call site, because reading a field of a response is this file's job.
120
+ // The expiry of the attachment base url a response carried, or zero when none. The next request
121
+ // sends it back.
123
122
  export function getBusinessResponseAttachmentBaseUrlExpiry(response) {
124
123
  return response?.attachmentBaseUrl?.expiry ?? 0n;
125
124
  }
126
- // One order as its own blob, for a caller holding a message rather than a whole response. The
127
- // payload is the only thing that says which a message is, so one carrying the other is refused
128
- // here rather than stored where a read would never find it.
129
- export function orderToBlob(order) {
125
+ // One order as its own blob, for a caller holding a message rather than a response. Only the payload
126
+ // says what a message is, so a non-order is refused here instead of stored where a read would not
127
+ // find it. heldETag is the eTag the container holds the order at, or null - see messageToBlob.
128
+ export function orderToBlob(order, heldETag) {
130
129
  if (order.payload.oneofKind !== "salesOrder")
131
130
  throw new InvalidArgumentException("That message carries no sales order, so it is not an order");
132
- return messageToBlob(order);
131
+ return messageToBlob(order, heldETag);
133
132
  }
134
- // Whether a message carries party preferences. Here rather than at a call site, because reading a
135
- // field of a message is this file's job.
133
+ // Whether a message carries party preferences.
136
134
  export function isPartyPreferencesMessage(message) {
137
135
  return message.payload.oneofKind === "partyPreferences";
138
136
  }
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.
137
+ // When a message was last written. It compares two copies of one thing held at different ids, which
138
+ // have no shared version.
141
139
  export function getMessageUpdatedAt(message) {
142
140
  return getEntity(message.entity).updatedAt;
143
141
  }
144
- export function partyPreferencesToBlob(partyPreferences) {
142
+ export function getMessageId(message) {
143
+ return getEntity(message.entity).id;
144
+ }
145
+ // heldETag as for orderToBlob
146
+ export function partyPreferencesToBlob(partyPreferences, heldETag) {
145
147
  if (partyPreferences.payload.oneofKind !== "partyPreferences")
146
148
  throw new InvalidArgumentException("That message carries no party preferences");
147
- return messageToBlob(partyPreferences);
149
+ return messageToBlob(partyPreferences, heldETag);
148
150
  }
149
- // The response the blobs of a business container make up: everything cached for that business, in
150
- // the shape the call that brought it returned, and null when they hold nothing of it yet.
151
- // The entity type in each blob id sorts them, so no stored type tag is needed; one this library
152
- // does not know is skipped rather than refused, so a newer server cannot break an older client.
151
+ // Rebuilds the response a business container's blobs make up, as the call returned it. Null when they
152
+ // hold nothing yet. The entity type in each blob id sorts them, so no type tag is stored. An unknown
153
+ // type is skipped, so a newer server cannot break an older client.
153
154
  export function businessResponseFromBlobs(blobs) {
154
155
  const response = proto_BusinessSyncOutResponse.create({});
155
156
  let hasContent = false;
@@ -196,7 +197,7 @@ export function businessResponseFromBlobs(blobs) {
196
197
  }
197
198
  return hasContent ? response : null;
198
199
  }
199
- // The same for a profile container, whose three entity types are siblings rather than a hierarchy.
200
+ // The same for a profile container, whose three entity types are siblings.
200
201
  export function profileResponseFromBlobs(blobs) {
201
202
  const response = proto_ProfileSyncOutResponse.create({});
202
203
  let hasContent = false;
@@ -220,25 +221,66 @@ export function profileResponseFromBlobs(blobs) {
220
221
  }
221
222
  return hasContent ? response : null;
222
223
  }
223
- // The orders a container holds. The payload a message carries is what says whether it is an
224
- // order or a cart - Message.messageType is not read, because here the payload is typed.
225
- export function orderMessagesFromBlobs(blobs) {
226
- const result = [];
227
- for (const message of messagesFromBlobs(blobs))
228
- if (message.payload.oneofKind === "salesOrder")
229
- result.push(message);
230
- return result;
224
+ // The tombstones a business container holds, as a response, each as stored (and so as sent). Null
225
+ // when it holds none. The attachment base url belongs to no entity, so it is left out.
226
+ export function deletedBusinessResponseFromBlobs(blobs) {
227
+ const held = businessResponseFromBlobs(blobs);
228
+ if (held === null)
229
+ return null;
230
+ const deleted = proto_BusinessSyncOutResponse.create({
231
+ business: held.business !== undefined && isDeletedEntity(getEntity(held.business.entity))
232
+ ? held.business
233
+ : undefined,
234
+ attachments: held.attachments.filter((attachment) => isDeletedEntity(getEntity(attachment.entity))),
235
+ configurations: held.configurations.filter((configuration) => isDeletedEntity(getEntity(configuration.entity))),
236
+ messages: held.messages.filter((message) => isDeletedEntity(getEntity(message.entity))),
237
+ productCategories: held.productCategories.filter((category) => isDeletedItem(category.item)),
238
+ products: held.products.filter((product) => isDeletedItem(product.item)),
239
+ packages: held.packages.filter((pkg) => isDeletedItem(pkg.item)),
240
+ priceLists: held.priceLists.filter((priceList) => isDeletedItem(priceList.item)),
241
+ prices: held.prices.filter((price) => isDeletedItem(price.item)),
242
+ });
243
+ return businessResponseEntityCount(deleted) === 0 ? null : deleted;
231
244
  }
232
- // The party preferences message among messages already in hand, or null when none of them is
233
- // one. A customer has one per business, so two means two callers' messages are in one container -
234
- // the most recently updated then wins for want of anything better: nothing in a message says
235
- // which caller it was sent to - see THE MESSAGES OF A BUSINESS ARE NOT PUBLIC.
236
- // Split out of partyPreferencesMessageFromBlobs so a caller already holding a response's messages
237
- // - such as Model.businessSyncIn folding the anonymous container in - can settle its cart without
238
- // asking the store for the same blobs again.
245
+ // The same for a profile container.
246
+ export function deletedProfileResponseFromBlobs(blobs) {
247
+ const held = profileResponseFromBlobs(blobs);
248
+ if (held === null)
249
+ return null;
250
+ const deleted = proto_ProfileSyncOutResponse.create({
251
+ user: held.user !== undefined && isDeletedEntity(getEntity(held.user.entity)) ? held.user : undefined,
252
+ businessLinks: held.businessLinks.filter((businessLink) => isDeletedEntity(getEntity(businessLink.entity))),
253
+ businesses: held.businesses.filter((business) => isDeletedEntity(getEntity(business.entity))),
254
+ });
255
+ return profileResponseEntityCount(deleted) === 0 ? null : deleted;
256
+ }
257
+ // The open orders a container holds, which the business has not completed. The payload says whether
258
+ // a message is an order or a cart. Message.messageType is not read, since the payload is typed.
259
+ export function orderMessagesFromBlobs(blobs) {
260
+ return ordersAmong(messagesFromBlobs(blobs, false));
261
+ }
262
+ // The orders the business has completed, by fulfilling or cancelling. The merchant app completes an
263
+ // order by deleting its message, so these are tombstones, but a server sends them whole because a
264
+ // customer is shown the order afterwards. A tombstone with no order (left by an older server that
265
+ // sent only the header) is not one.
266
+ export function deletedOrderMessagesFromBlobs(blobs) {
267
+ return ordersAmong(messagesFromBlobs(blobs, true));
268
+ }
269
+ function ordersAmong(messages) {
270
+ return messages.filter((message) => message.payload.oneofKind === "salesOrder");
271
+ }
272
+ // The party preferences message among these messages, or null. A customer has one per business, so
273
+ // two means two callers' messages share a container. The greater update number wins for want of
274
+ // anything better, since nothing in a message says which caller it was sent to. See THE MESSAGES OF
275
+ // A BUSINESS ARE NOT PUBLIC.
276
+ // Split from partyPreferencesMessageFromBlobs so a caller already holding the messages (such as
277
+ // Model.businessSyncIn folding the anonymous container in) need not read the blobs again.
278
+ // buildBusiness uses it too, so the cart shown, amended and carried over a sign in is one message.
239
279
  export function partyPreferencesMessageFromMessages(messages) {
240
280
  let result = null;
241
281
  for (const message of messages) {
282
+ if (isDeletedEntity(getEntity(message.entity)))
283
+ continue; // A tombstone is not a cart
242
284
  if (message.payload.oneofKind !== "partyPreferences")
243
285
  continue;
244
286
  if (result === null ||
@@ -254,11 +296,20 @@ export function partyPreferencesMessageFromBlobs(blobs) {
254
296
  }
255
297
  // #endregion
256
298
  // #region Requests and credentials
257
- // The request that asks for everything changed since these cursors.
258
- // 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) {
299
+ // The request for everything changed since these cursors. A bigint business is the Business entity
300
+ // id, which a caller that has read it before has. A string is its url friendly businessId, which is
301
+ // all a first read has. attachmentBaseUrlExpiry is not a cursor but the expiry of the url already
302
+ // held, sent back unchanged (zero when none).
303
+ export function buildBusinessSyncOutRequest(business, syncInState, attachmentBaseUrlExpiry) {
304
+ return createBusinessSyncOutRequest(typeof business === "bigint"
305
+ ? { oneofKind: "businessEntityId", businessEntityId: business }
306
+ : { oneofKind: "businessId", businessId: business }, syncInState, attachmentBaseUrlExpiry);
307
+ }
308
+ // Takes the business as the request holds it, so the request after a page names it the same way as
309
+ // the one before.
310
+ function createBusinessSyncOutRequest(business, syncInState, attachmentBaseUrlExpiry) {
260
311
  return proto_BusinessSyncOutRequest.create({
261
- businessId: businessId,
312
+ business: business,
262
313
  businessUpdateNumber: syncInState.getUpdateNumber(proto_Constants.BUSINESS_ENTITY_TYPE_ID),
263
314
  attachmentUpdateNumber: syncInState.getUpdateNumber(proto_Constants.ATTACHMENT_ENTITY_TYPE_ID),
264
315
  attachmentBaseUrlExpiry: attachmentBaseUrlExpiry,
@@ -267,40 +318,58 @@ export function buildBusinessSyncOutRequest(businessId, syncInState, attachmentB
267
318
  itemUpdateNumber: syncInState.getUpdateNumber(proto_Constants.ITEM_ENTITY_TYPE_ID),
268
319
  });
269
320
  }
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));
279
- }
280
- // The request that pushes back what this customer has changed. Messages are the only entity type a
281
- // storefront may push, and one request is one batch: an order and the cart it emptied go together,
282
- // 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.
321
+ // How far a container has been read, as one blob per moved key at its own id - see
322
+ // Ids.getSyncCursorBlobId. The cursor is the eTag, so there are no bytes. A cursor only rises: the
323
+ // store refuses a blob that is not greater, so a stale or repeated page, or another tab writing at
324
+ // the same time, cannot lower it.
325
+ export function cursorsToBlobs(cursors, movedKeys) {
326
+ const blobs = [];
327
+ for (const key of movedKeys)
328
+ blobs.push({
329
+ id: Ids.getSyncCursorBlobId(key),
330
+ eTag: cursors.getUpdateNumber(key),
331
+ bytes: NO_BYTES,
332
+ });
333
+ return blobs;
334
+ }
335
+ // The cursors a container holds, from the blobs at the cursor ids of keys (in key order, null where
336
+ // none). A container never read, or filled by a version that kept no cursors, gets zeros and asks for
337
+ // everything again. That costs bandwidth only, since the store keeps a blob only when its eTag is
338
+ // greater.
339
+ export function cursorsFromBlobs(keys, blobs) {
340
+ const cursors = new SyncState(keys);
341
+ for (const [index, key] of keys.entries()) {
342
+ const eTag = blobs[index]?.eTag ?? null;
343
+ if (eTag !== null)
344
+ cursors.raiseUpdateNumber(key, eTag);
345
+ }
346
+ return cursors;
347
+ }
348
+ // Raises the cursors to what a page of a business read brought, tombstones included, and answers the
349
+ // keys that moved. Only a read moves them. What this client pushed does not.
350
+ export function raiseBusinessCursors(cursors, response) {
351
+ return raiseCursorsFrom(cursors, getBusinessResponseEntities(response));
352
+ }
353
+ // The request that pushes what this customer changed. A storefront may push only messages, and one
354
+ // request is one batch, so an order and the cart it emptied go together and a customer is never left
355
+ // holding the order and a still full cart. messageUpdateNumber is how far reads of this container
356
+ // have got with messages - see SyncCache.readMessageUpdateNumber.
286
357
  export function buildBusinessSyncInRequest(messageUpdateNumber, messages) {
287
358
  return proto_BusinessSyncInRequest.create({
288
359
  messageUpdateNumber: messageUpdateNumber,
289
360
  messages: [...messages],
290
361
  });
291
362
  }
292
- // 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.
363
+ // The messages a sync in response carried: this push's own, with the update numbers the server
364
+ // assigned. Those numbers are not how far a read has got.
294
365
  export function getSyncInResponseMessages(response) {
295
366
  return response.messages;
296
367
  }
297
- // The same, for the profile of the caller - names no business, since a profile sync is not scoped
298
- // to one and has no sync in.
299
- // joinBusinessId is the exception, and it is not a scope but an instruction: make the caller a
300
- // customer of that business, by entity id, while answering. Constants.ENTITY_ID_NULL asks for no
301
- // join, which is what every ordinary read carries, and it is the default for that reason. A join
302
- // belongs to one call and not to a read, so nextProfileSyncOutRequest leaves it off the pages that
303
- // follow - see there.
368
+ // The request for everything changed in the caller's profile since these cursors. It names no
369
+ // business, since a profile sync is not scoped to one and has no sync in. joinBusinessId tells the
370
+ // server to make the caller a customer of that business, by entity id, while answering.
371
+ // Constants.ENTITY_ID_NULL asks for no join and is the default, since an ordinary read joins
372
+ // nothing. A join belongs to one call, so nextProfileSyncOutRequest leaves it off later pages.
304
373
  export function buildProfileSyncOutRequest(syncInState, joinBusinessId = BigInt(proto_Constants.ENTITY_ID_NULL)) {
305
374
  return proto_ProfileSyncOutRequest.create({
306
375
  userUpdateNumber: syncInState.getUpdateNumber(proto_Constants.USER_ENTITY_TYPE_ID),
@@ -309,18 +378,12 @@ export function buildProfileSyncOutRequest(syncInState, joinBusinessId = BigInt(
309
378
  joinBusinessId: joinBusinessId,
310
379
  });
311
380
  }
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);
320
- }
321
- // The user a sign up asks the server to create, as CreateUserRequest.user. Built from the domain
322
- // object rather than carrying one of its own: this user has no entity yet - the server assigns id,
323
- // createdAt and updatedAt on creation - so none of EntityHeader goes out here.
381
+ // The same for a profile read
382
+ export function raiseProfileCursors(cursors, response) {
383
+ return raiseCursorsFrom(cursors, getProfileResponseEntities(response));
384
+ }
385
+ // The user a sign up asks the server to create, as CreateUserRequest.user. It has no entity yet (the
386
+ // server assigns id, createdAt and updatedAt), so none of EntityHeader goes out.
324
387
  export function buildUser(userProfile) {
325
388
  return proto_User.create({
326
389
  firstName: userProfile.firstName,
@@ -330,22 +393,21 @@ export function buildUser(userProfile) {
330
393
  preferredLanguageCode: stringToProto(userProfile.preferredLanguageCode),
331
394
  });
332
395
  }
333
- // The user Service.updateUser asks the server to overwrite this customer's profile with - the same
334
- // fields as buildUser, plus the one field that call matches the caller by: its own entity id.
335
- // Nothing else of EntityHeader goes out, because nothing else of it is read on that call.
396
+ // The user Service.updateUser overwrites this customer's profile with: the fields of buildUser plus
397
+ // the entity id, which the call matches the caller by. The rest of EntityHeader is not read on that
398
+ // call, so it is not sent.
336
399
  export function buildUpdateUser(userProfile) {
337
400
  return proto_User.create({
338
401
  ...buildUser(userProfile),
339
402
  entity: proto_Entity.create({ id: userProfile.id }),
340
403
  });
341
404
  }
342
- // The two halves of what a join must be answered with, each asked separately so that a caller
343
- // reporting a server that answered without one can say which was missing - see
344
- // Model.requireJoinedBusiness. Both take the id rather than the request, because both of the
345
- // requests that carry a joinBusinessId need them.
346
- // The membership itself: a live BusinessLink naming that business. A tombstoned one is not a
347
- // membership - see OBJECT MODEL - so it does not count, and neither does a link to any other
348
- // business. Its linkToken is the only copy of that credential a profile response carries.
405
+ // The two things a join must be answered with, checked separately so a caller can report which one a
406
+ // server left out - see Model.requireJoinedBusiness. They take the id, since both requests that
407
+ // carry a joinBusinessId need them.
408
+ // The membership itself: a live BusinessLink naming that business. A tombstoned link does not count
409
+ // (see OBJECT MODEL), nor does a link to another business. Its linkToken is the only copy of that
410
+ // credential a profile response carries.
349
411
  export function hasJoinedBusinessLink(joinBusinessId, response) {
350
412
  if (joinBusinessId === BigInt(proto_Constants.ENTITY_ID_NULL))
351
413
  return false;
@@ -355,20 +417,70 @@ export function hasJoinedBusinessLink(joinBusinessId, response) {
355
417
  return true;
356
418
  return false;
357
419
  }
358
- // The business that link names, matched on Business.entity.id - which is what
359
- // BusinessLink.businessId is, and not Business.businessId, the url friendly string. A caller needs
360
- // it as well as the link, because the string is what every later request and container name for
361
- // that business is built out of, and the link carries only the id.
420
+ // The business the link names, matched on Business.entity.id, which BusinessLink.businessId holds
421
+ // (Business.businessId is the url friendly string). A caller needs the business as well as the link,
422
+ // because every later request and container name is built from that string and the link has only
423
+ // the id.
362
424
  export function hasJoinedBusiness(joinBusinessId, response) {
363
425
  if (joinBusinessId === BigInt(proto_Constants.ENTITY_ID_NULL))
364
426
  return false;
427
+ // A tombstone is not the business, which is what the join is answered with
365
428
  for (const business of response.businesses)
366
- if (getEntity(business.entity).id === joinBusinessId)
429
+ if (getEntity(business.entity).id === joinBusinessId &&
430
+ !isDeletedEntity(getEntity(business.entity)))
367
431
  return true;
368
432
  return false;
369
433
  }
370
- // The credentials of one call, as the wire carries them. An absent token is left off the message
371
- // rather than sent empty, because the protocol tells the two apart.
434
+ // The country of the business a response holds, ISO 3166-1 numeric, or undefined when it holds none.
435
+ // A tombstone has no country, and a bare header carries zero, which must not be taken for one: a
436
+ // cart's taxes are raw codes that mean something only within a country.
437
+ export function getBusinessResponseCountryCode(response) {
438
+ const business = response?.business;
439
+ if (business === undefined || isDeletedEntity(getEntity(business.entity)))
440
+ return undefined;
441
+ return business.countryCode;
442
+ }
443
+ // The entity id of the business a response holds, or null when it holds none or only a tombstone.
444
+ // Once a business has been read, later requests name it by this id: it is stable, and the url
445
+ // friendly id is only how it was first found.
446
+ export function getBusinessResponseBusinessEntityId(response) {
447
+ const entity = response?.business?.entity;
448
+ if (entity === undefined || isDeletedEntity(entity))
449
+ return null;
450
+ return entity.id;
451
+ }
452
+ // The url friendly id of the business a response holds (the one a business is found by, not its
453
+ // entity id), or null when it holds none or only a tombstone.
454
+ export function getBusinessResponseBusinessId(response) {
455
+ const business = response?.business;
456
+ if (business === undefined || isDeletedEntity(getEntity(business.entity)))
457
+ return null;
458
+ return business.businessId;
459
+ }
460
+ // Whether the general settings a response holds turn online shopping on. Missing settings (never
461
+ // made, deleted, or not read yet) count as off. The server sends a business's data whether or not
462
+ // shopping is on, so the client decides from what it holds once it has read everything.
463
+ export function isBusinessResponseOnlineShoppingOn(response) {
464
+ if (response === null)
465
+ return false;
466
+ return getGeneralSettingsOf(response)?.onlineShoppingEnabled ?? false;
467
+ }
468
+ // The general settings a response holds, as on the wire, or undefined when none: the one public
469
+ // configuration that carries them, unless it is deleted. The business built from them and whether
470
+ // shopping is on both read from here, so they cannot be about different settings.
471
+ function getGeneralSettingsOf(response) {
472
+ let settings;
473
+ for (const configuration of response.configurations) {
474
+ if (isDeletedEntity(getEntity(configuration.entity)))
475
+ continue;
476
+ if (configuration.data.oneofKind !== "generalSettings")
477
+ continue; // Not a configuration this library understands
478
+ settings = configuration.data.generalSettings;
479
+ }
480
+ return settings;
481
+ }
482
+ // The credentials of one call, as on the wire. An absent token is left off the message, since the
483
+ // protocol tells absent from empty.
372
484
  export function buildAuthTokens(authTokens) {
373
485
  const result = proto_AuthTokens.create({
374
486
  authenticationMethod: authTokens.authenticationMethod,
@@ -382,21 +494,19 @@ export function buildAuthTokens(authTokens) {
382
494
  result.linkToken = linkToken;
383
495
  return result;
384
496
  }
385
- // What a server issued while answering a call, in the terms this library holds credentials in
497
+ // What a server issued while answering a call, as this library's AuthTokens
386
498
  export function authTokensFromProto(authTokens) {
387
499
  return new AuthTokens(authTokens.authenticationMethod, authTokens.authenticationToken, stringFromProto(authTokens.userToken), stringFromProto(authTokens.linkToken));
388
500
  }
389
- // The authorization header of a request, encoded. Lives here because this is the only file that
390
- // writes a proto field; a transport puts these bytes in the header however it wants.
501
+ // The encoded authorization header of a request. It lives here because this file writes the proto
502
+ // fields. A transport puts the bytes in the header.
391
503
  export function buildAuthTokensBytes(authTokens) {
392
504
  return proto_AuthTokens.toBinary(authTokens);
393
505
  }
394
- // How a container keeps the credentials it earned: the two tokens this server issued, with a
395
- // null eTag because a reissued token is not a later version of an earlier one and greater-wins
396
- // would refuse it.
397
- // The authentication token is left out however it was passed in: it belongs to the identity
398
- // provider, so a stored copy could only be staler than the one held in memory and would outlive
399
- // the sign out that should have ended it.
506
+ // How a container keeps the credentials it earned: the two tokens this server issued. The eTag is
507
+ // null because a reissued token is not a later version of the old one, and greater-wins would refuse
508
+ // it. The authentication token is never stored: it belongs to the identity provider, so a copy would
509
+ // be staler than the one in memory and would outlive the sign out.
400
510
  export function authTokensToBlob(authTokens) {
401
511
  const stored = proto_AuthTokens.create({
402
512
  authenticationMethod: authTokens.authenticationMethod,
@@ -418,8 +528,7 @@ export function authTokensFromBytes(bytes) {
418
528
  }
419
529
  // #endregion
420
530
  // #region Paging
421
- // What the server issued while answering, which every response of this protocol carries in the same
422
- // field - the two sync outs and a sync in alike.
531
+ // What the server issued while answering. All three response types carry it in the same field.
423
532
  export function getResponseAuthTokens(response) {
424
533
  return response.authTokens;
425
534
  }
@@ -433,13 +542,13 @@ export function refreshAuthTokens(authTokens, issued) {
433
542
  result.linkToken = issued.linkToken;
434
543
  return result;
435
544
  }
436
- // The request that asks for the page after this response, or null when this response was the last.
437
- // A response carrying a full page has been cut short, so the cursors are raised from what arrived
438
- // and the call is repeated. A full page that raised no cursor at all can only come from a server
439
- // breaking the ascending order it promises; the loop is unbounded, so it stops rather than trust it.
545
+ // The request for the page after this response, or null when it was the last. A full page was cut
546
+ // short, so the cursors are raised from what arrived and the call repeats. A full page that raised no
547
+ // cursor means the server broke the ascending order it promises. The loop is unbounded, so it stops
548
+ // there.
440
549
  export function nextBusinessSyncOutRequest(request, response) {
441
- // Anything short of a full page is the server saying it has sent everything it had. Counted
442
- // before anything is looked at, so the ordinary answer costs no more than reading nine lengths.
550
+ // A short page means the server sent everything. Counting first keeps the ordinary case to nine
551
+ // length reads.
443
552
  if (businessResponseEntityCount(response) < proto_Constants.SYNC_OUT_LIMIT)
444
553
  return null;
445
554
  const cursors = businessSyncOutCursors(request);
@@ -470,10 +579,10 @@ export function nextBusinessSyncOutRequest(request, response) {
470
579
  return null;
471
580
  // A page that renewed the url means a later one is now held; otherwise what was sent still stands
472
581
  const renewedExpiry = getBusinessResponseAttachmentBaseUrlExpiry(response);
473
- return buildBusinessSyncOutRequest(request.businessId, cursors, renewedExpiry === 0n ? request.attachmentBaseUrlExpiry : renewedExpiry);
582
+ return createBusinessSyncOutRequest(request.business, cursors, renewedExpiry === 0n ? request.attachmentBaseUrlExpiry : renewedExpiry);
474
583
  }
475
- // The same for a profile read. Any joinBusinessId the first request carried is deliberately left
476
- // off the ones that follow: a join is done once, by the call that asked for it.
584
+ // The same for a profile read. A joinBusinessId on the first request is left off the later ones,
585
+ // since the call that asked for the join does it once.
477
586
  export function nextProfileSyncOutRequest(request, response) {
478
587
  if (profileResponseEntityCount(response) < proto_Constants.SYNC_OUT_LIMIT)
479
588
  return null;
@@ -487,15 +596,13 @@ export function nextProfileSyncOutRequest(request, response) {
487
596
  for (const business of response.businesses)
488
597
  if (raiseCursor(cursors, business.entity))
489
598
  haveCursorsRisen = true;
490
- // A full page that raised nothing can only come from a server breaking the ascending order it
491
- // promises; the loop is unbounded, so it stops rather than trust it
599
+ // A full page that raised no cursor ends the loop, as in nextBusinessSyncOutRequest
492
600
  if (!haveCursorsRisen)
493
601
  return null;
494
602
  return buildProfileSyncOutRequest(cursors);
495
603
  }
496
- // How many entities a response carried, without decoding or encoding any of them - a paging loop
497
- // asks this of every page, and getBusinessResponseEntities answers the same question only by encoding each
498
- // entity for the cache on the way past.
604
+ // How many entities a response carried, without encoding any. A paging loop asks this of every page,
605
+ // and getBusinessResponseEntities would encode each entity to answer it.
499
606
  function businessResponseEntityCount(response) {
500
607
  return ((response.business === undefined ? 0 : 1) +
501
608
  response.attachments.length +
@@ -512,15 +619,24 @@ function profileResponseEntityCount(response) {
512
619
  response.businessLinks.length +
513
620
  response.businesses.length);
514
621
  }
515
- // Raises the cursor one entity advances, and answers whether it moved. A tombstone counts exactly
516
- // as a value does - skipping one would have the server send it again for ever.
622
+ // Raises the cursors the entities of one page advance and answers the keys that moved, one per table
623
+ // however many entities raised it. A tombstone counts like any entity, as in raiseCursor.
624
+ function raiseCursorsFrom(cursors, entities) {
625
+ const movedKeys = new Set();
626
+ for (const entity of entities)
627
+ if (cursors.raiseUpdateNumber(entity.entityTypeId, entity.updateNumber))
628
+ movedKeys.add(SyncKeys.keyOf(entity.entityTypeId));
629
+ return movedKeys;
630
+ }
631
+ // Raises the cursor one entity advances and answers whether it moved. A tombstone counts, or the
632
+ // server would send it again for ever.
517
633
  function raiseCursor(cursors, entity) {
518
634
  if (entity === undefined)
519
635
  return false;
520
636
  return cursors.raiseUpdateNumber(Ids.getEntityTypeId(entity.id), entity.updateNumber);
521
637
  }
522
- // The cursors a request is carrying, as a state that can be raised - the inverse of the two builders
523
- // above, and how a paging loop carries what it has already been sent into the call after it.
638
+ // The cursors a request carries, as a state that can be raised: the inverse of the request builders.
639
+ // A paging loop uses it to carry them into the next call.
524
640
  function businessSyncOutCursors(request) {
525
641
  const cursors = new SyncState(SyncKeys.BUSINESS_SYNC_IN);
526
642
  cursors.raiseUpdateNumber(proto_Constants.BUSINESS_ENTITY_TYPE_ID, request.businessUpdateNumber);
@@ -539,18 +655,20 @@ function profileSyncOutCursors(request) {
539
655
  }
540
656
  // #endregion
541
657
  // #region A response as an object graph
542
- // The whole of one business as an object graph, regrouping the flat wire entities by owner id so
543
- // each class can be constructed with its children.
544
- // An entity whose parent is missing is dropped, since a paged read can hold a child whose parent
545
- // has yet to arrive; null when the response carries no Business at all.
658
+ // One business as an object graph, regrouping the flat wire entities by owner id so each class is
659
+ // built with its children. An entity whose parent is missing is dropped, since a paged read can hold
660
+ // a child before its parent arrives. Null when the response has no live Business.
546
661
  export function buildBusiness(response) {
547
662
  const businessProto = response.business;
548
663
  if (businessProto === undefined || isDeletedEntity(getEntity(businessProto.entity)))
549
664
  return null;
550
665
  const business = businessFromProto(businessProto);
551
666
  const updatableBusiness = business;
552
- // Resolved once here and handed to every Photo built below, since a photo takes the base url in
553
- // effect when it is built rather than looking one up later - see the comment on Photo's constructor.
667
+ // A tax travels as a raw code that means something only within the business's country, so every tax
668
+ // below is read against this one
669
+ const countryCode = businessProto.countryCode;
670
+ // Resolved once and handed to every Photo built below, since a Photo takes the base url when it is
671
+ // built - see Photo's constructor.
554
672
  const attachmentBaseUrlProto = response.attachmentBaseUrl;
555
673
  const attachmentBaseUrl = attachmentBaseUrlProto === undefined ? null : attachmentBaseUrlFromProto(attachmentBaseUrlProto);
556
674
  updatableBusiness.attachmentBaseUrl = attachmentBaseUrl;
@@ -567,19 +685,15 @@ export function buildBusiness(response) {
567
685
  owned.push(attachment);
568
686
  }
569
687
  // 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
- }
688
+ const generalSettings = getGeneralSettingsOf(response);
689
+ if (generalSettings !== undefined)
690
+ updatableBusiness.settings = generalSettingsFromProto(generalSettings, countryCode);
577
691
  // The photos of the store itself
578
692
  updatableBusiness.photos = takePhotos(attachmentsByOwner, business.id, attachmentBaseUrl);
579
- // Prices, by the package each applies to. Only the MeraBills Online list is this storefront's -
580
- // a business prices the same package in several lists, and a price from any other one is not
581
- // what it sells at here, so it is passed over rather than allowed to win a package. Within that
582
- // one list a package has one price; if a second arrives anyway, the first is kept.
693
+ // Prices, by the package each applies to. Only the MeraBills Online list is this storefront's. A
694
+ // business prices a package in several lists, and a price from another list is not what it sells at
695
+ // here, so it is skipped. A package has one price in that list; if a second arrives, the first is
696
+ // kept.
583
697
  const pricesByPackage = new Map();
584
698
  for (const priceProto of response.prices) {
585
699
  if (isDeletedItem(priceProto.item))
@@ -590,10 +704,9 @@ export function buildBusiness(response) {
590
704
  if (!pricesByPackage.has(price.packageId))
591
705
  pricesByPackage.set(price.packageId, price);
592
706
  }
593
- // Packages, by the product each belongs to, built with their photos and their price. A package
594
- // that is not buyable - disabled, unpriced, or priced with a price that is itself disabled - is
595
- // left out here rather than handed to the UI unbuyable: a product-level filter does not, by
596
- // itself, guarantee every package of an otherwise visible product is itself enabled.
707
+ // Packages, by the product each belongs to, built with their photos and price. A package that is not
708
+ // buyable (disabled, unpriced, or priced with a disabled price) is left out. A product-level filter
709
+ // alone would not guarantee that every package of a visible product is enabled.
597
710
  const packagesByProduct = new Map();
598
711
  for (const packageProto of response.packages) {
599
712
  if (isDeletedItem(packageProto.item))
@@ -612,9 +725,9 @@ export function buildBusiness(response) {
612
725
  else
613
726
  siblings.push(pkg);
614
727
  }
615
- // 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.
728
+ // Products, built with their photos and buyable packages, and parented to the business (their
729
+ // category only groups them). A product that is not buyable is left out: disabled, sold only offline
730
+ // (the server sends those, flagged), or left with no buyable package by the filtering above.
618
731
  const products = [];
619
732
  for (const productProto of response.products) {
620
733
  if (isDeletedItem(productProto.item))
@@ -622,11 +735,11 @@ export function buildBusiness(response) {
622
735
  const productId = getItemId(productProto.item);
623
736
  const packages = packagesByProduct.get(productId) ?? [];
624
737
  const productPhotos = takePhotos(attachmentsByOwner, productId, attachmentBaseUrl);
625
- const product = productFromProto(productProto, productPhotos, Object.freeze([...packages]));
738
+ const product = productFromProto(productProto, productPhotos, Object.freeze([...packages]), countryCode);
626
739
  if (!product.isBuyable)
627
740
  continue;
628
741
  UpdatableEntity.setParent(product, business);
629
- for (const pkg of packages)
742
+ for (const pkg of product.packages)
630
743
  UpdatableEntity.setParent(pkg, product);
631
744
  products.push(product);
632
745
  }
@@ -638,9 +751,9 @@ export function buildBusiness(response) {
638
751
  UpdatableEntity.setParent(productCategory, business);
639
752
  productCategories.push(productCategory);
640
753
  }
641
- // The one list a storefront sells from is MeraBills Online, named rather than taken to be
642
- // whichever list arrived first: the same sync can carry others, and the catalog has to answer
643
- // with the list the prices above came from or a cart would name one they did not.
754
+ // The one list a storefront sells from is MeraBills Online, picked by id since the same sync can
755
+ // carry others. The catalog must answer with the list the prices above came from, or a cart would
756
+ // name one they are not in.
644
757
  let priceList = null;
645
758
  for (const priceListProto of response.priceLists) {
646
759
  if (isDeletedItem(priceListProto.item))
@@ -652,38 +765,31 @@ export function buildBusiness(response) {
652
765
  break;
653
766
  }
654
767
  updatableBusiness.catalog = new Catalog(productCategories, products, priceList);
655
- // The orders of this customer, and their party preferences: the cart, and the fulfillment mode
656
- // to default their next order to. A message of any other payload type is dropped.
768
+ // The orders of this customer, and their party preferences (the cart and the fulfillment mode to
769
+ // default the next order to). A message of any other payload type is dropped. The business completes
770
+ // an order by deleting its message, and the server sends that message whole, so the orders are the
771
+ // open and the completed ones, told apart by Order.isDeleted. A deleted message with no order (from
772
+ // an older server that sent only the header) is not an order.
657
773
  const orders = [];
774
+ for (const message of response.messages) {
775
+ // Party preferences are one message out of possibly several, so they are settled below
776
+ if (message.payload.oneofKind === "salesOrder")
777
+ orders.push(orderFromProto(message, message.payload.salesOrder, countryCode));
778
+ }
779
+ // A customer has one party preferences message, so two means two callers' messages share a
780
+ // container. Take the one partyPreferencesMessageFromMessages picks, which is also what an amended or
781
+ // resent cart is read from. A rule of its own here would show one cart and amend another. The newer
782
+ // one wins even when it is the emptied one, and the older one's list is not shown.
783
+ const partyPreferences = partyPreferencesMessageFromMessages(response.messages);
658
784
  let cart = null;
659
- let cartUpdateNumber = 0n;
660
785
  let defaultFulfillmentMode = proto_FulfillmentMode.DELIVERY;
661
- for (const message of response.messages) {
662
- if (isDeletedEntity(getEntity(message.entity)))
663
- 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
- }
786
+ if (partyPreferences !== null && partyPreferences.payload.oneofKind === "partyPreferences") {
787
+ // An emptied cart is a message whose payload has no shopping list, which is what placing an order
788
+ // leaves. It answers null, the same as never having had a cart.
789
+ cart = cartShoppingListFromProto(partyPreferences.payload.partyPreferences, countryCode);
790
+ // Taken as the message has it. DELIVERY comes only from the fallback above, even when the message
791
+ // holds UNSPECIFIED_FULFILLMENT_MODE.
792
+ defaultFulfillmentMode = partyPreferences.payload.partyPreferences.fulfillmentMode;
687
793
  }
688
794
  for (const order of orders)
689
795
  UpdatableEntity.setParent(order, business);
@@ -715,55 +821,47 @@ export function buildProfile(profileId, response) {
715
821
  return null;
716
822
  return new Profile(profileId, user, businessLinks, businesses);
717
823
  }
718
- // One order on its own, for a caller holding a message rather than a whole response
719
- export function buildOrder(message) {
824
+ // One order on its own, for a caller holding a message rather than a whole response. The country of
825
+ // the business it was placed with is the caller's to give, since a tax travels as a raw code.
826
+ export function buildOrder(message, countryCode) {
720
827
  if (message.payload.oneofKind !== "salesOrder")
721
828
  return null;
722
- return orderFromProto(message, message.payload.salesOrder);
829
+ return orderFromProto(message, message.payload.salesOrder, countryCode);
723
830
  }
724
- // The shopping list a cart message carries, empty rather than absent when the cart has been
725
- // emptied. Unlike Business.cart (see the party preferences case of buildBusiness, which answers
726
- // null so "emptied" and "never had one" look the same to a customer), a caller here - createOrder,
727
- // the visitor cart carried over on sign in - works with an actual list either way.
728
- export function buildShoppingList(message) {
831
+ // The shopping list a cart message carries, empty when the cart was emptied. Business.cart answers
832
+ // null for that (see buildBusiness), but the callers here, createOrder and the visitor cart carried
833
+ // over on sign in, need an actual list. countryCode is the cart's business, as for buildOrder.
834
+ export function buildShoppingList(message, countryCode) {
729
835
  if (message.payload.oneofKind !== "partyPreferences")
730
836
  throw new InvalidArgumentException("That message carries no party preferences, so it is not a cart");
731
- return cartShoppingListFromProto(message.payload.partyPreferences) ?? ShoppingList.EMPTY;
837
+ return (cartShoppingListFromProto(message.payload.partyPreferences, countryCode) ?? ShoppingList.EMPTY);
732
838
  }
733
839
  // #endregion
734
840
  // #region What a customer pushes
735
841
  //
736
- // The only direction of this file that encodes rather than decodes, and the only entity type a
737
- // storefront may write: a Message, carrying either the cart or an order placed from it. Two rules
738
- // hold for both, and they are what make a pushed message storable here as well as on the server.
739
- // The first is that the id is the client's: a sync in answers with an update number and no id, so a
740
- // message the server named could never be found here again. The second is that updatedAt is the
741
- // client's too and the server keeps it unchanged - so the copy stored here carries the same eTag as
742
- // the copy a later read would bring back, and neither can look stale to the other.
743
- // The party preferences of this customer as a message to push, carrying the cart and (optionally)
744
- // the fulfillment mode both. An existing message is amended rather than rebuilt, because its
745
- // payload can carry fields neither param here speaks to, and losing them would be a storefront
746
- // overwriting data it cannot even read.
747
- // Its id is used in preference to the derived one for the same reason: that is where the server
748
- // already holds this customer's party preferences, and it is only worth deriving an id when there
749
- // is none to amend.
750
- // A list holding nothing is pushed as no list at all, the way Java serializeShoppingList answers
751
- // null below one item - so emptying a cart and never having had one are one state on the wire, and
752
- // placing an order is what puts a cart into it.
753
- // fulfillmentMode is null to leave whatever is already there alone - the ordinary case, since a
754
- // storefront otherwise treats it as the merchant app's own data - and a value only where createOrder
755
- // means to set it, to the mode of the order that just emptied this cart.
842
+ // A storefront writes one entity type, a Message carrying the cart or an order placed from it. Two
843
+ // rules hold for both, so a pushed message is storable here as well as on the server. The id is the
844
+ // client's: a sync in answers with an update number and no id, so a message the server named could
845
+ // not be found here again. updatedAt is the client's too and the server keeps it, so the copy stored
846
+ // here has the same eTag as the one a later read brings back.
847
+ // This customer's party preferences as a message to push, carrying the cart and optionally the
848
+ // fulfillment mode. An existing message is amended, since its payload can carry fields a storefront
849
+ // cannot read, and dropping them would overwrite that data. Its id is kept, as the server holds the
850
+ // preferences under it. The derived id is used only when there is nothing to amend.
851
+ // A list with no items is pushed as no list, as Java serializeShoppingList answers null below one
852
+ // item. Emptying a cart and never having had one are one state on the wire.
853
+ // fulfillmentMode null leaves the existing value alone, the ordinary case: a storefront treats it as
854
+ // the merchant app's data. createOrder sets it to the mode of the order that just emptied the cart.
756
855
  export function buildPartyPreferencesMessage(messageId, existing, senderId, shoppingList, fulfillmentMode, now) {
757
856
  const existingEntity = existing === null ? null : getEntity(existing.entity);
758
857
  const preferences = existing !== null && existing.payload.oneofKind === "partyPreferences"
759
858
  ? existing.payload.partyPreferences
760
859
  : 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.
860
+ // Later than the copy it replaces, whatever this client's clock reads. An amendment written without
861
+ // the server is stored at the update number of the copy it amends, so updatedAt decides. A client
862
+ // clock behind that copy (Constants.MAX_CLOCK_SKEW says it happens) would have its own store refuse
863
+ // the edit and leave the customer looking at a stale cart. One millisecond past the copy is enough,
864
+ // and the server keeps what is sent, so both sides agree.
767
865
  const updatedAt = existingEntity === null || existingEntity.updatedAt < now ? now : existingEntity.updatedAt + 1n;
768
866
  const partyPreferences = proto_PartyPreferencesPayload.create({
769
867
  ...preferences,
@@ -779,13 +877,12 @@ export function buildPartyPreferencesMessage(messageId, existing, senderId, shop
779
877
  payload: { oneofKind: "partyPreferences", partyPreferences: partyPreferences },
780
878
  });
781
879
  }
782
- // One order as a message to push. Its shopping list goes out final whatever was passed in, which is
783
- // what Java SalesOrderViewModel.saveChanges does before creating one: an order is what a customer
784
- // has finished choosing, and only a final list tracks a later change as a change rather than
785
- // silently altering what was ordered.
786
- // The two totals are filled in here from the list, and are what a fulfilled order is left with once
787
- // the business has cleared the list itself - so they are written off the items actually being sent
788
- // rather than off the counts the list carries, which a caller could have set to anything.
880
+ // One order as a message to push. Its shopping list goes out final whatever the list says, as Java
881
+ // SalesOrderViewModel.saveChanges does before creating an order: the customer has finished choosing,
882
+ // and only a final list tracks a later change as a change instead of silently altering what was
883
+ // ordered. The two totals are all that remains of an order completed by an older merchant app, which
884
+ // moved the list out of the order. They are filled from the list being sent, not from the counts it
885
+ // carries, which a caller could set to anything.
789
886
  export function buildOrderMessage(orderId, senderId, shoppingList, fulfillmentMode, deliveryAddressId, deliveryAddress, note, creationDate) {
790
887
  return proto_Message.create({
791
888
  entity: pushedEntity(orderId, creationDate, creationDate),
@@ -808,59 +905,57 @@ export function buildOrderMessage(orderId, senderId, shoppingList, fulfillmentMo
808
905
  },
809
906
  });
810
907
  }
811
- // A sync in response's messages as the cache write they make, tombstones included - the same as any
812
- // other entity array this library reads off the wire. This is how a push's own messages end up
813
- // stored under the update numbers the server just assigned them, with no separate stamping step:
814
- // the response already carries them as full entities, not as a bare number to reapply by hand.
908
+ // The messages of a sync in response as a cache write, tombstones included like any other entity
909
+ // array. This stores a push's own messages under the update numbers the server just assigned, with no
910
+ // separate stamping step, since the response carries full entities.
815
911
  export function messagesToCacheWrite(messages) {
816
912
  return toCacheWrite(messages.map((message) => toWireEntity(message.entity, proto_Message.toBinary(message))));
817
913
  }
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
- // The Entity of something this client is pushing. The update number goes out as zero: the protocol
832
- // 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.
914
+ // The Entity of something this client is pushing. The update number goes out as zero: the server
915
+ // replaces a caller's own with the one it assigns, so there is nothing to send. A message not pushed
916
+ // yet is stored holding zero, and messageToBlob versions it from the copy it amends. updateInfo is
917
+ // zero too, as it would be the id of the store that made the change and the server knows no store of
918
+ // this client. It needs none: a read sends these messages back (the server leaves no one's changes
919
+ // out of a storefront's read), and the eTag stored here is the one the server kept, so the read
920
+ // changes nothing.
837
921
  function pushedEntity(id, createdAt, updatedAt) {
838
922
  return proto_Entity.create({ id: id, createdAt: createdAt, updatedAt: updatedAt });
839
923
  }
840
- // Whether shoppingList differs from what stored already holds, so updateCart can check before
841
- // building or pushing a cart message rather than after - a caller may call it on every event it
842
- // cares about and only a cart the server has not got costs a request.
843
- // 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.
846
- // Also true whenever stored has never been pushed (updateNumber 0, left by updateCartLocalOnly),
847
- // regardless of content, so an unsent local edit still goes out.
924
+ // Whether shoppingList differs from what stored holds, so updateCart can skip a push. Compares raw
925
+ // lists, since a message's updatedAt differs on every call. That is sound because the server echoes
926
+ // a pushed list back with the items it was sent, so stored is what was last sent.
927
+ // The server echoes the items in package id order, and the list's own order is each item's
928
+ // shoppingListOrder, compared with the item. So both lists are put in package id order first, or a
929
+ // cart not picked in that order would differ from its echo and be pushed again, re-stamping
930
+ // updatedAt for every other device.
931
+ // True also when stored was never pushed (updateNumber 0, left by updateCartLocalOnly), so an
932
+ // unsent local edit still goes out.
848
933
  export function hasShoppingListChanged(stored, shoppingList) {
849
934
  if (stored === null)
850
935
  return pushedShoppingList(shoppingList, false) !== undefined;
851
936
  if (getEntity(stored.entity).updateNumber === 0n)
852
937
  return true;
853
- return !proto_ShoppingList.equals(getCartShoppingList(stored), pushedShoppingList(shoppingList, false));
938
+ return !proto_ShoppingList.equals(inPackageIdOrder(getCartShoppingList(stored)), inPackageIdOrder(pushedShoppingList(shoppingList, false)));
939
+ }
940
+ // The same list with its items by package id, the order in which the server sends them
941
+ function inPackageIdOrder(list) {
942
+ if (list === undefined)
943
+ return undefined;
944
+ const byPackageId = (first, second) => first.packageId < second.packageId ? -1 : first.packageId > second.packageId ? 1 : 0;
945
+ return proto_ShoppingList.create({
946
+ ...list,
947
+ existingItems: [...list.existingItems].sort(byPackageId),
948
+ deletedItems: [...list.deletedItems].sort(byPackageId),
949
+ });
854
950
  }
855
- // The list inside a cart message, or absent for one carrying no list - which is an emptied cart
951
+ // The list inside a cart message, or absent for an emptied cart
856
952
  function getCartShoppingList(message) {
857
953
  return message.payload.oneofKind === "partyPreferences"
858
954
  ? message.payload.partyPreferences.shoppingList
859
955
  : undefined;
860
956
  }
861
- // A shopping list on its way out, or absent when it holds nothing - see
862
- // buildPartyPreferencesMessage. Emptiness is judged by the items being sent and not by the counts
863
- // the list carries, for the reason the counts below are.
957
+ // A shopping list on its way out, or absent when it holds nothing - see buildPartyPreferencesMessage.
958
+ // Emptiness is judged by the items sent, not the counts, for the reason given at shoppingListToProto.
864
959
  function pushedShoppingList(shoppingList, isFinal) {
865
960
  if (shoppingList === null)
866
961
  return undefined;
@@ -868,10 +963,9 @@ function pushedShoppingList(shoppingList, isFinal) {
868
963
  return undefined;
869
964
  return shoppingListToProto(shoppingList, isFinal);
870
965
  }
871
- // The counts are written off the arrays rather than copied from the list. A read takes them from the
872
- // wire because the protocol sends a count even where it does not send the items, but a push always
873
- // sends the items - so the arrays are what is true here, and taking the counts from them is what
874
- // makes it impossible to push a list whose count disagrees with its contents.
966
+ // The counts come from the arrays, not the list. A read takes them from the wire because the
967
+ // protocol sends a count even where it omits the items, but a push always sends the items. Taking the
968
+ // counts from the arrays means a pushed list cannot disagree with its contents.
875
969
  function shoppingListToProto(shoppingList, isFinal) {
876
970
  return proto_ShoppingList.create({
877
971
  isFinal: isFinal,
@@ -887,9 +981,8 @@ function shoppingListToProto(shoppingList, isFinal) {
887
981
  deletedItems: shoppingList.deletedItems.map(shoppingListItemToProto),
888
982
  });
889
983
  }
890
- // The mirror of shoppingListItemFromProto: everything the model holds unscaled goes back scaled, and
891
- // nothing derived is sent - amount, totalTax and finalPrice are computed from these fields, here and
892
- // by the server both.
984
+ // The mirror of shoppingListItemFromProto. Unscaled model values go back scaled, and nothing derived
985
+ // is sent: amount, totalTax and finalPrice are computed from these fields on both sides.
893
986
  function shoppingListItemToProto(item) {
894
987
  return proto_ShoppingListItem.create({
895
988
  packageId: item.packageId,
@@ -907,42 +1000,69 @@ function shoppingListItemToProto(item) {
907
1000
  });
908
1001
  }
909
1002
  function taxToProto(tax) {
910
- return proto_Tax.create({ type: tax.type, rate: taxRateToScaledValue(tax.rate) });
1003
+ return proto_Tax.create({ taxCode: tax.taxCode, rate: taxRateToScaledValue(tax.rate) });
911
1004
  }
912
1005
  // #endregion
913
1006
  // #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.
918
- // Mutable arrays, so that businessResponseToCacheWrite can add the attachment base url to the blobs.
1007
+ // One pass over a response's entities, each becoming a blob at its own id, so one read of a container
1008
+ // is enough to sort it by entity type. Every blob is versioned by entityETag, tombstones too. A
1009
+ // delete is a write of its own, so a tombstone has a greater update number than any copy of what it
1010
+ // deleted and replaces it. An older copy sent again is lesser and refused, and a later write of the
1011
+ // same id is greater and kept. The arrays are mutable so that businessResponseToCacheWrite can add
1012
+ // the attachment base url.
919
1013
  function toCacheWrite(entities) {
920
1014
  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) {
1015
+ for (const entity of entities)
1016
+ blobs.push({
1017
+ id: entity.id,
1018
+ eTag: entityETag(entity.updateNumber, entity.updatedAt),
1019
+ bytes: entity.bytes,
1020
+ });
1021
+ return { blobs: blobs, deletedIds: [] };
1022
+ }
1023
+ // What a store versions an entity's blob by: the update number, with updatedAt below it, in one
1024
+ // bigint so the greater wins as the store says. The server assigns an update number to every write,
1025
+ // deletes included, so it rises with every change whatever any clock reads, and decides between
1026
+ // different versions. updatedAt decides between copies of one update number. Only a copy this client
1027
+ // wrote itself, at the update number of the one it amended, shares one. The value is wider than 64
1028
+ // bits, which a bigint holds exactly and a store must compare as one.
1029
+ export function entityETag(updateNumber, updatedAt) {
1030
+ if (updateNumber < 0n || updatedAt < 0n)
1031
+ throw new InvalidArgumentException("An update number and an updatedAt are never negative");
1032
+ return (updateNumber << UPDATED_AT_BITS) | updatedAt;
1033
+ }
1034
+ // A message this client wrote has no update number until a server assigns one, and has none in the
1035
+ // store while unpushed (see pushedEntity). It is versioned at the update number of the copy it
1036
+ // amends, which heldETag carries, so by its updatedAt it is the later copy of that version. The
1037
+ // server sending the amended copy again does not replace it, but a later server version with a
1038
+ // greater update number does. A message that amends nothing is at zero. A message with an update
1039
+ // number of its own is versioned by it, as any entity is.
1040
+ function messageToBlob(message, heldETag) {
931
1041
  const entity = getEntity(message.entity);
1042
+ const updateNumber = entity.updateNumber !== 0n
1043
+ ? entity.updateNumber
1044
+ : heldETag === null
1045
+ ? 0n
1046
+ : heldETag >> UPDATED_AT_BITS;
932
1047
  return {
933
1048
  id: entity.id,
934
- eTag: entity.updatedAt,
1049
+ eTag: entityETag(updateNumber, entity.updatedAt),
935
1050
  bytes: proto_Message.toBinary(message),
936
1051
  };
937
1052
  }
938
- function messagesFromBlobs(blobs) {
1053
+ // The messages a container holds that are deleted (isDeleted) or live. Only the update number says
1054
+ // which, never what a message carries. A deleted message is a completed order when it carries one,
1055
+ // and is never a customer's cart. Each as stored.
1056
+ function messagesFromBlobs(blobs, isDeleted = false) {
939
1057
  const result = [];
940
1058
  for (const blob of blobs) {
941
1059
  if (blob.id < 0n)
942
1060
  continue; // A fixed blob, not an entity
943
1061
  if (Ids.getEntityTypeId(blob.id) !== proto_Constants.MESSAGE_ENTITY_TYPE_ID)
944
1062
  continue;
945
- result.push(proto_Message.fromBinary(blob.bytes));
1063
+ const message = proto_Message.fromBinary(blob.bytes);
1064
+ if (isDeletedEntity(getEntity(message.entity)) === isDeleted)
1065
+ result.push(message);
946
1066
  }
947
1067
  return result;
948
1068
  }
@@ -956,7 +1076,7 @@ function toWireEntity(entity, bytes) {
956
1076
  updateNumber: entity.updateNumber,
957
1077
  updatedAt: entity.updatedAt,
958
1078
  isDeleted: isDeleted,
959
- bytes: isDeleted ? EMPTY_BYTES : bytes,
1079
+ bytes: bytes,
960
1080
  };
961
1081
  }
962
1082
  // A tombstone: the update type is held in the last two bits of the update number
@@ -991,28 +1111,24 @@ function getItemId(item) {
991
1111
  }
992
1112
  // #endregion
993
1113
  // #region Values
994
- // An absent optional string is null and not empty, because Java tells the two apart
1114
+ // An absent optional string is null, because Java tells absent from empty
995
1115
  function stringFromProto(value) {
996
1116
  return value === undefined ? null : value;
997
1117
  }
998
- // The way back. An empty string goes out absent as well: the two mean the same thing to a caller
999
- // that had nothing to say, and sending one of them would have the field come back as the other.
1118
+ // The way back. An empty string goes out absent too: the two mean the same, and sending one could
1119
+ // bring the field back as the other.
1000
1120
  function stringToProto(value) {
1001
1121
  return value === null || value.length === 0 ? undefined : value;
1002
1122
  }
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
1123
  function attachmentBaseUrlFromProto(urlAndToken) {
1008
1124
  return new AttachmentBaseUrl(urlAndToken.resourceUrl, urlAndToken.sasToken, urlAndToken.expiry);
1009
1125
  }
1010
1126
  function addressFromProto(address) {
1011
1127
  return new Address(address.id, address.addressType, stringFromProto(address.unitNumber), stringFromProto(address.community), stringFromProto(address.line2), stringFromProto(address.city), stringFromProto(address.state), stringFromProto(address.pinCode), address.countryCode);
1012
1128
  }
1013
- // The way back, for a delivery address a customer sends out directly rather than by id - see
1014
- // buildOrderMessage. id is sent as whatever Address carries, though it means nothing outside the
1015
- // list it was read from and this address belongs to none.
1129
+ // The way back, for a delivery address sent directly instead of by id - see buildOrderMessage. id is
1130
+ // whatever Address carries, though it means nothing outside the list it was read from, and this
1131
+ // address is in none.
1016
1132
  function addressToProto(address) {
1017
1133
  return proto_Address.create({
1018
1134
  id: address.id,
@@ -1026,7 +1142,7 @@ function addressToProto(address) {
1026
1142
  countryCode: address.countryCode,
1027
1143
  });
1028
1144
  }
1029
- // The way back, for the addresses of a user being sent to the server rather than read from it.
1145
+ // The way back, for the addresses of a user sent to the server.
1030
1146
  function addressListToProto(addressList) {
1031
1147
  return proto_AddressList.create({
1032
1148
  defaultAddressId: addressList.defaultAddressId,
@@ -1038,8 +1154,10 @@ function addressListFromProto(addressList) {
1038
1154
  return AddressList.EMPTY;
1039
1155
  return new AddressList(addressList.defaultAddressId, Object.freeze(addressList.addresses.map(addressFromProto)));
1040
1156
  }
1041
- function taxFromProto(tax) {
1042
- return Tax.fromScaledValues(tax.type, tax.rate);
1157
+ // The wire carries a tax's raw code, meaningful only within a country, so every caller names that of
1158
+ // the business the tax is on.
1159
+ function taxFromProto(tax, countryCode) {
1160
+ return Tax.fromScaledValues(countryCode, tax.taxCode, tax.rate);
1043
1161
  }
1044
1162
  function latLongFromProto(latLong) {
1045
1163
  if (latLong === undefined)
@@ -1071,23 +1189,23 @@ function workingHoursRangeFromProto(range) {
1071
1189
  function workingHoursFromProto(workingHours) {
1072
1190
  return new WorkingHours(Object.freeze(workingHours.ranges.map(workingHoursRangeFromProto)));
1073
1191
  }
1074
- // An absent WorkingDays means the business works every day, all day - the Java default, not "no day at all"
1192
+ // An absent WorkingDays means the business works every day, all day (the Java default)
1075
1193
  function workingDaysFromProto(workingDays) {
1076
1194
  if (workingDays === undefined)
1077
1195
  return WorkingDays.ALL_DAYS;
1078
- // Not an optional field, so an unspecified zone arrives as an empty string rather than absent
1196
+ // Not an optional field, so an unspecified zone arrives as an empty string
1079
1197
  return new WorkingDays(Object.freeze([...workingDays.days]), workingDays.timeZone.length === 0 ? null : workingDays.timeZone, Object.freeze(workingDays.workingHours.map(workingHoursFromProto)));
1080
1198
  }
1081
- function shoppingListItemFromProto(item) {
1082
- 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));
1199
+ function shoppingListItemFromProto(item, countryCode) {
1200
+ 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));
1083
1201
  }
1084
- function shoppingListFromProto(shoppingList) {
1202
+ function shoppingListFromProto(shoppingList, countryCode) {
1085
1203
  if (shoppingList === undefined)
1086
1204
  return ShoppingList.EMPTY;
1087
- 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)));
1205
+ 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))));
1088
1206
  }
1089
- function generalSettingsFromProto(settings) {
1090
- 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));
1207
+ function generalSettingsFromProto(settings, countryCode) {
1208
+ 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);
1091
1209
  }
1092
1210
  // #endregion
1093
1211
  // #region Entities
@@ -1132,31 +1250,30 @@ function packageFromProto(packageProto, photos, price) {
1132
1250
  const entity = getEntity(item.entity);
1133
1251
  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);
1134
1252
  }
1135
- function productFromProto(productProto, photos, packages) {
1253
+ function productFromProto(productProto, photos, packages, countryCode) {
1136
1254
  const item = getItem(productProto.item);
1137
1255
  const entity = getEntity(item.entity);
1138
- 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
1256
+ 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)
1257
+ stringFromProto(productProto.manufacturer), productProto.manufacturerAddress === undefined
1139
1258
  ? null
1140
- : addressFromProto(productProto.manufacturerAddress), Object.freeze(productProto.taxes.map(taxFromProto)), Object.freeze([...productProto.features]), photos, packages);
1259
+ : addressFromProto(productProto.manufacturerAddress), Object.freeze(productProto.taxes.map((tax) => taxFromProto(tax, countryCode))), Object.freeze([...productProto.features]), productProto.forOfflineSaleOnly, photos, packages);
1141
1260
  }
1142
- function orderFromProto(message, salesOrder) {
1261
+ function orderFromProto(message, salesOrder, countryCode) {
1143
1262
  const entity = getEntity(message.entity);
1144
- 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);
1145
- }
1146
- // The one part of a cart message a customer is shown. Absent rather than empty when the cart holds
1147
- // nothing, so that "emptied by placing an order" and "never had one" are the same answer - which is
1148
- // also what makes createOrder's emptied cart indistinguishable from a cart that was never created.
1149
- // Everything else the message carries is the server's: who sent it, how far the business has got
1150
- // with reading it, and the fulfillment mode Java keeps there, which belongs to an order here.
1151
- function cartShoppingListFromProto(partyPreferences) {
1263
+ 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);
1264
+ }
1265
+ // The one part of a cart message a customer is shown. Null when the cart holds nothing, so a cart
1266
+ // emptied by placing an order and one never created give the same answer. The rest of the message is
1267
+ // the server's or read elsewhere: who sent it, how far the business has read it, and the fulfillment
1268
+ // mode, which buildBusiness reads as defaultFulfillmentMode.
1269
+ function cartShoppingListFromProto(partyPreferences, countryCode) {
1152
1270
  const shoppingList = partyPreferences.shoppingList;
1153
- return shoppingList === undefined ? null : shoppingListFromProto(shoppingList);
1271
+ return shoppingList === undefined ? null : shoppingListFromProto(shoppingList, countryCode);
1154
1272
  }
1155
1273
  // #endregion
1156
1274
  // #region Photos
1157
- // The photos of one entity, in no particular order - a UI orders these for display. Each is built
1158
- // with the base url resolved for this response, since a Photo takes it at construction rather than
1159
- // looking it up later.
1275
+ // The photos of one entity, in no particular order (a UI orders them). Each is built with the
1276
+ // response's base url, since a Photo takes it at construction.
1160
1277
  function takePhotos(attachmentsByOwner, owningEntityId, attachmentBaseUrl) {
1161
1278
  const attachments = attachmentsByOwner.get(owningEntityId);
1162
1279
  if (attachments === undefined)
@@ -1164,5 +1281,8 @@ function takePhotos(attachmentsByOwner, owningEntityId, attachmentBaseUrl) {
1164
1281
  return Object.freeze(attachments.map((attachment) => new Photo(attachment, attachmentBaseUrl)));
1165
1282
  }
1166
1283
  // #endregion
1167
- const EMPTY_BYTES = new Uint8Array(0);
1284
+ // The width of the updatedAt an eTag carries below its update number. An int64 is never wider.
1285
+ const UPDATED_AT_BITS = 64n;
1286
+ // What a blob that is only its eTag holds
1287
+ const NO_BYTES = new Uint8Array(0);
1168
1288
  const NO_PHOTOS = Object.freeze([]);