@koolbase/core 12.13.1 → 12.15.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.
Files changed (49) hide show
  1. package/dist/cjs/auth.d.ts +31 -1
  2. package/dist/cjs/auth.js +167 -20
  3. package/dist/cjs/cache-store.d.ts +5 -0
  4. package/dist/cjs/cache-store.js +31 -0
  5. package/dist/cjs/connectivity.d.ts +25 -0
  6. package/dist/cjs/connectivity.js +59 -0
  7. package/dist/cjs/database.d.ts +32 -0
  8. package/dist/cjs/database.js +111 -16
  9. package/dist/cjs/fiscal.js +2 -2
  10. package/dist/cjs/functions.js +2 -2
  11. package/dist/cjs/index.d.ts +3 -1
  12. package/dist/cjs/index.js +3 -1
  13. package/dist/cjs/network.d.ts +14 -0
  14. package/dist/cjs/network.js +60 -0
  15. package/dist/cjs/offline-state.d.ts +4 -0
  16. package/dist/cjs/offline-state.js +36 -0
  17. package/dist/cjs/platform.d.ts +16 -0
  18. package/dist/cjs/react/use-record.d.ts +1 -1
  19. package/dist/cjs/record-controller.d.ts +8 -1
  20. package/dist/cjs/record-controller.js +29 -7
  21. package/dist/cjs/shared.d.ts +1 -0
  22. package/dist/cjs/storage.js +11 -11
  23. package/dist/cjs/sync-engine.js +3 -3
  24. package/dist/cjs/types.d.ts +7 -0
  25. package/dist/esm/auth.d.ts +31 -1
  26. package/dist/esm/auth.js +168 -21
  27. package/dist/esm/cache-store.d.ts +5 -0
  28. package/dist/esm/cache-store.js +30 -0
  29. package/dist/esm/connectivity.d.ts +25 -0
  30. package/dist/esm/connectivity.js +55 -0
  31. package/dist/esm/database.d.ts +32 -0
  32. package/dist/esm/database.js +101 -6
  33. package/dist/esm/fiscal.js +3 -3
  34. package/dist/esm/functions.js +3 -3
  35. package/dist/esm/index.d.ts +3 -1
  36. package/dist/esm/index.js +1 -0
  37. package/dist/esm/network.d.ts +14 -0
  38. package/dist/esm/network.js +58 -0
  39. package/dist/esm/offline-state.d.ts +4 -0
  40. package/dist/esm/offline-state.js +35 -0
  41. package/dist/esm/platform.d.ts +16 -0
  42. package/dist/esm/react/use-record.d.ts +1 -1
  43. package/dist/esm/record-controller.d.ts +8 -1
  44. package/dist/esm/record-controller.js +29 -7
  45. package/dist/esm/shared.d.ts +1 -0
  46. package/dist/esm/storage.js +12 -12
  47. package/dist/esm/sync-engine.js +4 -4
  48. package/dist/esm/types.d.ts +7 -0
  49. package/package.json +1 -1
@@ -1,9 +1,10 @@
1
- import { koolbaseFetch } from './network.js';
1
+ import { sessionFetch } from './network.js';
2
2
  import { refreshCollectionQueries } from './query-refresh.js';
3
3
  import { KoolbaseRevisionMismatchError } from './database-errors.js';
4
4
  import { KoolbaseError, KoolbaseOfflineBaselineUnavailableError, KoolbaseUnauthenticatedError, } from './errors.js';
5
5
  import { cacheRecord, getCachedRecord, removeCachedRecord } from './cache-store.js';
6
- import { readOfflineState, mutateOfflineState, queueWrite, rebaseHeldWrites, } from './offline-state.js';
6
+ import { readOfflineState, mutateOfflineState, queueWrite, rebaseHeldWrites, onOfflineStateChange, } from './offline-state.js';
7
+ import { removeFromCachedQueries, updateInCachedQueries } from './cache-store.js';
7
8
  import { KoolbaseConflict } from './conflict.js';
8
9
  import { toPendingWrite } from './pending-write.js';
9
10
  import { getCached, setCached, invalidateCache, optimisticallyInsert, hashQuery, } from './cache-store.js';
@@ -42,6 +43,8 @@ function batchOpToWire(op) {
42
43
  }
