@peabody-soft/mbs-model-ts 1.12.0 → 1.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/Constants.d.ts +1 -0
- package/dist/Constants.js +3 -0
- package/dist/Exceptions.d.ts +5 -0
- package/dist/Exceptions.js +16 -2
- package/dist/InMemoryLock.d.ts +5 -0
- package/dist/InMemoryLock.js +20 -0
- package/dist/Lock.d.ts +3 -0
- package/dist/Lock.js +1 -0
- package/dist/Model.d.ts +14 -1
- package/dist/Model.js +154 -17
- package/dist/SyncCache.d.ts +3 -1
- package/dist/SyncCache.js +38 -14
- package/dist/index.d.ts +4 -2
- package/dist/index.js +7 -5
- package/package.json +1 -1
package/dist/Constants.d.ts
CHANGED
|
@@ -9,3 +9,4 @@ export declare function quantityFromScaledValue(scaledQuantity: number): number;
|
|
|
9
9
|
export declare function quantityToScaledValue(quantity: number): number;
|
|
10
10
|
export declare function taxRateFromScaledValue(scaledTaxRate: number): number;
|
|
11
11
|
export declare function taxRateToScaledValue(taxRate: number): number;
|
|
12
|
+
export declare const MAX_PROFILE_STALENESS_MS: number;
|
package/dist/Constants.js
CHANGED
|
@@ -44,3 +44,6 @@ export function taxRateFromScaledValue(scaledTaxRate) {
|
|
|
44
44
|
export function taxRateToScaledValue(taxRate) {
|
|
45
45
|
return Math.round(taxRate * (proto_Constants.TAX_RATE_FACTOR / 100));
|
|
46
46
|
}
|
|
47
|
+
// How long a signed-in customer's profile may go without a read before Model.refreshProfileIfStale
|
|
48
|
+
// reads it again. A profile changes only when the customer edits it, on this or another device.
|
|
49
|
+
export const MAX_PROFILE_STALENESS_MS = 60 * 60 * 1000;
|
package/dist/Exceptions.d.ts
CHANGED
|
@@ -2,3 +2,8 @@ export declare class InvalidArgumentException extends Error {
|
|
|
2
2
|
}
|
|
3
3
|
export declare class IllegalOperationException extends Error {
|
|
4
4
|
}
|
|
5
|
+
export declare class StoreCorruptException extends Error {
|
|
6
|
+
readonly containerName: string;
|
|
7
|
+
readonly reason: unknown;
|
|
8
|
+
constructor(containerName: string, reason: unknown);
|
|
9
|
+
}
|
package/dist/Exceptions.js
CHANGED
|
@@ -1,8 +1,22 @@
|
|
|
1
|
-
// The two programming errors this library throws
|
|
2
|
-
// failure is a rejected promise (see
|
|
1
|
+
// The two programming errors this library throws, and the exception for a store it cannot read. A
|
|
2
|
+
// UI does not recover from a programming error. A sync failure is a rejected promise (see
|
|
3
|
+
// ServiceExceptions.ts).
|
|
3
4
|
// A caller passed something this library cannot work with
|
|
4
5
|
export class InvalidArgumentException extends Error {
|
|
5
6
|
}
|
|
6
7
|
// A caller did something in an order this library does not allow
|
|
7
8
|
export class IllegalOperationException extends Error {
|
|
8
9
|
}
|
|
10
|
+
// The store holds data that cannot be decoded, for example a blob cut short or one written by an
|
|
11
|
+
// incompatible version. Everything in a store came from a server, except a cart that was not sent
|
|
12
|
+
// and the id of a sign up that is under way. So deleting the containers and calling
|
|
13
|
+
// Model.initialize again recovers: SyncCache keeps the containers it has opened, so a new
|
|
14
|
+
// Model.initialize is needed after a delete.
|
|
15
|
+
// containerName is the container that failed, and reason is what the decoder threw.
|
|
16
|
+
export class StoreCorruptException extends Error {
|
|
17
|
+
constructor(containerName, reason) {
|
|
18
|
+
super(`The store holds data in container ${containerName} that cannot be read`);
|
|
19
|
+
this.containerName = containerName;
|
|
20
|
+
this.reason = reason;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// A Lock that excludes only callers of this object, in order. For a test, or for a caller whose
|
|
2
|
+
// store is used by one tab.
|
|
3
|
+
export default class InMemoryLock {
|
|
4
|
+
constructor() {
|
|
5
|
+
this._tails = new Map();
|
|
6
|
+
}
|
|
7
|
+
runExclusive(key, action) {
|
|
8
|
+
const previous = this._tails.get(key) ?? Promise.resolve();
|
|
9
|
+
const result = previous.then(action);
|
|
10
|
+
// The next caller starts when this one settles, whether or not it failed
|
|
11
|
+
const tail = result.then(() => undefined, () => undefined);
|
|
12
|
+
this._tails.set(key, tail);
|
|
13
|
+
// Forget the key when nobody is waiting, so the map does not grow with every key used
|
|
14
|
+
void tail.then(() => {
|
|
15
|
+
if (this._tails.get(key) === tail)
|
|
16
|
+
this._tails.delete(key);
|
|
17
|
+
});
|
|
18
|
+
return result;
|
|
19
|
+
}
|
|
20
|
+
}
|
package/dist/Lock.d.ts
ADDED
package/dist/Lock.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/Model.d.ts
CHANGED
|
@@ -4,19 +4,22 @@ import Address from "./Address";
|
|
|
4
4
|
import Business from "./Business";
|
|
5
5
|
import Database from "./Database";
|
|
6
6
|
import LiveData from "./LiveData";
|
|
7
|
+
import Lock from "./Lock";
|
|
7
8
|
import Profile from "./Profile";
|
|
8
9
|
import Service from "./Service";
|
|
9
10
|
import ShoppingList from "./ShoppingList";
|
|
10
11
|
import UserProfile from "./UserProfile";
|
|
11
12
|
export default class Model {
|
|
12
13
|
private constructor();
|
|
13
|
-
static initialize(service: Service, database: Database): void;
|
|
14
|
+
static initialize(service: Service, database: Database, lock?: Lock): void;
|
|
14
15
|
static getInstance(): Model;
|
|
15
16
|
get business(): LiveData<Business | null>;
|
|
16
17
|
get profile(): LiveData<Profile | null>;
|
|
17
18
|
signIn(authenticationMethod: proto_AuthenticationMethod, subjectId: string, authenticationToken: string): Promise<void>;
|
|
18
19
|
createUser(authenticationMethod: proto_AuthenticationMethod, subjectId: string, authenticationToken: string, userToCreate: UserProfile): Promise<void>;
|
|
19
20
|
updateUser(userProfile: UserProfile): Promise<void>;
|
|
21
|
+
changeUser(change: (current: UserProfile) => UserProfile): Promise<void>;
|
|
22
|
+
refreshProfileIfStale(): Promise<boolean>;
|
|
20
23
|
signOut(): Promise<void>;
|
|
21
24
|
setBusinessId(businessId: string): Promise<void>;
|
|
22
25
|
updateCart(shoppingList: ShoppingList): Promise<void>;
|
|
@@ -41,6 +44,9 @@ export default class Model {
|
|
|
41
44
|
private static getBusinessContainerName;
|
|
42
45
|
private requireBusinessAuthTokens;
|
|
43
46
|
private profileSyncIn;
|
|
47
|
+
private syncProfile;
|
|
48
|
+
private readStoredUser;
|
|
49
|
+
private sendUserUpdate;
|
|
44
50
|
private createProfileOnServer;
|
|
45
51
|
private readProfilePages;
|
|
46
52
|
private static requireJoinedBusiness;
|
|
@@ -49,20 +55,27 @@ export default class Model {
|
|
|
49
55
|
private requireProfileContainerName;
|
|
50
56
|
private buildProfileAuthTokens;
|
|
51
57
|
private requireProfileAuthTokens;
|
|
58
|
+
private static getProfileCursorsKey;
|
|
52
59
|
private publishBusiness;
|
|
53
60
|
private publish;
|
|
54
61
|
private static buildPublishable;
|
|
55
62
|
private publishProfile;
|
|
63
|
+
private publishProfileFromStore;
|
|
64
|
+
private refreshProfile;
|
|
56
65
|
private static getService;
|
|
57
66
|
private static getCache;
|
|
58
67
|
private static _service;
|
|
59
68
|
private static _cache;
|
|
69
|
+
private static _lock;
|
|
60
70
|
private static _instance;
|
|
61
71
|
private _authenticationMethod;
|
|
62
72
|
private _subjectId;
|
|
63
73
|
private _authenticationToken;
|
|
64
74
|
private _businessId;
|
|
65
75
|
private _isPublishedBusinessVetted;
|
|
76
|
+
private _profileReadAt;
|
|
77
|
+
private _publishedProfileCursors;
|
|
78
|
+
private _profileRefresh;
|
|
66
79
|
private readonly _business;
|
|
67
80
|
private readonly _profile;
|
|
68
81
|
}
|
package/dist/Model.js
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import { Constants as proto_Constants } from "./generated/Constants";
|
|
2
2
|
import { FulfillmentMode as proto_FulfillmentMode } from "./generated/FulfillmentMode";
|
|
3
3
|
import AuthTokens from "./AuthTokens";
|
|
4
|
+
import { MAX_PROFILE_STALENESS_MS } from "./Constants";
|
|
4
5
|
import * as ContainerNames from "./ContainerNames";
|
|
5
6
|
import { IllegalOperationException, InvalidArgumentException } from "./Exceptions";
|
|
6
7
|
import Ids from "./Ids";
|
|
8
|
+
import InMemoryLock from "./InMemoryLock";
|
|
7
9
|
import LiveData from "./LiveData";
|
|
8
10
|
import { canonicalizeBusinessId } from "./Profile";
|
|
9
11
|
import * as ProtoMap from "./ProtoMap";
|
|
@@ -15,15 +17,26 @@ import SyncState from "./SyncState";
|
|
|
15
17
|
// The root of the object model, and the only object a UI needs. Call initialize once, then get the
|
|
16
18
|
// current instance with getInstance.
|
|
17
19
|
//
|
|
18
|
-
//
|
|
20
|
+
// CALLS ARE NOT QUEUED. A caller makes one call at a time and awaits it. Otherwise:
|
|
19
21
|
// - A setBusinessId still reading when signIn reads the business too can publish the anonymous
|
|
20
22
|
// container's business over the customer's. The published business then has no cart or orders,
|
|
21
23
|
// and nothing publishes them again.
|
|
22
|
-
// - Two reads at once interleave their pages and writes
|
|
23
|
-
//
|
|
24
|
-
//
|
|
25
|
-
//
|
|
26
|
-
//
|
|
24
|
+
// - Two reads at once interleave their pages and writes.
|
|
25
|
+
// A queue inside Model would need an unqueued twin of each public method (signIn calls signOut,
|
|
26
|
+
// businessSyncIn calls updateCart). A caller that calls from several places, like a UI, queues the
|
|
27
|
+
// calls itself. Only changeUser takes a Lock, so that two tabs do not start from the same user.
|
|
28
|
+
//
|
|
29
|
+
// SEVERAL TABS can use one store, each with its own Model. The store applies a batch atomically and
|
|
30
|
+
// keeps the greater eTag, so what the tabs store converges on what the server holds. A profile
|
|
31
|
+
// container is shared by every tab the customer has open, on any shop, so concurrent use matters
|
|
32
|
+
// more there than in a business container, which only tabs on the same shop share. A sequence of
|
|
33
|
+
// calls is not atomic across tabs:
|
|
34
|
+
// - Deleting a container (deleteBusiness, or an eviction by the store) while another tab writes
|
|
35
|
+
// pages to it can leave its cursors ahead of its entities.
|
|
36
|
+
// - The Business and Profile a Model publishes change only when that Model reads or writes. A
|
|
37
|
+
// cart or a user built from them can be older than what the store holds, and sending it replaces
|
|
38
|
+
// the newer one on the server. changeUser starts from the stored user, and a caller can read the
|
|
39
|
+
// profile again with refreshProfileIfStale.
|
|
27
40
|
class Model {
|
|
28
41
|
constructor() {
|
|
29
42
|
// True when the published business, if any, has been read to the end and found to be the one
|
|
@@ -32,6 +45,13 @@ class Model {
|
|
|
32
45
|
// failure, a rejection) leaves the business published and unvetted. See readJoinBusinessId, which
|
|
33
46
|
// does not join such a business.
|
|
34
47
|
this._isPublishedBusinessVetted = false;
|
|
48
|
+
// When the last read of the profile ended, as Date.now() gave it. Null before the first.
|
|
49
|
+
this._profileReadAt = null;
|
|
50
|
+
// The profile cursors the published profile was read at, from getProfileCursorsKey. Null until a
|
|
51
|
+
// profile is published from the store.
|
|
52
|
+
this._publishedProfileCursors = null;
|
|
53
|
+
// The read that refreshProfileIfStale is running, so an overlapping call can share it
|
|
54
|
+
this._profileRefresh = null;
|
|
35
55
|
this._business = new LiveData(null);
|
|
36
56
|
this._profile = new LiveData(null);
|
|
37
57
|
this._authenticationMethod = null;
|
|
@@ -40,10 +60,13 @@ class Model {
|
|
|
40
60
|
this._businessId = null;
|
|
41
61
|
}
|
|
42
62
|
// Sets the service and store to use. Discards the current instance, so nobody is signed in and no
|
|
43
|
-
// business is browsed.
|
|
44
|
-
|
|
63
|
+
// business is browsed. lock makes changeUser exclusive: pass one that spans every tab that shares
|
|
64
|
+
// the store (see Lock), or leave the default, which excludes calls in this tab only. A call still
|
|
65
|
+
// running on the discarded instance is not excluded against the new one.
|
|
66
|
+
static initialize(service, database, lock = new InMemoryLock()) {
|
|
45
67
|
Model._service = service;
|
|
46
68
|
Model._cache = new SyncCache(database);
|
|
69
|
+
Model._lock = lock;
|
|
47
70
|
Model._instance = null;
|
|
48
71
|
}
|
|
49
72
|
// The current model. Fetch it each time and do not keep it: signOut discards it.
|
|
@@ -135,12 +158,62 @@ class Model {
|
|
|
135
158
|
// is cached and published as a profile read is, so the User as the server stored it replaces the
|
|
136
159
|
// held one (see readProfilePages).
|
|
137
160
|
async updateUser(userProfile) {
|
|
161
|
+
if (this._profile.value === null)
|
|
162
|
+
throw new IllegalOperationException("Nobody is signed in, so this call cannot be made");
|
|
163
|
+
await this.sendUserUpdate(this.requireProfileContainerName(), userProfile);
|
|
164
|
+
}
|
|
165
|
+
// Changes this customer's own user. Reads the profile, calls change with the user the profile
|
|
166
|
+
// container stores, and sends the user change returns, as updateUser does. Use it instead of
|
|
167
|
+
// updateUser when the new user is the current one with something altered, for example an address
|
|
168
|
+
// added. The current user includes what other tabs and devices have written, which a user built
|
|
169
|
+
// from the published profile may lack, and sending that user would erase it.
|
|
170
|
+
// Calls for one profile run one at a time. When the Lock given to initialize spans tabs, a second
|
|
171
|
+
// tab that changes the user waits for this change and starts from its result. The server still
|
|
172
|
+
// takes the last write of two devices that change the user at once.
|
|
173
|
+
// change must return a user with the id it is given, and must not call changeUser, which would
|
|
174
|
+
// wait for its own lock. Throws IllegalOperationException if no profile has been read,
|
|
175
|
+
// InvalidArgumentException if change returns another id, and what change throws.
|
|
176
|
+
async changeUser(change) {
|
|
138
177
|
if (this._profile.value === null)
|
|
139
178
|
throw new IllegalOperationException("Nobody is signed in, so this call cannot be made");
|
|
140
179
|
const containerName = this.requireProfileContainerName();
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
180
|
+
await Model._lock.runExclusive(containerName, async () => {
|
|
181
|
+
await this.syncProfile(containerName);
|
|
182
|
+
const current = await this.readStoredUser(containerName);
|
|
183
|
+
if (current === null)
|
|
184
|
+
throw new IllegalOperationException("No user has been read, so there is none to change");
|
|
185
|
+
const changed = change(current);
|
|
186
|
+
if (changed.id !== current.id)
|
|
187
|
+
throw new InvalidArgumentException("A change of the user must keep the id of the user");
|
|
188
|
+
await this.sendUserUpdate(containerName, changed);
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
// Reads the profile if the last read of it is older than MAX_PROFILE_STALENESS_MS, and returns
|
|
192
|
+
// whether it did. Every read of the profile counts, a sign in and an update included. Does
|
|
193
|
+
// nothing when nobody is signed in or the first read is still under way. The read fetches only
|
|
194
|
+
// what changed, and calls that overlap share one read, so a UI can call it from every event that
|
|
195
|
+
// shows a tab again. The profile is published if it differs from what was published, including
|
|
196
|
+
// when another tab stored the answer first. Throws what a read throws, and a failed read leaves
|
|
197
|
+
// the profile stale, so the next call reads again. A clock set back counts as stale.
|
|
198
|
+
async refreshProfileIfStale() {
|
|
199
|
+
if (this._profile.value === null)
|
|
200
|
+
return false;
|
|
201
|
+
if (this._profileRefresh !== null)
|
|
202
|
+
return this._profileRefresh;
|
|
203
|
+
const readAt = this._profileReadAt;
|
|
204
|
+
if (readAt === null)
|
|
205
|
+
return false;
|
|
206
|
+
const age = Date.now() - readAt;
|
|
207
|
+
if (age >= 0 && age < MAX_PROFILE_STALENESS_MS)
|
|
208
|
+
return false;
|
|
209
|
+
const refresh = this.refreshProfile();
|
|
210
|
+
this._profileRefresh = refresh;
|
|
211
|
+
try {
|
|
212
|
+
return await refresh;
|
|
213
|
+
}
|
|
214
|
+
finally {
|
|
215
|
+
this._profileRefresh = null;
|
|
216
|
+
}
|
|
144
217
|
}
|
|
145
218
|
// Discards this model. The next getInstance() builds a fresh one, signed out and browsing nothing.
|
|
146
219
|
async signOut() {
|
|
@@ -585,9 +658,26 @@ class Model {
|
|
|
585
658
|
if (link !== null)
|
|
586
659
|
return link;
|
|
587
660
|
const joinBusinessId = await this.readJoinBusinessId(businessId);
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
661
|
+
const containerName = this.requireProfileContainerName();
|
|
662
|
+
const cache = Model.getCache();
|
|
663
|
+
try {
|
|
664
|
+
await this.profileSyncIn(ProtoMap.buildProfileSyncOutRequest(await cache.readProfileCursors(containerName), joinBusinessId), await this.requireProfileAuthTokens());
|
|
665
|
+
}
|
|
666
|
+
catch (error) {
|
|
667
|
+
if (!(error instanceof JoinNotAnsweredException))
|
|
668
|
+
throw error;
|
|
669
|
+
// The answer lacks the link and the business because the cursors are past them. Either
|
|
670
|
+
// another tab stored them first, and profileSyncIn has published them from the store, or the
|
|
671
|
+
// container lost them (it was cleared while a read was under way). In the second case the
|
|
672
|
+
// cursors are deleted and the profile is read again from the start, once.
|
|
673
|
+
const published = this._profile.value;
|
|
674
|
+
if (published === null || Model.findBusinessLink(published, businessId) === null) {
|
|
675
|
+
await cache.resetProfileCursors(containerName);
|
|
676
|
+
await this.profileSyncIn(ProtoMap.buildProfileSyncOutRequest(await cache.readProfileCursors(containerName), joinBusinessId), await this.requireProfileAuthTokens());
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
// A join that still leaves no link after the profile was read again from the start is the
|
|
680
|
+
// server contradicting itself, and retrying would not fix it.
|
|
591
681
|
const joined = this._profile.value;
|
|
592
682
|
const joinedLink = joined === null ? null : Model.findBusinessLink(joined, businessId);
|
|
593
683
|
if (joinedLink === null)
|
|
@@ -677,9 +767,30 @@ class Model {
|
|
|
677
767
|
const containerName = this.getProfileContainerName();
|
|
678
768
|
if (containerName === null)
|
|
679
769
|
return;
|
|
680
|
-
|
|
770
|
+
await this.publishProfileFromStore(containerName);
|
|
681
771
|
await this.readProfilePages(containerName, firstRequest, firstAuthTokens, () => Model.getService().profileSyncOut(firstRequest, firstAuthTokens));
|
|
682
772
|
}
|
|
773
|
+
// Reads what changed in the profile since the cursors the container holds, as a sign in does, but
|
|
774
|
+
// without publishing the stored profile first.
|
|
775
|
+
async syncProfile(containerName) {
|
|
776
|
+
const request = ProtoMap.buildProfileSyncOutRequest(await Model.getCache().readProfileCursors(containerName));
|
|
777
|
+
const authTokens = await this.requireProfileAuthTokens();
|
|
778
|
+
await this.readProfilePages(containerName, request, authTokens, () => Model.getService().profileSyncOut(request, authTokens));
|
|
779
|
+
}
|
|
780
|
+
// The user the profile container stores, or null when it holds none
|
|
781
|
+
async readStoredUser(containerName) {
|
|
782
|
+
const stored = await Model.getCache().readProfile(containerName);
|
|
783
|
+
if (stored === null || this._subjectId === null)
|
|
784
|
+
return null;
|
|
785
|
+
return ProtoMap.buildProfile(this._subjectId, stored)?.user ?? null;
|
|
786
|
+
}
|
|
787
|
+
// Sends user over the one the server has, and stores and publishes the answer, which is the first
|
|
788
|
+
// page of a profile read.
|
|
789
|
+
async sendUserUpdate(containerName, user) {
|
|
790
|
+
const request = ProtoMap.buildUserRequest(ProtoMap.buildUpdateUser(user), await Model.getCache().readProfileCursors(containerName));
|
|
791
|
+
const authTokens = await this.requireProfileAuthTokens();
|
|
792
|
+
await this.readProfilePages(containerName, ProtoMap.getUserRequestSyncOutRequest(request), authTokens, () => Model.getService().updateUser(request, authTokens));
|
|
793
|
+
}
|
|
683
794
|
// The sign-up call. Its answer is the first page of a profile read, which readProfilePages
|
|
684
795
|
// continues if the page is full. The id saved for the sign up is deleted once that page is stored.
|
|
685
796
|
async createProfileOnServer(request, authTokens) {
|
|
@@ -725,8 +836,9 @@ class Model {
|
|
|
725
836
|
authTokens = ProtoMap.refreshAuthTokens(authTokens, ProtoMap.getResponseAuthTokens(page));
|
|
726
837
|
page = await Model.getService().profileSyncOut(request, authTokens);
|
|
727
838
|
}
|
|
839
|
+
this._profileReadAt = Date.now();
|
|
728
840
|
if (changeCount !== 0)
|
|
729
|
-
|
|
841
|
+
await this.publishProfileFromStore(containerName);
|
|
730
842
|
// After a sign up the User exists whatever became of the join, so what came back is kept and
|
|
731
843
|
// published before this throws
|
|
732
844
|
Model.requireJoinedBusiness(joinBusinessId, hasJoinedLink, hasJoinedBusiness);
|
|
@@ -752,7 +864,7 @@ class Model {
|
|
|
752
864
|
: hasBusinessLink
|
|
753
865
|
? "Business"
|
|
754
866
|
: "BusinessLink";
|
|
755
|
-
throw new
|
|
867
|
+
throw new JoinNotAnsweredException(`A join of business ${joinBusinessId} was answered with no ${missing}`);
|
|
756
868
|
}
|
|
757
869
|
// Stores the tokens a call received in the container the call was about: a profile call's tokens
|
|
758
870
|
// in the subject's profile container, a business call's in that business's container. The caller
|
|
@@ -825,6 +937,9 @@ class Model {
|
|
|
825
937
|
// be joined to it. A read that finds the published business is no longer open, or that the server
|
|
826
938
|
// rejects, publishes null in its place. A business published from the cache counts as open only
|
|
827
939
|
// once the read has ended (see _isPublishedBusinessVetted, which readJoinBusinessId checks).
|
|
940
|
+
static getProfileCursorsKey(cursors) {
|
|
941
|
+
return SyncKeys.PROFILE_SYNC_IN.map((key) => cursors.getUpdateNumber(key)).join(",");
|
|
942
|
+
}
|
|
828
943
|
publishBusiness(response) {
|
|
829
944
|
this.publish(Model.buildPublishable(response));
|
|
830
945
|
}
|
|
@@ -843,6 +958,23 @@ class Model {
|
|
|
843
958
|
? null
|
|
844
959
|
: ProtoMap.buildProfile(this._subjectId, response);
|
|
845
960
|
}
|
|
961
|
+
// Publishes what the profile container stores, and notes the cursors it was read at, so that
|
|
962
|
+
// refreshProfileIfStale can tell whether the store has moved since.
|
|
963
|
+
async publishProfileFromStore(containerName) {
|
|
964
|
+
const cache = Model.getCache();
|
|
965
|
+
const cursorsKey = Model.getProfileCursorsKey(await cache.readProfileCursors(containerName));
|
|
966
|
+
this.publishProfile(await cache.readProfile(containerName));
|
|
967
|
+
this._publishedProfileCursors = cursorsKey;
|
|
968
|
+
}
|
|
969
|
+
// The read behind refreshProfileIfStale
|
|
970
|
+
async refreshProfile() {
|
|
971
|
+
const containerName = this.requireProfileContainerName();
|
|
972
|
+
await this.syncProfile(containerName);
|
|
973
|
+
const cursors = await Model.getCache().readProfileCursors(containerName);
|
|
974
|
+
if (Model.getProfileCursorsKey(cursors) !== this._publishedProfileCursors)
|
|
975
|
+
await this.publishProfileFromStore(containerName);
|
|
976
|
+
return true;
|
|
977
|
+
}
|
|
846
978
|
// #endregion
|
|
847
979
|
static getService() {
|
|
848
980
|
const service = Model._service;
|
|
@@ -859,5 +991,10 @@ class Model {
|
|
|
859
991
|
}
|
|
860
992
|
Model._service = null;
|
|
861
993
|
Model._cache = null;
|
|
994
|
+
Model._lock = new InMemoryLock();
|
|
862
995
|
Model._instance = null;
|
|
863
996
|
export default Model;
|
|
997
|
+
// A join the server answered with no link or business. It is a 500 like any other fault of the
|
|
998
|
+
// server. requireBusinessLink catches it to read the profile again from the start.
|
|
999
|
+
class JoinNotAnsweredException extends InternalServerException {
|
|
1000
|
+
}
|
package/dist/SyncCache.d.ts
CHANGED
|
@@ -15,6 +15,7 @@ export default class SyncCache {
|
|
|
15
15
|
readProfile(containerName: string): Promise<proto_ProfileSyncOutResponse | null>;
|
|
16
16
|
readDeletedProfile(containerName: string): Promise<proto_ProfileSyncOutResponse | null>;
|
|
17
17
|
readProfileCursors(containerName: string): Promise<SyncState>;
|
|
18
|
+
resetProfileCursors(containerName: string): Promise<void>;
|
|
18
19
|
writeProfile(containerName: string, response: proto_ProfileSyncOutResponse): Promise<number>;
|
|
19
20
|
readAuthTokens(containerName: string): Promise<AuthTokens | null>;
|
|
20
21
|
writeAuthTokens(containerName: string, authTokens: AuthTokens): Promise<void>;
|
|
@@ -30,7 +31,8 @@ export default class SyncCache {
|
|
|
30
31
|
deleteBusiness(containerName: string): Promise<void>;
|
|
31
32
|
deletePartyPreferences(containerName: string): Promise<number>;
|
|
32
33
|
writeMessages(containerName: string, messages: ReadonlyArray<proto_Message>): Promise<number>;
|
|
33
|
-
private
|
|
34
|
+
private readDecoded;
|
|
35
|
+
private decode;
|
|
34
36
|
private writeOwnMessage;
|
|
35
37
|
private writeBlobs;
|
|
36
38
|
private write;
|
package/dist/SyncCache.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { Constants as proto_Constants } from "./generated/Constants";
|
|
2
|
+
import { StoreCorruptException } from "./Exceptions";
|
|
2
3
|
import Ids from "./Ids";
|
|
3
4
|
import * as ProtoMap from "./ProtoMap";
|
|
4
5
|
import * as SyncKeys from "./SyncKeys";
|
|
@@ -16,7 +17,8 @@ import * as SyncKeys from "./SyncKeys";
|
|
|
16
17
|
// readOrders and readPartyPreferences leave them out.
|
|
17
18
|
// - A cursor is a blob per entity type (its eTag is the cursor). It is not derived from the
|
|
18
19
|
// entities, because tombstones and this client's own changes would move it. It rises only with
|
|
19
|
-
// a page of a read, in that page's batch, and the store keeps the greater.
|
|
20
|
+
// a page of a read, in that page's batch, and the store keeps the greater. Only
|
|
21
|
+
// resetProfileCursors lowers one.
|
|
20
22
|
export default class SyncCache {
|
|
21
23
|
constructor(_database) {
|
|
22
24
|
this._database = _database;
|
|
@@ -26,7 +28,7 @@ export default class SyncCache {
|
|
|
26
28
|
// written. Tombstones are included as sent: a deleted entity carries what it was, or only its header
|
|
27
29
|
// for an item. Code that builds on this must leave tombstones out, except for the order of a message.
|
|
28
30
|
async readBusiness(containerName) {
|
|
29
|
-
return
|
|
31
|
+
return this.readDecoded(containerName, ProtoMap.businessResponseFromBlobs);
|
|
30
32
|
}
|
|
31
33
|
// The cursors the next read sends: the highest update number of each entity type that a page of a
|
|
32
34
|
// read brought, tombstones included, or zero for a type none has brought. A page is a response to a
|
|
@@ -53,19 +55,28 @@ export default class SyncCache {
|
|
|
53
55
|
// The tombstones cached for one business, as a response, each as stored and as the server sent it.
|
|
54
56
|
// Null when there are none. The entities a deleted entity owned are tombstones too.
|
|
55
57
|
async readDeletedBusiness(containerName) {
|
|
56
|
-
return
|
|
58
|
+
return this.readDecoded(containerName, ProtoMap.deletedBusinessResponseFromBlobs);
|
|
57
59
|
}
|
|
58
60
|
// The same for one profile: the customer, the businesses they may reach, and the link to each.
|
|
59
61
|
async readProfile(containerName) {
|
|
60
|
-
return
|
|
62
|
+
return this.readDecoded(containerName, ProtoMap.profileResponseFromBlobs);
|
|
61
63
|
}
|
|
62
64
|
// The tombstones of a profile, as readDeletedBusiness returns those of a business.
|
|
63
65
|
async readDeletedProfile(containerName) {
|
|
64
|
-
return
|
|
66
|
+
return this.readDecoded(containerName, ProtoMap.deletedProfileResponseFromBlobs);
|
|
65
67
|
}
|
|
66
68
|
async readProfileCursors(containerName) {
|
|
67
69
|
return this.readCursors(containerName, SyncKeys.PROFILE_SYNC_IN);
|
|
68
70
|
}
|
|
71
|
+
// Deletes the profile cursors, so the next read asks for the whole profile. Use it when the
|
|
72
|
+
// container lacks an entity that its cursors are past, which no read would send again. The
|
|
73
|
+
// entities, tokens and saved user id stay, and a repeated entity changes nothing.
|
|
74
|
+
async resetProfileCursors(containerName) {
|
|
75
|
+
await this.write(containerName, {
|
|
76
|
+
blobs: NO_BLOBS,
|
|
77
|
+
deletedIds: SyncKeys.PROFILE_SYNC_IN.map((key) => Ids.getSyncCursorBlobId(key)),
|
|
78
|
+
});
|
|
79
|
+
}
|
|
69
80
|
// One page of a profile read, or the answer to a sign up or an update, which is the first page of
|
|
70
81
|
// one. See writeBusiness.
|
|
71
82
|
async writeProfile(containerName, response) {
|
|
@@ -77,7 +88,7 @@ export default class SyncCache {
|
|
|
77
88
|
const blob = await (await this.getContainer(containerName)).read(Ids.AUTH_TOKENS_BLOB_ID);
|
|
78
89
|
if (blob === null)
|
|
79
90
|
return null;
|
|
80
|
-
return ProtoMap.authTokensFromBytes(blob.bytes);
|
|
91
|
+
return this.decode(containerName, () => ProtoMap.authTokensFromBytes(blob.bytes));
|
|
81
92
|
}
|
|
82
93
|
// Written without comparison, unlike everything else here: a reissued token is not a later version of
|
|
83
94
|
// the old one, so the last one written wins. Stores only what this server issued - see
|
|
@@ -91,7 +102,7 @@ export default class SyncCache {
|
|
|
91
102
|
const blob = await (await this.getContainer(containerName)).read(Ids.USER_ID_BLOB_ID);
|
|
92
103
|
if (blob === null)
|
|
93
104
|
return null;
|
|
94
|
-
return ProtoMap.userIdFromBytes(blob.bytes);
|
|
105
|
+
return this.decode(containerName, () => ProtoMap.userIdFromBytes(blob.bytes));
|
|
95
106
|
}
|
|
96
107
|
// Saves the id before the sign up is sent, and it stays until deleteUserId deletes it. It replaces
|
|
97
108
|
// an id saved before.
|
|
@@ -107,14 +118,14 @@ export default class SyncCache {
|
|
|
107
118
|
// are deleted messages, which readDeletedOrders returns. Nothing here records which caller that was
|
|
108
119
|
// or detects a change of caller. See THE MESSAGES OF A BUSINESS ARE NOT PUBLIC.
|
|
109
120
|
async readOrders(containerName) {
|
|
110
|
-
return
|
|
121
|
+
return this.readDecoded(containerName, ProtoMap.orderMessagesFromBlobs);
|
|
111
122
|
}
|
|
112
123
|
// The orders the business has completed, by fulfilling or cancelling. The merchant app completes an
|
|
113
124
|
// order by deleting its message, so each is a tombstone. A server sends these whole, because the
|
|
114
125
|
// customer is shown the order afterwards. Each is returned as stored and as sent. A tombstone with
|
|
115
126
|
// no order in it is not included.
|
|
116
127
|
async readDeletedOrders(containerName) {
|
|
117
|
-
return
|
|
128
|
+
return this.readDecoded(containerName, ProtoMap.deletedOrderMessagesFromBlobs);
|
|
118
129
|
}
|
|
119
130
|
// The cursor a sync in sends: how far reads of this container have got with this customer's
|
|
120
131
|
// messages, party preferences and orders alike. It is the cursor the next read sends. The answer
|
|
@@ -131,7 +142,7 @@ export default class SyncCache {
|
|
|
131
142
|
// per business, so two would mean two callers share the container, the open question described
|
|
132
143
|
// for readOrders.
|
|
133
144
|
async readPartyPreferences(containerName) {
|
|
134
|
-
return
|
|
145
|
+
return this.readDecoded(containerName, ProtoMap.partyPreferencesMessageFromBlobs);
|
|
135
146
|
}
|
|
136
147
|
async writePartyPreferences(containerName, partyPreferences) {
|
|
137
148
|
return this.writeOwnMessage(containerName, partyPreferences, ProtoMap.partyPreferencesToBlob);
|
|
@@ -173,8 +184,21 @@ export default class SyncCache {
|
|
|
173
184
|
async writeMessages(containerName, messages) {
|
|
174
185
|
return this.writeRead(containerName, ProtoMap.messagesToCacheWrite(messages), SyncKeys.BUSINESS_SYNC_IN, (cursors) => ProtoMap.raiseMessageCursors(cursors, messages));
|
|
175
186
|
}
|
|
176
|
-
|
|
177
|
-
|
|
187
|
+
// Reads every blob of the container and decodes them with decoder. Throws StoreCorruptException
|
|
188
|
+
// if decoder throws.
|
|
189
|
+
async readDecoded(containerName, decoder) {
|
|
190
|
+
const blobs = await (await this.getContainer(containerName)).readAll();
|
|
191
|
+
return this.decode(containerName, () => decoder(blobs));
|
|
192
|
+
}
|
|
193
|
+
// Anything thrown while reading what the store holds means the store holds something unreadable,
|
|
194
|
+
// since the same bytes decoded when they were written.
|
|
195
|
+
decode(containerName, decoder) {
|
|
196
|
+
try {
|
|
197
|
+
return decoder();
|
|
198
|
+
}
|
|
199
|
+
catch (error) {
|
|
200
|
+
throw new StoreCorruptException(containerName, error);
|
|
201
|
+
}
|
|
178
202
|
}
|
|
179
203
|
// Stores a message the caller built, not one a response carried, versioned from the copy this
|
|
180
204
|
// container stores. A message this client amended has no update number, so it is stored at that of
|
|
@@ -191,8 +215,8 @@ export default class SyncCache {
|
|
|
191
215
|
// One transaction per call, so an interrupted write can be repeated and never leaves the cache
|
|
192
216
|
// partly written. The store's count covers only what it stored: a blob that the store already holds
|
|
193
217
|
// a copy of that is as new (a tombstone too) is not stored and not counted. A delete has nothing
|
|
194
|
-
// to compare, so the ids are counted instead.
|
|
195
|
-
// deletePartyPreferences.
|
|
218
|
+
// to compare, so the ids are counted instead. A delete is a caller's own: see
|
|
219
|
+
// deletePartyPreferences, deleteUserId and resetProfileCursors.
|
|
196
220
|
async write(containerName, cacheWrite) {
|
|
197
221
|
const blobs = cacheWrite.blobs;
|
|
198
222
|
const deletedIds = cacheWrite.deletedIds;
|
package/dist/index.d.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
export { default as Model } from "./Model";
|
|
2
2
|
export { default as Database } from "./Database";
|
|
3
3
|
export type { Container, StoredBlob } from "./Database";
|
|
4
|
+
export { default as Lock } from "./Lock";
|
|
5
|
+
export { default as InMemoryLock } from "./InMemoryLock";
|
|
4
6
|
export { default as Service } from "./Service";
|
|
5
7
|
export * as ContainerNames from "./ContainerNames";
|
|
6
8
|
export { buildAuthTokensBytes } from "./ProtoMap";
|
|
@@ -45,8 +47,8 @@ export { default as ShoppingList, ItemChange, ShoppingListItem } from "./Shoppin
|
|
|
45
47
|
export { default as Tax, TaxAmount } from "./Tax";
|
|
46
48
|
export { default as WorkingDays, WorkingHours, WorkingHoursRange } from "./WorkingDays";
|
|
47
49
|
export * from "./Enums";
|
|
48
|
-
export { Constants, MERABILLS_ONLINE_PRICE_LIST_ID, buildPriceListId } from "./Constants";
|
|
50
|
+
export { Constants, MAX_PROFILE_STALENESS_MS, MERABILLS_ONLINE_PRICE_LIST_ID, buildPriceListId, } from "./Constants";
|
|
49
51
|
export { default as Ids } from "./Ids";
|
|
50
|
-
export { IllegalOperationException, InvalidArgumentException } from "./Exceptions";
|
|
52
|
+
export { IllegalOperationException, InvalidArgumentException, StoreCorruptException, } from "./Exceptions";
|
|
51
53
|
export { BadRequestException, ConflictException, InternalServerException, MethodNotAllowedException, NetworkConnectionException, NotFoundException, ServiceException, ServiceUnavailableException, UnauthorizedException, UpgradeRequiredException, } from "./ServiceExceptions";
|
|
52
54
|
export { default as MockDatabase, MockContainer } from "./MockDatabase";
|
package/dist/index.js
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
// - TaxHelper, which does tax arithmetic in scaled values. A UI reads a tax off a ShoppingListItem,
|
|
7
7
|
// ShoppingList or CartLine.
|
|
8
8
|
export { default as Model } from "./Model";
|
|
9
|
+
export { default as InMemoryLock } from "./InMemoryLock";
|
|
9
10
|
export * as ContainerNames from "./ContainerNames";
|
|
10
11
|
export { buildAuthTokensBytes } from "./ProtoMap";
|
|
11
12
|
// The Service implementation that reaches a real server, and its routes. A caller using plain
|
|
@@ -51,12 +52,13 @@ export { default as WorkingDays, WorkingHours, WorkingHoursRange } from "./Worki
|
|
|
51
52
|
// storefront sells from is exported because a UI that builds a cart must name it. Catalog.priceList
|
|
52
53
|
// is null until the catalog has arrived, and when the response did not carry that list.
|
|
53
54
|
export * from "./Enums";
|
|
54
|
-
export { Constants, MERABILLS_ONLINE_PRICE_LIST_ID, buildPriceListId } from "./Constants";
|
|
55
|
+
export { Constants, MAX_PROFILE_STALENESS_MS, MERABILLS_ONLINE_PRICE_LIST_ID, buildPriceListId, } from "./Constants";
|
|
55
56
|
export { default as Ids } from "./Ids";
|
|
56
|
-
// Failures.
|
|
57
|
-
//
|
|
58
|
-
//
|
|
59
|
-
|
|
57
|
+
// Failures. Two in Exceptions.ts are programming errors. StoreCorruptException means the store
|
|
58
|
+
// holds data that cannot be read, and a caller recovers by deleting the store. The rest are what a
|
|
59
|
+
// call fails with and are meant to be caught: ServiceException for an error response from the
|
|
60
|
+
// server, NetworkConnectionException for a call that got no response.
|
|
61
|
+
export { IllegalOperationException, InvalidArgumentException, StoreCorruptException, } from "./Exceptions";
|
|
60
62
|
export { BadRequestException, ConflictException, InternalServerException, MethodNotAllowedException, NetworkConnectionException, NotFoundException, ServiceException, ServiceUnavailableException, UnauthorizedException, UpgradeRequiredException, } from "./ServiceExceptions";
|
|
61
63
|
// The mock database: a Database over a Map, so a caller can pair it with any Service. MockService
|
|
62
64
|
// and MockData (a Service and its data, answering out of memory) live in test/ only - see
|