@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
@@ -101,7 +101,7 @@ class KoolbaseStorage {
101
101
  ? options.file.type || 'application/octet-stream'
102
102
  : options.file.type;
103
103
  // ─── Step 1: Get presigned upload URL ───
104
- const urlRes = await (0, network_js_1.koolbaseFetch)(`${this.config.baseUrl}/v1/sdk/storage/upload-url`, {
104
+ const urlRes = await (0, network_js_1.sessionFetch)(`${this.config.baseUrl}/v1/sdk/storage/upload-url`, {
105
105
  method: 'POST',
106
106
  headers: {
107
107
  ...(await this.buildHeaders()),
@@ -127,9 +127,9 @@ class KoolbaseStorage {
127
127
  // the two hosts differ here and nowhere else in this method.
128
128
  const fileBlob = isBlob
129
129
  ? options.file
130
- : await (await (0, network_js_1.koolbaseFetch)(options.file.uri)).blob();
130
+ : await (await (0, network_js_1.sessionFetch)(options.file.uri)).blob();
131
131
  const fileSize = fileBlob.size;
132
- const uploadRes = await (0, network_js_1.koolbaseFetch)(upload_url, {
132
+ const uploadRes = await (0, network_js_1.sessionFetch)(upload_url, {
133
133
  method: 'PUT',
134
134
  headers: { 'Content-Type': contentType },
135
135
  body: fileBlob,
@@ -156,7 +156,7 @@ class KoolbaseStorage {
156
156
  if (options.metadata !== undefined) {
157
157
  confirmBody.metadata = options.metadata;
158
158
  }
159
- const confirmRes = await (0, network_js_1.koolbaseFetch)(`${this.config.baseUrl}/v1/sdk/storage/confirm`, {
159
+ const confirmRes = await (0, network_js_1.sessionFetch)(`${this.config.baseUrl}/v1/sdk/storage/confirm`, {
160
160
  method: 'POST',
161
161
  headers: {
162
162
  ...(await this.buildHeaders()),
@@ -207,7 +207,7 @@ class KoolbaseStorage {
207
207
  * // -> { category: 'landscape', tag: 'sunset' }
208
208
  */
209
209
  async updateMetadata(bucket, path, metadata) {
210
- const res = await (0, network_js_1.koolbaseFetch)(`${this.config.baseUrl}/v1/sdk/storage/objects/metadata`, {
210
+ const res = await (0, network_js_1.sessionFetch)(`${this.config.baseUrl}/v1/sdk/storage/objects/metadata`, {
211
211
  method: 'PATCH',
212
212
  headers: {
213
213
  ...(await this.buildHeaders()),
@@ -230,7 +230,7 @@ class KoolbaseStorage {
230
230
  if (versionId) {
231
231
  url += `&version_id=${encodeURIComponent(versionId)}`;
232
232
  }
233
- const res = await (0, network_js_1.koolbaseFetch)(url, { headers: await this.buildHeaders() });
233
+ const res = await (0, network_js_1.sessionFetch)(url, { headers: await this.buildHeaders() });
234
234
  if (!res.ok) {
235
235
  throw await this.error(res, 'Failed to get download URL');
236
236
  }
@@ -342,7 +342,7 @@ class KoolbaseStorage {
342
342
  const url = forcePurge
343
343
  ? `${this.config.baseUrl}/v1/sdk/storage/object?force_purge=true`
344
344
  : `${this.config.baseUrl}/v1/sdk/storage/object`;
345
- const res = await (0, network_js_1.koolbaseFetch)(url, {
345
+ const res = await (0, network_js_1.sessionFetch)(url, {
346
346
  method: 'DELETE',
347
347
  headers: {
348
348
  ...(await this.buildHeaders()),
@@ -369,7 +369,7 @@ class KoolbaseStorage {
369
369
  async listVersions(bucket, path) {
370
370
  const url = `${this.config.baseUrl}/v1/sdk/storage/object-versions` +
371
371
  `?bucket=${encodeURIComponent(bucket)}&path=${encodeURIComponent(path)}`;
372
- const res = await (0, network_js_1.koolbaseFetch)(url, { headers: await this.buildHeaders() });
372
+ const res = await (0, network_js_1.sessionFetch)(url, { headers: await this.buildHeaders() });
373
373
  if (!res.ok) {
374
374
  throw await this.error(res, 'Failed to list versions');
375
375
  }
@@ -385,7 +385,7 @@ class KoolbaseStorage {
385
385
  async getVersion(bucket, path, versionId) {
386
386
  const url = `${this.config.baseUrl}/v1/sdk/storage/object-versions/${encodeURIComponent(versionId)}` +
387
387
  `?bucket=${encodeURIComponent(bucket)}&path=${encodeURIComponent(path)}`;
388
- const res = await (0, network_js_1.koolbaseFetch)(url, { headers: await this.buildHeaders() });
388
+ const res = await (0, network_js_1.sessionFetch)(url, { headers: await this.buildHeaders() });
389
389
  if (!res.ok) {
390
390
  throw await this.error(res, 'Failed to fetch version');
391
391
  }
@@ -404,7 +404,7 @@ class KoolbaseStorage {
404
404
  async restoreVersion(bucket, path, versionId) {
405
405
  const url = `${this.config.baseUrl}/v1/sdk/storage/object-versions/${encodeURIComponent(versionId)}/restore` +
406
406
  `?bucket=${encodeURIComponent(bucket)}&path=${encodeURIComponent(path)}`;
407
- const res = await (0, network_js_1.koolbaseFetch)(url, {
407
+ const res = await (0, network_js_1.sessionFetch)(url, {
408
408
  method: 'POST',
409
409
  headers: await this.buildHeaders(),
410
410
  });
@@ -422,7 +422,7 @@ class KoolbaseStorage {
422
422
  async purgeVersion(bucket, path, versionId) {
423
423
  const url = `${this.config.baseUrl}/v1/sdk/storage/object-versions/${encodeURIComponent(versionId)}` +
424
424
  `?bucket=${encodeURIComponent(bucket)}&path=${encodeURIComponent(path)}`;
425
- const res = await (0, network_js_1.koolbaseFetch)(url, {
425
+ const res = await (0, network_js_1.sessionFetch)(url, {
426
426
  method: 'DELETE',
427
427
  headers: await this.buildHeaders(),
428
428
  });
@@ -259,7 +259,7 @@ class SyncEngine {
259
259
  : `${this.config.baseUrl}/v1/sdk/db/records/${write.recordId}`;
260
260
  let res;
261
261
  if (write.operation === 'insert') {
262
- res = await (0, network_js_1.koolbaseFetch)(url, {
262
+ res = await (0, network_js_1.sessionFetch)(url, {
263
263
  method: 'POST',
264
264
  headers,
265
265
  // The write's own id: generated at enqueue, identical on every retry.
@@ -273,7 +273,7 @@ class SyncEngine {
273
273
  });
274
274
  }
275
275
  else if (write.operation === 'update') {
276
- res = await (0, network_js_1.koolbaseFetch)(url, {
276
+ res = await (0, network_js_1.sessionFetch)(url, {
277
277
  method: 'PATCH',
278
278
  headers,
279
279
  // The revision the change was composed against. The server applies it
@@ -292,7 +292,7 @@ class SyncEngine {
292
292
  const q = write.baseRevision !== undefined
293
293
  ? `?expected_revision=${write.baseRevision}`
294
294
  : '';
295
- res = await (0, network_js_1.koolbaseFetch)(`${url}${q}`, { method: 'DELETE', headers });
295
+ res = await (0, network_js_1.sessionFetch)(`${url}${q}`, { method: 'DELETE', headers });
296
296
  }
297
297
  if (res.status === 401)
298
298
  throw new errors_js_1.KoolbaseUnauthenticatedError('unauthorized');
@@ -180,6 +180,13 @@ export declare enum RestoreResult {
180
180
  * broken listener cannot break propagation to others.
181
181
  */
182
182
  export type AuthStateListener = (user: KoolbaseUser | null) => void;
183
+ /**
184
+ * Why the last session ended (#104): the person signed out, or the session
185
+ * ended on its own -- revoked, expired past renewal, signed out elsewhere.
186
+ * An app tells them apart to say "You were signed out" only when they did
187
+ * not ask to be.
188
+ */
189
+ export type SignOutReason = 'signed_out' | 'session_ended';
183
190
  /**
184
191
  * Drop-in replacement for the global `fetch` function. Inject via
185
192
  * KoolbaseConfig.fetch for testing (mock fetch), proxying, or
@@ -1,5 +1,5 @@
1
1
  import { AuthStateListener, KoolbaseConfig, KoolbaseSession, KoolbaseUser, LinkPhoneParams, LoginParams, OtpSendResult, PhoneVerifyResult, RegisterParams, RestoreResult, KoolbaseAuditPage, KoolbaseSessionInfo, ResendVerificationResult, SendOtpParams, SignUpResult, SignInWithAppleParams, VerifyOtpParams } from './types.js';
2
- import type { SignInWithGoogleParams } from './types.js';
2
+ import type { SignInWithGoogleParams, SignOutReason } from './types.js';
3
3
  export declare class KoolbaseAuth {
4
4
  private config;
5
5
  private storage;
@@ -9,6 +9,10 @@ export declare class KoolbaseAuth {
9
9
  private timeoutMs;
10
10
  private ongoingRefresh;
11
11
  private listeners;
12
+ /** Why the last session ended (#104); null while signed in or never signed in. */
13
+ private signOutReason;
14
+ /** The last renewal failed for want of a connection, not because the server refused it (#96). */
15
+ private renewalUnavailable;
12
16
  constructor(config: KoolbaseConfig);
13
17
  /**
14
18
  * Subscribe to authentication state changes. The listener fires:
@@ -82,6 +86,29 @@ export declare class KoolbaseAuth {
82
86
  * need to.
83
87
  */
84
88
  clearStoredSession(): Promise<void>;
89
+ /**
90
+ * Why the last session ended (#104): `'signed_out'` when the person signed
91
+ * out (or deleted their account), `'session_ended'` when it ended on its own
92
+ * -- revoked, past renewal, signed out on another device. null while signed
93
+ * in. Read it in an onAuthStateChange listener to say "You were signed out"
94
+ * only when they did not ask to be.
95
+ */
96
+ get lastSignOutReason(): SignOutReason | null;
97
+ /**
98
+ * A data request carrying `refused` came back unauthenticated. Renews the
99
+ * session and returns the token to send again with, or null when there is
100
+ * none -- nobody is signed in, or the server refused the renewal too, which
101
+ * ends the session (#96).
102
+ */
103
+ renewRejected(refused: string): Promise<string | null>;
104
+ /**
105
+ * A data request was refused as unauthenticated and renewing could not
106
+ * help: the session is over, so it ends here and every listener hears it.
107
+ * Unless the renewal failed only for want of a connection -- then the
108
+ * session may well be good, and signing someone out for a dropped network
109
+ * would be wrong (#96).
110
+ */
111
+ sessionRejected(): Promise<void>;
85
112
  private clearSessionInternal;
86
113
  restoreSession(): Promise<RestoreResult>;
87
114
  /**
@@ -164,6 +191,9 @@ export declare class KoolbaseAuth {
164
191
  private parseAppleSessionResponse;
165
192
  refresh(refreshToken?: string): Promise<KoolbaseSession>;
166
193
  private _doRefresh;
194
+ private readStored;
195
+ /** A newer session another tab stored after renewing `spent`, made current; null if none. */
196
+ private adoptNewer;
167
197
  logout(): Promise<boolean>;
168
198
  forgotPassword(email: string): Promise<void>;
169
199
  /**
package/dist/esm/auth.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { koolbaseFetch } from './network.js';
2
2
  import { RestoreResult, } from './types.js';
3
- import { AccountLockedError, EmailAlreadyInUseError, InvalidCredentialsError, InvalidPhoneNumberError, KoolbaseAuthError, OtpExpiredError, OtpInvalidError, OtpMaxAttemptsError, OtpRateLimitError, PhoneAlreadyLinkedError, IdentityNotFoundError, InsufficientScopeError, MalformedSessionResponseError, ProviderIdentityAlreadyLinkedError, AccountExistsError, ContactNotVerifiedError, CurrentPasswordIncorrectError, HideRequiresVerificationError, InsufficientAuthorityError, LastCredentialError, OAuthOnlyAccountError, SessionRequiredError, SignupsDisabledError, TokenAlreadyUsedError, TokenExpiredError, UnsupportedOAuthProviderError, RateLimitError, VerificationResendCooldownError, VerificationResendDailyCapError, SessionExpiredError, SmsConfigMissingError, UnlockTokenInvalidError, UserDisabledError, WeakPasswordError, AppleEmailRequiredError, AppleSignInNotConfiguredError, InvalidAppleTokenError, OAuthEmailConflictError, GoogleEmailRequiredError, GoogleSignInNotConfiguredError, InvalidGoogleTokenError, EmailCodeDisabledError, MfaRequiredError, RecentAuthRequiredError, RecentMfaRequiredError, MfaAlreadyEnabledError, MfaEnrollmentNotFoundError, MfaNotEnabledError, } from './auth-errors.js';
3
+ import { AccountLockedError, EmailAlreadyInUseError, InvalidCredentialsError, InvalidPhoneNumberError, KoolbaseAuthError, OtpExpiredError, OtpInvalidError, OtpMaxAttemptsError, OtpRateLimitError, PhoneAlreadyLinkedError, IdentityNotFoundError, InsufficientScopeError, MalformedSessionResponseError, ProviderIdentityAlreadyLinkedError, AccountExistsError, ContactNotVerifiedError, CurrentPasswordIncorrectError, HideRequiresVerificationError, InsufficientAuthorityError, LastCredentialError, OAuthOnlyAccountError, SessionRequiredError, SignupsDisabledError, TokenAlreadyUsedError, TokenExpiredError, UnsupportedOAuthProviderError, RateLimitError, VerificationResendCooldownError, VerificationResendDailyCapError, SessionExpiredError, SmsConfigMissingError, TokenRevokedError, UnlockTokenInvalidError, UserDisabledError, WeakPasswordError, AppleEmailRequiredError, AppleSignInNotConfiguredError, InvalidAppleTokenError, OAuthEmailConflictError, GoogleEmailRequiredError, GoogleSignInNotConfiguredError, InvalidGoogleTokenError, EmailCodeDisabledError, MfaRequiredError, RecentAuthRequiredError, RecentMfaRequiredError, MfaAlreadyEnabledError, MfaEnrollmentNotFoundError, MfaNotEnabledError, } from './auth-errors.js';
4
4
  import { getPlatform } from './platform.js';
5
5
  import { DeviceMetadata } from './device-metadata.js';
6
6
  export class KoolbaseAuth {
@@ -8,6 +8,10 @@ export class KoolbaseAuth {
8
8
  this.session = null;
9
9
  this.ongoingRefresh = null;
10
10
  this.listeners = new Set();
11
+ /** Why the last session ended (#104); null while signed in or never signed in. */
12
+ this.signOutReason = null;
13
+ /** The last renewal failed for want of a connection, not because the server refused it (#96). */
14
+ this.renewalUnavailable = false;
11
15
  this.config = config;
12
16
  this.metadata = new DeviceMetadata(config.appVersion);
13
17
  this.fetchFn = config.fetch ?? ((url, init) => koolbaseFetch(url, init));
@@ -149,6 +153,7 @@ export class KoolbaseAuth {
149
153
  }
150
154
  async setSessionInternal(session) {
151
155
  this.session = session;
156
+ this.signOutReason = null;
152
157
  if (this.storage) {
153
158
  try {
154
159
  await this.storage.saveSession(session);
@@ -175,9 +180,52 @@ export class KoolbaseAuth {
175
180
  * need to.
176
181
  */
177
182
  async clearStoredSession() {
178
- await this.clearSessionInternal();
183
+ await this.clearSessionInternal('session_ended');
179
184
  }
180
- async clearSessionInternal() {
185
+ /**
186
+ * Why the last session ended (#104): `'signed_out'` when the person signed
187
+ * out (or deleted their account), `'session_ended'` when it ended on its own
188
+ * -- revoked, past renewal, signed out on another device. null while signed
189
+ * in. Read it in an onAuthStateChange listener to say "You were signed out"
190
+ * only when they did not ask to be.
191
+ */
192
+ get lastSignOutReason() {
193
+ return this.signOutReason;
194
+ }
195
+ /**
196
+ * A data request carrying `refused` came back unauthenticated. Renews the
197
+ * session and returns the token to send again with, or null when there is
198
+ * none -- nobody is signed in, or the server refused the renewal too, which
199
+ * ends the session (#96).
200
+ */
201
+ async renewRejected(refused) {
202
+ if (!this.session)
203
+ return null;
204
+ // Another request renewed it while this one was out.
205
+ if (this.session.accessToken !== refused)
206
+ return this.session.accessToken;
207
+ try {
208
+ return (await this.refresh()).accessToken;
209
+ }
210
+ catch {
211
+ return null;
212
+ }
213
+ }
214
+ /**
215
+ * A data request was refused as unauthenticated and renewing could not
216
+ * help: the session is over, so it ends here and every listener hears it.
217
+ * Unless the renewal failed only for want of a connection -- then the
218
+ * session may well be good, and signing someone out for a dropped network
219
+ * would be wrong (#96).
220
+ */
221
+ async sessionRejected() {
222
+ if (!this.session || this.renewalUnavailable)
223
+ return;
224
+ await this.clearSessionInternal('session_ended');
225
+ }
226
+ async clearSessionInternal(reason = 'session_ended') {
227
+ if (this.session)
228
+ this.signOutReason = reason;
181
229
  this.session = null;
182
230
  if (this.storage) {
183
231
  try {
@@ -205,7 +253,28 @@ export class KoolbaseAuth {
205
253
  : 0;
206
254
  const oneMinuteMs = 60 * 1000;
207
255
  if (expiresAt > Date.now() + oneMinuteMs) {
208
- return RestoreResult.Restored;
256
+ // Valid by the clock is not valid: the session may have been signed out
257
+ // on another device, revoked, or the account disabled since. Ask the
258
+ // server before saying it is restored (#104). No answer -- offline, a
259
+ // timeout, a server error -- keeps the optimistic session: Offline.
260
+ try {
261
+ const res = await this.authRequest('/v1/sdk/auth/me', { method: 'GET', includeAuth: true });
262
+ if (res.ok) {
263
+ try {
264
+ await this.updateUserAndPersist(this.mapUser(await res.json()));
265
+ }
266
+ catch {
267
+ // The session is good; a user that will not parse keeps the stored one.
268
+ }
269
+ return RestoreResult.Restored;
270
+ }
271
+ if (res.status !== 401)
272
+ return RestoreResult.Offline;
273
+ // Refused: the access token is done. The refresh token decides.
274
+ }
275
+ catch {
276
+ return RestoreResult.Offline;
277
+ }
209
278
  }
210
279
  try {
211
280
  await this.refresh(persisted.refreshToken);
@@ -221,8 +290,8 @@ export class KoolbaseAuth {
221
290
  // key or the request rather than the user's session — and deleting the
222
291
  // refresh token on that reading signs someone out with no way back.
223
292
  // Nothing reaches here with it today; it stays out so nothing does.
224
- if (e instanceof SessionExpiredError) {
225
- await this.clearSessionInternal();
293
+ if (isRejection(e)) {
294
+ await this.clearSessionInternal('session_ended');
226
295
  return RestoreResult.Expired;
227
296
  }
228
297
  return RestoreResult.Offline;
@@ -495,17 +564,77 @@ export class KoolbaseAuth {
495
564
  return promise;
496
565
  }
497
566
  async _doRefresh(refreshToken) {
498
- const token = refreshToken ?? this.session?.refreshToken;
567
+ // The newest refresh token is the stored one: another tab of the same app
568
+ // may have renewed the session, and its in-memory copy here is spent.
569
+ const stored = refreshToken ? null : await this.readStored();
570
+ const token = refreshToken ?? stored?.refreshToken ?? this.session?.refreshToken;
499
571
  if (!token) {
500
572
  throw new SessionExpiredError();
501
573
  }
502
- const res = await this.authRequest('/v1/sdk/auth/refresh', {
503
- method: 'POST',
504
- body: { refresh_token: token },
505
- });
506
- const session = await this.parseSessionResponse(res, true);
507
- await this.setSessionInternal(session);
508
- return session;
574
+ try {
575
+ const res = await this.authRequest('/v1/sdk/auth/refresh', {
576
+ method: 'POST',
577
+ body: { refresh_token: token },
578
+ });
579
+ const session = await this.parseSessionResponse(res, true);
580
+ this.renewalUnavailable = false;
581
+ await this.setSessionInternal(session);
582
+ return session;
583
+ }
584
+ catch (e) {
585
+ if (!isRejection(e)) {
586
+ // No answer, or a server error: the session may be fine. Keep it.
587
+ this.renewalUnavailable = true;
588
+ throw e;
589
+ }
590
+ this.renewalUnavailable = false;
591
+ // Two tabs renewing at the same moment send the same token; the
592
+ // server renews one and refuses the other. The winner's session is in
593
+ // storage: take it rather than signing out (#96).
594
+ const adopted = await this.adoptNewer(token);
595
+ if (adopted)
596
+ return adopted;
597
+ await this.clearSessionInternal('session_ended');
598
+ throw e;
599
+ }
600
+ }
601
+ async readStored() {
602
+ if (!this.storage)
603
+ return null;
604
+ try {
605
+ return await this.storage.readSession();
606
+ }
607
+ catch {
608
+ return null;
609
+ }
610
+ }
611
+ /** A newer session another tab stored after renewing `spent`, made current; null if none. */
612
+ async adoptNewer(spent) {
613
+ for (const wait of [0, 400]) {
614
+ if (wait)
615
+ await new Promise((r) => setTimeout(r, wait));
616
+ const stored = await this.readStored();
617
+ if (!stored || stored.refreshToken === spent)
618
+ continue;
619
+ const expiresAt = stored.expiresAt ? new Date(stored.expiresAt).getTime() : 0;
620
+ if (expiresAt > Date.now() + 60 * 1000) {
621
+ await this.setSessionInternal(stored);
622
+ return stored;
623
+ }
624
+ try {
625
+ const res = await this.authRequest('/v1/sdk/auth/refresh', {
626
+ method: 'POST',
627
+ body: { refresh_token: stored.refreshToken },
628
+ });
629
+ const session = await this.parseSessionResponse(res, true);
630
+ await this.setSessionInternal(session);
631
+ return session;
632
+ }
633
+ catch {
634
+ return null;
635
+ }
636
+ }
637
+ return null;
509
638
  }
510
639
  async logout() {
511
640
  let serverSucceeded = true;
@@ -526,7 +655,7 @@ export class KoolbaseAuth {
526
655
  serverSucceeded = false;
527
656
  }
528
657
  finally {
529
- await this.clearSessionInternal();
658
+ await this.clearSessionInternal('signed_out');
530
659
  }
531
660
  return serverSucceeded;
532
661
  }
@@ -689,7 +818,7 @@ export class KoolbaseAuth {
689
818
  includeAuth: true,
690
819
  });
691
820
  await this.checkResponse(res);
692
- await this.clearSessionInternal();
821
+ await this.clearSessionInternal('signed_out');
693
822
  }
694
823
  /**
695
824
  * Every session the signed-in user has — a "where you're signed in" list.
@@ -812,8 +941,14 @@ export class KoolbaseAuth {
812
941
  try {
813
942
  return await this._ensureValidToken();
814
943
  }
815
- catch {
816
- return null;
944
+ catch (e) {
945
+ // Refused: the session has ended (and been cleared); go api-key-only.
946
+ if (isRejection(e))
947
+ return null;
948
+ // No answer: the session may be fine, so the request must not go out
949
+ // as a signed-out visitor (whose refusal would end it). It fails as
950
+ // the network failure it is, and the caller retries (#96).
951
+ throw e;
817
952
  }
818
953
  }
819
954
  async setSession(session) {
@@ -1061,9 +1196,9 @@ export class KoolbaseAuth {
1061
1196
  return session.accessToken;
1062
1197
  }
1063
1198
  catch (e) {
1064
- if (e instanceof KoolbaseAuthError)
1065
- throw e;
1066
- throw new SessionExpiredError();
1199
+ // A refusal stays a refusal; a network failure stays one too, rather
1200
+ // than being reported as an ended session (#96).
1201
+ throw e;
1067
1202
  }
1068
1203
  }
1069
1204
  mapUser(raw) {
@@ -1310,3 +1445,15 @@ export class KoolbaseAuth {
1310
1445
  throw new KoolbaseAuthError(msg || 'An unexpected error occurred', code || undefined);
1311
1446
  }
1312
1447
  }
1448
+ /**
1449
+ * Whether an error is the server refusing the session -- it ended -- rather
1450
+ * than no answer at all (#96). Only a refusal signs anyone out.
1451
+ */
1452
+ function isRejection(e) {
1453
+ if (e instanceof SessionExpiredError || e instanceof TokenRevokedError || e instanceof UserDisabledError)
1454
+ return true;
1455
+ if (e instanceof KoolbaseAuthError) {
1456
+ return ['invalid_refresh_token', 'token_revoked', 'account_disabled', 'mfa_session_insufficient', 'unauthenticated'].includes(e.code ?? '');
1457
+ }
1458
+ return false;
1459
+ }
@@ -67,3 +67,8 @@ export declare function optimisticallyInsert(userId: string, collection: string,
67
67
  * to get rid of one record.
68
68
  */
69
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>;
@@ -228,3 +228,33 @@ export async function removeFromCachedQueries(userId, collection, recordId) {
228
228
  // ignore
229
229
  }
230
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.