@peabody-soft/mbs-model-ts 1.5.0 → 1.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/Address.js +8 -11
- package/dist/Attachment.js +7 -8
- package/dist/AttachmentBaseUrl.js +10 -10
- package/dist/AuthTokens.js +12 -7
- package/dist/Business.js +19 -19
- package/dist/BusinessIdentifier.js +2 -2
- package/dist/BusinessLink.js +16 -16
- package/dist/BusinessProfile.js +4 -4
- package/dist/CartEditor.js +137 -166
- package/dist/Catalog.js +19 -25
- package/dist/Constants.js +10 -10
- package/dist/ContainerNames.js +10 -11
- package/dist/Entity.js +11 -9
- package/dist/Enums.js +2 -2
- package/dist/Exceptions.js +2 -2
- package/dist/GeneralSettings.js +22 -21
- package/dist/HttpStatusCodes.js +10 -11
- package/dist/Ids.js +24 -30
- package/dist/Item.js +6 -7
- package/dist/LatLong.js +3 -3
- package/dist/LiveData.js +6 -6
- package/dist/Measures.js +2 -2
- package/dist/Message.js +6 -6
- package/dist/MockDatabase.js +2 -2
- package/dist/Model.js +292 -359
- package/dist/Order.js +20 -17
- package/dist/Package.js +7 -8
- package/dist/PackageIdentifier.js +2 -2
- package/dist/PaymentMethodDetail.js +4 -4
- package/dist/Photo.js +6 -7
- package/dist/Price.js +8 -9
- package/dist/PriceList.js +2 -2
- package/dist/Product.js +17 -22
- package/dist/ProductCategory.js +3 -4
- package/dist/Profile.js +26 -27
- package/dist/ProtoMap.d.ts +3 -0
- package/dist/ProtoMap.js +288 -294
- package/dist/Routes.js +3 -3
- package/dist/ServiceExceptions.js +11 -12
- package/dist/ServiceProxy.js +19 -23
- package/dist/ShoppingList.js +112 -130
- package/dist/SyncCache.d.ts +3 -0
- package/dist/SyncCache.js +92 -75
- package/dist/SyncKeys.js +8 -8
- package/dist/SyncState.js +5 -5
- package/dist/Tax.js +16 -21
- package/dist/TaxHelper.js +22 -30
- package/dist/TaxType.js +8 -9
- package/dist/UserProfile.js +4 -4
- package/dist/WorkingDays.js +15 -15
- package/dist/index.js +9 -9
- package/package.json +1 -1
package/dist/Model.js
CHANGED
|
@@ -13,23 +13,23 @@ 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
|
|
17
|
-
//
|
|
16
|
+
// The root of the object model, and all a UI has to be given. Call initialize once, then get the
|
|
17
|
+
// current instance with getInstance.
|
|
18
18
|
//
|
|
19
|
-
// THERE IS NO LOCKING
|
|
20
|
-
//
|
|
21
|
-
// over the
|
|
22
|
-
// interleave their pages and
|
|
23
|
-
//
|
|
24
|
-
// A queue inside Model would
|
|
25
|
-
//
|
|
26
|
-
//
|
|
19
|
+
// THERE IS NO LOCKING. A caller makes one call at a time and awaits it. Otherwise:
|
|
20
|
+
// - A setBusinessId still reading when signIn reads the business too can publish the anonymous
|
|
21
|
+
// container's business over the customer's, with no cart or orders. Nothing republishes them.
|
|
22
|
+
// - Two reads at once interleave their pages and writes, as do two tabs on one store (each holds a
|
|
23
|
+
// Model), so a store supports one tab at a time.
|
|
24
|
+
// A queue inside Model would not fix the tabs, and each public method would need an unqueued twin
|
|
25
|
+
// (signIn calls signOut, businessSyncIn calls updateCart). A caller that calls from several places,
|
|
26
|
+
// like a UI, queues its own calls.
|
|
27
27
|
class Model {
|
|
28
28
|
constructor() {
|
|
29
|
-
//
|
|
30
|
-
//
|
|
31
|
-
//
|
|
32
|
-
// and unvetted. See readJoinBusinessId, which does not
|
|
29
|
+
// 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. A read's cache-first publish is of what an earlier read left,
|
|
31
|
+
// and is neither until the read ends. A read that does not end (network failure, a refusal)
|
|
32
|
+
// leaves it published and unvetted. See readJoinBusinessId, which does not join such a business.
|
|
33
33
|
this._isPublishedBusinessVetted = false;
|
|
34
34
|
this._business = new LiveData(null);
|
|
35
35
|
this._profile = new LiveData(null);
|
|
@@ -44,7 +44,7 @@ class Model {
|
|
|
44
44
|
Model._cache = new SyncCache(database);
|
|
45
45
|
Model._instance = null;
|
|
46
46
|
}
|
|
47
|
-
// The model
|
|
47
|
+
// The current model. Fetch it each time and do not keep it: signOut discards it.
|
|
48
48
|
static getInstance() {
|
|
49
49
|
return Model._instance ?? (Model._instance = new Model());
|
|
50
50
|
}
|
|
@@ -57,17 +57,13 @@ class Model {
|
|
|
57
57
|
get profile() {
|
|
58
58
|
return this._profile;
|
|
59
59
|
}
|
|
60
|
-
// Signs in
|
|
61
|
-
//
|
|
62
|
-
// reads in the business being browsed, if any, now signed in as this customer.
|
|
60
|
+
// Signs in and syncs this subject's profile in, for a subject the server has a profile for (use
|
|
61
|
+
// createUser otherwise). Also reads in the business being browsed, if any, as this customer.
|
|
63
62
|
//
|
|
64
|
-
//
|
|
65
|
-
//
|
|
66
|
-
//
|
|
67
|
-
//
|
|
68
|
-
// such a failure could find _profile already set and trip that same guard despite never having
|
|
69
|
-
// actually signed in.
|
|
70
|
-
// Throws NotFoundException if the business being browsed cannot be opened - see businessSyncIn.
|
|
63
|
+
// Anything thrown after beginSignIn's guard (a bad credential, a refused push while reading the
|
|
64
|
+
// business) calls signOut. profileSyncIn publishes a cached profile before it reaches the
|
|
65
|
+
// network, so a retry could otherwise find _profile set and trip the "already signed in" guard.
|
|
66
|
+
// Throws NotFoundException if the business being browsed cannot be opened. See businessSyncIn.
|
|
71
67
|
async signIn(authenticationMethod, subjectId, authenticationToken) {
|
|
72
68
|
this.beginSignIn(authenticationMethod, subjectId, authenticationToken);
|
|
73
69
|
try {
|
|
@@ -81,12 +77,10 @@ class Model {
|
|
|
81
77
|
throw error;
|
|
82
78
|
}
|
|
83
79
|
}
|
|
84
|
-
// Signs up
|
|
85
|
-
//
|
|
86
|
-
//
|
|
87
|
-
//
|
|
88
|
-
// after the User has been created, and nothing undoes it: the customer stays signed up, and signed in, with
|
|
89
|
-
// a profile held, whatever the read of the business answered.
|
|
80
|
+
// Signs up and creates userToCreate as this subject's profile, for a subject the server has no
|
|
81
|
+
// profile for (use signIn otherwise). Also reads in the business being browsed, if any, as this
|
|
82
|
+
// customer. Throws NotFoundException if that business cannot be opened (see businessSyncIn), after
|
|
83
|
+
// the User exists. Nothing undoes it: the customer stays signed up and signed in.
|
|
90
84
|
async createUser(authenticationMethod, subjectId, authenticationToken, userToCreate) {
|
|
91
85
|
this.beginSignIn(authenticationMethod, subjectId, authenticationToken);
|
|
92
86
|
const authTokens = await this.requireProfileAuthTokens();
|
|
@@ -94,38 +88,33 @@ class Model {
|
|
|
94
88
|
if (this._businessId !== null)
|
|
95
89
|
await this.businessSyncIn();
|
|
96
90
|
}
|
|
97
|
-
// Updates this customer's own profile with what userProfile
|
|
98
|
-
//
|
|
99
|
-
//
|
|
100
|
-
//
|
|
101
|
-
//
|
|
102
|
-
// entities, only whatever the server reissued - see writeProfileResponse.
|
|
91
|
+
// Updates this customer's own profile with what userProfile holds. Service.updateUser matches the
|
|
92
|
+
// 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. Needs a profile already read (see
|
|
94
|
+
// requireCustomerOfBusiness). Not paged. Publishes nothing today: the response carries no
|
|
95
|
+
// entities, only reissued tokens (see writeProfileResponse).
|
|
103
96
|
async updateUser(userProfile) {
|
|
104
97
|
if (this._profile.value === null)
|
|
105
98
|
throw new IllegalOperationException("Nobody is signed in, so this call cannot be made");
|
|
106
99
|
await this.writeProfileResponse(await Model.getService().updateUser(ProtoMap.buildUpdateUser(userProfile), await this.requireProfileAuthTokens()));
|
|
107
100
|
}
|
|
108
|
-
// Discards this model
|
|
109
|
-
// browsing nothing.
|
|
101
|
+
// Discards this model. The next getInstance() builds a fresh one, signed out and browsing nothing.
|
|
110
102
|
async signOut() {
|
|
111
103
|
Model._instance = null;
|
|
112
104
|
}
|
|
113
|
-
// Names the business to browse and reads it in
|
|
114
|
-
//
|
|
115
|
-
//
|
|
116
|
-
//
|
|
117
|
-
//
|
|
118
|
-
//
|
|
119
|
-
//
|
|
120
|
-
//
|
|
121
|
-
//
|
|
122
|
-
// The businessId is held and used as the server finds a business by it, in lower case and without the spaces round it
|
|
123
|
-
// (see canonicalizeBusinessId): a url is typed in any case, and /WARM-LOAF and /warm-loaf are one storefront, so, they are
|
|
124
|
-
// one container, one download, and not a different business to be shown as none first. What is sent to the server is that
|
|
125
|
-
// form too, which it makes of whatever it is sent, so nothing is lost by it.
|
|
105
|
+
// 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. A re-read of the current shop does not,
|
|
107
|
+
// since that would blank the UI and undo syncBusinessContainer's cache-first publish.
|
|
108
|
+
// Throws NotFoundException if the business cannot be opened (see businessSyncIn). What was cached
|
|
109
|
+
// is published before the network is asked, unless online shopping is off (see publishBusiness),
|
|
110
|
+
// and is taken back if the read finds the business cannot be opened.
|
|
111
|
+
// The businessId is held in canonical form (see canonicalizeBusinessId), as the server finds it,
|
|
112
|
+
// so /WARM-LOAF and /warm-loaf are one storefront, one container and one download. The server
|
|
113
|
+
// canonicalizes what it is sent.
|
|
126
114
|
async setBusinessId(businessId) {
|
|
127
|
-
// An id of
|
|
128
|
-
//
|
|
115
|
+
// An id of only spaces has no canonical form (Java refuses it), so it is held as written. The
|
|
116
|
+
// server then answers with the NotFoundException a UI shows for a missing business, and this
|
|
117
|
+
// does not call it a mistake.
|
|
129
118
|
const canonicalBusinessId = canonicalizeBusinessId(businessId) || businessId;
|
|
130
119
|
const isDifferentBusiness = this._businessId !== canonicalBusinessId;
|
|
131
120
|
this._businessId = canonicalBusinessId;
|
|
@@ -136,22 +125,19 @@ class Model {
|
|
|
136
125
|
// #region What a customer writes
|
|
137
126
|
//
|
|
138
127
|
// The only writes in this library: this customer's cart at the business being browsed, and an
|
|
139
|
-
// order placed from it.
|
|
140
|
-
//
|
|
141
|
-
//
|
|
142
|
-
// merchant has
|
|
143
|
-
//
|
|
144
|
-
//
|
|
145
|
-
//
|
|
146
|
-
//
|
|
147
|
-
//
|
|
148
|
-
//
|
|
149
|
-
//
|
|
150
|
-
//
|
|
151
|
-
//
|
|
152
|
-
// whatever the caller last sent, so a call that matches what is already there returns without a
|
|
153
|
-
// request. Not a substitute for reading the business, though - a push answers with the messages it
|
|
154
|
-
// pushed and nothing else, and a call that does not go out brings no change of anybody else's back.
|
|
128
|
+
// order placed from it. Both need a customer who is a member of the business (see
|
|
129
|
+
// requireCustomerOfBusiness).
|
|
130
|
+
// An order reaches the store only after the server accepts it, so a customer never holds an order
|
|
131
|
+
// the merchant has not heard of, and a failed call leaves nothing to reconcile. A cart is not held
|
|
132
|
+
// to that (see updateCartLocalOnly).
|
|
133
|
+
// Creates or replaces this customer's cart, on the server and in the store. An empty list
|
|
134
|
+
// empties the cart, the same state as never having had one.
|
|
135
|
+
// Signed out, this is updateCartLocalOnly, as the server has no cart for a visitor. Signed in, it
|
|
136
|
+
// costs a request, so an editor should write each change with updateCartLocalOnly and call this on
|
|
137
|
+
// its own schedule (debounced, on blur, a timer).
|
|
138
|
+
// A call that matches the stored copy returns without a request (hasShoppingListChanged). It does
|
|
139
|
+
// not replace reading the business: a push answers with the messages it pushed only, so it brings
|
|
140
|
+
// back nobody else's changes.
|
|
155
141
|
async updateCart(shoppingList) {
|
|
156
142
|
if (this._profile.value === null)
|
|
157
143
|
return this.updateCartLocalOnly(shoppingList);
|
|
@@ -162,42 +148,37 @@ class Model {
|
|
|
162
148
|
const partyPreferencesMessage = ProtoMap.buildPartyPreferencesMessage(Ids.getPartyPreferencesMessageId(target.businessLink.id), stored, target.senderId, shoppingList, null, BigInt(Date.now()));
|
|
163
149
|
await this.pushBusinessMessages(target, [partyPreferencesMessage]);
|
|
164
150
|
}
|
|
165
|
-
// The same, stored locally and not sent. Call
|
|
166
|
-
//
|
|
167
|
-
//
|
|
168
|
-
//
|
|
169
|
-
//
|
|
170
|
-
//
|
|
171
|
-
//
|
|
172
|
-
//
|
|
173
|
-
// buildPartyPreferencesMessage always dates an amendment past what it replaces.
|
|
151
|
+
// The same, stored locally and not sent. Call it on every edit (a request per tap of a quantity
|
|
152
|
+
// stepper is not worth making), and updateCart on your own schedule to send.
|
|
153
|
+
// 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, hear of
|
|
155
|
+
// the change late.
|
|
156
|
+
// A stale sync cannot bring an older server copy back over it: the store versions an amendment at
|
|
157
|
+
// the update number of the copy it amends, buildPartyPreferencesMessage dates it past that copy,
|
|
158
|
+
// and the later copy wins.
|
|
174
159
|
async updateCartLocalOnly(shoppingList) {
|
|
175
|
-
// No membership to derive a message id from while signed out, so one is drawn
|
|
176
|
-
//
|
|
177
|
-
//
|
|
160
|
+
// No membership to derive a message id from while signed out, so a fresh one is drawn. It
|
|
161
|
+
// names the cart only for the first edit: buildPartyPreferencesMessage reuses the stored
|
|
162
|
+
// cart's id, so later edits amend one entity. The sender is ENTITY_ID_NULL for want of a party;
|
|
178
163
|
// the first push after signing in names the real one.
|
|
179
164
|
if (this._profile.value === null)
|
|
180
165
|
return this.storeCartInCache(ContainerNames.forBusiness(this.requireBusinessBeingBrowsed()), Ids.getNewId(proto_Constants.MESSAGE_ENTITY_TYPE_ID), BigInt(proto_Constants.ENTITY_ID_NULL), shoppingList);
|
|
181
166
|
const target = this.requireCustomerOfBusiness();
|
|
182
167
|
return this.storeCartInCache(target.containerName, Ids.getPartyPreferencesMessageId(target.businessLink.id), target.senderId, shoppingList);
|
|
183
168
|
}
|
|
184
|
-
// Places an order for what is in the cart
|
|
185
|
-
//
|
|
186
|
-
//
|
|
187
|
-
//
|
|
188
|
-
//
|
|
189
|
-
//
|
|
190
|
-
//
|
|
191
|
-
//
|
|
192
|
-
//
|
|
193
|
-
// unresolved by sending the same id again rather than placing a second order, the same guarantee
|
|
194
|
-
// getPartyPreferencesMessageId gives a cart.
|
|
195
|
-
// A delivery must name an address, either an id from this customer's own list or a full address
|
|
196
|
-
// sent directly - see requireDeliveryAddress.
|
|
169
|
+
// Places an order for what is in the cart and empties the cart, as Java
|
|
170
|
+
// SalesOrderViewModel.saveChanges does on MODE_CREATE. So it takes no shopping list.
|
|
171
|
+
// The emptied cart is stamped with this fulfillmentMode, which Business.defaultFulfillmentMode
|
|
172
|
+
// reads back.
|
|
173
|
+
// The caller mints orderId with Ids.getNewId(Constants.MESSAGE_ENTITY_TYPE_ID), so it can find the
|
|
174
|
+
// placed order, and can retry a call a NetworkConnectionException left unresolved with the same
|
|
175
|
+
// id, without placing a second order. getPartyPreferencesMessageId gives a cart the same guarantee.
|
|
176
|
+
// A delivery must name an address: an id from this customer's list, or a full address sent
|
|
177
|
+
// directly (see requireDeliveryAddress).
|
|
197
178
|
async createOrder(orderId, fulfillmentMode, deliveryAddressId, deliveryAddress, note) {
|
|
198
179
|
const target = this.requireCustomerOfBusiness();
|
|
199
|
-
// Read from the store, not the published Business, so the order reflects the cart as
|
|
200
|
-
//
|
|
180
|
+
// Read from the store, not the published Business, so the order reflects the cart as stored,
|
|
181
|
+
// not a value an observer may still hold
|
|
201
182
|
const partyPreferencesMessage = await Model.getCache().readPartyPreferences(target.containerName);
|
|
202
183
|
const shoppingList = partyPreferencesMessage === null
|
|
203
184
|
? ShoppingList.EMPTY
|
|
@@ -205,21 +186,19 @@ class Model {
|
|
|
205
186
|
if (shoppingList.existingItems.length === 0)
|
|
206
187
|
throw new IllegalOperationException("The cart holds nothing, so there is no order to place");
|
|
207
188
|
Model.requireDeliveryAddress(target.addresses, fulfillmentMode, deliveryAddressId, deliveryAddress);
|
|
208
|
-
// One
|
|
189
|
+
// One time for both messages, which is when the order was placed
|
|
209
190
|
const now = BigInt(Date.now());
|
|
210
|
-
//
|
|
211
|
-
//
|
|
191
|
+
// One request for the order and the emptied cart: two calls could leave an order placed and the
|
|
192
|
+
// cart still full if the second failed
|
|
212
193
|
await this.pushBusinessMessages(target, [
|
|
213
194
|
ProtoMap.buildOrderMessage(orderId, target.senderId, shoppingList, fulfillmentMode, deliveryAddressId, deliveryAddress, note, now),
|
|
214
|
-
//
|
|
215
|
-
// this customer's own choice of how it is fulfilled, freshly made, so it is what
|
|
216
|
-
// defaultFulfillmentMode should offer next time rather than a possibly stale earlier one.
|
|
195
|
+
// The customer's latest choice of fulfillmentMode, for defaultFulfillmentMode to offer next time
|
|
217
196
|
ProtoMap.buildPartyPreferencesMessage(Ids.getPartyPreferencesMessageId(target.businessLink.id), partyPreferencesMessage, target.senderId, null, fulfillmentMode, now),
|
|
218
197
|
]);
|
|
219
198
|
}
|
|
220
199
|
// #endregion
|
|
221
|
-
// Throws if a profile is already held
|
|
222
|
-
// with. The
|
|
200
|
+
// Throws if a profile is already held, otherwise stores the credentials every later call signs
|
|
201
|
+
// with. The step signIn and createUser share.
|
|
223
202
|
beginSignIn(authenticationMethod, subjectId, authenticationToken) {
|
|
224
203
|
if (this._profile.value !== null)
|
|
225
204
|
throw new IllegalOperationException("A user is already signed in");
|
|
@@ -228,39 +207,38 @@ class Model {
|
|
|
228
207
|
this._authenticationToken = authenticationToken;
|
|
229
208
|
}
|
|
230
209
|
// #region Pushing
|
|
231
|
-
// One push, plus everything that follows
|
|
232
|
-
// request
|
|
233
|
-
//
|
|
234
|
-
// stored
|
|
235
|
-
// changed in the meantime
|
|
236
|
-
//
|
|
237
|
-
// reaching the write below already means the server took it.
|
|
210
|
+
// One push, plus everything that follows the server accepting it. The messages go out in one
|
|
211
|
+
// request with how far this business container's reads have got. The server answers with these
|
|
212
|
+
// messages under their assigned update numbers and nothing more: a push is not a read. They are
|
|
213
|
+
// stored and move no cursor, so the next read receives them again, with anything somebody else
|
|
214
|
+
// changed in the meantime. A refused push fails the call itself, like any Service call on a bad
|
|
215
|
+
// status, so reaching the write below means the server took it.
|
|
238
216
|
async pushBusinessMessages(target, messages) {
|
|
239
217
|
const cache = Model.getCache();
|
|
240
218
|
const messageUpdateNumber = await cache.readMessageUpdateNumber(target.containerName);
|
|
241
219
|
const authTokens = await this.requireBusinessAuthTokens(target.containerName, target.businessLink);
|
|
242
220
|
const response = await Model.getService().businessSyncIn(ProtoMap.buildBusinessSyncInRequest(messageUpdateNumber, messages), ProtoMap.buildAuthTokens(authTokens));
|
|
243
221
|
await this.storeIssuedTokens(ProtoMap.getResponseAuthTokens(response), target.containerName);
|
|
244
|
-
// Stored
|
|
245
|
-
//
|
|
222
|
+
// Stored as the response carried them: the server answers with the messages themselves, so
|
|
223
|
+
// nothing is stamped
|
|
246
224
|
const changeCount = await cache.writeMessages(target.containerName, ProtoMap.getSyncInResponseMessages(response));
|
|
247
225
|
// What the store took decides whether anything is published - see the Publishing region
|
|
248
226
|
if (changeCount !== 0)
|
|
249
227
|
this.publishBusiness(await cache.readBusiness(target.containerName));
|
|
250
228
|
}
|
|
251
|
-
// Amends
|
|
252
|
-
//
|
|
253
|
-
//
|
|
254
|
-
//
|
|
229
|
+
// Amends the cart containerName holds and writes the result to the cache. Nothing is pushed.
|
|
230
|
+
// The two paths of updateCartLocalOnly differ in container, id and sender: a visitor's has a
|
|
231
|
+
// freshly drawn id and no sender, a customer's has the id their membership derives and their own
|
|
232
|
+
// sender id.
|
|
255
233
|
async storeCartInCache(containerName, messageId, senderId, shoppingList) {
|
|
256
234
|
const cache = Model.getCache();
|
|
257
235
|
const changeCount = await cache.writePartyPreferences(containerName, ProtoMap.buildPartyPreferencesMessage(messageId, await cache.readPartyPreferences(containerName), senderId, shoppingList, null, BigInt(Date.now())));
|
|
258
|
-
// Published on what the store took,
|
|
236
|
+
// Published on what the store took, as for every read and push
|
|
259
237
|
if (changeCount !== 0)
|
|
260
238
|
this.publishBusiness(await cache.readBusiness(containerName));
|
|
261
239
|
}
|
|
262
|
-
// The business being browsed,
|
|
263
|
-
//
|
|
240
|
+
// The id of the business being browsed, for a write. Throws if there is none or it has not been
|
|
241
|
+
// read yet.
|
|
264
242
|
requireBusinessBeingBrowsed() {
|
|
265
243
|
const businessId = this._businessId;
|
|
266
244
|
if (businessId === null)
|
|
@@ -269,10 +247,9 @@ class Model {
|
|
|
269
247
|
throw new IllegalOperationException(`Business ${businessId} has not been read yet, so there is nothing to write to`);
|
|
270
248
|
return businessId;
|
|
271
249
|
}
|
|
272
|
-
// Everything a write needs, gathered before anything is built. Each precondition
|
|
273
|
-
//
|
|
274
|
-
//
|
|
275
|
-
// for want of a link token to sign with.
|
|
250
|
+
// Everything a write needs, gathered before anything is built. Each missing precondition throws
|
|
251
|
+
// its own error: no business browsed, business not loaded, no profile loaded, or a customer who
|
|
252
|
+
// is not a member. The server would refuse the last anyway, with no link token to sign with.
|
|
276
253
|
requireCustomerOfBusiness() {
|
|
277
254
|
const businessId = this.requireBusinessBeingBrowsed();
|
|
278
255
|
const business = this._business.value;
|
|
@@ -291,15 +268,15 @@ class Model {
|
|
|
291
268
|
countryCode: business.countryCode,
|
|
292
269
|
};
|
|
293
270
|
}
|
|
294
|
-
//
|
|
295
|
-
// Order.deliveryAddressId resolves against) or a full address sent directly. An id not in
|
|
296
|
-
// list would leave the business
|
|
297
|
-
//
|
|
271
|
+
// Checks where an order is delivered: an id in this customer's address list (the list
|
|
272
|
+
// 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 refused here. A full
|
|
274
|
+
// address needs no check against the list.
|
|
298
275
|
static requireDeliveryAddress(addresses, fulfillmentMode, deliveryAddressId, deliveryAddress) {
|
|
299
276
|
if (deliveryAddress !== null)
|
|
300
277
|
return;
|
|
301
278
|
if (deliveryAddressId === BigInt(proto_Constants.ENTITY_ID_NULL)) {
|
|
302
|
-
// Pick-up
|
|
279
|
+
// Pick-up names neither, which is normal. See Order.deliveryAddressId
|
|
303
280
|
if (fulfillmentMode === proto_FulfillmentMode.DELIVERY)
|
|
304
281
|
throw new InvalidArgumentException("An order to be delivered has to name a delivery address");
|
|
305
282
|
return;
|
|
@@ -310,23 +287,18 @@ class Model {
|
|
|
310
287
|
// #endregion
|
|
311
288
|
// #region The four calls of Service, each as this model makes it
|
|
312
289
|
//
|
|
313
|
-
// Each
|
|
314
|
-
//
|
|
315
|
-
// Reads in the business being browsed
|
|
316
|
-
//
|
|
317
|
-
//
|
|
318
|
-
//
|
|
319
|
-
//
|
|
320
|
-
//
|
|
321
|
-
//
|
|
322
|
-
//
|
|
323
|
-
//
|
|
324
|
-
//
|
|
325
|
-
// except that a business that is no longer the one that the businessId names is given up for the
|
|
326
|
-
// one that is, if there is one: see syncBusinessContainer.
|
|
327
|
-
// isStartedOver is for the one time that this calls itself: the link that a customer's profile found by the businessId is of
|
|
328
|
-
// a business that no longer has it, which syncBusinessContainer finds out once it has read the business. The profile is read again,
|
|
329
|
-
// as it is what said so, and this starts again from what it now says - which may be a business that the customer has to join, or none.
|
|
290
|
+
// Each pages, caches every page as it arrives, files what the server issued, and publishes what
|
|
291
|
+
// the cache then holds.
|
|
292
|
+
// Reads in the business being browsed: an anonymous visitor under its businessId, a signed-in
|
|
293
|
+
// customer under their own container (see getBusinessContainerName), joining as a customer first
|
|
294
|
+
// if not linked (see requireBusinessLink).
|
|
295
|
+
// 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 says so when it does not exist,
|
|
297
|
+
// is deleted, or does not allow customers to join. Online shopping off, or nothing to buy, is
|
|
298
|
+
// decided here (see syncBusinessContainer). The cache is kept, except that a business that is no
|
|
299
|
+
// longer the one the businessId names is replaced by the one that is.
|
|
300
|
+
// isStartedOver marks the one self-call, when the link the profile found for the businessId is of
|
|
301
|
+
// a business that no longer has it. The profile is read again and this starts over from it.
|
|
330
302
|
async businessSyncIn(isStartedOver = false) {
|
|
331
303
|
const businessId = this._businessId;
|
|
332
304
|
if (businessId === null || businessId.length === 0)
|
|
@@ -340,24 +312,20 @@ class Model {
|
|
|
340
312
|
const cache = Model.getCache();
|
|
341
313
|
const containerName = Model.getBusinessContainerName(link);
|
|
342
314
|
const anonymousName = ContainerNames.forBusiness(businessId);
|
|
343
|
-
//
|
|
344
|
-
//
|
|
345
|
-
//
|
|
346
|
-
//
|
|
347
|
-
//
|
|
348
|
-
//
|
|
349
|
-
//
|
|
350
|
-
//
|
|
351
|
-
//
|
|
352
|
-
// businessId has been given up and taken by another. Folded in, the one container would hold two businesses, and
|
|
353
|
-
// which of them it answers with would be decided by nothing - and a cart is the cart of the business it was built in.
|
|
354
|
-
// One that holds no business at all has nothing to contradict the link with, and is folded in: a cart can be all that a
|
|
355
|
-
// visit left of it.
|
|
315
|
+
// Folds what this device read while nobody was signed in into the customer's container. The
|
|
316
|
+
// later copy of an entity, by update number, wins, as for the server's own pages, so a tombstone
|
|
317
|
+
// beats what it deleted. No cursor moves.
|
|
318
|
+
// Left out:
|
|
319
|
+
// - The cart, settled below. The containers hold it under different ids, so a merge would file
|
|
320
|
+
// the visitor's as a second cart.
|
|
321
|
+
// - A container holding a different business than the link's. Its businessId may since have
|
|
322
|
+
// been given up and taken by another, and a cart belongs to the business it was built in.
|
|
323
|
+
// A container with no business is folded in, since a cart can be all a visit left.
|
|
356
324
|
const anonymousContainer = await cache.readBusiness(anonymousName);
|
|
357
325
|
const anonymousEntityId = ProtoMap.getBusinessResponseBusinessEntityId(anonymousContainer);
|
|
358
326
|
const anonymous = anonymousEntityId !== null && anonymousEntityId !== link.businessId ? null : anonymousContainer;
|
|
359
|
-
//
|
|
360
|
-
// partyPreferencesMessageFromMessages picks the same
|
|
327
|
+
// Taken from the messages just read, not a second read of the container.
|
|
328
|
+
// partyPreferencesMessageFromMessages picks the same message readPartyPreferences(anonymousName)
|
|
361
329
|
// would.
|
|
362
330
|
const anonymousPartyPreferences = anonymous === null ? null : ProtoMap.partyPreferencesMessageFromMessages(anonymous.messages);
|
|
363
331
|
if (anonymous !== null) {
|
|
@@ -365,12 +333,13 @@ class Model {
|
|
|
365
333
|
await cache.storeBusiness(containerName, anonymous);
|
|
366
334
|
}
|
|
367
335
|
const authTokens = await this.requireBusinessAuthTokens(containerName, link);
|
|
368
|
-
// The link names the business by
|
|
336
|
+
// The link names the business by entity id, because the link token that signs this call is for it
|
|
369
337
|
const isBusinessNamed = await this.syncBusinessContainer(containerName, businessId, link.businessId, ProtoMap.buildAuthTokens(authTokens));
|
|
370
|
-
// The
|
|
371
|
-
//
|
|
372
|
-
//
|
|
373
|
-
//
|
|
338
|
+
// The link's business has another businessId now, which the profile that found the link by this
|
|
339
|
+
// one did not know. The container is given up, so the profile is read again: the link is then
|
|
340
|
+
// found by this businessId or not, and a businessId naming another business is joined. Only
|
|
341
|
+
// once: a re-read profile that still finds the same link for the same businessId means the
|
|
342
|
+
// server contradicts itself
|
|
374
343
|
if (!isBusinessNamed) {
|
|
375
344
|
if (isStartedOver)
|
|
376
345
|
throw new InternalServerException(`The business that the link for the businessId ${businessId} is of does not have it, though the profile was read again`);
|
|
@@ -378,91 +347,72 @@ class Model {
|
|
|
378
347
|
await this.businessSyncIn(true);
|
|
379
348
|
return;
|
|
380
349
|
}
|
|
381
|
-
// THE CART A VISITOR BUILT BEFORE SIGNING IN. Signing in changes
|
|
382
|
-
//
|
|
383
|
-
// having just proved who they are.
|
|
350
|
+
// THE CART A VISITOR BUILT BEFORE SIGNING IN. Signing in changes the container being browsed,
|
|
351
|
+
// so without this the cart is left behind and the customer has to build it again.
|
|
384
352
|
if (anonymousPartyPreferences === null)
|
|
385
353
|
return;
|
|
386
|
-
// The visitor's cart holds taxes as raw codes,
|
|
387
|
-
//
|
|
388
|
-
//
|
|
389
|
-
//
|
|
354
|
+
// The visitor's cart holds taxes as raw codes, meaningful only within the country of the
|
|
355
|
+
// 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); failing that, the business being
|
|
357
|
+
// browsed is the same one.
|
|
390
358
|
const anonymousCountryCode = ProtoMap.getBusinessResponseCountryCode(anonymous) ?? this._business.value?.countryCode;
|
|
391
359
|
if (anonymousCountryCode === undefined)
|
|
392
360
|
throw new IllegalOperationException("The business the visitor's cart was built in is not known, so its taxes cannot be read");
|
|
393
|
-
// Read after the sync, so
|
|
394
|
-
//
|
|
361
|
+
// Read after the sync, so the comparison is against what the server just settled, not a local
|
|
362
|
+
// copy about to be replaced. The later updatedAt wins.
|
|
395
363
|
const userPartyPreferences = await cache.readPartyPreferences(containerName);
|
|
396
364
|
if (userPartyPreferences === null ||
|
|
397
365
|
ProtoMap.getMessageUpdatedAt(anonymousPartyPreferences) >
|
|
398
366
|
ProtoMap.getMessageUpdatedAt(userPartyPreferences))
|
|
399
367
|
await this.updateCart(ProtoMap.buildShoppingList(anonymousPartyPreferences, anonymousCountryCode));
|
|
400
|
-
// Dropped either way, carried over or beaten by the customer's own
|
|
401
|
-
//
|
|
368
|
+
// Dropped either way, carried over or beaten by the customer's own. The next visitor on this
|
|
369
|
+
// device reads the anonymous container, and a settled cart should not stay there.
|
|
402
370
|
await cache.deletePartyPreferences(anonymousName);
|
|
403
371
|
}
|
|
404
|
-
//
|
|
405
|
-
// learn a business's entity id while joining
|
|
372
|
+
// Reads the container named by the plain businessId: an anonymous read before signing in or
|
|
373
|
+
// joining, or to learn a business's entity id while joining (see readJoinBusinessId).
|
|
406
374
|
async syncAnonymousBusiness(businessId) {
|
|
407
375
|
await this.syncBusinessContainer(ContainerNames.forBusiness(businessId), businessId, null, null);
|
|
408
376
|
}
|
|
409
|
-
//
|
|
410
|
-
// returning customer sees the shop at once
|
|
411
|
-
//
|
|
412
|
-
//
|
|
413
|
-
//
|
|
414
|
-
//
|
|
415
|
-
//
|
|
416
|
-
// decided once, by the first request, and every page after it is about the same business named the
|
|
417
|
-
// same way. The first read of a business by an anonymous visitor names it by its businessId, and its
|
|
418
|
-
// answer is what makes every later one not have to - except the read that is made again, below,
|
|
419
|
-
// when the business that the container holds has turned out not to be the one that the businessId names.
|
|
377
|
+
// Reads one business into containerName, cache first: what is held publishes before the network
|
|
378
|
+
// is touched, so a returning customer sees the shop at once. Cursors start where earlier reads got
|
|
379
|
+
// to (see SyncCache.readBusinessCursors), so a repeat call asks only what changed.
|
|
380
|
+
// The first request names the business by entity id where known (businessEntityId from a link,
|
|
381
|
+
// 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 signed
|
|
383
|
+
// with nothing.
|
|
420
384
|
//
|
|
421
|
-
//
|
|
422
|
-
//
|
|
423
|
-
//
|
|
424
|
-
//
|
|
425
|
-
//
|
|
426
|
-
//
|
|
427
|
-
// read by
|
|
428
|
-
//
|
|
429
|
-
//
|
|
430
|
-
//
|
|
431
|
-
//
|
|
432
|
-
//
|
|
433
|
-
//
|
|
434
|
-
// it answers false: the link was found from what the profile said of the businessId, which is out of
|
|
435
|
-
// date, so the caller starts again from a profile that is read again - see businessSyncIn.
|
|
436
|
-
// Answers true when the container holds the business that businessId names.
|
|
437
|
-
// 2. That the business has turned online shopping on, which is the general settings it holds saying so.
|
|
438
|
-
// Settings it holds none of, because it has made none or has deleted them, do not. The server does not
|
|
439
|
-
// refuse a business for this, so this is where a business that has not is a NotFoundException, the
|
|
440
|
-
// same one as the server's for the businesses it does refuse, to every caller and every read.
|
|
441
|
-
// 3. That the business has something that a customer can buy: a product that is enabled, that it does not sell
|
|
442
|
-
// only offline, and that has a package with a price. A business with none, because it has made none, has
|
|
443
|
-
// turned them all off, or sells them all offline, is not a shop that there is anything to show or to order
|
|
444
|
-
// from, and is a NotFoundException as well. That is decided from the whole of what the container holds, as
|
|
445
|
-
// the first two are, and so it is decided when the last page has been written.
|
|
446
|
-
// firstAuthTokens are those of the link that businessEntityId is of, and none when that is none: they sign the reads that
|
|
447
|
-
// name the business by it, and a read by the businessId is signed with nothing, however it came to be made.
|
|
385
|
+
// Whether the business can be opened is decided from the container once every page is written,
|
|
386
|
+
// since a page carries only what changed. After every read, in order:
|
|
387
|
+
// 1. The container holds the business businessId names. A business keeps its entity id when its
|
|
388
|
+
// businessId changes and another can take the old one, so a container read by entity id may
|
|
389
|
+
// hold the wrong business. It is given up and read again from the start by businessId, once,
|
|
390
|
+
// 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 refused with a
|
|
392
|
+
// businessId). Its container is given up and this answers false, so the caller starts again
|
|
393
|
+
// from a re-read profile (see businessSyncIn). Otherwise this answers true.
|
|
394
|
+
// 2. Online shopping is on in the general settings (no settings means off). The server does not
|
|
395
|
+
// refuse a business for this, so this throws NotFoundException.
|
|
396
|
+
// 3. The business has something to buy: an enabled product, not sold only offline, with a priced
|
|
397
|
+
// package. Otherwise it throws NotFoundException too.
|
|
448
398
|
async syncBusinessContainer(containerName, businessId, businessEntityId, firstAuthTokens) {
|
|
449
399
|
const cache = Model.getCache();
|
|
450
400
|
const cached = await cache.readBusiness(containerName);
|
|
451
401
|
const cachedBusiness = Model.buildPublishable(cached);
|
|
452
|
-
this.publish(cachedBusiness); //
|
|
453
|
-
this._isPublishedBusinessVetted = false; // Until
|
|
454
|
-
// The entity id
|
|
455
|
-
//
|
|
456
|
-
//
|
|
402
|
+
this.publish(cachedBusiness); // Null if online shopping is off, see publishBusiness
|
|
403
|
+
this._isPublishedBusinessVetted = false; // Until the read ends
|
|
404
|
+
// The entity id an earlier read left in this container. It names the business when no link
|
|
405
|
+
// does. It may not be what the businessId finds now: a business can give up its businessId to
|
|
406
|
+
// another, or change it.
|
|
457
407
|
const cachedEntityId = ProtoMap.getBusinessResponseBusinessEntityId(cached);
|
|
458
|
-
// What the store accepted
|
|
408
|
+
// What the store accepted (the blobs it stored). Decides the publish at the end.
|
|
459
409
|
let changeCount;
|
|
460
410
|
try {
|
|
461
411
|
changeCount = await this.readBusinessPages(containerName, businessEntityId ?? cachedEntityId ?? businessId, await cache.readBusinessCursors(containerName), ProtoMap.getBusinessResponseAttachmentBaseUrlExpiry(cached), firstAuthTokens);
|
|
462
412
|
}
|
|
463
413
|
catch (error) {
|
|
464
|
-
// The server says
|
|
465
|
-
//
|
|
414
|
+
// The server says the business cannot be opened, so what was published from the cache is not
|
|
415
|
+
// what is there. The container is left as it is: deciding that a business is gone is the caller's
|
|
466
416
|
if (error instanceof NotFoundException)
|
|
467
417
|
this.publishBusiness(null);
|
|
468
418
|
throw error;
|
|
@@ -470,60 +420,61 @@ class Model {
|
|
|
470
420
|
let held = await cache.readBusiness(containerName);
|
|
471
421
|
// Whether what was published is no longer what the container holds
|
|
472
422
|
let isPublishedStale = changeCount !== 0;
|
|
473
|
-
// Named by the cached entity id,
|
|
423
|
+
// Named by the cached entity id, not by a link, which names only its own business
|
|
474
424
|
if (businessEntityId === null && cachedEntityId !== null && !Model.isBusinessNamed(held, businessId)) {
|
|
475
|
-
//
|
|
476
|
-
//
|
|
477
|
-
// no attachment url held
|
|
478
|
-
// old business's
|
|
479
|
-
// send none, leaving every photo
|
|
425
|
+
// Nothing read of one business says anything about another, so none of the container is kept,
|
|
426
|
+
// nor how far it was read. The business the businessId names is read from the beginning, with
|
|
427
|
+
// no attachment url held: the expiry is zero, not the container's, because the url it held
|
|
428
|
+
// was the old business's and went with it. A server sent that expiry would take the new
|
|
429
|
+
// business to have the url and send none, leaving every photo without one. Signed with
|
|
430
|
+
// nothing, as a link token is refused with a businessId
|
|
480
431
|
await cache.deleteBusiness(containerName);
|
|
481
432
|
this.publishBusiness(null);
|
|
482
433
|
await this.readBusinessPages(containerName, businessId, new SyncState(SyncKeys.BUSINESS_SYNC_IN), 0n, null);
|
|
483
434
|
held = await cache.readBusiness(containerName);
|
|
484
435
|
isPublishedStale = true;
|
|
485
436
|
}
|
|
486
|
-
//
|
|
437
|
+
// The businessId finds the business that has it, so any other is the server contradicting itself
|
|
487
438
|
if (businessEntityId === null && !Model.isBusinessNamed(held, businessId))
|
|
488
439
|
throw new InternalServerException(`The business that was read for the businessId ${businessId} has the businessId ${ProtoMap.getBusinessResponseBusinessId(held)}`);
|
|
489
|
-
// Named by a link
|
|
490
|
-
//
|
|
440
|
+
// Named by a link whose business no longer has the businessId. It cannot be re-read by
|
|
441
|
+
// businessId (a link token is refused with one), so the container is given up as above and the
|
|
442
|
+
// caller starts again
|
|
491
443
|
if (businessEntityId !== null && !Model.isBusinessNamed(held, businessId)) {
|
|
492
444
|
await cache.deleteBusiness(containerName);
|
|
493
445
|
this.publishBusiness(null);
|
|
494
446
|
return false;
|
|
495
447
|
}
|
|
496
|
-
// A container
|
|
497
|
-
// not a business
|
|
448
|
+
// A container holding no business says nothing about online shopping. That is a failure of its
|
|
449
|
+
// own, not a business with it off (see readJoinBusinessId)
|
|
498
450
|
if (ProtoMap.getBusinessResponseBusinessEntityId(held) !== null && !ProtoMap.isBusinessResponseOnlineShoppingOn(held)) {
|
|
499
|
-
// What was published from the cache
|
|
500
|
-
//
|
|
501
|
-
//
|
|
451
|
+
// What was published from the cache is the business as it was when open, not as it is now.
|
|
452
|
+
// Nothing of a business that cannot be opened stays published, or a customer could be joined
|
|
453
|
+
// to it (see readJoinBusinessId)
|
|
502
454
|
this.publishBusiness(null);
|
|
503
455
|
throw new NotFoundException("The business has not turned online shopping on");
|
|
504
456
|
}
|
|
505
|
-
// What the container holds now, built once for this and
|
|
506
|
-
//
|
|
457
|
+
// What the container holds now, built once for this check and the publish below. When the read
|
|
458
|
+
// changed nothing, it is the one built from the cache.
|
|
507
459
|
const business = isPublishedStale ? Model.buildPublishable(held) : cachedBusiness;
|
|
508
460
|
if (business !== null && business.catalog.productCount === 0) {
|
|
509
|
-
//
|
|
461
|
+
// A shop with nothing to buy is not found and not shown, even if the cache published it
|
|
510
462
|
this.publishBusiness(null);
|
|
511
463
|
throw new NotFoundException("The business has nothing that a customer can buy");
|
|
512
464
|
}
|
|
513
|
-
// A read the store took nothing from re-renders nobody
|
|
514
|
-
//
|
|
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
|
|
515
467
|
if (isPublishedStale)
|
|
516
468
|
this.publish(business);
|
|
517
|
-
// What is published now
|
|
469
|
+
// What is published now was read to the end, is the business the businessId names, and is open
|
|
518
470
|
this._isPublishedBusinessVetted = true;
|
|
519
471
|
return true;
|
|
520
472
|
}
|
|
521
|
-
// Reads every page
|
|
522
|
-
//
|
|
523
|
-
//
|
|
524
|
-
//
|
|
525
|
-
//
|
|
526
|
-
// it is sent none, whatever it is given: no request that names a businessId carries one, from any of the paths that make it.
|
|
473
|
+
// Reads every page the server has for the given cursors and writes each into the container as it
|
|
474
|
+
// arrives. The business is named by entity id or businessId, decided by the first request, and
|
|
475
|
+
// every later page names it the same way. Answers what the store accepted (the blobs it stored).
|
|
476
|
+
// firstAuthTokens are those of a link. A link token is refused with a businessId, so a read
|
|
477
|
+
// naming the business by it is sent none, even if given some.
|
|
527
478
|
async readBusinessPages(containerName, business, cursors, attachmentBaseUrlExpiry, firstAuthTokens) {
|
|
528
479
|
const cache = Model.getCache();
|
|
529
480
|
let request = ProtoMap.buildBusinessSyncOutRequest(business, cursors, attachmentBaseUrlExpiry);
|
|
@@ -532,12 +483,12 @@ class Model {
|
|
|
532
483
|
for (;;) {
|
|
533
484
|
const page = await Model.getService().businessSyncOut(request, authTokens);
|
|
534
485
|
const issued = ProtoMap.getResponseAuthTokens(page);
|
|
535
|
-
// Written as
|
|
536
|
-
//
|
|
486
|
+
// Written as each page arrives, so an interrupted read repeats work and loses no pages
|
|
487
|
+
// already received. Entities and tombstones go in together, one write per page.
|
|
537
488
|
changeCount += await cache.writeBusiness(containerName, page);
|
|
538
489
|
await this.storeIssuedTokens(issued, containerName);
|
|
539
|
-
// Null
|
|
540
|
-
//
|
|
490
|
+
// Null on the last page: fewer than a full page came back, or a full one advanced no cursor
|
|
491
|
+
// (only a server breaking its own ordering does that)
|
|
541
492
|
const next = ProtoMap.nextBusinessSyncOutRequest(request, page);
|
|
542
493
|
if (next === null)
|
|
543
494
|
return changeCount;
|
|
@@ -546,17 +497,17 @@ class Model {
|
|
|
546
497
|
authTokens = ProtoMap.refreshAuthTokens(authTokens, issued);
|
|
547
498
|
}
|
|
548
499
|
}
|
|
549
|
-
//
|
|
550
|
-
//
|
|
551
|
-
//
|
|
500
|
+
// True when the response holds the business businessId names, or no business, which says nothing
|
|
501
|
+
// against it. Only a business with another businessId is another business. Ids are compared as
|
|
502
|
+
// the server finds them, ignoring case and spacing (see canonicalizeBusinessId)
|
|
552
503
|
static isBusinessNamed(response, businessId) {
|
|
553
504
|
const heldBusinessId = ProtoMap.getBusinessResponseBusinessId(response);
|
|
554
505
|
return (heldBusinessId === null ||
|
|
555
506
|
canonicalizeBusinessId(heldBusinessId) === canonicalizeBusinessId(businessId));
|
|
556
507
|
}
|
|
557
|
-
// The BusinessLink of this profile for the
|
|
558
|
-
//
|
|
559
|
-
//
|
|
508
|
+
// The BusinessLink of this profile for the business, joining as a customer first (once) if not
|
|
509
|
+
// linked. A business in the profile with no matching link is a server inconsistency a join would
|
|
510
|
+
// not fix, so that throws.
|
|
560
511
|
async requireBusinessLink(businessId, profile) {
|
|
561
512
|
if (profile.user === null)
|
|
562
513
|
throw new IllegalOperationException("No profile has been read, so who is browsing is not known");
|
|
@@ -567,25 +518,25 @@ class Model {
|
|
|
567
518
|
throw new InternalServerException(`This customer has no BusinessLink to business ${businessId}`);
|
|
568
519
|
const joinBusinessId = await this.readJoinBusinessId(businessId);
|
|
569
520
|
await this.profileSyncIn(ProtoMap.buildProfileSyncOutRequest(await Model.getCache().readProfileCursors(this.requireProfileContainerName()), joinBusinessId), await this.requireProfileAuthTokens());
|
|
570
|
-
//
|
|
571
|
-
//
|
|
521
|
+
// Tried once: a join that leaves no link is the server contradicting itself, and retrying
|
|
522
|
+
// would not fix it.
|
|
572
523
|
const joined = this._profile.value;
|
|
573
524
|
const joinedLink = joined === null ? null : Model.findBusinessLink(joined, businessId);
|
|
574
525
|
if (joinedLink === null)
|
|
575
526
|
throw new InternalServerException(`Joining business ${businessId} did not leave this customer's profile naming it`);
|
|
576
527
|
return joinedLink;
|
|
577
528
|
}
|
|
578
|
-
// The entity id to join, for a business this profile
|
|
579
|
-
// already held, if an anonymous visit
|
|
580
|
-
// A business
|
|
581
|
-
// be
|
|
529
|
+
// The entity id to join, for a business this profile does not list yet. Taken from the Business
|
|
530
|
+
// already held, if an anonymous visit read it to the end, else from a fresh anonymous read.
|
|
531
|
+
// A business published from the cache by a read that has not ended is not known to be open or to
|
|
532
|
+
// be the one the businessId names, so a customer is not joined to it.
|
|
582
533
|
async readJoinBusinessId(businessId) {
|
|
583
534
|
const alreadyHeld = this._business.value;
|
|
584
535
|
if (alreadyHeld !== null && this._isPublishedBusinessVetted)
|
|
585
536
|
return alreadyHeld.id;
|
|
586
537
|
await this.syncAnonymousBusiness(businessId);
|
|
587
|
-
// A business
|
|
588
|
-
// published
|
|
538
|
+
// A missing business is a NotFoundException from the read above. Reaching here with none
|
|
539
|
+
// published means the server answered with no Business, a failure of its own
|
|
589
540
|
const business = this._business.value;
|
|
590
541
|
if (business === null)
|
|
591
542
|
throw new InternalServerException(`The read of business ${businessId} answered with no Business`);
|
|
@@ -598,8 +549,8 @@ class Model {
|
|
|
598
549
|
return business;
|
|
599
550
|
return null;
|
|
600
551
|
}
|
|
601
|
-
// Null unless both the business and a link naming this profile's
|
|
602
|
-
//
|
|
552
|
+
// Null unless both the business and a link naming this profile's user are present: a membership
|
|
553
|
+
// needs both.
|
|
603
554
|
static findBusinessLink(profile, businessId) {
|
|
604
555
|
const user = profile.user;
|
|
605
556
|
const businessEntity = Model.findBusinessEntity(profile, businessId);
|
|
@@ -610,22 +561,20 @@ class Model {
|
|
|
610
561
|
return link;
|
|
611
562
|
return null;
|
|
612
563
|
}
|
|
613
|
-
// Where this customer's
|
|
614
|
-
// Business.businessId so a signed-in customer's data
|
|
615
|
-
// same storefront. Hex for the
|
|
564
|
+
// Where this customer's copy of a business lives. Named for the BusinessLink, not
|
|
565
|
+
// Business.businessId, so a signed-in customer's data does not mix with an anonymous visit to the
|
|
566
|
+
// same storefront. Hex for the reason given at Ids.toStorageHexString: a fixed-width,
|
|
616
567
|
// case-sensitive name.
|
|
617
568
|
static getBusinessContainerName(link) {
|
|
618
569
|
return ContainerNames.forBusiness(Ids.toStorageHexString(link.id));
|
|
619
570
|
}
|
|
620
|
-
// The credentials that sign a call about a customer's
|
|
621
|
-
// token
|
|
622
|
-
//
|
|
623
|
-
//
|
|
624
|
-
//
|
|
625
|
-
//
|
|
626
|
-
//
|
|
627
|
-
// write below now gives this container to reissue over.
|
|
628
|
-
// A link with no linkToken at all is a server inconsistency: thrown rather than sent unsigned.
|
|
571
|
+
// The credentials that sign a call about a customer's business container. The authentication
|
|
572
|
+
// token comes from memory, as in buildProfileAuthTokens: a stored AuthTokens does not keep it.
|
|
573
|
+
// The user token and link token are read separately: storeIssuedTokens overwrites the whole blob
|
|
574
|
+
// per call, so relying on it alone would lose whichever token the last call did not reissue. Each
|
|
575
|
+
// falls back to its own source: the profile container's user token, and this link's linkToken. A
|
|
576
|
+
// fresh membership has no issued tokens, so the write below seeds this container with them.
|
|
577
|
+
// A link with no linkToken is a server inconsistency, and throws rather than sending unsigned.
|
|
629
578
|
async requireBusinessAuthTokens(containerName, link) {
|
|
630
579
|
const authenticationMethod = this._authenticationMethod;
|
|
631
580
|
const authenticationToken = this._authenticationToken;
|
|
@@ -646,10 +595,9 @@ class Model {
|
|
|
646
595
|
await cache.writeAuthTokens(containerName, authTokens);
|
|
647
596
|
return authTokens;
|
|
648
597
|
}
|
|
649
|
-
// The profile of whoever is signed in, paged
|
|
650
|
-
// profile container
|
|
651
|
-
//
|
|
652
|
-
// of the profile.
|
|
598
|
+
// The profile of whoever is signed in, paged like a business read. What it earns files in the
|
|
599
|
+
// profile container. The call is about the caller alone, so its AuthTokens carries no link
|
|
600
|
+
// token. A linkToken travels only inside a BusinessLink, stored with the profile.
|
|
653
601
|
async profileSyncIn(firstRequest, firstAuthTokens) {
|
|
654
602
|
const containerName = this.getProfileContainerName();
|
|
655
603
|
if (containerName === null)
|
|
@@ -659,8 +607,8 @@ class Model {
|
|
|
659
607
|
let request = firstRequest;
|
|
660
608
|
let authTokens = firstAuthTokens;
|
|
661
609
|
let changeCount = 0;
|
|
662
|
-
// Tracked across
|
|
663
|
-
//
|
|
610
|
+
// Tracked across all pages: a profile long enough to page could put the joined link and
|
|
611
|
+
// business on different pages.
|
|
664
612
|
const joinBusinessId = firstRequest.joinBusinessId;
|
|
665
613
|
let hasJoinedLink = false;
|
|
666
614
|
let hasJoinedBusiness = false;
|
|
@@ -675,8 +623,8 @@ class Model {
|
|
|
675
623
|
if (next === null)
|
|
676
624
|
break;
|
|
677
625
|
request = next;
|
|
678
|
-
// Each call after the first is signed with
|
|
679
|
-
//
|
|
626
|
+
// Each call after the first is signed with what the previous page reissued, since a token can
|
|
627
|
+
// fall due part way through a long read
|
|
680
628
|
authTokens = ProtoMap.refreshAuthTokens(authTokens, ProtoMap.getResponseAuthTokens(page));
|
|
681
629
|
}
|
|
682
630
|
// As above: nothing the store took, nothing published
|
|
@@ -685,40 +633,35 @@ class Model {
|
|
|
685
633
|
response = await Model.getCache().readProfile(containerName);
|
|
686
634
|
this.publishProfile(response);
|
|
687
635
|
}
|
|
688
|
-
// Last, so everything that arrived is cached and published first
|
|
689
|
-
//
|
|
636
|
+
// Last, so everything that arrived is cached and published first. The pages were not at fault,
|
|
637
|
+
// the join was
|
|
690
638
|
Model.requireJoinedBusiness(joinBusinessId, hasJoinedLink, hasJoinedBusiness);
|
|
691
639
|
return response;
|
|
692
640
|
}
|
|
693
|
-
// The sign-up call.
|
|
694
|
-
//
|
|
695
|
-
//
|
|
696
|
-
// instead of several is the only difference.
|
|
641
|
+
// The sign-up call. Not paged: resending it would create a second user, and a new caller has at
|
|
642
|
+
// most their User, one BusinessLink and the Business it names. It can join a business in the same
|
|
643
|
+
// call, with the same join requirements as a profile read, in one response.
|
|
697
644
|
async createProfileOnServer(request, authTokens) {
|
|
698
645
|
const response = await this.writeProfileResponse(await Model.getService().createUser(request, authTokens));
|
|
699
646
|
const joinBusinessId = request.joinBusinessId;
|
|
700
|
-
// Raised after the response is cached and published, as on a profile read
|
|
701
|
-
// the User exists regardless of the join, so discarding
|
|
702
|
-
// would create a second one
|
|
647
|
+
// Raised after the response is cached and published, as on a profile read. It matters more
|
|
648
|
+
// here: the User exists regardless of the join, so discarding the response and signing up
|
|
649
|
+
// again would create a second one
|
|
703
650
|
Model.requireJoinedBusiness(joinBusinessId, ProtoMap.hasJoinedBusinessLink(joinBusinessId, response), ProtoMap.hasJoinedBusiness(joinBusinessId, response));
|
|
704
651
|
return response;
|
|
705
652
|
}
|
|
706
653
|
// #endregion
|
|
707
654
|
// #region Driving one call
|
|
708
|
-
//
|
|
709
|
-
//
|
|
710
|
-
//
|
|
711
|
-
//
|
|
712
|
-
// so either one missing leaves the caller told it is a customer of something it cannot reach.
|
|
655
|
+
// Throws when a requested join is not answered with both the BusinessLink (the membership, whose
|
|
656
|
+
// token signs calls) and the Business it names (whose businessId everything else is built from).
|
|
657
|
+
// Without both, the caller is a customer of something it cannot reach. Shared by the two calls
|
|
658
|
+
// that can carry a joinBusinessId.
|
|
713
659
|
//
|
|
714
|
-
// A ServiceException, not
|
|
715
|
-
//
|
|
716
|
-
//
|
|
717
|
-
//
|
|
718
|
-
//
|
|
719
|
-
// test, which Constants.ENTITY_ID_NULL also fails, so "no join asked" needs no case of its own).
|
|
720
|
-
// An id that is not a business entity id never gets as far as here: a server refuses it (400),
|
|
721
|
-
// which fails the call itself, and sending one is the caller's bug, not the server's.
|
|
660
|
+
// A ServiceException, not a caller error: the server answered 200 and left out what was asked
|
|
661
|
+
// for. 500 says retrying is worth it.
|
|
662
|
+
// 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 refuses
|
|
664
|
+
// any other (400), which fails the call itself.
|
|
722
665
|
static requireJoinedBusiness(joinBusinessId, hasBusinessLink, hasBusiness) {
|
|
723
666
|
if (Ids.getEntityTypeId(joinBusinessId) !== proto_Constants.BUSINESS_ENTITY_TYPE_ID)
|
|
724
667
|
return;
|
|
@@ -731,14 +674,13 @@ class Model {
|
|
|
731
674
|
: "BusinessLink";
|
|
732
675
|
throw new InternalServerException(`A join of business ${joinBusinessId} was answered with no ${missing}`);
|
|
733
676
|
}
|
|
734
|
-
// What every profile response leaves in the store: its entities cached, and
|
|
735
|
-
// filed against the same container. Answers how much
|
|
736
|
-
//
|
|
737
|
-
//
|
|
738
|
-
//
|
|
739
|
-
//
|
|
740
|
-
//
|
|
741
|
-
// client made, and the next read brings them back, so, they are stored without moving any.
|
|
677
|
+
// What every profile response leaves in the store: its entities cached, and what it reissued
|
|
678
|
+
// filed against the same container. Answers how much the store took, which decides whether
|
|
679
|
+
// anything is published. Shared by the three calls that receive one (a page of a read, a sign up,
|
|
680
|
+
// an update) so they cache and file tokens the same way. It does not publish: a read publishes
|
|
681
|
+
// once for a whole loop of these, the other two once each.
|
|
682
|
+
// Only a page of a read moves the container's cursors. A sign up and an update are changes this
|
|
683
|
+
// client made and the next read brings them back, so they are stored without moving any.
|
|
742
684
|
async cacheProfileResponse(containerName, response, isPageOfRead) {
|
|
743
685
|
const cache = Model.getCache();
|
|
744
686
|
const changeCount = isPageOfRead
|
|
@@ -747,13 +689,11 @@ class Model {
|
|
|
747
689
|
await this.storeIssuedTokens(ProtoMap.getResponseAuthTokens(response), containerName);
|
|
748
690
|
return changeCount;
|
|
749
691
|
}
|
|
750
|
-
// What a
|
|
751
|
-
// paged read
|
|
752
|
-
//
|
|
753
|
-
//
|
|
754
|
-
//
|
|
755
|
-
// answers with the stored User - whose update number and updated time a caller cannot know - this
|
|
756
|
-
// publishes it with no change here.
|
|
692
|
+
// What a profile call other than a read leaves behind: the response cached and published, as for
|
|
693
|
+
// a paged read without the paging. A sign up and an update publish by the rule a read follows,
|
|
694
|
+
// on what the store took. An update publishes nothing today because the server puts no entities
|
|
695
|
+
// in its response, only what it reissued. If it ever answers with the stored User, whose update
|
|
696
|
+
// number and updated time a caller cannot know, this publishes it unchanged.
|
|
757
697
|
async writeProfileResponse(response) {
|
|
758
698
|
const containerName = this.getProfileContainerName();
|
|
759
699
|
if (containerName === null)
|
|
@@ -762,15 +702,14 @@ class Model {
|
|
|
762
702
|
this.publishProfile(await Model.getCache().readProfile(containerName));
|
|
763
703
|
return response;
|
|
764
704
|
}
|
|
765
|
-
// Files what a call earned against the container
|
|
766
|
-
// the subject's profile container, a business call's in that business's container.
|
|
767
|
-
//
|
|
768
|
-
//
|
|
769
|
-
//
|
|
770
|
-
//
|
|
771
|
-
// BusinessLink entity instead, stored with the rest of the profile.
|
|
705
|
+
// Files what a call earned against the container the call was about: a profile call's tokens in
|
|
706
|
+
// the subject's profile container, a business call's in that business's container. The call
|
|
707
|
+
// decides this, since a token says nothing about what it is for. A business sync out that
|
|
708
|
+
// reissues a user token still files that copy with the business it came from.
|
|
709
|
+
// A profile call carries no link token here: a membership's link token travels inside its
|
|
710
|
+
// BusinessLink, stored with the profile.
|
|
772
711
|
// A failed write is swallowed: the server issues another next call, so losing one costs a round
|
|
773
|
-
// trip,
|
|
712
|
+
// trip, and surfacing it would turn a working read into a thrown one.
|
|
774
713
|
async storeIssuedTokens(issued, containerName) {
|
|
775
714
|
if (issued === undefined)
|
|
776
715
|
return;
|
|
@@ -783,16 +722,15 @@ class Model {
|
|
|
783
722
|
}
|
|
784
723
|
// #endregion
|
|
785
724
|
// #region Containers and credentials
|
|
786
|
-
// Where this subject's profile and tokens are cached
|
|
787
|
-
//
|
|
788
|
-
//
|
|
725
|
+
// 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. Several
|
|
727
|
+
// can coexist, so signing back in finds the subject's own profile.
|
|
789
728
|
getProfileContainerName() {
|
|
790
729
|
return this._authenticationMethod === null || this._subjectId === null
|
|
791
730
|
? null
|
|
792
731
|
: ContainerNames.forProfile(`${this._authenticationMethod}.${this._subjectId}`);
|
|
793
732
|
}
|
|
794
|
-
// The profile container name, when a profile is
|
|
795
|
-
// silently building on a signed-out state the caller has already ruled out.
|
|
733
|
+
// The profile container name, for when a profile is known to exist. Throws if signed out.
|
|
796
734
|
requireProfileContainerName() {
|
|
797
735
|
const containerName = this.getProfileContainerName();
|
|
798
736
|
if (containerName === null)
|
|
@@ -800,7 +738,7 @@ class Model {
|
|
|
800
738
|
return containerName;
|
|
801
739
|
}
|
|
802
740
|
// Signs a call about the caller alone, or null if signed out. Rereads the user token from the
|
|
803
|
-
// profile container each time, since a call can reissue it. Carries no linkToken
|
|
741
|
+
// profile container each time, since a call can reissue it. Carries no linkToken. See
|
|
804
742
|
// requireBusinessAuthTokens for a business call.
|
|
805
743
|
async buildProfileAuthTokens() {
|
|
806
744
|
const authenticationMethod = this._authenticationMethod;
|
|
@@ -823,28 +761,23 @@ class Model {
|
|
|
823
761
|
// #endregion
|
|
824
762
|
// #region Publishing
|
|
825
763
|
//
|
|
826
|
-
//
|
|
827
|
-
//
|
|
828
|
-
//
|
|
829
|
-
//
|
|
830
|
-
//
|
|
831
|
-
//
|
|
832
|
-
//
|
|
833
|
-
//
|
|
834
|
-
//
|
|
835
|
-
//
|
|
836
|
-
// the cache-first publish of a container that a read of such a business left behind would show it again before the
|
|
837
|
-
// next read is refused, and a customer could be joined to it. A read that finds that a business that it published from
|
|
838
|
-
// the cache has stopped being open, or that the server says cannot be opened, takes it back: it publishes none.
|
|
839
|
-
// What is published from the cache is not taken to be open all the same until the read has ended (see
|
|
840
|
-
// _isPublishedBusinessVetted), which is what readJoinBusinessId goes by.
|
|
764
|
+
// A read publishes on how much the store accepted (blobs stored plus ids deleted, summed over
|
|
765
|
+
// every page), not on what the response carried. The store keeps a blob only when its eTag is
|
|
766
|
+
// later, so a response can be rejected in full (a cursor that moved without its eTag resends an
|
|
767
|
+
// entity already cached). Publishing that would re-render every observer over an identical value.
|
|
768
|
+
// All four calls follow this, so an update publishes nothing (see writeProfileResponse).
|
|
769
|
+
//
|
|
770
|
+
// A business with online shopping off is not published, even from the cache, or a customer could
|
|
771
|
+
// be joined to it. A read that finds the published business is no longer open, or that the server
|
|
772
|
+
// refuses, takes it back by publishing none. What came from the cache counts as open only once the
|
|
773
|
+
// read has ended (see _isPublishedBusinessVetted, which readJoinBusinessId goes by).
|
|
841
774
|
publishBusiness(response) {
|
|
842
775
|
this.publish(Model.buildPublishable(response));
|
|
843
776
|
}
|
|
844
777
|
publish(business) {
|
|
845
778
|
this._business.value = business;
|
|
846
779
|
}
|
|
847
|
-
// The business
|
|
780
|
+
// The business a response holds, or null when there is no response or online shopping is off
|
|
848
781
|
static buildPublishable(response) {
|
|
849
782
|
return response === null || !ProtoMap.isBusinessResponseOnlineShoppingOn(response)
|
|
850
783
|
? null
|