@koolbase/core 12.3.0 → 12.5.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.
@@ -36,3 +36,9 @@ export declare class KoolbaseAnalytics {
36
36
  flush(): Promise<void>;
37
37
  dispose(): Promise<void>;
38
38
  }
39
+ /**
40
+ * What Koolbase.analytics is while analytics is off (the default since 12.4.0).
41
+ * Every call does nothing, and the first says once how to turn it on, so an app
42
+ * that tracks events without opting in keeps working instead of crashing.
43
+ */
44
+ export declare function disabledAnalytics(): KoolbaseAnalytics;
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.KoolbaseAnalytics = void 0;
4
+ exports.disabledAnalytics = disabledAnalytics;
4
5
  const network_js_1 = require("./network.js");
5
6
  const platform_js_1 = require("./platform.js");
6
7
  const device_id_js_1 = require("./device-id.js");
@@ -129,3 +130,20 @@ class KoolbaseAnalytics {
129
130
  }
130
131
  }
131
132
  exports.KoolbaseAnalytics = KoolbaseAnalytics;
133
+ /**
134
+ * What Koolbase.analytics is while analytics is off (the default since 12.4.0).
135
+ * Every call does nothing, and the first says once how to turn it on, so an app
136
+ * that tracks events without opting in keeps working instead of crashing.
137
+ */
138
+ function disabledAnalytics() {
139
+ let told = false;
140
+ const noop = () => {
141
+ if (!told) {
142
+ told = true;
143
+ // eslint-disable-next-line no-console
144
+ console.info('[Koolbase] Analytics is off: it is opt-in since 12.4.0. Pass analyticsEnabled: true to Koolbase.initialize to send events.');
145
+ }
146
+ return Promise.resolve();
147
+ };
148
+ return new Proxy({}, { get: (_t, prop) => (prop === 'then' ? undefined : noop) });
149
+ }
@@ -1,4 +1,14 @@
1
1
  import { KoolbaseRecord, LegacyPendingWrite, QueryResult } from './types.js';
2
+ /**
3
+ * A query's identity in the cache. Keys are sorted at every depth, so options
4
+ * that differ only in key order ({ a, b } and { b, a }) share one entry.
5
+ * Array order is kept: it can matter. Undefined values and functions are left
6
+ * out, as JSON.stringify does.
7
+ *
8
+ * Changing this only strands earlier entries, which are fetched again and
9
+ * removed by the next invalidateCache for their collection. CACHE_VERSION is
10
+ * not the tool for a clean start: the write queue's key carries it too.
11
+ */
2
12
  export declare function hashQuery(collection: string, options: Record<string, unknown>): string;
3
13
  export declare function getCached(userId: string, collection: string, queryHash: string): Promise<QueryResult | null>;
4
14
  export declare function setCached(userId: string, collection: string, queryHash: string, result: QueryResult): Promise<void>;
