@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/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 generateId() {
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.syncEngine = new sync_engine_1.SyncEngine(config, getUserId, getToken);
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
- const data = await res.json();
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
- throw (0, database_errors_1.koolbaseDataError)(res.status, data, `Request failed: ${res.status}`);
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
- return { records: raw.records.map(record_1.recordFromWire), total: raw.total };
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, cache_store_1.hashQuery)(collection, options);
74
- const cached = await (0, cache_store_1.getCached)(userId, collection, queryHash);
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, cache_store_1.setCached)(userId, collection, queryHash, result);
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, cache_store_1.invalidateCache)(userId, collection);
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
- if (e instanceof database_errors_1.KoolbaseDataError)
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: generateId(),
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, cache_store_1.optimisticallyInsert)(userId, collection, optimisticRecord);
129
- await (0, cache_store_1.addToWriteQueue)(userId, {
130
- id: generateId(),
131
- type: 'insert',
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
- data,
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 res = await fetch(`${this.config.baseUrl}/v1/sdk/db/upsert`, {
156
- method: 'POST',
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, cache_store_1.invalidateCache)(userId, collection);
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 res = await fetch(`${this.config.baseUrl}/v1/sdk/db/delete-where`, {
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, cache_store_1.invalidateCache)(userId, collection);
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 res = await fetch(`${this.config.baseUrl}/v1/sdk/db/batch`, {
226
- method: 'POST',
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, cache_store_1.invalidateCache)(userId, col);
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
- return (0, record_1.recordFromWire)(raw);
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
- return (0, record_1.recordFromWire)(raw);
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
- if (e instanceof database_errors_1.KoolbaseDataError)
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 queue for sync and return an optimistic
292
- // partial record so the UI reflects the update immediately.
293
- await (0, cache_store_1.addToWriteQueue)(userId, {
294
- id: generateId(),
295
- type: 'update',
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
- data,
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
- // Add to write queue
311
- await (0, cache_store_1.addToWriteQueue)(userId, {
312
- id: generateId(),
313
- type: 'delete',
314
- recordId,
315
- });
316
- // Try network
317
- const res = await fetch(`${this.config.baseUrl}/v1/sdk/db/records/${recordId}`, { method: 'DELETE', headers: await this.buildHeaders(), });
318
- if (!res.ok && res.status !== 204) {
319
- // Queued for sync — will retry when online
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
- const res = await fetch(`${this.config.baseUrl}/v1/sdk/db/set-vector`, {
342
- method: 'POST',
343
- headers: await this.buildHeaders(),
344
- body: JSON.stringify({ record_id: recordId, field, vector }),
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
- const res = await fetch(`${this.config.baseUrl}/v1/sdk/db/delete-vector`, {
388
- method: 'POST',
389
- headers: await this.buildHeaders(),
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: (raw.results ?? []).map(r => ({
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
  }