@koolbase/core 10.0.1 → 10.2.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/analytics.js +5 -0
- package/dist/cjs/index.d.ts +1 -1
- package/dist/cjs/offline-state.d.ts +8 -0
- package/dist/cjs/offline-state.js +18 -1
- package/dist/cjs/platform.d.ts +26 -0
- package/dist/cjs/platform.js +6 -0
- package/dist/cjs/realtime.js +3 -0
- package/dist/cjs/storage.js +16 -4
- package/dist/cjs/sync-engine.d.ts +3 -0
- package/dist/cjs/sync-engine.js +27 -0
- package/dist/cjs/types.d.ts +10 -1
- package/dist/esm/analytics.js +5 -0
- package/dist/esm/index.d.ts +1 -1
- package/dist/esm/offline-state.d.ts +8 -0
- package/dist/esm/offline-state.js +16 -1
- package/dist/esm/platform.d.ts +26 -0
- package/dist/esm/platform.js +6 -0
- package/dist/esm/realtime.js +3 -0
- package/dist/esm/storage.js +16 -4
- package/dist/esm/sync-engine.d.ts +3 -0
- package/dist/esm/sync-engine.js +28 -1
- package/dist/esm/types.d.ts +10 -1
- package/package.json +1 -1
package/dist/cjs/analytics.js
CHANGED
|
@@ -29,6 +29,11 @@ class KoolbaseAnalytics {
|
|
|
29
29
|
(0, platform_js_1.getPlatform)().lifecycle.onBackground(() => { this.flush(); });
|
|
30
30
|
// Periodic flush
|
|
31
31
|
this.flushTimer = setInterval(() => this.flush(), FLUSH_INTERVAL_MS);
|
|
32
|
+
// A browser keeps the page alive regardless; Node counts this timer as
|
|
33
|
+
// work and will not exit while it is pending. Without unref, any script
|
|
34
|
+
// that initializes the SDK — a CLI, a test runner, an SSR build step —
|
|
35
|
+
// hangs after its last line. unref exists only on Node's timer object.
|
|
36
|
+
this.flushTimer.unref?.();
|
|
32
37
|
// Auto track app_open
|
|
33
38
|
this.track('app_open');
|
|
34
39
|
this.initialized = true;
|
package/dist/cjs/index.d.ts
CHANGED
|
@@ -19,4 +19,4 @@ export { getOrCreateDeviceId } from './device-id.js';
|
|
|
19
19
|
export { koolbaseSdkVersion } from './device-metadata.js';
|
|
20
20
|
export { RestoreResult } from './types.js';
|
|
21
21
|
export type { AuthStateListener, FetchLike, KoolbaseAuthStorage } from './types.js';
|
|
22
|
-
export { setPlatform, getPlatform, memoryPlatform, type PlatformAdapter, type PlatformStorage, type PlatformNetwork, type PlatformLifecycle, type PlatformInfo, } from './platform.js';
|
|
22
|
+
export { setPlatform, getPlatform, memoryPlatform, type PlatformAdapter, type PlatformStorage, type PlatformNetwork, type PlatformLifecycle, type PlatformLocks, type PlatformInfo, } from './platform.js';
|
|
@@ -70,6 +70,14 @@ export interface OfflineState {
|
|
|
70
70
|
export declare class OfflineStateTooLargeError extends Error {
|
|
71
71
|
constructor(bytes: number);
|
|
72
72
|
}
|
|
73
|
+
/**
|
|
74
|
+
* Lock names derive from the storage key they protect, so the two cannot
|
|
75
|
+
* drift apart. Note what the key does NOT carry: a project id. Two Koolbase
|
|
76
|
+
* apps on one origin with the same user id already share offline state —
|
|
77
|
+
* a pre-existing collision these locks neither create nor fix.
|
|
78
|
+
*/
|
|
79
|
+
export declare function stateLockName(userId: string): string;
|
|
80
|
+
export declare function flushLockName(userId: string): string;
|
|
73
81
|
export declare function readOfflineState(userId: string): Promise<OfflineState>;
|
|
74
82
|
/**
|
|
75
83
|
* Reads, mutates, and writes the state under the user's lock.
|
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.OfflineStateTooLargeError = void 0;
|
|
4
4
|
exports.recordKey = recordKey;
|
|
5
|
+
exports.stateLockName = stateLockName;
|
|
6
|
+
exports.flushLockName = flushLockName;
|
|
5
7
|
exports.readOfflineState = readOfflineState;
|
|
6
8
|
exports.mutateOfflineState = mutateOfflineState;
|
|
7
9
|
exports.queueWrite = queueWrite;
|
|
@@ -72,6 +74,18 @@ function byteLength(s) {
|
|
|
72
74
|
return bytes;
|
|
73
75
|
}
|
|
74
76
|
const locks = () => (0, shared_js_1.shared)('locks', () => new Map());
|
|
77
|
+
/**
|
|
78
|
+
* Lock names derive from the storage key they protect, so the two cannot
|
|
79
|
+
* drift apart. Note what the key does NOT carry: a project id. Two Koolbase
|
|
80
|
+
* apps on one origin with the same user id already share offline state —
|
|
81
|
+
* a pre-existing collision these locks neither create nor fix.
|
|
82
|
+
*/
|
|
83
|
+
function stateLockName(userId) {
|
|
84
|
+
return `${stateKey(userId)}:lock`;
|
|
85
|
+
}
|
|
86
|
+
function flushLockName(userId) {
|
|
87
|
+
return `${stateKey(userId)}:flush`;
|
|
88
|
+
}
|
|
75
89
|
async function withLock(userId, fn) {
|
|
76
90
|
const previous = locks().get(userId) ?? Promise.resolve();
|
|
77
91
|
let release = () => { };
|
|
@@ -79,7 +93,10 @@ async function withLock(userId, fn) {
|
|
|
79
93
|
locks().set(userId, previous.then(() => next));
|
|
80
94
|
await previous;
|
|
81
95
|
try {
|
|
82
|
-
|
|
96
|
+
// In-process first, then across copies of the app that share this store.
|
|
97
|
+
// Both are needed: the promise chain orders callers within one runtime,
|
|
98
|
+
// the platform lock orders runtimes.
|
|
99
|
+
return await (0, platform_js_1.getPlatform)().locks.exclusive(stateLockName(userId), fn);
|
|
83
100
|
}
|
|
84
101
|
finally {
|
|
85
102
|
release();
|
package/dist/cjs/platform.d.ts
CHANGED
|
@@ -17,6 +17,31 @@ export interface PlatformLifecycle {
|
|
|
17
17
|
/** Subscribe to the app moving to the background. Used to flush analytics. */
|
|
18
18
|
onBackground(callback: () => void): () => void;
|
|
19
19
|
}
|
|
20
|
+
/**
|
|
21
|
+
* Coordination between copies of the SDK that share durable storage.
|
|
22
|
+
*
|
|
23
|
+
* Two locks, different problems, different durations:
|
|
24
|
+
*
|
|
25
|
+
* exclusive short, waited for, held across a read-modify-write of the
|
|
26
|
+
* offline state and never across the network. Stops one copy
|
|
27
|
+
* overwriting another's queued write.
|
|
28
|
+
*
|
|
29
|
+
* tryExclusive a lease on the responsibility to replay the queue, held for
|
|
30
|
+
* a whole flush pass INCLUDING its HTTP calls, and never
|
|
31
|
+
* waited for. Without it two tabs read the same pending write
|
|
32
|
+
* and both send it: the state stays consistent and the server
|
|
33
|
+
* is hit twice. A copy that cannot take the lease skips,
|
|
34
|
+
* because whoever holds it is already doing the work.
|
|
35
|
+
*
|
|
36
|
+
* A host where the SDK cannot be running twice over one store — React Native,
|
|
37
|
+
* one process — satisfies both by just running the function.
|
|
38
|
+
*/
|
|
39
|
+
export interface PlatformLocks {
|
|
40
|
+
exclusive<T>(name: string, fn: () => Promise<T>): Promise<T>;
|
|
41
|
+
tryExclusive(name: string, fn: () => Promise<void>): Promise<{
|
|
42
|
+
ran: boolean;
|
|
43
|
+
}>;
|
|
44
|
+
}
|
|
20
45
|
export interface PlatformInfo {
|
|
21
46
|
/** e.g. 'ios', 'android', 'web' */
|
|
22
47
|
os: string;
|
|
@@ -28,6 +53,7 @@ export interface PlatformAdapter {
|
|
|
28
53
|
network: PlatformNetwork;
|
|
29
54
|
lifecycle: PlatformLifecycle;
|
|
30
55
|
info: PlatformInfo;
|
|
56
|
+
locks: PlatformLocks;
|
|
31
57
|
/**
|
|
32
58
|
* The host's best persistent store for the auth session, or null if it has
|
|
33
59
|
* none worth the name. Used only when the app injects nothing through
|
package/dist/cjs/platform.js
CHANGED
|
@@ -28,6 +28,12 @@ function memoryPlatform() {
|
|
|
28
28
|
onBackground: () => () => { },
|
|
29
29
|
},
|
|
30
30
|
info: { os: 'memory', version: '' },
|
|
31
|
+
// One process, one store, and the in-process promise chain in
|
|
32
|
+
// offline-state already serialises it.
|
|
33
|
+
locks: {
|
|
34
|
+
exclusive: (_n, fn) => fn(),
|
|
35
|
+
tryExclusive: async (_n, fn) => { await fn(); return { ran: true }; },
|
|
36
|
+
},
|
|
31
37
|
authStorage: () => null,
|
|
32
38
|
};
|
|
33
39
|
}
|
package/dist/cjs/realtime.js
CHANGED
|
@@ -176,10 +176,13 @@ class KoolbaseRealtime {
|
|
|
176
176
|
return;
|
|
177
177
|
const delay = Math.min(3000 * Math.pow(2, this.reconnectAttempts), 60000);
|
|
178
178
|
this.reconnectAttempts += 1;
|
|
179
|
+
// Same reason as the analytics flush: a pending reconnect must not be
|
|
180
|
+
// the thing that keeps a Node process alive.
|
|
179
181
|
this.reconnectTimer = setTimeout(() => {
|
|
180
182
|
this.reconnectTimer = null;
|
|
181
183
|
void this.connect();
|
|
182
184
|
}, delay);
|
|
185
|
+
this.reconnectTimer.unref?.();
|
|
183
186
|
}
|
|
184
187
|
disconnect() {
|
|
185
188
|
if (this.reconnectTimer) {
|
package/dist/cjs/storage.js
CHANGED
|
@@ -88,7 +88,14 @@ class KoolbaseStorage {
|
|
|
88
88
|
*/
|
|
89
89
|
async upload(options) {
|
|
90
90
|
const overwrite = options.overwrite ?? false;
|
|
91
|
-
|
|
91
|
+
// A Blob (browser File included) knows its own type; the React Native
|
|
92
|
+
// URI form carries it alongside. Falling back to octet-stream keeps a
|
|
93
|
+
// typeless Blob from presigning with an empty content type, which R2
|
|
94
|
+
// then refuses at PUT.
|
|
95
|
+
const isBlob = typeof Blob !== 'undefined' && options.file instanceof Blob;
|
|
96
|
+
const contentType = isBlob
|
|
97
|
+
? options.file.type || 'application/octet-stream'
|
|
98
|
+
: options.file.type;
|
|
92
99
|
// ─── Step 1: Get presigned upload URL ───
|
|
93
100
|
const urlRes = await fetch(`${this.config.baseUrl}/v1/sdk/storage/upload-url`, {
|
|
94
101
|
method: 'POST',
|
|
@@ -108,10 +115,15 @@ class KoolbaseStorage {
|
|
|
108
115
|
}
|
|
109
116
|
const { upload_url } = (await urlRes.json());
|
|
110
117
|
// ─── Step 2: Upload directly to R2 ───
|
|
111
|
-
// RN's fetch resolves local file URIs and Blob bodies on a raw PUT.
|
|
112
118
|
// R2 presigned URLs expect raw binary, NOT multipart/form-data.
|
|
113
|
-
|
|
114
|
-
|
|
119
|
+
//
|
|
120
|
+
// A browser hands us the bytes already: a File from an input is a Blob,
|
|
121
|
+
// and fetching its .uri would be fetching undefined. React Native hands
|
|
122
|
+
// us a local URI, and its fetch resolves that into a Blob — which is why
|
|
123
|
+
// the two hosts differ here and nowhere else in this method.
|
|
124
|
+
const fileBlob = isBlob
|
|
125
|
+
? options.file
|
|
126
|
+
: await (await fetch(options.file.uri)).blob();
|
|
115
127
|
const fileSize = fileBlob.size;
|
|
116
128
|
const uploadRes = await fetch(upload_url, {
|
|
117
129
|
method: 'PUT',
|
|
@@ -18,6 +18,9 @@ export declare class SyncEngine {
|
|
|
18
18
|
start(): void;
|
|
19
19
|
stop(): void;
|
|
20
20
|
flush(): Promise<void>;
|
|
21
|
+
private recheckTimer;
|
|
22
|
+
private scheduleRecheck;
|
|
23
|
+
private flushHeld;
|
|
21
24
|
/**
|
|
22
25
|
* Sends one queued write, returning the revision the record now carries.
|
|
23
26
|
*
|
package/dist/cjs/sync-engine.js
CHANGED
|
@@ -45,6 +45,7 @@ class RevisionMismatch extends Error {
|
|
|
45
45
|
class SyncEngine {
|
|
46
46
|
constructor(config, getUserId, getToken, onSyncComplete, onSessionExpired) {
|
|
47
47
|
this.isSyncing = false;
|
|
48
|
+
this.recheckTimer = null;
|
|
48
49
|
this.config = config;
|
|
49
50
|
this.getUserId = getUserId;
|
|
50
51
|
this.getToken = getToken;
|
|
@@ -66,6 +67,32 @@ class SyncEngine {
|
|
|
66
67
|
const userId = this.getUserId();
|
|
67
68
|
if (!userId)
|
|
68
69
|
return;
|
|
70
|
+
// A lease on replaying this user's queue, held for the whole pass
|
|
71
|
+
// including its HTTP calls. isSyncing does this within one runtime; this
|
|
72
|
+
// does it across every copy sharing the store. Not waited for: a copy
|
|
73
|
+
// that cannot take it skips, because the holder is replaying the same
|
|
74
|
+
// queue — waiting would duplicate the wait, not the work.
|
|
75
|
+
const { ran } = await (0, platform_js_1.getPlatform)().locks.tryExclusive((0, offline_state_js_1.flushLockName)(userId), () => this.flushHeld(userId));
|
|
76
|
+
if (!ran) {
|
|
77
|
+
// Skipped, and a write queued between the holder's last read and its
|
|
78
|
+
// release would otherwise sit until an unrelated reconnect. Re-check
|
|
79
|
+
// once, after the holder has had time to finish; if it is still
|
|
80
|
+
// running, its own recheck covers what we would have sent.
|
|
81
|
+
this.scheduleRecheck();
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
scheduleRecheck() {
|
|
85
|
+
// Bounded and coalesced: one pending recheck at a time, so a page with
|
|
86
|
+
// several tabs cannot turn a skipped flush into a retry storm.
|
|
87
|
+
if (this.recheckTimer)
|
|
88
|
+
return;
|
|
89
|
+
this.recheckTimer = setTimeout(() => {
|
|
90
|
+
this.recheckTimer = null;
|
|
91
|
+
void this.flush();
|
|
92
|
+
}, 1500);
|
|
93
|
+
this.recheckTimer.unref?.();
|
|
94
|
+
}
|
|
95
|
+
async flushHeld(userId) {
|
|
69
96
|
this.isSyncing = true;
|
|
70
97
|
try {
|
|
71
98
|
// Before anything is sent. Writes queued by an earlier version sit under a
|
package/dist/cjs/types.d.ts
CHANGED
|
@@ -214,7 +214,16 @@ export interface SemanticSearchResult {
|
|
|
214
214
|
export interface UploadOptions {
|
|
215
215
|
bucket: string;
|
|
216
216
|
path: string;
|
|
217
|
-
|
|
217
|
+
/**
|
|
218
|
+
* The bytes to upload, in whichever form the host produces them.
|
|
219
|
+
*
|
|
220
|
+
* A browser gives you a `File` from an `<input type="file">` or a `Blob`
|
|
221
|
+
* you built; pass it directly. React Native gives you a local URI from an
|
|
222
|
+
* image picker or the file system; pass `{ uri, name, type }` and the SDK
|
|
223
|
+
* resolves it. A Blob already carries its own content type, so `type` is
|
|
224
|
+
* only needed for the URI form.
|
|
225
|
+
*/
|
|
226
|
+
file: Blob | {
|
|
218
227
|
uri: string;
|
|
219
228
|
name: string;
|
|
220
229
|
type: string;
|
package/dist/esm/analytics.js
CHANGED
|
@@ -26,6 +26,11 @@ export class KoolbaseAnalytics {
|
|
|
26
26
|
getPlatform().lifecycle.onBackground(() => { this.flush(); });
|
|
27
27
|
// Periodic flush
|
|
28
28
|
this.flushTimer = setInterval(() => this.flush(), FLUSH_INTERVAL_MS);
|
|
29
|
+
// A browser keeps the page alive regardless; Node counts this timer as
|
|
30
|
+
// work and will not exit while it is pending. Without unref, any script
|
|
31
|
+
// that initializes the SDK — a CLI, a test runner, an SSR build step —
|
|
32
|
+
// hangs after its last line. unref exists only on Node's timer object.
|
|
33
|
+
this.flushTimer.unref?.();
|
|
29
34
|
// Auto track app_open
|
|
30
35
|
this.track('app_open');
|
|
31
36
|
this.initialized = true;
|
package/dist/esm/index.d.ts
CHANGED
|
@@ -19,4 +19,4 @@ export { getOrCreateDeviceId } from './device-id.js';
|
|
|
19
19
|
export { koolbaseSdkVersion } from './device-metadata.js';
|
|
20
20
|
export { RestoreResult } from './types.js';
|
|
21
21
|
export type { AuthStateListener, FetchLike, KoolbaseAuthStorage } from './types.js';
|
|
22
|
-
export { setPlatform, getPlatform, memoryPlatform, type PlatformAdapter, type PlatformStorage, type PlatformNetwork, type PlatformLifecycle, type PlatformInfo, } from './platform.js';
|
|
22
|
+
export { setPlatform, getPlatform, memoryPlatform, type PlatformAdapter, type PlatformStorage, type PlatformNetwork, type PlatformLifecycle, type PlatformLocks, type PlatformInfo, } from './platform.js';
|
|
@@ -70,6 +70,14 @@ export interface OfflineState {
|
|
|
70
70
|
export declare class OfflineStateTooLargeError extends Error {
|
|
71
71
|
constructor(bytes: number);
|
|
72
72
|
}
|
|
73
|
+
/**
|
|
74
|
+
* Lock names derive from the storage key they protect, so the two cannot
|
|
75
|
+
* drift apart. Note what the key does NOT carry: a project id. Two Koolbase
|
|
76
|
+
* apps on one origin with the same user id already share offline state —
|
|
77
|
+
* a pre-existing collision these locks neither create nor fix.
|
|
78
|
+
*/
|
|
79
|
+
export declare function stateLockName(userId: string): string;
|
|
80
|
+
export declare function flushLockName(userId: string): string;
|
|
73
81
|
export declare function readOfflineState(userId: string): Promise<OfflineState>;
|
|
74
82
|
/**
|
|
75
83
|
* Reads, mutates, and writes the state under the user's lock.
|
|
@@ -63,6 +63,18 @@ function byteLength(s) {
|
|
|
63
63
|
return bytes;
|
|
64
64
|
}
|
|
65
65
|
const locks = () => shared('locks', () => new Map());
|
|
66
|
+
/**
|
|
67
|
+
* Lock names derive from the storage key they protect, so the two cannot
|
|
68
|
+
* drift apart. Note what the key does NOT carry: a project id. Two Koolbase
|
|
69
|
+
* apps on one origin with the same user id already share offline state —
|
|
70
|
+
* a pre-existing collision these locks neither create nor fix.
|
|
71
|
+
*/
|
|
72
|
+
export function stateLockName(userId) {
|
|
73
|
+
return `${stateKey(userId)}:lock`;
|
|
74
|
+
}
|
|
75
|
+
export function flushLockName(userId) {
|
|
76
|
+
return `${stateKey(userId)}:flush`;
|
|
77
|
+
}
|
|
66
78
|
async function withLock(userId, fn) {
|
|
67
79
|
const previous = locks().get(userId) ?? Promise.resolve();
|
|
68
80
|
let release = () => { };
|
|
@@ -70,7 +82,10 @@ async function withLock(userId, fn) {
|
|
|
70
82
|
locks().set(userId, previous.then(() => next));
|
|
71
83
|
await previous;
|
|
72
84
|
try {
|
|
73
|
-
|
|
85
|
+
// In-process first, then across copies of the app that share this store.
|
|
86
|
+
// Both are needed: the promise chain orders callers within one runtime,
|
|
87
|
+
// the platform lock orders runtimes.
|
|
88
|
+
return await getPlatform().locks.exclusive(stateLockName(userId), fn);
|
|
74
89
|
}
|
|
75
90
|
finally {
|
|
76
91
|
release();
|
package/dist/esm/platform.d.ts
CHANGED
|
@@ -17,6 +17,31 @@ export interface PlatformLifecycle {
|
|
|
17
17
|
/** Subscribe to the app moving to the background. Used to flush analytics. */
|
|
18
18
|
onBackground(callback: () => void): () => void;
|
|
19
19
|
}
|
|
20
|
+
/**
|
|
21
|
+
* Coordination between copies of the SDK that share durable storage.
|
|
22
|
+
*
|
|
23
|
+
* Two locks, different problems, different durations:
|
|
24
|
+
*
|
|
25
|
+
* exclusive short, waited for, held across a read-modify-write of the
|
|
26
|
+
* offline state and never across the network. Stops one copy
|
|
27
|
+
* overwriting another's queued write.
|
|
28
|
+
*
|
|
29
|
+
* tryExclusive a lease on the responsibility to replay the queue, held for
|
|
30
|
+
* a whole flush pass INCLUDING its HTTP calls, and never
|
|
31
|
+
* waited for. Without it two tabs read the same pending write
|
|
32
|
+
* and both send it: the state stays consistent and the server
|
|
33
|
+
* is hit twice. A copy that cannot take the lease skips,
|
|
34
|
+
* because whoever holds it is already doing the work.
|
|
35
|
+
*
|
|
36
|
+
* A host where the SDK cannot be running twice over one store — React Native,
|
|
37
|
+
* one process — satisfies both by just running the function.
|
|
38
|
+
*/
|
|
39
|
+
export interface PlatformLocks {
|
|
40
|
+
exclusive<T>(name: string, fn: () => Promise<T>): Promise<T>;
|
|
41
|
+
tryExclusive(name: string, fn: () => Promise<void>): Promise<{
|
|
42
|
+
ran: boolean;
|
|
43
|
+
}>;
|
|
44
|
+
}
|
|
20
45
|
export interface PlatformInfo {
|
|
21
46
|
/** e.g. 'ios', 'android', 'web' */
|
|
22
47
|
os: string;
|
|
@@ -28,6 +53,7 @@ export interface PlatformAdapter {
|
|
|
28
53
|
network: PlatformNetwork;
|
|
29
54
|
lifecycle: PlatformLifecycle;
|
|
30
55
|
info: PlatformInfo;
|
|
56
|
+
locks: PlatformLocks;
|
|
31
57
|
/**
|
|
32
58
|
* The host's best persistent store for the auth session, or null if it has
|
|
33
59
|
* none worth the name. Used only when the app injects nothing through
|
package/dist/esm/platform.js
CHANGED
|
@@ -23,6 +23,12 @@ export function memoryPlatform() {
|
|
|
23
23
|
onBackground: () => () => { },
|
|
24
24
|
},
|
|
25
25
|
info: { os: 'memory', version: '' },
|
|
26
|
+
// One process, one store, and the in-process promise chain in
|
|
27
|
+
// offline-state already serialises it.
|
|
28
|
+
locks: {
|
|
29
|
+
exclusive: (_n, fn) => fn(),
|
|
30
|
+
tryExclusive: async (_n, fn) => { await fn(); return { ran: true }; },
|
|
31
|
+
},
|
|
26
32
|
authStorage: () => null,
|
|
27
33
|
};
|
|
28
34
|
}
|
package/dist/esm/realtime.js
CHANGED
|
@@ -173,10 +173,13 @@ export class KoolbaseRealtime {
|
|
|
173
173
|
return;
|
|
174
174
|
const delay = Math.min(3000 * Math.pow(2, this.reconnectAttempts), 60000);
|
|
175
175
|
this.reconnectAttempts += 1;
|
|
176
|
+
// Same reason as the analytics flush: a pending reconnect must not be
|
|
177
|
+
// the thing that keeps a Node process alive.
|
|
176
178
|
this.reconnectTimer = setTimeout(() => {
|
|
177
179
|
this.reconnectTimer = null;
|
|
178
180
|
void this.connect();
|
|
179
181
|
}, delay);
|
|
182
|
+
this.reconnectTimer.unref?.();
|
|
180
183
|
}
|
|
181
184
|
disconnect() {
|
|
182
185
|
if (this.reconnectTimer) {
|
package/dist/esm/storage.js
CHANGED
|
@@ -85,7 +85,14 @@ export class KoolbaseStorage {
|
|
|
85
85
|
*/
|
|
86
86
|
async upload(options) {
|
|
87
87
|
const overwrite = options.overwrite ?? false;
|
|
88
|
-
|
|
88
|
+
// A Blob (browser File included) knows its own type; the React Native
|
|
89
|
+
// URI form carries it alongside. Falling back to octet-stream keeps a
|
|
90
|
+
// typeless Blob from presigning with an empty content type, which R2
|
|
91
|
+
// then refuses at PUT.
|
|
92
|
+
const isBlob = typeof Blob !== 'undefined' && options.file instanceof Blob;
|
|
93
|
+
const contentType = isBlob
|
|
94
|
+
? options.file.type || 'application/octet-stream'
|
|
95
|
+
: options.file.type;
|
|
89
96
|
// ─── Step 1: Get presigned upload URL ───
|
|
90
97
|
const urlRes = await fetch(`${this.config.baseUrl}/v1/sdk/storage/upload-url`, {
|
|
91
98
|
method: 'POST',
|
|
@@ -105,10 +112,15 @@ export class KoolbaseStorage {
|
|
|
105
112
|
}
|
|
106
113
|
const { upload_url } = (await urlRes.json());
|
|
107
114
|
// ─── Step 2: Upload directly to R2 ───
|
|
108
|
-
// RN's fetch resolves local file URIs and Blob bodies on a raw PUT.
|
|
109
115
|
// R2 presigned URLs expect raw binary, NOT multipart/form-data.
|
|
110
|
-
|
|
111
|
-
|
|
116
|
+
//
|
|
117
|
+
// A browser hands us the bytes already: a File from an input is a Blob,
|
|
118
|
+
// and fetching its .uri would be fetching undefined. React Native hands
|
|
119
|
+
// us a local URI, and its fetch resolves that into a Blob — which is why
|
|
120
|
+
// the two hosts differ here and nowhere else in this method.
|
|
121
|
+
const fileBlob = isBlob
|
|
122
|
+
? options.file
|
|
123
|
+
: await (await fetch(options.file.uri)).blob();
|
|
112
124
|
const fileSize = fileBlob.size;
|
|
113
125
|
const uploadRes = await fetch(upload_url, {
|
|
114
126
|
method: 'PUT',
|
|
@@ -18,6 +18,9 @@ export declare class SyncEngine {
|
|
|
18
18
|
start(): void;
|
|
19
19
|
stop(): void;
|
|
20
20
|
flush(): Promise<void>;
|
|
21
|
+
private recheckTimer;
|
|
22
|
+
private scheduleRecheck;
|
|
23
|
+
private flushHeld;
|
|
21
24
|
/**
|
|
22
25
|
* Sends one queued write, returning the revision the record now carries.
|
|
23
26
|
*
|
package/dist/esm/sync-engine.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { readOfflineState, mutateOfflineState, migrateLegacyQueue, } from './offline-state.js';
|
|
1
|
+
import { readOfflineState, mutateOfflineState, migrateLegacyQueue, flushLockName, } from './offline-state.js';
|
|
2
2
|
import { KoolbaseUnauthenticatedError } from './errors.js';
|
|
3
3
|
import { getPlatform } from './platform.js';
|
|
4
4
|
import { invalidateCache, removeCachedRecord, } from './cache-store.js';
|
|
@@ -42,6 +42,7 @@ class RevisionMismatch extends Error {
|
|
|
42
42
|
export class SyncEngine {
|
|
43
43
|
constructor(config, getUserId, getToken, onSyncComplete, onSessionExpired) {
|
|
44
44
|
this.isSyncing = false;
|
|
45
|
+
this.recheckTimer = null;
|
|
45
46
|
this.config = config;
|
|
46
47
|
this.getUserId = getUserId;
|
|
47
48
|
this.getToken = getToken;
|
|
@@ -63,6 +64,32 @@ export class SyncEngine {
|
|
|
63
64
|
const userId = this.getUserId();
|
|
64
65
|
if (!userId)
|
|
65
66
|
return;
|
|
67
|
+
// A lease on replaying this user's queue, held for the whole pass
|
|
68
|
+
// including its HTTP calls. isSyncing does this within one runtime; this
|
|
69
|
+
// does it across every copy sharing the store. Not waited for: a copy
|
|
70
|
+
// that cannot take it skips, because the holder is replaying the same
|
|
71
|
+
// queue — waiting would duplicate the wait, not the work.
|
|
72
|
+
const { ran } = await getPlatform().locks.tryExclusive(flushLockName(userId), () => this.flushHeld(userId));
|
|
73
|
+
if (!ran) {
|
|
74
|
+
// Skipped, and a write queued between the holder's last read and its
|
|
75
|
+
// release would otherwise sit until an unrelated reconnect. Re-check
|
|
76
|
+
// once, after the holder has had time to finish; if it is still
|
|
77
|
+
// running, its own recheck covers what we would have sent.
|
|
78
|
+
this.scheduleRecheck();
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
scheduleRecheck() {
|
|
82
|
+
// Bounded and coalesced: one pending recheck at a time, so a page with
|
|
83
|
+
// several tabs cannot turn a skipped flush into a retry storm.
|
|
84
|
+
if (this.recheckTimer)
|
|
85
|
+
return;
|
|
86
|
+
this.recheckTimer = setTimeout(() => {
|
|
87
|
+
this.recheckTimer = null;
|
|
88
|
+
void this.flush();
|
|
89
|
+
}, 1500);
|
|
90
|
+
this.recheckTimer.unref?.();
|
|
91
|
+
}
|
|
92
|
+
async flushHeld(userId) {
|
|
66
93
|
this.isSyncing = true;
|
|
67
94
|
try {
|
|
68
95
|
// Before anything is sent. Writes queued by an earlier version sit under a
|
package/dist/esm/types.d.ts
CHANGED
|
@@ -214,7 +214,16 @@ export interface SemanticSearchResult {
|
|
|
214
214
|
export interface UploadOptions {
|
|
215
215
|
bucket: string;
|
|
216
216
|
path: string;
|
|
217
|
-
|
|
217
|
+
/**
|
|
218
|
+
* The bytes to upload, in whichever form the host produces them.
|
|
219
|
+
*
|
|
220
|
+
* A browser gives you a `File` from an `<input type="file">` or a `Blob`
|
|
221
|
+
* you built; pass it directly. React Native gives you a local URI from an
|
|
222
|
+
* image picker or the file system; pass `{ uri, name, type }` and the SDK
|
|
223
|
+
* resolves it. A Blob already carries its own content type, so `type` is
|
|
224
|
+
* only needed for the URI form.
|
|
225
|
+
*/
|
|
226
|
+
file: Blob | {
|
|
218
227
|
uri: string;
|
|
219
228
|
name: string;
|
|
220
229
|
type: string;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@koolbase/core",
|
|
3
|
-
"version": "10.0
|
|
3
|
+
"version": "10.2.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",
|