@koolbase/react-native 9.0.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 +135 -16
- package/dist/analytics.d.ts +0 -2
- package/dist/analytics.js +2 -26
- 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/device-id.d.ts +1 -0
- package/dist/device-id.js +60 -0
- 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 +5 -1
- package/dist/index.js +22 -12
- package/dist/messaging.d.ts +0 -7
- package/dist/messaging.js +0 -22
- 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
package/dist/database.js
CHANGED
|
@@ -1,13 +1,28 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.KoolbaseDatabase = void 0;
|
|
4
|
+
const errors_1 = require("./errors");
|
|
4
5
|
const cache_store_1 = require("./cache-store");
|
|
6
|
+
const offline_state_1 = require("./offline-state");
|
|
7
|
+
const conflict_1 = require("./conflict");
|
|
8
|
+
const pending_write_1 = require("./pending-write");
|
|
9
|
+
const cache_store_2 = require("./cache-store");
|
|
5
10
|
const sync_engine_1 = require("./sync-engine");
|
|
6
11
|
const record_1 = require("./record");
|
|
7
12
|
const database_errors_1 = require("./database-errors");
|
|
8
|
-
function
|
|
13
|
+
function generateWriteId() {
|
|
9
14
|
return 'local_' + Math.random().toString(36).slice(2) + Date.now().toString(36);
|
|
10
15
|
}
|
|
16
|
+
// Record ids are UUIDs from birth: the server honors a caller-supplied UUID id,
|
|
17
|
+
// so the optimistic identity and the server identity are the same string and
|
|
18
|
+
// chained offline writes need no remapping on replay. Write ids (above) stay
|
|
19
|
+
// local_-prefixed — they are idempotency keys, never addresses.
|
|
20
|
+
function generateRecordId() {
|
|
21
|
+
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => {
|
|
22
|
+
const r = (Math.random() * 16) | 0;
|
|
23
|
+
return (c === 'x' ? r : (r & 0x3) | 0x8).toString(16);
|
|
24
|
+
});
|
|
25
|
+
}
|
|
11
26
|
function batchOpToWire(op) {
|
|
12
27
|
switch (op.type) {
|
|
13
28
|
case 'insert':
|
|
@@ -26,11 +41,40 @@ function batchOpToWire(op) {
|
|
|
26
41
|
}
|
|
27
42
|
}
|
|
28
43
|
class KoolbaseDatabase {
|
|
29
|
-
constructor(config, getUserId, getToken) {
|
|
44
|
+
constructor(config, getUserId, getToken, onSessionExpired) {
|
|
45
|
+
/**
|
|
46
|
+
* Resolves by id, reloading the stored conflict first.
|
|
47
|
+
*
|
|
48
|
+
* A conflict object handed to a UI can sit there while someone decides, and a
|
|
49
|
+
* sync pass may resolve it or another write supersede it meanwhile. Acting on
|
|
50
|
+
* values captured when the object was built would write against a state that
|
|
51
|
+
* no longer exists.
|
|
52
|
+
*/
|
|
53
|
+
this.conflictResolver = {
|
|
54
|
+
resolveWithLocal: async (id) => {
|
|
55
|
+
const c = await this.requireConflict(id);
|
|
56
|
+
await this.applyResolution(c, c.local ?? {});
|
|
57
|
+
},
|
|
58
|
+
resolveWithMerge: async (id, data) => {
|
|
59
|
+
const c = await this.requireConflict(id);
|
|
60
|
+
await this.applyResolution(c, data);
|
|
61
|
+
},
|
|
62
|
+
resolveWithServer: async (id) => {
|
|
63
|
+
const c = await this.requireConflict(id);
|
|
64
|
+
// The server's version stands. Recorded as a decision by removing the
|
|
65
|
+
// conflict, rather than the change quietly disappearing.
|
|
66
|
+
await this.dropConflict(c.id);
|
|
67
|
+
},
|
|
68
|
+
abandon: async (id) => {
|
|
69
|
+
const c = await this.requireConflict(id);
|
|
70
|
+
await this.dropConflict(c.id);
|
|
71
|
+
},
|
|
72
|
+
};
|
|
30
73
|
this.config = config;
|
|
31
74
|
this.getUserId = getUserId;
|
|
32
75
|
this.getToken = getToken;
|
|
33
|
-
this.
|
|
76
|
+
this.onSessionExpired = onSessionExpired;
|
|
77
|
+
this.syncEngine = new sync_engine_1.SyncEngine(config, getUserId, getToken, undefined, onSessionExpired);
|
|
34
78
|
this.syncEngine.start();
|
|
35
79
|
}
|
|
36
80
|
// getUserId is kept only for local cache keys / offline metadata; request
|
|
@@ -49,12 +93,64 @@ class KoolbaseDatabase {
|
|
|
49
93
|
headers: await this.buildHeaders(),
|
|
50
94
|
body: body ? JSON.stringify(body) : undefined,
|
|
51
95
|
});
|
|
52
|
-
|
|
96
|
+
// A 204 carries no body, and some error responses carry none either.
|
|
97
|
+
// Parsing unconditionally would throw before the status was ever checked,
|
|
98
|
+
// which is why delete was written to bypass this path — and why its errors
|
|
99
|
+
// went unreported.
|
|
100
|
+
const text = await res.text();
|
|
101
|
+
let data = null;
|
|
102
|
+
if (text.length > 0) {
|
|
103
|
+
try {
|
|
104
|
+
data = JSON.parse(text);
|
|
105
|
+
}
|
|
106
|
+
catch {
|
|
107
|
+
// Leave data null: a body that is not JSON is not more informative than
|
|
108
|
+
// the status, and failing to parse it must not mask the status.
|
|
109
|
+
}
|
|
110
|
+
}
|
|
53
111
|
if (!res.ok) {
|
|
54
|
-
|
|
112
|
+
const err = (0, database_errors_1.koolbaseDataError)(res.status, data ?? {}, `Request failed: ${res.status}`);
|
|
113
|
+
if (err instanceof errors_1.KoolbaseUnauthenticatedError) {
|
|
114
|
+
await this.onSessionExpired?.();
|
|
115
|
+
}
|
|
116
|
+
throw err;
|
|
55
117
|
}
|
|
56
118
|
return data;
|
|
57
119
|
}
|
|
120
|
+
/**
|
|
121
|
+
* Like [request], but returns the status alongside the body.
|
|
122
|
+
*
|
|
123
|
+
* Several operations need it — upsert distinguishes create from update by a
|
|
124
|
+
* 201, batch reports per-operation outcomes — and needing it was why they
|
|
125
|
+
* hand-rolled their own fetch, each mapping errors slightly differently and
|
|
126
|
+
* none of them clearing a rejected session. One path, two shapes of result.
|
|
127
|
+
*/
|
|
128
|
+
async requestWithStatus(method, path, body) {
|
|
129
|
+
const res = await fetch(`${this.config.baseUrl}${path}`, {
|
|
130
|
+
method,
|
|
131
|
+
headers: await this.buildHeaders(),
|
|
132
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
133
|
+
});
|
|
134
|
+
const text = await res.text();
|
|
135
|
+
let data = null;
|
|
136
|
+
if (text.length > 0) {
|
|
137
|
+
try {
|
|
138
|
+
data = JSON.parse(text);
|
|
139
|
+
}
|
|
140
|
+
catch {
|
|
141
|
+
// Not JSON: the status is more informative than an unparseable body,
|
|
142
|
+
// and failing to parse must not mask it.
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
if (!res.ok) {
|
|
146
|
+
const err = (0, database_errors_1.koolbaseDataError)(res.status, data ?? {}, `Request failed: ${res.status}`);
|
|
147
|
+
if (err instanceof errors_1.KoolbaseUnauthenticatedError) {
|
|
148
|
+
await this.onSessionExpired?.();
|
|
149
|
+
}
|
|
150
|
+
throw err;
|
|
151
|
+
}
|
|
152
|
+
return { status: res.status, data: data };
|
|
153
|
+
}
|
|
58
154
|
// ─── Query (cache-first) ───────────────────────────────────────────────────
|
|
59
155
|
async runQuery(collection, options) {
|
|
60
156
|
const raw = await this.request('POST', '/v1/sdk/db/query', {
|
|
@@ -66,22 +162,53 @@ class KoolbaseDatabase {
|
|
|
66
162
|
order_desc: options.orderDesc ?? false,
|
|
67
163
|
populate: options.populate ?? [],
|
|
68
164
|
});
|
|
69
|
-
|
|
165
|
+
const records = raw.records.map(record_1.recordFromWire);
|
|
166
|
+
// Individually, as well as under the query key. The query cache answers
|
|
167
|
+
// "what did this query return"; the record cache answers "what is the
|
|
168
|
+
// latest copy of this record" — and an offline mutation composes against
|
|
169
|
+
// the second. Without this, listing records and editing one, the most
|
|
170
|
+
// ordinary flow there is, would have no baseline and be refused.
|
|
171
|
+
//
|
|
172
|
+
// Only the top-level records. Populated relations arrive embedded rather
|
|
173
|
+
// than fetched in their own right, and caching them as if they were would
|
|
174
|
+
// risk storing a shape that is not the whole record.
|
|
175
|
+
const userId = this.getUserId() ?? 'anonymous';
|
|
176
|
+
await Promise.all(records.map((r) => r.collection
|
|
177
|
+
? (0, cache_store_1.cacheRecord)(userId, r.collection, r.id, r.data, r.revision)
|
|
178
|
+
: Promise.resolve()));
|
|
179
|
+
return { records, total: raw.total };
|
|
70
180
|
}
|
|
181
|
+
/**
|
|
182
|
+
* Query records, cache-first (stale-while-revalidate).
|
|
183
|
+
*
|
|
184
|
+
* A cache hit is returned immediately with `isFromCache: true`, and a
|
|
185
|
+
* background refresh updates the cache for the next call — so a repeat
|
|
186
|
+
* query converges on the server's state one call behind it. Only a cache
|
|
187
|
+
* miss awaits the network (`isFromCache: false`).
|
|
188
|
+
*
|
|
189
|
+
* Two consequences worth designing for: results can be one refresh stale,
|
|
190
|
+
* even online — re-query if you need convergence after a known write; and
|
|
191
|
+
* background refresh failures are swallowed by design (the cached result
|
|
192
|
+
* has already been returned), so a dead network looks identical to a slow
|
|
193
|
+
* refresh. Check `isFromCache` when the difference matters.
|
|
194
|
+
*
|
|
195
|
+
* The cache is per-user and persisted; it doubles as the offline baseline
|
|
196
|
+
* store for `update`/`delete`.
|
|
197
|
+
*/
|
|
71
198
|
async query(collection, options = {}) {
|
|
72
199
|
const userId = this.getUserId() ?? 'anonymous';
|
|
73
|
-
const queryHash = (0,
|
|
74
|
-
const cached = await (0,
|
|
75
|
-
this.runQuery(collection, options)
|
|
76
|
-
.then(result => (0, cache_store_1.setCached)(userId, collection, queryHash, result))
|
|
77
|
-
.catch(() => {
|
|
78
|
-
// Network unavailable — cached data already returned
|
|
79
|
-
});
|
|
200
|
+
const queryHash = (0, cache_store_2.hashQuery)(collection, options);
|
|
201
|
+
const cached = await (0, cache_store_2.getCached)(userId, collection, queryHash);
|
|
80
202
|
if (cached) {
|
|
203
|
+
this.runQuery(collection, options)
|
|
204
|
+
.then(result => (0, cache_store_2.setCached)(userId, collection, queryHash, result))
|
|
205
|
+
.catch(() => {
|
|
206
|
+
// Network unavailable — cached data already returned
|
|
207
|
+
});
|
|
81
208
|
return { ...cached, isFromCache: true };
|
|
82
209
|
}
|
|
83
210
|
const result = await this.runQuery(collection, options);
|
|
84
|
-
await (0,
|
|
211
|
+
await (0, cache_store_2.setCached)(userId, collection, queryHash, result);
|
|
85
212
|
return { ...result, isFromCache: false };
|
|
86
213
|
}
|
|
87
214
|
// ─── Insert (online-first with offline fallback) ───────────────────────────
|
|
@@ -105,7 +232,10 @@ class KoolbaseDatabase {
|
|
|
105
232
|
// next query sees real data instead of a stale optimistic copy.
|
|
106
233
|
const raw = await this.request('POST', '/v1/sdk/db/insert', { collection, data });
|
|
107
234
|
const record = (0, record_1.recordFromWire)(raw);
|
|
108
|
-
await (0,
|
|
235
|
+
await (0, cache_store_2.invalidateCache)(userId, collection);
|
|
236
|
+
// The response carries a fresh revision, so caching it keeps the
|
|
237
|
+
// baseline current for whatever edits this record next.
|
|
238
|
+
await (0, cache_store_1.cacheRecord)(userId, collection, record.id, record.data, record.revision);
|
|
109
239
|
return record;
|
|
110
240
|
}
|
|
111
241
|
catch (e) {
|
|
@@ -113,24 +243,43 @@ class KoolbaseDatabase {
|
|
|
113
243
|
// Surface to the caller without writing optimistic state or queuing —
|
|
114
244
|
// the server has already decided it will not accept this write, and
|
|
115
245
|
// queuing it would just spin SyncEngine until max retries.
|
|
116
|
-
|
|
246
|
+
// Anything the server answered with — a refusal, a conflict, a rejected
|
|
247
|
+
// credential — must not be queued: it will be refused again on every
|
|
248
|
+
// retry. Checked against the root rather than the data family, because a
|
|
249
|
+
// rejected credential belongs to no single surface.
|
|
250
|
+
if (e instanceof errors_1.KoolbaseError)
|
|
117
251
|
throw e;
|
|
252
|
+
// The queue is per-user, and signed out there is no user: filing this
|
|
253
|
+
// into the anonymous bucket would queue real work where no signed-in
|
|
254
|
+
// sync ever looks — the fake-zero's origin. Refusing is honest; the
|
|
255
|
+
// caller knows the change did not save and can say so.
|
|
256
|
+
if (!this.getUserId()) {
|
|
257
|
+
throw new errors_1.KoolbaseUnauthenticatedError('Signed out and offline — this change cannot be queued for sync.');
|
|
258
|
+
}
|
|
118
259
|
// Genuine network failure → offline path: save to local cache and
|
|
119
260
|
// queue for SyncEngine to retry when online. Return the optimistic
|
|
120
261
|
// record so the UI has something to render in the meantime.
|
|
262
|
+
const recordId = generateRecordId();
|
|
121
263
|
const optimisticRecord = {
|
|
122
|
-
id:
|
|
264
|
+
id: recordId,
|
|
123
265
|
createdBy: userId,
|
|
124
|
-
data,
|
|
266
|
+
data: { ...data, id: recordId },
|
|
125
267
|
createdAt: new Date().toISOString(),
|
|
126
268
|
updatedAt: new Date().toISOString(),
|
|
127
269
|
};
|
|
128
|
-
await (0,
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
270
|
+
await (0, cache_store_2.optimisticallyInsert)(userId, collection, optimisticRecord);
|
|
271
|
+
// No baseline: an insert has no prior state, and the record does not
|
|
272
|
+
// exist on the server yet, so there is nothing to be conditional against.
|
|
273
|
+
// An offline edit to it composes against this queued write instead.
|
|
274
|
+
await (0, offline_state_1.queueWrite)(userId, {
|
|
275
|
+
id: generateWriteId(),
|
|
276
|
+
operation: 'insert',
|
|
132
277
|
collection,
|
|
133
|
-
|
|
278
|
+
recordId: optimisticRecord.id,
|
|
279
|
+
// The record's UUID travels inside the payload: the server honors a
|
|
280
|
+
// caller-supplied id, which is what keeps offline identity alive across
|
|
281
|
+
// the boundary — the whole reason record ids are UUIDs from birth.
|
|
282
|
+
data: optimisticRecord.data,
|
|
134
283
|
});
|
|
135
284
|
return optimisticRecord;
|
|
136
285
|
}
|
|
@@ -152,20 +301,13 @@ class KoolbaseDatabase {
|
|
|
152
301
|
* created, 200 = updated.
|
|
153
302
|
*/
|
|
154
303
|
async upsert(collection, match, data) {
|
|
155
|
-
const
|
|
156
|
-
|
|
157
|
-
headers: await this.buildHeaders(),
|
|
158
|
-
body: JSON.stringify({ collection, match, data }),
|
|
159
|
-
});
|
|
160
|
-
const body = await res.json();
|
|
161
|
-
if (!res.ok) {
|
|
162
|
-
throw (0, database_errors_1.koolbaseDataError)(res.status, body, `Upsert failed: ${res.status}`);
|
|
163
|
-
}
|
|
164
|
-
const created = res.status === 201;
|
|
304
|
+
const { status, data: body } = await this.requestWithStatus('POST', '/v1/sdk/db/upsert', { collection, match, data });
|
|
305
|
+
const created = status === 201;
|
|
165
306
|
const record = (0, record_1.recordFromWire)(body);
|
|
166
307
|
// Keep the cache fresh, same intent as insert's post-success invalidate.
|
|
167
308
|
const userId = this.getUserId() ?? 'anonymous';
|
|
168
|
-
await (0,
|
|
309
|
+
await (0, cache_store_2.invalidateCache)(userId, collection);
|
|
310
|
+
await (0, cache_store_1.cacheRecord)(userId, collection, record.id, record.data, record.revision);
|
|
169
311
|
return { record, created };
|
|
170
312
|
}
|
|
171
313
|
// ─── Delete where (online-only) ─────────────────────────────────────────────
|
|
@@ -181,17 +323,9 @@ class KoolbaseDatabase {
|
|
|
181
323
|
* The collection cache is invalidated on success.
|
|
182
324
|
*/
|
|
183
325
|
async deleteWhere(collection, filters) {
|
|
184
|
-
const
|
|
185
|
-
method: 'POST',
|
|
186
|
-
headers: await this.buildHeaders(),
|
|
187
|
-
body: JSON.stringify({ collection, filters }),
|
|
188
|
-
});
|
|
189
|
-
const body = await res.json();
|
|
190
|
-
if (!res.ok) {
|
|
191
|
-
throw (0, database_errors_1.koolbaseDataError)(res.status, body, `Delete failed: ${res.status}`);
|
|
192
|
-
}
|
|
326
|
+
const body = await this.request('POST', '/v1/sdk/db/delete-where', { collection, filters });
|
|
193
327
|
const userId = this.getUserId() ?? 'anonymous';
|
|
194
|
-
await (0,
|
|
328
|
+
await (0, cache_store_2.invalidateCache)(userId, collection);
|
|
195
329
|
return body.deleted ?? 0;
|
|
196
330
|
}
|
|
197
331
|
// ─── Batch (atomic, online-only) ────────────────────────────────────────────
|
|
@@ -222,17 +356,9 @@ class KoolbaseDatabase {
|
|
|
222
356
|
if (operations.length === 0) {
|
|
223
357
|
throw new Error('batch requires at least one operation');
|
|
224
358
|
}
|
|
225
|
-
const
|
|
226
|
-
|
|
227
|
-
headers: await this.buildHeaders(),
|
|
228
|
-
body: JSON.stringify({
|
|
229
|
-
operations: operations.map(batchOpToWire),
|
|
230
|
-
}),
|
|
359
|
+
const body = await this.request('POST', '/v1/sdk/db/batch', {
|
|
360
|
+
operations: operations.map(batchOpToWire),
|
|
231
361
|
});
|
|
232
|
-
const body = await res.json();
|
|
233
|
-
if (!res.ok) {
|
|
234
|
-
throw (0, database_errors_1.koolbaseDataError)(res.status, body, `Batch failed: ${res.status}`);
|
|
235
|
-
}
|
|
236
362
|
const results = (body.results ?? []).map(r => ({
|
|
237
363
|
type: r.type ?? '',
|
|
238
364
|
record: r.record
|
|
@@ -246,6 +372,15 @@ class KoolbaseDatabase {
|
|
|
246
372
|
// don't know the collection at this layer — those refresh naturally on
|
|
247
373
|
// the next query for the affected collection.
|
|
248
374
|
const userId = this.getUserId() ?? 'anonymous';
|
|
375
|
+
// Records returned by a batch carry their own collection on the wire, so
|
|
376
|
+
// they can be cached even where the input op did not name one — which is
|
|
377
|
+
// what the invalidation below cannot do. A batch commits transactionally, so
|
|
378
|
+
// every record here landed together and carries a fresh revision.
|
|
379
|
+
for (const r of results) {
|
|
380
|
+
if (r.record?.collection) {
|
|
381
|
+
await (0, cache_store_1.cacheRecord)(userId, r.record.collection, r.record.id, r.record.data, r.record.revision);
|
|
382
|
+
}
|
|
383
|
+
}
|
|
249
384
|
const touched = new Set();
|
|
250
385
|
for (const op of operations) {
|
|
251
386
|
if (op.type === 'insert' || op.type === 'upsert') {
|
|
@@ -253,7 +388,7 @@ class KoolbaseDatabase {
|
|
|
253
388
|
}
|
|
254
389
|
}
|
|
255
390
|
for (const col of touched) {
|
|
256
|
-
await (0,
|
|
391
|
+
await (0, cache_store_2.invalidateCache)(userId, col);
|
|
257
392
|
}
|
|
258
393
|
return results;
|
|
259
394
|
}
|
|
@@ -261,7 +396,126 @@ class KoolbaseDatabase {
|
|
|
261
396
|
// ─── Get single record ──────────────────────────────────────────────────────
|
|
262
397
|
async get(recordId) {
|
|
263
398
|
const raw = await this.request('GET', `/v1/sdk/db/records/${recordId}`);
|
|
264
|
-
|
|
399
|
+
const record = (0, record_1.recordFromWire)(raw);
|
|
400
|
+
// Opening a record then editing it is the other ordinary flow, and a deep
|
|
401
|
+
// link reaches it without a query ever having run.
|
|
402
|
+
if (record.collection) {
|
|
403
|
+
await (0, cache_store_1.cacheRecord)(this.getUserId() ?? 'anonymous', record.collection, record.id, record.data, record.revision);
|
|
404
|
+
}
|
|
405
|
+
return record;
|
|
406
|
+
}
|
|
407
|
+
// ─── Conflicts ──────────────────────────────────────────────────────────────
|
|
408
|
+
/**
|
|
409
|
+
* Writes that could not be applied, waiting for a decision.
|
|
410
|
+
*
|
|
411
|
+
* Held rather than discarded, and surviving restarts. An app that never reads
|
|
412
|
+
* these accumulates them invisibly, with the changes they hold never applied —
|
|
413
|
+
* so if you support offline editing, surface them somewhere.
|
|
414
|
+
*/
|
|
415
|
+
/**
|
|
416
|
+
* Changes made offline, waiting to be sent. Oldest first.
|
|
417
|
+
*
|
|
418
|
+
* For sync indicators ("3 changes waiting") and for warning a user who is
|
|
419
|
+
* about to log out with unsynced edits — see [PendingWrite] for why that
|
|
420
|
+
* moment matters. Snapshot, not a live handle; per-user.
|
|
421
|
+
*/
|
|
422
|
+
async pendingWrites() {
|
|
423
|
+
const userId = this.requireUserId('the pending-write queue');
|
|
424
|
+
const { pending } = await (0, offline_state_1.readOfflineState)(userId);
|
|
425
|
+
return pending.map(pending_write_1.toPendingWrite);
|
|
426
|
+
}
|
|
427
|
+
async conflicts() {
|
|
428
|
+
const userId = this.requireUserId('the conflict list');
|
|
429
|
+
const { conflicts } = await (0, offline_state_1.readOfflineState)(userId);
|
|
430
|
+
return conflicts.map((c) => new conflict_1.KoolbaseConflict(c.id, c.reason, c.operation, c.collection, c.recordId, c.local, c.baseline, c.server, c.baseRevision, c.serverRevision, c.createdAt, this.conflictResolver));
|
|
431
|
+
}
|
|
432
|
+
/**
|
|
433
|
+
* Per-user state demands a user. Signed out, "no answer" must not be
|
|
434
|
+
* disguised as "empty" — tonight's fake-zero: the display read the anonymous
|
|
435
|
+
* bucket while a signed-in user's writes sat unseen in theirs.
|
|
436
|
+
*/
|
|
437
|
+
requireUserId(doing) {
|
|
438
|
+
const userId = this.getUserId();
|
|
439
|
+
if (!userId) {
|
|
440
|
+
throw new errors_1.KoolbaseUnauthenticatedError(`Signed out — ${doing} is per-user state and has no answer without a user.`);
|
|
441
|
+
}
|
|
442
|
+
return userId;
|
|
443
|
+
}
|
|
444
|
+
async requireConflict(id) {
|
|
445
|
+
const userId = this.requireUserId('conflict resolution');
|
|
446
|
+
const { conflicts } = await (0, offline_state_1.readOfflineState)(userId);
|
|
447
|
+
const found = conflicts.find((c) => c.id === id);
|
|
448
|
+
if (!found) {
|
|
449
|
+
throw new database_errors_1.KoolbaseDataError('That conflict is no longer outstanding — it may already have been resolved.', 'conflict_not_found');
|
|
450
|
+
}
|
|
451
|
+
return found;
|
|
452
|
+
}
|
|
453
|
+
async dropConflict(id) {
|
|
454
|
+
const userId = this.requireUserId('conflict resolution');
|
|
455
|
+
await (0, offline_state_1.mutateOfflineState)(userId, (s) => {
|
|
456
|
+
s.conflicts = s.conflicts.filter((c) => c.id !== id);
|
|
457
|
+
});
|
|
458
|
+
}
|
|
459
|
+
/**
|
|
460
|
+
* Issues the resolving write, conditional on the revision the refusal
|
|
461
|
+
* reported, and clears the conflict only once the server accepts it.
|
|
462
|
+
*
|
|
463
|
+
* Clearing first would lose the change if the write then failed.
|
|
464
|
+
*/
|
|
465
|
+
async applyResolution(c, payload) {
|
|
466
|
+
const rev = c.serverRevision;
|
|
467
|
+
try {
|
|
468
|
+
if (c.operation === 'insert') {
|
|
469
|
+
// Resolving a rejected insert IS the insert, retried — with amended
|
|
470
|
+
// data via resolveWithMerge (the "fix the colliding title" path).
|
|
471
|
+
// Unconditional: there is no revision to be conditional against,
|
|
472
|
+
// because there is no record. The conflict's id rides as the
|
|
473
|
+
// idempotency key, so a resolution whose response is lost returns the
|
|
474
|
+
// original on retry rather than duplicating — the queue's own
|
|
475
|
+
// lost-response discipline, extended to the one insert path that
|
|
476
|
+
// lacked it.
|
|
477
|
+
await this.request('POST', '/v1/sdk/db/insert', {
|
|
478
|
+
collection: c.collection,
|
|
479
|
+
data: payload,
|
|
480
|
+
idempotency_key: c.id,
|
|
481
|
+
});
|
|
482
|
+
}
|
|
483
|
+
else if (c.operation === 'delete') {
|
|
484
|
+
const q = rev !== undefined ? `?expected_revision=${rev}` : '';
|
|
485
|
+
await this.request('DELETE', `/v1/sdk/db/records/${c.recordId}${q}`);
|
|
486
|
+
}
|
|
487
|
+
else {
|
|
488
|
+
await this.request('PATCH', `/v1/sdk/db/records/${c.recordId}`, { data: payload, ...(rev !== undefined ? { expected_revision: rev } : {}) });
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
catch (e) {
|
|
492
|
+
// A refusal must teach the stored conflict, not just gate it. The 409
|
|
493
|
+
// carries the server's current revision and record; absorbing them makes
|
|
494
|
+
// the NEXT attempt conditional against reality. Without this, every
|
|
495
|
+
// retry replays the stale condition and a conflict whose resolution
|
|
496
|
+
// fails once is permanently unresolvable except by abandon —
|
|
497
|
+
// device-proven: three identical refusals against an unchanged server.
|
|
498
|
+
const details = e instanceof database_errors_1.KoolbaseDataError ? e.details : undefined;
|
|
499
|
+
const current = details?.current_revision;
|
|
500
|
+
const record = details?.record;
|
|
501
|
+
if (typeof current === 'number') {
|
|
502
|
+
await (0, offline_state_1.mutateOfflineState)(this.requireUserId('conflict resolution'), (st) => {
|
|
503
|
+
const stored = st.conflicts.find((x) => x.id === c.id);
|
|
504
|
+
if (!stored)
|
|
505
|
+
return;
|
|
506
|
+
stored.serverRevision = current;
|
|
507
|
+
// Storing the fresh server snapshot IS the divergence update: the
|
|
508
|
+
// public conflict computes divergentFields from local vs server.
|
|
509
|
+
if (record)
|
|
510
|
+
stored.server = record;
|
|
511
|
+
});
|
|
512
|
+
throw new database_errors_1.KoolbaseDataError('The record has changed again while deciding. The conflict now ' +
|
|
513
|
+
'reflects the server\'s current state — review and retry.', 'revision_mismatch');
|
|
514
|
+
}
|
|
515
|
+
throw e;
|
|
516
|
+
}
|
|
517
|
+
await this.dropConflict(c.id);
|
|
518
|
+
await (0, cache_store_2.invalidateCache)(this.getUserId() ?? 'anonymous', c.collection);
|
|
265
519
|
}
|
|
266
520
|
// ─── Update (online-first with offline fallback) ───────────────────────────
|
|
267
521
|
/**
|
|
@@ -277,46 +531,136 @@ class KoolbaseDatabase {
|
|
|
277
531
|
* optimistic record is returned so the UI can re-render the new fields
|
|
278
532
|
* immediately.
|
|
279
533
|
*/
|
|
534
|
+
/**
|
|
535
|
+
* The record's state as the SDK last knew it, for composing an offline
|
|
536
|
+
* mutation against.
|
|
537
|
+
*
|
|
538
|
+
* Two sources, in order. A record created offline is not in the cache as a
|
|
539
|
+
* server record, but its queued insert holds the state a later edit builds on
|
|
540
|
+
* — insert-then-correct is the ordinary offline sequence. Otherwise the cached
|
|
541
|
+
* copy, with the revision it was read at.
|
|
542
|
+
*
|
|
543
|
+
* Null when neither exists: never seen on this device, or a queued delete has
|
|
544
|
+
* already removed it locally.
|
|
545
|
+
*/
|
|
546
|
+
async resolveBaseline(userId, recordId) {
|
|
547
|
+
const state = await (0, offline_state_1.readOfflineState)(userId);
|
|
548
|
+
const queued = state.pending.filter((w) => w.recordId === recordId);
|
|
549
|
+
if (queued.length > 0) {
|
|
550
|
+
let projected = null;
|
|
551
|
+
for (const w of queued) {
|
|
552
|
+
if (w.operation === 'insert')
|
|
553
|
+
projected = { ...(w.data ?? {}) };
|
|
554
|
+
else if (w.operation === 'update')
|
|
555
|
+
projected = { ...(projected ?? {}), ...(w.data ?? {}) };
|
|
556
|
+
else if (w.operation === 'delete')
|
|
557
|
+
projected = null;
|
|
558
|
+
}
|
|
559
|
+
// A chain ending in a delete leaves nothing to build on: editing a record
|
|
560
|
+
// already removed locally is a contradiction in the SDK's own state, not
|
|
561
|
+
// a conflict to resolve against the server.
|
|
562
|
+
if (projected === null)
|
|
563
|
+
return null;
|
|
564
|
+
return {
|
|
565
|
+
baseline: projected,
|
|
566
|
+
revision: queued[queued.length - 1].baseRevision,
|
|
567
|
+
collection: queued[0].collection,
|
|
568
|
+
};
|
|
569
|
+
}
|
|
570
|
+
const cached = await (0, cache_store_1.getCachedRecord)(userId, recordId);
|
|
571
|
+
if (!cached)
|
|
572
|
+
return null;
|
|
573
|
+
return { baseline: cached.data, revision: cached.revision, collection: cached.collection };
|
|
574
|
+
}
|
|
280
575
|
async update(recordId, data) {
|
|
281
576
|
const userId = this.getUserId() ?? 'anonymous';
|
|
577
|
+
// Resolved before the request, so a network failure has somewhere to go.
|
|
578
|
+
const base = await this.resolveBaseline(userId, recordId);
|
|
282
579
|
try {
|
|
283
580
|
const raw = await this.request('PATCH', `/v1/sdk/db/records/${recordId}`, { data });
|
|
284
|
-
|
|
581
|
+
const updated = (0, record_1.recordFromWire)(raw);
|
|
582
|
+
if (updated.collection) {
|
|
583
|
+
await (0, cache_store_1.cacheRecord)(userId, updated.collection, updated.id, updated.data, updated.revision);
|
|
584
|
+
}
|
|
585
|
+
return updated;
|
|
285
586
|
}
|
|
286
587
|
catch (e) {
|
|
287
588
|
// Server-reachable rejection: surface to caller without queuing — the
|
|
288
589
|
// server already refused the write and will refuse it again on retry.
|
|
289
|
-
|
|
590
|
+
// Anything the server answered with — a refusal, a conflict, a rejected
|
|
591
|
+
// credential — must not be queued: it will be refused again on every
|
|
592
|
+
// retry. Checked against the root rather than the data family, because a
|
|
593
|
+
// rejected credential belongs to no single surface.
|
|
594
|
+
if (e instanceof errors_1.KoolbaseError)
|
|
290
595
|
throw e;
|
|
291
|
-
// Genuine network failure
|
|
292
|
-
//
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
596
|
+
// Genuine network failure. Queueable only if the SDK knows what the
|
|
597
|
+
// change was composed against — without that, replay would apply it
|
|
598
|
+
// blindly and overwrite whatever happened while the device was away.
|
|
599
|
+
if (!base) {
|
|
600
|
+
throw new errors_1.KoolbaseOfflineBaselineUnavailableError('This record must be read at least once before it can be updated offline.');
|
|
601
|
+
}
|
|
602
|
+
await (0, offline_state_1.queueWrite)(userId, {
|
|
603
|
+
id: generateWriteId(),
|
|
604
|
+
operation: 'update',
|
|
605
|
+
collection: base.collection,
|
|
296
606
|
recordId,
|
|
297
607
|
data,
|
|
608
|
+
baseline: base.baseline,
|
|
609
|
+
baseRevision: base.revision,
|
|
298
610
|
});
|
|
611
|
+
const merged = { ...base.baseline, ...data };
|
|
612
|
+
await (0, cache_store_1.cacheRecord)(userId, base.collection, recordId, merged, base.revision);
|
|
613
|
+
// Optimistic: durable locally and queued to send, not yet accepted.
|
|
299
614
|
return {
|
|
300
615
|
id: recordId,
|
|
301
|
-
|
|
616
|
+
collection: base.collection,
|
|
617
|
+
data: merged,
|
|
302
618
|
createdAt: '',
|
|
303
619
|
updatedAt: new Date().toISOString(),
|
|
620
|
+
revision: base.revision,
|
|
304
621
|
};
|
|
305
622
|
}
|
|
306
623
|
}
|
|
307
624
|
// ─── Delete ─────────────────────────────────────────────────────────────────
|
|
308
625
|
async delete(recordId) {
|
|
309
626
|
const userId = this.getUserId() ?? 'anonymous';
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
//
|
|
627
|
+
const base = await this.resolveBaseline(userId, recordId);
|
|
628
|
+
try {
|
|
629
|
+
await this.request('DELETE', `/v1/sdk/db/records/${recordId}`);
|
|
630
|
+
await (0, cache_store_1.removeCachedRecord)(userId, recordId);
|
|
631
|
+
}
|
|
632
|
+
catch (e) {
|
|
633
|
+
// A server that answered has refused: a permission denial or a missing
|
|
634
|
+
// record will be refused again on every retry, so surface it rather than
|
|
635
|
+
// queueing. An app told a delete succeeded when it did not has no way to
|
|
636
|
+
// learn otherwise.
|
|
637
|
+
// Anything the server answered with — a refusal, a conflict, a rejected
|
|
638
|
+
// credential — must not be queued: it will be refused again on every
|
|
639
|
+
// retry. Checked against the root rather than the data family, because a
|
|
640
|
+
// rejected credential belongs to no single surface.
|
|
641
|
+
if (e instanceof errors_1.KoolbaseError)
|
|
642
|
+
throw e;
|
|
643
|
+
// Genuine network failure. Queued here rather than before the request,
|
|
644
|
+
// which would leave a successful delete in the queue to replay later
|
|
645
|
+
// against a record that may since have been recreated under the same id.
|
|
646
|
+
// A delete replayed without knowing what the record was would remove
|
|
647
|
+
// something the user last saw hours earlier and which may have changed
|
|
648
|
+
// since — the more destructive kind of stale write.
|
|
649
|
+
if (!base) {
|
|
650
|
+
throw new errors_1.KoolbaseOfflineBaselineUnavailableError('This record must be read at least once before it can be deleted offline.');
|
|
651
|
+
}
|
|
652
|
+
await (0, offline_state_1.queueWrite)(userId, {
|
|
653
|
+
id: generateWriteId(),
|
|
654
|
+
operation: 'delete',
|
|
655
|
+
collection: base.collection,
|
|
656
|
+
recordId,
|
|
657
|
+
baseline: base.baseline,
|
|
658
|
+
baseRevision: base.revision,
|
|
659
|
+
});
|
|
660
|
+
// The queued write holds its own copy of the baseline, so removing the
|
|
661
|
+
// cached record costs nothing and keeps local reads consistent with what
|
|
662
|
+
// the user just did.
|
|
663
|
+
await (0, cache_store_1.removeCachedRecord)(userId, recordId);
|
|
320
664
|
}
|
|
321
665
|
}
|
|
322
666
|
// ─── Vectors ────────────────────────────────────────────────────────────────
|
|
@@ -338,15 +682,11 @@ class KoolbaseDatabase {
|
|
|
338
682
|
* );
|
|
339
683
|
*/
|
|
340
684
|
async setVector(recordId, field, vector) {
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
685
|
+
await this.request('POST', '/v1/sdk/db/set-vector', {
|
|
686
|
+
record_id: recordId,
|
|
687
|
+
field,
|
|
688
|
+
vector,
|
|
345
689
|
});
|
|
346
|
-
if (res.status !== 204) {
|
|
347
|
-
const body = await res.json().catch(() => ({}));
|
|
348
|
-
throw (0, database_errors_1.koolbaseDataError)(res.status, body, 'Set vector failed');
|
|
349
|
-
}
|
|
350
690
|
}
|
|
351
691
|
/**
|
|
352
692
|
* Read a record's stored vector on the named `field`.
|
|
@@ -384,15 +724,10 @@ class KoolbaseDatabase {
|
|
|
384
724
|
* collection and is still settable on other records.
|
|
385
725
|
*/
|
|
386
726
|
async deleteVector(recordId, field) {
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
body: JSON.stringify({ record_id: recordId, field }),
|
|
727
|
+
await this.request('POST', '/v1/sdk/db/delete-vector', {
|
|
728
|
+
record_id: recordId,
|
|
729
|
+
field,
|
|
391
730
|
});
|
|
392
|
-
if (res.status !== 204) {
|
|
393
|
-
const body = await res.json().catch(() => ({}));
|
|
394
|
-
throw (0, database_errors_1.koolbaseDataError)(res.status, body, 'Delete vector failed');
|
|
395
|
-
}
|
|
396
731
|
}
|
|
397
732
|
/**
|
|
398
733
|
* Queue an embedding job for a record's vector field. The server's
|
|
@@ -492,11 +827,20 @@ class KoolbaseDatabase {
|
|
|
492
827
|
body.min_similarity = opts.minSimilarity;
|
|
493
828
|
}
|
|
494
829
|
const raw = await this.request('POST', '/v1/sdk/db/search-semantic', body);
|
|
830
|
+
// A hit carries the complete public record, not a projection, so these are
|
|
831
|
+
// safe to cache as baselines. A trimmed record would be worse than none: an
|
|
832
|
+
// offline edit would compose against an incomplete picture and conflict
|
|
833
|
+
// detection would compare against fields that were never there.
|
|
834
|
+
const hits = (raw.results ?? []).map((r) => ({
|
|
835
|
+
record: (0, record_1.recordFromWire)(r.record),
|
|
836
|
+
distance: r.distance,
|
|
837
|
+
}));
|
|
838
|
+
const searchUserId = this.getUserId() ?? 'anonymous';
|
|
839
|
+
await Promise.all(hits.map((h) => h.record.collection
|
|
840
|
+
? (0, cache_store_1.cacheRecord)(searchUserId, h.record.collection, h.record.id, h.record.data, h.record.revision)
|
|
841
|
+
: Promise.resolve()));
|
|
495
842
|
return {
|
|
496
|
-
hits:
|
|
497
|
-
record: (0, record_1.recordFromWire)(r.record),
|
|
498
|
-
distance: r.distance,
|
|
499
|
-
})),
|
|
843
|
+
hits: hits,
|
|
500
844
|
total: raw.total ?? (raw.results ?? []).length,
|
|
501
845
|
};
|
|
502
846
|
}
|