43
44
  export class KoolbaseDatabase {
44
45
  constructor(config, getUserId, getToken, onSessionExpired) {
46
+ this.stateWatchers = new Set();
47
+ this.offStateChange = null;
45
48
  /**
46
49
  * Resolves by id, reloading the stored conflict first.
47
50
  *
@@ -91,7 +94,7 @@ export class KoolbaseDatabase {
91
94
  };
92
95
  }
93
96
  async request(method, path, body) {
94
- const res = await koolbaseFetch(`${this.config.baseUrl}${path}`, {
97
+ const res = await sessionFetch(`${this.config.baseUrl}${path}`, {
95
98
  method,
96
99
  headers: await this.buildHeaders(),
97
100
  body: body ? JSON.stringify(body) : undefined,
@@ -129,7 +132,7 @@ export class KoolbaseDatabase {
129
132
  * none of them clearing a rejected session. One path, two shapes of result.
130
133
  */
131
134
  async requestWithStatus(method, path, body) {
132
- const res = await koolbaseFetch(`${this.config.baseUrl}${path}`, {
135
+ const res = await sessionFetch(`${this.config.baseUrl}${path}`, {
133
136
  method,
134
137
  headers: await this.buildHeaders(),
135
138
  body: body ? JSON.stringify(body) : undefined,
@@ -430,6 +433,29 @@ export class KoolbaseDatabase {
430
433
  }
431
434
  return record;
432
435
  }
436
+ /**
437
+ * The device's saved copy of a record: what this device last read, with the
438
+ * changes queued on it applied. Never contacts the server. Null when there is
439
+ * none: never read here, or deleted on this device.
440
+ *
441
+ * For showing something at once while get() asks the server -- saved-first,
442
+ * never saved-only. Kept data and revision, not createdAt or createdBy, which
443
+ * the saved copy does not hold (empty strings here).
444
+ */
445
+ async getSaved(recordId) {
446
+ const userId = this.getUserId() ?? 'anonymous';
447
+ const base = await this.resolveBaseline(userId, recordId);
448
+ if (!base)
449
+ return null;
450
+ return {
451
+ id: recordId,
452
+ collection: base.collection,
453
+ data: { ...base.baseline },
454
+ createdAt: '',
455
+ updatedAt: '',
456
+ revision: base.revision,
457
+ };
458
+ }
433
459
  // ─── Conflicts ──────────────────────────────────────────────────────────────
434
460
  /**
435
461
  * Writes that could not be applied, waiting for a decision.
@@ -450,6 +476,69 @@ export class KoolbaseDatabase {
450
476
  const { pending } = await readOfflineState(userId);
451
477
  return pending.map(toPendingWrite);
452
478
  }
479
+ /**
480
+ * The pending writes now, and again whenever they change -- here, in another
481
+ * tab, or because the signed-in user changed. For a sync badge that reflects
482
+ * reality. null while signed out: per-user state has no answer then, and a
483
+ * fake empty list is how a badge says "all synced" while a user's changes
484
+ * sit queued. Returns the unsubscribe.
485
+ */
486
+ watchPendingWrites(listener) {
487
+ return this.watchState(async (userId) => {
488
+ if (!userId)
489
+ return () => listener(null);
490
+ const { pending } = await readOfflineState(userId);
491
+ const writes = pending.map(toPendingWrite);
492
+ return () => listener(writes);
493
+ });
494
+ }
495
+ /** The unresolved conflicts now, and again whenever they change. null while signed out. */
496
+ watchConflicts(listener) {
497
+ return this.watchState(async (userId) => {
498
+ if (!userId)
499
+ return () => listener(null);
500
+ const list = await this.conflicts();
501
+ return () => listener(list);
502
+ });
503
+ }
504
+ /**
505
+ * The signed-in user changed: every watcher reads again, for the new user
506
+ * (or null, signed out). Koolbase.initialize wires it to auth changes.
507
+ */
508
+ sessionChanged() {
509
+ for (const emit of [...this.stateWatchers])
510
+ emit();
511
+ }
512
+ /**
513
+ * One watcher: reads at once, then on every change to the signed-in user's
514
+ * offline state. Reads can finish out of order; only the latest delivers.
515
+ */
516
+ watchState(read) {
517
+ let latest = 0;
518
+ let active = true;
519
+ const emit = () => {
520
+ const mine = ++latest;
521
+ read(this.getUserId()).then((deliver) => { if (active && mine === latest)
522
+ deliver(); }, () => { });
523
+ };
524
+ this.stateWatchers.add(emit);
525
+ if (!this.offStateChange) {
526
+ this.offStateChange = onOfflineStateChange((userId) => {
527
+ if (userId === this.getUserId())
528
+ for (const w of [...this.stateWatchers])
529
+ w();
530
+ });
531
+ }
532
+ emit();
533
+ return () => {
534
+ active = false;
535
+ this.stateWatchers.delete(emit);
536
+ if (this.stateWatchers.size === 0 && this.offStateChange) {
537
+ this.offStateChange();
538
+ this.offStateChange = null;
539
+ }
540
+ };
541
+ }
453
542
  /**
454
543
  * Count and total over a whole collection, with the read rule applied
455
544
  * inside the query.
@@ -664,8 +753,11 @@ export class KoolbaseDatabase {
664
753
  for (const w of queued) {
665
754
  if (w.operation === 'insert')
666
755
  projected = { ...(w.data ?? {}) };
756
+ // An edit applies to the record as it was: the queued add's state, or
757
+ // -- first in a chain with no add -- the baseline the edit was made
758
+ // against. Starting from nothing dropped every field it did not touch.
667
759
  else if (w.operation === 'update')
668
- projected = { ...(projected ?? {}), ...(w.data ?? {}) };
760
+ projected = { ...(projected ?? w.baseline ?? {}), ...(w.data ?? {}) };
669
761
  else if (w.operation === 'delete')
670
762
  projected = null;
671
763
  }
@@ -734,6 +826,8 @@ export class KoolbaseDatabase {
734
826
  });
735
827
  const merged = { ...base.baseline, ...data };
736
828
  await cacheRecord(userId, base.collection, recordId, merged, base.revision);
829
+ // And in the saved lists, so they show what the user just changed.
830
+ await updateInCachedQueries(userId, base.collection, recordId, data);
737
831
  // Optimistic: durable locally and queued to send, not yet accepted.
738
832
  return {
739
833
  id: recordId,
@@ -794,8 +888,9 @@ export class KoolbaseDatabase {
794
888
  });
795
889
  // The queued write holds its own copy of the baseline, so removing the
796
890
  // cached record costs nothing and keeps local reads consistent with what
797
- // the user just did.
891
+ // the user just did -- saved lists included.
798
892
  await removeCachedRecord(userId, recordId);
893
+ await removeFromCachedQueries(userId, base.collection, recordId);
799
894
  }
800
895
  }
801
896
  // ─── Vectors ────────────────────────────────────────────────────────────────
@@ -1,4 +1,4 @@
1
- import { koolbaseFetch } from './network.js';
1
+ import { sessionFetch } from './network.js';
2
2
  import { KoolbaseError, KoolbaseUnauthenticatedError } from './errors.js';
3
3
  const KNOWN_STATUSES = [
4
4
  'created', 'blocked', 'queued', 'submitting',
@@ -106,7 +106,7 @@ export class KoolbaseFiscal {
106
106
  const controller = new AbortController();
107
107
  const timer = setTimeout(() => controller.abort(), params.timeoutMs ?? 30000);
108
108
  try {
109
- const res = await koolbaseFetch(`${this.config.baseUrl}/v1/sdk/fiscal/submit`, {
109
+ const res = await sessionFetch(`${this.config.baseUrl}/v1/sdk/fiscal/submit`, {
110
110
  method: 'POST',
111
111
  headers: await this.buildHeaders(),
112
112
  signal: controller.signal,
@@ -144,7 +144,7 @@ export class KoolbaseFiscal {
144
144
  device_id: params.deviceId,
145
145
  client_ref: params.clientRef,
146
146
  });
147
- const res = await koolbaseFetch(`${this.config.baseUrl}/v1/sdk/fiscal/status?${q.toString()}`, { method: 'GET', headers: await this.buildHeaders(), signal: controller.signal });
147
+ const res = await sessionFetch(`${this.config.baseUrl}/v1/sdk/fiscal/status?${q.toString()}`, { method: 'GET', headers: await this.buildHeaders(), signal: controller.signal });
148
148
  return await this.decode(res);
149
149
  }
150
150
  catch (e) {
@@ -1,4 +1,4 @@
1
- import { koolbaseFetch } from './network.js';
1
+ import { sessionFetch } from './network.js';
2
2
  import { functionInvokeError } from './function-errors.js';
3
3
  import { KoolbaseUnauthenticatedError } from './errors.js';
4
4
  import { FunctionRuntime, } from './types.js';
@@ -11,7 +11,7 @@ export class KoolbaseFunctions {
11
11
  // ─── Deploy ────────────────────────────────────────────────────────────────
12
12
  async deploy(options) {
13
13
  const runtime = options.runtime ?? FunctionRuntime.Deno;
14
- const res = await koolbaseFetch(`${this.config.baseUrl}/v1/sdk/functions/deploy`, {
14
+ const res = await sessionFetch(`${this.config.baseUrl}/v1/sdk/functions/deploy`, {
15
15
  method: 'POST',
16
16
  headers: {
17
17
  'Content-Type': 'application/json',
@@ -55,7 +55,7 @@ export class KoolbaseFunctions {
55
55
  if (userToken) {
56
56
  headers['Authorization'] = `Bearer ${userToken}`;
57
57
  }
58
- const res = await koolbaseFetch(`${this.config.baseUrl}/v1/sdk/functions/${name}`, {
58
+ const res = await sessionFetch(`${this.config.baseUrl}/v1/sdk/functions/${name}`, {
59
59
  method: 'POST',
60
60
  headers,
61
61
  body: JSON.stringify({ body: body ?? {} }),
@@ -17,6 +17,8 @@ export type { RecordState, RecordStatus } from './record-controller.js';
17
17
  export { KoolbaseFlags } from './flags.js';
18
18
  export { KoolbaseFunctions } from './functions.js';
19
19
  export { KoolbaseRealtime } from './realtime.js';
20
+ export { KoolbaseConnectivity } from './connectivity.js';
21
+ export type { ConnectivityState } from './connectivity.js';
20
22
  export { KoolbaseStorage } from './storage.js';
21
23
  export { KoolbaseAnalytics, disabledAnalytics } from './analytics.js';
22
24
  export { KoolbaseFiscal, KoolbaseFiscalError, decodeFiscalResponse, isFiscalized, isPending } from './fiscal.js';
@@ -27,4 +29,4 @@ export { getOrCreateDeviceId } from './device-id.js';
27
29
  export { koolbaseSdkVersion } from './device-metadata.js';
28
30
  export { RestoreResult } from './types.js';
29
31
  export type { AuthStateListener, FetchLike, KoolbaseAuthStorage, ResendVerificationResult } from './types.js';
30
- export { setPlatform, getPlatform, memoryPlatform, type PlatformAdapter, type PlatformStorage, type PlatformNetwork, type PlatformLifecycle, type PlatformLocks, type PlatformInfo, } from './platform.js';
32
+ export { setPlatform, getPlatform, memoryPlatform, type PlatformAdapter, type PlatformBroadcast, type PlatformStorage, type PlatformNetwork, type PlatformLifecycle, type PlatformLocks, type PlatformInfo, } from './platform.js';
package/dist/esm/index.js CHANGED
@@ -20,6 +20,7 @@ export { KoolbaseRecordController, initialRecordState } from './record-controlle
20
20
  export { KoolbaseFlags } from './flags.js';
21
21
  export { KoolbaseFunctions } from './functions.js';
22
22
  export { KoolbaseRealtime } from './realtime.js';
23
+ export { KoolbaseConnectivity } from './connectivity.js';
23
24
  export { KoolbaseStorage } from './storage.js';
24
25
  export { KoolbaseAnalytics, disabledAnalytics } from './analytics.js';
25
26
  export { KoolbaseFiscal, KoolbaseFiscalError, decodeFiscalResponse, isFiscalized, isPending } from './fiscal.js';
@@ -19,3 +19,17 @@ export declare class KoolbaseNetworkError extends TypeError {
19
19
  }
20
20
  /** fetch, with network failures turned into a KoolbaseNetworkError. */
21
21
  export declare function koolbaseFetch(input: string, init?: RequestInit): Promise<Response>;
22
+ type Renewal = (refusedToken: string) => Promise<string | null>;
23
+ /** Installed by Koolbase.initialize: renews the session a request was refused with. */
24
+ export declare function setSessionRenewal(fn: Renewal | null): void;
25
+ /**
26
+ * koolbaseFetch for a data request. A request refused as unauthenticated
27
+ * (401) while carrying the signed-in person's token is the session needing
28
+ * renewal, not ending: the access token expired early, the clock is off, or
29
+ * another tab renewed it. The session is renewed once and the request sent
30
+ * again with the new token, so the caller never sees the first refusal.
31
+ * Only when renewal is refused does the 401 reach the caller, and with it the
32
+ * client's onSessionExpired, which ends the session.
33
+ */
34
+ export declare function sessionFetch(input: string, init?: RequestInit): Promise<Response>;
35
+ export {};
@@ -47,3 +47,61 @@ export async function koolbaseFetch(input, init) {
47
47
  throw new KoolbaseNetworkError(input, e);
48
48
  }
49
49
  }
50
+ let renewal = null;
51
+ /** Installed by Koolbase.initialize: renews the session a request was refused with. */
52
+ export function setSessionRenewal(fn) {
53
+ renewal = fn;
54
+ }
55
+ function headerOf(headers, name) {
56
+ if (!headers)
57
+ return undefined;
58
+ if (typeof headers.get === 'function')
59
+ return headers.get(name) ?? undefined;
60
+ if (Array.isArray(headers))
61
+ return headers.find(([k]) => k.toLowerCase() === name.toLowerCase())?.[1];
62
+ const rec = headers;
63
+ const key = Object.keys(rec).find((k) => k.toLowerCase() === name.toLowerCase());
64
+ return key ? rec[key] : undefined;
65
+ }
66
+ function withHeader(headers, name, value) {
67
+ const out = {};
68
+ if (headers && typeof headers.forEach === 'function' && !Array.isArray(headers)) {
69
+ headers.forEach((v, k) => { out[k] = v; });
70
+ }
71
+ else if (Array.isArray(headers)) {
72
+ for (const [k, v] of headers)
73
+ out[k] = v;
74
+ }
75
+ else if (headers) {
76
+ Object.assign(out, headers);
77
+ }
78
+ for (const k of Object.keys(out))
79
+ if (k.toLowerCase() === name.toLowerCase())
80
+ delete out[k];
81
+ out[name] = value;
82
+ return out;
83
+ }
84
+ /**
85
+ * koolbaseFetch for a data request. A request refused as unauthenticated
86
+ * (401) while carrying the signed-in person's token is the session needing
87
+ * renewal, not ending: the access token expired early, the clock is off, or
88
+ * another tab renewed it. The session is renewed once and the request sent
89
+ * again with the new token, so the caller never sees the first refusal.
90
+ * Only when renewal is refused does the 401 reach the caller, and with it the
91
+ * client's onSessionExpired, which ends the session.
92
+ */
93
+ export async function sessionFetch(input, init) {
94
+ const res = await koolbaseFetch(input, init);
95
+ if (res.status !== 401 || !renewal)
96
+ return res;
97
+ const auth = headerOf(init?.headers, 'Authorization');
98
+ if (!auth || !auth.startsWith('Bearer '))
99
+ return res;
100
+ // Only a body that can be sent twice. Data requests send JSON text.
101
+ if (init?.body !== undefined && init.body !== null && typeof init.body !== 'string')
102
+ return res;
103
+ const fresh = await renewal(auth.slice('Bearer '.length));
104
+ if (!fresh)
105
+ return res;
106
+ return koolbaseFetch(input, { ...init, headers: withHeader(init?.headers, 'Authorization', `Bearer ${fresh}`) });
107
+ }
@@ -119,3 +119,7 @@ export declare function migrateLegacyQueue(userId: string): Promise<void>;
119
119
  * be replayed through its projected states instead.
120
120
  */
121
121
  export declare function rebaseHeldWrites(state: OfflineState, recordId: string, resolved: Record<string, unknown>, revision: number | undefined): void;
122
+ type StateListener = (userId: string) => void;
123
+ /** Called with the user id whenever that user's offline state changes. */
124
+ export declare function onOfflineStateChange(listener: StateListener): () => void;
125
+ export {};
@@ -130,6 +130,7 @@ export async function mutateOfflineState(userId, mutate) {
130
130
  }
131
131
  await getPlatform().storage.setItem(stateKey(userId), serialised);
132
132
  });
133
+ announce(userId);
133
134
  }
134
135
  /** Adds a write to the queue, under the user's lock. */
135
136
  export async function queueWrite(userId, write) {
@@ -235,3 +236,37 @@ export function rebaseHeldWrites(state, recordId, resolved, revision) {
235
236
  break;
236
237
  }
237
238
  }
239
+ // ─── Change notification ─────────────────────────────────────────────────────
240
+ const ANNOUNCE_PREFIX = 'koolbase:offline-state:';
241
+ function stateListeners() {
242
+ return shared('offlineStateListeners', () => new Set());
243
+ }
244
+ /**
245
+ * Tells whoever is watching that a user's offline state changed -- here, and
246
+ * in every other copy of the SDK sharing this storage (other tabs). A badge in
247
+ * one tab must move when another tab's sync drains the queue.
248
+ */
249
+ function announce(userId) {
250
+ for (const listener of [...stateListeners()]) {
251
+ try {
252
+ listener(userId);
253
+ }
254
+ catch { /* one broken listener must not stop the rest */ }
255
+ }
256
+ try {
257
+ getPlatform().broadcast?.post(ANNOUNCE_PREFIX + userId);
258
+ }
259
+ catch { /* best effort */ }
260
+ }
261
+ /** Called with the user id whenever that user's offline state changes. */
262
+ export function onOfflineStateChange(listener) {
263
+ stateListeners().add(listener);
264
+ const off = getPlatform().broadcast?.on((message) => {
265
+ if (message.startsWith(ANNOUNCE_PREFIX))
266
+ listener(message.slice(ANNOUNCE_PREFIX.length));
267
+ });
268
+ return () => {
269
+ stateListeners().delete(listener);
270
+ off?.();
271
+ };
272
+ }
@@ -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. */
@@ -14,8 +14,10 @@
14
14
  // or landing after dispose(), is dropped.
15
15
  // - live (the option): realtime events for THIS record only. An update
16
16
  // re-reads it silently, once per burst; a delete is notFound at once.
17
- // Not yet: an offline fallback from the record cache, which keeps data and
18
- // revision but not createdAt or createdBy.
17
+ // - saved-first, never saved-only: the device's saved copy (db.getSaved)
18
+ // shows at once with isSaved true while the server is asked. Success
19
+ // replaces it (isSaved false); failure keeps it (isSaved stays true); not
20
+ // found is notFound. No saved copy: the ordinary loading/error states.
19
21
  import { KoolbaseNotFoundError } from './database-errors.js';
20
22
  import { liveSource } from './live-source.js';
21
23
  export const initialRecordState = Object.freeze({
@@ -23,6 +25,7 @@ export const initialRecordState = Object.freeze({
23
25
  record: null,
24
26
  error: null,
25
27
  refreshing: false,
28
+ isSaved: false,
26
29
  });
27
30
  export class KoolbaseRecordController {
28
31
  constructor(db, collection, id, options = {}) {
@@ -58,8 +61,27 @@ export class KoolbaseRecordController {
58
61
  if (this.live && this.id) {
59
62
  this.unlive ?? (this.unlive = liveSource()?.subscribe(this.collection, (event) => this.onLive(event)) ?? null);
60
63
  }
64
+ await this.showSaved();
61
65
  await this.fetch();
62
66
  }
67
+ /** The device's saved copy, shown at once while the server is asked. */
68
+ async showSaved() {
69
+ if (!this.id || !this.db.getSaved)
70
+ return;
71
+ const gen = this.generation;
72
+ let saved = null;
73
+ try {
74
+ saved = await this.db.getSaved(this.id);
75
+ }
76
+ catch {
77
+ return;
78
+ }
79
+ if (!saved || this.isStale(gen) || this.state.status !== 'loading')
80
+ return;
81
+ if (saved.collection !== undefined && saved.collection !== this.collection)
82
+ return;
83
+ this.set({ status: 'loaded', record: saved, error: null, isSaved: true });
84
+ }
63
85
  /** The record again, from the server. What is shown stays while it runs, and stays if it fails. */
64
86
  async refresh() {
65
87
  if (this.disposed)
@@ -99,7 +121,7 @@ export class KoolbaseRecordController {
99
121
  this.liveTimer = null;
100
122
  }
101
123
  this.generation += 1;
102
- this.set({ status: 'notFound', record: null, error: null, refreshing: false });
124
+ this.set({ status: 'notFound', record: null, error: null, refreshing: false, isSaved: false });
103
125
  return;
104
126
  }
105
127
  if (this.liveTimer)
@@ -114,7 +136,7 @@ export class KoolbaseRecordController {
114
136
  async fetch() {
115
137
  const gen = ++this.generation;
116
138
  if (!this.id) {
117
- this.set({ status: 'notFound', record: null, error: null });
139
+ this.set({ status: 'notFound', record: null, error: null, isSaved: false });
118
140
  return gen;
119
141
  }
120
142
  try {
@@ -122,17 +144,17 @@ export class KoolbaseRecordController {
122
144
  if (this.isStale(gen))
123
145
  return gen;
124
146
  if (record.collection !== undefined && record.collection !== this.collection) {
125
- this.set({ status: 'notFound', record: null, error: null });
147
+ this.set({ status: 'notFound', record: null, error: null, isSaved: false });
126
148
  }
127
149
  else {
128
- this.set({ status: 'loaded', record, error: null });
150
+ this.set({ status: 'loaded', record, error: null, isSaved: false });
129
151
  }
130
152
  }
131
153
  catch (error) {
132
154
  if (this.isStale(gen))
133
155
  return gen;
134
156
  if (error instanceof KoolbaseNotFoundError) {
135
- this.set({ status: 'notFound', record: null, error: null });
157
+ this.set({ status: 'notFound', record: null, error: null, isSaved: false });
136
158
  }
137
159
  else if (this.state.record === null) {
138
160
  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
  }
@@ -1,5 +1,5 @@
1
1
  import { KoolbaseStorageProjectIdentityError } from './storage-errors.js';
2
- import { koolbaseFetch } from './network.js';
2
+ import { sessionFetch } from './network.js';
3
3
  import { KoolbaseUnauthenticatedError } from './errors.js';
4
4
  import { KoolbaseStorageError, koolbaseStorageErrorFromResponse, } from './storage-errors.js';
5
5
  // --- Cloudflare image-transform URL helpers -------------------------------
@@ -98,7 +98,7 @@ export class KoolbaseStorage {
98
98
  ? options.file.type || 'application/octet-stream'
99
99
  : options.file.type;
100
100
  // ─── Step 1: Get presigned upload URL ───
101
- const urlRes = await koolbaseFetch(`${this.config.baseUrl}/v1/sdk/storage/upload-url`, {
101
+ const urlRes = await sessionFetch(`${this.config.baseUrl}/v1/sdk/storage/upload-url`, {
102
102
  method: 'POST',
103
103
  headers: {
104
104
  ...(await this.buildHeaders()),
@@ -124,9 +124,9 @@ export class KoolbaseStorage {
124
124
  // the two hosts differ here and nowhere else in this method.
125
125
  const fileBlob = isBlob
126
126
  ? options.file
127
- : await (await koolbaseFetch(options.file.uri)).blob();
127
+ : await (await sessionFetch(options.file.uri)).blob();
128
128
  const fileSize = fileBlob.size;
129
- const uploadRes = await koolbaseFetch(upload_url, {
129
+ const uploadRes = await sessionFetch(upload_url, {
130
130
  method: 'PUT',
131
131
  headers: { 'Content-Type': contentType },
132
132
  body: fileBlob,
@@ -153,7 +153,7 @@ export class KoolbaseStorage {
153
153
  if (options.metadata !== undefined) {
154
154
  confirmBody.metadata = options.metadata;
155
155
  }
156
- const confirmRes = await koolbaseFetch(`${this.config.baseUrl}/v1/sdk/storage/confirm`, {
156
+ const confirmRes = await sessionFetch(`${this.config.baseUrl}/v1/sdk/storage/confirm`, {
157
157
  method: 'POST',
158
158
  headers: {
159
159
  ...(await this.buildHeaders()),
@@ -204,7 +204,7 @@ export class KoolbaseStorage {
204
204
  * // -> { category: 'landscape', tag: 'sunset' }
205
205
  */
206
206
  async updateMetadata(bucket, path, metadata) {
207
- const res = await koolbaseFetch(`${this.config.baseUrl}/v1/sdk/storage/objects/metadata`, {
207
+ const res = await sessionFetch(`${this.config.baseUrl}/v1/sdk/storage/objects/metadata`, {
208
208
  method: 'PATCH',
209
209
  headers: {
210
210
  ...(await this.buildHeaders()),
@@ -227,7 +227,7 @@ export class KoolbaseStorage {
227
227
  if (versionId) {
228
228
  url += `&version_id=${encodeURIComponent(versionId)}`;
229
229
  }
230
- const res = await koolbaseFetch(url, { headers: await this.buildHeaders() });
230
+ const res = await sessionFetch(url, { headers: await this.buildHeaders() });
231
231
  if (!res.ok) {
232
232
  throw await this.error(res, 'Failed to get download URL');
233
233
  }
@@ -339,7 +339,7 @@ export class KoolbaseStorage {
339
339
  const url = forcePurge
340
340
  ? `${this.config.baseUrl}/v1/sdk/storage/object?force_purge=true`
341
341
  : `${this.config.baseUrl}/v1/sdk/storage/object`;
342
- const res = await koolbaseFetch(url, {
342
+ const res = await sessionFetch(url, {
343
343
  method: 'DELETE',
344
344
  headers: {
345
345
  ...(await this.buildHeaders()),
@@ -366,7 +366,7 @@ export class KoolbaseStorage {
366
366
  async listVersions(bucket, path) {
367
367
  const url = `${this.config.baseUrl}/v1/sdk/storage/object-versions` +
368
368
  `?bucket=${encodeURIComponent(bucket)}&path=${encodeURIComponent(path)}`;
369
- const res = await koolbaseFetch(url, { headers: await this.buildHeaders() });
369
+ const res = await sessionFetch(url, { headers: await this.buildHeaders() });
370
370
  if (!res.ok) {
371
371
  throw await this.error(res, 'Failed to list versions');
372
372
  }
@@ -382,7 +382,7 @@ export class KoolbaseStorage {
382
382
  async getVersion(bucket, path, versionId) {
383
383
  const url = `${this.config.baseUrl}/v1/sdk/storage/object-versions/${encodeURIComponent(versionId)}` +
384
384
  `?bucket=${encodeURIComponent(bucket)}&path=${encodeURIComponent(path)}`;
385
- const res = await koolbaseFetch(url, { headers: await this.buildHeaders() });
385
+ const res = await sessionFetch(url, { headers: await this.buildHeaders() });
386
386
  if (!res.ok) {
387
387
  throw await this.error(res, 'Failed to fetch version');
388
388
  }
@@ -401,7 +401,7 @@ export class KoolbaseStorage {
401
401
  async restoreVersion(bucket, path, versionId) {
402
402
  const url = `${this.config.baseUrl}/v1/sdk/storage/object-versions/${encodeURIComponent(versionId)}/restore` +
403
403
  `?bucket=${encodeURIComponent(bucket)}&path=${encodeURIComponent(path)}`;
404
- const res = await koolbaseFetch(url, {
404
+ const res = await sessionFetch(url, {
405
405
  method: 'POST',
406
406
  headers: await this.buildHeaders(),
407
407
  });
@@ -419,7 +419,7 @@ export class KoolbaseStorage {
419
419
  async purgeVersion(bucket, path, versionId) {
420
420
  const url = `${this.config.baseUrl}/v1/sdk/storage/object-versions/${encodeURIComponent(versionId)}` +
421
421
  `?bucket=${encodeURIComponent(bucket)}&path=${encodeURIComponent(path)}`;
422
- const res = await koolbaseFetch(url, {
422
+ const res = await sessionFetch(url, {
423
423
  method: 'DELETE',
424
424
  headers: await this.buildHeaders(),
425
425
  });