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