@peabody-soft/mbs-model-ts 1.11.0 → 1.13.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/Constants.d.ts +1 -0
- package/dist/Constants.js +3 -0
- package/dist/Exceptions.d.ts +5 -0
- package/dist/Exceptions.js +16 -2
- package/dist/Ids.d.ts +1 -0
- package/dist/Ids.js +5 -3
- package/dist/InMemoryLock.d.ts +5 -0
- package/dist/InMemoryLock.js +20 -0
- package/dist/Lock.d.ts +3 -0
- package/dist/Lock.js +1 -0
- package/dist/Message.d.ts +1 -5
- package/dist/Message.js +2 -12
- package/dist/Model.d.ts +17 -4
- package/dist/Model.js +304 -113
- package/dist/Order.d.ts +2 -2
- package/dist/Order.js +4 -4
- package/dist/ProtoMap.d.ts +13 -4
- package/dist/ProtoMap.js +139 -71
- package/dist/Service.d.ts +3 -4
- package/dist/ServiceProxy.d.ts +3 -4
- package/dist/ServiceProxy.js +4 -6
- package/dist/SyncCache.d.ts +6 -2
- package/dist/SyncCache.js +83 -44
- package/dist/SyncKeys.js +3 -2
- package/dist/generated/BusinessSyncInRequest.d.ts +30 -16
- package/dist/generated/BusinessSyncInRequest.js +9 -1
- package/dist/generated/BusinessSyncInResponse.d.ts +9 -6
- package/dist/generated/Constants.d.ts +2 -12
- package/dist/generated/Constants.js +2 -12
- package/dist/generated/Message.d.ts +5 -16
- package/dist/generated/Message.js +8 -22
- package/dist/generated/Party.d.ts +52 -0
- package/dist/generated/Party.js +68 -0
- package/dist/generated/PartyPreferencesPayload.d.ts +2 -1
- package/dist/generated/ProfileSyncOutRequest.d.ts +2 -0
- package/dist/generated/ProfileSyncOutResponse.d.ts +2 -2
- package/dist/generated/SalesOrderPayload.d.ts +9 -8
- package/dist/generated/User.d.ts +3 -1
- package/dist/generated/UserRequest.d.ts +52 -0
- package/dist/generated/{CreateUserRequest.js → UserRequest.js} +11 -11
- package/dist/index.d.ts +4 -2
- package/dist/index.js +7 -5
- package/package.json +2 -2
- package/dist/generated/CreateUserRequest.d.ts +0 -43
package/dist/Model.js
CHANGED
|
@@ -1,14 +1,15 @@
|
|
|
1
1
|
import { Constants as proto_Constants } from "./generated/Constants";
|
|
2
|
-
import { CreateUserRequest as proto_CreateUserRequest } from "./generated/CreateUserRequest";
|
|
3
2
|
import { FulfillmentMode as proto_FulfillmentMode } from "./generated/FulfillmentMode";
|
|
4
3
|
import AuthTokens from "./AuthTokens";
|
|
4
|
+
import { MAX_PROFILE_STALENESS_MS } from "./Constants";
|
|
5
5
|
import * as ContainerNames from "./ContainerNames";
|
|
6
6
|
import { IllegalOperationException, InvalidArgumentException } from "./Exceptions";
|
|
7
7
|
import Ids from "./Ids";
|
|
8
|
+
import InMemoryLock from "./InMemoryLock";
|
|
8
9
|
import LiveData from "./LiveData";
|
|
9
10
|
import { canonicalizeBusinessId } from "./Profile";
|
|
10
11
|
import * as ProtoMap from "./ProtoMap";
|
|
11
|
-
import { InternalServerException, NotFoundException } from "./ServiceExceptions";
|
|
12
|
+
import { ConflictException, InternalServerException, NotFoundException } from "./ServiceExceptions";
|
|
12
13
|
import ShoppingList from "./ShoppingList";
|
|
13
14
|
import SyncCache from "./SyncCache";
|
|
14
15
|
import * as SyncKeys from "./SyncKeys";
|
|
@@ -16,15 +17,26 @@ import SyncState from "./SyncState";
|
|
|
16
17
|
// The root of the object model, and the only object a UI needs. Call initialize once, then get the
|
|
17
18
|
// current instance with getInstance.
|
|
18
19
|
//
|
|
19
|
-
//
|
|
20
|
+
// CALLS ARE NOT QUEUED. A caller makes one call at a time and awaits it. Otherwise:
|
|
20
21
|
// - A setBusinessId still reading when signIn reads the business too can publish the anonymous
|
|
21
22
|
// container's business over the customer's. The published business then has no cart or orders,
|
|
22
23
|
// and nothing publishes them again.
|
|
23
|
-
// - Two reads at once interleave their pages and writes
|
|
24
|
-
//
|
|
25
|
-
//
|
|
26
|
-
//
|
|
27
|
-
//
|
|
24
|
+
// - Two reads at once interleave their pages and writes.
|
|
25
|
+
// A queue inside Model would need an unqueued twin of each public method (signIn calls signOut,
|
|
26
|
+
// businessSyncIn calls updateCart). A caller that calls from several places, like a UI, queues the
|
|
27
|
+
// calls itself. Only changeUser takes a Lock, so that two tabs do not start from the same user.
|
|
28
|
+
//
|
|
29
|
+
// SEVERAL TABS can use one store, each with its own Model. The store applies a batch atomically and
|
|
30
|
+
// keeps the greater eTag, so what the tabs store converges on what the server holds. A profile
|
|
31
|
+
// container is shared by every tab the customer has open, on any shop, so concurrent use matters
|
|
32
|
+
// more there than in a business container, which only tabs on the same shop share. A sequence of
|
|
33
|
+
// calls is not atomic across tabs:
|
|
34
|
+
// - Deleting a container (deleteBusiness, or an eviction by the store) while another tab writes
|
|
35
|
+
// pages to it can leave its cursors ahead of its entities.
|
|
36
|
+
// - The Business and Profile a Model publishes change only when that Model reads or writes. A
|
|
37
|
+
// cart or a user built from them can be older than what the store holds, and sending it replaces
|
|
38
|
+
// the newer one on the server. changeUser starts from the stored user, and a caller can read the
|
|
39
|
+
// profile again with refreshProfileIfStale.
|
|
28
40
|
class Model {
|
|
29
41
|
constructor() {
|
|
30
42
|
// True when the published business, if any, has been read to the end and found to be the one
|
|
@@ -33,6 +45,13 @@ class Model {
|
|
|
33
45
|
// failure, a rejection) leaves the business published and unvetted. See readJoinBusinessId, which
|
|
34
46
|
// does not join such a business.
|
|
35
47
|
this._isPublishedBusinessVetted = false;
|
|
48
|
+
// When the last read of the profile ended, as Date.now() gave it. Null before the first.
|
|
49
|
+
this._profileReadAt = null;
|
|
50
|
+
// The profile cursors the published profile was read at, from getProfileCursorsKey. Null until a
|
|
51
|
+
// profile is published from the store.
|
|
52
|
+
this._publishedProfileCursors = null;
|
|
53
|
+
// The read that refreshProfileIfStale is running, so an overlapping call can share it
|
|
54
|
+
this._profileRefresh = null;
|
|
36
55
|
this._business = new LiveData(null);
|
|
37
56
|
this._profile = new LiveData(null);
|
|
38
57
|
this._authenticationMethod = null;
|
|
@@ -41,10 +60,13 @@ class Model {
|
|
|
41
60
|
this._businessId = null;
|
|
42
61
|
}
|
|
43
62
|
// Sets the service and store to use. Discards the current instance, so nobody is signed in and no
|
|
44
|
-
// business is browsed.
|
|
45
|
-
|
|
63
|
+
// business is browsed. lock makes changeUser exclusive: pass one that spans every tab that shares
|
|
64
|
+
// the store (see Lock), or leave the default, which excludes calls in this tab only. A call still
|
|
65
|
+
// running on the discarded instance is not excluded against the new one.
|
|
66
|
+
static initialize(service, database, lock = new InMemoryLock()) {
|
|
46
67
|
Model._service = service;
|
|
47
68
|
Model._cache = new SyncCache(database);
|
|
69
|
+
Model._lock = lock;
|
|
48
70
|
Model._instance = null;
|
|
49
71
|
}
|
|
50
72
|
// The current model. Fetch it each time and do not keep it: signOut discards it.
|
|
@@ -63,7 +85,7 @@ class Model {
|
|
|
63
85
|
// Signs in and syncs this subject's profile in, for a subject the server has a profile for (use
|
|
64
86
|
// createUser otherwise). Also reads in the business being browsed, if any, as this customer.
|
|
65
87
|
//
|
|
66
|
-
// Anything thrown after beginSignIn's guard (a bad credential, a rejected
|
|
88
|
+
// Anything thrown after beginSignIn's guard (a bad credential, a rejected request while reading the
|
|
67
89
|
// business) calls signOut. profileSyncIn publishes a cached profile before it reaches the
|
|
68
90
|
// network, so a retry could otherwise find _profile set and fail the "already signed in" guard.
|
|
69
91
|
// Throws NotFoundException if the business being browsed cannot be opened. See businessSyncIn.
|
|
@@ -84,22 +106,114 @@ class Model {
|
|
|
84
106
|
// profile for (use signIn otherwise). Also reads in the business being browsed, if any, as this
|
|
85
107
|
// customer. Throws NotFoundException if that business cannot be opened (see businessSyncIn), after
|
|
86
108
|
// the User exists. The sign-up is not undone: the customer stays signed up and signed in.
|
|
109
|
+
// - The user is created under an id this model makes for the subject, not userToCreate.id. One id
|
|
110
|
+
// is made per subject and saved in the profile container before the call, and every attempt of
|
|
111
|
+
// that subject sends it until the sign up is stored or refused with a ConflictException. It is
|
|
112
|
+
// kept after a network error, a 5xx and any other error (a 400, a 401), and a successful signIn
|
|
113
|
+
// does not delete it. It is deleted once the answer is stored, and by a ConflictException.
|
|
114
|
+
// - A ConflictException means the server could not create the user: the subject has one already,
|
|
115
|
+
// or the id could not be used. It is rethrown, and this method does not sign in: the caller signs
|
|
116
|
+
// in, which reads the profile. If signIn answers UnauthorizedException the subject has no
|
|
117
|
+
// profile, and the caller creates again. The saved id is already deleted, so that sign up makes
|
|
118
|
+
// a new one.
|
|
119
|
+
// - When the business being browsed is published and was read to the end, the call joins it too,
|
|
120
|
+
// so the answer holds the link and the business and no second call is needed. If the server then
|
|
121
|
+
// answers a NotFoundException, the user was created but the join was rejected, and no answer came
|
|
122
|
+
// back: the profile is read without a join, and the business read that follows makes the join
|
|
123
|
+
// again, which throws. A NotFoundException when no join was sent is rethrown at once.
|
|
87
124
|
async createUser(authenticationMethod, subjectId, authenticationToken, userToCreate) {
|
|
88
125
|
this.beginSignIn(authenticationMethod, subjectId, authenticationToken);
|
|
89
126
|
const authTokens = await this.requireProfileAuthTokens();
|
|
90
|
-
|
|
127
|
+
const containerName = this.requireProfileContainerName();
|
|
128
|
+
const cache = Model.getCache();
|
|
129
|
+
let userId = await cache.readUserId(containerName);
|
|
130
|
+
if (userId === null) {
|
|
131
|
+
userId = Ids.getNewId(proto_Constants.USER_ENTITY_TYPE_ID);
|
|
132
|
+
await cache.writeUserId(containerName, userId);
|
|
133
|
+
}
|
|
134
|
+
// A subject with no profile has nothing synced, so every cursor is zero
|
|
135
|
+
const noCursors = new SyncState(SyncKeys.PROFILE_SYNC_IN);
|
|
136
|
+
const joinBusinessId = this.getVettedBusiness()?.id ?? BigInt(proto_Constants.ENTITY_ID_NULL);
|
|
137
|
+
try {
|
|
138
|
+
await this.createProfileOnServer(ProtoMap.buildUserRequest(ProtoMap.buildUser(userToCreate, userId), noCursors, joinBusinessId), authTokens);
|
|
139
|
+
}
|
|
140
|
+
catch (error) {
|
|
141
|
+
if (error instanceof ConflictException) {
|
|
142
|
+
await cache.deleteUserId(containerName);
|
|
143
|
+
throw error;
|
|
144
|
+
}
|
|
145
|
+
const isJoinRefused = error instanceof NotFoundException &&
|
|
146
|
+
joinBusinessId !== BigInt(proto_Constants.ENTITY_ID_NULL);
|
|
147
|
+
if (!isJoinRefused)
|
|
148
|
+
throw error;
|
|
149
|
+
await this.profileSyncIn(ProtoMap.buildProfileSyncOutRequest(noCursors), await this.requireProfileAuthTokens());
|
|
150
|
+
await cache.deleteUserId(containerName);
|
|
151
|
+
}
|
|
91
152
|
if (this._businessId !== null)
|
|
92
153
|
await this.businessSyncIn();
|
|
93
154
|
}
|
|
94
155
|
// Updates this customer's own profile with what userProfile holds. Service.updateUser matches the
|
|
95
156
|
// caller against the User by entity id (see ProtoMap.buildUpdateUser), so userProfile must be this
|
|
96
|
-
// customer's own, read in earlier, not built fresh. Throws if no profile has been read.
|
|
97
|
-
//
|
|
98
|
-
//
|
|
157
|
+
// customer's own, read in earlier, not built fresh. Throws if no profile has been read. The answer
|
|
158
|
+
// is cached and published as a profile read is, so the User as the server stored it replaces the
|
|
159
|
+
// held one (see readProfilePages).
|
|
99
160
|
async updateUser(userProfile) {
|
|
100
161
|
if (this._profile.value === null)
|
|
101
162
|
throw new IllegalOperationException("Nobody is signed in, so this call cannot be made");
|
|
102
|
-
await this.
|
|
163
|
+
await this.sendUserUpdate(this.requireProfileContainerName(), userProfile);
|
|
164
|
+
}
|
|
165
|
+
// Changes this customer's own user. Reads the profile, calls change with the user the profile
|
|
166
|
+
// container stores, and sends the user change returns, as updateUser does. Use it instead of
|
|
167
|
+
// updateUser when the new user is the current one with something altered, for example an address
|
|
168
|
+
// added. The current user includes what other tabs and devices have written, which a user built
|
|
169
|
+
// from the published profile may lack, and sending that user would erase it.
|
|
170
|
+
// Calls for one profile run one at a time. When the Lock given to initialize spans tabs, a second
|
|
171
|
+
// tab that changes the user waits for this change and starts from its result. The server still
|
|
172
|
+
// takes the last write of two devices that change the user at once.
|
|
173
|
+
// change must return a user with the id it is given, and must not call changeUser, which would
|
|
174
|
+
// wait for its own lock. Throws IllegalOperationException if no profile has been read,
|
|
175
|
+
// InvalidArgumentException if change returns another id, and what change throws.
|
|
176
|
+
async changeUser(change) {
|
|
177
|
+
if (this._profile.value === null)
|
|
178
|
+
throw new IllegalOperationException("Nobody is signed in, so this call cannot be made");
|
|
179
|
+
const containerName = this.requireProfileContainerName();
|
|
180
|
+
await Model._lock.runExclusive(containerName, async () => {
|
|
181
|
+
await this.syncProfile(containerName);
|
|
182
|
+
const current = await this.readStoredUser(containerName);
|
|
183
|
+
if (current === null)
|
|
184
|
+
throw new IllegalOperationException("No user has been read, so there is none to change");
|
|
185
|
+
const changed = change(current);
|
|
186
|
+
if (changed.id !== current.id)
|
|
187
|
+
throw new InvalidArgumentException("A change of the user must keep the id of the user");
|
|
188
|
+
await this.sendUserUpdate(containerName, changed);
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
// Reads the profile if the last read of it is older than MAX_PROFILE_STALENESS_MS, and returns
|
|
192
|
+
// whether it did. Every read of the profile counts, a sign in and an update included. Does
|
|
193
|
+
// nothing when nobody is signed in or the first read is still under way. The read fetches only
|
|
194
|
+
// what changed, and calls that overlap share one read, so a UI can call it from every event that
|
|
195
|
+
// shows a tab again. The profile is published if it differs from what was published, including
|
|
196
|
+
// when another tab stored the answer first. Throws what a read throws, and a failed read leaves
|
|
197
|
+
// the profile stale, so the next call reads again. A clock set back counts as stale.
|
|
198
|
+
async refreshProfileIfStale() {
|
|
199
|
+
if (this._profile.value === null)
|
|
200
|
+
return false;
|
|
201
|
+
if (this._profileRefresh !== null)
|
|
202
|
+
return this._profileRefresh;
|
|
203
|
+
const readAt = this._profileReadAt;
|
|
204
|
+
if (readAt === null)
|
|
205
|
+
return false;
|
|
206
|
+
const age = Date.now() - readAt;
|
|
207
|
+
if (age >= 0 && age < MAX_PROFILE_STALENESS_MS)
|
|
208
|
+
return false;
|
|
209
|
+
const refresh = this.refreshProfile();
|
|
210
|
+
this._profileRefresh = refresh;
|
|
211
|
+
try {
|
|
212
|
+
return await refresh;
|
|
213
|
+
}
|
|
214
|
+
finally {
|
|
215
|
+
this._profileRefresh = null;
|
|
216
|
+
}
|
|
103
217
|
}
|
|
104
218
|
// Discards this model. The next getInstance() builds a fresh one, signed out and browsing nothing.
|
|
105
219
|
async signOut() {
|
|
@@ -131,7 +245,7 @@ class Model {
|
|
|
131
245
|
// order placed from it. Both need a customer who is a member of the business (see
|
|
132
246
|
// requireCustomerOfBusiness), except that a signed-out visitor can keep a cart locally (see
|
|
133
247
|
// updateCartLocalOnly).
|
|
134
|
-
// An order reaches the store only after the server accepts it, so a customer never
|
|
248
|
+
// An order reaches the store only after the server accepts it, so a customer never has an order
|
|
135
249
|
// the server has not accepted, and a failed call leaves nothing to reconcile. A cart can be
|
|
136
250
|
// stored before the server has it (see updateCartLocalOnly).
|
|
137
251
|
// Creates or replaces this customer's cart, on the server and in the store. An empty list
|
|
@@ -139,9 +253,11 @@ class Model {
|
|
|
139
253
|
// Signed out, this calls updateCartLocalOnly, as the server has no cart for a visitor. Signed in,
|
|
140
254
|
// it costs a request, so an editor should write each change with updateCartLocalOnly and call this
|
|
141
255
|
// on a schedule (debounced, on blur, on a timer).
|
|
142
|
-
// A call that matches the stored copy returns without a request (hasShoppingListChanged)
|
|
143
|
-
//
|
|
144
|
-
//
|
|
256
|
+
// A call that matches the stored copy returns without a request (hasShoppingListChanged), and so
|
|
257
|
+
// reads nothing. A call that sends gets back the customer's messages that are newer than the last
|
|
258
|
+
// read, the cart among them: a change that the business made to an order comes with it. It brings
|
|
259
|
+
// nothing of the rest of the business, so it does not replace a read of it.
|
|
260
|
+
// The cart is sent as a whole and replaces the stored cart if it is dated later.
|
|
145
261
|
async updateCart(shoppingList) {
|
|
146
262
|
if (this._profile.value === null)
|
|
147
263
|
return this.updateCartLocalOnly(shoppingList);
|
|
@@ -150,7 +266,7 @@ class Model {
|
|
|
150
266
|
if (!ProtoMap.hasShoppingListChanged(stored, shoppingList))
|
|
151
267
|
return;
|
|
152
268
|
const partyPreferencesMessage = ProtoMap.buildPartyPreferencesMessage(Ids.getPartyPreferencesMessageId(target.businessLink.id), stored, target.senderId, shoppingList, null, BigInt(Date.now()));
|
|
153
|
-
await this.
|
|
269
|
+
await this.sendBusinessMessages(target, [partyPreferencesMessage]);
|
|
154
270
|
}
|
|
155
271
|
// The same, but stored locally and not sent. Call it on every edit (a request per tap of a
|
|
156
272
|
// quantity stepper is too many), and call updateCart on a schedule to send.
|
|
@@ -161,12 +277,17 @@ class Model {
|
|
|
161
277
|
// the update number of the copy it amends, buildPartyPreferencesMessage dates it past that copy,
|
|
162
278
|
// and the later copy wins.
|
|
163
279
|
async updateCartLocalOnly(shoppingList) {
|
|
164
|
-
// A signed-out visitor has no membership to derive a message id from, so a fresh id is drawn
|
|
165
|
-
//
|
|
166
|
-
//
|
|
167
|
-
//
|
|
168
|
-
if (this._profile.value === null)
|
|
169
|
-
|
|
280
|
+
// A signed-out visitor has no membership to derive a message id from, so a fresh id is drawn for
|
|
281
|
+
// the first edit and the stored cart keeps it, so later edits amend one entity. With no party, the
|
|
282
|
+
// sender is ENTITY_ID_NULL. A visitor's cart is never sent as it is: signing in sends a new cart
|
|
283
|
+
// under the id of the membership (see businessSyncIn).
|
|
284
|
+
if (this._profile.value === null) {
|
|
285
|
+
const containerName = ContainerNames.forBusiness(this.requireBusinessBeingBrowsed());
|
|
286
|
+
const stored = await Model.getCache().readPartyPreferences(containerName);
|
|
287
|
+
return this.storeCartInCache(containerName, stored === null
|
|
288
|
+
? Ids.getNewId(proto_Constants.MESSAGE_ENTITY_TYPE_ID)
|
|
289
|
+
: ProtoMap.getMessageId(stored), BigInt(proto_Constants.ENTITY_ID_NULL), shoppingList);
|
|
290
|
+
}
|
|
170
291
|
const target = this.requireCustomerOfBusiness();
|
|
171
292
|
return this.storeCartInCache(target.containerName, Ids.getPartyPreferencesMessageId(target.businessLink.id), target.senderId, shoppingList);
|
|
172
293
|
}
|
|
@@ -176,10 +297,14 @@ class Model {
|
|
|
176
297
|
// The emptied cart records this fulfillmentMode, which Business.defaultFulfillmentMode returns.
|
|
177
298
|
// The caller creates orderId with Ids.getNewId(Constants.MESSAGE_ENTITY_TYPE_ID), so it can find
|
|
178
299
|
// the placed order, and can retry a call that a NetworkConnectionException left unresolved using
|
|
179
|
-
// the same id, without placing a second order
|
|
180
|
-
//
|
|
300
|
+
// the same id, without placing a second order: the server stores an order only if no message has
|
|
301
|
+
// its id, so a retry changes nothing, even after the business has started the order. The cart has
|
|
302
|
+
// one id for the same reason (see getPartyPreferencesMessageId).
|
|
181
303
|
// A delivery must name an address: an id from this customer's list, or a full address sent
|
|
182
|
-
// directly (see requireDeliveryAddress).
|
|
304
|
+
// directly (see requireDeliveryAddress). Either way the address goes to the business with the
|
|
305
|
+
// order, in the party of the customer, so a full address has an id that is not zero.
|
|
306
|
+
// The party of the customer goes with every order: their name and phone number, and the delivery
|
|
307
|
+
// address of an order that is delivered. It is not sent with a cart.
|
|
183
308
|
async createOrder(orderId, fulfillmentMode, deliveryAddressId, deliveryAddress, note) {
|
|
184
309
|
const target = this.requireCustomerOfBusiness();
|
|
185
310
|
// Read from the store rather than the published Business, so the order matches the stored cart
|
|
@@ -190,16 +315,25 @@ class Model {
|
|
|
190
315
|
: ProtoMap.buildShoppingList(partyPreferencesMessage, target.countryCode);
|
|
191
316
|
if (shoppingList.existingItems.length === 0)
|
|
192
317
|
throw new IllegalOperationException("The cart holds nothing, so there is no order to place");
|
|
193
|
-
Model.requireDeliveryAddress(target.addresses, fulfillmentMode, deliveryAddressId, deliveryAddress);
|
|
318
|
+
Model.requireDeliveryAddress(target.user.addresses, fulfillmentMode, deliveryAddressId, deliveryAddress);
|
|
319
|
+
// A delivery is where the party of the customer gets an address, as in the merchant app, which adds
|
|
320
|
+
// the delivery address of an order to the party and none for a pick-up. It is the address of the
|
|
321
|
+
// order: the one sent whole, else the one the id names in the customer's list.
|
|
322
|
+
const partyAddress = fulfillmentMode === proto_FulfillmentMode.DELIVERY
|
|
323
|
+
? (deliveryAddress ?? target.user.addresses.getAddress(deliveryAddressId))
|
|
324
|
+
: null;
|
|
325
|
+
if (partyAddress !== null && partyAddress.id === BigInt(proto_Constants.ENTITY_ID_NULL))
|
|
326
|
+
throw new InvalidArgumentException("An address to deliver to has an id of its own, which is how the party of the customer keeps it");
|
|
194
327
|
// One timestamp for both messages: the time the order was placed
|
|
195
328
|
const now = BigInt(Date.now());
|
|
196
|
-
// One request for the order
|
|
197
|
-
// cart still full if the second failed
|
|
198
|
-
|
|
329
|
+
// One request for the order, the emptied cart and the party: two calls could leave an order placed
|
|
330
|
+
// and the cart still full if the second failed. The party goes with every order, one sent again
|
|
331
|
+
// included, and with nothing else: the server refuses a party that has no order with it.
|
|
332
|
+
await this.sendBusinessMessages(target, [
|
|
199
333
|
ProtoMap.buildOrderMessage(orderId, target.senderId, shoppingList, fulfillmentMode, deliveryAddressId, deliveryAddress, note, now),
|
|
200
334
|
// The customer's latest choice of fulfillmentMode, for defaultFulfillmentMode to offer next time
|
|
201
335
|
ProtoMap.buildPartyPreferencesMessage(Ids.getPartyPreferencesMessageId(target.businessLink.id), partyPreferencesMessage, target.senderId, null, fulfillmentMode, now),
|
|
202
|
-
]);
|
|
336
|
+
], ProtoMap.buildParty(target.user, partyAddress));
|
|
203
337
|
}
|
|
204
338
|
// #endregion
|
|
205
339
|
// Throws if a profile is already held, otherwise stores the credentials every later call signs
|
|
@@ -211,32 +345,40 @@ class Model {
|
|
|
211
345
|
this._subjectId = subjectId;
|
|
212
346
|
this._authenticationToken = authenticationToken;
|
|
213
347
|
}
|
|
214
|
-
// #region
|
|
215
|
-
//
|
|
216
|
-
// reads have reached, then stores the response.
|
|
217
|
-
//
|
|
218
|
-
//
|
|
219
|
-
//
|
|
220
|
-
//
|
|
221
|
-
|
|
348
|
+
// #region Sending
|
|
349
|
+
// Sends the messages, and the party of the customer if there is one, in one request, together with
|
|
350
|
+
// the update number this business container's reads have reached, then stores the response.
|
|
351
|
+
// The server answers with a sync out of the customer's messages, made after it writes: the stored
|
|
352
|
+
// messages that are newer than that update number, in the order of their update numbers, with the
|
|
353
|
+
// ones that were sent among them, and not in the order they were sent in. It is stored as a page of
|
|
354
|
+
// a read is, and moves the message cursor the same way. A message that was not stored (an order
|
|
355
|
+
// whose id exists, a cart dated no later than the stored one) is in it only if the stored message
|
|
356
|
+
// is newer than the cursor, and then it is the stored message. The cache has every message at or
|
|
357
|
+
// below the cursor, so what a caller needs afterwards is read from the cache by id.
|
|
358
|
+
// A full page may have more behind it, which is read as a read reads. A rejected request throws
|
|
359
|
+
// from the Service call itself, as for any bad status, so reaching the write below means the
|
|
360
|
+
// server accepted it.
|
|
361
|
+
async sendBusinessMessages(target, messages, party = null) {
|
|
222
362
|
const cache = Model.getCache();
|
|
223
363
|
const messageUpdateNumber = await cache.readMessageUpdateNumber(target.containerName);
|
|
224
364
|
const authTokens = await this.requireBusinessAuthTokens(target.containerName, target.businessLink);
|
|
225
|
-
const
|
|
365
|
+
const request = ProtoMap.buildBusinessSyncInRequest(messageUpdateNumber, messages, party);
|
|
366
|
+
const response = await Model.getService().businessSyncIn(request, ProtoMap.buildAuthTokens(authTokens));
|
|
226
367
|
await this.storeIssuedTokens(ProtoMap.getResponseAuthTokens(response), target.containerName);
|
|
227
|
-
|
|
228
|
-
//
|
|
229
|
-
|
|
368
|
+
let changeCount = await cache.writeMessages(target.containerName, ProtoMap.getSyncInResponseMessages(response));
|
|
369
|
+
// Read on from the cursor the page moved, with the tokens the response may have renewed
|
|
370
|
+
if (ProtoMap.isSyncInResponseFull(request, response))
|
|
371
|
+
changeCount += await this.readBusinessPages(target.containerName, target.businessLink.businessId, await cache.readBusinessCursors(target.containerName), ProtoMap.getBusinessResponseAttachmentBaseUrlExpiry(await cache.readBusiness(target.containerName)), ProtoMap.buildAuthTokens(await this.requireBusinessAuthTokens(target.containerName, target.businessLink)));
|
|
230
372
|
// What the store took decides whether anything is published - see the Publishing region
|
|
231
373
|
if (changeCount !== 0)
|
|
232
374
|
this.publishBusiness(await cache.readBusiness(target.containerName));
|
|
233
375
|
}
|
|
234
|
-
// Amends the cart containerName
|
|
376
|
+
// Amends the cart containerName stores and writes the result to the cache. Nothing is sent.
|
|
235
377
|
// The two callers differ in container, id and sender: see updateCartLocalOnly.
|
|
236
378
|
async storeCartInCache(containerName, messageId, senderId, shoppingList) {
|
|
237
379
|
const cache = Model.getCache();
|
|
238
380
|
const changeCount = await cache.writePartyPreferences(containerName, ProtoMap.buildPartyPreferencesMessage(messageId, await cache.readPartyPreferences(containerName), senderId, shoppingList, null, BigInt(Date.now())));
|
|
239
|
-
// Published on what the store took, as for every read and
|
|
381
|
+
// Published on what the store took, as for every read and sync in
|
|
240
382
|
if (changeCount !== 0)
|
|
241
383
|
this.publishBusiness(await cache.readBusiness(containerName));
|
|
242
384
|
}
|
|
@@ -268,7 +410,7 @@ class Model {
|
|
|
268
410
|
businessLink: businessLink,
|
|
269
411
|
containerName: Model.getBusinessContainerName(businessLink),
|
|
270
412
|
senderId: businessLink.partyId,
|
|
271
|
-
|
|
413
|
+
user: user,
|
|
272
414
|
countryCode: business.countryCode,
|
|
273
415
|
};
|
|
274
416
|
}
|
|
@@ -292,7 +434,8 @@ class Model {
|
|
|
292
434
|
// #region The four calls of Service, each as this model makes it
|
|
293
435
|
//
|
|
294
436
|
// 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
|
|
437
|
+
// issued, and publishes what the cache then holds. A read is paged, and the answer to a sign up or
|
|
438
|
+
// an update is the first page of a profile read.
|
|
296
439
|
// Reads in the business being browsed: an anonymous visitor under its businessId, a signed-in
|
|
297
440
|
// customer under their own container (see getBusinessContainerName), joining as a customer first
|
|
298
441
|
// if not linked (see requireBusinessLink).
|
|
@@ -515,9 +658,26 @@ class Model {
|
|
|
515
658
|
if (link !== null)
|
|
516
659
|
return link;
|
|
517
660
|
const joinBusinessId = await this.readJoinBusinessId(businessId);
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
661
|
+
const containerName = this.requireProfileContainerName();
|
|
662
|
+
const cache = Model.getCache();
|
|
663
|
+
try {
|
|
664
|
+
await this.profileSyncIn(ProtoMap.buildProfileSyncOutRequest(await cache.readProfileCursors(containerName), joinBusinessId), await this.requireProfileAuthTokens());
|
|
665
|
+
}
|
|
666
|
+
catch (error) {
|
|
667
|
+
if (!(error instanceof JoinNotAnsweredException))
|
|
668
|
+
throw error;
|
|
669
|
+
// The answer lacks the link and the business because the cursors are past them. Either
|
|
670
|
+
// another tab stored them first, and profileSyncIn has published them from the store, or the
|
|
671
|
+
// container lost them (it was cleared while a read was under way). In the second case the
|
|
672
|
+
// cursors are deleted and the profile is read again from the start, once.
|
|
673
|
+
const published = this._profile.value;
|
|
674
|
+
if (published === null || Model.findBusinessLink(published, businessId) === null) {
|
|
675
|
+
await cache.resetProfileCursors(containerName);
|
|
676
|
+
await this.profileSyncIn(ProtoMap.buildProfileSyncOutRequest(await cache.readProfileCursors(containerName), joinBusinessId), await this.requireProfileAuthTokens());
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
// A join that still leaves no link after the profile was read again from the start is the
|
|
680
|
+
// server contradicting itself, and retrying would not fix it.
|
|
521
681
|
const joined = this._profile.value;
|
|
522
682
|
const joinedLink = joined === null ? null : Model.findBusinessLink(joined, businessId);
|
|
523
683
|
if (joinedLink === null)
|
|
@@ -529,8 +689,8 @@ class Model {
|
|
|
529
689
|
// A business published from the cache by a read that has not ended is not known to be open or to
|
|
530
690
|
// be the one the businessId names, so a customer is not joined to it.
|
|
531
691
|
async readJoinBusinessId(businessId) {
|
|
532
|
-
const alreadyHeld = this.
|
|
533
|
-
if (alreadyHeld !== null
|
|
692
|
+
const alreadyHeld = this.getVettedBusiness();
|
|
693
|
+
if (alreadyHeld !== null)
|
|
534
694
|
return alreadyHeld.id;
|
|
535
695
|
await this.syncAnonymousBusiness(businessId);
|
|
536
696
|
// A missing business is a NotFoundException from the read above. Reaching here with none
|
|
@@ -540,6 +700,12 @@ class Model {
|
|
|
540
700
|
throw new InternalServerException(`The read of business ${businessId} answered with no Business`);
|
|
541
701
|
return business.id;
|
|
542
702
|
}
|
|
703
|
+
// The business being browsed, if it is published and has been read to the end and found open. A
|
|
704
|
+
// business published from the cache by a read that has not ended is not known to be either, so
|
|
705
|
+
// this is null for it.
|
|
706
|
+
getVettedBusiness() {
|
|
707
|
+
return this._isPublishedBusinessVetted ? this._business.value : null;
|
|
708
|
+
}
|
|
543
709
|
static findBusinessEntity(profile, businessId) {
|
|
544
710
|
const wanted = canonicalizeBusinessId(businessId);
|
|
545
711
|
for (const business of profile.businesses)
|
|
@@ -600,9 +766,47 @@ class Model {
|
|
|
600
766
|
async profileSyncIn(firstRequest, firstAuthTokens) {
|
|
601
767
|
const containerName = this.getProfileContainerName();
|
|
602
768
|
if (containerName === null)
|
|
769
|
+
return;
|
|
770
|
+
await this.publishProfileFromStore(containerName);
|
|
771
|
+
await this.readProfilePages(containerName, firstRequest, firstAuthTokens, () => Model.getService().profileSyncOut(firstRequest, firstAuthTokens));
|
|
772
|
+
}
|
|
773
|
+
// Reads what changed in the profile since the cursors the container holds, as a sign in does, but
|
|
774
|
+
// without publishing the stored profile first.
|
|
775
|
+
async syncProfile(containerName) {
|
|
776
|
+
const request = ProtoMap.buildProfileSyncOutRequest(await Model.getCache().readProfileCursors(containerName));
|
|
777
|
+
const authTokens = await this.requireProfileAuthTokens();
|
|
778
|
+
await this.readProfilePages(containerName, request, authTokens, () => Model.getService().profileSyncOut(request, authTokens));
|
|
779
|
+
}
|
|
780
|
+
// The user the profile container stores, or null when it holds none
|
|
781
|
+
async readStoredUser(containerName) {
|
|
782
|
+
const stored = await Model.getCache().readProfile(containerName);
|
|
783
|
+
if (stored === null || this._subjectId === null)
|
|
603
784
|
return null;
|
|
604
|
-
|
|
605
|
-
|
|
785
|
+
return ProtoMap.buildProfile(this._subjectId, stored)?.user ?? null;
|
|
786
|
+
}
|
|
787
|
+
// Sends user over the one the server has, and stores and publishes the answer, which is the first
|
|
788
|
+
// page of a profile read.
|
|
789
|
+
async sendUserUpdate(containerName, user) {
|
|
790
|
+
const request = ProtoMap.buildUserRequest(ProtoMap.buildUpdateUser(user), await Model.getCache().readProfileCursors(containerName));
|
|
791
|
+
const authTokens = await this.requireProfileAuthTokens();
|
|
792
|
+
await this.readProfilePages(containerName, ProtoMap.getUserRequestSyncOutRequest(request), authTokens, () => Model.getService().updateUser(request, authTokens));
|
|
793
|
+
}
|
|
794
|
+
// The sign-up call. Its answer is the first page of a profile read, which readProfilePages
|
|
795
|
+
// continues if the page is full. The id saved for the sign up is deleted once that page is stored.
|
|
796
|
+
async createProfileOnServer(request, authTokens) {
|
|
797
|
+
const containerName = this.requireProfileContainerName();
|
|
798
|
+
await this.readProfilePages(containerName, ProtoMap.getUserRequestSyncOutRequest(request), authTokens, () => Model.getService().createUser(request, authTokens), () => Model.getCache().deleteUserId(containerName));
|
|
799
|
+
}
|
|
800
|
+
// Caches and publishes a profile read of the pages that firstRequest starts. sendFirstPage makes
|
|
801
|
+
// the first call: profileSyncOut for a read, createUser or updateUser for a write, which answers
|
|
802
|
+
// with the first page of the read that its profileSyncOutRequest describes. Later pages are read
|
|
803
|
+
// with profileSyncOut, because a write cannot be sent again.
|
|
804
|
+
// Each page is cached, moving the cursors, and the tokens it reissues are stored. afterFirstPage
|
|
805
|
+
// runs once the first page and its tokens are stored. The profile is published once, and only if
|
|
806
|
+
// the store took something. The join is checked last, so everything that arrived is cached and
|
|
807
|
+
// published before it can throw: the pages are valid even when the join is not.
|
|
808
|
+
async readProfilePages(containerName, firstRequest, firstAuthTokens, sendFirstPage, afterFirstPage = null) {
|
|
809
|
+
const cache = Model.getCache();
|
|
606
810
|
let request = firstRequest;
|
|
607
811
|
let authTokens = firstAuthTokens;
|
|
608
812
|
let changeCount = 0;
|
|
@@ -611,9 +815,14 @@ class Model {
|
|
|
611
815
|
const joinBusinessId = firstRequest.joinBusinessId;
|
|
612
816
|
let hasJoinedLink = false;
|
|
613
817
|
let hasJoinedBusiness = false;
|
|
818
|
+
let page = await sendFirstPage();
|
|
614
819
|
for (;;) {
|
|
615
|
-
|
|
616
|
-
|
|
820
|
+
changeCount += await cache.writeProfile(containerName, page);
|
|
821
|
+
await this.storeIssuedTokens(ProtoMap.getResponseAuthTokens(page), containerName);
|
|
822
|
+
if (afterFirstPage !== null) {
|
|
823
|
+
await afterFirstPage();
|
|
824
|
+
afterFirstPage = null;
|
|
825
|
+
}
|
|
617
826
|
if (ProtoMap.hasJoinedBusinessLink(joinBusinessId, page))
|
|
618
827
|
hasJoinedLink = true;
|
|
619
828
|
if (ProtoMap.hasJoinedBusiness(joinBusinessId, page))
|
|
@@ -625,36 +834,20 @@ class Model {
|
|
|
625
834
|
// Each call after the first is signed with what the previous page reissued, since a token can
|
|
626
835
|
// expire part way through a long read
|
|
627
836
|
authTokens = ProtoMap.refreshAuthTokens(authTokens, ProtoMap.getResponseAuthTokens(page));
|
|
837
|
+
page = await Model.getService().profileSyncOut(request, authTokens);
|
|
628
838
|
}
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
}
|
|
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.
|
|
839
|
+
this._profileReadAt = Date.now();
|
|
840
|
+
if (changeCount !== 0)
|
|
841
|
+
await this.publishProfileFromStore(containerName);
|
|
842
|
+
// After a sign up the User exists whatever became of the join, so what came back is kept and
|
|
843
|
+
// published before this throws
|
|
637
844
|
Model.requireJoinedBusiness(joinBusinessId, hasJoinedLink, hasJoinedBusiness);
|
|
638
|
-
return response;
|
|
639
|
-
}
|
|
640
|
-
// The sign-up call. Not paged: resending it would create a second user, and a new caller has at
|
|
641
|
-
// most their User, one BusinessLink and the Business it names. It can join a business in the same
|
|
642
|
-
// call, with the same join requirements as a profile read, in one response.
|
|
643
|
-
async createProfileOnServer(request, authTokens) {
|
|
644
|
-
const response = await this.writeProfileResponse(await Model.getService().createUser(request, authTokens));
|
|
645
|
-
const joinBusinessId = request.joinBusinessId;
|
|
646
|
-
// Raised after the response is cached and published, as on a profile read. It matters more
|
|
647
|
-
// here: the User exists regardless of the join, so discarding the response and signing up
|
|
648
|
-
// again would create a second one
|
|
649
|
-
Model.requireJoinedBusiness(joinBusinessId, ProtoMap.hasJoinedBusinessLink(joinBusinessId, response), ProtoMap.hasJoinedBusiness(joinBusinessId, response));
|
|
650
|
-
return response;
|
|
651
845
|
}
|
|
652
846
|
// #endregion
|
|
653
847
|
// #region Driving one call
|
|
654
848
|
// Throws when a requested join is not answered with both the BusinessLink (the membership, whose
|
|
655
849
|
// token signs calls) and the Business it names (whose businessId everything else is built from).
|
|
656
|
-
// Without both, the caller is a customer of something it cannot reach.
|
|
657
|
-
// that can carry a joinBusinessId.
|
|
850
|
+
// Without both, the caller is a customer of something it cannot reach.
|
|
658
851
|
//
|
|
659
852
|
// Throws a ServiceException, since the fault is the server's and not the caller's: the server
|
|
660
853
|
// answered 200 and left out what was asked for. The 500 status means a retry is worthwhile.
|
|
@@ -671,35 +864,7 @@ class Model {
|
|
|
671
864
|
: hasBusinessLink
|
|
672
865
|
? "Business"
|
|
673
866
|
: "BusinessLink";
|
|
674
|
-
throw new
|
|
675
|
-
}
|
|
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.
|
|
681
|
-
// Only a page of a read moves the container's cursors. A sign up and an update are changes this
|
|
682
|
-
// client made and the next read brings them back, so they are stored without moving any.
|
|
683
|
-
async cacheProfileResponse(containerName, response, isPageOfRead) {
|
|
684
|
-
const cache = Model.getCache();
|
|
685
|
-
const changeCount = isPageOfRead
|
|
686
|
-
? await cache.writeProfile(containerName, response)
|
|
687
|
-
: await cache.storeProfile(containerName, response);
|
|
688
|
-
await this.storeIssuedTokens(ProtoMap.getResponseAuthTokens(response), containerName);
|
|
689
|
-
return changeCount;
|
|
690
|
-
}
|
|
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.
|
|
696
|
-
async writeProfileResponse(response) {
|
|
697
|
-
const containerName = this.getProfileContainerName();
|
|
698
|
-
if (containerName === null)
|
|
699
|
-
return response;
|
|
700
|
-
if ((await this.cacheProfileResponse(containerName, response, false)) !== 0)
|
|
701
|
-
this.publishProfile(await Model.getCache().readProfile(containerName));
|
|
702
|
-
return response;
|
|
867
|
+
throw new JoinNotAnsweredException(`A join of business ${joinBusinessId} was answered with no ${missing}`);
|
|
703
868
|
}
|
|
704
869
|
// Stores the tokens a call received in the container the call was about: a profile call's tokens
|
|
705
870
|
// in the subject's profile container, a business call's in that business's container. The caller
|
|
@@ -765,12 +930,16 @@ class Model {
|
|
|
765
930
|
// over every page), whatever the response carried. The store keeps a blob only when its eTag is
|
|
766
931
|
// later, so it can reject a response in full (a cursor that moved without its eTag resends an
|
|
767
932
|
// entity already cached). Publishing then would notify every observer of an identical value.
|
|
768
|
-
//
|
|
933
|
+
// A sign up and an update follow this too, because their answer is the first page of a profile read
|
|
934
|
+
// (see readProfilePages).
|
|
769
935
|
//
|
|
770
936
|
// A business with online shopping off is not published, even from the cache, or a customer could
|
|
771
937
|
// be joined to it. A read that finds the published business is no longer open, or that the server
|
|
772
938
|
// rejects, publishes null in its place. A business published from the cache counts as open only
|
|
773
939
|
// once the read has ended (see _isPublishedBusinessVetted, which readJoinBusinessId checks).
|
|
940
|
+
static getProfileCursorsKey(cursors) {
|
|
941
|
+
return SyncKeys.PROFILE_SYNC_IN.map((key) => cursors.getUpdateNumber(key)).join(",");
|
|
942
|
+
}
|
|
774
943
|
publishBusiness(response) {
|
|
775
944
|
this.publish(Model.buildPublishable(response));
|
|
776
945
|
}
|
|
@@ -789,6 +958,23 @@ class Model {
|
|
|
789
958
|
? null
|
|
790
959
|
: ProtoMap.buildProfile(this._subjectId, response);
|
|
791
960
|
}
|
|
961
|
+
// Publishes what the profile container stores, and notes the cursors it was read at, so that
|
|
962
|
+
// refreshProfileIfStale can tell whether the store has moved since.
|
|
963
|
+
async publishProfileFromStore(containerName) {
|
|
964
|
+
const cache = Model.getCache();
|
|
965
|
+
const cursorsKey = Model.getProfileCursorsKey(await cache.readProfileCursors(containerName));
|
|
966
|
+
this.publishProfile(await cache.readProfile(containerName));
|
|
967
|
+
this._publishedProfileCursors = cursorsKey;
|
|
968
|
+
}
|
|
969
|
+
// The read behind refreshProfileIfStale
|
|
970
|
+
async refreshProfile() {
|
|
971
|
+
const containerName = this.requireProfileContainerName();
|
|
972
|
+
await this.syncProfile(containerName);
|
|
973
|
+
const cursors = await Model.getCache().readProfileCursors(containerName);
|
|
974
|
+
if (Model.getProfileCursorsKey(cursors) !== this._publishedProfileCursors)
|
|
975
|
+
await this.publishProfileFromStore(containerName);
|
|
976
|
+
return true;
|
|
977
|
+
}
|
|
792
978
|
// #endregion
|
|
793
979
|
static getService() {
|
|
794
980
|
const service = Model._service;
|
|
@@ -805,5 +991,10 @@ class Model {
|
|
|
805
991
|
}
|
|
806
992
|
Model._service = null;
|
|
807
993
|
Model._cache = null;
|
|
994
|
+
Model._lock = new InMemoryLock();
|
|
808
995
|
Model._instance = null;
|
|
809
996
|
export default Model;
|
|
997
|
+
// A join the server answered with no link or business. It is a 500 like any other fault of the
|
|
998
|
+
// server. requireBusinessLink catches it to read the profile again from the start.
|
|
999
|
+
class JoinNotAnsweredException extends InternalServerException {
|
|
1000
|
+
}
|