@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.
@@ -3,7 +3,8 @@ import { refreshCollectionQueries } from './query-refresh.js';
3
3
  import { KoolbaseRevisionMismatchError } from './database-errors.js';
4
4
  import { KoolbaseError, KoolbaseOfflineBaselineUnavailableError, KoolbaseUnauthenticatedError, } from './errors.js';
5
5
  import { cacheRecord, getCachedRecord, removeCachedRecord } from './cache-store.js';
6
- import { readOfflineState, mutateOfflineState, queueWrite, } from './offline-state.js';
6
+ import { readOfflineState, mutateOfflineState, queueWrite, rebaseHeldWrites, onOfflineStateChange, } from './offline-state.js';
7
+ import { removeFromCachedQueries, updateInCachedQueries } from './cache-store.js';
7
8
  import { KoolbaseConflict } from './conflict.js';
8
9
  import { toPendingWrite } from './pending-write.js';
9
10
  import { getCached, setCached, invalidateCache, optimisticallyInsert, hashQuery, } from './cache-store.js';
@@ -42,6 +43,8 @@ function batchOpToWire(op) {
42
43
  }
43
44
  export class KoolbaseDatabase {
44
45
  constructor(config, getUserId, getToken, onSessionExpired) {
46
+ this.stateWatchers = new Set();
47
+ this.offStateChange = null;
45
48
  /**
46
49
  * Resolves by id, reloading the stored conflict first.
47
50
  *
@@ -62,12 +65,15 @@ export class KoolbaseDatabase {
62
65
  resolveWithServer: async (id) => {
63
66
  const c = await this.requireConflict(id);
64
67
  // 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);
68
+ // conflict, rather than the change quietly disappearing -- and what
69
+ // stands is what the writes held behind it now build on.
70
+ await this.settleConflict(c, c.server, c.serverRevision);
67
71
  },
68
72
  abandon: async (id) => {
69
73
  const c = await this.requireConflict(id);
70
- await this.dropConflict(c.id);
74
+ // Neither version claimed to win, so the record stands as the server
75
+ // has it: what anything still queued must build on.
76
+ await this.settleConflict(c, c.server, c.serverRevision);
71
77
  },
72
78
  };
73
79
  this.config = config;
@@ -427,6 +433,29 @@ export class KoolbaseDatabase {
427
433
  }
428
434
  return record;
429
435
  }
436
+ /**
437
+ * The device's saved copy of a record: what this device last read, with the
438
+ * changes queued on it applied. Never contacts the server. Null when there is
439
+ * none: never read here, or deleted on this device.
440
+ *
441
+ * For showing something at once while get() asks the server -- saved-first,
442
+ * never saved-only. Kept data and revision, not createdAt or createdBy, which
443
+ * the saved copy does not hold (empty strings here).
444
+ */
445
+ async getSaved(recordId) {
446
+ const userId = this.getUserId() ?? 'anonymous';
447
+ const base = await this.resolveBaseline(userId, recordId);
448
+ if (!base)
449
+ return null;
450
+ return {
451
+ id: recordId,
452
+ collection: base.collection,
453
+ data: { ...base.baseline },
454
+ createdAt: '',
455
+ updatedAt: '',
456
+ revision: base.revision,
457
+ };
458
+ }
430
459
  // ─── Conflicts ──────────────────────────────────────────────────────────────
431
460
  /**
432
461
  * Writes that could not be applied, waiting for a decision.
@@ -447,6 +476,69 @@ export class KoolbaseDatabase {
447
476
  const { pending } = await readOfflineState(userId);
448
477
  return pending.map(toPendingWrite);
449
478
  }
479
+ /**
480
+ * The pending writes now, and again whenever they change -- here, in another
481
+ * tab, or because the signed-in user changed. For a sync badge that reflects
482
+ * reality. null while signed out: per-user state has no answer then, and a
483
+ * fake empty list is how a badge says "all synced" while a user's changes
484
+ * sit queued. Returns the unsubscribe.
485
+ */
486
+ watchPendingWrites(listener) {
487
+ return this.watchState(async (userId) => {
488
+ if (!userId)
489
+ return () => listener(null);
490
+ const { pending } = await readOfflineState(userId);
491
+ const writes = pending.map(toPendingWrite);
492
+ return () => listener(writes);
493
+ });
494
+ }
495
+ /** The unresolved conflicts now, and again whenever they change. null while signed out. */
496
+ watchConflicts(listener) {
497
+ return this.watchState(async (userId) => {
498
+ if (!userId)
499
+ return () => listener(null);
500
+ const list = await this.conflicts();
501
+ return () => listener(list);
502
+ });
503
+ }
504
+ /**
505
+ * The signed-in user changed: every watcher reads again, for the new user
506
+ * (or null, signed out). Koolbase.initialize wires it to auth changes.
507
+ */
508
+ sessionChanged() {
509
+ for (const emit of [...this.stateWatchers])
510
+ emit();
511
+ }
512
+ /**
513
+ * One watcher: reads at once, then on every change to the signed-in user's
514
+ * offline state. Reads can finish out of order; only the latest delivers.
515
+ */
516
+ watchState(read) {
517
+ let latest = 0;
518
+ let active = true;
519
+ const emit = () => {
520
+ const mine = ++latest;
521
+ read(this.getUserId()).then((deliver) => { if (active && mine === latest)
522
+ deliver(); }, () => { });
523
+ };
524
+ this.stateWatchers.add(emit);
525
+ if (!this.offStateChange) {
526
+ this.offStateChange = onOfflineStateChange((userId) => {
527
+ if (userId === this.getUserId())
528
+ for (const w of [...this.stateWatchers])
529
+ w();
530
+ });
531
+ }
532
+ emit();
533
+ return () => {
534
+ active = false;
535
+ this.stateWatchers.delete(emit);
536
+ if (this.stateWatchers.size === 0 && this.offStateChange) {
537
+ this.offStateChange();
538
+ this.offStateChange = null;
539
+ }
540
+ };
541
+ }
450
542
  /**
451
543
  * Count and total over a whole collection, with the read rule applied
452
544
  * inside the query.
@@ -537,6 +629,25 @@ export class KoolbaseDatabase {
537
629
  }
538
630
  return found;
539
631
  }
632
+ /**
633
+ * Removes a decided conflict and points the writes held behind it at the
634
+ * state the record is now in -- in one transaction, so the conflict can
635
+ * never be gone while its held writes still describe the old world.
636
+ *
637
+ * Without a known state (a refused add, a delete) there is nothing to
638
+ * rebase onto: the conflict is removed and the held writes stay as they are.
639
+ */
640
+ async settleConflict(c, resolved, revision) {
641
+ if (!resolved || !c.recordId) {
642
+ await this.dropConflict(c.id);
643
+ return;
644
+ }
645
+ const userId = this.requireUserId('conflict resolution');
646
+ await mutateOfflineState(userId, (s) => {
647
+ s.conflicts = s.conflicts.filter((x) => x.id !== c.id);
648
+ rebaseHeldWrites(s, c.recordId, resolved, revision);
649
+ });
650
+ }
540
651
  async dropConflict(id) {
541
652
  const userId = this.requireUserId('conflict resolution');
542
653
  await mutateOfflineState(userId, (s) => {
@@ -551,6 +662,8 @@ export class KoolbaseDatabase {
551
662
  */
552
663
  async applyResolution(c, payload) {
553
664
  const rev = c.serverRevision;
665
+ // What the server now holds, from its answer: the state held writes build on.
666
+ let landed = null;
554
667
  try {
555
668
  if (c.operation === 'insert') {
556
669
  // Resolving a rejected insert IS the insert, retried — with amended
@@ -561,7 +674,7 @@ export class KoolbaseDatabase {
561
674
  // original on retry rather than duplicating — the queue's own
562
675
  // lost-response discipline, extended to the one insert path that
563
676
  // lacked it.
564
- await this.request('POST', '/v1/sdk/db/insert', {
677
+ landed = await this.request('POST', '/v1/sdk/db/insert', {
565
678
  collection: c.collection,
566
679
  data: payload,
567
680
  idempotency_key: c.id,
@@ -572,7 +685,7 @@ export class KoolbaseDatabase {
572
685
  await this.request('DELETE', `/v1/sdk/db/records/${c.recordId}${q}`);
573
686
  }
574
687
  else {
575
- await this.request('PATCH', `/v1/sdk/db/records/${c.recordId}`, { data: payload, ...(rev !== undefined ? { expected_revision: rev } : {}) });
688
+ landed = await this.request('PATCH', `/v1/sdk/db/records/${c.recordId}`, { data: payload, ...(rev !== undefined ? { expected_revision: rev } : {}) });
576
689
  }
577
690
  }
578
691
  catch (e) {
@@ -601,7 +714,8 @@ export class KoolbaseDatabase {
601
714
  }
602
715
  throw e;
603
716
  }
604
- await this.dropConflict(c.id);
717
+ const landedRevision = landed && typeof landed.$revision === 'number' ? landed.$revision : undefined;
718
+ await this.settleConflict(c, landed ?? undefined, landedRevision);
605
719
  await invalidateCache(this.getUserId() ?? 'anonymous', c.collection);
606
720
  void refreshCollectionQueries(c.collection);
607
721
  }
@@ -639,8 +753,11 @@ export class KoolbaseDatabase {
639
753
  for (const w of queued) {
640
754
  if (w.operation === 'insert')
641
755
  projected = { ...(w.data ?? {}) };
756
+ // An edit applies to the record as it was: the queued add's state, or
757
+ // -- first in a chain with no add -- the baseline the edit was made
758
+ // against. Starting from nothing dropped every field it did not touch.
642
759
  else if (w.operation === 'update')
643
- projected = { ...(projected ?? {}), ...(w.data ?? {}) };
760
+ projected = { ...(projected ?? w.baseline ?? {}), ...(w.data ?? {}) };
644
761
  else if (w.operation === 'delete')
645
762
  projected = null;
646
763
  }
@@ -709,6 +826,8 @@ export class KoolbaseDatabase {
709
826
  });
710
827
  const merged = { ...base.baseline, ...data };
711
828
  await cacheRecord(userId, base.collection, recordId, merged, base.revision);
829
+ // And in the saved lists, so they show what the user just changed.
830
+ await updateInCachedQueries(userId, base.collection, recordId, data);
712
831
  // Optimistic: durable locally and queued to send, not yet accepted.
713
832
  return {
714
833
  id: recordId,
@@ -769,8 +888,9 @@ export class KoolbaseDatabase {
769
888
  });
770
889
  // The queued write holds its own copy of the baseline, so removing the
771
890
  // cached record costs nothing and keeps local reads consistent with what
772
- // the user just did.
891
+ // the user just did -- saved lists included.
773
892
  await removeCachedRecord(userId, recordId);
893
+ await removeFromCachedQueries(userId, base.collection, recordId);
774
894
  }
775
895
  }
776
896
  // ─── 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/esm/index.js CHANGED
@@ -20,6 +20,7 @@ export { KoolbaseRecordController, initialRecordState } from './record-controlle
20
20
  export { KoolbaseFlags } from './flags.js';
21
21
  export { KoolbaseFunctions } from './functions.js';
22
22
  export { KoolbaseRealtime } from './realtime.js';
23
+ export { KoolbaseConnectivity } from './connectivity.js';
23
24
  export { KoolbaseStorage } from './storage.js';
24
25
  export { KoolbaseAnalytics, disabledAnalytics } from './analytics.js';
25
26
  export { KoolbaseFiscal, KoolbaseFiscalError, decodeFiscalResponse, isFiscalized, isPending } from './fiscal.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 {};
@@ -130,6 +130,7 @@ export async function mutateOfflineState(userId, mutate) {
130
130
  }
131
131
  await getPlatform().storage.setItem(stateKey(userId), serialised);
132
132
  });
133
+ announce(userId);
133
134
  }
134
135
  /** Adds a write to the queue, under the user's lock. */
135
136
  export async function queueWrite(userId, write) {
@@ -202,3 +203,70 @@ export async function migrateLegacyQueue(userId) {
202
203
  });
203
204
  await getPlatform().storage.removeItem(legacyQueueKey(userId));
204
205
  }
206
+ /**
207
+ * Points the writes still queued for a record at the state it is now in.
208
+ *
209
+ * When a conflict is decided, the writes held behind it carry baselines
210
+ * describing the state the conflicted write would have produced -- a state
211
+ * that no longer exists, and may never have. Released unchanged they would
212
+ * replay against a revision that has moved on, and each would conflict in
213
+ * turn: one disagreement multiplying into as many conflicts as the user had
214
+ * queued. The same rule as the Flutter SDK's rebaseAfterResolution.
215
+ *
216
+ * Refreshing the baseline is enough because every operation is an absolute
217
+ * assignment: a patch says what a field becomes, not what to do to it. If a
218
+ * relative operation is ever added (an increment, an append), the chain must
219
+ * be replayed through its projected states instead.
220
+ */
221
+ export function rebaseHeldWrites(state, recordId, resolved, revision) {
222
+ let current = {};
223
+ for (const [k, v] of Object.entries(resolved)) {
224
+ if (!k.startsWith('$'))
225
+ current[k] = v;
226
+ }
227
+ for (const w of state.pending) {
228
+ if (w.recordId !== recordId)
229
+ continue;
230
+ w.baseline = { ...current };
231
+ w.baseRevision = revision;
232
+ // Each write is composed against the one before it.
233
+ if (w.operation === 'update')
234
+ current = { ...current, ...(w.data ?? {}) };
235
+ else if (w.operation === 'delete')
236
+ break;
237
+ }
238
+ }
239
+ // ─── Change notification ─────────────────────────────────────────────────────
240
+ const ANNOUNCE_PREFIX = 'koolbase:offline-state:';
241
+ function stateListeners() {
242
+ return shared('offlineStateListeners', () => new Set());
243
+ }
244
+ /**
245
+ * Tells whoever is watching that a user's offline state changed -- here, and
246
+ * in every other copy of the SDK sharing this storage (other tabs). A badge in
247
+ * one tab must move when another tab's sync drains the queue.
248
+ */
249
+ function announce(userId) {
250
+ for (const listener of [...stateListeners()]) {
251
+ try {
252
+ listener(userId);
253
+ }
254
+ catch { /* one broken listener must not stop the rest */ }
255
+ }
256
+ try {
257
+ getPlatform().broadcast?.post(ANNOUNCE_PREFIX + userId);
258
+ }
259
+ catch { /* best effort */ }
260
+ }
261
+ /** Called with the user id whenever that user's offline state changes. */
262
+ export function onOfflineStateChange(listener) {
263
+ stateListeners().add(listener);
264
+ const off = getPlatform().broadcast?.on((message) => {
265
+ if (message.startsWith(ANNOUNCE_PREFIX))
266
+ listener(message.slice(ANNOUNCE_PREFIX.length));
267
+ });
268
+ return () => {
269
+ stateListeners().delete(listener);
270
+ off?.();
271
+ };
272
+ }
@@ -12,6 +12,21 @@ export interface PlatformNetwork {
12
12
  * Koolbase is unreachable is handled the same as any failed request.
13
13
  */
14
14
  onChange(callback: (online: boolean) => void): () => void;
15
+ /**
16
+ * What the host believes right now: true online, false offline, null when
17
+ * it cannot tell. Optional: a host without it reports nothing until its
18
+ * first change, and connectivity stays 'unknown' until then.
19
+ */
20
+ current?(): Promise<boolean | null>;
21
+ }
22
+ /**
23
+ * A message to every other copy of the SDK sharing this storage -- other tabs
24
+ * of the same origin in a browser. Optional: one process (React Native) has no
25
+ * other copies to tell.
26
+ */
27
+ export interface PlatformBroadcast {
28
+ post(message: string): void;
29
+ on(callback: (message: string) => void): () => void;
15
30
  }
16
31
  export interface PlatformLifecycle {
17
32
  /** Subscribe to the app moving to the background. Used to flush analytics. */
@@ -54,6 +69,7 @@ export interface PlatformAdapter {
54
69
  lifecycle: PlatformLifecycle;
55
70
  info: PlatformInfo;
56
71
  locks: PlatformLocks;
72
+ broadcast?: PlatformBroadcast;
57
73
  /**
58
74
  * The host's best persistent store for the auth session, or null if it has
59
75
  * none worth the name. Used only when the app injects nothing through
@@ -3,6 +3,6 @@ export interface UseRecordResult extends RecordState {
3
3
  /** The record again, from the server. What is shown stays while it runs, and stays if it fails. */
4
4
  refresh: () => Promise<void>;
5
5
  }
6
- export declare function createUseRecord(getDb: () => Pick<KoolbaseDatabase, 'get'>): (collection: string, id: string | null | undefined, options?: {
6
+ export declare function createUseRecord(getDb: () => Pick<KoolbaseDatabase, 'get'> & Partial<Pick<KoolbaseDatabase, 'getSaved'>>): (collection: string, id: string | null | undefined, options?: {
7
7
  live?: boolean;
8
8
  }) => UseRecordResult;
@@ -8,9 +8,14 @@ export interface RecordState {
8
8
  readonly error: unknown;
9
9
  /** True while refresh() runs. */
10
10
  readonly refreshing: boolean;
11
+ /**
12
+ * True while the record shown is the device's saved copy and the server has
13
+ * not confirmed it on this load. Stays true if the server cannot be reached.
14
+ */
15
+ readonly isSaved: boolean;
11
16
  }
12
17
  export declare const initialRecordState: RecordState;
13
- type RecordReader = Pick<KoolbaseDatabase, 'get'>;
18
+ type RecordReader = Pick<KoolbaseDatabase, 'get'> & Partial<Pick<KoolbaseDatabase, 'getSaved'>>;
14
19
  export declare class KoolbaseRecordController {
15
20
  readonly collection: string;
16
21
  readonly id: string;
@@ -31,6 +36,8 @@ export declare class KoolbaseRecordController {
31
36
  subscribe(listener: () => void): () => void;
32
37
  /** The first load. Call once; refresh() for later loads. */
33
38
  load(): Promise<void>;
39
+ /** The device's saved copy, shown at once while the server is asked. */
40
+ private showSaved;
34
41
  /** The record again, from the server. What is shown stays while it runs, and stays if it fails. */
35
42
  refresh(): Promise<void>;
36
43
  /** Stops all updates. A result still in flight is dropped when it lands. */
@@ -14,8 +14,10 @@
14
14
  // or landing after dispose(), is dropped.
15
15
  // - live (the option): realtime events for THIS record only. An update
16
16
  // re-reads it silently, once per burst; a delete is notFound at once.
17
- // Not yet: an offline fallback from the record cache, which keeps data and
18
- // revision but not createdAt or createdBy.
17
+ // - saved-first, never saved-only: the device's saved copy (db.getSaved)
18
+ // shows at once with isSaved true while the server is asked. Success
19
+ // replaces it (isSaved false); failure keeps it (isSaved stays true); not
20
+ // found is notFound. No saved copy: the ordinary loading/error states.
19
21
  import { KoolbaseNotFoundError } from './database-errors.js';
20
22
  import { liveSource } from './live-source.js';
21
23
  export const initialRecordState = Object.freeze({
@@ -23,6 +25,7 @@ export const initialRecordState = Object.freeze({
23
25
  record: null,
24
26
  error: null,
25
27
  refreshing: false,
28
+ isSaved: false,
26
29
  });
27
30
  export class KoolbaseRecordController {
28
31
  constructor(db, collection, id, options = {}) {
@@ -58,8 +61,27 @@ export class KoolbaseRecordController {
58
61
  if (this.live && this.id) {
59
62
  this.unlive ?? (this.unlive = liveSource()?.subscribe(this.collection, (event) => this.onLive(event)) ?? null);
60
63
  }
64
+ await this.showSaved();
61
65
  await this.fetch();
62
66
  }
67
+ /** The device's saved copy, shown at once while the server is asked. */
68
+ async showSaved() {
69
+ if (!this.id || !this.db.getSaved)
70
+ return;
71
+ const gen = this.generation;
72
+ let saved = null;
73
+ try {
74
+ saved = await this.db.getSaved(this.id);
75
+ }
76
+ catch {
77
+ return;
78
+ }
79
+ if (!saved || this.isStale(gen) || this.state.status !== 'loading')
80
+ return;
81
+ if (saved.collection !== undefined && saved.collection !== this.collection)
82
+ return;
83
+ this.set({ status: 'loaded', record: saved, error: null, isSaved: true });
84
+ }
63
85
  /** The record again, from the server. What is shown stays while it runs, and stays if it fails. */
64
86
  async refresh() {
65
87
  if (this.disposed)
@@ -99,7 +121,7 @@ export class KoolbaseRecordController {
99
121
  this.liveTimer = null;
100
122
  }
101
123
  this.generation += 1;
102
- this.set({ status: 'notFound', record: null, error: null, refreshing: false });
124
+ this.set({ status: 'notFound', record: null, error: null, refreshing: false, isSaved: false });
103
125
  return;
104
126
  }
105
127
  if (this.liveTimer)
@@ -114,7 +136,7 @@ export class KoolbaseRecordController {
114
136
  async fetch() {
115
137
  const gen = ++this.generation;
116
138
  if (!this.id) {
117
- this.set({ status: 'notFound', record: null, error: null });
139
+ this.set({ status: 'notFound', record: null, error: null, isSaved: false });
118
140
  return gen;
119
141
  }
120
142
  try {
@@ -122,17 +144,17 @@ export class KoolbaseRecordController {
122
144
  if (this.isStale(gen))
123
145
  return gen;
124
146
  if (record.collection !== undefined && record.collection !== this.collection) {
125
- this.set({ status: 'notFound', record: null, error: null });
147
+ this.set({ status: 'notFound', record: null, error: null, isSaved: false });
126
148
  }
127
149
  else {
128
- this.set({ status: 'loaded', record, error: null });
150
+ this.set({ status: 'loaded', record, error: null, isSaved: false });
129
151
  }
130
152
  }
131
153
  catch (error) {
132
154
  if (this.isStale(gen))
133
155
  return gen;
134
156
  if (error instanceof KoolbaseNotFoundError) {
135
- this.set({ status: 'notFound', record: null, error: null });
157
+ this.set({ status: 'notFound', record: null, error: null, isSaved: false });
136
158
  }
137
159
  else if (this.state.record === null) {
138
160
  this.set({ status: 'error', error });
@@ -1,4 +1,5 @@
1
1
  interface Slot {
2
+ offlineStateListeners: Set<(userId: string) => void>;
2
3
  platform?: unknown;
3
4
  locks?: Map<string, Promise<unknown>>;
4
5
  }
@@ -2,7 +2,7 @@ import { koolbaseFetch } from './network.js';
2
2
  import { readOfflineState, mutateOfflineState, migrateLegacyQueue, flushLockName, } from './offline-state.js';
3
3
  import { KoolbaseUnauthenticatedError } from './errors.js';
4
4
  import { getPlatform } from './platform.js';
5
- import { invalidateCache, removeCachedRecord, } from './cache-store.js';
5
+ import { invalidateCache, removeCachedRecord, removeFromCachedQueries, } from './cache-store.js';
6
6
  /**
7
7
  * Internal signal that the server refused a write because the record moved.
8
8
  *
@@ -98,13 +98,21 @@ export class SyncEngine {
98
98
  // being online — would give the same input different outcomes. It clears
99
99
  // the old key when done, so later calls find nothing and return.
100
100
  await migrateLegacyQueue(userId);
101
- const { pending } = await readOfflineState(userId);
101
+ const { pending, conflicts } = await readOfflineState(userId);
102
102
  if (pending.length === 0)
103
103
  return;
104
104
  // Records whose chain stopped this pass. Writes queued after a conflicted
105
105
  // one were composed against the state it would have produced, so applying
106
106
  // them now would write against a state their baseline never described.
107
107
  const blocked = new Set();
108
+ // And records whose chain stopped in an EARLIER pass: a conflict waits
109
+ // for a decision across restarts, and so must the writes behind it.
110
+ // Seeded only from this pass's finds, the next pass replayed them
111
+ // against a state their baselines never described -- each becoming its
112
+ // own conflict. Deciding the conflict rebases and releases them.
113
+ for (const c of conflicts)
114
+ if (c.recordId)
115
+ blocked.add(c.recordId);
108
116
  for (const queued of pending) {
109
117
  if (queued.recordId && blocked.has(queued.recordId))
110
118
  continue;
@@ -199,10 +207,15 @@ export class SyncEngine {
199
207
  // — cached at enqueue for a record the server refused to create.
200
208
  // Left alone it is a phantom: it renders as saved, and an offline
201
209
  // edit against it queues a write to a record that does not exist.
202
- // Evict it, and invalidate the collection so cached queries stop
203
- // serving it. The conflict above keeps the user's data and the
204
- // server's verdict; the cache stops testifying to a fiction.
205
- // MUTATION: phantom eviction removed
210
+ // Evict it from the record cache and from every cached query that
211
+ // holds it -- precisely, not by dropping the collection's saved
212
+ // results, which a device still offline needs. The conflict above
213
+ // keeps the user's data and the server's verdict; the cache stops
214
+ // testifying to a fiction.
215
+ if (write.operation === 'insert' && write.recordId) {
216
+ await removeCachedRecord(userId, write.recordId);
217
+ await removeFromCachedQueries(userId, write.collection, write.recordId);
218
+ }
206
219
  if (write.recordId)
207
220
  blocked.add(write.recordId);
208
221
  continue;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@koolbase/core",
3
- "version": "12.13.0",
3
+ "version": "12.14.0",
4
4
  "description": "Koolbase SDK core \u2014 shared behaviour behind @koolbase/react-native and @koolbase/js. Install one of those, not this.",
5
5
  "main": "./dist/cjs/index.js",
6
6
  "types": "./dist/esm/index.d.ts",