@koolbase/core 12.13.0 → 12.14.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/cjs/cache-store.d.ts +14 -0
- package/dist/cjs/cache-store.js +64 -0
- package/dist/cjs/connectivity.d.ts +25 -0
- package/dist/cjs/connectivity.js +59 -0
- package/dist/cjs/database.d.ts +41 -0
- package/dist/cjs/database.js +140 -20
- package/dist/cjs/index.d.ts +3 -1
- package/dist/cjs/index.js +3 -1
- package/dist/cjs/offline-state.d.ts +20 -0
- package/dist/cjs/offline-state.js +70 -0
- package/dist/cjs/platform.d.ts +16 -0
- package/dist/cjs/react/use-record.d.ts +1 -1
- package/dist/cjs/record-controller.d.ts +8 -1
- package/dist/cjs/record-controller.js +29 -7
- package/dist/cjs/shared.d.ts +1 -0
- package/dist/cjs/sync-engine.js +18 -5
- package/dist/esm/cache-store.d.ts +14 -0
- package/dist/esm/cache-store.js +62 -0
- package/dist/esm/connectivity.d.ts +25 -0
- package/dist/esm/connectivity.js +55 -0
- package/dist/esm/database.d.ts +41 -0
- package/dist/esm/database.js +129 -9
- package/dist/esm/index.d.ts +3 -1
- package/dist/esm/index.js +1 -0
- package/dist/esm/offline-state.d.ts +20 -0
- package/dist/esm/offline-state.js +68 -0
- package/dist/esm/platform.d.ts +16 -0
- package/dist/esm/react/use-record.d.ts +1 -1
- package/dist/esm/record-controller.d.ts +8 -1
- package/dist/esm/record-controller.js +29 -7
- package/dist/esm/shared.d.ts +1 -0
- package/dist/esm/sync-engine.js +19 -6
- package/package.json +1 -1
package/dist/esm/database.js
CHANGED
|
@@ -3,7 +3,8 @@ 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, onOfflineStateChange, } from './offline-state.js';
|
|
7
|
+
import { removeFromCachedQueries, updateInCachedQueries } from './cache-store.js';
|
|
7
8
|
import { KoolbaseConflict } from './conflict.js';
|
|
8
9
|
import { toPendingWrite } from './pending-write.js';
|
|
9
10
|
import { getCached, setCached, invalidateCache, optimisticallyInsert, hashQuery, } from './cache-store.js';
|
|
@@ -42,6 +43,8 @@ function batchOpToWire(op) {
|
|
|
42
43
|
}
|
|
43
44
|
export class KoolbaseDatabase {
|
|
44
45
|
constructor(config, getUserId, getToken, onSessionExpired) {
|
|
46
|
+
this.stateWatchers = new Set();
|
|
47
|
+
this.offStateChange = null;
|
|
45
48
|
/**
|
|
46
49
|
* Resolves by id, reloading the stored conflict first.
|
|
47
50
|
*
|
|
@@ -62,12 +65,15 @@ export class KoolbaseDatabase {
|
|
|
62
65
|
resolveWithServer: async (id) => {
|
|
63
66
|
const c = await this.requireConflict(id);
|
|
64
67
|
// The server's version stands. Recorded as a decision by removing the
|
|
65
|
-
// conflict, rather than the change quietly disappearing
|
|
66
|
-
|
|
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);
|
|
67
71
|
},
|
|
68
72
|
abandon: async (id) => {
|
|
69
73
|
const c = await this.requireConflict(id);
|
|
70
|
-
|
|
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);
|
|
71
77
|
},
|
|
72
78
|
};
|
|
73
79
|
this.config = config;
|
|
@@ -427,6 +433,29 @@ export class KoolbaseDatabase {
|
|
|
427
433
|
}
|
|
428
434
|
return record;
|
|
429
435
|
}
|
|
436
|
+
/**
|
|
437
|
+
* The device's saved copy of a record: what this device last read, with the
|
|
438
|
+
* changes queued on it applied. Never contacts the server. Null when there is
|
|
439
|
+
* none: never read here, or deleted on this device.
|
|
440
|
+
*
|
|
441
|
+
* For showing something at once while get() asks the server -- saved-first,
|
|
442
|
+
* never saved-only. Kept data and revision, not createdAt or createdBy, which
|
|
443
|
+
* the saved copy does not hold (empty strings here).
|
|
444
|
+
*/
|
|
445
|
+
async getSaved(recordId) {
|
|
446
|
+
const userId = this.getUserId() ?? 'anonymous';
|
|
447
|
+
const base = await this.resolveBaseline(userId, recordId);
|
|
448
|
+
if (!base)
|
|
449
|
+
return null;
|
|
450
|
+
return {
|
|
451
|
+
id: recordId,
|
|
452
|
+
collection: base.collection,
|
|
453
|
+
data: { ...base.baseline },
|
|
454
|
+
createdAt: '',
|
|
455
|
+
updatedAt: '',
|
|
456
|
+
revision: base.revision,
|
|
457
|
+
};
|
|
458
|
+
}
|
|
430
459
|
// ─── Conflicts ──────────────────────────────────────────────────────────────
|
|
431
460
|
/**
|
|
432
461
|
* Writes that could not be applied, waiting for a decision.
|
|
@@ -447,6 +476,69 @@ export class KoolbaseDatabase {
|
|
|
447
476
|
const { pending } = await readOfflineState(userId);
|
|
448
477
|
return pending.map(toPendingWrite);
|
|
449
478
|
}
|
|
479
|
+
/**
|
|
480
|
+
* The pending writes now, and again whenever they change -- here, in another
|
|
481
|
+
* tab, or because the signed-in user changed. For a sync badge that reflects
|
|
482
|
+
* reality. null while signed out: per-user state has no answer then, and a
|
|
483
|
+
* fake empty list is how a badge says "all synced" while a user's changes
|
|
484
|
+
* sit queued. Returns the unsubscribe.
|
|
485
|
+
*/
|
|
486
|
+
watchPendingWrites(listener) {
|
|
487
|
+
return this.watchState(async (userId) => {
|
|
488
|
+
if (!userId)
|
|
489
|
+
return () => listener(null);
|
|
490
|
+
const { pending } = await readOfflineState(userId);
|
|
491
|
+
const writes = pending.map(toPendingWrite);
|
|
492
|
+
return () => listener(writes);
|
|
493
|
+
});
|
|
494
|
+
}
|
|
495
|
+
/** The unresolved conflicts now, and again whenever they change. null while signed out. */
|
|
496
|
+
watchConflicts(listener) {
|
|
497
|
+
return this.watchState(async (userId) => {
|
|
498
|
+
if (!userId)
|
|
499
|
+
return () => listener(null);
|
|
500
|
+
const list = await this.conflicts();
|
|
501
|
+
return () => listener(list);
|
|
502
|
+
});
|
|
503
|
+
}
|
|
504
|
+
/**
|
|
505
|
+
* The signed-in user changed: every watcher reads again, for the new user
|
|
506
|
+
* (or null, signed out). Koolbase.initialize wires it to auth changes.
|
|
507
|
+
*/
|
|
508
|
+
sessionChanged() {
|
|
509
|
+
for (const emit of [...this.stateWatchers])
|
|
510
|
+
emit();
|
|
511
|
+
}
|
|
512
|
+
/**
|
|
513
|
+
* One watcher: reads at once, then on every change to the signed-in user's
|
|
514
|
+
* offline state. Reads can finish out of order; only the latest delivers.
|
|
515
|
+
*/
|
|
516
|
+
watchState(read) {
|
|
517
|
+
let latest = 0;
|
|
518
|
+
let active = true;
|
|
519
|
+
const emit = () => {
|
|
520
|
+
const mine = ++latest;
|
|
521
|
+
read(this.getUserId()).then((deliver) => { if (active && mine === latest)
|
|
522
|
+
deliver(); }, () => { });
|
|
523
|
+
};
|
|
524
|
+
this.stateWatchers.add(emit);
|
|
525
|
+
if (!this.offStateChange) {
|
|
526
|
+
this.offStateChange = onOfflineStateChange((userId) => {
|
|
527
|
+
if (userId === this.getUserId())
|
|
528
|
+
for (const w of [...this.stateWatchers])
|
|
529
|
+
w();
|
|
530
|
+
});
|
|
531
|
+
}
|
|
532
|
+
emit();
|
|
533
|
+
return () => {
|
|
534
|
+
active = false;
|
|
535
|
+
this.stateWatchers.delete(emit);
|
|
536
|
+
if (this.stateWatchers.size === 0 && this.offStateChange) {
|
|
537
|
+
this.offStateChange();
|
|
538
|
+
this.offStateChange = null;
|
|
539
|
+
}
|
|
540
|
+
};
|
|
541
|
+
}
|
|
450
542
|
/**
|
|
451
543
|
* Count and total over a whole collection, with the read rule applied
|
|
452
544
|
* inside the query.
|
|
@@ -537,6 +629,25 @@ export class KoolbaseDatabase {
|
|
|
537
629
|
}
|
|
538
630
|
return found;
|
|
539
631
|
}
|
|
632
|
+
/**
|
|
633
|
+
* Removes a decided conflict and points the writes held behind it at the
|
|
634
|
+
* state the record is now in -- in one transaction, so the conflict can
|
|
635
|
+
* never be gone while its held writes still describe the old world.
|
|
636
|
+
*
|
|
637
|
+
* Without a known state (a refused add, a delete) there is nothing to
|
|
638
|
+
* rebase onto: the conflict is removed and the held writes stay as they are.
|
|
639
|
+
*/
|
|
640
|
+
async settleConflict(c, resolved, revision) {
|
|
641
|
+
if (!resolved || !c.recordId) {
|
|
642
|
+
await this.dropConflict(c.id);
|
|
643
|
+
return;
|
|
644
|
+
}
|
|
645
|
+
const userId = this.requireUserId('conflict resolution');
|
|
646
|
+
await mutateOfflineState(userId, (s) => {
|
|
647
|
+
s.conflicts = s.conflicts.filter((x) => x.id !== c.id);
|
|
648
|
+
rebaseHeldWrites(s, c.recordId, resolved, revision);
|
|
649
|
+
});
|
|
650
|
+
}
|
|
540
651
|
async dropConflict(id) {
|
|
541
652
|
const userId = this.requireUserId('conflict resolution');
|
|
542
653
|
await mutateOfflineState(userId, (s) => {
|
|
@@ -551,6 +662,8 @@ export class KoolbaseDatabase {
|
|
|
551
662
|
*/
|
|
552
663
|
async applyResolution(c, payload) {
|
|
553
664
|
const rev = c.serverRevision;
|
|
665
|
+
// What the server now holds, from its answer: the state held writes build on.
|
|
666
|
+
let landed = null;
|
|
554
667
|
try {
|
|
555
668
|
if (c.operation === 'insert') {
|
|
556
669
|
// Resolving a rejected insert IS the insert, retried — with amended
|
|
@@ -561,7 +674,7 @@ export class KoolbaseDatabase {
|
|
|
561
674
|
// original on retry rather than duplicating — the queue's own
|
|
562
675
|
// lost-response discipline, extended to the one insert path that
|
|
563
676
|
// lacked it.
|
|
564
|
-
await this.request('POST', '/v1/sdk/db/insert', {
|
|
677
|
+
landed = await this.request('POST', '/v1/sdk/db/insert', {
|
|
565
678
|
collection: c.collection,
|
|
566
679
|
data: payload,
|
|
567
680
|
idempotency_key: c.id,
|
|
@@ -572,7 +685,7 @@ export class KoolbaseDatabase {
|
|
|
572
685
|
await this.request('DELETE', `/v1/sdk/db/records/${c.recordId}${q}`);
|
|
573
686
|
}
|
|
574
687
|
else {
|
|
575
|
-
await this.request('PATCH', `/v1/sdk/db/records/${c.recordId}`, { data: payload, ...(rev !== undefined ? { expected_revision: rev } : {}) });
|
|
688
|
+
landed = await this.request('PATCH', `/v1/sdk/db/records/${c.recordId}`, { data: payload, ...(rev !== undefined ? { expected_revision: rev } : {}) });
|
|
576
689
|
}
|
|
577
690
|
}
|
|
578
691
|
catch (e) {
|
|
@@ -601,7 +714,8 @@ export class KoolbaseDatabase {
|
|
|
601
714
|
}
|
|
602
715
|
throw e;
|
|
603
716
|
}
|
|
604
|
-
|
|
717
|
+
const landedRevision = landed && typeof landed.$revision === 'number' ? landed.$revision : undefined;
|
|
718
|
+
await this.settleConflict(c, landed ?? undefined, landedRevision);
|
|
605
719
|
await invalidateCache(this.getUserId() ?? 'anonymous', c.collection);
|
|
606
720
|
void refreshCollectionQueries(c.collection);
|
|
607
721
|
}
|
|
@@ -639,8 +753,11 @@ export class KoolbaseDatabase {
|
|
|
639
753
|
for (const w of queued) {
|
|
640
754
|
if (w.operation === 'insert')
|
|
641
755
|
projected = { ...(w.data ?? {}) };
|
|
756
|
+
// An edit applies to the record as it was: the queued add's state, or
|
|
757
|
+
// -- first in a chain with no add -- the baseline the edit was made
|
|
758
|
+
// against. Starting from nothing dropped every field it did not touch.
|
|
642
759
|
else if (w.operation === 'update')
|
|
643
|
-
projected = { ...(projected ?? {}), ...(w.data ?? {}) };
|
|
760
|
+
projected = { ...(projected ?? w.baseline ?? {}), ...(w.data ?? {}) };
|
|
644
761
|
else if (w.operation === 'delete')
|
|
645
762
|
projected = null;
|
|
646
763
|
}
|
|
@@ -709,6 +826,8 @@ export class KoolbaseDatabase {
|
|
|
709
826
|
});
|
|
710
827
|
const merged = { ...base.baseline, ...data };
|
|
711
828
|
await cacheRecord(userId, base.collection, recordId, merged, base.revision);
|
|
829
|
+
// And in the saved lists, so they show what the user just changed.
|
|
830
|
+
await updateInCachedQueries(userId, base.collection, recordId, data);
|
|
712
831
|
// Optimistic: durable locally and queued to send, not yet accepted.
|
|
713
832
|
return {
|
|
714
833
|
id: recordId,
|
|
@@ -769,8 +888,9 @@ export class KoolbaseDatabase {
|
|
|
769
888
|
});
|
|
770
889
|
// The queued write holds its own copy of the baseline, so removing the
|
|
771
890
|
// cached record costs nothing and keeps local reads consistent with what
|
|
772
|
-
// the user just did.
|
|
891
|
+
// the user just did -- saved lists included.
|
|
773
892
|
await removeCachedRecord(userId, recordId);
|
|
893
|
+
await removeFromCachedQueries(userId, base.collection, recordId);
|
|
774
894
|
}
|
|
775
895
|
}
|
|
776
896
|
// ─── Vectors ────────────────────────────────────────────────────────────────
|
package/dist/esm/index.d.ts
CHANGED
|
@@ -17,6 +17,8 @@ export type { RecordState, RecordStatus } from './record-controller.js';
|
|
|
17
17
|
export { KoolbaseFlags } from './flags.js';
|
|
18
18
|
export { KoolbaseFunctions } from './functions.js';
|
|
19
19
|
export { KoolbaseRealtime } from './realtime.js';
|
|
20
|
+
export { KoolbaseConnectivity } from './connectivity.js';
|
|
21
|
+
export type { ConnectivityState } from './connectivity.js';
|
|
20
22
|
export { KoolbaseStorage } from './storage.js';
|
|
21
23
|
export { KoolbaseAnalytics, disabledAnalytics } from './analytics.js';
|
|
22
24
|
export { KoolbaseFiscal, KoolbaseFiscalError, decodeFiscalResponse, isFiscalized, isPending } from './fiscal.js';
|
|
@@ -27,4 +29,4 @@ export { getOrCreateDeviceId } from './device-id.js';
|
|
|
27
29
|
export { koolbaseSdkVersion } from './device-metadata.js';
|
|
28
30
|
export { RestoreResult } from './types.js';
|
|
29
31
|
export type { AuthStateListener, FetchLike, KoolbaseAuthStorage, ResendVerificationResult } from './types.js';
|
|
30
|
-
export { setPlatform, getPlatform, memoryPlatform, type PlatformAdapter, type PlatformStorage, type PlatformNetwork, type PlatformLifecycle, type PlatformLocks, type PlatformInfo, } from './platform.js';
|
|
32
|
+
export { setPlatform, getPlatform, memoryPlatform, type PlatformAdapter, type PlatformBroadcast, type PlatformStorage, type PlatformNetwork, type PlatformLifecycle, type PlatformLocks, type PlatformInfo, } from './platform.js';
|
package/dist/esm/index.js
CHANGED
|
@@ -20,6 +20,7 @@ export { KoolbaseRecordController, initialRecordState } from './record-controlle
|
|
|
20
20
|
export { KoolbaseFlags } from './flags.js';
|
|
21
21
|
export { KoolbaseFunctions } from './functions.js';
|
|
22
22
|
export { KoolbaseRealtime } from './realtime.js';
|
|
23
|
+
export { KoolbaseConnectivity } from './connectivity.js';
|
|
23
24
|
export { KoolbaseStorage } from './storage.js';
|
|
24
25
|
export { KoolbaseAnalytics, disabledAnalytics } from './analytics.js';
|
|
25
26
|
export { KoolbaseFiscal, KoolbaseFiscalError, decodeFiscalResponse, isFiscalized, isPending } from './fiscal.js';
|
|
@@ -103,3 +103,23 @@ 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;
|
|
122
|
+
type StateListener = (userId: string) => void;
|
|
123
|
+
/** Called with the user id whenever that user's offline state changes. */
|
|
124
|
+
export declare function onOfflineStateChange(listener: StateListener): () => void;
|
|
125
|
+
export {};
|
|
@@ -130,6 +130,7 @@ export async function mutateOfflineState(userId, mutate) {
|
|
|
130
130
|
}
|
|
131
131
|
await getPlatform().storage.setItem(stateKey(userId), serialised);
|
|
132
132
|
});
|
|
133
|
+
announce(userId);
|
|
133
134
|
}
|
|
134
135
|
/** Adds a write to the queue, under the user's lock. */
|
|
135
136
|
export async function queueWrite(userId, write) {
|
|
@@ -202,3 +203,70 @@ export async function migrateLegacyQueue(userId) {
|
|
|
202
203
|
});
|
|
203
204
|
await getPlatform().storage.removeItem(legacyQueueKey(userId));
|
|
204
205
|
}
|
|
206
|
+
/**
|
|
207
|
+
* Points the writes still queued for a record at the state it is now in.
|
|
208
|
+
*
|
|
209
|
+
* When a conflict is decided, the writes held behind it carry baselines
|
|
210
|
+
* describing the state the conflicted write would have produced -- a state
|
|
211
|
+
* that no longer exists, and may never have. Released unchanged they would
|
|
212
|
+
* replay against a revision that has moved on, and each would conflict in
|
|
213
|
+
* turn: one disagreement multiplying into as many conflicts as the user had
|
|
214
|
+
* queued. The same rule as the Flutter SDK's rebaseAfterResolution.
|
|
215
|
+
*
|
|
216
|
+
* Refreshing the baseline is enough because every operation is an absolute
|
|
217
|
+
* assignment: a patch says what a field becomes, not what to do to it. If a
|
|
218
|
+
* relative operation is ever added (an increment, an append), the chain must
|
|
219
|
+
* be replayed through its projected states instead.
|
|
220
|
+
*/
|
|
221
|
+
export function rebaseHeldWrites(state, recordId, resolved, revision) {
|
|
222
|
+
let current = {};
|
|
223
|
+
for (const [k, v] of Object.entries(resolved)) {
|
|
224
|
+
if (!k.startsWith('$'))
|
|
225
|
+
current[k] = v;
|
|
226
|
+
}
|
|
227
|
+
for (const w of state.pending) {
|
|
228
|
+
if (w.recordId !== recordId)
|
|
229
|
+
continue;
|
|
230
|
+
w.baseline = { ...current };
|
|
231
|
+
w.baseRevision = revision;
|
|
232
|
+
// Each write is composed against the one before it.
|
|
233
|
+
if (w.operation === 'update')
|
|
234
|
+
current = { ...current, ...(w.data ?? {}) };
|
|
235
|
+
else if (w.operation === 'delete')
|
|
236
|
+
break;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
// ─── Change notification ─────────────────────────────────────────────────────
|
|
240
|
+
const ANNOUNCE_PREFIX = 'koolbase:offline-state:';
|
|
241
|
+
function stateListeners() {
|
|
242
|
+
return shared('offlineStateListeners', () => new Set());
|
|
243
|
+
}
|
|
244
|
+
/**
|
|
245
|
+
* Tells whoever is watching that a user's offline state changed -- here, and
|
|
246
|
+
* in every other copy of the SDK sharing this storage (other tabs). A badge in
|
|
247
|
+
* one tab must move when another tab's sync drains the queue.
|
|
248
|
+
*/
|
|
249
|
+
function announce(userId) {
|
|
250
|
+
for (const listener of [...stateListeners()]) {
|
|
251
|
+
try {
|
|
252
|
+
listener(userId);
|
|
253
|
+
}
|
|
254
|
+
catch { /* one broken listener must not stop the rest */ }
|
|
255
|
+
}
|
|
256
|
+
try {
|
|
257
|
+
getPlatform().broadcast?.post(ANNOUNCE_PREFIX + userId);
|
|
258
|
+
}
|
|
259
|
+
catch { /* best effort */ }
|
|
260
|
+
}
|
|
261
|
+
/** Called with the user id whenever that user's offline state changes. */
|
|
262
|
+
export function onOfflineStateChange(listener) {
|
|
263
|
+
stateListeners().add(listener);
|
|
264
|
+
const off = getPlatform().broadcast?.on((message) => {
|
|
265
|
+
if (message.startsWith(ANNOUNCE_PREFIX))
|
|
266
|
+
listener(message.slice(ANNOUNCE_PREFIX.length));
|
|
267
|
+
});
|
|
268
|
+
return () => {
|
|
269
|
+
stateListeners().delete(listener);
|
|
270
|
+
off?.();
|
|
271
|
+
};
|
|
272
|
+
}
|
package/dist/esm/platform.d.ts
CHANGED
|
@@ -12,6 +12,21 @@ export interface PlatformNetwork {
|
|
|
12
12
|
* Koolbase is unreachable is handled the same as any failed request.
|
|
13
13
|
*/
|
|
14
14
|
onChange(callback: (online: boolean) => void): () => void;
|
|
15
|
+
/**
|
|
16
|
+
* What the host believes right now: true online, false offline, null when
|
|
17
|
+
* it cannot tell. Optional: a host without it reports nothing until its
|
|
18
|
+
* first change, and connectivity stays 'unknown' until then.
|
|
19
|
+
*/
|
|
20
|
+
current?(): Promise<boolean | null>;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* A message to every other copy of the SDK sharing this storage -- other tabs
|
|
24
|
+
* of the same origin in a browser. Optional: one process (React Native) has no
|
|
25
|
+
* other copies to tell.
|
|
26
|
+
*/
|
|
27
|
+
export interface PlatformBroadcast {
|
|
28
|
+
post(message: string): void;
|
|
29
|
+
on(callback: (message: string) => void): () => void;
|
|
15
30
|
}
|
|
16
31
|
export interface PlatformLifecycle {
|
|
17
32
|
/** Subscribe to the app moving to the background. Used to flush analytics. */
|
|
@@ -54,6 +69,7 @@ export interface PlatformAdapter {
|
|
|
54
69
|
lifecycle: PlatformLifecycle;
|
|
55
70
|
info: PlatformInfo;
|
|
56
71
|
locks: PlatformLocks;
|
|
72
|
+
broadcast?: PlatformBroadcast;
|
|
57
73
|
/**
|
|
58
74
|
* The host's best persistent store for the auth session, or null if it has
|
|
59
75
|
* none worth the name. Used only when the app injects nothing through
|
|
@@ -3,6 +3,6 @@ export interface UseRecordResult extends RecordState {
|
|
|
3
3
|
/** The record again, from the server. What is shown stays while it runs, and stays if it fails. */
|
|
4
4
|
refresh: () => Promise<void>;
|
|
5
5
|
}
|
|
6
|
-
export declare function createUseRecord(getDb: () => Pick<KoolbaseDatabase, 'get'>): (collection: string, id: string | null | undefined, options?: {
|
|
6
|
+
export declare function createUseRecord(getDb: () => Pick<KoolbaseDatabase, 'get'> & Partial<Pick<KoolbaseDatabase, 'getSaved'>>): (collection: string, id: string | null | undefined, options?: {
|
|
7
7
|
live?: boolean;
|
|
8
8
|
}) => UseRecordResult;
|
|
@@ -8,9 +8,14 @@ export interface RecordState {
|
|
|
8
8
|
readonly error: unknown;
|
|
9
9
|
/** True while refresh() runs. */
|
|
10
10
|
readonly refreshing: boolean;
|
|
11
|
+
/**
|
|
12
|
+
* True while the record shown is the device's saved copy and the server has
|
|
13
|
+
* not confirmed it on this load. Stays true if the server cannot be reached.
|
|
14
|
+
*/
|
|
15
|
+
readonly isSaved: boolean;
|
|
11
16
|
}
|
|
12
17
|
export declare const initialRecordState: RecordState;
|
|
13
|
-
type RecordReader = Pick<KoolbaseDatabase, 'get'
|
|
18
|
+
type RecordReader = Pick<KoolbaseDatabase, 'get'> & Partial<Pick<KoolbaseDatabase, 'getSaved'>>;
|
|
14
19
|
export declare class KoolbaseRecordController {
|
|
15
20
|
readonly collection: string;
|
|
16
21
|
readonly id: string;
|
|
@@ -31,6 +36,8 @@ export declare class KoolbaseRecordController {
|
|
|
31
36
|
subscribe(listener: () => void): () => void;
|
|
32
37
|
/** The first load. Call once; refresh() for later loads. */
|
|
33
38
|
load(): Promise<void>;
|
|
39
|
+
/** The device's saved copy, shown at once while the server is asked. */
|
|
40
|
+
private showSaved;
|
|
34
41
|
/** The record again, from the server. What is shown stays while it runs, and stays if it fails. */
|
|
35
42
|
refresh(): Promise<void>;
|
|
36
43
|
/** Stops all updates. A result still in flight is dropped when it lands. */
|
|
@@ -14,8 +14,10 @@
|
|
|
14
14
|
// or landing after dispose(), is dropped.
|
|
15
15
|
// - live (the option): realtime events for THIS record only. An update
|
|
16
16
|
// re-reads it silently, once per burst; a delete is notFound at once.
|
|
17
|
-
//
|
|
18
|
-
//
|
|
17
|
+
// - saved-first, never saved-only: the device's saved copy (db.getSaved)
|
|
18
|
+
// shows at once with isSaved true while the server is asked. Success
|
|
19
|
+
// replaces it (isSaved false); failure keeps it (isSaved stays true); not
|
|
20
|
+
// found is notFound. No saved copy: the ordinary loading/error states.
|
|
19
21
|
import { KoolbaseNotFoundError } from './database-errors.js';
|
|
20
22
|
import { liveSource } from './live-source.js';
|
|
21
23
|
export const initialRecordState = Object.freeze({
|
|
@@ -23,6 +25,7 @@ export const initialRecordState = Object.freeze({
|
|
|
23
25
|
record: null,
|
|
24
26
|
error: null,
|
|
25
27
|
refreshing: false,
|
|
28
|
+
isSaved: false,
|
|
26
29
|
});
|
|
27
30
|
export class KoolbaseRecordController {
|
|
28
31
|
constructor(db, collection, id, options = {}) {
|
|
@@ -58,8 +61,27 @@ export class KoolbaseRecordController {
|
|
|
58
61
|
if (this.live && this.id) {
|
|
59
62
|
this.unlive ?? (this.unlive = liveSource()?.subscribe(this.collection, (event) => this.onLive(event)) ?? null);
|
|
60
63
|
}
|
|
64
|
+
await this.showSaved();
|
|
61
65
|
await this.fetch();
|
|
62
66
|
}
|
|
67
|
+
/** The device's saved copy, shown at once while the server is asked. */
|
|
68
|
+
async showSaved() {
|
|
69
|
+
if (!this.id || !this.db.getSaved)
|
|
70
|
+
return;
|
|
71
|
+
const gen = this.generation;
|
|
72
|
+
let saved = null;
|
|
73
|
+
try {
|
|
74
|
+
saved = await this.db.getSaved(this.id);
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
if (!saved || this.isStale(gen) || this.state.status !== 'loading')
|
|
80
|
+
return;
|
|
81
|
+
if (saved.collection !== undefined && saved.collection !== this.collection)
|
|
82
|
+
return;
|
|
83
|
+
this.set({ status: 'loaded', record: saved, error: null, isSaved: true });
|
|
84
|
+
}
|
|
63
85
|
/** The record again, from the server. What is shown stays while it runs, and stays if it fails. */
|
|
64
86
|
async refresh() {
|
|
65
87
|
if (this.disposed)
|
|
@@ -99,7 +121,7 @@ export class KoolbaseRecordController {
|
|
|
99
121
|
this.liveTimer = null;
|
|
100
122
|
}
|
|
101
123
|
this.generation += 1;
|
|
102
|
-
this.set({ status: 'notFound', record: null, error: null, refreshing: false });
|
|
124
|
+
this.set({ status: 'notFound', record: null, error: null, refreshing: false, isSaved: false });
|
|
103
125
|
return;
|
|
104
126
|
}
|
|
105
127
|
if (this.liveTimer)
|
|
@@ -114,7 +136,7 @@ export class KoolbaseRecordController {
|
|
|
114
136
|
async fetch() {
|
|
115
137
|
const gen = ++this.generation;
|
|
116
138
|
if (!this.id) {
|
|
117
|
-
this.set({ status: 'notFound', record: null, error: null });
|
|
139
|
+
this.set({ status: 'notFound', record: null, error: null, isSaved: false });
|
|
118
140
|
return gen;
|
|
119
141
|
}
|
|
120
142
|
try {
|
|
@@ -122,17 +144,17 @@ export class KoolbaseRecordController {
|
|
|
122
144
|
if (this.isStale(gen))
|
|
123
145
|
return gen;
|
|
124
146
|
if (record.collection !== undefined && record.collection !== this.collection) {
|
|
125
|
-
this.set({ status: 'notFound', record: null, error: null });
|
|
147
|
+
this.set({ status: 'notFound', record: null, error: null, isSaved: false });
|
|
126
148
|
}
|
|
127
149
|
else {
|
|
128
|
-
this.set({ status: 'loaded', record, error: null });
|
|
150
|
+
this.set({ status: 'loaded', record, error: null, isSaved: false });
|
|
129
151
|
}
|
|
130
152
|
}
|
|
131
153
|
catch (error) {
|
|
132
154
|
if (this.isStale(gen))
|
|
133
155
|
return gen;
|
|
134
156
|
if (error instanceof KoolbaseNotFoundError) {
|
|
135
|
-
this.set({ status: 'notFound', record: null, error: null });
|
|
157
|
+
this.set({ status: 'notFound', record: null, error: null, isSaved: false });
|
|
136
158
|
}
|
|
137
159
|
else if (this.state.record === null) {
|
|
138
160
|
this.set({ status: 'error', error });
|
package/dist/esm/shared.d.ts
CHANGED
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.14.0",
|
|
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",
|