@koolbase/core 12.13.0 → 12.14.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.
@@ -58,3 +58,17 @@ export declare function addToWriteQueue(userId: string, write: Omit<LegacyPendin
58
58
  export declare function removeFromWriteQueue(userId: string, writeId: string): Promise<void>;
59
59
  export declare function incrementWriteRetry(userId: string, writeId: string): Promise<void>;
60
60
  export declare function optimisticallyInsert(userId: string, collection: string, record: KoolbaseRecord): Promise<void>;
61
+ /**
62
+ * The reverse of optimisticallyInsert: takes one record out of every cached
63
+ * result of a collection.
64
+ *
65
+ * Precise on purpose. Dropping the collection's cached results instead would
66
+ * leave a device that is still offline with no saved lists to show at all,
67
+ * to get rid of one record.
68
+ */
69
+ export declare function removeFromCachedQueries(userId: string, collection: string, recordId: string): Promise<void>;
70
+ /**
71
+ * Applies an offline edit to the record wherever a cached result holds it, so
72
+ * a saved list shows what the user just changed rather than what it was.
73
+ */
74
+ export declare function updateInCachedQueries(userId: string, collection: string, recordId: string, data: Record<string, unknown>): Promise<void>;
@@ -13,6 +13,8 @@ exports.addToWriteQueue = addToWriteQueue;
13
13
  exports.removeFromWriteQueue = removeFromWriteQueue;
14
14
  exports.incrementWriteRetry = incrementWriteRetry;
15
15
  exports.optimisticallyInsert = optimisticallyInsert;
16
+ exports.removeFromCachedQueries = removeFromCachedQueries;
17
+ exports.updateInCachedQueries = updateInCachedQueries;
16
18
  const platform_js_1 = require("./platform.js");
17
19
  const CACHE_VERSION = 'v1';
18
20
  function cacheKey(userId, collection, queryHash) {
@@ -211,3 +213,65 @@ async function optimisticallyInsert(userId, collection, record) {
211
213
  // ignore
212
214
  }
213
215
  }
216
+ /**
217
+ * The reverse of optimisticallyInsert: takes one record out of every cached
218
+ * result of a collection.
219
+ *
220
+ * Precise on purpose. Dropping the collection's cached results instead would
221
+ * leave a device that is still offline with no saved lists to show at all,
222
+ * to get rid of one record.
223
+ */
224
+ async function removeFromCachedQueries(userId, collection, recordId) {
225
+ try {
226
+ const keys = await (0, platform_js_1.getPlatform)().storage.getAllKeys();
227
+ const prefix = `koolbase:${CACHE_VERSION}:${userId}:${collection}:`;
228
+ for (const key of keys.filter(k => k.startsWith(prefix))) {
229
+ const raw = await (0, platform_js_1.getPlatform)().storage.getItem(key);
230
+ if (!raw)
231
+ continue;
232
+ const cached = JSON.parse(raw);
233
+ if (!Array.isArray(cached.records))
234
+ continue;
235
+ const kept = cached.records.filter(r => r.id !== recordId);
236
+ const removed = cached.records.length - kept.length;
237
+ if (removed === 0)
238
+ continue;
239
+ cached.records = kept;
240
+ cached.total = Math.max(0, cached.total - removed);
241
+ await (0, platform_js_1.getPlatform)().storage.setItem(key, JSON.stringify(cached));
242
+ }
243
+ }
244
+ catch {
245
+ // ignore
246
+ }
247
+ }
248
+ /**
249
+ * Applies an offline edit to the record wherever a cached result holds it, so
250
+ * a saved list shows what the user just changed rather than what it was.
251
+ */
252
+ async function updateInCachedQueries(userId, collection, recordId, data) {
253
+ try {
254
+ const keys = await (0, platform_js_1.getPlatform)().storage.getAllKeys();
255
+ const prefix = `koolbase:${CACHE_VERSION}:${userId}:${collection}:`;
256
+ for (const key of keys.filter(k => k.startsWith(prefix))) {
257
+ const raw = await (0, platform_js_1.getPlatform)().storage.getItem(key);
258
+ if (!raw)
259
+ continue;
260
+ const cached = JSON.parse(raw);
261
+ if (!Array.isArray(cached.records))
262
+ continue;
263
+ let changed = false;
264
+ cached.records = cached.records.map(r => {
265
+ if (r.id !== recordId)
266
+ return r;
267
+ changed = true;
268
+ return { ...r, data: { ...(r.data ?? {}), ...data } };
269
+ });
270
+ if (changed)
271
+ await (0, platform_js_1.getPlatform)().storage.setItem(key, JSON.stringify(cached));
272
+ }
273
+ }
274
+ catch {
275
+ // ignore
276
+ }
277
+ }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Whether the device reports a network connection. A hint, not a promise that
3
+ * Koolbase is reachable: requests can still fail while online.
4
+ *
5
+ * 'unknown' until the platform first answers -- a real state, not a guess. An
6
+ * app should neither show "offline" nor run online-only logic on it.
7
+ */
8
+ export type ConnectivityState = 'unknown' | 'online' | 'offline';
9
+ /**
10
+ * The device's connectivity, as state an app can render: getState() for now,
11
+ * subscribe() for changes (the shape React's useSyncExternalStore takes).
12
+ */
13
+ export declare class KoolbaseConnectivity {
14
+ private state;
15
+ private readonly listeners;
16
+ private stop;
17
+ private heard;
18
+ /** Starts following the platform. Called once by Koolbase.initialize. */
19
+ start(): void;
20
+ getState(): ConnectivityState;
21
+ /** Called on every change. Returns the unsubscribe. */
22
+ subscribe(listener: () => void): () => void;
23
+ dispose(): void;
24
+ private set;
25
+ }
@@ -0,0 +1,59 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.KoolbaseConnectivity = void 0;
4
+ const platform_js_1 = require("./platform.js");
5
+ /**
6
+ * The device's connectivity, as state an app can render: getState() for now,
7
+ * subscribe() for changes (the shape React's useSyncExternalStore takes).
8
+ */
9
+ class KoolbaseConnectivity {
10
+ constructor() {
11
+ this.state = 'unknown';
12
+ this.listeners = new Set();
13
+ this.stop = null;
14
+ // A change event is fresher than the first read: once one arrives, a late
15
+ // answer to that read is ignored.
16
+ this.heard = false;
17
+ }
18
+ /** Starts following the platform. Called once by Koolbase.initialize. */
19
+ start() {
20
+ if (this.stop)
21
+ return;
22
+ const network = (0, platform_js_1.getPlatform)().network;
23
+ this.stop = network.onChange((online) => {
24
+ this.heard = true;
25
+ this.set(online ? 'online' : 'offline');
26
+ });
27
+ if (network.current) {
28
+ network.current().then((online) => {
29
+ if (!this.heard && online !== null && online !== undefined)
30
+ this.set(online ? 'online' : 'offline');
31
+ }, () => { });
32
+ }
33
+ }
34
+ getState() {
35
+ return this.state;
36
+ }
37
+ /** Called on every change. Returns the unsubscribe. */
38
+ subscribe(listener) {
39
+ this.listeners.add(listener);
40
+ return () => { this.listeners.delete(listener); };
41
+ }
42
+ dispose() {
43
+ this.stop?.();
44
+ this.stop = null;
45
+ this.listeners.clear();
46
+ }
47
+ set(next) {
48
+ if (next === this.state)
49
+ return;
50
+ this.state = next;
51
+ for (const listener of [...this.listeners]) {
52
+ try {
53
+ listener();
54
+ }
55
+ catch { /* one broken listener must not stop the rest */ }
56
+ }
57
+ }
58
+ }
59
+ exports.KoolbaseConnectivity = KoolbaseConnectivity;
@@ -119,6 +119,16 @@ export declare class KoolbaseDatabase {
119
119
  */
120
120
  batch(operations: BatchOp[]): Promise<BatchResult[]>;
121
121
  get(recordId: string): Promise<KoolbaseRecord>;
122
+ /**
123
+ * The device's saved copy of a record: what this device last read, with the
124
+ * changes queued on it applied. Never contacts the server. Null when there is
125
+ * none: never read here, or deleted on this device.
126
+ *
127
+ * For showing something at once while get() asks the server -- saved-first,
128
+ * never saved-only. Kept data and revision, not createdAt or createdBy, which
129
+ * the saved copy does not hold (empty strings here).
130
+ */
131
+ getSaved(recordId: string): Promise<KoolbaseRecord | null>;
122
132
  /**
123
133
  * Writes that could not be applied, waiting for a decision.
124
134
  *
@@ -134,6 +144,28 @@ export declare class KoolbaseDatabase {
134
144
  * moment matters. Snapshot, not a live handle; per-user.
135
145
  */
136
146
  pendingWrites(): Promise<PendingWrite[]>;
147
+ /**
148
+ * The pending writes now, and again whenever they change -- here, in another
149
+ * tab, or because the signed-in user changed. For a sync badge that reflects
150
+ * reality. null while signed out: per-user state has no answer then, and a
151
+ * fake empty list is how a badge says "all synced" while a user's changes
152
+ * sit queued. Returns the unsubscribe.
153
+ */
154
+ watchPendingWrites(listener: (writes: PendingWrite[] | null) => void): () => void;
155
+ /** The unresolved conflicts now, and again whenever they change. null while signed out. */
156
+ watchConflicts(listener: (conflicts: KoolbaseConflict[] | null) => void): () => void;
157
+ /**
158
+ * The signed-in user changed: every watcher reads again, for the new user
159
+ * (or null, signed out). Koolbase.initialize wires it to auth changes.
160
+ */
161
+ sessionChanged(): void;
162
+ private readonly stateWatchers;
163
+ private offStateChange;
164
+ /**
165
+ * One watcher: reads at once, then on every change to the signed-in user's
166
+ * offline state. Reads can finish out of order; only the latest delivers.
167
+ */
168
+ private watchState;
137
169
  /**
138
170
  * Count and total over a whole collection, with the read rule applied
139
171
  * inside the query.
@@ -173,6 +205,15 @@ export declare class KoolbaseDatabase {
173
205
  */
174
206
  private requireUserId;
175
207
  private requireConflict;
208
+ /**
209
+ * Removes a decided conflict and points the writes held behind it at the
210
+ * state the record is now in -- in one transaction, so the conflict can
211
+ * never be gone while its held writes still describe the old world.
212
+ *
213
+ * Without a known state (a refused add, a delete) there is nothing to
214
+ * rebase onto: the conflict is removed and the held writes stay as they are.
215
+ */
216
+ private settleConflict;
176
217
  private dropConflict;
177
218
  /**
178
219
  * Issues the resolving write, conditional on the revision the refusal
@@ -7,9 +7,10 @@ const database_errors_js_1 = require("./database-errors.js");
7
7
  const errors_js_1 = require("./errors.js");
8
8
  const cache_store_js_1 = require("./cache-store.js");
9
9
  const offline_state_js_1 = require("./offline-state.js");
10
+ const cache_store_js_2 = require("./cache-store.js");
10
11
  const conflict_js_1 = require("./conflict.js");
11
12
  const pending_write_js_1 = require("./pending-write.js");
12
- const cache_store_js_2 = require("./cache-store.js");
13
+ const cache_store_js_3 = require("./cache-store.js");
13
14
  const sync_engine_js_1 = require("./sync-engine.js");
14
15
  const record_js_1 = require("./record.js");
15
16
  const database_errors_js_2 = require("./database-errors.js");
@@ -45,6 +46,8 @@ function batchOpToWire(op) {
45
46
  }
46
47
  class KoolbaseDatabase {
47
48
  constructor(config, getUserId, getToken, onSessionExpired) {
49
+ this.stateWatchers = new Set();
50
+ this.offStateChange = null;
48
51
  /**
49
52
  * Resolves by id, reloading the stored conflict first.
50
53
  *
@@ -65,12 +68,15 @@ class KoolbaseDatabase {
65
68
  resolveWithServer: async (id) => {
66
69
  const c = await this.requireConflict(id);
67
70
  // The server's version stands. Recorded as a decision by removing the
68
- // conflict, rather than the change quietly disappearing.
69
- await this.dropConflict(c.id);
71
+ // conflict, rather than the change quietly disappearing -- and what
72
+ // stands is what the writes held behind it now build on.
73
+ await this.settleConflict(c, c.server, c.serverRevision);
70
74
  },
71
75
  abandon: async (id) => {
72
76
  const c = await this.requireConflict(id);
73
- await this.dropConflict(c.id);
77
+ // Neither version claimed to win, so the record stands as the server
78
+ // has it: what anything still queued must build on.
79
+ await this.settleConflict(c, c.server, c.serverRevision);
74
80
  },
75
81
  };
76
82
  this.config = config;
@@ -204,12 +210,12 @@ class KoolbaseDatabase {
204
210
  // query's identity, so both policies share one cache entry. onRefresh is
205
211
  // who to tell, not what to read, so it stays out the same way.
206
212
  const { cache: cachePolicy, onRefresh, ...queryOptions } = options;
207
- const queryHash = (0, cache_store_js_2.hashQuery)(collection, queryOptions);
208
- const cached = cachePolicy === 'network-only' ? null : await (0, cache_store_js_2.getCached)(userId, collection, queryHash);
213
+ const queryHash = (0, cache_store_js_3.hashQuery)(collection, queryOptions);
214
+ const cached = cachePolicy === 'network-only' ? null : await (0, cache_store_js_3.getCached)(userId, collection, queryHash);
209
215
  if (cached) {
210
216
  this.runQuery(collection, queryOptions)
211
217
  .then(async (result) => {
212
- await (0, cache_store_js_2.setCached)(userId, collection, queryHash, result);
218
+ await (0, cache_store_js_3.setCached)(userId, collection, queryHash, result);
213
219
  // Told outside the catch below, which is for the network: an
214
220
  // exception from the caller's callback is the caller's to see.
215
221
  if (onRefresh)
@@ -221,7 +227,7 @@ class KoolbaseDatabase {
221
227
  return { ...cached, isFromCache: true };
222
228
  }
223
229
  const result = await this.runQuery(collection, queryOptions);
224
- await (0, cache_store_js_2.setCached)(userId, collection, queryHash, result);
230
+ await (0, cache_store_js_3.setCached)(userId, collection, queryHash, result);
225
231
  return { ...result, isFromCache: false };
226
232
  }
227
233
  /**
@@ -230,7 +236,7 @@ class KoolbaseDatabase {
230
236
  * a server, or another user. The SDK's own writes already do this.
231
237
  */
232
238
  async invalidate(collection) {
233
- await (0, cache_store_js_2.invalidateCache)(this.getUserId() ?? 'anonymous', collection);
239
+ await (0, cache_store_js_3.invalidateCache)(this.getUserId() ?? 'anonymous', collection);
234
240
  void (0, query_refresh_js_1.refreshCollectionQueries)(collection);
235
241
  }
236
242
  // ─── Insert (online-first with offline fallback) ───────────────────────────
@@ -254,7 +260,7 @@ class KoolbaseDatabase {
254
260
  // next query sees real data instead of a stale optimistic copy.
255
261
  const raw = await this.request('POST', '/v1/sdk/db/insert', { collection, data });
256
262
  const record = (0, record_js_1.recordFromWire)(raw);
257
- await (0, cache_store_js_2.invalidateCache)(userId, collection);
263
+ await (0, cache_store_js_3.invalidateCache)(userId, collection);
258
264
  void (0, query_refresh_js_1.refreshCollectionQueries)(collection);
259
265
  // The response carries a fresh revision, so caching it keeps the
260
266
  // baseline current for whatever edits this record next.
@@ -290,7 +296,7 @@ class KoolbaseDatabase {
290
296
  createdAt: new Date().toISOString(),
291
297
  updatedAt: new Date().toISOString(),
292
298
  };
293
- await (0, cache_store_js_2.optimisticallyInsert)(userId, collection, optimisticRecord);
299
+ await (0, cache_store_js_3.optimisticallyInsert)(userId, collection, optimisticRecord);
294
300
  // No baseline: an insert has no prior state, and the record does not
295
301
  // exist on the server yet, so there is nothing to be conditional against.
296
302
  // An offline edit to it composes against this queued write instead.
@@ -329,7 +335,7 @@ class KoolbaseDatabase {
329
335
  const record = (0, record_js_1.recordFromWire)(body);
330
336
  // Keep the cache fresh, same intent as insert's post-success invalidate.
331
337
  const userId = this.getUserId() ?? 'anonymous';
332
- await (0, cache_store_js_2.invalidateCache)(userId, collection);
338
+ await (0, cache_store_js_3.invalidateCache)(userId, collection);
333
339
  void (0, query_refresh_js_1.refreshCollectionQueries)(collection);
334
340
  await (0, cache_store_js_1.cacheRecord)(userId, collection, record.id, record.data, record.revision);
335
341
  return { record, created };
@@ -349,7 +355,7 @@ class KoolbaseDatabase {
349
355
  async deleteWhere(collection, filters) {
350
356
  const body = await this.request('POST', '/v1/sdk/db/delete-where', { collection, filters });
351
357
  const userId = this.getUserId() ?? 'anonymous';
352
- await (0, cache_store_js_2.invalidateCache)(userId, collection);
358
+ await (0, cache_store_js_3.invalidateCache)(userId, collection);
353
359
  void (0, query_refresh_js_1.refreshCollectionQueries)(collection);
354
360
  return body.deleted ?? 0;
355
361
  }
@@ -413,7 +419,7 @@ class KoolbaseDatabase {
413
419
  }
414
420
  }
415
421
  for (const col of touched) {
416
- await (0, cache_store_js_2.invalidateCache)(userId, col);
422
+ await (0, cache_store_js_3.invalidateCache)(userId, col);
417
423
  void (0, query_refresh_js_1.refreshCollectionQueries)(col);
418
424
  }
419
425
  return results;
@@ -430,6 +436,29 @@ class KoolbaseDatabase {
430
436
  }
431
437
  return record;
432
438
  }
439
+ /**
440
+ * The device's saved copy of a record: what this device last read, with the
441
+ * changes queued on it applied. Never contacts the server. Null when there is
442
+ * none: never read here, or deleted on this device.
443
+ *
444
+ * For showing something at once while get() asks the server -- saved-first,
445
+ * never saved-only. Kept data and revision, not createdAt or createdBy, which
446
+ * the saved copy does not hold (empty strings here).
447
+ */
448
+ async getSaved(recordId) {
449
+ const userId = this.getUserId() ?? 'anonymous';
450
+ const base = await this.resolveBaseline(userId, recordId);
451
+ if (!base)
452
+ return null;
453
+ return {
454
+ id: recordId,
455
+ collection: base.collection,
456
+ data: { ...base.baseline },
457
+ createdAt: '',
458
+ updatedAt: '',
459
+ revision: base.revision,
460
+ };
461
+ }
433
462
  // ─── Conflicts ──────────────────────────────────────────────────────────────
434
463
  /**
435
464
  * Writes that could not be applied, waiting for a decision.
@@ -450,6 +479,69 @@ class KoolbaseDatabase {
450
479
  const { pending } = await (0, offline_state_js_1.readOfflineState)(userId);
451
480
  return pending.map(pending_write_js_1.toPendingWrite);
452
481
  }
482
+ /**
483
+ * The pending writes now, and again whenever they change -- here, in another
484
+ * tab, or because the signed-in user changed. For a sync badge that reflects
485
+ * reality. null while signed out: per-user state has no answer then, and a
486
+ * fake empty list is how a badge says "all synced" while a user's changes
487
+ * sit queued. Returns the unsubscribe.
488
+ */
489
+ watchPendingWrites(listener) {
490
+ return this.watchState(async (userId) => {
491
+ if (!userId)
492
+ return () => listener(null);
493
+ const { pending } = await (0, offline_state_js_1.readOfflineState)(userId);
494
+ const writes = pending.map(pending_write_js_1.toPendingWrite);
495
+ return () => listener(writes);
496
+ });
497
+ }
498
+ /** The unresolved conflicts now, and again whenever they change. null while signed out. */
499
+ watchConflicts(listener) {
500
+ return this.watchState(async (userId) => {
501
+ if (!userId)
502
+ return () => listener(null);
503
+ const list = await this.conflicts();
504
+ return () => listener(list);
505
+ });
506
+ }
507
+ /**
508
+ * The signed-in user changed: every watcher reads again, for the new user
509
+ * (or null, signed out). Koolbase.initialize wires it to auth changes.
510
+ */
511
+ sessionChanged() {
512
+ for (const emit of [...this.stateWatchers])
513
+ emit();
514
+ }
515
+ /**
516
+ * One watcher: reads at once, then on every change to the signed-in user's
517
+ * offline state. Reads can finish out of order; only the latest delivers.
518
+ */
519
+ watchState(read) {
520
+ let latest = 0;
521
+ let active = true;
522
+ const emit = () => {
523
+ const mine = ++latest;
524
+ read(this.getUserId()).then((deliver) => { if (active && mine === latest)
525
+ deliver(); }, () => { });
526
+ };
527
+ this.stateWatchers.add(emit);
528
+ if (!this.offStateChange) {
529
+ this.offStateChange = (0, offline_state_js_1.onOfflineStateChange)((userId) => {
530
+ if (userId === this.getUserId())
531
+ for (const w of [...this.stateWatchers])
532
+ w();
533
+ });
534
+ }
535
+ emit();
536
+ return () => {
537
+ active = false;
538
+ this.stateWatchers.delete(emit);
539
+ if (this.stateWatchers.size === 0 && this.offStateChange) {
540
+ this.offStateChange();
541
+ this.offStateChange = null;
542
+ }
543
+ };
544
+ }
453
545
  /**
454
546
  * Count and total over a whole collection, with the read rule applied
455
547
  * inside the query.
@@ -540,6 +632,25 @@ class KoolbaseDatabase {
540
632
  }
541
633
  return found;
542
634
  }
635
+ /**
636
+ * Removes a decided conflict and points the writes held behind it at the
637
+ * state the record is now in -- in one transaction, so the conflict can
638
+ * never be gone while its held writes still describe the old world.
639
+ *
640
+ * Without a known state (a refused add, a delete) there is nothing to
641
+ * rebase onto: the conflict is removed and the held writes stay as they are.
642
+ */
643
+ async settleConflict(c, resolved, revision) {
644
+ if (!resolved || !c.recordId) {
645
+ await this.dropConflict(c.id);
646
+ return;
647
+ }
648
+ const userId = this.requireUserId('conflict resolution');
649
+ await (0, offline_state_js_1.mutateOfflineState)(userId, (s) => {
650
+ s.conflicts = s.conflicts.filter((x) => x.id !== c.id);
651
+ (0, offline_state_js_1.rebaseHeldWrites)(s, c.recordId, resolved, revision);
652
+ });
653
+ }
543
654
  async dropConflict(id) {
544
655
  const userId = this.requireUserId('conflict resolution');
545
656
  await (0, offline_state_js_1.mutateOfflineState)(userId, (s) => {
@@ -554,6 +665,8 @@ class KoolbaseDatabase {
554
665
  */
555
666
  async applyResolution(c, payload) {
556
667
  const rev = c.serverRevision;
668
+ // What the server now holds, from its answer: the state held writes build on.
669
+ let landed = null;
557
670
  try {
558
671
  if (c.operation === 'insert') {
559
672
  // Resolving a rejected insert IS the insert, retried — with amended
@@ -564,7 +677,7 @@ class KoolbaseDatabase {
564
677
  // original on retry rather than duplicating — the queue's own
565
678
  // lost-response discipline, extended to the one insert path that
566
679
  // lacked it.
567
- await this.request('POST', '/v1/sdk/db/insert', {
680
+ landed = await this.request('POST', '/v1/sdk/db/insert', {
568
681
  collection: c.collection,
569
682
  data: payload,
570
683
  idempotency_key: c.id,
@@ -575,7 +688,7 @@ class KoolbaseDatabase {
575
688
  await this.request('DELETE', `/v1/sdk/db/records/${c.recordId}${q}`);
576
689
  }
577
690
  else {
578
- await this.request('PATCH', `/v1/sdk/db/records/${c.recordId}`, { data: payload, ...(rev !== undefined ? { expected_revision: rev } : {}) });
691
+ landed = await this.request('PATCH', `/v1/sdk/db/records/${c.recordId}`, { data: payload, ...(rev !== undefined ? { expected_revision: rev } : {}) });
579
692
  }
580
693
  }
581
694
  catch (e) {
@@ -604,8 +717,9 @@ class KoolbaseDatabase {
604
717
  }
605
718
  throw e;
606
719
  }
607
- await this.dropConflict(c.id);
608
- await (0, cache_store_js_2.invalidateCache)(this.getUserId() ?? 'anonymous', c.collection);
720
+ const landedRevision = landed && typeof landed.$revision === 'number' ? landed.$revision : undefined;
721
+ await this.settleConflict(c, landed ?? undefined, landedRevision);
722
+ await (0, cache_store_js_3.invalidateCache)(this.getUserId() ?? 'anonymous', c.collection);
609
723
  void (0, query_refresh_js_1.refreshCollectionQueries)(c.collection);
610
724
  }
611
725
  // ─── Update (online-first with offline fallback) ───────────────────────────
@@ -642,8 +756,11 @@ class KoolbaseDatabase {
642
756
  for (const w of queued) {
643
757
  if (w.operation === 'insert')
644
758
  projected = { ...(w.data ?? {}) };
759
+ // An edit applies to the record as it was: the queued add's state, or
760
+ // -- first in a chain with no add -- the baseline the edit was made
761
+ // against. Starting from nothing dropped every field it did not touch.
645
762
  else if (w.operation === 'update')
646
- projected = { ...(projected ?? {}), ...(w.data ?? {}) };
763
+ projected = { ...(projected ?? w.baseline ?? {}), ...(w.data ?? {}) };
647
764
  else if (w.operation === 'delete')
648
765
  projected = null;
649
766
  }
@@ -712,6 +829,8 @@ class KoolbaseDatabase {
712
829
  });
713
830
  const merged = { ...base.baseline, ...data };
714
831
  await (0, cache_store_js_1.cacheRecord)(userId, base.collection, recordId, merged, base.revision);
832
+ // And in the saved lists, so they show what the user just changed.
833
+ await (0, cache_store_js_2.updateInCachedQueries)(userId, base.collection, recordId, data);
715
834
  // Optimistic: durable locally and queued to send, not yet accepted.
716
835
  return {
717
836
  id: recordId,
@@ -772,8 +891,9 @@ class KoolbaseDatabase {
772
891
  });
773
892
  // The queued write holds its own copy of the baseline, so removing the
774
893
  // cached record costs nothing and keeps local reads consistent with what
775
- // the user just did.
894
+ // the user just did -- saved lists included.
776
895
  await (0, cache_store_js_1.removeCachedRecord)(userId, recordId);
896
+ await (0, cache_store_js_2.removeFromCachedQueries)(userId, base.collection, recordId);
777
897
  }
778
898
  }
779
899
  // ─── Vectors ────────────────────────────────────────────────────────────────
@@ -17,6 +17,8 @@ export type { RecordState, RecordStatus } from './record-controller.js';
17
17
  export { KoolbaseFlags } from './flags.js';
18
18
  export { KoolbaseFunctions } from './functions.js';
19
19
  export { KoolbaseRealtime } from './realtime.js';
20
+ export { KoolbaseConnectivity } from './connectivity.js';
21
+ export type { ConnectivityState } from './connectivity.js';
20
22
  export { KoolbaseStorage } from './storage.js';
21
23
  export { KoolbaseAnalytics, disabledAnalytics } from './analytics.js';
22
24
  export { KoolbaseFiscal, KoolbaseFiscalError, decodeFiscalResponse, isFiscalized, isPending } from './fiscal.js';
@@ -27,4 +29,4 @@ export { getOrCreateDeviceId } from './device-id.js';
27
29
  export { koolbaseSdkVersion } from './device-metadata.js';
28
30
  export { RestoreResult } from './types.js';
29
31
  export type { AuthStateListener, FetchLike, KoolbaseAuthStorage, ResendVerificationResult } from './types.js';
30
- export { setPlatform, getPlatform, memoryPlatform, type PlatformAdapter, type PlatformStorage, type PlatformNetwork, type PlatformLifecycle, type PlatformLocks, type PlatformInfo, } from './platform.js';
32
+ export { setPlatform, getPlatform, memoryPlatform, type PlatformAdapter, type PlatformBroadcast, type PlatformStorage, type PlatformNetwork, type PlatformLifecycle, type PlatformLocks, type PlatformInfo, } from './platform.js';
package/dist/cjs/index.js CHANGED
@@ -19,7 +19,7 @@ var __exportStar = (this && this.__exportStar) || function(m, exports) {
19
19
  for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
20
20
  };
21
21
  Object.defineProperty(exports, "__esModule", { value: true });
22
- exports.memoryPlatform = exports.getPlatform = exports.setPlatform = exports.RestoreResult = exports.koolbaseSdkVersion = exports.getOrCreateDeviceId = exports.KoolbaseMessaging = exports.isPending = exports.isFiscalized = exports.decodeFiscalResponse = exports.KoolbaseFiscalError = exports.KoolbaseFiscal = exports.disabledAnalytics = exports.KoolbaseAnalytics = exports.KoolbaseStorage = exports.KoolbaseRealtime = exports.KoolbaseFunctions = exports.KoolbaseFlags = exports.initialRecordState = exports.KoolbaseRecordController = exports.initialCollectionState = exports.collectionQueryKey = exports.KoolbaseCollectionController = exports.KoolbaseDatabase = exports.KoolbaseAuth = void 0;
22
+ exports.memoryPlatform = exports.getPlatform = exports.setPlatform = exports.RestoreResult = exports.koolbaseSdkVersion = exports.getOrCreateDeviceId = exports.KoolbaseMessaging = exports.isPending = exports.isFiscalized = exports.decodeFiscalResponse = exports.KoolbaseFiscalError = exports.KoolbaseFiscal = exports.disabledAnalytics = exports.KoolbaseAnalytics = exports.KoolbaseStorage = exports.KoolbaseConnectivity = exports.KoolbaseRealtime = exports.KoolbaseFunctions = exports.KoolbaseFlags = exports.initialRecordState = exports.KoolbaseRecordController = exports.initialCollectionState = exports.collectionQueryKey = exports.KoolbaseCollectionController = exports.KoolbaseDatabase = exports.KoolbaseAuth = void 0;
23
23
  __exportStar(require("./types.js"), exports);
24
24
  __exportStar(require("./errors.js"), exports);
25
25
  __exportStar(require("./network.js"), exports);
@@ -47,6 +47,8 @@ var functions_js_1 = require("./functions.js");
47
47
  Object.defineProperty(exports, "KoolbaseFunctions", { enumerable: true, get: function () { return functions_js_1.KoolbaseFunctions; } });
48
48
  var realtime_js_1 = require("./realtime.js");
49
49
  Object.defineProperty(exports, "KoolbaseRealtime", { enumerable: true, get: function () { return realtime_js_1.KoolbaseRealtime; } });
50
+ var connectivity_js_1 = require("./connectivity.js");
51
+ Object.defineProperty(exports, "KoolbaseConnectivity", { enumerable: true, get: function () { return connectivity_js_1.KoolbaseConnectivity; } });
50
52
  var storage_js_1 = require("./storage.js");
51
53
  Object.defineProperty(exports, "KoolbaseStorage", { enumerable: true, get: function () { return storage_js_1.KoolbaseStorage; } });
52
54
  var analytics_js_1 = require("./analytics.js");
@@ -103,3 +103,23 @@ export declare function queueWrite(userId: string, write: Omit<QueuedWrite, 'ret
103
103
  * and nothing is written on a guess.
104
104
  */
105
105
  export declare function migrateLegacyQueue(userId: string): Promise<void>;
106
+ /**
107
+ * Points the writes still queued for a record at the state it is now in.
108
+ *
109
+ * When a conflict is decided, the writes held behind it carry baselines
110
+ * describing the state the conflicted write would have produced -- a state
111
+ * that no longer exists, and may never have. Released unchanged they would
112
+ * replay against a revision that has moved on, and each would conflict in
113
+ * turn: one disagreement multiplying into as many conflicts as the user had
114
+ * queued. The same rule as the Flutter SDK's rebaseAfterResolution.
115
+ *
116
+ * Refreshing the baseline is enough because every operation is an absolute
117
+ * assignment: a patch says what a field becomes, not what to do to it. If a
118
+ * relative operation is ever added (an increment, an append), the chain must
119
+ * be replayed through its projected states instead.
120
+ */
121
+ export declare function rebaseHeldWrites(state: OfflineState, recordId: string, resolved: Record<string, unknown>, revision: number | undefined): void;
122
+ type StateListener = (userId: string) => void;
123
+ /** Called with the user id whenever that user's offline state changes. */
124
+ export declare function onOfflineStateChange(listener: StateListener): () => void;
125
+ export {};