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.js
CHANGED
|
@@ -30,10 +30,21 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
|
|
|
30
30
|
// src/index.ts
|
|
31
31
|
var index_exports = {};
|
|
32
32
|
__export(index_exports, {
|
|
33
|
+
COVERAGE_COLLECTION: () => COVERAGE_COLLECTION,
|
|
34
|
+
CoverageStore: () => CoverageStore,
|
|
33
35
|
HttpSyncAdapter: () => HttpSyncAdapter,
|
|
36
|
+
REPLICA_REVISION_FIELD: () => REPLICA_REVISION_FIELD,
|
|
37
|
+
REPLICA_SCOPE_FIELD: () => REPLICA_SCOPE_FIELD,
|
|
38
|
+
ReplicationCoordinator: () => ReplicationCoordinator,
|
|
34
39
|
TalaDbValidationError: () => TalaDbValidationError,
|
|
35
40
|
applySchema: () => applySchema,
|
|
41
|
+
coverageKey: () => coverageKey,
|
|
42
|
+
createRestSource: () => createRestSource,
|
|
43
|
+
deriveDocId: () => deriveDocId,
|
|
44
|
+
isAuthoritative: () => isAuthoritative,
|
|
36
45
|
openDB: () => openDB,
|
|
46
|
+
progress: () => progress,
|
|
47
|
+
rowsApplied: () => rowsApplied,
|
|
37
48
|
runMigrations: () => runMigrations
|
|
38
49
|
});
|
|
39
50
|
module.exports = __toCommonJS(index_exports);
|
|
@@ -137,7 +148,11 @@ function unsupportedSync(runtime) {
|
|
|
137
148
|
}
|
|
138
149
|
async function readCursor(cursorCol, target) {
|
|
139
150
|
const doc = await cursorCol.findOne({ target });
|
|
140
|
-
return {
|
|
151
|
+
return {
|
|
152
|
+
pushMs: doc?.pushMs ?? 0,
|
|
153
|
+
pullMs: doc?.pullMs ?? 0,
|
|
154
|
+
pullCursor: doc?.pullCursor
|
|
155
|
+
};
|
|
141
156
|
}
|
|
142
157
|
async function writeCursor(cursorCol, target, cursor) {
|
|
143
158
|
const updated = await cursorCol.updateOne({ target }, { $set: { ...cursor } });
|
|
@@ -145,13 +160,19 @@ async function writeCursor(cursorCol, target, cursor) {
|
|
|
145
160
|
await cursorCol.insert({ target, ...cursor });
|
|
146
161
|
}
|
|
147
162
|
}
|
|
163
|
+
function isCursorAdapter(adapter) {
|
|
164
|
+
return typeof adapter.pullWithCursor === "function";
|
|
165
|
+
}
|
|
166
|
+
var MAX_PULL_PAGES = 1e4;
|
|
148
167
|
async function runSync(handle, adapter, options, syncSchemas = {}) {
|
|
149
168
|
const direction = options.direction ?? "both";
|
|
150
169
|
const target = options.target ?? "default";
|
|
151
170
|
const doPush = direction === "push" || direction === "both";
|
|
152
171
|
const doPull = direction === "pull" || direction === "both";
|
|
153
|
-
if (doPull && !adapter.pull) {
|
|
154
|
-
throw new Error(
|
|
172
|
+
if (doPull && !adapter.pull && !isCursorAdapter(adapter)) {
|
|
173
|
+
throw new Error(
|
|
174
|
+
`sync direction '${direction}' requires adapter.pull() or adapter.pullWithCursor()`
|
|
175
|
+
);
|
|
155
176
|
}
|
|
156
177
|
if (doPush && !adapter.push) {
|
|
157
178
|
throw new Error(`sync direction '${direction}' requires adapter.push()`);
|
|
@@ -168,17 +189,35 @@ async function runSync(handle, adapter, options, syncSchemas = {}) {
|
|
|
168
189
|
let pulled = 0;
|
|
169
190
|
let skipped = 0;
|
|
170
191
|
let quarantined = 0;
|
|
192
|
+
let pullCursor = cursor.pullCursor;
|
|
193
|
+
async function importOne(changeset) {
|
|
194
|
+
if (!changeset || changeset === "[]") return;
|
|
195
|
+
if (useValidated) {
|
|
196
|
+
const report = await handle.importChangesValidated(changeset, JSON.stringify(scopedSchemas));
|
|
197
|
+
pulled += report.applied;
|
|
198
|
+
skipped += report.skipped;
|
|
199
|
+
quarantined += report.quarantined;
|
|
200
|
+
} else {
|
|
201
|
+
pulled += await handle.importChanges(changeset);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
171
204
|
if (doPull) {
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
const
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
205
|
+
if (isCursorAdapter(adapter)) {
|
|
206
|
+
let pages = 0;
|
|
207
|
+
for (; ; ) {
|
|
208
|
+
const result = await adapter.pullWithCursor(pullCursor ?? null);
|
|
209
|
+
await importOne(result.changeset);
|
|
210
|
+
pullCursor = result.cursor;
|
|
211
|
+
await writeCursor(cursorCol, target, { ...cursor, pullCursor });
|
|
212
|
+
if (!result.hasMore) break;
|
|
213
|
+
if (++pages >= MAX_PULL_PAGES) {
|
|
214
|
+
throw new Error(
|
|
215
|
+
`sync: origin returned hasMore after ${MAX_PULL_PAGES} pages for target '${target}' \u2014 it is probably not advancing its cursor.`
|
|
216
|
+
);
|
|
217
|
+
}
|
|
181
218
|
}
|
|
219
|
+
} else {
|
|
220
|
+
await importOne(await adapter.pull(0));
|
|
182
221
|
}
|
|
183
222
|
}
|
|
184
223
|
let pushed = 0;
|
|
@@ -188,7 +227,8 @@ async function runSync(handle, adapter, options, syncSchemas = {}) {
|
|
|
188
227
|
}
|
|
189
228
|
await writeCursor(cursorCol, target, {
|
|
190
229
|
pushMs: cursor.pushMs,
|
|
191
|
-
pullMs: cursor.pullMs
|
|
230
|
+
pullMs: cursor.pullMs,
|
|
231
|
+
...pullCursor !== void 0 ? { pullCursor } : {}
|
|
192
232
|
});
|
|
193
233
|
return { pushed, pulled, skipped, quarantined, cursor: 0 };
|
|
194
234
|
}
|
|
@@ -229,6 +269,478 @@ var HttpSyncAdapter = class {
|
|
|
229
269
|
}
|
|
230
270
|
};
|
|
231
271
|
|
|
272
|
+
// src/derive-id.ts
|
|
273
|
+
var FNV1A128_OFFSET_BASIS = 0x6c62272e07bb014262b821756295c58dn;
|
|
274
|
+
var FNV1A128_PRIME = 0x0000000001000000000000000000013bn;
|
|
275
|
+
var MASK_128 = (1n << 128n) - 1n;
|
|
276
|
+
var CROCKFORD = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
|
|
277
|
+
var UTF8 = new TextEncoder();
|
|
278
|
+
function encodeUlid(value) {
|
|
279
|
+
let out = "";
|
|
280
|
+
for (let i = 25; i >= 0; i--) {
|
|
281
|
+
out += CROCKFORD[Number(value >> BigInt(i * 5) & 31n)];
|
|
282
|
+
}
|
|
283
|
+
return out;
|
|
284
|
+
}
|
|
285
|
+
function deriveDocId(collection, key) {
|
|
286
|
+
const bytes = [...UTF8.encode(collection), 0, ...UTF8.encode(key)];
|
|
287
|
+
let hash = FNV1A128_OFFSET_BASIS;
|
|
288
|
+
for (const byte of bytes) {
|
|
289
|
+
hash ^= BigInt(byte);
|
|
290
|
+
hash = hash * FNV1A128_PRIME & MASK_128;
|
|
291
|
+
}
|
|
292
|
+
return encodeUlid(hash);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// src/replication/coverage.ts
|
|
296
|
+
var COVERAGE_COLLECTION = "__taladb_replica";
|
|
297
|
+
function coverageKey(key) {
|
|
298
|
+
return [
|
|
299
|
+
key.origin,
|
|
300
|
+
key.collection,
|
|
301
|
+
key.scope,
|
|
302
|
+
`p${key.projectionVersion}`,
|
|
303
|
+
`s${key.schemaVersion}`
|
|
304
|
+
].map(encodeURIComponent).join("|");
|
|
305
|
+
}
|
|
306
|
+
var CoverageStore = class {
|
|
307
|
+
constructor(db) {
|
|
308
|
+
this.col = db.collection(COVERAGE_COLLECTION);
|
|
309
|
+
}
|
|
310
|
+
async read(key) {
|
|
311
|
+
const doc = await this.col.findOne({ key: coverageKey(key) });
|
|
312
|
+
if (!doc?.state) return { status: "empty" };
|
|
313
|
+
try {
|
|
314
|
+
return JSON.parse(doc.state);
|
|
315
|
+
} catch {
|
|
316
|
+
return { status: "empty" };
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
async write(key, state) {
|
|
320
|
+
const k = coverageKey(key);
|
|
321
|
+
const state_json = JSON.stringify(state);
|
|
322
|
+
await this.col.replaceManyWithIds(
|
|
323
|
+
[{ _id: deriveDocId(COVERAGE_COLLECTION, k), key: k, state: state_json }],
|
|
324
|
+
"local"
|
|
325
|
+
);
|
|
326
|
+
}
|
|
327
|
+
/** Drop a scope's coverage, forcing a fresh bootstrap on next use. */
|
|
328
|
+
async clear(key) {
|
|
329
|
+
const k = coverageKey(key);
|
|
330
|
+
await this.col.deleteManyWithIds([deriveDocId(COVERAGE_COLLECTION, k)], "local");
|
|
331
|
+
}
|
|
332
|
+
};
|
|
333
|
+
function isAuthoritative(state) {
|
|
334
|
+
return state.status === "complete";
|
|
335
|
+
}
|
|
336
|
+
function rowsApplied(state) {
|
|
337
|
+
switch (state.status) {
|
|
338
|
+
case "hydrating":
|
|
339
|
+
case "complete":
|
|
340
|
+
case "best-effort":
|
|
341
|
+
return state.rowsApplied;
|
|
342
|
+
default:
|
|
343
|
+
return 0;
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
function progress(state) {
|
|
347
|
+
if (state.status === "complete") return 1;
|
|
348
|
+
if (state.status !== "hydrating" || !state.total) return void 0;
|
|
349
|
+
return Math.min(1, state.rowsApplied / state.total);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// src/replication/coordinator.ts
|
|
353
|
+
var DEFAULT_PAGE_SIZE = 500;
|
|
354
|
+
var defaultYield = () => new Promise((resolve) => setTimeout(resolve, 0));
|
|
355
|
+
var MAX_BOOTSTRAP_PAGES = 1e5;
|
|
356
|
+
var inflightByDatabase = /* @__PURE__ */ new WeakMap();
|
|
357
|
+
var REPLICA_SCOPE_FIELD = "_replica_scope";
|
|
358
|
+
var REPLICA_REVISION_FIELD = "_remote_rev";
|
|
359
|
+
var ReplicationCoordinator = class {
|
|
360
|
+
constructor(db, source, options = {}) {
|
|
361
|
+
this.db = db;
|
|
362
|
+
this.source = source;
|
|
363
|
+
this.coverage = new CoverageStore(db);
|
|
364
|
+
this.key = {
|
|
365
|
+
origin: source.origin,
|
|
366
|
+
collection: source.collection,
|
|
367
|
+
scope: source.scope,
|
|
368
|
+
projectionVersion: source.projectionVersion,
|
|
369
|
+
schemaVersion: source.schemaVersion
|
|
370
|
+
};
|
|
371
|
+
this.pageSize = options.pageSize ?? DEFAULT_PAGE_SIZE;
|
|
372
|
+
this.yieldFn = options.yieldFn ?? defaultYield;
|
|
373
|
+
this.onProgress = options.onProgress;
|
|
374
|
+
this.collectionOptions = options.collectionOptions;
|
|
375
|
+
let shared = inflightByDatabase.get(db);
|
|
376
|
+
if (!shared) {
|
|
377
|
+
shared = /* @__PURE__ */ new Map();
|
|
378
|
+
inflightByDatabase.set(db, shared);
|
|
379
|
+
}
|
|
380
|
+
this.inflight = shared;
|
|
381
|
+
}
|
|
382
|
+
get replicaScope() {
|
|
383
|
+
return coverageKey(this.key);
|
|
384
|
+
}
|
|
385
|
+
get identityNamespace() {
|
|
386
|
+
return `${this.source.origin}\0${this.source.scope}\0${this.source.collection}`;
|
|
387
|
+
}
|
|
388
|
+
getCoverage() {
|
|
389
|
+
return this.coverage.read(this.key);
|
|
390
|
+
}
|
|
391
|
+
/** Whether a purely local read is authorized right now. */
|
|
392
|
+
async isReady() {
|
|
393
|
+
return isAuthoritative(await this.getCoverage());
|
|
394
|
+
}
|
|
395
|
+
/** Dedup by intent: identical concurrent work joins rather than duplicating. */
|
|
396
|
+
dedup(key, run) {
|
|
397
|
+
const existing = this.inflight.get(key);
|
|
398
|
+
if (existing) return existing;
|
|
399
|
+
const pass = run().finally(() => this.inflight.delete(key));
|
|
400
|
+
this.inflight.set(key, pass);
|
|
401
|
+
return pass;
|
|
402
|
+
}
|
|
403
|
+
/**
|
|
404
|
+
* Write a batch of remote rows into the local collection.
|
|
405
|
+
*
|
|
406
|
+
* One commit for the whole batch, ids derived from the origin's primary key, and
|
|
407
|
+
* `origin: 'remote'` so the rows can never replicate back out at the origin they
|
|
408
|
+
* came from. This is the *only* write path in the coordinator — bootstrap, delta
|
|
409
|
+
* and bridge all funnel through it, which is precisely why they converge instead
|
|
410
|
+
* of conflicting.
|
|
411
|
+
*/
|
|
412
|
+
async applyRows(rows) {
|
|
413
|
+
if (rows.length === 0) return [];
|
|
414
|
+
const col = this.db.collection(this.source.collection, this.collectionOptions);
|
|
415
|
+
const docs = rows.map(
|
|
416
|
+
(row) => {
|
|
417
|
+
const revision = this.source.revisionOf(row);
|
|
418
|
+
return {
|
|
419
|
+
...this.source.mapRow(row),
|
|
420
|
+
_id: deriveDocId(this.identityNamespace, String(this.source.keyOf(row))),
|
|
421
|
+
[REPLICA_SCOPE_FIELD]: this.replicaScope,
|
|
422
|
+
[REPLICA_REVISION_FIELD]: revision
|
|
423
|
+
};
|
|
424
|
+
}
|
|
425
|
+
);
|
|
426
|
+
await col.replaceManyWithIds(docs, "remote");
|
|
427
|
+
return docs.map((doc) => doc._id);
|
|
428
|
+
}
|
|
429
|
+
/**
|
|
430
|
+
* Hydrate the scope: walk the origin page by page until the whole collection is
|
|
431
|
+
* local, then mark it complete.
|
|
432
|
+
*
|
|
433
|
+
* Resumable and idempotent. If the walk is interrupted — a reload, a crash, a
|
|
434
|
+
* dead network — the next call picks up from the last committed page, and
|
|
435
|
+
* re-applying a page it already wrote is a no-op because the ids are derived.
|
|
436
|
+
*/
|
|
437
|
+
hydrate() {
|
|
438
|
+
return this.dedup(`${this.replicaScope}:hydrate`, () => this.runHydrate());
|
|
439
|
+
}
|
|
440
|
+
async runHydrate() {
|
|
441
|
+
let state = await this.coverage.read(this.key);
|
|
442
|
+
if (state.status === "complete") return state;
|
|
443
|
+
let page = null;
|
|
444
|
+
let snapshot = null;
|
|
445
|
+
let rowsApplied2 = 0;
|
|
446
|
+
let total;
|
|
447
|
+
let deltaCursor;
|
|
448
|
+
if (state.status === "hydrating") {
|
|
449
|
+
page = state.nextPage;
|
|
450
|
+
snapshot = state.snapshot;
|
|
451
|
+
rowsApplied2 = state.rowsApplied;
|
|
452
|
+
total = state.total;
|
|
453
|
+
deltaCursor = state.deltaCursor;
|
|
454
|
+
} else if (state.status === "error" && state.snapshot) {
|
|
455
|
+
page = state.resumeFrom;
|
|
456
|
+
snapshot = state.snapshot;
|
|
457
|
+
rowsApplied2 = state.rowsApplied ?? 0;
|
|
458
|
+
total = state.total;
|
|
459
|
+
deltaCursor = state.deltaCursor;
|
|
460
|
+
}
|
|
461
|
+
let snapshotSupported = true;
|
|
462
|
+
let pages = 0;
|
|
463
|
+
try {
|
|
464
|
+
for (; ; ) {
|
|
465
|
+
const result = await this.source.bootstrap({ page, snapshot, limit: this.pageSize });
|
|
466
|
+
if (snapshot !== null && result.snapshot !== void 0 && result.snapshot !== snapshot) {
|
|
467
|
+
throw new Error(
|
|
468
|
+
`replication: origin '${this.source.origin}' changed snapshot token mid-walk`
|
|
469
|
+
);
|
|
470
|
+
}
|
|
471
|
+
if (snapshot === null && result.snapshot) snapshot = result.snapshot;
|
|
472
|
+
if (result.snapshot === void 0 && page === null) snapshotSupported = false;
|
|
473
|
+
if (result.deltaCursor && !deltaCursor) deltaCursor = result.deltaCursor;
|
|
474
|
+
if (result.total !== void 0) total = result.total;
|
|
475
|
+
rowsApplied2 += (await this.applyRows(result.rows)).length;
|
|
476
|
+
page = result.nextPage;
|
|
477
|
+
if (page !== null) {
|
|
478
|
+
const next = {
|
|
479
|
+
status: "hydrating",
|
|
480
|
+
snapshot: snapshot ?? "",
|
|
481
|
+
nextPage: page,
|
|
482
|
+
rowsApplied: rowsApplied2,
|
|
483
|
+
...deltaCursor !== void 0 ? { deltaCursor } : {},
|
|
484
|
+
...total !== void 0 ? { total } : {}
|
|
485
|
+
};
|
|
486
|
+
await this.coverage.write(this.key, next);
|
|
487
|
+
this.onProgress?.(next);
|
|
488
|
+
if (++pages >= MAX_BOOTSTRAP_PAGES) {
|
|
489
|
+
throw new Error(
|
|
490
|
+
`replication: origin '${this.source.origin}' offered more than ${MAX_BOOTSTRAP_PAGES} bootstrap pages for '${this.source.collection}' \u2014 it is probably not advancing nextPage.`
|
|
491
|
+
);
|
|
492
|
+
}
|
|
493
|
+
await this.yieldFn();
|
|
494
|
+
continue;
|
|
495
|
+
}
|
|
496
|
+
if (snapshotSupported && this.source.delta && deltaCursor === void 0) {
|
|
497
|
+
throw new Error(
|
|
498
|
+
`replication: origin '${this.source.origin}' supports delta refresh but did not issue deltaCursor on the first bootstrap page`
|
|
499
|
+
);
|
|
500
|
+
}
|
|
501
|
+
state = snapshotSupported ? {
|
|
502
|
+
status: "complete",
|
|
503
|
+
cursor: deltaCursor ?? "",
|
|
504
|
+
completedAt: Date.now(),
|
|
505
|
+
rowsApplied: rowsApplied2,
|
|
506
|
+
...total !== void 0 ? { total } : {}
|
|
507
|
+
} : {
|
|
508
|
+
// Every row the origin offered was applied — but without a snapshot
|
|
509
|
+
// we cannot prove we saw a consistent view of it, so we must not
|
|
510
|
+
// claim completeness. Reads keep going to the network.
|
|
511
|
+
status: "best-effort",
|
|
512
|
+
cursor: deltaCursor ?? "",
|
|
513
|
+
reason: "the origin did not return a snapshot token, so a row that moved between pages during the walk may have been missed",
|
|
514
|
+
rowsApplied: rowsApplied2,
|
|
515
|
+
...total !== void 0 ? { total } : {}
|
|
516
|
+
};
|
|
517
|
+
await this.coverage.write(this.key, state);
|
|
518
|
+
this.onProgress?.(state);
|
|
519
|
+
return state;
|
|
520
|
+
}
|
|
521
|
+
} catch (error) {
|
|
522
|
+
const failed = {
|
|
523
|
+
status: "error",
|
|
524
|
+
resumeFrom: page ?? 0,
|
|
525
|
+
...snapshot ? { snapshot } : {},
|
|
526
|
+
...deltaCursor !== void 0 ? { deltaCursor } : {},
|
|
527
|
+
rowsApplied: rowsApplied2,
|
|
528
|
+
...total !== void 0 ? { total } : {},
|
|
529
|
+
error: error instanceof Error ? error.message : String(error)
|
|
530
|
+
};
|
|
531
|
+
await this.coverage.write(this.key, failed);
|
|
532
|
+
this.onProgress?.(failed);
|
|
533
|
+
throw error;
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
/**
|
|
537
|
+
* Apply incremental changes since the stored cursor.
|
|
538
|
+
*
|
|
539
|
+
* Deletions are applied by mapping the origin's primary keys through the same
|
|
540
|
+
* `deriveDocId`, and are written with `origin: 'remote'` so they leave no
|
|
541
|
+
* tombstone — the origin already knows it deleted these, and a tombstone would
|
|
542
|
+
* push its own deletion back at it.
|
|
543
|
+
*/
|
|
544
|
+
refresh() {
|
|
545
|
+
return this.dedup(`${this.replicaScope}:refresh`, () => this.runRefresh());
|
|
546
|
+
}
|
|
547
|
+
async runRefresh() {
|
|
548
|
+
const state = await this.coverage.read(this.key);
|
|
549
|
+
if (state.status !== "complete") return state;
|
|
550
|
+
if (!this.source.delta) return state;
|
|
551
|
+
const col = this.db.collection(this.source.collection);
|
|
552
|
+
let cursor = state.cursor;
|
|
553
|
+
let rowsApplied2 = state.rowsApplied;
|
|
554
|
+
for (; ; ) {
|
|
555
|
+
const page = await this.source.delta(cursor);
|
|
556
|
+
rowsApplied2 += (await this.applyRows(page.changed)).length;
|
|
557
|
+
if (page.deleted.length > 0) {
|
|
558
|
+
const ids = page.deleted.map(
|
|
559
|
+
(k) => deriveDocId(this.identityNamespace, String(k))
|
|
560
|
+
);
|
|
561
|
+
await col.deleteManyWithIds(ids, "remote");
|
|
562
|
+
}
|
|
563
|
+
cursor = page.cursor;
|
|
564
|
+
const next = { ...state, cursor, rowsApplied: rowsApplied2 };
|
|
565
|
+
await this.coverage.write(this.key, next);
|
|
566
|
+
if (!page.hasMore) {
|
|
567
|
+
this.onProgress?.(next);
|
|
568
|
+
return next;
|
|
569
|
+
}
|
|
570
|
+
await this.yieldFn();
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
/**
|
|
574
|
+
* Cold-start bridge: fetch exactly the rows one query needs, right now.
|
|
575
|
+
*
|
|
576
|
+
* Needed because a SPA or React Native app has no server render to paint behind
|
|
577
|
+
* while the replica fills. The rows land in the same collection under the same
|
|
578
|
+
* derived ids as the walk's, so this is not a cache — it is the replica, arriving
|
|
579
|
+
* early.
|
|
580
|
+
*
|
|
581
|
+
* **Does not advance coverage.** These rows did not come from the bootstrap
|
|
582
|
+
* snapshot and prove nothing about completeness; treating them as progress would
|
|
583
|
+
* let a page-1 fetch masquerade as a hydrated catalog.
|
|
584
|
+
*/
|
|
585
|
+
bridge(query) {
|
|
586
|
+
if (!this.source.fetchQuery) return Promise.resolve({ count: 0, ids: [] });
|
|
587
|
+
const key = `bridge:${coverageKey(this.key)}:${JSON.stringify(query)}`;
|
|
588
|
+
return this.dedup(key, async () => {
|
|
589
|
+
const rows = await this.source.fetchQuery(query);
|
|
590
|
+
const ids = await this.applyRows(rows);
|
|
591
|
+
return { count: ids.length, ids };
|
|
592
|
+
});
|
|
593
|
+
}
|
|
594
|
+
/** Drop coverage and force a fresh bootstrap. Local rows are left alone. */
|
|
595
|
+
async reset() {
|
|
596
|
+
await this.coverage.clear(this.key);
|
|
597
|
+
}
|
|
598
|
+
};
|
|
599
|
+
|
|
600
|
+
// src/replication/rest.ts
|
|
601
|
+
function parseRows(body, endpoint) {
|
|
602
|
+
if (Array.isArray(body)) return body;
|
|
603
|
+
if (body && typeof body === "object") {
|
|
604
|
+
const env = body;
|
|
605
|
+
for (const field of ["data", "items", "rows"]) {
|
|
606
|
+
const value = env[field];
|
|
607
|
+
if (Array.isArray(value)) return value;
|
|
608
|
+
}
|
|
609
|
+
throw new Error(
|
|
610
|
+
`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.`
|
|
611
|
+
);
|
|
612
|
+
}
|
|
613
|
+
throw new Error(
|
|
614
|
+
`taladb: expected an array or object from ${endpoint}, got ${typeof body}.`
|
|
615
|
+
);
|
|
616
|
+
}
|
|
617
|
+
function pick(body, names) {
|
|
618
|
+
if (!body || typeof body !== "object") return void 0;
|
|
619
|
+
const rec = body;
|
|
620
|
+
for (const n of names) {
|
|
621
|
+
if (rec[n] !== void 0) return rec[n];
|
|
622
|
+
const meta = rec.meta;
|
|
623
|
+
if (meta && meta[n] !== void 0) return meta[n];
|
|
624
|
+
}
|
|
625
|
+
return void 0;
|
|
626
|
+
}
|
|
627
|
+
function createRestSource(options) {
|
|
628
|
+
const {
|
|
629
|
+
endpoint,
|
|
630
|
+
collection,
|
|
631
|
+
origin = endpoint,
|
|
632
|
+
scope = "global",
|
|
633
|
+
projectionVersion = 1,
|
|
634
|
+
schemaVersion = 1,
|
|
635
|
+
key = "id",
|
|
636
|
+
revision = "rev",
|
|
637
|
+
mapRow,
|
|
638
|
+
getAuth,
|
|
639
|
+
paths,
|
|
640
|
+
toParams,
|
|
641
|
+
parse,
|
|
642
|
+
pagination = "page"
|
|
643
|
+
} = options;
|
|
644
|
+
const doFetch = options.fetch ?? globalThis.fetch;
|
|
645
|
+
async function get(path, params) {
|
|
646
|
+
const url = new URL(path, globalThis.location?.origin ?? "http://localhost");
|
|
647
|
+
for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v);
|
|
648
|
+
const headers = getAuth ? await getAuth() : void 0;
|
|
649
|
+
const response = await doFetch(url.href, { headers });
|
|
650
|
+
if (!response.ok) {
|
|
651
|
+
throw new Error(
|
|
652
|
+
`taladb: ${path} responded ${response.status} ${response.statusText}`
|
|
653
|
+
);
|
|
654
|
+
}
|
|
655
|
+
return response.json();
|
|
656
|
+
}
|
|
657
|
+
const rowsFrom = (body) => parse ? parse(body) : parseRows(body, endpoint);
|
|
658
|
+
return {
|
|
659
|
+
origin,
|
|
660
|
+
collection,
|
|
661
|
+
scope,
|
|
662
|
+
projectionVersion,
|
|
663
|
+
schemaVersion,
|
|
664
|
+
keyOf: (row) => {
|
|
665
|
+
const value = row[key];
|
|
666
|
+
if (value === void 0 || value === null) {
|
|
667
|
+
throw new Error(
|
|
668
|
+
`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.`
|
|
669
|
+
);
|
|
670
|
+
}
|
|
671
|
+
return String(value);
|
|
672
|
+
},
|
|
673
|
+
revisionOf: (row) => {
|
|
674
|
+
const value = typeof revision === "function" ? revision(row) : row[revision];
|
|
675
|
+
if (value === void 0 || value === null) {
|
|
676
|
+
throw new Error(
|
|
677
|
+
`taladb: row from ${endpoint} has no authoritative revision. Pass { revision } to name the monotonic revision field.`
|
|
678
|
+
);
|
|
679
|
+
}
|
|
680
|
+
const n = Number(value);
|
|
681
|
+
if (!Number.isSafeInteger(n)) {
|
|
682
|
+
throw new Error(`taladb: authoritative revision must be a safe integer, got ${String(value)}`);
|
|
683
|
+
}
|
|
684
|
+
return n;
|
|
685
|
+
},
|
|
686
|
+
mapRow: (row) => {
|
|
687
|
+
if (mapRow) return mapRow(row);
|
|
688
|
+
const { _id, ...rest } = row;
|
|
689
|
+
void _id;
|
|
690
|
+
return rest;
|
|
691
|
+
},
|
|
692
|
+
bootstrap: async (request) => {
|
|
693
|
+
const params = { limit: String(request.limit) };
|
|
694
|
+
if (request.page !== null) params.page = String(request.page);
|
|
695
|
+
if (request.snapshot !== null) params.snapshot = request.snapshot;
|
|
696
|
+
const body = await get(endpoint + (paths?.bootstrap ?? ""), params);
|
|
697
|
+
const rows = rowsFrom(body);
|
|
698
|
+
const nextPage = pick(body, ["nextPage", "next_page", "next"]);
|
|
699
|
+
const snapshot = pick(body, ["snapshot"]);
|
|
700
|
+
const deltaCursor = pick(body, ["deltaCursor", "delta_cursor", "cursor"]);
|
|
701
|
+
const total = pick(body, ["total", "totalCount", "count"]);
|
|
702
|
+
return {
|
|
703
|
+
rows,
|
|
704
|
+
// An origin that reports no explicit `nextPage` is treated as exhausted
|
|
705
|
+
// once it returns a short page — the conventional REST behavior.
|
|
706
|
+
nextPage: nextPage !== void 0 ? nextPage : rows.length < request.limit ? null : pagination === "offset" ? Number(request.page ?? 0) + request.limit : Number(request.page ?? 1) + 1,
|
|
707
|
+
...snapshot !== void 0 ? { snapshot } : {},
|
|
708
|
+
...deltaCursor !== void 0 ? { deltaCursor } : {},
|
|
709
|
+
...total !== void 0 ? { total } : {}
|
|
710
|
+
};
|
|
711
|
+
},
|
|
712
|
+
...options.delta === true || paths?.delta ? { delta: async (cursor) => {
|
|
713
|
+
const body = await get(endpoint + (paths?.delta ?? ""), { since: cursor });
|
|
714
|
+
const changed = rowsFrom(body);
|
|
715
|
+
const deleted = pick(body, ["deleted", "removed"]) ?? [];
|
|
716
|
+
const next = pick(body, ["cursor", "now", "nextCursor"]);
|
|
717
|
+
return {
|
|
718
|
+
changed,
|
|
719
|
+
deleted: deleted.map(String),
|
|
720
|
+
cursor: next ?? cursor,
|
|
721
|
+
hasMore: Boolean(pick(body, ["hasMore", "has_more"]))
|
|
722
|
+
};
|
|
723
|
+
} } : {},
|
|
724
|
+
fetchQuery: async (query) => {
|
|
725
|
+
if (!toParams && Object.values(query.filter ?? {}).some((v) => typeof v === "object" && v !== null)) {
|
|
726
|
+
throw new Error(
|
|
727
|
+
"taladb: bridge filters with operators require RestSourceOptions.toParams; the default translator only supports scalar equality fields."
|
|
728
|
+
);
|
|
729
|
+
}
|
|
730
|
+
const sortEntry = Object.entries(query.sort ?? {})[0];
|
|
731
|
+
const params = toParams ? toParams(query) : {
|
|
732
|
+
...query.page !== void 0 ? { page: String(query.page) } : {},
|
|
733
|
+
...query.limit !== void 0 ? { limit: String(query.limit) } : {},
|
|
734
|
+
...Object.fromEntries(
|
|
735
|
+
Object.entries(query.filter ?? {}).map(([k, v]) => [k, String(v)])
|
|
736
|
+
),
|
|
737
|
+
...sortEntry ? { sort: sortEntry[0], order: sortEntry[1] === -1 ? "desc" : "asc" } : {}
|
|
738
|
+
};
|
|
739
|
+
return rowsFrom(await get(endpoint, params));
|
|
740
|
+
}
|
|
741
|
+
};
|
|
742
|
+
}
|
|
743
|
+
|
|
232
744
|
// src/index.ts
|
|
233
745
|
var import_meta = {};
|
|
234
746
|
var TalaDbValidationError = class extends Error {
|
|
@@ -256,18 +768,52 @@ function deepEqual(a, b) {
|
|
|
256
768
|
);
|
|
257
769
|
}
|
|
258
770
|
function applySchema(col, options) {
|
|
259
|
-
const {
|
|
771
|
+
const {
|
|
772
|
+
schema,
|
|
773
|
+
validateOnRead = false,
|
|
774
|
+
migrateDocument,
|
|
775
|
+
downgradeDocument,
|
|
776
|
+
syncSchema,
|
|
777
|
+
persistMigrations = false,
|
|
778
|
+
allowFieldRemoval = false,
|
|
779
|
+
retiredFields = []
|
|
780
|
+
} = options;
|
|
260
781
|
const targetVersion = syncSchema?.version ?? 0;
|
|
261
782
|
if (migrateDocument && targetVersion < 1) {
|
|
262
783
|
throw new Error("CollectionOptions.migrateDocument requires syncSchema.version (the migration target)");
|
|
263
784
|
}
|
|
785
|
+
if (downgradeDocument && targetVersion < 1) {
|
|
786
|
+
throw new Error("CollectionOptions.downgradeDocument requires syncSchema.version (the shape this build reads)");
|
|
787
|
+
}
|
|
264
788
|
if (syncSchema && targetVersion < 1 && (syncSchema.renames || syncSchema.defaults)) {
|
|
265
789
|
throw new Error(
|
|
266
790
|
"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"
|
|
267
791
|
);
|
|
268
792
|
}
|
|
269
793
|
const stampVersion = targetVersion > 0;
|
|
270
|
-
if (!schema && !migrateDocument && !stampVersion) return col;
|
|
794
|
+
if (!schema && !migrateDocument && !downgradeDocument && !stampVersion) return col;
|
|
795
|
+
const retired = new Set(retiredFields);
|
|
796
|
+
const engineOwned = /* @__PURE__ */ new Set([
|
|
797
|
+
"_id",
|
|
798
|
+
"_v",
|
|
799
|
+
"_changed_at",
|
|
800
|
+
"_remote",
|
|
801
|
+
"_remote_rev",
|
|
802
|
+
"_replica_scope"
|
|
803
|
+
]);
|
|
804
|
+
const downcastViews = /* @__PURE__ */ new WeakSet();
|
|
805
|
+
function preserveFields(original, next, preserveUnknown, preserveVersion = true) {
|
|
806
|
+
let out = null;
|
|
807
|
+
for (const k of Object.keys(original)) {
|
|
808
|
+
const ownedField = engineOwned.has(k) && (k !== "_v" || preserveVersion);
|
|
809
|
+
const mustRestore = ownedField || preserveUnknown && !retired.has(k) && !(k in next);
|
|
810
|
+
if (!mustRestore) continue;
|
|
811
|
+
if (!ownedField && k in next) continue;
|
|
812
|
+
out ?? (out = { ...next });
|
|
813
|
+
out[k] = original[k];
|
|
814
|
+
}
|
|
815
|
+
return out ?? next;
|
|
816
|
+
}
|
|
271
817
|
function parseWrite(doc, label) {
|
|
272
818
|
try {
|
|
273
819
|
return schema.parse(doc);
|
|
@@ -275,10 +821,51 @@ function applySchema(col, options) {
|
|
|
275
821
|
throw new TalaDbValidationError(err, label);
|
|
276
822
|
}
|
|
277
823
|
}
|
|
824
|
+
function assertWritableDocument(doc, label) {
|
|
825
|
+
if (doc && typeof doc === "object" && downcastViews.has(doc)) {
|
|
826
|
+
throw new Error(`${label}: a downgradeDocument result is a read-only compatibility view`);
|
|
827
|
+
}
|
|
828
|
+
const version = doc?._v;
|
|
829
|
+
if (targetVersion > 0 && typeof version === "number" && version > targetVersion) {
|
|
830
|
+
throw new Error(
|
|
831
|
+
`${label}: this client supports schema v${targetVersion}, but the document is v${version}`
|
|
832
|
+
);
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
function writableFilter(filter) {
|
|
836
|
+
if (targetVersion < 1) return filter;
|
|
837
|
+
return {
|
|
838
|
+
$and: [
|
|
839
|
+
filter,
|
|
840
|
+
{
|
|
841
|
+
$or: [
|
|
842
|
+
{ _v: { $exists: false } },
|
|
843
|
+
{ _v: { $lte: targetVersion } }
|
|
844
|
+
]
|
|
845
|
+
}
|
|
846
|
+
]
|
|
847
|
+
};
|
|
848
|
+
}
|
|
849
|
+
function assertSafeUpdate(update) {
|
|
850
|
+
const record = update;
|
|
851
|
+
for (const op of ["$set", "$unset", "$inc", "$push", "$pull"]) {
|
|
852
|
+
const fields = record[op];
|
|
853
|
+
if (!fields) continue;
|
|
854
|
+
for (const field of Object.keys(fields)) {
|
|
855
|
+
if (engineOwned.has(field)) {
|
|
856
|
+
throw new Error(`update cannot modify engine-owned field '${field}'`);
|
|
857
|
+
}
|
|
858
|
+
}
|
|
859
|
+
}
|
|
860
|
+
}
|
|
278
861
|
function stamp(doc) {
|
|
279
862
|
if (!stampVersion || doc._v !== void 0) return doc;
|
|
280
863
|
return { ...doc, _v: targetVersion };
|
|
281
864
|
}
|
|
865
|
+
function stampDoc(doc) {
|
|
866
|
+
if (!stampVersion || doc._v !== void 0) return doc;
|
|
867
|
+
return { ...doc, _v: targetVersion };
|
|
868
|
+
}
|
|
282
869
|
function diffUpdate(original, migrated) {
|
|
283
870
|
const $set = {};
|
|
284
871
|
const $unset = {};
|
|
@@ -286,85 +873,179 @@ function applySchema(col, options) {
|
|
|
286
873
|
if (k === "_id") continue;
|
|
287
874
|
if (!deepEqual(migrated[k], original[k])) $set[k] = migrated[k];
|
|
288
875
|
}
|
|
289
|
-
|
|
290
|
-
|
|
876
|
+
if (allowFieldRemoval || retired.size > 0) {
|
|
877
|
+
for (const k of Object.keys(original)) {
|
|
878
|
+
if (k !== "_id" && !(k in migrated) && (allowFieldRemoval || retired.has(k))) $unset[k] = true;
|
|
879
|
+
}
|
|
291
880
|
}
|
|
292
881
|
const update = {};
|
|
293
882
|
if (Object.keys($set).length) update.$set = $set;
|
|
294
883
|
if (Object.keys($unset).length) update.$unset = $unset;
|
|
295
884
|
return Object.keys(update).length ? update : null;
|
|
296
885
|
}
|
|
297
|
-
function
|
|
298
|
-
if (!migrateDocument) return doc;
|
|
886
|
+
function normalizeRead(doc) {
|
|
299
887
|
const fromVersion = typeof doc._v === "number" ? doc._v : 0;
|
|
300
|
-
if (fromVersion
|
|
301
|
-
|
|
888
|
+
if (migrateDocument && fromVersion < targetVersion) {
|
|
889
|
+
const up = { ...migrateDocument(doc, fromVersion), _v: targetVersion };
|
|
890
|
+
return {
|
|
891
|
+
// The migration owns the version transition, while every other
|
|
892
|
+
// engine-owned field continues to come from the stored document.
|
|
893
|
+
value: allowFieldRemoval ? preserveFields(doc, up, false, false) : preserveFields(doc, up, true, false),
|
|
894
|
+
persistable: true
|
|
895
|
+
};
|
|
896
|
+
}
|
|
897
|
+
if (downgradeDocument && fromVersion > targetVersion) {
|
|
898
|
+
const projected = {
|
|
899
|
+
...downgradeDocument(doc, fromVersion),
|
|
900
|
+
...doc._id !== void 0 ? { _id: doc._id } : {},
|
|
901
|
+
_v: fromVersion
|
|
902
|
+
};
|
|
903
|
+
downcastViews.add(projected);
|
|
904
|
+
return { value: projected, persistable: false };
|
|
905
|
+
}
|
|
906
|
+
return { value: doc, persistable: true };
|
|
302
907
|
}
|
|
303
908
|
function validateRead(doc) {
|
|
304
909
|
if (!validateOnRead || !schema) return doc;
|
|
305
910
|
try {
|
|
306
|
-
|
|
911
|
+
const parsed = schema.parse(doc);
|
|
912
|
+
const fromVersion = typeof doc._v === "number" ? doc._v : 0;
|
|
913
|
+
return preserveFields(doc, parsed, targetVersion > 0 && fromVersion > targetVersion);
|
|
307
914
|
} catch (err) {
|
|
308
915
|
throw new TalaDbValidationError(err, "read");
|
|
309
916
|
}
|
|
310
917
|
}
|
|
311
|
-
async function persistAll(originals,
|
|
918
|
+
async function persistAll(originals, normalized) {
|
|
312
919
|
if (!persistMigrations) return;
|
|
313
920
|
for (let i = 0; i < originals.length; i++) {
|
|
314
921
|
const original = originals[i];
|
|
315
|
-
if (
|
|
316
|
-
const
|
|
922
|
+
if (!normalized[i].persistable) continue;
|
|
923
|
+
const migrated = normalized[i].value;
|
|
924
|
+
if (migrated === original || typeof original._id !== "string") continue;
|
|
925
|
+
const update = diffUpdate(original, migrated);
|
|
317
926
|
if (!update) continue;
|
|
318
927
|
try {
|
|
319
|
-
|
|
928
|
+
const guards = [{ _id: original._id }];
|
|
929
|
+
if (original._v === void 0) guards.push({ _v: { $exists: false } });
|
|
930
|
+
else guards.push({ _v: original._v });
|
|
931
|
+
if (original._changed_at !== void 0) {
|
|
932
|
+
guards.push({ _changed_at: original._changed_at });
|
|
933
|
+
}
|
|
934
|
+
await col.updateOne({ $and: guards }, update);
|
|
320
935
|
} catch {
|
|
321
936
|
}
|
|
322
937
|
}
|
|
323
938
|
}
|
|
324
|
-
const wrapReads = Boolean(migrateDocument) || validateOnRead && Boolean(schema);
|
|
939
|
+
const wrapReads = Boolean(migrateDocument) || Boolean(downgradeDocument) || validateOnRead && Boolean(schema);
|
|
325
940
|
const wrapWrites = Boolean(schema) || stampVersion;
|
|
941
|
+
function pipelinePreservesDocuments(pipeline) {
|
|
942
|
+
return pipeline.every((stage) => !("$group" in stage) && !("$project" in stage));
|
|
943
|
+
}
|
|
944
|
+
function normalizeViewRows(docs) {
|
|
945
|
+
return docs.map((doc) => validateRead(normalizeRead(doc).value));
|
|
946
|
+
}
|
|
326
947
|
return {
|
|
327
948
|
...col,
|
|
328
949
|
insert: wrapWrites ? async (doc) => {
|
|
950
|
+
assertWritableDocument(doc, "insert");
|
|
329
951
|
if (schema) parseWrite(doc, "insert");
|
|
330
952
|
return col.insert(stamp(doc));
|
|
331
953
|
} : col.insert.bind(col),
|
|
332
954
|
insertMany: wrapWrites ? async (docs) => {
|
|
955
|
+
docs.forEach((doc, i) => assertWritableDocument(doc, `insertMany[${i}]`));
|
|
333
956
|
if (schema) docs.forEach((doc, i) => parseWrite(doc, `insertMany[${i}]`));
|
|
334
957
|
return col.insertMany(docs.map(stamp));
|
|
335
958
|
} : col.insertMany.bind(col),
|
|
959
|
+
// Rows arriving from a remote origin are validated like any other write. This
|
|
960
|
+
// is the "parse, don't assert" boundary: the compile-time generic and the
|
|
961
|
+
// runtime schema check have to be the same seam, or a malformed server
|
|
962
|
+
// response walks straight into a typed collection.
|
|
963
|
+
replaceManyWithIds: wrapWrites ? async (docs, origin) => {
|
|
964
|
+
if (origin !== "remote") {
|
|
965
|
+
docs.forEach((doc, i) => assertWritableDocument(doc, `replaceManyWithIds[${i}]`));
|
|
966
|
+
if (targetVersion > 0) {
|
|
967
|
+
throw new Error(
|
|
968
|
+
"local replaceManyWithIds is disabled on versioned collections; use updateOne/updateMany so schema-version guards are atomic"
|
|
969
|
+
);
|
|
970
|
+
}
|
|
971
|
+
}
|
|
972
|
+
if (schema) docs.forEach((doc, i) => {
|
|
973
|
+
const { _replica_scope, _remote_rev, ...schemaDoc } = doc;
|
|
974
|
+
void _replica_scope;
|
|
975
|
+
void _remote_rev;
|
|
976
|
+
parseWrite(schemaDoc, `replaceManyWithIds[${i}]`);
|
|
977
|
+
});
|
|
978
|
+
return col.replaceManyWithIds(docs.map((d) => stampDoc(d)), origin);
|
|
979
|
+
} : col.replaceManyWithIds.bind(col),
|
|
980
|
+
deleteManyWithIds: stampVersion ? async (ids, origin) => {
|
|
981
|
+
if (origin !== "remote") {
|
|
982
|
+
throw new Error(
|
|
983
|
+
"local deleteManyWithIds is disabled on versioned collections; use deleteOne/deleteMany so schema-version guards are atomic"
|
|
984
|
+
);
|
|
985
|
+
}
|
|
986
|
+
return col.deleteManyWithIds(ids, origin);
|
|
987
|
+
} : col.deleteManyWithIds.bind(col),
|
|
988
|
+
updateOne: wrapWrites ? async (filter, update) => {
|
|
989
|
+
assertSafeUpdate(update);
|
|
990
|
+
return col.updateOne(writableFilter(filter), update);
|
|
991
|
+
} : col.updateOne.bind(col),
|
|
992
|
+
updateMany: wrapWrites ? async (filter, update) => {
|
|
993
|
+
assertSafeUpdate(update);
|
|
994
|
+
return col.updateMany(writableFilter(filter), update);
|
|
995
|
+
} : col.updateMany.bind(col),
|
|
996
|
+
deleteOne: stampVersion ? (filter) => col.deleteOne(writableFilter(filter)) : col.deleteOne.bind(col),
|
|
997
|
+
deleteMany: stampVersion ? (filter) => col.deleteMany(writableFilter(filter)) : col.deleteMany.bind(col),
|
|
336
998
|
find: wrapReads ? async (filter) => {
|
|
337
999
|
const docs = await col.find(filter);
|
|
338
|
-
const
|
|
339
|
-
await persistAll(docs,
|
|
340
|
-
return
|
|
1000
|
+
const normalized = docs.map(normalizeRead);
|
|
1001
|
+
await persistAll(docs, normalized);
|
|
1002
|
+
return normalized.map((n) => validateRead(n.value));
|
|
341
1003
|
} : col.find.bind(col),
|
|
342
1004
|
findOne: wrapReads ? async (filter) => {
|
|
343
1005
|
const doc = await col.findOne(filter);
|
|
344
1006
|
if (doc === null) return null;
|
|
345
|
-
const
|
|
346
|
-
await persistAll([doc], [
|
|
347
|
-
return validateRead(
|
|
1007
|
+
const normalized = normalizeRead(doc);
|
|
1008
|
+
await persistAll([doc], [normalized]);
|
|
1009
|
+
return validateRead(normalized.value);
|
|
348
1010
|
} : col.findOne.bind(col),
|
|
1011
|
+
aggregate: wrapReads ? async (pipeline) => {
|
|
1012
|
+
const docs = await col.aggregate(pipeline);
|
|
1013
|
+
return pipelinePreservesDocuments(pipeline) ? normalizeViewRows(docs) : docs;
|
|
1014
|
+
} : col.aggregate.bind(col),
|
|
349
1015
|
// Live queries feed every @taladb/react hook (useFind, useFindOne,
|
|
350
1016
|
// useQueries). Leaving them unwrapped meant React components received the
|
|
351
1017
|
// un-migrated shape while a direct find() returned the migrated one.
|
|
352
1018
|
subscribe: wrapReads ? (filter, callback, onError) => col.subscribe(
|
|
353
1019
|
filter,
|
|
354
1020
|
(docs) => {
|
|
355
|
-
const
|
|
1021
|
+
const normalized = docs.map(normalizeRead);
|
|
356
1022
|
let out;
|
|
357
1023
|
try {
|
|
358
|
-
out =
|
|
1024
|
+
out = normalized.map((n) => validateRead(n.value));
|
|
359
1025
|
} catch (err) {
|
|
360
1026
|
onError?.(err);
|
|
361
1027
|
return;
|
|
362
1028
|
}
|
|
363
1029
|
callback(out);
|
|
364
|
-
void persistAll(docs,
|
|
1030
|
+
void persistAll(docs, normalized);
|
|
365
1031
|
},
|
|
366
1032
|
onError
|
|
367
|
-
) : col.subscribe.bind(col)
|
|
1033
|
+
) : col.subscribe.bind(col),
|
|
1034
|
+
subscribeAggregate: wrapReads ? (pipeline, callback, onError) => col.subscribeAggregate(
|
|
1035
|
+
pipeline,
|
|
1036
|
+
(docs) => {
|
|
1037
|
+
if (!pipelinePreservesDocuments(pipeline)) {
|
|
1038
|
+
callback(docs);
|
|
1039
|
+
return;
|
|
1040
|
+
}
|
|
1041
|
+
try {
|
|
1042
|
+
callback(normalizeViewRows(docs));
|
|
1043
|
+
} catch (error) {
|
|
1044
|
+
onError?.(error);
|
|
1045
|
+
}
|
|
1046
|
+
},
|
|
1047
|
+
onError
|
|
1048
|
+
) : col.subscribeAggregate.bind(col)
|
|
368
1049
|
};
|
|
369
1050
|
}
|
|
370
1051
|
function detectPlatform() {
|
|
@@ -490,6 +1171,19 @@ async function createBrowserDB(dbName, config, passphrase, migrations) {
|
|
|
490
1171
|
});
|
|
491
1172
|
return JSON.parse(json);
|
|
492
1173
|
},
|
|
1174
|
+
replaceManyWithIds: async (docs, origin = "local") => {
|
|
1175
|
+
const json = await proxy.send("replaceManyWithIds", {
|
|
1176
|
+
collection: name,
|
|
1177
|
+
docsJson: s(docs),
|
|
1178
|
+
origin
|
|
1179
|
+
});
|
|
1180
|
+
return JSON.parse(json);
|
|
1181
|
+
},
|
|
1182
|
+
deleteManyWithIds: (ids, origin = "local") => proxy.send("deleteManyWithIds", {
|
|
1183
|
+
collection: name,
|
|
1184
|
+
idsJson: s(ids),
|
|
1185
|
+
origin
|
|
1186
|
+
}),
|
|
493
1187
|
find: async (filter) => {
|
|
494
1188
|
const json = await proxy.send("find", {
|
|
495
1189
|
collection: name,
|
|
@@ -561,59 +1255,96 @@ async function createBrowserDB(dbName, config, passphrase, migrations) {
|
|
|
561
1255
|
});
|
|
562
1256
|
return JSON.parse(json);
|
|
563
1257
|
},
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
poll();
|
|
605
|
-
return () => {
|
|
606
|
-
active = false;
|
|
607
|
-
nudgeCallbacks.delete(poll);
|
|
608
|
-
if (timer !== null) {
|
|
609
|
-
clearTimeout(timer);
|
|
610
|
-
timer = null;
|
|
611
|
-
}
|
|
612
|
-
};
|
|
613
|
-
}
|
|
1258
|
+
searchText: async (field, query, topK, filter, options) => {
|
|
1259
|
+
const json = await proxy.send("searchText", {
|
|
1260
|
+
collection: name,
|
|
1261
|
+
field,
|
|
1262
|
+
query,
|
|
1263
|
+
topK,
|
|
1264
|
+
filterJson: filter ? JSON.stringify(filter) : "null",
|
|
1265
|
+
optionsJson: options ? JSON.stringify(options) : "null"
|
|
1266
|
+
});
|
|
1267
|
+
return JSON.parse(json);
|
|
1268
|
+
},
|
|
1269
|
+
hybridSearch: async (text, vector, topK, filter, options) => {
|
|
1270
|
+
const json = await proxy.send("hybridSearch", {
|
|
1271
|
+
collection: name,
|
|
1272
|
+
textField: text.textField,
|
|
1273
|
+
text: text.text,
|
|
1274
|
+
vectorField: vector.vectorField,
|
|
1275
|
+
vectorJson: JSON.stringify(vector.vector),
|
|
1276
|
+
topK,
|
|
1277
|
+
filterJson: filter ? JSON.stringify(filter) : "null",
|
|
1278
|
+
optionsJson: options ? JSON.stringify(options) : "null"
|
|
1279
|
+
});
|
|
1280
|
+
return JSON.parse(json);
|
|
1281
|
+
},
|
|
1282
|
+
subscribe: (filter, callback, onError) => nudgedPoller(
|
|
1283
|
+
() => proxy.send("find", {
|
|
1284
|
+
collection: name,
|
|
1285
|
+
filterJson: filter ? s(filter) : "null"
|
|
1286
|
+
}),
|
|
1287
|
+
callback,
|
|
1288
|
+
onError
|
|
1289
|
+
),
|
|
1290
|
+
subscribeAggregate: (pipeline, callback, onError) => nudgedPoller(
|
|
1291
|
+
() => proxy.send("aggregate", {
|
|
1292
|
+
collection: name,
|
|
1293
|
+
pipelineJson: s(pipeline)
|
|
1294
|
+
}),
|
|
1295
|
+
callback,
|
|
1296
|
+
onError
|
|
1297
|
+
)
|
|
614
1298
|
};
|
|
615
1299
|
return opts ? applySchema(wrapped, opts) : wrapped;
|
|
616
1300
|
}
|
|
1301
|
+
function nudgedPoller(fetchJson, callback, onError) {
|
|
1302
|
+
let active = true;
|
|
1303
|
+
let lastJson = "";
|
|
1304
|
+
let timer = null;
|
|
1305
|
+
let running = false;
|
|
1306
|
+
let rerun = false;
|
|
1307
|
+
const poll = async () => {
|
|
1308
|
+
if (!active) return;
|
|
1309
|
+
if (running) {
|
|
1310
|
+
rerun = true;
|
|
1311
|
+
return;
|
|
1312
|
+
}
|
|
1313
|
+
running = true;
|
|
1314
|
+
if (timer !== null) {
|
|
1315
|
+
clearTimeout(timer);
|
|
1316
|
+
timer = null;
|
|
1317
|
+
}
|
|
1318
|
+
try {
|
|
1319
|
+
const json = await fetchJson();
|
|
1320
|
+
if (!active) return;
|
|
1321
|
+
if (json !== lastJson) {
|
|
1322
|
+
lastJson = json;
|
|
1323
|
+
callback(JSON.parse(json));
|
|
1324
|
+
}
|
|
1325
|
+
} catch (error) {
|
|
1326
|
+
if (active) onError?.(error);
|
|
1327
|
+
} finally {
|
|
1328
|
+
running = false;
|
|
1329
|
+
}
|
|
1330
|
+
if (active) {
|
|
1331
|
+
if (rerun) {
|
|
1332
|
+
rerun = false;
|
|
1333
|
+
void poll();
|
|
1334
|
+
} else timer = setTimeout(poll, 300);
|
|
1335
|
+
}
|
|
1336
|
+
};
|
|
1337
|
+
nudgeCallbacks.add(poll);
|
|
1338
|
+
poll();
|
|
1339
|
+
return () => {
|
|
1340
|
+
active = false;
|
|
1341
|
+
nudgeCallbacks.delete(poll);
|
|
1342
|
+
if (timer !== null) {
|
|
1343
|
+
clearTimeout(timer);
|
|
1344
|
+
timer = null;
|
|
1345
|
+
}
|
|
1346
|
+
};
|
|
1347
|
+
}
|
|
617
1348
|
const handle = {
|
|
618
1349
|
collection: (name, opts) => wrapCollection(name, opts),
|
|
619
1350
|
compact: () => proxy.send("compact"),
|
|
@@ -665,6 +1396,8 @@ async function createNodeDB(dbName, config, passphrase, migrations) {
|
|
|
665
1396
|
const wrapped = {
|
|
666
1397
|
insert: async (doc) => col.insertAsync ? col.insertAsync(doc) : col.insert(doc),
|
|
667
1398
|
insertMany: async (docs) => col.insertManyAsync ? col.insertManyAsync(docs) : col.insertMany(docs),
|
|
1399
|
+
replaceManyWithIds: async (docs, origin = "local") => col.replaceManyWithIdsAsync ? col.replaceManyWithIdsAsync(docs, origin) : col.replaceManyWithIds(docs, origin),
|
|
1400
|
+
deleteManyWithIds: async (ids, origin = "local") => col.deleteManyWithIdsAsync ? col.deleteManyWithIdsAsync(ids, origin) : col.deleteManyWithIds(ids, origin),
|
|
668
1401
|
find: async (filter) => col.findAsync ? col.findAsync(filter ?? null) : col.find(filter ?? null),
|
|
669
1402
|
findOne: async (filter) => col.findOne(filter) ?? null,
|
|
670
1403
|
updateOne: async (filter, update) => col.updateOneAsync ? col.updateOneAsync(filter, update) : col.updateOne(filter, update),
|
|
@@ -690,7 +1423,14 @@ async function createNodeDB(dbName, config, passphrase, migrations) {
|
|
|
690
1423
|
const raw = await col.findNearest(field, vector, topK, filter ?? null);
|
|
691
1424
|
return raw;
|
|
692
1425
|
},
|
|
693
|
-
|
|
1426
|
+
searchText: async (field, query, topK, filter, options) => {
|
|
1427
|
+
return col.searchText(field, query, topK, filter ?? null, options ?? null);
|
|
1428
|
+
},
|
|
1429
|
+
hybridSearch: async (text, vector, topK, filter, options) => {
|
|
1430
|
+
return col.hybridSearch(text.textField, text.text, vector.vectorField, vector.vector, topK, filter ?? null, options ?? null);
|
|
1431
|
+
},
|
|
1432
|
+
subscribe: (filter, callback, onError) => makePoller(async () => col.find(filter ?? null), callback, onError),
|
|
1433
|
+
subscribeAggregate: (pipeline, callback, onError) => makePoller(async () => wrapped.aggregate(pipeline), callback, onError)
|
|
694
1434
|
};
|
|
695
1435
|
return opts ? applySchema(wrapped, opts) : wrapped;
|
|
696
1436
|
}
|
|
@@ -738,6 +1478,8 @@ async function createNativeDB(_dbName, migrations) {
|
|
|
738
1478
|
const wrapped = {
|
|
739
1479
|
insert: async (doc) => native.insert(name, doc),
|
|
740
1480
|
insertMany: async (docs) => native.insertMany(name, docs),
|
|
1481
|
+
replaceManyWithIds: async (docs, origin = "local") => native.replaceManyWithIds(name, docs, origin),
|
|
1482
|
+
deleteManyWithIds: async (ids, origin = "local") => native.deleteManyWithIds(name, ids, origin),
|
|
741
1483
|
find: async (filter) => native.find(name, filter ?? {}),
|
|
742
1484
|
findOne: async (filter) => native.findOne(name, filter ?? {}),
|
|
743
1485
|
updateOne: async (filter, update) => native.updateOne(name, filter, update),
|
|
@@ -769,7 +1511,20 @@ async function createNativeDB(_dbName, migrations) {
|
|
|
769
1511
|
const raw = native.findNearest(name, field, vector, topK, filter ?? null);
|
|
770
1512
|
return raw;
|
|
771
1513
|
},
|
|
772
|
-
|
|
1514
|
+
searchText: async (field, query, topK, filter, options) => {
|
|
1515
|
+
if (!native.searchText) {
|
|
1516
|
+
throw new Error("searchText requires @taladb/react-native \u2265 0.10 \u2014 rebuild the native module");
|
|
1517
|
+
}
|
|
1518
|
+
return native.searchText(name, field, query, topK, filter ?? null, options ?? null);
|
|
1519
|
+
},
|
|
1520
|
+
hybridSearch: async (text, vector, topK, filter, options) => {
|
|
1521
|
+
if (!native.hybridSearch) {
|
|
1522
|
+
throw new Error("hybridSearch requires @taladb/react-native \u2265 0.10 \u2014 rebuild the native module");
|
|
1523
|
+
}
|
|
1524
|
+
return native.hybridSearch(name, text.textField, text.text, vector.vectorField, vector.vector, topK, filter ?? null, options ?? null);
|
|
1525
|
+
},
|
|
1526
|
+
subscribe: (filter, callback, onError) => makePoller(async () => native.find(name, filter ?? {}), callback, onError),
|
|
1527
|
+
subscribeAggregate: (pipeline, callback, onError) => makePoller(async () => native.aggregate(name, pipeline), callback, onError)
|
|
773
1528
|
};
|
|
774
1529
|
return opts ? applySchema(wrapped, opts) : wrapped;
|
|
775
1530
|
}
|
|
@@ -866,9 +1621,20 @@ async function openDB(dbName = "taladb.db", options) {
|
|
|
866
1621
|
}
|
|
867
1622
|
// Annotate the CommonJS export names for ESM import in node:
|
|
868
1623
|
0 && (module.exports = {
|
|
1624
|
+
COVERAGE_COLLECTION,
|
|
1625
|
+
CoverageStore,
|
|
869
1626
|
HttpSyncAdapter,
|
|
1627
|
+
REPLICA_REVISION_FIELD,
|
|
1628
|
+
REPLICA_SCOPE_FIELD,
|
|
1629
|
+
ReplicationCoordinator,
|
|
870
1630
|
TalaDbValidationError,
|
|
871
1631
|
applySchema,
|
|
1632
|
+
coverageKey,
|
|
1633
|
+
createRestSource,
|
|
1634
|
+
deriveDocId,
|
|
1635
|
+
isAuthoritative,
|
|
872
1636
|
openDB,
|
|
1637
|
+
progress,
|
|
1638
|
+
rowsApplied,
|
|
873
1639
|
runMigrations
|
|
874
1640
|
});
|