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