@peabody-soft/mbs-model-ts 1.3.0 → 1.5.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/CartEditor.d.ts +10 -5
- package/dist/CartEditor.js +232 -125
- package/dist/Catalog.js +26 -18
- package/dist/Enums.d.ts +1 -1
- package/dist/Enums.js +2 -1
- package/dist/GeneralSettings.d.ts +4 -2
- package/dist/GeneralSettings.js +18 -7
- package/dist/Ids.d.ts +2 -0
- package/dist/Ids.js +10 -0
- package/dist/Message.js +3 -2
- package/dist/Model.d.ts +5 -0
- package/dist/Model.js +263 -50
- package/dist/Package.js +2 -2
- package/dist/Price.js +6 -2
- package/dist/Product.d.ts +5 -3
- package/dist/Product.js +30 -18
- package/dist/Profile.d.ts +1 -0
- package/dist/Profile.js +91 -4
- package/dist/ProtoMap.d.ts +15 -8
- package/dist/ProtoMap.js +267 -140
- package/dist/ShoppingList.d.ts +30 -1
- package/dist/ShoppingList.js +345 -35
- package/dist/SyncCache.d.ts +9 -0
- package/dist/SyncCache.js +93 -30
- package/dist/SyncState.js +1 -1
- package/dist/Tax.d.ts +12 -4
- package/dist/Tax.js +33 -4
- package/dist/TaxHelper.d.ts +12 -0
- package/dist/TaxHelper.js +93 -0
- package/dist/TaxType.d.ts +7 -0
- package/dist/TaxType.js +32 -0
- package/dist/WorkingDays.d.ts +3 -2
- package/dist/WorkingDays.js +16 -12
- package/dist/generated/AttachmentType.d.ts +19 -1
- package/dist/generated/AttachmentType.js +18 -0
- package/dist/generated/AuthTokens.d.ts +3 -1
- package/dist/generated/BusinessLink.d.ts +4 -1
- package/dist/generated/BusinessSyncInRequest.d.ts +11 -3
- package/dist/generated/BusinessSyncInResponse.d.ts +5 -4
- package/dist/generated/BusinessSyncOutRequest.d.ts +37 -10
- package/dist/generated/BusinessSyncOutRequest.js +42 -29
- package/dist/generated/BusinessSyncOutResponse.d.ts +14 -1
- package/dist/generated/Configuration.d.ts +2 -1
- package/dist/generated/CreateUserRequest.d.ts +8 -2
- package/dist/generated/Entity.d.ts +7 -3
- package/dist/generated/GeneralSettings.d.ts +14 -4
- package/dist/generated/GeneralSettings.js +9 -1
- package/dist/generated/Message.d.ts +4 -2
- package/dist/generated/Product.d.ts +10 -1
- package/dist/generated/Product.js +9 -1
- package/dist/generated/ProfileSyncOutRequest.d.ts +17 -1
- package/dist/generated/ProfileSyncOutResponse.d.ts +8 -3
- package/dist/generated/SalesOrderPayload.d.ts +7 -2
- package/dist/generated/ShoppingList.d.ts +6 -3
- package/dist/generated/ShoppingListItem.d.ts +21 -6
- package/dist/generated/Tax.d.ts +4 -4
- package/dist/generated/Tax.js +7 -8
- package/dist/generated/WorkingDays.d.ts +9 -1
- package/dist/generated/WorkingDays.js +12 -4
- package/dist/generated/WorkingHoursRange.d.ts +2 -2
- package/dist/index.d.ts +2 -2
- package/dist/index.js +4 -3
- package/package.json +55 -55
- package/dist/generated/TaxType.d.ts +0 -25
- package/dist/generated/TaxType.js +0 -30
package/dist/Model.js
CHANGED
|
@@ -6,14 +6,31 @@ import * as ContainerNames from "./ContainerNames";
|
|
|
6
6
|
import { IllegalOperationException, InvalidArgumentException } from "./Exceptions";
|
|
7
7
|
import Ids from "./Ids";
|
|
8
8
|
import LiveData from "./LiveData";
|
|
9
|
+
import { canonicalizeBusinessId } from "./Profile";
|
|
9
10
|
import * as ProtoMap from "./ProtoMap";
|
|
10
|
-
import { InternalServerException } from "./ServiceExceptions";
|
|
11
|
+
import { InternalServerException, NotFoundException } from "./ServiceExceptions";
|
|
11
12
|
import ShoppingList from "./ShoppingList";
|
|
12
13
|
import SyncCache from "./SyncCache";
|
|
14
|
+
import * as SyncKeys from "./SyncKeys";
|
|
15
|
+
import SyncState from "./SyncState";
|
|
13
16
|
// The root of the object model, and the only thing a UI has to be given. Call initialize once,
|
|
14
17
|
// then always fetch the current instance via getInstance.
|
|
18
|
+
//
|
|
19
|
+
// THERE IS NO LOCKING, and nothing here makes one call wait for another: a caller makes one call at a time, and awaits it. A
|
|
20
|
+
// setBusinessId that is still reading when signIn reads the business too can publish the business of the anonymous container
|
|
21
|
+
// over the one the customer's link names, with no cart and no orders, and nothing republishes them; two reads at once
|
|
22
|
+
// interleave their pages and their writes. And a store has one tab using it at a time: two tabs on one store each hold a Model
|
|
23
|
+
// of their own, and one tab's deleteBusiness and read again can interleave with the pages another is writing.
|
|
24
|
+
// A queue inside Model would settle the first and not the second, and would not be a small one: signIn calls signOut and
|
|
25
|
+
// businessSyncIn calls updateCart, so each public method would need a twin that does not queue, for these to call. A caller
|
|
26
|
+
// that calls from several places - a UI - queues its calls itself.
|
|
15
27
|
class Model {
|
|
16
28
|
constructor() {
|
|
29
|
+
// Whether the business that is published, if one is, is one that a read has been through to the end and found to be the
|
|
30
|
+
// one that _businessId names and to be open. The cache-first publish of a read is of what an earlier read left, and is
|
|
31
|
+
// neither of those until the read ends - a read that does not end (the network failing, or a refusal) leaves it published
|
|
32
|
+
// and unvetted. See readJoinBusinessId, which does not take such a business to join.
|
|
33
|
+
this._isPublishedBusinessVetted = false;
|
|
17
34
|
this._business = new LiveData(null);
|
|
18
35
|
this._profile = new LiveData(null);
|
|
19
36
|
this._authenticationMethod = null;
|
|
@@ -50,11 +67,12 @@ class Model {
|
|
|
50
67
|
// publishes a cached profile before it ever reaches the network, so without this, a retry after
|
|
51
68
|
// such a failure could find _profile already set and trip that same guard despite never having
|
|
52
69
|
// actually signed in.
|
|
70
|
+
// Throws NotFoundException if the business being browsed cannot be opened - see businessSyncIn.
|
|
53
71
|
async signIn(authenticationMethod, subjectId, authenticationToken) {
|
|
54
72
|
this.beginSignIn(authenticationMethod, subjectId, authenticationToken);
|
|
55
73
|
try {
|
|
56
74
|
const authTokens = await this.requireProfileAuthTokens();
|
|
57
|
-
await this.profileSyncIn(ProtoMap.
|
|
75
|
+
await this.profileSyncIn(ProtoMap.buildProfileSyncOutRequest(await Model.getCache().readProfileCursors(this.requireProfileContainerName())), authTokens);
|
|
58
76
|
if (this._businessId !== null)
|
|
59
77
|
await this.businessSyncIn();
|
|
60
78
|
}
|
|
@@ -66,6 +84,9 @@ class Model {
|
|
|
66
84
|
// Signs up with the given credentials, creating userToCreate as this subject's profile - for a
|
|
67
85
|
// subject the server has no profile for yet; signIn is what an already known subject uses instead.
|
|
68
86
|
// Also reads in the business being browsed, if any, now signed in as this customer.
|
|
87
|
+
// Throws NotFoundException if the business being browsed cannot be opened - see businessSyncIn. That is
|
|
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.
|
|
69
90
|
async createUser(authenticationMethod, subjectId, authenticationToken, userToCreate) {
|
|
70
91
|
this.beginSignIn(authenticationMethod, subjectId, authenticationToken);
|
|
71
92
|
const authTokens = await this.requireProfileAuthTokens();
|
|
@@ -94,9 +115,20 @@ class Model {
|
|
|
94
115
|
// This is also how a caller re-reads the shop it is already on, and nulling unconditionally
|
|
95
116
|
// would blank the UI for the length of the read and undo syncBusinessContainer's own
|
|
96
117
|
// cache-first publish two lines later.
|
|
118
|
+
// Throws NotFoundException if the business cannot be opened - see businessSyncIn. Whatever was cached
|
|
119
|
+
// is published before the network is asked, unless it is a business that has not turned online shopping
|
|
120
|
+
// on - see publishBusiness - and is taken back, by publishing none, if the read then finds that the business
|
|
121
|
+
// cannot be opened.
|
|
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.
|
|
97
126
|
async setBusinessId(businessId) {
|
|
98
|
-
|
|
99
|
-
this
|
|
127
|
+
// An id of nothing but spaces has no canonical form - Java refuses it - and is held as it was written: it is the server that
|
|
128
|
+
// says there is no such business, with the NotFoundException that a UI shows for one, and not this that calls it a mistake
|
|
129
|
+
const canonicalBusinessId = canonicalizeBusinessId(businessId) || businessId;
|
|
130
|
+
const isDifferentBusiness = this._businessId !== canonicalBusinessId;
|
|
131
|
+
this._businessId = canonicalBusinessId;
|
|
100
132
|
if (isDifferentBusiness)
|
|
101
133
|
this.publishBusiness(null);
|
|
102
134
|
await this.businessSyncIn();
|
|
@@ -118,8 +150,8 @@ class Model {
|
|
|
118
150
|
// with updateCartLocalOnly and call this on its own schedule (debounced, on blur, a timer).
|
|
119
151
|
// Safe to call often: hasShoppingListChanged checks against the store's own copy rather than
|
|
120
152
|
// whatever the caller last sent, so a call that matches what is already there returns without a
|
|
121
|
-
// request. Not a substitute for reading the business, though - a push answers
|
|
122
|
-
//
|
|
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.
|
|
123
155
|
async updateCart(shoppingList) {
|
|
124
156
|
if (this._profile.value === null)
|
|
125
157
|
return this.updateCartLocalOnly(shoppingList);
|
|
@@ -136,13 +168,13 @@ class Model {
|
|
|
136
168
|
// Nothing is lost by leaving it unsent: it survives a closed tab, the next updateCart sends it,
|
|
137
169
|
// and createOrder reads straight from the store. All a caller gives up is that the server, and so
|
|
138
170
|
// this customer's other devices, hear about the change late.
|
|
139
|
-
// It also cannot be undone by a stale sync in bringing an older server copy back: the store
|
|
140
|
-
//
|
|
141
|
-
// past what it replaces.
|
|
171
|
+
// It also cannot be undone by a stale sync in bringing an older server copy back: the store
|
|
172
|
+
// versions an amendment at the update number of the copy it amends, where the later copy wins, and
|
|
173
|
+
// buildPartyPreferencesMessage always dates an amendment past what it replaces.
|
|
142
174
|
async updateCartLocalOnly(shoppingList) {
|
|
143
175
|
// No membership to derive a message id from while signed out, so one is drawn instead - once,
|
|
144
176
|
// and only once: buildPartyPreferencesMessage keeps amending the same entity by reusing its id,
|
|
145
|
-
// so a second edit cannot leave two behind. The sender is ENTITY_ID_NULL for want of a
|
|
177
|
+
// so a second edit cannot leave two behind. The sender is ENTITY_ID_NULL for want of a party;
|
|
146
178
|
// the first push after signing in names the real one.
|
|
147
179
|
if (this._profile.value === null)
|
|
148
180
|
return this.storeCartInCache(ContainerNames.forBusiness(this.requireBusinessBeingBrowsed()), Ids.getNewId(proto_Constants.MESSAGE_ENTITY_TYPE_ID), BigInt(proto_Constants.ENTITY_ID_NULL), shoppingList);
|
|
@@ -169,7 +201,7 @@ class Model {
|
|
|
169
201
|
const partyPreferencesMessage = await Model.getCache().readPartyPreferences(target.containerName);
|
|
170
202
|
const shoppingList = partyPreferencesMessage === null
|
|
171
203
|
? ShoppingList.EMPTY
|
|
172
|
-
: ProtoMap.buildShoppingList(partyPreferencesMessage);
|
|
204
|
+
: ProtoMap.buildShoppingList(partyPreferencesMessage, target.countryCode);
|
|
173
205
|
if (shoppingList.existingItems.length === 0)
|
|
174
206
|
throw new IllegalOperationException("The cart holds nothing, so there is no order to place");
|
|
175
207
|
Model.requireDeliveryAddress(target.addresses, fulfillmentMode, deliveryAddressId, deliveryAddress);
|
|
@@ -197,9 +229,10 @@ class Model {
|
|
|
197
229
|
}
|
|
198
230
|
// #region Pushing
|
|
199
231
|
// One push, plus everything that follows a server accepting it. The messages go out in one
|
|
200
|
-
// request,
|
|
201
|
-
//
|
|
202
|
-
//
|
|
232
|
+
// request, with how far this business container's reads have got, and the server answers with
|
|
233
|
+
// these messages under their assigned update numbers, and no more: a push is not a read. They are
|
|
234
|
+
// stored, and move no cursor, so, the next read is sent them again, and anything somebody else
|
|
235
|
+
// changed in the meantime comes with them. A server that refuses the push fails the call
|
|
203
236
|
// itself, same as any other Service call on a bad status, so there is nothing left here to check:
|
|
204
237
|
// reaching the write below already means the server took it.
|
|
205
238
|
async pushBusinessMessages(target, messages) {
|
|
@@ -242,6 +275,7 @@ class Model {
|
|
|
242
275
|
// for want of a link token to sign with.
|
|
243
276
|
requireCustomerOfBusiness() {
|
|
244
277
|
const businessId = this.requireBusinessBeingBrowsed();
|
|
278
|
+
const business = this._business.value;
|
|
245
279
|
const profile = this._profile.value;
|
|
246
280
|
const user = profile?.user ?? null;
|
|
247
281
|
if (profile === null || user === null)
|
|
@@ -252,8 +286,9 @@ class Model {
|
|
|
252
286
|
return {
|
|
253
287
|
businessLink: businessLink,
|
|
254
288
|
containerName: Model.getBusinessContainerName(businessLink),
|
|
255
|
-
senderId:
|
|
289
|
+
senderId: businessLink.partyId,
|
|
256
290
|
addresses: user.addresses,
|
|
291
|
+
countryCode: business.countryCode,
|
|
257
292
|
};
|
|
258
293
|
}
|
|
259
294
|
// Where an order is delivered: an id into this customer's own address list (the same list
|
|
@@ -281,7 +316,18 @@ class Model {
|
|
|
281
316
|
// businessId. A signed-in customer reads it under their own container instead - see
|
|
282
317
|
// getBusinessContainerName - joining as a customer first if not already linked - see
|
|
283
318
|
// requireBusinessLink.
|
|
284
|
-
|
|
319
|
+
// Throws NotFoundException, from either, when the business is not one a storefront can open,
|
|
320
|
+
// whether or not this customer has joined it, and that is the answer to any read of it, the first
|
|
321
|
+
// or a later one: a business can stop being open after it was cached. The server says so when the
|
|
322
|
+
// business does not exist, has been deleted, or does not allow customers to join it. Whether it
|
|
323
|
+
// has turned online shopping on the server does not say: this decides, once everything the server
|
|
324
|
+
// sent has been read, from the settings that it then holds. What was cached is left as it is,
|
|
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.
|
|
330
|
+
async businessSyncIn(isStartedOver = false) {
|
|
285
331
|
const businessId = this._businessId;
|
|
286
332
|
if (businessId === null || businessId.length === 0)
|
|
287
333
|
throw new IllegalOperationException("No business is being browsed, so there is nothing to read");
|
|
@@ -295,34 +341,62 @@ class Model {
|
|
|
295
341
|
const containerName = Model.getBusinessContainerName(link);
|
|
296
342
|
const anonymousName = ContainerNames.forBusiness(businessId);
|
|
297
343
|
// What this device read while nobody was signed in, folded into the container it is read
|
|
298
|
-
// under now - the same rule the server's own pages take:
|
|
299
|
-
//
|
|
300
|
-
// can easily be the staler of the two.
|
|
344
|
+
// under now - the same rule the server's own pages take: the later of two copies per entity by
|
|
345
|
+
// update number is kept, so a tombstone wins over what it deleted. Unconditional, since a
|
|
346
|
+
// returning customer's own container can easily be the staler of the two. It moves no cursor:
|
|
347
|
+
// that container has not been read for any of this, and is read from where its own reads got to.
|
|
301
348
|
// Not the cart, which is settled below: the two containers hold it under different ids, so a
|
|
302
349
|
// merge keyed on entity id would file the visitor's as a second cart instead of replacing one.
|
|
303
|
-
|
|
350
|
+
// Nor one that holds another business than the link is of: what was read under the businessId while nobody was
|
|
351
|
+
// signed in is of the business that the businessId named then, which is not the one the link is of once the
|
|
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.
|
|
356
|
+
const anonymousContainer = await cache.readBusiness(anonymousName);
|
|
357
|
+
const anonymousEntityId = ProtoMap.getBusinessResponseBusinessEntityId(anonymousContainer);
|
|
358
|
+
const anonymous = anonymousEntityId !== null && anonymousEntityId !== link.businessId ? null : anonymousContainer;
|
|
304
359
|
// Out of the messages just read, rather than a second read of the same container -
|
|
305
360
|
// partyPreferencesMessageFromMessages picks the same one readPartyPreferences(anonymousName)
|
|
306
361
|
// would.
|
|
307
362
|
const anonymousPartyPreferences = anonymous === null ? null : ProtoMap.partyPreferencesMessageFromMessages(anonymous.messages);
|
|
308
363
|
if (anonymous !== null) {
|
|
309
364
|
anonymous.messages = anonymous.messages.filter((message) => !ProtoMap.isPartyPreferencesMessage(message));
|
|
310
|
-
await cache.
|
|
365
|
+
await cache.storeBusiness(containerName, anonymous);
|
|
311
366
|
}
|
|
312
367
|
const authTokens = await this.requireBusinessAuthTokens(containerName, link);
|
|
313
|
-
|
|
368
|
+
// The link names the business by its entity id, which the link token that signs this call has to be of
|
|
369
|
+
const isBusinessNamed = await this.syncBusinessContainer(containerName, businessId, link.businessId, ProtoMap.buildAuthTokens(authTokens));
|
|
370
|
+
// The business that the link is of has another businessId now, and the profile that found the link by this one did not know
|
|
371
|
+
// it. Its container has been given up, and what is asked next of the server is what the profile says: read again, so that
|
|
372
|
+
// the link is found by this businessId or not, and the businessId that names another business is joined. Once - a profile
|
|
373
|
+
// that is read again and still finds the same link for the same businessId is the server contradicting itself
|
|
374
|
+
if (!isBusinessNamed) {
|
|
375
|
+
if (isStartedOver)
|
|
376
|
+
throw new InternalServerException(`The business that the link for the businessId ${businessId} is of does not have it, though the profile was read again`);
|
|
377
|
+
await this.profileSyncIn(ProtoMap.buildProfileSyncOutRequest(await cache.readProfileCursors(this.requireProfileContainerName())), await this.requireProfileAuthTokens());
|
|
378
|
+
await this.businessSyncIn(true);
|
|
379
|
+
return;
|
|
380
|
+
}
|
|
314
381
|
// THE CART A VISITOR BUILT BEFORE SIGNING IN. Signing in changes which container is being
|
|
315
382
|
// browsed, so without this it is simply left behind - and a customer has to build it again
|
|
316
383
|
// having just proved who they are.
|
|
317
384
|
if (anonymousPartyPreferences === null)
|
|
318
385
|
return;
|
|
386
|
+
// The visitor's cart holds taxes as raw codes, which mean something only within the country of
|
|
387
|
+
// the business it was built in. The anonymous container is where that was read from, and a cart
|
|
388
|
+
// is only ever stored once its business has been (see updateCartLocalOnly); failing that, the
|
|
389
|
+
// business being browsed is the same one.
|
|
390
|
+
const anonymousCountryCode = ProtoMap.getBusinessResponseCountryCode(anonymous) ?? this._business.value?.countryCode;
|
|
391
|
+
if (anonymousCountryCode === undefined)
|
|
392
|
+
throw new IllegalOperationException("The business the visitor's cart was built in is not known, so its taxes cannot be read");
|
|
319
393
|
// Read after the sync, so this is weighed against what the server just settled rather than a
|
|
320
394
|
// local copy about to be replaced. Last written wins, by updatedAt.
|
|
321
395
|
const userPartyPreferences = await cache.readPartyPreferences(containerName);
|
|
322
396
|
if (userPartyPreferences === null ||
|
|
323
397
|
ProtoMap.getMessageUpdatedAt(anonymousPartyPreferences) >
|
|
324
398
|
ProtoMap.getMessageUpdatedAt(userPartyPreferences))
|
|
325
|
-
await this.updateCart(ProtoMap.buildShoppingList(anonymousPartyPreferences));
|
|
399
|
+
await this.updateCart(ProtoMap.buildShoppingList(anonymousPartyPreferences, anonymousCountryCode));
|
|
326
400
|
// Dropped either way, carried over or beaten by the customer's own: the anonymous container is
|
|
327
401
|
// what the next visitor on this device reads, and a settled cart has no business staying there.
|
|
328
402
|
await cache.deletePartyPreferences(anonymousName);
|
|
@@ -330,19 +404,130 @@ class Model {
|
|
|
330
404
|
// The plain businessId-named container: an anonymous read, before signing in or joining, or to
|
|
331
405
|
// learn a business's entity id while joining - see readJoinBusinessId.
|
|
332
406
|
async syncAnonymousBusiness(businessId) {
|
|
333
|
-
await this.syncBusinessContainer(ContainerNames.forBusiness(businessId), businessId, null);
|
|
407
|
+
await this.syncBusinessContainer(ContainerNames.forBusiness(businessId), businessId, null, null);
|
|
334
408
|
}
|
|
335
409
|
// One business, cache first: what's already held publishes before the network is touched, so a
|
|
336
410
|
// returning customer sees the shop at once instead of a loading state. Cursors start from
|
|
337
|
-
//
|
|
338
|
-
//
|
|
339
|
-
|
|
411
|
+
// how far the reads of containerName have got, which it keeps, and not from zero, so a repeat call
|
|
412
|
+
// asks only what changed - see SyncCache.readBusinessCursors. Each page moves them as it is written.
|
|
413
|
+
// The business is named to the server by its entity id where this knows it, and by its businessId
|
|
414
|
+
// only where it does not: a caller that has a BusinessLink has its businessEntityId, and one that
|
|
415
|
+
// has read the business before has it in the container, from that read. Which of the two is
|
|
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.
|
|
420
|
+
//
|
|
421
|
+
// What decides whether the business can be opened is what the container holds once every page has been
|
|
422
|
+
// read and written, and not what the pages carried, which is only what changed: a read that has nothing
|
|
423
|
+
// new to send says nothing of what has not changed. It is looked at after every read, whether the read
|
|
424
|
+
// brought anything or not, and in this order:
|
|
425
|
+
// 1. That the business the container holds is the one that businessId names. A business keeps its entity
|
|
426
|
+
// id when its businessId changes, and another can take the businessId it gave up, so a container that is
|
|
427
|
+
// read by the entity id it was left with may hold a business that is not the one that businessId names
|
|
428
|
+
// now. The business arrives changed when its businessId does, which is how this is seen, and it is seen
|
|
429
|
+
// after the next read however long that is, as it is the container that is looked at. If it is not that
|
|
430
|
+
// business, the container is given up and the business is read from the beginning by its businessId,
|
|
431
|
+
// once: that finds a business that has it, or none, which the server says with a NotFoundException.
|
|
432
|
+
// A read that a link names cannot be read again by the businessId, as a link token is refused with
|
|
433
|
+
// one, and the link names the one business that it is of. Its container is given up all the same, and
|
|
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.
|
|
448
|
+
async syncBusinessContainer(containerName, businessId, businessEntityId, firstAuthTokens) {
|
|
340
449
|
const cache = Model.getCache();
|
|
341
450
|
const cached = await cache.readBusiness(containerName);
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
//
|
|
451
|
+
const cachedBusiness = Model.buildPublishable(cached);
|
|
452
|
+
this.publish(cachedBusiness); // Unless it is a business that is not open - see publishBusiness
|
|
453
|
+
this._isPublishedBusinessVetted = false; // Until this has read it to the end
|
|
454
|
+
// The entity id that an earlier read of this container left in it: what a business is named by when no link names
|
|
455
|
+
// it. It is how the business was found then, and it may not be what the businessId finds now: a business can have
|
|
456
|
+
// its businessId given up and taken by another, or changed.
|
|
457
|
+
const cachedEntityId = ProtoMap.getBusinessResponseBusinessEntityId(cached);
|
|
458
|
+
// What the store accepted - the blobs it stored - which decides the publish at the end
|
|
459
|
+
let changeCount;
|
|
460
|
+
try {
|
|
461
|
+
changeCount = await this.readBusinessPages(containerName, businessEntityId ?? cachedEntityId ?? businessId, await cache.readBusinessCursors(containerName), ProtoMap.getBusinessResponseAttachmentBaseUrlExpiry(cached), firstAuthTokens);
|
|
462
|
+
}
|
|
463
|
+
catch (error) {
|
|
464
|
+
// The server says that the business cannot be opened: what was published from the cache, before it was asked, is
|
|
465
|
+
// not what is there. The container is left as it is - deciding that a business is gone is the caller's
|
|
466
|
+
if (error instanceof NotFoundException)
|
|
467
|
+
this.publishBusiness(null);
|
|
468
|
+
throw error;
|
|
469
|
+
}
|
|
470
|
+
let held = await cache.readBusiness(containerName);
|
|
471
|
+
// Whether what was published is no longer what the container holds
|
|
472
|
+
let isPublishedStale = changeCount !== 0;
|
|
473
|
+
// Named by the cached entity id, and not by a link, which names the business that it is of and no other
|
|
474
|
+
if (businessEntityId === null && cachedEntityId !== null && !Model.isBusinessNamed(held, businessId)) {
|
|
475
|
+
// What has been read of one business is no guide to what has not been read of another, so, none of the container
|
|
476
|
+
// is kept, nor how far it was read, and the business that the businessId names is read from the beginning. That is with
|
|
477
|
+
// no attachment url held either, which is why the expiry is zero and not that of the container's: the url it held was the
|
|
478
|
+
// old business's, which has been given up with it, and a server sent its expiry would take the new business to have it and
|
|
479
|
+
// send none, leaving every photo of it without one. Signed with nothing, a link token being refused with a businessId
|
|
480
|
+
await cache.deleteBusiness(containerName);
|
|
481
|
+
this.publishBusiness(null);
|
|
482
|
+
await this.readBusinessPages(containerName, businessId, new SyncState(SyncKeys.BUSINESS_SYNC_IN), 0n, null);
|
|
483
|
+
held = await cache.readBusiness(containerName);
|
|
484
|
+
isPublishedStale = true;
|
|
485
|
+
}
|
|
486
|
+
// What the businessId finds is the business that has it, so, one that does not is the server contradicting itself
|
|
487
|
+
if (businessEntityId === null && !Model.isBusinessNamed(held, businessId))
|
|
488
|
+
throw new InternalServerException(`The business that was read for the businessId ${businessId} has the businessId ${ProtoMap.getBusinessResponseBusinessId(held)}`);
|
|
489
|
+
// Named by a link, which is of a business that no longer has the businessId. It cannot be read again by the businessId,
|
|
490
|
+
// as a link token is refused with one, so, the container is given up as above and it is the caller's to start again
|
|
491
|
+
if (businessEntityId !== null && !Model.isBusinessNamed(held, businessId)) {
|
|
492
|
+
await cache.deleteBusiness(containerName);
|
|
493
|
+
this.publishBusiness(null);
|
|
494
|
+
return false;
|
|
495
|
+
}
|
|
496
|
+
// A container that holds no business is not one that this can say anything of: that is a failure of its own, and
|
|
497
|
+
// not a business that has not turned online shopping on - see readJoinBusinessId
|
|
498
|
+
if (ProtoMap.getBusinessResponseBusinessEntityId(held) !== null && !ProtoMap.isBusinessResponseOnlineShoppingOn(held)) {
|
|
499
|
+
// What was published from the cache, before the network was asked, is of the business as it was when it was open,
|
|
500
|
+
// which is not what it is now: nothing of a business that cannot be opened is published, or a customer could be
|
|
501
|
+
// joined to it - see readJoinBusinessId
|
|
502
|
+
this.publishBusiness(null);
|
|
503
|
+
throw new NotFoundException("The business has not turned online shopping on");
|
|
504
|
+
}
|
|
505
|
+
// What the container holds now, built once for this and for the publish below: the one that was built from the cache
|
|
506
|
+
// before the network was asked, when the read has changed nothing of it
|
|
507
|
+
const business = isPublishedStale ? Model.buildPublishable(held) : cachedBusiness;
|
|
508
|
+
if (business !== null && business.catalog.productCount === 0) {
|
|
509
|
+
// Nothing that a customer can buy is a shop that is not found, and is not shown, whatever the cache published
|
|
510
|
+
this.publishBusiness(null);
|
|
511
|
+
throw new NotFoundException("The business has nothing that a customer can buy");
|
|
512
|
+
}
|
|
513
|
+
// A read the store took nothing from re-renders nobody - already published above from cache,
|
|
514
|
+
// and nothing here changes what a later read of this container answers
|
|
515
|
+
if (isPublishedStale)
|
|
516
|
+
this.publish(business);
|
|
517
|
+
// What is published now is what was read to the end, and is the business that the businessId names, and is open
|
|
518
|
+
this._isPublishedBusinessVetted = true;
|
|
519
|
+
return true;
|
|
520
|
+
}
|
|
521
|
+
// Reads every page of a business that the server has for the cursors it is given, and writes each into the
|
|
522
|
+
// container as it arrives. The business is named by its entity id or by its businessId, which is decided by
|
|
523
|
+
// the first request, and every page after it is about the same business named the same way. Answers what the
|
|
524
|
+
// store accepted - the blobs it stored.
|
|
525
|
+
// firstAuthTokens are those of a link, and a link token is refused with a businessId, so a read that names the business by
|
|
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.
|
|
527
|
+
async readBusinessPages(containerName, business, cursors, attachmentBaseUrlExpiry, firstAuthTokens) {
|
|
528
|
+
const cache = Model.getCache();
|
|
529
|
+
let request = ProtoMap.buildBusinessSyncOutRequest(business, cursors, attachmentBaseUrlExpiry);
|
|
530
|
+
let authTokens = typeof business === "bigint" ? firstAuthTokens : null;
|
|
346
531
|
let changeCount = 0;
|
|
347
532
|
for (;;) {
|
|
348
533
|
const page = await Model.getService().businessSyncOut(request, authTokens);
|
|
@@ -355,16 +540,19 @@ class Model {
|
|
|
355
540
|
// no cursor - which only a server breaking its own ordering can do
|
|
356
541
|
const next = ProtoMap.nextBusinessSyncOutRequest(request, page);
|
|
357
542
|
if (next === null)
|
|
358
|
-
|
|
543
|
+
return changeCount;
|
|
359
544
|
// The next page asks from where this one got to, signed with any token this one reissued
|
|
360
545
|
request = next;
|
|
361
546
|
authTokens = ProtoMap.refreshAuthTokens(authTokens, issued);
|
|
362
547
|
}
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
548
|
+
}
|
|
549
|
+
// Whether a response holds the business that businessId names, or holds none at all, which says nothing against it:
|
|
550
|
+
// only a business that has another businessId is another business. A business id is compared as the server finds
|
|
551
|
+
// one, in whatever case it is written and however it is padded
|
|
552
|
+
static isBusinessNamed(response, businessId) {
|
|
553
|
+
const heldBusinessId = ProtoMap.getBusinessResponseBusinessId(response);
|
|
554
|
+
return (heldBusinessId === null ||
|
|
555
|
+
canonicalizeBusinessId(heldBusinessId) === canonicalizeBusinessId(businessId));
|
|
368
556
|
}
|
|
369
557
|
// The BusinessLink of this profile for the named business, joining as a customer first (once) if
|
|
370
558
|
// not linked yet. A business in the profile with no matching link is a server inconsistency, not
|
|
@@ -378,7 +566,7 @@ class Model {
|
|
|
378
566
|
if (Model.findBusinessEntity(profile, businessId) !== null)
|
|
379
567
|
throw new InternalServerException(`This customer has no BusinessLink to business ${businessId}`);
|
|
380
568
|
const joinBusinessId = await this.readJoinBusinessId(businessId);
|
|
381
|
-
await this.profileSyncIn(ProtoMap.
|
|
569
|
+
await this.profileSyncIn(ProtoMap.buildProfileSyncOutRequest(await Model.getCache().readProfileCursors(this.requireProfileContainerName()), joinBusinessId), await this.requireProfileAuthTokens());
|
|
382
570
|
// Attempted once: a join that still leaves no link is the server contradicting itself, not
|
|
383
571
|
// something retrying would fix.
|
|
384
572
|
const joined = this._profile.value;
|
|
@@ -388,20 +576,25 @@ class Model {
|
|
|
388
576
|
return joinedLink;
|
|
389
577
|
}
|
|
390
578
|
// The entity id to join, for a business this profile doesn't list yet. Read off the Business
|
|
391
|
-
// already held, if an anonymous visit left one behind, or off a fresh anonymous read otherwise.
|
|
579
|
+
// already held, if an anonymous visit left one behind and read it to the end, or off a fresh anonymous read otherwise.
|
|
580
|
+
// A business that is only published from the cache, by a read that has not ended, is not one that has been found to
|
|
581
|
+
// be open and to be the one that the businessId names, and a customer is not joined to it on that account.
|
|
392
582
|
async readJoinBusinessId(businessId) {
|
|
393
583
|
const alreadyHeld = this._business.value;
|
|
394
|
-
if (alreadyHeld !== null)
|
|
584
|
+
if (alreadyHeld !== null && this._isPublishedBusinessVetted)
|
|
395
585
|
return alreadyHeld.id;
|
|
396
586
|
await this.syncAnonymousBusiness(businessId);
|
|
587
|
+
// A business that is not there is a NotFoundException of the read above. Reaching here with none
|
|
588
|
+
// published is the server answering with no Business at all, which is its own failure
|
|
397
589
|
const business = this._business.value;
|
|
398
590
|
if (business === null)
|
|
399
|
-
throw new InternalServerException(`
|
|
591
|
+
throw new InternalServerException(`The read of business ${businessId} answered with no Business`);
|
|
400
592
|
return business.id;
|
|
401
593
|
}
|
|
402
594
|
static findBusinessEntity(profile, businessId) {
|
|
595
|
+
const wanted = canonicalizeBusinessId(businessId);
|
|
403
596
|
for (const business of profile.businesses)
|
|
404
|
-
if (business.businessId ===
|
|
597
|
+
if (canonicalizeBusinessId(business.businessId) === wanted)
|
|
405
598
|
return business;
|
|
406
599
|
return null;
|
|
407
600
|
}
|
|
@@ -473,7 +666,7 @@ class Model {
|
|
|
473
666
|
let hasJoinedBusiness = false;
|
|
474
667
|
for (;;) {
|
|
475
668
|
const page = await Model.getService().profileSyncOut(request, authTokens);
|
|
476
|
-
changeCount += await this.cacheProfileResponse(containerName, page);
|
|
669
|
+
changeCount += await this.cacheProfileResponse(containerName, page, true);
|
|
477
670
|
if (ProtoMap.hasJoinedBusinessLink(joinBusinessId, page))
|
|
478
671
|
hasJoinedLink = true;
|
|
479
672
|
if (ProtoMap.hasJoinedBusiness(joinBusinessId, page))
|
|
@@ -524,8 +717,8 @@ class Model {
|
|
|
524
717
|
//
|
|
525
718
|
// Only a joinBusinessId a server could act on is checked (Java Business.isValidBusinessId's
|
|
526
719
|
// test, which Constants.ENTITY_ID_NULL also fails, so "no join asked" needs no case of its own).
|
|
527
|
-
// An id that is not a business entity id
|
|
528
|
-
//
|
|
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.
|
|
529
722
|
static requireJoinedBusiness(joinBusinessId, hasBusinessLink, hasBusiness) {
|
|
530
723
|
if (Ids.getEntityTypeId(joinBusinessId) !== proto_Constants.BUSINESS_ENTITY_TYPE_ID)
|
|
531
724
|
return;
|
|
@@ -544,8 +737,13 @@ class Model {
|
|
|
544
737
|
// a sign up and an update - so none of them can come to cache a response differently from the
|
|
545
738
|
// others, or file a token in a different place. Publishing is not in here: a read publishes once
|
|
546
739
|
// for a whole loop of these, and the other two once each.
|
|
547
|
-
|
|
548
|
-
|
|
740
|
+
// Only a page of a read moves the cursors of the container. A sign up and an update are changes this
|
|
741
|
+
// client made, and the next read brings them back, so, they are stored without moving any.
|
|
742
|
+
async cacheProfileResponse(containerName, response, isPageOfRead) {
|
|
743
|
+
const cache = Model.getCache();
|
|
744
|
+
const changeCount = isPageOfRead
|
|
745
|
+
? await cache.writeProfile(containerName, response)
|
|
746
|
+
: await cache.storeProfile(containerName, response);
|
|
549
747
|
await this.storeIssuedTokens(ProtoMap.getResponseAuthTokens(response), containerName);
|
|
550
748
|
return changeCount;
|
|
551
749
|
}
|
|
@@ -560,7 +758,7 @@ class Model {
|
|
|
560
758
|
const containerName = this.getProfileContainerName();
|
|
561
759
|
if (containerName === null)
|
|
562
760
|
return response;
|
|
563
|
-
if ((await this.cacheProfileResponse(containerName, response)) !== 0)
|
|
761
|
+
if ((await this.cacheProfileResponse(containerName, response, false)) !== 0)
|
|
564
762
|
this.publishProfile(await Model.getCache().readProfile(containerName));
|
|
565
763
|
return response;
|
|
566
764
|
}
|
|
@@ -633,9 +831,24 @@ class Model {
|
|
|
633
831
|
// and nothing is lost: a response the store took nothing from can't change what a later read
|
|
634
832
|
// answers.
|
|
635
833
|
// All four calls use this, which is why an update publishes nothing - see writeProfileResponse.
|
|
834
|
+
// A business that has not turned online shopping on is not published, whatever the container holds of it: what is
|
|
835
|
+
// published is then no business, as it is a business that cannot be opened, which a read throws for. Without this,
|
|
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.
|
|
636
841
|
publishBusiness(response) {
|
|
637
|
-
this.
|
|
638
|
-
|
|
842
|
+
this.publish(Model.buildPublishable(response));
|
|
843
|
+
}
|
|
844
|
+
publish(business) {
|
|
845
|
+
this._business.value = business;
|
|
846
|
+
}
|
|
847
|
+
// The business that a response holds, or none when there is no response or the business has not turned online shopping on
|
|
848
|
+
static buildPublishable(response) {
|
|
849
|
+
return response === null || !ProtoMap.isBusinessResponseOnlineShoppingOn(response)
|
|
850
|
+
? null
|
|
851
|
+
: ProtoMap.buildBusiness(response);
|
|
639
852
|
}
|
|
640
853
|
publishProfile(response) {
|
|
641
854
|
this._profile.value =
|
package/dist/Package.js
CHANGED
|
@@ -37,8 +37,8 @@ export default class Package extends Item {
|
|
|
37
37
|
get photos() {
|
|
38
38
|
return this._photos;
|
|
39
39
|
}
|
|
40
|
-
// What this package costs
|
|
41
|
-
// bought.
|
|
40
|
+
// What this package costs, with the taxes of its product included (Product.taxes); null when the
|
|
41
|
+
// business has not priced it, in which case it cannot be bought.
|
|
42
42
|
get price() {
|
|
43
43
|
return this._price;
|
|
44
44
|
}
|
package/dist/Price.js
CHANGED
|
@@ -2,6 +2,10 @@ import { amountFromScaledValue } from "./Constants";
|
|
|
2
2
|
import Item from "./Item";
|
|
3
3
|
// What one package costs: the regular price and the offer price. Exposed unscaled as numbers; they
|
|
4
4
|
// travel multiplied by Constants.AMOUNT_FACTOR and stay well below 2^53, so nothing is lost.
|
|
5
|
+
//
|
|
6
|
+
// BOTH INCLUDE TAXES: the taxes of the product the package belongs to (Product.taxes) are in the
|
|
7
|
+
// price, not added to it, so this is what a customer pays and what a catalog shows. A shopping
|
|
8
|
+
// list stores a price without them, which is why a cart takes the taxes out of one (CartEditor).
|
|
5
9
|
export default class Price extends Item {
|
|
6
10
|
// Only ProtoMap calls this: it is the one place that sees prices still multiplied by
|
|
7
11
|
// Constants.AMOUNT_FACTOR.
|
|
@@ -30,8 +34,8 @@ export default class Price extends Item {
|
|
|
30
34
|
get offerPrice() {
|
|
31
35
|
return this._offerPrice;
|
|
32
36
|
}
|
|
33
|
-
// What a customer actually pays: the offer price when there is one, else the
|
|
34
|
-
// Computed here (not sent), matching the "on offer" definition from Price.proto.
|
|
37
|
+
// What a customer actually pays, taxes included: the offer price when there is one, else the
|
|
38
|
+
// regular price. Computed here (not sent), matching the "on offer" definition from Price.proto.
|
|
35
39
|
get finalPrice() {
|
|
36
40
|
return this.isOnOffer ? this._offerPrice : this._regularPrice;
|
|
37
41
|
}
|
package/dist/Product.d.ts
CHANGED
|
@@ -14,20 +14,22 @@ export default class Product extends Item {
|
|
|
14
14
|
private readonly _manufacturerAddress;
|
|
15
15
|
private readonly _taxes;
|
|
16
16
|
private readonly _features;
|
|
17
|
+
private readonly _isForOfflineSaleOnly;
|
|
17
18
|
private readonly _photos;
|
|
18
|
-
|
|
19
|
-
constructor(header: EntityHeader, name: string, description: string | null, productCategoryId: bigint, isDisabled: boolean, _productType: proto_ProductType, _brand: string | null, _isManufacturedByBusiness: boolean | null, _manufacturer: string | null, _manufacturerAddress: Address | null, _taxes: ReadonlyArray<Tax>, _features: ReadonlyArray<string>, _photos: ReadonlyArray<Photo>, _packages: ReadonlyArray<Package>);
|
|
19
|
+
constructor(header: EntityHeader, name: string, description: string | null, productCategoryId: bigint, isDisabled: boolean, _productType: proto_ProductType, _brand: string | null, _isManufacturedByBusiness: boolean, _manufacturer: string | null, _manufacturerAddress: Address | null, _taxes: ReadonlyArray<Tax>, _features: ReadonlyArray<string>, _isForOfflineSaleOnly: boolean, _photos: ReadonlyArray<Photo>, packages: ReadonlyArray<Package>);
|
|
20
20
|
get productCategoryId(): bigint;
|
|
21
21
|
get productType(): proto_ProductType;
|
|
22
22
|
get brand(): string | null;
|
|
23
|
-
get isManufacturedByBusiness(): boolean
|
|
23
|
+
get isManufacturedByBusiness(): boolean;
|
|
24
24
|
get manufacturer(): string | null;
|
|
25
25
|
get manufacturerAddress(): Address | null;
|
|
26
26
|
get taxes(): ReadonlyArray<Tax>;
|
|
27
27
|
get features(): ReadonlyArray<string>;
|
|
28
|
+
get isForOfflineSaleOnly(): boolean;
|
|
28
29
|
get photos(): ReadonlyArray<Photo>;
|
|
29
30
|
get packages(): ReadonlyArray<Package>;
|
|
30
31
|
get lowestPrice(): Price | null;
|
|
31
32
|
get isOnOffer(): boolean;
|
|
32
33
|
get isBuyable(): boolean;
|
|
34
|
+
private readonly _packages;
|
|
33
35
|
}
|