@koolbase/react-native 9.0.0 → 9.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.
@@ -4,15 +4,55 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.SyncEngine = void 0;
7
+ const offline_state_1 = require("./offline-state");
8
+ const errors_1 = require("./errors");
7
9
  const netinfo_1 = __importDefault(require("@react-native-community/netinfo"));
8
10
  const cache_store_1 = require("./cache-store");
11
+ /**
12
+ * Internal signal that the server refused a write because the record moved.
13
+ *
14
+ * Not exported: a conflict during replay becomes durable state rather than
15
+ * reaching a caller, since nobody is waiting on a write made hours ago.
16
+ */
17
+ /**
18
+ * Whether the server's answer can change on a later attempt.
19
+ *
20
+ * Terminal means the same request would meet the same decision: the data does
21
+ * not satisfy the collection's rules, the record is gone, the caller is not
22
+ * permitted, a unique value is taken. Retrying spends attempts to learn what is
23
+ * already known.
24
+ *
25
+ * 403 is terminal even though a role change could later permit it. A queue that
26
+ * holds writes indefinitely against a maybe is how a retry loop becomes
27
+ * invisible — surfacing it lets an app retry deliberately if roles change.
28
+ */
29
+ function isTerminal(status) {
30
+ return status === 400 || status === 403 || status === 404 || status === 409;
31
+ }
32
+ /** Raised when the server refused for a reason retrying cannot change. */
33
+ class TerminalRejection extends Error {
34
+ constructor(message, status) {
35
+ super(message);
36
+ this.status = status;
37
+ Object.setPrototypeOf(this, new.target.prototype);
38
+ }
39
+ }
40
+ class RevisionMismatch extends Error {
41
+ constructor(serverRecord, serverRevision) {
42
+ super('revision mismatch');
43
+ this.serverRecord = serverRecord;
44
+ this.serverRevision = serverRevision;
45
+ Object.setPrototypeOf(this, new.target.prototype);
46
+ }
47
+ }
9
48
  class SyncEngine {
10
- constructor(config, getUserId, getToken, onSyncComplete) {
49
+ constructor(config, getUserId, getToken, onSyncComplete, onSessionExpired) {
11
50
  this.isSyncing = false;
12
51
  this.config = config;
13
52
  this.getUserId = getUserId;
14
53
  this.getToken = getToken;
15
54
  this.onSyncComplete = onSyncComplete;
55
+ this.onSessionExpired = onSessionExpired;
16
56
  }
17
57
  start() {
18
58
  this.unsubscribe = netinfo_1.default.addEventListener(state => {
@@ -32,16 +72,129 @@ class SyncEngine {
32
72
  return;
33
73
  this.isSyncing = true;
34
74
  try {
35
- const queue = await (0, cache_store_1.getWriteQueue)(userId);
36
- if (queue.length === 0)
75
+ // Before anything is sent. Writes queued by an earlier version sit under a
76
+ // different key, and a migration that ran after replay — or depended on
77
+ // being online — would give the same input different outcomes. It clears
78
+ // the old key when done, so later calls find nothing and return.
79
+ await (0, offline_state_1.migrateLegacyQueue)(userId);
80
+ const { pending } = await (0, offline_state_1.readOfflineState)(userId);
81
+ if (pending.length === 0)
37
82
  return;
38
- for (const write of queue) {
83
+ // Records whose chain stopped this pass. Writes queued after a conflicted
84
+ // one were composed against the state it would have produced, so applying
85
+ // them now would write against a state their baseline never described.
86
+ const blocked = new Set();
87
+ for (const queued of pending) {
88
+ if (queued.recordId && blocked.has(queued.recordId))
89
+ continue;
90
+ // Read fresh. The list was taken at the start of the pass, so a write
91
+ // behind one that has already landed still carries the revision it was
92
+ // queued with — and would replay against a revision its predecessor has
93
+ // since superseded, conflicting for a reason the user never caused.
94
+ const state = await (0, offline_state_1.readOfflineState)(userId);
95
+ const write = state.pending.find((w) => w.id === queued.id);
96
+ if (!write)
97
+ continue;
39
98
  try {
40
- await this.executeWrite(write);
41
- await (0, cache_store_1.removeFromWriteQueue)(userId, write.id);
99
+ const revision = await this.executeWrite(write);
100
+ // The replayed write just changed the server; the cache must stop
101
+ // testifying to the old world. Mirrors the online paths — a replayed
102
+ // delete evicts the record, and every replayed write invalidates the
103
+ // collection's cached queries, so the next query reconverges instead
104
+ // of serving a ghost.
105
+ if (write.operation === 'delete' && write.recordId) {
106
+ await (0, cache_store_1.removeCachedRecord)(userId, write.recordId);
107
+ }
108
+ await (0, cache_store_1.invalidateCache)(userId, write.collection);
109
+ await (0, offline_state_1.mutateOfflineState)(userId, (s) => {
110
+ s.pending = s.pending.filter((w) => w.id !== write.id);
111
+ // Anything behind this for the same record was composed against its
112
+ // result, and now knows the revision that result carries.
113
+ if (revision !== undefined && write.recordId) {
114
+ for (const w of s.pending) {
115
+ if (w.recordId === write.recordId)
116
+ w.baseRevision = revision;
117
+ }
118
+ }
119
+ });
42
120
  }
43
- catch {
44
- await (0, cache_store_1.incrementWriteRetry)(userId, write.id);
121
+ catch (e) {
122
+ if (e instanceof errors_1.KoolbaseUnauthenticatedError) {
123
+ // The session is gone, so nothing else in the queue can succeed.
124
+ // Stopping beats spending a retry on every remaining write against
125
+ // a token the server has already refused; the queue is intact and
126
+ // replays after login.
127
+ await this.onSessionExpired?.();
128
+ return;
129
+ }
130
+ if (e instanceof RevisionMismatch) {
131
+ // Not a failure to retry — retrying cannot help. It becomes durable
132
+ // unresolved state, in one write so it can be in neither place nor
133
+ // both.
134
+ await (0, offline_state_1.mutateOfflineState)(userId, (s) => {
135
+ s.pending = s.pending.filter((w) => w.id !== write.id);
136
+ s.conflicts.push({
137
+ id: write.id,
138
+ // The record moved between the change being made and the queue
139
+ // reaching it — distinct from a write that never had a baseline
140
+ // to compare against at all.
141
+ reason: 'concurrent_modification',
142
+ operation: write.operation,
143
+ collection: write.collection,
144
+ recordId: write.recordId,
145
+ local: write.data,
146
+ baseline: write.baseline,
147
+ server: e.serverRecord,
148
+ baseRevision: write.baseRevision,
149
+ serverRevision: e.serverRevision,
150
+ createdAt: new Date().toISOString(),
151
+ });
152
+ });
153
+ if (write.recordId)
154
+ blocked.add(write.recordId);
155
+ continue;
156
+ }
157
+ if (e instanceof TerminalRejection) {
158
+ // The server made a decision that will not change on a later
159
+ // attempt. Retrying spends attempts to learn what is already known;
160
+ // dropping loses a change the user believes is saved. It waits,
161
+ // with what the server said, so someone can act on it.
162
+ await (0, offline_state_1.mutateOfflineState)(userId, (s) => {
163
+ s.pending = s.pending.filter((w) => w.id !== write.id);
164
+ s.conflicts.push({
165
+ id: write.id,
166
+ reason: 'rejected',
167
+ operation: write.operation,
168
+ collection: write.collection,
169
+ recordId: write.recordId ?? '',
170
+ local: write.data,
171
+ baseline: write.baseline,
172
+ baseRevision: write.baseRevision,
173
+ message: e.message,
174
+ createdAt: new Date().toISOString(),
175
+ });
176
+ });
177
+ // A terminally rejected insert leaves an optimistic record behind
178
+ // — cached at enqueue for a record the server refused to create.
179
+ // Left alone it is a phantom: it renders as saved, and an offline
180
+ // edit against it queues a write to a record that does not exist.
181
+ // Evict it, and invalidate the collection so cached queries stop
182
+ // serving it. The conflict above keeps the user's data and the
183
+ // server's verdict; the cache stops testifying to a fiction.
184
+ // MUTATION: phantom eviction removed
185
+ if (write.recordId)
186
+ blocked.add(write.recordId);
187
+ continue;
188
+ }
189
+ // Retryable: the network, a 5xx, a rate limit. The count is kept so a
190
+ // caller can see a write that keeps failing, but nothing drops it —
191
+ // a write discarded after three attempts is discarded silently, and
192
+ // the user is never told.
193
+ await (0, offline_state_1.mutateOfflineState)(userId, (s) => {
194
+ const w = s.pending.find((x) => x.id === write.id);
195
+ if (w)
196
+ w.retries += 1;
197
+ });
45
198
  }
46
199
  }
47
200
  this.onSyncComplete?.();
@@ -50,6 +203,13 @@ class SyncEngine {
50
203
  this.isSyncing = false;
51
204
  }
52
205
  }
206
+ /**
207
+ * Sends one queued write, returning the revision the record now carries.
208
+ *
209
+ * The revision matters to whatever is queued behind this for the same record:
210
+ * those were composed against this one's result and cannot know its revision
211
+ * until the server assigns it.
212
+ */
53
213
  async executeWrite(write) {
54
214
  const token = await this.getToken();
55
215
  const headers = {
@@ -57,30 +217,74 @@ class SyncEngine {
57
217
  'x-api-key': this.config.publicKey,
58
218
  ...(token ? { Authorization: `Bearer ${token}` } : {}),
59
219
  };
60
- if (write.type === 'insert') {
61
- const res = await fetch(`${this.config.baseUrl}/v1/sdk/db/insert`, {
220
+ const url = write.operation === 'insert'
221
+ ? `${this.config.baseUrl}/v1/sdk/db/insert`
222
+ : `${this.config.baseUrl}/v1/sdk/db/records/${write.recordId}`;
223
+ let res;
224
+ if (write.operation === 'insert') {
225
+ res = await fetch(url, {
62
226
  method: 'POST',
63
227
  headers,
64
- body: JSON.stringify({ collection: write.collection, data: write.data }),
228
+ // The write's own id: generated at enqueue, identical on every retry.
229
+ // Without it, an insert whose response was lost duplicated on replay —
230
+ // the server had no way to recognise the repeat.
231
+ body: JSON.stringify({
232
+ collection: write.collection,
233
+ data: write.data,
234
+ idempotency_key: write.id,
235
+ }),
65
236
  });
66
- if (!res.ok)
67
- throw new Error(`Insert failed: ${res.status}`);
68
237
  }
69
- else if (write.type === 'update') {
70
- const res = await fetch(`${this.config.baseUrl}/v1/sdk/db/records/${write.recordId}`, {
238
+ else if (write.operation === 'update') {
239
+ res = await fetch(url, {
71
240
  method: 'PATCH',
72
241
  headers,
73
- body: JSON.stringify({ data: write.data }),
242
+ // The revision the change was composed against. The server applies it
243
+ // only if the record still carries that revision, so nothing can land
244
+ // between the client deciding the write is safe and the server applying
245
+ // it — which matters here most of all, since hours may have passed.
246
+ body: JSON.stringify({
247
+ data: write.data,
248
+ ...(write.baseRevision !== undefined
249
+ ? { expected_revision: write.baseRevision }
250
+ : {}),
251
+ }),
74
252
  });
75
- if (!res.ok)
76
- throw new Error(`Update failed: ${res.status}`);
77
253
  }
78
- else if (write.type === 'delete') {
79
- const res = await fetch(`${this.config.baseUrl}/v1/sdk/db/records/${write.recordId}`, { method: 'DELETE', headers });
80
- if (!res.ok && res.status !== 204) {
81
- throw new Error(`Delete failed: ${res.status}`);
254
+ else {
255
+ const q = write.baseRevision !== undefined
256
+ ? `?expected_revision=${write.baseRevision}`
257
+ : '';
258
+ res = await fetch(`${url}${q}`, { method: 'DELETE', headers });
259
+ }
260
+ if (res.status === 401)
261
+ throw new errors_1.KoolbaseUnauthenticatedError('unauthorized');
262
+ if (res.status === 409) {
263
+ const body = await res.json().catch(() => ({}));
264
+ if (body?.code === 'revision_mismatch') {
265
+ throw new RevisionMismatch(body?.details?.record, body?.details?.current_revision);
82
266
  }
83
267
  }
268
+ if (!res.ok && res.status !== 204) {
269
+ const body = await res.json().catch(() => ({}));
270
+ const message = body?.error ?? `${write.operation} failed`;
271
+ // A 404 on a delete means the record is already gone, which is what the
272
+ // write was asking for. Satisfied, not failed.
273
+ if (res.status === 404 && write.operation === 'delete')
274
+ return undefined;
275
+ if (isTerminal(res.status))
276
+ throw new TerminalRejection(message, res.status);
277
+ throw new Error(`${write.operation} sync failed: ${res.status}`);
278
+ }
279
+ const text = await res.text().catch(() => '');
280
+ if (!text)
281
+ return undefined;
282
+ try {
283
+ return JSON.parse(text)?.$revision;
284
+ }
285
+ catch {
286
+ return undefined;
287
+ }
84
288
  }
85
289
  }
86
290
  exports.SyncEngine = SyncEngine;
package/dist/types.d.ts CHANGED
@@ -120,6 +120,17 @@ export interface KoolbaseRecord {
120
120
  data: Record<string, unknown>;
121
121
  createdAt: string;
122
122
  updatedAt: string;
123
+ /**
124
+ * Advanced by the server on every write.
125
+ *
126
+ * Pass it back on an update or delete to apply the change only if nothing
127
+ * else has touched the record since — the difference between overwriting
128
+ * someone else's change and being told about it.
129
+ *
130
+ * Undefined for records from a server that predates revisions, and for
131
+ * records cached by an earlier version of this SDK.
132
+ */
133
+ revision?: number;
123
134
  }
124
135
  export interface QueryOptions {
125
136
  filters?: Record<string, unknown>;
@@ -138,7 +149,13 @@ export interface UpsertResult {
138
149
  record: KoolbaseRecord;
139
150
  created: boolean;
140
151
  }
141
- export interface PendingWrite {
152
+ /**
153
+ * The 9.1.x queue entry shape. Superseded: the observable queue API returns
154
+ * the PendingWrite from './pending-write'; this shape survives only for
155
+ * migrateLegacyQueue, which drains the old storage key on first sync.
156
+ * No longer part of the public surface as of 9.2.0.
157
+ */
158
+ export interface LegacyPendingWrite {
142
159
  id: string;
143
160
  type: 'insert' | 'update' | 'delete';
144
161
  collection?: string;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@koolbase/react-native",
3
- "version": "9.0.0",
4
- "description": "React Native SDK for Koolbase auth, database, storage, realtime, feature flags, and functions in one package.",
3
+ "version": "9.2.0",
4
+ "description": "React Native SDK for Koolbase \u2014 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": [
@@ -9,7 +9,8 @@
9
9
  ],
10
10
  "scripts": {
11
11
  "build": "tsc",
12
- "prepare": "npm run build"
12
+ "prepare": "npm run build",
13
+ "test": "jest"
13
14
  },
14
15
  "keywords": [
15
16
  "koolbase",
@@ -26,19 +27,24 @@
26
27
  "url": "https://github.com/koolbase/koolbase-react-native"
27
28
  },
28
29
  "devDependencies": {
30
+ "@types/jest": "^30.0.0",
29
31
  "@types/jszip": "^3.4.0",
30
32
  "@types/node": "^25.9.1",
33
+ "jest": "^30.4.2",
31
34
  "react-native": "^0.85.3",
32
35
  "react-native-keychain": "^10.0.0",
33
- "typescript": "^5.0.0"
36
+ "ts-jest": "^29.4.12",
37
+ "typescript": "^5.0.0",
38
+ "@react-native-async-storage/async-storage": "^3.0.2",
39
+ "@react-native-community/netinfo": "^12.0.1"
34
40
  },
35
41
  "peerDependencies": {
36
42
  "react-native": ">=0.70.0",
37
- "react-native-keychain": ">=8.0.0"
43
+ "react-native-keychain": ">=8.0.0",
44
+ "@react-native-async-storage/async-storage": "^3.0.2",
45
+ "@react-native-community/netinfo": "^12.0.1"
38
46
  },
39
47
  "dependencies": {
40
- "@react-native-async-storage/async-storage": "^3.0.2",
41
- "@react-native-community/netinfo": "^12.0.1",
42
48
  "jszip": "^3.10.1"
43
49
  },
44
50
  "publishConfig": {