@koolbase/core 12.6.0 → 12.8.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.
@@ -38,6 +38,7 @@ export declare class KoolbaseCollectionController {
38
38
  private pageOne;
39
39
  private laterPages;
40
40
  private nextOffset;
41
+ private unregister;
41
42
  constructor(db: QueryReader, collection: string, query?: CollectionQuery);
42
43
  getState(): CollectionState;
43
44
  /** Called on every change. Returns the unsubscribe. */
@@ -46,6 +47,12 @@ export declare class KoolbaseCollectionController {
46
47
  load(): Promise<void>;
47
48
  /** Page one again, from the server. What is shown stays while it runs, and stays if it fails. */
48
49
  refresh(): Promise<void>;
50
+ /**
51
+ * After a write to this collection: page one again from the server,
52
+ * SILENTLY -- no `refreshing`, which is the user's pull-to-refresh. What is
53
+ * shown stays while it runs, and stays if it fails.
54
+ */
55
+ private refreshAfterWrite;
49
56
  /**
50
57
  * The next page, appended. Does nothing unless loaded with more to load.
51
58
  * If rows are added or removed on the server between pages, one can be
@@ -25,6 +25,7 @@
25
25
  Object.defineProperty(exports, "__esModule", { value: true });
26
26
  exports.KoolbaseCollectionController = exports.initialCollectionState = void 0;
27
27
  exports.collectionQueryKey = collectionQueryKey;
28
+ const query_refresh_js_1 = require("./query-refresh.js");
28
29
  exports.initialCollectionState = Object.freeze({
29
30
  status: 'loading',
30
31
  records: Object.freeze([]),
@@ -73,6 +74,8 @@ class KoolbaseCollectionController {
73
74
  // Where the next page starts on the server. Not records.length: once rows
74
75
  // are de-duplicated, a shifted page could otherwise be requested forever.
75
76
  this.nextOffset = 0;
77
+ // A write to this collection re-runs this query (query-refresh.ts).
78
+ this.unregister = null;
76
79
  this.db = db;
77
80
  this.collection = collection;
78
81
  this.spec = query;
@@ -93,6 +96,7 @@ class KoolbaseCollectionController {
93
96
  async load() {
94
97
  if (this.disposed)
95
98
  return;
99
+ this.unregister ?? (this.unregister = (0, query_refresh_js_1.registerQueryRefresher)(this.collection, () => this.refreshAfterWrite()));
96
100
  await this.firstPage('default');
97
101
  }
98
102
  /** Page one again, from the server. What is shown stays while it runs, and stays if it fails. */
@@ -104,6 +108,16 @@ class KoolbaseCollectionController {
104
108
  if (!this.isStale(gen))
105
109
  this.set({ refreshing: false });
106
110
  }
111
+ /**
112
+ * After a write to this collection: page one again from the server,
113
+ * SILENTLY -- no `refreshing`, which is the user's pull-to-refresh. What is
114
+ * shown stays while it runs, and stays if it fails.
115
+ */
116
+ async refreshAfterWrite() {
117
+ if (this.disposed)
118
+ return;
119
+ await this.firstPage('network-only');
120
+ }
107
121
  /**
108
122
  * The next page, appended. Does nothing unless loaded with more to load.
109
123
  * If rows are added or removed on the server between pages, one can be
@@ -137,6 +151,8 @@ class KoolbaseCollectionController {
137
151
  /** Stops all updates. Results still in flight are dropped when they land. */
138
152
  dispose() {
139
153
  this.disposed = true;
154
+ this.unregister?.();
155
+ this.unregister = null;
140
156
  this.listeners.clear();
141
157
  }
142
158
  async firstPage(cache) {
@@ -207,8 +207,19 @@ export declare class KoolbaseDatabase {
207
207
  * already removed it locally.
208
208
  */
209
209
  private resolveBaseline;
210
- update(recordId: string, data: Record<string, unknown>): Promise<KoolbaseRecord>;
211
- delete(recordId: string): Promise<void>;
210
+ update(recordId: string, data: Record<string, unknown>,
211
+ /** expectedRevision: apply only if the record still carries it; refused
212
+ * with KoolbaseRevisionMismatchError otherwise (as the Flutter SDK). */
213
+ options?: {
214
+ expectedRevision?: number;
215
+ }): Promise<KoolbaseRecord>;
216
+ delete(recordId: string,
217
+ /** expectedRevision: delete only if the record still carries it (as the
218
+ * Flutter SDK) -- removing a record someone changed a moment ago is the
219
+ * more destructive stale write. */
220
+ options?: {
221
+ expectedRevision?: number;
222
+ }): Promise<void>;
212
223
  /**
213
224
  * Write (or replace) a vector for a record on the named `field`.
214
225
  *
@@ -2,6 +2,8 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.KoolbaseDatabase = void 0;
4
4
  const network_js_1 = require("./network.js");
5
+ const query_refresh_js_1 = require("./query-refresh.js");
6
+ const database_errors_js_1 = require("./database-errors.js");
5
7
  const errors_js_1 = require("./errors.js");
6
8
  const cache_store_js_1 = require("./cache-store.js");
7
9
  const offline_state_js_1 = require("./offline-state.js");
@@ -10,7 +12,7 @@ const pending_write_js_1 = require("./pending-write.js");
10
12
  const cache_store_js_2 = require("./cache-store.js");
11
13
  const sync_engine_js_1 = require("./sync-engine.js");
12
14
  const record_js_1 = require("./record.js");
13
- const database_errors_js_1 = require("./database-errors.js");
15
+ const database_errors_js_2 = require("./database-errors.js");
14
16
  function generateWriteId() {
15
17
  return 'local_' + Math.random().toString(36).slice(2) + Date.now().toString(36);
16
18
  }
@@ -110,7 +112,7 @@ class KoolbaseDatabase {
110
112
  }
111
113
  }
112
114
  if (!res.ok) {
113
- const err = (0, database_errors_js_1.koolbaseDataError)(res.status, data ?? {}, `Request failed: ${res.status}`);
115
+ const err = (0, database_errors_js_2.koolbaseDataError)(res.status, data ?? {}, `Request failed: ${res.status}`);
114
116
  if (err instanceof errors_js_1.KoolbaseUnauthenticatedError) {
115
117
  await this.onSessionExpired?.();
116
118
  }
@@ -144,7 +146,7 @@ class KoolbaseDatabase {
144
146
  }
145
147
  }
146
148
  if (!res.ok) {
147
- const err = (0, database_errors_js_1.koolbaseDataError)(res.status, data ?? {}, `Request failed: ${res.status}`);
149
+ const err = (0, database_errors_js_2.koolbaseDataError)(res.status, data ?? {}, `Request failed: ${res.status}`);
148
150
  if (err instanceof errors_js_1.KoolbaseUnauthenticatedError) {
149
151
  await this.onSessionExpired?.();
150
152
  }
@@ -229,6 +231,7 @@ class KoolbaseDatabase {
229
231
  */
230
232
  async invalidate(collection) {
231
233
  await (0, cache_store_js_2.invalidateCache)(this.getUserId() ?? 'anonymous', collection);
234
+ void (0, query_refresh_js_1.refreshCollectionQueries)(collection);
232
235
  }
233
236
  // ─── Insert (online-first with offline fallback) ───────────────────────────
234
237
  /**
@@ -252,6 +255,7 @@ class KoolbaseDatabase {
252
255
  const raw = await this.request('POST', '/v1/sdk/db/insert', { collection, data });
253
256
  const record = (0, record_js_1.recordFromWire)(raw);
254
257
  await (0, cache_store_js_2.invalidateCache)(userId, collection);
258
+ void (0, query_refresh_js_1.refreshCollectionQueries)(collection);
255
259
  // The response carries a fresh revision, so caching it keeps the
256
260
  // baseline current for whatever edits this record next.
257
261
  await (0, cache_store_js_1.cacheRecord)(userId, collection, record.id, record.data, record.revision);
@@ -326,6 +330,7 @@ class KoolbaseDatabase {
326
330
  // Keep the cache fresh, same intent as insert's post-success invalidate.
327
331
  const userId = this.getUserId() ?? 'anonymous';
328
332
  await (0, cache_store_js_2.invalidateCache)(userId, collection);
333
+ void (0, query_refresh_js_1.refreshCollectionQueries)(collection);
329
334
  await (0, cache_store_js_1.cacheRecord)(userId, collection, record.id, record.data, record.revision);
330
335
  return { record, created };
331
336
  }
@@ -345,6 +350,7 @@ class KoolbaseDatabase {
345
350
  const body = await this.request('POST', '/v1/sdk/db/delete-where', { collection, filters });
346
351
  const userId = this.getUserId() ?? 'anonymous';
347
352
  await (0, cache_store_js_2.invalidateCache)(userId, collection);
353
+ void (0, query_refresh_js_1.refreshCollectionQueries)(collection);
348
354
  return body.deleted ?? 0;
349
355
  }
350
356
  // ─── Batch (atomic, online-only) ────────────────────────────────────────────
@@ -408,6 +414,7 @@ class KoolbaseDatabase {
408
414
  }
409
415
  for (const col of touched) {
410
416
  await (0, cache_store_js_2.invalidateCache)(userId, col);
417
+ void (0, query_refresh_js_1.refreshCollectionQueries)(col);
411
418
  }
412
419
  return results;
413
420
  }
@@ -529,7 +536,7 @@ class KoolbaseDatabase {
529
536
  const { conflicts } = await (0, offline_state_js_1.readOfflineState)(userId);
530
537
  const found = conflicts.find((c) => c.id === id);
531
538
  if (!found) {
532
- throw new database_errors_js_1.KoolbaseDataError('That conflict is no longer outstanding — it may already have been resolved.', 'conflict_not_found');
539
+ throw new database_errors_js_2.KoolbaseDataError('That conflict is no longer outstanding — it may already have been resolved.', 'conflict_not_found');
533
540
  }
534
541
  return found;
535
542
  }
@@ -578,7 +585,7 @@ class KoolbaseDatabase {
578
585
  // retry replays the stale condition and a conflict whose resolution
579
586
  // fails once is permanently unresolvable except by abandon —
580
587
  // device-proven: three identical refusals against an unchanged server.
581
- const details = e instanceof database_errors_js_1.KoolbaseDataError ? e.details : undefined;
588
+ const details = e instanceof database_errors_js_2.KoolbaseDataError ? e.details : undefined;
582
589
  const current = details?.current_revision;
583
590
  const record = details?.record;
584
591
  if (typeof current === 'number') {
@@ -592,13 +599,14 @@ class KoolbaseDatabase {
592
599
  if (record)
593
600
  stored.server = record;
594
601
  });
595
- throw new database_errors_js_1.KoolbaseDataError('The record has changed again while deciding. The conflict now ' +
602
+ throw new database_errors_js_2.KoolbaseDataError('The record has changed again while deciding. The conflict now ' +
596
603
  'reflects the server\'s current state — review and retry.', 'revision_mismatch');
597
604
  }
598
605
  throw e;
599
606
  }
600
607
  await this.dropConflict(c.id);
601
608
  await (0, cache_store_js_2.invalidateCache)(this.getUserId() ?? 'anonymous', c.collection);
609
+ void (0, query_refresh_js_1.refreshCollectionQueries)(c.collection);
602
610
  }
603
611
  // ─── Update (online-first with offline fallback) ───────────────────────────
604
612
  /**
@@ -655,16 +663,23 @@ class KoolbaseDatabase {
655
663
  return null;
656
664
  return { baseline: cached.data, revision: cached.revision, collection: cached.collection };
657
665
  }
658
- async update(recordId, data) {
666
+ async update(recordId, data,
667
+ /** expectedRevision: apply only if the record still carries it; refused
668
+ * with KoolbaseRevisionMismatchError otherwise (as the Flutter SDK). */
669
+ options = {}) {
659
670
  const userId = this.getUserId() ?? 'anonymous';
660
671
  // Resolved before the request, so a network failure has somewhere to go.
661
672
  const base = await this.resolveBaseline(userId, recordId);
662
673
  try {
663
- const raw = await this.request('PATCH', `/v1/sdk/db/records/${recordId}`, { data });
674
+ const raw = await this.request('PATCH', `/v1/sdk/db/records/${recordId}`, { data, ...(options.expectedRevision !== undefined ? { expected_revision: options.expectedRevision } : {}) });
664
675
  const updated = (0, record_js_1.recordFromWire)(raw);
676
+ // Its collection's live queries re-run (every one, if the record's
677
+ // collection is not known).
678
+ const refreshed = updated.collection;
665
679
  if (updated.collection) {
666
680
  await (0, cache_store_js_1.cacheRecord)(userId, updated.collection, updated.id, updated.data, updated.revision);
667
681
  }
682
+ void (0, query_refresh_js_1.refreshCollectionQueries)(refreshed);
668
683
  return updated;
669
684
  }
670
685
  catch (e) {
@@ -674,6 +689,10 @@ class KoolbaseDatabase {
674
689
  // credential — must not be queued: it will be refused again on every
675
690
  // retry. Checked against the root rather than the data family, because a
676
691
  // rejected credential belongs to no single surface.
692
+ // Refused as stale: the server holds newer data, so show it -- the
693
+ // collection's live queries re-run in the background (as Flutter).
694
+ if (e instanceof database_errors_js_1.KoolbaseRevisionMismatchError)
695
+ void (0, query_refresh_js_1.refreshCollectionQueries)(base?.collection);
677
696
  if (e instanceof errors_js_1.KoolbaseError)
678
697
  throw e;
679
698
  // Genuine network failure. Queueable only if the SDK knows what the
@@ -705,12 +724,19 @@ class KoolbaseDatabase {
705
724
  }
706
725
  }
707
726
  // ─── Delete ─────────────────────────────────────────────────────────────────
708
- async delete(recordId) {
727
+ async delete(recordId,
728
+ /** expectedRevision: delete only if the record still carries it (as the
729
+ * Flutter SDK) -- removing a record someone changed a moment ago is the
730
+ * more destructive stale write. */
731
+ options = {}) {
709
732
  const userId = this.getUserId() ?? 'anonymous';
710
733
  const base = await this.resolveBaseline(userId, recordId);
711
734
  try {
712
- await this.request('DELETE', `/v1/sdk/db/records/${recordId}`);
735
+ await this.request('DELETE', `/v1/sdk/db/records/${recordId}${options.expectedRevision !== undefined ? `?expected_revision=${options.expectedRevision}` : ''}`);
713
736
  await (0, cache_store_js_1.removeCachedRecord)(userId, recordId);
737
+ // Its collection is known from the baseline when the record was seen;
738
+ // otherwise every live query re-runs, rather than a guess.
739
+ void (0, query_refresh_js_1.refreshCollectionQueries)(base?.collection);
714
740
  }
715
741
  catch (e) {
716
742
  // A server that answered has refused: a permission denial or a missing
@@ -721,6 +747,10 @@ class KoolbaseDatabase {
721
747
  // credential — must not be queued: it will be refused again on every
722
748
  // retry. Checked against the root rather than the data family, because a
723
749
  // rejected credential belongs to no single surface.
750
+ // Refused as stale: the server holds newer data, so show it -- the
751
+ // collection's live queries re-run in the background (as Flutter).
752
+ if (e instanceof database_errors_js_1.KoolbaseRevisionMismatchError)
753
+ void (0, query_refresh_js_1.refreshCollectionQueries)(base?.collection);
724
754
  if (e instanceof errors_js_1.KoolbaseError)
725
755
  throw e;
726
756
  // Genuine network failure. Queued here rather than before the request,
@@ -0,0 +1,10 @@
1
+ /** Registers one live query's refresher; returns the unregister. */
2
+ export declare function registerQueryRefresher(collection: string, refresh: () => Promise<void>): () => void;
3
+ /**
4
+ * Re-runs every live query on a collection, after a write -- or every live
5
+ * query when the collection is not known (a delete of a record never read on
6
+ * this device). One after another; a failure in one never stops the others.
7
+ */
8
+ export declare function refreshCollectionQueries(collection?: string): Promise<void>;
9
+ /** Tests only. */
10
+ export declare function clearQueryRefreshers(): void;
@@ -0,0 +1,41 @@
1
+ "use strict";
2
+ // Live queries refreshed after a write -- the Flutter SDK's _refreshers and
3
+ // refreshCollectionStreams, mirrored. A query refreshes ITSELF: a key carries
4
+ // the collection and filters but not the ordering, limit or paging, so a
5
+ // refresh rebuilt from a key alone would be a different query.
6
+ Object.defineProperty(exports, "__esModule", { value: true });
7
+ exports.registerQueryRefresher = registerQueryRefresher;
8
+ exports.refreshCollectionQueries = refreshCollectionQueries;
9
+ exports.clearQueryRefreshers = clearQueryRefreshers;
10
+ const refreshers = new Map();
11
+ let next = 0;
12
+ /** Registers one live query's refresher; returns the unregister. */
13
+ function registerQueryRefresher(collection, refresh) {
14
+ const key = `${collection}:${++next}`;
15
+ refreshers.set(key, refresh);
16
+ return () => {
17
+ refreshers.delete(key);
18
+ };
19
+ }
20
+ /**
21
+ * Re-runs every live query on a collection, after a write -- or every live
22
+ * query when the collection is not known (a delete of a record never read on
23
+ * this device). One after another; a failure in one never stops the others.
24
+ */
25
+ async function refreshCollectionQueries(collection) {
26
+ const prefix = collection === undefined ? null : `${collection}:`;
27
+ const due = [...refreshers.entries()].filter(([k]) => prefix === null || k.startsWith(prefix)).map(([, f]) => f);
28
+ for (const refresh of due) {
29
+ try {
30
+ await refresh();
31
+ }
32
+ catch (e) {
33
+ // eslint-disable-next-line no-console
34
+ console.warn('[Koolbase] A live query failed to refresh after a write:', e);
35
+ }
36
+ }
37
+ }
38
+ /** Tests only. */
39
+ function clearQueryRefreshers() {
40
+ refreshers.clear();
41
+ }
@@ -38,6 +38,7 @@ export declare class KoolbaseCollectionController {
38
38
  private pageOne;
39
39
  private laterPages;
40
40
  private nextOffset;
41
+ private unregister;
41
42
  constructor(db: QueryReader, collection: string, query?: CollectionQuery);
42
43
  getState(): CollectionState;
43
44
  /** Called on every change. Returns the unsubscribe. */
@@ -46,6 +47,12 @@ export declare class KoolbaseCollectionController {
46
47
  load(): Promise<void>;
47
48
  /** Page one again, from the server. What is shown stays while it runs, and stays if it fails. */
48
49
  refresh(): Promise<void>;
50
+ /**
51
+ * After a write to this collection: page one again from the server,
52
+ * SILENTLY -- no `refreshing`, which is the user's pull-to-refresh. What is
53
+ * shown stays while it runs, and stays if it fails.
54
+ */
55
+ private refreshAfterWrite;
49
56
  /**
50
57
  * The next page, appended. Does nothing unless loaded with more to load.
51
58
  * If rows are added or removed on the server between pages, one can be
@@ -21,6 +21,7 @@
21
21
  // Known limit: if rows are added or removed on the server between pages,
22
22
  // the pages shift and a row can be skipped until refresh(), which reloads
23
23
  // from page one. Duplicates are removed; a skipped row cannot be detected.
24
+ import { registerQueryRefresher } from './query-refresh.js';
24
25
  export const initialCollectionState = Object.freeze({
25
26
  status: 'loading',
26
27
  records: Object.freeze([]),
@@ -69,6 +70,8 @@ export class KoolbaseCollectionController {
69
70
  // Where the next page starts on the server. Not records.length: once rows
70
71
  // are de-duplicated, a shifted page could otherwise be requested forever.
71
72
  this.nextOffset = 0;
73
+ // A write to this collection re-runs this query (query-refresh.ts).
74
+ this.unregister = null;
72
75
  this.db = db;
73
76
  this.collection = collection;
74
77
  this.spec = query;
@@ -89,6 +92,7 @@ export class KoolbaseCollectionController {
89
92
  async load() {
90
93
  if (this.disposed)
91
94
  return;
95
+ this.unregister ?? (this.unregister = registerQueryRefresher(this.collection, () => this.refreshAfterWrite()));
92
96
  await this.firstPage('default');
93
97
  }
94
98
  /** Page one again, from the server. What is shown stays while it runs, and stays if it fails. */
@@ -100,6 +104,16 @@ export class KoolbaseCollectionController {
100
104
  if (!this.isStale(gen))
101
105
  this.set({ refreshing: false });
102
106
  }
107
+ /**
108
+ * After a write to this collection: page one again from the server,
109
+ * SILENTLY -- no `refreshing`, which is the user's pull-to-refresh. What is
110
+ * shown stays while it runs, and stays if it fails.
111
+ */
112
+ async refreshAfterWrite() {
113
+ if (this.disposed)
114
+ return;
115
+ await this.firstPage('network-only');
116
+ }
103
117
  /**
104
118
  * The next page, appended. Does nothing unless loaded with more to load.
105
119
  * If rows are added or removed on the server between pages, one can be
@@ -133,6 +147,8 @@ export class KoolbaseCollectionController {
133
147
  /** Stops all updates. Results still in flight are dropped when they land. */
134
148
  dispose() {
135
149
  this.disposed = true;
150
+ this.unregister?.();
151
+ this.unregister = null;
136
152
  this.listeners.clear();
137
153
  }
138
154
  async firstPage(cache) {
@@ -207,8 +207,19 @@ export declare class KoolbaseDatabase {
207
207
  * already removed it locally.
208
208
  */
209
209
  private resolveBaseline;
210
- update(recordId: string, data: Record<string, unknown>): Promise<KoolbaseRecord>;
211
- delete(recordId: string): Promise<void>;
210
+ update(recordId: string, data: Record<string, unknown>,
211
+ /** expectedRevision: apply only if the record still carries it; refused
212
+ * with KoolbaseRevisionMismatchError otherwise (as the Flutter SDK). */
213
+ options?: {
214
+ expectedRevision?: number;
215
+ }): Promise<KoolbaseRecord>;
216
+ delete(recordId: string,
217
+ /** expectedRevision: delete only if the record still carries it (as the
218
+ * Flutter SDK) -- removing a record someone changed a moment ago is the
219
+ * more destructive stale write. */
220
+ options?: {
221
+ expectedRevision?: number;
222
+ }): Promise<void>;
212
223
  /**
213
224
  * Write (or replace) a vector for a record on the named `field`.
214
225
  *
@@ -1,4 +1,6 @@
1
1
  import { koolbaseFetch } from './network.js';
2
+ import { refreshCollectionQueries } from './query-refresh.js';
3
+ import { KoolbaseRevisionMismatchError } from './database-errors.js';
2
4
  import { KoolbaseError, KoolbaseOfflineBaselineUnavailableError, KoolbaseUnauthenticatedError, } from './errors.js';
3
5
  import { cacheRecord, getCachedRecord, removeCachedRecord } from './cache-store.js';
4
6
  import { readOfflineState, mutateOfflineState, queueWrite, } from './offline-state.js';
@@ -226,6 +228,7 @@ export class KoolbaseDatabase {
226
228
  */
227
229
  async invalidate(collection) {
228
230
  await invalidateCache(this.getUserId() ?? 'anonymous', collection);
231
+ void refreshCollectionQueries(collection);
229
232
  }
230
233
  // ─── Insert (online-first with offline fallback) ───────────────────────────
231
234
  /**
@@ -249,6 +252,7 @@ export class KoolbaseDatabase {
249
252
  const raw = await this.request('POST', '/v1/sdk/db/insert', { collection, data });
250
253
  const record = recordFromWire(raw);
251
254
  await invalidateCache(userId, collection);
255
+ void refreshCollectionQueries(collection);
252
256
  // The response carries a fresh revision, so caching it keeps the
253
257
  // baseline current for whatever edits this record next.
254
258
  await cacheRecord(userId, collection, record.id, record.data, record.revision);
@@ -323,6 +327,7 @@ export class KoolbaseDatabase {
323
327
  // Keep the cache fresh, same intent as insert's post-success invalidate.
324
328
  const userId = this.getUserId() ?? 'anonymous';
325
329
  await invalidateCache(userId, collection);
330
+ void refreshCollectionQueries(collection);
326
331
  await cacheRecord(userId, collection, record.id, record.data, record.revision);
327
332
  return { record, created };
328
333
  }
@@ -342,6 +347,7 @@ export class KoolbaseDatabase {
342
347
  const body = await this.request('POST', '/v1/sdk/db/delete-where', { collection, filters });
343
348
  const userId = this.getUserId() ?? 'anonymous';
344
349
  await invalidateCache(userId, collection);
350
+ void refreshCollectionQueries(collection);
345
351
  return body.deleted ?? 0;
346
352
  }
347
353
  // ─── Batch (atomic, online-only) ────────────────────────────────────────────
@@ -405,6 +411,7 @@ export class KoolbaseDatabase {
405
411
  }
406
412
  for (const col of touched) {
407
413
  await invalidateCache(userId, col);
414
+ void refreshCollectionQueries(col);
408
415
  }
409
416
  return results;
410
417
  }
@@ -596,6 +603,7 @@ export class KoolbaseDatabase {
596
603
  }
597
604
  await this.dropConflict(c.id);
598
605
  await invalidateCache(this.getUserId() ?? 'anonymous', c.collection);
606
+ void refreshCollectionQueries(c.collection);
599
607
  }
600
608
  // ─── Update (online-first with offline fallback) ───────────────────────────
601
609
  /**
@@ -652,16 +660,23 @@ export class KoolbaseDatabase {
652
660
  return null;
653
661
  return { baseline: cached.data, revision: cached.revision, collection: cached.collection };
654
662
  }
655
- async update(recordId, data) {
663
+ async update(recordId, data,
664
+ /** expectedRevision: apply only if the record still carries it; refused
665
+ * with KoolbaseRevisionMismatchError otherwise (as the Flutter SDK). */
666
+ options = {}) {
656
667
  const userId = this.getUserId() ?? 'anonymous';
657
668
  // Resolved before the request, so a network failure has somewhere to go.
658
669
  const base = await this.resolveBaseline(userId, recordId);
659
670
  try {
660
- const raw = await this.request('PATCH', `/v1/sdk/db/records/${recordId}`, { data });
671
+ const raw = await this.request('PATCH', `/v1/sdk/db/records/${recordId}`, { data, ...(options.expectedRevision !== undefined ? { expected_revision: options.expectedRevision } : {}) });
661
672
  const updated = recordFromWire(raw);
673
+ // Its collection's live queries re-run (every one, if the record's
674
+ // collection is not known).
675
+ const refreshed = updated.collection;
662
676
  if (updated.collection) {
663
677
  await cacheRecord(userId, updated.collection, updated.id, updated.data, updated.revision);
664
678
  }
679
+ void refreshCollectionQueries(refreshed);
665
680
  return updated;
666
681
  }
667
682
  catch (e) {
@@ -671,6 +686,10 @@ export class KoolbaseDatabase {
671
686
  // credential — must not be queued: it will be refused again on every
672
687
  // retry. Checked against the root rather than the data family, because a
673
688
  // rejected credential belongs to no single surface.
689
+ // Refused as stale: the server holds newer data, so show it -- the
690
+ // collection's live queries re-run in the background (as Flutter).
691
+ if (e instanceof KoolbaseRevisionMismatchError)
692
+ void refreshCollectionQueries(base?.collection);
674
693
  if (e instanceof KoolbaseError)
675
694
  throw e;
676
695
  // Genuine network failure. Queueable only if the SDK knows what the
@@ -702,12 +721,19 @@ export class KoolbaseDatabase {
702
721
  }
703
722
  }
704
723
  // ─── Delete ─────────────────────────────────────────────────────────────────
705
- async delete(recordId) {
724
+ async delete(recordId,
725
+ /** expectedRevision: delete only if the record still carries it (as the
726
+ * Flutter SDK) -- removing a record someone changed a moment ago is the
727
+ * more destructive stale write. */
728
+ options = {}) {
706
729
  const userId = this.getUserId() ?? 'anonymous';
707
730
  const base = await this.resolveBaseline(userId, recordId);
708
731
  try {
709
- await this.request('DELETE', `/v1/sdk/db/records/${recordId}`);
732
+ await this.request('DELETE', `/v1/sdk/db/records/${recordId}${options.expectedRevision !== undefined ? `?expected_revision=${options.expectedRevision}` : ''}`);
710
733
  await removeCachedRecord(userId, recordId);
734
+ // Its collection is known from the baseline when the record was seen;
735
+ // otherwise every live query re-runs, rather than a guess.
736
+ void refreshCollectionQueries(base?.collection);
711
737
  }
712
738
  catch (e) {
713
739
  // A server that answered has refused: a permission denial or a missing
@@ -718,6 +744,10 @@ export class KoolbaseDatabase {
718
744
  // credential — must not be queued: it will be refused again on every
719
745
  // retry. Checked against the root rather than the data family, because a
720
746
  // rejected credential belongs to no single surface.
747
+ // Refused as stale: the server holds newer data, so show it -- the
748
+ // collection's live queries re-run in the background (as Flutter).
749
+ if (e instanceof KoolbaseRevisionMismatchError)
750
+ void refreshCollectionQueries(base?.collection);
721
751
  if (e instanceof KoolbaseError)
722
752
  throw e;
723
753
  // Genuine network failure. Queued here rather than before the request,
@@ -0,0 +1,10 @@
1
+ /** Registers one live query's refresher; returns the unregister. */
2
+ export declare function registerQueryRefresher(collection: string, refresh: () => Promise<void>): () => void;
3
+ /**
4
+ * Re-runs every live query on a collection, after a write -- or every live
5
+ * query when the collection is not known (a delete of a record never read on
6
+ * this device). One after another; a failure in one never stops the others.
7
+ */
8
+ export declare function refreshCollectionQueries(collection?: string): Promise<void>;
9
+ /** Tests only. */
10
+ export declare function clearQueryRefreshers(): void;
@@ -0,0 +1,36 @@
1
+ // Live queries refreshed after a write -- the Flutter SDK's _refreshers and
2
+ // refreshCollectionStreams, mirrored. A query refreshes ITSELF: a key carries
3
+ // the collection and filters but not the ordering, limit or paging, so a
4
+ // refresh rebuilt from a key alone would be a different query.
5
+ const refreshers = new Map();
6
+ let next = 0;
7
+ /** Registers one live query's refresher; returns the unregister. */
8
+ export function registerQueryRefresher(collection, refresh) {
9
+ const key = `${collection}:${++next}`;
10
+ refreshers.set(key, refresh);
11
+ return () => {
12
+ refreshers.delete(key);
13
+ };
14
+ }
15
+ /**
16
+ * Re-runs every live query on a collection, after a write -- or every live
17
+ * query when the collection is not known (a delete of a record never read on
18
+ * this device). One after another; a failure in one never stops the others.
19
+ */
20
+ export async function refreshCollectionQueries(collection) {
21
+ const prefix = collection === undefined ? null : `${collection}:`;
22
+ const due = [...refreshers.entries()].filter(([k]) => prefix === null || k.startsWith(prefix)).map(([, f]) => f);
23
+ for (const refresh of due) {
24
+ try {
25
+ await refresh();
26
+ }
27
+ catch (e) {
28
+ // eslint-disable-next-line no-console
29
+ console.warn('[Koolbase] A live query failed to refresh after a write:', e);
30
+ }
31
+ }
32
+ }
33
+ /** Tests only. */
34
+ export function clearQueryRefreshers() {
35
+ refreshers.clear();
36
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@koolbase/core",
3
- "version": "12.6.0",
4
- "description": "Koolbase SDK core \u2014 shared behaviour behind @koolbase/react-native and @koolbase/js. Install one of those, not this.",
3
+ "version": "12.8.0",
4
+ "description": "Koolbase SDK core — 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",
7
7
  "files": [