@koolbase/core 12.7.0 → 12.9.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.
@@ -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>): Promise<KoolbaseRecord>;
211
- delete(recordId: string): Promise<void>;
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
  *
@@ -3,6 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.KoolbaseDatabase = void 0;
4
4
  const network_js_1 = require("./network.js");
5
5
  const query_refresh_js_1 = require("./query-refresh.js");
6
+ const database_errors_js_1 = require("./database-errors.js");
6
7
  const errors_js_1 = require("./errors.js");
7
8
  const cache_store_js_1 = require("./cache-store.js");
8
9
  const offline_state_js_1 = require("./offline-state.js");
@@ -11,7 +12,7 @@ const pending_write_js_1 = require("./pending-write.js");
11
12
  const cache_store_js_2 = require("./cache-store.js");
12
13
  const sync_engine_js_1 = require("./sync-engine.js");
13
14
  const record_js_1 = require("./record.js");
14
- const database_errors_js_1 = require("./database-errors.js");
15
+ const database_errors_js_2 = require("./database-errors.js");
15
16
  function generateWriteId() {
16
17
  return 'local_' + Math.random().toString(36).slice(2) + Date.now().toString(36);
17
18
  }
@@ -111,7 +112,7 @@ class KoolbaseDatabase {
111
112
  }
112
113
  }
113
114
  if (!res.ok) {
114
- const err = (0, database_errors_js_1.koolbaseDataError)(res.status, data ?? {}, `Request failed: ${res.status}`);
115
+ const err = (0, database_errors_js_2.koolbaseDataError)(res.status, data ?? {}, `Request failed: ${res.status}`);
115
116
  if (err instanceof errors_js_1.KoolbaseUnauthenticatedError) {
116
117
  await this.onSessionExpired?.();
117
118
  }
@@ -145,7 +146,7 @@ class KoolbaseDatabase {
145
146
  }
146
147
  }
