@peabody-soft/mbs-model-ts 1.7.0 → 1.8.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.
- 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.d.ts +19 -7
- package/dist/Catalog.js +121 -41
- package/dist/Constants.d.ts +1 -0
- package/dist/Constants.js +7 -5
- package/dist/ContainerNames.js +6 -6
- package/dist/Entity.js +4 -3
- package/dist/Enums.d.ts +1 -0
- package/dist/Enums.js +3 -2
- package/dist/Exceptions.js +2 -2
- package/dist/GeneralSettings.d.ts +5 -4
- package/dist/GeneralSettings.js +17 -15
- package/dist/HttpStatusCodes.js +7 -7
- package/dist/Ids.js +4 -5
- package/dist/Item.js +8 -5
- 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 +166 -162
- package/dist/Order.js +10 -12
- package/dist/Package.d.ts +6 -2
- package/dist/Package.js +10 -5
- package/dist/PackageIdentifier.js +2 -2
- package/dist/Price.js +6 -5
- package/dist/PriceList.d.ts +13 -0
- package/dist/PriceList.js +34 -2
- package/dist/Product.d.ts +6 -2
- package/dist/Product.js +15 -10
- package/dist/Profile.js +15 -14
- package/dist/ProtoMap.js +189 -206
- package/dist/ServiceExceptions.js +6 -6
- package/dist/ServiceProxy.js +15 -14
- package/dist/ShoppingList.js +102 -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/generated/BusinessSyncOutResponse.d.ts +4 -1
- package/dist/generated/Constants.d.ts +0 -4
- package/dist/generated/Constants.js +0 -4
- package/dist/generated/GeneralSettings.d.ts +5 -4
- package/dist/generated/GeneralSettings.js +8 -6
- package/dist/generated/Item.d.ts +4 -3
- package/dist/generated/Message.d.ts +5 -0
- package/dist/generated/OnlineStoreTheme.d.ts +33 -0
- package/dist/generated/OnlineStoreTheme.js +38 -0
- package/dist/generated/PriceList.d.ts +3 -2
- package/dist/generated/ProfileSyncOutRequest.d.ts +16 -15
- package/dist/generated/ProfileSyncOutResponse.d.ts +4 -6
- package/dist/generated/SalesOrderPayload.d.ts +6 -3
- package/dist/index.d.ts +4 -0
- package/dist/index.js +11 -8
- package/package.json +2 -2
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,19 +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
|
-
// syncBusinessContainer).
|
|
299
|
-
// catalog. The cache is kept,
|
|
300
|
-
// names is replaced
|
|
301
|
-
// isStartedOver
|
|
302
|
-
// a business that no longer has it
|
|
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.
|
|
303
308
|
async businessSyncIn(isStartedOver = false) {
|
|
304
309
|
const businessId = this._businessId;
|
|
305
310
|
if (businessId === null || businessId.length === 0)
|
|
@@ -336,11 +341,11 @@ class Model {
|
|
|
336
341
|
const authTokens = await this.requireBusinessAuthTokens(containerName, link);
|
|
337
342
|
// The link names the business by entity id, because the link token that signs this call is for it
|
|
338
343
|
const isBusinessNamed = await this.syncBusinessContainer(containerName, businessId, link.businessId, ProtoMap.buildAuthTokens(authTokens));
|
|
339
|
-
// The link's business has
|
|
340
|
-
//
|
|
341
|
-
//
|
|
342
|
-
// once: a re-read profile
|
|
343
|
-
// 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
|
|
344
349
|
if (!isBusinessNamed) {
|
|
345
350
|
if (isStartedOver)
|
|
346
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`);
|
|
@@ -354,8 +359,8 @@ class Model {
|
|
|
354
359
|
return;
|
|
355
360
|
// The visitor's cart holds taxes as raw codes, meaningful only within the country of the
|
|
356
361
|
// business it was built in. That country is read from the anonymous container. A cart is stored
|
|
357
|
-
// only after its business has been (see updateCartLocalOnly)
|
|
358
|
-
// 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.
|
|
359
364
|
const anonymousCountryCode = ProtoMap.getBusinessResponseCountryCode(anonymous) ?? this._business.value?.countryCode;
|
|
360
365
|
if (anonymousCountryCode === undefined)
|
|
361
366
|
throw new IllegalOperationException("The business the visitor's cart was built in is not known, so its taxes cannot be read");
|
|
@@ -375,28 +380,27 @@ class Model {
|
|
|
375
380
|
async syncAnonymousBusiness(businessId) {
|
|
376
381
|
await this.syncBusinessContainer(ContainerNames.forBusiness(businessId), businessId, null, null);
|
|
377
382
|
}
|
|
378
|
-
// Reads one business into containerName, cache first:
|
|
379
|
-
//
|
|
380
|
-
// 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.
|
|
381
386
|
// The first request names the business by entity id where known (businessEntityId from a link,
|
|
382
387
|
// else the one in the container), else by businessId, and every later page does the same.
|
|
383
|
-
// firstAuthTokens are the link's and sign the reads by entity id. A read by businessId is
|
|
384
|
-
// with
|
|
388
|
+
// firstAuthTokens are the link's and sign the reads by entity id. A read by businessId is sent
|
|
389
|
+
// with no tokens.
|
|
385
390
|
//
|
|
386
391
|
// Whether the business can be opened is decided from the container once every page is written,
|
|
387
392
|
// since a page carries only what changed. After every read, in order:
|
|
388
393
|
// 1. The container holds the business businessId names. A business keeps its entity id when its
|
|
389
394
|
// businessId changes and another can take the old one, so a container read by entity id may
|
|
390
|
-
// hold the wrong business. It is
|
|
395
|
+
// hold the wrong business. It is discarded and read again from the start by businessId, once,
|
|
391
396
|
// which finds the right business or none (NotFoundException from the server).
|
|
392
|
-
// A read named by a link cannot be redone that way (a link token is
|
|
393
|
-
// businessId). Its container is
|
|
394
|
-
// 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.
|
|
395
400
|
// 2. Online shopping is on in the general settings (no settings means off). The server does not
|
|
396
|
-
//
|
|
397
|
-
//
|
|
398
|
-
//
|
|
399
|
-
// catalog, and a UI tells its customers there is nothing to buy.
|
|
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.
|
|
400
404
|
async syncBusinessContainer(containerName, businessId, businessEntityId, firstAuthTokens) {
|
|
401
405
|
const cache = Model.getCache();
|
|
402
406
|
const cached = await cache.readBusiness(containerName);
|
|
@@ -407,14 +411,15 @@ class Model {
|
|
|
407
411
|
// does. It may not be what the businessId finds now: a business can give up its businessId to
|
|
408
412
|
// another, or change it.
|
|
409
413
|
const cachedEntityId = ProtoMap.getBusinessResponseBusinessEntityId(cached);
|
|
410
|
-
//
|
|
414
|
+
// How much the store accepted: blobs stored plus ids deleted, as in the Publishing region.
|
|
415
|
+
// Decides the publish at the end.
|
|
411
416
|
let changeCount;
|
|
412
417
|
try {
|
|
413
418
|
changeCount = await this.readBusinessPages(containerName, businessEntityId ?? cachedEntityId ?? businessId, await cache.readBusinessCursors(containerName), ProtoMap.getBusinessResponseAttachmentBaseUrlExpiry(cached), firstAuthTokens);
|
|
414
419
|
}
|
|
415
420
|
catch (error) {
|
|
416
|
-
// The server says the business cannot be opened, so
|
|
417
|
-
//
|
|
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
|
|
418
423
|
if (error instanceof NotFoundException)
|
|
419
424
|
this.publishBusiness(null);
|
|
420
425
|
throw error;
|
|
@@ -422,14 +427,15 @@ class Model {
|
|
|
422
427
|
let held = await cache.readBusiness(containerName);
|
|
423
428
|
// Whether what was published is no longer what the container holds
|
|
424
429
|
let isPublishedStale = changeCount !== 0;
|
|
425
|
-
//
|
|
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
|
|
426
432
|
if (businessEntityId === null && cachedEntityId !== null && !Model.isBusinessNamed(held, businessId)) {
|
|
427
|
-
// Nothing read
|
|
428
|
-
//
|
|
429
|
-
//
|
|
430
|
-
//
|
|
431
|
-
// business to have the url and send none, leaving every photo
|
|
432
|
-
//
|
|
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
|
|
433
439
|
await cache.deleteBusiness(containerName);
|
|
434
440
|
this.publishBusiness(null);
|
|
435
441
|
await this.readBusinessPages(containerName, businessId, new SyncState(SyncKeys.BUSINESS_SYNC_IN), 0n, null);
|
|
@@ -439,27 +445,25 @@ class Model {
|
|
|
439
445
|
// The businessId finds the business that has it, so any other is the server contradicting itself
|
|
440
446
|
if (businessEntityId === null && !Model.isBusinessNamed(held, businessId))
|
|
441
447
|
throw new InternalServerException(`The business that was read for the businessId ${businessId} has the businessId ${ProtoMap.getBusinessResponseBusinessId(held)}`);
|
|
442
|
-
//
|
|
443
|
-
// businessId (a link token is
|
|
444
|
-
// 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
|
|
445
451
|
if (businessEntityId !== null && !Model.isBusinessNamed(held, businessId)) {
|
|
446
452
|
await cache.deleteBusiness(containerName);
|
|
447
453
|
this.publishBusiness(null);
|
|
448
454
|
return false;
|
|
449
455
|
}
|
|
450
|
-
// A container
|
|
451
|
-
//
|
|
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)
|
|
452
458
|
if (ProtoMap.getBusinessResponseBusinessEntityId(held) !== null && !ProtoMap.isBusinessResponseOnlineShoppingOn(held)) {
|
|
453
|
-
// What was published from the cache is the business as it was when open
|
|
454
|
-
//
|
|
455
|
-
//
|
|
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)
|
|
456
462
|
this.publishBusiness(null);
|
|
457
463
|
throw new NotFoundException("The business has not turned online shopping on");
|
|
458
464
|
}
|
|
459
|
-
//
|
|
460
|
-
//
|
|
461
|
-
// A business with nothing a customer can buy is published like any other. It is open, and says
|
|
462
|
-
// so with an empty catalog (Catalog.productCount), which a UI words for its customers.
|
|
465
|
+
// Publish what the container now holds if it changed during the read. Otherwise the cache
|
|
466
|
+
// published at the start is still current.
|
|
463
467
|
if (isPublishedStale)
|
|
464
468
|
this.publishBusiness(held);
|
|
465
469
|
// What is published now was read to the end, is the business the businessId names, and is open
|
|
@@ -467,10 +471,10 @@ class Model {
|
|
|
467
471
|
return true;
|
|
468
472
|
}
|
|
469
473
|
// Reads every page the server has for the given cursors and writes each into the container as it
|
|
470
|
-
// arrives. The
|
|
471
|
-
//
|
|
472
|
-
// firstAuthTokens are those of a link. A link token is
|
|
473
|
-
//
|
|
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.
|
|
474
478
|
async readBusinessPages(containerName, business, cursors, attachmentBaseUrlExpiry, firstAuthTokens) {
|
|
475
479
|
const cache = Model.getCache();
|
|
476
480
|
let request = ProtoMap.buildBusinessSyncOutRequest(business, cursors, attachmentBaseUrlExpiry);
|
|
@@ -488,30 +492,28 @@ class Model {
|
|
|
488
492
|
const next = ProtoMap.nextBusinessSyncOutRequest(request, page);
|
|
489
493
|
if (next === null)
|
|
490
494
|
return changeCount;
|
|
491
|
-
// The next
|
|
495
|
+
// The next request continues from where this page ended, signed with any token this page reissued
|
|
492
496
|
request = next;
|
|
493
497
|
authTokens = ProtoMap.refreshAuthTokens(authTokens, issued);
|
|
494
498
|
}
|
|
495
499
|
}
|
|
496
|
-
// True when the response holds the business businessId names, or no business, which
|
|
497
|
-
//
|
|
498
|
-
//
|
|
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)
|
|
499
503
|
static isBusinessNamed(response, businessId) {
|
|
500
504
|
const heldBusinessId = ProtoMap.getBusinessResponseBusinessId(response);
|
|
501
505
|
return (heldBusinessId === null ||
|
|
502
506
|
canonicalizeBusinessId(heldBusinessId) === canonicalizeBusinessId(businessId));
|
|
503
507
|
}
|
|
504
508
|
// The BusinessLink of this profile for the business, joining as a customer first (once) if not
|
|
505
|
-
// linked.
|
|
506
|
-
//
|
|
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.
|
|
507
511
|
async requireBusinessLink(businessId, profile) {
|
|
508
512
|
if (profile.user === null)
|
|
509
513
|
throw new IllegalOperationException("No profile has been read, so who is browsing is not known");
|
|
510
514
|
const link = Model.findBusinessLink(profile, businessId);
|
|
511
515
|
if (link !== null)
|
|
512
516
|
return link;
|
|
513
|
-
if (Model.findBusinessEntity(profile, businessId) !== null)
|
|
514
|
-
throw new InternalServerException(`This customer has no BusinessLink to business ${businessId}`);
|
|
515
517
|
const joinBusinessId = await this.readJoinBusinessId(businessId);
|
|
516
518
|
await this.profileSyncIn(ProtoMap.buildProfileSyncOutRequest(await Model.getCache().readProfileCursors(this.requireProfileContainerName()), joinBusinessId), await this.requireProfileAuthTokens());
|
|
517
519
|
// Tried once: a join that leaves no link is the server contradicting itself, and retrying
|
|
@@ -532,7 +534,7 @@ class Model {
|
|
|
532
534
|
return alreadyHeld.id;
|
|
533
535
|
await this.syncAnonymousBusiness(businessId);
|
|
534
536
|
// A missing business is a NotFoundException from the read above. Reaching here with none
|
|
535
|
-
// published means the server answered with no Business,
|
|
537
|
+
// published means the server answered with no Business, which is a server failure
|
|
536
538
|
const business = this._business.value;
|
|
537
539
|
if (business === null)
|
|
538
540
|
throw new InternalServerException(`The read of business ${businessId} answered with no Business`);
|
|
@@ -567,9 +569,10 @@ class Model {
|
|
|
567
569
|
// The credentials that sign a call about a customer's business container. The authentication
|
|
568
570
|
// token comes from memory, as in buildProfileAuthTokens: a stored AuthTokens does not keep it.
|
|
569
571
|
// The user token and link token are read separately: storeIssuedTokens overwrites the whole blob
|
|
570
|
-
// per call, so relying on it alone would lose
|
|
571
|
-
//
|
|
572
|
-
// 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.
|
|
573
576
|
// A link with no linkToken is a server inconsistency, and throws rather than sending unsigned.
|
|
574
577
|
async requireBusinessAuthTokens(containerName, link) {
|
|
575
578
|
const authenticationMethod = this._authenticationMethod;
|
|
@@ -591,9 +594,9 @@ class Model {
|
|
|
591
594
|
await cache.writeAuthTokens(containerName, authTokens);
|
|
592
595
|
return authTokens;
|
|
593
596
|
}
|
|
594
|
-
//
|
|
595
|
-
// profile container. The call is about the caller alone, so its AuthTokens
|
|
596
|
-
// 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.
|
|
597
600
|
async profileSyncIn(firstRequest, firstAuthTokens) {
|
|
598
601
|
const containerName = this.getProfileContainerName();
|
|
599
602
|
if (containerName === null)
|
|
@@ -620,7 +623,7 @@ class Model {
|
|
|
620
623
|
break;
|
|
621
624
|
request = next;
|
|
622
625
|
// Each call after the first is signed with what the previous page reissued, since a token can
|
|
623
|
-
//
|
|
626
|
+
// expire part way through a long read
|
|
624
627
|
authTokens = ProtoMap.refreshAuthTokens(authTokens, ProtoMap.getResponseAuthTokens(page));
|
|
625
628
|
}
|
|
626
629
|
// As above: nothing the store took, nothing published
|
|
@@ -629,8 +632,8 @@ class Model {
|
|
|
629
632
|
response = await Model.getCache().readProfile(containerName);
|
|
630
633
|
this.publishProfile(response);
|
|
631
634
|
}
|
|
632
|
-
//
|
|
633
|
-
// 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.
|
|
634
637
|
Model.requireJoinedBusiness(joinBusinessId, hasJoinedLink, hasJoinedBusiness);
|
|
635
638
|
return response;
|
|
636
639
|
}
|
|
@@ -653,11 +656,11 @@ class Model {
|
|
|
653
656
|
// Without both, the caller is a customer of something it cannot reach. Shared by the two calls
|
|
654
657
|
// that can carry a joinBusinessId.
|
|
655
658
|
//
|
|
656
|
-
//
|
|
657
|
-
// 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.
|
|
658
661
|
// Only a joinBusinessId the server could act on is checked (Java Business.isValidBusinessId,
|
|
659
|
-
// which Constants.ENTITY_ID_NULL also fails, so "no join asked" needs no case). The server
|
|
660
|
-
// 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.
|
|
661
664
|
static requireJoinedBusiness(joinBusinessId, hasBusinessLink, hasBusiness) {
|
|
662
665
|
if (Ids.getEntityTypeId(joinBusinessId) !== proto_Constants.BUSINESS_ENTITY_TYPE_ID)
|
|
663
666
|
return;
|
|
@@ -670,11 +673,11 @@ class Model {
|
|
|
670
673
|
: "BusinessLink";
|
|
671
674
|
throw new InternalServerException(`A join of business ${joinBusinessId} was answered with no ${missing}`);
|
|
672
675
|
}
|
|
673
|
-
//
|
|
674
|
-
//
|
|
675
|
-
//
|
|
676
|
-
//
|
|
677
|
-
//
|
|
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.
|
|
678
681
|
// Only a page of a read moves the container's cursors. A sign up and an update are changes this
|
|
679
682
|
// client made and the next read brings them back, so they are stored without moving any.
|
|
680
683
|
async cacheProfileResponse(containerName, response, isPageOfRead) {
|
|
@@ -685,11 +688,11 @@ class Model {
|
|
|
685
688
|
await this.storeIssuedTokens(ProtoMap.getResponseAuthTokens(response), containerName);
|
|
686
689
|
return changeCount;
|
|
687
690
|
}
|
|
688
|
-
//
|
|
689
|
-
//
|
|
690
|
-
//
|
|
691
|
-
//
|
|
692
|
-
// 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.
|
|
693
696
|
async writeProfileResponse(response) {
|
|
694
697
|
const containerName = this.getProfileContainerName();
|
|
695
698
|
if (containerName === null)
|
|
@@ -698,14 +701,14 @@ class Model {
|
|
|
698
701
|
this.publishProfile(await Model.getCache().readProfile(containerName));
|
|
699
702
|
return response;
|
|
700
703
|
}
|
|
701
|
-
//
|
|
702
|
-
// the subject's profile container, a business call's in that business's container. The
|
|
703
|
-
//
|
|
704
|
-
// 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.
|
|
705
708
|
// A profile call carries no link token here: a membership's link token travels inside its
|
|
706
709
|
// BusinessLink, stored with the profile.
|
|
707
|
-
// A failed write is swallowed: the server issues
|
|
708
|
-
//
|
|
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.
|
|
709
712
|
async storeIssuedTokens(issued, containerName) {
|
|
710
713
|
if (issued === undefined)
|
|
711
714
|
return;
|
|
@@ -719,8 +722,9 @@ class Model {
|
|
|
719
722
|
// #endregion
|
|
720
723
|
// #region Containers and credentials
|
|
721
724
|
// Where this subject's profile and tokens are cached. Keyed by authentication method and subject
|
|
722
|
-
// id, since two identity providers could issue the same subject id. Null when signed out.
|
|
723
|
-
// 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.
|
|
724
728
|
getProfileContainerName() {
|
|
725
729
|
return this._authenticationMethod === null || this._subjectId === null
|
|
726
730
|
? null
|
|
@@ -757,16 +761,16 @@ class Model {
|
|
|
757
761
|
// #endregion
|
|
758
762
|
// #region Publishing
|
|
759
763
|
//
|
|
760
|
-
// A read publishes
|
|
761
|
-
// every page),
|
|
762
|
-
// later, so
|
|
763
|
-
// 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.
|
|
764
768
|
// All four calls follow this, so an update publishes nothing (see writeProfileResponse).
|
|
765
769
|
//
|
|
766
770
|
// A business with online shopping off is not published, even from the cache, or a customer could
|
|
767
771
|
// be joined to it. A read that finds the published business is no longer open, or that the server
|
|
768
|
-
//
|
|
769
|
-
// 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).
|
|
770
774
|
publishBusiness(response) {
|
|
771
775
|
this.publish(Model.buildPublishable(response));
|
|
772
776
|
}
|