@koolbase/core 12.12.0 → 12.13.1
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/cjs/cache-store.d.ts +9 -0
- package/dist/cjs/cache-store.js +33 -0
- package/dist/cjs/collection-controller.d.ts +3 -2
- package/dist/cjs/database.d.ts +9 -0
- package/dist/cjs/database.js +31 -6
- package/dist/cjs/offline-state.d.ts +16 -0
- package/dist/cjs/offline-state.js +34 -0
- package/dist/cjs/realtime.d.ts +16 -0
- package/dist/cjs/realtime.js +84 -10
- package/dist/cjs/sync-engine.js +18 -5
- package/dist/esm/cache-store.d.ts +9 -0
- package/dist/esm/cache-store.js +32 -0
- package/dist/esm/collection-controller.d.ts +3 -2
- package/dist/esm/database.d.ts +9 -0
- package/dist/esm/database.js +32 -7
- package/dist/esm/offline-state.d.ts +16 -0
- package/dist/esm/offline-state.js +33 -0
- package/dist/esm/realtime.d.ts +16 -0
- package/dist/esm/realtime.js +84 -10
- package/dist/esm/sync-engine.js +19 -6
- package/package.json +1 -1
|
@@ -58,3 +58,12 @@ export declare function addToWriteQueue(userId: string, write: Omit<LegacyPendin
|
|
|
58
58
|
export declare function removeFromWriteQueue(userId: string, writeId: string): Promise<void>;
|
|
59
59
|
export declare function incrementWriteRetry(userId: string, writeId: string): Promise<void>;
|
|
60
60
|
export declare function optimisticallyInsert(userId: string, collection: string, record: KoolbaseRecord): Promise<void>;
|
|
61
|
+
/**
|
|
62
|
+
* The reverse of optimisticallyInsert: takes one record out of every cached
|
|
63
|
+
* result of a collection.
|
|
64
|
+
*
|
|
65
|
+
* Precise on purpose. Dropping the collection's cached results instead would
|
|
66
|
+
* leave a device that is still offline with no saved lists to show at all,
|
|
67
|
+
* to get rid of one record.
|
|
68
|
+
*/
|
|
69
|
+
export declare function removeFromCachedQueries(userId: string, collection: string, recordId: string): Promise<void>;
|
package/dist/cjs/cache-store.js
CHANGED
|
@@ -13,6 +13,7 @@ exports.addToWriteQueue = addToWriteQueue;
|
|
|
13
13
|
exports.removeFromWriteQueue = removeFromWriteQueue;
|
|
14
14
|
exports.incrementWriteRetry = incrementWriteRetry;
|
|
15
15
|
exports.optimisticallyInsert = optimisticallyInsert;
|
|
16
|
+
exports.removeFromCachedQueries = removeFromCachedQueries;
|
|
16
17
|
const platform_js_1 = require("./platform.js");
|
|
17
18
|
const CACHE_VERSION = 'v1';
|
|
18
19
|
function cacheKey(userId, collection, queryHash) {
|
|
@@ -211,3 +212,35 @@ async function optimisticallyInsert(userId, collection, record) {
|
|
|
211
212
|
// ignore
|
|
212
213
|
}
|
|
213
214
|
}
|
|
215
|
+
/**
|
|
216
|
+
* The reverse of optimisticallyInsert: takes one record out of every cached
|
|
217
|
+
* result of a collection.
|
|
218
|
+
*
|
|
219
|
+
* Precise on purpose. Dropping the collection's cached results instead would
|
|
220
|
+
* leave a device that is still offline with no saved lists to show at all,
|
|
221
|
+
* to get rid of one record.
|
|
222
|
+
*/
|
|
223
|
+
async function removeFromCachedQueries(userId, collection, recordId) {
|
|
224
|
+
try {
|
|
225
|
+
const keys = await (0, platform_js_1.getPlatform)().storage.getAllKeys();
|
|
226
|
+
const prefix = `koolbase:${CACHE_VERSION}:${userId}:${collection}:`;
|
|
227
|
+
for (const key of keys.filter(k => k.startsWith(prefix))) {
|
|
228
|
+
const raw = await (0, platform_js_1.getPlatform)().storage.getItem(key);
|
|
229
|
+
if (!raw)
|
|
230
|
+
continue;
|
|
231
|
+
const cached = JSON.parse(raw);
|
|
232
|
+
if (!Array.isArray(cached.records))
|
|
233
|
+
continue;
|
|
234
|
+
const kept = cached.records.filter(r => r.id !== recordId);
|
|
235
|
+
const removed = cached.records.length - kept.length;
|
|
236
|
+
if (removed === 0)
|
|
237
|
+
continue;
|
|
238
|
+
cached.records = kept;
|
|
239
|
+
cached.total = Math.max(0, cached.total - removed);
|
|
240
|
+
await (0, platform_js_1.getPlatform)().storage.setItem(key, JSON.stringify(cached));
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
catch {
|
|
244
|
+
// ignore
|
|
245
|
+
}
|
|
246
|
+
}
|
|
@@ -11,8 +11,9 @@ export interface CollectionQuery {
|
|
|
11
11
|
/**
|
|
12
12
|
* Re-read page one, silently, when Koolbase realtime reports a record
|
|
13
13
|
* created, updated or deleted in this collection (debounced: a burst is one
|
|
14
|
-
* read).
|
|
15
|
-
* behaves as a normal list
|
|
14
|
+
* read). Signed out, only a collection anyone can read (read rule
|
|
15
|
+
* "public") is live; any other behaves as a normal list until a user signs
|
|
16
|
+
* in, and goes live then.
|
|
16
17
|
*/
|
|
17
18
|
live?: boolean;
|
|
18
19
|
}
|
package/dist/cjs/database.d.ts
CHANGED
|
@@ -173,6 +173,15 @@ export declare class KoolbaseDatabase {
|
|
|
173
173
|
*/
|
|
174
174
|
private requireUserId;
|
|
175
175
|
private requireConflict;
|
|
176
|
+
/**
|
|
177
|
+
* Removes a decided conflict and points the writes held behind it at the
|
|
178
|
+
* state the record is now in -- in one transaction, so the conflict can
|
|
179
|
+
* never be gone while its held writes still describe the old world.
|
|
180
|
+
*
|
|
181
|
+
* Without a known state (a refused add, a delete) there is nothing to
|
|
182
|
+
* rebase onto: the conflict is removed and the held writes stay as they are.
|
|
183
|
+
*/
|
|
184
|
+
private settleConflict;
|
|
176
185
|
private dropConflict;
|
|
177
186
|
/**
|
|
178
187
|
* Issues the resolving write, conditional on the revision the refusal
|
package/dist/cjs/database.js
CHANGED
|
@@ -65,12 +65,15 @@ class KoolbaseDatabase {
|
|
|
65
65
|
resolveWithServer: async (id) => {
|
|
66
66
|
const c = await this.requireConflict(id);
|
|
67
67
|
// The server's version stands. Recorded as a decision by removing the
|
|
68
|
-
// conflict, rather than the change quietly disappearing
|
|
69
|
-
|
|
68
|
+
// conflict, rather than the change quietly disappearing -- and what
|
|
69
|
+
// stands is what the writes held behind it now build on.
|
|
70
|
+
await this.settleConflict(c, c.server, c.serverRevision);
|
|
70
71
|
},
|
|
71
72
|
abandon: async (id) => {
|
|
72
73
|
const c = await this.requireConflict(id);
|
|
73
|
-
|
|
74
|
+
// Neither version claimed to win, so the record stands as the server
|
|
75
|
+
// has it: what anything still queued must build on.
|
|
76
|
+
await this.settleConflict(c, c.server, c.serverRevision);
|
|
74
77
|
},
|
|
75
78
|
};
|
|
76
79
|
this.config = config;
|
|
@@ -540,6 +543,25 @@ class KoolbaseDatabase {
|
|
|
540
543
|
}
|
|
541
544
|
return found;
|
|
542
545
|
}
|
|
546
|
+
/**
|
|
547
|
+
* Removes a decided conflict and points the writes held behind it at the
|
|
548
|
+
* state the record is now in -- in one transaction, so the conflict can
|
|
549
|
+
* never be gone while its held writes still describe the old world.
|
|
550
|
+
*
|
|
551
|
+
* Without a known state (a refused add, a delete) there is nothing to
|
|
552
|
+
* rebase onto: the conflict is removed and the held writes stay as they are.
|
|
553
|
+
*/
|
|
554
|
+
async settleConflict(c, resolved, revision) {
|
|
555
|
+
if (!resolved || !c.recordId) {
|
|
556
|
+
await this.dropConflict(c.id);
|
|
557
|
+
return;
|
|
558
|
+
}
|
|
559
|
+
const userId = this.requireUserId('conflict resolution');
|
|
560
|
+
await (0, offline_state_js_1.mutateOfflineState)(userId, (s) => {
|
|
561
|
+
s.conflicts = s.conflicts.filter((x) => x.id !== c.id);
|
|
562
|
+
(0, offline_state_js_1.rebaseHeldWrites)(s, c.recordId, resolved, revision);
|
|
563
|
+
});
|
|
564
|
+
}
|
|
543
565
|
async dropConflict(id) {
|
|
544
566
|
const userId = this.requireUserId('conflict resolution');
|
|
545
567
|
await (0, offline_state_js_1.mutateOfflineState)(userId, (s) => {
|
|
@@ -554,6 +576,8 @@ class KoolbaseDatabase {
|
|
|
554
576
|
*/
|
|
555
577
|
async applyResolution(c, payload) {
|
|
556
578
|
const rev = c.serverRevision;
|
|
579
|
+
// What the server now holds, from its answer: the state held writes build on.
|
|
580
|
+
let landed = null;
|
|
557
581
|
try {
|
|
558
582
|
if (c.operation === 'insert') {
|
|
559
583
|
// Resolving a rejected insert IS the insert, retried — with amended
|
|
@@ -564,7 +588,7 @@ class KoolbaseDatabase {
|
|
|
564
588
|
// original on retry rather than duplicating — the queue's own
|
|
565
589
|
// lost-response discipline, extended to the one insert path that
|
|
566
590
|
// lacked it.
|
|
567
|
-
await this.request('POST', '/v1/sdk/db/insert', {
|
|
591
|
+
landed = await this.request('POST', '/v1/sdk/db/insert', {
|
|
568
592
|
collection: c.collection,
|
|
569
593
|
data: payload,
|
|
570
594
|
idempotency_key: c.id,
|
|
@@ -575,7 +599,7 @@ class KoolbaseDatabase {
|
|
|
575
599
|
await this.request('DELETE', `/v1/sdk/db/records/${c.recordId}${q}`);
|
|
576
600
|
}
|
|
577
601
|
else {
|
|
578
|
-
await this.request('PATCH', `/v1/sdk/db/records/${c.recordId}`, { data: payload, ...(rev !== undefined ? { expected_revision: rev } : {}) });
|
|
602
|
+
landed = await this.request('PATCH', `/v1/sdk/db/records/${c.recordId}`, { data: payload, ...(rev !== undefined ? { expected_revision: rev } : {}) });
|
|
579
603
|
}
|
|
580
604
|
}
|
|
581
605
|
catch (e) {
|
|
@@ -604,7 +628,8 @@ class KoolbaseDatabase {
|
|
|
604
628
|
}
|
|
605
629
|
throw e;
|
|
606
630
|
}
|
|
607
|
-
|
|
631
|
+
const landedRevision = landed && typeof landed.$revision === 'number' ? landed.$revision : undefined;
|
|
632
|
+
await this.settleConflict(c, landed ?? undefined, landedRevision);
|
|
608
633
|
await (0, cache_store_js_2.invalidateCache)(this.getUserId() ?? 'anonymous', c.collection);
|
|
609
634
|
void (0, query_refresh_js_1.refreshCollectionQueries)(c.collection);
|
|
610
635
|
}
|
|
@@ -103,3 +103,19 @@ export declare function queueWrite(userId: string, write: Omit<QueuedWrite, 'ret
|
|
|
103
103
|
* and nothing is written on a guess.
|
|
104
104
|
*/
|
|
105
105
|
export declare function migrateLegacyQueue(userId: string): Promise<void>;
|
|
106
|
+
/**
|
|
107
|
+
* Points the writes still queued for a record at the state it is now in.
|
|
108
|
+
*
|
|
109
|
+
* When a conflict is decided, the writes held behind it carry baselines
|
|
110
|
+
* describing the state the conflicted write would have produced -- a state
|
|
111
|
+
* that no longer exists, and may never have. Released unchanged they would
|
|
112
|
+
* replay against a revision that has moved on, and each would conflict in
|
|
113
|
+
* turn: one disagreement multiplying into as many conflicts as the user had
|
|
114
|
+
* queued. The same rule as the Flutter SDK's rebaseAfterResolution.
|
|
115
|
+
*
|
|
116
|
+
* Refreshing the baseline is enough because every operation is an absolute
|
|
117
|
+
* assignment: a patch says what a field becomes, not what to do to it. If a
|
|
118
|
+
* relative operation is ever added (an increment, an append), the chain must
|
|
119
|
+
* be replayed through its projected states instead.
|
|
120
|
+
*/
|
|
121
|
+
export declare function rebaseHeldWrites(state: OfflineState, recordId: string, resolved: Record<string, unknown>, revision: number | undefined): void;
|
|
@@ -8,6 +8,7 @@ exports.readOfflineState = readOfflineState;
|
|
|
8
8
|
exports.mutateOfflineState = mutateOfflineState;
|
|
9
9
|
exports.queueWrite = queueWrite;
|
|
10
10
|
exports.migrateLegacyQueue = migrateLegacyQueue;
|
|
11
|
+
exports.rebaseHeldWrites = rebaseHeldWrites;
|
|
11
12
|
const platform_js_1 = require("./platform.js");
|
|
12
13
|
const shared_js_1 = require("./shared.js");
|
|
13
14
|
/**
|
|
@@ -213,3 +214,36 @@ async function migrateLegacyQueue(userId) {
|
|
|
213
214
|
});
|
|
214
215
|
await (0, platform_js_1.getPlatform)().storage.removeItem(legacyQueueKey(userId));
|
|
215
216
|
}
|
|
217
|
+
/**
|
|
218
|
+
* Points the writes still queued for a record at the state it is now in.
|
|
219
|
+
*
|
|
220
|
+
* When a conflict is decided, the writes held behind it carry baselines
|
|
221
|
+
* describing the state the conflicted write would have produced -- a state
|
|
222
|
+
* that no longer exists, and may never have. Released unchanged they would
|
|
223
|
+
* replay against a revision that has moved on, and each would conflict in
|
|
224
|
+
* turn: one disagreement multiplying into as many conflicts as the user had
|
|
225
|
+
* queued. The same rule as the Flutter SDK's rebaseAfterResolution.
|
|
226
|
+
*
|
|
227
|
+
* Refreshing the baseline is enough because every operation is an absolute
|
|
228
|
+
* assignment: a patch says what a field becomes, not what to do to it. If a
|
|
229
|
+
* relative operation is ever added (an increment, an append), the chain must
|
|
230
|
+
* be replayed through its projected states instead.
|
|
231
|
+
*/
|
|
232
|
+
function rebaseHeldWrites(state, recordId, resolved, revision) {
|
|
233
|
+
let current = {};
|
|
234
|
+
for (const [k, v] of Object.entries(resolved)) {
|
|
235
|
+
if (!k.startsWith('$'))
|
|
236
|
+
current[k] = v;
|
|
237
|
+
}
|
|
238
|
+
for (const w of state.pending) {
|
|
239
|
+
if (w.recordId !== recordId)
|
|
240
|
+
continue;
|
|
241
|
+
w.baseline = { ...current };
|
|
242
|
+
w.baseRevision = revision;
|
|
243
|
+
// Each write is composed against the one before it.
|
|
244
|
+
if (w.operation === 'update')
|
|
245
|
+
current = { ...current, ...(w.data ?? {}) };
|
|
246
|
+
else if (w.operation === 'delete')
|
|
247
|
+
break;
|
|
248
|
+
}
|
|
249
|
+
}
|
package/dist/cjs/realtime.d.ts
CHANGED
|
@@ -9,6 +9,9 @@ export declare class KoolbaseRealtime {
|
|
|
9
9
|
private reconnectAttempts;
|
|
10
10
|
private reconnectTimer;
|
|
11
11
|
private connecting;
|
|
12
|
+
private anonymous;
|
|
13
|
+
private connectedAs;
|
|
14
|
+
private generation;
|
|
12
15
|
private idleTimer;
|
|
13
16
|
/** How long an unused connection stays open, in ms: a screen that comes
|
|
14
17
|
* straight back (navigating back, React's development double start)
|
|
@@ -30,6 +33,9 @@ export declare class KoolbaseRealtime {
|
|
|
30
33
|
private connect;
|
|
31
34
|
private sendSubscribe;
|
|
32
35
|
private sendUnsubscribe;
|
|
36
|
+
/** A signed-out visitor's message leaves project_id out: it has none to
|
|
37
|
+
* give, and the server uses the public key's project. */
|
|
38
|
+
private send;
|
|
33
39
|
/**
|
|
34
40
|
* Reconnects with backoff, rather than every three seconds forever.
|
|
35
41
|
*
|
|
@@ -51,6 +57,16 @@ export declare class KoolbaseRealtime {
|
|
|
51
57
|
* it: reconnecting needs a subscriber.
|
|
52
58
|
*/
|
|
53
59
|
private scheduleIdleClose;
|
|
60
|
+
/**
|
|
61
|
+
* The signed-in user changed: signed in, signed out, or another user. The
|
|
62
|
+
* open connection was made for the previous session -- signed out it sees
|
|
63
|
+
* only public collections, signed in it carries that user's access -- so it
|
|
64
|
+
* is replaced by one for the new session, resubscribing every collection.
|
|
65
|
+
* The same user again (a token refresh) changes nothing, and with nothing
|
|
66
|
+
* open there is nothing to do: the next subscribe connects as whoever is
|
|
67
|
+
* signed in then.
|
|
68
|
+
*/
|
|
69
|
+
sessionChanged(): void;
|
|
54
70
|
disconnect(): void;
|
|
55
71
|
}
|
|
56
72
|
export {};
|
package/dist/cjs/realtime.js
CHANGED
|
@@ -41,6 +41,15 @@ class KoolbaseRealtime {
|
|
|
41
41
|
this.reconnectAttempts = 0;
|
|
42
42
|
this.reconnectTimer = null;
|
|
43
43
|
this.connecting = false;
|
|
44
|
+
// The open connection is a signed-out visitor's (the public key): its
|
|
45
|
+
// messages leave project_id out.
|
|
46
|
+
this.anonymous = false;
|
|
47
|
+
// Whose session the open connection was made for: the user id, or null
|
|
48
|
+
// when signed out. sessionChanged compares against it.
|
|
49
|
+
this.connectedAs = null;
|
|
50
|
+
// Bumped by sessionChanged: a connect still waiting for its token gives up
|
|
51
|
+
// rather than open a connection for the old session.
|
|
52
|
+
this.generation = 0;
|
|
44
53
|
// Pending close of an unused connection (scheduleIdleClose); null while
|
|
45
54
|
// anyone is subscribed.
|
|
46
55
|
this.idleTimer = null;
|
|
@@ -101,28 +110,49 @@ class KoolbaseRealtime {
|
|
|
101
110
|
// double start -- found nobody connecting and opened its own socket; only
|
|
102
111
|
// the last was remembered, and the others stayed open for good.
|
|
103
112
|
this.connecting = true;
|
|
113
|
+
const generation = this.generation;
|
|
104
114
|
let token;
|
|
105
115
|
try {
|
|
106
116
|
token = await this.getToken();
|
|
107
117
|
}
|
|
108
118
|
catch {
|
|
109
|
-
|
|
119
|
+
// A session that could not be refreshed is still a session: try again
|
|
120
|
+
// later, never as a signed-out visitor.
|
|
121
|
+
if (generation !== this.generation)
|
|
122
|
+
return;
|
|
123
|
+
this.connecting = false;
|
|
124
|
+
this.scheduleReconnect();
|
|
125
|
+
return;
|
|
110
126
|
}
|
|
127
|
+
// The session changed while the token was on its way: sessionChanged
|
|
128
|
+
// has started the connection for the new one.
|
|
129
|
+
if (generation !== this.generation)
|
|
130
|
+
return;
|
|
111
131
|
if (this.listeners.size === 0) {
|
|
112
132
|
// Everyone left while the token was on its way: nothing to connect for.
|
|
113
133
|
this.connecting = false;
|
|
114
134
|
return;
|
|
115
135
|
}
|
|
116
|
-
|
|
136
|
+
const userId = this.getUserId?.() ?? null;
|
|
137
|
+
if (!token && userId) {
|
|
138
|
+
// Signed in, but no usable token right now: try again later rather
|
|
139
|
+
// than drop to public-only.
|
|
117
140
|
this.connecting = false;
|
|
118
|
-
this.scheduleReconnect();
|
|
141
|
+
this.scheduleReconnect();
|
|
119
142
|
return;
|
|
120
143
|
}
|
|
121
|
-
|
|
144
|
+
// Signed out: the project's public key, and only collections anyone can
|
|
145
|
+
// read. The server pins the connection to the key's project.
|
|
146
|
+
this.anonymous = !token;
|
|
147
|
+
this.connectedAs = token ? userId : null;
|
|
148
|
+
this.projectId = token ? projectIdFromToken(token) : null;
|
|
149
|
+
const credential = token
|
|
150
|
+
? `token=${encodeURIComponent(token)}`
|
|
151
|
+
: `public_key=${encodeURIComponent(this.config.publicKey)}`;
|
|
122
152
|
const wsUrl = this.config.baseUrl.replace('https://', 'wss://').replace('http://', 'ws://');
|
|
123
153
|
let ws;
|
|
124
154
|
try {
|
|
125
|
-
ws = new WebSocket(`${wsUrl}/v1/realtime/ws
|
|
155
|
+
ws = new WebSocket(`${wsUrl}/v1/realtime/ws?${credential}`);
|
|
126
156
|
}
|
|
127
157
|
catch {
|
|
128
158
|
this.connecting = false;
|
|
@@ -186,14 +216,22 @@ class KoolbaseRealtime {
|
|
|
186
216
|
ws.onerror = () => { };
|
|
187
217
|
}
|
|
188
218
|
sendSubscribe(collection) {
|
|
189
|
-
|
|
190
|
-
return;
|
|
191
|
-
this.ws.send(JSON.stringify({ action: 'subscribe', project_id: this.projectId, collection }));
|
|
219
|
+
this.send('subscribe', collection);
|
|
192
220
|
}
|
|
193
221
|
sendUnsubscribe(collection) {
|
|
194
|
-
|
|
222
|
+
this.send('unsubscribe', collection);
|
|
223
|
+
}
|
|
224
|
+
/** A signed-out visitor's message leaves project_id out: it has none to
|
|
225
|
+
* give, and the server uses the public key's project. */
|
|
226
|
+
send(action, collection) {
|
|
227
|
+
if (!this.ws || this.ws.readyState !== WebSocket.OPEN)
|
|
195
228
|
return;
|
|
196
|
-
|
|
229
|
+
if (this.anonymous) {
|
|
230
|
+
this.ws.send(JSON.stringify({ action, collection }));
|
|
231
|
+
}
|
|
232
|
+
else if (this.projectId) {
|
|
233
|
+
this.ws.send(JSON.stringify({ action, project_id: this.projectId, collection }));
|
|
234
|
+
}
|
|
197
235
|
}
|
|
198
236
|
/**
|
|
199
237
|
* Reconnects with backoff, rather than every three seconds forever.
|
|
@@ -252,6 +290,42 @@ class KoolbaseRealtime {
|
|
|
252
290
|
}, KoolbaseRealtime.idleGraceMs);
|
|
253
291
|
this.idleTimer.unref?.();
|
|
254
292
|
}
|
|
293
|
+
/**
|
|
294
|
+
* The signed-in user changed: signed in, signed out, or another user. The
|
|
295
|
+
* open connection was made for the previous session -- signed out it sees
|
|
296
|
+
* only public collections, signed in it carries that user's access -- so it
|
|
297
|
+
* is replaced by one for the new session, resubscribing every collection.
|
|
298
|
+
* The same user again (a token refresh) changes nothing, and with nothing
|
|
299
|
+
* open there is nothing to do: the next subscribe connects as whoever is
|
|
300
|
+
* signed in then.
|
|
301
|
+
*/
|
|
302
|
+
sessionChanged() {
|
|
303
|
+
if (!this.ws && !this.connecting && !this.reconnectTimer)
|
|
304
|
+
return;
|
|
305
|
+
if (this.ws && !this.connecting && (this.getUserId?.() ?? null) === this.connectedAs)
|
|
306
|
+
return;
|
|
307
|
+
this.generation += 1;
|
|
308
|
+
if (this.reconnectTimer) {
|
|
309
|
+
clearTimeout(this.reconnectTimer);
|
|
310
|
+
this.reconnectTimer = null;
|
|
311
|
+
}
|
|
312
|
+
const ws = this.ws;
|
|
313
|
+
this.ws = null;
|
|
314
|
+
this.connecting = false;
|
|
315
|
+
this.anonymous = false;
|
|
316
|
+
this.projectId = null;
|
|
317
|
+
this.reconnectAttempts = 0;
|
|
318
|
+
if (ws) {
|
|
319
|
+
// Detached first: this close is ours, not a dropped connection.
|
|
320
|
+
ws.onopen = null;
|
|
321
|
+
ws.onmessage = null;
|
|
322
|
+
ws.onclose = null;
|
|
323
|
+
ws.onerror = null;
|
|
324
|
+
ws.close();
|
|
325
|
+
}
|
|
326
|
+
if (this.listeners.size > 0)
|
|
327
|
+
void this.connect();
|
|
328
|
+
}
|
|
255
329
|
disconnect() {
|
|
256
330
|
if (this.idleTimer) {
|
|
257
331
|
clearTimeout(this.idleTimer);
|
package/dist/cjs/sync-engine.js
CHANGED
|
@@ -101,13 +101,21 @@ class SyncEngine {
|
|
|
101
101
|
// being online — would give the same input different outcomes. It clears
|
|
102
102
|
// the old key when done, so later calls find nothing and return.
|
|
103
103
|
await (0, offline_state_js_1.migrateLegacyQueue)(userId);
|
|
104
|
-
const { pending } = await (0, offline_state_js_1.readOfflineState)(userId);
|
|
104
|
+
const { pending, conflicts } = await (0, offline_state_js_1.readOfflineState)(userId);
|
|
105
105
|
if (pending.length === 0)
|
|
106
106
|
return;
|
|
107
107
|
// Records whose chain stopped this pass. Writes queued after a conflicted
|
|
108
108
|
// one were composed against the state it would have produced, so applying
|
|
109
109
|
// them now would write against a state their baseline never described.
|
|
110
110
|
const blocked = new Set();
|
|
111
|
+
// And records whose chain stopped in an EARLIER pass: a conflict waits
|
|
112
|
+
// for a decision across restarts, and so must the writes behind it.
|
|
113
|
+
// Seeded only from this pass's finds, the next pass replayed them
|
|
114
|
+
// against a state their baselines never described -- each becoming its
|
|
115
|
+
// own conflict. Deciding the conflict rebases and releases them.
|
|
116
|
+
for (const c of conflicts)
|
|
117
|
+
if (c.recordId)
|
|
118
|
+
blocked.add(c.recordId);
|
|
111
119
|
for (const queued of pending) {
|
|
112
120
|
if (queued.recordId && blocked.has(queued.recordId))
|
|
113
121
|
continue;
|
|
@@ -202,10 +210,15 @@ class SyncEngine {
|
|
|
202
210
|
// — cached at enqueue for a record the server refused to create.
|
|
203
211
|
// Left alone it is a phantom: it renders as saved, and an offline
|
|
204
212
|
// edit against it queues a write to a record that does not exist.
|
|
205
|
-
// Evict it
|
|
206
|
-
//
|
|
207
|
-
//
|
|
208
|
-
//
|
|
213
|
+
// Evict it from the record cache and from every cached query that
|
|
214
|
+
// holds it -- precisely, not by dropping the collection's saved
|
|
215
|
+
// results, which a device still offline needs. The conflict above
|
|
216
|
+
// keeps the user's data and the server's verdict; the cache stops
|
|
217
|
+
// testifying to a fiction.
|
|
218
|
+
if (write.operation === 'insert' && write.recordId) {
|
|
219
|
+
await (0, cache_store_js_1.removeCachedRecord)(userId, write.recordId);
|
|
220
|
+
await (0, cache_store_js_1.removeFromCachedQueries)(userId, write.collection, write.recordId);
|
|
221
|
+
}
|
|
209
222
|
if (write.recordId)
|
|
210
223
|
blocked.add(write.recordId);
|
|
211
224
|
continue;
|
|
@@ -58,3 +58,12 @@ export declare function addToWriteQueue(userId: string, write: Omit<LegacyPendin
|
|
|
58
58
|
export declare function removeFromWriteQueue(userId: string, writeId: string): Promise<void>;
|
|
59
59
|
export declare function incrementWriteRetry(userId: string, writeId: string): Promise<void>;
|
|
60
60
|
export declare function optimisticallyInsert(userId: string, collection: string, record: KoolbaseRecord): Promise<void>;
|
|
61
|
+
/**
|
|
62
|
+
* The reverse of optimisticallyInsert: takes one record out of every cached
|
|
63
|
+
* result of a collection.
|
|
64
|
+
*
|
|
65
|
+
* Precise on purpose. Dropping the collection's cached results instead would
|
|
66
|
+
* leave a device that is still offline with no saved lists to show at all,
|
|
67
|
+
* to get rid of one record.
|
|
68
|
+
*/
|
|
69
|
+
export declare function removeFromCachedQueries(userId: string, collection: string, recordId: string): Promise<void>;
|
package/dist/esm/cache-store.js
CHANGED
|
@@ -196,3 +196,35 @@ export async function optimisticallyInsert(userId, collection, record) {
|
|
|
196
196
|
// ignore
|
|
197
197
|
}
|
|
198
198
|
}
|
|
199
|
+
/**
|
|
200
|
+
* The reverse of optimisticallyInsert: takes one record out of every cached
|
|
201
|
+
* result of a collection.
|
|
202
|
+
*
|
|
203
|
+
* Precise on purpose. Dropping the collection's cached results instead would
|
|
204
|
+
* leave a device that is still offline with no saved lists to show at all,
|
|
205
|
+
* to get rid of one record.
|
|
206
|
+
*/
|
|
207
|
+
export async function removeFromCachedQueries(userId, collection, recordId) {
|
|
208
|
+
try {
|
|
209
|
+
const keys = await getPlatform().storage.getAllKeys();
|
|
210
|
+
const prefix = `koolbase:${CACHE_VERSION}:${userId}:${collection}:`;
|
|
211
|
+
for (const key of keys.filter(k => k.startsWith(prefix))) {
|
|
212
|
+
const raw = await getPlatform().storage.getItem(key);
|
|
213
|
+
if (!raw)
|
|
214
|
+
continue;
|
|
215
|
+
const cached = JSON.parse(raw);
|
|
216
|
+
if (!Array.isArray(cached.records))
|
|
217
|
+
continue;
|
|
218
|
+
const kept = cached.records.filter(r => r.id !== recordId);
|
|
219
|
+
const removed = cached.records.length - kept.length;
|
|
220
|
+
if (removed === 0)
|
|
221
|
+
continue;
|
|
222
|
+
cached.records = kept;
|
|
223
|
+
cached.total = Math.max(0, cached.total - removed);
|
|
224
|
+
await getPlatform().storage.setItem(key, JSON.stringify(cached));
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
catch {
|
|
228
|
+
// ignore
|
|
229
|
+
}
|
|
230
|
+
}
|
|
@@ -11,8 +11,9 @@ export interface CollectionQuery {
|
|
|
11
11
|
/**
|
|
12
12
|
* Re-read page one, silently, when Koolbase realtime reports a record
|
|
13
13
|
* created, updated or deleted in this collection (debounced: a burst is one
|
|
14
|
-
* read).
|
|
15
|
-
* behaves as a normal list
|
|
14
|
+
* read). Signed out, only a collection anyone can read (read rule
|
|
15
|
+
* "public") is live; any other behaves as a normal list until a user signs
|
|
16
|
+
* in, and goes live then.
|
|
16
17
|
*/
|
|
17
18
|
live?: boolean;
|
|
18
19
|
}
|
package/dist/esm/database.d.ts
CHANGED
|
@@ -173,6 +173,15 @@ export declare class KoolbaseDatabase {
|
|
|
173
173
|
*/
|
|
174
174
|
private requireUserId;
|
|
175
175
|
private requireConflict;
|
|
176
|
+
/**
|
|
177
|
+
* Removes a decided conflict and points the writes held behind it at the
|
|
178
|
+
* state the record is now in -- in one transaction, so the conflict can
|
|
179
|
+
* never be gone while its held writes still describe the old world.
|
|
180
|
+
*
|
|
181
|
+
* Without a known state (a refused add, a delete) there is nothing to
|
|
182
|
+
* rebase onto: the conflict is removed and the held writes stay as they are.
|
|
183
|
+
*/
|
|
184
|
+
private settleConflict;
|
|
176
185
|
private dropConflict;
|
|
177
186
|
/**
|
|
178
187
|
* Issues the resolving write, conditional on the revision the refusal
|
package/dist/esm/database.js
CHANGED
|
@@ -3,7 +3,7 @@ import { refreshCollectionQueries } from './query-refresh.js';
|
|
|
3
3
|
import { KoolbaseRevisionMismatchError } from './database-errors.js';
|
|
4
4
|
import { KoolbaseError, KoolbaseOfflineBaselineUnavailableError, KoolbaseUnauthenticatedError, } from './errors.js';
|
|
5
5
|
import { cacheRecord, getCachedRecord, removeCachedRecord } from './cache-store.js';
|
|
6
|
-
import { readOfflineState, mutateOfflineState, queueWrite, } from './offline-state.js';
|
|
6
|
+
import { readOfflineState, mutateOfflineState, queueWrite, rebaseHeldWrites, } from './offline-state.js';
|
|
7
7
|
import { KoolbaseConflict } from './conflict.js';
|
|
8
8
|
import { toPendingWrite } from './pending-write.js';
|
|
9
9
|
import { getCached, setCached, invalidateCache, optimisticallyInsert, hashQuery, } from './cache-store.js';
|
|
@@ -62,12 +62,15 @@ export class KoolbaseDatabase {
|
|
|
62
62
|
resolveWithServer: async (id) => {
|
|
63
63
|
const c = await this.requireConflict(id);
|
|
64
64
|
// The server's version stands. Recorded as a decision by removing the
|
|
65
|
-
// conflict, rather than the change quietly disappearing
|
|
66
|
-
|
|
65
|
+
// conflict, rather than the change quietly disappearing -- and what
|
|
66
|
+
// stands is what the writes held behind it now build on.
|
|
67
|
+
await this.settleConflict(c, c.server, c.serverRevision);
|
|
67
68
|
},
|
|
68
69
|
abandon: async (id) => {
|
|
69
70
|
const c = await this.requireConflict(id);
|
|
70
|
-
|
|
71
|
+
// Neither version claimed to win, so the record stands as the server
|
|
72
|
+
// has it: what anything still queued must build on.
|
|
73
|
+
await this.settleConflict(c, c.server, c.serverRevision);
|
|
71
74
|
},
|
|
72
75
|
};
|
|
73
76
|
this.config = config;
|
|
@@ -537,6 +540,25 @@ export class KoolbaseDatabase {
|
|
|
537
540
|
}
|
|
538
541
|
return found;
|
|
539
542
|
}
|
|
543
|
+
/**
|
|
544
|
+
* Removes a decided conflict and points the writes held behind it at the
|
|
545
|
+
* state the record is now in -- in one transaction, so the conflict can
|
|
546
|
+
* never be gone while its held writes still describe the old world.
|
|
547
|
+
*
|
|
548
|
+
* Without a known state (a refused add, a delete) there is nothing to
|
|
549
|
+
* rebase onto: the conflict is removed and the held writes stay as they are.
|
|
550
|
+
*/
|
|
551
|
+
async settleConflict(c, resolved, revision) {
|
|
552
|
+
if (!resolved || !c.recordId) {
|
|
553
|
+
await this.dropConflict(c.id);
|
|
554
|
+
return;
|
|
555
|
+
}
|
|
556
|
+
const userId = this.requireUserId('conflict resolution');
|
|
557
|
+
await mutateOfflineState(userId, (s) => {
|
|
558
|
+
s.conflicts = s.conflicts.filter((x) => x.id !== c.id);
|
|
559
|
+
rebaseHeldWrites(s, c.recordId, resolved, revision);
|
|
560
|
+
});
|
|
561
|
+
}
|
|
540
562
|
async dropConflict(id) {
|
|
541
563
|
const userId = this.requireUserId('conflict resolution');
|
|
542
564
|
await mutateOfflineState(userId, (s) => {
|
|
@@ -551,6 +573,8 @@ export class KoolbaseDatabase {
|
|
|
551
573
|
*/
|
|
552
574
|
async applyResolution(c, payload) {
|
|
553
575
|
const rev = c.serverRevision;
|
|
576
|
+
// What the server now holds, from its answer: the state held writes build on.
|
|
577
|
+
let landed = null;
|
|
554
578
|
try {
|
|
555
579
|
if (c.operation === 'insert') {
|
|
556
580
|
// Resolving a rejected insert IS the insert, retried — with amended
|
|
@@ -561,7 +585,7 @@ export class KoolbaseDatabase {
|
|
|
561
585
|
// original on retry rather than duplicating — the queue's own
|
|
562
586
|
// lost-response discipline, extended to the one insert path that
|
|
563
587
|
// lacked it.
|
|
564
|
-
await this.request('POST', '/v1/sdk/db/insert', {
|
|
588
|
+
landed = await this.request('POST', '/v1/sdk/db/insert', {
|
|
565
589
|
collection: c.collection,
|
|
566
590
|
data: payload,
|
|
567
591
|
idempotency_key: c.id,
|
|
@@ -572,7 +596,7 @@ export class KoolbaseDatabase {
|
|
|
572
596
|
await this.request('DELETE', `/v1/sdk/db/records/${c.recordId}${q}`);
|
|
573
597
|
}
|
|
574
598
|
else {
|
|
575
|
-
await this.request('PATCH', `/v1/sdk/db/records/${c.recordId}`, { data: payload, ...(rev !== undefined ? { expected_revision: rev } : {}) });
|
|
599
|
+
landed = await this.request('PATCH', `/v1/sdk/db/records/${c.recordId}`, { data: payload, ...(rev !== undefined ? { expected_revision: rev } : {}) });
|
|
576
600
|
}
|
|
577
601
|
}
|
|
578
602
|
catch (e) {
|
|
@@ -601,7 +625,8 @@ export class KoolbaseDatabase {
|
|
|
601
625
|
}
|
|
602
626
|
throw e;
|
|
603
627
|
}
|
|
604
|
-
|
|
628
|
+
const landedRevision = landed && typeof landed.$revision === 'number' ? landed.$revision : undefined;
|
|
629
|
+
await this.settleConflict(c, landed ?? undefined, landedRevision);
|
|
605
630
|
await invalidateCache(this.getUserId() ?? 'anonymous', c.collection);
|
|
606
631
|
void refreshCollectionQueries(c.collection);
|
|
607
632
|
}
|
|
@@ -103,3 +103,19 @@ export declare function queueWrite(userId: string, write: Omit<QueuedWrite, 'ret
|
|
|
103
103
|
* and nothing is written on a guess.
|
|
104
104
|
*/
|
|
105
105
|
export declare function migrateLegacyQueue(userId: string): Promise<void>;
|
|
106
|
+
/**
|
|
107
|
+
* Points the writes still queued for a record at the state it is now in.
|
|
108
|
+
*
|
|
109
|
+
* When a conflict is decided, the writes held behind it carry baselines
|
|
110
|
+
* describing the state the conflicted write would have produced -- a state
|
|
111
|
+
* that no longer exists, and may never have. Released unchanged they would
|
|
112
|
+
* replay against a revision that has moved on, and each would conflict in
|
|
113
|
+
* turn: one disagreement multiplying into as many conflicts as the user had
|
|
114
|
+
* queued. The same rule as the Flutter SDK's rebaseAfterResolution.
|
|
115
|
+
*
|
|
116
|
+
* Refreshing the baseline is enough because every operation is an absolute
|
|
117
|
+
* assignment: a patch says what a field becomes, not what to do to it. If a
|
|
118
|
+
* relative operation is ever added (an increment, an append), the chain must
|
|
119
|
+
* be replayed through its projected states instead.
|
|
120
|
+
*/
|
|
121
|
+
export declare function rebaseHeldWrites(state: OfflineState, recordId: string, resolved: Record<string, unknown>, revision: number | undefined): void;
|
|
@@ -202,3 +202,36 @@ export async function migrateLegacyQueue(userId) {
|
|
|
202
202
|
});
|
|
203
203
|
await getPlatform().storage.removeItem(legacyQueueKey(userId));
|
|
204
204
|
}
|
|
205
|
+
/**
|
|
206
|
+
* Points the writes still queued for a record at the state it is now in.
|
|
207
|
+
*
|
|
208
|
+
* When a conflict is decided, the writes held behind it carry baselines
|
|
209
|
+
* describing the state the conflicted write would have produced -- a state
|
|
210
|
+
* that no longer exists, and may never have. Released unchanged they would
|
|
211
|
+
* replay against a revision that has moved on, and each would conflict in
|
|
212
|
+
* turn: one disagreement multiplying into as many conflicts as the user had
|
|
213
|
+
* queued. The same rule as the Flutter SDK's rebaseAfterResolution.
|
|
214
|
+
*
|
|
215
|
+
* Refreshing the baseline is enough because every operation is an absolute
|
|
216
|
+
* assignment: a patch says what a field becomes, not what to do to it. If a
|
|
217
|
+
* relative operation is ever added (an increment, an append), the chain must
|
|
218
|
+
* be replayed through its projected states instead.
|
|
219
|
+
*/
|
|
220
|
+
export function rebaseHeldWrites(state, recordId, resolved, revision) {
|
|
221
|
+
let current = {};
|
|
222
|
+
for (const [k, v] of Object.entries(resolved)) {
|
|
223
|
+
if (!k.startsWith('$'))
|
|
224
|
+
current[k] = v;
|
|
225
|
+
}
|
|
226
|
+
for (const w of state.pending) {
|
|
227
|
+
if (w.recordId !== recordId)
|
|
228
|
+
continue;
|
|
229
|
+
w.baseline = { ...current };
|
|
230
|
+
w.baseRevision = revision;
|
|
231
|
+
// Each write is composed against the one before it.
|
|
232
|
+
if (w.operation === 'update')
|
|
233
|
+
current = { ...current, ...(w.data ?? {}) };
|
|
234
|
+
else if (w.operation === 'delete')
|
|
235
|
+
break;
|
|
236
|
+
}
|
|
237
|
+
}
|
package/dist/esm/realtime.d.ts
CHANGED
|
@@ -9,6 +9,9 @@ export declare class KoolbaseRealtime {
|
|
|
9
9
|
private reconnectAttempts;
|
|
10
10
|
private reconnectTimer;
|
|
11
11
|
private connecting;
|
|
12
|
+
private anonymous;
|
|
13
|
+
private connectedAs;
|
|
14
|
+
private generation;
|
|
12
15
|
private idleTimer;
|
|
13
16
|
/** How long an unused connection stays open, in ms: a screen that comes
|
|
14
17
|
* straight back (navigating back, React's development double start)
|
|
@@ -30,6 +33,9 @@ export declare class KoolbaseRealtime {
|
|
|
30
33
|
private connect;
|
|
31
34
|
private sendSubscribe;
|
|
32
35
|
private sendUnsubscribe;
|
|
36
|
+
/** A signed-out visitor's message leaves project_id out: it has none to
|
|
37
|
+
* give, and the server uses the public key's project. */
|
|
38
|
+
private send;
|
|
33
39
|
/**
|
|
34
40
|
* Reconnects with backoff, rather than every three seconds forever.
|
|
35
41
|
*
|
|
@@ -51,6 +57,16 @@ export declare class KoolbaseRealtime {
|
|
|
51
57
|
* it: reconnecting needs a subscriber.
|
|
52
58
|
*/
|
|
53
59
|
private scheduleIdleClose;
|
|
60
|
+
/**
|
|
61
|
+
* The signed-in user changed: signed in, signed out, or another user. The
|
|
62
|
+
* open connection was made for the previous session -- signed out it sees
|
|
63
|
+
* only public collections, signed in it carries that user's access -- so it
|
|
64
|
+
* is replaced by one for the new session, resubscribing every collection.
|
|
65
|
+
* The same user again (a token refresh) changes nothing, and with nothing
|
|
66
|
+
* open there is nothing to do: the next subscribe connects as whoever is
|
|
67
|
+
* signed in then.
|
|
68
|
+
*/
|
|
69
|
+
sessionChanged(): void;
|
|
54
70
|
disconnect(): void;
|
|
55
71
|
}
|
|
56
72
|
export {};
|
package/dist/esm/realtime.js
CHANGED
|
@@ -38,6 +38,15 @@ export class KoolbaseRealtime {
|
|
|
38
38
|
this.reconnectAttempts = 0;
|
|
39
39
|
this.reconnectTimer = null;
|
|
40
40
|
this.connecting = false;
|
|
41
|
+
// The open connection is a signed-out visitor's (the public key): its
|
|
42
|
+
// messages leave project_id out.
|
|
43
|
+
this.anonymous = false;
|
|
44
|
+
// Whose session the open connection was made for: the user id, or null
|
|
45
|
+
// when signed out. sessionChanged compares against it.
|
|
46
|
+
this.connectedAs = null;
|
|
47
|
+
// Bumped by sessionChanged: a connect still waiting for its token gives up
|
|
48
|
+
// rather than open a connection for the old session.
|
|
49
|
+
this.generation = 0;
|
|
41
50
|
// Pending close of an unused connection (scheduleIdleClose); null while
|
|
42
51
|
// anyone is subscribed.
|
|
43
52
|
this.idleTimer = null;
|
|
@@ -98,28 +107,49 @@ export class KoolbaseRealtime {
|
|
|
98
107
|
// double start -- found nobody connecting and opened its own socket; only
|
|
99
108
|
// the last was remembered, and the others stayed open for good.
|
|
100
109
|
this.connecting = true;
|
|
110
|
+
const generation = this.generation;
|
|
101
111
|
let token;
|
|
102
112
|
try {
|
|
103
113
|
token = await this.getToken();
|
|
104
114
|
}
|
|
105
115
|
catch {
|
|
106
|
-
|
|
116
|
+
// A session that could not be refreshed is still a session: try again
|
|
117
|
+
// later, never as a signed-out visitor.
|
|
118
|
+
if (generation !== this.generation)
|
|
119
|
+
return;
|
|
120
|
+
this.connecting = false;
|
|
121
|
+
this.scheduleReconnect();
|
|
122
|
+
return;
|
|
107
123
|
}
|
|
124
|
+
// The session changed while the token was on its way: sessionChanged
|
|
125
|
+
// has started the connection for the new one.
|
|
126
|
+
if (generation !== this.generation)
|
|
127
|
+
return;
|
|
108
128
|
if (this.listeners.size === 0) {
|
|
109
129
|
// Everyone left while the token was on its way: nothing to connect for.
|
|
110
130
|
this.connecting = false;
|
|
111
131
|
return;
|
|
112
132
|
}
|
|
113
|
-
|
|
133
|
+
const userId = this.getUserId?.() ?? null;
|
|
134
|
+
if (!token && userId) {
|
|
135
|
+
// Signed in, but no usable token right now: try again later rather
|
|
136
|
+
// than drop to public-only.
|
|
114
137
|
this.connecting = false;
|
|
115
|
-
this.scheduleReconnect();
|
|
138
|
+
this.scheduleReconnect();
|
|
116
139
|
return;
|
|
117
140
|
}
|
|
118
|
-
|
|
141
|
+
// Signed out: the project's public key, and only collections anyone can
|
|
142
|
+
// read. The server pins the connection to the key's project.
|
|
143
|
+
this.anonymous = !token;
|
|
144
|
+
this.connectedAs = token ? userId : null;
|
|
145
|
+
this.projectId = token ? projectIdFromToken(token) : null;
|
|
146
|
+
const credential = token
|
|
147
|
+
? `token=${encodeURIComponent(token)}`
|
|
148
|
+
: `public_key=${encodeURIComponent(this.config.publicKey)}`;
|
|
119
149
|
const wsUrl = this.config.baseUrl.replace('https://', 'wss://').replace('http://', 'ws://');
|
|
120
150
|
let ws;
|
|
121
151
|
try {
|
|
122
|
-
ws = new WebSocket(`${wsUrl}/v1/realtime/ws
|
|
152
|
+
ws = new WebSocket(`${wsUrl}/v1/realtime/ws?${credential}`);
|
|
123
153
|
}
|
|
124
154
|
catch {
|
|
125
155
|
this.connecting = false;
|
|
@@ -183,14 +213,22 @@ export class KoolbaseRealtime {
|
|
|
183
213
|
ws.onerror = () => { };
|
|
184
214
|
}
|
|
185
215
|
sendSubscribe(collection) {
|
|
186
|
-
|
|
187
|
-
return;
|
|
188
|
-
this.ws.send(JSON.stringify({ action: 'subscribe', project_id: this.projectId, collection }));
|
|
216
|
+
this.send('subscribe', collection);
|
|
189
217
|
}
|
|
190
218
|
sendUnsubscribe(collection) {
|
|
191
|
-
|
|
219
|
+
this.send('unsubscribe', collection);
|
|
220
|
+
}
|
|
221
|
+
/** A signed-out visitor's message leaves project_id out: it has none to
|
|
222
|
+
* give, and the server uses the public key's project. */
|
|
223
|
+
send(action, collection) {
|
|
224
|
+
if (!this.ws || this.ws.readyState !== WebSocket.OPEN)
|
|
192
225
|
return;
|
|
193
|
-
|
|
226
|
+
if (this.anonymous) {
|
|
227
|
+
this.ws.send(JSON.stringify({ action, collection }));
|
|
228
|
+
}
|
|
229
|
+
else if (this.projectId) {
|
|
230
|
+
this.ws.send(JSON.stringify({ action, project_id: this.projectId, collection }));
|
|
231
|
+
}
|
|
194
232
|
}
|
|
195
233
|
/**
|
|
196
234
|
* Reconnects with backoff, rather than every three seconds forever.
|
|
@@ -249,6 +287,42 @@ export class KoolbaseRealtime {
|
|
|
249
287
|
}, KoolbaseRealtime.idleGraceMs);
|
|
250
288
|
this.idleTimer.unref?.();
|
|
251
289
|
}
|
|
290
|
+
/**
|
|
291
|
+
* The signed-in user changed: signed in, signed out, or another user. The
|
|
292
|
+
* open connection was made for the previous session -- signed out it sees
|
|
293
|
+
* only public collections, signed in it carries that user's access -- so it
|
|
294
|
+
* is replaced by one for the new session, resubscribing every collection.
|
|
295
|
+
* The same user again (a token refresh) changes nothing, and with nothing
|
|
296
|
+
* open there is nothing to do: the next subscribe connects as whoever is
|
|
297
|
+
* signed in then.
|
|
298
|
+
*/
|
|
299
|
+
sessionChanged() {
|
|
300
|
+
if (!this.ws && !this.connecting && !this.reconnectTimer)
|
|
301
|
+
return;
|
|
302
|
+
if (this.ws && !this.connecting && (this.getUserId?.() ?? null) === this.connectedAs)
|
|
303
|
+
return;
|
|
304
|
+
this.generation += 1;
|
|
305
|
+
if (this.reconnectTimer) {
|
|
306
|
+
clearTimeout(this.reconnectTimer);
|
|
307
|
+
this.reconnectTimer = null;
|
|
308
|
+
}
|
|
309
|
+
const ws = this.ws;
|
|
310
|
+
this.ws = null;
|
|
311
|
+
this.connecting = false;
|
|
312
|
+
this.anonymous = false;
|
|
313
|
+
this.projectId = null;
|
|
314
|
+
this.reconnectAttempts = 0;
|
|
315
|
+
if (ws) {
|
|
316
|
+
// Detached first: this close is ours, not a dropped connection.
|
|
317
|
+
ws.onopen = null;
|
|
318
|
+
ws.onmessage = null;
|
|
319
|
+
ws.onclose = null;
|
|
320
|
+
ws.onerror = null;
|
|
321
|
+
ws.close();
|
|
322
|
+
}
|
|
323
|
+
if (this.listeners.size > 0)
|
|
324
|
+
void this.connect();
|
|
325
|
+
}
|
|
252
326
|
disconnect() {
|
|
253
327
|
if (this.idleTimer) {
|
|
254
328
|
clearTimeout(this.idleTimer);
|
package/dist/esm/sync-engine.js
CHANGED
|
@@ -2,7 +2,7 @@ import { koolbaseFetch } from './network.js';
|
|
|
2
2
|
import { readOfflineState, mutateOfflineState, migrateLegacyQueue, flushLockName, } from './offline-state.js';
|
|
3
3
|
import { KoolbaseUnauthenticatedError } from './errors.js';
|
|
4
4
|
import { getPlatform } from './platform.js';
|
|
5
|
-
import { invalidateCache, removeCachedRecord, } from './cache-store.js';
|
|
5
|
+
import { invalidateCache, removeCachedRecord, removeFromCachedQueries, } from './cache-store.js';
|
|
6
6
|
/**
|
|
7
7
|
* Internal signal that the server refused a write because the record moved.
|
|
8
8
|
*
|
|
@@ -98,13 +98,21 @@ export class SyncEngine {
|
|
|
98
98
|
// being online — would give the same input different outcomes. It clears
|
|
99
99
|
// the old key when done, so later calls find nothing and return.
|
|
100
100
|
await migrateLegacyQueue(userId);
|
|
101
|
-
const { pending } = await readOfflineState(userId);
|
|
101
|
+
const { pending, conflicts } = await readOfflineState(userId);
|
|
102
102
|
if (pending.length === 0)
|
|
103
103
|
return;
|
|
104
104
|
// Records whose chain stopped this pass. Writes queued after a conflicted
|
|
105
105
|
// one were composed against the state it would have produced, so applying
|
|
106
106
|
// them now would write against a state their baseline never described.
|
|
107
107
|
const blocked = new Set();
|
|
108
|
+
// And records whose chain stopped in an EARLIER pass: a conflict waits
|
|
109
|
+
// for a decision across restarts, and so must the writes behind it.
|
|
110
|
+
// Seeded only from this pass's finds, the next pass replayed them
|
|
111
|
+
// against a state their baselines never described -- each becoming its
|
|
112
|
+
// own conflict. Deciding the conflict rebases and releases them.
|
|
113
|
+
for (const c of conflicts)
|
|
114
|
+
if (c.recordId)
|
|
115
|
+
blocked.add(c.recordId);
|
|
108
116
|
for (const queued of pending) {
|
|
109
117
|
if (queued.recordId && blocked.has(queued.recordId))
|
|
110
118
|
continue;
|
|
@@ -199,10 +207,15 @@ export class SyncEngine {
|
|
|
199
207
|
// — cached at enqueue for a record the server refused to create.
|
|
200
208
|
// Left alone it is a phantom: it renders as saved, and an offline
|
|
201
209
|
// edit against it queues a write to a record that does not exist.
|
|
202
|
-
// Evict it
|
|
203
|
-
//
|
|
204
|
-
//
|
|
205
|
-
//
|
|
210
|
+
// Evict it from the record cache and from every cached query that
|
|
211
|
+
// holds it -- precisely, not by dropping the collection's saved
|
|
212
|
+
// results, which a device still offline needs. The conflict above
|
|
213
|
+
// keeps the user's data and the server's verdict; the cache stops
|
|
214
|
+
// testifying to a fiction.
|
|
215
|
+
if (write.operation === 'insert' && write.recordId) {
|
|
216
|
+
await removeCachedRecord(userId, write.recordId);
|
|
217
|
+
await removeFromCachedQueries(userId, write.collection, write.recordId);
|
|
218
|
+
}
|
|
206
219
|
if (write.recordId)
|
|
207
220
|
blocked.add(write.recordId);
|
|
208
221
|
continue;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@koolbase/core",
|
|
3
|
-
"version": "12.
|
|
3
|
+
"version": "12.13.1",
|
|
4
4
|
"description": "Koolbase SDK core \u2014 shared behaviour behind @koolbase/react-native and @koolbase/js. Install one of those, not this.",
|
|
5
5
|
"main": "./dist/cjs/index.js",
|
|
6
6
|
"types": "./dist/esm/index.d.ts",
|