@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 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, these writes are queued and synced automatically when connectivity returns.
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 { KoolbaseConflictError, KoolbaseDataError } from '@koolbase/react-native';
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 KoolbaseConflictError) {
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
- > `query`, `get`, `upsert`, and `deleteWhere` throw these typed errors. `insert`,
948
- > `update`, and `delete` are optimistic/offline-firstthey queue and sync in
949
- > the background, so their conflicts surface via the sync engine, not as a
950
- > thrown error.
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: <hello@koolbase.com>
1140
+ - Email: <dev@koolbase.com>
1025
1141
 
1026
1142
  ## License
1027
1143
 
@@ -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 Error {
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 {
@@ -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 Error {
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) {
@@ -1,11 +1,50 @@
1
- import { KoolbaseRecord, PendingWrite, QueryResult } from './types';
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
- export declare function getWriteQueue(userId: string): Promise<PendingWrite[]>;
8
- export declare function addToWriteQueue(userId: string, write: Omit<PendingWrite, 'retries' | 'createdAt'>): Promise<void>;
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>;
@@ -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 toDelete = keys.filter(k => k.startsWith(prefix));
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
+ }
@@ -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 Error {
11
- code?: string;
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): KoolbaseDataError;
101
+ export declare function koolbaseDataError(status: number, body: any, fallbackMessage?: string): KoolbaseError;