@koolbase/react-native 9.2.0 → 10.0.1

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.
Files changed (69) hide show
  1. package/CHANGELOG.md +1348 -0
  2. package/README.md +403 -568
  3. package/dist/{auth-storage.d.ts → cjs/auth-storage.d.ts} +1 -1
  4. package/dist/cjs/index.d.ts +19 -0
  5. package/dist/cjs/index.js +125 -0
  6. package/dist/cjs/package.json +3 -0
  7. package/dist/cjs/platform.d.ts +2 -0
  8. package/dist/cjs/platform.js +43 -0
  9. package/dist/esm/auth-storage.d.ts +26 -0
  10. package/dist/esm/auth-storage.js +100 -0
  11. package/dist/esm/index.d.ts +19 -0
  12. package/dist/esm/index.js +106 -0
  13. package/dist/esm/package.json +3 -0
  14. package/dist/esm/platform.d.ts +2 -0
  15. package/dist/esm/platform.js +37 -0
  16. package/package.json +30 -30
  17. package/dist/analytics.d.ts +0 -24
  18. package/dist/analytics.js +0 -114
  19. package/dist/apple-auth.d.ts +0 -22
  20. package/dist/apple-auth.js +0 -74
  21. package/dist/auth-errors.d.ts +0 -117
  22. package/dist/auth-errors.js +0 -250
  23. package/dist/auth.d.ts +0 -213
  24. package/dist/auth.js +0 -810
  25. package/dist/cache-store.d.ts +0 -50
  26. package/dist/cache-store.js +0 -197
  27. package/dist/code-push.d.ts +0 -59
  28. package/dist/code-push.js +0 -255
  29. package/dist/conflict.d.ts +0 -80
  30. package/dist/conflict.js +0 -84
  31. package/dist/database-errors.d.ts +0 -101
  32. package/dist/database-errors.js +0 -200
  33. package/dist/database.d.ts +0 -298
  34. package/dist/database.js +0 -852
  35. package/dist/device-id.d.ts +0 -1
  36. package/dist/device-id.js +0 -60
  37. package/dist/device-metadata.d.ts +0 -36
  38. package/dist/device-metadata.js +0 -102
  39. package/dist/errors.d.ts +0 -64
  40. package/dist/errors.js +0 -85
  41. package/dist/flags.d.ts +0 -15
  42. package/dist/flags.js +0 -76
  43. package/dist/function-errors.d.ts +0 -51
  44. package/dist/function-errors.js +0 -103
  45. package/dist/functions.d.ts +0 -15
  46. package/dist/functions.js +0 -83
  47. package/dist/index.d.ts +0 -49
  48. package/dist/index.js +0 -204
  49. package/dist/logic-engine.d.ts +0 -17
  50. package/dist/logic-engine.js +0 -193
  51. package/dist/messaging.d.ts +0 -13
  52. package/dist/messaging.js +0 -36
  53. package/dist/offline-state.d.ts +0 -97
  54. package/dist/offline-state.js +0 -200
  55. package/dist/pending-write.d.ts +0 -47
  56. package/dist/pending-write.js +0 -22
  57. package/dist/realtime.d.ts +0 -44
  58. package/dist/realtime.js +0 -195
  59. package/dist/record.d.ts +0 -2
  60. package/dist/record.js +0 -23
  61. package/dist/storage-errors.d.ts +0 -163
  62. package/dist/storage-errors.js +0 -253
  63. package/dist/storage.d.ts +0 -198
  64. package/dist/storage.js +0 -451
  65. package/dist/sync-engine.d.ts +0 -30
  66. package/dist/sync-engine.js +0 -290
  67. package/dist/types.d.ts +0 -487
  68. package/dist/types.js +0 -40
  69. /package/dist/{auth-storage.js → cjs/auth-storage.js} +0 -0
