@koolbase/core 12.13.0 → 12.14.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.
@@ -8,6 +8,8 @@ exports.readOfflineState = readOfflineState;
8
8
  exports.mutateOfflineState = mutateOfflineState;
9
9
  exports.queueWrite = queueWrite;
10
10
  exports.migrateLegacyQueue = migrateLegacyQueue;
11
+ exports.rebaseHeldWrites = rebaseHeldWrites;
12
+ exports.onOfflineStateChange = onOfflineStateChange;
11
13
  const platform_js_1 = require("./platform.js");
12
14
  const shared_js_1 = require("./shared.js");
13
15
  /**
@@ -141,6 +143,7 @@ async function mutateOfflineState(userId, mutate) {
141
143
  }
142
144
  await (0, platform_js_1.getPlatform)().storage.setItem(stateKey(userId), serialised);
143
145
  });
146
+ announce(userId);
144
147
  }
145
148
  /** Adds a write to the queue, under the user's lock. */
146
149
  async function queueWrite(userId, write) {
@@ -213,3 +216,70 @@ async function migrateLegacyQueue(userId) {
213
216
  });
214
217
  await (0, platform_js_1.getPlatform)().storage.removeItem(legacyQueueKey(userId));
215
218
  }
219
+ /**
220
+ * Points the writes still queued for a record at the state it is now in.
221
+ *
222
+ * When a conflict is decided, the writes held behind it carry baselines
223
+ * describing the state the conflicted write would have produced -- a state
224
+ * that no longer exists, and may never have. Released unchanged they would
225
+ * replay against a revision that has moved on, and each would conflict in
226
+ * turn: one disagreement multiplying into as many conflicts as the user had
227
+ * queued. The same rule as the Flutter SDK's rebaseAfterResolution.
228
+ *
229
+ * Refreshing the baseline is enough because every operation is an absolute
230
+ * assignment: a patch says what a field becomes, not what to do to it. If a
231
+ * relative operation is ever added (an increment, an append), the chain must
232
+ * be replayed through its projected states instead.
233
+ */
234
+ function rebaseHeldWrites(state, recordId, resolved, revision) {
235
+ let current = {};
236
+ for (const [k, v] of Object.entries(resolved)) {
237
+ if (!k.startsWith('$'))
238
+ current[k] = v;
239
+ }
240
+ for (const w of state.pending) {
241
+ if (w.recordId !== recordId)
242
+ continue;
243
+ w.baseline = { ...current };
244
+ w.baseRevision = revision;
245
+ // Each write is composed against the one before it.
246
+ if (w.operation === 'update')
247
+ current = { ...current, ...(w.data ?? {}) };
248
+ else if (w.operation === 'delete')
249
+ break;
250
+ }
251
+ }
252
+ // ─── Change notification ─────────────────────────────────────────────────────
253
+ const ANNOUNCE_PREFIX = 'koolbase:offline-state:';
254
+ function stateListeners() {
255
+ return (0, shared_js_1.shared)('offlineStateListeners', () => new Set());
256
+ }
257
+ /**
258
+ * Tells whoever is watching that a user's offline state changed -- here, and
259
+ * in every other copy of the SDK sharing this storage (other tabs). A badge in
260
+ * one tab must move when another tab's sync drains the queue.
261
+ */
262
+ function announce(userId) {
263
+ for (const listener of [...stateListeners()]) {
264
+ try {
265
+ listener(userId);
266
+ }
267
+ catch { /* one broken listener must not stop the rest */ }
268
+ }
269
+ try {
270
+ (0, platform_js_1.getPlatform)().broadcast?.post(ANNOUNCE_PREFIX + userId);
271
+ }
272
+ catch { /* best effort */ }
273
+ }
274
+ /** Called with the user id whenever that user's offline state changes. */
275
+ function onOfflineStateChange(listener) {
276
+ stateListeners().add(listener);
277
+ const off = (0, platform_js_1.getPlatform)().broadcast?.on((message) => {
278
+ if (message.startsWith(ANNOUNCE_PREFIX))
279
+ listener(message.slice(ANNOUNCE_PREFIX.length));
280
+ });
281
+ return () => {
282
+ stateListeners().delete(listener);
283
+ off?.();
284
+ };
285
+ }
@@ -12,6 +12,21 @@ export interface PlatformNetwork {
12
12
  * Koolbase is unreachable is handled the same as any failed request.
13
13
  */
14
14
  onChange(callback: (online: boolean) => void): () => void;
15
+ /**
16
+ * What the host believes right now: true online, false offline, null when
17
+ * it cannot tell. Optional: a host without it reports nothing until its
18
+ * first change, and connectivity stays 'unknown' until then.
19
+ */
20
+ current?(): Promise<boolean | null>;
21
+ }
22
+ /**
23
+ * A message to every other copy of the SDK sharing this storage -- other tabs
24
+ * of the same origin in a browser. Optional: one process (React Native) has no
25
+ * other copies to tell.
26
+ */
27
+ export interface PlatformBroadcast {
28
+ post(message: string): void;
29
+ on(callback: (message: string) => void): () => void;
15
30
  }