@@ -21,8 +21,27 @@ function cacheKey(userId, collection, queryHash) {
21
21
  function writeQueueKey(userId) {
22
22
  return `koolbase:${CACHE_VERSION}:${userId}:write_queue`;
23
23
  }
24
+ /**
25
+ * A query's identity in the cache. Keys are sorted at every depth, so options
26
+ * that differ only in key order ({ a, b } and { b, a }) share one entry.
27
+ * Array order is kept: it can matter. Undefined values and functions are left
28
+ * out, as JSON.stringify does.
29
+ *
30
+ * Changing this only strands earlier entries, which are fetched again and
31
+ * removed by the next invalidateCache for their collection. CACHE_VERSION is
32
+ * not the tool for a clean start: the write queue's key carries it too.
33
+ */
24
34
  function hashQuery(collection, options) {
25
- return `${collection}:${JSON.stringify(options)}`;
35
+ return `${collection}:${JSON.stringify(options, sortKeys)}`;
36
+ }
37
+ function sortKeys(_key, value) {
38
+ if (!value || typeof value !== 'object' || Array.isArray(value))
39
+ return value;
40
+ const source = value;
41
+ const sorted = {};
42
+ for (const key of Object.keys(source).sort())
43
+ sorted[key] = source[key];
44
+ return sorted;
26
45
  }
27
46
  // ─── Cache ──────────────────────────────────────────────────────────────────
28
47
  async function getCached(userId, collection, queryHash) {
@@ -0,0 +1,64 @@
1
+ import type { KoolbaseDatabase } from './database.js';
2
+ import type { KoolbaseRecord } from './types.js';
3
+ export type CollectionStatus = 'loading' | 'loaded' | 'error';
4
+ /** What to read. Equality only: each `where` entry is field = literal value. */
5
+ export interface CollectionQuery {
6
+ where?: Record<string, unknown>;
7
+ orderBy?: string;
8
+ orderDesc?: boolean;
9
+ /** Page size; the server's default (20) when absent. */
10
+ limit?: number;
11
+ }
12
+ export interface CollectionState {
13
+ readonly status: CollectionStatus;
14
+ readonly records: readonly KoolbaseRecord[];
15
+ /** Why the first load failed, while status is 'error'. */
16
+ readonly error: unknown;
17
+ /** True while the records shown came from cache and the server has not answered yet. */
18
+ readonly isFromCache: boolean;
19
+ /** True while refresh() runs. */
20
+ readonly refreshing: boolean;
21
+ /** True while loadMore() runs. */
22
+ readonly loadingMore: boolean;
23
+ /** Whether the server has rows past the last page loaded. True before the first load. */
24
+ readonly hasMore: boolean;
25
+ }
26
+ export declare const initialCollectionState: CollectionState;
27
+ /** One string per distinct query, whatever the order of its `where` keys. */
28
+ export declare function collectionQueryKey(collection: string, query?: CollectionQuery): string;
29
+ type QueryReader = Pick<KoolbaseDatabase, 'query'>;
30
+ export declare class KoolbaseCollectionController {
31
+ readonly collection: string;
32
+ private readonly db;
33
+ private readonly spec;
34
+ private state;
35
+ private readonly listeners;
36
+ private generation;
37
+ private disposed;
38
+ private pageOne;
39
+ private laterPages;
40
+ private nextOffset;
41
+ constructor(db: QueryReader, collection: string, query?: CollectionQuery);
42
+ getState(): CollectionState;
43
+ /** Called on every change. Returns the unsubscribe. */
44
+ subscribe(listener: () => void): () => void;
45
+ /** The first load. Call once; refresh() for later loads. */
46
+ load(): Promise<void>;
47
+ /** Page one again, from the server. What is shown stays while it runs, and stays if it fails. */
48
+ refresh(): Promise<void>;
49
+ /**
50
+ * The next page, appended. Does nothing unless loaded with more to load.
51
+ * If rows are added or removed on the server between pages, one can be
52
+ * skipped until refresh(); none is ever shown twice.
53
+ */
54
+ loadMore(): Promise<void>;
55
+ /** Stops all updates. Results still in flight are dropped when they land. */
56
+ dispose(): void;
57
+ private firstPage;
58
+ private showPageOne;
59
+ private publish;
60
+ private options;
61
+ private isStale;
62
+ private set;
63
+ }
64
+ export {};
@@ -0,0 +1,204 @@
1
+ "use strict";
2
+ // A collection read as a list, framework-free.
3
+ //
4
+ // The data half of a list screen: loading, loaded or error, refresh, and
5
+ // paging. No React here, so it is tested in this package and any host can
6
+ // drive it; @koolbase/react-native's useCollection is a thin wrapper.
7
+ //
8
+ // Mirrors the Flutter SDK's KoolbaseCollectionController:
9
+ // - the first load is cache-first, and when the cache answered, the
10
+ // server's answer replaces it as soon as it lands (query's onRefresh)
11
+ // - only a first load with nothing to show is the error state; a failed
12
+ // refresh or loadMore keeps what is shown (stale beats blank)
13
+ // - loadMore appends the next page while there is more
14
+ // Deliberate differences:
15
+ // - refresh() waits for the server (network-only), so `refreshing` ends
16
+ // when fresh records are in, not when the cache answered
17
+ // - a background refresh replaces page one only; pages loadMore appended
18
+ // after it stay, merged by record id so no row is shown twice
19
+ // - every fetch belongs to a generation: a result from a fetch a later
20
+ // refresh superseded, or one landing after dispose(), is dropped
21
+ //
22
+ // Known limit: if rows are added or removed on the server between pages,
23
+ // the pages shift and a row can be skipped until refresh(), which reloads
24
+ // from page one. Duplicates are removed; a skipped row cannot be detected.
25
+ Object.defineProperty(exports, "__esModule", { value: true });
26
+ exports.KoolbaseCollectionController = exports.initialCollectionState = void 0;
27
+ exports.collectionQueryKey = collectionQueryKey;
28
+ exports.initialCollectionState = Object.freeze({
29
+ status: 'loading',
30
+ records: Object.freeze([]),
31
+ error: null,
32
+ isFromCache: false,
33
+ refreshing: false,
34
+ loadingMore: false,
35
+ hasMore: true,
36
+ });
37
+ function sortedWhere(where) {
38
+ const out = {};
39
+ for (const key of Object.keys(where ?? {}).sort())
40
+ out[key] = where[key];
41
+ return out;
42
+ }
43
+ /** One string per distinct query, whatever the order of its `where` keys. */
44
+ function collectionQueryKey(collection, query = {}) {
45
+ return JSON.stringify([
46
+ collection,
47
+ sortedWhere(query.where),
48
+ query.orderBy ?? null,
49
+ query.orderDesc === true,
50
+ query.limit ?? null,
51
+ ]);
52
+ }
53
+ /** Page one, then the later pages without any record already shown. */
54
+ function mergeById(first, rest) {
55
+ const seen = new Set(first.map((r) => r.id));
56
+ const out = [...first];
57
+ for (const record of rest) {
58
+ if (seen.has(record.id))
59
+ continue;
60
+ seen.add(record.id);
61
+ out.push(record);
62
+ }
63
+ return out;
64
+ }
65
+ class KoolbaseCollectionController {
66
+ constructor(db, collection, query = {}) {
67
+ this.state = exports.initialCollectionState;
68
+ this.listeners = new Set();
69
+ this.generation = 0;
70
+ this.disposed = false;
71
+ this.pageOne = [];
72
+ this.laterPages = [];
73
+ // Where the next page starts on the server. Not records.length: once rows
74
+ // are de-duplicated, a shifted page could otherwise be requested forever.
75
+ this.nextOffset = 0;
76
+ this.db = db;
77
+ this.collection = collection;
78
+ this.spec = query;
79
+ }
80
+ getState() {
81
+ return this.state;
82
+ }
83
+ /** Called on every change. Returns the unsubscribe. */
84
+ subscribe(listener) {
85
+ if (this.disposed)
86
+ return () => { };
87
+ this.listeners.add(listener);
88
+ return () => {
89
+ this.listeners.delete(listener);
90
+ };
91
+ }
92
+ /** The first load. Call once; refresh() for later loads. */
93
+ async load() {
94
+ if (this.disposed)
95
+ return;
96
+ await this.firstPage('default');
97
+ }
98
+ /** Page one again, from the server. What is shown stays while it runs, and stays if it fails. */
99
+ async refresh() {
100
+ if (this.disposed)
101
+ return;
102
+ this.set({ refreshing: true });
103
+ const gen = await this.firstPage('network-only');
104
+ if (!this.isStale(gen))
105
+ this.set({ refreshing: false });
106
+ }
107
+ /**
108
+ * The next page, appended. Does nothing unless loaded with more to load.
109
+ * If rows are added or removed on the server between pages, one can be
110
+ * skipped until refresh(); none is ever shown twice.
111
+ */
112
+ async loadMore() {
113
+ const s = this.state;
114
+ if (this.disposed || s.loadingMore || !s.hasMore || s.status !== 'loaded')
115
+ return;
116
+ const gen = this.generation;
117
+ const offset = this.nextOffset;
118
+ this.set({ loadingMore: true });
119
+ try {
120
+ const result = await this.db.query(this.collection, this.options(offset, 'default'));
121
+ if (this.isStale(gen))
122
+ return;
123
+ this.laterPages = [...this.laterPages, ...result.records];
124
+ this.nextOffset = offset + result.records.length;
125
+ // An empty page is the end, whatever total says: rows were removed.
126
+ const total = result.records.length === 0 ? this.nextOffset : result.total;
127
+ this.publish({}, total);
128
+ }
129
+ catch {
130
+ // Keep what is shown; the caller can try again.
131
+ }
132
+ finally {
133
+ if (!this.isStale(gen))
134
+ this.set({ loadingMore: false });
135
+ }
136
+ }
137
+ /** Stops all updates. Results still in flight are dropped when they land. */
138
+ dispose() {
139
+ this.disposed = true;
140
+ this.listeners.clear();
141
+ }
142
+ async firstPage(cache) {
143
+ const gen = ++this.generation;
144
+ // A loadMore still in flight now belongs to an older generation and
145
+ // will not clear its own flag.
146
+ if (this.state.loadingMore)
147
+ this.set({ loadingMore: false });
148
+ try {
149
+ const result = await this.db.query(this.collection, this.options(0, cache, (fresh) => {
150
+ if (!this.isStale(gen))
151
+ this.showPageOne(fresh, false, true);
152
+ }));
153
+ if (!this.isStale(gen))
154
+ this.showPageOne(result, result.isFromCache === true, false);
155
+ }
156
+ catch (error) {
157
+ if (!this.isStale(gen) && this.state.records.length === 0) {
158
+ this.set({ status: 'error', error });
159
+ }
160
+ }
161
+ return gen;
162
+ }
163
+ showPageOne(result, isFromCache, keepLaterPages) {
164
+ this.pageOne = result.records;
165
+ if (keepLaterPages && this.laterPages.length > 0) {
166
+ this.nextOffset = Math.max(this.nextOffset, result.records.length);
167
+ }
168
+ else {
169
+ this.laterPages = [];
170
+ this.nextOffset = result.records.length;
171
+ }
172
+ this.publish({ status: 'loaded', error: null, isFromCache }, result.total);
173
+ }
174
+ publish(patch, total) {
175
+ this.set({
176
+ ...patch,
177
+ records: mergeById(this.pageOne, this.laterPages),
178
+ hasMore: this.nextOffset < total,
179
+ });
180
+ }
181
+ // Key order does not matter: the SDK's cache key sorts keys (hashQuery).
182
+ options(offset, cache, onRefresh) {
183
+ return {
184
+ filters: this.spec.where ?? {},
185
+ limit: this.spec.limit,
186
+ offset,
187
+ orderBy: this.spec.orderBy,
188
+ orderDesc: this.spec.orderDesc,
189
+ cache,
190
+ onRefresh,
191
+ };
192
+ }
193
+ isStale(gen) {
194
+ return this.disposed || gen !== this.generation;
195
+ }
196
+ set(patch) {
197
+ if (this.disposed)
198
+ return;
199
+ this.state = Object.freeze({ ...this.state, ...patch });
200
+ for (const listener of [...this.listeners])
201
+ listener();
202
+ }
203
+ }
204
+ exports.KoolbaseCollectionController = KoolbaseCollectionController;
@@ -199,13 +199,20 @@ class KoolbaseDatabase {
199
199
  async query(collection, options = {}) {
200
200
  const userId = this.getUserId() ?? 'anonymous';
201
201
  // The cache policy is how to read, not what to read: it stays out of the
202
- // query's identity, so both policies share one cache entry.
203
- const { cache: cachePolicy, ...queryOptions } = options;
202
+ // query's identity, so both policies share one cache entry. onRefresh is
203
+ // who to tell, not what to read, so it stays out the same way.
204
+ const { cache: cachePolicy, onRefresh, ...queryOptions } = options;
204
205
  const queryHash = (0, cache_store_js_2.hashQuery)(collection, queryOptions);
205
206
  const cached = cachePolicy === 'network-only' ? null : await (0, cache_store_js_2.getCached)(userId, collection, queryHash);
206
207
  if (cached) {
207
208
  this.runQuery(collection, queryOptions)
208
- .then(result => (0, cache_store_js_2.setCached)(userId, collection, queryHash, result))
209
+ .then(async (result) => {
210
+ await (0, cache_store_js_2.setCached)(userId, collection, queryHash, result);
211
+ // Told outside the catch below, which is for the network: an
212
+ // exception from the caller's callback is the caller's to see.
213
+ if (onRefresh)
214
+ queueMicrotask(() => onRefresh({ ...result, isFromCache: false }));
215
+ })
209
216
  .catch(() => {
210
217
  // Network unavailable — cached data already returned
211
218
  });
@@ -10,11 +10,13 @@ export * from './database-errors.js';
10
10
  export * from './storage-errors.js';
11
11
  export { KoolbaseAuth } from './auth.js';
12
12
  export { KoolbaseDatabase } from './database.js';
13
+ export { KoolbaseCollectionController, collectionQueryKey, initialCollectionState, } from './collection-controller.js';
14
+ export type { CollectionQuery, CollectionState, CollectionStatus } from './collection-controller.js';
13
15
  export { KoolbaseFlags } from './flags.js';
14
16
  export { KoolbaseFunctions } from './functions.js';
15
17
  export { KoolbaseRealtime } from './realtime.js';
16
18
  export { KoolbaseStorage } from './storage.js';
17
- export { KoolbaseAnalytics } from './analytics.js';
19
+ export { KoolbaseAnalytics, disabledAnalytics } from './analytics.js';
18
20
  export { KoolbaseFiscal, KoolbaseFiscalError, decodeFiscalResponse, isFiscalized, isPending } from './fiscal.js';
19
21
  export type { FiscalIntentResult, FiscalStatus } from './fiscal.js';
20
22
  export { KoolbaseMessaging } from './messaging.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.KoolbaseAnalytics = exports.KoolbaseStorage = exports.KoolbaseRealtime = exports.KoolbaseFunctions = exports.KoolbaseFlags = 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.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);
@@ -34,6 +34,10 @@ var auth_js_1 = require("./auth.js");
34
34
  Object.defineProperty(exports, "KoolbaseAuth", { enumerable: true, get: function () { return auth_js_1.KoolbaseAuth; } });
35
35
  var database_js_1 = require("./database.js");
36
36
  Object.defineProperty(exports, "KoolbaseDatabase", { enumerable: true, get: function () { return database_js_1.KoolbaseDatabase; } });
37
+ var collection_controller_js_1 = require("./collection-controller.js");
38
+ Object.defineProperty(exports, "KoolbaseCollectionController", { enumerable: true, get: function () { return collection_controller_js_1.KoolbaseCollectionController; } });
39
+ Object.defineProperty(exports, "collectionQueryKey", { enumerable: true, get: function () { return collection_controller_js_1.collectionQueryKey; } });
40
+ Object.defineProperty(exports, "initialCollectionState", { enumerable: true, get: function () { return collection_controller_js_1.initialCollectionState; } });
37
41
  var flags_js_1 = require("./flags.js");
38
42
  Object.defineProperty(exports, "KoolbaseFlags", { enumerable: true, get: function () { return flags_js_1.KoolbaseFlags; } });
39
43
  var functions_js_1 = require("./functions.js");
@@ -44,6 +48,7 @@ var storage_js_1 = require("./storage.js");
44
48
  Object.defineProperty(exports, "KoolbaseStorage", { enumerable: true, get: function () { return storage_js_1.KoolbaseStorage; } });
45
49
  var analytics_js_1 = require("./analytics.js");
46
50
  Object.defineProperty(exports, "KoolbaseAnalytics", { enumerable: true, get: function () { return analytics_js_1.KoolbaseAnalytics; } });
51
+ Object.defineProperty(exports, "disabledAnalytics", { enumerable: true, get: function () { return analytics_js_1.disabledAnalytics; } });
47
52
  var fiscal_js_1 = require("./fiscal.js");
48
53
  Object.defineProperty(exports, "KoolbaseFiscal", { enumerable: true, get: function () { return fiscal_js_1.KoolbaseFiscal; } });
49
54
  Object.defineProperty(exports, "KoolbaseFiscalError", { enumerable: true, get: function () { return fiscal_js_1.KoolbaseFiscalError; } });
@@ -11,6 +11,8 @@
11
11
  */
12
12
  export declare class KoolbaseNetworkError extends TypeError {
13
13
  code: string;
14
+ /** A short message that is safe to show to people; `message` is for developers. */
15
+ readonly userMessage = "We can't connect right now. Check your connection and try again.";
14
16
  readonly url: string;
15
17
  readonly origin?: string;
16
18
  constructor(url: string, cause: unknown);
@@ -31,6 +31,8 @@ class KoolbaseNetworkError extends TypeError {
31
31
  : ' Check the network connection and the configured base URL.';
32
32
  super(message);
33
33
  this.code = 'network_error';
34
+ /** A short message that is safe to show to people; `message` is for developers. */
35
+ this.userMessage = "We can't connect right now. Check your connection and try again.";
34
36
  this.name = 'KoolbaseNetworkError';
35
37
  this.url = url;
36
38
  this.origin = origin;
@@ -246,6 +246,13 @@ export interface QueryOptions {
246
246
  * make, by a Function or another user, when you need the server's state now.
247
247
  */
248
248
  cache?: 'default' | 'network-only';
249
+ /**
250
+ * Called once with the server's result when a cache hit's background
251
+ * refresh lands, so a screen showing the cached result can show the fresh
252
+ * one. Not called on a cache miss (the awaited result is already the
253
+ * server's) or when the refresh fails. Not part of the query's identity.
254
+ */
255
+ onRefresh?: (result: QueryResult) => void;
249
256
  }
250
257
  export interface QueryResult {
251
258
  records: KoolbaseRecord[];
@@ -36,3 +36,9 @@ export declare class KoolbaseAnalytics {
36
36
  flush(): Promise<void>;
37
37
  dispose(): Promise<void>;
38
38
  }
39
+ /**
40
+ * What Koolbase.analytics is while analytics is off (the default since 12.4.0).
41
+ * Every call does nothing, and the first says once how to turn it on, so an app
42
+ * that tracks events without opting in keeps working instead of crashing.
43
+ */
44
+ export declare function disabledAnalytics(): KoolbaseAnalytics;
@@ -125,3 +125,20 @@ export class KoolbaseAnalytics {
125
125
  await this.flush();
126
126
  }
127
127
  }
128
+ /**
129
+ * What Koolbase.analytics is while analytics is off (the default since 12.4.0).
130
+ * Every call does nothing, and the first says once how to turn it on, so an app
131
+ * that tracks events without opting in keeps working instead of crashing.
132
+ */
133
+ export function disabledAnalytics() {
134
+ let told = false;
135
+ const noop = () => {
136
+ if (!told) {
137
+ told = true;
138
+ // eslint-disable-next-line no-console
139
+ console.info('[Koolbase] Analytics is off: it is opt-in since 12.4.0. Pass analyticsEnabled: true to Koolbase.initialize to send events.');
140
+ }
141
+ return Promise.resolve();
142
+ };
143
+ return new Proxy({}, { get: (_t, prop) => (prop === 'then' ? undefined : noop) });
144
+ }
@@ -1,4 +1,14 @@
1
1
  import { KoolbaseRecord, LegacyPendingWrite, QueryResult } from './types.js';
2
+ /**
3
+ * A query's identity in the cache. Keys are sorted at every depth, so options
4
+ * that differ only in key order ({ a, b } and { b, a }) share one entry.
5
+ * Array order is kept: it can matter. Undefined values and functions are left
6
+ * out, as JSON.stringify does.
7
+ *
8
+ * Changing this only strands earlier entries, which are fetched again and
9
+ * removed by the next invalidateCache for their collection. CACHE_VERSION is
10
+ * not the tool for a clean start: the write queue's key carries it too.
11
+ */
2
12
  export declare function hashQuery(collection: string, options: Record<string, unknown>): string;
3
13
  export declare function getCached(userId: string, collection: string, queryHash: string): Promise<QueryResult | null>;
4
14
  export declare function setCached(userId: string, collection: string, queryHash: string, result: QueryResult): Promise<void>;
@@ -6,8 +6,27 @@ function cacheKey(userId, collection, queryHash) {
6
6
  function writeQueueKey(userId) {
7
7
  return `koolbase:${CACHE_VERSION}:${userId}:write_queue`;
8
8
  }
9
+ /**
10
+ * A query's identity in the cache. Keys are sorted at every depth, so options
11
+ * that differ only in key order ({ a, b } and { b, a }) share one entry.
12
+ * Array order is kept: it can matter. Undefined values and functions are left
13
+ * out, as JSON.stringify does.
14
+ *
15
+ * Changing this only strands earlier entries, which are fetched again and
16
+ * removed by the next invalidateCache for their collection. CACHE_VERSION is
17
+ * not the tool for a clean start: the write queue's key carries it too.
18
+ */
9
19
  export function hashQuery(collection, options) {
10
- return `${collection}:${JSON.stringify(options)}`;
20
+ return `${collection}:${JSON.stringify(options, sortKeys)}`;
21
+ }
22
+ function sortKeys(_key, value) {
23
+ if (!value || typeof value !== 'object' || Array.isArray(value))
24
+ return value;
25
+ const source = value;
26
+ const sorted = {};
27
+ for (const key of Object.keys(source).sort())
28
+ sorted[key] = source[key];
29
+ return sorted;
11
30
  }
12
31
  // ─── Cache ──────────────────────────────────────────────────────────────────
13
32
  export async function getCached(userId, collection, queryHash) {
@@ -0,0 +1,64 @@
1
+ import type { KoolbaseDatabase } from './database.js';
2
+ import type { KoolbaseRecord } from './types.js';
3
+ export type CollectionStatus = 'loading' | 'loaded' | 'error';
4
+ /** What to read. Equality only: each `where` entry is field = literal value. */
5
+ export interface CollectionQuery {
6
+ where?: Record<string, unknown>;
7
+ orderBy?: string;
8
+ orderDesc?: boolean;
9
+ /** Page size; the server's default (20) when absent. */
10
+ limit?: number;
11
+ }
12
+ export interface CollectionState {
13
+ readonly status: CollectionStatus;
14
+ readonly records: readonly KoolbaseRecord[];
15
+ /** Why the first load failed, while status is 'error'. */
16
+ readonly error: unknown;
17
+ /** True while the records shown came from cache and the server has not answered yet. */
18
+ readonly isFromCache: boolean;
19
+ /** True while refresh() runs. */
20
+ readonly refreshing: boolean;
21
+ /** True while loadMore() runs. */
22
+ readonly loadingMore: boolean;
23
+ /** Whether the server has rows past the last page loaded. True before the first load. */
24
+ readonly hasMore: boolean;
25
+ }
26
+ export declare const initialCollectionState: CollectionState;
27
+ /** One string per distinct query, whatever the order of its `where` keys. */
28
+ export declare function collectionQueryKey(collection: string, query?: CollectionQuery): string;
29
+ type QueryReader = Pick<KoolbaseDatabase, 'query'>;
30
+ export declare class KoolbaseCollectionController {
31
+ readonly collection: string;
32
+ private readonly db;
33
+ private readonly spec;
34
+ private state;
35
+ private readonly listeners;
36
+ private generation;
37
+ private disposed;
38
+ private pageOne;
39
+ private laterPages;
40
+ private nextOffset;
41
+ constructor(db: QueryReader, collection: string, query?: CollectionQuery);
42
+ getState(): CollectionState;
43
+ /** Called on every change. Returns the unsubscribe. */
44
+ subscribe(listener: () => void): () => void;
45
+ /** The first load. Call once; refresh() for later loads. */
46
+ load(): Promise<void>;
47
+ /** Page one again, from the server. What is shown stays while it runs, and stays if it fails. */
48
+ refresh(): Promise<void>;
49
+ /**
50
+ * The next page, appended. Does nothing unless loaded with more to load.
51
+ * If rows are added or removed on the server between pages, one can be
52
+ * skipped until refresh(); none is ever shown twice.
53
+ */
54
+ loadMore(): Promise<void>;
55
+ /** Stops all updates. Results still in flight are dropped when they land. */
56
+ dispose(): void;
57
+ private firstPage;
58
+ private showPageOne;
59
+ private publish;
60
+ private options;
61
+ private isStale;
62
+ private set;
63
+ }
64
+ export {};
@@ -0,0 +1,199 @@
1
+ // A collection read as a list, framework-free.
2
+ //
3
+ // The data half of a list screen: loading, loaded or error, refresh, and
4
+ // paging. No React here, so it is tested in this package and any host can
5
+ // drive it; @koolbase/react-native's useCollection is a thin wrapper.
6
+ //
7
+ // Mirrors the Flutter SDK's KoolbaseCollectionController:
8
+ // - the first load is cache-first, and when the cache answered, the
9
+ // server's answer replaces it as soon as it lands (query's onRefresh)
10
+ // - only a first load with nothing to show is the error state; a failed
11
+ // refresh or loadMore keeps what is shown (stale beats blank)
12
+ // - loadMore appends the next page while there is more
13
+ // Deliberate differences:
14
+ // - refresh() waits for the server (network-only), so `refreshing` ends
15
+ // when fresh records are in, not when the cache answered
16
+ // - a background refresh replaces page one only; pages loadMore appended
17
+ // after it stay, merged by record id so no row is shown twice
18
+ // - every fetch belongs to a generation: a result from a fetch a later
19
+ // refresh superseded, or one landing after dispose(), is dropped
20
+ //
21
+ // Known limit: if rows are added or removed on the server between pages,
22
+ // the pages shift and a row can be skipped until refresh(), which reloads
23
+ // from page one. Duplicates are removed; a skipped row cannot be detected.
24
+ export const initialCollectionState = Object.freeze({
25
+ status: 'loading',
26
+ records: Object.freeze([]),
27
+ error: null,
28
+ isFromCache: false,
29
+ refreshing: false,
30
+ loadingMore: false,
31
+ hasMore: true,
32
+ });
33
+ function sortedWhere(where) {
34
+ const out = {};
35
+ for (const key of Object.keys(where ?? {}).sort())
36
+ out[key] = where[key];
37
+ return out;
38
+ }
39
+ /** One string per distinct query, whatever the order of its `where` keys. */
40
+ export function collectionQueryKey(collection, query = {}) {
41
+ return JSON.stringify([
42
+ collection,
43
+ sortedWhere(query.where),
44
+ query.orderBy ?? null,
45
+ query.orderDesc === true,
46
+ query.limit ?? null,
47
+ ]);
48
+ }
49
+ /** Page one, then the later pages without any record already shown. */
50
+ function mergeById(first, rest) {
51
+ const seen = new Set(first.map((r) => r.id));
52
+ const out = [...first];
53
+ for (const record of rest) {
54
+ if (seen.has(record.id))
55
+ continue;
56
+ seen.add(record.id);
57
+ out.push(record);
58
+ }
59
+ return out;
60
+ }
61
+ export class KoolbaseCollectionController {
62
+ constructor(db, collection, query = {}) {
63
+ this.state = initialCollectionState;
64
+ this.listeners = new Set();
65
+ this.generation = 0;
66
+ this.disposed = false;
67
+ this.pageOne = [];
68
+ this.laterPages = [];
69
+ // Where the next page starts on the server. Not records.length: once rows
70
+ // are de-duplicated, a shifted page could otherwise be requested forever.
71
+ this.nextOffset = 0;
72
+ this.db = db;
73
+ this.collection = collection;
74
+ this.spec = query;
75
+ }
76
+ getState() {
77
+ return this.state;
78
+ }
79
+ /** Called on every change. Returns the unsubscribe. */
80
+ subscribe(listener) {
81
+ if (this.disposed)
82
+ return () => { };
83
+ this.listeners.add(listener);
84
+ return () => {
85
+ this.listeners.delete(listener);
86
+ };
87
+ }
88
+ /** The first load. Call once; refresh() for later loads. */
89
+ async load() {
90
+ if (this.disposed)
91
+ return;
92
+ await this.firstPage('default');
93
+ }
94
+ /** Page one again, from the server. What is shown stays while it runs, and stays if it fails. */
95
+ async refresh() {
96
+ if (this.disposed)
97
+ return;
98
+ this.set({ refreshing: true });
99
+ const gen = await this.firstPage('network-only');
100
+ if (!this.isStale(gen))
101
+ this.set({ refreshing: false });
102
+ }
103
+ /**
104
+ * The next page, appended. Does nothing unless loaded with more to load.
105
+ * If rows are added or removed on the server between pages, one can be
106
+ * skipped until refresh(); none is ever shown twice.
107
+ */
108
+ async loadMore() {
109
+ const s = this.state;
110
+ if (this.disposed || s.loadingMore || !s.hasMore || s.status !== 'loaded')
111
+ return;
112
+ const gen = this.generation;
113
+ const offset = this.nextOffset;
114
+ this.set({ loadingMore: true });
115
+ try {
116
+ const result = await this.db.query(this.collection, this.options(offset, 'default'));
117
+ if (this.isStale(gen))
118
+ return;
119
+ this.laterPages = [...this.laterPages, ...result.records];
120
+ this.nextOffset = offset + result.records.length;
121
+ // An empty page is the end, whatever total says: rows were removed.
122
+ const total = result.records.length === 0 ? this.nextOffset : result.total;
123
+ this.publish({}, total);
124
+ }
125
+ catch {
126
+ // Keep what is shown; the caller can try again.
127
+ }
128
+ finally {
129
+ if (!this.isStale(gen))
130
+ this.set({ loadingMore: false });
131
+ }
132
+ }
133
+ /** Stops all updates. Results still in flight are dropped when they land. */
134
+ dispose() {
135
+ this.disposed = true;
136
+ this.listeners.clear();
137
+ }
138
+ async firstPage(cache) {
139
+ const gen = ++this.generation;
140
+ // A loadMore still in flight now belongs to an older generation and
141
+ // will not clear its own flag.
142
+ if (this.state.loadingMore)
143
+ this.set({ loadingMore: false });
144
+ try {
145
+ const result = await this.db.query(this.collection, this.options(0, cache, (fresh) => {
146
+ if (!this.isStale(gen))
147
+ this.showPageOne(fresh, false, true);
148
+ }));
149
+ if (!this.isStale(gen))
150
+ this.showPageOne(result, result.isFromCache === true, false);
151
+ }
152
+ catch (error) {
153
+ if (!this.isStale(gen) && this.state.records.length === 0) {
154
+ this.set({ status: 'error', error });
155
+ }
156
+ }
157
+ return gen;
158
+ }
159
+ showPageOne(result, isFromCache, keepLaterPages) {
160
+ this.pageOne = result.records;
161
+ if (keepLaterPages && this.laterPages.length > 0) {
162
+ this.nextOffset = Math.max(this.nextOffset, result.records.length);
163
+ }
164
+ else {
165
+ this.laterPages = [];
166
+ this.nextOffset = result.records.length;
167
+ }
168
+ this.publish({ status: 'loaded', error: null, isFromCache }, result.total);
169
+ }
170
+ publish(patch, total) {
171
+ this.set({
172
+ ...patch,
173
+ records: mergeById(this.pageOne, this.laterPages),
174
+ hasMore: this.nextOffset < total,
175
+ });
176
+ }
177
+ // Key order does not matter: the SDK's cache key sorts keys (hashQuery).
178
+ options(offset, cache, onRefresh) {
179
+ return {
180
+ filters: this.spec.where ?? {},
181
+ limit: this.spec.limit,
182
+ offset,
183
+ orderBy: this.spec.orderBy,
184
+ orderDesc: this.spec.orderDesc,
185
+ cache,
186
+ onRefresh,
187
+ };
188
+ }
189
+ isStale(gen) {
190
+ return this.disposed || gen !== this.generation;
191
+ }
192
+ set(patch) {
193
+ if (this.disposed)
194
+ return;
195
+ this.state = Object.freeze({ ...this.state, ...patch });
196
+ for (const listener of [...this.listeners])
197
+ listener();
198
+ }
199
+ }
@@ -196,13 +196,20 @@ export class KoolbaseDatabase {
196
196
  async query(collection, options = {}) {
197
197
  const userId = this.getUserId() ?? 'anonymous';
198
198
  // The cache policy is how to read, not what to read: it stays out of the
199
- // query's identity, so both policies share one cache entry.
200
- const { cache: cachePolicy, ...queryOptions } = options;
199
+ // query's identity, so both policies share one cache entry. onRefresh is
200
+ // who to tell, not what to read, so it stays out the same way.
201
+ const { cache: cachePolicy, onRefresh, ...queryOptions } = options;
201
202
  const queryHash = hashQuery(collection, queryOptions);
202
203
  const cached = cachePolicy === 'network-only' ? null : await getCached(userId, collection, queryHash);
203
204
  if (cached) {
204
205
  this.runQuery(collection, queryOptions)
205
- .then(result => setCached(userId, collection, queryHash, result))
206
+ .then(async (result) => {
207
+ await setCached(userId, collection, queryHash, result);
208
+ // Told outside the catch below, which is for the network: an
209
+ // exception from the caller's callback is the caller's to see.
210
+ if (onRefresh)
211
+ queueMicrotask(() => onRefresh({ ...result, isFromCache: false }));
212
+ })
206
213
  .catch(() => {
207
214
  // Network unavailable — cached data already returned
208
215
  });
@@ -10,11 +10,13 @@ export * from './database-errors.js';
10
10
  export * from './storage-errors.js';
11
11
  export { KoolbaseAuth } from './auth.js';
12
12
  export { KoolbaseDatabase } from './database.js';
13
+ export { KoolbaseCollectionController, collectionQueryKey, initialCollectionState, } from './collection-controller.js';
14
+ export type { CollectionQuery, CollectionState, CollectionStatus } from './collection-controller.js';
13
15
  export { KoolbaseFlags } from './flags.js';
14
16
  export { KoolbaseFunctions } from './functions.js';
15
17
  export { KoolbaseRealtime } from './realtime.js';
16
18
  export { KoolbaseStorage } from './storage.js';
17
- export { KoolbaseAnalytics } from './analytics.js';
19
+ export { KoolbaseAnalytics, disabledAnalytics } from './analytics.js';
18
20
  export { KoolbaseFiscal, KoolbaseFiscalError, decodeFiscalResponse, isFiscalized, isPending } from './fiscal.js';
19
21
  export type { FiscalIntentResult, FiscalStatus } from './fiscal.js';
20
22
  export { KoolbaseMessaging } from './messaging.js';
package/dist/esm/index.js CHANGED
@@ -15,11 +15,12 @@ export * from './database-errors.js';
15
15
  export * from './storage-errors.js';
16
16
  export { KoolbaseAuth } from './auth.js';
17
17
  export { KoolbaseDatabase } from './database.js';
18
+ export { KoolbaseCollectionController, collectionQueryKey, initialCollectionState, } from './collection-controller.js';
18
19
  export { KoolbaseFlags } from './flags.js';
19
20
  export { KoolbaseFunctions } from './functions.js';
20
21
  export { KoolbaseRealtime } from './realtime.js';
21
22
  export { KoolbaseStorage } from './storage.js';
22
- export { KoolbaseAnalytics } from './analytics.js';
23
+ export { KoolbaseAnalytics, disabledAnalytics } from './analytics.js';
23
24
  export { KoolbaseFiscal, KoolbaseFiscalError, decodeFiscalResponse, isFiscalized, isPending } from './fiscal.js';
24
25
  export { KoolbaseMessaging } from './messaging.js';
25
26
  export { getOrCreateDeviceId } from './device-id.js';
@@ -11,6 +11,8 @@
11
11
  */
12
12
  export declare class KoolbaseNetworkError extends TypeError {
13
13
  code: string;
14
+ /** A short message that is safe to show to people; `message` is for developers. */
15
+ readonly userMessage = "We can't connect right now. Check your connection and try again.";
14
16
  readonly url: string;
15
17
  readonly origin?: string;
16
18
  constructor(url: string, cause: unknown);
@@ -27,6 +27,8 @@ export class KoolbaseNetworkError extends TypeError {
27
27
  : ' Check the network connection and the configured base URL.';
28
28
  super(message);
29
29
  this.code = 'network_error';
30
+ /** A short message that is safe to show to people; `message` is for developers. */
31
+ this.userMessage = "We can't connect right now. Check your connection and try again.";
30
32
  this.name = 'KoolbaseNetworkError';
31
33
  this.url = url;
32
34
  this.origin = origin;
@@ -246,6 +246,13 @@ export interface QueryOptions {
246
246
  * make, by a Function or another user, when you need the server's state now.
247
247
  */
248
248
  cache?: 'default' | 'network-only';
249
+ /**
250
+ * Called once with the server's result when a cache hit's background
251
+ * refresh lands, so a screen showing the cached result can show the fresh
252
+ * one. Not called on a cache miss (the awaited result is already the
253
+ * server's) or when the refresh fails. Not part of the query's identity.
254
+ */
255
+ onRefresh?: (result: QueryResult) => void;
249
256
  }
250
257
  export interface QueryResult {
251
258
  records: KoolbaseRecord[];
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@koolbase/core",
3
- "version": "12.3.0",
4
- "description": "Koolbase SDK core — shared behaviour behind @koolbase/react-native and @koolbase/js. Install one of those, not this.",
3
+ "version": "12.5.0",
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",
7
7
  "files": [