package/dist/database.js DELETED
@@ -1,852 +0,0 @@
1
- "use strict";
2
- Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.KoolbaseDatabase = void 0;
4
- const errors_1 = require("./errors");
5
- const cache_store_1 = require("./cache-store");
6
- const offline_state_1 = require("./offline-state");
7
- const conflict_1 = require("./conflict");
8
- const pending_write_1 = require("./pending-write");
9
- const cache_store_2 = require("./cache-store");
10
- const sync_engine_1 = require("./sync-engine");
11
- const record_1 = require("./record");
12
- const database_errors_1 = require("./database-errors");
13
- function generateWriteId() {
14
- return 'local_' + Math.random().toString(36).slice(2) + Date.now().toString(36);
15
- }
16
- // Record ids are UUIDs from birth: the server honors a caller-supplied UUID id,
17
- // so the optimistic identity and the server identity are the same string and
18
- // chained offline writes need no remapping on replay. Write ids (above) stay
19
- // local_-prefixed — they are idempotency keys, never addresses.
20
- function generateRecordId() {
21
- return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => {
22
- const r = (Math.random() * 16) | 0;
23
- return (c === 'x' ? r : (r & 0x3) | 0x8).toString(16);
24
- });
25
- }
26
- function batchOpToWire(op) {
27
- switch (op.type) {
28
- case 'insert':
29
- return { type: 'insert', collection: op.collection, data: op.data };
30
- case 'update':
31
- return { type: 'update', record_id: op.recordId, data: op.data };
32
- case 'delete':
33
- return { type: 'delete', record_id: op.recordId };
34
- case 'upsert':
35
- return {
36
- type: 'upsert',
37
- collection: op.collection,
38
- match: op.match,
39
- data: op.data,
40
- };
41
- }
42
- }
43
- class KoolbaseDatabase {
44
- constructor(config, getUserId, getToken, onSessionExpired) {
45
- /**
46
- * Resolves by id, reloading the stored conflict first.
47
- *
48
- * A conflict object handed to a UI can sit there while someone decides, and a
49
- * sync pass may resolve it or another write supersede it meanwhile. Acting on
50
- * values captured when the object was built would write against a state that
51
- * no longer exists.
52
- */
53
- this.conflictResolver = {
54
- resolveWithLocal: async (id) => {
55
- const c = await this.requireConflict(id);
56
- await this.applyResolution(c, c.local ?? {});
57
- },
58
- resolveWithMerge: async (id, data) => {
59
- const c = await this.requireConflict(id);
60
- await this.applyResolution(c, data);
61
- },
62
- resolveWithServer: async (id) => {
63
- const c = await this.requireConflict(id);
64
- // The server's version stands. Recorded as a decision by removing the
65
- // conflict, rather than the change quietly disappearing.
66
- await this.dropConflict(c.id);
67
- },
68
- abandon: async (id) => {
69
- const c = await this.requireConflict(id);
70
- await this.dropConflict(c.id);
71
- },
72
- };
73
- this.config = config;
74
- this.getUserId = getUserId;
75
- this.getToken = getToken;
76
- this.onSessionExpired = onSessionExpired;
77
- this.syncEngine = new sync_engine_1.SyncEngine(config, getUserId, getToken, undefined, onSessionExpired);
78
- this.syncEngine.start();
79
- }
80
- // getUserId is kept only for local cache keys / offline metadata; request
81
- // identity now comes solely from the verified access token.
82
- async buildHeaders() {
83
- const token = await this.getToken();
84
- return {
85
- 'Content-Type': 'application/json',
86
- 'x-api-key': this.config.publicKey,
87
- ...(token ? { Authorization: `Bearer ${token}` } : {}),
88
- };
89
- }
90
- async request(method, path, body) {
91
- const res = await fetch(`${this.config.baseUrl}${path}`, {
92
- method,
93
- headers: await this.buildHeaders(),
94
- body: body ? JSON.stringify(body) : undefined,
95
- });
96
- // A 204 carries no body, and some error responses carry none either.
97
- // Parsing unconditionally would throw before the status was ever checked,
98
- // which is why delete was written to bypass this path — and why its errors
99
- // went unreported.
100
- const text = await res.text();
101
- let data = null;
102
- if (text.length > 0) {
103
- try {
104
- data = JSON.parse(text);
105
- }
106
- catch {
107
- // Leave data null: a body that is not JSON is not more informative than
108
- // the status, and failing to parse it must not mask the status.
109
- }
110
- }
111
- if (!res.ok) {
112
- const err = (0, database_errors_1.koolbaseDataError)(res.status, data ?? {}, `Request failed: ${res.status}`);
113
- if (err instanceof errors_1.KoolbaseUnauthenticatedError) {
114
- await this.onSessionExpired?.();
115
- }
116
- throw err;
117
- }
118
- return data;
119
- }
120
- /**
121
- * Like [request], but returns the status alongside the body.
122
- *
123
- * Several operations need it — upsert distinguishes create from update by a
124
- * 201, batch reports per-operation outcomes — and needing it was why they
125
- * hand-rolled their own fetch, each mapping errors slightly differently and
126
- * none of them clearing a rejected session. One path, two shapes of result.
127
- */
128
- async requestWithStatus(method, path, body) {
129
- const res = await fetch(`${this.config.baseUrl}${path}`, {
130
- method,
131
- headers: await this.buildHeaders(),
132
- body: body ? JSON.stringify(body) : undefined,
133
- });
134
- const text = await res.text();
135
- let data = null;
136
- if (text.length > 0) {
137
- try {
138
- data = JSON.parse(text);
139
- }
140
- catch {
141
- // Not JSON: the status is more informative than an unparseable body,
142
- // and failing to parse must not mask it.
143
- }
144
- }
145
- if (!res.ok) {
146
- const err = (0, database_errors_1.koolbaseDataError)(res.status, data ?? {}, `Request failed: ${res.status}`);
147
- if (err instanceof errors_1.KoolbaseUnauthenticatedError) {
148
- await this.onSessionExpired?.();
149
- }
150
- throw err;
151
- }
152
- return { status: res.status, data: data };
153
- }
154
- // ─── Query (cache-first) ───────────────────────────────────────────────────
155
- async runQuery(collection, options) {
156
- const raw = await this.request('POST', '/v1/sdk/db/query', {
157
- collection,
158
- filters: options.filters ?? {},
159
- limit: options.limit ?? 20,
160
- offset: options.offset ?? 0,
161
- order_by: options.orderBy,
162
- order_desc: options.orderDesc ?? false,
163
- populate: options.populate ?? [],
164
- });
165
- const records = raw.records.map(record_1.recordFromWire);
166
- // Individually, as well as under the query key. The query cache answers
167
- // "what did this query return"; the record cache answers "what is the
168
- // latest copy of this record" — and an offline mutation composes against
169
- // the second. Without this, listing records and editing one, the most
170
- // ordinary flow there is, would have no baseline and be refused.
171
- //
172
- // Only the top-level records. Populated relations arrive embedded rather
173
- // than fetched in their own right, and caching them as if they were would
174
- // risk storing a shape that is not the whole record.
175
- const userId = this.getUserId() ?? 'anonymous';
176
- await Promise.all(records.map((r) => r.collection
177
- ? (0, cache_store_1.cacheRecord)(userId, r.collection, r.id, r.data, r.revision)
178
- : Promise.resolve()));
179
- return { records, total: raw.total };
180
- }
181
- /**
182
- * Query records, cache-first (stale-while-revalidate).
183
- *
184
- * A cache hit is returned immediately with `isFromCache: true`, and a
185
- * background refresh updates the cache for the next call — so a repeat
186
- * query converges on the server's state one call behind it. Only a cache
187
- * miss awaits the network (`isFromCache: false`).
188
- *
189
- * Two consequences worth designing for: results can be one refresh stale,
190
- * even online — re-query if you need convergence after a known write; and
191
- * background refresh failures are swallowed by design (the cached result
192
- * has already been returned), so a dead network looks identical to a slow
193
- * refresh. Check `isFromCache` when the difference matters.
194
- *
195
- * The cache is per-user and persisted; it doubles as the offline baseline
196
- * store for `update`/`delete`.
197
- */
198
- async query(collection, options = {}) {
199
- const userId = this.getUserId() ?? 'anonymous';
200
- const queryHash = (0, cache_store_2.hashQuery)(collection, options);
201
- const cached = await (0, cache_store_2.getCached)(userId, collection, queryHash);
202
- if (cached) {
203
- this.runQuery(collection, options)
204
- .then(result => (0, cache_store_2.setCached)(userId, collection, queryHash, result))
205
- .catch(() => {
206
- // Network unavailable — cached data already returned
207
- });
208
- return { ...cached, isFromCache: true };
209
- }
210
- const result = await this.runQuery(collection, options);
211
- await (0, cache_store_2.setCached)(userId, collection, queryHash, result);
212
- return { ...result, isFromCache: false };
213
- }
214
- // ─── Insert (online-first with offline fallback) ───────────────────────────
215
- /**
216
- * Insert a new record into a collection.
217
- *
218
- * Online-first: awaits the server so a server-side rejection (unique
219
- * violation, validation error, permission denial) surfaces as the typed
220
- * `KoolbaseDataError` subclass — `insert` now throws `KoolbaseConflictError`
221
- * with the offending field on a 409, matching `upsert` and `update`.
222
- *
223
- * On genuine network failure (server unreachable, timeout) the write is
224
- * accepted optimistically: saved to the local cache and queued for sync
225
- * when connectivity returns.
226
- */
227
- async insert(collection, data) {
228
- const userId = this.getUserId() ?? 'anonymous';
229
- try {
230
- // Online path: await the server and return the authoritative record
231
- // (with the server-assigned id). Refresh the collection cache so the
232
- // next query sees real data instead of a stale optimistic copy.
233
- const raw = await this.request('POST', '/v1/sdk/db/insert', { collection, data });
234
- const record = (0, record_1.recordFromWire)(raw);
235
- await (0, cache_store_2.invalidateCache)(userId, collection);
236
- // The response carries a fresh revision, so caching it keeps the
237
- // baseline current for whatever edits this record next.
238
- await (0, cache_store_1.cacheRecord)(userId, collection, record.id, record.data, record.revision);
239
- return record;
240
- }
241
- catch (e) {
242
- // Server-reachable rejection: the server saw the request and refused.
243
- // Surface to the caller without writing optimistic state or queuing —
244
- // the server has already decided it will not accept this write, and
245
- // queuing it would just spin SyncEngine until max retries.
246
- // Anything the server answered with — a refusal, a conflict, a rejected
247
- // credential — must not be queued: it will be refused again on every
248
- // retry. Checked against the root rather than the data family, because a
249
- // rejected credential belongs to no single surface.
250
- if (e instanceof errors_1.KoolbaseError)
251
- throw e;
252
- // The queue is per-user, and signed out there is no user: filing this
253
- // into the anonymous bucket would queue real work where no signed-in
254
- // sync ever looks — the fake-zero's origin. Refusing is honest; the
255
- // caller knows the change did not save and can say so.
256
- if (!this.getUserId()) {
257
- throw new errors_1.KoolbaseUnauthenticatedError('Signed out and offline — this change cannot be queued for sync.');
258
- }
259
- // Genuine network failure → offline path: save to local cache and
260
- // queue for SyncEngine to retry when online. Return the optimistic
261
- // record so the UI has something to render in the meantime.
262
- const recordId = generateRecordId();
263
- const optimisticRecord = {
264
- id: recordId,
265
- createdBy: userId,
266
- data: { ...data, id: recordId },
267
- createdAt: new Date().toISOString(),
268
- updatedAt: new Date().toISOString(),
269
- };
270
- await (0, cache_store_2.optimisticallyInsert)(userId, collection, optimisticRecord);
271
- // No baseline: an insert has no prior state, and the record does not
272
- // exist on the server yet, so there is nothing to be conditional against.
273
- // An offline edit to it composes against this queued write instead.
274
- await (0, offline_state_1.queueWrite)(userId, {
275
- id: generateWriteId(),
276
- operation: 'insert',
277
- collection,
278
- recordId: optimisticRecord.id,
279
- // The record's UUID travels inside the payload: the server honors a
280
- // caller-supplied id, which is what keeps offline identity alive across
281
- // the boundary — the whole reason record ids are UUIDs from birth.
282
- data: optimisticRecord.data,
283
- });
284
- return optimisticRecord;
285
- }
286
- }
287
- // ─── Upsert (online-only) ─────────────────────────────────────────────────
288
- /**
289
- * Insert a record, or update the existing one matching `match`.
290
- *
291
- * The server decides: exactly one match updates it, no match inserts a new
292
- * record (seeded with the `match` fields), more than one match is an error.
293
- * Returns the resulting record and a `created` flag (true = inserted, false
294
- * = updated).
295
- *
296
- * Online-only by design. Unlike `insert`, an upsert is NOT queued offline:
297
- * the insert-vs-update decision needs the server's authoritative view of
298
- * what already exists, so deferring it could create a duplicate or apply a
299
- * wrong update on later sync. It throws on network failure instead. A raw
300
- * fetch is used (not `request`) so the status code is readable: 201 =
301
- * created, 200 = updated.
302
- */
303
- async upsert(collection, match, data) {
304
- const { status, data: body } = await this.requestWithStatus('POST', '/v1/sdk/db/upsert', { collection, match, data });
305
- const created = status === 201;
306
- const record = (0, record_1.recordFromWire)(body);
307
- // Keep the cache fresh, same intent as insert's post-success invalidate.
308
- const userId = this.getUserId() ?? 'anonymous';
309
- await (0, cache_store_2.invalidateCache)(userId, collection);
310
- await (0, cache_store_1.cacheRecord)(userId, collection, record.id, record.data, record.revision);
311
- return { record, created };
312
- }
313
- // ─── Delete where (online-only) ─────────────────────────────────────────────
314
- /**
315
- * Bulk-delete every record in `collection` matching `filters`.
316
- *
317
- * The server applies the collection's delete rule (scoping to the caller for
318
- * owner/scoped rules) and returns the number of records deleted.
319
- *
320
- * Online-only by design — like upsert, this is NOT queued offline: a bulk
321
- * delete needs the server's authoritative view of what matches, so it throws
322
- * on network failure rather than risk deleting the wrong set on later sync.
323
- * The collection cache is invalidated on success.
324
- */
325
- async deleteWhere(collection, filters) {
326
- const body = await this.request('POST', '/v1/sdk/db/delete-where', { collection, filters });
327
- const userId = this.getUserId() ?? 'anonymous';
328
- await (0, cache_store_2.invalidateCache)(userId, collection);
329
- return body.deleted ?? 0;
330
- }
331
- // ─── Batch (atomic, online-only) ────────────────────────────────────────────
332
- /**
333
- * Run multiple writes as a single atomic transaction.
334
- *
335
- * All `operations` commit together or none are applied — the server runs
336
- * them in one database transaction and rolls back entirely on any failure.
337
- * Operations apply in order and may span multiple collections.
338
- *
339
- * Online-only by design (like `upsert` and `deleteWhere`): atomicity needs
340
- * the server's authoritative view, so a batch is never queued offline — it
341
- * throws on network failure. A server-side rejection throws a
342
- * `KoolbaseDataException` whose message identifies which operation failed;
343
- * nothing was persisted.
344
- *
345
- * Returns one `BatchResult` per operation, in order.
346
- *
347
- * @example
348
- * const results = await Koolbase.db.batch([
349
- * BatchOp.insert('orders', { total: 50 }),
350
- * BatchOp.update(inventoryId, { stock: 9 }),
351
- * BatchOp.upsert('counters', { match: { name: 'orders' }, data: { value: 1 } }),
352
- * BatchOp.delete(cartItemId),
353
- * ]);
354
- */
355
- async batch(operations) {
356
- if (operations.length === 0) {
357
- throw new Error('batch requires at least one operation');
358
- }
359
- const body = await this.request('POST', '/v1/sdk/db/batch', {
360
- operations: operations.map(batchOpToWire),
361
- });
362
- const results = (body.results ?? []).map(r => ({
363
- type: r.type ?? '',
364
- record: r.record
365
- ? (0, record_1.recordFromWire)(r.record)
366
- : undefined,
367
- created: r.created,
368
- deleted: r.deleted ?? false,
369
- }));
370
- // Keep the cache consistent with what committed. Insert/upsert carry the
371
- // collection in the input op; update/delete address records by id, so we
372
- // don't know the collection at this layer — those refresh naturally on
373
- // the next query for the affected collection.
374
- const userId = this.getUserId() ?? 'anonymous';
375
- // Records returned by a batch carry their own collection on the wire, so
376
- // they can be cached even where the input op did not name one — which is
377
- // what the invalidation below cannot do. A batch commits transactionally, so
378
- // every record here landed together and carries a fresh revision.
379
- for (const r of results) {
380
- if (r.record?.collection) {
381
- await (0, cache_store_1.cacheRecord)(userId, r.record.collection, r.record.id, r.record.data, r.record.revision);
382
- }
383
- }
384
- const touched = new Set();
385
- for (const op of operations) {
386
- if (op.type === 'insert' || op.type === 'upsert') {
387
- touched.add(op.collection);
388
- }
389
- }
390
- for (const col of touched) {
391
- await (0, cache_store_2.invalidateCache)(userId, col);
392
- }
393
- return results;
394
- }
395
- // ─── Get single record ──────────────────────────────────────────────────────
396
- // ─── Get single record ──────────────────────────────────────────────────────
397
- async get(recordId) {
398
- const raw = await this.request('GET', `/v1/sdk/db/records/${recordId}`);
399
- const record = (0, record_1.recordFromWire)(raw);
400
- // Opening a record then editing it is the other ordinary flow, and a deep
401
- // link reaches it without a query ever having run.
402
- if (record.collection) {
403
- await (0, cache_store_1.cacheRecord)(this.getUserId() ?? 'anonymous', record.collection, record.id, record.data, record.revision);
404
- }
405
- return record;
406
- }
407
- // ─── Conflicts ──────────────────────────────────────────────────────────────
408
- /**
409
- * Writes that could not be applied, waiting for a decision.
410
- *
411
- * Held rather than discarded, and surviving restarts. An app that never reads
412
- * these accumulates them invisibly, with the changes they hold never applied —
413
- * so if you support offline editing, surface them somewhere.
414
- */
415
- /**
416
- * Changes made offline, waiting to be sent. Oldest first.
417
- *
418
- * For sync indicators ("3 changes waiting") and for warning a user who is
419
- * about to log out with unsynced edits — see [PendingWrite] for why that
420
- * moment matters. Snapshot, not a live handle; per-user.
421
- */
422
- async pendingWrites() {
423
- const userId = this.requireUserId('the pending-write queue');
424
- const { pending } = await (0, offline_state_1.readOfflineState)(userId);
425
- return pending.map(pending_write_1.toPendingWrite);
426
- }
427
- async conflicts() {
428
- const userId = this.requireUserId('the conflict list');
429
- const { conflicts } = await (0, offline_state_1.readOfflineState)(userId);
430
- return conflicts.map((c) => new conflict_1.KoolbaseConflict(c.id, c.reason, c.operation, c.collection, c.recordId, c.local, c.baseline, c.server, c.baseRevision, c.serverRevision, c.createdAt, this.conflictResolver));
431
- }
432
- /**
433
- * Per-user state demands a user. Signed out, "no answer" must not be
434
- * disguised as "empty" — tonight's fake-zero: the display read the anonymous
435
- * bucket while a signed-in user's writes sat unseen in theirs.
436
- */
437
- requireUserId(doing) {
438
- const userId = this.getUserId();
439
- if (!userId) {
440
- throw new errors_1.KoolbaseUnauthenticatedError(`Signed out — ${doing} is per-user state and has no answer without a user.`);
441
- }
442
- return userId;
443
- }
444
- async requireConflict(id) {
445
- const userId = this.requireUserId('conflict resolution');
446
- const { conflicts } = await (0, offline_state_1.readOfflineState)(userId);
447
- const found = conflicts.find((c) => c.id === id);
448
- if (!found) {
449
- throw new database_errors_1.KoolbaseDataError('That conflict is no longer outstanding — it may already have been resolved.', 'conflict_not_found');
450
- }
451
- return found;
452
- }
453
- async dropConflict(id) {
454
- const userId = this.requireUserId('conflict resolution');
455
- await (0, offline_state_1.mutateOfflineState)(userId, (s) => {
456
- s.conflicts = s.conflicts.filter((c) => c.id !== id);
457
- });
458
- }
459
- /**
460
- * Issues the resolving write, conditional on the revision the refusal
461
- * reported, and clears the conflict only once the server accepts it.
462
- *
463
- * Clearing first would lose the change if the write then failed.
464
- */
465
- async applyResolution(c, payload) {
466
- const rev = c.serverRevision;
467
- try {
468
- if (c.operation === 'insert') {
469
- // Resolving a rejected insert IS the insert, retried — with amended
470
- // data via resolveWithMerge (the "fix the colliding title" path).
471
- // Unconditional: there is no revision to be conditional against,
472
- // because there is no record. The conflict's id rides as the
473
- // idempotency key, so a resolution whose response is lost returns the
474
- // original on retry rather than duplicating — the queue's own
475
- // lost-response discipline, extended to the one insert path that
476
- // lacked it.
477
- await this.request('POST', '/v1/sdk/db/insert', {
478
- collection: c.collection,
479
- data: payload,
480
- idempotency_key: c.id,
481
- });
482
- }
483
- else if (c.operation === 'delete') {
484
- const q = rev !== undefined ? `?expected_revision=${rev}` : '';
485
- await this.request('DELETE', `/v1/sdk/db/records/${c.recordId}${q}`);
486
- }
487
- else {
488
- await this.request('PATCH', `/v1/sdk/db/records/${c.recordId}`, { data: payload, ...(rev !== undefined ? { expected_revision: rev } : {}) });
489
- }
490
- }
491
- catch (e) {
492
- // A refusal must teach the stored conflict, not just gate it. The 409
493
- // carries the server's current revision and record; absorbing them makes
494
- // the NEXT attempt conditional against reality. Without this, every
495
- // retry replays the stale condition and a conflict whose resolution
496
- // fails once is permanently unresolvable except by abandon —
497
- // device-proven: three identical refusals against an unchanged server.
498
- const details = e instanceof database_errors_1.KoolbaseDataError ? e.details : undefined;
499
- const current = details?.current_revision;
500
- const record = details?.record;
501
- if (typeof current === 'number') {
502
- await (0, offline_state_1.mutateOfflineState)(this.requireUserId('conflict resolution'), (st) => {
503
- const stored = st.conflicts.find((x) => x.id === c.id);
504
- if (!stored)
505
- return;
506
- stored.serverRevision = current;
507
- // Storing the fresh server snapshot IS the divergence update: the
508
- // public conflict computes divergentFields from local vs server.
509
- if (record)
510
- stored.server = record;
511
- });
512
- throw new database_errors_1.KoolbaseDataError('The record has changed again while deciding. The conflict now ' +
513
- 'reflects the server\'s current state — review and retry.', 'revision_mismatch');
514
- }
515
- throw e;
516
- }
517
- await this.dropConflict(c.id);
518
- await (0, cache_store_2.invalidateCache)(this.getUserId() ?? 'anonymous', c.collection);
519
- }
520
- // ─── Update (online-first with offline fallback) ───────────────────────────
521
- /**
522
- * Update a record's fields by id.
523
- *
524
- * Online-first: awaits the server so a server-side rejection (unique
525
- * violation, not found, permission denial) surfaces as the typed
526
- * `KoolbaseDataError` subclass. An update that would violate a unique
527
- * constraint now throws `KoolbaseConflictError` with the offending field —
528
- * same shape as `insert` and `upsert`.
529
- *
530
- * On genuine network failure the update is queued for sync and a partial
531
- * optimistic record is returned so the UI can re-render the new fields
532
- * immediately.
533
- */
534
- /**
535
- * The record's state as the SDK last knew it, for composing an offline
536
- * mutation against.
537
- *
538
- * Two sources, in order. A record created offline is not in the cache as a
539
- * server record, but its queued insert holds the state a later edit builds on
540
- * — insert-then-correct is the ordinary offline sequence. Otherwise the cached
541
- * copy, with the revision it was read at.
542
- *
543
- * Null when neither exists: never seen on this device, or a queued delete has
544
- * already removed it locally.
545
- */
546
- async resolveBaseline(userId, recordId) {
547
- const state = await (0, offline_state_1.readOfflineState)(userId);
548
- const queued = state.pending.filter((w) => w.recordId === recordId);
549
- if (queued.length > 0) {
550
- let projected = null;
551
- for (const w of queued) {
552
- if (w.operation === 'insert')
553
- projected = { ...(w.data ?? {}) };
554
- else if (w.operation === 'update')
555
- projected = { ...(projected ?? {}), ...(w.data ?? {}) };
556
- else if (w.operation === 'delete')
557
- projected = null;
558
- }
559
- // A chain ending in a delete leaves nothing to build on: editing a record
560
- // already removed locally is a contradiction in the SDK's own state, not
561
- // a conflict to resolve against the server.
562
- if (projected === null)
563
- return null;
564
- return {
565
- baseline: projected,
566
- revision: queued[queued.length - 1].baseRevision,
567
- collection: queued[0].collection,
568
- };
569
- }
570
- const cached = await (0, cache_store_1.getCachedRecord)(userId, recordId);
571
- if (!cached)
572
- return null;
573
- return { baseline: cached.data, revision: cached.revision, collection: cached.collection };
574
- }
575
- async update(recordId, data) {
576
- const userId = this.getUserId() ?? 'anonymous';
577
- // Resolved before the request, so a network failure has somewhere to go.
578
- const base = await this.resolveBaseline(userId, recordId);
579
- try {
580
- const raw = await this.request('PATCH', `/v1/sdk/db/records/${recordId}`, { data });
581
- const updated = (0, record_1.recordFromWire)(raw);
582
- if (updated.collection) {
583
- await (0, cache_store_1.cacheRecord)(userId, updated.collection, updated.id, updated.data, updated.revision);
584
- }
585
- return updated;
586
- }
587
- catch (e) {
588
- // Server-reachable rejection: surface to caller without queuing — the
589
- // server already refused the write and will refuse it again on retry.
590
- // Anything the server answered with — a refusal, a conflict, a rejected
591
- // credential — must not be queued: it will be refused again on every
592
- // retry. Checked against the root rather than the data family, because a
593
- // rejected credential belongs to no single surface.
594
- if (e instanceof errors_1.KoolbaseError)
595
- throw e;
596
- // Genuine network failure. Queueable only if the SDK knows what the
597
- // change was composed against — without that, replay would apply it
598
- // blindly and overwrite whatever happened while the device was away.
599
- if (!base) {
600
- throw new errors_1.KoolbaseOfflineBaselineUnavailableError('This record must be read at least once before it can be updated offline.');
601
- }
602
- await (0, offline_state_1.queueWrite)(userId, {
603
- id: generateWriteId(),
604
- operation: 'update',
605
- collection: base.collection,
606
- recordId,
607
- data,
608
- baseline: base.baseline,
609
- baseRevision: base.revision,
610
- });
611
- const merged = { ...base.baseline, ...data };
612
- await (0, cache_store_1.cacheRecord)(userId, base.collection, recordId, merged, base.revision);
613
- // Optimistic: durable locally and queued to send, not yet accepted.
614
- return {
615
- id: recordId,
616
- collection: base.collection,
617
- data: merged,
618
- createdAt: '',
619
- updatedAt: new Date().toISOString(),
620
- revision: base.revision,
621
- };
622
- }
623
- }
624
- // ─── Delete ─────────────────────────────────────────────────────────────────
625
- async delete(recordId) {
626
- const userId = this.getUserId() ?? 'anonymous';
627
- const base = await this.resolveBaseline(userId, recordId);
628
- try {
629
- await this.request('DELETE', `/v1/sdk/db/records/${recordId}`);
630
- await (0, cache_store_1.removeCachedRecord)(userId, recordId);
631
- }
632
- catch (e) {
633
- // A server that answered has refused: a permission denial or a missing
634
- // record will be refused again on every retry, so surface it rather than
635
- // queueing. An app told a delete succeeded when it did not has no way to
636
- // learn otherwise.
637
- // Anything the server answered with — a refusal, a conflict, a rejected
638
- // credential — must not be queued: it will be refused again on every
639
- // retry. Checked against the root rather than the data family, because a
640
- // rejected credential belongs to no single surface.
641
- if (e instanceof errors_1.KoolbaseError)
642
- throw e;
643
- // Genuine network failure. Queued here rather than before the request,
644
- // which would leave a successful delete in the queue to replay later
645
- // against a record that may since have been recreated under the same id.
646
- // A delete replayed without knowing what the record was would remove
647
- // something the user last saw hours earlier and which may have changed
648
- // since — the more destructive kind of stale write.
649
- if (!base) {
650
- throw new errors_1.KoolbaseOfflineBaselineUnavailableError('This record must be read at least once before it can be deleted offline.');
651
- }
652
- await (0, offline_state_1.queueWrite)(userId, {
653
- id: generateWriteId(),
654
- operation: 'delete',
655
- collection: base.collection,
656
- recordId,
657
- baseline: base.baseline,
658
- baseRevision: base.revision,
659
- });
660
- // The queued write holds its own copy of the baseline, so removing the
661
- // cached record costs nothing and keeps local reads consistent with what
662
- // the user just did.
663
- await (0, cache_store_1.removeCachedRecord)(userId, recordId);
664
- }
665
- }
666
- // ─── Vectors ────────────────────────────────────────────────────────────────
667
- /**
668
- * Write (or replace) a vector for a record on the named `field`.
669
- *
670
- * The field must already be declared on the collection via the dashboard
671
- * or CLI. `vector.length` must match the field's declared dimension;
672
- * otherwise throws `KoolbaseVectorDimensionMismatchError`.
673
- *
674
- * Online-only — vectors are not cached locally or queued offline because
675
- * HNSW similarity search has no useful offline semantics.
676
- *
677
- * @example
678
- * await Koolbase.db.setVector(
679
- * articleId,
680
- * 'embedding',
681
- * await myEmbeddingModel.encode(article.content),
682
- * );
683
- */
684
- async setVector(recordId, field, vector) {
685
- await this.request('POST', '/v1/sdk/db/set-vector', {
686
- record_id: recordId,
687
- field,
688
- vector,
689
- });
690
- }
691
- /**
692
- * Read a record's stored vector on the named `field`.
693
- *
694
- * Throws `KoolbaseNotFoundError` if either the field is not declared or
695
- * no vector has been set for this record on this field. Throws
696
- * `KoolbasePermissionError` if the caller cannot read this record per
697
- * the collection's read rule.
698
- *
699
- * Online-only.
700
- *
701
- * @example
702
- * const v = await Koolbase.db.getVector(articleId, 'embedding');
703
- * console.log(`${v.vector.length}-dim, updated ${v.updatedAt}`);
704
- */
705
- async getVector(recordId, field) {
706
- const raw = await this.request('POST', '/v1/sdk/db/get-vector', { record_id: recordId, field });
707
- return {
708
- recordId: raw.record_id,
709
- fieldName: raw.field_name,
710
- vector: raw.vector,
711
- createdAt: raw.created_at,
712
- updatedAt: raw.updated_at,
713
- };
714
- }
715
- /**
716
- * Remove a record's stored vector on the named `field`.
717
- *
718
- * Online-only. Throws `KoolbaseNotFoundError` if no vector is set for
719
- * `(recordId, field)`; throws `KoolbasePermissionError` if the caller
720
- * cannot write this record per the collection's write rule.
721
- *
722
- * Note: this removes the vector from the dimension table but does NOT
723
- * remove the field declaration itself — the field stays on the
724
- * collection and is still settable on other records.
725
- */
726
- async deleteVector(recordId, field) {
727
- await this.request('POST', '/v1/sdk/db/delete-vector', {
728
- record_id: recordId,
729
- field,
730
- });
731
- }
732
- /**
733
- * Queue an embedding job for a record's vector field. The server's
734
- * embedding worker picks it up within ~1 second.
735
- *
736
- * If `text` is omitted, the vector field's configured `source_field`
737
- * value on the record is used.
738
- *
739
- * @example
740
- * await Koolbase.db.embedText({
741
- * collection: 'articles',
742
- * recordId: article.$id,
743
- * vectorField: 'content_embedding',
744
- * });
745
- */
746
- async embedText(opts) {
747
- const body = {
748
- collection: opts.collection,
749
- record_id: opts.recordId,
750
- vector_field: opts.vectorField,
751
- };
752
- if (opts.text && opts.text.length > 0) {
753
- body.text = opts.text;
754
- }
755
- await this.request('POST', '/v1/sdk/db/embed-text', body);
756
- }
757
- /**
758
- * Search for records based on their semantic similarity to a query.
759
- *
760
- * @example
761
- * // Server-side embedding — most common:
762
- * const result = await Koolbase.db.searchSemantic({
763
- * collection: 'articles',
764
- * field: 'content_embedding',
765
- * queryText: 'how do I configure CI/CD?',
766
- * limit: 10,
767
- * });
768
- *
769
- * // Client-side embedding:
770
- * const result = await Koolbase.db.searchSemantic({
771
- * collection: 'articles',
772
- * field: 'content_embedding',
773
- * queryVector: precomputed,
774
- * limit: 10,
775
- * });
776
- *
777
- * // Hybrid search (vector + BM25, RRF-fused):
778
- * const result = await Koolbase.db.searchSemantic({
779
- * collection: 'articles',
780
- * field: 'content_embedding',
781
- * queryText: 'how do I configure CI/CD?',
782
- * mode: 'hybrid',
783
- * minSimilarity: 70,
784
- * });
785
- *
786
- * `mode` selects the retrieval strategy:
787
- * - `'semantic'` (default) — pure vector search via HNSW
788
- * - `'lexical'` — pure BM25 over the field's source text
789
- * - `'hybrid'` — vector + lexical, RRF-fused (k=60)
790
- *
791
- * `minSimilarity` (0..100, optional) filters out results below the
792
- * given similarity percentage server-side. Saves bandwidth on weak
793
- * matches. Only valid for semantic and hybrid; rejected by the
794
- * server on lexical mode.
795
- */
796
- async searchSemantic(opts) {
797
- const hasVector = Array.isArray(opts.queryVector) && opts.queryVector.length > 0;
798
- const hasText = typeof opts.queryText === 'string' && opts.queryText.trim().length > 0;
799
- if (!hasVector && !hasText) {
800
- throw new Error('searchSemantic: provide either queryVector or queryText.');
801
- }
802
- if (hasVector && hasText) {
803
- throw new Error('searchSemantic: provide only one of queryVector or queryText.');
804
- }
805
- if (opts.minSimilarity !== undefined &&
806
- (opts.minSimilarity < 0 || opts.minSimilarity > 100)) {
807
- throw new Error(`searchSemantic: minSimilarity must be between 0 and 100, got ${opts.minSimilarity}.`);
808
- }
809
- const body = {
810
- collection: opts.collection,
811
- field: opts.field,
812
- limit: opts.limit ?? 20,
813
- // Always send mode so the server uses the SDK's intent rather
814
- // than its own default. Omitting for 'semantic' would also work
815
- // (server defaults to semantic) but explicit is safer if the
816
- // server's default ever shifts.
817
- mode: opts.mode ?? 'semantic',
818
- };
819
- if (hasVector)
820
- body.query_vector = opts.queryVector;
821
- if (hasText)
822
- body.query_text = opts.queryText;
823
- if (opts.where && Object.keys(opts.where).length > 0) {
824
- body.where = opts.where;
825
- }
826
- if (opts.minSimilarity !== undefined) {
827
- body.min_similarity = opts.minSimilarity;
828
- }
829
- const raw = await this.request('POST', '/v1/sdk/db/search-semantic', body);
830
- // A hit carries the complete public record, not a projection, so these are
831
- // safe to cache as baselines. A trimmed record would be worse than none: an
832
- // offline edit would compose against an incomplete picture and conflict
833
- // detection would compare against fields that were never there.
834
- const hits = (raw.results ?? []).map((r) => ({
835
- record: (0, record_1.recordFromWire)(r.record),
836
- distance: r.distance,
837
- }));
838
- const searchUserId = this.getUserId() ?? 'anonymous';
839
- await Promise.all(hits.map((h) => h.record.collection
840
- ? (0, cache_store_1.cacheRecord)(searchUserId, h.record.collection, h.record.id, h.record.data, h.record.revision)
841
- : Promise.resolve()));
842
- return {
843
- hits: hits,
844
- total: raw.total ?? (raw.results ?? []).length,
845
- };
846
- }
847
- // ─── Manual sync ────────────────────────────────────────────────────────────
848
- async syncPendingWrites() {
849
- await this.syncEngine.flush();
850
- }
851
- }
852
- exports.KoolbaseDatabase = KoolbaseDatabase;