16
31
  export interface PlatformLifecycle {
17
32
  /** Subscribe to the app moving to the background. Used to flush analytics. */
@@ -54,6 +69,7 @@ export interface PlatformAdapter {
54
69
  lifecycle: PlatformLifecycle;
55
70
  info: PlatformInfo;
56
71
  locks: PlatformLocks;
72
+ broadcast?: PlatformBroadcast;
57
73
  /**
58
74
  * The host's best persistent store for the auth session, or null if it has
59
75
  * none worth the name. Used only when the app injects nothing through
@@ -3,6 +3,6 @@ export interface UseRecordResult extends RecordState {
3
3
  /** The record again, from the server. What is shown stays while it runs, and stays if it fails. */
4
4
  refresh: () => Promise<void>;
5
5
  }
6
- export declare function createUseRecord(getDb: () => Pick<KoolbaseDatabase, 'get'>): (collection: string, id: string | null | undefined, options?: {
6
+ export declare function createUseRecord(getDb: () => Pick<KoolbaseDatabase, 'get'> & Partial<Pick<KoolbaseDatabase, 'getSaved'>>): (collection: string, id: string | null | undefined, options?: {
7
7
  live?: boolean;
8
8
  }) => UseRecordResult;
@@ -8,9 +8,14 @@ export interface RecordState {
8
8
  readonly error: unknown;
9
9
  /** True while refresh() runs. */
10
10
  readonly refreshing: boolean;
11
+ /**
12
+ * True while the record shown is the device's saved copy and the server has
13
+ * not confirmed it on this load. Stays true if the server cannot be reached.
14
+ */
15
+ readonly isSaved: boolean;
11
16
  }
12
17
  export declare const initialRecordState: RecordState;
13
- type RecordReader = Pick<KoolbaseDatabase, 'get'>;
18
+ type RecordReader = Pick<KoolbaseDatabase, 'get'> & Partial<Pick<KoolbaseDatabase, 'getSaved'>>;
14
19
  export declare class KoolbaseRecordController {
15
20
  readonly collection: string;
16
21
  readonly id: string;
@@ -31,6 +36,8 @@ export declare class KoolbaseRecordController {
31
36
  subscribe(listener: () => void): () => void;
32
37
  /** The first load. Call once; refresh() for later loads. */
33
38
  load(): Promise<void>;
39
+ /** The device's saved copy, shown at once while the server is asked. */
40
+ private showSaved;
34
41
  /** The record again, from the server. What is shown stays while it runs, and stays if it fails. */
35
42
  refresh(): Promise<void>;
36
43
  /** Stops all updates. A result still in flight is dropped when it lands. */
@@ -15,8 +15,10 @@
15
15
  // or landing after dispose(), is dropped.
16
16
  // - live (the option): realtime events for THIS record only. An update
17
17
  // re-reads it silently, once per burst; a delete is notFound at once.
18
- // Not yet: an offline fallback from the record cache, which keeps data and
19
- // revision but not createdAt or createdBy.
18
+ // - saved-first, never saved-only: the device's saved copy (db.getSaved)
19
+ // shows at once with isSaved true while the server is asked. Success
20
+ // replaces it (isSaved false); failure keeps it (isSaved stays true); not
21
+ // found is notFound. No saved copy: the ordinary loading/error states.
20
22
  Object.defineProperty(exports, "__esModule", { value: true });
21
23
  exports.KoolbaseRecordController = exports.initialRecordState = void 0;
22
24
  const database_errors_js_1 = require("./database-errors.js");
@@ -26,6 +28,7 @@ exports.initialRecordState = Object.freeze({
26
28
  record: null,
27
29
  error: null,
28
30
  refreshing: false,
31
+ isSaved: false,
29
32
  });
30
33
  class KoolbaseRecordController {
31
34
  constructor(db, collection, id, options = {}) {
@@ -61,8 +64,27 @@ class KoolbaseRecordController {
61
64
  if (this.live && this.id) {
62
65
  this.unlive ?? (this.unlive = (0, live_source_js_1.liveSource)()?.subscribe(this.collection, (event) => this.onLive(event)) ?? null);
63
66
  }
67
+ await this.showSaved();
64
68
  await this.fetch();
65
69
  }
70
+ /** The device's saved copy, shown at once while the server is asked. */
71
+ async showSaved() {
72
+ if (!this.id || !this.db.getSaved)
73
+ return;
74
+ const gen = this.generation;
75
+ let saved = null;
76
+ try {
77
+ saved = await this.db.getSaved(this.id);
78
+ }
79
+ catch {
80
+ return;
81
+ }
82
+ if (!saved || this.isStale(gen) || this.state.status !== 'loading')
83
+ return;
84
+ if (saved.collection !== undefined && saved.collection !== this.collection)
85
+ return;
86
+ this.set({ status: 'loaded', record: saved, error: null, isSaved: true });
87
+ }
66
88
  /** The record again, from the server. What is shown stays while it runs, and stays if it fails. */
67
89
  async refresh() {
68
90
  if (this.disposed)
@@ -102,7 +124,7 @@ class KoolbaseRecordController {
102
124
  this.liveTimer = null;
103
125
  }
104
126
  this.generation += 1;
105
- this.set({ status: 'notFound', record: null, error: null, refreshing: false });
127
+ this.set({ status: 'notFound', record: null, error: null, refreshing: false, isSaved: false });
106
128
  return;
107
129
  }
108
130
  if (this.liveTimer)
@@ -117,7 +139,7 @@ class KoolbaseRecordController {
117
139
  async fetch() {
118
140
  const gen = ++this.generation;
119
141
  if (!this.id) {
120
- this.set({ status: 'notFound', record: null, error: null });
142
+ this.set({ status: 'notFound', record: null, error: null, isSaved: false });
121
143
  return gen;
122
144
  }
123
145
  try {
@@ -125,17 +147,17 @@ class KoolbaseRecordController {
125
147
  if (this.isStale(gen))
126
148
  return gen;
127
149
  if (record.collection !== undefined && record.collection !== this.collection) {
128
- this.set({ status: 'notFound', record: null, error: null });
150
+ this.set({ status: 'notFound', record: null, error: null, isSaved: false });
129
151
  }
130
152
  else {
131
- this.set({ status: 'loaded', record, error: null });
153
+ this.set({ status: 'loaded', record, error: null, isSaved: false });
132
154
  }
133
155
  }
134
156
  catch (error) {
135
157
  if (this.isStale(gen))
136
158
  return gen;
137
159
  if (error instanceof database_errors_js_1.KoolbaseNotFoundError) {
138
- this.set({ status: 'notFound', record: null, error: null });
160
+ this.set({ status: 'notFound', record: null, error: null, isSaved: false });
139
161
  }
140
162
  else if (this.state.record === null) {
141
163
  this.set({ status: 'error', error });
@@ -1,4 +1,5 @@
1
1
  interface Slot {
2
+ offlineStateListeners: Set<(userId: string) => void>;
2
3
  platform?: unknown;
3
4
  locks?: Map<string, Promise<unknown>>;
4
5
  }
@@ -101,13 +101,21 @@ class SyncEngine {
101
101
  // being online — would give the same input different outcomes. It clears
102
102
  // the old key when done, so later calls find nothing and return.
103
103
  await (0, offline_state_js_1.migrateLegacyQueue)(userId);
104
- const { pending } = await (0, offline_state_js_1.readOfflineState)(userId);
104
+ const { pending, conflicts } = await (0, offline_state_js_1.readOfflineState)(userId);
105
105
  if (pending.length === 0)
106
106
  return;
107
107
  // Records whose chain stopped this pass. Writes queued after a conflicted
108
108
  // one were composed against the state it would have produced, so applying
109
109
  // them now would write against a state their baseline never described.
110
110
  const blocked = new Set();
111
+ // And records whose chain stopped in an EARLIER pass: a conflict waits
112
+ // for a decision across restarts, and so must the writes behind it.
113
+ // Seeded only from this pass's finds, the next pass replayed them
114
+ // against a state their baselines never described -- each becoming its
115
+ // own conflict. Deciding the conflict rebases and releases them.
116
+ for (const c of conflicts)
117
+ if (c.recordId)
118
+ blocked.add(c.recordId);
111
119
  for (const queued of pending) {
112
120
  if (queued.recordId && blocked.has(queued.recordId))
113
121
  continue;
@@ -202,10 +210,15 @@ class SyncEngine {
202
210
  // — cached at enqueue for a record the server refused to create.
203
211
  // Left alone it is a phantom: it renders as saved, and an offline
204
212
  // edit against it queues a write to a record that does not exist.
205
- // Evict it, and invalidate the collection so cached queries stop
206
- // serving it. The conflict above keeps the user's data and the
207
- // server's verdict; the cache stops testifying to a fiction.
208
- // MUTATION: phantom eviction removed
213
+ // Evict it from the record cache and from every cached query that
214
+ // holds it -- precisely, not by dropping the collection's saved
215
+ // results, which a device still offline needs. The conflict above
216
+ // keeps the user's data and the server's verdict; the cache stops
217
+ // testifying to a fiction.
218
+ if (write.operation === 'insert' && write.recordId) {
219
+ await (0, cache_store_js_1.removeCachedRecord)(userId, write.recordId);
220
+ await (0, cache_store_js_1.removeFromCachedQueries)(userId, write.collection, write.recordId);
221
+ }
209
222
  if (write.recordId)
210
223
  blocked.add(write.recordId);
211
224
  continue;
@@ -58,3 +58,17 @@ export declare function addToWriteQueue(userId: string, write: Omit<LegacyPendin
58
58
  export declare function removeFromWriteQueue(userId: string, writeId: string): Promise<void>;
59
59
  export declare function incrementWriteRetry(userId: string, writeId: string): Promise<void>;
60
60
  export declare function optimisticallyInsert(userId: string, collection: string, record: KoolbaseRecord): Promise<void>;
61
+ /**
62
+ * The reverse of optimisticallyInsert: takes one record out of every cached
63
+ * result of a collection.
64
+ *
65
+ * Precise on purpose. Dropping the collection's cached results instead would
66
+ * leave a device that is still offline with no saved lists to show at all,
67
+ * to get rid of one record.
68
+ */
69
+ export declare function removeFromCachedQueries(userId: string, collection: string, recordId: string): Promise<void>;
70
+ /**
71
+ * Applies an offline edit to the record wherever a cached result holds it, so
72
+ * a saved list shows what the user just changed rather than what it was.
73
+ */
74
+ export declare function updateInCachedQueries(userId: string, collection: string, recordId: string, data: Record<string, unknown>): Promise<void>;
@@ -196,3 +196,65 @@ export async function optimisticallyInsert(userId, collection, record) {
196
196
  // ignore
197
197
  }
198
198
  }
199
+ /**
200
+ * The reverse of optimisticallyInsert: takes one record out of every cached
201
+ * result of a collection.
202
+ *
203
+ * Precise on purpose. Dropping the collection's cached results instead would
204
+ * leave a device that is still offline with no saved lists to show at all,
205
+ * to get rid of one record.
206
+ */
207
+ export async function removeFromCachedQueries(userId, collection, recordId) {
208
+ try {
209
+ const keys = await getPlatform().storage.getAllKeys();
210
+ const prefix = `koolbase:${CACHE_VERSION}:${userId}:${collection}:`;
211
+ for (const key of keys.filter(k => k.startsWith(prefix))) {
212
+ const raw = await getPlatform().storage.getItem(key);
213
+ if (!raw)
214
+ continue;
215
+ const cached = JSON.parse(raw);
216
+ if (!Array.isArray(cached.records))
217
+ continue;
218
+ const kept = cached.records.filter(r => r.id !== recordId);
219
+ const removed = cached.records.length - kept.length;
220
+ if (removed === 0)
221
+ continue;
222
+ cached.records = kept;
223
+ cached.total = Math.max(0, cached.total - removed);
224
+ await getPlatform().storage.setItem(key, JSON.stringify(cached));
225
+ }
226
+ }
227
+ catch {
228
+ // ignore
229
+ }
230
+ }
231
+ /**
232
+ * Applies an offline edit to the record wherever a cached result holds it, so
233
+ * a saved list shows what the user just changed rather than what it was.
234
+ */
235
+ export async function updateInCachedQueries(userId, collection, recordId, data) {
236
+ try {
237
+ const keys = await getPlatform().storage.getAllKeys();
238
+ const prefix = `koolbase:${CACHE_VERSION}:${userId}:${collection}:`;
239
+ for (const key of keys.filter(k => k.startsWith(prefix))) {
240
+ const raw = await getPlatform().storage.getItem(key);
241
+ if (!raw)
242
+ continue;
243
+ const cached = JSON.parse(raw);
244
+ if (!Array.isArray(cached.records))
245
+ continue;
246
+ let changed = false;
247
+ cached.records = cached.records.map(r => {
248
+ if (r.id !== recordId)
249
+ return r;
250
+ changed = true;
251
+ return { ...r, data: { ...(r.data ?? {}), ...data } };
252
+ });
253
+ if (changed)
254
+ await getPlatform().storage.setItem(key, JSON.stringify(cached));
255
+ }
256
+ }
257
+ catch {
258
+ // ignore
259
+ }
260
+ }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Whether the device reports a network connection. A hint, not a promise that
3
+ * Koolbase is reachable: requests can still fail while online.
4
+ *
5
+ * 'unknown' until the platform first answers -- a real state, not a guess. An
6
+ * app should neither show "offline" nor run online-only logic on it.
7
+ */
8
+ export type ConnectivityState = 'unknown' | 'online' | 'offline';
9
+ /**
10
+ * The device's connectivity, as state an app can render: getState() for now,
11
+ * subscribe() for changes (the shape React's useSyncExternalStore takes).
12
+ */
13
+ export declare class KoolbaseConnectivity {
14
+ private state;
15
+ private readonly listeners;
16
+ private stop;
17
+ private heard;
18
+ /** Starts following the platform. Called once by Koolbase.initialize. */
19
+ start(): void;
20
+ getState(): ConnectivityState;
21
+ /** Called on every change. Returns the unsubscribe. */
22
+ subscribe(listener: () => void): () => void;
23
+ dispose(): void;
24
+ private set;
25
+ }
@@ -0,0 +1,55 @@
1
+ import { getPlatform } from './platform.js';
2
+ /**
3
+ * The device's connectivity, as state an app can render: getState() for now,
4
+ * subscribe() for changes (the shape React's useSyncExternalStore takes).
5
+ */
6
+ export class KoolbaseConnectivity {
7
+ constructor() {
8
+ this.state = 'unknown';
9
+ this.listeners = new Set();
10
+ this.stop = null;
11
+ // A change event is fresher than the first read: once one arrives, a late
12
+ // answer to that read is ignored.
13
+ this.heard = false;
14
+ }
15
+ /** Starts following the platform. Called once by Koolbase.initialize. */
16
+ start() {
17
+ if (this.stop)
18
+ return;
19
+ const network = getPlatform().network;
20
+ this.stop = network.onChange((online) => {
21
+ this.heard = true;
22
+ this.set(online ? 'online' : 'offline');
23
+ });
24
+ if (network.current) {
25
+ network.current().then((online) => {
26
+ if (!this.heard && online !== null && online !== undefined)
27
+ this.set(online ? 'online' : 'offline');
28
+ }, () => { });
29
+ }
30
+ }
31
+ getState() {
32
+ return this.state;
33
+ }
34
+ /** Called on every change. Returns the unsubscribe. */
35
+ subscribe(listener) {
36
+ this.listeners.add(listener);
37
+ return () => { this.listeners.delete(listener); };
38
+ }
39
+ dispose() {
40
+ this.stop?.();
41
+ this.stop = null;
42
+ this.listeners.clear();
43
+ }
44
+ set(next) {
45
+ if (next === this.state)
46
+ return;
47
+ this.state = next;
48
+ for (const listener of [...this.listeners]) {
49
+ try {
50
+ listener();
51
+ }
52
+ catch { /* one broken listener must not stop the rest */ }
53
+ }
54
+ }
55
+ }
@@ -119,6 +119,16 @@ export declare class KoolbaseDatabase {
119
119
  */
120
120
  batch(operations: BatchOp[]): Promise<BatchResult[]>;
121
121
  get(recordId: string): Promise<KoolbaseRecord>;
122
+ /**
123
+ * The device's saved copy of a record: what this device last read, with the
124
+ * changes queued on it applied. Never contacts the server. Null when there is
125
+ * none: never read here, or deleted on this device.
126
+ *
127
+ * For showing something at once while get() asks the server -- saved-first,
128
+ * never saved-only. Kept data and revision, not createdAt or createdBy, which
129
+ * the saved copy does not hold (empty strings here).
130
+ */
131
+ getSaved(recordId: string): Promise<KoolbaseRecord | null>;
122
132
  /**
123
133
  * Writes that could not be applied, waiting for a decision.
124
134
  *
@@ -134,6 +144,28 @@ export declare class KoolbaseDatabase {
134
144
  * moment matters. Snapshot, not a live handle; per-user.
135
145
  */
136
146
  pendingWrites(): Promise<PendingWrite[]>;
147
+ /**
148
+ * The pending writes now, and again whenever they change -- here, in another
149
+ * tab, or because the signed-in user changed. For a sync badge that reflects
150
+ * reality. null while signed out: per-user state has no answer then, and a
151
+ * fake empty list is how a badge says "all synced" while a user's changes
152
+ * sit queued. Returns the unsubscribe.
153
+ */
154
+ watchPendingWrites(listener: (writes: PendingWrite[] | null) => void): () => void;
155
+ /** The unresolved conflicts now, and again whenever they change. null while signed out. */
156
+ watchConflicts(listener: (conflicts: KoolbaseConflict[] | null) => void): () => void;
157
+ /**
158
+ * The signed-in user changed: every watcher reads again, for the new user
159
+ * (or null, signed out). Koolbase.initialize wires it to auth changes.
160
+ */
161
+ sessionChanged(): void;
162
+ private readonly stateWatchers;
163
+ private offStateChange;
164
+ /**
165
+ * One watcher: reads at once, then on every change to the signed-in user's
166
+ * offline state. Reads can finish out of order; only the latest delivers.
167
+ */
168
+ private watchState;
137
169
  /**
138
170
  * Count and total over a whole collection, with the read rule applied
139
171
  * inside the query.
@@ -173,6 +205,15 @@ export declare class KoolbaseDatabase {
173
205
  */
174
206
  private requireUserId;
175
207
  private requireConflict;
208
+ /**
209
+ * Removes a decided conflict and points the writes held behind it at the
210
+ * state the record is now in -- in one transaction, so the conflict can
211
+ * never be gone while its held writes still describe the old world.
212
+ *
213
+ * Without a known state (a refused add, a delete) there is nothing to
214
+ * rebase onto: the conflict is removed and the held writes stay as they are.
215
+ */
216
+ private settleConflict;
176
217
  private dropConflict;
177
218
  /**
178
219
  * Issues the resolving write, conditional on the revision the refusal