@peabody-soft/mbs-model-ts 1.0.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/LICENCE +661 -0
- package/dist/Address.d.ts +33 -0
- package/dist/Address.js +97 -0
- package/dist/Attachment.d.ts +16 -0
- package/dist/Attachment.js +49 -0
- package/dist/AttachmentBaseUrl.d.ts +11 -0
- package/dist/AttachmentBaseUrl.js +41 -0
- package/dist/AuthTokens.d.ts +13 -0
- package/dist/AuthTokens.js +30 -0
- package/dist/Business.d.ts +44 -0
- package/dist/Business.js +87 -0
- package/dist/BusinessIdentifier.d.ts +8 -0
- package/dist/BusinessIdentifier.js +14 -0
- package/dist/BusinessLink.d.ts +21 -0
- package/dist/BusinessLink.js +61 -0
- package/dist/BusinessProfile.d.ts +11 -0
- package/dist/BusinessProfile.js +27 -0
- package/dist/CartEditor.d.ts +59 -0
- package/dist/CartEditor.js +324 -0
- package/dist/Catalog.d.ts +28 -0
- package/dist/Catalog.js +115 -0
- package/dist/Constants.d.ts +10 -0
- package/dist/Constants.js +43 -0
- package/dist/ContainerNames.d.ts +2 -0
- package/dist/ContainerNames.js +21 -0
- package/dist/Database.d.ts +15 -0
- package/dist/Database.js +1 -0
- package/dist/Entity.d.ts +27 -0
- package/dist/Entity.js +69 -0
- package/dist/Enums.d.ts +16 -0
- package/dist/Enums.js +18 -0
- package/dist/Exceptions.d.ts +4 -0
- package/dist/Exceptions.js +8 -0
- package/dist/GeneralSettings.d.ts +46 -0
- package/dist/GeneralSettings.js +99 -0
- package/dist/HttpStatusCodes.d.ts +2 -0
- package/dist/HttpStatusCodes.js +34 -0
- package/dist/Ids.d.ts +17 -0
- package/dist/Ids.js +78 -0
- package/dist/Item.d.ts +12 -0
- package/dist/Item.js +30 -0
- package/dist/LatLong.d.ts +7 -0
- package/dist/LatLong.js +17 -0
- package/dist/LiveData.d.ts +30 -0
- package/dist/LiveData.js +76 -0
- package/dist/Measures.d.ts +20 -0
- package/dist/Measures.js +34 -0
- package/dist/Message.d.ts +16 -0
- package/dist/Message.js +32 -0
- package/dist/MockDatabase.d.ts +19 -0
- package/dist/MockDatabase.js +73 -0
- package/dist/Model.d.ts +63 -0
- package/dist/Model.js +629 -0
- package/dist/Order.d.ts +31 -0
- package/dist/Order.js +70 -0
- package/dist/Package.d.ts +25 -0
- package/dist/Package.js +52 -0
- package/dist/PackageIdentifier.d.ts +8 -0
- package/dist/PackageIdentifier.js +14 -0
- package/dist/PaymentMethodDetail.d.ts +8 -0
- package/dist/PaymentMethodDetail.js +17 -0
- package/dist/Photo.d.ts +11 -0
- package/dist/Photo.js +29 -0
- package/dist/Price.d.ts +17 -0
- package/dist/Price.js +52 -0
- package/dist/PriceList.d.ts +3 -0
- package/dist/PriceList.js +5 -0
- package/dist/Product.d.ts +33 -0
- package/dist/Product.js +84 -0
- package/dist/ProductCategory.d.ts +10 -0
- package/dist/ProductCategory.js +18 -0
- package/dist/Profile.d.ts +20 -0
- package/dist/Profile.js +55 -0
- package/dist/ProtoMap.d.ts +71 -0
- package/dist/ProtoMap.js +1126 -0
- package/dist/Routes.d.ts +7 -0
- package/dist/Routes.js +12 -0
- package/dist/Service.d.ts +16 -0
- package/dist/Service.js +1 -0
- package/dist/ServiceExceptions.d.ts +40 -0
- package/dist/ServiceExceptions.js +84 -0
- package/dist/ServiceProxy.d.ts +25 -0
- package/dist/ServiceProxy.js +117 -0
- package/dist/ShoppingList.d.ts +64 -0
- package/dist/ShoppingList.js +188 -0
- package/dist/SyncCache.d.ts +28 -0
- package/dist/SyncCache.js +137 -0
- package/dist/SyncKeys.d.ts +5 -0
- package/dist/SyncKeys.js +41 -0
- package/dist/SyncState.d.ts +8 -0
- package/dist/SyncState.js +30 -0
- package/dist/Tax.d.ts +10 -0
- package/dist/Tax.js +30 -0
- package/dist/UserProfile.d.ts +16 -0
- package/dist/UserProfile.js +40 -0
- package/dist/WorkingDays.d.ts +33 -0
- package/dist/WorkingDays.js +121 -0
- package/dist/generated/Address.d.ts +77 -0
- package/dist/generated/Address.js +110 -0
- package/dist/generated/AddressList.d.ts +39 -0
- package/dist/generated/AddressList.js +60 -0
- package/dist/generated/AddressType.d.ts +15 -0
- package/dist/generated/AddressType.js +20 -0
- package/dist/generated/Attachment.d.ts +66 -0
- package/dist/generated/Attachment.js +89 -0
- package/dist/generated/AttachmentType.d.ts +154 -0
- package/dist/generated/AttachmentType.js +159 -0
- package/dist/generated/AuthTokens.d.ts +56 -0
- package/dist/generated/AuthTokens.js +74 -0
- package/dist/generated/AuthenticationMethod.d.ts +24 -0
- package/dist/generated/AuthenticationMethod.js +29 -0
- package/dist/generated/Business.d.ts +54 -0
- package/dist/generated/Business.js +75 -0
- package/dist/generated/BusinessIdentifier.d.ts +36 -0
- package/dist/generated/BusinessIdentifier.js +60 -0
- package/dist/generated/BusinessIdentifierType.d.ts +49 -0
- package/dist/generated/BusinessIdentifierType.js +54 -0
- package/dist/generated/BusinessLink.d.ts +76 -0
- package/dist/generated/BusinessLink.js +99 -0
- package/dist/generated/BusinessSyncInRequest.d.ts +42 -0
- package/dist/generated/BusinessSyncInRequest.js +60 -0
- package/dist/generated/BusinessSyncInResponse.d.ts +42 -0
- package/dist/generated/BusinessSyncInResponse.js +60 -0
- package/dist/generated/BusinessSyncOutRequest.d.ts +82 -0
- package/dist/generated/BusinessSyncOutRequest.js +107 -0
- package/dist/generated/BusinessSyncOutResponse.d.ts +106 -0
- package/dist/generated/BusinessSyncOutResponse.js +139 -0
- package/dist/generated/Configuration.d.ts +48 -0
- package/dist/generated/Configuration.js +63 -0
- package/dist/generated/Constants.d.ts +474 -0
- package/dist/generated/Constants.js +479 -0
- package/dist/generated/CreateUserRequest.d.ts +37 -0
- package/dist/generated/CreateUserRequest.js +59 -0
- package/dist/generated/DimensionUnit.d.ts +28 -0
- package/dist/generated/DimensionUnit.js +33 -0
- package/dist/generated/Dimensions.d.ts +41 -0
- package/dist/generated/Dimensions.js +76 -0
- package/dist/generated/Entity.d.ts +62 -0
- package/dist/generated/Entity.js +83 -0
- package/dist/generated/FulfillmentMode.d.ts +32 -0
- package/dist/generated/FulfillmentMode.js +37 -0
- package/dist/generated/GeneralSettings.d.ts +141 -0
- package/dist/generated/GeneralSettings.js +186 -0
- package/dist/generated/Item.d.ts +54 -0
- package/dist/generated/Item.js +82 -0
- package/dist/generated/LatLong.d.ts +36 -0
- package/dist/generated/LatLong.js +59 -0
- package/dist/generated/Message.d.ts +80 -0
- package/dist/generated/Message.js +114 -0
- package/dist/generated/MessageType.d.ts +34 -0
- package/dist/generated/MessageType.js +39 -0
- package/dist/generated/OrderStatus.d.ts +50 -0
- package/dist/generated/OrderStatus.js +55 -0
- package/dist/generated/Package.d.ts +63 -0
- package/dist/generated/Package.js +92 -0
- package/dist/generated/PackageIdentifier.d.ts +36 -0
- package/dist/generated/PackageIdentifier.js +60 -0
- package/dist/generated/PackageIdentifierType.d.ts +49 -0
- package/dist/generated/PackageIdentifierType.js +54 -0
- package/dist/generated/PartyPreferencesPayload.d.ts +41 -0
- package/dist/generated/PartyPreferencesPayload.js +60 -0
- package/dist/generated/PaymentMethod.d.ts +20 -0
- package/dist/generated/PaymentMethod.js +25 -0
- package/dist/generated/PaymentMethodDetail.d.ts +37 -0
- package/dist/generated/PaymentMethodDetail.js +59 -0
- package/dist/generated/Price.d.ts +52 -0
- package/dist/generated/Price.js +75 -0
- package/dist/generated/PriceList.d.ts +34 -0
- package/dist/generated/PriceList.js +51 -0
- package/dist/generated/Product.d.ts +77 -0
- package/dist/generated/Product.js +106 -0
- package/dist/generated/ProductCategory.d.ts +32 -0
- package/dist/generated/ProductCategory.js +51 -0
- package/dist/generated/ProductType.d.ts +42 -0
- package/dist/generated/ProductType.js +47 -0
- package/dist/generated/ProfileSyncOutRequest.d.ts +51 -0
- package/dist/generated/ProfileSyncOutRequest.js +75 -0
- package/dist/generated/ProfileSyncOutResponse.d.ts +56 -0
- package/dist/generated/ProfileSyncOutResponse.js +77 -0
- package/dist/generated/ReservedPackage.d.ts +34 -0
- package/dist/generated/ReservedPackage.js +39 -0
- package/dist/generated/Role.d.ts +71 -0
- package/dist/generated/Role.js +76 -0
- package/dist/generated/SalesOrderPayload.d.ts +85 -0
- package/dist/generated/SalesOrderPayload.js +123 -0
- package/dist/generated/ShoppingList.d.ts +99 -0
- package/dist/generated/ShoppingList.js +132 -0
- package/dist/generated/ShoppingListItem.d.ts +102 -0
- package/dist/generated/ShoppingListItem.js +139 -0
- package/dist/generated/TaskStatus.d.ts +20 -0
- package/dist/generated/TaskStatus.js +25 -0
- package/dist/generated/Tax.d.ts +37 -0
- package/dist/generated/Tax.js +60 -0
- package/dist/generated/TaxType.d.ts +25 -0
- package/dist/generated/TaxType.js +30 -0
- package/dist/generated/UrlAndToken.d.ts +54 -0
- package/dist/generated/UrlAndToken.js +67 -0
- package/dist/generated/User.d.ts +63 -0
- package/dist/generated/User.js +89 -0
- package/dist/generated/Weight.d.ts +33 -0
- package/dist/generated/Weight.js +60 -0
- package/dist/generated/WeightUnit.d.ts +28 -0
- package/dist/generated/WeightUnit.js +33 -0
- package/dist/generated/WorkingDays.d.ts +42 -0
- package/dist/generated/WorkingDays.js +68 -0
- package/dist/generated/WorkingHours.d.ts +33 -0
- package/dist/generated/WorkingHours.js +52 -0
- package/dist/generated/WorkingHoursRange.d.ts +42 -0
- package/dist/generated/WorkingHoursRange.js +59 -0
- package/dist/index.d.ts +48 -0
- package/dist/index.js +60 -0
- package/package.json +55 -0
package/dist/Model.js
ADDED
|
@@ -0,0 +1,629 @@
|
|
|
1
|
+
import { Constants as proto_Constants } from "./generated/Constants";
|
|
2
|
+
import { CreateUserRequest as proto_CreateUserRequest } from "./generated/CreateUserRequest";
|
|
3
|
+
import { FulfillmentMode as proto_FulfillmentMode } from "./generated/FulfillmentMode";
|
|
4
|
+
import AuthTokens from "./AuthTokens";
|
|
5
|
+
import * as ContainerNames from "./ContainerNames";
|
|
6
|
+
import { IllegalOperationException, InvalidArgumentException } from "./Exceptions";
|
|
7
|
+
import Ids from "./Ids";
|
|
8
|
+
import LiveData from "./LiveData";
|
|
9
|
+
import * as ProtoMap from "./ProtoMap";
|
|
10
|
+
import { InternalServerException } from "./ServiceExceptions";
|
|
11
|
+
import SyncCache from "./SyncCache";
|
|
12
|
+
// The root of the object model, and the only thing a UI has to be given. Call initialize once,
|
|
13
|
+
// then always fetch the current instance via getInstance.
|
|
14
|
+
class Model {
|
|
15
|
+
constructor() {
|
|
16
|
+
this._business = new LiveData(null);
|
|
17
|
+
this._profile = new LiveData(null);
|
|
18
|
+
this._authenticationMethod = null;
|
|
19
|
+
this._subjectId = null;
|
|
20
|
+
this._authenticationToken = null;
|
|
21
|
+
this._businessId = null;
|
|
22
|
+
}
|
|
23
|
+
// Sets the service and store to use, and forgets who is signed in and what was being browsed
|
|
24
|
+
static initialize(service, database) {
|
|
25
|
+
Model._service = service;
|
|
26
|
+
Model._cache = new SyncCache(database);
|
|
27
|
+
Model._instance = null;
|
|
28
|
+
}
|
|
29
|
+
// The model as it is now. Ask for it every time; never keep it - see the note on this class.
|
|
30
|
+
static getInstance() {
|
|
31
|
+
return Model._instance ?? (Model._instance = new Model());
|
|
32
|
+
}
|
|
33
|
+
// The business being browsed. Null until setBusinessId has named one and it has loaded.
|
|
34
|
+
get business() {
|
|
35
|
+
return this._business;
|
|
36
|
+
}
|
|
37
|
+
// The customer, their businesses, and the link to each - one LiveData since useful questions
|
|
38
|
+
// span all three. Null until signed in and loaded.
|
|
39
|
+
get profile() {
|
|
40
|
+
return this._profile;
|
|
41
|
+
}
|
|
42
|
+
// Signs in with the given credentials and syncs this subject's profile in - for a subject the
|
|
43
|
+
// server already has a profile for; createUser is what a subject with none yet uses instead. Also
|
|
44
|
+
// reads in the business being browsed, if any, now signed in as this customer.
|
|
45
|
+
async signIn(authenticationMethod, subjectId, authenticationToken) {
|
|
46
|
+
this.beginSignIn(authenticationMethod, subjectId, authenticationToken);
|
|
47
|
+
const authTokens = await this.requireProfileAuthTokens();
|
|
48
|
+
await this.profileSyncIn(ProtoMap.buildProfileSyncOutRequestFromCache(await Model.getCache().readProfile(this.requireProfileContainerName())), authTokens);
|
|
49
|
+
if (this._businessId !== null)
|
|
50
|
+
await this.businessSyncIn();
|
|
51
|
+
}
|
|
52
|
+
// Signs up with the given credentials, creating userToCreate as this subject's profile - for a
|
|
53
|
+
// subject the server has no profile for yet; signIn is what an already known subject uses instead.
|
|
54
|
+
// Also reads in the business being browsed, if any, now signed in as this customer.
|
|
55
|
+
async createUser(authenticationMethod, subjectId, authenticationToken, userToCreate) {
|
|
56
|
+
this.beginSignIn(authenticationMethod, subjectId, authenticationToken);
|
|
57
|
+
const authTokens = await this.requireProfileAuthTokens();
|
|
58
|
+
await this.createProfileOnServer(proto_CreateUserRequest.create({ user: ProtoMap.buildUser(userToCreate) }), authTokens);
|
|
59
|
+
if (this._businessId !== null)
|
|
60
|
+
await this.businessSyncIn();
|
|
61
|
+
}
|
|
62
|
+
// Updates this customer's own profile with what userProfile now holds. Service.updateUser matches
|
|
63
|
+
// the caller against the User it is sent by entity id - see ProtoMap.buildUpdateUser - so
|
|
64
|
+
// userProfile must be this customer's own, previously read in rather than built fresh.
|
|
65
|
+
// Requires a profile already read, the same "signed in" a business write needs of its profile half
|
|
66
|
+
// - see requireCustomerOfBusiness. Not paged, and today publishes nothing: its response carries no
|
|
67
|
+
// entities, only whatever the server reissued - see writeProfileResponse.
|
|
68
|
+
async updateUser(userProfile) {
|
|
69
|
+
if (this._profile.value === null)
|
|
70
|
+
throw new IllegalOperationException("Nobody is signed in, so this call cannot be made");
|
|
71
|
+
await this.writeProfileResponse(await Model.getService().updateUser(ProtoMap.buildUpdateUser(userProfile), await this.requireProfileAuthTokens()));
|
|
72
|
+
}
|
|
73
|
+
// Discards this model outright: the next getInstance() builds a fresh one, signed out and
|
|
74
|
+
// browsing nothing.
|
|
75
|
+
async signOut() {
|
|
76
|
+
Model._instance = null;
|
|
77
|
+
}
|
|
78
|
+
// Names the business to browse and reads it in, publishing null first so a stale or
|
|
79
|
+
// about-to-be-replaced Business is never shown as the one now being browsed.
|
|
80
|
+
async setBusinessId(businessId) {
|
|
81
|
+
this._businessId = businessId;
|
|
82
|
+
this.publishBusiness(null);
|
|
83
|
+
await this.businessSyncIn();
|
|
84
|
+
}
|
|
85
|
+
// #region What a customer writes
|
|
86
|
+
//
|
|
87
|
+
// The only writes in this library: this customer's cart at the business being browsed, and an
|
|
88
|
+
// order placed from it. An order pushes to the server and touches the store only once the server
|
|
89
|
+
// accepts, so a failed call leaves nothing to reconcile and a caller just retries: an order
|
|
90
|
+
// stored here that the server never saw would be one a customer believes they placed and a
|
|
91
|
+
// merchant has never heard of. A cart is not held to that, and updateCartLocalOnly is the whole
|
|
92
|
+
// of the difference.
|
|
93
|
+
// Both need this customer to be a member of the business being browsed - three separate things
|
|
94
|
+
// to be missing, so three separate failures; see requireCustomerOfBusiness.
|
|
95
|
+
// Creates or replaces this customer's cart, in the store and on the server both. An empty list
|
|
96
|
+
// empties the cart - an emptied cart and one never created are the same state here, on the wire
|
|
97
|
+
// and in what Business.cart answers.
|
|
98
|
+
// Signed out this is updateCartLocalOnly, there being no cart on the server for a visitor the
|
|
99
|
+
// server has not been told about. Signed in it is a push, so it costs a request: a caller
|
|
100
|
+
// editing a cart a tap at a time writes with updateCartLocalOnly and calls this one on whatever
|
|
101
|
+
// schedule it likes.
|
|
102
|
+
// Calling it with a cart the server already has costs nothing: it returns without a request. So a
|
|
103
|
+
// caller is free to call it on every event it cares about - a page hidden, a timer, both at
|
|
104
|
+
// once - without tracking what it last sent, and what it checks against is the store's own copy
|
|
105
|
+
// rather than a caller's idea of one. It is not a substitute for reading the business, though: a
|
|
106
|
+
// push answers with whatever else of this customer's changed, and a call that does not go out
|
|
107
|
+
// brings none of that back.
|
|
108
|
+
async updateCart(shoppingList) {
|
|
109
|
+
if (this._profile.value === null)
|
|
110
|
+
return this.updateCartLocalOnly(shoppingList);
|
|
111
|
+
const target = this.requireCustomerOfBusiness();
|
|
112
|
+
const stored = await Model.getCache().readCart(target.containerName);
|
|
113
|
+
const cartMessage = ProtoMap.buildCartMessage(Ids.getPartyPreferencesMessageId(target.businessLink.id), stored, target.senderId, shoppingList, BigInt(Date.now()));
|
|
114
|
+
if (!ProtoMap.isCartWorthPushing(stored, cartMessage))
|
|
115
|
+
return;
|
|
116
|
+
await this.pushBusinessMessages(target, [cartMessage]);
|
|
117
|
+
}
|
|
118
|
+
// The same, stored here and not sent. This is the call to make on every edit: a cart is worth
|
|
119
|
+
// keeping the moment a customer changes it, and a request per tap of a quantity stepper is not
|
|
120
|
+
// worth making. updateCart then sends whatever the caller has reached whenever it decides to -
|
|
121
|
+
// debounced, on the page being hidden, before signing in.
|
|
122
|
+
//
|
|
123
|
+
// Little is lost by a cart that is only ever stored. It survives the tab closing, the next
|
|
124
|
+
// updateCart sends it, and createOrder reads the cart from the store and carries that list in
|
|
125
|
+
// the order message itself - so an order placed from a cart never sent on its own is still the
|
|
126
|
+
// right order. What a caller gives up is that the server, and so this customer's other devices,
|
|
127
|
+
// learn of a change only when the next updateCart goes out.
|
|
128
|
+
//
|
|
129
|
+
// The store keeps whichever copy has the greater eTag, and buildCartMessage dates an amendment
|
|
130
|
+
// past the copy it replaces, so a cart stored here cannot be undone by a sync in bringing the
|
|
131
|
+
// server's older one back.
|
|
132
|
+
async updateCartLocalOnly(shoppingList) {
|
|
133
|
+
// No membership to derive a cart id from while nobody is signed in (see
|
|
134
|
+
// Ids.getPartyPreferencesMessageId) and none to be had until somebody signs in, so one is
|
|
135
|
+
// drawn - once, and only once: buildCartMessage amends the cart already stored and keeps its
|
|
136
|
+
// own id, so a second write cannot leave two behind. The sender is ENTITY_ID_NULL for want of
|
|
137
|
+
// a User, and the first push after signing in names the real one.
|
|
138
|
+
if (this._profile.value === null)
|
|
139
|
+
return this.storeCart(ContainerNames.forBusiness(this.requireBusinessBeingBrowsed()), Ids.getNewId(proto_Constants.MESSAGE_ENTITY_TYPE_ID), BigInt(proto_Constants.ENTITY_ID_NULL), shoppingList);
|
|
140
|
+
const target = this.requireCustomerOfBusiness();
|
|
141
|
+
return this.storeCart(target.containerName, Ids.getPartyPreferencesMessageId(target.businessLink.id), target.senderId, shoppingList);
|
|
142
|
+
}
|
|
143
|
+
// Places an order for what is in the cart, and empties it - the list is used up becoming the
|
|
144
|
+
// order, as Java SalesOrderViewModel.saveChanges does on MODE_CREATE. Takes no shopping list
|
|
145
|
+
// itself for that reason: the cart is the order.
|
|
146
|
+
// A delivery must name an address, either an id from this customer's own list or a full address
|
|
147
|
+
// sent directly - see requireDeliveryAddress.
|
|
148
|
+
async createOrder(fulfillmentMode, deliveryAddressId, deliveryAddress, note) {
|
|
149
|
+
const target = this.requireCustomerOfBusiness();
|
|
150
|
+
// Read from the store, not the published Business, so the order reflects the cart as the
|
|
151
|
+
// server last confirmed it, not a value some observer may still hold
|
|
152
|
+
const cartMessage = await Model.getCache().readCart(target.containerName);
|
|
153
|
+
const shoppingList = cartMessage === null ? null : ProtoMap.buildCart(cartMessage);
|
|
154
|
+
if (shoppingList === null || shoppingList.existingItems.length === 0)
|
|
155
|
+
throw new IllegalOperationException("The cart holds nothing, so there is no order to place");
|
|
156
|
+
Model.requireDeliveryAddress(target.addresses, fulfillmentMode, deliveryAddressId, deliveryAddress);
|
|
157
|
+
// One creation time for both, and it is what the order was placed at
|
|
158
|
+
const now = BigInt(Date.now());
|
|
159
|
+
// Order and emptied cart in one request: two calls could leave a customer with an order placed
|
|
160
|
+
// but a cart still full, if the second one failed
|
|
161
|
+
await this.pushBusinessMessages(target, [
|
|
162
|
+
ProtoMap.buildOrderMessage(Ids.getNewId(proto_Constants.MESSAGE_ENTITY_TYPE_ID), target.senderId, shoppingList, fulfillmentMode, deliveryAddressId, deliveryAddress, note, now),
|
|
163
|
+
ProtoMap.buildCartMessage(Ids.getPartyPreferencesMessageId(target.businessLink.id), cartMessage, target.senderId, null, now),
|
|
164
|
+
]);
|
|
165
|
+
}
|
|
166
|
+
// #endregion
|
|
167
|
+
// Throws if a profile is already held; otherwise stores the credentials every later call signs
|
|
168
|
+
// with. The one step signIn and createUser share before parting ways over how each gets a profile.
|
|
169
|
+
beginSignIn(authenticationMethod, subjectId, authenticationToken) {
|
|
170
|
+
if (this._profile.value !== null)
|
|
171
|
+
throw new IllegalOperationException("A user is already signed in");
|
|
172
|
+
this._authenticationMethod = authenticationMethod;
|
|
173
|
+
this._subjectId = subjectId;
|
|
174
|
+
this._authenticationToken = authenticationToken;
|
|
175
|
+
}
|
|
176
|
+
// #region Pushing
|
|
177
|
+
// One push, plus everything that follows a server accepting it. The messages go out in one
|
|
178
|
+
// request, over this business container's own cursor, and the server answers with what has
|
|
179
|
+
// changed since - these messages under their assigned update numbers, plus anything else of this
|
|
180
|
+
// customer's a later sync would also pick up. A server that refuses the push fails the call
|
|
181
|
+
// itself, same as any other Service call on a bad status, so there is nothing left here to check:
|
|
182
|
+
// reaching the write below already means the server took it.
|
|
183
|
+
async pushBusinessMessages(target, messages) {
|
|
184
|
+
const cache = Model.getCache();
|
|
185
|
+
const messageUpdateNumber = await cache.readMessageUpdateNumber(target.containerName);
|
|
186
|
+
const authTokens = await this.requireBusinessAuthTokens(target.containerName, target.businessLink);
|
|
187
|
+
const response = await Model.getService().businessSyncIn(ProtoMap.buildBusinessSyncInRequest(messageUpdateNumber, messages), ProtoMap.buildAuthTokens(authTokens));
|
|
188
|
+
await this.storeIssuedTokens(ProtoMap.getResponseAuthTokens(response), target.containerName);
|
|
189
|
+
// Stored exactly as the response carried it, entities and all - no stamping step, because the
|
|
190
|
+
// server already answered with the messages themselves rather than a bare number to reapply
|
|
191
|
+
const changeCount = await cache.writeMessages(target.containerName, ProtoMap.getSyncInResponseMessages(response));
|
|
192
|
+
// What the store took decides whether anything is published - see the Publishing region
|
|
193
|
+
if (changeCount !== 0)
|
|
194
|
+
this.publishBusiness(await cache.readBusiness(target.containerName));
|
|
195
|
+
}
|
|
196
|
+
// One cart into one container, amending whatever is there already. Both halves of
|
|
197
|
+
// updateCartLocalOnly end here, differing only in which container, which id and which sender:
|
|
198
|
+
// the container an anonymous read filled, with a drawn id and no sender, or the membership's,
|
|
199
|
+
// with the id derived from it and the customer's own.
|
|
200
|
+
// A visitor's copy goes into the container the anonymous read filled, which is what carries it
|
|
201
|
+
// across a sign in: the container named for a new membership is seeded from that one - see
|
|
202
|
+
// syncCustomerBusiness.
|
|
203
|
+
async storeCart(containerName, cartMessageId, senderId, shoppingList) {
|
|
204
|
+
const cache = Model.getCache();
|
|
205
|
+
const changeCount = await cache.writeCart(containerName, ProtoMap.buildCartMessage(cartMessageId, await cache.readCart(containerName), senderId, shoppingList, BigInt(Date.now())));
|
|
206
|
+
// Published on what the store took, the same rule every read and push keeps
|
|
207
|
+
if (changeCount !== 0)
|
|
208
|
+
this.publishBusiness(await cache.readBusiness(containerName));
|
|
209
|
+
}
|
|
210
|
+
// The business being browsed, refused by name when there is none or it has not been read yet.
|
|
211
|
+
// Shared by every write, because neither has anything to write to without it.
|
|
212
|
+
requireBusinessBeingBrowsed() {
|
|
213
|
+
const businessId = this._businessId;
|
|
214
|
+
if (businessId === null)
|
|
215
|
+
throw new IllegalOperationException("No business is being browsed, so there is nothing to write to");
|
|
216
|
+
if (this._business.value === null)
|
|
217
|
+
throw new IllegalOperationException(`Business ${businessId} has not been read yet, so there is nothing to write to`);
|
|
218
|
+
return businessId;
|
|
219
|
+
}
|
|
220
|
+
// Everything a write needs, gathered before anything is built. Each precondition is refused by
|
|
221
|
+
// name rather than one unhelpful "not ready": no business browsed, one not yet loaded, no profile
|
|
222
|
+
// loaded, or a customer who isn't a member - the last being what the server would refuse anyway,
|
|
223
|
+
// for want of a link token to sign with.
|
|
224
|
+
requireCustomerOfBusiness() {
|
|
225
|
+
const businessId = this.requireBusinessBeingBrowsed();
|
|
226
|
+
const profile = this._profile.value;
|
|
227
|
+
const user = profile?.user ?? null;
|
|
228
|
+
if (profile === null || user === null)
|
|
229
|
+
throw new IllegalOperationException("No profile has been read, so who is writing is not known");
|
|
230
|
+
const businessLink = profile.getBusinessLinkFor(businessId);
|
|
231
|
+
if (businessLink === null)
|
|
232
|
+
throw new IllegalOperationException(`This customer is not a member of business ${businessId}, so they may not write to it`);
|
|
233
|
+
return {
|
|
234
|
+
businessLink: businessLink,
|
|
235
|
+
containerName: Model.getBusinessContainerName(businessLink),
|
|
236
|
+
senderId: user.id,
|
|
237
|
+
addresses: user.addresses,
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
// Where an order is delivered: an id into this customer's own address list (the same list
|
|
241
|
+
// Order.deliveryAddressId resolves against) or a full address sent directly. An id not in that
|
|
242
|
+
// list would leave the business holding an order it can't read, so it's refused here rather than
|
|
243
|
+
// sent; a full address carries everything it needs and so isn't checked against the list.
|
|
244
|
+
static requireDeliveryAddress(addresses, fulfillmentMode, deliveryAddressId, deliveryAddress) {
|
|
245
|
+
if (deliveryAddress !== null)
|
|
246
|
+
return;
|
|
247
|
+
if (deliveryAddressId === BigInt(proto_Constants.ENTITY_ID_NULL)) {
|
|
248
|
+
// Pick-up is the case that names neither, which is normal - see Order.deliveryAddressId
|
|
249
|
+
if (fulfillmentMode === proto_FulfillmentMode.DELIVERY)
|
|
250
|
+
throw new InvalidArgumentException("An order to be delivered has to name a delivery address");
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
if (addresses.getAddress(deliveryAddressId) === null)
|
|
254
|
+
throw new InvalidArgumentException(`This customer has no address ${deliveryAddressId}, so an order cannot be delivered to it`);
|
|
255
|
+
}
|
|
256
|
+
// #endregion
|
|
257
|
+
// #region The four calls of Service, each as this model makes it
|
|
258
|
+
//
|
|
259
|
+
// Each is complete in itself: it pages, caches every page as it arrives, files what the server
|
|
260
|
+
// issued, and publishes what the cache then holds.
|
|
261
|
+
// Reads in the business being browsed. An anonymous visitor reads it straight, under its own
|
|
262
|
+
// businessId. A signed-in customer reads it under their own container instead - see
|
|
263
|
+
// getBusinessContainerName - joining as a customer first if not already linked - see
|
|
264
|
+
// requireBusinessLink.
|
|
265
|
+
async businessSyncIn() {
|
|
266
|
+
const businessId = this._businessId;
|
|
267
|
+
if (businessId === null || businessId.length === 0)
|
|
268
|
+
throw new IllegalOperationException("No business is being browsed, so there is nothing to read");
|
|
269
|
+
const profile = this._profile.value;
|
|
270
|
+
if (profile === null) {
|
|
271
|
+
await this.syncAnonymousBusiness(businessId);
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
const link = await this.requireBusinessLink(businessId, profile);
|
|
275
|
+
const cache = Model.getCache();
|
|
276
|
+
const containerName = Model.getBusinessContainerName(link);
|
|
277
|
+
// Seeded from the plain businessId container's cache, the one time this customer's own
|
|
278
|
+
// container does not exist yet - sparing a full redownload of a business already browsed, and
|
|
279
|
+
// carrying over the cart of the visit before this sign in.
|
|
280
|
+
if ((await cache.readBusiness(containerName)) === null) {
|
|
281
|
+
const anonymousName = ContainerNames.forBusiness(businessId);
|
|
282
|
+
const anonymous = await cache.readBusiness(anonymousName);
|
|
283
|
+
if (anonymous !== null) {
|
|
284
|
+
await cache.writeBusiness(containerName, anonymous);
|
|
285
|
+
// That cart now belongs to this membership, so the copy left behind is dropped. The plain
|
|
286
|
+
// container is what the next anonymous visitor reads, and what the next customer to sign in
|
|
287
|
+
// is seeded from, and a cart is one customer's. Deleted after the copy and not before, so a
|
|
288
|
+
// failed copy leaves the cart where it was rather than losing it.
|
|
289
|
+
await cache.deleteCart(anonymousName);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
const authTokens = await this.requireBusinessAuthTokens(containerName, link);
|
|
293
|
+
await this.syncBusinessContainer(containerName, businessId, ProtoMap.buildAuthTokens(authTokens));
|
|
294
|
+
}
|
|
295
|
+
// The plain businessId-named container: an anonymous read, before signing in or joining, or to
|
|
296
|
+
// learn a business's entity id while joining - see readJoinBusinessId.
|
|
297
|
+
async syncAnonymousBusiness(businessId) {
|
|
298
|
+
await this.syncBusinessContainer(ContainerNames.forBusiness(businessId), businessId, null);
|
|
299
|
+
}
|
|
300
|
+
// One business, cache first: what's already held publishes before the network is touched, so a
|
|
301
|
+
// returning customer sees the shop at once instead of a loading state. Cursors start from
|
|
302
|
+
// whatever containerName already holds, not zero, so a repeat call asks only what changed - see
|
|
303
|
+
// ProtoMap.buildBusinessSyncOutRequestFromCache.
|
|
304
|
+
async syncBusinessContainer(containerName, businessId, firstAuthTokens) {
|
|
305
|
+
const cache = Model.getCache();
|
|
306
|
+
const cached = await cache.readBusiness(containerName);
|
|
307
|
+
this.publishBusiness(cached);
|
|
308
|
+
let request = ProtoMap.buildBusinessSyncOutRequestFromCache(businessId, cached);
|
|
309
|
+
let authTokens = firstAuthTokens;
|
|
310
|
+
// What the store accepted - blobs stored plus ids deleted - which decides the publish at the end
|
|
311
|
+
let changeCount = 0;
|
|
312
|
+
for (;;) {
|
|
313
|
+
const page = await Model.getService().businessSyncOut(request, authTokens);
|
|
314
|
+
const issued = ProtoMap.getResponseAuthTokens(page);
|
|
315
|
+
// Written as it arrives, not at the end, so an interrupted read merely repeats itself instead
|
|
316
|
+
// of losing pages already received. Entities and tombstones go in together, one write per page.
|
|
317
|
+
changeCount += await cache.writeBusiness(containerName, page);
|
|
318
|
+
await this.storeIssuedTokens(issued, containerName);
|
|
319
|
+
// Null when this was the last page: fewer than a full page came back, or a full one advanced
|
|
320
|
+
// no cursor - which only a server breaking its own ordering can do
|
|
321
|
+
const next = ProtoMap.nextBusinessSyncOutRequest(request, page);
|
|
322
|
+
if (next === null)
|
|
323
|
+
break;
|
|
324
|
+
// The next page asks from where this one got to, signed with any token this one reissued
|
|
325
|
+
request = next;
|
|
326
|
+
authTokens = ProtoMap.refreshAuthTokens(authTokens, issued);
|
|
327
|
+
}
|
|
328
|
+
// A read the store took nothing from re-renders nobody - already published above from cache,
|
|
329
|
+
// and nothing here changes what a later read of this container answers
|
|
330
|
+
if (changeCount === 0)
|
|
331
|
+
return;
|
|
332
|
+
this.publishBusiness(await cache.readBusiness(containerName));
|
|
333
|
+
}
|
|
334
|
+
// The BusinessLink of this profile for the named business, joining as a customer first (once) if
|
|
335
|
+
// not linked yet. A business in the profile with no matching link is a server inconsistency, not
|
|
336
|
+
// something a join would fix, so that case throws directly.
|
|
337
|
+
async requireBusinessLink(businessId, profile) {
|
|
338
|
+
if (profile.user === null)
|
|
339
|
+
throw new IllegalOperationException("No profile has been read, so who is browsing is not known");
|
|
340
|
+
const link = Model.findBusinessLink(profile, businessId);
|
|
341
|
+
if (link !== null)
|
|
342
|
+
return link;
|
|
343
|
+
if (Model.findBusinessEntity(profile, businessId) !== null)
|
|
344
|
+
throw new InternalServerException(`This customer has no BusinessLink to business ${businessId}`);
|
|
345
|
+
const joinBusinessId = await this.readJoinBusinessId(businessId);
|
|
346
|
+
await this.profileSyncIn(ProtoMap.buildProfileSyncOutRequestFromCache(await Model.getCache().readProfile(this.requireProfileContainerName()), joinBusinessId), await this.requireProfileAuthTokens());
|
|
347
|
+
// Attempted once: a join that still leaves no link is the server contradicting itself, not
|
|
348
|
+
// something retrying would fix.
|
|
349
|
+
const joined = this._profile.value;
|
|
350
|
+
const joinedLink = joined === null ? null : Model.findBusinessLink(joined, businessId);
|
|
351
|
+
if (joinedLink === null)
|
|
352
|
+
throw new InternalServerException(`Joining business ${businessId} did not leave this customer's profile naming it`);
|
|
353
|
+
return joinedLink;
|
|
354
|
+
}
|
|
355
|
+
// The entity id to join, for a business this profile doesn't list yet. Read off the Business
|
|
356
|
+
// already held, if an anonymous visit left one behind, or off a fresh anonymous read otherwise.
|
|
357
|
+
async readJoinBusinessId(businessId) {
|
|
358
|
+
const alreadyHeld = this._business.value;
|
|
359
|
+
if (alreadyHeld !== null)
|
|
360
|
+
return alreadyHeld.id;
|
|
361
|
+
await this.syncAnonymousBusiness(businessId);
|
|
362
|
+
const business = this._business.value;
|
|
363
|
+
if (business === null)
|
|
364
|
+
throw new InternalServerException(`Business ${businessId} does not exist`);
|
|
365
|
+
return business.id;
|
|
366
|
+
}
|
|
367
|
+
static findBusinessEntity(profile, businessId) {
|
|
368
|
+
for (const business of profile.businesses)
|
|
369
|
+
if (business.businessId === businessId)
|
|
370
|
+
return business;
|
|
371
|
+
return null;
|
|
372
|
+
}
|
|
373
|
+
// Null unless both the business and a link naming this profile's own user are present - a
|
|
374
|
+
// membership is both of those together, not either alone.
|
|
375
|
+
static findBusinessLink(profile, businessId) {
|
|
376
|
+
const user = profile.user;
|
|
377
|
+
const businessEntity = Model.findBusinessEntity(profile, businessId);
|
|
378
|
+
if (user === null || businessEntity === null)
|
|
379
|
+
return null;
|
|
380
|
+
for (const link of profile.businessLinks)
|
|
381
|
+
if (link.businessId === businessEntity.id && link.linkedEntityId === user.id)
|
|
382
|
+
return link;
|
|
383
|
+
return null;
|
|
384
|
+
}
|
|
385
|
+
// Where this customer's own copy of a business lives, named for the BusinessLink rather than
|
|
386
|
+
// Business.businessId so a signed-in customer's data never mixes with an anonymous visit to the
|
|
387
|
+
// same storefront. Hex for the same reason as Ids.toStorageHexString: a fixed-width,
|
|
388
|
+
// case-sensitive name.
|
|
389
|
+
static getBusinessContainerName(link) {
|
|
390
|
+
return ContainerNames.forBusiness(Ids.toStorageHexString(link.id));
|
|
391
|
+
}
|
|
392
|
+
// The credentials that sign a call about a customer's own business container. The authentication
|
|
393
|
+
// token always comes from memory, never a cache read, for the same reason buildProfileAuthTokens
|
|
394
|
+
// reads it that way: a stored AuthTokens never persists it.
|
|
395
|
+
// The user token and link token are read independently off whatever this container has reissued,
|
|
396
|
+
// rather than as one object - storeIssuedTokens overwrites the whole blob per call, so trusting
|
|
397
|
+
// only that would lose whichever of the two a call didn't reissue. Each falls back to its own
|
|
398
|
+
// source instead: the profile container's user token, and this link's own linkToken - exactly
|
|
399
|
+
// what a fresh membership lacks, and what the write below now gives this container to reissue
|
|
400
|
+
// over. A link with no linkToken at all could never sign a call, so that is a server
|
|
401
|
+
// inconsistency, thrown rather than sent unsigned.
|
|
402
|
+
async requireBusinessAuthTokens(containerName, link) {
|
|
403
|
+
const authenticationMethod = this._authenticationMethod;
|
|
404
|
+
const authenticationToken = this._authenticationToken;
|
|
405
|
+
if (authenticationMethod === null || authenticationToken === null)
|
|
406
|
+
throw new IllegalOperationException("Nobody is signed in, so this call cannot be made");
|
|
407
|
+
const cache = Model.getCache();
|
|
408
|
+
const issuedHere = await cache.readAuthTokens(containerName);
|
|
409
|
+
const linkToken = issuedHere?.linkToken ?? link.linkToken;
|
|
410
|
+
if (linkToken === null)
|
|
411
|
+
throw new InternalServerException(`BusinessLink ${link.id} carries no linkToken, so it cannot sign a call`);
|
|
412
|
+
const profileContainerName = this.getProfileContainerName();
|
|
413
|
+
const userToken = issuedHere?.userToken ??
|
|
414
|
+
(profileContainerName === null
|
|
415
|
+
? null
|
|
416
|
+
: ((await cache.readAuthTokens(profileContainerName))?.userToken ?? null));
|
|
417
|
+
const authTokens = new AuthTokens(authenticationMethod, authenticationToken, userToken, linkToken);
|
|
418
|
+
if (issuedHere === null)
|
|
419
|
+
await cache.writeAuthTokens(containerName, authTokens);
|
|
420
|
+
return authTokens;
|
|
421
|
+
}
|
|
422
|
+
// The profile of whoever is signed in, paged the same way. Everything it earns files in the
|
|
423
|
+
// profile container - this call is entirely about the caller, so its AuthTokens never carries a
|
|
424
|
+
// link token; the only linkToken the protocol sends travels inside a BusinessLink, stored as part
|
|
425
|
+
// of the profile.
|
|
426
|
+
async profileSyncIn(firstRequest, firstAuthTokens) {
|
|
427
|
+
const containerName = this.getProfileContainerName();
|
|
428
|
+
if (containerName === null)
|
|
429
|
+
return null;
|
|
430
|
+
const cached = await Model.getCache().readProfile(containerName);
|
|
431
|
+
this.publishProfile(cached);
|
|
432
|
+
let request = firstRequest;
|
|
433
|
+
let authTokens = firstAuthTokens;
|
|
434
|
+
let changeCount = 0;
|
|
435
|
+
// Tracked across every page, not just the first: a profile long enough to page could put the
|
|
436
|
+
// joined link and business on different pages, and two flags cost nothing next to relying on that.
|
|
437
|
+
const joinBusinessId = firstRequest.joinBusinessId;
|
|
438
|
+
let hasJoinedLink = false;
|
|
439
|
+
let hasJoinedBusiness = false;
|
|
440
|
+
for (;;) {
|
|
441
|
+
const page = await Model.getService().profileSyncOut(request, authTokens);
|
|
442
|
+
changeCount += await this.cacheProfileResponse(containerName, page);
|
|
443
|
+
if (ProtoMap.hasJoinedBusinessLink(joinBusinessId, page))
|
|
444
|
+
hasJoinedLink = true;
|
|
445
|
+
if (ProtoMap.hasJoinedBusiness(joinBusinessId, page))
|
|
446
|
+
hasJoinedBusiness = true;
|
|
447
|
+
const next = ProtoMap.nextProfileSyncOutRequest(request, page);
|
|
448
|
+
if (next === null)
|
|
449
|
+
break;
|
|
450
|
+
request = next;
|
|
451
|
+
// Each call after the first is signed with whatever the page before it reissued - a read of
|
|
452
|
+
// many pages is where a token can fall due part way through
|
|
453
|
+
authTokens = ProtoMap.refreshAuthTokens(authTokens, ProtoMap.getResponseAuthTokens(page));
|
|
454
|
+
}
|
|
455
|
+
// As above: nothing the store took, nothing published
|
|
456
|
+
let response = cached;
|
|
457
|
+
if (changeCount !== 0) {
|
|
458
|
+
response = await Model.getCache().readProfile(containerName);
|
|
459
|
+
this.publishProfile(response);
|
|
460
|
+
}
|
|
461
|
+
// Last, so everything that arrived is cached and published first: those pages weren't at
|
|
462
|
+
// fault, the join was
|
|
463
|
+
Model.requireJoinedBusiness(joinBusinessId, hasJoinedLink, hasJoinedBusiness);
|
|
464
|
+
return response;
|
|
465
|
+
}
|
|
466
|
+
// The sign-up call. Never paged: resending it would create a second user, and a caller signing up
|
|
467
|
+
// has at most their new User, one BusinessLink and the Business it names. It can join a business
|
|
468
|
+
// in the same call, and a join's requirements are the same as on a profile read - one response
|
|
469
|
+
// instead of several is the only difference.
|
|
470
|
+
async createProfileOnServer(request, authTokens) {
|
|
471
|
+
const response = await this.writeProfileResponse(await Model.getService().createUser(request, authTokens));
|
|
472
|
+
const joinBusinessId = request.joinBusinessId;
|
|
473
|
+
// Raised after the response is cached and published, as on a profile read - sharper here since
|
|
474
|
+
// the User exists regardless of the join, so discarding this response and signing up again
|
|
475
|
+
// would create a second one
|
|
476
|
+
Model.requireJoinedBusiness(joinBusinessId, ProtoMap.hasJoinedBusinessLink(joinBusinessId, response), ProtoMap.hasJoinedBusiness(joinBusinessId, response));
|
|
477
|
+
return response;
|
|
478
|
+
}
|
|
479
|
+
// #endregion
|
|
480
|
+
// #region Driving one call
|
|
481
|
+
// A join that was asked for but not answered. Shared by both calls that can carry a
|
|
482
|
+
// joinBusinessId, since what a join must return doesn't depend on which one asked: the
|
|
483
|
+
// BusinessLink (the membership) and the Business it names. Both are needed to reach a business -
|
|
484
|
+
// the link for its token, the Business for the url-friendly id everything else is built from - so
|
|
485
|
+
// either one missing leaves the caller told it's a customer of something it can't reach.
|
|
486
|
+
//
|
|
487
|
+
// A ServiceException, not one of Exceptions.ts: nothing the caller passed was wrong - the server
|
|
488
|
+
// answered 200 and left out what was asked for - so a caller can retry or carry on with what's
|
|
489
|
+
// cached. 500 is the closest code, and the one that says retrying is worth it.
|
|
490
|
+
//
|
|
491
|
+
// Only a joinBusinessId a server could act on is checked (Java Business.isValidBusinessId's test,
|
|
492
|
+
// which Constants.ENTITY_ID_NULL also fails, so "no join asked" needs no case of its own). An id
|
|
493
|
+
// that isn't a business entity id isn't a failure here: a server ignores it rather than refusing
|
|
494
|
+
// it (see THE MOCKS), and sending one is the caller's bug, not the server's.
|
|
495
|
+
static requireJoinedBusiness(joinBusinessId, hasBusinessLink, hasBusiness) {
|
|
496
|
+
if (Ids.getEntityTypeId(joinBusinessId) !== proto_Constants.BUSINESS_ENTITY_TYPE_ID)
|
|
497
|
+
return;
|
|
498
|
+
if (hasBusinessLink && hasBusiness)
|
|
499
|
+
return;
|
|
500
|
+
const missing = hasBusinessLink === hasBusiness
|
|
501
|
+
? "BusinessLink or Business"
|
|
502
|
+
: hasBusinessLink
|
|
503
|
+
? "Business"
|
|
504
|
+
: "BusinessLink";
|
|
505
|
+
throw new InternalServerException(`A join of business ${joinBusinessId} was answered with no ${missing}`);
|
|
506
|
+
}
|
|
507
|
+
// What every profile response leaves in the store: its entities cached, and whatever it reissued
|
|
508
|
+
// filed against the same container. Answers how much of it the store took, which is what decides
|
|
509
|
+
// whether anything is published. Shared by all three calls that receive one - each page of a read,
|
|
510
|
+
// a sign up and an update - so none of them can come to cache a response differently from the
|
|
511
|
+
// others, or file a token in a different place. Publishing is not in here: a read publishes once
|
|
512
|
+
// for a whole loop of these, and the other two once each.
|
|
513
|
+
async cacheProfileResponse(containerName, response) {
|
|
514
|
+
const changeCount = await Model.getCache().writeProfile(containerName, response);
|
|
515
|
+
await this.storeIssuedTokens(ProtoMap.getResponseAuthTokens(response), containerName);
|
|
516
|
+
return changeCount;
|
|
517
|
+
}
|
|
518
|
+
// What a non-sync-out profile call leaves behind: the response cached and published, same as a
|
|
519
|
+
// paged read, minus paging neither needs. Both calls that come through here - a sign up and an
|
|
520
|
+
// update - publish on exactly the rule a read keeps, which is what the store took. An update
|
|
521
|
+
// publishes nothing today only because the server puts no entities in that response: it answers
|
|
522
|
+
// with whatever it reissued and nothing else, the caller already knowing what it sent. The day it
|
|
523
|
+
// answers with the stored User - whose update number and updated time a caller cannot know - this
|
|
524
|
+
// publishes it with no change here.
|
|
525
|
+
async writeProfileResponse(response) {
|
|
526
|
+
const containerName = this.getProfileContainerName();
|
|
527
|
+
if (containerName === null)
|
|
528
|
+
return response;
|
|
529
|
+
if ((await this.cacheProfileResponse(containerName, response)) !== 0)
|
|
530
|
+
this.publishProfile(await Model.getCache().readProfile(containerName));
|
|
531
|
+
return response;
|
|
532
|
+
}
|
|
533
|
+
// Files what a call earned against the container that call was about: a profile call's tokens in
|
|
534
|
+
// the subject's profile container, a business call's in that business's container. Which call it
|
|
535
|
+
// was decides this, not which token arrived - a token carries nothing saying what it's about, so
|
|
536
|
+
// only the caller, which knows what it asked, can attribute one. A business sync out that reissues
|
|
537
|
+
// a user token still files that copy with the business it came back from.
|
|
538
|
+
// A profile call never carries a link token here - a membership's link token travels inside its
|
|
539
|
+
// BusinessLink entity instead, stored with the rest of the profile.
|
|
540
|
+
// A failed write is swallowed: the server issues another next call, so losing one costs a round
|
|
541
|
+
// trip, where surfacing it would turn a working read into a thrown one.
|
|
542
|
+
async storeIssuedTokens(issued, containerName) {
|
|
543
|
+
if (issued === undefined)
|
|
544
|
+
return;
|
|
545
|
+
try {
|
|
546
|
+
await Model.getCache().writeAuthTokens(containerName, ProtoMap.authTokensFromProto(issued));
|
|
547
|
+
}
|
|
548
|
+
catch {
|
|
549
|
+
// Swallowed - see the comment above
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
// #endregion
|
|
553
|
+
// #region Containers and credentials
|
|
554
|
+
// Where this subject's profile and tokens are cached, keyed by authentication method plus
|
|
555
|
+
// subject id since two identity providers could otherwise issue the same subject id. Null when
|
|
556
|
+
// signed out; several can coexist, so signing back in finds this subject's own profile.
|
|
557
|
+
getProfileContainerName() {
|
|
558
|
+
return this._authenticationMethod === null || this._subjectId === null
|
|
559
|
+
? null
|
|
560
|
+
: ContainerNames.forProfile(`${this._authenticationMethod}.${this._subjectId}`);
|
|
561
|
+
}
|
|
562
|
+
// The profile container name, when a profile is already known to exist - throws rather than
|
|
563
|
+
// silently building on a signed-out state the caller has already ruled out.
|
|
564
|
+
requireProfileContainerName() {
|
|
565
|
+
const containerName = this.getProfileContainerName();
|
|
566
|
+
if (containerName === null)
|
|
567
|
+
throw new IllegalOperationException("Nobody is signed in, so this call cannot be made");
|
|
568
|
+
return containerName;
|
|
569
|
+
}
|
|
570
|
+
// Signs a call about the caller alone, or null if signed out. Rereads the user token from the
|
|
571
|
+
// profile container each time, since a call can reissue it. Carries no linkToken - see
|
|
572
|
+
// requireBusinessAuthTokens for a business call.
|
|
573
|
+
async buildProfileAuthTokens() {
|
|
574
|
+
const authenticationMethod = this._authenticationMethod;
|
|
575
|
+
const authenticationToken = this._authenticationToken;
|
|
576
|
+
const profileContainerName = this.getProfileContainerName();
|
|
577
|
+
if (authenticationMethod === null ||
|
|
578
|
+
authenticationToken === null ||
|
|
579
|
+
profileContainerName === null)
|
|
580
|
+
return null;
|
|
581
|
+
const issued = await Model.getCache().readAuthTokens(profileContainerName);
|
|
582
|
+
return ProtoMap.buildAuthTokens(new AuthTokens(authenticationMethod, authenticationToken, issued?.userToken ?? null, null));
|
|
583
|
+
}
|
|
584
|
+
// Every profile-only call must be signed; a signed-out visitor cannot make one.
|
|
585
|
+
async requireProfileAuthTokens() {
|
|
586
|
+
const authTokens = await this.buildProfileAuthTokens();
|
|
587
|
+
if (authTokens === null)
|
|
588
|
+
throw new IllegalOperationException("Nobody is signed in, so this call cannot be made");
|
|
589
|
+
return authTokens;
|
|
590
|
+
}
|
|
591
|
+
// #endregion
|
|
592
|
+
// #region Publishing
|
|
593
|
+
//
|
|
594
|
+
// What a read publishes turns on how much the store accepted - blobs stored plus ids deleted,
|
|
595
|
+
// summed across every page - not on what the response carried. They differ because the store
|
|
596
|
+
// keeps a blob only when its eTag is later, so a response the server sent can still be rejected
|
|
597
|
+
// in full - a cursor that moved without its eTag moving is resent an entity already cached.
|
|
598
|
+
// Publishing that would re-render every observer over an identical value, which the count avoids,
|
|
599
|
+
// and nothing is lost: a response the store took nothing from can't change what a later read
|
|
600
|
+
// answers.
|
|
601
|
+
// All four calls use this, which is why an update publishes nothing - see writeProfileResponse.
|
|
602
|
+
publishBusiness(response) {
|
|
603
|
+
this._business.value =
|
|
604
|
+
response === null ? null : ProtoMap.buildBusiness(response);
|
|
605
|
+
}
|
|
606
|
+
publishProfile(response) {
|
|
607
|
+
this._profile.value =
|
|
608
|
+
response === null || this._subjectId === null
|
|
609
|
+
? null
|
|
610
|
+
: ProtoMap.buildProfile(this._subjectId, response);
|
|
611
|
+
}
|
|
612
|
+
// #endregion
|
|
613
|
+
static getService() {
|
|
614
|
+
const service = Model._service;
|
|
615
|
+
if (service === null)
|
|
616
|
+
throw new IllegalOperationException("Model.initialize has not been called");
|
|
617
|
+
return service;
|
|
618
|
+
}
|
|
619
|
+
static getCache() {
|
|
620
|
+
const cache = Model._cache;
|
|
621
|
+
if (cache === null)
|
|
622
|
+
throw new IllegalOperationException("Model.initialize has not been called");
|
|
623
|
+
return cache;
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
Model._service = null;
|
|
627
|
+
Model._cache = null;
|
|
628
|
+
Model._instance = null;
|
|
629
|
+
export default Model;
|