@peabody-soft/mbs-model-ts 1.2.0 → 1.4.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.
@@ -1,3 +1,4 @@
1
+ import { FulfillmentMode as proto_FulfillmentMode } from "./generated/FulfillmentMode";
1
2
  import AttachmentBaseUrl from "./AttachmentBaseUrl";
2
3
  import CartEditor from "./CartEditor";
3
4
  import Catalog from "./Catalog";
@@ -22,6 +23,8 @@ export default class Business extends Entity implements UpdatableBusiness {
22
23
  private set orders(value);
23
24
  get cart(): ShoppingList | null;
24
25
  private set cart(value);
26
+ get defaultFulfillmentMode(): proto_FulfillmentMode;
27
+ private set defaultFulfillmentMode(value);
25
28
  get catalog(): Catalog;
26
29
  private set catalog(value);
27
30
  createCartEditor(): CartEditor;
@@ -31,6 +34,7 @@ export default class Business extends Entity implements UpdatableBusiness {
31
34
  private _photos;
32
35
  private _orders;
33
36
  private _cart;
37
+ private _defaultFulfillmentMode;
34
38
  private _catalog;
35
39
  private _attachmentBaseUrl;
36
40
  }
@@ -39,6 +43,7 @@ export interface UpdatableBusiness {
39
43
  set photos(value: ReadonlyArray<Photo>);
40
44
  set orders(value: ReadonlyArray<Order>);
41
45
  set cart(value: ShoppingList | null);
46
+ set defaultFulfillmentMode(value: proto_FulfillmentMode);
42
47
  set catalog(value: Catalog);
43
48
  set attachmentBaseUrl(value: AttachmentBaseUrl | null);
44
49
  }
package/dist/Business.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { FulfillmentMode as proto_FulfillmentMode } from "./generated/FulfillmentMode";
1
2
  import CartEditor from "./CartEditor";
2
3
  import Catalog from "./Catalog";
3
4
  import Entity from "./Entity";
@@ -15,6 +16,7 @@ export default class Business extends Entity {
15
16
  this._photos = Object.freeze([]);
16
17
  this._orders = Object.freeze([]);
17
18
  this._cart = null;
19
+ this._defaultFulfillmentMode = proto_FulfillmentMode.DELIVERY;
18
20
  this._catalog = Catalog.EMPTY;
19
21
  this._attachmentBaseUrl = null;
20
22
  }
@@ -56,13 +58,23 @@ export default class Business extends Entity {
56
58
  // cart, and null again once they have ordered what was in it - the message the cart travels in
57
59
  // outlives being emptied, but nothing of it is a customer's business, so what is exposed is the
58
60
  // list alone. The fulfillment mode is not part of it: that is chosen when an order is placed, and
59
- // is a field of the Order.
61
+ // is a field of the Order - see defaultFulfillmentMode for the one other place it is read.
60
62
  get cart() {
61
63
  return this._cart;
62
64
  }
63
65
  set cart(value) {
64
66
  this._cart = value;
65
67
  }
68
+ // What to preselect for this customer's next order: whatever the party preferences message
69
+ // already carries, which createOrder stamps with the mode an order was just placed under - so
70
+ // this is that order's own choice, not necessarily today's cart's. DELIVERY when there has never
71
+ // been a party preferences message to read one from.
72
+ get defaultFulfillmentMode() {
73
+ return this._defaultFulfillmentMode;
74
+ }
75
+ set defaultFulfillmentMode(value) {
76
+ this._defaultFulfillmentMode = value;
77
+ }
66
78
  // Catalog.EMPTY until the catalog of this business arrives
67
79
  get catalog() {
68
80
  return this._catalog;
package/dist/Model.d.ts CHANGED
@@ -24,7 +24,7 @@ export default class Model {
24
24
  createOrder(orderId: bigint, fulfillmentMode: proto_FulfillmentMode, deliveryAddressId: bigint, deliveryAddress: Address | null, note: string | null): Promise<void>;
25
25
  private beginSignIn;
26
26
  private pushBusinessMessages;
27
- private storeCart;
27
+ private storeCartInCache;
28
28
  private requireBusinessBeingBrowsed;
29
29
  private requireCustomerOfBusiness;
30
30
  private static requireDeliveryAddress;
package/dist/Model.js CHANGED
@@ -8,6 +8,7 @@ import Ids from "./Ids";
8
8
  import LiveData from "./LiveData";
9
9
  import * as ProtoMap from "./ProtoMap";
10
10
  import { InternalServerException } from "./ServiceExceptions";
11
+ import ShoppingList from "./ShoppingList";
11
12
  import SyncCache from "./SyncCache";
12
13
  // The root of the object model, and the only thing a UI has to be given. Call initialize once,
13
14
  // then always fetch the current instance via getInstance.
@@ -42,12 +43,25 @@ class Model {
42
43
  // Signs in with the given credentials and syncs this subject's profile in - for a subject the
43
44
  // server already has a profile for; createUser is what a subject with none yet uses instead. Also
44
45
  // reads in the business being browsed, if any, now signed in as this customer.
46
+ //
47
+ // Idempotent on failure: beginSignIn's own "already signed in" guard aside, anything that throws
48
+ // after it - a bad credential, a refused push while reading in the business being browsed -
49
+ // discards this instance via signOut rather than leaving it half signed in. profileSyncIn
50
+ // publishes a cached profile before it ever reaches the network, so without this, a retry after
51
+ // such a failure could find _profile already set and trip that same guard despite never having
52
+ // actually signed in.
45
53
  async signIn(authenticationMethod, subjectId, authenticationToken) {
46
54
  this.beginSignIn(authenticationMethod, subjectId, authenticationToken);
47
- const authTokens = await this.requireProfileAuthTokens();
48
- await this.profileSyncIn(ProtoMap.buildProfileSyncOutRequestFromCache(await Model.getCache().readProfile(this.requireProfileContainerName())), authTokens);
49
- if (this._businessId !== null)
50
- await this.businessSyncIn();
55
+ try {
56
+ const authTokens = await this.requireProfileAuthTokens();
57
+ await this.profileSyncIn(ProtoMap.buildProfileSyncOutRequestFromCache(await Model.getCache().readProfile(this.requireProfileContainerName())), authTokens);
58
+ if (this._businessId !== null)
59
+ await this.businessSyncIn();
60
+ }
61
+ catch (error) {
62
+ await this.signOut();
63
+ throw error;
64
+ }
51
65
  }
52
66
  // Signs up with the given credentials, creating userToCreate as this subject's profile - for a
53
67
  // subject the server has no profile for yet; signIn is what an already known subject uses instead.
@@ -75,11 +89,16 @@ class Model {
75
89
  async signOut() {
76
90
  Model._instance = null;
77
91
  }
78
- // Names the business to browse and reads it in, publishing null first so a stale or
79
- // about-to-be-replaced Business is never shown as the one now being browsed.
92
+ // Names the business to browse and reads it in, publishing null first so a Business being
93
+ // replaced is never shown as the one now being browsed - but only when it IS being replaced.
94
+ // This is also how a caller re-reads the shop it is already on, and nulling unconditionally
95
+ // would blank the UI for the length of the read and undo syncBusinessContainer's own
96
+ // cache-first publish two lines later.
80
97
  async setBusinessId(businessId) {
98
+ const isDifferentBusiness = this._businessId !== businessId;
81
99
  this._businessId = businessId;
82
- this.publishBusiness(null);
100
+ if (isDifferentBusiness)
101
+ this.publishBusiness(null);
83
102
  await this.businessSyncIn();
84
103
  }
85
104
  // #region What a customer writes
@@ -92,57 +111,50 @@ class Model {
92
111
  // of the difference.
93
112
  // Both need this customer to be a member of the business being browsed - three separate things
94
113
  // to be missing, so three separate failures; see requireCustomerOfBusiness.
95
- // Creates or replaces this customer's cart, in the store and on the server both. An empty list
96
- // empties the cart - an emptied cart and one never created are the same state here, on the wire
97
- // and in what Business.cart answers.
98
- // Signed out this is updateCartLocalOnly, there being no cart on the server for a visitor the
99
- // server has not been told about. Signed in it is a push, so it costs a request: a caller
100
- // editing a cart a tap at a time writes with updateCartLocalOnly and calls this one on whatever
101
- // schedule it likes.
102
- // Calling it with a cart the server already has costs nothing: it returns without a request. So a
103
- // caller is free to call it on every event it cares about - a page hidden, a timer, both at
104
- // once - without tracking what it last sent, and what it checks against is the store's own copy
105
- // rather than a caller's idea of one. It is not a substitute for reading the business, though: a
106
- // push answers with whatever else of this customer's changed, and a call that does not go out
107
- // brings none of that back.
114
+ // Creates or replaces this customer's cart, on the server and in the store both. An empty list
115
+ // empties the cart - the same state as never having had one.
116
+ // Signed out, this is just updateCartLocalOnly - there is no server cart yet for a visitor the
117
+ // server has not heard of. Signed in, it costs a request, so an editor should write every change
118
+ // with updateCartLocalOnly and call this on its own schedule (debounced, on blur, a timer).
119
+ // Safe to call often: hasShoppingListChanged checks against the store's own copy rather than
120
+ // whatever the caller last sent, so a call that matches what is already there returns without a
121
+ // request. Not a substitute for reading the business, though - a push answers only with whatever
122
+ // else of this customer's changed, and a call that does not go out brings none of that back.
108
123
  async updateCart(shoppingList) {
109
124
  if (this._profile.value === null)
110
125
  return this.updateCartLocalOnly(shoppingList);
111
126
  const target = this.requireCustomerOfBusiness();
112
- const stored = await Model.getCache().readCart(target.containerName);
113
- const cartMessage = ProtoMap.buildCartMessage(Ids.getPartyPreferencesMessageId(target.businessLink.id), stored, target.senderId, shoppingList, BigInt(Date.now()));
114
- if (!ProtoMap.isCartWorthPushing(stored, cartMessage))
127
+ const stored = await Model.getCache().readPartyPreferences(target.containerName);
128
+ if (!ProtoMap.hasShoppingListChanged(stored, shoppingList))
115
129
  return;
116
- await this.pushBusinessMessages(target, [cartMessage]);
117
- }
118
- // The same, stored here and not sent. This is the call to make on every edit: a cart is worth
119
- // keeping the moment a customer changes it, and a request per tap of a quantity stepper is not
120
- // worth making. updateCart then sends whatever the caller has reached whenever it decides to -
121
- // debounced, on the page being hidden, before signing in.
122
- //
123
- // Little is lost by a cart that is only ever stored. It survives the tab closing, the next
124
- // updateCart sends it, and createOrder reads the cart from the store and carries that list in
125
- // the order message itself - so an order placed from a cart never sent on its own is still the
126
- // right order. What a caller gives up is that the server, and so this customer's other devices,
127
- // learn of a change only when the next updateCart goes out.
128
- //
129
- // The store keeps whichever copy has the greater eTag, and buildCartMessage dates an amendment
130
- // past the copy it replaces, so a cart stored here cannot be undone by a sync in bringing the
131
- // server's older one back.
130
+ const partyPreferencesMessage = ProtoMap.buildPartyPreferencesMessage(Ids.getPartyPreferencesMessageId(target.businessLink.id), stored, target.senderId, shoppingList, null, BigInt(Date.now()));
131
+ await this.pushBusinessMessages(target, [partyPreferencesMessage]);
132
+ }
133
+ // The same, stored locally and not sent. Call this on every edit - a request per tap of a
134
+ // quantity stepper is not worth making - then call updateCart on your own schedule (debounced,
135
+ // on blur, before signing in) to actually send it.
136
+ // Nothing is lost by leaving it unsent: it survives a closed tab, the next updateCart sends it,
137
+ // and createOrder reads straight from the store. All a caller gives up is that the server, and so
138
+ // this customer's other devices, hear about the change late.
139
+ // It also cannot be undone by a stale sync in bringing an older server copy back: the store keeps
140
+ // whichever copy has the later eTag, and buildPartyPreferencesMessage always dates an amendment
141
+ // past what it replaces.
132
142
  async updateCartLocalOnly(shoppingList) {
133
- // No membership to derive a cart id from while nobody is signed in (see
134
- // Ids.getPartyPreferencesMessageId) and none to be had until somebody signs in, so one is
135
- // drawn - once, and only once: buildCartMessage amends the cart already stored and keeps its
136
- // own id, so a second write cannot leave two behind. The sender is ENTITY_ID_NULL for want of
137
- // a User, and the first push after signing in names the real one.
143
+ // No membership to derive a message id from while signed out, so one is drawn instead - once,
144
+ // and only once: buildPartyPreferencesMessage keeps amending the same entity by reusing its id,
145
+ // so a second edit cannot leave two behind. The sender is ENTITY_ID_NULL for want of a User;
146
+ // the first push after signing in names the real one.
138
147
  if (this._profile.value === null)
139
- return this.storeCart(ContainerNames.forBusiness(this.requireBusinessBeingBrowsed()), Ids.getNewId(proto_Constants.MESSAGE_ENTITY_TYPE_ID), BigInt(proto_Constants.ENTITY_ID_NULL), shoppingList);
148
+ return this.storeCartInCache(ContainerNames.forBusiness(this.requireBusinessBeingBrowsed()), Ids.getNewId(proto_Constants.MESSAGE_ENTITY_TYPE_ID), BigInt(proto_Constants.ENTITY_ID_NULL), shoppingList);
140
149
  const target = this.requireCustomerOfBusiness();
141
- return this.storeCart(target.containerName, Ids.getPartyPreferencesMessageId(target.businessLink.id), target.senderId, shoppingList);
150
+ return this.storeCartInCache(target.containerName, Ids.getPartyPreferencesMessageId(target.businessLink.id), target.senderId, shoppingList);
142
151
  }
143
152
  // Places an order for what is in the cart, and empties it - the list is used up becoming the
144
153
  // order, as Java SalesOrderViewModel.saveChanges does on MODE_CREATE. Takes no shopping list
145
154
  // itself for that reason: the cart is the order.
155
+ // The emptied party preferences message is also stamped with this fulfillmentMode, so it becomes
156
+ // this order's rather than whatever the cart last held - see Business.defaultFulfillmentMode,
157
+ // which is what reads it back.
146
158
  // orderId is the caller's to mint, with Ids.getNewId(Constants.MESSAGE_ENTITY_TYPE_ID) - not
147
159
  // minted here - so that a caller holds it before the call is made: to find the order once placed
148
160
  // without a server-assigned id to wait for, and to retry a call a NetworkConnectionException left
@@ -154,9 +166,11 @@ class Model {
154
166
  const target = this.requireCustomerOfBusiness();
155
167
  // Read from the store, not the published Business, so the order reflects the cart as the
156
168
  // server last confirmed it, not a value some observer may still hold
157
- const cartMessage = await Model.getCache().readCart(target.containerName);
158
- const shoppingList = cartMessage === null ? null : ProtoMap.buildCart(cartMessage);
159
- if (shoppingList === null || shoppingList.existingItems.length === 0)
169
+ const partyPreferencesMessage = await Model.getCache().readPartyPreferences(target.containerName);
170
+ const shoppingList = partyPreferencesMessage === null
171
+ ? ShoppingList.EMPTY
172
+ : ProtoMap.buildShoppingList(partyPreferencesMessage);
173
+ if (shoppingList.existingItems.length === 0)
160
174
  throw new IllegalOperationException("The cart holds nothing, so there is no order to place");
161
175
  Model.requireDeliveryAddress(target.addresses, fulfillmentMode, deliveryAddressId, deliveryAddress);
162
176
  // One creation time for both, and it is what the order was placed at
@@ -165,7 +179,10 @@ class Model {
165
179
  // but a cart still full, if the second one failed
166
180
  await this.pushBusinessMessages(target, [
167
181
  ProtoMap.buildOrderMessage(orderId, target.senderId, shoppingList, fulfillmentMode, deliveryAddressId, deliveryAddress, note, now),
168
- ProtoMap.buildCartMessage(Ids.getPartyPreferencesMessageId(target.businessLink.id), cartMessage, target.senderId, null, now),
182
+ // fulfillmentMode is the order's, not left as whatever the cart held: placing an order is
183
+ // this customer's own choice of how it is fulfilled, freshly made, so it is what
184
+ // defaultFulfillmentMode should offer next time rather than a possibly stale earlier one.
185
+ ProtoMap.buildPartyPreferencesMessage(Ids.getPartyPreferencesMessageId(target.businessLink.id), partyPreferencesMessage, target.senderId, null, fulfillmentMode, now),
169
186
  ]);
170
187
  }
171
188
  // #endregion
@@ -198,16 +215,13 @@ class Model {
198
215
  if (changeCount !== 0)
199
216
  this.publishBusiness(await cache.readBusiness(target.containerName));
200
217
  }
201
- // One cart into one container, amending whatever is there already. Both halves of
202
- // updateCartLocalOnly end here, differing only in which container, which id and which sender:
203
- // the container an anonymous read filled, with a drawn id and no sender, or the membership's,
204
- // with the id derived from it and the customer's own.
205
- // A visitor's copy goes into the container the anonymous read filled, which is what carries it
206
- // across a sign in: the container named for a new membership is seeded from that one - see
207
- // syncCustomerBusiness.
208
- async storeCart(containerName, cartMessageId, senderId, shoppingList) {
218
+ // Amends whatever cart containerName already holds and writes the result back to the cache -
219
+ // nothing is pushed to the server. updateCartLocalOnly's two callers differ only in which
220
+ // container, which id, and which sender: a visitor's has a freshly drawn id and no sender, a
221
+ // customer's has the id their membership derives and their own sender id.
222
+ async storeCartInCache(containerName, messageId, senderId, shoppingList) {
209
223
  const cache = Model.getCache();
210
- const changeCount = await cache.writeCart(containerName, ProtoMap.buildCartMessage(cartMessageId, await cache.readCart(containerName), senderId, shoppingList, BigInt(Date.now())));
224
+ const changeCount = await cache.writePartyPreferences(containerName, ProtoMap.buildPartyPreferencesMessage(messageId, await cache.readPartyPreferences(containerName), senderId, shoppingList, null, BigInt(Date.now())));
211
225
  // Published on what the store took, the same rule every read and push keeps
212
226
  if (changeCount !== 0)
213
227
  this.publishBusiness(await cache.readBusiness(containerName));
@@ -279,23 +293,39 @@ class Model {
279
293
  const link = await this.requireBusinessLink(businessId, profile);
280
294
  const cache = Model.getCache();
281
295
  const containerName = Model.getBusinessContainerName(link);
282
- // Seeded from the plain businessId container's cache, the one time this customer's own
283
- // container does not exist yet - sparing a full redownload of a business already browsed, and
284
- // carrying over the cart of the visit before this sign in.
285
- if ((await cache.readBusiness(containerName)) === null) {
286
- const anonymousName = ContainerNames.forBusiness(businessId);
287
- const anonymous = await cache.readBusiness(anonymousName);
288
- if (anonymous !== null) {
289
- await cache.writeBusiness(containerName, anonymous);
290
- // That cart now belongs to this membership, so the copy left behind is dropped. The plain
291
- // container is what the next anonymous visitor reads, and what the next customer to sign in
292
- // is seeded from, and a cart is one customer's. Deleted after the copy and not before, so a
293
- // failed copy leaves the cart where it was rather than losing it.
294
- await cache.deleteCart(anonymousName);
295
- }
296
+ const anonymousName = ContainerNames.forBusiness(businessId);
297
+ // What this device read while nobody was signed in, folded into the container it is read
298
+ // under now - the same rule the server's own pages take: writeBusiness keeps the later of two
299
+ // copies per entity by updatedAt. Unconditional, since a returning customer's own container
300
+ // can easily be the staler of the two.
301
+ // Not the cart, which is settled below: the two containers hold it under different ids, so a
302
+ // merge keyed on entity id would file the visitor's as a second cart instead of replacing one.
303
+ const anonymous = await cache.readBusiness(anonymousName);
304
+ // Out of the messages just read, rather than a second read of the same container -
305
+ // partyPreferencesMessageFromMessages picks the same one readPartyPreferences(anonymousName)
306
+ // would.
307
+ const anonymousPartyPreferences = anonymous === null ? null : ProtoMap.partyPreferencesMessageFromMessages(anonymous.messages);
308
+ if (anonymous !== null) {
309
+ anonymous.messages = anonymous.messages.filter((message) => !ProtoMap.isPartyPreferencesMessage(message));
310
+ await cache.writeBusiness(containerName, anonymous);
296
311
  }
297
312
  const authTokens = await this.requireBusinessAuthTokens(containerName, link);
298
313
  await this.syncBusinessContainer(containerName, businessId, ProtoMap.buildAuthTokens(authTokens));
314
+ // THE CART A VISITOR BUILT BEFORE SIGNING IN. Signing in changes which container is being
315
+ // browsed, so without this it is simply left behind - and a customer has to build it again
316
+ // having just proved who they are.
317
+ if (anonymousPartyPreferences === null)
318
+ return;
319
+ // Read after the sync, so this is weighed against what the server just settled rather than a
320
+ // local copy about to be replaced. Last written wins, by updatedAt.
321
+ const userPartyPreferences = await cache.readPartyPreferences(containerName);
322
+ if (userPartyPreferences === null ||
323
+ ProtoMap.getMessageUpdatedAt(anonymousPartyPreferences) >
324
+ ProtoMap.getMessageUpdatedAt(userPartyPreferences))
325
+ await this.updateCart(ProtoMap.buildShoppingList(anonymousPartyPreferences));
326
+ // Dropped either way, carried over or beaten by the customer's own: the anonymous container is
327
+ // what the next visitor on this device reads, and a settled cart has no business staying there.
328
+ await cache.deletePartyPreferences(anonymousName);
299
329
  }
300
330
  // The plain businessId-named container: an anonymous read, before signing in or joining, or to
301
331
  // learn a business's entity id while joining - see readJoinBusinessId.
@@ -395,15 +425,14 @@ class Model {
395
425
  return ContainerNames.forBusiness(Ids.toStorageHexString(link.id));
396
426
  }
397
427
  // The credentials that sign a call about a customer's own business container. The authentication
398
- // token always comes from memory, never a cache read, for the same reason buildProfileAuthTokens
399
- // reads it that way: a stored AuthTokens never persists it.
400
- // The user token and link token are read independently off whatever this container has reissued,
401
- // rather than as one object - storeIssuedTokens overwrites the whole blob per call, so trusting
402
- // only that would lose whichever of the two a call didn't reissue. Each falls back to its own
403
- // source instead: the profile container's user token, and this link's own linkToken - exactly
404
- // what a fresh membership lacks, and what the write below now gives this container to reissue
405
- // over. A link with no linkToken at all could never sign a call, so that is a server
406
- // inconsistency, thrown rather than sent unsigned.
428
+ // token always comes from memory, not a cache read - same as buildProfileAuthTokens, since a
429
+ // stored AuthTokens never persists it.
430
+ // The user token and link token are read independently, not as one object: storeIssuedTokens
431
+ // overwrites the whole blob per call, so trusting only that would lose whichever token the last
432
+ // call didn't reissue. Each falls back to its own source instead - the profile container's user
433
+ // token, this link's own linkToken - which is exactly what a fresh membership lacks and what the
434
+ // write below now gives this container to reissue over.
435
+ // A link with no linkToken at all is a server inconsistency: thrown rather than sent unsigned.
407
436
  async requireBusinessAuthTokens(containerName, link) {
408
437
  const authenticationMethod = this._authenticationMethod;
409
438
  const authenticationToken = this._authenticationToken;
@@ -484,19 +513,19 @@ class Model {
484
513
  // #endregion
485
514
  // #region Driving one call
486
515
  // A join that was asked for but not answered. Shared by both calls that can carry a
487
- // joinBusinessId, since what a join must return doesn't depend on which one asked: the
516
+ // joinBusinessId, since a join answers the same two things regardless of which one asked: the
488
517
  // BusinessLink (the membership) and the Business it names. Both are needed to reach a business -
489
- // the link for its token, the Business for the url-friendly id everything else is built from - so
490
- // either one missing leaves the caller told it's a customer of something it can't reach.
518
+ // the link for its token, the Business for the url-friendly id everything else is built from -
519
+ // so either one missing leaves the caller told it is a customer of something it cannot reach.
491
520
  //
492
521
  // A ServiceException, not one of Exceptions.ts: nothing the caller passed was wrong - the server
493
- // answered 200 and left out what was asked for - so a caller can retry or carry on with what's
522
+ // answered 200 and left out what was asked for - so a caller can retry or carry on with what is
494
523
  // cached. 500 is the closest code, and the one that says retrying is worth it.
495
524
  //
496
- // Only a joinBusinessId a server could act on is checked (Java Business.isValidBusinessId's test,
497
- // which Constants.ENTITY_ID_NULL also fails, so "no join asked" needs no case of its own). An id
498
- // that isn't a business entity id isn't a failure here: a server ignores it rather than refusing
499
- // it (see THE MOCKS), and sending one is the caller's bug, not the server's.
525
+ // Only a joinBusinessId a server could act on is checked (Java Business.isValidBusinessId's
526
+ // test, which Constants.ENTITY_ID_NULL also fails, so "no join asked" needs no case of its own).
527
+ // An id that is not a business entity id is not a failure here: a server ignores it rather than
528
+ // refusing it (see THE MOCKS), and sending one is the caller's bug, not the server's.
500
529
  static requireJoinedBusiness(joinBusinessId, hasBusinessLink, hasBusiness) {
501
530
  if (Ids.getEntityTypeId(joinBusinessId) !== proto_Constants.BUSINESS_ENTITY_TYPE_ID)
502
531
  return;
@@ -35,11 +35,14 @@ export declare function businessResponseToCacheWrite(response: proto_BusinessSyn
35
35
  export declare function profileResponseToCacheWrite(response: proto_ProfileSyncOutResponse): CacheWrite;
36
36
  export declare function getBusinessResponseAttachmentBaseUrlExpiry(response: proto_BusinessSyncOutResponse | null): bigint;
37
37
  export declare function orderToBlob(order: proto_Message): StoredBlob;
38
- export declare function cartToBlob(cart: proto_Message): StoredBlob;
38
+ export declare function isPartyPreferencesMessage(message: proto_Message): boolean;
39
+ export declare function getMessageUpdatedAt(message: proto_Message): bigint;
40
+ export declare function partyPreferencesToBlob(partyPreferences: proto_Message): StoredBlob;
39
41
  export declare function businessResponseFromBlobs(blobs: ReadonlyArray<StoredBlob>): proto_BusinessSyncOutResponse | null;
40
42
  export declare function profileResponseFromBlobs(blobs: ReadonlyArray<StoredBlob>): proto_ProfileSyncOutResponse | null;
41
43
  export declare function orderMessagesFromBlobs(blobs: ReadonlyArray<StoredBlob>): ReadonlyArray<proto_Message>;
42
- export declare function cartMessageFromBlobs(blobs: ReadonlyArray<StoredBlob>): proto_Message | null;
44
+ export declare function partyPreferencesMessageFromMessages(messages: ReadonlyArray<proto_Message>): proto_Message | null;
45
+ export declare function partyPreferencesMessageFromBlobs(blobs: ReadonlyArray<StoredBlob>): proto_Message | null;
43
46
  export declare function buildBusinessSyncOutRequest(businessId: string, syncInState: SyncState, attachmentBaseUrlExpiry: bigint): proto_BusinessSyncOutRequest;
44
47
  export declare function buildBusinessSyncOutRequestFromCache(businessId: string, cached: proto_BusinessSyncOutResponse | null): proto_BusinessSyncOutRequest;
45
48
  export declare function buildBusinessSyncInRequest(messageUpdateNumber: bigint, messages: ReadonlyArray<proto_Message>): proto_BusinessSyncInRequest;
@@ -63,9 +66,9 @@ export declare function nextProfileSyncOutRequest(request: proto_ProfileSyncOutR
63
66
  export declare function buildBusiness(response: proto_BusinessSyncOutResponse): Business | null;
64
67
  export declare function buildProfile(profileId: string, response: proto_ProfileSyncOutResponse): Profile | null;
65
68
  export declare function buildOrder(message: proto_Message): Order | null;
66
- export declare function buildCart(message: proto_Message): ShoppingList | null;
67
- export declare function buildCartMessage(cartMessageId: bigint, existing: proto_Message | null, senderId: bigint, shoppingList: ShoppingList | null, now: bigint): proto_Message;
69
+ export declare function buildShoppingList(message: proto_Message): ShoppingList;
70
+ export declare function buildPartyPreferencesMessage(messageId: bigint, existing: proto_Message | null, senderId: bigint, shoppingList: ShoppingList | null, fulfillmentMode: proto_FulfillmentMode | null, now: bigint): proto_Message;
68
71
  export declare function buildOrderMessage(orderId: bigint, senderId: bigint, shoppingList: ShoppingList, fulfillmentMode: proto_FulfillmentMode, deliveryAddressId: bigint, deliveryAddress: Address | null, note: string | null, creationDate: bigint): proto_Message;
69
72
  export declare function messagesToCacheWrite(messages: ReadonlyArray<proto_Message>): CacheWrite;
70
73
  export declare function highestCachedMessageUpdateNumber(blobs: ReadonlyArray<StoredBlob>): bigint;
71
- export declare function isCartWorthPushing(stored: proto_Message | null, cartMessage: proto_Message): boolean;
74
+ export declare function hasShoppingListChanged(stored: proto_Message | null, shoppingList: ShoppingList): boolean;
package/dist/ProtoMap.js CHANGED
@@ -10,6 +10,7 @@ import { BusinessSyncOutResponse as proto_BusinessSyncOutResponse } from "./gene
10
10
  import { Configuration as proto_Configuration } from "./generated/Configuration";
11
11
  import { Constants as proto_Constants } from "./generated/Constants";
12
12
  import { Entity as proto_Entity } from "./generated/Entity";
13
+ import { FulfillmentMode as proto_FulfillmentMode } from "./generated/FulfillmentMode";
13
14
  import { Message as proto_Message } from "./generated/Message";
14
15
  import { MessageType as proto_MessageType } from "./generated/MessageType";
15
16
  import { OrderStatus as proto_OrderStatus } from "./generated/OrderStatus";
@@ -101,7 +102,8 @@ export function getProfileResponseEntities(response) {
101
102
  // any entity of it.
102
103
  export function businessResponseToCacheWrite(response) {
103
104
  const write = toCacheWrite(getBusinessResponseEntities(response));
104
- // Its eTag is its expiry, which is exactly its cursor: a client sends back the expiry it holds and is sent a later url only when one exists
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
107
  const attachmentBaseUrl = response.attachmentBaseUrl;
106
108
  if (attachmentBaseUrl !== undefined)
107
109
  write.blobs.push({
@@ -115,24 +117,39 @@ export function businessResponseToCacheWrite(response) {
115
117
  export function profileResponseToCacheWrite(response) {
116
118
  return toCacheWrite(getProfileResponseEntities(response));
117
119
  }
118
- // The expiry of the attachment base url a response carried, and zero when it carried none, which is exactly what the next request sends back.
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.
119
122
  // Here rather than at a call site, because reading a field of a response is this file's job.
120
123
  export function getBusinessResponseAttachmentBaseUrlExpiry(response) {
121
124
  return response?.attachmentBaseUrl?.expiry ?? 0n;
122
125
  }
123
- // One order as its own blob, for a caller holding a message rather than a whole response. The payload is the only thing that says which a message is, so one carrying the other is refused here rather than stored where a read would never find it.
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.
124
129
  export function orderToBlob(order) {
125
130
  if (order.payload.oneofKind !== "salesOrder")
126
131
  throw new InvalidArgumentException("That message carries no sales order, so it is not an order");
127
132
  return messageToBlob(order);
128
133
  }
129
- export function cartToBlob(cart) {
130
- if (cart.payload.oneofKind !== "partyPreferences")
131
- throw new InvalidArgumentException("That message carries no party preferences, so it is not a cart");
132
- return messageToBlob(cart);
133
- }
134
- // The response the blobs of a business container make up: everything cached for that business, in the shape the call that brought it returned, and null when they hold nothing of it yet.
135
- // The entity type in each blob id sorts them, so no stored type tag is needed; one this library does not know is skipped rather than refused, so a newer server cannot break an older client.
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.
136
+ export function isPartyPreferencesMessage(message) {
137
+ return message.payload.oneofKind === "partyPreferences";
138
+ }
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.
141
+ export function getMessageUpdatedAt(message) {
142
+ return getEntity(message.entity).updatedAt;
143
+ }
144
+ export function partyPreferencesToBlob(partyPreferences) {
145
+ if (partyPreferences.payload.oneofKind !== "partyPreferences")
146
+ throw new InvalidArgumentException("That message carries no party preferences");
147
+ return messageToBlob(partyPreferences);
148
+ }
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.
136
153
  export function businessResponseFromBlobs(blobs) {
137
154
  const response = proto_BusinessSyncOutResponse.create({});
138
155
  let hasContent = false;
@@ -203,7 +220,8 @@ export function profileResponseFromBlobs(blobs) {
203
220
  }
204
221
  return hasContent ? response : null;
205
222
  }
206
- // The orders a container holds. The payload a message carries is what says whether it is an order or a cart - Message.messageType is not read, because here the payload is typed.
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.
207
225
  export function orderMessagesFromBlobs(blobs) {
208
226
  const result = [];
209
227
  for (const message of messagesFromBlobs(blobs))
@@ -211,11 +229,16 @@ export function orderMessagesFromBlobs(blobs) {
211
229
  result.push(message);
212
230
  return result;
213
231
  }
214
- // The cart a container holds, or null when the customer has not put anything in one. A customer has one cart per business, so two means two callers' messages are in one container.
215
- // The most recently updated then wins for want of anything better: nothing in a message says which caller it was sent to - see THE MESSAGES OF A BUSINESS ARE NOT PUBLIC.
216
- export function cartMessageFromBlobs(blobs) {
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.
239
+ export function partyPreferencesMessageFromMessages(messages) {
217
240
  let result = null;
218
- for (const message of messagesFromBlobs(blobs)) {
241
+ for (const message of messages) {
219
242
  if (message.payload.oneofKind !== "partyPreferences")
220
243
  continue;
221
244
  if (result === null ||
@@ -224,6 +247,11 @@ export function cartMessageFromBlobs(blobs) {
224
247
  }
225
248
  return result;
226
249
  }
250
+ // The party preferences message a container holds, or null when the customer has not put
251
+ // anything in a cart yet.
252
+ export function partyPreferencesMessageFromBlobs(blobs) {
253
+ return partyPreferencesMessageFromMessages(messagesFromBlobs(blobs));
254
+ }
227
255
  // #endregion
228
256
  // #region Requests and credentials
229
257
  // The request that asks for everything changed since these cursors.
@@ -363,8 +391,12 @@ export function authTokensFromProto(authTokens) {
363
391
  export function buildAuthTokensBytes(authTokens) {
364
392
  return proto_AuthTokens.toBinary(authTokens);
365
393
  }
366
- // How a container keeps the credentials it earned: the two tokens this server issued, with a null eTag because a reissued token is not a later version of an earlier one and greater-wins would refuse it.
367
- // The authentication token is left out however it was passed in: it belongs to the identity provider, so a stored copy could only be staler than the one held in memory and would outlive the sign out that should have ended it.
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.
368
400
  export function authTokensToBlob(authTokens) {
369
401
  const stored = proto_AuthTokens.create({
370
402
  authenticationMethod: authTokens.authenticationMethod,
@@ -507,8 +539,10 @@ function profileSyncOutCursors(request) {
507
539
  }
508
540
  // #endregion
509
541
  // #region A response as an object graph
510
- // The whole of one business as an object graph, regrouping the flat wire entities by owner id so each class can be constructed with its children.
511
- // An entity whose parent is missing is dropped, since a paged read can hold a child whose parent has yet to arrive; null when the response carries no Business at all.
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.
512
546
  export function buildBusiness(response) {
513
547
  const businessProto = response.business;
514
548
  if (businessProto === undefined || isDeletedEntity(getEntity(businessProto.entity)))
@@ -618,10 +652,12 @@ export function buildBusiness(response) {
618
652
  break;
619
653
  }
620
654
  updatableBusiness.catalog = new Catalog(productCategories, products, priceList);
621
- // The orders of this customer, and their cart. A message of any other payload type is dropped.
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.
622
657
  const orders = [];
623
658
  let cart = null;
624
659
  let cartUpdateNumber = 0n;
660
+ let defaultFulfillmentMode = proto_FulfillmentMode.DELIVERY;
625
661
  for (const message of response.messages) {
626
662
  if (isDeletedEntity(getEntity(message.entity)))
627
663
  continue;
@@ -631,8 +667,8 @@ export function buildBusiness(response) {
631
667
  break;
632
668
  }
633
669
  case "partyPreferences": {
634
- // One customer has one cart, so two means two callers' messages are in one container - see
635
- // cartMessageFromBlobs, which breaks the tie the same way
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
636
672
  const updateNumber = getEntity(message.entity).updateNumber;
637
673
  if (cart !== null && updateNumber <= cartUpdateNumber)
638
674
  break;
@@ -641,6 +677,9 @@ export function buildBusiness(response) {
641
677
  // never having had a cart at all
642
678
  cart = cartShoppingListFromProto(message.payload.partyPreferences);
643
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;
644
683
  break;
645
684
  }
646
685
  // No default: MONEY_TRANSFER has no payload in this protocol, and OTHER is not ours
@@ -650,6 +689,7 @@ export function buildBusiness(response) {
650
689
  UpdatableEntity.setParent(order, business);
651
690
  updatableBusiness.orders = Object.freeze(orders);
652
691
  updatableBusiness.cart = cart;
692
+ updatableBusiness.defaultFulfillmentMode = defaultFulfillmentMode;
653
693
  return business;
654
694
  }
655
695
  // One profile as an object graph: the caller, their links, and the businesses those links name.
@@ -681,13 +721,14 @@ export function buildOrder(message) {
681
721
  return null;
682
722
  return orderFromProto(message, message.payload.salesOrder);
683
723
  }
684
- // The cart one message holds, for a caller holding a message rather than a whole response. Null for
685
- // a message that is not a cart, and null for a cart that has been emptied - see the party
686
- // preferences case of buildBusiness.
687
- export function buildCart(message) {
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) {
688
729
  if (message.payload.oneofKind !== "partyPreferences")
689
- return null;
690
- return cartShoppingListFromProto(message.payload.partyPreferences);
730
+ throw new InvalidArgumentException("That message carries no party preferences, so it is not a cart");
731
+ return cartShoppingListFromProto(message.payload.partyPreferences) ?? ShoppingList.EMPTY;
691
732
  }
692
733
  // #endregion
693
734
  // #region What a customer pushes
@@ -699,16 +740,20 @@ export function buildCart(message) {
699
740
  // message the server named could never be found here again. The second is that updatedAt is the
700
741
  // client's too and the server keeps it unchanged - so the copy stored here carries the same eTag as
701
742
  // the copy a later read would bring back, and neither can look stale to the other.
702
- // The cart of this customer as a message to push. An existing message is amended rather than
703
- // rebuilt, because its payload carries fields this protocol does not expose - the fulfillment mode
704
- // the merchant app keeps there among them - and losing them would be a storefront overwriting
705
- // merchant data it cannot even read.
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.
706
747
  // Its id is used in preference to the derived one for the same reason: that is where the server
707
- // already holds this cart, and it is only worth deriving an id when there is no cart to amend.
748
+ // already holds this customer's party preferences, and it is only worth deriving an id when there
749
+ // is none to amend.
708
750
  // A list holding nothing is pushed as no list at all, the way Java serializeShoppingList answers
709
751
  // null below one item - so emptying a cart and never having had one are one state on the wire, and
710
752
  // placing an order is what puts a cart into it.
711
- export function buildCartMessage(cartMessageId, existing, senderId, shoppingList, now) {
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.
756
+ export function buildPartyPreferencesMessage(messageId, existing, senderId, shoppingList, fulfillmentMode, now) {
712
757
  const existingEntity = existing === null ? null : getEntity(existing.entity);
713
758
  const preferences = existing !== null && existing.payload.oneofKind === "partyPreferences"
714
759
  ? existing.payload.partyPreferences
@@ -720,18 +765,18 @@ export function buildCartMessage(cartMessageId, existing, senderId, shoppingList
720
765
  // the two disagreeing about the cart the customer is looking at. One millisecond past the copy
721
766
  // being replaced is enough, and the server keeps whatever is sent, so both sides still agree.
722
767
  const updatedAt = existingEntity === null || existingEntity.updatedAt < now ? now : existingEntity.updatedAt + 1n;
768
+ const partyPreferences = proto_PartyPreferencesPayload.create({
769
+ ...preferences,
770
+ shoppingList: pushedShoppingList(shoppingList, false),
771
+ });
772
+ if (fulfillmentMode !== null)
773
+ partyPreferences.fulfillmentMode = fulfillmentMode;
723
774
  return proto_Message.create({
724
- entity: pushedEntity(existingEntity?.id ?? cartMessageId, existingEntity?.createdAt ?? updatedAt, updatedAt),
775
+ entity: pushedEntity(existingEntity?.id ?? messageId, existingEntity?.createdAt ?? updatedAt, updatedAt),
725
776
  senderId: senderId,
726
777
  messageType: proto_MessageType.PARTY_PREFERENCES,
727
778
  status: proto_TaskStatus.NOT_YET_PROCESSED,
728
- payload: {
729
- oneofKind: "partyPreferences",
730
- partyPreferences: proto_PartyPreferencesPayload.create({
731
- ...preferences,
732
- shoppingList: pushedShoppingList(shoppingList, false),
733
- }),
734
- },
779
+ payload: { oneofKind: "partyPreferences", partyPreferences: partyPreferences },
735
780
  });
736
781
  }
737
782
  // One order as a message to push. Its shopping list goes out final whatever was passed in, which is
@@ -792,24 +837,20 @@ export function highestCachedMessageUpdateNumber(blobs) {
792
837
  function pushedEntity(id, createdAt, updatedAt) {
793
838
  return proto_Entity.create({ id: id, createdAt: createdAt, updatedAt: updatedAt });
794
839
  }
795
- // Whether a cart is worth the request, answered off what the store already holds - so a caller may
796
- // call updateCart as often as it likes and only a cart the server has not got costs anything.
797
- // Two ways it is worth sending. The list differs from the stored one, which is the ordinary case.
798
- // Or the stored copy has never been accepted: a pushed entity goes out with updateNumber zero and
799
- // comes back carrying the server's, so zero here means this cart was written by
800
- // Model.updateCartLocalOnly and has not been sent since.
801
- // The lists are compared rather than the messages, because a message always differs - updatedAt is
802
- // the clock's reading, and buildCartMessage moves it on every call. Comparing them is sound only
803
- // because the server keeps a pushed list exactly as it arrived, so what comes back is what went
804
- // out; a server that normalised the list would make every call differ and push every time, which
805
- // is what this library did before and so is no worse.
806
- export function isCartWorthPushing(stored, cartMessage) {
807
- // Nothing stored and nothing to send: an emptied cart and one never created are one state
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.
848
+ export function hasShoppingListChanged(stored, shoppingList) {
808
849
  if (stored === null)
809
- return getCartShoppingList(cartMessage) !== undefined;
850
+ return pushedShoppingList(shoppingList, false) !== undefined;
810
851
  if (getEntity(stored.entity).updateNumber === 0n)
811
852
  return true;
812
- return !proto_ShoppingList.equals(getCartShoppingList(stored), getCartShoppingList(cartMessage));
853
+ return !proto_ShoppingList.equals(getCartShoppingList(stored), pushedShoppingList(shoppingList, false));
813
854
  }
814
855
  // The list inside a cart message, or absent for one carrying no list - which is an emptied cart
815
856
  function getCartShoppingList(message) {
@@ -817,9 +858,9 @@ function getCartShoppingList(message) {
817
858
  ? message.payload.partyPreferences.shoppingList
818
859
  : undefined;
819
860
  }
820
- // A shopping list on its way out, or absent when it holds nothing - see buildCartMessage. Emptiness
821
- // is judged by the items being sent and not by the counts the list carries, for the reason the
822
- // counts below are.
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.
823
864
  function pushedShoppingList(shoppingList, isFinal) {
824
865
  if (shoppingList === null)
825
866
  return undefined;
@@ -1034,7 +1075,8 @@ function workingHoursFromProto(workingHours) {
1034
1075
  function workingDaysFromProto(workingDays) {
1035
1076
  if (workingDays === undefined)
1036
1077
  return WorkingDays.ALL_DAYS;
1037
- return new WorkingDays(Object.freeze([...workingDays.days]), Object.freeze(workingDays.workingHours.map(workingHoursFromProto)));
1078
+ // Not an optional field, so an unspecified zone arrives as an empty string rather than absent
1079
+ return new WorkingDays(Object.freeze([...workingDays.days]), workingDays.timeZone.length === 0 ? null : workingDays.timeZone, Object.freeze(workingDays.workingHours.map(workingHoursFromProto)));
1038
1080
  }
1039
1081
  function shoppingListItemFromProto(item) {
1040
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));
@@ -15,9 +15,9 @@ export default class SyncCache {
15
15
  readOrders(containerName: string): Promise<ReadonlyArray<proto_Message>>;
16
16
  readMessageUpdateNumber(containerName: string): Promise<bigint>;
17
17
  writeOrder(containerName: string, order: proto_Message): Promise<number>;
18
- readCart(containerName: string): Promise<proto_Message | null>;
19
- writeCart(containerName: string, cart: proto_Message): Promise<number>;
20
- deleteCart(containerName: string): Promise<number>;
18
+ readPartyPreferences(containerName: string): Promise<proto_Message | null>;
19
+ writePartyPreferences(containerName: string, partyPreferences: proto_Message): Promise<number>;
20
+ deletePartyPreferences(containerName: string): Promise<number>;
21
21
  writeMessages(containerName: string, messages: ReadonlyArray<proto_Message>): Promise<number>;
22
22
  private readAll;
23
23
  private writeBlobs;
package/dist/SyncCache.js CHANGED
@@ -53,8 +53,8 @@ export default class SyncCache {
53
53
  return ProtoMap.orderMessagesFromBlobs(await this.readAll(containerName));
54
54
  }
55
55
  // The cursor a push sends: the highest update number among this customer's messages already
56
- // cached here, cart and orders alike. Not persisted, like every cursor in this library - read off
57
- // the store fresh each time rather than kept anywhere.
56
+ // cached here, party preferences and orders alike. Not persisted, like every cursor in this
57
+ // library - read off the store fresh each time rather than kept anywhere.
58
58
  async readMessageUpdateNumber(containerName) {
59
59
  return ProtoMap.highestCachedMessageUpdateNumber(await this.readAll(containerName));
60
60
  }
@@ -62,36 +62,38 @@ export default class SyncCache {
62
62
  async writeOrder(containerName, order) {
63
63
  return this.writeBlobs(containerName, [ProtoMap.orderToBlob(order)]);
64
64
  }
65
- // The cart in this container, or null when there is none. Two carts mean two callers, which is the same open question as the orders above.
66
- async readCart(containerName) {
67
- return ProtoMap.cartMessageFromBlobs(await this.readAll(containerName));
68
- }
69
- async writeCart(containerName, cart) {
70
- return this.writeBlobs(containerName, [ProtoMap.cartToBlob(cart)]);
71
- }
72
- // Drops the cart this container holds, and answers zero when it holds none. The one delete here
73
- // that no tombstone asked for, and the only reason it exists: a cart written before anybody
74
- // signed in is handed over to the container named for the membership, and the copy left behind
75
- // would otherwise be read by the next anonymous visitor and by the next customer to sign in - see
76
- // Model.businessSyncIn. Nothing else about a container is a caller's to remove: everything in one
77
- // came off the wire, and what the server has not said is gone stays.
78
- async deleteCart(containerName) {
79
- const cart = await this.readCart(containerName);
80
- if (cart === null)
65
+ // The party preferences message in this container, or null when there is none. Two mean two
66
+ // callers, which is the same open question as the orders above.
67
+ async readPartyPreferences(containerName) {
68
+ return ProtoMap.partyPreferencesMessageFromBlobs(await this.readAll(containerName));
69
+ }
70
+ async writePartyPreferences(containerName, partyPreferences) {
71
+ return this.writeBlobs(containerName, [ProtoMap.partyPreferencesToBlob(partyPreferences)]);
72
+ }
73
+ // Drops the party preferences message this container holds, and answers zero when it holds none.
74
+ // The one delete here that no tombstone asked for, and the only reason it exists: one written
75
+ // before anybody signed in is handed over to the container named for the membership, and the
76
+ // copy left behind would otherwise be read by the next anonymous visitor and by the next customer
77
+ // to sign in - see Model.businessSyncIn. Nothing else about a container is a caller's to remove:
78
+ // everything in one came off the wire, and what the server has not said is gone stays.
79
+ async deletePartyPreferences(containerName) {
80
+ const partyPreferences = await this.readPartyPreferences(containerName);
81
+ if (partyPreferences === null)
81
82
  return 0;
82
83
  return this.write(containerName, {
83
84
  blobs: NO_BLOBS,
84
- deletedIds: [ProtoMap.cartToBlob(cart).id],
85
+ deletedIds: [ProtoMap.partyPreferencesToBlob(partyPreferences).id],
85
86
  });
86
87
  }
87
- // The messages a sync in response carried, in a single batch - a pushed order and the cart it
88
- // emptied belong in one write, so that this cache cannot come to hold the order without the
89
- // emptying. Tombstones included, the same as any other entity array read off the wire: this is
90
- // what a push's own messages are stored as once the server has echoed them back with real update
91
- // numbers, and it is also how anything else of this customer's that the response carried lands
92
- // here. Neither is checked against a payload type, unlike the two writers above - those are for a
93
- // caller handing in a message it was given, where storing an order as a cart would be a mistake
94
- // worth catching, and this is for messages the response itself is the authority on.
88
+ // The messages a sync in response carried, in a single batch - a pushed order and the party
89
+ // preferences message it emptied belong in one write, so that this cache cannot come to hold the
90
+ // order without the emptying. Tombstones included, the same as any other entity array read off
91
+ // the wire: this is what a push's own messages are stored as once the server has echoed them back
92
+ // with real update numbers, and it is also how anything else of this customer's that the response
93
+ // carried lands here. Neither is checked against a payload type, unlike the two writers above -
94
+ // those are for a caller handing in a message it was given, where storing an order as party
95
+ // preferences would be a mistake worth catching, and this is for messages the response itself is
96
+ // the authority on.
95
97
  async writeMessages(containerName, messages) {
96
98
  return this.write(containerName, ProtoMap.messagesToCacheWrite(messages));
97
99
  }
@@ -1,14 +1,15 @@
1
1
  export default class WorkingDays {
2
2
  private readonly _days;
3
+ private readonly _timeZone;
3
4
  private readonly _workingHours;
4
- constructor(_days: ReadonlyArray<boolean>, _workingHours: ReadonlyArray<WorkingHours>);
5
+ constructor(_days: ReadonlyArray<boolean>, _timeZone: string | null, _workingHours: ReadonlyArray<WorkingHours>);
5
6
  get days(): ReadonlyArray<boolean>;
7
+ get timeZone(): string | null;
6
8
  get workingHours(): ReadonlyArray<WorkingHours>;
7
9
  get includesAllDaysOfWeek(): boolean;
8
10
  isWorkingDay(dayOfWeek: number): boolean;
9
11
  getWorkingHours(dayOfWeek: number): WorkingHours | null;
10
12
  isOpenAllDay(dayOfWeek: number): boolean;
11
- isOpenAt(dayOfWeek: number, minuteOfDay: number): boolean;
12
13
  private static checkDayOfWeek;
13
14
  static readonly ALL_DAYS: WorkingDays;
14
15
  }
@@ -2,11 +2,12 @@ import { Constants as proto_Constants } from "./generated/Constants";
2
2
  import { InvalidArgumentException } from "./Exceptions";
3
3
  // Days and hours a business works. Absence is the permissive value throughout: no WorkingDays
4
4
  // means every day (ALL_DAYS); a day with no hours/ranges means worked all day.
5
- // No time zone here (the protocol carries none) - only a caller that knows the business's zone
6
- // can turn a moment into these day/minute values.
5
+ // The hours are minutes after local midnight in timeZone, so a caller asking about a moment reads
6
+ // it in that zone first, as a day and a minute.
7
7
  class WorkingDays {
8
- constructor(_days, _workingHours) {
8
+ constructor(_days, _timeZone, _workingHours) {
9
9
  this._days = _days;
10
+ this._timeZone = _timeZone;
10
11
  this._workingHours = _workingHours;
11
12
  }
12
13
  // Index 0 is Sunday and index 6 is Saturday, the same numbering as Date.getDay. Shorter than
@@ -14,6 +15,13 @@ class WorkingDays {
14
15
  get days() {
15
16
  return this._days;
16
17
  }
18
+ // The IANA ID of the zone the hours are in, for example "Asia/Kolkata" - never an abbreviation
19
+ // like "IST". Null when none was specified: settings saved before the protocol carried one, and
20
+ // ALL_DAYS. Then the hours cannot be placed on a clock, and only a caller that knows the
21
+ // business's zone can read a moment into them.
22
+ get timeZone() {
23
+ return this._timeZone;
24
+ }
17
25
  // Indexed exactly as days above. Shorter than days, or empty, is normal: see the comment on
18
26
  // this class for what a missing entry means.
19
27
  get workingHours() {
@@ -41,20 +49,14 @@ class WorkingDays {
41
49
  isOpenAllDay(dayOfWeek) {
42
50
  return this.isWorkingDay(dayOfWeek) && this.getWorkingHours(dayOfWeek) === null;
43
51
  }
44
- // minuteOfDay is minutes after local midnight, from 0 to Constants.MINUTES_PER_DAY - 1
45
- isOpenAt(dayOfWeek, minuteOfDay) {
46
- if (!this.isWorkingDay(dayOfWeek))
47
- return false;
48
- const workingHours = this.getWorkingHours(dayOfWeek);
49
- return workingHours === null || workingHours.contains(minuteOfDay);
50
- }
51
52
  static checkDayOfWeek(dayOfWeek) {
52
53
  if (!Number.isInteger(dayOfWeek) || dayOfWeek < 0 || dayOfWeek >= proto_Constants.DAYS_IN_WEEK)
53
54
  throw new InvalidArgumentException(`Not a day of the week: ${dayOfWeek}`);
54
55
  }
55
56
  }
56
- // What an absent WorkingDays means: the business works every day, all day long
57
- WorkingDays.ALL_DAYS = new WorkingDays(Object.freeze([true, true, true, true, true, true, true]), Object.freeze([]));
57
+ // What an absent WorkingDays means: the business works every day, all day long. With no hours
58
+ // there is nothing for a zone to place, so it has none.
59
+ WorkingDays.ALL_DAYS = new WorkingDays(Object.freeze([true, true, true, true, true, true, true]), null, Object.freeze([]));
58
60
  export default WorkingDays;
59
61
  // The hours of a single day that are worked, as the stretches of it that are.
60
62
  export class WorkingHours {
@@ -73,6 +75,8 @@ export class WorkingHours {
73
75
  get isAllDay() {
74
76
  return (this._ranges.length === 0 || (this._ranges.length === 1 && this._ranges[0].isAllDay));
75
77
  }
78
+ // minuteOfDay is minutes after local midnight in the zone of the WorkingDays these hours belong
79
+ // to, from 0 to Constants.MINUTES_PER_DAY - 1
76
80
  contains(minuteOfDay) {
77
81
  if (this.isAllDay)
78
82
  return true;
@@ -20,12 +20,20 @@ export interface WorkingDays {
20
20
  * @generated from protobuf field: repeated bool days = 1
21
21
  */
22
22
  days: boolean[];
23
+ /**
24
+ * The IANA ID of the time zone the working hours are specified in, for example "Asia/Kolkata";
25
+ * never an abbreviation like "IST", which is ambiguous. At most 64 characters. Empty means it
26
+ * was never specified, as in settings saved before this field existed.
27
+ *
28
+ * @generated from protobuf field: string timeZone = 2
29
+ */
30
+ timeZone: string;
23
31
  /**
24
32
  * Working hours of each day, indexed as days above (entry 0 = Sunday); up to
25
33
  * Constants.DAYS_IN_WEEK entries, fewer is normal. A missing entry, or one with no ranges, means
26
34
  * work all day; only meaningful on a day whose days entry is true.
27
35
  *
28
- * @generated from protobuf field: repeated com.merabills.storefront.WorkingHours workingHours = 2
36
+ * @generated from protobuf field: repeated com.merabills.storefront.WorkingHours workingHours = 3
29
37
  */
30
38
  workingHours: WorkingHours[];
31
39
  }
@@ -8,12 +8,14 @@ class WorkingDays$Type extends MessageType {
8
8
  constructor() {
9
9
  super("com.merabills.storefront.WorkingDays", [
10
10
  { no: 1, name: "days", kind: "scalar", repeat: 1 /*RepeatType.PACKED*/, T: 8 /*ScalarType.BOOL*/ },
11
- { no: 2, name: "workingHours", kind: "message", repeat: 2 /*RepeatType.UNPACKED*/, T: () => WorkingHours }
11
+ { no: 2, name: "timeZone", kind: "scalar", T: 9 /*ScalarType.STRING*/ },
12
+ { no: 3, name: "workingHours", kind: "message", repeat: 2 /*RepeatType.UNPACKED*/, T: () => WorkingHours }
12
13
  ]);
13
14
  }
14
15
  create(value) {
15
16
  const message = globalThis.Object.create((this.messagePrototype));
16
17
  message.days = [];
18
+ message.timeZone = "";
17
19
  message.workingHours = [];
18
20
  if (value !== undefined)
19
21
  reflectionMergePartial(this, message, value);
@@ -31,7 +33,10 @@ class WorkingDays$Type extends MessageType {
31
33
  else
32
34
  message.days.push(reader.bool());
33
35
  break;
34
- case /* repeated com.merabills.storefront.WorkingHours workingHours */ 2:
36
+ case /* string timeZone */ 2:
37
+ message.timeZone = reader.string();
38
+ break;
39
+ case /* repeated com.merabills.storefront.WorkingHours workingHours */ 3:
35
40
  message.workingHours.push(WorkingHours.internalBinaryRead(reader, reader.uint32(), options));
36
41
  break;
37
42
  default:
@@ -53,9 +58,12 @@ class WorkingDays$Type extends MessageType {
53
58
  writer.bool(message.days[i]);
54
59
  writer.join();
55
60
  }
56
- /* repeated com.merabills.storefront.WorkingHours workingHours = 2; */
61
+ /* string timeZone = 2; */
62
+ if (message.timeZone !== "")
63
+ writer.tag(2, WireType.LengthDelimited).string(message.timeZone);
64
+ /* repeated com.merabills.storefront.WorkingHours workingHours = 3; */
57
65
  for (let i = 0; i < message.workingHours.length; i++)
58
- WorkingHours.internalBinaryWrite(message.workingHours[i], writer.tag(2, WireType.LengthDelimited).fork(), options).join();
66
+ WorkingHours.internalBinaryWrite(message.workingHours[i], writer.tag(3, WireType.LengthDelimited).fork(), options).join();
59
67
  let u = options.writeUnknownFields;
60
68
  if (u !== false)
61
69
  (u == true ? UnknownFieldHandler.onWrite : u)(this.typeName, message, writer);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@peabody-soft/mbs-model-ts",
3
- "version": "1.2.0",
3
+ "version": "1.4.0",
4
4
  "description": "MeraBills storefront client-side model (TypeScript)",
5
5
  "author": "Peabody Soft",
6
6
  "license": "AGPL-3.0-or-later",
@@ -49,7 +49,7 @@
49
49
  "typescript": "^7.0.2"
50
50
  },
51
51
  "dependencies": {
52
- "@peabody-soft/mbs-protos": "^1.9.0",
52
+ "@peabody-soft/mbs-protos": "^1.10.0",
53
53
  "@protobuf-ts/runtime": "^2.11.1"
54
54
  }
55
55
  }