@koolbase/core 12.5.1 → 12.7.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) {
@@ -2,6 +2,7 @@
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");
5
6
  const errors_js_1 = require("./errors.js");
6
7
  const cache_store_js_1 = require("./cache-store.js");
7
8
  const offline_state_js_1 = require("./offline-state.js");
@@ -229,6 +230,7 @@ class KoolbaseDatabase {
229
230
  */
230
231
  async invalidate(collection) {
231
232
  await (0, cache_store_js_2.invalidateCache)(this.getUserId() ?? 'anonymous', collection);
233
+ await (0, query_refresh_js_1.refreshCollectionQueries)(collection);
232
234
  }
233
235
  // ─── Insert (online-first with offline fallback) ───────────────────────────
234
236
  /**
@@ -252,6 +254,7 @@ class KoolbaseDatabase {
252
254
  const raw = await this.request('POST', '/v1/sdk/db/insert', { collection, data });
253
255
  const record = (0, record_js_1.recordFromWire)(raw);
254
256
  await (0, cache_store_js_2.invalidateCache)(userId, collection);
257
+ await (0, query_refresh_js_1.refreshCollectionQueries)(collection);
255
258
  // The response carries a fresh revision, so caching it keeps the
256
259
  // baseline current for whatever edits this record next.
257
260
  await (0, cache_store_js_1.cacheRecord)(userId, collection, record.id, record.data, record.revision);
@@ -326,6 +329,7 @@ class KoolbaseDatabase {
326
329
  // Keep the cache fresh, same intent as insert's post-success invalidate.
327
330
  const userId = this.getUserId() ?? 'anonymous';
328
331
  await (0, cache_store_js_2.invalidateCache)(userId, collection);
332
+ await (0, query_refresh_js_1.refreshCollectionQueries)(collection);
329
333
  await (0, cache_store_js_1.cacheRecord)(userId, collection, record.id, record.data, record.revision);
330
334
  return { record, created };
331
335
  }
@@ -345,6 +349,7 @@ class KoolbaseDatabase {
345
349
  const body = await this.request('POST', '/v1/sdk/db/delete-where', { collection, filters });
346
350
  const userId = this.getUserId() ?? 'anonymous';
347
351
  await (0, cache_store_js_2.invalidateCache)(userId, collection);
352
+ await (0, query_refresh_js_1.refreshCollectionQueries)(collection);
348
353
  return body.deleted ?? 0;
349
354
  }
350
355
  // ─── Batch (atomic, online-only) ────────────────────────────────────────────
@@ -408,6 +413,7 @@ class KoolbaseDatabase {
408
413
  }
409
414
  for (const col of touched) {
410
415
  await (0, cache_store_js_2.invalidateCache)(userId, col);
416
+ await (0, query_refresh_js_1.refreshCollectionQueries)(col);
411
417
  }
412
418
  return results;
413
419
  }
@@ -599,6 +605,7 @@ class KoolbaseDatabase {
599
605
  }
600
606
  await this.dropConflict(c.id);
601
607
  await (0, cache_store_js_2.invalidateCache)(this.getUserId() ?? 'anonymous', c.collection);
608
+ await (0, query_refresh_js_1.refreshCollectionQueries)(c.collection);
602
609
  }
603
610
  // ─── Update (online-first with offline fallback) ───────────────────────────
604
611
  /**
@@ -662,9 +669,13 @@ class KoolbaseDatabase {
662
669
  try {
663
670
  const raw = await this.request('PATCH', `/v1/sdk/db/records/${recordId}`, { data });
664
671
  const updated = (0, record_js_1.recordFromWire)(raw);
672
+ // Its collection's live queries re-run (every one, if the record's
673
+ // collection is not known).
674
+ const refreshed = updated.collection;
665
675
  if (updated.collection) {
666
676
  await (0, cache_store_js_1.cacheRecord)(userId, updated.collection, updated.id, updated.data, updated.revision);
667
677
  }
678
+ await (0, query_refresh_js_1.refreshCollectionQueries)(refreshed);
668
679
  return updated;
669
680
  }
670
681
  catch (e) {
@@ -711,6 +722,9 @@ class KoolbaseDatabase {
711
722
  try {
712
723
  await this.request('DELETE', `/v1/sdk/db/records/${recordId}`);
713
724
  await (0, cache_store_js_1.removeCachedRecord)(userId, recordId);
725
+ // Its collection is known from the baseline when the record was seen;
726
+ // otherwise every live query re-runs, rather than a guess.
727
+ await (0, query_refresh_js_1.refreshCollectionQueries)(base?.collection);
714
728
  }
715
729
  catch (e) {
716
730
  // A server that answered has refused: a permission denial or a missing
@@ -12,6 +12,8 @@ export { KoolbaseAuth } from './auth.js';
12
12
  export { KoolbaseDatabase } from './database.js';
13
13
  export { KoolbaseCollectionController, collectionQueryKey, initialCollectionState, } from './collection-controller.js';
14
14
  export type { CollectionQuery, CollectionState, CollectionStatus } from './collection-controller.js';
15
+ export { KoolbaseRecordController, initialRecordState } from './record-controller.js';
16
+ export type { RecordState, RecordStatus } from './record-controller.js';
15
17
  export { KoolbaseFlags } from './flags.js';
16
18
  export { KoolbaseFunctions } from './functions.js';
17
19
  export { KoolbaseRealtime } from './realtime.js';
package/dist/cjs/index.js CHANGED
@@ -19,7 +19,7 @@ var __exportStar = (this && this.__exportStar) || function(m, exports) {
19
19
  for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
20
20
  };
21
21
  Object.defineProperty(exports, "__esModule", { value: true });
22
- exports.memoryPlatform = exports.getPlatform = exports.setPlatform = exports.RestoreResult = exports.koolbaseSdkVersion = exports.getOrCreateDeviceId = exports.KoolbaseMessaging = exports.isPending = exports.isFiscalized = exports.decodeFiscalResponse = exports.KoolbaseFiscalError = exports.KoolbaseFiscal = exports.disabledAnalytics = exports.KoolbaseAnalytics = exports.KoolbaseStorage = exports.KoolbaseRealtime = exports.KoolbaseFunctions = exports.KoolbaseFlags = exports.initialCollectionState = exports.collectionQueryKey = exports.KoolbaseCollectionController = exports.KoolbaseDatabase = exports.KoolbaseAuth = void 0;
22
+ exports.memoryPlatform = exports.getPlatform = exports.setPlatform = exports.RestoreResult = exports.koolbaseSdkVersion = exports.getOrCreateDeviceId = exports.KoolbaseMessaging = exports.isPending = exports.isFiscalized = exports.decodeFiscalResponse = exports.KoolbaseFiscalError = exports.KoolbaseFiscal = exports.disabledAnalytics = exports.KoolbaseAnalytics = exports.KoolbaseStorage = exports.KoolbaseRealtime = exports.KoolbaseFunctions = exports.KoolbaseFlags = exports.initialRecordState = exports.KoolbaseRecordController = exports.initialCollectionState = exports.collectionQueryKey = exports.KoolbaseCollectionController = exports.KoolbaseDatabase = exports.KoolbaseAuth = void 0;
23
23
  __exportStar(require("./types.js"), exports);
24
24
  __exportStar(require("./errors.js"), exports);
25
25
  __exportStar(require("./network.js"), exports);
@@ -38,6 +38,9 @@ var collection_controller_js_1 = require("./collection-controller.js");
38
38
  Object.defineProperty(exports, "KoolbaseCollectionController", { enumerable: true, get: function () { return collection_controller_js_1.KoolbaseCollectionController; } });
39
39
  Object.defineProperty(exports, "collectionQueryKey", { enumerable: true, get: function () { return collection_controller_js_1.collectionQueryKey; } });
40
40
  Object.defineProperty(exports, "initialCollectionState", { enumerable: true, get: function () { return collection_controller_js_1.initialCollectionState; } });
41
+ var record_controller_js_1 = require("./record-controller.js");
42
+ Object.defineProperty(exports, "KoolbaseRecordController", { enumerable: true, get: function () { return record_controller_js_1.KoolbaseRecordController; } });
43
+ Object.defineProperty(exports, "initialRecordState", { enumerable: true, get: function () { return record_controller_js_1.initialRecordState; } });
41
44
  var flags_js_1 = require("./flags.js");
42
45
  Object.defineProperty(exports, "KoolbaseFlags", { enumerable: true, get: function () { return flags_js_1.KoolbaseFlags; } });
43
46
  var functions_js_1 = require("./functions.js");
@@ -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
+ }
@@ -0,0 +1,36 @@
1
+ import type { KoolbaseDatabase } from './database.js';
2
+ import type { KoolbaseRecord } from './types.js';
3
+ export type RecordStatus = 'loading' | 'loaded' | 'notFound' | 'error';
4
+ export interface RecordState {
5
+ readonly status: RecordStatus;
6
+ readonly record: KoolbaseRecord | null;
7
+ /** Why the first load failed, while status is 'error'. */
8
+ readonly error: unknown;
9
+ /** True while refresh() runs. */
10
+ readonly refreshing: boolean;
11
+ }
12
+ export declare const initialRecordState: RecordState;
13
+ type RecordReader = Pick<KoolbaseDatabase, 'get'>;
14
+ export declare class KoolbaseRecordController {
15
+ readonly collection: string;
16
+ readonly id: string;
17
+ private readonly db;
18
+ private state;
19
+ private readonly listeners;
20
+ private generation;
21
+ private disposed;
22
+ constructor(db: RecordReader, collection: string, id: string);
23
+ getState(): RecordState;
24
+ /** Called on every change. Returns the unsubscribe. */
25
+ subscribe(listener: () => void): () => void;
26
+ /** The first load. Call once; refresh() for later loads. */
27
+ load(): Promise<void>;
28
+ /** The record again, from the server. What is shown stays while it runs, and stays if it fails. */
29
+ refresh(): Promise<void>;
30
+ /** Stops all updates. A result still in flight is dropped when it lands. */
31
+ dispose(): void;
32
+ private fetch;
33
+ private isStale;
34
+ private set;
35
+ }
36
+ export {};
@@ -0,0 +1,110 @@
1
+ "use strict";
2
+ // One record, read by id, framework-free: the detail-screen counterpart of
3
+ // KoolbaseCollectionController. @koolbase/react-native's useRecord wraps it.
4
+ //
5
+ // - loading, loaded, notFound or error. notFound is its own state because
6
+ // the API answers a denied read as "not found" too (it does not reveal
7
+ // whether a record exists): a detail screen says "not available", not
8
+ // "something went wrong".
9
+ // - an empty id is notFound with no request: a screen opened without one.
10
+ // - a record from a different collection is notFound: the API names each
11
+ // record's collection, and a mistyped or crafted id must not put another
12
+ // collection's record on this screen.
13
+ // - refresh() keeps the record shown while it runs, and if it fails.
14
+ // - every fetch has a generation: a result superseded by a later refresh,
15
+ // or landing after dispose(), is dropped.
16
+ // Not yet: an offline fallback from the record cache, which keeps data and
17
+ // revision but not createdAt or createdBy.
18
+ Object.defineProperty(exports, "__esModule", { value: true });
19
+ exports.KoolbaseRecordController = exports.initialRecordState = void 0;
20
+ const database_errors_js_1 = require("./database-errors.js");
21
+ exports.initialRecordState = Object.freeze({
22
+ status: 'loading',
23
+ record: null,
24
+ error: null,
25
+ refreshing: false,
26
+ });
27
+ class KoolbaseRecordController {
28
+ constructor(db, collection, id) {
29
+ this.state = exports.initialRecordState;
30
+ this.listeners = new Set();
31
+ this.generation = 0;
32
+ this.disposed = false;
33
+ this.db = db;
34
+ this.collection = collection;
35
+ this.id = id;
36
+ }
37
+ getState() {
38
+ return this.state;
39
+ }
40
+ /** Called on every change. Returns the unsubscribe. */
41
+ subscribe(listener) {
42
+ if (this.disposed)
43
+ return () => { };
44
+ this.listeners.add(listener);
45
+ return () => {
46
+ this.listeners.delete(listener);
47
+ };
48
+ }
49
+ /** The first load. Call once; refresh() for later loads. */
50
+ async load() {
51
+ if (this.disposed)
52
+ return;
53
+ await this.fetch();
54
+ }
55
+ /** The record again, from the server. What is shown stays while it runs, and stays if it fails. */
56
+ async refresh() {
57
+ if (this.disposed)
58
+ return;
59
+ this.set({ refreshing: true });
60
+ const gen = await this.fetch();
61
+ if (!this.isStale(gen))
62
+ this.set({ refreshing: false });
63
+ }
64
+ /** Stops all updates. A result still in flight is dropped when it lands. */
65
+ dispose() {
66
+ this.disposed = true;
67
+ this.listeners.clear();
68
+ }
69
+ async fetch() {
70
+ const gen = ++this.generation;
71
+ if (!this.id) {
72
+ this.set({ status: 'notFound', record: null, error: null });
73
+ return gen;
74
+ }
75
+ try {
76
+ const record = await this.db.get(this.id);
77
+ if (this.isStale(gen))
78
+ return gen;
79
+ if (record.collection !== undefined && record.collection !== this.collection) {
80
+ this.set({ status: 'notFound', record: null, error: null });
81
+ }
82
+ else {
83
+ this.set({ status: 'loaded', record, error: null });
84
+ }
85
+ }
86
+ catch (error) {
87
+ if (this.isStale(gen))
88
+ return gen;
89
+ if (error instanceof database_errors_js_1.KoolbaseNotFoundError) {
90
+ this.set({ status: 'notFound', record: null, error: null });
91
+ }
92
+ else if (this.state.record === null) {
93
+ this.set({ status: 'error', error });
94
+ }
95
+ // Otherwise keep the record shown: stale beats blank.
96
+ }
97
+ return gen;
98
+ }
99
+ isStale(gen) {
100
+ return this.disposed || gen !== this.generation;
101
+ }
102
+ set(patch) {
103
+ if (this.disposed)
104
+ return;
105
+ this.state = Object.freeze({ ...this.state, ...patch });
106
+ for (const listener of [...this.listeners])
107
+ listener();
108
+ }
109
+ }
110
+ exports.KoolbaseRecordController = KoolbaseRecordController;
@@ -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) {
@@ -1,4 +1,5 @@
1
1
  import { koolbaseFetch } from './network.js';
2
+ import { refreshCollectionQueries } from './query-refresh.js';
2
3
  import { KoolbaseError, KoolbaseOfflineBaselineUnavailableError, KoolbaseUnauthenticatedError, } from './errors.js';
3
4
  import { cacheRecord, getCachedRecord, removeCachedRecord } from './cache-store.js';
4
5
  import { readOfflineState, mutateOfflineState, queueWrite, } from './offline-state.js';
@@ -226,6 +227,7 @@ export class KoolbaseDatabase {
226
227
  */
227
228
  async invalidate(collection) {
228
229
  await invalidateCache(this.getUserId() ?? 'anonymous', collection);
230
+ await refreshCollectionQueries(collection);
229
231
  }
230
232
  // ─── Insert (online-first with offline fallback) ───────────────────────────
231
233
  /**
@@ -249,6 +251,7 @@ export class KoolbaseDatabase {
249
251
  const raw = await this.request('POST', '/v1/sdk/db/insert', { collection, data });
250
252
  const record = recordFromWire(raw);
251
253
  await invalidateCache(userId, collection);
254
+ await refreshCollectionQueries(collection);
252
255
  // The response carries a fresh revision, so caching it keeps the
253
256
  // baseline current for whatever edits this record next.
254
257
  await cacheRecord(userId, collection, record.id, record.data, record.revision);
@@ -323,6 +326,7 @@ export class KoolbaseDatabase {
323
326
  // Keep the cache fresh, same intent as insert's post-success invalidate.
324
327
  const userId = this.getUserId() ?? 'anonymous';
325
328
  await invalidateCache(userId, collection);
329
+ await refreshCollectionQueries(collection);
326
330
  await cacheRecord(userId, collection, record.id, record.data, record.revision);
327
331
  return { record, created };
328
332
  }
@@ -342,6 +346,7 @@ export class KoolbaseDatabase {
342
346
  const body = await this.request('POST', '/v1/sdk/db/delete-where', { collection, filters });
343
347
  const userId = this.getUserId() ?? 'anonymous';
344
348
  await invalidateCache(userId, collection);
349
+ await refreshCollectionQueries(collection);
345
350
  return body.deleted ?? 0;
346
351
  }
347
352
  // ─── Batch (atomic, online-only) ────────────────────────────────────────────
@@ -405,6 +410,7 @@ export class KoolbaseDatabase {
405
410
  }
406
411
  for (const col of touched) {
407
412
  await invalidateCache(userId, col);
413
+ await refreshCollectionQueries(col);
408
414
  }
409
415
  return results;
410
416
  }
@@ -596,6 +602,7 @@ export class KoolbaseDatabase {
596
602
  }
597
603
  await this.dropConflict(c.id);
598
604
  await invalidateCache(this.getUserId() ?? 'anonymous', c.collection);
605
+ await refreshCollectionQueries(c.collection);
599
606
  }
600
607
  // ─── Update (online-first with offline fallback) ───────────────────────────
601
608
  /**
@@ -659,9 +666,13 @@ export class KoolbaseDatabase {
659
666
  try {
660
667
  const raw = await this.request('PATCH', `/v1/sdk/db/records/${recordId}`, { data });
661
668
  const updated = recordFromWire(raw);
669
+ // Its collection's live queries re-run (every one, if the record's
670
+ // collection is not known).
671
+ const refreshed = updated.collection;
662
672
  if (updated.collection) {
663
673
  await cacheRecord(userId, updated.collection, updated.id, updated.data, updated.revision);
664
674
  }
675
+ await refreshCollectionQueries(refreshed);
665
676
  return updated;
666
677
  }
667
678
  catch (e) {
@@ -708,6 +719,9 @@ export class KoolbaseDatabase {
708
719
  try {
709
720
  await this.request('DELETE', `/v1/sdk/db/records/${recordId}`);
710
721
  await removeCachedRecord(userId, recordId);
722
+ // Its collection is known from the baseline when the record was seen;
723
+ // otherwise every live query re-runs, rather than a guess.
724
+ await refreshCollectionQueries(base?.collection);
711
725
  }
712
726
  catch (e) {
713
727
  // A server that answered has refused: a permission denial or a missing
@@ -12,6 +12,8 @@ export { KoolbaseAuth } from './auth.js';
12
12
  export { KoolbaseDatabase } from './database.js';
13
13
  export { KoolbaseCollectionController, collectionQueryKey, initialCollectionState, } from './collection-controller.js';
14
14
  export type { CollectionQuery, CollectionState, CollectionStatus } from './collection-controller.js';
15
+ export { KoolbaseRecordController, initialRecordState } from './record-controller.js';
16
+ export type { RecordState, RecordStatus } from './record-controller.js';
15
17
  export { KoolbaseFlags } from './flags.js';
16
18
  export { KoolbaseFunctions } from './functions.js';
17
19
  export { KoolbaseRealtime } from './realtime.js';
package/dist/esm/index.js CHANGED
@@ -16,6 +16,7 @@ export * from './storage-errors.js';
16
16
  export { KoolbaseAuth } from './auth.js';
17
17
  export { KoolbaseDatabase } from './database.js';
18
18
  export { KoolbaseCollectionController, collectionQueryKey, initialCollectionState, } from './collection-controller.js';
19
+ export { KoolbaseRecordController, initialRecordState } from './record-controller.js';
19
20
  export { KoolbaseFlags } from './flags.js';
20
21
  export { KoolbaseFunctions } from './functions.js';
21
22
  export { KoolbaseRealtime } from './realtime.js';
@@ -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
+ }
@@ -0,0 +1,36 @@
1
+ import type { KoolbaseDatabase } from './database.js';
2
+ import type { KoolbaseRecord } from './types.js';
3
+ export type RecordStatus = 'loading' | 'loaded' | 'notFound' | 'error';
4
+ export interface RecordState {
5
+ readonly status: RecordStatus;
6
+ readonly record: KoolbaseRecord | null;
7
+ /** Why the first load failed, while status is 'error'. */
8
+ readonly error: unknown;
9
+ /** True while refresh() runs. */
10
+ readonly refreshing: boolean;
11
+ }
12
+ export declare const initialRecordState: RecordState;
13
+ type RecordReader = Pick<KoolbaseDatabase, 'get'>;
14
+ export declare class KoolbaseRecordController {
15
+ readonly collection: string;
16
+ readonly id: string;
17
+ private readonly db;
18
+ private state;
19
+ private readonly listeners;
20
+ private generation;
21
+ private disposed;
22
+ constructor(db: RecordReader, collection: string, id: string);
23
+ getState(): RecordState;
24
+ /** Called on every change. Returns the unsubscribe. */
25
+ subscribe(listener: () => void): () => void;
26
+ /** The first load. Call once; refresh() for later loads. */
27
+ load(): Promise<void>;
28
+ /** The record again, from the server. What is shown stays while it runs, and stays if it fails. */
29
+ refresh(): Promise<void>;
30
+ /** Stops all updates. A result still in flight is dropped when it lands. */
31
+ dispose(): void;
32
+ private fetch;
33
+ private isStale;
34
+ private set;
35
+ }
36
+ export {};
@@ -0,0 +1,106 @@
1
+ // One record, read by id, framework-free: the detail-screen counterpart of
2
+ // KoolbaseCollectionController. @koolbase/react-native's useRecord wraps it.
3
+ //
4
+ // - loading, loaded, notFound or error. notFound is its own state because
5
+ // the API answers a denied read as "not found" too (it does not reveal
6
+ // whether a record exists): a detail screen says "not available", not
7
+ // "something went wrong".
8
+ // - an empty id is notFound with no request: a screen opened without one.
9
+ // - a record from a different collection is notFound: the API names each
10
+ // record's collection, and a mistyped or crafted id must not put another
11
+ // collection's record on this screen.
12
+ // - refresh() keeps the record shown while it runs, and if it fails.
13
+ // - every fetch has a generation: a result superseded by a later refresh,
14
+ // or landing after dispose(), is dropped.
15
+ // Not yet: an offline fallback from the record cache, which keeps data and
16
+ // revision but not createdAt or createdBy.
17
+ import { KoolbaseNotFoundError } from './database-errors.js';
18
+ export const initialRecordState = Object.freeze({
19
+ status: 'loading',
20
+ record: null,
21
+ error: null,
22
+ refreshing: false,
23
+ });
24
+ export class KoolbaseRecordController {
25
+ constructor(db, collection, id) {
26
+ this.state = initialRecordState;
27
+ this.listeners = new Set();
28
+ this.generation = 0;
29
+ this.disposed = false;
30
+ this.db = db;
31
+ this.collection = collection;
32
+ this.id = id;
33
+ }
34
+ getState() {
35
+ return this.state;
36
+ }
37
+ /** Called on every change. Returns the unsubscribe. */
38
+ subscribe(listener) {
39
+ if (this.disposed)
40
+ return () => { };
41
+ this.listeners.add(listener);
42
+ return () => {
43
+ this.listeners.delete(listener);
44
+ };
45
+ }
46
+ /** The first load. Call once; refresh() for later loads. */
47
+ async load() {
48
+ if (this.disposed)
49
+ return;
50
+ await this.fetch();
51
+ }
52
+ /** The record again, from the server. What is shown stays while it runs, and stays if it fails. */
53
+ async refresh() {
54
+ if (this.disposed)
55
+ return;
56
+ this.set({ refreshing: true });
57
+ const gen = await this.fetch();
58
+ if (!this.isStale(gen))
59
+ this.set({ refreshing: false });
60
+ }
61
+ /** Stops all updates. A result still in flight is dropped when it lands. */
62
+ dispose() {
63
+ this.disposed = true;
64
+ this.listeners.clear();
65
+ }
66
+ async fetch() {
67
+ const gen = ++this.generation;
68
+ if (!this.id) {
69
+ this.set({ status: 'notFound', record: null, error: null });
70
+ return gen;
71
+ }
72
+ try {
73
+ const record = await this.db.get(this.id);
74
+ if (this.isStale(gen))
75
+ return gen;
76
+ if (record.collection !== undefined && record.collection !== this.collection) {
77
+ this.set({ status: 'notFound', record: null, error: null });
78
+ }
79
+ else {
80
+ this.set({ status: 'loaded', record, error: null });
81
+ }
82
+ }
83
+ catch (error) {
84
+ if (this.isStale(gen))
85
+ return gen;
86
+ if (error instanceof KoolbaseNotFoundError) {
87
+ this.set({ status: 'notFound', record: null, error: null });
88
+ }
89
+ else if (this.state.record === null) {
90
+ this.set({ status: 'error', error });
91
+ }
92
+ // Otherwise keep the record shown: stale beats blank.
93
+ }
94
+ return gen;
95
+ }
96
+ isStale(gen) {
97
+ return this.disposed || gen !== this.generation;
98
+ }
99
+ set(patch) {
100
+ if (this.disposed)
101
+ return;
102
+ this.state = Object.freeze({ ...this.state, ...patch });
103
+ for (const listener of [...this.listeners])
104
+ listener();
105
+ }
106
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@koolbase/core",
3
- "version": "12.5.1",
4
- "description": "Koolbase SDK core \u2014 shared behaviour behind @koolbase/react-native and @koolbase/js. Install one of those, not this.",
3
+ "version": "12.7.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": [