@koolbase/core 12.4.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.
- package/dist/cjs/cache-store.d.ts +10 -0
- package/dist/cjs/cache-store.js +20 -1
- package/dist/cjs/collection-controller.d.ts +64 -0
- package/dist/cjs/collection-controller.js +204 -0
- package/dist/cjs/database.js +10 -3
- package/dist/cjs/index.d.ts +2 -0
- package/dist/cjs/index.js +5 -1
- package/dist/cjs/types.d.ts +7 -0
- package/dist/esm/cache-store.d.ts +10 -0
- package/dist/esm/cache-store.js +20 -1
- package/dist/esm/collection-controller.d.ts +64 -0
- package/dist/esm/collection-controller.js +199 -0
- package/dist/esm/database.js +10 -3
- package/dist/esm/index.d.ts +2 -0
- package/dist/esm/index.js +1 -0
- package/dist/esm/types.d.ts +7 -0
- package/package.json +1 -1
|
@@ -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>;
|
package/dist/cjs/cache-store.js
CHANGED
|
@@ -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;
|
package/dist/cjs/database.js
CHANGED
|
@@ -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
|
-
|
|
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(
|
|
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
|
});
|
package/dist/cjs/index.d.ts
CHANGED
|
@@ -10,6 +10,8 @@ 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';
|
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.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");
|
package/dist/cjs/types.d.ts
CHANGED
|
@@ -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[];
|
|
@@ -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>;
|
package/dist/esm/cache-store.js
CHANGED
|
@@ -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
|
+
}
|
package/dist/esm/database.js
CHANGED
|
@@ -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
|
-
|
|
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 =>
|
|
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
|
});
|
package/dist/esm/index.d.ts
CHANGED
|
@@ -10,6 +10,8 @@ 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';
|
package/dist/esm/index.js
CHANGED
|
@@ -15,6 +15,7 @@ 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';
|
package/dist/esm/types.d.ts
CHANGED
|
@@ -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,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@koolbase/core",
|
|
3
|
-
"version": "12.
|
|
3
|
+
"version": "12.5.0",
|
|
4
4
|
"description": "Koolbase SDK core \u2014 shared behaviour behind @koolbase/react-native and @koolbase/js. Install one of those, not this.",
|
|
5
5
|
"main": "./dist/cjs/index.js",
|
|
6
6
|
"types": "./dist/esm/index.d.ts",
|