@peabody-soft/mbs-model-ts 1.6.0 → 1.7.1
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.
- package/dist/Attachment.js +2 -2
- package/dist/AttachmentBaseUrl.js +4 -4
- package/dist/AuthTokens.js +7 -7
- package/dist/Business.js +11 -10
- package/dist/BusinessIdentifier.js +3 -2
- package/dist/BusinessLink.js +3 -2
- package/dist/CartEditor.js +89 -81
- package/dist/Catalog.js +4 -4
- package/dist/Constants.js +2 -2
- package/dist/ContainerNames.js +6 -6
- package/dist/Entity.js +4 -3
- package/dist/Enums.js +2 -2
- package/dist/Exceptions.js +2 -2
- package/dist/GeneralSettings.js +9 -7
- package/dist/HttpStatusCodes.js +7 -7
- package/dist/Ids.js +4 -5
- package/dist/Item.js +6 -4
- package/dist/LiveData.js +2 -2
- package/dist/Measures.js +3 -2
- package/dist/Message.js +1 -1
- package/dist/MockDatabase.js +6 -5
- package/dist/Model.js +167 -167
- package/dist/Order.js +10 -12
- package/dist/PackageIdentifier.js +2 -2
- package/dist/Price.js +6 -5
- package/dist/PriceList.js +1 -1
- package/dist/Product.js +8 -6
- package/dist/Profile.js +15 -14
- package/dist/ProtoMap.js +159 -155
- package/dist/ServiceExceptions.js +6 -6
- package/dist/ServiceProxy.js +15 -14
- package/dist/ShoppingList.js +101 -100
- package/dist/SyncCache.js +76 -68
- package/dist/SyncKeys.js +5 -5
- package/dist/SyncState.js +14 -6
- package/dist/Tax.js +11 -10
- package/dist/TaxHelper.js +9 -8
- package/dist/TaxType.js +7 -6
- package/dist/UserProfile.js +7 -7
- package/dist/WorkingDays.js +21 -18
- package/dist/index.js +11 -8
- package/package.json +1 -1
package/dist/Model.js
CHANGED
|
@@ -13,23 +13,25 @@ import ShoppingList from "./ShoppingList";
|
|
|
13
13
|
import SyncCache from "./SyncCache";
|
|
14
14
|
import * as SyncKeys from "./SyncKeys";
|
|
15
15
|
import SyncState from "./SyncState";
|
|
16
|
-
// The root of the object model, and
|
|
16
|
+
// The root of the object model, and the only object a UI needs. Call initialize once, then get the
|
|
17
17
|
// current instance with getInstance.
|
|
18
18
|
//
|
|
19
19
|
// THERE IS NO LOCKING. A caller makes one call at a time and awaits it. Otherwise:
|
|
20
20
|
// - A setBusinessId still reading when signIn reads the business too can publish the anonymous
|
|
21
|
-
// container's business over the customer's
|
|
21
|
+
// container's business over the customer's. The published business then has no cart or orders,
|
|
22
|
+
// and nothing publishes them again.
|
|
22
23
|
// - Two reads at once interleave their pages and writes, as do two tabs on one store (each holds a
|
|
23
24
|
// Model), so a store supports one tab at a time.
|
|
24
25
|
// A queue inside Model would not fix the tabs, and each public method would need an unqueued twin
|
|
25
26
|
// (signIn calls signOut, businessSyncIn calls updateCart). A caller that calls from several places,
|
|
26
|
-
// like a UI, queues
|
|
27
|
+
// like a UI, queues the calls itself.
|
|
27
28
|
class Model {
|
|
28
29
|
constructor() {
|
|
29
30
|
// True when the published business, if any, has been read to the end and found to be the one
|
|
30
|
-
// _businessId names and to be open.
|
|
31
|
-
//
|
|
32
|
-
// leaves
|
|
31
|
+
// _businessId names and to be open. The cache-first publish of a read holds what an earlier read
|
|
32
|
+
// stored, which is not known to be either until the read ends. A read that does not end (network
|
|
33
|
+
// failure, a rejection) leaves the business published and unvetted. See readJoinBusinessId, which
|
|
34
|
+
// does not join such a business.
|
|
33
35
|
this._isPublishedBusinessVetted = false;
|
|
34
36
|
this._business = new LiveData(null);
|
|
35
37
|
this._profile = new LiveData(null);
|
|
@@ -38,7 +40,8 @@ class Model {
|
|
|
38
40
|
this._authenticationToken = null;
|
|
39
41
|
this._businessId = null;
|
|
40
42
|
}
|
|
41
|
-
// Sets the service and store to use,
|
|
43
|
+
// Sets the service and store to use. Discards the current instance, so nobody is signed in and no
|
|
44
|
+
// business is browsed.
|
|
42
45
|
static initialize(service, database) {
|
|
43
46
|
Model._service = service;
|
|
44
47
|
Model._cache = new SyncCache(database);
|
|
@@ -52,17 +55,17 @@ class Model {
|
|
|
52
55
|
get business() {
|
|
53
56
|
return this._business;
|
|
54
57
|
}
|
|
55
|
-
// The customer, their businesses, and the link to each
|
|
56
|
-
//
|
|
58
|
+
// The customer, their businesses, and the link to each, in one LiveData because a UI usually
|
|
59
|
+
// needs all three together. Null until signed in and loaded.
|
|
57
60
|
get profile() {
|
|
58
61
|
return this._profile;
|
|
59
62
|
}
|
|
60
63
|
// Signs in and syncs this subject's profile in, for a subject the server has a profile for (use
|
|
61
64
|
// createUser otherwise). Also reads in the business being browsed, if any, as this customer.
|
|
62
65
|
//
|
|
63
|
-
// Anything thrown after beginSignIn's guard (a bad credential, a
|
|
66
|
+
// Anything thrown after beginSignIn's guard (a bad credential, a rejected push while reading the
|
|
64
67
|
// business) calls signOut. profileSyncIn publishes a cached profile before it reaches the
|
|
65
|
-
// network, so a retry could otherwise find _profile set and
|
|
68
|
+
// network, so a retry could otherwise find _profile set and fail the "already signed in" guard.
|
|
66
69
|
// Throws NotFoundException if the business being browsed cannot be opened. See businessSyncIn.
|
|
67
70
|
async signIn(authenticationMethod, subjectId, authenticationToken) {
|
|
68
71
|
this.beginSignIn(authenticationMethod, subjectId, authenticationToken);
|
|
@@ -80,7 +83,7 @@ class Model {
|
|
|
80
83
|
// Signs up and creates userToCreate as this subject's profile, for a subject the server has no
|
|
81
84
|
// profile for (use signIn otherwise). Also reads in the business being browsed, if any, as this
|
|
82
85
|
// customer. Throws NotFoundException if that business cannot be opened (see businessSyncIn), after
|
|
83
|
-
// the User exists.
|
|
86
|
+
// the User exists. The sign-up is not undone: the customer stays signed up and signed in.
|
|
84
87
|
async createUser(authenticationMethod, subjectId, authenticationToken, userToCreate) {
|
|
85
88
|
this.beginSignIn(authenticationMethod, subjectId, authenticationToken);
|
|
86
89
|
const authTokens = await this.requireProfileAuthTokens();
|
|
@@ -90,9 +93,9 @@ class Model {
|
|
|
90
93
|
}
|
|
91
94
|
// Updates this customer's own profile with what userProfile holds. Service.updateUser matches the
|
|
92
95
|
// caller against the User by entity id (see ProtoMap.buildUpdateUser), so userProfile must be this
|
|
93
|
-
// customer's own, read in earlier, not built fresh.
|
|
94
|
-
//
|
|
95
|
-
//
|
|
96
|
+
// customer's own, read in earlier, not built fresh. Throws if no profile has been read. Not
|
|
97
|
+
// paged. Publishes nothing today: the response carries no entities, only reissued tokens (see
|
|
98
|
+
// writeProfileResponse).
|
|
96
99
|
async updateUser(userProfile) {
|
|
97
100
|
if (this._profile.value === null)
|
|
98
101
|
throw new IllegalOperationException("Nobody is signed in, so this call cannot be made");
|
|
@@ -103,18 +106,18 @@ class Model {
|
|
|
103
106
|
Model._instance = null;
|
|
104
107
|
}
|
|
105
108
|
// Names the business to browse and reads it in. Publishes null first when replacing a Business,
|
|
106
|
-
// so the old one is not shown as the one now browsed.
|
|
107
|
-
//
|
|
109
|
+
// so the old one is not shown as the one now browsed. Re-reading the current business does not,
|
|
110
|
+
// because that would blank the UI and discard syncBusinessContainer's cache-first publish.
|
|
108
111
|
// Throws NotFoundException if the business cannot be opened (see businessSyncIn). What was cached
|
|
109
|
-
// is published before the network
|
|
110
|
-
//
|
|
111
|
-
// The businessId is held in canonical form (see canonicalizeBusinessId),
|
|
112
|
-
// so /WARM-LOAF and /warm-loaf are one storefront, one container and one download.
|
|
113
|
-
// canonicalizes
|
|
112
|
+
// is published before the network call, unless online shopping is off (see publishBusiness), and
|
|
113
|
+
// is replaced with null if the read finds the business cannot be opened.
|
|
114
|
+
// The businessId is held in canonical form (see canonicalizeBusinessId), the form the server finds
|
|
115
|
+
// a business by, so /WARM-LOAF and /warm-loaf are one storefront, one container and one download.
|
|
116
|
+
// The server canonicalizes the id it is sent.
|
|
114
117
|
async setBusinessId(businessId) {
|
|
115
|
-
// An id of only spaces has no canonical form (Java
|
|
116
|
-
// server then answers with the NotFoundException a UI shows for a missing business
|
|
117
|
-
// does not
|
|
118
|
+
// An id of only spaces has no canonical form (Java rejects it), so it is held as written. The
|
|
119
|
+
// server then answers with the NotFoundException a UI shows for a missing business. This method
|
|
120
|
+
// does not throw for it.
|
|
118
121
|
const canonicalBusinessId = canonicalizeBusinessId(businessId) || businessId;
|
|
119
122
|
const isDifferentBusiness = this._businessId !== canonicalBusinessId;
|
|
120
123
|
this._businessId = canonicalBusinessId;
|
|
@@ -126,18 +129,19 @@ class Model {
|
|
|
126
129
|
//
|
|
127
130
|
// The only writes in this library: this customer's cart at the business being browsed, and an
|
|
128
131
|
// order placed from it. Both need a customer who is a member of the business (see
|
|
129
|
-
// requireCustomerOfBusiness)
|
|
132
|
+
// requireCustomerOfBusiness), except that a signed-out visitor can keep a cart locally (see
|
|
133
|
+
// updateCartLocalOnly).
|
|
130
134
|
// An order reaches the store only after the server accepts it, so a customer never holds an order
|
|
131
|
-
// the
|
|
132
|
-
//
|
|
135
|
+
// the server has not accepted, and a failed call leaves nothing to reconcile. A cart can be
|
|
136
|
+
// stored before the server has it (see updateCartLocalOnly).
|
|
133
137
|
// Creates or replaces this customer's cart, on the server and in the store. An empty list
|
|
134
138
|
// empties the cart, the same state as never having had one.
|
|
135
|
-
// Signed out, this
|
|
136
|
-
// costs a request, so an editor should write each change with updateCartLocalOnly and call this
|
|
137
|
-
//
|
|
139
|
+
// Signed out, this calls updateCartLocalOnly, as the server has no cart for a visitor. Signed in,
|
|
140
|
+
// it costs a request, so an editor should write each change with updateCartLocalOnly and call this
|
|
141
|
+
// on a schedule (debounced, on blur, on a timer).
|
|
138
142
|
// A call that matches the stored copy returns without a request (hasShoppingListChanged). It does
|
|
139
|
-
// not replace
|
|
140
|
-
//
|
|
143
|
+
// not replace a read of the business: the response to a push holds only the pushed messages, so
|
|
144
|
+
// it brings no changes made by others.
|
|
141
145
|
async updateCart(shoppingList) {
|
|
142
146
|
if (this._profile.value === null)
|
|
143
147
|
return this.updateCartLocalOnly(shoppingList);
|
|
@@ -148,37 +152,38 @@ class Model {
|
|
|
148
152
|
const partyPreferencesMessage = ProtoMap.buildPartyPreferencesMessage(Ids.getPartyPreferencesMessageId(target.businessLink.id), stored, target.senderId, shoppingList, null, BigInt(Date.now()));
|
|
149
153
|
await this.pushBusinessMessages(target, [partyPreferencesMessage]);
|
|
150
154
|
}
|
|
151
|
-
// The same, stored locally and not sent. Call it on every edit (a request per tap of a
|
|
152
|
-
// stepper is
|
|
155
|
+
// The same, but stored locally and not sent. Call it on every edit (a request per tap of a
|
|
156
|
+
// quantity stepper is too many), and call updateCart on a schedule to send.
|
|
153
157
|
// Unsent, nothing is lost: it survives a closed tab, the next updateCart sends it, and createOrder
|
|
154
|
-
// reads from the store. The cost is that the server, and so this customer's other devices,
|
|
158
|
+
// reads from the store. The cost is that the server, and so this customer's other devices, receive
|
|
155
159
|
// the change late.
|
|
156
160
|
// A stale sync cannot bring an older server copy back over it: the store versions an amendment at
|
|
157
161
|
// the update number of the copy it amends, buildPartyPreferencesMessage dates it past that copy,
|
|
158
162
|
// and the later copy wins.
|
|
159
163
|
async updateCartLocalOnly(shoppingList) {
|
|
160
|
-
//
|
|
161
|
-
//
|
|
162
|
-
// cart's id, so later edits amend one entity.
|
|
163
|
-
//
|
|
164
|
+
// A signed-out visitor has no membership to derive a message id from, so a fresh id is drawn.
|
|
165
|
+
// It is the cart's id only for the first edit: buildPartyPreferencesMessage reuses the stored
|
|
166
|
+
// cart's id, so later edits amend one entity. With no party, the sender is ENTITY_ID_NULL. The
|
|
167
|
+
// first push after signing in carries the real one.
|
|
164
168
|
if (this._profile.value === null)
|
|
165
169
|
return this.storeCartInCache(ContainerNames.forBusiness(this.requireBusinessBeingBrowsed()), Ids.getNewId(proto_Constants.MESSAGE_ENTITY_TYPE_ID), BigInt(proto_Constants.ENTITY_ID_NULL), shoppingList);
|
|
166
170
|
const target = this.requireCustomerOfBusiness();
|
|
167
171
|
return this.storeCartInCache(target.containerName, Ids.getPartyPreferencesMessageId(target.businessLink.id), target.senderId, shoppingList);
|
|
168
172
|
}
|
|
169
173
|
// Places an order for what is in the cart and empties the cart, as Java
|
|
170
|
-
// SalesOrderViewModel.saveChanges does on MODE_CREATE.
|
|
171
|
-
//
|
|
172
|
-
//
|
|
173
|
-
// The caller
|
|
174
|
-
// placed order, and can retry a call a NetworkConnectionException left unresolved
|
|
175
|
-
// id, without placing a second order. getPartyPreferencesMessageId gives a cart the same
|
|
174
|
+
// SalesOrderViewModel.saveChanges does on MODE_CREATE. It takes no shopping list because it reads
|
|
175
|
+
// the cart from the store.
|
|
176
|
+
// The emptied cart records this fulfillmentMode, which Business.defaultFulfillmentMode returns.
|
|
177
|
+
// The caller creates orderId with Ids.getNewId(Constants.MESSAGE_ENTITY_TYPE_ID), so it can find
|
|
178
|
+
// the placed order, and can retry a call that a NetworkConnectionException left unresolved using
|
|
179
|
+
// the same id, without placing a second order. getPartyPreferencesMessageId gives a cart the same
|
|
180
|
+
// guarantee.
|
|
176
181
|
// A delivery must name an address: an id from this customer's list, or a full address sent
|
|
177
182
|
// directly (see requireDeliveryAddress).
|
|
178
183
|
async createOrder(orderId, fulfillmentMode, deliveryAddressId, deliveryAddress, note) {
|
|
179
184
|
const target = this.requireCustomerOfBusiness();
|
|
180
|
-
// Read from the store
|
|
181
|
-
// not a
|
|
185
|
+
// Read from the store rather than the published Business, so the order matches the stored cart
|
|
186
|
+
// and not a copy an observer may hold
|
|
182
187
|
const partyPreferencesMessage = await Model.getCache().readPartyPreferences(target.containerName);
|
|
183
188
|
const shoppingList = partyPreferencesMessage === null
|
|
184
189
|
? ShoppingList.EMPTY
|
|
@@ -186,7 +191,7 @@ class Model {
|
|
|
186
191
|
if (shoppingList.existingItems.length === 0)
|
|
187
192
|
throw new IllegalOperationException("The cart holds nothing, so there is no order to place");
|
|
188
193
|
Model.requireDeliveryAddress(target.addresses, fulfillmentMode, deliveryAddressId, deliveryAddress);
|
|
189
|
-
// One
|
|
194
|
+
// One timestamp for both messages: the time the order was placed
|
|
190
195
|
const now = BigInt(Date.now());
|
|
191
196
|
// One request for the order and the emptied cart: two calls could leave an order placed and the
|
|
192
197
|
// cart still full if the second failed
|
|
@@ -207,12 +212,12 @@ class Model {
|
|
|
207
212
|
this._authenticationToken = authenticationToken;
|
|
208
213
|
}
|
|
209
214
|
// #region Pushing
|
|
210
|
-
//
|
|
211
|
-
//
|
|
212
|
-
//
|
|
213
|
-
//
|
|
214
|
-
//
|
|
215
|
-
//
|
|
215
|
+
// Pushes the messages in one request, together with the update number this business container's
|
|
216
|
+
// reads have reached, then stores the response. The server answers with these messages under their
|
|
217
|
+
// assigned update numbers and nothing else: a push is not a read. They are stored without moving
|
|
218
|
+
// any cursor, so the next read receives them again, along with anything others changed in the
|
|
219
|
+
// meantime. A rejected push throws from the Service call itself, as for any bad status, so
|
|
220
|
+
// reaching the write below means the server accepted it.
|
|
216
221
|
async pushBusinessMessages(target, messages) {
|
|
217
222
|
const cache = Model.getCache();
|
|
218
223
|
const messageUpdateNumber = await cache.readMessageUpdateNumber(target.containerName);
|
|
@@ -227,9 +232,7 @@ class Model {
|
|
|
227
232
|
this.publishBusiness(await cache.readBusiness(target.containerName));
|
|
228
233
|
}
|
|
229
234
|
// Amends the cart containerName holds and writes the result to the cache. Nothing is pushed.
|
|
230
|
-
// The two
|
|
231
|
-
// freshly drawn id and no sender, a customer's has the id their membership derives and their own
|
|
232
|
-
// sender id.
|
|
235
|
+
// The two callers differ in container, id and sender: see updateCartLocalOnly.
|
|
233
236
|
async storeCartInCache(containerName, messageId, senderId, shoppingList) {
|
|
234
237
|
const cache = Model.getCache();
|
|
235
238
|
const changeCount = await cache.writePartyPreferences(containerName, ProtoMap.buildPartyPreferencesMessage(messageId, await cache.readPartyPreferences(containerName), senderId, shoppingList, null, BigInt(Date.now())));
|
|
@@ -249,7 +252,8 @@ class Model {
|
|
|
249
252
|
}
|
|
250
253
|
// Everything a write needs, gathered before anything is built. Each missing precondition throws
|
|
251
254
|
// its own error: no business browsed, business not loaded, no profile loaded, or a customer who
|
|
252
|
-
// is not a member. The server would
|
|
255
|
+
// is not a member. The server would reject the last anyway, since there is no link token to sign
|
|
256
|
+
// with.
|
|
253
257
|
requireCustomerOfBusiness() {
|
|
254
258
|
const businessId = this.requireBusinessBeingBrowsed();
|
|
255
259
|
const business = this._business.value;
|
|
@@ -270,13 +274,13 @@ class Model {
|
|
|
270
274
|
}
|
|
271
275
|
// Checks where an order is delivered: an id in this customer's address list (the list
|
|
272
276
|
// Order.deliveryAddressId resolves against) or a full address sent directly. An id not in the
|
|
273
|
-
// list would leave the business with an order it cannot read, so it is
|
|
277
|
+
// list would leave the business with an order it cannot read, so it is rejected here. A full
|
|
274
278
|
// address needs no check against the list.
|
|
275
279
|
static requireDeliveryAddress(addresses, fulfillmentMode, deliveryAddressId, deliveryAddress) {
|
|
276
280
|
if (deliveryAddress !== null)
|
|
277
281
|
return;
|
|
278
282
|
if (deliveryAddressId === BigInt(proto_Constants.ENTITY_ID_NULL)) {
|
|
279
|
-
//
|
|
283
|
+
// A pick-up has neither, which is normal. See Order.deliveryAddressId
|
|
280
284
|
if (fulfillmentMode === proto_FulfillmentMode.DELIVERY)
|
|
281
285
|
throw new InvalidArgumentException("An order to be delivered has to name a delivery address");
|
|
282
286
|
return;
|
|
@@ -287,18 +291,20 @@ class Model {
|
|
|
287
291
|
// #endregion
|
|
288
292
|
// #region The four calls of Service, each as this model makes it
|
|
289
293
|
//
|
|
290
|
-
// Each
|
|
291
|
-
// the cache then holds.
|
|
294
|
+
// Each caches the response (a read caches every page as it arrives), files the tokens the server
|
|
295
|
+
// issued, and publishes what the cache then holds. A read is paged. A sign up and an update are not.
|
|
292
296
|
// Reads in the business being browsed: an anonymous visitor under its businessId, a signed-in
|
|
293
297
|
// customer under their own container (see getBusinessContainerName), joining as a customer first
|
|
294
298
|
// if not linked (see requireBusinessLink).
|
|
295
299
|
// Throws NotFoundException when the business cannot be opened, on a first read or a later one (a
|
|
296
|
-
// business can stop being open after it was cached). The server
|
|
297
|
-
// is deleted, or does not allow customers to join.
|
|
298
|
-
//
|
|
299
|
-
//
|
|
300
|
-
//
|
|
301
|
-
//
|
|
300
|
+
// business can stop being open after it was cached). The server throws it when the business does
|
|
301
|
+
// not exist, is deleted, or does not allow customers to join. This method throws it too when
|
|
302
|
+
// online shopping is off (see syncBusinessContainer). A business with nothing to buy is open,
|
|
303
|
+
// with an empty catalog. The cache is kept, unless it holds a business other than the one the
|
|
304
|
+
// businessId now names, which is replaced.
|
|
305
|
+
// isStartedOver is true on the single recursive call. That call happens when the profile's link
|
|
306
|
+
// for the businessId belongs to a business that no longer has it: the profile is read again and
|
|
307
|
+
// this method starts over.
|
|
302
308
|
async businessSyncIn(isStartedOver = false) {
|
|
303
309
|
const businessId = this._businessId;
|
|
304
310
|
if (businessId === null || businessId.length === 0)
|
|
@@ -335,11 +341,11 @@ class Model {
|
|
|
335
341
|
const authTokens = await this.requireBusinessAuthTokens(containerName, link);
|
|
336
342
|
// The link names the business by entity id, because the link token that signs this call is for it
|
|
337
343
|
const isBusinessNamed = await this.syncBusinessContainer(containerName, businessId, link.businessId, ProtoMap.buildAuthTokens(authTokens));
|
|
338
|
-
// The link's business has
|
|
339
|
-
//
|
|
340
|
-
//
|
|
341
|
-
// once: a re-read profile
|
|
342
|
-
// server contradicts itself
|
|
344
|
+
// The link's business has a different businessId than the profile knew when it found the link
|
|
345
|
+
// by this one. The container is discarded and the profile is read again. The link is then found
|
|
346
|
+
// by this businessId or not, and if the businessId names another business, that business is
|
|
347
|
+
// joined. This happens only once: if a re-read profile still finds the same link for the same
|
|
348
|
+
// businessId, the server contradicts itself
|
|
343
349
|
if (!isBusinessNamed) {
|
|
344
350
|
if (isStartedOver)
|
|
345
351
|
throw new InternalServerException(`The business that the link for the businessId ${businessId} is of does not have it, though the profile was read again`);
|
|
@@ -353,8 +359,8 @@ class Model {
|
|
|
353
359
|
return;
|
|
354
360
|
// The visitor's cart holds taxes as raw codes, meaningful only within the country of the
|
|
355
361
|
// business it was built in. That country is read from the anonymous container. A cart is stored
|
|
356
|
-
// only after its business has been (see updateCartLocalOnly)
|
|
357
|
-
// browsed is the same
|
|
362
|
+
// only after its business has been read (see updateCartLocalOnly), so the container normally
|
|
363
|
+
// has the country. If it has none, the business being browsed is the same business.
|
|
358
364
|
const anonymousCountryCode = ProtoMap.getBusinessResponseCountryCode(anonymous) ?? this._business.value?.countryCode;
|
|
359
365
|
if (anonymousCountryCode === undefined)
|
|
360
366
|
throw new IllegalOperationException("The business the visitor's cart was built in is not known, so its taxes cannot be read");
|
|
@@ -374,27 +380,27 @@ class Model {
|
|
|
374
380
|
async syncAnonymousBusiness(businessId) {
|
|
375
381
|
await this.syncBusinessContainer(ContainerNames.forBusiness(businessId), businessId, null, null);
|
|
376
382
|
}
|
|
377
|
-
// Reads one business into containerName, cache first:
|
|
378
|
-
//
|
|
379
|
-
// to (see SyncCache.readBusinessCursors), so a repeat call
|
|
383
|
+
// Reads one business into containerName, cache first: the cached business is published before any
|
|
384
|
+
// network call, so a returning customer sees the shop at once. Cursors start where earlier reads
|
|
385
|
+
// got to (see SyncCache.readBusinessCursors), so a repeat call requests only what changed.
|
|
380
386
|
// The first request names the business by entity id where known (businessEntityId from a link,
|
|
381
387
|
// else the one in the container), else by businessId, and every later page does the same.
|
|
382
|
-
// firstAuthTokens are the link's and sign the reads by entity id. A read by businessId is
|
|
383
|
-
// with
|
|
388
|
+
// firstAuthTokens are the link's and sign the reads by entity id. A read by businessId is sent
|
|
389
|
+
// with no tokens.
|
|
384
390
|
//
|
|
385
391
|
// Whether the business can be opened is decided from the container once every page is written,
|
|
386
392
|
// since a page carries only what changed. After every read, in order:
|
|
387
393
|
// 1. The container holds the business businessId names. A business keeps its entity id when its
|
|
388
394
|
// businessId changes and another can take the old one, so a container read by entity id may
|
|
389
|
-
// hold the wrong business. It is
|
|
395
|
+
// hold the wrong business. It is discarded and read again from the start by businessId, once,
|
|
390
396
|
// which finds the right business or none (NotFoundException from the server).
|
|
391
|
-
// A read named by a link cannot be redone that way (a link token is
|
|
392
|
-
// businessId). Its container is
|
|
393
|
-
// from a re-read profile (see businessSyncIn). Otherwise this
|
|
397
|
+
// A read named by a link cannot be redone that way (a link token is rejected with a
|
|
398
|
+
// businessId). Its container is discarded and this returns false, so the caller starts again
|
|
399
|
+
// from a re-read profile (see businessSyncIn). Otherwise this returns true.
|
|
394
400
|
// 2. Online shopping is on in the general settings (no settings means off). The server does not
|
|
395
|
-
//
|
|
396
|
-
//
|
|
397
|
-
//
|
|
401
|
+
// reject a business for this, so this throws NotFoundException.
|
|
402
|
+
// A business with nothing to buy (no enabled product that is sold online and has a priced
|
|
403
|
+
// package) is open, and is published with an empty catalog.
|
|
398
404
|
async syncBusinessContainer(containerName, businessId, businessEntityId, firstAuthTokens) {
|
|
399
405
|
const cache = Model.getCache();
|
|
400
406
|
const cached = await cache.readBusiness(containerName);
|
|
@@ -405,14 +411,15 @@ class Model {
|
|
|
405
411
|
// does. It may not be what the businessId finds now: a business can give up its businessId to
|
|
406
412
|
// another, or change it.
|
|
407
413
|
const cachedEntityId = ProtoMap.getBusinessResponseBusinessEntityId(cached);
|
|
408
|
-
//
|
|
414
|
+
// How much the store accepted: blobs stored plus ids deleted, as in the Publishing region.
|
|
415
|
+
// Decides the publish at the end.
|
|
409
416
|
let changeCount;
|
|
410
417
|
try {
|
|
411
418
|
changeCount = await this.readBusinessPages(containerName, businessEntityId ?? cachedEntityId ?? businessId, await cache.readBusinessCursors(containerName), ProtoMap.getBusinessResponseAttachmentBaseUrlExpiry(cached), firstAuthTokens);
|
|
412
419
|
}
|
|
413
420
|
catch (error) {
|
|
414
|
-
// The server says the business cannot be opened, so
|
|
415
|
-
//
|
|
421
|
+
// The server says the business cannot be opened, so the business published from the cache is
|
|
422
|
+
// out of date. The container is left as it is, because the caller decides whether the business is gone
|
|
416
423
|
if (error instanceof NotFoundException)
|
|
417
424
|
this.publishBusiness(null);
|
|
418
425
|
throw error;
|
|
@@ -420,14 +427,15 @@ class Model {
|
|
|
420
427
|
let held = await cache.readBusiness(containerName);
|
|
421
428
|
// Whether what was published is no longer what the container holds
|
|
422
429
|
let isPublishedStale = changeCount !== 0;
|
|
423
|
-
//
|
|
430
|
+
// For a read named by the cached entity id. A link names only its own business, so a read named
|
|
431
|
+
// by a link is handled below
|
|
424
432
|
if (businessEntityId === null && cachedEntityId !== null && !Model.isBusinessNamed(held, businessId)) {
|
|
425
|
-
// Nothing read
|
|
426
|
-
//
|
|
427
|
-
//
|
|
428
|
-
//
|
|
429
|
-
// business to have the url and send none, leaving every photo
|
|
430
|
-
//
|
|
433
|
+
// Nothing read from one business applies to another, so the whole container is discarded,
|
|
434
|
+
// along with its cursors. The business the businessId names is read from the beginning, with
|
|
435
|
+
// an attachment url expiry of zero instead of the container's, because the url the container
|
|
436
|
+
// held belonged to the old business and is gone. If the server were sent the container's
|
|
437
|
+
// expiry, it would take the new business to have the url and send none, leaving every photo
|
|
438
|
+
// without one. The read is sent with no tokens, as a link token is rejected with a businessId
|
|
431
439
|
await cache.deleteBusiness(containerName);
|
|
432
440
|
this.publishBusiness(null);
|
|
433
441
|
await this.readBusinessPages(containerName, businessId, new SyncState(SyncKeys.BUSINESS_SYNC_IN), 0n, null);
|
|
@@ -437,44 +445,36 @@ class Model {
|
|
|
437
445
|
// The businessId finds the business that has it, so any other is the server contradicting itself
|
|
438
446
|
if (businessEntityId === null && !Model.isBusinessNamed(held, businessId))
|
|
439
447
|
throw new InternalServerException(`The business that was read for the businessId ${businessId} has the businessId ${ProtoMap.getBusinessResponseBusinessId(held)}`);
|
|
440
|
-
//
|
|
441
|
-
// businessId (a link token is
|
|
442
|
-
// caller starts again
|
|
448
|
+
// For a read named by a link whose business no longer has the businessId. It cannot be re-read
|
|
449
|
+
// by businessId (a link token is rejected with one), so the container is discarded as above and
|
|
450
|
+
// the caller starts again
|
|
443
451
|
if (businessEntityId !== null && !Model.isBusinessNamed(held, businessId)) {
|
|
444
452
|
await cache.deleteBusiness(containerName);
|
|
445
453
|
this.publishBusiness(null);
|
|
446
454
|
return false;
|
|
447
455
|
}
|
|
448
|
-
// A container
|
|
449
|
-
//
|
|
456
|
+
// A container with no business has no online shopping setting to check. A read that ends with no
|
|
457
|
+
// business is a server failure, not a business with online shopping off (see readJoinBusinessId)
|
|
450
458
|
if (ProtoMap.getBusinessResponseBusinessEntityId(held) !== null && !ProtoMap.isBusinessResponseOnlineShoppingOn(held)) {
|
|
451
|
-
// What was published from the cache is the business as it was when open
|
|
452
|
-
//
|
|
453
|
-
//
|
|
459
|
+
// What was published from the cache is the business as it was when open. A business that
|
|
460
|
+
// cannot be opened must not stay published, or a customer could be joined to it (see
|
|
461
|
+
// readJoinBusinessId)
|
|
454
462
|
this.publishBusiness(null);
|
|
455
463
|
throw new NotFoundException("The business has not turned online shopping on");
|
|
456
464
|
}
|
|
457
|
-
//
|
|
458
|
-
//
|
|
459
|
-
const business = isPublishedStale ? Model.buildPublishable(held) : cachedBusiness;
|
|
460
|
-
if (business !== null && business.catalog.productCount === 0) {
|
|
461
|
-
// A shop with nothing to buy is not found and not shown, even if the cache published it
|
|
462
|
-
this.publishBusiness(null);
|
|
463
|
-
throw new NotFoundException("The business has nothing that a customer can buy");
|
|
464
|
-
}
|
|
465
|
-
// A read the store took nothing from re-renders nobody: the cache was published above, and
|
|
466
|
-
// nothing here changes what a later read answers
|
|
465
|
+
// Publish what the container now holds if it changed during the read. Otherwise the cache
|
|
466
|
+
// published at the start is still current.
|
|
467
467
|
if (isPublishedStale)
|
|
468
|
-
this.
|
|
468
|
+
this.publishBusiness(held);
|
|
469
469
|
// What is published now was read to the end, is the business the businessId names, and is open
|
|
470
470
|
this._isPublishedBusinessVetted = true;
|
|
471
471
|
return true;
|
|
472
472
|
}
|
|
473
473
|
// Reads every page the server has for the given cursors and writes each into the container as it
|
|
474
|
-
// arrives. The
|
|
475
|
-
//
|
|
476
|
-
// firstAuthTokens are those of a link. A link token is
|
|
477
|
-
//
|
|
474
|
+
// arrives. The first request names the business by entity id or by businessId, and every later
|
|
475
|
+
// page names it the same way. Returns how much the store accepted: blobs stored plus ids deleted,
|
|
476
|
+
// summed over the pages. firstAuthTokens are those of a link. A link token is rejected with a
|
|
477
|
+
// businessId, so a read that names the business by it sends no tokens, even if given some.
|
|
478
478
|
async readBusinessPages(containerName, business, cursors, attachmentBaseUrlExpiry, firstAuthTokens) {
|
|
479
479
|
const cache = Model.getCache();
|
|
480
480
|
let request = ProtoMap.buildBusinessSyncOutRequest(business, cursors, attachmentBaseUrlExpiry);
|
|
@@ -492,30 +492,28 @@ class Model {
|
|
|
492
492
|
const next = ProtoMap.nextBusinessSyncOutRequest(request, page);
|
|
493
493
|
if (next === null)
|
|
494
494
|
return changeCount;
|
|
495
|
-
// The next
|
|
495
|
+
// The next request continues from where this page ended, signed with any token this page reissued
|
|
496
496
|
request = next;
|
|
497
497
|
authTokens = ProtoMap.refreshAuthTokens(authTokens, issued);
|
|
498
498
|
}
|
|
499
499
|
}
|
|
500
|
-
// True when the response holds the business businessId names, or no business, which
|
|
501
|
-
//
|
|
502
|
-
//
|
|
500
|
+
// True when the response holds the business businessId names, or holds no business, which does
|
|
501
|
+
// not contradict it. Ids are compared as the server finds them, ignoring case and spacing (see
|
|
502
|
+
// canonicalizeBusinessId)
|
|
503
503
|
static isBusinessNamed(response, businessId) {
|
|
504
504
|
const heldBusinessId = ProtoMap.getBusinessResponseBusinessId(response);
|
|
505
505
|
return (heldBusinessId === null ||
|
|
506
506
|
canonicalizeBusinessId(heldBusinessId) === canonicalizeBusinessId(businessId));
|
|
507
507
|
}
|
|
508
508
|
// The BusinessLink of this profile for the business, joining as a customer first (once) if not
|
|
509
|
-
// linked.
|
|
510
|
-
//
|
|
509
|
+
// linked. The profile can hold the business without a link to it: the link of a membership that
|
|
510
|
+
// ended is deleted and the business stays. The join then makes a new link.
|
|
511
511
|
async requireBusinessLink(businessId, profile) {
|
|
512
512
|
if (profile.user === null)
|
|
513
513
|
throw new IllegalOperationException("No profile has been read, so who is browsing is not known");
|
|
514
514
|
const link = Model.findBusinessLink(profile, businessId);
|
|
515
515
|
if (link !== null)
|
|
516
516
|
return link;
|
|
517
|
-
if (Model.findBusinessEntity(profile, businessId) !== null)
|
|
518
|
-
throw new InternalServerException(`This customer has no BusinessLink to business ${businessId}`);
|
|
519
517
|
const joinBusinessId = await this.readJoinBusinessId(businessId);
|
|
520
518
|
await this.profileSyncIn(ProtoMap.buildProfileSyncOutRequest(await Model.getCache().readProfileCursors(this.requireProfileContainerName()), joinBusinessId), await this.requireProfileAuthTokens());
|
|
521
519
|
// Tried once: a join that leaves no link is the server contradicting itself, and retrying
|
|
@@ -536,7 +534,7 @@ class Model {
|
|
|
536
534
|
return alreadyHeld.id;
|
|
537
535
|
await this.syncAnonymousBusiness(businessId);
|
|
538
536
|
// A missing business is a NotFoundException from the read above. Reaching here with none
|
|
539
|
-
// published means the server answered with no Business,
|
|
537
|
+
// published means the server answered with no Business, which is a server failure
|
|
540
538
|
const business = this._business.value;
|
|
541
539
|
if (business === null)
|
|
542
540
|
throw new InternalServerException(`The read of business ${businessId} answered with no Business`);
|
|
@@ -571,9 +569,10 @@ class Model {
|
|
|
571
569
|
// The credentials that sign a call about a customer's business container. The authentication
|
|
572
570
|
// token comes from memory, as in buildProfileAuthTokens: a stored AuthTokens does not keep it.
|
|
573
571
|
// The user token and link token are read separately: storeIssuedTokens overwrites the whole blob
|
|
574
|
-
// per call, so relying on it alone would lose
|
|
575
|
-
//
|
|
576
|
-
// fresh membership has no issued tokens, so the write below seeds
|
|
572
|
+
// per call, so relying on it alone would lose any token the last call did not reissue. A token
|
|
573
|
+
// not stored here comes from another source: the user token from the profile container, and the
|
|
574
|
+
// link token from the link. A fresh membership has no issued tokens, so the write below seeds
|
|
575
|
+
// this container with them.
|
|
577
576
|
// A link with no linkToken is a server inconsistency, and throws rather than sending unsigned.
|
|
578
577
|
async requireBusinessAuthTokens(containerName, link) {
|
|
579
578
|
const authenticationMethod = this._authenticationMethod;
|
|
@@ -595,9 +594,9 @@ class Model {
|
|
|
595
594
|
await cache.writeAuthTokens(containerName, authTokens);
|
|
596
595
|
return authTokens;
|
|
597
596
|
}
|
|
598
|
-
//
|
|
599
|
-
// profile container. The call is about the caller alone, so its AuthTokens
|
|
600
|
-
// token. A linkToken travels only inside a BusinessLink, stored with the profile.
|
|
597
|
+
// Reads the profile of whoever is signed in, paged like a business read. The tokens it receives
|
|
598
|
+
// are stored in the profile container. The call is about the caller alone, so its AuthTokens has
|
|
599
|
+
// no link token. A linkToken travels only inside a BusinessLink, stored with the profile.
|
|
601
600
|
async profileSyncIn(firstRequest, firstAuthTokens) {
|
|
602
601
|
const containerName = this.getProfileContainerName();
|
|
603
602
|
if (containerName === null)
|
|
@@ -624,7 +623,7 @@ class Model {
|
|
|
624
623
|
break;
|
|
625
624
|
request = next;
|
|
626
625
|
// Each call after the first is signed with what the previous page reissued, since a token can
|
|
627
|
-
//
|
|
626
|
+
// expire part way through a long read
|
|
628
627
|
authTokens = ProtoMap.refreshAuthTokens(authTokens, ProtoMap.getResponseAuthTokens(page));
|
|
629
628
|
}
|
|
630
629
|
// As above: nothing the store took, nothing published
|
|
@@ -633,8 +632,8 @@ class Model {
|
|
|
633
632
|
response = await Model.getCache().readProfile(containerName);
|
|
634
633
|
this.publishProfile(response);
|
|
635
634
|
}
|
|
636
|
-
//
|
|
637
|
-
// the join
|
|
635
|
+
// Checked last, so everything that arrived is cached and published before this can throw. The
|
|
636
|
+
// pages are valid even when the join is not.
|
|
638
637
|
Model.requireJoinedBusiness(joinBusinessId, hasJoinedLink, hasJoinedBusiness);
|
|
639
638
|
return response;
|
|
640
639
|
}
|
|
@@ -657,11 +656,11 @@ class Model {
|
|
|
657
656
|
// Without both, the caller is a customer of something it cannot reach. Shared by the two calls
|
|
658
657
|
// that can carry a joinBusinessId.
|
|
659
658
|
//
|
|
660
|
-
//
|
|
661
|
-
// for. 500
|
|
659
|
+
// Throws a ServiceException, since the fault is the server's and not the caller's: the server
|
|
660
|
+
// answered 200 and left out what was asked for. The 500 status means a retry is worthwhile.
|
|
662
661
|
// Only a joinBusinessId the server could act on is checked (Java Business.isValidBusinessId,
|
|
663
|
-
// which Constants.ENTITY_ID_NULL also fails, so "no join asked" needs no case). The server
|
|
664
|
-
// any other
|
|
662
|
+
// which Constants.ENTITY_ID_NULL also fails, so "no join asked" needs no case). The server rejects
|
|
663
|
+
// any other with a 400, which fails the call itself.
|
|
665
664
|
static requireJoinedBusiness(joinBusinessId, hasBusinessLink, hasBusiness) {
|
|
666
665
|
if (Ids.getEntityTypeId(joinBusinessId) !== proto_Constants.BUSINESS_ENTITY_TYPE_ID)
|
|
667
666
|
return;
|
|
@@ -674,11 +673,11 @@ class Model {
|
|
|
674
673
|
: "BusinessLink";
|
|
675
674
|
throw new InternalServerException(`A join of business ${joinBusinessId} was answered with no ${missing}`);
|
|
676
675
|
}
|
|
677
|
-
//
|
|
678
|
-
//
|
|
679
|
-
//
|
|
680
|
-
//
|
|
681
|
-
//
|
|
676
|
+
// Caches the entities of a profile response and files the tokens it reissued under the same
|
|
677
|
+
// container. Returns how much the store accepted, which decides whether anything is published.
|
|
678
|
+
// The three calls that receive a profile response (a page of a read, a sign up, an update) share
|
|
679
|
+
// this, so they cache and file tokens the same way. It does not publish: a read publishes once
|
|
680
|
+
// after all its pages, the other two once each.
|
|
682
681
|
// Only a page of a read moves the container's cursors. A sign up and an update are changes this
|
|
683
682
|
// client made and the next read brings them back, so they are stored without moving any.
|
|
684
683
|
async cacheProfileResponse(containerName, response, isPageOfRead) {
|
|
@@ -689,11 +688,11 @@ class Model {
|
|
|
689
688
|
await this.storeIssuedTokens(ProtoMap.getResponseAuthTokens(response), containerName);
|
|
690
689
|
return changeCount;
|
|
691
690
|
}
|
|
692
|
-
//
|
|
693
|
-
//
|
|
694
|
-
//
|
|
695
|
-
//
|
|
696
|
-
// number and updated time a caller cannot know, this publishes it
|
|
691
|
+
// Handles the response of a profile call other than a read: caches and publishes it, as for a
|
|
692
|
+
// paged read without the paging. Like a read, a sign up and an update publish only when the store
|
|
693
|
+
// accepted something. An update publishes nothing today because the server puts no entities in
|
|
694
|
+
// its response, only reissued tokens. If it ever answers with the stored User, whose update
|
|
695
|
+
// number and updated time a caller cannot know, this publishes it as the server sent it.
|
|
697
696
|
async writeProfileResponse(response) {
|
|
698
697
|
const containerName = this.getProfileContainerName();
|
|
699
698
|
if (containerName === null)
|
|
@@ -702,14 +701,14 @@ class Model {
|
|
|
702
701
|
this.publishProfile(await Model.getCache().readProfile(containerName));
|
|
703
702
|
return response;
|
|
704
703
|
}
|
|
705
|
-
//
|
|
706
|
-
// the subject's profile container, a business call's in that business's container. The
|
|
707
|
-
//
|
|
708
|
-
// reissues a user token
|
|
704
|
+
// Stores the tokens a call received in the container the call was about: a profile call's tokens
|
|
705
|
+
// in the subject's profile container, a business call's in that business's container. The caller
|
|
706
|
+
// picks the container, because a token does not record what it is for. A business sync out that
|
|
707
|
+
// reissues a user token stores that copy with the business it came from.
|
|
709
708
|
// A profile call carries no link token here: a membership's link token travels inside its
|
|
710
709
|
// BusinessLink, stored with the profile.
|
|
711
|
-
// A failed write is swallowed: the server issues
|
|
712
|
-
//
|
|
710
|
+
// A failed write is swallowed: the server issues new tokens on the next call, so a lost one is
|
|
711
|
+
// replaced, and throwing would turn a successful read into a failed one.
|
|
713
712
|
async storeIssuedTokens(issued, containerName) {
|
|
714
713
|
if (issued === undefined)
|
|
715
714
|
return;
|
|
@@ -723,8 +722,9 @@ class Model {
|
|
|
723
722
|
// #endregion
|
|
724
723
|
// #region Containers and credentials
|
|
725
724
|
// Where this subject's profile and tokens are cached. Keyed by authentication method and subject
|
|
726
|
-
// id, since two identity providers could issue the same subject id. Null when signed out.
|
|
727
|
-
// can coexist, so signing back in finds
|
|
725
|
+
// id, since two identity providers could issue the same subject id. Null when signed out.
|
|
726
|
+
// Containers for several subjects can coexist, so signing back in finds that subject's own
|
|
727
|
+
// profile.
|
|
728
728
|
getProfileContainerName() {
|
|
729
729
|
return this._authenticationMethod === null || this._subjectId === null
|
|
730
730
|
? null
|
|
@@ -761,16 +761,16 @@ class Model {
|
|
|
761
761
|
// #endregion
|
|
762
762
|
// #region Publishing
|
|
763
763
|
//
|
|
764
|
-
// A read publishes
|
|
765
|
-
// every page),
|
|
766
|
-
// later, so
|
|
767
|
-
// entity already cached). Publishing
|
|
764
|
+
// A read publishes only when the store accepted something (blobs stored plus ids deleted, summed
|
|
765
|
+
// over every page), whatever the response carried. The store keeps a blob only when its eTag is
|
|
766
|
+
// later, so it can reject a response in full (a cursor that moved without its eTag resends an
|
|
767
|
+
// entity already cached). Publishing then would notify every observer of an identical value.
|
|
768
768
|
// All four calls follow this, so an update publishes nothing (see writeProfileResponse).
|
|
769
769
|
//
|
|
770
770
|
// A business with online shopping off is not published, even from the cache, or a customer could
|
|
771
771
|
// be joined to it. A read that finds the published business is no longer open, or that the server
|
|
772
|
-
//
|
|
773
|
-
// read has ended (see _isPublishedBusinessVetted, which readJoinBusinessId
|
|
772
|
+
// rejects, publishes null in its place. A business published from the cache counts as open only
|
|
773
|
+
// once the read has ended (see _isPublishedBusinessVetted, which readJoinBusinessId checks).
|
|
774
774
|
publishBusiness(response) {
|
|
775
775
|
this.publish(Model.buildPublishable(response));
|
|
776
776
|
}
|