taladb 0.9.3 → 0.10.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 +2 -2
- package/dist/index.browser.mjs +844 -89
- package/dist/index.d.mts +781 -6
- package/dist/index.d.ts +781 -6
- package/dist/index.js +855 -89
- package/dist/index.mjs +844 -89
- package/dist/index.react-native.mjs +844 -89
- package/package.json +9 -4
package/dist/index.browser.mjs
CHANGED
|
@@ -40,7 +40,11 @@ function unsupportedSync(runtime) {
|
|
|
40
40
|
}
|
|
41
41
|
async function readCursor(cursorCol, target) {
|
|
42
42
|
const doc = await cursorCol.findOne({ target });
|
|
43
|
-
return {
|
|
43
|
+
return {
|
|
44
|
+
pushMs: doc?.pushMs ?? 0,
|
|
45
|
+
pullMs: doc?.pullMs ?? 0,
|
|
46
|
+
pullCursor: doc?.pullCursor
|
|
47
|
+
};
|
|
44
48
|
}
|
|
45
49
|
async function writeCursor(cursorCol, target, cursor) {
|
|
46
50
|
const updated = await cursorCol.updateOne({ target }, { $set: { ...cursor } });
|
|
@@ -48,13 +52,19 @@ async function writeCursor(cursorCol, target, cursor) {
|
|
|
48
52
|
await cursorCol.insert({ target, ...cursor });
|
|
49
53
|
}
|
|
50
54
|
}
|
|
55
|
+
function isCursorAdapter(adapter) {
|
|
56
|
+
return typeof adapter.pullWithCursor === "function";
|
|
57
|
+
}
|
|
58
|
+
var MAX_PULL_PAGES = 1e4;
|
|
51
59
|
async function runSync(handle, adapter, options, syncSchemas = {}) {
|
|
52
60
|
const direction = options.direction ?? "both";
|
|
53
61
|
const target = options.target ?? "default";
|
|
54
62
|
const doPush = direction === "push" || direction === "both";
|
|
55
63
|
const doPull = direction === "pull" || direction === "both";
|
|
56
|
-
if (doPull && !adapter.pull) {
|
|
57
|
-
throw new Error(
|
|
64
|
+
if (doPull && !adapter.pull && !isCursorAdapter(adapter)) {
|
|
65
|
+
throw new Error(
|
|
66
|
+
`sync direction '${direction}' requires adapter.pull() or adapter.pullWithCursor()`
|
|
67
|
+
);
|
|
58
68
|
}
|
|
59
69
|
if (doPush && !adapter.push) {
|
|
60
70
|
throw new Error(`sync direction '${direction}' requires adapter.push()`);
|
|
@@ -71,17 +81,35 @@ async function runSync(handle, adapter, options, syncSchemas = {}) {
|
|
|
71
81
|
let pulled = 0;
|
|
72
82
|
let skipped = 0;
|
|
73
83
|
let quarantined = 0;
|
|
84
|
+
let pullCursor = cursor.pullCursor;
|
|
85
|
+
async function importOne(changeset) {
|
|
86
|
+
if (!changeset || changeset === "[]") return;
|
|
87
|
+
if (useValidated) {
|
|
88
|
+
const report = await handle.importChangesValidated(changeset, JSON.stringify(scopedSchemas));
|
|
89
|
+
pulled += report.applied;
|
|
90
|
+
skipped += report.skipped;
|
|
91
|
+
quarantined += report.quarantined;
|
|
92
|
+
} else {
|
|
93
|
+
pulled += await handle.importChanges(changeset);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
74
96
|
if (doPull) {
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
const
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
97
|
+
if (isCursorAdapter(adapter)) {
|
|
98
|
+
let pages = 0;
|
|
99
|
+
for (; ; ) {
|
|
100
|
+
const result = await adapter.pullWithCursor(pullCursor ?? null);
|
|
101
|
+
await importOne(result.changeset);
|
|
102
|
+
pullCursor = result.cursor;
|
|
103
|
+
await writeCursor(cursorCol, target, { ...cursor, pullCursor });
|
|
104
|
+
if (!result.hasMore) break;
|
|
105
|
+
if (++pages >= MAX_PULL_PAGES) {
|
|
106
|
+
throw new Error(
|
|
107
|
+
`sync: origin returned hasMore after ${MAX_PULL_PAGES} pages for target '${target}' \u2014 it is probably not advancing its cursor.`
|
|
108
|
+
);
|
|
109
|
+
}
|
|
84
110
|
}
|
|
111
|
+
} else {
|
|
112
|
+
await importOne(await adapter.pull(0));
|
|
85
113
|
}
|
|
86
114
|
}
|
|
87
115
|
let pushed = 0;
|
|
@@ -91,7 +119,8 @@ async function runSync(handle, adapter, options, syncSchemas = {}) {
|
|
|
91
119
|
}
|
|
92
120
|
await writeCursor(cursorCol, target, {
|
|
93
121
|
pushMs: cursor.pushMs,
|
|
94
|
-
pullMs: cursor.pullMs
|
|
122
|
+
pullMs: cursor.pullMs,
|
|
123
|
+
...pullCursor !== void 0 ? { pullCursor } : {}
|
|
95
124
|
});
|
|
96
125
|
return { pushed, pulled, skipped, quarantined, cursor: 0 };
|
|
97
126
|
}
|
|
@@ -132,6 +161,478 @@ var HttpSyncAdapter = class {
|
|
|
132
161
|
}
|
|
133
162
|
};
|
|
134
163
|
|
|
164
|
+
// src/derive-id.ts
|
|
165
|
+
var FNV1A128_OFFSET_BASIS = 0x6c62272e07bb014262b821756295c58dn;
|
|
166
|
+
var FNV1A128_PRIME = 0x0000000001000000000000000000013bn;
|
|
167
|
+
var MASK_128 = (1n << 128n) - 1n;
|
|
168
|
+
var CROCKFORD = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
|
|
169
|
+
var UTF8 = new TextEncoder();
|
|
170
|
+
function encodeUlid(value) {
|
|
171
|
+
let out = "";
|
|
172
|
+
for (let i = 25; i >= 0; i--) {
|
|
173
|
+
out += CROCKFORD[Number(value >> BigInt(i * 5) & 31n)];
|
|
174
|
+
}
|
|
175
|
+
return out;
|
|
176
|
+
}
|
|
177
|
+
function deriveDocId(collection, key) {
|
|
178
|
+
const bytes = [...UTF8.encode(collection), 0, ...UTF8.encode(key)];
|
|
179
|
+
let hash = FNV1A128_OFFSET_BASIS;
|
|
180
|
+
for (const byte of bytes) {
|
|
181
|
+
hash ^= BigInt(byte);
|
|
182
|
+
hash = hash * FNV1A128_PRIME & MASK_128;
|
|
183
|
+
}
|
|
184
|
+
return encodeUlid(hash);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// src/replication/coverage.ts
|
|
188
|
+
var COVERAGE_COLLECTION = "__taladb_replica";
|
|
189
|
+
function coverageKey(key) {
|
|
190
|
+
return [
|
|
191
|
+
key.origin,
|
|
192
|
+
key.collection,
|
|
193
|
+
key.scope,
|
|
194
|
+
`p${key.projectionVersion}`,
|
|
195
|
+
`s${key.schemaVersion}`
|
|
196
|
+
].map(encodeURIComponent).join("|");
|
|
197
|
+
}
|
|
198
|
+
var CoverageStore = class {
|
|
199
|
+
constructor(db) {
|
|
200
|
+
this.col = db.collection(COVERAGE_COLLECTION);
|
|
201
|
+
}
|
|
202
|
+
async read(key) {
|
|
203
|
+
const doc = await this.col.findOne({ key: coverageKey(key) });
|
|
204
|
+
if (!doc?.state) return { status: "empty" };
|
|
205
|
+
try {
|
|
206
|
+
return JSON.parse(doc.state);
|
|
207
|
+
} catch {
|
|
208
|
+
return { status: "empty" };
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
async write(key, state) {
|
|
212
|
+
const k = coverageKey(key);
|
|
213
|
+
const state_json = JSON.stringify(state);
|
|
214
|
+
await this.col.replaceManyWithIds(
|
|
215
|
+
[{ _id: deriveDocId(COVERAGE_COLLECTION, k), key: k, state: state_json }],
|
|
216
|
+
"local"
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
/** Drop a scope's coverage, forcing a fresh bootstrap on next use. */
|
|
220
|
+
async clear(key) {
|
|
221
|
+
const k = coverageKey(key);
|
|
222
|
+
await this.col.deleteManyWithIds([deriveDocId(COVERAGE_COLLECTION, k)], "local");
|
|
223
|
+
}
|
|
224
|
+
};
|
|
225
|
+
function isAuthoritative(state) {
|
|
226
|
+
return state.status === "complete";
|
|
227
|
+
}
|
|
228
|
+
function rowsApplied(state) {
|
|
229
|
+
switch (state.status) {
|
|
230
|
+
case "hydrating":
|
|
231
|
+
case "complete":
|
|
232
|
+
case "best-effort":
|
|
233
|
+
return state.rowsApplied;
|
|
234
|
+
default:
|
|
235
|
+
return 0;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
function progress(state) {
|
|
239
|
+
if (state.status === "complete") return 1;
|
|
240
|
+
if (state.status !== "hydrating" || !state.total) return void 0;
|
|
241
|
+
return Math.min(1, state.rowsApplied / state.total);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// src/replication/coordinator.ts
|
|
245
|
+
var DEFAULT_PAGE_SIZE = 500;
|
|
246
|
+
var defaultYield = () => new Promise((resolve) => setTimeout(resolve, 0));
|
|
247
|
+
var MAX_BOOTSTRAP_PAGES = 1e5;
|
|
248
|
+
var inflightByDatabase = /* @__PURE__ */ new WeakMap();
|
|
249
|
+
var REPLICA_SCOPE_FIELD = "_replica_scope";
|
|
250
|
+
var REPLICA_REVISION_FIELD = "_remote_rev";
|
|
251
|
+
var ReplicationCoordinator = class {
|
|
252
|
+
constructor(db, source, options = {}) {
|
|
253
|
+
this.db = db;
|
|
254
|
+
this.source = source;
|
|
255
|
+
this.coverage = new CoverageStore(db);
|
|
256
|
+
this.key = {
|
|
257
|
+
origin: source.origin,
|
|
258
|
+
collection: source.collection,
|
|
259
|
+
scope: source.scope,
|
|
260
|
+
projectionVersion: source.projectionVersion,
|
|
261
|
+
schemaVersion: source.schemaVersion
|
|
262
|
+
};
|
|
263
|
+
this.pageSize = options.pageSize ?? DEFAULT_PAGE_SIZE;
|
|
264
|
+
this.yieldFn = options.yieldFn ?? defaultYield;
|
|
265
|
+
this.onProgress = options.onProgress;
|
|
266
|
+
this.collectionOptions = options.collectionOptions;
|
|
267
|
+
let shared = inflightByDatabase.get(db);
|
|
268
|
+
if (!shared) {
|
|
269
|
+
shared = /* @__PURE__ */ new Map();
|
|
270
|
+
inflightByDatabase.set(db, shared);
|
|
271
|
+
}
|
|
272
|
+
this.inflight = shared;
|
|
273
|
+
}
|
|
274
|
+
get replicaScope() {
|
|
275
|
+
return coverageKey(this.key);
|
|
276
|
+
}
|
|
277
|
+
get identityNamespace() {
|
|
278
|
+
return `${this.source.origin}\0${this.source.scope}\0${this.source.collection}`;
|
|
279
|
+
}
|
|
280
|
+
getCoverage() {
|
|
281
|
+
return this.coverage.read(this.key);
|
|
282
|
+
}
|
|
283
|
+
/** Whether a purely local read is authorized right now. */
|
|
284
|
+
async isReady() {
|
|
285
|
+
return isAuthoritative(await this.getCoverage());
|
|
286
|
+
}
|
|
287
|
+
/** Dedup by intent: identical concurrent work joins rather than duplicating. */
|
|
288
|
+
dedup(key, run) {
|
|
289
|
+
const existing = this.inflight.get(key);
|
|
290
|
+
if (existing) return existing;
|
|
291
|
+
const pass = run().finally(() => this.inflight.delete(key));
|
|
292
|
+
this.inflight.set(key, pass);
|
|
293
|
+
return pass;
|
|
294
|
+
}
|
|
295
|
+
/**
|
|
296
|
+
* Write a batch of remote rows into the local collection.
|
|
297
|
+
*
|
|
298
|
+
* One commit for the whole batch, ids derived from the origin's primary key, and
|
|
299
|
+
* `origin: 'remote'` so the rows can never replicate back out at the origin they
|
|
300
|
+
* came from. This is the *only* write path in the coordinator — bootstrap, delta
|
|
301
|
+
* and bridge all funnel through it, which is precisely why they converge instead
|
|
302
|
+
* of conflicting.
|
|
303
|
+
*/
|
|
304
|
+
async applyRows(rows) {
|
|
305
|
+
if (rows.length === 0) return [];
|
|
306
|
+
const col = this.db.collection(this.source.collection, this.collectionOptions);
|
|
307
|
+
const docs = rows.map(
|
|
308
|
+
(row) => {
|
|
309
|
+
const revision = this.source.revisionOf(row);
|
|
310
|
+
return {
|
|
311
|
+
...this.source.mapRow(row),
|
|
312
|
+
_id: deriveDocId(this.identityNamespace, String(this.source.keyOf(row))),
|
|
313
|
+
[REPLICA_SCOPE_FIELD]: this.replicaScope,
|
|
314
|
+
[REPLICA_REVISION_FIELD]: revision
|
|
315
|
+
};
|
|
316
|
+
}
|
|
317
|
+
);
|
|
318
|
+
await col.replaceManyWithIds(docs, "remote");
|
|
319
|
+
return docs.map((doc) => doc._id);
|
|
320
|
+
}
|
|
321
|
+
/**
|
|
322
|
+
* Hydrate the scope: walk the origin page by page until the whole collection is
|
|
323
|
+
* local, then mark it complete.
|
|
324
|
+
*
|
|
325
|
+
* Resumable and idempotent. If the walk is interrupted — a reload, a crash, a
|
|
326
|
+
* dead network — the next call picks up from the last committed page, and
|
|
327
|
+
* re-applying a page it already wrote is a no-op because the ids are derived.
|
|
328
|
+
*/
|
|
329
|
+
hydrate() {
|
|
330
|
+
return this.dedup(`${this.replicaScope}:hydrate`, () => this.runHydrate());
|
|
331
|
+
}
|
|
332
|
+
async runHydrate() {
|
|
333
|
+
let state = await this.coverage.read(this.key);
|
|
334
|
+
if (state.status === "complete") return state;
|
|
335
|
+
let page = null;
|
|
336
|
+
let snapshot = null;
|
|
337
|
+
let rowsApplied2 = 0;
|
|
338
|
+
let total;
|
|
339
|
+
let deltaCursor;
|
|
340
|
+
if (state.status === "hydrating") {
|
|
341
|
+
page = state.nextPage;
|
|
342
|
+
snapshot = state.snapshot;
|
|
343
|
+
rowsApplied2 = state.rowsApplied;
|
|
344
|
+
total = state.total;
|
|
345
|
+
deltaCursor = state.deltaCursor;
|
|
346
|
+
} else if (state.status === "error" && state.snapshot) {
|
|
347
|
+
page = state.resumeFrom;
|
|
348
|
+
snapshot = state.snapshot;
|
|
349
|
+
rowsApplied2 = state.rowsApplied ?? 0;
|
|
350
|
+
total = state.total;
|
|
351
|
+
deltaCursor = state.deltaCursor;
|
|
352
|
+
}
|
|
353
|
+
let snapshotSupported = true;
|
|
354
|
+
let pages = 0;
|
|
355
|
+
try {
|
|
356
|
+
for (; ; ) {
|
|
357
|
+
const result = await this.source.bootstrap({ page, snapshot, limit: this.pageSize });
|
|
358
|
+
if (snapshot !== null && result.snapshot !== void 0 && result.snapshot !== snapshot) {
|
|
359
|
+
throw new Error(
|
|
360
|
+
`replication: origin '${this.source.origin}' changed snapshot token mid-walk`
|
|
361
|
+
);
|
|
362
|
+
}
|
|
363
|
+
if (snapshot === null && result.snapshot) snapshot = result.snapshot;
|
|
364
|
+
if (result.snapshot === void 0 && page === null) snapshotSupported = false;
|
|
365
|
+
if (result.deltaCursor && !deltaCursor) deltaCursor = result.deltaCursor;
|
|
366
|
+
if (result.total !== void 0) total = result.total;
|
|
367
|
+
rowsApplied2 += (await this.applyRows(result.rows)).length;
|
|
368
|
+
page = result.nextPage;
|
|
369
|
+
if (page !== null) {
|
|
370
|
+
const next = {
|
|
371
|
+
status: "hydrating",
|
|
372
|
+
snapshot: snapshot ?? "",
|
|
373
|
+
nextPage: page,
|
|
374
|
+
rowsApplied: rowsApplied2,
|
|
375
|
+
...deltaCursor !== void 0 ? { deltaCursor } : {},
|
|
376
|
+
...total !== void 0 ? { total } : {}
|
|
377
|
+
};
|
|
378
|
+
await this.coverage.write(this.key, next);
|
|
379
|
+
this.onProgress?.(next);
|
|
380
|
+
if (++pages >= MAX_BOOTSTRAP_PAGES) {
|
|
381
|
+
throw new Error(
|
|
382
|
+
`replication: origin '${this.source.origin}' offered more than ${MAX_BOOTSTRAP_PAGES} bootstrap pages for '${this.source.collection}' \u2014 it is probably not advancing nextPage.`
|
|
383
|
+
);
|
|
384
|
+
}
|
|
385
|
+
await this.yieldFn();
|
|
386
|
+
continue;
|
|
387
|
+
}
|
|
388
|
+
if (snapshotSupported && this.source.delta && deltaCursor === void 0) {
|
|
389
|
+
throw new Error(
|
|
390
|
+
`replication: origin '${this.source.origin}' supports delta refresh but did not issue deltaCursor on the first bootstrap page`
|
|
391
|
+
);
|
|
392
|
+
}
|
|
393
|
+
state = snapshotSupported ? {
|
|
394
|
+
status: "complete",
|
|
395
|
+
cursor: deltaCursor ?? "",
|
|
396
|
+
completedAt: Date.now(),
|
|
397
|
+
rowsApplied: rowsApplied2,
|
|
398
|
+
...total !== void 0 ? { total } : {}
|
|
399
|
+
} : {
|
|
400
|
+
// Every row the origin offered was applied — but without a snapshot
|
|
401
|
+
// we cannot prove we saw a consistent view of it, so we must not
|
|
402
|
+
// claim completeness. Reads keep going to the network.
|
|
403
|
+
status: "best-effort",
|
|
404
|
+
cursor: deltaCursor ?? "",
|
|
405
|
+
reason: "the origin did not return a snapshot token, so a row that moved between pages during the walk may have been missed",
|
|
406
|
+
rowsApplied: rowsApplied2,
|
|
407
|
+
...total !== void 0 ? { total } : {}
|
|
408
|
+
};
|
|
409
|
+
await this.coverage.write(this.key, state);
|
|
410
|
+
this.onProgress?.(state);
|
|
411
|
+
return state;
|
|
412
|
+
}
|
|
413
|
+
} catch (error) {
|
|
414
|
+
const failed = {
|
|
415
|
+
status: "error",
|
|
416
|
+
resumeFrom: page ?? 0,
|
|
417
|
+
...snapshot ? { snapshot } : {},
|
|
418
|
+
...deltaCursor !== void 0 ? { deltaCursor } : {},
|
|
419
|
+
rowsApplied: rowsApplied2,
|
|
420
|
+
...total !== void 0 ? { total } : {},
|
|
421
|
+
error: error instanceof Error ? error.message : String(error)
|
|
422
|
+
};
|
|
423
|
+
await this.coverage.write(this.key, failed);
|
|
424
|
+
this.onProgress?.(failed);
|
|
425
|
+
throw error;
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
/**
|
|
429
|
+
* Apply incremental changes since the stored cursor.
|
|
430
|
+
*
|
|
431
|
+
* Deletions are applied by mapping the origin's primary keys through the same
|
|
432
|
+
* `deriveDocId`, and are written with `origin: 'remote'` so they leave no
|
|
433
|
+
* tombstone — the origin already knows it deleted these, and a tombstone would
|
|
434
|
+
* push its own deletion back at it.
|
|
435
|
+
*/
|
|
436
|
+
refresh() {
|
|
437
|
+
return this.dedup(`${this.replicaScope}:refresh`, () => this.runRefresh());
|
|
438
|
+
}
|
|
439
|
+
async runRefresh() {
|
|
440
|
+
const state = await this.coverage.read(this.key);
|
|
441
|
+
if (state.status !== "complete") return state;
|
|
442
|
+
if (!this.source.delta) return state;
|
|
443
|
+
const col = this.db.collection(this.source.collection);
|
|
444
|
+
let cursor = state.cursor;
|
|
445
|
+
let rowsApplied2 = state.rowsApplied;
|
|
446
|
+
for (; ; ) {
|
|
447
|
+
const page = await this.source.delta(cursor);
|
|
448
|
+
rowsApplied2 += (await this.applyRows(page.changed)).length;
|
|
449
|
+
if (page.deleted.length > 0) {
|
|
450
|
+
const ids = page.deleted.map(
|
|
451
|
+
(k) => deriveDocId(this.identityNamespace, String(k))
|
|
452
|
+
);
|
|
453
|
+
await col.deleteManyWithIds(ids, "remote");
|
|
454
|
+
}
|
|
455
|
+
cursor = page.cursor;
|
|
456
|
+
const next = { ...state, cursor, rowsApplied: rowsApplied2 };
|
|
457
|
+
await this.coverage.write(this.key, next);
|
|
458
|
+
if (!page.hasMore) {
|
|
459
|
+
this.onProgress?.(next);
|
|
460
|
+
return next;
|
|
461
|
+
}
|
|
462
|
+
await this.yieldFn();
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
/**
|
|
466
|
+
* Cold-start bridge: fetch exactly the rows one query needs, right now.
|
|
467
|
+
*
|
|
468
|
+
* Needed because a SPA or React Native app has no server render to paint behind
|
|
469
|
+
* while the replica fills. The rows land in the same collection under the same
|
|
470
|
+
* derived ids as the walk's, so this is not a cache — it is the replica, arriving
|
|
471
|
+
* early.
|
|
472
|
+
*
|
|
473
|
+
* **Does not advance coverage.** These rows did not come from the bootstrap
|
|
474
|
+
* snapshot and prove nothing about completeness; treating them as progress would
|
|
475
|
+
* let a page-1 fetch masquerade as a hydrated catalog.
|
|
476
|
+
*/
|
|
477
|
+
bridge(query) {
|
|
478
|
+
if (!this.source.fetchQuery) return Promise.resolve({ count: 0, ids: [] });
|
|
479
|
+
const key = `bridge:${coverageKey(this.key)}:${JSON.stringify(query)}`;
|
|
480
|
+
return this.dedup(key, async () => {
|
|
481
|
+
const rows = await this.source.fetchQuery(query);
|
|
482
|
+
const ids = await this.applyRows(rows);
|
|
483
|
+
return { count: ids.length, ids };
|
|
484
|
+
});
|
|
485
|
+
}
|
|
486
|
+
/** Drop coverage and force a fresh bootstrap. Local rows are left alone. */
|
|
487
|
+
async reset() {
|
|
488
|
+
await this.coverage.clear(this.key);
|
|
489
|
+
}
|
|
490
|
+
};
|
|
491
|
+
|
|
492
|
+
// src/replication/rest.ts
|
|
493
|
+
function parseRows(body, endpoint) {
|
|
494
|
+
if (Array.isArray(body)) return body;
|
|
495
|
+
if (body && typeof body === "object") {
|
|
496
|
+
const env = body;
|
|
497
|
+
for (const field of ["data", "items", "rows"]) {
|
|
498
|
+
const value = env[field];
|
|
499
|
+
if (Array.isArray(value)) return value;
|
|
500
|
+
}
|
|
501
|
+
throw new Error(
|
|
502
|
+
`taladb: could not find a row array in the response from ${endpoint}. Expected a bare array or a { data | items | rows } envelope, but got an object with keys: ${Object.keys(env).join(", ") || "(none)"}. Pass { parse } to extract them yourself.`
|
|
503
|
+
);
|
|
504
|
+
}
|
|
505
|
+
throw new Error(
|
|
506
|
+
`taladb: expected an array or object from ${endpoint}, got ${typeof body}.`
|
|
507
|
+
);
|
|
508
|
+
}
|
|
509
|
+
function pick(body, names) {
|
|
510
|
+
if (!body || typeof body !== "object") return void 0;
|
|
511
|
+
const rec = body;
|
|
512
|
+
for (const n of names) {
|
|
513
|
+
if (rec[n] !== void 0) return rec[n];
|
|
514
|
+
const meta = rec.meta;
|
|
515
|
+
if (meta && meta[n] !== void 0) return meta[n];
|
|
516
|
+
}
|
|
517
|
+
return void 0;
|
|
518
|
+
}
|
|
519
|
+
function createRestSource(options) {
|
|
520
|
+
const {
|
|
521
|
+
endpoint,
|
|
522
|
+
collection,
|
|
523
|
+
origin = endpoint,
|
|
524
|
+
scope = "global",
|
|
525
|
+
projectionVersion = 1,
|
|
526
|
+
schemaVersion = 1,
|
|
527
|
+
key = "id",
|
|
528
|
+
revision = "rev",
|
|
529
|
+
mapRow,
|
|
530
|
+
getAuth,
|
|
531
|
+
paths,
|
|
532
|
+
toParams,
|
|
533
|
+
parse,
|
|
534
|
+
pagination = "page"
|
|
535
|
+
} = options;
|
|
536
|
+
const doFetch = options.fetch ?? globalThis.fetch;
|
|
537
|
+
async function get(path, params) {
|
|
538
|
+
const url = new URL(path, globalThis.location?.origin ?? "http://localhost");
|
|
539
|
+
for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v);
|
|
540
|
+
const headers = getAuth ? await getAuth() : void 0;
|
|
541
|
+
const response = await doFetch(url.href, { headers });
|
|
542
|
+
if (!response.ok) {
|
|
543
|
+
throw new Error(
|
|
544
|
+
`taladb: ${path} responded ${response.status} ${response.statusText}`
|
|
545
|
+
);
|
|
546
|
+
}
|
|
547
|
+
return response.json();
|
|
548
|
+
}
|
|
549
|
+
const rowsFrom = (body) => parse ? parse(body) : parseRows(body, endpoint);
|
|
550
|
+
return {
|
|
551
|
+
origin,
|
|
552
|
+
collection,
|
|
553
|
+
scope,
|
|
554
|
+
projectionVersion,
|
|
555
|
+
schemaVersion,
|
|
556
|
+
keyOf: (row) => {
|
|
557
|
+
const value = row[key];
|
|
558
|
+
if (value === void 0 || value === null) {
|
|
559
|
+
throw new Error(
|
|
560
|
+
`taladb: row from ${endpoint} has no '${key}' field to use as its primary key. Pass { key } to name the right one. Without a stable key, repeated fetches of the same row cannot be recognized as the same row.`
|
|
561
|
+
);
|
|
562
|
+
}
|
|
563
|
+
return String(value);
|
|
564
|
+
},
|
|
565
|
+
revisionOf: (row) => {
|
|
566
|
+
const value = typeof revision === "function" ? revision(row) : row[revision];
|
|
567
|
+
if (value === void 0 || value === null) {
|
|
568
|
+
throw new Error(
|
|
569
|
+
`taladb: row from ${endpoint} has no authoritative revision. Pass { revision } to name the monotonic revision field.`
|
|
570
|
+
);
|
|
571
|
+
}
|
|
572
|
+
const n = Number(value);
|
|
573
|
+
if (!Number.isSafeInteger(n)) {
|
|
574
|
+
throw new Error(`taladb: authoritative revision must be a safe integer, got ${String(value)}`);
|
|
575
|
+
}
|
|
576
|
+
return n;
|
|
577
|
+
},
|
|
578
|
+
mapRow: (row) => {
|
|
579
|
+
if (mapRow) return mapRow(row);
|
|
580
|
+
const { _id, ...rest } = row;
|
|
581
|
+
void _id;
|
|
582
|
+
return rest;
|
|
583
|
+
},
|
|
584
|
+
bootstrap: async (request) => {
|
|
585
|
+
const params = { limit: String(request.limit) };
|
|
586
|
+
if (request.page !== null) params.page = String(request.page);
|
|
587
|
+
if (request.snapshot !== null) params.snapshot = request.snapshot;
|
|
588
|
+
const body = await get(endpoint + (paths?.bootstrap ?? ""), params);
|
|
589
|
+
const rows = rowsFrom(body);
|
|
590
|
+
const nextPage = pick(body, ["nextPage", "next_page", "next"]);
|
|
591
|
+
const snapshot = pick(body, ["snapshot"]);
|
|
592
|
+
const deltaCursor = pick(body, ["deltaCursor", "delta_cursor", "cursor"]);
|
|
593
|
+
const total = pick(body, ["total", "totalCount", "count"]);
|
|
594
|
+
return {
|
|
595
|
+
rows,
|
|
596
|
+
// An origin that reports no explicit `nextPage` is treated as exhausted
|
|
597
|
+
// once it returns a short page — the conventional REST behavior.
|
|
598
|
+
nextPage: nextPage !== void 0 ? nextPage : rows.length < request.limit ? null : pagination === "offset" ? Number(request.page ?? 0) + request.limit : Number(request.page ?? 1) + 1,
|
|
599
|
+
...snapshot !== void 0 ? { snapshot } : {},
|
|
600
|
+
...deltaCursor !== void 0 ? { deltaCursor } : {},
|
|
601
|
+
...total !== void 0 ? { total } : {}
|
|
602
|
+
};
|
|
603
|
+
},
|
|
604
|
+
...options.delta === true || paths?.delta ? { delta: async (cursor) => {
|
|
605
|
+
const body = await get(endpoint + (paths?.delta ?? ""), { since: cursor });
|
|
606
|
+
const changed = rowsFrom(body);
|
|
607
|
+
const deleted = pick(body, ["deleted", "removed"]) ?? [];
|
|
608
|
+
const next = pick(body, ["cursor", "now", "nextCursor"]);
|
|
609
|
+
return {
|
|
610
|
+
changed,
|
|
611
|
+
deleted: deleted.map(String),
|
|
612
|
+
cursor: next ?? cursor,
|
|
613
|
+
hasMore: Boolean(pick(body, ["hasMore", "has_more"]))
|
|
614
|
+
};
|
|
615
|
+
} } : {},
|
|
616
|
+
fetchQuery: async (query) => {
|
|
617
|
+
if (!toParams && Object.values(query.filter ?? {}).some((v) => typeof v === "object" && v !== null)) {
|
|
618
|
+
throw new Error(
|
|
619
|
+
"taladb: bridge filters with operators require RestSourceOptions.toParams; the default translator only supports scalar equality fields."
|
|
620
|
+
);
|
|
621
|
+
}
|
|
622
|
+
const sortEntry = Object.entries(query.sort ?? {})[0];
|
|
623
|
+
const params = toParams ? toParams(query) : {
|
|
624
|
+
...query.page !== void 0 ? { page: String(query.page) } : {},
|
|
625
|
+
...query.limit !== void 0 ? { limit: String(query.limit) } : {},
|
|
626
|
+
...Object.fromEntries(
|
|
627
|
+
Object.entries(query.filter ?? {}).map(([k, v]) => [k, String(v)])
|
|
628
|
+
),
|
|
629
|
+
...sortEntry ? { sort: sortEntry[0], order: sortEntry[1] === -1 ? "desc" : "asc" } : {}
|
|
630
|
+
};
|
|
631
|
+
return rowsFrom(await get(endpoint, params));
|
|
632
|
+
}
|
|
633
|
+
};
|
|
634
|
+
}
|
|
635
|
+
|
|
135
636
|
// src/index.ts
|
|
136
637
|
var TalaDbValidationError = class extends Error {
|
|
137
638
|
constructor(cause, context) {
|
|
@@ -158,18 +659,52 @@ function deepEqual(a, b) {
|
|
|
158
659
|
);
|
|
159
660
|
}
|
|
160
661
|
function applySchema(col, options) {
|
|
161
|
-
const {
|
|
662
|
+
const {
|
|
663
|
+
schema,
|
|
664
|
+
validateOnRead = false,
|
|
665
|
+
migrateDocument,
|
|
666
|
+
downgradeDocument,
|
|
667
|
+
syncSchema,
|
|
668
|
+
persistMigrations = false,
|
|
669
|
+
allowFieldRemoval = false,
|
|
670
|
+
retiredFields = []
|
|
671
|
+
} = options;
|
|
162
672
|
const targetVersion = syncSchema?.version ?? 0;
|
|
163
673
|
if (migrateDocument && targetVersion < 1) {
|
|
164
674
|
throw new Error("CollectionOptions.migrateDocument requires syncSchema.version (the migration target)");
|
|
165
675
|
}
|
|
676
|
+
if (downgradeDocument && targetVersion < 1) {
|
|
677
|
+
throw new Error("CollectionOptions.downgradeDocument requires syncSchema.version (the shape this build reads)");
|
|
678
|
+
}
|
|
166
679
|
if (syncSchema && targetVersion < 1 && (syncSchema.renames || syncSchema.defaults)) {
|
|
167
680
|
throw new Error(
|
|
168
681
|
"CollectionOptions.syncSchema.renames/defaults require syncSchema.version >= 1 \u2014 without a version the import migration step never runs and documents missing the renamed/defaulted fields are quarantined instead of upgraded"
|
|
169
682
|
);
|
|
170
683
|
}
|
|
171
684
|
const stampVersion = targetVersion > 0;
|
|
172
|
-
if (!schema && !migrateDocument && !stampVersion) return col;
|
|
685
|
+
if (!schema && !migrateDocument && !downgradeDocument && !stampVersion) return col;
|
|
686
|
+
const retired = new Set(retiredFields);
|
|
687
|
+
const engineOwned = /* @__PURE__ */ new Set([
|
|
688
|
+
"_id",
|
|
689
|
+
"_v",
|
|
690
|
+
"_changed_at",
|
|
691
|
+
"_remote",
|
|
692
|
+
"_remote_rev",
|
|
693
|
+
"_replica_scope"
|
|
694
|
+
]);
|
|
695
|
+
const downcastViews = /* @__PURE__ */ new WeakSet();
|
|
696
|
+
function preserveFields(original, next, preserveUnknown, preserveVersion = true) {
|
|
697
|
+
let out = null;
|
|
698
|
+
for (const k of Object.keys(original)) {
|
|
699
|
+
const ownedField = engineOwned.has(k) && (k !== "_v" || preserveVersion);
|
|
700
|
+
const mustRestore = ownedField || preserveUnknown && !retired.has(k) && !(k in next);
|
|
701
|
+
if (!mustRestore) continue;
|
|
702
|
+
if (!ownedField && k in next) continue;
|
|
703
|
+
out ?? (out = { ...next });
|
|
704
|
+
out[k] = original[k];
|
|
705
|
+
}
|
|
706
|
+
return out ?? next;
|
|
707
|
+
}
|
|
173
708
|
function parseWrite(doc, label) {
|
|
174
709
|
try {
|
|
175
710
|
return schema.parse(doc);
|
|
@@ -177,10 +712,51 @@ function applySchema(col, options) {
|
|
|
177
712
|
throw new TalaDbValidationError(err, label);
|
|
178
713
|
}
|
|
179
714
|
}
|
|
715
|
+
function assertWritableDocument(doc, label) {
|
|
716
|
+
if (doc && typeof doc === "object" && downcastViews.has(doc)) {
|
|
717
|
+
throw new Error(`${label}: a downgradeDocument result is a read-only compatibility view`);
|
|
718
|
+
}
|
|
719
|
+
const version = doc?._v;
|
|
720
|
+
if (targetVersion > 0 && typeof version === "number" && version > targetVersion) {
|
|
721
|
+
throw new Error(
|
|
722
|
+
`${label}: this client supports schema v${targetVersion}, but the document is v${version}`
|
|
723
|
+
);
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
function writableFilter(filter) {
|
|
727
|
+
if (targetVersion < 1) return filter;
|
|
728
|
+
return {
|
|
729
|
+
$and: [
|
|
730
|
+
filter,
|
|
731
|
+
{
|
|
732
|
+
$or: [
|
|
733
|
+
{ _v: { $exists: false } },
|
|
734
|
+
{ _v: { $lte: targetVersion } }
|
|
735
|
+
]
|
|
736
|
+
}
|
|
737
|
+
]
|
|
738
|
+
};
|
|
739
|
+
}
|
|
740
|
+
function assertSafeUpdate(update) {
|
|
741
|
+
const record = update;
|
|
742
|
+
for (const op of ["$set", "$unset", "$inc", "$push", "$pull"]) {
|
|
743
|
+
const fields = record[op];
|
|
744
|
+
if (!fields) continue;
|
|
745
|
+
for (const field of Object.keys(fields)) {
|
|
746
|
+
if (engineOwned.has(field)) {
|
|
747
|
+
throw new Error(`update cannot modify engine-owned field '${field}'`);
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
}
|
|
180
752
|
function stamp(doc) {
|
|
181
753
|
if (!stampVersion || doc._v !== void 0) return doc;
|
|
182
754
|
return { ...doc, _v: targetVersion };
|
|
183
755
|
}
|
|
756
|
+
function stampDoc(doc) {
|
|
757
|
+
if (!stampVersion || doc._v !== void 0) return doc;
|
|
758
|
+
return { ...doc, _v: targetVersion };
|
|
759
|
+
}
|
|
184
760
|
function diffUpdate(original, migrated) {
|
|
185
761
|
const $set = {};
|
|
186
762
|
const $unset = {};
|
|
@@ -188,85 +764,179 @@ function applySchema(col, options) {
|
|
|
188
764
|
if (k === "_id") continue;
|
|
189
765
|
if (!deepEqual(migrated[k], original[k])) $set[k] = migrated[k];
|
|
190
766
|
}
|
|
191
|
-
|
|
192
|
-
|
|
767
|
+
if (allowFieldRemoval || retired.size > 0) {
|
|
768
|
+
for (const k of Object.keys(original)) {
|
|
769
|
+
if (k !== "_id" && !(k in migrated) && (allowFieldRemoval || retired.has(k))) $unset[k] = true;
|
|
770
|
+
}
|
|
193
771
|
}
|
|
194
772
|
const update = {};
|
|
195
773
|
if (Object.keys($set).length) update.$set = $set;
|
|
196
774
|
if (Object.keys($unset).length) update.$unset = $unset;
|
|
197
775
|
return Object.keys(update).length ? update : null;
|
|
198
776
|
}
|
|
199
|
-
function
|
|
200
|
-
if (!migrateDocument) return doc;
|
|
777
|
+
function normalizeRead(doc) {
|
|
201
778
|
const fromVersion = typeof doc._v === "number" ? doc._v : 0;
|
|
202
|
-
if (fromVersion
|
|
203
|
-
|
|
779
|
+
if (migrateDocument && fromVersion < targetVersion) {
|
|
780
|
+
const up = { ...migrateDocument(doc, fromVersion), _v: targetVersion };
|
|
781
|
+
return {
|
|
782
|
+
// The migration owns the version transition, while every other
|
|
783
|
+
// engine-owned field continues to come from the stored document.
|
|
784
|
+
value: allowFieldRemoval ? preserveFields(doc, up, false, false) : preserveFields(doc, up, true, false),
|
|
785
|
+
persistable: true
|
|
786
|
+
};
|
|
787
|
+
}
|
|
788
|
+
if (downgradeDocument && fromVersion > targetVersion) {
|
|
789
|
+
const projected = {
|
|
790
|
+
...downgradeDocument(doc, fromVersion),
|
|
791
|
+
...doc._id !== void 0 ? { _id: doc._id } : {},
|
|
792
|
+
_v: fromVersion
|
|
793
|
+
};
|
|
794
|
+
downcastViews.add(projected);
|
|
795
|
+
return { value: projected, persistable: false };
|
|
796
|
+
}
|
|
797
|
+
return { value: doc, persistable: true };
|
|
204
798
|
}
|
|
205
799
|
function validateRead(doc) {
|
|
206
800
|
if (!validateOnRead || !schema) return doc;
|
|
207
801
|
try {
|
|
208
|
-
|
|
802
|
+
const parsed = schema.parse(doc);
|
|
803
|
+
const fromVersion = typeof doc._v === "number" ? doc._v : 0;
|
|
804
|
+
return preserveFields(doc, parsed, targetVersion > 0 && fromVersion > targetVersion);
|
|
209
805
|
} catch (err) {
|
|
210
806
|
throw new TalaDbValidationError(err, "read");
|
|
211
807
|
}
|
|
212
808
|
}
|
|
213
|
-
async function persistAll(originals,
|
|
809
|
+
async function persistAll(originals, normalized) {
|
|
214
810
|
if (!persistMigrations) return;
|
|
215
811
|
for (let i = 0; i < originals.length; i++) {
|
|
216
812
|
const original = originals[i];
|
|
217
|
-
if (
|
|
218
|
-
const
|
|
813
|
+
if (!normalized[i].persistable) continue;
|
|
814
|
+
const migrated = normalized[i].value;
|
|
815
|
+
if (migrated === original || typeof original._id !== "string") continue;
|
|
816
|
+
const update = diffUpdate(original, migrated);
|
|
219
817
|
if (!update) continue;
|
|
220
818
|
try {
|
|
221
|
-
|
|
819
|
+
const guards = [{ _id: original._id }];
|
|
820
|
+
if (original._v === void 0) guards.push({ _v: { $exists: false } });
|
|
821
|
+
else guards.push({ _v: original._v });
|
|
822
|
+
if (original._changed_at !== void 0) {
|
|
823
|
+
guards.push({ _changed_at: original._changed_at });
|
|
824
|
+
}
|
|
825
|
+
await col.updateOne({ $and: guards }, update);
|
|
222
826
|
} catch {
|
|
223
827
|
}
|
|
224
828
|
}
|
|
225
829
|
}
|
|
226
|
-
const wrapReads = Boolean(migrateDocument) || validateOnRead && Boolean(schema);
|
|
830
|
+
const wrapReads = Boolean(migrateDocument) || Boolean(downgradeDocument) || validateOnRead && Boolean(schema);
|
|
227
831
|
const wrapWrites = Boolean(schema) || stampVersion;
|
|
832
|
+
function pipelinePreservesDocuments(pipeline) {
|
|
833
|
+
return pipeline.every((stage) => !("$group" in stage) && !("$project" in stage));
|
|
834
|
+
}
|
|
835
|
+
function normalizeViewRows(docs) {
|
|
836
|
+
return docs.map((doc) => validateRead(normalizeRead(doc).value));
|
|
837
|
+
}
|
|
228
838
|
return {
|
|
229
839
|
...col,
|
|
230
840
|
insert: wrapWrites ? async (doc) => {
|
|
841
|
+
assertWritableDocument(doc, "insert");
|
|
231
842
|
if (schema) parseWrite(doc, "insert");
|
|
232
843
|
return col.insert(stamp(doc));
|
|
233
844
|
} : col.insert.bind(col),
|
|
234
845
|
insertMany: wrapWrites ? async (docs) => {
|
|
846
|
+
docs.forEach((doc, i) => assertWritableDocument(doc, `insertMany[${i}]`));
|
|
235
847
|
if (schema) docs.forEach((doc, i) => parseWrite(doc, `insertMany[${i}]`));
|
|
236
848
|
return col.insertMany(docs.map(stamp));
|
|
237
849
|
} : col.insertMany.bind(col),
|
|
850
|
+
// Rows arriving from a remote origin are validated like any other write. This
|
|
851
|
+
// is the "parse, don't assert" boundary: the compile-time generic and the
|
|
852
|
+
// runtime schema check have to be the same seam, or a malformed server
|
|
853
|
+
// response walks straight into a typed collection.
|
|
854
|
+
replaceManyWithIds: wrapWrites ? async (docs, origin) => {
|
|
855
|
+
if (origin !== "remote") {
|
|
856
|
+
docs.forEach((doc, i) => assertWritableDocument(doc, `replaceManyWithIds[${i}]`));
|
|
857
|
+
if (targetVersion > 0) {
|
|
858
|
+
throw new Error(
|
|
859
|
+
"local replaceManyWithIds is disabled on versioned collections; use updateOne/updateMany so schema-version guards are atomic"
|
|
860
|
+
);
|
|
861
|
+
}
|
|
862
|
+
}
|
|
863
|
+
if (schema) docs.forEach((doc, i) => {
|
|
864
|
+
const { _replica_scope, _remote_rev, ...schemaDoc } = doc;
|
|
865
|
+
void _replica_scope;
|
|
866
|
+
void _remote_rev;
|
|
867
|
+
parseWrite(schemaDoc, `replaceManyWithIds[${i}]`);
|
|
868
|
+
});
|
|
869
|
+
return col.replaceManyWithIds(docs.map((d) => stampDoc(d)), origin);
|
|
870
|
+
} : col.replaceManyWithIds.bind(col),
|
|
871
|
+
deleteManyWithIds: stampVersion ? async (ids, origin) => {
|
|
872
|
+
if (origin !== "remote") {
|
|
873
|
+
throw new Error(
|
|
874
|
+
"local deleteManyWithIds is disabled on versioned collections; use deleteOne/deleteMany so schema-version guards are atomic"
|
|
875
|
+
);
|
|
876
|
+
}
|
|
877
|
+
return col.deleteManyWithIds(ids, origin);
|
|
878
|
+
} : col.deleteManyWithIds.bind(col),
|
|
879
|
+
updateOne: wrapWrites ? async (filter, update) => {
|
|
880
|
+
assertSafeUpdate(update);
|
|
881
|
+
return col.updateOne(writableFilter(filter), update);
|
|
882
|
+
} : col.updateOne.bind(col),
|
|
883
|
+
updateMany: wrapWrites ? async (filter, update) => {
|
|
884
|
+
assertSafeUpdate(update);
|
|
885
|
+
return col.updateMany(writableFilter(filter), update);
|
|
886
|
+
} : col.updateMany.bind(col),
|
|
887
|
+
deleteOne: stampVersion ? (filter) => col.deleteOne(writableFilter(filter)) : col.deleteOne.bind(col),
|
|
888
|
+
deleteMany: stampVersion ? (filter) => col.deleteMany(writableFilter(filter)) : col.deleteMany.bind(col),
|
|
238
889
|
find: wrapReads ? async (filter) => {
|
|
239
890
|
const docs = await col.find(filter);
|
|
240
|
-
const
|
|
241
|
-
await persistAll(docs,
|
|
242
|
-
return
|
|
891
|
+
const normalized = docs.map(normalizeRead);
|
|
892
|
+
await persistAll(docs, normalized);
|
|
893
|
+
return normalized.map((n) => validateRead(n.value));
|
|
243
894
|
} : col.find.bind(col),
|
|
244
895
|
findOne: wrapReads ? async (filter) => {
|
|
245
896
|
const doc = await col.findOne(filter);
|
|
246
897
|
if (doc === null) return null;
|
|
247
|
-
const
|
|
248
|
-
await persistAll([doc], [
|
|
249
|
-
return validateRead(
|
|
898
|
+
const normalized = normalizeRead(doc);
|
|
899
|
+
await persistAll([doc], [normalized]);
|
|
900
|
+
return validateRead(normalized.value);
|
|
250
901
|
} : col.findOne.bind(col),
|
|
902
|
+
aggregate: wrapReads ? async (pipeline) => {
|
|
903
|
+
const docs = await col.aggregate(pipeline);
|
|
904
|
+
return pipelinePreservesDocuments(pipeline) ? normalizeViewRows(docs) : docs;
|
|
905
|
+
} : col.aggregate.bind(col),
|
|
251
906
|
// Live queries feed every @taladb/react hook (useFind, useFindOne,
|
|
252
907
|
// useQueries). Leaving them unwrapped meant React components received the
|
|
253
908
|
// un-migrated shape while a direct find() returned the migrated one.
|
|
254
909
|
subscribe: wrapReads ? (filter, callback, onError) => col.subscribe(
|
|
255
910
|
filter,
|
|
256
911
|
(docs) => {
|
|
257
|
-
const
|
|
912
|
+
const normalized = docs.map(normalizeRead);
|
|
258
913
|
let out;
|
|
259
914
|
try {
|
|
260
|
-
out =
|
|
915
|
+
out = normalized.map((n) => validateRead(n.value));
|
|
261
916
|
} catch (err) {
|
|
262
917
|
onError?.(err);
|
|
263
918
|
return;
|
|
264
919
|
}
|
|
265
920
|
callback(out);
|
|
266
|
-
void persistAll(docs,
|
|
921
|
+
void persistAll(docs, normalized);
|
|
267
922
|
},
|
|
268
923
|
onError
|
|
269
|
-
) : col.subscribe.bind(col)
|
|
924
|
+
) : col.subscribe.bind(col),
|
|
925
|
+
subscribeAggregate: wrapReads ? (pipeline, callback, onError) => col.subscribeAggregate(
|
|
926
|
+
pipeline,
|
|
927
|
+
(docs) => {
|
|
928
|
+
if (!pipelinePreservesDocuments(pipeline)) {
|
|
929
|
+
callback(docs);
|
|
930
|
+
return;
|
|
931
|
+
}
|
|
932
|
+
try {
|
|
933
|
+
callback(normalizeViewRows(docs));
|
|
934
|
+
} catch (error) {
|
|
935
|
+
onError?.(error);
|
|
936
|
+
}
|
|
937
|
+
},
|
|
938
|
+
onError
|
|
939
|
+
) : col.subscribeAggregate.bind(col)
|
|
270
940
|
};
|
|
271
941
|
}
|
|
272
942
|
function detectPlatform() {
|
|
@@ -392,6 +1062,19 @@ async function createBrowserDB(dbName, config, passphrase, migrations) {
|
|
|
392
1062
|
});
|
|
393
1063
|
return JSON.parse(json);
|
|
394
1064
|
},
|
|
1065
|
+
replaceManyWithIds: async (docs, origin = "local") => {
|
|
1066
|
+
const json = await proxy.send("replaceManyWithIds", {
|
|
1067
|
+
collection: name,
|
|
1068
|
+
docsJson: s(docs),
|
|
1069
|
+
origin
|
|
1070
|
+
});
|
|
1071
|
+
return JSON.parse(json);
|
|
1072
|
+
},
|
|
1073
|
+
deleteManyWithIds: (ids, origin = "local") => proxy.send("deleteManyWithIds", {
|
|
1074
|
+
collection: name,
|
|
1075
|
+
idsJson: s(ids),
|
|
1076
|
+
origin
|
|
1077
|
+
}),
|
|
395
1078
|
find: async (filter) => {
|
|
396
1079
|
const json = await proxy.send("find", {
|
|
397
1080
|
collection: name,
|
|
@@ -463,59 +1146,96 @@ async function createBrowserDB(dbName, config, passphrase, migrations) {
|
|
|
463
1146
|
});
|
|
464
1147
|
return JSON.parse(json);
|
|
465
1148
|
},
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
poll();
|
|
507
|
-
return () => {
|
|
508
|
-
active = false;
|
|
509
|
-
nudgeCallbacks.delete(poll);
|
|
510
|
-
if (timer !== null) {
|
|
511
|
-
clearTimeout(timer);
|
|
512
|
-
timer = null;
|
|
513
|
-
}
|
|
514
|
-
};
|
|
515
|
-
}
|
|
1149
|
+
searchText: async (field, query, topK, filter, options) => {
|
|
1150
|
+
const json = await proxy.send("searchText", {
|
|
1151
|
+
collection: name,
|
|
1152
|
+
field,
|
|
1153
|
+
query,
|
|
1154
|
+
topK,
|
|
1155
|
+
filterJson: filter ? JSON.stringify(filter) : "null",
|
|
1156
|
+
optionsJson: options ? JSON.stringify(options) : "null"
|
|
1157
|
+
});
|
|
1158
|
+
return JSON.parse(json);
|
|
1159
|
+
},
|
|
1160
|
+
hybridSearch: async (text, vector, topK, filter, options) => {
|
|
1161
|
+
const json = await proxy.send("hybridSearch", {
|
|
1162
|
+
collection: name,
|
|
1163
|
+
textField: text.textField,
|
|
1164
|
+
text: text.text,
|
|
1165
|
+
vectorField: vector.vectorField,
|
|
1166
|
+
vectorJson: JSON.stringify(vector.vector),
|
|
1167
|
+
topK,
|
|
1168
|
+
filterJson: filter ? JSON.stringify(filter) : "null",
|
|
1169
|
+
optionsJson: options ? JSON.stringify(options) : "null"
|
|
1170
|
+
});
|
|
1171
|
+
return JSON.parse(json);
|
|
1172
|
+
},
|
|
1173
|
+
subscribe: (filter, callback, onError) => nudgedPoller(
|
|
1174
|
+
() => proxy.send("find", {
|
|
1175
|
+
collection: name,
|
|
1176
|
+
filterJson: filter ? s(filter) : "null"
|
|
1177
|
+
}),
|
|
1178
|
+
callback,
|
|
1179
|
+
onError
|
|
1180
|
+
),
|
|
1181
|
+
subscribeAggregate: (pipeline, callback, onError) => nudgedPoller(
|
|
1182
|
+
() => proxy.send("aggregate", {
|
|
1183
|
+
collection: name,
|
|
1184
|
+
pipelineJson: s(pipeline)
|
|
1185
|
+
}),
|
|
1186
|
+
callback,
|
|
1187
|
+
onError
|
|
1188
|
+
)
|
|
516
1189
|
};
|
|
517
1190
|
return opts ? applySchema(wrapped, opts) : wrapped;
|
|
518
1191
|
}
|
|
1192
|
+
function nudgedPoller(fetchJson, callback, onError) {
|
|
1193
|
+
let active = true;
|
|
1194
|
+
let lastJson = "";
|
|
1195
|
+
let timer = null;
|
|
1196
|
+
let running = false;
|
|
1197
|
+
let rerun = false;
|
|
1198
|
+
const poll = async () => {
|
|
1199
|
+
if (!active) return;
|
|
1200
|
+
if (running) {
|
|
1201
|
+
rerun = true;
|
|
1202
|
+
return;
|
|
1203
|
+
}
|
|
1204
|
+
running = true;
|
|
1205
|
+
if (timer !== null) {
|
|
1206
|
+
clearTimeout(timer);
|
|
1207
|
+
timer = null;
|
|
1208
|
+
}
|
|
1209
|
+
try {
|
|
1210
|
+
const json = await fetchJson();
|
|
1211
|
+
if (!active) return;
|
|
1212
|
+
if (json !== lastJson) {
|
|
1213
|
+
lastJson = json;
|
|
1214
|
+
callback(JSON.parse(json));
|
|
1215
|
+
}
|
|
1216
|
+
} catch (error) {
|
|
1217
|
+
if (active) onError?.(error);
|
|
1218
|
+
} finally {
|
|
1219
|
+
running = false;
|
|
1220
|
+
}
|
|
1221
|
+
if (active) {
|
|
1222
|
+
if (rerun) {
|
|
1223
|
+
rerun = false;
|
|
1224
|
+
void poll();
|
|
1225
|
+
} else timer = setTimeout(poll, 300);
|
|
1226
|
+
}
|
|
1227
|
+
};
|
|
1228
|
+
nudgeCallbacks.add(poll);
|
|
1229
|
+
poll();
|
|
1230
|
+
return () => {
|
|
1231
|
+
active = false;
|
|
1232
|
+
nudgeCallbacks.delete(poll);
|
|
1233
|
+
if (timer !== null) {
|
|
1234
|
+
clearTimeout(timer);
|
|
1235
|
+
timer = null;
|
|
1236
|
+
}
|
|
1237
|
+
};
|
|
1238
|
+
}
|
|
519
1239
|
const handle = {
|
|
520
1240
|
collection: (name, opts) => wrapCollection(name, opts),
|
|
521
1241
|
compact: () => proxy.send("compact"),
|
|
@@ -567,6 +1287,8 @@ async function createNodeDB(dbName, config, passphrase, migrations) {
|
|
|
567
1287
|
const wrapped = {
|
|
568
1288
|
insert: async (doc) => col.insertAsync ? col.insertAsync(doc) : col.insert(doc),
|
|
569
1289
|
insertMany: async (docs) => col.insertManyAsync ? col.insertManyAsync(docs) : col.insertMany(docs),
|
|
1290
|
+
replaceManyWithIds: async (docs, origin = "local") => col.replaceManyWithIdsAsync ? col.replaceManyWithIdsAsync(docs, origin) : col.replaceManyWithIds(docs, origin),
|
|
1291
|
+
deleteManyWithIds: async (ids, origin = "local") => col.deleteManyWithIdsAsync ? col.deleteManyWithIdsAsync(ids, origin) : col.deleteManyWithIds(ids, origin),
|
|
570
1292
|
find: async (filter) => col.findAsync ? col.findAsync(filter ?? null) : col.find(filter ?? null),
|
|
571
1293
|
findOne: async (filter) => col.findOne(filter) ?? null,
|
|
572
1294
|
updateOne: async (filter, update) => col.updateOneAsync ? col.updateOneAsync(filter, update) : col.updateOne(filter, update),
|
|
@@ -592,7 +1314,14 @@ async function createNodeDB(dbName, config, passphrase, migrations) {
|
|
|
592
1314
|
const raw = await col.findNearest(field, vector, topK, filter ?? null);
|
|
593
1315
|
return raw;
|
|
594
1316
|
},
|
|
595
|
-
|
|
1317
|
+
searchText: async (field, query, topK, filter, options) => {
|
|
1318
|
+
return col.searchText(field, query, topK, filter ?? null, options ?? null);
|
|
1319
|
+
},
|
|
1320
|
+
hybridSearch: async (text, vector, topK, filter, options) => {
|
|
1321
|
+
return col.hybridSearch(text.textField, text.text, vector.vectorField, vector.vector, topK, filter ?? null, options ?? null);
|
|
1322
|
+
},
|
|
1323
|
+
subscribe: (filter, callback, onError) => makePoller(async () => col.find(filter ?? null), callback, onError),
|
|
1324
|
+
subscribeAggregate: (pipeline, callback, onError) => makePoller(async () => wrapped.aggregate(pipeline), callback, onError)
|
|
596
1325
|
};
|
|
597
1326
|
return opts ? applySchema(wrapped, opts) : wrapped;
|
|
598
1327
|
}
|
|
@@ -640,6 +1369,8 @@ async function createNativeDB(_dbName, migrations) {
|
|
|
640
1369
|
const wrapped = {
|
|
641
1370
|
insert: async (doc) => native.insert(name, doc),
|
|
642
1371
|
insertMany: async (docs) => native.insertMany(name, docs),
|
|
1372
|
+
replaceManyWithIds: async (docs, origin = "local") => native.replaceManyWithIds(name, docs, origin),
|
|
1373
|
+
deleteManyWithIds: async (ids, origin = "local") => native.deleteManyWithIds(name, ids, origin),
|
|
643
1374
|
find: async (filter) => native.find(name, filter ?? {}),
|
|
644
1375
|
findOne: async (filter) => native.findOne(name, filter ?? {}),
|
|
645
1376
|
updateOne: async (filter, update) => native.updateOne(name, filter, update),
|
|
@@ -671,7 +1402,20 @@ async function createNativeDB(_dbName, migrations) {
|
|
|
671
1402
|
const raw = native.findNearest(name, field, vector, topK, filter ?? null);
|
|
672
1403
|
return raw;
|
|
673
1404
|
},
|
|
674
|
-
|
|
1405
|
+
searchText: async (field, query, topK, filter, options) => {
|
|
1406
|
+
if (!native.searchText) {
|
|
1407
|
+
throw new Error("searchText requires @taladb/react-native \u2265 0.10 \u2014 rebuild the native module");
|
|
1408
|
+
}
|
|
1409
|
+
return native.searchText(name, field, query, topK, filter ?? null, options ?? null);
|
|
1410
|
+
},
|
|
1411
|
+
hybridSearch: async (text, vector, topK, filter, options) => {
|
|
1412
|
+
if (!native.hybridSearch) {
|
|
1413
|
+
throw new Error("hybridSearch requires @taladb/react-native \u2265 0.10 \u2014 rebuild the native module");
|
|
1414
|
+
}
|
|
1415
|
+
return native.hybridSearch(name, text.textField, text.text, vector.vectorField, vector.vector, topK, filter ?? null, options ?? null);
|
|
1416
|
+
},
|
|
1417
|
+
subscribe: (filter, callback, onError) => makePoller(async () => native.find(name, filter ?? {}), callback, onError),
|
|
1418
|
+
subscribeAggregate: (pipeline, callback, onError) => makePoller(async () => native.aggregate(name, pipeline), callback, onError)
|
|
675
1419
|
};
|
|
676
1420
|
return opts ? applySchema(wrapped, opts) : wrapped;
|
|
677
1421
|
}
|
|
@@ -767,9 +1511,20 @@ async function openDB(dbName = "taladb.db", options) {
|
|
|
767
1511
|
}
|
|
768
1512
|
}
|
|
769
1513
|
export {
|
|
1514
|
+
COVERAGE_COLLECTION,
|
|
1515
|
+
CoverageStore,
|
|
770
1516
|
HttpSyncAdapter,
|
|
1517
|
+
REPLICA_REVISION_FIELD,
|
|
1518
|
+
REPLICA_SCOPE_FIELD,
|
|
1519
|
+
ReplicationCoordinator,
|
|
771
1520
|
TalaDbValidationError,
|
|
772
1521
|
applySchema,
|
|
1522
|
+
coverageKey,
|
|
1523
|
+
createRestSource,
|
|
1524
|
+
deriveDocId,
|
|
1525
|
+
isAuthoritative,
|
|
773
1526
|
openDB,
|
|
1527
|
+
progress,
|
|
1528
|
+
rowsApplied,
|
|
774
1529
|
runMigrations
|
|
775
1530
|
};
|