@koolbase/core 12.12.0 → 12.13.1

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,12 @@ 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>;
@@ -13,6 +13,7 @@ exports.addToWriteQueue = addToWriteQueue;
13
13
  exports.removeFromWriteQueue = removeFromWriteQueue;
14
14
  exports.incrementWriteRetry = incrementWriteRetry;
15
15
  exports.optimisticallyInsert = optimisticallyInsert;
16
+ exports.removeFromCachedQueries = removeFromCachedQueries;
16
17
  const platform_js_1 = require("./platform.js");
17
18
  const CACHE_VERSION = 'v1';
18
19
  function cacheKey(userId, collection, queryHash) {
@@ -211,3 +212,35 @@ async function optimisticallyInsert(userId, collection, record) {
211
212
  // ignore
212
213
  }
213
214
  }
215
+ /**
216
+ * The reverse of optimisticallyInsert: takes one record out of every cached
217
+ * result of a collection.
218
+ *
219
+ * Precise on purpose. Dropping the collection's cached results instead would
220
+ * leave a device that is still offline with no saved lists to show at all,
221
+ * to get rid of one record.
222
+ */
223
+ async function removeFromCachedQueries(userId, collection, recordId) {
224
+ try {
225
+ const keys = await (0, platform_js_1.getPlatform)().storage.getAllKeys();
226
+ const prefix = `koolbase:${CACHE_VERSION}:${userId}:${collection}:`;
227
+ for (const key of keys.filter(k => k.startsWith(prefix))) {
228
+ const raw = await (0, platform_js_1.getPlatform)().storage.getItem(key);
229
+ if (!raw)
230
+ continue;
231
+ const cached = JSON.parse(raw);
232
+ if (!Array.isArray(cached.records))
233
+ continue;
234
+ const kept = cached.records.filter(r => r.id !== recordId);
235
+ const removed = cached.records.length - kept.length;
236
+ if (removed === 0)
237
+ continue;
238
+ cached.records = kept;
239
+ cached.total = Math.max(0, cached.total - removed);
240
+ await (0, platform_js_1.getPlatform)().storage.setItem(key, JSON.stringify(cached));
241
+ }
242
+ }
243
+ catch {
244
+ // ignore
245
+ }
246
+ }
@@ -11,8 +11,9 @@ export interface CollectionQuery {
11
11
  /**
12
12
  * Re-read page one, silently, when Koolbase realtime reports a record
13
13
  * created, updated or deleted in this collection (debounced: a burst is one
14
- * read). Realtime needs a signed-in user; until there is one, the list
15
- * behaves as a normal list and goes live once the session exists.
14
+ * read). Signed out, only a collection anyone can read (read rule
15
+ * "public") is live; any other behaves as a normal list until a user signs
16
+ * in, and goes live then.
16
17
  */
17
18
  live?: boolean;
18
19
  }
