@koolbase/core 12.13.1 → 12.15.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/auth.d.ts +31 -1
- package/dist/cjs/auth.js +167 -20
- package/dist/cjs/cache-store.d.ts +5 -0
- package/dist/cjs/cache-store.js +31 -0
- package/dist/cjs/connectivity.d.ts +25 -0
- package/dist/cjs/connectivity.js +59 -0
- package/dist/cjs/database.d.ts +32 -0
- package/dist/cjs/database.js +111 -16
- package/dist/cjs/fiscal.js +2 -2
- package/dist/cjs/functions.js +2 -2
- package/dist/cjs/index.d.ts +3 -1
- package/dist/cjs/index.js +3 -1
- package/dist/cjs/network.d.ts +14 -0
- package/dist/cjs/network.js +60 -0
- package/dist/cjs/offline-state.d.ts +4 -0
- package/dist/cjs/offline-state.js +36 -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/storage.js +11 -11
- package/dist/cjs/sync-engine.js +3 -3
- package/dist/cjs/types.d.ts +7 -0
- package/dist/esm/auth.d.ts +31 -1
- package/dist/esm/auth.js +168 -21
- package/dist/esm/cache-store.d.ts +5 -0
- package/dist/esm/cache-store.js +30 -0
- package/dist/esm/connectivity.d.ts +25 -0
- package/dist/esm/connectivity.js +55 -0
- package/dist/esm/database.d.ts +32 -0
- package/dist/esm/database.js +101 -6
- package/dist/esm/fiscal.js +3 -3
- package/dist/esm/functions.js +3 -3
- package/dist/esm/index.d.ts +3 -1
- package/dist/esm/index.js +1 -0
- package/dist/esm/network.d.ts +14 -0
- package/dist/esm/network.js +58 -0
- package/dist/esm/offline-state.d.ts +4 -0
- package/dist/esm/offline-state.js +35 -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/storage.js +12 -12
- package/dist/esm/sync-engine.js +4 -4
- package/dist/esm/types.d.ts +7 -0
- package/package.json +1 -1
package/dist/esm/database.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { sessionFetch } from './network.js';
|
|
2
2
|
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, rebaseHeldWrites, } 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
|
*
|
|
@@ -91,7 +94,7 @@ export class KoolbaseDatabase {
|
|
|
91
94
|
};
|
|
92
95
|
}
|
|
93
96
|
async request(method, path, body) {
|
|
94
|
-
const res = await
|
|
97
|
+
const res = await sessionFetch(`${this.config.baseUrl}${path}`, {
|
|
95
98
|
method,
|
|
96
99
|
headers: await this.buildHeaders(),
|
|
97
100
|
body: body ? JSON.stringify(body) : undefined,
|
|
@@ -129,7 +132,7 @@ export class KoolbaseDatabase {
|
|
|
129
132
|
* none of them clearing a rejected session. One path, two shapes of result.
|
|
130
133
|
*/
|
|
131
134
|
async requestWithStatus(method, path, body) {
|
|
132
|
-
const res = await
|
|
135
|
+
const res = await sessionFetch(`${this.config.baseUrl}${path}`, {
|
|
133
136
|
method,
|
|
134
137
|
headers: await this.buildHeaders(),
|
|
135
138
|
body: body ? JSON.stringify(body) : undefined,
|
|
@@ -430,6 +433,29 @@ export class KoolbaseDatabase {
|
|
|
430
433
|
}
|
|
431
434
|
return record;
|
|
432
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
|
+
}
|
|
433
459
|
// ─── Conflicts ──────────────────────────────────────────────────────────────
|
|
434
460
|
/**
|
|
435
461
|
* Writes that could not be applied, waiting for a decision.
|
|
@@ -450,6 +476,69 @@ export class KoolbaseDatabase {
|
|
|
450
476
|
const { pending } = await readOfflineState(userId);
|
|
451
477
|
return pending.map(toPendingWrite);
|
|
452
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
|
+
}
|
|
453
542
|
/**
|
|
454
543
|
* Count and total over a whole collection, with the read rule applied
|
|
455
544
|
* inside the query.
|
|
@@ -664,8 +753,11 @@ export class KoolbaseDatabase {
|
|
|
664
753
|
for (const w of queued) {
|
|
665
754
|
if (w.operation === 'insert')
|
|
666
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.
|
|
667
759
|
else if (w.operation === 'update')
|
|
668
|
-
projected = { ...(projected ?? {}), ...(w.data ?? {}) };
|
|
760
|
+
projected = { ...(projected ?? w.baseline ?? {}), ...(w.data ?? {}) };
|
|
669
761
|
else if (w.operation === 'delete')
|
|
670
762
|
projected = null;
|
|
671
763
|
}
|
|
@@ -734,6 +826,8 @@ export class KoolbaseDatabase {
|
|
|
734
826
|
});
|
|
735
827
|
const merged = { ...base.baseline, ...data };
|
|
736
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);
|
|
737
831
|
// Optimistic: durable locally and queued to send, not yet accepted.
|
|
738
832
|
return {
|
|
739
833
|
id: recordId,
|
|
@@ -794,8 +888,9 @@ export class KoolbaseDatabase {
|
|
|
794
888
|
});
|
|
795
889
|
// The queued write holds its own copy of the baseline, so removing the
|
|
796
890
|
// cached record costs nothing and keeps local reads consistent with what
|
|
797
|
-
// the user just did.
|
|
891
|
+
// the user just did -- saved lists included.
|
|
798
892
|
await removeCachedRecord(userId, recordId);
|
|
893
|
+
await removeFromCachedQueries(userId, base.collection, recordId);
|
|
799
894
|
}
|
|
800
895
|
}
|
|
801
896
|
// ─── Vectors ────────────────────────────────────────────────────────────────
|
package/dist/esm/fiscal.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { sessionFetch } from './network.js';
|
|
2
2
|
import { KoolbaseError, KoolbaseUnauthenticatedError } from './errors.js';
|
|
3
3
|
const KNOWN_STATUSES = [
|
|
4
4
|
'created', 'blocked', 'queued', 'submitting',
|
|
@@ -106,7 +106,7 @@ export class KoolbaseFiscal {
|
|
|
106
106
|
const controller = new AbortController();
|
|
107
107
|
const timer = setTimeout(() => controller.abort(), params.timeoutMs ?? 30000);
|
|
108
108
|
try {
|
|
109
|
-
const res = await
|
|
109
|
+
const res = await sessionFetch(`${this.config.baseUrl}/v1/sdk/fiscal/submit`, {
|
|
110
110
|
method: 'POST',
|
|
111
111
|
headers: await this.buildHeaders(),
|
|
112
112
|
signal: controller.signal,
|
|
@@ -144,7 +144,7 @@ export class KoolbaseFiscal {
|
|
|
144
144
|
device_id: params.deviceId,
|
|
145
145
|
client_ref: params.clientRef,
|
|
146
146
|
});
|
|
147
|
-
const res = await
|
|
147
|
+
const res = await sessionFetch(`${this.config.baseUrl}/v1/sdk/fiscal/status?${q.toString()}`, { method: 'GET', headers: await this.buildHeaders(), signal: controller.signal });
|
|
148
148
|
return await this.decode(res);
|
|
149
149
|
}
|
|
150
150
|
catch (e) {
|
package/dist/esm/functions.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { sessionFetch } from './network.js';
|
|
2
2
|
import { functionInvokeError } from './function-errors.js';
|
|
3
3
|
import { KoolbaseUnauthenticatedError } from './errors.js';
|
|
4
4
|
import { FunctionRuntime, } from './types.js';
|
|
@@ -11,7 +11,7 @@ export class KoolbaseFunctions {
|
|
|
11
11
|
// ─── Deploy ────────────────────────────────────────────────────────────────
|
|
12
12
|
async deploy(options) {
|
|
13
13
|
const runtime = options.runtime ?? FunctionRuntime.Deno;
|
|
14
|
-
const res = await
|
|
14
|
+
const res = await sessionFetch(`${this.config.baseUrl}/v1/sdk/functions/deploy`, {
|
|
15
15
|
method: 'POST',
|
|
16
16
|
headers: {
|
|
17
17
|
'Content-Type': 'application/json',
|
|
@@ -55,7 +55,7 @@ export class KoolbaseFunctions {
|
|
|
55
55
|
if (userToken) {
|
|
56
56
|
headers['Authorization'] = `Bearer ${userToken}`;
|
|
57
57
|
}
|
|
58
|
-
const res = await
|
|
58
|
+
const res = await sessionFetch(`${this.config.baseUrl}/v1/sdk/functions/${name}`, {
|
|
59
59
|
method: 'POST',
|
|
60
60
|
headers,
|
|
61
61
|
body: JSON.stringify({ body: body ?? {} }),
|
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';
|
package/dist/esm/network.d.ts
CHANGED
|
@@ -19,3 +19,17 @@ export declare class KoolbaseNetworkError extends TypeError {
|
|
|
19
19
|
}
|
|
20
20
|
/** fetch, with network failures turned into a KoolbaseNetworkError. */
|
|
21
21
|
export declare function koolbaseFetch(input: string, init?: RequestInit): Promise<Response>;
|
|
22
|
+
type Renewal = (refusedToken: string) => Promise<string | null>;
|
|
23
|
+
/** Installed by Koolbase.initialize: renews the session a request was refused with. */
|
|
24
|
+
export declare function setSessionRenewal(fn: Renewal | null): void;
|
|
25
|
+
/**
|
|
26
|
+
* koolbaseFetch for a data request. A request refused as unauthenticated
|
|
27
|
+
* (401) while carrying the signed-in person's token is the session needing
|
|
28
|
+
* renewal, not ending: the access token expired early, the clock is off, or
|
|
29
|
+
* another tab renewed it. The session is renewed once and the request sent
|
|
30
|
+
* again with the new token, so the caller never sees the first refusal.
|
|
31
|
+
* Only when renewal is refused does the 401 reach the caller, and with it the
|
|
32
|
+
* client's onSessionExpired, which ends the session.
|
|
33
|
+
*/
|
|
34
|
+
export declare function sessionFetch(input: string, init?: RequestInit): Promise<Response>;
|
|
35
|
+
export {};
|
package/dist/esm/network.js
CHANGED
|
@@ -47,3 +47,61 @@ export async function koolbaseFetch(input, init) {
|
|
|
47
47
|
throw new KoolbaseNetworkError(input, e);
|
|
48
48
|
}
|
|
49
49
|
}
|
|
50
|
+
let renewal = null;
|
|
51
|
+
/** Installed by Koolbase.initialize: renews the session a request was refused with. */
|
|
52
|
+
export function setSessionRenewal(fn) {
|
|
53
|
+
renewal = fn;
|
|
54
|
+
}
|
|
55
|
+
function headerOf(headers, name) {
|
|
56
|
+
if (!headers)
|
|
57
|
+
return undefined;
|
|
58
|
+
if (typeof headers.get === 'function')
|
|
59
|
+
return headers.get(name) ?? undefined;
|
|
60
|
+
if (Array.isArray(headers))
|
|
61
|
+
return headers.find(([k]) => k.toLowerCase() === name.toLowerCase())?.[1];
|
|
62
|
+
const rec = headers;
|
|
63
|
+
const key = Object.keys(rec).find((k) => k.toLowerCase() === name.toLowerCase());
|
|
64
|
+
return key ? rec[key] : undefined;
|
|
65
|
+
}
|
|
66
|
+
function withHeader(headers, name, value) {
|
|
67
|
+
const out = {};
|
|
68
|
+
if (headers && typeof headers.forEach === 'function' && !Array.isArray(headers)) {
|
|
69
|
+
headers.forEach((v, k) => { out[k] = v; });
|
|
70
|
+
}
|
|
71
|
+
else if (Array.isArray(headers)) {
|
|
72
|
+
for (const [k, v] of headers)
|
|
73
|
+
out[k] = v;
|
|
74
|
+
}
|
|
75
|
+
else if (headers) {
|
|
76
|
+
Object.assign(out, headers);
|
|
77
|
+
}
|
|
78
|
+
for (const k of Object.keys(out))
|
|
79
|
+
if (k.toLowerCase() === name.toLowerCase())
|
|
80
|
+
delete out[k];
|
|
81
|
+
out[name] = value;
|
|
82
|
+
return out;
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* koolbaseFetch for a data request. A request refused as unauthenticated
|
|
86
|
+
* (401) while carrying the signed-in person's token is the session needing
|
|
87
|
+
* renewal, not ending: the access token expired early, the clock is off, or
|
|
88
|
+
* another tab renewed it. The session is renewed once and the request sent
|
|
89
|
+
* again with the new token, so the caller never sees the first refusal.
|
|
90
|
+
* Only when renewal is refused does the 401 reach the caller, and with it the
|
|
91
|
+
* client's onSessionExpired, which ends the session.
|
|
92
|
+
*/
|
|
93
|
+
export async function sessionFetch(input, init) {
|
|
94
|
+
const res = await koolbaseFetch(input, init);
|
|
95
|
+
if (res.status !== 401 || !renewal)
|
|
96
|
+
return res;
|
|
97
|
+
const auth = headerOf(init?.headers, 'Authorization');
|
|
98
|
+
if (!auth || !auth.startsWith('Bearer '))
|
|
99
|
+
return res;
|
|
100
|
+
// Only a body that can be sent twice. Data requests send JSON text.
|
|
101
|
+
if (init?.body !== undefined && init.body !== null && typeof init.body !== 'string')
|
|
102
|
+
return res;
|
|
103
|
+
const fresh = await renewal(auth.slice('Bearer '.length));
|
|
104
|
+
if (!fresh)
|
|
105
|
+
return res;
|
|
106
|
+
return koolbaseFetch(input, { ...init, headers: withHeader(init?.headers, 'Authorization', `Bearer ${fresh}`) });
|
|
107
|
+
}
|
|
@@ -119,3 +119,7 @@ export declare function migrateLegacyQueue(userId: string): Promise<void>;
|
|
|
119
119
|
* be replayed through its projected states instead.
|
|
120
120
|
*/
|
|
121
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) {
|
|
@@ -235,3 +236,37 @@ export function rebaseHeldWrites(state, recordId, resolved, revision) {
|
|
|
235
236
|
break;
|
|
236
237
|
}
|
|
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/storage.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { KoolbaseStorageProjectIdentityError } from './storage-errors.js';
|
|
2
|
-
import {
|
|
2
|
+
import { sessionFetch } from './network.js';
|
|
3
3
|
import { KoolbaseUnauthenticatedError } from './errors.js';
|
|
4
4
|
import { KoolbaseStorageError, koolbaseStorageErrorFromResponse, } from './storage-errors.js';
|
|
5
5
|
// --- Cloudflare image-transform URL helpers -------------------------------
|
|
@@ -98,7 +98,7 @@ export class KoolbaseStorage {
|
|
|
98
98
|
? options.file.type || 'application/octet-stream'
|
|
99
99
|
: options.file.type;
|
|
100
100
|
// ─── Step 1: Get presigned upload URL ───
|
|
101
|
-
const urlRes = await
|
|
101
|
+
const urlRes = await sessionFetch(`${this.config.baseUrl}/v1/sdk/storage/upload-url`, {
|
|
102
102
|
method: 'POST',
|
|
103
103
|
headers: {
|
|
104
104
|
...(await this.buildHeaders()),
|
|
@@ -124,9 +124,9 @@ export class KoolbaseStorage {
|
|
|
124
124
|
// the two hosts differ here and nowhere else in this method.
|
|
125
125
|
const fileBlob = isBlob
|
|
126
126
|
? options.file
|
|
127
|
-
: await (await
|
|
127
|
+
: await (await sessionFetch(options.file.uri)).blob();
|
|
128
128
|
const fileSize = fileBlob.size;
|
|
129
|
-
const uploadRes = await
|
|
129
|
+
const uploadRes = await sessionFetch(upload_url, {
|
|
130
130
|
method: 'PUT',
|
|
131
131
|
headers: { 'Content-Type': contentType },
|
|
132
132
|
body: fileBlob,
|
|
@@ -153,7 +153,7 @@ export class KoolbaseStorage {
|
|
|
153
153
|
if (options.metadata !== undefined) {
|
|
154
154
|
confirmBody.metadata = options.metadata;
|
|
155
155
|
}
|
|
156
|
-
const confirmRes = await
|
|
156
|
+
const confirmRes = await sessionFetch(`${this.config.baseUrl}/v1/sdk/storage/confirm`, {
|
|
157
157
|
method: 'POST',
|
|
158
158
|
headers: {
|
|
159
159
|
...(await this.buildHeaders()),
|
|
@@ -204,7 +204,7 @@ export class KoolbaseStorage {
|
|
|
204
204
|
* // -> { category: 'landscape', tag: 'sunset' }
|
|
205
205
|
*/
|
|
206
206
|
async updateMetadata(bucket, path, metadata) {
|
|
207
|
-
const res = await
|
|
207
|
+
const res = await sessionFetch(`${this.config.baseUrl}/v1/sdk/storage/objects/metadata`, {
|
|
208
208
|
method: 'PATCH',
|
|
209
209
|
headers: {
|
|
210
210
|
...(await this.buildHeaders()),
|
|
@@ -227,7 +227,7 @@ export class KoolbaseStorage {
|
|
|
227
227
|
if (versionId) {
|
|
228
228
|
url += `&version_id=${encodeURIComponent(versionId)}`;
|
|
229
229
|
}
|
|
230
|
-
const res = await
|
|
230
|
+
const res = await sessionFetch(url, { headers: await this.buildHeaders() });
|
|
231
231
|
if (!res.ok) {
|
|
232
232
|
throw await this.error(res, 'Failed to get download URL');
|
|
233
233
|
}
|
|
@@ -339,7 +339,7 @@ export class KoolbaseStorage {
|
|
|
339
339
|
const url = forcePurge
|
|
340
340
|
? `${this.config.baseUrl}/v1/sdk/storage/object?force_purge=true`
|
|
341
341
|
: `${this.config.baseUrl}/v1/sdk/storage/object`;
|
|
342
|
-
const res = await
|
|
342
|
+
const res = await sessionFetch(url, {
|
|
343
343
|
method: 'DELETE',
|
|
344
344
|
headers: {
|
|
345
345
|
...(await this.buildHeaders()),
|
|
@@ -366,7 +366,7 @@ export class KoolbaseStorage {
|
|
|
366
366
|
async listVersions(bucket, path) {
|
|
367
367
|
const url = `${this.config.baseUrl}/v1/sdk/storage/object-versions` +
|
|
368
368
|
`?bucket=${encodeURIComponent(bucket)}&path=${encodeURIComponent(path)}`;
|
|
369
|
-
const res = await
|
|
369
|
+
const res = await sessionFetch(url, { headers: await this.buildHeaders() });
|
|
370
370
|
if (!res.ok) {
|
|
371
371
|
throw await this.error(res, 'Failed to list versions');
|
|
372
372
|
}
|
|
@@ -382,7 +382,7 @@ export class KoolbaseStorage {
|
|
|
382
382
|
async getVersion(bucket, path, versionId) {
|
|
383
383
|
const url = `${this.config.baseUrl}/v1/sdk/storage/object-versions/${encodeURIComponent(versionId)}` +
|
|
384
384
|
`?bucket=${encodeURIComponent(bucket)}&path=${encodeURIComponent(path)}`;
|
|
385
|
-
const res = await
|
|
385
|
+
const res = await sessionFetch(url, { headers: await this.buildHeaders() });
|
|
386
386
|
if (!res.ok) {
|
|
387
387
|
throw await this.error(res, 'Failed to fetch version');
|
|
388
388
|
}
|
|
@@ -401,7 +401,7 @@ export class KoolbaseStorage {
|
|
|
401
401
|
async restoreVersion(bucket, path, versionId) {
|
|
402
402
|
const url = `${this.config.baseUrl}/v1/sdk/storage/object-versions/${encodeURIComponent(versionId)}/restore` +
|
|
403
403
|
`?bucket=${encodeURIComponent(bucket)}&path=${encodeURIComponent(path)}`;
|
|
404
|
-
const res = await
|
|
404
|
+
const res = await sessionFetch(url, {
|
|
405
405
|
method: 'POST',
|
|
406
406
|
headers: await this.buildHeaders(),
|
|
407
407
|
});
|
|
@@ -419,7 +419,7 @@ export class KoolbaseStorage {
|
|
|
419
419
|
async purgeVersion(bucket, path, versionId) {
|
|
420
420
|
const url = `${this.config.baseUrl}/v1/sdk/storage/object-versions/${encodeURIComponent(versionId)}` +
|
|
421
421
|
`?bucket=${encodeURIComponent(bucket)}&path=${encodeURIComponent(path)}`;
|
|
422
|
-
const res = await
|
|
422
|
+
const res = await sessionFetch(url, {
|
|
423
423
|
method: 'DELETE',
|
|
424
424
|
headers: await this.buildHeaders(),
|
|
425
425
|
});
|