@koolbase/react-native 9.1.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.
- package/README.md +125 -9
- package/dist/auth-errors.d.ts +2 -2
- package/dist/auth-errors.js +3 -3
- package/dist/auth.d.ts +14 -0
- package/dist/auth.js +16 -0
- package/dist/cache-store.d.ts +42 -3
- package/dist/cache-store.js +62 -1
- package/dist/conflict.d.ts +80 -0
- package/dist/conflict.js +84 -0
- package/dist/database-errors.d.ts +9 -3
- package/dist/database-errors.js +42 -15
- package/dist/database.d.ts +91 -1
- package/dist/database.js +438 -94
- package/dist/errors.d.ts +64 -0
- package/dist/errors.js +85 -0
- package/dist/function-errors.d.ts +51 -0
- package/dist/function-errors.js +103 -0
- package/dist/functions.d.ts +8 -1
- package/dist/functions.js +18 -5
- package/dist/index.d.ts +4 -0
- package/dist/index.js +15 -4
- package/dist/offline-state.d.ts +97 -0
- package/dist/offline-state.js +200 -0
- package/dist/pending-write.d.ts +47 -0
- package/dist/pending-write.js +22 -0
- package/dist/realtime.d.ts +26 -1
- package/dist/realtime.js +50 -3
- package/dist/record.js +3 -0
- package/dist/storage-errors.d.ts +2 -2
- package/dist/storage-errors.js +7 -3
- package/dist/storage.d.ts +15 -1
- package/dist/storage.js +23 -10
- package/dist/sync-engine.d.ts +15 -1
- package/dist/sync-engine.js +226 -22
- package/dist/types.d.ts +18 -1
- package/package.json +13 -7
package/README.md
CHANGED
|
@@ -16,7 +16,14 @@ Auth, database, storage, realtime, functions, feature flags, remote config, vers
|
|
|
16
16
|
3. Add the SDK:
|
|
17
17
|
|
|
18
18
|
```bash
|
|
19
|
-
npm install @koolbase/react-native
|
|
19
|
+
npm install @koolbase/react-native \\
|
|
20
|
+
@react-native-async-storage/async-storage @react-native-community/netinfo react-native-keychain
|
|
21
|
+
|
|
22
|
+
> The three native modules are peer dependencies — your app installs them so
|
|
23
|
+
> exactly one copy of each exists. Two copies of a native module in one app is
|
|
24
|
+
> a runtime failure that looks like an SDK bug, which is why they are not
|
|
25
|
+
> bundled.
|
|
26
|
+
|
|
20
27
|
# or
|
|
21
28
|
yarn add @koolbase/react-native
|
|
22
29
|
# or
|
|
@@ -321,15 +328,92 @@ const deleted = await Koolbase.db.deleteWhere('sessions', {
|
|
|
321
328
|
|
|
322
329
|
### Offline-first
|
|
323
330
|
|
|
331
|
+
Reads come from a local cache when the network is unavailable, and `insert`,
|
|
332
|
+
`update`, and `delete` are queued and sent when it returns.
|
|
333
|
+
|
|
324
334
|
```typescript
|
|
325
335
|
const { records, isFromCache } = await Koolbase.db.query('posts', { limit: 20 });
|
|
326
336
|
if (isFromCache) console.log('Served from local cache');
|
|
327
337
|
|
|
338
|
+
// Queued offline, applied on reconnect
|
|
339
|
+
await Koolbase.db.update(id, { title: 'Corrected' });
|
|
340
|
+
await Koolbase.db.delete(id);
|
|
341
|
+
|
|
342
|
+
// Sync happens on reconnect. To force it:
|
|
328
343
|
await Koolbase.db.syncPendingWrites();
|
|
329
344
|
```
|
|
330
345
|
|
|
346
|
+
A server-side rejection is never queued. A unique-constraint violation, a
|
|
347
|
+
validation failure, or a permission denial surfaces immediately — only a genuine
|
|
348
|
+
network failure defers.
|
|
349
|
+
|
|
350
|
+
#### Editing offline requires having read the record
|
|
351
|
+
|
|
352
|
+
An update or delete is queued only if the SDK knows what the record looked like
|
|
353
|
+
when the change was made. Replaying a change without that means applying it
|
|
354
|
+
blindly: whatever else happened to the record in the meantime is overwritten,
|
|
355
|
+
silently, with nobody able to tell.
|
|
356
|
+
|
|
357
|
+
The SDK has that state if the record has been seen on this device — through a
|
|
358
|
+
query, a single read, a realtime event, or because it was created here and is
|
|
359
|
+
still queued. If it has not, the write is refused rather than queued:
|
|
360
|
+
|
|
361
|
+
```typescript
|
|
362
|
+
try {
|
|
363
|
+
await Koolbase.db.update(id, { title: 'Corrected' });
|
|
364
|
+
} catch (e) {
|
|
365
|
+
if (e instanceof KoolbaseOfflineBaselineUnavailableError) {
|
|
366
|
+
// Never seen on this device. Read it, or make the change while online.
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
```
|
|
370
|
+
|
|
371
|
+
That is deliberate rather than lenient. Queueing it anyway would mean most
|
|
372
|
+
offline updates are conflict-safe and some quietly are not, which is a worse
|
|
373
|
+
guarantee than a clear refusal.
|
|
374
|
+
|
|
375
|
+
#### When a queued write cannot be applied
|
|
376
|
+
|
|
377
|
+
On replay the server applies a queued write only if the record still carries the
|
|
378
|
+
revision the change was based on. If something changed it meanwhile — another
|
|
379
|
+
device, another user, a Function — the write is refused and held for a decision.
|
|
380
|
+
It is not lost, and not applied, and it survives restarts.
|
|
381
|
+
|
|
382
|
+
```typescript
|
|
383
|
+
const conflicts = await Koolbase.db.conflicts();
|
|
384
|
+
|
|
385
|
+
for (const c of conflicts) {
|
|
386
|
+
c.local; // the change the user made
|
|
387
|
+
c.server; // the record as the server holds it now
|
|
388
|
+
c.divergentFields; // where they disagree
|
|
389
|
+
c.reason; // why it is waiting
|
|
390
|
+
|
|
391
|
+
await c.resolveWithLocal(); // reapply the user's change
|
|
392
|
+
await c.resolveWithServer(); // keep the server's version
|
|
393
|
+
await c.resolveWithMerge({ ... }); // something composed from both
|
|
394
|
+
await c.abandon(); // drop it, neither side wins
|
|
395
|
+
}
|
|
396
|
+
```
|
|
397
|
+
|
|
398
|
+
`reason` distinguishes two situations. `concurrent_modification` means the record
|
|
399
|
+
moved while the change was queued. `baseline_unavailable` means the change was
|
|
400
|
+
queued by a version of this SDK that did not record what it was based on — those
|
|
401
|
+
are migrated on upgrade rather than replayed, since there is nothing to check
|
|
402
|
+
them against.
|
|
403
|
+
|
|
404
|
+
Resolving is itself conditional: if the record has moved again while someone was
|
|
405
|
+
deciding, resolution produces a new conflict rather than overwriting a change
|
|
406
|
+
nobody has seen.
|
|
407
|
+
|
|
408
|
+
> **These do not expire.** An app that never reads `conflicts()` accumulates
|
|
409
|
+
> them in local storage indefinitely, invisible to the user, with the changes
|
|
410
|
+
> they hold never applied. If you support offline editing, surface them
|
|
411
|
+
> somewhere. Automatic expiry would hide the problem while quietly losing the
|
|
412
|
+
> work.
|
|
413
|
+
|
|
331
414
|
---
|
|
332
415
|
|
|
416
|
+
|
|
333
417
|
### Atomic batch writes
|
|
334
418
|
|
|
335
419
|
Run multiple writes in a single server-side transaction. All operations commit together or none are applied — any failure rolls back the entire batch.
|
|
@@ -375,7 +459,10 @@ try {
|
|
|
375
459
|
}
|
|
376
460
|
```
|
|
377
461
|
|
|
378
|
-
When the device is offline,
|
|
462
|
+
When the device is offline, `insert`, `update`, and `delete` are queued and sent
|
|
463
|
+
when connectivity returns — an update or delete only if the record has been read
|
|
464
|
+
on this device, so the change has something to be applied against. See
|
|
465
|
+
[Offline-first](#offline-first).
|
|
379
466
|
|
|
380
467
|
---
|
|
381
468
|
|
|
@@ -921,6 +1008,22 @@ handling doesn't depend on message text.
|
|
|
921
1008
|
|
|
922
1009
|
All data-layer failures extend `KoolbaseDataError` (which extends `Error`):
|
|
923
1010
|
|
|
1011
|
+
Two errors are raised by any subsystem, because they are not about any one of
|
|
1012
|
+
them:
|
|
1013
|
+
|
|
1014
|
+
| Error | When |
|
|
1015
|
+
|---|---|
|
|
1016
|
+
| `KoolbaseUnauthenticatedError` | The server would not accept the caller's credentials (401) — an expired session, a revoked key, or none at all; it does not distinguish. Raised by database, storage, Functions, and background sync alike, since a session stops working for the whole SDK at once. **The SDK has already signed the user out by the time you catch this** — route to login rather than retrying. |
|
|
1017
|
+
| `KoolbaseOfflineBaselineUnavailableError` | An offline update or delete could not be queued: the record has never been seen on this device, so there is nothing to apply the change against. Read it first, or make the change online. |
|
|
1018
|
+
|
|
1019
|
+
Note the difference from `KoolbasePermissionError` (403), which means the
|
|
1020
|
+
credentials *were* accepted and this caller may not proceed. It does not sign
|
|
1021
|
+
anyone out, and signing someone out for opening the wrong record would be worse
|
|
1022
|
+
than the failure itself.
|
|
1023
|
+
|
|
1024
|
+
All errors extend `KoolbaseError`, so `e instanceof KoolbaseError` catches
|
|
1025
|
+
anything the SDK raises.
|
|
1026
|
+
|
|
924
1027
|
| Error | When |
|
|
925
1028
|
|---|---|
|
|
926
1029
|
| `KoolbaseConflictError` | A write violates a unique constraint (409). Exposes `.field` — the field that collided, when the server reports it. |
|
|
@@ -931,12 +1034,19 @@ All data-layer failures extend `KoolbaseDataError` (which extends `Error`):
|
|
|
931
1034
|
| `KoolbaseVectorDimensionMismatchError` | A vector's length doesn't match the field's declared dimension (400, code `vector_dimension_mismatch`). |
|
|
932
1035
|
|
|
933
1036
|
```ts
|
|
934
|
-
import {
|
|
1037
|
+
import {
|
|
1038
|
+
KoolbaseConflictError,
|
|
1039
|
+
KoolbaseDataError,
|
|
1040
|
+
KoolbaseUnauthenticatedError,
|
|
1041
|
+
} from '@koolbase/react-native';
|
|
935
1042
|
|
|
936
1043
|
try {
|
|
937
1044
|
await Koolbase.db.upsert('users', { email }, { name });
|
|
938
1045
|
} catch (e) {
|
|
939
|
-
if (e instanceof
|
|
1046
|
+
if (e instanceof KoolbaseUnauthenticatedError) {
|
|
1047
|
+
// Already signed out — the session was cleared before this threw.
|
|
1048
|
+
goToLogin();
|
|
1049
|
+
} else if (e instanceof KoolbaseConflictError) {
|
|
940
1050
|
showError(`That ${e.field ?? 'value'} is already taken.`);
|
|
941
1051
|
} else if (e instanceof KoolbaseDataError) {
|
|
942
1052
|
showError(e.message);
|
|
@@ -944,10 +1054,16 @@ try {
|
|
|
944
1054
|
}
|
|
945
1055
|
```
|
|
946
1056
|
|
|
947
|
-
> `
|
|
948
|
-
>
|
|
949
|
-
>
|
|
950
|
-
>
|
|
1057
|
+
> `insert`, `update`, and `delete` queue when the network is unreachable, but
|
|
1058
|
+
> they still throw. A server that answered has refused — a permission denial, a
|
|
1059
|
+
> unique-constraint violation, a rejected credential — and that is surfaced
|
|
1060
|
+
> rather than queued, since it would be refused again on every retry. An update
|
|
1061
|
+
> or delete also throws `KoolbaseOfflineBaselineUnavailableError` when the record
|
|
1062
|
+
> has never been seen on this device.
|
|
1063
|
+
>
|
|
1064
|
+
> What does not throw is a write refused during replay, hours after it was made:
|
|
1065
|
+
> nobody is waiting on it, so it becomes a conflict you read from
|
|
1066
|
+
> `Koolbase.db.conflicts()`.
|
|
951
1067
|
|
|
952
1068
|
---
|
|
953
1069
|
|
|
@@ -1021,7 +1137,7 @@ Manage your projects at [app.koolbase.com](https://app.koolbase.com)
|
|
|
1021
1137
|
|
|
1022
1138
|
- [GitHub Issues](https://github.com/kennedyowusu/koolbase-react-native/issues)
|
|
1023
1139
|
- [docs.koolbase.com](https://docs.koolbase.com)
|
|
1024
|
-
- Email: <
|
|
1140
|
+
- Email: <dev@koolbase.com>
|
|
1025
1141
|
|
|
1026
1142
|
## License
|
|
1027
1143
|
|
package/dist/auth-errors.d.ts
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
|
+
import { KoolbaseError } from './errors';
|
|
1
2
|
/**
|
|
2
3
|
* Base error type for all Koolbase auth errors. Catchable via
|
|
3
4
|
* `instanceof KoolbaseAuthError` to handle any auth-related failure
|
|
4
5
|
* generically; subclasses let you handle specific cases.
|
|
5
6
|
*/
|
|
6
|
-
export declare class KoolbaseAuthError extends
|
|
7
|
-
code?: string;
|
|
7
|
+
export declare class KoolbaseAuthError extends KoolbaseError {
|
|
8
8
|
constructor(message: string, code?: string);
|
|
9
9
|
}
|
|
10
10
|
export declare class InvalidCredentialsError extends KoolbaseAuthError {
|
package/dist/auth-errors.js
CHANGED
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.GoogleEmailRequiredError = exports.InvalidGoogleTokenError = exports.GoogleSignInNotConfiguredError = exports.OAuthEmailConflictError = exports.AppleEmailRequiredError = exports.InvalidAppleTokenError = exports.AppleSignInNotConfiguredError = exports.SmsConfigMissingError = exports.PhoneAlreadyLinkedError = exports.OtpRateLimitError = exports.OtpMaxAttemptsError = exports.OtpInvalidError = exports.OtpExpiredError = exports.InvalidPhoneNumberError = exports.NetworkError = exports.RateLimitError = exports.UnlockTokenInvalidError = exports.AccountLockedError = exports.TokenRevokedError = exports.SessionExpiredError = exports.WeakPasswordError = exports.UserDisabledError = exports.EmailAlreadyInUseError = exports.InvalidCredentialsError = exports.KoolbaseAuthError = void 0;
|
|
4
|
+
const errors_1 = require("./errors");
|
|
4
5
|
/**
|
|
5
6
|
* Base error type for all Koolbase auth errors. Catchable via
|
|
6
7
|
* `instanceof KoolbaseAuthError` to handle any auth-related failure
|
|
7
8
|
* generically; subclasses let you handle specific cases.
|
|
8
9
|
*/
|
|
9
|
-
class KoolbaseAuthError extends
|
|
10
|
+
class KoolbaseAuthError extends errors_1.KoolbaseError {
|
|
10
11
|
constructor(message, code) {
|
|
11
|
-
super(message);
|
|
12
|
-
this.code = code;
|
|
12
|
+
super(message, code);
|
|
13
13
|
this.name = 'KoolbaseAuthError';
|
|
14
14
|
Object.setPrototypeOf(this, KoolbaseAuthError.prototype);
|
|
15
15
|
}
|
package/dist/auth.d.ts
CHANGED
|
@@ -56,6 +56,20 @@ export declare class KoolbaseAuth {
|
|
|
56
56
|
*/
|
|
57
57
|
private authedRequest;
|
|
58
58
|
private setSessionInternal;
|
|
59
|
+
/**
|
|
60
|
+
* Discards the stored session without contacting the server.
|
|
61
|
+
*
|
|
62
|
+
* For when the session is already known to be unusable — the server rejected
|
|
63
|
+
* the token, or a build was pointed at a different project and the persisted
|
|
64
|
+
* session belongs to the old one. Unlike `logout()` there is no server call:
|
|
65
|
+
* the token has already been refused, and asking for it to be revoked would
|
|
66
|
+
* only add a round trip that cannot succeed.
|
|
67
|
+
*
|
|
68
|
+
* Safe in any state, including with no session at all. The SDK calls this
|
|
69
|
+
* itself when a request is rejected as unauthenticated, so most apps will not
|
|
70
|
+
* need to.
|
|
71
|
+
*/
|
|
72
|
+
clearStoredSession(): Promise<void>;
|
|
59
73
|
private clearSessionInternal;
|
|
60
74
|
restoreSession(): Promise<RestoreResult>;
|
|
61
75
|
register(params: RegisterParams): Promise<KoolbaseUser>;
|
package/dist/auth.js
CHANGED
|
@@ -144,6 +144,22 @@ class KoolbaseAuth {
|
|
|
144
144
|
}
|
|
145
145
|
this.fireAuthStateChange();
|
|
146
146
|
}
|
|
147
|
+
/**
|
|
148
|
+
* Discards the stored session without contacting the server.
|
|
149
|
+
*
|
|
150
|
+
* For when the session is already known to be unusable — the server rejected
|
|
151
|
+
* the token, or a build was pointed at a different project and the persisted
|
|
152
|
+
* session belongs to the old one. Unlike `logout()` there is no server call:
|
|
153
|
+
* the token has already been refused, and asking for it to be revoked would
|
|
154
|
+
* only add a round trip that cannot succeed.
|
|
155
|
+
*
|
|
156
|
+
* Safe in any state, including with no session at all. The SDK calls this
|
|
157
|
+
* itself when a request is rejected as unauthenticated, so most apps will not
|
|
158
|
+
* need to.
|
|
159
|
+
*/
|
|
160
|
+
async clearStoredSession() {
|
|
161
|
+
await this.clearSessionInternal();
|
|
162
|
+
}
|
|
147
163
|
async clearSessionInternal() {
|
|
148
164
|
this.session = null;
|
|
149
165
|
if (this.storage) {
|
package/dist/cache-store.d.ts
CHANGED
|
@@ -1,11 +1,50 @@
|
|
|
1
|
-
import { KoolbaseRecord,
|
|
1
|
+
import { KoolbaseRecord, LegacyPendingWrite, QueryResult } from './types';
|
|
2
2
|
export declare function hashQuery(collection: string, options: Record<string, unknown>): string;
|
|
3
3
|
export declare function getCached(userId: string, collection: string, queryHash: string): Promise<QueryResult | null>;
|
|
4
4
|
export declare function setCached(userId: string, collection: string, queryHash: string, result: QueryResult): Promise<void>;
|
|
5
5
|
export declare function invalidateCache(userId: string, collection: string): Promise<void>;
|
|
6
|
+
/**
|
|
7
|
+
* Drops everything cached for a user.
|
|
8
|
+
*
|
|
9
|
+
* Deliberately spares the write queue. The prefix covers every key for this
|
|
10
|
+
* user, and the queue lives under one of them — so clearing the cache used to
|
|
11
|
+
* delete offline writes the user believes are saved, silently and
|
|
12
|
+
* irrecoverably. A cache is what can be refetched; queued writes are not that.
|
|
13
|
+
*/
|
|
6
14
|
export declare function clearUserCache(userId: string): Promise<void>;
|
|
7
|
-
|
|
8
|
-
|
|
15
|
+
/**
|
|
16
|
+
* A record as the SDK last saw it, with the revision it was read at.
|
|
17
|
+
*
|
|
18
|
+
* Separate from the query cache, which answers "what did this query return".
|
|
19
|
+
* This answers "what is the latest copy of this record" — and an offline
|
|
20
|
+
* mutation composes against the second. Scanning query blobs for one would mean
|
|
21
|
+
* the same record appearing in several snapshots at different revisions, with no
|
|
22
|
+
* principled way to choose.
|
|
23
|
+
*
|
|
24
|
+
* Keyed with `record` where a collection name would sit, so invalidateCache —
|
|
25
|
+
* which scopes to a collection and runs after every write — cannot reach these.
|
|
26
|
+
* A user's own edit must not remove the baseline they need for the next one.
|
|
27
|
+
*/
|
|
28
|
+
export interface CachedRecord {
|
|
29
|
+
collection: string;
|
|
30
|
+
data: Record<string, unknown>;
|
|
31
|
+
revision?: number;
|
|
32
|
+
cachedAt: string;
|
|
33
|
+
}
|
|
34
|
+
export declare function getCachedRecord(userId: string, recordId: string): Promise<CachedRecord | null>;
|
|
35
|
+
/**
|
|
36
|
+
* Stores a record, refusing to move it backwards.
|
|
37
|
+
*
|
|
38
|
+
* Query responses can arrive out of order — a slow request from an earlier
|
|
39
|
+
* screen resolving after a fresh one — and an older copy overwriting a newer
|
|
40
|
+
* would compose the next mutation against a stale revision, producing a conflict
|
|
41
|
+
* the user never caused. False conflicts teach people to force-overwrite, which
|
|
42
|
+
* is worse than none.
|
|
43
|
+
*/
|
|
44
|
+
export declare function cacheRecord(userId: string, collection: string, recordId: string, data: Record<string, unknown>, revision?: number): Promise<void>;
|
|
45
|
+
export declare function removeCachedRecord(userId: string, recordId: string): Promise<void>;
|
|
46
|
+
export declare function getWriteQueue(userId: string): Promise<LegacyPendingWrite[]>;
|
|
47
|
+
export declare function addToWriteQueue(userId: string, write: Omit<LegacyPendingWrite, 'retries' | 'createdAt'>): Promise<void>;
|
|
9
48
|
export declare function removeFromWriteQueue(userId: string, writeId: string): Promise<void>;
|
|
10
49
|
export declare function incrementWriteRetry(userId: string, writeId: string): Promise<void>;
|
|
11
50
|
export declare function optimisticallyInsert(userId: string, collection: string, record: KoolbaseRecord): Promise<void>;
|
package/dist/cache-store.js
CHANGED
|
@@ -8,6 +8,9 @@ exports.getCached = getCached;
|
|
|
8
8
|
exports.setCached = setCached;
|
|
9
9
|
exports.invalidateCache = invalidateCache;
|
|
10
10
|
exports.clearUserCache = clearUserCache;
|
|
11
|
+
exports.getCachedRecord = getCachedRecord;
|
|
12
|
+
exports.cacheRecord = cacheRecord;
|
|
13
|
+
exports.removeCachedRecord = removeCachedRecord;
|
|
11
14
|
exports.getWriteQueue = getWriteQueue;
|
|
12
15
|
exports.addToWriteQueue = addToWriteQueue;
|
|
13
16
|
exports.removeFromWriteQueue = removeFromWriteQueue;
|
|
@@ -57,11 +60,20 @@ async function invalidateCache(userId, collection) {
|
|
|
57
60
|
// ignore
|
|
58
61
|
}
|
|
59
62
|
}
|
|
63
|
+
/**
|
|
64
|
+
* Drops everything cached for a user.
|
|
65
|
+
*
|
|
66
|
+
* Deliberately spares the write queue. The prefix covers every key for this
|
|
67
|
+
* user, and the queue lives under one of them — so clearing the cache used to
|
|
68
|
+
* delete offline writes the user believes are saved, silently and
|
|
69
|
+
* irrecoverably. A cache is what can be refetched; queued writes are not that.
|
|
70
|
+
*/
|
|
60
71
|
async function clearUserCache(userId) {
|
|
61
72
|
try {
|
|
62
73
|
const keys = await async_storage_1.default.getAllKeys();
|
|
63
74
|
const prefix = `koolbase:${CACHE_VERSION}:${userId}:`;
|
|
64
|
-
const
|
|
75
|
+
const queueKey = writeQueueKey(userId);
|
|
76
|
+
const toDelete = keys.filter(k => k.startsWith(prefix) && k !== queueKey);
|
|
65
77
|
for (const key of toDelete) {
|
|
66
78
|
await async_storage_1.default.removeItem(key);
|
|
67
79
|
}
|
|
@@ -70,6 +82,55 @@ async function clearUserCache(userId) {
|
|
|
70
82
|
// ignore
|
|
71
83
|
}
|
|
72
84
|
}
|
|
85
|
+
function recordCacheKey(userId, recordId) {
|
|
86
|
+
return `koolbase:${CACHE_VERSION}:${userId}:record:${recordId}`;
|
|
87
|
+
}
|
|
88
|
+
async function getCachedRecord(userId, recordId) {
|
|
89
|
+
try {
|
|
90
|
+
const raw = await async_storage_1.default.getItem(recordCacheKey(userId, recordId));
|
|
91
|
+
return raw ? JSON.parse(raw) : null;
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Stores a record, refusing to move it backwards.
|
|
99
|
+
*
|
|
100
|
+
* Query responses can arrive out of order — a slow request from an earlier
|
|
101
|
+
* screen resolving after a fresh one — and an older copy overwriting a newer
|
|
102
|
+
* would compose the next mutation against a stale revision, producing a conflict
|
|
103
|
+
* the user never caused. False conflicts teach people to force-overwrite, which
|
|
104
|
+
* is worse than none.
|
|
105
|
+
*/
|
|
106
|
+
async function cacheRecord(userId, collection, recordId, data, revision) {
|
|
107
|
+
try {
|
|
108
|
+
const existing = await getCachedRecord(userId, recordId);
|
|
109
|
+
if (existing?.revision !== undefined &&
|
|
110
|
+
revision !== undefined &&
|
|
111
|
+
revision < existing.revision) {
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
const entry = {
|
|
115
|
+
collection,
|
|
116
|
+
data,
|
|
117
|
+
revision,
|
|
118
|
+
cachedAt: new Date().toISOString(),
|
|
119
|
+
};
|
|
120
|
+
await async_storage_1.default.setItem(recordCacheKey(userId, recordId), JSON.stringify(entry));
|
|
121
|
+
}
|
|
122
|
+
catch {
|
|
123
|
+
// ignore
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
async function removeCachedRecord(userId, recordId) {
|
|
127
|
+
try {
|
|
128
|
+
await async_storage_1.default.removeItem(recordCacheKey(userId, recordId));
|
|
129
|
+
}
|
|
130
|
+
catch {
|
|
131
|
+
// ignore
|
|
132
|
+
}
|
|
133
|
+
}
|
|
73
134
|
// ─── Write Queue ────────────────────────────────────────────────────────────
|
|
74
135
|
async function getWriteQueue(userId) {
|
|
75
136
|
try {
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { ConflictReason } from './offline-state';
|
|
2
|
+
/** Resolves conflicts by id. */
|
|
3
|
+
export interface ConflictResolver {
|
|
4
|
+
resolveWithLocal(conflictId: string): Promise<void>;
|
|
5
|
+
resolveWithServer(conflictId: string): Promise<void>;
|
|
6
|
+
resolveWithMerge(conflictId: string, data: Record<string, unknown>): Promise<void>;
|
|
7
|
+
abandon(conflictId: string): Promise<void>;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* A queued offline write that could not be applied, waiting for a decision.
|
|
11
|
+
*
|
|
12
|
+
* Not an error to dismiss and not a write to retry: retrying cannot help, and
|
|
13
|
+
* discarding it would lose a change the user believes is saved. It waits, and
|
|
14
|
+
* keeps waiting across restarts, until the application decides.
|
|
15
|
+
*
|
|
16
|
+
* Only the application can decide. Whether a later edit should win depends on
|
|
17
|
+
* what the data means, and a platform that chooses for everyone is wrong for
|
|
18
|
+
* someone.
|
|
19
|
+
*/
|
|
20
|
+
export declare class KoolbaseConflict {
|
|
21
|
+
readonly id: string;
|
|
22
|
+
readonly reason: ConflictReason;
|
|
23
|
+
readonly operation: 'insert' | 'update' | 'delete';
|
|
24
|
+
readonly collection: string;
|
|
25
|
+
readonly recordId: string;
|
|
26
|
+
/** The change the user made, still unapplied. */
|
|
27
|
+
readonly local: Record<string, unknown> | undefined;
|
|
28
|
+
/** The record as it was when the change was composed, where that is known. */
|
|
29
|
+
readonly baseline: Record<string, unknown> | undefined;
|
|
30
|
+
/**
|
|
31
|
+
* The record as the server held it when the write was refused, captured with
|
|
32
|
+
* the refusal so deciding needs no fetch and cannot race one.
|
|
33
|
+
*
|
|
34
|
+
* Undefined when the reason is `baseline_unavailable` — nothing was ever
|
|
35
|
+
* sent, so the server never answered.
|
|
36
|
+
*/
|
|
37
|
+
readonly server: Record<string, unknown> | undefined;
|
|
38
|
+
readonly baseRevision: number | undefined;
|
|
39
|
+
readonly serverRevision: number | undefined;
|
|
40
|
+
readonly createdAt: string;
|
|
41
|
+
private readonly resolver;
|
|
42
|
+
constructor(id: string, reason: ConflictReason, operation: 'insert' | 'update' | 'delete', collection: string, recordId: string,
|
|
43
|
+
/** The change the user made, still unapplied. */
|
|
44
|
+
local: Record<string, unknown> | undefined,
|
|
45
|
+
/** The record as it was when the change was composed, where that is known. */
|
|
46
|
+
baseline: Record<string, unknown> | undefined,
|
|
47
|
+
/**
|
|
48
|
+
* The record as the server held it when the write was refused, captured with
|
|
49
|
+
* the refusal so deciding needs no fetch and cannot race one.
|
|
50
|
+
*
|
|
51
|
+
* Undefined when the reason is `baseline_unavailable` — nothing was ever
|
|
52
|
+
* sent, so the server never answered.
|
|
53
|
+
*/
|
|
54
|
+
server: Record<string, unknown> | undefined, baseRevision: number | undefined, serverRevision: number | undefined, createdAt: string, resolver: ConflictResolver);
|
|
55
|
+
/**
|
|
56
|
+
* Fields where the user's change and the server's version disagree.
|
|
57
|
+
*
|
|
58
|
+
* Only the fields the change touches: a record accumulates values the write
|
|
59
|
+
* never asserted, and listing those would bury the real disagreement. Empty
|
|
60
|
+
* when there is no server version to compare against.
|
|
61
|
+
*/
|
|
62
|
+
get divergentFields(): string[];
|
|
63
|
+
/** How long this has been waiting. Metadata, not a deletion rule. */
|
|
64
|
+
get ageMs(): number;
|
|
65
|
+
/**
|
|
66
|
+
* Reapplies the user's change to the record as it stands now.
|
|
67
|
+
*
|
|
68
|
+
* An explicit decision to overwrite the server's version of the fields that
|
|
69
|
+
* disagree. Conditional where a revision is known, so a record that moved
|
|
70
|
+
* again while someone was deciding produces a new conflict rather than an
|
|
71
|
+
* unnoticed overwrite.
|
|
72
|
+
*/
|
|
73
|
+
resolveWithLocal(): Promise<void>;
|
|
74
|
+
/** Keeps the server's version and discards the user's change, as a decision. */
|
|
75
|
+
resolveWithServer(): Promise<void>;
|
|
76
|
+
/** Applies something the application composed from both versions. */
|
|
77
|
+
resolveWithMerge(data: Record<string, unknown>): Promise<void>;
|
|
78
|
+
/** Drops the change without claiming either version won. */
|
|
79
|
+
abandon(): Promise<void>;
|
|
80
|
+
}
|
package/dist/conflict.js
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.KoolbaseConflict = void 0;
|
|
4
|
+
/**
|
|
5
|
+
* A queued offline write that could not be applied, waiting for a decision.
|
|
6
|
+
*
|
|
7
|
+
* Not an error to dismiss and not a write to retry: retrying cannot help, and
|
|
8
|
+
* discarding it would lose a change the user believes is saved. It waits, and
|
|
9
|
+
* keeps waiting across restarts, until the application decides.
|
|
10
|
+
*
|
|
11
|
+
* Only the application can decide. Whether a later edit should win depends on
|
|
12
|
+
* what the data means, and a platform that chooses for everyone is wrong for
|
|
13
|
+
* someone.
|
|
14
|
+
*/
|
|
15
|
+
class KoolbaseConflict {
|
|
16
|
+
constructor(id, reason, operation, collection, recordId,
|
|
17
|
+
/** The change the user made, still unapplied. */
|
|
18
|
+
local,
|
|
19
|
+
/** The record as it was when the change was composed, where that is known. */
|
|
20
|
+
baseline,
|
|
21
|
+
/**
|
|
22
|
+
* The record as the server held it when the write was refused, captured with
|
|
23
|
+
* the refusal so deciding needs no fetch and cannot race one.
|
|
24
|
+
*
|
|
25
|
+
* Undefined when the reason is `baseline_unavailable` — nothing was ever
|
|
26
|
+
* sent, so the server never answered.
|
|
27
|
+
*/
|
|
28
|
+
server, baseRevision, serverRevision, createdAt, resolver) {
|
|
29
|
+
this.id = id;
|
|
30
|
+
this.reason = reason;
|
|
31
|
+
this.operation = operation;
|
|
32
|
+
this.collection = collection;
|
|
33
|
+
this.recordId = recordId;
|
|
34
|
+
this.local = local;
|
|
35
|
+
this.baseline = baseline;
|
|
36
|
+
this.server = server;
|
|
37
|
+
this.baseRevision = baseRevision;
|
|
38
|
+
this.serverRevision = serverRevision;
|
|
39
|
+
this.createdAt = createdAt;
|
|
40
|
+
this.resolver = resolver;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Fields where the user's change and the server's version disagree.
|
|
44
|
+
*
|
|
45
|
+
* Only the fields the change touches: a record accumulates values the write
|
|
46
|
+
* never asserted, and listing those would bury the real disagreement. Empty
|
|
47
|
+
* when there is no server version to compare against.
|
|
48
|
+
*/
|
|
49
|
+
get divergentFields() {
|
|
50
|
+
if (!this.local)
|
|
51
|
+
return [];
|
|
52
|
+
if (!this.server)
|
|
53
|
+
return Object.keys(this.local);
|
|
54
|
+
return Object.keys(this.local).filter((k) => JSON.stringify(this.server[k]) !== JSON.stringify(this.local[k]));
|
|
55
|
+
}
|
|
56
|
+
/** How long this has been waiting. Metadata, not a deletion rule. */
|
|
57
|
+
get ageMs() {
|
|
58
|
+
return Date.now() - new Date(this.createdAt).getTime();
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Reapplies the user's change to the record as it stands now.
|
|
62
|
+
*
|
|
63
|
+
* An explicit decision to overwrite the server's version of the fields that
|
|
64
|
+
* disagree. Conditional where a revision is known, so a record that moved
|
|
65
|
+
* again while someone was deciding produces a new conflict rather than an
|
|
66
|
+
* unnoticed overwrite.
|
|
67
|
+
*/
|
|
68
|
+
resolveWithLocal() {
|
|
69
|
+
return this.resolver.resolveWithLocal(this.id);
|
|
70
|
+
}
|
|
71
|
+
/** Keeps the server's version and discards the user's change, as a decision. */
|
|
72
|
+
resolveWithServer() {
|
|
73
|
+
return this.resolver.resolveWithServer(this.id);
|
|
74
|
+
}
|
|
75
|
+
/** Applies something the application composed from both versions. */
|
|
76
|
+
resolveWithMerge(data) {
|
|
77
|
+
return this.resolver.resolveWithMerge(this.id, data);
|
|
78
|
+
}
|
|
79
|
+
/** Drops the change without claiming either version won. */
|
|
80
|
+
abandon() {
|
|
81
|
+
return this.resolver.abandon(this.id);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
exports.KoolbaseConflict = KoolbaseConflict;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { KoolbaseError } from './errors';
|
|
1
2
|
/**
|
|
2
3
|
* Base class for errors surfaced by the Koolbase data layer (database reads
|
|
3
4
|
* and writes). Every data error carries a `message` and, when the server
|
|
@@ -7,8 +8,13 @@
|
|
|
7
8
|
* Catch this to handle any data-layer failure generically, or catch a
|
|
8
9
|
* specific subclass to branch on the kind of failure.
|
|
9
10
|
*/
|
|
10
|
-
export declare class KoolbaseDataError extends
|
|
11
|
-
|
|
11
|
+
export declare class KoolbaseDataError extends KoolbaseError {
|
|
12
|
+
/**
|
|
13
|
+
* Structured payload from the server's error body, when it sent one — e.g. a
|
|
14
|
+
* revision_mismatch 409 carries {expected_revision, current_revision,
|
|
15
|
+
* record}. Attached by the factory; absent when the body had none.
|
|
16
|
+
*/
|
|
17
|
+
details?: Record<string, unknown>;
|
|
12
18
|
constructor(message: string, code?: string);
|
|
13
19
|
}
|
|
14
20
|
/**
|
|
@@ -92,4 +98,4 @@ export declare class KoolbaseVectorDimensionMismatchError extends KoolbaseDataEr
|
|
|
92
98
|
* preferring the server's stable `code` and falling back to the HTTP status
|
|
93
99
|
* for older or uncoded responses. Always returns an error to throw.
|
|
94
100
|
*/
|
|
95
|
-
export declare function koolbaseDataError(status: number, body: any, fallbackMessage?: string):
|
|
101
|
+
export declare function koolbaseDataError(status: number, body: any, fallbackMessage?: string): KoolbaseError;
|