@@ -173,6 +173,15 @@ export declare class KoolbaseDatabase {
173
173
  */
174
174
  private requireUserId;
175
175
  private requireConflict;
176
+ /**
177
+ * Removes a decided conflict and points the writes held behind it at the
178
+ * state the record is now in -- in one transaction, so the conflict can
179
+ * never be gone while its held writes still describe the old world.
180
+ *
181
+ * Without a known state (a refused add, a delete) there is nothing to
182
+ * rebase onto: the conflict is removed and the held writes stay as they are.
183
+ */
184
+ private settleConflict;
176
185
  private dropConflict;
177
186
  /**
178
187
  * Issues the resolving write, conditional on the revision the refusal
@@ -65,12 +65,15 @@ class KoolbaseDatabase {
65
65
  resolveWithServer: async (id) => {
66
66
  const c = await this.requireConflict(id);
67
67
  // 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);
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);
70
71
  },
71
72
  abandon: async (id) => {
72
73
  const c = await this.requireConflict(id);
73
- 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);
74
77
  },
75
78
  };
76
79
  this.config = config;
@@ -540,6 +543,25 @@ class KoolbaseDatabase {
540
543
  }
541
544
  return found;
542
545
  }
546
+ /**
547
+ * Removes a decided conflict and points the writes held behind it at the
548
+ * state the record is now in -- in one transaction, so the conflict can
549
+ * never be gone while its held writes still describe the old world.
550
+ *
551
+ * Without a known state (a refused add, a delete) there is nothing to
552
+ * rebase onto: the conflict is removed and the held writes stay as they are.
553
+ */
554
+ async settleConflict(c, resolved, revision) {
555
+ if (!resolved || !c.recordId) {
556
+ await this.dropConflict(c.id);
557
+ return;
558
+ }
559
+ const userId = this.requireUserId('conflict resolution');
560
+ await (0, offline_state_js_1.mutateOfflineState)(userId, (s) => {
561
+ s.conflicts = s.conflicts.filter((x) => x.id !== c.id);
562
+ (0, offline_state_js_1.rebaseHeldWrites)(s, c.recordId, resolved, revision);
563
+ });
564
+ }
543
565
  async dropConflict(id) {
544
566
  const userId = this.requireUserId('conflict resolution');
545
567
  await (0, offline_state_js_1.mutateOfflineState)(userId, (s) => {
@@ -554,6 +576,8 @@ class KoolbaseDatabase {
554
576
  */
555
577
  async applyResolution(c, payload) {
556
578
  const rev = c.serverRevision;
579
+ // What the server now holds, from its answer: the state held writes build on.
580
+ let landed = null;
557
581
  try {
558
582
  if (c.operation === 'insert') {
559
583
  // Resolving a rejected insert IS the insert, retried — with amended
@@ -564,7 +588,7 @@ class KoolbaseDatabase {
564
588
  // original on retry rather than duplicating — the queue's own
565
589
  // lost-response discipline, extended to the one insert path that
566
590
  // lacked it.
567
- await this.request('POST', '/v1/sdk/db/insert', {
591
+ landed = await this.request('POST', '/v1/sdk/db/insert', {
568
592
  collection: c.collection,
569
593
  data: payload,
570
594
  idempotency_key: c.id,
@@ -575,7 +599,7 @@ class KoolbaseDatabase {
575
599
  await this.request('DELETE', `/v1/sdk/db/records/${c.recordId}${q}`);
576
600
  }
577
601
  else {
578
- await this.request('PATCH', `/v1/sdk/db/records/${c.recordId}`, { data: payload, ...(rev !== undefined ? { expected_revision: rev } : {}) });
602
+ landed = await this.request('PATCH', `/v1/sdk/db/records/${c.recordId}`, { data: payload, ...(rev !== undefined ? { expected_revision: rev } : {}) });
579
603
  }
580
604
  }
581
605
  catch (e) {
@@ -604,7 +628,8 @@ class KoolbaseDatabase {
604
628
  }
605
629
  throw e;
606
630
  }
607
- await this.dropConflict(c.id);
631
+ const landedRevision = landed && typeof landed.$revision === 'number' ? landed.$revision : undefined;
632
+ await this.settleConflict(c, landed ?? undefined, landedRevision);
608
633
  await (0, cache_store_js_2.invalidateCache)(this.getUserId() ?? 'anonymous', c.collection);
609
634
  void (0, query_refresh_js_1.refreshCollectionQueries)(c.collection);
610
635
  }
@@ -103,3 +103,19 @@ 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;
@@ -8,6 +8,7 @@ exports.readOfflineState = readOfflineState;
8
8
  exports.mutateOfflineState = mutateOfflineState;
9
9
  exports.queueWrite = queueWrite;
10
10
  exports.migrateLegacyQueue = migrateLegacyQueue;
11
+ exports.rebaseHeldWrites = rebaseHeldWrites;
11
12
  const platform_js_1 = require("./platform.js");
12
13
  const shared_js_1 = require("./shared.js");
13
14
  /**
@@ -213,3 +214,36 @@ async function migrateLegacyQueue(userId) {
213
214
  });
214
215
  await (0, platform_js_1.getPlatform)().storage.removeItem(legacyQueueKey(userId));
215
216
  }
217
+ /**
218
+ * Points the writes still queued for a record at the state it is now in.
219
+ *
220
+ * When a conflict is decided, the writes held behind it carry baselines
221
+ * describing the state the conflicted write would have produced -- a state
222
+ * that no longer exists, and may never have. Released unchanged they would
223
+ * replay against a revision that has moved on, and each would conflict in
224
+ * turn: one disagreement multiplying into as many conflicts as the user had
225
+ * queued. The same rule as the Flutter SDK's rebaseAfterResolution.
226
+ *
227
+ * Refreshing the baseline is enough because every operation is an absolute
228
+ * assignment: a patch says what a field becomes, not what to do to it. If a
229
+ * relative operation is ever added (an increment, an append), the chain must
230
+ * be replayed through its projected states instead.
231
+ */
232
+ function rebaseHeldWrites(state, recordId, resolved, revision) {
233
+ let current = {};
234
+ for (const [k, v] of Object.entries(resolved)) {
235
+ if (!k.startsWith('$'))
236
+ current[k] = v;
237
+ }
238
+ for (const w of state.pending) {
239
+ if (w.recordId !== recordId)
240
+ continue;
241
+ w.baseline = { ...current };
242
+ w.baseRevision = revision;
243
+ // Each write is composed against the one before it.
244
+ if (w.operation === 'update')
245
+ current = { ...current, ...(w.data ?? {}) };
246
+ else if (w.operation === 'delete')
247
+ break;
248
+ }
249
+ }
@@ -9,6 +9,9 @@ export declare class KoolbaseRealtime {
9
9
  private reconnectAttempts;
10
10
  private reconnectTimer;
11
11
  private connecting;
12
+ private anonymous;
13
+ private connectedAs;
14
+ private generation;
12
15
  private idleTimer;
13
16
  /** How long an unused connection stays open, in ms: a screen that comes
14
17
  * straight back (navigating back, React's development double start)
@@ -30,6 +33,9 @@ export declare class KoolbaseRealtime {
30
33
  private connect;
31
34
  private sendSubscribe;
32
35
  private sendUnsubscribe;
36
+ /** A signed-out visitor's message leaves project_id out: it has none to
37
+ * give, and the server uses the public key's project. */
38
+ private send;
33
39
  /**
34
40
  * Reconnects with backoff, rather than every three seconds forever.
35
41
  *
@@ -51,6 +57,16 @@ export declare class KoolbaseRealtime {
51
57
  * it: reconnecting needs a subscriber.
52
58
  */
53
59
  private scheduleIdleClose;
60
+ /**
61
+ * The signed-in user changed: signed in, signed out, or another user. The
62
+ * open connection was made for the previous session -- signed out it sees
63
+ * only public collections, signed in it carries that user's access -- so it
64
+ * is replaced by one for the new session, resubscribing every collection.
65
+ * The same user again (a token refresh) changes nothing, and with nothing
66
+ * open there is nothing to do: the next subscribe connects as whoever is
67
+ * signed in then.
68
+ */
69
+ sessionChanged(): void;
54
70
  disconnect(): void;
55
71
  }
56
72
  export {};
@@ -41,6 +41,15 @@ class KoolbaseRealtime {
41
41
  this.reconnectAttempts = 0;
42
42
  this.reconnectTimer = null;
43
43
  this.connecting = false;
44
+ // The open connection is a signed-out visitor's (the public key): its
45
+ // messages leave project_id out.
46
+ this.anonymous = false;
47
+ // Whose session the open connection was made for: the user id, or null
48
+ // when signed out. sessionChanged compares against it.
49
+ this.connectedAs = null;
50
+ // Bumped by sessionChanged: a connect still waiting for its token gives up
51
+ // rather than open a connection for the old session.
52
+ this.generation = 0;
44
53
  // Pending close of an unused connection (scheduleIdleClose); null while
45
54
  // anyone is subscribed.
46
55
  this.idleTimer = null;
@@ -101,28 +110,49 @@ class KoolbaseRealtime {
101
110
  // double start -- found nobody connecting and opened its own socket; only
102
111
  // the last was remembered, and the others stayed open for good.
103
112
  this.connecting = true;
113
+ const generation = this.generation;
104
114
  let token;
105
115
  try {
106
116
  token = await this.getToken();
107
117
  }
108
118
  catch {
109
- token = null;
119
+ // A session that could not be refreshed is still a session: try again
120
+ // later, never as a signed-out visitor.
121
+ if (generation !== this.generation)
122
+ return;
123
+ this.connecting = false;
124
+ this.scheduleReconnect();
125
+ return;
110
126
  }
127
+ // The session changed while the token was on its way: sessionChanged
128
+ // has started the connection for the new one.
129
+ if (generation !== this.generation)
130
+ return;
111
131
  if (this.listeners.size === 0) {
112
132
  // Everyone left while the token was on its way: nothing to connect for.
113
133
  this.connecting = false;
114
134
  return;
115
135
  }
116
- if (!token) {
136
+ const userId = this.getUserId?.() ?? null;
137
+ if (!token && userId) {
138
+ // Signed in, but no usable token right now: try again later rather
139
+ // than drop to public-only.
117
140
  this.connecting = false;
118
- this.scheduleReconnect(); // sign-in may be in flight
141
+ this.scheduleReconnect();
119
142
  return;
120
143
  }
121
- this.projectId = projectIdFromToken(token);
144
+ // Signed out: the project's public key, and only collections anyone can
145
+ // read. The server pins the connection to the key's project.
146
+ this.anonymous = !token;
147
+ this.connectedAs = token ? userId : null;
148
+ this.projectId = token ? projectIdFromToken(token) : null;
149
+ const credential = token
150
+ ? `token=${encodeURIComponent(token)}`
151
+ : `public_key=${encodeURIComponent(this.config.publicKey)}`;
122
152
  const wsUrl = this.config.baseUrl.replace('https://', 'wss://').replace('http://', 'ws://');
123
153
  let ws;
124
154
  try {
125
- ws = new WebSocket(`${wsUrl}/v1/realtime/ws?token=${encodeURIComponent(token)}`);
155
+ ws = new WebSocket(`${wsUrl}/v1/realtime/ws?${credential}`);
126
156
  }
127
157
  catch {
128
158
  this.connecting = false;
@@ -186,14 +216,22 @@ class KoolbaseRealtime {
186
216
  ws.onerror = () => { };
187
217
  }
188
218
  sendSubscribe(collection) {
189
- if (!this.projectId || !this.ws || this.ws.readyState !== WebSocket.OPEN)
190
- return;
191
- this.ws.send(JSON.stringify({ action: 'subscribe', project_id: this.projectId, collection }));
219
+ this.send('subscribe', collection);
192
220
  }
193
221
  sendUnsubscribe(collection) {
194
- if (!this.projectId || !this.ws || this.ws.readyState !== WebSocket.OPEN)
222
+ this.send('unsubscribe', collection);
223
+ }
224
+ /** A signed-out visitor's message leaves project_id out: it has none to
225
+ * give, and the server uses the public key's project. */
226
+ send(action, collection) {
227
+ if (!this.ws || this.ws.readyState !== WebSocket.OPEN)
195
228
  return;
196
- this.ws.send(JSON.stringify({ action: 'unsubscribe', project_id: this.projectId, collection }));
229
+ if (this.anonymous) {
230
+ this.ws.send(JSON.stringify({ action, collection }));
231
+ }
232
+ else if (this.projectId) {
233
+ this.ws.send(JSON.stringify({ action, project_id: this.projectId, collection }));
234
+ }
197
235
  }
198
236
  /**
199
237
  * Reconnects with backoff, rather than every three seconds forever.
@@ -252,6 +290,42 @@ class KoolbaseRealtime {
252
290
  }, KoolbaseRealtime.idleGraceMs);
253
291
  this.idleTimer.unref?.();
254
292
  }
293
+ /**
294
+ * The signed-in user changed: signed in, signed out, or another user. The
295
+ * open connection was made for the previous session -- signed out it sees
296
+ * only public collections, signed in it carries that user's access -- so it
297
+ * is replaced by one for the new session, resubscribing every collection.
298
+ * The same user again (a token refresh) changes nothing, and with nothing
299
+ * open there is nothing to do: the next subscribe connects as whoever is
300
+ * signed in then.
301
+ */
302
+ sessionChanged() {
303
+ if (!this.ws && !this.connecting && !this.reconnectTimer)
304
+ return;
305
+ if (this.ws && !this.connecting && (this.getUserId?.() ?? null) === this.connectedAs)
306
+ return;
307
+ this.generation += 1;
308
+ if (this.reconnectTimer) {
309
+ clearTimeout(this.reconnectTimer);
310
+ this.reconnectTimer = null;
311
+ }
312
+ const ws = this.ws;
313
+ this.ws = null;
314
+ this.connecting = false;
315
+ this.anonymous = false;
316
+ this.projectId = null;
317
+ this.reconnectAttempts = 0;
318
+ if (ws) {
319
+ // Detached first: this close is ours, not a dropped connection.
320
+ ws.onopen = null;
321
+ ws.onmessage = null;
322
+ ws.onclose = null;
323
+ ws.onerror = null;
324
+ ws.close();
325
+ }
326
+ if (this.listeners.size > 0)
327
+ void this.connect();
328
+ }
255
329
  disconnect() {
256
330
  if (this.idleTimer) {
257
331
  clearTimeout(this.idleTimer);
@@ -101,13 +101,21 @@ class SyncEngine {
101
101
  // being online — would give the same input different outcomes. It clears
102
102
  // the old key when done, so later calls find nothing and return.
103
103
  await (0, offline_state_js_1.migrateLegacyQueue)(userId);
104
- const { pending } = await (0, offline_state_js_1.readOfflineState)(userId);
104
+ const { pending, conflicts } = await (0, offline_state_js_1.readOfflineState)(userId);
105
105
  if (pending.length === 0)
106
106
  return;
107
107
  // Records whose chain stopped this pass. Writes queued after a conflicted
108
108
  // one were composed against the state it would have produced, so applying
109
109
  // them now would write against a state their baseline never described.
110
110
  const blocked = new Set();
111
+ // And records whose chain stopped in an EARLIER pass: a conflict waits
112
+ // for a decision across restarts, and so must the writes behind it.
113
+ // Seeded only from this pass's finds, the next pass replayed them
114
+ // against a state their baselines never described -- each becoming its
115
+ // own conflict. Deciding the conflict rebases and releases them.
116
+ for (const c of conflicts)
117
+ if (c.recordId)
118
+ blocked.add(c.recordId);
111
119
  for (const queued of pending) {
112
120
  if (queued.recordId && blocked.has(queued.recordId))
113
121
  continue;
@@ -202,10 +210,15 @@ class SyncEngine {
202
210
  // — cached at enqueue for a record the server refused to create.
203
211
  // Left alone it is a phantom: it renders as saved, and an offline
204
212
  // edit against it queues a write to a record that does not exist.
205
- // Evict it, and invalidate the collection so cached queries stop
206
- // serving it. The conflict above keeps the user's data and the
207
- // server's verdict; the cache stops testifying to a fiction.
208
- // MUTATION: phantom eviction removed
213
+ // Evict it from the record cache and from every cached query that
214
+ // holds it -- precisely, not by dropping the collection's saved
215
+ // results, which a device still offline needs. The conflict above
216
+ // keeps the user's data and the server's verdict; the cache stops
217
+ // testifying to a fiction.
218
+ if (write.operation === 'insert' && write.recordId) {
219
+ await (0, cache_store_js_1.removeCachedRecord)(userId, write.recordId);
220
+ await (0, cache_store_js_1.removeFromCachedQueries)(userId, write.collection, write.recordId);
221
+ }
209
222
  if (write.recordId)
210
223
  blocked.add(write.recordId);
211
224
  continue;
@@ -58,3 +58,12 @@ 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>;
@@ -196,3 +196,35 @@ export async function optimisticallyInsert(userId, collection, record) {
196
196
  // ignore
197
197
  }
198
198
  }
199
+ /**
200
+ * The reverse of optimisticallyInsert: takes one record out of every cached
201
+ * result of a collection.
202
+ *
203
+ * Precise on purpose. Dropping the collection's cached results instead would
204
+ * leave a device that is still offline with no saved lists to show at all,
205
+ * to get rid of one record.
206
+ */
207
+ export async function removeFromCachedQueries(userId, collection, recordId) {
208
+ try {
209
+ const keys = await getPlatform().storage.getAllKeys();
210
+ const prefix = `koolbase:${CACHE_VERSION}:${userId}:${collection}:`;
211
+ for (const key of keys.filter(k => k.startsWith(prefix))) {
212
+ const raw = await getPlatform().storage.getItem(key);
213
+ if (!raw)
214
+ continue;
215
+ const cached = JSON.parse(raw);
216
+ if (!Array.isArray(cached.records))
217
+ continue;
218
+ const kept = cached.records.filter(r => r.id !== recordId);
219
+ const removed = cached.records.length - kept.length;
220
+ if (removed === 0)
221
+ continue;
222
+ cached.records = kept;
223
+ cached.total = Math.max(0, cached.total - removed);
224
+ await getPlatform().storage.setItem(key, JSON.stringify(cached));
225
+ }
226
+ }
227
+ catch {
228
+ // ignore
229
+ }
230
+ }
@@ -11,8 +11,9 @@ export interface CollectionQuery {
11
11
  /**
12
12
  * Re-read page one, silently, when Koolbase realtime reports a record
13
13
  * created, updated or deleted in this collection (debounced: a burst is one
14
- * read). Realtime needs a signed-in user; until there is one, the list
15
- * behaves as a normal list and goes live once the session exists.
14
+ * read). Signed out, only a collection anyone can read (read rule
15
+ * "public") is live; any other behaves as a normal list until a user signs
16
+ * in, and goes live then.
16
17
  */
17
18
  live?: boolean;
18
19
  }
@@ -173,6 +173,15 @@ export declare class KoolbaseDatabase {
173
173
  */
174
174
  private requireUserId;
175
175
  private requireConflict;
176
+ /**
177
+ * Removes a decided conflict and points the writes held behind it at the
178
+ * state the record is now in -- in one transaction, so the conflict can
179
+ * never be gone while its held writes still describe the old world.
180
+ *
181
+ * Without a known state (a refused add, a delete) there is nothing to
182
+ * rebase onto: the conflict is removed and the held writes stay as they are.
183
+ */
184
+ private settleConflict;
176
185
  private dropConflict;
177
186
  /**
178
187
  * Issues the resolving write, conditional on the revision the refusal
@@ -3,7 +3,7 @@ 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, } from './offline-state.js';
7
7
  import { KoolbaseConflict } from './conflict.js';
8
8
  import { toPendingWrite } from './pending-write.js';
9
9
  import { getCached, setCached, invalidateCache, optimisticallyInsert, hashQuery, } from './cache-store.js';
@@ -62,12 +62,15 @@ export class KoolbaseDatabase {
62
62
  resolveWithServer: async (id) => {
63
63
  const c = await this.requireConflict(id);
64
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);
65
+ // conflict, rather than the change quietly disappearing -- and what
66
+ // stands is what the writes held behind it now build on.
67
+ await this.settleConflict(c, c.server, c.serverRevision);
67
68
  },
68
69
  abandon: async (id) => {
69
70
  const c = await this.requireConflict(id);
70
- await this.dropConflict(c.id);
71
+ // Neither version claimed to win, so the record stands as the server
72
+ // has it: what anything still queued must build on.
73
+ await this.settleConflict(c, c.server, c.serverRevision);
71
74
  },
72
75
  };
73
76
  this.config = config;
@@ -537,6 +540,25 @@ export class KoolbaseDatabase {
537
540
  }
538
541
  return found;
539
542
  }
543
+ /**
544
+ * Removes a decided conflict and points the writes held behind it at the
545
+ * state the record is now in -- in one transaction, so the conflict can
546
+ * never be gone while its held writes still describe the old world.
547
+ *
548
+ * Without a known state (a refused add, a delete) there is nothing to
549
+ * rebase onto: the conflict is removed and the held writes stay as they are.
550
+ */
551
+ async settleConflict(c, resolved, revision) {
552
+ if (!resolved || !c.recordId) {
553
+ await this.dropConflict(c.id);
554
+ return;
555
+ }
556
+ const userId = this.requireUserId('conflict resolution');
557
+ await mutateOfflineState(userId, (s) => {
558
+ s.conflicts = s.conflicts.filter((x) => x.id !== c.id);
559
+ rebaseHeldWrites(s, c.recordId, resolved, revision);
560
+ });
561
+ }
540
562
  async dropConflict(id) {
541
563
  const userId = this.requireUserId('conflict resolution');
542
564
  await mutateOfflineState(userId, (s) => {
@@ -551,6 +573,8 @@ export class KoolbaseDatabase {
551
573
  */
552
574
  async applyResolution(c, payload) {
553
575
  const rev = c.serverRevision;
576
+ // What the server now holds, from its answer: the state held writes build on.
577
+ let landed = null;
554
578
  try {
555
579
  if (c.operation === 'insert') {
556
580
  // Resolving a rejected insert IS the insert, retried — with amended
@@ -561,7 +585,7 @@ export class KoolbaseDatabase {
561
585
  // original on retry rather than duplicating — the queue's own
562
586
  // lost-response discipline, extended to the one insert path that
563
587
  // lacked it.
564
- await this.request('POST', '/v1/sdk/db/insert', {
588
+ landed = await this.request('POST', '/v1/sdk/db/insert', {
565
589
  collection: c.collection,
566
590
  data: payload,
567
591
  idempotency_key: c.id,
@@ -572,7 +596,7 @@ export class KoolbaseDatabase {
572
596
  await this.request('DELETE', `/v1/sdk/db/records/${c.recordId}${q}`);
573
597
  }
574
598
  else {
575
- await this.request('PATCH', `/v1/sdk/db/records/${c.recordId}`, { data: payload, ...(rev !== undefined ? { expected_revision: rev } : {}) });
599
+ landed = await this.request('PATCH', `/v1/sdk/db/records/${c.recordId}`, { data: payload, ...(rev !== undefined ? { expected_revision: rev } : {}) });
576
600
  }
577
601
  }
578
602
  catch (e) {
@@ -601,7 +625,8 @@ export class KoolbaseDatabase {
601
625
  }
602
626
  throw e;
603
627
  }
604
- await this.dropConflict(c.id);
628
+ const landedRevision = landed && typeof landed.$revision === 'number' ? landed.$revision : undefined;
629
+ await this.settleConflict(c, landed ?? undefined, landedRevision);
605
630
  await invalidateCache(this.getUserId() ?? 'anonymous', c.collection);
606
631
  void refreshCollectionQueries(c.collection);
607
632
  }
@@ -103,3 +103,19 @@ 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;
@@ -202,3 +202,36 @@ export async function migrateLegacyQueue(userId) {
202
202
  });
203
203
  await getPlatform().storage.removeItem(legacyQueueKey(userId));
204
204
  }
205
+ /**
206
+ * Points the writes still queued for a record at the state it is now in.
207
+ *
208
+ * When a conflict is decided, the writes held behind it carry baselines
209
+ * describing the state the conflicted write would have produced -- a state
210
+ * that no longer exists, and may never have. Released unchanged they would
211
+ * replay against a revision that has moved on, and each would conflict in
212
+ * turn: one disagreement multiplying into as many conflicts as the user had
213
+ * queued. The same rule as the Flutter SDK's rebaseAfterResolution.
214
+ *
215
+ * Refreshing the baseline is enough because every operation is an absolute
216
+ * assignment: a patch says what a field becomes, not what to do to it. If a
217
+ * relative operation is ever added (an increment, an append), the chain must
218
+ * be replayed through its projected states instead.
219
+ */
220
+ export function rebaseHeldWrites(state, recordId, resolved, revision) {
221
+ let current = {};
222
+ for (const [k, v] of Object.entries(resolved)) {
223
+ if (!k.startsWith('$'))
224
+ current[k] = v;
225
+ }
226
+ for (const w of state.pending) {
227
+ if (w.recordId !== recordId)
228
+ continue;
229
+ w.baseline = { ...current };
230
+ w.baseRevision = revision;
231
+ // Each write is composed against the one before it.
232
+ if (w.operation === 'update')
233
+ current = { ...current, ...(w.data ?? {}) };
234
+ else if (w.operation === 'delete')
235
+ break;
236
+ }
237
+ }
@@ -9,6 +9,9 @@ export declare class KoolbaseRealtime {
9
9
  private reconnectAttempts;
10
10
  private reconnectTimer;
11
11
  private connecting;
12
+ private anonymous;
13
+ private connectedAs;
14
+ private generation;
12
15
  private idleTimer;
13
16
  /** How long an unused connection stays open, in ms: a screen that comes
14
17
  * straight back (navigating back, React's development double start)
@@ -30,6 +33,9 @@ export declare class KoolbaseRealtime {
30
33
  private connect;
31
34
  private sendSubscribe;
32
35
  private sendUnsubscribe;
36
+ /** A signed-out visitor's message leaves project_id out: it has none to
37
+ * give, and the server uses the public key's project. */
38
+ private send;
33
39
  /**
34
40
  * Reconnects with backoff, rather than every three seconds forever.
35
41
  *
@@ -51,6 +57,16 @@ export declare class KoolbaseRealtime {
51
57
  * it: reconnecting needs a subscriber.
52
58
  */
53
59
  private scheduleIdleClose;
60
+ /**
61
+ * The signed-in user changed: signed in, signed out, or another user. The
62
+ * open connection was made for the previous session -- signed out it sees
63
+ * only public collections, signed in it carries that user's access -- so it
64
+ * is replaced by one for the new session, resubscribing every collection.
65
+ * The same user again (a token refresh) changes nothing, and with nothing
66
+ * open there is nothing to do: the next subscribe connects as whoever is
67
+ * signed in then.
68
+ */
69
+ sessionChanged(): void;
54
70
  disconnect(): void;
55
71
  }
56
72
  export {};
@@ -38,6 +38,15 @@ export class KoolbaseRealtime {
38
38
  this.reconnectAttempts = 0;
39
39
  this.reconnectTimer = null;
40
40
  this.connecting = false;
41
+ // The open connection is a signed-out visitor's (the public key): its
42
+ // messages leave project_id out.
43
+ this.anonymous = false;
44
+ // Whose session the open connection was made for: the user id, or null
45
+ // when signed out. sessionChanged compares against it.
46
+ this.connectedAs = null;
47
+ // Bumped by sessionChanged: a connect still waiting for its token gives up
48
+ // rather than open a connection for the old session.
49
+ this.generation = 0;
41
50
  // Pending close of an unused connection (scheduleIdleClose); null while
42
51
  // anyone is subscribed.
43
52
  this.idleTimer = null;
@@ -98,28 +107,49 @@ export class KoolbaseRealtime {
98
107
  // double start -- found nobody connecting and opened its own socket; only
99
108
  // the last was remembered, and the others stayed open for good.
100
109
  this.connecting = true;
110
+ const generation = this.generation;
101
111
  let token;
102
112
  try {
103
113
  token = await this.getToken();
104
114
  }
105
115
  catch {
106
- token = null;
116
+ // A session that could not be refreshed is still a session: try again
117
+ // later, never as a signed-out visitor.
118
+ if (generation !== this.generation)
119
+ return;
120
+ this.connecting = false;
121
+ this.scheduleReconnect();
122
+ return;
107
123
  }
124
+ // The session changed while the token was on its way: sessionChanged
125
+ // has started the connection for the new one.
126
+ if (generation !== this.generation)
127
+ return;
108
128
  if (this.listeners.size === 0) {
109
129
  // Everyone left while the token was on its way: nothing to connect for.
110
130
  this.connecting = false;
111
131
  return;
112
132
  }
113
- if (!token) {
133
+ const userId = this.getUserId?.() ?? null;
134
+ if (!token && userId) {
135
+ // Signed in, but no usable token right now: try again later rather
136
+ // than drop to public-only.
114
137
  this.connecting = false;
115
- this.scheduleReconnect(); // sign-in may be in flight
138
+ this.scheduleReconnect();
116
139
  return;
117
140
  }
118
- this.projectId = projectIdFromToken(token);
141
+ // Signed out: the project's public key, and only collections anyone can
142
+ // read. The server pins the connection to the key's project.
143
+ this.anonymous = !token;
144
+ this.connectedAs = token ? userId : null;
145
+ this.projectId = token ? projectIdFromToken(token) : null;
146
+ const credential = token
147
+ ? `token=${encodeURIComponent(token)}`
148
+ : `public_key=${encodeURIComponent(this.config.publicKey)}`;
119
149
  const wsUrl = this.config.baseUrl.replace('https://', 'wss://').replace('http://', 'ws://');
120
150
  let ws;
121
151
  try {
122
- ws = new WebSocket(`${wsUrl}/v1/realtime/ws?token=${encodeURIComponent(token)}`);
152
+ ws = new WebSocket(`${wsUrl}/v1/realtime/ws?${credential}`);
123
153
  }
124
154
  catch {
125
155
  this.connecting = false;
@@ -183,14 +213,22 @@ export class KoolbaseRealtime {
183
213
  ws.onerror = () => { };
184
214
  }
185
215
  sendSubscribe(collection) {
186
- if (!this.projectId || !this.ws || this.ws.readyState !== WebSocket.OPEN)
187
- return;
188
- this.ws.send(JSON.stringify({ action: 'subscribe', project_id: this.projectId, collection }));
216
+ this.send('subscribe', collection);
189
217
  }
190
218
  sendUnsubscribe(collection) {
191
- if (!this.projectId || !this.ws || this.ws.readyState !== WebSocket.OPEN)
219
+ this.send('unsubscribe', collection);
220
+ }
221
+ /** A signed-out visitor's message leaves project_id out: it has none to
222
+ * give, and the server uses the public key's project. */
223
+ send(action, collection) {
224
+ if (!this.ws || this.ws.readyState !== WebSocket.OPEN)
192
225
  return;
193
- this.ws.send(JSON.stringify({ action: 'unsubscribe', project_id: this.projectId, collection }));
226
+ if (this.anonymous) {
227
+ this.ws.send(JSON.stringify({ action, collection }));
228
+ }
229
+ else if (this.projectId) {
230
+ this.ws.send(JSON.stringify({ action, project_id: this.projectId, collection }));
231
+ }
194
232
  }
195
233
  /**
196
234
  * Reconnects with backoff, rather than every three seconds forever.
@@ -249,6 +287,42 @@ export class KoolbaseRealtime {
249
287
  }, KoolbaseRealtime.idleGraceMs);
250
288
  this.idleTimer.unref?.();
251
289
  }
290
+ /**
291
+ * The signed-in user changed: signed in, signed out, or another user. The
292
+ * open connection was made for the previous session -- signed out it sees
293
+ * only public collections, signed in it carries that user's access -- so it
294
+ * is replaced by one for the new session, resubscribing every collection.
295
+ * The same user again (a token refresh) changes nothing, and with nothing
296
+ * open there is nothing to do: the next subscribe connects as whoever is
297
+ * signed in then.
298
+ */
299
+ sessionChanged() {
300
+ if (!this.ws && !this.connecting && !this.reconnectTimer)
301
+ return;
302
+ if (this.ws && !this.connecting && (this.getUserId?.() ?? null) === this.connectedAs)
303
+ return;
304
+ this.generation += 1;
305
+ if (this.reconnectTimer) {
306
+ clearTimeout(this.reconnectTimer);
307
+ this.reconnectTimer = null;
308
+ }
309
+ const ws = this.ws;
310
+ this.ws = null;
311
+ this.connecting = false;
312
+ this.anonymous = false;
313
+ this.projectId = null;
314
+ this.reconnectAttempts = 0;
315
+ if (ws) {
316
+ // Detached first: this close is ours, not a dropped connection.
317
+ ws.onopen = null;
318
+ ws.onmessage = null;
319
+ ws.onclose = null;
320
+ ws.onerror = null;
321
+ ws.close();
322
+ }
323
+ if (this.listeners.size > 0)
324
+ void this.connect();
325
+ }
252
326
  disconnect() {
253
327
  if (this.idleTimer) {
254
328
  clearTimeout(this.idleTimer);
@@ -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.12.0",
3
+ "version": "12.13.1",
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",