@koolbase/react-native 9.1.0 → 9.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/README.md +125 -9
- package/dist/auth-errors.d.ts +2 -2
- package/dist/auth-errors.js +3 -3
- package/dist/auth.d.ts +14 -0
- package/dist/auth.js +16 -0
- package/dist/cache-store.d.ts +42 -3
- package/dist/cache-store.js +62 -1
- package/dist/conflict.d.ts +80 -0
- package/dist/conflict.js +84 -0
- package/dist/database-errors.d.ts +9 -3
- package/dist/database-errors.js +42 -15
- package/dist/database.d.ts +91 -1
- package/dist/database.js +438 -94
- package/dist/errors.d.ts +64 -0
- package/dist/errors.js +85 -0
- package/dist/function-errors.d.ts +51 -0
- package/dist/function-errors.js +103 -0
- package/dist/functions.d.ts +8 -1
- package/dist/functions.js +18 -5
- package/dist/index.d.ts +4 -0
- package/dist/index.js +15 -4
- package/dist/offline-state.d.ts +97 -0
- package/dist/offline-state.js +200 -0
- package/dist/pending-write.d.ts +47 -0
- package/dist/pending-write.js +22 -0
- package/dist/realtime.d.ts +26 -1
- package/dist/realtime.js +50 -3
- package/dist/record.js +3 -0
- package/dist/storage-errors.d.ts +2 -2
- package/dist/storage-errors.js +7 -3
- package/dist/storage.d.ts +15 -1
- package/dist/storage.js +23 -10
- package/dist/sync-engine.d.ts +15 -1
- package/dist/sync-engine.js +226 -22
- package/dist/types.d.ts +18 -1
- package/package.json +13 -7
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.OfflineStateTooLargeError = void 0;
|
|
7
|
+
exports.recordKey = recordKey;
|
|
8
|
+
exports.readOfflineState = readOfflineState;
|
|
9
|
+
exports.mutateOfflineState = mutateOfflineState;
|
|
10
|
+
exports.queueWrite = queueWrite;
|
|
11
|
+
exports.migrateLegacyQueue = migrateLegacyQueue;
|
|
12
|
+
const async_storage_1 = __importDefault(require("@react-native-async-storage/async-storage"));
|
|
13
|
+
/**
|
|
14
|
+
* The offline system's correctness-critical state: writes waiting to be sent,
|
|
15
|
+
* and writes the server refused because the record moved underneath them.
|
|
16
|
+
*
|
|
17
|
+
* Both live under one key so moving between them is a single write. A conflict
|
|
18
|
+
* recorded without removing the pending write would replay and be refused
|
|
19
|
+
* forever; a pending write removed without recording the conflict would lose a
|
|
20
|
+
* change the user believes is saved. Two keys admit both outcomes.
|
|
21
|
+
*
|
|
22
|
+
* Records are cached separately, per record. Putting them here would let a
|
|
23
|
+
* growing cache push this value past a storage ceiling and make the queue
|
|
24
|
+
* unreadable — losing the correctness-critical state to something incidental.
|
|
25
|
+
*/
|
|
26
|
+
const VERSION = 'v1';
|
|
27
|
+
function stateKey(userId) {
|
|
28
|
+
return `koolbase:${VERSION}:${userId}:offline-state`;
|
|
29
|
+
}
|
|
30
|
+
function recordKey(userId, collection, recordId) {
|
|
31
|
+
return `koolbase:${VERSION}:${userId}:record:${collection}:${recordId}`;
|
|
32
|
+
}
|
|
33
|
+
const EMPTY = { pending: [], conflicts: [] };
|
|
34
|
+
/**
|
|
35
|
+
* A conservative ceiling, below the lowest per-value limit any supported
|
|
36
|
+
* AsyncStorage implementation imposes.
|
|
37
|
+
*
|
|
38
|
+
* Not a claim about a platform maximum — a boundary that leaves headroom for
|
|
39
|
+
* rewriting and migration. A queue that cannot be written is a queue that
|
|
40
|
+
* silently stops accepting work, so the limit is enforced with an error rather
|
|
41
|
+
* than discovered.
|
|
42
|
+
*/
|
|
43
|
+
const MAX_STATE_BYTES = 1000000;
|
|
44
|
+
class OfflineStateTooLargeError extends Error {
|
|
45
|
+
constructor(bytes) {
|
|
46
|
+
super(`Offline state is ${bytes} bytes, above the ${MAX_STATE_BYTES} byte limit. ` +
|
|
47
|
+
'Sync or resolve what is queued before making more offline changes.');
|
|
48
|
+
this.name = 'OfflineStateTooLargeError';
|
|
49
|
+
Object.setPrototypeOf(this, new.target.prototype);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
exports.OfflineStateTooLargeError = OfflineStateTooLargeError;
|
|
53
|
+
/**
|
|
54
|
+
* Serialises access per user.
|
|
55
|
+
*
|
|
56
|
+
* A single setItem is one storage operation, but read-modify-write is not: two
|
|
57
|
+
* callers can both read, both modify, and the second write silently discards
|
|
58
|
+
* the first. Two components queueing a write in the same tick is enough. The
|
|
59
|
+
* lock is in-process — one JavaScript runtime is the assumption every React
|
|
60
|
+
* Native app satisfies, and the SDK does not promise more.
|
|
61
|
+
*/
|
|
62
|
+
/** UTF-8 byte length, without depending on TextEncoder being present. */
|
|
63
|
+
function byteLength(s) {
|
|
64
|
+
if (typeof TextEncoder !== 'undefined') {
|
|
65
|
+
return new TextEncoder().encode(s).length;
|
|
66
|
+
}
|
|
67
|
+
let bytes = 0;
|
|
68
|
+
for (let i = 0; i < s.length; i++) {
|
|
69
|
+
const c = s.codePointAt(i);
|
|
70
|
+
if (c > 0xffff)
|
|
71
|
+
i++; // surrogate pair, counted once
|
|
72
|
+
bytes += c < 0x80 ? 1 : c < 0x800 ? 2 : c < 0x10000 ? 3 : 4;
|
|
73
|
+
}
|
|
74
|
+
return bytes;
|
|
75
|
+
}
|
|
76
|
+
const locks = new Map();
|
|
77
|
+
async function withLock(userId, fn) {
|
|
78
|
+
const previous = locks.get(userId) ?? Promise.resolve();
|
|
79
|
+
let release = () => { };
|
|
80
|
+
const next = new Promise((resolve) => { release = resolve; });
|
|
81
|
+
locks.set(userId, previous.then(() => next));
|
|
82
|
+
await previous;
|
|
83
|
+
try {
|
|
84
|
+
return await fn();
|
|
85
|
+
}
|
|
86
|
+
finally {
|
|
87
|
+
release();
|
|
88
|
+
if (locks.get(userId) === next)
|
|
89
|
+
locks.delete(userId);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
async function readOfflineState(userId) {
|
|
93
|
+
try {
|
|
94
|
+
const raw = await async_storage_1.default.getItem(stateKey(userId));
|
|
95
|
+
if (!raw)
|
|
96
|
+
return { ...EMPTY, pending: [], conflicts: [] };
|
|
97
|
+
const parsed = JSON.parse(raw);
|
|
98
|
+
return { pending: parsed.pending ?? [], conflicts: parsed.conflicts ?? [] };
|
|
99
|
+
}
|
|
100
|
+
catch {
|
|
101
|
+
// Unreadable state is treated as empty rather than throwing: an app that
|
|
102
|
+
// cannot start is worse than one that has lost a queue it could not read
|
|
103
|
+
// anyway. The write path's size guard is what keeps this from happening.
|
|
104
|
+
return { pending: [], conflicts: [] };
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Reads, mutates, and writes the state under the user's lock.
|
|
109
|
+
*
|
|
110
|
+
* Every mutation goes through here. A caller that reads and writes separately
|
|
111
|
+
* reintroduces the race this exists to prevent.
|
|
112
|
+
*/
|
|
113
|
+
async function mutateOfflineState(userId, mutate) {
|
|
114
|
+
await withLock(userId, async () => {
|
|
115
|
+
const state = await readOfflineState(userId);
|
|
116
|
+
mutate(state);
|
|
117
|
+
const serialised = JSON.stringify(state);
|
|
118
|
+
// Bytes, not characters. String length undercounts anything outside ASCII —
|
|
119
|
+
// an accented name, any non-Latin script — so a limit measured in
|
|
120
|
+
// characters permits more than it claims, which is the wrong direction for
|
|
121
|
+
// a safety boundary. TextEncoder is not guaranteed in every React Native
|
|
122
|
+
// runtime, so fall back to a byte count computed directly.
|
|
123
|
+
const bytes = byteLength(serialised);
|
|
124
|
+
if (bytes > MAX_STATE_BYTES) {
|
|
125
|
+
throw new OfflineStateTooLargeError(bytes);
|
|
126
|
+
}
|
|
127
|
+
await async_storage_1.default.setItem(stateKey(userId), serialised);
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
/** Adds a write to the queue, under the user's lock. */
|
|
131
|
+
async function queueWrite(userId, write) {
|
|
132
|
+
await mutateOfflineState(userId, (state) => {
|
|
133
|
+
state.pending.push({
|
|
134
|
+
...write,
|
|
135
|
+
retries: 0,
|
|
136
|
+
enqueuedAt: new Date().toISOString(),
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
const LEGACY_QUEUE_VERSION = 'v1';
|
|
141
|
+
function legacyQueueKey(userId) {
|
|
142
|
+
return `koolbase:${LEGACY_QUEUE_VERSION}:${userId}:write_queue`;
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* Moves writes queued by an earlier version into the current state.
|
|
146
|
+
*
|
|
147
|
+
* Runs once, before any replay, and never contacts the network. Migration that
|
|
148
|
+
* depended on connectivity would give the same input two different outcomes
|
|
149
|
+
* depending on whether the device happened to be online at startup — which is
|
|
150
|
+
* how a bug becomes unreproducible.
|
|
151
|
+
*
|
|
152
|
+
* Inserts carry everything they need and simply move across. Updates and
|
|
153
|
+
* deletes do not: they were queued before baselines were recorded, so replaying
|
|
154
|
+
* one would apply it blindly and overwrite whatever changed in the meantime.
|
|
155
|
+
* They are preserved as waiting for a decision instead — the change is not lost,
|
|
156
|
+
* and nothing is written on a guess.
|
|
157
|
+
*/
|
|
158
|
+
async function migrateLegacyQueue(userId) {
|
|
159
|
+
const raw = await async_storage_1.default.getItem(legacyQueueKey(userId));
|
|
160
|
+
if (!raw)
|
|
161
|
+
return;
|
|
162
|
+
let legacy = [];
|
|
163
|
+
try {
|
|
164
|
+
legacy = JSON.parse(raw);
|
|
165
|
+
}
|
|
166
|
+
catch {
|
|
167
|
+
// Unreadable: nothing recoverable, and leaving the key would retry forever.
|
|
168
|
+
await async_storage_1.default.removeItem(legacyQueueKey(userId));
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
await mutateOfflineState(userId, (state) => {
|
|
172
|
+
for (const w of legacy) {
|
|
173
|
+
const operation = w.type;
|
|
174
|
+
if (operation === 'insert') {
|
|
175
|
+
state.pending.push({
|
|
176
|
+
id: w.id,
|
|
177
|
+
operation: 'insert',
|
|
178
|
+
collection: w.collection,
|
|
179
|
+
recordId: w.recordId,
|
|
180
|
+
data: w.data,
|
|
181
|
+
retries: w.retries ?? 0,
|
|
182
|
+
enqueuedAt: w.createdAt ?? new Date().toISOString(),
|
|
183
|
+
});
|
|
184
|
+
continue;
|
|
185
|
+
}
|
|
186
|
+
if (!w.recordId)
|
|
187
|
+
continue;
|
|
188
|
+
state.conflicts.push({
|
|
189
|
+
id: w.id,
|
|
190
|
+
reason: 'baseline_unavailable',
|
|
191
|
+
operation,
|
|
192
|
+
collection: w.collection ?? '',
|
|
193
|
+
recordId: w.recordId,
|
|
194
|
+
local: w.data,
|
|
195
|
+
createdAt: w.createdAt ?? new Date().toISOString(),
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
});
|
|
199
|
+
await async_storage_1.default.removeItem(legacyQueueKey(userId));
|
|
200
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { QueuedWrite } from './offline-state';
|
|
2
|
+
/**
|
|
3
|
+
* A change made offline, waiting to be sent.
|
|
4
|
+
*
|
|
5
|
+
* The counterpart to KoolbaseConflict, one step earlier in the lifecycle: a
|
|
6
|
+
* conflict is a write the server refused; a pending write is one the server has
|
|
7
|
+
* not seen yet. Both are durable state an app should surface — a queue nobody
|
|
8
|
+
* can see accumulates invisibly, and the changes it holds feel saved to the
|
|
9
|
+
* user while existing only on this device.
|
|
10
|
+
*
|
|
11
|
+
* The case that makes this API matter: logout. Queues are per-user and survive
|
|
12
|
+
* logout by design, so a user signing out with pending writes walks away
|
|
13
|
+
* believing their edits saved — and they sync whenever that user next logs in
|
|
14
|
+
* on this device, which may be never. Warn before logout:
|
|
15
|
+
*
|
|
16
|
+
* ```ts
|
|
17
|
+
* const pending = await Koolbase.db.pendingWrites();
|
|
18
|
+
* if (pending.length > 0) {
|
|
19
|
+
* // "You have 3 unsynced changes. Sync now, or they wait until you
|
|
20
|
+
* // next sign in on this device."
|
|
21
|
+
* }
|
|
22
|
+
* ```
|
|
23
|
+
*
|
|
24
|
+
* This is a snapshot, not a live handle — re-read after a sync. Per-user:
|
|
25
|
+
* another account's queue on this device is not visible here.
|
|
26
|
+
*/
|
|
27
|
+
export interface PendingWrite {
|
|
28
|
+
id: string;
|
|
29
|
+
operation: 'insert' | 'update' | 'delete';
|
|
30
|
+
collection: string;
|
|
31
|
+
/** Absent for an insert the server has not yet assigned. */
|
|
32
|
+
recordId?: string;
|
|
33
|
+
/** What the user changed. Absent for a delete. */
|
|
34
|
+
data?: Record<string, unknown>;
|
|
35
|
+
enqueuedAt: string;
|
|
36
|
+
/** Failed send attempts so far. A count, not a policy — nothing drops it. */
|
|
37
|
+
attempts: number;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Maps the stored write to its public shape, field by field.
|
|
41
|
+
*
|
|
42
|
+
* Deliberately not a spread: the stored write carries baseline and baseRevision
|
|
43
|
+
* — replay mechanics, not contract. A spread would leak whatever the storage
|
|
44
|
+
* shape grows next; naming each field means new internals stay internal until
|
|
45
|
+
* someone chooses otherwise.
|
|
46
|
+
*/
|
|
47
|
+
export declare function toPendingWrite(w: QueuedWrite): PendingWrite;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.toPendingWrite = toPendingWrite;
|
|
4
|
+
/**
|
|
5
|
+
* Maps the stored write to its public shape, field by field.
|
|
6
|
+
*
|
|
7
|
+
* Deliberately not a spread: the stored write carries baseline and baseRevision
|
|
8
|
+
* — replay mechanics, not contract. A spread would leak whatever the storage
|
|
9
|
+
* shape grows next; naming each field means new internals stay internal until
|
|
10
|
+
* someone chooses otherwise.
|
|
11
|
+
*/
|
|
12
|
+
function toPendingWrite(w) {
|
|
13
|
+
return {
|
|
14
|
+
id: w.id,
|
|
15
|
+
operation: w.operation,
|
|
16
|
+
collection: w.collection,
|
|
17
|
+
recordId: w.recordId,
|
|
18
|
+
data: w.data,
|
|
19
|
+
enqueuedAt: w.enqueuedAt,
|
|
20
|
+
attempts: w.retries,
|
|
21
|
+
};
|
|
22
|
+
}
|
package/dist/realtime.d.ts
CHANGED
|
@@ -6,13 +6,38 @@ export declare class KoolbaseRealtime {
|
|
|
6
6
|
private ws;
|
|
7
7
|
private projectId;
|
|
8
8
|
private listeners;
|
|
9
|
+
private reconnectAttempts;
|
|
9
10
|
private reconnectTimer;
|
|
10
11
|
private connecting;
|
|
11
|
-
|
|
12
|
+
/**
|
|
13
|
+
* Identifies whose cache a seen record belongs in.
|
|
14
|
+
*
|
|
15
|
+
* The record cache is keyed by user, so without this a watched record would
|
|
16
|
+
* be filed under the wrong key — or under 'anonymous', which is worse than
|
|
17
|
+
* not caching at all: a baseline stored where it will never be read.
|
|
18
|
+
*/
|
|
19
|
+
private getUserId?;
|
|
20
|
+
constructor(config: KoolbaseConfig, getToken: TokenProvider, getUserId?: () => string | null);
|
|
21
|
+
/** Files a record seen over the socket, if we know whose it is. */
|
|
22
|
+
private cacheSeenRecord;
|
|
23
|
+
private forgetSeenRecord;
|
|
12
24
|
subscribe(collection: string, callback: RealtimeCallback): () => void;
|
|
13
25
|
private connect;
|
|
14
26
|
private sendSubscribe;
|
|
15
27
|
private sendUnsubscribe;
|
|
28
|
+
/**
|
|
29
|
+
* Reconnects with backoff, rather than every three seconds forever.
|
|
30
|
+
*
|
|
31
|
+
* A fixed interval is fine while a connection is merely interrupted and
|
|
32
|
+
* costly when it is not: a device with no network, a wrong URL, or a session
|
|
33
|
+
* the server will not accept retried indefinitely, draining battery and data
|
|
34
|
+
* the user cannot see or stop.
|
|
35
|
+
*
|
|
36
|
+
* Doubling from three seconds to a minute keeps a brief interruption
|
|
37
|
+
* recovering quickly while a lasting one settles into an interval that costs
|
|
38
|
+
* almost nothing. The counter resets when a connection opens, so a flaky link
|
|
39
|
+
* does not accumulate delay.
|
|
40
|
+
*/
|
|
16
41
|
private scheduleReconnect;
|
|
17
42
|
disconnect(): void;
|
|
18
43
|
}
|
package/dist/realtime.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.KoolbaseRealtime = void 0;
|
|
4
|
+
const cache_store_1 = require("./cache-store");
|
|
4
5
|
const record_1 = require("./record");
|
|
5
6
|
const EVENT_TYPE_MAP = {
|
|
6
7
|
'db.record.created': 'created',
|
|
@@ -32,14 +33,29 @@ function projectIdFromToken(token) {
|
|
|
32
33
|
}
|
|
33
34
|
}
|
|
34
35
|
class KoolbaseRealtime {
|
|
35
|
-
constructor(config, getToken) {
|
|
36
|
+
constructor(config, getToken, getUserId) {
|
|
36
37
|
this.ws = null;
|
|
37
38
|
this.projectId = null;
|
|
38
39
|
this.listeners = new Map();
|
|
40
|
+
this.reconnectAttempts = 0;
|
|
39
41
|
this.reconnectTimer = null;
|
|
40
42
|
this.connecting = false;
|
|
41
43
|
this.config = config;
|
|
42
44
|
this.getToken = getToken;
|
|
45
|
+
this.getUserId = getUserId;
|
|
46
|
+
}
|
|
47
|
+
/** Files a record seen over the socket, if we know whose it is. */
|
|
48
|
+
async cacheSeenRecord(collection, record) {
|
|
49
|
+
const userId = this.getUserId?.();
|
|
50
|
+
if (!userId)
|
|
51
|
+
return;
|
|
52
|
+
await (0, cache_store_1.cacheRecord)(userId, collection, record.id, record.data, record.revision);
|
|
53
|
+
}
|
|
54
|
+
async forgetSeenRecord(recordId) {
|
|
55
|
+
const userId = this.getUserId?.();
|
|
56
|
+
if (!userId)
|
|
57
|
+
return;
|
|
58
|
+
await (0, cache_store_1.removeCachedRecord)(userId, recordId);
|
|
43
59
|
}
|
|
44
60
|
subscribe(collection, callback) {
|
|
45
61
|
if (!this.listeners.has(collection))
|
|
@@ -79,6 +95,10 @@ class KoolbaseRealtime {
|
|
|
79
95
|
this.ws = ws;
|
|
80
96
|
ws.onopen = () => {
|
|
81
97
|
this.connecting = false;
|
|
98
|
+
// A connection that opened is the only proof the endpoint and the
|
|
99
|
+
// credentials are usable, so the backoff resets here rather than on a
|
|
100
|
+
// close — a flaky link should not accumulate delay.
|
|
101
|
+
this.reconnectAttempts = 0;
|
|
82
102
|
for (const collection of this.listeners.keys())
|
|
83
103
|
this.sendSubscribe(collection); // (re)subscribe all
|
|
84
104
|
};
|
|
@@ -99,9 +119,21 @@ class KoolbaseRealtime {
|
|
|
99
119
|
let msg;
|
|
100
120
|
if (mapped === 'deleted') {
|
|
101
121
|
msg = { type: 'deleted', collection: payload.collection, recordId: payload.record_id };
|
|
122
|
+
// Gone for everyone, so the cached copy is no longer a baseline for
|
|
123
|
+
// anything. An edit composed against it would be refused at replay
|
|
124
|
+
// regardless; removing it makes that a local refusal rather than a
|
|
125
|
+
// round trip.
|
|
126
|
+
void this.forgetSeenRecord(payload.record_id);
|
|
102
127
|
}
|
|
103
128
|
else if (payload.record) {
|
|
104
|
-
|
|
129
|
+
const record = (0, record_1.recordFromWire)(payload.record);
|
|
130
|
+
msg = { type: mapped, collection: payload.collection, record };
|
|
131
|
+
// A record seen over the socket is as freshly seen as one fetched, and
|
|
132
|
+
// a client watching a collection would otherwise hold a stale baseline
|
|
133
|
+
// while looking at the change. Writes already queued are unaffected:
|
|
134
|
+
// their baseline was copied in when they were made, so this cannot move
|
|
135
|
+
// ground beneath them.
|
|
136
|
+
void this.cacheSeenRecord(payload.collection, record);
|
|
105
137
|
}
|
|
106
138
|
else {
|
|
107
139
|
return;
|
|
@@ -126,13 +158,28 @@ class KoolbaseRealtime {
|
|
|
126
158
|
return;
|
|
127
159
|
this.ws.send(JSON.stringify({ action: 'unsubscribe', project_id: this.projectId, collection }));
|
|
128
160
|
}
|
|
161
|
+
/**
|
|
162
|
+
* Reconnects with backoff, rather than every three seconds forever.
|
|
163
|
+
*
|
|
164
|
+
* A fixed interval is fine while a connection is merely interrupted and
|
|
165
|
+
* costly when it is not: a device with no network, a wrong URL, or a session
|
|
166
|
+
* the server will not accept retried indefinitely, draining battery and data
|
|
167
|
+
* the user cannot see or stop.
|
|
168
|
+
*
|
|
169
|
+
* Doubling from three seconds to a minute keeps a brief interruption
|
|
170
|
+
* recovering quickly while a lasting one settles into an interval that costs
|
|
171
|
+
* almost nothing. The counter resets when a connection opens, so a flaky link
|
|
172
|
+
* does not accumulate delay.
|
|
173
|
+
*/
|
|
129
174
|
scheduleReconnect() {
|
|
130
175
|
if (this.listeners.size === 0 || this.reconnectTimer)
|
|
131
176
|
return;
|
|
177
|
+
const delay = Math.min(3000 * Math.pow(2, this.reconnectAttempts), 60000);
|
|
178
|
+
this.reconnectAttempts += 1;
|
|
132
179
|
this.reconnectTimer = setTimeout(() => {
|
|
133
180
|
this.reconnectTimer = null;
|
|
134
181
|
void this.connect();
|
|
135
|
-
},
|
|
182
|
+
}, delay);
|
|
136
183
|
}
|
|
137
184
|
disconnect() {
|
|
138
185
|
if (this.reconnectTimer) {
|
package/dist/record.js
CHANGED
package/dist/storage-errors.d.ts
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
|
+
import { KoolbaseError } from './errors';
|
|
1
2
|
/**
|
|
2
3
|
* Base error type for all Koolbase storage errors. Catchable via
|
|
3
4
|
* `instanceof KoolbaseStorageError` to handle any storage-related failure
|
|
4
5
|
* generically; subclasses let you handle specific cases.
|
|
5
6
|
*/
|
|
6
|
-
export declare class KoolbaseStorageError extends
|
|
7
|
-
code?: string;
|
|
7
|
+
export declare class KoolbaseStorageError extends KoolbaseError {
|
|
8
8
|
constructor(message: string, code?: string);
|
|
9
9
|
}
|
|
10
10
|
/**
|
package/dist/storage-errors.js
CHANGED
|
@@ -3,15 +3,15 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
3
3
|
exports.KoolbaseStorageMetadataInvalidError = exports.KoolbaseStorageMimeTypeError = exports.KoolbaseStorageFileTooLargeError = exports.KoolbaseStorageQuotaError = exports.KoolbaseStoragePermissionError = exports.KoolbaseStorageValidationError = exports.KoolbaseStorageNotFoundError = exports.KoolbaseStorageConflictError = exports.KoolbaseStorageError = void 0;
|
|
4
4
|
exports.koolbaseStorageError = koolbaseStorageError;
|
|
5
5
|
exports.koolbaseStorageErrorFromResponse = koolbaseStorageErrorFromResponse;
|
|
6
|
+
const errors_1 = require("./errors");
|
|
6
7
|
/**
|
|
7
8
|
* Base error type for all Koolbase storage errors. Catchable via
|
|
8
9
|
* `instanceof KoolbaseStorageError` to handle any storage-related failure
|
|
9
10
|
* generically; subclasses let you handle specific cases.
|
|
10
11
|
*/
|
|
11
|
-
class KoolbaseStorageError extends
|
|
12
|
+
class KoolbaseStorageError extends errors_1.KoolbaseError {
|
|
12
13
|
constructor(message, code) {
|
|
13
|
-
super(message);
|
|
14
|
-
this.code = code;
|
|
14
|
+
super(message, code);
|
|
15
15
|
this.name = 'KoolbaseStorageError';
|
|
16
16
|
Object.setPrototypeOf(this, KoolbaseStorageError.prototype);
|
|
17
17
|
}
|
|
@@ -226,6 +226,10 @@ function koolbaseStorageError(status, body, fallbackMessage = 'Storage request f
|
|
|
226
226
|
return new KoolbaseStorageMimeTypeError(message);
|
|
227
227
|
case 404:
|
|
228
228
|
return new KoolbaseStorageNotFoundError(message);
|
|
229
|
+
case 401:
|
|
230
|
+
// A rejected credential is not a storage problem: a session stops
|
|
231
|
+
// working for the whole SDK at once, so it raises the shared type.
|
|
232
|
+
return new errors_1.KoolbaseUnauthenticatedError(message);
|
|
229
233
|
case 403:
|
|
230
234
|
return new KoolbaseStoragePermissionError(message);
|
|
231
235
|
case 400:
|
package/dist/storage.d.ts
CHANGED
|
@@ -10,7 +10,21 @@ import { KoolbaseConfig, UploadOptions, UploadResult, KoolbaseObject, KoolbaseOb
|
|
|
10
10
|
export declare class KoolbaseStorage {
|
|
11
11
|
private config;
|
|
12
12
|
private getToken;
|
|
13
|
-
|
|
13
|
+
/**
|
|
14
|
+
* Called when the server rejects the caller's credentials.
|
|
15
|
+
*
|
|
16
|
+
* A session stops working for the whole SDK at once, not one subsystem at a
|
|
17
|
+
* time, so a 401 met during an upload has to clear it just as one met during a
|
|
18
|
+
* query does. Otherwise an app whose failing call happens to be a file upload
|
|
19
|
+
* keeps believing it is signed in.
|
|
20
|
+
*/
|
|
21
|
+
private onSessionExpired?;
|
|
22
|
+
constructor(config: KoolbaseConfig, getToken: () => Promise<string | null>, onSessionExpired?: () => Promise<void>);
|
|
23
|
+
/**
|
|
24
|
+
* Builds the error for a failed response and clears the session when the
|
|
25
|
+
* credentials were refused, before the error reaches the caller.
|
|
26
|
+
*/
|
|
27
|
+
private error;
|
|
14
28
|
private buildHeaders;
|
|
15
29
|
/**
|
|
16
30
|
* Upload a file to a bucket. Returns the object metadata and a download URL.
|
package/dist/storage.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.KoolbaseStorage = void 0;
|
|
4
|
+
const errors_1 = require("./errors");
|
|
4
5
|
const storage_errors_1 = require("./storage-errors");
|
|
5
6
|
// --- Cloudflare image-transform URL helpers -------------------------------
|
|
6
7
|
// Module-private — callers use KoolbaseStorage.publicUrl / publicUrlForObject.
|
|
@@ -40,9 +41,21 @@ function serializeTransform(t) {
|
|
|
40
41
|
* `overwrite: true` is passed.
|
|
41
42
|
*/
|
|
42
43
|
class KoolbaseStorage {
|
|
43
|
-
constructor(config, getToken) {
|
|
44
|
+
constructor(config, getToken, onSessionExpired) {
|
|
44
45
|
this.config = config;
|
|
45
46
|
this.getToken = getToken;
|
|
47
|
+
this.onSessionExpired = onSessionExpired;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Builds the error for a failed response and clears the session when the
|
|
51
|
+
* credentials were refused, before the error reaches the caller.
|
|
52
|
+
*/
|
|
53
|
+
async error(res, fallback) {
|
|
54
|
+
const err = await (0, storage_errors_1.koolbaseStorageErrorFromResponse)(res, fallback);
|
|
55
|
+
if (err instanceof errors_1.KoolbaseUnauthenticatedError) {
|
|
56
|
+
await this.onSessionExpired?.();
|
|
57
|
+
}
|
|
58
|
+
return err;
|
|
46
59
|
}
|
|
47
60
|
async buildHeaders() {
|
|
48
61
|
const token = await this.getToken();
|
|
@@ -91,7 +104,7 @@ class KoolbaseStorage {
|
|
|
91
104
|
}),
|
|
92
105
|
});
|
|
93
106
|
if (!urlRes.ok) {
|
|
94
|
-
throw await
|
|
107
|
+
throw await this.error(urlRes, 'Failed to get upload URL');
|
|
95
108
|
}
|
|
96
109
|
const { upload_url } = (await urlRes.json());
|
|
97
110
|
// ─── Step 2: Upload directly to R2 ───
|
|
@@ -136,7 +149,7 @@ class KoolbaseStorage {
|
|
|
136
149
|
body: JSON.stringify(confirmBody),
|
|
137
150
|
});
|
|
138
151
|
if (!confirmRes.ok) {
|
|
139
|
-
throw await
|
|
152
|
+
throw await this.error(confirmRes, 'Failed to confirm upload');
|
|
140
153
|
}
|
|
141
154
|
const raw = await confirmRes.json();
|
|
142
155
|
const object = mapObjectFromServer(raw);
|
|
@@ -187,7 +200,7 @@ class KoolbaseStorage {
|
|
|
187
200
|
body: JSON.stringify({ bucket, path, metadata }),
|
|
188
201
|
});
|
|
189
202
|
if (!res.ok) {
|
|
190
|
-
throw await
|
|
203
|
+
throw await this.error(res, 'Failed to update metadata');
|
|
191
204
|
}
|
|
192
205
|
const raw = await res.json();
|
|
193
206
|
return mapObjectFromServer(raw);
|
|
@@ -203,7 +216,7 @@ class KoolbaseStorage {
|
|
|
203
216
|
}
|
|
204
217
|
const res = await fetch(url, { headers: await this.buildHeaders() });
|
|
205
218
|
if (!res.ok) {
|
|
206
|
-
throw await
|
|
219
|
+
throw await this.error(res, 'Failed to get download URL');
|
|
207
220
|
}
|
|
208
221
|
const data = (await res.json());
|
|
209
222
|
return data.url;
|
|
@@ -307,7 +320,7 @@ class KoolbaseStorage {
|
|
|
307
320
|
if (res.status === 204)
|
|
308
321
|
return;
|
|
309
322
|
if (!res.ok) {
|
|
310
|
-
throw await
|
|
323
|
+
throw await this.error(res, 'Failed to delete file');
|
|
311
324
|
}
|
|
312
325
|
}
|
|
313
326
|
/**
|
|
@@ -325,7 +338,7 @@ class KoolbaseStorage {
|
|
|
325
338
|
`?bucket=${encodeURIComponent(bucket)}&path=${encodeURIComponent(path)}`;
|
|
326
339
|
const res = await fetch(url, { headers: await this.buildHeaders() });
|
|
327
340
|
if (!res.ok) {
|
|
328
|
-
throw await
|
|
341
|
+
throw await this.error(res, 'Failed to list versions');
|
|
329
342
|
}
|
|
330
343
|
const data = (await res.json());
|
|
331
344
|
const list = Array.isArray(data.versions) ? data.versions : [];
|
|
@@ -341,7 +354,7 @@ class KoolbaseStorage {
|
|
|
341
354
|
`?bucket=${encodeURIComponent(bucket)}&path=${encodeURIComponent(path)}`;
|
|
342
355
|
const res = await fetch(url, { headers: await this.buildHeaders() });
|
|
343
356
|
if (!res.ok) {
|
|
344
|
-
throw await
|
|
357
|
+
throw await this.error(res, 'Failed to fetch version');
|
|
345
358
|
}
|
|
346
359
|
return fromVersionJson((await res.json()));
|
|
347
360
|
}
|
|
@@ -363,7 +376,7 @@ class KoolbaseStorage {
|
|
|
363
376
|
headers: await this.buildHeaders(),
|
|
364
377
|
});
|
|
365
378
|
if (!res.ok) {
|
|
366
|
-
throw await
|
|
379
|
+
throw await this.error(res, 'Failed to restore version');
|
|
367
380
|
}
|
|
368
381
|
return mapObjectFromServer(await res.json());
|
|
369
382
|
}
|
|
@@ -383,7 +396,7 @@ class KoolbaseStorage {
|
|
|
383
396
|
if (res.status === 204)
|
|
384
397
|
return;
|
|
385
398
|
if (!res.ok) {
|
|
386
|
-
throw await
|
|
399
|
+
throw await this.error(res, 'Failed to purge version');
|
|
387
400
|
}
|
|
388
401
|
}
|
|
389
402
|
}
|
package/dist/sync-engine.d.ts
CHANGED
|
@@ -5,12 +5,26 @@ export declare class SyncEngine {
|
|
|
5
5
|
private getUserId;
|
|
6
6
|
private getToken;
|
|
7
7
|
private onSyncComplete?;
|
|
8
|
+
/**
|
|
9
|
+
* Called when the server rejects the caller's credentials during replay.
|
|
10
|
+
*
|
|
11
|
+
* Background sync is the likeliest place to meet a dead session: the queue
|
|
12
|
+
* replays writes made long before the session stopped being honoured.
|
|
13
|
+
*/
|
|
14
|
+
private onSessionExpired?;
|
|
8
15
|
private unsubscribe?;
|
|
9
16
|
private isSyncing;
|
|
10
|
-
constructor(config: KoolbaseConfig, getUserId: () => string | null, getToken: () => Promise<string | null>, onSyncComplete?: SyncCallback);
|
|
17
|
+
constructor(config: KoolbaseConfig, getUserId: () => string | null, getToken: () => Promise<string | null>, onSyncComplete?: SyncCallback, onSessionExpired?: () => Promise<void>);
|
|
11
18
|
start(): void;
|
|
12
19
|
stop(): void;
|
|
13
20
|
flush(): Promise<void>;
|
|
21
|
+
/**
|
|
22
|
+
* Sends one queued write, returning the revision the record now carries.
|
|
23
|
+
*
|
|
24
|
+
* The revision matters to whatever is queued behind this for the same record:
|
|
25
|
+
* those were composed against this one's result and cannot know its revision
|
|
26
|
+
* until the server assigns it.
|
|
27
|
+
*/
|
|
14
28
|
private executeWrite;
|
|
15
29
|
}
|
|
16
30
|
export {};
|