147
148
  if (!res.ok) {
148
- const err = (0, database_errors_js_1.koolbaseDataError)(res.status, data ?? {}, `Request failed: ${res.status}`);
149
+ const err = (0, database_errors_js_2.koolbaseDataError)(res.status, data ?? {}, `Request failed: ${res.status}`);
149
150
  if (err instanceof errors_js_1.KoolbaseUnauthenticatedError) {
150
151
  await this.onSessionExpired?.();
151
152
  }
@@ -230,7 +231,7 @@ class KoolbaseDatabase {
230
231
  */
231
232
  async invalidate(collection) {
232
233
  await (0, cache_store_js_2.invalidateCache)(this.getUserId() ?? 'anonymous', collection);
233
- await (0, query_refresh_js_1.refreshCollectionQueries)(collection);
234
+ void (0, query_refresh_js_1.refreshCollectionQueries)(collection);
234
235
  }
235
236
  // ─── Insert (online-first with offline fallback) ───────────────────────────
236
237
  /**
@@ -254,7 +255,7 @@ class KoolbaseDatabase {
254
255
  const raw = await this.request('POST', '/v1/sdk/db/insert', { collection, data });
255
256
  const record = (0, record_js_1.recordFromWire)(raw);
256
257
  await (0, cache_store_js_2.invalidateCache)(userId, collection);
257
- await (0, query_refresh_js_1.refreshCollectionQueries)(collection);
258
+ void (0, query_refresh_js_1.refreshCollectionQueries)(collection);
258
259
  // The response carries a fresh revision, so caching it keeps the
259
260
  // baseline current for whatever edits this record next.
260
261
  await (0, cache_store_js_1.cacheRecord)(userId, collection, record.id, record.data, record.revision);
@@ -329,7 +330,7 @@ class KoolbaseDatabase {
329
330
  // Keep the cache fresh, same intent as insert's post-success invalidate.
330
331
  const userId = this.getUserId() ?? 'anonymous';
331
332
  await (0, cache_store_js_2.invalidateCache)(userId, collection);
332
- await (0, query_refresh_js_1.refreshCollectionQueries)(collection);
333
+ void (0, query_refresh_js_1.refreshCollectionQueries)(collection);
333
334
  await (0, cache_store_js_1.cacheRecord)(userId, collection, record.id, record.data, record.revision);
334
335
  return { record, created };
335
336
  }
@@ -349,7 +350,7 @@ class KoolbaseDatabase {
349
350
  const body = await this.request('POST', '/v1/sdk/db/delete-where', { collection, filters });
350
351
  const userId = this.getUserId() ?? 'anonymous';
351
352
  await (0, cache_store_js_2.invalidateCache)(userId, collection);
352
- await (0, query_refresh_js_1.refreshCollectionQueries)(collection);
353
+ void (0, query_refresh_js_1.refreshCollectionQueries)(collection);
353
354
  return body.deleted ?? 0;
354
355
  }
355
356
  // ─── Batch (atomic, online-only) ────────────────────────────────────────────
@@ -413,7 +414,7 @@ class KoolbaseDatabase {
413
414
  }
414
415
  for (const col of touched) {
415
416
  await (0, cache_store_js_2.invalidateCache)(userId, col);
416
- await (0, query_refresh_js_1.refreshCollectionQueries)(col);
417
+ void (0, query_refresh_js_1.refreshCollectionQueries)(col);
417
418
  }
418
419
  return results;
419
420
  }
@@ -535,7 +536,7 @@ class KoolbaseDatabase {
535
536
  const { conflicts } = await (0, offline_state_js_1.readOfflineState)(userId);
536
537
  const found = conflicts.find((c) => c.id === id);
537
538
  if (!found) {
538
- throw new database_errors_js_1.KoolbaseDataError('That conflict is no longer outstanding — it may already have been resolved.', 'conflict_not_found');
539
+ throw new database_errors_js_2.KoolbaseDataError('That conflict is no longer outstanding — it may already have been resolved.', 'conflict_not_found');
539
540
  }
540
541
  return found;
541
542
  }
@@ -584,7 +585,7 @@ class KoolbaseDatabase {
584
585
  // retry replays the stale condition and a conflict whose resolution
585
586
  // fails once is permanently unresolvable except by abandon —
586
587
  // device-proven: three identical refusals against an unchanged server.
587
- const details = e instanceof database_errors_js_1.KoolbaseDataError ? e.details : undefined;
588
+ const details = e instanceof database_errors_js_2.KoolbaseDataError ? e.details : undefined;
588
589
  const current = details?.current_revision;
589
590
  const record = details?.record;
590
591
  if (typeof current === 'number') {
@@ -598,14 +599,14 @@ class KoolbaseDatabase {
598
599
  if (record)
599
600
  stored.server = record;
600
601
  });
601
- throw new database_errors_js_1.KoolbaseDataError('The record has changed again while deciding. The conflict now ' +
602
+ throw new database_errors_js_2.KoolbaseDataError('The record has changed again while deciding. The conflict now ' +
602
603
  'reflects the server\'s current state — review and retry.', 'revision_mismatch');
603
604
  }
604
605
  throw e;
605
606
  }
606
607
  await this.dropConflict(c.id);
607
608
  await (0, cache_store_js_2.invalidateCache)(this.getUserId() ?? 'anonymous', c.collection);
608
- await (0, query_refresh_js_1.refreshCollectionQueries)(c.collection);
609
+ void (0, query_refresh_js_1.refreshCollectionQueries)(c.collection);
609
610
  }
610
611
  // ─── Update (online-first with offline fallback) ───────────────────────────
611
612
  /**
@@ -662,12 +663,15 @@ class KoolbaseDatabase {
662
663
  return null;
663
664
  return { baseline: cached.data, revision: cached.revision, collection: cached.collection };
664
665
  }
665
- 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 = {}) {
666
670
  const userId = this.getUserId() ?? 'anonymous';
667
671
  // Resolved before the request, so a network failure has somewhere to go.
668
672
  const base = await this.resolveBaseline(userId, recordId);
669
673
  try {
670
- 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 } : {}) });
671
675
  const updated = (0, record_js_1.recordFromWire)(raw);
672
676
  // Its collection's live queries re-run (every one, if the record's
673
677
  // collection is not known).
@@ -675,7 +679,7 @@ class KoolbaseDatabase {
675
679
  if (updated.collection) {
676
680
  await (0, cache_store_js_1.cacheRecord)(userId, updated.collection, updated.id, updated.data, updated.revision);
677
681
  }
678
- await (0, query_refresh_js_1.refreshCollectionQueries)(refreshed);
682
+ void (0, query_refresh_js_1.refreshCollectionQueries)(refreshed);
679
683
  return updated;
680
684
  }
681
685
  catch (e) {
@@ -685,6 +689,10 @@ class KoolbaseDatabase {
685
689
  // credential — must not be queued: it will be refused again on every
686
690
  // retry. Checked against the root rather than the data family, because a
687
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);
688
696
  if (e instanceof errors_js_1.KoolbaseError)
689
697
  throw e;
690
698
  // Genuine network failure. Queueable only if the SDK knows what the
@@ -716,15 +724,19 @@ class KoolbaseDatabase {
716
724
  }
717
725
  }
718
726
  // ─── Delete ─────────────────────────────────────────────────────────────────
719
- 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 = {}) {
720
732
  const userId = this.getUserId() ?? 'anonymous';
721
733
  const base = await this.resolveBaseline(userId, recordId);
722
734
  try {
723
- 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}` : ''}`);
724
736
  await (0, cache_store_js_1.removeCachedRecord)(userId, recordId);
725
737
  // Its collection is known from the baseline when the record was seen;
726
738
  // otherwise every live query re-runs, rather than a guess.
727
- await (0, query_refresh_js_1.refreshCollectionQueries)(base?.collection);
739
+ void (0, query_refresh_js_1.refreshCollectionQueries)(base?.collection);
728
740
  }
729
741
  catch (e) {
730
742
  // A server that answered has refused: a permission denial or a missing
@@ -735,6 +747,10 @@ class KoolbaseDatabase {
735
747
  // credential — must not be queued: it will be refused again on every
736
748
  // retry. Checked against the root rather than the data family, because a
737
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);
738
754
  if (e instanceof errors_js_1.KoolbaseError)
739
755
  throw e;
740
756
  // Genuine network failure. Queued here rather than before the request,
@@ -3,8 +3,21 @@ export declare class KoolbaseFlags {
3
3
  private config;
4
4
  private payload;
5
5
  private deviceId;
6
+ private lastArgs;
7
+ private inFlight;
6
8
  constructor(config: KoolbaseConfig, deviceId: string);
7
9
  koolbaseFetch(appVersion: string, platform: string): Promise<void>;
10
+ /**
11
+ * The project id learned from the bootstrap payload, or '' while the first
12
+ * bootstrap has not completed (storage's publicUrlFor reads it).
13
+ */
14
+ projectId(): string;
15
+ /**
16
+ * Fetch the bootstrap payload again with the last arguments -- a nudge from
17
+ * storage when identity is missing, so a startup failure heals within the
18
+ * session. One fetch at a time; never throws.
19
+ */
20
+ refresh(): Promise<void>;
8
21
  isEnabled(key: string): boolean;
9
22
  getString(key: string, fallback?: string): string;
10
23
  getNumber(key: string, fallback?: number): number;
package/dist/cjs/flags.js CHANGED
@@ -5,10 +5,15 @@ const network_js_1 = require("./network.js");
5
5
  class KoolbaseFlags {
6
6
  constructor(config, deviceId) {
7
7
  this.payload = null;
8
+ // The last bootstrap's arguments, so a nudge can refresh with the same ones;
9
+ // and the fetch in flight, so nudges never stack.
10
+ this.lastArgs = null;
11
+ this.inFlight = null;
8
12
  this.config = config;
9
13
  this.deviceId = deviceId;
10
14
  }
11
15
  async koolbaseFetch(appVersion, platform) {
16
+ this.lastArgs = { appVersion, platform };
12
17
  try {
13
18
  const res = await (0, network_js_1.koolbaseFetch)(`${this.config.baseUrl}/v1/bootstrap?public_key=${this.config.publicKey}&device_id=${this.deviceId}&app_version=${appVersion}&platform=${platform}`);
14
19
  if (res.ok) {
@@ -17,6 +22,25 @@ class KoolbaseFlags {
17
22
  }
18
23
  catch (_) { }
19
24
  }
25
+ /**
26
+ * The project id learned from the bootstrap payload, or '' while the first
27
+ * bootstrap has not completed (storage's publicUrlFor reads it).
28
+ */
29
+ projectId() {
30
+ return this.payload?.project_id ?? '';
31
+ }
32
+ /**
33
+ * Fetch the bootstrap payload again with the last arguments -- a nudge from
34
+ * storage when identity is missing, so a startup failure heals within the
35
+ * session. One fetch at a time; never throws.
36
+ */
37
+ refresh() {
38
+ if (this.inFlight)
39
+ return this.inFlight;
40
+ const args = this.lastArgs ?? { appVersion: this.config.appVersion ?? '0.0.0', platform: 'unknown' };
41
+ this.inFlight = this.koolbaseFetch(args.appVersion, args.platform).finally(() => { this.inFlight = null; });
42
+ return this.inFlight;
43
+ }
20
44
  isEnabled(key) {
21
45
  const flag = this.payload?.flags[key];
22
46
  if (!flag || !flag.enabled || flag.kill_switch)
@@ -7,6 +7,22 @@ import { KoolbaseError } from './errors.js';
7
7
  export declare class KoolbaseStorageError extends KoolbaseError {
8
8
  constructor(message: string, code?: string);
9
9
  }
10
+ /**
11
+ * Thrown by {@link KoolbaseStorage.publicUrlFor} when the SDK has not yet
12
+ * learned which project it belongs to. Project identity arrives with the
13
+ * bootstrap payload; it is unavailable until the first bootstrap completes
14
+ * (an app starting offline) or when the server predates identity metadata.
15
+ *
16
+ * A DIFFERENT state from a `null` public URL: null means the project is
17
+ * known but the object cannot have a public URL (private bucket). This one is
18
+ * transient: it heals on the next successful bootstrap, which calling
19
+ * publicUrlFor also nudges.
20
+ *
21
+ * Stable `code`: `project_identity_unavailable`.
22
+ */
23
+ export declare class KoolbaseStorageProjectIdentityError extends KoolbaseStorageError {
24
+ constructor();
25
+ }
10
26
  /**
11
27
  * Thrown when an upload is rejected because an object already exists at
12
28
  * the requested path — the server responds with 409 Conflict and code
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.KoolbaseStorageMetadataInvalidError = exports.KoolbaseStorageMimeTypeError = exports.KoolbaseStorageFileTooLargeError = exports.KoolbaseStorageQuotaError = exports.KoolbaseCapBelowUsageError = exports.KoolbaseUploadExpiredError = exports.KoolbaseUploadURLFailedError = exports.KoolbaseStoragePermissionError = exports.KoolbaseStorageValidationError = exports.KoolbaseStorageNotFoundError = exports.KoolbaseStorageConflictError = exports.KoolbaseStorageError = void 0;
3
+ exports.KoolbaseStorageMetadataInvalidError = exports.KoolbaseStorageMimeTypeError = exports.KoolbaseStorageFileTooLargeError = exports.KoolbaseStorageQuotaError = exports.KoolbaseCapBelowUsageError = exports.KoolbaseUploadExpiredError = exports.KoolbaseUploadURLFailedError = exports.KoolbaseStoragePermissionError = exports.KoolbaseStorageValidationError = exports.KoolbaseStorageNotFoundError = exports.KoolbaseStorageConflictError = exports.KoolbaseStorageProjectIdentityError = exports.KoolbaseStorageError = void 0;
4
4
  exports.koolbaseStorageError = koolbaseStorageError;
5
5
  exports.koolbaseStorageErrorFromResponse = koolbaseStorageErrorFromResponse;
6
6
  const errors_js_1 = require("./errors.js");
@@ -17,6 +17,27 @@ class KoolbaseStorageError extends errors_js_1.KoolbaseError {
17
17
  }
18
18
  }
19
19
  exports.KoolbaseStorageError = KoolbaseStorageError;
20
+ /**
21
+ * Thrown by {@link KoolbaseStorage.publicUrlFor} when the SDK has not yet
22
+ * learned which project it belongs to. Project identity arrives with the
23
+ * bootstrap payload; it is unavailable until the first bootstrap completes
24
+ * (an app starting offline) or when the server predates identity metadata.
25
+ *
26
+ * A DIFFERENT state from a `null` public URL: null means the project is
27
+ * known but the object cannot have a public URL (private bucket). This one is
28
+ * transient: it heals on the next successful bootstrap, which calling
29
+ * publicUrlFor also nudges.
30
+ *
31
+ * Stable `code`: `project_identity_unavailable`.
32
+ */
33
+ class KoolbaseStorageProjectIdentityError extends KoolbaseStorageError {
34
+ constructor() {
35
+ super('The SDK has not yet learned its project identity -- it arrives with the first successful bootstrap. Retry after connectivity is available.', 'project_identity_unavailable');
36
+ this.name = 'KoolbaseStorageProjectIdentityError';
37
+ Object.setPrototypeOf(this, KoolbaseStorageProjectIdentityError.prototype);
38
+ }
39
+ }
40
+ exports.KoolbaseStorageProjectIdentityError = KoolbaseStorageProjectIdentityError;
20
41
  /**
21
42
  * Thrown when an upload is rejected because an object already exists at
22
43
  * the requested path — the server responds with 409 Conflict and code
@@ -19,7 +19,11 @@ export declare class KoolbaseStorage {
19
19
  * keeps believing it is signed in.
20
20
  */
21
21
  private onSessionExpired?;
22
- constructor(config: KoolbaseConfig, getToken: () => Promise<string | null>, onSessionExpired?: () => Promise<void>);
22
+ /** The project id from the bootstrap payload, '' until it arrives. */
23
+ private projectIdProvider?;
24
+ /** A background bootstrap refresh, nudged when identity is missing. */
25
+ private nudgeBootstrap?;
26
+ constructor(config: KoolbaseConfig, getToken: () => Promise<string | null>, onSessionExpired?: () => Promise<void>, projectIdProvider?: () => string, nudgeBootstrap?: () => void);
23
27
  /**
24
28
  * Builds the error for a failed response and clears the session when the
25
29
  * credentials were refused, before the error reaches the caller.
@@ -87,6 +91,20 @@ export declare class KoolbaseStorage {
87
91
  * Get a signed download URL for a file.
88
92
  */
89
93
  getDownloadUrl(bucket: string, path: string, versionId?: string): Promise<string>;
94
+ /**
95
+ * The stable public CDN URL for a file in a public bucket, using the project
96
+ * identity the SDK learned at bootstrap -- the runtime form of
97
+ * {@link KoolbaseStorage.publicUrl}, needing no projectId (as Flutter's).
98
+ *
99
+ * Throws {@link KoolbaseStorageProjectIdentityError} while identity is
100
+ * unavailable, and nudges a background bootstrap refresh so a later call in
101
+ * the same session succeeds once connectivity returns.
102
+ */
103
+ publicUrlFor(args: {
104
+ bucket: string;
105
+ path: string;
106
+ transform?: KoolbaseImageTransform;
107
+ }): string;
90
108
  /**
91
109
  * Build the stable public CDN URL for a file in a public bucket.
92
110
  *
@@ -1,9 +1,10 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.KoolbaseStorage = void 0;
4
+ const storage_errors_js_1 = require("./storage-errors.js");
4
5
  const network_js_1 = require("./network.js");
5
6
  const errors_js_1 = require("./errors.js");
6
- const storage_errors_js_1 = require("./storage-errors.js");
7
+ const storage_errors_js_2 = require("./storage-errors.js");
7
8
  // --- Cloudflare image-transform URL helpers -------------------------------
8
9
  // Module-private — callers use KoolbaseStorage.publicUrl / publicUrlForObject.
9
10
  function clampInt(v, min, max) {
@@ -42,17 +43,19 @@ function serializeTransform(t) {
42
43
  * `overwrite: true` is passed.
43
44
  */
44
45
  class KoolbaseStorage {
45
- constructor(config, getToken, onSessionExpired) {
46
+ constructor(config, getToken, onSessionExpired, projectIdProvider, nudgeBootstrap) {
46
47
  this.config = config;
47
48
  this.getToken = getToken;
48
49
  this.onSessionExpired = onSessionExpired;
50
+ this.projectIdProvider = projectIdProvider;
51
+ this.nudgeBootstrap = nudgeBootstrap;
49
52
  }
50
53
  /**
51
54
  * Builds the error for a failed response and clears the session when the
52
55
  * credentials were refused, before the error reaches the caller.
53
56
  */
54
57
  async error(res, fallback) {
55
- const err = await (0, storage_errors_js_1.koolbaseStorageErrorFromResponse)(res, fallback);
58
+ const err = await (0, storage_errors_js_2.koolbaseStorageErrorFromResponse)(res, fallback);
56
59
  if (err instanceof errors_js_1.KoolbaseUnauthenticatedError) {
57
60
  await this.onSessionExpired?.();
58
61
  }
@@ -134,7 +137,7 @@ class KoolbaseStorage {
134
137
  if (!uploadRes.ok) {
135
138
  // R2 PUT errors don't follow the Koolbase error shape — surface as a
136
139
  // generic storage error rather than trying to decode a Koolbase body.
137
- throw new storage_errors_js_1.KoolbaseStorageError(`Upload to storage failed: ${uploadRes.status}`);
140
+ throw new storage_errors_js_2.KoolbaseStorageError(`Upload to storage failed: ${uploadRes.status}`);
138
141
  }
139
142
  const etag = uploadRes.headers.get('etag') ?? '';
140
143
  // ─── Step 3: Confirm upload ───
@@ -234,6 +237,23 @@ class KoolbaseStorage {
234
237
  const data = (await res.json());
235
238
  return data.url;
236
239
  }
240
+ /**
241
+ * The stable public CDN URL for a file in a public bucket, using the project
242
+ * identity the SDK learned at bootstrap -- the runtime form of
243
+ * {@link KoolbaseStorage.publicUrl}, needing no projectId (as Flutter's).
244
+ *
245
+ * Throws {@link KoolbaseStorageProjectIdentityError} while identity is
246
+ * unavailable, and nudges a background bootstrap refresh so a later call in
247
+ * the same session succeeds once connectivity returns.
248
+ */
249
+ publicUrlFor(args) {
250
+ const pid = this.projectIdProvider?.() ?? '';
251
+ if (pid === '') {
252
+ this.nudgeBootstrap?.();
253
+ throw new storage_errors_js_1.KoolbaseStorageProjectIdentityError();
254
+ }
255
+ return KoolbaseStorage.publicUrl({ projectId: pid, bucket: args.bucket, path: args.path, transform: args.transform });
256
+ }
237
257
  /**
238
258
  * Build the stable public CDN URL for a file in a public bucket.
239
259
  *
@@ -457,6 +457,9 @@ export interface RealtimeEvent {
457
457
  export type RealtimeCallback = (event: RealtimeEvent) => void;
458
458
  export interface BootstrapPayload {
459
459
  payload_version: string;
460
+ /** The project this public key belongs to: storage's publicUrlFor builds CDN
461
+ * URLs with it. Absent from servers that predate identity metadata. */
462
+ project_id?: string;
460
463
  flags: Record<string, {
461
464
  enabled: boolean;
462
465
  rollout_percentage: number;
@@ -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>): Promise<KoolbaseRecord>;
211
- delete(recordId: string): Promise<void>;
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
  *
@@ -1,5 +1,6 @@
1
1
  import { koolbaseFetch } from './network.js';
2
2
  import { refreshCollectionQueries } from './query-refresh.js';
3
+ import { KoolbaseRevisionMismatchError } from './database-errors.js';
3
4
  import { KoolbaseError, KoolbaseOfflineBaselineUnavailableError, KoolbaseUnauthenticatedError, } from './errors.js';
4
5
  import { cacheRecord, getCachedRecord, removeCachedRecord } from './cache-store.js';
5
6
  import { readOfflineState, mutateOfflineState, queueWrite, } from './offline-state.js';
@@ -227,7 +228,7 @@ export class KoolbaseDatabase {
227
228
  */
228
229
  async invalidate(collection) {
229
230
  await invalidateCache(this.getUserId() ?? 'anonymous', collection);
230
- await refreshCollectionQueries(collection);
231
+ void refreshCollectionQueries(collection);
231
232
  }
232
233
  // ─── Insert (online-first with offline fallback) ───────────────────────────
233
234
  /**
@@ -251,7 +252,7 @@ export class KoolbaseDatabase {
251
252
  const raw = await this.request('POST', '/v1/sdk/db/insert', { collection, data });
252
253
  const record = recordFromWire(raw);
253
254
  await invalidateCache(userId, collection);
254
- await refreshCollectionQueries(collection);
255
+ void refreshCollectionQueries(collection);
255
256
  // The response carries a fresh revision, so caching it keeps the
256
257
  // baseline current for whatever edits this record next.
257
258
  await cacheRecord(userId, collection, record.id, record.data, record.revision);
@@ -326,7 +327,7 @@ export class KoolbaseDatabase {
326
327
  // Keep the cache fresh, same intent as insert's post-success invalidate.
327
328
  const userId = this.getUserId() ?? 'anonymous';
328
329
  await invalidateCache(userId, collection);
329
- await refreshCollectionQueries(collection);
330
+ void refreshCollectionQueries(collection);
330
331
  await cacheRecord(userId, collection, record.id, record.data, record.revision);
331
332
  return { record, created };
332
333
  }
@@ -346,7 +347,7 @@ export class KoolbaseDatabase {
346
347
  const body = await this.request('POST', '/v1/sdk/db/delete-where', { collection, filters });
347
348
  const userId = this.getUserId() ?? 'anonymous';
348
349
  await invalidateCache(userId, collection);
349
- await refreshCollectionQueries(collection);
350
+ void refreshCollectionQueries(collection);
350
351
  return body.deleted ?? 0;
351
352
  }
352
353
  // ─── Batch (atomic, online-only) ────────────────────────────────────────────
@@ -410,7 +411,7 @@ export class KoolbaseDatabase {
410
411
  }
411
412
  for (const col of touched) {
412
413
  await invalidateCache(userId, col);
413
- await refreshCollectionQueries(col);
414
+ void refreshCollectionQueries(col);
414
415
  }
415
416
  return results;
416
417
  }
@@ -602,7 +603,7 @@ export class KoolbaseDatabase {
602
603
  }
603
604
  await this.dropConflict(c.id);
604
605
  await invalidateCache(this.getUserId() ?? 'anonymous', c.collection);
605
- await refreshCollectionQueries(c.collection);
606
+ void refreshCollectionQueries(c.collection);
606
607
  }
607
608
  // ─── Update (online-first with offline fallback) ───────────────────────────
608
609
  /**
@@ -659,12 +660,15 @@ export class KoolbaseDatabase {
659
660
  return null;
660
661
  return { baseline: cached.data, revision: cached.revision, collection: cached.collection };
661
662
  }
662
- 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 = {}) {
663
667
  const userId = this.getUserId() ?? 'anonymous';
664
668
  // Resolved before the request, so a network failure has somewhere to go.
665
669
  const base = await this.resolveBaseline(userId, recordId);
666
670
  try {
667
- 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 } : {}) });
668
672
  const updated = recordFromWire(raw);
669
673
  // Its collection's live queries re-run (every one, if the record's
670
674
  // collection is not known).
@@ -672,7 +676,7 @@ export class KoolbaseDatabase {
672
676
  if (updated.collection) {
673
677
  await cacheRecord(userId, updated.collection, updated.id, updated.data, updated.revision);
674
678
  }
675
- await refreshCollectionQueries(refreshed);
679
+ void refreshCollectionQueries(refreshed);
676
680
  return updated;
677
681
  }
678
682
  catch (e) {
@@ -682,6 +686,10 @@ export class KoolbaseDatabase {
682
686
  // credential — must not be queued: it will be refused again on every
683
687
  // retry. Checked against the root rather than the data family, because a
684
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);
685
693
  if (e instanceof KoolbaseError)
686
694
  throw e;
687
695
  // Genuine network failure. Queueable only if the SDK knows what the
@@ -713,15 +721,19 @@ export class KoolbaseDatabase {
713
721
  }
714
722
  }
715
723
  // ─── Delete ─────────────────────────────────────────────────────────────────
716
- 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 = {}) {
717
729
  const userId = this.getUserId() ?? 'anonymous';
718
730
  const base = await this.resolveBaseline(userId, recordId);
719
731
  try {
720
- 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}` : ''}`);
721
733
  await removeCachedRecord(userId, recordId);
722
734
  // Its collection is known from the baseline when the record was seen;
723
735
  // otherwise every live query re-runs, rather than a guess.
724
- await refreshCollectionQueries(base?.collection);
736
+ void refreshCollectionQueries(base?.collection);
725
737
  }
726
738
  catch (e) {
727
739
  // A server that answered has refused: a permission denial or a missing
@@ -732,6 +744,10 @@ export class KoolbaseDatabase {
732
744
  // credential — must not be queued: it will be refused again on every
733
745
  // retry. Checked against the root rather than the data family, because a
734
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);
735
751
  if (e instanceof KoolbaseError)
736
752
  throw e;
737
753
  // Genuine network failure. Queued here rather than before the request,
@@ -3,8 +3,21 @@ export declare class KoolbaseFlags {
3
3
  private config;
4
4
  private payload;
5
5
  private deviceId;
6
+ private lastArgs;
7
+ private inFlight;
6
8
  constructor(config: KoolbaseConfig, deviceId: string);
7
9
  koolbaseFetch(appVersion: string, platform: string): Promise<void>;
10
+ /**
11
+ * The project id learned from the bootstrap payload, or '' while the first
12
+ * bootstrap has not completed (storage's publicUrlFor reads it).
13
+ */
14
+ projectId(): string;
15
+ /**
16
+ * Fetch the bootstrap payload again with the last arguments -- a nudge from
17
+ * storage when identity is missing, so a startup failure heals within the
18
+ * session. One fetch at a time; never throws.
19
+ */
20
+ refresh(): Promise<void>;
8
21
  isEnabled(key: string): boolean;
9
22
  getString(key: string, fallback?: string): string;
10
23
  getNumber(key: string, fallback?: number): number;
package/dist/esm/flags.js CHANGED
@@ -2,10 +2,15 @@ import { koolbaseFetch } from './network.js';
2
2
  export class KoolbaseFlags {
3
3
  constructor(config, deviceId) {
4
4
  this.payload = null;
5
+ // The last bootstrap's arguments, so a nudge can refresh with the same ones;
6
+ // and the fetch in flight, so nudges never stack.
7
+ this.lastArgs = null;
8
+ this.inFlight = null;
5
9
  this.config = config;
6
10
  this.deviceId = deviceId;
7
11
  }
8
12
  async koolbaseFetch(appVersion, platform) {
13
+ this.lastArgs = { appVersion, platform };
9
14
  try {
10
15
  const res = await koolbaseFetch(`${this.config.baseUrl}/v1/bootstrap?public_key=${this.config.publicKey}&device_id=${this.deviceId}&app_version=${appVersion}&platform=${platform}`);
11
16
  if (res.ok) {
@@ -14,6 +19,25 @@ export class KoolbaseFlags {
14
19
  }
15
20
  catch (_) { }
16
21
  }
22
+ /**
23
+ * The project id learned from the bootstrap payload, or '' while the first
24
+ * bootstrap has not completed (storage's publicUrlFor reads it).
25
+ */
26
+ projectId() {
27
+ return this.payload?.project_id ?? '';
28
+ }
29
+ /**
30
+ * Fetch the bootstrap payload again with the last arguments -- a nudge from
31
+ * storage when identity is missing, so a startup failure heals within the
32
+ * session. One fetch at a time; never throws.
33
+ */
34
+ refresh() {
35
+ if (this.inFlight)
36
+ return this.inFlight;
37
+ const args = this.lastArgs ?? { appVersion: this.config.appVersion ?? '0.0.0', platform: 'unknown' };
38
+ this.inFlight = this.koolbaseFetch(args.appVersion, args.platform).finally(() => { this.inFlight = null; });
39
+ return this.inFlight;
40
+ }
17
41
  isEnabled(key) {
18
42
  const flag = this.payload?.flags[key];
19
43
  if (!flag || !flag.enabled || flag.kill_switch)
@@ -7,6 +7,22 @@ import { KoolbaseError } from './errors.js';
7
7
  export declare class KoolbaseStorageError extends KoolbaseError {
8
8
  constructor(message: string, code?: string);
9
9
  }
10
+ /**
11
+ * Thrown by {@link KoolbaseStorage.publicUrlFor} when the SDK has not yet
12
+ * learned which project it belongs to. Project identity arrives with the
13
+ * bootstrap payload; it is unavailable until the first bootstrap completes
14
+ * (an app starting offline) or when the server predates identity metadata.
15
+ *
16
+ * A DIFFERENT state from a `null` public URL: null means the project is
17
+ * known but the object cannot have a public URL (private bucket). This one is
18
+ * transient: it heals on the next successful bootstrap, which calling
19
+ * publicUrlFor also nudges.
20
+ *
21
+ * Stable `code`: `project_identity_unavailable`.
22
+ */
23
+ export declare class KoolbaseStorageProjectIdentityError extends KoolbaseStorageError {
24
+ constructor();
25
+ }
10
26
  /**
11
27
  * Thrown when an upload is rejected because an object already exists at
12
28
  * the requested path — the server responds with 409 Conflict and code
@@ -11,6 +11,26 @@ export class KoolbaseStorageError extends KoolbaseError {
11
11
  Object.setPrototypeOf(this, KoolbaseStorageError.prototype);
12
12
  }
13
13
  }
14
+ /**
15
+ * Thrown by {@link KoolbaseStorage.publicUrlFor} when the SDK has not yet
16
+ * learned which project it belongs to. Project identity arrives with the
17
+ * bootstrap payload; it is unavailable until the first bootstrap completes
18
+ * (an app starting offline) or when the server predates identity metadata.
19
+ *
20
+ * A DIFFERENT state from a `null` public URL: null means the project is
21
+ * known but the object cannot have a public URL (private bucket). This one is
22
+ * transient: it heals on the next successful bootstrap, which calling
23
+ * publicUrlFor also nudges.
24
+ *
25
+ * Stable `code`: `project_identity_unavailable`.
26
+ */
27
+ export class KoolbaseStorageProjectIdentityError extends KoolbaseStorageError {
28
+ constructor() {
29
+ super('The SDK has not yet learned its project identity -- it arrives with the first successful bootstrap. Retry after connectivity is available.', 'project_identity_unavailable');
30
+ this.name = 'KoolbaseStorageProjectIdentityError';
31
+ Object.setPrototypeOf(this, KoolbaseStorageProjectIdentityError.prototype);
32
+ }
33
+ }
14
34
  /**
15
35
  * Thrown when an upload is rejected because an object already exists at
16
36
  * the requested path — the server responds with 409 Conflict and code
@@ -19,7 +19,11 @@ export declare class KoolbaseStorage {
19
19
  * keeps believing it is signed in.
20
20
  */
21
21
  private onSessionExpired?;
22
- constructor(config: KoolbaseConfig, getToken: () => Promise<string | null>, onSessionExpired?: () => Promise<void>);
22
+ /** The project id from the bootstrap payload, '' until it arrives. */
23
+ private projectIdProvider?;
24
+ /** A background bootstrap refresh, nudged when identity is missing. */
25
+ private nudgeBootstrap?;
26
+ constructor(config: KoolbaseConfig, getToken: () => Promise<string | null>, onSessionExpired?: () => Promise<void>, projectIdProvider?: () => string, nudgeBootstrap?: () => void);
23
27
  /**
24
28
  * Builds the error for a failed response and clears the session when the
25
29
  * credentials were refused, before the error reaches the caller.
@@ -87,6 +91,20 @@ export declare class KoolbaseStorage {
87
91
  * Get a signed download URL for a file.
88
92
  */
89
93
  getDownloadUrl(bucket: string, path: string, versionId?: string): Promise<string>;
94
+ /**
95
+ * The stable public CDN URL for a file in a public bucket, using the project
96
+ * identity the SDK learned at bootstrap -- the runtime form of
97
+ * {@link KoolbaseStorage.publicUrl}, needing no projectId (as Flutter's).
98
+ *
99
+ * Throws {@link KoolbaseStorageProjectIdentityError} while identity is
100
+ * unavailable, and nudges a background bootstrap refresh so a later call in
101
+ * the same session succeeds once connectivity returns.
102
+ */
103
+ publicUrlFor(args: {
104
+ bucket: string;
105
+ path: string;
106
+ transform?: KoolbaseImageTransform;
107
+ }): string;
90
108
  /**
91
109
  * Build the stable public CDN URL for a file in a public bucket.
92
110
  *
@@ -1,3 +1,4 @@
1
+ import { KoolbaseStorageProjectIdentityError } from './storage-errors.js';
1
2
  import { koolbaseFetch } from './network.js';
2
3
  import { KoolbaseUnauthenticatedError } from './errors.js';
3
4
  import { KoolbaseStorageError, koolbaseStorageErrorFromResponse, } from './storage-errors.js';
@@ -39,10 +40,12 @@ function serializeTransform(t) {
39
40
  * `overwrite: true` is passed.
40
41
  */
41
42
  export class KoolbaseStorage {
42
- constructor(config, getToken, onSessionExpired) {
43
+ constructor(config, getToken, onSessionExpired, projectIdProvider, nudgeBootstrap) {
43
44
  this.config = config;
44
45
  this.getToken = getToken;
45
46
  this.onSessionExpired = onSessionExpired;
47
+ this.projectIdProvider = projectIdProvider;
48
+ this.nudgeBootstrap = nudgeBootstrap;
46
49
  }
47
50
  /**
48
51
  * Builds the error for a failed response and clears the session when the
@@ -231,6 +234,23 @@ export class KoolbaseStorage {
231
234
  const data = (await res.json());
232
235
  return data.url;
233
236
  }
237
+ /**
238
+ * The stable public CDN URL for a file in a public bucket, using the project
239
+ * identity the SDK learned at bootstrap -- the runtime form of
240
+ * {@link KoolbaseStorage.publicUrl}, needing no projectId (as Flutter's).
241
+ *
242
+ * Throws {@link KoolbaseStorageProjectIdentityError} while identity is
243
+ * unavailable, and nudges a background bootstrap refresh so a later call in
244
+ * the same session succeeds once connectivity returns.
245
+ */
246
+ publicUrlFor(args) {
247
+ const pid = this.projectIdProvider?.() ?? '';
248
+ if (pid === '') {
249
+ this.nudgeBootstrap?.();
250
+ throw new KoolbaseStorageProjectIdentityError();
251
+ }
252
+ return KoolbaseStorage.publicUrl({ projectId: pid, bucket: args.bucket, path: args.path, transform: args.transform });
253
+ }
234
254
  /**
235
255
  * Build the stable public CDN URL for a file in a public bucket.
236
256
  *
@@ -457,6 +457,9 @@ export interface RealtimeEvent {
457
457
  export type RealtimeCallback = (event: RealtimeEvent) => void;
458
458
  export interface BootstrapPayload {
459
459
  payload_version: string;
460
+ /** The project this public key belongs to: storage's publicUrlFor builds CDN
461
+ * URLs with it. Absent from servers that predate identity metadata. */
462
+ project_id?: string;
460
463
  flags: Record<string, {
461
464
  enabled: boolean;
462
465
  rollout_percentage: number;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@koolbase/core",
3
- "version": "12.7.0",
4
- "description": "Koolbase SDK core — shared behaviour behind @koolbase/react-native and @koolbase/js. Install one of those, not this.",
3
+ "version": "12.9.0",
4
+ "description": "Koolbase SDK core \u2014 shared behaviour behind @koolbase/react-native and @koolbase/js. Install one of those, not this.",
5
5
  "main": "./dist/cjs/index.js",
6
6
  "types": "./dist/esm/index.d.ts",
7
7
  "files": [