@koolbase/core 10.0.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 +19 -0
- package/dist/cjs/analytics.d.ts +24 -0
- package/dist/cjs/analytics.js +110 -0
- package/dist/cjs/auth-errors.d.ts +117 -0
- package/dist/cjs/auth-errors.js +250 -0
- package/dist/cjs/auth.d.ts +213 -0
- package/dist/cjs/auth.js +813 -0
- package/dist/cjs/cache-store.d.ts +50 -0
- package/dist/cjs/cache-store.js +194 -0
- package/dist/cjs/conflict.d.ts +80 -0
- package/dist/cjs/conflict.js +84 -0
- package/dist/cjs/database-errors.d.ts +101 -0
- package/dist/cjs/database-errors.js +200 -0
- package/dist/cjs/database.d.ts +298 -0
- package/dist/cjs/database.js +852 -0
- package/dist/cjs/device-id.d.ts +1 -0
- package/dist/cjs/device-id.js +57 -0
- package/dist/cjs/device-metadata.d.ts +36 -0
- package/dist/cjs/device-metadata.js +94 -0
- package/dist/cjs/errors.d.ts +64 -0
- package/dist/cjs/errors.js +85 -0
- package/dist/cjs/flags.d.ts +15 -0
- package/dist/cjs/flags.js +76 -0
- package/dist/cjs/function-errors.d.ts +51 -0
- package/dist/cjs/function-errors.js +103 -0
- package/dist/cjs/functions.d.ts +15 -0
- package/dist/cjs/functions.js +83 -0
- package/dist/cjs/index.d.ts +22 -0
- package/dist/cjs/index.js +56 -0
- package/dist/cjs/messaging.d.ts +13 -0
- package/dist/cjs/messaging.js +36 -0
- package/dist/cjs/offline-state.d.ts +97 -0
- package/dist/cjs/offline-state.js +198 -0
- package/dist/cjs/package.json +3 -0
- package/dist/cjs/pending-write.d.ts +47 -0
- package/dist/cjs/pending-write.js +22 -0
- package/dist/cjs/platform.d.ts +51 -0
- package/dist/cjs/platform.js +41 -0
- package/dist/cjs/realtime.d.ts +44 -0
- package/dist/cjs/realtime.js +195 -0
- package/dist/cjs/record.d.ts +2 -0
- package/dist/cjs/record.js +23 -0
- package/dist/cjs/shared.d.ts +9 -0
- package/dist/cjs/shared.js +43 -0
- package/dist/cjs/storage-errors.d.ts +163 -0
- package/dist/cjs/storage-errors.js +253 -0
- package/dist/cjs/storage.d.ts +198 -0
- package/dist/cjs/storage.js +451 -0
- package/dist/cjs/sync-engine.d.ts +30 -0
- package/dist/cjs/sync-engine.js +286 -0
- package/dist/cjs/types.d.ts +487 -0
- package/dist/cjs/types.js +40 -0
- package/dist/esm/analytics.d.ts +24 -0
- package/dist/esm/analytics.js +106 -0
- package/dist/esm/auth-errors.d.ts +117 -0
- package/dist/esm/auth-errors.js +222 -0
- package/dist/esm/auth.d.ts +213 -0
- package/dist/esm/auth.js +809 -0
- package/dist/esm/cache-store.d.ts +50 -0
- package/dist/esm/cache-store.js +179 -0
- package/dist/esm/conflict.d.ts +80 -0
- package/dist/esm/conflict.js +80 -0
- package/dist/esm/database-errors.d.ts +101 -0
- package/dist/esm/database-errors.js +189 -0
- package/dist/esm/database.d.ts +298 -0
- package/dist/esm/database.js +848 -0
- package/dist/esm/device-id.d.ts +1 -0
- package/dist/esm/device-id.js +54 -0
- package/dist/esm/device-metadata.d.ts +36 -0
- package/dist/esm/device-metadata.js +90 -0
- package/dist/esm/errors.d.ts +64 -0
- package/dist/esm/errors.js +79 -0
- package/dist/esm/flags.d.ts +15 -0
- package/dist/esm/flags.js +72 -0
- package/dist/esm/function-errors.d.ts +51 -0
- package/dist/esm/function-errors.js +93 -0
- package/dist/esm/functions.d.ts +15 -0
- package/dist/esm/functions.js +79 -0
- package/dist/esm/index.d.ts +22 -0
- package/dist/esm/index.js +25 -0
- package/dist/esm/messaging.d.ts +13 -0
- package/dist/esm/messaging.js +32 -0
- package/dist/esm/offline-state.d.ts +97 -0
- package/dist/esm/offline-state.js +189 -0
- package/dist/esm/package.json +3 -0
- package/dist/esm/pending-write.d.ts +47 -0
- package/dist/esm/pending-write.js +19 -0
- package/dist/esm/platform.d.ts +51 -0
- package/dist/esm/platform.js +36 -0
- package/dist/esm/realtime.d.ts +44 -0
- package/dist/esm/realtime.js +191 -0
- package/dist/esm/record.d.ts +2 -0
- package/dist/esm/record.js +20 -0
- package/dist/esm/shared.d.ts +9 -0
- package/dist/esm/shared.js +39 -0
- package/dist/esm/storage-errors.d.ts +163 -0
- package/dist/esm/storage-errors.js +239 -0
- package/dist/esm/storage.d.ts +198 -0
- package/dist/esm/storage.js +447 -0
- package/dist/esm/sync-engine.d.ts +30 -0
- package/dist/esm/sync-engine.js +282 -0
- package/dist/esm/types.d.ts +487 -0
- package/dist/esm/types.js +37 -0
- package/package.json +45 -0
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
import { readOfflineState, mutateOfflineState, migrateLegacyQueue, } from './offline-state.js';
|
|
2
|
+
import { KoolbaseUnauthenticatedError } from './errors.js';
|
|
3
|
+
import { getPlatform } from './platform.js';
|
|
4
|
+
import { invalidateCache, removeCachedRecord, } from './cache-store.js';
|
|
5
|
+
/**
|
|
6
|
+
* Internal signal that the server refused a write because the record moved.
|
|
7
|
+
*
|
|
8
|
+
* Not exported: a conflict during replay becomes durable state rather than
|
|
9
|
+
* reaching a caller, since nobody is waiting on a write made hours ago.
|
|
10
|
+
*/
|
|
11
|
+
/**
|
|
12
|
+
* Whether the server's answer can change on a later attempt.
|
|
13
|
+
*
|
|
14
|
+
* Terminal means the same request would meet the same decision: the data does
|
|
15
|
+
* not satisfy the collection's rules, the record is gone, the caller is not
|
|
16
|
+
* permitted, a unique value is taken. Retrying spends attempts to learn what is
|
|
17
|
+
* already known.
|
|
18
|
+
*
|
|
19
|
+
* 403 is terminal even though a role change could later permit it. A queue that
|
|
20
|
+
* holds writes indefinitely against a maybe is how a retry loop becomes
|
|
21
|
+
* invisible — surfacing it lets an app retry deliberately if roles change.
|
|
22
|
+
*/
|
|
23
|
+
function isTerminal(status) {
|
|
24
|
+
return status === 400 || status === 403 || status === 404 || status === 409;
|
|
25
|
+
}
|
|
26
|
+
/** Raised when the server refused for a reason retrying cannot change. */
|
|
27
|
+
class TerminalRejection extends Error {
|
|
28
|
+
constructor(message, status) {
|
|
29
|
+
super(message);
|
|
30
|
+
this.status = status;
|
|
31
|
+
Object.setPrototypeOf(this, new.target.prototype);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
class RevisionMismatch extends Error {
|
|
35
|
+
constructor(serverRecord, serverRevision) {
|
|
36
|
+
super('revision mismatch');
|
|
37
|
+
this.serverRecord = serverRecord;
|
|
38
|
+
this.serverRevision = serverRevision;
|
|
39
|
+
Object.setPrototypeOf(this, new.target.prototype);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
export class SyncEngine {
|
|
43
|
+
constructor(config, getUserId, getToken, onSyncComplete, onSessionExpired) {
|
|
44
|
+
this.isSyncing = false;
|
|
45
|
+
this.config = config;
|
|
46
|
+
this.getUserId = getUserId;
|
|
47
|
+
this.getToken = getToken;
|
|
48
|
+
this.onSyncComplete = onSyncComplete;
|
|
49
|
+
this.onSessionExpired = onSessionExpired;
|
|
50
|
+
}
|
|
51
|
+
start() {
|
|
52
|
+
this.unsubscribe = getPlatform().network.onChange(online => {
|
|
53
|
+
if (online)
|
|
54
|
+
this.flush();
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
stop() {
|
|
58
|
+
this.unsubscribe?.();
|
|
59
|
+
}
|
|
60
|
+
async flush() {
|
|
61
|
+
if (this.isSyncing)
|
|
62
|
+
return;
|
|
63
|
+
const userId = this.getUserId();
|
|
64
|
+
if (!userId)
|
|
65
|
+
return;
|
|
66
|
+
this.isSyncing = true;
|
|
67
|
+
try {
|
|
68
|
+
// Before anything is sent. Writes queued by an earlier version sit under a
|
|
69
|
+
// different key, and a migration that ran after replay — or depended on
|
|
70
|
+
// being online — would give the same input different outcomes. It clears
|
|
71
|
+
// the old key when done, so later calls find nothing and return.
|
|
72
|
+
await migrateLegacyQueue(userId);
|
|
73
|
+
const { pending } = await readOfflineState(userId);
|
|
74
|
+
if (pending.length === 0)
|
|
75
|
+
return;
|
|
76
|
+
// Records whose chain stopped this pass. Writes queued after a conflicted
|
|
77
|
+
// one were composed against the state it would have produced, so applying
|
|
78
|
+
// them now would write against a state their baseline never described.
|
|
79
|
+
const blocked = new Set();
|
|
80
|
+
for (const queued of pending) {
|
|
81
|
+
if (queued.recordId && blocked.has(queued.recordId))
|
|
82
|
+
continue;
|
|
83
|
+
// Read fresh. The list was taken at the start of the pass, so a write
|
|
84
|
+
// behind one that has already landed still carries the revision it was
|
|
85
|
+
// queued with — and would replay against a revision its predecessor has
|
|
86
|
+
// since superseded, conflicting for a reason the user never caused.
|
|
87
|
+
const state = await readOfflineState(userId);
|
|
88
|
+
const write = state.pending.find((w) => w.id === queued.id);
|
|
89
|
+
if (!write)
|
|
90
|
+
continue;
|
|
91
|
+
try {
|
|
92
|
+
const revision = await this.executeWrite(write);
|
|
93
|
+
// The replayed write just changed the server; the cache must stop
|
|
94
|
+
// testifying to the old world. Mirrors the online paths — a replayed
|
|
95
|
+
// delete evicts the record, and every replayed write invalidates the
|
|
96
|
+
// collection's cached queries, so the next query reconverges instead
|
|
97
|
+
// of serving a ghost.
|
|
98
|
+
if (write.operation === 'delete' && write.recordId) {
|
|
99
|
+
await removeCachedRecord(userId, write.recordId);
|
|
100
|
+
}
|
|
101
|
+
await invalidateCache(userId, write.collection);
|
|
102
|
+
await mutateOfflineState(userId, (s) => {
|
|
103
|
+
s.pending = s.pending.filter((w) => w.id !== write.id);
|
|
104
|
+
// Anything behind this for the same record was composed against its
|
|
105
|
+
// result, and now knows the revision that result carries.
|
|
106
|
+
if (revision !== undefined && write.recordId) {
|
|
107
|
+
for (const w of s.pending) {
|
|
108
|
+
if (w.recordId === write.recordId)
|
|
109
|
+
w.baseRevision = revision;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
catch (e) {
|
|
115
|
+
if (e instanceof KoolbaseUnauthenticatedError) {
|
|
116
|
+
// The session is gone, so nothing else in the queue can succeed.
|
|
117
|
+
// Stopping beats spending a retry on every remaining write against
|
|
118
|
+
// a token the server has already refused; the queue is intact and
|
|
119
|
+
// replays after login.
|
|
120
|
+
await this.onSessionExpired?.();
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
if (e instanceof RevisionMismatch) {
|
|
124
|
+
// Not a failure to retry — retrying cannot help. It becomes durable
|
|
125
|
+
// unresolved state, in one write so it can be in neither place nor
|
|
126
|
+
// both.
|
|
127
|
+
await mutateOfflineState(userId, (s) => {
|
|
128
|
+
s.pending = s.pending.filter((w) => w.id !== write.id);
|
|
129
|
+
s.conflicts.push({
|
|
130
|
+
id: write.id,
|
|
131
|
+
// The record moved between the change being made and the queue
|
|
132
|
+
// reaching it — distinct from a write that never had a baseline
|
|
133
|
+
// to compare against at all.
|
|
134
|
+
reason: 'concurrent_modification',
|
|
135
|
+
operation: write.operation,
|
|
136
|
+
collection: write.collection,
|
|
137
|
+
recordId: write.recordId,
|
|
138
|
+
local: write.data,
|
|
139
|
+
baseline: write.baseline,
|
|
140
|
+
server: e.serverRecord,
|
|
141
|
+
baseRevision: write.baseRevision,
|
|
142
|
+
serverRevision: e.serverRevision,
|
|
143
|
+
createdAt: new Date().toISOString(),
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
if (write.recordId)
|
|
147
|
+
blocked.add(write.recordId);
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
if (e instanceof TerminalRejection) {
|
|
151
|
+
// The server made a decision that will not change on a later
|
|
152
|
+
// attempt. Retrying spends attempts to learn what is already known;
|
|
153
|
+
// dropping loses a change the user believes is saved. It waits,
|
|
154
|
+
// with what the server said, so someone can act on it.
|
|
155
|
+
await mutateOfflineState(userId, (s) => {
|
|
156
|
+
s.pending = s.pending.filter((w) => w.id !== write.id);
|
|
157
|
+
s.conflicts.push({
|
|
158
|
+
id: write.id,
|
|
159
|
+
reason: 'rejected',
|
|
160
|
+
operation: write.operation,
|
|
161
|
+
collection: write.collection,
|
|
162
|
+
recordId: write.recordId ?? '',
|
|
163
|
+
local: write.data,
|
|
164
|
+
baseline: write.baseline,
|
|
165
|
+
baseRevision: write.baseRevision,
|
|
166
|
+
message: e.message,
|
|
167
|
+
createdAt: new Date().toISOString(),
|
|
168
|
+
});
|
|
169
|
+
});
|
|
170
|
+
// A terminally rejected insert leaves an optimistic record behind
|
|
171
|
+
// — cached at enqueue for a record the server refused to create.
|
|
172
|
+
// Left alone it is a phantom: it renders as saved, and an offline
|
|
173
|
+
// edit against it queues a write to a record that does not exist.
|
|
174
|
+
// Evict it, and invalidate the collection so cached queries stop
|
|
175
|
+
// serving it. The conflict above keeps the user's data and the
|
|
176
|
+
// server's verdict; the cache stops testifying to a fiction.
|
|
177
|
+
// MUTATION: phantom eviction removed
|
|
178
|
+
if (write.recordId)
|
|
179
|
+
blocked.add(write.recordId);
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
// Retryable: the network, a 5xx, a rate limit. The count is kept so a
|
|
183
|
+
// caller can see a write that keeps failing, but nothing drops it —
|
|
184
|
+
// a write discarded after three attempts is discarded silently, and
|
|
185
|
+
// the user is never told.
|
|
186
|
+
await mutateOfflineState(userId, (s) => {
|
|
187
|
+
const w = s.pending.find((x) => x.id === write.id);
|
|
188
|
+
if (w)
|
|
189
|
+
w.retries += 1;
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
this.onSyncComplete?.();
|
|
194
|
+
}
|
|
195
|
+
finally {
|
|
196
|
+
this.isSyncing = false;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
/**
|
|
200
|
+
* Sends one queued write, returning the revision the record now carries.
|
|
201
|
+
*
|
|
202
|
+
* The revision matters to whatever is queued behind this for the same record:
|
|
203
|
+
* those were composed against this one's result and cannot know its revision
|
|
204
|
+
* until the server assigns it.
|
|
205
|
+
*/
|
|
206
|
+
async executeWrite(write) {
|
|
207
|
+
const token = await this.getToken();
|
|
208
|
+
const headers = {
|
|
209
|
+
'Content-Type': 'application/json',
|
|
210
|
+
'x-api-key': this.config.publicKey,
|
|
211
|
+
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
|
212
|
+
};
|
|
213
|
+
const url = write.operation === 'insert'
|
|
214
|
+
? `${this.config.baseUrl}/v1/sdk/db/insert`
|
|
215
|
+
: `${this.config.baseUrl}/v1/sdk/db/records/${write.recordId}`;
|
|
216
|
+
let res;
|
|
217
|
+
if (write.operation === 'insert') {
|
|
218
|
+
res = await fetch(url, {
|
|
219
|
+
method: 'POST',
|
|
220
|
+
headers,
|
|
221
|
+
// The write's own id: generated at enqueue, identical on every retry.
|
|
222
|
+
// Without it, an insert whose response was lost duplicated on replay —
|
|
223
|
+
// the server had no way to recognise the repeat.
|
|
224
|
+
body: JSON.stringify({
|
|
225
|
+
collection: write.collection,
|
|
226
|
+
data: write.data,
|
|
227
|
+
idempotency_key: write.id,
|
|
228
|
+
}),
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
else if (write.operation === 'update') {
|
|
232
|
+
res = await fetch(url, {
|
|
233
|
+
method: 'PATCH',
|
|
234
|
+
headers,
|
|
235
|
+
// The revision the change was composed against. The server applies it
|
|
236
|
+
// only if the record still carries that revision, so nothing can land
|
|
237
|
+
// between the client deciding the write is safe and the server applying
|
|
238
|
+
// it — which matters here most of all, since hours may have passed.
|
|
239
|
+
body: JSON.stringify({
|
|
240
|
+
data: write.data,
|
|
241
|
+
...(write.baseRevision !== undefined
|
|
242
|
+
? { expected_revision: write.baseRevision }
|
|
243
|
+
: {}),
|
|
244
|
+
}),
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
else {
|
|
248
|
+
const q = write.baseRevision !== undefined
|
|
249
|
+
? `?expected_revision=${write.baseRevision}`
|
|
250
|
+
: '';
|
|
251
|
+
res = await fetch(`${url}${q}`, { method: 'DELETE', headers });
|
|
252
|
+
}
|
|
253
|
+
if (res.status === 401)
|
|
254
|
+
throw new KoolbaseUnauthenticatedError('unauthorized');
|
|
255
|
+
if (res.status === 409) {
|
|
256
|
+
const body = await res.json().catch(() => ({}));
|
|
257
|
+
if (body?.code === 'revision_mismatch') {
|
|
258
|
+
throw new RevisionMismatch(body?.details?.record, body?.details?.current_revision);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
if (!res.ok && res.status !== 204) {
|
|
262
|
+
const body = await res.json().catch(() => ({}));
|
|
263
|
+
const message = body?.error ?? `${write.operation} failed`;
|
|
264
|
+
// A 404 on a delete means the record is already gone, which is what the
|
|
265
|
+
// write was asking for. Satisfied, not failed.
|
|
266
|
+
if (res.status === 404 && write.operation === 'delete')
|
|
267
|
+
return undefined;
|
|
268
|
+
if (isTerminal(res.status))
|
|
269
|
+
throw new TerminalRejection(message, res.status);
|
|
270
|
+
throw new Error(`${write.operation} sync failed: ${res.status}`);
|
|
271
|
+
}
|
|
272
|
+
const text = await res.text().catch(() => '');
|
|
273
|
+
if (!text)
|
|
274
|
+
return undefined;
|
|
275
|
+
try {
|
|
276
|
+
return JSON.parse(text)?.$revision;
|
|
277
|
+
}
|
|
278
|
+
catch {
|
|
279
|
+
return undefined;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
}
|