@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.
- package/README.md +19 -0
- package/dist/cjs/analytics.d.ts +24 -0
- package/dist/cjs/analytics.js +110 -0
- package/dist/cjs/auth-errors.d.ts +117 -0
- package/dist/cjs/auth-errors.js +250 -0
- package/dist/cjs/auth.d.ts +213 -0
- package/dist/cjs/auth.js +813 -0
- package/dist/cjs/cache-store.d.ts +50 -0
- package/dist/cjs/cache-store.js +194 -0
- package/dist/cjs/conflict.d.ts +80 -0
- package/dist/cjs/conflict.js +84 -0
- package/dist/cjs/database-errors.d.ts +101 -0
- package/dist/cjs/database-errors.js +200 -0
- package/dist/cjs/database.d.ts +298 -0
- package/dist/cjs/database.js +852 -0
- package/dist/cjs/device-id.d.ts +1 -0
- package/dist/cjs/device-id.js +57 -0
- package/dist/cjs/device-metadata.d.ts +36 -0
- package/dist/cjs/device-metadata.js +94 -0
- package/dist/cjs/errors.d.ts +64 -0
- package/dist/cjs/errors.js +85 -0
- package/dist/cjs/flags.d.ts +15 -0
- package/dist/cjs/flags.js +76 -0
- package/dist/cjs/function-errors.d.ts +51 -0
- package/dist/cjs/function-errors.js +103 -0
- package/dist/cjs/functions.d.ts +15 -0
- package/dist/cjs/functions.js +83 -0
- package/dist/cjs/index.d.ts +22 -0
- package/dist/cjs/index.js +56 -0
- package/dist/cjs/messaging.d.ts +13 -0
- package/dist/cjs/messaging.js +36 -0
- package/dist/cjs/offline-state.d.ts +97 -0
- package/dist/cjs/offline-state.js +198 -0
- package/dist/cjs/package.json +3 -0
- package/dist/cjs/pending-write.d.ts +47 -0
- package/dist/cjs/pending-write.js +22 -0
- package/dist/cjs/platform.d.ts +51 -0
- package/dist/cjs/platform.js +41 -0
- package/dist/cjs/realtime.d.ts +44 -0
- package/dist/cjs/realtime.js +195 -0
- package/dist/cjs/record.d.ts +2 -0
- package/dist/cjs/record.js +23 -0
- package/dist/cjs/shared.d.ts +9 -0
- package/dist/cjs/shared.js +43 -0
- package/dist/cjs/storage-errors.d.ts +163 -0
- package/dist/cjs/storage-errors.js +253 -0
- package/dist/cjs/storage.d.ts +198 -0
- package/dist/cjs/storage.js +451 -0
- package/dist/cjs/sync-engine.d.ts +30 -0
- package/dist/cjs/sync-engine.js +286 -0
- package/dist/cjs/types.d.ts +487 -0
- package/dist/cjs/types.js +40 -0
- package/dist/esm/analytics.d.ts +24 -0
- package/dist/esm/analytics.js +106 -0
- package/dist/esm/auth-errors.d.ts +117 -0
- package/dist/esm/auth-errors.js +222 -0
- package/dist/esm/auth.d.ts +213 -0
- package/dist/esm/auth.js +809 -0
- package/dist/esm/cache-store.d.ts +50 -0
- package/dist/esm/cache-store.js +179 -0
- package/dist/esm/conflict.d.ts +80 -0
- package/dist/esm/conflict.js +80 -0
- package/dist/esm/database-errors.d.ts +101 -0
- package/dist/esm/database-errors.js +189 -0
- package/dist/esm/database.d.ts +298 -0
- package/dist/esm/database.js +848 -0
- package/dist/esm/device-id.d.ts +1 -0
- package/dist/esm/device-id.js +54 -0
- package/dist/esm/device-metadata.d.ts +36 -0
- package/dist/esm/device-metadata.js +90 -0
- package/dist/esm/errors.d.ts +64 -0
- package/dist/esm/errors.js +79 -0
- package/dist/esm/flags.d.ts +15 -0
- package/dist/esm/flags.js +72 -0
- package/dist/esm/function-errors.d.ts +51 -0
- package/dist/esm/function-errors.js +93 -0
- package/dist/esm/functions.d.ts +15 -0
- package/dist/esm/functions.js +79 -0
- package/dist/esm/index.d.ts +22 -0
- package/dist/esm/index.js +25 -0
- package/dist/esm/messaging.d.ts +13 -0
- package/dist/esm/messaging.js +32 -0
- package/dist/esm/offline-state.d.ts +97 -0
- package/dist/esm/offline-state.js +189 -0
- package/dist/esm/package.json +3 -0
- package/dist/esm/pending-write.d.ts +47 -0
- package/dist/esm/pending-write.js +19 -0
- package/dist/esm/platform.d.ts +51 -0
- package/dist/esm/platform.js +36 -0
- package/dist/esm/realtime.d.ts +44 -0
- package/dist/esm/realtime.js +191 -0
- package/dist/esm/record.d.ts +2 -0
- package/dist/esm/record.js +20 -0
- package/dist/esm/shared.d.ts +9 -0
- package/dist/esm/shared.js +39 -0
- package/dist/esm/storage-errors.d.ts +163 -0
- package/dist/esm/storage-errors.js +239 -0
- package/dist/esm/storage.d.ts +198 -0
- package/dist/esm/storage.js +447 -0
- package/dist/esm/sync-engine.d.ts +30 -0
- package/dist/esm/sync-engine.js +282 -0
- package/dist/esm/types.d.ts +487 -0
- package/dist/esm/types.js +37 -0
- 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
|
+
}
|