@techfinityedge/koolbase-react-native 3.1.0 → 4.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -257,6 +257,28 @@ const results = await Koolbase.db.batch([
257
257
 
258
258
  ---
259
259
 
260
+ ### Handling write conflicts
261
+
262
+ `insert`, `update`, and `upsert` are online-first: when the server is reachable they throw a typed error on rejection. Catch `KoolbaseConflictError` to handle unique-constraint violations (e.g. a duplicate email):
263
+
264
+ ```ts
265
+ import { KoolbaseConflictError } from '@techfinityedge/koolbase-react-native';
266
+
267
+ try {
268
+ await Koolbase.db.insert('users', { email, name });
269
+ } catch (e) {
270
+ if (e instanceof KoolbaseConflictError) {
271
+ showError(`That ${e.field ?? 'value'} is already in use.`);
272
+ } else {
273
+ throw e;
274
+ }
275
+ }
276
+ ```
277
+
278
+ When the device is offline, these writes are queued and synced automatically when connectivity returns.
279
+
280
+ ---
281
+
260
282
  ## Storage
261
283
 
262
284
  ```typescript
@@ -275,15 +297,27 @@ await Koolbase.storage.delete('avatars', `user-${userId}.jpg`);
275
297
 
276
298
  ## Realtime
277
299
 
278
- ```typescript
300
+ Subscribe to live changes on a collection. Realtime uses the signed-in user's
301
+ session, so subscribe after login. It streams `created` and `updated` events for
302
+ collections whose read rule is `public` or `authenticated`.
303
+
304
+ ```ts
305
+ import { Koolbase } from '@techfinityedge/koolbase-react-native';
306
+
279
307
  const unsubscribe = Koolbase.realtime.subscribe('messages', (event) => {
280
- if (event.type === 'created') setMessages(prev => [event.record, ...prev]);
308
+ // event.type -> 'created' | 'updated'
309
+ // event.collection -> 'messages'
310
+ // event.record -> KoolbaseRecord
311
+ console.log(event.type, event.record.data);
281
312
  });
282
313
 
283
- // Cleanup
284
- unsubscribe();
314
+ unsubscribe(); // stop listening
285
315
  ```
286
316
 
317
+ The socket opens lazily on first `subscribe`, is shared across all subscriptions,
318
+ and reconnects automatically. The project is taken from the user's session — you
319
+ don't pass it.
320
+
287
321
  ---
288
322
 
289
323
  ## Functions
@@ -20,10 +20,11 @@ export declare class KoolbaseDataError extends Error {
20
20
  * (`details.field`) — useful when a collection has more than one unique
21
21
  * constraint and you need to know which value clashed.
22
22
  *
23
- * Currently surfaced by `upsert` (the online-only write). `insert` and
24
- * `update` are optimistic/offline-first: they accept the write locally and
25
- * sync in the background, so a constraint conflict on those paths is a
26
- * sync-time concern rather than a thrown error.
23
+ * Surfaced by `insert`, `update`, and `upsert` whenever the server is
24
+ * reachable and rejects the write with a 409. These writes are online-first:
25
+ * a server-side conflict throws immediately. Only a genuine network failure
26
+ * falls back to the offline queue, where a conflict that surfaces at sync
27
+ * time is handled by the sync engine rather than thrown here.
27
28
  *
28
29
  * @example
29
30
  * try {
@@ -29,10 +29,11 @@ exports.KoolbaseDataError = KoolbaseDataError;
29
29
  * (`details.field`) — useful when a collection has more than one unique
30
30
  * constraint and you need to know which value clashed.
31
31
  *
32
- * Currently surfaced by `upsert` (the online-only write). `insert` and
33
- * `update` are optimistic/offline-first: they accept the write locally and
34
- * sync in the background, so a constraint conflict on those paths is a
35
- * sync-time concern rather than a thrown error.
32
+ * Surfaced by `insert`, `update`, and `upsert` whenever the server is
33
+ * reachable and rejects the write with a 409. These writes are online-first:
34
+ * a server-side conflict throws immediately. Only a genuine network failure
35
+ * falls back to the offline queue, where a conflict that surfaces at sync
36
+ * time is handled by the sync engine rather than thrown here.
36
37
  *
37
38
  * @example
38
39
  * try {
@@ -9,6 +9,18 @@ export declare class KoolbaseDatabase {
9
9
  private request;
10
10
  private runQuery;
11
11
  query(collection: string, options?: QueryOptions): Promise<QueryResult>;
12
+ /**
13
+ * Insert a new record into a collection.
14
+ *
15
+ * Online-first: awaits the server so a server-side rejection (unique
16
+ * violation, validation error, permission denial) surfaces as the typed
17
+ * `KoolbaseDataError` subclass — `insert` now throws `KoolbaseConflictError`
18
+ * with the offending field on a 409, matching `upsert` and `update`.
19
+ *
20
+ * On genuine network failure (server unreachable, timeout) the write is
21
+ * accepted optimistically: saved to the local cache and queued for sync
22
+ * when connectivity returns.
23
+ */
12
24
  insert(collection: string, data: Record<string, unknown>): Promise<KoolbaseRecord>;
13
25
  /**
14
26
  * Insert a record, or update the existing one matching `match`.
@@ -63,6 +75,19 @@ export declare class KoolbaseDatabase {
63
75
  */
64
76
  batch(operations: BatchOp[]): Promise<BatchResult[]>;
65
77
  get(recordId: string): Promise<KoolbaseRecord>;
78
+ /**
79
+ * Update a record's fields by id.
80
+ *
81
+ * Online-first: awaits the server so a server-side rejection (unique
82
+ * violation, not found, permission denial) surfaces as the typed
83
+ * `KoolbaseDataError` subclass. An update that would violate a unique
84
+ * constraint now throws `KoolbaseConflictError` with the offending field —
85
+ * same shape as `insert` and `upsert`.
86
+ *
87
+ * On genuine network failure the update is queued for sync and a partial
88
+ * optimistic record is returned so the UI can re-render the new fields
89
+ * immediately.
90
+ */
66
91
  update(recordId: string, data: Record<string, unknown>): Promise<KoolbaseRecord>;
67
92
  delete(recordId: string): Promise<void>;
68
93
  syncPendingWrites(): Promise<void>;
package/dist/database.js CHANGED
@@ -84,39 +84,56 @@ class KoolbaseDatabase {
84
84
  await (0, cache_store_1.setCached)(userId, collection, queryHash, result);
85
85
  return { ...result, isFromCache: false };
86
86
  }
87
- // ─── Insert (optimistic) ────────────────────────────────────────────────────
87
+ // ─── Insert (online-first with offline fallback) ───────────────────────────
88
+ /**
89
+ * Insert a new record into a collection.
90
+ *
91
+ * Online-first: awaits the server so a server-side rejection (unique
92
+ * violation, validation error, permission denial) surfaces as the typed
93
+ * `KoolbaseDataError` subclass — `insert` now throws `KoolbaseConflictError`
94
+ * with the offending field on a 409, matching `upsert` and `update`.
95
+ *
96
+ * On genuine network failure (server unreachable, timeout) the write is
97
+ * accepted optimistically: saved to the local cache and queued for sync
98
+ * when connectivity returns.
99
+ */
88
100
  async insert(collection, data) {
89
101
  const userId = this.getUserId() ?? 'anonymous';
90
- // Build optimistic record
91
- const optimisticRecord = {
92
- id: generateId(),
93
- createdBy: userId,
94
- data,
95
- createdAt: new Date().toISOString(),
96
- updatedAt: new Date().toISOString(),
97
- };
98
- // Write to local cache immediately
99
- await (0, cache_store_1.optimisticallyInsert)(userId, collection, optimisticRecord);
100
- // Add to write queue
101
- await (0, cache_store_1.addToWriteQueue)(userId, {
102
- id: generateId(),
103
- type: 'insert',
104
- collection,
105
- data,
106
- });
107
- // Try network in background
108
- this.request('POST', '/v1/sdk/db/insert', {
109
- collection,
110
- data,
111
- })
112
- .then(async (serverRecord) => {
113
- // Invalidate cache so next query gets real data
102
+ try {
103
+ // Online path: await the server and return the authoritative record
104
+ // (with the server-assigned id). Refresh the collection cache so the
105
+ // next query sees real data instead of a stale optimistic copy.
106
+ const raw = await this.request('POST', '/v1/sdk/db/insert', { collection, data });
107
+ const record = (0, record_1.recordFromWire)(raw);
114
108
  await (0, cache_store_1.invalidateCache)(userId, collection);
115
- })
116
- .catch(() => {
117
- // Will sync when online via SyncEngine
118
- });
119
- return optimisticRecord;
109
+ return record;
110
+ }
111
+ catch (e) {
112
+ // Server-reachable rejection: the server saw the request and refused.
113
+ // Surface to the caller without writing optimistic state or queuing —
114
+ // the server has already decided it will not accept this write, and
115
+ // queuing it would just spin SyncEngine until max retries.
116
+ if (e instanceof database_errors_1.KoolbaseDataError)
117
+ throw e;
118
+ // Genuine network failure → offline path: save to local cache and
119
+ // queue for SyncEngine to retry when online. Return the optimistic
120
+ // record so the UI has something to render in the meantime.
121
+ const optimisticRecord = {
122
+ id: generateId(),
123
+ createdBy: userId,
124
+ data,
125
+ createdAt: new Date().toISOString(),
126
+ updatedAt: new Date().toISOString(),
127
+ };
128
+ await (0, cache_store_1.optimisticallyInsert)(userId, collection, optimisticRecord);
129
+ await (0, cache_store_1.addToWriteQueue)(userId, {
130
+ id: generateId(),
131
+ type: 'insert',
132
+ collection,
133
+ data,
134
+ });
135
+ return optimisticRecord;
136
+ }
120
137
  }
121
138
  // ─── Upsert (online-only) ─────────────────────────────────────────────────
122
139
  /**
@@ -246,20 +263,39 @@ class KoolbaseDatabase {
246
263
  const raw = await this.request('GET', `/v1/sdk/db/records/${recordId}`);
247
264
  return (0, record_1.recordFromWire)(raw);
248
265
  }
249
- // ─── Update ─────────────────────────────────────────────────────────────────
266
+ // ─── Update (online-first with offline fallback) ───────────────────────────
267
+ /**
268
+ * Update a record's fields by id.
269
+ *
270
+ * Online-first: awaits the server so a server-side rejection (unique
271
+ * violation, not found, permission denial) surfaces as the typed
272
+ * `KoolbaseDataError` subclass. An update that would violate a unique
273
+ * constraint now throws `KoolbaseConflictError` with the offending field —
274
+ * same shape as `insert` and `upsert`.
275
+ *
276
+ * On genuine network failure the update is queued for sync and a partial
277
+ * optimistic record is returned so the UI can re-render the new fields
278
+ * immediately.
279
+ */
250
280
  async update(recordId, data) {
251
281
  const userId = this.getUserId() ?? 'anonymous';
252
- await (0, cache_store_1.addToWriteQueue)(userId, {
253
- id: generateId(),
254
- type: 'update',
255
- recordId,
256
- data,
257
- });
258
282
  try {
259
283
  const raw = await this.request('PATCH', `/v1/sdk/db/records/${recordId}`, { data });
260
284
  return (0, record_1.recordFromWire)(raw);
261
285
  }
262
- catch {
286
+ catch (e) {
287
+ // Server-reachable rejection: surface to caller without queuing — the
288
+ // server already refused the write and will refuse it again on retry.
289
+ if (e instanceof database_errors_1.KoolbaseDataError)
290
+ throw e;
291
+ // Genuine network failure → queue for sync and return an optimistic
292
+ // partial record so the UI reflects the update immediately.
293
+ await (0, cache_store_1.addToWriteQueue)(userId, {
294
+ id: generateId(),
295
+ type: 'update',
296
+ recordId,
297
+ data,
298
+ });
263
299
  return {
264
300
  id: recordId,
265
301
  data,
package/dist/index.js CHANGED
@@ -69,7 +69,7 @@ exports.Koolbase = {
69
69
  _auth = new auth_1.KoolbaseAuth(config);
70
70
  _db = new database_1.KoolbaseDatabase(config, () => _auth?.currentUser?.id ?? null, () => _auth?.validAccessToken() ?? Promise.resolve(null));
71
71
  _storage = new storage_1.KoolbaseStorage(config, () => _auth?.validAccessToken() ?? Promise.resolve(null));
72
- _realtime = new realtime_1.KoolbaseRealtime(config);
72
+ _realtime = new realtime_1.KoolbaseRealtime(config, () => _auth?.validAccessToken() ?? Promise.resolve(null));
73
73
  _functions = new functions_1.KoolbaseFunctions(config, () => _auth?.validAccessToken() ?? Promise.resolve(null));
74
74
  _flags = new flags_1.KoolbaseFlags(config, 'rn-device');
75
75
  _codePush = new code_push_1.KoolbaseCodePush(config, config.codePushChannel ?? 'stable');
@@ -1,11 +1,19 @@
1
1
  import { KoolbaseConfig, RealtimeCallback } from './types';
2
+ type TokenProvider = () => Promise<string | null>;
2
3
  export declare class KoolbaseRealtime {
3
4
  private config;
5
+ private getToken;
4
6
  private ws;
7
+ private projectId;
5
8
  private listeners;
6
9
  private reconnectTimer;
7
- constructor(config: KoolbaseConfig);
10
+ private connecting;
11
+ constructor(config: KoolbaseConfig, getToken: TokenProvider);
8
12
  subscribe(collection: string, callback: RealtimeCallback): () => void;
9
13
  private connect;
14
+ private sendSubscribe;
15
+ private sendUnsubscribe;
16
+ private scheduleReconnect;
10
17
  disconnect(): void;
11
18
  }
19
+ export {};
package/dist/realtime.js CHANGED
@@ -2,58 +2,141 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.KoolbaseRealtime = void 0;
4
4
  const record_1 = require("./record");
5
+ const EVENT_TYPE_MAP = {
6
+ 'db.record.created': 'created',
7
+ 'db.record.updated': 'updated',
8
+ 'db.record.deleted': 'deleted',
9
+ };
10
+ function projectIdFromToken(token) {
11
+ try {
12
+ const part = token.split('.')[1];
13
+ if (!part)
14
+ return null;
15
+ const b64 = part.replace(/-/g, '+').replace(/_/g, '/');
16
+ const g = globalThis;
17
+ let json;
18
+ if (typeof g.atob === 'function') {
19
+ const bin = g.atob(b64);
20
+ json = decodeURIComponent(bin.split('').map((c) => '%' + c.charCodeAt(0).toString(16).padStart(2, '0')).join(''));
21
+ }
22
+ else if (g.Buffer) {
23
+ json = g.Buffer.from(b64, 'base64').toString('utf8');
24
+ }
25
+ else {
26
+ return null;
27
+ }
28
+ return JSON.parse(json).project_id ?? null;
29
+ }
30
+ catch {
31
+ return null;
32
+ }
33
+ }
5
34
  class KoolbaseRealtime {
6
- constructor(config) {
35
+ constructor(config, getToken) {
7
36
  this.ws = null;
37
+ this.projectId = null;
8
38
  this.listeners = new Map();
9
39
  this.reconnectTimer = null;
40
+ this.connecting = false;
10
41
  this.config = config;
42
+ this.getToken = getToken;
11
43
  }
12
44
  subscribe(collection, callback) {
13
- if (!this.listeners.has(collection)) {
45
+ if (!this.listeners.has(collection))
14
46
  this.listeners.set(collection, []);
15
- }
16
47
  this.listeners.get(collection).push(callback);
17
- if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
18
- this.connect();
48
+ if (this.ws && this.ws.readyState === WebSocket.OPEN) {
49
+ this.sendSubscribe(collection);
50
+ }
51
+ else {
52
+ void this.connect();
19
53
  }
20
- // Return unsubscribe function
21
54
  return () => {
22
55
  const callbacks = this.listeners.get(collection) ?? [];
23
- const index = callbacks.indexOf(callback);
24
- if (index > -1)
25
- callbacks.splice(index, 1);
56
+ const i = callbacks.indexOf(callback);
57
+ if (i > -1)
58
+ callbacks.splice(i, 1);
59
+ if (callbacks.length === 0) {
60
+ this.listeners.delete(collection);
61
+ this.sendUnsubscribe(collection);
62
+ }
26
63
  };
27
64
  }
28
- connect() {
29
- const wsUrl = this.config.baseUrl
30
- .replace('https://', 'wss://')
31
- .replace('http://', 'ws://');
32
- this.ws = new WebSocket(`${wsUrl}/v1/sdk/realtime?key=${this.config.publicKey}`);
33
- this.ws.onmessage = (event) => {
65
+ async connect() {
66
+ if (this.connecting)
67
+ return;
68
+ if (this.ws && (this.ws.readyState === WebSocket.OPEN || this.ws.readyState === WebSocket.CONNECTING))
69
+ return;
70
+ const token = await this.getToken();
71
+ if (!token) {
72
+ this.scheduleReconnect(); // sign-in may be in flight
73
+ return;
74
+ }
75
+ this.projectId = projectIdFromToken(token);
76
+ this.connecting = true;
77
+ const wsUrl = this.config.baseUrl.replace('https://', 'wss://').replace('http://', 'ws://');
78
+ const ws = new WebSocket(`${wsUrl}/v1/realtime/ws?token=${encodeURIComponent(token)}`);
79
+ this.ws = ws;
80
+ ws.onopen = () => {
81
+ this.connecting = false;
82
+ for (const collection of this.listeners.keys())
83
+ this.sendSubscribe(collection); // (re)subscribe all
84
+ };
85
+ ws.onmessage = (event) => {
86
+ let raw;
34
87
  try {
35
- const raw = JSON.parse(event.data);
36
- if (!raw || !raw.record)
37
- return;
38
- const msg = {
39
- type: raw.type,
40
- collection: raw.collection,
41
- record: (0, record_1.recordFromWire)(raw.record),
42
- };
43
- const callbacks = this.listeners.get(msg.collection) ?? [];
44
- callbacks.forEach((cb) => cb(msg));
88
+ raw = JSON.parse(event.data);
89
+ }
90
+ catch {
91
+ return;
45
92
  }
46
- catch (_) { }
93
+ const mapped = EVENT_TYPE_MAP[raw?.type];
94
+ if (!mapped)
95
+ return; // ignore subscribed / unsubscribed / error / unknown
96
+ const payload = raw.payload;
97
+ if (!payload || !payload.collection || !payload.record)
98
+ return; // created/updated carry a record
99
+ const msg = {
100
+ type: mapped,
101
+ collection: payload.collection,
102
+ record: (0, record_1.recordFromWire)(payload.record),
103
+ };
104
+ (this.listeners.get(payload.collection) ?? []).forEach((cb) => cb(msg));
47
105
  };
48
- this.ws.onclose = () => {
49
- this.reconnectTimer = setTimeout(() => this.connect(), 3000);
106
+ ws.onclose = () => {
107
+ this.connecting = false;
108
+ if (this.ws === ws)
109
+ this.ws = null;
110
+ this.scheduleReconnect();
50
111
  };
112
+ ws.onerror = () => { };
113
+ }
114
+ sendSubscribe(collection) {
115
+ if (!this.projectId || !this.ws || this.ws.readyState !== WebSocket.OPEN)
116
+ return;
117
+ this.ws.send(JSON.stringify({ action: 'subscribe', project_id: this.projectId, collection }));
118
+ }
119
+ sendUnsubscribe(collection) {
120
+ if (!this.projectId || !this.ws || this.ws.readyState !== WebSocket.OPEN)
121
+ return;
122
+ this.ws.send(JSON.stringify({ action: 'unsubscribe', project_id: this.projectId, collection }));
123
+ }
124
+ scheduleReconnect() {
125
+ if (this.listeners.size === 0 || this.reconnectTimer)
126
+ return;
127
+ this.reconnectTimer = setTimeout(() => {
128
+ this.reconnectTimer = null;
129
+ void this.connect();
130
+ }, 3000);
51
131
  }
52
132
  disconnect() {
53
- if (this.reconnectTimer)
133
+ if (this.reconnectTimer) {
54
134
  clearTimeout(this.reconnectTimer);
135
+ this.reconnectTimer = null;
136
+ }
55
137
  this.ws?.close();
56
138
  this.ws = null;
139
+ this.projectId = null;
57
140
  this.listeners.clear();
58
141
  }
59
142
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@techfinityedge/koolbase-react-native",
3
- "version": "3.1.0",
4
- "description": "React Native SDK for Koolbase \u2014 auth, database, storage, realtime, feature flags, and functions in one package.",
3
+ "version": "4.2.0",
4
+ "description": "React Native SDK for Koolbase auth, database, storage, realtime, feature flags, and functions in one package.",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
7
7
  "files": [